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/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;
}
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/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/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/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 %}
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: {} });
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']);
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);
+});