From 861e1c9137e046173e5f2209c869d347c7e48d4c Mon Sep 17 00:00:00 2001 From: Aaron Toulmin Date: Mon, 17 Aug 2026 12:04:16 +1000 Subject: [PATCH] Add Assist, cameras, energy row, and dual-path connection Talk to Home Assistant Assist from the bar, show configurable camera stills and an energy summary, and fail over from a local URL to Nabu Casa when the LAN is unreachable. Assist uses the preferred or selected pipeline over the existing websocket. Cameras refresh only while the panel is open; a click plays the go2rtc main stream in a muted mpv window. Settings gain a Panel tab for cameras, battery sensors, and pipeline, plus separate local and remote addresses. --- AGENTS.md | 8 +- Assist.js | 60 +++++ CameraThumb.qml | 46 ++++ Cameras.js | 118 +++++++++ ConfigStore.js | 35 ++- Connection.js | 18 ++ Panel.qml | 440 ++++++++++++++++++++++++++++++++- Powerwall.js | 101 ++++++++ README.md | 55 ++++- Service.qml | 436 +++++++++++++++++++++++++++++--- Settings.qml | 201 ++++++++++++++- bin/hass-bridge | 391 ++++++++++++++++++++++++++++- manifest.json | 6 +- mpv-preview.conf | 5 + tests/fake_ha.py | 42 +++- tests/test_assist.js | 72 ++++++ tests/test_bridge.py | 238 +++++++++++++++++- tests/test_cameras.js | 63 +++++ tests/test_config.js | 27 +- tests/test_connection.js | 10 + tests/test_powerwall.js | 69 ++++++ tests/test_service_contract.py | 14 ++ 22 files changed, 2379 insertions(+), 76 deletions(-) create mode 100644 Assist.js create mode 100644 CameraThumb.qml create mode 100644 Cameras.js create mode 100644 Powerwall.js create mode 100644 mpv-preview.conf create mode 100644 tests/test_assist.js create mode 100644 tests/test_cameras.js create mode 100644 tests/test_powerwall.js diff --git a/AGENTS.md b/AGENTS.md index 2bc620c..1387a5e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,10 @@ first-run downloads. Python 3.11 or newer, `secret-tool`, and the vendored - `Model.js`: entity display policy, capabilities, action classification, and attribute redaction. - `RowModel.js`: projection from an entity into a QML `ListModel` row. -- `Panel.qml`: bar widget, popup, keyboard navigation, and IPC surface. +- `Assist.js`: user-text and conversation-id sanitization, transcript rows. +- `Powerwall.js`: charge, home load, and battery-flow projection for the panel. +- `Cameras.js`: Frigate camera catalog and snapshot path rules. +- `Panel.qml`: bar widget, popup, Assist composer, keyboard navigation, and IPC surface. - `Settings.qml`: connection settings and entity picker. - `controls/`: domain-specific expanded controls. - `bin/hass-bridge`: WebSocket protocol adapter and demo backend. @@ -100,6 +103,9 @@ node tests/test_connection.js node tests/test_store.js node tests/test_model.js node tests/test_row_model.js +node tests/test_assist.js +node tests/test_powerwall.js +node tests/test_cameras.js python3 -m py_compile bin/hass-bridge tests/*.py bash -n scripts/update-websockets-vendor ``` diff --git a/Assist.js b/Assist.js new file mode 100644 index 0000000..fd14154 --- /dev/null +++ b/Assist.js @@ -0,0 +1,60 @@ +.pragma library + +// Stateless Assist helpers. The bridge already strips Home Assistant's +// conversation payload down to speech / ids; this module validates what the +// shell still touches: the user's sentence, the conversation id we send back, +// and the bounded transcript ListModel. + +var MAX_TEXT = 1000 +var MAX_SPEECH = 4000 +var MAX_CONVERSATION_ID = 128 +var MAX_MESSAGES = 20 +var IDLE_MS = 60000 + +function normalizeText(text) { + if (typeof text !== "string") return "" + var trimmed = text.trim() + if (!trimmed) return "" + return trimmed.length > MAX_TEXT ? trimmed.slice(0, MAX_TEXT) : trimmed +} + +function sanitizeConversationId(value) { + if (typeof value !== "string") return "" + var id = value.trim() + if (!id || id.length > MAX_CONVERSATION_ID) return "" + for (var i = 0; i < id.length; i++) { + if (id.charCodeAt(i) < 32) return "" + } + return id +} + +function speechFromResult(event) { + if (!event || typeof event !== "object") return "" + if (typeof event.speech !== "string") return "" + var speech = event.speech.trim() + if (!speech) return "" + return speech.length > MAX_SPEECH ? speech.slice(0, MAX_SPEECH) : speech +} + +function projectResult(event) { + var ok = !!(event && event.ok === true) + var speech = speechFromResult(event) + return { + ok: ok, + speech: ok ? (speech || "Done.") : "", + conversationId: sanitizeConversationId(event && event.conversation_id), + continueConversation: !!(event && event.continue_conversation), + responseType: (event && typeof event.response_type === "string") + ? event.response_type : "", + error: (!ok && event && typeof event.error === "string" && event.error) + ? event.error : "Assist could not complete that request." + } +} + +function messageFor(speaker, body) { + var allowed = speaker === "user" || speaker === "assist" || speaker === "error" + return { + speaker: allowed ? speaker : "assist", + body: typeof body === "string" ? body : "" + } +} diff --git a/CameraThumb.qml b/CameraThumb.qml new file mode 100644 index 0000000..2300dfd --- /dev/null +++ b/CameraThumb.qml @@ -0,0 +1,46 @@ +import QtQuick +import qs.Commons + +// Double-buffered still. The visible frame stays put until the next +// snapshot has decoded, so a refresh does not flash the placeholder. +Item { + id: root + + property url frame: "" + property color fill: "#000000" + + Rectangle { + anchors.fill: parent + radius: Style.cornerRadius + color: root.fill + clip: true + + Image { + id: imageA + anchors.fill: parent + fillMode: Image.PreserveAspectCrop + asynchronous: true + cache: true + visible: root.useA && status === Image.Ready + onStatusChanged: if (!root.useA && status === Image.Ready) root.useA = true + } + + Image { + id: imageB + anchors.fill: parent + fillMode: Image.PreserveAspectCrop + asynchronous: true + cache: true + visible: !root.useA && status === Image.Ready + onStatusChanged: if (root.useA && status === Image.Ready) root.useA = false + } + } + + property bool useA: true + + onFrameChanged: { + if (!frame || frame.toString() === "") return + if (root.useA) imageB.source = frame + else imageA.source = frame + } +} diff --git a/Cameras.js b/Cameras.js new file mode 100644 index 0000000..38c9f15 --- /dev/null +++ b/Cameras.js @@ -0,0 +1,118 @@ +.pragma library + +// The four Frigate cameras shown above Powerwall. Still frames only. + +var CAMERAS = [ + { id: "camera.frontyard", title: "Frontyard", stream: "Frontyard" }, + { id: "camera.driveway", title: "Driveway", stream: "Driveway" }, + { id: "camera.backyard", title: "Backyard", stream: "Backyard" }, + { id: "camera.rear", title: "Rearyard", stream: "Rear" } +] + +function defaultIds() { + var out = [] + for (var i = 0; i < CAMERAS.length; i++) out.push(CAMERAS[i].id) + return out +} + +function ids(configured, states) { + if (Array.isArray(configured) && configured.length) + return configured.slice(0, 6) + var defaults = defaultIds() + if (!states) return defaults + for (var i = 0; i < defaults.length; i++) { + if (!isMissing(states[defaults[i]])) return defaults + } + return [] +} + +function titleFor(entityId, entity) { + for (var i = 0; i < CAMERAS.length; i++) { + if (CAMERAS[i].id === entityId) return CAMERAS[i].title + } + if (entity && entity.attributes && typeof entity.attributes.friendly_name === "string" + && entity.attributes.friendly_name.trim()) { + return entity.attributes.friendly_name.trim() + } + var slug = String(entityId || "") + var dot = slug.indexOf(".") + slug = dot === -1 ? slug : slug.slice(dot + 1) + if (!slug) return "Camera" + return slug.charAt(0).toUpperCase() + slug.slice(1).replace(/_/g, " ") +} + +function isMissing(entity) { + if (!entity) return true + var state = typeof entity.state === "string" ? entity.state : "" + return state === "" || state === "unavailable" || state === "unknown" +} + +function tiles(states, configured) { + var map = states && typeof states === "object" ? states : {} + var chosen = ids(configured) + var out = [] + for (var i = 0; i < chosen.length; i++) { + var id = chosen[i] + out.push({ + entityId: id, + title: titleFor(id, map[id]), + available: !isMissing(map[id]) + }) + } + return out +} + +function anyAvailable(states, configured) { + var list = tiles(states, configured) + for (var i = 0; i < list.length; i++) { + if (list[i].available) return true + } + return false +} + +function hostOf(baseUrl) { + var text = String(baseUrl || "") + var scheme = text.indexOf("://") + if (scheme < 0) return "" + var rest = text.slice(scheme + 3) + var cut = rest.search(/[\/?#]/) + var authority = cut === -1 ? rest : rest.slice(0, cut) + if (!authority || authority.indexOf("@") !== -1) return "" + if (authority.charAt(0) === "[") { + var close = authority.indexOf("]") + if (close <= 1) return "" + return authority.slice(0, close + 1) + } + var colon = authority.lastIndexOf(":") + return colon === -1 ? authority : authority.slice(0, colon) +} + +function streamName(entityId, entity) { + for (var i = 0; i < CAMERAS.length; i++) { + if (CAMERAS[i].id === entityId) return CAMERAS[i].stream + } + if (entity && entity.attributes && typeof entity.attributes.camera_name === "string" + && /^[A-Za-z0-9_]+$/.test(entity.attributes.camera_name)) { + return entity.attributes.camera_name + } + var slug = String(entityId || "") + var dot = slug.indexOf(".") + slug = dot === -1 ? slug : slug.slice(dot + 1) + if (!slug) return "" + return slug.charAt(0).toUpperCase() + slug.slice(1) +} + +function streamUrl(baseUrl, entityId, entity) { + var name = streamName(entityId, entity) + var host = hostOf(baseUrl) + if (!name || !host) return "" + if (!/^[A-Za-z0-9_]+$/.test(name)) return "" + return "rtsp://" + host + ":8554/" + name +} + +function fileSource(path, revision) { + if (typeof path !== "string" || path.indexOf("/") !== 0) return "" + if (path.indexOf("..") !== -1) return "" + var rev = typeof revision === "number" ? revision : 0 + return "file://" + path + "?r=" + rev +} diff --git a/ConfigStore.js b/ConfigStore.js index e1d2d2d..9c48209 100644 --- a/ConfigStore.js +++ b/ConfigStore.js @@ -1,8 +1,10 @@ .pragma library var KEYS = [ - "baseUrl", "demoMode", "favorites", "demoFavorites", "groupByArea", - "showEntityIcons", "selectedTab", "displayNameOverrides", "iconOverrides" + "baseUrl", "localUrl", "remoteUrl", "demoMode", "favorites", "demoFavorites", "groupByArea", + "showEntityIcons", "selectedTab", "displayNameOverrides", "iconOverrides", + "cameraIds", "chargeEntityId", "batteryPowerEntityId", "loadPowerEntityId", + "assistPipelineId" ] function stringList(value, fallback) { @@ -20,6 +22,18 @@ function stringList(value, fallback) { return out } +function entityId(value) { + if (typeof value !== "string") return "" + return /^[a-z0-9_]+\.[a-z0-9_]+$/.test(value) ? value : "" +} + +function pipelineId(value) { + if (typeof value !== "string") return "" + var id = value.trim() + if (!id || id.length > 128) return "" + return /^[A-Za-z0-9_-]+$/.test(id) ? id : "" +} + function plainMap(value) { if (!value || typeof value !== "object" || Array.isArray(value)) return {} var out = {} @@ -47,7 +61,13 @@ function parse(text, demoDefaults) { return { error: error, config: { - baseUrl: typeof raw.baseUrl === "string" ? raw.baseUrl : "", + baseUrl: typeof raw.localUrl === "string" && raw.localUrl + ? raw.localUrl + : (typeof raw.baseUrl === "string" ? raw.baseUrl : ""), + localUrl: typeof raw.localUrl === "string" && raw.localUrl + ? raw.localUrl + : (typeof raw.baseUrl === "string" ? raw.baseUrl : ""), + remoteUrl: typeof raw.remoteUrl === "string" ? raw.remoteUrl : "", demoMode: raw.demoMode === true, favorites: stringList(raw.favorites, []), demoFavorites: stringList(raw.demoFavorites, @@ -57,7 +77,14 @@ function parse(text, demoDefaults) { selectedTab: typeof raw.selectedTab === "string" && raw.selectedTab ? raw.selectedTab : "favorites", displayNameOverrides: plainMap(raw.displayNameOverrides), - iconOverrides: plainMap(raw.iconOverrides) + iconOverrides: plainMap(raw.iconOverrides), + cameraIds: stringList(raw.cameraIds, []).filter(function(id) { + return id.indexOf("camera.") === 0 + }).slice(0, 6), + chargeEntityId: entityId(raw.chargeEntityId), + batteryPowerEntityId: entityId(raw.batteryPowerEntityId), + loadPowerEntityId: entityId(raw.loadPowerEntityId), + assistPipelineId: pipelineId(raw.assistPipelineId) } } } diff --git a/Connection.js b/Connection.js index fcd02aa..86361ff 100644 --- a/Connection.js +++ b/Connection.js @@ -76,6 +76,24 @@ function normalizeOrigin(value) { // // Empty when the URL cannot be normalized; callers treat that as invalid // rather than as a connection worth starting. +function isNabuCasa(value) { + var origin = normalizeOrigin(value) + if (!origin) return false + var host = origin.slice(origin.indexOf("://") + 3) + var colon = host.lastIndexOf(":") + if (colon !== -1) host = host.slice(0, colon) + return host.indexOf(".ui.nabu.casa") !== -1 || host === "ui.nabu.casa" + || host.indexOf(".nabu.casa") !== -1 +} + +function connectionLabel(demoMode, route, remoteUrl) { + if (demoMode) return "Demo" + if (route === "remote") { + return isNabuCasa(remoteUrl) ? "Connected via Nabu Casa" : "Connected remotely" + } + return "Connected locally" +} + function signature(demoMode, value) { if (demoMode) return "demo" var origin = normalizeOrigin(value) diff --git a/Panel.qml b/Panel.qml index 10850ba..48349e0 100644 --- a/Panel.qml +++ b/Panel.qml @@ -20,6 +20,10 @@ Panel { readonly property string phase: serviceReady ? hass.phase : "idle" property string expandedEntityId: "" + property bool cameraViewerOpen: false + property string cameraViewerId: "" + property string cameraViewerTitle: "" + property string cameraStream: "" // One cursor for keyboard and mouse, per the CursorSurface contract. // Dormant until a key is pressed. @@ -29,11 +33,95 @@ Panel { readonly property int rowCount: serviceReady ? hass.rows.count : 0 readonly property bool hasDevices: serviceReady && hass.hasDevices readonly property var tabs: serviceReady ? hass.tabs : [] + readonly property var powerwall: (serviceReady && hass) + ? hass.powerwall + : ({ available: false, icon: "", subtitle: "", percentText: "—", + fraction: 0, charging: false }) + readonly property var cameras: (serviceReady && hass) ? hass.cameraTiles : [] + readonly property bool camerasAvailable: serviceReady && hass && hass.camerasAvailable + + onOpenedChanged: { + if (root.serviceReady) root.hass.setCameraWatching(opened) + if (!opened) { + expandedEntityId = "" + cursorActive = false + cursorIndex = 0 + root.closeCameraViewer() + return + } + root.scheduleScrollAssistToEnd() + } - onOpenedChanged: if (!opened) { - expandedEntityId = "" - cursorActive = false - cursorIndex = 0 + Component.onDestruction: { + if (root.opened && root.serviceReady) root.hass.setCameraWatching(false) + } + + function cameraSource(entityId) { + return root.serviceReady ? root.hass.cameraSource(entityId) : "" + } + + function openCameraViewer(cam) { + if (!cam || !cam.entityId || !root.serviceReady) return + var stream = root.hass.cameraStreamUrl(cam.entityId) + if (!stream) return + root.cameraViewerId = cam.entityId + root.cameraViewerTitle = String(cam.title || "").toUpperCase() + root.cameraStream = stream + root.cameraViewerOpen = true + } + + function closeCameraViewer() { + root.cameraViewerOpen = false + root.cameraViewerId = "" + root.cameraViewerTitle = "" + root.cameraStream = "" + } + + function submitAssist() { + if (!root.serviceReady || root.hass.assistBusy) return + if (root.hass.sendAssist(assistInput.text)) + assistInput.text = "" + root.scheduleScrollAssistToEnd() + } + + // Wait a frame so wrapped reply height is known, then ease the thread + // up to the latest line. The viewport itself is a fixed size. + property Timer assistScrollSettle: Timer { + interval: 16 + repeat: false + onTriggered: root.scrollAssistToEnd() + } + + property NumberAnimation assistScrollAnim: NumberAnimation { + target: assistList + property: "contentY" + duration: 260 + easing.type: Easing.OutCubic + } + + function scheduleScrollAssistToEnd() { + assistScrollSettle.restart() + } + + function scrollAssistToEnd() { + if (!assistList) return + var maxY = Math.max(0, assistList.contentHeight - assistList.height) + if (Math.abs(assistList.contentY - maxY) < 1) return + assistScrollAnim.stop() + assistScrollAnim.from = assistList.contentY + assistScrollAnim.to = maxY + assistScrollAnim.start() + } + + function focusAssistInput() { + if (assistInput) assistInput.forceActiveFocus() + root.cursorActive = false + } + + function focusDevicesFromAssist() { + assistInput.focus = false + keyCatcher.forceActiveFocus() + if (root.rowCount > 0) root.cursorActive = true } function moveCursor(delta) { @@ -100,8 +188,10 @@ Panel { if (!hass.configured) return "Not connected" switch (phase) { case "connected": - return (hass.demoMode ? "Demo · " : "") + hass.activitySummary - case "connecting": return hass.lastError ? "Retrying" : "Connecting…" + return hass.connectionStatus + case "connecting": + if (hass.lastError) return "Retrying" + return hass.activeRoute === "remote" ? "Connecting remotely…" : "Connecting locally…" case "error": return "Disconnected" default: return "Idle" } @@ -185,6 +275,41 @@ Panel { if (!entity) return "unknown entity " + entityId return entity.state + " " + JSON.stringify(Model.redactAttributes(entity)) } + + function assist(text: string): string { + if (!root.serviceReady) return "service unavailable" + root.open() + return root.hass.sendAssist(text) + ? "ok" : (root.hass.lastError || "failed") + } + + function assistClear(): void { + if (root.serviceReady) root.hass.resetAssist(true) + } + } + + Process { + id: cameraPlayer + command: [ + "mpv", + "--no-audio", + "--mute=yes", + "--volume=0", + "--force-window=immediate", + "--keep-open=no", + "--osc=no", + "--osd-level=0", + "--no-border", + "--title=omarchy-hass-camera", + "--hwdec=auto-safe", + "--profile=low-latency", + "--untimed=yes", + "--cache=no", + "--input-conf=" + (root.serviceReady ? root.hass.pluginDir : "") + "/mpv-preview.conf", + root.cameraStream + ] + running: root.cameraViewerOpen && root.cameraStream !== "" + onExited: if (root.cameraViewerOpen) root.closeCameraViewer() } BarIconButton { @@ -203,17 +328,29 @@ Panel { anchorItem: button owner: root bar: root.bar - open: root.opened - focusTarget: keyCatcher + // Hide the overlay while the stream is open so mpv isn't buried under + // the layer-shell panel on short screens. + open: root.opened && !root.cameraViewerOpen + focusTarget: (root.serviceReady && root.hass && root.hass.connected) + ? assistInput : keyCatcher contentWidth: panel.fittedContentWidth(Style.space(380)) contentHeight: panel.fittedContentHeight(column.implicitHeight) PanelKeyCatcher { id: keyCatcher anchors.fill: parent - onCloseRequested: root.close() + blocked: assistInput.activeFocus || root.cameraViewerOpen + onCloseRequested: { + if (root.cameraViewerOpen) root.closeCameraViewer() + else root.close() + } onTabRequested: function(direction) { root.switchPanel(direction) } onMoveRequested: function(dx, dy) { + if (dy < 0 && (!root.cursorActive || root.cursorIndex === 0) + && root.serviceReady && root.hass.connected) { + root.focusAssistInput() + return + } // The first key press only wakes the cursor. if (!root.cursorActive) { root.cursorActive = true; return } if (dy !== 0) root.moveCursor(dy) @@ -222,6 +359,10 @@ Panel { onActivateRequested: if (root.cursorActive) root.activateCursor() onTextKey: function(key) { var lower = String(key).toLowerCase() + if (key === "/" && root.serviceReady && root.hass.connected) { + root.focusAssistInput() + return + } if (lower === "r" && root.serviceReady) root.hass.refresh() else if (lower === "e" && root.cursorActive) root.expandCursor() else if (lower === "s") root.openSettings("connection") @@ -262,6 +403,287 @@ Panel { PanelSeparator { width: parent.width; foreground: root.fg } + // ---------- Assist ---------- + Column { + id: assistColumn + width: parent.width + visible: root.serviceReady && root.hass.connected + spacing: Style.spacing.panelGap + + Item { + width: parent.width + height: Style.space(50) + + ListView { + id: assistList + anchors.fill: parent + clip: true + spacing: Style.spacing.lg + boundsBehavior: Flickable.StopAtBounds + boundsMovement: Flickable.StopAtBounds + interactive: true + model: root.serviceReady ? root.hass.assistMessages : null + bottomMargin: (root.serviceReady && root.hass.assistBusy) + ? Style.font.caption + Style.spacing.sm : 0 + onCountChanged: root.scheduleScrollAssistToEnd() + onContentHeightChanged: root.scheduleScrollAssistToEnd() + onMovementStarted: assistScrollAnim.stop() + + ScrollBar.vertical: ScrollBar { + policy: ScrollBar.AsNeeded + } + + delegate: Column { + required property int index + required property string speaker + required property string body + + width: assistList.width + spacing: Style.spacing.hairline + opacity: 0 + + Component.onCompleted: assistFadeIn.start() + + NumberAnimation on opacity { + id: assistFadeIn + from: 0 + to: 1 + duration: 220 + easing.type: Easing.OutCubic + } + + Text { + textFormat: Text.PlainText + text: speaker === "user" ? "You" : "Assist" + color: speaker === "error" ? Color.urgent : root.dim + font.family: root.family + font.pixelSize: Style.font.caption + } + + Text { + textFormat: Text.PlainText + width: parent.width + text: body + wrapMode: Text.WordWrap + color: speaker === "error" ? Color.urgent : root.fg + font.family: root.family + font.pixelSize: Style.font.bodySmall + } + } + } + + Text { + id: assistGreeting + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + textFormat: Text.PlainText + text: "How can I assist you?" + wrapMode: Text.WordWrap + color: root.fg + font.family: root.family + font.pixelSize: Style.font.bodySmall + visible: root.opened && root.serviceReady && root.hass.connected + && assistList.count === 0 && !root.hass.assistBusy + opacity: 0 + + onVisibleChanged: { + assistGreetingDelay.stop() + assistGreetingFade.stop() + opacity = 0 + if (visible) assistGreetingDelay.start() + } + + Timer { + id: assistGreetingDelay + interval: 1000 + repeat: false + onTriggered: assistGreetingFade.start() + } + + NumberAnimation { + id: assistGreetingFade + target: assistGreeting + property: "opacity" + from: 0 + to: 1 + duration: 280 + easing.type: Easing.OutCubic + } + } + + Text { + visible: root.serviceReady && root.hass.assistBusy + anchors.left: parent.left + anchors.bottom: parent.bottom + textFormat: Text.PlainText + text: "Assist is thinking…" + color: root.dim + font.family: root.family + font.pixelSize: Style.font.caption + } + } + + TextField { + id: assistInput + width: parent.width + foreground: root.fg + placeholderText: "Ask Assist…" + onAccepted: root.submitAssist() + Keys.onEscapePressed: root.close() + Keys.onDownPressed: root.focusDevicesFromAssist() + } + } + + PanelSeparator { + width: parent.width + visible: assistColumn.visible + foreground: root.fg + } + + // ---------- Cameras ---------- + Column { + id: cameraColumn + width: parent.width + visible: root.opened && root.serviceReady && root.hass.connected + && root.camerasAvailable + spacing: Style.spacing.panelGap + + PanelSectionHeader { + width: parent.width + text: "CAMERAS" + foreground: root.fg + fontFamily: root.family + } + + Row { + id: cameraRow + width: parent.width + spacing: Style.spacing.sm + + Repeater { + model: root.cameras.length + delegate: Column { + required property int index + readonly property var cam: root.cameras[index] || ({}) + + width: (cameraRow.width - cameraRow.spacing * Math.max(0, root.cameras.length - 1)) + / Math.max(1, root.cameras.length) + spacing: Style.spacing.xxs + + CameraThumb { + width: parent.width + height: Math.round(width * 2 / 3) + fill: Qt.rgba(root.fg.r, root.fg.g, root.fg.b, 0.08) + frame: root.cameraSource(cam.entityId || "") + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.openCameraViewer(cam) + } + } + + Text { + textFormat: Text.PlainText + width: parent.width + text: (cam.title || "").toUpperCase() + color: root.dim + font.family: root.family + font.pixelSize: Style.font.caption + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + } + } + } + } + } + + PanelSeparator { + width: parent.width + visible: cameraColumn.visible + foreground: root.fg + } + + // ---------- Powerwall ---------- + Column { + id: powerwallColumn + width: parent.width + visible: root.serviceReady && root.hass.connected && root.powerwall.available + spacing: Style.spacing.panelGap + + PanelSectionHeader { + width: parent.width + text: "POWERWALL" + foreground: root.fg + fontFamily: root.family + } + + Item { + width: parent.width + implicitHeight: Math.max(pwGlyph.implicitHeight, pwLabels.implicitHeight, + pwPercent.implicitHeight) + + Text { + id: pwGlyph + textFormat: Text.PlainText + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: root.powerwall.icon + color: root.fg + font.family: root.family + font.pixelSize: Style.font.heading + } + + Column { + id: pwLabels + anchors.left: pwGlyph.right + anchors.leftMargin: Style.spacing.xl + anchors.right: pwPercent.left + anchors.rightMargin: Style.spacing.lg + anchors.verticalCenter: parent.verticalCenter + spacing: Style.spacing.xxs + + Text { + textFormat: Text.PlainText + width: parent.width + text: "Powerwall" + color: root.fg + font.family: root.family + font.pixelSize: Style.font.body + elide: Text.ElideRight + } + + Text { + textFormat: Text.PlainText + width: parent.width + text: root.powerwall.subtitle + color: root.dim + font.family: root.family + font.pixelSize: Style.font.caption + elide: Text.ElideRight + } + } + + Text { + id: pwPercent + textFormat: Text.PlainText + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: root.powerwall.percentText + color: root.fg + font.family: root.family + font.pixelSize: Style.font.heading + } + } + } + + PanelSeparator { + width: parent.width + visible: powerwallColumn.visible + foreground: root.fg + } + // ---------- area tabs ---------- // ButtonGroup is a Row and does not wrap, so it scrolls instead of // pushing chips off the panel edge. diff --git a/Powerwall.js b/Powerwall.js new file mode 100644 index 0000000..ddaa899 --- /dev/null +++ b/Powerwall.js @@ -0,0 +1,101 @@ +.pragma library + +// Live Powerwall projection. Entity ids are this house's Tesla Energy site; +// the panel only renders what project() returns. + +var CHARGE_ID = "sensor.home_percentage_charged" +var BATTERY_POWER_ID = "sensor.home_battery_power" +var LOAD_POWER_ID = "sensor.home_load_power" + +// Below this, treat the battery as idle rather than flickering Charging/Using. +var IDLE_KW = 0.05 + +function parseNumber(state) { + if (typeof state === "number" && isFinite(state)) return state + if (typeof state !== "string") return null + var value = parseFloat(state) + return isFinite(value) ? value : null +} + +function isMissing(entity) { + if (!entity) return true + var state = typeof entity.state === "string" ? entity.state : "" + return state === "" || state === "unavailable" || state === "unknown" +} + +function formatKw(value) { + if (value === null || value === undefined || !isFinite(value)) return "—" + var mag = Math.abs(value) + if (mag >= 10) return mag.toFixed(1) + " kW" + return mag.toFixed(1) + " kW" +} + +function formatPercent(value) { + if (value === null || value === undefined || !isFinite(value)) return "—" + return Math.round(value) + "%" +} + +function batteryIcon(percent, charging) { + if (charging) return "󰂄" // md-battery-charging + if (percent === null || percent === undefined) return "󰂎" // md-battery-outline + if (percent >= 90) return "󰁹" + if (percent >= 70) return "󰂂" + if (percent >= 50) return "󰁿" + if (percent >= 30) return "󰁽" + if (percent >= 15) return "󰁻" + return "󰂎" +} + +function resolvedId(configured, fallback, map) { + if (configured) return configured + if (map && map[fallback]) return fallback + return "" +} + +function project(states, chargeId, batteryId, loadId) { + var map = states && typeof states === "object" ? states : {} + var chargeEntity = map[resolvedId(chargeId, CHARGE_ID, map)] + var batteryEntity = map[resolvedId(batteryId, BATTERY_POWER_ID, map)] + var loadEntity = map[resolvedId(loadId, LOAD_POWER_ID, map)] + if (isMissing(chargeEntity)) { + return { + available: false, + percent: null, + fraction: 0, + percentText: "—", + usageText: "—", + flowLabel: "", + subtitle: "", + charging: false, + discharging: false, + icon: batteryIcon(null, false) + } + } + + var percent = parseNumber(chargeEntity.state) + var batteryKw = isMissing(batteryEntity) ? null : parseNumber(batteryEntity.state) + var loadKw = isMissing(loadEntity) ? null : parseNumber(loadEntity.state) + var charging = batteryKw !== null && batteryKw < -IDLE_KW + var discharging = batteryKw !== null && batteryKw > IDLE_KW + + var flowLabel = "Idle" + if (charging) flowLabel = "Charging " + formatKw(batteryKw) + else if (discharging) flowLabel = "Using " + formatKw(batteryKw) + + var usageText = formatKw(loadKw) + var subtitle = "Home " + usageText + if (flowLabel) subtitle += " · " + flowLabel + + return { + available: true, + percent: percent, + fraction: percent === null ? 0 : Math.max(0, Math.min(1, percent / 100)), + percentText: formatPercent(percent), + usageText: usageText, + flowLabel: flowLabel, + subtitle: subtitle, + charging: charging, + discharging: discharging, + icon: batteryIcon(percent, charging) + } +} diff --git a/README.md b/README.md index 0ca1485..4d40cd6 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Home Assistant for Omarchy -View and control your Home Assistant devices from the Omarchy bar. +Talk to Assist and control your Home Assistant devices from the Omarchy bar. -Quickshell plugin for **Omarchy 4**. Pick the devices and toggle lights, adjust climate, drive media, and open covers. +Quickshell plugin for **Omarchy 4**. Ask Assist in plain language, pick favorite devices, and toggle lights, adjust climate, drive media, and open covers. > Not affiliated with or endorsed by the Home Assistant project. @@ -16,11 +16,38 @@ Quickshell plugin for **Omarchy 4**. Pick the devices and toggle lights, adjust ![Home Assistant demo device list and panel favorites using the Solitude theme](docs/screenshots/demo-devices-and-favorites.png) +## Assist + +With the panel open, type in the Assist field and press Enter. The request +goes to your Home Assistant instance's preferred Assist pipeline — the same +conversation agent your Voice PE / Assist setup uses. Follow-ups stay in the +same conversation until a minute of silence, a reconnect, or +`omarchy-shell hass assistClear`. + +Voice (push-to-talk) is not in this version. + +## Cameras and energy + +Settings → **Panel** picks the cameras and battery sensors to show. + +While the panel is open, selected cameras render silent stills. Click a +still to play the high-bitrate go2rtc/RTSP stream in a floating `mpv` +window (no audio). Escape or a click on the video closes it. + +An energy row can show charge percentage, battery power, and home load +when those sensors are configured. + +The hero status is the connection path: **Connected locally** or +**Connected via Nabu Casa**. + ## Keyboard -With the panel open: `j`/`k` or arrows move, `←`/`→` switch area tabs, `enter` +The Assist field is focused when you open a connected panel. `enter` sends, +`↓` moves to devices, `/` returns to Assist, `esc` closes. + +On the device list: `j`/`k` or arrows move, `←`/`→` switch area tabs, `enter` turns the highlighted device on or off, `e` expands its controls, `s` opens -settings, `r` refreshes, `esc` closes, `tab` moves to the next bar panel. +settings, `r` refreshes, `tab` moves to the next bar panel. ## What you can control @@ -34,8 +61,7 @@ settings, `r` refreshes, `esc` closes, `tab` moves to the next bar panel. | `cover` | Open / stop / close | | `climate` | On/off when advertised, plus a target temperature or low/high band | | `sensor`, `binary_sensor`, everything else | State display only | - -Cameras not yet. +| `camera` | Stills in the panel; click for a live stream | ## Scripting @@ -46,6 +72,8 @@ omarchy-shell hass toggleEntity light.desk omarchy-shell hass activate scene.movie_night omarchy-shell hass expand climate.hallway # opens the panel, unfolded omarchy-shell hass favorite light.desk # add to / remove from the panel +omarchy-shell hass assist "turn off the living room lamp" +omarchy-shell hass assistClear omarchy-shell hass status omarchy-shell hass settings # connection settings omarchy-shell hass devices # device picker @@ -56,6 +84,7 @@ omarchy-shell hass devices # device picker - Omarchy 4 (`schemaVersion: 1` plugin API) - Python 3.11 or newer - `secret-tool` (libsecret) with a running keyring daemon +- `mpv` for the optional live camera preview (`omarchy-hass-camera` title) The pure-Python runtime of `websockets` 17.0.1 is bundled with the plugin and loaded from `vendor/`. Users don't need `python-websockets`, `qt6-websockets`, @@ -81,10 +110,13 @@ Click the gear in the panel header, or press `s` with the panel open. From a terminal: `omarchy-shell hass settings`, or `omarchy-shell hass devices` to open device picker. -Paste your Home Assistant URL and a long-lived access token (Home Assistant → -your profile → Security), or flip on **Demo mode** to try the panel against a -built-in fake house with no instance at all. Then switch to **Devices** and -star the ones you want in the panel. +Paste the local Home Assistant URL, optional Nabu Casa remote URL, and a +long-lived access token (Home Assistant → your profile → Security). The panel +tries local first and falls back to Nabu Casa when local is unreachable. Or +flip on **Demo mode** to try the panel against a built-in fake house. Then +switch to **Panel** to pick +cameras, battery sensors, and the Assist pipeline, and **Devices** to star the +ones you want in the list. ## Debugging @@ -105,6 +137,9 @@ node tests/test_config.js # config normalization and secret exclusion node tests/test_store.js # state and registry projections node tests/test_model.js # entity formatting and classification node tests/test_row_model.js # ListModel row projection +node tests/test_assist.js # Assist text and result projection +node tests/test_powerwall.js # Powerwall charge and power projection +node tests/test_cameras.js # Frigate camera catalog and path rules python3 tests/test_qml_style.py # UI house style (fonts, palette, tokens) ``` diff --git a/Service.qml b/Service.qml index c2595f2..e797349 100644 --- a/Service.qml +++ b/Service.qml @@ -6,6 +6,9 @@ import "Connection.js" as Connection import "EntityStore.js" as EntityStore import "ConfigStore.js" as ConfigStore import "RowModel.js" as RowModel +import "Assist.js" as Assist +import "Powerwall.js" as Powerwall +import "Cameras.js" as Cameras // Owner of all Home Assistant state. // @@ -19,6 +22,7 @@ QtObject { readonly property string pluginDir: home + "/.config/omarchy/plugins/hass" readonly property string configDir: home + "/.config/omarchy/hass" readonly property string configPath: configDir + "/config.json" + readonly property string cameraCacheDir: home + "/.cache/omarchy/hass/cameras" // idle | connecting | connected | error property string phase: "idle" @@ -27,6 +31,12 @@ QtObject { property bool configured: false property bool demoMode: false property string baseUrl: "" + property string localUrl: "" + property string remoteUrl: "" + property string activeRoute: "" + property bool remoteAttempted: false + property bool triedPairedLookup: false + property string pendingRemoteOrigin: "" property int connectionGeneration: 0 property bool connectionSuppressed: false @@ -79,9 +89,19 @@ QtObject { onFileChanged: reload() } + property var cameraIds: [] + property string chargeEntityId: "" + property string batteryPowerEntityId: "" + property string loadPowerEntityId: "" + property string assistPipelineId: "" + property var assistPipelines: [] + property string assistPreferredPipeline: "" + function currentConfig() { return { - baseUrl: root.baseUrl, + baseUrl: root.localUrl, + localUrl: root.localUrl, + remoteUrl: root.remoteUrl, demoMode: root.demoMode, favorites: root.liveFavorites.slice(), demoFavorites: root.demoFavorites.slice(), @@ -89,7 +109,12 @@ QtObject { showEntityIcons: root.showEntityIcons, selectedTab: root.activeTab, displayNameOverrides: root.displayNameOverrides, - iconOverrides: root.iconOverrides + iconOverrides: root.iconOverrides, + cameraIds: root.cameraIds.slice(), + chargeEntityId: root.chargeEntityId, + batteryPowerEntityId: root.batteryPowerEntityId, + loadPowerEntityId: root.loadPowerEntityId, + assistPipelineId: root.assistPipelineId } } @@ -112,7 +137,7 @@ QtObject { // supposed to enable, which on a fresh install loses the first save silently // (printErrors is off). Once, at startup, is early enough for every write. property Process configDirProcess: Process { - command: ["mkdir", "-p", root.configDir] + command: ["mkdir", "-p", root.configDir, root.cameraCacheDir] } Component.onCompleted: root.configDirProcess.running = true @@ -151,32 +176,87 @@ QtObject { property CredentialManager credentials: CredentialManager { onTokenReady: function(token, origin) { - if (!root.demoMode && !root.connectionSuppressed - && origin === root.currentOrigin()) { - root.pushConfig(token) - } else if (!root.connectionSuppressed) { - Qt.callLater(root.pushCredentials) + if (root.demoMode || root.connectionSuppressed) return + if (origin === root.currentOrigin() + || (origin && origin === root.localOrigin()) + || (origin && origin === root.remoteOrigin())) { + root.triedPairedLookup = false + if (origin === root.currentOrigin() + || (root.activeRoute === "remote" && origin === root.localOrigin())) { + root.pushConfig(token) + } + if (root.pendingRemoteOrigin && root.pendingRemoteOrigin !== origin) { + var extra = root.pendingRemoteOrigin + root.pendingRemoteOrigin = "" + credentials.store(token, extra) + } + return } + if (!root.connectionSuppressed) Qt.callLater(root.pushCredentials) } onCleared: function(origin) { - if (origin === root.currentOrigin()) root.finishRemoveConnection() + if (root.pendingClearOrigins.length) { + var next = root.pendingClearOrigins.shift() + if (!credentials.clear(next)) root.finishRemoveConnection() + return + } + if (origin === root.localOrigin() || origin === root.remoteOrigin() + || !root.localOrigin()) { + root.finishRemoveConnection() + } } onFailed: function(message, origin) { - if (origin && origin !== root.currentOrigin()) return + if (origin && origin !== root.currentOrigin() + && origin !== root.localOrigin() && origin !== root.remoteOrigin()) { + return + } + if (!root.triedPairedLookup && origin === root.currentOrigin()) { + var other = origin === root.localOrigin() ? root.remoteOrigin() + : root.localOrigin() + if (other && other !== origin) { + root.triedPairedLookup = true + if (credentials.lookup(other)) return + } + } + if (origin && origin !== root.currentOrigin() + && origin !== root.localOrigin()) return root.phase = "error" root.lastError = message root.lastErrorKind = "credential" } } + property var pendingClearOrigins: [] + + function localOrigin() { + return Connection.normalizeOrigin(root.localUrl) + } + + function remoteOrigin() { + return Connection.normalizeOrigin(root.remoteUrl) + } + function currentOrigin() { return Connection.normalizeOrigin(root.baseUrl) } - function requiresTokenFor(url) { - var origin = Connection.normalizeOrigin(url) - if (!origin) return true - return root.demoMode || !root.configured || origin !== root.currentOrigin() + function activeUrl() { + return root.activeRoute === "remote" ? root.remoteUrl : root.localUrl + } + + readonly property string connectionStatus: Connection.connectionLabel( + root.demoMode, root.activeRoute, root.remoteUrl) + + function requiresTokenFor(local, remote) { + if (root.demoMode) return false + var nextLocal = Connection.normalizeOrigin(local) + if (!nextLocal && remote !== undefined) + nextLocal = Connection.normalizeOrigin(remote) + if (!nextLocal) return true + if (!root.configured) return true + if (nextLocal === root.localOrigin() || nextLocal === root.remoteOrigin()) + return false + return true } function removeConnection() { @@ -184,16 +264,21 @@ QtObject { root.lastError = "Wait for the current keyring operation to finish." return } - var origin = root.currentOrigin() + var origins = [] + if (root.localOrigin()) origins.push(root.localOrigin()) + if (root.remoteOrigin() && origins.indexOf(root.remoteOrigin()) === -1) + origins.push(root.remoteOrigin()) root.connectionSuppressed = true root.disconnectBridge() root.appliedConnection = "" + root.activeRoute = "" root.forgetDevices() - if (!origin) { + if (!origins.length) { root.finishRemoveConnection() return } - if (!credentials.clear(origin)) { + root.pendingClearOrigins = origins.slice(1) + if (!credentials.clear(origins[0])) { root.phase = "error" root.lastError = "Could not start token removal while the keyring is busy." } @@ -201,8 +286,11 @@ QtObject { function finishRemoveConnection() { root.connectionSuppressed = false + root.pendingClearOrigins = [] + root.activeRoute = "" + root.remoteAttempted = false root.saveConfig({ - baseUrl: "", demoMode: false, favorites: [], + baseUrl: "", localUrl: "", remoteUrl: "", demoMode: false, favorites: [], displayNameOverrides: {}, iconOverrides: {}, selectedTab: "favorites" }) // demoFavorites untouched: not part of the connection } @@ -231,31 +319,56 @@ QtObject { function retryConnection() { root.connectionSuppressed = false root.appliedConnection = "" + root.remoteAttempted = false + root.triedPairedLookup = false + root.activeRoute = root.localUrl ? "local" : (root.remoteUrl ? "remote" : "") + root.baseUrl = root.activeUrl() root.lastError = "" root.reconcileConnection() } - function applyConnection(url, token, demo) { - var origin = demo ? "demo" : Connection.normalizeOrigin(url) - if (!origin) { + function applyConnection(local, remote, token, demo) { + if (demo === undefined) { + demo = token + token = remote + remote = "" + } + var localOrigin = demo ? "demo" : Connection.normalizeOrigin(local) + var remoteOrigin = demo ? "" : Connection.normalizeOrigin(remote) + if (!localOrigin && !remoteOrigin) { root.phase = "error" - root.lastError = "Enter a valid http(s) or ws(s) Home Assistant URL." + root.lastError = "Enter a local or Nabu Casa Home Assistant URL." return false } - if (!demo && !token && root.requiresTokenFor(url)) { + if (!demo && !token && root.requiresTokenFor(local, remote)) { root.phase = "error" root.lastError = "A new Home Assistant origin requires a new token." return false } root.connectionSuppressed = false + root.remoteAttempted = false + root.triedPairedLookup = false + root.activeRoute = localOrigin ? "local" : "remote" // Start the serialized write before applyConfig runs so reconciliation // cannot race a lookup of the previous credential. - if (!demo && token.length > 0 && !credentials.store(token, origin)) { + var storeOrigin = localOrigin || remoteOrigin + if (!demo && token.length > 0 && !credentials.store(token, storeOrigin)) { root.phase = "error" root.lastError = "Could not start token storage while the keyring is busy." return false } - root.saveConfig({ baseUrl: url, demoMode: demo }) + if (!demo && token.length > 0 && remoteOrigin && localOrigin + && remoteOrigin !== localOrigin) { + root.pendingRemoteOrigin = remoteOrigin + } else { + root.pendingRemoteOrigin = "" + } + root.saveConfig({ + localUrl: local || "", + remoteUrl: remote || "", + baseUrl: local || remote || "", + demoMode: demo + }) return true } @@ -279,7 +392,17 @@ QtObject { if (parsed.error) root.lastError = parsed.error root.demoMode = config.demoMode - root.baseUrl = config.baseUrl + root.localUrl = config.localUrl + root.remoteUrl = config.remoteUrl + if (!root.activeRoute) { + root.activeRoute = config.localUrl ? "local" + : (config.remoteUrl ? "remote" : "") + } else if (root.activeRoute === "local" && !config.localUrl) { + root.activeRoute = config.remoteUrl ? "remote" : "" + } else if (root.activeRoute === "remote" && !config.remoteUrl) { + root.activeRoute = config.localUrl ? "local" : "" + } + root.baseUrl = root.activeUrl() root.liveFavorites = config.favorites root.demoFavorites = config.demoFavorites root.displayNameOverrides = config.displayNameOverrides @@ -287,8 +410,14 @@ QtObject { root.groupByArea = config.groupByArea root.showEntityIcons = config.showEntityIcons root.activeTab = config.selectedTab - - root.configured = root.demoMode || root.baseUrl.length > 0 + root.cameraIds = config.cameraIds + root.chargeEntityId = config.chargeEntityId + root.batteryPowerEntityId = config.batteryPowerEntityId + root.loadPowerEntityId = config.loadPowerEntityId + root.assistPipelineId = config.assistPipelineId + + root.configured = root.demoMode + || root.localUrl.length > 0 || root.remoteUrl.length > 0 rebuildSortedIds() rebuildRows() root.reconcileConnection() @@ -307,6 +436,9 @@ QtObject { root.temperatureUnit = "" root.pendingToggles = ({}) pendingSweep.running = false + root.resetAssist(true) + root.cameraPaths = ({}) + root.cameraRevision++ root.rebuildRows() } @@ -317,6 +449,7 @@ QtObject { function reconcileConnection() { if (root.connectionSuppressed) return + root.baseUrl = root.activeUrl() if (!root.configured) { if (root.appliedConnection !== "") { @@ -333,7 +466,7 @@ QtObject { // Connection.js owns this rule, so the definition of "same connection" // cannot drift from the one the tests pin. - var signature = Connection.signature(root.demoMode, root.baseUrl) + var signature = Connection.signature(root.demoMode, root.activeUrl()) if (!signature) { root.phase = "error" root.lastError = "Home Assistant URL is invalid." @@ -351,6 +484,21 @@ QtObject { if (root.startBridge()) root.pushCredentials() } + function considerFailover() { + if (root.connectionSuppressed || root.demoMode || root.remoteAttempted) return + if (root.activeRoute !== "local") return + if (!Connection.normalizeOrigin(root.remoteUrl)) return + root.remoteAttempted = true + root.triedPairedLookup = false + root.activeRoute = "remote" + root.baseUrl = root.activeUrl() + root.appliedConnection = "" + root.lastError = Connection.isNabuCasa(root.remoteUrl) + ? "Local unreachable, trying Nabu Casa…" + : "Local unreachable, trying the remote URL…" + root.reconcileConnection() + } + // Split out of reconcileConnection because a bridge restart has to redo it: // the push that went to the process we just signalled never arrived. function pushCredentials() { @@ -391,6 +539,7 @@ QtObject { onFailed: function(message) { root.phase = "error" root.lastError = message + root.considerFailover() } } @@ -427,6 +576,86 @@ QtObject { }) } + // ------------------------------------------------------------ assist + + property ListModel assistMessages: ListModel {} + property string assistConversationId: "" + property bool assistBusy: false + property string assistError: "" + property int assistSeq: 0 + + property Timer assistIdle: Timer { + interval: Assist.IDLE_MS + repeat: false + onTriggered: root.resetAssist(true) + } + + function touchAssistIdle() { + if (assistMessages.count === 0 && !root.assistBusy) { + assistIdle.stop() + return + } + assistIdle.restart() + } + + function resetAssist(clearMessages) { + assistIdle.stop() + root.assistBusy = false + root.assistConversationId = "" + root.assistError = "" + if (clearMessages) assistMessages.clear() + } + + function appendAssistMessage(speaker, body) { + var row = Assist.messageFor(speaker, body) + if (!row.body) return + assistMessages.append(row) + while (assistMessages.count > Assist.MAX_MESSAGES) + assistMessages.remove(0) + } + + function sendAssist(text) { + var normalized = Assist.normalizeText(text) + if (!normalized) return root.rejectAction("Type something for Assist.") + if (!root.connected) return root.rejectAction("Not connected to Home Assistant.") + if (root.assistBusy) return false + + root.assistSeq += 1 + var tag = "assist:" + root.assistSeq + root.assistBusy = true + root.assistError = "" + root.appendAssistMessage("user", normalized) + var sent = root.send({ + op: "conversation", + text: normalized, + conversation_id: root.assistConversationId, + pipeline: root.assistPipelineId, + tag: tag + }) + if (!sent) { + root.assistBusy = false + root.appendAssistMessage("error", "Could not reach Home Assistant.") + root.touchAssistIdle() + return false + } + root.touchAssistIdle() + return true + } + + function finishAssist(event) { + root.assistBusy = false + var projected = Assist.projectResult(event) + if (projected.ok) { + if (projected.conversationId) + root.assistConversationId = projected.conversationId + root.appendAssistMessage("assist", projected.speech) + } else { + root.assistError = projected.error + root.appendAssistMessage("error", projected.error) + } + root.touchAssistIdle() + } + // ------------------------------------------------------------ actions // entity_id -> { desired, deadline }. The row flips at once and waits for @@ -659,6 +888,10 @@ QtObject { root.phase = transition.state.phase root.lastError = transition.state.error root.lastErrorKind = transition.state.errorKind + if (root.phase === "error" && root.lastErrorKind !== "credential" + && root.lastErrorKind !== "protocol") { + root.considerFailover() + } break case "states": root.applyStates(event.entities || []) @@ -679,6 +912,12 @@ QtObject { root.temperatureUnit = String(event.unit_temperature || "") root.rebuildRows() break + case "snapshots": + root.applySnapshots(event) + break + case "pipelines": + root.applyPipelines(event) + break case "result": root.handleResult(event) break @@ -689,9 +928,14 @@ QtObject { } function handleResult(event) { + var tag = String(event.tag || "") + if (tag === "snap") return + if (tag.indexOf("assist:") === 0) { + root.finishAssist(event) + if (event.ok === true) return + } if (event.ok === true) return - var tag = String(event.tag || "") if (tag.indexOf("toggle:") === 0) { // Drop the guess now rather than at the sweep timer. On success it // stays: the confirming state_changed is already on its way. @@ -815,6 +1059,138 @@ QtObject { return false } + readonly property var powerwall: { + root.stateRevision + return Powerwall.project( + root.states, root.chargeEntityId, root.batteryPowerEntityId, + root.loadPowerEntityId) + } + + readonly property var cameraTiles: { + root.stateRevision + return Cameras.tiles(root.states, root.cameraIds) + } + + readonly property bool camerasAvailable: { + root.stateRevision + return Cameras.anyAvailable(root.states, root.cameraIds) + } + + property var cameraPaths: ({}) + property int cameraRevision: 0 + property int cameraWatchers: 0 + + property Timer cameraTimer: Timer { + interval: 1500 + repeat: true + running: root.cameraWatchers > 0 && root.connected && !root.demoMode + triggeredOnStart: true + onTriggered: root.requestSnapshots() + } + + function setCameraWatching(enabled) { + root.cameraWatchers = Math.max(0, root.cameraWatchers + (enabled ? 1 : -1)) + } + + function requestSnapshots() { + if (!root.connected || root.demoMode) return + root.send({ + op: "snapshots", + entities: Cameras.ids(root.cameraIds, root.states), + dest: root.cameraCacheDir, + tag: "snap" + }) + } + + function cameraSource(entityId) { + root.cameraRevision + return Cameras.fileSource(root.cameraPaths[entityId], root.cameraRevision) + } + + function cameraStreamUrl(entityId) { + return Cameras.streamUrl(root.baseUrl, entityId, root.states[entityId]) + } + + function setCameraIds(ids) { + root.saveConfig({ + cameraIds: ConfigStore.stringList(ids, []).filter(function(id) { + return id.indexOf("camera.") === 0 + }).slice(0, 6) + }) + } + + function setBatteryEntities(chargeId, batteryId, loadId) { + root.saveConfig({ + chargeEntityId: ConfigStore.entityId(chargeId), + batteryPowerEntityId: ConfigStore.entityId(batteryId), + loadPowerEntityId: ConfigStore.entityId(loadId) + }) + } + + function setAssistPipeline(pipelineId) { + root.saveConfig({ assistPipelineId: ConfigStore.pipelineId(pipelineId) }) + } + + function applyPipelines(event) { + var raw = event.pipelines + var out = [] + if (raw && raw.length) { + for (var i = 0; i < raw.length; i++) { + var item = raw[i] + if (!item || typeof item.id !== "string" || typeof item.name !== "string") + continue + out.push({ id: item.id, name: item.name }) + } + } + root.assistPipelines = out + root.assistPreferredPipeline = typeof event.preferred === "string" + ? event.preferred : "" + } + + function optionList(kind) { + var out = kind === "camera" ? [] : [{ value: "", label: "None" }] + var ids = root.sortedEntityIds + for (var i = 0; i < ids.length; i++) { + var entityId = ids[i] + var entity = root.states[entityId] + if (!entity) continue + var domain = Model.domainOf(entityId) + var deviceClass = String((entity.attributes || {}).device_class || "") + var unit = String((entity.attributes || {}).unit_of_measurement || "") + var keep = false + if (kind === "camera") keep = domain === "camera" + else if (kind === "battery") { + keep = domain === "sensor" && (deviceClass === "battery" || unit === "%") + } else if (kind === "power") { + keep = domain === "sensor" && (deviceClass === "power" + || unit === "kW" || unit === "W") + } + if (!keep) continue + out.push({ + value: entityId, + label: root.displayName(entityId), + description: entityId + }) + } + return out + } + + function applySnapshots(event) { + var frames = event.frames + if (!frames || !frames.length) return + var next = {} + var key + for (key in root.cameraPaths) next[key] = root.cameraPaths[key] + for (var i = 0; i < frames.length; i++) { + var frame = frames[i] + if (!frame || typeof frame.entity_id !== "string") continue + if (frame.ok && typeof frame.path === "string" && frame.path.indexOf("/") === 0) + next[frame.entity_id] = frame.path + } + root.cameraPaths = next + root.cameraRevision++ + } + readonly property string activitySummary: { root.stateRevision var picked = [] diff --git a/Settings.qml b/Settings.qml index 33d91a2..0f16441 100644 --- a/Settings.qml +++ b/Settings.qml @@ -25,6 +25,7 @@ Item { // Local until Connect, so a half-typed URL never reaches the bridge. property string urlDraft: "" + property string remoteDraft: "" property string tokenDraft: "" property string query: "" @@ -66,7 +67,7 @@ Item { try { var payload = payloadJson ? JSON.parse(payloadJson) : {} if (payload.tab === "entities" || payload.tab === "connection" - || payload.tab === "general") { + || payload.tab === "general" || payload.tab === "panel") { root.tab = payload.tab } } catch (e) { @@ -88,7 +89,8 @@ Item { function resetDrafts() { if (!service) return - root.urlDraft = service.baseUrl + root.urlDraft = service.localUrl || service.baseUrl + root.remoteDraft = service.remoteUrl || "" // The stored token never comes back to screen; blank means "keep it". root.tokenDraft = "" root.query = "" @@ -101,7 +103,8 @@ Item { function applyConnection() { if (!service) return - if (service.applyConnection(root.urlDraft.trim(), root.tokenDraft, false)) { + if (service.applyConnection(root.urlDraft.trim(), root.remoteDraft.trim(), + root.tokenDraft, false)) { root.tokenDraft = "" } } @@ -135,7 +138,8 @@ Item { readonly property int preferredWidth: root.tab === "entities" ? Style.space(940) : Style.space(620) readonly property int preferredHeight: - root.tab === "entities" ? Style.space(620) : Style.space(560) + root.tab === "entities" ? Style.space(620) + : root.tab === "panel" ? Style.space(640) : Style.space(680) width: Math.min(card.preferredWidth, window.width - Style.gapsOut * 2) height: Math.min(card.preferredHeight, window.height - Style.gapsOut * 2) radius: Style.cornerRadius @@ -154,6 +158,7 @@ Item { anchors.bottomMargin: card.contentBottomInset anchors.leftMargin: card.contentLeftInset anchors.rightMargin: card.contentRightInset + blocked: panelLoader.item && panelLoader.item.pickerOpen onCloseRequested: root.dismiss() // Anchors, not computed heights: deriving the body from the card @@ -185,6 +190,7 @@ Item { fontSize: Style.font.caption options: [{ value: "connection", label: "Connection" }, { value: "general", label: "General" }, + { value: "panel", label: "Panel" }, { value: "entities", label: "Devices" }] value: root.tab onChanged: function(value) { root.tab = value } @@ -212,6 +218,14 @@ Item { sourceComponent: connectionTab } + Loader { + id: panelLoader + anchors.fill: parent + active: root.tab === "panel" + visible: active + sourceComponent: panelTab + } + Loader { anchors.fill: parent active: root.tab === "general" @@ -230,6 +244,137 @@ Item { } } + // ------------------------------------------------------------ panel + + Component { + id: panelTab + + Flickable { + id: panelPane + clip: true + contentWidth: width + contentHeight: panelColumn.implicitHeight + boundsBehavior: Flickable.StopAtBounds + flickableDirection: Flickable.VerticalFlick + + readonly property bool pickerOpen: pipelinePick.popupOpen + || cameraPick.popupOpen || chargePick.popupOpen + || batteryPick.popupOpen || loadPick.popupOpen + readonly property bool live: root.service && root.service.connected + + readonly property var pipelineOptions: { + var out = [{ value: "", label: "Preferred pipeline" }] + var list = root.service ? root.service.assistPipelines : [] + for (var i = 0; i < list.length; i++) { + out.push({ + value: list[i].id, + label: list[i].name, + description: list[i].id + }) + } + return out + } + + Column { + id: panelColumn + width: panelPane.width + spacing: Style.spacing.xxxl + + Text { + textFormat: Text.PlainText + width: parent.width + text: panelPane.live + ? "Choose what the bar panel shows. Changes save immediately." + : "Connect to Home Assistant first to load cameras, sensors, and Assist pipelines." + wrapMode: Text.WordWrap + color: Color.muted + font.family: root.family + font.pixelSize: Style.font.bodySmall + } + + Dropdown { + id: pipelinePick + width: parent.width + label: "ASSIST PIPELINE" + foreground: root.foreground + fontFamily: root.family + value: root.service ? root.service.assistPipelineId : "" + options: panelPane.pipelineOptions + onChanged: function(value) { + if (root.service) root.service.setAssistPipeline(value) + } + } + + MultiSelect { + id: cameraPick + width: parent.width + label: "CAMERAS" + foreground: root.foreground + fontFamily: root.family + values: root.service ? root.service.cameraIds : [] + options: root.service + ? (root.shownRevision, root.service.optionList("camera")) : [] + noSelectionText: "Using defaults when those cameras exist" + emptyText: "No cameras found" + onChanged: function(values) { + if (root.service) root.service.setCameraIds(values) + } + } + + SearchableDropdown { + id: chargePick + width: parent.width + label: "BATTERY CHARGE" + foreground: root.foreground + fontFamily: root.family + placeholderText: "Search battery sensors…" + emptyText: "No battery sensors found" + value: root.service ? root.service.chargeEntityId : "" + options: root.service + ? (root.shownRevision, root.service.optionList("battery")) : [] + onChanged: function(value) { + if (root.service) root.service.setBatteryEntities( + value, root.service.batteryPowerEntityId, root.service.loadPowerEntityId) + } + } + + SearchableDropdown { + id: batteryPick + width: parent.width + label: "BATTERY POWER" + foreground: root.foreground + fontFamily: root.family + placeholderText: "Search power sensors…" + emptyText: "No power sensors found" + value: root.service ? root.service.batteryPowerEntityId : "" + options: root.service + ? (root.shownRevision, root.service.optionList("power")) : [] + onChanged: function(value) { + if (root.service) root.service.setBatteryEntities( + root.service.chargeEntityId, value, root.service.loadPowerEntityId) + } + } + + SearchableDropdown { + id: loadPick + width: parent.width + label: "HOME LOAD" + foreground: root.foreground + fontFamily: root.family + placeholderText: "Search power sensors…" + emptyText: "No power sensors found" + value: root.service ? root.service.loadPowerEntityId : "" + options: root.service + ? (root.shownRevision, root.service.optionList("power")) : [] + onChanged: function(value) { + if (root.service) root.service.setBatteryEntities( + root.service.chargeEntityId, root.service.batteryPowerEntityId, value) + } + } + } + } + } + // ------------------------------------------------------------ connection Component { @@ -255,8 +400,11 @@ Item { readonly property bool paused: connectionPane.live && connectionPane.stalled readonly property bool keyringBusy: root.service && root.service.credentialBusy readonly property bool needsToken: root.service - ? root.service.requiresTokenFor(root.urlDraft.trim()) : true - readonly property bool validUrl: Connection.normalizeOrigin(root.urlDraft) !== "" + ? root.service.requiresTokenFor(root.urlDraft.trim(), root.remoteDraft.trim()) + : true + readonly property bool validUrl: + Connection.normalizeOrigin(root.urlDraft) !== "" + || Connection.normalizeOrigin(root.remoteDraft) !== "" readonly property bool canConnect: validUrl && !keyringBusy && (!needsToken || root.tokenDraft.length > 0) @@ -271,7 +419,7 @@ Item { Text { textFormat: Text.PlainText - text: "Home Assistant URL" + text: "Local address" color: Color.muted font.family: root.family font.pixelSize: Style.font.bodySmall @@ -280,7 +428,7 @@ Item { TextField { width: connectionColumn.width text: root.urlDraft - placeholderText: "https://homeassistant.local:8123" + placeholderText: "http://homeassistant.local:8123" onTextChanged: root.urlDraft = text } @@ -297,6 +445,36 @@ Item { } } + Column { + width: connectionColumn.width + spacing: Style.spacing.sm + + Text { + textFormat: Text.PlainText + text: "Nabu Casa remote address" + color: Color.muted + font.family: root.family + font.pixelSize: Style.font.bodySmall + } + + TextField { + width: connectionColumn.width + text: root.remoteDraft + placeholderText: "https://xxxxxxxx.ui.nabu.casa" + onTextChanged: root.remoteDraft = text + } + + Text { + textFormat: Text.PlainText + width: connectionColumn.width + text: "The panel tries the local address first, then this URL if local is unreachable." + color: Color.muted + font.family: root.family + font.pixelSize: Style.font.caption + wrapMode: Text.WordWrap + } + } + Column { width: connectionColumn.width spacing: Style.spacing.sm @@ -322,7 +500,8 @@ Item { Text { textFormat: Text.PlainText width: connectionColumn.width - visible: connectionPane.needsToken && root.urlDraft.trim().length > 0 + visible: connectionPane.needsToken + && (root.urlDraft.trim().length > 0 || root.remoteDraft.trim().length > 0) text: "Changing the server origin requires entering its token again." color: Color.muted font.family: root.family @@ -413,8 +592,8 @@ Item { if (!root.service.configured) return "Not connected" switch (root.service.phase) { case "connected": - return (root.service.demoMode ? "Demo running · " : "Connected · ") - + Object.keys(root.service.states).length + " devices" + return root.service.connectionStatus + + " · " + Object.keys(root.service.states).length + " devices" case "connecting": return root.service.lastError ? "Connecting… · " + root.service.lastError : "Connecting…" diff --git a/bin/hass-bridge b/bin/hass-bridge index ccfeb7a..31cd940 100755 --- a/bin/hass-bridge +++ b/bin/hass-bridge @@ -18,6 +18,11 @@ Commands (stdin) process running as this user. {"op":"call_service","domain":"light","service":"turn_on", "entity_id":"light.x","data":{"brightness_pct":40},"tag":"..."} + {"op":"conversation","text":"turn off the lights", + "conversation_id":"...","tag":"..."} + Run the preferred Assist pipeline from text (intent stage only). + {"op":"snapshots","entities":["camera.frontyard"],"dest":"...","tag":"..."} + Fetch still JPEGs for the open panel. Runs off the websocket thread. {"op":"refresh"} re-request the full state snapshot {"op":"registries"} re-request area/entity/device registries {"op":"shutdown"} @@ -31,6 +36,7 @@ Events (stdout) {"ev":"removed","entity_id":"..."} entity disappeared {"ev":"registries","areas":[...],"entities":[...],"devices":[...]} {"ev":"config","unit_temperature":"°C"} instance-wide unit system + {"ev":"snapshots","frames":[{"entity_id":"...","path":"...","ok":true}]} {"ev":"result","tag":"...","ok":true,"error":"...", "errorKind":"command"} {"ev":"log","level":"info|warn","msg":"..."} @@ -47,12 +53,15 @@ import json import os import queue import random +import re import socket import ssl import sys import threading import time +import urllib.error import urllib.parse +import urllib.request # Runtime bytecode doesn't belong in the plugin checkout and isn't useful for # a small long-lived helper. Set this before loading the vendored package. @@ -85,6 +94,114 @@ WS_SCHEMES = {"https": "wss", "http": "ws", "wss": "wss", "ws": "ws"} # shell its error back. REQUEST_TIMEOUT = 5.0 +# Assist pipelines (especially LLM conversation agents) are slower than a +# light toggle. The initial assist_pipeline/run acknowledgement still uses +# REQUEST_TIMEOUT; this budget covers intent-end / run-end after that. +CONVERSATION_TIMEOUT = 90.0 +MAX_CONVERSATION_TEXT = 1000 +MAX_CONVERSATION_ID = 128 +MAX_SPEECH = 4000 +ALLOWED_RESPONSE_TYPES = {"action_done", "query_answer", "error"} +CAMERA_ID_RE = re.compile(r"^camera\.[a-z0-9_]+$") +MAX_SNAPSHOT_ENTITIES = 4 +MAX_SNAPSHOT_BYTES = 2 * 1024 * 1024 +SNAPSHOT_TIMEOUT = 5.0 +HTTP_SCHEMES = {"https": "https", "http": "http", "wss": "https", "ws": "http"} + + +def parse_intent_output(raw): + """Pull the fields the shell may render from an Assist intent_output.""" + if not isinstance(raw, dict): + return {} + + conversation_id = raw.get("conversation_id") + if not isinstance(conversation_id, str): + conversation_id = "" + conversation_id = conversation_id.strip()[:MAX_CONVERSATION_ID] + if any(ord(ch) < 32 for ch in conversation_id): + conversation_id = "" + + response = raw.get("response") if isinstance(raw.get("response"), dict) else {} + response_type = response.get("response_type") + if response_type not in ALLOWED_RESPONSE_TYPES: + response_type = "" + + speech = "" + speech_obj = response.get("speech") if isinstance(response.get("speech"), dict) else {} + for key in ("plain", "ssml"): + part = speech_obj.get(key) + if isinstance(part, dict) and isinstance(part.get("speech"), str): + speech = part["speech"] + break + speech = speech.strip()[:MAX_SPEECH] + + return { + "speech": speech, + "conversation_id": conversation_id, + "continue_conversation": bool(raw.get("continue_conversation")), + "response_type": response_type, + } + + +def normalize_camera_ids(raw): + if not isinstance(raw, list): + return [] + out = [] + for item in raw: + if not isinstance(item, str) or not CAMERA_ID_RE.fullmatch(item): + continue + if item in out: + continue + out.append(item) + if len(out) >= MAX_SNAPSHOT_ENTITIES: + break + return out + + +def normalize_camera_dest(raw): + if not isinstance(raw, str) or not raw.strip(): + return "" + path = os.path.abspath(raw) + home = os.path.expanduser("~") + root = os.path.join(home, ".cache", "omarchy", "hass") + if path != root and not path.startswith(root + os.sep): + return "" + if ".." in path.split(os.sep): + return "" + return path + + +def fetch_camera_snapshot(base, token, entity_id, dest, verify_tls=True): + url = base.rstrip("/") + "/api/camera_proxy/" + entity_id + request = urllib.request.Request(url, method="GET") + request.add_header("Authorization", "Bearer " + (token or "")) + context = None + if url.startswith("https://") and not verify_tls: + context = ssl.create_default_context() + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + opener = urllib.request.build_opener( + urllib.request.ProxyHandler({}), + urllib.request.HTTPSHandler(context=context) if context else urllib.request.HTTPSHandler(), + ) + try: + with opener.open(request, timeout=SNAPSHOT_TIMEOUT) as response: + data = response.read(MAX_SNAPSHOT_BYTES + 1) + except (urllib.error.URLError, TimeoutError, OSError, ssl.SSLError): + return "" + if not data or len(data) > MAX_SNAPSHOT_BYTES: + return "" + if data[:3] != b"\xff\xd8\xff" and data[:8] != b"\x89PNG\r\n\x1a\n": + return "" + ext = ".png" if data[:8] == b"\x89PNG\r\n\x1a\n" else ".jpg" + filename = entity_id.replace(".", "_") + ext + path = os.path.join(dest, filename) + tmp = path + ".tmp" + with open(tmp, "wb") as handle: + handle.write(data) + os.replace(tmp, path) + return path + # Home Assistant's largest message is the initial get_states snapshot. Keep a # documented finite ceiling for both complete messages and individual frames; # max_queue below adds backpressure before several such frames can accumulate. @@ -343,6 +460,13 @@ class DemoTransport: self._ok(msg_id, {"unit_system": {"temperature": "°C"}}) elif kind == "call_service": self._call_service(obj) + elif kind == "assist_pipeline/run": + self._assist(obj) + elif kind == "assist_pipeline/pipeline/list": + self._ok(msg_id, { + "pipelines": [{"id": "demo", "name": "Demo Assist"}], + "preferred_pipeline": "demo", + }) else: self._fail(msg_id, "demo backend does not implement %s" % kind) @@ -436,6 +560,76 @@ class DemoTransport: self._ok(msg_id, None) + def _assist(self, obj): + msg_id = obj.get("id") + raw_input = obj.get("input") if isinstance(obj.get("input"), dict) else {} + text = raw_input.get("text") if isinstance(raw_input.get("text"), str) else "" + text = text.strip() + if not text: + self._fail(msg_id, "No text provided.") + return + if obj.get("start_stage") != "intent" or obj.get("end_stage") != "intent": + self._fail(msg_id, "demo Assist only accepts text intent runs.") + return + + self._ok(msg_id, None) + speech, response_type = self._assist_reply(text.lower()) + conversation_id = obj.get("conversation_id") + if not isinstance(conversation_id, str) or not conversation_id.strip(): + conversation_id = "demo-conversation" + + self._emit({ + "id": msg_id, + "type": "event", + "event": {"type": "run-start", "data": {"pipeline": "demo", "language": "en"}}, + }) + self._emit({ + "id": msg_id, + "type": "event", + "event": { + "type": "intent-end", + "data": { + "intent_output": { + "continue_conversation": False, + "conversation_id": conversation_id, + "response": { + "response_type": response_type, + "language": "en", + "speech": {"plain": {"speech": speech}}, + }, + } + }, + }, + }) + self._emit({ + "id": msg_id, + "type": "event", + "event": {"type": "run-end", "data": {}}, + }) + + def _assist_reply(self, text): + if "lamp" in text: + if "off" in text: + self._set_state("light.living_room_lamp", "off") + self._set_attrs("light.living_room_lamp", { + "brightness": None, "color_mode": None}) + return "Turned off Living Room Lamp", "action_done" + self._set_state("light.living_room_lamp", "on") + return "Turned on Living Room Lamp", "action_done" + if "temperature" in text: + sensor = self._states["sensor.kitchen_temperature"] + return "Kitchen Temperature is %s degrees" % sensor["state"], "query_answer" + if "movie" in text: + self._set_state("scene.living_room_movie_night", "scening") + self._set_state("light.living_room_lamp", "on") + return "Activated Movie Night", "action_done" + if "garage" in text and ("open" in text or "close" in text): + nxt = "open" if "open" in text else "closed" + self._set_state("cover.garage_door", nxt) + verb = "Opened" if nxt == "open" else "Closed" + return "%s Garage Door" % verb, "action_done" + return "Sorry, I didn't understand that", "error" + def _advance_playlist(self, entity_id, step): self._playlist_index = (self._playlist_index + step) % len(DEMO_PLAYLIST) title, artist = DEMO_PLAYLIST[self._playlist_index] @@ -539,6 +733,7 @@ class Bridge: self._out_lock = threading.Lock() self._commands = queue.Queue() self._command_event = threading.Event() + self._snapshot_inflight = False # -- output ----------------------------------------------------------- @@ -547,7 +742,7 @@ class Bridge: obj.setdefault("protocolVersion", PROTOCOL_VERSION) if obj.get("ev") in { "phase", "states", "state_changed", "removed", - "registries", "config", "result", "log"}: + "registries", "config", "snapshots", "pipelines", "result", "log"}: obj.setdefault("generation", self.generation) line = json.dumps(obj, ensure_ascii=False) with self._out_lock: @@ -558,10 +753,14 @@ class Bridge: self.emit({"ev": "log", "level": level, "msg": msg, "errorKind": "warning"}) - def safe_error(self, error, fallback="Home Assistant request failed."): - text = str(error or "").strip() or fallback + def redact(self, text): + text = str(text or "") if self.token: text = text.replace(self.token, "[redacted]") + return text + + def safe_error(self, error, fallback="Home Assistant request failed."): + text = self.redact(error).strip() or fallback return text[:1000] def set_phase(self, phase, error="", force=False, error_kind="connection"): @@ -634,6 +833,12 @@ class Bridge: self.request_registries() elif op == "call_service": self.call_service(command) + elif op == "conversation": + self.conversation(command) + elif op == "snapshots": + self.snapshots(command) + elif op == "pipelines": + self.request_pipelines() else: self.log("warn", "unknown op %r" % op) @@ -685,6 +890,36 @@ class Bridge: self.request("get_states", {"type": "get_states"}) self.request_registries() self.request("config", {"type": "get_config"}) + self.request_pipelines() + + def request_pipelines(self): + if not self.authenticated: + return + self.request("pipelines", {"type": "assist_pipeline/pipeline/list"}) + + def emit_pipelines(self, result): + raw = result if isinstance(result, dict) else {} + pipelines = raw.get("pipelines") + out = [] + if isinstance(pipelines, list): + for item in pipelines: + if not isinstance(item, dict): + continue + ident = item.get("id") + name = item.get("name") + if not isinstance(ident, str) or not ident: + continue + if not isinstance(name, str) or not name.strip(): + name = ident + out.append({"id": ident[:128], "name": name.strip()[:80]}) + preferred = raw.get("preferred_pipeline") + if not isinstance(preferred, str): + preferred = "" + self.emit({ + "ev": "pipelines", + "pipelines": out, + "preferred": preferred[:128], + }) def call_service(self, command): domain = command.get("domain") @@ -714,6 +949,88 @@ class Bridge: payload["target"] = {"entity_id": entity_id} self.request("call_service", payload, tag=tag) + def conversation(self, command): + tag = command.get("tag") + text = command.get("text") + if not isinstance(text, str) or not text.strip(): + self.emit_result(tag, False, "Type something for Assist.") + return + text = text.strip()[:MAX_CONVERSATION_TEXT] + conversation_id = command.get("conversation_id") or "" + if not isinstance(conversation_id, str): + conversation_id = "" + conversation_id = conversation_id.strip()[:MAX_CONVERSATION_ID] + if any(ord(ch) < 32 for ch in conversation_id): + conversation_id = "" + + payload = { + "type": "assist_pipeline/run", + "start_stage": "intent", + "end_stage": "intent", + "input": {"text": text}, + "timeout": CONVERSATION_TIMEOUT, + } + if conversation_id: + payload["conversation_id"] = conversation_id + pipeline = command.get("pipeline") or "" + if isinstance(pipeline, str): + pipeline = pipeline.strip() + if pipeline and all(ch.isalnum() or ch in "-_" for ch in pipeline): + payload["pipeline"] = pipeline[:128] + self.request("conversation", payload, tag=tag) + + def snapshots(self, command): + tag = command.get("tag") + if self.demo: + self.emit_result(tag, False, "Snapshots are not available in demo mode.") + return + if self.phase != "connected" or not self.token or not self.url: + self.emit_result(tag, False, "Not connected to Home Assistant.") + return + if self._snapshot_inflight: + return + entities = normalize_camera_ids(command.get("entities")) + if not entities: + self.emit_result(tag, False, "No valid camera entities.") + return + dest = normalize_camera_dest(command.get("dest")) + if not dest: + self.emit_result(tag, False, "Invalid snapshot cache directory.") + return + try: + base = self.http_base() + except WebSocketError as exc: + self.emit_result(tag, False, str(exc)) + return + self._snapshot_inflight = True + threading.Thread( + target=self._snapshot_worker, + args=(self.generation, dest, entities, base, self.token, self.verify_tls), + daemon=True, + ).start() + + def _snapshot_worker(self, generation, dest, entities, base, token, verify_tls): + try: + os.makedirs(dest, exist_ok=True) + frames = [] + for entity_id in entities: + path = fetch_camera_snapshot( + base, token, entity_id, dest, verify_tls) + frames.append({ + "entity_id": entity_id, + "path": path or "", + "ok": bool(path), + }) + if generation != self.generation: + return + self.emit({"ev": "snapshots", "frames": frames, "generation": generation}) + except Exception as exc: + if generation == self.generation: + self.log("warn", "snapshot fetch failed: %s" % self.safe_error(exc)) + finally: + if generation == self.generation: + self._snapshot_inflight = False + # -- request plumbing -------------------------------------------------- def next_id(self): @@ -721,20 +1038,40 @@ class Bridge: self.msg_id += 1 return current - def emit_result(self, tag, ok, error=""): + def emit_result(self, tag, ok, error="", extra=None): if not tag: return payload = {"ev": "result", "tag": str(tag), "ok": bool(ok)} if error: payload["error"] = self.safe_error(error) payload["errorKind"] = "command" + if extra: + for key, value in extra.items(): + if key in payload: + continue + payload[key] = value self.emit(payload) + def emit_conversation_result(self, pending, ok, error="", parsed=None): + extra = {} + parsed = parsed or {} + if ok: + speech = parsed.get("speech") or "" + if not isinstance(speech, str) or not speech.strip(): + speech = "Done." + extra["speech"] = self.redact(speech).strip()[:MAX_SPEECH] or "Done." + conversation_id = parsed.get("conversation_id") or "" + extra["conversation_id"] = conversation_id if isinstance(conversation_id, str) else "" + extra["continue_conversation"] = bool(parsed.get("continue_conversation")) + response_type = parsed.get("response_type") or "" + extra["response_type"] = response_type if response_type in ALLOWED_RESPONSE_TYPES else "" + self.emit_result(pending.get("tag"), ok, error, extra) + def request(self, kind, payload, tag=None, critical=False, batch=None): if not self.transport or not self.authenticated: self.emit_result(tag, False, "Not connected to Home Assistant.") return None - if kind == "call_service" and self.phase != "connected": + if kind in ("call_service", "conversation") and self.phase != "connected": self.emit_result(tag, False, "Home Assistant is still synchronizing.") return None msg_id = self.next_id() @@ -898,6 +1235,15 @@ class Bridge: else base_path + "/api/websocket" return urllib.parse.urlunsplit((scheme, parts.netloc, path, "", "")) + def http_base(self): + ws = self.websocket_url() + parts = urllib.parse.urlsplit(ws) + scheme = "https" if parts.scheme == "wss" else "http" + path = parts.path + if path.endswith("/api/websocket"): + path = path[: -len("/api/websocket")] + return urllib.parse.urlunsplit((scheme, parts.netloc, path, "", "")).rstrip("/") + def disconnect(self): if self.transport: try: @@ -957,6 +1303,7 @@ class Bridge: self.request_registries() # Climate entities carry no unit of their own; the instance decides. self.request("config", {"type": "get_config"}) + self.request_pipelines() def on_auth_invalid(self, msg): raw_message = msg.get("message") or "" @@ -1014,6 +1361,13 @@ class Bridge: return result = msg.get("result") + if kind == "conversation": + # assist_pipeline/run acknowledges immediately, then streams + # intent-end / run-end as events on this same request id. + pending["deadline"] = now() + CONVERSATION_TIMEOUT + pending["pipeline"] = {} + self.pending[msg_id] = pending + return if kind == "subscribe": self.subscription_ready = True self.maybe_ready() @@ -1037,6 +1391,8 @@ class Bridge: units = units if isinstance(units, dict) else {} self.emit({"ev": "config", "unit_temperature": units.get("temperature", "")}) + elif kind == "pipelines": + self.emit_pipelines(result) elif tag: self.emit_result(tag, True) @@ -1045,6 +1401,13 @@ class Bridge: self.set_phase("connected") def handle_event(self, msg): + msg_id = msg.get("id") + pending = self.pending.get(msg_id) if isinstance(msg_id, int) else None + if pending is not None and pending.get("kind") == "conversation": + if pending.get("epoch") == self.transport_epoch: + self.handle_pipeline_event(msg_id, pending, msg.get("event")) + return + event = msg.get("event") or {} if not isinstance(event, dict): return @@ -1062,6 +1425,24 @@ class Bridge: if isinstance(new_state, dict) and isinstance(new_state.get("entity_id"), str): self.emit({"ev": "state_changed", "entity": new_state}) + def handle_pipeline_event(self, msg_id, pending, event): + if not isinstance(event, dict): + return + kind = event.get("type") or event.get("event_type") + data = event.get("data") if isinstance(event.get("data"), dict) else {} + + if kind == "intent-end": + pending["pipeline"] = parse_intent_output(data.get("intent_output")) + elif kind == "error": + raw_message = data.get("message") + message = raw_message.strip() if isinstance(raw_message, str) else "" + message = message or "Assist could not complete that request." + self.pending.pop(msg_id, None) + self.emit_conversation_result(pending, False, message) + elif kind == "run-end": + self.pending.pop(msg_id, None) + self.emit_conversation_result(pending, True, "", pending.get("pipeline") or {}) + # -- main loop --------------------------------------------------------- def run(self): diff --git a/manifest.json b/manifest.json index f9d00ca..5806c12 100644 --- a/manifest.json +++ b/manifest.json @@ -2,10 +2,10 @@ "schemaVersion": 1, "id": "hass", "name": "Home Assistant", - "version": "0.2.2", + "version": "0.3.0", "author": "Konrad Kruk", "license": "MIT", - "description": "View and control Home Assistant devices from the Omarchy bar.", + "description": "Talk to Assist and control Home Assistant devices from the Omarchy bar.", "kinds": [ "service", "bar-widget", @@ -18,7 +18,7 @@ }, "barWidget": { "displayName": "Home Assistant", - "description": "Favorites-first control of Home Assistant entities.", + "description": "Talk to Assist and control favorite Home Assistant devices.", "category": "Home", "allowMultiple": false, "defaultSection": "right" diff --git a/mpv-preview.conf b/mpv-preview.conf new file mode 100644 index 0000000..a13d327 --- /dev/null +++ b/mpv-preview.conf @@ -0,0 +1,5 @@ +# Lightbox-style close for the camera preview window. +ESC quit +MBTN_LEFT quit +MBTN_RIGHT quit +q quit diff --git a/tests/fake_ha.py b/tests/fake_ha.py index 7f73b9c..fa5c129 100644 --- a/tests/fake_ha.py +++ b/tests/fake_ha.py @@ -113,7 +113,9 @@ class FakeHA: def __init__(self, auth_mode="ok", drop_after=None, fail_calls=False, giant_frame=False, fail_requests=None, delays=None, invalid_message=False, empty_fragments=0, - host="127.0.0.1", tls=False, echo_auth_token=False): + host="127.0.0.1", tls=False, echo_auth_token=False, + fail_conversations=False, echo_conversation_token=False, + hostile_conversation=False): self.auth_mode = auth_mode self.drop_after = drop_after # Reject every call_service. Home Assistant does this for a service @@ -130,6 +132,11 @@ def __init__(self, auth_mode="ok", drop_after=None, fail_calls=False, self.host = host self.tls = tls self.echo_auth_token = echo_auth_token + self.fail_conversations = fail_conversations + self.echo_conversation_token = echo_conversation_token + self.hostile_conversation = hostile_conversation + self.last_token = "" + self.conversations = [] self.connections = 0 self.states = [ {"entity_id": "light.test", "state": "off", @@ -207,6 +214,7 @@ def _handle(self, conn, msg, index): msg_id = msg.get("id") if kind == "auth": + self.last_token = str(msg.get("access_token") or "") reject = (self.auth_mode == "invalid" or (self.auth_mode == "invalid_once" and index == 1)) if reject: @@ -271,6 +279,38 @@ def _handle(self, conn, msg, index): send_json(conn, {"type": "event", "event": { "event_type": "state_changed", "data": {"entity_id": "light.test", "new_state": self.states[0]}}}) + elif kind == "assist_pipeline/run": + self.conversations.append(msg) + if self.fail_conversations: + send_json(conn, {"id": msg_id, "type": "result", "success": False, + "error": {"code": "pipeline-not-found", + "message": "Assist pipeline failed"}}) + return + send_json(conn, {"id": msg_id, "type": "result", "success": True, "result": None}) + if self.hostile_conversation: + send_json(conn, {"id": msg_id, "type": "event", + "event": {"type": "intent-end", "data": ["nope"]}}) + send_json(conn, {"id": msg_id, "type": "event", + "event": {"type": "run-end", "data": {}}}) + return + speech = "Turned Test Light on" + if self.echo_conversation_token: + speech = "Heard token " + self.last_token + conversation_id = msg.get("conversation_id") or "conv-test" + send_json(conn, {"id": msg_id, "type": "event", "event": { + "type": "run-start", "data": {"pipeline": "preferred", "language": "en"}}}) + send_json(conn, {"id": msg_id, "type": "event", "event": { + "type": "intent-end", + "data": {"intent_output": { + "continue_conversation": False, + "conversation_id": conversation_id, + "response": { + "response_type": "action_done", + "speech": {"plain": {"speech": speech}}, + }, + }}}}) + send_json(conn, {"id": msg_id, "type": "event", + "event": {"type": "run-end", "data": {}}}) else: send_json(conn, {"id": msg_id, "type": "result", "success": False, "error": {"code": "unknown", "message": "no such command"}}) diff --git a/tests/test_assist.js b/tests/test_assist.js new file mode 100644 index 0000000..a05e70d --- /dev/null +++ b/tests/test_assist.js @@ -0,0 +1,72 @@ +#!/usr/bin/env node +// Unit tests for Assist.js. Run: node tests/test_assist.js + +const fs = require("fs"); +const path = require("path"); + +const source = fs + .readFileSync(path.join(__dirname, "..", "Assist.js"), "utf8") + .replace(/^\.pragma library\s*$/m, ""); + +const names = [...source.matchAll(/^function\s+([A-Za-z0-9_]+)/gm)].map((m) => m[1]); +const consts = [...source.matchAll(/^var\s+([A-Z][A-Z0-9_]*)/gm)].map((m) => m[1]); +const Assist = new Function(`${source}\nreturn {${[...names, ...consts].join(",")}};`)(); + +let failures = 0; +let checks = 0; + +function eq(label, actual, expected) { + checks++; + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + failures++; + console.log(` FAIL ${label}\n got ${JSON.stringify(actual)}` + + `\n expected ${JSON.stringify(expected)}`); + } +} + +console.log("assist text and conversation id sanitization"); +eq("trims user text", Assist.normalizeText(" turn off the lamp "), "turn off the lamp"); +eq("rejects blank text", Assist.normalizeText(" "), ""); +eq("rejects non-strings", Assist.normalizeText(12), ""); +eq("truncates long text", Assist.normalizeText("x".repeat(Assist.MAX_TEXT + 20)).length, + Assist.MAX_TEXT); +eq("keeps a normal conversation id", Assist.sanitizeConversationId("conv-1"), "conv-1"); +eq("drops control characters", Assist.sanitizeConversationId("conv\u0001id"), ""); +eq("drops oversized ids", Assist.sanitizeConversationId("c".repeat(200)), ""); +eq("drops non-string ids", Assist.sanitizeConversationId({ id: "x" }), ""); + +console.log("assist result projection"); +eq("reads speech from a successful result", + Assist.projectResult({ + ok: true, + speech: " Turned off the lamp ", + conversation_id: "conv-9", + continue_conversation: true, + response_type: "action_done" + }), + { + ok: true, + speech: "Turned off the lamp", + conversationId: "conv-9", + continueConversation: true, + responseType: "action_done", + error: "Assist could not complete that request." + }); +eq("falls back when speech is missing", + Assist.projectResult({ ok: true }).speech, "Done."); +eq("keeps a failed result's error", + Assist.projectResult({ ok: false, error: "pipeline missing" }).error, + "pipeline missing"); +eq("does not treat a non-string speech as text", + Assist.speechFromResult({ speech: { html: "hi" } }), ""); +eq("labels user and assist rows", + Assist.messageFor("user", "hello"), { speaker: "user", body: "hello" }); +eq("unknown speakers become assist", + Assist.messageFor("system", "x").speaker, "assist"); +eq("idle timeout is one minute", Assist.IDLE_MS, 60000); + +if (failures) { + console.log("\nFAILED: %d of %d checks", failures, checks); + process.exit(1); +} +console.log("all %d checks passed", checks); diff --git a/tests/test_bridge.py b/tests/test_bridge.py index 3572be8..1d5c379 100644 --- a/tests/test_bridge.py +++ b/tests/test_bridge.py @@ -672,6 +672,234 @@ def test_demo_needs_no_server(): lambda e: e["ev"] == "state_changed" and e["entity"]["entity_id"].startswith("climate."), budget=12) check("emits unprompted events", drift is not None) + + bridge.send({"op": "conversation", "text": "turn off the lamp", + "tag": "assist-demo"}) + spoken = bridge.wait_for( + lambda e: e["ev"] == "result" and e.get("tag") == "assist-demo") + check("demo Assist replies", + spoken is not None and spoken.get("ok") + and spoken.get("speech") == "Turned off Living Room Lamp", spoken) + lamp = bridge.wait_for( + lambda e: e["ev"] == "state_changed" + and e["entity"]["entity_id"] == "light.living_room_lamp" + and e["entity"]["state"] == "off") + check("demo Assist mutates the house", lamp is not None) + finally: + bridge.stop() + + +def test_conversation_happy_path(): + print("assist: text runs the preferred pipeline and returns speech") + server = FakeHA() + bridge = BridgeProc() + try: + bridge.send({"op": "config", "url": server.url, "token": "tok"}) + bridge.wait_for(lambda e: e["ev"] == "phase" and e["phase"] == "connected") + + bridge.send({"op": "conversation", "text": "turn on the lights", + "conversation_id": "conv-1", "tag": "assist-1"}) + result = bridge.wait_for( + lambda e: e["ev"] == "result" and e.get("tag") == "assist-1") + check("returns speech", + result is not None and result.get("ok") + and result.get("speech") == "Turned Test Light on", result) + check("echoes the conversation id", + result is not None and result.get("conversation_id") == "conv-1", result) + check("does not leak the raw intent payload", + result is not None and "intent_output" not in result, result) + + sent = server.conversations[0] if server.conversations else {} + check("uses the preferred Assist pipeline", + sent.get("type") == "assist_pipeline/run" + and sent.get("start_stage") == "intent" + and sent.get("end_stage") == "intent", sent) + check("forwards the sentence", + (sent.get("input") or {}).get("text") == "turn on the lights", sent) + check("forwards the conversation id", + sent.get("conversation_id") == "conv-1", sent) + finally: + bridge.stop() + server.stop() + + +def test_conversation_rejection_is_reported(): + print("assist: a failed pipeline comes back tagged") + server = FakeHA(fail_conversations=True) + bridge = BridgeProc() + try: + bridge.send({"op": "config", "url": server.url, "token": "tok"}) + bridge.wait_for(lambda e: e["ev"] == "phase" and e["phase"] == "connected") + + bridge.send({"op": "conversation", "text": "turn on the lights", + "tag": "assist-fail"}) + result = bridge.wait_for( + lambda e: e["ev"] == "result" and e.get("tag") == "assist-fail") + check("reports the failure", result is not None and not result["ok"], result) + check("keeps the server's reason", + result is not None and "pipeline" in result.get("error", "").lower(), + result) + finally: + bridge.stop() + server.stop() + + +def test_conversation_rejects_empty_text(): + print("assist: empty text is refused without a pipeline run") + server = FakeHA() + bridge = BridgeProc() + try: + bridge.send({"op": "config", "url": server.url, "token": "tok"}) + bridge.wait_for(lambda e: e["ev"] == "phase" and e["phase"] == "connected") + + bridge.send({"op": "conversation", "text": " ", "tag": "assist-empty"}) + result = bridge.wait_for( + lambda e: e["ev"] == "result" and e.get("tag") == "assist-empty") + check("rejects empty text", + result is not None and not result["ok"], result) + check("does not start a pipeline", server.conversations == [], + server.conversations) + finally: + bridge.stop() + server.stop() + + +def test_conversation_hostile_payload_is_safe(): + print("assist: a hostile intent payload still completes") + server = FakeHA(hostile_conversation=True) + bridge = BridgeProc() + try: + bridge.send({"op": "config", "url": server.url, "token": "tok"}) + bridge.wait_for(lambda e: e["ev"] == "phase" and e["phase"] == "connected") + + bridge.send({"op": "conversation", "text": "hello", "tag": "assist-hostile"}) + result = bridge.wait_for( + lambda e: e["ev"] == "result" and e.get("tag") == "assist-hostile") + check("still succeeds", + result is not None and result.get("ok"), result) + check("falls back to a safe reply", + result is not None and result.get("speech") == "Done.", result) + check("does not forward the hostile payload", + result is not None and "intent_output" not in result + and not isinstance(result.get("speech"), list), result) + finally: + bridge.stop() + server.stop() + + +def test_conversation_redacts_token_from_speech(): + print("assist: a malicious reply cannot echo the access token") + token = "TOP_SECRET_ASSIST_TOKEN" + server = FakeHA(echo_conversation_token=True) + bridge = BridgeProc() + try: + bridge.send({"op": "config", "url": server.url, "token": token}) + bridge.wait_for(lambda e: e["ev"] == "phase" and e["phase"] == "connected") + + bridge.send({"op": "conversation", "text": "hello", "tag": "assist-secret"}) + result = bridge.wait_for( + lambda e: e["ev"] == "result" and e.get("tag") == "assist-secret") + serialized = json.dumps(bridge.snapshot()) + check("returns a result", result is not None and result.get("ok"), result) + check("token is redacted from speech", + result is not None and token not in (result.get("speech") or "") + and "[redacted]" in (result.get("speech") or ""), result) + check("token is absent from every NDJSON event", token not in serialized, + serialized) + finally: + bridge.stop() + server.stop() + + +def _load_bridge_module(): + import importlib.machinery + import importlib.util + loader = importlib.machinery.SourceFileLoader("hass_bridge", BRIDGE) + spec = importlib.util.spec_from_loader(loader.name, loader) + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + + +def test_snapshot_helpers_reject_hostile_input(): + print("cameras: snapshot helpers reject hostile entity ids and dests") + module = _load_bridge_module() + check("keeps only camera.* ids", + module.normalize_camera_ids( + ["camera.frontyard", "light.x", "camera.evil/../x", + "camera.driveway", "camera.frontyard"]) + == ["camera.frontyard", "camera.driveway"]) + home = os.path.expanduser("~") + ok = os.path.join(home, ".cache", "omarchy", "hass", "cameras") + check("allows the plugin cache dir", + module.normalize_camera_dest(ok) == os.path.abspath(ok)) + check("rejects dest outside the cache root", + module.normalize_camera_dest("/tmp/hass-cameras") == "") + + +def test_snapshot_fetch_does_not_use_proxy_or_leak_token(): + print("cameras: still fetch ignores proxies and writes a local jpeg") + import http.server + import socketserver + import tempfile + + module = _load_bridge_module() + jpeg = b"\xff\xd8\xff\xd9" + token = "SNAP_SECRET_TOKEN" + + class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + if self.headers.get("Authorization") != "Bearer " + token: + self.send_error(401) + return + if self.path != "/api/camera_proxy/camera.frontyard": + self.send_error(404) + return + self.send_response(200) + self.send_header("Content-Type", "image/jpeg") + self.end_headers() + self.wfile.write(jpeg) + + def log_message(self, format, *args): + return + + server = socketserver.TCPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + dest_root = os.path.join(os.path.expanduser("~"), ".cache", "omarchy", "hass") + os.makedirs(dest_root, exist_ok=True) + dest = tempfile.mkdtemp(prefix="cameras-", dir=dest_root) + try: + path = module.fetch_camera_snapshot( + "http://127.0.0.1:%d" % server.server_address[1], + token, "camera.frontyard", dest) + check("writes a jpeg", + path and os.path.isfile(path) and open(path, "rb").read() == jpeg, + path) + check("names the file from the entity id", + path.endswith("camera_frontyard.jpg"), path) + finally: + server.shutdown() + try: + for name in os.listdir(dest): + os.remove(os.path.join(dest, name)) + os.rmdir(dest) + except OSError: + pass + + +def test_snapshots_while_disconnected_fail_fast(): + print("cameras: snapshots fail immediately while disconnected") + bridge = BridgeProc() + try: + dest = os.path.join(os.path.expanduser("~"), + ".cache", "omarchy", "hass", "cameras") + bridge.send({"op": "snapshots", "entities": ["camera.frontyard"], + "dest": dest, "tag": "snap"}) + result = bridge.wait_for( + lambda e: e["ev"] == "result" and e.get("tag") == "snap") + check("reports not connected", + result is not None and not result["ok"], result) finally: bridge.stop() @@ -702,7 +930,15 @@ def main(): test_wss_certificate_policy, test_invalid_websocket_message_is_controlled, test_fragment_flood_hits_size_limit, - test_demo_needs_no_server): + test_demo_needs_no_server, + test_conversation_happy_path, + test_conversation_rejection_is_reported, + test_conversation_rejects_empty_text, + test_conversation_hostile_payload_is_safe, + test_conversation_redacts_token_from_speech, + test_snapshot_helpers_reject_hostile_input, + test_snapshot_fetch_does_not_use_proxy_or_leak_token, + test_snapshots_while_disconnected_fail_fast): test() print() diff --git a/tests/test_cameras.js b/tests/test_cameras.js new file mode 100644 index 0000000..9e7b2b5 --- /dev/null +++ b/tests/test_cameras.js @@ -0,0 +1,63 @@ +#!/usr/bin/env node +// Unit tests for Cameras.js. Run: node tests/test_cameras.js + +const fs = require("fs"); +const path = require("path"); + +const source = fs + .readFileSync(path.join(__dirname, "..", "Cameras.js"), "utf8") + .replace(/^\.pragma library\s*$/m, ""); + +const names = [...source.matchAll(/^function\s+([A-Za-z0-9_]+)/gm)].map((m) => m[1]); +const consts = [...source.matchAll(/^var\s+([A-Z][A-Z0-9_]*)/gm)].map((m) => m[1]); +const Cameras = new Function(`${source}\nreturn {${[...names, ...consts].join(",")}};`)(); + +let failures = 0; +let checks = 0; + +function eq(label, actual, expected) { + checks++; + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + failures++; + console.log(` FAIL ${label}\n got ${JSON.stringify(actual)}` + + `\n expected ${JSON.stringify(expected)}`); + } +} + +console.log("camera catalog"); +eq("four cameras", Cameras.ids().length, 4); +eq("configured cameras win", Cameras.ids(["camera.garage"]).length, 1); +eq("includes rearyard", Cameras.CAMERAS[3].title, "Rearyard"); + +const tiles = Cameras.tiles({ + "camera.frontyard": { state: "recording" }, + "camera.driveway": { state: "unavailable" } +}); +eq("frontyard available", tiles[0].available, true); +eq("driveway unavailable", tiles[1].available, false); +eq("missing backyard unavailable", tiles[2].available, false); +eq("anyAvailable when one is live", Cameras.anyAvailable({ + "camera.frontyard": { state: "recording" } +}), true); +eq("anyAvailable when none exist", Cameras.anyAvailable({}), false); + +eq("file source is cache-busted", + Cameras.fileSource("/home/aaron/.cache/omarchy/hass/cameras/x.jpg", 7), + "file:///home/aaron/.cache/omarchy/hass/cameras/x.jpg?r=7"); +eq("rejects relative paths", Cameras.fileSource("tmp/x.jpg", 1), ""); +eq("rejects parent traversal", Cameras.fileSource("/tmp/../etc/passwd", 1), ""); + +eq("stream host comes from the HA url", + Cameras.streamUrl("http://192.168.0.123:8123", "camera.frontyard"), + "rtsp://192.168.0.123:8554/Frontyard"); +eq("unknown camera derives a go2rtc name", + Cameras.streamUrl("http://192.168.0.123:8123", "camera.other"), + "rtsp://192.168.0.123:8554/Other"); +eq("credentials in the HA url are refused", + Cameras.streamUrl("http://user:pass@192.168.0.123:8123", "camera.frontyard"), ""); + +if (failures) { + console.log("\nFAILED: %d of %d checks", failures, checks); + process.exit(1); +} +console.log("all %d checks passed", checks); diff --git a/tests/test_config.js b/tests/test_config.js index 0970c49..238102e 100644 --- a/tests/test_config.js +++ b/tests/test_config.js @@ -38,6 +38,8 @@ const parsed = Config.parse(JSON.stringify({ }), ["light.demo"]); eq("typed values are normalized", parsed.config, { baseUrl: "", + localUrl: "", + remoteUrl: "", demoMode: true, favorites: ["light.a"], demoFavorites: [], @@ -45,7 +47,12 @@ eq("typed values are normalized", parsed.config, { showEntityIcons: false, selectedTab: "area:kitchen", displayNameOverrides: { "light.a": "Desk" }, - iconOverrides: {} + iconOverrides: {}, + cameraIds: [], + chargeEntityId: "", + batteryPowerEntityId: "", + loadPowerEntityId: "", + assistPipelineId: "" }); const merged = Config.merge(parsed.config, { @@ -59,6 +66,24 @@ eq("serialized config has one trailing newline", Config.serialize(merged).endsWith("}\n"), true); eq("serialized config contains no token", Config.serialize(merged).includes("token"), false); +const panel = Config.parse(JSON.stringify({ + cameraIds: ["camera.frontyard", "light.x", "camera.frontyard", "not-an-id"], + chargeEntityId: "sensor.home_percentage_charged", + assistPipelineId: "01ab-cd" +}), []); +eq("camera ids are unique camera-like entities", panel.config.cameraIds, + ["camera.frontyard"]); +eq("charge entity is kept", panel.config.chargeEntityId, + "sensor.home_percentage_charged"); +eq("pipeline id is kept", panel.config.assistPipelineId, "01ab-cd"); + +const migrated = Config.parse(JSON.stringify({ + baseUrl: "http://192.168.0.123:8123" +}), []); +eq("legacy baseUrl becomes the local URL", migrated.config.localUrl, + "http://192.168.0.123:8123"); +eq("legacy configs have no remote URL", migrated.config.remoteUrl, ""); + console.log(); if (failures) { console.log(`FAILED: ${failures} of ${checks} checks`); diff --git a/tests/test_connection.js b/tests/test_connection.js index 7301fef..01f4688 100644 --- a/tests/test_connection.js +++ b/tests/test_connection.js @@ -57,6 +57,16 @@ eq("bad ports are rejected", Connection.normalizeOrigin("https://ha.local:99999" eq("an empty explicit port is rejected", Connection.normalizeOrigin("https://ha.local:"), ""); eq("unknown schemes are rejected", Connection.normalizeOrigin("ftp://ha.local"), ""); eq("demo has a separate signature", Connection.signature(true, ""), "demo"); +eq("nabu casa hosts are detected", + Connection.isNabuCasa("https://abcd1234.ui.nabu.casa"), true); +eq("local hosts are not nabu casa", + Connection.isNabuCasa("http://192.168.0.123:8123"), false); +eq("local connection label", + Connection.connectionLabel(false, "local", ""), "Connected locally"); +eq("nabu casa connection label", + Connection.connectionLabel(false, "remote", "https://x.ui.nabu.casa"), + "Connected via Nabu Casa"); +eq("demo connection label", Connection.connectionLabel(true, "local", ""), "Demo"); // The signature is what reconcileConnection compares to decide whether the // running bridge is still the right one, so it has to pin the URL text and not // just the credential origin. diff --git a/tests/test_powerwall.js b/tests/test_powerwall.js new file mode 100644 index 0000000..daf3cf7 --- /dev/null +++ b/tests/test_powerwall.js @@ -0,0 +1,69 @@ +#!/usr/bin/env node +// Unit tests for Powerwall.js. Run: node tests/test_powerwall.js + +const fs = require("fs"); +const path = require("path"); + +const source = fs + .readFileSync(path.join(__dirname, "..", "Powerwall.js"), "utf8") + .replace(/^\.pragma library\s*$/m, ""); + +const names = [...source.matchAll(/^function\s+([A-Za-z0-9_]+)/gm)].map((m) => m[1]); +const consts = [...source.matchAll(/^var\s+([A-Z][A-Z0-9_]*)/gm)].map((m) => m[1]); +const Powerwall = new Function(`${source}\nreturn {${[...names, ...consts].join(",")}};`)(); + +let failures = 0; +let checks = 0; + +function eq(label, actual, expected) { + checks++; + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + failures++; + console.log(` FAIL ${label}\n got ${JSON.stringify(actual)}` + + `\n expected ${JSON.stringify(expected)}`); + } +} + +function states(charge, battery, load) { + const map = {}; + if (charge !== undefined) map[Powerwall.CHARGE_ID] = { state: String(charge) }; + if (battery !== undefined) map[Powerwall.BATTERY_POWER_ID] = { state: String(battery) }; + if (load !== undefined) map[Powerwall.LOAD_POWER_ID] = { state: String(load) }; + return map; +} + +console.log("powerwall formatting"); +eq("rounds percent", Powerwall.formatPercent(32.706), "33%"); +eq("formats kilowatts", Powerwall.formatKw(-1.981), "2.0 kW"); +eq("formats small load", Powerwall.formatKw(0.521), "0.5 kW"); +eq("missing number is an em dash", Powerwall.formatKw(null), "—"); + +console.log("powerwall projection"); +eq("missing charge hides the card", Powerwall.project({}).available, false); + +const charging = Powerwall.project(states("32.7067669172932", "-1.981", "0.521")); +eq("charging is available", charging.available, true); +eq("charging percent text", charging.percentText, "33%"); +eq("charging fraction", Math.round(charging.fraction * 100), 33); +eq("charging subtitle", charging.subtitle, "Home 0.5 kW · Charging 2.0 kW"); +eq("charging flag", charging.charging, true); +eq("charging not discharging", charging.discharging, false); + +const discharging = Powerwall.project(states("80", "1.2", "1.8")); +eq("discharging subtitle", discharging.subtitle, "Home 1.8 kW · Using 1.2 kW"); +eq("discharging flag", discharging.discharging, true); + +const idle = Powerwall.project(states("50", "0.01", "0.3")); +eq("near-zero battery power is idle", idle.flowLabel, "Idle"); +eq("idle subtitle", idle.subtitle, "Home 0.3 kW · Idle"); + +const gone = Powerwall.project({ + [Powerwall.CHARGE_ID]: { state: "unavailable" } +}); +eq("unavailable charge hides the card", gone.available, false); + +if (failures) { + console.log("\nFAILED: %d of %d checks", failures, checks); + process.exit(1); +} +console.log("all %d checks passed", checks); diff --git a/tests/test_service_contract.py b/tests/test_service_contract.py index d8ab6bd..584764d 100644 --- a/tests/test_service_contract.py +++ b/tests/test_service_contract.py @@ -118,6 +118,20 @@ def main(): and "Model.capabilitiesFor(entity)" in service) check("selected tab persistence is debounced", "selectedTabSaveDebounce.restart()" in service) + check("Assist conversation state is not persisted", + "conversation_id" not in current_config.lower() + and "assistConversationId" not in current_config) + check("Assist replies are correlated by tagged results", + 'tag.indexOf("assist:")' in function_block("handleResult") + and 'op: "conversation"' in function_block("sendAssist")) + check("Assist conversation expires after a minute of silence", + "Assist.IDLE_MS" in service + and "assistIdle.restart()" in function_block("touchAssistIdle") + and "assistIdle.stop()" in function_block("resetAssist")) + check("camera stills live under the user cache directory", + ".cache/omarchy/hass/cameras" in service) + check("snapshot failures do not become panel errors", + 'tag === "snap"' in function_block("handleResult")) check("bridge never accepts the token in argv", "access_token" in bridge and '"--token"' not in bridge)