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 `${body}`; + } + 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;