From c6a3d5592489878a6767bb2e60246882950a293d Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:42:19 +0200 Subject: [PATCH 1/5] feat(smarthome): make sensors assignable to portal owners --- src/services/smarthome/smarthomeOwners.js | 2 +- tests/smarthome_owners.test.js | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/services/smarthome/smarthomeOwners.js b/src/services/smarthome/smarthomeOwners.js index 20c421d7..fe2baa8b 100644 --- a/src/services/smarthome/smarthomeOwners.js +++ b/src/services/smarthome/smarthomeOwners.js @@ -3,7 +3,7 @@ const { getDb } = require('../../db/connection'); -const ASSIGNABLE = new Set(['light', 'plug', 'group']); +const ASSIGNABLE = new Set(['light', 'plug', 'group', 'sensor']); // Validate-before-write: resource must exist + be assignable; every userId must exist. // On any failure throw and write nothing. Then replace the owner set atomically. diff --git a/tests/smarthome_owners.test.js b/tests/smarthome_owners.test.js index 9a4037ce..98a667df 100644 --- a/tests/smarthome_owners.test.js +++ b/tests/smarthome_owners.test.js @@ -40,7 +40,7 @@ test('setOwners works for plug kind (assignable)', () => { test('setOwners refuses non-assignable kinds and missing resource', () => { const gw = dev.createGateway({ name: 'GW', route_id: null, apiKey: 'K', enabled: true }); - const sid = dev.upsertResource({ gateway_id: gw.id, deconz_id: '2', deconz_type: 'sensors', kind: 'sensor', name: 'S', capabilities: {} }); + const sid = dev.upsertResource({ gateway_id: gw.id, deconz_id: '2', deconz_type: 'sensors', kind: 'switch', name: 'S', capabilities: {} }); assert.throws(() => owners.setOwners(sid, []), (e) => e.code === 'SMARTHOME_NOT_ASSIGNABLE'); assert.throws(() => owners.setOwners(99999, []), (e) => e.code === 'SMARTHOME_RESOURCE_NOT_FOUND'); }); @@ -60,6 +60,17 @@ test('resourcesOwnedBy includes scenes of owned groups; isOwner is direct-only', assert.equal(owners.isOwner(grp, u), true); }); +test('setOwners accepts sensor kind; switch stays non-assignable; resourcesOwnedBy includes owned sensor', () => { + const gw = dev.createGateway({ name: 'GWs', route_id: null, apiKey: 'K', enabled: true }); + const sensor = dev.upsertResource({ gateway_id: gw.id, deconz_id: '2', deconz_type: 'sensors', kind: 'sensor', name: 'Temp', capabilities: {} }); + const sw = dev.upsertResource({ gateway_id: gw.id, deconz_id: '3', deconz_type: 'sensors', kind: 'switch', name: 'Btn', capabilities: {} }); + const u = mkUser('s'); + owners.setOwners(sensor, [u]); + assert.deepEqual(owners.ownersOf(sensor).map((o) => o.username), ['s']); + assert.ok(owners.resourcesOwnedBy(u).includes(sensor)); + assert.throws(() => owners.setOwners(sw, [u]), (e) => e.code === 'SMARTHOME_NOT_ASSIGNABLE'); +}); + test('removeAllForUser and removeAllForResource clear rows', () => { const gw = dev.createGateway({ name: 'GW', route_id: null, apiKey: 'K', enabled: true }); const rid = dev.upsertResource({ gateway_id: gw.id, deconz_id: '1', deconz_type: 'lights', kind: 'light', name: 'L', capabilities: {} }); From c73f12a79b9f39110cd057dc6abd7c6e24447ac0 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:45:33 +0200 Subject: [PATCH 2/5] feat(smarthome): portal serves owned sensors read-only; reject sensor control --- src/routes/api/portal.js | 13 ++++----- tests/smarthome_portal_api.test.js | 42 ++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/src/routes/api/portal.js b/src/routes/api/portal.js index 78b43abc..a7f3e22f 100644 --- a/src/routes/api/portal.js +++ b/src/routes/api/portal.js @@ -302,7 +302,7 @@ router.post('/midea/:id/state', async (req, res) => { function smarthomeUnavailable() { return !license.hasFeature('smarthome'); } -const SH_STATE_KEYS = new Set(['on', 'bri', 'reachable']); +const SH_STATE_KEYS = new Set(['on', 'bri', 'reachable', 'type', 'value']); function redactState(s) { if (!s || typeof s !== 'object') return {}; return Object.fromEntries(Object.entries(s).filter(([k]) => SH_STATE_KEYS.has(k))); @@ -328,11 +328,11 @@ router.get('/smarthome', async (req, res) => { const ids = new Set(smarthomeOwners.resourcesOwnedBy(req.portalOwnerId)); if (!ids.size) return res.json({ ok: true, data: null, reason: 'no_data' }); const all = await smarthome.getResources(); - const devices = all - .filter((r) => r.enabled && ids.has(r.id) && r.kind !== 'sensor' && r.kind !== 'switch') - .map(redactSmarthomeResource); - if (!devices.length) return res.json({ ok: true, data: null, reason: 'no_data' }); - res.json({ ok: true, data: { devices, loggedIn: req.portalLoggedIn } }); + const owned = all.filter((r) => r.enabled && ids.has(r.id)); + const devices = owned.filter((r) => r.kind !== 'sensor' && r.kind !== 'switch').map(redactSmarthomeResource); + const sensors = owned.filter((r) => r.kind === 'sensor').map(redactSmarthomeResource); // switch bleibt draußen + if (!devices.length && !sensors.length) return res.json({ ok: true, data: null, reason: 'no_data' }); + res.json({ ok: true, data: { devices, sensors, loggedIn: req.portalLoggedIn } }); } catch (err) { logger.error({ error: err.message }, 'portal /smarthome failed'); return res.json({ ok: true, data: null, reason: 'unavailable' }); @@ -351,6 +351,7 @@ router.post('/smarthome/:id/state', async (req, res) => { const all = await smarthome.getResources(); const resource = all.find((r) => r.id === id); if (!resource || !resource.enabled) return res.status(404).json({ ok: false, error: 'SMARTHOME_RESOURCE_NOT_FOUND' }); + if (resource.kind === 'sensor' || resource.kind === 'switch') return res.status(400).json({ ok: false, error: 'SMARTHOME_NOT_CONTROLLABLE' }); const patch = validateSmarthomePatch(req.body && req.body.patch, resource.capabilities); if (patch === null) return res.status(400).json({ ok: false, error: 'SMARTHOME_INVALID_PATCH' }); await smarthome.setResourceState(id, patch); diff --git a/tests/smarthome_portal_api.test.js b/tests/smarthome_portal_api.test.js index 04eaf1fa..fb862279 100644 --- a/tests/smarthome_portal_api.test.js +++ b/tests/smarthome_portal_api.test.js @@ -51,3 +51,45 @@ test('POST /portal/smarthome/:id/state without login → login_required', async const r = await anon.post(`/api/v1/portal/smarthome/${rid}/state`).send({ patch: { on: true } }).expect(200); assert.equal(r.body.reason, 'login_required'); }); + +test('GET /portal/smarthome returns owned sensors in sensors[] (redacted); non-owned sensor excluded', async () => { + const dev = require('../src/services/smarthome/smarthomeDevices'); + const owners = require('../src/services/smarthome/smarthomeOwners'); + const adminId = getDb().prepare("SELECT id FROM users WHERE role='admin' ORDER BY id LIMIT 1").get().id; + const gw = dev.createGateway({ name: 'GWsens', route_id: null, apiKey: 'K', enabled: true }); + const mine = dev.upsertResource({ gateway_id: gw.id, deconz_id: '10', deconz_type: 'sensors', kind: 'sensor', name: 'MyTemp', capabilities: {}, state: { type: 'temperature', value: 21.5 } }); + dev.upsertResource({ gateway_id: gw.id, deconz_id: '11', deconz_type: 'sensors', kind: 'sensor', name: 'NotMine', capabilities: {}, state: { type: 'temperature', value: 9 } }); + owners.setOwners(mine, [adminId]); + const res = await agent.get('/api/v1/portal/smarthome').expect(200); + assert.ok(res.body.data && Array.isArray(res.body.data.sensors)); + const names = res.body.data.sensors.map((s) => s.name); + assert.ok(names.includes('MyTemp')); + assert.ok(!names.includes('NotMine')); + const s = res.body.data.sensors.find((x) => x.name === 'MyTemp'); + assert.equal(s.state.type, 'temperature'); + assert.equal(s.state.value, 21.5); + assert.equal(s.gateway_id, undefined); // redigiert + assert.equal(s.deconz_id, undefined); // redigiert +}); + +test('GET /portal/smarthome: owner with ONLY a sensor gets sensors[] filled, not no_data', async () => { + const dev = require('../src/services/smarthome/smarthomeDevices'); + const owners = require('../src/services/smarthome/smarthomeOwners'); + const adminId = getDb().prepare("SELECT id FROM users WHERE role='admin' ORDER BY id LIMIT 1").get().id; + const gw = dev.createGateway({ name: 'GWonly', route_id: null, apiKey: 'K', enabled: true }); + const s1 = dev.upsertResource({ gateway_id: gw.id, deconz_id: '12', deconz_type: 'sensors', kind: 'sensor', name: 'Solo', capabilities: {}, state: { type: 'lightlevel', value: 42 } }); + owners.setOwners(s1, [adminId]); + const res = await agent.get('/api/v1/portal/smarthome').expect(200); + assert.ok(res.body.data, 'data must not be null for a sensor-only owner'); + assert.ok(res.body.data.sensors.some((x) => x.name === 'Solo')); +}); + +test('POST /portal/smarthome/:id/state on an owned SENSOR is rejected 400', async () => { + const dev = require('../src/services/smarthome/smarthomeDevices'); + const owners = require('../src/services/smarthome/smarthomeOwners'); + const adminId = getDb().prepare("SELECT id FROM users WHERE role='admin' ORDER BY id LIMIT 1").get().id; + const gw = dev.createGateway({ name: 'GWctl', route_id: null, apiKey: 'K', enabled: true }); + const sensor = dev.upsertResource({ gateway_id: gw.id, deconz_id: '13', deconz_type: 'sensors', kind: 'sensor', name: 'Ctl', capabilities: {}, state: { type: 'temperature', value: 20 } }); + owners.setOwners(sensor, [adminId]); + await agent.post(`/api/v1/portal/smarthome/${sensor}/state`).set('x-csrf-token', csrfToken).send({ patch: { on: true } }).expect(400); +}); From 31f06a4075800ed0c8db98189c1c14eee2d8c5d8 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:53:29 +0200 Subject: [PATCH 3/5] feat(smarthome): portal c-smarthome read-only sensor section --- public/css/portal.css | 5 +++++ public/js/portal.js | 38 +++++++++++++++++++++++++++++++++++-- src/i18n/de.json | 9 ++++++++- src/i18n/en.json | 9 ++++++++- templates/portal/portal.njk | 10 +++++++++- 5 files changed, 66 insertions(+), 5 deletions(-) diff --git a/public/css/portal.css b/public/css/portal.css index fb1b44d9..8d83841f 100644 --- a/public/css/portal.css +++ b/public/css/portal.css @@ -405,6 +405,11 @@ body::before{ .c-sh-sw.on{background:linear-gradient(145deg,var(--green,#4ade80),#15924f);border-color:transparent;color:#fff} .c-sh-bri{width:100%;margin-top:10px} .c-sh-msg{margin-top:10px;font-size:13px;color:var(--muted,#90a1b3)} +.c-sh-sensors{margin-top:12px;display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:8px} +.c-sh-sensor-head{grid-column:1/-1;font-size:12px;font-weight:600;color:var(--muted,#90a1b3);margin-top:6px} +.c-sh-sensor-card{background:var(--surface-2,#16212e);border:1px solid var(--line,rgba(255,255,255,.08));border-radius:10px;padding:10px} +.c-sh-sensor-name{font-size:13px;margin-bottom:4px} +.c-sh-sensor-val{font-size:16px;font-weight:600} /* ============================================================ REDUCED MOTION diff --git a/public/js/portal.js b/public/js/portal.js index bac659f6..c61a33b9 100644 --- a/public/js/portal.js +++ b/public/js/portal.js @@ -712,15 +712,49 @@ } return el; } + // type-strings EXACTLY as sensorReading() in src/services/smarthome/index.js emits: + // presence|open|water (boolean) · temperature|humidity|lightlevel (number, already /100 normalised) · button|unknown → "—" + function formatSensor(type, value) { + if (value === null || value === undefined || value === '') return '—'; + switch (type) { + case 'temperature': return Number(value).toFixed(1) + ' °C'; + case 'humidity': return Number(value) + ' %'; + case 'lightlevel': return Number(value) + ' lux'; + case 'open': return value ? (PT.smarthomeOpen || 'Open') : (PT.smarthomeClosed || 'Closed'); + case 'presence': return value ? (PT.smarthomeMotion || 'Motion') : (PT.smarthomeNoMotion || 'No motion'); + case 'water': return value ? (PT.smarthomeWet || 'Wet') : (PT.smarthomeDry || 'Dry'); + default: return '—'; // ponytail: button/unknown/future types → safe fallback, no raw value render + } + } + function renderSensorCard(s) { + var el = document.createElement('div'); el.className = 'c-sh-sensor-card'; + var st = s.state || {}; + var name = document.createElement('div'); name.className = 'c-sh-sensor-name'; name.textContent = s.name || ''; el.appendChild(name); + var val = document.createElement('div'); val.className = 'c-sh-sensor-val'; val.textContent = formatSensor(st.type, st.value); el.appendChild(val); + return el; + } function hydrateSmarthome() { var card = document.querySelector('.c-smarthome'); if (!card) return; fetch('/api/v1/portal/smarthome').then(function (r) { return r.json(); }).then(function (j) { var list = document.getElementById('smarthome-list'); if (!list) return; list.innerHTML = ''; - if (!j || !j.data || !j.data.devices || !j.data.devices.length) { card.style.display = 'none'; return; } + if (!j || !j.data) { card.style.display = 'none'; return; } card.style.display = ''; - j.data.devices.forEach(function (d) { list.appendChild(renderSmarthomeCard(d)); }); + (j.data.devices || []).forEach(function (d) { list.appendChild(renderSmarthomeCard(d)); }); + var sensorBox = document.getElementById('smarthome-sensors'); + if (sensorBox) { + sensorBox.innerHTML = ''; + var sensors = (j && j.data && j.data.sensors) || []; + if (sensors.length) { + var head = document.createElement('div'); head.className = 'c-sh-sensor-head'; head.textContent = PT.smarthomeSensors || 'Sensors'; + sensorBox.appendChild(head); + sensors.forEach(function (s) { sensorBox.appendChild(renderSensorCard(s)); }); + sensorBox.style.display = ''; + } else { + sensorBox.style.display = 'none'; + } + } }).catch(function () { card.style.display = 'none'; }); } diff --git a/src/i18n/de.json b/src/i18n/de.json index 35f43ee9..637128a9 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -2196,5 +2196,12 @@ "portal.smarthome.power": "Ein/Aus", "portal.smarthome.brightness": "Helligkeit", "portal.smarthome.activate": "Aktivieren", - "portal.smarthome.login_to_control": "Zum Steuern bitte anmelden" + "portal.smarthome.login_to_control": "Zum Steuern bitte anmelden", + "portal.smarthome.sensors": "Sensoren", + "portal.smarthome.open": "offen", + "portal.smarthome.closed": "zu", + "portal.smarthome.motion": "Bewegung", + "portal.smarthome.no_motion": "keine Bewegung", + "portal.smarthome.wet": "nass", + "portal.smarthome.dry": "trocken" } diff --git a/src/i18n/en.json b/src/i18n/en.json index 447e3b7b..0667804e 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -2252,5 +2252,12 @@ "portal.smarthome.power": "Power", "portal.smarthome.brightness": "Brightness", "portal.smarthome.activate": "Activate", - "portal.smarthome.login_to_control": "Log in to control" + "portal.smarthome.login_to_control": "Log in to control", + "portal.smarthome.sensors": "Sensors", + "portal.smarthome.open": "Open", + "portal.smarthome.closed": "Closed", + "portal.smarthome.motion": "Motion", + "portal.smarthome.no_motion": "No motion", + "portal.smarthome.wet": "Wet", + "portal.smarthome.dry": "Dry" } diff --git a/templates/portal/portal.njk b/templates/portal/portal.njk index af16a459..8b554191 100644 --- a/templates/portal/portal.njk +++ b/templates/portal/portal.njk @@ -61,7 +61,14 @@ smarthomePower: t('portal.smarthome.power'), smarthomeBrightness: t('portal.smarthome.brightness'), smarthomeActivate: t('portal.smarthome.activate'), - smarthomeLoginToControl: t('portal.smarthome.login_to_control') + smarthomeLoginToControl: t('portal.smarthome.login_to_control'), + smarthomeSensors: t('portal.smarthome.sensors'), + smarthomeOpen: t('portal.smarthome.open'), + smarthomeClosed: t('portal.smarthome.closed'), + smarthomeMotion: t('portal.smarthome.motion'), + smarthomeNoMotion: t('portal.smarthome.no_motion'), + smarthomeWet: t('portal.smarthome.wet'), + smarthomeDry: t('portal.smarthome.dry') } | dump | safe }} @@ -194,6 +201,7 @@

{{ t('portal.smarthome.title') }}

+
{% endif %} From 274f3dc3736c9c9a6d41b93baa914fbe201c6549 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:56:51 +0200 Subject: [PATCH 4/5] feat(smarthome): admin owner picker on sensor cards --- public/js/smarthome.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/public/js/smarthome.js b/public/js/smarthome.js index 49a1f25c..1461d5da 100644 --- a/public/js/smarthome.js +++ b/public/js/smarthome.js @@ -146,6 +146,15 @@ const v = document.createElement('div'); v.className = 'sh-sensorval'; v.id = `sv-${r.id}`; v.textContent = formatValue(r); el.appendChild(v); + // Owner-Zuweisung (read-only-Sensor, aber Admin kann Besitzer setzen — erscheinen im Portal read-only) + const own = document.createElement('div'); + const names = (r.owners || []).map((o) => o.username); + own.innerHTML = `
${names.length ? esc(names.join(', ')) : esc(T('smarthome.owners.none'))}
`; + const btn = document.createElement('button'); btn.className = 'sh-owner-btn'; btn.type = 'button'; + btn.textContent = T('smarthome.owners.manage'); + btn.addEventListener('click', () => openOwners(r)); + own.appendChild(btn); + el.appendChild(own); return el; } From e5e3025505e6d0b03018c221d4921dd7eda6b980 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:05:57 +0200 Subject: [PATCH 5/5] test(smarthome): use switch as non-assignable example in owners API test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sensor became assignable in this branch, so the admin owners-API test's 'non-assignable → 400' case must use switch (still non-assignable) to keep its intent. --- tests/smarthome_owners_api.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/smarthome_owners_api.test.js b/tests/smarthome_owners_api.test.js index 3afc3414..8f3ac290 100644 --- a/tests/smarthome_owners_api.test.js +++ b/tests/smarthome_owners_api.test.js @@ -26,7 +26,7 @@ test('PUT /resources/:id/owners sets owners; unknown user → 400; non-assignabl const dev = require('../src/services/smarthome/smarthomeDevices'); const gw = dev.createGateway({ name: 'GW2', route_id: null, apiKey: 'K', enabled: true }); const rid = dev.upsertResource({ gateway_id: gw.id, deconz_id: '9', deconz_type: 'lights', kind: 'light', name: 'L9', capabilities: {} }); - const sid = dev.upsertResource({ gateway_id: gw.id, deconz_id: '8', deconz_type: 'sensors', kind: 'sensor', name: 'S8', capabilities: {} }); + const sid = dev.upsertResource({ gateway_id: gw.id, deconz_id: '8', deconz_type: 'sensors', kind: 'switch', name: 'S8', capabilities: {} }); const uid = Number(getDb().prepare("INSERT INTO users (username, password_hash, role) VALUES ('owner1', 'x', 'user')").run().lastInsertRowid); const ok = await agent.put(`/api/v1/smarthome/resources/${rid}/owners`).set('x-csrf-token', csrfToken).send({ userIds: [uid] }).expect(200); assert.deepEqual(ok.body.owners.map((o) => o.username), ['owner1']);