Project 3: Smart Home Controller (Home Assistant/MQTT)

Project 3: Smart Home Controller (Home Assistant/MQTT)

Comprehensive Learning Guide Build a bidirectional smart home control surface with real-time state synchronization between Stream Deck and Home Assistant via nested WebSocket connections


Table of Contents

  1. Learning Objectives
  2. Deep Theoretical Foundation
  3. Complete Project Specification
  4. Real World Outcome
  5. Solution Architecture
  6. Phased Implementation Guide
  7. Testing Strategy
  8. Common Pitfalls & Debugging
  9. Extensions & Challenges
  10. Resources
  11. Self-Assessment Checklist

Metadata

Property Value
Difficulty Intermediate (Level 2)
Time Estimate 1-2 weeks
Main Language TypeScript
Alternative Languages JavaScript, Python, Go
Knowledge Areas IoT, Smart Home, WebSocket, Bidirectional Sync
Software/Tools Stream Deck, Home Assistant, MQTT (optional)
Main Book Designing Data-Intensive Applications by Martin Kleppmann
Prerequisites JavaScript/TypeScript, basic understanding of REST/WebSocket APIs, a Home Assistant instance (or MQTT broker), Stream Deck SDK basics (Projects 1-2)

Learning Objectives

By completing this project, you will master:

  • External API Integration: Connect your plugin to third-party services (Home Assistant) using their WebSocket APIs, handling authentication, message routing, and protocol-specific requirements.

  • Nested WebSocket Connections: Manage two simultaneous WebSocket connections - one inbound from Stream Deck and one outbound to Home Assistant - understanding how your plugin acts as a “WebSocket proxy” between systems.

  • Long-Lived Token Authentication: Implement secure authentication flows using Home Assistant’s long-lived access tokens, including secure storage in plugin settings and validation error handling.

  • State Change Event Subscriptions: Subscribe to real-time event streams from Home Assistant, filtering relevant entity changes and routing them to appropriate Stream Deck keys.

  • Bidirectional State Synchronization: Implement the complex dance of state sync where your UI, the physical device, and Home Assistant’s state can temporarily disagree - understanding optimistic updates vs. authoritative state.

  • Exponential Backoff Reconnection: Build resilient connections that gracefully handle network failures, Home Assistant restarts, and temporary outages using exponential backoff with jitter.

  • Entity State Representation: Map Home Assistant’s complex entity state objects (with attributes like brightness, color temperature, and friendly names) to meaningful visual representations on 72x72 pixel Stream Deck keys.

  • Error Handling in Distributed Systems: Handle the unique failure modes of distributed systems: network partitions, stale state, race conditions, and partial failures.


The Core Question You’re Answering

How do I create bidirectional communication between Stream Deck and external services with real-time state synchronization?

This project forces you to manage nested WebSocket connections - your plugin connects to Stream Deck via WebSocket, and simultaneously maintains a separate WebSocket connection to Home Assistant. You must handle authentication, state subscriptions, and graceful degradation when either connection fails. This is the pattern behind professional IoT control surfaces.

The hardest conceptual leap: your plugin isn’t the source of truth - Home Assistant is. When you press a key to toggle a light, you’re not toggling state locally then hoping it works. You’re sending a command, waiting for confirmation, and updating the UI only when the external service confirms the change.


Deep Theoretical Foundation

Home Assistant WebSocket API Protocol

Home Assistant exposes a WebSocket API that enables real-time communication with full entity control. Understanding this protocol is essential before writing code.

Home Assistant WebSocket Protocol
=================================

Connection: ws://homeassistant.local:8123/api/websocket

The protocol is message-based JSON over WebSocket:

1. AUTHENTICATION PHASE
   ---------------------
   Server -> Client: {"type": "auth_required", "ha_version": "2024.1.0"}
   Client -> Server: {"type": "auth", "access_token": "your_long_lived_token"}
   Server -> Client: {"type": "auth_ok", "ha_version": "2024.1.0"}
                 OR: {"type": "auth_invalid", "message": "Invalid access token"}

2. COMMAND/RESPONSE PATTERN
   -------------------------
   Every command includes an incrementing "id" field.
   Responses reference this id to correlate with requests.

   Client -> Server: {"id": 1, "type": "get_states"}
   Server -> Client: {"id": 1, "type": "result", "success": true, "result": [...]}

3. SUBSCRIPTION PATTERN
   ---------------------
   Subscribe once, receive many events:

   Client -> Server: {"id": 2, "type": "subscribe_events", "event_type": "state_changed"}
   Server -> Client: {"id": 2, "type": "result", "success": true}

   Then, for every state change in HA:
   Server -> Client: {
     "id": 2,
     "type": "event",
     "event": {
       "event_type": "state_changed",
       "data": {
         "entity_id": "light.living_room",
         "old_state": {...},
         "new_state": {...}
       }
     }
   }

Home Assistant WebSocket Protocol

Message ID Management: You must track message IDs and their pending callbacks. This is the request-response correlation pattern common in multiplexed protocols.

// Message ID correlation pattern
class HAWebSocket {
  private messageId = 0;
  private pendingRequests = new Map<number, {
    resolve: (result: any) => void;
    reject: (error: Error) => void;
  }>();

  async sendCommand(type: string, payload: object): Promise<any> {
    const id = ++this.messageId;

    return new Promise((resolve, reject) => {
      this.pendingRequests.set(id, { resolve, reject });
      this.ws.send(JSON.stringify({ id, type, ...payload }));
    });
  }

  handleMessage(message: any) {
    if (message.type === 'result' && this.pendingRequests.has(message.id)) {
      const { resolve, reject } = this.pendingRequests.get(message.id)!;
      this.pendingRequests.delete(message.id);

      if (message.success) {
        resolve(message.result);
      } else {
        reject(new Error(message.error?.message || 'Unknown error'));
      }
    }
  }
}

Nested WebSocket Connections: The Proxy Architecture

Your plugin operates at the intersection of two WebSocket connections. Understanding this architecture is crucial.

Nested WebSocket Architecture
=============================

+------------------------------------------------------------------+
|                        STREAM DECK APP                            |
|  +------------------------------------------------------------+  |
|  |                     Plugin Manager                          |  |
|  |                                                             |  |
|  |   "Hey plugin, user pressed the Living Room Light key"     |  |
|  +---------------------------+--------------------------------+  |
|                              |                                    |
|                              | WebSocket #1                       |
|                              | (Stream Deck SDK manages this)     |
|                              |                                    |
+------------------------------+------------------------------------+
                               |
                               v
+------------------------------+------------------------------------+
|                        YOUR PLUGIN                                |
|                                                                   |
|   +-----------------------+      +---------------------------+   |
|   |  Stream Deck Handler  |      |  Home Assistant Client    |   |
|   |-----------------------|      |---------------------------|   |
|   | onKeyDown()           |----->| callService('toggle')     |   |
|   | onWillAppear()        |      |                           |   |
|   | setImage()            |<-----| onStateChanged()          |   |
|   +-----------------------+      +---------------------------+   |
|                                             |                     |
|                                             | WebSocket #2        |
|                                             | (YOU manage this)   |
|                                             |                     |
+---------------------------------------------+---------------------+
                                              |
                                              v
+---------------------------------------------+---------------------+
|                     HOME ASSISTANT                                |
|  +------------------------------------------------------------+  |
|  |                   WebSocket API                             |  |
|  |                                                             |  |
|  |   /api/websocket                                            |  |
|  |   - Authentication                                          |  |
|  |   - Entity state queries                                    |  |
|  |   - Service calls (toggle, turn_on, turn_off)              |  |
|  |   - Event subscriptions                                     |  |
|  +------------------------------------------------------------+  |
|                              |                                    |
|                              v                                    |
|  +------------------------------------------------------------+  |
|  |                   Smart Home Devices                        |  |
|  |   Lights, Thermostats, Sensors, Switches...                |  |
|  +------------------------------------------------------------+  |
+------------------------------------------------------------------+

Nested WebSocket Architecture

Key Insight: WebSocket #1 is managed by the Stream Deck SDK - you receive events and send commands through the SDK’s API. WebSocket #2 is entirely your responsibility - you must establish it, authenticate, handle reconnection, and manage the message lifecycle.

Event Flow: User Toggles Light
==============================

1. Physical button press on Stream Deck hardware
   |
   v
2. Stream Deck App detects press
   |
   v
3. Stream Deck App sends event via WebSocket #1
   |
   v
4. Your plugin receives onKeyDown() callback
   |
   v
5. Your plugin extracts entity_id from action settings
   |
   v
6. Your plugin sends call_service via WebSocket #2
   |
   v
7. Home Assistant receives command
   |
   v
8. Home Assistant sends command to physical device (Zigbee/Z-Wave/WiFi)
   |
   v
9. Physical light toggles
   |
   v
10. Device confirms new state to Home Assistant
    |
    v
11. Home Assistant updates internal state
    |
    v
12. Home Assistant broadcasts state_changed event via WebSocket #2
    |
    v
13. Your plugin receives state_changed event
    |
    v
14. Your plugin maps entity_id to Stream Deck action(s)
    |
    v
15. Your plugin generates new key image
    |
    v
16. Your plugin calls setImage() via SDK (WebSocket #1)
    |
    v
17. Stream Deck hardware displays updated icon

Total latency: typically 100-500ms depending on device type

Event Flow: User Toggles Light


Long-Lived Token Authentication

Home Assistant supports multiple authentication methods. For desktop plugins, long-lived tokens are the most practical approach.

Authentication Methods Comparison
=================================

+------------------+-------------+------------------+------------------+
| Method           | Use Case    | Pros             | Cons             |
+------------------+-------------+------------------+------------------+
| Long-Lived Token | Desktop     | - Simple         | - No auto-expiry |
|                  | plugins,    | - No refresh     | - Manual revoke  |
|                  | scripts     |   needed         |   if compromised |
+------------------+-------------+------------------+------------------+
| OAuth 2.0        | Web apps,   | - Auto-refresh   | - Complex flow   |
|                  | mobile apps | - Scoped access  | - Needs callback |
+------------------+-------------+------------------+------------------+
| Trusted Networks | Local only  | - No token       | - Not for remote |
|                  |             |   needed         |   access         |
+------------------+-------------+------------------+------------------+

For Stream Deck plugins: Use Long-Lived Tokens

Authentication Methods Comparison

Creating a Long-Lived Token in Home Assistant:

  1. Navigate to your Profile (click your username)
  2. Scroll to “Long-Lived Access Tokens”
  3. Click “Create Token”
  4. Give it a name like “Stream Deck Plugin”
  5. Copy the token immediately (it won’t be shown again)
Token Security Best Practices
=============================

DO:
  - Store tokens in Stream Deck's global settings
    (globalSettings are encrypted at rest)
  - Validate tokens on Property Inspector save
  - Show clear error for invalid tokens
  - Allow easy token update when revoked

DON'T:
  - Hardcode tokens in source code
  - Log tokens to console
  - Store tokens in localStorage
  - Include tokens in error messages
// Secure token storage pattern
class PluginSettings {
  // Global settings are shared across all actions
  // and encrypted by Stream Deck
  async saveToken(token: string): Promise<void> {
    await streamDeck.settings.setGlobalSettings({
      haToken: token,
      haUrl: 'homeassistant.local:8123'
    });
  }

  async getToken(): Promise<string | undefined> {
    const settings = await streamDeck.settings.getGlobalSettings<{
      haToken?: string;
    }>();
    return settings.haToken;
  }
}

State Change Event Subscriptions

After authentication, you must subscribe to entity state changes to keep your keys synchronized.

Subscription Lifecycle
======================

+-------------------+
| Connection opens  |
+--------+----------+
         |
         v
+--------+----------+
| Authenticate      |
| (auth_required -> |
|  auth -> auth_ok) |
+--------+----------+
         |
         v
+--------+----------+
| Subscribe to      |
| state_changed     |
| events            |
+--------+----------+
         |
         v
+--------+----------+
| Fetch initial     |
| states for all    |
| configured keys   |
+--------+----------+
         |
         v
+--------+----------+
| Event loop:       |
| Receive events,   |
| update keys       |<----+
+--------+----------+     |
         |                |
         v                |
+--------+----------+     |
| state_changed     |-----+
| event received    |
+-------------------+

Subscription Lifecycle

Important: After subscribing, you receive events for ALL entity state changes in Home Assistant. You must filter to only the entities your keys care about.

// Efficient event filtering
class EntityTracker {
  // Map of entity_id -> Set of action UUIDs
  private entityToActions = new Map<string, Set<string>>();

  registerAction(entityId: string, actionUUID: string) {
    if (!this.entityToActions.has(entityId)) {
      this.entityToActions.set(entityId, new Set());
    }
    this.entityToActions.get(entityId)!.add(actionUUID);
  }

  unregisterAction(entityId: string, actionUUID: string) {
    const actions = this.entityToActions.get(entityId);
    if (actions) {
      actions.delete(actionUUID);
      if (actions.size === 0) {
        this.entityToActions.delete(entityId);
      }
    }
  }

  handleStateChange(entityId: string, newState: EntityState) {
    const actions = this.entityToActions.get(entityId);
    if (!actions || actions.size === 0) {
      // No keys care about this entity - ignore
      return;
    }

    // Update only the relevant keys
    for (const actionUUID of actions) {
      this.updateKeyImage(actionUUID, newState);
    }
  }
}

Bidirectional State Synchronization Patterns

This is the heart of the project - understanding how to keep your UI, the external service, and the physical device in sync.

State Synchronization Models
============================

MODEL 1: OPTIMISTIC UPDATES (What we DON'T want)
-------------------------------------------------

User presses key
      |
      v
+-------------+
| Update UI   |  <-- Update immediately, HOPE it works
| to "ON"     |
+-------------+
      |
      v
+-------------+
| Send toggle |
| command     |
+-------------+
      |
      +--------+--------+
      |                 |
   Success           Failure
      |                 |
      v                 v
+-------------+   +-------------+
| UI correct  |   | UI shows ON |
| (lucky!)    |   | but light   |
+-------------+   | is OFF!     |
                  +-------------+
                        ^
                        |
                        PROBLEM: Stale UI

MODEL 2: PESSIMISTIC UPDATES (What we want)
-------------------------------------------

User presses key
      |
      v
+-------------+
| Show        |  <-- Visual feedback that command is pending
| "pending"   |
| state       |
+-------------+
      |
      v
+-------------+
| Send toggle |
| command     |
+-------------+
      |
      +--------+--------+
      |                 |
   Success           Failure
      |                 |
      v                 v
+-------------+   +-------------+
| Wait for    |   | Show error  |
| state_      |   | state,      |
| changed     |   | revert icon |
| event       |   +-------------+
+-------------+
      |
      v
+-------------+
| Update UI   |  <-- Only update when HA confirms
| based on    |
| authoritative|
| state       |
+-------------+

State Synchronization Models

The Authoritative State Principle: Home Assistant is the single source of truth. Your plugin’s role is to:

  1. Send commands to HA
  2. Display what HA says is the current state
  3. Never assume a command succeeded until HA confirms
Handling State Conflicts
========================

Scenario: User rapidly toggles light

T0: UI shows OFF, HA says OFF, Light is OFF  [CONSISTENT]
T1: User presses key
T2: UI shows PENDING
T3: Plugin sends "toggle" command
T4: User presses key AGAIN before response
T5: UI stays PENDING (debounce!)
T6: HA responds with state ON
T7: UI shows ON, HA says ON, Light is ON    [CONSISTENT]

Without debouncing at T4:
T4: Plugin sends second "toggle" command
T5: HA toggles to ON
T6: HA receives second toggle, toggles to OFF
T7: HA sends state_changed: OFF
T8: UI shows OFF                             [USER CONFUSED]
// Debounce pattern for state commands
class DebouncedAction {
  private pendingCommand: NodeJS.Timeout | null = null;
  private lastCommandTime = 0;
  private readonly debounceMs = 500;

  async executeCommand(entityId: string, service: string): Promise<void> {
    const now = Date.now();

    // If a command was sent recently, ignore this one
    if (now - this.lastCommandTime < this.debounceMs) {
      console.log('Command debounced - too soon after last command');
      return;
    }

    this.lastCommandTime = now;
    await this.haClient.callService(entityId, service);
  }
}

Optimistic Updates vs Authoritative State

Sometimes you DO want optimistic updates - but only as temporary UI feedback.

Hybrid Approach: Optimistic Feedback + Authoritative State
==========================================================

User presses key
      |
      v
+------------------+
| Show "pending"   |  <-- Optimistic: immediate feedback
| icon with        |
| spinner overlay  |
+------------------+
      |
      v
+------------------+
| Start 3-second   |  <-- Timeout for slow responses
| timeout          |
+------------------+
      |
      v
+------------------+
| Send command     |
+------------------+
      |
      +------------+------------+
      |                         |
  state_changed              Timeout expires
  received                   (no response)
      |                         |
      v                         v
+------------------+   +------------------+
| Cancel timeout   |   | Show error icon  |
| Update icon from |   | with "?" or "!"  |
| authoritative    |   | User can retry   |
| state            |   +------------------+
+------------------+

This gives users:
1. Immediate feedback (they know they pressed the button)
2. Accurate final state (from Home Assistant)
3. Clear error indication (when something fails)

Exponential Backoff Reconnection

Network connections fail. Your plugin must handle this gracefully without hammering Home Assistant with reconnection attempts.

Exponential Backoff Algorithm
=============================

Attempt 1: Wait 1 second    (1000ms)
Attempt 2: Wait 2 seconds   (2000ms)
Attempt 3: Wait 4 seconds   (4000ms)
Attempt 4: Wait 8 seconds   (8000ms)
Attempt 5: Wait 16 seconds  (16000ms)
Attempt 6: Wait 30 seconds  (30000ms - cap)
Attempt 7: Wait 30 seconds  (30000ms - cap)
...

Formula: min(initialDelay * 2^attempt, maxDelay)

With Jitter (prevents thundering herd):
========================================

Multiple plugins reconnecting after HA restart would
all hit HA at exactly the same times without jitter.

Add random jitter: +/- 20% of calculated delay

Attempt 1: 1000ms +/- 200ms = 800-1200ms
Attempt 2: 2000ms +/- 400ms = 1600-2400ms
...
class ReconnectionManager {
  private attempt = 0;
  private readonly initialDelay = 1000;
  private readonly maxDelay = 30000;
  private readonly jitterFactor = 0.2;

  getNextDelay(): number {
    // Calculate base delay with exponential backoff
    const baseDelay = Math.min(
      this.initialDelay * Math.pow(2, this.attempt),
      this.maxDelay
    );

    // Add jitter (+/- 20%)
    const jitter = baseDelay * this.jitterFactor * (Math.random() * 2 - 1);

    this.attempt++;
    return Math.round(baseDelay + jitter);
  }

  reset(): void {
    this.attempt = 0;
  }
}
Reconnection State Machine
==========================

           +----------------+
           |   CONNECTED    |<----------------------+
           +-------+--------+                       |
                   |                                |
           Connection lost                     Connect success
                   |                                |
                   v                                |
           +-------+--------+                       |
           |  DISCONNECTED  |                       |
           +-------+--------+                       |
                   |                                |
           Start reconnect                          |
                   |                                |
                   v                                |
           +-------+--------+      +--------+-------+
      +--->|  RECONNECTING  |----->| AUTHENTICATING |
      |    +-------+--------+      +--------+-------+
      |            |                        |
      |       Connect failed           Auth failed
      |            |                        |
      |            v                        v
      |    +-------+--------+      +--------+-------+
      +----| WAITING_RETRY  |      |  AUTH_ERROR    |
           +----------------+      +----------------+
                                          |
                                   User must fix
                                   token in settings

Reconnection State Machine


Entity State Representation

Home Assistant entities have rich state objects. Mapping these to Stream Deck’s 72x72 pixel keys requires thoughtful design.

Home Assistant Entity State Structure
=====================================

Light Entity:
{
  "entity_id": "light.living_room",
  "state": "on",                    // "on", "off", "unavailable"
  "attributes": {
    "brightness": 255,              // 0-255
    "color_temp": 370,              // Mireds (warm/cool)
    "rgb_color": [255, 200, 150],   // RGB values
    "friendly_name": "Living Room Light",
    "supported_features": 63        // Bitmask of capabilities
  },
  "last_changed": "2025-01-15T10:30:00+00:00",
  "last_updated": "2025-01-15T10:30:00+00:00"
}

Climate Entity (Thermostat):
{
  "entity_id": "climate.living_room",
  "state": "heat",                  // "heat", "cool", "auto", "off"
  "attributes": {
    "current_temperature": 68,
    "temperature": 72,              // Target temperature
    "hvac_action": "heating",       // "heating", "cooling", "idle"
    "friendly_name": "Nest Thermostat"
  }
}

Binary Sensor (Door/Window):
{
  "entity_id": "binary_sensor.front_door",
  "state": "off",                   // "on" = open, "off" = closed
  "attributes": {
    "device_class": "door",
    "friendly_name": "Front Door"
  }
}
Mapping Entity States to Key Visuals
====================================

LIGHT STATES:
+------------------+------------------+------------------+
|   State: OFF     |   State: ON      |   State: N/A     |
|                  |                  |                  |
|     [BULB]       |     [BULB]       |     [BULB]       |
|     (gray)       |    (yellow)      |     (red X)      |
|                  |                  |                  |
|   "Living Room"  |   "Living Room"  |   "Unavailable"  |
|      OFF         |    ON (75%)      |                  |
+------------------+------------------+------------------+
   Background:         Background:         Background:
   Dark gray           Warm yellow         Red tint

THERMOSTAT STATES:
+------------------+------------------+------------------+
|   Mode: HEAT     |   Mode: COOL     |   Mode: OFF      |
|                  |                  |                  |
|      72 F        |      72 F        |      --          |
|    [====|  ]     |    [  |====]     |    [------]      |
|   Target: 72     |   Target: 68     |                  |
|   Heating...     |   Cooling...     |      OFF         |
+------------------+------------------+------------------+
   Background:         Background:         Background:
   Orange/warm         Blue/cool           Gray

DOOR SENSOR STATES:
+------------------+------------------+
|   State: CLOSED  |   State: OPEN    |
|                  |                  |
|     [DOOR]       |     [DOOR]       |
|    (closed)      |    (open)        |
|                  |                  |
|   Front Door     |   Front Door     |
|     SECURE       |   !! OPEN !!     |
+------------------+------------------+
   Background:         Background:
   Green               Red (alert!)

Entity State to Key Visuals Mapping


Concepts You Must Understand First

Before writing code, ensure you understand these foundational concepts:

1. WebSocket Client Connections (Nested WebSockets)

Your plugin is already a WebSocket server (Stream Deck connects to it), but now you also need to be a WebSocket client connecting outbound to Home Assistant. This creates a “WebSocket proxy” pattern where your plugin sits between two WebSocket endpoints.

References:

  • Designing Data-Intensive Applications (Ch. 11: “Stream Processing”) - Kleppmann: Understand event-driven architectures and message passing between systems
  • ws npm package documentation - The Node.js WebSocket library you’ll use for the Home Assistant connection

2. OAuth/Long-Lived Token Authentication

Home Assistant uses long-lived access tokens rather than full OAuth flows. You need to understand:

  • How tokens are generated in Home Assistant (User Profile > Long-Lived Access Tokens)
  • How to securely store tokens in plugin settings (never hardcode!)
  • Token rotation and expiration handling

References:

  • Home Assistant Authentication API - Official documentation
  • Effective TypeScript (Item 42: “Understand Type Narrowing”) - Vanderkam: Properly typing authentication responses

3. Bidirectional State Synchronization Patterns

The key challenge: when a light turns on, how does your plugin know? You must subscribe to Home Assistant’s state change events and update your key images reactively. This is the classic distributed systems problem: your UI, the real device, and HA’s state can temporarily disagree.

References:

  • Designing Data-Intensive Applications (Ch. 5: “Replication”) - Kleppmann: Leader/follower replication concepts apply to state sync
  • Designing Data-Intensive Applications (Ch. 12: “The Future of Data Systems”) - Kleppmann: Subscribing to change streams

4. Error Handling and Reconnection Strategies

Network connections fail. Home Assistant restarts. Your plugin must handle:

  • Initial connection failures (wrong URL, wrong token)
  • Mid-session disconnections
  • Exponential backoff for reconnection attempts
  • User feedback during connection issues

References:

  • Node.js Design Patterns (Ch. 3: “Callbacks and Events”) - Casciaro & Mammino: Event-driven error handling
  • Node.js Design Patterns (Ch. 11: “Advanced Recipes”) - Reconnection patterns
  • Effective TypeScript (Item 46: “Use unknown Instead of any for Values of Unknown Type”) - Vanderkam: Safe error type handling

5. Entity State Representation

Home Assistant entities have complex state objects. A light might include brightness, color temperature, and more. You must map this to visual representations on 72x72 pixel keys.

References:


Questions to Guide Your Design

Work through these questions before implementing:

Authentication & Security

  • How do you securely store API tokens in settings? (Use Stream Deck’s global settings, never localStorage or hardcoded values)
  • What happens if the user enters an invalid token? (Graceful error message, not a crash)
  • Should you validate the token on Property Inspector save or on plugin load?

Connection Management

  • How do you maintain two WebSocket connections simultaneously? (Event emitters, connection state machines)
  • What’s your reconnection strategy? (Exponential backoff: 1s, 2s, 4s, 8s, max 30s)
  • How do you notify the user when Home Assistant is unreachable? (Key icon changes, error states)

State Synchronization

  • What happens if Home Assistant goes offline while your plugin is running?
  • How do you update the key image when device state changes externally (from phone app)?
  • How do you handle stale state when reconnecting after a disconnect?
  • What if multiple Stream Deck keys control the same entity?

Entity Selection

  • How does the user select which entity each key controls? (Property Inspector with entity dropdown)
  • How do you fetch the list of available entities for the dropdown?
  • Should you cache the entity list or fetch fresh each time?

Thinking Exercise

Trace this complete flow through your system:

User toggles light from phone app
        |
        v
Home Assistant receives command
        |
        v
Home Assistant updates internal state
        |
        v
Home Assistant broadcasts state_changed event via WebSocket
        |
        v
Your plugin receives WebSocket message
        |
        v
Your plugin parses entity_id and new state
        |
        v
Your plugin finds which Stream Deck key(s) control this entity
        |
        v
Your plugin generates new key image (bulb icon, ON state, yellow background)
        |
        v
Your plugin calls setImage() via Stream Deck SDK
        |
        v
Stream Deck hardware displays updated key

Question: At which step could things go wrong? What error handling do you need at each stage?

Consider:

  • WebSocket disconnected at step 4
  • Entity ID not found in your tracking map at step 6
  • Canvas rendering fails at step 7
  • Stream Deck SDK call fails at step 8

Complete Project Specification

Functional Requirements

Core Features (Must Have):

Feature Description Priority
HA Connection Connect to Home Assistant WebSocket API P0
Token Authentication Authenticate with long-lived token P0
Light Toggle Toggle lights on key press P0
State Reflection Update key icons when state changes P0
Reconnection Auto-reconnect on connection loss P0
Entity Selection Property Inspector for choosing entities P1
Thermostat Control Temperature display and dial adjustment P1
Sensor Display Show binary sensor states (doors) P1
Error States Visual feedback for connection issues P1
Multi-entity Multiple keys controlling same entity P2

Action Types

Light Toggle Action
===================
- Press: Toggle light on/off
- Settings: entity_id selection
- Display: Bulb icon with state color (yellow=on, gray=off)

Thermostat Action
=================
- Press: Cycle through modes (heat/cool/auto/off)
- Dial: Adjust target temperature (+/- 1 degree per tick)
- Display: Current temp, target temp, mode indicator

Binary Sensor Action
====================
- Press: (No action - display only)
- Display: Sensor state with icon (door open/closed)
- Alert: Red background when state = "on" (door open)

Non-Functional Requirements

Requirement Target Rationale
Connection time < 3 seconds User shouldn’t wait for initial connection
State update latency < 500ms Changes should feel instant
Reconnection Automatic, exponential backoff Resilient to network issues
Token storage Encrypted in settings Security best practice

Real World Outcome

When complete, your Stream Deck becomes a professional smart home control surface:

+------------------+     +------------------+     +------------------+
|  Living Room     |     |   Thermostat     |     |   Front Door     |
|                  |     |                  |     |                  |
|     [BULB]       |     |      72 F        |     |    [DOOR]        |
|      OFF         |     |    [====|  ]     |     |     CLOSED       |
|   ~~~~~~~~~~~~   |     |   Target: 70 F   |     |   ~~~~~~~~~~~~   |
|   Tap to Toggle  |     |   Dial to Adjust |     |   Sensor Status  |
+------------------+     +------------------+     +------------------+
        |                        |                        |
        v                        v                        v
+------------------+     +------------------+     +------------------+
|  Living Room     |     |   Thermostat     |     |   Front Door     |
|                  |     |                  |     |                  |
|     [BULB]       |     |      70 F        |     |    [DOOR]        |
|      ON          |     |    [==|    ]     |     |     OPEN         |
|   ~~~~~~~~~~~~   |     |   Target: 70 F   |     |   ~~~~~~~~~~~~   |
|   Tap to Toggle  |     |   Cooling...     |     |   !! ALERT !!    |
+------------------+     +------------------+     +------------------+
     (Yellow)                (Blue)                   (Red)

Bidirectional behavior: When you toggle the living room light from your phone’s Home Assistant app, the Stream Deck key automatically updates from OFF to ON within seconds. The plugin maintains a persistent WebSocket subscription to Home Assistant’s state change events, ensuring your physical control surface always reflects the true state of your smart home.


Solution Architecture

System Architecture Diagram

+------------------------------------------------------------------------+
|                          STREAM DECK PLUGIN                             |
+------------------------------------------------------------------------+
|                                                                         |
|   +----------------------+      +----------------------+                |
|   |   Action Handlers    |      |   HA Connection      |                |
|   |----------------------|      |----------------------|                |
|   | LightToggleAction    |      | HAWebSocketClient    |                |
|   | ThermostatAction     |<---->| - connect()          |                |
|   | BinarySensorAction   |      | - authenticate()     |                |
|   +----------+-----------+      | - subscribe()        |                |
|              |                  | - callService()      |                |
|              |                  +----------+-----------+                |
|              |                             |                            |
|              v                             v                            |
|   +----------+-----------+      +----------+-----------+                |
|   |   Entity Tracker     |      |  Connection Manager  |                |
|   |----------------------|      |----------------------|                |
|   | - entityToActions    |      | - reconnection logic |                |
|   | - handleStateChange  |      | - backoff calculator |                |
|   | - registerAction     |      | - state machine      |                |
|   +----------+-----------+      +----------------------+                |
|              |                                                          |
|              v                                                          |
|   +----------+-----------+                                              |
|   |   Image Generator    |                                              |
|   |----------------------|                                              |
|   | - lightImage()       |                                              |
|   | - thermostatImage()  |                                              |
|   | - sensorImage()      |                                              |
|   | - errorImage()       |                                              |
|   +----------------------+                                              |
|                                                                         |
+------------------------------------------------------------------------+

Plugin System Architecture

Module Breakdown

src/
+-- plugin.ts                    # Entry point, action registration
+-- actions/
|   +-- light-toggle.ts          # Light toggle action handler
|   +-- thermostat.ts            # Thermostat action handler
|   +-- binary-sensor.ts         # Binary sensor action handler
+-- ha/
|   +-- client.ts                # Home Assistant WebSocket client
|   +-- connection-manager.ts    # Reconnection logic
|   +-- types.ts                 # HA-specific type definitions
+-- state/
|   +-- entity-tracker.ts        # Maps entities to actions
|   +-- state-store.ts           # Caches current entity states
+-- ui/
|   +-- image-generator.ts       # Canvas-based key image generation
|   +-- icons.ts                 # Base64 encoded icons
+-- property-inspector/
|   +-- index.html               # PI HTML
|   +-- index.ts                 # PI logic
|   +-- entity-selector.ts       # Entity dropdown component
+-- utils/
    +-- backoff.ts               # Exponential backoff calculator
    +-- debounce.ts              # Command debouncing

Data Flow Diagram

                     OUTBOUND FLOW (User presses key)
                     ================================

+----------------+    +----------------+    +----------------+
| Stream Deck    |    | Your Plugin    |    | Home Assistant |
| Hardware       |    |                |    |                |
+-------+--------+    +-------+--------+    +-------+--------+
        |                     |                     |
        | 1. Button press     |                     |
        |-------------------->|                     |
        |                     |                     |
        |                     | 2. call_service     |
        |                     |-------------------->|
        |                     |                     |
        |                     |                     | 3. Command
        |                     |                     |    to device
        |                     |                     |
        |                     | 4. state_changed    |
        |                     |<--------------------|
        |                     |                     |
        | 5. setImage()       |                     |
        |<--------------------|                     |
        |                     |                     |


                     INBOUND FLOW (External change)
                     ===============================

+----------------+    +----------------+    +----------------+
| Phone App      |    | Home Assistant |    | Your Plugin    |
|                |    |                |    |                |
+-------+--------+    +-------+--------+    +-------+--------+
        |                     |                     |
        | 1. Toggle light     |                     |
        |-------------------->|                     |
        |                     |                     |
        |                     | 2. state_changed    |
        |                     |-------------------->|
        |                     |                     |
        |                     |                     | 3. Update
        |                     |                     |    key image
        |                     |                     |

Data Flow Diagrams - Outbound and Inbound


Phased Implementation Guide

Phase 1: Home Assistant Connection (Days 1-2)

Goal: Establish WebSocket connection and authenticate with Home Assistant.

Milestone: Successfully connect and receive auth_ok response.

Tasks:

  1. Project Setup
    streamdeck create
    # Select TypeScript template
    # Name: com.yourname.smarthome
    
    cd com.yourname.smarthome
    npm install ws
    npm install -D @types/ws
    
  2. Create HA Client (src/ha/client.ts)
    • Establish WebSocket connection
    • Implement authentication flow
    • Handle auth_required, auth_ok, auth_invalid
  3. Create Connection Manager (src/ha/connection-manager.ts)
    • State machine for connection states
    • Exponential backoff for reconnection
    • Event emitter for state changes
  4. Test Connection
    • Hardcode test URL and token
    • Log successful authentication
    • Log any errors

Success Criteria: Plugin logs “Connected to Home Assistant” on startup.


Phase 2: State Subscription (Days 3-4)

Goal: Subscribe to entity state changes and log them.

Milestone: Plugin logs state changes when you toggle lights from HA UI.

Tasks:

  1. Implement Subscription (src/ha/client.ts)
    • Send subscribe_events message after auth
    • Handle incoming event messages
    • Parse state_changed events
  2. Create Entity Tracker (src/state/entity-tracker.ts)
    • Store current states of entities
    • Provide methods to query state
    • Emit events on state changes
  3. Fetch Initial States
    • Call get_states after subscription
    • Populate entity tracker with current states
  4. Test State Updates
    • Toggle light from HA UI
    • Verify plugin logs state change

Success Criteria: Plugin logs “light.living_room changed from off to on” when toggled.


Phase 3: Light Toggle Action (Days 5-6)

Goal: Toggle lights from Stream Deck with state reflection.

Milestone: Press key to toggle light; key image updates to reflect state.

Tasks:

  1. Create Light Action (src/actions/light-toggle.ts)
    • Extend SingletonAction
    • Implement onKeyDown to call toggle service
    • Implement onWillAppear to set initial image
  2. Create Image Generator (src/ui/image-generator.ts)
    • Generate light ON image (yellow background, lit bulb)
    • Generate light OFF image (gray background, dark bulb)
    • Generate PENDING image (with spinner)
    • Generate ERROR image (red, with X)
  3. Wire Up State Changes
    • Listen for state changes from entity tracker
    • Update key image when state changes
  4. Test Bidirectional Sync
    • Press Stream Deck key -> light toggles -> key updates
    • Toggle from phone app -> key updates

Success Criteria: Light toggles work in both directions with visual feedback.


Phase 4: Property Inspector (Days 7-8)

Goal: Allow users to configure entity selection and connection settings.

Milestone: User can select entities from dropdown and configure HA URL/token.

Tasks:

  1. Create Property Inspector HTML (src/property-inspector/index.html)
    • Connection settings (URL, token)
    • Entity dropdown (populated dynamically)
    • Save/test connection buttons
  2. Implement Entity Fetching
    • Fetch available entities from HA
    • Filter by domain (light, climate, binary_sensor)
    • Populate dropdown
  3. Token Validation
    • Test connection on save
    • Show success/error feedback
    • Store in global settings
  4. Action Settings
    • Store entity_id per action
    • Load settings on action appear

Success Criteria: User can configure connection and select entities without code changes.


Phase 5: Error Handling & Polish (Days 9-10)

Goal: Handle all error cases gracefully with user feedback.

Milestone: Plugin recovers from network failures and shows clear error states.

Tasks:

  1. Reconnection Logic
    • Implement exponential backoff
    • Update all keys to “disconnected” state during reconnection
    • Re-subscribe after reconnect
  2. Error State Images
    • “Connecting…” state
    • “Disconnected” state (with retry indicator)
    • “Auth Failed” state (directs to settings)
  3. Command Timeout
    • Set timeout for state change after command
    • Show error if no response within timeout
    • Allow retry
  4. Edge Cases
    • Multiple keys controlling same entity
    • Entity becomes unavailable
    • HA restarts mid-session

Success Criteria: Plugin handles network failures gracefully without user intervention.


Phase 6: Additional Actions (Days 11-14)

Goal: Add thermostat and sensor actions.

Milestone: Complete smart home control surface with multiple action types.

Tasks:

  1. Thermostat Action (src/actions/thermostat.ts)
    • Display current/target temperature
    • Dial rotation adjusts target
    • Press cycles modes
  2. Binary Sensor Action (src/actions/binary-sensor.ts)
    • Display sensor state
    • Alert state for “on” (door open, motion detected)
    • No press action (display only)
  3. Thermostat Image Generator
    • Temperature display
    • Mode indicator (heat/cool/auto)
    • Progress bar for current vs target
  4. Final Polish
    • Consistent error handling across actions
    • Comprehensive logging
    • Performance optimization

Success Criteria: All three action types work with full bidirectional sync.


Testing Strategy

Unit Tests: Connection State Machine

// tests/unit/connection-manager.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ConnectionManager, ConnectionState } from '../../src/ha/connection-manager';

describe('ConnectionManager', () => {
  let manager: ConnectionManager;

  beforeEach(() => {
    manager = new ConnectionManager();
  });

  it('starts in DISCONNECTED state', () => {
    expect(manager.state).toBe(ConnectionState.DISCONNECTED);
  });

  it('transitions to CONNECTING on connect()', () => {
    manager.connect();
    expect(manager.state).toBe(ConnectionState.CONNECTING);
  });

  it('transitions to AUTHENTICATING after connection opens', () => {
    manager.connect();
    manager.onOpen();
    expect(manager.state).toBe(ConnectionState.AUTHENTICATING);
  });

  it('transitions to CONNECTED after auth_ok', () => {
    manager.connect();
    manager.onOpen();
    manager.onAuthOk();
    expect(manager.state).toBe(ConnectionState.CONNECTED);
  });

  it('calculates exponential backoff correctly', () => {
    expect(manager.getBackoffDelay(0)).toBe(1000);
    expect(manager.getBackoffDelay(1)).toBe(2000);
    expect(manager.getBackoffDelay(2)).toBe(4000);
    expect(manager.getBackoffDelay(5)).toBe(30000); // Cap
    expect(manager.getBackoffDelay(10)).toBe(30000); // Still capped
  });
});

Integration Tests: Mocked Home Assistant

// tests/integration/ha-client.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { HAWebSocketClient } from '../../src/ha/client';
import WebSocket from 'ws';

// Mock WebSocket server
let mockServer: WebSocket.Server;

beforeEach(() => {
  mockServer = new WebSocket.Server({ port: 8765 });
});

afterEach(() => {
  mockServer.close();
});

describe('HAWebSocketClient', () => {
  it('authenticates successfully with valid token', async () => {
    // Setup mock server to accept auth
    mockServer.on('connection', (ws) => {
      ws.send(JSON.stringify({ type: 'auth_required' }));

      ws.on('message', (data) => {
        const msg = JSON.parse(data.toString());
        if (msg.type === 'auth') {
          ws.send(JSON.stringify({ type: 'auth_ok' }));
        }
      });
    });

    const client = new HAWebSocketClient();
    await expect(client.connect('localhost:8765', 'valid_token'))
      .resolves.not.toThrow();
  });

  it('rejects with auth error for invalid token', async () => {
    mockServer.on('connection', (ws) => {
      ws.send(JSON.stringify({ type: 'auth_required' }));

      ws.on('message', (data) => {
        const msg = JSON.parse(data.toString());
        if (msg.type === 'auth') {
          ws.send(JSON.stringify({ type: 'auth_invalid' }));
        }
      });
    });

    const client = new HAWebSocketClient();
    await expect(client.connect('localhost:8765', 'bad_token'))
      .rejects.toThrow('Authentication failed');
  });
});

End-to-End Tests: State Synchronization

// tests/e2e/state-sync.test.ts
describe('State Synchronization', () => {
  it('updates key image when external state change occurs', async () => {
    // 1. Setup plugin with mock HA connection
    // 2. Register a light action
    // 3. Simulate state_changed event from HA
    // 4. Verify setImage was called with correct image
  });

  it('debounces rapid key presses', async () => {
    // 1. Setup plugin
    // 2. Simulate 5 rapid key presses
    // 3. Verify only 1 call_service was sent
  });

  it('recovers state after reconnection', async () => {
    // 1. Setup plugin, establish connection
    // 2. Simulate disconnect
    // 3. Simulate reconnect
    // 4. Verify plugin fetches fresh state
  });
});

Common Pitfalls & Debugging

Pitfall 1: Not Handling auth_required Before Sending Commands

Symptom: Commands fail silently, no state updates received.

Problem: Sending commands before authentication completes.

// BAD: Sending command immediately
const ws = new WebSocket('ws://homeassistant:8123/api/websocket');
ws.onopen = () => {
  ws.send(JSON.stringify({ type: 'call_service', ... })); // FAILS!
};

// GOOD: Wait for auth_ok
ws.onmessage = (event) => {
  const msg = JSON.parse(event.data);
  if (msg.type === 'auth_required') {
    ws.send(JSON.stringify({ type: 'auth', access_token: token }));
  } else if (msg.type === 'auth_ok') {
    // NOW safe to send commands
    this.isAuthenticated = true;
    this.onReady();
  }
};

Pitfall 2: Not Tracking Message IDs

Symptom: Responses get mismatched with requests, callbacks never fire.

Problem: Home Assistant uses message IDs to correlate responses.

// BAD: Ignoring message IDs
ws.send(JSON.stringify({ type: 'get_states' }));
// How do you know which response is yours?

// GOOD: Track message IDs
const id = ++this.messageId;
const promise = new Promise((resolve) => {
  this.pendingRequests.set(id, resolve);
});
ws.send(JSON.stringify({ id, type: 'get_states' }));

// In message handler:
if (msg.type === 'result') {
  const resolver = this.pendingRequests.get(msg.id);
  if (resolver) {
    resolver(msg.result);
    this.pendingRequests.delete(msg.id);
  }
}

Pitfall 3: Stale State After Reconnection

Symptom: Keys show wrong state after network recovery.

Problem: Not fetching fresh state after reconnecting.

// BAD: Just reconnect and hope
async reconnect() {
  await this.connect();
  // Old cached state might be wrong!
}

// GOOD: Fetch fresh state after reconnect
async reconnect() {
  await this.connect();
  await this.authenticate();
  await this.subscribe();

  // Fetch fresh state for all tracked entities
  const states = await this.getStates();
  for (const state of states) {
    this.entityTracker.updateState(state.entity_id, state);
  }

  // Update all key images
  this.entityTracker.refreshAllKeys();
}

Pitfall 4: Memory Leak from Uncleared Event Listeners

Symptom: Plugin slows down over time, eventually crashes.

Problem: Adding event listeners on each reconnect without removing old ones.

// BAD: Listeners accumulate
connect() {
  this.ws = new WebSocket(url);
  this.ws.on('message', this.handleMessage); // Added each time!
}

// GOOD: Clean up before reconnecting
connect() {
  if (this.ws) {
    this.ws.removeAllListeners();
    this.ws.close();
  }

  this.ws = new WebSocket(url);
  this.ws.on('message', this.handleMessage);
}

Pitfall 5: Not Handling Entity Unavailable State

Symptom: Plugin crashes or shows wrong state when devices go offline.

Problem: Assuming entities always have valid state.

// BAD: Trust the state
function updateKeyImage(state: EntityState) {
  if (state.state === 'on') {
    setImage(onImage);
  } else {
    setImage(offImage);
  }
}

// GOOD: Handle unavailable
function updateKeyImage(state: EntityState) {
  if (state.state === 'unavailable') {
    setImage(unavailableImage);
    return;
  }

  if (state.state === 'on') {
    setImage(onImage);
  } else {
    setImage(offImage);
  }
}

Pitfall 6: Race Condition in Rapid Toggling

Symptom: Light ends up in unexpected state after rapid button presses.

Problem: Multiple toggle commands before first response.

// BAD: Send toggle on every press
onKeyDown() {
  this.callService('toggle');
}

// GOOD: Debounce and track pending commands
onKeyDown() {
  if (this.pendingCommand) {
    return; // Ignore, command already in flight
  }

  this.pendingCommand = true;
  this.showPendingState();

  this.callService('toggle')
    .finally(() => {
      this.pendingCommand = false;
    });
}

Pitfall 7: WebSocket URL Protocol Confusion

Symptom: Connection fails with cryptic errors.

Problem: Using wrong protocol (ws vs wss, http vs ws).

// BAD: Mixing protocols
const url = 'http://homeassistant:8123/api/websocket'; // Wrong!
const url = 'https://homeassistant:8123/api/websocket'; // Wrong!

// GOOD: Use ws:// or wss://
const url = 'ws://homeassistant:8123/api/websocket';  // Local
const url = 'wss://homeassistant.example.com/api/websocket'; // Remote with SSL

The Interview Questions They’ll Ask

After building this project, you should be able to answer:

1. “How do you implement bidirectional state sync between a desktop app and an IoT service?”

Strong answer: “I establish a persistent WebSocket connection and subscribe to state change events. When the remote service state changes, I receive an event and update my local representation. When the user takes action locally, I send a command and optimistically update the UI while waiting for the authoritative state confirmation from the server. I handle the case where my optimistic update is wrong - for example, if the light fails to turn on, I revert the icon.”

2. “What’s your reconnection strategy for WebSocket failures?”

Strong answer: “I use exponential backoff with jitter. First retry at 1 second, then 2, 4, 8, up to a maximum of 30 seconds. I add random jitter (plus or minus 20%) to prevent thundering herd problems if many clients reconnect simultaneously. I also distinguish between recoverable errors (network timeout) and fatal errors (invalid authentication) to avoid wasting reconnection attempts.”

3. “How do you handle authentication securely in desktop plugins?”

Strong answer: “I never hardcode credentials. For Home Assistant, I use long-lived tokens stored in the plugin’s settings, which Stream Deck encrypts at rest. I validate tokens on save in the Property Inspector and provide clear error messages. For services requiring OAuth, I’d implement the authorization code flow with PKCE, opening the auth URL in the system browser and listening for the callback.”

4. “What happens if the user issues a command while the external service is unreachable?”

Strong answer: “I implement command queuing with timeout. When the user presses a key, I show a ‘pending’ state on the icon, attempt the WebSocket send, and start a timeout. If the connection is down, the send fails immediately and I show an error state. If the connection is up but the service doesn’t respond within the timeout, I revert to the previous state and show an error indicator. I also provide visual feedback that the system is reconnecting.”

5. “How do you handle multiple clients controlling the same device?”

Strong answer: “This is why authoritative state is so important. My plugin doesn’t track ‘what I set the light to’ - it tracks ‘what Home Assistant says the light is’. When any client changes the device, Home Assistant broadcasts the state change to all subscribers. My plugin receives this event and updates the UI accordingly, regardless of who initiated the change. This ensures consistency across all control surfaces.”


Hints in Layers

Hint 1: Home Assistant WebSocket API Connection

Click to reveal
import WebSocket from 'ws';

class HomeAssistantConnection {
  private ws: WebSocket | null = null;
  private messageId = 0;

  connect(url: string, token: string): Promise<void> {
    return new Promise((resolve, reject) => {
      this.ws = new WebSocket(`ws://${url}/api/websocket`);

      this.ws.on('message', (data) => {
        const message = JSON.parse(data.toString());

        if (message.type === 'auth_required') {
          // Send authentication
          this.ws?.send(JSON.stringify({
            type: 'auth',
            access_token: token
          }));
        } else if (message.type === 'auth_ok') {
          resolve();
        } else if (message.type === 'auth_invalid') {
          reject(new Error('Invalid authentication'));
        }
      });
    });
  }
}

Hint 2: Entity State Subscription

Click to reveal
// After authentication succeeds, subscribe to state changes
subscribeToStateChanges(): void {
  const subscribeMessage = {
    id: ++this.messageId,
    type: 'subscribe_events',
    event_type: 'state_changed'
  };

  this.ws?.send(JSON.stringify(subscribeMessage));
}

// Handle incoming state change events
handleMessage(message: any): void {
  if (message.type === 'event' && message.event.event_type === 'state_changed') {
    const { entity_id, new_state } = message.event.data;

    // Find keys that control this entity and update their images
    this.updateKeysForEntity(entity_id, new_state);
  }
}

Hint 3: Bidirectional Sync Implementation

Click to reveal
// When user presses Stream Deck key
async onKeyDown(ev: KeyDownEvent): Promise<void> {
  const entityId = ev.payload.settings.entityId;

  // Optimistically update the key image
  this.updateKeyImage(ev.action, 'pending');

  try {
    // Send command to Home Assistant
    await this.callService(entityId, 'toggle');

    // Don't update image here - wait for state_changed event
    // This ensures the key reflects the actual device state
  } catch (error) {
    // Revert to previous state on failure
    this.updateKeyImage(ev.action, 'error');
  }
}

// Call a Home Assistant service
callService(entityId: string, service: string): Promise<void> {
  const [domain] = entityId.split('.');

  return this.sendCommand({
    type: 'call_service',
    domain,
    service,
    target: { entity_id: entityId }
  });
}

Hint 4: Robust Error Recovery

Click to reveal
class RobustConnection {
  private reconnectAttempts = 0;
  private maxReconnectDelay = 30000;

  private getReconnectDelay(): number {
    // Exponential backoff: 1s, 2s, 4s, 8s... max 30s
    const baseDelay = Math.min(
      1000 * Math.pow(2, this.reconnectAttempts),
      this.maxReconnectDelay
    );

    // Add jitter (plus or minus 20%)
    const jitter = baseDelay * 0.2 * (Math.random() - 0.5);
    return baseDelay + jitter;
  }

  private scheduleReconnect(): void {
    const delay = this.getReconnectDelay();
    this.reconnectAttempts++;

    // Update all keys to show "reconnecting" state
    this.updateAllKeysState('reconnecting');

    setTimeout(() => {
      this.connect()
        .then(() => {
          this.reconnectAttempts = 0;
          this.resubscribeToStates();
        })
        .catch(() => this.scheduleReconnect());
    }, delay);
  }

  private onDisconnect(): void {
    if (this.wasCleanClose) return;
    this.scheduleReconnect();
  }
}

Hint 5: Entity Tracker Implementation

Click to reveal
class EntityTracker {
  // entity_id -> current state
  private states = new Map<string, EntityState>();

  // entity_id -> Set of action UUIDs that control this entity
  private entityToActions = new Map<string, Set<string>>();

  // action UUID -> entity_id
  private actionToEntity = new Map<string, string>();

  registerAction(actionUUID: string, entityId: string): void {
    // Remove any existing mapping for this action
    const oldEntityId = this.actionToEntity.get(actionUUID);
    if (oldEntityId) {
      this.entityToActions.get(oldEntityId)?.delete(actionUUID);
    }

    // Create new mapping
    this.actionToEntity.set(actionUUID, entityId);

    if (!this.entityToActions.has(entityId)) {
      this.entityToActions.set(entityId, new Set());
    }
    this.entityToActions.get(entityId)!.add(actionUUID);
  }

  handleStateChange(entityId: string, newState: EntityState): void {
    this.states.set(entityId, newState);

    const actions = this.entityToActions.get(entityId);
    if (!actions) return;

    for (const actionUUID of actions) {
      this.updateActionImage(actionUUID, newState);
    }
  }
}

Hint 6: Property Inspector Entity Selector

Click to reveal
// In Property Inspector (browser context)
class EntitySelector {
  async fetchEntities(url: string, token: string): Promise<Entity[]> {
    // Use REST API instead of WebSocket for simplicity in PI
    const response = await fetch(`http://${url}/api/states`, {
      headers: {
        'Authorization': `Bearer ${token}`,
        'Content-Type': 'application/json'
      }
    });

    if (!response.ok) {
      throw new Error('Failed to fetch entities');
    }

    const states = await response.json();

    // Filter to relevant domains
    return states.filter((entity: Entity) => {
      const domain = entity.entity_id.split('.')[0];
      return ['light', 'switch', 'climate', 'binary_sensor'].includes(domain);
    });
  }

  populateDropdown(entities: Entity[], dropdown: HTMLSelectElement): void {
    dropdown.innerHTML = '<option value="">Select an entity...</option>';

    // Group by domain
    const grouped = this.groupByDomain(entities);

    for (const [domain, domainEntities] of Object.entries(grouped)) {
      const optgroup = document.createElement('optgroup');
      optgroup.label = domain.charAt(0).toUpperCase() + domain.slice(1);

      for (const entity of domainEntities) {
        const option = document.createElement('option');
        option.value = entity.entity_id;
        option.textContent = entity.attributes.friendly_name || entity.entity_id;
        optgroup.appendChild(option);
      }

      dropdown.appendChild(optgroup);
    }
  }
}

Extensions & Challenges

Extension 1: MQTT Support

Add support for controlling devices via MQTT as an alternative to Home Assistant.

Challenge: Implement MQTT client connection with topic-based pub/sub for device control.

Implementation Ideas:

  • Use mqtt npm package
  • Subscribe to state topics (e.g., homeassistant/light/living_room/state)
  • Publish to command topics (e.g., homeassistant/light/living_room/set)
  • Support MQTT discovery for auto-configuration
// MQTT implementation sketch
import mqtt from 'mqtt';

class MQTTClient {
  connect(broker: string, username?: string, password?: string) {
    this.client = mqtt.connect(broker, { username, password });

    this.client.on('connect', () => {
      // Subscribe to state topics
      this.client.subscribe('homeassistant/+/+/state');
    });

    this.client.on('message', (topic, payload) => {
      const [, domain, name] = topic.split('/');
      const entityId = `${domain}.${name}`;
      const state = payload.toString();
      this.onStateChange(entityId, state);
    });
  }

  sendCommand(entityId: string, command: string) {
    const [domain, name] = entityId.split('.');
    this.client.publish(`homeassistant/${domain}/${name}/set`, command);
  }
}

Extension 2: Multiple Home Assistant Instances

Support connecting to multiple Home Assistant instances simultaneously.

Challenge: Manage multiple WebSocket connections with independent authentication and state tracking.

Implementation Ideas:

  • Add “instance” selector in Property Inspector
  • Create connection pool manager
  • Prefix entity IDs with instance identifier
  • Handle cross-instance state updates

Extension 3: Scene and Script Actions

Add actions for triggering Home Assistant scenes and scripts.

Challenge: Scenes are “fire and forget” - no state reflection, just execution.

Implementation Ideas:

  • New action type: SceneTriggerAction
  • Show success/failure animation on press
  • Support for script parameters via Property Inspector

Extension 4: Automation Control

Toggle Home Assistant automations on/off from Stream Deck.

Challenge: Automations have different state model than devices.

Implementation Ideas:

  • Fetch automation entities (automation.*)
  • Display enabled/disabled state
  • Toggle via automation.turn_on/turn_off services

Extension 5: Energy Dashboard

Display real-time energy usage from Home Assistant sensors.

Challenge: Render numerical data meaningfully in 72x72 pixels.

Implementation Ideas:

  • Display current wattage with gauge visualization
  • Show daily/monthly trends as mini sparklines
  • Color-code by usage thresholds (green/yellow/red)

Real-World Connections

IoT and Smart Home Industry

This project teaches patterns used throughout the IoT industry:

Stream Deck Pattern IoT/Industry Equivalent
WebSocket subscription MQTT pub/sub, Azure IoT Hub
State sync Device twin synchronization
Reconnection logic Industrial SCADA resilience
Entity abstraction IoT device models
Command/response Request/reply patterns

Microservices Communication

The bidirectional communication patterns you learn here apply directly to microservices:

Plugin Pattern Microservices Equivalent
Nested WebSockets API Gateway + backend services
Message ID correlation Correlation IDs in distributed tracing
State change events Event-driven architecture, CQRS
Reconnection backoff Circuit breaker pattern
Authoritative state Event sourcing

Real-Time Applications

The techniques transfer to any real-time application:

  • Chat applications (Slack, Discord bots)
  • Live dashboards (financial data, monitoring)
  • Collaborative tools (Google Docs-style sync)
  • Gaming (multiplayer state sync)
  • Streaming (live comments, reactions)

Books That Will Help

Topic Book Chapter/Section Why It Helps
State Synchronization Designing Data-Intensive Applications Ch. 5: Replication Understand leader/follower patterns for keeping remote state in sync
Stream Processing Designing Data-Intensive Applications Ch. 11: Stream Processing Event-driven architecture for real-time updates
WebSocket Patterns Node.js Design Patterns Ch. 3: Callbacks and Events Managing async connections and event emitters
Error Recovery Node.js Design Patterns Ch. 11: Advanced Recipes Reconnection and resilience patterns
TypeScript Error Handling Effective TypeScript Item 46: Use unknown Safely handling WebSocket message types
API Authentication Web Application Security Ch. 4: Authentication Token-based auth best practices
Reconnection Strategies Release It! Ch. 5: Stability Patterns Circuit breakers and timeouts for resilient systems
Distributed Systems Designing Distributed Systems Ch. 4: Serving Patterns Ambassador and adapter patterns

Self-Assessment Checklist

Before considering this project complete, verify your understanding:

Conceptual Understanding

  • Can you explain the difference between optimistic updates and authoritative state?
  • Can you describe the WebSocket authentication flow for Home Assistant?
  • Can you explain why exponential backoff uses jitter?
  • Can you describe the event flow when a light is toggled from the phone app?
  • Can you explain how message ID correlation works in multiplexed protocols?

Implementation Skills

  • Can you establish a WebSocket client connection from Node.js?
  • Can you implement exponential backoff with jitter?
  • Can you track pending requests by message ID?
  • Can you handle WebSocket disconnection and reconnection?
  • Can you generate dynamic key images based on entity state?

Connection Management

  • Does your plugin connect to Home Assistant on startup?
  • Does your plugin authenticate with long-lived tokens?
  • Does your plugin reconnect automatically after disconnection?
  • Does your plugin show connection status on keys?
  • Does your plugin handle auth errors gracefully?

State Synchronization

  • Do keys update when devices change from other sources?
  • Do keys show pending state during command execution?
  • Do keys recover correct state after reconnection?
  • Can multiple keys control the same entity?
  • Does state sync work within 500ms?

Error Handling

  • Does invalid token show clear error message?
  • Does network failure trigger reconnection?
  • Do keys show error state when disconnected?
  • Does the plugin recover from Home Assistant restarts?
  • Are there no unhandled promise rejections?

Code Quality

  • Is your code organized into logical modules?
  • Are WebSocket messages properly typed?
  • Is the connection state machine clearly defined?
  • Are event listeners properly cleaned up?
  • Could another developer understand your architecture?

Real-World Readiness

  • Can you control lights from Stream Deck?
  • Do keys reflect changes from phone app within 1 second?
  • Does the plugin survive a Home Assistant restart?
  • Can you configure entities without code changes?
  • Could you use this plugin daily for smart home control?

Learning Milestones

Track your progress through the project:

  1. First milestone: Connect to Home Assistant and list available entities
  2. Second milestone: Single light toggle action with icon state reflection
  3. Third milestone: Property Inspector for selecting entities and configuring actions
  4. Final milestone: Multiple device types with bidirectional sync and error recovery

Project Guide Version 1.0 - December 2025