Skip to content

Commit f304966

Browse files
feat(add): ND-01 (#12490)
Co-authored-by: Marian <marian@jana-marian.de>
1 parent be81afc commit f304966

3 files changed

Lines changed: 281 additions & 0 deletions

File tree

src/devices/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,7 @@ import {definitions as niko} from "./niko";
226226
import {definitions as ninjaBlocks} from "./ninja_blocks";
227227
import {definitions as niviss} from "./niviss";
228228
import {definitions as nobo} from "./nobo";
229+
import {definitions as nodieby} from "./nodieby";
229230
import {definitions as nodon} from "./nodon";
230231
import {definitions as nordtronic} from "./nordtronic";
231232
import {definitions as nous} from "./nous";
@@ -600,6 +601,7 @@ const definitions: DefinitionWithExtend[] = [
600601
...niko,
601602
...ninjaBlocks,
602603
...niviss,
604+
...nodieby,
603605
...nodon,
604606
...nordtronic,
605607
...nous,

src/devices/nodieby.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import {Zcl} from "zigbee-herdsman";
2+
3+
import * as m from "../lib/modernExtend";
4+
import type {DefinitionWithExtend} from "../lib/types";
5+
6+
// Custom manufacturer cluster (0xFC00) that holds the NoDieby device settings.
7+
// The firmware registers these as plain read/write attributes without a
8+
// manufacturer code, so the cluster is defined without one here as well.
9+
const NODIEBY_CLUSTER = "nodiebyConfig";
10+
11+
// Describes the custom cluster so the typed modernExtend helpers (numeric,
12+
// enumLookup) accept the attribute names below.
13+
interface NodiebyConfig {
14+
attributes: {
15+
ledBrightness: number;
16+
sirenVolume: number;
17+
sensitivity: number;
18+
alarmDuration: number;
19+
alarmDelay: number;
20+
};
21+
commands: never;
22+
commandResponses: never;
23+
}
24+
25+
export const definitions: DefinitionWithExtend[] = [
26+
{
27+
zigbeeModel: ["ND-01"],
28+
model: "ND-01",
29+
vendor: "NoDieby",
30+
description: "Infrasonic intrusion detector",
31+
extend: [
32+
// Endpoint 1 = alarm (motion, armed state, settings), endpoint 2 = siren.
33+
m.deviceEndpoints({endpoints: {alarm: 1, siren: 2}}),
34+
m.deviceAddCustomCluster(NODIEBY_CLUSTER, {
35+
name: NODIEBY_CLUSTER,
36+
ID: 0xfc00,
37+
attributes: {
38+
ledBrightness: {name: "ledBrightness", ID: 0x0001, type: Zcl.DataType.UINT8, write: true, max: 100},
39+
sirenVolume: {name: "sirenVolume", ID: 0x0002, type: Zcl.DataType.UINT8, write: true, max: 100},
40+
sensitivity: {name: "sensitivity", ID: 0x0003, type: Zcl.DataType.UINT8, write: true, max: 2},
41+
alarmDuration: {name: "alarmDuration", ID: 0x0004, type: Zcl.DataType.UINT16, write: true, max: 300},
42+
alarmDelay: {name: "alarmDelay", ID: 0x0005, type: Zcl.DataType.UINT8, write: true, max: 120},
43+
},
44+
commands: {},
45+
commandsResponse: {},
46+
}),
47+
// Armed state on endpoint 1, siren on endpoint 2. powerOnBehavior is
48+
// disabled because the firmware does not implement startUpOnOff.
49+
m.onOff({powerOnBehavior: false, endpointNames: ["alarm", "siren"]}),
50+
// IAS Zone motion detection on endpoint 1, surfaced as occupancy.
51+
m.iasZoneAlarm({zoneType: "occupancy", zoneAttributes: ["alarm_1"]}),
52+
// iasZoneAlarm is event-driven and never reads zoneStatus at join, so
53+
// occupancy stays unknown until the first notification. Read it once
54+
// during configure to initialise the state right after pairing.
55+
{
56+
isModernExtend: true,
57+
configure: [
58+
async (device) => {
59+
await device.getEndpoint(1).read("ssIasZone", ["zoneStatus"]);
60+
},
61+
],
62+
},
63+
m.numeric<typeof NODIEBY_CLUSTER, NodiebyConfig>({
64+
name: "led_brightness",
65+
cluster: NODIEBY_CLUSTER,
66+
attribute: "ledBrightness",
67+
valueMin: 0,
68+
valueMax: 100,
69+
unit: "%",
70+
description: "Brightness of the status LED",
71+
access: "ALL",
72+
entityCategory: "config",
73+
endpointNames: ["alarm"],
74+
}),
75+
m.numeric<typeof NODIEBY_CLUSTER, NodiebyConfig>({
76+
name: "volume",
77+
cluster: NODIEBY_CLUSTER,
78+
attribute: "sirenVolume",
79+
valueMin: 0,
80+
valueMax: 100,
81+
unit: "%",
82+
description: "Siren volume",
83+
access: "ALL",
84+
entityCategory: "config",
85+
endpointNames: ["alarm"],
86+
}),
87+
m.enumLookup<typeof NODIEBY_CLUSTER, NodiebyConfig>({
88+
name: "sensitivity",
89+
cluster: NODIEBY_CLUSTER,
90+
attribute: "sensitivity",
91+
lookup: {low: 0, medium: 1, high: 2},
92+
description: "Intrusion detection sensitivity",
93+
access: "ALL",
94+
entityCategory: "config",
95+
endpointName: "alarm",
96+
}),
97+
m.numeric<typeof NODIEBY_CLUSTER, NodiebyConfig>({
98+
name: "alarm_duration",
99+
cluster: NODIEBY_CLUSTER,
100+
attribute: "alarmDuration",
101+
valueMin: 1,
102+
valueMax: 300,
103+
unit: "s",
104+
description: "Siren duration once triggered",
105+
access: "ALL",
106+
entityCategory: "config",
107+
endpointNames: ["alarm"],
108+
}),
109+
m.numeric<typeof NODIEBY_CLUSTER, NodiebyConfig>({
110+
name: "alarm_delay",
111+
cluster: NODIEBY_CLUSTER,
112+
attribute: "alarmDelay",
113+
valueMin: 0,
114+
valueMax: 120,
115+
unit: "s",
116+
description: "Delay before the alarm triggers after detection",
117+
access: "ALL",
118+
entityCategory: "config",
119+
endpointNames: ["alarm"],
120+
}),
121+
],
122+
},
123+
];

test/nodieby.test.ts

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
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

Comments
 (0)