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.
This commit is contained in:
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user