A side pane for ESPHome in Claude Code: a map of the device from its YAML (board, network, buses, entities, pins, triggers) with what a save changed lit up…


A side pane for the ESPHome device Claude is working on. Claude reads and edits YAML and runs builds that take minutes, and its tools answer only at the end; this pane shows the device and the build as they are:
on press → light.toggle), and which pin does what. A pin used twice, or a strapping pin the chip boots from, is flagged. After a save, what it added, changed or removed is lit for a few seconds. Passwords and keys in the YAML are never shown.The pane only reads. It never starts a build: Claude does, with its own tool calls, which you approve as usual.
1. Add the Vome app to Home Assistant, then get a key from it. No sign-up needed.
The button opens your own Home Assistant and adds the app's repository. Pick Vome and Install, open it, and on the Agent tab create a key. The same Home Assistant needs the ESPHome Device Builder app: that is what compiles and flashes, and Vome reaches it for Claude with no ports opened.
2. Paste these into Claude Code, in a terminal (version 2.1.275 or newer):
/plugin install vome-connect --marketplace Vortitron/home-assistant-mcp
It asks for the key from step 1. Then:
/plugin install vome-esphome --marketplace Vortitron/home-assistant-mcp
/reload-plugins
Or all four Vome panes at once (automations, ESPHome, health and a working dashboard): /plugin install vome-panes --marketplace Vortitron/home-assistant-mcp.
3. Ask Claude about a device, for example "Add a temperature sensor on GPIO4 to the hallway node and flash it". The pane opens beside the conversation with the device's map, and the build when it starts.
In auto mode, the pane asks you to allow its two read-only tools the first time; it shows the exact lines and where they go.
The pane reads in the background with two of the MCP's read-only tools: the build's progress (esphome_activity) and, when Claude builds a device it has not read, its YAML for the map (esphome_get_config). In auto mode, Claude Code refuses background calls that no request of yours is behind, unless they're allowed by name; the pane then shows the exact lines to add and where. Under your server's name, for example:
{ "permissions": { "allow": ["mcp__vome__esphome_activity", "mcp__vome__esphome_get_config"] } }
With vome-connect the server is plugin_vome-connect_vome, so the lines are mcp__plugin_vome-connect_vome__esphome_activity and mcp__plugin_vome-connect_vome__esphome_get_config. Without them, the pane still shows the elapsed time, the map of any config Claude reads, and the output once the build finishes.
/esphome | open the pane |
| l | the whole build log, or back to the map and the latest lines |
| b | the chip animation off or on (remembered) |
If another pane is in front, the ESPHome tab's title says how the build is going (ESPHome · 45%, ESPHome ✓).
The pane reads only from your own MCP server, through the connection Claude Code already has: what the build commands Claude ran have printed, and the YAML of the device being built. Nothing is sent anywhere else.
hooks/register.tsx 734 lines1// A side pane for the ESPHome build Claude is running.
2//
3// A compile or a flash takes minutes and its tool returns all of its output
4// only at the end, so there was nothing to watch while it ran. This pane opens
5// when Claude starts one, reads the build's output as it arrives (the MCP's
6// esphome_activity, read-only), and shows the phase, a progress bar where the
7// output gives one, the latest lines with errors picked out, and the result.
8// With an MCP that predates esphome_activity it still shows the elapsed time
9// and then the output once the build returns. It never runs a build itself.
10//
11// Under the build it draws a map of the device from its YAML (board, network,
12// buses, entities, pins, triggers), whenever Claude reads or writes a config or
13// builds one, with what a save changed lit for a few seconds. The build log is
14// two lines by default; l shows all of it.
15
16import type { EngineInterface, Register } from 'claude-code'
17
18import type { Build, ConfigView, Device } from '../types'
19import { bar, elapsed, kindOf, phaseWords, progressOf, toLines } from './build'
20import { chipFrame, STRIP_ROWS } from './chip'
21import type { ChipState, Scene, Thing } from './chip'
22import { diffMaps, mapDevice, rowKey } from './config'
23import type { MapRow } from './config'
24import type { LineKind } from './build'
25
26const PANE = 'vome-esphome'
27const POLL_MS = 1500
28const KEEP_LINES = 300
29const BUILD_COMMANDS: Record<string, string> = {
30 esphome_validate: 'validate',
31 esphome_compile: 'compile',
32 esphome_upload: 'upload',
33 esphome_logs: 'logs',
34}
35const README_URL = 'https://github.com/Vortitron/home-assistant-mcp/tree/main/claude-plugin/vome-esphome'
36
37const build = { plugin: 'vome-esphome', key: 'build' } as const
38const devices = { plugin: 'vome-esphome', key: 'devices' } as const
39const blocked = { plugin: 'vome-esphome', key: 'blocked' } as const
40const fxOn = { plugin: 'vome-esphome', key: 'fxOn' } as const
41const config = { plugin: 'vome-esphome', key: 'config' } as const
42const showLog = { plugin: 'vome-esphome', key: 'showLog' } as const
43const sibling = { plugin: 'vome-esphome', key: 'sibling' } as const
44const AUTOMATION_INSTALL = '/plugin install vome-automation --marketplace Vortitron/home-assistant-mcp'
45/** vome-automation joined the chain after this plugin, so its hint is not needed (it cannot see one loaded before it). */
46let hasSibling = false
47/** How long a save's changes stay lit in the map. */
48const CHANGE_MS = 8_000
49const LOG_LINES = 2
50/** The strip repaints this often while a build runs, and every few frames at rest. */
51const FRAME_MS = 80
52const IDLE_EVERY = 3
53
54// Module variables reset on a reload, which is all these need.
55let isPolling = false
56/** The MCP has no esphome_activity: stop asking until the next build. */
57let isUnsupported = false
58let settingsFile = '~/.claude/settings.json'
59/** Where the strip is mounted and what it shows, for the frame timer. */
60let strip: { columns: number; state: ChipState } | null = null
61let frame = 0
62let isFxOn = true
63let isBlitting = false
64let shownTitle = 'ESPHome'
65/** The configuration whose YAML the poll has asked for, once a build. */
66let configAsked: string | null = null
67
68export const register: Register = on => {
69 on('plugin.register', ($, e, next) => {
70 if (e.name === 'vome-automation') hasSibling = true
71 return next(e)
72 })
73
74 on('session.start', async ($, e, next) => {
75 await $.command.register({ name: 'esphome', description: 'Show the ESPHome build pane' })
76 const configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('HOME')) ?? '~'}/.claude`
77 settingsFile = `${configDir.replace(/\/+$/, '')}/settings.json`
78 $.clock.every(POLL_MS, () => void poll($))
79 isFxOn = (await $.store.get('fxOn')) !== false
80 await $.state.set(fxOn, isFxOn)
81 $.clock.every(FRAME_MS, () => void animate($))
82
83 return next(e)
84 })
85
86 on('command.run', { command: 'esphome' }, async $ => {
87 const opened = await $.ui.open({ id: PANE, title: 'ESPHome' })
88 return { text: opened.isPlaced ? 'ESPHome pane open.' : `The ESPHome pane is not drawn here: ${opened.reason}.` }
89 })
90
91 // The MCP's ESPHome tools, on whatever its server is called (tool matchers must be literals).
92 on('tool.call', async ($, e, next) => {
93 const match = /^mcp__(.+)__(esphome_[a-z_]+)$/.exec(e.tool)
94 // Claude is on an automation: say once that the automation pane shows it, unless it is here or was waved off.
95 if (!match && !hasSibling && /^mcp__.+__ha_(get|set|list|trigger)_automations?$|^mcp__.+__ha_(get|list)_traces?$/.test(e.tool)) {
96 if (!(await $.state.get(sibling)).value && !(await isHintOff($))) await $.state.set(sibling, true)
97 }
98 if (!match?.[1] || !match[2]) return next(e)
99 const server = match[1]
100 const name = match[2]
101 const args = e as unknown as { configuration?: unknown }
102
103 if (name === 'esphome_get_config' && typeof args.configuration === 'string') {
104 const ran = await next(e)
105 if (ran.deny === undefined && !ran.isError && ran.text) await showConfig($, args.configuration, stripStamp(ran.text))
106 return ran
107 }
108 if (name === 'esphome_save_config' && typeof args.configuration === 'string') {
109 const yaml = (e as unknown as { yaml?: unknown }).yaml
110 const ran = await next(e)
111 if (ran.deny === undefined && !ran.isError && typeof yaml === 'string') await showConfig($, args.configuration, yaml)
112 return ran
113 }
114
115 if (name === 'esphome_edit_config' && typeof args.configuration === 'string') {
116 // The edits carry only the changed lines: read the file back for the map, which marks what changed.
117 const ran = await next(e)
118 if (ran.deny === undefined && !ran.isError) await loadConfig($, server, args.configuration)
119 return ran
120 }
121
122 if (name === 'esphome_list_devices') {
123 const ran = await next(e)
124 if (ran.deny === undefined && !ran.isError) {
125 const list = parseDevices(ran.text ?? '')
126 if (list) await $.state.set(devices, list)
127 }
128 return ran
129 }
130
131 const command = BUILD_COMMANDS[name]
132 if (!command || typeof args.configuration !== 'string') return next(e)
133
134 const started: Build = {
135 server,
136 command,
137 configuration: args.configuration,
138 startedAt: Date.now(),
139 finishedAt: null,
140 lines: [],
141 seq: 0,
142 isLive: false,
143 outcome: 'running',
144 error: null,
145 }
146 isUnsupported = false
147 await $.state.set(build, started)
148 await $.ui.open({ id: PANE, title: 'ESPHome' }).catch(() => undefined)
149 shownTitle = 'ESPHome'
150 configAsked = null
151
152 const ran = await next(e)
153 const current = (await $.state.get(build)).value ?? started
154 const body = parseJson(ran.text ?? '')
155 const output = body && typeof body.output === 'string' ? body.output : ''
156 // Lines that arrived live stay; otherwise the full output is the first the pane sees of it.
157 const lines = current.isLive && current.lines.length > 0 ? current.lines : toLines([output]).slice(-KEEP_LINES)
158 const isOk =
159 ran.deny === undefined && !ran.isError && body !== null && (body.success === true || (command === 'logs' && body.stopped === 'timeout'))
160 // Claude Code moved the call to the background (after 120 s) or gave up waiting on it (a silent
161 // 300 s), but the build carries on at home: keep following it, and let the job say how it ended.
162 const said = ran.text ?? ran.deny ?? ''
163 const isBackground = !isOk && /moved to the background|still running after/i.test(said)
164 const isAbandoned = !isOk && (isBackground || (!isUnsupported && /no response or progress|aborted|cancel|timed? ?out/i.test(said)))
165 if (isAbandoned) {
166 await $.state.set(build, {
167 ...current,
168 error: isBackground ? 'Running in the background; it shows here as it goes.' : 'Claude stopped waiting; the build carries on at home and shows here.',
169 })
170 // A read refused earlier may be allowed by now: try again.
171 isUnsupported = false
172 return ran
173 }
174 await $.state.set(build, {
175 ...current,
176 lines,
177 finishedAt: Date.now(),
178 outcome: isOk ? 'ok' : 'failed',
179 error: isOk ? null : firstLine(ran.deny ?? (body === null ? ran.text : null) ?? null),
180 })
181
182 return ran
183 })
184
185 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
186 const { Box, Text, Button, Code, Markdown } = $.ui.resolve(e)
187 const current = (await $.state.get(build)).value ?? null
188 const known = (await $.state.get(devices)).value ?? null
189 const refusal = (await $.state.get(blocked)).value ?? null
190 const isFxEnabled = (await $.state.get(fxOn)).value ?? true
191 const view = (await $.state.get(config)).value ?? null
192 const isFullLog = (await $.state.get(showLog)).value ?? false
193 const siblingHint =
194 !hasSibling && ((await $.state.get(sibling)).value ?? false) ? (
195 <Box flexDirection="row" gap={2} marginTop={1}>
196 <Text dimColor wrap="wrap">
197 Working on automations too? The automation pane shows them, and their runs: <Text color="suggestion">{AUTOMATION_INSTALL}</Text>
198 </Text>
199 <Button
200 key="hide-sibling"
201 dimColor
202 onPress={async () => {
203 await $.store.set('siblingHintOff', true).catch(() => undefined)
204 await $.state.set(sibling, false)
205 }}
206 >
207 Hide
208 </Button>
209 </Box>
210 ) : null
211 const width = Math.max(20, e.props.bodyColumns)
212 const rows = Math.max(6, (e.viewport?.rows ?? 30) - 14)
213
214 const deviceRows = known && known.length > 0 ? (
215 <Box flexDirection="column" marginTop={1}>
216 <Text bold>Devices</Text>
217 {known.map(d => {
218 const isBehind = d.deployed !== null && d.current !== null && d.deployed !== d.current
219 return (
220 <Text wrap="truncate-end">
221 <Text color={isBehind ? 'warning' : undefined}>{isBehind ? '↑ ' : ' '}</Text>
222 {d.name}
223 <Text dimColor>
224 {' '}
225 {d.configuration}
226 {d.deployed ? ` ${d.deployed}` : ''}
227 {isBehind ? ` → ${d.current} available` : ''}
228 </Text>
229 </Text>
230 )
231 })}
232 </Box>
233 ) : null
234
235 // Auto mode refuses a background read nobody asked for unless it is allowed by name.
236 const help = refusal ? (
237 <Box flexDirection="column" borderStyle="round" borderColor="warning" paddingX={1} marginTop={1}>
238 <Text color="warning" wrap="wrap">
239 Auto mode refused the pane's read of the build's progress or the device's YAML. Add these to permissions.allow
240 in {settingsFile}, then the next build shows live:
241 </Text>
242 <Markdown text={`[${settingsFile}](file://${settingsFile})`} />
243 <Code source={allowLines(refusal.server)} />
244 <Box flexDirection="row" gap={2}>
245 <Button key="copy-rule" hotkey="c" onPress={press => $.ui.copy({ text: allowLines(refusal.server), surface: press.surface })}>
246 Copy the lines
247 </Button>
248 <Button
249 key="retry"
250 hotkey="r"
251 onPress={async () => {
252 // Allowed since? Try the reads again now rather than at the next build.
253 await $.state.set(blocked, null)
254 isUnsupported = false
255 configAsked = null
256 const running = (await $.state.get(build)).value ?? null
257 if (running && running.command !== 'logs') void loadConfig($, refusal.server, running.configuration)
258 void poll($)
259 }}
260 >
261 Retry
262 </Button>
263 </Box>
264 </Box>
265 ) : null
266
267 const map = view ? deviceMap($.ui.resolve(e), view) : null
268 const isStage = e.surface === 'terminal' && isFxEnabled
269 const things = view ? thingsOf(view) : []
270 const stage = (state: Omit<ChipState, 'things' | 'hasWifi'>) => {
271 if (e.surface !== 'terminal') return null
272 const { Raster } = $.ui.resolve(e)
273 const columns = Math.min(512, width)
274 strip = { columns, state: { ...state, things, hasWifi: !!view?.rows.some(row => row.section === 'Network' && row.label === 'wifi') } }
275 return <Raster key="chip" columns={columns} rows={STRIP_ROWS} cells={chipFrame(Date.now(), strip.state, columns)} />
276 }
277
278 if (!current) {
279 if (!isStage) strip = null
280 return (
281 <Box flexDirection="column">
282 {isStage
283 ? stage({
284 outcome: 'idle',
285 scene: 'idle',
286 percent: null,
287 words: view ? `${view.name} · ${things.length || 'no'} thing${things.length === 1 ? '' : 's'} attached` : 'dreaming of devices…',
288 endedAt: null,
289 })
290 : null}
291 {map ?? (
292 <Text dimColor wrap="wrap">
293 Nothing building. When Claude reads, edits or builds an ESPHome device, its map shows here, and a build's
294 progress as it runs.
295 </Text>
296 )}
297 {deviceRows}
298 {help}
299 {siblingHint}
300 </Box>
301 )
302 }
303
304 const parsed = progressOf(current.lines, current.command)
305 const progress = { ...parsed, uploadPercent: uploadShown(parsed, current) }
306 const took = elapsed((current.finishedAt ?? Date.now()) - current.startedAt)
307 const verb = { validate: 'Checking', compile: 'Compiling', upload: 'Flashing', logs: 'Logs from' }[current.command] ?? current.command
308 const done = { validate: 'Valid', compile: 'Compiled', upload: 'Flashed', logs: 'Read the logs' }[current.command] ?? 'Done'
309 const status =
310 current.outcome === 'running'
311 ? `${phaseWords(progress, current.command)} · ${took}`
312 : current.outcome === 'ok'
313 ? `✓ ${done} in ${took}`
314 : `✗ Failed after ${took}`
315 const isUploading = current.outcome === 'running' && progress.uploadPercent !== null
316 const isCompiling = current.outcome === 'running' && !isUploading && progress.compilePercent !== null
317 const kept = current.lines.filter(line => current.command === 'logs' || kindOf(line) !== 'debug')
318 // The latest couple of lines unless asked for all; a failure keeps a few more, where it explains itself.
319 const tail = isFullLog || current.command === 'logs' ? kept.slice(-rows) : kept.slice(current.outcome === 'failed' ? -6 : -LOG_LINES)
320
321 // The chip on the bench: a Raster, which only the terminal draws. Not for a logs read.
322 let chip = null
323 if (isStage) {
324 // The scene follows the build, not the command: a flash compiles first, then uploads.
325 const isUploadPhase = current.command === 'upload' && (progress.uploadPercent !== null || progress.phase === 'uploading')
326 const scene: Scene =
327 current.command === 'logs' ? 'logs' : current.command === 'validate' ? 'validate' : isUploadPhase ? 'upload' : 'compile'
328 const words =
329 current.outcome === 'running'
330 ? `${scene === 'upload' ? 'flashing' : scene === 'validate' ? 'checking' : scene === 'logs' ? 'listening to' : 'compiling'} ${current.configuration}${(scene === 'upload' ? progress.uploadPercent : progress.compilePercent) !== null && scene !== 'logs' ? ` · ${scene === 'upload' ? progress.uploadPercent : progress.compilePercent}%` : ''}`
331 : current.outcome === 'ok'
332 ? `✓ ${current.configuration} ${done.toLowerCase()}`
333 : `✗ ${current.configuration} failed`
334 chip = stage({
335 outcome: current.outcome,
336 scene,
337 percent: scene === 'upload' ? progress.uploadPercent : progress.compilePercent,
338 words,
339 endedAt: current.finishedAt,
340 })
341 } else {
342 strip = null
343 }
344
345 return (
346 <Box flexDirection="column">
347 {chip}
348 <Text bold wrap="truncate-end">
349 {verb} {current.configuration}
350 </Text>
351 <Text color={current.outcome === 'ok' ? 'success' : current.outcome === 'failed' ? 'error' : 'warning'} wrap="truncate-end">
352 {status}
353 </Text>
354 {isUploading ? (
355 <Text color="suggestion">
356 {bar(progress.uploadPercent ?? 0, Math.min(40, width - 8))} {progress.uploadPercent}%
357 </Text>
358 ) : isCompiling ? (
359 <Text color="suggestion">
360 {bar(progress.compilePercent ?? 0, Math.min(40, width - 8))} {progress.compilePercent}%
361 </Text>
362 ) : current.outcome === 'running' ? (
363 <Text color="suggestion">{busy(Date.now(), Math.min(40, width - 8))}</Text>
364 ) : null}
365 <Text dimColor wrap="truncate-end">
366 {[
367 progress.ram ? `RAM ${progress.ram}` : null,
368 progress.flash ? `Flash ${progress.flash}` : null,
369 progress.warnings ? `${progress.warnings} warning${progress.warnings === 1 ? '' : 's'}` : null,
370 current.outcome === 'running' && !current.isLive ? 'output arrives when it finishes' : null,
371 ]
372 .filter(Boolean)
373 .join(' · ') || ' '}
374 </Text>
375 {current.outcome === 'failed' && (progress.errors[0] || current.error) ? (
376 <Text color="error" wrap="wrap">
377 {progress.errors[0] ?? current.error}
378 </Text>
379 ) : null}
380 <Box flexDirection="column">
381 {tail.map(line => (
382 <Text wrap="truncate-end" color={lineColour(kindOf(line))} dimColor={kindOf(line) === 'plain' || kindOf(line) === 'debug'}>
383 {line}
384 </Text>
385 ))}
386 </Box>
387 {isFullLog ? null : map}
388 {deviceRows}
389 {help}
390 {siblingHint}
391 <Box marginTop={1} gap={2}>
392 <Button key="log" hotkey="l" dimColor onPress={() => $.state.set(showLog, !isFullLog)}>
393 {isFullLog ? 'Map and latest lines' : 'Full log'}
394 </Button>
395 <Button
396 key="fx"
397 hotkey="b"
398 dimColor
399 onPress={async () => {
400 isFxOn = !isFxEnabled
401 if (!isFxOn) strip = null
402 await $.store.set('fxOn', isFxOn)
403 await $.state.set(fxOn, isFxOn)
404 }}
405 >
406 {isFxEnabled ? 'Animation off' : 'Animation on'}
407 </Button>
408 </Box>
409 </Box>
410 )
411 })
412}
413
414/** Repaints the chip while the build runs and briefly after, without redrawing the pane. */
415async function animate($: EngineInterface) {
416 if (!isFxOn || !strip || isBlitting) return
417 // At rest (idle, or a finished build that has settled), a gentler frame rate.
418 const ended = strip.state.endedAt
419 const isResting = strip.state.outcome === 'idle' || (ended !== null && Date.now() - ended > 5_000)
420 frame = (frame + 1) % IDLE_EVERY
421 if (isResting && frame !== 0) return
422 isBlitting = true
423 try {
424 const result = await $.ui.blit({ requestId: PANE, key: 'chip', cells: chipFrame(Date.now(), strip.state, strip.columns) })
425 if (result.deny !== undefined) strip = null
426 } finally {
427 isBlitting = false
428 }
429}
430
431// ---------------------------------------------------------------- the device map
432
433function deviceMap(ui: ReturnType<EngineInterface['ui']['resolve']>, view: ConfigView) {
434 const { Box, Text } = ui
435 const isFresh = view.changedAt !== null && Date.now() - view.changedAt < CHANGE_MS
436 const rows: MapRow[] = [...view.rows]
437 if (isFresh) {
438 // A removed row stays a moment where it was, struck out, after the rest of its section.
439 for (const gone of view.removed) {
440 const last = rows.map(r => r.section).lastIndexOf(gone.section)
441 rows.splice(last < 0 ? rows.length : last + 1, 0, gone)
442 }
443 }
444 let section = ''
445 return (
446 <Box flexDirection="column" marginTop={1}>
447 <Text bold wrap="truncate-end">
448 {view.name}
449 <Text dimColor>
450 {' '}
451 {view.chip}
452 {view.chip ? ' · ' : ''}
453 {view.configuration}
454 </Text>
455 </Text>
456 {rows.map(row => {
457 const change = isFresh ? view.changes[rowKey(row)] : undefined
458 const heading = row.section !== section ? (section = row.section) : null
459 const mark = change === 'added' ? '+' : change === 'changed' ? '~' : change === 'removed' ? '-' : ' '
460 const colour = change === 'removed' ? 'error' : change ? 'suggestion' : undefined
461 return (
462 <Box flexDirection="column">
463 {heading ? (
464 <Text color="subtle" wrap="truncate-end">
465 {heading}
466 </Text>
467 ) : null}
468 <Text wrap="truncate-end" color={colour} strikethrough={change === 'removed'}>
469 {mark}
470 {' '.repeat(row.depth + 1)}
471 <Text color={colour ?? (row.icon === '!' ? 'warning' : 'suggestion')}>{row.icon}</Text> {row.label}
472 <Text dimColor>
473 {row.detail ? ' ' : ''}
474 {row.detail}
475 </Text>
476 </Text>
477 </Box>
478 )
479 })}
480 </Box>
481 )
482}
483
484/** What to draw wired to the chip at rest: the device's things, from its map, six at most. */
485function thingsOf(view: ConfigView): Thing[] {
486 const things: Thing[] = []
487 for (const row of view.rows) {
488 if (row.icon === '⚡') continue
489 const words = `${row.label} ${row.detail}`.toLowerCase()
490 const kind: Thing | null =
491 row.section === 'Lights' ? 'light'
492 : row.section === 'Sensors' ? (/humid|moist|water/.test(words) ? 'droplet' : /batt|adc|volt/.test(words) ? 'battery' : row.depth === 0 && view.rows.some(r => r.depth === 1 && r.section === 'Sensors' && view.rows.indexOf(r) === view.rows.indexOf(row) + 1) ? null : 'thermometer')
493 : row.section === 'Binary sensors' || row.section === 'Buttons' ? 'button'
494 : row.section === 'Switches' ? 'toggle'
495 : row.section === 'Fans' ? 'fan'
496 : row.section === 'Displays' ? 'screen'
497 : row.section === 'Climate' ? 'thermometer'
498 : ['Covers', 'Locks', 'Valves', 'Numbers', 'Selects', 'Media', 'Infrared and RF'].includes(row.section) ? 'gadget'
499 : null
500 if (kind) things.push(kind)
501 }
502 return things.slice(0, 6)
503}
504
505/** Maps the YAML and, against the last map of the same file, marks what changed. */
506async function showConfig($: EngineInterface, configuration: string, yaml: string) {
507 let device
508 try {
509 device = mapDevice(yaml)
510 } catch {
511 return
512 }
513 const before = (await $.state.get(config)).value ?? null
514 const isSame = before?.configuration === configuration
515 const diff = isSame ? diffMaps(before.rows, device.rows) : { changes: {}, removed: [] }
516 const hasChanges = Object.keys(diff.changes).length > 0
517 await $.state.set(config, {
518 configuration,
519 name: device.name,
520 chip: device.chip,
521 rows: device.rows,
522 changes: hasChanges ? diff.changes : isSame ? before.changes : {},
523 removed: hasChanges ? diff.removed : isSame ? before.removed : [],
524 changedAt: hasChanges ? Date.now() : isSame ? before.changedAt : null,
525 })
526 await $.ui.open({ id: PANE, title: shownTitle }).catch(() => undefined)
527}
528
529/** Reads the YAML of what is being built, for the map; a refusal shows the line to allow. */
530async function loadConfig($: EngineInterface, server: string, configuration: string) {
531 try {
532 const result = await $.mcp.call(server, 'esphome_get_config', { configuration })
533 if (result.isError) return
534 await showConfig($, configuration, stripStamp(result.content.map(block => block.text ?? '').join('\n')))
535 } catch (error) {
536 if (/auto mode|classifier|permission|denied|not allowed/i.test(String(error))) {
537 await $.state.set(blocked, { server, error: String(error) })
538 }
539 }
540}
541
542function allowLines(server: string): string {
543 return `"mcp__${server}__esphome_activity",\n"mcp__${server}__esphome_get_config"`
544}
545
546function stripStamp(text: string): string {
547 return text.replace(/\n?\[vome-instance\][^\n]*/g, '')
548}
549
550/** The pane's tab says how the build is going, since another pane may be in front of it. */
551async function retitle($: EngineInterface, current: Build) {
552 const parsed = progressOf(current.lines, current.command)
553 const progress = { ...parsed, uploadPercent: uploadShown(parsed, current) }
554 const percent = progress.uploadPercent ?? progress.compilePercent
555 const title =
556 current.outcome === 'ok'
557 ? 'ESPHome ✓'
558 : current.outcome === 'failed'
559 ? 'ESPHome ✗'
560 : `ESPHome · ${percent !== null ? `${Math.floor(percent / 5) * 5}%` : '…'}`
561 if (title === shownTitle) return
562 shownTitle = title
563 try {
564 // Only a pane that is up: one the person closed stays closed.
565 if (!(await $.ui.panes()).some(pane => pane.id === PANE)) return
566 await $.ui.open({ id: PANE, title })
567 } catch {
568 // A surface without panes: the title is a nicety.
569 }
570}
571
572// ---------------------------------------------------------------- polling
573
574/** While a build runs: its new output from esphome_activity, and a redraw for the clock. */
575async function poll($: EngineInterface) {
576 if (isPolling) return
577 const view = (await $.state.get(config)).value ?? null
578 if (view?.changedAt && Date.now() - view.changedAt < CHANGE_MS + POLL_MS) $.ui.invalidate('ui.render')
579 const current = (await $.state.get(build)).value ?? null
580 if (current && current.finishedAt && Date.now() - current.finishedAt < POLL_MS * 2) await retitle($, current)
581 if (!current || current.outcome !== 'running') return
582 // The map of what is being built, read alongside when Claude has not read it this session.
583 // (Here, not in the tool hook: a state read there before the call pins its view of the build.)
584 if (configAsked !== current.configuration && current.command !== 'logs') {
585 configAsked = current.configuration
586 if (view?.configuration !== current.configuration) void loadConfig($, current.server, current.configuration)
587 }
588 isPolling = true
589 try {
590 if (isUnsupported) {
591 $.ui.invalidate('ui.render')
592 return
593 }
594 let text = ''
595 try {
596 const result = await $.mcp.call(current.server, 'esphome_activity', { since: current.seq })
597 text = result.content.map(block => block.text ?? '').join('\n')
598 if (result.isError) {
599 isUnsupported = true
600 await giveUp($)
601 return
602 }
603 } catch (error) {
604 if (/auto mode|classifier|permission|denied|not allowed/i.test(String(error))) {
605 await $.state.set(blocked, { server: current.server, error: String(error) })
606 }
607 isUnsupported = true
608 await giveUp($)
609 return
610 }
611 const body = parseJson(text)
612 const jobs = body && Array.isArray(body.jobs) ? (body.jobs as Record<string, unknown>[]) : []
613 // This build's jobs: same configuration, started no earlier than the call. A flash is two
614 // (the MCP compiles, then uploads), followed as one build.
615 const commands = current.command === 'upload' ? ['compile', 'upload'] : [current.command]
616 const mine = jobs
617 .filter(j => commands.includes(String(j.command)) && j.configuration === current.configuration)
618 .filter(j => Date.parse(String(j.started)) >= current.startedAt - 5_000)
619 const fresh = mine.flatMap(j => (Array.isArray(j.lines) ? toLines(j.lines.map(String)) : []))
620 // The job that says how it ended: the last one, once it is the build's own command or has failed.
621 const last = mine.at(-1)
622 const job = last && (last.command === current.command || (last.done === true && last.exit_code !== 0)) ? last : undefined
623 const seq = typeof body?.seq === 'number' ? body.seq : current.seq
624 const latest = (await $.state.get(build)).value ?? current
625 if (latest.outcome !== 'running') return
626 // The job finished: the result comes from it when the tool call was abandoned before it.
627 if (job && job.done === true && latest.error) {
628 await $.state.set(build, {
629 ...latest,
630 lines: [...latest.lines, ...fresh].slice(-KEEP_LINES),
631 seq,
632 finishedAt: Date.now(),
633 outcome: job.exit_code === 0 ? 'ok' : 'failed',
634 error: job.exit_code === 0 ? null : typeof job.error === 'string' ? job.error : `exit code ${String(job.exit_code)}`,
635 })
636 return
637 }
638 await retitle($, { ...latest, lines: [...latest.lines, ...fresh] })
639 const isUploadStage = !latest.uploadSeenAt && progressOf([...latest.lines, ...fresh], latest.command).phase === 'uploading'
640 if (fresh.length > 0 || seq !== latest.seq || isUploadStage) {
641 await $.state.set(build, {
642 ...latest,
643 uploadSeenAt: latest.uploadSeenAt ?? (isUploadStage ? Date.now() : null),
644 lines: [...latest.lines, ...fresh].slice(-KEEP_LINES),
645 seq,
646 isLive: latest.isLive || fresh.length > 0,
647 })
648 if ((await $.state.get(blocked)).value) await $.state.set(blocked, null)
649 } else {
650 $.ui.invalidate('ui.render')
651 }
652 } finally {
653 isPolling = false
654 }
655}
656
657/** A call Claude abandoned, with no way to follow the job: say so rather than spin for ever. */
658async function giveUp($: EngineInterface) {
659 const latest = (await $.state.get(build)).value ?? null
660 if (latest?.outcome === 'running' && latest.error) await $.state.set(build, { ...latest, finishedAt: Date.now(), outcome: 'failed' })
661}
662
663/**
664 * The upload's percentage as shown. ESPHome's OTA redraws its bar with carriage returns and the
665 * stream delivers only the last state, "100% Done", at the end, so the chip used to fill in one
666 * jump. A real 1-99% wins; otherwise pace it from the firmware's size at about 90 KB/s (GamlaBio's
667 * LoftC3: 1.37 MB in 14.9 s), holding at 97% until the device says it is done.
668 */
669function uploadShown(progress: ReturnType<typeof progressOf>, current: Build): number | null {
670 if (progress.phase !== 'uploading' && progress.uploadPercent === null) return null
671 const real = progress.uploadPercent
672 if (real !== null && (real >= 100 || real > 0)) return real
673 if (current.outcome !== 'running') return current.outcome === 'ok' ? 100 : real
674 const since = current.uploadSeenAt ? Date.now() - current.uploadSeenAt : 0
675 const expected = progress.uploadBytes ? (progress.uploadBytes / 90_000) * 1000 + 1500 : 15_000
676 return Math.min(97, Math.round((since / expected) * 100))
677}
678
679// ---------------------------------------------------------------- small things
680
681function lineColour(kind: LineKind): string | undefined {
682 return kind === 'error' ? 'error' : kind === 'warning' ? 'warning' : undefined
683}
684
685/** A bar that moves while there is no percentage to show. */
686function busy(now: number, width: number): string {
687 const at = Math.floor(now / 400) % (width + 6)
688 return Array.from({ length: width }, (_, i) => (i >= at - 6 && i < at ? '▰' : '▱')).join('')
689}
690
691function firstLine(text: string | null): string | null {
692 return text ? (text.split('\n').find(line => line.trim()) ?? null) : null
693}
694
695/** The JSON body of a vome MCP reply, which ends with a `[vome-instance]` line. */
696function parseJson(raw: string): Record<string, unknown> | null {
697 // Drop the stamp first: its own brackets would otherwise end the slice below inside it.
698 const text = raw.replace(/\[vome-instance\][^\n]*/g, '')
699 const start = text.search(/[[{]/)
700 const end = Math.max(text.lastIndexOf('}'), text.lastIndexOf(']'))
701 if (start < 0 || end <= start) return null
702 try {
703 const value = JSON.parse(text.slice(start, end + 1)) as unknown
704 return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : { list: value }
705 } catch {
706 return null
707 }
708}
709
710/** esphome_list_devices: the dashboard's own model, `{configured: [...]}` or a bare list, tolerated either way. */
711function parseDevices(text: string): Device[] | null {
712 const body = parseJson(text)
713 if (!body) return null
714 const holder = (body.devices && typeof body.devices === 'object' ? body.devices : body) as Record<string, unknown>
715 const rows = Array.isArray(holder.configured) ? holder.configured : Array.isArray(holder.list) ? holder.list : Array.isArray(holder) ? holder : []
716 return (rows as Record<string, unknown>[])
717 .filter(row => row && typeof row === 'object')
718 .map(row => ({
719 name: String(row.friendly_name ?? row.name ?? row.configuration ?? '?'),
720 configuration: String(row.configuration ?? ''),
721 deployed: typeof row.deployed_version === 'string' ? row.deployed_version : null,
722 current: typeof row.current_version === 'string' ? row.current_version : null,
723 }))
724}
725
726/** Whether the person waved the other pane's hint off; a store that cannot answer means no. */
727async function isHintOff($: EngineInterface): Promise<boolean> {
728 try {
729 return (await $.store.get('siblingHintOff')) === true
730 } catch {
731 return false
732 }
733}
734hooks/build.ts 144 lines1// Reads ESPHome's build and log output into what the pane shows: the phase a
2// build is in, how far through it is where that is knowable, the errors and
3// warnings, and each line's kind. Pure: no `$` here.
4//
5// ESPHome drives PlatformIO, whose output has a recognisable shape: one
6// "Compiling <file>.o" per source file, then "Linking", then the firmware image,
7// RAM and Flash usage, and for a flash an "Uploading: [==== ] 45%" bar that
8// redraws itself with carriage returns.
9
10export type Phase = 'starting' | 'config' | 'setup' | 'compiling' | 'linking' | 'image' | 'uploading' | 'done' | 'failed'
11
12export type LineKind = 'error' | 'warning' | 'info' | 'debug' | 'plain'
13
14export type Progress = {
15 phase: Phase
16 /** Source files compiled so far. */
17 compiled: number
18 /** ESP-IDF's ninja counts its build steps ("[437/972] Building C object ..."), so a percentage is known. */
19 compilePercent: number | null
20 /** Upload progress, 0 to 100, once uploading. */
21 uploadPercent: number | null
22 /** The firmware's size, from "Uploading <file> (N bytes)": paces the bar when OTA reports only its end. */
23 uploadBytes: number | null
24 ram: string | null
25 flash: string | null
26 errors: string[]
27 warnings: number
28}
29
30// Colour codes, real or written out as text: the dashboard's stream sends `\033[32m` as characters.
31const ANSI = /(\x1b|\\033|\\x1b|\\u001b)\[[0-9;]*[A-Za-z]/g
32
33/** Output chunks as display lines: colours removed, carriage-return redraws collapsed to their last state. */
34export function toLines(chunks: string[]): string[] {
35 const out: string[] = []
36 for (const chunk of chunks) {
37 for (const raw of chunk.replace(ANSI, '').split('\n')) {
38 const last = raw.split('\r').filter(part => part.trim() !== '').at(-1)
39 if (last !== undefined) out.push(last.trimEnd())
40 }
41 }
42 return out
43}
44
45export function kindOf(line: string): LineKind {
46 if (/\[E\]|^ERROR\b|\berror:|\bFAILED\b|Error \d+$/i.test(line) && !/\b0 errors?\b/i.test(line)) return 'error'
47 if (/\[W\]|^WARNING\b|\bwarning:/i.test(line)) return 'warning'
48 if (/\[I\]|^INFO\b/.test(line)) return 'info'
49 if (/\[[DVC]\]/.test(line)) return 'debug'
50 return 'plain'
51}
52
53/** Where a build is, from all its lines so far. */
54export function progressOf(lines: string[], command: string): Progress {
55 let mainTotal = 0
56 const p: Progress = { phase: 'starting', compiled: 0, compilePercent: null, uploadPercent: null, uploadBytes: null, ram: null, flash: null, errors: [], warnings: 0 }
57 for (const line of lines) {
58 const kind = kindOf(line)
59 if (kind === 'error') p.errors.push(line)
60 if (kind === 'warning') p.warnings += 1
61 if (/Reading configuration|Generating C\+\+ source|Configuration is valid/.test(line)) p.phase = higher(p.phase, 'config')
62 // A first build fetches and unpacks the toolchain before compiling anything.
63 if (/Downloading ESP-IDF|Installing ESP-IDF|Installing tools|ESP-IDF tool|^Extracting |platformio.*install/i.test(line)) p.phase = higher(p.phase, 'setup')
64 if (/^Compiling .*\.o\b/.test(line)) {
65 p.compiled += 1
66 p.phase = higher(p.phase, 'compiling')
67 }
68 const step = /^\[\s*(\d+)\/(\d+)\]\s+(.*)/.exec(line)
69 // Near the end ESP-IDF builds the bootloader as a project of its own, counting "[1/110]" again:
70 // follow the main build's count (the largest total) so the bar does not drop from 99% to 1%.
71 if (step && Number(step[2]) >= mainTotal) {
72 const done = Number(step[1])
73 const total = Number(step[2])
74 mainTotal = total
75 if (total > 0) p.compilePercent = Math.min(100, Math.round((done / total) * 100))
76 p.compiled = done
77 // Static libraries are linked all through the build; only the executable is the final link.
78 p.phase = higher(p.phase, /^Linking (C|CXX) executable/.test(step[3] ?? '') ? 'linking' : 'compiling')
79 }
80 if (/^Linking \.pio|^Linking .*firmware\.elf|Linking (C|CXX) executable/.test(line)) p.phase = higher(p.phase, 'linking')
81 if (/^Building .*firmware\.(bin|elf|factory\.bin)|Successfully created .* image|elf2image|Generated .*\.bin/i.test(line)) p.phase = higher(p.phase, 'image')
82 const ram = /^RAM:\s+\[[^\]]*\]\s+([\d.]+%)/.exec(line)
83 if (ram) p.ram = ram[1] ?? null
84 const flash = /^Flash:\s+\[[^\]]*\]\s+([\d.]+%)/.exec(line)
85 if (flash) p.flash = flash[1] ?? null
86 const upload = /Uploading:\s+\[[=\s]*\]\s+(\d+)%/.exec(line)
87 if (upload) {
88 p.uploadPercent = Number(upload[1])
89 p.phase = higher(p.phase, 'uploading')
90 }
91 // The OTA has begun before any percentage: ESPHome connects, says what it sends, shakes hands.
92 if (command === 'upload' && /Connecting to .* port \d+|^INFO Uploading \/|^Uploading \/|Handshake complete/.test(line)) p.phase = higher(p.phase, 'uploading')
93 const size = /Uploading \S+ \((\d+) bytes\)/.exec(line)
94 if (size) p.uploadBytes = Number(size[1])
95 if (/OTA successful|Successfully uploaded program/i.test(line)) p.uploadPercent = 100
96 }
97 if (command === 'validate' && p.phase === 'starting' && lines.length > 0) p.phase = 'config'
98 return p
99}
100
101const ORDER: Phase[] = ['starting', 'config', 'setup', 'compiling', 'linking', 'image', 'uploading', 'done', 'failed']
102
103function higher(a: Phase, b: Phase): Phase {
104 return ORDER.indexOf(b) > ORDER.indexOf(a) ? b : a
105}
106
107/** "1:23" from milliseconds. */
108export function elapsed(ms: number): string {
109 const s = Math.max(0, Math.floor(ms / 1000))
110 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
111}
112
113/** A text progress bar: `width` cells, filled to `percent`. */
114export function bar(percent: number, width: number): string {
115 const filled = Math.round((Math.max(0, Math.min(100, percent)) / 100) * width)
116 return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
117}
118
119/** What a phase reads as, for the status line. */
120export function phaseWords(p: Progress, command: string): string {
121 switch (p.phase) {
122 case 'starting':
123 return command === 'logs' ? 'Connecting to the device' : 'Starting'
124 case 'config':
125 return command === 'validate' ? 'Checking the configuration' : 'Reading the configuration'
126 case 'setup':
127 return 'Setting up the toolchain (first build)'
128 case 'compiling':
129 return p.compilePercent !== null
130 ? `Compiling ${p.compilePercent}% (${p.compiled} steps)`
131 : `Compiling (${p.compiled} file${p.compiled === 1 ? '' : 's'})`
132 case 'linking':
133 return 'Linking'
134 case 'image':
135 return 'Building the firmware image'
136 case 'uploading':
137 return `Uploading ${p.uploadPercent ?? 0}%`
138 case 'done':
139 return 'Done'
140 case 'failed':
141 return 'Failed'
142 }
143}
144hooks/chip.ts 556 lines1// The dot-matrix stage over the ESPHome pane: a chip on the bench, and what is
2// happening to it, scene by scene as the build goes rather than by command (a
3// flash compiles first, then uploads).
4//
5// - compile: a forge. Two cogs turn and throw sparks, object files rain onto a
6// firmware block that grows with the build; the chip waits.
7// - upload: from the finished block, big dots whoosh to the chip with
8// glittering tails, and the chip fills as it flashes.
9// - validate: a magnifier scans lines of code. logs: a heartbeat.
10// - idle: the chip with its things wired to it, from the device's map (a bulb
11// glowing, a thermometer creeping, a button blinking...), pulses running down
12// the wires. With no device known it sleeps, and dreams of devices.
13// - the end: fireworks for a good build, sparks and smoke for a failed one.
14//
15// Pure: one frame per call, as braille Raster cells (2 x 4 dots a cell, one
16// colour a cell), the last row a caption.
17
18export const STRIP_ROWS = 6
19
20export type Scene = 'compile' | 'upload' | 'validate' | 'logs' | 'idle'
21
22/** Something on the device, drawn wired to the chip when it is idle. */
23export type Thing = 'light' | 'thermometer' | 'droplet' | 'button' | 'battery' | 'toggle' | 'fan' | 'screen' | 'gadget'
24
25export type ChipState = {
26 outcome: 'running' | 'ok' | 'failed' | 'idle'
27 scene: Scene
28 /** Build or upload progress where known, else null. */
29 percent: number | null
30 words: string
31 /** When the build ended, for the fireworks or smoke after it. */
32 endedAt: number | null
33 things: Thing[]
34 hasWifi: boolean
35}
36
37const PANEL = 0x0b1020
38const BODY = 0x3a4252
39const EDGE = 0x8e96a3
40const PIN = 0xc9a227
41const PACKET = 0x64d2ff
42const PAGE = 0xd8dee9
43const AMBER = 0xffb43c
44const YELLOW = 0xffe066
45const GREEN = 0x30d158
46const RED = 0xff453a
47const SMOKE = 0x6b7280
48const QUIET = 0x8e96a3
49const WIRE = 0x4b5566
50const NIGHT = 0x9d8cff
51
52/** After this long, a finished build settles into the idle scene. */
53const SETTLE_S = 4
54
55type Canvas = { w: number; h: number; px: Uint32Array }
56type Chip = { left: number; top: number; cw: number; ch: number; cy: number }
57
58export function chipFrame(now: number, state: ChipState, columns: number): string {
59 const c: Canvas = { w: columns * 2, h: (STRIP_ROWS - 1) * 4, px: new Uint32Array(columns * 2 * (STRIP_ROWS - 1) * 4) }
60 const t = now / 1000
61 const since = state.endedAt === null ? Infinity : (now - state.endedAt) / 1000
62 const isSettled = state.outcome === 'idle' || (state.outcome !== 'running' && since > SETTLE_S)
63 // Lined up with the braille cells (4 dots high): the body fills rows 1-3, the pins have rows of
64 // their own above and below, so a pin's colour never bleeds into the body (one colour a cell).
65 const cw = 26
66 const ch = 12
67 const top = 4
68 const left = Math.max(cw, Math.min(c.w - cw - 6, Math.floor(c.w * (isSettled ? 0.5 : 0.56) - (isSettled ? cw / 2 : 0))))
69 const chip: Chip = { left, top, cw, ch, cy: top + Math.floor(ch / 2) }
70
71 if (isSettled) {
72 idle(c, t, chip, state)
73 return encode(c, columns, state.words, state.outcome === 'failed' ? RED : state.outcome === 'ok' ? GREEN : QUIET)
74 }
75
76 const isRunning = state.outcome === 'running'
77 if (isRunning) {
78 if (state.scene === 'upload') upload(c, t, chip)
79 else if (state.scene === 'validate') scan(c, t, chip)
80 else if (state.scene === 'logs') heartbeat(c, t, chip)
81 else forge(c, t, chip, state.percent)
82 }
83 const fill = state.outcome === 'ok' ? 100 : state.scene === 'upload' || !isRunning ? state.percent : null
84 drawChip(c, t, chip, {
85 fill,
86 fillColour: state.outcome === 'failed' ? RED : PACKET,
87 pins: isRunning ? 'chase' : state.outcome === 'ok' ? GREEN : RED,
88 led: state.outcome === 'ok' ? GREEN : state.outcome === 'failed' ? RED : Math.floor(t * (state.scene === 'upload' ? 8 : 2)) % 2 === 0 ? AMBER : BODY,
89 isFilling: isRunning,
90 })
91 if (state.outcome === 'ok') fireworks(c, since, left + cw / 2, cw)
92 if (state.outcome === 'failed') smoke(c, t, since, chip)
93
94 return encode(c, columns, state.words, state.outcome === 'ok' ? GREEN : state.outcome === 'failed' ? RED : QUIET)
95}
96
97// ---------------------------------------------------------------- the chip
98
99function drawChip(
100 c: Canvas,
101 t: number,
102 { left, top, cw, ch }: Chip,
103 look: { fill: number | null; fillColour: number; pins: 'chase' | number; led: number; isFilling: boolean },
104) {
105 for (let y = top; y < top + ch; y++) {
106 for (let x = left; x < left + cw; x++) {
107 const isEdge = y === top || y === top + ch - 1 || x === left || x === left + cw - 1
108 // Round dots, not a block: the outline, and a sparse grid inside it.
109 if (isEdge) set(c, x, y, EDGE)
110 else if ((x - left) % 2 === 0 && (y - top) % 2 === 0) set(c, x, y, BODY)
111 }
112 }
113 if (look.fill !== null) {
114 const filled = Math.round((look.fill / 100) * (cw - 4))
115 for (let x = 0; x < filled; x++) {
116 for (let y = top + 3; y < top + ch - 2; y++) set(c, left + 2 + x, y, x === filled - 1 && look.isFilling ? PAGE : look.fillColour)
117 }
118 }
119 const pinCount = Math.floor((cw - 2) / 3)
120 for (let i = 0; i < pinCount; i++) {
121 const x = left + 2 + i * 3
122 let colour = typeof look.pins === 'number' ? look.pins : PIN
123 if (look.pins === 'chase') {
124 const lit = (i + Math.floor(t * 12)) % 5 === 0 || (pinCount - i + Math.floor(t * 12)) % 7 === 0
125 if (lit) colour = hue((i / pinCount + t * 0.4) % 1)
126 }
127 for (const y of [top - 1, top - 2, top + ch, top + ch + 1]) set(c, x, y, colour)
128 }
129 for (const [dx, dy] of [[0, 0], [1, 0], [0, 1], [1, 1]] as const) set(c, left + 3 + dx, top + 2 + dy, look.led)
130}
131
132// ---------------------------------------------------------------- building
133
134/** Where the forge stands: the firmware block, and the two cogs left of it. */
135function forgeAt({ left, cy }: Chip) {
136 const bw = 10
137 const bx = Math.max(16, Math.floor(left * 0.42))
138 return { bx, bw, base: cy + 7, gearA: { x: bx - 21, y: cy + 2, r: 4 }, gearB: { x: bx - 9, y: cy - 3, r: 3 } }
139}
140
141/** A cog: a rim, short teeth round it and spokes inside, all turning. */
142function cog(c: Canvas, x: number, y: number, r: number, turn: number, teeth: number) {
143 if (x < 4) return
144 const at = (angle: number, d: number) => [Math.round(x + Math.cos(angle) * d * 1.5), Math.round(y + Math.sin(angle) * d)] as const
145 for (let a = 0; a < 28; a++) {
146 const [px, py] = at((a / 28) * Math.PI * 2, r)
147 set(c, px, py, EDGE)
148 }
149 for (let k = 0; k < teeth; k++) {
150 const [px, py] = at(turn + (k / teeth) * Math.PI * 2, r + 1.2)
151 set(c, px, py, AMBER)
152 }
153 for (let k = 0; k < 3; k++) {
154 for (let d = 1; d < r; d++) {
155 const [px, py] = at(turn + (k / 3) * Math.PI * 2, d)
156 set(c, px, py, scale(AMBER, 0.7))
157 }
158 }
159 set(c, Math.round(x), Math.round(y), YELLOW)
160}
161
162/** The firmware block: rows of dots, as high as the build is far along. */
163function block(c: Canvas, bx: number, bw: number, base: number, percent: number, t: number) {
164 const tall = 13
165 const rows = Math.round((percent / 100) * tall)
166 for (let r = 0; r < rows; r++) {
167 for (let x = 0; x < bw; x++) {
168 if ((x + r) % 2 === 0) set(c, bx + x, base - r, r === rows - 1 ? (Math.floor(t * 6 + x) % 3 === 0 ? PAGE : PACKET) : PACKET)
169 }
170 }
171 // Its outline, waiting to be filled.
172 for (let r = 0; r <= tall; r += 2) {
173 set(c, bx - 1, base - r, WIRE)
174 set(c, bx + bw, base - r, WIRE)
175 }
176 return base - rows
177}
178
179/** Compiling: cogs turn and spark, object files rain onto the growing firmware. */
180function forge(c: Canvas, t: number, chip: Chip, percent: number | null) {
181 const { bx, bw, base, gearA, gearB } = forgeAt(chip)
182 cog(c, gearA.x, gearA.y, gearA.r, t * 2.2, 8)
183 cog(c, gearB.x, gearB.y, gearB.r, -t * 2.2 * (gearA.r / gearB.r), 6)
184 const surface = block(c, bx, bw, base, percent ?? (t * 7) % 100, t)
185 // Object files, little pages, falling onto the top of the block.
186 for (let k = 0; k < 3; k++) {
187 const drop = Math.max(2, surface - 3)
188 const run = t * 9 + k * (drop / 3)
189 const fall = run % drop
190 const x = bx + 1 + ((k * 3 + Math.floor(run / drop) * 4) % (bw - 3))
191 const colour = hue((k * 0.33 + Math.floor(run / drop) * 0.17) % 1)
192 for (let dx = 0; dx < 2; dx++) for (let dy = 0; dy < 2; dy++) set(c, x + dx, Math.round(fall) + dy, colour)
193 }
194 // Sparks where the cogs meet.
195 const mx = (gearA.x + gearB.x) / 2 + 2
196 const my = (gearA.y + gearB.y) / 2
197 for (let k = 0; k < 5; k++) {
198 const life = (t * 2.4 + k * 0.21) % 1
199 const angle = -Math.PI / 2 + Math.sin(k * 12.9898 + Math.floor(t * 2.4 + k * 0.21) * 4.1) * 1.3
200 if (life < 0.7) set(c, Math.round(mx + Math.cos(angle) * life * 10), Math.round(my + Math.sin(angle) * life * 7 + life * life * 6), life < 0.3 ? YELLOW : AMBER)
201 }
202}
203
204/** Flashing: big dots leave the finished firmware and whoosh to the chip, glittering behind. */
205function upload(c: Canvas, t: number, chip: Chip) {
206 const { bx, bw, base, gearA, gearB } = forgeAt(chip)
207 cog(c, gearA.x, gearA.y, gearA.r, t * 0.6, 8)
208 cog(c, gearB.x, gearB.y, gearB.r, -t * 0.6 * (gearA.r / gearB.r), 6)
209 block(c, bx, bw, base, 100, t)
210 const x0 = bx + bw + 1
211 const y0 = base - 6
212 const x1 = chip.left + 3
213 const y1 = chip.cy
214 for (let k = 0; k < 3; k++) {
215 const p = (t * 0.75 + k / 3) % 1
216 const at = (q: number) => {
217 const e = q * q * (3 - 2 * q)
218 return { x: x0 + (x1 - x0) * e, y: y0 + (y1 - y0) * e - Math.sin(e * Math.PI) * 7 }
219 }
220 // The glittering tail: behind the head, twinkling, fading.
221 for (let j = 1; j <= 14; j++) {
222 const q = p - j * 0.022
223 if (q <= 0) break
224 const spot = at(q)
225 const twinkle = hash(j, k, Math.floor(t * 18)) % 10
226 if (twinkle < 6) set(c, Math.round(spot.x + (twinkle % 3) - 1), Math.round(spot.y + ((twinkle >> 1) % 3) - 1), scale(hue((j * 0.07 + t * 0.5 + k / 3) % 1), 1 - j / 16))
227 }
228 const head = at(p)
229 for (const [dx, dy] of [[0, -1], [-1, 0], [0, 0], [1, 0], [0, 1], [1, -1], [1, 1], [-1, -1], [-1, 1]] as const) {
230 const isCorner = dx !== 0 && dy !== 0
231 set(c, Math.round(head.x) + dx, Math.round(head.y) + dy, isCorner ? PACKET : PAGE)
232 }
233 }
234}
235
236/** Lines of code on the left, a magnifier sliding down them, ticked green behind it. */
237function scan(c: Canvas, t: number, { left, cy }: Chip) {
238 const sx = Math.max(10, Math.floor(left * 0.35))
239 const rows = 7
240 const at = (t * 2.5) % rows
241 for (let row = 0; row < rows; row++) {
242 const y = cy - 7 + row * 2
243 const indent = (row % 3) * 2
244 const length = 8 + ((row * 7) % 9)
245 for (let x = indent; x < indent + length; x++) set(c, x + sx - 8, y, row < at ? GREEN : PAGE)
246 }
247 const gy = cy - 7 + at * 2
248 const gx = sx + 4
249 for (let a = 0; a < 16; a++) {
250 const angle = (a / 16) * Math.PI * 2
251 set(c, Math.round(gx + Math.cos(angle) * 4), Math.round(gy + Math.sin(angle) * 2.5), AMBER)
252 }
253 for (let d = 0; d < 4; d++) set(c, gx + 3 + d, Math.round(gy + 2 + d * 0.7), AMBER)
254}
255
256/** A heartbeat trace running out of the chip to the left. */
257function heartbeat(c: Canvas, t: number, { left, cy }: Chip) {
258 const shift = Math.floor(t * 30)
259 for (let x = 0; x < left - 1; x++) {
260 const phase = (x + shift) % 40
261 const y = phase === 20 ? cy - 6 : phase === 21 ? cy + 4 : phase === 22 ? cy - 2 : cy
262 set(c, left - 2 - x, y, x < 20 ? GREEN : x < 50 ? PACKET : EDGE)
263 }
264}
265
266// ---------------------------------------------------------------- endings
267
268/** Three bursts, coloured, falling a little as they open. */
269function fireworks(c: Canvas, since: number, centre: number, cw: number) {
270 for (let b = 0; b < 3; b++) {
271 const age = since - b * 0.5
272 if (age < 0 || age > 1.8) continue
273 const bx = centre + (b - 1) * cw * 0.9
274 const by = 6 + (b % 2) * 4
275 const r = age * 9
276 for (let k = 0; k < 14; k++) {
277 const angle = (k / 14) * Math.PI * 2 + b
278 const bright = hue((b * 0.3 + k / 14) % 1)
279 set(c, Math.round(bx + Math.cos(angle) * r * 1.6), Math.round(by + Math.sin(angle) * r + age * age * 2), age > 1.3 ? scale(bright, 0.5) : bright)
280 }
281 }
282}
283
284/** Sparks off the chip at once, then smoke drifting up and away to the right. */
285function smoke(c: Canvas, t: number, since: number, { left, cw, top }: Chip) {
286 if (since < 0.8) {
287 for (let k = 0; k < 8; k++) {
288 const angle = (k / 8) * Math.PI * 2 + t * 9
289 const r = 3 + since * 10
290 if (Math.sin(t * 40 + k) > 0) set(c, Math.round(left + cw / 2 + Math.cos(angle) * r * 1.5), Math.round(top + 4 + Math.sin(angle) * r * 0.6), k % 2 ? AMBER : RED)
291 }
292 }
293 for (let k = 0; k < 14; k++) {
294 const rise = ((since * 2.5 + k * 0.45) % 5) * 1.6
295 set(c, Math.round(left + cw - 4 + rise * 2.2 + Math.sin(t * 2 + k) * 1.5), Math.round(top + 8 - rise * 0.8), SMOKE)
296 }
297}
298
299// ---------------------------------------------------------------- idle
300
301/** The chip at rest: its things wired to it and alive, or, with none known, its dreams. */
302function idle(c: Canvas, t: number, chip: Chip, state: ChipState) {
303 const { left, cw, cy, top } = chip
304 const right = left + cw
305 const led = state.outcome === 'failed' ? RED : state.things.length > 0 ? (Math.sin(t * 2) > 0.6 ? GREEN : scale(GREEN, 0.4)) : BODY
306 drawChip(c, t, chip, { fill: null, fillColour: PACKET, pins: PIN, led, isFilling: false })
307 if (state.hasWifi) {
308 // On the network: a little signal in the chip's corner, breathing.
309 const strength = Math.floor((t * 1.5) % 4)
310 for (let k = 0; k < strength; k++) for (let d = 0; d <= k; d++) set(c, right - 5 + k, top + 3 - d, PACKET)
311 }
312 if (state.things.length === 0) {
313 dreams(c, t, chip)
314 return
315 }
316
317 // Slots: two a side close in (above and below the middle), then one a side further out.
318 const slots = [
319 { x: left - 12, y: cy - 4, side: -1 },
320 { x: right + 11, y: cy - 4, side: 1 },
321 { x: left - 12, y: cy + 5, side: -1 },
322 { x: right + 11, y: cy + 5, side: 1 },
323 { x: left - 28, y: cy, side: -1 },
324 { x: right + 27, y: cy, side: 1 },
325 ].filter(slot => slot.x > 3 && slot.x < c.w - 4)
326 state.things.slice(0, slots.length).forEach((thing, i) => {
327 const slot = slots[i]!
328 const from = slot.side < 0 ? left - 1 : right
329 const to = slot.x - slot.side * 4
330 const span = Math.abs(to - from)
331 for (let d = 0; d <= span; d += 2) set(c, from + slot.side * d, slot.y, WIRE)
332 // A pulse now and then, out to the thing.
333 const p = (t * 0.5 + i * 0.37) % 1
334 if (p < 0.5) set(c, Math.round(from + slot.side * span * (p * 2)), slot.y, PACKET)
335 sprite(c, t + i * 1.7, thing, slot.x, slot.y)
336 })
337}
338
339function sprite(c: Canvas, t: number, thing: Thing, x: number, y: number) {
340 switch (thing) {
341 case 'light': {
342 const glow = 0.55 + 0.45 * Math.sin(t * 1.6)
343 ring(c, x, y - 1, 2.6, 1.4, scale(YELLOW, glow))
344 set(c, x, y - 1, scale(YELLOW, glow))
345 for (const dx of [-1, 0, 1]) set(c, x + dx, y + 2, EDGE)
346 set(c, x, y + 3, EDGE)
347 if (glow > 0.9) for (const [dx, dy] of [[-4, -1], [4, -1], [0, -4]] as const) set(c, x + dx, y + dy, YELLOW)
348 return
349 }
350 case 'thermometer': {
351 for (let dy = -3; dy <= 1; dy++) {
352 set(c, x - 1, y + dy, EDGE)
353 set(c, x + 1, y + dy, EDGE)
354 }
355 const level = Math.round(1 + (Math.sin(t * 0.9) + 1) * 1.5)
356 for (let d = 0; d < level; d++) set(c, x, y + 1 - d, RED)
357 ring(c, x, y + 3, 1.4, 1, RED)
358 set(c, x, y + 3, RED)
359 return
360 }
361 case 'droplet': {
362 const sway = Math.round(Math.sin(t * 1.3) * 0.6)
363 set(c, x + sway, y - 3, PACKET)
364 for (const dx of [-1, 1]) set(c, x + dx + sway, y - 1, PACKET)
365 ring(c, x + sway, y + 1, 1.8, 1.4, PACKET)
366 return
367 }
368 case 'button': {
369 for (let d = -2; d <= 2; d++) {
370 set(c, x + d, y - 2, EDGE)
371 set(c, x + d, y + 2, EDGE)
372 set(c, x - 2, y + d, EDGE)
373 set(c, x + 2, y + d, EDGE)
374 }
375 const isPressed = (t % 3) < 0.35
376 set(c, x, y, isPressed ? GREEN : AMBER)
377 if (isPressed) for (const [dx, dy] of [[-1, 0], [1, 0], [0, -1], [0, 1]] as const) set(c, x + dx, y + dy, GREEN)
378 return
379 }
380 case 'battery': {
381 for (let d = -3; d <= 3; d++) {
382 set(c, x + d, y - 2, EDGE)
383 set(c, x + d, y + 2, EDGE)
384 }
385 for (let d = -2; d <= 2; d++) {
386 set(c, x - 3, y + d, EDGE)
387 set(c, x + 3, y + d, EDGE)
388 }
389 set(c, x + 4, y, EDGE)
390 const level = Math.floor((t * 0.8) % 6)
391 for (let d = 0; d < level; d++) for (const dy of [-1, 0, 1]) set(c, x - 2 + d, y + dy, level > 2 ? GREEN : AMBER)
392 return
393 }
394 case 'toggle': {
395 ring(c, x, y, 3.2, 1.6, EDGE)
396 const isOn = Math.floor(t / 2.5) % 2 === 0
397 for (const [dx, dy] of [[0, 0], [1, 0], [0, -1], [1, -1]] as const) set(c, x + (isOn ? 1 : -2) + dx, y + dy + 1 - 1, isOn ? GREEN : QUIET)
398 return
399 }
400 case 'fan': {
401 set(c, x, y, EDGE)
402 for (let k = 0; k < 3; k++) {
403 const angle = t * 6 + (k / 3) * Math.PI * 2
404 for (let d = 1; d <= 3; d++) set(c, Math.round(x + Math.cos(angle) * d * 1.3), Math.round(y + Math.sin(angle) * d * 0.8), PACKET)
405 }
406 return
407 }
408 case 'screen': {
409 for (let d = -3; d <= 3; d++) {
410 set(c, x + d, y - 2, EDGE)
411 set(c, x + d, y + 2, EDGE)
412 }
413 for (const dy of [-1, 0, 1]) {
414 set(c, x - 3, y + dy, EDGE)
415 set(c, x + 3, y + dy, EDGE)
416 }
417 const scroll = Math.floor(t * 4) % 5
418 for (let d = 0; d < 3; d++) set(c, x - 2 + ((scroll + d) % 5), y - 1 + (d % 3), PACKET)
419 return
420 }
421 default: {
422 ring(c, x, y, 2, 1.5, EDGE)
423 if (Math.floor(t * 2) % 2 === 0) set(c, x, y, PACKET)
424 }
425 }
426}
427
428/** Asleep: Zs drift up to a thought cloud, where the devices it could be come and go under the stars. */
429function dreams(c: Canvas, t: number, { left, cw, top, cy }: Chip) {
430 const right = left + cw
431 // Eyes closed: two little smiles in the body.
432 for (const ex of [left + 8, left + cw - 10]) for (const [dx, dy] of [[0, 0], [1, 1], [2, 1], [3, 0]] as const) set(c, ex + dx, cy + dy - 1, PAGE)
433 const cx = Math.min(c.w - 14, right + 20)
434 const cyCloud = 9
435 // The cloud: a few overlapping puffs.
436 // Puffs, of which only the outline is drawn: a dot inside one, beside a dot in none.
437 const puffs = [[-8, 1, 6.5, 4], [0, -3, 7.5, 4.5], [8, 1, 6.5, 4], [0, 4, 11, 3]] as const
438 const isIn = (x: number, y: number) => puffs.some(([dx, dy, rx, ry]) => ((x - cx - dx) / rx) ** 2 + ((y - cyCloud - dy) / ry) ** 2 <= 1)
439 for (let y = 0; y < c.h; y++) {
440 for (let x = cx - 20; x <= cx + 20; x++) {
441 if (isIn(x, y) && (!isIn(x - 1, y) || !isIn(x + 1, y) || !isIn(x, y - 1) || !isIn(x, y + 1))) set(c, x, y, scale(NIGHT, 0.6))
442 }
443 }
444 // What it dreams of, one after another.
445 const dreamt: Thing[] = ['light', 'thermometer', 'fan', 'button', 'droplet', 'screen']
446 sprite(c, t, dreamt[Math.floor(t / 2.5) % dreamt.length]!, cx, cyCloud)
447 // Stars twinkling round it.
448 for (let k = 0; k < 6; k++) {
449 if (hash(k, 3, Math.floor(t * 3)) % 3 === 0) continue
450 set(c, Math.round(cx - 14 + ((k * 11) % 30)), (k * 5) % 3 === 0 ? 0 : (k * 7) % 4, k % 2 ? YELLOW : PAGE)
451 }
452 // Zs rising from the chip towards the cloud, small to large.
453 for (let k = 0; k < 3; k++) {
454 const p = (t * 0.3 + k / 3) % 1
455 const zx = Math.round(right + 1 + (cx - 16 - (right + 1)) * p)
456 const zy = Math.round(top + 5 - p * 4)
457 const size = p < 0.35 ? 3 : 4
458 const colour = scale(NIGHT, 1 - p * 0.5)
459 for (let d = 0; d < size; d++) {
460 set(c, zx + d, zy - size + 1, colour)
461 set(c, zx + d, zy, colour)
462 set(c, zx + size - 1 - d, zy - size + 1 + d, colour)
463 }
464 }
465}
466
467// ---------------------------------------------------------------- drawing
468
469function ring(c: Canvas, x: number, y: number, rx: number, ry: number, colour: number) {
470 const steps = Math.max(10, Math.round((rx + ry) * 4))
471 for (let a = 0; a < steps; a++) {
472 const angle = (a / steps) * Math.PI * 2
473 set(c, Math.round(x + Math.cos(angle) * rx), Math.round(y + Math.sin(angle) * ry), colour)
474 }
475}
476
477/** A bright colour round the wheel, h from 0 to 1. */
478function hue(h: number): number {
479 const f = (n: number) => {
480 const k = (n + h * 6) % 6
481 return Math.round(255 * (1 - Math.max(0, Math.min(1, Math.min(k, 4 - k)))))
482 }
483 return (f(5) << 16) | (f(3) << 8) | f(1) || PAGE
484}
485
486function scale(colour: number, by: number): number {
487 const f = Math.max(0.15, Math.min(1, by))
488 const r = Math.round(((colour >> 16) & 255) * f)
489 const g = Math.round(((colour >> 8) & 255) * f)
490 const b = Math.round((colour & 255) * f)
491 return (r << 16) | (g << 8) | b || BODY
492}
493
494function hash(a: number, b: number, c: number): number {
495 return (Math.imul(a + 1, 73856093) ^ Math.imul(b + 1, 19349663) ^ Math.imul(c + 1, 83492791)) >>> 0
496}
497
498function set(c: Canvas, x: number, y: number, colour: number) {
499 if (x < 0 || y < 0 || x >= c.w || y >= c.h) return
500 c.px[y * c.w + x] = colour
501}
502
503const DOT_BITS = [
504 [0x01, 0x02, 0x04, 0x40],
505 [0x08, 0x10, 0x20, 0x80],
506]
507
508function encode(c: Canvas, columns: number, words: string, colour: number): string {
509 const bytes = new Uint8Array(columns * STRIP_ROWS * 12)
510 const view = new DataView(bytes.buffer)
511 const put = (cell: number, code: number, fg: number) => {
512 view.setUint32(cell * 12, code, true)
513 view.setUint32(cell * 12 + 4, fg, true)
514 view.setUint32(cell * 12 + 8, PANEL, true)
515 }
516 for (let row = 0; row < STRIP_ROWS - 1; row++) {
517 for (let col = 0; col < columns; col++) {
518 let bits = 0
519 let fg = PANEL
520 for (let dx = 0; dx < 2; dx++) {
521 for (let dy = 0; dy < 4; dy++) {
522 const dot = c.px[(row * 4 + dy) * c.w + col * 2 + dx] ?? 0
523 if (!dot) continue
524 bits |= DOT_BITS[dx]![dy]!
525 // A cell has one colour: the liveliest dot in it wins over the chip's greys.
526 if (fg === PANEL || (dot !== BODY && dot !== EDGE && dot !== WIRE)) fg = dot
527 }
528 }
529 put(row * columns + col, bits ? 0x2800 + bits : 0x20, fg)
530 }
531 }
532 const base = (STRIP_ROWS - 1) * columns
533 const text = words.length > columns ? `${words.slice(0, columns - 1)}…` : words
534 const start = Math.max(0, Math.floor((columns - text.length) / 2))
535 for (let col = 0; col < columns; col++) {
536 const i = col - start
537 const code = i >= 0 && i < text.length ? text.charCodeAt(i) : 0x20
538 const isPrintable = code >= 0x20 && code !== 0x7f && code < 0x1100
539 put(base + col, isPrintable || code === 0x2026 || code === 0x2713 || code === 0x2717 ? code : 0xb7, colour)
540 }
541 return base64(bytes)
542}
543
544const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
545
546function base64(bytes: Uint8Array): string {
547 let out = ''
548 for (let i = 0; i < bytes.length; i += 3) {
549 const n = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
550 out += ALPHABET[(n >> 18) & 63]! + ALPHABET[(n >> 12) & 63]!
551 out += i + 1 < bytes.length ? ALPHABET[(n >> 6) & 63]! : '='
552 out += i + 2 < bytes.length ? ALPHABET[n & 63]! : '='
553 }
554 return out
555}
556hooks/config.ts 387 lines1// A map of an ESPHome device, read from its YAML: the board, how it connects,
2// its buses, every entity with its pins, and what reacts to what. The pane
3// shows it whenever Claude reads or writes a config, and marks what a save
4// changed, as the automation pane does for automations.
5//
6// ESPHome YAML is a small, regular subset (keys, lists of maps, block scalars
7// for lambdas, a few tags), so a forgiving indentation parser reads it; it
8// never needs to be right about anything the map does not show. Passwords and
9// keys written into the YAML are never shown.
10
11export type YNode = {
12 key: string | null
13 value: string | null
14 /** A `!secret`, `!lambda`, `!include`... tag on the value. */
15 tag: string | null
16 isItem: boolean
17 children: YNode[]
18}
19
20import type { MapRow, RowChange } from '../types'
21
22export type { MapRow, RowChange }
23
24export type DeviceMap = {
25 name: string
26 chip: string
27 rows: MapRow[]
28}
29
30
31// ---------------------------------------------------------------- parsing
32
33export function parseYaml(text: string): YNode {
34 const root: YNode = { key: null, value: null, tag: null, isItem: false, children: [] }
35 const stack: { indent: number; node: YNode }[] = [{ indent: -1, node: root }]
36 const lines = text.replace(/\t/g, ' ').split('\n')
37
38 for (let i = 0; i < lines.length; i++) {
39 const raw = lines[i] ?? ''
40 const body = stripComment(raw).trimEnd()
41 if (!body.trim() || body.trim() === '---') continue
42 const indent = body.length - body.trimStart().length
43 let rest = body.trimStart()
44 let at = indent
45
46 if (rest.startsWith('- ') || rest === '-') {
47 while (stack.length > 1 && (stack.at(-1)!.indent > indent || (stack.at(-1)!.indent === indent && stack.at(-1)!.node.isItem))) stack.pop()
48 const item: YNode = { key: null, value: null, tag: null, isItem: true, children: [] }
49 stack.at(-1)!.node.children.push(item)
50 stack.push({ indent, node: item })
51 const after = rest.slice(1)
52 at = indent + 1 + (after.length - after.trimStart().length)
53 rest = after.trim()
54 if (!rest) continue
55 if (!keyOf(rest)) {
56 const { value, tag } = scalar(rest)
57 item.value = value
58 item.tag = tag
59 continue
60 }
61 } else {
62 while (stack.length > 1 && stack.at(-1)!.indent >= indent) stack.pop()
63 }
64
65 const pair = keyOf(rest)
66 if (!pair) continue
67 const node: YNode = { key: pair.key, value: null, tag: null, isItem: false, children: [] }
68 stack.at(-1)!.node.children.push(node)
69 const { value, tag } = scalar(pair.value)
70 node.tag = tag
71 if (value !== null && /^[|>][-+]?\d*$/.test(value)) {
72 // A block scalar (a lambda, usually): the more-indented lines after it.
73 const block: string[] = []
74 while (i + 1 < lines.length) {
75 const next = lines[i + 1] ?? ''
76 if (next.trim() && next.length - next.trimStart().length <= at) break
77 block.push(next.trim())
78 i++
79 }
80 node.value = block.filter(Boolean).join('\n')
81 continue
82 }
83 node.value = value
84 if (value === null) stack.push({ indent: at, node })
85 }
86 return root
87}
88
89function stripComment(line: string): string {
90 let quote: string | null = null
91 for (let i = 0; i < line.length; i++) {
92 const ch = line[i]
93 if (quote) {
94 if (ch === quote) quote = null
95 } else if (ch === '"' || ch === "'") {
96 quote = ch
97 } else if (ch === '#' && (i === 0 || /\s/.test(line[i - 1] ?? ''))) {
98 return line.slice(0, i)
99 }
100 }
101 return line
102}
103
104function keyOf(text: string): { key: string; value: string } | null {
105 const match = /^("[^"]*"|'[^']*'|[^\s"'{[!][^:]*?):(?:\s+(.*)|$)/.exec(text)
106 if (!match?.[1]) return null
107 return { key: match[1].replace(/^["']|["']$/g, ''), value: (match[2] ?? '').trim() }
108}
109
110function scalar(text: string): { value: string | null; tag: string | null } {
111 let value = text.trim()
112 let tag: string | null = null
113 const tagged = /^(![a-z_]+)(?:\s+(.*))?$/i.exec(value)
114 if (tagged?.[1]) {
115 tag = tagged[1]
116 value = (tagged[2] ?? '').trim()
117 }
118 if (!value) return { value: tag ? '' : null, tag }
119 return { value: value.replace(/^"(.*)"$|^'(.*)'$/, '$1$2'), tag }
120}
121
122// ---------------------------------------------------------------- the map
123
124const PLATFORMS = ['esp32', 'esp8266', 'rp2040', 'bk72xx', 'rtl87xx', 'ln882x', 'libretiny', 'nrf52', 'host']
125const NETWORK = ['wifi', 'ethernet', 'api', 'ota', 'mqtt', 'web_server', 'captive_portal', 'improv_serial', 'esp32_improv', 'bluetooth_proxy', 'esp32_ble_tracker', 'time', 'logger', 'safe_mode']
126const BUSES = ['i2c', 'spi', 'uart', 'one_wire', 'i2s_audio', 'canbus', 'modbus']
127const DOMAINS: [string, string, string][] = [
128 ['binary_sensor', 'Binary sensors', '◐'],
129 ['sensor', 'Sensors', '≈'],
130 ['text_sensor', 'Text sensors', '¶'],
131 ['switch', 'Switches', '⏻'],
132 ['light', 'Lights', '✺'],
133 ['fan', 'Fans', '✣'],
134 ['cover', 'Covers', '▤'],
135 ['climate', 'Climate', '❄'],
136 ['button', 'Buttons', '▣'],
137 ['number', 'Numbers', '#'],
138 ['select', 'Selects', '☰'],
139 ['text', 'Text', '¶'],
140 ['lock', 'Locks', '⊡'],
141 ['valve', 'Valves', '⊗'],
142 ['event', 'Events', '⚑'],
143 ['output', 'Outputs', '⇥'],
144 ['status_led', 'Outputs', '•'],
145 ['display', 'Displays', '▭'],
146 ['media_player', 'Media', '♫'],
147 ['speaker', 'Media', '♫'],
148 ['microphone', 'Media', '♪'],
149 ['voice_assistant', 'Media', '◎'],
150 ['remote_transmitter', 'Infrared and RF', '⇢'],
151 ['remote_receiver', 'Infrared and RF', '⇠'],
152 ['deep_sleep', 'Power', '☾'],
153 ['globals', 'Logic', '='],
154 ['script', 'Logic', '▶'],
155 ['interval', 'Logic', '↻'],
156]
157/** A section lists this many, then says how many more. */
158const MAX_PER_DOMAIN = 8
159const QUIET_KEYS = new Set(['esphome', 'substitutions', 'packages', 'dashboard_import', 'preferences', 'external_components', 'font', 'image', 'color', 'animation', 'psram', 'debug'])
160const HIDDEN = /password|(^|_)key$|^key$|psk|token|secret/i
161const PIN_KEY = /^(pin|.*_pin|sda|scl|tx|rx|clk|mosi|miso|cs|dc|reset|data)$/
162
163export function mapDevice(yaml: string): DeviceMap {
164 const root = parseYaml(yaml)
165 const subs = new Map<string, string>()
166 for (const s of child(root, 'substitutions')?.children ?? []) if (s.key && s.value) subs.set(s.key, s.value)
167 const sub = (text: string) => text.replace(/\$\{?([a-z_][a-z0-9_]*)\}?/gi, (all, name: string) => subs.get(name) ?? all)
168 const val = (node: YNode | undefined, key: string) => {
169 const found = node ? child(node, key) : undefined
170 return found?.value ? sub(found.value) : null
171 }
172
173 const core = child(root, 'esphome')
174 const name = val(core, 'friendly_name') ?? val(core, 'name') ?? 'device'
175 const platformNode = PLATFORMS.map(p => child(root, p)).find(Boolean)
176 const variant = val(platformNode, 'variant') ?? val(platformNode, 'board') ?? ''
177 const framework = val(child(platformNode ?? root, 'framework'), 'type')
178 const chip = [platformNode?.key?.toUpperCase().replace('ESP32', 'ESP32'), variant && variant !== platformNode?.key ? variant : null, framework]
179 .filter(Boolean)
180 .join(' · ')
181
182 const rows: MapRow[] = []
183 const add = (section: string, depth: number, icon: string, label: string, detail: string) => rows.push({ section, depth, icon, label: sub(label), detail: sub(detail) })
184 const pins: { pin: string; owner: string }[] = []
185
186 // How it connects.
187 for (const key of NETWORK) {
188 const node = child(root, key)
189 if (!node) continue
190 add('Network', 0, networkIcon(key), key.replace(/_/g, ' '), networkDetail(key, node, sub))
191 }
192 for (const key of BUSES) {
193 for (const item of itemsOf(child(root, key))) {
194 const label = val(item, 'id') ?? key
195 const wires = item.children.filter(c => c.key && PIN_KEY.test(c.key)).map(c => `${c.key} ${pinOf(c, sub) ?? '?'}`)
196 for (const c of item.children) if (c.key && PIN_KEY.test(c.key) && pinOf(c, sub)) pins.push({ pin: pinOf(c, sub)!, owner: `${key} ${c.key}` })
197 add('Buses', 0, '═', `${key}${label !== key ? ` ${label}` : ''}`, [...wires, val(item, 'baud_rate') ? `${val(item, 'baud_rate')} baud` : null, val(item, 'frequency')].filter(Boolean).join(' · '))
198 }
199 }
200
201 // Everything it does.
202 const seen = new Set<string>()
203 const labels = new Map<string, number>()
204 for (const [domain, section, icon] of DOMAINS) {
205 const node = child(root, domain)
206 if (!node) continue
207 seen.add(domain)
208 const items = itemsOf(node)
209 // Values the device only reads from Home Assistant, and its own variables, are one row each:
210 // a real device has dozens of them, and they are not what the map is for.
211 const imported = items.filter(item => val(item, 'platform') === 'homeassistant')
212 if (imported.length > 0) add(section, 0, '⌂', `${imported.length} from Home Assistant`, imported.slice(0, 4).map(item => val(item, 'name') ?? val(item, 'id') ?? '').filter(Boolean).join(', ') + (imported.length > 4 ? ', …' : ''))
213 if (domain === 'globals') {
214 add(section, 0, icon, `${items.length} variable${items.length === 1 ? '' : 's'}`, items.slice(0, 5).map(item => val(item, 'id') ?? '').filter(Boolean).join(', ') + (items.length > 5 ? ', …' : ''))
215 continue
216 }
217 const shown = items.filter(item => !imported.includes(item))
218 for (const item of shown.slice(0, MAX_PER_DOMAIN)) {
219 const named = val(item, 'name') ?? val(item, 'id') ?? (domain === 'interval' && val(item, 'interval') ? `every ${val(item, 'interval')}` : null)
220 const base = named ?? val(item, 'platform') ?? (item.value ? sub(item.value) : domain)
221 // Two rows with one label (two `every 1s` intervals) would share a key in the diff.
222 const repeats = labels.get(`${section}|${base}`) ?? 0
223 labels.set(`${section}|${base}`, repeats + 1)
224 const label = repeats > 0 ? `${base} (${repeats + 1})` : base
225 const detail = [
226 named ? val(item, 'platform') : null,
227 val(item, 'address'),
228 ...pinsIn(item, sub).map(p => p.pin),
229
230 val(item, 'update_interval') ? `every ${val(item, 'update_interval')}` : null,
231 item.children.some(c => c.tag === '!lambda') ? 'λ' : null,
232 ]
233 .filter(Boolean)
234 .join(' · ')
235 add(section, 0, icon, label, detail)
236 for (const p of pinsIn(item, sub)) pins.push({ pin: p.pin, owner: label })
237 // A platform that makes several entities (a BME280's temperature, humidity...).
238 for (const sub of item.children) {
239 if (sub.key && !sub.isItem && val(sub, 'name') && sub.key !== 'then') add(section, 1, '↳', val(sub, 'name')!, sub.key.replace(/_/g, ' '))
240 }
241 for (const trigger of item.children.filter(c => c.key?.startsWith('on_') || (domain === 'interval' && c.key === 'then') || (domain === 'script' && c.key === 'then'))) {
242 add(section, 1, '⚡', trigger.key === 'then' ? 'runs' : trigger.key!.replace(/^on_/, 'on ').replace(/_/g, ' '), actionsOf(trigger).join(', ') || '…')
243 }
244 }
245 if (shown.length > MAX_PER_DOMAIN) {
246 const rest = shown.slice(MAX_PER_DOMAIN)
247 add(section, 0, '…', `${rest.length} more`, rest.slice(0, 4).map(item => val(item, 'name') ?? val(item, 'id') ?? '').filter(Boolean).join(', ') + (rest.length > 4 ? ', …' : ''))
248 }
249 }
250 for (const trigger of (core?.children ?? []).filter(c => c.key?.startsWith('on_'))) {
251 add('Logic', 0, '⚡', trigger.key!.replace(/^on_/, 'on ').replace(/_/g, ' '), actionsOf(trigger).join(', ') || '…')
252 }
253
254 const other = root.children
255 .map(c => c.key)
256 .filter((k): k is string => !!k && !seen.has(k) && !QUIET_KEYS.has(k) && !PLATFORMS.includes(k) && !NETWORK.includes(k) && !BUSES.includes(k))
257 if (other.length > 0) add('Other', 0, '·', other.join(', '), '')
258 const packages = child(root, 'packages')
259 if (packages) add('Other', 0, '⧉', 'packages', packages.children.map(p => p.key ?? p.value).filter(Boolean).join(', ') || (packages.value ?? ''))
260
261 // Which pin does what: the wiring at a glance, with the pins the chip boots from called out.
262 const strapping = strappingPins(platformNode?.key ?? '', `${variant} ${val(platformNode, 'board') ?? ''}`)
263 const byPin = new Map<string, string[]>()
264 for (const p of pins) byPin.set(p.pin, [...(byPin.get(p.pin) ?? []), p.owner])
265 for (const [pin, owners] of [...byPin].sort((a, b) => pinNumber(a[0]) - pinNumber(b[0]))) {
266 const isStrapping = /^GPIO\d+$/.test(pin) && strapping.includes(pinNumber(pin))
267 const notes = [owners.length > 1 ? 'shared' : null, isStrapping ? 'strapping pin' : null].filter(Boolean)
268 add('Pins', 0, owners.length > 1 ? '!' : isStrapping ? '◇' : '○', pin, owners.join(', ') + (notes.length ? ` (${notes.join(', ')})` : ''))
269 }
270
271 return { name: sub(name), chip, rows }
272}
273
274function child(node: YNode, key: string): YNode | undefined {
275 return node.children.find(c => c.key === key)
276}
277
278/** A domain's entries: a list of maps, or a single map (`status_led:` with a pin). */
279function itemsOf(node: YNode | undefined): YNode[] {
280 if (!node) return []
281 const items = node.children.filter(c => c.isItem)
282 return items.length > 0 ? items : node.children.length > 0 ? [node] : []
283}
284
285function pinOf(node: YNode, sub: (text: string) => string = text => text): string | null {
286 const found = node.value ?? child(node, 'number')?.value ?? null
287 const raw = found === null ? null : sub(found)
288 if (!raw) return null
289 const flow = /number:\s*([A-Za-z0-9_]+)/.exec(raw)
290 const pin = (flow?.[1] ?? raw).trim()
291 if (/^\d+$/.test(pin)) return `GPIO${pin}`
292 return /^(GPIO|D|A|P|PA|PB)\d+|^GPIO/i.test(pin) ? pin.toUpperCase() : null
293}
294
295function pinsIn(item: YNode, sub: (text: string) => string): { pin: string }[] {
296 return item.children.filter(c => c.key && PIN_KEY.test(c.key)).map(c => pinOf(c, sub)).filter((p): p is string => !!p).map(pin => ({ pin }))
297}
298
299/** Pins the chip reads at boot, which ESPHome warns about: fine with care, a trap with a pull resistor. */
300function strappingPins(platform: string, variant: string): number[] {
301 const v = variant.toLowerCase().replace(/[-_]/g, '')
302 if (platform === 'esp8266') return [0, 2, 15]
303 if (platform !== 'esp32') return []
304 if (/c3/.test(v)) return [2, 8, 9]
305 if (/c6|h2/.test(v)) return [8, 9, 15]
306 if (/s3/.test(v)) return [0, 3, 45, 46]
307 if (/s2/.test(v)) return [0, 45, 46]
308 return [0, 2, 5, 12, 15]
309}
310
311function pinNumber(pin: string): number {
312 return Number(/\d+/.exec(pin)?.[0] ?? 999)
313}
314
315/** What a trigger does, as its action names: `light.toggle, delay`. */
316function actionsOf(trigger: YNode): string[] {
317 const steps = trigger.children.find(c => c.key === 'then') ?? trigger
318 // A trigger written as a list of `- priority: ... then: ...` entries: their actions, not their settings.
319 if (steps === trigger && trigger.children.some(c => c.isItem && c.children.some(k => k.key === 'then'))) {
320 return [...new Set(trigger.children.filter(c => c.isItem).flatMap(actionsOf))].slice(0, 4)
321 }
322 const names = steps.children.flatMap(c => (c.isItem ? c.children.slice(0, 1).map(a => a.key ?? '') : c.key && !['then', 'priority', 'mode'].includes(c.key) ? [c.key] : []))
323 if (trigger.tag === '!lambda' || steps.children.some(c => c.key === 'lambda')) names.push('λ')
324 return [...new Set(names.filter(Boolean).map(n => (n === 'lambda' ? 'λ' : n)))].slice(0, 4)
325}
326
327function networkIcon(key: string): string {
328 return ({ wifi: '◠', ethernet: '⇄', api: '⌂', ota: '⇪', mqtt: '⇋', web_server: '◫', bluetooth_proxy: 'ᛒ', esp32_ble_tracker: 'ᛒ', time: '◷', logger: '≡' } as Record<string, string>)[key] ?? '·'
329}
330
331function networkDetail(key: string, node: YNode, sub: (text: string) => string): string {
332 const v = (k: string, from: YNode = node) => {
333 const found = child(from, k)
334 if (!found) return null
335 if (found.tag === '!secret') return 'from secrets'
336 if (HIDDEN.test(k)) return '•••'
337 return found.value ? sub(found.value) : null
338 }
339 switch (key) {
340 case 'wifi': {
341 const networks = itemsOf(child(node, 'networks')).length
342 return [
343 v('ssid') ? `ssid ${v('ssid')}` : networks ? `${networks} networks` : null,
344 child(node, 'ap') ? 'fallback hotspot' : null,
345 child(node, 'manual_ip') ? `static ${v('static_ip', child(node, 'manual_ip')) ?? ''}`.trim() : null,
346 ].filter(Boolean).join(' · ')
347 }
348 case 'api':
349 return [child(node, 'encryption') ? 'encrypted' : 'not encrypted', child(node, 'actions') || child(node, 'services') ? 'with actions' : null].filter(Boolean).join(' · ')
350 case 'ota':
351 return itemsOf(node).map(item => v('platform', item) ?? 'esphome').join(', ')
352 case 'mqtt':
353 return v('broker') ? `broker ${v('broker')}` : ''
354 case 'web_server':
355 return v('port') ? `port ${v('port')}` : ''
356 case 'time':
357 return itemsOf(node).map(item => v('platform', item)).filter(Boolean).join(', ')
358 case 'logger':
359 return v('level') ? `level ${v('level')}` : ''
360 case 'ethernet':
361 return v('type') ?? ''
362 default:
363 return ''
364 }
365}
366
367// ---------------------------------------------------------------- what a save changed
368
369export function rowKey(row: MapRow): string {
370 return `${row.section}|${row.depth}|${row.label}`
371}
372
373/** Rows a new map adds or changes against the old one, and the old rows it drops. */
374export function diffMaps(before: MapRow[], after: MapRow[]): { changes: Record<string, RowChange>; removed: MapRow[] } {
375 const old = new Map(before.map(row => [rowKey(row), row]))
376 const fresh = new Set(after.map(rowKey))
377 const changes: Record<string, RowChange> = {}
378 for (const row of after) {
379 const was = old.get(rowKey(row))
380 if (!was) changes[rowKey(row)] = 'added'
381 else if (was.detail !== row.detail || was.icon !== row.icon) changes[rowKey(row)] = 'changed'
382 }
383 const removed = before.filter(row => !fresh.has(rowKey(row)))
384 for (const row of removed) changes[rowKey(row)] = 'removed'
385 return { changes, removed }
386}
387types/index.d.ts 69 lines1/** One build command Claude ran (validate, compile, upload or logs) and what it has produced. */
2export type Build = {
3 /** The MCP server it ran on, as the engine names it. */
4 server: string
5 command: string
6 configuration: string
7 startedAt: number
8 finishedAt: number | null
9 /** Display lines so far (the last few hundred). */
10 lines: string[]
11 /** The newest esphome_activity sequence seen, to ask only for what follows. */
12 seq: number
13 /** True once lines have arrived while it ran; false when they came only at the end. */
14 isLive: boolean
15 outcome: 'running' | 'ok' | 'failed'
16 error: string | null
17 /** When the upload stage was first seen, to pace its bar: OTA prints its percentage only at the end. */
18 uploadSeenAt?: number | null
19}
20
21/** A device the dashboard knows, from esphome_list_devices. */
22export type Device = {
23 name: string
24 configuration: string
25 deployed: string | null
26 current: string | null
27}
28
29/** One line of a device's map (hooks/config.ts builds them). */
30export type MapRow = {
31 section: string
32 depth: number
33 icon: string
34 label: string
35 detail: string
36}
37
38export type RowChange = 'added' | 'changed' | 'removed'
39
40/** The map of the device Claude last read or wrote, and what its latest save changed. */
41export type ConfigView = {
42 configuration: string
43 name: string
44 chip: string
45 rows: MapRow[]
46 /** Row keys (config.rowKey) a save added, changed or removed, shown for a few seconds. */
47 changes: Record<string, RowChange>
48 removed: MapRow[]
49 changedAt: number | null
50}
51
52declare module 'claude-code' {
53 interface PluginState {
54 'vome-esphome': {
55 build: Build | null
56 devices: Device[] | null
57 /** Auto mode refused the pane's read of esphome_activity: on which server. */
58 blocked: { server: string; error: string } | null
59 /** Whether the chip strip plays; kept across sessions in $.store as well. */
60 fxOn: boolean
61 config: ConfigView | null
62 /** The whole build log instead of its last lines and the map. */
63 showLog: boolean
64 /** Claude used automation tools and the automation pane does not seem to be here: offer it, once. */
65 sibling: boolean
66 }
67 }
68}
69