Device support: Sunricher ZG9095B-0-10V thermostat #8554
Description
I wrote an external definition for this thermostat https://www.sunricher.com/zigbee-thermostat-heating-cooling-controller-sr-zg9095b-0-10v.html, the device is pretty similar to the already supported SR-ZG9092A but can control both heating and cooling, and as i found out has a quite different zigbee spec, mostly different enums.
In any case, i was told on the discord server that given the fact there's multiple custom converters, it would be better to just put the external converter here and have someone more experienced with the codebase actually integrate it in herdsman.
The entire zigbee spec of the device is available in the manual https://www.sunricher.com/media/resources/manual/SR-ZG9095B-0-10V%20instruction.pdf.
I was able to make almost all of the features work fine in z2m, there were a couple (anti freezing config, ThermostatProgrammingOperationMode) that i wasn't able to integrate, but they're really specific and i don't think they're essential to be controlled via z2m.
following is the external converter i used (tested on the home assistant z2m addon, v.1.42.0-2)
const {} = require('zigbee-herdsman-converters/lib/modernExtend');
const fz = require('zigbee-herdsman-converters/converters/fromZigbee');
const tz = require('zigbee-herdsman-converters/converters/toZigbee');
const exposes = require('zigbee-herdsman-converters/lib/exposes');
const reporting = require('zigbee-herdsman-converters/lib/reporting');
const ota = require('zigbee-herdsman-converters/lib/ota');
const utils = require('zigbee-herdsman-converters/lib/utils');
const globalStore = require('zigbee-herdsman-converters/lib/store');
const logger = require('zigbee-herdsman-converters/lib/logger')
const constants = require('zigbee-herdsman-converters/lib/constants')
const zh = require('zigbee-herdsman')
const e = exposes.presets;
const ea = exposes.access;
async function syncTime(endpoint) {
try {
const time = Math.round((new Date().getTime() - constants.OneJanuary2000) / 1000 + new Date().getTimezoneOffset() * -1 * 60);
const values = {time: time};
await endpoint.write('genTime', values);
} catch (e){
/* Do nothing*/
logger.logger.warning(e)
}
}
const fzLocal = {
thermostat: {
cluster: 'hvacThermostat',
type: ['attributeReport', 'readResponse'],
convert: (model, msg, publish, options, meta) => {
const result = {};
if (msg.data.minSetpointDeadBand !== undefined) {
result[
utils.postfixWithEndpointName('min_setpoint_deadband', msg, model, meta)
] = utils.precisionRound(msg.data['minSetpointDeadBand'], 2) / 10;
}
return result;
},
},
};
const tzLocal = {
min_setpoint_deadband: {
key: ['min_setpoint_deadband'],
convertGet: async (entity, key, meta) => {
await entity.read('hvacThermostat', ['minSetpointDeadBand']);
},
convertSet: async (entity, key, value, meta) => {
let newValue = value;
await entity.write('hvacThermostat', {
minSetpointDeadBand: Math.round(Number(value) * 10),
});
return { state: { min_setpoint_deadband: value } };
},
},
temperature_display: {
key:['temperature_display'],
convertSet: async (entity, key, value, meta) => {
let newValue = value;
const lookup = {room: 0, set: 1, floor: 2};
const payload = {0x1008: {value: utils.getFromLookup(value, lookup), type: zh.Zcl.DataType.ENUM8}};
await entity.write('hvacThermostat', payload, {manufacturerCode: 0x1224});
return { state: { temperature_display: value } };
},
convertGet: async (entity, key, meta) => {
await entity.read('hvacThermostat', [0x1008], {manufacturerCode: 0x1224});
},
},
sensor:{
key:['sensor'],
convertSet: async (entity, key, value, meta) => {
const lookup = {room: 1, floor: 2};
const payload = {0x1003: {value: utils.getFromLookup(value, lookup), type: zh.Zcl.DataType.ENUM8}};
await entity.write('hvacThermostat', payload, {manufacturerCode: 0x1224});
return { state: { sensor: value } };
},
convertGet: async (entity, key, meta) => {
await entity.read('hvacThermostat', [0x1003], {manufacturerCode: 0x1224});
},
},
lcd_brightness:{
key:['lcd_brightness'],
convertSet: async (entity, key, value, meta) => {
const lookup = {low: 1, mid: 2, high: 3};
const payload = {0x1000: {value: utils.getFromLookup(value, lookup), type: zh.Zcl.DataType.ENUM8}};
await entity.write('hvacThermostat', payload, {manufacturerCode: 0x1224});
return { state: { lcd_brightness: value } };
},
convertGet: async (entity, key, meta) => {
await entity.read('hvacThermostat', [0x1000], {manufacturerCode: 0x1224});
},
}
};
const definition = {
zigbeeModel: ['ZG9095B'],
model: 'SR-ZG9095B',
vendor: 'Sunricher',
description: 'Touch thermostat',
fromZigbee: [fz.thermostat, fz.namron_thermostat, fz.metering, fz.electrical_measurement, fz.namron_hvac_user_interface, fzLocal.thermostat],
toZigbee: [
tzLocal.temperature_display,
tzLocal.sensor,
tzLocal.lcd_brightness,
tz.thermostat_occupied_heating_setpoint,
tz.thermostat_unoccupied_heating_setpoint,
tz.thermostat_occupied_cooling_setpoint,
tz.thermostat_unoccupied_cooling_setpoint,
tz.thermostat_local_temperature_calibration,
tz.thermostat_local_temperature,
tz.thermostat_outdoor_temperature,
tz.thermostat_system_mode,
tz.thermostat_control_sequence_of_operation,
tz.thermostat_running_state,
tz.namron_thermostat,
tz.namron_thermostat_child_lock,
tz.fan_mode,
tzLocal.min_setpoint_deadband
],
exposes: [
e.numeric('outdoor_temperature', ea.STATE_GET).withUnit('°C').withDescription('Current temperature measured from the floor sensor'),
e
.climate()
.withSetpoint('occupied_heating_setpoint', 5, 32, 0.1)
.withSetpoint('unoccupied_heating_setpoint', 5, 32, 0.1)
.withSetpoint('occupied_cooling_setpoint', 5, 32, 0.1)
.withSetpoint('unoccupied_cooling_setpoint', 5, 32, 0.1)
.withLocalTemperature()
.withLocalTemperatureCalibration(-2.5, 2.5, 0.1)
.withSystemMode(['off', 'auto', 'cool', 'heat', 'fan_only'])
.withRunningState(['idle', 'heat', 'cool', 'fan_only'])
.withFanMode(['off', 'low', 'medium', 'high', 'auto'])
.withControlSequenceOfOperation(['cooling_only', 'heating_only', 'cooling_and_heating_4-pipes']),
e.binary('away_mode', ea.ALL, 'ON', 'OFF').withDescription('Enable/disable away mode'),
e.binary('child_lock', ea.ALL, 'UNLOCK', 'LOCK').withDescription('Enables/disables physical input on the device'),
e.enum('lcd_brightness', ea.ALL, ['low', 'mid', 'high']).withDescription('OLED brightness when operating the buttons. Default: Medium.'),
e.enum('button_vibration_level', ea.ALL, ['off', 'low', 'high']).withDescription('Key beep volume and vibration level. Default: Low.'),
e
.enum('floor_sensor_type', ea.ALL, ['10k', '15k', '50k', '100k', '12k'])
.withDescription('Type of the external floor sensor. Default: NTC 10K/25.'),
e.enum('sensor', ea.ALL, ['room', 'floor']).withDescription('The sensor used for heat control. Default: Room Sensor.'),
e.enum('powerup_status', ea.ALL, ['default', 'last_status']).withDescription('The mode after a power reset. Default: Previous Mode.'),
e
.numeric('floor_sensor_calibration', ea.ALL)
.withUnit('°C')
.withValueMin(-2.5)
.withValueMax(2.5)
.withValueStep(0.1)
.withDescription('The tempearatue calibration for the external floor sensor, between -3 and 3 in 0.1°C. Default: 0.'),
e.enum('temperature_display', ea.ALL, ['room', 'set', 'floor']).withDescription('The temperature on the display. Default: Room Temperature.'),
e
.numeric('min_setpoint_deadband', ea.ALL)
.withUnit('°C')
.withValueMin(1)
.withValueMax(1.5)
.withValueStep(0.1)
.withDescription('This parameter refers to the minimum difference between cooling and heating temperatures. between 1 and 1.5 in 0.1 °C Default: 1 °C. The hysteresis used by this device = MinSetpointDeadBand /2'),
],
onEvent: async (type, data, device, options) => {
if (type === 'stop') {
clearInterval(globalStore.getValue(device, 'time'));
globalStore.clearValue(device, 'time');
} else if (!globalStore.hasValue(device, 'time')) {
const endpoint = device.getEndpoint(1);
const hours24 = 1000 * 60 * 60 * 24;
// Device does not ask for the time with binding, therefore we write the time every 24 hours
const interval = setInterval(async () => await syncTime(endpoint), hours24);
globalStore.putValue(device, 'time', interval);
}
},
configure: async (device, coordinatorEndpoint) => {
const endpoint = device.getEndpoint(1);
const binds = [
'genBasic',
'genIdentify',
'hvacThermostat',
'seMetering',
'genTime',
'hvacUserInterfaceCfg',
];
await reporting.bind(endpoint, coordinatorEndpoint, binds);
// standard ZCL attributes
await reporting.thermostatTemperature(endpoint);
await reporting.thermostatOccupiedHeatingSetpoint(endpoint);
await reporting.thermostatUnoccupiedHeatingSetpoint(endpoint);
try {
await reporting.thermostatKeypadLockMode(endpoint);
} catch {
// Fails for some
// https://github.com/Koenkk/zigbee2mqtt/issues/15025
logger.debug(`Failed to setup keypadLockout reporting`, NS);
}
// Custom attributes
const options = {manufacturerCode: 0x1224};
// OperateDisplayLcdBrightnesss
await endpoint.configureReporting(
'hvacThermostat',
[
{
attribute: {ID: 0x1000, type: 0x30},
minimumReportInterval: 0,
maximumReportInterval: constants.repInterval.HOUR,
reportableChange: null,
},
],
options,
);
// ButtonVibrationLevel
await endpoint.configureReporting(
'hvacThermostat',
[
{
attribute: {ID: 0x1001, type: 0x30},
minimumReportInterval: 0,
maximumReportInterval: constants.repInterval.HOUR,
reportableChange: null,
},
],
options,
);
// FloorSensorType
await endpoint.configureReporting(
'hvacThermostat',
[
{
attribute: {ID: 0x1002, type: 0x30},
minimumReportInterval: 0,
maximumReportInterval: constants.repInterval.HOUR,
reportableChange: null,
},
],
options,
);
// ControlType
await endpoint.configureReporting(
'hvacThermostat',
[
{
attribute: {ID: 0x1003, type: 0x30},
minimumReportInterval: 0,
maximumReportInterval: constants.repInterval.HOUR,
reportableChange: null,
},
],
options,
);
// PowerUpStatus
await endpoint.configureReporting(
'hvacThermostat',
[
{
attribute: {ID: 0x1004, type: 0x30},
minimumReportInterval: 0,
maximumReportInterval: constants.repInterval.HOUR,
reportableChange: null,
},
],
options,
);
// FloorSensorCalibration
await endpoint.configureReporting(
'hvacThermostat',
[
{
attribute: {ID: 0x1005, type: 0x28},
minimumReportInterval: 0,
maximumReportInterval: constants.repInterval.HOUR,
reportableChange: 0,
},
],
options,
);
// TemperatureDisplay
await endpoint.configureReporting(
'hvacThermostat',
[
{
attribute: {ID: 0x1008, type: 0x30},
minimumReportInterval: 0,
maximumReportInterval: constants.repInterval.HOUR,
reportableChange: null,
},
],
options,
);
// Away Mode Set
await endpoint.configureReporting(
'hvacThermostat',
[
{
attribute: {ID: 0x2002, type: 0x30},
minimumReportInterval: 0,
maximumReportInterval: constants.repInterval.HOUR,
reportableChange: null,
},
],
);
// Control Sequence Of Operation
await endpoint.configureReporting(
'hvacThermostat',
[
{
attribute: {ID: 0x001b, type: 0x30},
minimumReportInterval: 0,
maximumReportInterval: constants.repInterval.HOUR,
reportableChange: null,
}
]
)
// Device does not asks for the time with binding, we need to write time during configure
await syncTime(endpoint);
// Trigger initial read
await endpoint.read('hvacThermostat', ['systemMode', 'runningState', 'occupiedHeatingSetpoint']);
await endpoint.read('hvacThermostat', [0x2002, 0x001b]);
await endpoint.read('hvacThermostat', [0x1000, 0x1001, 0x1002, 0x1003], options);
await endpoint.read('hvacThermostat', [0x1004, 0x1005], options);
await endpoint.read('hvacThermostat', [0x1008], options);
},
};
module.exports = definition;