Skip to content

Commit 78bb972

Browse files
authored
feat(smarthome): Sensoren read-only im Portal (#212)
* feat(smarthome): make sensors assignable to portal owners * feat(smarthome): portal serves owned sensors read-only; reject sensor control * feat(smarthome): portal c-smarthome read-only sensor section * feat(smarthome): admin owner picker on sensor cards * test(smarthome): use switch as non-assignable example in owners API test 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.
1 parent e35bfda commit 78bb972

11 files changed

Lines changed: 138 additions & 14 deletions

File tree

public/css/portal.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,11 @@ body::before{
405405
.c-sh-sw.on{background:linear-gradient(145deg,var(--green,#4ade80),#15924f);border-color:transparent;color:#fff}
406406
.c-sh-bri{width:100%;margin-top:10px}
407407
.c-sh-msg{margin-top:10px;font-size:13px;color:var(--muted,#90a1b3)}
408+
.c-sh-sensors{margin-top:12px;display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:8px}
409+
.c-sh-sensor-head{grid-column:1/-1;font-size:12px;font-weight:600;color:var(--muted,#90a1b3);margin-top:6px}
410+
.c-sh-sensor-card{background:var(--surface-2,#16212e);border:1px solid var(--line,rgba(255,255,255,.08));border-radius:10px;padding:10px}
411+
.c-sh-sensor-name{font-size:13px;margin-bottom:4px}
412+
.c-sh-sensor-val{font-size:16px;font-weight:600}
408413

409414
/* ============================================================
410415
REDUCED MOTION

public/js/portal.js

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -712,15 +712,49 @@
712712
}
713713
return el;
714714
}
715+
// type-strings EXACTLY as sensorReading() in src/services/smarthome/index.js emits:
716+
// presence|open|water (boolean) · temperature|humidity|lightlevel (number, already /100 normalised) · button|unknown → "—"
717+
function formatSensor(type, value) {
718+
if (value === null || value === undefined || value === '') return '—';
719+
switch (type) {
720+
case 'temperature': return Number(value).toFixed(1) + ' °C';
721+
case 'humidity': return Number(value) + ' %';
722+
case 'lightlevel': return Number(value) + ' lux';
723+
case 'open': return value ? (PT.smarthomeOpen || 'Open') : (PT.smarthomeClosed || 'Closed');
724+
case 'presence': return value ? (PT.smarthomeMotion || 'Motion') : (PT.smarthomeNoMotion || 'No motion');
725+
case 'water': return value ? (PT.smarthomeWet || 'Wet') : (PT.smarthomeDry || 'Dry');
726+
default: return '—'; // ponytail: button/unknown/future types → safe fallback, no raw value render
727+
}
728+
}
729+
function renderSensorCard(s) {
730+
var el = document.createElement('div'); el.className = 'c-sh-sensor-card';
731+
var st = s.state || {};
732+
var name = document.createElement('div'); name.className = 'c-sh-sensor-name'; name.textContent = s.name || ''; el.appendChild(name);
733+
var val = document.createElement('div'); val.className = 'c-sh-sensor-val'; val.textContent = formatSensor(st.type, st.value); el.appendChild(val);
734+
return el;
735+
}
715736
function hydrateSmarthome() {
716737
var card = document.querySelector('.c-smarthome');
717738
if (!card) return;
718739
fetch('/api/v1/portal/smarthome').then(function (r) { return r.json(); }).then(function (j) {
719740
var list = document.getElementById('smarthome-list'); if (!list) return;
720741
list.innerHTML = '';
721-
if (!j || !j.data || !j.data.devices || !j.data.devices.length) { card.style.display = 'none'; return; }
742+
if (!j || !j.data) { card.style.display = 'none'; return; }
722743
card.style.display = '';
723-
j.data.devices.forEach(function (d) { list.appendChild(renderSmarthomeCard(d)); });
744+
(j.data.devices || []).forEach(function (d) { list.appendChild(renderSmarthomeCard(d)); });
745+
var sensorBox = document.getElementById('smarthome-sensors');
746+
if (sensorBox) {
747+
sensorBox.innerHTML = '';
748+
var sensors = (j && j.data && j.data.sensors) || [];
749+
if (sensors.length) {
750+
var head = document.createElement('div'); head.className = 'c-sh-sensor-head'; head.textContent = PT.smarthomeSensors || 'Sensors';
751+
sensorBox.appendChild(head);
752+
sensors.forEach(function (s) { sensorBox.appendChild(renderSensorCard(s)); });
753+
sensorBox.style.display = '';
754+
} else {
755+
sensorBox.style.display = 'none';
756+
}
757+
}
724758
}).catch(function () { card.style.display = 'none'; });
725759
}
726760

public/js/smarthome.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,15 @@
146146
const v = document.createElement('div'); v.className = 'sh-sensorval'; v.id = `sv-${r.id}`;
147147
v.textContent = formatValue(r);
148148
el.appendChild(v);
149+
// Owner-Zuweisung (read-only-Sensor, aber Admin kann Besitzer setzen — erscheinen im Portal read-only)
150+
const own = document.createElement('div');
151+
const names = (r.owners || []).map((o) => o.username);
152+
own.innerHTML = `<div class="sh-owner-chips">${names.length ? esc(names.join(', ')) : esc(T('smarthome.owners.none'))}</div>`;
153+
const btn = document.createElement('button'); btn.className = 'sh-owner-btn'; btn.type = 'button';
154+
btn.textContent = T('smarthome.owners.manage');
155+
btn.addEventListener('click', () => openOwners(r));
156+
own.appendChild(btn);
157+
el.appendChild(own);
149158
return el;
150159
}
151160

src/i18n/de.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2196,5 +2196,12 @@
21962196
"portal.smarthome.power": "Ein/Aus",
21972197
"portal.smarthome.brightness": "Helligkeit",
21982198
"portal.smarthome.activate": "Aktivieren",
2199-
"portal.smarthome.login_to_control": "Zum Steuern bitte anmelden"
2199+
"portal.smarthome.login_to_control": "Zum Steuern bitte anmelden",
2200+
"portal.smarthome.sensors": "Sensoren",
2201+
"portal.smarthome.open": "offen",
2202+
"portal.smarthome.closed": "zu",
2203+
"portal.smarthome.motion": "Bewegung",
2204+
"portal.smarthome.no_motion": "keine Bewegung",
2205+
"portal.smarthome.wet": "nass",
2206+
"portal.smarthome.dry": "trocken"
22002207
}

src/i18n/en.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2252,5 +2252,12 @@
22522252
"portal.smarthome.power": "Power",
22532253
"portal.smarthome.brightness": "Brightness",
22542254
"portal.smarthome.activate": "Activate",
2255-
"portal.smarthome.login_to_control": "Log in to control"
2255+
"portal.smarthome.login_to_control": "Log in to control",
2256+
"portal.smarthome.sensors": "Sensors",
2257+
"portal.smarthome.open": "Open",
2258+
"portal.smarthome.closed": "Closed",
2259+
"portal.smarthome.motion": "Motion",
2260+
"portal.smarthome.no_motion": "No motion",
2261+
"portal.smarthome.wet": "Wet",
2262+
"portal.smarthome.dry": "Dry"
22562263
}

src/routes/api/portal.js

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,7 @@ router.post('/midea/:id/state', async (req, res) => {
302302
function smarthomeUnavailable() {
303303
return !license.hasFeature('smarthome');
304304
}
305-
const SH_STATE_KEYS = new Set(['on', 'bri', 'reachable']);
305+
const SH_STATE_KEYS = new Set(['on', 'bri', 'reachable', 'type', 'value']);
306306
function redactState(s) {
307307
if (!s || typeof s !== 'object') return {};
308308
return Object.fromEntries(Object.entries(s).filter(([k]) => SH_STATE_KEYS.has(k)));
@@ -328,11 +328,11 @@ router.get('/smarthome', async (req, res) => {
328328
const ids = new Set(smarthomeOwners.resourcesOwnedBy(req.portalOwnerId));
329329
if (!ids.size) return res.json({ ok: true, data: null, reason: 'no_data' });
330330
const all = await smarthome.getResources();
331-
const devices = all
332-
.filter((r) => r.enabled && ids.has(r.id) && r.kind !== 'sensor' && r.kind !== 'switch')
333-
.map(redactSmarthomeResource);
334-
if (!devices.length) return res.json({ ok: true, data: null, reason: 'no_data' });
335-
res.json({ ok: true, data: { devices, loggedIn: req.portalLoggedIn } });
331+
const owned = all.filter((r) => r.enabled && ids.has(r.id));
332+
const devices = owned.filter((r) => r.kind !== 'sensor' && r.kind !== 'switch').map(redactSmarthomeResource);
333+
const sensors = owned.filter((r) => r.kind === 'sensor').map(redactSmarthomeResource); // switch bleibt draußen
334+
if (!devices.length && !sensors.length) return res.json({ ok: true, data: null, reason: 'no_data' });
335+
res.json({ ok: true, data: { devices, sensors, loggedIn: req.portalLoggedIn } });
336336
} catch (err) {
337337
logger.error({ error: err.message }, 'portal /smarthome failed');
338338
return res.json({ ok: true, data: null, reason: 'unavailable' });
@@ -351,6 +351,7 @@ router.post('/smarthome/:id/state', async (req, res) => {
351351
const all = await smarthome.getResources();
352352
const resource = all.find((r) => r.id === id);
353353
if (!resource || !resource.enabled) return res.status(404).json({ ok: false, error: 'SMARTHOME_RESOURCE_NOT_FOUND' });
354+
if (resource.kind === 'sensor' || resource.kind === 'switch') return res.status(400).json({ ok: false, error: 'SMARTHOME_NOT_CONTROLLABLE' });
354355
const patch = validateSmarthomePatch(req.body && req.body.patch, resource.capabilities);
355356
if (patch === null) return res.status(400).json({ ok: false, error: 'SMARTHOME_INVALID_PATCH' });
356357
await smarthome.setResourceState(id, patch);

src/services/smarthome/smarthomeOwners.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
const { getDb } = require('../../db/connection');
55

6-
const ASSIGNABLE = new Set(['light', 'plug', 'group']);
6+
const ASSIGNABLE = new Set(['light', 'plug', 'group', 'sensor']);
77

88
// Validate-before-write: resource must exist + be assignable; every userId must exist.
99
// On any failure throw and write nothing. Then replace the owner set atomically.

templates/portal/portal.njk

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,14 @@
6161
smarthomePower: t('portal.smarthome.power'),
6262
smarthomeBrightness: t('portal.smarthome.brightness'),
6363
smarthomeActivate: t('portal.smarthome.activate'),
64-
smarthomeLoginToControl: t('portal.smarthome.login_to_control')
64+
smarthomeLoginToControl: t('portal.smarthome.login_to_control'),
65+
smarthomeSensors: t('portal.smarthome.sensors'),
66+
smarthomeOpen: t('portal.smarthome.open'),
67+
smarthomeClosed: t('portal.smarthome.closed'),
68+
smarthomeMotion: t('portal.smarthome.motion'),
69+
smarthomeNoMotion: t('portal.smarthome.no_motion'),
70+
smarthomeWet: t('portal.smarthome.wet'),
71+
smarthomeDry: t('portal.smarthome.dry')
6572
} | dump | safe }}</script>
6673
<script src="/js/portal.js?v={{ appVersion }}" defer></script>
6774
</head>
@@ -194,6 +201,7 @@
194201
<section class="card c-smarthome">
195202
<div class="card-head"><h2>{{ t('portal.smarthome.title') }}</h2></div>
196203
<div id="smarthome-list" class="c-sh-list"></div>
204+
<div id="smarthome-sensors" class="c-sh-sensors" style="display:none"></div>
197205
<div id="smarthomeMsg" class="c-sh-msg" style="display:none"></div>
198206
</section>
199207
{% endif %}

tests/smarthome_owners.test.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ test('setOwners works for plug kind (assignable)', () => {
4040

4141
test('setOwners refuses non-assignable kinds and missing resource', () => {
4242
const gw = dev.createGateway({ name: 'GW', route_id: null, apiKey: 'K', enabled: true });
43-
const sid = dev.upsertResource({ gateway_id: gw.id, deconz_id: '2', deconz_type: 'sensors', kind: 'sensor', name: 'S', capabilities: {} });
43+
const sid = dev.upsertResource({ gateway_id: gw.id, deconz_id: '2', deconz_type: 'sensors', kind: 'switch', name: 'S', capabilities: {} });
4444
assert.throws(() => owners.setOwners(sid, []), (e) => e.code === 'SMARTHOME_NOT_ASSIGNABLE');
4545
assert.throws(() => owners.setOwners(99999, []), (e) => e.code === 'SMARTHOME_RESOURCE_NOT_FOUND');
4646
});
@@ -60,6 +60,17 @@ test('resourcesOwnedBy includes scenes of owned groups; isOwner is direct-only',
6060
assert.equal(owners.isOwner(grp, u), true);
6161
});
6262

63+
test('setOwners accepts sensor kind; switch stays non-assignable; resourcesOwnedBy includes owned sensor', () => {
64+
const gw = dev.createGateway({ name: 'GWs', route_id: null, apiKey: 'K', enabled: true });
65+
const sensor = dev.upsertResource({ gateway_id: gw.id, deconz_id: '2', deconz_type: 'sensors', kind: 'sensor', name: 'Temp', capabilities: {} });
66+
const sw = dev.upsertResource({ gateway_id: gw.id, deconz_id: '3', deconz_type: 'sensors', kind: 'switch', name: 'Btn', capabilities: {} });
67+
const u = mkUser('s');
68+
owners.setOwners(sensor, [u]);
69+
assert.deepEqual(owners.ownersOf(sensor).map((o) => o.username), ['s']);
70+
assert.ok(owners.resourcesOwnedBy(u).includes(sensor));
71+
assert.throws(() => owners.setOwners(sw, [u]), (e) => e.code === 'SMARTHOME_NOT_ASSIGNABLE');
72+
});
73+
6374
test('removeAllForUser and removeAllForResource clear rows', () => {
6475
const gw = dev.createGateway({ name: 'GW', route_id: null, apiKey: 'K', enabled: true });
6576
const rid = dev.upsertResource({ gateway_id: gw.id, deconz_id: '1', deconz_type: 'lights', kind: 'light', name: 'L', capabilities: {} });

tests/smarthome_owners_api.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ test('PUT /resources/:id/owners sets owners; unknown user → 400; non-assignabl
2626
const dev = require('../src/services/smarthome/smarthomeDevices');
2727
const gw = dev.createGateway({ name: 'GW2', route_id: null, apiKey: 'K', enabled: true });
2828
const rid = dev.upsertResource({ gateway_id: gw.id, deconz_id: '9', deconz_type: 'lights', kind: 'light', name: 'L9', capabilities: {} });
29-
const sid = dev.upsertResource({ gateway_id: gw.id, deconz_id: '8', deconz_type: 'sensors', kind: 'sensor', name: 'S8', capabilities: {} });
29+
const sid = dev.upsertResource({ gateway_id: gw.id, deconz_id: '8', deconz_type: 'sensors', kind: 'switch', name: 'S8', capabilities: {} });
3030
const uid = Number(getDb().prepare("INSERT INTO users (username, password_hash, role) VALUES ('owner1', 'x', 'user')").run().lastInsertRowid);
3131
const ok = await agent.put(`/api/v1/smarthome/resources/${rid}/owners`).set('x-csrf-token', csrfToken).send({ userIds: [uid] }).expect(200);
3232
assert.deepEqual(ok.body.owners.map((o) => o.username), ['owner1']);

0 commit comments

Comments
 (0)