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
- Learning Objectives
- Deep Theoretical Foundation
- Complete Project Specification
- Real World Outcome
- Solution Architecture
- Phased Implementation Guide
- Testing Strategy
- Common Pitfalls & Debugging
- Extensions & Challenges
- Resources
- 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": {...}
}
}
}

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... | |
| +------------------------------------------------------------+ |
+------------------------------------------------------------------+

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

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

Creating a Long-Lived Token in Home Assistant:
- Navigate to your Profile (click your username)
- Scroll to âLong-Lived Access Tokensâ
- Click âCreate Tokenâ
- Give it a name like âStream Deck Pluginâ
- 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 |
+-------------------+

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 |
+-------------+

The Authoritative State Principle: Home Assistant is the single source of truth. Your pluginâs role is to:
- Send commands to HA
- Display what HA says is the current state
- 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

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!)

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() | |
| +----------------------+ |
| |
+------------------------------------------------------------------------+

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
| | |

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:
- Project Setup
streamdeck create # Select TypeScript template # Name: com.yourname.smarthome cd com.yourname.smarthome npm install ws npm install -D @types/ws - Create HA Client (
src/ha/client.ts)- Establish WebSocket connection
- Implement authentication flow
- Handle
auth_required,auth_ok,auth_invalid
- Create Connection Manager (
src/ha/connection-manager.ts)- State machine for connection states
- Exponential backoff for reconnection
- Event emitter for state changes
- 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:
- Implement Subscription (
src/ha/client.ts)- Send
subscribe_eventsmessage after auth - Handle incoming
eventmessages - Parse
state_changedevents
- Send
- Create Entity Tracker (
src/state/entity-tracker.ts)- Store current states of entities
- Provide methods to query state
- Emit events on state changes
- Fetch Initial States
- Call
get_statesafter subscription - Populate entity tracker with current states
- Call
- 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:
- Create Light Action (
src/actions/light-toggle.ts)- Extend SingletonAction
- Implement
onKeyDownto call toggle service - Implement
onWillAppearto set initial image
- 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)
- Wire Up State Changes
- Listen for state changes from entity tracker
- Update key image when state changes
- 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:
- Create Property Inspector HTML (
src/property-inspector/index.html)- Connection settings (URL, token)
- Entity dropdown (populated dynamically)
- Save/test connection buttons
- Implement Entity Fetching
- Fetch available entities from HA
- Filter by domain (light, climate, binary_sensor)
- Populate dropdown
- Token Validation
- Test connection on save
- Show success/error feedback
- Store in global settings
- 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:
- Reconnection Logic
- Implement exponential backoff
- Update all keys to âdisconnectedâ state during reconnection
- Re-subscribe after reconnect
- Error State Images
- âConnectingâŚâ state
- âDisconnectedâ state (with retry indicator)
- âAuth Failedâ state (directs to settings)
- Command Timeout
- Set timeout for state change after command
- Show error if no response within timeout
- Allow retry
- 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:
- Thermostat Action (
src/actions/thermostat.ts)- Display current/target temperature
- Dial rotation adjusts target
- Press cycles modes
- Binary Sensor Action (
src/actions/binary-sensor.ts)- Display sensor state
- Alert state for âonâ (door open, motion detected)
- No press action (display only)
- Thermostat Image Generator
- Temperature display
- Mode indicator (heat/cool/auto)
- Progress bar for current vs target
- 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
mqttnpm 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:
- First milestone: Connect to Home Assistant and list available entities
- Second milestone: Single light toggle action with icon state reflection
- Third milestone: Property Inspector for selecting entities and configuring actions
- Final milestone: Multiple device types with bidirectional sync and error recovery
Project Guide Version 1.0 - December 2025