Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
```
Expand Down
60 changes: 60 additions & 0 deletions Assist.js
Original file line number Diff line number Diff line change
@@ -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 : ""
}
}
46 changes: 46 additions & 0 deletions CameraThumb.qml
Original file line number Diff line number Diff line change
@@ -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
}
}
118 changes: 118 additions & 0 deletions Cameras.js
Original file line number Diff line number Diff line change
@@ -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
}
35 changes: 31 additions & 4 deletions ConfigStore.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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 = {}
Expand Down Expand Up @@ -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,
Expand All @@ -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)
}
}
}
Expand Down
18 changes: 18 additions & 0 deletions Connection.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading