SLOPSHOPPER

vome-dash

A Home Assistant dashboard in a side pane of Claude Code, working: live states, and controls that toggle, step and run as the dashboard's own do. Shows the…

newpaneguardcommandtimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · vome-dash
│ ┃ vome-dash ✕ › fix the failing auth test and add an audit log call │ ┃ No dashboard yet. It appears here, working, │ ┃ when Claude reads or saves one, or pick one ⏺ Read(src/auth.ts) │ ┃ with /dash. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /dash │ ⎿ vome-dash: No connected MCP server has ha_get_dashboard yet: con │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · vome-dash
No dashboard yet. It appears here, working, when Claude reads or saves one, or pick one with /dash.
README

vome-dash: your Home Assistant dashboard, working, beside Claude Code

A side pane with a Home Assistant dashboard in it: the one Claude is working on, or any of yours. Not a picture of one: it works.

Claude Code asked to set two lights to 40% and switch a socket on; the dashboard pane beside it lights them up as the home reports the change.

  • Live states. Through Vome the home sends each change the moment it happens, so a light switched in Home Assistant changes here within a second, with nothing polled.
  • Controls that do what the dashboard's do. Switches switch, lights dim with − and +, sliders and thermostats step, scenes and scripts run, a card's buttons perform their actions, asking first where the card asks first. A press shows its way through: sending, sent, then the new state, or "no change seen" if the device did not answer.
  • Drawn like Home Assistant draws it. Cards in columns, each entity with its icon, a light that is on glowing in its own colour, glance cards as tiles, history graphs as line charts, Markdown rendered by the home, templates and all, and a camera's picture in coloured half blocks.
  • What Claude changes, lit up. When Claude edits a dashboard, the cards it changed light up for a few seconds.

Presses are yours: Claude never presses anything in the pane.

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. Live states need the Vome app 0.3.52 or newer.

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-dash --marketplace Vortitron/home-assistant-mcp
/reload-plugins

Or all four Vome panes at once (automations, ESPHome, health and this one): /plugin install vome-panes --marketplace Vortitron/home-assistant-mcp.

The dashboard pane: lights, sockets, a camera in half blocks, occupancy, temperatures and their graphs, in two columns.

3. Run /dash, pick a dashboard, or ask Claude to build one: "Make me a dashboard for the lights and the temperatures".

Allow its reads (auto mode)

The pane reads in the background (states, the live watch, templates, history, camera frames), all read-only. In auto mode Claude Code refuses background calls nobody asked for unless they are allowed by name; the pane shows the exact lines and where they go. With vome-connect:

"mcp__plugin_vome-connect_vome__ha_view_snapshot",
"mcp__plugin_vome-connect_vome__ha_watch_states",
"mcp__plugin_vome-connect_vome__ha_camera_frame",
"mcp__plugin_vome-connect_vome__ha_get_state",
"mcp__plugin_vome-connect_vome__ha_render_template",
"mcp__plugin_vome-connect_vome__ha_get_history",
"mcp__plugin_vome-connect_vome__ha_get_dashboard",
"mcp__plugin_vome-connect_vome__ha_list_dashboards"

A press calls ha_call_service. Allowing that by name would let Claude change things without asking too, so the pane leaves that choice to you: use the controls with auto mode off, or allow it if you are happy to.

Keys

/dashopen the pane and pick a dashboard (/dash <url_path> opens one)
mthe dashboards sidebar
rreload the dashboard

Privacy and security

The pane reads through the connection Claude Code already has. A live watch needs a key that may read the home, follows only the entities on show, reports states and never changes anything, and its access expires within minutes unless the key is still allowed. Camera pictures need the key's Cameras tick.

Source 3 files
hooks/register.tsx 1094 lines
1// A side pane with a Home Assistant dashboard in it, working: live states, and
2// controls that do what the dashboard's own do. Lights and switches toggle,
3// sliders and thermostats step, scenes and scripts run, a card's buttons
4// perform their actions (asking first where the card asks first).
5//
6// It shows the dashboard Claude reads or saves (ha_get_dashboard,
7// ha_save_dashboard), with what a save changed lit for a few seconds, or one
8// picked with /dash. States are read every few seconds for the view on show;
9// Markdown cards are rendered by the home, templates and all; history graphs
10// become sparklines. Cards only Home Assistant can draw keep a line naming
11// them and their entities.
12
13import type { EngineInterface, Register, RenderChildren } from 'claude-code'
14
15import type { Dash, Live, Pending } from '../types'
16import { READS, brailleChart, changedCards, entitiesOf, fromBase64, glowFor, glyphFor, halfBlocks, lightColour, steady, stepFor, toggleFor, viewsOf } from './lovelace'
17import type { Card, Row, ServiceCall, View } from './lovelace'
18
19const PANE = 'vome-dash'
20/**
21 * Vome rate-limits each Home Assistant endpoint per key (500 an hour), and Claude's own reads share
22 * it. So states are read every 10 s, and only while the pane is on show: at most 360 an hour.
23 */
24const STATE_MS = 10_000
25/** A refused read (the hourly limit) pauses the pane's reads this long. */
26const BACKOFF_MS = 5 * 60_000
27const TEMPLATE_MS = 60_000
28const HISTORY_MS = 60_000
29const LIT_MS = 8_000
30const CONFIRM_MS = 6_000
31const MAX_ENTITIES = 80
32
33const dash = { plugin: 'vome-dash', key: 'dash' } as const
34const view = { plugin: 'vome-dash', key: 'view' } as const
35const states = { plugin: 'vome-dash', key: 'states' } as const
36const lit = { plugin: 'vome-dash', key: 'lit' } as const
37const rendered = { plugin: 'vome-dash', key: 'rendered' } as const
38const history = { plugin: 'vome-dash', key: 'history' } as const
39const confirm = { plugin: 'vome-dash', key: 'confirm' } as const
40const choices = { plugin: 'vome-dash', key: 'choices' } as const
41const pending = { plugin: 'vome-dash', key: 'pending' } as const
42const sidebarOpen = { plugin: 'vome-dash', key: 'sidebarOpen' } as const
43const frames = { plugin: 'vome-dash', key: 'frames' } as const
44/** A camera's frame is fetched this often while it is on show. */
45const FRAME_EVERY_MS = 30_000
46/** How long a press waits to see its device change, how often it looks, how long the outcome stays. */
47const WATCH_MS = 8_000
48/** After a press, the device is looked at this long after it, then no more. */
49const WATCH_AT_MS = [1_000, 2_500, 5_000, 8_000]
50const WATCH_EVERY_MS = 500
51const OUTCOME_MS = 3_000
52/** The sidebar's narrowest and widest: it sizes itself to the longest title between them. */
53const SIDEBAR_MIN = 16
54const SIDEBAR_MAX = 30
55const blocked = { plugin: 'vome-dash', key: 'blocked' } as const
56const note = { plugin: 'vome-dash', key: 'note' } as const
57
58let isPolling = false
59/** Reads paused until then: the hourly limit was hit. */
60let pausedUntil = 0
61let lastFrames = 0
62/** The card width last drawn, for the size of camera frames and the points in a chart. */
63let lastCardWidth = 40
64/** The MCP has no ha_camera_frame, or no max_points on ha_get_history: ask the older way. */
65let hasMaxPoints = true
66/** A camera frame failed (an older MCP, a key without Cameras): ask again after this. */
67let framesRetryAt = 0
68/** The MCP has ha_view_snapshot: one call a refresh. False after it is found missing, until a reload. */
69let hasSnapshot = true
70/** Live states: one waiting read at a time while the pane is on show (ha_watch_states). */
71let isWatching = false
72/** The watch has answered at least once: states come by it, so the snapshot need not read them. */
73let isWatchLive = false
74/** The MCP or the home cannot watch (no tool, no component link): poll, and try again after this. */
75let watchRetryAt = 0
76let lastTemplates = 0
77let lastHistory = 0
78let settingsFile = '~/.claude/settings.json'
79/** The last press, so Retry can make it again once it is allowed. */
80let lastCall: { server: string; call: ServiceCall } | null = null
81
82export const register: Register = on => {
83  on('session.start', async ($, e, next) => {
84    await $.command.register({ name: 'dash', description: 'Show a Home Assistant dashboard, working, in a side pane', argumentHint: '[dashboard url_path]' })
85    const configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('HOME')) ?? '~'}/.claude`
86    settingsFile = `${configDir.replace(/\/+$/, '')}/settings.json`
87    $.clock.every(STATE_MS, () => void poll($))
88    $.clock.every(WATCH_EVERY_MS, () => void watchPresses($))
89    return next(e)
90  })
91
92  on('command.run', { command: 'dash' }, async ($, e) => {
93    const wanted = e.args.trim()
94    const current = (await $.state.get(dash)).value ?? null
95    const server = current?.server ?? (await dashServers($))[0]
96    if (!server) return { text: 'No connected MCP server has ha_get_dashboard yet: connect Home Assistant through Vome first (vome-connect).' }
97    const opened = await $.ui.open({ id: PANE, title: 'Dashboard' })
98    if (wanted) void loadDashboard($, server, wanted)
99    else void listDashboards($, server)
100    return { text: opened.isPlaced ? (wanted ? `Opening ${wanted} in the pane.` : "Listing the home's dashboards in the pane.") : `The dashboard pane is not drawn here: ${opened.reason}.` }
101  })
102
103  on('tool.call', async ($, e, next) => {
104    const match = /^mcp__(.+)__(ha_get_dashboard|ha_save_dashboard|ha_list_dashboards)$/.exec(e.tool)
105    if (!match?.[1] || !match[2]) return next(e)
106    const server = match[1]
107    const args = e as unknown as { url_path?: unknown; config?: unknown }
108    const ran = await next(e)
109    if (ran.deny !== undefined || ran.isError) return ran
110
111    if (match[2] === 'ha_get_dashboard') {
112      const body = parseJson(ran.text ?? '')
113      if (body && body.config && typeof body.config === 'object') {
114        await showDashboard($, server, String(body.url_path ?? args.url_path ?? 'lovelace'), body.config as Record<string, unknown>)
115      }
116    } else if (match[2] === 'ha_save_dashboard' && args.config && typeof args.config === 'object') {
117      const before = (await $.state.get(dash)).value ?? null
118      const urlPath = String(args.url_path ?? 'lovelace')
119      const config = args.config as Record<string, unknown>
120      if (before && before.urlPath === urlPath) await $.state.set(lit, { keys: [...changedCards(before.config, config)], at: Date.now() })
121      await showDashboard($, server, urlPath, config)
122    } else if (match[2] === 'ha_list_dashboards') {
123      await setChoices($, ran.text ?? '')
124    }
125    return ran
126  })
127
128  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
129    const { Box, Text, Button, Code, Markdown } = $.ui.resolve(e)
130    const settingsLink = <Markdown text={`[${settingsFile}](file://${settingsFile})`} />
131    const current = (await $.state.get(dash)).value ?? null
132    const shownView = (await $.state.get(view)).value ?? 0
133    const live = (await $.state.get(states)).value ?? {}
134    const lighting = (await $.state.get(lit)).value ?? null
135    const texts = (await $.state.get(rendered)).value ?? {}
136    const series = (await $.state.get(history)).value ?? {}
137    const asking = (await $.state.get(confirm)).value ?? null
138    const picking = (await $.state.get(choices)).value ?? null
139    const refusal = (await $.state.get(blocked)).value ?? null
140    const message = (await $.state.get(note)).value ?? null
141    const presses = (await $.state.get(pending)).value ?? {}
142    const isMenuOpen = (await $.state.get(sidebarOpen)).value ?? false
143    const pictures = (await $.state.get(frames)).value ?? {}
144    const width = Math.max(30, e.props.bodyColumns)
145    const isLit = (key: string) => lighting !== null && Date.now() - lighting.at < LIT_MS && lighting.keys.some(k => key === k || key.startsWith(`${k}/`))
146
147    const help = refusal ? (
148      <Box flexDirection="column" borderStyle="round" borderColor="warning" paddingX={1} marginTop={1}>
149        {refusal.tool === 'ha_call_service' ? (
150          <Text color="warning" wrap="wrap">
151            Auto mode refused the press. Pressing a control here calls ha_call_service, and allowing that by name would
152            let Claude change things without asking too. Switch auto mode off to use the controls, or allow it in
153            {' '}{settingsFile} if you are happy with that:
154          </Text>
155        ) : (
156          <Text color="warning" wrap="wrap">
157            Auto mode refused the pane's read of live states. Add these to permissions.allow in {settingsFile}; they only read:
158          </Text>
159        )}
160        {settingsLink}
161        <Code source={refusal.tool === 'ha_call_service' ? `"mcp__${refusal.server}__ha_call_service"` : READS.map(t => `"mcp__${refusal.server}__${t}"`).join(',\n')} />
162        <Box flexDirection="row" gap={2}>
163          <Button
164            key="copy-rule"
165            hotkey="c"
166            onPress={press =>
167              $.ui.copy({
168                text: refusal.tool === 'ha_call_service' ? `"mcp__${refusal.server}__ha_call_service"` : READS.map(t => `"mcp__${refusal.server}__${t}"`).join(',\n'),
169                surface: press.surface,
170              })
171            }
172          >
173            Copy
174          </Button>
175          <Button
176            key="retry"
177            hotkey="t"
178            onPress={async () => {
179              await $.state.set(blocked, null)
180              // Allowed since: nothing stays switched off for having been refused once.
181              hasSnapshot = true
182              hasMaxPoints = true
183              framesRetryAt = 0
184              watchRetryAt = 0
185              if (refusal.tool === 'ha_call_service' && lastCall) await callService($, lastCall.server, lastCall.call)
186              else void poll($, true)
187            }}
188          >
189            {refusal.tool === 'ha_call_service' ? 'Retry the press' : 'Retry'}
190          </Button>
191          <Button key="dismiss-rule" dimColor onPress={() => $.state.set(blocked, null)}>
192            Dismiss
193          </Button>
194        </Box>
195      </Box>
196    ) : null
197
198    const picker = picking ? (
199      <Box flexDirection="column" marginTop={1}>
200        <Text bold>Dashboards</Text>
201        <Box flexDirection="row" flexWrap="wrap" gap={1}>
202          {picking.map(choice => (
203            <Button
204              key={`pick-${choice.urlPath}`}
205              onPress={async () => {
206                await $.state.set(choices, null)
207                const server = current?.server ?? (await dashServers($))[0]
208                if (server) await loadDashboard($, server, choice.urlPath)
209              }}
210            >
211              {choice.title}
212            </Button>
213          ))}
214        </Box>
215      </Box>
216    ) : null
217
218    if (!current) {
219      return (
220        <Box flexDirection="column">
221          <Text dimColor wrap="wrap">
222            No dashboard yet. It appears here, working, when Claude reads or saves one, or pick one with /dash.
223          </Text>
224          {picker}
225          {message ? <Text color="warning" wrap="wrap">{message}</Text> : null}
226          {help}
227        </Box>
228      )
229    }
230
231    const views = viewsOf(current.config)
232    const index = Math.min(shownView, Math.max(0, views.length - 1))
233    const shown: View | undefined = views[index]
234    // The home's dashboards down the left, as Home Assistant's sidebar has them, until one is picked.
235    const hasMenu = (picking?.length ?? 0) > 1
236    const isSidebar = isMenuOpen && hasMenu && width >= 70
237    // Wide enough for the longest title, its marker and a button's brackets, inside the border.
238    const longest = Math.max(10, ...(picking ?? []).map(choice => choice.title.length))
239    const sidebarWidth = Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, longest + 2 + 4 + 4))
240    const mainWidth = isSidebar ? width - sidebarWidth - 1 : width
241    // Columns as Home Assistant's desktop layout has them: as many as fit, cards stacked in each.
242    const columns = mainWidth >= 120 ? 3 : mainWidth >= 72 ? 2 : 1
243    const cardWidth = columns > 1 ? Math.floor((mainWidth - (columns - 1)) / columns) : mainWidth
244    lastCardWidth = cardWidth
245    /** What a control's press is doing, for the row it is on. */
246    const pressOf = (prefix: string): Pending | undefined =>
247      Object.entries(presses)
248        .filter(([key]) => key === prefix || key.startsWith(`${prefix}/`))
249        .map(([, p]) => p)
250        .sort((a, b) => b.at - a.at)[0]
251    const spinner = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'[Math.floor(Date.now() / 120) % 10]
252    const pressNote = (p: Pending | undefined) =>
253      !p ? null : p.phase === 'sending' ? (
254        <Text color="warning">{spinner} sending</Text>
255      ) : p.phase === 'sent' ? (
256        <Text color="suggestion">{spinner} sent, waiting</Text>
257      ) : p.phase === 'done' ? (
258        <Text color="success">✓</Text>
259      ) : p.phase === 'quiet' ? (
260        <Text color="warning">no change seen</Text>
261      ) : (
262        <Text color="error">✗ failed</Text>
263      )
264
265    const press = async (key: string, call: ServiceCall) => {
266      if (call.confirm && !(asking && asking.key === key && asking.until > Date.now())) {
267        await $.state.set(confirm, { key, until: Date.now() + CONFIRM_MS })
268        return
269      }
270      await $.state.set(confirm, null)
271      await pressAndWatch($, current.server, key, call)
272    }
273
274    const entityRow = (row: Extract<Row, { kind: 'entity' }>, key: string) => {
275      const now: Live | undefined = live[row.entity]
276      const state = now?.state ?? '…'
277      const attributes = now?.attributes ?? {}
278      const name = row.name ?? (typeof attributes.friendly_name === 'string' ? steady(attributes.friendly_name) : row.entity)
279      const unit = typeof attributes.unit_of_measurement === 'string' ? ` ${attributes.unit_of_measurement}` : ''
280      const toggle = toggleFor(row.entity)
281      const down = now ? stepFor(row.entity, attributes, state, -1) : null
282      const up = now ? stepFor(row.entity, attributes, state, 1) : null
283      const isOn = state === 'on'
284      const shownState = row.entity.startsWith('light.') && isOn && typeof attributes.brightness === 'number' ? `on ${Math.round((attributes.brightness / 255) * 100)}%` : `${formatState(state)}${unit}`
285      const going = pressOf(key)
286      const isFresh = going?.phase === 'done'
287      // A light that is on glows in its own colour, as brightly as it is lit.
288      const glow = glowFor(row.entity, state, attributes)
289      const isLight = row.entity.startsWith('light.') && isOn
290      // A slider's position, as Home Assistant draws its bar.
291      const min = typeof attributes.min === 'number' ? attributes.min : null
292      const max = typeof attributes.max === 'number' ? attributes.max : null
293      const isSlider = /^(input_number|number)\./.test(row.entity) && min !== null && max !== null && max > min
294      const slider = isSlider ? sliderBar((Number(state) - min!) / (max! - min!), 8) : null
295      return (
296        <Box flexDirection="row" gap={1} backgroundColor={glow ?? undefined}>
297          <Box flexGrow={1} flexShrink={1} minWidth={4} overflow="hidden">
298            <Text wrap="truncate-end" backgroundColor={glow ?? undefined}>
299              {glyphFor(row.entity, attributes, row.icon)} <Text bold={isLight}>{name}</Text>
300            </Text>
301          </Box>
302          <Box flexDirection="row" gap={1} flexShrink={0}>
303            {slider ? <Text color="warning">{slider}</Text> : null}
304            {pressNote(going)}
305            <Text
306              color={isFresh ? 'success' : state === 'unavailable' ? 'error' : isLight ? lightColour(attributes) : isOn ? 'warning' : undefined}
307              backgroundColor={glow ?? undefined}
308              bold={isFresh || isLight}
309              dimColor={state === 'off' && !isFresh}
310            >
311              {shownState}
312            </Text>
313            {down ? (
314              <Button key={`${key}/down`} dimColor onPress={() => press(`${key}/down`, down)}>
315                −
316              </Button>
317            ) : null}
318            {up ? (
319              <Button key={`${key}/up`} dimColor onPress={() => press(`${key}/up`, up)}>
320                +
321              </Button>
322            ) : null}
323            {toggle && toggle.service === 'toggle' ? (
324              <Button key={`${key}/toggle`} dimColor={!isOn} onPress={() => press(`${key}/toggle`, toggle)}>
325                {isOn ? '━━●' : '○━━'}
326              </Button>
327            ) : toggle ? (
328              <Button key={`${key}/run`} onPress={() => press(`${key}/run`, toggle)}>
329                Run
330              </Button>
331            ) : null}
332          </Box>
333        </Box>
334      )
335    }
336
337    /** An entities card's header switch: all on if any is off, else all off, as Home Assistant's does. */
338    const headerToggle = (card: Card): RenderChildren => {
339      if (card.type !== 'entities' || !card.headerToggle) return null
340      const ids = card.rows.flatMap(row => (row.kind === 'entity' && toggleFor(row.entity)?.service === 'toggle' ? [row.entity] : []))
341      if (ids.length < 2) return null
342      const anyOff = ids.some(id => live[id]?.state !== 'on')
343      const key = `${card.key}/all`
344      return (
345        <Box flexDirection="row" gap={1} flexShrink={0}>
346          {pressNote(pressOf(key))}
347          <Button
348            key={key}
349            dimColor={anyOff}
350            onPress={() => press(key, { domain: 'homeassistant', service: anyOff ? 'turn_on' : 'turn_off', target: { entity_id: ids } })}
351          >
352            {anyOff ? '○━━' : '━━●'}
353          </Button>
354        </Box>
355      )
356    }
357
358    // A card is as wide as the room it is given; one inside a stack gets what is left inside its
359    // parent's border and padding (four cells), or it spills out over the edge.
360    const drawCard = (card: Card, cardKey: string, room = cardWidth): RenderChildren => {
361      const inner = Math.max(8, room - 4)
362      const titleColour = isLit(card.key) ? 'suggestion' : undefined
363      const body: RenderChildren[] = []
364      if (card.markdown !== null) {
365        const text = texts[card.key] ?? card.markdown.replace(/\{%[\s\S]*?%\}|\{\{[\s\S]*?\}\}/g, '…')
366        body.push(<Markdown text={text} />)
367      }
368      if (card.camera) {
369        // The camera's still in half blocks: two pixels a cell, in its own colours.
370        const frame = pictures[card.key]
371        const name = typeof live[card.camera]?.attributes.friendly_name === 'string' ? (live[card.camera]!.attributes.friendly_name as string) : card.camera
372        if (frame && e.surface === 'terminal') {
373          const { Raster } = $.ui.resolve(e)
374          const art = halfBlocks(fromBase64(frame.rgb), frame.width, frame.height)
375          body.push(<Raster key={`frame-${card.key}`} columns={art.columns} rows={art.rows} cells={art.cells} />)
376        } else {
377          body.push(<Text dimColor>{frame ? `📷 ${name}: the picture shows in a terminal` : `📷 ${name}: fetching…`}</Text>)
378        }
379        if (frame) body.push(<Text dimColor wrap="truncate-end">📷 {name} · {since(frame.at)}</Text>)
380      }
381      if (card.hours !== null) {
382        for (const row of card.rows) {
383          if (row.kind !== 'entity') continue
384          const points = series[card.key]?.[row.entity] ?? []
385          const numbers = points.filter((p): p is number => typeof p === 'number')
386          const name = row.name ?? row.entity
387          const unit = typeof live[row.entity]?.attributes.unit_of_measurement === 'string' ? ` ${live[row.entity]!.attributes.unit_of_measurement}` : ''
388          body.push(
389            <Text wrap="truncate-end">
390              {name}
391              <Text dimColor>
392                {'  '}
393                {formatState(live[row.entity]?.state ?? '')}
394                {unit}
395                {numbers.length > 1 ? `  (${formatState(String(Math.min(...numbers)))}–${formatState(String(Math.max(...numbers)))})` : ''}
396              </Text>
397            </Text>,
398          )
399          if (numbers.length > 1) {
400            for (const line of brailleChart(numbers, Math.max(10, inner), 3)) {
401              body.push(<Text color="suggestion">{line}</Text>)
402            }
403          } else {
404            body.push(<Text dimColor>{points.length > 0 ? [...new Set(points.map(String))].slice(-4).join(' → ') : '…'}</Text>)
405          }
406        }
407      } else if (card.type === 'glance') {
408        // A glance card as Home Assistant draws it: a row of icons, each with its name and state under it.
409        body.push(
410          <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
411            {card.rows.map((row, i) => {
412              if (row.kind !== 'entity') return null
413              const now = live[row.entity]
414              const attributes = now?.attributes ?? {}
415              const state = now?.state ?? '…'
416              const name = row.name ?? (typeof attributes.friendly_name === 'string' ? steady(attributes.friendly_name) : row.entity)
417              const toggle = toggleFor(row.entity)
418              const key = `${cardKey}/r${i}`
419              const isOn = state === 'on'
420              return (
421                <Box flexDirection="column" alignItems="center" width={12} backgroundColor={glowFor(row.entity, state, attributes) ?? undefined}>
422                  <Text wrap="truncate-end" dimColor={!isOn}>
423                    {name}
424                  </Text>
425                  <Text>{glyphFor(row.entity, attributes, row.icon)}</Text>
426                  <Text color={isOn ? 'warning' : undefined} dimColor={!isOn}>
427                    {formatState(state)}
428                  </Text>
429                  {toggle && toggle.service === 'toggle' ? (
430                    <Button key={`${key}/toggle`} dimColor={!isOn} onPress={() => press(`${key}/toggle`, toggle)}>
431                      {isOn ? '━━●' : '○━━'}
432                    </Button>
433                  ) : null}
434                  {pressNote(pressOf(key))}
435                </Box>
436              )
437            })}
438          </Box>,
439        )
440      } else {
441        card.rows.forEach((row, i) => {
442          const key = `${cardKey}/r${i}`
443          if (row.kind === 'entity') body.push(entityRow(row, key))
444          else if (row.kind === 'text') body.push(<Text dimColor>{row.text}</Text>)
445          else {
446            const isAsking = asking !== null && asking.key === key && asking.until > Date.now()
447            body.push(
448              <Box flexDirection="row" justifyContent="space-between">
449                <Text wrap="truncate-end">{isAsking ? <Text color="warning">{row.call?.confirm} Press again.</Text> : <Text>▸ {row.name}</Text>}</Text>
450                <Box flexDirection="row" gap={1}>
451                  {pressNote(pressOf(key))}
452                  {row.call ? (
453                    <Button key={key} onPress={() => press(key, row.call!)}>
454                      {isAsking ? 'Yes' : 'Run'}
455                    </Button>
456                  ) : null}
457                </Box>
458              </Box>,
459            )
460          }
461        })
462      }
463      if (card.isForeign) body.unshift(<Text dimColor>{card.type.replace(/^custom:/, '')} · drawn by Home Assistant</Text>)
464      // A horizontal stack splits its room between its cards; the others stack them at full width.
465      const across = card.type === 'horizontal-stack' || card.type === 'grid' ? Math.max(1, Math.min(card.children.length, Math.floor(inner / 24))) : 1
466      const childRoom = across > 1 ? Math.floor((inner - (across - 1)) / across) : inner
467      const children = card.children.map((child, i) => drawCard(child, `${cardKey}/c${i}`, childRoom))
468      if (across > 1) body.push(<Box flexDirection="row" flexWrap="wrap" columnGap={1}>{children}</Box>)
469      else body.push(...children)
470      if (card.isForeign && card.rows.length === 0 && card.children.length === 0 && !card.title) return null
471      return (
472        <Box flexDirection="column" borderStyle="round" borderColor={titleColour ?? 'subtle'} paddingX={1} width={room}>
473          {card.title ? (
474            <Box flexDirection="row">
475              <Box flexGrow={1} flexShrink={1} overflow="hidden">
476                <Text bold color={titleColour} wrap="truncate-end">
477                  {card.title}
478                </Text>
479              </Box>
480              {headerToggle(card)}
481            </Box>
482          ) : null}
483          {body}
484        </Box>
485      )
486    }
487
488    const open = (urlPath: string) => async () => {
489      await $.state.set(sidebarOpen, false)
490      if (urlPath !== current.urlPath) await loadDashboard($, current.server, urlPath)
491    }
492    const sidebar = isSidebar ? (
493      <Box flexDirection="column" width={sidebarWidth} borderStyle="round" borderColor="subtle" paddingX={1}>
494        <Text dimColor>Dashboards</Text>
495        {(picking ?? []).map(choice => (
496          <Button key={`side-${choice.urlPath}`} dimColor={choice.urlPath !== current.urlPath} onPress={open(choice.urlPath)}>
497            {fit(`${choice.urlPath === current.urlPath ? '▸ ' : ''}${choice.title}`, sidebarWidth - 8)}
498          </Button>
499        ))}
500      </Box>
501    ) : null
502    // Narrow, the same as a row along the top.
503    const topRow =
504      isMenuOpen && hasMenu && !isSidebar ? (
505        <Box flexDirection="row" flexWrap="wrap" gap={1}>
506          {(picking ?? []).map(choice => (
507            <Button key={`side-${choice.urlPath}`} dimColor={choice.urlPath !== current.urlPath} onPress={open(choice.urlPath)}>
508              {choice.title}
509            </Button>
510          ))}
511        </Box>
512      ) : null
513
514    const main = (
515      <Box flexDirection="column" width={mainWidth}>
516        {/* A fixed header: menu, the title in its own room (cut short, never wrapped), Reload. Symbols
517            whose width terminals disagree on (☰, ⟳) left letters behind when the title changed. */}
518        {/* paddingRight: the pane's own close cross sits on the top row's last cells. */}
519        <Box flexDirection="row" gap={1} paddingRight={2}>
520          {hasMenu ? (
521            <Button key="menu" hotkey="m" dimColor={!isMenuOpen} onPress={() => $.state.set(sidebarOpen, !isMenuOpen)}>
522              {isMenuOpen ? 'Close' : 'Dashboards'}
523            </Button>
524          ) : null}
525          <Box flexGrow={1} flexShrink={1} minWidth={4} overflow="hidden">
526            <Text bold wrap="truncate-end">
527              {current.title}
528            </Text>
529          </Box>
530          <Button
531            key="reload"
532            hotkey="r"
533            dimColor
534            onPress={() => {
535              framesRetryAt = 0
536              watchRetryAt = 0
537              hasSnapshot = true
538              hasMaxPoints = true
539              return loadDashboard($, current.server, current.urlPath)
540            }}
541          >
542            Reload
543          </Button>
544        </Box>
545        {views.length > 1 ? (
546          <Box flexDirection="row" flexWrap="wrap" gap={1}>
547            {views.map((v, i) => (
548              <Button key={`view-${i}`} dimColor={i !== index} onPress={() => $.state.set(view, i)}>
549                {v.title}
550              </Button>
551            ))}
552          </Box>
553        ) : null}
554        <Box flexDirection="row" gap={1}>
555          {masonry(shown?.cards ?? [], columns).map(column => (
556            <Box flexDirection="column" width={cardWidth}>
557              {column.map(card => drawCard(card, card.key))}
558            </Box>
559          ))}
560        </Box>
561        {message ? <Text color="warning" wrap="wrap">{message}</Text> : null}
562        {help}
563
564      </Box>
565    )
566
567    return (
568      <Box flexDirection="column">
569        {topRow}
570        {isSidebar ? (
571          <Box flexDirection="row" gap={1}>
572            {sidebar}
573            {main}
574          </Box>
575        ) : (
576          main
577        )}
578      </Box>
579    )
580  })
581}
582
583// ---------------------------------------------------------------- loading and live state
584
585async function showDashboard($: EngineInterface, server: string, urlPath: string, config: Record<string, unknown>) {
586  const before = (await $.state.get(dash)).value ?? null
587  const title = typeof config.title === 'string' ? config.title : urlPath
588  const next: Dash = { server, urlPath, title, config, at: Date.now() }
589  if (!before || before.urlPath !== urlPath || before.server !== server) {
590    await $.state.set(view, 0)
591    await $.state.set(rendered, {})
592    await $.state.set(history, {})
593    lastTemplates = 0
594    lastHistory = 0
595  }
596  await $.state.set(dash, next)
597  await $.ui.open({ id: PANE, title: 'Dashboard' }).catch(() => undefined)
598  void poll($, true)
599  // The sidebar: the home's dashboards, read once per home.
600  const listed = (await $.state.get(choices)).value ?? null
601  if (!listed || !before || before.server !== server) void listDashboards($, server)
602}
603
604async function loadDashboard($: EngineInterface, server: string, urlPath: string) {
605  const text = await callMcp($, server, 'ha_get_dashboard', { url_path: urlPath })
606  const body = text ? parseJson(text) : null
607  if (body && body.config && typeof body.config === 'object') await showDashboard($, server, urlPath, body.config as Record<string, unknown>)
608}
609
610async function listDashboards($: EngineInterface, server: string) {
611  const text = await callMcp($, server, 'ha_list_dashboards', {})
612  if (text) await setChoices($, text)
613}
614
615async function setChoices($: EngineInterface, text: string) {
616  const body = parseJson(text)
617  const rows = body && Array.isArray(body.dashboards) ? (body.dashboards as Record<string, unknown>[]) : []
618  const list = rows
619    .filter(row => typeof row.url_path === 'string')
620    .map(row => ({ urlPath: String(row.url_path), title: typeof row.title === 'string' ? row.title : String(row.url_path) }))
621  if (list.length > 0) await $.state.set(choices, list)
622}
623
624/** A press: the call, then a quick read of what it changed, so the pane answers at once. */
625async function callService($: EngineInterface, server: string, call: ServiceCall): Promise<boolean> {
626  lastCall = { server, call }
627  const { confirm: _asked, ...args } = call
628  void _asked
629  const text = await callMcp($, server, 'ha_call_service', args)
630  if (text === null) return false
631  const ids = targetsOf(call)
632  if (ids.length > 0) await readStates($, server, ids)
633  return true
634}
635
636function targetsOf(call: ServiceCall): string[] {
637  const target = call.target?.entity_id
638  return Array.isArray(target) ? target : target ? [target] : []
639}
640
641/** How an entity looks, to tell that a press changed it: its state and the attributes a press moves. */
642function lookOf(live: Live | undefined): string | null {
643  if (!live) return null
644  const a = live.attributes
645  return [live.state, a.brightness, a.temperature, a.current_position, a.percentage].map(v => String(v ?? '')).join('|')
646}
647
648/**
649 * A press with its outcome shown: sending, then sent once Home Assistant takes the call, then done
650 * when the device is seen to change (the state flashes), or "no change seen" if it has not within a
651 * few seconds. A slow device or a refused command no longer looks like a press that did nothing.
652 */
653async function pressAndWatch($: EngineInterface, server: string, key: string, call: ServiceCall) {
654  const entity = targetsOf(call)[0] ?? null
655  const before = entity ? lookOf(((await $.state.get(states)).value ?? {})[entity]) : null
656  const set = async (phase: Pending['phase']) => {
657    const now = (await $.state.get(pending)).value ?? {}
658    await $.state.set(pending, { ...now, [key]: { phase, at: Date.now(), entity, before } })
659  }
660  await set('sending')
661  const isSent = await callService($, server, call)
662  if (!isSent) return set('failed')
663  // Already changed by the read straight after the call? Done; else watch for it.
664  const after = entity ? lookOf(((await $.state.get(states)).value ?? {})[entity]) : null
665  await set(!entity || (after !== null && after !== before) ? 'done' : 'sent')
666}
667
668/** Presses waiting on their device: look again, settle them, and clear old outcomes. */
669async function watchPresses($: EngineInterface) {
670  const now = (await $.state.get(pending)).value ?? {}
671  const keys = Object.keys(now)
672  if (keys.length === 0) return
673  const waiting = keys.filter(key => now[key]!.phase === 'sent')
674  const current = (await $.state.get(dash)).value ?? null
675  // Look only at the moments set for each press (1, 2.5, 5 and 8 s after it), not every tick.
676  const due = waiting.filter(key => {
677    const age = Date.now() - now[key]!.at
678    return WATCH_AT_MS.some(at => age >= at && age < at + WATCH_EVERY_MS)
679  })
680  const ids = [...new Set(due.map(key => now[key]!.entity).filter((id): id is string => !!id))]
681  // While a watch runs the change arrives by itself: only look when polling.
682  if (current && ids.length > 0 && !isWatchLive) await readStates($, current.server, ids)
683  const live = (await $.state.get(states)).value ?? {}
684  const next: Record<string, Pending> = {}
685  for (const key of keys) {
686    const p = now[key]!
687    const age = Date.now() - p.at
688    void age
689    if (p.phase === 'sent' && p.entity && lookOf(live[p.entity]) !== p.before) next[key] = { ...p, phase: 'done', at: Date.now() }
690    else if (p.phase === 'sent' && age > WATCH_MS) next[key] = { ...p, phase: 'quiet', at: Date.now() }
691    else if ((p.phase === 'done' || p.phase === 'quiet' || p.phase === 'failed') && age > OUTCOME_MS) continue
692    else next[key] = p
693  }
694  await $.state.set(pending, next)
695  $.ui.invalidate('ui.render')
696}
697
698/** States every few seconds for the view on show; templates and history less often. */
699async function poll($: EngineInterface, isNow = false) {
700  if (isPolling) return
701  const current = (await $.state.get(dash)).value ?? null
702  if (!current) return
703  if ((await $.state.get(blocked)).value && !isNow) return
704  if (!isNow && Date.now() < pausedUntil) return
705  // Nobody is looking: read nothing. The tab behind another pane, or closed, costs no reads.
706  if (!isNow && !(await isShown($))) return
707  isPolling = true
708  try {
709    const views = viewsOf(current.config)
710    const index = Math.min((await $.state.get(view)).value ?? 0, Math.max(0, views.length - 1))
711    const cards = views[index]?.cards ?? []
712    const ids = entitiesOf(cards).slice(0, MAX_ENTITIES)
713    // States arrive by themselves while a watch runs; start one if none is.
714    if (!isWatching && Date.now() >= watchRetryAt && ids.length > 0) void watchLoop($, current.server, current.urlPath, ids)
715    const statesWatched = isWatchLive
716    // One call for everything due this time; the separate calls only for an MCP without it.
717    if (hasSnapshot && (await snapshot($, current.server, cards, statesWatched ? [] : ids, isNow))) return
718    if (ids.length > 0) await readStates($, current.server, ids)
719    if (isNow || Date.now() - lastTemplates > TEMPLATE_MS) {
720      lastTemplates = Date.now()
721      await renderTemplates($, current.server, cards)
722    }
723    if (isNow || Date.now() - lastHistory > HISTORY_MS) {
724      lastHistory = Date.now()
725      await readHistory($, current.server, cards)
726    }
727    if (isNow || Date.now() - lastFrames > FRAME_EVERY_MS) {
728      lastFrames = Date.now()
729      await readFrames($, current.server, cards)
730    }
731  } catch (error) {
732    // Never silently: a refresh that broke says how.
733    await $.state.set(note, `The dashboard could not refresh: ${firstLine(String(error))}`)
734  } finally {
735    isPolling = false
736  }
737}
738
739/**
740 * Live states while the pane is on show: wait for the next change (ha_watch_states holds the read
741 * open at the sync backend, up to 20 s), apply it, wait again. Nothing is polled while it runs.
742 * It ends when the pane is hidden, the dashboard or view changes, or the watch cannot run (an MCP
743 * without the tool, a home without its component linked), and then the pane polls as before.
744 */
745async function watchLoop($: EngineInterface, server: string, urlPath: string, ids: string[]) {
746  isWatching = true
747  let cursor = 0
748  const key = ids.join(',')
749  try {
750    while (true) {
751      const current = (await $.state.get(dash)).value ?? null
752      if (!current || current.urlPath !== urlPath || current.server !== server) return
753      const views = viewsOf(current.config)
754      const index = Math.min((await $.state.get(view)).value ?? 0, Math.max(0, views.length - 1))
755      if (entitiesOf(views[index]?.cards ?? []).slice(0, MAX_ENTITIES).join(',') !== key) return
756      if (!(await isShown($)) || (await $.state.get(blocked)).value) return
757      const text = await callMcp($, server, 'ha_watch_states', { entity_ids: ids, wait_seconds: 20, cursor }, true)
758      const body = text ? parseJson(text) : null
759      if (!body || !Array.isArray(body.states)) {
760        watchRetryAt = Date.now() + 5 * 60_000
761        return
762      }
763      cursor = typeof body.cursor === 'number' ? body.cursor : cursor
764      isWatchLive = true
765      const rows = (body.states as Array<{ entity_id?: string; state?: unknown; attributes?: Record<string, unknown> }>)
766        .filter(one => typeof one.entity_id === 'string')
767        .map(one => (one.state === null ? { entity_id: one.entity_id, found: false, state: null } : { entity_id: one.entity_id, found: true, state: { state: one.state, attributes: one.attributes ?? {} } }))
768      if (rows.length > 0) await applyStates($, rows)
769      if (body.done === true) return
770    }
771  } finally {
772    isWatching = false
773    isWatchLive = false
774  }
775}
776
777/** Every card of a view in one ha_view_snapshot: states always, templates, history and cameras when due. */
778async function snapshot($: EngineInterface, server: string, cards: Card[], ids: string[], isNow: boolean): Promise<boolean> {
779  const all: Card[] = []
780  const walk = (card: Card) => {
781    all.push(card)
782    card.children.forEach(walk)
783  }
784  cards.forEach(walk)
785  const wantTemplates = isNow || Date.now() - lastTemplates > TEMPLATE_MS
786  const wantHistory = isNow || Date.now() - lastHistory > HISTORY_MS
787  const wantFrames = (isNow || Date.now() - lastFrames > FRAME_EVERY_MS) && Date.now() >= framesRetryAt
788  const templates = wantTemplates
789    ? Object.fromEntries(all.filter(c => c.markdown !== null && /\{\{|\{%/.test(c.markdown)).slice(0, 10).map(c => [c.key, c.markdown!]))
790    : {}
791  const points = Math.max(20, Math.min(400, (lastCardWidth - 4) * 2))
792  const graphs = wantHistory
793    ? all
794        .filter(c => c.hours !== null)
795        .slice(0, 8)
796        .map(c => ({ key: c.key, entity_ids: c.rows.flatMap(r => (r.kind === 'entity' ? [r.entity] : [])).slice(0, 20), hours: c.hours ?? 24, max_points: points }))
797        .filter(h => h.entity_ids.length > 0)
798    : []
799  const width = Math.max(16, Math.min(160, lastCardWidth - 4))
800  const cameras = wantFrames ? all.filter(c => c.camera).slice(0, 4).map(c => ({ key: c.key, entity_id: c.camera!, width, height: width })) : []
801  const text = await callMcp($, server, 'ha_view_snapshot', {
802    ...(ids.length > 0 ? { entity_ids: ids } : {}),
803    ...(Object.keys(templates).length > 0 ? { templates } : {}),
804    ...(graphs.length > 0 ? { history: graphs } : {}),
805    ...(cameras.length > 0 ? { frames: cameras } : {}),
806  }, true)
807  const body = text ? parseJson(text) : null
808  if (!body) {
809    // No such tool (an older MCP): the separate calls from now on.
810    if (text === null && !(await $.state.get(blocked)).value && Date.now() >= pausedUntil) hasSnapshot = false
811    return !hasSnapshot ? false : true
812  }
813  if (wantTemplates) lastTemplates = Date.now()
814  if (wantHistory) lastHistory = Date.now()
815  if (wantFrames) lastFrames = Date.now()
816
817  const got = body.states && typeof body.states === 'object' ? (body.states as Record<string, { state?: unknown; attributes?: Record<string, unknown> } | null | { error: string }>) : {}
818  if ('error' in got && typeof got.error === 'string') {
819    if (/\b429\b|rate.?limit|too many requests|exceeded/i.test(got.error)) await pause($)
820  } else if (ids.length > 0) {
821    const rows = ids.map(id => {
822      const one = got[id] as { state?: unknown; attributes?: Record<string, unknown> } | null | undefined
823      return one ? { entity_id: id, found: true, state: { state: one.state, attributes: one.attributes ?? {} } } : { entity_id: id, found: false, state: null }
824    })
825    await applyStates($, rows)
826  }
827  if (body.templates && typeof body.templates === 'object') {
828    const before = (await $.state.get(rendered)).value ?? {}
829    const next = { ...before }
830    for (const [key, value] of Object.entries(body.templates as Record<string, unknown>)) if (typeof value === 'string') next[key] = value
831    await $.state.set(rendered, next)
832  }
833  if (body.history && typeof body.history === 'object') {
834    const before = (await $.state.get(history)).value ?? {}
835    const next = { ...before }
836    for (const [key, value] of Object.entries(body.history as Record<string, unknown>)) {
837      if (!Array.isArray(value)) continue
838      const byEntity: Record<string, Array<number | string>> = {}
839      for (const one of value as Array<{ entity_id?: string; points?: Array<[string, number | string]> }>) {
840        if (one.entity_id && Array.isArray(one.points)) byEntity[one.entity_id] = one.points.map(([, v]) => v)
841      }
842      next[key] = byEntity
843    }
844    await $.state.set(history, next)
845  }
846  if (body.frames && typeof body.frames === 'object') {
847    const before = (await $.state.get(frames)).value ?? {}
848    const next = { ...before }
849    let problem: string | null = null
850    for (const [key, value] of Object.entries(body.frames as Record<string, Record<string, unknown>>)) {
851      if (typeof value.rgb === 'string' && typeof value.width === 'number' && typeof value.height === 'number') {
852        next[key] = { width: value.width, height: value.height, rgb: value.rgb, at: Date.now() }
853      } else if (typeof value.error === 'string') {
854        problem = value.error
855      }
856    }
857    await $.state.set(frames, next)
858    if (problem) {
859      framesRetryAt = Date.now() + 60_000
860      await $.state.set(note, /403|camera|sensitive/i.test(problem) ? `Camera: ${problem}. Through Vome the key needs Cameras ticked under Sensitive devices.` : `Camera: ${problem}`)
861    }
862  }
863  return true
864}
865
866async function readStates($: EngineInterface, server: string, ids: string[]) {
867  const text = await callMcp($, server, 'ha_get_state', { entity_ids: ids })
868  const body = text ? parseJson(text) : null
869  const rows = body && Array.isArray(body.entities) ? (body.entities as Record<string, unknown>[]) : []
870  await applyStates($, rows)
871}
872
873/** States as ha_get_state answers them ({entity_id, found, state: {state, attributes}}), into the pane. */
874async function applyStates($: EngineInterface, rows: Record<string, unknown>[]) {
875  if (rows.length === 0) return
876  // Every one "not found" at once is the hourly limit talking, not a home with none of them: keep
877  // what was last seen, pause the reads, and say so.
878  if (rows.length > 2 && rows.every(row => row.found === false)) {
879    await pause($)
880    return
881  }
882  const before = (await $.state.get(states)).value ?? {}
883  const next = { ...before }
884  let isChanged = false
885  for (const row of rows) {
886    if (typeof row.entity_id !== 'string') continue
887    // ha_get_state nests the whole state object under `state`: {entity_id, found, state: {state, attributes}}.
888    const held = row.state && typeof row.state === 'object' ? (row.state as Record<string, unknown>) : row
889    const value = held.state
890    const state = row.found === false ? 'not found' : value === null || value === undefined ? 'unknown' : typeof value === 'object' ? 'unknown' : String(value)
891    const attributes = held.attributes && typeof held.attributes === 'object' ? (held.attributes as Record<string, unknown>) : {}
892    const was = before[row.entity_id]
893    if (!was || was.state !== state || JSON.stringify(was.attributes) !== JSON.stringify(attributes)) isChanged = true
894    next[row.entity_id] = { state, attributes, at: Date.now() }
895  }
896  if (isChanged) await $.state.set(states, next)
897}
898
899/** Markdown cards with templates, rendered by the home: the dashboard's own words, not its source. */
900async function renderTemplates($: EngineInterface, server: string, cards: Card[]) {
901  const all: Card[] = []
902  const walk = (card: Card) => {
903    if (card.markdown !== null && /\{\{|\{%/.test(card.markdown)) all.push(card)
904    card.children.forEach(walk)
905  }
906  cards.forEach(walk)
907  if (all.length === 0) return
908  const before = (await $.state.get(rendered)).value ?? {}
909  const next = { ...before }
910  for (const card of all.slice(0, 6)) {
911    const text = await callMcp($, server, 'ha_render_template', { template: card.markdown })
912    if (text !== null) next[card.key] = text.replace(/\n?\[vome-instance\][^\n]*/g, '')
913  }
914  await $.state.set(rendered, next)
915}
916
917async function readHistory($: EngineInterface, server: string, cards: Card[]) {
918  const graphs: Card[] = []
919  const walk = (card: Card) => {
920    if (card.hours !== null) graphs.push(card)
921    card.children.forEach(walk)
922  }
923  cards.forEach(walk)
924  if (graphs.length === 0) return
925  const before = (await $.state.get(history)).value ?? {}
926  const next = { ...before }
927  for (const card of graphs.slice(0, 6)) {
928    const ids = card.rows.flatMap(row => (row.kind === 'entity' ? [row.entity] : []))
929    if (ids.length === 0) continue
930    const start = new Date(Date.now() - (card.hours ?? 24) * 3600_000).toISOString()
931    // As many points as the chart has dots across: a day of a sensor is thousands of states otherwise.
932    const points = Math.max(20, Math.min(400, (lastCardWidth - 4) * 2))
933    let text = hasMaxPoints ? await callMcp($, server, 'ha_get_history', { entity_ids: ids, start_time: start, minimal: true, max_points: points }, true) : null
934    if (text === null && hasMaxPoints) {
935      hasMaxPoints = false
936      text = await callMcp($, server, 'ha_get_history', { entity_ids: ids, start_time: start, minimal: true })
937    } else if (!hasMaxPoints) {
938      text = await callMcp($, server, 'ha_get_history', { entity_ids: ids, start_time: start, minimal: true })
939    }
940    const body = text ? parseJson(text) : null
941    const byEntity: Record<string, Array<number | string>> = {}
942    const compact = body && Array.isArray(body.series) ? (body.series as unknown[]).filter((s): s is { entity_id: string; points: Array<[string, number | string]> } => !!s && typeof s === 'object' && Array.isArray((s as { points?: unknown }).points)) : []
943    for (const one of compact) if (one.entity_id) byEntity[one.entity_id] = one.points.map(([, v]) => v)
944    const lists = compact.length === 0 && body && Array.isArray(body.series) ? (body.series as Record<string, unknown>[][]) : []
945    lists.forEach((list, i) => {
946      const id = typeof list[0]?.entity_id === 'string' ? (list[0].entity_id as string) : ids[i]
947      if (!id) return
948      byEntity[id] = list.map(point => {
949        const value = Number(point.state)
950        return Number.isFinite(value) && String(point.state).trim() !== '' ? value : String(point.state)
951      })
952    })
953    next[card.key] = byEntity
954  }
955  await $.state.set(history, next)
956}
957
958/** One call from the pane; a refusal shows what to allow, any other failure a note. */
959async function callMcp($: EngineInterface, server: string, tool: string, args: Record<string, unknown>, isQuiet = false): Promise<string | null> {
960  try {
961    const result = await $.mcp.call(server, tool, args)
962    const text = result.content.map(block => block.text ?? '').join('\n')
963    if (result.isError && isQuiet) return null
964    if (result.isError) {
965      if (/\b429\b|rate.?limit|too many requests|exceeded/i.test(text)) await pause($)
966      else await $.state.set(note, firstLine(text) ?? `${tool} failed.`)
967      return null
968    }
969    // A good answer clears an old note, but not the pause's while it lasts.
970    if (Date.now() >= pausedUntil && (await $.state.get(note)).value) await $.state.set(note, null)
971    return text
972  } catch (error) {
973    if (/auto mode|classifier|permission|denied|not allowed/i.test(String(error))) {
974      await $.state.set(blocked, { server, tool })
975    } else {
976      await $.state.set(note, firstLine(String(error)))
977    }
978    return null
979  }
980}
981
982/** Camera stills for the picture cards on show, as small grids of pixels to draw in half blocks. */
983async function readFrames($: EngineInterface, server: string, cards: Card[]) {
984  if (Date.now() < framesRetryAt) return
985  const cameras: Card[] = []
986  const walk = (card: Card) => {
987    if (card.camera) cameras.push(card)
988    card.children.forEach(walk)
989  }
990  cards.forEach(walk)
991  if (cameras.length === 0) return
992  const before = (await $.state.get(frames)).value ?? {}
993  const next = { ...before }
994  // One pixel a cell across, two down: a cell is about twice as tall as it is wide.
995  const width = Math.max(16, Math.min(160, lastCardWidth - 4))
996  for (const card of cameras.slice(0, 4)) {
997    const text = await callMcp($, server, 'ha_camera_frame', { entity_id: card.camera, width, height: width }, true)
998    const body = text ? parseJson(text) : null
999    if (!body) {
1000      // Not now (an older MCP, a key without Cameras): ask again in a minute, not never.
1001      framesRetryAt = Date.now() + 60_000
1002      if (!text) await $.state.set(note, 'Pictures need the MCP with ha_camera_frame, and a key with Cameras ticked.')
1003      continue
1004    }
1005    if (typeof body.rgb === 'string' && typeof body.width === 'number' && typeof body.height === 'number') {
1006      next[card.key] = { width: body.width, height: body.height, rgb: body.rgb, at: Date.now() }
1007    }
1008  }
1009  await $.state.set(frames, next)
1010}
1011
1012/** Reads refused for the hourly limit: pause them, keep what was last seen, and say so. */
1013async function pause($: EngineInterface) {
1014  pausedUntil = Date.now() + BACKOFF_MS
1015  await $.state.set(
1016    note,
1017    "Vome's hourly limit for reading this home's states was reached (500 an hour, shared with Claude's own reads). The pane shows the last states and reads again in five minutes.",
1018  )
1019}
1020
1021async function isShown($: EngineInterface): Promise<boolean> {
1022  try {
1023    return (await $.ui.panes()).some(pane => pane.id === PANE && pane.isShown && pane.isPlaced)
1024  } catch {
1025    return true
1026  }
1027}
1028
1029/** Cards into columns as Home Assistant's masonry does: each to the shortest column so far. */
1030function masonry(cards: Card[], columns: number): Card[][] {
1031  const out: Card[][] = Array.from({ length: columns }, () => [])
1032  const heights = new Array<number>(columns).fill(0)
1033  for (const card of cards) {
1034    if (card.isForeign && card.rows.length === 0 && card.children.length === 0 && !card.title) continue
1035    const shortest = heights.indexOf(Math.min(...heights))
1036    out[shortest]!.push(card)
1037    heights[shortest]! += heightOf(card)
1038  }
1039  return out.filter(column => column.length > 0)
1040}
1041
1042function heightOf(card: Card): number {
1043  const own = 2 + (card.title ? 1 : 0) + (card.markdown !== null ? Math.ceil(card.markdown.length / 50) + 1 : 0) + card.rows.length * (card.hours !== null ? 4 : 1)
1044  return own + card.children.reduce((sum, child) => sum + heightOf(child), 0)
1045}
1046
1047// ---------------------------------------------------------------- small things
1048
1049async function dashServers($: EngineInterface): Promise<string[]> {
1050  return (await $.tool.list())
1051    .map(tool => /^mcp__(.+)__ha_get_dashboard$/.exec(tool.name)?.[1])
1052    .filter((server): server is string => !!server)
1053}
1054
1055/** A slider's bar: filled to its position, a knob at the end. */
1056function sliderBar(fraction: number, width: number): string {
1057  const at = Math.round(Math.max(0, Math.min(1, fraction)) * (width - 1))
1058  return '━'.repeat(at) + '●' + '─'.repeat(Math.max(0, width - 1 - at))
1059}
1060
1061function since(at: number): string {
1062  const s = Math.max(0, Math.round((Date.now() - at) / 1000))
1063  return s < 90 ? `${s} s ago` : `${Math.round(s / 60)} min ago`
1064}
1065
1066/** A label cut to `width` characters with an ellipsis, so a button never wraps its bracket. */
1067function fit(text: string, width: number): string {
1068  return text.length <= width ? text : `${text.slice(0, Math.max(1, width - 1))}…`
1069}
1070
1071function formatState(state: string): string {
1072  const number = Number(state)
1073  if (state.trim() !== '' && Number.isFinite(number) && !Number.isInteger(number)) return String(Math.round(number * 100) / 100)
1074  return state.replace(/_/g, ' ')
1075}
1076
1077function firstLine(text: string | null): string | null {
1078  return text ? (text.split('\n').find(line => line.trim()) ?? null) : null
1079}
1080
1081/** The JSON body of a vome MCP reply, which ends with a `[vome-instance]` line. */
1082function parseJson(raw: string): Record<string, unknown> | null {
1083  const text = raw.replace(/\[vome-instance\][^\n]*/g, '')
1084  const start = text.search(/[[{]/)
1085  const end = Math.max(text.lastIndexOf('}'), text.lastIndexOf(']'))
1086  if (start < 0 || end <= start) return null
1087  try {
1088    const value = JSON.parse(text.slice(start, end + 1)) as unknown
1089    return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : null
1090  } catch {
1091    return null
1092  }
1093}
1094
hooks/lovelace.ts 496 lines
1// A Lovelace dashboard as the pane draws it: views of cards, each card a title
2// and rows, each row an entity (with the control that suits it), a button with
3// its action, or text. Pure: the config in, a model out, and the service call
4// a press makes. Cards the pane cannot draw keep their entities, so a custom
5// card still shows the things it is about.
6
7export type ServiceCall = {
8  domain: string
9  service: string
10  target?: { entity_id: string | string[] }
11  data?: Record<string, unknown>
12  /** Asked before it runs: "Wipe everything this fly has learned?" */
13  confirm?: string
14}
15
16export type Row =
17  | { kind: 'entity'; entity: string; name: string | null; icon?: string | null }
18  | { kind: 'button'; name: string; icon: string | null; call: ServiceCall | null; entity: string | null }
19  | { kind: 'text'; text: string }
20
21export type Card = {
22  /** Where it is, `v0/c3/c1`: stable across a save that changes other cards. */
23  key: string
24  type: string
25  title: string | null
26  rows: Row[]
27  /** Markdown cards: their content, templates and all. */
28  markdown: string | null
29  /** History graphs: hours shown, and the entities graphed. */
30  hours: number | null
31  children: Card[]
32  /** A card only Home Assistant can draw (custom:*, maps). */
33  isForeign: boolean
34  /** A picture card's camera, drawn from its frames. */
35  camera: string | null
36  /** Entities cards: whether the header has a switch for all of them (Home Assistant's default). */
37  headerToggle: boolean
38}
39
40export type View = { title: string; path: string | null; cards: Card[] }
41
42type Obj = Record<string, unknown>
43
44const isObj = (value: unknown): value is Obj => !!value && typeof value === 'object' && !Array.isArray(value)
45const str = (value: unknown): string | null => (typeof value === 'string' && value.trim() ? value : null)
46
47/**
48 * A title or name without the pictographs terminals disagree about the width of: those that
49 * default to text presentation (🌡, 🎛, ☀, ❄…), which one terminal draws in one cell and another
50 * in two, so everything after them on the row shifts and leaves stray borders. Emoji that are
51 * always emoji (💡, 🔥) are kept.
52 */
53export function steady(text: string): string {
54  return text.replace(/(?![\p{Emoji_Presentation}])\p{Extended_Pictographic}\uFE0F?\s*/gu, '').replace(/\uFE0F/g, '').trim() || text
55}
56const label = (value: unknown): string | null => {
57  const s = str(value)
58  return s ? steady(s) : null
59}
60
61export function viewsOf(config: unknown): View[] {
62  if (!isObj(config) || !Array.isArray(config.views)) return []
63  return config.views.filter(isObj).map((view, v) => {
64    const sections = Array.isArray(view.sections) ? view.sections.filter(isObj).flatMap(section => (Array.isArray(section.cards) ? section.cards : [])) : []
65    const cards = [...(Array.isArray(view.cards) ? view.cards : []), ...sections]
66    return {
67      title: label(view.title) ?? str(view.path) ?? `View ${v + 1}`,
68      path: str(view.path),
69      cards: cards.filter(isObj).map((card, c) => cardOf(card, `v${v}/c${c}`)),
70    }
71  })
72}
73
74function cardOf(card: Obj, key: string): Card {
75  const type = str(card.type) ?? 'unknown'
76  const base: Card = {
77    key,
78    type,
79    title: label(card.title) ?? label(card.name),
80    rows: [],
81    markdown: null,
82    hours: null,
83    children: [],
84    isForeign: false,
85    camera: null,
86    headerToggle: card.show_header_toggle !== false,
87  }
88  switch (type) {
89    case 'vertical-stack':
90    case 'horizontal-stack':
91    case 'grid':
92      return { ...base, children: (Array.isArray(card.cards) ? card.cards : []).filter(isObj).map((child, i) => cardOf(child, `${key}/c${i}`)) }
93    case 'markdown':
94      return { ...base, markdown: str(card.content) ?? '' }
95    case 'entities':
96    case 'glance':
97      return { ...base, rows: (Array.isArray(card.entities) ? card.entities : []).map(rowOf).filter((row): row is Row => row !== null) }
98    case 'history-graph':
99    case 'statistics-graph':
100      return {
101        ...base,
102        hours: typeof card.hours_to_show === 'number' ? card.hours_to_show : 24,
103        rows: (Array.isArray(card.entities) ? card.entities : []).map(rowOf).filter((row): row is Row => row !== null),
104      }
105    case 'picture-entity':
106    case 'picture-glance':
107    case 'picture-elements': {
108      const camera = [str(card.camera_image), str(card.entity)].find(e => e?.startsWith('camera.')) ?? null
109      const named = (Array.isArray(card.entities) ? card.entities : []).map(e => (isObj(e) ? str(e.entity) : str(e))).filter((e): e is string => !!e)
110      return { ...base, camera, title: base.title ?? (camera ? null : base.title), rows: named.map(entity => ({ kind: 'entity' as const, entity, name: null })), isForeign: !camera }
111    }
112    case 'button': {
113      const entity = str(card.entity)
114      const call = actionOf(card.tap_action, entity)
115      // Its name is the button's label: not a title over it as well.
116      return { ...base, title: null, rows: [{ kind: 'button', name: label(card.name) ?? entity ?? 'Button', icon: str(card.icon), call, entity }] }
117    }
118    case 'tile':
119    case 'light':
120    case 'thermostat':
121    case 'sensor':
122    case 'gauge':
123    case 'entity':
124    case 'humidifier':
125    case 'media-control':
126    case 'alarm-panel':
127    case 'weather-forecast': {
128      const entity = str(card.entity)
129      return { ...base, rows: entity ? [{ kind: 'entity', entity, name: str(card.name), icon: str(card.icon) }] : [] }
130    }
131    default: {
132      // A card the pane cannot draw: keep whatever entities it names.
133      const named = [str(card.entity), ...(Array.isArray(card.entities) ? card.entities.map(e => (isObj(e) ? str(e.entity) : str(e))) : [])].filter((e): e is string => !!e)
134      return { ...base, isForeign: true, rows: named.map(entity => ({ kind: 'entity' as const, entity, name: null })) }
135    }
136  }
137}
138
139function rowOf(row: unknown): Row | null {
140  if (typeof row === 'string') return { kind: 'entity', entity: row, name: null }
141  if (!isObj(row)) return null
142  const entity = str(row.entity)
143  if (row.type === 'button' || (!entity && row.tap_action)) {
144    return { kind: 'button', name: str(row.name) ?? str(row.action_name) ?? 'Run', icon: str(row.icon), call: actionOf(row.tap_action, entity), entity }
145  }
146  if (row.type === 'section' || row.type === 'divider') return str(row.label) ? { kind: 'text', text: str(row.label)! } : null
147  if (row.type === 'text' || row.type === 'attribute') return str(row.name) ? { kind: 'text', text: str(row.name)! } : null
148  return entity ? { kind: 'entity', entity, name: str(row.name), icon: str(row.icon) } : null
149}
150
151/** A tap_action as the service call it makes; navigation, more-info and URLs are Home Assistant's own. */
152export function actionOf(action: unknown, entity: string | null): ServiceCall | null {
153  if (!isObj(action)) return entity ? toggleFor(entity) : null
154  const kind = str(action.action)
155  const confirm = isObj(action.confirmation) ? (str(action.confirmation.text) ?? 'Are you sure?') : action.confirmation === true ? 'Are you sure?' : undefined
156  if (kind === 'toggle' && entity) {
157    const call = toggleFor(entity)
158    return call ? { ...call, ...(confirm ? { confirm } : {}) } : null
159  }
160  if (kind === 'perform-action' || kind === 'call-service') {
161    const name = str(action.perform_action) ?? str(action.service)
162    const [domain, service] = (name ?? '').split('.')
163    if (!domain || !service) return null
164    const target = isObj(action.target) && (typeof action.target.entity_id === 'string' || Array.isArray(action.target.entity_id))
165      ? { entity_id: action.target.entity_id as string | string[] }
166      : undefined
167    const data = isObj(action.data) ? action.data : isObj(action.service_data) ? action.service_data : undefined
168    return { domain, service, ...(target ? { target } : {}), ...(data ? { data } : {}), ...(confirm ? { confirm } : {}) }
169  }
170  return null
171}
172
173const TOGGLE_DOMAINS = new Set(['light', 'switch', 'fan', 'input_boolean', 'automation', 'siren', 'humidifier'])
174const RUN_DOMAINS: Record<string, string> = { scene: 'turn_on', script: 'turn_on', button: 'press', input_button: 'press' }
175
176/** What pressing an entity does: toggle the toggleable, run scenes and scripts, press buttons. */
177export function toggleFor(entity: string): ServiceCall | null {
178  const domain = entity.split('.')[0] ?? ''
179  if (TOGGLE_DOMAINS.has(domain)) return { domain, service: 'toggle', target: { entity_id: entity } }
180  if (RUN_DOMAINS[domain]) return { domain, service: RUN_DOMAINS[domain]!, target: { entity_id: entity } }
181  return null
182}
183
184/** A step up or down for things with a value: input_number and number, a light's brightness, a thermostat's target. */
185export function stepFor(entity: string, attributes: Obj, state: string, direction: 1 | -1): ServiceCall | null {
186  const domain = entity.split('.')[0] ?? ''
187  if (domain === 'input_number' || domain === 'number') {
188    const step = typeof attributes.step === 'number' ? attributes.step : 1
189    const min = typeof attributes.min === 'number' ? attributes.min : -Infinity
190    const max = typeof attributes.max === 'number' ? attributes.max : Infinity
191    const value = Math.min(max, Math.max(min, Number(state) + direction * step * stepScale(attributes)))
192    return Number.isFinite(value) ? { domain, service: 'set_value', target: { entity_id: entity }, data: { value: round(value, step) } } : null
193  }
194  if (domain === 'light' && state === 'on') return { domain, service: 'turn_on', target: { entity_id: entity }, data: { brightness_step_pct: direction * 20 } }
195  if (domain === 'climate') {
196    const target = typeof attributes.temperature === 'number' ? attributes.temperature : null
197    const step = typeof attributes.target_temp_step === 'number' ? attributes.target_temp_step : 0.5
198    return target === null ? null : { domain, service: 'set_temperature', target: { entity_id: entity }, data: { temperature: round(target + direction * step, step) } }
199  }
200  if (domain === 'cover') return { domain, service: direction > 0 ? 'open_cover' : 'close_cover', target: { entity_id: entity } }
201  return null
202}
203
204/** A slider with a hundred steps moves a tenth of its range a press: nobody presses + fifty times. */
205function stepScale(attributes: Obj): number {
206  const step = typeof attributes.step === 'number' ? attributes.step : 1
207  const span = typeof attributes.max === 'number' && typeof attributes.min === 'number' ? attributes.max - attributes.min : 0
208  return span > 0 && span / step > 20 ? Math.round(span / step / 10) : 1
209}
210
211function round(value: number, step: number): number {
212  const places = Math.max(0, (String(step).split('.')[1] ?? '').length)
213  return Number(value.toFixed(places))
214}
215
216/** Every entity a view shows, for the state reads. */
217export function entitiesOf(cards: Card[]): string[] {
218  const out = new Set<string>()
219  const walk = (card: Card) => {
220    for (const row of card.rows) if (row.kind !== 'text' && row.entity) out.add(row.entity)
221    card.children.forEach(walk)
222  }
223  cards.forEach(walk)
224  return [...out]
225}
226
227/** Cards a save added or changed, by key and content. */
228export function changedCards(before: unknown, after: unknown): Set<string> {
229  const flat = (config: unknown) => {
230    const out = new Map<string, string>()
231    if (!isObj(config) || !Array.isArray(config.views)) return out
232    config.views.forEach((view, v) => {
233      const cards = isObj(view) && Array.isArray(view.cards) ? view.cards : []
234      cards.forEach((card, c) => out.set(`v${v}/c${c}`, JSON.stringify(card)))
235    })
236    return out
237  }
238  const old = flat(before)
239  const now = flat(after)
240  return new Set([...now].filter(([key, json]) => old.get(key) !== json).map(([key]) => key))
241}
242
243/** "▁▂▄▇" from a series of numbers, `width` characters wide. */
244export function sparkline(values: number[], width: number): string {
245  if (values.length === 0) return ''
246  const blocks = '▁▂▃▄▅▆▇█'
247  const step = values.length / width
248  const picked = Array.from({ length: Math.min(width, values.length) }, (_, i) => values[Math.floor(i * Math.max(1, step))] ?? 0)
249  const min = Math.min(...picked)
250  const max = Math.max(...picked)
251  return picked.map(v => blocks[max === min ? 3 : Math.round(((v - min) / (max - min)) * 7)]).join('')
252}
253
254/** A domain's glyph, single-width. */
255export function iconFor(entity: string, state: string): string {
256  const domain = entity.split('.')[0] ?? ''
257  const on = state === 'on' || state === 'open' || state === 'home' || state === 'playing'
258  switch (domain) {
259    case 'light':
260      return on ? '●' : '○'
261    case 'switch':
262    case 'input_boolean':
263    case 'fan':
264    case 'automation':
265      return on ? '■' : '□'
266    case 'binary_sensor':
267      return on ? '◉' : '◌'
268    case 'sensor':
269    case 'input_number':
270    case 'number':
271      return '≈'
272    case 'climate':
273      return '❄'
274    case 'cover':
275      return '▤'
276    case 'scene':
277    case 'script':
278      return '▶'
279    case 'person':
280    case 'device_tracker':
281      return '☺'
282    default:
283      return '·'
284  }
285}
286
287const DOT = [
288  [0x01, 0x02, 0x04, 0x40],
289  [0x08, 0x10, 0x20, 0x80],
290]
291
292/**
293 * A line chart in braille, `width` characters by `rows` lines (2 x 4 dots a character): each
294 * value a dot, joined to the next by a vertical run so a jump reads as a line, not a gap.
295 */
296export function brailleChart(values: number[], width: number, rows: number): string[] {
297  const w = Math.max(2, width * 2)
298  const h = Math.max(4, rows * 4)
299  if (values.length === 0) return Array.from({ length: rows }, () => ' '.repeat(width))
300  // One point per dot column: the average of the values that fall in it.
301  const points = Array.from({ length: w }, (_, x) => {
302    const from = Math.floor((x / w) * values.length)
303    const to = Math.max(from + 1, Math.floor(((x + 1) / w) * values.length))
304    const slice = values.slice(from, to)
305    return slice.reduce((a, b) => a + b, 0) / slice.length
306  })
307  const min = Math.min(...points)
308  const max = Math.max(...points)
309  const yOf = (v: number) => (max === min ? Math.floor(h / 2) : Math.round((1 - (v - min) / (max - min)) * (h - 1)))
310  const cells = Array.from({ length: rows }, () => new Array<number>(width).fill(0))
311  const dot = (x: number, y: number) => {
312    const row = Math.floor(y / 4)
313    const col = Math.floor(x / 2)
314    if (row >= 0 && row < rows && col >= 0 && col < width) cells[row]![col]! |= DOT[x % 2]![y % 4]!
315  }
316  let last: number | null = null
317  points.forEach((v, x) => {
318    const y = yOf(v)
319    if (last !== null) for (let k = Math.min(last, y); k <= Math.max(last, y); k++) dot(x, k)
320    dot(x, y)
321    last = y
322  })
323  return cells.map(row => row.map(bits => String.fromCharCode(0x2800 + bits)).join(''))
324}
325
326/** Home Assistant's mdi icons as emoji that draw two cells wide everywhere (no variation selectors). */
327const MDI: Array<[RegExp, string]> = [
328  [/lightbulb|lamp|ceiling-light|light-recessed|led/, '💡'],
329  [/power-socket|power-plug|outlet/, '🔌'],
330  [/water|humidity|drop/, '💧'],
331  [/motion|walk|run|human-male|account-arrow/, '🚶'],
332  [/door/, '🚪'],
333  [/window|blinds|curtain|shutter/, '🪟'],
334  [/fan/, '🌀'],
335  [/lock-open/, '🔓'],
336  [/lock/, '🔒'],
337  [/garage|car/, '🚗'],
338  [/home|house/, '🏠'],
339  [/account|person|human|face/, '👤'],
340  [/battery/, '🔋'],
341  [/fire|radiator|heat|flame/, '🔥'],
342  [/snowflake|ice|freezer/, '🧊'],
343  [/television|tv/, '📺'],
344  [/speaker|music|play/, '🎵'],
345  [/bell|alarm/, '🔔'],
346  [/camera|cctv|cam/, '📷'],
347  [/candy|food/, '🍬'],
348  [/hand/, '✋'],
349  [/delete|broom|sweep|trash/, '🧹'],
350  [/butterfly|bug|fly/, '🪰'],
351  [/brain/, '🧠'],
352  [/radar|wifi|antenna|signal/, '📡'],
353  [/chart|graph|trending/, '📈'],
354  [/flash|lightning|power|energy|meter-electric/, '⚡'],
355  [/weather-sunny|sun/, '🌞'],
356  [/moon|night|sleep/, '🌙'],
357  [/robot|auto/, '🤖'],
358  // A thermometer emoji is text-presentation: terminals draw it one cell or two, and the row shifts.
359  [/thermometer|temperature/, '🔥'],
360]
361
362/** The picture an entity is drawn with: its own mdi icon, else what kind of thing it is. */
363export function glyphFor(entity: string, attributes: Record<string, unknown>, icon?: string | null): string {
364  const mdi = (icon ?? (typeof attributes.icon === 'string' ? attributes.icon : '')).replace(/^mdi:/, '')
365  if (mdi) for (const [pattern, glyph] of MDI) if (pattern.test(mdi)) return glyph
366  const domain = entity.split('.')[0] ?? ''
367  const kind = typeof attributes.device_class === 'string' ? attributes.device_class : ''
368  switch (domain) {
369    case 'light':
370      return '💡'
371    case 'switch':
372      return '🔌'
373    case 'fan':
374      return '🌀'
375    case 'lock':
376      return '🔒'
377    case 'cover':
378      return '🪟'
379    case 'climate':
380    case 'water_heater':
381      return '🔥'
382    case 'person':
383    case 'device_tracker':
384      return '👤'
385    case 'media_player':
386      return '🎵'
387    case 'camera':
388      return '📷'
389    case 'automation':
390      return '🤖'
391    case 'scene':
392      return '🎬'
393    case 'script':
394      return '📜'
395    case 'input_boolean':
396    case 'button':
397    case 'input_button':
398      return '🔘'
399    case 'input_number':
400    case 'number':
401      return '🔢'
402    case 'binary_sensor':
403      return /occupancy|motion|presence/.test(kind) ? '🚶' : /door|garage/.test(kind) ? '🚪' : /window/.test(kind) ? '🪟' : /smoke|heat/.test(kind) ? '🔥' : /moisture/.test(kind) ? '💧' : '🔵'
404    case 'sensor':
405      return /temperature/.test(kind) ? '🔥' : /humidity|moisture/.test(kind) ? '💧' : /battery/.test(kind) ? '🔋' : /power|energy|current|voltage/.test(kind) ? '⚡' : /illuminance/.test(kind) ? '🌞' : /distance/.test(kind) ? '📏' : '📊'
406    default:
407      return '🔵'
408  }
409}
410
411/**
412 * A lit light's glow, as a background for its row: the light's own colour (or warm white), mixed
413 * into the dark panel by how bright it is. Null for anything that is not a light that is on.
414 */
415export function glowFor(entity: string, state: string, attributes: Record<string, unknown>): string | null {
416  if (!entity.startsWith('light.') || state !== 'on') return null
417  const rgb = Array.isArray(attributes.rgb_color) && attributes.rgb_color.length === 3 ? (attributes.rgb_color as number[]) : [255, 196, 96]
418  const level = typeof attributes.brightness === 'number' ? attributes.brightness / 255 : 1
419  const mix = 0.18 + 0.4 * level
420  const base = [24, 26, 32]
421  const hex = rgb.map((c, i) => Math.round(base[i]! + (c - base[i]!) * mix).toString(16).padStart(2, '0')).join('')
422  return `#${hex}`
423}
424
425/** A lit light's colour for its state text: its own, or warm white. */
426export function lightColour(attributes: Record<string, unknown>): string {
427  const rgb = Array.isArray(attributes.rgb_color) && attributes.rgb_color.length === 3 ? (attributes.rgb_color as number[]) : [255, 204, 102]
428  return `#${rgb.map(c => Math.round(c).toString(16).padStart(2, '0')).join('')}`
429}
430
431
432/**
433 * A picture in half blocks for a Raster: two pixels a cell, the upper one the
434 * foreground of an upper half block and the lower one its background. `rgb` is
435 * 3 bytes a pixel, `width` x `height`; the cells are 12 bytes each (code point,
436 * foreground, background), base64, as a Raster takes them.
437 */
438export function halfBlocks(rgb: Uint8Array, width: number, height: number): { cells: string; columns: number; rows: number } {
439  const rows = Math.ceil(height / 2)
440  const bytes = new Uint8Array(width * rows * 12)
441  const view = new DataView(bytes.buffer)
442  const at = (x: number, y: number) => {
443    if (y >= height) return 0x000000
444    const i = (y * width + x) * 3
445    return ((rgb[i] ?? 0) << 16) | ((rgb[i + 1] ?? 0) << 8) | (rgb[i + 2] ?? 0)
446  }
447  for (let r = 0; r < rows; r++) {
448    for (let c = 0; c < width; c++) {
449      const cell = (r * width + c) * 12
450      view.setUint32(cell, 0x2580, true)
451      view.setUint32(cell + 4, at(c, r * 2), true)
452      view.setUint32(cell + 8, at(c, r * 2 + 1), true)
453    }
454  }
455  let out = ''
456  const A = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
457  for (let i = 0; i < bytes.length; i += 3) {
458    const n = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
459    out += A[(n >> 18) & 63]! + A[(n >> 12) & 63]!
460    out += i + 1 < bytes.length ? A[(n >> 6) & 63]! : '='
461    out += i + 2 < bytes.length ? A[n & 63]! : '='
462  }
463  return { cells: out, columns: width, rows }
464}
465
466/** base64 to bytes, without a Buffer. */
467export function fromBase64(text: string): Uint8Array {
468  const A = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
469  const clean = text.replace(/[^A-Za-z0-9+/]/g, '')
470  const out = new Uint8Array(Math.floor((clean.length * 3) / 4))
471  let o = 0
472  for (let i = 0; i < clean.length; i += 4) {
473    const n = (A.indexOf(clean[i]!) << 18) | (A.indexOf(clean[i + 1] ?? 'A') << 12) | (A.indexOf(clean[i + 2] ?? 'A') << 6) | A.indexOf(clean[i + 3] ?? 'A')
474    if (o < out.length) out[o++] = (n >> 16) & 255
475    if (o < out.length) out[o++] = (n >> 8) & 255
476    if (o < out.length) out[o++] = n & 255
477  }
478  return out
479}
480
481/**
482 * Every read the pane makes in the background, all of them read-only: what auto mode needs
483 * allowed by name. Kept beside the calls; a tool the pane starts using must be added here, or the
484 * box asking for permission names the wrong lines (it missed the snapshot, watch and camera reads).
485 */
486export const READS = [
487  'ha_view_snapshot',
488  'ha_watch_states',
489  'ha_camera_frame',
490  'ha_get_state',
491  'ha_render_template',
492  'ha_get_history',
493  'ha_get_dashboard',
494  'ha_list_dashboards',
495]
496
types/index.d.ts 51 lines
1/** The dashboard in the pane: where it came from and its Lovelace config. */
2export type Dash = {
3  server: string
4  urlPath: string
5  title: string
6  config: Record<string, unknown>
7  at: number
8}
9
10/** A press on its way: sent to Home Assistant, accepted, and then seen to change the device (or not). */
11export type Pending = {
12  phase: 'sending' | 'sent' | 'done' | 'quiet' | 'failed'
13  at: number
14  /** The entity the press is about, and how it looked before, to see it change. */
15  entity: string | null
16  before: string | null
17}
18
19/** One entity's live state, as the pane last read it. */
20export type Live = { state: string; attributes: Record<string, unknown>; at: number }
21
22declare module 'claude-code' {
23  interface PluginState {
24    'vome-dash': {
25      dash: Dash | null
26      /** Which view is shown, by index. */
27      view: number
28      states: Record<string, Live>
29      /** Cards a save added or changed, lit for a few seconds. */
30      lit: { keys: string[]; at: number } | null
31      /** Markdown cards with templates, as the home renders them, by card key. */
32      rendered: Record<string, string>
33      /** History graphs' series by card key, then entity: numbers, or the states it went through. */
34      history: Record<string, Record<string, Array<number | string>>>
35      /** A press that asks first: which control, until when. */
36      confirm: { key: string; until: number } | null
37      /** The home's dashboards: the sidebar. */
38      choices: Array<{ urlPath: string; title: string }> | null
39      /** Presses on their way, by control. */
40      pending: Record<string, Pending>
41      /** The dashboards sidebar, shown until one is picked. */
42      sidebarOpen: boolean
43      /** Camera frames by card key: pixels to draw in half blocks. */
44      frames: Record<string, { width: number; height: number; rgb: string; at: number }>
45      /** Auto mode refused one of the pane's calls: on which server, which tool. */
46      blocked: { server: string; tool: string } | null
47      note: string | null
48    }
49  }
50}
51