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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## [Unreleased]

### Features
- TP3 — logic chains → native deCONZ rules: admin-defined WENN/DANN rules translated to native `/rules` (+ `/schedules` + CLIP sensors on delay chains) that run autonomously on the bridge; delay modes ignore/reset/cancel, rule builder UI (3 themes), gateway rule-count + limit warning, boot-time re-sync of unsynced rules

---

## [1.112.0] — 2026-06-29

### Features
Expand Down
32 changes: 32 additions & 0 deletions public/css/smarthome.css
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,35 @@ input[type=range].sh-bri::-webkit-slider-thumb{-webkit-appearance:none;width:19p
.sh-owner-row input{width:16px;height:16px}
.sh-owner-btn{margin-top:10px;font-size:12px;color:var(--accent,#8b9cff);background:none;border:none;cursor:pointer;padding:0;display:flex;align-items:center;gap:6px}
.sh-owner-chips{font-size:12px;color:var(--muted,#90a1b3);margin-top:6px}

/* ── Logikketten (rules subpage) — client-rendered, theme-agnostic via tokens ── */
.banner{padding:11px 15px;margin-bottom:16px;border-radius:12px;font-size:13px;font-weight:600;background:var(--surface-2,#16212e);border:1px solid var(--line,rgba(255,255,255,.08));color:var(--muted,#90a1b3)}
.banner-warn{background:rgba(245,196,81,.12);border-color:rgba(245,196,81,.35);color:var(--amber,#f5c451)}
.rule-list{display:flex;flex-direction:column;gap:12px}
.rule-card{display:flex;align-items:center;gap:14px;padding:15px 16px}
.rule-orphaned{opacity:.65}
.rule-ic{width:38px;height:38px;border-radius:11px;display:grid;place-items:center;flex:0 0 auto;background:var(--surface-3,#1b2836);color:var(--accent,#8b9cff)}
.rule-main{flex:1;min-width:0}
.rule-name{font-weight:700;font-size:15px}
.rule-flow{font-size:12.5px;color:var(--muted,#90a1b3);margin-top:4px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.rule-warn{font-size:11.5px;font-weight:600;color:var(--amber,#f5c451);margin-top:6px}
.rule-acts{display:flex;align-items:center;gap:6px;flex:0 0 auto}
.pill{display:inline-flex;align-items:center;gap:5px;padding:3px 9px;border-radius:999px;font-size:11.5px;font-weight:600}
.pill-when{background:rgba(139,156,255,.14);color:var(--accent,#8b9cff)}
.pill-then{background:rgba(245,196,81,.14);color:var(--amber,#f5c451)}
.pill-time{background:var(--surface-3,#1b2836);color:var(--muted,#90a1b3);border:1px solid var(--line,rgba(255,255,255,.08))}
.arrow{color:var(--faint,#5f6f7e)}

/* Builder modal blocks */
.builder-block{border:1px solid var(--line,rgba(255,255,255,.08));border-radius:14px;padding:14px;margin-bottom:14px;background:var(--surface,#111a24)}
.bb-head{display:flex;align-items:center;gap:9px;margin-bottom:12px}
.bb-badge{font-weight:700;font-size:11px;letter-spacing:.06em;padding:3px 10px;border-radius:7px}
.bb-when{background:rgba(139,156,255,.14);color:var(--accent,#8b9cff)}
.bb-then{background:rgba(245,196,81,.14);color:var(--amber,#f5c451)}
.shr-row{display:flex;align-items:center;gap:8px;margin-bottom:9px}
.shr-row .shr-res{flex:1 1 40%;min-width:0}
.shr-fields{flex:1 1 55%;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.shr-fields .form-select,.shr-fields .form-input{margin:0;flex:1 1 auto;min-width:80px}
.shr-fields input[type=color]{width:44px;height:36px;padding:2px;border:1px solid var(--line-2,rgba(255,255,255,.14));border-radius:9px;background:var(--surface,#111a24);cursor:pointer;flex:0 0 auto}
.row-x{width:34px;height:34px;border-radius:9px;border:1px solid var(--line,rgba(255,255,255,.08));background:var(--surface-3,#1b2836);color:var(--faint,#5f6f7e);font-size:18px;line-height:1;display:grid;place-items:center;cursor:pointer;flex:0 0 auto}
.row-x:hover{color:var(--coral,#ff7a59);border-color:var(--coral,#ff7a59)}
334 changes: 334 additions & 0 deletions public/js/smarthome-rules.js

Large diffs are not rendered by default.

40 changes: 40 additions & 0 deletions src/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -2092,6 +2092,9 @@
"error.smarthome.owner_unknown_user": "Unbekannter Nutzer in der Besitzerliste",
"error.smarthome.user_ids_required": "userIds muss ein Array sein",
"error.smarthome.resource_not_found": "Smart-Home-Ressource nicht gefunden",
"error.smarthome.rule_invalid": "Ungültige Regel-Definition",
"error.smarthome.rule_not_found": "Regel nicht gefunden",
"error.smarthome.rule_limit_reached": "Das Regellimit des Gateways ist erreicht",
"smarthome.title": "Smart Home",
"smarthome.subtitle": "Phoscon/deCONZ-Lichter und -Sensoren einbinden, Haushaltsmitgliedern zuweisen und Logikketten erstellen.",
"smarthome.eyebrow": "System · Smart Home",
Expand Down Expand Up @@ -2145,6 +2148,43 @@
"smarthome.owners.none": "Niemand zugewiesen",
"smarthome.owners.save": "Speichern",
"smarthome.owners.search": "Nutzer suchen…",
"smarthome.rules.title": "Logikketten",
"smarthome.rules.subtitle": "Automatisierungsregeln für Ihre Smart-Home-Geräte",
"smarthome.rules.back": "Zurück zum Inventar",
"smarthome.rules.new": "Neue Regel",
"smarthome.rules.load_count": "Gateway-Gesamtcount laden",
"smarthome.rules.when": "WENN",
"smarthome.rules.then": "DANN",
"smarthome.rules.add_condition": "Bedingung hinzufügen",
"smarthome.rules.add_action": "Aktion hinzufügen",
"smarthome.rules.time_window": "Nur in Zeitfenster",
"smarthome.rules.delay": "Verzögerung (Min.)",
"smarthome.rules.on_retrigger": "Bei erneuter Auslösung",
"smarthome.rules.retrigger_ignore": "ignorieren",
"smarthome.rules.retrigger_reset": "Timer zurücksetzen",
"smarthome.rules.retrigger_cancel": "abbrechen",
"smarthome.rules.multi_hint": "Die Regel feuert, wenn einer der Auslöser eintrifft UND alle anderen im angegebenen Zustand sind.",
"smarthome.rules.save": "Regel speichern",
"smarthome.rules.limit_warn": "Regellimit nähert sich",
"smarthome.rules.edit": "Bearbeiten",
"smarthome.rules.delete": "Löschen",
"smarthome.rules.orphaned_warn": "Gerät fehlt — Regel nur lesbar",
"smarthome.rules.cancel_unsupported_hint": "Diese Firmware unterstützt kein Abbrechen — nur Timer zurücksetzen",
"smarthome.rules.builder_title": "Regel bearbeiten",
"smarthome.rules.name_label": "Name",
"smarthome.rules.op_lt": "unter",
"smarthome.rules.op_gt": "über",
"smarthome.rules.act_on": "an",
"smarthome.rules.act_off": "aus",
"smarthome.rules.btn_short": "kurz",
"smarthome.rules.btn_long": "lang",
"smarthome.rules.btn_double": "doppelt",
"smarthome.rules.empty": "Noch keine Regeln",
"smarthome.rules.name_required": "Bitte einen Namen angeben",
"smarthome.rules.confirm_delete": "Regel wirklich löschen?",
"smarthome.rules.count_total": "Regeln insgesamt auf dem Gateway",
"smarthome.rules.count_gc": "Von GateControl verwaltet",
"smarthome.rules.count_external": "Externe Regeln",
"portal.midea.fan": "Lüfter",
"portal.midea.fan_auto": "Auto",
"portal.midea.fan_silent": "Silent",
Expand Down
40 changes: 40 additions & 0 deletions src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2148,6 +2148,9 @@
"error.smarthome.owner_unknown_user": "Unknown user in owner list",
"error.smarthome.user_ids_required": "userIds must be an array",
"error.smarthome.resource_not_found": "Smart Home resource not found",
"error.smarthome.rule_invalid": "Invalid rule definition",
"error.smarthome.rule_not_found": "Rule not found",
"error.smarthome.rule_limit_reached": "The gateway's rule limit is reached",
"smarthome.title": "Smart Home",
"smarthome.subtitle": "Connect Phoscon/deCONZ lights and sensors, assign to household members, and create automation rules.",
"smarthome.eyebrow": "System · Smart Home",
Expand Down Expand Up @@ -2201,6 +2204,43 @@
"smarthome.owners.none": "None assigned",
"smarthome.owners.save": "Save",
"smarthome.owners.search": "Search users…",
"smarthome.rules.title": "Logic Chains",
"smarthome.rules.subtitle": "Define automation rules for your smart home devices",
"smarthome.rules.back": "Back to inventory",
"smarthome.rules.new": "New rule",
"smarthome.rules.load_count": "Load gateway total count",
"smarthome.rules.when": "WHEN",
"smarthome.rules.then": "THEN",
"smarthome.rules.add_condition": "Add condition",
"smarthome.rules.add_action": "Add action",
"smarthome.rules.time_window": "Time window (optional)",
"smarthome.rules.delay": "Delay (min.)",
"smarthome.rules.on_retrigger": "On re-trigger",
"smarthome.rules.retrigger_ignore": "ignore",
"smarthome.rules.retrigger_reset": "reset timer",
"smarthome.rules.retrigger_cancel": "cancel",
"smarthome.rules.multi_hint": "The rule fires when any of the triggers occurs AND all others are in the specified state.",
"smarthome.rules.save": "Save rule",
"smarthome.rules.limit_warn": "Rule limit approaching",
"smarthome.rules.edit": "Edit",
"smarthome.rules.delete": "Delete",
"smarthome.rules.orphaned_warn": "Device missing — rule is read-only",
"smarthome.rules.cancel_unsupported_hint": "This firmware does not support cancel — use reset instead",
"smarthome.rules.builder_title": "Edit rule",
"smarthome.rules.name_label": "Name",
"smarthome.rules.op_lt": "below",
"smarthome.rules.op_gt": "above",
"smarthome.rules.act_on": "On",
"smarthome.rules.act_off": "Off",
"smarthome.rules.btn_short": "Short",
"smarthome.rules.btn_long": "Long",
"smarthome.rules.btn_double": "Double",
"smarthome.rules.empty": "No rules yet",
"smarthome.rules.name_required": "Please enter a name",
"smarthome.rules.confirm_delete": "Delete this rule?",
"smarthome.rules.count_total": "Total rules on gateway",
"smarthome.rules.count_gc": "Managed by GateControl",
"smarthome.rules.count_external": "External rules",
"portal.midea.fan": "Fan",
"portal.midea.fan_auto": "Auto",
"portal.midea.fan_silent": "Silent",
Expand Down
52 changes: 51 additions & 1 deletion src/routes/api/smarthome.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,17 @@ const { requireFeature } = require('../../middleware/license');
const users = require('../../services/users');
const smarthome = require('../../services/smarthome');
const smarthomeOwners = require('../../services/smarthome/smarthomeOwners');
const smarthomeRules = require('../../services/smarthome/smarthomeRules');
const deconzCaps = require('../../services/smarthome/deconzCapabilities');

const router = Router();

const RULE_ERR_I18N = {
SMARTHOME_RULE_INVALID: 'error.smarthome.rule_invalid',
SMARTHOME_RULE_NOT_FOUND: 'error.smarthome.rule_not_found',
DECONZ_RULE_LIMIT_REACHED: 'error.smarthome.rule_limit_reached',
};

// Admin-only: reject token auth, require an admin session.
router.use((req, res, next) => {
if (req.tokenAuth) return res.status(403).json({ ok: false, error: req.t('error.users.session_required') });
Expand All @@ -28,8 +36,12 @@ function wrap(fn) {
err.code === 'SMARTHOME_NO_ROUTE' ? 400 :
err.code === 'SMARTHOME_NO_API_KEY' ? 409 :
err.code === 'SMARTHOME_GATEWAY_NOT_FOUND' ? 404 :
err.code === 'SMARTHOME_RULE_INVALID' ? 400 :
err.code === 'DECONZ_RULE_LIMIT_REACHED' ? 409 :
err.code === 'SMARTHOME_RULE_NOT_FOUND' ? 404 :
/not found/i.test(err.message) ? 404 : 502;
res.status(status).json({ ok: false, error: err.message, code: err.code || null });
const key = RULE_ERR_I18N[err.code];
res.status(status).json({ ok: false, error: key && req.t ? req.t(key) : err.message, code: err.code || null });
}
};
}
Expand Down Expand Up @@ -92,4 +104,42 @@ router.post('/resources/:id/state', wrap(async (req, res) => {
res.json({ ok: true });
}));

function reqGatewayId(req) { const id = Number(req.query.gateway_id); if (!Number.isInteger(id) || id < 1) { const e = new Error('missing gateway_id'); e.code = 'SMARTHOME_RULE_INVALID'; throw e; } return id; }

router.get('/rules', wrap(async (req, res) => {
const gatewayId = reqGatewayId(req);
const list = smarthomeRules.list(gatewayId);
res.json({
rules: list,
gc_rule_count: list.length,
limit_warn: smarthomeRules.limitWarn(list.length),
cancelSupported: deconzCaps.cancelSupported,
});
}));

router.get('/rules/gateway-count', wrap(async (req, res) => {
res.json(await smarthomeRules.gatewayRuleCount(reqGatewayId(req)));
}));

router.post('/rules', wrap(async (req, res) => {
const { gateway_id, name, definition } = req.body || {};
if (!gateway_id || !name || !definition) { const e = new Error('missing fields'); e.code = 'SMARTHOME_RULE_INVALID'; throw e; }
res.json({ rule: await smarthomeRules.create(Number(gateway_id), String(name), definition) });
}));

router.put('/rules/:id', wrap(async (req, res) => {
const { name, definition } = req.body || {};
if (!name || !definition) { const e = new Error('missing fields'); e.code = 'SMARTHOME_RULE_INVALID'; throw e; }
res.json({ rule: await smarthomeRules.update(Number(req.params.id), String(name), definition) });
}));

router.delete('/rules/:id', wrap(async (req, res) => {
await smarthomeRules.remove(Number(req.params.id));
res.json({ ok: true });
}));

router.post('/rules/:id/enabled', wrap(async (req, res) => {
res.json({ rule: await smarthomeRules.setEnabled(Number(req.params.id), !!(req.body && req.body.enabled)) });
}));

module.exports = router;
1 change: 1 addition & 0 deletions src/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ const pages = [
{ path: '/pihole', template: 'pihole', titleKey: 'pihole.title' },
{ path: '/midea', template: 'midea', titleKey: 'midea.title' },
{ path: '/smarthome', template: 'smarthome', titleKey: 'smarthome.title' },
{ path: '/smarthome/rules', template: 'smarthome-rules', titleKey: 'smarthome.rules.title' },
{ path: '/gateway-pools', template: 'gateway-pools', titleKey: 'gateway_pools.title' },
{ path: '/gateways', template: 'gateways', titleKey: 'nav.gateways' },
];
Expand Down
2 changes: 2 additions & 0 deletions src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,8 @@ async function start() {
// Smart Home (deCONZ) poll loop — best-effort; no-op without license or gateways.
try { require('./services/smarthome').startPolling(); }
catch (err) { logger.warn({ err: err.message }, 'smarthome start failed'); }
// Re-push any rules that lost their deconz_rule_id (e.g. gateway wiped between restarts).
require('./services/smarthome/smarthomeRules').resyncPending().catch((e) => logger.warn({ err: e.message }, 'smarthome rule resync failed'));

// Internal DNS — rebuild the addn-hosts file on boot so route domains
// resolve to the gateway immediately. Without this, the file only gets
Expand Down
47 changes: 47 additions & 0 deletions src/services/smarthome/deconzCapabilities.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
'use strict';
// Aus Task-0-Live-Spike gegen das echte Gateway ermittelt (Phoscon 2.24.2 / apiversion 1.16.0).
// Beleg + rohe HTTP-Traces: docs/superpowers/specs/2026-06-30-smarthome-tp3-spike.md.

// deCONZ-buttonevent-Kodierung (live über RWL021 + lumi.sensor_switch verifiziert):
// buttonevent = button * 1000 + actionOffset
const buttonActionOffset = { press: 0, hold: 1, short: 2, long: 3, double: 4 };

// Beobachtete Modelle. `hasDouble` gated die UI (RWL021 = Hue-Dimmer ohne Doppelklick).
const buttonModels = {
RWL021: { buttons: [1, 2, 3, 4], hasDouble: false }, // Hue 4-Tasten-Dimmer
'lumi.sensor_switch': { buttons: [1], hasDouble: true }, // Aqara Einzeltaste (1002/1003/1004)
};

module.exports = {
buttonActionOffset,
buttonModels,
// Liefert den buttonevent-Code für (modelid, button, action) oder null bei unbekannter Aktion.
// modelid dient nur der UI-Gating-Info; der Code folgt der einheitlichen Formel (Spike Step 2).
buttonCode(modelid, button, action) {
const off = buttonActionOffset[action];
if (off == null) return null;
const b = Number(button);
return (Number.isInteger(b) && b > 0 ? b : 1) * 1000 + off;
},

// Daylight-Trigger auf dem booleschen `daylight`-Feld des Daylight-Sensors (Spike Step 3):
// sunrise → daylight wird true, sunset → daylight wird false. Kantengetriggert über lastupdated dx
// (fügt die Übersetzung hinzu). Binär-invertierbar → cancel-tauglich.
daylight: {
sunrise: { field: 'daylight', op: 'eq', value: 'true' },
sunset: { field: 'daylight', op: 'eq', value: 'false' },
},

// Spike Step 5: eine Regel kann ein Schedule via Action an-/abschalten und ein CLIP-Flag setzen →
// sauberes Storno bestätigt. CLIP-Sensoren sind per DELETE /sensors/:id löschbar.
cancelSupported: true,
clipDeletable: true,

// Spike Step 4: Schedule-command.address MUSS mit /api/<apiKey> prefixiert sein (Rule-Actions NICHT).
// Der Service injiziert das Präfix beim Materialisieren des Schedule-Objekts.
scheduleCommandNeedsApiPrefix: true,

// Spike Step 6: Limit nicht provoziert (28 Regeln, weit darunter; /config meldet keine Kapazität).
// errorCodes deckt beide Meldeformen ab — HTTP-Status UND 200-Body-Error-Array (DECONZ_ERR_<type>).
ruleLimit: { warnAtGcRules: 38 /* =150/4 */, errorCodes: ['DECONZ_HTTP_503', 'DECONZ_HTTP_507', 'DECONZ_ERR_601'] },
};
14 changes: 14 additions & 0 deletions src/services/smarthome/deconzClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ function createClient({ baseUrl, apiKey, headers: extraHeaders = {} } = {}) {
return arr;
}

function firstId(arr) {
const ok = Array.isArray(arr) ? arr.find((x) => x && x.success && x.success.id != null) : null;
return ok ? String(ok.success.id) : null;
}

async function acquireApiKey() {
const out = assertNoError(await raw('/api', { method: 'POST', body: { devicetype: 'GateControl' } }));
const ok = Array.isArray(out) ? out.find((x) => x && x.success) : null;
Expand All @@ -69,6 +74,15 @@ function createClient({ baseUrl, apiKey, headers: extraHeaders = {} } = {}) {
setLightState: (id, patch) => raw(api(`/lights/${id}/state`), { method: 'PUT', body: toDeconzBody(patch) }).then(assertNoError),
setGroupState: (id, patch) => raw(api(`/groups/${id}/action`), { method: 'PUT', body: toDeconzBody(patch) }).then(assertNoError),
recallScene: (groupId, sceneId) => raw(api(`/groups/${groupId}/scenes/${sceneId}/recall`), { method: 'PUT', body: {} }).then(assertNoError),
getRules: () => raw(api('/rules')),
createRule: (rule) => raw(api('/rules'), { method: 'POST', body: rule }).then(assertNoError).then(firstId),
updateRule: (id, rule) => raw(api(`/rules/${id}`), { method: 'PUT', body: rule }).then(assertNoError),
deleteRule: (id) => raw(api(`/rules/${id}`), { method: 'DELETE' }),
createSchedule: (sched) => raw(api('/schedules'), { method: 'POST', body: sched }).then(assertNoError).then(firstId),
deleteSchedule: (id) => raw(api(`/schedules/${id}`), { method: 'DELETE' }),
createClipSensor: (sensor) => raw(api('/sensors'), { method: 'POST', body: sensor }).then(assertNoError).then(firstId),
setClipSensorState: (id, state) => raw(api(`/sensors/${id}/state`), { method: 'PUT', body: state }).then(assertNoError),
deleteClipSensor: (id) => raw(api(`/sensors/${id}`), { method: 'DELETE' }), // CLIP-Sensoren leben unter /sensors
};
}

Expand Down
Loading
Loading