Skip to content

Instantly share code, notes, and snippets.

@basveeling
Last active January 4, 2022 17:19
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save basveeling/96ff0f16cd7185b0277a26c8d9305633 to your computer and use it in GitHub Desktop.
Save basveeling/96ff0f16cd7185b0277a26c8d9305633 to your computer and use it in GitHub Desktop.
Zigbee2mqtt hassio custom device for moes radiator
// Based on https://github.com/Koenkk/zigbee-herdsman-converters/pull/2209
// Place moes_radiator_alt.js in the root of your zigbee2mqtt data folder (as stated in data_path, e.g. /config/zigbee2mqtt_data)
// In your zigbee2mqtt hassio addon configuration, add the following two lines:
// ...
// external_converters:
// - moes_radiator_alt.js
// ...
const fz = require('zigbee-herdsman-converters/converters/fromZigbee');
const tz = require('zigbee-herdsman-converters/converters/toZigbee');
const tuya = require('zigbee-herdsman-converters/lib/tuya');
const exposes = require('zigbee-herdsman-converters/lib/exposes');
const reporting = require('zigbee-herdsman-converters/lib/reporting');
const e = exposes.presets;
const tuyaLocal = {
dataPoints: {
// Moes_Alt TZE200_fhn3negr specific, see issue #1803
moesAltMode: 2,
moesAltChildLock: 30,
moesAltBattery: 34,
moesAltComfortTemp: 101,
moesAltEcoTemp: 102,
moesAltVacationPeriod: 103,
moesAltTempCalibration: 104,
moesAltScheduleTempOverride: 105,
moesAltUnknown2: 106,
moesAltUnknown3: 107,
moesAltScheduleDay1: 109,
moesAltScheduleDay2: 110,
moesAltScheduleDay3: 111,
moesAltScheduleDay4: 112,
moesAltScheduleDay5: 113,
moesAltScheduleDay6: 114,
moesAltScheduleDay7: 115,
moesAltOpenWindowTemp: 116,
moesAltOpenWindowTime: 117,
moesAltRapidHeatCntdownTimer: 118,
moesAltRequestUpdate: 120,
},
};
const fzLocal = {
moes_alt_thermostat: {
cluster: 'manuSpecificTuya',
type: ['commandGetData', 'commandSetDataResponse'],
convert: (model, msg, publish, options, meta) => {
const dp = msg.data.dp;
const value = tuya.getDataValue(msg.data.datatype, msg.data.data);
function weeklySchedule(day, value) {
// byte 0 - Day of Week (0~7 = Mon ~ Sun) <- redundant?
// byte 1 - 1st period Temperature (1~59 = 0.5~29.5°C (0.5 step))
// byte 2 - 1st period end time (1~96 = 0:15 ~ 24:00 (15 min increment, i.e. 2 = 0:30, 3 = 0:45, ...))
// byte 3 - 2nd period Temperature
// byte 4 - 2nd period end time
// ...
// byte 16 - 8th period end time
// byte 17 - 9th period Temperature
const weekDays=['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
// we get supplied in value only a weekday schedule, so we must add it to
// the weekly schedule from meta.state, if it exists
const weeklySchedule= meta.state.hasOwnProperty('weekly_schedule') ? meta.state.weekly_schedule : {};
meta.logger.info(JSON.stringify({'received day': day, 'received values': value}));
let daySchedule = []; // result array
for (let i=1; i<18 && value[i]; ++i) {
const aTemp=value[i];
++i;
const time=value[i];
daySchedule=[...daySchedule, {
temperature: Math.floor(aTemp/2),
hour: Math.floor(time/4),
minute: time % 4 *15,
}];
}
meta.logger.info(JSON.stringify({'returned weekly schedule: ': daySchedule}));
return {'weekly-schedule': {...weeklySchedule, [weekDays[day]]: daySchedule}};
}
switch (dp) {
case tuyaLocal.dataPoints.moesAltMode: // 2
// 0-Schedule; 1-Manual; 2-Away
if (value == 0) {
return {system_mode: 'auto', away_mode: 'OFF'};
} else if (value == 1) {
return {system_mode: 'heat', away_mode: 'OFF'};
} else if (value == 2) {
return {system_mode: 'auto', away_mode: 'ON'};
}
break;
case tuya.dataPoints.moesHeatingSetpoint: // 16
// 0 - Valve full OFF, 60 - Valve full ON : only in "manual" mode
return {current_heating_setpoint: (value / 2).toFixed(1)};
case tuya.dataPoints.moesLocalTemp: // 24
return {local_temperature: (value / 10).toFixed(1)};
case tuyaLocal.dataPoints.moesAltChildLock: // 30
return {child_lock: value ? 'LOCKED' : 'UNLOCKED'};
case tuyaLocal.dataPoints.moesAltBattery: // 34
return {
battery: value > 130 ? 100 : value < 70 ? 0 : ((value - 70)*1.7).toFixed(1),
battery_low: value < 90,
};
case tuyaLocal.dataPoints.moesAltComfortTemp: // 101
return {comfort_temp_preset: (value / 2).toFixed(1)};
case tuyaLocal.dataPoints.moesAltEcoTemp: // 102
return {eco_temp_preset: (value / 2).toFixed(1)};
case tuyaLocal.dataPoints.moesAltVacationPeriod: // 103
return {
away_data: {
year: value[0]+2000,
month: value[1],
day: value[2],
hour: value[3],
minute: value[4],
temperature: (value[5] /2).toFixed(1),
away_hours: value[6]<< 8 | value[7],
},
};
// byte 0 - Start Year (0x00 = 2000)
// byte 1 - Start Month
// byte 2 - Start Day
// byte 3 - Start Hour
// byte 4 - Start Minute
// byte 5 - Temperature (1~59 = 0.5~29.5°C (0.5 step))
// byte 6-7 - Duration in Hours (0~2400 (100 days))
case tuyaLocal.dataPoints.moesAltTempCalibration: // 104
return {local_temperature_calibration: value > 55 ?
((value - 0x100000000)/10).toFixed(1): (value/ 10).toFixed(1)};
case tuyaLocal.dataPoints.moesAltScheduleTempOverride: // 105
return {current_heating_setpoint: (value / 2).toFixed(1)};
case tuyaLocal.dataPoints.moesAltUnknown2: // 106
break;
case tuyaLocal.dataPoints.moesAltUnknown3: // 107
break;
case tuyaLocal.dataPoints.moesAltScheduleDay1: // 109
return weeklySchedule(0, value);
case tuyaLocal.dataPoints.moesAltScheduleDay2: // 110
return weeklySchedule(1, value);
case tuyaLocal.dataPoints.moesAltScheduleDay3: // 111
return weeklySchedule(2, value);
case tuyaLocal.dataPoints.moesAltScheduleDay4: // 112
return weeklySchedule(3, value);
case tuyaLocal.dataPoints.moesAltScheduleDay5: // 113
return weeklySchedule(4, value);
case tuyaLocal.dataPoints.moesAltScheduleDay6: // 114
return weeklySchedule(5, value);
case tuyaLocal.dataPoints.moesAltScheduleDay7: // 115
return weeklySchedule(6, value);
case tuyaLocal.dataPoints.moesAltOpenWindowTemp: // 116
break;
case tuyaLocal.dataPoints.moesAltOpenWindowTime: // 117
break;
case tuyaLocal.dataPoints.moesAltRequestUpdate: // 120
break;
default:
break;
}
},
},
};
const tzLocal = {
moes_alt_thermostat_current_heating_setpoint: {
key: ['current_heating_setpoint'],
convertSet: async (entity, key, value, meta) => {
const temp = Math.round(value * 2);
if (meta.state.system_mode == 'heat') {
await tuya.sendDataPointValue(entity, tuya.dataPoints.moesHeatingSetpoint, temp);
} else if (meta.state.system_mode == 'auto') {
await tuya.sendDataPointValue(entity, tuyaLocal.dataPoints.moesAltScheduleTempOverride, temp);
}
},
},
moes_alt_thermostat_comfort_temp_preset: {
key: ['comfort_temp_preset'],
convertSet: async (entity, key, value, meta) => {
const temp = Math.round(value * 2);
await tuya.sendDataPointValue(entity, tuyaLocal.dataPoints.moesAltComfortTemp, temp);
},
},
moes_alt_thermostat_eco_temp_preset: {
key: ['eco_temp_preset'],
convertSet: async (entity, key, value, meta) => {
const temp = Math.round(value * 2);
await tuya.sendDataPointValue(entity, tuyaLocal.dataPoints.moesAltEcoTemp, temp);
},
},
moes_alt_thermostat_schedule_override_setpoint: {
key: ['schedule_override_setpoint'],
convertSet: async (entity, key, value, meta) => {
const temp = Math.round(value * 2);
await tuya.sendDataPointValue(entity, tuyaLocal.dataPoints.moesAltScheduleTempOverride, temp);
},
},
moes_alt_thermostat_mode: {
key: ['system_mode'],
convertSet: async (entity, key, value, meta) => {
if ( value == 'auto' ) {
await tuya.sendDataPointEnum(entity, tuyaLocal.dataPoints.moesAltMode, 0);
} else if ( value == 'heat' ) {
await tuya.sendDataPointEnum(entity, tuyaLocal.dataPoints.moesAltMode, 1);
} else if ( value == 'off') {
await tuya.sendDataPointEnum(entity, tuyaLocal.dataPoints.moesAltMode, 2);
//await tuya.sendDataPointValue(entity, tuya.dataPoints.moesHeatingSetpoint, 0);
}
},
},
moes_alt_thermostat_away: {
key: ['away_mode', 'away_data'],
convertSet: async (entity, key, value, meta) => {
if (key==='away_mode') {
if ( value == 'ON' ) {
await tuya.sendDataPointEnum(entity, tuyaLocal.dataPoints.moesAltMode, 2);
} else {
await tuya.sendDataPointEnum(entity, tuyaLocal.dataPoints.moesAltMode, 0);
}
} else if (key==='away_data') {
const output= new Buffer(8);
// byte 0 - Start Year (0x00 = 2000)
// byte 1 - Start Month
// byte 2 - Start Day
// byte 3 - Start Hour
// byte 4 - Start Minute
// byte 5 - Temperature (1~59 = 0.5~29.5°C (0.5 step))
// byte 6-7 - Duration in Hours (0~2400 (100 days))
output[0]=value.year > 2000 ? value.year-2000 : value.year; // year
output[1]=value.month; // month
output[2]=value.day; // day
output[3]=value.hour; // hour
output[4]=value.minute; // min
output[5]=Math.round(value.temperature * 2);
output[7]=value.away_hours & 0xFF;
output[6]=value.away_hours >> 8;
meta.logger.info(JSON.stringify({'send to tuya': output, 'value was': value, 'key was': key}));
await tuya.sendDataPointRaw(entity, tuyaLocal.dataPoints.moesAltVacationPeriod, output);
}
},
},
moes_alt_thermostat_schedule: {
key: ['weekly_schedule'],
convertSet: async (entity, key, value, meta) => {
const weekDays=['mon' , 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
// byte 0 - Day of Week (0~7 = Mon ~ Sun) <- redundant?
// byte 1 - 1st period Temperature (1~59 = 0.5~29.5°C (0.5 step))
// byte 2 - 1st period end time (1~96 = 0:15 ~ 24:00 (15 min increment, i.e. 2 = 0:30, 3 = 0:45, ...))
// byte 3 - 2nd period Temperature
// byte 4 - 2nd period end time
// ...
// byte 16 - 8th period end time
// byte 17 - 9th period Temperature
// we overwirte only the received days. The other ones keep stored on the device
const keys = Object.keys(value);
for (const dayName of keys) { // for loop in order to delete the empty day schedules
const output= new Buffer(17); // empty output byte buffer
const dayNo=weekDays.indexOf(dayName);
output[0]=dayNo+1;
const schedule=value[dayName];
schedule.forEach((el, Index) => {
if (Index <=8) {
output[1+2*Index]=Math.round(el.temperature*2);
output[2+2*Index]=el.hour*4+Math.floor((el.minute/15));
} else {
meta.logger.warn('more than 8 schedule points supplied for week-day '+dayName +
' additional schedule points will be ignored');
}
});
await tuya.sendDataPointRaw(entity, tuyaLocal.dataPoints.moesAltScheduleDay1+dayNo, output);
await new Promise((r) => setTimeout(r, 2000));
// wait 2 seconds between schedule sends in order not to overload the device
}
},
},
moes_alt_thermostat_child_lock: {
key: ['child_lock'],
convertSet: async (entity, key, value, meta) => {
await tuya.sendDataPointBool(entity, tuyaLocal.dataPoints.moesAltChildLock,
['LOCKED', 'ON', 'LOCK'].includes(value.toUpperCase()));
},
},
moes_alt_thermostat_calibration: {
key: ['local_temperature_calibration'],
convertSet: async (entity, key, value, meta) => {
if (value > 0) value = value*10;
if (value < 0) value = value*10 + 0x100000000;
await tuya.sendDataPointValue(entity, tuyaLocal.dataPoints.moesAltTempCalibration, value);
},
},
};
const device = {
// Moes Tuya Alt Thermostat
fingerprint: [
{modelID: 'TS0601', manufacturerName: '_TZE200_fhn3negr'},
{ modelID: 'TS0601', manufacturerName: '_TZE200_i48qyn9s' },
{ modelID: 'TS0601', manufacturerName: '_TZE200_zion52ef' },
{ modelID: 'TS0601', manufacturerName: '_TZE200_qc4fpmcn' },
],
model: 'TZE200_fhn3neg',
vendor: 'TuYa',
description: 'Radiator valve with thermostat',
fromZigbee: [
fz.ignore_basic_report,
fz.ignore_tuya_set_time, // handled in onEvent
fzLocal.moes_alt_thermostat,
// fz.tuya_data_point_dump,
],
toZigbee: [
tzLocal.moes_alt_thermostat_current_heating_setpoint,
tzLocal.moes_alt_thermostat_comfort_temp_preset,
tzLocal.moes_alt_thermostat_eco_temp_preset,
tzLocal.moes_alt_thermostat_away,
tzLocal.moes_alt_thermostat_mode,
tzLocal.moes_alt_thermostat_child_lock,
tzLocal.moes_alt_thermostat_calibration,
tzLocal.moes_alt_thermostat_schedule_override_setpoint,
tzLocal.moes_alt_thermostat_schedule,
tz.tuya_data_point_test,
],
onEvent: tuya.onEventSetLocalTime,
meta: {
configureKey: 1,
},
configure: async (device, coordinatorEndpoint, logger) => {
const endpoint = device.getEndpoint(1);
await reporting.bind(endpoint, coordinatorEndpoint, ['genBasic']);
},
exposes: [
e.battery(), e.battery_low(), e.window_detection(), e.child_lock(),
exposes.climate().withSetpoint('current_heating_setpoint', 0.5, 29.5, 0.5)
.withLocalTemperature()
.withSystemMode(['auto','heat','off'])
.withPreset(['rapid','comfort','eco','open'])
.withAwayMode()
],
};
module.exports = device;
@serrj-sv
Copy link

serrj-sv commented Jan 4, 2022

@jollytoad please note latest zigbee2mqtt update break functionality of this external component (see release notes to z2m version 1.22.2.
You can grab my version which also contains some minor polishing and fixes:
https://gist.github.com/serrj-sv/af142b25de2d7ac54c3a2eb2623d9a6d

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment