Skip to content
Draft
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
114 changes: 114 additions & 0 deletions .storybook/storybook-mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,23 @@ const cloneDeviceState = (ieee: string) => {
}
};

/**
* Resolve a topic (IEEE address or friendly name) to the friendly name.
* In real Z2M, state updates are always published to the friendly_name topic.
* Commands can be sent to either IEEE or friendly name, but responses go to friendly name.
*/
const resolveToFriendlyName = (topic: string): string => {
// Check if topic looks like an IEEE address (starts with 0x)
if (topic.startsWith("0x")) {
const device = BRIDGE_DEVICES.payload.find((d) => d.ieee_address === topic);
if (device) {
return device.friendly_name;
}
}
// Already a friendly name or unknown
return topic;
};

class MockWebSocket extends EventTarget {
static readonly CONNECTING = 0;
static readonly OPEN = 1;
Expand Down Expand Up @@ -432,11 +449,108 @@ class MockWebSocket extends EventTarget {
default: {
if (msg.topic.endsWith("/set")) {
const payload = msg.payload as Zigbee2MQTTRequest<"{friendlyNameOrId}/set">;
const deviceTopic = msg.topic.replace("/set", "");

if ("command" in payload) {
this.#emit(structuredClone(BRIDGE_LOGGING_EXECUTE_COMMAND), 500);
} else if ("read" in payload) {
this.#emit(structuredClone(BRIDGE_LOGGING_READ_ATTR), 500);
} else {
// Command Response API: Send response on {device}/response topic
const requestId = (payload as { z2m?: { request_id?: string } })?.z2m?.request_id;

// Resolve to friendly name for consistent checking
const friendlyName = resolveToFriendlyName(deviceTopic);

if (requestId) {
// Check if this is the sleepy test device (by friendly name)
const isSleepyDevice = friendlyName === "test/sleepy-device";

// Simulate backend processing delay (50-200ms, realistic for Zigbee)
const delay = 50 + Math.random() * 150;

if (isSleepyDevice) {
// Sleepy device: Return "pending" with final:true
// This simulates a battery-powered device that's asleep
// The command is queued and will be delivered when device wakes
this.#emit(
{
topic: `${deviceTopic}/response`,
payload: {
status: "pending",
z2m: { request_id: requestId, final: true },
data: payload,
},
},
delay,
);

// Simulate device waking up after 30 seconds and confirming the command
// This sends the state update as if the device finally received the command
const wakeupDelay = 30000 + Math.random() * 5000; // 30-35 seconds
const stateTopic = resolveToFriendlyName(deviceTopic);
const { z2m: _z2m, ...statePayload } = payload as Record<string, unknown>;
this.#emit(
{
topic: stateTopic,
payload: {
...statePayload,
last_seen: new Date().toISOString(),
},
},
wakeupDelay,
);
} else {
// Regular device: 90% success, 10% error for realistic testing
const isSuccess = Math.random() > 0.1;

if (isSuccess) {
// Success response
this.#emit(
{
topic: `${deviceTopic}/response`,
payload: {
status: "ok",
z2m: { request_id: requestId },
data: payload,
},
},
delay,
);

// Also send state update (always to friendly_name topic, like real Z2M)
const stateTopic = resolveToFriendlyName(deviceTopic);
const { z2m: _z2m, ...statePayload } = payload as Record<string, unknown>;
this.#emit(
{
topic: stateTopic,
payload: {
...statePayload,
last_seen: new Date().toISOString(),
},
},
delay + 50,
);
} else {
// Error response (simulate timeout or device error)
this.#emit(
{
topic: `${deviceTopic}/response`,
payload: {
status: "error",
z2m: { request_id: requestId },
error: {
code: "SEND_FAILED",
message: "Send failed",
zcl_status: 134,
},
},
},
delay,
);
}
}
}
}
} else if (msg.topic.startsWith("bridge/request/")) {
const payload = msg.payload as { transaction: string };
Expand Down
Loading