#33145·zigbee2mqtt

[External Converter]: OTH3600-GA-ZB from Ouellet

Author: mbdrlpCreated Sep 16, 2026Updated Sep 17, 2026
Labelsexternal converter

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
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: {
            dontMapPIHeati