Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions config.json
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
{
"dut": {
// Display name for logs
"name": "Z-Wave JS",
"name": "Z-Wave JS Server",

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

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

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

// Glob patterns to match storage files (supports %HOME_ID_LOWER% and %HOME_ID_UPPER% placeholders)
"storageFileFilter": [
Expand Down
133 changes: 133 additions & 0 deletions dut/zwave-js-server/handlers/behaviors/addMode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* Handler for add mode prompts
*
* Automates Z-Wave node inclusion (adding devices to the network).
* For S2 inclusion, uses event-driven flow via WebSocket:
* 1. Send controller.begin_inclusion
* 2. Listen for "grant security classes" event and respond
* 3. Wait for PIN from CTT log message
* 4. Listen for "validate dsk and enter pin" event and respond with PIN
*/

import {
createDeferredPromise,
type DeferredPromise,
} from "alcalzone-shared/deferred-promise";
import { registerHandler } from "../../prompt-handlers.ts";
import { wait } from "alcalzone-shared/async";
import { InclusionStrategy } from "zwave-js";

const PIN_PROMISE = "pin promise";

// Module-level variable to track cleanup function (persists across test state clears)
let currentInclusionCleanup: (() => void) | undefined;

registerHandler(/.*/, {
onTestStart: async () => {
// Clean up any leftover listeners from previous tests
if (currentInclusionCleanup) {
currentInclusionCleanup();
currentInclusionCleanup = undefined;
}
},

onPrompt: async (ctx) => {
// Handle ACTIVATE_NETWORK_MODE for ADD mode
if (
ctx.message?.type === "ACTIVATE_NETWORK_MODE" &&
ctx.message.mode === "ADD"
) {
const { client, state, message } = ctx;
state.set(PIN_PROMISE, createDeferredPromise<string>());

// Clean up any existing listeners first
if (currentInclusionCleanup) {
currentInclusionCleanup();
currentInclusionCleanup = undefined;
}

// Set up S2 inclusion event handlers
const grantSecurityClasses = async (data: { requested: unknown }) => {
await client.sendCommand("controller.grant_security_classes", {
inclusionGrant: data.requested,
});
};

const validateDsk = async () => {
const pinPromise = state.get(PIN_PROMISE) as DeferredPromise<string> | undefined;
if (pinPromise) {
const pin = await pinPromise;
await client.sendCommand("controller.validate_dsk_and_enter_pin", {
pin,
});
}
};

const cleanup = () => {
client.off("grant security classes", grantSecurityClasses);
client.off("validate dsk and enter pin", validateDsk);
client.off("node added", onNodeAdded);
currentInclusionCleanup = undefined;
};

const onNodeAdded = () => {
cleanup();
};

// Note: We do NOT clean up on "inclusion stopped" because that event fires
// after the initial NWI phase but BEFORE S2 negotiation completes.
// The S2 events (grant security classes, validate dsk) come after "inclusion stopped".

client.on("grant security classes", grantSecurityClasses);
client.on("validate dsk and enter pin", validateDsk);
client.on("node added", onNodeAdded);

// Store cleanup function at module level
currentInclusionCleanup = cleanup;

// Determine inclusion strategy based on forceS0 flag
const strategy = message.forceS0
? InclusionStrategy.Security_S0
: InclusionStrategy.Default;

for (let attempt = 1; attempt <= 5; attempt++) {
try {
const result = await client.sendCommand("controller.begin_inclusion", {
options: {
strategy,
},
});

if ((result as { success: boolean }).success !== false) break;
} catch (error) {
console.error(`Inclusion attempt ${attempt} failed:`, error);
}

// Backoff in case another in-/exclusion process is still busy
if (attempt < 5) {
await wait(1000 * attempt);
} else {
throw new Error("Failed to start inclusion after 5 attempts");
}
}

return "Ok";
}

// Let other prompts fall through to manual handling
return undefined;
},

onLog: async (ctx) => {
if (ctx.message?.type === "S2_PIN_CODE") {
const pinPromise = ctx.state.get(PIN_PROMISE) as
| DeferredPromise<string>
| undefined;
if (!pinPromise) return;

console.log("Detected PIN code:", ctx.message.pin);
pinPromise.resolve(ctx.message.pin);
return true;
}
},
});
118 changes: 118 additions & 0 deletions dut/zwave-js-server/handlers/behaviors/capabilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import {
registerHandler,
type PromptContext,
type PromptResponse,
} from "../../prompt-handlers.ts";
import type {
DUTCapabilityId,
CCCapabilityQueryMessage,
} from "../../../../src/ctt-message-types.ts";

// DUT capability responses by capabilityId
const dutCapabilityResponses: Record<
DUTCapabilityId,
PromptResponse | ((ctx: PromptContext) => PromptResponse)
> = {
ESTABLISH_ASSOCIATION: "No",
DISPLAY_LAST_STATE: "Yes",
QR_CODE: "Yes",
LEARN_MODE: "No",
LEARN_MODE_ACCESSIBLE: "No",
FACTORY_RESET: "Yes",
REMOVE_FAILED_NODE: "Yes",
ICON_TYPE_MATCH: "Yes",
IDENTIFY_OTHER_PURPOSE: "No",
CONTROLS_UNLISTED_CCS: "No",
ALL_DOCUMENTED_AS_CONTROLLED: "Yes",
PARTIAL_CONTROL_DOCUMENTED: (ctx) => {
// Entry Control CC and User Code CC is marked as partial control in the certification portal
if (
ctx.testName.includes("CCR_EntryControlCC") ||
ctx.testName.includes("CCR_UserCodeCC")
) {
return "Yes";
}
return "No";
},
MAINS_POWERED: "Yes",
};

// CC capability responses by commandClass and capabilityId
type CCCapabilityKey = `${string}:${string}`;
const ccCapabilityResponses: Record<
CCCapabilityKey,
PromptResponse | ((msg: CCCapabilityQueryMessage) => PromptResponse)
> = {
// Multilevel Switch capabilities
"Multilevel Switch:START_STOP_LEVEL_CHANGE": "Yes",
"Multilevel Switch:SET_DIMMING_DURATION": "Yes",
"Multilevel Switch:SET_LEVEL_CHANGE_PARAMS": "Yes",

// Barrier Operator capabilities
"Barrier Operator:CONTROL_EVENT_SIGNALING": "Yes",

// Anti-Theft capabilities
"Anti-Theft:LOCK_UNLOCK": "No",

// Door Lock capabilities
"Door Lock:CONFIGURE_DOOR_HANDLES": "Yes",

// Configuration capabilities
"Configuration:RESET_SINGLE_PARAM": "Yes",

// Notification capabilities
"Notification:CREATE_RULES_FROM_NOTIFICATIONS": "Yes",
"Notification:UPDATE_NOTIFICATION_LIST": "Yes",

// User Code capabilities
"User Code:MODIFY_USER_CODE": "Yes",
"User Code:SET_KEYPAD_MODE": "Yes",
"User Code:SET_ADMIN_CODE": "Yes",

// Entry Control capabilities
"Entry Control:CONFIGURE_KEYPAD": "Yes",
};

// CC version control - which CC versions we control
const controlledCCVersions: Record<string, number[]> = {
Basic: [1, 2],
Indicator: [1, 2, 3, 4],
Version: [1, 2, 3],
"Wake Up": [1, 2, 3],
};

registerHandler(/.*/, {
onPrompt: async (ctx) => {
if (ctx.message?.type === "DUT_CAPABILITY_QUERY") {
const response = dutCapabilityResponses[ctx.message.capabilityId];
if (response !== undefined) {
return typeof response === "function" ? response(ctx) : response;
}
}

if (ctx.message?.type === "CC_CAPABILITY_QUERY") {
const { commandClass, capabilityId } = ctx.message;

// Special handling for CONTROLS_CC with version
if (capabilityId === "CONTROLS_CC" && "version" in ctx.message) {
const versions = controlledCCVersions[commandClass];
if (versions?.includes(ctx.message.version)) {
return "Yes";
}
return "No";
}

// Look up standard capability response
const key: CCCapabilityKey = `${commandClass}:${capabilityId}`;
const response = ccCapabilityResponses[key];
if (response !== undefined) {
return typeof response === "function"
? response(ctx.message)
: response;
}
}

// Let other prompts fall through to manual handling
return undefined;
},
});
20 changes: 20 additions & 0 deletions dut/zwave-js-server/handlers/behaviors/disregardRecommendation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import {
registerHandler,
type PromptResponse,
} from "../../prompt-handlers.ts";

// Rules for how to respond based on recommendation type
const rules: Record<string, PromptResponse> = {
INDICATOR_REPORT_IN_AGI: "Yes",
};

registerHandler(/.*/, {
onPrompt: async (ctx) => {
if (ctx.message?.type !== "SHOULD_DISREGARD_RECOMMENDATION") {
return undefined;
}

const rule = rules[ctx.message.recommendationType];
return rule ?? undefined;
},
});
43 changes: 43 additions & 0 deletions dut/zwave-js-server/handlers/behaviors/endpointControl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { CommandClasses } from "@zwave-js/core";
import { registerHandler } from "../../prompt-handlers.ts";

// Map CTT CC names to CommandClasses enum values
const ccNameToCC: Record<string, CommandClasses> = {
"Binary Switch": CommandClasses["Binary Switch"],
"Multilevel Switch": CommandClasses["Multilevel Switch"],
Meter: CommandClasses.Meter,
};

registerHandler(/.*/, {
async onPrompt(ctx) {
if (ctx.message?.type !== "CHECK_ENDPOINT_CAPABILITY") return;

const node = ctx.includedNodes.at(-1);
if (!node) return;

// getDefinedValueIDs is async in the WebSocket client
const valueIDs = await node.getDefinedValueIDs();

// Check each endpoint/CC pair from the structured message
for (const { commandClass, endpoint } of ctx.message.endpoints) {
const ccId = ccNameToCC[commandClass];

if (ccId === undefined) {
console.log(`Unknown CC name: ${commandClass}`);
return "No";
}

// Check if this CC exists on the specified endpoint
const hasCC = valueIDs.some(
(v) => v.commandClass === ccId && v.endpoint === endpoint
);

if (!hasCC) {
console.log(`CC ${commandClass} not found on endpoint ${endpoint}`);
return "No";
}
}

return "Yes";
},
});
34 changes: 34 additions & 0 deletions dut/zwave-js-server/handlers/behaviors/interviewFinished.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { registerHandler } from "../../prompt-handlers.ts";
import { UI_CONTEXT, type UIContext } from "./uiContext.ts";

registerHandler(/.*/, {
onPrompt: async (ctx) => {
if (ctx.message?.type === "WAIT_FOR_INTERVIEW") {
const { client } = ctx;

// Capture embedded UI context if present
if (ctx.message.uiContext) {
ctx.state.set(UI_CONTEXT, {
commandClass: ctx.message.uiContext.commandClass,
nodeId: ctx.message.uiContext.nodeId,
} satisfies UIContext);
}

// Check if the last node's interview is already complete
const lastNode = ctx.includedNodes.at(-1);
if (lastNode?.interviewComplete) {
return "Ok";
}

// Wait for interview completed event
return new Promise((resolve) => {
client.once("node interview completed", () => {
resolve("Ok");
});
});
}

// Let other prompts fall through to manual handling
return undefined;
},
});
Loading