diff --git a/.storybook/storybook-mocks.ts b/.storybook/storybook-mocks.ts index 3c2daf774..34ed24c7d 100644 --- a/.storybook/storybook-mocks.ts +++ b/.storybook/storybook-mocks.ts @@ -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; @@ -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; + 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; + 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 }; diff --git a/mocks/bridgeDevices.ts b/mocks/bridgeDevices.ts index c7184537e..e62963965 100644 --- a/mocks/bridgeDevices.ts +++ b/mocks/bridgeDevices.ts @@ -4109,6 +4109,515 @@ export const BRIDGE_DEVICES: Message = { supported: true, type: "Router", }, + // Test device for UAT - composite with all feature types + { + date_code: "20250101", + definition: { + description: "Test device with ALL composite types: composite, list, climate, cover, fan, light, lock, switch", + exposes: [ + { + access: 7, + description: "UAT Test: Generic composite type with mixed feature types", + features: [ + { + access: 3, + description: "Numeric value with slider", + label: "Temperature setpoint", + name: "temperature_setpoint", + property: "temperature_setpoint", + type: "numeric", + unit: "°C", + value_max: 30, + value_min: 5, + value_step: 0.5, + }, + { + access: 3, + description: "Numeric value without slider (no min/max)", + label: "Custom value", + name: "custom_value", + property: "custom_value", + type: "numeric", + unit: "units", + }, + { + access: 3, + description: "Binary toggle option", + label: "Enabled", + name: "enabled", + property: "enabled", + type: "binary", + value_off: false, + value_on: true, + }, + { + access: 3, + description: "Binary with string values", + label: "Power mode", + name: "power_mode", + property: "power_mode", + type: "binary", + value_off: "OFF", + value_on: "ON", + }, + { + access: 3, + description: "Enum dropdown selection", + label: "Operating mode", + name: "operating_mode", + property: "operating_mode", + type: "enum", + values: ["auto", "manual", "eco", "boost", "away"], + }, + { + access: 3, + description: "Text input field", + label: "Device name", + name: "device_name", + property: "device_name", + type: "text", + }, + ], + label: "Test composite settings", + name: "test_composite", + property: "test_composite", + type: "composite", + }, + { + access: 7, + description: "List of numeric items for testing", + item_type: { + access: 3, + label: "Schedule time", + name: "schedule_time", + type: "numeric", + unit: "minutes", + value_max: 1440, + value_min: 0, + }, + label: "Schedule times", + length_max: 5, + length_min: 1, + name: "schedule_times", + property: "schedule_times", + type: "list", + }, + // === SPECIALIZED COMPOSITE TYPES FOR UAT TESTING === + // Climate: thermostat-like controls (setpoint, mode, running state) + { + access: 7, + description: "UAT Test: Climate/thermostat composite type", + label: "Test Climate", + name: "test_climate", + property: "test_climate", + features: [ + { + access: 5, + description: "Current temperature measured on the device", + label: "Local temperature", + name: "local_temperature", + property: "local_temperature", + type: "numeric", + unit: "°C", + }, + { + access: 7, + description: "Temperature setpoint for heating", + label: "Occupied heating setpoint", + name: "occupied_heating_setpoint", + property: "occupied_heating_setpoint", + type: "numeric", + unit: "°C", + value_max: 30, + value_min: 5, + value_step: 0.5, + }, + { + access: 7, + description: "System mode selection", + label: "System mode", + name: "system_mode", + property: "system_mode", + type: "enum", + values: ["off", "heat", "cool", "auto"], + }, + { + access: 5, + description: "Current running state of the thermostat", + label: "Running state", + name: "running_state", + property: "running_state", + type: "enum", + values: ["idle", "heat", "cool"], + }, + ], + type: "climate", + }, + // Cover: blinds/shutters controls (state, position, tilt) + { + access: 7, + description: "UAT Test: Cover/blinds composite type", + label: "Test Cover", + name: "test_cover", + property: "test_cover", + features: [ + { + access: 7, + description: "Open/close state of the cover", + label: "State", + name: "state", + property: "state", + type: "binary", + value_off: "CLOSE", + value_on: "OPEN", + }, + { + access: 7, + description: "Position of the cover (0=closed, 100=open)", + label: "Position", + name: "position", + property: "position", + type: "numeric", + unit: "%", + value_max: 100, + value_min: 0, + }, + { + access: 7, + description: "Tilt angle of the cover slats", + label: "Tilt", + name: "tilt", + property: "tilt", + type: "numeric", + unit: "%", + value_max: 100, + value_min: 0, + }, + ], + type: "cover", + }, + // Fan: fan controls (state, mode) + { + access: 7, + description: "UAT Test: Fan composite type", + label: "Test Fan", + name: "test_fan", + property: "test_fan", + features: [ + { + access: 7, + description: "On/off state of the fan", + label: "State", + name: "state", + property: "state", + type: "binary", + value_off: "OFF", + value_on: "ON", + }, + { + access: 7, + description: "Fan operating mode", + label: "Mode", + name: "mode", + property: "mode", + type: "enum", + values: ["off", "low", "medium", "high", "auto"], + }, + ], + type: "fan", + }, + // Light: light controls (state, brightness, color_temp) + { + access: 7, + description: "UAT Test: Light composite type", + label: "Test Light", + name: "test_light", + property: "test_light", + features: [ + { + access: 7, + description: "On/off state of the light", + label: "State", + name: "state", + property: "state", + type: "binary", + value_off: "OFF", + value_on: "ON", + }, + { + access: 7, + description: "Brightness of the light", + label: "Brightness", + name: "brightness", + property: "brightness", + type: "numeric", + value_max: 254, + value_min: 0, + }, + { + access: 7, + description: "Color temperature in mireds", + label: "Color temperature", + name: "color_temp", + property: "color_temp", + type: "numeric", + unit: "mired", + value_max: 500, + value_min: 153, + }, + ], + type: "light", + }, + // Lock: door lock controls (state) + { + access: 7, + description: "UAT Test: Lock composite type", + label: "Test Lock", + name: "test_lock", + property: "test_lock", + features: [ + { + access: 7, + description: "Lock/unlock state of the door lock", + label: "State", + name: "state", + property: "state", + type: "binary", + value_off: "UNLOCK", + value_on: "LOCK", + }, + ], + type: "lock", + }, + // Switch: on/off switch controls (state) + { + access: 7, + description: "UAT Test: Switch composite type", + label: "Test Switch", + name: "test_switch", + property: "test_switch", + features: [ + { + access: 7, + description: "On/off state of the switch", + label: "State", + name: "state", + property: "state", + type: "binary", + value_off: "OFF", + value_on: "ON", + value_toggle: "TOGGLE", + }, + ], + type: "switch", + }, + { + access: 1, + category: "diagnostic", + description: "Link quality (signal strength)", + label: "Linkquality", + name: "linkquality", + property: "linkquality", + type: "numeric", + unit: "lqi", + value_max: 255, + value_min: 0, + }, + ], + model: "TEST-COMPOSITE-001", + options: [], + supports_ota: false, + vendor: "Test Vendor", + }, + disabled: false, + endpoints: { + "1": { + bindings: [], + clusters: { + input: ["genBasic"], + output: [], + }, + configured_reportings: [], + scenes: [], + }, + }, + friendly_name: "test/composite-device", + ieee_address: "0xtest00composite01", + interview_completed: true, + interview_state: "SUCCESSFUL", + interviewing: false, + manufacturer: "Test Manufacturer", + model_id: "TEST-COMPOSITE-001", + network_address: 99999, + power_source: "Mains (single phase)", + software_build_id: "1.0.0", + supported: true, + type: "Router", + }, + // Sleepy device for testing Command Response API with queued commands (pending + final) + // Commands to this device return status: "pending" with z2m.final: true + // to simulate a battery-powered device that's asleep + { + date_code: "20250101", + definition: { + description: "Sleepy test device - commands return pending+final to simulate battery device", + exposes: [ + { + access: 1, + category: "diagnostic", + description: "Remaining battery in %", + label: "Battery", + name: "battery", + property: "battery", + type: "numeric", + unit: "%", + value_max: 100, + value_min: 0, + }, + // Generic composite (batched with Apply button) + { + access: 7, + description: "Sleepy composite - test Apply button with queued commands", + features: [ + { + access: 7, + description: "Temperature setpoint", + label: "Temperature setpoint", + name: "temperature_setpoint", + property: "temperature_setpoint", + type: "numeric", + unit: "°C", + value_max: 30, + value_min: 5, + value_step: 0.5, + }, + { + access: 7, + description: "Enable/disable", + label: "Enabled", + name: "enabled", + property: "enabled", + type: "binary", + value_off: false, + value_on: true, + }, + { + access: 7, + description: "Operating mode", + label: "Operating mode", + name: "operating_mode", + property: "operating_mode", + type: "enum", + values: ["comfort", "eco", "away", "boost"], + }, + ], + label: "Sleepy composite", + name: "sleepy_composite", + property: "sleepy_composite", + type: "composite", + }, + // List (batched with Apply button) + { + access: 7, + description: "Sleepy schedule list - test Apply button with queued commands", + item_type: { + access: 3, + label: "Schedule time", + name: "schedule_time", + type: "numeric", + unit: "minutes", + value_max: 1440, + value_min: 0, + }, + label: "Sleepy schedule", + length_max: 4, + length_min: 1, + name: "sleepy_schedule", + property: "sleepy_schedule", + type: "list", + }, + // === IMMEDIATE-MODE FEATURES (no Apply button) === + // These test how individual editors behave with sleepy devices + { + access: 7, + description: "Sleepy binary toggle - immediate mode test", + label: "Sleepy power", + name: "sleepy_power", + property: "sleepy_power", + type: "binary", + value_off: "OFF", + value_on: "ON", + }, + { + access: 7, + description: "Sleepy numeric slider - immediate mode test", + label: "Sleepy brightness", + name: "sleepy_brightness", + property: "sleepy_brightness", + type: "numeric", + value_max: 254, + value_min: 0, + }, + { + access: 7, + description: "Sleepy enum dropdown - immediate mode test", + label: "Sleepy mode", + name: "sleepy_mode", + property: "sleepy_mode", + type: "enum", + values: ["auto", "manual", "timer", "smart"], + }, + { + access: 7, + description: "Sleepy text input - immediate mode test", + label: "Sleepy name", + name: "sleepy_name", + property: "sleepy_name", + type: "text", + }, + { + access: 1, + category: "diagnostic", + description: "Link quality (signal strength)", + label: "Linkquality", + name: "linkquality", + property: "linkquality", + type: "numeric", + unit: "lqi", + value_max: 255, + value_min: 0, + }, + ], + model: "TEST-SLEEPY-001", + options: [], + supports_ota: false, + vendor: "Test Vendor", + }, + disabled: false, + endpoints: { + "1": { + bindings: [], + clusters: { + input: ["genBasic", "genPowerCfg"], + output: [], + }, + configured_reportings: [], + scenes: [], + }, + }, + friendly_name: "test/sleepy-device", + ieee_address: "0xtest00sleepy001", + interview_completed: true, + interview_state: "SUCCESSFUL", + interviewing: false, + manufacturer: "Test Manufacturer", + model_id: "TEST-SLEEPY-001", + network_address: 99998, + power_source: "Battery", + software_build_id: "1.0.0", + supported: true, + type: "EndDevice", + }, ], topic: "bridge/devices", }; diff --git a/mocks/bridgeInfo.ts b/mocks/bridgeInfo.ts index 71f151575..3aa0dfcef 100644 --- a/mocks/bridgeInfo.ts +++ b/mocks/bridgeInfo.ts @@ -133,6 +133,9 @@ export const BRIDGE_INFO: Message = { "0x2c1165fffeabe0ad": { friendly_name: "multi-sensor wiren", }, + "0xtest00composite01": { + friendly_name: "test/composite-device", + }, }, frontend: { package: "zigbee2mqtt-windfront", diff --git a/mocks/deviceState.ts b/mocks/deviceState.ts index 08da0df0e..7a5bb6450 100644 --- a/mocks/deviceState.ts +++ b/mocks/deviceState.ts @@ -309,4 +309,74 @@ export const DEVICE_STATES: Message[] = [ }, topic: "multi-sensor wiren", }, + { + payload: { + linkquality: 180, + // Generic composite test + test_composite: { + temperature_setpoint: 21.5, + custom_value: 42, + enabled: true, + power_mode: "ON", + operating_mode: "auto", + device_name: "Living Room", + }, + // List test + schedule_times: [360, 720, 1080], + // Climate type (thermostat controls) + test_climate: { + local_temperature: 22.5, + occupied_heating_setpoint: 21.0, + system_mode: "heat", + running_state: "idle", + }, + // Cover type (blinds/shutters) + test_cover: { + state: "OPEN", + position: 75, + tilt: 50, + }, + // Fan type + test_fan: { + state: "ON", + mode: "medium", + }, + // Light type + test_light: { + state: "ON", + brightness: 200, + color_temp: 300, + }, + // Lock type + test_lock: { + state: "LOCK", + }, + // Switch type + test_switch: { + state: "OFF", + }, + }, + topic: "test/composite-device", + }, + // Sleepy device for testing Command Response API with queued commands + { + payload: { + battery: 85, + linkquality: 120, + // Generic composite test (batched with Apply button) + sleepy_composite: { + temperature_setpoint: 20.0, + enabled: false, + operating_mode: "eco", + }, + // List test (batched with Apply button) + sleepy_schedule: [480, 1020], + // Immediate-mode features (no Apply button) + sleepy_power: "OFF", + sleepy_brightness: 128, + sleepy_mode: "auto", + sleepy_name: "Bedroom Sensor", + }, + topic: "test/sleepy-device", + }, ]; diff --git a/mocks/ws.ts b/mocks/ws.ts index 25a9582cc..64f9caca2 100644 --- a/mocks/ws.ts +++ b/mocks/ws.ts @@ -27,6 +27,20 @@ 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. + */ +const resolveToFriendlyName = (topic: string): string => { + if (topic.startsWith("0x")) { + const device = BRIDGE_DEVICES.payload.find((d) => d.ieee_address === topic); + if (device) { + return device.friendly_name; + } + } + return topic; +}; + const randomString = (len: number): string => Math.random() .toString(36) @@ -35,8 +49,9 @@ const randomString = (len: number): string => // const randomIntInclusive = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min; export function startServer() { + const port = Number.parseInt(process.env.MOCK_WS_PORT || "8579", 10); const wss = new WebSocketServer({ - port: 8579, + port, }); wss.on("connection", (ws) => { @@ -332,6 +347,8 @@ export function startServer() { } default: { if (msg.topic.endsWith("/set")) { + const deviceTopic = msg.topic.replace("/set", ""); + if ("command" in msg.payload) { setTimeout(() => { ws.send(JSON.stringify(BRIDGE_LOGGING_EXECUTE_COMMAND)); @@ -340,6 +357,99 @@ export function startServer() { setTimeout(() => { ws.send(JSON.stringify(BRIDGE_LOGGING_READ_ATTR)); }, 500); + } else { + // Command Response API: Send response on {device}/response topic + const requestId = msg.payload?.z2m?.request_id; + + if (requestId) { + // Check if this is the sleepy test device (resolve to friendly name first) + const friendlyName = resolveToFriendlyName(deviceTopic); + const isSleepyDevice = friendlyName === "test/sleepy-device"; + + // Simulate backend processing delay (50-200ms, realistic for Zigbee) + const delay = 50 + Math.random() * 150; + + setTimeout(() => { + 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 + ws.send( + JSON.stringify({ + topic: `${deviceTopic}/response`, + payload: { + status: "pending", + z2m: { request_id: requestId, final: true }, + data: msg.payload, + }, + }), + ); + + // 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 + setTimeout(() => { + const { z2m, ...statePayload } = msg.payload; + const stateTopic = resolveToFriendlyName(deviceTopic); + ws.send( + JSON.stringify({ + 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 + ws.send( + JSON.stringify({ + topic: `${deviceTopic}/response`, + payload: { + status: "ok", + z2m: { request_id: requestId }, + data: msg.payload, + }, + }), + ); + + // Also send state update (always to friendly_name topic, like real Z2M) + const { z2m, ...statePayload } = msg.payload; + const stateTopic = resolveToFriendlyName(deviceTopic); + ws.send( + JSON.stringify({ + topic: stateTopic, + payload: { + ...statePayload, + last_seen: new Date().toISOString(), + }, + }), + ); + } else { + // Error response (simulate timeout or device error) + ws.send( + JSON.stringify({ + 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/")) { sendResponseOK(); @@ -351,5 +461,5 @@ export function startServer() { }); }); - console.log("Started WebSocket server"); + console.log(`Started WebSocket server on port ${port}`); } diff --git a/src/components/dashboard-page/DashboardFeatureWrapper.tsx b/src/components/dashboard-page/DashboardFeatureWrapper.tsx index 3c24e57e4..e861d0d07 100644 --- a/src/components/dashboard-page/DashboardFeatureWrapper.tsx +++ b/src/components/dashboard-page/DashboardFeatureWrapper.tsx @@ -1,25 +1,202 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import startCase from "lodash/startCase.js"; -import type { PropsWithChildren } from "react"; +import { type PropsWithChildren, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import { type ColorFeature, type CommandResponse, FeatureAccessMode, type FeatureWithAnySubFeatures } from "../../types.js"; +import { generateTransactionId, registerDeviceSetCallback, unregisterDeviceSetCallback } from "../../websocket/WebSocketManager.js"; +import { FeatureReadingContext, type WriteState } from "../features/FeatureReadingContext.js"; import type { FeatureWrapperProps } from "../features/FeatureWrapper.js"; +import { READ_TIMEOUT_MS } from "../features/FeatureWrapper.js"; import { getFeatureIcon } from "../features/index.js"; +import StatusIndicator from "../features/StatusIndicator.js"; -export default function DashboardFeatureWrapper({ children, feature, deviceValue, endpointSpecific }: PropsWithChildren) { +function isColorFeature(feature: FeatureWithAnySubFeatures): feature is ColorFeature { + return feature.type === "composite" && (feature.name === "color_xy" || feature.name === "color_hs"); +} + +type SyncState = "idle" | "reading" | "queued" | "timed_out"; + +export default function DashboardFeatureWrapper({ + children, + feature, + deviceValue, + deviceStateVersion, + onRead, + endpointSpecific, + sourceIdx, +}: PropsWithChildren) { + const { t } = useTranslation("zigbee"); + + // Reading state management (mirrors FeatureWrapper) + const [syncState, setSyncState] = useState("idle"); + const timeoutRef = useRef | null>(null); + const lastDeviceValueRef = useRef(deviceValue); + const versionAtReadStartRef = useRef(undefined); + const activeReadTransactionRef = useRef(null); + + // Write state from child editors (RangeEditor, etc.) + const [writeState, setWriteState] = useState(); + const [onRetry, setOnRetry] = useState<(() => void) | undefined>(); + + // Read error details from Command Response API + const [readErrorDetails, setReadErrorDetails] = useState(); + + // Derive boolean values for context provider + const isReading = syncState === "reading"; + const isReadQueued = syncState === "queued"; + const readTimedOut = syncState === "timed_out"; + + // Feature icon and name // @ts-expect-error `undefined` is fine const unit = feature.unit as string | undefined; const [fi, fiClassName] = getFeatureIcon(feature.name, deviceValue, unit); - const { t } = useTranslation("zigbee"); + const isReadable = onRead !== undefined && (Boolean(feature.property && feature.access & FeatureAccessMode.GET) || isColorFeature(feature)); const featureName = feature.name === "state" ? feature.property : feature.name; + // Clear reading/timeout state when device responds + useEffect(() => { + if (syncState !== "idle") { + const valueChanged = deviceValue !== lastDeviceValueRef.current; + const versionChanged = + deviceStateVersion !== undefined && versionAtReadStartRef.current !== undefined && deviceStateVersion > versionAtReadStartRef.current; + + if (valueChanged || versionChanged) { + setSyncState("idle"); + setReadErrorDetails(undefined); + versionAtReadStartRef.current = undefined; + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + } + } + lastDeviceValueRef.current = deviceValue; + }, [deviceValue, deviceStateVersion, syncState]); + + // Cleanup timeout and transaction callback on unmount + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + if (activeReadTransactionRef.current && sourceIdx !== undefined) { + unregisterDeviceSetCallback(sourceIdx, activeReadTransactionRef.current); + activeReadTransactionRef.current = null; + } + }; + }, [sourceIdx]); + + // Sync callback - triggers a read from the device with Command Response API + const onSync = useCallback(() => { + if (feature.property && onRead) { + versionAtReadStartRef.current = deviceStateVersion; + setSyncState("reading"); + setReadErrorDetails(undefined); // Clear any previous error + + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + // Clean up previous read transaction if any + if (activeReadTransactionRef.current && sourceIdx !== undefined) { + unregisterDeviceSetCallback(sourceIdx, activeReadTransactionRef.current); + } + + // Generate transaction ID and register callback for Command Response API + let transactionId: string | undefined; + if (sourceIdx !== undefined) { + transactionId = generateTransactionId(sourceIdx); + activeReadTransactionRef.current = transactionId; + + registerDeviceSetCallback( + sourceIdx, + transactionId, + (response) => { + // Clear the active transaction ref + if (activeReadTransactionRef.current === transactionId) { + activeReadTransactionRef.current = null; + } + + // Clear timeout since we got a response + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + + switch (response.status) { + case "ok": + // Read succeeded - set to idle immediately + setSyncState("idle"); + setReadErrorDetails(undefined); + versionAtReadStartRef.current = undefined; + break; + case "pending": + // Command queued for sleepy device + setSyncState("queued"); + setReadErrorDetails(undefined); + break; + case "error": + // Read failed - capture error details + setSyncState("timed_out"); + setReadErrorDetails(response.error); + break; + case "partial": + // Partial success - set to idle + setSyncState("idle"); + setReadErrorDetails(undefined); + versionAtReadStartRef.current = undefined; + break; + } + }, + READ_TIMEOUT_MS, + ); + } + + timeoutRef.current = setTimeout(() => { + setSyncState("timed_out"); + timeoutRef.current = null; + // Clean up transaction callback on timeout + if (activeReadTransactionRef.current && sourceIdx !== undefined) { + unregisterDeviceSetCallback(sourceIdx, activeReadTransactionRef.current); + activeReadTransactionRef.current = null; + } + }, READ_TIMEOUT_MS); + + onRead({ [feature.property]: "" }, transactionId); + } + }, [feature.property, onRead, deviceStateVersion, sourceIdx]); + + // Determine if device value is unknown (null/undefined) + const isUnknown = deviceValue === null || deviceValue === undefined; + + // Memoize context value to prevent unnecessary re-renders + const contextValue = useMemo( + () => ({ + isReading, + isReadQueued, + readTimedOut, + readErrorDetails, + isUnknown, + writeState, + setWriteState, + onRetry, + setOnRetry, + onSync: isReadable ? onSync : undefined, + }), + [isReading, isReadQueued, readTimedOut, readErrorDetails, isUnknown, writeState, onRetry, isReadable, onSync], + ); + return ( -
- -
- {startCase(featureName)} - {!endpointSpecific && $.endpoint)}>{feature.endpoint ? ` (${feature.endpoint})` : null}} + +
+ + +
+ {startCase(featureName)} + {!endpointSpecific && $.endpoint)}>{feature.endpoint ? ` (${feature.endpoint})` : null}} +
+
{children}
-
{children}
-
+ ); } diff --git a/src/components/dashboard-page/DashboardItem.tsx b/src/components/dashboard-page/DashboardItem.tsx index e33611562..39867805f 100644 --- a/src/components/dashboard-page/DashboardItem.tsx +++ b/src/components/dashboard-page/DashboardItem.tsx @@ -17,12 +17,26 @@ const DashboardItem = ({ const { t } = useTranslation("zigbee"); const onCardChange = useCallback( - async (value: unknown) => { + async (value: unknown, transactionId?: string) => { + const payload = transactionId ? { ...(value as Record), z2m: { request_id: transactionId } } : value; await sendMessage<"{friendlyNameOrId}/set">( sourceIdx, // @ts-expect-error templated API endpoint `${device.ieee_address}/set`, - value, + payload, + ); + }, + [sourceIdx, device.ieee_address], + ); + + const onCardRead = useCallback( + async (value: Record, transactionId?: string) => { + const payload = transactionId ? { ...value, z2m: { request_id: transactionId } } : value; + await sendMessage<"{friendlyNameOrId}/get">( + sourceIdx, + // @ts-expect-error templated API endpoint + `${device.ieee_address}/get`, + payload, ); }, [sourceIdx, device.ieee_address], @@ -38,18 +52,17 @@ const DashboardItem = ({ device={device} deviceState={deviceState} onChange={onCardChange} + onRead={onCardRead} featureWrapperClass={DashboardFeatureWrapper} lastSeenConfig={lastSeenConfig} > -
- - onClick={async () => await NiceModal.show(RemoveDeviceModal, { sourceIdx, device, removeDevice })} - className="btn btn-outline btn-error btn-square btn-sm join-item tooltip-left" - title={t(($) => $.remove_device)} - > - - -
+ + onClick={async () => await NiceModal.show(RemoveDeviceModal, { sourceIdx, device, removeDevice })} + className="btn btn-outline btn-error btn-square btn-sm tooltip-left" + title={t(($) => $.remove_device)} + > + +
); diff --git a/src/components/device-page/tabs/Exposes.tsx b/src/components/device-page/tabs/Exposes.tsx index 8319acf2e..698206733 100644 --- a/src/components/device-page/tabs/Exposes.tsx +++ b/src/components/device-page/tabs/Exposes.tsx @@ -1,4 +1,4 @@ -import { useCallback } from "react"; +import { useCallback, useRef } from "react"; import { useTranslation } from "react-i18next"; import { useShallow } from "zustand/react/shallow"; import { useAppStore } from "../../../store.js"; @@ -17,25 +17,41 @@ export default function Exposes({ sourceIdx, device }: ExposesProps) { const { t } = useTranslation("common"); const deviceState = useAppStore(useShallow((state) => state.deviceStates[sourceIdx][device.friendly_name] ?? {})); + // Track device state updates - increments whenever deviceState object changes + // This allows child components to detect device responses even if specific values don't change + // IMPORTANT: Increment synchronously during render (not in useEffect) so children see the + // updated version on the same render cycle. Effects run after render, causing a race condition. + const deviceStateVersionRef = useRef(0); + const prevDeviceStateRef = useRef(null); + + if (deviceState !== prevDeviceStateRef.current) { + deviceStateVersionRef.current++; + } + prevDeviceStateRef.current = deviceState; + + const deviceStateVersion = deviceStateVersionRef.current; + const onChange = useCallback( - async (value: Record) => { + async (value: Record, transactionId?: string) => { + const payload = transactionId ? { ...value, z2m: { request_id: transactionId } } : value; await sendMessage<"{friendlyNameOrId}/set">( sourceIdx, // @ts-expect-error templated API endpoint `${device.ieee_address}/set`, - value, + payload, ); }, [sourceIdx, device.ieee_address], ); const onRead = useCallback( - async (value: Record) => { + async (value: Record, transactionId?: string) => { + const payload = transactionId ? { ...value, z2m: { request_id: transactionId } } : value; await sendMessage<"{friendlyNameOrId}/get">( sourceIdx, // @ts-expect-error templated API endpoint `${device.ieee_address}/get`, - value, + payload, ); }, [sourceIdx, device.ieee_address], @@ -49,10 +65,12 @@ export default function Exposes({ sourceIdx, device }: ExposesProps) { feature={expose} device={device} deviceState={deviceState} + deviceStateVersion={deviceStateVersion} onChange={onChange} onRead={onRead} featureWrapperClass={FeatureWrapper} parentFeatures={[]} + sourceIdx={sourceIdx} /> ))} diff --git a/src/components/device/DeviceCard.tsx b/src/components/device/DeviceCard.tsx index 5b752cd19..c499f3331 100644 --- a/src/components/device/DeviceCard.tsx +++ b/src/components/device/DeviceCard.tsx @@ -20,6 +20,7 @@ type Props = Omit, "feature" features: FeatureWithAnySubFeatures[]; lastSeenConfig: LastSeenConfig; endpoint?: number; + headerAction?: React.ReactNode; }>; const DeviceCard = memo( @@ -35,6 +36,7 @@ const DeviceCard = memo( features, featureWrapperClass, children, + headerAction, }: Props) => { const { t } = useTranslation(["zigbee", "devicePage"]); const endpointName = endpoint != null ? device.endpoints[endpoint]?.name : undefined; @@ -58,13 +60,14 @@ const DeviceCard = memo( minimal={true} parentFeatures={[]} endpointSpecific={endpointSpecific} + sourceIdx={sourceIdx} />, ); } } return elements; - }, [endpointName, device, endpoint, deviceState, features, featureWrapperClass, onChange, onRead]); + }, [endpointName, device, endpoint, deviceState, features, featureWrapperClass, onChange, onRead, sourceIdx]); return ( <> @@ -74,7 +77,7 @@ const DeviceCard = memo( {/* disabled always false because dashboard does not contain disabled devices */} -
+
{device.friendly_name} {endpoint != null ? ` (${t(($) => $.endpoint)}: ${endpointName ? `${endpointName} / ` : ""}${endpoint})` : ""} @@ -93,6 +96,7 @@ const DeviceCard = memo( )}
+ {headerAction &&
{headerAction}
}
{displayedFeatures} diff --git a/src/components/editors/ColorEditor.tsx b/src/components/editors/ColorEditor.tsx index a7adc0580..95890d909 100644 --- a/src/components/editors/ColorEditor.tsx +++ b/src/components/editors/ColorEditor.tsx @@ -3,6 +3,7 @@ import { type DetailedHTMLProps, type FocusEvent, type InputHTMLAttributes, + type KeyboardEvent, memo, useCallback, useEffect, @@ -22,13 +23,18 @@ import { SUPPORTED_GAMUTS, type ZigbeeColor, } from "./index.js"; +import { useCommandFeedback } from "./useCommandFeedback.js"; type ColorEditorProps = Omit, "onChange" | "value"> & { value: AnyColor; format: ColorFormat; gamut: keyof typeof SUPPORTED_GAMUTS; - onChange(color: AnyColor): Promise; + onChange(color: AnyColor, transactionId?: string): void; minimal?: boolean; + /** When true, changes are batched (Apply button) - only show editing state */ + batched?: boolean; + /** Source index for transaction ID generation */ + sourceIdx?: number; }; type ColorInputProps = DetailedHTMLProps, HTMLInputElement> & { @@ -46,7 +52,7 @@ const ColorInput = memo(({ label, ...rest }: ColorInputProps) => ( )); -const ColorEditor = memo(({ onChange, value: initialValue = {} as AnyColor, format, gamut, minimal }: ColorEditorProps) => { +const ColorEditor = memo(({ onChange, value: initialValue = {} as AnyColor, format, gamut, minimal, batched, sourceIdx }: ColorEditorProps) => { const [gamutKey, setGamutKey] = useState(gamut in SUPPORTED_GAMUTS ? gamut : "cie1931"); const selectedGamut = SUPPORTED_GAMUTS[gamutKey]; const [color, setColor] = useState(convertToColor(initialValue, format, selectedGamut)); @@ -58,9 +64,18 @@ const ColorEditor = memo(({ onChange, value: initialValue = {} as AnyColor, form hex: false, }); + // Track the value we want to send (for comparison with device response) + const colorToSend = useMemo(() => convertFromColor(color, format), [color, format]); + + const { send } = useCommandFeedback({ sourceIdx, batched }); + + // Sync color from device state whenever it changes. + // Value (device truth) and status dot are decoupled: + // - Value: always shows device state when available + // - Dot: shows command status (pending/ok/error) independently + // User's optimistic choice is shown until device state arrives. useEffect(() => { const newColor = convertToColor(initialValue, format, selectedGamut); - setColor(newColor); setColorString(convertColorToString(newColor)); }, [initialValue, format, selectedGamut]); @@ -72,7 +87,6 @@ const ColorEditor = memo(({ onChange, value: initialValue = {} as AnyColor, form useEffect(() => { if (!inputStates.color_xy) { const newColorString = convertXyYToString(color.color_xy); - setColorString((colorString) => ({ ...colorString, color_xy: newColorString })); } }, [inputStates.color_xy, color.color_xy]); @@ -80,7 +94,6 @@ const ColorEditor = memo(({ onChange, value: initialValue = {} as AnyColor, form useEffect(() => { if (!inputStates.color_hs) { const newColorString = convertHsvToString(color.color_hs); - setColorString((colorString) => ({ ...colorString, color_hs: newColorString })); } }, [inputStates.color_hs, color.color_hs]); @@ -88,7 +101,6 @@ const ColorEditor = memo(({ onChange, value: initialValue = {} as AnyColor, form useEffect(() => { if (!inputStates.color_rgb) { const newColorString = convertRgbToString(color.color_rgb); - setColorString((colorString) => ({ ...colorString, color_rgb: newColorString })); } }, [inputStates.color_rgb, color.color_rgb]); @@ -96,7 +108,6 @@ const ColorEditor = memo(({ onChange, value: initialValue = {} as AnyColor, form useEffect(() => { if (!inputStates.hex) { const newColorString = convertHexToString(color.hex); - setColorString((colorString) => ({ ...colorString, hex: newColorString })); } }, [inputStates.hex, color.hex]); @@ -131,12 +142,14 @@ const ColorEditor = memo(({ onChange, value: initialValue = {} as AnyColor, form [color.color_hs, selectedGamut], ); + // Handler for color text inputs - updates local value only, sends on blur/Enter const onInputChange = useCallback( (e: ChangeEvent) => { const { value, name } = e.target; setColorString((currentColorString) => ({ ...currentColorString, [name]: value })); - setColor(convertStringToColor(value, name as ColorFormat, selectedGamut)); + const newColor = convertStringToColor(value, name as ColorFormat, selectedGamut); + setColor(newColor); }, [selectedGamut], ); @@ -148,21 +161,31 @@ const ColorEditor = memo(({ onChange, value: initialValue = {} as AnyColor, form const onInputBlur = useCallback( (e: FocusEvent) => { setInputStates((states) => ({ ...states, [e.target.name]: false })); - onChange(convertFromColor(color, format)); + send((txId) => onChange(colorToSend, txId)); }, - [color, format, onChange], + [colorToSend, send, onChange], + ); + + const onInputKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Enter") { + send((txId) => onChange(colorToSend, txId)); + e.currentTarget.blur(); + } + }, + [colorToSend, send, onChange], ); const onRangeSubmit = useCallback(() => { - onChange(convertFromColor(color, format)); - }, [color, format, onChange]); + send((txId) => onChange(colorToSend, txId)); + }, [colorToSend, send, onChange]); const hueBackgroundColor = useMemo(() => `hsl(${color.color_hs[0]}, 100%, 50%)`, [color.color_hs[0]]); return ( <> -
-
+
+
-
-
- -
+
+
{!minimal && ( -
- +
+
)} diff --git a/src/components/editors/EnumEditor.tsx b/src/components/editors/EnumEditor.tsx index 285346248..976e0bf61 100644 --- a/src/components/editors/EnumEditor.tsx +++ b/src/components/editors/EnumEditor.tsx @@ -1,7 +1,8 @@ -import { type ChangeEvent, memo, useCallback } from "react"; +import { type ChangeEvent, memo, useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import Button from "../Button.js"; import DisplayValue from "../value-decorators/DisplayValue.js"; +import { useCommandFeedback } from "./useCommandFeedback.js"; export type ValueWithLabel = { value: number; @@ -13,62 +14,142 @@ export type ValueWithLabelOrPrimitive = ValueWithLabel | number | string; type EnumProps = { value?: ValueWithLabelOrPrimitive; - onChange(value: unknown): Promise; + onChange(value: unknown, transactionId?: string): void; values: ValueWithLabelOrPrimitive[]; minimal?: boolean; + /** When true, parent manages state machine (e.g., when used as sub-component in RangeEditor) */ + controlled?: boolean; + /** When true, changes are batched (Apply button) - only show editing state */ + batched?: boolean; + /** Source index for transaction ID generation */ + sourceIdx?: number; }; function isPrimitive(step?: ValueWithLabelOrPrimitive | null): step is number | string { return typeof step !== "object"; } +function getValueForComparison(v: ValueWithLabelOrPrimitive | undefined): unknown { + if (v === undefined) return undefined; + return isPrimitive(v) ? v : v.value; +} + const EnumEditor = memo((props: EnumProps) => { - const { onChange, values, value, minimal } = props; + const { onChange, values, value, minimal, controlled, batched, sourceIdx } = props; const { t } = useTranslation("common"); const primitiveValue = isPrimitive(value); + const currentValueForComparison = getValueForComparison(value); - const onSelectChange = useCallback( - async (e: ChangeEvent) => { - const selectedValue = values.find((v) => (isPrimitive(v) ? v === e.target.value : v.value === Number.parseInt(e.target.value, 10))); + const [selectedValue, setSelectedValue] = useState(currentValueForComparison); + const { send } = useCommandFeedback({ sourceIdx, batched }); - await onChange(selectedValue); + // Sync selected value from device state whenever it changes. + // Value (device truth) and status dot are decoupled: + // - Value: always shows device state when available + // - Dot: shows command status (pending/ok/error) independently + // User's optimistic choice is shown until device state arrives. + useEffect(() => { + setSelectedValue(currentValueForComparison); + }, [currentValueForComparison]); + + const handleChange = useCallback( + (selectedItem: ValueWithLabelOrPrimitive) => { + const newValue = isPrimitive(selectedItem) ? selectedItem : selectedItem.value; + setSelectedValue(newValue); + + if (controlled) { + onChange(selectedItem); + } else { + send((txId) => onChange(newValue, txId)); + } }, - [values, onChange], + [controlled, onChange, send], ); - return minimal ? ( - - ) : ( -
- {values.map((v) => { - const primitive = isPrimitive(v); - const current = primitive ? v === value : v.value === (primitiveValue ? value : value?.value); + const onButtonClick = useCallback((item: ValueWithLabelOrPrimitive) => handleChange(item), [handleChange]); - return ( - - key={primitive ? v : v.name} - className={`btn btn-outline btn-primary btn-sm join-item${current ? " btn-active" : ""}`} - onClick={onChange} - item={primitive ? v : v.value} - title={primitive ? `${v}` : v.description} - > - {primitive ? : v.name} - - ); - })} + // Controlled mode - parent manages feedback + if (controlled) { + return minimal ? ( + + ) : ( +
+ {values.map((v) => { + const primitive = isPrimitive(v); + const current = primitive ? v === value : v.value === (primitiveValue ? value : value?.value); + return ( + + key={primitive ? v : v.name} + className={`btn btn-outline btn-primary btn-sm join-item${current ? " btn-active" : ""}`} + onClick={onButtonClick} + item={primitive ? v : v.value} + title={primitive ? `${v}` : v.description} + > + {primitive ? : v.name} + + ); + })} +
+ ); + } + + // Standalone mode with command feedback + return ( +
+ {minimal ? ( + + ) : ( +
+ {values.map((v) => { + const primitive = isPrimitive(v); + const itemValue = primitive ? v : v.value; + const current = itemValue === selectedValue; + return ( + + key={primitive ? v : v.name} + className={`btn btn-outline btn-primary btn-sm join-item${current ? " btn-active" : ""}`} + onClick={onButtonClick} + item={primitive ? v : v.value} + title={primitive ? `${v}` : v.description} + > + {primitive ? : v.name} + + ); + })} +
+ )}
); }); diff --git a/src/components/editors/RangeEditor.tsx b/src/components/editors/RangeEditor.tsx index afd38d8c6..ada32c440 100644 --- a/src/components/editors/RangeEditor.tsx +++ b/src/components/editors/RangeEditor.tsx @@ -1,19 +1,26 @@ import { type ChangeEvent, type InputHTMLAttributes, memo, useCallback, useEffect, useState } from "react"; import EnumEditor, { type ValueWithLabelOrPrimitive } from "./EnumEditor.js"; +import { useCommandFeedback } from "./useCommandFeedback.js"; type RangeProps = Omit, "onChange" | "value"> & { value: number | ""; unit?: string; - onChange(value: number | null): Promise; + onChange(value: number | null, transactionId?: string): void; steps?: ValueWithLabelOrPrimitive[]; minimal?: boolean; + /** When true, changes are batched (Apply button) - only show editing state */ + batched?: boolean; + /** Source index for transaction ID generation */ + sourceIdx?: number; }; const RangeEditor = memo((props: RangeProps) => { - const { onChange, value, min, max, unit, steps, minimal, ...rest } = props; + const { onChange, value, min, max, unit, steps, minimal, batched, sourceIdx, ...rest } = props; const [currentValue, setCurrentValue] = useState(value); const showRange = min != null && max != null; + const { send } = useCommandFeedback({ sourceIdx, batched }); + useEffect(() => { setCurrentValue(value); }, [value]); @@ -23,17 +30,38 @@ const RangeEditor = memo((props: RangeProps) => { }, []); const onSubmit = useCallback( - async (e) => { - if (!e.target.validationMessage) { - await onChange(currentValue === "" ? null : currentValue); + (e: React.SyntheticEvent) => { + if (!e.currentTarget.validationMessage) { + send((txId) => onChange(currentValue === "" ? null : currentValue, txId)); + } + }, + [currentValue, onChange, send], + ); + + const onKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + onSubmit(e); } }, - [currentValue, onChange], + [onSubmit], + ); + + const handleEnumChange = useCallback( + (newValue: unknown) => { + if (typeof newValue === "number") { + setCurrentValue(newValue); + send((txId) => onChange(newValue, txId)); + } + }, + [onChange, send], ); return (
- {!minimal && steps ? : null} + {!minimal && steps ? ( + + ) : null} {showRange ? (
{ max={max} type="range" className="range range-xs range-primary validator" - value={currentValue} + value={currentValue === "" ? (typeof min === "number" ? min : 0) : currentValue} onChange={onInputChange} onTouchEnd={onSubmit} onMouseUp={onSubmit} @@ -63,6 +91,7 @@ const RangeEditor = memo((props: RangeProps) => { value={currentValue} onChange={onInputChange} onBlur={onSubmit} + onKeyDown={onKeyDown} min={min} max={max} {...rest} diff --git a/src/components/editors/TextEditor.tsx b/src/components/editors/TextEditor.tsx index b033bbc93..1fcd1f33c 100644 --- a/src/components/editors/TextEditor.tsx +++ b/src/components/editors/TextEditor.tsx @@ -1,27 +1,69 @@ -import { type InputHTMLAttributes, memo, useEffect, useState } from "react"; +import { type InputHTMLAttributes, memo, useCallback, useEffect, useState } from "react"; +import { useCommandFeedback } from "./useCommandFeedback.js"; type TextProps = Omit, "onChange"> & { value: string; - onChange(value: string): Promise; + onChange(value: string, transactionId?: string): void; + minimal?: boolean; + /** When true, changes are batched (Apply button) - only show editing state */ + batched?: boolean; + /** Parent's local change indicator (for batched mode) */ + hasLocalChange?: boolean; + /** Source index for transaction ID generation */ + sourceIdx?: number; }; const TextEditor = memo((props: TextProps) => { - const { onChange, value, ...rest } = props; + const { onChange, value, minimal, batched, hasLocalChange, sourceIdx, ...rest } = props; const [currentValue, setCurrentValue] = useState(value); + const [isEditing, setIsEditing] = useState(false); + const { send } = useCommandFeedback({ sourceIdx, batched }); + + // Sync currentValue from device value when not editing useEffect(() => { - setCurrentValue(value); - }, [value]); + if (!isEditing) setCurrentValue(value); + }, [value, isEditing]); + + const onInputChange = useCallback((e: React.ChangeEvent) => { + setCurrentValue(e.target.value); + }, []); + + const onFocus = useCallback(() => setIsEditing(true), []); + + const onBlur = useCallback( + (e: React.FocusEvent) => { + if (!e.target.validationMessage) { + send((txId) => onChange(currentValue, txId)); + } + setIsEditing(false); + }, + [currentValue, onChange, send], + ); + + const onKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.currentTarget.validationMessage) { + send((txId) => onChange(currentValue, txId)); + e.currentTarget.blur(); + } + }, + [currentValue, onChange, send], + ); return ( - setCurrentValue(e.target.value)} - onBlur={(e) => !e.target.validationMessage && onChange(currentValue)} - {...rest} - /> +
+ +
); }); diff --git a/src/components/editors/useCommandFeedback.ts b/src/components/editors/useCommandFeedback.ts new file mode 100644 index 000000000..237d22b04 --- /dev/null +++ b/src/components/editors/useCommandFeedback.ts @@ -0,0 +1,135 @@ +import { useCallback, useEffect, useRef } from "react"; +import type { CommandResponse } from "../../types.js"; +import { generateTransactionId, registerDeviceSetCallback, unregisterDeviceSetCallback } from "../../websocket/WebSocketManager.js"; +import { useFeatureReading, type WriteState } from "../features/FeatureReadingContext.js"; + +const SUCCESS_MS = 2000; +const TIMEOUT_MS = 10000; + +/** Command status - matches backend response status with 'idle' for no pending request */ +export type CommandStatus = "idle" | "pending" | "ok" | "error" | "queued" | "partial"; + +/** + * Minimal hook for command feedback - replaces useEditorState's 632-line state machine. + * Leverages existing WebSocketManager callback infrastructure for response handling. + * Also registers retry callback via context for SyncRetryButton to use. + */ +export function useCommandFeedback( + options: { + sourceIdx?: number; + batched?: boolean; + onSuccess?: () => void; + /** Called when command is queued for sleepy device (pending + final). Receives the sent payload for optimistic updates. */ + onQueued?: (sentPayload: unknown) => void; + } = {}, +) { + const { sourceIdx, batched, onSuccess, onQueued } = options; + const sentPayloadRef = useRef(undefined); + const { setWriteState, setOnRetry } = useFeatureReading(); + const statusRef = useRef("idle"); + const errorRef = useRef(undefined); + const failedRef = useRef | undefined>(undefined); + const txRef = useRef(null); + const timerRef = useRef | undefined>(undefined); + const lastSendFnRef = useRef<((txId?: string) => void) | null>(null); + + const update = useCallback( + (status: CommandStatus, err?: CommandResponse["error"], failed?: Record) => { + statusRef.current = status; + errorRef.current = err; + failedRef.current = failed; + if (batched || !setWriteState) return; + const state: WriteState = { + isPending: status === "pending", + isConfirmed: status === "ok", + isError: status === "error", + isTimedOut: false, + isQueued: status === "queued", + isPartial: status === "partial", + errorDetails: err, + ...(failed && { failedAttributes: failed }), + }; + setWriteState(state); + }, + [batched, setWriteState], + ); + + useEffect( + () => () => { + if (timerRef.current) clearTimeout(timerRef.current); + if (txRef.current && sourceIdx !== undefined) unregisterDeviceSetCallback(sourceIdx, txRef.current); + }, + [sourceIdx], + ); + + const send = useCallback( + (sendFn: (txId?: string) => void, sentPayload?: unknown) => { + sentPayloadRef.current = sentPayload; + if (batched) { + sendFn(); + return; + } + // Store for retry + lastSendFnRef.current = sendFn; + if (timerRef.current) clearTimeout(timerRef.current); + update("pending"); + if (sourceIdx === undefined) { + sendFn(); + return; + } + if (txRef.current) unregisterDeviceSetCallback(sourceIdx, txRef.current); + const txId = generateTransactionId(sourceIdx); + txRef.current = txId; + registerDeviceSetCallback( + sourceIdx, + txId, + (r) => { + if (txRef.current === txId) txRef.current = null; + if (r.status === "ok") { + update("ok"); + onSuccess?.(); // Safe to clear state - backend will publish state update + timerRef.current = setTimeout(() => update("idle"), SUCCESS_MS); + } else if (r.status === "error") update("error", r.error); + else if (r.status === "partial") update("partial", undefined, r.failed); + else if (r.status === "pending") { + update("queued"); + // If final=true, command was transmitted but can't confirm delivery (sleepy device). + // Call onQueued with sent payload for optimistic update, then onSuccess to clear local state. + // Note: This optimistic state is in-memory only - see pr-frontend.md for restart limitation. + // + // Future enhancement (pr-backend.md A): Backend could send final:false initially, then + // final:true when device wakes and confirms. This would enable green dot confirmation + // for sleepy devices. Currently we just clear to idle after 2s since no follow-up arrives. + if (r.z2m?.final) { + onQueued?.(sentPayloadRef.current); + onSuccess?.(); + timerRef.current = setTimeout(() => update("idle"), SUCCESS_MS); + } + } + }, + TIMEOUT_MS, + ); + sendFn(txId); + }, + [batched, sourceIdx, update, onSuccess, onQueued], + ); + + // Register retry callback - re-sends the last command + // Note: setOnRetry(() => fn) wraps fn because setState(fn) treats fn as an updater + useEffect(() => { + if (batched || !setOnRetry) return; + setOnRetry(() => () => { + if (lastSendFnRef.current) { + send(lastSendFnRef.current); + } + }); + return () => setOnRetry(() => undefined); + }, [batched, setOnRetry, send]); + + const reset = useCallback(() => { + if (timerRef.current) clearTimeout(timerRef.current); + update("idle"); + }, [update]); + + return { status: statusRef.current, errorDetails: errorRef.current, failedAttributes: failedRef.current, send, reset }; +} diff --git a/src/components/features/Binary.tsx b/src/components/features/Binary.tsx index 6163f6cf4..a07fed1cc 100644 --- a/src/components/features/Binary.tsx +++ b/src/components/features/Binary.tsx @@ -1,9 +1,10 @@ import { faQuestion } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { type ChangeEvent, memo, useCallback } from "react"; +import { type ChangeEvent, memo, useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { type BinaryFeature, FeatureAccessMode } from "../../types.js"; import Button from "../Button.js"; +import { useCommandFeedback } from "../editors/useCommandFeedback.js"; import DisplayValue from "../value-decorators/DisplayValue.js"; import BaseViewer from "./BaseViewer.js"; import type { BaseFeatureProps } from "./index.js"; @@ -17,16 +18,42 @@ const Binary = memo((props: BinaryProps) => { deviceValue, onChange, minimal, + batched, + sourceIdx, } = props; const { t } = useTranslation("zigbee"); - const onButtonClick = useCallback((value: string | boolean) => onChange(property ? { [property]: value } : value), [property, onChange]); + + // Track selected value for optimistic UI updates + const [selectedValue, setSelectedValue] = useState( + deviceValue === valueOn ? valueOn : deviceValue === valueOff ? valueOff : null, + ); + + const { send } = useCommandFeedback({ sourceIdx, batched }); + + // Sync selected value from device state whenever it changes. + // Value (device truth) and status dot are decoupled: + // - Value: always shows device state when available + // - Dot: shows command status (pending/ok/error) independently + // User's optimistic choice is shown until device state arrives. + useEffect(() => { + setSelectedValue(deviceValue === valueOn ? valueOn : deviceValue === valueOff ? valueOff : null); + }, [deviceValue, valueOn, valueOff]); + + const onButtonClick = useCallback( + (value: string | boolean) => { + setSelectedValue(value); + send((txId) => onChange(property ? { [property]: value } : value, txId)); + }, + [property, onChange, send], + ); + const onCheckboxChange = useCallback( - async (e: ChangeEvent) => { + (e: ChangeEvent) => { const checkedValue = e.target.checked ? valueOn : valueOff; - - await onChange(property ? { [property]: checkedValue } : checkedValue); + setSelectedValue(checkedValue); + send((txId) => onChange(property ? { [property]: checkedValue } : checkedValue, txId)); }, - [valueOn, valueOff, property, onChange], + [valueOn, valueOff, property, onChange, send], ); if (access & FeatureAccessMode.SET) { @@ -34,21 +61,21 @@ const Binary = memo((props: BinaryProps) => { const showOnOffButtons = !minimal || (minimal && !valueExists); return ( -
+
{showOnOffButtons && ( - className="btn btn-link" item={valueOff} onClick={onButtonClick}> + className="btn btn-link p-0 min-h-0 h-auto" item={valueOff} onClick={onButtonClick}> )} {valueExists ? ( - + ) : ( $.unknown)}> )} {showOnOffButtons && ( - className="btn btn-link" item={valueOn} onClick={onButtonClick}> + className="btn btn-link p-0 min-h-0 h-auto" item={valueOn} onClick={onButtonClick}> )} diff --git a/src/components/features/Color.tsx b/src/components/features/Color.tsx index 36179b84d..ce43636c7 100644 --- a/src/components/features/Color.tsx +++ b/src/components/features/Color.tsx @@ -13,6 +13,8 @@ const Color = memo((props: ColorProps) => { feature: { name, features, property }, onChange, minimal, + batched, + sourceIdx, } = props; const value = useMemo(() => { @@ -38,13 +40,13 @@ const Color = memo((props: ColorProps) => { }, [device.definition]); const onEditorChange = useCallback( - async (color: AnyColor) => { - await onChange({ [property ?? "color"]: color }); - }, + (color: AnyColor, transactionId?: string) => onChange({ [property ?? "color"]: color }, transactionId), [property, onChange], ); - return ; + return ( + + ); }); export default Color; diff --git a/src/components/features/Enum.tsx b/src/components/features/Enum.tsx index 342e0ec6c..d0d274dc2 100644 --- a/src/components/features/Enum.tsx +++ b/src/components/features/Enum.tsx @@ -14,12 +14,14 @@ const Enum = memo((props: EnumProps) => { feature: { access = FeatureAccessMode.SET, values, property }, deviceValue, minimal, + batched, + sourceIdx, } = props; if (access & FeatureAccessMode.SET) { return ( onChange(property ? { [property]: value } : value)} + onChange={(value, transactionId) => onChange(property ? { [property]: value } : value, transactionId)} values={values} value={ deviceValue != null && (typeof deviceValue === "string" || typeof deviceValue === "number" || typeof deviceValue === "object") @@ -27,6 +29,8 @@ const Enum = memo((props: EnumProps) => { : "" } minimal={minimal || values.length > BIG_ENUM_SIZE} + batched={batched} + sourceIdx={sourceIdx} /> ); } diff --git a/src/components/features/Feature.tsx b/src/components/features/Feature.tsx index 894e5528f..51335bf9b 100644 --- a/src/components/features/Feature.tsx +++ b/src/components/features/Feature.tsx @@ -23,20 +23,28 @@ import Text from "./Text.js"; interface FeatureProps extends Omit, "onChange"> { feature: FeatureWithAnySubFeatures; device: Device; - onChange(value: Record): Promise; - onRead?(value: Record): Promise; + onChange(value: Record, transactionId?: string): void; + onRead?(value: Record, transactionId?: string): void; featureWrapperClass: FunctionComponent>; minimal?: boolean; endpointSpecific?: boolean; steps?: ValueWithLabelOrPrimitive[]; parentFeatures: FeatureWithAnySubFeatures[]; deviceState: DeviceState | Zigbee2MQTTDeviceOptions; + deviceStateVersion?: number; + /** When true, changes are batched and submitted via Apply button */ + batched?: boolean; + /** When true, this feature has a local change pending Apply */ + hasLocalChange?: boolean; + /** Source index for transaction ID generation */ + sourceIdx?: number; } export default function Feature({ feature, device, deviceState, + deviceStateVersion, steps, onRead, onChange, @@ -44,25 +52,32 @@ export default function Feature({ minimal, endpointSpecific, parentFeatures, + batched, + hasLocalChange, + sourceIdx, }: FeatureProps): JSX.Element { const deviceValue = feature.property ? deviceState[feature.property] : deviceState; const key = getFeatureKey(feature); const genericParams = { device, deviceValue, + deviceStateVersion, onChange, onRead, featureWrapperClass: FeatureWrapper, minimal, endpointSpecific, parentFeatures, + batched, + hasLocalChange, + sourceIdx, }; - const wrapperParams = { feature, onRead, deviceValue, parentFeatures, endpointSpecific }; + const wrapperParams = { feature, onRead, deviceValue, deviceStateVersion, parentFeatures, endpointSpecific, sourceIdx }; switch (feature.type) { case "binary": { return ( - + ); diff --git a/src/components/features/FeatureReadingContext.tsx b/src/components/features/FeatureReadingContext.tsx new file mode 100644 index 000000000..130fbf54c --- /dev/null +++ b/src/components/features/FeatureReadingContext.tsx @@ -0,0 +1,60 @@ +import { createContext, useContext } from "react"; +import type { CommandResponse } from "../../types.js"; + +// Write state reported by editors (RangeEditor, etc.) +// Note: Value and status are decoupled - value shows device truth (from state channel), +// status dot shows command outcome (from response channel). No "conflict" detection needed. +export type WriteState = { + isPending: boolean; + isConfirmed: boolean; + isTimedOut: boolean; + /** Backend returned error response (SEND_FAILED, TIMEOUT, etc.) */ + isError?: boolean; + // States from Command Response API + isQueued?: boolean; + isPartial?: boolean; + // Error details + errorDetails?: CommandResponse["error"]; + failedAttributes?: Record; + elapsedMs?: number; +}; + +export type FeatureReadingState = { + // Read state (set by FeatureWrapper) + isReading: boolean; + isReadQueued: boolean; + readTimedOut: boolean; + /** Error details from failed read (Command Response API) */ + readErrorDetails?: CommandResponse["error"]; + + // Value state (set by FeatureWrapper) + isUnknown: boolean; + + // Write state (set by editors like RangeEditor) + writeState: WriteState | undefined; + setWriteState: ((state: WriteState | undefined) => void) | undefined; + + // Retry callback (set by editor, called by FeatureWrapper button) + onRetry: (() => void) | undefined; + setOnRetry: ((fn: (() => void) | undefined) => void) | undefined; + + // Sync callback (provided by FeatureWrapper, called by editors for read) + onSync: (() => void) | undefined; +}; + +export const FeatureReadingContext = createContext({ + isReading: false, + isReadQueued: false, + readTimedOut: false, + readErrorDetails: undefined, + isUnknown: false, + writeState: undefined, + setWriteState: undefined, + onRetry: undefined, + setOnRetry: undefined, + onSync: undefined, +}); + +export function useFeatureReading() { + return useContext(FeatureReadingContext); +} diff --git a/src/components/features/FeatureSubFeatures.tsx b/src/components/features/FeatureSubFeatures.tsx index fffabc491..123a22dde 100644 --- a/src/components/features/FeatureSubFeatures.tsx +++ b/src/components/features/FeatureSubFeatures.tsx @@ -1,9 +1,11 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import type { Zigbee2MQTTDeviceOptions } from "zigbee2mqtt"; +import { useAppStore } from "../../store.js"; import type { DeviceState, FeatureWithAnySubFeatures } from "../../types.js"; import Button from "../Button.js"; import type { ValueWithLabelOrPrimitive } from "../editors/EnumEditor.js"; +import { useCommandFeedback } from "../editors/useCommandFeedback.js"; import Feature from "./Feature.js"; import { type BaseFeatureProps, getFeatureKey } from "./index.js"; @@ -12,6 +14,7 @@ interface FeatureSubFeaturesProps extends Omit; parentFeatures?: FeatureWithAnySubFeatures[]; deviceState: DeviceState | Zigbee2MQTTDeviceOptions; + deviceStateVersion?: number; endpointSpecific?: boolean; } @@ -43,10 +46,12 @@ export default function FeatureSubFeatures({ onRead, device, deviceState, + deviceStateVersion, featureWrapperClass, minimal, endpointSpecific, steps, + sourceIdx, }: FeatureSubFeaturesProps) { const { type, property } = feature; const [state, setState] = useState({}); @@ -55,46 +60,80 @@ export default function FeatureSubFeatures({ const features = ("features" in feature && feature.features) || []; const isRoot = isFeatureRoot(type, parentFeatures); + // Clear local state when command succeeds + const clearLocalState = useCallback(() => setState({}), []); + + // Optimistically update device state when command is queued for sleepy device (pending + final). + // This allows hasLocalChanges to become false (button turns blue) while keeping values visible. + // + // KNOWN LIMITATION: This optimistic state is in-memory only. If frontend/backend restarts + // while a command is queued for a sleepy device, the UI reverts to old values (from state.db). + // The queued command still executes when device wakes, but UI won't reflect the pending change. + // See pr-frontend.md "Known Limitation: Restart While Command Queued" for details. + // Future enhancement: backend could optimistically update state.db for pending+final commands. + const updateDeviceStates = useAppStore((s) => s.updateDeviceStates); + const onQueued = useCallback( + (sentPayload: unknown) => { + if (sourceIdx === undefined || !sentPayload || typeof sentPayload !== "object") return; + // Update device state with the values we just sent + updateDeviceStates(sourceIdx, [{ topic: device.friendly_name, payload: sentPayload as Record }]); + }, + [sourceIdx, device.friendly_name, updateDeviceStates], + ); + + // Use command feedback hook for Apply button state + const { status, send } = useCommandFeedback({ sourceIdx, onSuccess: clearLocalState, onQueued }); + const isPending = status === "pending"; + const isQueued = status === "queued"; + const isConfirmed = status === "ok"; + const isTimedOut = status === "error"; + + // Check if there are actual local changes (value differs from device) + const hasLocalChanges = Object.keys(state).some((key) => state[key] !== deviceState?.[key]); + + // Helper to check if a specific property has a local change + const propertyHasLocalChange = (prop: string): boolean => { + // If Apply is pending/timed out, show amber for properties that were sent + if (isPending && prop in state) { + return true; + } + // If not pending, show amber only if local value differs from device + return prop in state && state[prop] !== deviceState?.[prop]; + }; + // biome-ignore lint/correctness/useExhaustiveDependencies: specific trigger useEffect(() => { setState({}); }, [device.ieee_address]); const onFeatureChange = useCallback( - (value: Record): Promise => { - setState((prev) => { - const newState = { ...prev, ...value }; - - if (!isRoot) { - if (type === "composite") { - const newValue = { ...deviceState, ...newState, ...value }; - - onChange(property ? { [property]: newValue } : newValue); - } else { - onChange(value); - } + (value: Record, transactionId?: string): void => { + setState((prev) => ({ ...prev, ...value })); + + if (!isRoot) { + if (type === "composite") { + onChange(property ? { [property]: { ...state, ...value } } : value, transactionId); + } else { + onChange(value, transactionId); } - - return newState; - }); - - return Promise.resolve(); + } }, - [deviceState, type, property, isRoot, onChange], + [state, type, property, isRoot, onChange], ); - const onRootApply = useCallback(async (): Promise => { + const onRootApply = useCallback((): void => { const newState = { ...deviceState, ...state }; - - await onChange(property ? { [property]: newState } : newState); - }, [property, onChange, state, deviceState]); + // The payload we send - used for optimistic update if queued + const sentPayload = property ? { [property]: newState } : newState; + send((txId) => onChange(sentPayload, txId), sentPayload); + }, [property, onChange, state, deviceState, send]); const onFeatureRead = useCallback( - async (prop: Record): Promise => { + (prop: Record, transactionId?: string): void => { if (type === "composite") { - await onRead?.(property ? { [property]: prop } : prop); + onRead?.(property ? { [property]: prop } : prop, transactionId); } else { - await onRead?.(prop); + onRead?.(prop, transactionId); } }, [onRead, type, property], @@ -111,18 +150,39 @@ export default function FeatureSubFeatures({ parentFeatures={parentFeatures ?? []} device={device} deviceState={combinedState} + deviceStateVersion={deviceStateVersion} onChange={onFeatureChange} onRead={onFeatureRead} featureWrapperClass={featureWrapperClass} minimal={minimal} endpointSpecific={endpointSpecific} steps={steps?.[feature.name]} + batched={isRoot} + hasLocalChange={isRoot && feature.property !== undefined && propertyHasLocalChange(feature.property)} + sourceIdx={sourceIdx} /> ))} {isRoot && ( -
-
)} diff --git a/src/components/features/FeatureWrapper.tsx b/src/components/features/FeatureWrapper.tsx index b3d52909d..49c3e40fc 100644 --- a/src/components/features/FeatureWrapper.tsx +++ b/src/components/features/FeatureWrapper.tsx @@ -1,33 +1,68 @@ -import { faSync } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import startCase from "lodash/startCase.js"; -import { type PropsWithChildren, useCallback } from "react"; +import { type PropsWithChildren, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import type { CommandResponse } from "../../types.js"; import { type ColorFeature, FeatureAccessMode, type FeatureWithAnySubFeatures } from "../../types.js"; -import Button from "../Button.js"; +import { generateTransactionId, registerDeviceSetCallback, unregisterDeviceSetCallback } from "../../websocket/WebSocketManager.js"; +import { FeatureReadingContext, type WriteState } from "./FeatureReadingContext.js"; import { getFeatureIcon } from "./index.js"; +import StatusIndicator from "./StatusIndicator.js"; +import SyncRetryButton from "./SyncRetryButton.js"; + +// Frontend timeout for read operations - matches backend ZCL command timeout (10 seconds). +// This is purely for UI feedback; the backend handles actual device communication. +export const READ_TIMEOUT_MS = 10000; export type FeatureWrapperProps = { feature: FeatureWithAnySubFeatures; parentFeatures: FeatureWithAnySubFeatures[]; deviceValue?: unknown; - onRead?(property: Record): void; + deviceStateVersion?: number; + onRead?(property: Record, transactionId?: string): void; endpointSpecific?: boolean; + /** When true, render children inline with label (used for Binary in batched composites) */ + inline?: boolean; + /** Source index for Command Response API (needed for read transaction callbacks) */ + sourceIdx?: number; }; function isColorFeature(feature: FeatureWithAnySubFeatures): feature is ColorFeature { return feature.type === "composite" && (feature.name === "color_xy" || feature.name === "color_hs"); } +type SyncState = "idle" | "reading" | "queued" | "timed_out"; + export default function FeatureWrapper({ children, feature, deviceValue, + deviceStateVersion, onRead, endpointSpecific, parentFeatures, + inline, + sourceIdx, }: PropsWithChildren) { const { t } = useTranslation("zigbee"); + const [syncState, setSyncState] = useState("idle"); + const timeoutRef = useRef | null>(null); + const lastDeviceValueRef = useRef(deviceValue); + const versionAtReadStartRef = useRef(undefined); + const activeReadTransactionRef = useRef(null); + + // Write state from child editors (RangeEditor, etc.) + const [writeState, setWriteState] = useState(); + const [onRetry, setOnRetry] = useState<(() => void) | undefined>(); + + // Read error details from Command Response API + const [readErrorDetails, setReadErrorDetails] = useState(); + + // Derive boolean values for context provider + const isReading = syncState === "reading"; + const isReadQueued = syncState === "queued"; + const readTimedOut = syncState === "timed_out"; + // @ts-expect-error `undefined` is fine const unit = feature.unit as string | undefined; const [fi, fiClassName] = getFeatureIcon(feature.name, deviceValue, unit); @@ -40,33 +75,187 @@ export default function FeatureWrapper({ label = `${parentFeature.label} ${feature.label.charAt(0).toLowerCase()}${feature.label.slice(1)}`; } + // Clear reading/timeout state when device responds + // We detect this by either: value changed, OR deviceStateVersion changed since read started + // This is a backup for when response callback doesn't fire (e.g., no transaction ID) + useEffect(() => { + if (syncState !== "idle") { + const valueChanged = deviceValue !== lastDeviceValueRef.current; + const versionChanged = + deviceStateVersion !== undefined && versionAtReadStartRef.current !== undefined && deviceStateVersion > versionAtReadStartRef.current; + + if (valueChanged || versionChanged) { + setSyncState("idle"); + setReadErrorDetails(undefined); + versionAtReadStartRef.current = undefined; + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + } + } + lastDeviceValueRef.current = deviceValue; + }, [deviceValue, deviceStateVersion, syncState]); + + // Cleanup timeout and transaction callback on unmount + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + if (activeReadTransactionRef.current && sourceIdx !== undefined) { + unregisterDeviceSetCallback(sourceIdx, activeReadTransactionRef.current); + activeReadTransactionRef.current = null; + } + }; + }, [sourceIdx]); + const onSyncClick = useCallback( (item: FeatureWithAnySubFeatures) => { if (item.property) { - onRead?.({ [item.property]: "" }); + // Store version at read start to detect device responses + versionAtReadStartRef.current = deviceStateVersion; + setSyncState("reading"); + setReadErrorDetails(undefined); // Clear any previous error + + // Clear any existing timeout + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + // Clean up previous read transaction if any + if (activeReadTransactionRef.current && sourceIdx !== undefined) { + unregisterDeviceSetCallback(sourceIdx, activeReadTransactionRef.current); + } + + // Generate transaction ID and register callback for Command Response API + let transactionId: string | undefined; + if (sourceIdx !== undefined) { + transactionId = generateTransactionId(sourceIdx); + activeReadTransactionRef.current = transactionId; + + registerDeviceSetCallback( + sourceIdx, + transactionId, + (response) => { + // Clear the active transaction ref + if (activeReadTransactionRef.current === transactionId) { + activeReadTransactionRef.current = null; + } + + // Clear timeout since we got a response + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + + switch (response.status) { + case "ok": + // Read succeeded - set to idle immediately + setSyncState("idle"); + setReadErrorDetails(undefined); + versionAtReadStartRef.current = undefined; + break; + case "pending": + // Command queued for sleepy device + setSyncState("queued"); + setReadErrorDetails(undefined); + break; + case "error": + // Read failed - capture error details + setSyncState("timed_out"); + setReadErrorDetails(response.error); + break; + case "partial": + // Partial success - set to idle + setSyncState("idle"); + setReadErrorDetails(undefined); + versionAtReadStartRef.current = undefined; + break; + } + }, + READ_TIMEOUT_MS, + ); + } + + // Set timeout - if no response, show timed out state + // NOTE: Keep versionAtReadStartRef so late responses can still be detected + timeoutRef.current = setTimeout(() => { + setSyncState("timed_out"); + timeoutRef.current = null; + // Clean up transaction callback on timeout + if (activeReadTransactionRef.current && sourceIdx !== undefined) { + unregisterDeviceSetCallback(sourceIdx, activeReadTransactionRef.current); + activeReadTransactionRef.current = null; + } + }, READ_TIMEOUT_MS); + + onRead?.({ [item.property]: "" }, transactionId); } }, - [onRead], + [onRead, deviceStateVersion, sourceIdx], + ); + + // Sync callback for editors to trigger a read + const onSync = useCallback(() => { + onSyncClick(feature); + }, [onSyncClick, feature]); + + // Determine if device value is unknown (null/undefined) + const isUnknown = deviceValue === null || deviceValue === undefined; + + // Memoize context value to prevent unnecessary re-renders + const contextValue = useMemo( + () => ({ + isReading, + isReadQueued, + readTimedOut, + readErrorDetails, + isUnknown, + writeState, + setWriteState, + onRetry, + setOnRetry, + onSync: isReadable ? onSync : undefined, + }), + [isReading, isReadQueued, readTimedOut, readErrorDetails, isUnknown, writeState, onRetry, isReadable, onSync], ); return ( -
-
- -
-
-
- {label} - {!endpointSpecific && feature.endpoint ? ` (${t(($) => $.endpoint)}: ${feature.endpoint})` : ""} + +
+
+
-
{feature.description}
+ {inline ? ( +
+
+
+ {label} + {!endpointSpecific && feature.endpoint ? ` (${t(($) => $.endpoint)}: ${feature.endpoint})` : ""} +
+
{feature.description}
+
+ +
{children}
+
+ ) : ( + <> +
+
+ {label} + {!endpointSpecific && feature.endpoint ? ` (${t(($) => $.endpoint)}: ${feature.endpoint})` : ""} +
+
{feature.description}
+
+
+ +
{children}
+
+ + )} +
-
{children}
- {isReadable && ( - item={feature} onClick={onSyncClick} className="btn btn-xs btn-square btn-primary btn-soft"> - - - )} -
+ ); } diff --git a/src/components/features/Gradient.tsx b/src/components/features/Gradient.tsx index 643efa74e..7247c828f 100644 --- a/src/components/features/Gradient.tsx +++ b/src/components/features/Gradient.tsx @@ -1,15 +1,20 @@ import { memo, useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; +import { useAppStore } from "../../store.js"; import type { GradientFeature } from "../../types.js"; import Button from "../Button.js"; import ColorEditor from "../editors/ColorEditor.js"; import { getDeviceGamut } from "../editors/index.js"; +import { useCommandFeedback } from "../editors/useCommandFeedback.js"; import { type BaseFeatureProps, clampList } from "./index.js"; type GradientProps = BaseFeatureProps; const buildDefaultArray = (min: number): string[] => (min > 0 ? Array(min).fill("#ffffff") : []); +// Helper to compare arrays +const arraysEqual = (a: string[], b: string[]): boolean => a.length === b.length && a.every((v, i) => v === b[i]); + export const Gradient = memo((props: GradientProps) => { const { device, @@ -17,24 +22,59 @@ export const Gradient = memo((props: GradientProps) => { onChange, feature: { length_min, length_max, property }, deviceValue, + sourceIdx, } = props; const { t } = useTranslation("common"); - const [colors, setColors] = useState(buildDefaultArray(length_min)); - const [canAdd, setCanAdd] = useState(false); - const [canRemove, setCanRemove] = useState(false); - useEffect(() => { + // Track pending colors as full array (null = no local edits, use device value) + const [pendingColors, setPendingColors] = useState(null); + + // Clear local state when command succeeds + const clearPendingColors = useCallback(() => setPendingColors(null), []); + + // Optimistically update device state when command is queued for sleepy device (pending + final). + // See FeatureSubFeatures.tsx and pr-frontend.md for details on restart limitation. + const updateDeviceStates = useAppStore((s) => s.updateDeviceStates); + const onQueued = useCallback( + (sentPayload: unknown) => { + if (sourceIdx === undefined || !sentPayload || typeof sentPayload !== "object") return; + updateDeviceStates(sourceIdx, [{ topic: device.friendly_name, payload: sentPayload as Record }]); + }, + [sourceIdx, device.friendly_name, updateDeviceStates], + ); + + // Use command feedback hook for Apply button state + const { status, send } = useCommandFeedback({ sourceIdx, onSuccess: clearPendingColors, onQueued }); + const isPending = status === "pending"; + const isQueued = status === "queued"; + const isConfirmed = status === "ok"; + const isTimedOut = status === "error"; + + // Helper to get device array from deviceValue prop + const getDeviceArray = useCallback((): string[] => { if (deviceValue && Array.isArray(deviceValue)) { - setColors(clampList(deviceValue, length_min, length_max, (min) => buildDefaultArray(min))); - } else { - setColors(buildDefaultArray(length_min)); + return clampList(deviceValue, length_min, length_max, (min) => buildDefaultArray(min)); } + return buildDefaultArray(length_min); }, [deviceValue, length_min, length_max]); + const deviceArray = getDeviceArray(); + + // Current colors = pending (if editing) or device array + const colors = pendingColors ?? deviceArray; + + // Check if there are actual local changes + const hasLocalChanges = pendingColors !== null && !arraysEqual(pendingColors, deviceArray); + + // Computed add/remove constraints + const canAdd = length_max !== undefined && length_max > 0 ? colors.length < length_max : true; + const canRemove = length_min !== undefined && length_min > 0 ? colors.length > length_min : true; + + // Reset on device change + // biome-ignore lint/correctness/useExhaustiveDependencies: specific trigger useEffect(() => { - setCanAdd(colors.length < length_max); - setCanRemove(colors.length > length_min); - }, [colors, length_min, length_max]); + setPendingColors(null); + }, [device.ieee_address]); const gamut = useMemo(() => { if (device.definition) { @@ -44,31 +84,41 @@ export const Gradient = memo((props: GradientProps) => { return "cie1931"; }, [device.definition]); - const setColor = useCallback((idx: number, hex: string) => { - setColors((prev) => { - const c = Array.from(prev); - c[idx] = hex; - - return c; - }); - }, []); + const setColor = useCallback( + (idx: number, hex: string) => { + setPendingColors((prev) => { + const current = prev ?? deviceArray; + const newColors = [...current]; + newColors[idx] = hex; + return newColors; + }); + }, + [deviceArray], + ); const addColor = useCallback(() => { - setColors((prev) => [...prev, "#ffffff"]); - }, []); - - const removeColor = useCallback((idx: number) => { - setColors((prev) => { - const c = Array.from(prev); - c.splice(idx, 1); - - return c; + setPendingColors((prev) => { + const current = prev ?? deviceArray; + return [...current, "#ffffff"]; }); - }, []); + }, [deviceArray]); + + const removeColor = useCallback( + (idx: number) => { + setPendingColors((prev) => { + const current = prev ?? deviceArray; + return current.filter((_, i) => i !== idx); + }); + }, + [deviceArray], + ); - const onGradientApply = useCallback(async () => { - await onChange({ [property ?? "gradient"]: colors }); - }, [colors, property, onChange]); + const onGradientApply = useCallback(() => { + const sentPayload = { [property ?? "gradient"]: colors }; + send((txId) => { + onChange(sentPayload, txId); + }, sentPayload); + }, [colors, property, onChange, send]); return ( <> @@ -85,6 +135,7 @@ export const Gradient = memo((props: GradientProps) => { format="hex" gamut={gamut} minimal={minimal} + batched={true} /> {canRemove && ( item={idx} className="btn btn-sm btn-error" onClick={removeColor}> @@ -100,9 +151,25 @@ export const Gradient = memo((props: GradientProps) => {
)} -
-
diff --git a/src/components/features/List.tsx b/src/components/features/List.tsx index 465432118..0f4dd8156 100644 --- a/src/components/features/List.tsx +++ b/src/components/features/List.tsx @@ -1,5 +1,6 @@ -import { memo, useCallback, useEffect, useState } from "react"; +import { memo, useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; +import { useAppStore } from "../../store.js"; import { type Device, type DeviceState, @@ -9,6 +10,7 @@ import { type ListFeature, } from "../../types.js"; import Button from "../Button.js"; +import { useCommandFeedback } from "../editors/useCommandFeedback.js"; import BaseViewer from "./BaseViewer.js"; import Feature from "./Feature.js"; import FeatureWrapper from "./FeatureWrapper.js"; @@ -40,81 +42,153 @@ const buildDefaultArray = (min: number, type: string) => (min > 0 ? Array(min).f const List = memo((props: Props) => { const { t } = useTranslation("common"); - const { feature, minimal, parentFeatures, onChange, deviceValue } = props; + const { feature, minimal, parentFeatures, onChange, deviceValue, device, sourceIdx } = props; const { property, access = FeatureAccessMode.SET, item_type, length_min, length_max } = feature; - const [currentValue, setCurrentValue] = useState(buildDefaultArray(length_min ?? 0, item_type.type)); - const [canAdd, setCanAdd] = useState(false); - const [canRemove, setCanRemove] = useState(false); const isRoot = isListRoot(parentFeatures); - useEffect(() => { + // Track local changes as sparse object (like FeatureSubFeatures uses state object) + const [localChanges, setLocalChanges] = useState>({}); + + // Clear local state when command succeeds + const clearLocalChanges = useCallback(() => setLocalChanges({}), []); + + // Optimistically update device state when command is queued for sleepy device (pending + final). + // See FeatureSubFeatures.tsx and pr-frontend.md for details on restart limitation. + const updateDeviceStates = useAppStore((s) => s.updateDeviceStates); + const onQueued = useCallback( + (sentPayload: unknown) => { + if (sourceIdx === undefined || !sentPayload || typeof sentPayload !== "object") return; + updateDeviceStates(sourceIdx, [{ topic: device.friendly_name, payload: sentPayload as Record }]); + }, + [sourceIdx, device.friendly_name, updateDeviceStates], + ); + + // Use command feedback hook for Apply button state + const { status, send } = useCommandFeedback({ sourceIdx, onSuccess: clearLocalChanges, onQueued }); + const isPending = status === "pending"; + const isQueued = status === "queued"; + const isConfirmed = status === "ok"; + const isTimedOut = status === "error"; + + // Helper to extract device array from deviceValue prop + const getDeviceArray = useCallback((): unknown[] => { if (deviceValue) { if (Array.isArray(deviceValue)) { - setCurrentValue(clampList(deviceValue, length_min, length_max, (min) => buildDefaultArray(min, item_type.type))); - } else if (property && typeof deviceValue === "object") { - const prop = deviceValue[property]; - + return clampList(deviceValue, length_min, length_max, (min) => buildDefaultArray(min, item_type.type)); + } + if (property && typeof deviceValue === "object") { + const prop = (deviceValue as Record)[property]; if (prop) { - setCurrentValue(clampList(prop, length_min, length_max, (min) => buildDefaultArray(min, item_type.type))); - } else { - setCurrentValue(buildDefaultArray(length_min ?? 0, item_type.type)); + return clampList(prop as unknown[], length_min, length_max, (min) => buildDefaultArray(min, item_type.type)); } - } else { - setCurrentValue(buildDefaultArray(length_min ?? 0, item_type.type)); } - } else { - setCurrentValue(buildDefaultArray(length_min ?? 0, item_type.type)); } + return buildDefaultArray(length_min ?? 0, item_type.type); }, [deviceValue, property, item_type.type, length_min, length_max]); + const deviceArray = getDeviceArray(); + + // Compute combined value (like FeatureSubFeatures.combinedState) + const combinedValue = useMemo(() => { + const result = [...deviceArray]; + for (const [key, value] of Object.entries(localChanges)) { + const index = Number(key); + if (index < result.length) { + result[index] = value; + } else { + // Handle added items beyond device array length + result[index] = value; + } + } + return result; + }, [deviceArray, localChanges]); + + // Check if there are actual local changes + const hasLocalChanges = Object.keys(localChanges).some((key) => localChanges[Number(key)] !== deviceArray[Number(key)]); + + // Helper to check if specific index has local change + const itemHasLocalChange = useCallback( + (itemIndex: number): boolean => { + // If Apply is pending, show amber for items that were sent + if (isPending && itemIndex in localChanges) { + return true; + } + // If not pending, show amber only if local value differs from device + return itemIndex in localChanges && localChanges[itemIndex] !== deviceArray[itemIndex]; + }, + [isPending, localChanges, deviceArray], + ); + + // Computed values for canAdd/canRemove + const canAdd = length_max !== undefined && length_max > 0 ? combinedValue.length < length_max : true; + const canRemove = length_min !== undefined && length_min > 0 ? combinedValue.length > length_min : true; + + // Reset on device change + // biome-ignore lint/correctness/useExhaustiveDependencies: specific trigger useEffect(() => { - setCanAdd(length_max !== undefined && length_max > 0 ? currentValue.length < length_max : true); - setCanRemove(length_min !== undefined && length_min > 0 ? currentValue.length > length_min : true); - }, [currentValue, length_min, length_max]); + setLocalChanges({}); + }, [device.ieee_address]); const onItemChange = useCallback( - async (itemValue: unknown, itemIndex: number) => { - const newListValue = Array.from(currentValue); - + (itemValue: unknown, itemIndex: number) => { + let newValue = itemValue; if (typeof itemValue === "object" && itemValue != null) { - itemValue = { ...(currentValue[itemIndex] as object), ...itemValue }; + newValue = { ...(combinedValue[itemIndex] as object), ...itemValue }; } - newListValue[itemIndex] = itemValue ?? ""; - - setCurrentValue(newListValue); + setLocalChanges((prev) => ({ ...prev, [itemIndex]: newValue ?? "" })); if (!isRoot) { - await onChange(property ? { [property]: newListValue } : newListValue); + const newListValue = [...combinedValue]; + newListValue[itemIndex] = newValue ?? ""; + onChange(property ? { [property]: newListValue } : newListValue); } }, - [currentValue, property, isRoot, onChange], + [combinedValue, property, isRoot, onChange], ); - const addItem = useCallback(() => setCurrentValue((prev) => [...prev, item_type.type === "composite" ? {} : ""]), [item_type.type]); + const addItem = useCallback(() => { + const newIndex = combinedValue.length; + setLocalChanges((prev) => ({ + ...prev, + [newIndex]: item_type.type === "composite" ? {} : "", + })); + }, [combinedValue.length, item_type.type]); const removeItem = useCallback( - async (itemIndex: number) => { - const newListValue = Array.from(currentValue); - - newListValue.splice(itemIndex, 1); - setCurrentValue(newListValue); + (itemIndex: number) => { + // For removal, we need to rebuild the changes map with shifted indices + const newChanges: Record = {}; + for (const [key, value] of Object.entries(localChanges)) { + const idx = Number(key); + if (idx < itemIndex) { + newChanges[idx] = value; + } else if (idx > itemIndex) { + newChanges[idx - 1] = value; + } + // Skip idx === itemIndex (removed) + } + setLocalChanges(newChanges); if (!isRoot) { - await onChange(property ? { [property]: newListValue } : newListValue); + const newListValue = combinedValue.filter((_, i) => i !== itemIndex); + onChange(property ? { [property]: newListValue } : newListValue); } }, - [currentValue, property, isRoot, onChange], + [localChanges, combinedValue, property, isRoot, onChange], ); - const onRootApply = useCallback(async () => { - await onChange(property ? { [property]: currentValue } : currentValue); - }, [property, onChange, currentValue]); + const onRootApply = useCallback((): void => { + const sentPayload = property ? { [property]: combinedValue } : combinedValue; + send((txId) => { + onChange(sentPayload, txId); + }, sentPayload); + }, [property, onChange, combinedValue, send]); if (access & FeatureAccessMode.SET) { return ( <> - {currentValue.map((itemValue, itemIndex) => ( + {combinedValue.map((itemValue, itemIndex) => ( // biome-ignore lint/suspicious/noArrayIndexKey: don't have a fixed value type
{ onChange={(value) => onItemChange(value, itemIndex)} featureWrapperClass={FeatureWrapper} parentFeatures={[...parentFeatures, feature]} + batched={isRoot} + hasLocalChange={isRoot && itemHasLocalChange(itemIndex)} /> {canRemove && ( item={itemIndex} className="btn btn-sm btn-error btn-square" onClick={removeItem}> @@ -140,9 +216,26 @@ const List = memo((props: Props) => {
)} {isRoot && ( -
-
)} diff --git a/src/components/features/Numeric.tsx b/src/components/features/Numeric.tsx index 8d2061a1d..fd3a1eead 100644 --- a/src/components/features/Numeric.tsx +++ b/src/components/features/Numeric.tsx @@ -17,14 +17,14 @@ const Numeric = memo((props: NumericProps) => { steps, onChange, minimal, + batched, + sourceIdx, } = props; if (access & FeatureAccessMode.SET) { return ( { - await onChange(property ? { [property]: value } : value); - }} + onChange={(value, transactionId) => onChange(property ? { [property]: value } : value, transactionId)} value={typeof deviceValue === "number" ? deviceValue : ""} min={valueMin} max={valueMax} @@ -32,6 +32,8 @@ const Numeric = memo((props: NumericProps) => { steps={presets?.length ? (presets as ValueWithLabelOrPrimitive[]) /* typing failure */ : steps} unit={unit} minimal={minimal} + batched={batched} + sourceIdx={sourceIdx} /> ); } diff --git a/src/components/features/StatusIndicator.tsx b/src/components/features/StatusIndicator.tsx new file mode 100644 index 000000000..30f6e514a --- /dev/null +++ b/src/components/features/StatusIndicator.tsx @@ -0,0 +1,108 @@ +import { useFeatureReading } from "./FeatureReadingContext.js"; + +/** + * StatusIndicator - Small colored dot showing editor state + * + * Displays a visual indicator based on the current write/read state: + * - unknown (? icon): device value not yet known + * - error (red dot): conflict or write timeout + * - partial (warning triangle): some attributes succeeded, some failed + * - warning (amber dot): pending write, reading, or queued for sleepy device + * - success (green dot): device confirmed the value + * - hidden: idle state (empty placeholder to prevent layout shift) + * + * Uses fixed-width wrapper to prevent layout shift when dot appears/disappears. + * Error states show details in tooltip when available. + */ +export default function StatusIndicator() { + const { isReading, isReadQueued, readTimedOut, readErrorDetails, writeState, isUnknown } = useFeatureReading(); + + const dotClass = "inline-block w-2 h-2 rounded-full"; + + // Fixed-size wrapper: width prevents layout shift, height matches typical control row (h-7 = 28px) + const wrapper = (content: React.ReactNode) => {content}; + + // Unknown value state (highest priority - show ? icon) + if (isUnknown && !writeState?.isPending && !isReading && !isReadQueued) { + return wrapper( + + ? + , + ); + } + + // Write error states (highest priority after unknown) + if (writeState?.isError || writeState?.isTimedOut) { + let tooltip = writeState?.isTimedOut ? "Timed out" : "Error"; + + // Add error details if available + if (writeState?.errorDetails) { + tooltip = `${writeState.errorDetails.code}: ${writeState.errorDetails.message}`; + if (writeState.errorDetails.zcl_status !== undefined) { + tooltip += ` (ZCL: ${writeState.errorDetails.zcl_status})`; + } + } + + return wrapper(); + } + + // Read error state (only show if we have error details from backend) + if (readTimedOut && readErrorDetails) { + let tooltip = `${readErrorDetails.code}: ${readErrorDetails.message}`; + if (readErrorDetails.zcl_status !== undefined) { + tooltip += ` (ZCL: ${readErrorDetails.zcl_status})`; + } + + return wrapper(); + } + + // Partial success (warning triangle with failed attributes) + if (writeState?.isPartial && writeState.failedAttributes) { + const failedList = Object.entries(writeState.failedAttributes) + .map(([attr, reason]) => `${attr}: ${reason}`) + .join("\n"); + const tooltip = `Partial success\n\nFailed:\n${failedList}`; + + return wrapper( + + + Partial success warning + + + , + ); + } + + // Queued for sleepy device (orange dot, same as pending) + if (writeState?.isQueued || isReadQueued) { + return wrapper(); + } + + // Pending or reading (warning dot) + if (writeState?.isPending || isReading) { + return wrapper(); + } + + // Confirmed (success) + if (writeState?.isConfirmed) { + return wrapper(); + } + + // Idle - empty placeholder to prevent layout shift + return wrapper(null); +} diff --git a/src/components/features/SyncRetryButton.tsx b/src/components/features/SyncRetryButton.tsx new file mode 100644 index 000000000..0c994fea0 --- /dev/null +++ b/src/components/features/SyncRetryButton.tsx @@ -0,0 +1,86 @@ +import { faRedo, faSync } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { useFeatureReading } from "./FeatureReadingContext.js"; + +// Short timeout for quick read before retry write +const QUICK_READ_TIMEOUT_MS = 2000; + +/** + * Consolidated sync/retry button that reads state from FeatureReadingContext. + * Renders inline - place this inside your editor's flex-row for proper positioning. + */ +export default function SyncRetryButton() { + const { t } = useTranslation("zigbee"); + const { isReading, readTimedOut, writeState, onRetry, onSync } = useFeatureReading(); + + // Handle button click - retry (quick read then write) or sync (read) + const handleClick = useCallback(() => { + if (writeState?.isError || writeState?.isTimedOut) { + // Retry: quick read (2s) then write + onSync?.(); + setTimeout(() => { + onRetry?.(); + }, QUICK_READ_TIMEOUT_MS); + } else { + // Normal sync: just read + onSync?.(); + } + }, [writeState?.isError, writeState?.isTimedOut, onSync, onRetry]); + + // Determine if button should show + const showButton = onSync || writeState?.isError || writeState?.isTimedOut || writeState?.isPending; + + if (!showButton) { + return null; + } + + // Button styling based on state + const getButtonClass = () => { + if (writeState?.isError || writeState?.isTimedOut) { + return "btn btn-xs btn-square btn-error btn-soft"; + } + if (writeState?.isPending || isReading) { + return "btn btn-xs btn-square btn-warning btn-soft"; + } + if (readTimedOut) { + return "btn btn-xs btn-square btn-error btn-soft"; + } + return "btn btn-xs btn-square btn-primary btn-soft"; + }; + + // Tooltip based on state + const getTooltip = () => { + if (writeState?.isTimedOut) { + return t(($) => $.no_response_retry); + } + if (writeState?.isError) { + return t(($) => $.command_failed_retry); + } + if (writeState?.isPending) { + return t(($) => $.sending_to_device); + } + if (readTimedOut) { + return t(($) => $.read_timed_out); + } + if (isReading) { + return t(($) => $.reading_from_device); + } + return t(($) => $.get_value_from_device); + }; + + const isDisabled = writeState?.isPending || isReading; + // faRedo = redo/retry (read + write) + // faSync = sync (read only) + const icon = writeState?.isError || writeState?.isTimedOut ? faRedo : faSync; + const shouldSpin = writeState?.isPending || isReading; + + return ( +
+ +
+ ); +} diff --git a/src/components/features/Text.tsx b/src/components/features/Text.tsx index 89b7b6bed..91f0d8740 100644 --- a/src/components/features/Text.tsx +++ b/src/components/features/Text.tsx @@ -5,22 +5,30 @@ import BaseViewer from "./BaseViewer.js"; import type { BaseFeatureProps } from "./index.js"; import NoAccessError from "./NoAccessError.js"; -type TextProps = BaseFeatureProps; +interface TextProps extends BaseFeatureProps { + hasLocalChange?: boolean; +} const Text = memo((props: TextProps) => { const { feature: { access = FeatureAccessMode.SET, property }, deviceValue, onChange, + minimal, + batched, + hasLocalChange, + sourceIdx, } = props; if (access & FeatureAccessMode.SET) { return ( { - await onChange(property ? { [property]: value } : value); - }} + onChange={(value, transactionId) => onChange(property ? { [property]: value } : value, transactionId)} value={deviceValue != null ? (typeof deviceValue === "string" ? deviceValue : JSON.stringify(deviceValue)) : ""} + minimal={minimal} + batched={batched} + hasLocalChange={hasLocalChange} + sourceIdx={sourceIdx} /> ); } diff --git a/src/components/features/index.tsx b/src/components/features/index.tsx index 917b01125..26266f6d0 100644 --- a/src/components/features/index.tsx +++ b/src/components/features/index.tsx @@ -121,10 +121,14 @@ export interface BaseFeatureProps extends O feature: T; deviceValue: unknown; device: Device; - onChange(value: Record | unknown): Promise; - onRead?(value: Record | unknown): Promise; + onChange(value: Record | unknown, transactionId?: string): void; + onRead?(value: Record | unknown, transactionId?: string): void; featureWrapperClass: FunctionComponent>; minimal?: boolean; + /** When true, changes are batched and submitted via Apply button - editors should show editing state only */ + batched?: boolean; + /** Source index for transaction ID generation */ + sourceIdx?: number; } export interface BaseWithSubFeaturesProps extends Omit, "deviceValue"> { diff --git a/src/components/group-page/GroupMember.tsx b/src/components/group-page/GroupMember.tsx index 82d25166e..65c3ec6f7 100644 --- a/src/components/group-page/GroupMember.tsx +++ b/src/components/group-page/GroupMember.tsx @@ -18,7 +18,7 @@ export type GroupMemberProps = { groupMember: AppState["groups"][number][number]["members"][number]; lastSeenConfig: AppState["bridgeInfo"][number]["config"]["advanced"]["last_seen"]; removeDeviceFromGroup(deviceIeee: string, endpoint: number): Promise; - setDeviceState(ieee: string, value: Record): Promise; + setDeviceState(ieee: string, value: Record, transactionId?: string): Promise; }; }; @@ -37,7 +37,7 @@ const GroupMember = ({ const scenesFeatures = useAppStore(useShallow((state) => state.deviceScenesFeatures[sourceIdx][device.ieee_address] ?? [])); const onCardChange = useCallback( - async (value: Record) => await setDeviceState(device.ieee_address, value), + async (value: Record, transactionId?: string) => await setDeviceState(device.ieee_address, value, transactionId), [device.ieee_address, setDeviceState], ); diff --git a/src/components/group-page/GroupMembers.tsx b/src/components/group-page/GroupMembers.tsx index 80b4ad83a..533dfb040 100644 --- a/src/components/group-page/GroupMembers.tsx +++ b/src/components/group-page/GroupMembers.tsx @@ -27,12 +27,13 @@ const GroupMembers = memo(({ sourceIdx, devices, group }: GroupMembersProps) => ); const setDeviceState = useCallback( - async (ieee: string, value: Record): Promise => { + async (ieee: string, value: Record, transactionId?: string): Promise => { + const payload = transactionId ? { ...value, z2m: { request_id: transactionId } } : value; await sendMessage<"{friendlyNameOrId}/set">( sourceIdx, // @ts-expect-error templated API endpoint `${ieee}/set`, - value, + payload, ); }, [sourceIdx], diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 1a4af366f..5ff4413ec 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -348,6 +348,14 @@ "firmware_id": "Firmware ID", "firmware_build_date": "Firmware build date", "force_remove": "Force remove", + "get_value_from_device": "Get current value from device", + "sync_all_features": "Sync all features from device", + "reading_from_device": "Reading from device...", + "read_timed_out": "Read timed out - click to retry", + "read_failed": "Failed to read from device", + "sending_to_device": "Sending to device...", + "no_response_retry": "No response from device - click to retry", + "command_failed_retry": "Command failed - click to retry", "force_remove_notice": "Use only as last resort (will not propagate to the network)", "ieee_address": "IEEE Address", "interview": "Interview", diff --git a/src/store.ts b/src/store.ts index de79bcdff..c74d6359a 100644 --- a/src/store.ts +++ b/src/store.ts @@ -469,6 +469,12 @@ export const useAppStore = create((set, _get, store) => ( addedToasts = true; const [, type, key, name, error] = match; + // KNOWN ISSUE: For sleepy devices, when a newer command supersedes an older + // queued command, zigbee-herdsman rejects the old command with "Delivery failed" + // error (the original failure reason), not a distinct "superseded" error. + // This causes confusing error toasts when rapidly sending commands. + // Future enhancement: herdsman could use distinct error for superseded commands, + // allowing frontend to suppress those toasts. See pr-backend.md enhancement C. newToasts.push({ sourceIdx, topic: `${name}/${type}(${key})`, diff --git a/src/types.ts b/src/types.ts index 64dc54690..e4ae30b22 100644 --- a/src/types.ts +++ b/src/types.ts @@ -165,6 +165,50 @@ export type AnySubFeature = BasicFeature | WithAnySubFeatures; + + /** Failed attributes with error messages (present if status is 'partial') */ + failed?: Record; + + /** Global error (present if status is 'error') */ + error?: { + /** Normalized error code (optional - Z2M sets where detectable) */ + code?: "TIMEOUT" | "NO_ROUTE" | "ZCL_ERROR" | "UNKNOWN"; + /** Raw message from zigbee-herdsman */ + message: string; + /** ZCL status code (e.g., 134 = UNSUPPORTED_ATTRIBUTE) */ + zcl_status?: number; + }; + + /** Z2M metadata */ + z2m: { + /** Echoed from request */ + request_id: string; + /** TRUE = Gateway finished processing (stop spinner) */ + final: boolean; + /** Round-trip time in milliseconds (optional) */ + elapsed_ms?: number; + /** For group commands only */ + transmission_type?: "unicast" | "multicast"; + member_count?: number; + }; +}; + export type RGBColor = { r: number; g: number; diff --git a/src/websocket/WebSocketManager.ts b/src/websocket/WebSocketManager.ts index b905fa8e2..70af18e96 100644 --- a/src/websocket/WebSocketManager.ts +++ b/src/websocket/WebSocketManager.ts @@ -4,7 +4,7 @@ import { AVAILABILITY_FEATURE_TOPIC_ENDING } from "../consts.js"; import { USE_PROXY } from "../envs.js"; import { AUTH_FLAG_KEY, AUTH_TOKEN_KEY } from "../localStoreConsts.js"; import { API_NAMES, API_URLS, useAppStore } from "../store.js"; -import type { LogMessage, Message, RecursiveMutable, ResponseMessage } from "../types.js"; +import type { CommandResponse, LogMessage, Message, RecursiveMutable, ResponseMessage } from "../types.js"; import { randomString, stringifyWithUndefinedAsNull } from "../utils.js"; // prevent stripping @@ -22,6 +22,8 @@ type PendingRequest = { timeoutId: number; }; +type DeviceSetCallback = (response: CommandResponse) => void; + type Connection = { idx: number; socket: WebSocket | undefined; @@ -30,6 +32,8 @@ type Connection = { transactionPrefix: string; transactionNumber: number; pending: Map; + /** Callbacks for device set/response messages (keyed by transaction ID) */ + deviceSetCallbacks: Map; deviceQueue: Message[]; logQueue: LogMessage[]; @@ -62,6 +66,7 @@ class WebSocketManager { transactionPrefix: randomString(5), transactionNumber: 1, pending: new Map(), + deviceSetCallbacks: new Map(), deviceQueue: [], logQueue: [], metricsMessagesSent: 0, @@ -174,6 +179,70 @@ class WebSocketManager { return this.#connections[idx].transactionPrefix; } + /** + * Generate a unique transaction ID for device set commands. + * Use this when you want to receive a response via registerDeviceSetCallback. + */ + generateTransactionId(sourceIdx: number): string { + const conn = this.#connections[sourceIdx]; + return `${conn.transactionPrefix}-${conn.transactionNumber++}`; + } + + /** + * Register a callback for a command response. + * The callback will be called when a {device}/response message arrives + * with a matching request_id (from z2m.request_id). + * + * @param sourceIdx - The source/connection index + * @param requestId - The request ID to listen for + * @param callback - Function to call with the CommandResponse + * @param timeoutMs - Optional timeout (default: 10000ms). Callback receives error response on timeout. + */ + registerDeviceSetCallback(sourceIdx: number, requestId: string, callback: DeviceSetCallback, timeoutMs = 10000): void { + const conn = this.#connections[sourceIdx]; + if (!conn) return; + + // Set up timeout to auto-cleanup if no response arrives + const timeoutId = window.setTimeout(() => { + const cb = conn.deviceSetCallbacks.get(requestId); + if (cb) { + conn.deviceSetCallbacks.delete(requestId); + // Return a CommandResponse-shaped timeout error + cb({ + type: "set", + status: "error", + target: "", + error: { + code: "TIMEOUT", + message: "Response timeout (frontend)", + }, + z2m: { + request_id: requestId, + final: true, + elapsed_ms: timeoutMs, + }, + }); + } + }, timeoutMs); + + // Wrap callback to clear timeout when called + const wrappedCallback: DeviceSetCallback = (response) => { + clearTimeout(timeoutId); + callback(response); + }; + + conn.deviceSetCallbacks.set(requestId, wrappedCallback); + } + + /** + * Unregister a command response callback (e.g., on component unmount). + */ + unregisterDeviceSetCallback(sourceIdx: number, requestId: string): void { + const conn = this.#connections[sourceIdx]; + if (!conn) return; + conn.deviceSetCallbacks.delete(requestId); + } + async sendMessage(sourceIdx: number, topic: T, payload: Zigbee2MQTTAPI[T]): Promise { if (this.#destroyed) { return; @@ -500,12 +569,41 @@ class WebSocketManager { return; } + // Handle command response messages (from backend Command Response API) + if (parsed.topic.endsWith("/response")) { + this.#handleDeviceSetResponse(conn, parsed); + this.#scheduleFlush(); // ensure metrics commit + + return; + } + conn.metricsMessagesDevice++; // this.#scheduleFlush() called inside this.#queueUpdateDeviceState(conn, parsed as Message); } + /** + * Handle command response messages (Command Response API V2). + * These arrive when a request_id was included in a command via z2m.request_id + * and the backend has processed it. + */ + #handleDeviceSetResponse(conn: Connection, msg: Message): void { + const payload = msg.payload as CommandResponse; + const requestId = payload?.z2m?.request_id; + + if (!requestId) { + return; + } + + const callback = conn.deviceSetCallbacks.get(requestId); + + if (callback) { + conn.deviceSetCallbacks.delete(requestId); + callback(payload); + } + } + #handleBridge(conn: Connection, msg: Message): void { const store = useAppStore.getState(); @@ -754,3 +852,40 @@ export async function sendMessage(sourceI export function getTransactionPrefix(sourceIdx: number): string { return manager.getTransactionPrefix(sourceIdx); } + +/** + * Generate a unique transaction ID for device set commands. + * Include this in your payload to receive a response via registerDeviceSetCallback. + */ +export function generateTransactionId(sourceIdx: number): string { + return manager.generateTransactionId(sourceIdx); +} + +/** + * Register a callback for a command response. + * The callback will be called when a {device}/response message arrives + * with a matching request_id (from z2m.request_id). + * + * @param sourceIdx - The source/connection index + * @param requestId - The request ID to listen for + * @param callback - Function to call with the CommandResponse + * @param timeoutMs - Optional timeout (default: 10000ms). Callback receives error response on timeout. + */ +export function registerDeviceSetCallback( + sourceIdx: number, + requestId: string, + callback: (response: CommandResponse) => void, + timeoutMs?: number, +): void { + manager.registerDeviceSetCallback(sourceIdx, requestId, callback, timeoutMs); +} + +/** + * Unregister a command response callback (e.g., on component unmount). + */ +export function unregisterDeviceSetCallback(sourceIdx: number, requestId: string): void { + manager.unregisterDeviceSetCallback(sourceIdx, requestId); +} + +// Re-export CommandResponse for consumers +export type { CommandResponse } from "../types.js"; diff --git a/test/FeatureSubFeatures.test.tsx b/test/FeatureSubFeatures.test.tsx deleted file mode 100644 index c1d207887..000000000 --- a/test/FeatureSubFeatures.test.tsx +++ /dev/null @@ -1,1054 +0,0 @@ -import { render } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import FeatureSubFeatures from "../src/components/features/FeatureSubFeatures.js"; -import FeatureWrapper from "../src/components/features/FeatureWrapper.js"; -import type { Device, FeatureWithAnySubFeatures } from "../src/types.js"; - -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ - t: (key: (arg: Record) => string) => { - const translations: Record = { - apply: "Apply", - }; - - return key(translations); - }, - }), -})); - -interface FeatureCallbacks { - onChange: (value: Record) => Promise; - onRead?: (prop: Record) => Promise; -} - -const mockFeatureCallbacks = new Map(); -let mockApplyCallback: (() => void) | null = null; - -vi.mock("../src/components/features/Feature.js", () => ({ - default: ({ - feature, - onChange, - onRead, - }: { - feature: { name: string }; - onChange: FeatureCallbacks["onChange"]; - onRead?: FeatureCallbacks["onRead"]; - }) => { - mockFeatureCallbacks.set(feature.name, { onChange, onRead }); - - return null; - }, -})); - -vi.mock("../src/components/Button.js", () => ({ - default: ({ onClick, children }: { onClick: () => void; children: React.ReactNode }) => { - mockApplyCallback = onClick; - - return ( - - ); - }, -})); - -const expectApplyButtonRendered = (rendered: boolean) => (rendered ? expect(mockApplyCallback).not.toBeNull() : expect(mockApplyCallback).toBeNull()); - -describe("FeatureSubFeatures", () => { - const mockDevice: Device = { - ieee_address: "0x00158d00045b2a5e", - friendly_name: "TestDevice", - definition: { - model: "TestModel", - vendor: "TestVendor", - }, - } as Device; - - beforeEach(() => { - mockFeatureCallbacks.clear(); - - mockApplyCallback = null; - - vi.clearAllMocks(); - }); - - describe("Component Rendering", () => { - it("renders all features from feature.features array", () => { - const feature: FeatureWithAnySubFeatures = { - type: "composite", - property: "test", - features: [ - { type: "numeric", name: "feature1", property: "prop1", access: 7, label: "" }, - { type: "numeric", name: "feature2", property: "prop2", access: 7, label: "" }, - { type: "binary", name: "feature3", property: "prop3", access: 7, label: "", value_on: "ON", value_off: "OFF" }, - ], - }; - - render( - , - ); - - expect(mockFeatureCallbacks.size).toStrictEqual(3); - expect(mockFeatureCallbacks.has("feature1")).toStrictEqual(true); - expect(mockFeatureCallbacks.has("feature2")).toStrictEqual(true); - expect(mockFeatureCallbacks.has("feature3")).toStrictEqual(true); - }); - - it("renders with empty features array", () => { - const feature: FeatureWithAnySubFeatures = { - type: "composite", - property: "test", - features: [], - }; - - render( - , - ); - - expect(mockFeatureCallbacks.size).toStrictEqual(0); - }); - - it("handles feature without features property", () => { - const feature = { - type: "composite", - property: "test", - } as FeatureWithAnySubFeatures; - - render( - , - ); - - expect(mockFeatureCallbacks.size).toStrictEqual(0); - }); - - it("passes all props to child Feature components", () => { - const feature: FeatureWithAnySubFeatures = { - type: "composite", - property: "test", - features: [{ type: "numeric", name: "feature1", property: "prop1", access: 7, label: "" }], - }; - const onChange = vi.fn(); - const onRead = vi.fn(); - - render( - , - ); - - const callback = mockFeatureCallbacks.get("feature1"); - expect(callback).toBeDefined(); - expect(callback?.onChange).toBeDefined(); - expect(callback?.onRead).toBeDefined(); - }); - }); - - describe("isFeatureRoot logic", () => { - it("identifies root when type=composite and parentFeatures is empty array", () => { - const feature: FeatureWithAnySubFeatures = { - type: "composite", - property: "test", - features: [ - { type: "numeric", name: "feature1", property: "prop1", access: 7, label: "" }, - { type: "numeric", name: "feature2", property: "prop2", access: 7, label: "" }, - { type: "binary", name: "feature3", property: "prop3", access: 7, label: "", value_on: "ON", value_off: "OFF" }, - ], - }; - - render( - , - ); - - expectApplyButtonRendered(true); - }); - - it("identifies root when type=composite with single non-composite/non-list parent", () => { - const feature: FeatureWithAnySubFeatures = { - type: "composite", - property: "test", - features: [{ type: "numeric", name: "feature1", property: "prop1", access: 7, label: "" }], - }; - - render( - , - ); - - expectApplyButtonRendered(true); - }); - - it("identifies non-root when type=composite with composite parent", () => { - const feature: FeatureWithAnySubFeatures = { - type: "composite", - property: "test", - features: [{ type: "numeric", name: "feature1", property: "prop1", access: 7, label: "" }], - }; - - render( - , - ); - - expectApplyButtonRendered(false); - }); - - it("identifies non-root when type=composite with list parent", () => { - const feature: FeatureWithAnySubFeatures = { - type: "composite", - property: "test", - features: [{ type: "numeric", name: "feature1", property: "prop1", access: 7, label: "" }], - }; - - render( - , - ); - - expectApplyButtonRendered(false); - }); - - it("identifies non-root when type=composite with multiple parents", () => { - const feature: FeatureWithAnySubFeatures = { - type: "composite", - property: "test", - features: [{ type: "numeric", name: "feature1", property: "prop1", access: 7, label: "" }], - }; - - render( - , - ); - - expectApplyButtonRendered(false); - }); - - it("identifies non-root when type is not composite", () => { - const feature: FeatureWithAnySubFeatures = { - type: "list", - property: "test", - }; - - render( - , - ); - - expectApplyButtonRendered(false); - }); - - it("identifies non-root when parentFeatures is undefined", () => { - const feature: FeatureWithAnySubFeatures = { - type: "composite", - property: "test", - features: [{ type: "numeric", name: "feature1", property: "prop1", access: 7, label: "" }], - }; - - render( - , - ); - - expectApplyButtonRendered(false); - }); - }); - - describe("onFeatureChange - non-root behavior", () => { - it("passes onChange callback to child features", () => { - const onChange = vi.fn(); - const feature: FeatureWithAnySubFeatures = { - type: "composite", - property: "test_property", - features: [{ type: "numeric", name: "feature1", property: "feature1", label: "", access: 7 }], - }; - - render( - , - ); - - const featureCallback = mockFeatureCallbacks.get("feature1"); - - expect(featureCallback?.onChange).toBeDefined(); - }); - - it("calls onChange immediately with merged deviceState", async () => { - const onChange = vi.fn(); - const feature: FeatureWithAnySubFeatures = { - type: "composite", - property: "climate", - features: [{ type: "numeric", name: "temp", property: "temperature", label: "", access: 7 }], - }; - const deviceState = { existing: "value", humidity: 50 }; - - render( - , - ); - - const callback = mockFeatureCallbacks.get("temp"); - - // non-root should call onChange immediately - await callback?.onChange({ temperature: 22 }); - - await new Promise((resolve) => setTimeout(resolve, 10)); - - expect(onChange).toHaveBeenCalledTimes(1); - expect(onChange).toHaveBeenCalledWith({ - climate: { - existing: "value", - humidity: 50, - temperature: 22, - }, - }); - }); - - it("calls onChange immediately with merged deviceState without property", async () => { - const onChange = vi.fn(); - const feature: FeatureWithAnySubFeatures = { - type: "composite", - features: [{ type: "numeric", name: "temp", property: "temperature", label: "", access: 7 }], - }; - const deviceState = { existing: "value", humidity: 50 }; - - render( - , - ); - - const callback = mockFeatureCallbacks.get("temp"); - - // non-root should call onChange immediately - await callback?.onChange({ temperature: 22 }); - - await new Promise((resolve) => setTimeout(resolve, 10)); - - expect(onChange).toHaveBeenCalledTimes(1); - expect(onChange).toHaveBeenCalledWith({ - existing: "value", - humidity: 50, - temperature: 22, - }); - }); - - it("handles sequential changes", async () => { - const onChange = vi.fn(); - const feature: FeatureWithAnySubFeatures = { - type: "composite", - property: "test", - features: [{ type: "numeric", name: "feature1", property: "val", label: "", access: 7 }], - }; - - render( - , - ); - - const callback = mockFeatureCallbacks.get("feature1"); - - await callback?.onChange({ val: "first" }); - await new Promise((resolve) => setTimeout(resolve, 10)); - - expect(onChange).toHaveBeenLastCalledWith({ - test: { val: "first" }, - }); - - await callback?.onChange({ val: "second" }); - await new Promise((resolve) => setTimeout(resolve, 10)); - - expect(onChange).toHaveBeenLastCalledWith({ - test: { val: "second" }, - }); - }); - }); - - describe("onFeatureChange - root behavior", () => { - it("does not call onChange immediately when root composite changes", async () => { - const onChange = vi.fn(); - const feature: FeatureWithAnySubFeatures = { - type: "composite", - property: "test_property", - features: [{ type: "numeric", name: "feature1", property: "feature1", label: "", access: 7 }], - }; - - render( - , - ); - - const featureCallback = mockFeatureCallbacks.get("feature1"); - - await featureCallback?.onChange({ feature1: "test-value" }); - await new Promise((resolve) => setTimeout(resolve, 10)); - - expect(onChange).not.toHaveBeenCalled(); - }); - - it("wraps value with property when type=composite and property exists", async () => { - const onChange = vi.fn(); - const feature: FeatureWithAnySubFeatures = { - type: "composite", - property: "climate", - features: [{ type: "numeric", name: "temp", property: "temperature", label: "", access: 7 }], - }; - - render( - , - ); - - const callback = mockFeatureCallbacks.get("temp"); - - expect(callback?.onChange).toBeDefined(); - - await callback?.onChange({ temperature: 22 }); - - await new Promise((resolve) => setTimeout(resolve, 10)); - - mockApplyCallback?.(); - - expect(onChange).toHaveBeenCalledWith({ climate: { temperature: 22 } }); - }); - - it("passes value unwrapped when type=composite without property", async () => { - const onChange = vi.fn(); - const feature: FeatureWithAnySubFeatures = { - type: "composite", - features: [{ type: "numeric", name: "feature1", property: "feature1", label: "", access: 7 }], - }; - - render( - , - ); - - const callback = mockFeatureCallbacks.get("feature1"); - - expect(callback?.onChange).toBeDefined(); - - await callback?.onChange({ feature1: 21 }); - - await new Promise((resolve) => setTimeout(resolve, 10)); - - mockApplyCallback?.(); - - expect(onChange).toHaveBeenCalledWith({ feature1: 21 }); - }); - - it("accumulates state through setState", async () => { - const onChange = vi.fn(); - const feature: FeatureWithAnySubFeatures = { - type: "composite", - property: "settings", - features: [ - { type: "numeric", name: "feature1", property: "val1", label: "", access: 7 }, - { type: "numeric", name: "feature2", property: "val2", label: "", access: 7 }, - ], - }; - - render( - , - ); - - const callback1 = mockFeatureCallbacks.get("feature1"); - const callback2 = mockFeatureCallbacks.get("feature2"); - expect(callback1).toBeDefined(); - expect(callback2).toBeDefined(); - - await callback1?.onChange({ val1: 10 }); - await new Promise((resolve) => setTimeout(resolve, 10)); - - expect(onChange).toHaveBeenCalledTimes(0); - - await callback2?.onChange({ val2: 20 }); - await new Promise((resolve) => setTimeout(resolve, 10)); - - expect(onChange).toHaveBeenCalledTimes(0); - - mockApplyCallback?.(); - - expect(onChange).toHaveBeenCalledTimes(1); - expect(onChange).toHaveBeenCalledWith({ settings: { val1: 10, val2: 20 } }); - }); - - it("accumulates state through setState with merged deviceState", async () => { - const onChange = vi.fn(); - const feature: FeatureWithAnySubFeatures = { - type: "composite", - property: "settings", - features: [ - { type: "numeric", name: "feature1", property: "val1", label: "", access: 7 }, - { type: "numeric", name: "feature2", property: "val2", label: "", access: 7 }, - ], - }; - const deviceState = { existing: "data" }; - - render( - , - ); - - const callback1 = mockFeatureCallbacks.get("feature1"); - const callback2 = mockFeatureCallbacks.get("feature2"); - - await callback1?.onChange({ val1: 10 }); - await new Promise((resolve) => setTimeout(resolve, 10)); - - expect(onChange).toHaveBeenCalledTimes(0); - - await callback2?.onChange({ val2: 20 }); - await new Promise((resolve) => setTimeout(resolve, 10)); - - expect(onChange).toHaveBeenCalledTimes(0); - - mockApplyCallback?.(); - - expect(onChange).toHaveBeenCalledTimes(1); - expect(onChange).toHaveBeenLastCalledWith({ - settings: { - existing: "data", - val1: 10, - val2: 20, - }, - }); - }); - }); - - describe("onRootApply callback", () => { - it("calls onChange with property-wrapped combined state", async () => { - const onChange = vi.fn(); - const feature: FeatureWithAnySubFeatures = { - type: "composite", - property: "test_property", - features: [{ type: "numeric", name: "feature1", property: "feature1", label: "", access: 7 }], - }; - const deviceState = { temperature: 25, humidity: 60 }; - - render( - , - ); - - const featureCallback = mockFeatureCallbacks.get("feature1"); - - await featureCallback?.onChange({ feature1: "test-value" }); - - await new Promise((resolve) => setTimeout(resolve, 10)); - - mockApplyCallback?.(); - - expect(onChange).toHaveBeenCalledTimes(1); - expect(onChange).toHaveBeenCalledWith({ - test_property: { - temperature: 25, - humidity: 60, - feature1: "test-value", - }, - }); - }); - - it("calls onChange with unwrapped combined state when no property", async () => { - const onChange = vi.fn(); - const feature: FeatureWithAnySubFeatures = { - type: "composite", - features: [{ type: "numeric", name: "feature1", property: "prop", label: "", access: 7 }], - }; - const deviceState = { temperature: 25, humidity: 60 }; - - render( - , - ); - - const featureCallback = mockFeatureCallbacks.get("feature1"); - - await featureCallback?.onChange({ prop: "test-value" }); - - await new Promise((resolve) => setTimeout(resolve, 10)); - - mockApplyCallback?.(); - - expect(onChange).toHaveBeenCalledTimes(1); - expect(onChange).toHaveBeenCalledWith({ - temperature: 25, - humidity: 60, - prop: "test-value", - }); - }); - - it("merges deviceState with accumulated state changes", async () => { - const onChange = vi.fn(); - const feature: FeatureWithAnySubFeatures = { - type: "composite", - property: "settings", - features: [ - { type: "numeric", name: "feature1", property: "val1", label: "", access: 7 }, - { type: "numeric", name: "feature2", property: "val2", label: "", access: 7 }, - { type: "numeric", name: "feature3", property: "val3", label: "", access: 7 }, - ], - }; - const deviceState = { existing: "value", val1: "old" }; - - render( - , - ); - - const callback1 = mockFeatureCallbacks.get("feature1"); - const callback2 = mockFeatureCallbacks.get("feature2"); - const callback3 = mockFeatureCallbacks.get("feature3"); - - await callback1?.onChange({ val1: "new1" }); - await callback2?.onChange({ val2: "new2" }); - await callback3?.onChange({ val3: "new3" }); - - await new Promise((resolve) => setTimeout(resolve, 10)); - - mockApplyCallback?.(); - - expect(onChange).toHaveBeenCalledTimes(1); - expect(onChange).toHaveBeenCalledWith({ settings: { existing: "value", val1: "new1", val2: "new2", val3: "new3" } }); - }); - - it("works with empty deviceState", async () => { - const onChange = vi.fn(); - const feature: FeatureWithAnySubFeatures = { - type: "composite", - property: "test", - features: [{ type: "numeric", name: "feature1", property: "prop", label: "", access: 7 }], - }; - - render( - , - ); - - const callback = mockFeatureCallbacks.get("feature1"); - await callback?.onChange({ prop: "value" }); - - await new Promise((resolve) => setTimeout(resolve, 10)); - - mockApplyCallback?.(); - - expect(onChange).toHaveBeenCalledWith({ - test: { - prop: "value", - }, - }); - }); - - it("works when no features have changed", async () => { - const onChange = vi.fn(); - const feature: FeatureWithAnySubFeatures = { - type: "composite", - property: "test", - features: [{ type: "numeric", name: "feature1", property: "prop", label: "", access: 7 }], - }; - - const deviceState = { prop: "original" }; - - render( - , - ); - - await new Promise((resolve) => setTimeout(resolve, 10)); - - mockApplyCallback?.(); - - expect(onChange).toHaveBeenCalledWith({ - test: deviceState, - }); - }); - }); - - describe("onFeatureRead callback", () => { - it("passes onRead to child Feature components", () => { - const onRead = vi.fn(); - const feature: FeatureWithAnySubFeatures = { - type: "composite", - property: "test", - features: [{ type: "numeric", name: "feature1", property: "prop", label: "", access: 7 }], - }; - - render( - , - ); - - const callback = mockFeatureCallbacks.get("feature1"); - expect(callback?.onRead).toBeDefined(); - }); - - it("wraps read value with property when type=composite", async () => { - const onRead = vi.fn(); - const feature: FeatureWithAnySubFeatures = { - type: "composite", - property: "climate", - features: [{ type: "numeric", name: "temp", property: "temperature", label: "", access: 7 }], - }; - - render( - , - ); - - const callback = mockFeatureCallbacks.get("temp"); - - await callback?.onRead?.({ temperature: 22 }); - - expect(onRead).toHaveBeenCalledWith({ - climate: { temperature: 22 }, - }); - }); - - it("passes unwrapped read value when no property", async () => { - const onRead = vi.fn(); - const feature: FeatureWithAnySubFeatures = { - type: "composite", - features: [{ type: "numeric", name: "feature1", property: "prop", label: "", access: 7 }], - }; - - render( - , - ); - - const callback = mockFeatureCallbacks.get("feature1"); - - await callback?.onRead?.({ prop: "value" }); - - expect(onRead).toHaveBeenCalledWith({ prop: "value" }); - }); - - it("passes read value directly when type is not composite", async () => { - const onRead = vi.fn(); - const feature: FeatureWithAnySubFeatures = { - type: "switch", - property: "climate", - features: [{ type: "numeric", name: "feature1", property: "prop", label: "", access: 7 }], - }; - - render( - , - ); - - const callback = mockFeatureCallbacks.get("feature1"); - - await callback?.onRead?.({ prop: "value" }); - - expect(onRead).toHaveBeenCalledWith({ prop: "value" }); - }); - }); - - it("resets state when device ieee_address changes", async () => { - const onChange = vi.fn(); - const feature: FeatureWithAnySubFeatures = { - type: "composite", - property: "test", - features: [{ type: "numeric", name: "feature1", property: "prop", label: "", access: 7 }], - }; - - const { rerender } = render( - , - ); - - const callback = mockFeatureCallbacks.get("feature1"); - await callback?.onChange({ prop: "value1" }); - - await new Promise((resolve) => setTimeout(resolve, 10)); - - mockFeatureCallbacks.clear(); - - rerender( - , - ); - - await new Promise((resolve) => setTimeout(resolve, 10)); - - mockApplyCallback?.(); - - // state was reset - expect(onChange).toHaveBeenCalledWith({ test: {} }); - }); - - it("handles endpointSpecific prop", () => { - const feature: FeatureWithAnySubFeatures = { - type: "composite", - property: "test", - features: [{ type: "numeric", name: "feature1", property: "prop", label: "", access: 7 }], - }; - - render( - , - ); - - expect(mockFeatureCallbacks.size).toStrictEqual(1); - }); - - it("handles steps prop", () => { - const feature: FeatureWithAnySubFeatures = { - type: "composite", - property: "test", - features: [{ type: "numeric", name: "feature1", property: "prop", label: "", access: 7 }], - }; - - const steps = { - feature1: [ - { value: 1, name: "One" }, - { value: 2, name: "Two" }, - ], - }; - - render( - , - ); - - expect(mockFeatureCallbacks.size).toStrictEqual(1); - }); - - it("handles complex nested deviceState", async () => { - const onChange = vi.fn(); - const feature: FeatureWithAnySubFeatures = { - type: "composite", - property: "config", - features: [{ type: "numeric", name: "feature1", property: "nested", label: "", access: 7 }], - }; - - const deviceState = { - nested: { deep: { value: "original" } }, - other: [1, 2, 3], - }; - - render( - , - ); - - const callback = mockFeatureCallbacks.get("feature1"); - await callback?.onChange({ nested: { deep: { value: "updated" } } }); - - await new Promise((resolve) => setTimeout(resolve, 10)); - - mockApplyCallback?.(); - - expect(onChange).toHaveBeenCalledTimes(1); - expect(onChange).toHaveBeenCalledWith({ config: { nested: { deep: { value: "updated" } }, other: [1, 2, 3] } }); - }); - - it("handles many features", () => { - const features = Array.from({ length: 20 }, (_, i) => ({ - type: "numeric" as const, - name: `feature${i}`, - property: `prop${i}`, - label: "", - access: 7, - })); - - const feature: FeatureWithAnySubFeatures = { - type: "composite", - property: "test", - features, - }; - - render(); - - expect(mockFeatureCallbacks.size).toStrictEqual(20); - }); -}); diff --git a/test/features/StatusIndicator.test.tsx b/test/features/StatusIndicator.test.tsx new file mode 100644 index 000000000..61ab94e1f --- /dev/null +++ b/test/features/StatusIndicator.test.tsx @@ -0,0 +1,369 @@ +import { act, createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { FeatureReadingContext, type FeatureReadingState, type WriteState } from "../../src/components/features/FeatureReadingContext.js"; +import StatusIndicator from "../../src/components/features/StatusIndicator.js"; + +let container: HTMLDivElement | null = null; +let root: Root | null = null; + +// Helper to render with context +function renderWithContext(contextValue: Partial) { + const defaultContext: FeatureReadingState = { + isReading: false, + isReadQueued: false, + readTimedOut: false, + readErrorDetails: undefined, + isUnknown: false, + writeState: undefined, + setWriteState: undefined, + onRetry: undefined, + setOnRetry: undefined, + onSync: undefined, + ...contextValue, + }; + + void act(() => { + root?.render(createElement(FeatureReadingContext.Provider, { value: defaultContext }, createElement(StatusIndicator))); + }); + + return container; +} + +describe("StatusIndicator", () => { + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + void act(() => { + root?.unmount(); + }); + container?.remove(); + container = null; + root = null; + }); + + describe("Unknown state", () => { + it("should show ? icon when value is unknown", () => { + renderWithContext({ isUnknown: true }); + const questionMark = container?.textContent; + expect(questionMark).toContain("?"); + }); + + it("should show tooltip for unknown value", () => { + renderWithContext({ isUnknown: true }); + const tooltip = container?.querySelector("[data-tip]"); + expect(tooltip?.getAttribute("data-tip")).toContain("unknown"); + }); + + it("should not show ? when pending (even if unknown)", () => { + const writeState: WriteState = { + isPending: true, + isError: false, + isTimedOut: false, + isConfirmed: false, + }; + renderWithContext({ isUnknown: true, writeState }); + // Should show pending indicator, not ? + const warningDot = container?.querySelector(".bg-warning"); + expect(warningDot).not.toBeNull(); + }); + }); + + describe("Idle state", () => { + it("should render nothing when idle (no writeState, not reading)", () => { + renderWithContext({}); + // StatusIndicator returns null, so no visible children + expect(container?.querySelector(".bg-error")).toBeNull(); + expect(container?.querySelector(".bg-warning")).toBeNull(); + expect(container?.querySelector(".bg-success")).toBeNull(); + expect(container?.querySelector("svg")).toBeNull(); + }); + + it("should render nothing when writeState is all false", () => { + const writeState: WriteState = { + isPending: false, + isError: false, + isTimedOut: false, + isConfirmed: false, + }; + renderWithContext({ writeState }); + // StatusIndicator returns null, so no visible children + expect(container?.querySelector(".bg-error")).toBeNull(); + expect(container?.querySelector(".bg-warning")).toBeNull(); + expect(container?.querySelector(".bg-success")).toBeNull(); + expect(container?.querySelector("svg")).toBeNull(); + }); + }); + + describe("Error states (highest priority)", () => { + it("should show red dot for error", () => { + const writeState: WriteState = { + isPending: false, + isError: true, + isTimedOut: false, + isConfirmed: false, + }; + renderWithContext({ writeState }); + const dot = container?.querySelector(".bg-error"); + expect(dot).not.toBeNull(); + }); + + it("should show red dot for timeout", () => { + const writeState: WriteState = { + isPending: false, + isError: false, + isTimedOut: true, + isConfirmed: false, + }; + renderWithContext({ writeState }); + const dot = container?.querySelector(".bg-error"); + expect(dot).not.toBeNull(); + }); + + it("should NOT show red dot for read timeout (? or nothing is sufficient)", () => { + // Read timeout alone doesn't warrant a red error indicator + // If value is unknown, the "?" shows; if known, old value remains visible + renderWithContext({ readTimedOut: true }); + const dot = container?.querySelector(".bg-error"); + expect(dot).toBeNull(); + }); + + it("should display error details in tooltip when available", () => { + const writeState: WriteState = { + isPending: false, + isError: true, + isTimedOut: false, + isConfirmed: false, + errorDetails: { + code: "TIMEOUT", + message: "Device did not respond", + }, + }; + renderWithContext({ writeState }); + const tooltip = container?.querySelector("[data-tip]"); + expect(tooltip?.getAttribute("data-tip")).toContain("TIMEOUT"); + expect(tooltip?.getAttribute("data-tip")).toContain("Device did not respond"); + }); + + it("should display ZCL status in tooltip when available", () => { + const writeState: WriteState = { + isPending: false, + isError: true, + isTimedOut: false, + isConfirmed: false, + errorDetails: { + code: "ZCL_ERROR", + message: "Invalid value", + zcl_status: 135, + }, + }; + renderWithContext({ writeState }); + const tooltip = container?.querySelector("[data-tip]"); + expect(tooltip?.getAttribute("data-tip")).toContain("ZCL: 135"); + }); + }); + + describe("Partial success state", () => { + it("should show warning triangle for partial success", () => { + const writeState: WriteState = { + isPending: false, + isError: false, + isTimedOut: false, + isConfirmed: false, + isPartial: true, + failedAttributes: { color_temp: "Unsupported" }, + }; + renderWithContext({ writeState }); + const svg = container?.querySelector("svg"); + expect(svg).not.toBeNull(); + const tooltip = container?.querySelector("[data-tip]"); + expect(tooltip?.getAttribute("data-tip")).toContain("Partial success"); + expect(tooltip?.getAttribute("data-tip")).toContain("color_temp: Unsupported"); + }); + + it("should list multiple failed attributes", () => { + const writeState: WriteState = { + isPending: false, + isError: false, + isTimedOut: false, + isConfirmed: false, + isPartial: true, + failedAttributes: { + color_temp: "Unsupported", + hue: "Out of range", + }, + }; + renderWithContext({ writeState }); + const tooltip = container?.querySelector("[data-tip]"); + const tip = tooltip?.getAttribute("data-tip") || ""; + expect(tip).toContain("color_temp: Unsupported"); + expect(tip).toContain("hue: Out of range"); + }); + }); + + describe("Queued state", () => { + it("should show clock icon for queued write (sleepy device)", () => { + const writeState: WriteState = { + isPending: false, + isError: false, + isTimedOut: false, + isConfirmed: false, + isQueued: true, + }; + renderWithContext({ writeState }); + const dot = container?.querySelector(".bg-warning"); + expect(dot).not.toBeNull(); + const tooltip = container?.querySelector("[data-tip]"); + expect(tooltip?.getAttribute("data-tip")).toContain("sleepy device"); + }); + + it("should show warning dot for queued read (sleepy device)", () => { + renderWithContext({ isReadQueued: true }); + const dot = container?.querySelector(".bg-warning"); + expect(dot).not.toBeNull(); + const tooltip = container?.querySelector("[data-tip]"); + expect(tooltip?.getAttribute("data-tip")).toContain("sleepy device"); + }); + }); + + describe("Pending state", () => { + it("should show warning dot when pending", () => { + const writeState: WriteState = { + isPending: true, + isError: false, + isTimedOut: false, + isConfirmed: false, + }; + renderWithContext({ writeState }); + const dot = container?.querySelector(".bg-warning"); + expect(dot).not.toBeNull(); + }); + + it("should show warning dot when reading", () => { + renderWithContext({ isReading: true }); + const dot = container?.querySelector(".bg-warning"); + expect(dot).not.toBeNull(); + }); + }); + + describe("Confirmed state", () => { + it("should show green dot when confirmed", () => { + const writeState: WriteState = { + isPending: false, + isError: false, + isTimedOut: false, + isConfirmed: true, + }; + renderWithContext({ writeState }); + const dot = container?.querySelector(".bg-success"); + expect(dot).not.toBeNull(); + }); + }); + + describe("State priority", () => { + it("should prioritize error over partial", () => { + const writeState: WriteState = { + isPending: false, + isError: true, + isTimedOut: false, + isConfirmed: false, + isPartial: true, + failedAttributes: { color: "Error" }, + }; + renderWithContext({ writeState }); + // Should show error (red dot), not partial (warning triangle) + const errorDot = container?.querySelector(".bg-error"); + expect(errorDot).not.toBeNull(); + }); + + it("should prioritize partial over queued", () => { + const writeState: WriteState = { + isPending: false, + isError: false, + isTimedOut: false, + isConfirmed: false, + isPartial: true, + isQueued: true, + failedAttributes: { color: "Error" }, + }; + renderWithContext({ writeState }); + const tooltip = container?.querySelector("[data-tip]"); + expect(tooltip?.getAttribute("data-tip")).toContain("Partial success"); + }); + + it("should prioritize queued over pending", () => { + const writeState: WriteState = { + isPending: true, + isError: false, + isTimedOut: false, + isConfirmed: false, + isQueued: true, + }; + renderWithContext({ writeState }); + const tooltip = container?.querySelector("[data-tip]"); + expect(tooltip?.getAttribute("data-tip")).toContain("sleepy device"); + }); + }); + + describe("Read states", () => { + it("should show warning dot when reading", () => { + renderWithContext({ isReading: true }); + const dot = container?.querySelector(".bg-warning"); + expect(dot).not.toBeNull(); + }); + + it("should show warning dot when read is queued (sleepy device)", () => { + renderWithContext({ isReadQueued: true }); + const dot = container?.querySelector(".bg-warning"); + expect(dot).not.toBeNull(); + const tooltip = container?.querySelector("[data-tip]"); + expect(tooltip?.getAttribute("data-tip")).toContain("sleepy device"); + }); + + it("should show red dot with error details when read fails with error", () => { + renderWithContext({ + readTimedOut: true, + readErrorDetails: { + code: "TIMEOUT", + message: "Device did not respond", + }, + }); + const dot = container?.querySelector(".bg-error"); + expect(dot).not.toBeNull(); + const tooltip = container?.querySelector("[data-tip]"); + expect(tooltip?.getAttribute("data-tip")).toContain("TIMEOUT"); + expect(tooltip?.getAttribute("data-tip")).toContain("Device did not respond"); + }); + + it("should show ZCL status in read error tooltip when available", () => { + renderWithContext({ + readTimedOut: true, + readErrorDetails: { + code: "ZCL_ERROR", + message: "Unsupported attribute", + zcl_status: 134, + }, + }); + const tooltip = container?.querySelector("[data-tip]"); + expect(tooltip?.getAttribute("data-tip")).toContain("ZCL: 134"); + }); + + it("should NOT show red dot when readTimedOut but no error details (frontend timeout)", () => { + // Frontend timeout without backend error details - "?" for unknown is sufficient + renderWithContext({ readTimedOut: true }); + const errorDot = container?.querySelector(".bg-error"); + expect(errorDot).toBeNull(); + }); + + it("should prioritize read queued over isReading", () => { + // If both somehow set (shouldn't happen), queued takes priority + renderWithContext({ isReading: true, isReadQueued: true }); + const tooltip = container?.querySelector("[data-tip]"); + expect(tooltip?.getAttribute("data-tip")).toContain("sleepy device"); + }); + }); +}); diff --git a/test/features/syncRetryButton.logic.test.ts b/test/features/syncRetryButton.logic.test.ts new file mode 100644 index 000000000..647fa1618 --- /dev/null +++ b/test/features/syncRetryButton.logic.test.ts @@ -0,0 +1,451 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Tests for SyncRetryButton visibility, styling, and click behavior logic. + * These tests verify the button's decision logic without rendering the full component. + */ + +// Button visibility logic (extracted from component) +function shouldShowButton(state: { + onSync: (() => void) | null; + writeState: { isPending?: boolean; isConflict?: boolean; isTimedOut?: boolean } | null; +}): boolean { + const { onSync, writeState } = state; + return !!(onSync || writeState?.isConflict || writeState?.isTimedOut || writeState?.isPending); +} + +// Button class logic (extracted from component) +function getButtonClass(state: { + writeState: { isPending?: boolean; isConflict?: boolean; isTimedOut?: boolean } | null; + isReading: boolean; + readTimedOut: boolean; +}): string { + const { writeState, isReading, readTimedOut } = state; + + if (writeState?.isConflict || writeState?.isTimedOut) { + return "btn btn-xs btn-square btn-error btn-soft"; + } + if (writeState?.isPending || isReading) { + return "btn btn-xs btn-square btn-warning btn-soft"; + } + if (readTimedOut) { + return "btn btn-xs btn-square btn-error btn-soft"; + } + return "btn btn-xs btn-square btn-primary btn-soft"; +} + +// Button disabled logic +function isButtonDisabled(state: { writeState: { isPending?: boolean } | null; isReading: boolean }): boolean { + return !!(state.writeState?.isPending || state.isReading); +} + +// Icon selection logic +type IconType = "redo" | "sync"; +function getIconType(state: { writeState: { isPending?: boolean; isConflict?: boolean; isTimedOut?: boolean } | null }): IconType { + if (state.writeState?.isConflict || state.writeState?.isTimedOut) { + return "redo"; + } + return "sync"; +} + +// Tooltip logic +function getTooltip( + state: { + writeState: { isPending?: boolean; isConflict?: boolean; isTimedOut?: boolean } | null; + isReading: boolean; + readTimedOut: boolean; + }, + translations: Record, +): string { + const { writeState, isReading, readTimedOut } = state; + + if (writeState?.isTimedOut) { + return translations.no_response_retry; + } + if (writeState?.isConflict) { + return translations.device_returned_different_retry; + } + if (writeState?.isPending) { + return translations.sending_to_device; + } + if (readTimedOut) { + return translations.read_timed_out; + } + if (isReading) { + return translations.reading_from_device; + } + return translations.get_value_from_device; +} + +// Click behavior logic +type ClickAction = "sync" | "retry"; +function getClickAction(state: { writeState: { isPending?: boolean; isConflict?: boolean; isTimedOut?: boolean } | null }): ClickAction { + if (state.writeState?.isConflict || state.writeState?.isTimedOut) { + return "retry"; + } + return "sync"; +} + +describe("SyncRetryButton Logic", () => { + const translations = { + get_value_from_device: "Sync", + reading_from_device: "Reading...", + sending_to_device: "Sending...", + no_response_retry: "No response, retry?", + device_returned_different_retry: "Different value, retry?", + read_timed_out: "Read timed out", + }; + + describe("Visibility Conditions", () => { + it("should show when onSync is provided", () => { + expect( + shouldShowButton({ + onSync: () => {}, + writeState: null, + }), + ).toBe(true); + }); + + it("should show when writeState.isPending", () => { + expect( + shouldShowButton({ + onSync: null, + writeState: { isPending: true }, + }), + ).toBe(true); + }); + + it("should show when writeState.isConflict", () => { + expect( + shouldShowButton({ + onSync: null, + writeState: { isConflict: true }, + }), + ).toBe(true); + }); + + it("should show when writeState.isTimedOut", () => { + expect( + shouldShowButton({ + onSync: null, + writeState: { isTimedOut: true }, + }), + ).toBe(true); + }); + + it("should NOT show when none of the above conditions are met", () => { + expect( + shouldShowButton({ + onSync: null, + writeState: null, + }), + ).toBe(false); + + expect( + shouldShowButton({ + onSync: null, + writeState: { isPending: false, isConflict: false, isTimedOut: false }, + }), + ).toBe(false); + }); + }); + + describe("Button Class Logic", () => { + it("should be btn-error when isConflict", () => { + const result = getButtonClass({ + writeState: { isConflict: true }, + isReading: false, + readTimedOut: false, + }); + expect(result).toContain("btn-error"); + }); + + it("should be btn-error when isTimedOut", () => { + const result = getButtonClass({ + writeState: { isTimedOut: true }, + isReading: false, + readTimedOut: false, + }); + expect(result).toContain("btn-error"); + }); + + it("should be btn-warning when isPending", () => { + const result = getButtonClass({ + writeState: { isPending: true }, + isReading: false, + readTimedOut: false, + }); + expect(result).toContain("btn-warning"); + }); + + it("should be btn-warning when isReading", () => { + const result = getButtonClass({ + writeState: null, + isReading: true, + readTimedOut: false, + }); + expect(result).toContain("btn-warning"); + }); + + it("should be btn-error when readTimedOut", () => { + const result = getButtonClass({ + writeState: null, + isReading: false, + readTimedOut: true, + }); + expect(result).toContain("btn-error"); + }); + + it("should be btn-primary when idle", () => { + const result = getButtonClass({ + writeState: null, + isReading: false, + readTimedOut: false, + }); + expect(result).toContain("btn-primary"); + }); + + it("isConflict takes priority over isReading", () => { + const result = getButtonClass({ + writeState: { isConflict: true }, + isReading: true, + readTimedOut: false, + }); + expect(result).toContain("btn-error"); + expect(result).not.toContain("btn-warning"); + }); + }); + + describe("Disabled Logic", () => { + it("should be disabled when isPending", () => { + expect( + isButtonDisabled({ + writeState: { isPending: true }, + isReading: false, + }), + ).toBe(true); + }); + + it("should be disabled when isReading", () => { + expect( + isButtonDisabled({ + writeState: null, + isReading: true, + }), + ).toBe(true); + }); + + it("should be enabled when isConflict (for retry)", () => { + expect( + isButtonDisabled({ + writeState: { isPending: false }, + isReading: false, + }), + ).toBe(false); + }); + + it("should be enabled when isTimedOut (for retry)", () => { + expect( + isButtonDisabled({ + writeState: { isPending: false }, + isReading: false, + }), + ).toBe(false); + }); + }); + + describe("Icon Logic", () => { + it("should use redo icon for conflict", () => { + expect( + getIconType({ + writeState: { isConflict: true }, + }), + ).toBe("redo"); + }); + + it("should use redo icon for timeout", () => { + expect( + getIconType({ + writeState: { isTimedOut: true }, + }), + ).toBe("redo"); + }); + + it("should use sync icon otherwise", () => { + expect( + getIconType({ + writeState: null, + }), + ).toBe("sync"); + + expect( + getIconType({ + writeState: { isPending: true }, + }), + ).toBe("sync"); + }); + }); + + describe("Tooltip Logic", () => { + it("should show timeout message when isTimedOut", () => { + expect(getTooltip({ writeState: { isTimedOut: true }, isReading: false, readTimedOut: false }, translations)).toBe("No response, retry?"); + }); + + it("should show conflict message when isConflict", () => { + expect(getTooltip({ writeState: { isConflict: true }, isReading: false, readTimedOut: false }, translations)).toBe( + "Different value, retry?", + ); + }); + + it("should show sending message when isPending", () => { + expect(getTooltip({ writeState: { isPending: true }, isReading: false, readTimedOut: false }, translations)).toBe("Sending..."); + }); + + it("should show read timeout message when readTimedOut", () => { + expect(getTooltip({ writeState: null, isReading: false, readTimedOut: true }, translations)).toBe("Read timed out"); + }); + + it("should show reading message when isReading", () => { + expect(getTooltip({ writeState: null, isReading: true, readTimedOut: false }, translations)).toBe("Reading..."); + }); + + it("should show sync message when idle", () => { + expect(getTooltip({ writeState: null, isReading: false, readTimedOut: false }, translations)).toBe("Sync"); + }); + + it("isTimedOut takes priority over isConflict in tooltip", () => { + expect(getTooltip({ writeState: { isTimedOut: true, isConflict: true }, isReading: false, readTimedOut: false }, translations)).toBe( + "No response, retry?", + ); + }); + }); + + describe("Click Behavior", () => { + it("should trigger sync action for normal click", () => { + expect( + getClickAction({ + writeState: null, + }), + ).toBe("sync"); + + expect( + getClickAction({ + writeState: { isPending: false, isConflict: false, isTimedOut: false }, + }), + ).toBe("sync"); + }); + + it("should trigger retry action when isConflict", () => { + expect( + getClickAction({ + writeState: { isConflict: true }, + }), + ).toBe("retry"); + }); + + it("should trigger retry action when isTimedOut", () => { + expect( + getClickAction({ + writeState: { isTimedOut: true }, + }), + ).toBe("retry"); + }); + }); + + describe("Click Handler Integration", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("sync click should only call onSync", () => { + const onSync = vi.fn(); + const onRetry = vi.fn(); + + // Simulate handleClick logic for sync + const writeState = { isConflict: false, isTimedOut: false }; + if (writeState.isConflict || writeState.isTimedOut) { + onSync(); + setTimeout(() => onRetry(), 2000); + } else { + onSync(); + } + + expect(onSync).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(3000); + expect(onRetry).not.toHaveBeenCalled(); + }); + + it("retry click should call onSync then onRetry after 2s", () => { + const onSync = vi.fn(); + const onRetry = vi.fn(); + + // Simulate handleClick logic for retry + const writeState = { isConflict: true, isTimedOut: false }; + if (writeState.isConflict || writeState.isTimedOut) { + onSync(); + setTimeout(() => onRetry(), 2000); + } else { + onSync(); + } + + expect(onSync).toHaveBeenCalledTimes(1); + expect(onRetry).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(2000); + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + it("retry click with timeout state should also call onSync then onRetry", () => { + const onSync = vi.fn(); + const onRetry = vi.fn(); + + // Simulate handleClick logic for retry (timeout case) + const writeState = { isConflict: false, isTimedOut: true }; + if (writeState.isConflict || writeState.isTimedOut) { + onSync(); + setTimeout(() => onRetry(), 2000); + } else { + onSync(); + } + + expect(onSync).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(2000); + expect(onRetry).toHaveBeenCalledTimes(1); + }); + }); + + describe("State Priority", () => { + it("writeState errors take priority over read states for class", () => { + // isConflict should override isReading + let result = getButtonClass({ + writeState: { isConflict: true, isPending: false }, + isReading: true, + readTimedOut: false, + }); + expect(result).toContain("btn-error"); + + // isTimedOut should override readTimedOut + result = getButtonClass({ + writeState: { isTimedOut: true, isPending: false }, + isReading: false, + readTimedOut: true, + }); + expect(result).toContain("btn-error"); + }); + + it("isPending takes priority over readTimedOut", () => { + const result = getButtonClass({ + writeState: { isPending: true }, + isReading: false, + readTimedOut: true, + }); + expect(result).toContain("btn-warning"); + }); + }); +}); diff --git a/test/setup.ts b/test/setup.ts new file mode 100644 index 000000000..5d235c3fe --- /dev/null +++ b/test/setup.ts @@ -0,0 +1,22 @@ +import { afterEach, vi } from "vitest"; + +// Mock i18next +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (fn: (obj: Record) => string) => { + const keys: Record = { + get_value_from_device: "Sync", + reading_from_device: "Reading...", + sending_to_device: "Sending...", + no_response_retry: "No response, retry?", + device_returned_different_retry: "Different value, retry?", + read_timed_out: "Read timed out", + }; + return fn(keys); + }, + }), +})); + +afterEach(() => { + vi.clearAllTimers(); +}); diff --git a/test/utils/mockContext.tsx b/test/utils/mockContext.tsx new file mode 100644 index 000000000..adc79a1e3 --- /dev/null +++ b/test/utils/mockContext.tsx @@ -0,0 +1,23 @@ +import { createElement, type ReactNode } from "react"; +import { vi } from "vitest"; +import { FeatureReadingContext, type FeatureReadingState } from "../../src/components/features/FeatureReadingContext.js"; + +export const createMockContext = (overrides: Partial = {}): FeatureReadingState => ({ + isReading: false, + isReadQueued: false, + readTimedOut: false, + readErrorDetails: undefined, + isUnknown: false, + writeState: undefined, + setWriteState: vi.fn(), + onRetry: undefined, + setOnRetry: vi.fn(), + onSync: undefined, + ...overrides, +}); + +export const createContextWrapper = (contextValue: FeatureReadingState) => { + return function ContextWrapper({ children }: { children: ReactNode }) { + return createElement(FeatureReadingContext.Provider, { value: contextValue }, children); + }; +}; diff --git a/test/utils/renderHook.tsx b/test/utils/renderHook.tsx new file mode 100644 index 000000000..7d466b9d4 --- /dev/null +++ b/test/utils/renderHook.tsx @@ -0,0 +1,61 @@ +import { act, createElement, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; + +type RenderHookResult = { + result: { current: T }; + rerender: (newProps?: P) => void; + unmount: () => void; +}; + +export function renderHook>( + hook: (props: P) => T, + options?: { + initialProps?: P; + wrapper?: React.ComponentType<{ children: ReactNode }>; + }, +): RenderHookResult { + const { initialProps, wrapper: Wrapper } = options ?? {}; + const resultRef = { current: undefined as T }; + let root: Root | null = null; + let container: HTMLDivElement | null = null; + let currentProps = initialProps as P; + + function TestComponent({ hookProps }: { hookProps: P }) { + resultRef.current = hook(hookProps); + return null; + } + + function render(props: P) { + const element = createElement(TestComponent, { hookProps: props }); + const wrapped = Wrapper ? createElement(Wrapper, null, element) : element; + + void act(() => { + if (!root) { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + } + root.render(wrapped); + }); + } + + render(currentProps); + + return { + result: resultRef, + rerender: (newProps?: P) => { + if (newProps !== undefined) { + currentProps = newProps; + } + render(currentProps); + }, + unmount: () => { + void act(() => { + root?.unmount(); + root = null; + container?.remove(); + container = null; + }); + }, + }; +} diff --git a/vite.config.mts b/vite.config.mts index f5ea3f296..b67b7f005 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -6,7 +6,9 @@ import { startServer } from "./mocks/ws.js"; // biome-ignore lint/suspicious/useAwait: follows API export default defineConfig(async ({ command, mode }) => { - if (command === "serve" && mode !== "test") { + // Only start mock server if no real backend is configured + const hasRealBackend = process.env.Z2M_API_URI || process.env.VITE_Z2M_API_URLS; + if (command === "serve" && mode !== "test" && !hasRealBackend) { startServer(); } @@ -30,6 +32,7 @@ export default defineConfig(async ({ command, mode }) => { root: ".", dir: "test", environment: "jsdom", + setupFiles: ["./test/setup.ts"], typecheck: { enabled: true, },