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