/** Polls the backend's Home Assistant proxy for the configured devices/scenes and * exposes a `callService` escape hatch for commands. No websocket: HA's own * auth/subscribe protocol is more machinery than a room panel needs - a short poll is * plenty, and it's the backend, not the browser, doing the actual HA calls (see * `GET/POST /api/ha/...` in the Python backend), so there's no token or Home * Assistant URL here at all. */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { api } from "../api/client"; import type { HaConfig, HaEntityState } from "../api/types"; const POLL_INTERVAL_MS = 2500; /** How long a tap's optimistic state wins over polled state. A device reports back * after HA has already answered the service call - a Hue light about a second later - * so a poll that *starts* just after the tap can still return the old colour and flip * the card back, which reads as "the tap did not work". */ const OPTIMISTIC_HOLD_MS = 4000; export interface HomeAssistant { states: Record; loading: boolean; /** Patch one entity's cached state locally so a tap feels instant; the next poll * reconciles with the truth. */ optimistic: (entityId: string, patch: Partial) => void; callService: (domain: string, service: string, body: Record) => Promise; } export function useHomeAssistant(config: HaConfig | null): HomeAssistant { const [states, setStates] = useState>({}); const [loading, setLoading] = useState(true); // When each entity last got an optimistic patch. A poll that was already in flight // when the patch landed resolves with pre-click data - without this, that stale // response clobbers the optimistic "on" back to "off" until the *next* poll catches // up, which is what made toggling feel laggy despite the optimistic update existing. // // `usePlayerState`'s `optimistic()` solves the same "local guess vs. eventual truth" // problem far more simply, by just patching the whole object - it can get away with // that because the backend pushes over a websocket rather than being polled, so // there's no in-flight request that can resolve late and stomp on a newer patch. const optimisticAt = useRef>({}); const entityIds = useMemo( () => [...(config?.devices ?? []), ...(config?.scenes ?? [])].map((d) => d.entity_id), [config], ); useEffect(() => { if (!config) return; let cancelled = false; const poll = async () => { const startedAt = Date.now(); const fresh = await api.haStates(entityIds); if (cancelled) return; setStates((previous) => { const next = { ...previous }; for (const [entityId, state] of Object.entries(fresh)) { // Only apply this result if it went out well after the last optimistic // patch - otherwise it may predate the device catching up and would undo a // newer change. See OPTIMISTIC_HOLD_MS. if (startedAt >= (optimisticAt.current[entityId] ?? -Infinity) + OPTIMISTIC_HOLD_MS) { next[entityId] = state; } } return next; }); setLoading(false); }; void poll(); const id = setInterval(() => void poll(), POLL_INTERVAL_MS); return () => { cancelled = true; clearInterval(id); }; }, [config, entityIds]); const optimistic = useCallback((entityId: string, patch: Partial) => { optimisticAt.current[entityId] = Date.now(); setStates((previous) => { const current = previous[entityId]; return current ? { ...previous, [entityId]: { ...current, ...patch } } : previous; }); }, []); const callService = useCallback( (domain: string, service: string, body: Record) => { if (!config) return Promise.resolve(); // Card components fire this with `void` - swallow a failure here (e.g. HA // unreachable, proxied as a 502) so it doesn't surface as an unhandled // rejection; the next poll corrects any optimistic update that didn't take. return api.haCallService(domain, service, body).catch((cause: unknown) => { console.error(`${domain}.${service} failed:`, cause); }); }, [config], ); return { states, loading, optimistic, callService }; }