SLOPSHOPPER

vome-automation

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…

newpaneguardcommandtoasttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · vome-automation
│ ┃ Automation ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ ⏺ Read(src/auth.ts) │ ┃ ⣤⣤⣤ ⎿ Read 6 lines │ ┃ ⣤⣿⣿⣿⣤ ⣿⣿⣿⣿⣿⣤ ⏺ Update(src/auth.ts) │ ┃ ⣤ ⣤⣤⣤⣤⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣤⣿⣿ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ No automation yet. It appears here when one ⎿ 3 pass, 1 fail │ ┃ is read or saved through Vome, or pick one │ ┃ with /automation. ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /automation │ ⎿ vome-automation: Listing the home's automations in the pane to p │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Automation
⣤⣿⣿ ⠛⣿⣿ ⣤⣤⣤ ⠛⣿⣿ ⣤⣿⣿⣿⣤ ⣿⣿⣿⣿⣿⣤ ⠛ ⠛ ⣤ ⣤⣤⣤⣤⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣤⣿⣿ No automation yet. It appears here when one is read or saved through Vome, or pick one with /automation.
README

vome-automation: an automation pane for Claude Code

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.

Claude explaining a home's automations, with the automation pane beside it: the automation as a WHEN/THEN map, each step marked by whether its latest run reached it.

Quickstart

1. Add the Vome app to Home Assistant, then get a key from it. No sign-up needed.

Add the Vome app to your Home Assistant.

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.

What it shows

  • The automation as Home Assistant's editor nests it. Triggers, conditions and actions, with choose, if/then/else, repeat and parallel blocks indented the way the editor shows them.
  • What a save changed. After Claude saves, each line is marked + added, ~ changed or − removed, and the changed lines light up for a few seconds. Steps are matched by what they do, so inserting one marks that one alone, and a step that moved shows as changed.
  • What the latest run did. Each step is marked ● ran, ✗ evaluated false (or errored) or ○ not reached, so "why didn't it fire?" is answered at a glance. Whenever Claude reads, refreshes or saves an automation, the pane watches for its runs for the next 30 minutes and tells you when one happens.
  • A car-stereo dot-matrix display. When something happens, it is acted out for a few seconds: a bulb glides in and lights when a run turns a light on, a fan spins up, a blind opens, a lock snaps shut. A condition that stopped the run flickers and stays dark, an error pops, and a save screws a new bulb in. While Claude is working on an automation, bulbs swim across the display like the dolphins did. Press b to turn it off.

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.

Connect Home Assistant first

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:

  • Through Vome (one key, nothing to run): get a key from the Vome app's Agent tab in Home Assistant (no sign-up needed; add the app to your Home Assistant) or, for a home hosted on Vome, from vome.io under Account → API tokens. Then install vome-connect, which asks for the key:
  /plugin install vome-connect --marketplace Vortitron/home-assistant-mcp
  • Without Vome: run the MCP on your machine with 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.

What it needs

  • @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 Code 2.1.287 or newer, in a terminal. The pane is drawn in the terminal. The VS Code and Cursor extensions don't show plugin panes yet, so run claude in a terminal there.
  • Plugins of function hooks enabled for you. Panes like this one are an early-access Claude Code feature, being rolled out gradually. If yours doesn't have it yet, the plugin installs but doesn't load; nothing else is affected.

Install

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.

Get updates

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.

Allow the pane's reads (auto mode)

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.

Using it

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.

/automationopen the pane; with nothing shown yet, list the home's automations to pick from
/automation listlist the home's automations to pick from
/automation <id>show an automation by its id or automation. entity id
rrefresh the automation and its latest run
opick another automation from the list
wstop watching for runs, or start again (30 minutes)
bthe dot-matrix display off or on (remembered)

The keys work while the pane has focus: click it, or press ctrl+x then tab.

Troubleshooting

  • No pane at all. Check you're in a terminal, not the VS Code or Cursor extension. /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.
  • "No run shown yet" and a line about the classifier. See Allow the pane's reads.
  • "Vome now points at a different home". The MCP was switched to another home (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.
  • No dot-matrix display. It draws only in the terminal, and only while something happens or Claude is working on an automation. If you turned it off, press b.

Privacy

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.

Development

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.

Source 4 files
hooks/register.tsx 1009 lines
1// 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}
1009
hooks/bulbs.ts 503 lines
1// 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}
503
hooks/flow.ts 581 lines
1// 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}
581
types/index.d.ts 96 lines
1export 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