Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 33 additions & 4 deletions public/js/smarthome.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<span>${T('smarthome.power')}</span><div class="sh-switch"><i></i></div>`;
pwr.innerHTML = `<span>${T('smarthome.power')}</span><div class="sh-switch${(r.state && r.state.on) ? ' on' : ''}"><i></i></div>`;
pwr.querySelector('.sh-switch').addEventListener('click', (e) => {
const on = !e.currentTarget.classList.contains('on');
e.currentTarget.classList.toggle('on', on);
Expand All @@ -79,7 +79,7 @@
// Brightness
if (caps.bri) {
const wrap = document.createElement('div');
wrap.innerHTML = `<div class="sh-ctl-lbl">${T('smarthome.brightness')}</div><input class="sh-bri" type="range" min="0" max="100" value="0">`;
wrap.innerHTML = `<div class="sh-ctl-lbl">${T('smarthome.brightness')}</div><input class="sh-bri" type="range" min="0" max="100" value="${(r.state && r.state.bri != null) ? Number(r.state.bri) : 0}">`;
wrap.querySelector('input').addEventListener('change', (e) => send(r.id, { bri: Number(e.target.value) }).catch(() => {}));
body.appendChild(wrap);
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 };
})();
6 changes: 6 additions & 0 deletions src/db/migrationList.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
6 changes: 6 additions & 0 deletions src/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
21 changes: 17 additions & 4 deletions src/services/smarthome/index.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -75,15 +87,15 @@ 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 */ }

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}`;
Expand All @@ -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 */ }
Expand Down
27 changes: 19 additions & 8 deletions src/services/smarthome/smarthomeDevices.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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).
Expand All @@ -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);
}

Expand Down
6 changes: 6 additions & 0 deletions templates/aurora/layout.njk
Original file line number Diff line number Diff line change
Expand Up @@ -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 }},
Expand Down
6 changes: 6 additions & 0 deletions templates/default/layout.njk
Original file line number Diff line number Diff line change
Expand Up @@ -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 }},
Expand Down
6 changes: 6 additions & 0 deletions templates/pro/layout.njk
Original file line number Diff line number Diff line change
Expand Up @@ -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 }},
Expand Down
22 changes: 22 additions & 0 deletions tests/smarthome_orchestrator.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading