From eb495574daab6a901b96f298ab58d90712dc5c73 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:15:56 +0200 Subject: [PATCH 01/13] feat(smarthome): deconz rule/schedule/CLIP capabilities from live spike --- src/services/smarthome/deconzCapabilities.js | 47 ++++++++++++++++++++ tests/fixtures/deconz_spike_vectors.js | 39 ++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 src/services/smarthome/deconzCapabilities.js create mode 100644 tests/fixtures/deconz_spike_vectors.js 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/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' }, + }, +}; From 51f1452cf83d7e576739c600aa7f9dcb1591bffc Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:17:55 +0200 Subject: [PATCH 02/13] feat(smarthome): deconz client methods for rules/schedules/clip sensors --- src/services/smarthome/deconzClient.js | 14 +++++ tests/smarthome_deconz_rules_client.test.js | 63 +++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 tests/smarthome_deconz_rules_client.test.js 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/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'); +}); From 66e6b46f73aed493d78892f920b1bc87e4a3f1ee Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:23:20 +0200 Subject: [PATCH 03/13] feat(smarthome): translate rule definition to deconz conditions/actions --- src/services/smarthome/rulesTranslate.js | 79 ++++++++++++++++++++++++ tests/smarthome_rules_translate.test.js | 75 ++++++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 src/services/smarthome/rulesTranslate.js create mode 100644 tests/smarthome_rules_translate.test.js diff --git a/src/services/smarthome/rulesTranslate.js b/src/services/smarthome/rulesTranslate.js new file mode 100644 index 00000000..b14f7272 --- /dev/null +++ b/src/services/smarthome/rulesTranslate.js @@ -0,0 +1,79 @@ +'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 }; + }); +} + +module.exports = { buildConditions, buildActions }; 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); +}); From 2de5db9269b9b603f5ba64e2390e6f18434ab780 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:28:53 +0200 Subject: [PATCH 04/13] feat(smarthome): build deconz object plan per delay mode (ignore/reset/cancel) --- src/services/smarthome/rulesTranslate.js | 62 +++++++++++++++++++++++- tests/smarthome_rules_objects.test.js | 49 +++++++++++++++++++ 2 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 tests/smarthome_rules_objects.test.js diff --git a/src/services/smarthome/rulesTranslate.js b/src/services/smarthome/rulesTranslate.js index b14f7272..d2738ca6 100644 --- a/src/services/smarthome/rulesTranslate.js +++ b/src/services/smarthome/rulesTranslate.js @@ -76,4 +76,64 @@ function buildActions(def, resolve) { }); } -module.exports = { buildConditions, buildActions }; +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 = [ + { type: 'rule', payload: { name: ruleLabel, status: 'enabled', conditions, actions: [armSchedule] }, ref: 'arm' }, + schedule, + ]; + 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/tests/smarthome_rules_objects.test.js b/tests/smarthome_rules_objects.test.js new file mode 100644 index 00000000..b316d363 --- /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), ['rule', 'schedule']); +}); + +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), ['rule', 'schedule', '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'); + } +}); From 0740c73ce82868a641936c94d43dd484ad11ff54 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:31:09 +0200 Subject: [PATCH 05/13] fix(smarthome): schedule-first object order for ignore/reset delay modes --- src/services/smarthome/rulesTranslate.js | 2 +- tests/smarthome_rules_objects.test.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/services/smarthome/rulesTranslate.js b/src/services/smarthome/rulesTranslate.js index d2738ca6..0ce8ce7a 100644 --- a/src/services/smarthome/rulesTranslate.js +++ b/src/services/smarthome/rulesTranslate.js @@ -115,8 +115,8 @@ function buildRuleObjects(def, resolve, ruleLabel) { if (mode === 'ignore' || mode === 'reset') { const objects = [ - { type: 'rule', payload: { name: ruleLabel, status: 'enabled', conditions, actions: [armSchedule] }, ref: 'arm' }, 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' }); diff --git a/tests/smarthome_rules_objects.test.js b/tests/smarthome_rules_objects.test.js index b316d363..2a1aa95a 100644 --- a/tests/smarthome_rules_objects.test.js +++ b/tests/smarthome_rules_objects.test.js @@ -19,13 +19,13 @@ test('no delay → single rule object', () => { 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), ['rule', 'schedule']); + 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), ['rule', 'schedule', 'rule']); + assert.deepEqual(objects.map((o) => o.type), ['schedule', 'rule', 'rule']); }); test('onRetrigger cancel → clip + rule + cancel-rule when supported', () => { From 2065acfbc2820cd709450b65b088072439cc0450 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:37:38 +0200 Subject: [PATCH 06/13] feat(smarthome): rules service with compensation, update atomicity, limit detection --- src/services/smarthome/smarthomeRules.js | 179 +++++++++++++++++++++++ tests/smarthome_rules_service.test.js | 90 ++++++++++++ 2 files changed, 269 insertions(+) create mode 100644 src/services/smarthome/smarthomeRules.js create mode 100644 tests/smarthome_rules_service.test.js 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/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'); +}); From 5417b929450f3184d3a2fb4166e223b6e43e1527 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:42:01 +0200 Subject: [PATCH 07/13] feat(smarthome): re-push pending (unsynced) rules on boot --- src/server.js | 2 ++ tests/smarthome_rules_resync.test.js | 28 ++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 tests/smarthome_rules_resync.test.js 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/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); +}); From 37fef18829f44ddca26295b233fdb612987867cf Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:46:26 +0200 Subject: [PATCH 08/13] feat(smarthome): admin API for logic-chain rules (CRUD + gateway count) --- src/i18n/de.json | 3 ++ src/i18n/en.json | 3 ++ src/routes/api/smarthome.js | 42 ++++++++++++++++++ tests/smarthome_rules_api.test.js | 72 +++++++++++++++++++++++++++++++ 4 files changed, 120 insertions(+) create mode 100644 tests/smarthome_rules_api.test.js diff --git a/src/i18n/de.json b/src/i18n/de.json index 6680be8d..8cc3cca3 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", diff --git a/src/i18n/en.json b/src/i18n/en.json index 591b2132..0847727a 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", diff --git a/src/routes/api/smarthome.js b/src/routes/api/smarthome.js index d747a9e1..38cd226b 100644 --- a/src/routes/api/smarthome.js +++ b/src/routes/api/smarthome.js @@ -5,6 +5,8 @@ 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(); @@ -28,6 +30,9 @@ 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 }); } @@ -92,4 +97,41 @@ 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 || {}; + 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/tests/smarthome_rules_api.test.js b/tests/smarthome_rules_api.test.js new file mode 100644 index 00000000..c3913df3 --- /dev/null +++ b/tests/smarthome_rules_api.test.js @@ -0,0 +1,72 @@ +// 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('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); +}); From 14466036c9cb2a10a5dc6c5720f64557ba360c1c Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:51:54 +0200 Subject: [PATCH 09/13] feat(smarthome): rules subpage route + shell templates (3 themes) --- src/routes/index.js | 1 + templates/aurora/pages/smarthome-rules.njk | 25 +++++++ templates/aurora/pages/smarthome.njk | 1 + .../modals/smarthome-rule-builder.njk | 65 +++++++++++++++++++ templates/default/pages/smarthome-rules.njk | 25 +++++++ templates/default/pages/smarthome.njk | 1 + .../modals/smarthome-rule-builder.njk | 65 +++++++++++++++++++ templates/pro/pages/smarthome-rules.njk | 25 +++++++ templates/pro/pages/smarthome.njk | 1 + .../modals/smarthome-rule-builder.njk | 65 +++++++++++++++++++ 10 files changed, 274 insertions(+) create mode 100644 templates/aurora/pages/smarthome-rules.njk create mode 100644 templates/aurora/partials/modals/smarthome-rule-builder.njk create mode 100644 templates/default/pages/smarthome-rules.njk create mode 100644 templates/default/partials/modals/smarthome-rule-builder.njk create mode 100644 templates/pro/pages/smarthome-rules.njk create mode 100644 templates/pro/partials/modals/smarthome-rule-builder.njk 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/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..35e2bd40 --- /dev/null +++ b/templates/aurora/partials/modals/smarthome-rule-builder.njk @@ -0,0 +1,65 @@ + 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..35e2bd40 --- /dev/null +++ b/templates/default/partials/modals/smarthome-rule-builder.njk @@ -0,0 +1,65 @@ + 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..35e2bd40 --- /dev/null +++ b/templates/pro/partials/modals/smarthome-rule-builder.njk @@ -0,0 +1,65 @@ + From 905d89dd11334ec71f0543de4c52bc28be8f198b Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:03:17 +0200 Subject: [PATCH 10/13] feat(smarthome): rules builder JS and styles --- public/css/smarthome.css | 32 ++++ public/js/smarthome-rules.js | 334 +++++++++++++++++++++++++++++++++++ 2 files changed, 366 insertions(+) create mode 100644 public/js/smarthome-rules.js 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 }; +})(); From 29688c095b24404aa2e5a9e82af56d5bbd3871eb Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:09:55 +0200 Subject: [PATCH 11/13] feat(smarthome): rules i18n keys + GC.t bridge (3 layouts) --- src/i18n/de.json | 37 +++++++++++++++++++ src/i18n/en.json | 37 +++++++++++++++++++ templates/aurora/layout.njk | 37 +++++++++++++++++++ .../modals/smarthome-rule-builder.njk | 4 +- templates/default/layout.njk | 37 +++++++++++++++++++ .../modals/smarthome-rule-builder.njk | 4 +- templates/pro/layout.njk | 37 +++++++++++++++++++ .../modals/smarthome-rule-builder.njk | 4 +- 8 files changed, 191 insertions(+), 6 deletions(-) diff --git a/src/i18n/de.json b/src/i18n/de.json index 8cc3cca3..35f43ee9 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -2148,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 0847727a..447e3b7b 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -2204,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/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/partials/modals/smarthome-rule-builder.njk b/templates/aurora/partials/modals/smarthome-rule-builder.njk index 35e2bd40..014b6134 100644 --- a/templates/aurora/partials/modals/smarthome-rule-builder.njk +++ b/templates/aurora/partials/modals/smarthome-rule-builder.njk @@ -1,14 +1,14 @@