@camstack/addon-provider-homeassistant 1.2.29 → 1.2.31
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-yXVqdsa3.js → dist-ByKdDxGp.js} +969 -78
- package/dist/{dist-D_VrlKNr.mjs → dist-CyjeVkIa.mjs} +969 -78
- package/dist/ha-export/ha-export.addon.js +528 -285
- package/dist/ha-export/ha-export.addon.mjs +528 -285
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
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-
|
|
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-CyjeVkIa.mjs";
|
|
2
2
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
3
3
|
//#region src/ha-export/topics.ts
|
|
4
4
|
/**
|
|
@@ -107,43 +107,57 @@ function toSlug(value) {
|
|
|
107
107
|
//#endregion
|
|
108
108
|
//#region src/ha-export/camera-entities.ts
|
|
109
109
|
/**
|
|
110
|
-
* The live `camera` entities — one per
|
|
110
|
+
* The live `camera` entities — one per STREAM of the camera.
|
|
111
111
|
*
|
|
112
|
-
*
|
|
113
|
-
* catalog never emitted one, so an operator's Home Assistant carried ~73
|
|
114
|
-
* entities about a camera and no picture of it. This module is that half:
|
|
115
|
-
* pure, so the URL policy below is tested rather than reasoned about.
|
|
112
|
+
* ── What was wrong, measured on the operator's Home Assistant ────────────
|
|
116
113
|
*
|
|
117
|
-
*
|
|
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.
|
|
118
121
|
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
* be able to leak it by accident. A refusal produces NO entity value; it
|
|
128
|
-
* never produces a redacted-looking URL that would then be dialled.
|
|
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.
|
|
129
130
|
*
|
|
130
|
-
*
|
|
131
|
-
* URLs against whatever address it was asked for. Home Assistant is not
|
|
132
|
-
* on the hub, so a loopback URL yields an entity that exists and never
|
|
133
|
-
* loads — the identical failure the image entities had before
|
|
134
|
-
* `publicBaseUrl` was added. The caller passes `hostname` explicitly
|
|
135
|
-
* (`getProfileRtspEntries({ deviceId, hostname })`) and this module
|
|
136
|
-
* REFUSES a loopback URL rather than publishing one, because "the picture
|
|
137
|
-
* never appears" is indistinguishable from "the camera is down" on a
|
|
138
|
-
* dashboard.
|
|
131
|
+
* ── What this module is now ──────────────────────────────────────────────
|
|
139
132
|
*
|
|
140
|
-
*
|
|
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.
|
|
141
139
|
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
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.
|
|
147
161
|
*/
|
|
148
162
|
/** Profile slots, in the order an operator reads them. */
|
|
149
163
|
var CAMERA_PROFILE_ORDER = [
|
|
@@ -151,143 +165,97 @@ var CAMERA_PROFILE_ORDER = [
|
|
|
151
165
|
"mid",
|
|
152
166
|
"low"
|
|
153
167
|
];
|
|
154
|
-
/**
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
"[::1]",
|
|
160
|
-
"0.0.0.0"
|
|
161
|
-
];
|
|
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
|
+
}
|
|
162
173
|
/**
|
|
163
|
-
*
|
|
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.
|
|
164
176
|
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
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.
|
|
169
184
|
*/
|
|
170
|
-
function
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
return {
|
|
176
|
-
ok: false,
|
|
177
|
-
reason: "unparseable"
|
|
178
|
-
};
|
|
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;
|
|
179
190
|
}
|
|
180
|
-
if (parsed.username.length > 0 || parsed.password.length > 0) return {
|
|
181
|
-
ok: false,
|
|
182
|
-
reason: "credentials-inline"
|
|
183
|
-
};
|
|
184
|
-
if (parsed.hostname.length === 0) return {
|
|
185
|
-
ok: false,
|
|
186
|
-
reason: "unparseable"
|
|
187
|
-
};
|
|
188
|
-
if (LOOPBACK_HOSTS.includes(parsed.hostname.toLowerCase())) return {
|
|
189
|
-
ok: false,
|
|
190
|
-
reason: "loopback-host"
|
|
191
|
-
};
|
|
192
|
-
return {
|
|
193
|
-
ok: true,
|
|
194
|
-
url
|
|
195
|
-
};
|
|
196
191
|
}
|
|
197
192
|
/**
|
|
198
|
-
* The
|
|
199
|
-
* media plane already resolved.
|
|
193
|
+
* The adaptive stream is the component's own entity and gets no component.
|
|
200
194
|
*
|
|
201
|
-
* The
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
* could disagree with the one the image entities use, and then half a
|
|
205
|
-
* camera's entities would load.
|
|
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.
|
|
206
198
|
*/
|
|
207
|
-
function
|
|
208
|
-
|
|
209
|
-
try {
|
|
210
|
-
const host = new URL(baseUrl).hostname;
|
|
211
|
-
if (host.length === 0 || LOOPBACK_HOSTS.includes(host.toLowerCase())) return null;
|
|
212
|
-
return host;
|
|
213
|
-
} catch {
|
|
214
|
-
return null;
|
|
215
|
-
}
|
|
199
|
+
function isAnnounced(target) {
|
|
200
|
+
return target.kind !== "adaptive";
|
|
216
201
|
}
|
|
217
|
-
/**
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
+
}
|
|
221
218
|
}
|
|
222
219
|
/**
|
|
223
|
-
*
|
|
224
|
-
* `high → mid → low` order, capped at {@link MAX_CAMERA_PROFILES}.
|
|
220
|
+
* Enabled by default: the assigned profiles.
|
|
225
221
|
*
|
|
226
|
-
* A
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
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.
|
|
230
226
|
*/
|
|
231
|
-
function
|
|
232
|
-
return
|
|
227
|
+
function enabledByDefaultFor(target) {
|
|
228
|
+
return target.kind !== "cam-stream";
|
|
233
229
|
}
|
|
234
230
|
/**
|
|
235
|
-
*
|
|
231
|
+
* The label an operator reads on a card.
|
|
236
232
|
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
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.
|
|
243
239
|
*/
|
|
244
|
-
function
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
const slug = toSlug(entry.profile);
|
|
248
|
-
for (const muted of [false, true]) entities.push({
|
|
249
|
-
entity: muted ? `stream_${slug}_muted` : `stream_${slug}`,
|
|
250
|
-
platform: "camera",
|
|
251
|
-
label: muted ? `Stream ${entry.profile} (muted)` : `Stream ${entry.profile}`,
|
|
252
|
-
enabledByDefault: entities.length === 0,
|
|
253
|
-
profile: entry.profile,
|
|
254
|
-
muted
|
|
255
|
-
});
|
|
256
|
-
}
|
|
257
|
-
return entities;
|
|
240
|
+
function labelFor(choice) {
|
|
241
|
+
const base = `Stream ${choice.label.toLowerCase()}`;
|
|
242
|
+
return choice.height === void 0 ? base : `${base} (${choice.height}p)`;
|
|
258
243
|
}
|
|
259
244
|
/**
|
|
260
|
-
*
|
|
245
|
+
* Stream list → the `camera` entities the catalog builds.
|
|
261
246
|
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
* muted alias of a profile is called.
|
|
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`.
|
|
266
250
|
*/
|
|
267
|
-
function
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
refused.push({
|
|
276
|
-
entity: entity.entity,
|
|
277
|
-
profile: entity.profile,
|
|
278
|
-
reason: decision.reason
|
|
279
|
-
});
|
|
280
|
-
continue;
|
|
281
|
-
}
|
|
282
|
-
values.push({
|
|
283
|
-
topic: stateTopic(deviceKey, entity.entity),
|
|
284
|
-
value: decision.url
|
|
285
|
-
});
|
|
286
|
-
}
|
|
287
|
-
return {
|
|
288
|
-
values,
|
|
289
|
-
refused
|
|
290
|
-
};
|
|
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
|
+
}));
|
|
291
259
|
}
|
|
292
260
|
//#endregion
|
|
293
261
|
//#region src/ha-export/component-support.ts
|
|
@@ -1040,6 +1008,29 @@ var ZONE_MACROS = [
|
|
|
1040
1008
|
* what the export is for. The three `*_last_image` entities — camera,
|
|
1041
1009
|
* per macro, per zone × macro — therefore ship enabled. They cost one
|
|
1042
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.
|
|
1043
1034
|
*/
|
|
1044
1035
|
/**
|
|
1045
1036
|
* The snooze surface, as a `select`.
|
|
@@ -1074,6 +1065,44 @@ var PTZ_BUTTONS = [
|
|
|
1074
1065
|
"ptz_zoom_in",
|
|
1075
1066
|
"ptz_zoom_out"
|
|
1076
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
|
+
]);
|
|
1077
1106
|
/** 0..100, the shape every camstack percentage declares in its own schema. */
|
|
1078
1107
|
var PERCENT_RANGE = {
|
|
1079
1108
|
min: 0,
|
|
@@ -1107,8 +1136,8 @@ function buildComponent(device, spec) {
|
|
|
1107
1136
|
platform: spec.platform,
|
|
1108
1137
|
unique_id: `${device.stableId}_${spec.uniqueSuffix ?? spec.entity}`,
|
|
1109
1138
|
name: spec.label,
|
|
1110
|
-
...spec.platform
|
|
1111
|
-
...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) } : {},
|
|
1112
1141
|
...spec.deviceClass !== void 0 ? { device_class: spec.deviceClass } : {},
|
|
1113
1142
|
...spec.unit !== void 0 ? { unit_of_measurement: spec.unit } : {},
|
|
1114
1143
|
...spec.icon !== void 0 ? { icon: spec.icon } : {},
|
|
@@ -1124,7 +1153,8 @@ function buildComponent(device, spec) {
|
|
|
1124
1153
|
payload_on: "true",
|
|
1125
1154
|
payload_off: "false"
|
|
1126
1155
|
} : {},
|
|
1127
|
-
...spec.platform === "button" ? { payload_press: "PRESS" } : {}
|
|
1156
|
+
...spec.platform === "button" ? { payload_press: "PRESS" } : {},
|
|
1157
|
+
...spec.streamTarget !== void 0 ? { stream_target: spec.streamTarget } : {}
|
|
1128
1158
|
};
|
|
1129
1159
|
}
|
|
1130
1160
|
function deviceBlock(device) {
|
|
@@ -1277,7 +1307,7 @@ function cameraSpecs(device) {
|
|
|
1277
1307
|
label: "Last detection",
|
|
1278
1308
|
deviceClass: "timestamp",
|
|
1279
1309
|
icon: "mdi:clock",
|
|
1280
|
-
enabledByDefault:
|
|
1310
|
+
enabledByDefault: true
|
|
1281
1311
|
}
|
|
1282
1312
|
];
|
|
1283
1313
|
if (device.slices.includes("battery")) specs.push({
|
|
@@ -1287,14 +1317,14 @@ function cameraSpecs(device) {
|
|
|
1287
1317
|
deviceClass: "battery",
|
|
1288
1318
|
unit: "%",
|
|
1289
1319
|
entityCategory: "diagnostic",
|
|
1290
|
-
enabledByDefault:
|
|
1320
|
+
enabledByDefault: true
|
|
1291
1321
|
}, {
|
|
1292
1322
|
entity: "charger",
|
|
1293
1323
|
platform: "binary_sensor",
|
|
1294
1324
|
label: "Charging",
|
|
1295
1325
|
deviceClass: "battery_charging",
|
|
1296
1326
|
entityCategory: "diagnostic",
|
|
1297
|
-
enabledByDefault:
|
|
1327
|
+
enabledByDefault: true
|
|
1298
1328
|
}, {
|
|
1299
1329
|
entity: "sleeping",
|
|
1300
1330
|
platform: "binary_sensor",
|
|
@@ -1426,7 +1456,7 @@ function cameraSpecs(device) {
|
|
|
1426
1456
|
platform: "sensor",
|
|
1427
1457
|
label: `${label} last detection`,
|
|
1428
1458
|
deviceClass: "timestamp",
|
|
1429
|
-
enabledByDefault:
|
|
1459
|
+
enabledByDefault: true
|
|
1430
1460
|
}, {
|
|
1431
1461
|
entity: `${macro}_objects`,
|
|
1432
1462
|
platform: "sensor",
|
|
@@ -1439,19 +1469,22 @@ function cameraSpecs(device) {
|
|
|
1439
1469
|
platform: "sensor",
|
|
1440
1470
|
label: `${label} last label`,
|
|
1441
1471
|
icon: "mdi:tag",
|
|
1442
|
-
enabledByDefault:
|
|
1472
|
+
enabledByDefault: true
|
|
1443
1473
|
});
|
|
1444
1474
|
}
|
|
1445
1475
|
/**
|
|
1446
|
-
* The live picture
|
|
1447
|
-
*
|
|
1448
|
-
*
|
|
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.
|
|
1449
1481
|
*/
|
|
1450
1482
|
for (const stream of cameraStreamEntities(device.streams ?? [])) specs.push({
|
|
1451
1483
|
entity: stream.entity,
|
|
1452
1484
|
platform: stream.platform,
|
|
1453
1485
|
label: stream.label,
|
|
1454
|
-
enabledByDefault: stream.enabledByDefault
|
|
1486
|
+
enabledByDefault: stream.enabledByDefault,
|
|
1487
|
+
streamTarget: stream.target
|
|
1455
1488
|
});
|
|
1456
1489
|
/**
|
|
1457
1490
|
* The two capability-derived entities a camera carries.
|
|
@@ -1492,23 +1525,35 @@ function buildCameraPlan(device) {
|
|
|
1492
1525
|
* must name a route `CAP_COMMAND_ROUTES` can resolve, or ship read-only.**
|
|
1493
1526
|
* The route table now covers every Tier A family, so the rows below are
|
|
1494
1527
|
* writable wherever the capability declares a method that writes exactly
|
|
1495
|
-
* the value the entity carries.
|
|
1496
|
-
* for a reason that is a property of the value and not of the table:
|
|
1528
|
+
* the value the entity carries.
|
|
1497
1529
|
*
|
|
1498
|
-
*
|
|
1499
|
-
* `.preset`, `water-heater.operationMode`, `humidifier.mode`,
|
|
1500
|
-
* `media-player.source` and `vacuum-control.fanSpeed` are free-form
|
|
1501
|
-
* strings whose accepted values are `available*` arrays on the DEVICE.
|
|
1502
|
-
* A `select` needs a closed static list, and inventing one would offer
|
|
1503
|
-
* values the device rejects while hiding the ones it takes. Only an
|
|
1504
|
-
* enum in the cap's own schema becomes a `select` here.
|
|
1505
|
-
* - **A setter that needs two values at once.** `setTargetRange` writes
|
|
1506
|
-
* `targetLow` AND `targetHigh`; two independent `number` entities
|
|
1507
|
-
* cannot make one call without reading each other, and the catalog and
|
|
1508
|
-
* the route table are both pure. Both stay sensors.
|
|
1509
|
-
* - **A value that is not flat.** See below.
|
|
1530
|
+
* ── `writable` is about the CAPABILITY, not about the platform ───────────
|
|
1510
1531
|
*
|
|
1511
|
-
*
|
|
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.
|
|
1512
1557
|
*
|
|
1513
1558
|
* ── Why a nested object is never a field ────────────────────────────────
|
|
1514
1559
|
*
|
|
@@ -1548,6 +1593,22 @@ var CAP_ENTITY_MAP = {
|
|
|
1548
1593
|
field: "state",
|
|
1549
1594
|
label: "Lock state",
|
|
1550
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
|
|
1551
1612
|
} }
|
|
1552
1613
|
},
|
|
1553
1614
|
button: {
|
|
@@ -1804,12 +1865,17 @@ var CAP_ENTITY_MAP = {
|
|
|
1804
1865
|
range: PERCENT_RANGE,
|
|
1805
1866
|
enabledByDefault: false
|
|
1806
1867
|
},
|
|
1807
|
-
/**
|
|
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
|
+
*/
|
|
1808
1873
|
"fan-mode": {
|
|
1809
1874
|
platform: "sensor",
|
|
1810
1875
|
field: "fanMode",
|
|
1811
1876
|
label: "Fan mode",
|
|
1812
1877
|
icon: "mdi:fan",
|
|
1878
|
+
writable: true,
|
|
1813
1879
|
enabledByDefault: false
|
|
1814
1880
|
},
|
|
1815
1881
|
/** Free-form: the accepted values are `availablePresets` on the slice. */
|
|
@@ -1817,15 +1883,21 @@ var CAP_ENTITY_MAP = {
|
|
|
1817
1883
|
platform: "sensor",
|
|
1818
1884
|
field: "preset",
|
|
1819
1885
|
label: "Preset",
|
|
1886
|
+
writable: true,
|
|
1820
1887
|
enabledByDefault: false
|
|
1821
1888
|
},
|
|
1822
1889
|
/**
|
|
1823
|
-
*
|
|
1824
|
-
*
|
|
1825
|
-
*
|
|
1826
|
-
*
|
|
1827
|
-
*
|
|
1828
|
-
*
|
|
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.
|
|
1829
1901
|
*/
|
|
1830
1902
|
"target-high": {
|
|
1831
1903
|
platform: "sensor",
|
|
@@ -1833,6 +1905,7 @@ var CAP_ENTITY_MAP = {
|
|
|
1833
1905
|
label: "Target high",
|
|
1834
1906
|
deviceClass: "temperature",
|
|
1835
1907
|
unit: "°C",
|
|
1908
|
+
writable: true,
|
|
1836
1909
|
enabledByDefault: false
|
|
1837
1910
|
},
|
|
1838
1911
|
"target-low": {
|
|
@@ -1841,6 +1914,7 @@ var CAP_ENTITY_MAP = {
|
|
|
1841
1914
|
label: "Target low",
|
|
1842
1915
|
deviceClass: "temperature",
|
|
1843
1916
|
unit: "°C",
|
|
1917
|
+
writable: true,
|
|
1844
1918
|
enabledByDefault: false
|
|
1845
1919
|
},
|
|
1846
1920
|
/**
|
|
@@ -1881,11 +1955,15 @@ var CAP_ENTITY_MAP = {
|
|
|
1881
1955
|
*/
|
|
1882
1956
|
range: PERCENT_RANGE,
|
|
1883
1957
|
extras: {
|
|
1884
|
-
/**
|
|
1958
|
+
/**
|
|
1959
|
+
* Free-form: the accepted values are `availablePresets` on the slice.
|
|
1960
|
+
* `fanControl.setPreset` is the route; the native fan renders it.
|
|
1961
|
+
*/
|
|
1885
1962
|
preset: {
|
|
1886
1963
|
platform: "sensor",
|
|
1887
1964
|
field: "preset",
|
|
1888
1965
|
label: "Preset",
|
|
1966
|
+
writable: true,
|
|
1889
1967
|
enabledByDefault: false
|
|
1890
1968
|
},
|
|
1891
1969
|
oscillating: {
|
|
@@ -1933,11 +2011,15 @@ var CAP_ENTITY_MAP = {
|
|
|
1933
2011
|
writable: true,
|
|
1934
2012
|
range: PERCENT_RANGE
|
|
1935
2013
|
},
|
|
1936
|
-
/**
|
|
2014
|
+
/**
|
|
2015
|
+
* Free-form: the accepted values are `availableModes` on the slice.
|
|
2016
|
+
* `humidifier.setMode` is the route; the native humidifier renders it.
|
|
2017
|
+
*/
|
|
1937
2018
|
mode: {
|
|
1938
2019
|
platform: "sensor",
|
|
1939
2020
|
field: "mode",
|
|
1940
2021
|
label: "Mode",
|
|
2022
|
+
writable: true,
|
|
1941
2023
|
enabledByDefault: false
|
|
1942
2024
|
},
|
|
1943
2025
|
action: {
|
|
@@ -1963,11 +2045,18 @@ var CAP_ENTITY_MAP = {
|
|
|
1963
2045
|
writable: true,
|
|
1964
2046
|
range: TEMPERATURE_RANGE
|
|
1965
2047
|
},
|
|
1966
|
-
/**
|
|
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
|
+
*/
|
|
1967
2055
|
mode: {
|
|
1968
2056
|
platform: "sensor",
|
|
1969
2057
|
field: "operationMode",
|
|
1970
2058
|
label: "Operation mode",
|
|
2059
|
+
writable: true,
|
|
1971
2060
|
enabledByDefault: false
|
|
1972
2061
|
},
|
|
1973
2062
|
away: {
|
|
@@ -2020,11 +2109,15 @@ var CAP_ENTITY_MAP = {
|
|
|
2020
2109
|
deviceClass: "battery",
|
|
2021
2110
|
unit: "%"
|
|
2022
2111
|
},
|
|
2023
|
-
/**
|
|
2112
|
+
/**
|
|
2113
|
+
* Free-form: the accepted values are `availableFanSpeeds` on the slice.
|
|
2114
|
+
* `vacuumControl.setFanSpeed` is the route; the native vacuum renders it.
|
|
2115
|
+
*/
|
|
2024
2116
|
"fan-speed": {
|
|
2025
2117
|
platform: "sensor",
|
|
2026
2118
|
field: "fanSpeed",
|
|
2027
2119
|
label: "Fan speed",
|
|
2120
|
+
writable: true,
|
|
2028
2121
|
enabledByDefault: false
|
|
2029
2122
|
},
|
|
2030
2123
|
progress: {
|
|
@@ -2130,11 +2223,16 @@ var CAP_ENTITY_MAP = {
|
|
|
2130
2223
|
icon: "mdi:volume-off",
|
|
2131
2224
|
writable: true
|
|
2132
2225
|
},
|
|
2133
|
-
/**
|
|
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
|
+
*/
|
|
2134
2231
|
source: {
|
|
2135
2232
|
platform: "sensor",
|
|
2136
2233
|
field: "source",
|
|
2137
2234
|
label: "Source",
|
|
2235
|
+
writable: true,
|
|
2138
2236
|
enabledByDefault: false
|
|
2139
2237
|
},
|
|
2140
2238
|
repeat: {
|
|
@@ -2880,6 +2978,21 @@ function parseLocked(value) {
|
|
|
2880
2978
|
if (lower === "unlock" || lower === "unlocked") return false;
|
|
2881
2979
|
return null;
|
|
2882
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
|
+
}
|
|
2883
2996
|
/** 0..100 inclusive, as `brightness.setBrightness` declares it. */
|
|
2884
2997
|
function parsePercentage(value) {
|
|
2885
2998
|
const parsed = Number(value.trim());
|
|
@@ -2943,10 +3056,13 @@ function parseEnum(schema, value) {
|
|
|
2943
3056
|
* never built, and a leftover that describes the right design reads as
|
|
2944
3057
|
* verification.
|
|
2945
3058
|
*
|
|
2946
|
-
* **Every builder names ONE method with ONE value
|
|
2947
|
-
*
|
|
2948
|
-
*
|
|
2949
|
-
*
|
|
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.
|
|
2950
3066
|
*/
|
|
2951
3067
|
var CAP_COMMAND_ROUTES = {
|
|
2952
3068
|
switch: { primary: (deviceId, value) => {
|
|
@@ -2958,14 +3074,20 @@ var CAP_COMMAND_ROUTES = {
|
|
|
2958
3074
|
on
|
|
2959
3075
|
};
|
|
2960
3076
|
} },
|
|
2961
|
-
"lock-control": {
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
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
|
+
},
|
|
2969
3091
|
brightness: { primary: (deviceId, value) => {
|
|
2970
3092
|
const percentage = parsePercentage(value);
|
|
2971
3093
|
return percentage === null ? null : {
|
|
@@ -3055,6 +3177,45 @@ var CAP_COMMAND_ROUTES = {
|
|
|
3055
3177
|
targetHumidity
|
|
3056
3178
|
};
|
|
3057
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
|
+
},
|
|
3058
3219
|
"swing-vertical": (deviceId, value) => {
|
|
3059
3220
|
const on = parseBool(value);
|
|
3060
3221
|
return on === null ? null : {
|
|
@@ -3085,6 +3246,14 @@ var CAP_COMMAND_ROUTES = {
|
|
|
3085
3246
|
};
|
|
3086
3247
|
},
|
|
3087
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
|
+
},
|
|
3088
3257
|
oscillating: (deviceId, value) => {
|
|
3089
3258
|
const oscillating = parseBool(value);
|
|
3090
3259
|
return oscillating === null ? null : {
|
|
@@ -3112,14 +3281,24 @@ var CAP_COMMAND_ROUTES = {
|
|
|
3112
3281
|
on
|
|
3113
3282
|
};
|
|
3114
3283
|
},
|
|
3115
|
-
extras: {
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
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
|
+
}
|
|
3123
3302
|
},
|
|
3124
3303
|
"water-heater": { extras: {
|
|
3125
3304
|
target: (deviceId, value) => {
|
|
@@ -3137,6 +3316,14 @@ var CAP_COMMAND_ROUTES = {
|
|
|
3137
3316
|
deviceId,
|
|
3138
3317
|
on
|
|
3139
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
|
+
};
|
|
3140
3327
|
}
|
|
3141
3328
|
} },
|
|
3142
3329
|
valve: {
|
|
@@ -3166,33 +3353,43 @@ var CAP_COMMAND_ROUTES = {
|
|
|
3166
3353
|
})
|
|
3167
3354
|
}
|
|
3168
3355
|
},
|
|
3169
|
-
"vacuum-control": {
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
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
|
+
},
|
|
3196
3393
|
"lawn-mower-control": { commands: {
|
|
3197
3394
|
start: (deviceId) => ({
|
|
3198
3395
|
kind: "mower-verb",
|
|
@@ -3243,6 +3440,14 @@ var CAP_COMMAND_ROUTES = {
|
|
|
3243
3440
|
deviceId,
|
|
3244
3441
|
repeat
|
|
3245
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
|
+
};
|
|
3246
3451
|
}
|
|
3247
3452
|
},
|
|
3248
3453
|
commands: {
|
|
@@ -3532,6 +3737,30 @@ async function applyCapCommand(device, command) {
|
|
|
3532
3737
|
if (command.locked) await device.lockControl.lock({});
|
|
3533
3738
|
else await device.lockControl.unlock({});
|
|
3534
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 };
|
|
3535
3764
|
case "cap-brightness":
|
|
3536
3765
|
if (device.brightness === void 0) return {
|
|
3537
3766
|
ok: false,
|
|
@@ -3595,15 +3824,40 @@ async function applyCapCommand(device, command) {
|
|
|
3595
3824
|
});
|
|
3596
3825
|
case "climate-mode": return call(device.climateControl, "climate-control", (climate) => climate.setMode({ mode: command.mode }));
|
|
3597
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
|
+
});
|
|
3598
3849
|
case "climate-target-humidity": return call(device.climateControl, "climate-control", (climate) => climate.setTargetHumidity({ targetHumidity: command.targetHumidity }));
|
|
3599
3850
|
case "climate-swing": return call(device.climateControl, "climate-control", (climate) => command.axis === "vertical" ? climate.setSwingVertical({ on: command.on }) : climate.setSwingHorizontal({ on: command.on }));
|
|
3600
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 }));
|
|
3601
3853
|
case "fan-oscillating": return call(device.fanControl, "fan-control", (fan) => fan.setOscillating({ oscillating: command.oscillating }));
|
|
3602
3854
|
case "fan-direction": return call(device.fanControl, "fan-control", (fan) => fan.setDirection({ direction: command.direction }));
|
|
3603
3855
|
case "humidifier-on": return call(device.humidifier, "humidifier", (humidifier) => humidifier.setOn({ on: command.on }));
|
|
3604
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 }));
|
|
3605
3858
|
case "water-heater-temp": return call(device.waterHeater, "water-heater", (heater) => heater.setTargetTemp({ temp: command.temp }));
|
|
3606
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 }));
|
|
3607
3861
|
case "valve-position": return call(device.valve, "valve", (valve) => valve.setPosition({ position: command.position }));
|
|
3608
3862
|
case "valve-verb": return call(device.valve, "valve", (valve) => {
|
|
3609
3863
|
switch (command.verb) {
|
|
@@ -3621,6 +3875,7 @@ async function applyCapCommand(device, command) {
|
|
|
3621
3875
|
case "locate": return vacuum.locate({});
|
|
3622
3876
|
}
|
|
3623
3877
|
});
|
|
3878
|
+
case "vacuum-fan-speed": return call(device.vacuumControl, "vacuum-control", (vacuum) => vacuum.setFanSpeed({ speed: command.speed }));
|
|
3624
3879
|
case "mower-verb": return call(device.lawnMowerControl, "lawn-mower-control", (mower) => {
|
|
3625
3880
|
switch (command.verb) {
|
|
3626
3881
|
case "start": return mower.startMowing({});
|
|
@@ -3641,6 +3896,7 @@ async function applyCapCommand(device, command) {
|
|
|
3641
3896
|
case "media-mute": return call(device.mediaPlayer, "media-player", (media) => media.setMute({ muted: command.muted }));
|
|
3642
3897
|
case "media-shuffle": return call(device.mediaPlayer, "media-player", (media) => media.setShuffle({ shuffle: command.shuffle }));
|
|
3643
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 }));
|
|
3644
3900
|
case "color-mireds": return call(device.color, "color", (color) => color.setColor({ color: {
|
|
3645
3901
|
mode: "mired",
|
|
3646
3902
|
mireds: command.mireds
|
|
@@ -5256,7 +5512,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
5256
5512
|
const plateText = readString(data, "plateText");
|
|
5257
5513
|
const lastSeen = readNumber(data, "lastSeen") ?? Date.now();
|
|
5258
5514
|
const zonesVisited = readStringArray(data, "zonesVisited");
|
|
5259
|
-
const mediaUrl = this.mintTrackMediaUrl(trackId);
|
|
5515
|
+
const mediaUrl = this.mintTrackMediaUrl(trackId, deviceId);
|
|
5260
5516
|
const values = projectTrackLifecycle(deviceKey, {
|
|
5261
5517
|
deviceId,
|
|
5262
5518
|
trackId,
|
|
@@ -5722,22 +5978,12 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
5722
5978
|
}
|
|
5723
5979
|
if (entry.target.type === "camera") {
|
|
5724
5980
|
/**
|
|
5725
|
-
* The live picture
|
|
5726
|
-
* entity
|
|
5727
|
-
*
|
|
5728
|
-
*
|
|
5729
|
-
*
|
|
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`.
|
|
5730
5986
|
*/
|
|
5731
|
-
const streams = cameraStreamValues(key, entry.streams);
|
|
5732
|
-
values.push(...streams.values);
|
|
5733
|
-
for (const refusal of streams.refused) this.warnOnce(entry.deviceId, "camera-streams", refusal.entity, {
|
|
5734
|
-
message: "ha-export: a camera entity exists and its stream URL cannot be published",
|
|
5735
|
-
meta: {
|
|
5736
|
-
entity: refusal.entity,
|
|
5737
|
-
profile: refusal.profile,
|
|
5738
|
-
reason: refusal.reason
|
|
5739
|
-
}
|
|
5740
|
-
});
|
|
5741
5987
|
const group = await this.ctx.api.pipelineOrchestrator.getCameraSwitches.query({ deviceId: entry.deviceId });
|
|
5742
5988
|
values.push(...projectCameraSwitches(key, group.switches.map((sw) => ({
|
|
5743
5989
|
id: sw.id,
|
|
@@ -5766,7 +6012,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
5766
6012
|
const zones = device.type === "camera" ? await this.loadZones(device.id) : [];
|
|
5767
6013
|
const switches = device.type === "camera" ? await this.loadSwitches(device.id) : [];
|
|
5768
6014
|
const ptzPresets = device.type === "camera" && boundCaps.includes("ptz") ? await this.loadPresets(device.id) : [];
|
|
5769
|
-
const streams = device.type === "camera" && boundCaps.includes("
|
|
6015
|
+
const streams = device.type === "camera" && boundCaps.includes("webrtc-session") ? await this.loadStreamChoices(device.id) : [];
|
|
5770
6016
|
const manufacturer = readString(device.metadata ?? {}, "manufacturer");
|
|
5771
6017
|
const model = readString(device.metadata ?? {}, "model");
|
|
5772
6018
|
return {
|
|
@@ -5793,42 +6039,30 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
5793
6039
|
};
|
|
5794
6040
|
}
|
|
5795
6041
|
/**
|
|
5796
|
-
*
|
|
6042
|
+
* Every stream of the camera, as the hub's own picker lists them.
|
|
5797
6043
|
*
|
|
5798
|
-
* `
|
|
5799
|
-
*
|
|
5800
|
-
*
|
|
5801
|
-
*
|
|
5802
|
-
*
|
|
5803
|
-
* camera
|
|
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.
|
|
5804
6052
|
*
|
|
5805
|
-
* `
|
|
5806
|
-
*
|
|
5807
|
-
* Assistant is not on the hub, so omitting it produces entities that
|
|
5808
|
-
* exist and never load — the identical failure the image entities had
|
|
5809
|
-
* before `publicBaseUrl`.
|
|
6053
|
+
* `listStreams` also reports the camera's raw substreams, which the
|
|
6054
|
+
* profile view could not express.
|
|
5810
6055
|
*/
|
|
5811
|
-
async
|
|
5812
|
-
const hostname = hostnameFromBaseUrl(this.mediaBaseUrl);
|
|
5813
|
-
if (hostname === null) {
|
|
5814
|
-
this.ctx.logger.warn("ha-export: no reachable hub hostname — this camera gets no live camera entity", {
|
|
5815
|
-
tags: { deviceId },
|
|
5816
|
-
meta: { mediaBaseUrl: this.mediaBaseUrl }
|
|
5817
|
-
});
|
|
5818
|
-
return [];
|
|
5819
|
-
}
|
|
6056
|
+
async loadStreamChoices(deviceId) {
|
|
5820
6057
|
try {
|
|
5821
|
-
return (await this.ctx.api.
|
|
5822
|
-
|
|
5823
|
-
|
|
5824
|
-
|
|
5825
|
-
|
|
5826
|
-
url: entry.url,
|
|
5827
|
-
mutedUrl: entry.mutedUrl,
|
|
5828
|
-
enabled: entry.enabled
|
|
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 }
|
|
5829
6063
|
}));
|
|
5830
6064
|
} catch (err) {
|
|
5831
|
-
this.ctx.logger.warn("ha-export: could not read
|
|
6065
|
+
this.ctx.logger.warn("ha-export: could not read the camera streams, no camera entity", {
|
|
5832
6066
|
tags: { deviceId },
|
|
5833
6067
|
meta: { error: errMsg(err) }
|
|
5834
6068
|
});
|
|
@@ -6341,10 +6575,10 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6341
6575
|
res.end();
|
|
6342
6576
|
return;
|
|
6343
6577
|
}
|
|
6344
|
-
const
|
|
6578
|
+
const compositeId = decodeURIComponent(id);
|
|
6345
6579
|
if (!verifyMediaSignature({
|
|
6346
6580
|
secret: this.mediaSecret,
|
|
6347
|
-
id:
|
|
6581
|
+
id: compositeId,
|
|
6348
6582
|
exp: url.searchParams.get("exp") ?? void 0,
|
|
6349
6583
|
sig: url.searchParams.get("sig") ?? void 0,
|
|
6350
6584
|
nowMs: Date.now()
|
|
@@ -6353,9 +6587,18 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6353
6587
|
res.end();
|
|
6354
6588
|
return;
|
|
6355
6589
|
}
|
|
6590
|
+
const sep = compositeId.indexOf(":");
|
|
6591
|
+
const deviceId = sep > 0 ? Number(compositeId.slice(0, sep)) : NaN;
|
|
6592
|
+
const trackId = sep > 0 ? compositeId.slice(sep + 1) : "";
|
|
6593
|
+
if (!Number.isInteger(deviceId) || deviceId <= 0 || trackId.length === 0) {
|
|
6594
|
+
res.statusCode = 400;
|
|
6595
|
+
res.end();
|
|
6596
|
+
return;
|
|
6597
|
+
}
|
|
6356
6598
|
try {
|
|
6357
6599
|
const first = (await this.ctx.api.pipelineAnalytics.getTrackMedia.query({
|
|
6358
6600
|
trackId,
|
|
6601
|
+
deviceId,
|
|
6359
6602
|
kinds: [
|
|
6360
6603
|
"thumbnailSmall",
|
|
6361
6604
|
"thumbnail",
|
|
@@ -6388,12 +6631,12 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6388
6631
|
* cannot open is worse than no image, because it renders as a broken
|
|
6389
6632
|
* entity rather than an empty one.
|
|
6390
6633
|
*/
|
|
6391
|
-
mintTrackMediaUrl(trackId) {
|
|
6634
|
+
mintTrackMediaUrl(trackId, deviceId) {
|
|
6392
6635
|
if (this.mediaBaseUrl === null || this.mediaSecret.length === 0) return null;
|
|
6393
6636
|
return buildMediaUrl({
|
|
6394
6637
|
baseUrl: this.mediaBaseUrl,
|
|
6395
6638
|
routePrefix: MEDIA_ROUTE_PREFIX,
|
|
6396
|
-
id: trackId
|
|
6639
|
+
id: `${String(deviceId)}:${trackId}`,
|
|
6397
6640
|
secret: this.mediaSecret,
|
|
6398
6641
|
expMs: Date.now() + MEDIA_URL_TTL_MS
|
|
6399
6642
|
});
|