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
3 changes: 2 additions & 1 deletion public/css/smarthome.css
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
39 changes: 38 additions & 1 deletion public/js/smarthome.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<circle cx="12" cy="9" r="5"/><path d="M9 18h6M10 21h4"/>',
plug: '<path d="M9 2v5M15 2v5"/><path d="M7 7h10v3a5 5 0 0 1-10 0z"/><path d="M12 15v7"/>',
group: '<circle cx="8" cy="9" r="3"/><circle cx="16" cy="9" r="3"/><circle cx="12" cy="16" r="3"/>',
scene: '<path d="M12 3l2.4 4.9 5.4.8-3.9 3.8.9 5.4-4.8-2.5-4.8 2.5.9-5.4L4.2 8.7l5.4-.8z"/>',
switch: '<rect x="6" y="3" width="12" height="18" rx="2"/><circle cx="12" cy="8" r="1.6"/><path d="M10 14h4"/>',
'sensor.presence': '<circle cx="12" cy="12" r="2"/><path d="M7 7a7 7 0 0 0 0 10M17 7a7 7 0 0 1 0 10M4.5 4.5a11 11 0 0 0 0 15M19.5 4.5a11 11 0 0 1 0 15"/>',
'sensor.open': '<rect x="4" y="3" width="16" height="18" rx="1"/><path d="M14 3v18"/><circle cx="11.5" cy="12" r="1"/>',
'sensor.water': '<path d="M12 3s6 6.5 6 11a6 6 0 0 1-12 0c0-4.5 6-11 6-11z"/>',
'sensor.temperature': '<path d="M10 13V5a2 2 0 1 1 4 0v8a4 4 0 1 1-4 0z"/>',
'sensor.humidity': '<path d="M12 3s6 6.5 6 11a6 6 0 0 1-12 0c0-4.5 6-11 6-11z"/><path d="M9 14a3 3 0 0 0 3 3"/>',
'sensor.lightlevel': '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M5 5l1.5 1.5M17.5 17.5L19 19M5 19l1.5-1.5M17.5 6.5L19 5"/>',
'sensor.button': '<rect x="6" y="3" width="12" height="18" rx="2"/><circle cx="12" cy="8" r="1.6"/>',
'sensor.unknown': '<circle cx="12" cy="12" r="8"/><path d="M12 8v4M12 16h.01"/>',
};
function iconSvg(r) {
const body = ICONS[catKey(r)] || ICONS['sensor.unknown'];
return `<svg class="sh-ico" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">${body}</svg>`;
}

function cardShell(r) {
const el = document.createElement('div');
el.className = 'sh-card';
el.innerHTML = `<div class="sh-name">${esc(r.name || '')}</div><div class="sh-sub">${esc(r.kind)}</div>`;
el.innerHTML = `<div class="sh-name">${iconSvg(r)}<span>${esc(r.name || '')}</span></div><div class="sh-sub">${esc(subLabel(r))}</div>`;
return el;
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 = `<div class="sh-empty">${T('smarthome.empty')}</div>`; return; }
Expand Down
15 changes: 15 additions & 0 deletions src/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
40 changes: 32 additions & 8 deletions src/services/smarthome/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 */ }

Expand All @@ -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 */ }

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
};
15 changes: 15 additions & 0 deletions templates/aurora/layout.njk
Original file line number Diff line number Diff line change
Expand Up @@ -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 }},
Expand Down
15 changes: 15 additions & 0 deletions templates/default/layout.njk
Original file line number Diff line number Diff line change
Expand Up @@ -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 }},
Expand Down
15 changes: 15 additions & 0 deletions templates/pro/layout.njk
Original file line number Diff line number Diff line change
Expand Up @@ -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 }},
Expand Down
61 changes: 61 additions & 0 deletions tests/smarthome_orchestrator.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading