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