@feezal/feezal-element 3.0.12 → 3.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,77 @@
1
+ /**
2
+ * @feezal/feezal-element/feezal-color.js (E137)
3
+ *
4
+ * Cross-controller color/scaling machinery shared by the light (and later
5
+ * wled) behavior controllers — consolidated from the byte-identical copies
6
+ * that lived in material/glass/metro-light. Pure functions, unit-testable.
7
+ */
8
+
9
+ /** Ring % → raw MQTT value. Rounds to the granularity of 1 % of the range so
10
+ * sub-integer ranges survive (Homematic dimmers use a 0–1 LEVEL: 49 % → 0.49)
11
+ * while integer ranges (0–100, 0–254) keep publishing whole numbers. */
12
+ export function pctToRaw(pct, min, max) {
13
+ const value = min + (pct / 100) * (max - min);
14
+ const step = Math.abs(max - min) / 100;
15
+ if (step >= 1 || step === 0) return Math.round(value);
16
+ const decimals = Math.min(6, Math.ceil(-Math.log10(step)));
17
+ return Number(value.toFixed(decimals));
18
+ }
19
+
20
+ /** Simple warm-to-cool interpolation: 2700 K (warm) → 6500 K (cool). */
21
+ export function kelvinToRgb(k) {
22
+ const t = Math.max(0, Math.min(1, (k - 2700) / 3800));
23
+ return [Math.round(255 - t * 60), Math.round(210 + t * 45), Math.round(90 + t * 165)];
24
+ }
25
+
26
+ export function rgbToHex(r, g, b) {
27
+ return '#' + [r, g, b].map(c => Math.round(Math.max(0, Math.min(255, c))).toString(16).padStart(2, '0')).join('');
28
+ }
29
+
30
+ export function rgbToHsv(r, g, b) {
31
+ r /= 255; g /= 255; b /= 255;
32
+ const max = Math.max(r, g, b), d = max - Math.min(r, g, b);
33
+ let h = 0;
34
+ if (d) {
35
+ if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) * 60;
36
+ else if (max === g) h = ((b - r) / d + 2) * 60;
37
+ else h = ((r - g) / d + 4) * 60;
38
+ }
39
+ return {h, s: max ? d / max : 0, v: max};
40
+ }
41
+
42
+ export function hsvToRgb(h, s, v) {
43
+ const i = Math.floor(h / 60) % 6;
44
+ const f = h / 60 - Math.floor(h / 60);
45
+ const p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s);
46
+ const combos = [[v, t, p], [q, v, p], [p, v, t], [p, q, v], [t, p, v], [v, p, q]];
47
+ const [r, g, b] = combos[i];
48
+ return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
49
+ }
50
+
51
+ /** "r,g,b" string / JSON array / array → [r,g,b] (null when unparseable). */
52
+ export function parseRgb(raw) {
53
+ try {
54
+ if (typeof raw === 'string' && !raw.startsWith('[')) {
55
+ const parts = raw.split(',').map(Number);
56
+ if (parts.length >= 3 && parts.every(n => !isNaN(n))) return parts.slice(0, 3);
57
+ }
58
+ const arr = typeof raw === 'string' ? JSON.parse(raw) : raw;
59
+ if (Array.isArray(arr)) return arr.slice(0, 3).map(Number);
60
+ } catch {}
61
+ return null;
62
+ }
63
+
64
+ /** CIE 1931 xy (+ optional brightness) → sRGB (zigbee2mqtt / Hue {x,y}). */
65
+ export function xyToRgb(x, y, bri = 1) {
66
+ if (!y) return [255, 255, 255];
67
+ const Y = bri;
68
+ const X = (Y / y) * x;
69
+ const Z = (Y / y) * (1 - x - y);
70
+ let r = X * 1.656492 - Y * 0.354851 - Z * 0.255038;
71
+ let g = -X * 0.707196 + Y * 1.655397 + Z * 0.036152;
72
+ let b = X * 0.051713 - Y * 0.121364 + Z * 1.011530;
73
+ const gamma = c => (c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055);
74
+ [r, g, b] = [r, g, b].map(c => gamma(Math.max(0, c)));
75
+ const max = Math.max(r, g, b, 1);
76
+ return [r, g, b].map(c => Math.round(Math.max(0, Math.min(1, c / max)) * 255));
77
+ }
@@ -0,0 +1,178 @@
1
+ import {html, css} from 'lit';
2
+
3
+ /**
4
+ * E135 — Homematic fault / sabotage decoding (shared).
5
+ *
6
+ * Two encodings, per the OpenCCU-Base research:
7
+ * - **Classic BidCoS/HM**: ONE integer enum datapoint — `FAULT_REPORTING` (TRV)
8
+ * or `ERROR` (Keymatic, contact). The meaning of the number is
9
+ * **device-family-specific** (value 7 = valve-mount error on a TRV but
10
+ * SABOTAGE on a contact), so the enum table is keyed by device type. Over
11
+ * MQTT the value arrives as the integer option index; feezal owns the text.
12
+ * - **HmIP**: many *named boolean* `ERROR_*` datapoints (the flag NAME is the
13
+ * message), plus a dedicated `SABOTAGE` / `SABOTAGE_STICKY` boolean.
14
+ *
15
+ * The number arrives live over MQTT, so decoding is a client-side render-time
16
+ * concern; the recognizer only wires the topic + the device-type hint.
17
+ */
18
+
19
+ // Classic enum tables, keyed by device type. `0` (and any unlisted value that
20
+ // maps to no fault) means OK. `sabotage` marks the value that is a tamper event.
21
+ export const HM_FAULT_ENUMS = {
22
+ 'HM-CC-RT-DN': {
23
+ datapoint: 'FAULT_REPORTING',
24
+ text: {1: 'Valve tight', 2: 'Adjusting range too large', 3: 'Adjusting range too small', 4: 'Communication error', 6: 'Low battery', 7: 'Valve mounting error'},
25
+ },
26
+ 'HM-Sec-Key': {
27
+ datapoint: 'ERROR',
28
+ text: {1: 'Clutch failure', 2: 'Motor aborted'},
29
+ },
30
+ 'HM-Sec-SC': {
31
+ datapoint: 'ERROR',
32
+ text: {7: 'Sabotage'},
33
+ sabotage: 7, // classic contacts encode sabotage as ERROR == 7
34
+ },
35
+ };
36
+
37
+ // HmIP named fault-flag → text. The flag name IS the actionable message.
38
+ export const HMIP_FAULT_FLAGS = {
39
+ ERROR_JAMMED: 'Jammed',
40
+ ERROR_LOAD_TOO_LOW: 'Load too low',
41
+ ERROR_NO_END_STOP_LOCK: 'No end stop (lock)',
42
+ ERROR_NO_END_STOP_UNLOCK: 'No end stop (unlock)',
43
+ ERROR_OVERHEAT: 'Overheating',
44
+ ERROR_UNDERVOLTAGE: 'Undervoltage',
45
+ ERROR_COPROCESSOR: 'Coprocessor error',
46
+ ERROR_DEGRADED_CHAMBER: 'Smoke chamber degraded',
47
+ ERROR_BUS_CONFIG_MISMATCH: 'Bus config mismatch',
48
+ ERROR_DALI_BUS: 'DALI bus error',
49
+ };
50
+
51
+ const asNum = v => {
52
+ if (typeof v === 'number') return v;
53
+ if (typeof v === 'string' && v.trim() !== '' && !Number.isNaN(Number(v))) return Number(v);
54
+ return null;
55
+ };
56
+ const asBool = v => v === true || v === 1 || v === '1' || String(v).toLowerCase() === 'true';
57
+
58
+ /**
59
+ * Decode a fault value to human text ('' = no fault). `deviceType` selects the
60
+ * classic enum table; unknown families fall back to "nonzero = fault, raw code".
61
+ * A bare HmIP flag name (`ERROR_JAMMED`) is decoded to its text.
62
+ */
63
+ export function decodeHmFault(deviceType, value) {
64
+ if (value === null || value === undefined || value === '') return '';
65
+ // HmIP: a flag name delivered as a truthy boolean — the datapoint is the message.
66
+ if (typeof value === 'string' && value.startsWith('ERROR_')) return hmipFlagText(value);
67
+ const spec = HM_FAULT_ENUMS[deviceType];
68
+ const n = asNum(value);
69
+ if (spec) {
70
+ if (n === null || n === 0) return '';
71
+ return spec.text[n] || `Fault ${n}`;
72
+ }
73
+ // Unknown family: nonzero numeric = fault (raw code); truthy string = as-is.
74
+ if (n !== null) return n ? `Fault ${n}` : '';
75
+ return asBool(value) ? String(value) : '';
76
+ }
77
+
78
+ /** Whether a classic-enum fault value is a SABOTAGE event for this device type. */
79
+ export function isHmSabotageValue(deviceType, value) {
80
+ const spec = HM_FAULT_ENUMS[deviceType];
81
+ const n = asNum(value);
82
+ return !!(spec && spec.sabotage != null && n === spec.sabotage);
83
+ }
84
+
85
+ /** HmIP flag name → text (e.g. ERROR_JAMMED → "Jammed"). */
86
+ export function hmipFlagText(flag) {
87
+ return HMIP_FAULT_FLAGS[flag] || String(flag).replace(/^ERROR_/, '').replace(/_/g, ' ').toLowerCase();
88
+ }
89
+
90
+ /** Interpret a sabotage signal: classic `ERROR == 7` (encoding 'error7') or an
91
+ * HmIP boolean `SABOTAGE`/`SABOTAGE_STICKY` (encoding 'bool'). Returns boolean. */
92
+ export function isSabotageActive(value, encoding, deviceType) {
93
+ if (value === null || value === undefined || value === '') return false;
94
+ if (encoding === 'error7' || (deviceType && HM_FAULT_ENUMS[deviceType] && HM_FAULT_ENUMS[deviceType].sabotage != null)) {
95
+ return asNum(value) === (HM_FAULT_ENUMS[deviceType] ? HM_FAULT_ENUMS[deviceType].sabotage : 7);
96
+ }
97
+ return asBool(value);
98
+ }
99
+
100
+ /**
101
+ * E135 shared attribute fragment — spread into a card's `feezal.attributes` so
102
+ * a device's fault (`FAULT_REPORTING`/`ERROR`) and sabotage (`SABOTAGE`, or
103
+ * classic `ERROR == 7`) signals auto-stamp and render as badges. Presence-checked
104
+ * like the E124 battery contract — only stamped when the device exposes them.
105
+ */
106
+ export const faultSabotageAttributes = [
107
+ {name: 'subscribe-error', type: 'mqttTopic', help: 'Optional device fault topic (Homematic FAULT_REPORTING / ERROR). A warning badge shows the decoded fault text.'},
108
+ {name: 'message-property-error', type: 'string', help: 'Property path within fault messages. Defaults to message-property.'},
109
+ {name: 'error-device-type', type: 'string', help: 'Homematic device type (e.g. HM-CC-RT-DN) — selects the fault-code text table. Blank = show the raw code.'},
110
+ {name: 'subscribe-sabotage', type: 'mqttTopic', help: 'Optional sabotage/tamper topic (HmIP SABOTAGE, or classic contacts via ERROR). Shows an alarm-grade badge.'},
111
+ {name: 'message-property-sabotage', type: 'string', help: 'Property path within sabotage messages. Defaults to message-property.'},
112
+ {name: 'sabotage-encoding', type: 'select', options: ['bool', 'error7'], default: 'bool', help: 'How sabotage is encoded: a boolean (HmIP SABOTAGE), or classic contacts where ERROR == 7 is sabotage.'},
113
+ ];
114
+
115
+ /** Attribute names of the E135 fragment (for parity-set derivation). */
116
+ export const FAULT_SABOTAGE_ATTRIBUTES = faultSabotageAttributes.map(a => a.name);
117
+
118
+ /**
119
+ * Wire a card's fault + sabotage subscriptions off its HOST attributes and
120
+ * decode them. `onError(text)` receives the decoded fault text ('' = no fault);
121
+ * `onSabotage(active)` a boolean. Mirrors the E124 battery wiring shape.
122
+ */
123
+ export function subscribeFaultSabotage(host, {onError, onSabotage} = {}) {
124
+ const attr = n => { const v = host.getAttribute(n); return v === null ? '' : v; };
125
+ const prop = (msg, specific) => host.getProperty(msg, attr(specific) || attr('message-property') || 'payload');
126
+ const err = attr('subscribe-error');
127
+ if (err && onError) {
128
+ host.addSubscription(err, msg => onError(decodeHmFault(attr('error-device-type'), prop(msg, 'message-property-error'))));
129
+ }
130
+ const sab = attr('subscribe-sabotage');
131
+ if (sab && onSabotage) {
132
+ host.addSubscription(sab, msg => onSabotage(isSabotageActive(prop(msg, 'message-property-sabotage'), attr('sabotage-encoding'), attr('error-device-type'))));
133
+ }
134
+ }
135
+
136
+ /** Signature fragment for rewire-on-edit (join into the controller's signature). */
137
+ export function faultSabotageSignature(host) {
138
+ return ['subscribe-error', 'subscribe-sabotage'].map(n => host.getAttribute(n) || '').join('|');
139
+ }
140
+
141
+ // E135 badge styles — sabotage is alarm-grade (error colour, prominent); fault
142
+ // is the warning tier. Bottom-left by default (battery owns bottom-right).
143
+ export const feezalFaultStyles = css`
144
+ .feezal-sabotage-badge, .feezal-fault-badge {
145
+ position: absolute;
146
+ bottom: var(--feezal-fault-bottom, 6px);
147
+ left: var(--feezal-fault-left, 6px);
148
+ width: var(--feezal-fault-size, 18px); height: var(--feezal-fault-size, 18px);
149
+ pointer-events: none; z-index: 2;
150
+ }
151
+ .feezal-sabotage-badge { color: var(--error-color, #d32f2f); opacity: 1; }
152
+ .feezal-fault-badge { color: var(--warning-color, #f0a30a); opacity: 0.95; }
153
+ .feezal-sabotage-badge svg, .feezal-fault-badge svg { width: 100%; height: 100%; display: block; }
154
+ `;
155
+
156
+ /** Alarm-grade sabotage badge — a shield with an exclamation. Renders nothing when inactive. */
157
+ export function sabotageBadge(active) {
158
+ return active ? html`<span class="feezal-sabotage-badge" title="Sabotage / tamper"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
159
+ <path d="M12 2 4 5v6c0 5 3.4 8.7 8 10 4.6-1.3 8-5 8-10V5l-8-3zm-1 5h2v6h-2V7zm0 8h2v2h-2v-2z"/>
160
+ </svg></span>` : '';
161
+ }
162
+
163
+ /** Warning-tier fault badge — a triangle; the decoded text is the tooltip. '' = none. */
164
+ export function faultBadge(text) {
165
+ return text ? html`<span class="feezal-fault-badge" title="${text}"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
166
+ <path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"/>
167
+ </svg></span>` : '';
168
+ }
169
+
170
+ /** The device-health board's wildcard datapoint set (classic + HmIP + N31/E124). */
171
+ export const HM_HEALTH_DATAPOINTS = {
172
+ fault: ['FAULT_REPORTING', 'ERROR'],
173
+ sabotage: ['SABOTAGE', 'SABOTAGE_STICKY'],
174
+ battery: ['LOWBAT', 'LOW_BAT'],
175
+ unreach: ['UNREACH'],
176
+ // HmIP named fault flags (collected as their own booleans).
177
+ hmipFlags: Object.keys(HMIP_FAULT_FLAGS),
178
+ };
@@ -0,0 +1,185 @@
1
+ /**
2
+ * E128 / E154 — movement indication for actuators that take visible time to
3
+ * travel: blinds/shutters (`DIRECTION` while the motor runs) and motor door
4
+ * locks (Keymatic `DIRECTION` while it turns).
5
+ *
6
+ * Two pieces, deliberately separate:
7
+ *
8
+ * - `parseDirection()` — the shared value→direction reading. Homematic's
9
+ * `DIRECTION` is an enum (`NONE` / `UP` / `DOWN` / `UNDEFINED`, device values
10
+ * 0…3, verified against OpenCCU-Base `rf_keymatic.xml`); depending on the
11
+ * interface it arrives as the option NAME or as the numeric index, so both
12
+ * are accepted. Any other/absent value reads as idle — an unwired movement
13
+ * topic simply means "no indication", exactly like E127's WORKING.
14
+ *
15
+ * - `MovementController` — the DISCRETE-state analog of E127's numeric
16
+ * `SettlingController`, for locks. A numeric settler holds a slider at its
17
+ * target until the reports catch up; a lock has no numeric scale, it has a
18
+ * transitional "…ing" state that must be held between the command and the
19
+ * device's confirmation, so `SettlingController` does not fit (the E154
20
+ * build flagged this). The rules:
21
+ * • `command(intent, expected)` — the user pressed Lock/Unlock/Open:
22
+ * enter the transitional state immediately (the device reports STATE
23
+ * only once it is done, and some interfaces echo the OLD state first).
24
+ * • `signal(active, hint)` — the device's movement datapoint. When one is
25
+ * wired it is the AUTHORITY: active extends the transitional state
26
+ * (also for movement started elsewhere — a physical key turn), idle
27
+ * ends it. `hint` names the intent when there was no local command.
28
+ * • `report(state)` — a resolved state report. It ends the transitional
29
+ * state only when NO movement signal is wired (otherwise the signal
30
+ * does), and only once the expected state is reached — so an echo of
31
+ * the pre-command state cannot cut the hold short.
32
+ * • `timeoutMs` — the fallback that always resolves, so a lost
33
+ * DIRECTION=NONE or a device that never confirms cannot leave the card
34
+ * stuck "locking…" forever.
35
+ */
36
+
37
+ import {svg} from 'lit';
38
+ import {css, html} from './feezal-element.js';
39
+
40
+ /** Homematic DIRECTION enum indices (OpenCCU-Base `rf_keymatic.xml`). */
41
+ const DIRECTION_INDEX = {0: '', 1: 'up', 2: 'down', 3: ''};
42
+
43
+ /**
44
+ * Read a movement-datapoint value as `'up'` / `'down'` / `''` (idle).
45
+ *
46
+ * @param {*} value the raw datapoint value
47
+ * @param {string} [upPayload] configured payload meaning "up"
48
+ * @param {string} [downPayload] configured payload meaning "down"
49
+ */
50
+ export function parseDirection(value, upPayload = 'UP', downPayload = 'DOWN') {
51
+ if (value === null || value === undefined || value === false) return '';
52
+ const raw = String(value).trim();
53
+ if (raw === '') return '';
54
+ const up = String(upPayload || 'UP').trim().toUpperCase();
55
+ const down = String(downPayload || 'DOWN').trim().toUpperCase();
56
+ const upper = raw.toUpperCase();
57
+ if (upper === up) return 'up';
58
+ if (upper === down) return 'down';
59
+ // Numeric form: the enum index as published by interfaces that send the
60
+ // device value instead of the option name.
61
+ if (/^-?\d+(\.\d+)?$/.test(raw)) return DIRECTION_INDEX[Number(raw)] ?? '';
62
+ return '';
63
+ }
64
+
65
+ /** True when the value reads as movement in either direction. */
66
+ export function isMoving(value, upPayload, downPayload) {
67
+ return parseDirection(value, upPayload, downPayload) !== '';
68
+ }
69
+
70
+ export class MovementController {
71
+ /**
72
+ * @param {object} opts
73
+ * @param {() => void} [opts.onChange] called whenever the transitional state changes
74
+ * @param {number} [opts.timeoutMs=20000] hard fallback that always resolves
75
+ * @param {boolean} [opts.signalWired=false] a movement datapoint feeds signal()
76
+ */
77
+ constructor({onChange = () => {}, timeoutMs = 20000, signalWired = false} = {}) {
78
+ this._onChange = onChange;
79
+ this._timeoutMs = timeoutMs;
80
+ this._signalWired = signalWired;
81
+ this._intent = ''; // 'locking' | 'unlocking' | 'opening' | 'moving'
82
+ this._expected = null; // resolved state that ends the hold (no signal wired)
83
+ this._timer = null;
84
+ }
85
+
86
+ /** True while the actuator is (believed to be) moving. */
87
+ get moving() { return this._intent !== ''; }
88
+ /** The transitional intent, '' while idle. */
89
+ get intent() { return this._intent; }
90
+
91
+ /** The user issued a command — hold the transitional state. */
92
+ command(intent, expected = null) {
93
+ this._set(intent, expected);
94
+ }
95
+
96
+ /** Movement datapoint report; `hint` names the intent for external movement. */
97
+ signal(active, hint = 'moving') {
98
+ if (active) {
99
+ this._set(this._intent || hint || 'moving', this._expected);
100
+ return;
101
+ }
102
+ this._stop();
103
+ }
104
+
105
+ /** Resolved state report (e.g. the lock's STATE datapoint). */
106
+ report(state) {
107
+ if (!this.moving) return;
108
+ // A wired movement datapoint is the authority — it ends the hold.
109
+ if (this._signalWired) return;
110
+ if (this._expected === null || state === this._expected) this._stop();
111
+ }
112
+
113
+ dispose() { this._clear(); }
114
+
115
+ _set(intent, expected) {
116
+ const changed = this._intent !== intent;
117
+ this._intent = intent;
118
+ this._expected = expected;
119
+ this._clear();
120
+ this._timer = setTimeout(() => { this._timer = null; this._stop(); }, this._timeoutMs);
121
+ if (changed) this._onChange();
122
+ }
123
+
124
+ _stop() {
125
+ this._clear();
126
+ if (this._intent === '') return;
127
+ this._intent = '';
128
+ this._expected = null;
129
+ this._onChange();
130
+ }
131
+
132
+ _clear() {
133
+ if (this._timer) { clearTimeout(this._timer); this._timer = null; }
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Shared movement badge (E128 covers / E154 locks) — a small pulsing chevron
139
+ * that points the way the actuator is travelling, or a neutral pulse when the
140
+ * direction is unknown. Positioned top-left by default (top-right is the
141
+ * availability badge, bottom-right the E124 battery symbol); families move it
142
+ * with the `--feezal-movement-*` custom properties. Colour is `currentColor`
143
+ * so every family's chrome carries it without a per-family override.
144
+ */
145
+ export const feezalMovementStyles = css`
146
+ .feezal-move-badge {
147
+ position: absolute;
148
+ top: var(--feezal-movement-top, 6px);
149
+ left: var(--feezal-movement-left, 6px);
150
+ width: var(--feezal-movement-size, 16px);
151
+ height: var(--feezal-movement-size, 16px);
152
+ color: var(--feezal-movement-color, currentColor);
153
+ opacity: 0.9; pointer-events: none; z-index: 2;
154
+ }
155
+ .feezal-move-badge svg { width: 100%; height: 100%; display: block; }
156
+ /* E154: applied to the element a family already draws (the lock disc /
157
+ icon) so a motor lock reads as "working" without adding chrome — the
158
+ badge above is for actuators whose DIRECTION is meaningful (blinds). */
159
+ @media (prefers-reduced-motion: no-preference) {
160
+ .feezal-move-badge,
161
+ .feezal-moving { animation: feezal-move-pulse 1.1s ease-in-out infinite; }
162
+ }
163
+ @keyframes feezal-move-pulse {
164
+ 0%, 100% { opacity: 0.25; }
165
+ 50% { opacity: 1; }
166
+ }
167
+ `;
168
+
169
+ /**
170
+ * Movement badge template partial: renders nothing while idle.
171
+ *
172
+ * @param {''|'up'|'down'|'moving'|string} direction '' = idle
173
+ * @param {string} [title] tooltip / a11y label
174
+ */
175
+ export function movementBadge(direction, title = 'Moving') {
176
+ if (!direction) return '';
177
+ // `svg` (not `html`): these fragments live inside the <svg> root below, so
178
+ // they must be created in the SVG namespace.
179
+ const glyph = direction === 'up'
180
+ ? svg`<path d="M12 5 L20 15 H4 Z" fill="currentColor"/>`
181
+ : direction === 'down'
182
+ ? svg`<path d="M12 19 L4 9 H20 Z" fill="currentColor"/>`
183
+ : svg`<circle cx="12" cy="12" r="6" fill="currentColor"/>`;
184
+ return html`<span class="feezal-move-badge" title="${title}"><svg viewBox="0 0 24 24" aria-hidden="true">${glyph}</svg></span>`;
185
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * E132 — the generalized boolean-sensor vocabulary, defined ONCE for the
3
+ * three family cards (material-motion / glass-occupancy / metro-occupancy —
4
+ * palette name "Sensor" since E132; the tag renames wait for the alias
5
+ * mechanism). Water alarms, smoke alarms and motion sensors all behave the
6
+ * same way: one boolean state, an icon that flips, a text per state,
7
+ * battery-driven hardware — this module carries the per-type defaults, the
8
+ * shared HA `device_class` map and the E124 battery-low attribute trio, the
9
+ * `publishLocalAttribute` way (name/help written once, E117 precedent).
10
+ */
11
+
12
+ // type → per-state defaults. `alarm: true` classes render their ACTIVE state
13
+ // in the error colour — an active fire alarm is not a neutral state chip.
14
+ export const SENSOR_TYPES = {
15
+ motion: {icon: 'directions_walk', iconClear: 'directions_walk', textActive: 'Motion', textClear: 'Clear', alarm: false},
16
+ presence: {icon: 'person', iconClear: 'person_outline', textActive: 'Occupied', textClear: 'Clear', alarm: false},
17
+ radar: {icon: 'radar', iconClear: 'radar', textActive: 'Presence', textClear: 'Clear', alarm: false},
18
+ zone: {icon: 'meeting_room', iconClear: 'meeting_room', textActive: 'Occupied', textClear: 'Vacant', alarm: false},
19
+ 'water-leak': {icon: 'water_damage', iconClear: 'water_drop', textActive: 'Leak!', textClear: 'Dry', alarm: true},
20
+ smoke: {icon: 'local_fire_department', iconClear: 'detector_smoke', textActive: 'Smoke!', textClear: 'Clear', alarm: true},
21
+ gas: {icon: 'warning', iconClear: 'gas_meter', textActive: 'Gas!', textClear: 'Clear', alarm: true},
22
+ co: {icon: 'co2', iconClear: 'co2', textActive: 'CO!', textClear: 'Clear', alarm: true},
23
+ vibration: {icon: 'vibration', iconClear: 'check_circle', textActive: 'Triggered', textClear: 'OK', alarm: false},
24
+ tamper: {icon: 'lock_open', iconClear: 'check_circle', textActive: 'Tamper!', textClear: 'OK', alarm: true},
25
+ generic: {icon: 'sensors', iconClear: 'sensors_off', textActive: 'Active', textClear: 'Inactive', alarm: false},
26
+ };
27
+
28
+ export const SENSOR_TYPE_OPTIONS = Object.keys(SENSOR_TYPES);
29
+
30
+ // ── E138: motion vs alarm SLICES of the vocabulary ──────────────────────────
31
+ // The boolean card splits into two UX characters with different active-state
32
+ // colour semantics: MOTION/presence (expected activity → accent colour) and
33
+ // ALARM (exceptional → error colour). The slices are the single source of truth
34
+ // for both the per-family element `type` option lists and the discovery
35
+ // device_class routing. The combined SENSOR_TYPES / SENSOR_TYPE_OPTIONS /
36
+ // SENSOR_DEVICE_CLASS_MAP above are unchanged (= slice 'all'); the current three
37
+ // boolean cards keep the full vocabulary until the E138 rename phase.
38
+ export const MOTION_SENSOR_TYPES = ['motion', 'presence', 'radar', 'zone'];
39
+ export const ALARM_SENSOR_TYPES = ['water-leak', 'smoke', 'gas', 'co', 'vibration', 'tamper', 'generic'];
40
+
41
+ // Active-state default colour var per slice (canonical theme vars, spec §5.1).
42
+ // motion-slice active → --accent-color; alarm-slice active → --error-color.
43
+ // These are DEFAULTS a view consumes (per-element --feezal-* overrides win).
44
+ export const SENSOR_SLICE_COLOR_VARS = {motion: '--accent-color', alarm: '--error-color'};
45
+
46
+ // Stamp each type entry with its slice's active colour var, derived from the
47
+ // slice arrays so the two never drift. A view reads `sensorType(t).colorVar`.
48
+ for (const t of MOTION_SENSOR_TYPES) if (SENSOR_TYPES[t]) SENSOR_TYPES[t].colorVar = SENSOR_SLICE_COLOR_VARS.motion;
49
+ for (const t of ALARM_SENSOR_TYPES) if (SENSOR_TYPES[t]) SENSOR_TYPES[t].colorVar = SENSOR_SLICE_COLOR_VARS.alarm;
50
+
51
+ /** The type-option list for a slice ('motion' | 'alarm' | 'all'). */
52
+ export function sensorTypesFor(slice = 'all') {
53
+ if (slice === 'motion') return MOTION_SENSOR_TYPES;
54
+ if (slice === 'alarm') return ALARM_SENSOR_TYPES;
55
+ return SENSOR_TYPE_OPTIONS;
56
+ }
57
+
58
+ /** Default `type` value for a slice (motion/all → motion; alarm → generic). */
59
+ export function sensorDefaultTypeFor(slice = 'all') {
60
+ return slice === 'alarm' ? 'generic' : 'motion';
61
+ }
62
+
63
+ /** The active-state colour var for a type ('all' resolves via slice membership). */
64
+ export function sensorActiveColorVar(type) {
65
+ return sensorType(type).colorVar || SENSOR_SLICE_COLOR_VARS.alarm;
66
+ }
67
+
68
+ /** Resolve a type entry (unknown → generic). */
69
+ export function sensorType(type) {
70
+ return SENSOR_TYPES[type] || SENSOR_TYPES.generic;
71
+ }
72
+
73
+ // HA/z2m `device_class` → feezal `type` — one map for all three cards AND
74
+ // consistent with what the Homematic sensor recognizer emits (E131 keeps its
75
+ // output HA-shaped: motion / moisture / smoke).
76
+ export const SENSOR_DEVICE_CLASS_MAP = {
77
+ motion: 'motion',
78
+ occupancy: 'presence',
79
+ presence: 'presence',
80
+ moisture: 'water-leak',
81
+ smoke: 'smoke',
82
+ gas: 'gas',
83
+ carbon_monoxide: 'co',
84
+ vibration: 'vibration',
85
+ tamper: 'tamper',
86
+ _default: 'generic',
87
+ };
88
+
89
+ // E138: slice-restricted device_class → type maps. A `*-motion` element routes
90
+ // only motion/occupancy/presence (unknown → motion); a `*-sensor` (alarm)
91
+ // element routes only the hazard classes (unknown → generic). The combined map
92
+ // above stays the 'all' behaviour for the current, not-yet-split cards.
93
+ export const MOTION_DEVICE_CLASS_MAP = {
94
+ motion: 'motion',
95
+ occupancy: 'presence',
96
+ presence: 'presence',
97
+ _default: 'motion',
98
+ };
99
+ export const ALARM_DEVICE_CLASS_MAP = {
100
+ smoke: 'smoke',
101
+ moisture: 'water-leak',
102
+ gas: 'gas',
103
+ carbon_monoxide: 'co',
104
+ vibration: 'vibration',
105
+ tamper: 'tamper',
106
+ _default: 'generic',
107
+ };
108
+
109
+ /** The device_class → type map for a slice ('motion' | 'alarm' | 'all'). */
110
+ export function sensorDeviceClassMapFor(slice = 'all') {
111
+ if (slice === 'motion') return MOTION_DEVICE_CLASS_MAP;
112
+ if (slice === 'alarm') return ALARM_DEVICE_CLASS_MAP;
113
+ return SENSOR_DEVICE_CLASS_MAP;
114
+ }
115
+
116
+ // ── E124: the shared low-battery attribute trio (+ threshold) ───────────────
117
+ // Battery devices signal a weak battery as a WARNING icon, not a blackout —
118
+ // the sensor keeps reporting its state. Auto-stamped by discovery from the
119
+ // canonical `battery_low_normalized` record (both ecosystems).
120
+ export const batteryLowAttributes = [
121
+ {name: 'subscribe-battery-low', type: 'mqttTopic', section: 'Battery',
122
+ help: 'Optional low-battery topic. Boolean payloads compare against payload-battery-low; a numeric payload (battery percentage) compares against battery-low-threshold. Shows a warning icon — the state keeps updating. Stamped by auto-discovery (zigbee2mqtt battery sibling / Homematic :0 LOWBAT·LOW_BAT).'},
123
+ {name: 'message-property-battery-low', type: 'string', default: 'payload', section: 'Battery', advanced: true,
124
+ help: 'Dot-notation path within the battery message. Blank = fall back to element-level message-property.'},
125
+ {name: 'payload-battery-low', type: 'string', default: 'true', section: 'Battery',
126
+ help: 'Payload meaning the battery is low (boolean payloads; true/1 coercion included).'},
127
+ {name: 'battery-low-threshold', type: 'number', default: 15, section: 'Battery',
128
+ help: 'When the subscribed value is a battery PERCENTAGE (0–100), values at or below this show the low-battery icon.'},
129
+ ];
130
+
131
+ /**
132
+ * Interpret a battery-low payload value: numbers above 1 are treated as a
133
+ * battery percentage (≤ threshold ⇒ low); everything else matches the
134
+ * payload-battery-low compare value with the usual true/1 coercion.
135
+ */
136
+ export function batteryLowFromValue(v, payloadLow = 'true', threshold = 15) {
137
+ const asNum = typeof v === 'number' ? v
138
+ : (typeof v === 'string' && v.trim() !== '' && !Number.isNaN(Number(v)) ? Number(v) : null);
139
+ if (asNum !== null && asNum > 1) return asNum <= Number(threshold ?? 15);
140
+ return v === true || v === 1 || String(v) === '1'
141
+ || String(v).toLowerCase() === 'true'
142
+ || String(v) === String(payloadLow);
143
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * E127 — SettlingController: suppression logic for ramping actuators.
3
+ *
4
+ * Homematic dimmers (and blinds, E128) ramp towards a commanded LEVEL and
5
+ * report every intermediate value on the way — a slider that follows those
6
+ * reports jumps around right after the user set it. This controller decides
7
+ * which incoming reports may reach the display:
8
+ *
9
+ * - command(target): the element just published a set value → hold the
10
+ * display at the target. Intermediate reports are swallowed until the
11
+ * reported value reaches the target, a settled signal arrives, or the
12
+ * settle timeout reconciles to the last reported value (interrupted
13
+ * ramps, device clamping, sentinel targets like Homematic's 1.005
14
+ * OLD_LEVEL restore that the device never echoes verbatim).
15
+ * - working(active): the device's WORKING datapoint. `true` enters/extends
16
+ * suppression (covers ramps started elsewhere); `false` ends it and
17
+ * applies the last report as the settled value.
18
+ * - live(value): a report from the state topic. Applied directly when idle;
19
+ * swallowed while holding/ramping. When a WORKING topic is wired, idle
20
+ * reports are buffered for `reportDelayMs` first — interfaces cannot
21
+ * guarantee WORKING=true precedes the first intermediate report (it
22
+ * arrives ≤ ~100 ms after), so a WORKING=true within the buffer cancels
23
+ * the pending jumpy update.
24
+ * - settled(value): a report from a settled-values-only topic (RedMatic's
25
+ * LEVEL_NOTWORKING). Always applied, ends any hold.
26
+ *
27
+ * The controller is value-scale-agnostic (raw device values in, raw values
28
+ * out through the apply callback) and family-agnostic — lights use it for
29
+ * brightness, covers (E128) for position.
30
+ */
31
+
32
+ const TARGET_EPSILON = 1e-3;
33
+
34
+ export class SettlingController {
35
+ /**
36
+ * @param {object} opts
37
+ * @param {(value: number) => void} opts.apply applies a raw report to the element state
38
+ * @param {number} [opts.timeoutMs=5000] hold/ramp reconcile timeout
39
+ * @param {number} [opts.reportDelayMs=100] idle-report buffer while a WORKING topic is wired (0 = off)
40
+ * @param {boolean} [opts.workingWired=false] a WORKING topic feeds working()
41
+ * @param {boolean} [opts.settledWired=false] a settled topic feeds settled() — live() then never drives apply
42
+ */
43
+ constructor({apply, timeoutMs = 5000, reportDelayMs = 100, workingWired = false, settledWired = false}) {
44
+ this._apply = apply;
45
+ this._timeoutMs = timeoutMs;
46
+ this._reportDelayMs = reportDelayMs;
47
+ this._workingWired = workingWired;
48
+ this._settledWired = settledWired;
49
+
50
+ this._holding = false; // own command in flight
51
+ this._target = null;
52
+ this._ramping = false; // externally initiated ramp (WORKING=true seen)
53
+ this._lastLive = null; // most recent live report (raw)
54
+ this._timeout = null;
55
+ this._pending = null; // {value, timer} — buffered idle report
56
+ }
57
+
58
+ /** The element just published `target` (raw device scale). */
59
+ command(target) {
60
+ this._cancelPending();
61
+ this._holding = true;
62
+ this._ramping = false;
63
+ this._target = Number(target);
64
+ this._startTimeout();
65
+ }
66
+
67
+ /** Report from the live state topic (raw device scale). */
68
+ live(value) {
69
+ this._lastLive = value;
70
+ if (this._settledWired) return; // slider follows the settled topic only
71
+ if (this._holding) {
72
+ if (Math.abs(value - this._target) <= TARGET_EPSILON) this._settle(value);
73
+ return; // swallow intermediate ramp values
74
+ }
75
+ if (this._ramping) return; // swallow external ramp values
76
+ if (this._workingWired && this._reportDelayMs > 0) {
77
+ // Buffer so a trailing WORKING=true can cancel the jumpy update.
78
+ // The timer runs from the FIRST buffered report; later reports
79
+ // only update the pending value (a ramp without WORKING must not
80
+ // starve the display forever).
81
+ if (this._pending) {
82
+ this._pending.value = value;
83
+ } else {
84
+ this._pending = {
85
+ value,
86
+ timer: setTimeout(() => {
87
+ const v = this._pending.value;
88
+ this._pending = null;
89
+ this._apply(v);
90
+ }, this._reportDelayMs),
91
+ };
92
+ }
93
+ return;
94
+ }
95
+ this._apply(value);
96
+ }
97
+
98
+ /** WORKING datapoint report. */
99
+ working(active) {
100
+ // B60: when a settled-values topic (RedMatic LEVEL_NOTWORKING) is wired it
101
+ // is the SOLE authority — the control follows command()/settled() only, so
102
+ // WORKING is redundant AND harmful here. RedMatic delivers WORKING=false a
103
+ // moment BEFORE the settled value, and `_lastLive` at that instant is a
104
+ // mid-ramp (often near-old) reading; acting on the WORKING=false edge would
105
+ // snap the slider to that stale value and then correct when the settled
106
+ // value lands — the reported two-step jump. Ignore WORKING entirely in
107
+ // settled mode. (WORKING-only devices, with no settled topic, are
108
+ // unaffected and still rely on it below.)
109
+ if (this._settledWired) return;
110
+ if (active) {
111
+ this._cancelPending();
112
+ if (!this._holding && !this._ramping) {
113
+ this._ramping = true;
114
+ this._startTimeout(); // safety: a lost WORKING=false must not freeze the display
115
+ }
116
+ return;
117
+ }
118
+ if (this._holding || this._ramping) this._settle(this._lastLive);
119
+ }
120
+
121
+ /** Report from the settled-values topic (LEVEL_NOTWORKING). */
122
+ settled(value) {
123
+ this._settle(value);
124
+ }
125
+
126
+ /**
127
+ * Abandon any hold/ramp WITHOUT applying a value — E128: the user pressed
128
+ * STOP, so the actuator halts somewhere between the old value and the
129
+ * target and the target is no longer meaningful. The next report must
130
+ * reach the display immediately instead of being swallowed until the
131
+ * timeout. (`settled()` would additionally apply a stale mid-travel value.)
132
+ */
133
+ cancel() {
134
+ this._holding = false;
135
+ this._ramping = false;
136
+ this._target = null;
137
+ this._clearTimeout();
138
+ this._cancelPending();
139
+ }
140
+
141
+ dispose() {
142
+ this._clearTimeout();
143
+ this._cancelPending();
144
+ }
145
+
146
+ _settle(value) {
147
+ this._holding = false;
148
+ this._ramping = false;
149
+ this._target = null;
150
+ this._clearTimeout();
151
+ this._cancelPending();
152
+ if (value !== null && value !== undefined && !Number.isNaN(Number(value))) this._apply(Number(value));
153
+ }
154
+
155
+ _startTimeout() {
156
+ this._clearTimeout();
157
+ this._timeout = setTimeout(() => {
158
+ this._timeout = null;
159
+ this._settle(this._lastLive);
160
+ }, this._timeoutMs);
161
+ }
162
+
163
+ _clearTimeout() {
164
+ if (this._timeout) { clearTimeout(this._timeout); this._timeout = null; }
165
+ }
166
+
167
+ _cancelPending() {
168
+ if (this._pending) { clearTimeout(this._pending.timer); this._pending = null; }
169
+ }
170
+ }
package/package.json CHANGED
@@ -14,9 +14,14 @@
14
14
  "feezal": {},
15
15
  "files": [
16
16
  "feezal-element.js",
17
+ "feezal-color.js",
17
18
  "feezal-conditions.js",
18
19
  "feezal-friendly-name.js",
20
+ "feezal-hm-fault.js",
21
+ "feezal-movement.js",
19
22
  "feezal-polymer-element.js",
23
+ "feezal-sensor-types.js",
24
+ "feezal-settling.js",
20
25
  "feezal-topic-input.js",
21
26
  "LICENSE"
22
27
  ],
@@ -36,5 +41,5 @@
36
41
  "url": "git+https://github.com/feezal/feezal.git",
37
42
  "directory": "www/packages/@feezal/feezal-element"
38
43
  },
39
- "version": "3.0.12"
44
+ "version": "3.0.13"
40
45
  }