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(),
],
};
This is my external converter for
lumi.sensor_ht.agl001fromAqarasoftware_build_id:
undefineddate_code:
20250507endpoints:
{"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
Converter