From 8296ca0f7e7f68be862cb4d7d09507469a5fe1f0 Mon Sep 17 00:00:00 2001 From: Artur Date: Sat, 18 Jul 2026 02:06:27 -0300 Subject: [PATCH] feat(hassio): Implemented the Home Assistant plugin for Noctalia v5. (#31) * feat(hassio): Implemented the Home Assistant plugin for Noctalia v5. * Update plugin.toml * Update plugin.toml * fix(*): Changed curl HTTP calls to native noctalia HTTP calls. * fix(*): Fixed headers on HTTP requests. * fix(service_sse): Fixed HA tokens visible on argv during streams. * fix(service_sse): Added an explicit exit signal for the stream and refresh/reload_entities no longer force `sseActive = false`. * fix(*): Fixed xdg-open missing from README.md and plugin.toml * refact(widget): Changed runInTerminal to runAsync on xdg-open calls to prevent the termial from popping up. * fix(*): Changed plugin data location to be stored where pluginDataDir() points. * fix(README): Added all IPC calls to README. * Updated README to reflect recent changes. * refact(*): Removed some dead code. * fix(*): Changed toggle timeout logic to use real time, fixing toggles being stuck in a loding animation. * refact(*): Added a token cleaner function. * refact(service_sse): Changed curl stream calls for the new native HTTP streaming API. * feat(*): Fixed some translations. --- hassio/README.md | 77 ++++ hassio/panel.luau | 634 +++++++++++++++++++++++++++++++++ hassio/plugin.toml | 95 +++++ hassio/service_sse.luau | 428 ++++++++++++++++++++++ hassio/shortcut.luau | 144 ++++++++ hassio/shortcut_2.luau | 144 ++++++++ hassio/shortcut_3.luau | 144 ++++++++ hassio/shortcut_4.luau | 144 ++++++++ hassio/shortcut_panel.luau | 10 + hassio/thumbnail.webp | Bin 0 -> 48548 bytes hassio/translations/en.json | 63 ++++ hassio/translations/pt-BR.json | 63 ++++ hassio/widget.luau | 96 +++++ 13 files changed, 2042 insertions(+) create mode 100644 hassio/README.md create mode 100644 hassio/panel.luau create mode 100644 hassio/plugin.toml create mode 100644 hassio/service_sse.luau create mode 100644 hassio/shortcut.luau create mode 100644 hassio/shortcut_2.luau create mode 100644 hassio/shortcut_3.luau create mode 100644 hassio/shortcut_4.luau create mode 100644 hassio/shortcut_panel.luau create mode 100644 hassio/thumbnail.webp create mode 100644 hassio/translations/en.json create mode 100644 hassio/translations/pt-BR.json create mode 100644 hassio/widget.luau diff --git a/hassio/README.md b/hassio/README.md new file mode 100644 index 0000000..58e8714 --- /dev/null +++ b/hassio/README.md @@ -0,0 +1,77 @@ +# Home Assistant + +Monitor and control your Home Assistant entities from the Noctalia bar and control center. Useful for quickly toggling lights, checking sensor states, and opening a full entity manager panel for richer controls. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `pozzoo/hassio` | +| Entries | Widget: `status`; Service: `connection`; Shortcuts: `ha_toggle_1`, `ha_toggle_2`, `ha_toggle_3`, `ha_toggle_4`, `ha_panel`; Panel: `entity_manager` | +| Launcher Prefix | — | + +## Requirements + +- A running Home Assistant instance with API access enabled +- A Long-lived Access Token (Profile → Security → Long-lived access tokens) +- `xdg-open` available on `PATH` (used to open the Home Assistant URL in your browser) + +## Usage + +1. Configure the plugin: open **Settings → Plugins → Home Assistant** and set: + + | Setting | Description | + | --- | --- | + | Home Assistant URL | The URL to your HA instance, for example `http://homeassistant.local:8123` | + | Long-Lived Access Token | Token created in HA under Profile → Security | + | Quick Toggle 1–4 — Entity ID | (Optional) Entity IDs to assign to each quick-toggle tile (e.g. `light.living_room`) | + +2. Add the bar widget: add the **status** widget from the widget picker to show connection state and (optionally) the number of monitored entities. Right-click the widget to open your Home Assistant URL in the default browser. + +3. Add control-center tiles: go to **Settings → Control Center** and add any combination of: + - **Home Assistant** (×4) — Quick-toggle tiles. Each corresponds to one of the four entity slots configured in plugin settings. + - **Home Assistant** (panel opener) — Opens the entity manager panel. + +4. Open the entity manager panel: use the panel opener tile or the panel IPC command to open the full browser and pin entities for monitoring. + +```sh +noctalia msg panel-toggle pozzoo/hassio:entity_manager +``` + +## Settings + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `ha_url` | `string` | `""` | Home Assistant base URL used for API requests (include protocol and port if needed). | +| `ha_token` | `string` | `""` | Long-lived access token for Home Assistant. Keep this secret. | +| `shortcut_entity_1` | `string` | `""` | Entity ID used by Quick Toggle 1 (e.g. `light.kitchen`). | +| `shortcut_entity_2` | `string` | `""` | Entity ID used by Quick Toggle 2. | +| `shortcut_entity_3` | `string` | `""` | Entity ID used by Quick Toggle 3. | +| `shortcut_entity_4` | `string` | `""` | Entity ID used by Quick Toggle 4. | +| `show_entity_count` | `bool` | `false` | Show the number of monitored entities next to the connection status in the `status` bar widget. | + +## IPC + +- Open the panel: + +```sh +noctalia msg panel-toggle pozzoo/hassio:entity_manager +``` + +- Force a refresh of the connection and entity states: + +```sh +noctalia msg plugin pozzoo/hassio:status focused refresh +``` + +Use `all` in place of `focused` to target every bar instance. This triggers the same refresh as sending the `refresh` command internally, and shows a notification when it starts. + +Notes: the plugin forwards Home Assistant state updates internally and uses Noctalia's state routing to update widgets and shortcuts. + +## Notes + +- The plugin maintains a live SSE connection to Home Assistant to receive real-time state changes. Noctalia's native HTTP streaming API is used for this connection; all other requests (fetching states, toggling entities, browsing) go through Noctalia's native HTTP API. +- The pinned entity list is saved to `managed_entities.json` in the plugin's persistent data directory (`noctalia.pluginDataDir()`), so it survives plugin updates. +- To authenticate the SSE connection without exposing the access token on the command line, the plugin uses Noctalia's native streaming request headers. +- The plugin issues HTTP requests to your HA instance; do not install untrusted plugins if you do not want them to access your network or tokens. +- If authentication fails, generate a new long-lived access token in Home Assistant and paste it into plugin settings. diff --git a/hassio/panel.luau b/hassio/panel.luau new file mode 100644 index 0000000..ff52d45 --- /dev/null +++ b/hassio/panel.luau @@ -0,0 +1,634 @@ +--!nonstrict + +local haUrl = noctalia.getConfig("ha_url") +local haToken = noctalia.getConfig("ha_token") + +local entityStates = {} +local status = "unconfigured" +local expandedEntityId = nil +local entitySlots = {} +local sliderDraft = {} +local sliderDirty = {} + +local pendingToggle = {} +local pendingSince = {} -- entity_id -> os.clock() when toggle was requested + +local PENDING_TIMEOUT_SECONDS = 8 + +local view = "list" -- "list" | "browser" +local allEntities = {} +local browserSlots = {} +local browserLoading = false +local searchText = "" +local monitoredEntities = {} -- current monitored list (owned here, saved to file) +local panelOpenCount = 0 + +local function tr(key, subst) + return noctalia.tr("panel." .. key, subst) +end + +local function getStatusText() + if status == "connected" then + return noctalia.tr("widget.status_connected") + elseif status == "connecting" then + return noctalia.tr("widget.status_connecting") + elseif status == "disconnected" then + return noctalia.tr("widget.status_disconnected") + elseif status == "auth_failed" then + return noctalia.tr("widget.status_auth_failed") + else + return noctalia.tr("widget.status_unconfigured") + end +end + +local function getCleanToken() + if type(haToken) ~= "string" then return "" end + return haToken:gsub("^%s+", ""):gsub("%s+$", "") +end + +local function haPost(path, body) + if not haUrl or haUrl == "" then return end + local cleanToken = getCleanToken() + if cleanToken == "" then return end + local encodedBody = noctalia.json.encode(body) + + noctalia.http({ + url = haUrl .. path, + method = "POST", + headers = { + "Authorization: Bearer " .. cleanToken, + "Content-Type: application/json", + }, + body = encodedBody, + }, function(response: HttpResponse)end) +end + +local function callService(domain, service, entityId) + haPost("/api/services/" .. domain .. "/" .. service, { entity_id = entityId }) +end + +local MANAGED_ENTITIES_FILE = "managed_entities.json" + +local function managedEntitiesPath() + local dataDir, dirErr = noctalia.pluginDataDir() + if not dataDir then + noctalia.log("Cannot resolve plugin data dir: " .. (dirErr or "unknown")) + return nil + end + return dataDir .. "/" .. MANAGED_ENTITIES_FILE +end + +local function loadMonitoredEntities() + local path = managedEntitiesPath() + local data = path and noctalia.readFile(path) + if data and data ~= "" then + local parsed = noctalia.json.decode(data) + if type(parsed) == "table" then + monitoredEntities = parsed + return + end + end + monitoredEntities = {} +end + +local function saveMonitoredEntities() + local path = managedEntitiesPath() + if not path then + noctalia.notifyError(noctalia.tr("shortcut.title"), tr("save_failed_no_data_dir")) + return + end + + local encodedEntities = noctalia.json.encode(monitoredEntities) + local ok, writeErr = noctalia.writeFile(path, encodedEntities) + if not ok then + noctalia.log("Failed to save managed entities: " .. (writeErr or "unknown")) + noctalia.notifyError(noctalia.tr("shortcut.title"), tr("save_failed")) + return + end + noctalia.state.set("entities_override", monitoredEntities) + noctalia.state.set("command", "reload_entities") +end + +local function isPinned(entityId) + for _, eid in ipairs(monitoredEntities) do + if eid == entityId then return true end + end + return false +end + +local function toggleBrowserPin(entityId) + if not entityId then return end + local newList, found = {}, false + for _, eid in ipairs(monitoredEntities) do + if eid == entityId then found = true + else table.insert(newList, eid) end + end + if not found then table.insert(newList, entityId) end + monitoredEntities = newList + saveMonitoredEntities() + render() +end + +local function fetchAllEntities() + if browserLoading then return end + local cleanToken = getCleanToken() + if not haUrl or haUrl == "" or cleanToken == "" then + allEntities = {} + browserLoading = false + render() + return + end + + browserLoading = true + render() + + noctalia.http({ + url = haUrl .. "/api/states", + headers = { + "Authorization: Bearer " .. cleanToken, + } + }, function(response: HttpResponse) + browserLoading = false + if response and response.body and response.body ~= "" then + local states = noctalia.json.decode(response.body) + if type(states) == "table" then + allEntities = {} + for _, s in ipairs(states) do + local attrs = s.attributes or {} + table.insert(allEntities, { + entity_id = s.entity_id, + friendly_name = attrs.friendly_name or s.entity_id, + domain = s.entity_id:match("^([^.]+)"), + }) + end + table.sort(allEntities, function(a, b) return a.entity_id < b.entity_id end) + end + end + render() + end) +end + +local function initDraft(entityId) + if sliderDraft[entityId] then return end + local e = entityStates[entityId] + local ct = e and e.color_temp or -1 + local ctKelvin + if ct > 500 then ctKelvin = math.floor(ct + 0.5) + elseif ct > 0 then ctKelvin = math.floor(1000000 / ct + 0.5) + else ctKelvin = 4000 end + sliderDraft[entityId] = { + brightness = e and (e.brightness > 0 and e.brightness or 255) or 255, + color_temp = ctKelvin, + hue = e and (e.hue or 0) or 0, + } +end + +local function markDirty(key) + if not expandedEntityId then return end + if not sliderDirty[expandedEntityId] then sliderDirty[expandedEntityId] = {} end + sliderDirty[expandedEntityId][key] = true +end + +local function clearPending(entityId) + pendingToggle[entityId] = nil + pendingSince[entityId] = nil +end + +local function isPendingTimedOut(entityId, now) + local startedAt = pendingSince[entityId] + if not startedAt then return false end + return (now or os.clock()) - startedAt >= PENDING_TIMEOUT_SECONDS +end + +local function reconcilePending(now) + local changed = false + for entityId, expected in pairs(pendingToggle) do + local real = entityStates[entityId] + if not real or real.state == expected or isPendingTimedOut(entityId, now) then + clearPending(entityId) + changed = true + end + end + return changed +end + +-- _actN/_expN are per-slot callback targets referenced by index from the rendered rows below. +-- In list view they act on the entity in that row's slot; in browser view _actN toggles its pin. +local function actSlot(i) + if view == "browser" then + toggleBrowserPin(browserSlots[i]) + return + end + local eid = entitySlots[i] + if not eid then return end + local entity = entityStates[eid] + if not entity then return end + local d = entity.domain + if d == "script" then callService("script", "turn_on", eid) + elseif d == "automation" then callService("automation", "trigger", eid) + else + local newState = entity.state == "on" and "off" or "on" + pendingToggle[eid] = newState + pendingSince[eid] = os.clock() + callService(d, "toggle", eid) + render() + end +end + +local function expSlot(i) + local eid = entitySlots[i] + if not eid then return end + if expandedEntityId == eid then + expandedEntityId = nil + else + sliderDraft[eid] = nil + sliderDirty[eid] = nil + expandedEntityId = eid + end + render() +end + +function _act1() actSlot(1) end; function _exp1() expSlot(1) end +function _act2() actSlot(2) end; function _exp2() expSlot(2) end +function _act3() actSlot(3) end; function _exp3() expSlot(3) end +function _act4() actSlot(4) end; function _exp4() expSlot(4) end +function _act5() actSlot(5) end; function _exp5() expSlot(5) end +function _act6() actSlot(6) end; function _exp6() expSlot(6) end +function _act7() actSlot(7) end; function _exp7() expSlot(7) end +function _act8() actSlot(8) end; function _exp8() expSlot(8) end +function _act9() actSlot(9) end; function _exp9() expSlot(9) end +function _act10() actSlot(10) end; function _exp10() expSlot(10) end +function _act11() actSlot(11) end; function _exp11() expSlot(11) end +function _act12() actSlot(12) end; function _exp12() expSlot(12) end +function _act13() actSlot(13) end; function _exp13() expSlot(13) end +function _act14() actSlot(14) end; function _exp14() expSlot(14) end +function _act15() actSlot(15) end; function _exp15() expSlot(15) end +function _act16() actSlot(16) end; function _exp16() expSlot(16) end +function _act17() actSlot(17) end; function _exp17() expSlot(17) end +function _act18() actSlot(18) end; function _exp18() expSlot(18) end +function _act19() actSlot(19) end; function _exp19() expSlot(19) end +function _act20() actSlot(20) end; function _exp20() expSlot(20) end +function _act21() actSlot(21) end; function _exp21() expSlot(21) end +function _act22() actSlot(22) end; function _exp22() expSlot(22) end +function _act23() actSlot(23) end; function _exp23() expSlot(23) end +function _act24() actSlot(24) end; function _exp24() expSlot(24) end +function _act25() actSlot(25) end; function _exp25() expSlot(25) end +function _act26() actSlot(26) end; function _exp26() expSlot(26) end +function _act27() actSlot(27) end; function _exp27() expSlot(27) end +function _act28() actSlot(28) end; function _exp28() expSlot(28) end +function _act29() actSlot(29) end; function _exp29() expSlot(29) end +function _act30() actSlot(30) end; function _exp30() expSlot(30) end + +function onBrightness(value) + if not expandedEntityId then return end + initDraft(expandedEntityId) + sliderDraft[expandedEntityId].brightness = math.floor((tonumber(value) or 255) + 0.5) + markDirty("brightness") + render() +end + +function onColorTemp(value) + if not expandedEntityId then return end + initDraft(expandedEntityId) + sliderDraft[expandedEntityId].color_temp = math.floor((tonumber(value) or 4000) + 0.5) + markDirty("color_temp") + render() +end + +function onHue(value) + if not expandedEntityId then return end + initDraft(expandedEntityId) + sliderDraft[expandedEntityId].hue = math.floor((tonumber(value) or 0) + 0.5) + markDirty("hue") + render() +end + +function onApplyLight() + if not expandedEntityId then return end + local draft = sliderDraft[expandedEntityId] + local dirty = sliderDirty[expandedEntityId] or {} + local entity = entityStates[expandedEntityId] + if not draft or not entity then return end + + local data = { entity_id = expandedEntityId } + if dirty.hue and entity.supports_rgb then + data.hs_color = { draft.hue, 100 } + if dirty.brightness and entity.supports_brightness then + data.brightness = draft.brightness + end + else + if dirty.brightness and entity.supports_brightness then + data.brightness = draft.brightness + end + if dirty.color_temp and entity.supports_color_temp then + data.color_temp_kelvin = draft.color_temp + end + end + + if dirty.brightness or dirty.color_temp or dirty.hue then + haPost("/api/services/light/turn_on", data) + sliderDirty[expandedEntityId] = {} + end +end + +function onNoop() end + +function onOpenBrowser() + view = "browser" + searchText = "" + fetchAllEntities() +end + +function onBackToList() + view = "list" + allEntities = {} + searchText = "" + render() +end + +function onSearchChange(value) + searchText = value or "" + render() +end + +local function isControllable(domain) + return domain == "light" or domain == "switch" or domain == "input_boolean" + or domain == "fan" or domain == "cover" or domain == "lock" +end + +local function isSensor(domain) + return domain == "sensor" or domain == "binary_sensor" or domain == "weather" or domain == "number" +end + +local function isAutomation(domain) + return domain == "automation" or domain == "script" +end + +local function domainGlyph(domain) + local g = { + light = "bulb", switch = "toggle-right", input_boolean = "toggle-right", + sensor = "chart-line", binary_sensor = "activity", climate = "temperature", + cover = "door", fan = "wind", lock = "lock", media_player = "device-speaker", + weather = "cloud", automation = "robot", script = "player-play", + } + return g[domain] or "smart-home" +end + +local function stateGlyph(domain, isOn) + if domain == "light" then return isOn and "bulb" or "bulb-off" + elseif domain == "switch" or domain == "input_boolean" then return isOn and "toggle-right" or "toggle-left" + elseif domain == "fan" then return isOn and "wind" or "wind-off" + elseif domain == "lock" then return isOn and "lock" or "lock-open" + elseif domain == "cover" then return isOn and "door-open" or "door" + end + return domainGlyph(domain) +end + +local function entityStateLabel(entity) + if isSensor(entity.domain) then + return entity.state .. (entity.unit ~= "" and " " .. entity.unit or "") + end + if entity.domain == "light" and entity.state == "on" and entity.brightness >= 0 then + local pct = math.floor(entity.brightness / 255 * 100 + 0.5) + return tr("state_on_brightness", { percent = pct }) + end + return entity.state +end + +local function buildExpandedSection(entity) + local eid = entity.entity_id + initDraft(eid) + local draft = sliderDraft[eid] + local rows = {} + + if entity.supports_brightness then + local bv = draft.brightness + table.insert(rows, ui.row({ align = "center", gap = 8 }, { + ui.label({ text = tr("brightness"), color = "on_surface_variant" }), + ui.slider({ min = 1, max = 255, step = 1, value = bv, onChange = "onBrightness", flexGrow = 1 }), + ui.label({ text = math.floor(bv / 255 * 100 + 0.5) .. "%" }), + })) + end + + if entity.supports_color_temp then + local ctk = draft.color_temp + table.insert(rows, ui.row({ align = "center", gap = 8 }, { + ui.label({ text = tr("color_temp"), color = "on_surface_variant" }), + ui.slider({ min = 2000, max = 6500, step = 50, value = ctk, onChange = "onColorTemp", flexGrow = 1 }), + ui.label({ text = math.floor(ctk) .. "K" }), + })) + end + + if entity.supports_rgb then + local hv = draft.hue + table.insert(rows, ui.row({ align = "center", gap = 8 }, { + ui.label({ text = tr("hue"), color = "on_surface_variant" }), + ui.slider({ min = 0, max = 360, step = 1, value = hv, onChange = "onHue", flexGrow = 1 }), + ui.label({ text = math.floor(hv) .. "°" }), + })) + end + + if #rows == 0 then return nil end + + table.insert(rows, ui.row({ justify = "end" }, { + ui.button({ glyph = "check", onClick = "onApplyLight" }), + })) + + return ui.column({ gap = 8 }, rows) +end + +local function buildEntityCard(entity, i) + local pending = pendingToggle[entity.entity_id] + local isOn = (pending or entity.state) == "on" + local domain = entity.domain + local canExpand = domain == "light" + and (entity.supports_brightness or entity.supports_color_temp or entity.supports_rgb) + local isExpanded = expandedEntityId == entity.entity_id + + local expandBtn + if canExpand then + expandBtn = ui.button({ + glyph = isExpanded and "chevron-up" or "chevron-down", + onClick = "_exp" .. i, + }) + end + + local isPending = pending ~= nil + local iconClick = isPending and "onNoop" + or (isControllable(domain) or isAutomation(domain)) and "_act" .. i or "onNoop" + local iconGlyph = isPending and "loader" + or isAutomation(domain) and "player-play" or stateGlyph(domain, isOn) + + local rowChildren = { + ui.button({ glyph = iconGlyph, onClick = iconClick }), + ui.column({ flexGrow = 1, gap = 2 }, { + ui.label({ text = entity.friendly_name or entity.entity_id, fontWeight = "medium" }), + ui.label({ text = entityStateLabel(entity), color = "on_surface_variant", fontSize = 12 }), + }), + } + if expandBtn then table.insert(rowChildren, expandBtn) end + + local cardChildren = { ui.row({ align = "center", gap = 8 }, rowChildren) } + + if isExpanded then + local expanded = buildExpandedSection(entity) + if expanded then table.insert(cardChildren, expanded) end + end + + return ui.column({ gap = 8 }, cardChildren) +end + +-- Capped to 30: matches the number of _actN/_expN slot callbacks defined above. +local MAX_SLOTS = 30 + +local function getOrderedIds() + local ids, seen = {}, {} + for _, eid in ipairs(monitoredEntities) do + if entityStates[eid] then + table.insert(ids, eid) + seen[eid] = true + if #ids >= MAX_SLOTS then return ids end + end + end + for eid in pairs(entityStates) do + if not seen[eid] then + table.insert(ids, eid) + if #ids >= MAX_SLOTS then return ids end + end + end + return ids +end + +local function buildListBody(ids) + if not haUrl or haUrl == "" or getCleanToken() == "" then + return ui.label({ text = tr("not_configured"), color = "on_surface_variant" }) + end + if status == "auth_failed" then + return ui.label({ text = tr("auth_failed"), color = "error" }) + end + if status == "connecting" then + return ui.label({ text = tr("connecting"), color = "on_surface_variant" }) + end + if status == "disconnected" then + return ui.label({ text = tr("disconnected_reconnecting"), color = "error" }) + end + if #ids == 0 then + return ui.label({ + text = status == "connected" + and tr("no_entities_monitored") + or tr("status_fallback", { status = status }), + color = "on_surface_variant", + }) + end + local cards = {} + for i, eid in ipairs(ids) do + table.insert(cards, buildEntityCard(entityStates[eid], i)) + end + return ui.scroll({ flexGrow = 1, gap = 8 }, cards) +end + +local function buildBrowserBody() + if browserLoading then + return ui.label({ text = tr("loading_entities"), color = "on_surface_variant" }) + end + if #allEntities == 0 then + return ui.label({ text = tr("no_entities_found"), color = "on_surface_variant" }) + end + + local q = searchText:lower() + local filtered = {} + for _, e in ipairs(allEntities) do + if q == "" + or e.entity_id:lower():find(q, 1, true) + or e.friendly_name:lower():find(q, 1, true) + then + table.insert(filtered, e) + if #filtered >= 30 then break end + end + end + + if #filtered == 0 then + return ui.label({ text = tr("no_entities_match"), color = "on_surface_variant" }) + end + + browserSlots = {} + local rows = {} + for i, e in ipairs(filtered) do + browserSlots[i] = e.entity_id + local pinned = isPinned(e.entity_id) + table.insert(rows, ui.row({ align = "center", gap = 8 }, { + ui.column({ flexGrow = 1, gap = 2 }, { + ui.label({ text = e.friendly_name, fontWeight = "medium" }), + ui.label({ text = e.entity_id, color = "on_surface_variant", fontSize = 12 }), + }), + ui.button({ glyph = pinned and "pin-filled" or "pin", onClick = "_act" .. i }), + })) + end + + return ui.scroll({ flexGrow = 1, gap = 4 }, rows) +end + +function render() + reconcilePending(os.clock()) + + if view == "browser" then + panel.render(ui.column({ flexGrow = 1, gap = 16 }, { + ui.row({ align = "center", gap = 8 }, { + ui.button({ glyph = "arrow-left", onClick = "onBackToList" }), + ui.label({ text = tr("manage_entities_title"), fontSize = 16, fontWeight = "bold", color = "primary", flexGrow = 1 }), + ui.button({ glyph = "close", onClick = "onCloseClicked" }), + }), + ui.input({ key = "entity_search", placeholder = tr("search_placeholder"), onChange = "onSearchChange" }), + buildBrowserBody(), + })) + else + local ids = getOrderedIds() + entitySlots = {} + for i, eid in ipairs(ids) do entitySlots[i] = eid end + + panel.render(ui.column({ flexGrow = 1, gap = 16 }, { + ui.row({ align = "center", gap = 8 }, { + ui.label({ text = noctalia.tr("shortcut.title"), fontSize = 16, fontWeight = "bold", color = "primary", flexGrow = 1 }), + ui.label({ text = getStatusText(), color = status == "connected" and "primary" or "on_surface_variant" }), + ui.button({ glyph = "list-search", onClick = "onOpenBrowser" }), + ui.button({ glyph = "close", onClick = "onCloseClicked" }), + }), + buildListBody(ids), + })) + end +end + +function onOpen(_context) + status = noctalia.state.get("connection_status") or "unconfigured" + entityStates = noctalia.state.get("entities") or {} + loadMonitoredEntities() + panelOpenCount = panelOpenCount + 1 + panel.setWantsSecondTicks(true) + noctalia.state.set("panel_open_signal", panelOpenCount) + render() +end + +function onClose() + panel.setWantsSecondTicks(false) +end + +function update() + if reconcilePending(os.clock()) then + render() + end +end + +function onCloseClicked() + panel.close() +end + +noctalia.state.watch("entities", function(value) + entityStates = value or {} + reconcilePending(os.clock()) + render() +end) + +noctalia.state.watch("connection_status", function(value) + status = value or "unconfigured" + render() +end) diff --git a/hassio/plugin.toml b/hassio/plugin.toml new file mode 100644 index 0000000..afef4a2 --- /dev/null +++ b/hassio/plugin.toml @@ -0,0 +1,95 @@ +id = "pozzoo/hassio" +name = "Home Assistant" +version = "2.0.0" +plugin_api = 4 +author = "Pozzoo" +license = "MIT" +tags = ["bar", "panel", "service", "shortcut", "network", "indicator"] +icon = "smart-home" +dependencies = ["xdg-open"] +description = "Monitor and control Home Assistant entities from the bar. Displays entity states and provides quick toggles." + +[[setting]] +key = "ha_url" +type = "string" +label_key = "settings.url.label" +description_key = "settings.url.description" +default = "" + +[[setting]] +key = "ha_token" +type = "string" +label_key = "settings.token.label" +description_key = "settings.token.description" +default = "" + +[[setting]] +key = "shortcut_entity_1" +type = "string" +label_key = "settings.shortcut_entity_1.label" +description_key = "settings.shortcut_entity.description" +default = "" + +[[setting]] +key = "shortcut_entity_2" +type = "string" +label_key = "settings.shortcut_entity_2.label" +description_key = "settings.shortcut_entity.description" +default = "" + +[[setting]] +key = "shortcut_entity_3" +type = "string" +label_key = "settings.shortcut_entity_3.label" +description_key = "settings.shortcut_entity.description" +default = "" + +[[setting]] +key = "shortcut_entity_4" +type = "string" +label_key = "settings.shortcut_entity_4.label" +description_key = "settings.shortcut_entity.description" +default = "" + +[[widget]] +id = "status" +entry = "widget.luau" + + [[widget.setting]] + key = "show_entity_count" + type = "bool" + label_key = "settings.show_entity_count.label" + default = false + +# Native streaming background service maintaining a live connection to HA +[[service]] +id = "connection" +entry = "service_sse.luau" + +[[shortcut]] +id = "ha_toggle_1" +entry = "shortcut.luau" + +[[shortcut]] +id = "ha_toggle_2" +entry = "shortcut_2.luau" + +[[shortcut]] +id = "ha_toggle_3" +entry = "shortcut_3.luau" + +[[shortcut]] +id = "ha_toggle_4" +entry = "shortcut_4.luau" + +[[shortcut]] +id = "ha_panel" +entry = "shortcut_panel.luau" + +[[panel]] +id = "entity_manager" +entry = "panel.luau" +width = 450 +height = 560 +placement = "floating" +position = "center" diff --git a/hassio/service_sse.luau b/hassio/service_sse.luau new file mode 100644 index 0000000..ead3d84 --- /dev/null +++ b/hassio/service_sse.luau @@ -0,0 +1,428 @@ +--!nonstrict + +local haUrl = noctalia.getConfig("ha_url") +local haToken = noctalia.getConfig("ha_token") + +local MANAGED_ENTITIES_FILE = "managed_entities.json" + +local function loadManagedEntities() + local dataDir, dirErr = noctalia.pluginDataDir() + if not dataDir then + noctalia.log("Cannot resolve plugin data dir: " .. (dirErr or "unknown")) + return {} + end + + local data = noctalia.readFile(dataDir .. "/" .. MANAGED_ENTITIES_FILE) + if data and data ~= "" then + local parsed = noctalia.json.decode(data) + if type(parsed) == "table" then return parsed end + end + return {} +end + +local entities = loadManagedEntities() + +local status = "unconfigured" +local entityStates = {} +local sseActive = false +local streamHandle = nil + +local MAX_RECONNECT_RETRIES = 10 +local CONNECTED_POLL_INTERVAL_MS = 30000 +local RECONNECT_POLL_INTERVAL_MS = 1000 +local retryCount = 0 +local maxRetriesNotified = false +local suspended = false +local connectionRequestInFlight = false + +local function getCleanToken() + if type(haToken) ~= "string" then return "" end + return noctalia.string.trim(haToken) +end + +local function syncUpdateInterval() + if status == "connected" and sseActive then + noctalia.setUpdateInterval(CONNECTED_POLL_INTERVAL_MS) + else + noctalia.setUpdateInterval(RECONNECT_POLL_INTERVAL_MS) + end +end + +local function isConfigured() + return haUrl and haUrl ~= "" and getCleanToken() ~= "" +end + +local function haRequest(endpoint, method, body, callback) + if not isConfigured() then return false end + + local url = haUrl .. endpoint + local cleanToken = getCleanToken() + + local jsonBody + if body then + jsonBody = noctalia.json.encode(body) + end + + local launched = noctalia.http({ + url = url, + method = method, + body = jsonBody, + headers = { + "Authorization: Bearer " .. cleanToken, + "Content-Type: application/json", + } + }, function(response: HttpResponse) + if not response then + callback({ ok = false, status = 0, body = "No response" }) + return + end + + callback({ + ok = response.status >= 200 and response.status < 300, + status = response.status, + body = response.body + }) + end) + + return launched + +end + +local function supportsColorMode(modes, targets) + if type(modes) ~= "table" then return false end + for _, mode in ipairs(modes) do + for _, target in ipairs(targets) do + if mode == target then return true end + end + end + return false +end + +local function processEntity(state) + if not state then return nil end + + local attrs = state.attributes or {} + local modes = attrs.supported_color_modes or {} + + -- stored as mireds for the panel slider + local colorTemp = -1 + if attrs.color_temp_kelvin and attrs.color_temp_kelvin > 0 then + colorTemp = math.floor(1000000 / attrs.color_temp_kelvin + 0.5) + elseif attrs.color_temp and attrs.color_temp > 0 then + colorTemp = attrs.color_temp + end + + local currentColor = "" + if type(attrs.rgb_color) == "table" and #attrs.rgb_color == 3 then + currentColor = attrs.rgb_color[1] .. "," .. attrs.rgb_color[2] .. "," .. attrs.rgb_color[3] + end + + return { + entity_id = state.entity_id, + state = state.state, + friendly_name = attrs.friendly_name or state.entity_id, + domain = state.entity_id:match("^([^.]+)"), + unit = attrs.unit_of_measurement or "", + brightness = attrs.brightness or -1, + color_temp = colorTemp, + hue = (type(attrs.hs_color) == "table") and (attrs.hs_color[1] or 0) or 0, + current_color = currentColor, + supports_brightness = supportsColorMode(modes, {"brightness","color_temp","hs","xy","rgb","rgbw","rgbww"}), + supports_color_temp = supportsColorMode(modes, {"color_temp"}), + supports_rgb = supportsColorMode(modes, {"hs","xy","rgb","rgbw","rgbww"}), + } +end + +local function updateEntity(entityId, newState) + if #entities > 0 then + local found = false + for _, id in ipairs(entities) do + if id == entityId then + found = true + break + end + end + if not found then return end + end + + local processed = processEntity(newState) + if processed then + entityStates[entityId] = processed + noctalia.state.set("entities", entityStates) + + local count = 0 + for _ in pairs(entityStates) do + count = count + 1 + end + noctalia.state.set("entity_count", count) + end +end + +-- HA embeds event_type inside the data JSON rather than a separate SSE "event:" field +local currentEvent = { + data = "", + event = "", +} + +local function tryDispatch() + if not currentEvent.data then return end + local parsed = noctalia.json.decode(currentEvent.data) + if not parsed then return end + local evType = currentEvent.event or parsed.event_type + if evType ~= "state_changed" then return end + local inner = parsed.data or {} + if inner.entity_id and inner.new_state then + updateEntity(inner.entity_id, inner.new_state) + end +end + +local function processSSELine(line) + if line == "" then + tryDispatch() + currentEvent = {} + elseif line:match("^event:") then + if currentEvent.data then tryDispatch() end + currentEvent = { event = line:match("^event:%s*(.+)") or "" } + elseif line:match("^data:") then + local data = line:match("^data:%s*(.+)") or "" + local parsed = noctalia.json.decode(data) + if parsed then + local evType = currentEvent.event or parsed.event_type + if evType == "state_changed" then + local inner = parsed.data or {} + if inner.entity_id and inner.new_state then + updateEntity(inner.entity_id, inner.new_state) + currentEvent = {} + return + end + end + end + if currentEvent.data and currentEvent.data ~= "" then + currentEvent.data = currentEvent.data .. "\n" .. data + else + currentEvent.data = data + end + end +end + +local function fetchInitialStates() + if not isConfigured() or suspended or connectionRequestInFlight then return end + connectionRequestInFlight = true + local launched = haRequest("/api/states", "GET", nil, function(res) + connectionRequestInFlight = false + + if not res.body or res.body == "" then + noctalia.log("Empty response body from /api/states") + status = "disconnected" + noctalia.state.set("connection_status", status) + syncUpdateInterval() + return + end + + if not res.ok or res.status == 401 or res.body:match("^401") or res.body:match("Unauthorized") then + status = "auth_failed" + noctalia.log("Authentication failed - check your token") + noctalia.notifyError(noctalia.tr("notifications.auth_failed")) + noctalia.state.set("connection_status", status) + syncUpdateInterval() + return + end + + if not res.ok or (res.status and res.status >= 400) then + status = "disconnected" + noctalia.log("HTTP request failed: status " .. (res.status or "unknown")) + noctalia.state.set("connection_status", status) + syncUpdateInterval() + return + end + + local allStates, err = noctalia.json.decode(res.body) + if not allStates then + noctalia.log("Failed to parse states: " .. (err or "unknown error")) + status = "disconnected" + noctalia.state.set("connection_status", status) + syncUpdateInterval() + return + end + + status = "connected" + noctalia.state.set("connection_status", status) + retryCount = 0 + maxRetriesNotified = false + suspended = false + + entityStates = {} + local count = 0 + + for _, state in ipairs(allStates) do + if #entities == 0 then break end + for _, entityId in ipairs(entities) do + if state.entity_id == entityId then + entityStates[entityId] = processEntity(state) + count = count + 1 + break + end + end + end + + noctalia.state.set("entities", entityStates) + noctalia.state.set("entity_count", count) + + if status == "connected" then + if not startSSEStream() then + status = "disconnected" + noctalia.state.set("connection_status", status) + syncUpdateInterval() + else + syncUpdateInterval() + end + end + end) + + if not launched then + connectionRequestInFlight = false + status = "disconnected" + noctalia.state.set("connection_status", status) + syncUpdateInterval() + end +end + +function startSSEStream() + if suspended then return false end + if sseActive then return true end + + local cleanToken = getCleanToken() + if cleanToken == "" then + return false + end + + local url = haUrl .. "/api/stream?restrict=state_changed" + + noctalia.log("Starting SSE stream") + currentEvent = { data = "", event = "" } + + local handle + handle = noctalia.httpStream({ + url = url, + headers = { + "Accept: text/event-stream", + "Authorization: Bearer " .. cleanToken, + }, + }, function(line) + processSSELine(line) + end, function(result) + if streamHandle ~= handle then return end + + streamHandle = nil + sseActive = false + currentEvent = {} + + if suspended then return end + + if result and result.ok and result.status == 401 then + status = "auth_failed" + noctalia.log("Authentication failed - check your token") + noctalia.notifyError(noctalia.tr("notifications.auth_failed")) + noctalia.state.set("connection_status", status) + syncUpdateInterval() + return + end + + status = "disconnected" + noctalia.log("SSE stream closed") + noctalia.state.set("connection_status", status) + syncUpdateInterval() + end) + + if not handle then + noctalia.log("Failed to start SSE stream") + return false + end + + streamHandle = handle + sseActive = true + syncUpdateInterval() + return true +end + +noctalia.state.watch("command", function(cmd) + if cmd == "refresh" then + -- Just refetch a fresh snapshot over HTTP. Do NOT touch sseActive here: + -- the running stream (if any) is untouched by this, and forcing the + -- flag false would bypass startSSEStream()'s duplicate guard and spawn + -- a second stream on top of one that's still alive. + fetchInitialStates() + elseif cmd == "reload_entities" then + local override = noctalia.state.get("entities_override") + if type(override) == "table" then + entities = override + else + entities = loadManagedEntities() + end + -- Entity filtering happens client-side in updateEntity(), so the + -- existing stream keeps working for the new entity list as-is. + -- The initial snapshot needs refetching, though, to account for any + -- entities that were removed from the filter. See the note above for why + -- sseActive must not be forced here. + fetchInitialStates() + end +end) + +noctalia.state.watch("panel_open_signal", function(value) + if value then + retryCount = 0 + maxRetriesNotified = false + suspended = false + end +end) + +function update() + syncUpdateInterval() + + if not isConfigured() then + status = "unconfigured" + noctalia.state.set("connection_status", status) + sseActive = false + return + end + + if suspended then + return + end + + if status == "disconnected" then + retryCount = retryCount + 1 + if retryCount >= MAX_RECONNECT_RETRIES then + if not maxRetriesNotified then + maxRetriesNotified = true + noctalia.notifyError(noctalia.tr("notifications.reconnect_failed", { count = MAX_RECONNECT_RETRIES })) + end + suspended = true + status = "disconnected" + noctalia.state.set("connection_status", status) + return + end + + status = "connecting" + noctalia.state.set("connection_status", status) + fetchInitialStates() + end +end + +function onExit() + if streamHandle then + streamHandle.stop() + streamHandle = nil + end + sseActive = false +end + +noctalia.state.set("connection_status", status) +noctalia.state.set("entity_count", 0) +noctalia.state.set("entities", {}) + +if isConfigured() then + status = "connecting" + noctalia.state.set("connection_status", status) + fetchInitialStates() +end diff --git a/hassio/shortcut.luau b/hassio/shortcut.luau new file mode 100644 index 0000000..664bb48 --- /dev/null +++ b/hassio/shortcut.luau @@ -0,0 +1,144 @@ +--!nonstrict + +local haUrl = noctalia.getConfig("ha_url") +local haToken = noctalia.getConfig("ha_token") +local configuredEntityId = noctalia.getConfig("shortcut_entity_1") or "" +local SLOT_LABEL = noctalia.tr("shortcut.label_1") + +local entityStates = {} +local pendingToggle = {} +local pendingSince = {} +local pendingTimerToken = {} +local pendingTimerSeq = 0 + +local PENDING_TIMEOUT_SECONDS = 8 +local render + +local function getCleanToken() + if type(haToken) ~= "string" then return "" end + return haToken:gsub("^%s+", ""):gsub("%s+$", "") +end + +local function stateGlyph(domain, isOn) + if domain == "light" then return isOn and "bulb" or "bulb-off" + elseif domain == "switch" or domain == "input_boolean" then return isOn and "toggle-right" or "toggle-left" + elseif domain == "fan" then return isOn and "wind" or "wind-off" + elseif domain == "lock" then return isOn and "lock" or "lock-open" + elseif domain == "cover" then return isOn and "door-open" or "door" + end + return "smart-home" +end + +local function getEntity() + if configuredEntityId == "" then return nil end + return entityStates[configuredEntityId] +end + +local function clearPending(entityId) + pendingToggle[entityId] = nil + pendingSince[entityId] = nil + pendingTimerToken[entityId] = nil +end + +local function isPendingTimedOut(entityId, now) + local startedAt = pendingSince[entityId] + if not startedAt then return false end + return (now or os.clock()) - startedAt >= PENDING_TIMEOUT_SECONDS +end + +local function reconcilePending(now) + for entityId, expected in pairs(pendingToggle) do + local real = entityStates[entityId] + if not real or real.state == expected or isPendingTimedOut(entityId, now) then + clearPending(entityId) + end + end +end + +local function schedulePendingTimeout(entityId) + pendingTimerSeq = pendingTimerSeq + 1 + local token = pendingTimerSeq + pendingSince[entityId] = os.clock() + pendingTimerToken[entityId] = token + + noctalia.runAsync("sleep " .. tostring(PENDING_TIMEOUT_SECONDS), function(_result) + if pendingTimerToken[entityId] ~= token then return end + if pendingToggle[entityId] and isPendingTimedOut(entityId) then + clearPending(entityId) + render() + end + end) +end + +render = function() + reconcilePending(os.clock()) + + local entity = getEntity() + + if not entity then + shortcut.setLabel(configuredEntityId ~= "" and configuredEntityId or SLOT_LABEL) + shortcut.setIcon("smart-home") + shortcut.setActive(false) + shortcut.setEnabled(false) + return + end + + local eid = entity.entity_id + local pending = pendingToggle[eid] + + if pending then + shortcut.setLabel(entity.friendly_name or eid) + shortcut.setIcon("loader") + shortcut.setActive(pending == "on") + shortcut.setEnabled(false) + return + end + + local isOn = entity.state == "on" + shortcut.setLabel(entity.friendly_name or eid) + shortcut.setIcon(stateGlyph(entity.domain, true), stateGlyph(entity.domain, false)) + shortcut.setActive(isOn) + shortcut.setEnabled(true) +end + +noctalia.state.watch("entities", function(value) + entityStates = value or {} + reconcilePending(os.clock()) + render() +end) + +noctalia.state.watch("connection_status", function(_value) + render() +end) + +function onClick() + reconcilePending(os.clock()) + + local entity = getEntity() + local cleanToken = getCleanToken() + if not entity or not haUrl or haUrl == "" or cleanToken == "" then return end + + local eid = entity.entity_id + if pendingToggle[eid] then return end + + local newState = entity.state == "on" and "off" or "on" + pendingToggle[eid] = newState + schedulePendingTimeout(eid) + + local encoded = noctalia.json.encode({ entity_id = eid }) + + noctalia.http({ + url = haUrl .. "/api/services/" .. entity.domain .. "/toggle", + method = "POST", + headers = { + "Authorization: Bearer " .. cleanToken, + "Content-Type: application/json", + }, + body = encoded, + }, function(response)end) + + render() +end + +entityStates = noctalia.state.get("entities") or {} +render() diff --git a/hassio/shortcut_2.luau b/hassio/shortcut_2.luau new file mode 100644 index 0000000..3e27b2f --- /dev/null +++ b/hassio/shortcut_2.luau @@ -0,0 +1,144 @@ +--!nonstrict + +local haUrl = noctalia.getConfig("ha_url") +local haToken = noctalia.getConfig("ha_token") +local configuredEntityId = noctalia.getConfig("shortcut_entity_2") or "" +local SLOT_LABEL = noctalia.tr("shortcut.label_2") + +local entityStates = {} +local pendingToggle = {} +local pendingSince = {} +local pendingTimerToken = {} +local pendingTimerSeq = 0 + +local PENDING_TIMEOUT_SECONDS = 8 +local render + +local function getCleanToken() + if type(haToken) ~= "string" then return "" end + return haToken:gsub("^%s+", ""):gsub("%s+$", "") +end + +local function stateGlyph(domain, isOn) + if domain == "light" then return isOn and "bulb" or "bulb-off" + elseif domain == "switch" or domain == "input_boolean" then return isOn and "toggle-right" or "toggle-left" + elseif domain == "fan" then return isOn and "wind" or "wind-off" + elseif domain == "lock" then return isOn and "lock" or "lock-open" + elseif domain == "cover" then return isOn and "door-open" or "door" + end + return "smart-home" +end + +local function getEntity() + if configuredEntityId == "" then return nil end + return entityStates[configuredEntityId] +end + +local function clearPending(entityId) + pendingToggle[entityId] = nil + pendingSince[entityId] = nil + pendingTimerToken[entityId] = nil +end + +local function isPendingTimedOut(entityId, now) + local startedAt = pendingSince[entityId] + if not startedAt then return false end + return (now or os.clock()) - startedAt >= PENDING_TIMEOUT_SECONDS +end + +local function reconcilePending(now) + for entityId, expected in pairs(pendingToggle) do + local real = entityStates[entityId] + if not real or real.state == expected or isPendingTimedOut(entityId, now) then + clearPending(entityId) + end + end +end + +local function schedulePendingTimeout(entityId) + pendingTimerSeq = pendingTimerSeq + 1 + local token = pendingTimerSeq + pendingSince[entityId] = os.clock() + pendingTimerToken[entityId] = token + + noctalia.runAsync("sleep " .. tostring(PENDING_TIMEOUT_SECONDS), function(_result) + if pendingTimerToken[entityId] ~= token then return end + if pendingToggle[entityId] and isPendingTimedOut(entityId) then + clearPending(entityId) + render() + end + end) +end + +render = function() + reconcilePending(os.clock()) + + local entity = getEntity() + + if not entity then + shortcut.setLabel(configuredEntityId ~= "" and configuredEntityId or SLOT_LABEL) + shortcut.setIcon("smart-home") + shortcut.setActive(false) + shortcut.setEnabled(false) + return + end + + local eid = entity.entity_id + local pending = pendingToggle[eid] + + if pending then + shortcut.setLabel(entity.friendly_name or eid) + shortcut.setIcon("loader") + shortcut.setActive(pending == "on") + shortcut.setEnabled(false) + return + end + + local isOn = entity.state == "on" + shortcut.setLabel(entity.friendly_name or eid) + shortcut.setIcon(stateGlyph(entity.domain, true), stateGlyph(entity.domain, false)) + shortcut.setActive(isOn) + shortcut.setEnabled(true) +end + +noctalia.state.watch("entities", function(value) + entityStates = value or {} + reconcilePending(os.clock()) + render() +end) + +noctalia.state.watch("connection_status", function(_value) + render() +end) + +function onClick() + reconcilePending(os.clock()) + + local entity = getEntity() + local cleanToken = getCleanToken() + if not entity or not haUrl or haUrl == "" or cleanToken == "" then return end + + local eid = entity.entity_id + if pendingToggle[eid] then return end + + local newState = entity.state == "on" and "off" or "on" + pendingToggle[eid] = newState + schedulePendingTimeout(eid) + + local encoded = noctalia.json.encode({ entity_id = eid }) + + noctalia.http({ + url = haUrl .. "/api/services/" .. entity.domain .. "/toggle", + method = "POST", + headers = { + "Authorization: Bearer " .. cleanToken, + "Content-Type: application/json", + }, + body = encoded, + }, function(response)end) + + render() +end + +entityStates = noctalia.state.get("entities") or {} +render() diff --git a/hassio/shortcut_3.luau b/hassio/shortcut_3.luau new file mode 100644 index 0000000..cf25cfe --- /dev/null +++ b/hassio/shortcut_3.luau @@ -0,0 +1,144 @@ +--!nonstrict + +local haUrl = noctalia.getConfig("ha_url") +local haToken = noctalia.getConfig("ha_token") +local configuredEntityId = noctalia.getConfig("shortcut_entity_3") or "" +local SLOT_LABEL = noctalia.tr("shortcut.label_3") + +local entityStates = {} +local pendingToggle = {} +local pendingSince = {} +local pendingTimerToken = {} +local pendingTimerSeq = 0 + +local PENDING_TIMEOUT_SECONDS = 8 +local render + +local function getCleanToken() + if type(haToken) ~= "string" then return "" end + return haToken:gsub("^%s+", ""):gsub("%s+$", "") +end + +local function stateGlyph(domain, isOn) + if domain == "light" then return isOn and "bulb" or "bulb-off" + elseif domain == "switch" or domain == "input_boolean" then return isOn and "toggle-right" or "toggle-left" + elseif domain == "fan" then return isOn and "wind" or "wind-off" + elseif domain == "lock" then return isOn and "lock" or "lock-open" + elseif domain == "cover" then return isOn and "door-open" or "door" + end + return "smart-home" +end + +local function getEntity() + if configuredEntityId == "" then return nil end + return entityStates[configuredEntityId] +end + +local function clearPending(entityId) + pendingToggle[entityId] = nil + pendingSince[entityId] = nil + pendingTimerToken[entityId] = nil +end + +local function isPendingTimedOut(entityId, now) + local startedAt = pendingSince[entityId] + if not startedAt then return false end + return (now or os.clock()) - startedAt >= PENDING_TIMEOUT_SECONDS +end + +local function reconcilePending(now) + for entityId, expected in pairs(pendingToggle) do + local real = entityStates[entityId] + if not real or real.state == expected or isPendingTimedOut(entityId, now) then + clearPending(entityId) + end + end +end + +local function schedulePendingTimeout(entityId) + pendingTimerSeq = pendingTimerSeq + 1 + local token = pendingTimerSeq + pendingSince[entityId] = os.clock() + pendingTimerToken[entityId] = token + + noctalia.runAsync("sleep " .. tostring(PENDING_TIMEOUT_SECONDS), function(_result) + if pendingTimerToken[entityId] ~= token then return end + if pendingToggle[entityId] and isPendingTimedOut(entityId) then + clearPending(entityId) + render() + end + end) +end + +render = function() + reconcilePending(os.clock()) + + local entity = getEntity() + + if not entity then + shortcut.setLabel(configuredEntityId ~= "" and configuredEntityId or SLOT_LABEL) + shortcut.setIcon("smart-home") + shortcut.setActive(false) + shortcut.setEnabled(false) + return + end + + local eid = entity.entity_id + local pending = pendingToggle[eid] + + if pending then + shortcut.setLabel(entity.friendly_name or eid) + shortcut.setIcon("loader") + shortcut.setActive(pending == "on") + shortcut.setEnabled(false) + return + end + + local isOn = entity.state == "on" + shortcut.setLabel(entity.friendly_name or eid) + shortcut.setIcon(stateGlyph(entity.domain, true), stateGlyph(entity.domain, false)) + shortcut.setActive(isOn) + shortcut.setEnabled(true) +end + +noctalia.state.watch("entities", function(value) + entityStates = value or {} + reconcilePending(os.clock()) + render() +end) + +noctalia.state.watch("connection_status", function(_value) + render() +end) + +function onClick() + reconcilePending(os.clock()) + + local entity = getEntity() + local cleanToken = getCleanToken() + if not entity or not haUrl or haUrl == "" or cleanToken == "" then return end + + local eid = entity.entity_id + if pendingToggle[eid] then return end + + local newState = entity.state == "on" and "off" or "on" + pendingToggle[eid] = newState + schedulePendingTimeout(eid) + + local encoded = noctalia.json.encode({ entity_id = eid }) + + noctalia.http({ + url = haUrl .. "/api/services/" .. entity.domain .. "/toggle", + method = "POST", + headers = { + "Authorization: Bearer " .. cleanToken, + "Content-Type: application/json", + }, + body = encoded, + }, function(response)end) + + render() +end + +entityStates = noctalia.state.get("entities") or {} +render() diff --git a/hassio/shortcut_4.luau b/hassio/shortcut_4.luau new file mode 100644 index 0000000..369bed1 --- /dev/null +++ b/hassio/shortcut_4.luau @@ -0,0 +1,144 @@ +--!nonstrict + +local haUrl = noctalia.getConfig("ha_url") +local haToken = noctalia.getConfig("ha_token") +local configuredEntityId = noctalia.getConfig("shortcut_entity_4") or "" +local SLOT_LABEL = noctalia.tr("shortcut.label_4") + +local entityStates = {} +local pendingToggle = {} +local pendingSince = {} +local pendingTimerToken = {} +local pendingTimerSeq = 0 + +local PENDING_TIMEOUT_SECONDS = 8 +local render + +local function getCleanToken() + if type(haToken) ~= "string" then return "" end + return haToken:gsub("^%s+", ""):gsub("%s+$", "") +end + +local function stateGlyph(domain, isOn) + if domain == "light" then return isOn and "bulb" or "bulb-off" + elseif domain == "switch" or domain == "input_boolean" then return isOn and "toggle-right" or "toggle-left" + elseif domain == "fan" then return isOn and "wind" or "wind-off" + elseif domain == "lock" then return isOn and "lock" or "lock-open" + elseif domain == "cover" then return isOn and "door-open" or "door" + end + return "smart-home" +end + +local function getEntity() + if configuredEntityId == "" then return nil end + return entityStates[configuredEntityId] +end + +local function clearPending(entityId) + pendingToggle[entityId] = nil + pendingSince[entityId] = nil + pendingTimerToken[entityId] = nil +end + +local function isPendingTimedOut(entityId, now) + local startedAt = pendingSince[entityId] + if not startedAt then return false end + return (now or os.clock()) - startedAt >= PENDING_TIMEOUT_SECONDS +end + +local function reconcilePending(now) + for entityId, expected in pairs(pendingToggle) do + local real = entityStates[entityId] + if not real or real.state == expected or isPendingTimedOut(entityId, now) then + clearPending(entityId) + end + end +end + +local function schedulePendingTimeout(entityId) + pendingTimerSeq = pendingTimerSeq + 1 + local token = pendingTimerSeq + pendingSince[entityId] = os.clock() + pendingTimerToken[entityId] = token + + noctalia.runAsync("sleep " .. tostring(PENDING_TIMEOUT_SECONDS), function(_result) + if pendingTimerToken[entityId] ~= token then return end + if pendingToggle[entityId] and isPendingTimedOut(entityId) then + clearPending(entityId) + render() + end + end) +end + +render = function() + reconcilePending(os.clock()) + + local entity = getEntity() + + if not entity then + shortcut.setLabel(configuredEntityId ~= "" and configuredEntityId or SLOT_LABEL) + shortcut.setIcon("smart-home") + shortcut.setActive(false) + shortcut.setEnabled(false) + return + end + + local eid = entity.entity_id + local pending = pendingToggle[eid] + + if pending then + shortcut.setLabel(entity.friendly_name or eid) + shortcut.setIcon("loader") + shortcut.setActive(pending == "on") + shortcut.setEnabled(false) + return + end + + local isOn = entity.state == "on" + shortcut.setLabel(entity.friendly_name or eid) + shortcut.setIcon(stateGlyph(entity.domain, true), stateGlyph(entity.domain, false)) + shortcut.setActive(isOn) + shortcut.setEnabled(true) +end + +noctalia.state.watch("entities", function(value) + entityStates = value or {} + reconcilePending(os.clock()) + render() +end) + +noctalia.state.watch("connection_status", function(_value) + render() +end) + +function onClick() + reconcilePending(os.clock()) + + local entity = getEntity() + local cleanToken = getCleanToken() + if not entity or not haUrl or haUrl == "" or cleanToken == "" then return end + + local eid = entity.entity_id + if pendingToggle[eid] then return end + + local newState = entity.state == "on" and "off" or "on" + pendingToggle[eid] = newState + schedulePendingTimeout(eid) + + local encoded = noctalia.json.encode({ entity_id = eid }) + + noctalia.http({ + url = haUrl .. "/api/services/" .. entity.domain .. "/toggle", + method = "POST", + headers = { + "Authorization: Bearer " .. cleanToken, + "Content-Type: application/json", + }, + body = encoded, + }, function(response)end) + + render() +end + +entityStates = noctalia.state.get("entities") or {} +render() diff --git a/hassio/shortcut_panel.luau b/hassio/shortcut_panel.luau new file mode 100644 index 0000000..3b0e832 --- /dev/null +++ b/hassio/shortcut_panel.luau @@ -0,0 +1,10 @@ +--!nonstrict + +shortcut.setLabel(noctalia.tr("shortcut.panel_label")) +shortcut.setIcon("layout-list") +shortcut.setActive(false) +shortcut.setEnabled(true) + +function onClick() + noctalia.togglePanel("pozzoo/hassio:entity_manager") +end diff --git a/hassio/thumbnail.webp b/hassio/thumbnail.webp new file mode 100644 index 0000000000000000000000000000000000000000..af01f61374330db347408d6605c99918f8fde22e GIT binary patch literal 48548 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 MU3)pVsyQ$K0J{R*Q2+n{ literal 0 HcmV?d00001 diff --git a/hassio/translations/en.json b/hassio/translations/en.json new file mode 100644 index 0000000..9715269 --- /dev/null +++ b/hassio/translations/en.json @@ -0,0 +1,63 @@ +{ + "widget": { + "status_connected": "Connected", + "status_disconnected": "Disconnected", + "status_connecting": "Connecting", + "status_auth_failed": "Auth Failed", + "status_unconfigured": "Not Configured", + "tooltip": "Home Assistant: {status}", + "tooltip_entities": "Home Assistant: {status} ({count} entities)", + "refreshing_connection": "Refreshing connection…" + }, + "shortcut": { + "title": "Home Assistant", + "panel_label": "Entity Manager", + "label_1": "Quick Toggle 1", + "label_2": "Quick Toggle 2", + "label_3": "Quick Toggle 3", + "label_4": "Quick Toggle 4" + }, + "panel": { + "manage_entities_title": "Manage Entities", + "search_placeholder": "Search entities…", + "not_configured": "Not configured. Set your Home Assistant URL and token in settings.", + "auth_failed": "Authentication failed. Check your access token.", + "connecting": "Connecting…", + "disconnected_reconnecting": "Disconnected. Reconnecting…", + "no_entities_monitored": "No entities monitored yet. Use the browser to add some.", + "status_fallback": "Status: {status}", + "loading_entities": "Loading entities…", + "no_entities_found": "No entities found", + "no_entities_match": "No entities match your search", + "state_on_brightness": "On · {percent}%", + "brightness": "Brightness", + "color_temp": "Color Temperature", + "hue": "Hue", + "save_failed": "Failed to save monitored entities", + "save_failed_no_data_dir": "Could not save: plugin data directory unavailable" + }, + "settings": { + "url": { + "label": "Home Assistant URL", + "description": "The URL to your Home Assistant instance (e.g., http://homeassistant.local:8123)" + }, + "token": { + "label": "Long-Lived Access Token", + "description": "Create a token in Home Assistant under Profile → Security → Long-lived access tokens" + }, + "shortcut_entity": { + "description": "Home Assistant entity ID to show and toggle on this tile (e.g. light.living_room)." + }, + "shortcut_entity_1": { "label": "Quick Toggle 1 - Entity ID" }, + "shortcut_entity_2": { "label": "Quick Toggle 2 - Entity ID" }, + "shortcut_entity_3": { "label": "Quick Toggle 3 - Entity ID" }, + "shortcut_entity_4": { "label": "Quick Toggle 4 - Entity ID" }, + "show_entity_count": { + "label": "Show entity count in widget" + } + }, + "notifications": { + "auth_failed": "Authentication failed. Check your access token.", + "reconnect_failed": "Unable to reconnect to Home Assistant after {count} attempts." + } +} diff --git a/hassio/translations/pt-BR.json b/hassio/translations/pt-BR.json new file mode 100644 index 0000000..311592e --- /dev/null +++ b/hassio/translations/pt-BR.json @@ -0,0 +1,63 @@ +{ + "widget": { + "status_connected": "Conectado", + "status_disconnected": "Desconectado", + "status_connecting": "Conectando", + "status_auth_failed": "Falha de Autenticação", + "status_unconfigured": "Não Configurado", + "tooltip": "Home Assistant: {status}", + "tooltip_entities": "Home Assistant: {status} ({count} entidades)", + "refreshing_connection": "Atualizando conexão…" + }, + "shortcut": { + "title": "Home Assistant", + "panel_label": "Gerenciador de Entidades", + "label_1": "Botão Rápido 1", + "label_2": "Botão Rápido 2", + "label_3": "Botão Rápido 3", + "label_4": "Botão Rápido 4" + }, + "panel": { + "manage_entities_title": "Gerenciar Entidades", + "search_placeholder": "Buscar entidades…", + "not_configured": "Não configurado. Defina a URL e o token do Home Assistant nas configurações.", + "auth_failed": "Falha na autenticação. Verifique seu token de acesso.", + "connecting": "Conectando…", + "disconnected_reconnecting": "Desconectado. Reconectando…", + "no_entities_monitored": "Nenhuma entidade monitorada ainda. Use o navegador para adicionar.", + "status_fallback": "Status: {status}", + "loading_entities": "Carregando entidades…", + "no_entities_found": "Nenhuma entidade encontrada", + "no_entities_match": "Nenhuma entidade corresponde à sua busca", + "state_on_brightness": "Ligado · {percent}%", + "brightness": "Brilho", + "color_temp": "Temperatura de Cor", + "hue": "Matiz", + "save_failed": "Falha ao salvar as entidades monitoradas", + "save_failed_no_data_dir": "Não foi possível salvar: diretório de dados do plugin indisponível" + }, + "settings": { + "url": { + "label": "URL do Home Assistant", + "description": "A URL para sua instância do Home Assistant (e.g., http://homeassistant.local:8123)" + }, + "token": { + "label": "Token de Acesso Longo", + "description": "Crie um token no Home Assistant em Profile → Security → Long-lived access tokens" + }, + "shortcut_entity": { + "description": "ID da entidade do Home Assistant para mostrar e alternar neste espaço (e.g. light.living_room)." + }, + "shortcut_entity_1": { "label": "Botão Rápido 1 - ID da Entidade" }, + "shortcut_entity_2": { "label": "Botão Rápido 2 - ID da Entidade" }, + "shortcut_entity_3": { "label": "Botão Rápido 3 - ID da Entidade" }, + "shortcut_entity_4": { "label": "Botão Rápido 4 - ID da Entidade" }, + "show_entity_count": { + "label": "Mostrar o Número de Entidades" + } + }, + "notifications": { + "auth_failed": "Falha na autenticação. Verifique seu token de acesso.", + "reconnect_failed": "Não foi possível reconectar ao Home Assistant após {count} tentativas." + } +} diff --git a/hassio/widget.luau b/hassio/widget.luau new file mode 100644 index 0000000..07e6d60 --- /dev/null +++ b/hassio/widget.luau @@ -0,0 +1,96 @@ +--!nonstrict + +local haUrl = noctalia.getConfig("ha_url") +local showEntityCount = noctalia.getConfig("show_entity_count") + +local status = "unconfigured" +local entityCount = 0 + +local render + +local function tr(key, subst) + return noctalia.tr("widget." .. key, subst) +end + +noctalia.state.watch("connection_status", function(value) + status = value or "unconfigured" + if render then render() end +end) + +noctalia.state.watch("entity_count", function(value) + entityCount = value or 0 + if render then render() end +end) + +local function getStatusText() + if status == "connected" then + return tr("status_connected") + elseif status == "connecting" then + return tr("status_connecting") + elseif status == "disconnected" then + return tr("status_disconnected") + elseif status == "auth_failed" then + return tr("status_auth_failed") + else + return tr("status_unconfigured") + end +end + +local function getStatusColor() + if status == "connected" then + return "primary" + elseif status == "connecting" then + return "on_error" + elseif status == "disconnected" or status == "auth_failed" then + return "error" + else + return "on_surface_variant" + end +end + +render = function() + barWidget.setGlyph("smart-home") + barWidget.setGlyphColor(getStatusColor()) + + if showEntityCount and entityCount > 0 then + barWidget.setText(tostring(entityCount)) + barWidget.setTooltip(tr("tooltip_entities", { + status = getStatusText(), + count = entityCount + })) + else + barWidget.setTooltip(tr("tooltip", { + status = getStatusText() + })) + end +end + +function update() + noctalia.setUpdateInterval(5000) + + if status == "unconfigured" then + local serviceStatus = noctalia.state.get("connection_status") + if serviceStatus then + status = serviceStatus + end + end + + render() +end + +function onClick() + noctalia.togglePanel("pozzoo/hassio:entity_manager") +end + +function onRightClick() + if haUrl and haUrl ~= "" then + noctalia.runAsync("xdg-open " .. ("'" .. haUrl:gsub("'", "'\\''") .. "'")) + end +end + +function onIpc(event, payload) + if event == "refresh" then + noctalia.state.set("command", "refresh") + noctalia.notify(noctalia.tr("shortcut.title"), tr("refreshing_connection")) + end +end