@camstack/addon-provider-homeassistant 1.1.15 → 1.1.16

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.
Files changed (3) hide show
  1. package/dist/addon.js +262 -149
  2. package/dist/addon.mjs +262 -149
  3. package/package.json +1 -1
package/dist/addon.js CHANGED
@@ -8475,7 +8475,13 @@ onStatusChanged: { data: object({
8475
8475
  }) } },
8476
8476
  status: {
8477
8477
  schema: BatteryStatusSchema,
8478
- kind: "push"
8478
+ kind: "push",
8479
+ empty: {
8480
+ percentage: 0,
8481
+ charging: "none",
8482
+ sleeping: false,
8483
+ lastUpdated: 0
8484
+ }
8479
8485
  },
8480
8486
  /**
8481
8487
  * Runtime-state slice — every provider that registers this cap
@@ -9418,21 +9424,38 @@ var connectivityCapability = {
9418
9424
  },
9419
9425
  runtimeState: ConnectivityStatusSchema
9420
9426
  };
9427
+ /**
9428
+ * Generic device-consumables capability — surfaces a device's
9429
+ * maintenance items (vacuum filters/brushes, replaceable cartridges,
9430
+ * descaling cycles, …) with their remaining life and an optional
9431
+ * "Replaced" reset action. Device-agnostic: any provider that knows its
9432
+ * device tracks consumables can register it; the cap declares no
9433
+ * vocabulary of its own — the provider names each item verbatim.
9434
+ *
9435
+ * Like `childLayout`, the cap is INERT until a provider sets items: no
9436
+ * provider populates it by guessing (no HA inference). The UI renders a
9437
+ * "No consumables reported" placeholder when `items` is empty.
9438
+ */
9439
+ /** A single consumable item. Either a continuous `level` (remaining
9440
+ * life %) or a discrete `status` may be known — both may be null when a
9441
+ * provider only knows the item exists. `level` and `status` are not
9442
+ * mutually exclusive; a provider may report both. */
9443
+ var ConsumableItemSchema = object({
9444
+ /** Stable id, e.g. 'main-brush'. */
9445
+ key: string().min(1),
9446
+ /** Display name. */
9447
+ label: string().min(1),
9448
+ /** Remaining life % when known (0..100). */
9449
+ level: number().min(0).max(100).nullable(),
9450
+ /** Discrete state when known (binary mode). */
9451
+ status: _enum(["ok", "replace"]).nullable(),
9452
+ /** Ms epoch of the last replace, when known. */
9453
+ lastResetAt: number().nullable(),
9454
+ /** Whether `reset()` is meaningful for this item. */
9455
+ resettable: boolean()
9456
+ });
9421
9457
  var ConsumablesStatusSchema = object({
9422
- items: array(object({
9423
- /** Stable id, e.g. 'main-brush'. */
9424
- key: string().min(1),
9425
- /** Display name. */
9426
- label: string().min(1),
9427
- /** Remaining life % when known (0..100). */
9428
- level: number().min(0).max(100).nullable(),
9429
- /** Discrete state when known (binary mode). */
9430
- status: _enum(["ok", "replace"]).nullable(),
9431
- /** Ms epoch of the last replace, when known. */
9432
- lastResetAt: number().nullable(),
9433
- /** Whether `reset()` is meaningful for this item. */
9434
- resettable: boolean()
9435
- })),
9458
+ items: array(ConsumableItemSchema),
9436
9459
  lastChangedAt: number()
9437
9460
  });
9438
9461
  var consumablesCapability = {
@@ -9491,7 +9514,25 @@ reset: method(object({
9491
9514
  }) },
9492
9515
  status: {
9493
9516
  schema: ConsumablesStatusSchema,
9494
- kind: "push"
9517
+ kind: "push",
9518
+ empty: {
9519
+ items: [],
9520
+ lastChangedAt: 0
9521
+ },
9522
+ itemArray: {
9523
+ path: "items",
9524
+ keyField: "key",
9525
+ labelField: "label",
9526
+ itemSchema: ConsumableItemSchema,
9527
+ emptyItem: {
9528
+ key: "",
9529
+ label: "",
9530
+ level: null,
9531
+ status: null,
9532
+ lastResetAt: null,
9533
+ resettable: false
9534
+ }
9535
+ }
9495
9536
  },
9496
9537
  runtimeState: ConsumablesStatusSchema.extend({ lastFetchedAt: number() })
9497
9538
  };
@@ -11267,6 +11308,31 @@ var ReportMotionInputSchema = object({
11267
11308
  regions: array(MotionRegionSchema).readonly().optional()
11268
11309
  });
11269
11310
  /**
11311
+ * Where a runner gets a camera's decoded frames (cross-node Phase 2,
11312
+ * restream-owner model — P2c).
11313
+ *
11314
+ * - `local-broker` (DEFAULT): today's path — subscribe to the co-located
11315
+ * stream-broker's shm frame plane. Every pre-P2c attach payload (no
11316
+ * `frameSource` key) parses to this, so the field is additive with zero
11317
+ * behavior change.
11318
+ * - `remote-restream`: the detect node is NOT the camera's source-owner.
11319
+ * The runner acquires the owner's COMPRESSED passthrough restream
11320
+ * (`streamBroker.getStreamWithCodec({video:'copy'})` — refcounted, the
11321
+ * double-pull guard) and decodes LOCALLY via a satellite frame plane +
11322
+ * pull-mode decoder session pinned to its own node. The shm ring stays
11323
+ * node-local; only H.264/H.265 packets cross the wire.
11324
+ * `hubHostnameOverride` mirrors the recorder's `recordingHubHostname`:
11325
+ * when set it overrides the `CAMSTACK_HUB_URL`-derived host the runner
11326
+ * dials for the owner's restream.
11327
+ */
11328
+ var RunnerFrameSourceSchema = discriminatedUnion("kind", [object({ kind: literal("local-broker") }), object({
11329
+ kind: literal("remote-restream"),
11330
+ /** The camera's source-owner node (slice 1: always the hub). */
11331
+ ownerNodeId: string(),
11332
+ /** Operator override for the owner host the runner dials. */
11333
+ hubHostnameOverride: string().optional()
11334
+ })]).describe("Per-camera frame-source mode for the runner (P2c)");
11335
+ /**
11270
11336
  * Camera assignment payload sent by `addon-pipeline-orchestrator` to a
11271
11337
  * specific runner instance via `attachCamera`. Carries everything the
11272
11338
  * runner needs to subscribe to the local broker and execute inference.
@@ -11364,7 +11430,15 @@ var RunnerCameraConfigSchema = object({
11364
11430
  */
11365
11431
  onboardMotionDrivesAnalyzer: boolean().default(true),
11366
11432
  occupancyRecheckSec: number().min(occupancyRecheckSecField.min).max(occupancyRecheckSecField.max).default(occupancyRecheckSecField.default),
11367
- occupancyRecheckFrames: number().min(occupancyRecheckFramesField.min).max(occupancyRecheckFramesField.max).default(occupancyRecheckFramesField.default)
11433
+ occupancyRecheckFrames: number().min(occupancyRecheckFramesField.min).max(occupancyRecheckFramesField.max).default(occupancyRecheckFramesField.default),
11434
+ /**
11435
+ * Where this runner gets the camera's decoded frames (P2c). Defaulted so
11436
+ * every existing payload behaves as `local-broker` — the pre-Phase-2 path.
11437
+ * Populated with `remote-restream` by the orchestrator ONLY when the
11438
+ * camera's detect node differs from its source-owner (P2d, gated by the
11439
+ * `remoteSourcingNodes` rollout setting).
11440
+ */
11441
+ frameSource: RunnerFrameSourceSchema.default({ kind: "local-broker" })
11368
11442
  });
11369
11443
  motionFpsField.min, motionFpsField.max, motionFpsField.step, motionFpsField.default, detectionFpsField.min, detectionFpsField.max, detectionFpsField.step, detectionFpsField.default, motionCooldownMsField.min, motionCooldownMsField.max, motionCooldownMsField.step, motionCooldownMsField.default, occupancyRecheckSecField.min, occupancyRecheckSecField.max, occupancyRecheckSecField.step, occupancyRecheckSecField.default, occupancyRecheckFramesField.min, occupancyRecheckFramesField.max, occupancyRecheckFramesField.step, occupancyRecheckFramesField.default;
11370
11444
  /**
@@ -15502,14 +15576,36 @@ var ChildLayoutEntrySchema = object({
15502
15576
  collapsed: boolean().optional()
15503
15577
  });
15504
15578
  /** Cap-wire shape of a DeviceLink — structurally mirrors `DeviceLink` in
15505
- * `device-management.ts`. */
15579
+ * `device-management.ts`. Source is a union: a FIELD source copies a sibling
15580
+ * accessory's status field (`kind` optional/absent for wire compat); a
15581
+ * LITERAL source carries a per-device constant (no sibling is read); a
15582
+ * GLOBAL source (P2e) copies ANY device's status field, addressed by the
15583
+ * source device's full re-sync-stable `stableId`. */
15506
15584
  var DeviceLinkSchema = object({
15507
15585
  id: string(),
15508
- source: object({
15509
- sourceKey: string(),
15510
- cap: string(),
15511
- fieldPath: string()
15512
- }),
15586
+ source: union([
15587
+ object({
15588
+ kind: literal("field").optional(),
15589
+ sourceKey: string(),
15590
+ cap: string(),
15591
+ fieldPath: string()
15592
+ }),
15593
+ object({
15594
+ kind: literal("literal"),
15595
+ value: union([
15596
+ string(),
15597
+ number(),
15598
+ boolean(),
15599
+ _null()
15600
+ ])
15601
+ }),
15602
+ object({
15603
+ kind: literal("global"),
15604
+ sourceStableId: string(),
15605
+ cap: string(),
15606
+ fieldPath: string()
15607
+ })
15608
+ ]),
15513
15609
  target: object({
15514
15610
  cap: string(),
15515
15611
  fieldPath: string(),
@@ -15752,7 +15848,10 @@ method(object({
15752
15848
  }), _void(), {
15753
15849
  kind: "mutation",
15754
15850
  auth: "admin"
15755
- }), method(object({ deviceId: number() }), object({ caps: array(object({
15851
+ }), method(object({
15852
+ deviceId: number(),
15853
+ includeSynthesizable: boolean().optional()
15854
+ }), object({ caps: array(object({
15756
15855
  cap: string(),
15757
15856
  fields: array(object({
15758
15857
  path: string(),
@@ -15762,8 +15861,13 @@ method(object({
15762
15861
  "boolean",
15763
15862
  "enum"
15764
15863
  ]),
15765
- enumValues: array(string()).optional()
15766
- })).readonly()
15864
+ enumValues: array(string()).optional(),
15865
+ item: boolean().optional()
15866
+ })).readonly(),
15867
+ itemArray: object({
15868
+ path: string(),
15869
+ keyField: string()
15870
+ }).optional()
15767
15871
  })).readonly() }), { kind: "query" }), method(object({
15768
15872
  deviceId: number(),
15769
15873
  role: string().nullable()
@@ -15833,7 +15937,11 @@ method(object({
15833
15937
  deviceId: number(),
15834
15938
  entries: array(object({
15835
15939
  capName: string(),
15836
- kind: _enum(["native", "wrapped"]),
15940
+ kind: _enum([
15941
+ "native",
15942
+ "wrapped",
15943
+ "linked"
15944
+ ]),
15837
15945
  providerAddonId: string(),
15838
15946
  providerNodeId: string(),
15839
15947
  nativeAddonId: string()
@@ -15842,7 +15950,11 @@ method(object({
15842
15950
  deviceId: number(),
15843
15951
  entries: array(object({
15844
15952
  capName: string(),
15845
- kind: _enum(["native", "wrapped"]),
15953
+ kind: _enum([
15954
+ "native",
15955
+ "wrapped",
15956
+ "linked"
15957
+ ]),
15846
15958
  providerAddonId: string(),
15847
15959
  providerNodeId: string(),
15848
15960
  nativeAddonId: string()
@@ -19872,6 +19984,16 @@ DeviceType.Camera, DeviceType.Sensor, DeviceType.Switch, method(object({ deviceI
19872
19984
  kind: "mutation",
19873
19985
  auth: "admin"
19874
19986
  });
19987
+ /**
19988
+ * `recording` cap — footage availability + HLS playback manifests + per-device
19989
+ * recording config. NOTE on events (source of truth, R5/C3): this cap carries
19990
+ * NO event surface — `getPlaybackManifest` returns playlist URLs only. Timeline
19991
+ * events (motion/object/audio) come from `pipelineAnalytics` (durable SQLite
19992
+ * rows); the recorder's internal EventMap markers are ephemeral in-RAM
19993
+ * annotations that are not exposed here and must not be treated as an event
19994
+ * feed. Event<->footage joins are by time, padded with the shared `EVENT_PAD_MS`
19995
+ * (`interfaces/recording-config.ts`).
19996
+ */
19875
19997
  var RecordingStatusSchema = object({
19876
19998
  deviceId: number(),
19877
19999
  enabled: boolean(),
@@ -28217,20 +28339,86 @@ function selectBatteryEntity(entities) {
28217
28339
  * fires — but not 0 % (the cell isn't dead, it just needs replacing). */
28218
28340
  var BINARY_BATTERY_NORMAL_PCT = 100;
28219
28341
  var BINARY_BATTERY_LOW_PCT = 15;
28220
- /** Parse an HA battery state string (`"42"`) into a clamped 0–100 percentage.
28221
- * Returns `null` for non-numeric states (`unavailable` / `unknown`). */
28222
- function parseBatteryPercentage(raw) {
28223
- const value = Number(raw);
28224
- if (!Number.isFinite(value)) return null;
28225
- return Math.max(0, Math.min(100, value));
28226
- }
28227
- /** Map a BINARY battery state (`LOW_BAT`) to a percentage. HA convention:
28228
- * `on` / `true` = LOW, `off` / `false` = normal. Unknown states → `null`. */
28229
- function parseBinaryBatteryPercentage(raw) {
28230
- const s = raw.trim().toLowerCase();
28231
- if (s === "on" || s === "true" || s === "low") return BINARY_BATTERY_LOW_PCT;
28232
- if (s === "off" || s === "false" || s === "normal" || s === "ok") return BINARY_BATTERY_NORMAL_PCT;
28233
- return null;
28342
+ /**
28343
+ * Derive the container's device-level `battery` wiring from its entities as
28344
+ * auto-authored `DeviceLink`s. The battery cap on the container is SYNTHESIZED
28345
+ * by the generic device-link machinery (cap `status.empty` + these links)
28346
+ * no native battery provider is registered on the container.
28347
+ *
28348
+ * - NUMERIC battery sensor (real 0–100 %): field link `numeric-sensor.value →
28349
+ * battery.percentage` with a `linear 1x+0 clamp [0,100]` transform (preserves
28350
+ * the historical `parseBatteryPercentage` clamping so an out-of-range
28351
+ * firmware value can never fail the whole synthesized status), plus
28352
+ * `lastFetchedAt lastUpdated`.
28353
+ * - BINARY `LOW_BAT` indicator: `binary.on battery.percentage` through an
28354
+ * `enum-map` (`on`/low 15 %, `off`/normal 100 % the historical
28355
+ * `parseBinaryBatteryPercentage` mapping), `lastChangedAt → lastUpdated`,
28356
+ * plus a LITERAL `binary: true` flag so the UI renders "Normal"/"Low"
28357
+ * instead of a misleading exact percentage.
28358
+ * - Numeric is preferred over binary when a device exposes both
28359
+ * (`selectBatteryEntity`); no battery entity → no links (cap absent).
28360
+ *
28361
+ * `sourceKey` is the battery child's accessory `stableIdSuffix` (its
28362
+ * `entityId` — see `getAccessoryChildren`), so links survive a re-sync.
28363
+ */
28364
+ function buildBatteryLinks(entities) {
28365
+ const battery = selectBatteryEntity(entities);
28366
+ if (!battery) return [];
28367
+ const binary = isBinaryBattery(battery);
28368
+ const cap = binary ? "binary" : "numeric-sensor";
28369
+ const percentage = {
28370
+ id: "battery-percentage",
28371
+ source: {
28372
+ sourceKey: battery.entityId,
28373
+ cap,
28374
+ fieldPath: binary ? "on" : "value"
28375
+ },
28376
+ target: {
28377
+ cap: "battery",
28378
+ fieldPath: "percentage"
28379
+ },
28380
+ transform: binary ? {
28381
+ kind: "enum-map",
28382
+ mapping: {
28383
+ true: BINARY_BATTERY_LOW_PCT,
28384
+ false: BINARY_BATTERY_NORMAL_PCT
28385
+ }
28386
+ } : {
28387
+ kind: "linear",
28388
+ scale: 1,
28389
+ offset: 0,
28390
+ clamp: [0, 100]
28391
+ }
28392
+ };
28393
+ const lastUpdated = {
28394
+ id: "battery-lastUpdated",
28395
+ source: {
28396
+ sourceKey: battery.entityId,
28397
+ cap,
28398
+ fieldPath: binary ? "lastChangedAt" : "lastFetchedAt"
28399
+ },
28400
+ target: {
28401
+ cap: "battery",
28402
+ fieldPath: "lastUpdated"
28403
+ },
28404
+ transform: { kind: "identity" }
28405
+ };
28406
+ if (!binary) return [percentage, lastUpdated];
28407
+ return [
28408
+ percentage,
28409
+ lastUpdated,
28410
+ {
28411
+ id: "battery-binary",
28412
+ source: {
28413
+ kind: "literal",
28414
+ value: true
28415
+ },
28416
+ target: {
28417
+ cap: "battery",
28418
+ fieldPath: "binary"
28419
+ }
28420
+ }
28421
+ ];
28234
28422
  }
28235
28423
  /**
28236
28424
  * Parent container device for a single HA physical device. Owns no
@@ -28253,17 +28441,6 @@ var HaContainerDevice = class HaContainerDevice extends BaseDevice {
28253
28441
  features;
28254
28442
  brokerId;
28255
28443
  integrationId;
28256
- /** entityId of the battery entity (if any) on this physical device. The
28257
- * device-level `battery` cap mirrors this entity's live percentage. */
28258
- batteryEntityId;
28259
- /** True when the battery entity is a binary `LOW_BAT` indicator (no real
28260
- * percentage) rather than a numeric sensor — drives the state decode. */
28261
- batteryIsBinary;
28262
- /** Disposer for the cap event-bus listener that decodes battery pushes. */
28263
- batteryEventUnsub = null;
28264
- /** Upstream broker subscription id for the battery entity — released in
28265
- * `removeDevice`. Null until `onActivate` subscribes. */
28266
- batterySubscriptionId = null;
28267
28444
  /** Set of this device's entity ids (the physical HA device's entities). The
28268
28445
  * container's `online` is derived as "any of these entities reachable" (A3). */
28269
28446
  entityIds;
@@ -28284,76 +28461,11 @@ var HaContainerDevice = class HaContainerDevice extends BaseDevice {
28284
28461
  super(ctx, haContainerSchema, { type: DeviceType.Container });
28285
28462
  this.brokerId = cfg.brokerId;
28286
28463
  this.integrationId = cfg.integrationId;
28287
- this.batteryEntityId = batteryEntity?.entityId ?? null;
28288
- this.batteryIsBinary = batteryEntity !== null && isBinaryBattery(batteryEntity);
28289
28464
  this.features = batteryEntity !== null ? [DeviceFeature.Resyncable, DeviceFeature.BatteryOperated] : [DeviceFeature.Resyncable];
28290
28465
  this.entityIds = new Set(cfg.entities.map((e) => e.entityId));
28291
28466
  this.online = false;
28292
28467
  }
28293
28468
  /**
28294
- * Register the device-level `battery` cap on the container, backed by
28295
- * the runtime-state slice. The UI battery badge gates on the presence
28296
- * of this slice (the `useDeviceBattery` hook reads `device.state.battery`),
28297
- * so registering the cap + seeding the slice is what surfaces the badge
28298
- * on the physical device — the per-entity numeric-sensor child stays.
28299
- *
28300
- * The slice is fed from the battery entity's live HA state via the same
28301
- * `broker.message` event-bus mechanism the per-domain children use; the
28302
- * listener is attached here so the integration manager's immediate
28303
- * cache-replay (triggered by the broker subscription in `onActivate`)
28304
- * lands after the cap schema is installed.
28305
- */
28306
- registerBatteryCap() {
28307
- const COLD_START = {
28308
- percentage: this.batteryIsBinary ? BINARY_BATTERY_NORMAL_PCT : 0,
28309
- charging: "none",
28310
- sleeping: false,
28311
- lastUpdated: 0,
28312
- binary: this.batteryIsBinary
28313
- };
28314
- this.ctx.registerNativeCap(batteryCapability, {
28315
- getStatus: async ({ deviceId }) => {
28316
- if (deviceId !== this.id) throw new Error(`HaContainerDevice: battery deviceId mismatch, expected ${this.id}, got ${deviceId}`);
28317
- return this.runtimeState.getCapState("battery") ?? COLD_START;
28318
- },
28319
- wakeForStream: async () => ({
28320
- awoke: true,
28321
- durationMs: 0
28322
- })
28323
- });
28324
- this.runtimeState.setCapState("battery", COLD_START);
28325
- this.attachBatteryEventListener();
28326
- }
28327
- /** Subscribe to `broker.message` events and decode the battery entity's
28328
- * percentage into the `battery` slice on every matching push. */
28329
- attachBatteryEventListener() {
28330
- const handler = (event) => {
28331
- const data = event.data;
28332
- if (!data) return;
28333
- if (data.brokerId !== this.brokerId) return;
28334
- if (data.key !== this.batteryEntityId) return;
28335
- const state = data.payload;
28336
- if (!state) return;
28337
- this.applyBatteryState(state);
28338
- };
28339
- const unsub = this.ctx.eventBus.subscribe({ category: HaContainerDevice.BROKER_MSG_CATEGORY }, handler);
28340
- this.batteryEventUnsub = typeof unsub === "function" ? unsub : null;
28341
- }
28342
- /** Decode an HA entity state into the battery slice. Non-numeric states
28343
- * (`unavailable` / `unknown`) leave the last known value untouched. */
28344
- applyBatteryState(state) {
28345
- const percentage = this.batteryIsBinary ? parseBinaryBatteryPercentage(state.state) : parseBatteryPercentage(state.state);
28346
- if (percentage === null) return;
28347
- const next = {
28348
- percentage,
28349
- charging: "none",
28350
- sleeping: false,
28351
- lastUpdated: Date.parse(state.last_updated) || Date.now(),
28352
- binary: this.batteryIsBinary
28353
- };
28354
- this.runtimeState.setCapState("battery", next);
28355
- }
28356
- /**
28357
28469
  * Subscribe to `broker.message` events for every one of this device's
28358
28470
  * entities and derive the container's `online` from their aggregate
28359
28471
  * availability (A3): `online = any entity reachable`. A child entity is
@@ -28412,21 +28524,7 @@ var HaContainerDevice = class HaContainerDevice extends BaseDevice {
28412
28524
  }
28413
28525
  }
28414
28526
  await this.persistChildLayout();
28415
- if (this.batteryEntityId === null) return;
28416
- this.registerBatteryCap();
28417
- try {
28418
- const result = await this.ctx.api.broker.subscribe.mutate({
28419
- brokerId: this.brokerId,
28420
- filter: { entityIds: [this.batteryEntityId] }
28421
- });
28422
- this.batterySubscriptionId = result.subscriptionId;
28423
- } catch (err) {
28424
- this.ctx.logger.warn("ha-container failed to subscribe battery entity", { meta: {
28425
- entityId: this.batteryEntityId,
28426
- brokerId: this.brokerId,
28427
- error: errMsg(err)
28428
- } });
28429
- }
28527
+ await this.persistBatteryLinks();
28430
28528
  }
28431
28529
  async removeDevice() {
28432
28530
  if (this.availabilityEventUnsub) {
@@ -28444,21 +28542,6 @@ var HaContainerDevice = class HaContainerDevice extends BaseDevice {
28444
28542
  } catch {}
28445
28543
  this.availabilitySubscriptionId = null;
28446
28544
  }
28447
- if (this.batteryEventUnsub) {
28448
- try {
28449
- this.batteryEventUnsub();
28450
- } catch {}
28451
- this.batteryEventUnsub = null;
28452
- }
28453
- if (this.batterySubscriptionId !== null) {
28454
- try {
28455
- await this.ctx.api.broker.unsubscribe.mutate({
28456
- brokerId: this.brokerId,
28457
- subscriptionId: this.batterySubscriptionId
28458
- });
28459
- } catch {}
28460
- this.batterySubscriptionId = null;
28461
- }
28462
28545
  }
28463
28546
  /**
28464
28547
  * Derive this container's `childLayout` from its LIVE entities and persist it
@@ -28483,6 +28566,33 @@ var HaContainerDevice = class HaContainerDevice extends BaseDevice {
28483
28566
  }
28484
28567
  }
28485
28568
  /**
28569
+ * Derive this container's device-level battery wiring from its LIVE entities
28570
+ * (`buildBatteryLinks`) and persist it via `deviceManager.setDeviceLinks`,
28571
+ * PRESERVING any operator-authored links that target other caps (the
28572
+ * mutation replaces the device's whole `deviceLinks` array, so we merge:
28573
+ * foreign-target links kept, battery-target links replaced by the derived
28574
+ * set). Churn-free: skips the mutation when the persisted links already
28575
+ * match. Idempotent + best-effort (mirrors `persistChildLayout`): a failure
28576
+ * is logged and swallowed so it never breaks activation or reconcile.
28577
+ */
28578
+ async persistBatteryLinks() {
28579
+ try {
28580
+ const built = buildBatteryLinks(this.config.values.entities);
28581
+ const current = (await this.ctx.api.deviceManager.getDevice.query({ deviceId: this.id }))?.deviceLinks ?? [];
28582
+ const next = [...current.filter((l) => l.target.cap !== "battery"), ...built];
28583
+ if (JSON.stringify(next) === JSON.stringify(current)) return;
28584
+ await this.ctx.api.deviceManager.setDeviceLinks.mutate({
28585
+ deviceId: this.id,
28586
+ deviceLinks: next
28587
+ });
28588
+ } catch (err) {
28589
+ this.ctx.logger.warn("ha-container failed to persist battery device-links", { meta: {
28590
+ haDeviceId: this.config.values.haDeviceId,
28591
+ error: errMsg(err)
28592
+ } });
28593
+ }
28594
+ }
28595
+ /**
28486
28596
  * Device-level RAW upstream state for the physical HA device. HA has no
28487
28597
  * single device-level state blob — a device's truth lives in its entities —
28488
28598
  * so we aggregate every linked entity's raw `{ state, attributes }` (the
@@ -32912,7 +33022,10 @@ var HaProviderAddon = class HaProviderAddon extends BaseDeviceProvider {
32912
33022
  }
32913
33023
  }
32914
33024
  if (plan.membershipChanged && container instanceof HaContainerDevice) await container.reprobe();
32915
- if (container instanceof HaContainerDevice) await container.persistChildLayout();
33025
+ if (container instanceof HaContainerDevice) {
33026
+ await container.persistChildLayout();
33027
+ await container.persistBatteryLinks();
33028
+ }
32916
33029
  const integrationId = container.config.get("integrationId");
32917
33030
  this.ctx.logger.info("ha re-sync: container reconciled", {
32918
33031
  tags: {
package/dist/addon.mjs CHANGED
@@ -8474,7 +8474,13 @@ onStatusChanged: { data: object({
8474
8474
  }) } },
8475
8475
  status: {
8476
8476
  schema: BatteryStatusSchema,
8477
- kind: "push"
8477
+ kind: "push",
8478
+ empty: {
8479
+ percentage: 0,
8480
+ charging: "none",
8481
+ sleeping: false,
8482
+ lastUpdated: 0
8483
+ }
8478
8484
  },
8479
8485
  /**
8480
8486
  * Runtime-state slice — every provider that registers this cap
@@ -9417,21 +9423,38 @@ var connectivityCapability = {
9417
9423
  },
9418
9424
  runtimeState: ConnectivityStatusSchema
9419
9425
  };
9426
+ /**
9427
+ * Generic device-consumables capability — surfaces a device's
9428
+ * maintenance items (vacuum filters/brushes, replaceable cartridges,
9429
+ * descaling cycles, …) with their remaining life and an optional
9430
+ * "Replaced" reset action. Device-agnostic: any provider that knows its
9431
+ * device tracks consumables can register it; the cap declares no
9432
+ * vocabulary of its own — the provider names each item verbatim.
9433
+ *
9434
+ * Like `childLayout`, the cap is INERT until a provider sets items: no
9435
+ * provider populates it by guessing (no HA inference). The UI renders a
9436
+ * "No consumables reported" placeholder when `items` is empty.
9437
+ */
9438
+ /** A single consumable item. Either a continuous `level` (remaining
9439
+ * life %) or a discrete `status` may be known — both may be null when a
9440
+ * provider only knows the item exists. `level` and `status` are not
9441
+ * mutually exclusive; a provider may report both. */
9442
+ var ConsumableItemSchema = object({
9443
+ /** Stable id, e.g. 'main-brush'. */
9444
+ key: string().min(1),
9445
+ /** Display name. */
9446
+ label: string().min(1),
9447
+ /** Remaining life % when known (0..100). */
9448
+ level: number().min(0).max(100).nullable(),
9449
+ /** Discrete state when known (binary mode). */
9450
+ status: _enum(["ok", "replace"]).nullable(),
9451
+ /** Ms epoch of the last replace, when known. */
9452
+ lastResetAt: number().nullable(),
9453
+ /** Whether `reset()` is meaningful for this item. */
9454
+ resettable: boolean()
9455
+ });
9420
9456
  var ConsumablesStatusSchema = object({
9421
- items: array(object({
9422
- /** Stable id, e.g. 'main-brush'. */
9423
- key: string().min(1),
9424
- /** Display name. */
9425
- label: string().min(1),
9426
- /** Remaining life % when known (0..100). */
9427
- level: number().min(0).max(100).nullable(),
9428
- /** Discrete state when known (binary mode). */
9429
- status: _enum(["ok", "replace"]).nullable(),
9430
- /** Ms epoch of the last replace, when known. */
9431
- lastResetAt: number().nullable(),
9432
- /** Whether `reset()` is meaningful for this item. */
9433
- resettable: boolean()
9434
- })),
9457
+ items: array(ConsumableItemSchema),
9435
9458
  lastChangedAt: number()
9436
9459
  });
9437
9460
  var consumablesCapability = {
@@ -9490,7 +9513,25 @@ reset: method(object({
9490
9513
  }) },
9491
9514
  status: {
9492
9515
  schema: ConsumablesStatusSchema,
9493
- kind: "push"
9516
+ kind: "push",
9517
+ empty: {
9518
+ items: [],
9519
+ lastChangedAt: 0
9520
+ },
9521
+ itemArray: {
9522
+ path: "items",
9523
+ keyField: "key",
9524
+ labelField: "label",
9525
+ itemSchema: ConsumableItemSchema,
9526
+ emptyItem: {
9527
+ key: "",
9528
+ label: "",
9529
+ level: null,
9530
+ status: null,
9531
+ lastResetAt: null,
9532
+ resettable: false
9533
+ }
9534
+ }
9494
9535
  },
9495
9536
  runtimeState: ConsumablesStatusSchema.extend({ lastFetchedAt: number() })
9496
9537
  };
@@ -11266,6 +11307,31 @@ var ReportMotionInputSchema = object({
11266
11307
  regions: array(MotionRegionSchema).readonly().optional()
11267
11308
  });
11268
11309
  /**
11310
+ * Where a runner gets a camera's decoded frames (cross-node Phase 2,
11311
+ * restream-owner model — P2c).
11312
+ *
11313
+ * - `local-broker` (DEFAULT): today's path — subscribe to the co-located
11314
+ * stream-broker's shm frame plane. Every pre-P2c attach payload (no
11315
+ * `frameSource` key) parses to this, so the field is additive with zero
11316
+ * behavior change.
11317
+ * - `remote-restream`: the detect node is NOT the camera's source-owner.
11318
+ * The runner acquires the owner's COMPRESSED passthrough restream
11319
+ * (`streamBroker.getStreamWithCodec({video:'copy'})` — refcounted, the
11320
+ * double-pull guard) and decodes LOCALLY via a satellite frame plane +
11321
+ * pull-mode decoder session pinned to its own node. The shm ring stays
11322
+ * node-local; only H.264/H.265 packets cross the wire.
11323
+ * `hubHostnameOverride` mirrors the recorder's `recordingHubHostname`:
11324
+ * when set it overrides the `CAMSTACK_HUB_URL`-derived host the runner
11325
+ * dials for the owner's restream.
11326
+ */
11327
+ var RunnerFrameSourceSchema = discriminatedUnion("kind", [object({ kind: literal("local-broker") }), object({
11328
+ kind: literal("remote-restream"),
11329
+ /** The camera's source-owner node (slice 1: always the hub). */
11330
+ ownerNodeId: string(),
11331
+ /** Operator override for the owner host the runner dials. */
11332
+ hubHostnameOverride: string().optional()
11333
+ })]).describe("Per-camera frame-source mode for the runner (P2c)");
11334
+ /**
11269
11335
  * Camera assignment payload sent by `addon-pipeline-orchestrator` to a
11270
11336
  * specific runner instance via `attachCamera`. Carries everything the
11271
11337
  * runner needs to subscribe to the local broker and execute inference.
@@ -11363,7 +11429,15 @@ var RunnerCameraConfigSchema = object({
11363
11429
  */
11364
11430
  onboardMotionDrivesAnalyzer: boolean().default(true),
11365
11431
  occupancyRecheckSec: number().min(occupancyRecheckSecField.min).max(occupancyRecheckSecField.max).default(occupancyRecheckSecField.default),
11366
- occupancyRecheckFrames: number().min(occupancyRecheckFramesField.min).max(occupancyRecheckFramesField.max).default(occupancyRecheckFramesField.default)
11432
+ occupancyRecheckFrames: number().min(occupancyRecheckFramesField.min).max(occupancyRecheckFramesField.max).default(occupancyRecheckFramesField.default),
11433
+ /**
11434
+ * Where this runner gets the camera's decoded frames (P2c). Defaulted so
11435
+ * every existing payload behaves as `local-broker` — the pre-Phase-2 path.
11436
+ * Populated with `remote-restream` by the orchestrator ONLY when the
11437
+ * camera's detect node differs from its source-owner (P2d, gated by the
11438
+ * `remoteSourcingNodes` rollout setting).
11439
+ */
11440
+ frameSource: RunnerFrameSourceSchema.default({ kind: "local-broker" })
11367
11441
  });
11368
11442
  motionFpsField.min, motionFpsField.max, motionFpsField.step, motionFpsField.default, detectionFpsField.min, detectionFpsField.max, detectionFpsField.step, detectionFpsField.default, motionCooldownMsField.min, motionCooldownMsField.max, motionCooldownMsField.step, motionCooldownMsField.default, occupancyRecheckSecField.min, occupancyRecheckSecField.max, occupancyRecheckSecField.step, occupancyRecheckSecField.default, occupancyRecheckFramesField.min, occupancyRecheckFramesField.max, occupancyRecheckFramesField.step, occupancyRecheckFramesField.default;
11369
11443
  /**
@@ -15501,14 +15575,36 @@ var ChildLayoutEntrySchema = object({
15501
15575
  collapsed: boolean().optional()
15502
15576
  });
15503
15577
  /** Cap-wire shape of a DeviceLink — structurally mirrors `DeviceLink` in
15504
- * `device-management.ts`. */
15578
+ * `device-management.ts`. Source is a union: a FIELD source copies a sibling
15579
+ * accessory's status field (`kind` optional/absent for wire compat); a
15580
+ * LITERAL source carries a per-device constant (no sibling is read); a
15581
+ * GLOBAL source (P2e) copies ANY device's status field, addressed by the
15582
+ * source device's full re-sync-stable `stableId`. */
15505
15583
  var DeviceLinkSchema = object({
15506
15584
  id: string(),
15507
- source: object({
15508
- sourceKey: string(),
15509
- cap: string(),
15510
- fieldPath: string()
15511
- }),
15585
+ source: union([
15586
+ object({
15587
+ kind: literal("field").optional(),
15588
+ sourceKey: string(),
15589
+ cap: string(),
15590
+ fieldPath: string()
15591
+ }),
15592
+ object({
15593
+ kind: literal("literal"),
15594
+ value: union([
15595
+ string(),
15596
+ number(),
15597
+ boolean(),
15598
+ _null()
15599
+ ])
15600
+ }),
15601
+ object({
15602
+ kind: literal("global"),
15603
+ sourceStableId: string(),
15604
+ cap: string(),
15605
+ fieldPath: string()
15606
+ })
15607
+ ]),
15512
15608
  target: object({
15513
15609
  cap: string(),
15514
15610
  fieldPath: string(),
@@ -15751,7 +15847,10 @@ method(object({
15751
15847
  }), _void(), {
15752
15848
  kind: "mutation",
15753
15849
  auth: "admin"
15754
- }), method(object({ deviceId: number() }), object({ caps: array(object({
15850
+ }), method(object({
15851
+ deviceId: number(),
15852
+ includeSynthesizable: boolean().optional()
15853
+ }), object({ caps: array(object({
15755
15854
  cap: string(),
15756
15855
  fields: array(object({
15757
15856
  path: string(),
@@ -15761,8 +15860,13 @@ method(object({
15761
15860
  "boolean",
15762
15861
  "enum"
15763
15862
  ]),
15764
- enumValues: array(string()).optional()
15765
- })).readonly()
15863
+ enumValues: array(string()).optional(),
15864
+ item: boolean().optional()
15865
+ })).readonly(),
15866
+ itemArray: object({
15867
+ path: string(),
15868
+ keyField: string()
15869
+ }).optional()
15766
15870
  })).readonly() }), { kind: "query" }), method(object({
15767
15871
  deviceId: number(),
15768
15872
  role: string().nullable()
@@ -15832,7 +15936,11 @@ method(object({
15832
15936
  deviceId: number(),
15833
15937
  entries: array(object({
15834
15938
  capName: string(),
15835
- kind: _enum(["native", "wrapped"]),
15939
+ kind: _enum([
15940
+ "native",
15941
+ "wrapped",
15942
+ "linked"
15943
+ ]),
15836
15944
  providerAddonId: string(),
15837
15945
  providerNodeId: string(),
15838
15946
  nativeAddonId: string()
@@ -15841,7 +15949,11 @@ method(object({
15841
15949
  deviceId: number(),
15842
15950
  entries: array(object({
15843
15951
  capName: string(),
15844
- kind: _enum(["native", "wrapped"]),
15952
+ kind: _enum([
15953
+ "native",
15954
+ "wrapped",
15955
+ "linked"
15956
+ ]),
15845
15957
  providerAddonId: string(),
15846
15958
  providerNodeId: string(),
15847
15959
  nativeAddonId: string()
@@ -19871,6 +19983,16 @@ DeviceType.Camera, DeviceType.Sensor, DeviceType.Switch, method(object({ deviceI
19871
19983
  kind: "mutation",
19872
19984
  auth: "admin"
19873
19985
  });
19986
+ /**
19987
+ * `recording` cap — footage availability + HLS playback manifests + per-device
19988
+ * recording config. NOTE on events (source of truth, R5/C3): this cap carries
19989
+ * NO event surface — `getPlaybackManifest` returns playlist URLs only. Timeline
19990
+ * events (motion/object/audio) come from `pipelineAnalytics` (durable SQLite
19991
+ * rows); the recorder's internal EventMap markers are ephemeral in-RAM
19992
+ * annotations that are not exposed here and must not be treated as an event
19993
+ * feed. Event<->footage joins are by time, padded with the shared `EVENT_PAD_MS`
19994
+ * (`interfaces/recording-config.ts`).
19995
+ */
19874
19996
  var RecordingStatusSchema = object({
19875
19997
  deviceId: number(),
19876
19998
  enabled: boolean(),
@@ -28216,20 +28338,86 @@ function selectBatteryEntity(entities) {
28216
28338
  * fires — but not 0 % (the cell isn't dead, it just needs replacing). */
28217
28339
  var BINARY_BATTERY_NORMAL_PCT = 100;
28218
28340
  var BINARY_BATTERY_LOW_PCT = 15;
28219
- /** Parse an HA battery state string (`"42"`) into a clamped 0–100 percentage.
28220
- * Returns `null` for non-numeric states (`unavailable` / `unknown`). */
28221
- function parseBatteryPercentage(raw) {
28222
- const value = Number(raw);
28223
- if (!Number.isFinite(value)) return null;
28224
- return Math.max(0, Math.min(100, value));
28225
- }
28226
- /** Map a BINARY battery state (`LOW_BAT`) to a percentage. HA convention:
28227
- * `on` / `true` = LOW, `off` / `false` = normal. Unknown states → `null`. */
28228
- function parseBinaryBatteryPercentage(raw) {
28229
- const s = raw.trim().toLowerCase();
28230
- if (s === "on" || s === "true" || s === "low") return BINARY_BATTERY_LOW_PCT;
28231
- if (s === "off" || s === "false" || s === "normal" || s === "ok") return BINARY_BATTERY_NORMAL_PCT;
28232
- return null;
28341
+ /**
28342
+ * Derive the container's device-level `battery` wiring from its entities as
28343
+ * auto-authored `DeviceLink`s. The battery cap on the container is SYNTHESIZED
28344
+ * by the generic device-link machinery (cap `status.empty` + these links)
28345
+ * no native battery provider is registered on the container.
28346
+ *
28347
+ * - NUMERIC battery sensor (real 0–100 %): field link `numeric-sensor.value →
28348
+ * battery.percentage` with a `linear 1x+0 clamp [0,100]` transform (preserves
28349
+ * the historical `parseBatteryPercentage` clamping so an out-of-range
28350
+ * firmware value can never fail the whole synthesized status), plus
28351
+ * `lastFetchedAt lastUpdated`.
28352
+ * - BINARY `LOW_BAT` indicator: `binary.on battery.percentage` through an
28353
+ * `enum-map` (`on`/low 15 %, `off`/normal 100 % the historical
28354
+ * `parseBinaryBatteryPercentage` mapping), `lastChangedAt → lastUpdated`,
28355
+ * plus a LITERAL `binary: true` flag so the UI renders "Normal"/"Low"
28356
+ * instead of a misleading exact percentage.
28357
+ * - Numeric is preferred over binary when a device exposes both
28358
+ * (`selectBatteryEntity`); no battery entity → no links (cap absent).
28359
+ *
28360
+ * `sourceKey` is the battery child's accessory `stableIdSuffix` (its
28361
+ * `entityId` — see `getAccessoryChildren`), so links survive a re-sync.
28362
+ */
28363
+ function buildBatteryLinks(entities) {
28364
+ const battery = selectBatteryEntity(entities);
28365
+ if (!battery) return [];
28366
+ const binary = isBinaryBattery(battery);
28367
+ const cap = binary ? "binary" : "numeric-sensor";
28368
+ const percentage = {
28369
+ id: "battery-percentage",
28370
+ source: {
28371
+ sourceKey: battery.entityId,
28372
+ cap,
28373
+ fieldPath: binary ? "on" : "value"
28374
+ },
28375
+ target: {
28376
+ cap: "battery",
28377
+ fieldPath: "percentage"
28378
+ },
28379
+ transform: binary ? {
28380
+ kind: "enum-map",
28381
+ mapping: {
28382
+ true: BINARY_BATTERY_LOW_PCT,
28383
+ false: BINARY_BATTERY_NORMAL_PCT
28384
+ }
28385
+ } : {
28386
+ kind: "linear",
28387
+ scale: 1,
28388
+ offset: 0,
28389
+ clamp: [0, 100]
28390
+ }
28391
+ };
28392
+ const lastUpdated = {
28393
+ id: "battery-lastUpdated",
28394
+ source: {
28395
+ sourceKey: battery.entityId,
28396
+ cap,
28397
+ fieldPath: binary ? "lastChangedAt" : "lastFetchedAt"
28398
+ },
28399
+ target: {
28400
+ cap: "battery",
28401
+ fieldPath: "lastUpdated"
28402
+ },
28403
+ transform: { kind: "identity" }
28404
+ };
28405
+ if (!binary) return [percentage, lastUpdated];
28406
+ return [
28407
+ percentage,
28408
+ lastUpdated,
28409
+ {
28410
+ id: "battery-binary",
28411
+ source: {
28412
+ kind: "literal",
28413
+ value: true
28414
+ },
28415
+ target: {
28416
+ cap: "battery",
28417
+ fieldPath: "binary"
28418
+ }
28419
+ }
28420
+ ];
28233
28421
  }
28234
28422
  /**
28235
28423
  * Parent container device for a single HA physical device. Owns no
@@ -28252,17 +28440,6 @@ var HaContainerDevice = class HaContainerDevice extends BaseDevice {
28252
28440
  features;
28253
28441
  brokerId;
28254
28442
  integrationId;
28255
- /** entityId of the battery entity (if any) on this physical device. The
28256
- * device-level `battery` cap mirrors this entity's live percentage. */
28257
- batteryEntityId;
28258
- /** True when the battery entity is a binary `LOW_BAT` indicator (no real
28259
- * percentage) rather than a numeric sensor — drives the state decode. */
28260
- batteryIsBinary;
28261
- /** Disposer for the cap event-bus listener that decodes battery pushes. */
28262
- batteryEventUnsub = null;
28263
- /** Upstream broker subscription id for the battery entity — released in
28264
- * `removeDevice`. Null until `onActivate` subscribes. */
28265
- batterySubscriptionId = null;
28266
28443
  /** Set of this device's entity ids (the physical HA device's entities). The
28267
28444
  * container's `online` is derived as "any of these entities reachable" (A3). */
28268
28445
  entityIds;
@@ -28283,76 +28460,11 @@ var HaContainerDevice = class HaContainerDevice extends BaseDevice {
28283
28460
  super(ctx, haContainerSchema, { type: DeviceType.Container });
28284
28461
  this.brokerId = cfg.brokerId;
28285
28462
  this.integrationId = cfg.integrationId;
28286
- this.batteryEntityId = batteryEntity?.entityId ?? null;
28287
- this.batteryIsBinary = batteryEntity !== null && isBinaryBattery(batteryEntity);
28288
28463
  this.features = batteryEntity !== null ? [DeviceFeature.Resyncable, DeviceFeature.BatteryOperated] : [DeviceFeature.Resyncable];
28289
28464
  this.entityIds = new Set(cfg.entities.map((e) => e.entityId));
28290
28465
  this.online = false;
28291
28466
  }
28292
28467
  /**
28293
- * Register the device-level `battery` cap on the container, backed by
28294
- * the runtime-state slice. The UI battery badge gates on the presence
28295
- * of this slice (the `useDeviceBattery` hook reads `device.state.battery`),
28296
- * so registering the cap + seeding the slice is what surfaces the badge
28297
- * on the physical device — the per-entity numeric-sensor child stays.
28298
- *
28299
- * The slice is fed from the battery entity's live HA state via the same
28300
- * `broker.message` event-bus mechanism the per-domain children use; the
28301
- * listener is attached here so the integration manager's immediate
28302
- * cache-replay (triggered by the broker subscription in `onActivate`)
28303
- * lands after the cap schema is installed.
28304
- */
28305
- registerBatteryCap() {
28306
- const COLD_START = {
28307
- percentage: this.batteryIsBinary ? BINARY_BATTERY_NORMAL_PCT : 0,
28308
- charging: "none",
28309
- sleeping: false,
28310
- lastUpdated: 0,
28311
- binary: this.batteryIsBinary
28312
- };
28313
- this.ctx.registerNativeCap(batteryCapability, {
28314
- getStatus: async ({ deviceId }) => {
28315
- if (deviceId !== this.id) throw new Error(`HaContainerDevice: battery deviceId mismatch, expected ${this.id}, got ${deviceId}`);
28316
- return this.runtimeState.getCapState("battery") ?? COLD_START;
28317
- },
28318
- wakeForStream: async () => ({
28319
- awoke: true,
28320
- durationMs: 0
28321
- })
28322
- });
28323
- this.runtimeState.setCapState("battery", COLD_START);
28324
- this.attachBatteryEventListener();
28325
- }
28326
- /** Subscribe to `broker.message` events and decode the battery entity's
28327
- * percentage into the `battery` slice on every matching push. */
28328
- attachBatteryEventListener() {
28329
- const handler = (event) => {
28330
- const data = event.data;
28331
- if (!data) return;
28332
- if (data.brokerId !== this.brokerId) return;
28333
- if (data.key !== this.batteryEntityId) return;
28334
- const state = data.payload;
28335
- if (!state) return;
28336
- this.applyBatteryState(state);
28337
- };
28338
- const unsub = this.ctx.eventBus.subscribe({ category: HaContainerDevice.BROKER_MSG_CATEGORY }, handler);
28339
- this.batteryEventUnsub = typeof unsub === "function" ? unsub : null;
28340
- }
28341
- /** Decode an HA entity state into the battery slice. Non-numeric states
28342
- * (`unavailable` / `unknown`) leave the last known value untouched. */
28343
- applyBatteryState(state) {
28344
- const percentage = this.batteryIsBinary ? parseBinaryBatteryPercentage(state.state) : parseBatteryPercentage(state.state);
28345
- if (percentage === null) return;
28346
- const next = {
28347
- percentage,
28348
- charging: "none",
28349
- sleeping: false,
28350
- lastUpdated: Date.parse(state.last_updated) || Date.now(),
28351
- binary: this.batteryIsBinary
28352
- };
28353
- this.runtimeState.setCapState("battery", next);
28354
- }
28355
- /**
28356
28468
  * Subscribe to `broker.message` events for every one of this device's
28357
28469
  * entities and derive the container's `online` from their aggregate
28358
28470
  * availability (A3): `online = any entity reachable`. A child entity is
@@ -28411,21 +28523,7 @@ var HaContainerDevice = class HaContainerDevice extends BaseDevice {
28411
28523
  }
28412
28524
  }
28413
28525
  await this.persistChildLayout();
28414
- if (this.batteryEntityId === null) return;
28415
- this.registerBatteryCap();
28416
- try {
28417
- const result = await this.ctx.api.broker.subscribe.mutate({
28418
- brokerId: this.brokerId,
28419
- filter: { entityIds: [this.batteryEntityId] }
28420
- });
28421
- this.batterySubscriptionId = result.subscriptionId;
28422
- } catch (err) {
28423
- this.ctx.logger.warn("ha-container failed to subscribe battery entity", { meta: {
28424
- entityId: this.batteryEntityId,
28425
- brokerId: this.brokerId,
28426
- error: errMsg(err)
28427
- } });
28428
- }
28526
+ await this.persistBatteryLinks();
28429
28527
  }
28430
28528
  async removeDevice() {
28431
28529
  if (this.availabilityEventUnsub) {
@@ -28443,21 +28541,6 @@ var HaContainerDevice = class HaContainerDevice extends BaseDevice {
28443
28541
  } catch {}
28444
28542
  this.availabilitySubscriptionId = null;
28445
28543
  }
28446
- if (this.batteryEventUnsub) {
28447
- try {
28448
- this.batteryEventUnsub();
28449
- } catch {}
28450
- this.batteryEventUnsub = null;
28451
- }
28452
- if (this.batterySubscriptionId !== null) {
28453
- try {
28454
- await this.ctx.api.broker.unsubscribe.mutate({
28455
- brokerId: this.brokerId,
28456
- subscriptionId: this.batterySubscriptionId
28457
- });
28458
- } catch {}
28459
- this.batterySubscriptionId = null;
28460
- }
28461
28544
  }
28462
28545
  /**
28463
28546
  * Derive this container's `childLayout` from its LIVE entities and persist it
@@ -28482,6 +28565,33 @@ var HaContainerDevice = class HaContainerDevice extends BaseDevice {
28482
28565
  }
28483
28566
  }
28484
28567
  /**
28568
+ * Derive this container's device-level battery wiring from its LIVE entities
28569
+ * (`buildBatteryLinks`) and persist it via `deviceManager.setDeviceLinks`,
28570
+ * PRESERVING any operator-authored links that target other caps (the
28571
+ * mutation replaces the device's whole `deviceLinks` array, so we merge:
28572
+ * foreign-target links kept, battery-target links replaced by the derived
28573
+ * set). Churn-free: skips the mutation when the persisted links already
28574
+ * match. Idempotent + best-effort (mirrors `persistChildLayout`): a failure
28575
+ * is logged and swallowed so it never breaks activation or reconcile.
28576
+ */
28577
+ async persistBatteryLinks() {
28578
+ try {
28579
+ const built = buildBatteryLinks(this.config.values.entities);
28580
+ const current = (await this.ctx.api.deviceManager.getDevice.query({ deviceId: this.id }))?.deviceLinks ?? [];
28581
+ const next = [...current.filter((l) => l.target.cap !== "battery"), ...built];
28582
+ if (JSON.stringify(next) === JSON.stringify(current)) return;
28583
+ await this.ctx.api.deviceManager.setDeviceLinks.mutate({
28584
+ deviceId: this.id,
28585
+ deviceLinks: next
28586
+ });
28587
+ } catch (err) {
28588
+ this.ctx.logger.warn("ha-container failed to persist battery device-links", { meta: {
28589
+ haDeviceId: this.config.values.haDeviceId,
28590
+ error: errMsg(err)
28591
+ } });
28592
+ }
28593
+ }
28594
+ /**
28485
28595
  * Device-level RAW upstream state for the physical HA device. HA has no
28486
28596
  * single device-level state blob — a device's truth lives in its entities —
28487
28597
  * so we aggregate every linked entity's raw `{ state, attributes }` (the
@@ -32911,7 +33021,10 @@ var HaProviderAddon = class HaProviderAddon extends BaseDeviceProvider {
32911
33021
  }
32912
33022
  }
32913
33023
  if (plan.membershipChanged && container instanceof HaContainerDevice) await container.reprobe();
32914
- if (container instanceof HaContainerDevice) await container.persistChildLayout();
33024
+ if (container instanceof HaContainerDevice) {
33025
+ await container.persistChildLayout();
33026
+ await container.persistBatteryLinks();
33027
+ }
32915
33028
  const integrationId = container.config.get("integrationId");
32916
33029
  this.ctx.logger.info("ha re-sync: container reconciled", {
32917
33030
  tags: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-homeassistant",
3
- "version": "1.1.15",
3
+ "version": "1.1.16",
4
4
  "description": "Home Assistant device provider addon for CamStack",
5
5
  "keywords": [
6
6
  "camstack",