Skip to content

Commit 6f2e386

Browse files
committed
port zwave-js DUT to zwave-js-server
1 parent 73a0fe2 commit 6f2e386

50 files changed

Lines changed: 238058 additions & 13 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

config.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
{
22
"dut": {
33
// Display name for logs
4-
"name": "Z-Wave JS",
4+
"name": "Z-Wave JS Server",
55

66
// Path to the DUT runner script (relative to repo root)
7-
"runnerPath": "dut/zwave-js/run.ts",
7+
"runnerPath": "dut/zwave-js-server/run.ts",
88

99
// Z-Wave network home ID (used for storage file filtering)
1010
"homeId": "d2658d0f",
1111

1212
// Directory containing DUT storage/cache files
13-
"storageDir": "dut/zwave-js/storage",
13+
"storageDir": "dut/zwave-js-server/storage",
1414

1515
// Glob patterns to match storage files (supports %HOME_ID_LOWER% and %HOME_ID_UPPER% placeholders)
1616
"storageFileFilter": [
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/**
2+
* Handler for add mode prompts
3+
*
4+
* Automates Z-Wave node inclusion (adding devices to the network).
5+
* For S2 inclusion, uses event-driven flow via WebSocket:
6+
* 1. Send controller.begin_inclusion
7+
* 2. Listen for "grant security classes" event and respond
8+
* 3. Wait for PIN from CTT log message
9+
* 4. Listen for "validate dsk and enter pin" event and respond with PIN
10+
*/
11+
12+
import {
13+
createDeferredPromise,
14+
type DeferredPromise,
15+
} from "alcalzone-shared/deferred-promise";
16+
import { registerHandler } from "../../prompt-handlers.ts";
17+
import { wait } from "alcalzone-shared/async";
18+
import { InclusionStrategy } from "zwave-js";
19+
20+
const PIN_PROMISE = "pin promise";
21+
22+
// Module-level variable to track cleanup function (persists across test state clears)
23+
let currentInclusionCleanup: (() => void) | undefined;
24+
25+
registerHandler(/.*/, {
26+
onTestStart: async () => {
27+
// Clean up any leftover listeners from previous tests
28+
if (currentInclusionCleanup) {
29+
currentInclusionCleanup();
30+
currentInclusionCleanup = undefined;
31+
}
32+
},
33+
34+
onPrompt: async (ctx) => {
35+
// Handle ACTIVATE_NETWORK_MODE for ADD mode
36+
if (
37+
ctx.message?.type === "ACTIVATE_NETWORK_MODE" &&
38+
ctx.message.mode === "ADD"
39+
) {
40+
const { client, state, message } = ctx;
41+
state.set(PIN_PROMISE, createDeferredPromise<string>());
42+
43+
// Clean up any existing listeners first
44+
if (currentInclusionCleanup) {
45+
currentInclusionCleanup();
46+
currentInclusionCleanup = undefined;
47+
}
48+
49+
// Set up S2 inclusion event handlers
50+
const grantSecurityClasses = async (data: { requested: unknown }) => {
51+
await client.sendCommand("controller.grant_security_classes", {
52+
inclusionGrant: data.requested,
53+
});
54+
};
55+
56+
const validateDsk = async () => {
57+
const pinPromise = state.get(PIN_PROMISE) as DeferredPromise<string> | undefined;
58+
if (pinPromise) {
59+
const pin = await pinPromise;
60+
await client.sendCommand("controller.validate_dsk_and_enter_pin", {
61+
pin,
62+
});
63+
}
64+
};
65+
66+
const cleanup = () => {
67+
client.off("grant security classes", grantSecurityClasses);
68+
client.off("validate dsk and enter pin", validateDsk);
69+
client.off("node added", onNodeAdded);
70+
currentInclusionCleanup = undefined;
71+
};
72+
73+
const onNodeAdded = () => {
74+
cleanup();
75+
};
76+
77+
// Note: We do NOT clean up on "inclusion stopped" because that event fires
78+
// after the initial NWI phase but BEFORE S2 negotiation completes.
79+
// The S2 events (grant security classes, validate dsk) come after "inclusion stopped".
80+
81+
client.on("grant security classes", grantSecurityClasses);
82+
client.on("validate dsk and enter pin", validateDsk);
83+
client.on("node added", onNodeAdded);
84+
85+
// Store cleanup function at module level
86+
currentInclusionCleanup = cleanup;
87+
88+
// Determine inclusion strategy based on forceS0 flag
89+
const strategy = message.forceS0
90+
? InclusionStrategy.Security_S0
91+
: InclusionStrategy.Default;
92+
93+
for (let attempt = 1; attempt <= 5; attempt++) {
94+
try {
95+
const result = await client.sendCommand("controller.begin_inclusion", {
96+
options: {
97+
strategy,
98+
},
99+
});
100+
101+
if ((result as { success: boolean }).success !== false) break;
102+
} catch (error) {
103+
console.error(`Inclusion attempt ${attempt} failed:`, error);
104+
}
105+
106+
// Backoff in case another in-/exclusion process is still busy
107+
if (attempt < 5) {
108+
await wait(1000 * attempt);
109+
} else {
110+
throw new Error("Failed to start inclusion after 5 attempts");
111+
}
112+
}
113+
114+
return "Ok";
115+
}
116+
117+
// Let other prompts fall through to manual handling
118+
return undefined;
119+
},
120+
121+
onLog: async (ctx) => {
122+
if (ctx.message?.type === "S2_PIN_CODE") {
123+
const pinPromise = ctx.state.get(PIN_PROMISE) as
124+
| DeferredPromise<string>
125+
| undefined;
126+
if (!pinPromise) return;
127+
128+
console.log("Detected PIN code:", ctx.message.pin);
129+
pinPromise.resolve(ctx.message.pin);
130+
return true;
131+
}
132+
},
133+
});
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import {
2+
registerHandler,
3+
type PromptContext,
4+
type PromptResponse,
5+
} from "../../prompt-handlers.ts";
6+
import type {
7+
DUTCapabilityId,
8+
CCCapabilityQueryMessage,
9+
} from "../../../../src/ctt-message-types.ts";
10+
11+
// DUT capability responses by capabilityId
12+
const dutCapabilityResponses: Record<
13+
DUTCapabilityId,
14+
PromptResponse | ((ctx: PromptContext) => PromptResponse)
15+
> = {
16+
ESTABLISH_ASSOCIATION: "No",
17+
DISPLAY_LAST_STATE: "Yes",
18+
QR_CODE: "Yes",
19+
LEARN_MODE: "No",
20+
LEARN_MODE_ACCESSIBLE: "No",
21+
FACTORY_RESET: "Yes",
22+
REMOVE_FAILED_NODE: "Yes",
23+
ICON_TYPE_MATCH: "Yes",
24+
IDENTIFY_OTHER_PURPOSE: "No",
25+
CONTROLS_UNLISTED_CCS: "No",
26+
ALL_DOCUMENTED_AS_CONTROLLED: "Yes",
27+
PARTIAL_CONTROL_DOCUMENTED: (ctx) => {
28+
// Entry Control CC and User Code CC is marked as partial control in the certification portal
29+
if (
30+
ctx.testName.includes("CCR_EntryControlCC") ||
31+
ctx.testName.includes("CCR_UserCodeCC")
32+
) {
33+
return "Yes";
34+
}
35+
return "No";
36+
},
37+
MAINS_POWERED: "Yes",
38+
};
39+
40+
// CC capability responses by commandClass and capabilityId
41+
type CCCapabilityKey = `${string}:${string}`;
42+
const ccCapabilityResponses: Record<
43+
CCCapabilityKey,
44+
PromptResponse | ((msg: CCCapabilityQueryMessage) => PromptResponse)
45+
> = {
46+
// Multilevel Switch capabilities
47+
"Multilevel Switch:START_STOP_LEVEL_CHANGE": "Yes",
48+
"Multilevel Switch:SET_DIMMING_DURATION": "Yes",
49+
"Multilevel Switch:SET_LEVEL_CHANGE_PARAMS": "Yes",
50+
51+
// Barrier Operator capabilities
52+
"Barrier Operator:CONTROL_EVENT_SIGNALING": "Yes",
53+
54+
// Anti-Theft capabilities
55+
"Anti-Theft:LOCK_UNLOCK": "No",
56+
57+
// Door Lock capabilities
58+
"Door Lock:CONFIGURE_DOOR_HANDLES": "Yes",
59+
60+
// Configuration capabilities
61+
"Configuration:RESET_SINGLE_PARAM": "Yes",
62+
63+
// Notification capabilities
64+
"Notification:CREATE_RULES_FROM_NOTIFICATIONS": "Yes",
65+
"Notification:UPDATE_NOTIFICATION_LIST": "Yes",
66+
67+
// User Code capabilities
68+
"User Code:MODIFY_USER_CODE": "Yes",
69+
"User Code:SET_KEYPAD_MODE": "Yes",
70+
"User Code:SET_ADMIN_CODE": "Yes",
71+
72+
// Entry Control capabilities
73+
"Entry Control:CONFIGURE_KEYPAD": "Yes",
74+
};
75+
76+
// CC version control - which CC versions we control
77+
const controlledCCVersions: Record<string, number[]> = {
78+
Basic: [1, 2],
79+
Indicator: [1, 2, 3, 4],
80+
Version: [1, 2, 3],
81+
"Wake Up": [1, 2, 3],
82+
};
83+
84+
registerHandler(/.*/, {
85+
onPrompt: async (ctx) => {
86+
if (ctx.message?.type === "DUT_CAPABILITY_QUERY") {
87+
const response = dutCapabilityResponses[ctx.message.capabilityId];
88+
if (response !== undefined) {
89+
return typeof response === "function" ? response(ctx) : response;
90+
}
91+
}
92+
93+
if (ctx.message?.type === "CC_CAPABILITY_QUERY") {
94+
const { commandClass, capabilityId } = ctx.message;
95+
96+
// Special handling for CONTROLS_CC with version
97+
if (capabilityId === "CONTROLS_CC" && "version" in ctx.message) {
98+
const versions = controlledCCVersions[commandClass];
99+
if (versions?.includes(ctx.message.version)) {
100+
return "Yes";
101+
}
102+
return "No";
103+
}
104+
105+
// Look up standard capability response
106+
const key: CCCapabilityKey = `${commandClass}:${capabilityId}`;
107+
const response = ccCapabilityResponses[key];
108+
if (response !== undefined) {
109+
return typeof response === "function"
110+
? response(ctx.message)
111+
: response;
112+
}
113+
}
114+
115+
// Let other prompts fall through to manual handling
116+
return undefined;
117+
},
118+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import {
2+
registerHandler,
3+
type PromptResponse,
4+
} from "../../prompt-handlers.ts";
5+
6+
// Rules for how to respond based on recommendation type
7+
const rules: Record<string, PromptResponse> = {
8+
INDICATOR_REPORT_IN_AGI: "Yes",
9+
};
10+
11+
registerHandler(/.*/, {
12+
onPrompt: async (ctx) => {
13+
if (ctx.message?.type !== "SHOULD_DISREGARD_RECOMMENDATION") {
14+
return undefined;
15+
}
16+
17+
const rule = rules[ctx.message.recommendationType];
18+
return rule ?? undefined;
19+
},
20+
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { CommandClasses } from "@zwave-js/core";
2+
import { registerHandler } from "../../prompt-handlers.ts";
3+
4+
// Map CTT CC names to CommandClasses enum values
5+
const ccNameToCC: Record<string, CommandClasses> = {
6+
"Binary Switch": CommandClasses["Binary Switch"],
7+
"Multilevel Switch": CommandClasses["Multilevel Switch"],
8+
Meter: CommandClasses.Meter,
9+
};
10+
11+
registerHandler(/.*/, {
12+
async onPrompt(ctx) {
13+
if (ctx.message?.type !== "CHECK_ENDPOINT_CAPABILITY") return;
14+
15+
const node = ctx.includedNodes.at(-1);
16+
if (!node) return;
17+
18+
// getDefinedValueIDs is async in the WebSocket client
19+
const valueIDs = await node.getDefinedValueIDs();
20+
21+
// Check each endpoint/CC pair from the structured message
22+
for (const { commandClass, endpoint } of ctx.message.endpoints) {
23+
const ccId = ccNameToCC[commandClass];
24+
25+
if (ccId === undefined) {
26+
console.log(`Unknown CC name: ${commandClass}`);
27+
return "No";
28+
}
29+
30+
// Check if this CC exists on the specified endpoint
31+
const hasCC = valueIDs.some(
32+
(v) => v.commandClass === ccId && v.endpoint === endpoint
33+
);
34+
35+
if (!hasCC) {
36+
console.log(`CC ${commandClass} not found on endpoint ${endpoint}`);
37+
return "No";
38+
}
39+
}
40+
41+
return "Yes";
42+
},
43+
});
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { registerHandler } from "../../prompt-handlers.ts";
2+
import { UI_CONTEXT, type UIContext } from "./uiContext.ts";
3+
4+
registerHandler(/.*/, {
5+
onPrompt: async (ctx) => {
6+
if (ctx.message?.type === "WAIT_FOR_INTERVIEW") {
7+
const { client } = ctx;
8+
9+
// Capture embedded UI context if present
10+
if (ctx.message.uiContext) {
11+
ctx.state.set(UI_CONTEXT, {
12+
commandClass: ctx.message.uiContext.commandClass,
13+
nodeId: ctx.message.uiContext.nodeId,
14+
} satisfies UIContext);
15+
}
16+
17+
// Check if the last node's interview is already complete
18+
const lastNode = ctx.includedNodes.at(-1);
19+
if (lastNode?.interviewComplete) {
20+
return "Ok";
21+
}
22+
23+
// Wait for interview completed event
24+
return new Promise((resolve) => {
25+
client.once("node interview completed", () => {
26+
resolve("Ok");
27+
});
28+
});
29+
}
30+
31+
// Let other prompts fall through to manual handling
32+
return undefined;
33+
},
34+
});

0 commit comments

Comments
 (0)