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', // Valeur par défaut décimale 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; // Convertir la valeur en chaîne si elle est numérique const stringValue = value.toString(); logger.debug(`PMTSD_to_W100: Received key=${key}, value=${value}, type=${typeof value}, stringValue=${stringValue}`); 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(stringValue)) { logger.error(`Invalid PMTSD_P value: ${stringValue}, expected '0' or '1'`); throw new Error('PMTSD_P must be "0" (on) or "1" (off)'); } pmtsd.P = stringValue; hasChanged = stringValue !== previousValue; break; case 'PMTSD_M': if (!['0', '1', '2'].includes(stringValue)) { logger.error(`Invalid PMTSD_M value: ${stringValue}, expected '0', '1', or '2'`); throw new Error('PMTSD_M must be "0" (cooling), "1" (heating), or "2" (auto)'); } pmtsd.M = stringValue; hasChanged = stringValue !== previousValue; break; case 'PMTSD_T': const numValue = parseFloat(stringValue); if (isNaN(numValue) || numValue < 15.0 || numValue > 30.0 || (numValue * 10) % 1 !== 0) { logger.error(`Invalid PMTSD_T value: ${stringValue}, expected 15.0 to 30.0 with 0.1 step`); throw new Error('PMTSD_T must be a number between 15.0 and 30.0 with one decimal place'); } pmtsd.T = numValue.toFixed(1); hasChanged = stringValue !== previousValue; break; case 'PMTSD_S': if (!['0', '1', '2', '3'].includes(stringValue)) { logger.error(`Invalid PMTSD_S value: ${stringValue}, expected '0', '1', '2', or '3'`); throw new Error('PMTSD_S must be "0" (auto), "1", "2", or "3"'); } pmtsd.S = stringValue; hasChanged = stringValue !== previousValue; break; case 'PMTSD_D': if (!['0', '1'].includes(stringValue)) { logger.error(`Invalid PMTSD_D value: ${stringValue}, expected '0' or '1'`); throw new Error('PMTSD_D must be "0" or "1"'); } pmtsd.D = stringValue; hasChanged = stringValue !== previousValue; break; default: logger.error(`Unrecognized key: ${key}`); 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; } // Utiliser T tel quel avec le point décimal 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; let processedValue = value; 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'; // Valider que TW est un nombre décimal avec un chiffre après la virgule const numValue = parseFloat(value); if (!isNaN(numValue) && numValue >= 15.0 && numValue <= 30.0 && (numValue * 10) % 1 === 0) { processedValue = numValue.toFixed(1); } else { return; // Ignorer les valeurs invalides } 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; // Conserver la valeur brute pour PW, MW, TW, etc. if (stateKey) { stateUpdate.state[stateKey] = processedValue; result[stateKey] = processedValue; // Publier la valeur traitée } partsForCombined.push(`${newKey}${value}`); } }); // Formater la date et l'heure const date = new Date(); const formattedDate = date.toLocaleString('fr-FR', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).replace(/,/, '').replace(/(\d+)\/(\d+)\/(\d+)/, '$3-$2-$1'); // 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.numeric('PMTSD_T', ea.ALL).withValueMin(15.0).withValueMax(30.0).withValueStep(0.1).withUnit('°C').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(), ], };