@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.
- package/dist/addon.js +262 -149
- package/dist/addon.mjs +262 -149
- 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(
|
|
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:
|
|
15509
|
-
|
|
15510
|
-
|
|
15511
|
-
|
|
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({
|
|
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
|
-
|
|
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([
|
|
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([
|
|
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
|
-
/**
|
|
28221
|
-
*
|
|
28222
|
-
|
|
28223
|
-
|
|
28224
|
-
|
|
28225
|
-
|
|
28226
|
-
|
|
28227
|
-
|
|
28228
|
-
*
|
|
28229
|
-
|
|
28230
|
-
|
|
28231
|
-
|
|
28232
|
-
|
|
28233
|
-
|
|
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
|
-
|
|
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)
|
|
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(
|
|
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:
|
|
15508
|
-
|
|
15509
|
-
|
|
15510
|
-
|
|
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({
|
|
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
|
-
|
|
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([
|
|
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([
|
|
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
|
-
/**
|
|
28220
|
-
*
|
|
28221
|
-
|
|
28222
|
-
|
|
28223
|
-
|
|
28224
|
-
|
|
28225
|
-
|
|
28226
|
-
|
|
28227
|
-
*
|
|
28228
|
-
|
|
28229
|
-
|
|
28230
|
-
|
|
28231
|
-
|
|
28232
|
-
|
|
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
|
-
|
|
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)
|
|
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: {
|