@feezal/feezal-element 3.0.14 → 3.0.16
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.
- package/feezal-discovery-fragments.js +274 -57
- package/feezal-element.js +33 -0
- package/package.json +1 -1
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* E156 — cross-component discovery fragments.
|
|
2
|
+
* E156/E157/E158/B81 — cross-component discovery fragments.
|
|
3
3
|
*
|
|
4
4
|
* Discovery used to match an element only to entities whose component EQUALS
|
|
5
5
|
* the element's own. That is too strict: a device of one component is often
|
|
6
|
-
* controllable by an element of another. These fragments express
|
|
7
|
-
* crossings the
|
|
8
|
-
*
|
|
6
|
+
* controllable by an element of another. These fragments express those
|
|
7
|
+
* crossings ONCE, so the elements that share a crossing do not each carry (and
|
|
8
|
+
* drift on) their own copy. Consumers today:
|
|
9
|
+
*
|
|
10
|
+
* switchAcceptsLight 8 *-switch, 4 *-checkbox, material-chip
|
|
11
|
+
* sliderDiscovery 3 *-slider
|
|
12
|
+
* settableAxes panel-knob (+ the sliders, via sliderDiscovery)
|
|
13
|
+
* readonlyNumericDiscovery material-tank, material-progress
|
|
9
14
|
*
|
|
10
15
|
* The mechanism is `discovery.accepts` — see `elementAcceptsComponent` /
|
|
11
16
|
* `discoveryCandidates` in `www/src/feezal-discovery-stamp.js`. A variant may
|
|
@@ -14,41 +19,51 @@
|
|
|
14
19
|
*/
|
|
15
20
|
|
|
16
21
|
/**
|
|
17
|
-
* A `light` offered to
|
|
22
|
+
* A `light` offered to an on/off control: drive the lamp as plain on/off.
|
|
18
23
|
*
|
|
19
24
|
* **Map order is load-bearing.** The stamp applies keys in insertion order and
|
|
20
25
|
* later writes win, and the DSL's `onlyWhen` can only test equality — there is
|
|
21
26
|
* no "unless". So the generic `payload_on`/`payload_off` are applied FIRST and
|
|
22
27
|
* the Homematic-style override LAST, where its `alsoSet` can overwrite them.
|
|
23
28
|
* Reordering this map silently changes which payloads a dimmer gets.
|
|
29
|
+
*
|
|
30
|
+
* E157: parameterised on the *read* attribute, because not every on/off control
|
|
31
|
+
* calls it `subscribe` — the legacy `paper-checkbox` reads from `topic`. Only
|
|
32
|
+
* that one key varies; everything else (including the whole Homematic branch)
|
|
33
|
+
* stays shared, so the two spellings cannot drift apart.
|
|
24
34
|
*/
|
|
25
|
-
export
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
export function makeSwitchAcceptsLight({subscribe = 'subscribe'} = {}) {
|
|
36
|
+
return {
|
|
37
|
+
component: 'light',
|
|
38
|
+
map: {
|
|
39
|
+
// ── generic on/off light (zigbee2mqtt, HA) ────────────────────────
|
|
40
|
+
name: 'label',
|
|
41
|
+
payload_on: 'payload-on',
|
|
42
|
+
payload_off: 'payload-off',
|
|
43
|
+
state_topic: subscribe,
|
|
44
|
+
state_command_topic: 'publish',
|
|
45
|
+
command_topic: 'publish',
|
|
46
|
+
value_template: {attr: 'message-property', transform: 'valueTemplateToPath'},
|
|
36
47
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
48
|
+
// ── Homematic-style dimmer: on/off IS the LEVEL value (E77/E126) ──
|
|
49
|
+
// The light card toggles on with the 1.005 OLD_LEVEL sentinel to restore
|
|
50
|
+
// the previous brightness; a switch has no "previous brightness" to
|
|
51
|
+
// restore and no way to represent 1.005, so it drives full-on/off with
|
|
52
|
+
// 1 / 0. These come last so `alsoSet` overwrites the payload_on
|
|
53
|
+
// (= '1.005') the generic rows above already wrote.
|
|
54
|
+
brightness_state_topic: {attr: subscribe, onlyWhen: {on_off_source: 'brightness'}},
|
|
55
|
+
message_property_brightness: {attr: 'message-property', onlyWhen: {on_off_source: 'brightness'}},
|
|
56
|
+
brightness_command_topic: {
|
|
57
|
+
attr: 'publish',
|
|
58
|
+
onlyWhen: {on_off_source: 'brightness'},
|
|
59
|
+
alsoSet: {'payload-on': '1', 'payload-off': '0'},
|
|
60
|
+
},
|
|
49
61
|
},
|
|
50
|
-
}
|
|
51
|
-
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The default spelling — every on/off control except `paper-checkbox`. */
|
|
66
|
+
export const switchAcceptsLight = makeSwitchAcceptsLight();
|
|
52
67
|
|
|
53
68
|
// A settable axis needs a COMMAND topic. This is the guardrail the item calls
|
|
54
69
|
// out: a slider must never be offered a read-only value, so every variant below
|
|
@@ -57,6 +72,185 @@ export const switchAcceptsLight = {
|
|
|
57
72
|
// as a brightness row.
|
|
58
73
|
const settable = key => cfg => Boolean(cfg[key]);
|
|
59
74
|
|
|
75
|
+
// B81 — a JSON-SCHEMA light (zigbee2mqtt, HA `schema: json`) has no per-axis
|
|
76
|
+
// command topic at all: it has ONE `command_topic` that takes an object, and
|
|
77
|
+
// brightness/colour temp are KEYS inside it. `settable('brightness_command_topic')`
|
|
78
|
+
// is therefore false for every z2m lamp, which is why none of them ever showed
|
|
79
|
+
// up in a slider's picker. These probes recognise the other dialect.
|
|
80
|
+
const isJsonLight = cfg => cfg.schema === 'json' && Boolean(cfg.command_topic);
|
|
81
|
+
// z2m advertises `brightness: true`; HA-native lights advertise capability
|
|
82
|
+
// through supported_color_modes, where every mode EXCEPT `onoff` implies a
|
|
83
|
+
// settable brightness. An onoff-only lamp stays excluded — it is a switch
|
|
84
|
+
// match, not a slider one (the same guardrail as the separate-mode path).
|
|
85
|
+
const jsonBrightness = cfg => isJsonLight(cfg) && (Boolean(cfg.brightness) ||
|
|
86
|
+
(cfg.supported_color_modes || []).some(m => m && m !== 'onoff'));
|
|
87
|
+
const jsonColorTemp = cfg => isJsonLight(cfg) && (Boolean(cfg.color_temp) ||
|
|
88
|
+
(cfg.supported_color_modes || []).includes('color_temp'));
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The settable numeric AXES of a light — brightness and colour temp, in both
|
|
92
|
+
* dialects: separate-mode (one topic per axis — Homematic) and JSON-schema
|
|
93
|
+
* (one command topic taking `{brightness: N}` — zigbee2mqtt, HA `schema: json`).
|
|
94
|
+
*
|
|
95
|
+
* Split out from `sliderDiscovery` (E157) because a slider is not the only
|
|
96
|
+
* continuous control: `panel-knob` is the same thing with a different gesture.
|
|
97
|
+
* It reuses these axes but keeps its own richer `number` map, so the two share
|
|
98
|
+
* the light wiring without the knob losing its `unit`/`step` mapping.
|
|
99
|
+
*/
|
|
100
|
+
export const lightSettableAxes = [
|
|
101
|
+
{
|
|
102
|
+
component: 'light',
|
|
103
|
+
label: 'brightness',
|
|
104
|
+
when: settable('brightness_command_topic'),
|
|
105
|
+
map: {
|
|
106
|
+
name: 'label',
|
|
107
|
+
brightness_state_topic: 'subscribe',
|
|
108
|
+
brightness_command_topic: 'publish',
|
|
109
|
+
brightness_min: {attr: 'min'},
|
|
110
|
+
// HA/z2m call the top of the range brightness_scale (254 on
|
|
111
|
+
// zigbee, 1 on Homematic LEVEL). Absent → the control's own
|
|
112
|
+
// 0–100 default stands.
|
|
113
|
+
brightness_scale: {attr: 'max'},
|
|
114
|
+
message_property_brightness: 'message-property',
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
component: 'light',
|
|
119
|
+
label: 'color temp',
|
|
120
|
+
when: settable('color_temp_command_topic'),
|
|
121
|
+
map: {
|
|
122
|
+
name: 'label',
|
|
123
|
+
color_temp_state_topic: 'subscribe',
|
|
124
|
+
color_temp_command_topic: 'publish',
|
|
125
|
+
// Mireds are the wire unit here: the control publishes the raw
|
|
126
|
+
// value, so the range is taken as-is rather than converted to
|
|
127
|
+
// kelvin the way a light card's colour-temp control does.
|
|
128
|
+
min_mireds: {attr: 'min'},
|
|
129
|
+
max_mireds: {attr: 'max'},
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
// ── B81: the same two axes on a JSON-schema light ───────────────────────
|
|
133
|
+
// `publish-json-key` switches the control from publishing `128` to
|
|
134
|
+
// publishing `{"brightness": 128}` on the single command topic. The read
|
|
135
|
+
// side needs no new machinery — the state topic carries the whole object,
|
|
136
|
+
// so `message-property` just points into it.
|
|
137
|
+
//
|
|
138
|
+
// **Map order is load-bearing here too.** `command_topic` carries the
|
|
139
|
+
// range DEFAULTS in its `alsoSet` (the JSON schema's brightness range is
|
|
140
|
+
// 0–255, and mireds 153–500 — the control's own 0–100 default would be
|
|
141
|
+
// meaningless), and the discovered `brightness_scale` / mired keys come
|
|
142
|
+
// AFTER so a device that states its range overrides the default.
|
|
143
|
+
{
|
|
144
|
+
component: 'light',
|
|
145
|
+
label: 'brightness',
|
|
146
|
+
when: jsonBrightness,
|
|
147
|
+
map: {
|
|
148
|
+
name: 'label',
|
|
149
|
+
command_topic: {attr: 'publish', alsoSet: {
|
|
150
|
+
'publish-json-key': 'brightness',
|
|
151
|
+
'message-property': 'payload.brightness',
|
|
152
|
+
min: '0', max: '255',
|
|
153
|
+
}},
|
|
154
|
+
state_topic: 'subscribe',
|
|
155
|
+
brightness_scale: {attr: 'max'}, // z2m: 254 — must beat the 255 default
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
component: 'light',
|
|
160
|
+
label: 'color temp',
|
|
161
|
+
when: jsonColorTemp,
|
|
162
|
+
map: {
|
|
163
|
+
name: 'label',
|
|
164
|
+
command_topic: {attr: 'publish', alsoSet: {
|
|
165
|
+
'publish-json-key': 'color_temp',
|
|
166
|
+
'message-property': 'payload.color_temp',
|
|
167
|
+
min: '153', max: '500',
|
|
168
|
+
}},
|
|
169
|
+
state_topic: 'subscribe',
|
|
170
|
+
min_mireds: {attr: 'min'},
|
|
171
|
+
max_mireds: {attr: 'max'},
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
];
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* E158 — a thermostat's settable target temperature.
|
|
178
|
+
*
|
|
179
|
+
* Homematic TRVs and HA `climate` entities use the same key names here, so one
|
|
180
|
+
* variant covers both. `water_heater` is climate-shaped (E150) and reuses the
|
|
181
|
+
* identically-named keys, so it rides along rather than being a parity gap.
|
|
182
|
+
*/
|
|
183
|
+
const setpointMap = {
|
|
184
|
+
name: 'label',
|
|
185
|
+
temperature_state_topic: 'subscribe',
|
|
186
|
+
temperature_command_topic: 'publish',
|
|
187
|
+
min_temp: {attr: 'min'},
|
|
188
|
+
max_temp: {attr: 'max'},
|
|
189
|
+
temp_step: {attr: 'step'},
|
|
190
|
+
message_property_setpoint: 'message-property',
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
export const climateSetpointAxes = [
|
|
194
|
+
{component: 'climate', label: 'setpoint', when: settable('temperature_command_topic'), map: setpointMap},
|
|
195
|
+
{component: 'water_heater', label: 'setpoint', when: settable('temperature_command_topic'), map: setpointMap},
|
|
196
|
+
];
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* E158 — a blind's settable position, in both dialects.
|
|
200
|
+
*
|
|
201
|
+
* Both publish a PLAIN NUMBER, so neither needs `publish-json-key`: Homematic's
|
|
202
|
+
* `position_command_topic` takes a LEVEL, and HA's `set_position_topic` is a
|
|
203
|
+
* dedicated topic whose default template is the bare position (z2m spells it
|
|
204
|
+
* `…/set/position`).
|
|
205
|
+
*
|
|
206
|
+
* An open/close-only blind has neither key and yields no row — the inherited
|
|
207
|
+
* settable-only guardrail, and the reason this is two variants rather than one
|
|
208
|
+
* map with optional keys.
|
|
209
|
+
*/
|
|
210
|
+
export const coverPositionAxes = [
|
|
211
|
+
{
|
|
212
|
+
// Separate mode — Homematic reports LEVEL 0…1, so the range comes from
|
|
213
|
+
// position_min/position_max rather than being assumed 0–100.
|
|
214
|
+
component: 'cover',
|
|
215
|
+
label: 'position',
|
|
216
|
+
when: settable('position_command_topic'),
|
|
217
|
+
map: {
|
|
218
|
+
name: 'label',
|
|
219
|
+
position_state_topic: 'subscribe',
|
|
220
|
+
position_command_topic: 'publish',
|
|
221
|
+
position_min: {attr: 'min'},
|
|
222
|
+
position_max: {attr: 'max'},
|
|
223
|
+
message_property_position: 'message-property',
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
// HA / zigbee2mqtt — a dedicated set-position topic. HA's defaults are
|
|
228
|
+
// already 0–100, which is the control's own default, so no alsoSet.
|
|
229
|
+
component: 'cover',
|
|
230
|
+
label: 'position',
|
|
231
|
+
when: settable('set_position_topic'),
|
|
232
|
+
map: {
|
|
233
|
+
name: 'label',
|
|
234
|
+
position_topic: 'subscribe',
|
|
235
|
+
set_position_topic: 'publish',
|
|
236
|
+
position_closed: {attr: 'min'},
|
|
237
|
+
position_open: {attr: 'max'},
|
|
238
|
+
position_template: {attr: 'message-property', transform: 'valueTemplateToPath'},
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
];
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Every settable numeric axis a continuous control can drive, regardless of
|
|
245
|
+
* which component exposes it. `sliderDiscovery` adds the `number` entity on
|
|
246
|
+
* top; `panel-knob` takes this list and keeps its own `number` map.
|
|
247
|
+
*/
|
|
248
|
+
export const settableAxes = [
|
|
249
|
+
...lightSettableAxes,
|
|
250
|
+
...climateSetpointAxes,
|
|
251
|
+
...coverPositionAxes,
|
|
252
|
+
];
|
|
253
|
+
|
|
60
254
|
/**
|
|
61
255
|
* Everything a *-slider can drive. There is no "slider" entity type, so the
|
|
62
256
|
* descriptor has no base `component` — every match arrives through `accepts`,
|
|
@@ -64,49 +258,72 @@ const settable = key => cfg => Boolean(cfg[key]);
|
|
|
64
258
|
*/
|
|
65
259
|
export const sliderDiscovery = {
|
|
66
260
|
accepts: [
|
|
261
|
+
...settableAxes,
|
|
67
262
|
{
|
|
68
|
-
component: '
|
|
69
|
-
|
|
70
|
-
when: settable('brightness_command_topic'),
|
|
263
|
+
component: 'number',
|
|
264
|
+
when: settable('command_topic'),
|
|
71
265
|
map: {
|
|
72
266
|
name: 'label',
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
brightness_scale: {attr: 'max'},
|
|
80
|
-
message_property_brightness: 'message-property',
|
|
267
|
+
state_topic: 'subscribe',
|
|
268
|
+
command_topic: 'publish',
|
|
269
|
+
min: {attr: 'min'},
|
|
270
|
+
max: {attr: 'max'},
|
|
271
|
+
step: {attr: 'step'},
|
|
272
|
+
value_template: {attr: 'message-property', transform: 'valueTemplateToPath'},
|
|
81
273
|
},
|
|
82
274
|
},
|
|
275
|
+
],
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
// E157 — the mirror image of `settable`. A read-only display needs a STATE
|
|
279
|
+
// topic and must never be handed a command topic, so each variant below gates
|
|
280
|
+
// on the read path existing and no variant maps a `*_command_topic` at all.
|
|
281
|
+
const readable = key => cfg => Boolean(cfg[key]);
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Everything a read-only numeric display (`material-tank`, `material-progress`)
|
|
285
|
+
* can show. The exact inverse of the slider's guardrail: these elements have a
|
|
286
|
+
* `subscribe` and no `publish`, so they accept a plain `sensor` and the READ
|
|
287
|
+
* side of a `number` or a light's brightness — and are never wired to a topic
|
|
288
|
+
* that would let them command the device.
|
|
289
|
+
*
|
|
290
|
+
* No base `component` (same shape as `sliderDiscovery`): every match arrives
|
|
291
|
+
* through `accepts`.
|
|
292
|
+
*/
|
|
293
|
+
export const readonlyNumericDiscovery = {
|
|
294
|
+
accepts: [
|
|
83
295
|
{
|
|
84
|
-
component: '
|
|
85
|
-
|
|
86
|
-
when: settable('color_temp_command_topic'),
|
|
296
|
+
component: 'sensor',
|
|
297
|
+
when: readable('state_topic'),
|
|
87
298
|
map: {
|
|
88
299
|
name: 'label',
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
// value, so the range is taken as-is rather than converted to
|
|
93
|
-
// kelvin the way a light card's colour-temp control does.
|
|
94
|
-
min_mireds: {attr: 'min'},
|
|
95
|
-
max_mireds: {attr: 'max'},
|
|
300
|
+
state_topic: 'subscribe',
|
|
301
|
+
unit_of_measurement: 'unit',
|
|
302
|
+
value_template: {attr: 'message-property', transform: 'valueTemplateToPath'},
|
|
96
303
|
},
|
|
97
304
|
},
|
|
98
305
|
{
|
|
99
306
|
component: 'number',
|
|
100
|
-
when:
|
|
307
|
+
when: readable('state_topic'),
|
|
101
308
|
map: {
|
|
102
309
|
name: 'label',
|
|
103
|
-
state_topic:
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
step: {attr: 'step'},
|
|
310
|
+
state_topic: 'subscribe',
|
|
311
|
+
min: {attr: 'min'},
|
|
312
|
+
max: {attr: 'max'},
|
|
313
|
+
unit_of_measurement: 'unit',
|
|
108
314
|
value_template: {attr: 'message-property', transform: 'valueTemplateToPath'},
|
|
109
315
|
},
|
|
110
316
|
},
|
|
317
|
+
{
|
|
318
|
+
component: 'light',
|
|
319
|
+
label: 'brightness',
|
|
320
|
+
when: readable('brightness_state_topic'),
|
|
321
|
+
map: {
|
|
322
|
+
name: 'label',
|
|
323
|
+
brightness_state_topic: 'subscribe',
|
|
324
|
+
brightness_scale: {attr: 'max'},
|
|
325
|
+
message_property_brightness: 'message-property',
|
|
326
|
+
},
|
|
327
|
+
},
|
|
111
328
|
],
|
|
112
329
|
};
|
package/feezal-element.js
CHANGED
|
@@ -528,6 +528,39 @@ export function dialogPlaceholderLabel(base, label) {
|
|
|
528
528
|
return l ? `${base}: ${l}` : base;
|
|
529
529
|
}
|
|
530
530
|
|
|
531
|
+
/**
|
|
532
|
+
* B81 — shared descriptor for a continuous control's JSON publish key.
|
|
533
|
+
*
|
|
534
|
+
* The sliders and the knob publish a bare number (`"128"`). That is right for
|
|
535
|
+
* a dedicated topic — a Homematic LEVEL set topic, an HA `set_position_topic`,
|
|
536
|
+
* a z2m `…/set/<attribute>` sub-topic — but wrong for a **JSON-schema** device
|
|
537
|
+
* (z2m lights, HA `schema: json`), which has ONE command topic that takes an
|
|
538
|
+
* object: `{"brightness": 128}`.
|
|
539
|
+
*
|
|
540
|
+
* Setting this attribute switches the control to that form. Presence IS the
|
|
541
|
+
* mode — no separate enum — because a single-value control needs exactly one
|
|
542
|
+
* key, unlike the light/cover controllers whose `payload-mode` + `json-map`
|
|
543
|
+
* pair has to carry a whole object's worth of properties.
|
|
544
|
+
*/
|
|
545
|
+
export const publishJsonKeyAttribute = {
|
|
546
|
+
name: 'publish-json-key',
|
|
547
|
+
type: 'string',
|
|
548
|
+
default: '',
|
|
549
|
+
help: 'Publish a JSON object instead of a bare number: the value is sent as {"<key>": value} to the publish topic. Needed for devices with a single JSON command topic (zigbee2mqtt lights, Home Assistant schema: json) — e.g. "brightness". Leave empty to publish the plain number.'
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* B81 — render a numeric value for publishing, honouring `publish-json-key`.
|
|
554
|
+
* Without a key the value is stringified as before (byte-for-byte the previous
|
|
555
|
+
* behaviour); with one it becomes a single-key JSON object, numeric where the
|
|
556
|
+
* value is numeric so `{"brightness": 128}` is not sent as `{"brightness": "128"}`.
|
|
557
|
+
*/
|
|
558
|
+
export function numericPublishPayload(value, jsonKey) {
|
|
559
|
+
if (!jsonKey) return String(value);
|
|
560
|
+
const n = Number(value);
|
|
561
|
+
return JSON.stringify({[jsonKey]: Number.isFinite(n) && value !== '' && value !== null ? n : value});
|
|
562
|
+
}
|
|
563
|
+
|
|
531
564
|
/**
|
|
532
565
|
* E137 — payload comparison, cross-controller shared machinery: string
|
|
533
566
|
* coercion (case-insensitive) plus boolean true/false matching the HA/z2m
|
package/package.json
CHANGED