diff --git a/public/css/smarthome.css b/public/css/smarthome.css
index 69f4242b..0c136ab5 100644
--- a/public/css/smarthome.css
+++ b/public/css/smarthome.css
@@ -8,7 +8,8 @@
.sh-section-title .ln{flex:1;height:1px;background:var(--line,rgba(255,255,255,.08))}
.sh-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px;margin-bottom:26px}
.sh-card{background:var(--surface,#111a24);border:1px solid var(--line,rgba(255,255,255,.08));border-radius:14px;padding:16px}
-.sh-name{font-weight:700;font-size:15px}
+.sh-name{font-weight:700;font-size:15px;display:flex;align-items:center;gap:8px}
+.sh-ico{flex:none;color:var(--accent,#8b9cff);opacity:.92}
.sh-sub{font-size:12px;color:var(--muted,#90a1b3);margin-top:2px}
.sh-body{margin-top:14px;display:flex;flex-direction:column;gap:13px}
.sh-pwr{display:flex;align-items:center;justify-content:space-between;gap:12px}
diff --git a/public/js/smarthome.js b/public/js/smarthome.js
index 964340cf..c91729a1 100644
--- a/public/js/smarthome.js
+++ b/public/js/smarthome.js
@@ -26,10 +26,40 @@
}
function send(id, body) { return api(`/resources/${id}/state`, { method: 'POST', body: JSON.stringify(body) }); }
+ // Category key: sensors get a subtype (caps.reading), everything else is its kind.
+ function catKey(r) {
+ if (r.kind === 'sensor') return 'sensor.' + ((r.capabilities && r.capabilities.reading) || 'unknown');
+ return r.kind;
+ }
+ function subLabel(r) {
+ return r.kind === 'sensor' ? T('smarthome.sensor.' + ((r.capabilities && r.capabilities.reading) || 'unknown'))
+ : T('smarthome.kind.' + r.kind);
+ }
+ // Minimal inline SVG icon per category (stroke-based, theme color).
+ const ICONS = {
+ light: '',
+ plug: '',
+ group: '',
+ scene: '',
+ switch: '',
+ 'sensor.presence': '',
+ 'sensor.open': '',
+ 'sensor.water': '',
+ 'sensor.temperature': '',
+ 'sensor.humidity': '',
+ 'sensor.lightlevel': '',
+ 'sensor.button': '',
+ 'sensor.unknown': '',
+ };
+ function iconSvg(r) {
+ const body = ICONS[catKey(r)] || ICONS['sensor.unknown'];
+ return ``;
+ }
+
function cardShell(r) {
const el = document.createElement('div');
el.className = 'sh-card';
- el.innerHTML = `
${esc(r.name || '')}
${esc(r.kind)}
`;
+ el.innerHTML = `${iconSvg(r)}${esc(r.name || '')}
${esc(subLabel(r))}
`;
return el;
}
@@ -90,6 +120,11 @@
return el;
}
+ // Switches/button remotes are inputs (read-only here). Button→action binding = TP3.
+ function renderSwitch(r) {
+ return cardShell(r);
+ }
+
function renderKpis(resources) {
const host = $('#smarthome-kpis'); if (!host) return;
const by = (k) => resources.filter((r) => r.kind === k).length;
@@ -121,7 +156,9 @@
renderKpis(res);
const blocks = [
section(T('smarthome.section.lights'), res.filter((r) => r.kind === 'light'), renderControllable),
+ section(T('smarthome.section.plugs'), res.filter((r) => r.kind === 'plug'), renderControllable),
section(T('smarthome.section.groups'), res.filter((r) => r.kind === 'group' || r.kind === 'scene'), (r) => r.kind === 'scene' ? renderScene(r) : renderControllable(r)),
+ section(T('smarthome.section.switches'), res.filter((r) => r.kind === 'switch'), renderSwitch),
section(T('smarthome.section.sensors'), res.filter((r) => r.kind === 'sensor'), renderSensor),
].filter(Boolean);
if (!blocks.length) { host.innerHTML = `${T('smarthome.empty')}
`; return; }
diff --git a/src/i18n/de.json b/src/i18n/de.json
index 3258b446..34a394a4 100644
--- a/src/i18n/de.json
+++ b/src/i18n/de.json
@@ -2106,6 +2106,21 @@
"smarthome.section.groups": "Gruppen & Szenen",
"smarthome.section.sensors": "Sensoren",
"smarthome.kpi.groups": "Gruppen/Szenen",
+ "smarthome.section.plugs": "Steckdosen",
+ "smarthome.section.switches": "Schalter",
+ "smarthome.kind.light": "Licht",
+ "smarthome.kind.plug": "Steckdose",
+ "smarthome.kind.group": "Gruppe",
+ "smarthome.kind.scene": "Szene",
+ "smarthome.kind.switch": "Schalter",
+ "smarthome.sensor.presence": "Bewegung",
+ "smarthome.sensor.open": "Fenster/Tür",
+ "smarthome.sensor.water": "Wasser",
+ "smarthome.sensor.temperature": "Temperatur",
+ "smarthome.sensor.humidity": "Feuchte",
+ "smarthome.sensor.lightlevel": "Helligkeit",
+ "smarthome.sensor.button": "Taster",
+ "smarthome.sensor.unknown": "Sensor",
"smarthome.connect.title": "Phoscon-Gateway verbinden",
"smarthome.connect.name": "Name",
"smarthome.connect.route": "Interne Route",
diff --git a/src/i18n/en.json b/src/i18n/en.json
index bee375c3..a83f7f83 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -2162,6 +2162,21 @@
"smarthome.section.groups": "Groups & Scenes",
"smarthome.section.sensors": "Sensors",
"smarthome.kpi.groups": "Groups/Scenes",
+ "smarthome.section.plugs": "Plugs",
+ "smarthome.section.switches": "Switches",
+ "smarthome.kind.light": "Light",
+ "smarthome.kind.plug": "Plug",
+ "smarthome.kind.group": "Group",
+ "smarthome.kind.scene": "Scene",
+ "smarthome.kind.switch": "Switch",
+ "smarthome.sensor.presence": "Motion",
+ "smarthome.sensor.open": "Contact",
+ "smarthome.sensor.water": "Water",
+ "smarthome.sensor.temperature": "Temperature",
+ "smarthome.sensor.humidity": "Humidity",
+ "smarthome.sensor.lightlevel": "Light level",
+ "smarthome.sensor.button": "Button",
+ "smarthome.sensor.unknown": "Sensor",
"smarthome.connect.title": "Connect Phoscon Gateway",
"smarthome.connect.name": "Name",
"smarthome.connect.route": "Internal route",
diff --git a/src/services/smarthome/index.js b/src/services/smarthome/index.js
index cd88d7a9..e0aeebb7 100644
--- a/src/services/smarthome/index.js
+++ b/src/services/smarthome/index.js
@@ -28,12 +28,30 @@ function capsFromLight(light) {
function sensorReading(sensor) {
const s = sensor.state || {};
if ('presence' in s) return { type: 'presence', value: s.presence };
- if ('temperature' in s) return { type: 'temperature', value: s.temperature / 100 };
if ('open' in s) return { type: 'open', value: s.open };
+ if ('water' in s) return { type: 'water', value: s.water };
+ if ('temperature' in s) return { type: 'temperature', value: s.temperature / 100 };
+ if ('humidity' in s) return { type: 'humidity', value: s.humidity / 100 };
+ if ('lightlevel' in s) return { type: 'lightlevel', value: s.lux != null ? s.lux : s.lightlevel };
if ('buttonevent' in s) return { type: 'button', value: s.buttonevent };
return { type: 'unknown', value: null };
}
+// deCONZ /lights also carries plugs + the virtual "Configuration tool".
+// null = skip (not a real device).
+function lightKind(light) {
+ if ((light.type || '') === 'Configuration tool') return null;
+ return /plug/i.test(light.type || '') ? 'plug' : 'light';
+}
+
+// deCONZ /sensors mixes passive sensors, button remotes (ZHASwitch) and virtuals.
+// null = skip.
+function sensorKind(sensor) {
+ const t = sensor.type || '';
+ if (t.startsWith('CLIP') || t === 'Daylight') return null; // virtual
+ return /switch/i.test(t) ? 'switch' : 'sensor';
+}
+
async function connectGateway({ name, route_id, apiKey }) {
let key = apiKey;
if (!key) {
@@ -49,14 +67,16 @@ async function syncGateway(gatewayId) {
const gw = dev.getGateway(gatewayId);
if (!gw) { const e = new Error('gateway not found'); e.code = 'SMARTHOME_GATEWAY_NOT_FOUND'; throw e; }
const client = clientForGateway(gw);
- const counts = { lights: 0, groups: 0, scenes: 0, sensors: 0 };
+ const counts = { lights: 0, plugs: 0, groups: 0, scenes: 0, sensors: 0, switches: 0 };
const seen = [];
try {
const lights = await client.getLights();
for (const [id, l] of Object.entries(lights)) {
- dev.upsertResource({ gateway_id: gw.id, deconz_id: id, deconz_type: 'lights', uniqueid: l.uniqueid || null, kind: 'light', name: l.name, capabilities: capsFromLight(l) });
- seen.push(`lights:${id}`); counts.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) });
+ seen.push(`lights:${id}`); counts[kind === 'plug' ? 'plugs' : 'lights']++;
}
} catch (_) { /* best-effort */ }
@@ -76,9 +96,10 @@ async function syncGateway(gatewayId) {
try {
const sensors = await client.getSensors();
for (const [id, s] of Object.entries(sensors)) {
- if (s.type && s.type.startsWith('CLIP')) continue; // ponytail: virtual sensors hidden; expose via config if needed
- dev.upsertResource({ gateway_id: gw.id, deconz_id: id, deconz_type: 'sensors', uniqueid: s.uniqueid || null, kind: 'sensor', name: s.name, capabilities: { reading: sensorReading(s).type } });
- seen.push(`sensors:${id}`); counts.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 } });
+ seen.push(`sensors:${id}`); counts[kind === 'switch' ? 'switches' : 'sensors']++;
}
} catch (_) { /* best-effort */ }
@@ -109,6 +130,9 @@ async function setResourceState(resourceId, raw) {
const [groupId, sceneId] = String(resource.deconz_id).split('/');
return client.recallScene(groupId, sceneId);
}
+ if (resource.kind === 'sensor' || resource.kind === 'switch') {
+ const e = new Error(`resource ${resourceId} is not controllable`); e.code = 'SMARTHOME_NOT_CONTROLLABLE'; throw e;
+ }
const patch = validatePatch(resource, raw);
if (resource.kind === 'group') return client.setGroupState(resource.deconz_id, patch);
return client.setLightState(resource.deconz_id, patch);
@@ -153,7 +177,7 @@ function stopPolling() { if (pollTimer) { clearInterval(pollTimer); pollTimer =
module.exports = {
connectGateway, syncGateway, getResources, setResourceState, testGateway,
- startPolling, stopPolling, pollTick, capsFromLight, sensorReading,
+ startPolling, stopPolling, pollTick, capsFromLight, sensorReading, lightKind, sensorKind,
listGateways: dev.listGateways, getGateway: dev.getGateway,
updateGateway: dev.updateGateway, removeGateway: dev.removeGateway,
};
diff --git a/templates/aurora/layout.njk b/templates/aurora/layout.njk
index 30dce19e..ee14f098 100644
--- a/templates/aurora/layout.njk
+++ b/templates/aurora/layout.njk
@@ -67,6 +67,21 @@
'smarthome.kpi.groups': {{ t('smarthome.kpi.groups') | dump | safe }},
'smarthome.test_ok': {{ t('smarthome.test_ok') | dump | safe }},
'smarthome.test_fail': {{ t('smarthome.test_fail') | dump | safe }},
+ 'smarthome.section.plugs': {{ t('smarthome.section.plugs') | dump | safe }},
+ 'smarthome.section.switches': {{ t('smarthome.section.switches') | dump | safe }},
+ 'smarthome.kind.light': {{ t('smarthome.kind.light') | dump | safe }},
+ 'smarthome.kind.plug': {{ t('smarthome.kind.plug') | dump | safe }},
+ 'smarthome.kind.group': {{ t('smarthome.kind.group') | dump | safe }},
+ 'smarthome.kind.scene': {{ t('smarthome.kind.scene') | dump | safe }},
+ 'smarthome.kind.switch': {{ t('smarthome.kind.switch') | dump | safe }},
+ 'smarthome.sensor.presence': {{ t('smarthome.sensor.presence') | dump | safe }},
+ 'smarthome.sensor.open': {{ t('smarthome.sensor.open') | dump | safe }},
+ 'smarthome.sensor.water': {{ t('smarthome.sensor.water') | dump | safe }},
+ 'smarthome.sensor.temperature': {{ t('smarthome.sensor.temperature') | dump | safe }},
+ 'smarthome.sensor.humidity': {{ t('smarthome.sensor.humidity') | dump | safe }},
+ '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 }},
'peers.no_peers': {{ t('peers.no_peers') | dump | safe }},
'peers.online': {{ t('peers.online') | dump | safe }},
'peers.offline': {{ t('peers.offline') | dump | safe }},
diff --git a/templates/default/layout.njk b/templates/default/layout.njk
index 95b4b21f..85043abc 100644
--- a/templates/default/layout.njk
+++ b/templates/default/layout.njk
@@ -66,6 +66,21 @@
'smarthome.kpi.groups': {{ t('smarthome.kpi.groups') | dump | safe }},
'smarthome.test_ok': {{ t('smarthome.test_ok') | dump | safe }},
'smarthome.test_fail': {{ t('smarthome.test_fail') | dump | safe }},
+ 'smarthome.section.plugs': {{ t('smarthome.section.plugs') | dump | safe }},
+ 'smarthome.section.switches': {{ t('smarthome.section.switches') | dump | safe }},
+ 'smarthome.kind.light': {{ t('smarthome.kind.light') | dump | safe }},
+ 'smarthome.kind.plug': {{ t('smarthome.kind.plug') | dump | safe }},
+ 'smarthome.kind.group': {{ t('smarthome.kind.group') | dump | safe }},
+ 'smarthome.kind.scene': {{ t('smarthome.kind.scene') | dump | safe }},
+ 'smarthome.kind.switch': {{ t('smarthome.kind.switch') | dump | safe }},
+ 'smarthome.sensor.presence': {{ t('smarthome.sensor.presence') | dump | safe }},
+ 'smarthome.sensor.open': {{ t('smarthome.sensor.open') | dump | safe }},
+ 'smarthome.sensor.water': {{ t('smarthome.sensor.water') | dump | safe }},
+ 'smarthome.sensor.temperature': {{ t('smarthome.sensor.temperature') | dump | safe }},
+ 'smarthome.sensor.humidity': {{ t('smarthome.sensor.humidity') | dump | safe }},
+ '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 }},
'peers.no_peers': {{ t('peers.no_peers') | dump | safe }},
'peers.online': {{ t('peers.online') | dump | safe }},
'peers.offline': {{ t('peers.offline') | dump | safe }},
diff --git a/templates/pro/layout.njk b/templates/pro/layout.njk
index 7a1a2482..4f067fce 100644
--- a/templates/pro/layout.njk
+++ b/templates/pro/layout.njk
@@ -68,6 +68,21 @@
'smarthome.kpi.groups': {{ t('smarthome.kpi.groups') | dump | safe }},
'smarthome.test_ok': {{ t('smarthome.test_ok') | dump | safe }},
'smarthome.test_fail': {{ t('smarthome.test_fail') | dump | safe }},
+ 'smarthome.section.plugs': {{ t('smarthome.section.plugs') | dump | safe }},
+ 'smarthome.section.switches': {{ t('smarthome.section.switches') | dump | safe }},
+ 'smarthome.kind.light': {{ t('smarthome.kind.light') | dump | safe }},
+ 'smarthome.kind.plug': {{ t('smarthome.kind.plug') | dump | safe }},
+ 'smarthome.kind.group': {{ t('smarthome.kind.group') | dump | safe }},
+ 'smarthome.kind.scene': {{ t('smarthome.kind.scene') | dump | safe }},
+ 'smarthome.kind.switch': {{ t('smarthome.kind.switch') | dump | safe }},
+ 'smarthome.sensor.presence': {{ t('smarthome.sensor.presence') | dump | safe }},
+ 'smarthome.sensor.open': {{ t('smarthome.sensor.open') | dump | safe }},
+ 'smarthome.sensor.water': {{ t('smarthome.sensor.water') | dump | safe }},
+ 'smarthome.sensor.temperature': {{ t('smarthome.sensor.temperature') | dump | safe }},
+ 'smarthome.sensor.humidity': {{ t('smarthome.sensor.humidity') | dump | safe }},
+ '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 }},
'peers.no_peers': {{ t('peers.no_peers') | dump | safe }},
'peers.online': {{ t('peers.online') | dump | safe }},
'peers.offline': {{ t('peers.offline') | dump | safe }},
diff --git a/tests/smarthome_orchestrator.test.js b/tests/smarthome_orchestrator.test.js
index 89def886..56807190 100644
--- a/tests/smarthome_orchestrator.test.js
+++ b/tests/smarthome_orchestrator.test.js
@@ -25,6 +25,67 @@ test('capsFromLight derives color capability', () => {
assert.equal(sh.capsFromLight({ state: { on: true, bri: 5 } }).bri, true);
});
+test('lightKind classifies plugs/lights and skips Configuration tool', () => {
+ assert.equal(sh.lightKind({ type: 'On/Off plug-in unit' }), 'plug');
+ assert.equal(sh.lightKind({ type: 'Smart plug' }), 'plug');
+ assert.equal(sh.lightKind({ type: 'Dimmable light' }), 'light');
+ assert.equal(sh.lightKind({ type: 'Extended color light' }), 'light');
+ assert.equal(sh.lightKind({ type: 'Configuration tool' }), null);
+});
+
+test('sensorKind classifies switches/sensors and skips virtuals', () => {
+ assert.equal(sh.sensorKind({ type: 'ZHASwitch' }), 'switch');
+ assert.equal(sh.sensorKind({ type: 'ZHAPresence' }), 'sensor');
+ assert.equal(sh.sensorKind({ type: 'ZHAOpenClose' }), 'sensor');
+ assert.equal(sh.sensorKind({ type: 'ZHAWater' }), 'sensor');
+ assert.equal(sh.sensorKind({ type: 'CLIPPresence' }), null);
+ assert.equal(sh.sensorKind({ type: 'Daylight' }), null);
+});
+
+test('sensorReading covers open/water/temperature/lightlevel/button', () => {
+ assert.equal(sh.sensorReading({ state: { open: true } }).type, 'open');
+ assert.equal(sh.sensorReading({ state: { water: false } }).type, 'water');
+ assert.equal(sh.sensorReading({ state: { temperature: 2150 } }).value, 21.5);
+ const ll = sh.sensorReading({ state: { lightlevel: 12000, lux: 25 } });
+ assert.equal(ll.type, 'lightlevel'); assert.equal(ll.value, 25);
+ assert.equal(sh.sensorReading({ state: { buttonevent: 1002 } }).type, 'button');
+});
+
+test('syncGateway classifies plugs/switches and skips virtuals', 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({
+ '1': { name: 'Configuration tool 1', type: 'Configuration tool', state: { reachable: true } },
+ '2': { name: 'Poolpumpe', type: 'On/Off plug-in unit', uniqueid: 'p1', state: { on: false } },
+ '3': { name: 'Wintergarten', type: 'Color temperature light', uniqueid: 'l1', state: { on: true, bri: 100, ct: 300 } },
+ });
+ if (url.endsWith('/groups')) return jsonRes({});
+ if (url.endsWith('/sensors')) return jsonRes({
+ '1': { name: 'Daylight', type: 'Daylight', state: {} },
+ '14': { name: 'Fensterkontakt', type: 'ZHAOpenClose', uniqueid: 's1', state: { open: true } },
+ '16': { name: 'Smart Switch', type: 'ZHASwitch', uniqueid: 'sw1', state: { buttonevent: 1002 } },
+ });
+ return jsonRes({});
+ };
+ try {
+ const out = await sh.syncGateway(gw.id);
+ assert.equal(out.counts.lights, 1);
+ assert.equal(out.counts.plugs, 1);
+ assert.equal(out.counts.sensors, 1);
+ assert.equal(out.counts.switches, 1);
+ const res = dev.listResources(gw.id);
+ assert.ok(res.find((r) => r.kind === 'plug' && r.name === 'Poolpumpe'));
+ assert.ok(res.find((r) => r.kind === 'switch' && r.name === 'Smart Switch'));
+ assert.ok(res.find((r) => r.kind === 'sensor' && r.capabilities.reading === 'open'));
+ assert.ok(!res.find((r) => r.name === 'Configuration tool 1'));
+ assert.ok(!res.find((r) => r.name === 'Daylight'));
+ } 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;