Skip to content

[External Converter] for Aqara W100 #9954

Description

@Noyax-37

This is my external converter for lumi.sensor_ht.agl001 from Aqara
software_build_id: undefined
date_code: 20250507
endpoints:

{"1":{"clusters":{"input":["genMultistateInput","msRelativeHumidity","msTemperatureMeasurement","genPowerCfg","genIdentify","genBasic","manuSpecificLumi"],"output":["genOta"]},"name":"plus"},"2":{"clusters":{"input":["genMultistateInput"],"output":[]},"name":"center"},"3":{"clusters":{"input":["genMultistateInput"],"output":[]},"name":"minus"}}

What works / what doesn't?

Hello, I modified @greenspeedracer's super converter ( https://github.com/greenspeedracer/W100-Scripts/tree/main ) so that everything can be modified directly from the zigbee2mqtt interface. Now you can turn the thermostat function ON or OFF and send any PMTSD values ​​you want. For the moment the W100 only retains the numbers before the decimal point for the value of T while waiting for a possible firmware update

Image

Converter

const {Zcl} = require("zigbee-herdsman");
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 { logger } = require("zigbee-herdsman-converters/lib/logger");
const lumi = require("zigbee-herdsman-converters/lib/lumi");
const m = require("zigbee-herdsman-converters/lib/modernExtend");

const e = exposes.presets;
const ea = exposes.access;

const {
    lumiAction,
    lumiZigbeeOTA,
    lumiExternalSensor,
} = lumi.modernExtend;

const NS = "zhc:lumi";
const manufacturerCode = lumi.manufacturerCode;

const W100_0844_req = {
    cluster: 'manuSpecificLumi',
    type: ['attributeReport', 'readResponse'],
    convert: async (model, msg, publish, options, meta) => {
        const attr = msg.data[65522];
        if (!attr || !Buffer.isBuffer(attr)) return;

        const endsWith = Buffer.from([0x08, 0x00, 0x08, 0x44]);
        if (attr.slice(-4).equals(endsWith)) {
            meta.logger.info(`PMTSD request detected from device ${meta.device.ieeeAddr}`);
            
            // Retrieve PMTSD values from meta.state
            const pmtsdValues = {
                P: meta.state?.PMTSD_P || '0',
                M: meta.state?.PMTSD_M || '0',
                T: meta.state?.PMTSD_T || '15.0',
                S: meta.state?.PMTSD_S || '0',
                D: meta.state?.PMTSD_D || '0'
            };
            
            // Send PMTSD frame with stored values
            try {
                await PMTSD_to_W100.convertSet(meta.device.getEndpoint(1), 'PMTSD_to_W100', pmtsdValues, meta);
                meta.logger.info(`PMTSD frame sent for ${meta.device.ieeeAddr}`);
            } catch (error) {
                meta.logger.error(`Failed to send PMTSD frame: ${error.message}`);
            }
            
            return { action: 'W100_PMTSD_request' };
        }
    },
};

const PMTSD_to_W100 = {
    key: ['PMTSD_P', 'PMTSD_M', 'PMTSD_T', 'PMTSD_S', 'PMTSD_D', 'PMTSD_to_W100'],
    convertSet: async (entity, key, value, meta) => {
        // Retrieve current PMTSD values from meta.state
        let pmtsd = {
            P: meta.state?.PMTSD_P || '0',
            M: meta.state?.PMTSD_M || '0',
            T: meta.state?.PMTSD_T || '15.0',
            S: meta.state?.PMTSD_S || '0',
            D: meta.state?.PMTSD_D || '0'
        };

        let hasChanged = false;

        if (key === 'PMTSD_to_W100') {
            // Internal call: use value as {P, M, T, S, D} object
            pmtsd = { ...pmtsd, ...value };
            hasChanged = true; // Force send for internal calls
        } else {
            // Check if the value has changed
            const previousValue = pmtsd[key.replace('PMTSD_', '')];
            switch (key) {
                case 'PMTSD_P':
                    if (!['0', '1'].includes(value)) throw new Error('PMTSD_P must be 0 (on) or 1 (off)');
                    pmtsd.P = value;
                    hasChanged = value !== previousValue;
                    break;
                case 'PMTSD_M':
                    if (!['0', '1', '2'].includes(value)) throw new Error('PMTSD_M must be 0 (cooling), 1 (heating), or 2 (auto)');
                    pmtsd.M = value;
                    hasChanged = value !== previousValue;
                    break;
                case 'PMTSD_T':
                    const numValue = parseFloat(value);
                    if (isNaN(numValue) || numValue < 15.0 || numValue > 30.0 || (numValue * 10) % 1 !== 0) {
                        throw new Error('PMTSD_T must be a number between 15.0 and 30.0 with at most one decimal place');
                    }
                    // Format T: integer if no decimal part, otherwise one decimal
                    pmtsd.T = Number.isInteger(numValue) ? numValue.toString() : numValue.toFixed(1);
                    hasChanged = value.toString() !== previousValue;
                    break;
                case 'PMTSD_S':
                    if (!['0', '1', '2', '3'].includes(value)) throw new Error('PMTSD_S must be 0 (auto), 1, 2, or 3');
                    pmtsd.S = value;
                    hasChanged = value !== previousValue;
                    break;
                case 'PMTSD_D':
                    if (!['0', '1'].includes(value)) throw new Error('PMTSD_D must be 0 or 1');
                    pmtsd.D = value;
                    hasChanged = value !== previousValue;
                    break;
                default:
                    throw new Error(`Unrecognized key: ${key}`);
            }
        }

        // Log update
        logger.info(`Processed ${key}, PMTSD: ${JSON.stringify(pmtsd)}, Changed: ${hasChanged}`);

        // Update state, even if no frame is sent
        const stateUpdate = { state: { PMTSD_P: pmtsd.P, PMTSD_M: pmtsd.M, PMTSD_T: pmtsd.T, PMTSD_S: pmtsd.S, PMTSD_D: pmtsd.D } };

        // Check if all PMTSD values are defined
        const { P, M, T, S, D } = pmtsd;
        if (!P || !M || !T || !S || !D) {
            logger.info(`PMTSD frame not sent: missing values (P:${P}, M:${M}, T:${T}, S:${S}, D:${D})`);
            return stateUpdate;
        }

        // Do not send frame if no value changed (except for internal calls)
        if (!hasChanged) {
            logger.info(`PMTSD frame not sent: no value change`);
            return stateUpdate;
        }

        // Use formatted T for the PMTSD string
        const pmtsdStr = `P${P}_M${M}_T${T}_S${S}_D${D}`;
        const pmtsdBytes = Array.from(pmtsdStr).map(c => c.charCodeAt(0));
        const pmtsdLen = pmtsdBytes.length;

        const fixedHeader = [
            0xAA, 0x71, 0x1F, 0x44,
            0x00, 0x00, 0x05, 0x41, 0x1C,
            0x00, 0x00,
            0x54, 0xEF, 0x44, 0x80, 0x71, 0x1A,
            0x08, 0x00, 0x08, 0x44, pmtsdLen,
        ];

        const counter = Math.floor(Math.random() * 256);
        fixedHeader[4] = counter;

        const fullPayload = [...fixedHeader, ...pmtsdBytes];

        const checksum = fullPayload.reduce((sum, b) => sum + b, 0) & 0xFF;
        fullPayload[5] = checksum;

        // Ensure entity is an Endpoint
        const endpoint = entity.getEndpoint ? entity.getEndpoint(1) : entity;
        if (!endpoint || typeof endpoint.write !== 'function') {
            logger.error(`Invalid endpoint for write: ${JSON.stringify(endpoint)}`);
            throw new Error('Endpoint does not support write operation');
        }

        await endpoint.write(
            64704,
            { 65522: { value: Buffer.from(fullPayload), type: 65 } },
            { manufacturerCode: 4447, disableDefaultResponse: true },
        );

        logger.info(`PMTSD frame sent: ${pmtsdStr}`);
        return stateUpdate;
    },
    convertGet: async (entity, key, meta) => {
        // Return persisted value from meta.state
        return { [key]: meta.state?.[key] || (key === 'PMTSD_T' ? '15.0' : '0') };
    },
};

const PMTSD_from_W100 = {
    cluster: 'manuSpecificLumi',
    type: ['attributeReport', 'readResponse'],
    convert: (model, msg, publish, options, meta) => {
        const data = msg.data[65522];
        if (!data || !Buffer.isBuffer(data)) return;

        const endsWith = Buffer.from([0x08, 0x44]);
        const idx = data.indexOf(endsWith);
        if (idx === -1 || idx + 2 >= data.length) return;

        const payloadLen = data[idx + 2];
        const payloadStart = idx + 3;
        const payloadEnd = payloadStart + payloadLen;

        if (payloadEnd > data.length) return;

        const payloadBytes = data.slice(payloadStart, payloadEnd);
        let payloadAscii;
        try {
            payloadAscii = payloadBytes.toString('ascii');
        } catch {
            return;
        }

        // Log initial state for debugging
        meta.logger.info(`Initial meta.state: ${JSON.stringify(meta.state)}`);

        const result = {};
        const stateUpdate = { state: {} };
        const partsForCombined = [];
        const pairs = payloadAscii.split('_');
        pairs.forEach(p => {
            if (p.length >= 2) {
                const key = p[0].toLowerCase(); // Normaliser la clé en minuscules
                const value = p.slice(1);
                let newKey;
                let stateKey;
                switch (key) {
                    case 'p':
                        newKey = 'PW';
                        stateKey = 'PMTSD_P';
                        break;
                    case 'm':
                        newKey = 'MW';
                        stateKey = 'PMTSD_M';
                        break;
                    case 't':
                        newKey = 'TW';
                        stateKey = 'PMTSD_T';
                        break;
                    case 's':
                        newKey = 'SW';
                        stateKey = 'PMTSD_S';
                        break;
                    case 'd':
                        newKey = 'DW';
                        stateKey = 'PMTSD_D';
                        break;
                    default:
                        newKey = key.toUpperCase() + 'W';
                        stateKey = null;
                }
                result[newKey] = value;
                if (stateKey) {
                    stateUpdate.state[stateKey] = value;
                    result[stateKey] = value; // Forcer la publication au niveau racine
                }
                partsForCombined.push(`${newKey}${value}`);
            }
        });

        // Formater la date et l'heure
        const date = new Date();
        const formattedDate = date.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ''); // Format: YYYY-MM-DD HH:mm:ss
        const combinedString = partsForCombined.length
            ? `${formattedDate}_${partsForCombined.join('_')}`
            : `${formattedDate}`;

        // Log updated state for debugging
        meta.logger.info(`PMTSD decoded: ${JSON.stringify(result)} from ${meta.device.ieeeAddr}`);
        meta.logger.info(`Updated meta.state: ${JSON.stringify({ ...meta.state, ...stateUpdate.state })}`);

        return { 
            ...result,
            PMTSD_from_W100_Data: combinedString,
            ...stateUpdate
        };
    },
};

const Thermostat_Mode = {
    key: ['Thermostat_Mode'],
    convertSet: async (entity, key, value, meta) => {
        const deviceMac = meta.device.ieeeAddr.replace(/^0x/, '').toLowerCase();
        const hubMac = '54ef4480711a';
        function cleanMac(mac, expectedLen) {
            const cleaned = mac.replace(/[:\-]/g, '');
            if (cleaned.length !== expectedLen) {
                throw new Error(`MAC address must contain ${expectedLen} hexadecimal digits`);
            }
            return cleaned;
        }

        const dev = Buffer.from(cleanMac(deviceMac, 16), 'hex');
        const hub = Buffer.from(cleanMac(hubMac, 12), 'hex');

        // Ensure entity is an Endpoint
        const endpoint = entity.getEndpoint ? entity.getEndpoint(1) : entity;
        if (!endpoint || typeof endpoint.write !== 'function') {
            logger.error(`Invalid endpoint for write: ${JSON.stringify(endpoint)}`);
            throw new Error('Endpoint does not support write operation');
        }

        let frame;

        if (value === 'ON') {
            const prefix = Buffer.from('aa713244', 'hex');
            const messageAlea = Buffer.from([Math.floor(Math.random() * 256), Math.floor(Math.random() * 256)]);
            const zigbeeHeader = Buffer.from('02412f6891', 'hex');
            const messageId = Buffer.from([Math.floor(Math.random() * 256), Math.floor(Math.random() * 256)]);
            const control = Buffer.from([0x18]);
            const payloadMacs = Buffer.concat([dev, Buffer.from('0000', 'hex'), hub]);
            const payloadTail = Buffer.from('08000844150a0109e7a9bae8b083e58a9f000000000001012a40', 'hex');

            frame = Buffer.concat([prefix, messageAlea, zigbeeHeader, messageId, control, payloadMacs, payloadTail]);

            // Log the frame for debugging
            logger.info(`Thermostat_Mode ON frame: ${frame.toString('hex')}`);
            
            await endpoint.write(
                64704,
                { 65522: { value: frame, type: 0x41 } },
                { manufacturerCode: 4447, disableDefaultResponse: true },
            );
        } else {
            const prefix = Buffer.from([
                0xaa, 0x71, 0x1c, 0x44, 0x69, 0x1c,
                0x04, 0x41, 0x19, 0x68, 0x91
            ]);
            const frameId = Buffer.from([Math.floor(Math.random() * 256)]);
            const seq = Buffer.from([Math.floor(Math.random() * 256)]);
            const control = Buffer.from([0x18]);

            frame = Buffer.concat([prefix, frameId, seq, control, dev]);
            if (frame.length < 34) {
                frame = Buffer.concat([frame, Buffer.alloc(34 - frame.length, 0x00)]);
            }

            await endpoint.write(
                64704,
                { 65522: { value: frame, type: 0x41 } },
                { manufacturerCode: 4447, disableDefaultResponse: true },
            );
        }

        logger.info(`Thermostat_Mode set to ${value}`);
        return {};
    },
};

module.exports = {
    zigbeeModel: ["lumi.sensor_ht.agl001"],
    model: "TH-S04D",
    vendor: "Aqara",
    description: "Climate Sensor W100",
    fromZigbee: [W100_0844_req, PMTSD_from_W100],
    toZigbee: [PMTSD_to_W100, Thermostat_Mode],
    exposes: [
        e.binary('Thermostat_Mode', ea.ALL, 'ON', 'OFF').withDescription('ON: Enables thermostat mode, buttons send encrypted payloads, and the middle line is displayed. OFF: Disables thermostat mode, buttons send actions, and the middle line is hidden.'),
        e.action(['W100_PMTSD_request']).withDescription('PMTSD request sent by the W100 via the 08000844 sequence'),
        e.text('PMTSD_from_W100_Data', ea.STATE).withDescription('Latest PMTSD values sent by the W100 when manually changed, formatted as "YYYY-MM-DD HH:mm:ss_Px_Mx_Tx_Sx_Dx"'),
        e.text('PMTSD_P', ea.ALL).withDescription('Value P: activates or not the operation of the thermostat => 0 = on, 1 = off'),
        e.text('PMTSD_M', ea.ALL).withDescription('Value M: thermostat operating mode => 0 = cooling, 1 = heating, 2 = auto'),
        e.text('PMTSD_T', ea.ALL).withDescription('Value T: setpoint temperature'),
        e.text('PMTSD_S', ea.ALL).withDescription('Value S: speed => 0 = auto, 1 to 3'),
        e.text('PMTSD_D', ea.ALL).withDescription('Value D: wind mode => 0 or 1'),
    ],
    extend: [
        lumiZigbeeOTA(),
        m.temperature(),
        m.humidity(),
        lumiExternalSensor(),
        m.deviceEndpoints({endpoints: {plus: 1, center: 2, minus: 3}}),
        lumiAction({
            actionLookup: {hold: 0, single: 1, double: 2, release: 255},
            endpointNames: ["plus", "center", "minus"],
        }),
        m.binary({
            name: "Auto_Hide_Middle_Line",
            cluster: "manuSpecificLumi",
            attribute: {ID: 0x0173, type: Zcl.DataType.BOOLEAN},
            valueOn: [true, 0],
            valueOff: [false, 1],
            description: "Applies only when thermostat mode is enabled. True: Hides the middle line after 30 seconds of inactivity. False: Always displays the middle line.",
            access: "ALL",
            entityCategory: "config",
            zigbeeCommandOptions: {manufacturerCode},
            reporting: false,
        }),
        m.numeric({
            name: "high_temperature",
            valueMin: 26,
            valueMax: 60,
            valueStep: 0.5,
            scale: 100,
            unit: "°C",
            cluster: "manuSpecificLumi",
            attribute: {ID: 0x0167, type: Zcl.DataType.INT16},
            description: "High temperature alert",
            zigbeeCommandOptions: {manufacturerCode},
        }),
        m.numeric({
            name: "low_temperature",
            valueMin: -20,
            valueMax: 20,
            valueStep: 0.5,
            scale: 100,
            unit: "°C",
            cluster: "manuSpecificLumi",
            attribute: {ID: 0x0166, type: Zcl.DataType.INT16},
            description: "Low temperature alert",
            zigbeeCommandOptions: {manufacturerCode},
        }),
        m.numeric({
            name: "high_humidity",
            valueMin: 65,
            valueMax: 100,
            valueStep: 1,
            scale: 100,
            unit: "%",
            cluster: "manuSpecificLumi",
            attribute: {ID: 0x016e, type: Zcl.DataType.INT16},
            description: "High humidity alert",
            zigbeeCommandOptions: {manufacturerCode},
        }),
        m.numeric({
            name: "low_humidity",
            valueMin: 0,
            valueMax: 30,
            valueStep: 1,
            scale: 100,
            unit: "%",
            cluster: "manuSpecificLumi",
            attribute: {ID: 0x016d, type: Zcl.DataType.INT16},
            description: "Low humidity alert",
            zigbeeCommandOptions: {manufacturerCode},
        }),
        m.enumLookup({
            name: "sampling",
            lookup: {low: 1, standard: 2, high: 3, custom: 4},
            cluster: "manuSpecificLumi",
            attribute: {ID: 0x0170, type: Zcl.DataType.UINT8},
            description: "Temperature and humidity sampling settings",
            zigbeeCommandOptions: {manufacturerCode},
        }),
        m.numeric({
            name: "period",
            valueMin: 0.5,
            valueMax: 600,
            valueStep: 0.5,
            scale: 1000,
            unit: "sec",
            cluster: "manuSpecificLumi",
            attribute: {ID: 0x0162, type: Zcl.DataType.UINT32},
            description: "Sampling period",
            zigbeeCommandOptions: {manufacturerCode},
        }),
        m.enumLookup({
            name: "temp_report_mode",
            lookup: {no: 0, threshold: 1, period: 2, threshold_period: 3},
            cluster: "manuSpecificLumi",
            attribute: {ID: 0x0165, type: Zcl.DataType.UINT8},
            description: "Temperature reporting mode",
            zigbeeCommandOptions: {manufacturerCode},
        }),
        m.numeric({
            name: "temp_period",
            valueMin: 1,
            valueMax: 10,
            valueStep: 1,
            scale: 1000,
            unit: "sec",
            cluster: "manuSpecificLumi",
            attribute: {ID: 0x0163, type: Zcl.DataType.UINT32},
            description: "Temperature reporting period",
            zigbeeCommandOptions: {manufacturerCode},
        }),
        m.numeric({
            name: "temp_threshold",
            valueMin: 0.2,
            valueMax: 3,
            valueStep: 0.1,
            scale: 100,
            unit: "°C",
            cluster: "manuSpecificLumi",
            attribute: {ID: 0x0164, type: Zcl.DataType.UINT16},
            description: "Temperature reporting threshold",
            zigbeeCommandOptions: {manufacturerCode},
        }),
        m.enumLookup({
            name: "humi_report_mode",
            lookup: {no: 0, threshold: 1, period: 2, threshold_period: 3},
            cluster: "manuSpecificLumi",
            attribute: {ID: 0x016c, type: Zcl.DataType.UINT8},
            description: "Humidity reporting mode",
            zigbeeCommandOptions: {manufacturerCode},
        }),
        m.numeric({
            name: "humi_period",
            valueMin: 1,
            valueMax: 10,
            valueStep: 1,
            scale: 1000,
            unit: "sec",
            cluster: "manuSpecificLumi",
            attribute: {ID: 0x016a, type: Zcl.DataType.UINT32},
            description: "Humidity reporting period",
            zigbeeCommandOptions: {manufacturerCode},
        }),
        m.numeric({
            name: "humi_threshold",
            valueMin: 2,
            valueMax: 10,
            valueStep: 0.5,
            scale: 100,
            unit: "%",
            cluster: "manuSpecificLumi",
            attribute: {ID: 0x016b, type: Zcl.DataType.UINT16},
            description: "Humidity reporting threshold",
            zigbeeCommandOptions: {manufacturerCode},
        }),
        m.identify(),
    ],
};

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions