Koenkk / Koenkk/zigbee2mqtt

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

Open
#33,145 5 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

external converter
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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.