From f1fcd6321446c4b3bdacda68a97c44a69420874c Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Mon, 29 Jun 2026 22:40:25 +0200 Subject: [PATCH] feat(smarthome): live state values for devices and sensors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - V65 migration: smarthome_resources.state_json (cached normalized live state). - Sync/poll caches state per resource: light/plug {on,bri}, group {on:any_on}, sensor {type,value} (temp °C, humidity %, lightlevel lux, presence/open/water bool). - upsertResource persists state_json (omit = keep existing); rowToResource exposes state. - UI reflects real state: power switch + brightness slider preset from state; sensor cards show formatted value (incl. open/closed, motion, water labels). - Visibility/focus-aware ~30s auto-poll mirrors the server poll. - i18n: 6 value labels en+de + GC.t bridge (3 layouts), parity verified. --- public/js/smarthome.js | 37 +++++++++++++++++++--- src/db/migrationList.js | 6 ++++ src/i18n/de.json | 6 ++++ src/i18n/en.json | 6 ++++ src/services/smarthome/index.js | 21 +++++++++--- src/services/smarthome/smarthomeDevices.js | 27 +++++++++++----- templates/aurora/layout.njk | 6 ++++ templates/default/layout.njk | 6 ++++ templates/pro/layout.njk | 6 ++++ tests/smarthome_orchestrator.test.js | 22 +++++++++++++ 10 files changed, 127 insertions(+), 16 deletions(-) diff --git a/public/js/smarthome.js b/public/js/smarthome.js index c91729a1..91e6b930 100644 --- a/public/js/smarthome.js +++ b/public/js/smarthome.js @@ -69,7 +69,7 @@ const body = document.createElement('div'); body.className = 'sh-body'; // Power const pwr = document.createElement('div'); pwr.className = 'sh-pwr'; - pwr.innerHTML = `${T('smarthome.power')}
`; + pwr.innerHTML = `${T('smarthome.power')}
`; pwr.querySelector('.sh-switch').addEventListener('click', (e) => { const on = !e.currentTarget.classList.contains('on'); e.currentTarget.classList.toggle('on', on); @@ -79,7 +79,7 @@ // Brightness if (caps.bri) { const wrap = document.createElement('div'); - wrap.innerHTML = `
${T('smarthome.brightness')}
`; + wrap.innerHTML = `
${T('smarthome.brightness')}
`; wrap.querySelector('input').addEventListener('change', (e) => send(r.id, { bri: Number(e.target.value) }).catch(() => {})); body.appendChild(wrap); } @@ -113,9 +113,23 @@ return el; } + function formatValue(r) { + const s = r.state || {}; const v = s.value; + switch (s.type) { + case 'temperature': return v == null ? '—' : `${v} °C`; + case 'humidity': return v == null ? '—' : `${v} %`; + case 'lightlevel': return v == null ? '—' : `${v} lx`; + case 'presence': return T(v ? 'smarthome.val.motion' : 'smarthome.val.idle'); + case 'open': return T(v ? 'smarthome.val.open' : 'smarthome.val.closed'); + case 'water': return T(v ? 'smarthome.val.wet' : 'smarthome.val.dry'); + default: return v == null ? '—' : String(v); + } + } + function renderSensor(r) { const el = cardShell(r); - const v = document.createElement('div'); v.className = 'sh-sensorval'; v.id = `sv-${r.id}`; v.textContent = '—'; + const v = document.createElement('div'); v.className = 'sh-sensorval'; v.id = `sv-${r.id}`; + v.textContent = formatValue(r); el.appendChild(v); return el; } @@ -177,6 +191,21 @@ await loadResources(first ? first.id : undefined); } + // Live-value refresh: server polls deCONZ ~30s; mirror that here. Pause when the + // tab is hidden or a control is focused (don't yank an active slider/select). + let autoTimer = null; + function startAutoPoll() { + if (autoTimer) return; + autoTimer = setInterval(() => { + if (document.hidden) return; + const ae = document.activeElement; + if (ae && /^(INPUT|SELECT|TEXTAREA)$/.test(ae.tagName)) return; + if ($('#sh-connect-modal') && $('#sh-connect-modal').style.display === 'flex') return; + const sel = $('#sh-gateway-select'); + loadResources(sel && sel.value ? Number(sel.value) : undefined); + }, 30000); + } + async function fillRoutes() { const sel = $('#sh-c-route'); if (!sel) return; try { @@ -237,6 +266,6 @@ }); } - document.addEventListener('DOMContentLoaded', () => { wireModal(); fillRoutes(); wireConnect(); wireTest(); loadGateways(); }); + document.addEventListener('DOMContentLoaded', () => { wireModal(); fillRoutes(); wireConnect(); wireTest(); loadGateways(); startAutoPoll(); }); window.SmartHome = { loadGateways, loadResources, api }; })(); diff --git a/src/db/migrationList.js b/src/db/migrationList.js index a5737243..ff535213 100644 --- a/src/db/migrationList.js +++ b/src/db/migrationList.js @@ -1170,6 +1170,12 @@ const migrations = [ AND id NOT IN (SELECT gateway_l4_route_id FROM rdp_routes WHERE gateway_l4_route_id IS NOT NULL) AND (SELECT COUNT(*) FROM service_bundles sb2 WHERE sb2.domain = routes.domain) = 1;`, }, + { + version: 65, + name: 'smarthome_resources_state', + sql: `ALTER TABLE smarthome_resources ADD COLUMN state_json TEXT;`, + detect: (db) => hasColumn(db, 'smarthome_resources', 'state_json'), + }, ]; module.exports = { migrations }; diff --git a/src/i18n/de.json b/src/i18n/de.json index 34a394a4..0b2e22a4 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -2121,6 +2121,12 @@ "smarthome.sensor.lightlevel": "Helligkeit", "smarthome.sensor.button": "Taster", "smarthome.sensor.unknown": "Sensor", + "smarthome.val.motion": "Bewegung", + "smarthome.val.idle": "keine Bewegung", + "smarthome.val.open": "offen", + "smarthome.val.closed": "geschlossen", + "smarthome.val.wet": "Wasser erkannt!", + "smarthome.val.dry": "trocken", "smarthome.connect.title": "Phoscon-Gateway verbinden", "smarthome.connect.name": "Name", "smarthome.connect.route": "Interne Route", diff --git a/src/i18n/en.json b/src/i18n/en.json index a83f7f83..9163dc0b 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -2177,6 +2177,12 @@ "smarthome.sensor.lightlevel": "Light level", "smarthome.sensor.button": "Button", "smarthome.sensor.unknown": "Sensor", + "smarthome.val.motion": "Motion", + "smarthome.val.idle": "No motion", + "smarthome.val.open": "Open", + "smarthome.val.closed": "Closed", + "smarthome.val.wet": "Water detected!", + "smarthome.val.dry": "Dry", "smarthome.connect.title": "Connect Phoscon Gateway", "smarthome.connect.name": "Name", "smarthome.connect.route": "Internal route", diff --git a/src/services/smarthome/index.js b/src/services/smarthome/index.js index e0aeebb7..1ca8dd1e 100644 --- a/src/services/smarthome/index.js +++ b/src/services/smarthome/index.js @@ -1,7 +1,7 @@ 'use strict'; const dev = require('./smarthomeDevices'); -const { createClient } = require('./deconzClient'); +const { createClient, briFromDeconz } = require('./deconzClient'); const license = require('../license'); const FEATURE = 'smarthome'; @@ -37,6 +37,18 @@ function sensorReading(sensor) { return { type: 'unknown', value: null }; } +// Normalized live state cached on each resource (bri 0-100, internal repr). +function lightStateOf(light) { + const s = light.state || {}; + const st = { on: !!s.on, reachable: s.reachable !== false }; + if ('bri' in s) st.bri = briFromDeconz(s.bri); + return st; +} +function groupStateOf(group) { + const s = group.state || {}; + return { on: !!s.any_on }; +} + // deCONZ /lights also carries plugs + the virtual "Configuration tool". // null = skip (not a real device). function lightKind(light) { @@ -75,7 +87,7 @@ async function syncGateway(gatewayId) { for (const [id, l] of Object.entries(lights)) { const kind = lightKind(l); if (!kind) continue; // skip Configuration tool (virtual) - dev.upsertResource({ gateway_id: gw.id, deconz_id: id, deconz_type: 'lights', uniqueid: l.uniqueid || null, kind, name: l.name, capabilities: capsFromLight(l) }); + dev.upsertResource({ gateway_id: gw.id, deconz_id: id, deconz_type: 'lights', uniqueid: l.uniqueid || null, kind, name: l.name, capabilities: capsFromLight(l), state: lightStateOf(l) }); seen.push(`lights:${id}`); counts[kind === 'plug' ? 'plugs' : 'lights']++; } } catch (_) { /* best-effort */ } @@ -83,7 +95,7 @@ async function syncGateway(gatewayId) { try { const groups = await client.getGroups(); for (const [id, g] of Object.entries(groups)) { - dev.upsertResource({ gateway_id: gw.id, deconz_id: id, deconz_type: 'groups', kind: 'group', name: g.name, capabilities: { on: true, bri: true, color: 'hs' } }); + dev.upsertResource({ gateway_id: gw.id, deconz_id: id, deconz_type: 'groups', kind: 'group', name: g.name, capabilities: { on: true, bri: true, color: 'hs' }, state: groupStateOf(g) }); seen.push(`groups:${id}`); counts.groups++; for (const sc of (g.scenes || [])) { const sceneKey = `${id}/${sc.id}`; @@ -98,7 +110,8 @@ async function syncGateway(gatewayId) { for (const [id, s] of Object.entries(sensors)) { const kind = sensorKind(s); if (!kind) continue; // skip CLIP*/Daylight (virtual) - dev.upsertResource({ gateway_id: gw.id, deconz_id: id, deconz_type: 'sensors', uniqueid: s.uniqueid || null, kind, name: s.name, capabilities: { reading: sensorReading(s).type } }); + const rd = sensorReading(s); + dev.upsertResource({ gateway_id: gw.id, deconz_id: id, deconz_type: 'sensors', uniqueid: s.uniqueid || null, kind, name: s.name, capabilities: { reading: rd.type }, state: rd }); seen.push(`sensors:${id}`); counts[kind === 'switch' ? 'switches' : 'sensors']++; } } catch (_) { /* best-effort */ } diff --git a/src/services/smarthome/smarthomeDevices.js b/src/services/smarthome/smarthomeDevices.js index 0d91ed1a..ae710310 100644 --- a/src/services/smarthome/smarthomeDevices.js +++ b/src/services/smarthome/smarthomeDevices.js @@ -73,8 +73,12 @@ function resolveTransport(routeId) { function rowToResource(row) { if (!row) return null; - const { capabilities_json, ...rest } = row; - return { ...rest, capabilities: capabilities_json ? JSON.parse(capabilities_json) : {} }; + const { capabilities_json, state_json, ...rest } = row; + return { + ...rest, + capabilities: capabilities_json ? JSON.parse(capabilities_json) : {}, + state: state_json ? JSON.parse(state_json) : {}, + }; } function listResources(gatewayId) { @@ -89,7 +93,7 @@ function getResource(id) { return rowToResource(getDb().prepare('SELECT * FROM smarthome_resources WHERE id=?').get(id)); } -function upsertResource({ gateway_id, deconz_id, deconz_type, uniqueid = null, kind, name, capabilities }) { +function upsertResource({ gateway_id, deconz_id, deconz_type, uniqueid = null, kind, name, capabilities, state }) { const db = getDb(); // Lights/sensors match by stable uniqueid (survives Conbee ID reassignment). // Groups/scenes match by deconz_id (no stable MAC-like identifier). @@ -102,15 +106,22 @@ function upsertResource({ gateway_id, deconz_id, deconz_type, uniqueid = null, k .get(gateway_id, deconz_type, String(deconz_id)); } const caps = JSON.stringify(capabilities || {}); + // state omitted (undefined) → keep existing cached state; explicit value → overwrite. + const stateJson = state === undefined ? undefined : JSON.stringify(state || {}); if (existing) { - db.prepare(`UPDATE smarthome_resources SET deconz_id=?, uniqueid=?, kind=?, name=?, capabilities_json=?, enabled=1, updated_at=datetime('now') WHERE id=?`) - .run(String(deconz_id), uniqueid, kind, name, caps, existing.id); + if (stateJson === undefined) { + db.prepare(`UPDATE smarthome_resources SET deconz_id=?, uniqueid=?, kind=?, name=?, capabilities_json=?, enabled=1, updated_at=datetime('now') WHERE id=?`) + .run(String(deconz_id), uniqueid, kind, name, caps, existing.id); + } else { + db.prepare(`UPDATE smarthome_resources SET deconz_id=?, uniqueid=?, kind=?, name=?, capabilities_json=?, state_json=?, enabled=1, updated_at=datetime('now') WHERE id=?`) + .run(String(deconz_id), uniqueid, kind, name, caps, stateJson, existing.id); + } return existing.id; } const info = db.prepare(` - INSERT INTO smarthome_resources (gateway_id, deconz_id, deconz_type, uniqueid, kind, name, capabilities_json) - VALUES (?, ?, ?, ?, ?, ?, ?) - `).run(gateway_id, String(deconz_id), deconz_type, uniqueid, kind, name, caps); + INSERT INTO smarthome_resources (gateway_id, deconz_id, deconz_type, uniqueid, kind, name, capabilities_json, state_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).run(gateway_id, String(deconz_id), deconz_type, uniqueid, kind, name, caps, stateJson === undefined ? null : stateJson); return Number(info.lastInsertRowid); } diff --git a/templates/aurora/layout.njk b/templates/aurora/layout.njk index ee14f098..1c57d532 100644 --- a/templates/aurora/layout.njk +++ b/templates/aurora/layout.njk @@ -82,6 +82,12 @@ 'smarthome.sensor.lightlevel': {{ t('smarthome.sensor.lightlevel') | dump | safe }}, 'smarthome.sensor.button': {{ t('smarthome.sensor.button') | dump | safe }}, 'smarthome.sensor.unknown': {{ t('smarthome.sensor.unknown') | dump | safe }}, + 'smarthome.val.motion': {{ t('smarthome.val.motion') | dump | safe }}, + 'smarthome.val.idle': {{ t('smarthome.val.idle') | dump | safe }}, + 'smarthome.val.open': {{ t('smarthome.val.open') | dump | safe }}, + 'smarthome.val.closed': {{ t('smarthome.val.closed') | dump | safe }}, + 'smarthome.val.wet': {{ t('smarthome.val.wet') | dump | safe }}, + 'smarthome.val.dry': {{ t('smarthome.val.dry') | dump | safe }}, 'peers.no_peers': {{ t('peers.no_peers') | dump | safe }}, 'peers.online': {{ t('peers.online') | dump | safe }}, 'peers.offline': {{ t('peers.offline') | dump | safe }}, diff --git a/templates/default/layout.njk b/templates/default/layout.njk index 85043abc..f9bf7eef 100644 --- a/templates/default/layout.njk +++ b/templates/default/layout.njk @@ -81,6 +81,12 @@ 'smarthome.sensor.lightlevel': {{ t('smarthome.sensor.lightlevel') | dump | safe }}, 'smarthome.sensor.button': {{ t('smarthome.sensor.button') | dump | safe }}, 'smarthome.sensor.unknown': {{ t('smarthome.sensor.unknown') | dump | safe }}, + 'smarthome.val.motion': {{ t('smarthome.val.motion') | dump | safe }}, + 'smarthome.val.idle': {{ t('smarthome.val.idle') | dump | safe }}, + 'smarthome.val.open': {{ t('smarthome.val.open') | dump | safe }}, + 'smarthome.val.closed': {{ t('smarthome.val.closed') | dump | safe }}, + 'smarthome.val.wet': {{ t('smarthome.val.wet') | dump | safe }}, + 'smarthome.val.dry': {{ t('smarthome.val.dry') | dump | safe }}, 'peers.no_peers': {{ t('peers.no_peers') | dump | safe }}, 'peers.online': {{ t('peers.online') | dump | safe }}, 'peers.offline': {{ t('peers.offline') | dump | safe }}, diff --git a/templates/pro/layout.njk b/templates/pro/layout.njk index 4f067fce..a2ec6730 100644 --- a/templates/pro/layout.njk +++ b/templates/pro/layout.njk @@ -83,6 +83,12 @@ 'smarthome.sensor.lightlevel': {{ t('smarthome.sensor.lightlevel') | dump | safe }}, 'smarthome.sensor.button': {{ t('smarthome.sensor.button') | dump | safe }}, 'smarthome.sensor.unknown': {{ t('smarthome.sensor.unknown') | dump | safe }}, + 'smarthome.val.motion': {{ t('smarthome.val.motion') | dump | safe }}, + 'smarthome.val.idle': {{ t('smarthome.val.idle') | dump | safe }}, + 'smarthome.val.open': {{ t('smarthome.val.open') | dump | safe }}, + 'smarthome.val.closed': {{ t('smarthome.val.closed') | dump | safe }}, + 'smarthome.val.wet': {{ t('smarthome.val.wet') | dump | safe }}, + 'smarthome.val.dry': {{ t('smarthome.val.dry') | dump | safe }}, 'peers.no_peers': {{ t('peers.no_peers') | dump | safe }}, 'peers.online': {{ t('peers.online') | dump | safe }}, 'peers.offline': {{ t('peers.offline') | dump | safe }}, diff --git a/tests/smarthome_orchestrator.test.js b/tests/smarthome_orchestrator.test.js index 56807190..f8ec6830 100644 --- a/tests/smarthome_orchestrator.test.js +++ b/tests/smarthome_orchestrator.test.js @@ -86,6 +86,28 @@ test('syncGateway classifies plugs/switches and skips virtuals', async () => { } }); +test('syncGateway caches normalized live state', async () => { + const gw = dev.createGateway({ name: 'GW', route_id: null, apiKey: 'KEY', enabled: true }); + const origResolve = dev.resolveTransport; + dev.resolveTransport = () => ({ baseUrl: 'http://gw', domain: 'gw' }); + global.fetch = async (url) => { + if (url.endsWith('/lights')) return jsonRes({ '5': { name: 'Lamp', type: 'Dimmable light', uniqueid: 'l', state: { on: true, bri: 254, reachable: true } } }); + if (url.endsWith('/groups')) return jsonRes({}); + if (url.endsWith('/sensors')) return jsonRes({ '10': { name: 'Temp', type: 'ZHATemperature', uniqueid: 't', state: { temperature: 2150 } } }); + return jsonRes({}); + }; + try { + await sh.syncGateway(gw.id); + const res = dev.listResources(gw.id); + const lamp = res.find((r) => r.kind === 'light'); + assert.equal(lamp.state.on, true); + assert.equal(lamp.state.bri, 100); + const temp = res.find((r) => r.kind === 'sensor'); + assert.equal(temp.state.type, 'temperature'); + assert.equal(temp.state.value, 21.5); + } finally { dev.resolveTransport = origResolve; } +}); + test('syncGateway upserts lights and sensors (best-effort)', async () => { const gw = dev.createGateway({ name: 'GW', route_id: null, apiKey: 'KEY', enabled: true }); const origResolve = dev.resolveTransport;