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
5 changes: 5 additions & 0 deletions public/css/portal.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 36 additions & 2 deletions public/js/portal.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'; });
}

Expand Down
9 changes: 9 additions & 0 deletions public/js/smarthome.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<div class="sh-owner-chips">${names.length ? esc(names.join(', ')) : esc(T('smarthome.owners.none'))}</div>`;
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;
}

Expand Down
9 changes: 8 additions & 1 deletion src/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
9 changes: 8 additions & 1 deletion src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
13 changes: 7 additions & 6 deletions src/routes/api/portal.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
Expand All @@ -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' });
Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/services/smarthome/smarthomeOwners.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 9 additions & 1 deletion templates/portal/portal.njk
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}</script>
<script src="/js/portal.js?v={{ appVersion }}" defer></script>
</head>
Expand Down Expand Up @@ -194,6 +201,7 @@
<section class="card c-smarthome">
<div class="card-head"><h2>{{ t('portal.smarthome.title') }}</h2></div>
<div id="smarthome-list" class="c-sh-list"></div>
<div id="smarthome-sensors" class="c-sh-sensors" style="display:none"></div>
<div id="smarthomeMsg" class="c-sh-msg" style="display:none"></div>
</section>
{% endif %}
Expand Down
13 changes: 12 additions & 1 deletion tests/smarthome_owners.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Expand All @@ -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: {} });
Expand Down
2 changes: 1 addition & 1 deletion tests/smarthome_owners_api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down
42 changes: 42 additions & 0 deletions tests/smarthome_portal_api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Loading