@camstack/addon-provider-homeassistant 1.2.28 → 1.2.30
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/dist/addon.js +1 -1
- package/dist/addon.mjs +1 -1
- package/dist/{dist-BvkeiSOJ.mjs → dist-CzZXxFrl.mjs} +2464 -124
- package/dist/{dist-D_Zn5oFS.js → dist-XWveotlj.js} +2487 -123
- package/dist/ha-export/ha-export.addon.js +3619 -118
- package/dist/ha-export/ha-export.addon.mjs +3619 -118
- package/package.json +1 -1
|
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
|
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
5
|
//#endregion
|
|
6
|
-
const require_dist = require("../dist-
|
|
6
|
+
const require_dist = require("../dist-XWveotlj.js");
|
|
7
7
|
let node_crypto = require("node:crypto");
|
|
8
8
|
//#region src/ha-export/topics.ts
|
|
9
9
|
/**
|
|
@@ -67,11 +67,861 @@ function humanise(entity) {
|
|
|
67
67
|
function toComponentKey(platform, entity) {
|
|
68
68
|
return `${platform}-${entity.replace(/_/g, "-").replace(/[^a-zA-Z0-9_-]/g, "_")}`;
|
|
69
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* The entity id of a DERIVED capability entity — the one expression.
|
|
72
|
+
*
|
|
73
|
+
* The derived half of the catalog names its entity after the capability
|
|
74
|
+
* it comes from, and the projector has to publish to that exact name.
|
|
75
|
+
* They used to be two expressions in two files, and they disagreed:
|
|
76
|
+
* the catalog built `camstack/<key>/motion` while the projector
|
|
77
|
+
* published `motion_detected`, `triggered` and a set of doorbell topics
|
|
78
|
+
* of its own. **Only `battery` and `online` lined up**, so a non-camera
|
|
79
|
+
* motion, contact, smoke, leak or doorbell device got entities that
|
|
80
|
+
* could never receive a value — the same failure the 2026-08-05 audit
|
|
81
|
+
* measured on the old exporter (177 of 293 devices), reproduced on a
|
|
82
|
+
* narrower set.
|
|
83
|
+
*
|
|
84
|
+
* Both sides now come from here, and
|
|
85
|
+
* `__tests__/state-projector.spec.ts` walks every `CAP_ENTITY_MAP` entry
|
|
86
|
+
* asserting the catalog's `state_topic` is the topic the projector
|
|
87
|
+
* publishes to. That test is the guard: it fails the moment either side
|
|
88
|
+
* drifts.
|
|
89
|
+
*/
|
|
90
|
+
function derivedEntityId(capName) {
|
|
91
|
+
return toSlug(capName);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The entity id of a SECONDARY entity of the same capability.
|
|
95
|
+
*
|
|
96
|
+
* The nine allowed platforms cannot express a cover, a thermostat or a
|
|
97
|
+
* media player as one entity, so a capability may produce several: a
|
|
98
|
+
* `cover` is its state AND its position, a `climate-control` is its mode
|
|
99
|
+
* AND the temperature it measures. They are still ONE capability and one
|
|
100
|
+
* slice, so their ids are built from the same expression as the primary
|
|
101
|
+
* — `<cap>_<suffix>` — rather than invented per row. Two expressions in
|
|
102
|
+
* two files is exactly what left a non-camera contact sensor with an
|
|
103
|
+
* entity that could never receive a value.
|
|
104
|
+
*/
|
|
105
|
+
function derivedExtraEntityId(capName, suffix) {
|
|
106
|
+
return `${toSlug(capName)}_${toSlug(suffix)}`;
|
|
107
|
+
}
|
|
70
108
|
/** Lower-case, underscore-joined, diacritic-free. Ids only, never labels. */
|
|
71
109
|
function toSlug(value) {
|
|
72
110
|
return value.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_+|_+$/g, "").toLowerCase();
|
|
73
111
|
}
|
|
74
112
|
//#endregion
|
|
113
|
+
//#region src/ha-export/camera-entities.ts
|
|
114
|
+
/**
|
|
115
|
+
* The live `camera` entities — one per STREAM of the camera.
|
|
116
|
+
*
|
|
117
|
+
* ── What was wrong, measured on the operator's Home Assistant ────────────
|
|
118
|
+
*
|
|
119
|
+
* This module used to mint six components per camera (three profiles ×
|
|
120
|
+
* muted/unmuted), each carrying a broker RTSP url on a `state_topic`,
|
|
121
|
+
* behind two url gates: a refusal for a credential in the authority and a
|
|
122
|
+
* refusal for a loopback host. On 2026-08-14 the live registry was read:
|
|
123
|
+
* `platform: camstack` held **165 entities and exactly two `camera` ones**
|
|
124
|
+
* — one per exported camera, `unique_id` `camstack_<stableId>_camera`.
|
|
125
|
+
* None of the twelve announced stream components existed.
|
|
126
|
+
*
|
|
127
|
+
* They never could. `custom_components/camstack/camera.py` builds its
|
|
128
|
+
* cameras from the export MEMBERSHIP, not from `cmps`: a still comes from
|
|
129
|
+
* `snapshot.getSnapshot` and live video from `webrtcSession.handleOffer`,
|
|
130
|
+
* which is native WebRTC and has no use for an RTSP address. The
|
|
131
|
+
* component's platform probe answers `camera` — truthfully, it builds the
|
|
132
|
+
* platform — and the hub read that as "it will build MY camera
|
|
133
|
+
* components". Twelve components, two entities, no warning on either
|
|
134
|
+
* side.
|
|
135
|
+
*
|
|
136
|
+
* ── What this module is now ──────────────────────────────────────────────
|
|
137
|
+
*
|
|
138
|
+
* One component per entry of `webrtcSession.listStreams`, carrying the
|
|
139
|
+
* `target` verbatim so the component hands it back to `handleOffer`. No
|
|
140
|
+
* url crosses, which is why the two gates are gone rather than kept "just
|
|
141
|
+
* in case": there is no address here to leak a password or to point at
|
|
142
|
+
* 127.0.0.1, and a gate guarding a value that no longer exists is the
|
|
143
|
+
* leftover that reads as verification.
|
|
144
|
+
*
|
|
145
|
+
* ── The adaptive stream is announced by NOBODY, on purpose ───────────────
|
|
146
|
+
*
|
|
147
|
+
* The component's membership entity IS the adaptive stream: it negotiates
|
|
148
|
+
* no target, it is the device's primary camera (`_attr_name = None`), and
|
|
149
|
+
* it has carried `camstack_<stableId>_camera` since the integration
|
|
150
|
+
* shipped. Announcing a component for it would claim the same
|
|
151
|
+
* `unique_id` — Home Assistant keys its registry on
|
|
152
|
+
* `(platform, integration, unique_id)` — and one of the two would lose the
|
|
153
|
+
* race and be dropped with a "does not generate unique IDs" error. So the
|
|
154
|
+
* operator's existing `camera.videocamera_ingresso` is left exactly where
|
|
155
|
+
* it is and gains SIBLINGS: one per profile, one per substream, which are
|
|
156
|
+
* the streams membership cannot express.
|
|
157
|
+
*
|
|
158
|
+
* ── Fan-out ──────────────────────────────────────────────────────────────
|
|
159
|
+
*
|
|
160
|
+
* Every remaining stream is announced. What arrives ENABLED is the
|
|
161
|
+
* assigned profiles — the pictures an operator picks between on a
|
|
162
|
+
* dashboard. The raw `cam-stream` sources (the camera's own main/sub
|
|
163
|
+
* substreams) are registered-and-off: they are the same pictures the
|
|
164
|
+
* profiles already serve, one layer below the slot the rest of camstack
|
|
165
|
+
* dials, and an operator who wants one switches it on per camera.
|
|
166
|
+
*/
|
|
167
|
+
/** Profile slots, in the order an operator reads them. */
|
|
168
|
+
var CAMERA_PROFILE_ORDER = [
|
|
169
|
+
"high",
|
|
170
|
+
"mid",
|
|
171
|
+
"low"
|
|
172
|
+
];
|
|
173
|
+
/** `high` → 0. An unknown profile sorts after the known ones, stably. */
|
|
174
|
+
function profileRank(profile) {
|
|
175
|
+
const index = CAMERA_PROFILE_ORDER.indexOf(profile);
|
|
176
|
+
return index === -1 ? CAMERA_PROFILE_ORDER.length : index;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Reading order: adaptive, then the profile slots high → mid → low, then
|
|
180
|
+
* whatever raw sources the camera reports, in the hub's own order.
|
|
181
|
+
*
|
|
182
|
+
* Rank only. The one stream that does NOT become a component is the
|
|
183
|
+
* adaptive one, and it is filtered by {@link isAnnounced} rather than
|
|
184
|
+
* here, because it is not a drop: it reaches Home Assistant on the
|
|
185
|
+
* membership entity. Every other slot the hub reports becomes an entity —
|
|
186
|
+
* "include all the streams of a camera" is the request this module exists
|
|
187
|
+
* to answer, and a slot silently dropped is a loss the operator cannot see
|
|
188
|
+
* from Home Assistant.
|
|
189
|
+
*/
|
|
190
|
+
function streamRank(target) {
|
|
191
|
+
switch (target.kind) {
|
|
192
|
+
case "adaptive": return 0;
|
|
193
|
+
case "profile": return 1 + profileRank(target.profile);
|
|
194
|
+
case "cam-stream": return 1 + CAMERA_PROFILE_ORDER.length;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* The adaptive stream is the component's own entity and gets no component.
|
|
199
|
+
*
|
|
200
|
+
* The one filter in this file, and the reason it is not the "drop a slot"
|
|
201
|
+
* kind: the stream still reaches Home Assistant, on the entity that has
|
|
202
|
+
* always carried it. See the identity note above.
|
|
203
|
+
*/
|
|
204
|
+
function isAnnounced(target) {
|
|
205
|
+
return target.kind !== "adaptive";
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* The entity id of one stream.
|
|
209
|
+
*
|
|
210
|
+
* Derived from the TARGET rather than from `choice.id`, so the ids stay
|
|
211
|
+
* `stream_high` / `stream_mid` / `stream_low` — what this file already
|
|
212
|
+
* announced — instead of following the picker's `profile:high` spelling.
|
|
213
|
+
* An entity id is identity; the picker's id is a React key.
|
|
214
|
+
*/
|
|
215
|
+
function entityIdFor(target) {
|
|
216
|
+
switch (target.kind) {
|
|
217
|
+
case "adaptive":
|
|
218
|
+
/* c8 ignore next -- filtered by isAnnounced before it can be reached */
|
|
219
|
+
return "stream_adaptive";
|
|
220
|
+
case "profile": return `stream_${toSlug(target.profile)}`;
|
|
221
|
+
case "cam-stream": return `stream_${toSlug(target.camStreamId)}`;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Enabled by default: the assigned profiles.
|
|
226
|
+
*
|
|
227
|
+
* A raw `cam-stream` is the source a profile slot already points at. It
|
|
228
|
+
* is announced so an operator who wants the camera's own substream can
|
|
229
|
+
* have it, and it is off so that a camera does not arrive with six
|
|
230
|
+
* pictures of the same doorway switched on.
|
|
231
|
+
*/
|
|
232
|
+
function enabledByDefaultFor(target) {
|
|
233
|
+
return target.kind !== "cam-stream";
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* The label an operator reads on a card.
|
|
237
|
+
*
|
|
238
|
+
* The hub's own label plus the vertical resolution, because that is the
|
|
239
|
+
* one thing that distinguishes three pictures of the same doorway —
|
|
240
|
+
* "Stream high (2160p)" answers "which of these do I put on the wall
|
|
241
|
+
* tablet" and "Stream high" does not. The resolution is only in the label
|
|
242
|
+
* (never in the id), so a re-encoded profile renames an entity and never
|
|
243
|
+
* orphans one.
|
|
244
|
+
*/
|
|
245
|
+
function labelFor(choice) {
|
|
246
|
+
const base = `Stream ${choice.label.toLowerCase()}`;
|
|
247
|
+
return choice.height === void 0 ? base : `${base} (${choice.height}p)`;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Stream list → the `camera` entities the catalog builds.
|
|
251
|
+
*
|
|
252
|
+
* Pure, and value-free: a `camera` entity has no state on the push plane
|
|
253
|
+
* at all. It exists because the hub lists the stream, and everything the
|
|
254
|
+
* component needs to show it travels in `stream_target`.
|
|
255
|
+
*/
|
|
256
|
+
function cameraStreamEntities(choices) {
|
|
257
|
+
return [...choices].filter((choice) => isAnnounced(choice.target)).sort((a, b) => streamRank(a.target) - streamRank(b.target)).map((choice) => ({
|
|
258
|
+
entity: entityIdFor(choice.target),
|
|
259
|
+
platform: "camera",
|
|
260
|
+
label: labelFor(choice),
|
|
261
|
+
enabledByDefault: enabledByDefaultFor(choice.target),
|
|
262
|
+
target: choice.target
|
|
263
|
+
}));
|
|
264
|
+
}
|
|
265
|
+
//#endregion
|
|
266
|
+
//#region src/ha-export/component-support.ts
|
|
267
|
+
/** Where the component publishes what it builds. A wire format. */
|
|
268
|
+
var COMPONENT_VERSION_PATH = "/api/camstack/version";
|
|
269
|
+
/**
|
|
270
|
+
* The platforms every released component builds.
|
|
271
|
+
*
|
|
272
|
+
* Assumed when the component does not answer the probe at all, which is
|
|
273
|
+
* exactly what 0.3.x and older do: the endpoint arrived with the native
|
|
274
|
+
* platforms. `alarm_control_panel` is deliberately ABSENT — the export has
|
|
275
|
+
* been emitting it since T1 and the 0.3.x component never built it, so
|
|
276
|
+
* assuming it here would keep an alarm panel invisible on the very
|
|
277
|
+
* installations this list exists to protect.
|
|
278
|
+
*/
|
|
279
|
+
var LEGACY_PLATFORMS = [
|
|
280
|
+
"binary_sensor",
|
|
281
|
+
"sensor",
|
|
282
|
+
"image",
|
|
283
|
+
"switch",
|
|
284
|
+
"button",
|
|
285
|
+
"select",
|
|
286
|
+
"camera",
|
|
287
|
+
"number"
|
|
288
|
+
];
|
|
289
|
+
var LEGACY_SUPPORT = new Set(LEGACY_PLATFORMS);
|
|
290
|
+
/**
|
|
291
|
+
* What the catalog assumes when NOBODY asked.
|
|
292
|
+
*
|
|
293
|
+
* Deliberately not {@link LEGACY_SUPPORT}: the platforms the degraded
|
|
294
|
+
* tables themselves name, which is exactly the behaviour the export had
|
|
295
|
+
* before any of this existed. A caller that does not negotiate therefore
|
|
296
|
+
* gets what it always got — no native platforms, and no degradation
|
|
297
|
+
* either. Both changes are decisions the NEGOTIATION makes, on an answer
|
|
298
|
+
* from the Home Assistant that has to build them, and neither is a
|
|
299
|
+
* default a test fixture or a future caller can trip over by accident.
|
|
300
|
+
*/
|
|
301
|
+
var UNNEGOTIATED_SUPPORT = new Set([...LEGACY_PLATFORMS, "alarm_control_panel"]);
|
|
302
|
+
var KNOWN_PLATFORMS = new Set([
|
|
303
|
+
"binary_sensor",
|
|
304
|
+
"sensor",
|
|
305
|
+
"image",
|
|
306
|
+
"switch",
|
|
307
|
+
"button",
|
|
308
|
+
"select",
|
|
309
|
+
"camera",
|
|
310
|
+
"alarm_control_panel",
|
|
311
|
+
"number",
|
|
312
|
+
"cover",
|
|
313
|
+
"climate",
|
|
314
|
+
"lock",
|
|
315
|
+
"fan",
|
|
316
|
+
"vacuum",
|
|
317
|
+
"valve",
|
|
318
|
+
"humidifier",
|
|
319
|
+
"water_heater",
|
|
320
|
+
"media_player"
|
|
321
|
+
]);
|
|
322
|
+
function isKnownPlatform(value) {
|
|
323
|
+
return KNOWN_PLATFORMS.has(value);
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Parse the component's answer.
|
|
327
|
+
*
|
|
328
|
+
* A platform this hub does not know is DROPPED rather than refused: the
|
|
329
|
+
* component ships on its own train and may well build something newer
|
|
330
|
+
* than this hub can emit, and refusing the whole report over one unknown
|
|
331
|
+
* name would downgrade every entity on that installation.
|
|
332
|
+
*/
|
|
333
|
+
function parseComponentReport(body) {
|
|
334
|
+
if (body === null || typeof body !== "object") return null;
|
|
335
|
+
const raw = Reflect.get(body, "platforms");
|
|
336
|
+
if (!Array.isArray(raw)) return null;
|
|
337
|
+
const platforms = /* @__PURE__ */ new Set();
|
|
338
|
+
for (const entry of raw) if (typeof entry === "string" && isKnownPlatform(entry)) platforms.add(entry);
|
|
339
|
+
if (platforms.size === 0) return null;
|
|
340
|
+
const version = Reflect.get(body, "version");
|
|
341
|
+
return {
|
|
342
|
+
version: typeof version === "string" ? version : null,
|
|
343
|
+
platforms
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
/** Stable, comparable text for a support set. Used for change detection. */
|
|
347
|
+
function supportSignature(support) {
|
|
348
|
+
return [...support].sort().join(",");
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* The platforms every one of these components builds.
|
|
352
|
+
*
|
|
353
|
+
* A device exported to two Home Assistant instances is announced ONCE, so
|
|
354
|
+
* a platform only one of them builds cannot be used: the other would
|
|
355
|
+
* receive a component it drops on the floor. Two instances on different
|
|
356
|
+
* component versions is the case this exists for, and the cost is that
|
|
357
|
+
* the newer one waits for the older to be updated — visible in the log,
|
|
358
|
+
* and never an entity that silently does not arrive.
|
|
359
|
+
*/
|
|
360
|
+
function intersectSupport(supports) {
|
|
361
|
+
const [first, ...rest] = supports;
|
|
362
|
+
if (first === void 0) return LEGACY_SUPPORT;
|
|
363
|
+
const out = /* @__PURE__ */ new Set();
|
|
364
|
+
for (const platform of first) if (rest.every((other) => other.has(platform))) out.add(platform);
|
|
365
|
+
return out;
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Per-broker negotiated support, with the downgrade rule.
|
|
369
|
+
*
|
|
370
|
+
* Pure and injectable-free: the probe is somebody else's job, this only
|
|
371
|
+
* decides what a sequence of answers means.
|
|
372
|
+
*/
|
|
373
|
+
var ComponentSupportTracker = class {
|
|
374
|
+
current = /* @__PURE__ */ new Map();
|
|
375
|
+
/** brokerId → the signature of the downgrade that has been seen once. */
|
|
376
|
+
pendingDowngrade = /* @__PURE__ */ new Map();
|
|
377
|
+
/** The support in force for a broker. Legacy until something says otherwise. */
|
|
378
|
+
supportFor(brokerId) {
|
|
379
|
+
return this.current.get(brokerId) ?? LEGACY_SUPPORT;
|
|
380
|
+
}
|
|
381
|
+
/** The support in force for a set of brokers, intersected. */
|
|
382
|
+
supportForAll(brokerIds) {
|
|
383
|
+
if (brokerIds.length === 0) return LEGACY_SUPPORT;
|
|
384
|
+
return intersectSupport(brokerIds.map((id) => this.supportFor(id)));
|
|
385
|
+
}
|
|
386
|
+
forget(brokerId) {
|
|
387
|
+
this.current.delete(brokerId);
|
|
388
|
+
this.pendingDowngrade.delete(brokerId);
|
|
389
|
+
}
|
|
390
|
+
observe(brokerId, probe) {
|
|
391
|
+
const before = this.supportFor(brokerId);
|
|
392
|
+
if (probe.kind === "unknown") return {
|
|
393
|
+
support: before,
|
|
394
|
+
changed: false,
|
|
395
|
+
withheldDowngrade: false
|
|
396
|
+
};
|
|
397
|
+
const next = probe.kind === "absent" ? LEGACY_SUPPORT : probe.report.platforms;
|
|
398
|
+
const signature = supportSignature(next);
|
|
399
|
+
if (signature === supportSignature(before)) {
|
|
400
|
+
this.pendingDowngrade.delete(brokerId);
|
|
401
|
+
this.current.set(brokerId, next);
|
|
402
|
+
return {
|
|
403
|
+
support: before,
|
|
404
|
+
changed: false,
|
|
405
|
+
withheldDowngrade: false
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
if ([...before].some((platform) => !next.has(platform)) && this.pendingDowngrade.get(brokerId) !== signature) {
|
|
409
|
+
this.pendingDowngrade.set(brokerId, signature);
|
|
410
|
+
return {
|
|
411
|
+
support: before,
|
|
412
|
+
changed: false,
|
|
413
|
+
withheldDowngrade: true
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
this.pendingDowngrade.delete(brokerId);
|
|
417
|
+
this.current.set(brokerId, next);
|
|
418
|
+
return {
|
|
419
|
+
support: next,
|
|
420
|
+
changed: true,
|
|
421
|
+
withheldDowngrade: false
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
//#endregion
|
|
426
|
+
//#region src/ha-export/native-platforms.ts
|
|
427
|
+
/**
|
|
428
|
+
* The NATIVE half of the Home Assistant export.
|
|
429
|
+
*
|
|
430
|
+
* A `cover` in Home Assistant is one entity with an open/close/stop
|
|
431
|
+
* surface, a position slider and a tilt slider. Exported through the nine
|
|
432
|
+
* platforms the 0.3.x component builds it is a `sensor` plus two more
|
|
433
|
+
* `sensor`s plus three `button`s — automatable, but not a cover: no
|
|
434
|
+
* `cover.open_cover`, no position in the more-info dialog, no
|
|
435
|
+
* `device_class: garage`, and every dashboard card that expects a cover
|
|
436
|
+
* refuses it. Same for a lock (`jammed` is not a boolean), a thermostat,
|
|
437
|
+
* a vacuum and a media player.
|
|
438
|
+
*
|
|
439
|
+
* From component 0.4.0 those platforms exist, and this table says which
|
|
440
|
+
* capability becomes which one. It is **structure only**: the topics a
|
|
441
|
+
* native component names are the topics the DEGRADED entities already
|
|
442
|
+
* used, so `state-projector.ts` is untouched and the value plane cannot
|
|
443
|
+
* disagree with the entity plane. Nothing here publishes a value.
|
|
444
|
+
*
|
|
445
|
+
* ── Three rules, each with a cost behind it ──────────────────────────────
|
|
446
|
+
*
|
|
447
|
+
* 1. **A control appears only when its descriptor is writable.** The
|
|
448
|
+
* authority is `CAP_ENTITY_MAP` — the same flag that decides whether the
|
|
449
|
+
* degraded entity gets a command topic, and therefore the same flag that
|
|
450
|
+
* tracks `CAP_COMMAND_ROUTES`. A native cover on a hub whose route table
|
|
451
|
+
* cannot move a cover is a READ-ONLY cover: it shows state, position and
|
|
452
|
+
* tilt and offers no buttons. A control that calls nothing is the defect
|
|
453
|
+
* this repo has shipped twice (D62), and a prettier platform is not a
|
|
454
|
+
* reason to ship it a third time.
|
|
455
|
+
*
|
|
456
|
+
* 2. **A binding whose entity does not exist is skipped.** The degraded
|
|
457
|
+
* table is being widened in parallel (verb buttons, writable numbers).
|
|
458
|
+
* Every binding below is resolved against `CAP_ENTITY_MAP` at build
|
|
459
|
+
* time, so a control lights up the moment its descriptor appears and
|
|
460
|
+
* costs nothing until then. This is what lets the two halves land in
|
|
461
|
+
* either order.
|
|
462
|
+
*
|
|
463
|
+
* 3. **What the native entity reads, it OWNS — and only that.** A bound
|
|
464
|
+
* entity is not announced separately: a native cover plus a `sensor`
|
|
465
|
+
* reporting the same position is two readings of one value, and a native
|
|
466
|
+
* cover plus a `number` writing the same position is two knobs (D62).
|
|
467
|
+
* Everything NOT bound stays exactly as it was, and that is the property
|
|
468
|
+
* that makes widening safe in both directions: a vacuum's error label is
|
|
469
|
+
* still a diagnostic sensor because HA's vacuum cannot show one, and a
|
|
470
|
+
* thermostat's vertical-swing toggle is still a `switch` because HA's
|
|
471
|
+
* climate expresses swing as a mode vocabulary the capability does not
|
|
472
|
+
* have. A control this table has no binding for is never silently lost —
|
|
473
|
+
* it is simply not absorbed.
|
|
474
|
+
*/
|
|
475
|
+
/**
|
|
476
|
+
* capability → the native Home Assistant platform it becomes.
|
|
477
|
+
*
|
|
478
|
+
* Every capability here also has a row in `CAP_ENTITY_MAP`, which stays
|
|
479
|
+
* the fallback for a component that does not build the platform. The two
|
|
480
|
+
* are never both announced.
|
|
481
|
+
*/
|
|
482
|
+
var NATIVE_CAP_PLATFORMS = {
|
|
483
|
+
/**
|
|
484
|
+
* `CoverStatusSchema`: `state` is the HA lifecycle verbatim
|
|
485
|
+
* (`open`/`opening`/`closing`/`closed`/`stopped`), `position` and
|
|
486
|
+
* `tiltPosition` are 0..100 or null. The three verbs are bound BOTH ways:
|
|
487
|
+
* to the primary command topic (one topic, `OPEN`/`CLOSE`/`STOP`) and to
|
|
488
|
+
* the per-verb buttons, so whichever the degraded table grows, the
|
|
489
|
+
* native cover can drive it.
|
|
490
|
+
*/
|
|
491
|
+
cover: {
|
|
492
|
+
platform: "cover",
|
|
493
|
+
primary: { from: null },
|
|
494
|
+
controls: {
|
|
495
|
+
position: {
|
|
496
|
+
from: "position",
|
|
497
|
+
min: 0,
|
|
498
|
+
max: 100,
|
|
499
|
+
step: 1
|
|
500
|
+
},
|
|
501
|
+
tilt: {
|
|
502
|
+
from: "tilt",
|
|
503
|
+
min: 0,
|
|
504
|
+
max: 100,
|
|
505
|
+
step: 1
|
|
506
|
+
},
|
|
507
|
+
open: {
|
|
508
|
+
from: "open",
|
|
509
|
+
commandOnly: true
|
|
510
|
+
},
|
|
511
|
+
close: {
|
|
512
|
+
from: "close",
|
|
513
|
+
commandOnly: true
|
|
514
|
+
},
|
|
515
|
+
stop: {
|
|
516
|
+
from: "stop",
|
|
517
|
+
commandOnly: true
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
},
|
|
521
|
+
/** `ValveStatusSchema` — the cover lifecycle without tilt. */
|
|
522
|
+
valve: {
|
|
523
|
+
platform: "valve",
|
|
524
|
+
primary: { from: null },
|
|
525
|
+
controls: {
|
|
526
|
+
position: {
|
|
527
|
+
from: "position",
|
|
528
|
+
min: 0,
|
|
529
|
+
max: 100,
|
|
530
|
+
step: 1
|
|
531
|
+
},
|
|
532
|
+
open: {
|
|
533
|
+
from: "open",
|
|
534
|
+
commandOnly: true
|
|
535
|
+
},
|
|
536
|
+
close: {
|
|
537
|
+
from: "close",
|
|
538
|
+
commandOnly: true
|
|
539
|
+
},
|
|
540
|
+
stop: {
|
|
541
|
+
from: "stop",
|
|
542
|
+
commandOnly: true
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
},
|
|
546
|
+
/**
|
|
547
|
+
* The reason this platform exists. `LockStateSchema` is
|
|
548
|
+
* `locked | unlocked | locking | unlocking | jammed`, and every one of
|
|
549
|
+
* those is a state Home Assistant's `lock` has — including the one a
|
|
550
|
+
* `switch` silently reports as "unlocked".
|
|
551
|
+
*
|
|
552
|
+
* It READS the enum extra and WRITES the switch. The degraded primary
|
|
553
|
+
* publishes a boolean derived from the same field, so binding the state
|
|
554
|
+
* to it would throw away exactly the distinction the platform is for.
|
|
555
|
+
*/
|
|
556
|
+
"lock-control": {
|
|
557
|
+
platform: "lock",
|
|
558
|
+
primary: {
|
|
559
|
+
from: "state",
|
|
560
|
+
commandFrom: null
|
|
561
|
+
},
|
|
562
|
+
controls: { open: {
|
|
563
|
+
from: "open",
|
|
564
|
+
commandOnly: true
|
|
565
|
+
} }
|
|
566
|
+
},
|
|
567
|
+
/**
|
|
568
|
+
* `AlarmStateSchema` IS Home Assistant's alarm vocabulary, and
|
|
569
|
+
* `availableModes` is the subset this panel accepts — a panel that
|
|
570
|
+
* cannot arm `vacation` must not offer the button. `requiresCode` is the
|
|
571
|
+
* panel's own answer about a PIN.
|
|
572
|
+
*
|
|
573
|
+
* This is also the only row here whose degraded form is a REGRESSION
|
|
574
|
+
* rather than the status quo: `CAP_ENTITY_MAP` already names
|
|
575
|
+
* `alarm_control_panel`, which the 0.3.x component does not build, so an
|
|
576
|
+
* alarm panel exported to it produces NO entity at all. The fallback
|
|
577
|
+
* below gives those installations the state as a sensor.
|
|
578
|
+
*/
|
|
579
|
+
"alarm-panel": {
|
|
580
|
+
platform: "alarm_control_panel",
|
|
581
|
+
primary: {
|
|
582
|
+
from: null,
|
|
583
|
+
optionsField: "availableModes"
|
|
584
|
+
},
|
|
585
|
+
flagFields: { code_arm_required: "requiresCode" }
|
|
586
|
+
},
|
|
587
|
+
/**
|
|
588
|
+
* `ClimateControlStatusSchema`. Every temperature is Celsius by the
|
|
589
|
+
* cap's own doc comment, and the mode/fan-mode/preset vocabularies are
|
|
590
|
+
* `available*` arrays on the DEVICE — which is exactly why they cannot be
|
|
591
|
+
* a static `select` in the degraded table and can be a native climate
|
|
592
|
+
* here: the component receives the list with the entity.
|
|
593
|
+
*/
|
|
594
|
+
"climate-control": {
|
|
595
|
+
platform: "climate",
|
|
596
|
+
primary: {
|
|
597
|
+
from: null,
|
|
598
|
+
optionsField: "availableModes"
|
|
599
|
+
},
|
|
600
|
+
constants: { temperature_unit: "°C" },
|
|
601
|
+
controls: {
|
|
602
|
+
current_temperature: { from: "current-temp" },
|
|
603
|
+
current_humidity: { from: "current-humidity" },
|
|
604
|
+
target_humidity: {
|
|
605
|
+
from: "target-humidity",
|
|
606
|
+
min: 0,
|
|
607
|
+
max: 100,
|
|
608
|
+
step: 1
|
|
609
|
+
},
|
|
610
|
+
target: {
|
|
611
|
+
from: "target",
|
|
612
|
+
min: -20,
|
|
613
|
+
max: 90,
|
|
614
|
+
step: .5
|
|
615
|
+
},
|
|
616
|
+
target_low: {
|
|
617
|
+
from: "target-low",
|
|
618
|
+
min: -20,
|
|
619
|
+
max: 90,
|
|
620
|
+
step: .5
|
|
621
|
+
},
|
|
622
|
+
target_high: {
|
|
623
|
+
from: "target-high",
|
|
624
|
+
min: -20,
|
|
625
|
+
max: 90,
|
|
626
|
+
step: .5
|
|
627
|
+
},
|
|
628
|
+
fan_mode: {
|
|
629
|
+
from: "fan-mode",
|
|
630
|
+
optionsField: "availableFanModes"
|
|
631
|
+
},
|
|
632
|
+
preset: {
|
|
633
|
+
from: "preset",
|
|
634
|
+
optionsField: "availablePresets"
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
},
|
|
638
|
+
/**
|
|
639
|
+
* A fan has no `on` field: `percentage` IS its state and 0 is off, which
|
|
640
|
+
* is what Home Assistant's fan does too (`turn_off` writes 0).
|
|
641
|
+
* `percentageStep` is the device's own granularity — a 4-speed fan
|
|
642
|
+
* reports 25, and a step of 1 would offer 37% to hardware that rounds it.
|
|
643
|
+
*/
|
|
644
|
+
"fan-control": {
|
|
645
|
+
platform: "fan",
|
|
646
|
+
primary: {
|
|
647
|
+
from: null,
|
|
648
|
+
min: 0,
|
|
649
|
+
max: 100,
|
|
650
|
+
step: 1,
|
|
651
|
+
stepField: "percentageStep"
|
|
652
|
+
},
|
|
653
|
+
controls: {
|
|
654
|
+
preset: {
|
|
655
|
+
from: "preset",
|
|
656
|
+
optionsField: "availablePresets"
|
|
657
|
+
},
|
|
658
|
+
oscillation: { from: "oscillating" },
|
|
659
|
+
direction: { from: "direction" }
|
|
660
|
+
}
|
|
661
|
+
},
|
|
662
|
+
/** `HumidifierStatusSchema`. `minHumidity`/`maxHumidity` are per device. */
|
|
663
|
+
humidifier: {
|
|
664
|
+
platform: "humidifier",
|
|
665
|
+
deviceClass: "humidifier",
|
|
666
|
+
primary: { from: null },
|
|
667
|
+
controls: {
|
|
668
|
+
current_humidity: { from: "current-humidity" },
|
|
669
|
+
target_humidity: {
|
|
670
|
+
from: "target-humidity",
|
|
671
|
+
minField: "minHumidity",
|
|
672
|
+
maxField: "maxHumidity",
|
|
673
|
+
min: 0,
|
|
674
|
+
max: 100,
|
|
675
|
+
step: 1
|
|
676
|
+
},
|
|
677
|
+
mode: {
|
|
678
|
+
from: "mode",
|
|
679
|
+
optionsField: "availableModes"
|
|
680
|
+
},
|
|
681
|
+
action: { from: "action" }
|
|
682
|
+
}
|
|
683
|
+
},
|
|
684
|
+
/**
|
|
685
|
+
* Home Assistant's water heater IS its operation mode — the entity state
|
|
686
|
+
* is `eco`/`performance`/`off`, not a temperature. The capability's
|
|
687
|
+
* primary entity is the MEASURED temperature, so the primary binding
|
|
688
|
+
* names the mode extra and the measurement becomes a control.
|
|
689
|
+
*/
|
|
690
|
+
"water-heater": {
|
|
691
|
+
platform: "water_heater",
|
|
692
|
+
primary: {
|
|
693
|
+
from: "mode",
|
|
694
|
+
optionsField: "availableModes"
|
|
695
|
+
},
|
|
696
|
+
constants: { temperature_unit: "°C" },
|
|
697
|
+
controls: {
|
|
698
|
+
current_temperature: { from: null },
|
|
699
|
+
target: {
|
|
700
|
+
from: "target",
|
|
701
|
+
minField: "minTemp",
|
|
702
|
+
maxField: "maxTemp",
|
|
703
|
+
min: -20,
|
|
704
|
+
max: 90,
|
|
705
|
+
step: .5
|
|
706
|
+
},
|
|
707
|
+
away: { from: "away" }
|
|
708
|
+
}
|
|
709
|
+
},
|
|
710
|
+
/**
|
|
711
|
+
* `VacuumStateSchema` is `idle|cleaning|paused|returning|docked|drying|error`;
|
|
712
|
+
* Home Assistant has every one but `drying`, which the component folds
|
|
713
|
+
* into `cleaning` rather than dropping the update.
|
|
714
|
+
*
|
|
715
|
+
* Battery, progress and the error label are deliberately NOT absorbed.
|
|
716
|
+
* Home Assistant deprecated the vacuum battery attribute in favour of a
|
|
717
|
+
* separate `sensor`, and it has nowhere to show a progress percentage or
|
|
718
|
+
* a vendor error string at all — as sensors they stay automatable.
|
|
719
|
+
*/
|
|
720
|
+
"vacuum-control": {
|
|
721
|
+
platform: "vacuum",
|
|
722
|
+
primary: { from: null },
|
|
723
|
+
controls: {
|
|
724
|
+
fan_speed: {
|
|
725
|
+
from: "fan-speed",
|
|
726
|
+
optionsField: "availableFanSpeeds"
|
|
727
|
+
},
|
|
728
|
+
start: {
|
|
729
|
+
from: "start",
|
|
730
|
+
commandOnly: true
|
|
731
|
+
},
|
|
732
|
+
pause: {
|
|
733
|
+
from: "pause",
|
|
734
|
+
commandOnly: true
|
|
735
|
+
},
|
|
736
|
+
stop: {
|
|
737
|
+
from: "stop",
|
|
738
|
+
commandOnly: true
|
|
739
|
+
},
|
|
740
|
+
return_to_base: {
|
|
741
|
+
from: "return-to-base",
|
|
742
|
+
commandOnly: true
|
|
743
|
+
},
|
|
744
|
+
locate: {
|
|
745
|
+
from: "locate",
|
|
746
|
+
commandOnly: true
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
},
|
|
750
|
+
/**
|
|
751
|
+
* `MediaPlayerStatusSchema`. `volumeLevel` is 0..100 in the cap and
|
|
752
|
+
* 0..1 in Home Assistant — the component converts, in one place, because
|
|
753
|
+
* a factor of 100 applied on the wrong side is a player that jumps to
|
|
754
|
+
* full volume.
|
|
755
|
+
*
|
|
756
|
+
* `currentMedia` is an object and stays unexported: title and artist
|
|
757
|
+
* would need a projector that walks into a sub-object, and an entity
|
|
758
|
+
* that reports `unrenderable` on every delivery is worse than one that
|
|
759
|
+
* does not exist.
|
|
760
|
+
*/
|
|
761
|
+
"media-player": {
|
|
762
|
+
platform: "media_player",
|
|
763
|
+
primary: { from: null },
|
|
764
|
+
controls: {
|
|
765
|
+
volume: {
|
|
766
|
+
from: "volume",
|
|
767
|
+
min: 0,
|
|
768
|
+
max: 100,
|
|
769
|
+
step: 1
|
|
770
|
+
},
|
|
771
|
+
mute: { from: "muted" },
|
|
772
|
+
source: {
|
|
773
|
+
from: "source",
|
|
774
|
+
optionsField: "availableSources"
|
|
775
|
+
},
|
|
776
|
+
shuffle: { from: "shuffle" },
|
|
777
|
+
repeat: { from: "repeat" },
|
|
778
|
+
play: {
|
|
779
|
+
from: "play",
|
|
780
|
+
commandOnly: true
|
|
781
|
+
},
|
|
782
|
+
pause: {
|
|
783
|
+
from: "pause",
|
|
784
|
+
commandOnly: true
|
|
785
|
+
},
|
|
786
|
+
stop: {
|
|
787
|
+
from: "stop",
|
|
788
|
+
commandOnly: true
|
|
789
|
+
},
|
|
790
|
+
next: {
|
|
791
|
+
from: "next",
|
|
792
|
+
commandOnly: true
|
|
793
|
+
},
|
|
794
|
+
previous: {
|
|
795
|
+
from: "previous",
|
|
796
|
+
commandOnly: true
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
};
|
|
801
|
+
/**
|
|
802
|
+
* What a platform becomes when the component on the other end cannot
|
|
803
|
+
* build it.
|
|
804
|
+
*
|
|
805
|
+
* The native platforms never reach this: `buildDerivedPlan` asks about
|
|
806
|
+
* support BEFORE choosing one, and falls back to the capability's own
|
|
807
|
+
* degraded row. This is for the other direction — a platform the DEGRADED
|
|
808
|
+
* table already names that an older component still does not build.
|
|
809
|
+
*
|
|
810
|
+
* There is exactly one, and it is a live defect rather than a
|
|
811
|
+
* hypothetical: the export has been announcing `alarm_control_panel`
|
|
812
|
+
* since the native export shipped, and the 0.3.x component builds eight
|
|
813
|
+
* platforms not including it. Every alarm panel exported to one of those
|
|
814
|
+
* installations produced a warning line and no entity at all. A `sensor`
|
|
815
|
+
* carrying the panel state is not an alarm card, but it is a value an
|
|
816
|
+
* operator can automate on, which is strictly more than nothing.
|
|
817
|
+
*/
|
|
818
|
+
var DEGRADED_PLATFORM = { alarm_control_panel: "sensor" };
|
|
819
|
+
/** Whether a capability has a native platform at all. */
|
|
820
|
+
function nativePlatformFor(capName) {
|
|
821
|
+
return NATIVE_CAP_PLATFORMS[capName]?.platform ?? null;
|
|
822
|
+
}
|
|
823
|
+
function readStringArray$1(slice, field) {
|
|
824
|
+
const raw = slice?.[field];
|
|
825
|
+
if (!Array.isArray(raw)) return void 0;
|
|
826
|
+
const values = raw.filter((entry) => typeof entry === "string");
|
|
827
|
+
return values.length > 0 ? values : void 0;
|
|
828
|
+
}
|
|
829
|
+
function readNumber$1(slice, field) {
|
|
830
|
+
const raw = slice?.[field];
|
|
831
|
+
return typeof raw === "number" && Number.isFinite(raw) ? raw : void 0;
|
|
832
|
+
}
|
|
833
|
+
function resolveEntity(input, suffix) {
|
|
834
|
+
const { capName, mapping } = input;
|
|
835
|
+
const descriptor = suffix === null ? mapping : mapping.extras?.[suffix];
|
|
836
|
+
const isCommandEntity = suffix !== null && descriptor === void 0 && mapping.commands?.[suffix] !== void 0;
|
|
837
|
+
if (descriptor === void 0 && !isCommandEntity) return null;
|
|
838
|
+
return {
|
|
839
|
+
entity: suffix === null ? derivedEntityId(capName) : derivedExtraEntityId(capName, suffix),
|
|
840
|
+
writable: isCommandEntity || descriptor?.writable === true,
|
|
841
|
+
descriptor
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
function resolveBinding(input, binding) {
|
|
845
|
+
const { deviceKey } = input;
|
|
846
|
+
const source = resolveEntity(input, binding.from);
|
|
847
|
+
if (source === null) return null;
|
|
848
|
+
/**
|
|
849
|
+
* The written entity, when it is not the read one. Absent rather than
|
|
850
|
+
* refused: a control whose command entity does not exist is still a
|
|
851
|
+
* READING worth announcing.
|
|
852
|
+
*/
|
|
853
|
+
const target = binding.commandFrom === void 0 ? source : resolveEntity(input, binding.commandFrom);
|
|
854
|
+
const entity = source.entity;
|
|
855
|
+
const descriptor = source.descriptor;
|
|
856
|
+
const writable = target?.writable === true;
|
|
857
|
+
const options = binding.optionsField === void 0 ? void 0 : readStringArray$1(input.slice, binding.optionsField);
|
|
858
|
+
/**
|
|
859
|
+
* The DEVICE's own bounds win over the static ones. A heat-pump water
|
|
860
|
+
* heater that reports `maxTemp: 60` must not offer 90, and a static
|
|
861
|
+
* range is only ever the backstop for hardware that reports none.
|
|
862
|
+
*/
|
|
863
|
+
const min = (binding.minField === void 0 ? void 0 : readNumber$1(input.slice, binding.minField)) ?? descriptor?.range?.min ?? binding.min;
|
|
864
|
+
const max = (binding.maxField === void 0 ? void 0 : readNumber$1(input.slice, binding.maxField)) ?? descriptor?.range?.max ?? binding.max;
|
|
865
|
+
const step = (binding.stepField === void 0 ? void 0 : readNumber$1(input.slice, binding.stepField)) ?? descriptor?.range?.step ?? binding.step;
|
|
866
|
+
const control = {
|
|
867
|
+
...binding.commandOnly === true ? {} : { state_topic: stateTopic(deviceKey, entity) },
|
|
868
|
+
...writable && target !== null ? { command_topic: commandTopic(deviceKey, target.entity) } : {},
|
|
869
|
+
...options !== void 0 ? { options } : {},
|
|
870
|
+
...min !== void 0 ? { min } : {},
|
|
871
|
+
...max !== void 0 ? { max } : {},
|
|
872
|
+
...step !== void 0 ? { step } : {}
|
|
873
|
+
};
|
|
874
|
+
return {
|
|
875
|
+
entities: target === null || target.entity === entity ? [entity] : [entity, target.entity],
|
|
876
|
+
control
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Build one native component, or `null` when the capability has none.
|
|
881
|
+
*
|
|
882
|
+
* The component's identity — its `unique_id` and therefore the component
|
|
883
|
+
* key — is the capability's PRIMARY entity id, the same one the degraded
|
|
884
|
+
* `sensor` used. Home Assistant keys its registry on
|
|
885
|
+
* `(platform, integration, unique_id)`, so this is a NEW entity even
|
|
886
|
+
* though the id is unchanged, and the old one is removed by the
|
|
887
|
+
* component's own diff rather than left orphaned. That is the whole
|
|
888
|
+
* migration, and `docs/design/ecosystem-export-plan.md` records why an
|
|
889
|
+
* in-place one is not possible.
|
|
890
|
+
*/
|
|
891
|
+
function buildNativeComponent(input) {
|
|
892
|
+
const native = NATIVE_CAP_PLATFORMS[input.capName];
|
|
893
|
+
if (native === void 0) return null;
|
|
894
|
+
const primary = resolveBinding(input, native.primary);
|
|
895
|
+
if (primary === null) return null;
|
|
896
|
+
const absorbed = new Set([derivedEntityId(input.capName), ...primary.entities]);
|
|
897
|
+
const controls = {};
|
|
898
|
+
for (const [name, binding] of Object.entries(native.controls ?? {})) {
|
|
899
|
+
const resolved = resolveBinding(input, binding);
|
|
900
|
+
if (resolved === null) continue;
|
|
901
|
+
controls[name] = resolved.control;
|
|
902
|
+
for (const entity of resolved.entities) absorbed.add(entity);
|
|
903
|
+
}
|
|
904
|
+
const flags = {};
|
|
905
|
+
for (const [key, field] of Object.entries(native.flagFields ?? {})) {
|
|
906
|
+
const raw = input.slice?.[field];
|
|
907
|
+
if (typeof raw === "boolean") flags[key] = raw;
|
|
908
|
+
}
|
|
909
|
+
return {
|
|
910
|
+
component: {
|
|
911
|
+
platform: native.platform,
|
|
912
|
+
unique_id: `${input.stableId}_${derivedEntityId(input.capName)}`,
|
|
913
|
+
name: input.label,
|
|
914
|
+
...primary.control,
|
|
915
|
+
...native.deviceClass !== void 0 ? { device_class: native.deviceClass } : {},
|
|
916
|
+
...native.icon !== void 0 ? { icon: native.icon } : {},
|
|
917
|
+
...Object.keys(controls).length > 0 ? { controls } : {},
|
|
918
|
+
...native.constants,
|
|
919
|
+
...flags
|
|
920
|
+
},
|
|
921
|
+
absorbed
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
//#endregion
|
|
75
925
|
//#region src/ha-export/entity-catalog.ts
|
|
76
926
|
/**
|
|
77
927
|
* The entity catalog — pure. Device + zones + macros → the `cmps` set.
|
|
@@ -97,6 +947,11 @@ function toSlug(value) {
|
|
|
97
947
|
* nothing produces is the failure this repo keeps paying for.
|
|
98
948
|
*/
|
|
99
949
|
/**
|
|
950
|
+
* The enums a `select` offers come from the CAP, never from a list here: a
|
|
951
|
+
* second copy of `HvacModeSchema` would be one rename away from offering an
|
|
952
|
+
* operator a mode their thermostat has never heard of.
|
|
953
|
+
*/
|
|
954
|
+
/**
|
|
100
955
|
* The macro classes an operator automates on.
|
|
101
956
|
*
|
|
102
957
|
* Deliberately the macros and NOT the 21 fine classes: those include
|
|
@@ -158,6 +1013,29 @@ var ZONE_MACROS = [
|
|
|
158
1013
|
* what the export is for. The three `*_last_image` entities — camera,
|
|
159
1014
|
* per macro, per zone × macro — therefore ship enabled. They cost one
|
|
160
1015
|
* entity each and carry a signed URL, not bytes.
|
|
1016
|
+
*
|
|
1017
|
+
* **So are the three things the operator asked for on 2026-08-14**, and
|
|
1018
|
+
* each has a reason that is a property of the value rather than a taste:
|
|
1019
|
+
*
|
|
1020
|
+
* - **`<macro>_last_detection`, and the camera's own.** "When was a
|
|
1021
|
+
* person last seen" is a timestamp, and half of what an operator
|
|
1022
|
+
* builds on a camera is a duration since one — a `for:` in a
|
|
1023
|
+
* template, a "nobody has been at the door since" notification. A
|
|
1024
|
+
* boolean that is `off` right now cannot answer it.
|
|
1025
|
+
* - **`<macro>_last_label`.** This is where the RECOGNISED FACE and the
|
|
1026
|
+
* read PLATE arrive. It was the single highest-value item in the gap
|
|
1027
|
+
* against the reference integration and it shipped switched off, so
|
|
1028
|
+
* the name camstack worked out was in Home Assistant and invisible.
|
|
1029
|
+
* - **`battery` and `charger` on a battery camera.** A battery
|
|
1030
|
+
* percentage is the headline of a battery device, not a diagnostic
|
|
1031
|
+
* detail about one; `sleeping` stays off because it describes the
|
|
1032
|
+
* power state machine rather than the charge.
|
|
1033
|
+
*
|
|
1034
|
+
* What stays off is what the valve was built for: the per-macro and
|
|
1035
|
+
* per-zone `*_objects` COUNTERS (a count of what is on screen right now,
|
|
1036
|
+
* which the `_detected` boolean and the camera-wide `objects` total
|
|
1037
|
+
* already answer for automation), the per-zone timestamps, and the
|
|
1038
|
+
* doorbell's since-restart counter.
|
|
161
1039
|
*/
|
|
162
1040
|
/**
|
|
163
1041
|
* The snooze surface, as a `select`.
|
|
@@ -192,25 +1070,96 @@ var PTZ_BUTTONS = [
|
|
|
192
1070
|
"ptz_zoom_in",
|
|
193
1071
|
"ptz_zoom_out"
|
|
194
1072
|
];
|
|
1073
|
+
/**
|
|
1074
|
+
* Platforms whose entity carries no value on the push plane.
|
|
1075
|
+
*
|
|
1076
|
+
* A `button` is a command. A `camera` is a stream the component fetches
|
|
1077
|
+
* for itself (`snapshot.getSnapshot` + `webrtcSession.handleOffer`) from
|
|
1078
|
+
* the target it is given. Announcing a `state_topic` for either would
|
|
1079
|
+
* declare a value nothing ever publishes — which is precisely how twelve
|
|
1080
|
+
* camera components came to sit in an announce that produced no entity.
|
|
1081
|
+
*/
|
|
1082
|
+
var STATELESS_PLATFORMS = new Set(["button", "camera"]);
|
|
1083
|
+
/**
|
|
1084
|
+
* Platforms that can carry a command topic.
|
|
1085
|
+
*
|
|
1086
|
+
* `writable` on a descriptor means "the route table writes exactly this
|
|
1087
|
+
* value" — a fact about the CAPABILITY. Whether Home Assistant can offer
|
|
1088
|
+
* a control for it is a fact about the PLATFORM, and the two are not the
|
|
1089
|
+
* same: `climate-control.fanMode` is written by `setFanMode` and has no
|
|
1090
|
+
* closed vocabulary, so its degraded form is a `sensor` and its native
|
|
1091
|
+
* form is the climate entity's fan-mode control. Announcing a command
|
|
1092
|
+
* topic on the degraded `sensor` would describe a control Home Assistant
|
|
1093
|
+
* cannot render — a leftover that reads as verification.
|
|
1094
|
+
*/
|
|
1095
|
+
var COMMANDABLE_PLATFORMS = new Set([
|
|
1096
|
+
"switch",
|
|
1097
|
+
"button",
|
|
1098
|
+
"select",
|
|
1099
|
+
"number",
|
|
1100
|
+
"alarm_control_panel",
|
|
1101
|
+
"cover",
|
|
1102
|
+
"climate",
|
|
1103
|
+
"lock",
|
|
1104
|
+
"fan",
|
|
1105
|
+
"vacuum",
|
|
1106
|
+
"valve",
|
|
1107
|
+
"humidifier",
|
|
1108
|
+
"water_heater",
|
|
1109
|
+
"media_player"
|
|
1110
|
+
]);
|
|
1111
|
+
/** 0..100, the shape every camstack percentage declares in its own schema. */
|
|
1112
|
+
var PERCENT_RANGE = {
|
|
1113
|
+
min: 0,
|
|
1114
|
+
max: 100,
|
|
1115
|
+
step: 1
|
|
1116
|
+
};
|
|
1117
|
+
/**
|
|
1118
|
+
* A temperature setpoint, in Celsius.
|
|
1119
|
+
*
|
|
1120
|
+
* Deliberately WIDER than any one device: a heat-pump water heater runs to
|
|
1121
|
+
* 80 °C, a freezer setpoint is negative, and the per-device truth
|
|
1122
|
+
* (`climate-control`'s `getOptions`, `water-heater`'s `minTemp`/`maxTemp`)
|
|
1123
|
+
* is on the provider and the slice, not in this static table. Half-degree
|
|
1124
|
+
* steps because every thermostat this repo has met accepts them and a step of
|
|
1125
|
+
* 1 would make 20.5 unreachable.
|
|
1126
|
+
*/
|
|
1127
|
+
var TEMPERATURE_RANGE = {
|
|
1128
|
+
min: -20,
|
|
1129
|
+
max: 90,
|
|
1130
|
+
step: .5
|
|
1131
|
+
};
|
|
1132
|
+
/** `ColorInputSchema` declares mireds as `int().min(50).max(1000)`. */
|
|
1133
|
+
var MIRED_RANGE = {
|
|
1134
|
+
min: 50,
|
|
1135
|
+
max: 1e3,
|
|
1136
|
+
step: 1
|
|
1137
|
+
};
|
|
195
1138
|
function buildComponent(device, spec) {
|
|
196
1139
|
const deviceKey = deviceKeyFor(device.stableId);
|
|
197
1140
|
return {
|
|
198
1141
|
platform: spec.platform,
|
|
199
1142
|
unique_id: `${device.stableId}_${spec.uniqueSuffix ?? spec.entity}`,
|
|
200
1143
|
name: spec.label,
|
|
201
|
-
...spec.platform
|
|
202
|
-
...spec.writable === true ? { command_topic: commandTopic(deviceKey, spec.entity) } : {},
|
|
1144
|
+
...STATELESS_PLATFORMS.has(spec.platform) ? {} : { state_topic: stateTopic(deviceKey, spec.entity) },
|
|
1145
|
+
...spec.writable === true && COMMANDABLE_PLATFORMS.has(spec.platform) ? { command_topic: commandTopic(deviceKey, spec.entity) } : {},
|
|
203
1146
|
...spec.deviceClass !== void 0 ? { device_class: spec.deviceClass } : {},
|
|
204
1147
|
...spec.unit !== void 0 ? { unit_of_measurement: spec.unit } : {},
|
|
205
1148
|
...spec.icon !== void 0 ? { icon: spec.icon } : {},
|
|
206
1149
|
...spec.options !== void 0 ? { options: spec.options } : {},
|
|
1150
|
+
...spec.range !== void 0 ? {
|
|
1151
|
+
min: spec.range.min,
|
|
1152
|
+
max: spec.range.max,
|
|
1153
|
+
step: spec.range.step
|
|
1154
|
+
} : {},
|
|
207
1155
|
...spec.entityCategory !== void 0 ? { entity_category: spec.entityCategory } : {},
|
|
208
1156
|
...spec.enabledByDefault ? {} : { enabled_by_default: false },
|
|
209
1157
|
...spec.platform === "binary_sensor" || spec.platform === "switch" ? {
|
|
210
1158
|
payload_on: "true",
|
|
211
1159
|
payload_off: "false"
|
|
212
1160
|
} : {},
|
|
213
|
-
...spec.platform === "button" ? { payload_press: "PRESS" } : {}
|
|
1161
|
+
...spec.platform === "button" ? { payload_press: "PRESS" } : {},
|
|
1162
|
+
...spec.streamTarget !== void 0 ? { stream_target: spec.streamTarget } : {}
|
|
214
1163
|
};
|
|
215
1164
|
}
|
|
216
1165
|
function deviceBlock(device) {
|
|
@@ -363,7 +1312,7 @@ function cameraSpecs(device) {
|
|
|
363
1312
|
label: "Last detection",
|
|
364
1313
|
deviceClass: "timestamp",
|
|
365
1314
|
icon: "mdi:clock",
|
|
366
|
-
enabledByDefault:
|
|
1315
|
+
enabledByDefault: true
|
|
367
1316
|
}
|
|
368
1317
|
];
|
|
369
1318
|
if (device.slices.includes("battery")) specs.push({
|
|
@@ -373,14 +1322,14 @@ function cameraSpecs(device) {
|
|
|
373
1322
|
deviceClass: "battery",
|
|
374
1323
|
unit: "%",
|
|
375
1324
|
entityCategory: "diagnostic",
|
|
376
|
-
enabledByDefault:
|
|
1325
|
+
enabledByDefault: true
|
|
377
1326
|
}, {
|
|
378
1327
|
entity: "charger",
|
|
379
1328
|
platform: "binary_sensor",
|
|
380
1329
|
label: "Charging",
|
|
381
1330
|
deviceClass: "battery_charging",
|
|
382
1331
|
entityCategory: "diagnostic",
|
|
383
|
-
enabledByDefault:
|
|
1332
|
+
enabledByDefault: true
|
|
384
1333
|
}, {
|
|
385
1334
|
entity: "sleeping",
|
|
386
1335
|
platform: "binary_sensor",
|
|
@@ -512,7 +1461,7 @@ function cameraSpecs(device) {
|
|
|
512
1461
|
platform: "sensor",
|
|
513
1462
|
label: `${label} last detection`,
|
|
514
1463
|
deviceClass: "timestamp",
|
|
515
|
-
enabledByDefault:
|
|
1464
|
+
enabledByDefault: true
|
|
516
1465
|
}, {
|
|
517
1466
|
entity: `${macro}_objects`,
|
|
518
1467
|
platform: "sensor",
|
|
@@ -525,9 +1474,36 @@ function cameraSpecs(device) {
|
|
|
525
1474
|
platform: "sensor",
|
|
526
1475
|
label: `${label} last label`,
|
|
527
1476
|
icon: "mdi:tag",
|
|
528
|
-
enabledByDefault:
|
|
1477
|
+
enabledByDefault: true
|
|
529
1478
|
});
|
|
530
1479
|
}
|
|
1480
|
+
/**
|
|
1481
|
+
* The live picture — every stream the hub lists EXCEPT the adaptive one,
|
|
1482
|
+
* which the component's own entity already is. See `camera-entities.ts`
|
|
1483
|
+
* for why the entity carries a WebRTC target and not a url, and why
|
|
1484
|
+
* announcing the adaptive stream would collide on an identity that has
|
|
1485
|
+
* shipped.
|
|
1486
|
+
*/
|
|
1487
|
+
for (const stream of cameraStreamEntities(device.streams ?? [])) specs.push({
|
|
1488
|
+
entity: stream.entity,
|
|
1489
|
+
platform: stream.platform,
|
|
1490
|
+
label: stream.label,
|
|
1491
|
+
enabledByDefault: stream.enabledByDefault,
|
|
1492
|
+
streamTarget: stream.target
|
|
1493
|
+
});
|
|
1494
|
+
/**
|
|
1495
|
+
* The two capability-derived entities a camera carries.
|
|
1496
|
+
*
|
|
1497
|
+
* `buildDerivedPlan` is the NON-camera path, so a camera-scoped
|
|
1498
|
+
* capability reaches Home Assistant only if the camera catalog builds
|
|
1499
|
+
* it — and it is built from the same descriptor and the same entity id
|
|
1500
|
+
* as the derived half, so `projectCapSlice` publishes to the topic
|
|
1501
|
+
* this component declares without a second expression anywhere.
|
|
1502
|
+
*/
|
|
1503
|
+
for (const cap of CAMERA_DERIVED_CAPS) {
|
|
1504
|
+
if (!device.boundCaps.includes(cap)) continue;
|
|
1505
|
+
specs.push(...capEntitySpecs(cap));
|
|
1506
|
+
}
|
|
531
1507
|
for (const zone of device.zones) specs.push(...zoneSpecs(zone));
|
|
532
1508
|
return specs;
|
|
533
1509
|
}
|
|
@@ -545,36 +1521,128 @@ function assemble(device, specs) {
|
|
|
545
1521
|
function buildCameraPlan(device) {
|
|
546
1522
|
return assemble(device, cameraSpecs(device));
|
|
547
1523
|
}
|
|
1524
|
+
/**
|
|
1525
|
+
* capability → the entities it produces.
|
|
1526
|
+
*
|
|
1527
|
+
* ── What decides read-only ──────────────────────────────────────────────
|
|
1528
|
+
*
|
|
1529
|
+
* The plan's rule, and D62's rule generalised: **a writable descriptor
|
|
1530
|
+
* must name a route `CAP_COMMAND_ROUTES` can resolve, or ship read-only.**
|
|
1531
|
+
* The route table now covers every Tier A family, so the rows below are
|
|
1532
|
+
* writable wherever the capability declares a method that writes exactly
|
|
1533
|
+
* the value the entity carries.
|
|
1534
|
+
*
|
|
1535
|
+
* ── `writable` is about the CAPABILITY, not about the platform ───────────
|
|
1536
|
+
*
|
|
1537
|
+
* Six values are written by a real method and have no Home Assistant
|
|
1538
|
+
* control in the DEGRADED shape: `climate-control.fanMode`, `.preset`,
|
|
1539
|
+
* `fan-control.preset`, `water-heater.operationMode`, `humidifier.mode`,
|
|
1540
|
+
* `media-player.source` and `vacuum-control.fanSpeed` are free-form
|
|
1541
|
+
* strings whose accepted values are `available*` arrays on the DEVICE. A
|
|
1542
|
+
* static `select` cannot hold them — it would offer values the device
|
|
1543
|
+
* rejects while hiding the ones it takes — so their degraded platform is
|
|
1544
|
+
* a `sensor`.
|
|
1545
|
+
*
|
|
1546
|
+
* They are still marked `writable`, and that is the point: the flag means
|
|
1547
|
+
* "`CAP_COMMAND_ROUTES` writes exactly this value", which is what
|
|
1548
|
+
* `native-platforms.ts` asks before giving a native climate its fan-mode
|
|
1549
|
+
* control or a native vacuum its fan-speed control. `buildComponent`
|
|
1550
|
+
* announces a `command_topic` only on a platform that can carry one
|
|
1551
|
+
* (`COMMANDABLE_PLATFORMS`), so the degraded `sensor` stays a reading and
|
|
1552
|
+
* the native entity gains the control. They were read-only until
|
|
1553
|
+
* 2026-08-14 and the native controls silently did not appear, because the
|
|
1554
|
+
* one flag was answering two different questions.
|
|
1555
|
+
*
|
|
1556
|
+
* **`setTargetRange` is the one setter that needs two values at once**,
|
|
1557
|
+
* and it is routed by READING the other half first — see the note on
|
|
1558
|
+
* `target-high` below. Everything else in this table names one method
|
|
1559
|
+
* with one value.
|
|
1560
|
+
*
|
|
1561
|
+
* Only **a value that is not flat** still ships read-only. See below.
|
|
1562
|
+
*
|
|
1563
|
+
* ── Why a nested object is never a field ────────────────────────────────
|
|
1564
|
+
*
|
|
1565
|
+
* `renderCapValue` carries strings, numbers and booleans. `color.rgb`,
|
|
1566
|
+
* `vacuum-control.dustBin`, `event-emitter.lastEvent` and
|
|
1567
|
+
* `consumables.items` are objects and arrays: mapping one would publish
|
|
1568
|
+
* an entity that reports `unrenderable` on every single delivery. Where a
|
|
1569
|
+
* capability's headline value is only expressible as an object, the flat
|
|
1570
|
+
* neighbours are exported and the object is named in the doc comment
|
|
1571
|
+
* rather than mapped.
|
|
1572
|
+
*/
|
|
548
1573
|
var CAP_ENTITY_MAP = {
|
|
549
1574
|
switch: {
|
|
550
1575
|
platform: "switch",
|
|
551
1576
|
field: "on",
|
|
552
1577
|
writable: true
|
|
553
1578
|
},
|
|
1579
|
+
/**
|
|
1580
|
+
* The lock is BOTH: a switch, because that is what an operator
|
|
1581
|
+
* automates on and `lockControl.lock` / `.unlock` is a route that
|
|
1582
|
+
* works — and a sensor, because `LockStateSchema` is
|
|
1583
|
+
* `locked | unlocked | locking | unlocking | jammed` and a boolean
|
|
1584
|
+
* silently reports a JAMMED door as unlocked. The boolean is derived
|
|
1585
|
+
* from the same field, so the two can never disagree.
|
|
1586
|
+
*/
|
|
554
1587
|
"lock-control": {
|
|
555
1588
|
platform: "switch",
|
|
556
|
-
field: "
|
|
1589
|
+
field: "state",
|
|
557
1590
|
deviceClass: "lock",
|
|
558
|
-
writable: true
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
1591
|
+
writable: true,
|
|
1592
|
+
derive: {
|
|
1593
|
+
kind: "equals",
|
|
1594
|
+
value: "locked"
|
|
1595
|
+
},
|
|
1596
|
+
extras: { state: {
|
|
1597
|
+
platform: "sensor",
|
|
1598
|
+
field: "state",
|
|
1599
|
+
label: "Lock state",
|
|
1600
|
+
icon: "mdi:lock-question"
|
|
1601
|
+
} },
|
|
1602
|
+
/**
|
|
1603
|
+
* `lockControl.open` — the LATCH, which is not `unlock`.
|
|
1604
|
+
*
|
|
1605
|
+
* Home Assistant spells it `LockEntityFeature.OPEN` and its native lock
|
|
1606
|
+
* offers it as a third button; the cap has declared the method since it
|
|
1607
|
+
* was written and nothing routed it, so `NATIVE_CAP_PLATFORMS`'
|
|
1608
|
+
* `open` control resolved to no entity and was skipped every time.
|
|
1609
|
+
* Registered and off in the degraded table for the same reason `stop`
|
|
1610
|
+
* is: a lock without a latch answers the press with the provider's own
|
|
1611
|
+
* refusal, and that is a button most operators never want to see.
|
|
1612
|
+
*/
|
|
1613
|
+
commands: { open: {
|
|
1614
|
+
label: "Open latch",
|
|
1615
|
+
icon: "mdi:door-open",
|
|
1616
|
+
enabledByDefault: false
|
|
1617
|
+
} }
|
|
564
1618
|
},
|
|
565
1619
|
button: {
|
|
566
1620
|
platform: "button",
|
|
567
1621
|
field: "pressed",
|
|
568
1622
|
writable: true
|
|
569
1623
|
},
|
|
1624
|
+
/**
|
|
1625
|
+
* The range is not decoration. `brightness.setBrightness` declares
|
|
1626
|
+
* `0..100` and the entity carried no bounds, so Home Assistant applied its
|
|
1627
|
+
* own default of min 1 — an operator could not send `0` and could not turn
|
|
1628
|
+
* the light off from the number. It has been that way since the entity
|
|
1629
|
+
* existed.
|
|
1630
|
+
*/
|
|
570
1631
|
brightness: {
|
|
571
1632
|
platform: "number",
|
|
572
|
-
field: "
|
|
1633
|
+
field: "percentage",
|
|
1634
|
+
unit: "%",
|
|
1635
|
+
writable: true,
|
|
1636
|
+
range: PERCENT_RANGE
|
|
1637
|
+
},
|
|
1638
|
+
"alarm-panel": {
|
|
1639
|
+
platform: "alarm_control_panel",
|
|
1640
|
+
field: "state",
|
|
573
1641
|
writable: true
|
|
574
1642
|
},
|
|
575
1643
|
binary: {
|
|
576
1644
|
platform: "binary_sensor",
|
|
577
|
-
field: "
|
|
1645
|
+
field: "on"
|
|
578
1646
|
},
|
|
579
1647
|
motion: {
|
|
580
1648
|
platform: "binary_sensor",
|
|
@@ -583,22 +1651,39 @@ var CAP_ENTITY_MAP = {
|
|
|
583
1651
|
},
|
|
584
1652
|
contact: {
|
|
585
1653
|
platform: "binary_sensor",
|
|
586
|
-
field: "
|
|
1654
|
+
field: "entryOpen",
|
|
587
1655
|
deviceClass: "door"
|
|
588
1656
|
},
|
|
1657
|
+
/**
|
|
1658
|
+
* `state` is a STRING — `home`, `not_home`, or the name of any zone the
|
|
1659
|
+
* operator defined. The binary answers "is anybody in", which is what
|
|
1660
|
+
* an automation triggers on; the extra carries the zone NAME, which is
|
|
1661
|
+
* the whole reason a presence device is not a contact sensor. A native
|
|
1662
|
+
* `device_tracker` (which carries both at once) is Tier B.
|
|
1663
|
+
*/
|
|
589
1664
|
presence: {
|
|
590
1665
|
platform: "binary_sensor",
|
|
591
|
-
field: "
|
|
592
|
-
deviceClass: "presence"
|
|
1666
|
+
field: "state",
|
|
1667
|
+
deviceClass: "presence",
|
|
1668
|
+
derive: {
|
|
1669
|
+
kind: "not-equals",
|
|
1670
|
+
value: "not_home"
|
|
1671
|
+
},
|
|
1672
|
+
extras: { zone: {
|
|
1673
|
+
platform: "sensor",
|
|
1674
|
+
field: "state",
|
|
1675
|
+
label: "Presence zone",
|
|
1676
|
+
icon: "mdi:map-marker-account"
|
|
1677
|
+
} }
|
|
593
1678
|
},
|
|
594
1679
|
connectivity: {
|
|
595
1680
|
platform: "binary_sensor",
|
|
596
|
-
field: "
|
|
1681
|
+
field: "connected",
|
|
597
1682
|
deviceClass: "connectivity"
|
|
598
1683
|
},
|
|
599
1684
|
flood: {
|
|
600
1685
|
platform: "binary_sensor",
|
|
601
|
-
field: "
|
|
1686
|
+
field: "flooded",
|
|
602
1687
|
deviceClass: "moisture"
|
|
603
1688
|
},
|
|
604
1689
|
smoke: {
|
|
@@ -618,7 +1703,7 @@ var CAP_ENTITY_MAP = {
|
|
|
618
1703
|
},
|
|
619
1704
|
tamper: {
|
|
620
1705
|
platform: "binary_sensor",
|
|
621
|
-
field: "
|
|
1706
|
+
field: "tampered",
|
|
622
1707
|
deviceClass: "tamper"
|
|
623
1708
|
},
|
|
624
1709
|
vibration: {
|
|
@@ -633,25 +1718,25 @@ var CAP_ENTITY_MAP = {
|
|
|
633
1718
|
},
|
|
634
1719
|
"temperature-sensor": {
|
|
635
1720
|
platform: "sensor",
|
|
636
|
-
field: "
|
|
1721
|
+
field: "celsius",
|
|
637
1722
|
deviceClass: "temperature",
|
|
638
1723
|
unit: "°C"
|
|
639
1724
|
},
|
|
640
1725
|
"humidity-sensor": {
|
|
641
1726
|
platform: "sensor",
|
|
642
|
-
field: "
|
|
1727
|
+
field: "percent",
|
|
643
1728
|
deviceClass: "humidity",
|
|
644
1729
|
unit: "%"
|
|
645
1730
|
},
|
|
646
1731
|
"pressure-sensor": {
|
|
647
1732
|
platform: "sensor",
|
|
648
|
-
field: "
|
|
1733
|
+
field: "hpa",
|
|
649
1734
|
deviceClass: "pressure",
|
|
650
1735
|
unit: "hPa"
|
|
651
1736
|
},
|
|
652
1737
|
"ambient-light-sensor": {
|
|
653
1738
|
platform: "sensor",
|
|
654
|
-
field: "
|
|
1739
|
+
field: "lux",
|
|
655
1740
|
deviceClass: "illuminance",
|
|
656
1741
|
unit: "lx"
|
|
657
1742
|
},
|
|
@@ -680,12 +1765,926 @@ var CAP_ENTITY_MAP = {
|
|
|
680
1765
|
field: "aqi",
|
|
681
1766
|
deviceClass: "aqi"
|
|
682
1767
|
},
|
|
683
|
-
|
|
684
|
-
|
|
1768
|
+
/**
|
|
1769
|
+
* A cover is its state, its two positions and its three verbs.
|
|
1770
|
+
*
|
|
1771
|
+
* `state` stays a `sensor`: `opening`/`closing` are transitions, not
|
|
1772
|
+
* commands, and there is no cap method that writes the lifecycle directly.
|
|
1773
|
+
* The positions are `number`s because `setPosition` / `setTiltPosition`
|
|
1774
|
+
* write exactly them, and the verbs are buttons because `open`, `close` and
|
|
1775
|
+
* `stop` take no value at all.
|
|
1776
|
+
*/
|
|
1777
|
+
cover: {
|
|
1778
|
+
platform: "sensor",
|
|
685
1779
|
field: "state",
|
|
686
|
-
|
|
1780
|
+
icon: "mdi:window-shutter",
|
|
1781
|
+
extras: {
|
|
1782
|
+
position: {
|
|
1783
|
+
platform: "number",
|
|
1784
|
+
field: "position",
|
|
1785
|
+
label: "Position",
|
|
1786
|
+
unit: "%",
|
|
1787
|
+
icon: "mdi:arrow-up-down",
|
|
1788
|
+
writable: true,
|
|
1789
|
+
range: PERCENT_RANGE
|
|
1790
|
+
},
|
|
1791
|
+
tilt: {
|
|
1792
|
+
platform: "number",
|
|
1793
|
+
field: "tiltPosition",
|
|
1794
|
+
label: "Tilt position",
|
|
1795
|
+
unit: "%",
|
|
1796
|
+
icon: "mdi:angle-acute",
|
|
1797
|
+
writable: true,
|
|
1798
|
+
range: PERCENT_RANGE,
|
|
1799
|
+
enabledByDefault: false
|
|
1800
|
+
}
|
|
1801
|
+
},
|
|
1802
|
+
commands: {
|
|
1803
|
+
open: {
|
|
1804
|
+
label: "Open",
|
|
1805
|
+
icon: "mdi:arrow-up-box"
|
|
1806
|
+
},
|
|
1807
|
+
close: {
|
|
1808
|
+
label: "Close",
|
|
1809
|
+
icon: "mdi:arrow-down-box"
|
|
1810
|
+
},
|
|
1811
|
+
stop: {
|
|
1812
|
+
label: "Stop",
|
|
1813
|
+
icon: "mdi:stop",
|
|
1814
|
+
enabledByDefault: false
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
},
|
|
1818
|
+
/**
|
|
1819
|
+
* Every temperature here is CELSIUS by the cap's own doc comment
|
|
1820
|
+
* ("Single setpoint in Celsius", "Current measured temperature in
|
|
1821
|
+
* Celsius"), so the unit is declared rather than guessed.
|
|
1822
|
+
*/
|
|
1823
|
+
"climate-control": {
|
|
1824
|
+
platform: "select",
|
|
1825
|
+
field: "mode",
|
|
1826
|
+
icon: "mdi:thermostat",
|
|
1827
|
+
writable: true,
|
|
1828
|
+
/**
|
|
1829
|
+
* The one closed list on this capability, and it is closed because
|
|
1830
|
+
* `HvacModeSchema` is an enum. `availableModes` narrows it PER DEVICE and
|
|
1831
|
+
* a static descriptor cannot read a slice, so an operator can select a
|
|
1832
|
+
* mode their unit rejects — the provider refuses it and the refusal
|
|
1833
|
+
* surfaces as a 422 with the device's own message, which is the honest
|
|
1834
|
+
* failure. `fanMode` and `preset` get no such list at all: they are
|
|
1835
|
+
* free-form strings, so they stay sensors.
|
|
1836
|
+
*/
|
|
1837
|
+
options: require_dist.HvacModeSchema.options,
|
|
1838
|
+
extras: {
|
|
1839
|
+
"current-temp": {
|
|
1840
|
+
platform: "sensor",
|
|
1841
|
+
field: "currentTemp",
|
|
1842
|
+
label: "Current temperature",
|
|
1843
|
+
deviceClass: "temperature",
|
|
1844
|
+
unit: "°C"
|
|
1845
|
+
},
|
|
1846
|
+
target: {
|
|
1847
|
+
platform: "number",
|
|
1848
|
+
field: "target",
|
|
1849
|
+
label: "Target temperature",
|
|
1850
|
+
deviceClass: "temperature",
|
|
1851
|
+
unit: "°C",
|
|
1852
|
+
writable: true,
|
|
1853
|
+
range: TEMPERATURE_RANGE
|
|
1854
|
+
},
|
|
1855
|
+
"current-humidity": {
|
|
1856
|
+
platform: "sensor",
|
|
1857
|
+
field: "currentHumidity",
|
|
1858
|
+
label: "Current humidity",
|
|
1859
|
+
deviceClass: "humidity",
|
|
1860
|
+
unit: "%",
|
|
1861
|
+
enabledByDefault: false
|
|
1862
|
+
},
|
|
1863
|
+
"target-humidity": {
|
|
1864
|
+
platform: "number",
|
|
1865
|
+
field: "targetHumidity",
|
|
1866
|
+
label: "Target humidity",
|
|
1867
|
+
deviceClass: "humidity",
|
|
1868
|
+
unit: "%",
|
|
1869
|
+
writable: true,
|
|
1870
|
+
range: PERCENT_RANGE,
|
|
1871
|
+
enabledByDefault: false
|
|
1872
|
+
},
|
|
1873
|
+
/**
|
|
1874
|
+
* Free-form: the accepted values are `availableFanModes` on the slice,
|
|
1875
|
+
* so the degraded platform is a `sensor` and only the native climate
|
|
1876
|
+
* renders the control. `setFanMode` is the route.
|
|
1877
|
+
*/
|
|
1878
|
+
"fan-mode": {
|
|
1879
|
+
platform: "sensor",
|
|
1880
|
+
field: "fanMode",
|
|
1881
|
+
label: "Fan mode",
|
|
1882
|
+
icon: "mdi:fan",
|
|
1883
|
+
writable: true,
|
|
1884
|
+
enabledByDefault: false
|
|
1885
|
+
},
|
|
1886
|
+
/** Free-form: the accepted values are `availablePresets` on the slice. */
|
|
1887
|
+
preset: {
|
|
1888
|
+
platform: "sensor",
|
|
1889
|
+
field: "preset",
|
|
1890
|
+
label: "Preset",
|
|
1891
|
+
writable: true,
|
|
1892
|
+
enabledByDefault: false
|
|
1893
|
+
},
|
|
1894
|
+
/**
|
|
1895
|
+
* The dual setpoint — and the one route in this export that READS
|
|
1896
|
+
* before it writes.
|
|
1897
|
+
*
|
|
1898
|
+
* `setTargetRange` takes `targetLow` AND `targetHigh` together, while
|
|
1899
|
+
* Home Assistant's climate sends the two halves on two topics, so a
|
|
1900
|
+
* command carrying one of them has to learn the other before it can
|
|
1901
|
+
* make the call. `applyCapCommand` therefore reads
|
|
1902
|
+
* `climateControl.getStatus`, substitutes the half it was given, and
|
|
1903
|
+
* calls once. A device reporting `null` for the other half is REFUSED
|
|
1904
|
+
* rather than defaulted: inventing the missing bound is how a
|
|
1905
|
+
* thermostat comes to be told to hold 20 °C to 20 °C.
|
|
1906
|
+
*/
|
|
1907
|
+
"target-high": {
|
|
1908
|
+
platform: "sensor",
|
|
1909
|
+
field: "targetHigh",
|
|
1910
|
+
label: "Target high",
|
|
1911
|
+
deviceClass: "temperature",
|
|
1912
|
+
unit: "°C",
|
|
1913
|
+
writable: true,
|
|
1914
|
+
enabledByDefault: false
|
|
1915
|
+
},
|
|
1916
|
+
"target-low": {
|
|
1917
|
+
platform: "sensor",
|
|
1918
|
+
field: "targetLow",
|
|
1919
|
+
label: "Target low",
|
|
1920
|
+
deviceClass: "temperature",
|
|
1921
|
+
unit: "°C",
|
|
1922
|
+
writable: true,
|
|
1923
|
+
enabledByDefault: false
|
|
1924
|
+
},
|
|
1925
|
+
/**
|
|
1926
|
+
* Two INDEPENDENT axes, each with its own setter and its own
|
|
1927
|
+
* `DeviceFeature`. A device with neither reports `null` on both, and a
|
|
1928
|
+
* null renders as `unknown` rather than as a switch stuck off.
|
|
1929
|
+
*/
|
|
1930
|
+
"swing-vertical": {
|
|
1931
|
+
platform: "switch",
|
|
1932
|
+
field: "swingVertical",
|
|
1933
|
+
label: "Vertical swing",
|
|
1934
|
+
icon: "mdi:arrow-up-down",
|
|
1935
|
+
writable: true,
|
|
1936
|
+
enabledByDefault: false
|
|
1937
|
+
},
|
|
1938
|
+
"swing-horizontal": {
|
|
1939
|
+
platform: "switch",
|
|
1940
|
+
field: "swingHorizontal",
|
|
1941
|
+
label: "Horizontal swing",
|
|
1942
|
+
icon: "mdi:arrow-left-right",
|
|
1943
|
+
writable: true,
|
|
1944
|
+
enabledByDefault: false
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
},
|
|
1948
|
+
/** A fan has no `on` field — `percentage` IS its state, and 0 is off. */
|
|
1949
|
+
"fan-control": {
|
|
1950
|
+
platform: "number",
|
|
1951
|
+
field: "percentage",
|
|
1952
|
+
unit: "%",
|
|
1953
|
+
icon: "mdi:fan",
|
|
1954
|
+
writable: true,
|
|
1955
|
+
/**
|
|
1956
|
+
* `percentageStep` on the slice is the device's own granularity (25 for a
|
|
1957
|
+
* four-speed fan). A static step of 1 is the WIDER choice: a value the
|
|
1958
|
+
* device rounds is recoverable, a step that hides three of its four speeds
|
|
1959
|
+
* is not.
|
|
1960
|
+
*/
|
|
1961
|
+
range: PERCENT_RANGE,
|
|
1962
|
+
extras: {
|
|
1963
|
+
/**
|
|
1964
|
+
* Free-form: the accepted values are `availablePresets` on the slice.
|
|
1965
|
+
* `fanControl.setPreset` is the route; the native fan renders it.
|
|
1966
|
+
*/
|
|
1967
|
+
preset: {
|
|
1968
|
+
platform: "sensor",
|
|
1969
|
+
field: "preset",
|
|
1970
|
+
label: "Preset",
|
|
1971
|
+
writable: true,
|
|
1972
|
+
enabledByDefault: false
|
|
1973
|
+
},
|
|
1974
|
+
oscillating: {
|
|
1975
|
+
platform: "switch",
|
|
1976
|
+
field: "oscillating",
|
|
1977
|
+
label: "Oscillating",
|
|
1978
|
+
icon: "mdi:arrow-oscillating",
|
|
1979
|
+
writable: true,
|
|
1980
|
+
enabledByDefault: false
|
|
1981
|
+
},
|
|
1982
|
+
direction: {
|
|
1983
|
+
platform: "select",
|
|
1984
|
+
field: "direction",
|
|
1985
|
+
label: "Direction",
|
|
1986
|
+
writable: true,
|
|
1987
|
+
options: require_dist.FanDirectionSchema.options,
|
|
1988
|
+
enabledByDefault: false
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
},
|
|
1992
|
+
humidifier: {
|
|
1993
|
+
platform: "switch",
|
|
1994
|
+
field: "on",
|
|
1995
|
+
icon: "mdi:air-humidifier",
|
|
1996
|
+
writable: true,
|
|
1997
|
+
extras: {
|
|
1998
|
+
"current-humidity": {
|
|
1999
|
+
platform: "sensor",
|
|
2000
|
+
field: "currentHumidity",
|
|
2001
|
+
label: "Current humidity",
|
|
2002
|
+
deviceClass: "humidity",
|
|
2003
|
+
unit: "%"
|
|
2004
|
+
},
|
|
2005
|
+
/**
|
|
2006
|
+
* `minHumidity`/`maxHumidity` on the slice are the device's own bounds;
|
|
2007
|
+
* the static range is the full percentage for the reason on
|
|
2008
|
+
* {@link HaNumberRange}.
|
|
2009
|
+
*/
|
|
2010
|
+
"target-humidity": {
|
|
2011
|
+
platform: "number",
|
|
2012
|
+
field: "targetHumidity",
|
|
2013
|
+
label: "Target humidity",
|
|
2014
|
+
deviceClass: "humidity",
|
|
2015
|
+
unit: "%",
|
|
2016
|
+
writable: true,
|
|
2017
|
+
range: PERCENT_RANGE
|
|
2018
|
+
},
|
|
2019
|
+
/**
|
|
2020
|
+
* Free-form: the accepted values are `availableModes` on the slice.
|
|
2021
|
+
* `humidifier.setMode` is the route; the native humidifier renders it.
|
|
2022
|
+
*/
|
|
2023
|
+
mode: {
|
|
2024
|
+
platform: "sensor",
|
|
2025
|
+
field: "mode",
|
|
2026
|
+
label: "Mode",
|
|
2027
|
+
writable: true,
|
|
2028
|
+
enabledByDefault: false
|
|
2029
|
+
},
|
|
2030
|
+
action: {
|
|
2031
|
+
platform: "sensor",
|
|
2032
|
+
field: "action",
|
|
2033
|
+
label: "Action",
|
|
2034
|
+
enabledByDefault: false
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
},
|
|
2038
|
+
"water-heater": {
|
|
2039
|
+
platform: "sensor",
|
|
2040
|
+
field: "currentTemp",
|
|
2041
|
+
deviceClass: "temperature",
|
|
2042
|
+
unit: "°C",
|
|
2043
|
+
extras: {
|
|
2044
|
+
target: {
|
|
2045
|
+
platform: "number",
|
|
2046
|
+
field: "targetTemp",
|
|
2047
|
+
label: "Target temperature",
|
|
2048
|
+
deviceClass: "temperature",
|
|
2049
|
+
unit: "°C",
|
|
2050
|
+
writable: true,
|
|
2051
|
+
range: TEMPERATURE_RANGE
|
|
2052
|
+
},
|
|
2053
|
+
/**
|
|
2054
|
+
* Free-form: the accepted values are `availableModes` on the slice, and
|
|
2055
|
+
* this is the entity Home Assistant's water heater uses as its STATE —
|
|
2056
|
+
* `setOperationMode` is what a native water_heater writes when the
|
|
2057
|
+
* operator picks `eco`. Read-only here left that platform with no
|
|
2058
|
+
* command at all.
|
|
2059
|
+
*/
|
|
2060
|
+
mode: {
|
|
2061
|
+
platform: "sensor",
|
|
2062
|
+
field: "operationMode",
|
|
2063
|
+
label: "Operation mode",
|
|
2064
|
+
writable: true,
|
|
2065
|
+
enabledByDefault: false
|
|
2066
|
+
},
|
|
2067
|
+
away: {
|
|
2068
|
+
platform: "switch",
|
|
2069
|
+
field: "away",
|
|
2070
|
+
label: "Away mode",
|
|
2071
|
+
writable: true,
|
|
2072
|
+
enabledByDefault: false
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2075
|
+
},
|
|
2076
|
+
valve: {
|
|
2077
|
+
platform: "sensor",
|
|
2078
|
+
field: "state",
|
|
2079
|
+
icon: "mdi:valve",
|
|
2080
|
+
extras: { position: {
|
|
2081
|
+
platform: "number",
|
|
2082
|
+
field: "position",
|
|
2083
|
+
label: "Position",
|
|
2084
|
+
unit: "%",
|
|
2085
|
+
icon: "mdi:arrow-up-down",
|
|
2086
|
+
writable: true,
|
|
2087
|
+
range: PERCENT_RANGE
|
|
2088
|
+
} },
|
|
2089
|
+
commands: {
|
|
2090
|
+
open: {
|
|
2091
|
+
label: "Open",
|
|
2092
|
+
icon: "mdi:valve-open"
|
|
2093
|
+
},
|
|
2094
|
+
close: {
|
|
2095
|
+
label: "Close",
|
|
2096
|
+
icon: "mdi:valve-closed"
|
|
2097
|
+
},
|
|
2098
|
+
stop: {
|
|
2099
|
+
label: "Stop",
|
|
2100
|
+
icon: "mdi:stop",
|
|
2101
|
+
enabledByDefault: false
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
},
|
|
2105
|
+
"vacuum-control": {
|
|
2106
|
+
platform: "sensor",
|
|
2107
|
+
field: "state",
|
|
2108
|
+
icon: "mdi:robot-vacuum",
|
|
2109
|
+
extras: {
|
|
2110
|
+
battery: {
|
|
2111
|
+
platform: "sensor",
|
|
2112
|
+
field: "batteryLevel",
|
|
2113
|
+
label: "Battery",
|
|
2114
|
+
deviceClass: "battery",
|
|
2115
|
+
unit: "%"
|
|
2116
|
+
},
|
|
2117
|
+
/**
|
|
2118
|
+
* Free-form: the accepted values are `availableFanSpeeds` on the slice.
|
|
2119
|
+
* `vacuumControl.setFanSpeed` is the route; the native vacuum renders it.
|
|
2120
|
+
*/
|
|
2121
|
+
"fan-speed": {
|
|
2122
|
+
platform: "sensor",
|
|
2123
|
+
field: "fanSpeed",
|
|
2124
|
+
label: "Fan speed",
|
|
2125
|
+
writable: true,
|
|
2126
|
+
enabledByDefault: false
|
|
2127
|
+
},
|
|
2128
|
+
progress: {
|
|
2129
|
+
platform: "sensor",
|
|
2130
|
+
field: "progressPercent",
|
|
2131
|
+
label: "Progress",
|
|
2132
|
+
unit: "%",
|
|
2133
|
+
enabledByDefault: false
|
|
2134
|
+
},
|
|
2135
|
+
error: {
|
|
2136
|
+
platform: "sensor",
|
|
2137
|
+
field: "errorLabel",
|
|
2138
|
+
label: "Error",
|
|
2139
|
+
entityCategory: "diagnostic",
|
|
2140
|
+
enabledByDefault: false
|
|
2141
|
+
}
|
|
2142
|
+
},
|
|
2143
|
+
commands: {
|
|
2144
|
+
start: {
|
|
2145
|
+
label: "Start",
|
|
2146
|
+
icon: "mdi:play"
|
|
2147
|
+
},
|
|
2148
|
+
pause: {
|
|
2149
|
+
label: "Pause",
|
|
2150
|
+
icon: "mdi:pause"
|
|
2151
|
+
},
|
|
2152
|
+
stop: {
|
|
2153
|
+
label: "Stop",
|
|
2154
|
+
icon: "mdi:stop",
|
|
2155
|
+
enabledByDefault: false
|
|
2156
|
+
},
|
|
2157
|
+
"return-to-base": {
|
|
2158
|
+
label: "Return to base",
|
|
2159
|
+
icon: "mdi:home-import-outline"
|
|
2160
|
+
},
|
|
2161
|
+
locate: {
|
|
2162
|
+
label: "Locate",
|
|
2163
|
+
icon: "mdi:map-marker-radius",
|
|
2164
|
+
enabledByDefault: false
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
2167
|
+
},
|
|
2168
|
+
"lawn-mower-control": {
|
|
2169
|
+
platform: "sensor",
|
|
2170
|
+
field: "activity",
|
|
2171
|
+
icon: "mdi:robot-mower",
|
|
2172
|
+
extras: {
|
|
2173
|
+
battery: {
|
|
2174
|
+
platform: "sensor",
|
|
2175
|
+
field: "batteryLevel",
|
|
2176
|
+
label: "Battery",
|
|
2177
|
+
deviceClass: "battery",
|
|
2178
|
+
unit: "%"
|
|
2179
|
+
},
|
|
2180
|
+
progress: {
|
|
2181
|
+
platform: "sensor",
|
|
2182
|
+
field: "progressPercent",
|
|
2183
|
+
label: "Progress",
|
|
2184
|
+
unit: "%",
|
|
2185
|
+
enabledByDefault: false
|
|
2186
|
+
},
|
|
2187
|
+
error: {
|
|
2188
|
+
platform: "sensor",
|
|
2189
|
+
field: "currentCodeLabel",
|
|
2190
|
+
label: "Status code",
|
|
2191
|
+
entityCategory: "diagnostic",
|
|
2192
|
+
enabledByDefault: false
|
|
2193
|
+
}
|
|
2194
|
+
},
|
|
2195
|
+
commands: {
|
|
2196
|
+
start: {
|
|
2197
|
+
label: "Start mowing",
|
|
2198
|
+
icon: "mdi:play"
|
|
2199
|
+
},
|
|
2200
|
+
pause: {
|
|
2201
|
+
label: "Pause",
|
|
2202
|
+
icon: "mdi:pause"
|
|
2203
|
+
},
|
|
2204
|
+
dock: {
|
|
2205
|
+
label: "Dock",
|
|
2206
|
+
icon: "mdi:home-import-outline"
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
},
|
|
2210
|
+
/** `currentMedia` is an object; the flat fields around it are exported. */
|
|
2211
|
+
"media-player": {
|
|
2212
|
+
platform: "sensor",
|
|
2213
|
+
field: "state",
|
|
2214
|
+
icon: "mdi:play-circle",
|
|
2215
|
+
extras: {
|
|
2216
|
+
volume: {
|
|
2217
|
+
platform: "number",
|
|
2218
|
+
field: "volumeLevel",
|
|
2219
|
+
label: "Volume",
|
|
2220
|
+
icon: "mdi:volume-high",
|
|
2221
|
+
writable: true,
|
|
2222
|
+
range: PERCENT_RANGE
|
|
2223
|
+
},
|
|
2224
|
+
muted: {
|
|
2225
|
+
platform: "switch",
|
|
2226
|
+
field: "isMuted",
|
|
2227
|
+
label: "Muted",
|
|
2228
|
+
icon: "mdi:volume-off",
|
|
2229
|
+
writable: true
|
|
2230
|
+
},
|
|
2231
|
+
/**
|
|
2232
|
+
* Free-form: the accepted values are `availableSources` on the slice.
|
|
2233
|
+
* `mediaPlayer.selectSource` is the route; the native media_player
|
|
2234
|
+
* renders it as its source list.
|
|
2235
|
+
*/
|
|
2236
|
+
source: {
|
|
2237
|
+
platform: "sensor",
|
|
2238
|
+
field: "source",
|
|
2239
|
+
label: "Source",
|
|
2240
|
+
writable: true,
|
|
2241
|
+
enabledByDefault: false
|
|
2242
|
+
},
|
|
2243
|
+
repeat: {
|
|
2244
|
+
platform: "select",
|
|
2245
|
+
field: "repeat",
|
|
2246
|
+
label: "Repeat",
|
|
2247
|
+
writable: true,
|
|
2248
|
+
options: require_dist.MediaPlayerRepeatSchema.options,
|
|
2249
|
+
enabledByDefault: false
|
|
2250
|
+
},
|
|
2251
|
+
shuffle: {
|
|
2252
|
+
platform: "switch",
|
|
2253
|
+
field: "shuffle",
|
|
2254
|
+
label: "Shuffle",
|
|
2255
|
+
writable: true,
|
|
2256
|
+
enabledByDefault: false
|
|
2257
|
+
}
|
|
2258
|
+
},
|
|
2259
|
+
commands: {
|
|
2260
|
+
play: {
|
|
2261
|
+
label: "Play",
|
|
2262
|
+
icon: "mdi:play"
|
|
2263
|
+
},
|
|
2264
|
+
pause: {
|
|
2265
|
+
label: "Pause",
|
|
2266
|
+
icon: "mdi:pause"
|
|
2267
|
+
},
|
|
2268
|
+
stop: {
|
|
2269
|
+
label: "Stop",
|
|
2270
|
+
icon: "mdi:stop",
|
|
2271
|
+
enabledByDefault: false
|
|
2272
|
+
},
|
|
2273
|
+
next: {
|
|
2274
|
+
label: "Next",
|
|
2275
|
+
icon: "mdi:skip-next"
|
|
2276
|
+
},
|
|
2277
|
+
previous: {
|
|
2278
|
+
label: "Previous",
|
|
2279
|
+
icon: "mdi:skip-previous"
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
},
|
|
2283
|
+
/**
|
|
2284
|
+
* `rgb` and `hsv` are nested objects, so hue and saturation cannot be
|
|
2285
|
+
* two flat `number` entities without a projector that walks into a
|
|
2286
|
+
* sub-object — and a `light` platform that carries the whole colour is
|
|
2287
|
+
* Tier B. What IS flat is the colour MODE and the colour temperature,
|
|
2288
|
+
* and both are automatable.
|
|
2289
|
+
*/
|
|
2290
|
+
color: {
|
|
2291
|
+
platform: "sensor",
|
|
2292
|
+
field: "mode",
|
|
2293
|
+
icon: "mdi:palette",
|
|
2294
|
+
extras: {
|
|
2295
|
+
/**
|
|
2296
|
+
* The one writable surface `setColor` has that is a single number: its
|
|
2297
|
+
* input is a discriminated union and `{ mode: 'mired', mireds }` is the
|
|
2298
|
+
* only arm with exactly one scalar in it. `mode` itself stays read-only
|
|
2299
|
+
* — writing it alone would be a call with no colour in it.
|
|
2300
|
+
*/
|
|
2301
|
+
mireds: {
|
|
2302
|
+
platform: "number",
|
|
2303
|
+
field: "mireds",
|
|
2304
|
+
label: "Colour temperature",
|
|
2305
|
+
unit: "mired",
|
|
2306
|
+
writable: true,
|
|
2307
|
+
range: MIRED_RANGE,
|
|
2308
|
+
enabledByDefault: false
|
|
2309
|
+
} }
|
|
2310
|
+
},
|
|
2311
|
+
update: {
|
|
2312
|
+
platform: "binary_sensor",
|
|
2313
|
+
field: "updatable",
|
|
2314
|
+
deviceClass: "update",
|
|
2315
|
+
extras: {
|
|
2316
|
+
available: {
|
|
2317
|
+
platform: "sensor",
|
|
2318
|
+
field: "availableVersion",
|
|
2319
|
+
label: "Available version",
|
|
2320
|
+
entityCategory: "diagnostic"
|
|
2321
|
+
},
|
|
2322
|
+
current: {
|
|
2323
|
+
platform: "sensor",
|
|
2324
|
+
field: "currentVersion",
|
|
2325
|
+
label: "Installed version",
|
|
2326
|
+
entityCategory: "diagnostic"
|
|
2327
|
+
},
|
|
2328
|
+
installing: {
|
|
2329
|
+
platform: "binary_sensor",
|
|
2330
|
+
field: "inProgress",
|
|
2331
|
+
label: "Installing",
|
|
2332
|
+
deviceClass: "running",
|
|
2333
|
+
enabledByDefault: false
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
},
|
|
2337
|
+
image: {
|
|
2338
|
+
platform: "image",
|
|
2339
|
+
field: "url",
|
|
2340
|
+
extras: { updated: {
|
|
2341
|
+
platform: "sensor",
|
|
2342
|
+
field: "lastUpdated",
|
|
2343
|
+
label: "Last updated",
|
|
2344
|
+
deviceClass: "timestamp",
|
|
2345
|
+
enabledByDefault: false
|
|
2346
|
+
} }
|
|
2347
|
+
},
|
|
2348
|
+
"script-runner": {
|
|
2349
|
+
platform: "binary_sensor",
|
|
2350
|
+
field: "isRunning",
|
|
2351
|
+
deviceClass: "running",
|
|
2352
|
+
icon: "mdi:script-text-play",
|
|
2353
|
+
extras: {
|
|
2354
|
+
"last-run": {
|
|
2355
|
+
platform: "sensor",
|
|
2356
|
+
field: "lastRunAt",
|
|
2357
|
+
label: "Last run",
|
|
2358
|
+
deviceClass: "timestamp"
|
|
2359
|
+
},
|
|
2360
|
+
"last-error": {
|
|
2361
|
+
platform: "sensor",
|
|
2362
|
+
field: "lastError",
|
|
2363
|
+
label: "Last error",
|
|
2364
|
+
entityCategory: "diagnostic",
|
|
2365
|
+
enabledByDefault: false
|
|
2366
|
+
}
|
|
2367
|
+
},
|
|
2368
|
+
commands: {
|
|
2369
|
+
run: {
|
|
2370
|
+
label: "Run",
|
|
2371
|
+
icon: "mdi:play"
|
|
2372
|
+
},
|
|
2373
|
+
stop: {
|
|
2374
|
+
label: "Stop",
|
|
2375
|
+
icon: "mdi:stop"
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
2378
|
+
},
|
|
2379
|
+
/**
|
|
2380
|
+
* `enabled` is a switch because `enable`/`disable` write exactly it;
|
|
2381
|
+
* `trigger` is a button because firing the action block is a verb, and it
|
|
2382
|
+
* works on a DISABLED automation — which is why it is not folded into the
|
|
2383
|
+
* switch.
|
|
2384
|
+
*/
|
|
2385
|
+
"automation-control": {
|
|
2386
|
+
platform: "switch",
|
|
2387
|
+
field: "enabled",
|
|
2388
|
+
icon: "mdi:robot",
|
|
2389
|
+
writable: true,
|
|
2390
|
+
commands: { trigger: {
|
|
2391
|
+
label: "Trigger",
|
|
2392
|
+
icon: "mdi:flash"
|
|
2393
|
+
} },
|
|
2394
|
+
extras: {
|
|
2395
|
+
running: {
|
|
2396
|
+
platform: "binary_sensor",
|
|
2397
|
+
field: "isRunning",
|
|
2398
|
+
label: "Running",
|
|
2399
|
+
deviceClass: "running",
|
|
2400
|
+
enabledByDefault: false
|
|
2401
|
+
},
|
|
2402
|
+
"last-triggered": {
|
|
2403
|
+
platform: "sensor",
|
|
2404
|
+
field: "lastTriggeredAt",
|
|
2405
|
+
label: "Last triggered",
|
|
2406
|
+
deviceClass: "timestamp"
|
|
2407
|
+
},
|
|
2408
|
+
"last-error": {
|
|
2409
|
+
platform: "sensor",
|
|
2410
|
+
field: "lastError",
|
|
2411
|
+
label: "Last error",
|
|
2412
|
+
entityCategory: "diagnostic",
|
|
2413
|
+
enabledByDefault: false
|
|
2414
|
+
}
|
|
2415
|
+
}
|
|
2416
|
+
},
|
|
2417
|
+
/**
|
|
2418
|
+
* `lastEvent` is an object and `eventTypes` an array, so the plan's
|
|
2419
|
+
* "last-event + last-event-at" pair has nothing flat to read. The COUNT
|
|
2420
|
+
* is flat, monotonic and the thing an automation can trigger on ("it
|
|
2421
|
+
* went up") — a native `event` platform, which carries the payload, is
|
|
2422
|
+
* Tier B.
|
|
2423
|
+
*/
|
|
2424
|
+
"event-emitter": {
|
|
2425
|
+
platform: "sensor",
|
|
2426
|
+
field: "eventCountSinceStart",
|
|
2427
|
+
icon: "mdi:counter"
|
|
2428
|
+
},
|
|
2429
|
+
"pet-feeder": {
|
|
2430
|
+
platform: "sensor",
|
|
2431
|
+
field: "foodLevel",
|
|
2432
|
+
unit: "%",
|
|
2433
|
+
icon: "mdi:food-drumstick",
|
|
2434
|
+
extras: {
|
|
2435
|
+
"low-food": {
|
|
2436
|
+
platform: "binary_sensor",
|
|
2437
|
+
field: "lowFood",
|
|
2438
|
+
label: "Low food",
|
|
2439
|
+
deviceClass: "problem"
|
|
2440
|
+
},
|
|
2441
|
+
feeding: {
|
|
2442
|
+
platform: "binary_sensor",
|
|
2443
|
+
field: "feeding",
|
|
2444
|
+
label: "Feeding",
|
|
2445
|
+
deviceClass: "running",
|
|
2446
|
+
enabledByDefault: false
|
|
2447
|
+
},
|
|
2448
|
+
desiccant: {
|
|
2449
|
+
platform: "sensor",
|
|
2450
|
+
field: "desiccantLeftDays",
|
|
2451
|
+
label: "Desiccant remaining",
|
|
2452
|
+
unit: "d",
|
|
2453
|
+
entityCategory: "diagnostic",
|
|
2454
|
+
enabledByDefault: false
|
|
2455
|
+
},
|
|
2456
|
+
status: {
|
|
2457
|
+
platform: "sensor",
|
|
2458
|
+
field: "status",
|
|
2459
|
+
label: "Status",
|
|
2460
|
+
entityCategory: "diagnostic",
|
|
2461
|
+
enabledByDefault: false
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
},
|
|
2465
|
+
/**
|
|
2466
|
+
* The cap carries its OWN unit next to each measurement
|
|
2467
|
+
* (`temperatureUnit`, `pressureUnit`, `windSpeedUnit`), so no static
|
|
2468
|
+
* `unit_of_measurement` can be honest for a provider reporting °F.
|
|
2469
|
+
* Declaring one anyway is how a dashboard comes to say 71 °C. Humidity
|
|
2470
|
+
* is the exception: a percentage is a percentage.
|
|
2471
|
+
*/
|
|
2472
|
+
weather: {
|
|
2473
|
+
platform: "sensor",
|
|
2474
|
+
field: "condition",
|
|
2475
|
+
icon: "mdi:weather-partly-cloudy",
|
|
2476
|
+
extras: {
|
|
2477
|
+
temperature: {
|
|
2478
|
+
platform: "sensor",
|
|
2479
|
+
field: "temperature",
|
|
2480
|
+
label: "Temperature",
|
|
2481
|
+
icon: "mdi:thermometer"
|
|
2482
|
+
},
|
|
2483
|
+
humidity: {
|
|
2484
|
+
platform: "sensor",
|
|
2485
|
+
field: "humidity",
|
|
2486
|
+
label: "Humidity",
|
|
2487
|
+
deviceClass: "humidity",
|
|
2488
|
+
unit: "%"
|
|
2489
|
+
},
|
|
2490
|
+
pressure: {
|
|
2491
|
+
platform: "sensor",
|
|
2492
|
+
field: "pressure",
|
|
2493
|
+
label: "Pressure",
|
|
2494
|
+
icon: "mdi:gauge",
|
|
2495
|
+
enabledByDefault: false
|
|
2496
|
+
},
|
|
2497
|
+
wind: {
|
|
2498
|
+
platform: "sensor",
|
|
2499
|
+
field: "windSpeed",
|
|
2500
|
+
label: "Wind speed",
|
|
2501
|
+
icon: "mdi:weather-windy",
|
|
2502
|
+
enabledByDefault: false
|
|
2503
|
+
},
|
|
2504
|
+
bearing: {
|
|
2505
|
+
platform: "sensor",
|
|
2506
|
+
field: "windBearing",
|
|
2507
|
+
label: "Wind bearing",
|
|
2508
|
+
unit: "°",
|
|
2509
|
+
enabledByDefault: false
|
|
2510
|
+
}
|
|
2511
|
+
}
|
|
2512
|
+
},
|
|
2513
|
+
/**
|
|
2514
|
+
* The generic user-settable input. `value` is `number | string` and
|
|
2515
|
+
* `kind` says which of the four shapes it is — a `number`, a `select`
|
|
2516
|
+
* or a text box, all three of which would need `control.setValue` in
|
|
2517
|
+
* the route table before they could be anything but a reading.
|
|
2518
|
+
*/
|
|
2519
|
+
control: {
|
|
2520
|
+
platform: "sensor",
|
|
2521
|
+
field: "value",
|
|
2522
|
+
extras: { kind: {
|
|
2523
|
+
platform: "sensor",
|
|
2524
|
+
field: "kind",
|
|
2525
|
+
label: "Input kind",
|
|
2526
|
+
entityCategory: "diagnostic",
|
|
2527
|
+
enabledByDefault: false
|
|
2528
|
+
} }
|
|
2529
|
+
},
|
|
2530
|
+
"motion-trigger": {
|
|
2531
|
+
platform: "binary_sensor",
|
|
2532
|
+
field: "enabled",
|
|
2533
|
+
icon: "mdi:motion-sensor"
|
|
2534
|
+
},
|
|
2535
|
+
/**
|
|
2536
|
+
* Three capabilities a camera carries that an operator automates on. They
|
|
2537
|
+
* are in this table because the descriptor and the push path are the
|
|
2538
|
+
* derived half's; they reach a camera through {@link CAMERA_DERIVED_CAPS}
|
|
2539
|
+
* because `buildDerivedPlan` never runs for one.
|
|
2540
|
+
*/
|
|
2541
|
+
/**
|
|
2542
|
+
* Talk-back, exported on the operator's decision and READ-ONLY.
|
|
2543
|
+
*
|
|
2544
|
+
* It was excluded until 2026-08-14 for a measured reason: the cap declared
|
|
2545
|
+
* `status` and no `runtimeState`, and the export's only two sources of a
|
|
2546
|
+
* value — the `device.state-changed` slice and the
|
|
2547
|
+
* `deviceState.getAllSnapshots` snapshot — are both built from runtime
|
|
2548
|
+
* state. The entity would have been published and never received a value.
|
|
2549
|
+
* `intercom.cap.ts` now declares the slice and the Reolink and Hikvision
|
|
2550
|
+
* providers write it at the four points that open and close a session, so
|
|
2551
|
+
* the entity has a feed before it has a row here.
|
|
2552
|
+
*
|
|
2553
|
+
* Read-only, and that is not a missing route: talk-back is an AUDIO
|
|
2554
|
+
* STREAM. `startTalkSession` opens a channel that `pushTalkAudio` feeds
|
|
2555
|
+
* frame by frame, and Home Assistant's switch carries `true`. A switch
|
|
2556
|
+
* that opened a session nobody could speak into is the control that lies.
|
|
2557
|
+
*/
|
|
2558
|
+
intercom: {
|
|
2559
|
+
platform: "binary_sensor",
|
|
2560
|
+
field: "talking",
|
|
2561
|
+
icon: "mdi:account-voice",
|
|
2562
|
+
extras: { "last-session": {
|
|
2563
|
+
platform: "sensor",
|
|
2564
|
+
field: "lastSessionAt",
|
|
2565
|
+
label: "Last talk session",
|
|
2566
|
+
deviceClass: "timestamp",
|
|
2567
|
+
enabledByDefault: false
|
|
2568
|
+
} }
|
|
2569
|
+
},
|
|
2570
|
+
"day-night": {
|
|
2571
|
+
platform: "sensor",
|
|
2572
|
+
field: "mode",
|
|
2573
|
+
icon: "mdi:theme-light-dark",
|
|
2574
|
+
extras: {
|
|
2575
|
+
sensitivity: {
|
|
2576
|
+
platform: "sensor",
|
|
2577
|
+
field: "sensitivity",
|
|
2578
|
+
label: "IR-cut sensitivity",
|
|
2579
|
+
entityCategory: "diagnostic",
|
|
2580
|
+
enabledByDefault: false
|
|
2581
|
+
},
|
|
2582
|
+
"switch-delay": {
|
|
2583
|
+
platform: "sensor",
|
|
2584
|
+
field: "switchDelaySec",
|
|
2585
|
+
label: "IR-cut switch delay",
|
|
2586
|
+
unit: "s",
|
|
2587
|
+
entityCategory: "diagnostic",
|
|
2588
|
+
enabledByDefault: false
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
},
|
|
2592
|
+
"ptz-autotrack": {
|
|
2593
|
+
platform: "binary_sensor",
|
|
2594
|
+
field: "enabled",
|
|
2595
|
+
icon: "mdi:crosshairs-gps"
|
|
687
2596
|
}
|
|
688
2597
|
};
|
|
2598
|
+
/**
|
|
2599
|
+
* Capabilities exported on a CAMERA rather than through
|
|
2600
|
+
* `buildDerivedPlan`, which only ever runs for a non-camera device.
|
|
2601
|
+
*/
|
|
2602
|
+
var CAMERA_DERIVED_CAPS = [
|
|
2603
|
+
"day-night",
|
|
2604
|
+
"ptz-autotrack",
|
|
2605
|
+
"intercom"
|
|
2606
|
+
];
|
|
2607
|
+
/**
|
|
2608
|
+
* The command buttons a capability produces.
|
|
2609
|
+
*
|
|
2610
|
+
* Separate from {@link capEntities} on purpose: that list is what the
|
|
2611
|
+
* PROJECTOR walks, and a button has nothing to project. Keeping them in one
|
|
2612
|
+
* list would put an entity with no `field` in front of code whose whole job is
|
|
2613
|
+
* to read one.
|
|
2614
|
+
*/
|
|
2615
|
+
function capCommandEntities(capName) {
|
|
2616
|
+
const mapping = CAP_ENTITY_MAP[capName];
|
|
2617
|
+
if (mapping === void 0) return [];
|
|
2618
|
+
return Object.entries(mapping.commands ?? {}).map(([suffix, command]) => ({
|
|
2619
|
+
entity: derivedExtraEntityId(capName, suffix),
|
|
2620
|
+
suffix,
|
|
2621
|
+
label: command.label,
|
|
2622
|
+
command
|
|
2623
|
+
}));
|
|
2624
|
+
}
|
|
2625
|
+
/**
|
|
2626
|
+
* The one expression that turns a descriptor into entities.
|
|
2627
|
+
*
|
|
2628
|
+
* Both halves of the catalog and the projector call it, so a capability
|
|
2629
|
+
* cannot build a component under one id and publish under another — the
|
|
2630
|
+
* failure `derivedEntityId` was extracted to end, now that a capability
|
|
2631
|
+
* can produce more than one entity.
|
|
2632
|
+
*/
|
|
2633
|
+
function capEntities(capName) {
|
|
2634
|
+
const mapping = CAP_ENTITY_MAP[capName];
|
|
2635
|
+
if (mapping === void 0) return [];
|
|
2636
|
+
const refs = [{
|
|
2637
|
+
entity: derivedEntityId(capName),
|
|
2638
|
+
label: humanise(capName),
|
|
2639
|
+
descriptor: mapping
|
|
2640
|
+
}];
|
|
2641
|
+
for (const [suffix, extra] of Object.entries(mapping.extras ?? {})) refs.push({
|
|
2642
|
+
entity: derivedExtraEntityId(capName, suffix),
|
|
2643
|
+
label: extra.label,
|
|
2644
|
+
descriptor: extra
|
|
2645
|
+
});
|
|
2646
|
+
return refs;
|
|
2647
|
+
}
|
|
2648
|
+
/**
|
|
2649
|
+
* Apply a descriptor's derivation.
|
|
2650
|
+
*
|
|
2651
|
+
* Returns `null` — which renders as `unrenderable` and is LOGGED — when
|
|
2652
|
+
* the slice carried something a comparison cannot be made against. A
|
|
2653
|
+
* derivation that quietly answered `false` for a non-string would report
|
|
2654
|
+
* a jammed lock as unlocked, which is the failure the derivation exists
|
|
2655
|
+
* to avoid.
|
|
2656
|
+
*/
|
|
2657
|
+
function deriveCapValue(derivation, raw) {
|
|
2658
|
+
if (derivation === void 0) return raw;
|
|
2659
|
+
if (typeof raw !== "string") return null;
|
|
2660
|
+
return derivation.kind === "equals" ? raw === derivation.value : raw !== derivation.value;
|
|
2661
|
+
}
|
|
2662
|
+
/** A capability's entities as the catalog builders consume them. */
|
|
2663
|
+
function capEntitySpecs(capName) {
|
|
2664
|
+
const specs = capEntities(capName).map((ref) => ({
|
|
2665
|
+
entity: ref.entity,
|
|
2666
|
+
platform: ref.descriptor.platform,
|
|
2667
|
+
label: ref.label,
|
|
2668
|
+
enabledByDefault: ref.descriptor.enabledByDefault ?? true,
|
|
2669
|
+
...ref.descriptor.deviceClass !== void 0 ? { deviceClass: ref.descriptor.deviceClass } : {},
|
|
2670
|
+
...ref.descriptor.unit !== void 0 ? { unit: ref.descriptor.unit } : {},
|
|
2671
|
+
...ref.descriptor.icon !== void 0 ? { icon: ref.descriptor.icon } : {},
|
|
2672
|
+
...ref.descriptor.options !== void 0 ? { options: ref.descriptor.options } : {},
|
|
2673
|
+
...ref.descriptor.range !== void 0 ? { range: ref.descriptor.range } : {},
|
|
2674
|
+
...ref.descriptor.entityCategory !== void 0 ? { entityCategory: ref.descriptor.entityCategory } : {},
|
|
2675
|
+
...ref.descriptor.writable === true ? { writable: true } : {}
|
|
2676
|
+
}));
|
|
2677
|
+
for (const ref of capCommandEntities(capName)) specs.push({
|
|
2678
|
+
entity: ref.entity,
|
|
2679
|
+
platform: "button",
|
|
2680
|
+
label: ref.label,
|
|
2681
|
+
enabledByDefault: ref.command.enabledByDefault ?? true,
|
|
2682
|
+
writable: true,
|
|
2683
|
+
...ref.command.icon !== void 0 ? { icon: ref.command.icon } : {},
|
|
2684
|
+
...ref.command.entityCategory !== void 0 ? { entityCategory: ref.command.entityCategory } : {}
|
|
2685
|
+
});
|
|
2686
|
+
return specs;
|
|
2687
|
+
}
|
|
689
2688
|
var CAPS_NOT_EXPORTED = [
|
|
690
2689
|
{
|
|
691
2690
|
cap: "snapshot",
|
|
@@ -726,8 +2725,116 @@ var CAPS_NOT_EXPORTED = [
|
|
|
726
2725
|
{
|
|
727
2726
|
cap: "device-discovery",
|
|
728
2727
|
reason: "Adoption-time only — it describes candidates, never the state of an adopted device."
|
|
2728
|
+
},
|
|
2729
|
+
{
|
|
2730
|
+
cap: "camera-credentials",
|
|
2731
|
+
reason: "It carries the camera's host, port and password. An entity would write that password into Home Assistant's registry and into every debug log it keeps, on a host camstack does not own. Never an entity, at any tier."
|
|
2732
|
+
},
|
|
2733
|
+
{
|
|
2734
|
+
cap: "stream-catalog",
|
|
2735
|
+
reason: "Transport — and it returns the camera's native URL with the password inline, so exporting it leaks the credential exactly as camera-credentials would. The streams reach HA as `camera` entities built from the broker restream instead (camera-entities.ts)."
|
|
2736
|
+
},
|
|
2737
|
+
{
|
|
2738
|
+
cap: "audio-analysis",
|
|
2739
|
+
reason: "Pipeline configuration. The audio DETECTIONS it drives already reach HA as the camera catalog's `audio_last_sound` / `audio_volume` entities, from the analyzer's own event."
|
|
2740
|
+
},
|
|
2741
|
+
{
|
|
2742
|
+
cap: "camera-pipeline-config",
|
|
2743
|
+
reason: "Transport/pipeline configuration — it selects the road, it is not a state."
|
|
2744
|
+
},
|
|
2745
|
+
{
|
|
2746
|
+
cap: "camera-streams",
|
|
2747
|
+
reason: "Transport. Its profile slots become the live `camera` entities (T4), which is the exported form; the slot configuration itself is not something an automation reads."
|
|
2748
|
+
},
|
|
2749
|
+
{
|
|
2750
|
+
cap: "detection-pipeline",
|
|
2751
|
+
reason: "Pipeline configuration. Its output is the per-macro camera entities, which are exported and which would disagree with a second set derived from the config."
|
|
2752
|
+
},
|
|
2753
|
+
{
|
|
2754
|
+
cap: "image-settings",
|
|
2755
|
+
reason: "Firmware image tuning — encoder brightness/contrast/saturation. A rendering setting with no state an automation reads."
|
|
2756
|
+
},
|
|
2757
|
+
{
|
|
2758
|
+
cap: "motion-detection",
|
|
2759
|
+
reason: "The LOCAL ML motion pipeline's configuration. Motion STATE is the `motion` capability, which is mapped; two sources on one question is the failure D62 records."
|
|
2760
|
+
},
|
|
2761
|
+
{
|
|
2762
|
+
cap: "motion-zones",
|
|
2763
|
+
reason: "Zone geometry, not state. Per-zone counts already reach HA through the camera catalog's zone fan-out."
|
|
2764
|
+
},
|
|
2765
|
+
{
|
|
2766
|
+
cap: "osd",
|
|
2767
|
+
reason: "On-screen-display text overlay — a rendering setting with no automatable state."
|
|
2768
|
+
},
|
|
2769
|
+
{
|
|
2770
|
+
cap: "pipeline-analytics",
|
|
2771
|
+
reason: "Pipeline telemetry. Cluster health is already the CamStack Server synthetic device; per-camera pipeline counters would fan out across the fleet with nothing consuming them."
|
|
2772
|
+
},
|
|
2773
|
+
{
|
|
2774
|
+
cap: "stream-params",
|
|
2775
|
+
reason: "Encoder parameters (bitrate/resolution/fps) — transport rather than state."
|
|
2776
|
+
},
|
|
2777
|
+
{
|
|
2778
|
+
cap: "videoclips",
|
|
2779
|
+
reason: "Media retrieval by handle, not a state. Clips reach the operator through notifications and the viewer."
|
|
2780
|
+
},
|
|
2781
|
+
{
|
|
2782
|
+
cap: "webrtc-session",
|
|
2783
|
+
reason: "A session opener — the definition of transport."
|
|
2784
|
+
},
|
|
2785
|
+
{
|
|
2786
|
+
cap: "events",
|
|
2787
|
+
reason: "A paginated event LOG, not a state. Its natural target is HA's `event` platform, which the component does not build (Tier B)."
|
|
2788
|
+
},
|
|
2789
|
+
{
|
|
2790
|
+
cap: "consumables",
|
|
2791
|
+
reason: "The value is `items`, an ARRAY of per-device consumables named by the provider (a filter, a brush, a cartridge). This table is static per capability, so it cannot mint one sensor per item; doing it needs a reconcile-time entity mint keyed on the device, which is a design, not a row."
|
|
2792
|
+
},
|
|
2793
|
+
{
|
|
2794
|
+
cap: "scene-monitor",
|
|
2795
|
+
reason: "Per-ROI reference-region state, `monitors` being an array of operator-named regions. Same shape as consumables: the entity set is per device and changes when the operator adds a region, which a static descriptor cannot express."
|
|
2796
|
+
},
|
|
2797
|
+
{
|
|
2798
|
+
cap: "audio-metrics",
|
|
2799
|
+
reason: "A real measurement, and already exported from its authority: the camera catalog projects `audio_volume` and `audio_level_rms` from the analyzer's own `pipeline.audio-inference-result`. A second source on the same question is the two-knobs failure D62 records."
|
|
2800
|
+
},
|
|
2801
|
+
{
|
|
2802
|
+
cap: "notifier",
|
|
2803
|
+
reason: "A delivery service, not an entity — HA models these as `notify.<service>`, which is not one of the nine platforms the component builds."
|
|
2804
|
+
},
|
|
2805
|
+
{
|
|
2806
|
+
cap: "accessories",
|
|
2807
|
+
reason: "Grouping metadata. Every listed child is a device in its own right and is exported as one; a second entity would restate the link and disagree with it the moment a child moves."
|
|
2808
|
+
},
|
|
2809
|
+
{
|
|
2810
|
+
cap: "zones",
|
|
2811
|
+
reason: "Zone geometry — configuration, not state. Same reason as `zone-analytics`, whose per-zone counts are what actually reaches HA."
|
|
2812
|
+
},
|
|
2813
|
+
{
|
|
2814
|
+
cap: "zone-rules",
|
|
2815
|
+
reason: "Per-stage zone rules — configuration the pipeline consumes, with no value an automation reads."
|
|
729
2816
|
}
|
|
730
2817
|
];
|
|
2818
|
+
var UNNEGOTIATED_PLAN_OPTIONS = { platforms: UNNEGOTIATED_SUPPORT };
|
|
2819
|
+
/**
|
|
2820
|
+
* An entity whose platform the component cannot build, degraded to one it
|
|
2821
|
+
* can.
|
|
2822
|
+
*
|
|
2823
|
+
* The command topic goes with the platform: the degraded form is a
|
|
2824
|
+
* READING, and announcing a command topic on it would leave a route open
|
|
2825
|
+
* that nothing can reach. See `DEGRADED_PLATFORM` for the one entry this
|
|
2826
|
+
* exists for.
|
|
2827
|
+
*/
|
|
2828
|
+
function degradeUnsupported(spec, options) {
|
|
2829
|
+
if (options.platforms.has(spec.platform)) return spec;
|
|
2830
|
+
const fallback = DEGRADED_PLATFORM[spec.platform];
|
|
2831
|
+
if (fallback === void 0) return spec;
|
|
2832
|
+
const { writable: _writable, ...rest } = spec;
|
|
2833
|
+
return {
|
|
2834
|
+
...rest,
|
|
2835
|
+
platform: fallback
|
|
2836
|
+
};
|
|
2837
|
+
}
|
|
731
2838
|
/** A capability in neither list — the thing the guard exists to surface. */
|
|
732
2839
|
function unclassifiedCaps(caps) {
|
|
733
2840
|
const known = new Set([...Object.keys(CAP_ENTITY_MAP), ...CAPS_NOT_EXPORTED.map((e) => e.cap)]);
|
|
@@ -746,8 +2853,19 @@ function unclassifiedCaps(caps) {
|
|
|
746
2853
|
* (`device-status`, `feature-probe`, `device-ops`) carry no entity at
|
|
747
2854
|
* all. The camera valve is for 73-per-device; this is not that problem,
|
|
748
2855
|
* and exporting the operator's temperature switched off was the bug.
|
|
2856
|
+
*
|
|
2857
|
+
* **The Tier A widening qualifies that, per ENTITY rather than per
|
|
2858
|
+
* device.** A cover, a thermostat or a media player is not one value; it
|
|
2859
|
+
* is a state plus the numbers around it, and the numbers are the reason
|
|
2860
|
+
* to own the device. So the PRIMARY entity of every capability still
|
|
2861
|
+
* arrives enabled — it is what the device is — and a secondary one
|
|
2862
|
+
* arrives enabled only when an operator automates on it. A thermostat's
|
|
2863
|
+
* measured temperature: yes. Its swing preset and its dual-setpoint
|
|
2864
|
+
* bounds: registered and off. That keeps a widened `climate-control`
|
|
2865
|
+
* device at three enabled entities rather than eight, without hiding the
|
|
2866
|
+
* data from anyone who wants it.
|
|
749
2867
|
*/
|
|
750
|
-
function buildDerivedPlan(device) {
|
|
2868
|
+
function buildDerivedPlan(device, options = UNNEGOTIATED_PLAN_OPTIONS) {
|
|
751
2869
|
const specs = [{
|
|
752
2870
|
entity: "online",
|
|
753
2871
|
platform: "binary_sensor",
|
|
@@ -756,24 +2874,50 @@ function buildDerivedPlan(device) {
|
|
|
756
2874
|
entityCategory: "diagnostic",
|
|
757
2875
|
enabledByDefault: true
|
|
758
2876
|
}];
|
|
2877
|
+
const native = {};
|
|
759
2878
|
for (const cap of device.boundCaps) {
|
|
2879
|
+
/**
|
|
2880
|
+
* A camera-scoped capability is built by the CAMERA half. Nothing
|
|
2881
|
+
* binds `day-night` to a base device today, and if something did,
|
|
2882
|
+
* two halves of this file would build the same entity id twice.
|
|
2883
|
+
*/
|
|
2884
|
+
if (CAMERA_DERIVED_CAPS.includes(cap)) continue;
|
|
760
2885
|
const mapping = CAP_ENTITY_MAP[cap];
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
2886
|
+
const platform = nativePlatformFor(cap);
|
|
2887
|
+
const plan = mapping === void 0 || platform === null || !options.platforms.has(platform) ? null : buildNativeComponent({
|
|
2888
|
+
deviceKey: deviceKeyFor(device.stableId),
|
|
2889
|
+
stableId: device.stableId,
|
|
2890
|
+
capName: cap,
|
|
765
2891
|
label: humanise(cap),
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
...mapping.unit !== void 0 ? { unit: mapping.unit } : {},
|
|
769
|
-
...mapping.writable === true ? { writable: true } : {}
|
|
2892
|
+
slice: device.capSlices?.[cap],
|
|
2893
|
+
mapping
|
|
770
2894
|
});
|
|
2895
|
+
if (plan === null) {
|
|
2896
|
+
specs.push(...capEntitySpecs(cap).map((spec) => degradeUnsupported(spec, options)));
|
|
2897
|
+
continue;
|
|
2898
|
+
}
|
|
2899
|
+
native[toComponentKey(plan.component.platform, derivedEntityId(cap))] = plan.component;
|
|
2900
|
+
/**
|
|
2901
|
+
* Rule 3 of `native-platforms.ts`: what the native entity reads, it
|
|
2902
|
+
* owns. What it does NOT read is still announced — a vacuum's error
|
|
2903
|
+
* label has nowhere to go on Home Assistant's vacuum, and dropping it
|
|
2904
|
+
* to make the device look tidy would be a silent loss of a value an
|
|
2905
|
+
* operator already automates on.
|
|
2906
|
+
*/
|
|
2907
|
+
specs.push(...capEntitySpecs(cap).filter((spec) => !plan.absorbed.has(spec.entity)));
|
|
771
2908
|
}
|
|
772
|
-
|
|
2909
|
+
const assembled = assemble(device, specs);
|
|
2910
|
+
return {
|
|
2911
|
+
...assembled,
|
|
2912
|
+
cmps: {
|
|
2913
|
+
...assembled.cmps,
|
|
2914
|
+
...native
|
|
2915
|
+
}
|
|
2916
|
+
};
|
|
773
2917
|
}
|
|
774
2918
|
/** Camera or base kind — the only place that decision is made. */
|
|
775
|
-
function buildDevicePlan(device) {
|
|
776
|
-
return device.type === "camera" ? buildCameraPlan(device) : buildDerivedPlan(device);
|
|
2919
|
+
function buildDevicePlan(device, options = UNNEGOTIATED_PLAN_OPTIONS) {
|
|
2920
|
+
return device.type === "camera" ? buildCameraPlan(device) : buildDerivedPlan(device, options);
|
|
777
2921
|
}
|
|
778
2922
|
//#endregion
|
|
779
2923
|
//#region src/ha-export/command-routes.ts
|
|
@@ -781,15 +2925,28 @@ function buildDevicePlan(device) {
|
|
|
781
2925
|
* Home Assistant → camstack.
|
|
782
2926
|
*
|
|
783
2927
|
* The component POSTs `{topic, value}` onto this addon's `addon-routes`
|
|
784
|
-
* surface; this module turns that pair into a typed command
|
|
785
|
-
*
|
|
786
|
-
*
|
|
2928
|
+
* surface; this module turns that pair into a typed command AND owns the
|
|
2929
|
+
* call each capability command makes. Resolution is pure; the dispatch
|
|
2930
|
+
* takes an injected `DeviceProxy` slice, so both halves are testable
|
|
2931
|
+
* without a hub.
|
|
2932
|
+
*
|
|
2933
|
+
* **The route and the call live together on purpose.** They used not to:
|
|
2934
|
+
* `resolveCommand` collapsed every non-button writable cap into
|
|
2935
|
+
* `kind: 'cap-switch'` and the addon called `device.switch.setState`
|
|
2936
|
+
* unconditionally, so `lock-control`, `siren`, `alarm-panel` and
|
|
2937
|
+
* `brightness` all advertised a control in Home Assistant that could not
|
|
2938
|
+
* work — a lock got `noProvider` and a 422, and `brightness` is a number
|
|
2939
|
+
* that never parsed as a boolean and never routed at all. A table that
|
|
2940
|
+
* names a method in one file and a switch that calls it in another is
|
|
2941
|
+
* exactly the drift that produced four lying controls.
|
|
787
2942
|
*
|
|
788
2943
|
* The rule that shapes every branch: **an unroutable or malformed
|
|
789
2944
|
* command is refused, never approximated.** A `switch` payload that is
|
|
790
2945
|
* neither `true` nor `false` is not "off"; a PTZ press on a camera that
|
|
791
|
-
* declares no `ptz` cap is not a no-op worth pretending succeeded
|
|
792
|
-
*
|
|
2946
|
+
* declares no `ptz` cap is not a no-op worth pretending succeeded; a cap
|
|
2947
|
+
* with no route does NOT fall through to `switch.setState`. Every
|
|
2948
|
+
* refusal carries a named reason so the log line says which of those it
|
|
2949
|
+
* was.
|
|
793
2950
|
*/
|
|
794
2951
|
/** The camera switch ids, keyed by the entity slug the catalog emits. */
|
|
795
2952
|
var CAMERA_SWITCH_BY_SLUG = Object.fromEntries([
|
|
@@ -809,76 +2966,981 @@ function parseBool(value) {
|
|
|
809
2966
|
if (lower === "false" || lower === "off" || lower === "0") return false;
|
|
810
2967
|
return null;
|
|
811
2968
|
}
|
|
2969
|
+
/**
|
|
2970
|
+
* A lock's payload.
|
|
2971
|
+
*
|
|
2972
|
+
* The catalog renders `lock-control` as a `switch`, so Home Assistant
|
|
2973
|
+
* sends `true`/`false` — but the component is not the only caller and a
|
|
2974
|
+
* lock's own vocabulary is `lock`/`unlock`. Both are accepted because
|
|
2975
|
+
* both are unambiguous; anything else is refused rather than read as
|
|
2976
|
+
* "unlock", which is the wrong way to be wrong about a door.
|
|
2977
|
+
*/
|
|
2978
|
+
function parseLocked(value) {
|
|
2979
|
+
const asBool = parseBool(value);
|
|
2980
|
+
if (asBool !== null) return asBool;
|
|
2981
|
+
const lower = value.trim().toLowerCase();
|
|
2982
|
+
if (lower === "lock" || lower === "locked") return true;
|
|
2983
|
+
if (lower === "unlock" || lower === "unlocked") return false;
|
|
2984
|
+
return null;
|
|
2985
|
+
}
|
|
2986
|
+
/**
|
|
2987
|
+
* A free-form vocabulary value, as the DEVICE spells it.
|
|
2988
|
+
*
|
|
2989
|
+
* `fanMode`, `preset`, `operationMode`, `mode`, `fanSpeed` and `source` are
|
|
2990
|
+
* `z.string().min(1)` in their caps and their accepted values live in an
|
|
2991
|
+
* `available*` array on the slice. Nothing here validates against that list:
|
|
2992
|
+
* a pure resolution has no slice, and a second copy of the vocabulary would
|
|
2993
|
+
* be one firmware away from refusing a mode the device accepts. The device
|
|
2994
|
+
* is the authority on its own vocabulary, and its refusal surfaces with its
|
|
2995
|
+
* own words. Only EMPTY is refused here, because a blank is never a mode.
|
|
2996
|
+
*/
|
|
2997
|
+
function parseVocabulary(value) {
|
|
2998
|
+
const trimmed = value.trim();
|
|
2999
|
+
return trimmed.length === 0 ? null : trimmed;
|
|
3000
|
+
}
|
|
3001
|
+
/** 0..100 inclusive, as `brightness.setBrightness` declares it. */
|
|
3002
|
+
function parsePercentage(value) {
|
|
3003
|
+
const parsed = Number(value.trim());
|
|
3004
|
+
if (!Number.isFinite(parsed)) return null;
|
|
3005
|
+
if (parsed < 0 || parsed > 100) return null;
|
|
3006
|
+
return parsed;
|
|
3007
|
+
}
|
|
3008
|
+
/**
|
|
3009
|
+
* An alarm panel's payload.
|
|
3010
|
+
*
|
|
3011
|
+
* Home Assistant's alarm-panel command vocabulary is `ARM_HOME`,
|
|
3012
|
+
* `ARM_AWAY`, `ARM_NIGHT`, `ARM_VACATION`, `ARM_CUSTOM_BYPASS`, `DISARM`
|
|
3013
|
+
* and `TRIGGER`; the cap's own vocabulary is the arm MODE (`home`,
|
|
3014
|
+
* `away`, …) and its state vocabulary is `armed_home`. All three are
|
|
3015
|
+
* accepted — they are the same instruction spelled by three layers of the
|
|
3016
|
+
* same stack — and the mode itself is validated by `AlarmArmModeSchema`
|
|
3017
|
+
* rather than by a second list here that could drift from the cap.
|
|
3018
|
+
*/
|
|
3019
|
+
function parseAlarmAction(value) {
|
|
3020
|
+
const lower = value.trim().toLowerCase();
|
|
3021
|
+
if (lower === "disarm" || lower === "disarmed") return { kind: "disarm" };
|
|
3022
|
+
if (lower === "trigger" || lower === "triggered") return { kind: "trigger" };
|
|
3023
|
+
const withoutPrefix = lower.startsWith("armed_") ? lower.slice(6) : lower.startsWith("arm_") ? lower.slice(4) : lower;
|
|
3024
|
+
const mode = require_dist.AlarmArmModeSchema.safeParse(withoutPrefix);
|
|
3025
|
+
return mode.success ? {
|
|
3026
|
+
kind: "arm",
|
|
3027
|
+
mode: mode.data
|
|
3028
|
+
} : null;
|
|
3029
|
+
}
|
|
3030
|
+
/** A finite number in `[min, max]`, or `null` when the payload is neither. */
|
|
3031
|
+
function parseBounded(value, min, max) {
|
|
3032
|
+
const parsed = Number(value.trim());
|
|
3033
|
+
if (!Number.isFinite(parsed)) return null;
|
|
3034
|
+
if (parsed < min || parsed > max) return null;
|
|
3035
|
+
return parsed;
|
|
3036
|
+
}
|
|
3037
|
+
/**
|
|
3038
|
+
* A payload against an enum the CAP declares.
|
|
3039
|
+
*
|
|
3040
|
+
* Never against a second list here. `HvacModeSchema`, `FanDirectionSchema`
|
|
3041
|
+
* and `MediaPlayerRepeatSchema` are the same objects the catalog offers as
|
|
3042
|
+
* the `select`'s options, so the values HA can send and the values this
|
|
3043
|
+
* accepts cannot drift apart.
|
|
3044
|
+
*/
|
|
3045
|
+
function parseEnum(schema, value) {
|
|
3046
|
+
const parsed = schema.safeParse(value.trim().toLowerCase());
|
|
3047
|
+
return parsed.success ? parsed.data : null;
|
|
3048
|
+
}
|
|
3049
|
+
/**
|
|
3050
|
+
* capability → the commands that WRITE it.
|
|
3051
|
+
*
|
|
3052
|
+
* The entity catalog decides WHICH entities are writable; this decides what
|
|
3053
|
+
* writing each one does. An entity declared `writable` with no entry here is
|
|
3054
|
+
* refused with `no-route` and the drop is logged — it must never fall through
|
|
3055
|
+
* to another cap's method, which is how four controls came to advertise a
|
|
3056
|
+
* function they could not perform.
|
|
3057
|
+
*
|
|
3058
|
+
* Both directions are asserted by the specs: a writable descriptor with no
|
|
3059
|
+
* route fails, and a route with no descriptor fails too. An unreachable route
|
|
3060
|
+
* is the same defect read backwards — it describes a control the catalog
|
|
3061
|
+
* never built, and a leftover that describes the right design reads as
|
|
3062
|
+
* verification.
|
|
3063
|
+
*
|
|
3064
|
+
* **Every builder names ONE method with ONE value, and none of them reads
|
|
3065
|
+
* the device.** Resolution is pure and has no slice, which is why
|
|
3066
|
+
* `climate-control.setTargetRange` — the only cap method that takes two
|
|
3067
|
+
* values at once — resolves to a `climate-target-edge` carrying the half it
|
|
3068
|
+
* was given. The read of the other half belongs to {@link applyCapCommand},
|
|
3069
|
+
* which has the device; composing it here would have needed a slice this
|
|
3070
|
+
* function must not have.
|
|
3071
|
+
*/
|
|
3072
|
+
var CAP_COMMAND_ROUTES = {
|
|
3073
|
+
switch: { primary: (deviceId, value) => {
|
|
3074
|
+
const on = parseBool(value);
|
|
3075
|
+
return on === null ? null : {
|
|
3076
|
+
kind: "cap-switch",
|
|
3077
|
+
deviceId,
|
|
3078
|
+
capName: "switch",
|
|
3079
|
+
on
|
|
3080
|
+
};
|
|
3081
|
+
} },
|
|
3082
|
+
"lock-control": {
|
|
3083
|
+
primary: (deviceId, value) => {
|
|
3084
|
+
const locked = parseLocked(value);
|
|
3085
|
+
return locked === null ? null : {
|
|
3086
|
+
kind: "cap-lock",
|
|
3087
|
+
deviceId,
|
|
3088
|
+
locked
|
|
3089
|
+
};
|
|
3090
|
+
},
|
|
3091
|
+
commands: { open: (deviceId) => ({
|
|
3092
|
+
kind: "cap-lock-open",
|
|
3093
|
+
deviceId
|
|
3094
|
+
}) }
|
|
3095
|
+
},
|
|
3096
|
+
brightness: { primary: (deviceId, value) => {
|
|
3097
|
+
const percentage = parsePercentage(value);
|
|
3098
|
+
return percentage === null ? null : {
|
|
3099
|
+
kind: "cap-brightness",
|
|
3100
|
+
deviceId,
|
|
3101
|
+
percentage
|
|
3102
|
+
};
|
|
3103
|
+
} },
|
|
3104
|
+
"alarm-panel": { primary: (deviceId, value) => {
|
|
3105
|
+
const action = parseAlarmAction(value);
|
|
3106
|
+
return action === null ? null : {
|
|
3107
|
+
kind: "cap-alarm",
|
|
3108
|
+
deviceId,
|
|
3109
|
+
action
|
|
3110
|
+
};
|
|
3111
|
+
} },
|
|
3112
|
+
button: { primary: (deviceId) => ({
|
|
3113
|
+
kind: "cap-button",
|
|
3114
|
+
deviceId,
|
|
3115
|
+
capName: "button"
|
|
3116
|
+
}) },
|
|
3117
|
+
cover: {
|
|
3118
|
+
extras: {
|
|
3119
|
+
position: (deviceId, value) => {
|
|
3120
|
+
const position = parsePercentage(value);
|
|
3121
|
+
return position === null ? null : {
|
|
3122
|
+
kind: "cover-position",
|
|
3123
|
+
deviceId,
|
|
3124
|
+
position
|
|
3125
|
+
};
|
|
3126
|
+
},
|
|
3127
|
+
tilt: (deviceId, value) => {
|
|
3128
|
+
const tiltPosition = parsePercentage(value);
|
|
3129
|
+
return tiltPosition === null ? null : {
|
|
3130
|
+
kind: "cover-tilt",
|
|
3131
|
+
deviceId,
|
|
3132
|
+
tiltPosition
|
|
3133
|
+
};
|
|
3134
|
+
}
|
|
3135
|
+
},
|
|
3136
|
+
commands: {
|
|
3137
|
+
open: (deviceId) => ({
|
|
3138
|
+
kind: "cover-verb",
|
|
3139
|
+
deviceId,
|
|
3140
|
+
verb: "open"
|
|
3141
|
+
}),
|
|
3142
|
+
close: (deviceId) => ({
|
|
3143
|
+
kind: "cover-verb",
|
|
3144
|
+
deviceId,
|
|
3145
|
+
verb: "close"
|
|
3146
|
+
}),
|
|
3147
|
+
stop: (deviceId) => ({
|
|
3148
|
+
kind: "cover-verb",
|
|
3149
|
+
deviceId,
|
|
3150
|
+
verb: "stop"
|
|
3151
|
+
})
|
|
3152
|
+
}
|
|
3153
|
+
},
|
|
3154
|
+
"climate-control": {
|
|
3155
|
+
primary: (deviceId, value) => {
|
|
3156
|
+
const mode = parseEnum(require_dist.HvacModeSchema, value);
|
|
3157
|
+
return mode === null ? null : {
|
|
3158
|
+
kind: "climate-mode",
|
|
3159
|
+
deviceId,
|
|
3160
|
+
mode
|
|
3161
|
+
};
|
|
3162
|
+
},
|
|
3163
|
+
extras: {
|
|
3164
|
+
/**
|
|
3165
|
+
* Bounded by the entity's declared range and nothing narrower: the
|
|
3166
|
+
* per-device limits are the provider's (`getOptions`), and refusing
|
|
3167
|
+
* here on a guess would reject a setpoint the device accepts.
|
|
3168
|
+
*/
|
|
3169
|
+
target: (deviceId, value) => {
|
|
3170
|
+
const target = parseBounded(value, TEMPERATURE_MIN, TEMPERATURE_MAX);
|
|
3171
|
+
return target === null ? null : {
|
|
3172
|
+
kind: "climate-target",
|
|
3173
|
+
deviceId,
|
|
3174
|
+
target
|
|
3175
|
+
};
|
|
3176
|
+
},
|
|
3177
|
+
"target-humidity": (deviceId, value) => {
|
|
3178
|
+
const targetHumidity = parsePercentage(value);
|
|
3179
|
+
return targetHumidity === null ? null : {
|
|
3180
|
+
kind: "climate-target-humidity",
|
|
3181
|
+
deviceId,
|
|
3182
|
+
targetHumidity
|
|
3183
|
+
};
|
|
3184
|
+
},
|
|
3185
|
+
"fan-mode": (deviceId, value) => {
|
|
3186
|
+
const fanMode = parseVocabulary(value);
|
|
3187
|
+
return fanMode === null ? null : {
|
|
3188
|
+
kind: "climate-fan-mode",
|
|
3189
|
+
deviceId,
|
|
3190
|
+
fanMode
|
|
3191
|
+
};
|
|
3192
|
+
},
|
|
3193
|
+
preset: (deviceId, value) => {
|
|
3194
|
+
const preset = parseVocabulary(value);
|
|
3195
|
+
return preset === null ? null : {
|
|
3196
|
+
kind: "climate-preset",
|
|
3197
|
+
deviceId,
|
|
3198
|
+
preset
|
|
3199
|
+
};
|
|
3200
|
+
},
|
|
3201
|
+
/**
|
|
3202
|
+
* The two halves of `setTargetRange`, each bounded by the same range
|
|
3203
|
+
* `target` is. They resolve to an EDGE rather than to a call: the other
|
|
3204
|
+
* half is a fact about the device, and resolution is pure.
|
|
3205
|
+
*/
|
|
3206
|
+
"target-low": (deviceId, value) => {
|
|
3207
|
+
const low = parseBounded(value, TEMPERATURE_MIN, TEMPERATURE_MAX);
|
|
3208
|
+
return low === null ? null : {
|
|
3209
|
+
kind: "climate-target-edge",
|
|
3210
|
+
deviceId,
|
|
3211
|
+
edge: "low",
|
|
3212
|
+
value: low
|
|
3213
|
+
};
|
|
3214
|
+
},
|
|
3215
|
+
"target-high": (deviceId, value) => {
|
|
3216
|
+
const high = parseBounded(value, TEMPERATURE_MIN, TEMPERATURE_MAX);
|
|
3217
|
+
return high === null ? null : {
|
|
3218
|
+
kind: "climate-target-edge",
|
|
3219
|
+
deviceId,
|
|
3220
|
+
edge: "high",
|
|
3221
|
+
value: high
|
|
3222
|
+
};
|
|
3223
|
+
},
|
|
3224
|
+
"swing-vertical": (deviceId, value) => {
|
|
3225
|
+
const on = parseBool(value);
|
|
3226
|
+
return on === null ? null : {
|
|
3227
|
+
kind: "climate-swing",
|
|
3228
|
+
deviceId,
|
|
3229
|
+
axis: "vertical",
|
|
3230
|
+
on
|
|
3231
|
+
};
|
|
3232
|
+
},
|
|
3233
|
+
"swing-horizontal": (deviceId, value) => {
|
|
3234
|
+
const on = parseBool(value);
|
|
3235
|
+
return on === null ? null : {
|
|
3236
|
+
kind: "climate-swing",
|
|
3237
|
+
deviceId,
|
|
3238
|
+
axis: "horizontal",
|
|
3239
|
+
on
|
|
3240
|
+
};
|
|
3241
|
+
}
|
|
3242
|
+
}
|
|
3243
|
+
},
|
|
3244
|
+
"fan-control": {
|
|
3245
|
+
primary: (deviceId, value) => {
|
|
3246
|
+
const percentage = parsePercentage(value);
|
|
3247
|
+
return percentage === null ? null : {
|
|
3248
|
+
kind: "fan-percentage",
|
|
3249
|
+
deviceId,
|
|
3250
|
+
percentage
|
|
3251
|
+
};
|
|
3252
|
+
},
|
|
3253
|
+
extras: {
|
|
3254
|
+
preset: (deviceId, value) => {
|
|
3255
|
+
const preset = parseVocabulary(value);
|
|
3256
|
+
return preset === null ? null : {
|
|
3257
|
+
kind: "fan-preset",
|
|
3258
|
+
deviceId,
|
|
3259
|
+
preset
|
|
3260
|
+
};
|
|
3261
|
+
},
|
|
3262
|
+
oscillating: (deviceId, value) => {
|
|
3263
|
+
const oscillating = parseBool(value);
|
|
3264
|
+
return oscillating === null ? null : {
|
|
3265
|
+
kind: "fan-oscillating",
|
|
3266
|
+
deviceId,
|
|
3267
|
+
oscillating
|
|
3268
|
+
};
|
|
3269
|
+
},
|
|
3270
|
+
direction: (deviceId, value) => {
|
|
3271
|
+
const direction = parseEnum(require_dist.FanDirectionSchema, value);
|
|
3272
|
+
return direction === null ? null : {
|
|
3273
|
+
kind: "fan-direction",
|
|
3274
|
+
deviceId,
|
|
3275
|
+
direction
|
|
3276
|
+
};
|
|
3277
|
+
}
|
|
3278
|
+
}
|
|
3279
|
+
},
|
|
3280
|
+
humidifier: {
|
|
3281
|
+
primary: (deviceId, value) => {
|
|
3282
|
+
const on = parseBool(value);
|
|
3283
|
+
return on === null ? null : {
|
|
3284
|
+
kind: "humidifier-on",
|
|
3285
|
+
deviceId,
|
|
3286
|
+
on
|
|
3287
|
+
};
|
|
3288
|
+
},
|
|
3289
|
+
extras: {
|
|
3290
|
+
"target-humidity": (deviceId, value) => {
|
|
3291
|
+
const humidity = parsePercentage(value);
|
|
3292
|
+
return humidity === null ? null : {
|
|
3293
|
+
kind: "humidifier-humidity",
|
|
3294
|
+
deviceId,
|
|
3295
|
+
humidity
|
|
3296
|
+
};
|
|
3297
|
+
},
|
|
3298
|
+
mode: (deviceId, value) => {
|
|
3299
|
+
const mode = parseVocabulary(value);
|
|
3300
|
+
return mode === null ? null : {
|
|
3301
|
+
kind: "humidifier-mode",
|
|
3302
|
+
deviceId,
|
|
3303
|
+
mode
|
|
3304
|
+
};
|
|
3305
|
+
}
|
|
3306
|
+
}
|
|
3307
|
+
},
|
|
3308
|
+
"water-heater": { extras: {
|
|
3309
|
+
target: (deviceId, value) => {
|
|
3310
|
+
const temp = parseBounded(value, TEMPERATURE_MIN, TEMPERATURE_MAX);
|
|
3311
|
+
return temp === null ? null : {
|
|
3312
|
+
kind: "water-heater-temp",
|
|
3313
|
+
deviceId,
|
|
3314
|
+
temp
|
|
3315
|
+
};
|
|
3316
|
+
},
|
|
3317
|
+
away: (deviceId, value) => {
|
|
3318
|
+
const on = parseBool(value);
|
|
3319
|
+
return on === null ? null : {
|
|
3320
|
+
kind: "water-heater-away",
|
|
3321
|
+
deviceId,
|
|
3322
|
+
on
|
|
3323
|
+
};
|
|
3324
|
+
},
|
|
3325
|
+
mode: (deviceId, value) => {
|
|
3326
|
+
const mode = parseVocabulary(value);
|
|
3327
|
+
return mode === null ? null : {
|
|
3328
|
+
kind: "water-heater-mode",
|
|
3329
|
+
deviceId,
|
|
3330
|
+
mode
|
|
3331
|
+
};
|
|
3332
|
+
}
|
|
3333
|
+
} },
|
|
3334
|
+
valve: {
|
|
3335
|
+
extras: { position: (deviceId, value) => {
|
|
3336
|
+
const position = parsePercentage(value);
|
|
3337
|
+
return position === null ? null : {
|
|
3338
|
+
kind: "valve-position",
|
|
3339
|
+
deviceId,
|
|
3340
|
+
position
|
|
3341
|
+
};
|
|
3342
|
+
} },
|
|
3343
|
+
commands: {
|
|
3344
|
+
open: (deviceId) => ({
|
|
3345
|
+
kind: "valve-verb",
|
|
3346
|
+
deviceId,
|
|
3347
|
+
verb: "open"
|
|
3348
|
+
}),
|
|
3349
|
+
close: (deviceId) => ({
|
|
3350
|
+
kind: "valve-verb",
|
|
3351
|
+
deviceId,
|
|
3352
|
+
verb: "close"
|
|
3353
|
+
}),
|
|
3354
|
+
stop: (deviceId) => ({
|
|
3355
|
+
kind: "valve-verb",
|
|
3356
|
+
deviceId,
|
|
3357
|
+
verb: "stop"
|
|
3358
|
+
})
|
|
3359
|
+
}
|
|
3360
|
+
},
|
|
3361
|
+
"vacuum-control": {
|
|
3362
|
+
extras: { "fan-speed": (deviceId, value) => {
|
|
3363
|
+
const speed = parseVocabulary(value);
|
|
3364
|
+
return speed === null ? null : {
|
|
3365
|
+
kind: "vacuum-fan-speed",
|
|
3366
|
+
deviceId,
|
|
3367
|
+
speed
|
|
3368
|
+
};
|
|
3369
|
+
} },
|
|
3370
|
+
commands: {
|
|
3371
|
+
start: (deviceId) => ({
|
|
3372
|
+
kind: "vacuum-verb",
|
|
3373
|
+
deviceId,
|
|
3374
|
+
verb: "start"
|
|
3375
|
+
}),
|
|
3376
|
+
pause: (deviceId) => ({
|
|
3377
|
+
kind: "vacuum-verb",
|
|
3378
|
+
deviceId,
|
|
3379
|
+
verb: "pause"
|
|
3380
|
+
}),
|
|
3381
|
+
stop: (deviceId) => ({
|
|
3382
|
+
kind: "vacuum-verb",
|
|
3383
|
+
deviceId,
|
|
3384
|
+
verb: "stop"
|
|
3385
|
+
}),
|
|
3386
|
+
"return-to-base": (deviceId) => ({
|
|
3387
|
+
kind: "vacuum-verb",
|
|
3388
|
+
deviceId,
|
|
3389
|
+
verb: "return-to-base"
|
|
3390
|
+
}),
|
|
3391
|
+
locate: (deviceId) => ({
|
|
3392
|
+
kind: "vacuum-verb",
|
|
3393
|
+
deviceId,
|
|
3394
|
+
verb: "locate"
|
|
3395
|
+
})
|
|
3396
|
+
}
|
|
3397
|
+
},
|
|
3398
|
+
"lawn-mower-control": { commands: {
|
|
3399
|
+
start: (deviceId) => ({
|
|
3400
|
+
kind: "mower-verb",
|
|
3401
|
+
deviceId,
|
|
3402
|
+
verb: "start"
|
|
3403
|
+
}),
|
|
3404
|
+
pause: (deviceId) => ({
|
|
3405
|
+
kind: "mower-verb",
|
|
3406
|
+
deviceId,
|
|
3407
|
+
verb: "pause"
|
|
3408
|
+
}),
|
|
3409
|
+
dock: (deviceId) => ({
|
|
3410
|
+
kind: "mower-verb",
|
|
3411
|
+
deviceId,
|
|
3412
|
+
verb: "dock"
|
|
3413
|
+
})
|
|
3414
|
+
} },
|
|
3415
|
+
"media-player": {
|
|
3416
|
+
extras: {
|
|
3417
|
+
volume: (deviceId, value) => {
|
|
3418
|
+
const volumeLevel = parsePercentage(value);
|
|
3419
|
+
return volumeLevel === null ? null : {
|
|
3420
|
+
kind: "media-volume",
|
|
3421
|
+
deviceId,
|
|
3422
|
+
volumeLevel
|
|
3423
|
+
};
|
|
3424
|
+
},
|
|
3425
|
+
muted: (deviceId, value) => {
|
|
3426
|
+
const muted = parseBool(value);
|
|
3427
|
+
return muted === null ? null : {
|
|
3428
|
+
kind: "media-mute",
|
|
3429
|
+
deviceId,
|
|
3430
|
+
muted
|
|
3431
|
+
};
|
|
3432
|
+
},
|
|
3433
|
+
shuffle: (deviceId, value) => {
|
|
3434
|
+
const shuffle = parseBool(value);
|
|
3435
|
+
return shuffle === null ? null : {
|
|
3436
|
+
kind: "media-shuffle",
|
|
3437
|
+
deviceId,
|
|
3438
|
+
shuffle
|
|
3439
|
+
};
|
|
3440
|
+
},
|
|
3441
|
+
repeat: (deviceId, value) => {
|
|
3442
|
+
const repeat = parseEnum(require_dist.MediaPlayerRepeatSchema, value);
|
|
3443
|
+
return repeat === null ? null : {
|
|
3444
|
+
kind: "media-repeat",
|
|
3445
|
+
deviceId,
|
|
3446
|
+
repeat
|
|
3447
|
+
};
|
|
3448
|
+
},
|
|
3449
|
+
source: (deviceId, value) => {
|
|
3450
|
+
const source = parseVocabulary(value);
|
|
3451
|
+
return source === null ? null : {
|
|
3452
|
+
kind: "media-source",
|
|
3453
|
+
deviceId,
|
|
3454
|
+
source
|
|
3455
|
+
};
|
|
3456
|
+
}
|
|
3457
|
+
},
|
|
3458
|
+
commands: {
|
|
3459
|
+
play: (deviceId) => ({
|
|
3460
|
+
kind: "media-verb",
|
|
3461
|
+
deviceId,
|
|
3462
|
+
verb: "play"
|
|
3463
|
+
}),
|
|
3464
|
+
pause: (deviceId) => ({
|
|
3465
|
+
kind: "media-verb",
|
|
3466
|
+
deviceId,
|
|
3467
|
+
verb: "pause"
|
|
3468
|
+
}),
|
|
3469
|
+
stop: (deviceId) => ({
|
|
3470
|
+
kind: "media-verb",
|
|
3471
|
+
deviceId,
|
|
3472
|
+
verb: "stop"
|
|
3473
|
+
}),
|
|
3474
|
+
next: (deviceId) => ({
|
|
3475
|
+
kind: "media-verb",
|
|
3476
|
+
deviceId,
|
|
3477
|
+
verb: "next"
|
|
3478
|
+
}),
|
|
3479
|
+
previous: (deviceId) => ({
|
|
3480
|
+
kind: "media-verb",
|
|
3481
|
+
deviceId,
|
|
3482
|
+
verb: "previous"
|
|
3483
|
+
})
|
|
3484
|
+
}
|
|
3485
|
+
},
|
|
3486
|
+
color: { extras: { mireds: (deviceId, value) => {
|
|
3487
|
+
const mireds = parseBounded(value, MIRED_MIN, MIRED_MAX);
|
|
3488
|
+
if (mireds === null || !Number.isInteger(mireds)) return null;
|
|
3489
|
+
return {
|
|
3490
|
+
kind: "color-mireds",
|
|
3491
|
+
deviceId,
|
|
3492
|
+
mireds
|
|
3493
|
+
};
|
|
3494
|
+
} } },
|
|
3495
|
+
"script-runner": { commands: {
|
|
3496
|
+
run: (deviceId) => ({
|
|
3497
|
+
kind: "script-verb",
|
|
3498
|
+
deviceId,
|
|
3499
|
+
verb: "run"
|
|
3500
|
+
}),
|
|
3501
|
+
stop: (deviceId) => ({
|
|
3502
|
+
kind: "script-verb",
|
|
3503
|
+
deviceId,
|
|
3504
|
+
verb: "stop"
|
|
3505
|
+
})
|
|
3506
|
+
} },
|
|
3507
|
+
"automation-control": {
|
|
3508
|
+
primary: (deviceId, value) => {
|
|
3509
|
+
const enabled = parseBool(value);
|
|
3510
|
+
return enabled === null ? null : {
|
|
3511
|
+
kind: "automation-enabled",
|
|
3512
|
+
deviceId,
|
|
3513
|
+
enabled
|
|
3514
|
+
};
|
|
3515
|
+
},
|
|
3516
|
+
commands: { trigger: (deviceId) => ({
|
|
3517
|
+
kind: "automation-trigger",
|
|
3518
|
+
deviceId
|
|
3519
|
+
}) }
|
|
3520
|
+
}
|
|
3521
|
+
};
|
|
3522
|
+
/**
|
|
3523
|
+
* The bounds the parsers enforce, kept HERE rather than imported from the
|
|
3524
|
+
* catalog's `TEMPERATURE_RANGE` / `MIRED_RANGE`: the catalog's ranges are
|
|
3525
|
+
* what Home Assistant is TOLD, and these are what the hub ACCEPTS. They
|
|
3526
|
+
* agree today, and the catalog spec asserts they still do — but the
|
|
3527
|
+
* assertion has to be able to fail, which it cannot if both read one
|
|
3528
|
+
* constant.
|
|
3529
|
+
*/
|
|
3530
|
+
var TEMPERATURE_MIN = -20;
|
|
3531
|
+
var TEMPERATURE_MAX = 90;
|
|
3532
|
+
var MIRED_MIN = 50;
|
|
3533
|
+
var MIRED_MAX = 1e3;
|
|
812
3534
|
function resolveCommand(target, entity, value) {
|
|
813
3535
|
if (target.type === "camera") {
|
|
814
3536
|
const switchId = CAMERA_SWITCH_BY_SLUG[entity];
|
|
815
3537
|
if (switchId !== void 0) {
|
|
816
3538
|
const enabled = parseBool(value);
|
|
817
|
-
if (enabled === null) return
|
|
3539
|
+
if (enabled === null) return {
|
|
3540
|
+
ok: false,
|
|
3541
|
+
reason: "bad-value"
|
|
3542
|
+
};
|
|
818
3543
|
return {
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
3544
|
+
ok: true,
|
|
3545
|
+
command: {
|
|
3546
|
+
kind: "camera-switch",
|
|
3547
|
+
deviceId: target.deviceId,
|
|
3548
|
+
switchId,
|
|
3549
|
+
enabled
|
|
3550
|
+
}
|
|
823
3551
|
};
|
|
824
3552
|
}
|
|
825
3553
|
if (entity === "reboot") return {
|
|
826
|
-
|
|
827
|
-
|
|
3554
|
+
ok: true,
|
|
3555
|
+
command: {
|
|
3556
|
+
kind: "reboot",
|
|
3557
|
+
deviceId: target.deviceId
|
|
3558
|
+
}
|
|
828
3559
|
};
|
|
829
3560
|
const direction = PTZ_DIRECTION_BY_ENTITY[entity];
|
|
830
3561
|
if (direction !== void 0) {
|
|
831
|
-
if (!target.boundCaps.includes("ptz")) return
|
|
3562
|
+
if (!target.boundCaps.includes("ptz")) return {
|
|
3563
|
+
ok: false,
|
|
3564
|
+
reason: "cap-not-bound",
|
|
3565
|
+
capName: "ptz"
|
|
3566
|
+
};
|
|
832
3567
|
return {
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
3568
|
+
ok: true,
|
|
3569
|
+
command: {
|
|
3570
|
+
kind: "ptz-move",
|
|
3571
|
+
deviceId: target.deviceId,
|
|
3572
|
+
direction
|
|
3573
|
+
}
|
|
836
3574
|
};
|
|
837
3575
|
}
|
|
838
3576
|
if (entity === "ptz_preset") {
|
|
839
|
-
if (!target.boundCaps.includes("ptz")) return
|
|
840
|
-
|
|
3577
|
+
if (!target.boundCaps.includes("ptz")) return {
|
|
3578
|
+
ok: false,
|
|
3579
|
+
reason: "cap-not-bound",
|
|
3580
|
+
capName: "ptz"
|
|
3581
|
+
};
|
|
3582
|
+
if (value.trim().length === 0) return {
|
|
3583
|
+
ok: false,
|
|
3584
|
+
reason: "bad-value",
|
|
3585
|
+
capName: "ptz"
|
|
3586
|
+
};
|
|
841
3587
|
return {
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
3588
|
+
ok: true,
|
|
3589
|
+
command: {
|
|
3590
|
+
kind: "ptz-preset",
|
|
3591
|
+
deviceId: target.deviceId,
|
|
3592
|
+
preset: value
|
|
3593
|
+
}
|
|
845
3594
|
};
|
|
846
3595
|
}
|
|
847
3596
|
if (entity === "snooze") {
|
|
848
|
-
if (!SNOOZE_OPTIONS.includes(value)) return
|
|
3597
|
+
if (!SNOOZE_OPTIONS.includes(value)) return {
|
|
3598
|
+
ok: false,
|
|
3599
|
+
reason: "bad-value"
|
|
3600
|
+
};
|
|
849
3601
|
const minutes = SNOOZE_MINUTES[value];
|
|
850
3602
|
return {
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
3603
|
+
ok: true,
|
|
3604
|
+
command: {
|
|
3605
|
+
kind: "snooze",
|
|
3606
|
+
deviceId: target.deviceId,
|
|
3607
|
+
minutes: minutes ?? null
|
|
3608
|
+
}
|
|
854
3609
|
};
|
|
855
3610
|
}
|
|
856
|
-
return
|
|
3611
|
+
return {
|
|
3612
|
+
ok: false,
|
|
3613
|
+
reason: "unknown-entity"
|
|
3614
|
+
};
|
|
857
3615
|
}
|
|
858
3616
|
/**
|
|
859
|
-
* The derived half.
|
|
860
|
-
*
|
|
861
|
-
*
|
|
3617
|
+
* The derived half.
|
|
3618
|
+
*
|
|
3619
|
+
* One capability can produce several entities — a cover is a state, two
|
|
3620
|
+
* positions and three verbs — so the match is against every id the CATALOG
|
|
3621
|
+
* builds for the cap, through the same two expressions it used
|
|
3622
|
+
* (`derivedEntityId` / `derivedExtraEntityId`). Anything derived from the
|
|
3623
|
+
* catalog rather than re-spelled here cannot address an entity that does
|
|
3624
|
+
* not exist, which is the failure the shared expressions were extracted to
|
|
3625
|
+
* end.
|
|
862
3626
|
*/
|
|
863
|
-
for (const
|
|
864
|
-
|
|
865
|
-
if (
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
3627
|
+
for (const capName of Object.keys(CAP_ENTITY_MAP)) {
|
|
3628
|
+
const route = CAP_COMMAND_ROUTES[capName];
|
|
3629
|
+
if (derivedEntityId(capName) === entity) {
|
|
3630
|
+
if (CAP_ENTITY_MAP[capName]?.writable !== true) return {
|
|
3631
|
+
ok: false,
|
|
3632
|
+
reason: "not-writable",
|
|
3633
|
+
capName
|
|
3634
|
+
};
|
|
3635
|
+
if (!target.boundCaps.includes(capName)) return {
|
|
3636
|
+
ok: false,
|
|
3637
|
+
reason: "cap-not-bound",
|
|
3638
|
+
capName
|
|
3639
|
+
};
|
|
3640
|
+
return build(route?.primary, target.deviceId, value, capName);
|
|
3641
|
+
}
|
|
3642
|
+
for (const [suffix, extra] of Object.entries(CAP_ENTITY_MAP[capName]?.extras ?? {})) {
|
|
3643
|
+
if (derivedExtraEntityId(capName, suffix) !== entity) continue;
|
|
3644
|
+
if (extra.writable !== true) return {
|
|
3645
|
+
ok: false,
|
|
3646
|
+
reason: "not-writable",
|
|
3647
|
+
capName
|
|
3648
|
+
};
|
|
3649
|
+
if (!target.boundCaps.includes(capName)) return {
|
|
3650
|
+
ok: false,
|
|
3651
|
+
reason: "cap-not-bound",
|
|
3652
|
+
capName
|
|
3653
|
+
};
|
|
3654
|
+
return build(route?.extras?.[suffix], target.deviceId, value, capName);
|
|
3655
|
+
}
|
|
3656
|
+
/**
|
|
3657
|
+
* Command buttons. A button carries no state, so there is no `writable`
|
|
3658
|
+
* to consult — its existence in the catalog IS the declaration, and the
|
|
3659
|
+
* route table is the other half. The payload (`PRESS`) is ignored on
|
|
3660
|
+
* purpose: HA sends whatever `payload_press` says and the verb is
|
|
3661
|
+
* already in the topic.
|
|
3662
|
+
*/
|
|
3663
|
+
for (const ref of capCommandEntities(capName)) {
|
|
3664
|
+
if (ref.entity !== entity) continue;
|
|
3665
|
+
if (!target.boundCaps.includes(capName)) return {
|
|
3666
|
+
ok: false,
|
|
3667
|
+
reason: "cap-not-bound",
|
|
3668
|
+
capName
|
|
3669
|
+
};
|
|
3670
|
+
return build(route?.commands?.[ref.suffix], target.deviceId, value, capName);
|
|
3671
|
+
}
|
|
3672
|
+
}
|
|
3673
|
+
return {
|
|
3674
|
+
ok: false,
|
|
3675
|
+
reason: "unknown-entity"
|
|
3676
|
+
};
|
|
3677
|
+
}
|
|
3678
|
+
/** Run a builder, or name the refusal. The one place `no-route` is decided. */
|
|
3679
|
+
function build(builder, deviceId, value, capName) {
|
|
3680
|
+
if (builder === void 0) return {
|
|
3681
|
+
ok: false,
|
|
3682
|
+
reason: "no-route",
|
|
3683
|
+
capName
|
|
3684
|
+
};
|
|
3685
|
+
const command = builder(deviceId, value);
|
|
3686
|
+
if (command === null) return {
|
|
3687
|
+
ok: false,
|
|
3688
|
+
reason: "bad-value",
|
|
3689
|
+
capName
|
|
3690
|
+
};
|
|
3691
|
+
return {
|
|
3692
|
+
ok: true,
|
|
3693
|
+
command
|
|
3694
|
+
};
|
|
3695
|
+
}
|
|
3696
|
+
/**
|
|
3697
|
+
* The camera half, listed rather than the capability half.
|
|
3698
|
+
*
|
|
3699
|
+
* The capability half is the one that grows — every Tier A row adds a
|
|
3700
|
+
* variant — and a narrowing written the other way round would silently send
|
|
3701
|
+
* each new one down the camera switch below. There are five camera
|
|
3702
|
+
* projections and there have been for the life of this file.
|
|
3703
|
+
*/
|
|
3704
|
+
function isCameraCommand(command) {
|
|
3705
|
+
switch (command.kind) {
|
|
3706
|
+
case "camera-switch":
|
|
3707
|
+
case "reboot":
|
|
3708
|
+
case "ptz-move":
|
|
3709
|
+
case "ptz-preset":
|
|
3710
|
+
case "snooze": return true;
|
|
3711
|
+
default: return false;
|
|
3712
|
+
}
|
|
3713
|
+
}
|
|
3714
|
+
/** Narrows an `ExportCommand` to the half {@link applyCapCommand} can make. */
|
|
3715
|
+
function isCapCommand(command) {
|
|
3716
|
+
return !isCameraCommand(command);
|
|
3717
|
+
}
|
|
3718
|
+
/**
|
|
3719
|
+
* Make the call the command names.
|
|
3720
|
+
*
|
|
3721
|
+
* Every branch names ONE method on ONE cap. Nothing here falls back to a
|
|
3722
|
+
* neighbouring cap: a device that declares `lock-control` but serves no
|
|
3723
|
+
* provider for it is refused with `no-provider`, and the caller logs the
|
|
3724
|
+
* drop rather than turning a lock into a switch.
|
|
3725
|
+
*/
|
|
3726
|
+
async function applyCapCommand(device, command) {
|
|
3727
|
+
switch (command.kind) {
|
|
3728
|
+
case "cap-switch":
|
|
3729
|
+
if (device.switch === void 0) return {
|
|
3730
|
+
ok: false,
|
|
3731
|
+
reason: "no-provider",
|
|
3732
|
+
capName: command.capName
|
|
3733
|
+
};
|
|
3734
|
+
await device.switch.setState({ on: command.on });
|
|
3735
|
+
return { ok: true };
|
|
3736
|
+
case "cap-lock":
|
|
3737
|
+
if (device.lockControl === void 0) return {
|
|
3738
|
+
ok: false,
|
|
3739
|
+
reason: "no-provider",
|
|
3740
|
+
capName: "lock-control"
|
|
3741
|
+
};
|
|
3742
|
+
if (command.locked) await device.lockControl.lock({});
|
|
3743
|
+
else await device.lockControl.unlock({});
|
|
3744
|
+
return { ok: true };
|
|
3745
|
+
case "cap-lock-open":
|
|
3746
|
+
if (device.lockControl === void 0) return {
|
|
3747
|
+
ok: false,
|
|
3748
|
+
reason: "no-provider",
|
|
3749
|
+
capName: "lock-control"
|
|
3750
|
+
};
|
|
3751
|
+
/**
|
|
3752
|
+
* A lock without a latch REFUSES this, and the refusal is the answer.
|
|
3753
|
+
* `lock-control` declares `open` unconditionally while the latch is a
|
|
3754
|
+
* property of the hardware, so the provider is the only thing that
|
|
3755
|
+
* knows — reporting success over its refusal would leave Home
|
|
3756
|
+
* Assistant showing a door that opened and did not.
|
|
3757
|
+
*/
|
|
3758
|
+
try {
|
|
3759
|
+
await device.lockControl.open({});
|
|
3760
|
+
} catch (err) {
|
|
3761
|
+
return {
|
|
3762
|
+
ok: false,
|
|
3763
|
+
reason: "refused",
|
|
3764
|
+
capName: "lock-control",
|
|
3765
|
+
error: err instanceof Error ? err.message : String(err)
|
|
3766
|
+
};
|
|
3767
|
+
}
|
|
3768
|
+
return { ok: true };
|
|
3769
|
+
case "cap-brightness":
|
|
3770
|
+
if (device.brightness === void 0) return {
|
|
3771
|
+
ok: false,
|
|
3772
|
+
reason: "no-provider",
|
|
3773
|
+
capName: "brightness"
|
|
3774
|
+
};
|
|
3775
|
+
await device.brightness.setBrightness({ percentage: command.percentage });
|
|
3776
|
+
return { ok: true };
|
|
3777
|
+
case "cap-alarm":
|
|
3778
|
+
if (device.alarmPanel === void 0) return {
|
|
3779
|
+
ok: false,
|
|
3780
|
+
reason: "no-provider",
|
|
3781
|
+
capName: "alarm-panel"
|
|
3782
|
+
};
|
|
3783
|
+
/**
|
|
3784
|
+
* **The arm can be REFUSED.** CamStack's own panel throws
|
|
3785
|
+
* `NcAlarmArmRefusedError` when a contact the mode covers is open,
|
|
3786
|
+
* and that refusal is the operator's answer — reporting success
|
|
3787
|
+
* over it would leave Home Assistant showing `armed_away` on a
|
|
3788
|
+
* house with an open door. It is caught HERE rather than left to
|
|
3789
|
+
* the caller's generic catch so the reason is named `refused` and
|
|
3790
|
+
* the panel's own message survives into the log.
|
|
3791
|
+
*/
|
|
3792
|
+
try {
|
|
3793
|
+
switch (command.action.kind) {
|
|
3794
|
+
case "arm":
|
|
3795
|
+
await device.alarmPanel.arm({ mode: command.action.mode });
|
|
3796
|
+
break;
|
|
3797
|
+
case "disarm":
|
|
3798
|
+
await device.alarmPanel.disarm({});
|
|
3799
|
+
break;
|
|
3800
|
+
case "trigger":
|
|
3801
|
+
await device.alarmPanel.trigger({});
|
|
3802
|
+
break;
|
|
3803
|
+
}
|
|
3804
|
+
} catch (err) {
|
|
3805
|
+
return {
|
|
3806
|
+
ok: false,
|
|
3807
|
+
reason: "refused",
|
|
3808
|
+
capName: "alarm-panel",
|
|
3809
|
+
error: err instanceof Error ? err.message : String(err)
|
|
3810
|
+
};
|
|
3811
|
+
}
|
|
3812
|
+
return { ok: true };
|
|
3813
|
+
case "cap-button":
|
|
3814
|
+
if (device.button === void 0) return {
|
|
3815
|
+
ok: false,
|
|
3816
|
+
reason: "no-provider",
|
|
3817
|
+
capName: command.capName
|
|
3818
|
+
};
|
|
3819
|
+
await device.button.press({});
|
|
3820
|
+
return { ok: true };
|
|
3821
|
+
case "cover-position": return call(device.cover, "cover", (cover) => cover.setPosition({ position: command.position }));
|
|
3822
|
+
case "cover-tilt": return call(device.cover, "cover", (cover) => cover.setTiltPosition({ tiltPosition: command.tiltPosition }));
|
|
3823
|
+
case "cover-verb": return call(device.cover, "cover", (cover) => {
|
|
3824
|
+
switch (command.verb) {
|
|
3825
|
+
case "open": return cover.open({});
|
|
3826
|
+
case "close": return cover.close({});
|
|
3827
|
+
case "stop": return cover.stop({});
|
|
3828
|
+
}
|
|
3829
|
+
});
|
|
3830
|
+
case "climate-mode": return call(device.climateControl, "climate-control", (climate) => climate.setMode({ mode: command.mode }));
|
|
3831
|
+
case "climate-target": return call(device.climateControl, "climate-control", (climate) => climate.setTarget({ target: command.target }));
|
|
3832
|
+
case "climate-fan-mode": return call(device.climateControl, "climate-control", (climate) => climate.setFanMode({ fanMode: command.fanMode }));
|
|
3833
|
+
case "climate-preset": return call(device.climateControl, "climate-control", (climate) => climate.setPreset({ preset: command.preset }));
|
|
3834
|
+
/**
|
|
3835
|
+
* The one read-then-write in this file, and the reason is the cap's
|
|
3836
|
+
* signature rather than a preference: `setTargetRange` takes both edges
|
|
3837
|
+
* and Home Assistant sends them one at a time. The OTHER edge is read
|
|
3838
|
+
* from `getStatus` — the provider's own current answer — and a `null`
|
|
3839
|
+
* there is REFUSED, because a dual-setpoint device that reports no range
|
|
3840
|
+
* has no range to move one edge of, and substituting the single `target`
|
|
3841
|
+
* would silently collapse the band to a point.
|
|
3842
|
+
*/
|
|
3843
|
+
case "climate-target-edge": return call(device.climateControl, "climate-control", async (climate) => {
|
|
3844
|
+
const status = await climate.getStatus({});
|
|
3845
|
+
if (status === null) throw new Error("the device reports no climate status, so setTargetRange has no range");
|
|
3846
|
+
const low = command.edge === "low" ? command.value : status.targetLow;
|
|
3847
|
+
const high = command.edge === "high" ? command.value : status.targetHigh;
|
|
3848
|
+
if (low === null || high === null) throw new Error(`the device reports no ${command.edge === "low" ? "high" : "low"} setpoint, so setTargetRange has nothing to preserve`);
|
|
3849
|
+
await climate.setTargetRange({
|
|
3850
|
+
targetLow: low,
|
|
3851
|
+
targetHigh: high
|
|
3852
|
+
});
|
|
3853
|
+
});
|
|
3854
|
+
case "climate-target-humidity": return call(device.climateControl, "climate-control", (climate) => climate.setTargetHumidity({ targetHumidity: command.targetHumidity }));
|
|
3855
|
+
case "climate-swing": return call(device.climateControl, "climate-control", (climate) => command.axis === "vertical" ? climate.setSwingVertical({ on: command.on }) : climate.setSwingHorizontal({ on: command.on }));
|
|
3856
|
+
case "fan-percentage": return call(device.fanControl, "fan-control", (fan) => fan.setPercentage({ percentage: command.percentage }));
|
|
3857
|
+
case "fan-preset": return call(device.fanControl, "fan-control", (fan) => fan.setPreset({ preset: command.preset }));
|
|
3858
|
+
case "fan-oscillating": return call(device.fanControl, "fan-control", (fan) => fan.setOscillating({ oscillating: command.oscillating }));
|
|
3859
|
+
case "fan-direction": return call(device.fanControl, "fan-control", (fan) => fan.setDirection({ direction: command.direction }));
|
|
3860
|
+
case "humidifier-on": return call(device.humidifier, "humidifier", (humidifier) => humidifier.setOn({ on: command.on }));
|
|
3861
|
+
case "humidifier-humidity": return call(device.humidifier, "humidifier", (humidifier) => humidifier.setTargetHumidity({ humidity: command.humidity }));
|
|
3862
|
+
case "humidifier-mode": return call(device.humidifier, "humidifier", (humidifier) => humidifier.setMode({ mode: command.mode }));
|
|
3863
|
+
case "water-heater-temp": return call(device.waterHeater, "water-heater", (heater) => heater.setTargetTemp({ temp: command.temp }));
|
|
3864
|
+
case "water-heater-away": return call(device.waterHeater, "water-heater", (heater) => heater.setAway({ on: command.on }));
|
|
3865
|
+
case "water-heater-mode": return call(device.waterHeater, "water-heater", (heater) => heater.setOperationMode({ mode: command.mode }));
|
|
3866
|
+
case "valve-position": return call(device.valve, "valve", (valve) => valve.setPosition({ position: command.position }));
|
|
3867
|
+
case "valve-verb": return call(device.valve, "valve", (valve) => {
|
|
3868
|
+
switch (command.verb) {
|
|
3869
|
+
case "open": return valve.open({});
|
|
3870
|
+
case "close": return valve.close({});
|
|
3871
|
+
case "stop": return valve.stop({});
|
|
3872
|
+
}
|
|
3873
|
+
});
|
|
3874
|
+
case "vacuum-verb": return call(device.vacuumControl, "vacuum-control", (vacuum) => {
|
|
3875
|
+
switch (command.verb) {
|
|
3876
|
+
case "start": return vacuum.start({});
|
|
3877
|
+
case "pause": return vacuum.pause({});
|
|
3878
|
+
case "stop": return vacuum.stop({});
|
|
3879
|
+
case "return-to-base": return vacuum.returnToBase({});
|
|
3880
|
+
case "locate": return vacuum.locate({});
|
|
3881
|
+
}
|
|
3882
|
+
});
|
|
3883
|
+
case "vacuum-fan-speed": return call(device.vacuumControl, "vacuum-control", (vacuum) => vacuum.setFanSpeed({ speed: command.speed }));
|
|
3884
|
+
case "mower-verb": return call(device.lawnMowerControl, "lawn-mower-control", (mower) => {
|
|
3885
|
+
switch (command.verb) {
|
|
3886
|
+
case "start": return mower.startMowing({});
|
|
3887
|
+
case "pause": return mower.pause({});
|
|
3888
|
+
case "dock": return mower.dock({});
|
|
3889
|
+
}
|
|
3890
|
+
});
|
|
3891
|
+
case "media-verb": return call(device.mediaPlayer, "media-player", (media) => {
|
|
3892
|
+
switch (command.verb) {
|
|
3893
|
+
case "play": return media.play({});
|
|
3894
|
+
case "pause": return media.pause({});
|
|
3895
|
+
case "stop": return media.stop({});
|
|
3896
|
+
case "next": return media.next({});
|
|
3897
|
+
case "previous": return media.previous({});
|
|
3898
|
+
}
|
|
3899
|
+
});
|
|
3900
|
+
case "media-volume": return call(device.mediaPlayer, "media-player", (media) => media.setVolume({ volumeLevel: command.volumeLevel }));
|
|
3901
|
+
case "media-mute": return call(device.mediaPlayer, "media-player", (media) => media.setMute({ muted: command.muted }));
|
|
3902
|
+
case "media-shuffle": return call(device.mediaPlayer, "media-player", (media) => media.setShuffle({ shuffle: command.shuffle }));
|
|
3903
|
+
case "media-repeat": return call(device.mediaPlayer, "media-player", (media) => media.setRepeat({ repeat: command.repeat }));
|
|
3904
|
+
case "media-source": return call(device.mediaPlayer, "media-player", (media) => media.selectSource({ source: command.source }));
|
|
3905
|
+
case "color-mireds": return call(device.color, "color", (color) => color.setColor({ color: {
|
|
3906
|
+
mode: "mired",
|
|
3907
|
+
mireds: command.mireds
|
|
3908
|
+
} }));
|
|
3909
|
+
case "script-verb": return call(device.scriptRunner, "script-runner", (script) => command.verb === "run" ? script.run({}) : script.stop({}));
|
|
3910
|
+
case "automation-enabled": return call(device.automationControl, "automation-control", (automation) => command.enabled ? automation.enable({}) : automation.disable({}));
|
|
3911
|
+
case "automation-trigger": return call(device.automationControl, "automation-control", (automation) => automation.trigger({}));
|
|
3912
|
+
}
|
|
3913
|
+
}
|
|
3914
|
+
/**
|
|
3915
|
+
* One capability call, with the two outcomes that are not success.
|
|
3916
|
+
*
|
|
3917
|
+
* `no-provider` is a device that DECLARES the cap and serves nothing —
|
|
3918
|
+
* distinct from a throw, which is the device REFUSING. The distinction is the
|
|
3919
|
+
* whole reason `alarm-panel` grew its own try/catch first: an arm the panel
|
|
3920
|
+
* turns down (`NcAlarmArmRefusedError`, a contact the mode covers is open) is
|
|
3921
|
+
* the operator's answer and must reach them with the panel's own words, not
|
|
3922
|
+
* as an optimistic success. Every Tier A method can refuse the same way — a
|
|
3923
|
+
* cover already moving, a vacuum with an empty tank, a climate mode the unit
|
|
3924
|
+
* does not have in `availableModes` — so the handling is here rather than
|
|
3925
|
+
* repeated per branch.
|
|
3926
|
+
*/
|
|
3927
|
+
async function call(slice, capName, make) {
|
|
3928
|
+
if (slice === void 0) return {
|
|
3929
|
+
ok: false,
|
|
3930
|
+
reason: "no-provider",
|
|
3931
|
+
capName
|
|
3932
|
+
};
|
|
3933
|
+
try {
|
|
3934
|
+
await make(slice);
|
|
3935
|
+
} catch (err) {
|
|
874
3936
|
return {
|
|
875
|
-
|
|
876
|
-
|
|
3937
|
+
ok: false,
|
|
3938
|
+
reason: "refused",
|
|
877
3939
|
capName,
|
|
878
|
-
|
|
3940
|
+
error: err instanceof Error ? err.message : String(err)
|
|
879
3941
|
};
|
|
880
3942
|
}
|
|
881
|
-
return
|
|
3943
|
+
return { ok: true };
|
|
882
3944
|
}
|
|
883
3945
|
/**
|
|
884
3946
|
* The ONE derivation of "a signed, expiring URL".
|
|
@@ -1363,6 +4425,110 @@ function bool(value) {
|
|
|
1363
4425
|
return value ? "true" : "false";
|
|
1364
4426
|
}
|
|
1365
4427
|
/**
|
|
4428
|
+
* A value → the string Home Assistant's platform expects.
|
|
4429
|
+
*
|
|
4430
|
+
* Exhaustive over `HaPlatform` so a new platform cannot be added
|
|
4431
|
+
* without deciding what its values look like. Every value is a STRING:
|
|
4432
|
+
* the component lowercases binary values verbatim and a JSON number or
|
|
4433
|
+
* boolean raises inside its state callback, which it swallows — so the
|
|
4434
|
+
* entity silently never updates again.
|
|
4435
|
+
*/
|
|
4436
|
+
function renderCapValue(mapping, raw) {
|
|
4437
|
+
switch (mapping.platform) {
|
|
4438
|
+
case "binary_sensor":
|
|
4439
|
+
case "switch": return typeof raw === "boolean" ? bool(raw) : null;
|
|
4440
|
+
case "number": return typeof raw === "number" && Number.isFinite(raw) ? String(raw) : null;
|
|
4441
|
+
case "sensor":
|
|
4442
|
+
if (mapping.deviceClass === "timestamp") {
|
|
4443
|
+
if (typeof raw === "number" && Number.isFinite(raw)) return iso(raw);
|
|
4444
|
+
return typeof raw === "string" ? raw : null;
|
|
4445
|
+
}
|
|
4446
|
+
if (typeof raw === "number") return Number.isFinite(raw) ? String(raw) : null;
|
|
4447
|
+
if (typeof raw === "string") return raw;
|
|
4448
|
+
return typeof raw === "boolean" ? bool(raw) : null;
|
|
4449
|
+
case "select":
|
|
4450
|
+
case "alarm_control_panel":
|
|
4451
|
+
case "image":
|
|
4452
|
+
case "camera": return typeof raw === "string" ? raw : null;
|
|
4453
|
+
case "button": return null;
|
|
4454
|
+
case "cover":
|
|
4455
|
+
case "climate":
|
|
4456
|
+
case "lock":
|
|
4457
|
+
case "fan":
|
|
4458
|
+
case "vacuum":
|
|
4459
|
+
case "valve":
|
|
4460
|
+
case "humidifier":
|
|
4461
|
+
case "water_heater":
|
|
4462
|
+
case "media_player":
|
|
4463
|
+
/**
|
|
4464
|
+
* A native platform carries no value of its own. It is built from
|
|
4465
|
+
* the topics of the DEGRADED descriptors — which are the ones this
|
|
4466
|
+
* function renders — so `CAP_ENTITY_MAP` never names one, and a
|
|
4467
|
+
* descriptor that did would be publishing to a topic no entity
|
|
4468
|
+
* reads. `null` here is `unrenderable`, which the addon LOGS: the
|
|
4469
|
+
* loud failure is the point.
|
|
4470
|
+
*/
|
|
4471
|
+
return null;
|
|
4472
|
+
}
|
|
4473
|
+
}
|
|
4474
|
+
/**
|
|
4475
|
+
* One capability slice → the derived entity's value.
|
|
4476
|
+
*
|
|
4477
|
+
* **This is the push path every derived capability was missing.** The
|
|
4478
|
+
* addon used to handle four caps by hand (`device-status`, `battery`,
|
|
4479
|
+
* `doorbell`, `motion`) and the other 23 entries in `CAP_ENTITY_MAP` had
|
|
4480
|
+
* no push path at all — they moved only on the 300 s reconcile, and the
|
|
4481
|
+
* ones whose topics disagreed with the catalog never moved at all. This
|
|
4482
|
+
* is descriptor-driven, so a capability T3 adds to `CAP_ENTITY_MAP`
|
|
4483
|
+
* arrives with its push path already built.
|
|
4484
|
+
*/
|
|
4485
|
+
function projectCapSlice(deviceKey, capName, slice) {
|
|
4486
|
+
const mapping = CAP_ENTITY_MAP[capName];
|
|
4487
|
+
if (mapping === void 0) return {
|
|
4488
|
+
kind: "no-entity",
|
|
4489
|
+
capName
|
|
4490
|
+
};
|
|
4491
|
+
const entity = derivedEntityId(capName);
|
|
4492
|
+
const values = [];
|
|
4493
|
+
/**
|
|
4494
|
+
* Every entity the capability produces, not just its primary. A `cover`
|
|
4495
|
+
* is a state AND a position, and projecting only the first would leave
|
|
4496
|
+
* the second in Home Assistant's registry with nothing arriving on it —
|
|
4497
|
+
* which is precisely the defect the descriptor table replaced.
|
|
4498
|
+
*/
|
|
4499
|
+
for (const ref of capEntities(capName)) {
|
|
4500
|
+
if (ref.descriptor.platform === "button") continue;
|
|
4501
|
+
if (!Object.prototype.hasOwnProperty.call(slice, ref.descriptor.field)) return {
|
|
4502
|
+
kind: "missing-field",
|
|
4503
|
+
capName,
|
|
4504
|
+
field: ref.descriptor.field,
|
|
4505
|
+
sliceFields: Object.keys(slice)
|
|
4506
|
+
};
|
|
4507
|
+
const raw = slice[ref.descriptor.field];
|
|
4508
|
+
if (raw === null || raw === void 0) continue;
|
|
4509
|
+
const rendered = renderCapValue(ref.descriptor, deriveCapValue(ref.descriptor.derive, raw));
|
|
4510
|
+
if (rendered === null) return {
|
|
4511
|
+
kind: "unrenderable",
|
|
4512
|
+
capName,
|
|
4513
|
+
field: ref.descriptor.field,
|
|
4514
|
+
valueType: typeof raw
|
|
4515
|
+
};
|
|
4516
|
+
values.push({
|
|
4517
|
+
topic: stateTopic(deviceKey, ref.entity),
|
|
4518
|
+
value: rendered
|
|
4519
|
+
});
|
|
4520
|
+
}
|
|
4521
|
+
if (mapping.platform === "button" && mapping.extras === void 0) return {
|
|
4522
|
+
kind: "no-entity",
|
|
4523
|
+
capName
|
|
4524
|
+
};
|
|
4525
|
+
return {
|
|
4526
|
+
kind: "values",
|
|
4527
|
+
entity,
|
|
4528
|
+
values
|
|
4529
|
+
};
|
|
4530
|
+
}
|
|
4531
|
+
/**
|
|
1366
4532
|
* Reachability.
|
|
1367
4533
|
*
|
|
1368
4534
|
* `device.sleeping` deliberately does NOT feed this: a battery camera in
|
|
@@ -1889,6 +5055,22 @@ function projectSynthetic(input, nowMs) {
|
|
|
1889
5055
|
}
|
|
1890
5056
|
return values;
|
|
1891
5057
|
}
|
|
5058
|
+
//#endregion
|
|
5059
|
+
//#region src/ha-export/reconcile-fingerprint.ts
|
|
5060
|
+
function structureFingerprint(exported) {
|
|
5061
|
+
return JSON.stringify([...exported.keys()].sort().map((key) => {
|
|
5062
|
+
const entry = exported.get(key);
|
|
5063
|
+
return [
|
|
5064
|
+
key,
|
|
5065
|
+
entry?.brokerIds,
|
|
5066
|
+
entry?.plan
|
|
5067
|
+
];
|
|
5068
|
+
}));
|
|
5069
|
+
}
|
|
5070
|
+
/** Only an event-debounced pass with an unchanged structure may skip. */
|
|
5071
|
+
function shouldSkipAnnounce(reason, fingerprint, lastFingerprint) {
|
|
5072
|
+
return reason === "structure-changed" && fingerprint === lastFingerprint;
|
|
5073
|
+
}
|
|
1892
5074
|
/** The `BrokerInfo.kind` tag the Home Assistant provider stamps. */
|
|
1893
5075
|
var HA_BROKER_KIND = "home-assistant";
|
|
1894
5076
|
/**
|
|
@@ -1975,6 +5157,17 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
1975
5157
|
/** A reason asked for while one was running. Re-run, never dropped. */
|
|
1976
5158
|
reconcilePending = null;
|
|
1977
5159
|
lastError;
|
|
5160
|
+
/**
|
|
5161
|
+
* Fingerprint of the last ANNOUNCED structure. An event-debounced
|
|
5162
|
+
* reconcile that rebuilds an identical structure skips the announce and
|
|
5163
|
+
* the full state push: a flapping camera (RTSP retry every 4 s emits a
|
|
5164
|
+
* device event per attempt) was driving a full 2.7 s reconcile every
|
|
5165
|
+
* ~13 s, forever. Only the 'structure-changed' reason short-circuits —
|
|
5166
|
+
* 'boot', 'periodic' and config-driven passes always announce, so the
|
|
5167
|
+
* D8/D11 contract (events are an optimisation, never a substitute)
|
|
5168
|
+
* still holds through the periodic pass.
|
|
5169
|
+
*/
|
|
5170
|
+
lastStructureFingerprint;
|
|
1978
5171
|
entityCount = 0;
|
|
1979
5172
|
/** deviceId → the `lastPressedAt` already rung, so a re-read does not ring again. */
|
|
1980
5173
|
lastDoorbellPressAt = /* @__PURE__ */ new Map();
|
|
@@ -1982,6 +5175,15 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
1982
5175
|
doorbellReleaseTimers = /* @__PURE__ */ new Map();
|
|
1983
5176
|
unclassified = [];
|
|
1984
5177
|
/**
|
|
5178
|
+
* What each Home Assistant's custom component can BUILD.
|
|
5179
|
+
*
|
|
5180
|
+
* The hub and the component ship on different trains — the operator
|
|
5181
|
+
* updates the integration through HACS when they decide to — so a
|
|
5182
|
+
* platform this hub knows about is not one the component has. Refreshed
|
|
5183
|
+
* at the top of every reconcile, before any plan is built.
|
|
5184
|
+
*/
|
|
5185
|
+
componentSupport = new ComponentSupportTracker();
|
|
5186
|
+
/**
|
|
1985
5187
|
* The rules as of the last reconcile, so a command arriving on
|
|
1986
5188
|
* `rule_<slug>` can be turned back into the rule id it came from. Refreshed
|
|
1987
5189
|
* whenever the synthetic devices are, never written to.
|
|
@@ -2116,6 +5318,19 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
2116
5318
|
this.reconcileSoon();
|
|
2117
5319
|
});
|
|
2118
5320
|
}
|
|
5321
|
+
/**
|
|
5322
|
+
* A slice changed → the entities that read it.
|
|
5323
|
+
*
|
|
5324
|
+
* Two halves, exactly as the catalog has two halves. `device-status`
|
|
5325
|
+
* and `battery` are projected by hand for BOTH kinds — their topics are
|
|
5326
|
+
* the same on a camera and on a base device, and `battery` deliberately
|
|
5327
|
+
* publishes nothing when the device reports a low-battery INDICATOR
|
|
5328
|
+
* rather than a level, which a descriptor cannot express. Everything
|
|
5329
|
+
* else on a camera is a projection of pipeline output, and everything
|
|
5330
|
+
* else on a base device is derived from `CAP_ENTITY_MAP` — the same
|
|
5331
|
+
* branch `buildDevicePlan` takes, so the value can only land on an
|
|
5332
|
+
* entity the plan actually built.
|
|
5333
|
+
*/
|
|
2119
5334
|
onSliceChanged(data) {
|
|
2120
5335
|
const deviceId = readNumber(data, "deviceId");
|
|
2121
5336
|
const capName = readString(data, "capName");
|
|
@@ -2123,6 +5338,19 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
2123
5338
|
if (deviceId === null || capName === null || slice === null) return;
|
|
2124
5339
|
const deviceKey = this.keyByDeviceId.get(deviceId);
|
|
2125
5340
|
if (deviceKey === void 0) return;
|
|
5341
|
+
if (capName !== "device-status" && capName !== "battery" && !this.isCamera(deviceKey)) {
|
|
5342
|
+
this.pushDerivedCap(deviceId, deviceKey, capName, slice);
|
|
5343
|
+
return;
|
|
5344
|
+
}
|
|
5345
|
+
/**
|
|
5346
|
+
* The two capabilities a CAMERA carries from the derived half. Same
|
|
5347
|
+
* descriptor, same entity id, same projector — the camera catalog
|
|
5348
|
+
* simply builds the component instead of `buildDerivedPlan`.
|
|
5349
|
+
*/
|
|
5350
|
+
if (CAMERA_DERIVED_CAPS.includes(capName)) {
|
|
5351
|
+
this.pushDerivedCap(deviceId, deviceKey, capName, slice);
|
|
5352
|
+
return;
|
|
5353
|
+
}
|
|
2126
5354
|
if (capName === "device-status") {
|
|
2127
5355
|
const online = slice["online"];
|
|
2128
5356
|
if (typeof online === "boolean") this.push(deviceId, projectDeviceStatus(deviceKey, online));
|
|
@@ -2186,6 +5414,61 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
2186
5414
|
}));
|
|
2187
5415
|
}
|
|
2188
5416
|
}
|
|
5417
|
+
/** Camera or base kind — the same question `buildDevicePlan` asks. */
|
|
5418
|
+
isCamera(deviceKey) {
|
|
5419
|
+
return this.exported.get(deviceKey)?.target.type === "camera";
|
|
5420
|
+
}
|
|
5421
|
+
/**
|
|
5422
|
+
* The generic derived push: any capability in `CAP_ENTITY_MAP`.
|
|
5423
|
+
*
|
|
5424
|
+
* Every branch that produces no value SAYS so, once per
|
|
5425
|
+
* (device, cap, field): an entity that exists and never receives a
|
|
5426
|
+
* value used to be indistinguishable from an entity nothing had
|
|
5427
|
+
* happened to, and that is how 23 of the 27 mapped capabilities went
|
|
5428
|
+
* unnoticed with no push path at all. Once, because a slice that
|
|
5429
|
+
* changes every second must not turn a catalog defect into a log
|
|
5430
|
+
* flood — the first line already carries everything the fix needs.
|
|
5431
|
+
*/
|
|
5432
|
+
pushDerivedCap(deviceId, deviceKey, capName, slice) {
|
|
5433
|
+
const projection = projectCapSlice(deviceKey, capName, slice);
|
|
5434
|
+
switch (projection.kind) {
|
|
5435
|
+
case "values":
|
|
5436
|
+
if (projection.values.length > 0) this.push(deviceId, projection.values);
|
|
5437
|
+
return;
|
|
5438
|
+
case "no-entity": return;
|
|
5439
|
+
case "missing-field":
|
|
5440
|
+
this.warnOnce(deviceId, capName, projection.field, {
|
|
5441
|
+
message: "ha-export: a derived entity reads a field its slice does not carry",
|
|
5442
|
+
meta: {
|
|
5443
|
+
capName,
|
|
5444
|
+
field: projection.field,
|
|
5445
|
+
sliceFields: projection.sliceFields
|
|
5446
|
+
}
|
|
5447
|
+
});
|
|
5448
|
+
return;
|
|
5449
|
+
case "unrenderable":
|
|
5450
|
+
this.warnOnce(deviceId, capName, projection.field, {
|
|
5451
|
+
message: "ha-export: a derived entity cannot render the value its slice carries",
|
|
5452
|
+
meta: {
|
|
5453
|
+
capName,
|
|
5454
|
+
field: projection.field,
|
|
5455
|
+
valueType: projection.valueType
|
|
5456
|
+
}
|
|
5457
|
+
});
|
|
5458
|
+
return;
|
|
5459
|
+
}
|
|
5460
|
+
}
|
|
5461
|
+
/** deviceId:cap:field already reported. Bounded by devices × mapped caps. */
|
|
5462
|
+
warnedCapFields = /* @__PURE__ */ new Set();
|
|
5463
|
+
warnOnce(deviceId, capName, field, line) {
|
|
5464
|
+
const key = `${deviceId}:${capName}:${field}`;
|
|
5465
|
+
if (this.warnedCapFields.has(key)) return;
|
|
5466
|
+
this.warnedCapFields.add(key);
|
|
5467
|
+
this.ctx.logger.warn(line.message, {
|
|
5468
|
+
tags: { deviceId },
|
|
5469
|
+
meta: line.meta
|
|
5470
|
+
});
|
|
5471
|
+
}
|
|
2189
5472
|
onOnlineChanged(data, online) {
|
|
2190
5473
|
const deviceId = readNumber(data, "deviceId");
|
|
2191
5474
|
if (deviceId === null) return;
|
|
@@ -2199,6 +5482,17 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
2199
5482
|
if (deviceId === null || typeof detected !== "boolean") return;
|
|
2200
5483
|
const deviceKey = this.keyByDeviceId.get(deviceId);
|
|
2201
5484
|
if (deviceKey === void 0) return;
|
|
5485
|
+
/**
|
|
5486
|
+
* A base device's motion entity is `motion`, a camera's is
|
|
5487
|
+
* `motion_detected` + `triggered`. The cap event is the same one, so
|
|
5488
|
+
* the projection has to follow the device's own half of the catalog —
|
|
5489
|
+
* publishing the camera topics for a PIR sensor addressed three
|
|
5490
|
+
* entities the sensor's plan never built.
|
|
5491
|
+
*/
|
|
5492
|
+
if (!this.isCamera(deviceKey)) {
|
|
5493
|
+
this.pushDerivedCap(deviceId, deviceKey, "motion", { detected });
|
|
5494
|
+
return;
|
|
5495
|
+
}
|
|
2202
5496
|
const timestamp = readNumber(data, "timestamp") ?? Date.now();
|
|
2203
5497
|
this.push(deviceId, projectMotion(deviceKey, {
|
|
2204
5498
|
detected,
|
|
@@ -2390,6 +5684,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
2390
5684
|
try {
|
|
2391
5685
|
await this.refreshBrokers();
|
|
2392
5686
|
await this.refreshMediaBaseUrl();
|
|
5687
|
+
await this.refreshComponentSupport();
|
|
2393
5688
|
const enabled = this.enabledBrokerIds();
|
|
2394
5689
|
const devices = await this.ctx.api.deviceManager.listAll.query({});
|
|
2395
5690
|
const snapshots = await this.ctx.api.deviceState.getAllSnapshots.query({});
|
|
@@ -2397,6 +5692,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
2397
5692
|
const exported = /* @__PURE__ */ new Map();
|
|
2398
5693
|
const keyByDeviceId = /* @__PURE__ */ new Map();
|
|
2399
5694
|
const allCaps = /* @__PURE__ */ new Set();
|
|
5695
|
+
const exportedDeviceLog = [];
|
|
2400
5696
|
let entityCount = 0;
|
|
2401
5697
|
for (const deviceIdStr of exposedDeviceIds(this.config.membership, enabled)) {
|
|
2402
5698
|
const numericId = Number(deviceIdStr);
|
|
@@ -2420,8 +5716,15 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
2420
5716
|
}
|
|
2421
5717
|
const catalogDevice = await this.buildCatalogDevice(device, snapshots[String(numericId)]);
|
|
2422
5718
|
for (const cap of catalogDevice.boundCaps) allCaps.add(cap);
|
|
2423
|
-
const plan = buildDevicePlan(catalogDevice);
|
|
2424
5719
|
const brokerIds = brokersExposing(this.config.membership, deviceIdStr).filter((id) => enabled.includes(id));
|
|
5720
|
+
/**
|
|
5721
|
+
* The plan is built ONCE and announced to every broker that
|
|
5722
|
+
* receives the device, so it can only use platforms ALL of them
|
|
5723
|
+
* build — see `intersectSupport`. One instance on an old component
|
|
5724
|
+
* therefore holds its device back and nobody's entities silently
|
|
5725
|
+
* fail to arrive.
|
|
5726
|
+
*/
|
|
5727
|
+
const plan = buildDevicePlan(catalogDevice, { platforms: this.componentSupport.supportForAll(brokerIds) });
|
|
2425
5728
|
exported.set(plan.deviceKey, {
|
|
2426
5729
|
plan,
|
|
2427
5730
|
deviceId: numericId,
|
|
@@ -2430,7 +5733,8 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
2430
5733
|
type: device.type,
|
|
2431
5734
|
boundCaps: catalogDevice.boundCaps
|
|
2432
5735
|
},
|
|
2433
|
-
brokerIds
|
|
5736
|
+
brokerIds,
|
|
5737
|
+
streams: catalogDevice.streams ?? []
|
|
2434
5738
|
});
|
|
2435
5739
|
keyByDeviceId.set(numericId, plan.deviceKey);
|
|
2436
5740
|
entityCount += Object.keys(plan.cmps).length;
|
|
@@ -2439,16 +5743,16 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
2439
5743
|
* device being DROPPED; without this one an operator can see why
|
|
2440
5744
|
* a device is missing but cannot confirm which devices were
|
|
2441
5745
|
* actually exported, to which Home Assistant, with which
|
|
2442
|
-
* entities. Silence reads as "never happened" both ways.
|
|
5746
|
+
* entities. Silence reads as "never happened" both ways. Deferred
|
|
5747
|
+
* until the announce decision: a skipped identical pass must not
|
|
5748
|
+
* repeat the whole roster either.
|
|
2443
5749
|
*/
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
entities: Object.keys(plan.cmps).length
|
|
2451
|
-
}
|
|
5750
|
+
exportedDeviceLog.push({
|
|
5751
|
+
deviceId: numericId,
|
|
5752
|
+
deviceType: device.type,
|
|
5753
|
+
deviceKey: plan.deviceKey,
|
|
5754
|
+
brokers: brokerIds,
|
|
5755
|
+
entities: Object.keys(plan.cmps).length
|
|
2452
5756
|
});
|
|
2453
5757
|
}
|
|
2454
5758
|
/**
|
|
@@ -2469,7 +5773,8 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
2469
5773
|
type: "synthetic",
|
|
2470
5774
|
boundCaps: []
|
|
2471
5775
|
},
|
|
2472
|
-
brokerIds: [...enabled]
|
|
5776
|
+
brokerIds: [...enabled],
|
|
5777
|
+
streams: []
|
|
2473
5778
|
});
|
|
2474
5779
|
entityCount += Object.keys(plan.cmps).length;
|
|
2475
5780
|
}
|
|
@@ -2477,6 +5782,25 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
2477
5782
|
this.keyByDeviceId = keyByDeviceId;
|
|
2478
5783
|
this.entityCount = entityCount;
|
|
2479
5784
|
this.unclassified = unclassifiedCaps([...allCaps]);
|
|
5785
|
+
const fingerprint = structureFingerprint(exported);
|
|
5786
|
+
if (shouldSkipAnnounce(reason, fingerprint, this.lastStructureFingerprint)) {
|
|
5787
|
+
this.ctx.logger.debug("ha-export: structure unchanged, skipping announce", { meta: {
|
|
5788
|
+
devices: exported.size,
|
|
5789
|
+
entities: entityCount
|
|
5790
|
+
} });
|
|
5791
|
+
this.lastError = void 0;
|
|
5792
|
+
return;
|
|
5793
|
+
}
|
|
5794
|
+
this.lastStructureFingerprint = fingerprint;
|
|
5795
|
+
for (const line of exportedDeviceLog) this.ctx.logger.info("ha-export: exporting device", {
|
|
5796
|
+
tags: { deviceId: line.deviceId },
|
|
5797
|
+
meta: {
|
|
5798
|
+
deviceType: line.deviceType,
|
|
5799
|
+
deviceKey: line.deviceKey,
|
|
5800
|
+
brokers: line.brokers,
|
|
5801
|
+
entities: line.entities
|
|
5802
|
+
}
|
|
5803
|
+
});
|
|
2480
5804
|
await this.announce(exported);
|
|
2481
5805
|
await this.pushFullState(exported, snapshots);
|
|
2482
5806
|
if (synthetic !== null) await this.pushSynthetic(projectSynthetic(synthetic, Date.now()), [...enabled]);
|
|
@@ -2616,7 +5940,55 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
2616
5940
|
}));
|
|
2617
5941
|
}
|
|
2618
5942
|
}
|
|
5943
|
+
/**
|
|
5944
|
+
* The derived half, from the snapshot the reconcile already read.
|
|
5945
|
+
*
|
|
5946
|
+
* The reconcile is the CONTRACT — events are an optimisation over it
|
|
5947
|
+
* (D8) — so every entity it announces must also get a value from it.
|
|
5948
|
+
* It used to push only `device-status` and `battery`, which is why a
|
|
5949
|
+
* derived entity whose event path was missing stayed blank for ever
|
|
5950
|
+
* rather than for one interval.
|
|
5951
|
+
*/
|
|
5952
|
+
{
|
|
5953
|
+
/**
|
|
5954
|
+
* The derived half, and the two capabilities a CAMERA carries from
|
|
5955
|
+
* it (`CAMERA_DERIVED_CAPS`). A camera takes the hand-written
|
|
5956
|
+
* catalog, so every other cap slice on one addresses no component;
|
|
5957
|
+
* these two do, because the camera catalog builds them from the
|
|
5958
|
+
* same descriptor and the same entity id.
|
|
5959
|
+
*/
|
|
5960
|
+
const isCamera = entry.target.type === "camera";
|
|
5961
|
+
for (const [capName, capSlice] of Object.entries(snapshot)) {
|
|
5962
|
+
if (capName === "device-status" || capName === "battery") continue;
|
|
5963
|
+
if (isCamera && !CAMERA_DERIVED_CAPS.includes(capName)) continue;
|
|
5964
|
+
const projection = projectCapSlice(key, capName, capSlice);
|
|
5965
|
+
if (projection.kind === "values") values.push(...projection.values);
|
|
5966
|
+
else if (projection.kind === "missing-field") this.warnOnce(entry.deviceId, capName, projection.field, {
|
|
5967
|
+
message: "ha-export: a derived entity reads a field its slice does not carry",
|
|
5968
|
+
meta: {
|
|
5969
|
+
capName,
|
|
5970
|
+
field: projection.field,
|
|
5971
|
+
sliceFields: projection.sliceFields
|
|
5972
|
+
}
|
|
5973
|
+
});
|
|
5974
|
+
else if (projection.kind === "unrenderable") this.warnOnce(entry.deviceId, capName, projection.field, {
|
|
5975
|
+
message: "ha-export: a derived entity cannot render the value its slice carries",
|
|
5976
|
+
meta: {
|
|
5977
|
+
capName,
|
|
5978
|
+
field: projection.field,
|
|
5979
|
+
valueType: projection.valueType
|
|
5980
|
+
}
|
|
5981
|
+
});
|
|
5982
|
+
}
|
|
5983
|
+
}
|
|
2619
5984
|
if (entry.target.type === "camera") {
|
|
5985
|
+
/**
|
|
5986
|
+
* The live picture publishes NOTHING here, deliberately. A `camera`
|
|
5987
|
+
* entity's whole content is its `stream_target`, which travelled with
|
|
5988
|
+
* the announce; the component fetches the still and negotiates the
|
|
5989
|
+
* video itself. The url this block used to publish was read by
|
|
5990
|
+
* nobody — see `camera-entities.ts`.
|
|
5991
|
+
*/
|
|
2620
5992
|
const group = await this.ctx.api.pipelineOrchestrator.getCameraSwitches.query({ deviceId: entry.deviceId });
|
|
2621
5993
|
values.push(...projectCameraSwitches(key, group.switches.map((sw) => ({
|
|
2622
5994
|
id: sw.id,
|
|
@@ -2645,6 +6017,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
2645
6017
|
const zones = device.type === "camera" ? await this.loadZones(device.id) : [];
|
|
2646
6018
|
const switches = device.type === "camera" ? await this.loadSwitches(device.id) : [];
|
|
2647
6019
|
const ptzPresets = device.type === "camera" && boundCaps.includes("ptz") ? await this.loadPresets(device.id) : [];
|
|
6020
|
+
const streams = device.type === "camera" && boundCaps.includes("webrtc-session") ? await this.loadStreamChoices(device.id) : [];
|
|
2648
6021
|
const manufacturer = readString(device.metadata ?? {}, "manufacturer");
|
|
2649
6022
|
const model = readString(device.metadata ?? {}, "model");
|
|
2650
6023
|
return {
|
|
@@ -2659,9 +6032,48 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
2659
6032
|
zones,
|
|
2660
6033
|
switches,
|
|
2661
6034
|
ptzPresets,
|
|
2662
|
-
|
|
6035
|
+
streams,
|
|
6036
|
+
slices: Object.keys(snapshot),
|
|
6037
|
+
/**
|
|
6038
|
+
* The slice VALUES, for the native platforms only: a native
|
|
6039
|
+
* `climate` announces the modes the device accepts, and
|
|
6040
|
+
* `availableModes` is on the slice and nowhere else. See
|
|
6041
|
+
* `CatalogDevice.capSlices`.
|
|
6042
|
+
*/
|
|
6043
|
+
capSlices: snapshot
|
|
2663
6044
|
};
|
|
2664
6045
|
}
|
|
6046
|
+
/**
|
|
6047
|
+
* Every stream of the camera, as the hub's own picker lists them.
|
|
6048
|
+
*
|
|
6049
|
+
* `webrtcSession.listStreams` rather than
|
|
6050
|
+
* `cameraStreams.getProfileRtspEntries`: the component negotiates live
|
|
6051
|
+
* video through `webrtcSession.handleOffer` and needs the `target`, not
|
|
6052
|
+
* an address. Reading the RTSP entries instead cost this export three
|
|
6053
|
+
* things it no longer pays — a `hostname` argument (the broker binds
|
|
6054
|
+
* `127.0.0.1`, so a camera on a hub with no reachable base url got NO
|
|
6055
|
+
* camera entity at all), a refusal for a credentialed url, and a whole
|
|
6056
|
+
* value plane nothing consumed.
|
|
6057
|
+
*
|
|
6058
|
+
* `listStreams` also reports the camera's raw substreams, which the
|
|
6059
|
+
* profile view could not express.
|
|
6060
|
+
*/
|
|
6061
|
+
async loadStreamChoices(deviceId) {
|
|
6062
|
+
try {
|
|
6063
|
+
return (await this.ctx.api.webrtcSession.listStreams.query({ deviceId })).map((choice) => ({
|
|
6064
|
+
id: choice.id,
|
|
6065
|
+
label: choice.label,
|
|
6066
|
+
target: choice.target,
|
|
6067
|
+
...choice.resolution === null ? {} : { height: choice.resolution.height }
|
|
6068
|
+
}));
|
|
6069
|
+
} catch (err) {
|
|
6070
|
+
this.ctx.logger.warn("ha-export: could not read the camera streams, no camera entity", {
|
|
6071
|
+
tags: { deviceId },
|
|
6072
|
+
meta: { error: errMsg(err) }
|
|
6073
|
+
});
|
|
6074
|
+
return [];
|
|
6075
|
+
}
|
|
6076
|
+
}
|
|
2665
6077
|
async loadBoundCaps(deviceId) {
|
|
2666
6078
|
try {
|
|
2667
6079
|
return (await this.ctx.api.deviceManager.getBindings.query({ deviceId })).entries.map((entry) => entry.capName);
|
|
@@ -2769,6 +6181,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
2769
6181
|
if (wanted.has(brokerId)) continue;
|
|
2770
6182
|
await link.client.dispose();
|
|
2771
6183
|
this.links.delete(brokerId);
|
|
6184
|
+
this.componentSupport.forget(brokerId);
|
|
2772
6185
|
this.ctx.logger.info("ha-export: stopped exporting to a broker", { meta: { brokerId } });
|
|
2773
6186
|
}
|
|
2774
6187
|
for (const broker of haBrokers) {
|
|
@@ -2796,6 +6209,75 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
2796
6209
|
} });
|
|
2797
6210
|
}
|
|
2798
6211
|
}
|
|
6212
|
+
/**
|
|
6213
|
+
* Ask every live link what its component builds.
|
|
6214
|
+
*
|
|
6215
|
+
* Sequential and cheap — one GET per Home Assistant per reconcile, and
|
|
6216
|
+
* there is one Home Assistant on almost every installation. A link that
|
|
6217
|
+
* does not answer keeps whatever was negotiated last: the tracker treats
|
|
6218
|
+
* silence as no information, because the alternative is that one
|
|
6219
|
+
* timeout migrates every native entity back to a sensor.
|
|
6220
|
+
*/
|
|
6221
|
+
async refreshComponentSupport() {
|
|
6222
|
+
for (const [brokerId, link] of this.links) {
|
|
6223
|
+
const probe = await this.probeComponent(link);
|
|
6224
|
+
const change = this.componentSupport.observe(brokerId, probe);
|
|
6225
|
+
if (change.withheldDowngrade) {
|
|
6226
|
+
/**
|
|
6227
|
+
* D49. The read that DESTROYS work needs a second read to agree,
|
|
6228
|
+
* and the operator gets to see that this is what happened rather
|
|
6229
|
+
* than watching their entities migrate on one bad answer.
|
|
6230
|
+
*/
|
|
6231
|
+
this.ctx.logger.warn("ha-export: the Home Assistant component reports fewer platforms than before — holding the old set until a second read agrees", { meta: {
|
|
6232
|
+
brokerId,
|
|
6233
|
+
current: supportSignature(change.support),
|
|
6234
|
+
reported: probe.kind === "reported" ? supportSignature(probe.report.platforms) : "none"
|
|
6235
|
+
} });
|
|
6236
|
+
continue;
|
|
6237
|
+
}
|
|
6238
|
+
if (!change.changed) continue;
|
|
6239
|
+
this.ctx.logger.info("ha-export: negotiated the Home Assistant component platform set", { meta: {
|
|
6240
|
+
brokerId,
|
|
6241
|
+
componentVersion: probe.kind === "reported" ? probe.report.version ?? "unknown" : "pre-0.4.0",
|
|
6242
|
+
platforms: supportSignature(change.support)
|
|
6243
|
+
} });
|
|
6244
|
+
}
|
|
6245
|
+
}
|
|
6246
|
+
/**
|
|
6247
|
+
* One `GET /api/camstack/version`, turned into the three answers the
|
|
6248
|
+
* tracker distinguishes.
|
|
6249
|
+
*
|
|
6250
|
+
* A 404 is the OLD component saying it has no such endpoint, which is
|
|
6251
|
+
* information. Anything else — a timeout, a 500, a rotated token — is
|
|
6252
|
+
* not, and must not be read as one.
|
|
6253
|
+
*/
|
|
6254
|
+
async probeComponent(link) {
|
|
6255
|
+
try {
|
|
6256
|
+
const response = await fetch(`${link.baseUrl.replace(/\/+$/, "")}${COMPONENT_VERSION_PATH}`, {
|
|
6257
|
+
headers: { authorization: `Bearer ${link.token}` },
|
|
6258
|
+
signal: AbortSignal.timeout(1e4)
|
|
6259
|
+
});
|
|
6260
|
+
if (response.status === 404 || response.status === 405) return { kind: "absent" };
|
|
6261
|
+
if (!response.ok) return {
|
|
6262
|
+
kind: "unknown",
|
|
6263
|
+
error: `Home Assistant answered ${response.status}`
|
|
6264
|
+
};
|
|
6265
|
+
const report = parseComponentReport(await response.json());
|
|
6266
|
+
if (report === null) return {
|
|
6267
|
+
kind: "unknown",
|
|
6268
|
+
error: "the component answered without a platform list"
|
|
6269
|
+
};
|
|
6270
|
+
return {
|
|
6271
|
+
kind: "reported",
|
|
6272
|
+
report
|
|
6273
|
+
};
|
|
6274
|
+
} catch (err) {
|
|
6275
|
+
return {
|
|
6276
|
+
kind: "unknown",
|
|
6277
|
+
error: errMsg(err)
|
|
6278
|
+
};
|
|
6279
|
+
}
|
|
6280
|
+
}
|
|
2799
6281
|
async resolveBrokerConnection(brokerId, addonId) {
|
|
2800
6282
|
try {
|
|
2801
6283
|
const raw = await this.ctx.api.broker.getBrokerConfig.query({
|
|
@@ -2924,25 +6406,56 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
2924
6406
|
}
|
|
2925
6407
|
return;
|
|
2926
6408
|
}
|
|
2927
|
-
const
|
|
2928
|
-
if (
|
|
2929
|
-
|
|
6409
|
+
const resolution = resolveCommand(exported.target, parsed.entity, value);
|
|
6410
|
+
if (!resolution.ok) {
|
|
6411
|
+
/**
|
|
6412
|
+
* The reason is NAMED. "Unroutable" covered five different faults —
|
|
6413
|
+
* a typo in a topic, a capability the device does not declare, a
|
|
6414
|
+
* payload the platform cannot carry, and a control the catalog
|
|
6415
|
+
* advertised with no route behind it — and an operator watching a
|
|
6416
|
+
* switch snap back in Home Assistant could not tell which.
|
|
6417
|
+
*/
|
|
6418
|
+
this.ctx.logger.warn("ha-export: dropping a command it cannot route", {
|
|
2930
6419
|
tags: { deviceId: exported.deviceId },
|
|
2931
6420
|
meta: {
|
|
2932
6421
|
topic,
|
|
2933
|
-
entity: parsed.entity
|
|
6422
|
+
entity: parsed.entity,
|
|
6423
|
+
reason: resolution.reason,
|
|
6424
|
+
...resolution.capName !== void 0 ? { capName: resolution.capName } : {}
|
|
2934
6425
|
}
|
|
2935
6426
|
});
|
|
2936
6427
|
reply.status(422);
|
|
2937
|
-
reply.send({ error:
|
|
6428
|
+
reply.send({ error: `unroutable command (${resolution.reason})` });
|
|
2938
6429
|
return;
|
|
2939
6430
|
}
|
|
2940
|
-
const applied = await this.dispatch(command);
|
|
6431
|
+
const applied = await this.dispatch(resolution.command);
|
|
2941
6432
|
reply.status(applied ? 200 : 422);
|
|
2942
6433
|
reply.send(applied ? {} : { error: "command not applied" });
|
|
2943
6434
|
}
|
|
2944
6435
|
async dispatch(command) {
|
|
2945
6436
|
try {
|
|
6437
|
+
/**
|
|
6438
|
+
* The capability half goes through the route table that NAMED the
|
|
6439
|
+
* method — `lockControl.lock`, `brightness.setBrightness`,
|
|
6440
|
+
* `alarmPanel.arm`, `switch.setState`. It used to end here
|
|
6441
|
+
* regardless of the capability, calling `device.switch.setState`
|
|
6442
|
+
* for all of them, so a lock got `noProvider` and a 422 and a
|
|
6443
|
+
* brightness command never arrived at all.
|
|
6444
|
+
*/
|
|
6445
|
+
if (isCapCommand(command)) {
|
|
6446
|
+
const outcome = await applyCapCommand(await this.ctx.fetchDevice(command.deviceId), command);
|
|
6447
|
+
if (outcome.ok) return true;
|
|
6448
|
+
if (outcome.reason === "no-provider") return this.noProvider(command.deviceId, outcome.capName);
|
|
6449
|
+
this.ctx.logger.warn("ha-export: the device refused the command", {
|
|
6450
|
+
tags: { deviceId: command.deviceId },
|
|
6451
|
+
meta: {
|
|
6452
|
+
capName: outcome.capName,
|
|
6453
|
+
kind: command.kind,
|
|
6454
|
+
...outcome.error !== void 0 ? { error: outcome.error } : {}
|
|
6455
|
+
}
|
|
6456
|
+
});
|
|
6457
|
+
return false;
|
|
6458
|
+
}
|
|
2946
6459
|
switch (command.kind) {
|
|
2947
6460
|
case "camera-switch": {
|
|
2948
6461
|
const parsed = require_dist.CameraSwitchIdSchema.safeParse(command.switchId);
|
|
@@ -2987,18 +6500,6 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
2987
6500
|
return true;
|
|
2988
6501
|
}
|
|
2989
6502
|
case "snooze": return await this.applySnooze(command.deviceId, command.minutes);
|
|
2990
|
-
case "cap-switch": {
|
|
2991
|
-
const device = await this.ctx.fetchDevice(command.deviceId);
|
|
2992
|
-
if (device.switch === void 0) return this.noProvider(command.deviceId, "switch");
|
|
2993
|
-
await device.switch.setState({ on: command.on });
|
|
2994
|
-
return true;
|
|
2995
|
-
}
|
|
2996
|
-
case "cap-button": {
|
|
2997
|
-
const device = await this.ctx.fetchDevice(command.deviceId);
|
|
2998
|
-
if (device.button === void 0) return this.noProvider(command.deviceId, "button");
|
|
2999
|
-
await device.button.press({});
|
|
3000
|
-
return true;
|
|
3001
|
-
}
|
|
3002
6503
|
}
|
|
3003
6504
|
} catch (err) {
|
|
3004
6505
|
/**
|