Project 1: Personal Pomodoro Timer
Project 1: Personal Pomodoro Timer
Comprehensive Learning Guide Master Stream Deck plugin development through building a real-time countdown timer with dynamic visual feedback
Table of Contents
- Learning Objectives
- The Core Question Youâre Answering
- Deep Theoretical Foundation
- Concepts You Must Understand First
- Complete Project Specification
- Real World Outcome
- Solution Architecture
- Phased Implementation Guide
- Questions to Guide Your Design
- Thinking Exercise
- Testing Strategy
- Common Pitfalls & Debugging
- The Interview Questions Theyâll Ask
- Hints in Layers
- Extensions & Challenges
- Real-World Connections
- Books That Will Help
- Self-Assessment Checklist
Metadata
| Field | Value |
|---|---|
| Difficulty | Level 1: Beginner |
| Time Estimate | Weekend (8-12 hours) |
| Programming Language | JavaScript/TypeScript |
| Prerequisites | Basic JavaScript/TypeScript, Node.js fundamentals |
| Knowledge Area | GUI / Hardware Integration |
| Software/Tool | Elgato Stream Deck SDK |
| Main Book | âThe Pragmatic Programmerâ (for state machines context) |
| Coolness Level | Level 2: Practical but Forgettable |
| Business Potential | 2. The âMicro-SaaS / Pro Toolâ |
Learning Objectives
By completing this project, you will master:
-
Plugin Lifecycle Management: Understand the complete Stream Deck plugin lifecycle - from
onWillAppearwhen a key becomes visible toonWillDisappearwhen it leaves. Youâll know exactly when each event fires and what data is available. -
WebSocket Communication Architecture: Comprehend how your Node.js plugin communicates bidirectionally with the Stream Deck application through WebSockets. The SDK abstracts this, but understanding the underlying protocol makes debugging infinitely easier.
-
State Machine Design: Implement a clean finite state machine with exactly four states (IDLE, RUNNING, PAUSED, BREAK). Learn why state machines prevent impossible states and make complex logic manageable.
-
Canvas Rendering for Hardware: Generate dynamic 72x72 pixel images using HTML5 Canvas (or node-canvas in Node.js). Understand pixel density, font sizing, and color contrast for tiny LCD screens.
-
Settings Persistence Patterns: Master the
setSettings/getSettingsAPI to persist user preferences across plugin restarts. Learn the critical distinction between runtime state and persisted settings. -
Event-Driven Timer Implementation: Build accurate countdown timers using
setIntervalwhile understanding JavaScriptâs timing guarantees and limitations. Learn why naive implementations drift over time. -
Property Inspector Development: Create configuration UIs that communicate with your plugin through the Stream Deck bridge. Handle the peculiar bidirectional communication between main plugin and PI.
-
Real-Time Visual Feedback: Update hardware displays at 1Hz (once per second) without performance degradation. Understand the cost of image generation and network communication.
The Core Question Youâre Answering
âHow do I make a Stream Deck key update its display dynamically based on internal state, while persisting user preferences across restarts?â
This project answers the fundamental question of real-time visual feedback in Stream Deck plugins. By the end, youâll understand the complete data flow:
User Input --> State Change --> Visual Update --> Persistence
^ |
|________________________________________________|
(on restart)
Every professional Stream Deck plugin needs to:
- React to user input (key presses)
- Maintain internal state
- Render visual feedback to the hardware
- Persist configuration
The Pomodoro timer combines all four in a single, cohesive project. Once you understand this pattern, you can build any Stream Deck plugin.
Deep Theoretical Foundation
Stream Deck Plugin Architecture
Before writing any code, you must understand how Stream Deck plugins work at a fundamental level. The architecture follows a process separation model where your plugin runs in an isolated Node.js process, communicating with the main Stream Deck application via WebSocket.
+================================================================+
| YOUR COMPUTER |
+================================================================+
| |
| +---------------------------+ +---------------------------+ |
| | STREAM DECK APP | | YOUR PLUGIN | |
| | (Electron/Native) | | (Node.js Process) | |
| | | | | |
| | +---------+ +---------+ | | +---------+ +---------+ | |
| | | UI | | Hardware| | | | Action | | Property| | |
| | | Manager | | Driver | | | | Handler | | Inspector| | |
| | +---------+ +---------+ | | +---------+ +---------+ | |
| | | ^ | | ^ ^ | |
| | v | | | | | | |
| | +---------+ | | | | | | |
| | | WebSocket|<-----|------|----+-------+------------+ | |
| | | Server | | | | | |
| | +---------+ | | +---------------------------+ |
| | ^ | | ^ |
| | | | | | |
| +-------|------------|------+ | |
| | | | |
| v v | |
| +---------------------------+ | |
| | STREAM DECK HARDWARE |<---------------+ |
| | (Physical Device) | (via setImage, setTitle) |
| | | |
| | [Key 0] [Key 1] [Key 2] | |
| | [Key 3] [Key 4] [Key 5] | |
| | [Key 6] [Key 7] [Key 8] | |
| +---------------------------+ |
| |
+================================================================+
Why This Architecture Matters:
-
Process Isolation: Your plugin cannot crash the Stream Deck app. Bugs in your code only affect your plugin.
-
Language Freedom: The WebSocket protocol is language-agnostic. While Elgatoâs SDK uses Node.js, you could theoretically write plugins in any language.
-
Hot Reload: The Stream Deck app can restart your plugin without restarting itself. This enables rapid development iteration.
-
Security: Plugins donât have direct hardware access. All communication goes through the Stream Deck app, which acts as a gatekeeper.
WebSocket Communication Model
The Stream Deck SDK abstracts WebSocket communication, but understanding the underlying protocol helps with debugging and advanced scenarios.
Plugin Startup Sequence
=======================
1. Stream Deck App launches your plugin process
|
v
2. Plugin receives connection info via command-line args:
- Port number for WebSocket connection
- UUID to identify this plugin instance
- Register event name
- Additional context info
|
v
3. Plugin creates WebSocket connection to localhost:{port}
|
v
4. Plugin sends registration message:
{
"event": "registerPlugin",
"uuid": "com.example.pomodoro"
}
|
v
5. Stream Deck App acknowledges registration
|
v
6. Two-way communication begins
Message Flow (Simplified)
=========================
Stream Deck App Your Plugin
| |
| -- onWillAppear --> |
| {"event": "willAppear", |
| "context": "abc123", |
| "payload": {...}} |
| |
| |
| <-- setImage -- |
| {"event": "setImage", |
| "context": "abc123", |
| "payload": {"image": "..."}}|
| |
| -- onKeyDown --> |
| {"event": "keyDown", |
| "context": "abc123"} |
| |
v v
Key Protocol Details:
-
Context: Every action instance has a unique context string. If a user has two Pomodoro timers on their deck, each has a different context. You MUST track state per-context, not globally.
-
Event Names: The SDK uses camelCase for events (
willAppear,keyDown), but exposes them asonWillAppear,onKeyDownin your code. -
Payload: Most events include a
payloadobject with relevant data. ForwillAppear, this includes current settings. ForkeyDown, it includes coordinates and whether itâs a multi-action.
Plugin Lifecycle Events
Understanding when each event fires is critical for building reliable plugins. Hereâs the complete lifecycle:
Plugin Instance Lifecycle
=========================
Stream Deck App
|
| (User drags action to key)
v
+--------------------+
| onWillAppear |<---- Entry point for new instances
| | Receive: context, settings, coordinates
| - Load settings | Action: Initialize state, render initial image
| - Initialize state |
| - Render initial |
+--------------------+
|
v
+--------------------+
| ACTIVE STATE |<---- Normal operation
| |
| onKeyDown -------->| User pressed key
| onKeyUp ---------->| User released key
| onTitleParamsDidChange -> Title/parameters changed
| onPropertyInspectorDidAppear -> PI opened
| onPropertyInspectorDidDisappear -> PI closed
| onDidReceiveSettings ------> Settings changed (from PI)
+--------------------+
|
| (User removes action from key OR
| User navigates to different profile)
v
+--------------------+
| onWillDisappear |<---- Cleanup point
| | Action: Stop timers, save state
| - Clear intervals |
| - Save final state |
+--------------------+
Property Inspector Lifecycle
============================
User clicks gear icon Your Plugin
| |
v |
+-------------------+ |
| PI Loads | |
| (separate HTML) | |
+-------------------+ |
| |
| sendToPlugin({...}) |
|------------------------->|
| | onSendToPlugin
| |
| <------------------------|
| sendToPropertyInspector|
| |
+-------------------+ |
| PI Updates UI | |
+-------------------+ |
| |
| (User changes settings) |
| |
| setSettings({...}) |
|------------------------->|
| | onDidReceiveSettings
| |
+-------------------+ |
| PI Closes | |
| (gear click again)| |
+-------------------+ |
Critical Timing Details:
onWillAppear: Fires when a key becomes visible. This happens:- When a profile containing your action loads
- When the user drags your action onto a key
- When the Stream Deck device connects
- NOT when the plugin first starts (unless your action is already on a key)
-
onKeyDownvsonKeyUp: For a Pomodoro timer, youâll typically useonKeyDownfor immediate response. UseonKeyUpif you need to measure press duration (for long-press detection). -
onWillDisappear: Fires when the action is removed or hidden. CRITICAL: Stop anysetIntervaltimers here or theyâll continue running and cause errors. onDidReceiveSettings: Fires whenever settings change, including:- When the Property Inspector calls
setSettings - When
onWillAppearincludes new settings - You should update your internal state and re-render
- When the Property Inspector calls
State Machines for Timer Logic
A Pomodoro timer has exactly four states. Using a state machine prevents âimpossibleâ combinations like being both running and paused simultaneously.
State Machine Diagram
=====================
+------------------+
| |
| IDLE |
| |
| Display: START |
| Timer: Stopped |
| Background: Gray|
+--------+---------+
|
| KEY_DOWN
v
+------------------------+------------------------+
| |
v |
+------------------+ |
| | KEY_DOWN +------------------+ |
| RUNNING |<------------->| PAUSED | |
| | | | |
| Display: MM:SS | | Display: MM:SS | |
| Timer: Active | | Timer: Stopped | |
| Background:Green| | Background:Yellow| |
+--------+---------+ +--------+---------+ |
| | |
| TIMER_COMPLETE | |
v | |
+------------------+ | |
| | | |
| BREAK |------------------------+ |
| | TIMER_COMPLETE |
| Display: MM:SS | or LONG_PRESS |
| Timer: Active |--------------------------------------+
| Background: Blue|
+------------------+
State Transition Table
======================
| Current State | Event | Next State | Actions |
|---------------|-----------------|------------|------------------------------|
| IDLE | KEY_DOWN | RUNNING | Start timer, play start sound|
| RUNNING | KEY_DOWN | PAUSED | Stop interval, save time |
| RUNNING | TIMER_COMPLETE | BREAK | Play alert, start break timer|
| RUNNING | LONG_PRESS | IDLE | Reset to initial state |
| PAUSED | KEY_DOWN | RUNNING | Resume timer |
| PAUSED | LONG_PRESS | IDLE | Reset to initial state |
| BREAK | KEY_DOWN | PAUSED | Pause break timer |
| BREAK | TIMER_COMPLETE | IDLE/RUN | Complete cycle, maybe restart|
| BREAK | LONG_PRESS | IDLE | Skip break, reset |
Why State Machines Work:
-
Explicit States: Every possible configuration is named and documented. No âhalfwayâ states.
-
Explicit Transitions: You canât go from IDLE to BREAK directly. The transition table is the source of truth.
-
Predictable Actions: Each transition triggers specific actions. This makes debugging easier.
-
Testable: You can unit test the state machine in isolation from the Stream Deck SDK.
Implementation Pattern:
type TimerState = 'idle' | 'running' | 'paused' | 'break';
type TimerEvent =
| { type: 'KEY_DOWN' }
| { type: 'KEY_UP'; duration: number } // for long-press detection
| { type: 'TIMER_COMPLETE' };
function transition(state: TimerState, event: TimerEvent): TimerState {
switch (state) {
case 'idle':
if (event.type === 'KEY_DOWN') return 'running';
return state;
case 'running':
if (event.type === 'KEY_DOWN') return 'paused';
if (event.type === 'TIMER_COMPLETE') return 'break';
if (event.type === 'KEY_UP' && event.duration > 2000) return 'idle';
return state;
// ... etc
}
}
Reference: The Pragmatic Programmer by Hunt & Thomas, Topic 26, discusses why state machines make code more reliable. Refactoring by Fowler, Chapter 10, shows the State Pattern as an OOP alternative.
Canvas Rendering for Dynamic Images
Stream Deck keys are 72x72 pixel LCD screens. Your plugin generates images as base64-encoded PNGs and sends them via the setImage API.
Canvas Coordinate System
========================
(0,0)--------------------------> X (72)
|
| +-----------------------+
| | |
| | Your Drawing |
| | Area |
| | |
| | |
| +-----------------------+
|
v
Y (72)
Retina Rendering (2x)
=====================
Physical Key: 72x72 pixels
Canvas Size: 144x144 pixels (2x for retina)
144 canvas pixels
+------------------------+
| |
| +-----------+ |
144 | | Text | | --> Scaled down to
| | renders | | 72x72 for display
| | crisply | |
| +-----------+ |
+------------------------+
Layout for Pomodoro Timer
=========================
+------------------------+
| |
| +---------+ |
| | 24:37 | | <-- Time: 48px bold font
| +---------+ | Centered at (72, 60)
| |
| FOCUS | <-- Mode: 20px font
| | Centered at (72, 100)
| +----------------+ |
| |################| | <-- Progress bar
| +----------------+ | Rect from (20, 120) to (124, 130)
+------------------------+
Key Rendering Decisions:
-
Font Choice: Sans-serif fonts render best at small sizes. Arial, Helvetica, or system fonts work well.
- Font Size: For a 144x144 canvas (2x retina):
- Large numbers: 48-56px
- Labels: 20-24px
- Minimum readable: 16px
-
Contrast: Use high contrast colors. White text on dark backgrounds or vice versa. Avoid subtle gradients.
- Progress Visualization: A horizontal progress bar is more readable than a circular one at this size.
Canvas in Node.js:
The Stream Deck plugin runs in Node.js, which doesnât have a native Canvas API. You have two options:
Option 1: node-canvas (server-side rendering)
=============================================
const { createCanvas } = require('canvas');
const canvas = createCanvas(144, 144);
const ctx = canvas.getContext('2d');
// Draw your timer
ctx.fillStyle = '#2ecc71';
ctx.fillRect(0, 0, 144, 144);
ctx.fillStyle = 'white';
ctx.font = 'bold 48px Arial';
ctx.textAlign = 'center';
ctx.fillText('24:37', 72, 72);
// Convert to base64
const base64 = canvas.toDataURL('image/png');
Option 2: SVG to PNG (if node-canvas won't install)
===================================================
const sharp = require('sharp');
const svg = `
<svg width="144" height="144" xmlns="http://www.w3.org/2000/svg">
<rect width="144" height="144" fill="#2ecc71"/>
<text x="72" y="72" font-size="48" fill="white"
text-anchor="middle" dominant-baseline="middle">
24:37
</text>
</svg>
`;
const pngBuffer = await sharp(Buffer.from(svg)).png().toBuffer();
const base64 = 'data:image/png;base64,' + pngBuffer.toString('base64');
Performance Considerations:
- Rendering at 1Hz (once per second) is fine for a timer
- Cache static elements (background, icons) if possible
- The
setImagecall goes over WebSocket - donât call it more than needed
Settings Persistence
Stream Deck provides two types of storage, and understanding the difference is crucial:
Settings vs Global Settings
===========================
+-------------------+ +------------------------+
| Settings | | Global Settings |
+-------------------+ +------------------------+
| Per-action | | Per-plugin |
| instance | | (all instances share) |
+-------------------+ +------------------------+
| Use for: | | Use for: |
| - Work duration | | - API keys |
| - Break duration | | - User preferences |
| - Sound selection | | - Shared resources |
+-------------------+ +------------------------+
| API: | | API: |
| setSettings() | | setGlobalSettings() |
| ev.payload.settings| | getGlobalSettings() |
+-------------------+ +------------------------+
Data Flow: Settings Lifecycle
=============================
Plugin Code Stream Deck App Disk Storage
| | |
| setSettings({ | |
| workMinutes: 25 | --- writes to ---> |
| }) | [profile.json]
| | |
| | |
(plugin restarts) | |
| | |
| <-- onWillAppear ------| |
| payload.settings = | <--- reads from --- |
| { workMinutes: 25 }| [profile.json]
| | |
What to Store Where
===================
SETTINGS (per-instance): RUNTIME STATE (in memory):
- workMinutes - currentState (idle/running/etc)
- breakMinutes - remainingSeconds
- longBreakMinutes - intervalId
- sessionsBeforeLongBreak - sessionCount (or store in settings
- soundEnabled if you want to preserve across restart)
- soundFile
- autoStartBreak
- autoStartWork
GLOBAL SETTINGS (per-plugin):
- defaultSoundVolume
- analyticsEnabled
- colorTheme
Critical Persistence Patterns:
- Load Settings in
onWillAppear:onWillAppear(ev: WillAppearEvent<PomodoroSettings>) { // Settings come with the event! this.settings = ev.payload.settings ?? DEFAULT_SETTINGS; this.remainingSeconds = this.settings.workMinutes * 60; this.render(); } - Save Settings from Property Inspector: ```typescript // Property Inspector (HTML/JS) document.getElementById(âsaveâ).addEventListener(âclickâ, () => { $SD.setSettings({ workMinutes: parseInt(workInput.value), breakMinutes: parseInt(breakInput.value), }); });
// Plugin receives update
onDidReceiveSettings(ev: DidReceiveSettingsEvent
3. **Don't Persist Runtime State** (usually):
```typescript
// BAD: Persisting timer state
setSettings({
remainingSeconds: 847, // Don't do this!
isRunning: true, // Don't do this!
});
// WHY BAD:
// - Timer state becomes stale quickly
// - What if the user has multiple computers?
// - What if settings sync causes conflicts?
// GOOD: Only persist configuration
setSettings({
workMinutes: 25,
breakMinutes: 5,
});
Concepts You Must Understand First
Before writing any code, ensure you understand these foundations:
| Concept | Why It Matters | Where to Learn |
|---|---|---|
| Plugin Lifecycle Events | onWillAppear and onKeyDown are your entry points for all interaction. Without understanding when these fire, your timer will behave unpredictably. |
Stream Deck SDK docs, Node.js Design Patterns Ch. 3 |
| State Machines | A Pomodoro timer has distinct states (IDLE, RUNNING, PAUSED, BREAK). State machines prevent âimpossibleâ states like being paused and running simultaneously. | The Pragmatic Programmer Topic 26, Refactoring Ch. 10 |
| Canvas Rendering Basics | Stream Deck keys are 72x72 pixel images. Youâll generate these dynamically using node-canvas or SVG conversion. | MDN Canvas Tutorial, node-canvas docs |
| Settings Persistence | User preferences must survive plugin restarts. Stream Deck provides setSettings/getSettings for this. |
Stream Deck SDK Settings Guide |
| setInterval/Timers in Node.js | Your countdown runs via JavaScript timers. Understand how they interact with the event loop. | Node.js Design Patterns Ch. 2 |
| TypeScript Generics | The SDK uses generics for type-safe settings. WillAppearEvent<YourSettings> gives you typed access to payload. |
Effective TypeScript Items 29-37 |
Complete Project Specification
Functional Requirements
Core Features (Must Have):
| Feature | Description | Priority |
|---|---|---|
| Timer countdown display | Show remaining time on key in MM:SS format | P0 |
| Start/Pause toggle | Single press starts or pauses timer | P0 |
| Reset functionality | Long press (2s) resets timer to initial state | P0 |
| Work/Break modes | Timer alternates between work and break periods | P0 |
| Visual state indication | Different colors/icons for each state | P0 |
| Configurable durations | User sets work/break times in Property Inspector | P1 |
| Sound notifications | Audio alert when timer completes | P1 |
| Progress visualization | Visual progress bar on key | P1 |
| Auto-start options | Optionally auto-start break after work | P2 |
| Session counting | Track completed Pomodoros for long breaks | P2 |
Timer State Requirements:
interface PomodoroSettings {
workMinutes: number; // Default: 25
breakMinutes: number; // Default: 5
longBreakMinutes: number; // Default: 15
sessionsBeforeLongBreak: number; // Default: 4
soundEnabled: boolean; // Default: true
soundFile: string; // Default: 'chime.mp3'
autoStartBreak: boolean; // Default: true
autoStartWork: boolean; // Default: false
}
interface RuntimeState {
state: 'idle' | 'running' | 'paused' | 'break';
remainingSeconds: number;
completedSessions: number;
intervalId: NodeJS.Timeout | null;
}
Non-Functional Requirements
| Requirement | Target | Rationale |
|---|---|---|
| Update frequency | 1 Hz (once per second) | Human-readable countdown, not wasteful |
| Image render time | < 50ms | No visible lag when updating display |
| Memory usage | < 50MB | Plugins run indefinitely; must be lean |
| Timer drift | < 1 second per hour | Use timestamp-based calculation, not counter |
| CPU usage | < 1% idle, < 5% active | Donât drain laptop battery |
Real World Outcome
When you complete this project, hereâs exactly what youâll see:
Stream Deck Key Display
Stream Deck Key (72x72 pixels):
+---------------------+
| |
| +---------+ |
| | 24:37 | | <-- Time remaining in large text
| +---------+ |
| FOCUS | <-- Current mode (FOCUS/BREAK)
| *--------o | <-- Progress bar
+---------------------+
States:
- IDLE: Shows "START" with tomato icon, gray background
- RUNNING: Shows countdown "MM:SS", green border, progress bar fills
- PAUSED: Shows paused time, yellow background, pulsing effect
- BREAK: Shows break countdown, blue background, coffee icon
Key States Visual Progression
+------------------------------------------------------------------+
| YOUR STREAM DECK KEY |
| |
| IDLE STATE WORKING STATE BREAK STATE |
| +----------+ +----------+ +----------+ |
| | START | | 24:37 | | 04:22 | |
| | :) | ---> | ######## | ---> | ######oo | |
| | 25:00 | | WORK | | BREAK | |
| +----------+ +----------+ +----------+ |
| (Gray) (Red/Orange) (Green/Blue) |
+------------------------------------------------------------------+
Property Inspector UI
When you click the gear icon for your action, this configuration panel appears:
+---------------------------------------------+
| Pomodoro Timer Settings |
+---------------------------------------------+
| Work Duration: [25] minutes |
| Break Duration: [5 ] minutes |
| Long Break: [15] minutes |
| Sessions Before Long Break: [4] |
| |
| [x] Play sound on completion |
| [x] Auto-start break after work |
| [ ] Auto-start work after break |
| |
| Sound: [Chime v] |
+---------------------------------------------+
Interaction Flow
Interaction Timeline
====================
Time Action Display State
---- ------ ------- -----
0:00 User presses key "25:00" green IDLE -> RUNNING
0:01 - "24:59" RUNNING
...
24:00 - "01:00" RUNNING
24:59 - "00:01" RUNNING
25:00 Timer completes! "BREAK" blue RUNNING -> BREAK
Sound plays
Break timer starts "05:00"
...
30:00 Break completes "START" gray BREAK -> IDLE
(or auto-starts work)
Long Press Detection
====================
Time Action Result
---- ------ ------
0:00 User presses key KEY_DOWN fires
0:50 - (still held)
2:00 User releases key KEY_UP fires with duration=2000ms
State: ANY -> IDLE (reset)
Solution Architecture
System Architecture Diagram
+==================================================================+
| POMODORO TIMER PLUGIN |
+==================================================================+
| |
| +------------------+ +------------------+ +------------+ |
| | Action Class | | Timer Engine | | Renderer | |
| |------------------| |------------------| |------------| |
| | - onWillAppear |---->| - startTimer |---->| - drawIdle | |
| | - onKeyDown | | - pauseTimer | | - drawRun | |
| | - onKeyUp | | - resetTimer | | - drawPause| |
| | - onWillDisappear|<----| - tick |<----| - drawBreak| |
| +------------------+ +------------------+ +------------+ |
| | | | |
| | v | |
| | +------------------+ | |
| | | State Machine | | |
| | |------------------| | |
| +-------------->| - currentState |<----------+ |
| | - transition() | |
| | - canTransition()| |
| +------------------+ |
| | |
| v |
| +------------------+ +------------------+ |
| | Property Inspector| | Sound Player | |
| |------------------| |------------------| |
| | - settings form | | - playChime | |
| | - save handler | | - playAlert | |
| | - defaults | | - setVolume | |
| +------------------+ +------------------+ |
| |
+==================================================================+
|
v WebSocket (via SDK)
+==================================================================+
| STREAM DECK APPLICATION |
+==================================================================+
| |
| Receives: setImage, setTitle, setSettings |
| Sends: onWillAppear, onKeyDown, onKeyUp, onDidReceiveSettings |
| |
+==================================================================+
|
v USB/Hardware
+==================================================================+
| STREAM DECK DEVICE |
+==================================================================+
Class Diagram
+----------------------------------+
| PomodoroAction |
+----------------------------------+
| - context: string |
| - state: TimerState |
| - remainingSeconds: number |
| - intervalId: Timeout | null |
| - settings: PomodoroSettings |
| - pressStartTime: number | null |
+----------------------------------+
| + onWillAppear(ev): void |
| + onWillDisappear(ev): void |
| + onKeyDown(ev): void |
| + onKeyUp(ev): void |
| + onDidReceiveSettings(ev): void |
+----------------------------------+
| - startTimer(): void |
| - pauseTimer(): void |
| - resetTimer(): void |
| - tick(): void |
| - transitionToBreak(): void |
| - transitionToIdle(): void |
| - render(): void |
| - playSound(): void |
+----------------------------------+
|
| uses
v
+----------------------------------+
| TimerRenderer |
+----------------------------------+
| - canvas: Canvas |
| - ctx: CanvasRenderingContext2D |
+----------------------------------+
| + renderIdle(settings): string |
| + renderRunning(secs, total): str|
| + renderPaused(secs, total): str |
| + renderBreak(secs, total): str |
+----------------------------------+
| - drawBackground(color): void |
| - drawTime(secs): void |
| - drawLabel(text): void |
| - drawProgress(pct): void |
| - toBase64(): string |
+----------------------------------+
+----------------------------------+
| PomodoroSettings |
+----------------------------------+
| workMinutes: number |
| breakMinutes: number |
| longBreakMinutes: number |
| sessionsBeforeLongBreak: number |
| soundEnabled: boolean |
| soundFile: string |
| autoStartBreak: boolean |
| autoStartWork: boolean |
+----------------------------------+
+----------------------------------+
| TimerState |
+----------------------------------+
| 'idle' | 'running' | |
| 'paused' | 'break' |
+----------------------------------+
File Structure
pomodoro-timer/
+-- package.json # Dependencies: @elgato/streamdeck, canvas
+-- tsconfig.json # TypeScript config
+-- rollup.config.js # Bundler config (or esbuild)
|
+-- src/
| +-- plugin.ts # Entry point, registers action
| +-- actions/
| | +-- pomodoro.ts # Main PomodoroAction class
| +-- core/
| | +-- state-machine.ts # State transitions
| | +-- timer-engine.ts # setInterval wrapper
| | +-- renderer.ts # Canvas rendering
| | +-- sound.ts # Audio playback
| +-- types/
| +-- settings.ts # PomodoroSettings interface
| +-- events.ts # Event type extensions
|
+-- pi/ # Property Inspector
| +-- index.html # PI markup
| +-- index.js # PI logic
| +-- style.css # PI styling
|
+-- assets/
| +-- images/
| | +-- action-icon.png # Default action icon
| | +-- action-icon@2x.png
| +-- sounds/
| +-- chime.mp3 # Notification sound
| +-- bell.mp3
|
+-- com.example.pomodoro.sdPlugin/ # Built plugin folder
+-- manifest.json # Plugin manifest
+-- bin/ # Compiled JS
+-- pi/ # Property Inspector files
+-- images/ # Icons
+-- sounds/ # Audio files
Phased Implementation Guide
Phase 1: Plugin Scaffolding (Day 1, Morning)
Goal: Create a plugin that loads in Stream Deck and shows a static icon.
Milestone: Plugin appears in Stream Deck app, action can be dragged to a key.
Steps:
- Install Stream Deck CLI:
npm install -g @elgato/cli - Create new plugin:
streamdeck create pomodoro-timer cd pomodoro-timer - Examine the generated structure:
- Open
manifest.json- this defines your plugin - Open
src/plugin.ts- this is your entry point - Open the action file created by the template
- Open
- Build and link for development:
npm run build streamdeck link # Creates symlink for hot reload - Verify in Stream Deck app:
- Open Stream Deck app
- Find your plugin in the action list
- Drag it to a key
- Observe the console output
Success Criteria:
- Plugin loads without errors
- Action appears in Stream Deck
- Dragging to key triggers
onWillAppear(check console)
Phase 2: Static Display (Day 1, Afternoon)
Goal: Render a custom image on the key showing âSTARTâ and a time.
Milestone: Key displays âSTART / 25:00â instead of default icon.
Steps:
- Install canvas dependency:
npm install canvas # If canvas won't build, try: npm install sharp - Create a basic renderer (
src/core/renderer.ts):- Create 144x144 canvas (2x for retina)
- Draw gray background
- Draw âSTARTâ text centered
- Draw â25:00â below
- Export as base64 PNG
- Update action to use renderer:
- In
onWillAppear, call your renderer - Call
ev.action.setImage(base64String)
- In
- Test:
- Build and reload plugin
- Drag action to key
- Observe custom image
Hints:
- Canvas text alignment:
ctx.textAlign = 'center' - Canvas baseline:
ctx.textBaseline = 'middle' - To center at (72, 72), use:
ctx.fillText('TEXT', 72, 72)
Success Criteria:
- Key shows your custom-rendered image
- Text is readable and centered
- No console errors
Phase 3: Timer Logic (Day 2, Morning)
Goal: Implement countdown timer that updates the display every second.
Milestone: Pressing key starts countdown, display updates in real-time.
Steps:
- Add state tracking to your action:
private state: 'idle' | 'running' = 'idle'; private remainingSeconds: number = 25 * 60; private intervalId: NodeJS.Timeout | null = null; - Implement
onKeyDown:- If idle: start timer, set state to running
- If running: pause timer (for now, just stop)
- Create tick function:
- Decrement
remainingSeconds - Re-render display
- If
remainingSeconds === 0: stop timer, reset
- Decrement
- Wire up the interval:
private startTimer() { this.state = 'running'; this.intervalId = setInterval(() => this.tick(), 1000); this.render(); } - Test:
- Press key: timer starts
- Watch countdown: updates every second
- Wait for completion: timer resets
Critical Bug Prevention:
- Clear interval in
onWillDisappear:onWillDisappear() { if (this.intervalId) { clearInterval(this.intervalId); this.intervalId = null; } }
Success Criteria:
- Timer starts on key press
- Display updates every second
- Timer resets when complete
- No zombie intervals after removing action
Phase 4: State Machine (Day 2, Afternoon)
Goal: Implement full state machine with pause, break, and reset.
Milestone: All four states work correctly with proper transitions.
Steps:
- Expand state type:
type TimerState = 'idle' | 'running' | 'paused' | 'break'; - Implement state transitions:
- idle + KEY_DOWN = running
- running + KEY_DOWN = paused
- paused + KEY_DOWN = running (resume)
- running + COMPLETE = break
- break + COMPLETE = idle
- Add long-press detection:
- Track
keyDownTimeinonKeyDown - Check duration in
onKeyUp - If > 2 seconds: reset to idle
- Track
- Update renderer for all states:
- Idle: gray background, âSTARTâ
- Running: green background, countdown
- Paused: yellow background, âPAUSEDâ
- Break: blue background, âBREAKâ
- Test all transitions:
- Press: idle -> running
- Press: running -> paused
- Press: paused -> running
- Wait: running -> break
- Long press from any state: -> idle
Success Criteria:
- All state transitions work
- Visual states are distinct
- Long press resets from any state
- No impossible state combinations
Phase 5: Property Inspector (Day 3, Morning)
Goal: Create settings UI for configuring timer durations.
Milestone: User can change work/break durations; settings persist.
Steps:
- Create Property Inspector HTML (
pi/index.html):<div class="sdpi-wrapper"> <div class="sdpi-item"> <div class="sdpi-item-label">Work (minutes)</div> <input id="work" class="sdpi-item-value" type="number" min="1" max="60"> </div> <!-- More fields... --> </div> - Add Property Inspector JS (
pi/index.js):- Load current settings from
$SD.settings - Populate form fields
- On change, call
$SD.setSettings()
- Load current settings from
- Handle settings in plugin:
- Implement
onDidReceiveSettings - Update internal settings object
- If idle, update remaining time
- Implement
- Test:
- Open PI (click gear icon)
- Change work minutes
- Close PI
- Verify timer uses new duration
Hints:
- Use the Stream Deck SDKâs PI library: Include
sdpi.cssand$SDglobal - Settings are automatically saved to profile
- Test by restarting Stream Deck app
Success Criteria:
- PI loads and shows current settings
- Changing settings updates plugin behavior
- Settings persist across restarts
Phase 6: Sound Notifications (Day 3, Afternoon)
Goal: Play audio alerts when timer completes.
Milestone: Chime plays when work/break period ends.
Steps:
- Add sound files:
- Place
chime.mp3inassets/sounds/ - Ensure itâs copied to build output
- Place
- Install audio player:
npm install play-sound # or: npm install node-wav-player - Create sound module (
src/core/sound.ts):import player from 'play-sound'; export function playNotification(soundFile: string) { const soundPath = path.join(__dirname, 'sounds', soundFile); player().play(soundPath, (err) => { if (err) console.error('Sound failed:', err); }); } - Wire up to state transitions:
- When RUNNING -> BREAK: play work complete sound
- When BREAK -> IDLE: play break complete sound
- Add sound setting to PI:
- Checkbox for enable/disable
- Dropdown for sound selection
Alternative (simpler but hacky):
- Use system notification with sound via
node-notifier
Success Criteria:
- Sound plays when timer completes
- Sound can be disabled in settings
- Different sounds for work/break
Phase 7: Polish and Edge Cases (Day 4)
Goal: Handle all edge cases, improve visuals, optimize performance.
Milestone: Production-ready plugin.
Steps:
- Handle multiple instances:
- Each action has unique context
- Store state per-context, not globally
- Test with two Pomodoro actions
- Optimize rendering:
- Donât re-render if nothing changed
- Reuse canvas instance
- Profile with console.time
- Add progress bar:
- Calculate:
progress = 1 - (remaining / total) - Draw filled rectangle proportional to progress
- Calculate:
- Improve visual feedback:
- Add pulsing effect when paused
- Add celebration animation on completion
- Color transitions between states
- Error handling:
- Wrap all async operations in try/catch
- Log errors with context
- Graceful degradation if sound fails
- Test edge cases:
- Remove action while timer running
- Restart Stream Deck app mid-timer
- Set very short durations (1 min)
- Set very long durations (60 min)
Success Criteria:
- Multiple instances work independently
- No memory leaks over time
- All error cases handled gracefully
- Professional visual appearance
Questions to Guide Your Design
Before coding, answer these questions on paper:
- State Tracking: How will you track timer state between key presses?
- Consider: class instance variables vs. Stream Deck settings
- What survives a plugin restart? What should?
- Multi-Instance: What if the user drags two Pomodoro actions onto different keys?
- Should they share state or be independent?
- How do you store per-instance data?
- Timer Accuracy: JavaScriptâs
setIntervalisnât perfectly accurate.- How do you prevent drift over a 25-minute period?
- Should you store the target end time instead of decrementing?
- Break Transitions: How will you handle work/break transitions?
- Should breaks start automatically?
- What if the user wants to skip a break?
- Long Press: How do you detect a long press (2+ seconds)?
- When does
onKeyDownfire vsonKeyUp? - Where do you store the press start time?
- When does
- Settings vs State: What belongs in persisted settings vs runtime state?
- Should
remainingSecondspersist? - What happens if settings sync across computers?
- Should
- Error Recovery: What happens if your timer callback throws?
- Does the timer stop? Can you recover?
- Should you wrap callbacks in try/catch?
Thinking Exercise
Before writing code, trace through this scenario mentally:
SCENARIO: User has a 25-minute Pomodoro timer configured.
They work for 10 minutes, pause for 5 minutes (coffee break), then resume.
Time 0:00 - User presses key
-> What event fires? _______________
-> What state do you enter? _______________
-> What does the key display? _______________
-> What does your code do with setInterval? _______________
Time 10:00 - User presses key to pause
-> Timer shows "15:00" remaining
-> What do you save? _______________
-> What happens to setInterval? _______________
-> What color is the key background? _______________
Time 15:00 - User presses key to resume (after 5 min pause)
-> Should the timer still show "15:00"? _______________
-> How do you restart the countdown? _______________
-> Does `remainingSeconds` change? _______________
Time 30:00 - Timer reaches 00:00 (15 min later, so 30 total elapsed)
-> What event triggers (internal)? _______________
-> What sound plays? _______________
-> What state do you transition to? _______________
-> Does break timer start automatically? _______________
Answers to verify your understanding:
- Time 0:00:
onKeyDownfires; enterrunning; display â25:00â; startsetInterval - Time 10:00: save
remainingSeconds = 900; clear interval; background yellow - Time 15:00: yes, 15:00; start new interval;
remainingSecondsunchanged - Time 30:00: internal tick sees 0; play completion sound; transition to
break; depends onautoStartBreaksetting
Testing Strategy
Unit Tests: State Machine
Test state transitions in isolation:
// tests/state-machine.test.ts
import { describe, it, expect } from 'vitest';
import { transition, TimerState, TimerEvent } from '../src/core/state-machine';
describe('Timer State Machine', () => {
it('transitions from idle to running on key down', () => {
const result = transition('idle', { type: 'KEY_DOWN' });
expect(result).toBe('running');
});
it('transitions from running to paused on key down', () => {
const result = transition('running', { type: 'KEY_DOWN' });
expect(result).toBe('paused');
});
it('transitions from running to break on timer complete', () => {
const result = transition('running', { type: 'TIMER_COMPLETE' });
expect(result).toBe('break');
});
it('transitions from any state to idle on long press', () => {
const states: TimerState[] = ['running', 'paused', 'break'];
for (const state of states) {
const result = transition(state, { type: 'LONG_PRESS' });
expect(result).toBe('idle');
}
});
it('stays in idle on long press', () => {
const result = transition('idle', { type: 'LONG_PRESS' });
expect(result).toBe('idle');
});
});
Unit Tests: Timer Calculation
Test time formatting and progress calculation:
// tests/timer-utils.test.ts
describe('Timer Utilities', () => {
it('formats seconds as MM:SS', () => {
expect(formatTime(1500)).toBe('25:00');
expect(formatTime(90)).toBe('01:30');
expect(formatTime(5)).toBe('00:05');
});
it('calculates progress percentage', () => {
expect(calculateProgress(1500, 1500)).toBe(0); // Just started
expect(calculateProgress(750, 1500)).toBe(0.5); // Halfway
expect(calculateProgress(0, 1500)).toBe(1); // Complete
});
it('handles edge case of 0 total', () => {
expect(calculateProgress(0, 0)).toBe(0);
});
});
Integration Tests: Mocked SDK
Test action behavior without real Stream Deck:
// tests/pomodoro-action.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock the SDK
const mockSetImage = vi.fn();
const mockContext = 'test-context-123';
const mockEvent = {
action: {
setImage: mockSetImage,
setSettings: vi.fn(),
},
payload: {
settings: { workMinutes: 25, breakMinutes: 5 },
},
};
describe('PomodoroAction', () => {
let action: PomodoroAction;
beforeEach(() => {
vi.clearAllMocks();
action = new PomodoroAction();
action.onWillAppear(mockEvent);
});
it('renders idle state on appear', () => {
expect(mockSetImage).toHaveBeenCalled();
const imageData = mockSetImage.mock.calls[0][0];
expect(imageData).toContain('data:image/png;base64');
});
it('starts timer on key down from idle', () => {
action.onKeyDown(mockEvent);
expect(action.getState()).toBe('running');
});
it('pauses timer on key down from running', () => {
action.onKeyDown(mockEvent); // idle -> running
action.onKeyDown(mockEvent); // running -> paused
expect(action.getState()).toBe('paused');
});
});
Manual Testing Checklist
[ ] Plugin loads without console errors
[ ] Action appears in Stream Deck action list
[ ] Dragging to key shows custom image
[ ] Key press starts timer
[ ] Timer updates every second
[ ] Key press pauses timer
[ ] Key press resumes paused timer
[ ] Long press (2s+) resets timer
[ ] Timer transitions to break at 0:00
[ ] Sound plays on completion
[ ] Break timer counts down
[ ] Settings persist across restarts
[ ] Property Inspector loads
[ ] Changing settings updates timer
[ ] Multiple instances work independently
[ ] Removing action stops its timer
[ ] No memory leaks (check after 1 hour)
Common Pitfalls & Debugging
Pitfall 1: Zombie Intervals
Symptom: Timer continues running after action is removed; console errors about missing context.
Cause: Forgetting to clear setInterval in onWillDisappear.
Bad:
// No cleanup!
onWillDisappear() {
// Empty or missing
}
Good:
onWillDisappear() {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
}
Debug: Add logging to tick() to see if it fires after removal.
Pitfall 2: Timer Drift
Symptom: After 25 minutes, the timer is off by 5-30 seconds.
Cause: Naively decrementing a counter instead of calculating from timestamps.
Bad:
// Drifts over time!
private tick() {
this.remainingSeconds--;
this.render();
}
Good:
private startTime: number;
private duration: number;
startTimer() {
this.startTime = Date.now();
this.duration = this.settings.workMinutes * 60 * 1000;
this.intervalId = setInterval(() => this.tick(), 1000);
}
private tick() {
const elapsed = Date.now() - this.startTime;
const remaining = Math.max(0, this.duration - elapsed);
this.remainingSeconds = Math.ceil(remaining / 1000);
this.render();
if (remaining <= 0) {
this.onTimerComplete();
}
}
Pitfall 3: Shared State Between Instances
Symptom: Two Pomodoro buttons on the deck control the same timer.
Cause: Using class-level static variables or singleton patterns.
Bad:
// WRONG: Shared across all instances!
@action({ UUID: '...' })
class PomodoroAction {
static state = 'idle'; // Static = shared
static remainingSeconds = 0;
}
Good:
// CORRECT: Instance variables
@action({ UUID: '...' })
class PomodoroAction {
private state = 'idle'; // Per-instance
private remainingSeconds = 0;
}
Alternative (for complex state):
// Store per-context
const contextState = new Map<string, TimerState>();
onKeyDown(ev) {
const state = contextState.get(ev.context) ?? defaultState;
// Use state
}
Pitfall 4: Canvas Not Installing
Symptom: npm install canvas fails with native build errors.
Cause: node-canvas requires native dependencies (Cairo, Pango).
Workarounds:
- Install system dependencies (Mac):
brew install pkg-config cairo pango libpng jpeg giflib librsvg - Use prebuilt binary:
npm install canvas --build-from-source=false - Use SVG + Sharp instead:
const sharp = require('sharp'); function renderTimer(seconds: number): Promise<string> { const svg = `<svg>...</svg>`; const buffer = await sharp(Buffer.from(svg)).png().toBuffer(); return 'data:image/png;base64,' + buffer.toString('base64'); }
Pitfall 5: Property Inspector Not Communicating
Symptom: Settings changes in PI donât affect the plugin.
Cause: Not implementing onDidReceiveSettings or wrong event handling.
Debug Steps:
- Add logging to
onDidReceiveSettings:onDidReceiveSettings(ev) { console.log('Received settings:', ev.payload.settings); } - Add logging to PI:
$SD.on('connected', () => { console.log('PI connected'); }); - Check manifest.json has correct PI path:
"Actions": [{ "PropertyInspectorPath": "pi/index.html" }]
Pitfall 6: Sound Not Playing
Symptom: No audio when timer completes.
Possible Causes:
- Wrong path: Sound file not in the right location
// Debug: log the path const soundPath = path.join(__dirname, '..', 'sounds', 'chime.mp3'); console.log('Sound path:', soundPath); console.log('Exists:', fs.existsSync(soundPath)); - File not bundled: Add to your build config
// rollup.config.js copy({ targets: [{ src: 'assets/sounds/*', dest: 'dist/sounds' }] }) - Audio library issue: Try different library
npm install afplay # Mac-specific, simpler
Pitfall 7: Long Press Not Detecting
Symptom: Long press doesnât reset timer.
Cause: Only handling onKeyDown, not onKeyUp.
Solution:
private keyDownTime: number | null = null;
onKeyDown() {
this.keyDownTime = Date.now();
}
onKeyUp() {
if (this.keyDownTime) {
const duration = Date.now() - this.keyDownTime;
if (duration > 2000) {
this.resetTimer();
}
this.keyDownTime = null;
}
}
Note: Stream Deck also fires onKeyDown repeatedly during hold. You may want to debounce.
The Interview Questions Theyâll Ask
After completing this project, you should be able to answer:
1. âWhat is the Stream Deck plugin lifecycle?â
Expected Answer Structure:
- Plugin runs as separate Node.js process
- Communicates via WebSocket with Stream Deck app
- Key events:
onWillAppear(key becomes visible),onKeyDown/onKeyUp(user interaction),onWillDisappear(cleanup) - Settings events:
onDidReceiveSettingsfor updates - Must cleanup resources (intervals, listeners) in
onWillDisappear
2. âHow do you persist state between plugin restarts?â
Expected Answer Structure:
- Use
setSettings()for per-action configuration - Use
setGlobalSettings()for plugin-wide preferences - Settings are stored in Stream Deck profile files
- Distinguish between config (persisted) and runtime state (not persisted)
- Receive saved settings in
onWillAppearviaev.payload.settings
3. âExplain how WebSocket communication works in Stream Deck plugins.â
Expected Answer Structure:
- Plugin receives port and UUID via command-line args at startup
- Opens WebSocket connection to localhost on that port
- Sends registration message with plugin UUID
- Bidirectional JSON messages for events and commands
- SDK abstracts this, but understanding helps debugging
- Each action instance has unique âcontextâ identifier
4. âHow would you test a Stream Deck plugin?â
Expected Answer Structure:
- Unit tests for pure logic (state machine, time calculations)
- Mock the SDK for action behavior tests
- Integration tests with real Stream Deck in dev mode
- Manual testing checklist for all interactions
- Use
streamdeck linkfor rapid iteration - Check for memory leaks over extended runs
5. âYour timer drifts by a few seconds over an hour. Why?â
Expected Answer Structure:
setIntervaltiming isnât guaranteed precise- JavaScript event loop can delay callback execution
- Fix: Store target end time, calculate remaining on each tick
- Use
Date.now()instead of decrementing counter - Consider
requestAnimationFramefor visual updates (in browser context)
6. âHow do you handle multiple instances of the same action?â
Expected Answer Structure:
- Each instance has unique âcontextâ string
- Store state per-context, not globally
- Avoid static/class-level variables
- Map<context, state> pattern for complex cases
- Each instance should operate independently
Hints in Layers
If you get stuck, reveal hints progressively:
Hint 1: Timer State Management (click to reveal)
Your action class needs these instance variables:
private state: 'idle' | 'running' | 'paused' | 'break' = 'idle';
private remainingSeconds: number = 0;
private intervalId: NodeJS.Timeout | null = null;
private settings: PomodoroSettings = { workMinutes: 25, breakMinutes: 5 };
The key insight: state and remainingSeconds are runtime state (lost on restart), while settings are persisted via the SDK.
When the plugin restarts, you get settings back via onWillAppear, but the timer was reset. This is usually the expected behavior.
Hint 2: Canvas Rendering for Countdown (click to reveal)
import { createCanvas } from 'canvas';
function renderTimer(minutes: number, seconds: number, state: string): string {
const canvas = createCanvas(144, 144); // 2x for retina
const ctx = canvas.getContext('2d');
// Background color based on state
const bgColors = {
idle: '#7f8c8d',
running: '#27ae60',
paused: '#f39c12',
break: '#3498db',
};
ctx.fillStyle = bgColors[state] || '#7f8c8d';
ctx.fillRect(0, 0, 144, 144);
// Time text
ctx.fillStyle = 'white';
ctx.font = 'bold 48px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const timeStr = `${minutes}:${seconds.toString().padStart(2, '0')}`;
ctx.fillText(timeStr, 72, 60);
// State label
ctx.font = '20px Arial';
ctx.fillText(state.toUpperCase(), 72, 100);
return canvas.toDataURL('image/png');
}
Call this in your render() method and pass to ev.action.setImage().
Hint 3: Settings Persistence Pattern (click to reveal)
// In your action class
interface PomodoroSettings {
workMinutes: number;
breakMinutes: number;
soundEnabled: boolean;
}
// Load settings when action appears
override onWillAppear(ev: WillAppearEvent<PomodoroSettings>): void {
this.settings = ev.payload.settings ?? {
workMinutes: 25,
breakMinutes: 5,
soundEnabled: true,
};
this.remainingSeconds = this.settings.workMinutes * 60;
this.render();
}
// Handle settings updates from Property Inspector
override onDidReceiveSettings(ev: DidReceiveSettingsEvent<PomodoroSettings>): void {
this.settings = ev.payload.settings;
// Only update time if we're idle
if (this.state === 'idle') {
this.remainingSeconds = this.settings.workMinutes * 60;
this.render();
}
}
In Property Inspector (pi/index.js):
// When user changes setting
document.getElementById('work-minutes').addEventListener('change', (e) => {
const settings = {
...currentSettings,
workMinutes: parseInt(e.target.value),
};
$SD.setSettings(settings);
});
Hint 4: Sound Notification Approach (click to reveal)
Stream Deck plugins run in Node.js, which doesnât have the browserâs Audio API. Options:
Option 1: play-sound package (recommended)
import player from 'play-sound';
function playNotificationSound(soundFile: string): void {
const soundPath = path.join(__dirname, '..', 'sounds', soundFile);
player().play(soundPath, (err) => {
if (err) console.error('Sound playback failed:', err);
});
}
Option 2: node-notifier with system sound
import notifier from 'node-notifier';
function playNotificationSound(): void {
notifier.notify({
title: 'Pomodoro Timer',
message: 'Time is up!',
sound: true, // Uses system sound
});
}
Option 3: afplay on Mac (simplest)
import { exec } from 'child_process';
function playNotificationSound(soundPath: string): void {
exec(`afplay "${soundPath}"`);
}
Hint 5: Long Press Detection (click to reveal)
private keyDownTime: number | null = null;
private readonly LONG_PRESS_MS = 2000;
override onKeyDown(ev: KeyDownEvent): void {
this.keyDownTime = Date.now();
}
override onKeyUp(ev: KeyUpEvent): void {
if (this.keyDownTime) {
const pressDuration = Date.now() - this.keyDownTime;
this.keyDownTime = null;
if (pressDuration >= this.LONG_PRESS_MS) {
// Long press: reset timer
this.resetTimer();
} else {
// Short press: toggle state
this.handleShortPress();
}
}
}
private handleShortPress(): void {
switch (this.state) {
case 'idle':
this.startTimer();
break;
case 'running':
this.pauseTimer();
break;
case 'paused':
this.resumeTimer();
break;
case 'break':
this.pauseTimer();
break;
}
}
private resetTimer(): void {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
this.state = 'idle';
this.remainingSeconds = this.settings.workMinutes * 60;
this.render();
}
Hint 6: Complete Action Class Structure (click to reveal)
import { action, KeyDownEvent, KeyUpEvent, WillAppearEvent,
WillDisappearEvent, DidReceiveSettingsEvent, SingletonAction } from "@elgato/streamdeck";
interface PomodoroSettings {
workMinutes: number;
breakMinutes: number;
soundEnabled: boolean;
}
type TimerState = 'idle' | 'running' | 'paused' | 'break';
@action({ UUID: "com.example.pomodoro.timer" })
export class PomodoroAction extends SingletonAction<PomodoroSettings> {
// State
private state: TimerState = 'idle';
private remainingSeconds: number = 25 * 60;
private intervalId: NodeJS.Timeout | null = null;
private keyDownTime: number | null = null;
private settings: PomodoroSettings = {
workMinutes: 25,
breakMinutes: 5,
soundEnabled: true,
};
// Lifecycle
override onWillAppear(ev: WillAppearEvent<PomodoroSettings>): void {
this.settings = ev.payload.settings ?? this.settings;
this.remainingSeconds = this.settings.workMinutes * 60;
this.render(ev);
}
override onWillDisappear(ev: WillDisappearEvent): void {
this.cleanup();
}
override onKeyDown(ev: KeyDownEvent): void {
this.keyDownTime = Date.now();
}
override onKeyUp(ev: KeyUpEvent): void {
const duration = Date.now() - (this.keyDownTime ?? Date.now());
this.keyDownTime = null;
if (duration >= 2000) {
this.reset(ev);
} else {
this.toggle(ev);
}
}
override onDidReceiveSettings(ev: DidReceiveSettingsEvent<PomodoroSettings>): void {
this.settings = ev.payload.settings;
if (this.state === 'idle') {
this.remainingSeconds = this.settings.workMinutes * 60;
this.render(ev);
}
}
// Timer logic
private toggle(ev: any): void {
switch (this.state) {
case 'idle': this.start(ev); break;
case 'running': this.pause(ev); break;
case 'paused': this.resume(ev); break;
case 'break': this.pause(ev); break;
}
}
private start(ev: any): void {
this.state = 'running';
this.intervalId = setInterval(() => this.tick(ev), 1000);
this.render(ev);
}
private pause(ev: any): void {
this.state = 'paused';
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
this.render(ev);
}
private resume(ev: any): void {
this.state = 'running';
this.intervalId = setInterval(() => this.tick(ev), 1000);
this.render(ev);
}
private reset(ev: any): void {
this.cleanup();
this.state = 'idle';
this.remainingSeconds = this.settings.workMinutes * 60;
this.render(ev);
}
private tick(ev: any): void {
this.remainingSeconds--;
if (this.remainingSeconds <= 0) {
this.onComplete(ev);
} else {
this.render(ev);
}
}
private onComplete(ev: any): void {
this.cleanup();
if (this.state === 'running') {
this.state = 'break';
this.remainingSeconds = this.settings.breakMinutes * 60;
this.playSound();
this.intervalId = setInterval(() => this.tick(ev), 1000);
} else {
this.state = 'idle';
this.remainingSeconds = this.settings.workMinutes * 60;
this.playSound();
}
this.render(ev);
}
private cleanup(): void {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
}
private render(ev: any): void {
const mins = Math.floor(this.remainingSeconds / 60);
const secs = this.remainingSeconds % 60;
const image = this.generateImage(mins, secs);
ev.action.setImage(image);
}
private generateImage(mins: number, secs: number): string {
// Canvas rendering code here
// Return base64 PNG
}
private playSound(): void {
if (this.settings.soundEnabled) {
// Sound playing code here
}
}
}
Extensions & Challenges
Extension 1: Multi-Timer Dashboard
Challenge: Create a dashboard view on Stream Deck that shows multiple timers at once (work timer, break timer, daily count).
Implementation Ideas:
- Create a separate action that subscribes to timer events
- Use Stream Deck profiles to create a âdashboardâ layout
- Share state between actions via global settings
Extension 2: Statistics Tracking
Challenge: Track Pomodoro statistics over time - daily sessions, weekly trends, productivity score.
Implementation Ideas:
- Store completion timestamps in a local SQLite database
- Create a âStatsâ action that shows todayâs progress
- Export data to CSV for analysis
- Sync with productivity apps via API
Extension 3: Focus Mode Integration
Challenge: When timer is running, trigger Focus/Do Not Disturb modes on macOS/Windows.
Implementation Ideas:
- Use
osascripton Mac to enable Focus - Use Windows API for Focus Assist
- Restore normal mode when timer pauses/completes
- Option to block specific apps during work periods
// Mac Focus Mode (via Shortcuts automation)
import { exec } from 'child_process';
function enableFocusMode() {
exec('shortcuts run "Start Focus"');
}
function disableFocusMode() {
exec('shortcuts run "Stop Focus"');
}
Extension 4: Team Pomodoro
Challenge: Synchronize Pomodoro timers across multiple team members for pair programming or group focus sessions.
Implementation Ideas:
- Create a simple WebSocket server for sync
- One person hosts, others join with a code
- All timers start/stop together
- Show team member status on the key
Extension 5: Habit Stacking
Challenge: Chain multiple timers together - morning routine (5 min stretch, 2 min meditation, 25 min work).
Implementation Ideas:
- Define timer sequences in settings
- Auto-advance through sequence
- Visual indication of current step
- Reset restarts sequence from beginning
Real-World Connections
The patterns you learn in this project apply far beyond Stream Deck development:
1. Hardware Display Programming
Similar Domains:
- Smart watch app development (Apple Watch, Wear OS)
- IoT device displays (ESP32 with screens)
- Kiosk applications
- Digital signage
Transferable Skills:
- Canvas rendering at small sizes
- State machine for device modes
- Efficient update cycles (power management)
2. Real-Time Communication
Similar Domains:
- WebSocket-based chat applications
- Live collaboration tools (Figma, Google Docs)
- Game networking
- Financial trading interfaces
Transferable Skills:
- Event-driven architecture
- Bidirectional message handling
- State synchronization across clients
3. Timer and Scheduling Systems
Similar Domains:
- Cron job management UIs
- Appointment scheduling apps
- Countdown/event timer websites
- Task queue monitoring
Transferable Skills:
- Accurate time tracking (drift prevention)
- State machine design
- Progress visualization
4. Settings and Configuration Systems
Similar Domains:
- Electron app preferences
- VSCode extension settings
- Mobile app configuration
- SaaS user preferences
Transferable Skills:
- Persistent vs. runtime state
- Settings UI design
- Default value handling
- Migration strategies
5. Plugin Architectures
Similar Domains:
- VSCode extensions
- Figma plugins
- Browser extensions
- WordPress plugins
- Jenkins plugins
Transferable Skills:
- Plugin lifecycle management
- Host-plugin communication
- Sandboxed execution
- API surface design
Books That Will Help
| Topic | Book | Chapter/Section | Why It Helps |
|---|---|---|---|
| State machine design | The Pragmatic Programmer | Topic 26: âHow to Balance Resourcesâ | Teaches clean state transitions and resource management. The Pomodoro timerâs four states map directly to these patterns. |
| Event-driven architecture | Node.js Design Patterns | Ch. 3: âCallbacks and Eventsâ | Stream Deck plugins are entirely event-driven. This chapter explains the pattern fundamentals. |
| Canvas rendering | HTML5 Canvas by Fulton & Fulton | Ch. 2-4 | Drawing dynamic images for the key display. Even though youâre in Node.js, the Canvas API is the same. |
| TypeScript for settings | Effective TypeScript | Items 29-37 | Type-safe configuration objects. Generic events like WillAppearEvent<T> use these patterns. |
| Timer precision | You Donât Know JS: Async & Performance | Ch. 1 | Understanding JavaScript timing guarantees and why setInterval drifts. |
| Clean code structure | Refactoring by Fowler | Ch. 10: âOrganizing Dataâ | Keeping state management clean as the project grows. |
| Node.js best practices | Node.js Design Patterns | Ch. 2: âAsynchronous Control Flowâ | Your timer callbacks and event handlers all use async patterns. |
| Desktop integration | Electron in Action | Ch. 8-9: âNative APIsâ | Though not Electron, similar patterns for OS integration (sounds, notifications). |
Self-Assessment Checklist
Before considering this project complete, verify your understanding:
Conceptual Understanding
- Can you explain the Stream Deck plugin architecture to a colleague?
- Can you describe the WebSocket communication flow between plugin and app?
- Can you list all lifecycle events and when they fire?
- Can you explain why state machines prevent bugs in this project?
- Can you describe the difference between settings and runtime state?
Implementation Skills
- Can you create a Stream Deck plugin from scratch using the CLI?
- Can you render custom images on keys using Canvas?
- Can you implement a finite state machine in TypeScript?
- Can you handle key press events (short press vs long press)?
- Can you persist and load settings correctly?
State Machine Design
- Did you implement exactly four states (idle, running, paused, break)?
- Are all state transitions explicit and documented?
- Is it impossible to be in two states simultaneously?
- Does long press reset work from any state?
- Does the state machine have no dead ends?
Timer Accuracy
- Does your timer use timestamps instead of counter decrements?
- Is timer drift less than 1 second over 25 minutes?
- Does pausing preserve the exact remaining time?
- Does the timer survive Edge cases (0 duration, 60 min)?
Visual Feedback
- Are all four states visually distinct?
- Is the countdown text readable at 72x72 pixels?
- Does the progress bar accurately reflect completion?
- Is there clear feedback when state changes?
Persistence
- Do settings survive plugin restarts?
- Do settings survive Stream Deck app restarts?
- Does changing settings update behavior appropriately?
- Is default behavior sensible when no settings exist?
Error Handling
- Is there no way to crash the plugin with key presses?
- Are intervals cleaned up when action is removed?
- Does sound playback fail gracefully?
- Are there no zombie processes after hours of use?
Code Quality
- Is state managed per-instance, not globally?
- Are there unit tests for the state machine?
- Is the code organized into logical modules?
- Could another developer understand your code?
Real-World Readiness
- Would you use this timer for actual Pomodoro sessions?
- Is the UX intuitive without reading documentation?
- Does it work with multiple instances on one deck?
- Is performance acceptable after 8+ hours of use?
The Core Question Youâve Answered
âHow do I make a Stream Deck key update its display dynamically based on internal state, while persisting user preferences across restarts?â
By building this Pomodoro timer, you have mastered:
-
Plugin Architecture: You understand how Stream Deck plugins run as separate processes, communicate via WebSocket, and integrate with the host application.
-
Event-Driven Development: You can handle lifecycle events (
onWillAppear,onKeyDown) and understand when to perform setup, action, and cleanup. -
State Machine Design: You implemented a clean four-state system that prevents impossible configurations and makes debugging straightforward.
-
Real-Time Rendering: You can generate and push dynamic images to hardware displays at appropriate intervals without performance issues.
-
Settings Persistence: You understand the difference between configuration (persisted) and runtime state (transient), and handle both correctly.
These patterns transfer directly to:
- Other Stream Deck plugins (volume controller, scene switcher, API trigger)
- Smart device programming (watches, IoT displays)
- Real-time web applications (dashboards, collaboration tools)
- Any plugin architecture (VSCode, Figma, browsers)
You now have the foundation to build any Stream Deck plugin. The next project will layer additional complexity on these same patterns.
Project Guide Version 1.0 - December 2025