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…

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.

Presses are yours: Claude never presses anything in the pane.
1. Add the Vome app to Home Assistant, then get a key from it. No sign-up needed.
The button opens your own Home Assistant and adds the app's repository. Pick Vome and Install, open it, and on the Agent tab create a key. 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.

3. Run /dash, pick a dashboard, or ask Claude to build one: "Make me a dashboard for the lights and the temperatures".
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.
/dash | open the pane and pick a dashboard (/dash <url_path> opens one) |
| m | the dashboards sidebar |
| r | reload the dashboard |
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.
hooks/register.tsx 1094 lines1// 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}
1094hooks/lovelace.ts 496 lines1// 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]
496types/index.d.ts 51 lines1/** 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