[External Converter]: OTH3600-GA-ZB from Ouellet
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 15.7k
- Forks
- 2k
- Avg merge
- 18h 55m
- Merged PRs (30d)
- 35
Description
Link
https://www.ouellet.com/en-ca/products/thermostats-and-controls/oth3600-ga-zb.aspx
Database entry
{"id":15,"type":"Router","ieeeAddr":"0x500b9140000204af","nwkAddr":62611,"manufId":4508,"manufName":"Sinope Technologies\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000","powerSource":"Mains (single phase)","modelId":"OTH3600-GA-ZB\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000","epList":[1],"endpoints":{"1":{"profId":260,"epId":1,"devId":769,"inClusterList":[0,3,4,5,513,516,1026,1794,2820,2821,65281],"outClusterList":[10,65281,25],"clusters":{"65281":{"attributes":{"268":0,"277":0}},"genBasic":{"attributes":{"hwVersion":0}},"hvacThermostat":{"attributes":{"localTemp":2430,"occupiedHeatingSetpoint":700,"pIHeatingDemand":0,"ctrlSeqeOfOper":2,"systemMode":4}},"seMetering":{"attributes":{"currentSummDelivered":1596176}},"haElectricalMeasurement":{"attributes":{"1361":1595969,"rmsVoltage":240,"rmsCurrent":190,"apparentPower":456,"acCurrentMultiplier":1,"acCurrentDivisor":100}},"manuSpecificSinope":{"attributes":{"268":0,"277":0,"floorTemperature":2211,"roomTemperature":2289,"outdoorTempToDisplay":1000}},"msTemperatureMeasurement":{"attributes":{"tolerance":50,"measuredValue":2428}}},"binds":[{"cluster":0,"type":"endpoint","deviceIeeeAddress":"0xe406bffffe21ad0c","endpointID":1},{"cluster":3,"type":"endpoint","deviceIeeeAddress":"0xe406bffffe21ad0c","endpointID":1},{"cluster":513,"type":"endpoint","deviceIeeeAddress":"0xe406bffffe21ad0c","endpointID":1},{"cluster":516,"type":"endpoint","deviceIeeeAddress":"0xe406bffffe21ad0c","endpointID":1},{"cluster":2820,"type":"endpoint","deviceIeeeAddress":"0xe406bffffe21ad0c","endpointID":1},{"cluster":1794,"type":"endpoint","deviceIeeeAddress":"0xe406bffffe21ad0c","endpointID":1},{"cluster":4,"type":"endpoint","deviceIeeeAddress":"0xe406bffffe21ad0c","endpointID":1},{"cluster":1026,"type":"endpoint","deviceIeeeAddress":"0xe406bffffe21ad0c","endpointID":1},{"cluster":65281,"type":"endpoint","deviceIeeeAddress":"0xe406bffffe21ad0c","endpointID":1}],"configuredReportings":[{"cluster":513,"attrId":8,"minRepIntval":0,"maxRepIntval":3600,"repChange":10},{"cluster":65281,"attrId":277,"minRepIntval":1,"maxRepIntval":3600,"repChange":1,"manufacturerCode":4508},{"cluster":65281,"attrId":268,"minRepIntval":1,"maxRepIntval":3600,"repChange":1,"manufacturerCode":4508},{"cluster":513,"attrId":0,"minRepIntval":0,"maxRepIntval":3600,"repChange":10},{"cluster":513,"attrId":18,"minRepIntval":0,"maxRepIntval":3600,"repChange":10},{"cluster":1026,"attrId":0,"minRepIntval":10,"maxRepIntval":3600,"repChange":100}],"meta":{}}},"appVersion":2,"stackVersion":2,"hwVersion":0,"dateCode":"20191021\u0000\u0000\u0000\u0000\u0000\u0000\u0000","swBuildId":"746\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000","zclVersion":2,"interviewCompleted":true,"interviewState":"SUCCESSFUL","meta":{"ctrlSeqeOfOper":{"1":2},"configured":"0.0.0"},"lastSeen":1789591403984}
Zigbee2MQTT version
2.14.1 (unknown)
External converter
const exposes = require('zigbee-herdsman-converters/lib/exposes');
const {Zcl} = require('zigbee-herdsman');
const e = exposes.presets;
const ea = exposes.access;
const manuSinope = {
manufacturerCode: 0x119c,
};
/*
* ==========================================================================
* HELPERS
* ==========================================================================
*/
function getCached(endpoint, cluster, attribute) {
try {
return endpoint.getClusterAttributeValue(cluster, attribute);
} catch {
return undefined;
}
}
/*
* Electrical measurements for the Ouellet OTH3600-GA-ZB.
*
* Observed scaling:
*
* rmsVoltage = volts
* rmsCurrent / 100 = amperes
*
* Power:
*
* P = V × I
*
* When PI heating demand is 0%, current and power are forced to zero.
*/
function calculateElectrical(endpoint, newValues = {}) {
const result = {};
const voltage =
newValues.voltage !== undefined
? Number(newValues.voltage)
: Number(
getCached(
endpoint,
'haElectricalMeasurement',
'rmsVoltage',
),
);
const rawCurrent =
newValues.rawCurrent !== undefined
? Number(newValues.rawCurrent)
: Number(
getCached(
endpoint,
'haElectricalMeasurement',
'rmsCurrent',
),
);
const demand =
newValues.demand !== undefined
? Number(newValues.demand)
: Number(
getCached(
endpoint,
'hvacThermostat',
'pIHeatingDemand',
),
);
/*
* Voltage
*/
if (
Number.isFinite(voltage) &&
voltage > 0
) {
result.voltage =
Math.round(voltage * 10) / 10;
}
/*
* PI heating demand
*/
if (
Number.isFinite(demand) &&
demand >= 0 &&
demand <= 100
) {
result.pi_heating_demand =
Math.round(demand);
result.running_state =
demand >= 10
? 'heat'
: 'idle';
}
/*
* Current
*
* OTH3600:
*
* rmsCurrent / 100
*/
let current;
if (
Number.isFinite(rawCurrent) &&
rawCurrent >= 0
) {
current =
rawCurrent / 100;
if (
Number.isFinite(demand) &&
demand === 0
) {
current = 0;
}
result.current =
Math.round(current * 100) / 100;
}
/*
* Power
*
* Electric floor heating is essentially resistive:
*
* P = V × I
*/
if (
Number.isFinite(voltage) &&
voltage > 0 &&
Number.isFinite(current)
) {
let power =
voltage * current;
if (
Number.isFinite(demand) &&
demand === 0
) {
power = 0;
}
result.power =
Math.round(power);
}
return result;
}
/*
* ==========================================================================
* FROM ZIGBEE
* ==========================================================================
*/
const fzLocal = {
/*
* ----------------------------------------------------------------------
* Sinopé proprietary cluster
* ----------------------------------------------------------------------
*/
sinopeData: {
cluster: 'manuSpecificSinope',
type: [
'attributeReport',
'readResponse',
],
convert: (
model,
msg,
publish,
options,
meta,
) => {
const result = {};
/*
* Outdoor temperature
*/
if (
msg.data.outdoorTempToDisplay !==
undefined
) {
result.thermostat_outdoor_temperature =
msg.data.outdoorTempToDisplay /
100;
}
/*
* Outdoor temperature timeout
*/
if (
msg.data.outdoorTempToDisplayTimeout !==
undefined
) {
result.outdoor_temperature_timeout =
msg.data.outdoorTempToDisplayTimeout;
}
/*
* Secondary display mode
*/
if (
msg.data.secondScreenBehavior !==
undefined
) {
const lookup = {
0: 'auto',
1: 'setpoint',
2: 'outdoor temp',
};
result.second_display_mode =
lookup[
msg.data.secondScreenBehavior
];
}
return result;
},
},
/*
* ----------------------------------------------------------------------
* Electrical measurements
* ----------------------------------------------------------------------
*/
electricalMeasurement: {
cluster: 'haElectricalMeasurement',
type: [
'attributeReport',
'readResponse',
],
convert: (
model,
msg,
publish,
options,
meta,
) => {
const values = {};
if (
msg.data.rmsVoltage !==
undefined
) {
values.voltage =
msg.data.rmsVoltage;
}
if (
msg.data.rmsCurrent !==
undefined
) {
values.rawCurrent =
msg.data.rmsCurrent;
}
return calculateElectrical(
msg.endpoint,
values,
);
},
},
/*
* ----------------------------------------------------------------------
* PI heating demand
* ----------------------------------------------------------------------
*/
heatingDemand: {
cluster: 'hvacThermostat',
type: [
'attributeReport',
'readResponse',
],
convert: (
model,
msg,
publish,
options,
meta,
) => {
if (
msg.data.pIHeatingDemand ===
undefined
) {
return {};
}
return calculateElectrical(
msg.endpoint,
{
demand:
Number(
msg.data.pIHeatingDemand
),
},
);
},
},
/*
* ----------------------------------------------------------------------
* Energy
* ----------------------------------------------------------------------
*
* seMetering.currentSummDelivered contains cumulative consumed energy.
*
* Zigbee formula:
*
* Energy =
* currentSummDelivered × multiplier / divisor
*
* If multiplier/divisor are not implemented by the OTH3600,
* the raw currentSummDelivered value is used.
*/
metering: {
cluster: 'seMetering',
type: [
'attributeReport',
'readResponse',
],
convert: (
model,
msg,
publish,
options,
meta,
) => {
const result = {};
if (
msg.data.currentSummDelivered !==
undefined
) {
const rawEnergy =
Number(
msg.data.currentSummDelivered
);
const multiplier =
Number(
getCached(
msg.endpoint,
'seMetering',
'multiplier',
),
);
const divisor =
Number(
getCached(
msg.endpoint,
'seMetering',
'divisor',
),
);
let factor = 1;
if (
Number.isFinite(multiplier) &&
multiplier > 0 &&
Number.isFinite(divisor) &&
divisor > 0
) {
factor =
multiplier / divisor;
}
if (
Number.isFinite(rawEnergy)
) {
result.energy =
Math.round(
rawEnergy *
factor *
1000
) / 1000;
}
}
return result;
},
},
};
/*
* ==========================================================================
* TO ZIGBEE
* ==========================================================================
*/
const tzLocal = {
/*
* ----------------------------------------------------------------------
* Outdoor temperature
* ----------------------------------------------------------------------
*/
thermostatOutdoorTemperature: {
key: [
'thermostat_outdoor_temperature',
],
convertSet: async (
entity,
key,
value,
meta,
) => {
const temperature =
Number(value);
if (
!Number.isFinite(temperature) ||
temperature < -99.5 ||
temperature > 99.5
) {
throw new Error(
'Outdoor temperature must be between -99.5 and 99.5 °C'
);
}
await entity.write(
'manuSpecificSinope',
{
outdoorTempToDisplay:
Math.round(
temperature * 100
),
},
manuSinope,
);
return {
state: {
thermostat_outdoor_temperature:
temperature,
},
};
},
convertGet: async (
entity,
key,
meta,
) => {
await entity.read(
'manuSpecificSinope',
[
'outdoorTempToDisplay',
],
manuSinope,
);
},
},
/*
* ----------------------------------------------------------------------
* Outdoor temperature timeout
* ----------------------------------------------------------------------
*/
outdoorTemperatureTimeout: {
key: [
'outdoor_temperature_timeout',
],
convertSet: async (
entity,
key,
value,
meta,
) => {
const timeout =
Number(value);
if (
!Number.isFinite(timeout) ||
timeout < 30 ||
timeout > 64800
) {
throw new Error(
'Outdoor temperature timeout must be between 30 and 64800 seconds'
);
}
await entity.write(
'manuSpecificSinope',
{
outdoorTempToDisplayTimeout:
Math.round(timeout),
},
manuSinope,
);
return {
state: {
outdoor_temperature_timeout:
timeout,
},
};
},
convertGet: async (
entity,
key,
meta,
) => {
await entity.read(
'manuSpecificSinope',
[
'outdoorTempToDisplayTimeout',
],
manuSinope,
);
},
},
/*
* ----------------------------------------------------------------------
* Secondary display mode
* ----------------------------------------------------------------------
*/
secondDisplayMode: {
key: [
'second_display_mode',
],
convertSet: async (
entity,
key,
value,
meta,
) => {
const lookup = {
auto: 0,
setpoint: 1,
'outdoor temp': 2,
};
if (
lookup[value] === undefined
) {
throw new Error(
'second_display_mode must be auto, setpoint or outdoor temp'
);
}
await entity.write(
'manuSpecificSinope',
{
secondScreenBehavior:
lookup[value],
},
manuSinope,
);
return {
state: {
second_display_mode:
value,
},
};
},
convertGet: async (
entity,
key,
meta,
) => {
await entity.read(
'manuSpecificSinope',
[
'secondScreenBehavior',
],
manuSinope,
);
},
},
/*
* ----------------------------------------------------------------------
* Electrical / Energy refresh
* ----------------------------------------------------------------------
*/
electricalMeasurements: {
key: [
'voltage',
'current',
'power',
'energy',
'pi_heating_demand',
'running_state',
],
convertGet: async (
entity,
key,
meta,
) => {
/*
* Voltage + current
*/
try {
await entity.read(
'haElectricalMeasurement',
[
'rmsVoltage',
'rmsCurrent',
],
);
} catch {
// Ignore unsupported read
}
/*
* PI heating demand
*/
try {
await entity.read(
'hvacThermostat',
[
'pIHeatingDemand',
],
);
} catch {
// Ignore unsupported read
}
/*
* Metering multiplier / divisor.
*
* Read separately because the OTH3600 may not
* implement them.
*/
try {
await entity.read(
'seMetering',
[
'multiplier',
'divisor',
],
);
} catch {
// Raw energy value will be used.
}
/*
* Cumulative consumed energy.
*/
try {
await entity.read(
'seMetering',
[
'currentSummDelivered',
],
);
} catch {
// Ignore unsupported read
}
},
},
};
/*
* ==========================================================================
* DEVICE DEFINITION
* ==========================================================================
*/
const definition = {
zigbeeModel: [
'OTH3600-GA-ZB',
],
model:
'OTH3600-GA-ZB',
vendor:
'Ouellet',
description:
'Ouellet Zigbee floor heating thermostat',
/*
* Sinopé/Ouellet PI heating demand is already 0..100.
*/
meta: {
thermostat: {
dontMapPIHeatingDemand: true,
},
},
/*
* ----------------------------------------------------------------------
* EXTENDS
* ----------------------------------------------------------------------
*/
extend: [
/*
* Sinopé custom cluster 0xFF01.
*
* No bind and no configureReporting.
*/
m.deviceAddCustomCluster(
'manuSpecificSinope',
{
name:
'manuSpecificSinope',
ID:
0xff01,
manufacturerCode:
0x119c,
attributes: {
outdoorTempToDisplay: {
name:
'outdoorTempToDisplay',
ID:
0x0010,
type:
Zcl.DataType.INT16,
write:
true,
},
outdoorTempToDisplayTimeout: {
name:
'outdoorTempToDisplayTimeout',
ID:
0x0011,
type:
Zcl.DataType.UINT16,
write:
true,
},
secondScreenBehavior: {
name:
'secondScreenBehavior',
ID:
0x0012,
type:
Zcl.DataType.ENUM8,
write:
true,
},
},
commands: {},
commandsResponse: {},
},
),
/*
* Identify
*/
m.identify(),
/*
* Thermostat
*/
m.thermostat({
localTemperature: {},
setpoints: {
values: {
occupiedHeatingSetpoint: {
min:
7,
max:
30,
step:
0.5,
},
},
},
systemMode: {
values: [
'off',
'heat',
],
configure: {
reporting:
false,
},
},
}),
/*
* Thermostat UI
*/
m.thermostatUi({
reporting:
false,
}),
/*
* Temperature
*/
m.temperature(),
],
/*
* ----------------------------------------------------------------------
* FROM ZIGBEE
* ----------------------------------------------------------------------
*/
fromZigbee: [
fzLocal.sinopeData,
fzLocal.electricalMeasurement,
fzLocal.heatingDemand,
fzLocal.metering,
],
/*
* ----------------------------------------------------------------------
* TO ZIGBEE
* ----------------------------------------------------------------------
*/
toZigbee: [
tzLocal.thermostatOutdoorTemperature,
tzLocal.outdoorTemperatureTimeout,
tzLocal.secondDisplayMode,
tzLocal.electricalMeasurements,
],
/*
* ----------------------------------------------------------------------
* EXPOSES
* ----------------------------------------------------------------------
*/
exposes: [
e.numeric(
'thermostat_outdoor_temperature',
ea.ALL,
)
.withUnit('°C')
.withValueMin(-99.5)
.withValueMax(99.5)
.withValueStep(0.5)
.withDescription(
'Outdoor temperature displayed on the thermostat'
),
e.numeric(
'outdoor_temperature_timeout',
ea.ALL,
)
.withUnit('s')
.withValueMin(30)
.withValueMax(64800)
.withDescription(
'Time before the outdoor temperature expires'
),
e.enum(
'second_display_mode',
ea.ALL,
[
'auto',
'setpoint',
'outdoor temp',
],
)
.withDescription(
'Content displayed on the thermostat secondary display'
),
/*
* Voltage
*/
e.numeric(
'voltage',
ea.STATE_GET,
)
.withUnit('V')
.withDescription(
'Measured mains voltage'
),
/*
* Current
*/
e.numeric(
'current',
ea.STATE_GET,
)
.withUnit('A')
.withDescription(
'Heating current'
),
/*
* Power
*/
e.numeric(
'power',
ea.STATE_GET,
)
.withUnit('W')
.withDescription(
'Calculated heating power'
),
/*
* Energy
*/
e.numeric(
'energy',
ea.STATE_GET,
)
.withUnit('kWh')
.withDescription(
'Total consumed energy'
),
/*
* PI heating demand
*/
e.numeric(
'pi_heating_demand',
ea.STATE_GET,
)
.withUnit('%')
.withValueMin(0)
.withValueMax(100)
.withDescription(
'Thermostat heating demand'
),
/*
* Running state
*/
e.enum(
'running_state',
ea.STATE_GET,
[
'idle',
'heat',
],
)
.withDescription(
'Heating state calculated from PI heating demand'
),
],
};
module.exports = definition;
What does/doesn't work with the external definition?
Everything works so far
- heating set point
- outdoor temperature
- voltage
- current
- power
- temperature
Notes
software_build_id: 746
date_code: 20191021
endpoints:
{"1":{"clusters":{"input":["genBasic","genIdentify","genGroups","genScenes","hvacThermostat","hvacUserInterfaceCfg","msTemperatureMeasurement","seMetering","haElectricalMeasurement","haDiagnostic","manuSpecificSinope"],"output":["genTime","manuSpecificSinope","genOta"]}}}
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the supplied external converter, especially calculateElectrical and the fzLocal handlers for Sinope, electrical measurements, heating demand, and metering. Compare those definitions with existing zigbee-herdsman-converters patterns, then verify that the OTH3600-GA-ZB is recognized and publishes the described thermostat, electrical, energy, and display values.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, typescript
- Domain
- embedded-iot
- Issue type
- Feature
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 58/100