Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Changelog

## [Unreleased]

### Fixed
- Smart Home rules: rule-limit (409) now shows only the banner instead of banner + alert (double signal)
- Smart Home rules: rule-name field enforces the 20-char cap client-side (`maxlength`) across all three themes
- Smart Home rules: `limitWarn` now derives from `deconzCapabilities.ruleLimit.warnAtGcRules` (single source) instead of a duplicated magic threshold; corrected a stale comment on cancel-condition inversion

### Tests
- Added coverage for the daylight trigger translation, the unknown-button-action rejection, and `deconzClient.deleteSchedule`

---

## [1.113.1] — 2026-07-01

### Fixes
Expand Down
2 changes: 1 addition & 1 deletion public/js/smarthome-rules.js
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@
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
if (e.status === 409) { showLimit(e.message); return; } // rule-limit / no-api-key → banner only
alert(e.message); // 400 = validation detail from the server
}
}
Expand Down
4 changes: 2 additions & 2 deletions src/services/smarthome/rulesTranslate.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,8 @@ function relTime(mins) {
}

// 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.
// Only eq-conditions with 'true'/'false' values are binary-invertible (presence/open/water/daylight).
// button conditions (numeric buttonevent code) are excluded — not invertible.
function cancelConditionsFrom(conditions) {
return conditions
.filter((c) => c.operator === 'eq' && (c.value === 'true' || c.value === 'false'))
Expand Down
2 changes: 1 addition & 1 deletion src/services/smarthome/smarthomeRules.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ 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)
function limitWarn(gcRuleCount) { return gcRuleCount >= caps.ruleLimit.warnAtGcRules; } // §8/§10: gc rules × Worst-Case-4-Slots > 150 ⇒ ≥ warnAtGcRules (=38)

// Default-Factory: lokaler Gateway-Client wie index.js' privates clientForGateway (kein index-Import → kein Zirkularbezug).
function defaultClientFactory(gatewayId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<div class="modal-body">
<div class="form-group">
<label class="form-label" for="shr-name">{{ t('smarthome.rules.name_label') }}</label>
<input class="form-input" id="shr-name" type="text">
<input class="form-input" id="shr-name" type="text" maxlength="20">
</div>

<!-- WENN -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<div class="modal-body">
<div class="form-group">
<label class="form-label" for="shr-name">{{ t('smarthome.rules.name_label') }}</label>
<input class="form-input" id="shr-name" type="text">
<input class="form-input" id="shr-name" type="text" maxlength="20">
</div>

<!-- WENN -->
Expand Down
2 changes: 1 addition & 1 deletion templates/pro/partials/modals/smarthome-rule-builder.njk
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<div class="modal-body">
<div class="form-group">
<label class="form-label" for="shr-name">{{ t('smarthome.rules.name_label') }}</label>
<input class="form-input" id="shr-name" type="text">
<input class="form-input" id="shr-name" type="text" maxlength="20">
</div>

<!-- WENN -->
Expand Down
5 changes: 5 additions & 0 deletions tests/smarthome_deconz_rules_client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,8 @@ 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');
});

test('deleteSchedule DELETEs /schedules/:id', async () => {
mockFetch((url, opts) => { assert.equal(url, 'http://gw/api/KEY/schedules/s2'); assert.equal(opts.method, 'DELETE'); return jsonRes([{ success: '/schedules/s2 deleted.' }]); });
await client().deleteSchedule('s2');
});
17 changes: 17 additions & 0 deletions tests/smarthome_rules_translate.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const vectors = require('./fixtures/deconz_spike_vectors');

// Stub-resolve: bildet resourceId → deCONZ-Koordinaten ab.
const R = {
1: { deconz_id: '1', deconz_type: 'sensors', kind: 'sensor', capabilities: {} }, // Daylight sensor
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 } },
Expand Down Expand Up @@ -62,6 +63,22 @@ test('unknown resource throws SMARTHOME_RULE_INVALID', () => {
(e) => e.code === 'SMARTHOME_RULE_INVALID');
});

test('daylight trigger → daylight eq false (sunset) + lastupdated dx', () => {
// daylight needs the Daylight sensor's resourceId (the §3 no-resourceId shorthand is UI-latent; the API resolves it).
const def = { triggers: [{ kind: 'daylight', resourceId: 1, event: 'sunset' }], actions: [{ kind: 'group', resourceId: 30, set: { on: false } }] };
const c = T.buildConditions(def, resolve);
assert.deepEqual(c, [
{ address: '/sensors/1/state/daylight', operator: 'eq', value: 'false' }, // sunset = daylight false
{ address: '/sensors/1/state/lastupdated', operator: 'dx' },
]);
});

test('button with unknown action throws unknown_button_action', () => {
const def = { triggers: [{ kind: 'button', resourceId: 12, button: 1, action: 'triple' }], actions: [{ kind: 'group', resourceId: 30, set: { on: false } }] };
assert.throws(() => T.buildConditions(def, resolve),
(e) => e.code === 'SMARTHOME_RULE_INVALID' && e.detail === 'unknown_button_action');
});

// 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;
Expand Down
Loading