@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.
@@ -1,4 +1,4 @@
1
- import { M as deviceExportCapability, Ot as EventCategory, S as buildAddonRouteProvider, Y as oauthIntegrationCapability, a as CameraSwitchIdSchema, c as HvacModeSchema, f as addonRoutesCapability, ft as BaseAddon, i as COCO_TO_MACRO, l as MediaPlayerRepeatSchema, s as FanDirectionSchema, t as AlarmArmModeSchema, wt as string } from "../dist-D_VrlKNr.mjs";
1
+ import { M as deviceExportCapability, Ot as EventCategory, S as buildAddonRouteProvider, Y as oauthIntegrationCapability, a as CameraSwitchIdSchema, c as HvacModeSchema, f as addonRoutesCapability, ft as BaseAddon, i as COCO_TO_MACRO, l as MediaPlayerRepeatSchema, s as FanDirectionSchema, t as AlarmArmModeSchema, wt as string } from "../dist-CzZXxFrl.mjs";
2
2
  import { createHmac, timingSafeEqual } from "node:crypto";
3
3
  //#region src/ha-export/topics.ts
4
4
  /**
@@ -107,43 +107,57 @@ function toSlug(value) {
107
107
  //#endregion
108
108
  //#region src/ha-export/camera-entities.ts
109
109
  /**
110
- * The live `camera` entities — one per stream profile, muted and unmuted.
110
+ * The live `camera` entities — one per STREAM of the camera.
111
111
  *
112
- * `HaPlatform` has declared `camera` since the export was written and the
113
- * catalog never emitted one, so an operator's Home Assistant carried ~73
114
- * entities about a camera and no picture of it. This module is that half:
115
- * pure, so the URL policy below is tested rather than reasoned about.
112
+ * ── What was wrong, measured on the operator's Home Assistant ────────────
116
113
  *
117
- * ── The two traps, both measured ────────────────────────────────────────
114
+ * This module used to mint six components per camera (three profiles ×
115
+ * muted/unmuted), each carrying a broker RTSP url on a `state_topic`,
116
+ * behind two url gates: a refusal for a credential in the authority and a
117
+ * refusal for a loopback host. On 2026-08-14 the live registry was read:
118
+ * `platform: camstack` held **165 entities and exactly two `camera` ones**
119
+ * — one per exported camera, `unique_id` `camstack_<stableId>_camera`.
120
+ * None of the twelve announced stream components existed.
118
121
  *
119
- * **1. The password.** `streamCatalog.getCatalog` returns the camera's
120
- * NATIVE url `rtsp://admin:hunter2@192.168.1.50:554/…`. Writing that
121
- * into a `state_update` would put the camera's password into Home
122
- * Assistant's entity registry and into every debug log it writes, on a
123
- * host camstack does not own. So the source here is the BROKER restream
124
- * (`cameraStreams.getProfileRtspEntries`), which carries no credentials,
125
- * and {@link sanitiseStreamUrl} refuses anything with a userinfo section
126
- * anyway — a second source, a provider change or a future caller must not
127
- * be able to leak it by accident. A refusal produces NO entity value; it
128
- * never produces a redacted-looking URL that would then be dialled.
122
+ * They never could. `custom_components/camstack/camera.py` builds its
123
+ * cameras from the export MEMBERSHIP, not from `cmps`: a still comes from
124
+ * `snapshot.getSnapshot` and live video from `webrtcSession.handleOffer`,
125
+ * which is native WebRTC and has no use for an RTSP address. The
126
+ * component's platform probe answers `camera` truthfully, it builds the
127
+ * platform and the hub read that as "it will build MY camera
128
+ * components". Twelve components, two entities, no warning on either
129
+ * side.
129
130
  *
130
- * **2. The host.** The broker binds `127.0.0.1` and mints its restream
131
- * URLs against whatever address it was asked for. Home Assistant is not
132
- * on the hub, so a loopback URL yields an entity that exists and never
133
- * loads — the identical failure the image entities had before
134
- * `publicBaseUrl` was added. The caller passes `hostname` explicitly
135
- * (`getProfileRtspEntries({ deviceId, hostname })`) and this module
136
- * REFUSES a loopback URL rather than publishing one, because "the picture
137
- * never appears" is indistinguishable from "the camera is down" on a
138
- * dashboard.
131
+ * ── What this module is now ──────────────────────────────────────────────
139
132
  *
140
- * ── Fan-out ─────────────────────────────────────────────────────────────
133
+ * One component per entry of `webrtcSession.listStreams`, carrying the
134
+ * `target` verbatim so the component hands it back to `handleOffer`. No
135
+ * url crosses, which is why the two gates are gone rather than kept "just
136
+ * in case": there is no address here to leak a password or to point at
137
+ * 127.0.0.1, and a gate guarding a value that no longer exists is the
138
+ * leftover that reads as verification.
141
139
  *
142
- * Three profiles × two mute states is six entities on a fleet of ~880
143
- * cameras. Only the FIRST is `enabled_by_default`; the rest are
144
- * registered-and-off, for the operator to switch on per camera. That is
145
- * the same pressure valve the camera catalog already uses, applied to the
146
- * one part of this export that could double a camera's entity count.
140
+ * ── The adaptive stream is announced by NOBODY, on purpose ───────────────
141
+ *
142
+ * The component's membership entity IS the adaptive stream: it negotiates
143
+ * no target, it is the device's primary camera (`_attr_name = None`), and
144
+ * it has carried `camstack_<stableId>_camera` since the integration
145
+ * shipped. Announcing a component for it would claim the same
146
+ * `unique_id` — Home Assistant keys its registry on
147
+ * `(platform, integration, unique_id)` — and one of the two would lose the
148
+ * race and be dropped with a "does not generate unique IDs" error. So the
149
+ * operator's existing `camera.videocamera_ingresso` is left exactly where
150
+ * it is and gains SIBLINGS: one per profile, one per substream, which are
151
+ * the streams membership cannot express.
152
+ *
153
+ * ── Fan-out ──────────────────────────────────────────────────────────────
154
+ *
155
+ * Every remaining stream is announced. What arrives ENABLED is the
156
+ * assigned profiles — the pictures an operator picks between on a
157
+ * dashboard. The raw `cam-stream` sources (the camera's own main/sub
158
+ * substreams) are registered-and-off: they are the same pictures the
159
+ * profiles already serve, one layer below the slot the rest of camstack
160
+ * dials, and an operator who wants one switches it on per camera.
147
161
  */
148
162
  /** Profile slots, in the order an operator reads them. */
149
163
  var CAMERA_PROFILE_ORDER = [
@@ -151,143 +165,97 @@ var CAMERA_PROFILE_ORDER = [
151
165
  "mid",
152
166
  "low"
153
167
  ];
154
- /** Hosts that are only routable from the hub itself. */
155
- var LOOPBACK_HOSTS = [
156
- "127.0.0.1",
157
- "localhost",
158
- "::1",
159
- "[::1]",
160
- "0.0.0.0"
161
- ];
168
+ /** `high` 0. An unknown profile sorts after the known ones, stably. */
169
+ function profileRank(profile) {
170
+ const index = CAMERA_PROFILE_ORDER.indexOf(profile);
171
+ return index === -1 ? CAMERA_PROFILE_ORDER.length : index;
172
+ }
162
173
  /**
163
- * The one place a stream URL is judged fit to leave the hub.
174
+ * Reading order: adaptive, then the profile slots high mid low, then
175
+ * whatever raw sources the camera reports, in the hub's own order.
164
176
  *
165
- * Deliberately a REFUSAL rather than a repair: rewriting a loopback host
166
- * here would need an address this module does not have, and stripping a
167
- * userinfo section would produce a URL that dials and fails
168
- * authentication both hide the defect instead of reporting it.
177
+ * Rank only. The one stream that does NOT become a component is the
178
+ * adaptive one, and it is filtered by {@link isAnnounced} rather than
179
+ * here, because it is not a drop: it reaches Home Assistant on the
180
+ * membership entity. Every other slot the hub reports becomes an entity —
181
+ * "include all the streams of a camera" is the request this module exists
182
+ * to answer, and a slot silently dropped is a loss the operator cannot see
183
+ * from Home Assistant.
169
184
  */
170
- function sanitiseStreamUrl(url) {
171
- let parsed;
172
- try {
173
- parsed = new URL(url);
174
- } catch {
175
- return {
176
- ok: false,
177
- reason: "unparseable"
178
- };
185
+ function streamRank(target) {
186
+ switch (target.kind) {
187
+ case "adaptive": return 0;
188
+ case "profile": return 1 + profileRank(target.profile);
189
+ case "cam-stream": return 1 + CAMERA_PROFILE_ORDER.length;
179
190
  }
180
- if (parsed.username.length > 0 || parsed.password.length > 0) return {
181
- ok: false,
182
- reason: "credentials-inline"
183
- };
184
- if (parsed.hostname.length === 0) return {
185
- ok: false,
186
- reason: "unparseable"
187
- };
188
- if (LOOPBACK_HOSTS.includes(parsed.hostname.toLowerCase())) return {
189
- ok: false,
190
- reason: "loopback-host"
191
- };
192
- return {
193
- ok: true,
194
- url
195
- };
196
191
  }
197
192
  /**
198
- * The host Home Assistant can reach the hub on, from the base URL the
199
- * media plane already resolved.
193
+ * The adaptive stream is the component's own entity and gets no component.
200
194
  *
201
- * The same answer feeds both planes on purpose. `publicBaseUrl` (or the
202
- * lowest-priority non-loopback endpoint behind it) is the ONE address the
203
- * operator has told us Home Assistant can open; a second discovery here
204
- * could disagree with the one the image entities use, and then half a
205
- * camera's entities would load.
195
+ * The one filter in this file, and the reason it is not the "drop a slot"
196
+ * kind: the stream still reaches Home Assistant, on the entity that has
197
+ * always carried it. See the identity note above.
206
198
  */
207
- function hostnameFromBaseUrl(baseUrl) {
208
- if (baseUrl === null || baseUrl.length === 0) return null;
209
- try {
210
- const host = new URL(baseUrl).hostname;
211
- if (host.length === 0 || LOOPBACK_HOSTS.includes(host.toLowerCase())) return null;
212
- return host;
213
- } catch {
214
- return null;
215
- }
199
+ function isAnnounced(target) {
200
+ return target.kind !== "adaptive";
216
201
  }
217
- /** `high` → 0. An unknown profile sorts after the known ones, stably. */
218
- function profileRank(profile) {
219
- const index = CAMERA_PROFILE_ORDER.indexOf(profile);
220
- return index === -1 ? CAMERA_PROFILE_ORDER.length : index;
202
+ /**
203
+ * The entity id of one stream.
204
+ *
205
+ * Derived from the TARGET rather than from `choice.id`, so the ids stay
206
+ * `stream_high` / `stream_mid` / `stream_low` — what this file already
207
+ * announced — instead of following the picker's `profile:high` spelling.
208
+ * An entity id is identity; the picker's id is a React key.
209
+ */
210
+ function entityIdFor(target) {
211
+ switch (target.kind) {
212
+ case "adaptive":
213
+ /* c8 ignore next -- filtered by isAnnounced before it can be reached */
214
+ return "stream_adaptive";
215
+ case "profile": return `stream_${toSlug(target.profile)}`;
216
+ case "cam-stream": return `stream_${toSlug(target.camStreamId)}`;
217
+ }
221
218
  }
222
219
  /**
223
- * The profiles that produce entities: assigned, restream-enabled, in
224
- * `high → mid → low` order, capped at {@link MAX_CAMERA_PROFILES}.
220
+ * Enabled by default: the assigned profiles.
225
221
  *
226
- * A DISABLED profile produces nothing. Its `url` is minted all the same
227
- * by the broker, and publishing it would give the operator a camera
228
- * entity that dials a restream nobody is serving a control that lies,
229
- * in the shape D62 forbids for switches.
222
+ * A raw `cam-stream` is the source a profile slot already points at. It
223
+ * is announced so an operator who wants the camera's own substream can
224
+ * have it, and it is off so that a camera does not arrive with six
225
+ * pictures of the same doorway switched on.
230
226
  */
231
- function activeStreamProfiles(profiles) {
232
- return [...profiles].filter((entry) => entry.enabled).sort((a, b) => profileRank(a.profile) - profileRank(b.profile)).slice(0, 3);
227
+ function enabledByDefaultFor(target) {
228
+ return target.kind !== "cam-stream";
233
229
  }
234
230
  /**
235
- * Profile list the `camera` entities the catalog builds.
231
+ * The label an operator reads on a card.
236
232
  *
237
- * Pure and URL-free: an entity EXISTS because a profile is assigned and
238
- * enabled, and it carries a value only when {@link cameraStreamValues}
239
- * judges that profile's URL publishable. The two questions are separate
240
- * because a URL that becomes publishable later (the operator sets
241
- * `publicBaseUrl`) must fill an entity that is already in HA's registry,
242
- * not mint a new one and orphan the automations pointing at the old id.
233
+ * The hub's own label plus the vertical resolution, because that is the
234
+ * one thing that distinguishes three pictures of the same doorway —
235
+ * "Stream high (2160p)" answers "which of these do I put on the wall
236
+ * tablet" and "Stream high" does not. The resolution is only in the label
237
+ * (never in the id), so a re-encoded profile renames an entity and never
238
+ * orphans one.
243
239
  */
244
- function cameraStreamEntities(profiles) {
245
- const entities = [];
246
- for (const entry of activeStreamProfiles(profiles)) {
247
- const slug = toSlug(entry.profile);
248
- for (const muted of [false, true]) entities.push({
249
- entity: muted ? `stream_${slug}_muted` : `stream_${slug}`,
250
- platform: "camera",
251
- label: muted ? `Stream ${entry.profile} (muted)` : `Stream ${entry.profile}`,
252
- enabledByDefault: entities.length === 0,
253
- profile: entry.profile,
254
- muted
255
- });
256
- }
257
- return entities;
240
+ function labelFor(choice) {
241
+ const base = `Stream ${choice.label.toLowerCase()}`;
242
+ return choice.height === void 0 ? base : `${base} (${choice.height}p)`;
258
243
  }
259
244
  /**
260
- * The entities' values: one URL per (profile, mute) pair.
245
+ * Stream list the `camera` entities the catalog builds.
261
246
  *
262
- * The muted variant is the broker's own `mutedUrl`, not a query-string
263
- * edit of `url` deriving one topic by string-editing another is the
264
- * mistake `topics.ts` records, and the broker is the authority on what a
265
- * muted alias of a profile is called.
247
+ * Pure, and value-free: a `camera` entity has no state on the push plane
248
+ * at all. It exists because the hub lists the stream, and everything the
249
+ * component needs to show it travels in `stream_target`.
266
250
  */
267
- function cameraStreamValues(deviceKey, profiles) {
268
- const values = [];
269
- const refused = [];
270
- for (const entity of cameraStreamEntities(profiles)) {
271
- const source = profiles.find((entry) => entry.profile === entity.profile);
272
- if (source === void 0) continue;
273
- const decision = sanitiseStreamUrl(entity.muted ? source.mutedUrl : source.url);
274
- if (!decision.ok) {
275
- refused.push({
276
- entity: entity.entity,
277
- profile: entity.profile,
278
- reason: decision.reason
279
- });
280
- continue;
281
- }
282
- values.push({
283
- topic: stateTopic(deviceKey, entity.entity),
284
- value: decision.url
285
- });
286
- }
287
- return {
288
- values,
289
- refused
290
- };
251
+ function cameraStreamEntities(choices) {
252
+ return [...choices].filter((choice) => isAnnounced(choice.target)).sort((a, b) => streamRank(a.target) - streamRank(b.target)).map((choice) => ({
253
+ entity: entityIdFor(choice.target),
254
+ platform: "camera",
255
+ label: labelFor(choice),
256
+ enabledByDefault: enabledByDefaultFor(choice.target),
257
+ target: choice.target
258
+ }));
291
259
  }
292
260
  //#endregion
293
261
  //#region src/ha-export/component-support.ts
@@ -1040,6 +1008,29 @@ var ZONE_MACROS = [
1040
1008
  * what the export is for. The three `*_last_image` entities — camera,
1041
1009
  * per macro, per zone × macro — therefore ship enabled. They cost one
1042
1010
  * entity each and carry a signed URL, not bytes.
1011
+ *
1012
+ * **So are the three things the operator asked for on 2026-08-14**, and
1013
+ * each has a reason that is a property of the value rather than a taste:
1014
+ *
1015
+ * - **`<macro>_last_detection`, and the camera's own.** "When was a
1016
+ * person last seen" is a timestamp, and half of what an operator
1017
+ * builds on a camera is a duration since one — a `for:` in a
1018
+ * template, a "nobody has been at the door since" notification. A
1019
+ * boolean that is `off` right now cannot answer it.
1020
+ * - **`<macro>_last_label`.** This is where the RECOGNISED FACE and the
1021
+ * read PLATE arrive. It was the single highest-value item in the gap
1022
+ * against the reference integration and it shipped switched off, so
1023
+ * the name camstack worked out was in Home Assistant and invisible.
1024
+ * - **`battery` and `charger` on a battery camera.** A battery
1025
+ * percentage is the headline of a battery device, not a diagnostic
1026
+ * detail about one; `sleeping` stays off because it describes the
1027
+ * power state machine rather than the charge.
1028
+ *
1029
+ * What stays off is what the valve was built for: the per-macro and
1030
+ * per-zone `*_objects` COUNTERS (a count of what is on screen right now,
1031
+ * which the `_detected` boolean and the camera-wide `objects` total
1032
+ * already answer for automation), the per-zone timestamps, and the
1033
+ * doorbell's since-restart counter.
1043
1034
  */
1044
1035
  /**
1045
1036
  * The snooze surface, as a `select`.
@@ -1074,6 +1065,44 @@ var PTZ_BUTTONS = [
1074
1065
  "ptz_zoom_in",
1075
1066
  "ptz_zoom_out"
1076
1067
  ];
1068
+ /**
1069
+ * Platforms whose entity carries no value on the push plane.
1070
+ *
1071
+ * A `button` is a command. A `camera` is a stream the component fetches
1072
+ * for itself (`snapshot.getSnapshot` + `webrtcSession.handleOffer`) from
1073
+ * the target it is given. Announcing a `state_topic` for either would
1074
+ * declare a value nothing ever publishes — which is precisely how twelve
1075
+ * camera components came to sit in an announce that produced no entity.
1076
+ */
1077
+ var STATELESS_PLATFORMS = new Set(["button", "camera"]);
1078
+ /**
1079
+ * Platforms that can carry a command topic.
1080
+ *
1081
+ * `writable` on a descriptor means "the route table writes exactly this
1082
+ * value" — a fact about the CAPABILITY. Whether Home Assistant can offer
1083
+ * a control for it is a fact about the PLATFORM, and the two are not the
1084
+ * same: `climate-control.fanMode` is written by `setFanMode` and has no
1085
+ * closed vocabulary, so its degraded form is a `sensor` and its native
1086
+ * form is the climate entity's fan-mode control. Announcing a command
1087
+ * topic on the degraded `sensor` would describe a control Home Assistant
1088
+ * cannot render — a leftover that reads as verification.
1089
+ */
1090
+ var COMMANDABLE_PLATFORMS = new Set([
1091
+ "switch",
1092
+ "button",
1093
+ "select",
1094
+ "number",
1095
+ "alarm_control_panel",
1096
+ "cover",
1097
+ "climate",
1098
+ "lock",
1099
+ "fan",
1100
+ "vacuum",
1101
+ "valve",
1102
+ "humidifier",
1103
+ "water_heater",
1104
+ "media_player"
1105
+ ]);
1077
1106
  /** 0..100, the shape every camstack percentage declares in its own schema. */
1078
1107
  var PERCENT_RANGE = {
1079
1108
  min: 0,
@@ -1107,8 +1136,8 @@ function buildComponent(device, spec) {
1107
1136
  platform: spec.platform,
1108
1137
  unique_id: `${device.stableId}_${spec.uniqueSuffix ?? spec.entity}`,
1109
1138
  name: spec.label,
1110
- ...spec.platform === "button" ? {} : { state_topic: stateTopic(deviceKey, spec.entity) },
1111
- ...spec.writable === true ? { command_topic: commandTopic(deviceKey, spec.entity) } : {},
1139
+ ...STATELESS_PLATFORMS.has(spec.platform) ? {} : { state_topic: stateTopic(deviceKey, spec.entity) },
1140
+ ...spec.writable === true && COMMANDABLE_PLATFORMS.has(spec.platform) ? { command_topic: commandTopic(deviceKey, spec.entity) } : {},
1112
1141
  ...spec.deviceClass !== void 0 ? { device_class: spec.deviceClass } : {},
1113
1142
  ...spec.unit !== void 0 ? { unit_of_measurement: spec.unit } : {},
1114
1143
  ...spec.icon !== void 0 ? { icon: spec.icon } : {},
@@ -1124,7 +1153,8 @@ function buildComponent(device, spec) {
1124
1153
  payload_on: "true",
1125
1154
  payload_off: "false"
1126
1155
  } : {},
1127
- ...spec.platform === "button" ? { payload_press: "PRESS" } : {}
1156
+ ...spec.platform === "button" ? { payload_press: "PRESS" } : {},
1157
+ ...spec.streamTarget !== void 0 ? { stream_target: spec.streamTarget } : {}
1128
1158
  };
1129
1159
  }
1130
1160
  function deviceBlock(device) {
@@ -1277,7 +1307,7 @@ function cameraSpecs(device) {
1277
1307
  label: "Last detection",
1278
1308
  deviceClass: "timestamp",
1279
1309
  icon: "mdi:clock",
1280
- enabledByDefault: false
1310
+ enabledByDefault: true
1281
1311
  }
1282
1312
  ];
1283
1313
  if (device.slices.includes("battery")) specs.push({
@@ -1287,14 +1317,14 @@ function cameraSpecs(device) {
1287
1317
  deviceClass: "battery",
1288
1318
  unit: "%",
1289
1319
  entityCategory: "diagnostic",
1290
- enabledByDefault: false
1320
+ enabledByDefault: true
1291
1321
  }, {
1292
1322
  entity: "charger",
1293
1323
  platform: "binary_sensor",
1294
1324
  label: "Charging",
1295
1325
  deviceClass: "battery_charging",
1296
1326
  entityCategory: "diagnostic",
1297
- enabledByDefault: false
1327
+ enabledByDefault: true
1298
1328
  }, {
1299
1329
  entity: "sleeping",
1300
1330
  platform: "binary_sensor",
@@ -1426,7 +1456,7 @@ function cameraSpecs(device) {
1426
1456
  platform: "sensor",
1427
1457
  label: `${label} last detection`,
1428
1458
  deviceClass: "timestamp",
1429
- enabledByDefault: false
1459
+ enabledByDefault: true
1430
1460
  }, {
1431
1461
  entity: `${macro}_objects`,
1432
1462
  platform: "sensor",
@@ -1439,19 +1469,22 @@ function cameraSpecs(device) {
1439
1469
  platform: "sensor",
1440
1470
  label: `${label} last label`,
1441
1471
  icon: "mdi:tag",
1442
- enabledByDefault: false
1472
+ enabledByDefault: true
1443
1473
  });
1444
1474
  }
1445
1475
  /**
1446
- * The live picture. Six at most, only the first switched on see
1447
- * `camera-entities.ts` for the password and loopback traps this side
1448
- * deliberately knows nothing about.
1476
+ * The live picture every stream the hub lists EXCEPT the adaptive one,
1477
+ * which the component's own entity already is. See `camera-entities.ts`
1478
+ * for why the entity carries a WebRTC target and not a url, and why
1479
+ * announcing the adaptive stream would collide on an identity that has
1480
+ * shipped.
1449
1481
  */
1450
1482
  for (const stream of cameraStreamEntities(device.streams ?? [])) specs.push({
1451
1483
  entity: stream.entity,
1452
1484
  platform: stream.platform,
1453
1485
  label: stream.label,
1454
- enabledByDefault: stream.enabledByDefault
1486
+ enabledByDefault: stream.enabledByDefault,
1487
+ streamTarget: stream.target
1455
1488
  });
1456
1489
  /**
1457
1490
  * The two capability-derived entities a camera carries.
@@ -1492,23 +1525,35 @@ function buildCameraPlan(device) {
1492
1525
  * must name a route `CAP_COMMAND_ROUTES` can resolve, or ship read-only.**
1493
1526
  * The route table now covers every Tier A family, so the rows below are
1494
1527
  * writable wherever the capability declares a method that writes exactly
1495
- * the value the entity carries. Three things still ship read-only, each
1496
- * for a reason that is a property of the value and not of the table:
1528
+ * the value the entity carries.
1529
+ *
1530
+ * ── `writable` is about the CAPABILITY, not about the platform ───────────
1531
+ *
1532
+ * Six values are written by a real method and have no Home Assistant
1533
+ * control in the DEGRADED shape: `climate-control.fanMode`, `.preset`,
1534
+ * `fan-control.preset`, `water-heater.operationMode`, `humidifier.mode`,
1535
+ * `media-player.source` and `vacuum-control.fanSpeed` are free-form
1536
+ * strings whose accepted values are `available*` arrays on the DEVICE. A
1537
+ * static `select` cannot hold them — it would offer values the device
1538
+ * rejects while hiding the ones it takes — so their degraded platform is
1539
+ * a `sensor`.
1540
+ *
1541
+ * They are still marked `writable`, and that is the point: the flag means
1542
+ * "`CAP_COMMAND_ROUTES` writes exactly this value", which is what
1543
+ * `native-platforms.ts` asks before giving a native climate its fan-mode
1544
+ * control or a native vacuum its fan-speed control. `buildComponent`
1545
+ * announces a `command_topic` only on a platform that can carry one
1546
+ * (`COMMANDABLE_PLATFORMS`), so the degraded `sensor` stays a reading and
1547
+ * the native entity gains the control. They were read-only until
1548
+ * 2026-08-14 and the native controls silently did not appear, because the
1549
+ * one flag was answering two different questions.
1497
1550
  *
1498
- * - **A vocabulary that lives on the slice.** `climate-control.fanMode`,
1499
- * `.preset`, `water-heater.operationMode`, `humidifier.mode`,
1500
- * `media-player.source` and `vacuum-control.fanSpeed` are free-form
1501
- * strings whose accepted values are `available*` arrays on the DEVICE.
1502
- * A `select` needs a closed static list, and inventing one would offer
1503
- * values the device rejects while hiding the ones it takes. Only an
1504
- * enum in the cap's own schema becomes a `select` here.
1505
- * - **A setter that needs two values at once.** `setTargetRange` writes
1506
- * `targetLow` AND `targetHigh`; two independent `number` entities
1507
- * cannot make one call without reading each other, and the catalog and
1508
- * the route table are both pure. Both stay sensors.
1509
- * - **A value that is not flat.** See below.
1551
+ * **`setTargetRange` is the one setter that needs two values at once**,
1552
+ * and it is routed by READING the other half first — see the note on
1553
+ * `target-high` below. Everything else in this table names one method
1554
+ * with one value.
1510
1555
  *
1511
- * A row that is read-only for one of those reasons says so where it sits.
1556
+ * Only **a value that is not flat** still ships read-only. See below.
1512
1557
  *
1513
1558
  * ── Why a nested object is never a field ────────────────────────────────
1514
1559
  *
@@ -1548,6 +1593,22 @@ var CAP_ENTITY_MAP = {
1548
1593
  field: "state",
1549
1594
  label: "Lock state",
1550
1595
  icon: "mdi:lock-question"
1596
+ } },
1597
+ /**
1598
+ * `lockControl.open` — the LATCH, which is not `unlock`.
1599
+ *
1600
+ * Home Assistant spells it `LockEntityFeature.OPEN` and its native lock
1601
+ * offers it as a third button; the cap has declared the method since it
1602
+ * was written and nothing routed it, so `NATIVE_CAP_PLATFORMS`'
1603
+ * `open` control resolved to no entity and was skipped every time.
1604
+ * Registered and off in the degraded table for the same reason `stop`
1605
+ * is: a lock without a latch answers the press with the provider's own
1606
+ * refusal, and that is a button most operators never want to see.
1607
+ */
1608
+ commands: { open: {
1609
+ label: "Open latch",
1610
+ icon: "mdi:door-open",
1611
+ enabledByDefault: false
1551
1612
  } }
1552
1613
  },
1553
1614
  button: {
@@ -1804,12 +1865,17 @@ var CAP_ENTITY_MAP = {
1804
1865
  range: PERCENT_RANGE,
1805
1866
  enabledByDefault: false
1806
1867
  },
1807
- /** Free-form: the accepted values are `availableFanModes` on the slice. */
1868
+ /**
1869
+ * Free-form: the accepted values are `availableFanModes` on the slice,
1870
+ * so the degraded platform is a `sensor` and only the native climate
1871
+ * renders the control. `setFanMode` is the route.
1872
+ */
1808
1873
  "fan-mode": {
1809
1874
  platform: "sensor",
1810
1875
  field: "fanMode",
1811
1876
  label: "Fan mode",
1812
1877
  icon: "mdi:fan",
1878
+ writable: true,
1813
1879
  enabledByDefault: false
1814
1880
  },
1815
1881
  /** Free-form: the accepted values are `availablePresets` on the slice. */
@@ -1817,15 +1883,21 @@ var CAP_ENTITY_MAP = {
1817
1883
  platform: "sensor",
1818
1884
  field: "preset",
1819
1885
  label: "Preset",
1886
+ writable: true,
1820
1887
  enabledByDefault: false
1821
1888
  },
1822
1889
  /**
1823
- * Read-only, and not for want of a method: `setTargetRange` writes
1824
- * `targetLow` AND `targetHigh` in one call. Two independent `number`
1825
- * entities would each have to read the other's current value to make it,
1826
- * and both the catalog and the route table are pure. A dual-setpoint
1827
- * control belongs to the native `climate` platform (Tier B), which
1828
- * carries the pair as one entity.
1890
+ * The dual setpoint — and the one route in this export that READS
1891
+ * before it writes.
1892
+ *
1893
+ * `setTargetRange` takes `targetLow` AND `targetHigh` together, while
1894
+ * Home Assistant's climate sends the two halves on two topics, so a
1895
+ * command carrying one of them has to learn the other before it can
1896
+ * make the call. `applyCapCommand` therefore reads
1897
+ * `climateControl.getStatus`, substitutes the half it was given, and
1898
+ * calls once. A device reporting `null` for the other half is REFUSED
1899
+ * rather than defaulted: inventing the missing bound is how a
1900
+ * thermostat comes to be told to hold 20 °C to 20 °C.
1829
1901
  */
1830
1902
  "target-high": {
1831
1903
  platform: "sensor",
@@ -1833,6 +1905,7 @@ var CAP_ENTITY_MAP = {
1833
1905
  label: "Target high",
1834
1906
  deviceClass: "temperature",
1835
1907
  unit: "°C",
1908
+ writable: true,
1836
1909
  enabledByDefault: false
1837
1910
  },
1838
1911
  "target-low": {
@@ -1841,6 +1914,7 @@ var CAP_ENTITY_MAP = {
1841
1914
  label: "Target low",
1842
1915
  deviceClass: "temperature",
1843
1916
  unit: "°C",
1917
+ writable: true,
1844
1918
  enabledByDefault: false
1845
1919
  },
1846
1920
  /**
@@ -1881,11 +1955,15 @@ var CAP_ENTITY_MAP = {
1881
1955
  */
1882
1956
  range: PERCENT_RANGE,
1883
1957
  extras: {
1884
- /** Free-form: the accepted values are `availablePresets` on the slice. */
1958
+ /**
1959
+ * Free-form: the accepted values are `availablePresets` on the slice.
1960
+ * `fanControl.setPreset` is the route; the native fan renders it.
1961
+ */
1885
1962
  preset: {
1886
1963
  platform: "sensor",
1887
1964
  field: "preset",
1888
1965
  label: "Preset",
1966
+ writable: true,
1889
1967
  enabledByDefault: false
1890
1968
  },
1891
1969
  oscillating: {
@@ -1933,11 +2011,15 @@ var CAP_ENTITY_MAP = {
1933
2011
  writable: true,
1934
2012
  range: PERCENT_RANGE
1935
2013
  },
1936
- /** Free-form: the accepted values are `availableModes` on the slice. */
2014
+ /**
2015
+ * Free-form: the accepted values are `availableModes` on the slice.
2016
+ * `humidifier.setMode` is the route; the native humidifier renders it.
2017
+ */
1937
2018
  mode: {
1938
2019
  platform: "sensor",
1939
2020
  field: "mode",
1940
2021
  label: "Mode",
2022
+ writable: true,
1941
2023
  enabledByDefault: false
1942
2024
  },
1943
2025
  action: {
@@ -1963,11 +2045,18 @@ var CAP_ENTITY_MAP = {
1963
2045
  writable: true,
1964
2046
  range: TEMPERATURE_RANGE
1965
2047
  },
1966
- /** Free-form: the accepted values are `availableModes` on the slice. */
2048
+ /**
2049
+ * Free-form: the accepted values are `availableModes` on the slice, and
2050
+ * this is the entity Home Assistant's water heater uses as its STATE —
2051
+ * `setOperationMode` is what a native water_heater writes when the
2052
+ * operator picks `eco`. Read-only here left that platform with no
2053
+ * command at all.
2054
+ */
1967
2055
  mode: {
1968
2056
  platform: "sensor",
1969
2057
  field: "operationMode",
1970
2058
  label: "Operation mode",
2059
+ writable: true,
1971
2060
  enabledByDefault: false
1972
2061
  },
1973
2062
  away: {
@@ -2020,11 +2109,15 @@ var CAP_ENTITY_MAP = {
2020
2109
  deviceClass: "battery",
2021
2110
  unit: "%"
2022
2111
  },
2023
- /** Free-form: the accepted values are `availableFanSpeeds` on the slice. */
2112
+ /**
2113
+ * Free-form: the accepted values are `availableFanSpeeds` on the slice.
2114
+ * `vacuumControl.setFanSpeed` is the route; the native vacuum renders it.
2115
+ */
2024
2116
  "fan-speed": {
2025
2117
  platform: "sensor",
2026
2118
  field: "fanSpeed",
2027
2119
  label: "Fan speed",
2120
+ writable: true,
2028
2121
  enabledByDefault: false
2029
2122
  },
2030
2123
  progress: {
@@ -2130,11 +2223,16 @@ var CAP_ENTITY_MAP = {
2130
2223
  icon: "mdi:volume-off",
2131
2224
  writable: true
2132
2225
  },
2133
- /** Free-form: the accepted values are `availableSources` on the slice. */
2226
+ /**
2227
+ * Free-form: the accepted values are `availableSources` on the slice.
2228
+ * `mediaPlayer.selectSource` is the route; the native media_player
2229
+ * renders it as its source list.
2230
+ */
2134
2231
  source: {
2135
2232
  platform: "sensor",
2136
2233
  field: "source",
2137
2234
  label: "Source",
2235
+ writable: true,
2138
2236
  enabledByDefault: false
2139
2237
  },
2140
2238
  repeat: {
@@ -2880,6 +2978,21 @@ function parseLocked(value) {
2880
2978
  if (lower === "unlock" || lower === "unlocked") return false;
2881
2979
  return null;
2882
2980
  }
2981
+ /**
2982
+ * A free-form vocabulary value, as the DEVICE spells it.
2983
+ *
2984
+ * `fanMode`, `preset`, `operationMode`, `mode`, `fanSpeed` and `source` are
2985
+ * `z.string().min(1)` in their caps and their accepted values live in an
2986
+ * `available*` array on the slice. Nothing here validates against that list:
2987
+ * a pure resolution has no slice, and a second copy of the vocabulary would
2988
+ * be one firmware away from refusing a mode the device accepts. The device
2989
+ * is the authority on its own vocabulary, and its refusal surfaces with its
2990
+ * own words. Only EMPTY is refused here, because a blank is never a mode.
2991
+ */
2992
+ function parseVocabulary(value) {
2993
+ const trimmed = value.trim();
2994
+ return trimmed.length === 0 ? null : trimmed;
2995
+ }
2883
2996
  /** 0..100 inclusive, as `brightness.setBrightness` declares it. */
2884
2997
  function parsePercentage(value) {
2885
2998
  const parsed = Number(value.trim());
@@ -2943,10 +3056,13 @@ function parseEnum(schema, value) {
2943
3056
  * never built, and a leftover that describes the right design reads as
2944
3057
  * verification.
2945
3058
  *
2946
- * **Every builder names ONE method with ONE value.** Nothing here composes
2947
- * two slice fields into one call, because the resolution is pure and has no
2948
- * slice: `climate-control.setTargetRange` writes `targetLow` and
2949
- * `targetHigh` together and is therefore absent, not approximated.
3059
+ * **Every builder names ONE method with ONE value, and none of them reads
3060
+ * the device.** Resolution is pure and has no slice, which is why
3061
+ * `climate-control.setTargetRange` the only cap method that takes two
3062
+ * values at once resolves to a `climate-target-edge` carrying the half it
3063
+ * was given. The read of the other half belongs to {@link applyCapCommand},
3064
+ * which has the device; composing it here would have needed a slice this
3065
+ * function must not have.
2950
3066
  */
2951
3067
  var CAP_COMMAND_ROUTES = {
2952
3068
  switch: { primary: (deviceId, value) => {
@@ -2958,14 +3074,20 @@ var CAP_COMMAND_ROUTES = {
2958
3074
  on
2959
3075
  };
2960
3076
  } },
2961
- "lock-control": { primary: (deviceId, value) => {
2962
- const locked = parseLocked(value);
2963
- return locked === null ? null : {
2964
- kind: "cap-lock",
2965
- deviceId,
2966
- locked
2967
- };
2968
- } },
3077
+ "lock-control": {
3078
+ primary: (deviceId, value) => {
3079
+ const locked = parseLocked(value);
3080
+ return locked === null ? null : {
3081
+ kind: "cap-lock",
3082
+ deviceId,
3083
+ locked
3084
+ };
3085
+ },
3086
+ commands: { open: (deviceId) => ({
3087
+ kind: "cap-lock-open",
3088
+ deviceId
3089
+ }) }
3090
+ },
2969
3091
  brightness: { primary: (deviceId, value) => {
2970
3092
  const percentage = parsePercentage(value);
2971
3093
  return percentage === null ? null : {
@@ -3055,6 +3177,45 @@ var CAP_COMMAND_ROUTES = {
3055
3177
  targetHumidity
3056
3178
  };
3057
3179
  },
3180
+ "fan-mode": (deviceId, value) => {
3181
+ const fanMode = parseVocabulary(value);
3182
+ return fanMode === null ? null : {
3183
+ kind: "climate-fan-mode",
3184
+ deviceId,
3185
+ fanMode
3186
+ };
3187
+ },
3188
+ preset: (deviceId, value) => {
3189
+ const preset = parseVocabulary(value);
3190
+ return preset === null ? null : {
3191
+ kind: "climate-preset",
3192
+ deviceId,
3193
+ preset
3194
+ };
3195
+ },
3196
+ /**
3197
+ * The two halves of `setTargetRange`, each bounded by the same range
3198
+ * `target` is. They resolve to an EDGE rather than to a call: the other
3199
+ * half is a fact about the device, and resolution is pure.
3200
+ */
3201
+ "target-low": (deviceId, value) => {
3202
+ const low = parseBounded(value, TEMPERATURE_MIN, TEMPERATURE_MAX);
3203
+ return low === null ? null : {
3204
+ kind: "climate-target-edge",
3205
+ deviceId,
3206
+ edge: "low",
3207
+ value: low
3208
+ };
3209
+ },
3210
+ "target-high": (deviceId, value) => {
3211
+ const high = parseBounded(value, TEMPERATURE_MIN, TEMPERATURE_MAX);
3212
+ return high === null ? null : {
3213
+ kind: "climate-target-edge",
3214
+ deviceId,
3215
+ edge: "high",
3216
+ value: high
3217
+ };
3218
+ },
3058
3219
  "swing-vertical": (deviceId, value) => {
3059
3220
  const on = parseBool(value);
3060
3221
  return on === null ? null : {
@@ -3085,6 +3246,14 @@ var CAP_COMMAND_ROUTES = {
3085
3246
  };
3086
3247
  },
3087
3248
  extras: {
3249
+ preset: (deviceId, value) => {
3250
+ const preset = parseVocabulary(value);
3251
+ return preset === null ? null : {
3252
+ kind: "fan-preset",
3253
+ deviceId,
3254
+ preset
3255
+ };
3256
+ },
3088
3257
  oscillating: (deviceId, value) => {
3089
3258
  const oscillating = parseBool(value);
3090
3259
  return oscillating === null ? null : {
@@ -3112,14 +3281,24 @@ var CAP_COMMAND_ROUTES = {
3112
3281
  on
3113
3282
  };
3114
3283
  },
3115
- extras: { "target-humidity": (deviceId, value) => {
3116
- const humidity = parsePercentage(value);
3117
- return humidity === null ? null : {
3118
- kind: "humidifier-humidity",
3119
- deviceId,
3120
- humidity
3121
- };
3122
- } }
3284
+ extras: {
3285
+ "target-humidity": (deviceId, value) => {
3286
+ const humidity = parsePercentage(value);
3287
+ return humidity === null ? null : {
3288
+ kind: "humidifier-humidity",
3289
+ deviceId,
3290
+ humidity
3291
+ };
3292
+ },
3293
+ mode: (deviceId, value) => {
3294
+ const mode = parseVocabulary(value);
3295
+ return mode === null ? null : {
3296
+ kind: "humidifier-mode",
3297
+ deviceId,
3298
+ mode
3299
+ };
3300
+ }
3301
+ }
3123
3302
  },
3124
3303
  "water-heater": { extras: {
3125
3304
  target: (deviceId, value) => {
@@ -3137,6 +3316,14 @@ var CAP_COMMAND_ROUTES = {
3137
3316
  deviceId,
3138
3317
  on
3139
3318
  };
3319
+ },
3320
+ mode: (deviceId, value) => {
3321
+ const mode = parseVocabulary(value);
3322
+ return mode === null ? null : {
3323
+ kind: "water-heater-mode",
3324
+ deviceId,
3325
+ mode
3326
+ };
3140
3327
  }
3141
3328
  } },
3142
3329
  valve: {
@@ -3166,33 +3353,43 @@ var CAP_COMMAND_ROUTES = {
3166
3353
  })
3167
3354
  }
3168
3355
  },
3169
- "vacuum-control": { commands: {
3170
- start: (deviceId) => ({
3171
- kind: "vacuum-verb",
3172
- deviceId,
3173
- verb: "start"
3174
- }),
3175
- pause: (deviceId) => ({
3176
- kind: "vacuum-verb",
3177
- deviceId,
3178
- verb: "pause"
3179
- }),
3180
- stop: (deviceId) => ({
3181
- kind: "vacuum-verb",
3182
- deviceId,
3183
- verb: "stop"
3184
- }),
3185
- "return-to-base": (deviceId) => ({
3186
- kind: "vacuum-verb",
3187
- deviceId,
3188
- verb: "return-to-base"
3189
- }),
3190
- locate: (deviceId) => ({
3191
- kind: "vacuum-verb",
3192
- deviceId,
3193
- verb: "locate"
3194
- })
3195
- } },
3356
+ "vacuum-control": {
3357
+ extras: { "fan-speed": (deviceId, value) => {
3358
+ const speed = parseVocabulary(value);
3359
+ return speed === null ? null : {
3360
+ kind: "vacuum-fan-speed",
3361
+ deviceId,
3362
+ speed
3363
+ };
3364
+ } },
3365
+ commands: {
3366
+ start: (deviceId) => ({
3367
+ kind: "vacuum-verb",
3368
+ deviceId,
3369
+ verb: "start"
3370
+ }),
3371
+ pause: (deviceId) => ({
3372
+ kind: "vacuum-verb",
3373
+ deviceId,
3374
+ verb: "pause"
3375
+ }),
3376
+ stop: (deviceId) => ({
3377
+ kind: "vacuum-verb",
3378
+ deviceId,
3379
+ verb: "stop"
3380
+ }),
3381
+ "return-to-base": (deviceId) => ({
3382
+ kind: "vacuum-verb",
3383
+ deviceId,
3384
+ verb: "return-to-base"
3385
+ }),
3386
+ locate: (deviceId) => ({
3387
+ kind: "vacuum-verb",
3388
+ deviceId,
3389
+ verb: "locate"
3390
+ })
3391
+ }
3392
+ },
3196
3393
  "lawn-mower-control": { commands: {
3197
3394
  start: (deviceId) => ({
3198
3395
  kind: "mower-verb",
@@ -3243,6 +3440,14 @@ var CAP_COMMAND_ROUTES = {
3243
3440
  deviceId,
3244
3441
  repeat
3245
3442
  };
3443
+ },
3444
+ source: (deviceId, value) => {
3445
+ const source = parseVocabulary(value);
3446
+ return source === null ? null : {
3447
+ kind: "media-source",
3448
+ deviceId,
3449
+ source
3450
+ };
3246
3451
  }
3247
3452
  },
3248
3453
  commands: {
@@ -3532,6 +3737,30 @@ async function applyCapCommand(device, command) {
3532
3737
  if (command.locked) await device.lockControl.lock({});
3533
3738
  else await device.lockControl.unlock({});
3534
3739
  return { ok: true };
3740
+ case "cap-lock-open":
3741
+ if (device.lockControl === void 0) return {
3742
+ ok: false,
3743
+ reason: "no-provider",
3744
+ capName: "lock-control"
3745
+ };
3746
+ /**
3747
+ * A lock without a latch REFUSES this, and the refusal is the answer.
3748
+ * `lock-control` declares `open` unconditionally while the latch is a
3749
+ * property of the hardware, so the provider is the only thing that
3750
+ * knows — reporting success over its refusal would leave Home
3751
+ * Assistant showing a door that opened and did not.
3752
+ */
3753
+ try {
3754
+ await device.lockControl.open({});
3755
+ } catch (err) {
3756
+ return {
3757
+ ok: false,
3758
+ reason: "refused",
3759
+ capName: "lock-control",
3760
+ error: err instanceof Error ? err.message : String(err)
3761
+ };
3762
+ }
3763
+ return { ok: true };
3535
3764
  case "cap-brightness":
3536
3765
  if (device.brightness === void 0) return {
3537
3766
  ok: false,
@@ -3595,15 +3824,40 @@ async function applyCapCommand(device, command) {
3595
3824
  });
3596
3825
  case "climate-mode": return call(device.climateControl, "climate-control", (climate) => climate.setMode({ mode: command.mode }));
3597
3826
  case "climate-target": return call(device.climateControl, "climate-control", (climate) => climate.setTarget({ target: command.target }));
3827
+ case "climate-fan-mode": return call(device.climateControl, "climate-control", (climate) => climate.setFanMode({ fanMode: command.fanMode }));
3828
+ case "climate-preset": return call(device.climateControl, "climate-control", (climate) => climate.setPreset({ preset: command.preset }));
3829
+ /**
3830
+ * The one read-then-write in this file, and the reason is the cap's
3831
+ * signature rather than a preference: `setTargetRange` takes both edges
3832
+ * and Home Assistant sends them one at a time. The OTHER edge is read
3833
+ * from `getStatus` — the provider's own current answer — and a `null`
3834
+ * there is REFUSED, because a dual-setpoint device that reports no range
3835
+ * has no range to move one edge of, and substituting the single `target`
3836
+ * would silently collapse the band to a point.
3837
+ */
3838
+ case "climate-target-edge": return call(device.climateControl, "climate-control", async (climate) => {
3839
+ const status = await climate.getStatus({});
3840
+ if (status === null) throw new Error("the device reports no climate status, so setTargetRange has no range");
3841
+ const low = command.edge === "low" ? command.value : status.targetLow;
3842
+ const high = command.edge === "high" ? command.value : status.targetHigh;
3843
+ if (low === null || high === null) throw new Error(`the device reports no ${command.edge === "low" ? "high" : "low"} setpoint, so setTargetRange has nothing to preserve`);
3844
+ await climate.setTargetRange({
3845
+ targetLow: low,
3846
+ targetHigh: high
3847
+ });
3848
+ });
3598
3849
  case "climate-target-humidity": return call(device.climateControl, "climate-control", (climate) => climate.setTargetHumidity({ targetHumidity: command.targetHumidity }));
3599
3850
  case "climate-swing": return call(device.climateControl, "climate-control", (climate) => command.axis === "vertical" ? climate.setSwingVertical({ on: command.on }) : climate.setSwingHorizontal({ on: command.on }));
3600
3851
  case "fan-percentage": return call(device.fanControl, "fan-control", (fan) => fan.setPercentage({ percentage: command.percentage }));
3852
+ case "fan-preset": return call(device.fanControl, "fan-control", (fan) => fan.setPreset({ preset: command.preset }));
3601
3853
  case "fan-oscillating": return call(device.fanControl, "fan-control", (fan) => fan.setOscillating({ oscillating: command.oscillating }));
3602
3854
  case "fan-direction": return call(device.fanControl, "fan-control", (fan) => fan.setDirection({ direction: command.direction }));
3603
3855
  case "humidifier-on": return call(device.humidifier, "humidifier", (humidifier) => humidifier.setOn({ on: command.on }));
3604
3856
  case "humidifier-humidity": return call(device.humidifier, "humidifier", (humidifier) => humidifier.setTargetHumidity({ humidity: command.humidity }));
3857
+ case "humidifier-mode": return call(device.humidifier, "humidifier", (humidifier) => humidifier.setMode({ mode: command.mode }));
3605
3858
  case "water-heater-temp": return call(device.waterHeater, "water-heater", (heater) => heater.setTargetTemp({ temp: command.temp }));
3606
3859
  case "water-heater-away": return call(device.waterHeater, "water-heater", (heater) => heater.setAway({ on: command.on }));
3860
+ case "water-heater-mode": return call(device.waterHeater, "water-heater", (heater) => heater.setOperationMode({ mode: command.mode }));
3607
3861
  case "valve-position": return call(device.valve, "valve", (valve) => valve.setPosition({ position: command.position }));
3608
3862
  case "valve-verb": return call(device.valve, "valve", (valve) => {
3609
3863
  switch (command.verb) {
@@ -3621,6 +3875,7 @@ async function applyCapCommand(device, command) {
3621
3875
  case "locate": return vacuum.locate({});
3622
3876
  }
3623
3877
  });
3878
+ case "vacuum-fan-speed": return call(device.vacuumControl, "vacuum-control", (vacuum) => vacuum.setFanSpeed({ speed: command.speed }));
3624
3879
  case "mower-verb": return call(device.lawnMowerControl, "lawn-mower-control", (mower) => {
3625
3880
  switch (command.verb) {
3626
3881
  case "start": return mower.startMowing({});
@@ -3641,6 +3896,7 @@ async function applyCapCommand(device, command) {
3641
3896
  case "media-mute": return call(device.mediaPlayer, "media-player", (media) => media.setMute({ muted: command.muted }));
3642
3897
  case "media-shuffle": return call(device.mediaPlayer, "media-player", (media) => media.setShuffle({ shuffle: command.shuffle }));
3643
3898
  case "media-repeat": return call(device.mediaPlayer, "media-player", (media) => media.setRepeat({ repeat: command.repeat }));
3899
+ case "media-source": return call(device.mediaPlayer, "media-player", (media) => media.selectSource({ source: command.source }));
3644
3900
  case "color-mireds": return call(device.color, "color", (color) => color.setColor({ color: {
3645
3901
  mode: "mired",
3646
3902
  mireds: command.mireds
@@ -5722,22 +5978,12 @@ var HaExportAddon = class extends BaseAddon {
5722
5978
  }
5723
5979
  if (entry.target.type === "camera") {
5724
5980
  /**
5725
- * The live picture. A refused URL is DROPPED WORK and says so: an
5726
- * entity Home Assistant shows and never loads is indistinguishable
5727
- * from a camera that is down, and `credentials-inline` in
5728
- * particular means a source handed us the camera password which
5729
- * is a defect to fix, not a value to publish.
5981
+ * The live picture publishes NOTHING here, deliberately. A `camera`
5982
+ * entity's whole content is its `stream_target`, which travelled with
5983
+ * the announce; the component fetches the still and negotiates the
5984
+ * video itself. The url this block used to publish was read by
5985
+ * nobody see `camera-entities.ts`.
5730
5986
  */
5731
- const streams = cameraStreamValues(key, entry.streams);
5732
- values.push(...streams.values);
5733
- for (const refusal of streams.refused) this.warnOnce(entry.deviceId, "camera-streams", refusal.entity, {
5734
- message: "ha-export: a camera entity exists and its stream URL cannot be published",
5735
- meta: {
5736
- entity: refusal.entity,
5737
- profile: refusal.profile,
5738
- reason: refusal.reason
5739
- }
5740
- });
5741
5987
  const group = await this.ctx.api.pipelineOrchestrator.getCameraSwitches.query({ deviceId: entry.deviceId });
5742
5988
  values.push(...projectCameraSwitches(key, group.switches.map((sw) => ({
5743
5989
  id: sw.id,
@@ -5766,7 +6012,7 @@ var HaExportAddon = class extends BaseAddon {
5766
6012
  const zones = device.type === "camera" ? await this.loadZones(device.id) : [];
5767
6013
  const switches = device.type === "camera" ? await this.loadSwitches(device.id) : [];
5768
6014
  const ptzPresets = device.type === "camera" && boundCaps.includes("ptz") ? await this.loadPresets(device.id) : [];
5769
- const streams = device.type === "camera" && boundCaps.includes("camera-streams") ? await this.loadStreamProfiles(device.id) : [];
6015
+ const streams = device.type === "camera" && boundCaps.includes("webrtc-session") ? await this.loadStreamChoices(device.id) : [];
5770
6016
  const manufacturer = readString(device.metadata ?? {}, "manufacturer");
5771
6017
  const model = readString(device.metadata ?? {}, "model");
5772
6018
  return {
@@ -5793,42 +6039,30 @@ var HaExportAddon = class extends BaseAddon {
5793
6039
  };
5794
6040
  }
5795
6041
  /**
5796
- * The camera's assigned profile slots, as broker restream URLs.
6042
+ * Every stream of the camera, as the hub's own picker lists them.
5797
6043
  *
5798
- * `getProfileRtspEntries`, never `streamCatalog.getCatalog`: the latter
5799
- * returns the camera's NATIVE url with its password inline, and this
5800
- * value is written into Home Assistant's entity registry and its debug
5801
- * logs. `camera-entities.ts` refuses a credentialed URL as well two
5802
- * gates, because the cost of one of them being wrong is the operator's
5803
- * camera password on a host we do not own.
6044
+ * `webrtcSession.listStreams` rather than
6045
+ * `cameraStreams.getProfileRtspEntries`: the component negotiates live
6046
+ * video through `webrtcSession.handleOffer` and needs the `target`, not
6047
+ * an address. Reading the RTSP entries instead cost this export three
6048
+ * things it no longer pays a `hostname` argument (the broker binds
6049
+ * `127.0.0.1`, so a camera on a hub with no reachable base url got NO
6050
+ * camera entity at all), a refusal for a credentialed url, and a whole
6051
+ * value plane nothing consumed.
5804
6052
  *
5805
- * `hostname` is passed EXPLICITLY. The broker binds `127.0.0.1` and
5806
- * mints its URLs against whatever address it was asked for; Home
5807
- * Assistant is not on the hub, so omitting it produces entities that
5808
- * exist and never load — the identical failure the image entities had
5809
- * before `publicBaseUrl`.
6053
+ * `listStreams` also reports the camera's raw substreams, which the
6054
+ * profile view could not express.
5810
6055
  */
5811
- async loadStreamProfiles(deviceId) {
5812
- const hostname = hostnameFromBaseUrl(this.mediaBaseUrl);
5813
- if (hostname === null) {
5814
- this.ctx.logger.warn("ha-export: no reachable hub hostname — this camera gets no live camera entity", {
5815
- tags: { deviceId },
5816
- meta: { mediaBaseUrl: this.mediaBaseUrl }
5817
- });
5818
- return [];
5819
- }
6056
+ async loadStreamChoices(deviceId) {
5820
6057
  try {
5821
- return (await this.ctx.api.cameraStreams.getProfileRtspEntries.query({
5822
- deviceId,
5823
- hostname
5824
- })).map((entry) => ({
5825
- profile: entry.profile,
5826
- url: entry.url,
5827
- mutedUrl: entry.mutedUrl,
5828
- enabled: entry.enabled
6058
+ return (await this.ctx.api.webrtcSession.listStreams.query({ deviceId })).map((choice) => ({
6059
+ id: choice.id,
6060
+ label: choice.label,
6061
+ target: choice.target,
6062
+ ...choice.resolution === null ? {} : { height: choice.resolution.height }
5829
6063
  }));
5830
6064
  } catch (err) {
5831
- this.ctx.logger.warn("ha-export: could not read stream profiles, no camera entity", {
6065
+ this.ctx.logger.warn("ha-export: could not read the camera streams, no camera entity", {
5832
6066
  tags: { deviceId },
5833
6067
  meta: { error: errMsg(err) }
5834
6068
  });