@camstack/addon-provider-homeassistant 1.2.29 → 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-D_VrlKNr.mjs → dist-CzZXxFrl.mjs} +556 -58
- package/dist/{dist-yXVqdsa3.js → dist-XWveotlj.js} +556 -58
- package/dist/ha-export/ha-export.addon.js +514 -280
- package/dist/ha-export/ha-export.addon.mjs +514 -280
- package/package.json +1 -1
|
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
|
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
5
|
//#endregion
|
|
6
|
-
const require_dist = require("../dist-
|
|
6
|
+
const require_dist = require("../dist-XWveotlj.js");
|
|
7
7
|
let node_crypto = require("node:crypto");
|
|
8
8
|
//#region src/ha-export/topics.ts
|
|
9
9
|
/**
|
|
@@ -112,43 +112,57 @@ function toSlug(value) {
|
|
|
112
112
|
//#endregion
|
|
113
113
|
//#region src/ha-export/camera-entities.ts
|
|
114
114
|
/**
|
|
115
|
-
* The live `camera` entities — one per
|
|
115
|
+
* The live `camera` entities — one per STREAM of the camera.
|
|
116
116
|
*
|
|
117
|
-
*
|
|
118
|
-
* catalog never emitted one, so an operator's Home Assistant carried ~73
|
|
119
|
-
* entities about a camera and no picture of it. This module is that half:
|
|
120
|
-
* pure, so the URL policy below is tested rather than reasoned about.
|
|
117
|
+
* ── What was wrong, measured on the operator's Home Assistant ────────────
|
|
121
118
|
*
|
|
122
|
-
*
|
|
119
|
+
* This module used to mint six components per camera (three profiles ×
|
|
120
|
+
* muted/unmuted), each carrying a broker RTSP url on a `state_topic`,
|
|
121
|
+
* behind two url gates: a refusal for a credential in the authority and a
|
|
122
|
+
* refusal for a loopback host. On 2026-08-14 the live registry was read:
|
|
123
|
+
* `platform: camstack` held **165 entities and exactly two `camera` ones**
|
|
124
|
+
* — one per exported camera, `unique_id` `camstack_<stableId>_camera`.
|
|
125
|
+
* None of the twelve announced stream components existed.
|
|
123
126
|
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
* be able to leak it by accident. A refusal produces NO entity value; it
|
|
133
|
-
* never produces a redacted-looking URL that would then be dialled.
|
|
127
|
+
* They never could. `custom_components/camstack/camera.py` builds its
|
|
128
|
+
* cameras from the export MEMBERSHIP, not from `cmps`: a still comes from
|
|
129
|
+
* `snapshot.getSnapshot` and live video from `webrtcSession.handleOffer`,
|
|
130
|
+
* which is native WebRTC and has no use for an RTSP address. The
|
|
131
|
+
* component's platform probe answers `camera` — truthfully, it builds the
|
|
132
|
+
* platform — and the hub read that as "it will build MY camera
|
|
133
|
+
* components". Twelve components, two entities, no warning on either
|
|
134
|
+
* side.
|
|
134
135
|
*
|
|
135
|
-
*
|
|
136
|
-
* URLs against whatever address it was asked for. Home Assistant is not
|
|
137
|
-
* on the hub, so a loopback URL yields an entity that exists and never
|
|
138
|
-
* loads — the identical failure the image entities had before
|
|
139
|
-
* `publicBaseUrl` was added. The caller passes `hostname` explicitly
|
|
140
|
-
* (`getProfileRtspEntries({ deviceId, hostname })`) and this module
|
|
141
|
-
* REFUSES a loopback URL rather than publishing one, because "the picture
|
|
142
|
-
* never appears" is indistinguishable from "the camera is down" on a
|
|
143
|
-
* dashboard.
|
|
136
|
+
* ── What this module is now ──────────────────────────────────────────────
|
|
144
137
|
*
|
|
145
|
-
*
|
|
138
|
+
* One component per entry of `webrtcSession.listStreams`, carrying the
|
|
139
|
+
* `target` verbatim so the component hands it back to `handleOffer`. No
|
|
140
|
+
* url crosses, which is why the two gates are gone rather than kept "just
|
|
141
|
+
* in case": there is no address here to leak a password or to point at
|
|
142
|
+
* 127.0.0.1, and a gate guarding a value that no longer exists is the
|
|
143
|
+
* leftover that reads as verification.
|
|
146
144
|
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
145
|
+
* ── The adaptive stream is announced by NOBODY, on purpose ───────────────
|
|
146
|
+
*
|
|
147
|
+
* The component's membership entity IS the adaptive stream: it negotiates
|
|
148
|
+
* no target, it is the device's primary camera (`_attr_name = None`), and
|
|
149
|
+
* it has carried `camstack_<stableId>_camera` since the integration
|
|
150
|
+
* shipped. Announcing a component for it would claim the same
|
|
151
|
+
* `unique_id` — Home Assistant keys its registry on
|
|
152
|
+
* `(platform, integration, unique_id)` — and one of the two would lose the
|
|
153
|
+
* race and be dropped with a "does not generate unique IDs" error. So the
|
|
154
|
+
* operator's existing `camera.videocamera_ingresso` is left exactly where
|
|
155
|
+
* it is and gains SIBLINGS: one per profile, one per substream, which are
|
|
156
|
+
* the streams membership cannot express.
|
|
157
|
+
*
|
|
158
|
+
* ── Fan-out ──────────────────────────────────────────────────────────────
|
|
159
|
+
*
|
|
160
|
+
* Every remaining stream is announced. What arrives ENABLED is the
|
|
161
|
+
* assigned profiles — the pictures an operator picks between on a
|
|
162
|
+
* dashboard. The raw `cam-stream` sources (the camera's own main/sub
|
|
163
|
+
* substreams) are registered-and-off: they are the same pictures the
|
|
164
|
+
* profiles already serve, one layer below the slot the rest of camstack
|
|
165
|
+
* dials, and an operator who wants one switches it on per camera.
|
|
152
166
|
*/
|
|
153
167
|
/** Profile slots, in the order an operator reads them. */
|
|
154
168
|
var CAMERA_PROFILE_ORDER = [
|
|
@@ -156,143 +170,97 @@ var CAMERA_PROFILE_ORDER = [
|
|
|
156
170
|
"mid",
|
|
157
171
|
"low"
|
|
158
172
|
];
|
|
159
|
-
/**
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
"[::1]",
|
|
165
|
-
"0.0.0.0"
|
|
166
|
-
];
|
|
173
|
+
/** `high` → 0. An unknown profile sorts after the known ones, stably. */
|
|
174
|
+
function profileRank(profile) {
|
|
175
|
+
const index = CAMERA_PROFILE_ORDER.indexOf(profile);
|
|
176
|
+
return index === -1 ? CAMERA_PROFILE_ORDER.length : index;
|
|
177
|
+
}
|
|
167
178
|
/**
|
|
168
|
-
*
|
|
179
|
+
* Reading order: adaptive, then the profile slots high → mid → low, then
|
|
180
|
+
* whatever raw sources the camera reports, in the hub's own order.
|
|
169
181
|
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
182
|
+
* Rank only. The one stream that does NOT become a component is the
|
|
183
|
+
* adaptive one, and it is filtered by {@link isAnnounced} rather than
|
|
184
|
+
* here, because it is not a drop: it reaches Home Assistant on the
|
|
185
|
+
* membership entity. Every other slot the hub reports becomes an entity —
|
|
186
|
+
* "include all the streams of a camera" is the request this module exists
|
|
187
|
+
* to answer, and a slot silently dropped is a loss the operator cannot see
|
|
188
|
+
* from Home Assistant.
|
|
174
189
|
*/
|
|
175
|
-
function
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
return {
|
|
181
|
-
ok: false,
|
|
182
|
-
reason: "unparseable"
|
|
183
|
-
};
|
|
190
|
+
function streamRank(target) {
|
|
191
|
+
switch (target.kind) {
|
|
192
|
+
case "adaptive": return 0;
|
|
193
|
+
case "profile": return 1 + profileRank(target.profile);
|
|
194
|
+
case "cam-stream": return 1 + CAMERA_PROFILE_ORDER.length;
|
|
184
195
|
}
|
|
185
|
-
if (parsed.username.length > 0 || parsed.password.length > 0) return {
|
|
186
|
-
ok: false,
|
|
187
|
-
reason: "credentials-inline"
|
|
188
|
-
};
|
|
189
|
-
if (parsed.hostname.length === 0) return {
|
|
190
|
-
ok: false,
|
|
191
|
-
reason: "unparseable"
|
|
192
|
-
};
|
|
193
|
-
if (LOOPBACK_HOSTS.includes(parsed.hostname.toLowerCase())) return {
|
|
194
|
-
ok: false,
|
|
195
|
-
reason: "loopback-host"
|
|
196
|
-
};
|
|
197
|
-
return {
|
|
198
|
-
ok: true,
|
|
199
|
-
url
|
|
200
|
-
};
|
|
201
196
|
}
|
|
202
197
|
/**
|
|
203
|
-
* The
|
|
204
|
-
* media plane already resolved.
|
|
198
|
+
* The adaptive stream is the component's own entity and gets no component.
|
|
205
199
|
*
|
|
206
|
-
* The
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
* could disagree with the one the image entities use, and then half a
|
|
210
|
-
* camera's entities would load.
|
|
200
|
+
* The one filter in this file, and the reason it is not the "drop a slot"
|
|
201
|
+
* kind: the stream still reaches Home Assistant, on the entity that has
|
|
202
|
+
* always carried it. See the identity note above.
|
|
211
203
|
*/
|
|
212
|
-
function
|
|
213
|
-
|
|
214
|
-
try {
|
|
215
|
-
const host = new URL(baseUrl).hostname;
|
|
216
|
-
if (host.length === 0 || LOOPBACK_HOSTS.includes(host.toLowerCase())) return null;
|
|
217
|
-
return host;
|
|
218
|
-
} catch {
|
|
219
|
-
return null;
|
|
220
|
-
}
|
|
204
|
+
function isAnnounced(target) {
|
|
205
|
+
return target.kind !== "adaptive";
|
|
221
206
|
}
|
|
222
|
-
/**
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
207
|
+
/**
|
|
208
|
+
* The entity id of one stream.
|
|
209
|
+
*
|
|
210
|
+
* Derived from the TARGET rather than from `choice.id`, so the ids stay
|
|
211
|
+
* `stream_high` / `stream_mid` / `stream_low` — what this file already
|
|
212
|
+
* announced — instead of following the picker's `profile:high` spelling.
|
|
213
|
+
* An entity id is identity; the picker's id is a React key.
|
|
214
|
+
*/
|
|
215
|
+
function entityIdFor(target) {
|
|
216
|
+
switch (target.kind) {
|
|
217
|
+
case "adaptive":
|
|
218
|
+
/* c8 ignore next -- filtered by isAnnounced before it can be reached */
|
|
219
|
+
return "stream_adaptive";
|
|
220
|
+
case "profile": return `stream_${toSlug(target.profile)}`;
|
|
221
|
+
case "cam-stream": return `stream_${toSlug(target.camStreamId)}`;
|
|
222
|
+
}
|
|
226
223
|
}
|
|
227
224
|
/**
|
|
228
|
-
*
|
|
229
|
-
* `high → mid → low` order, capped at {@link MAX_CAMERA_PROFILES}.
|
|
225
|
+
* Enabled by default: the assigned profiles.
|
|
230
226
|
*
|
|
231
|
-
* A
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
227
|
+
* A raw `cam-stream` is the source a profile slot already points at. It
|
|
228
|
+
* is announced so an operator who wants the camera's own substream can
|
|
229
|
+
* have it, and it is off so that a camera does not arrive with six
|
|
230
|
+
* pictures of the same doorway switched on.
|
|
235
231
|
*/
|
|
236
|
-
function
|
|
237
|
-
return
|
|
232
|
+
function enabledByDefaultFor(target) {
|
|
233
|
+
return target.kind !== "cam-stream";
|
|
238
234
|
}
|
|
239
235
|
/**
|
|
240
|
-
*
|
|
236
|
+
* The label an operator reads on a card.
|
|
241
237
|
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
238
|
+
* The hub's own label plus the vertical resolution, because that is the
|
|
239
|
+
* one thing that distinguishes three pictures of the same doorway —
|
|
240
|
+
* "Stream high (2160p)" answers "which of these do I put on the wall
|
|
241
|
+
* tablet" and "Stream high" does not. The resolution is only in the label
|
|
242
|
+
* (never in the id), so a re-encoded profile renames an entity and never
|
|
243
|
+
* orphans one.
|
|
248
244
|
*/
|
|
249
|
-
function
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
const slug = toSlug(entry.profile);
|
|
253
|
-
for (const muted of [false, true]) entities.push({
|
|
254
|
-
entity: muted ? `stream_${slug}_muted` : `stream_${slug}`,
|
|
255
|
-
platform: "camera",
|
|
256
|
-
label: muted ? `Stream ${entry.profile} (muted)` : `Stream ${entry.profile}`,
|
|
257
|
-
enabledByDefault: entities.length === 0,
|
|
258
|
-
profile: entry.profile,
|
|
259
|
-
muted
|
|
260
|
-
});
|
|
261
|
-
}
|
|
262
|
-
return entities;
|
|
245
|
+
function labelFor(choice) {
|
|
246
|
+
const base = `Stream ${choice.label.toLowerCase()}`;
|
|
247
|
+
return choice.height === void 0 ? base : `${base} (${choice.height}p)`;
|
|
263
248
|
}
|
|
264
249
|
/**
|
|
265
|
-
*
|
|
250
|
+
* Stream list → the `camera` entities the catalog builds.
|
|
266
251
|
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
* muted alias of a profile is called.
|
|
252
|
+
* Pure, and value-free: a `camera` entity has no state on the push plane
|
|
253
|
+
* at all. It exists because the hub lists the stream, and everything the
|
|
254
|
+
* component needs to show it travels in `stream_target`.
|
|
271
255
|
*/
|
|
272
|
-
function
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
refused.push({
|
|
281
|
-
entity: entity.entity,
|
|
282
|
-
profile: entity.profile,
|
|
283
|
-
reason: decision.reason
|
|
284
|
-
});
|
|
285
|
-
continue;
|
|
286
|
-
}
|
|
287
|
-
values.push({
|
|
288
|
-
topic: stateTopic(deviceKey, entity.entity),
|
|
289
|
-
value: decision.url
|
|
290
|
-
});
|
|
291
|
-
}
|
|
292
|
-
return {
|
|
293
|
-
values,
|
|
294
|
-
refused
|
|
295
|
-
};
|
|
256
|
+
function cameraStreamEntities(choices) {
|
|
257
|
+
return [...choices].filter((choice) => isAnnounced(choice.target)).sort((a, b) => streamRank(a.target) - streamRank(b.target)).map((choice) => ({
|
|
258
|
+
entity: entityIdFor(choice.target),
|
|
259
|
+
platform: "camera",
|
|
260
|
+
label: labelFor(choice),
|
|
261
|
+
enabledByDefault: enabledByDefaultFor(choice.target),
|
|
262
|
+
target: choice.target
|
|
263
|
+
}));
|
|
296
264
|
}
|
|
297
265
|
//#endregion
|
|
298
266
|
//#region src/ha-export/component-support.ts
|
|
@@ -1045,6 +1013,29 @@ var ZONE_MACROS = [
|
|
|
1045
1013
|
* what the export is for. The three `*_last_image` entities — camera,
|
|
1046
1014
|
* per macro, per zone × macro — therefore ship enabled. They cost one
|
|
1047
1015
|
* entity each and carry a signed URL, not bytes.
|
|
1016
|
+
*
|
|
1017
|
+
* **So are the three things the operator asked for on 2026-08-14**, and
|
|
1018
|
+
* each has a reason that is a property of the value rather than a taste:
|
|
1019
|
+
*
|
|
1020
|
+
* - **`<macro>_last_detection`, and the camera's own.** "When was a
|
|
1021
|
+
* person last seen" is a timestamp, and half of what an operator
|
|
1022
|
+
* builds on a camera is a duration since one — a `for:` in a
|
|
1023
|
+
* template, a "nobody has been at the door since" notification. A
|
|
1024
|
+
* boolean that is `off` right now cannot answer it.
|
|
1025
|
+
* - **`<macro>_last_label`.** This is where the RECOGNISED FACE and the
|
|
1026
|
+
* read PLATE arrive. It was the single highest-value item in the gap
|
|
1027
|
+
* against the reference integration and it shipped switched off, so
|
|
1028
|
+
* the name camstack worked out was in Home Assistant and invisible.
|
|
1029
|
+
* - **`battery` and `charger` on a battery camera.** A battery
|
|
1030
|
+
* percentage is the headline of a battery device, not a diagnostic
|
|
1031
|
+
* detail about one; `sleeping` stays off because it describes the
|
|
1032
|
+
* power state machine rather than the charge.
|
|
1033
|
+
*
|
|
1034
|
+
* What stays off is what the valve was built for: the per-macro and
|
|
1035
|
+
* per-zone `*_objects` COUNTERS (a count of what is on screen right now,
|
|
1036
|
+
* which the `_detected` boolean and the camera-wide `objects` total
|
|
1037
|
+
* already answer for automation), the per-zone timestamps, and the
|
|
1038
|
+
* doorbell's since-restart counter.
|
|
1048
1039
|
*/
|
|
1049
1040
|
/**
|
|
1050
1041
|
* The snooze surface, as a `select`.
|
|
@@ -1079,6 +1070,44 @@ var PTZ_BUTTONS = [
|
|
|
1079
1070
|
"ptz_zoom_in",
|
|
1080
1071
|
"ptz_zoom_out"
|
|
1081
1072
|
];
|
|
1073
|
+
/**
|
|
1074
|
+
* Platforms whose entity carries no value on the push plane.
|
|
1075
|
+
*
|
|
1076
|
+
* A `button` is a command. A `camera` is a stream the component fetches
|
|
1077
|
+
* for itself (`snapshot.getSnapshot` + `webrtcSession.handleOffer`) from
|
|
1078
|
+
* the target it is given. Announcing a `state_topic` for either would
|
|
1079
|
+
* declare a value nothing ever publishes — which is precisely how twelve
|
|
1080
|
+
* camera components came to sit in an announce that produced no entity.
|
|
1081
|
+
*/
|
|
1082
|
+
var STATELESS_PLATFORMS = new Set(["button", "camera"]);
|
|
1083
|
+
/**
|
|
1084
|
+
* Platforms that can carry a command topic.
|
|
1085
|
+
*
|
|
1086
|
+
* `writable` on a descriptor means "the route table writes exactly this
|
|
1087
|
+
* value" — a fact about the CAPABILITY. Whether Home Assistant can offer
|
|
1088
|
+
* a control for it is a fact about the PLATFORM, and the two are not the
|
|
1089
|
+
* same: `climate-control.fanMode` is written by `setFanMode` and has no
|
|
1090
|
+
* closed vocabulary, so its degraded form is a `sensor` and its native
|
|
1091
|
+
* form is the climate entity's fan-mode control. Announcing a command
|
|
1092
|
+
* topic on the degraded `sensor` would describe a control Home Assistant
|
|
1093
|
+
* cannot render — a leftover that reads as verification.
|
|
1094
|
+
*/
|
|
1095
|
+
var COMMANDABLE_PLATFORMS = new Set([
|
|
1096
|
+
"switch",
|
|
1097
|
+
"button",
|
|
1098
|
+
"select",
|
|
1099
|
+
"number",
|
|
1100
|
+
"alarm_control_panel",
|
|
1101
|
+
"cover",
|
|
1102
|
+
"climate",
|
|
1103
|
+
"lock",
|
|
1104
|
+
"fan",
|
|
1105
|
+
"vacuum",
|
|
1106
|
+
"valve",
|
|
1107
|
+
"humidifier",
|
|
1108
|
+
"water_heater",
|
|
1109
|
+
"media_player"
|
|
1110
|
+
]);
|
|
1082
1111
|
/** 0..100, the shape every camstack percentage declares in its own schema. */
|
|
1083
1112
|
var PERCENT_RANGE = {
|
|
1084
1113
|
min: 0,
|
|
@@ -1112,8 +1141,8 @@ function buildComponent(device, spec) {
|
|
|
1112
1141
|
platform: spec.platform,
|
|
1113
1142
|
unique_id: `${device.stableId}_${spec.uniqueSuffix ?? spec.entity}`,
|
|
1114
1143
|
name: spec.label,
|
|
1115
|
-
...spec.platform
|
|
1116
|
-
...spec.writable === true ? { command_topic: commandTopic(deviceKey, spec.entity) } : {},
|
|
1144
|
+
...STATELESS_PLATFORMS.has(spec.platform) ? {} : { state_topic: stateTopic(deviceKey, spec.entity) },
|
|
1145
|
+
...spec.writable === true && COMMANDABLE_PLATFORMS.has(spec.platform) ? { command_topic: commandTopic(deviceKey, spec.entity) } : {},
|
|
1117
1146
|
...spec.deviceClass !== void 0 ? { device_class: spec.deviceClass } : {},
|
|
1118
1147
|
...spec.unit !== void 0 ? { unit_of_measurement: spec.unit } : {},
|
|
1119
1148
|
...spec.icon !== void 0 ? { icon: spec.icon } : {},
|
|
@@ -1129,7 +1158,8 @@ function buildComponent(device, spec) {
|
|
|
1129
1158
|
payload_on: "true",
|
|
1130
1159
|
payload_off: "false"
|
|
1131
1160
|
} : {},
|
|
1132
|
-
...spec.platform === "button" ? { payload_press: "PRESS" } : {}
|
|
1161
|
+
...spec.platform === "button" ? { payload_press: "PRESS" } : {},
|
|
1162
|
+
...spec.streamTarget !== void 0 ? { stream_target: spec.streamTarget } : {}
|
|
1133
1163
|
};
|
|
1134
1164
|
}
|
|
1135
1165
|
function deviceBlock(device) {
|
|
@@ -1282,7 +1312,7 @@ function cameraSpecs(device) {
|
|
|
1282
1312
|
label: "Last detection",
|
|
1283
1313
|
deviceClass: "timestamp",
|
|
1284
1314
|
icon: "mdi:clock",
|
|
1285
|
-
enabledByDefault:
|
|
1315
|
+
enabledByDefault: true
|
|
1286
1316
|
}
|
|
1287
1317
|
];
|
|
1288
1318
|
if (device.slices.includes("battery")) specs.push({
|
|
@@ -1292,14 +1322,14 @@ function cameraSpecs(device) {
|
|
|
1292
1322
|
deviceClass: "battery",
|
|
1293
1323
|
unit: "%",
|
|
1294
1324
|
entityCategory: "diagnostic",
|
|
1295
|
-
enabledByDefault:
|
|
1325
|
+
enabledByDefault: true
|
|
1296
1326
|
}, {
|
|
1297
1327
|
entity: "charger",
|
|
1298
1328
|
platform: "binary_sensor",
|
|
1299
1329
|
label: "Charging",
|
|
1300
1330
|
deviceClass: "battery_charging",
|
|
1301
1331
|
entityCategory: "diagnostic",
|
|
1302
|
-
enabledByDefault:
|
|
1332
|
+
enabledByDefault: true
|
|
1303
1333
|
}, {
|
|
1304
1334
|
entity: "sleeping",
|
|
1305
1335
|
platform: "binary_sensor",
|
|
@@ -1431,7 +1461,7 @@ function cameraSpecs(device) {
|
|
|
1431
1461
|
platform: "sensor",
|
|
1432
1462
|
label: `${label} last detection`,
|
|
1433
1463
|
deviceClass: "timestamp",
|
|
1434
|
-
enabledByDefault:
|
|
1464
|
+
enabledByDefault: true
|
|
1435
1465
|
}, {
|
|
1436
1466
|
entity: `${macro}_objects`,
|
|
1437
1467
|
platform: "sensor",
|
|
@@ -1444,19 +1474,22 @@ function cameraSpecs(device) {
|
|
|
1444
1474
|
platform: "sensor",
|
|
1445
1475
|
label: `${label} last label`,
|
|
1446
1476
|
icon: "mdi:tag",
|
|
1447
|
-
enabledByDefault:
|
|
1477
|
+
enabledByDefault: true
|
|
1448
1478
|
});
|
|
1449
1479
|
}
|
|
1450
1480
|
/**
|
|
1451
|
-
* The live picture
|
|
1452
|
-
*
|
|
1453
|
-
*
|
|
1481
|
+
* The live picture — every stream the hub lists EXCEPT the adaptive one,
|
|
1482
|
+
* which the component's own entity already is. See `camera-entities.ts`
|
|
1483
|
+
* for why the entity carries a WebRTC target and not a url, and why
|
|
1484
|
+
* announcing the adaptive stream would collide on an identity that has
|
|
1485
|
+
* shipped.
|
|
1454
1486
|
*/
|
|
1455
1487
|
for (const stream of cameraStreamEntities(device.streams ?? [])) specs.push({
|
|
1456
1488
|
entity: stream.entity,
|
|
1457
1489
|
platform: stream.platform,
|
|
1458
1490
|
label: stream.label,
|
|
1459
|
-
enabledByDefault: stream.enabledByDefault
|
|
1491
|
+
enabledByDefault: stream.enabledByDefault,
|
|
1492
|
+
streamTarget: stream.target
|
|
1460
1493
|
});
|
|
1461
1494
|
/**
|
|
1462
1495
|
* The two capability-derived entities a camera carries.
|
|
@@ -1497,23 +1530,35 @@ function buildCameraPlan(device) {
|
|
|
1497
1530
|
* must name a route `CAP_COMMAND_ROUTES` can resolve, or ship read-only.**
|
|
1498
1531
|
* The route table now covers every Tier A family, so the rows below are
|
|
1499
1532
|
* writable wherever the capability declares a method that writes exactly
|
|
1500
|
-
* the value the entity carries.
|
|
1501
|
-
*
|
|
1533
|
+
* the value the entity carries.
|
|
1534
|
+
*
|
|
1535
|
+
* ── `writable` is about the CAPABILITY, not about the platform ───────────
|
|
1536
|
+
*
|
|
1537
|
+
* Six values are written by a real method and have no Home Assistant
|
|
1538
|
+
* control in the DEGRADED shape: `climate-control.fanMode`, `.preset`,
|
|
1539
|
+
* `fan-control.preset`, `water-heater.operationMode`, `humidifier.mode`,
|
|
1540
|
+
* `media-player.source` and `vacuum-control.fanSpeed` are free-form
|
|
1541
|
+
* strings whose accepted values are `available*` arrays on the DEVICE. A
|
|
1542
|
+
* static `select` cannot hold them — it would offer values the device
|
|
1543
|
+
* rejects while hiding the ones it takes — so their degraded platform is
|
|
1544
|
+
* a `sensor`.
|
|
1545
|
+
*
|
|
1546
|
+
* They are still marked `writable`, and that is the point: the flag means
|
|
1547
|
+
* "`CAP_COMMAND_ROUTES` writes exactly this value", which is what
|
|
1548
|
+
* `native-platforms.ts` asks before giving a native climate its fan-mode
|
|
1549
|
+
* control or a native vacuum its fan-speed control. `buildComponent`
|
|
1550
|
+
* announces a `command_topic` only on a platform that can carry one
|
|
1551
|
+
* (`COMMANDABLE_PLATFORMS`), so the degraded `sensor` stays a reading and
|
|
1552
|
+
* the native entity gains the control. They were read-only until
|
|
1553
|
+
* 2026-08-14 and the native controls silently did not appear, because the
|
|
1554
|
+
* one flag was answering two different questions.
|
|
1502
1555
|
*
|
|
1503
|
-
*
|
|
1504
|
-
*
|
|
1505
|
-
*
|
|
1506
|
-
*
|
|
1507
|
-
* A `select` needs a closed static list, and inventing one would offer
|
|
1508
|
-
* values the device rejects while hiding the ones it takes. Only an
|
|
1509
|
-
* enum in the cap's own schema becomes a `select` here.
|
|
1510
|
-
* - **A setter that needs two values at once.** `setTargetRange` writes
|
|
1511
|
-
* `targetLow` AND `targetHigh`; two independent `number` entities
|
|
1512
|
-
* cannot make one call without reading each other, and the catalog and
|
|
1513
|
-
* the route table are both pure. Both stay sensors.
|
|
1514
|
-
* - **A value that is not flat.** See below.
|
|
1556
|
+
* **`setTargetRange` is the one setter that needs two values at once**,
|
|
1557
|
+
* and it is routed by READING the other half first — see the note on
|
|
1558
|
+
* `target-high` below. Everything else in this table names one method
|
|
1559
|
+
* with one value.
|
|
1515
1560
|
*
|
|
1516
|
-
*
|
|
1561
|
+
* Only **a value that is not flat** still ships read-only. See below.
|
|
1517
1562
|
*
|
|
1518
1563
|
* ── Why a nested object is never a field ────────────────────────────────
|
|
1519
1564
|
*
|
|
@@ -1553,6 +1598,22 @@ var CAP_ENTITY_MAP = {
|
|
|
1553
1598
|
field: "state",
|
|
1554
1599
|
label: "Lock state",
|
|
1555
1600
|
icon: "mdi:lock-question"
|
|
1601
|
+
} },
|
|
1602
|
+
/**
|
|
1603
|
+
* `lockControl.open` — the LATCH, which is not `unlock`.
|
|
1604
|
+
*
|
|
1605
|
+
* Home Assistant spells it `LockEntityFeature.OPEN` and its native lock
|
|
1606
|
+
* offers it as a third button; the cap has declared the method since it
|
|
1607
|
+
* was written and nothing routed it, so `NATIVE_CAP_PLATFORMS`'
|
|
1608
|
+
* `open` control resolved to no entity and was skipped every time.
|
|
1609
|
+
* Registered and off in the degraded table for the same reason `stop`
|
|
1610
|
+
* is: a lock without a latch answers the press with the provider's own
|
|
1611
|
+
* refusal, and that is a button most operators never want to see.
|
|
1612
|
+
*/
|
|
1613
|
+
commands: { open: {
|
|
1614
|
+
label: "Open latch",
|
|
1615
|
+
icon: "mdi:door-open",
|
|
1616
|
+
enabledByDefault: false
|
|
1556
1617
|
} }
|
|
1557
1618
|
},
|
|
1558
1619
|
button: {
|
|
@@ -1809,12 +1870,17 @@ var CAP_ENTITY_MAP = {
|
|
|
1809
1870
|
range: PERCENT_RANGE,
|
|
1810
1871
|
enabledByDefault: false
|
|
1811
1872
|
},
|
|
1812
|
-
/**
|
|
1873
|
+
/**
|
|
1874
|
+
* Free-form: the accepted values are `availableFanModes` on the slice,
|
|
1875
|
+
* so the degraded platform is a `sensor` and only the native climate
|
|
1876
|
+
* renders the control. `setFanMode` is the route.
|
|
1877
|
+
*/
|
|
1813
1878
|
"fan-mode": {
|
|
1814
1879
|
platform: "sensor",
|
|
1815
1880
|
field: "fanMode",
|
|
1816
1881
|
label: "Fan mode",
|
|
1817
1882
|
icon: "mdi:fan",
|
|
1883
|
+
writable: true,
|
|
1818
1884
|
enabledByDefault: false
|
|
1819
1885
|
},
|
|
1820
1886
|
/** Free-form: the accepted values are `availablePresets` on the slice. */
|
|
@@ -1822,15 +1888,21 @@ var CAP_ENTITY_MAP = {
|
|
|
1822
1888
|
platform: "sensor",
|
|
1823
1889
|
field: "preset",
|
|
1824
1890
|
label: "Preset",
|
|
1891
|
+
writable: true,
|
|
1825
1892
|
enabledByDefault: false
|
|
1826
1893
|
},
|
|
1827
1894
|
/**
|
|
1828
|
-
*
|
|
1829
|
-
*
|
|
1830
|
-
*
|
|
1831
|
-
*
|
|
1832
|
-
*
|
|
1833
|
-
*
|
|
1895
|
+
* The dual setpoint — and the one route in this export that READS
|
|
1896
|
+
* before it writes.
|
|
1897
|
+
*
|
|
1898
|
+
* `setTargetRange` takes `targetLow` AND `targetHigh` together, while
|
|
1899
|
+
* Home Assistant's climate sends the two halves on two topics, so a
|
|
1900
|
+
* command carrying one of them has to learn the other before it can
|
|
1901
|
+
* make the call. `applyCapCommand` therefore reads
|
|
1902
|
+
* `climateControl.getStatus`, substitutes the half it was given, and
|
|
1903
|
+
* calls once. A device reporting `null` for the other half is REFUSED
|
|
1904
|
+
* rather than defaulted: inventing the missing bound is how a
|
|
1905
|
+
* thermostat comes to be told to hold 20 °C to 20 °C.
|
|
1834
1906
|
*/
|
|
1835
1907
|
"target-high": {
|
|
1836
1908
|
platform: "sensor",
|
|
@@ -1838,6 +1910,7 @@ var CAP_ENTITY_MAP = {
|
|
|
1838
1910
|
label: "Target high",
|
|
1839
1911
|
deviceClass: "temperature",
|
|
1840
1912
|
unit: "°C",
|
|
1913
|
+
writable: true,
|
|
1841
1914
|
enabledByDefault: false
|
|
1842
1915
|
},
|
|
1843
1916
|
"target-low": {
|
|
@@ -1846,6 +1919,7 @@ var CAP_ENTITY_MAP = {
|
|
|
1846
1919
|
label: "Target low",
|
|
1847
1920
|
deviceClass: "temperature",
|
|
1848
1921
|
unit: "°C",
|
|
1922
|
+
writable: true,
|
|
1849
1923
|
enabledByDefault: false
|
|
1850
1924
|
},
|
|
1851
1925
|
/**
|
|
@@ -1886,11 +1960,15 @@ var CAP_ENTITY_MAP = {
|
|
|
1886
1960
|
*/
|
|
1887
1961
|
range: PERCENT_RANGE,
|
|
1888
1962
|
extras: {
|
|
1889
|
-
/**
|
|
1963
|
+
/**
|
|
1964
|
+
* Free-form: the accepted values are `availablePresets` on the slice.
|
|
1965
|
+
* `fanControl.setPreset` is the route; the native fan renders it.
|
|
1966
|
+
*/
|
|
1890
1967
|
preset: {
|
|
1891
1968
|
platform: "sensor",
|
|
1892
1969
|
field: "preset",
|
|
1893
1970
|
label: "Preset",
|
|
1971
|
+
writable: true,
|
|
1894
1972
|
enabledByDefault: false
|
|
1895
1973
|
},
|
|
1896
1974
|
oscillating: {
|
|
@@ -1938,11 +2016,15 @@ var CAP_ENTITY_MAP = {
|
|
|
1938
2016
|
writable: true,
|
|
1939
2017
|
range: PERCENT_RANGE
|
|
1940
2018
|
},
|
|
1941
|
-
/**
|
|
2019
|
+
/**
|
|
2020
|
+
* Free-form: the accepted values are `availableModes` on the slice.
|
|
2021
|
+
* `humidifier.setMode` is the route; the native humidifier renders it.
|
|
2022
|
+
*/
|
|
1942
2023
|
mode: {
|
|
1943
2024
|
platform: "sensor",
|
|
1944
2025
|
field: "mode",
|
|
1945
2026
|
label: "Mode",
|
|
2027
|
+
writable: true,
|
|
1946
2028
|
enabledByDefault: false
|
|
1947
2029
|
},
|
|
1948
2030
|
action: {
|
|
@@ -1968,11 +2050,18 @@ var CAP_ENTITY_MAP = {
|
|
|
1968
2050
|
writable: true,
|
|
1969
2051
|
range: TEMPERATURE_RANGE
|
|
1970
2052
|
},
|
|
1971
|
-
/**
|
|
2053
|
+
/**
|
|
2054
|
+
* Free-form: the accepted values are `availableModes` on the slice, and
|
|
2055
|
+
* this is the entity Home Assistant's water heater uses as its STATE —
|
|
2056
|
+
* `setOperationMode` is what a native water_heater writes when the
|
|
2057
|
+
* operator picks `eco`. Read-only here left that platform with no
|
|
2058
|
+
* command at all.
|
|
2059
|
+
*/
|
|
1972
2060
|
mode: {
|
|
1973
2061
|
platform: "sensor",
|
|
1974
2062
|
field: "operationMode",
|
|
1975
2063
|
label: "Operation mode",
|
|
2064
|
+
writable: true,
|
|
1976
2065
|
enabledByDefault: false
|
|
1977
2066
|
},
|
|
1978
2067
|
away: {
|
|
@@ -2025,11 +2114,15 @@ var CAP_ENTITY_MAP = {
|
|
|
2025
2114
|
deviceClass: "battery",
|
|
2026
2115
|
unit: "%"
|
|
2027
2116
|
},
|
|
2028
|
-
/**
|
|
2117
|
+
/**
|
|
2118
|
+
* Free-form: the accepted values are `availableFanSpeeds` on the slice.
|
|
2119
|
+
* `vacuumControl.setFanSpeed` is the route; the native vacuum renders it.
|
|
2120
|
+
*/
|
|
2029
2121
|
"fan-speed": {
|
|
2030
2122
|
platform: "sensor",
|
|
2031
2123
|
field: "fanSpeed",
|
|
2032
2124
|
label: "Fan speed",
|
|
2125
|
+
writable: true,
|
|
2033
2126
|
enabledByDefault: false
|
|
2034
2127
|
},
|
|
2035
2128
|
progress: {
|
|
@@ -2135,11 +2228,16 @@ var CAP_ENTITY_MAP = {
|
|
|
2135
2228
|
icon: "mdi:volume-off",
|
|
2136
2229
|
writable: true
|
|
2137
2230
|
},
|
|
2138
|
-
/**
|
|
2231
|
+
/**
|
|
2232
|
+
* Free-form: the accepted values are `availableSources` on the slice.
|
|
2233
|
+
* `mediaPlayer.selectSource` is the route; the native media_player
|
|
2234
|
+
* renders it as its source list.
|
|
2235
|
+
*/
|
|
2139
2236
|
source: {
|
|
2140
2237
|
platform: "sensor",
|
|
2141
2238
|
field: "source",
|
|
2142
2239
|
label: "Source",
|
|
2240
|
+
writable: true,
|
|
2143
2241
|
enabledByDefault: false
|
|
2144
2242
|
},
|
|
2145
2243
|
repeat: {
|
|
@@ -2885,6 +2983,21 @@ function parseLocked(value) {
|
|
|
2885
2983
|
if (lower === "unlock" || lower === "unlocked") return false;
|
|
2886
2984
|
return null;
|
|
2887
2985
|
}
|
|
2986
|
+
/**
|
|
2987
|
+
* A free-form vocabulary value, as the DEVICE spells it.
|
|
2988
|
+
*
|
|
2989
|
+
* `fanMode`, `preset`, `operationMode`, `mode`, `fanSpeed` and `source` are
|
|
2990
|
+
* `z.string().min(1)` in their caps and their accepted values live in an
|
|
2991
|
+
* `available*` array on the slice. Nothing here validates against that list:
|
|
2992
|
+
* a pure resolution has no slice, and a second copy of the vocabulary would
|
|
2993
|
+
* be one firmware away from refusing a mode the device accepts. The device
|
|
2994
|
+
* is the authority on its own vocabulary, and its refusal surfaces with its
|
|
2995
|
+
* own words. Only EMPTY is refused here, because a blank is never a mode.
|
|
2996
|
+
*/
|
|
2997
|
+
function parseVocabulary(value) {
|
|
2998
|
+
const trimmed = value.trim();
|
|
2999
|
+
return trimmed.length === 0 ? null : trimmed;
|
|
3000
|
+
}
|
|
2888
3001
|
/** 0..100 inclusive, as `brightness.setBrightness` declares it. */
|
|
2889
3002
|
function parsePercentage(value) {
|
|
2890
3003
|
const parsed = Number(value.trim());
|
|
@@ -2948,10 +3061,13 @@ function parseEnum(schema, value) {
|
|
|
2948
3061
|
* never built, and a leftover that describes the right design reads as
|
|
2949
3062
|
* verification.
|
|
2950
3063
|
*
|
|
2951
|
-
* **Every builder names ONE method with ONE value
|
|
2952
|
-
*
|
|
2953
|
-
*
|
|
2954
|
-
*
|
|
3064
|
+
* **Every builder names ONE method with ONE value, and none of them reads
|
|
3065
|
+
* the device.** Resolution is pure and has no slice, which is why
|
|
3066
|
+
* `climate-control.setTargetRange` — the only cap method that takes two
|
|
3067
|
+
* values at once — resolves to a `climate-target-edge` carrying the half it
|
|
3068
|
+
* was given. The read of the other half belongs to {@link applyCapCommand},
|
|
3069
|
+
* which has the device; composing it here would have needed a slice this
|
|
3070
|
+
* function must not have.
|
|
2955
3071
|
*/
|
|
2956
3072
|
var CAP_COMMAND_ROUTES = {
|
|
2957
3073
|
switch: { primary: (deviceId, value) => {
|
|
@@ -2963,14 +3079,20 @@ var CAP_COMMAND_ROUTES = {
|
|
|
2963
3079
|
on
|
|
2964
3080
|
};
|
|
2965
3081
|
} },
|
|
2966
|
-
"lock-control": {
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
3082
|
+
"lock-control": {
|
|
3083
|
+
primary: (deviceId, value) => {
|
|
3084
|
+
const locked = parseLocked(value);
|
|
3085
|
+
return locked === null ? null : {
|
|
3086
|
+
kind: "cap-lock",
|
|
3087
|
+
deviceId,
|
|
3088
|
+
locked
|
|
3089
|
+
};
|
|
3090
|
+
},
|
|
3091
|
+
commands: { open: (deviceId) => ({
|
|
3092
|
+
kind: "cap-lock-open",
|
|
3093
|
+
deviceId
|
|
3094
|
+
}) }
|
|
3095
|
+
},
|
|
2974
3096
|
brightness: { primary: (deviceId, value) => {
|
|
2975
3097
|
const percentage = parsePercentage(value);
|
|
2976
3098
|
return percentage === null ? null : {
|
|
@@ -3060,6 +3182,45 @@ var CAP_COMMAND_ROUTES = {
|
|
|
3060
3182
|
targetHumidity
|
|
3061
3183
|
};
|
|
3062
3184
|
},
|
|
3185
|
+
"fan-mode": (deviceId, value) => {
|
|
3186
|
+
const fanMode = parseVocabulary(value);
|
|
3187
|
+
return fanMode === null ? null : {
|
|
3188
|
+
kind: "climate-fan-mode",
|
|
3189
|
+
deviceId,
|
|
3190
|
+
fanMode
|
|
3191
|
+
};
|
|
3192
|
+
},
|
|
3193
|
+
preset: (deviceId, value) => {
|
|
3194
|
+
const preset = parseVocabulary(value);
|
|
3195
|
+
return preset === null ? null : {
|
|
3196
|
+
kind: "climate-preset",
|
|
3197
|
+
deviceId,
|
|
3198
|
+
preset
|
|
3199
|
+
};
|
|
3200
|
+
},
|
|
3201
|
+
/**
|
|
3202
|
+
* The two halves of `setTargetRange`, each bounded by the same range
|
|
3203
|
+
* `target` is. They resolve to an EDGE rather than to a call: the other
|
|
3204
|
+
* half is a fact about the device, and resolution is pure.
|
|
3205
|
+
*/
|
|
3206
|
+
"target-low": (deviceId, value) => {
|
|
3207
|
+
const low = parseBounded(value, TEMPERATURE_MIN, TEMPERATURE_MAX);
|
|
3208
|
+
return low === null ? null : {
|
|
3209
|
+
kind: "climate-target-edge",
|
|
3210
|
+
deviceId,
|
|
3211
|
+
edge: "low",
|
|
3212
|
+
value: low
|
|
3213
|
+
};
|
|
3214
|
+
},
|
|
3215
|
+
"target-high": (deviceId, value) => {
|
|
3216
|
+
const high = parseBounded(value, TEMPERATURE_MIN, TEMPERATURE_MAX);
|
|
3217
|
+
return high === null ? null : {
|
|
3218
|
+
kind: "climate-target-edge",
|
|
3219
|
+
deviceId,
|
|
3220
|
+
edge: "high",
|
|
3221
|
+
value: high
|
|
3222
|
+
};
|
|
3223
|
+
},
|
|
3063
3224
|
"swing-vertical": (deviceId, value) => {
|
|
3064
3225
|
const on = parseBool(value);
|
|
3065
3226
|
return on === null ? null : {
|
|
@@ -3090,6 +3251,14 @@ var CAP_COMMAND_ROUTES = {
|
|
|
3090
3251
|
};
|
|
3091
3252
|
},
|
|
3092
3253
|
extras: {
|
|
3254
|
+
preset: (deviceId, value) => {
|
|
3255
|
+
const preset = parseVocabulary(value);
|
|
3256
|
+
return preset === null ? null : {
|
|
3257
|
+
kind: "fan-preset",
|
|
3258
|
+
deviceId,
|
|
3259
|
+
preset
|
|
3260
|
+
};
|
|
3261
|
+
},
|
|
3093
3262
|
oscillating: (deviceId, value) => {
|
|
3094
3263
|
const oscillating = parseBool(value);
|
|
3095
3264
|
return oscillating === null ? null : {
|
|
@@ -3117,14 +3286,24 @@ var CAP_COMMAND_ROUTES = {
|
|
|
3117
3286
|
on
|
|
3118
3287
|
};
|
|
3119
3288
|
},
|
|
3120
|
-
extras: {
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3289
|
+
extras: {
|
|
3290
|
+
"target-humidity": (deviceId, value) => {
|
|
3291
|
+
const humidity = parsePercentage(value);
|
|
3292
|
+
return humidity === null ? null : {
|
|
3293
|
+
kind: "humidifier-humidity",
|
|
3294
|
+
deviceId,
|
|
3295
|
+
humidity
|
|
3296
|
+
};
|
|
3297
|
+
},
|
|
3298
|
+
mode: (deviceId, value) => {
|
|
3299
|
+
const mode = parseVocabulary(value);
|
|
3300
|
+
return mode === null ? null : {
|
|
3301
|
+
kind: "humidifier-mode",
|
|
3302
|
+
deviceId,
|
|
3303
|
+
mode
|
|
3304
|
+
};
|
|
3305
|
+
}
|
|
3306
|
+
}
|
|
3128
3307
|
},
|
|
3129
3308
|
"water-heater": { extras: {
|
|
3130
3309
|
target: (deviceId, value) => {
|
|
@@ -3142,6 +3321,14 @@ var CAP_COMMAND_ROUTES = {
|
|
|
3142
3321
|
deviceId,
|
|
3143
3322
|
on
|
|
3144
3323
|
};
|
|
3324
|
+
},
|
|
3325
|
+
mode: (deviceId, value) => {
|
|
3326
|
+
const mode = parseVocabulary(value);
|
|
3327
|
+
return mode === null ? null : {
|
|
3328
|
+
kind: "water-heater-mode",
|
|
3329
|
+
deviceId,
|
|
3330
|
+
mode
|
|
3331
|
+
};
|
|
3145
3332
|
}
|
|
3146
3333
|
} },
|
|
3147
3334
|
valve: {
|
|
@@ -3171,33 +3358,43 @@ var CAP_COMMAND_ROUTES = {
|
|
|
3171
3358
|
})
|
|
3172
3359
|
}
|
|
3173
3360
|
},
|
|
3174
|
-
"vacuum-control": {
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3361
|
+
"vacuum-control": {
|
|
3362
|
+
extras: { "fan-speed": (deviceId, value) => {
|
|
3363
|
+
const speed = parseVocabulary(value);
|
|
3364
|
+
return speed === null ? null : {
|
|
3365
|
+
kind: "vacuum-fan-speed",
|
|
3366
|
+
deviceId,
|
|
3367
|
+
speed
|
|
3368
|
+
};
|
|
3369
|
+
} },
|
|
3370
|
+
commands: {
|
|
3371
|
+
start: (deviceId) => ({
|
|
3372
|
+
kind: "vacuum-verb",
|
|
3373
|
+
deviceId,
|
|
3374
|
+
verb: "start"
|
|
3375
|
+
}),
|
|
3376
|
+
pause: (deviceId) => ({
|
|
3377
|
+
kind: "vacuum-verb",
|
|
3378
|
+
deviceId,
|
|
3379
|
+
verb: "pause"
|
|
3380
|
+
}),
|
|
3381
|
+
stop: (deviceId) => ({
|
|
3382
|
+
kind: "vacuum-verb",
|
|
3383
|
+
deviceId,
|
|
3384
|
+
verb: "stop"
|
|
3385
|
+
}),
|
|
3386
|
+
"return-to-base": (deviceId) => ({
|
|
3387
|
+
kind: "vacuum-verb",
|
|
3388
|
+
deviceId,
|
|
3389
|
+
verb: "return-to-base"
|
|
3390
|
+
}),
|
|
3391
|
+
locate: (deviceId) => ({
|
|
3392
|
+
kind: "vacuum-verb",
|
|
3393
|
+
deviceId,
|
|
3394
|
+
verb: "locate"
|
|
3395
|
+
})
|
|
3396
|
+
}
|
|
3397
|
+
},
|
|
3201
3398
|
"lawn-mower-control": { commands: {
|
|
3202
3399
|
start: (deviceId) => ({
|
|
3203
3400
|
kind: "mower-verb",
|
|
@@ -3248,6 +3445,14 @@ var CAP_COMMAND_ROUTES = {
|
|
|
3248
3445
|
deviceId,
|
|
3249
3446
|
repeat
|
|
3250
3447
|
};
|
|
3448
|
+
},
|
|
3449
|
+
source: (deviceId, value) => {
|
|
3450
|
+
const source = parseVocabulary(value);
|
|
3451
|
+
return source === null ? null : {
|
|
3452
|
+
kind: "media-source",
|
|
3453
|
+
deviceId,
|
|
3454
|
+
source
|
|
3455
|
+
};
|
|
3251
3456
|
}
|
|
3252
3457
|
},
|
|
3253
3458
|
commands: {
|
|
@@ -3537,6 +3742,30 @@ async function applyCapCommand(device, command) {
|
|
|
3537
3742
|
if (command.locked) await device.lockControl.lock({});
|
|
3538
3743
|
else await device.lockControl.unlock({});
|
|
3539
3744
|
return { ok: true };
|
|
3745
|
+
case "cap-lock-open":
|
|
3746
|
+
if (device.lockControl === void 0) return {
|
|
3747
|
+
ok: false,
|
|
3748
|
+
reason: "no-provider",
|
|
3749
|
+
capName: "lock-control"
|
|
3750
|
+
};
|
|
3751
|
+
/**
|
|
3752
|
+
* A lock without a latch REFUSES this, and the refusal is the answer.
|
|
3753
|
+
* `lock-control` declares `open` unconditionally while the latch is a
|
|
3754
|
+
* property of the hardware, so the provider is the only thing that
|
|
3755
|
+
* knows — reporting success over its refusal would leave Home
|
|
3756
|
+
* Assistant showing a door that opened and did not.
|
|
3757
|
+
*/
|
|
3758
|
+
try {
|
|
3759
|
+
await device.lockControl.open({});
|
|
3760
|
+
} catch (err) {
|
|
3761
|
+
return {
|
|
3762
|
+
ok: false,
|
|
3763
|
+
reason: "refused",
|
|
3764
|
+
capName: "lock-control",
|
|
3765
|
+
error: err instanceof Error ? err.message : String(err)
|
|
3766
|
+
};
|
|
3767
|
+
}
|
|
3768
|
+
return { ok: true };
|
|
3540
3769
|
case "cap-brightness":
|
|
3541
3770
|
if (device.brightness === void 0) return {
|
|
3542
3771
|
ok: false,
|
|
@@ -3600,15 +3829,40 @@ async function applyCapCommand(device, command) {
|
|
|
3600
3829
|
});
|
|
3601
3830
|
case "climate-mode": return call(device.climateControl, "climate-control", (climate) => climate.setMode({ mode: command.mode }));
|
|
3602
3831
|
case "climate-target": return call(device.climateControl, "climate-control", (climate) => climate.setTarget({ target: command.target }));
|
|
3832
|
+
case "climate-fan-mode": return call(device.climateControl, "climate-control", (climate) => climate.setFanMode({ fanMode: command.fanMode }));
|
|
3833
|
+
case "climate-preset": return call(device.climateControl, "climate-control", (climate) => climate.setPreset({ preset: command.preset }));
|
|
3834
|
+
/**
|
|
3835
|
+
* The one read-then-write in this file, and the reason is the cap's
|
|
3836
|
+
* signature rather than a preference: `setTargetRange` takes both edges
|
|
3837
|
+
* and Home Assistant sends them one at a time. The OTHER edge is read
|
|
3838
|
+
* from `getStatus` — the provider's own current answer — and a `null`
|
|
3839
|
+
* there is REFUSED, because a dual-setpoint device that reports no range
|
|
3840
|
+
* has no range to move one edge of, and substituting the single `target`
|
|
3841
|
+
* would silently collapse the band to a point.
|
|
3842
|
+
*/
|
|
3843
|
+
case "climate-target-edge": return call(device.climateControl, "climate-control", async (climate) => {
|
|
3844
|
+
const status = await climate.getStatus({});
|
|
3845
|
+
if (status === null) throw new Error("the device reports no climate status, so setTargetRange has no range");
|
|
3846
|
+
const low = command.edge === "low" ? command.value : status.targetLow;
|
|
3847
|
+
const high = command.edge === "high" ? command.value : status.targetHigh;
|
|
3848
|
+
if (low === null || high === null) throw new Error(`the device reports no ${command.edge === "low" ? "high" : "low"} setpoint, so setTargetRange has nothing to preserve`);
|
|
3849
|
+
await climate.setTargetRange({
|
|
3850
|
+
targetLow: low,
|
|
3851
|
+
targetHigh: high
|
|
3852
|
+
});
|
|
3853
|
+
});
|
|
3603
3854
|
case "climate-target-humidity": return call(device.climateControl, "climate-control", (climate) => climate.setTargetHumidity({ targetHumidity: command.targetHumidity }));
|
|
3604
3855
|
case "climate-swing": return call(device.climateControl, "climate-control", (climate) => command.axis === "vertical" ? climate.setSwingVertical({ on: command.on }) : climate.setSwingHorizontal({ on: command.on }));
|
|
3605
3856
|
case "fan-percentage": return call(device.fanControl, "fan-control", (fan) => fan.setPercentage({ percentage: command.percentage }));
|
|
3857
|
+
case "fan-preset": return call(device.fanControl, "fan-control", (fan) => fan.setPreset({ preset: command.preset }));
|
|
3606
3858
|
case "fan-oscillating": return call(device.fanControl, "fan-control", (fan) => fan.setOscillating({ oscillating: command.oscillating }));
|
|
3607
3859
|
case "fan-direction": return call(device.fanControl, "fan-control", (fan) => fan.setDirection({ direction: command.direction }));
|
|
3608
3860
|
case "humidifier-on": return call(device.humidifier, "humidifier", (humidifier) => humidifier.setOn({ on: command.on }));
|
|
3609
3861
|
case "humidifier-humidity": return call(device.humidifier, "humidifier", (humidifier) => humidifier.setTargetHumidity({ humidity: command.humidity }));
|
|
3862
|
+
case "humidifier-mode": return call(device.humidifier, "humidifier", (humidifier) => humidifier.setMode({ mode: command.mode }));
|
|
3610
3863
|
case "water-heater-temp": return call(device.waterHeater, "water-heater", (heater) => heater.setTargetTemp({ temp: command.temp }));
|
|
3611
3864
|
case "water-heater-away": return call(device.waterHeater, "water-heater", (heater) => heater.setAway({ on: command.on }));
|
|
3865
|
+
case "water-heater-mode": return call(device.waterHeater, "water-heater", (heater) => heater.setOperationMode({ mode: command.mode }));
|
|
3612
3866
|
case "valve-position": return call(device.valve, "valve", (valve) => valve.setPosition({ position: command.position }));
|
|
3613
3867
|
case "valve-verb": return call(device.valve, "valve", (valve) => {
|
|
3614
3868
|
switch (command.verb) {
|
|
@@ -3626,6 +3880,7 @@ async function applyCapCommand(device, command) {
|
|
|
3626
3880
|
case "locate": return vacuum.locate({});
|
|
3627
3881
|
}
|
|
3628
3882
|
});
|
|
3883
|
+
case "vacuum-fan-speed": return call(device.vacuumControl, "vacuum-control", (vacuum) => vacuum.setFanSpeed({ speed: command.speed }));
|
|
3629
3884
|
case "mower-verb": return call(device.lawnMowerControl, "lawn-mower-control", (mower) => {
|
|
3630
3885
|
switch (command.verb) {
|
|
3631
3886
|
case "start": return mower.startMowing({});
|
|
@@ -3646,6 +3901,7 @@ async function applyCapCommand(device, command) {
|
|
|
3646
3901
|
case "media-mute": return call(device.mediaPlayer, "media-player", (media) => media.setMute({ muted: command.muted }));
|
|
3647
3902
|
case "media-shuffle": return call(device.mediaPlayer, "media-player", (media) => media.setShuffle({ shuffle: command.shuffle }));
|
|
3648
3903
|
case "media-repeat": return call(device.mediaPlayer, "media-player", (media) => media.setRepeat({ repeat: command.repeat }));
|
|
3904
|
+
case "media-source": return call(device.mediaPlayer, "media-player", (media) => media.selectSource({ source: command.source }));
|
|
3649
3905
|
case "color-mireds": return call(device.color, "color", (color) => color.setColor({ color: {
|
|
3650
3906
|
mode: "mired",
|
|
3651
3907
|
mireds: command.mireds
|
|
@@ -5727,22 +5983,12 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
5727
5983
|
}
|
|
5728
5984
|
if (entry.target.type === "camera") {
|
|
5729
5985
|
/**
|
|
5730
|
-
* The live picture
|
|
5731
|
-
* entity
|
|
5732
|
-
*
|
|
5733
|
-
*
|
|
5734
|
-
*
|
|
5986
|
+
* The live picture publishes NOTHING here, deliberately. A `camera`
|
|
5987
|
+
* entity's whole content is its `stream_target`, which travelled with
|
|
5988
|
+
* the announce; the component fetches the still and negotiates the
|
|
5989
|
+
* video itself. The url this block used to publish was read by
|
|
5990
|
+
* nobody — see `camera-entities.ts`.
|
|
5735
5991
|
*/
|
|
5736
|
-
const streams = cameraStreamValues(key, entry.streams);
|
|
5737
|
-
values.push(...streams.values);
|
|
5738
|
-
for (const refusal of streams.refused) this.warnOnce(entry.deviceId, "camera-streams", refusal.entity, {
|
|
5739
|
-
message: "ha-export: a camera entity exists and its stream URL cannot be published",
|
|
5740
|
-
meta: {
|
|
5741
|
-
entity: refusal.entity,
|
|
5742
|
-
profile: refusal.profile,
|
|
5743
|
-
reason: refusal.reason
|
|
5744
|
-
}
|
|
5745
|
-
});
|
|
5746
5992
|
const group = await this.ctx.api.pipelineOrchestrator.getCameraSwitches.query({ deviceId: entry.deviceId });
|
|
5747
5993
|
values.push(...projectCameraSwitches(key, group.switches.map((sw) => ({
|
|
5748
5994
|
id: sw.id,
|
|
@@ -5771,7 +6017,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
5771
6017
|
const zones = device.type === "camera" ? await this.loadZones(device.id) : [];
|
|
5772
6018
|
const switches = device.type === "camera" ? await this.loadSwitches(device.id) : [];
|
|
5773
6019
|
const ptzPresets = device.type === "camera" && boundCaps.includes("ptz") ? await this.loadPresets(device.id) : [];
|
|
5774
|
-
const streams = device.type === "camera" && boundCaps.includes("
|
|
6020
|
+
const streams = device.type === "camera" && boundCaps.includes("webrtc-session") ? await this.loadStreamChoices(device.id) : [];
|
|
5775
6021
|
const manufacturer = readString(device.metadata ?? {}, "manufacturer");
|
|
5776
6022
|
const model = readString(device.metadata ?? {}, "model");
|
|
5777
6023
|
return {
|
|
@@ -5798,42 +6044,30 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
5798
6044
|
};
|
|
5799
6045
|
}
|
|
5800
6046
|
/**
|
|
5801
|
-
*
|
|
6047
|
+
* Every stream of the camera, as the hub's own picker lists them.
|
|
5802
6048
|
*
|
|
5803
|
-
* `
|
|
5804
|
-
*
|
|
5805
|
-
*
|
|
5806
|
-
*
|
|
5807
|
-
*
|
|
5808
|
-
* camera
|
|
6049
|
+
* `webrtcSession.listStreams` rather than
|
|
6050
|
+
* `cameraStreams.getProfileRtspEntries`: the component negotiates live
|
|
6051
|
+
* video through `webrtcSession.handleOffer` and needs the `target`, not
|
|
6052
|
+
* an address. Reading the RTSP entries instead cost this export three
|
|
6053
|
+
* things it no longer pays — a `hostname` argument (the broker binds
|
|
6054
|
+
* `127.0.0.1`, so a camera on a hub with no reachable base url got NO
|
|
6055
|
+
* camera entity at all), a refusal for a credentialed url, and a whole
|
|
6056
|
+
* value plane nothing consumed.
|
|
5809
6057
|
*
|
|
5810
|
-
* `
|
|
5811
|
-
*
|
|
5812
|
-
* Assistant is not on the hub, so omitting it produces entities that
|
|
5813
|
-
* exist and never load — the identical failure the image entities had
|
|
5814
|
-
* before `publicBaseUrl`.
|
|
6058
|
+
* `listStreams` also reports the camera's raw substreams, which the
|
|
6059
|
+
* profile view could not express.
|
|
5815
6060
|
*/
|
|
5816
|
-
async
|
|
5817
|
-
const hostname = hostnameFromBaseUrl(this.mediaBaseUrl);
|
|
5818
|
-
if (hostname === null) {
|
|
5819
|
-
this.ctx.logger.warn("ha-export: no reachable hub hostname — this camera gets no live camera entity", {
|
|
5820
|
-
tags: { deviceId },
|
|
5821
|
-
meta: { mediaBaseUrl: this.mediaBaseUrl }
|
|
5822
|
-
});
|
|
5823
|
-
return [];
|
|
5824
|
-
}
|
|
6061
|
+
async loadStreamChoices(deviceId) {
|
|
5825
6062
|
try {
|
|
5826
|
-
return (await this.ctx.api.
|
|
5827
|
-
|
|
5828
|
-
|
|
5829
|
-
|
|
5830
|
-
|
|
5831
|
-
url: entry.url,
|
|
5832
|
-
mutedUrl: entry.mutedUrl,
|
|
5833
|
-
enabled: entry.enabled
|
|
6063
|
+
return (await this.ctx.api.webrtcSession.listStreams.query({ deviceId })).map((choice) => ({
|
|
6064
|
+
id: choice.id,
|
|
6065
|
+
label: choice.label,
|
|
6066
|
+
target: choice.target,
|
|
6067
|
+
...choice.resolution === null ? {} : { height: choice.resolution.height }
|
|
5834
6068
|
}));
|
|
5835
6069
|
} catch (err) {
|
|
5836
|
-
this.ctx.logger.warn("ha-export: could not read
|
|
6070
|
+
this.ctx.logger.warn("ha-export: could not read the camera streams, no camera entity", {
|
|
5837
6071
|
tags: { deviceId },
|
|
5838
6072
|
meta: { error: errMsg(err) }
|
|
5839
6073
|
});
|