From 5014e91aa50c5db7a1e2718499497acc81200aa5 Mon Sep 17 00:00:00 2001 From: calvinastroboy <38746027+calvinastroboy@users.noreply.github.com> Date: Wed, 27 May 2026 13:04:02 +0800 Subject: [PATCH 1/2] Add Terncy DIM003 and WS07-D3 --- src/devices/terncy.ts | 498 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 497 insertions(+), 1 deletion(-) diff --git a/src/devices/terncy.ts b/src/devices/terncy.ts index 9c488bed6895d..9fcd8c57f1bd7 100644 --- a/src/devices/terncy.ts +++ b/src/devices/terncy.ts @@ -4,31 +4,264 @@ import * as tz from "../converters/toZigbee"; import * as exposes from "../lib/exposes"; import * as m from "../lib/modernExtend"; import * as reporting from "../lib/reporting"; -import type {DefinitionWithExtend, Fz, KeyValueAny} from "../lib/types"; +import type {DefinitionWithExtend, Fz, KeyValueAny, Tz, Zh} from "../lib/types"; const e = exposes.presets; const ea = exposes.access; +const XIAOYAN_MANUFACTURER_CODE = 0x1228; +const ADURO_SMART_CLUSTER = "manuSpecificClusterAduroSmart"; +const TERNCY_DIMMER_POWER_CLUSTER = "terncyDimmerPower"; +const TERNCY_DIMMER_LIGHT_EFFECT_CLUSTER = "terncyDimmerLightEffect"; +const terncyManufacturerOptions = {manufacturerCode: XIAOYAN_MANUFACTURER_CODE}; + interface AduroSmart { attributes: { terncyRotation: number; + cfgButtonLedPolarity: number; + cfgButtonLedStatus: number; + cfgDisabledRelayStatus: number; + cfgLoopHasRelay: boolean; }; commands: { cmd0: Record; + enableRelay: {value: number}; + configIndicatorLed: {value: boolean}; + setInputMode: {value: number}; + enablePureInput: {value: number}; + setSwitchPolarity: {value: number}; + setButtonLedStatus: {value: number}; + }; + commandResponses: never; +} + +interface TerncyDimmerPower { + attributes: Record; + commands: { + powerCalibration: {mode: number; current: number; reserved: number}; }; commandResponses: never; } +interface TerncyDimmerLightEffect { + attributes: { + xyPwmLowFrequency: number; + xyRevertColorTempGpio: boolean; + xyLightUpCurve: number; + mappedXyLevel: number; + }; + commands: { + adjustStartLevel: {level: number}; + setColorTempRange: {min: number; max: number}; + setBezier: {type: number; x1: number; y1: number; x2: number; y2: number; x3: number; y3: number}; + }; + commandResponses: never; +} + +const WS07_ENDPOINTS = {l1: 1, l2: 2, l3: 3}; +const WS07_WIRELESS_LED_STATUS = { + off: 0, + on: 1, +}; +const WS07_LED_FEEDBACK_MODE = { + positive: 0, + negative: 1, +}; +const WS07_CLICK_ACTIONS: Record = { + 1: "single", + 2: "double", + 3: "triple", + 4: "quadruple", + 5: "5_click", + 6: "6_click", + 7: "7_click", +}; + +const DIM003_DEFAULT_MIN_KELVIN = 1600; +const DIM003_DEFAULT_MAX_KELVIN = 7000; +const DIM003_POWER_CALIBRATION_MODE_RATED_MAX_CURRENT = 0x00; +const DIM003_LIGHT_UP_CURVE = { + fast_start: 0, + uniform: 1, + slow_start: 2, +}; +const DIM003_LIGHT_UP_CURVE_BY_VALUE: Record = { + 0: "fast_start", + 1: "uniform", + 2: "slow_start", +}; + +function lookupNumber>(value: unknown, key: string, lookup: T): {name: keyof T & string; value: number} { + const name = String(value); + if (!Object.hasOwn(lookup, name)) { + throw new Error(`${key} must be one of: ${Object.keys(lookup).join(", ")}`); + } + + return {name: name as keyof T & string, value: lookup[name]}; +} + +function numberInRange(value: unknown, key: string, min: number, max: number): number { + const number = Number(value); + if (!Number.isFinite(number) || number < min || number > max) { + throw new Error(`${key} must be a number from ${min} to ${max}`); + } + + return Math.round(number); +} + +function stateNumber(meta: Tz.Meta, key: string, fallback: number): number { + const value = Number(meta.state?.[key]); + return Number.isFinite(value) ? value : fallback; +} + +function kelvinToMired(kelvin: number): number { + return Math.round(1000000 / kelvin); +} + +function booleanValue(value: unknown, key: string): boolean { + if (typeof value === "boolean") { + return value; + } + + const normalized = String(value).toLowerCase(); + if (["true", "on", "1"].includes(normalized)) { + return true; + } + if (["false", "off", "0"].includes(normalized)) { + return false; + } + + throw new Error(`${key} must be true or false`); +} + +function ws07EndpointName(msg: Fz.Message): string | undefined { + return Object.entries(WS07_ENDPOINTS).find(([, Id]) => Id === msg.endpoint.ID)?.[0]; +} + +function ws07EndpointActions(): string[] { + const baseActions = [...Object.values(WS07_CLICK_ACTIONS), "hold", "release"]; + return Object.keys(WS07_ENDPOINTS).flatMap((ep) => baseActions.map((action) => `${action}_${ep}`)); +} + +async function sendWs07SwitchConfig(entity: Zh.Endpoint | Zh.Group, command: "enablePureInput" | "setButtonLedStatus", value: number): Promise { + await entity.command( + ADURO_SMART_CLUSTER, + command, + {value}, + {...terncyManufacturerOptions, disableDefaultResponse: false}, + ); +} + +async function sendDim003PowerCommand(entity: Zh.Endpoint | Zh.Group, payload: TerncyDimmerPower["commands"]["powerCalibration"]): Promise { + await entity.command( + TERNCY_DIMMER_POWER_CLUSTER, + "powerCalibration", + payload, + {...terncyManufacturerOptions, disableDefaultResponse: false}, + ); +} + +async function sendDim003AdjustStartLevel( + entity: Zh.Endpoint | Zh.Group, + payload: TerncyDimmerLightEffect["commands"]["adjustStartLevel"], +): Promise { + await entity.command( + TERNCY_DIMMER_LIGHT_EFFECT_CLUSTER, + "adjustStartLevel", + payload, + {...terncyManufacturerOptions, disableDefaultResponse: false}, + ); +} + +async function sendDim003ColorTempRange( + entity: Zh.Endpoint | Zh.Group, + payload: TerncyDimmerLightEffect["commands"]["setColorTempRange"], +): Promise { + await entity.command( + TERNCY_DIMMER_LIGHT_EFFECT_CLUSTER, + "setColorTempRange", + payload, + {...terncyManufacturerOptions, disableDefaultResponse: false}, + ); +} + const terncyExtend = { addClusterAduroSmart: () => m.deviceAddCustomCluster("manuSpecificClusterAduroSmart", { name: "manuSpecificClusterAduroSmart", ID: 0xfccc, + manufacturerCode: XIAOYAN_MANUFACTURER_CODE, attributes: { terncyRotation: {name: "terncyRotation", ID: 0x001b, type: Zcl.DataType.UINT16}, + cfgButtonLedPolarity: {name: "cfgButtonLedPolarity", ID: 0x001f, type: Zcl.DataType.UINT8, write: true}, + cfgButtonLedStatus: {name: "cfgButtonLedStatus", ID: 0x0020, type: Zcl.DataType.UINT8}, + cfgDisabledRelayStatus: {name: "cfgDisabledRelayStatus", ID: 0x0021, type: Zcl.DataType.UINT8}, + cfgLoopHasRelay: {name: "cfgLoopHasRelay", ID: 0x0026, type: Zcl.DataType.BOOLEAN}, }, commands: { cmd0: {name: "cmd0", ID: 0x00, parameters: []}, + enableRelay: {name: "enableRelay", ID: 0x13, parameters: [{name: "value", type: Zcl.DataType.UINT8}]}, + configIndicatorLed: {name: "configIndicatorLed", ID: 0x16, parameters: [{name: "value", type: Zcl.DataType.BOOLEAN}]}, + setInputMode: {name: "setInputMode", ID: 0x1c, parameters: [{name: "value", type: Zcl.DataType.UINT8}]}, + enablePureInput: {name: "enablePureInput", ID: 0x1d, parameters: [{name: "value", type: Zcl.DataType.UINT8}]}, + setSwitchPolarity: {name: "setSwitchPolarity", ID: 0x1e, parameters: [{name: "value", type: Zcl.DataType.UINT8}]}, + setButtonLedStatus: {name: "setButtonLedStatus", ID: 0x1f, parameters: [{name: "value", type: Zcl.DataType.UINT8}]}, + }, + commandsResponse: {}, + }), + addDimmerPowerCluster: () => + m.deviceAddCustomCluster(TERNCY_DIMMER_POWER_CLUSTER, { + name: TERNCY_DIMMER_POWER_CLUSTER, + ID: 0xfccc, + manufacturerCode: XIAOYAN_MANUFACTURER_CODE, + attributes: {}, + commands: { + powerCalibration: { + name: "powerCalibration", + ID: 0x08, + parameters: [ + {name: "mode", type: Zcl.DataType.UINT8}, + {name: "current", type: Zcl.DataType.UINT32}, + {name: "reserved", type: Zcl.DataType.UINT32}, + ], + }, + }, + commandsResponse: {}, + }), + addDimmerLightEffectCluster: () => + m.deviceAddCustomCluster(TERNCY_DIMMER_LIGHT_EFFECT_CLUSTER, { + name: TERNCY_DIMMER_LIGHT_EFFECT_CLUSTER, + ID: 0xfccd, + manufacturerCode: XIAOYAN_MANUFACTURER_CODE, + attributes: { + xyPwmLowFrequency: {name: "xyPwmLowFrequency", ID: 0x0002, type: Zcl.DataType.UINT16}, + xyRevertColorTempGpio: {name: "xyRevertColorTempGpio", ID: 0x0005, type: Zcl.DataType.BOOLEAN, write: true}, + xyLightUpCurve: {name: "xyLightUpCurve", ID: 0x0007, type: Zcl.DataType.UINT8, write: true}, + mappedXyLevel: {name: "mappedXyLevel", ID: 0x0009, type: Zcl.DataType.UINT16}, + }, + commands: { + adjustStartLevel: {name: "adjustStartLevel", ID: 0x00, parameters: [{name: "level", type: Zcl.DataType.UINT32}]}, + setColorTempRange: { + name: "setColorTempRange", + ID: 0x03, + parameters: [ + {name: "min", type: Zcl.DataType.UINT16}, + {name: "max", type: Zcl.DataType.UINT16}, + ], + }, + setBezier: { + name: "setBezier", + ID: 0x05, + parameters: [ + {name: "type", type: Zcl.DataType.UINT8}, + {name: "x1", type: Zcl.DataType.UINT16}, + {name: "y1", type: Zcl.DataType.UINT16}, + {name: "x2", type: Zcl.DataType.UINT16}, + {name: "y2", type: Zcl.DataType.UINT16}, + {name: "x3", type: Zcl.DataType.UINT16}, + {name: "y3", type: Zcl.DataType.UINT16}, + ], + }, }, commandsResponse: {}, }), @@ -101,6 +334,149 @@ const fzLocal = { } }, } satisfies Fz.Converter<"manuSpecificClusterAduroSmart", AduroSmart, "raw">, + ws07_action: { + cluster: ADURO_SMART_CLUSTER, + type: "raw", + convert: (model, msg, publish, options, meta) => { + const data = [...msg.data]; + const ep = ws07EndpointName(msg); + + if (!ep || data[0] !== 0x0d || data[1] !== 0x28 || data[2] !== 0x12) { + return; + } + + if (data[4] === 0x00) { + const clickAction = WS07_CLICK_ACTIONS[data[6]]; + if (clickAction) { + return {action: `${clickAction}_${ep}`}; + } + } + + if (data[4] === 0x29 && (data[5] === 0x02 || data[5] === 0x08)) { + return { + action: `${data[5] === 0x02 ? "hold" : "release"}_${ep}`, + action_duration: data[7] + (data[8] << 8), + }; + } + }, + } satisfies Fz.Converter, + dim003_light_effect_attributes: { + cluster: TERNCY_DIMMER_LIGHT_EFFECT_CLUSTER, + type: ["attributeReport", "readResponse"], + convert: (model, msg, publish, options, meta) => { + const curve = DIM003_LIGHT_UP_CURVE_BY_VALUE[msg.data.xyLightUpCurve]; + if (curve === undefined) { + return; + } + + return {light_up_curve: curve}; + }, + } satisfies Fz.Converter, +}; + +const tzLocal = { + ws07_operation_mode: { + key: ["operation_mode"], + convertSet: async (entity, key, value, meta) => { + const mode = lookupNumber(value, key, {control_relay: 0, wireless: 1}); + await sendWs07SwitchConfig(entity, "enablePureInput", mode.value); + return {state: {[key]: mode.name}}; + }, + } satisfies Tz.Converter, + ws07_wireless_led_status: { + key: ["wireless_led_status"], + convertSet: async (entity, key, value, meta) => { + const status = lookupNumber(value, key, WS07_WIRELESS_LED_STATUS); + await sendWs07SwitchConfig(entity, "setButtonLedStatus", status.value); + return {state: {[key]: status.name}}; + }, + } satisfies Tz.Converter, + ws07_led_feedback_mode: { + key: ["led_feedback_mode"], + convertSet: async (entity, key, value, meta) => { + const mode = lookupNumber(value, key, WS07_LED_FEEDBACK_MODE); + await entity.write( + ADURO_SMART_CLUSTER, + {cfgButtonLedPolarity: mode.value}, + {...terncyManufacturerOptions, disableDefaultResponse: false, srcEndpoint: 110}, + ); + return {state: {[key]: mode.name}}; + }, + } satisfies Tz.Converter, + dim003_rated_max_current: { + key: ["rated_max_current_ma"], + convertSet: async (entity, key, value, meta) => { + const current = numberInRange(value, key, 120, 4950); + await sendDim003PowerCommand(entity, { + mode: DIM003_POWER_CALIBRATION_MODE_RATED_MAX_CURRENT, + current, + reserved: 0, + }); + return {state: {[key]: current}}; + }, + } satisfies Tz.Converter, + dim003_startup_depth_calibration: { + key: ["startup_depth_calibration"], + convertSet: async (entity, key, value, meta) => { + const level = numberInRange(value, key, 0, 5000); + await sendDim003AdjustStartLevel(entity, {level}); + return {state: {[key]: level}}; + }, + } satisfies Tz.Converter, + dim003_color_temperature_range: { + key: ["color_temperature_range_min_kelvin", "color_temperature_range_max_kelvin"], + convertSet: async (entity, key, value, meta) => { + const nextValue = numberInRange(value, key, 1000, 10000); + const minKelvin = + key === "color_temperature_range_min_kelvin" + ? nextValue + : stateNumber(meta, "color_temperature_range_min_kelvin", DIM003_DEFAULT_MIN_KELVIN); + const maxKelvin = + key === "color_temperature_range_max_kelvin" + ? nextValue + : stateNumber(meta, "color_temperature_range_max_kelvin", DIM003_DEFAULT_MAX_KELVIN); + + if (minKelvin >= maxKelvin) { + throw new Error("color_temperature_range_min_kelvin must be lower than color_temperature_range_max_kelvin"); + } + + await sendDim003ColorTempRange(entity, { + min: kelvinToMired(maxKelvin), + max: kelvinToMired(minKelvin), + }); + + return { + state: { + color_temperature_range_min_kelvin: minKelvin, + color_temperature_range_max_kelvin: maxKelvin, + }, + }; + }, + } satisfies Tz.Converter, + dim003_color_temperature_io_reversed: { + key: ["color_temperature_io_reversed"], + convertSet: async (entity, key, value, meta) => { + const reversed = booleanValue(value, key); + await entity.write( + TERNCY_DIMMER_LIGHT_EFFECT_CLUSTER, + {xyRevertColorTempGpio: reversed}, + {...terncyManufacturerOptions, disableDefaultResponse: false}, + ); + return {state: {[key]: reversed}}; + }, + } satisfies Tz.Converter, + dim003_light_up_curve: { + key: ["light_up_curve"], + convertSet: async (entity, key, value, meta) => { + const curve = lookupNumber(value, key, DIM003_LIGHT_UP_CURVE); + await entity.write( + TERNCY_DIMMER_LIGHT_EFFECT_CLUSTER, + {xyLightUpCurve: curve.value}, + {...terncyManufacturerOptions, disableDefaultResponse: false}, + ); + return {state: {[key]: curve.name}}; + }, + } satisfies Tz.Converter, }; export const definitions: DefinitionWithExtend[] = [ @@ -118,6 +494,68 @@ export const definitions: DefinitionWithExtend[] = [ description: "Two color temperature intelligent downlight", extend: [m.light({colorTemp: {range: [156, 476]}})], }, + { + fingerprint: [{modelID: "DIM003", manufacturerName: "Xiaoyan"}], + model: "DIM003", + vendor: "TERNCY", + description: "Color temperature dimmer", + fromZigbee: [fzLocal.dim003_light_effect_attributes], + toZigbee: [ + tzLocal.dim003_rated_max_current, + tzLocal.dim003_startup_depth_calibration, + tzLocal.dim003_color_temperature_range, + tzLocal.dim003_color_temperature_io_reversed, + tzLocal.dim003_light_up_curve, + ], + exposes: [ + e + .numeric("rated_max_current_ma", ea.STATE_SET) + .withUnit("mA") + .withDescription("Rated maximum current limit; setting may apply after a short delay") + .withValueMin(120) + .withValueMax(4950) + .withValueStep(1) + .withCategory("config"), + e + .numeric("startup_depth_calibration", ea.STATE_SET) + .withDescription("Xiaoyan startup depth calibration") + .withValueMin(0) + .withValueMax(5000) + .withValueStep(1) + .withCategory("config"), + e + .numeric("color_temperature_range_min_kelvin", ea.STATE_SET) + .withUnit("K") + .withDescription("Warmest color temperature limit") + .withValueMin(1000) + .withValueMax(10000) + .withValueStep(1) + .withCategory("config"), + e + .numeric("color_temperature_range_max_kelvin", ea.STATE_SET) + .withUnit("K") + .withDescription("Coolest color temperature limit") + .withValueMin(1000) + .withValueMax(10000) + .withValueStep(1) + .withCategory("config"), + e + .binary("color_temperature_io_reversed", ea.STATE_SET, true, false) + .withDescription("Reverse the cool and warm white output IO mapping") + .withCategory("config"), + exposes.enum("light_up_curve", ea.STATE_SET, Object.keys(DIM003_LIGHT_UP_CURVE)).withDescription("Light-up curve").withCategory("config"), + ], + extend: [ + terncyExtend.addDimmerPowerCluster(), + terncyExtend.addDimmerLightEffectCluster(), + m.light({ + colorTemp: {range: [142, 625], startup: false}, + configureReporting: true, + effect: false, + powerOnBehavior: false, + }), + ], + }, { zigbeeModel: ["TERNCY-DC01"], model: "TERNCY-DC01", @@ -162,6 +600,64 @@ export const definitions: DefinitionWithExtend[] = [ await reporting.bind(endpoint, coordinatorEndpoint, ["genOnOff"]); }, }, + { + fingerprint: [{modelID: "TERNCY-WS07-D3", manufacturerName: "Xiaoyan"}], + model: "TERNCY-WS07-D3", + vendor: "TERNCY", + description: "3-gang neutral wall switch", + fromZigbee: [fzLocal.ws07_action], + toZigbee: [tzLocal.ws07_operation_mode, tzLocal.ws07_wireless_led_status, tzLocal.ws07_led_feedback_mode], + exposes: [ + exposes + .enum("operation_mode", ea.STATE_SET, ["control_relay", "wireless"]) + .withEndpoint("l1") + .withDescription("Control relay or act as wireless switch") + .withCategory("config"), + exposes + .enum("operation_mode", ea.STATE_SET, ["control_relay", "wireless"]) + .withEndpoint("l2") + .withDescription("Control relay or act as wireless switch") + .withCategory("config"), + exposes + .enum("operation_mode", ea.STATE_SET, ["control_relay", "wireless"]) + .withEndpoint("l3") + .withDescription("Control relay or act as wireless switch") + .withCategory("config"), + exposes + .enum("wireless_led_status", ea.STATE_SET, Object.keys(WS07_WIRELESS_LED_STATUS)) + .withEndpoint("l1") + .withDescription("LED state while in wireless switch mode") + .withCategory("config"), + exposes + .enum("wireless_led_status", ea.STATE_SET, Object.keys(WS07_WIRELESS_LED_STATUS)) + .withEndpoint("l2") + .withDescription("LED state while in wireless switch mode") + .withCategory("config"), + exposes + .enum("wireless_led_status", ea.STATE_SET, Object.keys(WS07_WIRELESS_LED_STATUS)) + .withEndpoint("l3") + .withDescription("LED state while in wireless switch mode") + .withCategory("config"), + exposes + .enum("led_feedback_mode", ea.STATE_SET, Object.keys(WS07_LED_FEEDBACK_MODE)) + .withEndpoint("l1") + .withDescription("Relay-mode LED feedback relation") + .withCategory("config"), + exposes + .enum("led_feedback_mode", ea.STATE_SET, Object.keys(WS07_LED_FEEDBACK_MODE)) + .withEndpoint("l2") + .withDescription("Relay-mode LED feedback relation") + .withCategory("config"), + exposes + .enum("led_feedback_mode", ea.STATE_SET, Object.keys(WS07_LED_FEEDBACK_MODE)) + .withEndpoint("l3") + .withDescription("Relay-mode LED feedback relation") + .withCategory("config"), + e.action(ws07EndpointActions()), + e.action_duration(), + ], + extend: [terncyExtend.addClusterAduroSmart(), m.deviceEndpoints({endpoints: WS07_ENDPOINTS}), m.onOff({endpointNames: ["l1", "l2", "l3"]})], + }, { zigbeeModel: ["CL001"], model: "CL001", From 5bd7e36f94507455ba172c07dfe9378559fd99f8 Mon Sep 17 00:00:00 2001 From: calvinastroboy <38746027+calvinastroboy@users.noreply.github.com> Date: Tue, 14 Jul 2026 04:40:24 +0800 Subject: [PATCH 2/2] Add Terncy WS01 wall switch variants --- src/devices/terncy.ts | 155 +++++++++++++++++++++++++++++++++++++-- test/terncy-ws01.test.ts | 80 ++++++++++++++++++++ 2 files changed, 228 insertions(+), 7 deletions(-) create mode 100644 test/terncy-ws01.test.ts diff --git a/src/devices/terncy.ts b/src/devices/terncy.ts index 9fcd8c57f1bd7..2cde6b9c6891b 100644 --- a/src/devices/terncy.ts +++ b/src/devices/terncy.ts @@ -18,9 +18,11 @@ const terncyManufacturerOptions = {manufacturerCode: XIAOYAN_MANUFACTURER_CODE}; interface AduroSmart { attributes: { terncyRotation: number; + cfgSwPureInput: boolean; cfgButtonLedPolarity: number; cfgButtonLedStatus: number; cfgDisabledRelayStatus: number; + cfgSelfBindingId: number; cfgLoopHasRelay: boolean; }; commands: { @@ -59,6 +61,17 @@ interface TerncyDimmerLightEffect { } const WS07_ENDPOINTS = {l1: 1, l2: 2, l3: 3}; +const WS01_ENDPOINTS = {l1: 1, l2: 2, l3: 3, l4: 4}; +const WS01_MODELS = [ + {model: "TERNCY-WS01-S1", relayCount: 1, neutral: false}, + {model: "TERNCY-WS01-S2", relayCount: 2, neutral: false}, + {model: "TERNCY-WS01-S3", relayCount: 3, neutral: false}, + {model: "TERNCY-WS01-S4", relayCount: 4, neutral: false}, + {model: "TERNCY-WS01-D1", relayCount: 1, neutral: true}, + {model: "TERNCY-WS01-D2", relayCount: 2, neutral: true}, + {model: "TERNCY-WS01-D3", relayCount: 3, neutral: true}, + {model: "TERNCY-WS01-D4", relayCount: 4, neutral: true}, +] as const; const WS07_WIRELESS_LED_STATUS = { off: 0, on: 1, @@ -143,6 +156,15 @@ function ws07EndpointActions(): string[] { return Object.keys(WS07_ENDPOINTS).flatMap((ep) => baseActions.map((action) => `${action}_${ep}`)); } +function ws01EndpointName(msg: Fz.Message): string | undefined { + return Object.entries(WS01_ENDPOINTS).find(([, Id]) => Id === msg.endpoint.ID)?.[0]; +} + +function ws01EndpointActions(): string[] { + const baseActions = [...Object.values(WS07_CLICK_ACTIONS), "hold", "release"]; + return Object.keys(WS01_ENDPOINTS).flatMap((ep) => baseActions.map((action) => `${action}_${ep}`)); +} + async function sendWs07SwitchConfig(entity: Zh.Endpoint | Zh.Group, command: "enablePureInput" | "setButtonLedStatus", value: number): Promise { await entity.command( ADURO_SMART_CLUSTER, @@ -152,6 +174,15 @@ async function sendWs07SwitchConfig(entity: Zh.Endpoint | Zh.Group, command: "en ); } +async function sendWs01SwitchConfig(entity: Zh.Endpoint | Zh.Group, command: "enablePureInput" | "setButtonLedStatus", value: number): Promise { + await entity.command( + ADURO_SMART_CLUSTER, + command, + {value}, + {...terncyManufacturerOptions, disableDefaultResponse: false}, + ); +} + async function sendDim003PowerCommand(entity: Zh.Endpoint | Zh.Group, payload: TerncyDimmerPower["commands"]["powerCalibration"]): Promise { await entity.command( TERNCY_DIMMER_POWER_CLUSTER, @@ -193,9 +224,11 @@ const terncyExtend = { manufacturerCode: XIAOYAN_MANUFACTURER_CODE, attributes: { terncyRotation: {name: "terncyRotation", ID: 0x001b, type: Zcl.DataType.UINT16}, + cfgSwPureInput: {name: "cfgSwPureInput", ID: 0x001c, type: Zcl.DataType.BOOLEAN}, cfgButtonLedPolarity: {name: "cfgButtonLedPolarity", ID: 0x001f, type: Zcl.DataType.UINT8, write: true}, cfgButtonLedStatus: {name: "cfgButtonLedStatus", ID: 0x0020, type: Zcl.DataType.UINT8}, cfgDisabledRelayStatus: {name: "cfgDisabledRelayStatus", ID: 0x0021, type: Zcl.DataType.UINT8}, + cfgSelfBindingId: {name: "cfgSelfBindingId", ID: 0x0025, type: Zcl.DataType.UINT8, write: true}, cfgLoopHasRelay: {name: "cfgLoopHasRelay", ID: 0x0026, type: Zcl.DataType.BOOLEAN}, }, commands: { @@ -360,6 +393,59 @@ const fzLocal = { } }, } satisfies Fz.Converter, + ws01_action: { + cluster: ADURO_SMART_CLUSTER, + type: "raw", + convert: (model, msg, publish, options, meta) => { + const data = [...msg.data]; + const ep = ws01EndpointName(msg); + + if (!ep || data[0] !== 0x0d || data[1] !== 0x28 || data[2] !== 0x12) { + return; + } + + if (data[4] === 0x00 && data.length >= 7) { + const clickAction = WS07_CLICK_ACTIONS[data[6]]; + if (clickAction) { + return {action: `${clickAction}_${ep}`}; + } + } + + if (data[4] === 0x29 && data.length >= 9 && (data[5] === 0x02 || data[5] === 0x08)) { + return { + action: `${data[5] === 0x02 ? "hold" : "release"}_${ep}`, + action_duration: data[7] + (data[8] << 8), + }; + } + }, + } satisfies Fz.Converter, + ws01_switch_config: { + cluster: ADURO_SMART_CLUSTER, + type: ["attributeReport", "readResponse"], + convert: (model, msg, publish, options, meta) => { + const ep = Object.entries(WS01_ENDPOINTS).find(([, Id]) => Id === msg.endpoint.ID)?.[0]; + if (!ep) { + return; + } + + const state: Record = {}; + if (typeof msg.data.cfgSwPureInput === "boolean") { + state[`operation_mode_${ep}`] = msg.data.cfgSwPureInput ? "wireless" : "control_relay"; + } + + const ledStatus = Object.entries(WS07_WIRELESS_LED_STATUS).find(([, value]) => value === msg.data.cfgButtonLedStatus)?.[0]; + if (ledStatus) { + state[`wireless_led_status_${ep}`] = ledStatus; + } + + const feedbackMode = Object.entries(WS07_LED_FEEDBACK_MODE).find(([, value]) => value === msg.data.cfgButtonLedPolarity)?.[0]; + if (feedbackMode) { + state[`led_feedback_mode_${ep}`] = feedbackMode; + } + + return Object.keys(state).length > 0 ? state : undefined; + }, + } satisfies Fz.Converter, dim003_light_effect_attributes: { cluster: TERNCY_DIMMER_LIGHT_EFFECT_CLUSTER, type: ["attributeReport", "readResponse"], @@ -403,6 +489,34 @@ const tzLocal = { return {state: {[key]: mode.name}}; }, } satisfies Tz.Converter, + ws01_operation_mode: { + key: ["operation_mode"], + convertSet: async (entity, key, value, meta) => { + const mode = lookupNumber(value, key, {control_relay: 0, wireless: 1}); + await sendWs01SwitchConfig(entity, "enablePureInput", mode.value); + return {state: {[key]: mode.name}}; + }, + } satisfies Tz.Converter, + ws01_wireless_led_status: { + key: ["wireless_led_status"], + convertSet: async (entity, key, value, meta) => { + const status = lookupNumber(value, key, WS07_WIRELESS_LED_STATUS); + await sendWs01SwitchConfig(entity, "setButtonLedStatus", status.value); + return {state: {[key]: status.name}}; + }, + } satisfies Tz.Converter, + ws01_led_feedback_mode: { + key: ["led_feedback_mode"], + convertSet: async (entity, key, value, meta) => { + const mode = lookupNumber(value, key, WS07_LED_FEEDBACK_MODE); + await entity.write( + ADURO_SMART_CLUSTER, + {cfgButtonLedPolarity: mode.value}, + {...terncyManufacturerOptions, disableDefaultResponse: false, srcEndpoint: 110}, + ); + return {state: {[key]: mode.name}}; + }, + } satisfies Tz.Converter, dim003_rated_max_current: { key: ["rated_max_current_ma"], convertSet: async (entity, key, value, meta) => { @@ -479,14 +593,41 @@ const tzLocal = { } satisfies Tz.Converter, }; -export const definitions: DefinitionWithExtend[] = [ - { - zigbeeModel: ["TERNCY-WS01-S4"], - model: "TERNCY-WS01", +function createWs01Definition(modelInfo: (typeof WS01_MODELS)[number]): DefinitionWithExtend { + const relayEndpointNames = Object.keys(WS01_ENDPOINTS).slice(0, modelInfo.relayCount); + const relayConfigExposes = relayEndpointNames.flatMap((endpoint) => [ + exposes + .enum("operation_mode", ea.STATE_SET, ["control_relay", "wireless"]) + .withEndpoint(endpoint) + .withDescription("Control relay or act as wireless switch") + .withCategory("config"), + exposes + .enum("wireless_led_status", ea.STATE_SET, Object.keys(WS07_WIRELESS_LED_STATUS)) + .withEndpoint(endpoint) + .withDescription("LED state while in wireless switch mode") + .withCategory("config"), + exposes + .enum("led_feedback_mode", ea.STATE_SET, Object.keys(WS07_LED_FEEDBACK_MODE)) + .withEndpoint(endpoint) + .withDescription("Relay-mode LED feedback relation") + .withCategory("config"), + ]); + + return { + zigbeeModel: [modelInfo.model], + model: modelInfo.model, vendor: "TERNCY", - description: "Smart light switch - 4 gang without neutral wire", - extend: [m.deviceEndpoints({endpoints: {l1: 1, l2: 2, l3: 3, l4: 4}}), m.onOff({endpointNames: ["l1", "l2", "l3", "l4"]})], - }, + description: `${modelInfo.relayCount}-gang ${modelInfo.neutral ? "neutral" : "no-neutral"} wall switch`, + fromZigbee: [fzLocal.ws01_action, fzLocal.ws01_switch_config], + toZigbee: [tzLocal.ws01_operation_mode, tzLocal.ws01_wireless_led_status, tzLocal.ws01_led_feedback_mode], + exposes: [...relayConfigExposes, e.action(ws01EndpointActions()), e.action_duration()], + extend: [terncyExtend.addClusterAduroSmart(), m.deviceEndpoints({endpoints: WS01_ENDPOINTS}), m.onOff({endpointNames: relayEndpointNames})], + meta: {multiEndpoint: true}, + }; +} + +export const definitions: DefinitionWithExtend[] = [ + ...WS01_MODELS.map(createWs01Definition), { zigbeeModel: ["DL001"], model: "DL001", diff --git a/test/terncy-ws01.test.ts b/test/terncy-ws01.test.ts new file mode 100644 index 0000000000000..1bbaca19db88c --- /dev/null +++ b/test/terncy-ws01.test.ts @@ -0,0 +1,80 @@ +import {describe, expect, test} from "vitest"; +import {findByDevice} from "../src"; +import {mockDevice} from "./utils"; + +const WS01_RELAY_COUNTS: Record = { + "TERNCY-WS01-S1": 1, + "TERNCY-WS01-S2": 2, + "TERNCY-WS01-S3": 3, + "TERNCY-WS01-S4": 4, + "TERNCY-WS01-D1": 1, + "TERNCY-WS01-D2": 2, + "TERNCY-WS01-D3": 3, + "TERNCY-WS01-D4": 4, +}; + +const clickActions = ["single", "double", "triple", "quadruple", "5_click", "6_click", "7_click"]; + +function deviceFor(modelID: string, relayCount: number) { + return mockDevice( + { + modelID, + manufacturerName: "Xiaoyan", + endpoints: Array.from({length: 4}, (_, index) => ({ + ID: index + 1, + inputClusterIDs: index < relayCount ? [6, 64716] : [64716], + outputClusterIDs: [], + })), + }, + "Router", + ); +} + +describe("TERNCY WS01 family", () => { + test.each(Object.entries(WS01_RELAY_COUNTS))("resolves %s with the correct relay/action boundary", async (modelID, relayCount) => { + const definition = await findByDevice(deviceFor(modelID, relayCount)); + + expect(definition).toBeDefined(); + expect(definition.model).toBe(modelID); + expect(definition.vendor).toBe("TERNCY"); + expect(definition.endpoint?.()).toEqual({l1: 1, l2: 2, l3: 3, l4: 4}); + + const relaySwitches = definition.exposes.filter((expose) => expose.type === "switch").map((expose) => expose.endpoint); + expect(relaySwitches).toEqual(Array.from({length: relayCount}, (_, index) => `l${index + 1}`)); + + const relayConfigProperties = ["operation_mode", "wireless_led_status", "led_feedback_mode"]; + for (const property of relayConfigProperties) { + expect(definition.exposes.filter((expose) => expose.property?.startsWith(`${property}_`)).map((expose) => expose.property)).toEqual( + Array.from({length: relayCount}, (_, index) => `${property}_l${index + 1}`), + ); + } + + const expectedActions = Array.from({length: 4}, (_, index) => + [...clickActions, "hold", "release"].map((action) => `${action}_l${index + 1}`), + ).flat(); + const actionExpose = definition.exposes.find((expose) => expose.property === "action"); + expect((actionExpose as unknown as {values: string[]}).values).toEqual(expectedActions); + expect(definition.exposes.some((expose) => expose.property === "action_duration")).toBe(true); + + expect(definition.exposes.some((expose) => expose.property === "relay_enabled")).toBe(false); + expect(definition.exposes.some((expose) => expose.property === "relay_constant_power")).toBe(false); + expect(definition.exposes.some((expose) => expose.property === "button_relay_binding")).toBe(false); + }); + + test("decodes a click reported by a non-relay private endpoint", async () => { + const definition = await findByDevice(deviceFor("TERNCY-WS01-S1", 1)); + const actionConverter = definition.fromZigbee.find( + (converter) => converter.cluster === "manuSpecificClusterAduroSmart" && converter.type === "raw", + ); + + expect(actionConverter).toBeDefined(); + const result = actionConverter?.convert( + definition, + {data: [0x0d, 0x28, 0x12, 0x00, 0x00, 0x00, 0x01], endpoint: {ID: 4}} as never, + () => undefined, + {}, + {}, + ); + expect(result).toEqual({action: "single_l4"}); + }); +});