@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.
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  //#endregion
6
- const require_dist = require("../dist-yXVqdsa3.js");
6
+ const require_dist = require("../dist-ByKdDxGp.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 stream profile, muted and unmuted.
115
+ * The live `camera` entities — one per STREAM of the camera.
116
116
  *
117
- * `HaPlatform` has declared `camera` since the export was written and the
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
- * ── The two traps, both measured ────────────────────────────────────────
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
- * **1. The password.** `streamCatalog.getCatalog` returns the camera's
125
- * NATIVE url `rtsp://admin:hunter2@192.168.1.50:554/…`. Writing that
126
- * into a `state_update` would put the camera's password into Home
127
- * Assistant's entity registry and into every debug log it writes, on a
128
- * host camstack does not own. So the source here is the BROKER restream
129
- * (`cameraStreams.getProfileRtspEntries`), which carries no credentials,
130
- * and {@link sanitiseStreamUrl} refuses anything with a userinfo section
131
- * anyway — a second source, a provider change or a future caller must not
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
- * **2. The host.** The broker binds `127.0.0.1` and mints its restream
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
- * ── Fan-out ─────────────────────────────────────────────────────────────
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
- * Three profiles × two mute states is six entities on a fleet of ~880
148
- * cameras. Only the FIRST is `enabled_by_default`; the rest are
149
- * registered-and-off, for the operator to switch on per camera. That is
150
- * the same pressure valve the camera catalog already uses, applied to the
151
- * one part of this export that could double a camera's entity count.
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
- /** Hosts that are only routable from the hub itself. */
160
- var LOOPBACK_HOSTS = [
161
- "127.0.0.1",
162
- "localhost",
163
- "::1",
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
- * The one place a stream URL is judged fit to leave the hub.
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
- * Deliberately a REFUSAL rather than a repair: rewriting a loopback host
171
- * here would need an address this module does not have, and stripping a
172
- * userinfo section would produce a URL that dials and fails
173
- * authentication both hide the defect instead of reporting it.
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 sanitiseStreamUrl(url) {
176
- let parsed;
177
- try {
178
- parsed = new URL(url);
179
- } catch {
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 host Home Assistant can reach the hub on, from the base URL the
204
- * media plane already resolved.
198
+ * The adaptive stream is the component's own entity and gets no component.
205
199
  *
206
- * The same answer feeds both planes on purpose. `publicBaseUrl` (or the
207
- * lowest-priority non-loopback endpoint behind it) is the ONE address the
208
- * operator has told us Home Assistant can open; a second discovery here
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 hostnameFromBaseUrl(baseUrl) {
213
- if (baseUrl === null || baseUrl.length === 0) return null;
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
- /** `high` → 0. An unknown profile sorts after the known ones, stably. */
223
- function profileRank(profile) {
224
- const index = CAMERA_PROFILE_ORDER.indexOf(profile);
225
- return index === -1 ? CAMERA_PROFILE_ORDER.length : index;
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
- * The profiles that produce entities: assigned, restream-enabled, in
229
- * `high → mid → low` order, capped at {@link MAX_CAMERA_PROFILES}.
225
+ * Enabled by default: the assigned profiles.
230
226
  *
231
- * A DISABLED profile produces nothing. Its `url` is minted all the same
232
- * by the broker, and publishing it would give the operator a camera
233
- * entity that dials a restream nobody is serving a control that lies,
234
- * in the shape D62 forbids for switches.
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 activeStreamProfiles(profiles) {
237
- return [...profiles].filter((entry) => entry.enabled).sort((a, b) => profileRank(a.profile) - profileRank(b.profile)).slice(0, 3);
232
+ function enabledByDefaultFor(target) {
233
+ return target.kind !== "cam-stream";
238
234
  }
239
235
  /**
240
- * Profile list the `camera` entities the catalog builds.
236
+ * The label an operator reads on a card.
241
237
  *
242
- * Pure and URL-free: an entity EXISTS because a profile is assigned and
243
- * enabled, and it carries a value only when {@link cameraStreamValues}
244
- * judges that profile's URL publishable. The two questions are separate
245
- * because a URL that becomes publishable later (the operator sets
246
- * `publicBaseUrl`) must fill an entity that is already in HA's registry,
247
- * not mint a new one and orphan the automations pointing at the old id.
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 cameraStreamEntities(profiles) {
250
- const entities = [];
251
- for (const entry of activeStreamProfiles(profiles)) {
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
- * The entities' values: one URL per (profile, mute) pair.
250
+ * Stream list the `camera` entities the catalog builds.
266
251
  *
267
- * The muted variant is the broker's own `mutedUrl`, not a query-string
268
- * edit of `url` deriving one topic by string-editing another is the
269
- * mistake `topics.ts` records, and the broker is the authority on what a
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 cameraStreamValues(deviceKey, profiles) {
273
- const values = [];
274
- const refused = [];
275
- for (const entity of cameraStreamEntities(profiles)) {
276
- const source = profiles.find((entry) => entry.profile === entity.profile);
277
- if (source === void 0) continue;
278
- const decision = sanitiseStreamUrl(entity.muted ? source.mutedUrl : source.url);
279
- if (!decision.ok) {
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 === "button" ? {} : { state_topic: stateTopic(deviceKey, spec.entity) },
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: false
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: false
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: false
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: false
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: false
1477
+ enabledByDefault: true
1448
1478
  });
1449
1479
  }
1450
1480
  /**
1451
- * The live picture. Six at most, only the first switched on see
1452
- * `camera-entities.ts` for the password and loopback traps this side
1453
- * deliberately knows nothing about.
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. Three things still ship read-only, each
1501
- * for a reason that is a property of the value and not of the table:
1533
+ * the value the entity carries.
1502
1534
  *
1503
- * - **A vocabulary that lives on the slice.** `climate-control.fanMode`,
1504
- * `.preset`, `water-heater.operationMode`, `humidifier.mode`,
1505
- * `media-player.source` and `vacuum-control.fanSpeed` are free-form
1506
- * strings whose accepted values are `available*` arrays on the DEVICE.
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.
1535
+ * ── `writable` is about the CAPABILITY, not about the platform ───────────
1515
1536
  *
1516
- * A row that is read-only for one of those reasons says so where it sits.
1537
+ * Six values are written by a real method and have no Home Assistant
1538
+ * control in the DEGRADED shape: `climate-control.fanMode`, `.preset`,
1539
+ * `fan-control.preset`, `water-heater.operationMode`, `humidifier.mode`,
1540
+ * `media-player.source` and `vacuum-control.fanSpeed` are free-form
1541
+ * strings whose accepted values are `available*` arrays on the DEVICE. A
1542
+ * static `select` cannot hold them — it would offer values the device
1543
+ * rejects while hiding the ones it takes — so their degraded platform is
1544
+ * a `sensor`.
1545
+ *
1546
+ * They are still marked `writable`, and that is the point: the flag means
1547
+ * "`CAP_COMMAND_ROUTES` writes exactly this value", which is what
1548
+ * `native-platforms.ts` asks before giving a native climate its fan-mode
1549
+ * control or a native vacuum its fan-speed control. `buildComponent`
1550
+ * announces a `command_topic` only on a platform that can carry one
1551
+ * (`COMMANDABLE_PLATFORMS`), so the degraded `sensor` stays a reading and
1552
+ * the native entity gains the control. They were read-only until
1553
+ * 2026-08-14 and the native controls silently did not appear, because the
1554
+ * one flag was answering two different questions.
1555
+ *
1556
+ * **`setTargetRange` is the one setter that needs two values at once**,
1557
+ * and it is routed by READING the other half first — see the note on
1558
+ * `target-high` below. Everything else in this table names one method
1559
+ * with one value.
1560
+ *
1561
+ * Only **a value that is not flat** still ships read-only. See below.
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
- /** Free-form: the accepted values are `availableFanModes` on the slice. */
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
- * Read-only, and not for want of a method: `setTargetRange` writes
1829
- * `targetLow` AND `targetHigh` in one call. Two independent `number`
1830
- * entities would each have to read the other's current value to make it,
1831
- * and both the catalog and the route table are pure. A dual-setpoint
1832
- * control belongs to the native `climate` platform (Tier B), which
1833
- * carries the pair as one entity.
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
- /** Free-form: the accepted values are `availablePresets` on the slice. */
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
- /** Free-form: the accepted values are `availableModes` on the slice. */
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
- /** Free-form: the accepted values are `availableModes` on the slice. */
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
- /** Free-form: the accepted values are `availableFanSpeeds` on the slice. */
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
- /** Free-form: the accepted values are `availableSources` on the slice. */
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.** Nothing here composes
2952
- * two slice fields into one call, because the resolution is pure and has no
2953
- * slice: `climate-control.setTargetRange` writes `targetLow` and
2954
- * `targetHigh` together and is therefore absent, not approximated.
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": { primary: (deviceId, value) => {
2967
- const locked = parseLocked(value);
2968
- return locked === null ? null : {
2969
- kind: "cap-lock",
2970
- deviceId,
2971
- locked
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: { "target-humidity": (deviceId, value) => {
3121
- const humidity = parsePercentage(value);
3122
- return humidity === null ? null : {
3123
- kind: "humidifier-humidity",
3124
- deviceId,
3125
- humidity
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": { commands: {
3175
- start: (deviceId) => ({
3176
- kind: "vacuum-verb",
3177
- deviceId,
3178
- verb: "start"
3179
- }),
3180
- pause: (deviceId) => ({
3181
- kind: "vacuum-verb",
3182
- deviceId,
3183
- verb: "pause"
3184
- }),
3185
- stop: (deviceId) => ({
3186
- kind: "vacuum-verb",
3187
- deviceId,
3188
- verb: "stop"
3189
- }),
3190
- "return-to-base": (deviceId) => ({
3191
- kind: "vacuum-verb",
3192
- deviceId,
3193
- verb: "return-to-base"
3194
- }),
3195
- locate: (deviceId) => ({
3196
- kind: "vacuum-verb",
3197
- deviceId,
3198
- verb: "locate"
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
@@ -5261,7 +5517,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
5261
5517
  const plateText = readString(data, "plateText");
5262
5518
  const lastSeen = readNumber(data, "lastSeen") ?? Date.now();
5263
5519
  const zonesVisited = readStringArray(data, "zonesVisited");
5264
- const mediaUrl = this.mintTrackMediaUrl(trackId);
5520
+ const mediaUrl = this.mintTrackMediaUrl(trackId, deviceId);
5265
5521
  const values = projectTrackLifecycle(deviceKey, {
5266
5522
  deviceId,
5267
5523
  trackId,
@@ -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. A refused URL is DROPPED WORK and says so: an
5731
- * entity Home Assistant shows and never loads is indistinguishable
5732
- * from a camera that is down, and `credentials-inline` in
5733
- * particular means a source handed us the camera password which
5734
- * is a defect to fix, not a value to publish.
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("camera-streams") ? await this.loadStreamProfiles(device.id) : [];
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
- * The camera's assigned profile slots, as broker restream URLs.
6047
+ * Every stream of the camera, as the hub's own picker lists them.
5802
6048
  *
5803
- * `getProfileRtspEntries`, never `streamCatalog.getCatalog`: the latter
5804
- * returns the camera's NATIVE url with its password inline, and this
5805
- * value is written into Home Assistant's entity registry and its debug
5806
- * logs. `camera-entities.ts` refuses a credentialed URL as well two
5807
- * gates, because the cost of one of them being wrong is the operator's
5808
- * camera password on a host we do not own.
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
- * `hostname` is passed EXPLICITLY. The broker binds `127.0.0.1` and
5811
- * mints its URLs against whatever address it was asked for; Home
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 loadStreamProfiles(deviceId) {
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.cameraStreams.getProfileRtspEntries.query({
5827
- deviceId,
5828
- hostname
5829
- })).map((entry) => ({
5830
- profile: entry.profile,
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 stream profiles, no camera entity", {
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
  });
@@ -6346,10 +6580,10 @@ var HaExportAddon = class extends require_dist.BaseAddon {
6346
6580
  res.end();
6347
6581
  return;
6348
6582
  }
6349
- const trackId = decodeURIComponent(id);
6583
+ const compositeId = decodeURIComponent(id);
6350
6584
  if (!verifyMediaSignature({
6351
6585
  secret: this.mediaSecret,
6352
- id: trackId,
6586
+ id: compositeId,
6353
6587
  exp: url.searchParams.get("exp") ?? void 0,
6354
6588
  sig: url.searchParams.get("sig") ?? void 0,
6355
6589
  nowMs: Date.now()
@@ -6358,9 +6592,18 @@ var HaExportAddon = class extends require_dist.BaseAddon {
6358
6592
  res.end();
6359
6593
  return;
6360
6594
  }
6595
+ const sep = compositeId.indexOf(":");
6596
+ const deviceId = sep > 0 ? Number(compositeId.slice(0, sep)) : NaN;
6597
+ const trackId = sep > 0 ? compositeId.slice(sep + 1) : "";
6598
+ if (!Number.isInteger(deviceId) || deviceId <= 0 || trackId.length === 0) {
6599
+ res.statusCode = 400;
6600
+ res.end();
6601
+ return;
6602
+ }
6361
6603
  try {
6362
6604
  const first = (await this.ctx.api.pipelineAnalytics.getTrackMedia.query({
6363
6605
  trackId,
6606
+ deviceId,
6364
6607
  kinds: [
6365
6608
  "thumbnailSmall",
6366
6609
  "thumbnail",
@@ -6393,12 +6636,12 @@ var HaExportAddon = class extends require_dist.BaseAddon {
6393
6636
  * cannot open is worse than no image, because it renders as a broken
6394
6637
  * entity rather than an empty one.
6395
6638
  */
6396
- mintTrackMediaUrl(trackId) {
6639
+ mintTrackMediaUrl(trackId, deviceId) {
6397
6640
  if (this.mediaBaseUrl === null || this.mediaSecret.length === 0) return null;
6398
6641
  return buildMediaUrl({
6399
6642
  baseUrl: this.mediaBaseUrl,
6400
6643
  routePrefix: MEDIA_ROUTE_PREFIX,
6401
- id: trackId,
6644
+ id: `${String(deviceId)}:${trackId}`,
6402
6645
  secret: this.mediaSecret,
6403
6646
  expMs: Date.now() + MEDIA_URL_TTL_MS
6404
6647
  });