@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.
@@ -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 the two
7
- * crossings the item asked for, ONCE, so the ~10 switch elements and the three
8
- * slider elements do not each carry (and drift on) their own copy.
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 a *-switch: drive the lamp as plain on/off.
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 const switchAcceptsLight = {
26
- component: 'light',
27
- map: {
28
- // ── generic on/off light (zigbee2mqtt, HA) ────────────────────────
29
- name: 'label',
30
- payload_on: 'payload-on',
31
- payload_off: 'payload-off',
32
- state_topic: 'subscribe',
33
- state_command_topic: 'publish',
34
- command_topic: 'publish',
35
- value_template: {attr: 'message-property', transform: 'valueTemplateToPath'},
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
- // ── Homematic-style dimmer: on/off IS the LEVEL value (E77/E126) ──
38
- // The light card toggles on with the 1.005 OLD_LEVEL sentinel to restore
39
- // the previous brightness; a switch has no "previous brightness" to
40
- // restore and no way to represent 1.005, so it drives full-on/off with
41
- // 1 / 0. These come last so `alsoSet` overwrites the payload_on
42
- // (= '1.005') the generic rows above already wrote.
43
- brightness_state_topic: {attr: 'subscribe', onlyWhen: {on_off_source: 'brightness'}},
44
- message_property_brightness: {attr: 'message-property', onlyWhen: {on_off_source: 'brightness'}},
45
- brightness_command_topic: {
46
- attr: 'publish',
47
- onlyWhen: {on_off_source: 'brightness'},
48
- alsoSet: {'payload-on': '1', 'payload-off': '0'},
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: 'light',
69
- label: 'brightness',
70
- when: settable('brightness_command_topic'),
263
+ component: 'number',
264
+ when: settable('command_topic'),
71
265
  map: {
72
266
  name: 'label',
73
- brightness_state_topic: 'subscribe',
74
- brightness_command_topic: 'publish',
75
- brightness_min: {attr: 'min'},
76
- // HA/z2m call the top of the range brightness_scale (254 on
77
- // zigbee, 1 on Homematic LEVEL). Absent → the slider's own
78
- // 0–100 default stands.
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: 'light',
85
- label: 'color temp',
86
- when: settable('color_temp_command_topic'),
296
+ component: 'sensor',
297
+ when: readable('state_topic'),
87
298
  map: {
88
299
  name: 'label',
89
- color_temp_state_topic: 'subscribe',
90
- color_temp_command_topic: 'publish',
91
- // Mireds are the wire unit here: the slider publishes the raw
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: settable('command_topic'),
307
+ when: readable('state_topic'),
101
308
  map: {
102
309
  name: 'label',
103
- state_topic: 'subscribe',
104
- command_topic: 'publish',
105
- min: {attr: 'min'},
106
- max: {attr: 'max'},
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
@@ -42,5 +42,5 @@
42
42
  "url": "git+https://github.com/feezal/feezal.git",
43
43
  "directory": "www/packages/@feezal/feezal-element"
44
44
  },
45
- "version": "3.0.14"
45
+ "version": "3.0.16"
46
46
  }