Skip to content

feat: Command Response API integration with visual feedback#409

Draft
rusty-art wants to merge 1 commit intoNerivec:mainfrom
rusty-art:main
Draft

feat: Command Response API integration with visual feedback#409
rusty-art wants to merge 1 commit intoNerivec:mainfrom
rusty-art:main

Conversation

@rusty-art
Copy link

Summary

Integrates the Zigbee2MQTT Command Response API to provide real-time visual feedback when users interact with device controls.

Depends on: Backend PR #30774 (Command Response API)
Related: Backend feature request #30679

Features

  • StatusIndicator: Visual colored dot showing command status

    • Orange = pending/queued
    • Green = confirmed (auto-clears after 2s)
    • Red = error (with tooltip details)
  • SyncRetryButton: Retry failed commands with one click

  • Sleepy device support: Optimistic UI updates for battery-powered devices that may not respond immediately (queued commands)

Changes

New Files

  • src/components/editors/useCommandFeedback.ts - Minimal hook for command tracking
  • src/components/features/FeatureReadingContext.tsx - React context for read state
  • src/components/features/StatusIndicator.tsx - Visual status dot component
  • src/components/features/SyncRetryButton.tsx - Retry button component

Modified

  • Editors: ColorEditor, EnumEditor, RangeEditor, TextEditor - Command Response API integration
  • Features: Binary, Feature, FeatureSubFeatures, FeatureWrapper, Gradient, List, Numeric, Text
  • WebSocketManager: Transaction ID generation and callback system
  • store.ts: updateDeviceStates action for optimistic updates
  • types.ts: CommandResponse type definitions

Tests

  • StatusIndicator.test.tsx - Visual state rendering tests (26 tests)
  • syncRetryButton.logic.test.ts - Retry mechanism tests (34 tests)
  • Fixed test infrastructure (added @testing-library/react dependency)

Test Plan

  • StatusIndicator shows correct states (pending → confirmed → idle)
  • Error states show tooltip with error code/message
  • Partial success shows warning triangle with failed attributes
  • Retry button re-sends failed commands
  • Sleepy devices show queued state, then clear after 2s
  • Composites with Apply button show per-field status indicators

Implementation Details & Design Decisions

Frontend Feedback Mechanism - Functional Requirements

Context

The backend Command Response API (v2.0) provides:

  • Topic: zigbee2mqtt/[friendly_name]/response (unified for SET and GET)
  • Correlation: z2m.request_id echoed back from request
  • Status values: ok, error, pending, partial
  • Type field: type: 'set' | 'get' to distinguish operation

Related:


Backend Response Structure

interface CommandResponse {
    type: 'set' | 'get';
    status: 'ok' | 'partial' | 'error' | 'pending';
    target: string;
    data?: { ... };           // Present if status is 'ok' or 'partial'
    failed?: { ... };         // Present if status is 'partial'
    error?: {                 // Present if status is 'error'
        code?: 'TIMEOUT' | 'NO_ROUTE' | 'ZCL_ERROR' | 'UNKNOWN';
        message: string;
        zcl_status?: number;
    };
    z2m: {
        request_id: string;   // Echoed from request
        final: boolean;       // TRUE = Gateway finished processing
        elapsed_ms?: number;
    }
}

Critical from backend spec:

'pending' = Command successfully queued (sendWhen: 'active'). NOTE: The request is closed; no further confirmation will be sent.

This confirms: for sleepy devices, we get ONE response with pending and then NO follow-up.


Production Behavior by Component Type

Upstream (production) has inconsistent behavior across editor types and contexts:

Component Context Has local state? Immediate feedback?
TextEditor Any YES (currentValue) YES (while typing)
RangeEditor Any YES (currentValue) YES (while dragging)
EnumEditor Standalone NO NO (waits for device)
EnumEditor Inside RangeEditor YES (uses RangeEditor state) YES
Binary Standalone NO NO (waits for device)
Binary Inside composite Varies YES (observed)
Any Sleepy device N/A NO (stuck on old value)

Summary:

  • TextEditor/RangeEditor: Always immediate feedback (have local state)
  • EnumEditor/Binary standalone: No immediate feedback (no local state, wait for device)
  • EnumEditor/Binary in composite: May have immediate feedback (context-dependent)
  • Sleepy devices: No feedback at all - UI stays on old value until device wakes

What the Backend Provides (SET operations)

Scenario Response channel (/response) State channel Has txId?
Non-sleepy, success {type:"set", status:"ok", z2m:{request_id:"X"}} New value YES
Non-sleepy, error {type:"set", status:"error", z2m:{request_id:"X"}, error:{...}} No change YES
Non-sleepy, partial {type:"set", status:"partial", z2m:{request_id:"X"}, data:{...}, failed:{...}} Partial value YES
Sleepy, queued {type:"set", status:"pending", z2m:{request_id:"X"}} No change (yet) YES
Sleepy, eventually executes Nothing New value NO

The Sleepy Device Gap

From backend spec:

'pending' = Command successfully queued (sendWhen: 'active'). NOTE: The request is closed; no further confirmation will be sent.

What this means:

  1. When we send to a sleepy device, backend responds immediately with status: "pending"
  2. The command is queued in the parent router
  3. When device wakes and executes, backend does NOT send another response
  4. We only see the new value on the state channel (no correlation ID)

Why backend can't send follow-up (technical explanation):

Zigbee DOES have a Transaction Sequence Number (TSN) - a 1-byte identifier (0-255) that the device echoes back in its ZCL response. However:

  1. Indirect Transmission: For sleepy devices, the Coordinator sends to the Parent Router, which buffers the command (typically 7 seconds). When the device wakes and polls, it receives the command and sends back a ZCL response with the TSN.

  2. Z2M Architecture: When sendWhen: 'active' is triggered, the zigbee-herdsman promise resolves immediately with "queued" status. Z2M returns pending with final: true and closes the transaction. The correlation ID is no longer tracked.

  3. When device responds: The ZCL response arrives (potentially minutes/hours later), but Z2M has no mapping from TSN → original request_id. The state is updated, but there's no way to emit a correlated response.

  4. TSN wraparound: The TSN is only 1 byte (256 values), making long-term tracking impractical - it would wrap around on a busy network.

Bottom line: The Zigbee protocol supports tracking, but Z2M deliberately closes the transaction at pending to avoid holding resources indefinitely. This is a reasonable architectural decision.

Our resolution:

  • Show orange dot while pending
  • For status: pending with final: true: optimistically update UI, clear indicator after 2 seconds
  • No green dot for sleepy resolution (can't confirm it was our command)

Known Limitation: Restart While Command Queued

When a command is queued for a sleepy device (pending + final), the frontend shows the user's requested values via optimistic updates. However, this state is in-memory only. If the frontend or backend restarts while a command is queued:

  • Frontend state is lost
  • Backend reloads from state.db (which has old values)
  • UI reverts to showing old values

The queued command still executes when the device wakes, but the UI won't reflect the pending change after restart.

This is strictly better than upstream behavior, which showed no feedback at all for sleepy devices.


Design Decision: Decoupled Value and Status

The Two Independent Channels

Zigbee provides two separate sources of information:

  1. Response Channel ({device}/response) - Command outcome with correlation ID

    • status: "ok" - Device accepted the ZCL command
    • status: "error" - Command failed (timeout, no route, ZCL error)
    • status: "pending" - Queued for sleepy device
    • status: "partial" - Some attributes succeeded, some failed
  2. State Channel ({device}) - Device truth (no correlation ID)

    • Reports actual device values
    • May arrive before, after, or independently of response

The Decoupled Model

Instead of trying to detect conflicts, we keep value and status independent:

Aspect Source What it shows
Value State channel Device truth (always)
Status Dot Response channel Command outcome

Why This Works:

  • Simpler: No complex value comparison logic
  • Correct: Always shows device truth when available
  • Predictable: Order of arrival determines what's shown
  • Resilient: Works even when state/response arrive out of order

Status Indicator Design

Visual: Colored dots (simple, consistent)

  • Orange dot = waiting/pending OR queued (sleepy device)
  • Green dot = confirmed (command accepted)
  • Red dot = error (command failed)
  • Warning triangle = partial success
  • No dot = baseline/idle

Accessibility: Tooltips on hover

  • Orange (pending): "Sending..."
  • Orange (queued): "Queued for sleepy device"
  • Green: "Confirmed"
  • Red: "Error: {code}: {message}"

Behavior Summary

Scenario Value shows Dot shows
User clicks User's choice (optimistic) Orange (pending)
State arrives (any) Device value (unchanged)
Response OK (unchanged) Green (2s) → None
Response ERROR (unchanged) Red
Response PENDING (sleepy) (unchanged) Orange (2s) → None
Response PARTIAL (unchanged) Warning triangle
Timeout (no response) (unchanged) Red

Integrates the Zigbee2MQTT Command Response API to provide real-time
visual feedback when users interact with device controls.

- **StatusIndicator**: Visual dot showing command status (pending/confirmed/error)
- **SyncRetryButton**: Retry failed commands with one click
- **Sleepy device support**: Optimistic UI updates for battery-powered devices
  that may not respond immediately (queued commands)

- `useCommandFeedback` hook: Minimal state machine for command tracking
- `FeatureReadingContext`: React context for read operation state
- WebSocketManager: Callback registration for transaction-based responses

- src/components/editors/useCommandFeedback.ts
- src/components/features/FeatureReadingContext.tsx
- src/components/features/StatusIndicator.tsx
- src/components/features/SyncRetryButton.tsx

- Editors: ColorEditor, EnumEditor, RangeEditor, TextEditor
- Features: Binary, Feature, FeatureSubFeatures, FeatureWrapper,
  Gradient, List, Numeric, Text
- WebSocketManager: Transaction ID generation and callback system
- store.ts: updateDeviceStates action for optimistic updates
- types.ts: CommandResponse type definitions

- StatusIndicator.test.tsx: Visual state rendering tests
- syncRetryButton.logic.test.ts: Retry mechanism tests
@Nerivec
Copy link
Owner

Nerivec commented Jan 24, 2026

Same problems as before, including the fact the PR tries to do far too much at once. The potential for issues/breaks is astronomical. (Same goes for the Z2M PR.)

@rusty-art
Copy link
Author

understood. Will revert to bare-minimum; and replace with the zigbee2mqtt (backend) preferred MQTT topics approach in discussion. Thanks for your patience and guidance.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants