@camstack/addon-provider-amcrest 0.2.10 → 0.2.11

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 +2248 -1325
  2. package/dist/addon.mjs +2248 -1325
  3. package/package.json +1 -1
package/dist/addon.mjs CHANGED
@@ -6467,7 +6467,20 @@ var BrokerStatsSchema = object({
6467
6467
  sampleRate: number(),
6468
6468
  channels: number(),
6469
6469
  supported: boolean()
6470
- }).nullable().optional()
6470
+ }).nullable().optional(),
6471
+ /**
6472
+ * BROKER-SIDE AUDIO MUTE (D83). `true` = this broker is deliberately
6473
+ * distributing none of the device's audio, on live or recording.
6474
+ *
6475
+ * Present so a silent camera can be told apart from a broken one on the
6476
+ * stream panel itself, without cross-referencing the switch group: a
6477
+ * broker holding an `audio` track descriptor while `audioMuted` is true is
6478
+ * working exactly as asked. `audioMutedDropped` counts the audio units
6479
+ * thrown away since the current dial — it is how you confirm from stats
6480
+ * alone that the mute is on the packet path and not merely persisted.
6481
+ */
6482
+ audioMuted: boolean().optional(),
6483
+ audioMutedDropped: number().optional()
6471
6484
  });
6472
6485
  /**
6473
6486
  * Exporter-facing "profile restream" entry. Returned by
@@ -7100,6 +7113,19 @@ object({
7100
7113
  * | `notifications` | `notificationRules.setDeviceMuted` | `NotificationCenter.evaluateAndEnqueue` returns before any rule is evaluated |
7101
7114
  * | `privacy-mask` | `privacyMask.setMask({ enabled })` → the CAMERA | the camera blanks the masked regions itself; every stream and recording carries the black boxes |
7102
7115
  * | `device-audio` | `privacyMask.setAudioEnabled` → the CAMERA | the camera stops encoding an audio track at all; every consumer sees silent video |
7116
+ * | `broker-audio` | `streamBroker.setDeviceAudioMute` → `DeviceOverride.audioMuted` | `StreamBroker.setAudioMuted` drops the audio plane at the source: no `type:'audio'` packet leaves `fanOutEncoded`, no RTP reaches the restreamer, and the restreamer serves the video-only SDP |
7117
+ *
7118
+ * ## `device-audio` and `broker-audio` are two functions, not two knobs
7119
+ *
7120
+ * They look adjacent and they are not the same control ([D83](../../../../docs/decisions/adr-0083.md)):
7121
+ * `device-audio` writes the CAMERA, so it is hardware privacy — the microphone
7122
+ * genuinely stops, it survives CamStack entirely, and it costs a multi-second
7123
+ * encoder restart on every flip. `broker-audio` writes THIS server, so it is
7124
+ * instant, vendor-independent and reversible without touching the camera, and
7125
+ * a camera that ignores or lacks the ISAPI/Reolink control is still silenced.
7126
+ * D62 forbids a second switch that *disagrees* with the first; these two
7127
+ * cannot disagree, because neither reads the other's store — the camera holds
7128
+ * one, the broker holds the other, and each reports its own fact.
7103
7129
  *
7104
7130
  * ## The two switches whose authority is not on this server
7105
7131
  *
@@ -7161,6 +7187,7 @@ var CameraSwitchIdSchema = _enum([
7161
7187
  "object-detection",
7162
7188
  "privacy-mask",
7163
7189
  "device-audio",
7190
+ "broker-audio",
7164
7191
  "audio-analysis",
7165
7192
  "recording",
7166
7193
  "notifications"
@@ -7186,7 +7213,8 @@ var CameraSwitchAuthoritySchema = discriminatedUnion("kind", [
7186
7213
  object({
7187
7214
  kind: literal("camera-mask"),
7188
7215
  capName: string()
7189
- })
7216
+ }),
7217
+ object({ kind: literal("broker-audio-mute") })
7190
7218
  ]);
7191
7219
  /**
7192
7220
  * Why a switch is not offered for this camera. Rendered instead of the
@@ -8912,6 +8940,69 @@ var StreamFormatSchema = _enum([
8912
8940
  "mjpeg",
8913
8941
  "rtsp"
8914
8942
  ]);
8943
+ /** A container `produceEventMedia` can emit. */
8944
+ var EventMediaKindSchema = _enum(["mp4", "gif"]);
8945
+ /**
8946
+ * One produced artifact, referenced by HANDLE.
8947
+ *
8948
+ * Never inline bytes: a produced clip is 200 KB–5 MB and every consumer of this
8949
+ * method is in another runner ([D9](../../../../docs/decisions/adr-0009.md),
8950
+ * [D18](../../../../docs/decisions/adr-0018.md) — cross-process media is fetched
8951
+ * on demand, compressed, by handle). `bytes` is here so a caller can decide
8952
+ * whether it wants the fetch at all.
8953
+ */
8954
+ var EventMediaArtifactSchema = object({
8955
+ kind: EventMediaKindSchema,
8956
+ /** Opaque, single-camera, short-lived. Redeem with `fetchEventMedia`. */
8957
+ handle: string(),
8958
+ /**
8959
+ * The node holding the bytes — the ROUTING key for `fetchEventMedia`.
8960
+ *
8961
+ * `stream-broker` is a singleton cap and an unpinned call never leaves the
8962
+ * hub, so a handle produced on an agent's broker would be redeemed against
8963
+ * the hub's store and come back `null`. Same contract, same field name and
8964
+ * the same reason as `FrameHandleSchema.nodeId`: the producer stamps where it
8965
+ * lives and the consumer pins to it.
8966
+ */
8967
+ nodeId: string(),
8968
+ mime: string(),
8969
+ bytes: number().int(),
8970
+ width: number().int(),
8971
+ height: number().int()
8972
+ });
8973
+ /**
8974
+ * What a production actually covered — the answer to the only question an
8975
+ * operator asks about a notification clip.
8976
+ *
8977
+ * `fromTs`/`toTs` are WALL CLOCK, derived from the ring's own packet timeline,
8978
+ * so a caller can state "this clip starts 4.1 s before the event" instead of
8979
+ * inferring it from a duration. A production whose `fromTs` is later than the
8980
+ * event is a production with no pre-roll, and that is exactly the defect this
8981
+ * method exists to make visible rather than plausible.
8982
+ */
8983
+ var EventMediaCoverageSchema = object({
8984
+ fromTs: number(),
8985
+ toTs: number(),
8986
+ /** Encoded packets in the muxed window. */
8987
+ packets: number().int()
8988
+ });
8989
+ /**
8990
+ * The result of ONE cut, in every container the caller asked for.
8991
+ *
8992
+ * Every artifact in `media` came out of the SAME window of the SAME rendition —
8993
+ * that is the whole reason this is one method rather than one call per format.
8994
+ * A consumer attaching a gif and a video can no longer show two different
8995
+ * moments, because it never chose two sources.
8996
+ */
8997
+ var EventMediaProductionSchema = object({
8998
+ media: array(EventMediaArtifactSchema).readonly(),
8999
+ coverage: EventMediaCoverageSchema,
9000
+ /** The rendition actually cut from — what the default or the fallback chose. */
9001
+ profile: CamProfileSchema,
9002
+ /** `copy` = the camera's own H.264, untouched. `encode` = re-encoded (H.265
9003
+ * source, a downscale, or a playback rate other than 1). */
9004
+ video: _enum(["copy", "encode"])
9005
+ });
8915
9006
  var RtspRestreamEntrySchema = object({
8916
9007
  brokerId: string(),
8917
9008
  url: string(),
@@ -9311,6 +9402,56 @@ method(object({
9311
9402
  }), {
9312
9403
  kind: "mutation",
9313
9404
  auth: "admin"
9405
+ }), method(object({
9406
+ deviceId: number(),
9407
+ /** Absent = the largest H.264 rendition at or below 1080p, which is
9408
+ * also the one that can be copied. Falls back to whatever the ring
9409
+ * actually retained, and the answer says which. */
9410
+ profile: CamProfileSchema.optional(),
9411
+ aroundMs: number(),
9412
+ preSeconds: number().min(0).max(20).default(4),
9413
+ postSeconds: number().min(0).max(20).default(6),
9414
+ kinds: array(EventMediaKindSchema).min(1).default(["mp4"]),
9415
+ /** GIF geometry. The video keeps the source's own. */
9416
+ gifMaxWidth: number().int().min(120).max(1280).default(640),
9417
+ /**
9418
+ * The gif's own PLAYBACK rate in frames per second — what the finished
9419
+ * gif runs at, not how many source frames feed it. The decimation that
9420
+ * feeds it samples `gifFps / gifSpeed` source frames per second, so at
9421
+ * the defaults a 12 fps gif is built out of 3 source frames a second.
9422
+ */
9423
+ gifFps: number().int().min(1).max(15).default(12),
9424
+ /**
9425
+ * How fast the GIF plays against real time, independent of `speed`.
9426
+ *
9427
+ * 4× by default, by operator request: a notification gif is glanced at
9428
+ * on a lock screen, so a ~12 s window has to be over in ~3 s. It stays
9429
+ * a separate knob from `speed` even though both now default to 4 —
9430
+ * a caller wanting a real-time video and a fast gif must not have to
9431
+ * choose.
9432
+ */
9433
+ gifSpeed: number().min(1).max(8).default(4),
9434
+ /**
9435
+ * Playback rate of the VIDEO. Also 4× by default, by operator decision.
9436
+ *
9437
+ * `1` is real time and is the ONLY value that allows the copy branch —
9438
+ * anything else forces `libx264` over the window. That was priced
9439
+ * before it was chosen: a per-event burst measured at 0.23 s and 254 KB
9440
+ * on a real 615 720p cut, against 922 KB for the copy it replaces. A
9441
+ * re-encode is capped at 720p (`EVENT_CLIP_ENCODE_MAX_WIDTH`), because
9442
+ * once the decode is forced the width stops being free.
9443
+ */
9444
+ speed: number().min(1).max(8).default(4)
9445
+ }), EventMediaProductionSchema, {
9446
+ kind: "mutation",
9447
+ auth: "admin"
9448
+ }), method(object({ handle: string() }), object({
9449
+ base64: string(),
9450
+ mime: string(),
9451
+ bytes: number().int()
9452
+ }).nullable(), {
9453
+ kind: "mutation",
9454
+ auth: "admin"
9314
9455
  }), method(_void(), array(CameraStreamSchema).readonly()), method(_void(), array(ProfileSlotSchema).readonly()), method(object({ brokerId: string() }), BrokerStatsSchema), method(object({ brokerId: string() }), object({
9315
9456
  probed: boolean(),
9316
9457
  summary: string()
@@ -9383,7 +9524,25 @@ method(object({
9383
9524
  }), _void(), {
9384
9525
  kind: "mutation",
9385
9526
  auth: "admin"
9386
- }), method(object({ brokerId: string() }), boolean()), object({
9527
+ }), method(object({ brokerId: string() }), boolean()), method(object({ deviceId: number().int() }), object({
9528
+ muted: boolean(),
9529
+ /**
9530
+ * How many live non-derived brokers currently hold the mute. Purely
9531
+ * diagnostic: `muted` is the policy and is authoritative on its own
9532
+ * (it applies to brokers that do not exist yet), while this says
9533
+ * whether anything is presently being silenced.
9534
+ */
9535
+ appliedBrokers: number().int().nonnegative()
9536
+ })), method(object({
9537
+ deviceId: number().int(),
9538
+ muted: boolean()
9539
+ }), object({
9540
+ muted: boolean(),
9541
+ appliedBrokers: number().int().nonnegative()
9542
+ }), {
9543
+ kind: "mutation",
9544
+ auth: "admin"
9545
+ }), object({
9387
9546
  deviceId: number().int().nonnegative(),
9388
9547
  camStreamId: string(),
9389
9548
  profile: CamProfileSchema
@@ -9580,25 +9739,6 @@ var cameraStreamsCapability = {
9580
9739
  lastChangedAt: number()
9581
9740
  })
9582
9741
  };
9583
- /**
9584
- * core-blocks — user-authored TypeScript, stored in the kernel and executed in
9585
- * its own process.
9586
- *
9587
- * Spec: `docs/superpowers/specs/2026-08-04-core-blocks-and-synthetic-devices-design.md`.
9588
- *
9589
- * The first use is **owning devices without being a device provider**: a block
9590
- * declares devices under a system or custom integration and drives their state,
9591
- * with the same `ctx` an addon gets. Automations come later; nothing here
9592
- * models a trigger.
9593
- *
9594
- * **Stated plainly, because it does not change by being true:** a block has an
9595
- * addon's powers — devices, storage, the event bus, `ctx.api`. It is a plugin
9596
- * with no review step. What makes that survivable is not a sandbox, it is
9597
- * PROCESS ISOLATION: one process per block, supervised by `CrashSupervisor`,
9598
- * so a block that throws or never returns is marked `failed` and visible
9599
- * instead of taking the hub with it (D6). Every method here is admin-only, and
9600
- * must stay so.
9601
- */
9602
9742
  /** Where a block runs. The operator chooses — a block driving a device on an
9603
9743
  * agent is the reason placement is not fixed to the hub. */
9604
9744
  var CoreBlockPlacementSchema = union([literal("hub"), string().min(1)]);
@@ -9670,6 +9810,9 @@ method(object({}), object({ blocks: array(CoreBlockSchema) }), { auth: "admin" }
9670
9810
  }), object({ block: CoreBlockSchema }), {
9671
9811
  kind: "mutation",
9672
9812
  auth: "admin"
9813
+ }), method(object({ blockId: string() }), object({ block: CoreBlockSchema }), {
9814
+ kind: "mutation",
9815
+ auth: "admin"
9673
9816
  }), method(object({ code: string() }), CoreBlockCompileResultSchema, {
9674
9817
  kind: "mutation",
9675
9818
  auth: "admin"
@@ -10448,895 +10591,214 @@ var ExposeInputSchema = object({
10448
10591
  });
10449
10592
  var UnexposeInputSchema = object({ deviceId: string() });
10450
10593
  method(_void(), DeviceExportStatusSchema), method(_void(), array(DeviceKindSchema)), method(_void(), array(ExposedDeviceSchema)), method(ExposeInputSchema, _void(), { kind: "mutation" }), method(UnexposeInputSchema, _void(), { kind: "mutation" });
10594
+ var ProviderStatusSchema = object({
10595
+ connected: boolean(),
10596
+ deviceCount: number(),
10597
+ error: string().optional()
10598
+ });
10599
+ object({
10600
+ externalId: string(),
10601
+ name: string(),
10602
+ type: string(),
10603
+ metadata: record(string(), unknown()).optional()
10604
+ });
10451
10605
  /**
10452
- * Resource-bound constants for the safe expression engine.
10453
- *
10454
- * Every bound is defense-in-depth: the grammar is non-Turing-complete (no
10455
- * loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
10456
- * O(nodeCount) by construction. These caps merely put a hard ceiling on the
10457
- * work a single author-supplied expression can request, so a hostile or
10458
- * accidental pathological string can never spend unbounded CPU/memory.
10606
+ * Candidate handed back from discovery and accepted by
10607
+ * `adoptDiscoveredDevice`. Shape mirrors the in-process
10608
+ * `DiscoveredDevice` interface used by `DeviceDiscovery`.
10459
10609
  */
10460
- /** Max source length (chars) — checked BEFORE tokenizing so a huge string is
10461
- * rejected without allocation. */
10462
- var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
10463
- /** A legal binding / identifier name. */
10464
- var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
10465
- /** Binding names an author may NOT use: `now` is auto-injected; the literal
10466
- * keywords lex as values, not identifiers, so binding to them is meaningless. */
10467
- var RESERVED_BINDING_NAMES = new Set([
10468
- "now",
10469
- "true",
10470
- "false",
10471
- "null"
10472
- ]);
10610
+ var DiscoveryCandidateSchema = object({
10611
+ stableId: string(),
10612
+ type: _enum(DeviceType),
10613
+ suggestedName: string(),
10614
+ prefilledConfig: record(string(), unknown()),
10615
+ /**
10616
+ * Optional upstream-system identity (HA entity_id, vendor MAC, …).
10617
+ * Discovery pre-populates this for systems that know the upstream
10618
+ * identity ahead of adoption. Rendering metadata (unit, precision)
10619
+ * flows live through the cap STATUS SLICE after adoption.
10620
+ */
10621
+ sourceInfo: SourceInfoSchema.optional()
10622
+ });
10473
10623
  /**
10474
- * Error types for the safe expression engine. Two distinct classes so callers
10475
- * can tell a compile-time (grammar) failure from a runtime (evaluation)
10476
- * failure both are non-fatal to the host: read paths degrade to "skip link".
10624
+ * Flat device summary returned by `createDevice` / `adoptDiscoveredDevice`.
10625
+ * Mirrors `toDeviceShape()` output in `device-management.router.ts` so the
10626
+ * tRPC layer can pass it through without reshaping.
10477
10627
  */
10478
- /** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
10479
- * the failure is anchored to a character (author-facing inline feedback). */
10480
- var ExpressionParseError = class extends Error {
10481
- position;
10482
- constructor(message, position) {
10483
- super(message);
10484
- this.name = "ExpressionParseError";
10485
- this.position = position;
10486
- }
10487
- };
10488
- /** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
10489
- * result, unknown builtin, step-budget exceeded). */
10490
- var ExpressionEvalError = class extends Error {
10491
- constructor(message) {
10492
- super(message);
10493
- this.name = "ExpressionEvalError";
10628
+ var DeviceSummarySchema = object({
10629
+ id: number(),
10630
+ stableId: string(),
10631
+ addonId: string(),
10632
+ type: string(),
10633
+ name: string(),
10634
+ parentDeviceId: number().nullable(),
10635
+ online: boolean(),
10636
+ features: array(string()),
10637
+ config: record(string(), unknown()),
10638
+ /** Optional upstream-system identity (dispatch key + system tag).
10639
+ * See `SourceInfo`. Present when the device has a non-synthetic
10640
+ * source identifier (HA entities, vendor MAC, …); omitted when the
10641
+ * synthetic backfill is in effect. */
10642
+ sourceInfo: SourceInfoSchema.optional()
10643
+ });
10644
+ /**
10645
+ * Result of a live field test (e.g. probing an RTSP URL during device
10646
+ * creation). Matches the UI-side `FieldProbeResult` in
10647
+ * `interfaces/config-ui.ts` — the admin `FormBuilder` renders the
10648
+ * returned `labels` as chips next to the input.
10649
+ */
10650
+ var FieldProbeResultSchema = object({
10651
+ status: _enum(["ok", "error"]),
10652
+ labels: array(string()).optional(),
10653
+ error: string().optional()
10654
+ });
10655
+ /**
10656
+ * The output of `getChildCreationSchema` is a UI schema tree. We store
10657
+ * it as `unknown` at the capability layer — the router just passes it
10658
+ * through and the admin UI renders it via `FormBuilder`. The actual
10659
+ * type is `ConfigUISchema` (see `packages/types/src/interfaces/config-ui.ts`),
10660
+ * but we deliberately avoid a Zod mirror because the union is large and
10661
+ * not meant for runtime validation at this seam.
10662
+ */
10663
+ var CreationSchemaOutputSchema = unknown();
10664
+ var deviceProviderCapability = {
10665
+ name: "device-provider",
10666
+ scope: "system",
10667
+ mode: "collection",
10668
+ methods: {
10669
+ start: method(_void(), _void(), { kind: "mutation" }),
10670
+ stop: method(_void(), _void(), { kind: "mutation" }),
10671
+ getStatus: method(_void(), ProviderStatusSchema),
10672
+ getDevices: method(_void(), array(object({
10673
+ id: string(),
10674
+ name: string(),
10675
+ type: string()
10676
+ }))),
10677
+ supportsDiscovery: method(object({}), boolean()),
10678
+ /**
10679
+ * Run a network scan. `params` carries optional provider-specific scan
10680
+ * inputs (e.g. a broadcast address / subnet for cross-subnet discovery),
10681
+ * shaped by `getDiscoveryParamsSchema`. Omitted for the generic scan
10682
+ * (provider uses its local-network default).
10683
+ */
10684
+ discoverDevices: method(object({ params: record(string(), unknown()).optional() }), array(DiscoveryCandidateSchema), {
10685
+ kind: "mutation",
10686
+ auth: "admin"
10687
+ }),
10688
+ /**
10689
+ * Optional form schema (`ConfigUISchema`) for the EXTRA per-scan inputs a
10690
+ * provider accepts (e.g. Gree's broadcast address for a different subnet).
10691
+ * `null` when the provider takes no extra scan params — the generic
10692
+ * aggregated scan never renders this; the per-integration scan does.
10693
+ */
10694
+ getDiscoveryParamsSchema: method(object({}), CreationSchemaOutputSchema),
10695
+ /**
10696
+ * The DeviceType this provider creates via manual add (Camera for
10697
+ * Reolink/ONVIF, Container for Gree, Hub for Ecowitt). `null` when the
10698
+ * provider does not support manual creation. Lets the Add-Device dialog
10699
+ * pick the right type instead of assuming Camera.
10700
+ */
10701
+ getManualCreationType: method(object({}), object({ deviceType: _enum(DeviceType).nullable() })),
10702
+ adoptDiscoveredDevice: method(object({ candidate: DiscoveryCandidateSchema }), DeviceSummarySchema, {
10703
+ kind: "mutation",
10704
+ auth: "admin"
10705
+ }),
10706
+ supportsManualCreation: method(object({}), boolean()),
10707
+ /**
10708
+ * Fetch the creation form schema for a given DeviceType. Returns
10709
+ * `null` when the provider does not support manually creating
10710
+ * devices of that type. The output is a `ConfigUISchema` — the
10711
+ * router type-asserts it at the boundary.
10712
+ */
10713
+ getChildCreationSchema: method(object({ type: _enum(DeviceType) }), CreationSchemaOutputSchema),
10714
+ createDevice: method(object({
10715
+ type: _enum(DeviceType),
10716
+ config: record(string(), unknown())
10717
+ }), DeviceSummarySchema, {
10718
+ kind: "mutation",
10719
+ auth: "admin"
10720
+ }),
10721
+ /**
10722
+ * Test a single field in the creation form before the device has
10723
+ * been persisted. Typical use: probing an RTSP URL entered by the
10724
+ * user. Providers that don't support field probing return
10725
+ * `{ success: true, message: 'Field test not supported' }`.
10726
+ *
10727
+ * `formValues` is the live snapshot of every field in the form at
10728
+ * the moment the user clicked Test — useful for probes that depend
10729
+ * on multiple fields together (e.g. Reolink autodetect needs host
10730
+ * + credentials + UID + transport mode in a single call). Optional
10731
+ * for backwards compatibility; providers free to ignore it.
10732
+ */
10733
+ testCreationField: method(object({
10734
+ type: _enum(DeviceType),
10735
+ key: string(),
10736
+ value: unknown(),
10737
+ formValues: record(string(), unknown()).optional()
10738
+ }), FieldProbeResultSchema, {
10739
+ kind: "mutation",
10740
+ auth: "admin"
10741
+ })
10494
10742
  }
10495
10743
  };
10496
10744
  /**
10497
- * Frozen, null-prototype builtin function table for the expression engine
10498
- * (spec §4 rule 4). The table is the SOLE surface of callable functions: the
10499
- * parser rejects any callee not in it, and the evaluator gates each call on an
10500
- * own-property check against it.
10745
+ * Device Manager capability hub-side singleton that unifies device persistence,
10746
+ * live registry access, and all management operations into a single tRPC surface.
10501
10747
  *
10502
- * Because the object has a NULL prototype AND is `Object.freeze`d:
10503
- * - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
10504
- * - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
10505
- * (there is no `Object.prototype` in the chain), so those names are not
10506
- * callable — they are simply "unknown function" at parse time.
10748
+ * Replaces:
10749
+ * - `device-persistence` capability (persistence methods absorbed here)
10750
+ * - `device-management.router.ts` (deleted in Phase 2)
10751
+ * - `device-ops.router.ts` (compat layer deleted; device-provider ops absorbed here)
10507
10752
  *
10508
- * Every numeric argument is validated as a finite number and every numeric
10509
- * RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
10510
- * (`pow(10,400)` Infinity) all raise `ExpressionEvalError` and fail the link
10511
- * closed rather than emitting a garbage value.
10753
+ * All device provider addons (rtsp, onvif, frigate, …) are hub-local: they may
10754
+ * fork into separate processes but never run on remote cluster agents. Therefore:
10755
+ * - No nodeId routing needed this is a pure hub singleton.
10756
+ * - The hub's DeviceRegistry is the single source of truth for all live devices.
10757
+ * - No shadow registry or cross-node aggregation required.
10758
+ *
10759
+ * Forked workers register devices back to the hub via `ctx.devices`
10760
+ * (DeviceManagerApi → ctx.api.deviceManager.registerDevice), same as today.
10512
10761
  */
10513
- function asFiniteNumber(value, name, index) {
10514
- if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
10515
- return value;
10516
- }
10517
- function asString$1(value, name, index) {
10518
- if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
10519
- return value;
10520
- }
10521
- function finiteResult(value, name) {
10522
- if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
10523
- return value;
10524
- }
10525
- function allFiniteNumbers(args, name) {
10526
- return args.map((a, idx) => asFiniteNumber(a, name, idx));
10527
- }
10528
- var INF = Number.POSITIVE_INFINITY;
10529
- var table = {
10530
- min: {
10531
- minArgs: 1,
10532
- maxArgs: INF,
10533
- apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
10534
- },
10535
- max: {
10536
- minArgs: 1,
10537
- maxArgs: INF,
10538
- apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
10539
- },
10540
- abs: {
10541
- minArgs: 1,
10542
- maxArgs: 1,
10543
- apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
10544
- },
10545
- floor: {
10546
- minArgs: 1,
10547
- maxArgs: 1,
10548
- apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
10549
- },
10550
- ceil: {
10551
- minArgs: 1,
10552
- maxArgs: 1,
10553
- apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
10554
- },
10555
- sqrt: {
10556
- minArgs: 1,
10557
- maxArgs: 1,
10558
- apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
10559
- },
10560
- round: {
10561
- minArgs: 1,
10562
- maxArgs: 2,
10563
- apply: (args) => {
10564
- const x = asFiniteNumber(args[0], "round", 0);
10565
- const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
10566
- if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
10567
- const factor = 10 ** digits;
10568
- return finiteResult(Math.round(x * factor) / factor, "round");
10569
- }
10570
- },
10571
- pow: {
10572
- minArgs: 2,
10573
- maxArgs: 2,
10574
- apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
10575
- },
10576
- clamp: {
10577
- minArgs: 3,
10578
- maxArgs: 3,
10579
- apply: (args) => {
10580
- const x = asFiniteNumber(args[0], "clamp", 0);
10581
- const lo = asFiniteNumber(args[1], "clamp", 1);
10582
- const hi = asFiniteNumber(args[2], "clamp", 2);
10583
- if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
10584
- return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
10585
- }
10586
- },
10587
- avg: {
10588
- minArgs: 1,
10589
- maxArgs: INF,
10590
- apply: (args) => {
10591
- const nums = allFiniteNumbers(args, "avg");
10592
- return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
10593
- }
10594
- },
10595
- sum: {
10596
- minArgs: 1,
10597
- maxArgs: INF,
10598
- apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
10599
- },
10600
- coalesce: {
10601
- minArgs: 1,
10602
- maxArgs: INF,
10603
- apply: (args) => {
10604
- for (const a of args) if (a !== null) return a;
10605
- return null;
10606
- }
10607
- },
10608
- age: {
10609
- minArgs: 2,
10610
- maxArgs: 2,
10611
- apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
10612
- },
10613
- convert: {
10614
- minArgs: 3,
10615
- maxArgs: 3,
10616
- apply: (args, hooks) => {
10617
- const x = asFiniteNumber(args[0], "convert", 0);
10618
- const from = asString$1(args[1], "convert", 1).trim();
10619
- const to = asString$1(args[2], "convert", 2).trim();
10620
- if (hooks.convert) {
10621
- const out = hooks.convert(x, from, to);
10622
- if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
10623
- return finiteResult(out, "convert");
10624
- }
10625
- if (from === to) return x;
10626
- throw new ExpressionEvalError("convert: unit conversion table not installed");
10627
- }
10628
- }
10629
- };
10630
- Object.freeze(Object.assign(Object.create(null), table));
10631
- /** The set of valid builtin names — used by the parser to reject unknown
10632
- * callees at parse time (immediate author feedback). */
10633
- var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
10762
+ /** One child-placement directive on a container's `childLayout`. Structurally
10763
+ * identical to `ChildLayoutEntry` in `device-management.ts` the cap wire
10764
+ * shape for the same field. The child is identified by its re-sync-stable
10765
+ * accessory `stableIdSuffix` (`childKey`); listed children are grouped into
10766
+ * named accordion sections (with optional intra-section order). */
10767
+ var ChildLayoutEntrySchema = object({
10768
+ childKey: string(),
10769
+ section: string(),
10770
+ order: number().optional(),
10771
+ collapsed: boolean().optional()
10772
+ });
10773
+ /** Cap-wire shape of a per-cap display refinement — mirrors
10774
+ * `DeviceCapDisplayOverride` in `device-management.ts`. */
10775
+ var DeviceCapDisplayOverrideSchema = object({
10776
+ unit: string().min(1).optional(),
10777
+ precision: number().int().min(0).max(10).optional()
10778
+ });
10779
+ /** Cap-wire shape of an operator-authored per-device display override —
10780
+ * mirrors `DeviceDisplayOverride` in `device-management.ts`. `precision`
10781
+ * bounds mirror `numeric-sensor.cap.ts` (`int 0-10`). */
10782
+ var DeviceDisplayOverrideSchema = object({
10783
+ icon: string().min(1).optional(),
10784
+ label: string().min(1).optional(),
10785
+ unit: string().min(1).optional(),
10786
+ precision: number().int().min(0).max(10).optional(),
10787
+ hidden: boolean().optional(),
10788
+ perCap: record(string(), DeviceCapDisplayOverrideSchema).optional()
10789
+ });
10790
+ /** Cap-wire shape of a per-role display default — mirrors `RoleDisplayDefault`
10791
+ * in `device-management.ts`. Keyed by `DeviceRole` string (role strings cross
10792
+ * the wire as plain strings everywhere else — cf. `DeviceInfoSchema.role`). */
10793
+ var RoleDisplayDefaultSchema = object({
10794
+ unit: string().min(1).optional(),
10795
+ precision: number().int().min(0).max(10).optional(),
10796
+ icon: string().min(1).optional()
10797
+ });
10634
10798
  /**
10635
- * Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
10636
- * zero-dependency. The grammar is deliberately boring: decimal numbers,
10637
- * single/double-quoted strings with a tiny escape set, identifiers, the three
10638
- * value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
10639
- * outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
10640
- * is a parse error with a source position, so member access / assignment /
10641
- * template literals are lexically impossible.
10642
- */
10643
- var KEYWORDS = new Set([
10644
- "true",
10645
- "false",
10646
- "null"
10647
- ]);
10648
- function isDigit(ch) {
10649
- return ch >= "0" && ch <= "9";
10650
- }
10651
- function isIdentStart(ch) {
10652
- return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
10653
- }
10654
- function isIdentPart(ch) {
10655
- return isIdentStart(ch) || isDigit(ch);
10656
- }
10657
- function isWhitespace(ch) {
10658
- return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
10659
- }
10660
- /** Tokenize `source` into a flat token list ending with a single `eof` token.
10661
- * Throws `ExpressionParseError` on any illegal character or unterminated
10662
- * string. */
10663
- function tokenize(source) {
10664
- if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
10665
- const tokens = [];
10666
- let i = 0;
10667
- const n = source.length;
10668
- while (i < n) {
10669
- const ch = source[i];
10670
- if (isWhitespace(ch)) {
10671
- i += 1;
10672
- continue;
10673
- }
10674
- if (isDigit(ch)) {
10675
- const start = i;
10676
- while (i < n && isDigit(source[i])) i += 1;
10677
- if (i < n && source[i] === ".") {
10678
- if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
10679
- i += 1;
10680
- while (i < n && isDigit(source[i])) i += 1;
10681
- }
10682
- const text = source.slice(start, i);
10683
- const value = Number(text);
10684
- if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
10685
- tokens.push({
10686
- type: "number",
10687
- value,
10688
- pos: start
10689
- });
10690
- continue;
10691
- }
10692
- if (ch === "'" || ch === "\"") {
10693
- const quote = ch;
10694
- const start = i;
10695
- i += 1;
10696
- let out = "";
10697
- let closed = false;
10698
- while (i < n) {
10699
- const c = source[i];
10700
- if (c === "\\") {
10701
- const next = i + 1 < n ? source[i + 1] : "";
10702
- if (next === "\\" || next === "'" || next === "\"") {
10703
- out += next;
10704
- i += 2;
10705
- continue;
10706
- }
10707
- throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
10708
- }
10709
- if (c === quote) {
10710
- closed = true;
10711
- i += 1;
10712
- break;
10713
- }
10714
- out += c;
10715
- i += 1;
10716
- }
10717
- if (!closed) throw new ExpressionParseError("unterminated string literal", start);
10718
- tokens.push({
10719
- type: "string",
10720
- value: out,
10721
- pos: start
10722
- });
10723
- continue;
10724
- }
10725
- if (isIdentStart(ch)) {
10726
- const start = i;
10727
- while (i < n && isIdentPart(source[i])) i += 1;
10728
- const text = source.slice(start, i);
10729
- if (KEYWORDS.has(text)) tokens.push({
10730
- type: "keyword",
10731
- keyword: keywordOf(text),
10732
- pos: start
10733
- });
10734
- else tokens.push({
10735
- type: "identifier",
10736
- name: text,
10737
- pos: start
10738
- });
10739
- continue;
10740
- }
10741
- const two = i + 1 < n ? source.slice(i, i + 2) : "";
10742
- if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
10743
- tokens.push({
10744
- type: "punct",
10745
- punct: two,
10746
- pos: i
10747
- });
10748
- i += 2;
10749
- continue;
10750
- }
10751
- if (isSinglePunct(ch)) {
10752
- tokens.push({
10753
- type: "punct",
10754
- punct: ch,
10755
- pos: i
10756
- });
10757
- i += 1;
10758
- continue;
10759
- }
10760
- throw new ExpressionParseError(`unexpected character '${ch}'`, i);
10761
- }
10762
- tokens.push({
10763
- type: "eof",
10764
- pos: n
10765
- });
10766
- return tokens;
10767
- }
10768
- function keywordOf(text) {
10769
- if (text === "true") return "true";
10770
- if (text === "false") return "false";
10771
- return "null";
10772
- }
10773
- function isSinglePunct(ch) {
10774
- return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
10775
- }
10776
- /**
10777
- * Pratt (precedence-climbing) parser for the safe expression mini-language.
10778
- *
10779
- * Precedence (low → high): ternary `?:` (right-assoc) → `||` → `&&` → equality
10780
- * → relational → additive → multiplicative → unary `! -` → call / primary.
10781
- * Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
10782
- * string validated against the builtin table at parse time, so an unknown
10783
- * function is rejected immediately (author feedback) and a persisted expression
10784
- * that references a since-removed builtin degrades at read.
10785
- *
10786
- * A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
10787
- * arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
10788
- */
10789
- /** Binary/logical operator precedence (higher binds tighter). */
10790
- var BINARY_PRECEDENCE = {
10791
- "||": 1,
10792
- "&&": 2,
10793
- "==": 3,
10794
- "!=": 3,
10795
- "<": 4,
10796
- "<=": 4,
10797
- ">": 4,
10798
- ">=": 4,
10799
- "+": 5,
10800
- "-": 5,
10801
- "*": 6,
10802
- "/": 6,
10803
- "%": 6
10804
- };
10805
- function isLogicalOp(op) {
10806
- return op === "&&" || op === "||";
10807
- }
10808
- function isBinaryOp(op) {
10809
- return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
10810
- }
10811
- var Parser = class {
10812
- tokens;
10813
- pos = 0;
10814
- nodeCount = 0;
10815
- identifiers = /* @__PURE__ */ new Set();
10816
- callees = /* @__PURE__ */ new Set();
10817
- constructor(tokens) {
10818
- this.tokens = tokens;
10819
- }
10820
- parse() {
10821
- const ast = this.parseTernary();
10822
- const tok = this.peek();
10823
- if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
10824
- return {
10825
- ast,
10826
- identifiers: this.identifiers,
10827
- callees: this.callees,
10828
- nodeCount: this.nodeCount
10829
- };
10830
- }
10831
- peek() {
10832
- return this.tokens[this.pos];
10833
- }
10834
- next() {
10835
- return this.tokens[this.pos++];
10836
- }
10837
- /** Consume a punctuator token, erroring if the next token isn't it. */
10838
- expectPunct(punct) {
10839
- const tok = this.peek();
10840
- if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
10841
- this.pos += 1;
10842
- }
10843
- matchPunct(punct) {
10844
- const tok = this.peek();
10845
- if (tok.type === "punct" && tok.punct === punct) {
10846
- this.pos += 1;
10847
- return true;
10848
- }
10849
- return false;
10850
- }
10851
- countNode() {
10852
- this.nodeCount += 1;
10853
- if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
10854
- }
10855
- parseTernary() {
10856
- const test = this.parseBinary(1);
10857
- if (this.matchPunct("?")) {
10858
- const consequent = this.parseTernary();
10859
- this.expectPunct(":");
10860
- const alternate = this.parseTernary();
10861
- this.countNode();
10862
- return {
10863
- kind: "conditional",
10864
- test,
10865
- consequent,
10866
- alternate
10867
- };
10868
- }
10869
- return test;
10870
- }
10871
- parseBinary(minPrec) {
10872
- let left = this.parseUnary();
10873
- for (;;) {
10874
- const tok = this.peek();
10875
- if (tok.type !== "punct") break;
10876
- const prec = BINARY_PRECEDENCE[tok.punct];
10877
- if (prec === void 0 || prec < minPrec) break;
10878
- const op = tok.punct;
10879
- this.pos += 1;
10880
- const right = this.parseBinary(prec + 1);
10881
- this.countNode();
10882
- if (isLogicalOp(op)) left = {
10883
- kind: "logical",
10884
- op,
10885
- left,
10886
- right
10887
- };
10888
- else if (isBinaryOp(op)) left = {
10889
- kind: "binary",
10890
- op,
10891
- left,
10892
- right
10893
- };
10894
- else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
10895
- }
10896
- return left;
10897
- }
10898
- parseUnary() {
10899
- const tok = this.peek();
10900
- if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
10901
- const op = tok.punct;
10902
- this.pos += 1;
10903
- const operand = this.parseUnary();
10904
- this.countNode();
10905
- return {
10906
- kind: "unary",
10907
- op,
10908
- operand
10909
- };
10910
- }
10911
- return this.parsePrimary();
10912
- }
10913
- parsePrimary() {
10914
- const tok = this.next();
10915
- switch (tok.type) {
10916
- case "number":
10917
- this.countNode();
10918
- return {
10919
- kind: "literal",
10920
- value: tok.value
10921
- };
10922
- case "string":
10923
- this.countNode();
10924
- return {
10925
- kind: "literal",
10926
- value: tok.value
10927
- };
10928
- case "keyword":
10929
- this.countNode();
10930
- return {
10931
- kind: "literal",
10932
- value: tok.keyword === "null" ? null : tok.keyword === "true"
10933
- };
10934
- case "identifier": {
10935
- const nextTok = this.peek();
10936
- if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
10937
- this.identifiers.add(tok.name);
10938
- this.countNode();
10939
- return {
10940
- kind: "identifier",
10941
- name: tok.name
10942
- };
10943
- }
10944
- case "punct":
10945
- if (tok.punct === "(") {
10946
- const inner = this.parseTernary();
10947
- this.expectPunct(")");
10948
- return inner;
10949
- }
10950
- throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
10951
- case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
10952
- }
10953
- }
10954
- parseCall(callee, pos) {
10955
- if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
10956
- this.expectPunct("(");
10957
- const args = [];
10958
- if (!this.matchPunct(")")) for (;;) {
10959
- args.push(this.parseTernary());
10960
- if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
10961
- if (this.matchPunct(",")) continue;
10962
- this.expectPunct(")");
10963
- break;
10964
- }
10965
- this.callees.add(callee);
10966
- this.countNode();
10967
- return {
10968
- kind: "call",
10969
- callee,
10970
- args
10971
- };
10972
- }
10973
- };
10974
- /** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
10975
- * `ExpressionParseError` on any lexical or grammatical failure. */
10976
- function parseExpression(source) {
10977
- return new Parser(tokenize(source)).parse();
10978
- }
10979
- /**
10980
- * LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
10981
- * by expr"). The cache stores BOTH successes and failures (negative caching),
10982
- * so a corrupt persisted string costs exactly one tokenize+parse total — not
10983
- * one per read on a hot resolve path.
10984
- *
10985
- * The cache is a module-level singleton: entries are pure, content-addressed
10986
- * ASTs keyed by the raw source string, so sharing one instance across all
10987
- * callers is safe and maximises hit rate.
10988
- */
10989
- var cache = /* @__PURE__ */ new Map();
10990
- function getCached(source) {
10991
- const hit = cache.get(source);
10992
- if (hit !== void 0) {
10993
- cache.delete(source);
10994
- cache.set(source, hit);
10995
- return hit;
10996
- }
10997
- let result;
10998
- try {
10999
- result = {
11000
- ok: true,
11001
- parsed: parseExpression(source)
11002
- };
11003
- } catch (err) {
11004
- result = {
11005
- ok: false,
11006
- error: err instanceof ExpressionParseError ? err.message : String(err)
11007
- };
11008
- }
11009
- cache.set(source, result);
11010
- if (cache.size > 256) {
11011
- const oldest = cache.keys().next().value;
11012
- if (oldest !== void 0) cache.delete(oldest);
11013
- }
11014
- return result;
11015
- }
11016
- /** Compile `source`, returning a discriminated result instead of throwing.
11017
- * Used by read paths that must degrade rather than raise. LRU/negative-cached. */
11018
- function compileExpressionSafe(source) {
11019
- return getCached(source);
11020
- }
11021
- Object.freeze({});
11022
- /**
11023
- * Author-time validation. Returns `null` when the source is valid, else a
11024
- * human-readable error message. Checks: the expression compiles; binding count
11025
- * is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
11026
- * is not reserved (`now`/keywords) and does not shadow a builtin; and every
11027
- * FREE identifier of the AST is covered by a binding or the injected `now`.
11028
- */
11029
- function validateExpressionSource(src) {
11030
- const names = Object.keys(src.bindings);
11031
- if (names.length > 32) return `too many bindings (${names.length} > 32)`;
11032
- for (const name of names) {
11033
- if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
11034
- if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
11035
- if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
11036
- }
11037
- const compiled = compileExpressionSafe(src.expr);
11038
- if (!compiled.ok) return compiled.error;
11039
- const bound = new Set(names);
11040
- for (const id of compiled.parsed.identifiers) {
11041
- if (id === "now") continue;
11042
- if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
11043
- }
11044
- return null;
11045
- }
11046
- var ProviderStatusSchema = object({
11047
- connected: boolean(),
11048
- deviceCount: number(),
11049
- error: string().optional()
11050
- });
11051
- object({
11052
- externalId: string(),
11053
- name: string(),
11054
- type: string(),
11055
- metadata: record(string(), unknown()).optional()
11056
- });
11057
- /**
11058
- * Candidate handed back from discovery and accepted by
11059
- * `adoptDiscoveredDevice`. Shape mirrors the in-process
11060
- * `DiscoveredDevice` interface used by `DeviceDiscovery`.
11061
- */
11062
- var DiscoveryCandidateSchema = object({
11063
- stableId: string(),
11064
- type: _enum(DeviceType),
11065
- suggestedName: string(),
11066
- prefilledConfig: record(string(), unknown()),
11067
- /**
11068
- * Optional upstream-system identity (HA entity_id, vendor MAC, …).
11069
- * Discovery pre-populates this for systems that know the upstream
11070
- * identity ahead of adoption. Rendering metadata (unit, precision)
11071
- * flows live through the cap STATUS SLICE after adoption.
11072
- */
11073
- sourceInfo: SourceInfoSchema.optional()
11074
- });
11075
- /**
11076
- * Flat device summary returned by `createDevice` / `adoptDiscoveredDevice`.
11077
- * Mirrors `toDeviceShape()` output in `device-management.router.ts` so the
11078
- * tRPC layer can pass it through without reshaping.
11079
- */
11080
- var DeviceSummarySchema = object({
11081
- id: number(),
11082
- stableId: string(),
11083
- addonId: string(),
11084
- type: string(),
11085
- name: string(),
11086
- parentDeviceId: number().nullable(),
11087
- online: boolean(),
11088
- features: array(string()),
11089
- config: record(string(), unknown()),
11090
- /** Optional upstream-system identity (dispatch key + system tag).
11091
- * See `SourceInfo`. Present when the device has a non-synthetic
11092
- * source identifier (HA entities, vendor MAC, …); omitted when the
11093
- * synthetic backfill is in effect. */
11094
- sourceInfo: SourceInfoSchema.optional()
11095
- });
11096
- /**
11097
- * Result of a live field test (e.g. probing an RTSP URL during device
11098
- * creation). Matches the UI-side `FieldProbeResult` in
11099
- * `interfaces/config-ui.ts` — the admin `FormBuilder` renders the
11100
- * returned `labels` as chips next to the input.
11101
- */
11102
- var FieldProbeResultSchema = object({
11103
- status: _enum(["ok", "error"]),
11104
- labels: array(string()).optional(),
11105
- error: string().optional()
11106
- });
11107
- /**
11108
- * The output of `getChildCreationSchema` is a UI schema tree. We store
11109
- * it as `unknown` at the capability layer — the router just passes it
11110
- * through and the admin UI renders it via `FormBuilder`. The actual
11111
- * type is `ConfigUISchema` (see `packages/types/src/interfaces/config-ui.ts`),
11112
- * but we deliberately avoid a Zod mirror because the union is large and
11113
- * not meant for runtime validation at this seam.
11114
- */
11115
- var CreationSchemaOutputSchema = unknown();
11116
- var deviceProviderCapability = {
11117
- name: "device-provider",
11118
- scope: "system",
11119
- mode: "collection",
11120
- methods: {
11121
- start: method(_void(), _void(), { kind: "mutation" }),
11122
- stop: method(_void(), _void(), { kind: "mutation" }),
11123
- getStatus: method(_void(), ProviderStatusSchema),
11124
- getDevices: method(_void(), array(object({
11125
- id: string(),
11126
- name: string(),
11127
- type: string()
11128
- }))),
11129
- supportsDiscovery: method(object({}), boolean()),
11130
- /**
11131
- * Run a network scan. `params` carries optional provider-specific scan
11132
- * inputs (e.g. a broadcast address / subnet for cross-subnet discovery),
11133
- * shaped by `getDiscoveryParamsSchema`. Omitted for the generic scan
11134
- * (provider uses its local-network default).
11135
- */
11136
- discoverDevices: method(object({ params: record(string(), unknown()).optional() }), array(DiscoveryCandidateSchema), {
11137
- kind: "mutation",
11138
- auth: "admin"
11139
- }),
11140
- /**
11141
- * Optional form schema (`ConfigUISchema`) for the EXTRA per-scan inputs a
11142
- * provider accepts (e.g. Gree's broadcast address for a different subnet).
11143
- * `null` when the provider takes no extra scan params — the generic
11144
- * aggregated scan never renders this; the per-integration scan does.
11145
- */
11146
- getDiscoveryParamsSchema: method(object({}), CreationSchemaOutputSchema),
11147
- /**
11148
- * The DeviceType this provider creates via manual add (Camera for
11149
- * Reolink/ONVIF, Container for Gree, Hub for Ecowitt). `null` when the
11150
- * provider does not support manual creation. Lets the Add-Device dialog
11151
- * pick the right type instead of assuming Camera.
11152
- */
11153
- getManualCreationType: method(object({}), object({ deviceType: _enum(DeviceType).nullable() })),
11154
- adoptDiscoveredDevice: method(object({ candidate: DiscoveryCandidateSchema }), DeviceSummarySchema, {
11155
- kind: "mutation",
11156
- auth: "admin"
11157
- }),
11158
- supportsManualCreation: method(object({}), boolean()),
11159
- /**
11160
- * Fetch the creation form schema for a given DeviceType. Returns
11161
- * `null` when the provider does not support manually creating
11162
- * devices of that type. The output is a `ConfigUISchema` — the
11163
- * router type-asserts it at the boundary.
11164
- */
11165
- getChildCreationSchema: method(object({ type: _enum(DeviceType) }), CreationSchemaOutputSchema),
11166
- createDevice: method(object({
11167
- type: _enum(DeviceType),
11168
- config: record(string(), unknown())
11169
- }), DeviceSummarySchema, {
11170
- kind: "mutation",
11171
- auth: "admin"
11172
- }),
11173
- /**
11174
- * Test a single field in the creation form before the device has
11175
- * been persisted. Typical use: probing an RTSP URL entered by the
11176
- * user. Providers that don't support field probing return
11177
- * `{ success: true, message: 'Field test not supported' }`.
11178
- *
11179
- * `formValues` is the live snapshot of every field in the form at
11180
- * the moment the user clicked Test — useful for probes that depend
11181
- * on multiple fields together (e.g. Reolink autodetect needs host
11182
- * + credentials + UID + transport mode in a single call). Optional
11183
- * for backwards compatibility; providers free to ignore it.
11184
- */
11185
- testCreationField: method(object({
11186
- type: _enum(DeviceType),
11187
- key: string(),
11188
- value: unknown(),
11189
- formValues: record(string(), unknown()).optional()
11190
- }), FieldProbeResultSchema, {
11191
- kind: "mutation",
11192
- auth: "admin"
11193
- })
11194
- }
11195
- };
11196
- /**
11197
- * Device Manager capability — hub-side singleton that unifies device persistence,
11198
- * live registry access, and all management operations into a single tRPC surface.
11199
- *
11200
- * Replaces:
11201
- * - `device-persistence` capability (persistence methods absorbed here)
11202
- * - `device-management.router.ts` (deleted in Phase 2)
11203
- * - `device-ops.router.ts` (compat layer — deleted; device-provider ops absorbed here)
11204
- *
11205
- * All device provider addons (rtsp, onvif, frigate, …) are hub-local: they may
11206
- * fork into separate processes but never run on remote cluster agents. Therefore:
11207
- * - No nodeId routing needed — this is a pure hub singleton.
11208
- * - The hub's DeviceRegistry is the single source of truth for all live devices.
11209
- * - No shadow registry or cross-node aggregation required.
11210
- *
11211
- * Forked workers register devices back to the hub via `ctx.devices`
11212
- * (DeviceManagerApi → ctx.api.deviceManager.registerDevice), same as today.
11213
- */
11214
- /** One child-placement directive on a container's `childLayout`. Structurally
11215
- * identical to `ChildLayoutEntry` in `device-management.ts` — the cap wire
11216
- * shape for the same field. The child is identified by its re-sync-stable
11217
- * accessory `stableIdSuffix` (`childKey`); listed children are grouped into
11218
- * named accordion sections (with optional intra-section order). */
11219
- var ChildLayoutEntrySchema = object({
11220
- childKey: string(),
11221
- section: string(),
11222
- order: number().optional(),
11223
- collapsed: boolean().optional()
11224
- });
11225
- /** Cap-wire shape of a DeviceLink — structurally mirrors `DeviceLink` in
11226
- * `device-management.ts`. Source is a union: a FIELD source copies a sibling
11227
- * accessory's status field (`kind` optional/absent for wire compat); a
11228
- * LITERAL source carries a per-device constant (no sibling is read); a
11229
- * GLOBAL source (P2e) copies ANY device's status field, addressed by the
11230
- * source device's full re-sync-stable `stableId`. */
11231
- var DeviceLinkFieldSourceSchema = object({
11232
- kind: literal("field").optional(),
11233
- sourceKey: string(),
11234
- cap: string(),
11235
- fieldPath: string()
11236
- });
11237
- var DeviceLinkLiteralSourceSchema = object({
11238
- kind: literal("literal"),
11239
- value: union([
11240
- string(),
11241
- number(),
11242
- boolean(),
11243
- _null()
11244
- ])
11245
- });
11246
- var DeviceLinkGlobalSourceSchema = object({
11247
- kind: literal("global"),
11248
- sourceStableId: string(),
11249
- cap: string(),
11250
- fieldPath: string()
11251
- });
11252
- /** Expression source (Stage X): compute the target field from N named bindings
11253
- * via the safe expression engine. Bindings are field | literal | global — never
11254
- * another expression (no nesting). The `superRefine` runs the SAME author-time
11255
- * validation as `validateExpressionSource` (compiles the expr, checks binding
11256
- * names + identifier coverage) so every wire boundary that parses a DeviceLink
11257
- * (tRPC mount, kernel create pre-seed, projection output) validates-at-write.
11258
- * Compiles are LRU-cached, so repeated validation of the same expr is a hit. */
11259
- var DeviceLinkExpressionSourceSchema = object({
11260
- kind: literal("expression"),
11261
- expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
11262
- bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), union([
11263
- DeviceLinkFieldSourceSchema,
11264
- DeviceLinkLiteralSourceSchema,
11265
- DeviceLinkGlobalSourceSchema
11266
- ]))
11267
- }).superRefine((src, ctx) => {
11268
- const err = validateExpressionSource(src);
11269
- if (err !== null) ctx.addIssue({
11270
- code: "custom",
11271
- message: err,
11272
- path: ["expr"]
11273
- });
11274
- });
11275
- var DeviceLinkSchema = object({
11276
- id: string(),
11277
- source: union([
11278
- DeviceLinkFieldSourceSchema,
11279
- DeviceLinkLiteralSourceSchema,
11280
- DeviceLinkGlobalSourceSchema,
11281
- DeviceLinkExpressionSourceSchema
11282
- ]),
11283
- target: object({
11284
- cap: string(),
11285
- fieldPath: string(),
11286
- itemKey: string().optional()
11287
- }),
11288
- transform: discriminatedUnion("kind", [
11289
- object({ kind: literal("identity") }),
11290
- object({
11291
- kind: literal("enum-map"),
11292
- mapping: record(string(), union([
11293
- string(),
11294
- number(),
11295
- boolean()
11296
- ])),
11297
- fallback: union([
11298
- string(),
11299
- number(),
11300
- boolean()
11301
- ]).optional()
11302
- }),
11303
- object({
11304
- kind: literal("linear"),
11305
- scale: number(),
11306
- offset: number(),
11307
- clamp: tuple([number(), number()]).readonly().optional()
11308
- })
11309
- ]).optional()
11310
- });
11311
- /** Cap-wire shape of a per-cap display refinement — mirrors
11312
- * `DeviceCapDisplayOverride` in `device-management.ts`. */
11313
- var DeviceCapDisplayOverrideSchema = object({
11314
- unit: string().min(1).optional(),
11315
- precision: number().int().min(0).max(10).optional()
11316
- });
11317
- /** Cap-wire shape of an operator-authored per-device display override —
11318
- * mirrors `DeviceDisplayOverride` in `device-management.ts`. `precision`
11319
- * bounds mirror `numeric-sensor.cap.ts` (`int 0-10`). */
11320
- var DeviceDisplayOverrideSchema = object({
11321
- icon: string().min(1).optional(),
11322
- label: string().min(1).optional(),
11323
- unit: string().min(1).optional(),
11324
- precision: number().int().min(0).max(10).optional(),
11325
- hidden: boolean().optional(),
11326
- perCap: record(string(), DeviceCapDisplayOverrideSchema).optional()
11327
- });
11328
- /** Cap-wire shape of a per-role display default — mirrors `RoleDisplayDefault`
11329
- * in `device-management.ts`. Keyed by `DeviceRole` string (role strings cross
11330
- * the wire as plain strings everywhere else — cf. `DeviceInfoSchema.role`). */
11331
- var RoleDisplayDefaultSchema = object({
11332
- unit: string().min(1).optional(),
11333
- precision: number().int().min(0).max(10).optional(),
11334
- icon: string().min(1).optional()
11335
- });
11336
- /**
11337
- * Serializable projection of a live IDevice.
11338
- * Returned by listAll, getDevice, getChildren.
11339
- * Live methods (getStreamSources, getConfigSchema) are separate calls.
10799
+ * Serializable projection of a live IDevice.
10800
+ * Returned by listAll, getDevice, getChildren.
10801
+ * Live methods (getStreamSources, getConfigSchema) are separate calls.
11340
10802
  */
11341
10803
  var DeviceInfoSchema = object({
11342
10804
  /** Progressive, system-wide unique number. Allocated synchronously by
@@ -11387,8 +10849,6 @@ var DeviceInfoSchema = object({
11387
10849
  * named accordion sections (with optional intra-section order). See
11388
10850
  * `DeviceMeta.childLayout`. Absent ⇒ no layout declared. */
11389
10851
  childLayout: array(ChildLayoutEntrySchema).readonly().optional(),
11390
- /** Operator-authored cross-device field wirings. See `DeviceMeta.deviceLinks`. */
11391
- deviceLinks: array(DeviceLinkSchema).readonly().optional(),
11392
10852
  /** Operator-authored per-device display override. See `DeviceMeta.display`. */
11393
10853
  display: DeviceDisplayOverrideSchema.optional()
11394
10854
  });
@@ -11397,7 +10857,7 @@ var ConfigEntrySchema = object({
11397
10857
  value: unknown(),
11398
10858
  description: string().optional()
11399
10859
  });
11400
- var DeviceLinkModeSchema = _enum(["auto", "manual"]);
10860
+ var LinkedDevicesModeSchema = _enum(["auto", "manual"]);
11401
10861
  /** One resolved linked device — the compact projection consumers need. */
11402
10862
  var LinkedDeviceSchema = object({
11403
10863
  deviceId: number(),
@@ -11460,8 +10920,6 @@ var DeviceMetaSchema = object({
11460
10920
  * accordion sections (with optional intra-section order). See
11461
10921
  * `DeviceMeta.childLayout`. Absent ⇒ no layout declared. */
11462
10922
  childLayout: array(ChildLayoutEntrySchema).readonly().optional(),
11463
- /** Operator-authored cross-device field wirings. See `DeviceMeta.deviceLinks`. */
11464
- deviceLinks: array(DeviceLinkSchema).readonly().optional(),
11465
10923
  /** Semantic role string (`DeviceRole`) — propagated from the spawn pre-seed.
11466
10924
  * Optional: only present for accessory children that carry a known role. */
11467
10925
  role: string().nullable().optional(),
@@ -11554,12 +11012,6 @@ method(object({
11554
11012
  }), _void(), {
11555
11013
  kind: "mutation",
11556
11014
  auth: "admin"
11557
- }), method(object({
11558
- deviceId: number(),
11559
- deviceLinks: array(DeviceLinkSchema).readonly()
11560
- }), _void(), {
11561
- kind: "mutation",
11562
- auth: "admin"
11563
11015
  }), method(object({
11564
11016
  deviceId: number(),
11565
11017
  display: DeviceDisplayOverrideSchema.nullable()
@@ -11641,7 +11093,7 @@ method(object({
11641
11093
  * shipping 293 rows to find 12. */
11642
11094
  isCamera: boolean().optional()
11643
11095
  }), array(DeviceInfoSchema)), method(object({ deviceId: number() }), DeviceInfoSchema.nullable()), method(object({ parentDeviceId: number() }), array(DeviceInfoSchema)), method(object({ deviceId: number() }), object({
11644
- mode: DeviceLinkModeSchema,
11096
+ mode: LinkedDevicesModeSchema,
11645
11097
  devices: array(LinkedDeviceSchema)
11646
11098
  })), method(object({ deviceId: number() }), array(StreamSourceEntrySchema$1)), method(object({ deviceId: number() }), array(ConfigEntrySchema)), method(object({ deviceId: number() }), ConfigUISchemaOutput), method(object({
11647
11099
  deviceId: number(),
@@ -11674,11 +11126,7 @@ method(object({
11674
11126
  deviceId: number(),
11675
11127
  entries: array(object({
11676
11128
  capName: string(),
11677
- kind: _enum([
11678
- "native",
11679
- "wrapped",
11680
- "linked"
11681
- ]),
11129
+ kind: _enum(["native", "wrapped"]),
11682
11130
  providerAddonId: string(),
11683
11131
  providerNodeId: string(),
11684
11132
  nativeAddonId: string()
@@ -11687,11 +11135,7 @@ method(object({
11687
11135
  deviceId: number(),
11688
11136
  entries: array(object({
11689
11137
  capName: string(),
11690
- kind: _enum([
11691
- "native",
11692
- "wrapped",
11693
- "linked"
11694
- ]),
11138
+ kind: _enum(["native", "wrapped"]),
11695
11139
  providerAddonId: string(),
11696
11140
  providerNodeId: string(),
11697
11141
  nativeAddonId: string()
@@ -12494,7 +11938,7 @@ var MotionAnalysisResultSchema = object({
12494
11938
  frameHeight: number(),
12495
11939
  analysisMs: number()
12496
11940
  });
12497
- method(object({
11941
+ DeviceType.Camera, method(object({
12498
11942
  deviceId: number(),
12499
11943
  frame: FrameInputSchema.optional(),
12500
11944
  frameHandle: FrameHandleSchema.optional()
@@ -14711,6 +14155,18 @@ var OauthIntegrationDescriptorSchema = object({
14711
14155
  * redirect_uri that does not start with one of these. Required —
14712
14156
  * an empty list means the integration can never complete linking. */
14713
14157
  allowedRedirectPrefixes: array(string()).min(1),
14158
+ /** Paths accepted as a `redirect_uri` when the host is PRIVATE — loopback,
14159
+ * RFC1918, CGNAT (100.64/10, Tailscale), link-local, IPv6 ULA, or an
14160
+ * `.local` / `.internal` / `.ts.net` name. Exists for self-hosted clients
14161
+ * whose address the hub cannot know in advance (a Home Assistant at
14162
+ * `http://<lan-ip>:8123/auth/external/callback`). The PATH must match
14163
+ * exactly; a public host never satisfies this branch, so it is not a
14164
+ * wildcard prefix by another name. */
14165
+ allowedPrivateHostPaths: array(string()).optional(),
14166
+ /** When true this is a PUBLIC client (source is published, no secret can be
14167
+ * protected) and PKCE is mandatory: `/authorize` refuses without an S256
14168
+ * `code_challenge`, `/token` refuses without the matching `code_verifier`. */
14169
+ requiresPkce: boolean().optional(),
14714
14170
  /** Optional public origin (no trailing slash) that this integration's
14715
14171
  * issued codes/tokens should carry as the `hubUrl` claim — typically the
14716
14172
  * operator-selected external-access endpoint resolved by the addon. When
@@ -14861,7 +14317,7 @@ var TrackEnvelopeSchema = object({
14861
14317
  * `snapshots[]` references — megabytes across a page of tracks. `slim`
14862
14318
  * keeps every scalar the list surfaces actually render (ids, class(es),
14863
14319
  * label / audioLabels / importance enrichment, firstSeen/lastSeen, state,
14864
- * zonesVisited, bestEventId, envelope) and returns `positions` /
14320
+ * zonesVisited, bestEventId, envelope, hasFace) and returns `positions` /
14865
14321
  * `snapshots` as EMPTY arrays — detail views re-fetch the full row via
14866
14322
  * `getTrack`. Mirrors the event-store `projection` convention
14867
14323
  * (`getObjectEvents` et al.).
@@ -14903,6 +14359,30 @@ var TrackSourceSchema = _enum([
14903
14359
  "audio"
14904
14360
  ]);
14905
14361
  /**
14362
+ * Where a track sits in the RETRAIN lifecycle (D81).
14363
+ *
14364
+ * - `none` — never marked, or un-marked. Evictable.
14365
+ * - `staging` — the operator wants this track as training material and has not
14366
+ * finished with it. **This is the only state retention holds**: the track and
14367
+ * everything it owns (object events, crops, keyframes, CLIP vector) survive
14368
+ * the device's age window.
14369
+ * - `trained` — the retrain page has taken what it needed. The frames it chose
14370
+ * were COPIED into the retrain dataset at selection time, so the dataset no
14371
+ * longer depends on the track's media and the track becomes EVICTABLE again.
14372
+ * Terminal for the plain `markForTrain` toggle: returning it to `staging` is
14373
+ * a deliberate action of the retrain page, not a side effect of a checkbox.
14374
+ *
14375
+ * There is no `null`. The state is stored `TEXT NOT NULL DEFAULT 'none'` because
14376
+ * the store's filter language has only positive equality and `whereIn` — no
14377
+ * negation, no IS NULL — so a NULL would be unselectable by ANY predicate and
14378
+ * would make the entire pre-column history immortal in one deploy.
14379
+ */
14380
+ var RetrainStatusSchema = _enum([
14381
+ "none",
14382
+ "staging",
14383
+ "trained"
14384
+ ]);
14385
+ /**
14906
14386
  * Per-track OPERATOR flags — set by hand from the admin UI or the viewer, never
14907
14387
  * by the pipeline. Spread into `TrackSchema` and `KeyEventSchema` from one place
14908
14388
  * so the two surfaces cannot drift.
@@ -14912,18 +14392,31 @@ var TrackSourceSchema = _enum([
14912
14392
  * columns existed read as absent, and a consumer that needs a boolean should say
14913
14393
  * `flag === true`, not `flag !== false`.
14914
14394
  *
14915
- * What the flags DO is deliberately UNDEFINED at the time of writing: they are
14916
- * operator curation, and the behaviour they drive will be specified separately.
14917
- * In particular a `markForTrain` track is NOT pinned against retention — see
14918
- * `docs/decisions/adr-0059.md` for why that is a store-level change, not a flag.
14395
+ * `markForTrain` is the WIRE FACE of {@link RetrainStatusSchema}, not a column:
14396
+ * it is exactly `retrainStatus === 'staging'`, in both directions. Writing
14397
+ * `true` moves `none → staging`, writing `false` moves `staging none`, and a
14398
+ * `trained` track reports `false` while refusing both writes. The boolean is
14399
+ * kept because three surfaces drive a toggle off it; anything that needs to tell
14400
+ * "never marked" from "already trained" must read `retrainStatus`.
14401
+ *
14402
+ * `debug` does NOT pin; it is attention, not durability.
14919
14403
  */
14920
14404
  var TrackFlagFields = {
14921
- /** Operator marked this track as training material. */
14405
+ /** Operator marked this track as training material — i.e. `retrainStatus` is
14406
+ * `'staging'`. */
14922
14407
  markForTrain: boolean().optional(),
14923
14408
  /** Operator marked this track for diagnostic attention. */
14924
14409
  debug: boolean().optional()
14925
14410
  };
14926
14411
  /**
14412
+ * The lifecycle field itself, on the READ surfaces only (`Track`, `KeyEvent`).
14413
+ * Deliberately NOT part of {@link TrackFlagFields}: that group also builds the
14414
+ * write patch, and the status is not something the toggle sets — it is what the
14415
+ * toggle's boolean is derived from. Absent on an in-RAM track never touched;
14416
+ * always present on a persisted row (the column default materialises `'none'`).
14417
+ */
14418
+ var TrackRetrainFields = { retrainStatus: RetrainStatusSchema.optional() };
14419
+ /**
14927
14420
  * The write half: a PARTIAL patch. An omitted key is left untouched, so setting
14928
14421
  * one flag can never clear the other — the toggles are independent and are
14929
14422
  * driven from three surfaces that do not know about each other.
@@ -14937,13 +14430,92 @@ var TrackFlagsPatchSchema = object(TrackFlagFields);
14937
14430
  var TrackFlagsSchema = object({
14938
14431
  trackId: string(),
14939
14432
  markForTrain: boolean(),
14940
- debug: boolean()
14433
+ debug: boolean(),
14434
+ /** The lifecycle state the boolean was derived from. Required here (unlike on
14435
+ * a track row) because this shape is only ever produced by the write body,
14436
+ * which always knows it — and a surface that has just written needs to render
14437
+ * `trained` without a re-fetch. */
14438
+ retrainStatus: RetrainStatusSchema
14439
+ });
14440
+ union([literal(1), literal(2)]);
14441
+ /**
14442
+ * WHO decided a label, and when. Carried per tier so a value can be traced to
14443
+ * the step and model that produced it — which is what makes the write rule
14444
+ * arguable after the fact ("why is 592's label `dog` and not `Canis lupus`?")
14445
+ * and what lets a migrated, UNATTRIBUTED value be told apart from a real one.
14446
+ *
14447
+ * `stepId` is the pipeline step id (`animal-classifier`, `bird-classifier`,
14448
+ * `plate-ocr`, `face-embedding`, `object-detection`), or the sentinel
14449
+ * `migration:4g` for a value the 4g migration moved from the single-slot era —
14450
+ * that value has no provenance, and the write rule lets ANY properly-attributed
14451
+ * write of the same tier replace it regardless of score.
14452
+ */
14453
+ var LabelAttributionSchema = object({
14454
+ stepId: string(),
14455
+ modelId: string().optional(),
14456
+ decidedAt: number()
14457
+ });
14458
+ /**
14459
+ * The TIERED label model (roadmap 4g), spread into `TrackSchema` and
14460
+ * `ObjectEventSchema` from ONE place so the two surfaces cannot drift — a
14461
+ * track and its events always answer the same question the same way.
14462
+ *
14463
+ * Two scalar columns, not an array: every consumer wants "the coarse one" or
14464
+ * "the fine one", and an array made both a scan. `label` is tier 1, `subLabel`
14465
+ * is tier 2, and each carries its own score + attribution.
14466
+ *
14467
+ * **Reading it.** What a human should be shown is `subLabel ?? label` — the
14468
+ * finest thing known. Before 4g the single `label` column held the finest
14469
+ * value, so a consumer that has not been updated reads the tier-1 slot and
14470
+ * shows nothing on a species-only row; that is why the migration puts every
14471
+ * pre-4g value in tier 2 (it cannot regress a display that reads the fallback)
14472
+ * and why the read surfaces were changed in the same train.
14473
+ *
14474
+ * **Writing it.** The slots are independent, which is the whole point: a
14475
+ * tier-1 write (`bird`) can never overwrite a tier-2 value (`Turdus
14476
+ * migratorius`), so fineness cannot regress by construction. Within a tier the
14477
+ * higher score wins. One rule, one implementation — see
14478
+ * `pipeline/label-tier.ts` in addon-post-analysis.
14479
+ */
14480
+ var TieredLabelFields = {
14481
+ /** Tier 1 — the sub-class. See {@link LabelTierSchema}. */
14482
+ label: string().optional(),
14483
+ /** Confidence of the tier-1 value, as reported by the deciding step. */
14484
+ labelScore: number().optional(),
14485
+ /** Provenance of the tier-1 value. See {@link LabelAttributionSchema}. */
14486
+ labelMeta: LabelAttributionSchema.optional(),
14487
+ /** Tier 2 — the instance. See {@link LabelTierSchema}. */
14488
+ subLabel: string().optional(),
14489
+ /** Confidence of the tier-2 value, as reported by the deciding step. */
14490
+ subLabelScore: number().optional(),
14491
+ /** Provenance of the tier-2 value. See {@link LabelAttributionSchema}. */
14492
+ subLabelMeta: LabelAttributionSchema.optional()
14493
+ };
14494
+ /** Per-camera slice of a training-export estimate. */
14495
+ var TrainingExportDeviceTotalsSchema = object({
14496
+ deviceId: number(),
14497
+ tracks: number().int(),
14498
+ files: number().int(),
14499
+ bytes: number().int()
14500
+ });
14501
+ /**
14502
+ * What a training export WOULD contain. Computed from media index rows only —
14503
+ * no blob is read to produce this.
14504
+ */
14505
+ var TrainingExportSummarySchema = object({
14506
+ generatedAt: number(),
14507
+ trackCount: number().int(),
14508
+ fileCount: number().int(),
14509
+ byteCount: number().int(),
14510
+ /** More marked tracks exist than a single pass carries. */
14511
+ truncated: boolean(),
14512
+ devices: array(TrainingExportDeviceTotalsSchema).readonly()
14941
14513
  });
14942
14514
  var TrackSchema = object({
14943
14515
  trackId: string(),
14944
14516
  deviceId: number(),
14945
14517
  className: string(),
14946
- label: string().optional(),
14518
+ ...TieredLabelFields,
14947
14519
  producingDeviceName: string().optional(),
14948
14520
  /** Track provenance. Absent ⇒ `pipeline` (legacy rows). */
14949
14521
  source: TrackSourceSchema.optional(),
@@ -14982,7 +14554,26 @@ var TrackSchema = object({
14982
14554
  * Populated from the persisted envelope columns on historical reads;
14983
14555
  * absent on legacy rows, dims-less tracks and active (in-RAM) tracks. */
14984
14556
  envelope: TrackEnvelopeSchema.optional(),
14985
- ...TrackFlagFields
14557
+ /**
14558
+ * A face DETECTOR found a face on this track — nothing more. It says the
14559
+ * detail plane produced a `face` detail; it does NOT say the face was
14560
+ * embedded, matched, above `minFacePx`, or that the recognizer was even
14561
+ * enabled. Set once and never cleared.
14562
+ *
14563
+ * **This exists so "face present but not recognised" is expressible.** A
14564
+ * recognised identity lands in `subLabel` (attributed to the face chain via
14565
+ * `subLabelMeta.stepId`), so before this field a track with an unmatched face
14566
+ * and a track with no face at all were byte-identical on the wire and no
14567
+ * surface could tell them apart. The read is `hasFace === true && subLabel
14568
+ * === undefined`.
14569
+ *
14570
+ * **Absent ≠ false.** Every row written before the column existed omits it,
14571
+ * and so does every server that predates the field — a consumer must test
14572
+ * `=== true` and render nothing otherwise, never infer "no face".
14573
+ */
14574
+ hasFace: boolean().optional(),
14575
+ ...TrackFlagFields,
14576
+ ...TrackRetrainFields
14986
14577
  });
14987
14578
  var BaseEventFields = {
14988
14579
  id: string(),
@@ -15055,7 +14646,7 @@ var ObjectEventSchema = object({
15055
14646
  /** Omitted in slim projection. */
15056
14647
  trackId: string().optional(),
15057
14648
  className: string(),
15058
- label: string().optional(),
14649
+ ...TieredLabelFields,
15059
14650
  /** Omitted in slim projection. */
15060
14651
  confidence: number().optional(),
15061
14652
  /** Heavy JSON — omitted in slim projection. */
@@ -15136,6 +14727,173 @@ var MediaFileSchema = object({
15136
14727
  * stored blob and a `?variant=thumb` rendering without fetching either.
15137
14728
  */
15138
14729
  var MediaFileInfoSchema = MediaFileSchema.omit({ base64: true });
14730
+ /**
14731
+ * The MACRO tier of an annotation — a CLOSED set.
14732
+ *
14733
+ * This is what the exported detector predicts, so a typo here is a new class
14734
+ * with one example in it. `label` and `subLabel` are open strings by contrast:
14735
+ * the whole point of the page is teaching the model things it does not know
14736
+ * yet, and constraining that vocabulary would make it useless.
14737
+ *
14738
+ * A macro class is NEVER a label. The provider refuses a write whose `label` or
14739
+ * `subLabel` is one of these values, in any casing, because once `person`
14740
+ * exists in both tiers "every person box" stops being answerable without
14741
+ * knowing every string anyone ever typed — and the damage is retroactive.
14742
+ */
14743
+ var RetrainMacroClassSchema = _enum([
14744
+ "person",
14745
+ "vehicle",
14746
+ "animal",
14747
+ "package",
14748
+ "face",
14749
+ "plate"
14750
+ ]);
14751
+ /** A subject to learn, or a phantom to unlearn (taught by OMISSION). */
14752
+ var RetrainAnnotationKindSchema = _enum(["subject", "model_error"]);
14753
+ /** Did a human draw this box, or did the assist propose it? */
14754
+ var RetrainAnnotationSourceSchema = _enum(["operator", "assist"]);
14755
+ /** Normalised `[0,1]` rectangle against the FULL frame — the canonical form. */
14756
+ var RetrainBboxSchema = object({
14757
+ x: number(),
14758
+ y: number(),
14759
+ w: number(),
14760
+ h: number()
14761
+ });
14762
+ /**
14763
+ * One annotated subject.
14764
+ *
14765
+ * `bbox` is normalised against the full frame, ALWAYS. The per-model shapes
14766
+ * (letterboxed root / zone-cropped package / subject-cropped classifier) are
14767
+ * derived from it at export and never stored — storing them is how one feature
14768
+ * space ends up holding two crops of the same subject (D52).
14769
+ */
14770
+ var RetrainAnnotationSchema = object({
14771
+ id: string(),
14772
+ trackId: string(),
14773
+ deviceId: number(),
14774
+ /** The COPY in retrain storage — never the source track's media key. */
14775
+ mediaKey: string(),
14776
+ bbox: RetrainBboxSchema,
14777
+ macroClass: RetrainMacroClassSchema,
14778
+ label: string().optional(),
14779
+ subLabel: string().optional(),
14780
+ kind: RetrainAnnotationKindSchema,
14781
+ source: RetrainAnnotationSourceSchema,
14782
+ /** Which model proposed this box — or, on a `model_error`, drew the phantom. */
14783
+ assistModelId: string().optional(),
14784
+ assistScore: number().optional(),
14785
+ exportedInBatch: string().optional(),
14786
+ createdAt: number()
14787
+ });
14788
+ /** The write form — the server owns `id`, `createdAt` and the frame binding. */
14789
+ var RetrainAnnotationDraftSchema = RetrainAnnotationSchema.omit({
14790
+ id: true,
14791
+ trackId: true,
14792
+ deviceId: true,
14793
+ mediaKey: true,
14794
+ createdAt: true,
14795
+ exportedInBatch: true
14796
+ });
14797
+ /** A track sitting in `staging`, with everything the worklist needs to rank it. */
14798
+ var RetrainTrackSchema = object({
14799
+ trackId: string(),
14800
+ deviceId: number(),
14801
+ className: string(),
14802
+ label: string().optional(),
14803
+ firstSeen: number(),
14804
+ lastSeen: number(),
14805
+ /** How many frames the dataset already holds from this track. */
14806
+ frameCount: number().int(),
14807
+ /** How many subjects have been annotated on those frames. `0` with
14808
+ * `frameCount: 0` is exactly "staging, still to work". */
14809
+ annotationCount: number().int()
14810
+ });
14811
+ /** A frame the picker may offer — an index row, no blob was read to produce it. */
14812
+ var RetrainFrameCandidateSchema = object({
14813
+ mediaKey: string(),
14814
+ kind: MediaFileKindEnum,
14815
+ timestamp: number(),
14816
+ sizeBytes: number().int(),
14817
+ /** A copy of this original already exists — selecting it is free and cannot
14818
+ * fail, whatever became of the original. */
14819
+ copied: boolean()
14820
+ });
14821
+ /** A frame the dataset OWNS: bytes copied at selection time. */
14822
+ var RetrainFrameSchema = object({
14823
+ frameId: string(),
14824
+ deviceId: number(),
14825
+ trackId: string(),
14826
+ /** Provenance only. It may already point at nothing — that is expected. */
14827
+ sourceMediaKey: string(),
14828
+ sourceKind: MediaFileKindEnum,
14829
+ sizeBytes: number().int(),
14830
+ width: number().int(),
14831
+ height: number().int(),
14832
+ copiedAt: number()
14833
+ });
14834
+ /** Why a copy-on-select could not be honoured — named, never a silent skip. */
14835
+ var RetrainCopyRefusalSchema = _enum([
14836
+ "source-missing",
14837
+ "unreadable-image",
14838
+ "write-failed"
14839
+ ]);
14840
+ var RetrainFrameSelectionSchema = object({
14841
+ copied: array(RetrainFrameSchema).readonly(),
14842
+ refused: array(object({
14843
+ sourceMediaKey: string(),
14844
+ reason: RetrainCopyRefusalSchema
14845
+ })).readonly()
14846
+ });
14847
+ var RetrainFrameListSchema = object({
14848
+ candidates: array(RetrainFrameCandidateSchema).readonly(),
14849
+ copies: array(RetrainFrameSchema).readonly(),
14850
+ /** What the page pre-selects — the native key frame when one survives. */
14851
+ autoPickMediaKey: string().optional()
14852
+ });
14853
+ /** What the operator asked the assist to look for. */
14854
+ var RetrainAssistSubjectSchema = discriminatedUnion("kind", [object({
14855
+ kind: literal("package"),
14856
+ zone: RetrainBboxSchema.optional()
14857
+ }), object({
14858
+ kind: literal("objects"),
14859
+ modelId: string(),
14860
+ minScore: number().optional()
14861
+ })]);
14862
+ /**
14863
+ * The assist's answer — a discriminated union, because "the model saw nothing"
14864
+ * and "this node cannot run that model" lead to different next moves and a
14865
+ * nullable result cannot tell them apart.
14866
+ */
14867
+ var RetrainAssistResultSchema = discriminatedUnion("kind", [object({
14868
+ kind: literal("proposed"),
14869
+ modelId: string(),
14870
+ stepId: string(),
14871
+ minScore: number(),
14872
+ /** Drafts, ready to edit. `source: 'assist'` until the operator touches one. */
14873
+ proposals: array(RetrainAnnotationDraftSchema).readonly(),
14874
+ /** Returned by the runner but removed by the threshold. */
14875
+ belowThreshold: number().int()
14876
+ }), object({
14877
+ kind: literal("refused"),
14878
+ /** `no-zone` is ours; the rest are the runner's own refusal vocabulary. */
14879
+ reason: string(),
14880
+ detail: string().optional()
14881
+ })]);
14882
+ /** The outcome of a lifecycle move owned by the retrain page. */
14883
+ var RetrainTransitionResultSchema = object({
14884
+ trackId: string(),
14885
+ /** Where the track ended up, whatever happened. */
14886
+ retrainStatus: RetrainStatusSchema,
14887
+ /** `false` ⇒ the move was refused or was a no-op; `reason` says which. */
14888
+ changed: boolean(),
14889
+ reason: _enum([
14890
+ "unknown-track",
14891
+ "no-frames-copied",
14892
+ "not-staging",
14893
+ "not-trained",
14894
+ "unchanged"
14895
+ ]).optional()
14896
+ });
15139
14897
  var DEFAULT_EVENT_QUERY_LIMIT = 1e3;
15140
14898
  var MAX_EVENT_QUERY_LIMIT = 5e3;
15141
14899
  var DeviceEventQueryInput = object({
@@ -15190,13 +14948,14 @@ var KeyEventSchema = object({
15190
14948
  /** Track start time (firstSeen). */
15191
14949
  timestamp: number(),
15192
14950
  className: string(),
15193
- label: string().optional(),
14951
+ ...TieredLabelFields,
15194
14952
  importance: number(),
15195
14953
  /** Highest-confidence ObjectEvent id for the track (empty when none). */
15196
14954
  bestEventId: string(),
15197
14955
  /** Track lifetime in ms (lastSeen - firstSeen). */
15198
14956
  windowMs: number().optional(),
15199
- ...TrackFlagFields
14957
+ ...TrackFlagFields,
14958
+ ...TrackRetrainFields
15200
14959
  });
15201
14960
  object({
15202
14961
  trackId: string(),
@@ -15457,6 +15216,85 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
15457
15216
  }), method(OpsLogQueryInputSchema, array(OpsLogEntrySchema).readonly(), {
15458
15217
  kind: "query",
15459
15218
  auth: "admin"
15219
+ }), method(object({ deviceIds: array(number()).optional() }), TrainingExportSummarySchema, {
15220
+ kind: "query",
15221
+ auth: "admin"
15222
+ }), method(object({ deviceIds: array(number()).optional() }), object({ url: string() }), {
15223
+ kind: "query",
15224
+ auth: "admin"
15225
+ }), method(object({
15226
+ /** Empty ⇒ every camera that has staging tracks. A LIST, not a single
15227
+ * `deviceId`, deliberately: `deviceId` would make this device-bound and
15228
+ * route it at one camera's owner, and "every camera" would stop being
15229
+ * expressible at all. */
15230
+ deviceIds: array(number()).optional(),
15231
+ limit: number().int().min(1).max(500).optional()
15232
+ }), array(RetrainTrackSchema).readonly(), {
15233
+ kind: "query",
15234
+ auth: "admin"
15235
+ }), method(object({ trackId: string() }), RetrainFrameListSchema, {
15236
+ kind: "query",
15237
+ auth: "admin"
15238
+ }), method(object({
15239
+ deviceId: number(),
15240
+ trackId: string(),
15241
+ mediaKeys: array(string()).min(1)
15242
+ }), RetrainFrameSelectionSchema, {
15243
+ kind: "mutation",
15244
+ auth: "admin"
15245
+ }), method(object({
15246
+ deviceId: number(),
15247
+ trackId: string(),
15248
+ frameId: string()
15249
+ }), object({
15250
+ removed: boolean(),
15251
+ removedAnnotations: number().int()
15252
+ }), {
15253
+ kind: "mutation",
15254
+ auth: "admin"
15255
+ }), method(object({ frameId: string() }), object({
15256
+ base64: string(),
15257
+ width: number().int(),
15258
+ height: number().int()
15259
+ }), {
15260
+ kind: "query",
15261
+ auth: "admin"
15262
+ }), method(object({
15263
+ deviceId: number(),
15264
+ trackId: string(),
15265
+ frameId: string(),
15266
+ subject: RetrainAssistSubjectSchema,
15267
+ /** Which node runs it. Absent ⇒ wherever an unowned call lands. */
15268
+ nodeId: string().optional()
15269
+ }), RetrainAssistResultSchema, {
15270
+ kind: "mutation",
15271
+ auth: "admin"
15272
+ }), method(object({ trackId: string() }), array(RetrainAnnotationSchema).readonly(), {
15273
+ kind: "query",
15274
+ auth: "admin"
15275
+ }), method(object({
15276
+ deviceId: number(),
15277
+ trackId: string(),
15278
+ frameId: string(),
15279
+ annotations: array(RetrainAnnotationDraftSchema)
15280
+ }), array(RetrainAnnotationSchema).readonly(), {
15281
+ kind: "mutation",
15282
+ auth: "admin"
15283
+ }), method(object({
15284
+ deviceId: number(),
15285
+ trackId: string()
15286
+ }), RetrainTransitionResultSchema, {
15287
+ kind: "mutation",
15288
+ auth: "admin"
15289
+ }), method(object({
15290
+ deviceId: number(),
15291
+ trackId: string()
15292
+ }), RetrainTransitionResultSchema, {
15293
+ kind: "mutation",
15294
+ auth: "admin"
15295
+ }), method(object({ deviceIds: array(number()).optional() }), object({ url: string() }), {
15296
+ kind: "query",
15297
+ auth: "admin"
15460
15298
  }), method(object({
15461
15299
  eventId: string(),
15462
15300
  kind: MediaFileKindEnum.optional()
@@ -16069,6 +15907,22 @@ var DetailResultSchema = object({
16069
15907
  bbox: NativeCropBboxSchema.optional(),
16070
15908
  embedding: string().optional(),
16071
15909
  label: string().optional(),
15910
+ /**
15911
+ * The tier `label` occupies, copied VERBATIM from the producing step's
15912
+ * `StepDefinition.labelTier` (roadmap 4g). Present only when `label` is.
15913
+ *
15914
+ * It rides the wire rather than being resolved by the consumer because the
15915
+ * declaration lives with the step definition, which only the executing node
15916
+ * has: post-analysis holds no step registry, and re-deriving the tier from
15917
+ * `className` there would be exactly the inference this model exists to
15918
+ * forbid. A `label` that arrives WITHOUT this field is refused by the write
15919
+ * rule and logged (`label tier undeclared`) — an older runner therefore
15920
+ * stops enriching rather than guessing, which is why addon-pipeline is
15921
+ * deployed BEFORE addon-post-analysis.
15922
+ */
15923
+ labelTier: union([literal(1), literal(2)]).optional(),
15924
+ /** Model that produced `label` — carried into the tier's attribution. */
15925
+ labelModelId: string().optional(),
16072
15926
  alignedCropJpeg: string().optional(),
16073
15927
  /** Face short side (px) measured on the NATIVE crop surface. The `bbox`
16074
15928
  * above is detection-frame px (≈6× smaller on a 4K camera) — min-face-size
@@ -16776,6 +16630,23 @@ var CameraRecordingStatusSchema = object({
16776
16630
  active: boolean(),
16777
16631
  storageBytes: number()
16778
16632
  });
16633
+ /** One stage of the fan-out that could NOT be read, and how long it cost. */
16634
+ var CameraStatusDegradationSchema = object({
16635
+ stage: _enum([
16636
+ "source",
16637
+ "broker",
16638
+ "detection",
16639
+ "recording",
16640
+ "switches"
16641
+ ]),
16642
+ reason: _enum([
16643
+ "timeout",
16644
+ "error",
16645
+ "partial"
16646
+ ]),
16647
+ /** Wall-clock ms spent on the stage before it was abandoned. */
16648
+ elapsedMs: number()
16649
+ });
16779
16650
  /**
16780
16651
  * Aggregated per-camera pipeline status — server-composed, single call.
16781
16652
  *
@@ -16806,9 +16677,28 @@ var CameraStatusSchema = object({
16806
16677
  * differently — a quiet camera that looks identical to a dead one is the
16807
16678
  * silence-reads-as-never-happened trap this repo keeps paying for.
16808
16679
  *
16809
- * Empty when nothing is off. Never contains a switch no provider offers.
16680
+ * Empty when nothing is off, and never contains a switch no provider offers
16681
+ * — but an empty list is only a POSITIVE claim when `degraded` does not name
16682
+ * `'switches'`. When it does, the switch set could not be read and nothing
16683
+ * here may be rendered as "the operator turned nothing off": that is the
16684
+ * D62 failure (a camera we could not read painted as broken) in the very
16685
+ * field that exists to prevent it.
16810
16686
  */
16811
16687
  switchedOff: array(CameraSwitchIdSchema).readonly(),
16688
+ /**
16689
+ * Stages of the bounded fan-out that were CUT SHORT — a timeout or a
16690
+ * rejection — and whose block is therefore `null` because we could not
16691
+ * READ it, not because there is nothing there.
16692
+ *
16693
+ * Without this, three different facts arrive as the same `null`: "the stage
16694
+ * timed out", "the stage failed", and "this camera legitimately has no
16695
+ * decoder / no recording". Every surface that draws a conclusion from a null
16696
+ * block (or from an empty `switchedOff`) must consult this first; a stage
16697
+ * named here supports no conclusion at all, only "unknown".
16698
+ *
16699
+ * Empty on a clean read — the overwhelmingly common case.
16700
+ */
16701
+ degraded: array(CameraStatusDegradationSchema).readonly(),
16812
16702
  /** Unix timestamp (ms) when this snapshot was composed server-side. */
16813
16703
  fetchedAt: number()
16814
16704
  });
@@ -17416,6 +17306,10 @@ var SsoBridgeClaimsSchema = object({
17416
17306
  integrationId: string().optional(),
17417
17307
  /** JWT ID — unique per issued code; consumed-set enforces single-use. */
17418
17308
  jti: string().optional(),
17309
+ /** PKCE S256 challenge — set only on `oauth-code` tokens issued to a public
17310
+ * client. Its PRESENCE is what makes the verifier mandatory at exchange,
17311
+ * so the requirement travels with the code and not with mutable config. */
17312
+ codeChallenge: string().optional(),
17419
17313
  /** OAuth session registry id — set on `oauth-access`/`oauth-refresh`
17420
17314
  * tokens so the verify path can check the session is not revoked. */
17421
17315
  sessionId: string().optional()
@@ -17968,7 +17862,7 @@ var ClipPlaybackSchema = object({
17968
17862
  playbackEndpoints: array(string()).optional(),
17969
17863
  token: string().optional()
17970
17864
  });
17971
- method(object({
17865
+ DeviceType.Camera, method(object({
17972
17866
  deviceId: number(),
17973
17867
  since: number(),
17974
17868
  until: number(),
@@ -20037,7 +19931,29 @@ var FaceInfoSchema = object({
20037
19931
  recognizedIdentityId: string().optional(),
20038
19932
  identityName: string().optional(),
20039
19933
  assigned: boolean(),
19934
+ /**
19935
+ * The crop, inline, base64.
19936
+ *
19937
+ * **Prefer {@link cropUrl}.** At the 500 rows the Faces view asks for this
19938
+ * field alone is ~2.87 MiB, re-sent in full on every operator assign and
19939
+ * every 30 s poll, base64-inflated over the msgpack socket and held in the
19940
+ * query heap. It stays for callers that have not migrated; `includeCrops:
19941
+ * false` turns it off once they have.
19942
+ */
20040
19943
  base64: string().optional(),
19944
+ /**
19945
+ * Same crop, as a data-plane URL for `<img src>` — the move the admin
19946
+ * snapshot surfaces made on 2026-08-08.
19947
+ *
19948
+ * Served by the `event-media` plane, which resolves a raw MediaStore key and
19949
+ * is `access: 'authenticated'`: a bare `<img>` carries the `camstack_session`
19950
+ * cookie, so no header plumbing is needed. The bytes then ride the browser's
19951
+ * HTTP cache with an ETag and `immutable`, instead of the WebSocket.
19952
+ *
19953
+ * Absent when the face has no stored crop, or when the addon has no data
19954
+ * plane — callers fall back to {@link base64}.
19955
+ */
19956
+ cropUrl: string().optional(),
20041
19957
  /** Design B: the face bbox (pixel space) on the key frame — lets a detail
20042
19958
  * view draw the box over the native `keyFrameMediaKey` frame. Absent on
20043
19959
  * legacy rows written before design B. */
@@ -20102,7 +20018,23 @@ method(_void(), array(IdentitySchema).readonly()), method(object({ name: string(
20102
20018
  auth: "admin"
20103
20019
  }), method(object({
20104
20020
  limit: number().int().positive().optional(),
20105
- filter: FaceFilterEnum.optional()
20021
+ filter: FaceFilterEnum.optional(),
20022
+ /**
20023
+ * Inline the base64 crop on every row. Default `true` — the existing
20024
+ * behaviour, kept so no caller breaks.
20025
+ *
20026
+ * Set `false` once the caller renders {@link FaceInfo.cropUrl}: that
20027
+ * drops ~2.87 MiB per 500-row page to a few KiB of metadata and lets
20028
+ * the browser cache the images.
20029
+ *
20030
+ * **This is an INPUT field, so it does not reach the addon until the
20031
+ * next train.** The hub router validates cap inputs against its own
20032
+ * compiled Zod, which strips a key it does not know — verified today
20033
+ * on the OUTPUT side, where an additive field DOES arrive immediately
20034
+ * (`Track.hasFace`). Until the train ships, sending `false` is
20035
+ * harmless and simply keeps the crops inline.
20036
+ */
20037
+ includeCrops: boolean().optional()
20106
20038
  }).optional(), array(FaceInfoSchema).readonly()), method(object({
20107
20039
  deviceId: number().int(),
20108
20040
  trackId: string()
@@ -22268,6 +22200,173 @@ setOverlay: method(object({
22268
22200
  }] }
22269
22201
  };
22270
22202
  /**
22203
+ * `osd-manager` — the ORCHESTRATOR over the device-scope `osd` cap.
22204
+ *
22205
+ * The `osd` cap is the firmware contract: it probes a camera's overlay
22206
+ * SLOTS and writes literal text into one. It has no idea WHERE that text
22207
+ * comes from, and it must not — a driver that grew a "show the temperature
22208
+ * here" feature would grow it once per vendor.
22209
+ *
22210
+ * This cap owns the other half: a per-(camera, slot) BINDING that says
22211
+ * which value feeds the slot, how it is formatted, and under which
22212
+ * conditions it is shown at all. One addon renders every binding on every
22213
+ * camera, so a new source costs zero driver code.
22214
+ *
22215
+ * Three deliberate choices, each with a rejected alternative:
22216
+ *
22217
+ * 1. A source is `(capName, valuePath)` over the kernel's device
22218
+ * runtime-state mirror — NOT a closed enum of source kinds. Every
22219
+ * cap-keyed slice a device publishes is bindable the day the cap
22220
+ * ships. The rejected alternative (one enum member per source, with
22221
+ * a resolver branch each) is what makes "add the humidity too" a
22222
+ * code change.
22223
+ * 2. The display gate reuses `NcConditionsSchema` verbatim — the
22224
+ * notification centre's condition vocabulary — rather than a parallel
22225
+ * model. An operator who has learned one condition editor has learned
22226
+ * both.
22227
+ * 3. Because the renderer's facts are device STATE and not a detection
22228
+ * record, only a SUBSET of that vocabulary can be answered here.
22229
+ * `setSlotBinding` REJECTS the rest at write time (see
22230
+ * `getConditionSupport`). It does not accept-then-fail-closed: a
22231
+ * condition that can never be true renders a permanently blank
22232
+ * overlay, and a blank overlay looks exactly like a broken camera.
22233
+ */
22234
+ /** Where a slot's value comes from. */
22235
+ var OsdSourceSchema = discriminatedUnion("kind", [
22236
+ object({
22237
+ kind: literal("static"),
22238
+ text: string().max(64)
22239
+ }),
22240
+ object({
22241
+ kind: literal("clock"),
22242
+ /** Token pattern: `YYYY MM DD HH mm ss`. Everything else is literal. */
22243
+ pattern: string().min(1).max(32).default("HH:mm"),
22244
+ /** IANA zone. Omitted = the server's zone. */
22245
+ timezone: string().min(1).max(64).optional()
22246
+ }),
22247
+ object({
22248
+ kind: literal("device-state"),
22249
+ deviceId: number().int().optional(),
22250
+ capName: string().min(1).max(64),
22251
+ /** Dot path inside the slice, e.g. `detected`, `value`, `mode`. */
22252
+ valuePath: string().min(1).max(64)
22253
+ })
22254
+ ]);
22255
+ var OsdSlotBindingSchema = object({
22256
+ /** Off = the manager stops driving this slot. It does NOT clear it. */
22257
+ enabled: boolean().default(true),
22258
+ source: OsdSourceSchema,
22259
+ /** `${value}` and `${unit}` are substituted; every occurrence. */
22260
+ template: string().max(96).default("${value}"),
22261
+ /** Truncate with an ellipsis past this length. Absent = no limit. */
22262
+ maxCharacters: number().int().min(4).max(64).optional(),
22263
+ /**
22264
+ * Decimal places for a numeric value. `0` yields an integer — the
22265
+ * documented workaround for firmwares that reject `.` in overlay text.
22266
+ */
22267
+ maxDecimals: number().int().min(0).max(4).default(1),
22268
+ /** Appended via `${unit}`. The state mirror does not carry units. */
22269
+ unitLabel: string().max(8).optional(),
22270
+ /** Raw value → display text, e.g. `{"true":"MOTION","false":""}`. */
22271
+ valueMap: record(string(), string()).optional(),
22272
+ /** Time windows in which the slot is shown. Absent = always. */
22273
+ schedule: NcScheduleSchema.optional(),
22274
+ /**
22275
+ * Display gate, in the notification centre's condition vocabulary.
22276
+ * Only the keys reported by `getConditionSupport` are accepted.
22277
+ */
22278
+ conditions: NcConditionsSchema.optional(),
22279
+ /** Rendered when the gate is closed or the value unreadable. Empty = hide. */
22280
+ fallbackText: string().max(64).default("")
22281
+ });
22282
+ /** One camera slot, as the operator sees it: firmware truth + our binding. */
22283
+ var OsdSlotViewSchema = object({
22284
+ slotId: string(),
22285
+ kind: OsdOverlayKindEnum,
22286
+ /** Firmware refuses text edits (a timestamp, the channel name). */
22287
+ readOnly: boolean(),
22288
+ cameraEnabled: boolean(),
22289
+ cameraText: string().optional(),
22290
+ binding: OsdSlotBindingSchema.nullable()
22291
+ });
22292
+ /**
22293
+ * What happened to one slot on one render pass. `unchanged` exists so the
22294
+ * operator can tell "we are driving this and the value is steady" from
22295
+ * "we never got there" — and so the loop can prove it is not rewriting
22296
+ * identical text to the camera every tick.
22297
+ */
22298
+ var OsdRenderOutcomeEnum = _enum([
22299
+ "written",
22300
+ "unchanged",
22301
+ "gated",
22302
+ "unreadable",
22303
+ "disabled",
22304
+ "unbound",
22305
+ "failed"
22306
+ ]);
22307
+ var OsdRenderResultSchema = object({
22308
+ slotId: string(),
22309
+ outcome: OsdRenderOutcomeEnum,
22310
+ /** The text the slot should carry. Empty = the slot is switched off. */
22311
+ text: string(),
22312
+ /** Why, whenever the outcome is not a plain write. Never silent. */
22313
+ reason: string().optional()
22314
+ });
22315
+ var OsdSourceValueTypeEnum = _enum([
22316
+ "number",
22317
+ "boolean",
22318
+ "string",
22319
+ "enum"
22320
+ ]);
22321
+ /**
22322
+ * One bindable value, derived from a cap's `runtimeState` schema — never
22323
+ * hand-listed. The editor renders from this, so a cap that ships a new
22324
+ * state field becomes bindable with no UI change.
22325
+ */
22326
+ var OsdSourceOptionSchema = object({
22327
+ deviceId: number().int(),
22328
+ deviceName: string(),
22329
+ capName: string(),
22330
+ valuePath: string(),
22331
+ label: string(),
22332
+ valueType: OsdSourceValueTypeEnum,
22333
+ /** Present for `enum`; the editor offers these as `valueMap` keys. */
22334
+ enumValues: array(string()).readonly().optional()
22335
+ });
22336
+ method(object({ deviceId: number().int() }), object({
22337
+ supported: boolean(),
22338
+ slots: array(OsdSlotViewSchema)
22339
+ }), { auth: "admin" }), method(object({ deviceId: number().int() }), object({ sources: array(OsdSourceOptionSchema) }), { auth: "admin" }), method(object({}), object({
22340
+ supported: array(string()),
22341
+ catalog: array(NcConditionDescriptorSchema)
22342
+ }), { auth: "admin" }), method(object({
22343
+ deviceId: number().int(),
22344
+ slotId: string().min(1),
22345
+ binding: OsdSlotBindingSchema
22346
+ }), object({
22347
+ slot: OsdSlotViewSchema,
22348
+ render: OsdRenderResultSchema
22349
+ }), {
22350
+ kind: "mutation",
22351
+ auth: "admin"
22352
+ }), method(object({
22353
+ deviceId: number().int(),
22354
+ slotId: string().min(1)
22355
+ }), object({ success: literal(true) }), {
22356
+ kind: "mutation",
22357
+ auth: "admin"
22358
+ }), method(object({
22359
+ deviceId: number().int(),
22360
+ slotId: string().min(1),
22361
+ binding: OsdSlotBindingSchema.optional()
22362
+ }), OsdRenderResultSchema, {
22363
+ kind: "mutation",
22364
+ auth: "admin"
22365
+ }), method(object({ deviceId: number().int() }), object({ results: array(OsdRenderResultSchema) }), {
22366
+ kind: "mutation",
22367
+ auth: "admin"
22368
+ });
22369
+ /**
22271
22370
  * Feeder connectivity / power status — mirrors the HA petkit device-status
22272
22371
  * enum: `normal` (online, mains), `offline` (not reaching PetKit cloud),
22273
22372
  * `on_batteries` (running on battery backup). `null` until first reported.
@@ -24632,13 +24731,18 @@ method(_void(), array(UserSummarySchema), { auth: "admin" }), method(CreateUserI
24632
24731
  username: string(),
24633
24732
  scopes: array(TokenScopeSchema),
24634
24733
  redirectUri: string(),
24635
- hubUrl: string()
24734
+ hubUrl: string(),
24735
+ /** PKCE (RFC 7636) S256 challenge. Baked into the signed code; a code
24736
+ * that carries one can ONLY be exchanged with the matching verifier. */
24737
+ codeChallenge: string().optional()
24636
24738
  }), object({ code: string() }), {
24637
24739
  kind: "mutation",
24638
24740
  access: "create"
24639
24741
  }), method(object({
24640
24742
  code: string(),
24641
- redirectUri: string()
24743
+ redirectUri: string(),
24744
+ /** PKCE verifier. REQUIRED when the code carries a challenge. */
24745
+ codeVerifier: string().optional()
24642
24746
  }), object({
24643
24747
  accessToken: string(),
24644
24748
  refreshToken: string(),
@@ -25979,426 +26083,1096 @@ var BaseDevice = class {
25979
26083
  * DO probe override this and write their own `feature-probe` slice (including
25980
26084
  * `lastProbedAt`) once their probe actually succeeds.
25981
26085
  */
25982
- async onProbe() {
25983
- const base = this.runtimeState.getCapState("feature-probe") ?? {
25984
- flags: {},
25985
- deviceType: null,
25986
- model: null,
25987
- channelCount: null,
25988
- lastProbedAt: 0,
25989
- lastFetchedAt: 0
26086
+ async onProbe() {
26087
+ const base = this.runtimeState.getCapState("feature-probe") ?? {
26088
+ flags: {},
26089
+ deviceType: null,
26090
+ model: null,
26091
+ channelCount: null,
26092
+ lastProbedAt: 0,
26093
+ lastFetchedAt: 0
26094
+ };
26095
+ this.runtimeState.setCapState("feature-probe", {
26096
+ ...base,
26097
+ lastProbedAt: Date.now()
26098
+ });
26099
+ }
26100
+ /**
26101
+ * Phase 5 — fired after the device + its accessories are registered.
26102
+ * Drivers publish streams to the broker, kick off background tasks,
26103
+ * or subscribe to lib events that need a fully-registered device id.
26104
+ *
26105
+ * Default: no-op.
26106
+ *
26107
+ * RENAMED FROM `onCreated` (which still exists for back-compat in this
26108
+ * pass). The new name reflects the post-probe, post-accessory contract.
26109
+ */
26110
+ async onActivate() {}
26111
+ /**
26112
+ * Re-run the probe + reconcile accessories + refresh features meta.
26113
+ * Drivers call this when device-side state changes (battery cam wakes,
26114
+ * firmware update, manual operator trigger).
26115
+ *
26116
+ * The kernel injects `_kernelReprobe` on registration so this method
26117
+ * delegates to the same orchestrator that runs the boot-time phase
26118
+ * 3 + 4 sequence. Drivers should NOT override this — they override
26119
+ * `onProbe()` instead.
26120
+ */
26121
+ async reprobe() {
26122
+ if (this._kernelReprobe) await this._kernelReprobe();
26123
+ else await this.onProbe();
26124
+ }
26125
+ /**
26126
+ * Kernel-injected callback that runs the full post-probe orchestration
26127
+ * (onProbe → registerDevice meta refresh → accessory reconciliation).
26128
+ * Set by `device-cap-proxy.register()`. Drivers should not touch this
26129
+ * directly — call `reprobe()` instead.
26130
+ */
26131
+ _kernelReprobe;
26132
+ /**
26133
+ * Declare accessory child devices the kernel should auto-spawn
26134
+ * after `onProbe()` resolves. Each spec fully describes one child
26135
+ * — stableId suffix (deterministic per kind for restore-safety),
26136
+ * meta (type / name / location), config (initial blob the child
26137
+ * self-hydrates), and a factory that constructs the concrete
26138
+ * class with whatever closure-captured refs it needs (typically
26139
+ * `this` for the parent reference).
26140
+ *
26141
+ * The kernel handles the rest: allocateDeviceId, persistInitialConfig
26142
+ * (skipped on restore when the row already exists),
26143
+ * persistInitialMeta, createContext, factory invocation, register,
26144
+ * and recursive lifecycle (probe + accessories + activate).
26145
+ *
26146
+ * Implementations should derive children from
26147
+ * `this.runtimeState.getCapState('feature-probe')` (post-probe truth).
26148
+ * Drivers can use the `getProbeFlags()` helper to read the flag bag
26149
+ * with a typed cast.
26150
+ *
26151
+ * Default: no children.
26152
+ */
26153
+ getAccessoryChildren() {
26154
+ return [];
26155
+ }
26156
+ /**
26157
+ * Read the current feature-probe flag bag with a typed cast. Helper
26158
+ * for `getAccessoryChildren()` and `features` getters that derive
26159
+ * outputs from the probe results.
26160
+ */
26161
+ getProbeFlags() {
26162
+ return this.runtimeState.getCapState("feature-probe")?.flags ?? {};
26163
+ }
26164
+ /**
26165
+ * Returns true once `onProbe` has completed at least once
26166
+ * (`lastProbedAt > 0`). Drivers gate `getAccessoryChildren()` on this
26167
+ * to avoid spawning stale accessories on a fresh device whose probe
26168
+ * hasn't landed yet.
26169
+ */
26170
+ hasProbed() {
26171
+ return (this.runtimeState.getCapState("feature-probe")?.lastProbedAt ?? 0) > 0;
26172
+ }
26173
+ };
26174
+ /**
26175
+ * Convert an IDevice to the flat DeviceSummary shape expected by the
26176
+ * device-provider cap router. Shared across all providers.
26177
+ */
26178
+ function toDeviceSummary(device, addonId) {
26179
+ const config = {};
26180
+ for (const entry of device.config.entries()) config[entry.key] = entry.value;
26181
+ return {
26182
+ id: device.id,
26183
+ stableId: device.stableId,
26184
+ addonId,
26185
+ type: String(device.type),
26186
+ name: device.name,
26187
+ parentDeviceId: device.parentDeviceId,
26188
+ online: device.online,
26189
+ features: [...device.features],
26190
+ config,
26191
+ sourceInfo: device.sourceInfo
26192
+ };
26193
+ }
26194
+ /**
26195
+ * Base class for device-provider addons (rtsp, onvif, frigate).
26196
+ *
26197
+ * Provides default implementations for the common device-provider cap
26198
+ * methods (`start`, `stop`, `getStatus`, `getDevices`, `supportsDiscovery`,
26199
+ * `supportsManualCreation`, `toDeviceSummary`). Subclasses override the
26200
+ * methods that differ per provider.
26201
+ *
26202
+ * @example
26203
+ * ```ts
26204
+ * class RtspProvider extends BaseDeviceProvider {
26205
+ * protected readonly addonId = 'provider-rtsp'
26206
+ * protected readonly providerName = 'RTSP'
26207
+ *
26208
+ * protected async onCreateDevice(input) { ... }
26209
+ * protected async onGetCreationSchema(type) { ... }
26210
+ * protected async onRestoreDevices(saved) { ... }
26211
+ * }
26212
+ * ```
26213
+ */
26214
+ var BaseDeviceProvider = class extends BaseAddon {
26215
+ async onInitialize() {
26216
+ this.ctx.logger.info(`${this.providerName} Provider initialized`);
26217
+ return [{
26218
+ capability: deviceProviderCapability,
26219
+ provider: this
26220
+ }];
26221
+ }
26222
+ async onShutdown() {
26223
+ const devices = await this.ctx.kernel.devices?.getAll() ?? [];
26224
+ for (const device of devices) try {
26225
+ await this.ctx.kernel.devices?.decommission(device.id);
26226
+ } catch (err) {
26227
+ this.ctx.logger.warn(`${this.providerName}: decommission failed`, {
26228
+ tags: {
26229
+ deviceId: device.id,
26230
+ stableId: device.stableId
26231
+ },
26232
+ meta: { error: err instanceof Error ? err.message : String(err) }
26233
+ });
26234
+ }
26235
+ this.ctx.logger.info(`${this.providerName} Provider shut down`, { meta: { decommissionedCount: devices.length } });
26236
+ }
26237
+ async start() {}
26238
+ async stop() {}
26239
+ async getStatus() {
26240
+ return {
26241
+ connected: true,
26242
+ deviceCount: (await this.ctx.kernel.devices?.getAll() ?? []).length
26243
+ };
26244
+ }
26245
+ async getDevices() {
26246
+ return (await this.ctx.kernel.devices?.getAll() ?? []).map((d) => ({
26247
+ id: d.stableId,
26248
+ name: d.name,
26249
+ type: String(d.type)
26250
+ }));
26251
+ }
26252
+ async supportsDiscovery() {
26253
+ return false;
26254
+ }
26255
+ async discoverDevices(_input) {
26256
+ return [];
26257
+ }
26258
+ /** Extra per-scan input form (e.g. a broadcast address for another subnet).
26259
+ * Null = no extra params. Override in providers that support scoped scans. */
26260
+ async getDiscoveryParamsSchema() {
26261
+ return null;
26262
+ }
26263
+ /**
26264
+ * The DeviceType this provider creates via manual add — derived from the
26265
+ * `deviceClasses` map (first registered type). `null` when manual creation is
26266
+ * unsupported. Lets the Add-Device dialog pick the right type per provider.
26267
+ */
26268
+ async getManualCreationType() {
26269
+ if (!await this.supportsManualCreation()) return { deviceType: null };
26270
+ return { deviceType: Object.values(DeviceType).find((t) => this.deviceClasses[t] !== void 0) ?? null };
26271
+ }
26272
+ async adoptDiscoveredDevice(_input) {
26273
+ throw new Error(`${this.providerName} provider does not support discovery-based adoption`);
26274
+ }
26275
+ async supportsManualCreation() {
26276
+ return true;
26277
+ }
26278
+ async getChildCreationSchema(input) {
26279
+ return this.onGetCreationSchema(input.type);
26280
+ }
26281
+ /**
26282
+ * Default kernel-orchestrated `createDevice` implementation. The
26283
+ * subclass's `onCreateDevice` returns a declarative
26284
+ * `CreateDeviceSpec` (`{meta, config}`) — this method handles
26285
+ * stableId generation, class lookup, kernel.devices.create
26286
+ * dispatch, and DeviceSummary mapping. Subclasses should NOT
26287
+ * override this method; override `onCreateDevice` and
26288
+ * `deviceClasses` instead.
26289
+ */
26290
+ async createDevice(input) {
26291
+ const spec = await this.onCreateDevice(input.type, input.config);
26292
+ const Class = this.deviceClasses[spec.meta.type];
26293
+ if (!Class) throw new Error(`${this.providerName} provider: no device class registered for type "${spec.meta.type}" — add it to the deviceClasses map`);
26294
+ const stableId = this.generateStableId(spec.meta.type, spec.config);
26295
+ const device = await this.ctx.kernel.devices.create(stableId, Class, spec.config, null, spec.meta);
26296
+ if (spec.onAfterCreate) try {
26297
+ await spec.onAfterCreate(device);
26298
+ } catch (err) {
26299
+ this.ctx.logger.warn("createDevice: onAfterCreate hook threw — device is already registered", {
26300
+ tags: {
26301
+ deviceId: device.id,
26302
+ stableId
26303
+ },
26304
+ meta: { error: err instanceof Error ? err.message : String(err) }
26305
+ });
26306
+ }
26307
+ return this.toSummary(device);
26308
+ }
26309
+ /**
26310
+ * Generate a stableId for a newly-created device. Default uses the
26311
+ * `${addonId}-${Date.now()}` pattern as a unique-but-opaque
26312
+ * fallback; any provider that has access to durable hardware
26313
+ * identity (UID, MAC, serial) should override and derive from it
26314
+ * so re-adding the same physical device reuses its persisted row.
26315
+ *
26316
+ * `config` is the parsed CreateDeviceSpec.config the subclass
26317
+ * returned from `onCreateDevice` — the override has access to
26318
+ * every operator-supplied + autodetect-resolved field. Optional
26319
+ * for back-compat: existing overrides that take only `type`
26320
+ * keep working unchanged.
26321
+ */
26322
+ generateStableId(_type, _config) {
26323
+ return `${this.addonId}-${Date.now()}`;
26324
+ }
26325
+ async testCreationField(_input) {
26326
+ return {
26327
+ status: "ok",
26328
+ labels: ["probe not implemented"]
25990
26329
  };
25991
- this.runtimeState.setCapState("feature-probe", {
25992
- ...base,
25993
- lastProbedAt: Date.now()
25994
- });
26330
+ }
26331
+ async restoreDevices(savedDevices) {
26332
+ await this.onRestoreDevices(savedDevices);
26333
+ if (savedDevices.length > 0) this.ctx.logger.info(`Restored ${savedDevices.length} ${this.providerName} device(s)`);
25995
26334
  }
25996
26335
  /**
25997
- * Phase 5 fired after the device + its accessories are registered.
25998
- * Drivers publish streams to the broker, kick off background tasks,
25999
- * or subscribe to lib events that need a fully-registered device id.
26336
+ * Restore devices from persisted state. Two-pass:
26000
26337
  *
26001
- * Default: no-op.
26338
+ * 1. **Top-level pass** — invokes `kernel.devices.create()` for every
26339
+ * `parentDeviceId === null` row using the `deviceClasses` map.
26340
+ * The kernel's register flow handles `getAccessoryChildren()` for
26341
+ * each parent (siren / floodlight / PIR / etc).
26002
26342
  *
26003
- * RENAMED FROM `onCreated` (which still exists for back-compat in this
26004
- * pass). The new name reflects the post-probe, post-accessory contract.
26005
- */
26006
- async onActivate() {}
26007
- /**
26008
- * Re-run the probe + reconcile accessories + refresh features meta.
26009
- * Drivers call this when device-side state changes (battery cam wakes,
26010
- * firmware update, manual operator trigger).
26343
+ * 2. **Hub-adopted children pass** for rows with
26344
+ * `parentDeviceId !== null` whose `type` IS in `deviceClasses`
26345
+ * (e.g. Reolink hub-adopted cameras under an NVR), spawn them
26346
+ * explicitly with the persisted `parentDeviceId`. These are
26347
+ * NOT accessory children — they're first-class adopted devices
26348
+ * that just happen to have a parent. Without this pass, every
26349
+ * server restart would lose hub-adopted cameras (their type is
26350
+ * in `deviceClasses` but parent's `getAccessoryChildren` doesn't
26351
+ * spawn them — that callback is only for purpose-built
26352
+ * accessory roles).
26011
26353
  *
26012
- * The kernel injects `_kernelReprobe` on registration so this method
26013
- * delegates to the same orchestrator that runs the boot-time phase
26014
- * 3 + 4 sequence. Drivers should NOT override this — they override
26015
- * `onProbe()` instead.
26354
+ * Rows whose `type` is NOT in `deviceClasses` are skipped those
26355
+ * are accessory children (siren/light/sensor) that the kernel's
26356
+ * accessory-spawn flow handles via the parent's
26357
+ * `getAccessoryChildren()`. Override only when the default doesn't
26358
+ * fit.
26016
26359
  */
26017
- async reprobe() {
26018
- if (this._kernelReprobe) await this._kernelReprobe();
26019
- else await this.onProbe();
26360
+ async onRestoreDevices(savedDevices) {
26361
+ const restored = /* @__PURE__ */ new Set();
26362
+ for (const saved of savedDevices) {
26363
+ if (saved.parentDeviceId !== null) continue;
26364
+ const Class = this.deviceClasses[saved.type];
26365
+ if (!Class) {
26366
+ this.ctx.logger.warn("No device class registered for restored type — skipping", {
26367
+ tags: { stableId: saved.stableId },
26368
+ meta: { type: saved.type }
26369
+ });
26370
+ continue;
26371
+ }
26372
+ try {
26373
+ await this.ctx.kernel.devices.create(saved.stableId, Class, {});
26374
+ restored.add(saved.id);
26375
+ } catch (err) {
26376
+ this.ctx.logger.warn("Failed to restore device", {
26377
+ tags: { stableId: saved.stableId },
26378
+ meta: {
26379
+ type: saved.type,
26380
+ error: err instanceof Error ? err.message : String(err)
26381
+ }
26382
+ });
26383
+ }
26384
+ }
26385
+ const childRows = savedDevices.filter((s) => s.parentDeviceId !== null);
26386
+ for (const saved of childRows) {
26387
+ const Class = this.deviceClasses[saved.type];
26388
+ if (!Class) continue;
26389
+ if (saved.parentDeviceId === null) continue;
26390
+ if (!restored.has(saved.parentDeviceId)) continue;
26391
+ try {
26392
+ await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
26393
+ restored.add(saved.id);
26394
+ } catch (err) {
26395
+ this.ctx.logger.warn("Failed to restore hub-adopted child", {
26396
+ tags: {
26397
+ stableId: saved.stableId,
26398
+ parentDeviceId: saved.parentDeviceId
26399
+ },
26400
+ meta: {
26401
+ type: saved.type,
26402
+ error: err instanceof Error ? err.message : String(err)
26403
+ }
26404
+ });
26405
+ }
26406
+ }
26020
26407
  }
26408
+ /** Convert an IDevice to the flat DeviceSummary for the cap router. */
26409
+ toSummary(device) {
26410
+ return toDeviceSummary(device, this.addonId);
26411
+ }
26412
+ };
26413
+ DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.Camera, DeviceType.Hub, DeviceType.Switch, DeviceType.Siren, DeviceType.Light, DeviceType.Fan, DeviceType.Sensor, DeviceType.Thermostat, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder;
26414
+ new Set(Object.values(DeviceType));
26415
+ DeviceFeature.BatteryOperated;
26416
+ /** Reject after `ms`; always clears its own timer. */
26417
+ async function withTimeout$1(promise, ms, label) {
26418
+ let timer;
26419
+ const timeout = new Promise((_resolve, reject) => {
26420
+ timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`${label} timed out after ${String(ms)}ms`)), ms);
26421
+ });
26422
+ try {
26423
+ return await Promise.race([promise, timeout]);
26424
+ } finally {
26425
+ if (timer !== void 0) clearTimeout(timer);
26426
+ }
26427
+ }
26428
+ /**
26429
+ * Start the reachability poll loop. Returns a handle whose `stop()` clears the
26430
+ * timer and prevents any further ticks. Start on device activation, stop on
26431
+ * device teardown (`removeDevice`) so no timer leaks.
26432
+ */
26433
+ function startReachabilityPoll(options) {
26434
+ const intervalMs = options.intervalMs ?? 3e4;
26435
+ const failuresToOffline = options.failuresToOffline ?? 3;
26436
+ const probeTimeoutMs = options.probeTimeoutMs ?? 1e4;
26437
+ const runImmediately = options.runImmediately ?? true;
26438
+ let stopped = false;
26439
+ let running = false;
26440
+ let consecutiveFailures = 0;
26441
+ let timer;
26442
+ const tick = async () => {
26443
+ if (stopped) return;
26444
+ if (running) return;
26445
+ if (options.isEnabled && !options.isEnabled()) return;
26446
+ running = true;
26447
+ try {
26448
+ const reachable = await withTimeout$1(Promise.resolve().then(options.probe), probeTimeoutMs, "reachability probe");
26449
+ if (stopped) return;
26450
+ if (reachable) {
26451
+ consecutiveFailures = 0;
26452
+ options.setOnline(true);
26453
+ } else registerFailure("probe resolved unreachable");
26454
+ } catch (error) {
26455
+ if (stopped) return;
26456
+ registerFailure(error instanceof Error ? error.message : "probe threw");
26457
+ } finally {
26458
+ running = false;
26459
+ }
26460
+ };
26461
+ const registerFailure = (reason) => {
26462
+ consecutiveFailures += 1;
26463
+ options.logger?.debug("reachability probe failed", {
26464
+ reason,
26465
+ consecutiveFailures,
26466
+ failuresToOffline
26467
+ });
26468
+ if (consecutiveFailures >= failuresToOffline) options.setOnline(false);
26469
+ };
26470
+ timer = setInterval(() => {
26471
+ tick();
26472
+ }, intervalMs);
26473
+ if (typeof timer === "object" && timer !== null && "unref" in timer) timer.unref();
26474
+ if (runImmediately) tick();
26475
+ return { stop: () => {
26476
+ if (stopped) return;
26477
+ stopped = true;
26478
+ if (timer !== void 0) clearInterval(timer);
26479
+ timer = void 0;
26480
+ } };
26481
+ }
26482
+ var LAST_FETCHED_FIELD = "lastFetchedAt";
26483
+ function createRuntimeStateBridge(params) {
26484
+ const { runtimeState, cap, ownDeviceId, refresh, staleMs, empty, logger } = params;
26485
+ const missCooldownMs = params.refreshMissCooldownMs ?? 6e4;
26486
+ /** Epoch ms until which a refresh is not re-attempted. 0 = no cooldown. */
26487
+ let missCooldownUntil = 0;
26488
+ const readFetchedAt = () => {
26489
+ const value = runtimeState.getCapState(cap.name)?.[LAST_FETCHED_FIELD];
26490
+ return typeof value === "number" ? value : 0;
26491
+ };
26021
26492
  /**
26022
- * Kernel-injected callback that runs the full post-probe orchestration
26023
- * (onProbe registerDevice meta refresh accessory reconciliation).
26024
- * Set by `device-cap-proxy.register()`. Drivers should not touch this
26025
- * directlycall `reprobe()` instead.
26026
- */
26027
- _kernelReprobe;
26028
- /**
26029
- * Declare accessory child devices the kernel should auto-spawn
26030
- * after `onProbe()` resolves. Each spec fully describes one child
26031
- * — stableId suffix (deterministic per kind for restore-safety),
26032
- * meta (type / name / location), config (initial blob the child
26033
- * self-hydrates), and a factory that constructs the concrete
26034
- * class with whatever closure-captured refs it needs (typically
26035
- * `this` for the parent reference).
26036
- *
26037
- * The kernel handles the rest: allocateDeviceId, persistInitialConfig
26038
- * (skipped on restore when the row already exists),
26039
- * persistInitialMeta, createContext, factory invocation, register,
26040
- * and recursive lifecycle (probe + accessories + activate).
26041
- *
26042
- * Implementations should derive children from
26043
- * `this.runtimeState.getCapState('feature-probe')` (post-probe truth).
26044
- * Drivers can use the `getProbeFlags()` helper to read the flag bag
26045
- * with a typed cast.
26046
- *
26047
- * Default: no children.
26493
+ * Stop re-reading this camera for `missCooldownMs`, and say so. The
26494
+ * warn is the ONLY line an operator gets for a cap that has silently
26495
+ * been answering with defaults, so it names the cap and carries
26496
+ * `tags.deviceId`a miss is always asked per-camera.
26048
26497
  */
26049
- getAccessoryChildren() {
26050
- return [];
26498
+ const openMissCooldown = (err) => {
26499
+ missCooldownUntil = Date.now() + missCooldownMs;
26500
+ logger?.warn(`${cap.name}: refresh did not land — serving the last slice and not re-reading the camera for ${String(missCooldownMs)}ms`, {
26501
+ tags: { deviceId: ownDeviceId },
26502
+ meta: {
26503
+ cooldownMs: missCooldownMs,
26504
+ error: err === void 0 ? null : err instanceof Error ? err.message : String(err)
26505
+ }
26506
+ });
26507
+ };
26508
+ const ensureFresh = async () => {
26509
+ const slice = runtimeState.getCapState(cap.name);
26510
+ const fetchedAt = readFetchedAt();
26511
+ if (slice && Date.now() - fetchedAt <= staleMs) {
26512
+ missCooldownUntil = 0;
26513
+ return;
26514
+ }
26515
+ if (Date.now() < missCooldownUntil) return;
26516
+ try {
26517
+ await refresh();
26518
+ } catch (err) {
26519
+ openMissCooldown(err);
26520
+ throw err;
26521
+ }
26522
+ if (readFetchedAt() > fetchedAt) {
26523
+ if (missCooldownUntil !== 0) {
26524
+ missCooldownUntil = 0;
26525
+ logger?.info(`${cap.name}: refresh landed again — resuming normal polling`, { tags: { deviceId: ownDeviceId } });
26526
+ }
26527
+ return;
26528
+ }
26529
+ openMissCooldown(void 0);
26530
+ };
26531
+ const projectStatus = () => {
26532
+ const slice = runtimeState.getCapState(cap.name);
26533
+ if (!slice) return empty();
26534
+ const { [LAST_FETCHED_FIELD]: _omit, ...rest } = slice;
26535
+ return rest;
26536
+ };
26537
+ const getStatus = async ({ deviceId }) => {
26538
+ if (deviceId !== ownDeviceId) throw new Error(`${cap.name}: deviceId mismatch, expected ${ownDeviceId}, got ${deviceId}`);
26539
+ await ensureFresh();
26540
+ return projectStatus();
26541
+ };
26542
+ return {
26543
+ ensureFresh,
26544
+ getStatus
26545
+ };
26546
+ }
26547
+ /**
26548
+ * Error types for the safe expression engine. Two distinct classes so callers
26549
+ * can tell a compile-time (grammar) failure from a runtime (evaluation)
26550
+ * failure — both are non-fatal to the host: read paths degrade to "skip link".
26551
+ */
26552
+ /** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
26553
+ * the failure is anchored to a character (author-facing inline feedback). */
26554
+ var ExpressionParseError = class extends Error {
26555
+ position;
26556
+ constructor(message, position) {
26557
+ super(message);
26558
+ this.name = "ExpressionParseError";
26559
+ this.position = position;
26051
26560
  }
26052
- /**
26053
- * Read the current feature-probe flag bag with a typed cast. Helper
26054
- * for `getAccessoryChildren()` and `features` getters that derive
26055
- * outputs from the probe results.
26056
- */
26057
- getProbeFlags() {
26058
- return this.runtimeState.getCapState("feature-probe")?.flags ?? {};
26561
+ };
26562
+ /** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
26563
+ * result, unknown builtin, step-budget exceeded). */
26564
+ var ExpressionEvalError = class extends Error {
26565
+ constructor(message) {
26566
+ super(message);
26567
+ this.name = "ExpressionEvalError";
26059
26568
  }
26060
- /**
26061
- * Returns true once `onProbe` has completed at least once
26062
- * (`lastProbedAt > 0`). Drivers gate `getAccessoryChildren()` on this
26063
- * to avoid spawning stale accessories on a fresh device whose probe
26064
- * hasn't landed yet.
26065
- */
26066
- hasProbed() {
26067
- return (this.runtimeState.getCapState("feature-probe")?.lastProbedAt ?? 0) > 0;
26569
+ };
26570
+ /**
26571
+ * Frozen, null-prototype builtin function table for the expression engine
26572
+ * (spec §4 rule 4). The table is the SOLE surface of callable functions: the
26573
+ * parser rejects any callee not in it, and the evaluator gates each call on an
26574
+ * own-property check against it.
26575
+ *
26576
+ * Because the object has a NULL prototype AND is `Object.freeze`d:
26577
+ * - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
26578
+ * - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
26579
+ * (there is no `Object.prototype` in the chain), so those names are not
26580
+ * callable — they are simply "unknown function" at parse time.
26581
+ *
26582
+ * Every numeric argument is validated as a finite number and every numeric
26583
+ * RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
26584
+ * (`pow(10,400)` → Infinity) all raise `ExpressionEvalError` and fail the link
26585
+ * closed rather than emitting a garbage value.
26586
+ */
26587
+ function asFiniteNumber(value, name, index) {
26588
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
26589
+ return value;
26590
+ }
26591
+ function asString$1(value, name, index) {
26592
+ if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
26593
+ return value;
26594
+ }
26595
+ function finiteResult(value, name) {
26596
+ if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
26597
+ return value;
26598
+ }
26599
+ function allFiniteNumbers(args, name) {
26600
+ return args.map((a, idx) => asFiniteNumber(a, name, idx));
26601
+ }
26602
+ var INF = Number.POSITIVE_INFINITY;
26603
+ var table = {
26604
+ min: {
26605
+ minArgs: 1,
26606
+ maxArgs: INF,
26607
+ apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
26608
+ },
26609
+ max: {
26610
+ minArgs: 1,
26611
+ maxArgs: INF,
26612
+ apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
26613
+ },
26614
+ abs: {
26615
+ minArgs: 1,
26616
+ maxArgs: 1,
26617
+ apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
26618
+ },
26619
+ floor: {
26620
+ minArgs: 1,
26621
+ maxArgs: 1,
26622
+ apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
26623
+ },
26624
+ ceil: {
26625
+ minArgs: 1,
26626
+ maxArgs: 1,
26627
+ apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
26628
+ },
26629
+ sqrt: {
26630
+ minArgs: 1,
26631
+ maxArgs: 1,
26632
+ apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
26633
+ },
26634
+ round: {
26635
+ minArgs: 1,
26636
+ maxArgs: 2,
26637
+ apply: (args) => {
26638
+ const x = asFiniteNumber(args[0], "round", 0);
26639
+ const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
26640
+ if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
26641
+ const factor = 10 ** digits;
26642
+ return finiteResult(Math.round(x * factor) / factor, "round");
26643
+ }
26644
+ },
26645
+ pow: {
26646
+ minArgs: 2,
26647
+ maxArgs: 2,
26648
+ apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
26649
+ },
26650
+ clamp: {
26651
+ minArgs: 3,
26652
+ maxArgs: 3,
26653
+ apply: (args) => {
26654
+ const x = asFiniteNumber(args[0], "clamp", 0);
26655
+ const lo = asFiniteNumber(args[1], "clamp", 1);
26656
+ const hi = asFiniteNumber(args[2], "clamp", 2);
26657
+ if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
26658
+ return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
26659
+ }
26660
+ },
26661
+ avg: {
26662
+ minArgs: 1,
26663
+ maxArgs: INF,
26664
+ apply: (args) => {
26665
+ const nums = allFiniteNumbers(args, "avg");
26666
+ return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
26667
+ }
26668
+ },
26669
+ sum: {
26670
+ minArgs: 1,
26671
+ maxArgs: INF,
26672
+ apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
26673
+ },
26674
+ coalesce: {
26675
+ minArgs: 1,
26676
+ maxArgs: INF,
26677
+ apply: (args) => {
26678
+ for (const a of args) if (a !== null) return a;
26679
+ return null;
26680
+ }
26681
+ },
26682
+ age: {
26683
+ minArgs: 2,
26684
+ maxArgs: 2,
26685
+ apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
26686
+ },
26687
+ convert: {
26688
+ minArgs: 3,
26689
+ maxArgs: 3,
26690
+ apply: (args, hooks) => {
26691
+ const x = asFiniteNumber(args[0], "convert", 0);
26692
+ const from = asString$1(args[1], "convert", 1).trim();
26693
+ const to = asString$1(args[2], "convert", 2).trim();
26694
+ if (hooks.convert) {
26695
+ const out = hooks.convert(x, from, to);
26696
+ if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
26697
+ return finiteResult(out, "convert");
26698
+ }
26699
+ if (from === to) return x;
26700
+ throw new ExpressionEvalError("convert: unit conversion table not installed");
26701
+ }
26702
+ }
26703
+ };
26704
+ Object.freeze(Object.assign(Object.create(null), table));
26705
+ /** The set of valid builtin names — used by the parser to reject unknown
26706
+ * callees at parse time (immediate author feedback). */
26707
+ var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
26708
+ /**
26709
+ * Resource-bound constants for the safe expression engine.
26710
+ *
26711
+ * Every bound is defense-in-depth: the grammar is non-Turing-complete (no
26712
+ * loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
26713
+ * O(nodeCount) by construction. These caps merely put a hard ceiling on the
26714
+ * work a single author-supplied expression can request, so a hostile or
26715
+ * accidental pathological string can never spend unbounded CPU/memory.
26716
+ */
26717
+ /** Max source length (chars) — checked BEFORE tokenizing so a huge string is
26718
+ * rejected without allocation. */
26719
+ var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
26720
+ /** A legal binding / identifier name. */
26721
+ var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
26722
+ /** Binding names an author may NOT use: `now` is auto-injected; the literal
26723
+ * keywords lex as values, not identifiers, so binding to them is meaningless. */
26724
+ var RESERVED_BINDING_NAMES = new Set([
26725
+ "now",
26726
+ "true",
26727
+ "false",
26728
+ "null"
26729
+ ]);
26730
+ /**
26731
+ * Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
26732
+ * zero-dependency. The grammar is deliberately boring: decimal numbers,
26733
+ * single/double-quoted strings with a tiny escape set, identifiers, the three
26734
+ * value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
26735
+ * outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
26736
+ * is a parse error with a source position, so member access / assignment /
26737
+ * template literals are lexically impossible.
26738
+ */
26739
+ var KEYWORDS = new Set([
26740
+ "true",
26741
+ "false",
26742
+ "null"
26743
+ ]);
26744
+ function isDigit(ch) {
26745
+ return ch >= "0" && ch <= "9";
26746
+ }
26747
+ function isIdentStart(ch) {
26748
+ return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
26749
+ }
26750
+ function isIdentPart(ch) {
26751
+ return isIdentStart(ch) || isDigit(ch);
26752
+ }
26753
+ function isWhitespace(ch) {
26754
+ return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
26755
+ }
26756
+ /** Tokenize `source` into a flat token list ending with a single `eof` token.
26757
+ * Throws `ExpressionParseError` on any illegal character or unterminated
26758
+ * string. */
26759
+ function tokenize(source) {
26760
+ if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
26761
+ const tokens = [];
26762
+ let i = 0;
26763
+ const n = source.length;
26764
+ while (i < n) {
26765
+ const ch = source[i];
26766
+ if (isWhitespace(ch)) {
26767
+ i += 1;
26768
+ continue;
26769
+ }
26770
+ if (isDigit(ch)) {
26771
+ const start = i;
26772
+ while (i < n && isDigit(source[i])) i += 1;
26773
+ if (i < n && source[i] === ".") {
26774
+ if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
26775
+ i += 1;
26776
+ while (i < n && isDigit(source[i])) i += 1;
26777
+ }
26778
+ const text = source.slice(start, i);
26779
+ const value = Number(text);
26780
+ if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
26781
+ tokens.push({
26782
+ type: "number",
26783
+ value,
26784
+ pos: start
26785
+ });
26786
+ continue;
26787
+ }
26788
+ if (ch === "'" || ch === "\"") {
26789
+ const quote = ch;
26790
+ const start = i;
26791
+ i += 1;
26792
+ let out = "";
26793
+ let closed = false;
26794
+ while (i < n) {
26795
+ const c = source[i];
26796
+ if (c === "\\") {
26797
+ const next = i + 1 < n ? source[i + 1] : "";
26798
+ if (next === "\\" || next === "'" || next === "\"") {
26799
+ out += next;
26800
+ i += 2;
26801
+ continue;
26802
+ }
26803
+ throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
26804
+ }
26805
+ if (c === quote) {
26806
+ closed = true;
26807
+ i += 1;
26808
+ break;
26809
+ }
26810
+ out += c;
26811
+ i += 1;
26812
+ }
26813
+ if (!closed) throw new ExpressionParseError("unterminated string literal", start);
26814
+ tokens.push({
26815
+ type: "string",
26816
+ value: out,
26817
+ pos: start
26818
+ });
26819
+ continue;
26820
+ }
26821
+ if (isIdentStart(ch)) {
26822
+ const start = i;
26823
+ while (i < n && isIdentPart(source[i])) i += 1;
26824
+ const text = source.slice(start, i);
26825
+ if (KEYWORDS.has(text)) tokens.push({
26826
+ type: "keyword",
26827
+ keyword: keywordOf(text),
26828
+ pos: start
26829
+ });
26830
+ else tokens.push({
26831
+ type: "identifier",
26832
+ name: text,
26833
+ pos: start
26834
+ });
26835
+ continue;
26836
+ }
26837
+ const two = i + 1 < n ? source.slice(i, i + 2) : "";
26838
+ if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
26839
+ tokens.push({
26840
+ type: "punct",
26841
+ punct: two,
26842
+ pos: i
26843
+ });
26844
+ i += 2;
26845
+ continue;
26846
+ }
26847
+ if (isSinglePunct(ch)) {
26848
+ tokens.push({
26849
+ type: "punct",
26850
+ punct: ch,
26851
+ pos: i
26852
+ });
26853
+ i += 1;
26854
+ continue;
26855
+ }
26856
+ throw new ExpressionParseError(`unexpected character '${ch}'`, i);
26068
26857
  }
26069
- };
26070
- /**
26071
- * Convert an IDevice to the flat DeviceSummary shape expected by the
26072
- * device-provider cap router. Shared across all providers.
26073
- */
26074
- function toDeviceSummary(device, addonId) {
26075
- const config = {};
26076
- for (const entry of device.config.entries()) config[entry.key] = entry.value;
26077
- return {
26078
- id: device.id,
26079
- stableId: device.stableId,
26080
- addonId,
26081
- type: String(device.type),
26082
- name: device.name,
26083
- parentDeviceId: device.parentDeviceId,
26084
- online: device.online,
26085
- features: [...device.features],
26086
- config,
26087
- sourceInfo: device.sourceInfo
26088
- };
26858
+ tokens.push({
26859
+ type: "eof",
26860
+ pos: n
26861
+ });
26862
+ return tokens;
26863
+ }
26864
+ function keywordOf(text) {
26865
+ if (text === "true") return "true";
26866
+ if (text === "false") return "false";
26867
+ return "null";
26868
+ }
26869
+ function isSinglePunct(ch) {
26870
+ return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
26089
26871
  }
26090
26872
  /**
26091
- * Base class for device-provider addons (rtsp, onvif, frigate).
26092
- *
26093
- * Provides default implementations for the common device-provider cap
26094
- * methods (`start`, `stop`, `getStatus`, `getDevices`, `supportsDiscovery`,
26095
- * `supportsManualCreation`, `toDeviceSummary`). Subclasses override the
26096
- * methods that differ per provider.
26873
+ * Pratt (precedence-climbing) parser for the safe expression mini-language.
26097
26874
  *
26098
- * @example
26099
- * ```ts
26100
- * class RtspProvider extends BaseDeviceProvider {
26101
- * protected readonly addonId = 'provider-rtsp'
26102
- * protected readonly providerName = 'RTSP'
26875
+ * Precedence (low → high): ternary `?:` (right-assoc) → `||` → `&&` → equality
26876
+ * → relational → additive → multiplicative → unary `! -` → call / primary.
26877
+ * Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
26878
+ * string validated against the builtin table at parse time, so an unknown
26879
+ * function is rejected immediately (author feedback) and a persisted expression
26880
+ * that references a since-removed builtin degrades at read.
26103
26881
  *
26104
- * protected async onCreateDevice(input) { ... }
26105
- * protected async onGetCreationSchema(type) { ... }
26106
- * protected async onRestoreDevices(saved) { ... }
26107
- * }
26108
- * ```
26882
+ * A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
26883
+ * arity is capped (`MAX_EXPRESSION_CALL_ARGS`) both raise `ExpressionParseError`.
26109
26884
  */
26110
- var BaseDeviceProvider = class extends BaseAddon {
26111
- async onInitialize() {
26112
- this.ctx.logger.info(`${this.providerName} Provider initialized`);
26113
- return [{
26114
- capability: deviceProviderCapability,
26115
- provider: this
26116
- }];
26117
- }
26118
- async onShutdown() {
26119
- const devices = await this.ctx.kernel.devices?.getAll() ?? [];
26120
- for (const device of devices) try {
26121
- await this.ctx.kernel.devices?.decommission(device.id);
26122
- } catch (err) {
26123
- this.ctx.logger.warn(`${this.providerName}: decommission failed`, {
26124
- tags: {
26125
- deviceId: device.id,
26126
- stableId: device.stableId
26127
- },
26128
- meta: { error: err instanceof Error ? err.message : String(err) }
26129
- });
26130
- }
26131
- this.ctx.logger.info(`${this.providerName} Provider shut down`, { meta: { decommissionedCount: devices.length } });
26885
+ /** Binary/logical operator precedence (higher binds tighter). */
26886
+ var BINARY_PRECEDENCE = {
26887
+ "||": 1,
26888
+ "&&": 2,
26889
+ "==": 3,
26890
+ "!=": 3,
26891
+ "<": 4,
26892
+ "<=": 4,
26893
+ ">": 4,
26894
+ ">=": 4,
26895
+ "+": 5,
26896
+ "-": 5,
26897
+ "*": 6,
26898
+ "/": 6,
26899
+ "%": 6
26900
+ };
26901
+ function isLogicalOp(op) {
26902
+ return op === "&&" || op === "||";
26903
+ }
26904
+ function isBinaryOp(op) {
26905
+ return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
26906
+ }
26907
+ var Parser = class {
26908
+ tokens;
26909
+ pos = 0;
26910
+ nodeCount = 0;
26911
+ identifiers = /* @__PURE__ */ new Set();
26912
+ callees = /* @__PURE__ */ new Set();
26913
+ constructor(tokens) {
26914
+ this.tokens = tokens;
26132
26915
  }
26133
- async start() {}
26134
- async stop() {}
26135
- async getStatus() {
26916
+ parse() {
26917
+ const ast = this.parseTernary();
26918
+ const tok = this.peek();
26919
+ if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
26136
26920
  return {
26137
- connected: true,
26138
- deviceCount: (await this.ctx.kernel.devices?.getAll() ?? []).length
26921
+ ast,
26922
+ identifiers: this.identifiers,
26923
+ callees: this.callees,
26924
+ nodeCount: this.nodeCount
26139
26925
  };
26140
26926
  }
26141
- async getDevices() {
26142
- return (await this.ctx.kernel.devices?.getAll() ?? []).map((d) => ({
26143
- id: d.stableId,
26144
- name: d.name,
26145
- type: String(d.type)
26146
- }));
26147
- }
26148
- async supportsDiscovery() {
26149
- return false;
26150
- }
26151
- async discoverDevices(_input) {
26152
- return [];
26153
- }
26154
- /** Extra per-scan input form (e.g. a broadcast address for another subnet).
26155
- * Null = no extra params. Override in providers that support scoped scans. */
26156
- async getDiscoveryParamsSchema() {
26157
- return null;
26158
- }
26159
- /**
26160
- * The DeviceType this provider creates via manual add — derived from the
26161
- * `deviceClasses` map (first registered type). `null` when manual creation is
26162
- * unsupported. Lets the Add-Device dialog pick the right type per provider.
26163
- */
26164
- async getManualCreationType() {
26165
- if (!await this.supportsManualCreation()) return { deviceType: null };
26166
- return { deviceType: Object.values(DeviceType).find((t) => this.deviceClasses[t] !== void 0) ?? null };
26167
- }
26168
- async adoptDiscoveredDevice(_input) {
26169
- throw new Error(`${this.providerName} provider does not support discovery-based adoption`);
26170
- }
26171
- async supportsManualCreation() {
26172
- return true;
26927
+ peek() {
26928
+ return this.tokens[this.pos];
26173
26929
  }
26174
- async getChildCreationSchema(input) {
26175
- return this.onGetCreationSchema(input.type);
26930
+ next() {
26931
+ return this.tokens[this.pos++];
26176
26932
  }
26177
- /**
26178
- * Default kernel-orchestrated `createDevice` implementation. The
26179
- * subclass's `onCreateDevice` returns a declarative
26180
- * `CreateDeviceSpec` (`{meta, config}`) this method handles
26181
- * stableId generation, class lookup, kernel.devices.create
26182
- * dispatch, and DeviceSummary mapping. Subclasses should NOT
26183
- * override this method; override `onCreateDevice` and
26184
- * `deviceClasses` instead.
26185
- */
26186
- async createDevice(input) {
26187
- const spec = await this.onCreateDevice(input.type, input.config);
26188
- const Class = this.deviceClasses[spec.meta.type];
26189
- if (!Class) throw new Error(`${this.providerName} provider: no device class registered for type "${spec.meta.type}" — add it to the deviceClasses map`);
26190
- const stableId = this.generateStableId(spec.meta.type, spec.config);
26191
- const device = await this.ctx.kernel.devices.create(stableId, Class, spec.config, null, spec.meta);
26192
- if (spec.onAfterCreate) try {
26193
- await spec.onAfterCreate(device);
26194
- } catch (err) {
26195
- this.ctx.logger.warn("createDevice: onAfterCreate hook threw — device is already registered", {
26196
- tags: {
26197
- deviceId: device.id,
26198
- stableId
26199
- },
26200
- meta: { error: err instanceof Error ? err.message : String(err) }
26201
- });
26933
+ /** Consume a punctuator token, erroring if the next token isn't it. */
26934
+ expectPunct(punct) {
26935
+ const tok = this.peek();
26936
+ if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
26937
+ this.pos += 1;
26938
+ }
26939
+ matchPunct(punct) {
26940
+ const tok = this.peek();
26941
+ if (tok.type === "punct" && tok.punct === punct) {
26942
+ this.pos += 1;
26943
+ return true;
26202
26944
  }
26203
- return this.toSummary(device);
26945
+ return false;
26204
26946
  }
26205
- /**
26206
- * Generate a stableId for a newly-created device. Default uses the
26207
- * `${addonId}-${Date.now()}` pattern as a unique-but-opaque
26208
- * fallback; any provider that has access to durable hardware
26209
- * identity (UID, MAC, serial) should override and derive from it
26210
- * so re-adding the same physical device reuses its persisted row.
26211
- *
26212
- * `config` is the parsed CreateDeviceSpec.config the subclass
26213
- * returned from `onCreateDevice` — the override has access to
26214
- * every operator-supplied + autodetect-resolved field. Optional
26215
- * for back-compat: existing overrides that take only `type`
26216
- * keep working unchanged.
26217
- */
26218
- generateStableId(_type, _config) {
26219
- return `${this.addonId}-${Date.now()}`;
26947
+ countNode() {
26948
+ this.nodeCount += 1;
26949
+ if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
26220
26950
  }
26221
- async testCreationField(_input) {
26222
- return {
26223
- status: "ok",
26224
- labels: ["probe not implemented"]
26225
- };
26951
+ parseTernary() {
26952
+ const test = this.parseBinary(1);
26953
+ if (this.matchPunct("?")) {
26954
+ const consequent = this.parseTernary();
26955
+ this.expectPunct(":");
26956
+ const alternate = this.parseTernary();
26957
+ this.countNode();
26958
+ return {
26959
+ kind: "conditional",
26960
+ test,
26961
+ consequent,
26962
+ alternate
26963
+ };
26964
+ }
26965
+ return test;
26226
26966
  }
26227
- async restoreDevices(savedDevices) {
26228
- await this.onRestoreDevices(savedDevices);
26229
- if (savedDevices.length > 0) this.ctx.logger.info(`Restored ${savedDevices.length} ${this.providerName} device(s)`);
26967
+ parseBinary(minPrec) {
26968
+ let left = this.parseUnary();
26969
+ for (;;) {
26970
+ const tok = this.peek();
26971
+ if (tok.type !== "punct") break;
26972
+ const prec = BINARY_PRECEDENCE[tok.punct];
26973
+ if (prec === void 0 || prec < minPrec) break;
26974
+ const op = tok.punct;
26975
+ this.pos += 1;
26976
+ const right = this.parseBinary(prec + 1);
26977
+ this.countNode();
26978
+ if (isLogicalOp(op)) left = {
26979
+ kind: "logical",
26980
+ op,
26981
+ left,
26982
+ right
26983
+ };
26984
+ else if (isBinaryOp(op)) left = {
26985
+ kind: "binary",
26986
+ op,
26987
+ left,
26988
+ right
26989
+ };
26990
+ else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
26991
+ }
26992
+ return left;
26230
26993
  }
26231
- /**
26232
- * Restore devices from persisted state. Two-pass:
26233
- *
26234
- * 1. **Top-level pass** — invokes `kernel.devices.create()` for every
26235
- * `parentDeviceId === null` row using the `deviceClasses` map.
26236
- * The kernel's register flow handles `getAccessoryChildren()` for
26237
- * each parent (siren / floodlight / PIR / etc).
26238
- *
26239
- * 2. **Hub-adopted children pass** — for rows with
26240
- * `parentDeviceId !== null` whose `type` IS in `deviceClasses`
26241
- * (e.g. Reolink hub-adopted cameras under an NVR), spawn them
26242
- * explicitly with the persisted `parentDeviceId`. These are
26243
- * NOT accessory children — they're first-class adopted devices
26244
- * that just happen to have a parent. Without this pass, every
26245
- * server restart would lose hub-adopted cameras (their type is
26246
- * in `deviceClasses` but parent's `getAccessoryChildren` doesn't
26247
- * spawn them — that callback is only for purpose-built
26248
- * accessory roles).
26249
- *
26250
- * Rows whose `type` is NOT in `deviceClasses` are skipped — those
26251
- * are accessory children (siren/light/sensor) that the kernel's
26252
- * accessory-spawn flow handles via the parent's
26253
- * `getAccessoryChildren()`. Override only when the default doesn't
26254
- * fit.
26255
- */
26256
- async onRestoreDevices(savedDevices) {
26257
- const restored = /* @__PURE__ */ new Set();
26258
- for (const saved of savedDevices) {
26259
- if (saved.parentDeviceId !== null) continue;
26260
- const Class = this.deviceClasses[saved.type];
26261
- if (!Class) {
26262
- this.ctx.logger.warn("No device class registered for restored type — skipping", {
26263
- tags: { stableId: saved.stableId },
26264
- meta: { type: saved.type }
26265
- });
26266
- continue;
26267
- }
26268
- try {
26269
- await this.ctx.kernel.devices.create(saved.stableId, Class, {});
26270
- restored.add(saved.id);
26271
- } catch (err) {
26272
- this.ctx.logger.warn("Failed to restore device", {
26273
- tags: { stableId: saved.stableId },
26274
- meta: {
26275
- type: saved.type,
26276
- error: err instanceof Error ? err.message : String(err)
26277
- }
26278
- });
26279
- }
26994
+ parseUnary() {
26995
+ const tok = this.peek();
26996
+ if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
26997
+ const op = tok.punct;
26998
+ this.pos += 1;
26999
+ const operand = this.parseUnary();
27000
+ this.countNode();
27001
+ return {
27002
+ kind: "unary",
27003
+ op,
27004
+ operand
27005
+ };
26280
27006
  }
26281
- const childRows = savedDevices.filter((s) => s.parentDeviceId !== null);
26282
- for (const saved of childRows) {
26283
- const Class = this.deviceClasses[saved.type];
26284
- if (!Class) continue;
26285
- if (saved.parentDeviceId === null) continue;
26286
- if (!restored.has(saved.parentDeviceId)) continue;
26287
- try {
26288
- await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
26289
- restored.add(saved.id);
26290
- } catch (err) {
26291
- this.ctx.logger.warn("Failed to restore hub-adopted child", {
26292
- tags: {
26293
- stableId: saved.stableId,
26294
- parentDeviceId: saved.parentDeviceId
26295
- },
26296
- meta: {
26297
- type: saved.type,
26298
- error: err instanceof Error ? err.message : String(err)
26299
- }
26300
- });
27007
+ return this.parsePrimary();
27008
+ }
27009
+ parsePrimary() {
27010
+ const tok = this.next();
27011
+ switch (tok.type) {
27012
+ case "number":
27013
+ this.countNode();
27014
+ return {
27015
+ kind: "literal",
27016
+ value: tok.value
27017
+ };
27018
+ case "string":
27019
+ this.countNode();
27020
+ return {
27021
+ kind: "literal",
27022
+ value: tok.value
27023
+ };
27024
+ case "keyword":
27025
+ this.countNode();
27026
+ return {
27027
+ kind: "literal",
27028
+ value: tok.keyword === "null" ? null : tok.keyword === "true"
27029
+ };
27030
+ case "identifier": {
27031
+ const nextTok = this.peek();
27032
+ if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
27033
+ this.identifiers.add(tok.name);
27034
+ this.countNode();
27035
+ return {
27036
+ kind: "identifier",
27037
+ name: tok.name
27038
+ };
26301
27039
  }
27040
+ case "punct":
27041
+ if (tok.punct === "(") {
27042
+ const inner = this.parseTernary();
27043
+ this.expectPunct(")");
27044
+ return inner;
27045
+ }
27046
+ throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
27047
+ case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
26302
27048
  }
26303
27049
  }
26304
- /** Convert an IDevice to the flat DeviceSummary for the cap router. */
26305
- toSummary(device) {
26306
- return toDeviceSummary(device, this.addonId);
27050
+ parseCall(callee, pos) {
27051
+ if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
27052
+ this.expectPunct("(");
27053
+ const args = [];
27054
+ if (!this.matchPunct(")")) for (;;) {
27055
+ args.push(this.parseTernary());
27056
+ if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
27057
+ if (this.matchPunct(",")) continue;
27058
+ this.expectPunct(")");
27059
+ break;
27060
+ }
27061
+ this.callees.add(callee);
27062
+ this.countNode();
27063
+ return {
27064
+ kind: "call",
27065
+ callee,
27066
+ args
27067
+ };
26307
27068
  }
26308
27069
  };
26309
- DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.Camera, DeviceType.Hub, DeviceType.Switch, DeviceType.Siren, DeviceType.Light, DeviceType.Fan, DeviceType.Sensor, DeviceType.Thermostat, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder;
26310
- new Set(Object.values(DeviceType));
26311
- DeviceFeature.BatteryOperated;
26312
- /** Reject after `ms`; always clears its own timer. */
26313
- async function withTimeout$1(promise, ms, label) {
26314
- let timer;
26315
- const timeout = new Promise((_resolve, reject) => {
26316
- timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`${label} timed out after ${String(ms)}ms`)), ms);
26317
- });
27070
+ /** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
27071
+ * `ExpressionParseError` on any lexical or grammatical failure. */
27072
+ function parseExpression(source) {
27073
+ return new Parser(tokenize(source)).parse();
27074
+ }
27075
+ /**
27076
+ * LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
27077
+ * by expr"). The cache stores BOTH successes and failures (negative caching),
27078
+ * so a corrupt persisted string costs exactly one tokenize+parse total — not
27079
+ * one per read on a hot resolve path.
27080
+ *
27081
+ * The cache is a module-level singleton: entries are pure, content-addressed
27082
+ * ASTs keyed by the raw source string, so sharing one instance across all
27083
+ * callers is safe and maximises hit rate.
27084
+ */
27085
+ var cache = /* @__PURE__ */ new Map();
27086
+ function getCached(source) {
27087
+ const hit = cache.get(source);
27088
+ if (hit !== void 0) {
27089
+ cache.delete(source);
27090
+ cache.set(source, hit);
27091
+ return hit;
27092
+ }
27093
+ let result;
26318
27094
  try {
26319
- return await Promise.race([promise, timeout]);
26320
- } finally {
26321
- if (timer !== void 0) clearTimeout(timer);
27095
+ result = {
27096
+ ok: true,
27097
+ parsed: parseExpression(source)
27098
+ };
27099
+ } catch (err) {
27100
+ result = {
27101
+ ok: false,
27102
+ error: err instanceof ExpressionParseError ? err.message : String(err)
27103
+ };
27104
+ }
27105
+ cache.set(source, result);
27106
+ if (cache.size > 256) {
27107
+ const oldest = cache.keys().next().value;
27108
+ if (oldest !== void 0) cache.delete(oldest);
26322
27109
  }
27110
+ return result;
27111
+ }
27112
+ /** Compile `source`, returning a discriminated result instead of throwing.
27113
+ * Used by read paths that must degrade rather than raise. LRU/negative-cached. */
27114
+ function compileExpressionSafe(source) {
27115
+ return getCached(source);
26323
27116
  }
27117
+ Object.freeze({});
26324
27118
  /**
26325
- * Start the reachability poll loop. Returns a handle whose `stop()` clears the
26326
- * timer and prevents any further ticks. Start on device activation, stop on
26327
- * device teardown (`removeDevice`) so no timer leaks.
27119
+ * Author-time validation. Returns `null` when the source is valid, else a
27120
+ * human-readable error message. Checks: the expression compiles; binding count
27121
+ * is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
27122
+ * is not reserved (`now`/keywords) and does not shadow a builtin; and every
27123
+ * FREE identifier of the AST is covered by a binding or the injected `now`.
26328
27124
  */
26329
- function startReachabilityPoll(options) {
26330
- const intervalMs = options.intervalMs ?? 3e4;
26331
- const failuresToOffline = options.failuresToOffline ?? 3;
26332
- const probeTimeoutMs = options.probeTimeoutMs ?? 1e4;
26333
- const runImmediately = options.runImmediately ?? true;
26334
- let stopped = false;
26335
- let running = false;
26336
- let consecutiveFailures = 0;
26337
- let timer;
26338
- const tick = async () => {
26339
- if (stopped) return;
26340
- if (running) return;
26341
- if (options.isEnabled && !options.isEnabled()) return;
26342
- running = true;
26343
- try {
26344
- const reachable = await withTimeout$1(Promise.resolve().then(options.probe), probeTimeoutMs, "reachability probe");
26345
- if (stopped) return;
26346
- if (reachable) {
26347
- consecutiveFailures = 0;
26348
- options.setOnline(true);
26349
- } else registerFailure("probe resolved unreachable");
26350
- } catch (error) {
26351
- if (stopped) return;
26352
- registerFailure(error instanceof Error ? error.message : "probe threw");
26353
- } finally {
26354
- running = false;
26355
- }
26356
- };
26357
- const registerFailure = (reason) => {
26358
- consecutiveFailures += 1;
26359
- options.logger?.debug("reachability probe failed", {
26360
- reason,
26361
- consecutiveFailures,
26362
- failuresToOffline
26363
- });
26364
- if (consecutiveFailures >= failuresToOffline) options.setOnline(false);
26365
- };
26366
- timer = setInterval(() => {
26367
- tick();
26368
- }, intervalMs);
26369
- if (typeof timer === "object" && timer !== null && "unref" in timer) timer.unref();
26370
- if (runImmediately) tick();
26371
- return { stop: () => {
26372
- if (stopped) return;
26373
- stopped = true;
26374
- if (timer !== void 0) clearInterval(timer);
26375
- timer = void 0;
26376
- } };
26377
- }
26378
- var LAST_FETCHED_FIELD = "lastFetchedAt";
26379
- function createRuntimeStateBridge(params) {
26380
- const { runtimeState, cap, ownDeviceId, refresh, staleMs, empty } = params;
26381
- const ensureFresh = async () => {
26382
- const slice = runtimeState.getCapState(cap.name);
26383
- const fetchedAt = typeof slice?.[LAST_FETCHED_FIELD] === "number" ? slice[LAST_FETCHED_FIELD] : 0;
26384
- if (!slice || Date.now() - fetchedAt > staleMs) await refresh();
26385
- };
26386
- const projectStatus = () => {
26387
- const slice = runtimeState.getCapState(cap.name);
26388
- if (!slice) return empty();
26389
- const { [LAST_FETCHED_FIELD]: _omit, ...rest } = slice;
26390
- return rest;
26391
- };
26392
- const getStatus = async ({ deviceId }) => {
26393
- if (deviceId !== ownDeviceId) throw new Error(`${cap.name}: deviceId mismatch, expected ${ownDeviceId}, got ${deviceId}`);
26394
- await ensureFresh();
26395
- return projectStatus();
26396
- };
26397
- return {
26398
- ensureFresh,
26399
- getStatus
26400
- };
27125
+ function validateExpressionSource(src) {
27126
+ const names = Object.keys(src.bindings);
27127
+ if (names.length > 32) return `too many bindings (${names.length} > 32)`;
27128
+ for (const name of names) {
27129
+ if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
27130
+ if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
27131
+ if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
27132
+ }
27133
+ const compiled = compileExpressionSafe(src.expr);
27134
+ if (!compiled.ok) return compiled.error;
27135
+ const bound = new Set(names);
27136
+ for (const id of compiled.parsed.identifiers) {
27137
+ if (id === "now") continue;
27138
+ if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
27139
+ }
27140
+ return null;
26401
27141
  }
27142
+ var ExpressionBindingSourceSchema = union([
27143
+ object({
27144
+ kind: literal("field").optional(),
27145
+ sourceKey: string(),
27146
+ cap: string(),
27147
+ fieldPath: string()
27148
+ }),
27149
+ object({
27150
+ kind: literal("literal"),
27151
+ value: union([
27152
+ string(),
27153
+ number(),
27154
+ boolean(),
27155
+ _null()
27156
+ ])
27157
+ }),
27158
+ object({
27159
+ kind: literal("global"),
27160
+ sourceStableId: string(),
27161
+ cap: string(),
27162
+ fieldPath: string()
27163
+ })
27164
+ ]);
27165
+ object({
27166
+ expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
27167
+ bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
27168
+ }).superRefine((src, ctx) => {
27169
+ const err = validateExpressionSource(src);
27170
+ if (err !== null) ctx.addIssue({
27171
+ code: "custom",
27172
+ message: err,
27173
+ path: ["expr"]
27174
+ });
27175
+ });
26402
27176
  Object.freeze({
26403
27177
  "accessories.setChildHidden": {
26404
27178
  capName: "accessories",
@@ -27222,6 +27996,12 @@ Object.freeze({
27222
27996
  addonId: null,
27223
27997
  access: "view"
27224
27998
  },
27999
+ "coreBlocks.restart": {
28000
+ capName: "core-blocks",
28001
+ capScope: "system",
28002
+ addonId: null,
28003
+ access: "create"
28004
+ },
27225
28005
  "coreBlocks.setEnabled": {
27226
28006
  capName: "core-blocks",
27227
28007
  capScope: "system",
@@ -27858,12 +28638,6 @@ Object.freeze({
27858
28638
  addonId: null,
27859
28639
  access: "create"
27860
28640
  },
27861
- "deviceManager.setDeviceLinks": {
27862
- capName: "device-manager",
27863
- capScope: "system",
27864
- addonId: null,
27865
- access: "create"
27866
- },
27867
28641
  "deviceManager.setDisabled": {
27868
28642
  capName: "device-manager",
27869
28643
  capScope: "system",
@@ -29262,6 +30036,48 @@ Object.freeze({
29262
30036
  addonId: null,
29263
30037
  access: "create"
29264
30038
  },
30039
+ "osdManager.clearSlotBinding": {
30040
+ capName: "osd-manager",
30041
+ capScope: "system",
30042
+ addonId: null,
30043
+ access: "delete"
30044
+ },
30045
+ "osdManager.getConditionSupport": {
30046
+ capName: "osd-manager",
30047
+ capScope: "system",
30048
+ addonId: null,
30049
+ access: "view"
30050
+ },
30051
+ "osdManager.getDeviceOsd": {
30052
+ capName: "osd-manager",
30053
+ capScope: "system",
30054
+ addonId: null,
30055
+ access: "view"
30056
+ },
30057
+ "osdManager.getSourceCatalog": {
30058
+ capName: "osd-manager",
30059
+ capScope: "system",
30060
+ addonId: null,
30061
+ access: "view"
30062
+ },
30063
+ "osdManager.previewSlot": {
30064
+ capName: "osd-manager",
30065
+ capScope: "system",
30066
+ addonId: null,
30067
+ access: "create"
30068
+ },
30069
+ "osdManager.renderDevice": {
30070
+ capName: "osd-manager",
30071
+ capScope: "system",
30072
+ addonId: null,
30073
+ access: "create"
30074
+ },
30075
+ "osdManager.setSlotBinding": {
30076
+ capName: "osd-manager",
30077
+ capScope: "system",
30078
+ addonId: null,
30079
+ access: "create"
30080
+ },
29265
30081
  "petFeeder.callPet": {
29266
30082
  capName: "pet-feeder",
29267
30083
  capScope: "device",
@@ -29334,6 +30150,12 @@ Object.freeze({
29334
30150
  addonId: null,
29335
30151
  access: "delete"
29336
30152
  },
30153
+ "pipelineAnalytics.completeRetrainTrack": {
30154
+ capName: "pipeline-analytics",
30155
+ capScope: "device",
30156
+ addonId: null,
30157
+ access: "create"
30158
+ },
29337
30159
  "pipelineAnalytics.deleteDeviceEvents": {
29338
30160
  capName: "pipeline-analytics",
29339
30161
  capScope: "device",
@@ -29346,6 +30168,12 @@ Object.freeze({
29346
30168
  addonId: null,
29347
30169
  access: "delete"
29348
30170
  },
30171
+ "pipelineAnalytics.deselectRetrainFrame": {
30172
+ capName: "pipeline-analytics",
30173
+ capScope: "device",
30174
+ addonId: null,
30175
+ access: "create"
30176
+ },
29349
30177
  "pipelineAnalytics.getActiveTracks": {
29350
30178
  capName: "pipeline-analytics",
29351
30179
  capScope: "device",
@@ -29406,6 +30234,18 @@ Object.freeze({
29406
30234
  addonId: null,
29407
30235
  access: "view"
29408
30236
  },
30237
+ "pipelineAnalytics.getRetrainExportUrl": {
30238
+ capName: "pipeline-analytics",
30239
+ capScope: "device",
30240
+ addonId: null,
30241
+ access: "view"
30242
+ },
30243
+ "pipelineAnalytics.getRetrainFrameImage": {
30244
+ capName: "pipeline-analytics",
30245
+ capScope: "device",
30246
+ addonId: null,
30247
+ access: "view"
30248
+ },
29409
30249
  "pipelineAnalytics.getSensorEvents": {
29410
30250
  capName: "pipeline-analytics",
29411
30251
  capScope: "device",
@@ -29424,6 +30264,18 @@ Object.freeze({
29424
30264
  addonId: null,
29425
30265
  access: "view"
29426
30266
  },
30267
+ "pipelineAnalytics.getTrainingExportSummary": {
30268
+ capName: "pipeline-analytics",
30269
+ capScope: "device",
30270
+ addonId: null,
30271
+ access: "view"
30272
+ },
30273
+ "pipelineAnalytics.getTrainingExportUrl": {
30274
+ capName: "pipeline-analytics",
30275
+ capScope: "device",
30276
+ addonId: null,
30277
+ access: "view"
30278
+ },
29427
30279
  "pipelineAnalytics.listEventKinds": {
29428
30280
  capName: "pipeline-analytics",
29429
30281
  capScope: "device",
@@ -29448,6 +30300,24 @@ Object.freeze({
29448
30300
  addonId: null,
29449
30301
  access: "view"
29450
30302
  },
30303
+ "pipelineAnalytics.listRetrainAnnotations": {
30304
+ capName: "pipeline-analytics",
30305
+ capScope: "device",
30306
+ addonId: null,
30307
+ access: "view"
30308
+ },
30309
+ "pipelineAnalytics.listRetrainFrames": {
30310
+ capName: "pipeline-analytics",
30311
+ capScope: "device",
30312
+ addonId: null,
30313
+ access: "view"
30314
+ },
30315
+ "pipelineAnalytics.listRetrainStaging": {
30316
+ capName: "pipeline-analytics",
30317
+ capScope: "device",
30318
+ addonId: null,
30319
+ access: "view"
30320
+ },
29451
30321
  "pipelineAnalytics.listTrackMedia": {
29452
30322
  capName: "pipeline-analytics",
29453
30323
  capScope: "device",
@@ -29460,6 +30330,12 @@ Object.freeze({
29460
30330
  addonId: null,
29461
30331
  access: "view"
29462
30332
  },
30333
+ "pipelineAnalytics.proposeRetrainAnnotations": {
30334
+ capName: "pipeline-analytics",
30335
+ capScope: "device",
30336
+ addonId: null,
30337
+ access: "create"
30338
+ },
29463
30339
  "pipelineAnalytics.pruneEvents": {
29464
30340
  capName: "pipeline-analytics",
29465
30341
  capScope: "device",
@@ -29490,12 +30366,30 @@ Object.freeze({
29490
30366
  addonId: null,
29491
30367
  access: "create"
29492
30368
  },
30369
+ "pipelineAnalytics.restageRetrainTrack": {
30370
+ capName: "pipeline-analytics",
30371
+ capScope: "device",
30372
+ addonId: null,
30373
+ access: "create"
30374
+ },
30375
+ "pipelineAnalytics.saveRetrainAnnotations": {
30376
+ capName: "pipeline-analytics",
30377
+ capScope: "device",
30378
+ addonId: null,
30379
+ access: "create"
30380
+ },
29493
30381
  "pipelineAnalytics.searchObjectEvents": {
29494
30382
  capName: "pipeline-analytics",
29495
30383
  capScope: "device",
29496
30384
  addonId: null,
29497
30385
  access: "view"
29498
30386
  },
30387
+ "pipelineAnalytics.selectRetrainFrames": {
30388
+ capName: "pipeline-analytics",
30389
+ capScope: "device",
30390
+ addonId: null,
30391
+ access: "create"
30392
+ },
29499
30393
  "pipelineAnalytics.setTrackFlags": {
29500
30394
  capName: "pipeline-analytics",
29501
30395
  capScope: "device",
@@ -30888,6 +31782,12 @@ Object.freeze({
30888
31782
  addonId: null,
30889
31783
  access: "create"
30890
31784
  },
31785
+ "streamBroker.fetchEventMedia": {
31786
+ capName: "stream-broker",
31787
+ capScope: "system",
31788
+ addonId: null,
31789
+ access: "create"
31790
+ },
30891
31791
  "streamBroker.getAllRtspEntries": {
30892
31792
  capName: "stream-broker",
30893
31793
  capScope: "system",
@@ -30900,6 +31800,12 @@ Object.freeze({
30900
31800
  addonId: null,
30901
31801
  access: "view"
30902
31802
  },
31803
+ "streamBroker.getDeviceAudioMute": {
31804
+ capName: "stream-broker",
31805
+ capScope: "system",
31806
+ addonId: null,
31807
+ access: "view"
31808
+ },
30903
31809
  "streamBroker.getPreBufferInfo": {
30904
31810
  capName: "stream-broker",
30905
31811
  capScope: "system",
@@ -30966,6 +31872,12 @@ Object.freeze({
30966
31872
  addonId: null,
30967
31873
  access: "create"
30968
31874
  },
31875
+ "streamBroker.produceEventMedia": {
31876
+ capName: "stream-broker",
31877
+ capScope: "system",
31878
+ addonId: null,
31879
+ access: "create"
31880
+ },
30969
31881
  "streamBroker.publishCameraStream": {
30970
31882
  capName: "stream-broker",
30971
31883
  capScope: "system",
@@ -31020,6 +31932,12 @@ Object.freeze({
31020
31932
  addonId: null,
31021
31933
  access: "create"
31022
31934
  },
31935
+ "streamBroker.setDeviceAudioMute": {
31936
+ capName: "stream-broker",
31937
+ capScope: "system",
31938
+ addonId: null,
31939
+ access: "create"
31940
+ },
31023
31941
  "streamBroker.setPreBufferDuration": {
31024
31942
  capName: "stream-broker",
31025
31943
  capScope: "system",
@@ -33134,6 +34052,7 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
33134
34052
  ownDeviceId: this.id,
33135
34053
  refresh: refreshFromCamera,
33136
34054
  staleMs: STALE_MS,
34055
+ logger: this.ctx.logger,
33137
34056
  empty: () => ({ lastFetchedAt: 0 })
33138
34057
  });
33139
34058
  const provider = {
@@ -33225,6 +34144,7 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
33225
34144
  ownDeviceId: this.id,
33226
34145
  refresh: refreshFromCamera,
33227
34146
  staleMs: STALE_MS,
34147
+ logger: this.ctx.logger,
33228
34148
  empty: () => ({
33229
34149
  enabled: false,
33230
34150
  sensitivity: 0,
@@ -33327,6 +34247,7 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
33327
34247
  ownDeviceId: this.id,
33328
34248
  refresh: refreshFromCamera,
33329
34249
  staleMs: STALE_MS,
34250
+ logger: this.ctx.logger,
33330
34251
  empty: () => ({
33331
34252
  enabled: false,
33332
34253
  regions: [],
@@ -33483,6 +34404,7 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
33483
34404
  ownDeviceId: this.id,
33484
34405
  refresh: refreshFromCamera,
33485
34406
  staleMs: STALE_MS,
34407
+ logger: this.ctx.logger,
33486
34408
  empty: () => ({
33487
34409
  mode: "auto",
33488
34410
  lastFetchedAt: 0
@@ -33573,6 +34495,7 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
33573
34495
  ownDeviceId: this.id,
33574
34496
  refresh: refreshFromCamera,
33575
34497
  staleMs: STALE_MS,
34498
+ logger: this.ctx.logger,
33576
34499
  empty: () => ({ lastFetchedAt: 0 })
33577
34500
  }).getStatus,
33578
34501
  getOptions: async ({ deviceId }) => {