|
| 1 | +import {beforeEach, describe, expect, it} from "vitest"; |
| 2 | +import {Zcl} from "zigbee-herdsman"; |
| 3 | +import {findByDevice} from "../src/index"; |
| 4 | +import type {Definition, Fz, KeyValueAny, Tz} from "../src/lib/types"; |
| 5 | +import {mockDevice} from "./utils"; |
| 6 | + |
| 7 | +// These tests pin the on-air Zigbee behaviour of the ND-01 converter to exactly |
| 8 | +// what the firmware implements, so the modernExtend definition is guaranteed to |
| 9 | +// talk to real hardware correctly. |
| 10 | +// |
| 11 | +// Firmware wire contract: |
| 12 | +// Custom cluster 0xFC00 (plain attributes, NO manufacturer code): |
| 13 | +// ID 0x0001 ledBrightness UINT8 |
| 14 | +// ID 0x0002 sirenVolume UINT8 |
| 15 | +// ID 0x0003 sensitivity UINT8 |
| 16 | +// ID 0x0004 alarmDuration UINT16 |
| 17 | +// ID 0x0005 alarmDelay UINT8 |
| 18 | +// EP1 = genOnOff (armed) + ssIasZone (motion) + custom cluster |
| 19 | +// EP2 = genOnOff (siren) |
| 20 | + |
| 21 | +function buildDevice() { |
| 22 | + return mockDevice({ |
| 23 | + modelID: "ND-01", |
| 24 | + manufacturerName: "NoDieby", |
| 25 | + endpoints: [ |
| 26 | + {ID: 1, inputClusters: ["genBasic", "genIdentify", "ssIasZone", "genOnOff"], inputClusterIDs: [0xfc00]}, |
| 27 | + {ID: 2, inputClusters: ["genBasic", "genOnOff"]}, |
| 28 | + ], |
| 29 | + }); |
| 30 | +} |
| 31 | + |
| 32 | +function buildMeta(device: ReturnType<typeof mockDevice>, definition: Definition, overrides?: Partial<Tz.Meta>): Tz.Meta { |
| 33 | + return { |
| 34 | + state: {}, |
| 35 | + device, |
| 36 | + message: {} as KeyValueAny, |
| 37 | + mapped: definition, |
| 38 | + options: {}, |
| 39 | + endpoint_name: undefined, |
| 40 | + ...overrides, |
| 41 | + } as Tz.Meta; |
| 42 | +} |
| 43 | + |
| 44 | +describe("NoDieby ND-01", () => { |
| 45 | + let device: ReturnType<typeof mockDevice>; |
| 46 | + let definition: Definition; |
| 47 | + |
| 48 | + beforeEach(async () => { |
| 49 | + device = buildDevice(); |
| 50 | + definition = await findByDevice(device); |
| 51 | + // Registers the custom cluster on the device (deviceAddCustomCluster). |
| 52 | + await definition.configure?.(device, device.getEndpoint(1), definition); |
| 53 | + }); |
| 54 | + |
| 55 | + it("registers the custom cluster exactly as the firmware expects (ID, type, no manufacturer code)", () => { |
| 56 | + const cluster = device.customClusters.nodiebyConfig; |
| 57 | + expect(cluster.ID).toBe(0xfc00); |
| 58 | + expect(cluster.manufacturerCode).toBeUndefined(); |
| 59 | + expect(cluster.attributes).toMatchObject({ |
| 60 | + ledBrightness: {ID: 0x0001, type: Zcl.DataType.UINT8}, |
| 61 | + sirenVolume: {ID: 0x0002, type: Zcl.DataType.UINT8}, |
| 62 | + sensitivity: {ID: 0x0003, type: Zcl.DataType.UINT8}, |
| 63 | + alarmDuration: {ID: 0x0004, type: Zcl.DataType.UINT16}, |
| 64 | + alarmDelay: {ID: 0x0005, type: Zcl.DataType.UINT8}, |
| 65 | + }); |
| 66 | + }); |
| 67 | + |
| 68 | + it("reads IAS zoneStatus on configure so occupancy is initialised at join", () => { |
| 69 | + // iasZoneAlarm is event-driven; without this read occupancy stays unknown |
| 70 | + // until the first notification. Configure ran in beforeEach. |
| 71 | + expect(device.getEndpoint(1).read).toHaveBeenCalledWith("ssIasZone", ["zoneStatus"]); |
| 72 | + }); |
| 73 | + |
| 74 | + describe("toZigbee (commands sent to the device)", () => { |
| 75 | + const findTz = (key: string): Tz.Converter => definition.toZigbee.find((c) => c.key.includes(key)); |
| 76 | + |
| 77 | + const writeCases: {key: string; value: number | string; attribute: string; written: number}[] = [ |
| 78 | + {key: "led_brightness", value: 50, attribute: "ledBrightness", written: 50}, |
| 79 | + {key: "volume", value: 80, attribute: "sirenVolume", written: 80}, |
| 80 | + {key: "sensitivity", value: "high", attribute: "sensitivity", written: 2}, |
| 81 | + {key: "alarm_duration", value: 200, attribute: "alarmDuration", written: 200}, |
| 82 | + {key: "alarm_delay", value: 30, attribute: "alarmDelay", written: 30}, |
| 83 | + ]; |
| 84 | + |
| 85 | + it.each(writeCases)("set $key writes to the custom cluster on EP1 without a manufacturer code", async ({key, value, attribute, written}) => { |
| 86 | + const ep = device.getEndpoint(1); |
| 87 | + const meta = buildMeta(device, definition, {endpoint_name: "alarm", message: {[key]: value}}); |
| 88 | + |
| 89 | + const result = await findTz(key).convertSet(ep, key, value, meta); |
| 90 | + |
| 91 | + expect(ep.write).toHaveBeenCalledWith("nodiebyConfig", {[attribute]: written}, undefined); |
| 92 | + expect(result).toStrictEqual({state: {[key]: value}}); |
| 93 | + }); |
| 94 | + |
| 95 | + it.each(writeCases)("get $key reads the matching custom attribute on EP1", async ({key, attribute}) => { |
| 96 | + const ep = device.getEndpoint(1); |
| 97 | + const meta = buildMeta(device, definition, {endpoint_name: "alarm"}); |
| 98 | + |
| 99 | + await findTz(key).convertGet(ep, key, meta); |
| 100 | + |
| 101 | + expect(ep.read).toHaveBeenCalledWith("nodiebyConfig", [attribute], undefined); |
| 102 | + }); |
| 103 | + |
| 104 | + it("set armed state toggles genOnOff on EP1", async () => { |
| 105 | + const ep = device.getEndpoint(1); |
| 106 | + const meta = buildMeta(device, definition, {endpoint_name: "alarm", message: {state: "ON"}}); |
| 107 | + |
| 108 | + await findTz("state").convertSet(ep, "state", "ON", meta); |
| 109 | + |
| 110 | + expect(ep.command).toHaveBeenCalledWith("genOnOff", "on", {}, expect.anything()); |
| 111 | + }); |
| 112 | + |
| 113 | + it("set siren state toggles genOnOff on EP2", async () => { |
| 114 | + const ep = device.getEndpoint(2); |
| 115 | + const meta = buildMeta(device, definition, {endpoint_name: "siren", message: {state: "OFF"}}); |
| 116 | + |
| 117 | + await findTz("state").convertSet(ep, "state", "OFF", meta); |
| 118 | + |
| 119 | + expect(ep.command).toHaveBeenCalledWith("genOnOff", "off", {}, expect.anything()); |
| 120 | + }); |
| 121 | + }); |
| 122 | + |
| 123 | + describe("fromZigbee (messages received from the device)", () => { |
| 124 | + // Mirrors Z2M runtime: every fromZigbee converter matching the cluster/type runs and the results are merged. |
| 125 | + const runFz = (cluster: string, type: string, data: KeyValueAny, endpointId = 1) => { |
| 126 | + const converters = definition.fromZigbee.filter((c) => c.cluster === cluster && c.type.includes(type)); |
| 127 | + const endpoint = device.getEndpoint(endpointId); |
| 128 | + const msg = {data, endpoint, type, cluster, device, meta: {}, groupID: 0, linkquality: 100} as unknown as Fz.Message; |
| 129 | + const meta = {state: {}, device, deviceExposesChanged: null} as unknown as Fz.Meta; |
| 130 | + let result: KeyValueAny | undefined; |
| 131 | + for (const converter of converters) { |
| 132 | + const partial = converter.convert(definition, msg, () => {}, {}, meta); |
| 133 | + if (partial !== undefined) result = {...result, ...partial}; |
| 134 | + } |
| 135 | + return result; |
| 136 | + }; |
| 137 | + |
| 138 | + it("maps an IAS zone status change to occupancy", () => { |
| 139 | + expect(runFz("ssIasZone", "commandStatusChangeNotification", {zonestatus: 1})).toMatchObject({occupancy: true}); |
| 140 | + expect(runFz("ssIasZone", "commandStatusChangeNotification", {zonestatus: 0})).toMatchObject({occupancy: false}); |
| 141 | + }); |
| 142 | + |
| 143 | + it("maps custom cluster attribute reports to the right exposes on EP1", () => { |
| 144 | + expect(runFz("nodiebyConfig", "attributeReport", {ledBrightness: 42})).toStrictEqual({led_brightness_alarm: 42}); |
| 145 | + expect(runFz("nodiebyConfig", "attributeReport", {sirenVolume: 75})).toStrictEqual({volume_alarm: 75}); |
| 146 | + expect(runFz("nodiebyConfig", "attributeReport", {sensitivity: 0})).toStrictEqual({sensitivity_alarm: "low"}); |
| 147 | + expect(runFz("nodiebyConfig", "attributeReport", {alarmDuration: 250})).toStrictEqual({alarm_duration_alarm: 250}); |
| 148 | + expect(runFz("nodiebyConfig", "attributeReport", {alarmDelay: 10})).toStrictEqual({alarm_delay_alarm: 10}); |
| 149 | + }); |
| 150 | + |
| 151 | + it("maps genOnOff reports to the armed (EP1) and siren (EP2) switches", () => { |
| 152 | + expect(runFz("genOnOff", "attributeReport", {onOff: 1}, 1)).toMatchObject({state_alarm: "ON"}); |
| 153 | + expect(runFz("genOnOff", "attributeReport", {onOff: 0}, 2)).toMatchObject({state_siren: "OFF"}); |
| 154 | + }); |
| 155 | + }); |
| 156 | +}); |
0 commit comments