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 0000000..af01f61 Binary files /dev/null and b/hassio/thumbnail.webp differ 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