diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9813e1f4..b0298bd1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/public/css/smarthome.css b/public/css/smarthome.css
index a75d3a13..899e6eb5 100644
--- a/public/css/smarthome.css
+++ b/public/css/smarthome.css
@@ -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)}
diff --git a/public/js/smarthome-rules.js b/public/js/smarthome-rules.js
new file mode 100644
index 00000000..7e5ff793
--- /dev/null
+++ b/public/js/smarthome-rules.js
@@ -0,0 +1,334 @@
+'use strict';
+// Smart-Home Logikketten (rules) subpage. Mirrors public/js/smarthome.js idioms:
+// IIFE, $/esc/T/api helpers, CSRF header on mutations, addEventListener only (CSP
+// blocks inline handlers: script-src-attr 'none').
+(function () {
+ const API = '/api/v1/smarthome';
+ const T = (k) => (window.GC && GC.t && GC.t[k]) || k;
+ const $ = (s, r = document) => r.querySelector(s);
+ const $$ = (s, r = document) => [...r.querySelectorAll(s)];
+ function esc(s) {
+ return String(s == null ? '' : s).replace(/[&<>"']/g, (c) =>
+ ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+ }
+
+ // api() mirrors smarthome.js but attaches res.status so save() can branch 409 (limit) vs 400 (invalid).
+ async function api(path, opts) {
+ const csrf = window.GC && GC.csrfToken;
+ const res = await fetch(API + path, {
+ headers: { 'Content-Type': 'application/json', ...(csrf ? { 'x-csrf-token': csrf } : {}) },
+ ...opts,
+ });
+ if (!res.ok) { const b = await res.json().catch(() => ({})); const e = new Error(b.error || res.status); e.status = res.status; throw e; }
+ return res.json();
+ }
+
+ const RULE_ICON = '';
+
+ // Sensor reading → trigger kind understood by the server (rulesTranslate). humidity/unknown have no rule support.
+ const TRIG = { presence: 'motion', open: 'contact', water: 'water', temperature: 'temperature', lightlevel: 'lux', button: 'button' };
+ const EVENT_KINDS = ['motion', 'contact', 'water', 'button']; // edge-triggered (count for multi-hint)
+
+ let gatewayId = null;
+ let resources = [];
+ let resById = {};
+ let cancelSupported = true;
+ let editingId = null;
+
+ function resourceName(id) { const r = resById[id]; return r ? (r.name || ('#' + id)) : ('#' + id); }
+ function trigType(r) { if (!r) return null; if (r.kind === 'switch') return 'button'; if (r.kind === 'sensor') return TRIG[r.capabilities && r.capabilities.reading] || null; return null; }
+ function triggerResources() { return resources.filter((r) => r.enabled && trigType(r)); }
+ function actionResources() { return resources.filter((r) => r.enabled && ['light', 'plug', 'group', 'scene'].includes(r.kind)); }
+
+ // sRGB hex → CIE xy (deCONZ colour lights take xy; hs/xy caps).
+ function hexToXy(hex) {
+ const n = parseInt(hex.slice(1), 16);
+ const g = (c) => (c > 0.04045 ? Math.pow((c + 0.055) / 1.055, 2.4) : c / 12.92);
+ const r = g(((n >> 16) & 255) / 255), gg = g(((n >> 8) & 255) / 255), b = g((n & 255) / 255);
+ const X = r * 0.4124 + gg * 0.3576 + b * 0.1805, Y = r * 0.2126 + gg * 0.7152 + b * 0.0722, Z = r * 0.0193 + gg * 0.1192 + b * 0.9505;
+ const s = X + Y + Z || 1; return [+(X / s).toFixed(4), +(Y / s).toFixed(4)];
+ }
+
+ // ── Flow line (read-only card summary) ──────────────────────────────────
+ function trigLabel(t) {
+ const n = resourceName(t.resourceId);
+ switch (t.kind) {
+ case 'motion': return `${n}: ${T(t.event === 'detected' ? 'smarthome.val.motion' : 'smarthome.val.idle')}`;
+ case 'contact': return `${n}: ${T(t.event === 'open' ? 'smarthome.val.open' : 'smarthome.val.closed')}`;
+ case 'water': return `${n}: ${T(t.event === 'wet' ? 'smarthome.val.wet' : 'smarthome.val.dry')}`;
+ case 'temperature': return `${n} ${t.op === 'lt' ? '<' : '>'} ${t.value} °C`;
+ case 'lux': return `${n} ${t.op === 'lt' ? '<' : '>'} ${t.value} lx`;
+ case 'button': return `${n}: ${T('smarthome.rules.btn_' + (t.action || 'short'))}`;
+ default: return n;
+ }
+ }
+ function actLabel(a) {
+ const n = resourceName(a.resourceId);
+ if (a.kind === 'scene') return `${n}: ${T('smarthome.activate')}`;
+ const s = a.set || {}; let x = n;
+ if (s.on === true) x += ': ' + T('smarthome.rules.act_on');
+ else if (s.on === false) x += ': ' + T('smarthome.rules.act_off');
+ if ('bri' in s) x += ' · ' + s.bri + ' %';
+ if ('color' in s) x += ' · ' + T('smarthome.color');
+ return x;
+ }
+ // Returns HTML; every user-origin field (resource names in labels) is wrapped in esc().
+ function flowText(def) {
+ def = def || {};
+ const parts = [];
+ (def.triggers || []).forEach((t) => parts.push(`${esc(trigLabel(t))}`));
+ parts.push('→');
+ (def.actions || []).forEach((a) => parts.push(`${esc(actLabel(a))}`));
+ if (def.timeWindow && def.timeWindow.from && def.timeWindow.to) parts.push(`${esc(def.timeWindow.from)}–${esc(def.timeWindow.to)}`);
+ if (def.delay && def.delay.minutes) parts.push(`+${esc(def.delay.minutes)} min`);
+ return parts.join('');
+ }
+
+ function ruleCard(r) {
+ const el = document.createElement('div');
+ el.className = 'card rule-card' + (r.orphaned ? ' rule-orphaned' : '');
+ // Single innerHTML line: user fields (esc(r.name)) + flowText (esc internally) + optional warn (esc).
+ el.innerHTML = `
${RULE_ICON}
${esc(r.name || '')}
${flowText(r.definition)}
${r.orphaned ? `
${esc(T('smarthome.rules.orphaned_warn'))}
` : ''}
`;
+ const acts = document.createElement('div'); acts.className = 'rule-acts';
+ if (!r.orphaned) {
+ const edit = document.createElement('button'); edit.type = 'button'; edit.className = 'btn btn-sm btn-ghost'; edit.textContent = T('smarthome.rules.edit');
+ edit.addEventListener('click', () => openBuilder(r)); acts.appendChild(edit);
+ }
+ const del = document.createElement('button'); del.type = 'button'; del.className = 'btn btn-sm btn-ghost'; del.textContent = T('smarthome.rules.delete');
+ del.addEventListener('click', () => deleteRule(r)); acts.appendChild(del);
+ el.appendChild(acts);
+ const tog = document.createElement('div'); tog.className = 'sh-switch' + (r.enabled ? ' on' : ''); tog.appendChild(document.createElement('i'));
+ if (r.orphaned) { tog.style.opacity = '.4'; tog.style.pointerEvents = 'none'; } else tog.addEventListener('click', () => toggleRule(r, tog));
+ el.appendChild(tog);
+ return el;
+ }
+
+ function showLimit(msg) { const b = $('#shr-limit'); if (!b) return; if (msg) { b.textContent = msg; b.style.display = ''; } else { b.style.display = 'none'; } }
+
+ function emptyMsg(list, txt) { list.replaceChildren(); const em = document.createElement('div'); em.className = 'sh-empty'; em.textContent = txt; list.appendChild(em); }
+
+ async function loadRules() {
+ const list = $('#shr-list'); if (!list) return;
+ if (!gatewayId) {
+ const gw = await api('/gateways').catch(() => ({ gateways: [] }));
+ const g = (gw.gateways || []).find((x) => x.enabled) || (gw.gateways || [])[0];
+ gatewayId = g ? g.id : null;
+ }
+ if (!gatewayId) { emptyMsg(list, T('smarthome.empty')); return; }
+ // Resources power both the card name lookup and the builder dropdowns.
+ try { const rd = await api(`/resources?gateway_id=${gatewayId}`); resources = rd.resources || []; }
+ catch (_) { resources = []; }
+ resById = {}; resources.forEach((r) => { resById[r.id] = r; });
+ let data;
+ try { data = await api(`/rules?gateway_id=${gatewayId}`); }
+ catch (_) { emptyMsg(list, T('smarthome.load_error')); return; }
+ cancelSupported = data.cancelSupported !== false;
+ showLimit(data.limit_warn ? T('smarthome.rules.limit_warn') : null);
+ if (!data.rules.length) { emptyMsg(list, T('smarthome.rules.empty')); return; }
+ list.replaceChildren();
+ data.rules.forEach((r) => list.appendChild(ruleCard(r)));
+ }
+
+ async function toggleRule(r, el) {
+ const on = !r.enabled;
+ try { await api(`/rules/${r.id}/enabled`, { method: 'POST', body: JSON.stringify({ enabled: on }) }); r.enabled = on; el.classList.toggle('on', on); }
+ catch (e) { alert(e.message); }
+ }
+ async function deleteRule(r) {
+ if (!confirm(T('smarthome.rules.confirm_delete'))) return;
+ try { await api(`/rules/${r.id}`, { method: 'DELETE' }); await loadRules(); }
+ catch (e) { alert(e.message); }
+ }
+
+ // ── Builder ─────────────────────────────────────────────────────────────
+ function makeSelect(cls, opts) {
+ const s = document.createElement('select'); s.className = 'form-select ' + cls;
+ opts.forEach(([v, l]) => { const o = document.createElement('option'); o.value = v; o.textContent = l; s.appendChild(o); });
+ return s;
+ }
+ function makeNum(cls, ph) { const i = document.createElement('input'); i.type = 'number'; i.className = 'form-input ' + cls; if (ph) i.placeholder = ph; return i; }
+
+ function renderTriggerFields(fields, r) {
+ fields.replaceChildren(); if (!r) return;
+ const type = trigType(r);
+ if (type === 'motion') fields.appendChild(makeSelect('shr-f-event', [['detected', T('smarthome.val.motion')], ['ended', T('smarthome.val.idle')]]));
+ else if (type === 'contact') fields.appendChild(makeSelect('shr-f-event', [['open', T('smarthome.val.open')], ['closed', T('smarthome.val.closed')]]));
+ else if (type === 'water') fields.appendChild(makeSelect('shr-f-event', [['wet', T('smarthome.val.wet')], ['dry', T('smarthome.val.dry')]]));
+ else if (type === 'temperature' || type === 'lux') {
+ fields.appendChild(makeSelect('shr-f-op', [['lt', T('smarthome.rules.op_lt')], ['gt', T('smarthome.rules.op_gt')]]));
+ fields.appendChild(makeNum('shr-f-val', type === 'temperature' ? '°C' : 'lx'));
+ } else if (type === 'button') {
+ fields.appendChild(makeSelect('shr-f-btn', [['1', '1'], ['2', '2'], ['3', '3'], ['4', '4']]));
+ fields.appendChild(makeSelect('shr-f-act', [['short', T('smarthome.rules.btn_short')], ['long', T('smarthome.rules.btn_long')], ['double', T('smarthome.rules.btn_double')]]));
+ }
+ }
+ function renderActionFields(fields, r) {
+ fields.replaceChildren(); if (!r || r.kind === 'scene') return;
+ const caps = r.capabilities || {};
+ const opts = [['on', T('smarthome.rules.act_on')], ['off', T('smarthome.rules.act_off')]];
+ if (caps.bri) opts.push(['bri', T('smarthome.brightness')]);
+ if (caps.color) opts.push(['color', T('smarthome.color')]);
+ const op = makeSelect('shr-f-aop', opts); fields.appendChild(op);
+ const extra = document.createElement('span'); extra.className = 'shr-extra'; fields.appendChild(extra);
+ function renderExtra() {
+ extra.replaceChildren();
+ if (op.value === 'bri') { const b = makeNum('shr-f-bri', '%'); b.min = 0; b.max = 100; b.value = 100; extra.appendChild(b); }
+ else if (op.value === 'color') {
+ if (caps.color === 'ct') { const n = makeNum('shr-f-ct', 'ct'); n.min = 153; n.max = 500; n.value = 300; extra.appendChild(n); }
+ else { const c = document.createElement('input'); c.type = 'color'; c.className = 'shr-f-color'; c.value = '#ffd27a'; extra.appendChild(c); }
+ }
+ }
+ op.addEventListener('change', renderExtra); renderExtra();
+ }
+
+ function rowSelect(resList) {
+ const sel = document.createElement('select'); sel.className = 'form-select shr-res';
+ resList.forEach((r) => { const o = document.createElement('option'); o.value = String(r.id); o.textContent = r.name || ('#' + r.id); sel.appendChild(o); });
+ return sel;
+ }
+ function removeBtn(row, after) {
+ const rm = document.createElement('button'); rm.type = 'button'; rm.className = 'row-x'; rm.textContent = '×'; rm.title = T('smarthome.rules.delete');
+ rm.addEventListener('click', () => { row.remove(); if (after) after(); });
+ return rm;
+ }
+
+ function addTriggerRow(pre) {
+ const list = $('#shr-when'); const sel = rowSelect(triggerResources());
+ const row = document.createElement('div'); row.className = 'shr-row';
+ const fields = document.createElement('div'); fields.className = 'shr-fields';
+ row.appendChild(sel); row.appendChild(fields); row.appendChild(removeBtn(row, updateMultiHint));
+ const setRes = () => { row._res = resById[Number(sel.value)]; renderTriggerFields(fields, row._res); updateMultiHint(); };
+ sel.addEventListener('change', setRes);
+ if (pre && pre.resourceId != null) sel.value = String(pre.resourceId);
+ list.appendChild(row); setRes();
+ if (pre) { const set = (c, v) => { const el = fields.querySelector('.' + c); if (el && v != null) el.value = String(v); }; set('shr-f-event', pre.event); set('shr-f-op', pre.op); set('shr-f-val', pre.value); set('shr-f-btn', pre.button); set('shr-f-act', pre.action); }
+ }
+ function addActionRow(pre) {
+ const list = $('#shr-then'); const sel = rowSelect(actionResources());
+ const row = document.createElement('div'); row.className = 'shr-row';
+ const fields = document.createElement('div'); fields.className = 'shr-fields';
+ row.appendChild(sel); row.appendChild(fields); row.appendChild(removeBtn(row));
+ const setRes = () => { row._res = resById[Number(sel.value)]; renderActionFields(fields, row._res); };
+ sel.addEventListener('change', setRes);
+ if (pre && pre.resourceId != null) sel.value = String(pre.resourceId);
+ list.appendChild(row); setRes();
+ if (pre && pre.set) {
+ const op = fields.querySelector('.shr-f-aop');
+ if (op) {
+ const s = pre.set; let v = 'on';
+ if ('bri' in s) v = 'bri'; else if ('color' in s) v = 'color'; else if (s.on === false) v = 'off';
+ op.value = v; op.dispatchEvent(new Event('change'));
+ if (v === 'bri') { const b = fields.querySelector('.shr-f-bri'); if (b) b.value = s.bri; }
+ else if (v === 'color' && s.color && s.color.ct != null) { const ct = fields.querySelector('.shr-f-ct'); if (ct) ct.value = s.color.ct; }
+ }
+ }
+ }
+
+ function updateMultiHint() {
+ const n = $$('#shr-when .shr-row').filter((row) => EVENT_KINDS.includes(trigType(row._res))).length;
+ const h = $('#shr-multi-hint'); if (h) h.style.display = n > 1 ? '' : 'none';
+ }
+
+ // deCONZ firmware without cancel-support downgrades to reset server-side; disable + hint the option.
+ function applyCancelSupport() {
+ const sel = $('#shr-onretrigger'); if (!sel) return;
+ const opt = sel.querySelector('option[value="cancel"]'); if (!opt) return;
+ opt.disabled = !cancelSupported;
+ opt.title = cancelSupported ? '' : T('smarthome.rules.cancel_unsupported_hint');
+ if (!cancelSupported && sel.value === 'cancel') sel.value = 'reset';
+ }
+
+ function openBuilder(rule) {
+ editingId = rule ? rule.id : null;
+ $('#shr-name').value = rule ? (rule.name || '') : '';
+ $('#shr-from').value = ''; $('#shr-to').value = ''; $('#shr-delay-min').value = ''; $('#shr-onretrigger').value = 'ignore';
+ $('#shr-when').replaceChildren(); $('#shr-then').replaceChildren();
+ applyCancelSupport();
+ const def = rule && rule.definition;
+ if (def) {
+ (def.triggers || []).forEach((t) => addTriggerRow(t));
+ (def.actions || []).forEach((a) => addActionRow(a));
+ if (def.timeWindow) { $('#shr-from').value = def.timeWindow.from || ''; $('#shr-to').value = def.timeWindow.to || ''; }
+ if (def.delay) { $('#shr-delay-min').value = def.delay.minutes || ''; $('#shr-onretrigger').value = def.delay.onRetrigger || 'ignore'; }
+ } else { addTriggerRow(); addActionRow(); }
+ applyCancelSupport();
+ updateMultiHint();
+ const m = $('#shr-modal'); if (m) m.style.display = 'flex';
+ }
+ function closeBuilder() { const m = $('#shr-modal'); if (m) m.style.display = 'none'; }
+
+ function readTrigger(row) {
+ const r = row._res; if (!r) return null; const type = trigType(r);
+ const g = (c) => row.querySelector('.' + c);
+ if (type === 'motion' || type === 'contact' || type === 'water') return { kind: type, resourceId: r.id, event: g('shr-f-event').value };
+ if (type === 'temperature' || type === 'lux') return { kind: type, resourceId: r.id, op: g('shr-f-op').value, value: Number(g('shr-f-val').value) };
+ if (type === 'button') return { kind: 'button', resourceId: r.id, button: Number(g('shr-f-btn').value), action: g('shr-f-act').value };
+ return null;
+ }
+ function readAction(row) {
+ const r = row._res; if (!r) return null;
+ if (r.kind === 'scene') return { kind: 'scene', resourceId: r.id };
+ const opEl = row.querySelector('.shr-f-aop'); const op = opEl ? opEl.value : 'on';
+ const a = { kind: r.kind, resourceId: r.id, set: {} };
+ if (op === 'on') a.set.on = true;
+ else if (op === 'off') a.set.on = false;
+ else if (op === 'bri') a.set.bri = Number(row.querySelector('.shr-f-bri').value);
+ else if (op === 'color') {
+ const caps = r.capabilities || {};
+ if (caps.color === 'ct') a.set.color = { ct: Number(row.querySelector('.shr-f-ct').value) };
+ else a.set.color = { xy: hexToXy(row.querySelector('.shr-f-color').value) };
+ }
+ return a;
+ }
+ function buildDefinition() {
+ const def = { triggers: [], actions: [] };
+ $$('#shr-when .shr-row').forEach((row) => { const t = readTrigger(row); if (t) def.triggers.push(t); });
+ $$('#shr-then .shr-row').forEach((row) => { const a = readAction(row); if (a) def.actions.push(a); });
+ const from = $('#shr-from').value, to = $('#shr-to').value;
+ if (from && to) def.timeWindow = { from, to };
+ const mins = parseInt($('#shr-delay-min').value, 10);
+ if (mins > 0) def.delay = { minutes: mins, onRetrigger: $('#shr-onretrigger').value };
+ return def;
+ }
+ async function saveRule() {
+ const name = $('#shr-name').value.trim();
+ if (!name) { alert(T('smarthome.rules.name_required')); return; }
+ const definition = buildDefinition();
+ try {
+ if (editingId) await api(`/rules/${editingId}`, { method: 'PUT', body: JSON.stringify({ name, definition }) });
+ else await api('/rules', { method: 'POST', body: JSON.stringify({ gateway_id: gatewayId, name, definition }) });
+ closeBuilder(); await loadRules();
+ } catch (e) {
+ if (e.status === 409) showLimit(e.message); // rule-limit / no-api-key
+ alert(e.message); // 400 = validation detail from the server
+ }
+ }
+
+ // Gateway-wide rule count (GC vs external) shown in an inline hint.
+ function countHint() {
+ let el = $('#shr-count-hint');
+ if (!el) { el = document.createElement('div'); el.id = 'shr-count-hint'; el.className = 'banner'; const list = $('#shr-list'); if (list && list.parentNode) list.parentNode.insertBefore(el, list); }
+ return el;
+ }
+ async function loadCount() {
+ if (!gatewayId) return;
+ try {
+ const d = await api(`/rules/gateway-count?gateway_id=${gatewayId}`);
+ const el = countHint();
+ el.textContent = `${T('smarthome.rules.count_total')}: ${d.total_rules} · ${T('smarthome.rules.count_gc')}: ${d.gc_rules} · ${T('smarthome.rules.count_external')}: ${d.external_rules}`;
+ el.style.display = '';
+ } catch (e) { alert(e.message); }
+ }
+
+ function wire() {
+ const nw = $('#shr-new'); if (nw) nw.addEventListener('click', () => openBuilder(null));
+ const save = $('#shr-save'); if (save) save.addEventListener('click', saveRule);
+ const addW = $('#shr-add-when'); if (addW) addW.addEventListener('click', () => addTriggerRow());
+ const addT = $('#shr-add-then'); if (addT) addT.addEventListener('click', () => addActionRow());
+ const cnt = $('#shr-gateway-count'); if (cnt) cnt.addEventListener('click', loadCount);
+ $$('[data-shr-close]').forEach((el) => el.addEventListener('click', closeBuilder));
+ }
+
+ document.addEventListener('DOMContentLoaded', () => { wire(); loadRules(); });
+ window.SmartHomeRules = { loadRules, api };
+})();
diff --git a/src/i18n/de.json b/src/i18n/de.json
index 6680be8d..35f43ee9 100644
--- a/src/i18n/de.json
+++ b/src/i18n/de.json
@@ -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",
@@ -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",
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 591b2132..447e3b7b 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -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",
@@ -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",
diff --git a/src/routes/api/smarthome.js b/src/routes/api/smarthome.js
index d747a9e1..280330d6 100644
--- a/src/routes/api/smarthome.js
+++ b/src/routes/api/smarthome.js
@@ -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') });
@@ -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 });
}
};
}
@@ -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;
diff --git a/src/routes/index.js b/src/routes/index.js
index 477d194a..ca2a624d 100644
--- a/src/routes/index.js
+++ b/src/routes/index.js
@@ -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' },
];
diff --git a/src/server.js b/src/server.js
index 46bc0ec3..e23f8f27 100644
--- a/src/server.js
+++ b/src/server.js
@@ -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
diff --git a/src/services/smarthome/deconzCapabilities.js b/src/services/smarthome/deconzCapabilities.js
new file mode 100644
index 00000000..ae35aea0
--- /dev/null
+++ b/src/services/smarthome/deconzCapabilities.js
@@ -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/ 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_).
+ ruleLimit: { warnAtGcRules: 38 /* =150/4 */, errorCodes: ['DECONZ_HTTP_503', 'DECONZ_HTTP_507', 'DECONZ_ERR_601'] },
+};
diff --git a/src/services/smarthome/deconzClient.js b/src/services/smarthome/deconzClient.js
index 164ba3f8..18f2f134 100644
--- a/src/services/smarthome/deconzClient.js
+++ b/src/services/smarthome/deconzClient.js
@@ -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;
@@ -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
};
}
diff --git a/src/services/smarthome/rulesTranslate.js b/src/services/smarthome/rulesTranslate.js
new file mode 100644
index 00000000..0ce8ce7a
--- /dev/null
+++ b/src/services/smarthome/rulesTranslate.js
@@ -0,0 +1,139 @@
+'use strict';
+
+const { briToDeconz } = require('./deconzClient');
+const caps = require('./deconzCapabilities');
+
+function invalid(detail) { const e = new Error(`rule invalid: ${detail}`); e.code = 'SMARTHOME_RULE_INVALID'; e.detail = detail; return e; }
+
+// event-trigger → { address, value, op? } auf dem Sensor; dx kommt immer auf lastupdated.
+function eventCondition(t, r) {
+ const base = `/sensors/${r.deconz_id}/state`;
+ switch (t.kind) {
+ case 'motion': return { address: `${base}/presence`, value: t.event === 'detected' ? 'true' : 'false' };
+ case 'contact': return { address: `${base}/open`, value: t.event === 'open' ? 'true' : 'false' };
+ case 'water': return { address: `${base}/water`, value: t.event === 'wet' ? 'true' : 'false' };
+ case 'button': {
+ const code = caps.buttonCode(r.capabilities && r.capabilities.modelid, t.button, t.action);
+ if (code == null) throw invalid('unknown_button_action');
+ return { address: `${base}/buttonevent`, value: String(code) };
+ }
+ case 'daylight': {
+ const d = caps.daylight[t.event]; if (!d) throw invalid('unknown_daylight_event');
+ return { address: `${base}/${d.field}`, value: d.value, op: d.op };
+ }
+ default: throw invalid('not_event_trigger');
+ }
+}
+
+function buildConditions(def, resolve) {
+ const out = [];
+ for (const t of def.triggers || []) {
+ if (t.kind === 'temperature' || t.kind === 'lux') {
+ const r = resolve(t.resourceId);
+ if (t.op !== 'lt' && t.op !== 'gt') throw invalid('bad_threshold_op');
+ const field = t.kind === 'temperature' ? 'temperature' : 'lightlevel';
+ const value = t.kind === 'temperature' ? String(Math.round(Number(t.value) * 100)) : String(Math.round(Number(t.value)));
+ out.push({ address: `/sensors/${r.deconz_id}/state/${field}`, operator: t.op, value });
+ continue;
+ }
+ const r = resolve(t.resourceId);
+ const ec = eventCondition(t, r);
+ out.push({ address: ec.address, operator: ec.op || 'eq', value: ec.value });
+ out.push({ address: `/sensors/${r.deconz_id}/state/lastupdated`, operator: 'dx' }); // edge-OR marker
+ }
+ if (def.timeWindow && def.timeWindow.from && def.timeWindow.to) {
+ const timeRe = /^[0-2]\d:[0-5]\d$/;
+ if (!timeRe.test(def.timeWindow.from) || !timeRe.test(def.timeWindow.to)) throw invalid('bad_time_window');
+ out.push({ address: '/config/localtime', operator: 'in', value: `T${def.timeWindow.from}:00/T${def.timeWindow.to}:00` });
+ }
+ return out;
+}
+
+function buildActions(def, resolve) {
+ return (def.actions || []).map((a) => {
+ const r = resolve(a.resourceId);
+ if (a.kind === 'scene') {
+ const [g, s] = String(r.deconz_id).split('/');
+ if (!/^\d+$/.test(g) || !/^\d+$/.test(s)) throw invalid('invalid_scene_deconz_id');
+ return { address: `/groups/${g}/scenes/${s}/recall`, method: 'PUT', body: { on: true } };
+ }
+ const set = a.set || {};
+ const body = {};
+ if ('on' in set) body.on = !!set.on;
+ if ('bri' in set) {
+ if (a.kind === 'plug') throw invalid('plug_no_bri');
+ if (!r.capabilities || !r.capabilities.bri) throw invalid('no_bri_capability');
+ body.bri = briToDeconz(Number(set.bri));
+ }
+ if ('color' in set) {
+ if (a.kind === 'plug') throw invalid('plug_no_color');
+ if (!r.capabilities || !r.capabilities.color) throw invalid('no_color_capability');
+ if (set.color.ct != null) body.ct = Number(set.color.ct);
+ else if (set.color.xy) body.xy = set.color.xy;
+ }
+ const address = a.kind === 'group' ? `/groups/${r.deconz_id}/action` : `/lights/${r.deconz_id}/state`;
+ return { address, method: 'PUT', body };
+ });
+}
+
+function relTime(mins) {
+ const h = String(Math.floor(mins / 60)).padStart(2, '0');
+ const m = String(mins % 60).padStart(2, '0');
+ return `PT${h}:${m}:00`;
+}
+
+// Cancel-condition = trigger back to its inverse binary state.
+// Only eq-conditions with 'true'/'false' values are binary-invertible.
+// button (numeric code) and daylight (non-eq) are excluded.
+function cancelConditionsFrom(conditions) {
+ return conditions
+ .filter((c) => c.operator === 'eq' && (c.value === 'true' || c.value === 'false'))
+ .map((c) => ({ address: c.address, operator: 'eq', value: c.value === 'true' ? 'false' : 'true' }));
+}
+
+// ruleLabel = "GC::" — prefix to identify GC-owned deCONZ objects.
+// Placeholders '__schedule__' and '__clip_state__' are resolved by Task-4 service after object creation.
+function buildRuleObjects(def, resolve, ruleLabel) {
+ const conditions = buildConditions(def, resolve);
+ const actions = buildActions(def, resolve);
+ const delay = def.delay && def.delay.minutes ? def.delay : null;
+
+ if (delay) {
+ if (!Number.isInteger(delay.minutes) || delay.minutes < 1 || delay.minutes > 1440) { const e = new Error('bad delay'); e.code = 'SMARTHOME_RULE_INVALID'; e.detail = 'bad_delay_minutes'; throw e; }
+ if (actions.length > 1) { const e = new Error('multi-action delay'); e.code = 'SMARTHOME_RULE_INVALID'; e.detail = 'delay_multi_action_not_supported'; throw e; }
+ }
+
+ if (!delay) {
+ return { objects: [{ type: 'rule', payload: { name: ruleLabel, status: 'enabled', conditions, actions } }], effectiveOnRetrigger: null };
+ }
+
+ let mode = delay.onRetrigger || 'ignore';
+ if (mode === 'cancel' && !caps.cancelSupported) mode = 'reset'; // ponytail: spike-gated downgrade
+
+ const schedule = { type: 'schedule', payload: { name: `${ruleLabel}#sched`, time: relTime(delay.minutes), status: 'disabled', autodelete: false, command: { address: actions[0].address, method: 'PUT', body: actions[0].body } } };
+ const armSchedule = { address: '__schedule__', method: 'PUT', body: { status: 'enabled' } };
+
+ if (mode === 'ignore' || mode === 'reset') {
+ const objects = [
+ schedule,
+ { type: 'rule', payload: { name: ruleLabel, status: 'enabled', conditions, actions: [armSchedule] }, ref: 'arm' },
+ ];
+ if (mode === 'reset') {
+ objects.push({ type: 'rule', payload: { name: `${ruleLabel}#reset`, status: 'enabled', conditions, actions: [armSchedule] }, ref: 'reset' });
+ }
+ return { objects, effectiveOnRetrigger: mode };
+ }
+
+ // cancel: CLIP flag + schedule + arm-rule (flag→true, arm schedule) + cancel-rule (flag→false, disable schedule).
+ const cancelConds = cancelConditionsFrom(conditions);
+ if (!cancelConds.length) { const e = new Error('cancel needs binary trigger'); e.code = 'SMARTHOME_RULE_INVALID'; e.detail = 'cancel_requires_binary_trigger'; throw e; }
+ const objects = [
+ { type: 'clip', payload: { name: `${ruleLabel}#flag`, type: 'CLIPGenericFlag', modelid: 'GC-Flag', manufacturername: 'GateControl', uniqueid: `${ruleLabel}#flag`, swversion: '1', state: { flag: false } } },
+ schedule,
+ { type: 'rule', payload: { name: ruleLabel, status: 'enabled', conditions, actions: [{ address: '__clip_state__', method: 'PUT', body: { flag: true } }, armSchedule] }, ref: 'arm' },
+ { type: 'rule', payload: { name: `${ruleLabel}#cancel`, status: 'enabled', conditions: cancelConds, actions: [{ address: '__clip_state__', method: 'PUT', body: { flag: false } }, { address: '__schedule__', method: 'PUT', body: { status: 'disabled' } }] }, ref: 'cancel' },
+ ];
+ return { objects, effectiveOnRetrigger: 'cancel' };
+}
+
+module.exports = { buildConditions, buildActions, buildRuleObjects, relTime, cancelConditionsFrom };
diff --git a/src/services/smarthome/smarthomeRules.js b/src/services/smarthome/smarthomeRules.js
new file mode 100644
index 00000000..9100e6ce
--- /dev/null
+++ b/src/services/smarthome/smarthomeRules.js
@@ -0,0 +1,179 @@
+'use strict';
+const { getDb } = require('../../db/connection');
+const logger = require('../../utils/logger');
+const dev = require('./smarthomeDevices');
+const { createClient } = require('./deconzClient');
+const translate = require('./rulesTranslate');
+const caps = require('./deconzCapabilities');
+
+const LIMIT_CODES = new Set(caps.ruleLimit.errorCodes); // deckt HTTP-Status UND 200-Body-Error-Codes ab (Spike Step 6)
+function isLimit(e) { return !!(e && LIMIT_CODES.has(e.code)); }
+function ruleLimitError() { const e = new Error('deconz rule limit reached'); e.code = 'DECONZ_RULE_LIMIT_REACHED'; return e; }
+function limitWarn(gcRuleCount) { return gcRuleCount * 4 > 150; } // Worst-Case-Slot-Multiplikator (§8/§10)
+
+// Default-Factory: lokaler Gateway-Client wie index.js' privates clientForGateway (kein index-Import → kein Zirkularbezug).
+function defaultClientFactory(gatewayId) {
+ const gw = dev.getGateway(gatewayId);
+ if (!gw) { const e = new Error('gateway not found'); e.code = 'SMARTHOME_GATEWAY_NOT_FOUND'; throw e; }
+ const t = dev.resolveTransport(gw.route_id);
+ if (!t) { const e = new Error('route not resolvable'); e.code = 'SMARTHOME_NO_ROUTE'; throw e; }
+ if (!gw.apiKey) { const e = new Error('no api key'); e.code = 'SMARTHOME_NO_API_KEY'; throw e; }
+ return createClient({ baseUrl: t.baseUrl, apiKey: gw.apiKey, headers: { 'X-Gateway-Target-Domain': t.domain } });
+}
+let clientFactory = defaultClientFactory;
+function _setClientFactoryForTest(fn) { clientFactory = fn; }
+
+function resolveFor(gatewayId) {
+ return (resourceId) => {
+ const r = dev.getResource(resourceId);
+ if (!r || r.gateway_id !== gatewayId) { const e = new Error('resource not in gateway'); e.code = 'SMARTHOME_RULE_INVALID'; e.detail = 'foreign_resource'; throw e; }
+ return r;
+ };
+}
+
+function parseRow(row) {
+ if (!row) return null;
+ return { id: row.id, gateway_id: row.gateway_id, name: row.name, enabled: !!row.enabled,
+ definition: JSON.parse(row.definition_json || '{}'),
+ deconz_rule_id: row.deconz_rule_id, deconz_schedule_id: row.deconz_schedule_id, deconz_clip_sensor_id: row.deconz_clip_sensor_id,
+ synced: row.deconz_rule_id != null };
+}
+
+// true wenn ALLE in der definition referenzierten resourceIds (Trigger+Aktionen) im selben Gateway auflösbar sind.
+function resourcesResolve(def, gatewayId) {
+ const ids = [...((def && def.triggers) || []).map((t) => t.resourceId), ...((def && def.actions) || []).map((a) => a.resourceId)].filter((x) => x != null);
+ for (const id of ids) { const r = dev.getResource(id); if (!r || r.gateway_id !== gatewayId) return false; }
+ return true;
+}
+function list(gatewayId) {
+ return getDb().prepare('SELECT * FROM smarthome_rules WHERE gateway_id = ? ORDER BY id').all(gatewayId).map((row) => {
+ const r = parseRow(row);
+ r.orphaned = !resourcesResolve(r.definition, gatewayId); // verwaiste Referenz → UI read-only mit Warnung (§11)
+ return r;
+ });
+}
+function get(id) { return parseRow(getDb().prepare('SELECT * FROM smarthome_rules WHERE id = ?').get(id)); }
+
+// Erzeugt die deCONZ-Objekte aus dem Objekt-Plan; löst __schedule__/__clip_state__ auf;
+// merkt erzeugte IDs für Kompensation. Gibt {ruleId, scheduleId, clipId} zurück.
+async function materialize(client, objectPlan, apiKey) {
+ const created = []; // [kind, id]
+ let scheduleId = null, clipId = null, ruleId = null;
+ try {
+ // Reihenfolge clip → schedule → rules ist im Objekt-Plan garantiert (buildRuleObjects). Platzhalter werden hier aufgelöst.
+ for (const obj of objectPlan.objects) {
+ if (obj.type === 'clip') { clipId = await client.createClipSensor(obj.payload); created.push(['clip', clipId]); continue; }
+ if (obj.type === 'schedule') {
+ // SPIKE-OVERRIDE: schedule.command.address MUSS mit /api/ präfixiert sein (Rule-Actions bleiben bare).
+ const sp = JSON.parse(JSON.stringify(obj.payload));
+ if (sp.command && typeof sp.command.address === 'string' && !sp.command.address.startsWith('/api/')) {
+ sp.command.address = `/api/${apiKey}${sp.command.address}`;
+ }
+ scheduleId = await client.createSchedule(sp); created.push(['schedule', scheduleId]); continue;
+ }
+ const payload = JSON.parse(JSON.stringify(obj.payload));
+ payload.actions = (payload.actions || []).map((a) => {
+ if (a.address === '__schedule__') { if (!scheduleId) throw new Error('__schedule__ unresolved (no schedule preceded this rule)'); return { ...a, address: `/schedules/${scheduleId}` }; }
+ if (a.address === '__clip_state__') { if (!clipId) throw new Error('__clip_state__ unresolved (no clip sensor preceded this rule)'); return { ...a, address: `/sensors/${clipId}/state` }; }
+ return a;
+ });
+ const id = await client.createRule(payload);
+ created.push(['rule', id]);
+ if (obj.ref !== 'reset' && obj.ref !== 'cancel') ruleId = id; // primäre Auslöse-Regel
+ }
+ return { ruleId, scheduleId, clipId };
+ } catch (e) {
+ // Kompensation: in umgekehrter Reihenfolge best-effort löschen — keine Waisen.
+ for (const [kind, id] of created.reverse()) {
+ try {
+ if (kind === 'rule') await client.deleteRule(id);
+ else if (kind === 'schedule') await client.deleteSchedule(id);
+ else if (kind === 'clip') { if (caps.clipDeletable && client.deleteClipSensor) await client.deleteClipSensor(id); }
+ } catch (ce) { logger.warn({ error: ce.message, kind, id }, 'smarthome: compensation delete failed (potential orphan)'); }
+ }
+ if (isLimit(e)) throw ruleLimitError();
+ throw e;
+ }
+}
+
+async function create(gatewayId, name, definition) {
+ if (!name || typeof name !== 'string' || name.length > 20) { const e = new Error('name too long'); e.code = 'SMARTHOME_RULE_INVALID'; e.detail = 'name_too_long'; throw e; } // deCONZ-Regelname ~32 Zeichen inkl. GC::-Präfix
+ const resolve = resolveFor(gatewayId);
+ const gw = dev.getGateway(gatewayId);
+ const db = getDb();
+ // Zeile zuerst anlegen (für den GC:-Label-Präfix), IDs noch NULL.
+ const id = Number(db.prepare('INSERT INTO smarthome_rules (gateway_id, name, enabled, definition_json) VALUES (?,?,?,?)')
+ .run(gatewayId, name, 1, JSON.stringify(definition)).lastInsertRowid);
+ try {
+ const plan = translate.buildRuleObjects(definition, resolve, `GC:${id}:${name}`); // wirft bei Validierung → Zeile unten gelöscht
+ const { ruleId, scheduleId, clipId } = await materialize(clientFactory(gatewayId), plan, gw && gw.apiKey);
+ db.prepare('UPDATE smarthome_rules SET deconz_rule_id=?, deconz_schedule_id=?, deconz_clip_sensor_id=? WHERE id=?')
+ .run(ruleId, scheduleId, clipId, id);
+ return get(id);
+ } catch (e) {
+ db.prepare('DELETE FROM smarthome_rules WHERE id = ?').run(id); // kein Waisen-GC-Eintrag
+ throw e;
+ }
+}
+
+async function update(id, name, definition) {
+ if (!name || typeof name !== 'string' || name.length > 20) { const e = new Error('name too long'); e.code = 'SMARTHOME_RULE_INVALID'; e.detail = 'name_too_long'; throw e; }
+ const db = getDb();
+ const row = get(id);
+ if (!row) { const e = new Error('rule not found'); e.code = 'SMARTHOME_RULE_NOT_FOUND'; throw e; }
+ const gw = dev.getGateway(row.gateway_id);
+ const client = clientFactory(row.gateway_id);
+ const resolve = resolveFor(row.gateway_id);
+ // NULL-before-delete: IDs lösen, dann alte Objekte best-effort entfernen (Lösch-Fehler != 404 loggen → sichtbare Waisen).
+ db.prepare('UPDATE smarthome_rules SET deconz_rule_id=NULL, deconz_schedule_id=NULL, deconz_clip_sensor_id=NULL WHERE id=?').run(id);
+ for (const [m, did] of [['deleteRule', row.deconz_rule_id], ['deleteSchedule', row.deconz_schedule_id], ['deleteClipSensor', row.deconz_clip_sensor_id]]) {
+ if (did && client[m]) { try { await client[m](did); } catch (e) { if (e.code !== 'DECONZ_HTTP_404') logger.warn({ error: e.message, method: m, id: did }, 'smarthome: old deconz object delete failed (potential orphan)'); } }
+ }
+ db.prepare('UPDATE smarthome_rules SET name=?, definition_json=? WHERE id=?').run(name, JSON.stringify(definition), id);
+ try {
+ const plan = translate.buildRuleObjects(definition, resolve, `GC:${id}:${name}`);
+ const { ruleId, scheduleId, clipId } = await materialize(client, plan, gw && gw.apiKey);
+ db.prepare('UPDATE smarthome_rules SET deconz_rule_id=?, deconz_schedule_id=?, deconz_clip_sensor_id=? WHERE id=?').run(ruleId, scheduleId, clipId, id);
+ return get(id);
+ } catch (e) {
+ db.prepare('UPDATE smarthome_rules SET enabled=0 WHERE id=?').run(id); // §7: nicht synchronisiert + enabled=false → resyncPending überspringt es (keine Waisen-Kaskade)
+ throw e;
+ }
+}
+
+async function remove(id) {
+ const db = getDb();
+ const row = get(id);
+ if (!row) return;
+ const client = clientFactory(row.gateway_id);
+ for (const [m, did] of [['deleteRule', row.deconz_rule_id], ['deleteSchedule', row.deconz_schedule_id], ['deleteClipSensor', row.deconz_clip_sensor_id]]) {
+ if (did && client[m]) { try { await client[m](did); } catch (e) { if (e.code !== 'DECONZ_HTTP_404') logger.warn({ error: e.message, method: m, id: did }, 'smarthome: deconz object delete failed (potential orphan)'); } }
+ }
+ db.prepare('DELETE FROM smarthome_rules WHERE id = ?').run(id);
+}
+
+async function setEnabled(id, on) {
+ const db = getDb();
+ const row = get(id);
+ if (!row) { const e = new Error('rule not found'); e.code = 'SMARTHOME_RULE_NOT_FOUND'; throw e; }
+ if (row.deconz_rule_id) { try { await clientFactory(row.gateway_id).updateRule(row.deconz_rule_id, { status: on ? 'enabled' : 'disabled' }); } catch (_) { /* best-effort */ } }
+ db.prepare('UPDATE smarthome_rules SET enabled=? WHERE id=?').run(on ? 1 : 0, id);
+ return get(id);
+}
+
+async function gatewayRuleCount(gatewayId) {
+ const total = Object.keys(await clientFactory(gatewayId).getRules() || {}).length;
+ const gc = getDb().prepare('SELECT COUNT(*) c FROM smarthome_rules WHERE gateway_id = ? AND deconz_rule_id IS NOT NULL').get(gatewayId).c;
+ return { total_rules: total, gc_rules: gc, external_rules: Math.max(0, total - gc) };
+}
+
+async function resyncPending() {
+ const rows = getDb().prepare('SELECT id FROM smarthome_rules WHERE enabled = 1 AND deconz_rule_id IS NULL').all();
+ for (const { id } of rows) {
+ const row = get(id);
+ try { await update(id, row.name, row.definition); } catch (_) { /* log-and-continue */ }
+ }
+ return rows.length;
+}
+
+module.exports = { list, get, create, update, remove, setEnabled, gatewayRuleCount, resyncPending, limitWarn, _setClientFactoryForTest };
diff --git a/templates/aurora/layout.njk b/templates/aurora/layout.njk
index 2e6b174d..f522b0a5 100644
--- a/templates/aurora/layout.njk
+++ b/templates/aurora/layout.njk
@@ -93,6 +93,43 @@
'smarthome.owners.none': {{ t('smarthome.owners.none') | dump | safe }},
'smarthome.owners.save': {{ t('smarthome.owners.save') | dump | safe }},
'smarthome.owners.search': {{ t('smarthome.owners.search') | dump | safe }},
+ 'smarthome.rules.title': {{ t('smarthome.rules.title') | dump | safe }},
+ 'smarthome.rules.subtitle': {{ t('smarthome.rules.subtitle') | dump | safe }},
+ 'smarthome.rules.back': {{ t('smarthome.rules.back') | dump | safe }},
+ 'smarthome.rules.new': {{ t('smarthome.rules.new') | dump | safe }},
+ 'smarthome.rules.load_count': {{ t('smarthome.rules.load_count') | dump | safe }},
+ 'smarthome.rules.when': {{ t('smarthome.rules.when') | dump | safe }},
+ 'smarthome.rules.then': {{ t('smarthome.rules.then') | dump | safe }},
+ 'smarthome.rules.add_condition': {{ t('smarthome.rules.add_condition') | dump | safe }},
+ 'smarthome.rules.add_action': {{ t('smarthome.rules.add_action') | dump | safe }},
+ 'smarthome.rules.time_window': {{ t('smarthome.rules.time_window') | dump | safe }},
+ 'smarthome.rules.delay': {{ t('smarthome.rules.delay') | dump | safe }},
+ 'smarthome.rules.on_retrigger': {{ t('smarthome.rules.on_retrigger') | dump | safe }},
+ 'smarthome.rules.retrigger_ignore': {{ t('smarthome.rules.retrigger_ignore') | dump | safe }},
+ 'smarthome.rules.retrigger_reset': {{ t('smarthome.rules.retrigger_reset') | dump | safe }},
+ 'smarthome.rules.retrigger_cancel': {{ t('smarthome.rules.retrigger_cancel') | dump | safe }},
+ 'smarthome.rules.multi_hint': {{ t('smarthome.rules.multi_hint') | dump | safe }},
+ 'smarthome.rules.save': {{ t('smarthome.rules.save') | dump | safe }},
+ 'smarthome.rules.limit_warn': {{ t('smarthome.rules.limit_warn') | dump | safe }},
+ 'smarthome.rules.edit': {{ t('smarthome.rules.edit') | dump | safe }},
+ 'smarthome.rules.delete': {{ t('smarthome.rules.delete') | dump | safe }},
+ 'smarthome.rules.orphaned_warn': {{ t('smarthome.rules.orphaned_warn') | dump | safe }},
+ 'smarthome.rules.cancel_unsupported_hint': {{ t('smarthome.rules.cancel_unsupported_hint') | dump | safe }},
+ 'smarthome.rules.builder_title': {{ t('smarthome.rules.builder_title') | dump | safe }},
+ 'smarthome.rules.name_label': {{ t('smarthome.rules.name_label') | dump | safe }},
+ 'smarthome.rules.op_lt': {{ t('smarthome.rules.op_lt') | dump | safe }},
+ 'smarthome.rules.op_gt': {{ t('smarthome.rules.op_gt') | dump | safe }},
+ 'smarthome.rules.act_on': {{ t('smarthome.rules.act_on') | dump | safe }},
+ 'smarthome.rules.act_off': {{ t('smarthome.rules.act_off') | dump | safe }},
+ 'smarthome.rules.btn_short': {{ t('smarthome.rules.btn_short') | dump | safe }},
+ 'smarthome.rules.btn_long': {{ t('smarthome.rules.btn_long') | dump | safe }},
+ 'smarthome.rules.btn_double': {{ t('smarthome.rules.btn_double') | dump | safe }},
+ 'smarthome.rules.empty': {{ t('smarthome.rules.empty') | dump | safe }},
+ 'smarthome.rules.name_required': {{ t('smarthome.rules.name_required') | dump | safe }},
+ 'smarthome.rules.confirm_delete': {{ t('smarthome.rules.confirm_delete') | dump | safe }},
+ 'smarthome.rules.count_total': {{ t('smarthome.rules.count_total') | dump | safe }},
+ 'smarthome.rules.count_gc': {{ t('smarthome.rules.count_gc') | dump | safe }},
+ 'smarthome.rules.count_external': {{ t('smarthome.rules.count_external') | 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/aurora/pages/smarthome-rules.njk b/templates/aurora/pages/smarthome-rules.njk
new file mode 100644
index 00000000..1de5f256
--- /dev/null
+++ b/templates/aurora/pages/smarthome-rules.njk
@@ -0,0 +1,25 @@
+{% extends theme + "/layout.njk" %}
+
+{% block head %}{% endblock %}
+
+{% block content %}
+
+
+
+{% include theme + "/partials/modals/smarthome-rule-builder.njk" %}
+{% endblock %}
+
+{% block scripts %}
+
+{% endblock %}
diff --git a/templates/aurora/pages/smarthome.njk b/templates/aurora/pages/smarthome.njk
index 0cbaee1e..cb1e8a4a 100644
--- a/templates/aurora/pages/smarthome.njk
+++ b/templates/aurora/pages/smarthome.njk
@@ -13,6 +13,7 @@
+ {{ t('smarthome.rules.title') }}
diff --git a/templates/aurora/partials/modals/smarthome-rule-builder.njk b/templates/aurora/partials/modals/smarthome-rule-builder.njk
new file mode 100644
index 00000000..014b6134
--- /dev/null
+++ b/templates/aurora/partials/modals/smarthome-rule-builder.njk
@@ -0,0 +1,65 @@
+
+
+
+
{{ t('smarthome.rules.builder_title') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('smarthome.rules.when') }}
+
+
+
+
+
+
+
{{ t('smarthome.rules.multi_hint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('smarthome.rules.then') }}
+
+
+
+
+
+
+
+
diff --git a/templates/default/layout.njk b/templates/default/layout.njk
index da78c9f5..9916be03 100644
--- a/templates/default/layout.njk
+++ b/templates/default/layout.njk
@@ -92,6 +92,43 @@
'smarthome.owners.none': {{ t('smarthome.owners.none') | dump | safe }},
'smarthome.owners.save': {{ t('smarthome.owners.save') | dump | safe }},
'smarthome.owners.search': {{ t('smarthome.owners.search') | dump | safe }},
+ 'smarthome.rules.title': {{ t('smarthome.rules.title') | dump | safe }},
+ 'smarthome.rules.subtitle': {{ t('smarthome.rules.subtitle') | dump | safe }},
+ 'smarthome.rules.back': {{ t('smarthome.rules.back') | dump | safe }},
+ 'smarthome.rules.new': {{ t('smarthome.rules.new') | dump | safe }},
+ 'smarthome.rules.load_count': {{ t('smarthome.rules.load_count') | dump | safe }},
+ 'smarthome.rules.when': {{ t('smarthome.rules.when') | dump | safe }},
+ 'smarthome.rules.then': {{ t('smarthome.rules.then') | dump | safe }},
+ 'smarthome.rules.add_condition': {{ t('smarthome.rules.add_condition') | dump | safe }},
+ 'smarthome.rules.add_action': {{ t('smarthome.rules.add_action') | dump | safe }},
+ 'smarthome.rules.time_window': {{ t('smarthome.rules.time_window') | dump | safe }},
+ 'smarthome.rules.delay': {{ t('smarthome.rules.delay') | dump | safe }},
+ 'smarthome.rules.on_retrigger': {{ t('smarthome.rules.on_retrigger') | dump | safe }},
+ 'smarthome.rules.retrigger_ignore': {{ t('smarthome.rules.retrigger_ignore') | dump | safe }},
+ 'smarthome.rules.retrigger_reset': {{ t('smarthome.rules.retrigger_reset') | dump | safe }},
+ 'smarthome.rules.retrigger_cancel': {{ t('smarthome.rules.retrigger_cancel') | dump | safe }},
+ 'smarthome.rules.multi_hint': {{ t('smarthome.rules.multi_hint') | dump | safe }},
+ 'smarthome.rules.save': {{ t('smarthome.rules.save') | dump | safe }},
+ 'smarthome.rules.limit_warn': {{ t('smarthome.rules.limit_warn') | dump | safe }},
+ 'smarthome.rules.edit': {{ t('smarthome.rules.edit') | dump | safe }},
+ 'smarthome.rules.delete': {{ t('smarthome.rules.delete') | dump | safe }},
+ 'smarthome.rules.orphaned_warn': {{ t('smarthome.rules.orphaned_warn') | dump | safe }},
+ 'smarthome.rules.cancel_unsupported_hint': {{ t('smarthome.rules.cancel_unsupported_hint') | dump | safe }},
+ 'smarthome.rules.builder_title': {{ t('smarthome.rules.builder_title') | dump | safe }},
+ 'smarthome.rules.name_label': {{ t('smarthome.rules.name_label') | dump | safe }},
+ 'smarthome.rules.op_lt': {{ t('smarthome.rules.op_lt') | dump | safe }},
+ 'smarthome.rules.op_gt': {{ t('smarthome.rules.op_gt') | dump | safe }},
+ 'smarthome.rules.act_on': {{ t('smarthome.rules.act_on') | dump | safe }},
+ 'smarthome.rules.act_off': {{ t('smarthome.rules.act_off') | dump | safe }},
+ 'smarthome.rules.btn_short': {{ t('smarthome.rules.btn_short') | dump | safe }},
+ 'smarthome.rules.btn_long': {{ t('smarthome.rules.btn_long') | dump | safe }},
+ 'smarthome.rules.btn_double': {{ t('smarthome.rules.btn_double') | dump | safe }},
+ 'smarthome.rules.empty': {{ t('smarthome.rules.empty') | dump | safe }},
+ 'smarthome.rules.name_required': {{ t('smarthome.rules.name_required') | dump | safe }},
+ 'smarthome.rules.confirm_delete': {{ t('smarthome.rules.confirm_delete') | dump | safe }},
+ 'smarthome.rules.count_total': {{ t('smarthome.rules.count_total') | dump | safe }},
+ 'smarthome.rules.count_gc': {{ t('smarthome.rules.count_gc') | dump | safe }},
+ 'smarthome.rules.count_external': {{ t('smarthome.rules.count_external') | 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/pages/smarthome-rules.njk b/templates/default/pages/smarthome-rules.njk
new file mode 100644
index 00000000..c1dd896f
--- /dev/null
+++ b/templates/default/pages/smarthome-rules.njk
@@ -0,0 +1,25 @@
+{% extends theme + "/layout.njk" %}
+
+{% block head %}{% endblock %}
+
+{% block content %}
+
+
+
+{% include theme + "/partials/modals/smarthome-rule-builder.njk" %}
+{% endblock %}
+
+{% block scripts %}
+
+{% endblock %}
diff --git a/templates/default/pages/smarthome.njk b/templates/default/pages/smarthome.njk
index 7dc10070..60688fbc 100644
--- a/templates/default/pages/smarthome.njk
+++ b/templates/default/pages/smarthome.njk
@@ -13,6 +13,7 @@
+ {{ t('smarthome.rules.title') }}
diff --git a/templates/default/partials/modals/smarthome-rule-builder.njk b/templates/default/partials/modals/smarthome-rule-builder.njk
new file mode 100644
index 00000000..014b6134
--- /dev/null
+++ b/templates/default/partials/modals/smarthome-rule-builder.njk
@@ -0,0 +1,65 @@
+
+
+
+
{{ t('smarthome.rules.builder_title') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('smarthome.rules.when') }}
+
+
+
+
+
+
+
{{ t('smarthome.rules.multi_hint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('smarthome.rules.then') }}
+
+
+
+
+
+
+
+
diff --git a/templates/pro/layout.njk b/templates/pro/layout.njk
index b89b0d28..4813f1c2 100644
--- a/templates/pro/layout.njk
+++ b/templates/pro/layout.njk
@@ -94,6 +94,43 @@
'smarthome.owners.none': {{ t('smarthome.owners.none') | dump | safe }},
'smarthome.owners.save': {{ t('smarthome.owners.save') | dump | safe }},
'smarthome.owners.search': {{ t('smarthome.owners.search') | dump | safe }},
+ 'smarthome.rules.title': {{ t('smarthome.rules.title') | dump | safe }},
+ 'smarthome.rules.subtitle': {{ t('smarthome.rules.subtitle') | dump | safe }},
+ 'smarthome.rules.back': {{ t('smarthome.rules.back') | dump | safe }},
+ 'smarthome.rules.new': {{ t('smarthome.rules.new') | dump | safe }},
+ 'smarthome.rules.load_count': {{ t('smarthome.rules.load_count') | dump | safe }},
+ 'smarthome.rules.when': {{ t('smarthome.rules.when') | dump | safe }},
+ 'smarthome.rules.then': {{ t('smarthome.rules.then') | dump | safe }},
+ 'smarthome.rules.add_condition': {{ t('smarthome.rules.add_condition') | dump | safe }},
+ 'smarthome.rules.add_action': {{ t('smarthome.rules.add_action') | dump | safe }},
+ 'smarthome.rules.time_window': {{ t('smarthome.rules.time_window') | dump | safe }},
+ 'smarthome.rules.delay': {{ t('smarthome.rules.delay') | dump | safe }},
+ 'smarthome.rules.on_retrigger': {{ t('smarthome.rules.on_retrigger') | dump | safe }},
+ 'smarthome.rules.retrigger_ignore': {{ t('smarthome.rules.retrigger_ignore') | dump | safe }},
+ 'smarthome.rules.retrigger_reset': {{ t('smarthome.rules.retrigger_reset') | dump | safe }},
+ 'smarthome.rules.retrigger_cancel': {{ t('smarthome.rules.retrigger_cancel') | dump | safe }},
+ 'smarthome.rules.multi_hint': {{ t('smarthome.rules.multi_hint') | dump | safe }},
+ 'smarthome.rules.save': {{ t('smarthome.rules.save') | dump | safe }},
+ 'smarthome.rules.limit_warn': {{ t('smarthome.rules.limit_warn') | dump | safe }},
+ 'smarthome.rules.edit': {{ t('smarthome.rules.edit') | dump | safe }},
+ 'smarthome.rules.delete': {{ t('smarthome.rules.delete') | dump | safe }},
+ 'smarthome.rules.orphaned_warn': {{ t('smarthome.rules.orphaned_warn') | dump | safe }},
+ 'smarthome.rules.cancel_unsupported_hint': {{ t('smarthome.rules.cancel_unsupported_hint') | dump | safe }},
+ 'smarthome.rules.builder_title': {{ t('smarthome.rules.builder_title') | dump | safe }},
+ 'smarthome.rules.name_label': {{ t('smarthome.rules.name_label') | dump | safe }},
+ 'smarthome.rules.op_lt': {{ t('smarthome.rules.op_lt') | dump | safe }},
+ 'smarthome.rules.op_gt': {{ t('smarthome.rules.op_gt') | dump | safe }},
+ 'smarthome.rules.act_on': {{ t('smarthome.rules.act_on') | dump | safe }},
+ 'smarthome.rules.act_off': {{ t('smarthome.rules.act_off') | dump | safe }},
+ 'smarthome.rules.btn_short': {{ t('smarthome.rules.btn_short') | dump | safe }},
+ 'smarthome.rules.btn_long': {{ t('smarthome.rules.btn_long') | dump | safe }},
+ 'smarthome.rules.btn_double': {{ t('smarthome.rules.btn_double') | dump | safe }},
+ 'smarthome.rules.empty': {{ t('smarthome.rules.empty') | dump | safe }},
+ 'smarthome.rules.name_required': {{ t('smarthome.rules.name_required') | dump | safe }},
+ 'smarthome.rules.confirm_delete': {{ t('smarthome.rules.confirm_delete') | dump | safe }},
+ 'smarthome.rules.count_total': {{ t('smarthome.rules.count_total') | dump | safe }},
+ 'smarthome.rules.count_gc': {{ t('smarthome.rules.count_gc') | dump | safe }},
+ 'smarthome.rules.count_external': {{ t('smarthome.rules.count_external') | 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/pages/smarthome-rules.njk b/templates/pro/pages/smarthome-rules.njk
new file mode 100644
index 00000000..c1dd896f
--- /dev/null
+++ b/templates/pro/pages/smarthome-rules.njk
@@ -0,0 +1,25 @@
+{% extends theme + "/layout.njk" %}
+
+{% block head %}{% endblock %}
+
+{% block content %}
+
+
+
+{% include theme + "/partials/modals/smarthome-rule-builder.njk" %}
+{% endblock %}
+
+{% block scripts %}
+
+{% endblock %}
diff --git a/templates/pro/pages/smarthome.njk b/templates/pro/pages/smarthome.njk
index 7dc10070..60688fbc 100644
--- a/templates/pro/pages/smarthome.njk
+++ b/templates/pro/pages/smarthome.njk
@@ -13,6 +13,7 @@
+ {{ t('smarthome.rules.title') }}
diff --git a/templates/pro/partials/modals/smarthome-rule-builder.njk b/templates/pro/partials/modals/smarthome-rule-builder.njk
new file mode 100644
index 00000000..014b6134
--- /dev/null
+++ b/templates/pro/partials/modals/smarthome-rule-builder.njk
@@ -0,0 +1,65 @@
+
+
+
+
{{ t('smarthome.rules.builder_title') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('smarthome.rules.when') }}
+
+
+
+
+
+
+
{{ t('smarthome.rules.multi_hint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('smarthome.rules.then') }}
+
+
+
+
+
+
+
+
diff --git a/tests/fixtures/deconz_spike_vectors.js b/tests/fixtures/deconz_spike_vectors.js
new file mode 100644
index 00000000..fc40340f
--- /dev/null
+++ b/tests/fixtures/deconz_spike_vectors.js
@@ -0,0 +1,39 @@
+'use strict';
+// Echte deCONZ-Response-Samples aus Task-0-Live-Spike (Phoscon 2.24.2).
+// Beleg: docs/superpowers/specs/2026-06-30-smarthome-tp3-spike.md.
+module.exports = {
+ // POST /rules Erfolgs-Envelope.
+ createRuleSuccess: [{ success: { id: '21' } }],
+ // POST /schedules bzw. POST /sensors (CLIP) Erfolgs-Envelope.
+ createScheduleSuccess: [{ success: { id: '1' } }],
+ createClipSuccess: [{ success: { id: '21' } }],
+ setClipStateSuccess: [{ success: { '/sensors/21/state/flag': true } }],
+ // DELETE nicht-existent → idempotent zu ignorieren (Client-Code DECONZ_HTTP_404).
+ deleteMissing: { status: 404, body: [{ error: { address: '/rules/21', description: 'resource, /rules/21, not available', type: 3 } }] },
+ // Regellimit: nicht live provoziert; defensive Repräsentation der 200-Body-Error-Form.
+ ruleLimitError: { status: 503, body: [{ error: { type: 601, address: '/rules', description: 'rule limit reached' } }] },
+
+ // Echter ZHASwitch (RWL021, /sensors/19) — modelid → buttonCode-Formel (button*1000+offset).
+ zhaSwitchSample: {
+ id: '19', type: 'ZHASwitch', modelid: 'RWL021', manufacturername: 'Philips', name: 'Schalter Wohnzimmer',
+ state: { buttonevent: 4002, eventduration: 0, lastupdated: '2026-05-14T02:11:35.744' },
+ },
+ // Aqara-Einzeltaste (lumi.sensor_switch, /sensors/16) — hat nativen Doppelklick.
+ zhaSwitchAqaraSample: {
+ id: '16', type: 'ZHASwitch', modelid: 'lumi.sensor_switch', name: 'Smart Switch',
+ state: { buttonevent: 1001, lastupdated: '2023-02-11T17:12:04.559' },
+ },
+
+ // Echter Daylight-Sensor (/sensors/1) — Feld `daylight` (bool) treibt sunrise/sunset.
+ daylightSample: {
+ id: '1', type: 'Daylight', modelid: 'PHDL00', name: 'Daylight',
+ state: { dark: false, daylight: true, status: 160, sunrise: '2026-07-01T03:25:21', sunset: '2026-07-01T19:41:36', lastupdated: '2026-07-01T04:16:39.342' },
+ config: { configured: true, on: true, sunriseoffset: 30, sunsetoffset: -30 },
+ },
+
+ // GET /schedules/:id — command.address trägt /api/-Präfix (Wire-Format-Fund Step 4).
+ scheduleGetSample: {
+ activation: 'start', autodelete: false, status: 'disabled', time: 'PT00:05:00',
+ command: { address: '/api/4BD54DF895/sensors/21/state', body: { flag: false }, method: 'PUT' },
+ },
+};
diff --git a/tests/smarthome_deconz_rules_client.test.js b/tests/smarthome_deconz_rules_client.test.js
new file mode 100644
index 00000000..6ced7e11
--- /dev/null
+++ b/tests/smarthome_deconz_rules_client.test.js
@@ -0,0 +1,63 @@
+'use strict';
+const { test, beforeEach, afterEach } = require('node:test');
+const assert = require('node:assert/strict');
+
+let origFetch;
+beforeEach(() => { origFetch = global.fetch; });
+afterEach(() => { global.fetch = origFetch; });
+function mockFetch(handler) { global.fetch = async (url, opts) => handler(url, opts); }
+function jsonRes(body, status = 200) {
+ return { ok: status >= 200 && status < 300, status, async json() { return body; }, async text() { return JSON.stringify(body); } };
+}
+const { createClient } = require('../src/services/smarthome/deconzClient');
+const client = () => createClient({ baseUrl: 'http://gw', apiKey: 'KEY' });
+
+test('createRule POSTs /rules and returns new id', async () => {
+ mockFetch((url, opts) => {
+ assert.equal(url, 'http://gw/api/KEY/rules');
+ assert.equal(opts.method, 'POST');
+ assert.match(opts.body, /"conditions"/);
+ return jsonRes([{ success: { id: '7' } }]);
+ });
+ const id = await client().createRule({ name: 'r', conditions: [], actions: [] });
+ assert.equal(id, '7');
+});
+
+test('createRule throws coded error on deconz error array', async () => {
+ mockFetch(() => jsonRes([{ error: { type: 601, description: 'rule limit reached' } }]));
+ await assert.rejects(() => client().createRule({}), (e) => e.code === 'DECONZ_ERR_601');
+});
+
+test('getRules GETs /rules', async () => {
+ mockFetch((url) => { assert.equal(url, 'http://gw/api/KEY/rules'); return jsonRes({ '1': { name: 'r1' } }); });
+ const rules = await client().getRules();
+ assert.equal(rules['1'].name, 'r1');
+});
+
+test('updateRule PUTs /rules/:id', async () => {
+ mockFetch((url, opts) => { assert.equal(url, 'http://gw/api/KEY/rules/5'); assert.equal(opts.method, 'PUT'); return jsonRes([{ success: {} }]); });
+ await client().updateRule('5', { name: 'x' });
+});
+
+test('deleteRule DELETEs /rules/:id', async () => {
+ mockFetch((url, opts) => { assert.equal(url, 'http://gw/api/KEY/rules/5'); assert.equal(opts.method, 'DELETE'); return jsonRes([{ success: {} }]); });
+ await client().deleteRule('5');
+});
+
+test('createSchedule returns id; createClipSensor returns id; setClipSensorState PUTs state', async () => {
+ mockFetch((url, opts) => {
+ if (url.endsWith('/schedules')) { assert.equal(opts.method, 'POST'); return jsonRes([{ success: { id: 's2' } }]); }
+ if (url.endsWith('/sensors')) { assert.equal(opts.method, 'POST'); return jsonRes([{ success: { id: 'c3' } }]); }
+ if (url.endsWith('/sensors/c3/state')) { assert.equal(opts.method, 'PUT'); return jsonRes([{ success: {} }]); }
+ throw new Error('unexpected ' + url);
+ });
+ const c = client();
+ assert.equal(await c.createSchedule({ time: 'PT00:05:00' }), 's2');
+ assert.equal(await c.createClipSensor({ name: 'flag' }), 'c3');
+ await c.setClipSensorState('c3', { flag: true });
+});
+
+test('deleteClipSensor DELETEs /sensors/:id', async () => {
+ mockFetch((url, opts) => { assert.equal(url, 'http://gw/api/KEY/sensors/c3'); assert.equal(opts.method, 'DELETE'); return jsonRes([{ success: {} }]); });
+ await client().deleteClipSensor('c3');
+});
diff --git a/tests/smarthome_rules_api.test.js b/tests/smarthome_rules_api.test.js
new file mode 100644
index 00000000..5cdc98c9
--- /dev/null
+++ b/tests/smarthome_rules_api.test.js
@@ -0,0 +1,89 @@
+// tests/smarthome_rules_api.test.js
+'use strict';
+const { test, before, after } = require('node:test');
+const assert = require('node:assert/strict');
+const nodeCrypto = require('node:crypto');
+process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || nodeCrypto.randomBytes(32).toString('hex');
+const { setup, teardown } = require('./helpers/setup');
+
+let app, agent, csrfToken, dev, rules;
+before(async () => {
+ ({ app, agent, csrfToken } = await setup());
+ require('../src/services/license')._overrideForTest({ smarthome: true });
+ dev = require('../src/services/smarthome/smarthomeDevices');
+ rules = require('../src/services/smarthome/smarthomeRules');
+ rules._setClientFactoryForTest(() => ({
+ getRules: async () => ({ '1': {}, '2': {} }),
+ createRule: async () => 'R1', updateRule: async () => {}, deleteRule: async () => {},
+ createSchedule: async () => 'S1', deleteSchedule: async () => {}, createClipSensor: async () => 'C1', deleteClipSensor: async () => {},
+ }));
+});
+after(async () => { await teardown(); });
+
+test('POST /rules creates and GET /rules lists it', async () => {
+ const gw = dev.createGateway({ name: 'GW', route_id: null, apiKey: 'K', enabled: true });
+ const m = dev.upsertResource({ gateway_id: gw.id, deconz_id: '12', deconz_type: 'sensors', kind: 'sensor', name: 'M', capabilities: {} });
+ const g = dev.upsertResource({ gateway_id: gw.id, deconz_id: '30', deconz_type: 'groups', kind: 'group', name: 'G', capabilities: { on: true } });
+ const def = { triggers: [{ kind: 'motion', resourceId: m, event: 'detected' }], actions: [{ kind: 'group', resourceId: g, set: { on: true } }] };
+ const created = await agent.post('/api/v1/smarthome/rules').set('x-csrf-token', csrfToken).send({ gateway_id: gw.id, name: 'Flur', definition: def }).expect(200);
+ assert.ok(created.body.rule.id);
+ const list = await agent.get(`/api/v1/smarthome/rules?gateway_id=${gw.id}`).expect(200);
+ assert.equal(list.body.rules.length, 1);
+ assert.equal(typeof list.body.limit_warn, 'boolean');
+});
+
+test('GET /rules/gateway-count returns totals', async () => {
+ const gw = dev.createGateway({ name: 'GW2', route_id: null, apiKey: 'K', enabled: true });
+ const res = await agent.get(`/api/v1/smarthome/rules/gateway-count?gateway_id=${gw.id}`).expect(200);
+ assert.equal(res.body.total_rules, 2);
+ assert.ok('external_rules' in res.body);
+});
+
+test('POST /rules without CSRF token → 403 (guard present on mutation)', async () => {
+ const gw = dev.createGateway({ name: 'GWNX', route_id: null, apiKey: 'K', enabled: true });
+ await agent.post('/api/v1/smarthome/rules').send({ gateway_id: gw.id, name: 'X', definition: {} }).expect(403);
+});
+
+test('PUT /rules/:id replaces name and definition', async () => {
+ const gw = dev.createGateway({ name: 'GWP', route_id: null, apiKey: 'K', enabled: true });
+ const m = dev.upsertResource({ gateway_id: gw.id, deconz_id: '12', deconz_type: 'sensors', kind: 'sensor', name: 'M', capabilities: {} });
+ const g = dev.upsertResource({ gateway_id: gw.id, deconz_id: '30', deconz_type: 'groups', kind: 'group', name: 'G', capabilities: { on: true } });
+ const def = { triggers: [{ kind: 'motion', resourceId: m, event: 'detected' }], actions: [{ kind: 'group', resourceId: g, set: { on: true } }] };
+ const created = await agent.post('/api/v1/smarthome/rules').set('x-csrf-token', csrfToken).send({ gateway_id: gw.id, name: 'R', definition: def }).expect(200);
+ const upd = await agent.put(`/api/v1/smarthome/rules/${created.body.rule.id}`).set('x-csrf-token', csrfToken).send({ name: 'R2', definition: def }).expect(200);
+ assert.equal(upd.body.rule.name, 'R2');
+});
+
+test('POST /rules with invalid resource → 400', async () => {
+ const gw = dev.createGateway({ name: 'GW3', route_id: null, apiKey: 'K', enabled: true });
+ const def = { triggers: [{ kind: 'motion', resourceId: 99999, event: 'detected' }], actions: [{ kind: 'group', resourceId: 99999, set: { on: true } }] };
+ await agent.post('/api/v1/smarthome/rules').set('x-csrf-token', csrfToken).send({ gateway_id: gw.id, name: 'bad', definition: def }).expect(400);
+});
+
+test('PUT /rules/:id without definition → 400 (guard before delete)', async () => {
+ const gw = dev.createGateway({ name: 'GWPG', route_id: null, apiKey: 'K', enabled: true });
+ const m = dev.upsertResource({ gateway_id: gw.id, deconz_id: '12', deconz_type: 'sensors', kind: 'sensor', name: 'M', capabilities: {} });
+ const g = dev.upsertResource({ gateway_id: gw.id, deconz_id: '30', deconz_type: 'groups', kind: 'group', name: 'G', capabilities: { on: true } });
+ const def = { triggers: [{ kind: 'motion', resourceId: m, event: 'detected' }], actions: [{ kind: 'group', resourceId: g, set: { on: true } }] };
+ const created = await agent.post('/api/v1/smarthome/rules').set('x-csrf-token', csrfToken).send({ gateway_id: gw.id, name: 'R', definition: def }).expect(200);
+ await agent.put(`/api/v1/smarthome/rules/${created.body.rule.id}`).set('x-csrf-token', csrfToken).send({ name: 'X' }).expect(400);
+});
+
+test('POST /rules with invalid resource → 400 with localized error message', async () => {
+ const gw = dev.createGateway({ name: 'GW5', route_id: null, apiKey: 'K', enabled: true });
+ const def = { triggers: [{ kind: 'motion', resourceId: 99999, event: 'detected' }], actions: [{ kind: 'group', resourceId: 99999, set: { on: true } }] };
+ const res = await agent.post('/api/v1/smarthome/rules').set('x-csrf-token', csrfToken).send({ gateway_id: gw.id, name: 'bad', definition: def }).expect(400);
+ assert.equal(res.body.error, 'Invalid rule definition');
+ assert.equal(res.body.code, 'SMARTHOME_RULE_INVALID');
+});
+
+test('DELETE and enabled toggle work', async () => {
+ const gw = dev.createGateway({ name: 'GW4', route_id: null, apiKey: 'K', enabled: true });
+ const g = dev.upsertResource({ gateway_id: gw.id, deconz_id: '30', deconz_type: 'groups', kind: 'group', name: 'G', capabilities: { on: true } });
+ const m = dev.upsertResource({ gateway_id: gw.id, deconz_id: '12', deconz_type: 'sensors', kind: 'sensor', name: 'M', capabilities: {} });
+ const def = { triggers: [{ kind: 'motion', resourceId: m, event: 'detected' }], actions: [{ kind: 'group', resourceId: g, set: { on: true } }] };
+ const created = await agent.post('/api/v1/smarthome/rules').set('x-csrf-token', csrfToken).send({ gateway_id: gw.id, name: 'R', definition: def }).expect(200);
+ const id = created.body.rule.id;
+ await agent.post(`/api/v1/smarthome/rules/${id}/enabled`).set('x-csrf-token', csrfToken).send({ enabled: false }).expect(200);
+ await agent.delete(`/api/v1/smarthome/rules/${id}`).set('x-csrf-token', csrfToken).expect(200);
+});
diff --git a/tests/smarthome_rules_objects.test.js b/tests/smarthome_rules_objects.test.js
new file mode 100644
index 00000000..2a1aa95a
--- /dev/null
+++ b/tests/smarthome_rules_objects.test.js
@@ -0,0 +1,49 @@
+'use strict';
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const T = require('../src/services/smarthome/rulesTranslate');
+
+const R = {
+ 12: { deconz_id: '12', deconz_type: 'sensors', kind: 'sensor', capabilities: {} },
+ 30: { deconz_id: '30', deconz_type: 'groups', kind: 'group', capabilities: { on: true } },
+};
+const resolve = (id) => R[id];
+
+test('no delay → single rule object', () => {
+ const def = { triggers: [{ kind: 'motion', resourceId: 12, event: 'detected' }], actions: [{ kind: 'group', resourceId: 30, set: { on: true } }] };
+ const { objects } = T.buildRuleObjects(def, resolve, 'GC:1:test');
+ assert.equal(objects.length, 1);
+ assert.equal(objects[0].type, 'rule');
+});
+
+test('onRetrigger ignore → rule + schedule', () => {
+ const def = { triggers: [{ kind: 'motion', resourceId: 12, event: 'ended' }], actions: [{ kind: 'group', resourceId: 30, set: { on: false } }], delay: { minutes: 5, onRetrigger: 'ignore' } };
+ const { objects } = T.buildRuleObjects(def, resolve, 'GC:1:t');
+ assert.deepEqual(objects.map((o) => o.type), ['schedule', 'rule']);
+});
+
+test('onRetrigger reset → rule + schedule + reset-rule', () => {
+ const def = { triggers: [{ kind: 'motion', resourceId: 12, event: 'ended' }], actions: [{ kind: 'group', resourceId: 30, set: { on: false } }], delay: { minutes: 5, onRetrigger: 'reset' } };
+ const { objects } = T.buildRuleObjects(def, resolve, 'GC:1:t');
+ assert.deepEqual(objects.map((o) => o.type), ['schedule', 'rule', 'rule']);
+});
+
+test('onRetrigger cancel → clip + rule + cancel-rule when supported', () => {
+ const def = { triggers: [{ kind: 'motion', resourceId: 12, event: 'ended' }], actions: [{ kind: 'group', resourceId: 30, set: { on: false } }], delay: { minutes: 5, onRetrigger: 'cancel' } };
+ const { objects, effectiveOnRetrigger } = T.buildRuleObjects(def, resolve, 'GC:1:t');
+ assert.equal(effectiveOnRetrigger, require('../src/services/smarthome/deconzCapabilities').cancelSupported ? 'cancel' : 'reset');
+ if (effectiveOnRetrigger === 'cancel') {
+ assert.deepEqual(objects.map((o) => o.type), ['clip', 'schedule', 'rule', 'rule']);
+ const arm = objects.find((o) => o.ref === 'arm');
+ assert.ok(arm.payload.actions.some((a) => a.body && a.body.flag === true)); // arm setzt das CLIP-Flag
+ const cancel = objects.find((o) => o.ref === 'cancel');
+ assert.ok(cancel.payload.actions.some((a) => a.body && a.body.flag === false)); // cancel löscht das Flag
+ }
+});
+
+test('cancel mode with only a button trigger is rejected (no binary-invertible trigger)', () => {
+ const def = { triggers: [{ kind: 'button', resourceId: 12, button: 1, action: 'short' }], actions: [{ kind: 'group', resourceId: 30, set: { on: false } }], delay: { minutes: 5, onRetrigger: 'cancel' } };
+ if (require('../src/services/smarthome/deconzCapabilities').cancelSupported) {
+ assert.throws(() => T.buildRuleObjects(def, resolve, 'GC:1:t'), (e) => e.code === 'SMARTHOME_RULE_INVALID' && e.detail === 'cancel_requires_binary_trigger');
+ }
+});
diff --git a/tests/smarthome_rules_resync.test.js b/tests/smarthome_rules_resync.test.js
new file mode 100644
index 00000000..454add24
--- /dev/null
+++ b/tests/smarthome_rules_resync.test.js
@@ -0,0 +1,28 @@
+'use strict';
+const { test, beforeEach, afterEach } = require('node:test');
+const assert = require('node:assert/strict');
+const nodeCrypto = require('node:crypto');
+process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || nodeCrypto.randomBytes(32).toString('hex');
+const { setup, teardown } = require('./helpers/setup');
+const { getDb } = require('../src/db/connection');
+
+let dev, rules;
+beforeEach(async () => { await setup(); dev = require('../src/services/smarthome/smarthomeDevices'); rules = require('../src/services/smarthome/smarthomeRules'); });
+afterEach(async () => { await teardown(); });
+
+test('resyncPending rewrites enabled rules with NULL deconz_rule_id', async () => {
+ const log = [];
+ rules._setClientFactoryForTest(() => ({
+ getRules: async () => ({}), createRule: async () => { log.push('createRule'); return 'R1'; },
+ updateRule: async () => {}, deleteRule: async () => {}, createSchedule: async () => 'S1', deleteSchedule: async () => {}, createClipSensor: async () => 'C1',
+ }));
+ const gw = dev.createGateway({ name: 'GW', route_id: null, apiKey: 'K', enabled: true });
+ const m = dev.upsertResource({ gateway_id: gw.id, deconz_id: '12', deconz_type: 'sensors', kind: 'sensor', name: 'M', capabilities: {} });
+ const g = dev.upsertResource({ gateway_id: gw.id, deconz_id: '30', deconz_type: 'groups', kind: 'group', name: 'G', capabilities: { on: true } });
+ const def = JSON.stringify({ triggers: [{ kind: 'motion', resourceId: m, event: 'detected' }], actions: [{ kind: 'group', resourceId: g, set: { on: true } }] });
+ getDb().prepare('INSERT INTO smarthome_rules (gateway_id, name, enabled, definition_json, deconz_rule_id) VALUES (?,?,1,?,NULL)').run(gw.id, 'Pending', def);
+ const n = await rules.resyncPending();
+ assert.equal(n, 1);
+ assert.ok(log.includes('createRule'));
+ assert.ok(rules.list(gw.id)[0].deconz_rule_id);
+});
diff --git a/tests/smarthome_rules_service.test.js b/tests/smarthome_rules_service.test.js
new file mode 100644
index 00000000..cd6f2531
--- /dev/null
+++ b/tests/smarthome_rules_service.test.js
@@ -0,0 +1,90 @@
+'use strict';
+const { test, beforeEach, afterEach } = require('node:test');
+const assert = require('node:assert/strict');
+const nodeCrypto = require('node:crypto');
+process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || nodeCrypto.randomBytes(32).toString('hex');
+const { setup, teardown } = require('./helpers/setup');
+
+let dev, rules, svc;
+beforeEach(async () => {
+ await setup();
+ dev = require('../src/services/smarthome/smarthomeDevices');
+ svc = require('../src/services/smarthome');
+ rules = require('../src/services/smarthome/smarthomeRules');
+});
+afterEach(async () => { await teardown(); });
+
+// Fake deconz client capturing calls; injected via rules._setClientFactoryForTest.
+function fakeClient(log, opts = {}) {
+ let n = 0;
+ return {
+ getRules: async () => opts.rules || {},
+ createRule: async (r) => { log.push(['createRule', r.name]); if (opts.failOnRule && r.name.includes(opts.failOnRule)) { const e = new Error('limit'); e.code = 'DECONZ_HTTP_503'; throw e; } return `R${++n}`; },
+ updateRule: async (id, r) => log.push(['updateRule', id]),
+ deleteRule: async (id) => log.push(['deleteRule', id]),
+ createSchedule: async (s) => { log.push(['createSchedule', s.name]); return `S${++n}`; },
+ deleteSchedule: async (id) => log.push(['deleteSchedule', id]),
+ createClipSensor: async (s) => { log.push(['createClipSensor', s.name]); return `C${++n}`; },
+ deleteClipSensor: async (id) => log.push(['deleteClipSensor', id]),
+ };
+}
+
+function mkGatewayAndMotionGroup() {
+ const gw = dev.createGateway({ name: 'GW', route_id: null, apiKey: 'K', enabled: true });
+ const motion = dev.upsertResource({ gateway_id: gw.id, deconz_id: '12', deconz_type: 'sensors', kind: 'sensor', name: 'M', capabilities: {} });
+ const group = dev.upsertResource({ gateway_id: gw.id, deconz_id: '30', deconz_type: 'groups', kind: 'group', name: 'G', capabilities: { on: true } });
+ return { gw, motion, group };
+}
+
+test('create persists deconz_rule_id and lists as synced', async () => {
+ const log = [];
+ rules._setClientFactoryForTest(() => fakeClient(log));
+ const { gw, motion, group } = mkGatewayAndMotionGroup();
+ const def = { triggers: [{ kind: 'motion', resourceId: motion, event: 'detected' }], actions: [{ kind: 'group', resourceId: group, set: { on: true } }] };
+ const row = await rules.create(gw.id, 'Flur', def);
+ assert.ok(row.deconz_rule_id);
+ const list = rules.list(gw.id);
+ assert.equal(list[0].synced, true);
+ assert.ok(log.some((l) => l[0] === 'createRule'));
+});
+
+test('create compensates: schedule created then rule fails → schedule deleted, no GC row', async () => {
+ const log = [];
+ rules._setClientFactoryForTest(() => fakeClient(log, { failOnRule: 'Flur' }));
+ const { gw, motion, group } = mkGatewayAndMotionGroup();
+ const def = { triggers: [{ kind: 'motion', resourceId: motion, event: 'ended' }], actions: [{ kind: 'group', resourceId: group, set: { on: false } }], delay: { minutes: 5, onRetrigger: 'ignore' } };
+ await assert.rejects(() => rules.create(gw.id, 'Flur', def), (e) => e.code === 'DECONZ_RULE_LIMIT_REACHED');
+ assert.ok(log.some((l) => l[0] === 'deleteSchedule')); // compensation ran
+ assert.equal(rules.list(gw.id).length, 0); // no orphan GC row
+});
+
+test('update nulls ids before delete; on success re-persists', async () => {
+ const log = [];
+ rules._setClientFactoryForTest(() => fakeClient(log));
+ const { gw, motion, group } = mkGatewayAndMotionGroup();
+ const def = { triggers: [{ kind: 'motion', resourceId: motion, event: 'detected' }], actions: [{ kind: 'group', resourceId: group, set: { on: true } }] };
+ const row = await rules.create(gw.id, 'R', def);
+ const updated = await rules.update(row.id, 'R2', def);
+ assert.equal(updated.name, 'R2');
+ assert.ok(updated.deconz_rule_id);
+ assert.ok(log.some((l) => l[0] === 'deleteRule')); // old deleted
+});
+
+test('remove deletes deconz objects then row (404 ignored)', async () => {
+ const log = [];
+ rules._setClientFactoryForTest(() => ({ ...fakeClient(log), deleteRule: async () => { const e = new Error('gone'); e.code = 'DECONZ_HTTP_404'; throw e; } }));
+ const { gw, motion, group } = mkGatewayAndMotionGroup();
+ // seed a row directly with a fake id:
+ const { getDb } = require('../src/db/connection');
+ const id = Number(getDb().prepare("INSERT INTO smarthome_rules (gateway_id, name, enabled, definition_json, deconz_rule_id) VALUES (?,?,1,?,?)").run(gw.id, 'X', '{}', 'R9').lastInsertRowid);
+ await rules.remove(id); // must not throw despite 404
+ assert.equal(rules.list(gw.id).length, 0);
+});
+
+test('DECONZ_RULE_LIMIT_REACHED mapped from 503/507', async () => {
+ const log = [];
+ rules._setClientFactoryForTest(() => fakeClient(log, { failOnRule: 'L' }));
+ const { gw, motion, group } = mkGatewayAndMotionGroup();
+ const def = { triggers: [{ kind: 'motion', resourceId: motion, event: 'detected' }], actions: [{ kind: 'group', resourceId: group, set: { on: true } }] };
+ await assert.rejects(() => rules.create(gw.id, 'L', def), (e) => e.code === 'DECONZ_RULE_LIMIT_REACHED');
+});
diff --git a/tests/smarthome_rules_translate.test.js b/tests/smarthome_rules_translate.test.js
new file mode 100644
index 00000000..176b1fb4
--- /dev/null
+++ b/tests/smarthome_rules_translate.test.js
@@ -0,0 +1,75 @@
+'use strict';
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const T = require('../src/services/smarthome/rulesTranslate');
+const caps = require('../src/services/smarthome/deconzCapabilities');
+const vectors = require('./fixtures/deconz_spike_vectors');
+
+// Stub-resolve: bildet resourceId → deCONZ-Koordinaten ab.
+const R = {
+ 12: { deconz_id: '12', deconz_type: 'sensors', kind: 'sensor', capabilities: {} }, // motion (presence)
+ 5: { deconz_id: '5', deconz_type: 'sensors', kind: 'sensor', capabilities: {} }, // temperature
+ 20: { deconz_id: '20', deconz_type: 'lights', kind: 'light', capabilities: { on: true, bri: true } },
+ 30: { deconz_id: '30', deconz_type: 'groups', kind: 'group', capabilities: { on: true } },
+ 40: { deconz_id: '40', deconz_type: 'lights', kind: 'plug', capabilities: { on: true } },
+ 41: { deconz_id: '8/2', deconz_type: 'scenes', kind: 'scene', capabilities: {} }, // group 8, scene 2
+};
+const resolve = (id) => { const r = R[id]; if (!r) { const e = new Error('missing'); e.code = 'SMARTHOME_RULE_INVALID'; e.detail = 'unknown_resource'; throw e; } return r; };
+
+test('motion trigger → presence eq + lastupdated dx; temperature threshold ×100; time window in', () => {
+ const def = {
+ triggers: [
+ { kind: 'motion', resourceId: 12, event: 'detected' },
+ { kind: 'temperature', resourceId: 5, op: 'lt', value: 5 },
+ ],
+ timeWindow: { from: '18:00', to: '06:00' },
+ actions: [{ kind: 'group', resourceId: 30, set: { on: false } }],
+ };
+ const c = T.buildConditions(def, resolve);
+ assert.deepEqual(c, [
+ { address: '/sensors/12/state/presence', operator: 'eq', value: 'true' },
+ { address: '/sensors/12/state/lastupdated', operator: 'dx' },
+ { address: '/sensors/5/state/temperature', operator: 'lt', value: '500' },
+ { address: '/config/localtime', operator: 'in', value: 'T18:00:00/T06:00:00' },
+ ]);
+});
+
+test('multiple event triggers each get their own dx (edge-OR / state-AND)', () => {
+ const def = { triggers: [
+ { kind: 'motion', resourceId: 12, event: 'ended' },
+ { kind: 'button', resourceId: 12, button: 1, action: 'short' }, // reuse 12 as a switch for address shape
+ ], actions: [{ kind: 'group', resourceId: 30, set: { on: false } }] };
+ const c = T.buildConditions(def, resolve);
+ const dx = c.filter((x) => x.operator === 'dx');
+ assert.equal(dx.length, 2); // both event triggers carry dx
+});
+
+test('actions: light set on+bri, group on, scene recall body, plug rejects bri', () => {
+ const a = T.buildActions({ actions: [
+ { kind: 'light', resourceId: 20, set: { on: true, bri: 60 } },
+ { kind: 'group', resourceId: 30, set: { on: false } },
+ { kind: 'scene', resourceId: 41 },
+ ] }, resolve);
+ assert.deepEqual(a[0], { address: '/lights/20/state', method: 'PUT', body: { on: true, bri: 152 } }); // 60% → 152 (round(0.6*254), deconzClient uses ×254)
+ assert.deepEqual(a[1], { address: '/groups/30/action', method: 'PUT', body: { on: false } });
+ assert.deepEqual(a[2], { address: '/groups/8/scenes/2/recall', method: 'PUT', body: { on: true } });
+ assert.throws(() => T.buildActions({ actions: [{ kind: 'plug', resourceId: 40, set: { on: true, bri: 50 } }] }, resolve),
+ (e) => e.code === 'SMARTHOME_RULE_INVALID' && e.detail === 'plug_no_bri');
+});
+
+test('unknown resource throws SMARTHOME_RULE_INVALID', () => {
+ assert.throws(() => T.buildActions({ actions: [{ kind: 'light', resourceId: 999, set: { on: true } }] }, resolve),
+ (e) => e.code === 'SMARTHOME_RULE_INVALID');
+});
+
+// Step 4b: spike-vector-grounded assertions to anchor the live contract.
+test('spike vectors: buttonCode resolves RWL021 button 4 short → 4002', () => {
+ const { modelid, state: { buttonevent } } = vectors.zhaSwitchSample;
+ assert.equal(caps.buttonCode(modelid, 4, 'short'), buttonevent); // 4002
+});
+
+test('spike vectors: daylight field is "daylight" and daylightSample.state has matching bool', () => {
+ assert.equal(caps.daylight.sunrise.field, 'daylight');
+ // sunrise value is 'true'; daylightSample.state.daylight is true at time of spike.
+ assert.equal(String(vectors.daylightSample.state.daylight), caps.daylight.sunrise.value);
+});