A side pane for the Home Assistant automation Claude is working on: triggers, conditions and actions as HA's editor nests them, what a save changed, which…

A side pane in Claude Code that shows the Home Assistant automation Claude is working on, kept up to date as Claude reads it, edits it and runs it.

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. A home hosted on Vome gets its key at vome.io instead, under Account → API tokens.
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-automation --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 an automation, for example "Why didn't my hallway lights turn off?". The pane opens beside the conversation.
In auto mode, the pane asks you to allow its read-only tools the first time; it shows the exact lines and where they go.
choose, if/then/else, repeat and parallel blocks indented the way the editor shows them.The pane only reads. It never changes anything in your home itself; every change is Claude's own tool call, which you see and approve as usual.
The pane follows Claude's work through the Home Assistant MCP server, @vortitron/home-assistant-mcp. Connect that to Claude Code before (or after) installing the pane; until it is connected, the pane says so and shows these two ways in:
vome-connect, which asks for the key: /plugin install vome-connect --marketplace Vortitron/home-assistant-mcp
npx, pointed at your Home Assistant's address and a long-lived access token. See Install in the MCP's README.Check it's connected with /mcp; the pane picks it up by itself.
@vortitron/home-assistant-mcp connected to Claude Code. Any setup works: a home hosted on Vome, one linked through the Vome relay, or your own Home Assistant via HA_URL and a token. The server can have any name; the pane follows our tools whatever it is called. Other Home Assistant MCP servers are not supported; their tools and replies differ.claude in a terminal there.In Claude Code (2.1.275 or newer), one command adds the marketplace and installs the pane:
/plugin install vome-automation --marketplace Vortitron/home-assistant-mcp
On an older Claude Code, use two:
/plugin marketplace add Vortitron/home-assistant-mcp
/plugin install vome-automation@vome
Then run /reload-plugins, or restart Claude Code.
Claude Code doesn't update plugins from a marketplace like this one unless you ask it to. Turn it on once: run /plugin, open Marketplaces, select vome and choose Enable auto-update. Or update by hand with claude plugin update vome-automation@vome.
The pane reads the latest run of the automation in the background, and checks every 15 seconds for new ones while it watches. In auto mode, Claude Code's safety check refuses background calls like these, because no request of yours is behind them, and run marks and "it ran" notices would be missing.
Allow the read-only tools it uses, under your server's name. Find the name with claude mcp list: the Vome portal's snippet calls it vome, and vome-connect calls it plugin_vome-connect_vome (so mcp__plugin_vome-connect_vome__ha_get_trace). Add them to ~/.claude/settings.json (every project) or the project's .claude/settings.json:
{
"permissions": {
"allow": [
"mcp__vome__ha_get_automation",
"mcp__vome__ha_get_trace",
"mcp__vome__ha_list_traces",
"mcp__vome__ha_list_automations",
"mcp__vome__vomehome_get_instance"
]
}
}
If you skip this, the pane turns into a "needs permission" screen the first time a read is refused. It names the read, shows these exact lines for your server with a button to copy them, links your settings.json, and quotes what Claude Code said. d dismisses it and h brings it back.
Replace vome with your server's name, for example mcp__home-assistant__ha_get_trace for a server named home-assistant. The last line is only for homes on Vome: it lets the pane show an "Open in Home Assistant" link. You can also add each one with /permissions → Allow.
These tools only read: an automation's config and its recorded runs. Allowing them also lets Claude call them without asking, which it would mostly do anyway.
The pane opens on its own the first time Claude reads or saves an automation through the MCP. On a narrow terminal (under 144 columns) it waits; open it with /automation.
/automation | open the pane; with nothing shown yet, list the home's automations to pick from |
/automation list | list the home's automations to pick from |
/automation <id> | show an automation by its id or automation. entity id |
| r | refresh the automation and its latest run |
| o | pick another automation from the list |
| w | stop watching for runs, or start again (30 minutes) |
| b | the dot-matrix display off or on (remembered) |
The keys work while the pane has focus: click it, or press ctrl+x then tab.
/automation says where it is being drawn; "no surface is attached to draw it" means an editor extension. If /automation is an unknown command, the plugin didn't load: plugins of function hooks aren't enabled for you yet.vomehome_use_instance) while the pane showed an automation from the first. The pane stops rather than mixing two homes up; ask Claude to read the automation again.The pane talks only to your own MCP server, with the same connection Claude Code already has, and only to read. It keeps what it shows in the Claude Code session; the only thing stored between sessions is whether you turned the display off. Nothing is sent anywhere else.
The hooks module is hooks/register.tsx. hooks/flow.ts turns an automation and a run into the pane's rows, and hooks/bulbs.ts draws the display; both are pure. Load a checkout with claude --plugin-dir claude-plugin/vome-automation (saving a file reloads it), and run the tests with claude plugin test claude-plugin/vome-automation. claude plugin validate checks the plugin and the marketplace at the repository root.
hooks/register.tsx 1009 lines1// A side pane for the Home Assistant automation being worked on over Vome.
2//
3// It follows the model's own vome MCP calls (reading, saving and tracing an
4// automation) and draws the automation as Home Assistant's editor nests it:
5// triggers, conditions, actions. After a save it marks what changed and
6// watches for the next run, then marks which steps that run took and where it
7// stopped. Its own calls to Home Assistant are reads only; it never writes to
8// a home. On the terminal a dot-matrix interstitial acts out what just
9// happened (a light switched, a save, a run that stopped); b turns it off.
10
11import type { ElementTable, EngineInterface, Register } from 'claude-code'
12
13import type { AutomationConfig, AutomationView, Flash, Interstitial, Lamp, Pending, RunView, Watch } from '../types'
14import { frame, FX_ROWS, SCENE_MS, swim, SWIM_ROWS } from './bulbs'
15import { buildRows, compact, countChanges, describePath, isObj, parseReply, parseRun, parseStamp } from './flow'
16import type { Row, Tone } from './flow'
17
18const PANE = 'vome-automation'
19/** The server `/automation <id>` reads from before anything has been seen: the portal's name for it. */
20const DEFAULT_SERVER = 'vome'
21/** The tools of ours the pane follows, whatever the server is called. */
22const FOLLOWED = new Set(['ha_get_automation', 'ha_set_automation', 'ha_get_trace', 'ha_trigger_automation', 'ha_call_service'])
23
24/**
25 * How long the pane watches for runs after an automation is read, refreshed or saved. Watching
26 * was opt-in (w), and nobody found the key, so runs never appeared; now it is the default.
27 */
28const WATCH_AFTER_SAVE_MS = 30 * 60_000
29/** How long a manual trigger keeps it watching. */
30const WATCH_AFTER_TRIGGER_MS = 2 * 60_000
31const POLL_RUNS_MS = 15_000
32const TICK_MS = 3_000
33/** The swim stops this long after the last call about the automation, even mid-turn. */
34const BUILD_IDLE_MS = 90_000
35/** The interstitial's frame interval: 25 a second. */
36const FRAME_MS = 40
37/** A lit row fades in three steps over three seconds. */
38const FLASH_STEP_MS = 1_000
39const FLASH_COLOURS: Record<Flash['kind'], string[]> = {
40 change: ['#806000', '#4d3a00', '#261d00'],
41 run: ['#006b3c', '#00402a', '#002015'],
42}
43
44const view = { plugin: 'vome-automation', key: 'view' } as const
45const run = { plugin: 'vome-automation', key: 'run' } as const
46const watch = { plugin: 'vome-automation', key: 'watch' } as const
47const pending = { plugin: 'vome-automation', key: 'pending' } as const
48const note = { plugin: 'vome-automation', key: 'note' } as const
49const known = { plugin: 'vome-automation', key: 'known' } as const
50const haUrls = { plugin: 'vome-automation', key: 'haUrls' } as const
51const flash = { plugin: 'vome-automation', key: 'flash' } as const
52const fxOn = { plugin: 'vome-automation', key: 'fxOn' } as const
53const fx = { plugin: 'vome-automation', key: 'fx' } as const
54const sibling = { plugin: 'vome-automation', key: 'sibling' } as const
55const ESPHOME_INSTALL = '/plugin install vome-esphome --marketplace Vortitron/home-assistant-mcp'
56/** vome-esphome joined the chain after this plugin, so its hint is not needed (it cannot see one loaded before it). */
57let hasSibling = false
58const building = { plugin: 'vome-automation', key: 'building' } as const
59const blocked = { plugin: 'vome-automation', key: 'blocked' } as const
60const servers = { plugin: 'vome-automation', key: 'servers' } as const
61const choices = { plugin: 'vome-automation', key: 'choices' } as const
62
63/** Where someone with no Home Assistant connected yet goes next. */
64const VOME_TOKENS_URL = 'https://vome.io/account/api-tokens'
65const OWN_HA_URL = 'https://github.com/Vortitron/home-assistant-mcp#install'
66/** Opens the person's own Home Assistant and adds the Vome app's repository (the portal's link too). */
67const ADD_APP_URL =
68 'https://my.home-assistant.io/redirect/supervisor_add_addon_repository/?repository_url=https%3A%2F%2Fgithub.com%2FVortitron%2FVomeSync'
69const CONNECT_COMMAND = '/plugin install vome-connect --marketplace Vortitron/home-assistant-mcp'
70const CONNECT_README_URL = 'https://github.com/Vortitron/home-assistant-mcp/tree/main/claude-plugin/vome-connect'
71
72/** The pane's background reads; in auto mode each needs an allow rule (README: "Allow the pane's reads"). */
73const READ_TOOLS = ['ha_get_automation', 'ha_get_trace', 'ha_list_traces', 'ha_list_automations', 'vomehome_get_instance']
74const README_URL = 'https://github.com/Vortitron/home-assistant-mcp/tree/main/claude-plugin/vome-automation#allow-the-panes-reads-auto-mode'
75
76// Module variables reset on a reload, which is all these need.
77let isBusy = false
78let lastPoll = 0
79let hasToldNarrow = false
80let lastLogged = ''
81let isBlocked = false
82/** Whether a server with our tools has been seen; once it has, the check stops. */
83let hasSeenServer = false
84/** The note line holds an error, which the next read that works should take away. */
85let isNoteAnError = false
86/** When the automation was last read or acted on; the swim ends BUILD_IDLE_MS after. */
87let buildingSince = 0
88/** Where this person's Claude Code settings live, for the link when auto mode refuses a read. */
89let settingsFile = '~/.claude/settings.json'
90// The animation's own bookkeeping: what plays, where it is mounted, the fade.
91let fxAt = 0
92let fxScene: Interstitial | null = null
93// Where the strip is mounted, and when nothing else plays, the swim it shows.
94let fxSite: { columns: number; isSwim: boolean } | null = null
95let isFxOn = true
96let isBlitting = false
97let flashAt = 0
98let lastFlashStep = 0
99
100type Stamp = { instance: string | null; home: string | null }
101type Reply = { body: ReturnType<typeof parseReply>; stamp: Stamp; isError: boolean; text: string }
102
103export const register: Register = on => {
104 on('plugin.register', ($, e, next) => {
105 if (e.name === 'vome-esphome') hasSibling = true
106 return next(e)
107 })
108
109 on('session.start', async ($, e, next) => {
110 await $.command.register({
111 name: 'automation',
112 description: 'Show a Home Assistant automation (via Vome) in a side pane',
113 argumentHint: '[automation id, entity_id, or "list"]',
114 })
115 const configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('HOME')) ?? '~'}/.claude`
116 settingsFile = `${configDir.replace(/\/+$/, '')}/settings.json`
117 $.clock.every(TICK_MS, () => void tick($))
118 isFxOn = (await $.store.get('fxOn')) !== false
119 // State outlives a reload, so a HooksError an older version wrote would sit in the note
120 // line forever: start clean, and let the next read say again if something is wrong.
121 await $.state.set(note, null)
122 await $.state.set(blocked, null)
123 await $.state.set(building, false)
124 await $.state.set(fxOn, isFxOn)
125 $.clock.every(FRAME_MS, () => void animate($))
126
127 return next(e)
128 })
129
130 on('command.run', { command: 'automation' }, async ($, e) => {
131 const wanted = e.args.trim()
132 const isList = wanted === 'list' || (!wanted && !(await $.state.get(view)).value)
133 // Nothing to show yet, or asked for: the home's automations to pick from.
134 if (isList) await $.state.set(pending, { list: true })
135 else if (wanted) await $.state.set(pending, { automation: wanted, run: true })
136 const opened = await $.ui.open({ id: PANE, title: 'Automation' })
137 const current = (await $.state.get(view)).value ?? null
138 // Say plainly when this surface will not draw the pane, rather than look like it did.
139 // A session with no surface attached reports every pane placed, so name the surfaces too.
140 const surfaces = await $.session.surfaces()
141 const drawnOn = surfaces.length > 0 ? `drawing on ${surfaces.join(', ')}` : 'no surface is attached to draw it'
142 const placement = opened.isPlaced ? ` (pane placed; ${drawnOn})` : ` The pane is not drawn here: ${opened.reason} (${drawnOn}).`
143
144 if (isList) return { text: `Listing the home's automations in the pane to pick from.${placement}` }
145 if (wanted) return { text: `Fetching ${wanted} into the automation pane.${placement}` }
146 if (current) return { text: `The automation pane shows "${current.alias}".${placement}` }
147 return {
148 text: `The automation pane fills when an automation is read or saved through the Home Assistant MCP, or with /automation <id>.${placement}`,
149 }
150 })
151
152 // Our MCP's tools, on whatever its server is called: `vome` from the portal, `home-assistant`
153 // from the README, a relay's own name. Tool matchers must be literals, so match here instead.
154 on('tool.call', async ($, e, next) => {
155 const ours = ourTool(e.tool)
156 // Claude is on an ESPHome device: say once that the ESPHome pane shows it, unless it is here or was waved off.
157 if (!ours && !hasSibling && /^mcp__.+__esphome_[a-z_]+$/.test(e.tool) && !(await $.state.get(sibling)).value) {
158 if (!(await isHintOff($))) await $.state.set(sibling, true)
159 }
160 if (!ours) return next(e)
161 const { server, name } = ours
162 const args = e as unknown as Record<string, unknown>
163
164 // The model saved one: diff it against what came before, then watch for its next run.
165 if (name === 'ha_set_automation') {
166 const id = typeof args.automation_id === 'string' ? args.automation_id : null
167 const config = isObj(args.config as never) ? (args.config as AutomationConfig) : null
168 if (!id || !config) return next(e)
169
170 const before = await baseline($, server, id)
171 const ran = await next(e)
172 if (ran.deny !== undefined || ran.isError) {
173 await $.state.set(note, `Save of ${id} failed: ${compact(stripStamp(ran.text ?? ran.deny ?? ''), 160)}`)
174 return ran
175 }
176
177 const stamp = parseStamp(ran.text ?? '')
178 const sameHome = before !== null && (before.instance === null || before.instance === stamp.instance)
179 const prev = sameHome ? before.config : {}
180 await show($, server, id, config, stamp, { prev })
181 await $.state.set(building, false)
182 buildingSince = 0
183 await $.state.set(run, null)
184 const changed = buildRows(config, null, prev).filter(row => row.change !== null)
185 await startFlash($, changed.map(row => row.path), 'change')
186 await play($, { kind: 'saved', caption: `saved · ${changed.length} line${changed.length === 1 ? '' : 's'} changed` })
187 await $.state.set(watch, { id, instance: stamp.instance, since: new Date().toISOString(), until: Date.now() + WATCH_AFTER_SAVE_MS })
188 lastPoll = 0
189 await openPane($)
190
191 return ran
192 }
193
194 const ran = await next(e)
195 if (ran.deny !== undefined || ran.isError) return ran
196 const text = ran.text ?? ''
197 if (buildingSince > 0) buildingSince = Date.now()
198
199 if (name === 'ha_get_automation') {
200 // The model read an automation: show it, and swim while it is worked on.
201 const body = parseReply(text)
202 if (body && isObj(body.config)) {
203 const id = typeof body.id === 'string' ? body.id : String(body.config.id ?? '')
204 if (id) {
205 await show($, server, id, body.config, parseStamp(text), null)
206 await $.state.set(pending, { run: true })
207 await $.state.set(building, true)
208 buildingSince = Date.now()
209 await openPane($)
210 }
211 }
212 } else if (name === 'ha_get_trace') {
213 // The model fetched a run of the automation on show: draw it too.
214 const current = (await $.state.get(view)).value ?? null
215 const body = parseReply(text)
216 if (current && body && current.server === server && body.item_id === current.id && isSameHome(current.instance, parseStamp(text).instance)) {
217 const parsed = parseRun(body)
218 if (parsed) await $.state.set(run, parsed)
219 }
220 } else if (name === 'ha_trigger_automation') {
221 // The model ran it by hand: watch briefly for that run.
222 const current = (await $.state.get(view)).value ?? null
223 if (current && current.server === server) {
224 const existing = (await $.state.get(watch)).value ?? null
225 await $.state.set(watch, {
226 id: current.id,
227 instance: current.instance,
228 since: new Date(Date.now() - 2_000).toISOString(),
229 until: Math.max(existing?.until ?? 0, Date.now() + WATCH_AFTER_TRIGGER_MS),
230 })
231 lastPoll = 0
232 }
233 } else if (name === 'ha_call_service') {
234 // The model switched a device by hand: act it out.
235 const domain = typeof args.domain === 'string' ? args.domain : ''
236 const isOn = SWITCHED[typeof args.service === 'string' ? args.service : '']
237 const target = (args.target ?? {}) as { entity_id?: unknown }
238 const data = (args.data ?? {}) as { entity_id?: unknown }
239 if (DEVICE_DOMAINS.has(domain) && isOn !== undefined) {
240 const lamps = entityIds(target.entity_id ?? data.entity_id).map(id => ({ label: friendly(id), isOn, domain }))
241 if (lamps.length > 0) await play($, { kind: 'switch', lamps })
242 }
243 }
244
245 return ran
246 })
247
248 // The turn is over: whatever was being built is done (or set aside), so the swimmers rest.
249 on('turn.complete', async ($, e, next) => {
250 const result = await next(e)
251 buildingSince = 0
252 if ((await $.state.get(building)).value) await $.state.set(building, false)
253
254 return result
255 })
256
257 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
258 const { Box, Text, Button, Link, Code, Markdown } = $.ui.resolve(e)
259 const current = (await $.state.get(view)).value ?? null
260 const latest = (await $.state.get(run)).value ?? null
261 const watching = (await $.state.get(watch)).value ?? null
262 const message = (await $.state.get(note)).value ?? null
263 const urls = (await $.state.get(haUrls)).value ?? {}
264 const lit = (await $.state.get(flash)).value ?? null
265 const isFxEnabled = (await $.state.get(fxOn)).value ?? true
266 const playing = (await $.state.get(fx)).value ?? null
267 const isBuilding = (await $.state.get(building)).value ?? false
268 const refusal = (await $.state.get(blocked)).value ?? null
269 const picking = (await $.state.get(choices)).value ?? null
270 const isSiblingHint = !hasSibling && ((await $.state.get(sibling)).value ?? false)
271 const siblingHint = isSiblingHint ? (
272 <Box flexDirection="row" gap={2} marginTop={1}>
273 <Text dimColor wrap="wrap">
274 Working on ESPHome too? The ESPHome pane shows the device and its builds: <Text color="suggestion">{ESPHOME_INSTALL}</Text>
275 </Text>
276 <Button
277 key="hide-sibling"
278 dimColor
279 onPress={async () => {
280 await $.store.set('siblingHintOff', true).catch(() => undefined)
281 await $.state.set(sibling, false)
282 }}
283 >
284 Hide
285 </Button>
286 </Box>
287 ) : null
288
289 // The home's automations to pick one from (/automation with nothing shown, or o). The phone app
290 // has no Select, so there they are buttons, the first dozen.
291 let pickerList = null
292 if (picking) {
293 const pick = (id: string) => $.state.set(pending, { automation: id, run: true, server: picking.server })
294 if (e.surface === 'mobile') {
295 pickerList = picking.automations.slice(0, 12).map(item => (
296 <Button key={`pick-${item.id}`} plain onPress={() => pick(item.id)}>
297 {item.alias}
298 </Button>
299 ))
300 } else {
301 const { Select } = $.ui.resolve(e)
302 pickerList = (
303 <Select
304 key="pick"
305 label="Automation"
306 autoFocus
307 options={picking.automations.map(item => ({ value: item.id, label: `${item.isOn ? '●' : '○'} ${item.alias}` }))}
308 onSelect={value => pick(value)}
309 />
310 )
311 }
312 }
313 const picker = picking ? (
314 <Box flexDirection="column" marginTop={1}>
315 <Text bold>Pick an automation ({picking.automations.length})</Text>
316 {pickerList}
317 <Button key="close-picker" dimColor onPress={() => $.state.set(choices, null)}>
318 Close the list
319 </Button>
320 </Box>
321 ) : null
322
323 // A refused read takes the whole pane until dismissed: what to add and where, over everything,
324 // rather than a HooksError in the note line that names neither.
325 if (refusal && !refusal.isDismissed) {
326 fxSite = null
327 const rules = READ_TOOLS.map(tool => `"mcp__${refusal.server}__${tool}"`).join(',\n')
328 return (
329 <Box flexDirection="column" borderStyle="round" borderColor="warning" paddingX={1}>
330 <Text bold color="warning">
331 The automation pane needs permission to read
332 </Text>
333 <Text wrap="wrap">
334 Auto mode refused its background read of {refusal.tool} on the {refusal.server} server. Claude Code refuses
335 reads nobody asked for unless they are allowed by name.
336 </Text>
337 <Box marginTop={1}>
338 <Text wrap="wrap">Add these lines to permissions.allow in</Text>
339 </Box>
340 <Markdown text={`[${settingsFile}](file://${settingsFile})`} />
341 <Code source={rules} />
342 <Text dimColor wrap="wrap">
343 Or run /permissions and choose Allow for each. They only read: an automation and its recorded runs.
344 </Text>
345 <Box marginTop={1}>
346 <Text dimColor wrap="wrap">
347 Claude Code said: {compact(refusal.error, 300)}
348 </Text>
349 </Box>
350 <Box flexDirection="row" gap={2} marginTop={1}>
351 <Button
352 key="copy-rules"
353 hotkey="c"
354 variant="primary"
355 autoFocus
356 onPress={press => $.ui.copy({ text: rules, surface: press.surface })}
357 >
358 Copy the lines
359 </Button>
360 <Button
361 key="retry"
362 hotkey="r"
363 onPress={async () => {
364 isBlocked = false
365 await $.state.set(blocked, null)
366 await $.state.set(note, null)
367 await $.state.set(pending, { run: true })
368 }}
369 >
370 Try again
371 </Button>
372 <Button key="dismiss" hotkey="d" role="dismiss" onPress={() => $.state.set(blocked, { ...refusal, isDismissed: true })}>
373 Dismiss
374 </Button>
375 </Box>
376 <Link href={README_URL}>Why the pane needs these</Link>
377 </Box>
378 )
379 }
380 // Dismissed: the pane as usual, its note line saying how to get the help back.
381 const showHelp = refusal ? (
382 <Button key="help" hotkey="h" dimColor onPress={() => $.state.set(blocked, { ...refusal, isDismissed: false })}>
383 What to allow
384 </Button>
385 ) : null
386
387 // While an interstitial plays it takes the header's first four lines; the swim (the intro, or
388 // while an automation is worked on) takes all six. Same heights, so nothing below moves.
389 // Both are Rasters, which only the terminal draws; elsewhere the pane goes without.
390 const isSwimming = !current || isBuilding
391 let sceneStrip = null
392 let swimStrip = null
393 if (e.surface === 'terminal' && isFxEnabled && (playing || isSwimming)) {
394 const { Raster } = $.ui.resolve(e)
395 const columns = Math.max(10, Math.min(512, e.props.bodyColumns))
396 fxSite = { columns, isSwim: !playing }
397 if (playing) {
398 sceneStrip = <Raster key="bulbs" columns={columns} rows={FX_ROWS} cells={frame(Date.now() - playing.at, playing.scene, columns)} />
399 } else {
400 swimStrip = <Raster key="bulbs" columns={columns} rows={SWIM_ROWS} cells={swim(Date.now(), columns)} />
401 }
402 } else {
403 fxSite = null
404 }
405
406 const connected = (await $.state.get(servers)).value ?? null
407 if (!current && connected !== null && connected.length === 0) {
408 return (
409 <Box flexDirection="column">
410 {swimStrip ?? sceneStrip}
411 <Text bold>Connect Home Assistant first</Text>
412 <Text wrap="wrap">
413 This pane follows Claude's work through the Home Assistant MCP (@vortitron/home-assistant-mcp), and nothing
414 connected in this session has its tools.
415 </Text>
416 <Box flexDirection="column" marginTop={1}>
417 <Text bold>Through Vome: one key, nothing to run</Text>
418 <Text wrap="wrap">
419 1. Get a key. Home Assistant on your own hardware: the Vome app's Agent tab, no sign-up needed. A home on
420 Vome: vome.io, Account → API tokens.
421 </Text>
422 <Link href={ADD_APP_URL}>Add the Vome app to Home Assistant</Link>
423 <Text wrap="wrap">2. Run this; it asks for the key:</Text>
424 <Code source={CONNECT_COMMAND} />
425 <Box flexDirection="row" gap={2}>
426 <Button key="copy-connect" hotkey="c" onPress={press => $.ui.copy({ text: CONNECT_COMMAND, surface: press.surface })}>
427 Copy the command
428 </Button>
429 <Link href={VOME_TOKENS_URL}>vome.io → API tokens</Link>
430 <Link href={CONNECT_README_URL}>About vome-connect</Link>
431 </Box>
432 </Box>
433 <Box flexDirection="column" marginTop={1}>
434 <Text bold>Without Vome</Text>
435 <Text wrap="wrap">Run the same MCP on your machine with npx, pointed at your Home Assistant and a token.</Text>
436 <Link href={OWN_HA_URL}>How to set it up</Link>
437 </Box>
438 <Box marginTop={1}>
439 <Text dimColor wrap="wrap">
440 Then run /reload-plugins. Once it's connected (check with /mcp) this goes by itself; ask Claude about an
441 automation to fill the pane.
442 </Text>
443 </Box>
444 </Box>
445 )
446 }
447
448 if (!current) {
449 return (
450 <Box flexDirection="column">
451 {swimStrip ?? sceneStrip}
452 <Text dimColor>No automation yet. It appears here when one is read or saved through Vome, or pick one with /automation.</Text>
453 {message ? <Text color="warning">{message}</Text> : null}
454 {picker}
455 {showHelp}
456 {siblingHint}
457 </Box>
458 )
459 }
460
461 const isRunBeforeSave = latest !== null && current.savedAt !== null && toMs(latest.started) < toMs(current.savedAt)
462 const rows = buildRows(current.config, isRunBeforeSave ? null : latest, current.prev)
463 const changes = countChanges(rows)
464 const mode = typeof current.config.mode === 'string' ? current.config.mode : 'single'
465 const url = current.instance ? urls[current.instance] : undefined
466 const outcome = latest ? describeRun(latest) : null
467 const step = lit ? Math.floor((Date.now() - lit.at) / FLASH_STEP_MS) : -1
468 const litColour = lit ? FLASH_COLOURS[lit.kind][step] : undefined
469 const litPaths = new Set(litColour && lit ? lit.paths : [])
470
471 return (
472 <Box flexDirection="column">
473 {/* Six header lines, always there, so a change rewrites a line instead of moving the tree.
474 The swim takes all six while it plays; an interstitial the first four. */}
475 {swimStrip ?? (
476 <Box flexDirection="column">
477 {sceneStrip ?? (
478 <Box flexDirection="column">
479 <Text bold wrap="truncate-end">{current.alias}</Text>
480 <Text dimColor wrap="truncate-end">
481 {[current.id, mode, current.home].filter(Boolean).join(' · ')}
482 </Text>
483
484 {current.savedAt ? (
485 <Text color="warning" wrap="truncate-end">
486 Saved {clock(current.savedAt)}
487 {current.prev ? ` · ${changes.added} added, ${changes.changed} changed, ${changes.removed} removed` : ''}
488 </Text>
489 ) : (
490 <Text dimColor>Not changed this session.</Text>
491 )}
492
493 {latest && outcome ? (
494 <Text wrap="truncate-end">
495 <Text color={toneColour(isRunBeforeSave ? 'skipped' : outcome.tone)}>
496 Last run {clock(latest.started)} · {outcome.text}
497 </Text>
498 {latest.trigger ? <Text dimColor> · {latest.trigger}</Text> : null}
499 {isRunBeforeSave ? <Text dimColor> (before the save, so not marked below)</Text> : null}
500 </Text>
501 ) : (
502 <Text dimColor>No run shown yet.</Text>
503 )}
504 </Box>
505 )}
506
507 <Text dimColor wrap="truncate-end">
508 {watching ? `Watching for a new run until ${clock(new Date(watching.until).toISOString())}.` : 'Not watching for runs.'}
509 </Text>
510 <Text color="warning" wrap="truncate-end">
511 {message ?? ' '}
512 </Text>
513 </Box>
514 )}
515
516 <Box flexDirection="column" marginTop={1}>
517 {rows.map(row => drawRow(Text, row, litPaths.has(row.path) ? litColour : undefined))}
518 </Box>
519
520 <Box flexDirection="row" gap={2} marginTop={1}>
521 <Button key="refresh" hotkey="r" onPress={() => $.state.set(pending, { automation: current.id, run: true })}>
522 Refresh
523 </Button>
524 <Button
525 key="watch"
526 hotkey="w"
527 onPress={() =>
528 $.state.set(
529 watch,
530 watching
531 ? null
532 : {
533 id: current.id,
534 instance: current.instance,
535 since: new Date().toISOString(),
536 until: Date.now() + WATCH_AFTER_SAVE_MS,
537 },
538 )
539 }
540 >
541 {watching ? 'Stop watching' : 'Watch for runs'}
542 </Button>
543 {showHelp}
544 <Button key="other" hotkey="o" dimColor onPress={() => $.state.set(pending, { list: true, server: current.server })}>
545 Other automation
546 </Button>
547 <Button
548 key="bulbs"
549 hotkey="b"
550 dimColor
551 onPress={async () => {
552 const isOn = !isFxEnabled
553 isFxOn = isOn
554 if (!isOn) fxSite = null
555 await $.store.set('fxOn', isOn)
556 await $.state.set(fxOn, isOn)
557 }}
558 >
559 {isFxEnabled ? 'Bulbs off' : 'Bulbs on'}
560 </Button>
561 {url ? <Link href={`${url}/config/automation/edit/${encodeURIComponent(current.id)}`}>Open in Home Assistant</Link> : null}
562 </Box>
563 <Text dimColor>● ran ✗ false or error ○ not reached + added ~ changed − removed</Text>
564 {picker}
565 {siblingHint}
566 </Box>
567 )
568 })
569}
570
571// ---------------------------------------------------------------- drawing
572
573function drawRow(T: ElementTable['Text'], row: Row, highlight: string | undefined) {
574 if (row.isSection) {
575 return (
576 <T bold color="suggestion">
577 {row.text.toUpperCase()}
578 </T>
579 )
580 }
581 const isStruck = row.change === '-' || row.tone === 'disabled'
582 return (
583 <T wrap="truncate-end" backgroundColor={highlight}>
584 <T color={changeColour(row.change)}>{row.change === '-' ? '−' : row.change ?? ' '}</T>
585 {' '.repeat(row.depth)}
586 <T color={toneColour(row.tone)}>{toneGlyph(row.tone)} </T>
587 <T dimColor={row.tone === 'skipped' || row.tone === 'disabled'} strikethrough={isStruck}>
588 {row.text}
589 </T>
590 {row.note ? <T dimColor>{` ${row.note}`}</T> : null}
591 </T>
592 )
593}
594
595function toneGlyph(tone: Tone): string {
596 return { plain: '·', ran: '●', false: '✗', error: '✗', skipped: '○', disabled: '⊘' }[tone]
597}
598
599function toneColour(tone: Tone): string | undefined {
600 return { plain: undefined, ran: 'success', false: 'warning', error: 'error', skipped: undefined, disabled: undefined }[tone]
601}
602
603function changeColour(change: Row['change']): string | undefined {
604 return change === '+' ? 'success' : change === '~' ? 'warning' : change === '-' ? 'error' : undefined
605}
606
607function describeRun(r: RunView): { text: string; tone: Tone } {
608 if (r.error || (r.failedAt && /error/.test(r.failedAt.reason))) {
609 const where = r.failedAt ? ` at ${describePath(r.failedAt.path)}` : ''
610 return { text: `error${where}${r.error ? `: ${r.error}` : ''}`, tone: 'error' }
611 }
612 if (r.failedAt) return { text: `stopped: ${describePath(r.failedAt.path)} was false`, tone: 'false' }
613 return { text: r.execution ?? r.state ?? 'ran', tone: 'ran' }
614}
615
616/** What a run acts out: the lights it switched, else how it stopped, else that it ran. */
617function sceneForRun(r: RunView): Interstitial {
618 const outcome = describeRun(r)
619 if (outcome.tone === 'error') return { kind: 'error', caption: outcome.text }
620 if (outcome.tone === 'false') return { kind: 'stopped', caption: outcome.text }
621 const lamps: Lamp[] = []
622 let firstService: string | null = null
623 for (const step of r.steps) {
624 const params = serviceParams(step.result)
625 if (!params) continue
626 firstService ??= `${params.domain}.${params.service}`
627 const isOn = SWITCHED[params.service]
628 if (!DEVICE_DOMAINS.has(params.domain) || isOn === undefined) continue
629 for (const id of params.entities) lamps.push({ label: friendly(id), isOn, domain: params.domain })
630 }
631 if (lamps.length > 0) return { kind: 'switch', lamps }
632 return { kind: 'ran', caption: `ran · ${firstService ?? outcome.text}` }
633}
634
635/** The domains a run or call acts out as a device switching. */
636const DEVICE_DOMAINS = new Set(['light', 'switch', 'input_boolean', 'fan', 'cover', 'climate', 'water_heater', 'lock', 'media_player'])
637/** Which way a service sends a device ("on" is open for a cover, locked for a lock); a toggle shows as on. */
638const SWITCHED: Record<string, boolean> = {
639 turn_on: true,
640 turn_off: false,
641 toggle: true,
642 open_cover: true,
643 close_cover: false,
644 lock: true,
645 unlock: false,
646 media_play: true,
647 media_pause: false,
648 media_stop: false,
649}
650
651/** A trace step's service call, from its result: `{"params":{"domain","service","target":{...}}}`. */
652function serviceParams(result: string | undefined): { domain: string; service: string; entities: string[] } | null {
653 if (!result) return null
654 try {
655 const parsed = JSON.parse(result) as { params?: { domain?: unknown; service?: unknown; target?: { entity_id?: unknown }; service_data?: { entity_id?: unknown } } }
656 const params = parsed.params
657 if (!params || typeof params.domain !== 'string' || typeof params.service !== 'string') return null
658 return { domain: params.domain, service: params.service, entities: entityIds(params.target?.entity_id ?? params.service_data?.entity_id) }
659 } catch {
660 return null
661 }
662}
663
664function entityIds(value: unknown): string[] {
665 const all = Array.isArray(value) ? value : [value]
666 return all.filter((id): id is string => typeof id === 'string' && id.includes('.'))
667}
668
669/** `light.living_room` → "living room". */
670function friendly(entityId: string): string {
671 return (entityId.split('.')[1] ?? entityId).replace(/_/g, ' ')
672}
673
674// ---------------------------------------------------------------- state
675
676async function show(
677 $: EngineInterface,
678 server: string,
679 id: string,
680 config: AutomationConfig,
681 stamp: Stamp,
682 saved: { prev: AutomationConfig } | null,
683) {
684 const all = (await $.state.get(known)).value ?? {}
685 await $.state.set(known, { ...all, [id]: { instance: stamp.instance, config } })
686 const current = (await $.state.get(view)).value ?? null
687 const alias = typeof config.alias === 'string' ? config.alias : id
688 // Re-reading what was just saved keeps the diff rather than wiping it.
689 const isSameSave =
690 !saved &&
691 current !== null &&
692 current.server === server &&
693 current.id === id &&
694 current.instance === stamp.instance &&
695 JSON.stringify(current.config) === JSON.stringify(config)
696 const next: AutomationView = isSameSave
697 ? { ...current, alias, home: stamp.home ?? current.home }
698 : {
699 server,
700 id,
701 alias,
702 config,
703 prev: saved ? saved.prev : null,
704 savedAt: saved ? new Date().toISOString() : null,
705 instance: stamp.instance,
706 home: stamp.home,
707 }
708 await $.state.set(view, next)
709 await $.state.set(note, null)
710 await $.state.set(choices, null)
711
712 // Watch for its runs by default: a fresh watch for a new automation, a longer one for the same.
713 const watching = (await $.state.get(watch)).value ?? null
714 const isSameWatch = watching !== null && watching.id === id && watching.instance === stamp.instance
715 await $.state.set(watch, {
716 id,
717 instance: stamp.instance,
718 since: isSameWatch ? watching.since : new Date().toISOString(),
719 until: Date.now() + WATCH_AFTER_SAVE_MS,
720 })
721 lastPoll = 0
722}
723
724/** The config a save replaces: the last one seen, else read live before the save lands. */
725async function baseline($: EngineInterface, server: string, id: string): Promise<{ instance: string | null; config: AutomationConfig } | null> {
726 const seen = ((await $.state.get(known)).value ?? {})[id]
727 if (seen) return seen
728 const reply = await callVome($, server, 'ha_get_automation', { automation: id })
729 if (reply.body && isObj(reply.body.config)) return { instance: reply.stamp.instance, config: reply.body.config }
730 // Not found: a new automation, so everything in it is added.
731 return reply.isError ? { instance: reply.stamp.instance, config: {} } : null
732}
733
734async function openPane($: EngineInterface) {
735 // The view is already in state; a surface that cannot open panes must not lose it.
736 const opened = await $.ui.open({ id: PANE, title: 'Automation' }).catch((error: unknown) => ({
737 isPlaced: false as const,
738 reason: String(error),
739 }))
740 if (!opened.isPlaced && !hasToldNarrow) {
741 hasToldNarrow = true
742 $.ui.log(`vome-automation: the pane is not drawn here (${opened.reason}); /automation tries again.`)
743 }
744}
745
746// ---------------------------------------------------------------- animation
747
748async function play($: EngineInterface, scene: Interstitial) {
749 fxAt = Date.now()
750 fxScene = scene
751 await $.state.set(fx, { at: fxAt, scene })
752}
753
754async function startFlash($: EngineInterface, paths: string[], kind: Flash['kind']) {
755 flashAt = Date.now()
756 lastFlashStep = 0
757 await $.state.set(flash, { at: flashAt, paths, kind })
758}
759
760/** Every frame: step the highlight's fade, and repaint the bulbs where the strip is mounted. */
761async function animate($: EngineInterface) {
762 const now = Date.now()
763 // A long turn about something else should not leave the swimmers over the header.
764 if (buildingSince > 0 && now - buildingSince > BUILD_IDLE_MS) {
765 buildingSince = 0
766 await $.state.set(building, false)
767 }
768 if (flashAt > 0) {
769 const step = Math.floor((now - flashAt) / FLASH_STEP_MS)
770 if (step >= FLASH_COLOURS.change.length) {
771 flashAt = 0
772 await $.state.set(flash, null)
773 } else if (step !== lastFlashStep) {
774 lastFlashStep = step
775 $.ui.invalidate('ui.render')
776 }
777 }
778 if (fxAt > 0 && now - fxAt > SCENE_MS) {
779 fxAt = 0
780 fxScene = null
781 fxSite = null
782 await $.state.set(fx, null)
783 }
784
785 if (!isFxOn || !fxSite || isBlitting) return
786 const cells = fxScene ? frame(now - fxAt, fxScene, fxSite.columns) : fxSite.isSwim ? swim(now, fxSite.columns) : null
787 if (!cells) return
788 isBlitting = true
789 try {
790 const result = await $.ui.blit({ requestId: PANE, key: 'bulbs', cells })
791 // Not mounted (the pane closed, or redrawn at another size): wait for the next drawing.
792 if (result.deny !== undefined) fxSite = null
793 } finally {
794 isBlitting = false
795 }
796}
797
798// ---------------------------------------------------------------- polling
799
800/**
801 * Which connected MCP servers carry our tools. Someone who installs the pane before connecting
802 * Home Assistant otherwise sees a pane that waits forever for an automation; with none, it tells
803 * them how to connect instead. Servers connect after the session starts, so this runs each tick
804 * until one is seen.
805 */
806async function lookForServers($: EngineInterface) {
807 const names = (await $.tool.list())
808 .map(tool => ourTool(tool.name))
809 .filter((found): found is { server: string; name: string } => found !== null && found.name === 'ha_get_automation')
810 .map(found => found.server)
811 hasSeenServer = names.length > 0
812 const before = (await $.state.get(servers)).value
813 if (!before || before.join() !== names.join()) await $.state.set(servers, names)
814}
815
816async function tick($: EngineInterface) {
817 if (isBusy) return
818 isBusy = true
819 try {
820 // Its own failure must not stop the reads below it: worst case, the connect screen waits.
821 if (!hasSeenServer) await lookForServers($).catch(() => undefined)
822 const want = (await $.state.get(pending)).value ?? null
823 if (want) {
824 await $.state.set(pending, null)
825 await fetchPending($, want)
826 }
827
828 const watching = (await $.state.get(watch)).value ?? null
829 if (watching) {
830 if (Date.now() > watching.until) await $.state.set(watch, null)
831 else if (Date.now() - lastPoll >= POLL_RUNS_MS) {
832 lastPoll = Date.now()
833 await pollRuns($, watching)
834 }
835 }
836
837 await lookUpHaUrl($)
838 } catch (error) {
839 // Already shown, with what to do about it, by callVome.
840 if (error instanceof RefusedRead) return
841 const text = `Vome: ${String(error)}`
842 // Once per distinct failure in full to the transcript; the pane keeps a short form.
843 if (text !== lastLogged) {
844 lastLogged = text
845 $.ui.log(text)
846 }
847 isNoteAnError = true
848 await $.state.set(note, compact(text, 400))
849 } finally {
850 isBusy = false
851 }
852}
853
854async function fetchPending($: EngineInterface, want: Pending) {
855 if (want.list) {
856 const server =
857 want.server ?? ((await $.state.get(view)).value ?? null)?.server ?? ((await $.state.get(servers)).value ?? [])[0] ?? DEFAULT_SERVER
858 const reply = await callVome($, server, 'ha_list_automations', {})
859 const listed = reply.body && Array.isArray(reply.body.automations) ? reply.body.automations.filter(isObj) : []
860 const automations = listed
861 .map(item => ({
862 id: typeof item.id === 'string' ? item.id : typeof item.entity_id === 'string' ? item.entity_id : '',
863 alias: typeof item.friendly_name === 'string' ? item.friendly_name : String(item.id ?? item.entity_id ?? '?'),
864 isOn: item.state === 'on',
865 }))
866 .filter(item => item.id)
867 .sort((a, b) => a.alias.localeCompare(b.alias))
868 if (automations.length === 0) {
869 await $.state.set(note, `No automations to pick from: ${compact(stripStamp(reply.text), 140)}`)
870 return
871 }
872 await $.state.set(choices, { server, automations })
873 await openPane($)
874 return
875 }
876
877 if (want.automation) {
878 const server = want.server ?? ((await $.state.get(view)).value ?? null)?.server ?? DEFAULT_SERVER
879 const reply = await callVome($, server, 'ha_get_automation', { automation: want.automation })
880 if (!reply.body || !isObj(reply.body.config)) {
881 await $.state.set(note, `Couldn't read ${want.automation}: ${compact(stripStamp(reply.text), 160)}`)
882 return
883 }
884 const id = typeof reply.body.id === 'string' ? reply.body.id : want.automation
885 await show($, server, id, reply.body.config, reply.stamp, null)
886 await openPane($)
887 }
888
889 if (want.run) {
890 const current = (await $.state.get(view)).value ?? null
891 if (!current) return
892 const reply = await callVome($, current.server, 'ha_get_trace', { item: current.id })
893 if (!isSameHome(current.instance, reply.stamp.instance)) {
894 await $.state.set(note, 'Vome now points at a different home than this automation came from.')
895 return
896 }
897 const parsed = reply.body ? parseRun(reply.body) : null
898 await $.state.set(run, parsed)
899 if (!parsed) await $.state.set(note, `No run to show: ${compact(stripStamp(reply.text), 140)}`)
900 }
901}
902
903async function pollRuns($: EngineInterface, watching: Watch) {
904 const current = (await $.state.get(view)).value ?? null
905 if (!current || current.id !== watching.id) {
906 await $.state.set(watch, null)
907 return
908 }
909
910 const reply = await callVome($, current.server, 'ha_list_traces', { item: watching.id, limit: 1 })
911 if (!isSameHome(watching.instance, reply.stamp.instance)) {
912 await $.state.set(watch, null)
913 await $.state.set(note, 'Vome now points at a different home; stopped watching.')
914 return
915 }
916 const newest = reply.body && Array.isArray(reply.body.traces) ? reply.body.traces.find(isObj) : undefined
917 if (!newest || typeof newest.run_id !== 'string' || typeof newest.started !== 'string') return
918 if (toMs(newest.started) < toMs(watching.since)) return
919 if (((await $.state.get(run)).value ?? null)?.runId === newest.run_id) return
920
921 const detail = await callVome($, current.server, 'ha_get_trace', { item: watching.id, run_id: newest.run_id })
922 const parsed = detail.body ? parseRun(detail.body) : null
923 if (!parsed) return
924 await $.state.set(run, parsed)
925 const outcome = describeRun(parsed)
926 const touched = buildRows(current.config, parsed, null).filter(
927 row => !row.isSection && (row.tone === 'ran' || row.tone === 'false' || row.tone === 'error'),
928 )
929 await startFlash($, touched.map(row => row.path), 'run')
930 await play($, sceneForRun(parsed))
931 $.ui.toast(`${current.alias}: ${outcome.text}`)
932}
933
934async function lookUpHaUrl($: EngineInterface) {
935 const current = (await $.state.get(view)).value ?? null
936 const instance = current?.instance
937 if (!instance || ((await $.state.get(haUrls)).value ?? {})[instance] !== undefined) return
938 // Only a server linked to Vome has this tool; any other answers no link, quietly.
939 const reply = await callVome($, current.server, 'vomehome_get_instance', { instance_id: instance }).catch(() => null)
940 const url = reply?.body && typeof reply.body.ha_url === 'string' ? reply.body.ha_url : ''
941 const all = (await $.state.get(haUrls)).value ?? {}
942 await $.state.set(haUrls, { ...all, [instance]: url.startsWith('https://') ? url.replace(/\/+$/, '') : '' })
943}
944
945/** A background read auto mode refused; the pane already shows what to allow. */
946class RefusedRead extends Error {}
947
948async function callVome($: EngineInterface, server: string, tool: string, args: Record<string, unknown>): Promise<Reply> {
949 let result
950 try {
951 result = await $.mcp.call(server, tool, args)
952 } catch (error) {
953 // Auto mode refuses a background read nobody asked for unless it is allowed by name. Say so
954 // where it shows, with the exact lines for this server, rather than as a raw HooksError. Its
955 // wording differs between builds, so only "auto mode" is relied on; server and tool are ours.
956 if (/auto mode|classifier|permission|denied|not allowed/i.test(String(error))) {
957 isBlocked = true
958 await $.state.set(blocked, { server, tool, error: String(error), isDismissed: false })
959 await $.state.set(note, `A read of ${tool} was refused: press h for what to allow.`)
960 throw new RefusedRead(tool)
961 }
962 throw new Error(`${tool}: ${String(error)}`)
963 }
964 if (isBlocked || isNoteAnError) {
965 isBlocked = false
966 isNoteAnError = false
967 await $.state.set(blocked, null)
968 await $.state.set(note, null)
969 }
970 const text = result.content.map(block => block.text ?? '').join('\n')
971 return { body: result.isError ? null : parseReply(text), stamp: parseStamp(text), isError: result.isError, text }
972}
973
974// ---------------------------------------------------------------- small things
975
976/** `mcp__<server>__<tool>` for a tool the pane follows, as its server and name; else null. */
977function ourTool(tool: string): { server: string; name: string } | null {
978 const match = /^mcp__(.+)__([a-z_]+)$/.exec(tool)
979 return match && match[1] && match[2] && FOLLOWED.has(match[2]) ? { server: match[1], name: match[2] } : null
980}
981
982function isSameHome(expected: string | null, actual: string | null): boolean {
983 return expected === null || actual === null || expected === actual
984}
985
986function stripStamp(text: string): string {
987 return text.replace(/\[vome-instance\][^\n]*/g, '').trim()
988}
989
990/** HA writes microseconds; keep milliseconds so every engine parses it. */
991function toMs(iso: string): number {
992 return Date.parse(iso.replace(/(\.\d{3})\d+/, '$1')) || 0
993}
994
995function clock(iso: string): string {
996 const at = new Date(toMs(iso))
997 const pad = (n: number) => String(n).padStart(2, '0')
998 return `${pad(at.getHours())}:${pad(at.getMinutes())}:${pad(at.getSeconds())}`
999}
1000
1001/** Whether the person waved the other pane's hint off; a store that cannot answer means no. */
1002async function isHintOff($: EngineInterface): Promise<boolean> {
1003 try {
1004 return (await $.store.get('siblingHintOff')) === true
1005 } catch {
1006 return false
1007 }
1008}
1009hooks/bulbs.ts 503 lines1// The dot-matrix display, in the manner of an early-2000s car stereo.
2//
3// Interstitials act out what just happened: a device switched (a bulb lights,
4// a fan spins up, a blind opens, a lock snaps shut), a save screws a bulb in,
5// a run stopped by a condition flickers and stays dark, an error pops. The
6// swim plays between them: the automation's own devices swim through dotted
7// water and leap like the dolphins did, as an intro and while one is built.
8// Pure: one frame per call, as Raster cells.
9//
10// Dots are braille cells (2 x 4 dots each, one colour per cell); the last
11// row is a line of text.
12
13import type { Interstitial, Lamp } from '../types'
14
15/** How long one interstitial plays. */
16export const SCENE_MS = 3200
17/** Rows of cells: three of dots, then the caption. */
18export const FX_ROWS = 4
19
20const PANEL = 0x0b1020
21const LIT = 0xffd75e
22const HALO = 0xffb43c
23const OUTLINE = 0x7c8594
24const BASE = 0x9aa0a8
25const SPARK = 0xff8a2b
26const GREEN = 0x30d158
27const AMBER = 0xffd60a
28const RED = 0xff453a
29const QUIET = 0x8e96a3
30const CREST = 0x64d2ff
31const STAR = 0x3b4a6b
32
33// ---------------------------------------------------------------- devices
34//
35// Each sprite is 7 x 12 dots: rows 1-7 the body (two cell rows), 8-11 the
36// base (a third), row 0 clear so body and base never share a cell's colour.
37// # outline: grey when off, the device's colour when on
38// o fill, shown dot by dot as it comes on
39// * accent, shown only when on
40// = base, always grey
41// A device with several frames animates through them while on.
42
43type Sprite = { colour: number; accent: number; frames: string[][]; rate: number }
44
45const DEVICES: Record<string, Sprite> = {
46 light: {
47 colour: LIT,
48 accent: LIT,
49 rate: 0,
50 frames: [['.......', '..###..', '.#ooo#.', '#ooooo#', '#ooooo#', '#ooooo#', '.#ooo#.', '..#o#..', '..===..', '...=...', '..===..', '...=...']],
51 },
52 switch: {
53 colour: GREEN,
54 accent: GREEN,
55 rate: 0,
56 frames: [['.......', '.#####.', '#.....#', '#.#.#.#', '#.#.#.#', '#.....#', '#..*..#', '.#####.', '...=...', '...=...', '...=...', '..===..']],
57 },
58 fan: {
59 colour: CREST,
60 accent: 0xffffff,
61 rate: 12,
62 frames: [
63 ['.......', '...#...', '...#...', '...#...', '###*###', '...#...', '...#...', '...#...', '...=...', '...=...', '..===..', '.=====.'],
64 ['.......', '#.....#', '.#...#.', '..#.#..', '...*...', '..#.#..', '.#...#.', '#.....#', '...=...', '...=...', '..===..', '.=====.'],
65 ],
66 },
67 cover: {
68 colour: AMBER,
69 accent: LIT,
70 rate: 0,
71 // Off: the blind down. On: the blind up and the sun in.
72 frames: [
73 ['.......', '#######', '#=====#', '#.....#', '#=====#', '#.....#', '#=====#', '#######', '.......', '.......', '.......', '.......'],
74 ['.......', '#######', '#=====#', '#=====#', '#..*..#', '#.***.#', '#..*..#', '#######', '.......', '.......', '.......', '.......'],
75 ],
76 },
77 climate: {
78 colour: 0xff6b3d,
79 accent: 0xff6b3d,
80 rate: 0,
81 frames: [['.......', '...#...', '..#.#..', '..#o#..', '..#o#..', '..#o#..', '..#o#..', '..#o#..', '.#ooo#.', '.#ooo#.', '..###..', '.......']],
82 },
83 lock: {
84 colour: GREEN,
85 accent: GREEN,
86 rate: 0,
87 // Off: unlocked, the shackle up. On: locked.
88 frames: [
89 ['..###..', '.#...#.', '.#.....', '.#.....', '#######', '#..*..#', '#..*..#', '#######', '.......', '.......', '.......', '.......'],
90 ['.......', '..###..', '.#...#.', '.#...#.', '#######', '#..*..#', '#..*..#', '#######', '.......', '.......', '.......', '.......'],
91 ],
92 },
93 media_player: {
94 colour: 0xff6bd5,
95 accent: 0xff6bd5,
96 rate: 6,
97 frames: [
98 ['.......', '.......', '..#....', '.##.*..', '###.*..', '.##.*..', '..#....', '.......', '.......', '.......', '.......', '.......'],
99 ['.......', '......*', '..#...*', '.##.*.*', '###.*.*', '.##.*.*', '..#...*', '......*', '.......', '.......', '.......', '.......'],
100 ],
101 },
102 sensor: {
103 colour: CREST,
104 accent: CREST,
105 rate: 0,
106 frames: [['.......', '.......', '..###..', '.#...#.', '#..o..#', '.#...#.', '..###..', '.......', '.......', '.......', '.......', '.......']],
107 },
108}
109
110const DOMAIN_SPRITE: Record<string, string> = {
111 light: 'light',
112 switch: 'switch',
113 input_boolean: 'switch',
114 fan: 'fan',
115 cover: 'cover',
116 climate: 'climate',
117 water_heater: 'climate',
118 lock: 'lock',
119 media_player: 'media_player',
120 binary_sensor: 'sensor',
121 sensor: 'sensor',
122 person: 'sensor',
123 device_tracker: 'sensor',
124}
125
126/** The sprite a domain is drawn as; anything unknown is a bulb. */
127export function spriteFor(domain: string): string {
128 return DOMAIN_SPRITE[domain] ?? 'light'
129}
130
131/** Which way a domain's on and off read in a caption. */
132export function stateWords(domain: string, isOn: boolean): string {
133 if (domain === 'cover') return isOn ? 'open' : 'closed'
134 if (domain === 'lock') return isOn ? 'locked' : 'unlocked'
135 return isOn ? 'on' : 'off'
136}
137
138// ---------------------------------------------------------------- frames
139
140type Canvas = { w: number; h: number; px: Uint32Array; caption: { text: string; x: number; colour: number }[] }
141type DeviceDraw = { kind: string; x: number; y: number; lit: number; halo: number; t: number; isBroken?: boolean; turn?: number; bodyOnly?: boolean; below?: (x: number) => number }
142
143/** One frame of an interstitial, `elapsed` ms in, as the base64 `cells` a Raster takes. */
144export function frame(elapsed: number, scene: Interstitial, columns: number): string {
145 const c = canvas(columns)
146 const t = elapsed / 1000
147
148 if (scene.kind === 'switch') switchScene(c, t, scene.lamps)
149 else if (scene.kind === 'saved') savedScene(c, t, scene.caption)
150 else if (scene.kind === 'stopped') stoppedScene(c, t, scene.caption)
151 else if (scene.kind === 'error') errorScene(c, t, scene.caption)
152 else ranScene(c, t, scene.caption)
153
154 return encode(c, columns)
155}
156
157/** Rows the swim takes: the whole six-line header, two pixels a row. */
158export const SWIM_ROWS = 6
159
160/**
161 * One frame of the swim at `now` ms: three bulbs swimming through the water and
162 * leaping like the dolphins did, trailing sparkles. Each cell is two pixels,
163 * upper and lower, each drawn as a round cluster of dots rather than a block.
164 */
165export function swim(now: number, columns: number): string {
166 const p: Pixels = { w: columns, h: SWIM_ROWS * 2, px: new Uint32Array(columns * SWIM_ROWS * 2) }
167 const t = now / 1000
168 const width = columns
169
170 for (let k = 0; k < Math.max(3, Math.floor(columns / 10)); k++) {
171 const x = Math.floor(hash(k * 5.3) * width)
172 if (Math.sin(t * 2 + k * 1.7) > 0.3) put(p, x, Math.floor(hash(k * 9.1) * 5), STAR)
173 }
174 const bulbs = swimmers(t, width, p.h)
175 for (const bulb of bulbs) glowAround(p, bulb, width)
176 for (const bulb of bulbs) sparkleTrail(p, bulb, t, width)
177 for (const bulb of bulbs) swimmer(p, bulb)
178 waves(p, t, width)
179
180 return encodePixels(p)
181}
182
183type Pixels = { w: number; h: number; px: Uint32Array }
184type Swimmer = { x: number; y: number; glass: number; isLeaping: boolean }
185
186// The swimming bulb, 5 x 6 pixels: G glass, H highlight, S screw, D screw shadow.
187const SWIMMER = ['.GGG.', 'GHGGG', 'GGGGG', '.GGG.', '.SSS.', '.DSD.']
188
189function swimmers(t: number, width: number, height: number): Swimmer[] {
190 const out: Swimmer[] = []
191 for (let i = 0; i < 3; i++) {
192 const span = width + 10
193 const x = (((t * (7 + i * 1.5) + (i / 3) * span) % span) + span) % span - 5
194 const cycle = (((t + i * 2.3) % 5.5) + 5.5) % 5.5
195 const lift = cycle < 1.1 ? Math.sin((Math.PI * cycle) / 1.1) * (height - 7) : 0
196 const y = height - 7 + Math.sin(t * 2.2 + i * 2) * 0.8 - lift
197 out.push({ x, y, glass: mix(0xffb43c, 0xfff2a8, 0.5 + 0.5 * Math.sin(t * 3 + i)), isLeaping: lift > 0.5 })
198 }
199 return out
200}
201
202function glowAround(p: Pixels, bulb: Swimmer, width: number) {
203 const cx = bulb.x + 2.5
204 const cy = bulb.y + 2
205 for (let y = Math.floor(cy - 4); y <= cy + 4; y++) {
206 for (let x = Math.floor(cx - 6); x <= cx + 6; x++) {
207 if (x < 0 || x >= width) continue
208 const d = Math.hypot((x - cx) / 1.6, y - cy) / 4
209 // Only the nearer glow is drawn: faint dots everywhere would be noise, not light.
210 if (d < 0.45) blendPixel(p, x, y, 0xffb43c, (1 - d) * 0.45)
211 }
212 }
213}
214
215function sparkleTrail(p: Pixels, bulb: Swimmer, t: number, width: number) {
216 if (!bulb.isLeaping) return
217 for (let k = 1; k <= 6; k++) {
218 const x = Math.round(bulb.x + 2.5 - k * 1.3)
219 const y = Math.round(bulb.y + 3 + k * 0.6 + Math.sin(t * 20 + k) * 0.6)
220 if (x >= 0 && x < width && hash(k + Math.floor(t * 12)) > 0.35) blendPixel(p, x, y, 0xfff0a0, 1 - k / 7)
221 }
222}
223
224function swimmer(p: Pixels, bulb: Swimmer) {
225 SWIMMER.forEach((line, sy) => {
226 for (let sx = 0; sx < 5; sx++) {
227 const ch = line[sx]
228 const colour = ch === 'G' ? bulb.glass : ch === 'H' ? mix(bulb.glass, 0xffffff, 0.7) : ch === 'S' ? BASE : ch === 'D' ? 0x5a5f66 : -1
229 if (colour >= 0) put(p, Math.round(bulb.x) + sx, Math.round(bulb.y) + sy, colour)
230 }
231 })
232}
233
234function waves(p: Pixels, t: number, width: number) {
235 for (let x = 0; x < width; x++) {
236 const level = p.h - 2.6 + Math.sin(x * 0.35 + t * 3) * 0.7 + Math.sin(x * 0.11 - t * 1.7) * 0.4
237 for (let y = Math.max(0, Math.floor(level)); y < p.h; y++) {
238 const isCrest = y === Math.floor(level)
239 // Over whatever swam into it: the crest bright, the depth see-through.
240 blendPixel(p, x, y, isCrest ? CREST : mix(0x0a3d7a, 0x041c3d, (y - level) / 3), isCrest ? 0.9 : 0.72)
241 }
242 }
243}
244
245function put(p: Pixels, x: number, y: number, colour: number) {
246 if (x < 0 || y < 0 || x >= p.w || y >= p.h) return
247 p.px[y * p.w + x] = colour
248}
249
250/** Blends over the pixel; an empty one counts as the panel, so glow fades into it. */
251function blendPixel(p: Pixels, x: number, y: number, colour: number, alpha: number) {
252 if (x < 0 || y < 0 || x >= p.w || y >= p.h) return
253 const i = y * p.w + x
254 p.px[i] = mix(p.px[i] || PANEL, colour, Math.min(1, Math.max(0, alpha)))
255}
256
257/** Each cell's upper pixel as the top 2 x 2 dots, its lower as the bottom 2 x 2; the brighter one colours it. */
258function encodePixels(p: Pixels): string {
259 const rows = p.h / 2
260 const bytes = new Uint8Array(p.w * rows * 12)
261 const view = new DataView(bytes.buffer)
262 for (let row = 0; row < rows; row++) {
263 for (let x = 0; x < p.w; x++) {
264 const upper = p.px[2 * row * p.w + x] ?? 0
265 const lower = p.px[(2 * row + 1) * p.w + x] ?? 0
266 const bits = (upper ? 0x1b : 0) | (lower ? 0xe4 : 0)
267 const colour = upper && lower ? (luminance(upper) >= luminance(lower) ? upper : lower) : upper || lower || PANEL
268 const o = (row * p.w + x) * 12
269 view.setUint32(o, bits ? 0x2800 + bits : 0x20, true)
270 view.setUint32(o + 4, colour, true)
271 view.setUint32(o + 8, PANEL, true)
272 }
273 }
274 return base64(bytes)
275}
276
277function mix(a: number, b: number, u: number): number {
278 const ch = (shift: number) => Math.round(((a >> shift) & 255) + (((b >> shift) & 255) - ((a >> shift) & 255)) * u)
279 return (ch(16) << 16) | (ch(8) << 8) | ch(0)
280}
281
282// ---------------------------------------------------------------- scenes
283
284/** Each device glides in to its own slot and then goes the way the run (or call) sent it. */
285function switchScene(c: Canvas, t: number, lamps: Lamp[]) {
286 const shown = lamps.slice(0, 3)
287 const slot = Math.floor(c.w / Math.max(1, shown.length))
288 shown.forEach((lamp, i) => {
289 const target = slot * i + Math.floor(slot / 2) - 3
290 const x = glide(t - i * 0.15, target)
291 const at = 0.9 + i * 0.35
292 let lit = lamp.isOn ? 0 : 1
293 if (t >= at && t < at + 0.35) lit = flicker(t, i) ? 1 : 0
294 else if (t >= at + 0.35) lit = lamp.isOn ? 1 : Math.max(0, 1 - (t - at - 0.35) * 3)
295 const isLit = lit >= 1 && (lamp.isOn ? t >= at + 0.35 : t < at)
296 const kind = spriteFor(lamp.domain)
297 drawDevice(c, { kind, x, y: 0, lit, halo: isLit && kind === 'light' ? 0.6 + 0.4 * Math.sin(t * 6 + i) : 0, t })
298
299 const isDone = t >= at + 0.35
300 const words = isDone ? `${lamp.label} ${stateWords(lamp.domain, lamp.isOn)}` : lamp.label
301 caption(c, words, Math.floor((slot * i) / 2), Math.floor(slot / 2), isDone ? (lamp.isOn ? GREEN : QUIET) : QUIET)
302 })
303}
304
305/** A bulb lowered into its socket, screwed in, and lit for a moment. */
306function savedScene(c: Canvas, t: number, words: string) {
307 const x = Math.floor(c.w / 2) - 3
308 const y = t < 0.7 ? Math.round(-12 * (1 - easeOut(t / 0.7))) : 0
309 const turn = t >= 0.7 && t < 1.5 ? Math.floor(t * 10) : 0
310 const lit = t < 1.5 ? 0 : t < 2.3 ? 1 : Math.max(0.3, 1 - (t - 2.3) * 1.5)
311 drawDevice(c, { kind: 'light', x, y, lit, halo: t >= 1.5 && t < 2.3 ? 0.8 : 0, t, turn })
312 if (t >= 0.7) caption(c, words, 0, c.w / 2, AMBER)
313}
314
315/** A bulb that comes in, tries, and stays dark: a condition said no. */
316function stoppedScene(c: Canvas, t: number, words: string) {
317 const x = glide(t, Math.floor(c.w / 2) - 3)
318 const lit = t >= 0.9 && t < 1.6 ? (flicker(t, 7) ? 0.35 : 0) : 0
319 drawDevice(c, { kind: 'light', x, y: 0, lit, halo: 0, t })
320 if (t >= 1.0) caption(c, words, 0, c.w / 2, AMBER)
321}
322
323/** A bulb that lights too bright and pops. */
324function errorScene(c: Canvas, t: number, words: string) {
325 const x = glide(t, Math.floor(c.w / 2) - 3)
326 const isPopped = t >= 1.3
327 const isLit = t >= 0.9 && !isPopped
328 drawDevice(c, { kind: 'light', x, y: 0, lit: isLit ? 1 : 0, halo: isLit ? 1 : 0, t, isBroken: isPopped })
329 if (isPopped) sparks(c, t - 1.3, x + 3, 4)
330 if (t >= 1.0) caption(c, words, 0, c.w / 2, RED)
331}
332
333/** A run that touched no device: one bulb blinks twice to say it happened. */
334function ranScene(c: Canvas, t: number, words: string) {
335 const x = glide(t, Math.floor(c.w / 2) - 3)
336 const isOn = (t >= 1.0 && t < 1.3) || (t >= 1.5 && t < 1.8)
337 drawDevice(c, { kind: 'light', x, y: 0, lit: isOn ? 1 : 0, halo: isOn ? 0.7 : 0, t })
338 if (t >= 1.0) caption(c, words, 0, c.w / 2, GREEN)
339}
340
341// ---------------------------------------------------------------- drawing
342
343function drawDevice(c: Canvas, d: DeviceDraw) {
344 const sprite = DEVICES[d.kind] ?? DEVICES.light!
345 const isOn = d.lit > 0.5
346 // Off shows the first frame; on animates through them (or shows the second, its "on" pose).
347 const index = !isOn ? 0 : sprite.rate > 0 ? Math.floor(d.t * sprite.rate) % sprite.frames.length : sprite.frames.length - 1
348 const rows = sprite.frames[index] ?? sprite.frames[0]!
349 const ox = Math.round(d.x)
350 const oy = Math.round(d.y)
351 const lastRow = d.bodyOnly ? 7 : 11
352
353 rows.forEach((line, row) => {
354 if (row > lastRow) return
355 for (let col = 0; col < 7; col++) {
356 const ch = line[col]
357 const x = ox + col
358 const y = oy + row
359 if (d.below && y > d.below(x)) continue // under the water
360 if (ch === '#' && !d.isBroken) set(c, x, y, isOn ? sprite.colour : OUTLINE)
361 else if (ch === 'o' && !d.isBroken && d.lit > 0 && hash(col * 13 + row * 7) < d.lit) set(c, x, y, sprite.colour)
362 else if (ch === '*' && isOn) set(c, x, y, sprite.accent)
363 else if (ch === '=') {
364 // Screwing in: the thread's dots trade places as it turns.
365 const isThread = (d.turn ?? 0) % 2 === 1 ? row % 2 === 1 : row % 2 === 0
366 if (d.kind !== 'light' || isThread || row === 11) set(c, x, y, BASE)
367 }
368 }
369 })
370
371 if (d.halo > 0) {
372 const cx = ox + 3
373 const cy = oy + 4
374 for (let ray = 0; ray < 8; ray++) {
375 const angle = (ray / 8) * Math.PI * 2 + Math.PI / 8
376 const reach = 1 + Math.round(d.halo * 2)
377 for (let k = 0; k < reach; k++) {
378 const r = 5.5 + k
379 set(c, Math.round(cx + Math.cos(angle) * r * 1.3), Math.round(cy + Math.sin(angle) * r * 0.8), HALO)
380 }
381 }
382 }
383}
384
385function sparks(c: Canvas, s: number, cx: number, cy: number) {
386 for (let k = 0; k < 16; k++) {
387 const angle = (k / 16) * Math.PI * 2 + hash(k) * 0.4
388 const speed = 6 + hash(k * 3.1) * 8
389 const x = Math.round(cx + Math.cos(angle) * speed * s * 1.8)
390 const y = Math.round(cy + Math.sin(angle) * speed * s + 10 * s * s)
391 if (s < 1.6) set(c, x, y, hash(k * 7) > 0.5 ? SPARK : RED)
392 }
393}
394
395/** Text in the caption row, centred in a span of cells and cut to fit. */
396function caption(c: Canvas, words: string, left: number, span: number, colour: number) {
397 const width = Math.max(1, Math.floor(span))
398 const text = words.length > width ? `${words.slice(0, width - 1)}…` : words
399 c.caption.push({ text, x: left + Math.floor((width - text.length) / 2), colour })
400}
401
402function canvas(columns: number): Canvas {
403 const w = columns * 2
404 const h = (FX_ROWS - 1) * 4
405 return { w, h, px: new Uint32Array(w * h), caption: [] }
406}
407
408function set(c: Canvas, x: number, y: number, colour: number) {
409 if (x < 0 || y < 0 || x >= c.w || y >= c.h) return
410 c.px[y * c.w + x] = colour
411}
412
413// ---------------------------------------------------------------- cells
414
415// Braille dot bits by [x][y] within a cell.
416const DOT_BITS = [
417 [0x01, 0x02, 0x04, 0x40],
418 [0x08, 0x10, 0x20, 0x80],
419]
420
421/** Braille cells for the dots, then the caption row; u32 little-endian triplets, base64. */
422function encode(c: Canvas, columns: number): string {
423 const bytes = new Uint8Array(columns * FX_ROWS * 12)
424 const view = new DataView(bytes.buffer)
425 const put = (cell: number, code: number, fg: number) => {
426 view.setUint32(cell * 12, code, true)
427 view.setUint32(cell * 12 + 4, fg, true)
428 view.setUint32(cell * 12 + 8, PANEL, true)
429 }
430
431 for (let row = 0; row < FX_ROWS - 1; row++) {
432 for (let col = 0; col < columns; col++) {
433 let bits = 0
434 let colour = PANEL
435 let brightest = -1
436 for (let dx = 0; dx < 2; dx++) {
437 for (let dy = 0; dy < 4; dy++) {
438 const dot = c.px[(row * 4 + dy) * c.w + col * 2 + dx] ?? 0
439 if (dot === 0) continue
440 bits |= DOT_BITS[dx]![dy]!
441 const light = luminance(dot)
442 if (light > brightest) {
443 brightest = light
444 colour = dot
445 }
446 }
447 }
448 put(row * columns + col, bits ? 0x2800 + bits : 0x20, colour)
449 }
450 }
451
452 const base = (FX_ROWS - 1) * columns
453 for (let col = 0; col < columns; col++) put(base + col, 0x20, PANEL)
454 for (const line of c.caption) {
455 for (let i = 0; i < line.text.length; i++) {
456 const col = line.x + i
457 if (col < 0 || col >= columns) continue
458 const code = line.text.charCodeAt(i)
459 // Printable, width-1 BMP characters only; anything else is a dot.
460 const isPrintable = code >= 0x20 && code !== 0x7f && !(code >= 0xd800 && code <= 0xdfff) && code < 0x1100
461 put(base + col, isPrintable || code === 0x2026 || code === 0x203a ? code : 0xb7, line.colour)
462 }
463 }
464 return base64(bytes)
465}
466
467const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
468
469function base64(bytes: Uint8Array): string {
470 let out = ''
471 for (let i = 0; i < bytes.length; i += 3) {
472 const n = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
473 out += ALPHABET[(n >> 18) & 63]! + ALPHABET[(n >> 12) & 63]!
474 out += i + 1 < bytes.length ? ALPHABET[(n >> 6) & 63]! : '='
475 out += i + 2 < bytes.length ? ALPHABET[n & 63]! : '='
476 }
477 return out
478}
479
480// ---------------------------------------------------------------- small things
481
482/** From off the left edge to `target`, easing out, over 0.8 s. */
483function glide(t: number, target: number): number {
484 return -9 + (target + 9) * easeOut(Math.min(1, Math.max(0, t / 0.8)))
485}
486
487function easeOut(u: number): number {
488 return 1 - (1 - u) ** 3
489}
490
491function flicker(t: number, seed: number): boolean {
492 return hash(Math.floor(t * 25) + seed * 17) > 0.45
493}
494
495function luminance(colour: number): number {
496 return ((colour >> 16) & 255) * 0.3 + ((colour >> 8) & 255) * 0.59 + (colour & 255) * 0.11
497}
498
499function hash(n: number): number {
500 const s = Math.sin(n * 127.1 + 311.7) * 43758.5453
501 return s - Math.floor(s)
502}
503hooks/flow.ts 581 lines1// The automation as rows the pane draws: one per trigger, condition and
2// action, nested as Home Assistant nests them, each keyed by the path HA's
3// traces use (trigger/0, action/1/choose/0/sequence/2), so a run's steps and
4// a previous version line up with the same rows. Pure: no `$` here.
5
6import type { AutomationConfig, Json, RunView } from '../types'
7
8type Obj = { [key: string]: Json }
9
10export type Tone = 'plain' | 'ran' | 'false' | 'error' | 'skipped' | 'disabled'
11export type Change = '+' | '~' | '-' | null
12
13export type Row = {
14 path: string
15 depth: number
16 isSection: boolean
17 text: string
18 note: string
19 tone: Tone
20 change: Change
21 /** What the diff compares: the node without its nested steps. */
22 fingerprint: string
23}
24
25// ---------------------------------------------------------------- parsing
26
27/** The JSON body of a vome MCP reply, which ends with a `[vome-instance]` line. */
28export function parseReply(text: string): Obj | null {
29 const start = text.indexOf('{')
30 const end = text.lastIndexOf('}')
31 if (start < 0 || end <= start) return null
32 try {
33 const value = JSON.parse(text.slice(start, end + 1)) as Json
34 return isObj(value) ? value : null
35 } catch {
36 return null
37 }
38}
39
40/** Which home answered: the vome MCP stamps every reply with it. */
41export function parseStamp(text: string): { instance: string | null; home: string | null } {
42 const match = /\[vome-instance\] target=(\S+)(?: home=("(?:[^"\\]|\\.)*"))?/.exec(text)
43 if (!match) return { instance: null, home: null }
44 let home: string | null = null
45 try {
46 home = match[2] ? (JSON.parse(match[2]) as string) : null
47 } catch {
48 home = null
49 }
50 return { instance: match[1] ?? null, home }
51}
52
53/** ha_get_trace's summary as the pane keeps it. */
54export function parseRun(body: Obj): RunView | null {
55 const runId = str(body.run_id)
56 if (!runId) return null
57 const trigger = isObj(body.trigger) ? str(body.trigger.description) : null
58 const failed = isObj(body.failed_at) ? body.failed_at : null
59 const steps = Array.isArray(body.steps) ? body.steps.filter(isObj) : []
60 return {
61 runId,
62 started: str(body.started) ?? '',
63 finished: str(body.finished),
64 state: str(body.state),
65 execution: str(body.script_execution),
66 trigger,
67 error: body.error == null ? null : compact(body.error, 160),
68 failedAt: failed ? { path: str(failed.path) ?? '', reason: str(failed.reason) ?? '' } : null,
69 steps: steps.map(step => ({
70 path: str(step.path) ?? '',
71 ...(step.result != null ? { result: typeof step.result === 'string' ? step.result : JSON.stringify(step.result) } : {}),
72 ...(step.error != null ? { error: compact(step.error, 160) } : {}),
73 })),
74 }
75}
76
77// ---------------------------------------------------------------- rows
78
79const CHILD_KEYS = new Set(['choose', 'default', 'sequence', 'then', 'else', 'if', 'conditions', 'parallel', 'and', 'or', 'not'])
80
81/** Every row of `config`, with the run's tones and the changes since `prev`. */
82export function buildRows(config: AutomationConfig, run: RunView | null, prev: AutomationConfig | null): Row[] {
83 const rows = walk(config, run)
84 if (!prev) return rows
85
86 // Rows are matched by what they say, not where they sit: matched by position, inserting one
87 // step marked every step after it as changed. A longest common subsequence over the rows of
88 // each section (same depth, same content) finds what stayed; a new row left unmatched is a
89 // change if an unmatched old row held its place, and an addition otherwise.
90 const isStep = (row: Row) => !row.isSection && !row.path.endsWith('/-')
91 const key = (row: Row) => `${row.path.split('/')[0]}|${row.depth}|${row.fingerprint}`
92 const after = rows.filter(isStep)
93 const before = walk(prev, null).filter(isStep)
94 const kept = lcs(before.map(key), after.map(key))
95
96 const unmatchedBefore = new Map(before.filter((_, i) => !kept.before.has(i)).map(row => [row.path, row]))
97 const changes = new Map<Row, Change>()
98 after.forEach((row, i) => {
99 if (kept.after.has(i)) return
100 // The same step somewhere else: it moved, which is a change here and not a removal there.
101 const moved = [...unmatchedBefore.values()].find(old => key(old) === key(row))
102 if (moved) {
103 unmatchedBefore.delete(moved.path)
104 changes.set(row, '~')
105 } else if (unmatchedBefore.has(row.path)) {
106 unmatchedBefore.delete(row.path)
107 changes.set(row, '~')
108 } else {
109 changes.set(row, '+')
110 }
111 })
112
113 const marked = rows.map(row => (changes.has(row) ? { ...row, change: changes.get(row) ?? null } : row))
114 const removed = [...unmatchedBefore.values()]
115 if (removed.length === 0) return marked
116 return [
117 ...marked,
118 section('Removed', 'removed'),
119 ...removed.map(row => ({ ...row, tone: 'plain' as Tone, change: '-' as Change })),
120 ]
121}
122
123/** Indices of a longest common subsequence of two key lists, on each side. */
124function lcs(a: string[], b: string[]): { before: Set<number>; after: Set<number> } {
125 const table: number[][] = Array.from({ length: a.length + 1 }, () => new Array<number>(b.length + 1).fill(0))
126 for (let i = a.length - 1; i >= 0; i--) {
127 for (let j = b.length - 1; j >= 0; j--) {
128 table[i]![j] = a[i] === b[j] ? table[i + 1]![j + 1]! + 1 : Math.max(table[i + 1]![j]!, table[i]![j + 1]!)
129 }
130 }
131 const before = new Set<number>()
132 const after = new Set<number>()
133 let i = 0
134 let j = 0
135 while (i < a.length && j < b.length) {
136 if (a[i] === b[j]) {
137 before.add(i++)
138 after.add(j++)
139 } else if (table[i + 1]![j]! >= table[i]![j + 1]!) {
140 i++
141 } else {
142 j++
143 }
144 }
145 return { before, after }
146}
147
148/** How many rows were added, changed and removed. */
149export function countChanges(rows: Row[]): { added: number; changed: number; removed: number } {
150 return {
151 added: rows.filter(row => row.change === '+').length,
152 changed: rows.filter(row => row.change === '~').length,
153 removed: rows.filter(row => row.change === '-').length,
154 }
155}
156
157/** A trace path as a reader would say it: "Then › step 1 › option 2 › step 1". */
158export function describePath(path: string): string {
159 const parts = path.split('/')
160 const words: string[] = []
161 for (let i = 0; i < parts.length; i++) {
162 const part = parts[i] ?? ''
163 const next = parts[i + 1]
164 const index = next !== undefined && /^\d+$/.test(next) ? Number(next) + 1 : null
165 const name = PATH_WORDS[part] ?? part
166 if (/^\d+$/.test(part)) continue
167 words.push(index !== null ? `${name} ${index}` : name)
168 }
169 return words.join(' › ')
170}
171
172const PATH_WORDS: Record<string, string> = {
173 trigger: 'trigger',
174 condition: 'condition',
175 action: 'step',
176 choose: 'option',
177 conditions: 'condition',
178 sequence: 'step',
179 default: 'otherwise',
180 if: 'if',
181 then: 'then',
182 else: 'else',
183 repeat: 'repeat',
184 while: 'while',
185 until: 'until',
186 parallel: 'branch',
187}
188
189function walk(config: AutomationConfig, run: RunView | null): Row[] {
190 const trace = traceIndex(run)
191 const rows: Row[] = []
192 const triggers = list(config.triggers ?? config.trigger)
193 const conditions = list(config.conditions ?? config.condition)
194 const actions = list(config.actions ?? config.action)
195
196 rows.push(section('When', 'trigger'))
197 triggers.forEach((trigger, i) => rows.push(leaf(`trigger/${i}`, 1, trigger, describeTrigger(trigger), trace)))
198 if (triggers.length === 0) rows.push(empty('trigger/-', 'no triggers'))
199
200 if (conditions.length > 0) {
201 rows.push(section('And if', 'condition'))
202 conditions.forEach((condition, i) => pushCondition(rows, condition, `condition/${i}`, 1, trace))
203 }
204
205 rows.push(section('Then', 'action'))
206 actions.forEach((action, i) => pushAction(rows, action, `action/${i}`, 1, trace))
207 if (actions.length === 0) rows.push(empty('action/-', 'no actions'))
208 return rows
209}
210
211type Trace = { steps: Map<string, { result?: string; error?: string }>; failedAt: RunView['failedAt'] } | null
212
213function traceIndex(run: RunView | null): Trace {
214 if (!run) return null
215 return { steps: new Map(run.steps.map(step => [step.path, step])), failedAt: run.failedAt }
216}
217
218function toneAt(path: string, trace: Trace, node: Json): Tone {
219 if (isObj(node) && node.enabled === false) return 'disabled'
220 if (!trace) return 'plain'
221 if (trace.failedAt?.path === path && /error/.test(trace.failedAt.reason)) return 'error'
222 const step = trace.steps.get(path)
223 if (!step) return 'skipped'
224 if (step.error) return 'error'
225 if (step.result && /"result"\s*:\s*false/.test(step.result)) return 'false'
226 return 'ran'
227}
228
229/** A branch (then, else, otherwise, a parallel branch) ran when any step under it did. */
230function branchTone(prefix: string, trace: Trace): Tone {
231 if (!trace) return 'plain'
232 for (const path of trace.steps.keys()) if (path.startsWith(`${prefix}/`)) return 'ran'
233 return 'skipped'
234}
235
236function pushCondition(rows: Row[], condition: Json, path: string, depth: number, trace: Trace) {
237 rows.push(leaf(path, depth, condition, describeCondition(condition), trace))
238 if (!isObj(condition)) return
239 const children = list(condition.conditions ?? condition.and ?? condition.or ?? condition.not)
240 children.forEach((child, j) => pushCondition(rows, child, `${path}/conditions/${j}`, depth + 1, trace))
241}
242
243function pushSteps(rows: Row[], steps: Json | undefined, prefix: string, depth: number, trace: Trace) {
244 list(steps).forEach((step, j) => pushAction(rows, step, `${prefix}/${j}`, depth, trace))
245}
246
247function pushAction(rows: Row[], action: Json, path: string, depth: number, trace: Trace) {
248 if (!isObj(action)) {
249 rows.push(leaf(path, depth, action, { text: compact(action, 80), note: '' }, trace))
250 return
251 }
252
253 if (action.choose !== undefined) {
254 rows.push(leaf(path, depth, action, { text: action.alias ? str(action.alias)! : 'Choose', note: '' }, trace))
255 list(action.choose).forEach((option, k) => {
256 const at = `${path}/choose/${k}`
257 const alias = isObj(option) ? str(option.alias) : null
258 rows.push(leaf(at, depth + 1, option, { text: alias ?? `Option ${k + 1}`, note: '' }, trace))
259 if (!isObj(option)) return
260 list(option.conditions).forEach((condition, j) =>
261 pushCondition(rows, condition, `${at}/conditions/${j}`, depth + 2, trace),
262 )
263 pushSteps(rows, option.sequence, `${at}/sequence`, depth + 2, trace)
264 })
265 if (action.default !== undefined) {
266 rows.push(branch(`${path}/default`, depth + 1, 'Otherwise', trace))
267 pushSteps(rows, action.default, `${path}/default`, depth + 2, trace)
268 }
269 return
270 }
271
272 if (action.if !== undefined) {
273 rows.push(leaf(path, depth, action, { text: action.alias ? str(action.alias)! : 'If', note: '' }, trace))
274 list(action.if).forEach((condition, j) => pushCondition(rows, condition, `${path}/if/${j}`, depth + 1, trace))
275 rows.push(branch(`${path}/then`, depth, 'Then', trace))
276 pushSteps(rows, action.then, `${path}/then`, depth + 1, trace)
277 if (action.else !== undefined) {
278 rows.push(branch(`${path}/else`, depth, 'Else', trace))
279 pushSteps(rows, action.else, `${path}/else`, depth + 1, trace)
280 }
281 return
282 }
283
284 if (isObj(action.repeat)) {
285 const repeat = action.repeat
286 rows.push(leaf(path, depth, action, describeRepeat(repeat, action), trace))
287 list(repeat.while).forEach((condition, j) => pushCondition(rows, condition, `${path}/repeat/while/${j}`, depth + 1, trace))
288 pushSteps(rows, repeat.sequence, `${path}/repeat/sequence`, depth + 1, trace)
289 list(repeat.until).forEach((condition, j) => pushCondition(rows, condition, `${path}/repeat/until/${j}`, depth + 1, trace))
290 return
291 }
292
293 if (action.parallel !== undefined) {
294 rows.push(leaf(path, depth, action, { text: action.alias ? str(action.alias)! : 'In parallel', note: '' }, trace))
295 list(action.parallel).forEach((item, j) => {
296 const at = `${path}/parallel/${j}`
297 if (isObj(item) && item.sequence !== undefined && !isAction(item)) {
298 rows.push(branch(at, depth + 1, str(item.alias) ?? `Branch ${j + 1}`, trace))
299 pushSteps(rows, item.sequence, `${at}/sequence`, depth + 2, trace)
300 } else {
301 pushAction(rows, item, at, depth + 1, trace)
302 }
303 })
304 return
305 }
306
307 if (action.sequence !== undefined) {
308 rows.push(leaf(path, depth, action, { text: action.alias ? str(action.alias)! : 'Sequence', note: '' }, trace))
309 pushSteps(rows, action.sequence, `${path}/sequence`, depth + 1, trace)
310 return
311 }
312
313 if (action.condition !== undefined) {
314 pushCondition(rows, action, path, depth, trace)
315 return
316 }
317
318 rows.push(leaf(path, depth, action, describeAction(action), trace))
319}
320
321function isAction(item: Obj): boolean {
322 return ['action', 'service', 'delay', 'event', 'scene', 'choose', 'if', 'repeat', 'parallel', 'condition'].some(
323 key => item[key] !== undefined,
324 )
325}
326
327function section(title: string, path: string): Row {
328 return { path: `#${path}`, depth: 0, isSection: true, text: title, note: '', tone: 'plain', change: null, fingerprint: '' }
329}
330
331function empty(path: string, text: string): Row {
332 return { path, depth: 1, isSection: false, text, note: '', tone: 'skipped', change: null, fingerprint: text }
333}
334
335function branch(path: string, depth: number, text: string, trace: Trace): Row {
336 return { path, depth, isSection: false, text, note: '', tone: branchTone(path, trace), change: null, fingerprint: text }
337}
338
339function leaf(path: string, depth: number, node: Json, words: Words, trace: Trace): Row {
340 return {
341 path,
342 depth,
343 isSection: false,
344 text: words.text,
345 note: words.note,
346 tone: toneAt(path, trace, node),
347 change: null,
348 fingerprint: JSON.stringify(own(node)),
349 }
350}
351
352/** The node without its nested steps, so a changed child does not mark its parent. */
353function own(node: Json): Json {
354 if (!isObj(node)) return node
355 const out: Obj = {}
356 for (const [key, value] of Object.entries(node)) {
357 if (CHILD_KEYS.has(key) && (Array.isArray(value) || isObj(value))) continue
358 if (key === 'repeat' && isObj(value)) {
359 const { sequence: _s, while: _w, until: _u, ...rest } = value
360 out[key] = rest
361 continue
362 }
363 out[key] = value
364 }
365 return out
366}
367
368// ---------------------------------------------------------------- words
369
370type Words = { text: string; note: string }
371
372function describeTrigger(trigger: Json): Words {
373 if (!isObj(trigger)) return { text: compact(trigger, 80), note: '' }
374 const kind = str(trigger.trigger) ?? str(trigger.platform) ?? '?'
375 const id = str(trigger.id)
376 const detail = triggerDetail(kind, trigger)
377 const alias = str(trigger.alias)
378 if (alias) return { text: alias, note: [detail, id && `#${id}`].filter(Boolean).join(' ') }
379 return { text: detail, note: id ? `#${id}` : '' }
380}
381
382function triggerDetail(kind: string, t: Obj): string {
383 const forText = t.for !== undefined ? ` for ${duration(t.for)}` : ''
384 const attribute = t.attribute !== undefined ? `.${str(t.attribute)}` : ''
385 switch (kind) {
386 case 'state': {
387 const from = t.from !== undefined ? ` from ${value(t.from)}` : ''
388 const to = t.to !== undefined ? ` → ${value(t.to)}` : ' changes'
389 return `${entities(t.entity_id)}${attribute}${from}${to}${forText}`
390 }
391 case 'numeric_state':
392 return `${entities(t.entity_id)}${attribute}${bounds(t)}${forText}`
393 case 'time':
394 return `at ${list(t.at).map(item => value(item)).join(', ')}${weekdays(t.weekday)}`
395 case 'time_pattern':
396 return `every ${['hours', 'minutes', 'seconds'].filter(key => t[key] !== undefined).map(key => `${key} ${value(t[key])}`).join(' ')}`
397 case 'sun':
398 return `${str(t.event) ?? 'sun'}${t.offset !== undefined ? ` ${value(t.offset)}` : ''}`
399 case 'template':
400 return `${template(t.value_template)}${forText}`
401 case 'event':
402 return `event ${value(t.event_type)}${t.event_data !== undefined ? ` ${compact(t.event_data, 40)}` : ''}`
403 case 'homeassistant':
404 return `Home Assistant ${str(t.event) ?? ''}`
405 case 'mqtt':
406 return `MQTT ${value(t.topic)}${t.payload !== undefined ? ` = ${value(t.payload)}` : ''}`
407 case 'webhook':
408 return `webhook ${value(t.webhook_id)}`
409 case 'zone':
410 return `${entities(t.entity_id)} ${str(t.event) ?? ''} ${value(t.zone)}`
411 case 'device':
412 return `device ${str(t.domain) ?? ''} ${str(t.type) ?? ''}${t.entity_id !== undefined ? ` ${entities(t.entity_id)}` : ''}${forText}`
413 case 'tag':
414 return `tag ${value(t.tag_id)}`
415 case 'conversation':
416 return `says ${list(t.command).map(item => `"${str(item)}"`).join(' / ')}`
417 case 'calendar':
418 return `calendar ${entities(t.entity_id)} ${str(t.event) ?? ''}${t.offset !== undefined ? ` ${value(t.offset)}` : ''}`
419 default:
420 return `${kind} ${compact(rest(t, ['trigger', 'platform', 'id', 'alias']), 60)}`
421 }
422}
423
424function describeCondition(condition: Json): Words {
425 if (typeof condition === 'string') return { text: template(condition), note: '' }
426 if (!isObj(condition)) return { text: compact(condition, 80), note: '' }
427 const alias = str(condition.alias)
428 const detail = conditionDetail(condition)
429 return alias ? { text: alias, note: detail } : { text: detail, note: '' }
430}
431
432function conditionDetail(c: Obj): string {
433 const kind = str(c.condition) ?? (c.and ? 'and' : c.or ? 'or' : c.not ? 'not' : '?')
434 const forText = c.for !== undefined ? ` for ${duration(c.for)}` : ''
435 const attribute = c.attribute !== undefined ? `.${str(c.attribute)}` : ''
436 switch (kind) {
437 case 'and':
438 return 'all of'
439 case 'or':
440 return 'any of'
441 case 'not':
442 return 'none of'
443 case 'state':
444 return `if ${entities(c.entity_id)}${attribute} is ${list(c.state).map(item => value(item)).join(' or ')}${forText}`
445 case 'numeric_state':
446 return `if ${entities(c.entity_id)}${attribute}${bounds(c)}`
447 case 'template':
448 return `if ${template(c.value_template)}`
449 case 'trigger':
450 return `if triggered by ${list(c.id).map(item => `#${value(item)}`).join(' or ')}`
451 case 'time': {
452 const parts = [c.after !== undefined && `after ${value(c.after)}`, c.before !== undefined && `before ${value(c.before)}`]
453 return `if ${parts.filter(Boolean).join(' and ') || 'time'}${weekdays(c.weekday)}`
454 }
455 case 'sun': {
456 const parts = [c.after !== undefined && `after ${value(c.after)}`, c.before !== undefined && `before ${value(c.before)}`]
457 return `if ${parts.filter(Boolean).join(' and ') || 'sun'}`
458 }
459 case 'zone':
460 return `if ${entities(c.entity_id)} in ${value(c.zone)}`
461 case 'device':
462 return `if device ${str(c.domain) ?? ''} ${str(c.type) ?? ''}${c.entity_id !== undefined ? ` ${entities(c.entity_id)}` : ''}`
463 default:
464 return `if ${kind} ${compact(rest(c, ['condition', 'alias']), 60)}`
465 }
466}
467
468function describeAction(a: Obj): Words {
469 const alias = str(a.alias)
470 const detail = actionDetail(a)
471 return alias ? { text: alias, note: detail } : { text: detail, note: '' }
472}
473
474function actionDetail(a: Obj): string {
475 const service = str(a.action) ?? str(a.service)
476 if (service) {
477 const target = targets(a)
478 const data = isObj(a.data) ? a.data : isObj(a.data_template) ? a.data_template : null
479 const dataText = data && Object.keys(data).length > 0 ? ` ${Object.entries(data).map(([key, v]) => `${key} ${value(v)}`).join(', ')}` : ''
480 return `${service}${target ? ` → ${target}` : ''}${dataText}`
481 }
482 if (a.delay !== undefined) return `wait ${duration(a.delay)}`
483 if (a.wait_template !== undefined)
484 return `wait until ${template(a.wait_template)}${a.timeout !== undefined ? ` (timeout ${duration(a.timeout)})` : ''}`
485 if (a.wait_for_trigger !== undefined) {
486 const count = list(a.wait_for_trigger).length
487 return `wait for ${count === 1 ? describeTrigger(list(a.wait_for_trigger)[0] ?? null).text : `${count} triggers`}${a.timeout !== undefined ? ` (timeout ${duration(a.timeout)})` : ''}`
488 }
489 if (a.event !== undefined) return `fire event ${value(a.event)}`
490 if (a.scene !== undefined) return `scene ${value(a.scene)}`
491 if (isObj(a.variables)) return `set ${Object.keys(a.variables).join(', ')}`
492 if (a.stop !== undefined) return `stop: ${value(a.stop)}${a.error === true ? ' (as error)' : ''}`
493 if (a.device_id !== undefined) return `device ${str(a.domain) ?? ''} ${str(a.type) ?? ''}`
494 return compact(rest(a, ['alias', 'enabled']), 80)
495}
496
497function describeRepeat(repeat: Obj, action: Obj): Words {
498 const alias = str(action.alias)
499 const how =
500 repeat.count !== undefined
501 ? `repeat ${value(repeat.count)} times`
502 : repeat.for_each !== undefined
503 ? `for each of ${compact(repeat.for_each, 40)}`
504 : repeat.while !== undefined
505 ? 'repeat while'
506 : repeat.until !== undefined
507 ? 'repeat until'
508 : 'repeat'
509 return alias ? { text: alias, note: how } : { text: how, note: '' }
510}
511
512function targets(a: Obj): string {
513 const target = isObj(a.target) ? a.target : {}
514 const parts = [
515 target.entity_id ?? a.entity_id,
516 target.area_id !== undefined ? list(target.area_id).map(area => `area ${value(area)}`) : undefined,
517 target.device_id !== undefined ? `${list(target.device_id).length} device(s)` : undefined,
518 target.label_id !== undefined ? list(target.label_id).map(label => `label ${value(label)}`) : undefined,
519 ].filter(part => part !== undefined && part !== null)
520 return parts.map(part => (typeof part === 'string' && part.endsWith('device(s)') ? part : entities(part as Json))).join(', ')
521}
522
523function bounds(t: Obj): string {
524 return [t.above !== undefined && ` above ${value(t.above)}`, t.below !== undefined && ` below ${value(t.below)}`]
525 .filter(Boolean)
526 .join(' and')
527}
528
529function weekdays(weekday: Json | undefined): string {
530 return weekday === undefined ? '' : ` on ${list(weekday).map(day => value(day)).join(', ')}`
531}
532
533function entities(ids: Json | undefined): string {
534 const all = list(ids ?? null).map(id => value(id))
535 if (all.length === 0) return '(no entity)'
536 return all.length <= 2 ? all.join(', ') : `${all.slice(0, 2).join(', ')} +${all.length - 2}`
537}
538
539function duration(d: Json): string {
540 if (typeof d === 'number') return `${d}s`
541 if (typeof d === 'string') return d
542 if (isObj(d)) {
543 const units: [string, string][] = [['days', 'd'], ['hours', 'h'], ['minutes', 'm'], ['seconds', 's'], ['milliseconds', 'ms']]
544 const text = units.filter(([key]) => d[key] !== undefined).map(([key, unit]) => `${value(d[key])}${unit}`).join(' ')
545 return text || compact(d, 30)
546 }
547 return compact(d, 30)
548}
549
550function template(t: Json | undefined): string {
551 return typeof t === 'string' ? t.replace(/\s+/g, ' ').trim() : compact(t ?? null, 60)
552}
553
554function value(v: Json | undefined): string {
555 if (typeof v === 'string') return v
556 if (v === undefined) return '?'
557 return compact(v, 40)
558}
559
560function rest(node: Obj, drop: string[]): Obj {
561 return Object.fromEntries(Object.entries(node).filter(([key]) => !drop.includes(key)))
562}
563
564export function compact(v: Json | unknown, max: number): string {
565 const text = typeof v === 'string' ? v : JSON.stringify(v) ?? String(v)
566 return text.length > max ? `${text.slice(0, max - 1)}…` : text
567}
568
569function list(v: Json | undefined): Json[] {
570 if (v === undefined || v === null) return []
571 return Array.isArray(v) ? v : [v]
572}
573
574function str(v: Json | undefined): string | null {
575 return typeof v === 'string' ? v : typeof v === 'number' ? String(v) : null
576}
577
578export function isObj(v: Json | undefined): v is Obj {
579 return typeof v === 'object' && v !== null && !Array.isArray(v)
580}
581types/index.d.ts 96 lines1export type Json = null | boolean | number | string | Json[] | { [key: string]: Json }
2
3/** An automation's config as Home Assistant stores it (alias, triggers, conditions, actions, mode). */
4export type AutomationConfig = { [key: string]: Json }
5
6/** The automation the pane shows, and the version before the last save when there was one. */
7export type AutomationView = {
8 /** The MCP server it was read through, as the engine names it (`vome`, `home-assistant`, ...). */
9 server: string
10 id: string
11 alias: string
12 config: AutomationConfig
13 /** The config before the last ha_set_automation, when the pane knew it: what the diff is against. */
14 prev: AutomationConfig | null
15 /** ISO time of the last save made through Vome, or null when the pane only read it. */
16 savedAt: string | null
17 /** The VomeHome instance the config came from, off the MCP's [vome-instance] stamp. */
18 instance: string | null
19 home: string | null
20}
21
22/** One step of a run, as ha_get_trace summarises it. */
23export type RunStep = { path: string; result?: string; error?: string }
24
25/** A run of the automation, as ha_get_trace summarises it. */
26export type RunView = {
27 runId: string
28 started: string
29 finished: string | null
30 state: string | null
31 execution: string | null
32 trigger: string | null
33 error: string | null
34 failedAt: { path: string; reason: string } | null
35 steps: RunStep[]
36}
37
38/** Watching for new runs after a save (or on request), until `until`. */
39export type Watch = { id: string; instance: string | null; since: string; until: number }
40
41/** A config the pane has seen, and the VomeHome instance it came from. */
42export type Known = { instance: string | null; config: AutomationConfig }
43
44/** Work the poller owes the pane: fetch a config by name, fetch the latest run. */
45export type Pending = { automation?: string; run?: boolean; server?: string; list?: boolean }
46
47/** The home's automations, fetched for the picker. */
48export type Choices = { server: string; automations: { id: string; alias: string; isOn: boolean }[] }
49
50/** Rows lit for a few seconds: those a save changed, or those a new run went through. */
51export type Flash = { at: number; paths: string[]; kind: 'change' | 'run' }
52
53/** A device the interstitial shows: what it is called, its kind (the entity's domain), which way it went. */
54export type Lamp = { label: string; isOn: boolean; domain: string }
55
56/** What the interstitial acts out: lights switched, a save, a run that stopped, failed or simply ran. */
57export type Interstitial =
58 | { kind: 'switch'; lamps: Lamp[] }
59 | { kind: 'saved'; caption: string }
60 | { kind: 'stopped'; caption: string }
61 | { kind: 'error'; caption: string }
62 | { kind: 'ran'; caption: string }
63
64/** The interstitial playing now, and when it started. */
65export type Fx = { at: number; scene: Interstitial }
66
67declare module 'claude-code' {
68 interface PluginState {
69 'vome-automation': {
70 view: AutomationView | null
71 run: RunView | null
72 watch: Watch | null
73 pending: Pending | null
74 note: string | null
75 /** Last config seen per automation id, and which home it was read from: the baseline a save is diffed against. */
76 known: Record<string, Known>
77 /** Home Assistant URL per VomeHome instance ('' once looked up and absent). */
78 haUrls: Record<string, string>
79 flash: Flash | null
80 fx: Fx | null
81 /** True from the model reading an automation until it saves or its turn ends: the swimmers play. */
82 building: boolean
83 /** Auto mode refused one of the pane's background reads: which, and on which server. */
84 blocked: { server: string; tool: string; error: string; isDismissed: boolean } | null
85 /** Connected MCP servers that carry our tools; empty means nothing to follow yet. */
86 servers: string[]
87 /** Claude used ESPHome tools and the ESPHome pane does not seem to be here: offer it, once. */
88 sibling: boolean
89 /** The picker's list, while it is open. */
90 choices: Choices | null
91 /** Whether the bulb strip plays; kept across sessions in $.store as well. */
92 fxOn: boolean
93 }
94 }
95}
96