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;