@camstack/addon-pipeline 1.2.47 → 1.2.49

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 (37) hide show
  1. package/dist/{model-download-service-D8B-4ktF-7-YxE9Wx.js → addon-utils-DveoBR6G.js} +219 -3
  2. package/dist/{model-download-service-D8B-4ktF-BB7oZL3y.mjs → addon-utils-QuxtOdK1.mjs} +219 -3
  3. package/dist/audio-analyzer/index.js +4 -4
  4. package/dist/audio-analyzer/index.mjs +2 -2
  5. package/dist/detection-pipeline/index.js +9 -9
  6. package/dist/detection-pipeline/index.mjs +3 -3
  7. package/dist/{dist-CfxC7XoI.mjs → dist-CLJKoYEP.mjs} +1301 -1284
  8. package/dist/{dist-C9Q1t0nT.js → dist-J2DeRZAN.js} +1301 -1284
  9. package/dist/{event-loop-stall-monitor-B_XUPTwp.mjs → event-loop-stall-monitor-B6dqQjiX.mjs} +1 -1
  10. package/dist/{event-loop-stall-monitor-Luq9AX4t.js → event-loop-stall-monitor-Bkbiw20z.js} +1 -1
  11. package/dist/motion-wasm/index.js +1 -1
  12. package/dist/motion-wasm/index.mjs +1 -1
  13. package/dist/pipeline-runner/index.js +3 -3
  14. package/dist/pipeline-runner/index.mjs +3 -3
  15. package/dist/recorder/index.js +5 -5
  16. package/dist/recorder/index.mjs +2 -2
  17. package/dist/session-decode/decode-worker-child.js +1 -1
  18. package/dist/session-decode/decode-worker-child.mjs +1 -1
  19. package/dist/stream-broker/_stub.js +2 -2
  20. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-SKj0Zdz2.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DZikMEgD.mjs} +2 -2
  21. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Cj2xMupA.mjs +26 -0
  22. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-Czrsozsg.mjs +26 -0
  23. package/dist/stream-broker/{hostInit-D8gyxL0j.mjs → hostInit-i24FVz-3.mjs} +2 -2
  24. package/dist/stream-broker/index.js +198 -132
  25. package/dist/stream-broker/index.mjs +197 -131
  26. package/dist/stream-broker/remoteEntry.js +1 -1
  27. package/dist/{worker-protocol-rqbyKlm9.mjs → worker-protocol-BxPNiino.mjs} +1 -1
  28. package/dist/{worker-protocol-BUJpm7l3.js → worker-protocol-CNi5srgV.js} +1 -1
  29. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-DwXIyaHw.js → MaskShapeCanvas-DI4BY7W2-m7YpaY98.js} +1 -1
  30. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-Cf8EtWEG.js → MotionZonesSettings-NcxxQN8r-619YTOjy.js} +1 -1
  31. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-BvsVXya9.js → PrivacyMaskSettings-APgPLF7p-B-k8v0O5.js} +1 -1
  32. package/embed-dist/assets/index-CYO4OY8_.js +112 -0
  33. package/embed-dist/index.html +1 -1
  34. package/package.json +1 -1
  35. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-KpixNEma.mjs +0 -26
  36. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-rd7UaqfV.mjs +0 -26
  37. package/embed-dist/assets/index-BuAeIGv8.js +0 -112
@@ -7133,7 +7133,7 @@ function sleep(ms) {
7133
7133
  return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
7134
7134
  }
7135
7135
  //#endregion
7136
- //#region ../types/dist/fmp4-box-splitter-B53u9-Nu.mjs
7136
+ //#region ../types/dist/canonical-hash-rO1sRmEK.mjs
7137
7137
  var AUDIO_ENCODER_BY_CODEC = {
7138
7138
  opus: "libopus",
7139
7139
  aac: "aac",
@@ -7538,37 +7538,6 @@ function invocationFromEncodeProfile(input) {
7538
7538
  ...input.audioSidecar !== void 0 ? { audioSidecar: input.audioSidecar } : {}
7539
7539
  };
7540
7540
  }
7541
- /**
7542
- * Deterministic SHA-256 hash of an arbitrary serialisable value. The
7543
- * canonical form sorts object keys alphabetically at every depth so two
7544
- * structurally-equal inputs with different key insertion orders produce
7545
- * the same hash. Returns a 64-char lowercase hex digest.
7546
- *
7547
- * Used by export adapters (Alexa, HAP) to short-circuit re-discovery /
7548
- * accessory-rebuild work when the upstream shape is byte-identical to
7549
- * the last applied state — preventing user-visible "re-discovery"
7550
- * notifications on every addon-runner respawn. Each respawn re-fires
7551
- * `DeviceBindingsChanged` for every cap registration, which without
7552
- * this guard would propagate redundant pushes.
7553
- *
7554
- * Note: this is a SYMPTOMATIC fix layered on top of the binding-change
7555
- * subscription. The proper fix is a single "device ready" lifecycle
7556
- * barrier so exports react only when the full cap set has landed —
7557
- * tracked separately for post-HA-integration work.
7558
- */
7559
- function canonicalHash(value) {
7560
- const canonical = JSON.stringify(value, replaceWithSortedKeys);
7561
- return createHash("sha256").update(canonical ?? "").digest("hex");
7562
- }
7563
- function replaceWithSortedKeys(_key, value) {
7564
- if (value && typeof value === "object" && !Array.isArray(value)) {
7565
- const obj = value;
7566
- const out = {};
7567
- for (const k of Object.keys(obj).toSorted()) out[k] = obj[k];
7568
- return out;
7569
- }
7570
- return value;
7571
- }
7572
7541
  var DEFAULT_MAX_UNIT_BYTES = 16 * 1024 * 1024;
7573
7542
  /** Header size for a normal box, and for one carrying a 64-bit `largesize`. */
7574
7543
  var BOX_HEADER_BYTES = 8;
@@ -7741,6 +7710,37 @@ var Fmp4BoxSplitter = class {
7741
7710
  return [];
7742
7711
  }
7743
7712
  };
7713
+ /**
7714
+ * Deterministic SHA-256 hash of an arbitrary serialisable value. The
7715
+ * canonical form sorts object keys alphabetically at every depth so two
7716
+ * structurally-equal inputs with different key insertion orders produce
7717
+ * the same hash. Returns a 64-char lowercase hex digest.
7718
+ *
7719
+ * Used by export adapters (Alexa, HAP) to short-circuit re-discovery /
7720
+ * accessory-rebuild work when the upstream shape is byte-identical to
7721
+ * the last applied state — preventing user-visible "re-discovery"
7722
+ * notifications on every addon-runner respawn. Each respawn re-fires
7723
+ * `DeviceBindingsChanged` for every cap registration, which without
7724
+ * this guard would propagate redundant pushes.
7725
+ *
7726
+ * Note: this is a SYMPTOMATIC fix layered on top of the binding-change
7727
+ * subscription. The proper fix is a single "device ready" lifecycle
7728
+ * barrier so exports react only when the full cap set has landed —
7729
+ * tracked separately for post-HA-integration work.
7730
+ */
7731
+ function canonicalHash(value) {
7732
+ const canonical = JSON.stringify(value, replaceWithSortedKeys);
7733
+ return createHash("sha256").update(canonical ?? "").digest("hex");
7734
+ }
7735
+ function replaceWithSortedKeys(_key, value) {
7736
+ if (value && typeof value === "object" && !Array.isArray(value)) {
7737
+ const obj = value;
7738
+ const out = {};
7739
+ for (const k of Object.keys(obj).toSorted()) out[k] = obj[k];
7740
+ return out;
7741
+ }
7742
+ return value;
7743
+ }
7744
7744
  //#endregion
7745
7745
  //#region ../types/dist/err-msg-IQTHeDzc.mjs
7746
7746
  /**
@@ -10542,12 +10542,34 @@ var streamBrokerCapability = {
10542
10542
  kinds: array(EventMediaKindSchema).min(1).default(["mp4"]),
10543
10543
  /** GIF geometry. The video keeps the source's own. */
10544
10544
  gifMaxWidth: number().int().min(120).max(1280).default(640),
10545
- gifFps: number().int().min(1).max(15).default(8),
10546
10545
  /**
10547
- * Playback rate, applied to EVERY container so they stay one clip.
10548
- * `1` is real time and is what allows the copy branch.
10546
+ * The gif's own PLAYBACK rate in frames per second what the finished
10547
+ * gif runs at, not how many source frames feed it. The decimation that
10548
+ * feeds it samples `gifFps / gifSpeed` source frames per second, so at
10549
+ * the defaults a 12 fps gif is built out of 3 source frames a second.
10550
+ */
10551
+ gifFps: number().int().min(1).max(15).default(12),
10552
+ /**
10553
+ * How fast the GIF plays against real time, independent of `speed`.
10554
+ *
10555
+ * 4× by default, by operator request: a notification gif is glanced at
10556
+ * on a lock screen, so a ~12 s window has to be over in ~3 s. It stays
10557
+ * a separate knob from `speed` even though both now default to 4 —
10558
+ * a caller wanting a real-time video and a fast gif must not have to
10559
+ * choose.
10560
+ */
10561
+ gifSpeed: number().min(1).max(8).default(4),
10562
+ /**
10563
+ * Playback rate of the VIDEO. Also 4× by default, by operator decision.
10564
+ *
10565
+ * `1` is real time and is the ONLY value that allows the copy branch —
10566
+ * anything else forces `libx264` over the window. That was priced
10567
+ * before it was chosen: a per-event burst measured at 0.23 s and 254 KB
10568
+ * on a real 615 720p cut, against 922 KB for the copy it replaces. A
10569
+ * re-encode is capped at 720p (`EVENT_CLIP_ENCODE_MAX_WIDTH`), because
10570
+ * once the decode is forced the width stops being free.
10549
10571
  */
10550
- speed: number().min(1).max(8).default(1)
10572
+ speed: number().min(1).max(8).default(4)
10551
10573
  }), EventMediaProductionSchema, {
10552
10574
  kind: "mutation",
10553
10575
  auth: "admin"
@@ -10998,25 +11020,6 @@ var cameraStreamsCapability = {
10998
11020
  lastChangedAt: number()
10999
11021
  })
11000
11022
  };
11001
- /**
11002
- * core-blocks — user-authored TypeScript, stored in the kernel and executed in
11003
- * its own process.
11004
- *
11005
- * Spec: `docs/superpowers/specs/2026-08-04-core-blocks-and-synthetic-devices-design.md`.
11006
- *
11007
- * The first use is **owning devices without being a device provider**: a block
11008
- * declares devices under a system or custom integration and drives their state,
11009
- * with the same `ctx` an addon gets. Automations come later; nothing here
11010
- * models a trigger.
11011
- *
11012
- * **Stated plainly, because it does not change by being true:** a block has an
11013
- * addon's powers — devices, storage, the event bus, `ctx.api`. It is a plugin
11014
- * with no review step. What makes that survivable is not a sandbox, it is
11015
- * PROCESS ISOLATION: one process per block, supervised by `CrashSupervisor`,
11016
- * so a block that throws or never returns is marked `failed` and visible
11017
- * instead of taking the hub with it (D6). Every method here is admin-only, and
11018
- * must stay so.
11019
- */
11020
11023
  /** Where a block runs. The operator chooses — a block driving a device on an
11021
11024
  * agent is the reason placement is not fixed to the hub. */
11022
11025
  var CoreBlockPlacementSchema = union([literal("hub"), string().min(1)]);
@@ -11088,6 +11091,9 @@ method(object({}), object({ blocks: array(CoreBlockSchema) }), { auth: "admin" }
11088
11091
  }), object({ block: CoreBlockSchema }), {
11089
11092
  kind: "mutation",
11090
11093
  auth: "admin"
11094
+ }), method(object({ blockId: string() }), object({ block: CoreBlockSchema }), {
11095
+ kind: "mutation",
11096
+ auth: "admin"
11091
11097
  }), method(object({ code: string() }), CoreBlockCompileResultSchema, {
11092
11098
  kind: "mutation",
11093
11099
  auth: "admin"
@@ -11819,713 +11825,118 @@ var ExposeInputSchema = object({
11819
11825
  });
11820
11826
  var UnexposeInputSchema = object({ deviceId: string() });
11821
11827
  method(_void(), DeviceExportStatusSchema), method(_void(), array(DeviceKindSchema)), method(_void(), array(ExposedDeviceSchema)), method(ExposeInputSchema, _void(), { kind: "mutation" }), method(UnexposeInputSchema, _void(), { kind: "mutation" });
11828
+ var ProviderStatusSchema = object({
11829
+ connected: boolean(),
11830
+ deviceCount: number(),
11831
+ error: string().optional()
11832
+ });
11833
+ object({
11834
+ externalId: string(),
11835
+ name: string(),
11836
+ type: string(),
11837
+ metadata: record(string(), unknown()).optional()
11838
+ });
11822
11839
  /**
11823
- * Resource-bound constants for the safe expression engine.
11824
- *
11825
- * Every bound is defense-in-depth: the grammar is non-Turing-complete (no
11826
- * loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
11827
- * O(nodeCount) by construction. These caps merely put a hard ceiling on the
11828
- * work a single author-supplied expression can request, so a hostile or
11829
- * accidental pathological string can never spend unbounded CPU/memory.
11840
+ * Candidate handed back from discovery and accepted by
11841
+ * `adoptDiscoveredDevice`. Shape mirrors the in-process
11842
+ * `DiscoveredDevice` interface used by `DeviceDiscovery`.
11830
11843
  */
11831
- /** Max source length (chars) — checked BEFORE tokenizing so a huge string is
11832
- * rejected without allocation. */
11833
- var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
11834
- /** A legal binding / identifier name. */
11835
- var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
11836
- /** Binding names an author may NOT use: `now` is auto-injected; the literal
11837
- * keywords lex as values, not identifiers, so binding to them is meaningless. */
11838
- var RESERVED_BINDING_NAMES = new Set([
11839
- "now",
11840
- "true",
11841
- "false",
11842
- "null"
11843
- ]);
11844
+ var DiscoveryCandidateSchema = object({
11845
+ stableId: string(),
11846
+ type: _enum(DeviceType),
11847
+ suggestedName: string(),
11848
+ prefilledConfig: record(string(), unknown()),
11849
+ /**
11850
+ * Optional upstream-system identity (HA entity_id, vendor MAC, …).
11851
+ * Discovery pre-populates this for systems that know the upstream
11852
+ * identity ahead of adoption. Rendering metadata (unit, precision)
11853
+ * flows live through the cap STATUS SLICE after adoption.
11854
+ */
11855
+ sourceInfo: SourceInfoSchema.optional()
11856
+ });
11844
11857
  /**
11845
- * Error types for the safe expression engine. Two distinct classes so callers
11846
- * can tell a compile-time (grammar) failure from a runtime (evaluation)
11847
- * failure both are non-fatal to the host: read paths degrade to "skip link".
11858
+ * Flat device summary returned by `createDevice` / `adoptDiscoveredDevice`.
11859
+ * Mirrors `toDeviceShape()` output in `device-management.router.ts` so the
11860
+ * tRPC layer can pass it through without reshaping.
11848
11861
  */
11849
- /** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
11850
- * the failure is anchored to a character (author-facing inline feedback). */
11851
- var ExpressionParseError = class extends Error {
11852
- position;
11853
- constructor(message, position) {
11854
- super(message);
11855
- this.name = "ExpressionParseError";
11856
- this.position = position;
11857
- }
11858
- };
11859
- /** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
11860
- * result, unknown builtin, step-budget exceeded). */
11861
- var ExpressionEvalError = class extends Error {
11862
- constructor(message) {
11863
- super(message);
11864
- this.name = "ExpressionEvalError";
11865
- }
11866
- };
11862
+ var DeviceSummarySchema = object({
11863
+ id: number(),
11864
+ stableId: string(),
11865
+ addonId: string(),
11866
+ type: string(),
11867
+ name: string(),
11868
+ parentDeviceId: number().nullable(),
11869
+ online: boolean(),
11870
+ features: array(string()),
11871
+ config: record(string(), unknown()),
11872
+ /** Optional upstream-system identity (dispatch key + system tag).
11873
+ * See `SourceInfo`. Present when the device has a non-synthetic
11874
+ * source identifier (HA entities, vendor MAC, …); omitted when the
11875
+ * synthetic backfill is in effect. */
11876
+ sourceInfo: SourceInfoSchema.optional()
11877
+ });
11867
11878
  /**
11868
- * Frozen, null-prototype builtin function table for the expression engine
11869
- * (spec §4 rule 4). The table is the SOLE surface of callable functions: the
11870
- * parser rejects any callee not in it, and the evaluator gates each call on an
11871
- * own-property check against it.
11879
+ * Result of a live field test (e.g. probing an RTSP URL during device
11880
+ * creation). Matches the UI-side `FieldProbeResult` in
11881
+ * `interfaces/config-ui.ts` the admin `FormBuilder` renders the
11882
+ * returned `labels` as chips next to the input.
11883
+ */
11884
+ var FieldProbeResultSchema = object({
11885
+ status: _enum(["ok", "error"]),
11886
+ labels: array(string()).optional(),
11887
+ error: string().optional()
11888
+ });
11889
+ /**
11890
+ * The output of `getChildCreationSchema` is a UI schema tree. We store
11891
+ * it as `unknown` at the capability layer — the router just passes it
11892
+ * through and the admin UI renders it via `FormBuilder`. The actual
11893
+ * type is `ConfigUISchema` (see `packages/types/src/interfaces/config-ui.ts`),
11894
+ * but we deliberately avoid a Zod mirror because the union is large and
11895
+ * not meant for runtime validation at this seam.
11896
+ */
11897
+ var CreationSchemaOutputSchema = unknown();
11898
+ method(_void(), _void(), { kind: "mutation" }), method(_void(), _void(), { kind: "mutation" }), method(_void(), ProviderStatusSchema), method(_void(), array(object({
11899
+ id: string(),
11900
+ name: string(),
11901
+ type: string()
11902
+ }))), method(object({}), boolean()), method(object({ params: record(string(), unknown()).optional() }), array(DiscoveryCandidateSchema), {
11903
+ kind: "mutation",
11904
+ auth: "admin"
11905
+ }), method(object({}), CreationSchemaOutputSchema), method(object({}), object({ deviceType: _enum(DeviceType).nullable() })), method(object({ candidate: DiscoveryCandidateSchema }), DeviceSummarySchema, {
11906
+ kind: "mutation",
11907
+ auth: "admin"
11908
+ }), method(object({}), boolean()), method(object({ type: _enum(DeviceType) }), CreationSchemaOutputSchema), method(object({
11909
+ type: _enum(DeviceType),
11910
+ config: record(string(), unknown())
11911
+ }), DeviceSummarySchema, {
11912
+ kind: "mutation",
11913
+ auth: "admin"
11914
+ }), method(object({
11915
+ type: _enum(DeviceType),
11916
+ key: string(),
11917
+ value: unknown(),
11918
+ formValues: record(string(), unknown()).optional()
11919
+ }), FieldProbeResultSchema, {
11920
+ kind: "mutation",
11921
+ auth: "admin"
11922
+ });
11923
+ /**
11924
+ * Device Manager capability — hub-side singleton that unifies device persistence,
11925
+ * live registry access, and all management operations into a single tRPC surface.
11872
11926
  *
11873
- * Because the object has a NULL prototype AND is `Object.freeze`d:
11874
- * - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
11875
- * - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
11876
- * (there is no `Object.prototype` in the chain), so those names are not
11877
- * callable — they are simply "unknown function" at parse time.
11927
+ * Replaces:
11928
+ * - `device-persistence` capability (persistence methods absorbed here)
11929
+ * - `device-management.router.ts` (deleted in Phase 2)
11930
+ * - `device-ops.router.ts` (compat layer deleted; device-provider ops absorbed here)
11878
11931
  *
11879
- * Every numeric argument is validated as a finite number and every numeric
11880
- * RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
11881
- * (`pow(10,400)` Infinity) all raise `ExpressionEvalError` and fail the link
11882
- * closed rather than emitting a garbage value.
11883
- */
11884
- function asFiniteNumber(value, name, index) {
11885
- if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
11886
- return value;
11887
- }
11888
- function asString$1(value, name, index) {
11889
- if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
11890
- return value;
11891
- }
11892
- function finiteResult(value, name) {
11893
- if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
11894
- return value;
11895
- }
11896
- function allFiniteNumbers(args, name) {
11897
- return args.map((a, idx) => asFiniteNumber(a, name, idx));
11898
- }
11899
- var INF = Number.POSITIVE_INFINITY;
11900
- var table = {
11901
- min: {
11902
- minArgs: 1,
11903
- maxArgs: INF,
11904
- apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
11905
- },
11906
- max: {
11907
- minArgs: 1,
11908
- maxArgs: INF,
11909
- apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
11910
- },
11911
- abs: {
11912
- minArgs: 1,
11913
- maxArgs: 1,
11914
- apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
11915
- },
11916
- floor: {
11917
- minArgs: 1,
11918
- maxArgs: 1,
11919
- apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
11920
- },
11921
- ceil: {
11922
- minArgs: 1,
11923
- maxArgs: 1,
11924
- apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
11925
- },
11926
- sqrt: {
11927
- minArgs: 1,
11928
- maxArgs: 1,
11929
- apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
11930
- },
11931
- round: {
11932
- minArgs: 1,
11933
- maxArgs: 2,
11934
- apply: (args) => {
11935
- const x = asFiniteNumber(args[0], "round", 0);
11936
- const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
11937
- if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
11938
- const factor = 10 ** digits;
11939
- return finiteResult(Math.round(x * factor) / factor, "round");
11940
- }
11941
- },
11942
- pow: {
11943
- minArgs: 2,
11944
- maxArgs: 2,
11945
- apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
11946
- },
11947
- clamp: {
11948
- minArgs: 3,
11949
- maxArgs: 3,
11950
- apply: (args) => {
11951
- const x = asFiniteNumber(args[0], "clamp", 0);
11952
- const lo = asFiniteNumber(args[1], "clamp", 1);
11953
- const hi = asFiniteNumber(args[2], "clamp", 2);
11954
- if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
11955
- return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
11956
- }
11957
- },
11958
- avg: {
11959
- minArgs: 1,
11960
- maxArgs: INF,
11961
- apply: (args) => {
11962
- const nums = allFiniteNumbers(args, "avg");
11963
- return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
11964
- }
11965
- },
11966
- sum: {
11967
- minArgs: 1,
11968
- maxArgs: INF,
11969
- apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
11970
- },
11971
- coalesce: {
11972
- minArgs: 1,
11973
- maxArgs: INF,
11974
- apply: (args) => {
11975
- for (const a of args) if (a !== null) return a;
11976
- return null;
11977
- }
11978
- },
11979
- age: {
11980
- minArgs: 2,
11981
- maxArgs: 2,
11982
- apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
11983
- },
11984
- convert: {
11985
- minArgs: 3,
11986
- maxArgs: 3,
11987
- apply: (args, hooks) => {
11988
- const x = asFiniteNumber(args[0], "convert", 0);
11989
- const from = asString$1(args[1], "convert", 1).trim();
11990
- const to = asString$1(args[2], "convert", 2).trim();
11991
- if (hooks.convert) {
11992
- const out = hooks.convert(x, from, to);
11993
- if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
11994
- return finiteResult(out, "convert");
11995
- }
11996
- if (from === to) return x;
11997
- throw new ExpressionEvalError("convert: unit conversion table not installed");
11998
- }
11999
- }
12000
- };
12001
- Object.freeze(Object.assign(Object.create(null), table));
12002
- /** The set of valid builtin names — used by the parser to reject unknown
12003
- * callees at parse time (immediate author feedback). */
12004
- var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
12005
- /**
12006
- * Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
12007
- * zero-dependency. The grammar is deliberately boring: decimal numbers,
12008
- * single/double-quoted strings with a tiny escape set, identifiers, the three
12009
- * value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
12010
- * outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
12011
- * is a parse error with a source position, so member access / assignment /
12012
- * template literals are lexically impossible.
12013
- */
12014
- var KEYWORDS = new Set([
12015
- "true",
12016
- "false",
12017
- "null"
12018
- ]);
12019
- function isDigit(ch) {
12020
- return ch >= "0" && ch <= "9";
12021
- }
12022
- function isIdentStart(ch) {
12023
- return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
12024
- }
12025
- function isIdentPart(ch) {
12026
- return isIdentStart(ch) || isDigit(ch);
12027
- }
12028
- function isWhitespace(ch) {
12029
- return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
12030
- }
12031
- /** Tokenize `source` into a flat token list ending with a single `eof` token.
12032
- * Throws `ExpressionParseError` on any illegal character or unterminated
12033
- * string. */
12034
- function tokenize(source) {
12035
- if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
12036
- const tokens = [];
12037
- let i = 0;
12038
- const n = source.length;
12039
- while (i < n) {
12040
- const ch = source[i];
12041
- if (isWhitespace(ch)) {
12042
- i += 1;
12043
- continue;
12044
- }
12045
- if (isDigit(ch)) {
12046
- const start = i;
12047
- while (i < n && isDigit(source[i])) i += 1;
12048
- if (i < n && source[i] === ".") {
12049
- if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
12050
- i += 1;
12051
- while (i < n && isDigit(source[i])) i += 1;
12052
- }
12053
- const text = source.slice(start, i);
12054
- const value = Number(text);
12055
- if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
12056
- tokens.push({
12057
- type: "number",
12058
- value,
12059
- pos: start
12060
- });
12061
- continue;
12062
- }
12063
- if (ch === "'" || ch === "\"") {
12064
- const quote = ch;
12065
- const start = i;
12066
- i += 1;
12067
- let out = "";
12068
- let closed = false;
12069
- while (i < n) {
12070
- const c = source[i];
12071
- if (c === "\\") {
12072
- const next = i + 1 < n ? source[i + 1] : "";
12073
- if (next === "\\" || next === "'" || next === "\"") {
12074
- out += next;
12075
- i += 2;
12076
- continue;
12077
- }
12078
- throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
12079
- }
12080
- if (c === quote) {
12081
- closed = true;
12082
- i += 1;
12083
- break;
12084
- }
12085
- out += c;
12086
- i += 1;
12087
- }
12088
- if (!closed) throw new ExpressionParseError("unterminated string literal", start);
12089
- tokens.push({
12090
- type: "string",
12091
- value: out,
12092
- pos: start
12093
- });
12094
- continue;
12095
- }
12096
- if (isIdentStart(ch)) {
12097
- const start = i;
12098
- while (i < n && isIdentPart(source[i])) i += 1;
12099
- const text = source.slice(start, i);
12100
- if (KEYWORDS.has(text)) tokens.push({
12101
- type: "keyword",
12102
- keyword: keywordOf(text),
12103
- pos: start
12104
- });
12105
- else tokens.push({
12106
- type: "identifier",
12107
- name: text,
12108
- pos: start
12109
- });
12110
- continue;
12111
- }
12112
- const two = i + 1 < n ? source.slice(i, i + 2) : "";
12113
- if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
12114
- tokens.push({
12115
- type: "punct",
12116
- punct: two,
12117
- pos: i
12118
- });
12119
- i += 2;
12120
- continue;
12121
- }
12122
- if (isSinglePunct(ch)) {
12123
- tokens.push({
12124
- type: "punct",
12125
- punct: ch,
12126
- pos: i
12127
- });
12128
- i += 1;
12129
- continue;
12130
- }
12131
- throw new ExpressionParseError(`unexpected character '${ch}'`, i);
12132
- }
12133
- tokens.push({
12134
- type: "eof",
12135
- pos: n
12136
- });
12137
- return tokens;
12138
- }
12139
- function keywordOf(text) {
12140
- if (text === "true") return "true";
12141
- if (text === "false") return "false";
12142
- return "null";
12143
- }
12144
- function isSinglePunct(ch) {
12145
- return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
12146
- }
12147
- /**
12148
- * Pratt (precedence-climbing) parser for the safe expression mini-language.
12149
- *
12150
- * Precedence (low → high): ternary `?:` (right-assoc) → `||` → `&&` → equality
12151
- * → relational → additive → multiplicative → unary `! -` → call / primary.
12152
- * Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
12153
- * string validated against the builtin table at parse time, so an unknown
12154
- * function is rejected immediately (author feedback) and a persisted expression
12155
- * that references a since-removed builtin degrades at read.
12156
- *
12157
- * A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
12158
- * arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
12159
- */
12160
- /** Binary/logical operator precedence (higher binds tighter). */
12161
- var BINARY_PRECEDENCE = {
12162
- "||": 1,
12163
- "&&": 2,
12164
- "==": 3,
12165
- "!=": 3,
12166
- "<": 4,
12167
- "<=": 4,
12168
- ">": 4,
12169
- ">=": 4,
12170
- "+": 5,
12171
- "-": 5,
12172
- "*": 6,
12173
- "/": 6,
12174
- "%": 6
12175
- };
12176
- function isLogicalOp(op) {
12177
- return op === "&&" || op === "||";
12178
- }
12179
- function isBinaryOp(op) {
12180
- return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
12181
- }
12182
- var Parser = class {
12183
- tokens;
12184
- pos = 0;
12185
- nodeCount = 0;
12186
- identifiers = /* @__PURE__ */ new Set();
12187
- callees = /* @__PURE__ */ new Set();
12188
- constructor(tokens) {
12189
- this.tokens = tokens;
12190
- }
12191
- parse() {
12192
- const ast = this.parseTernary();
12193
- const tok = this.peek();
12194
- if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
12195
- return {
12196
- ast,
12197
- identifiers: this.identifiers,
12198
- callees: this.callees,
12199
- nodeCount: this.nodeCount
12200
- };
12201
- }
12202
- peek() {
12203
- return this.tokens[this.pos];
12204
- }
12205
- next() {
12206
- return this.tokens[this.pos++];
12207
- }
12208
- /** Consume a punctuator token, erroring if the next token isn't it. */
12209
- expectPunct(punct) {
12210
- const tok = this.peek();
12211
- if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
12212
- this.pos += 1;
12213
- }
12214
- matchPunct(punct) {
12215
- const tok = this.peek();
12216
- if (tok.type === "punct" && tok.punct === punct) {
12217
- this.pos += 1;
12218
- return true;
12219
- }
12220
- return false;
12221
- }
12222
- countNode() {
12223
- this.nodeCount += 1;
12224
- if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
12225
- }
12226
- parseTernary() {
12227
- const test = this.parseBinary(1);
12228
- if (this.matchPunct("?")) {
12229
- const consequent = this.parseTernary();
12230
- this.expectPunct(":");
12231
- const alternate = this.parseTernary();
12232
- this.countNode();
12233
- return {
12234
- kind: "conditional",
12235
- test,
12236
- consequent,
12237
- alternate
12238
- };
12239
- }
12240
- return test;
12241
- }
12242
- parseBinary(minPrec) {
12243
- let left = this.parseUnary();
12244
- for (;;) {
12245
- const tok = this.peek();
12246
- if (tok.type !== "punct") break;
12247
- const prec = BINARY_PRECEDENCE[tok.punct];
12248
- if (prec === void 0 || prec < minPrec) break;
12249
- const op = tok.punct;
12250
- this.pos += 1;
12251
- const right = this.parseBinary(prec + 1);
12252
- this.countNode();
12253
- if (isLogicalOp(op)) left = {
12254
- kind: "logical",
12255
- op,
12256
- left,
12257
- right
12258
- };
12259
- else if (isBinaryOp(op)) left = {
12260
- kind: "binary",
12261
- op,
12262
- left,
12263
- right
12264
- };
12265
- else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
12266
- }
12267
- return left;
12268
- }
12269
- parseUnary() {
12270
- const tok = this.peek();
12271
- if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
12272
- const op = tok.punct;
12273
- this.pos += 1;
12274
- const operand = this.parseUnary();
12275
- this.countNode();
12276
- return {
12277
- kind: "unary",
12278
- op,
12279
- operand
12280
- };
12281
- }
12282
- return this.parsePrimary();
12283
- }
12284
- parsePrimary() {
12285
- const tok = this.next();
12286
- switch (tok.type) {
12287
- case "number":
12288
- this.countNode();
12289
- return {
12290
- kind: "literal",
12291
- value: tok.value
12292
- };
12293
- case "string":
12294
- this.countNode();
12295
- return {
12296
- kind: "literal",
12297
- value: tok.value
12298
- };
12299
- case "keyword":
12300
- this.countNode();
12301
- return {
12302
- kind: "literal",
12303
- value: tok.keyword === "null" ? null : tok.keyword === "true"
12304
- };
12305
- case "identifier": {
12306
- const nextTok = this.peek();
12307
- if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
12308
- this.identifiers.add(tok.name);
12309
- this.countNode();
12310
- return {
12311
- kind: "identifier",
12312
- name: tok.name
12313
- };
12314
- }
12315
- case "punct":
12316
- if (tok.punct === "(") {
12317
- const inner = this.parseTernary();
12318
- this.expectPunct(")");
12319
- return inner;
12320
- }
12321
- throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
12322
- case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
12323
- }
12324
- }
12325
- parseCall(callee, pos) {
12326
- if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
12327
- this.expectPunct("(");
12328
- const args = [];
12329
- if (!this.matchPunct(")")) for (;;) {
12330
- args.push(this.parseTernary());
12331
- if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
12332
- if (this.matchPunct(",")) continue;
12333
- this.expectPunct(")");
12334
- break;
12335
- }
12336
- this.callees.add(callee);
12337
- this.countNode();
12338
- return {
12339
- kind: "call",
12340
- callee,
12341
- args
12342
- };
12343
- }
12344
- };
12345
- /** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
12346
- * `ExpressionParseError` on any lexical or grammatical failure. */
12347
- function parseExpression(source) {
12348
- return new Parser(tokenize(source)).parse();
12349
- }
12350
- /**
12351
- * LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
12352
- * by expr"). The cache stores BOTH successes and failures (negative caching),
12353
- * so a corrupt persisted string costs exactly one tokenize+parse total — not
12354
- * one per read on a hot resolve path.
12355
- *
12356
- * The cache is a module-level singleton: entries are pure, content-addressed
12357
- * ASTs keyed by the raw source string, so sharing one instance across all
12358
- * callers is safe and maximises hit rate.
12359
- */
12360
- var cache = /* @__PURE__ */ new Map();
12361
- function getCached(source) {
12362
- const hit = cache.get(source);
12363
- if (hit !== void 0) {
12364
- cache.delete(source);
12365
- cache.set(source, hit);
12366
- return hit;
12367
- }
12368
- let result;
12369
- try {
12370
- result = {
12371
- ok: true,
12372
- parsed: parseExpression(source)
12373
- };
12374
- } catch (err) {
12375
- result = {
12376
- ok: false,
12377
- error: err instanceof ExpressionParseError ? err.message : String(err)
12378
- };
12379
- }
12380
- cache.set(source, result);
12381
- if (cache.size > 256) {
12382
- const oldest = cache.keys().next().value;
12383
- if (oldest !== void 0) cache.delete(oldest);
12384
- }
12385
- return result;
12386
- }
12387
- /** Compile `source`, returning a discriminated result instead of throwing.
12388
- * Used by read paths that must degrade rather than raise. LRU/negative-cached. */
12389
- function compileExpressionSafe(source) {
12390
- return getCached(source);
12391
- }
12392
- Object.freeze({});
12393
- /**
12394
- * Author-time validation. Returns `null` when the source is valid, else a
12395
- * human-readable error message. Checks: the expression compiles; binding count
12396
- * is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
12397
- * is not reserved (`now`/keywords) and does not shadow a builtin; and every
12398
- * FREE identifier of the AST is covered by a binding or the injected `now`.
12399
- */
12400
- function validateExpressionSource(src) {
12401
- const names = Object.keys(src.bindings);
12402
- if (names.length > 32) return `too many bindings (${names.length} > 32)`;
12403
- for (const name of names) {
12404
- if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
12405
- if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
12406
- if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
12407
- }
12408
- const compiled = compileExpressionSafe(src.expr);
12409
- if (!compiled.ok) return compiled.error;
12410
- const bound = new Set(names);
12411
- for (const id of compiled.parsed.identifiers) {
12412
- if (id === "now") continue;
12413
- if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
12414
- }
12415
- return null;
12416
- }
12417
- var ProviderStatusSchema = object({
12418
- connected: boolean(),
12419
- deviceCount: number(),
12420
- error: string().optional()
12421
- });
12422
- object({
12423
- externalId: string(),
12424
- name: string(),
12425
- type: string(),
12426
- metadata: record(string(), unknown()).optional()
12427
- });
12428
- /**
12429
- * Candidate handed back from discovery and accepted by
12430
- * `adoptDiscoveredDevice`. Shape mirrors the in-process
12431
- * `DiscoveredDevice` interface used by `DeviceDiscovery`.
12432
- */
12433
- var DiscoveryCandidateSchema = object({
12434
- stableId: string(),
12435
- type: _enum(DeviceType),
12436
- suggestedName: string(),
12437
- prefilledConfig: record(string(), unknown()),
12438
- /**
12439
- * Optional upstream-system identity (HA entity_id, vendor MAC, …).
12440
- * Discovery pre-populates this for systems that know the upstream
12441
- * identity ahead of adoption. Rendering metadata (unit, precision)
12442
- * flows live through the cap STATUS SLICE after adoption.
12443
- */
12444
- sourceInfo: SourceInfoSchema.optional()
12445
- });
12446
- /**
12447
- * Flat device summary returned by `createDevice` / `adoptDiscoveredDevice`.
12448
- * Mirrors `toDeviceShape()` output in `device-management.router.ts` so the
12449
- * tRPC layer can pass it through without reshaping.
12450
- */
12451
- var DeviceSummarySchema = object({
12452
- id: number(),
12453
- stableId: string(),
12454
- addonId: string(),
12455
- type: string(),
12456
- name: string(),
12457
- parentDeviceId: number().nullable(),
12458
- online: boolean(),
12459
- features: array(string()),
12460
- config: record(string(), unknown()),
12461
- /** Optional upstream-system identity (dispatch key + system tag).
12462
- * See `SourceInfo`. Present when the device has a non-synthetic
12463
- * source identifier (HA entities, vendor MAC, …); omitted when the
12464
- * synthetic backfill is in effect. */
12465
- sourceInfo: SourceInfoSchema.optional()
12466
- });
12467
- /**
12468
- * Result of a live field test (e.g. probing an RTSP URL during device
12469
- * creation). Matches the UI-side `FieldProbeResult` in
12470
- * `interfaces/config-ui.ts` — the admin `FormBuilder` renders the
12471
- * returned `labels` as chips next to the input.
12472
- */
12473
- var FieldProbeResultSchema = object({
12474
- status: _enum(["ok", "error"]),
12475
- labels: array(string()).optional(),
12476
- error: string().optional()
12477
- });
12478
- /**
12479
- * The output of `getChildCreationSchema` is a UI schema tree. We store
12480
- * it as `unknown` at the capability layer — the router just passes it
12481
- * through and the admin UI renders it via `FormBuilder`. The actual
12482
- * type is `ConfigUISchema` (see `packages/types/src/interfaces/config-ui.ts`),
12483
- * but we deliberately avoid a Zod mirror because the union is large and
12484
- * not meant for runtime validation at this seam.
12485
- */
12486
- var CreationSchemaOutputSchema = unknown();
12487
- method(_void(), _void(), { kind: "mutation" }), method(_void(), _void(), { kind: "mutation" }), method(_void(), ProviderStatusSchema), method(_void(), array(object({
12488
- id: string(),
12489
- name: string(),
12490
- type: string()
12491
- }))), method(object({}), boolean()), method(object({ params: record(string(), unknown()).optional() }), array(DiscoveryCandidateSchema), {
12492
- kind: "mutation",
12493
- auth: "admin"
12494
- }), method(object({}), CreationSchemaOutputSchema), method(object({}), object({ deviceType: _enum(DeviceType).nullable() })), method(object({ candidate: DiscoveryCandidateSchema }), DeviceSummarySchema, {
12495
- kind: "mutation",
12496
- auth: "admin"
12497
- }), method(object({}), boolean()), method(object({ type: _enum(DeviceType) }), CreationSchemaOutputSchema), method(object({
12498
- type: _enum(DeviceType),
12499
- config: record(string(), unknown())
12500
- }), DeviceSummarySchema, {
12501
- kind: "mutation",
12502
- auth: "admin"
12503
- }), method(object({
12504
- type: _enum(DeviceType),
12505
- key: string(),
12506
- value: unknown(),
12507
- formValues: record(string(), unknown()).optional()
12508
- }), FieldProbeResultSchema, {
12509
- kind: "mutation",
12510
- auth: "admin"
12511
- });
12512
- /**
12513
- * Device Manager capability — hub-side singleton that unifies device persistence,
12514
- * live registry access, and all management operations into a single tRPC surface.
12515
- *
12516
- * Replaces:
12517
- * - `device-persistence` capability (persistence methods absorbed here)
12518
- * - `device-management.router.ts` (deleted in Phase 2)
12519
- * - `device-ops.router.ts` (compat layer — deleted; device-provider ops absorbed here)
12520
- *
12521
- * All device provider addons (rtsp, onvif, frigate, …) are hub-local: they may
12522
- * fork into separate processes but never run on remote cluster agents. Therefore:
12523
- * - No nodeId routing needed — this is a pure hub singleton.
12524
- * - The hub's DeviceRegistry is the single source of truth for all live devices.
12525
- * - No shadow registry or cross-node aggregation required.
12526
- *
12527
- * Forked workers register devices back to the hub via `ctx.devices`
12528
- * (DeviceManagerApi → ctx.api.deviceManager.registerDevice), same as today.
11932
+ * All device provider addons (rtsp, onvif, frigate, …) are hub-local: they may
11933
+ * fork into separate processes but never run on remote cluster agents. Therefore:
11934
+ * - No nodeId routing needed this is a pure hub singleton.
11935
+ * - The hub's DeviceRegistry is the single source of truth for all live devices.
11936
+ * - No shadow registry or cross-node aggregation required.
11937
+ *
11938
+ * Forked workers register devices back to the hub via `ctx.devices`
11939
+ * (DeviceManagerApi → ctx.api.deviceManager.registerDevice), same as today.
12529
11940
  */
12530
11941
  /** One child-placement directive on a container's `childLayout`. Structurally
12531
11942
  * identical to `ChildLayoutEntry` in `device-management.ts` — the cap wire
@@ -12538,92 +11949,6 @@ var ChildLayoutEntrySchema = object({
12538
11949
  order: number().optional(),
12539
11950
  collapsed: boolean().optional()
12540
11951
  });
12541
- /** Cap-wire shape of a DeviceLink — structurally mirrors `DeviceLink` in
12542
- * `device-management.ts`. Source is a union: a FIELD source copies a sibling
12543
- * accessory's status field (`kind` optional/absent for wire compat); a
12544
- * LITERAL source carries a per-device constant (no sibling is read); a
12545
- * GLOBAL source (P2e) copies ANY device's status field, addressed by the
12546
- * source device's full re-sync-stable `stableId`. */
12547
- var DeviceLinkFieldSourceSchema = object({
12548
- kind: literal("field").optional(),
12549
- sourceKey: string(),
12550
- cap: string(),
12551
- fieldPath: string()
12552
- });
12553
- var DeviceLinkLiteralSourceSchema = object({
12554
- kind: literal("literal"),
12555
- value: union([
12556
- string(),
12557
- number(),
12558
- boolean(),
12559
- _null()
12560
- ])
12561
- });
12562
- var DeviceLinkGlobalSourceSchema = object({
12563
- kind: literal("global"),
12564
- sourceStableId: string(),
12565
- cap: string(),
12566
- fieldPath: string()
12567
- });
12568
- /** Expression source (Stage X): compute the target field from N named bindings
12569
- * via the safe expression engine. Bindings are field | literal | global — never
12570
- * another expression (no nesting). The `superRefine` runs the SAME author-time
12571
- * validation as `validateExpressionSource` (compiles the expr, checks binding
12572
- * names + identifier coverage) so every wire boundary that parses a DeviceLink
12573
- * (tRPC mount, kernel create pre-seed, projection output) validates-at-write.
12574
- * Compiles are LRU-cached, so repeated validation of the same expr is a hit. */
12575
- var DeviceLinkExpressionSourceSchema = object({
12576
- kind: literal("expression"),
12577
- expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
12578
- bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), union([
12579
- DeviceLinkFieldSourceSchema,
12580
- DeviceLinkLiteralSourceSchema,
12581
- DeviceLinkGlobalSourceSchema
12582
- ]))
12583
- }).superRefine((src, ctx) => {
12584
- const err = validateExpressionSource(src);
12585
- if (err !== null) ctx.addIssue({
12586
- code: "custom",
12587
- message: err,
12588
- path: ["expr"]
12589
- });
12590
- });
12591
- var DeviceLinkSchema = object({
12592
- id: string(),
12593
- source: union([
12594
- DeviceLinkFieldSourceSchema,
12595
- DeviceLinkLiteralSourceSchema,
12596
- DeviceLinkGlobalSourceSchema,
12597
- DeviceLinkExpressionSourceSchema
12598
- ]),
12599
- target: object({
12600
- cap: string(),
12601
- fieldPath: string(),
12602
- itemKey: string().optional()
12603
- }),
12604
- transform: discriminatedUnion("kind", [
12605
- object({ kind: literal("identity") }),
12606
- object({
12607
- kind: literal("enum-map"),
12608
- mapping: record(string(), union([
12609
- string(),
12610
- number(),
12611
- boolean()
12612
- ])),
12613
- fallback: union([
12614
- string(),
12615
- number(),
12616
- boolean()
12617
- ]).optional()
12618
- }),
12619
- object({
12620
- kind: literal("linear"),
12621
- scale: number(),
12622
- offset: number(),
12623
- clamp: tuple([number(), number()]).readonly().optional()
12624
- })
12625
- ]).optional()
12626
- });
12627
11952
  /** Cap-wire shape of a per-cap display refinement — mirrors
12628
11953
  * `DeviceCapDisplayOverride` in `device-management.ts`. */
12629
11954
  var DeviceCapDisplayOverrideSchema = object({
@@ -12703,8 +12028,6 @@ var DeviceInfoSchema = object({
12703
12028
  * named accordion sections (with optional intra-section order). See
12704
12029
  * `DeviceMeta.childLayout`. Absent ⇒ no layout declared. */
12705
12030
  childLayout: array(ChildLayoutEntrySchema).readonly().optional(),
12706
- /** Operator-authored cross-device field wirings. See `DeviceMeta.deviceLinks`. */
12707
- deviceLinks: array(DeviceLinkSchema).readonly().optional(),
12708
12031
  /** Operator-authored per-device display override. See `DeviceMeta.display`. */
12709
12032
  display: DeviceDisplayOverrideSchema.optional()
12710
12033
  });
@@ -12713,7 +12036,7 @@ var ConfigEntrySchema = object({
12713
12036
  value: unknown(),
12714
12037
  description: string().optional()
12715
12038
  });
12716
- var DeviceLinkModeSchema = _enum(["auto", "manual"]);
12039
+ var LinkedDevicesModeSchema = _enum(["auto", "manual"]);
12717
12040
  /** One resolved linked device — the compact projection consumers need. */
12718
12041
  var LinkedDeviceSchema = object({
12719
12042
  deviceId: number(),
@@ -12776,8 +12099,6 @@ var DeviceMetaSchema = object({
12776
12099
  * accordion sections (with optional intra-section order). See
12777
12100
  * `DeviceMeta.childLayout`. Absent ⇒ no layout declared. */
12778
12101
  childLayout: array(ChildLayoutEntrySchema).readonly().optional(),
12779
- /** Operator-authored cross-device field wirings. See `DeviceMeta.deviceLinks`. */
12780
- deviceLinks: array(DeviceLinkSchema).readonly().optional(),
12781
12102
  /** Semantic role string (`DeviceRole`) — propagated from the spawn pre-seed.
12782
12103
  * Optional: only present for accessory children that carry a known role. */
12783
12104
  role: string().nullable().optional(),
@@ -12870,12 +12191,6 @@ method(object({
12870
12191
  }), _void(), {
12871
12192
  kind: "mutation",
12872
12193
  auth: "admin"
12873
- }), method(object({
12874
- deviceId: number(),
12875
- deviceLinks: array(DeviceLinkSchema).readonly()
12876
- }), _void(), {
12877
- kind: "mutation",
12878
- auth: "admin"
12879
12194
  }), method(object({
12880
12195
  deviceId: number(),
12881
12196
  display: DeviceDisplayOverrideSchema.nullable()
@@ -12957,7 +12272,7 @@ method(object({
12957
12272
  * shipping 293 rows to find 12. */
12958
12273
  isCamera: boolean().optional()
12959
12274
  }), array(DeviceInfoSchema)), method(object({ deviceId: number() }), DeviceInfoSchema.nullable()), method(object({ parentDeviceId: number() }), array(DeviceInfoSchema)), method(object({ deviceId: number() }), object({
12960
- mode: DeviceLinkModeSchema,
12275
+ mode: LinkedDevicesModeSchema,
12961
12276
  devices: array(LinkedDeviceSchema)
12962
12277
  })), method(object({ deviceId: number() }), array(StreamSourceEntrySchema$1)), method(object({ deviceId: number() }), array(ConfigEntrySchema)), method(object({ deviceId: number() }), ConfigUISchemaOutput), method(object({
12963
12278
  deviceId: number(),
@@ -12990,11 +12305,7 @@ method(object({
12990
12305
  deviceId: number(),
12991
12306
  entries: array(object({
12992
12307
  capName: string(),
12993
- kind: _enum([
12994
- "native",
12995
- "wrapped",
12996
- "linked"
12997
- ]),
12308
+ kind: _enum(["native", "wrapped"]),
12998
12309
  providerAddonId: string(),
12999
12310
  providerNodeId: string(),
13000
12311
  nativeAddonId: string()
@@ -13003,11 +12314,7 @@ method(object({
13003
12314
  deviceId: number(),
13004
12315
  entries: array(object({
13005
12316
  capName: string(),
13006
- kind: _enum([
13007
- "native",
13008
- "wrapped",
13009
- "linked"
13010
- ]),
12317
+ kind: _enum(["native", "wrapped"]),
13011
12318
  providerAddonId: string(),
13012
12319
  providerNodeId: string(),
13013
12320
  nativeAddonId: string()
@@ -13816,6 +13123,10 @@ var motionDetectionCapability = {
13816
13123
  mode: "singleton",
13817
13124
  kind: "wrapper",
13818
13125
  defaultActive: true,
13126
+ /** Frame-differencing motion analysis over a video stream — only a camera
13127
+ * produces the frames. Read by the `defaultActive` auto-bind in
13128
+ * `device-manager.getBindings` to decide which devices it may claim. */
13129
+ deviceTypes: [DeviceType.Camera],
13819
13130
  exposesDeviceSettings: true,
13820
13131
  methods: {
13821
13132
  analyze: method(object({
@@ -16338,6 +15649,18 @@ var OauthIntegrationDescriptorSchema = object({
16338
15649
  * redirect_uri that does not start with one of these. Required —
16339
15650
  * an empty list means the integration can never complete linking. */
16340
15651
  allowedRedirectPrefixes: array(string()).min(1),
15652
+ /** Paths accepted as a `redirect_uri` when the host is PRIVATE — loopback,
15653
+ * RFC1918, CGNAT (100.64/10, Tailscale), link-local, IPv6 ULA, or an
15654
+ * `.local` / `.internal` / `.ts.net` name. Exists for self-hosted clients
15655
+ * whose address the hub cannot know in advance (a Home Assistant at
15656
+ * `http://<lan-ip>:8123/auth/external/callback`). The PATH must match
15657
+ * exactly; a public host never satisfies this branch, so it is not a
15658
+ * wildcard prefix by another name. */
15659
+ allowedPrivateHostPaths: array(string()).optional(),
15660
+ /** When true this is a PUBLIC client (source is published, no secret can be
15661
+ * protected) and PKCE is mandatory: `/authorize` refuses without an S256
15662
+ * `code_challenge`, `/token` refuses without the matching `code_verifier`. */
15663
+ requiresPkce: boolean().optional(),
16341
15664
  /** Optional public origin (no trailing slash) that this integration's
16342
15665
  * issued codes/tokens should carry as the `hubUrl` claim — typically the
16343
15666
  * operator-selected external-access endpoint resolved by the addon. When
@@ -16488,7 +15811,7 @@ var TrackEnvelopeSchema = object({
16488
15811
  * `snapshots[]` references — megabytes across a page of tracks. `slim`
16489
15812
  * keeps every scalar the list surfaces actually render (ids, class(es),
16490
15813
  * label / audioLabels / importance enrichment, firstSeen/lastSeen, state,
16491
- * zonesVisited, bestEventId, envelope) and returns `positions` /
15814
+ * zonesVisited, bestEventId, envelope, hasFace) and returns `positions` /
16492
15815
  * `snapshots` as EMPTY arrays — detail views re-fetch the full row via
16493
15816
  * `getTrack`. Mirrors the event-store `projection` convention
16494
15817
  * (`getObjectEvents` et al.).
@@ -16725,6 +16048,24 @@ var TrackSchema = object({
16725
16048
  * Populated from the persisted envelope columns on historical reads;
16726
16049
  * absent on legacy rows, dims-less tracks and active (in-RAM) tracks. */
16727
16050
  envelope: TrackEnvelopeSchema.optional(),
16051
+ /**
16052
+ * A face DETECTOR found a face on this track — nothing more. It says the
16053
+ * detail plane produced a `face` detail; it does NOT say the face was
16054
+ * embedded, matched, above `minFacePx`, or that the recognizer was even
16055
+ * enabled. Set once and never cleared.
16056
+ *
16057
+ * **This exists so "face present but not recognised" is expressible.** A
16058
+ * recognised identity lands in `subLabel` (attributed to the face chain via
16059
+ * `subLabelMeta.stepId`), so before this field a track with an unmatched face
16060
+ * and a track with no face at all were byte-identical on the wire and no
16061
+ * surface could tell them apart. The read is `hasFace === true && subLabel
16062
+ * === undefined`.
16063
+ *
16064
+ * **Absent ≠ false.** Every row written before the column existed omits it,
16065
+ * and so does every server that predates the field — a consumer must test
16066
+ * `=== true` and render nothing otherwise, never infer "no face".
16067
+ */
16068
+ hasFace: boolean().optional(),
16728
16069
  ...TrackFlagFields,
16729
16070
  ...TrackRetrainFields
16730
16071
  });
@@ -19633,6 +18974,10 @@ var SsoBridgeClaimsSchema = object({
19633
18974
  integrationId: string().optional(),
19634
18975
  /** JWT ID — unique per issued code; consumed-set enforces single-use. */
19635
18976
  jti: string().optional(),
18977
+ /** PKCE S256 challenge — set only on `oauth-code` tokens issued to a public
18978
+ * client. Its PRESENCE is what makes the verifier mandatory at exchange,
18979
+ * so the requirement travels with the code and not with mutable config. */
18980
+ codeChallenge: string().optional(),
19636
18981
  /** OAuth session registry id — set on `oauth-access`/`oauth-refresh`
19637
18982
  * tokens so the verify path can check the session is not revoked. */
19638
18983
  sessionId: string().optional()
@@ -20200,7 +19545,7 @@ var ClipPlaybackSchema = object({
20200
19545
  playbackEndpoints: array(string()).optional(),
20201
19546
  token: string().optional()
20202
19547
  });
20203
- method(object({
19548
+ DeviceType.Camera, method(object({
20204
19549
  deviceId: number(),
20205
19550
  since: number(),
20206
19551
  until: number(),
@@ -21792,7 +21137,29 @@ var FaceInfoSchema = object({
21792
21137
  recognizedIdentityId: string().optional(),
21793
21138
  identityName: string().optional(),
21794
21139
  assigned: boolean(),
21140
+ /**
21141
+ * The crop, inline, base64.
21142
+ *
21143
+ * **Prefer {@link cropUrl}.** At the 500 rows the Faces view asks for this
21144
+ * field alone is ~2.87 MiB, re-sent in full on every operator assign and
21145
+ * every 30 s poll, base64-inflated over the msgpack socket and held in the
21146
+ * query heap. It stays for callers that have not migrated; `includeCrops:
21147
+ * false` turns it off once they have.
21148
+ */
21795
21149
  base64: string().optional(),
21150
+ /**
21151
+ * Same crop, as a data-plane URL for `<img src>` — the move the admin
21152
+ * snapshot surfaces made on 2026-08-08.
21153
+ *
21154
+ * Served by the `event-media` plane, which resolves a raw MediaStore key and
21155
+ * is `access: 'authenticated'`: a bare `<img>` carries the `camstack_session`
21156
+ * cookie, so no header plumbing is needed. The bytes then ride the browser's
21157
+ * HTTP cache with an ETag and `immutable`, instead of the WebSocket.
21158
+ *
21159
+ * Absent when the face has no stored crop, or when the addon has no data
21160
+ * plane — callers fall back to {@link base64}.
21161
+ */
21162
+ cropUrl: string().optional(),
21796
21163
  /** Design B: the face bbox (pixel space) on the key frame — lets a detail
21797
21164
  * view draw the box over the native `keyFrameMediaKey` frame. Absent on
21798
21165
  * legacy rows written before design B. */
@@ -21857,7 +21224,23 @@ method(_void(), array(IdentitySchema).readonly()), method(object({ name: string(
21857
21224
  auth: "admin"
21858
21225
  }), method(object({
21859
21226
  limit: number().int().positive().optional(),
21860
- filter: FaceFilterEnum.optional()
21227
+ filter: FaceFilterEnum.optional(),
21228
+ /**
21229
+ * Inline the base64 crop on every row. Default `true` — the existing
21230
+ * behaviour, kept so no caller breaks.
21231
+ *
21232
+ * Set `false` once the caller renders {@link FaceInfo.cropUrl}: that
21233
+ * drops ~2.87 MiB per 500-row page to a few KiB of metadata and lets
21234
+ * the browser cache the images.
21235
+ *
21236
+ * **This is an INPUT field, so it does not reach the addon until the
21237
+ * next train.** The hub router validates cap inputs against its own
21238
+ * compiled Zod, which strips a key it does not know — verified today
21239
+ * on the OUTPUT side, where an additive field DOES arrive immediately
21240
+ * (`Track.hasFace`). Until the train ships, sending `false` is
21241
+ * harmless and simply keeps the crops inline.
21242
+ */
21243
+ includeCrops: boolean().optional()
21861
21244
  }).optional(), array(FaceInfoSchema).readonly()), method(object({
21862
21245
  deviceId: number().int(),
21863
21246
  trackId: string()
@@ -25456,13 +24839,18 @@ method(_void(), array(UserSummarySchema), { auth: "admin" }), method(CreateUserI
25456
24839
  username: string(),
25457
24840
  scopes: array(TokenScopeSchema),
25458
24841
  redirectUri: string(),
25459
- hubUrl: string()
24842
+ hubUrl: string(),
24843
+ /** PKCE (RFC 7636) S256 challenge. Baked into the signed code; a code
24844
+ * that carries one can ONLY be exchanged with the matching verifier. */
24845
+ codeChallenge: string().optional()
25460
24846
  }), object({ code: string() }), {
25461
24847
  kind: "mutation",
25462
24848
  access: "create"
25463
24849
  }), method(object({
25464
24850
  code: string(),
25465
- redirectUri: string()
24851
+ redirectUri: string(),
24852
+ /** PKCE verifier. REQUIRED when the code carries a challenge. */
24853
+ codeVerifier: string().optional()
25466
24854
  }), object({
25467
24855
  accessToken: string(),
25468
24856
  refreshToken: string(),
@@ -25689,190 +25077,819 @@ object({
25689
25077
  });
25690
25078
  DeviceType.Weather;
25691
25079
  /**
25692
- * Per-zone occupancy aggregation produced by the analytics frame
25693
- * processor on every inference result. Covers the full combinatorial
25694
- * matrix the operator UI needs: total objects everywhere, total
25695
- * objects per zone, single class everywhere, single class per zone,
25696
- * objects outside any zone.
25080
+ * Per-zone occupancy aggregation produced by the analytics frame
25081
+ * processor on every inference result. Covers the full combinatorial
25082
+ * matrix the operator UI needs: total objects everywhere, total
25083
+ * objects per zone, single class everywhere, single class per zone,
25084
+ * objects outside any zone.
25085
+ *
25086
+ * Counts are derived from the analytics tracker (tracked detections
25087
+ * with stable trackIds), not raw detector hits — this filters out
25088
+ * one-off detector flickers and gives counts that match what the user
25089
+ * sees on the live overlay.
25090
+ *
25091
+ * Overlap policy: a detection that intersects two zones counts in
25092
+ * BOTH zones' `byClass` and `totalObjects` (count-in-each). The
25093
+ * `frame` aggregate de-duplicates trivially since it's frame-wide.
25094
+ * `unzoned` counts only detections that landed in zero zones.
25095
+ */
25096
+ var PerScopeBreakdownSchema = object({
25097
+ /** Total tracked objects in this scope (frame / zone / unzoned). */
25098
+ totalObjects: number().int().nonnegative(),
25099
+ /** Per-class count. Keys are macro class names (e.g. `person`, `car`). */
25100
+ byClass: record(string(), number().int().nonnegative())
25101
+ });
25102
+ var ZoneScopeBreakdownSchema = PerScopeBreakdownSchema.extend({
25103
+ zoneId: string(),
25104
+ zoneName: string(),
25105
+ /** TrackIds of objects currently inside this zone — for cross-reference
25106
+ * with the per-track detail panel and live overlay. */
25107
+ trackIds: array(string()).readonly()
25108
+ });
25109
+ /**
25110
+ * A parked ("stationary") object surfaced alongside occupancy — an object that
25111
+ * settled and stopped moving. It is NO LONGER a tracked object (the tracker was
25112
+ * told to forget it so it stops re-spawning tracks/events), but it IS still
25113
+ * physically present, so it keeps counting toward `frame` occupancy and is
25114
+ * listed here so the UI can show it in a dedicated "Stationary" section instead
25115
+ * of flooding the live event feed.
25116
+ */
25117
+ var StationaryObjectSchema = object({
25118
+ id: string(),
25119
+ className: string(),
25120
+ bbox: object({
25121
+ x: number(),
25122
+ y: number(),
25123
+ w: number(),
25124
+ h: number()
25125
+ }),
25126
+ frameWidth: number().int().nonnegative(),
25127
+ frameHeight: number().int().nonnegative(),
25128
+ /** When the source track was first seen. */
25129
+ firstSeenAt: number().int(),
25130
+ /** When the object was recognised as parked (promotion time). */
25131
+ becameStationaryAt: number().int(),
25132
+ /** Last frame a detection confirmed the object is still there. */
25133
+ lastConfirmedAt: number().int(),
25134
+ /** Enrichment label carried from the source track (identity / plate). */
25135
+ label: string().optional(),
25136
+ /** Native-resolution key-frame media key for the parked object's best image. */
25137
+ keyFrameMediaKey: string().optional()
25138
+ });
25139
+ var CameraOccupancySnapshotSchema = object({
25140
+ /** Frame timestamp of the inference result that produced this snapshot. */
25141
+ ts: number().int(),
25142
+ /** Frame width/height in pixels — let the UI normalize bbox coords. */
25143
+ frameWidth: number().int().nonnegative(),
25144
+ frameHeight: number().int().nonnegative(),
25145
+ /** Per-zone breakdown — one entry per defined zone (user + onboard). */
25146
+ zones: array(ZoneScopeBreakdownSchema).readonly(),
25147
+ /** Frame-wide aggregate (everywhere, regardless of zone membership).
25148
+ * INCLUDES currently-confirmed stationary objects (they are still present). */
25149
+ frame: PerScopeBreakdownSchema,
25150
+ /** Detections that landed outside every zone. Empty when no zones defined. */
25151
+ unzoned: PerScopeBreakdownSchema,
25152
+ /** Parked objects on this camera (additive — absent on legacy snapshots).
25153
+ * Surfaced separately so the UI shows them in a dedicated section rather
25154
+ * than as repeated tracks/events. */
25155
+ stationaryObjects: array(StationaryObjectSchema).readonly().optional()
25156
+ });
25157
+ /**
25158
+ * Time-series resolution. The history methods return one bucket per
25159
+ * step over the requested range. Smaller resolutions cost more
25160
+ * memory + bandwidth; bound to discrete steps so caller cannot ask
25161
+ * for arbitrary fractional buckets.
25162
+ */
25163
+ var HistoryResolutionEnum = _enum([
25164
+ "minute",
25165
+ "5min",
25166
+ "hour"
25167
+ ]);
25168
+ var HistoryRangeSchema = object({
25169
+ /** Range start (epoch ms, inclusive). */
25170
+ from: number().int(),
25171
+ /** Range end (epoch ms, inclusive). Defaults to "now" at query time. */
25172
+ to: number().int(),
25173
+ resolution: HistoryResolutionEnum
25174
+ });
25175
+ var HistoryPointSchema = object({
25176
+ /** Bucket midpoint (epoch ms). */
25177
+ ts: number().int(),
25178
+ /** Object count averaged over the bucket (rounded to nearest integer). */
25179
+ count: number().int().nonnegative()
25180
+ });
25181
+ DeviceType.Camera, method(object({ deviceId: number() }), CameraOccupancySnapshotSchema.nullable()), method(object({
25182
+ deviceId: number(),
25183
+ zoneId: string(),
25184
+ className: string().optional()
25185
+ }).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly()), method(object({
25186
+ deviceId: number(),
25187
+ className: string().optional()
25188
+ }).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly()), method(object({
25189
+ deviceId: number(),
25190
+ className: string().optional()
25191
+ }).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly());
25192
+ /**
25193
+ * Stages a {@link ZoneRule} can apply to. Discriminator on the rules
25194
+ * cap so a single CRUD surface backs every consumer; each stage has
25195
+ * its own dev-state mirror slice (`motion-zone-rules`,
25196
+ * `detection-zone-rules`, …) so consumer addons subscribe independently.
25197
+ *
25198
+ * Extend the enum here when a new gating consumer comes online (audio
25199
+ * gating, alert filtering, …) — no other surface needs to change.
25200
+ */
25201
+ var ZoneRuleStageEnum = _enum([
25202
+ "motion",
25203
+ "detection",
25204
+ "package"
25205
+ ]);
25206
+ DeviceType.Camera, method(object({
25207
+ deviceId: number(),
25208
+ stage: ZoneRuleStageEnum
25209
+ }), array(ZoneRuleSchema).readonly()), method(object({
25210
+ deviceId: number(),
25211
+ stage: ZoneRuleStageEnum,
25212
+ rules: array(ZoneRuleSchema).readonly()
25213
+ }), _void(), {
25214
+ kind: "mutation",
25215
+ auth: "admin"
25216
+ }), object({
25217
+ motion: array(ZoneRuleSchema).readonly(),
25218
+ detection: array(ZoneRuleSchema).readonly(),
25219
+ package: array(ZoneRuleSchema).readonly()
25220
+ });
25221
+ var HF_BASE_URL = `https://huggingface.co/camstack/camstack-models/resolve/main`;
25222
+ /**
25223
+ * Accessory device helpers — shared across drivers.
25224
+ *
25225
+ * Many vendor-specific drivers register accessory child devices on
25226
+ * top of a parent (Reolink: siren / floodlight / PIR / autotrack /
25227
+ * chime; ONVIF: relay outputs; future: Tapo Hub child devices). Each
25228
+ * driver picks the right `DeviceType` + `DeviceRole` explicitly when
25229
+ * spawning, builds a name derived from the parent, and produces a
25230
+ * stableId tied to the parent so boot-restore can reconstruct the
25231
+ * relationship.
25232
+ *
25233
+ * Centralised `(kind → DeviceType)` mapping was dropped on purpose:
25234
+ * drivers may reasonably disagree on the right type for an accessory
25235
+ * (a Reolink PIR exposes a switch on/off + sensitivity, while a hypothetical
25236
+ * read-only motion-only sensor might be `DeviceType.Sensor`). Forcing
25237
+ * one canonical mapping was over-prescriptive and added a layer of
25238
+ * indirection without saving meaningful code at call sites — the
25239
+ * driver knows its own hardware best.
25240
+ */
25241
+ /**
25242
+ * Subset of `DeviceRole` values that drivers register as child
25243
+ * accessories of a parent device. Sourced verbatim from `DeviceRole`
25244
+ * — `AccessoryKind` is the alias drivers use when building accessory
25245
+ * children, so the call site reads as
25246
+ * `accessoryStableId(parent, AccessoryKind.Siren)` rather than
25247
+ * `accessoryStableId(parent, DeviceRole.Siren)` (which would imply
25248
+ * any role works, including non-accessory ones like Doorbell).
25249
+ */
25250
+ var AccessoryKind = {
25251
+ Siren: DeviceRole.Siren,
25252
+ Floodlight: DeviceRole.Floodlight,
25253
+ Spotlight: DeviceRole.Spotlight,
25254
+ PirSensor: DeviceRole.PirSensor,
25255
+ Chime: DeviceRole.Chime,
25256
+ Autotrack: DeviceRole.Autotrack,
25257
+ Nightvision: DeviceRole.Nightvision,
25258
+ PrivacyMask: DeviceRole.PrivacyMask
25259
+ };
25260
+ AccessoryKind.Siren, AccessoryKind.Floodlight, AccessoryKind.Spotlight, AccessoryKind.PirSensor, AccessoryKind.Chime, AccessoryKind.Autotrack, AccessoryKind.Nightvision, AccessoryKind.PrivacyMask;
25261
+ 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;
25262
+ new Set(Object.values(DeviceType));
25263
+ DeviceFeature.BatteryOperated;
25264
+ /**
25265
+ * Error types for the safe expression engine. Two distinct classes so callers
25266
+ * can tell a compile-time (grammar) failure from a runtime (evaluation)
25267
+ * failure — both are non-fatal to the host: read paths degrade to "skip link".
25268
+ */
25269
+ /** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
25270
+ * the failure is anchored to a character (author-facing inline feedback). */
25271
+ var ExpressionParseError = class extends Error {
25272
+ position;
25273
+ constructor(message, position) {
25274
+ super(message);
25275
+ this.name = "ExpressionParseError";
25276
+ this.position = position;
25277
+ }
25278
+ };
25279
+ /** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
25280
+ * result, unknown builtin, step-budget exceeded). */
25281
+ var ExpressionEvalError = class extends Error {
25282
+ constructor(message) {
25283
+ super(message);
25284
+ this.name = "ExpressionEvalError";
25285
+ }
25286
+ };
25287
+ /**
25288
+ * Frozen, null-prototype builtin function table for the expression engine
25289
+ * (spec §4 rule 4). The table is the SOLE surface of callable functions: the
25290
+ * parser rejects any callee not in it, and the evaluator gates each call on an
25291
+ * own-property check against it.
25697
25292
  *
25698
- * Counts are derived from the analytics tracker (tracked detections
25699
- * with stable trackIds), not raw detector hits this filters out
25700
- * one-off detector flickers and gives counts that match what the user
25701
- * sees on the live overlay.
25293
+ * Because the object has a NULL prototype AND is `Object.freeze`d:
25294
+ * - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
25295
+ * - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
25296
+ * (there is no `Object.prototype` in the chain), so those names are not
25297
+ * callable — they are simply "unknown function" at parse time.
25702
25298
  *
25703
- * Overlap policy: a detection that intersects two zones counts in
25704
- * BOTH zones' `byClass` and `totalObjects` (count-in-each). The
25705
- * `frame` aggregate de-duplicates trivially since it's frame-wide.
25706
- * `unzoned` counts only detections that landed in zero zones.
25299
+ * Every numeric argument is validated as a finite number and every numeric
25300
+ * RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
25301
+ * (`pow(10,400)` Infinity) all raise `ExpressionEvalError` and fail the link
25302
+ * closed rather than emitting a garbage value.
25707
25303
  */
25708
- var PerScopeBreakdownSchema = object({
25709
- /** Total tracked objects in this scope (frame / zone / unzoned). */
25710
- totalObjects: number().int().nonnegative(),
25711
- /** Per-class count. Keys are macro class names (e.g. `person`, `car`). */
25712
- byClass: record(string(), number().int().nonnegative())
25713
- });
25714
- var ZoneScopeBreakdownSchema = PerScopeBreakdownSchema.extend({
25715
- zoneId: string(),
25716
- zoneName: string(),
25717
- /** TrackIds of objects currently inside this zone — for cross-reference
25718
- * with the per-track detail panel and live overlay. */
25719
- trackIds: array(string()).readonly()
25720
- });
25304
+ function asFiniteNumber(value, name, index) {
25305
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
25306
+ return value;
25307
+ }
25308
+ function asString$1(value, name, index) {
25309
+ if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
25310
+ return value;
25311
+ }
25312
+ function finiteResult(value, name) {
25313
+ if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
25314
+ return value;
25315
+ }
25316
+ function allFiniteNumbers(args, name) {
25317
+ return args.map((a, idx) => asFiniteNumber(a, name, idx));
25318
+ }
25319
+ var INF = Number.POSITIVE_INFINITY;
25320
+ var table = {
25321
+ min: {
25322
+ minArgs: 1,
25323
+ maxArgs: INF,
25324
+ apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
25325
+ },
25326
+ max: {
25327
+ minArgs: 1,
25328
+ maxArgs: INF,
25329
+ apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
25330
+ },
25331
+ abs: {
25332
+ minArgs: 1,
25333
+ maxArgs: 1,
25334
+ apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
25335
+ },
25336
+ floor: {
25337
+ minArgs: 1,
25338
+ maxArgs: 1,
25339
+ apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
25340
+ },
25341
+ ceil: {
25342
+ minArgs: 1,
25343
+ maxArgs: 1,
25344
+ apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
25345
+ },
25346
+ sqrt: {
25347
+ minArgs: 1,
25348
+ maxArgs: 1,
25349
+ apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
25350
+ },
25351
+ round: {
25352
+ minArgs: 1,
25353
+ maxArgs: 2,
25354
+ apply: (args) => {
25355
+ const x = asFiniteNumber(args[0], "round", 0);
25356
+ const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
25357
+ if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
25358
+ const factor = 10 ** digits;
25359
+ return finiteResult(Math.round(x * factor) / factor, "round");
25360
+ }
25361
+ },
25362
+ pow: {
25363
+ minArgs: 2,
25364
+ maxArgs: 2,
25365
+ apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
25366
+ },
25367
+ clamp: {
25368
+ minArgs: 3,
25369
+ maxArgs: 3,
25370
+ apply: (args) => {
25371
+ const x = asFiniteNumber(args[0], "clamp", 0);
25372
+ const lo = asFiniteNumber(args[1], "clamp", 1);
25373
+ const hi = asFiniteNumber(args[2], "clamp", 2);
25374
+ if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
25375
+ return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
25376
+ }
25377
+ },
25378
+ avg: {
25379
+ minArgs: 1,
25380
+ maxArgs: INF,
25381
+ apply: (args) => {
25382
+ const nums = allFiniteNumbers(args, "avg");
25383
+ return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
25384
+ }
25385
+ },
25386
+ sum: {
25387
+ minArgs: 1,
25388
+ maxArgs: INF,
25389
+ apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
25390
+ },
25391
+ coalesce: {
25392
+ minArgs: 1,
25393
+ maxArgs: INF,
25394
+ apply: (args) => {
25395
+ for (const a of args) if (a !== null) return a;
25396
+ return null;
25397
+ }
25398
+ },
25399
+ age: {
25400
+ minArgs: 2,
25401
+ maxArgs: 2,
25402
+ apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
25403
+ },
25404
+ convert: {
25405
+ minArgs: 3,
25406
+ maxArgs: 3,
25407
+ apply: (args, hooks) => {
25408
+ const x = asFiniteNumber(args[0], "convert", 0);
25409
+ const from = asString$1(args[1], "convert", 1).trim();
25410
+ const to = asString$1(args[2], "convert", 2).trim();
25411
+ if (hooks.convert) {
25412
+ const out = hooks.convert(x, from, to);
25413
+ if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
25414
+ return finiteResult(out, "convert");
25415
+ }
25416
+ if (from === to) return x;
25417
+ throw new ExpressionEvalError("convert: unit conversion table not installed");
25418
+ }
25419
+ }
25420
+ };
25421
+ Object.freeze(Object.assign(Object.create(null), table));
25422
+ /** The set of valid builtin names — used by the parser to reject unknown
25423
+ * callees at parse time (immediate author feedback). */
25424
+ var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
25721
25425
  /**
25722
- * A parked ("stationary") object surfaced alongside occupancy — an object that
25723
- * settled and stopped moving. It is NO LONGER a tracked object (the tracker was
25724
- * told to forget it so it stops re-spawning tracks/events), but it IS still
25725
- * physically present, so it keeps counting toward `frame` occupancy and is
25726
- * listed here so the UI can show it in a dedicated "Stationary" section instead
25727
- * of flooding the live event feed.
25728
- */
25729
- var StationaryObjectSchema = object({
25730
- id: string(),
25731
- className: string(),
25732
- bbox: object({
25733
- x: number(),
25734
- y: number(),
25735
- w: number(),
25736
- h: number()
25737
- }),
25738
- frameWidth: number().int().nonnegative(),
25739
- frameHeight: number().int().nonnegative(),
25740
- /** When the source track was first seen. */
25741
- firstSeenAt: number().int(),
25742
- /** When the object was recognised as parked (promotion time). */
25743
- becameStationaryAt: number().int(),
25744
- /** Last frame a detection confirmed the object is still there. */
25745
- lastConfirmedAt: number().int(),
25746
- /** Enrichment label carried from the source track (identity / plate). */
25747
- label: string().optional(),
25748
- /** Native-resolution key-frame media key for the parked object's best image. */
25749
- keyFrameMediaKey: string().optional()
25750
- });
25751
- var CameraOccupancySnapshotSchema = object({
25752
- /** Frame timestamp of the inference result that produced this snapshot. */
25753
- ts: number().int(),
25754
- /** Frame width/height in pixels — let the UI normalize bbox coords. */
25755
- frameWidth: number().int().nonnegative(),
25756
- frameHeight: number().int().nonnegative(),
25757
- /** Per-zone breakdown — one entry per defined zone (user + onboard). */
25758
- zones: array(ZoneScopeBreakdownSchema).readonly(),
25759
- /** Frame-wide aggregate (everywhere, regardless of zone membership).
25760
- * INCLUDES currently-confirmed stationary objects (they are still present). */
25761
- frame: PerScopeBreakdownSchema,
25762
- /** Detections that landed outside every zone. Empty when no zones defined. */
25763
- unzoned: PerScopeBreakdownSchema,
25764
- /** Parked objects on this camera (additive — absent on legacy snapshots).
25765
- * Surfaced separately so the UI shows them in a dedicated section rather
25766
- * than as repeated tracks/events. */
25767
- stationaryObjects: array(StationaryObjectSchema).readonly().optional()
25768
- });
25426
+ * Resource-bound constants for the safe expression engine.
25427
+ *
25428
+ * Every bound is defense-in-depth: the grammar is non-Turing-complete (no
25429
+ * loops, recursion, lambdas or member access see `ast.ts`), so evaluation is
25430
+ * O(nodeCount) by construction. These caps merely put a hard ceiling on the
25431
+ * work a single author-supplied expression can request, so a hostile or
25432
+ * accidental pathological string can never spend unbounded CPU/memory.
25433
+ */
25434
+ /** Max source length (chars) — checked BEFORE tokenizing so a huge string is
25435
+ * rejected without allocation. */
25436
+ var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
25437
+ /** A legal binding / identifier name. */
25438
+ var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
25439
+ /** Binding names an author may NOT use: `now` is auto-injected; the literal
25440
+ * keywords lex as values, not identifiers, so binding to them is meaningless. */
25441
+ var RESERVED_BINDING_NAMES = new Set([
25442
+ "now",
25443
+ "true",
25444
+ "false",
25445
+ "null"
25446
+ ]);
25769
25447
  /**
25770
- * Time-series resolution. The history methods return one bucket per
25771
- * step over the requested range. Smaller resolutions cost more
25772
- * memory + bandwidth; bound to discrete steps so caller cannot ask
25773
- * for arbitrary fractional buckets.
25448
+ * Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
25449
+ * zero-dependency. The grammar is deliberately boring: decimal numbers,
25450
+ * single/double-quoted strings with a tiny escape set, identifiers, the three
25451
+ * value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
25452
+ * outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
25453
+ * is a parse error with a source position, so member access / assignment /
25454
+ * template literals are lexically impossible.
25774
25455
  */
25775
- var HistoryResolutionEnum = _enum([
25776
- "minute",
25777
- "5min",
25778
- "hour"
25456
+ var KEYWORDS = new Set([
25457
+ "true",
25458
+ "false",
25459
+ "null"
25779
25460
  ]);
25780
- var HistoryRangeSchema = object({
25781
- /** Range start (epoch ms, inclusive). */
25782
- from: number().int(),
25783
- /** Range end (epoch ms, inclusive). Defaults to "now" at query time. */
25784
- to: number().int(),
25785
- resolution: HistoryResolutionEnum
25786
- });
25787
- var HistoryPointSchema = object({
25788
- /** Bucket midpoint (epoch ms). */
25789
- ts: number().int(),
25790
- /** Object count averaged over the bucket (rounded to nearest integer). */
25791
- count: number().int().nonnegative()
25792
- });
25793
- DeviceType.Camera, method(object({ deviceId: number() }), CameraOccupancySnapshotSchema.nullable()), method(object({
25794
- deviceId: number(),
25795
- zoneId: string(),
25796
- className: string().optional()
25797
- }).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly()), method(object({
25798
- deviceId: number(),
25799
- className: string().optional()
25800
- }).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly()), method(object({
25801
- deviceId: number(),
25802
- className: string().optional()
25803
- }).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly());
25461
+ function isDigit(ch) {
25462
+ return ch >= "0" && ch <= "9";
25463
+ }
25464
+ function isIdentStart(ch) {
25465
+ return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
25466
+ }
25467
+ function isIdentPart(ch) {
25468
+ return isIdentStart(ch) || isDigit(ch);
25469
+ }
25470
+ function isWhitespace(ch) {
25471
+ return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
25472
+ }
25473
+ /** Tokenize `source` into a flat token list ending with a single `eof` token.
25474
+ * Throws `ExpressionParseError` on any illegal character or unterminated
25475
+ * string. */
25476
+ function tokenize(source) {
25477
+ if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
25478
+ const tokens = [];
25479
+ let i = 0;
25480
+ const n = source.length;
25481
+ while (i < n) {
25482
+ const ch = source[i];
25483
+ if (isWhitespace(ch)) {
25484
+ i += 1;
25485
+ continue;
25486
+ }
25487
+ if (isDigit(ch)) {
25488
+ const start = i;
25489
+ while (i < n && isDigit(source[i])) i += 1;
25490
+ if (i < n && source[i] === ".") {
25491
+ if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
25492
+ i += 1;
25493
+ while (i < n && isDigit(source[i])) i += 1;
25494
+ }
25495
+ const text = source.slice(start, i);
25496
+ const value = Number(text);
25497
+ if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
25498
+ tokens.push({
25499
+ type: "number",
25500
+ value,
25501
+ pos: start
25502
+ });
25503
+ continue;
25504
+ }
25505
+ if (ch === "'" || ch === "\"") {
25506
+ const quote = ch;
25507
+ const start = i;
25508
+ i += 1;
25509
+ let out = "";
25510
+ let closed = false;
25511
+ while (i < n) {
25512
+ const c = source[i];
25513
+ if (c === "\\") {
25514
+ const next = i + 1 < n ? source[i + 1] : "";
25515
+ if (next === "\\" || next === "'" || next === "\"") {
25516
+ out += next;
25517
+ i += 2;
25518
+ continue;
25519
+ }
25520
+ throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
25521
+ }
25522
+ if (c === quote) {
25523
+ closed = true;
25524
+ i += 1;
25525
+ break;
25526
+ }
25527
+ out += c;
25528
+ i += 1;
25529
+ }
25530
+ if (!closed) throw new ExpressionParseError("unterminated string literal", start);
25531
+ tokens.push({
25532
+ type: "string",
25533
+ value: out,
25534
+ pos: start
25535
+ });
25536
+ continue;
25537
+ }
25538
+ if (isIdentStart(ch)) {
25539
+ const start = i;
25540
+ while (i < n && isIdentPart(source[i])) i += 1;
25541
+ const text = source.slice(start, i);
25542
+ if (KEYWORDS.has(text)) tokens.push({
25543
+ type: "keyword",
25544
+ keyword: keywordOf(text),
25545
+ pos: start
25546
+ });
25547
+ else tokens.push({
25548
+ type: "identifier",
25549
+ name: text,
25550
+ pos: start
25551
+ });
25552
+ continue;
25553
+ }
25554
+ const two = i + 1 < n ? source.slice(i, i + 2) : "";
25555
+ if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
25556
+ tokens.push({
25557
+ type: "punct",
25558
+ punct: two,
25559
+ pos: i
25560
+ });
25561
+ i += 2;
25562
+ continue;
25563
+ }
25564
+ if (isSinglePunct(ch)) {
25565
+ tokens.push({
25566
+ type: "punct",
25567
+ punct: ch,
25568
+ pos: i
25569
+ });
25570
+ i += 1;
25571
+ continue;
25572
+ }
25573
+ throw new ExpressionParseError(`unexpected character '${ch}'`, i);
25574
+ }
25575
+ tokens.push({
25576
+ type: "eof",
25577
+ pos: n
25578
+ });
25579
+ return tokens;
25580
+ }
25581
+ function keywordOf(text) {
25582
+ if (text === "true") return "true";
25583
+ if (text === "false") return "false";
25584
+ return "null";
25585
+ }
25586
+ function isSinglePunct(ch) {
25587
+ return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
25588
+ }
25804
25589
  /**
25805
- * Stages a {@link ZoneRule} can apply to. Discriminator on the rules
25806
- * cap so a single CRUD surface backs every consumer; each stage has
25807
- * its own dev-state mirror slice (`motion-zone-rules`,
25808
- * `detection-zone-rules`, …) so consumer addons subscribe independently.
25590
+ * Pratt (precedence-climbing) parser for the safe expression mini-language.
25809
25591
  *
25810
- * Extend the enum here when a new gating consumer comes online (audio
25811
- * gating, alert filtering, …) no other surface needs to change.
25592
+ * Precedence (low high): ternary `?:` (right-assoc) `||` `&&` → equality
25593
+ * relational additive multiplicative unary `! -` → call / primary.
25594
+ * Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
25595
+ * string validated against the builtin table at parse time, so an unknown
25596
+ * function is rejected immediately (author feedback) and a persisted expression
25597
+ * that references a since-removed builtin degrades at read.
25598
+ *
25599
+ * A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
25600
+ * arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
25812
25601
  */
25813
- var ZoneRuleStageEnum = _enum([
25814
- "motion",
25815
- "detection",
25816
- "package"
25817
- ]);
25818
- DeviceType.Camera, method(object({
25819
- deviceId: number(),
25820
- stage: ZoneRuleStageEnum
25821
- }), array(ZoneRuleSchema).readonly()), method(object({
25822
- deviceId: number(),
25823
- stage: ZoneRuleStageEnum,
25824
- rules: array(ZoneRuleSchema).readonly()
25825
- }), _void(), {
25826
- kind: "mutation",
25827
- auth: "admin"
25828
- }), object({
25829
- motion: array(ZoneRuleSchema).readonly(),
25830
- detection: array(ZoneRuleSchema).readonly(),
25831
- package: array(ZoneRuleSchema).readonly()
25832
- });
25833
- var HF_BASE_URL = `https://huggingface.co/camstack/camstack-models/resolve/main`;
25602
+ /** Binary/logical operator precedence (higher binds tighter). */
25603
+ var BINARY_PRECEDENCE = {
25604
+ "||": 1,
25605
+ "&&": 2,
25606
+ "==": 3,
25607
+ "!=": 3,
25608
+ "<": 4,
25609
+ "<=": 4,
25610
+ ">": 4,
25611
+ ">=": 4,
25612
+ "+": 5,
25613
+ "-": 5,
25614
+ "*": 6,
25615
+ "/": 6,
25616
+ "%": 6
25617
+ };
25618
+ function isLogicalOp(op) {
25619
+ return op === "&&" || op === "||";
25620
+ }
25621
+ function isBinaryOp(op) {
25622
+ return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
25623
+ }
25624
+ var Parser = class {
25625
+ tokens;
25626
+ pos = 0;
25627
+ nodeCount = 0;
25628
+ identifiers = /* @__PURE__ */ new Set();
25629
+ callees = /* @__PURE__ */ new Set();
25630
+ constructor(tokens) {
25631
+ this.tokens = tokens;
25632
+ }
25633
+ parse() {
25634
+ const ast = this.parseTernary();
25635
+ const tok = this.peek();
25636
+ if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
25637
+ return {
25638
+ ast,
25639
+ identifiers: this.identifiers,
25640
+ callees: this.callees,
25641
+ nodeCount: this.nodeCount
25642
+ };
25643
+ }
25644
+ peek() {
25645
+ return this.tokens[this.pos];
25646
+ }
25647
+ next() {
25648
+ return this.tokens[this.pos++];
25649
+ }
25650
+ /** Consume a punctuator token, erroring if the next token isn't it. */
25651
+ expectPunct(punct) {
25652
+ const tok = this.peek();
25653
+ if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
25654
+ this.pos += 1;
25655
+ }
25656
+ matchPunct(punct) {
25657
+ const tok = this.peek();
25658
+ if (tok.type === "punct" && tok.punct === punct) {
25659
+ this.pos += 1;
25660
+ return true;
25661
+ }
25662
+ return false;
25663
+ }
25664
+ countNode() {
25665
+ this.nodeCount += 1;
25666
+ if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
25667
+ }
25668
+ parseTernary() {
25669
+ const test = this.parseBinary(1);
25670
+ if (this.matchPunct("?")) {
25671
+ const consequent = this.parseTernary();
25672
+ this.expectPunct(":");
25673
+ const alternate = this.parseTernary();
25674
+ this.countNode();
25675
+ return {
25676
+ kind: "conditional",
25677
+ test,
25678
+ consequent,
25679
+ alternate
25680
+ };
25681
+ }
25682
+ return test;
25683
+ }
25684
+ parseBinary(minPrec) {
25685
+ let left = this.parseUnary();
25686
+ for (;;) {
25687
+ const tok = this.peek();
25688
+ if (tok.type !== "punct") break;
25689
+ const prec = BINARY_PRECEDENCE[tok.punct];
25690
+ if (prec === void 0 || prec < minPrec) break;
25691
+ const op = tok.punct;
25692
+ this.pos += 1;
25693
+ const right = this.parseBinary(prec + 1);
25694
+ this.countNode();
25695
+ if (isLogicalOp(op)) left = {
25696
+ kind: "logical",
25697
+ op,
25698
+ left,
25699
+ right
25700
+ };
25701
+ else if (isBinaryOp(op)) left = {
25702
+ kind: "binary",
25703
+ op,
25704
+ left,
25705
+ right
25706
+ };
25707
+ else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
25708
+ }
25709
+ return left;
25710
+ }
25711
+ parseUnary() {
25712
+ const tok = this.peek();
25713
+ if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
25714
+ const op = tok.punct;
25715
+ this.pos += 1;
25716
+ const operand = this.parseUnary();
25717
+ this.countNode();
25718
+ return {
25719
+ kind: "unary",
25720
+ op,
25721
+ operand
25722
+ };
25723
+ }
25724
+ return this.parsePrimary();
25725
+ }
25726
+ parsePrimary() {
25727
+ const tok = this.next();
25728
+ switch (tok.type) {
25729
+ case "number":
25730
+ this.countNode();
25731
+ return {
25732
+ kind: "literal",
25733
+ value: tok.value
25734
+ };
25735
+ case "string":
25736
+ this.countNode();
25737
+ return {
25738
+ kind: "literal",
25739
+ value: tok.value
25740
+ };
25741
+ case "keyword":
25742
+ this.countNode();
25743
+ return {
25744
+ kind: "literal",
25745
+ value: tok.keyword === "null" ? null : tok.keyword === "true"
25746
+ };
25747
+ case "identifier": {
25748
+ const nextTok = this.peek();
25749
+ if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
25750
+ this.identifiers.add(tok.name);
25751
+ this.countNode();
25752
+ return {
25753
+ kind: "identifier",
25754
+ name: tok.name
25755
+ };
25756
+ }
25757
+ case "punct":
25758
+ if (tok.punct === "(") {
25759
+ const inner = this.parseTernary();
25760
+ this.expectPunct(")");
25761
+ return inner;
25762
+ }
25763
+ throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
25764
+ case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
25765
+ }
25766
+ }
25767
+ parseCall(callee, pos) {
25768
+ if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
25769
+ this.expectPunct("(");
25770
+ const args = [];
25771
+ if (!this.matchPunct(")")) for (;;) {
25772
+ args.push(this.parseTernary());
25773
+ if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
25774
+ if (this.matchPunct(",")) continue;
25775
+ this.expectPunct(")");
25776
+ break;
25777
+ }
25778
+ this.callees.add(callee);
25779
+ this.countNode();
25780
+ return {
25781
+ kind: "call",
25782
+ callee,
25783
+ args
25784
+ };
25785
+ }
25786
+ };
25787
+ /** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
25788
+ * `ExpressionParseError` on any lexical or grammatical failure. */
25789
+ function parseExpression(source) {
25790
+ return new Parser(tokenize(source)).parse();
25791
+ }
25834
25792
  /**
25835
- * Accessory device helpers shared across drivers.
25836
- *
25837
- * Many vendor-specific drivers register accessory child devices on
25838
- * top of a parent (Reolink: siren / floodlight / PIR / autotrack /
25839
- * chime; ONVIF: relay outputs; future: Tapo Hub child devices). Each
25840
- * driver picks the right `DeviceType` + `DeviceRole` explicitly when
25841
- * spawning, builds a name derived from the parent, and produces a
25842
- * stableId tied to the parent so boot-restore can reconstruct the
25843
- * relationship.
25793
+ * LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
25794
+ * by expr"). The cache stores BOTH successes and failures (negative caching),
25795
+ * so a corrupt persisted string costs exactly one tokenize+parse total — not
25796
+ * one per read on a hot resolve path.
25844
25797
  *
25845
- * Centralised `(kind DeviceType)` mapping was dropped on purpose:
25846
- * drivers may reasonably disagree on the right type for an accessory
25847
- * (a Reolink PIR exposes a switch on/off + sensitivity, while a hypothetical
25848
- * read-only motion-only sensor might be `DeviceType.Sensor`). Forcing
25849
- * one canonical mapping was over-prescriptive and added a layer of
25850
- * indirection without saving meaningful code at call sites — the
25851
- * driver knows its own hardware best.
25798
+ * The cache is a module-level singleton: entries are pure, content-addressed
25799
+ * ASTs keyed by the raw source string, so sharing one instance across all
25800
+ * callers is safe and maximises hit rate.
25852
25801
  */
25802
+ var cache = /* @__PURE__ */ new Map();
25803
+ function getCached(source) {
25804
+ const hit = cache.get(source);
25805
+ if (hit !== void 0) {
25806
+ cache.delete(source);
25807
+ cache.set(source, hit);
25808
+ return hit;
25809
+ }
25810
+ let result;
25811
+ try {
25812
+ result = {
25813
+ ok: true,
25814
+ parsed: parseExpression(source)
25815
+ };
25816
+ } catch (err) {
25817
+ result = {
25818
+ ok: false,
25819
+ error: err instanceof ExpressionParseError ? err.message : String(err)
25820
+ };
25821
+ }
25822
+ cache.set(source, result);
25823
+ if (cache.size > 256) {
25824
+ const oldest = cache.keys().next().value;
25825
+ if (oldest !== void 0) cache.delete(oldest);
25826
+ }
25827
+ return result;
25828
+ }
25829
+ /** Compile `source`, returning a discriminated result instead of throwing.
25830
+ * Used by read paths that must degrade rather than raise. LRU/negative-cached. */
25831
+ function compileExpressionSafe(source) {
25832
+ return getCached(source);
25833
+ }
25834
+ Object.freeze({});
25853
25835
  /**
25854
- * Subset of `DeviceRole` values that drivers register as child
25855
- * accessories of a parent device. Sourced verbatim from `DeviceRole`
25856
- * `AccessoryKind` is the alias drivers use when building accessory
25857
- * children, so the call site reads as
25858
- * `accessoryStableId(parent, AccessoryKind.Siren)` rather than
25859
- * `accessoryStableId(parent, DeviceRole.Siren)` (which would imply
25860
- * any role works, including non-accessory ones like Doorbell).
25836
+ * Author-time validation. Returns `null` when the source is valid, else a
25837
+ * human-readable error message. Checks: the expression compiles; binding count
25838
+ * is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
25839
+ * is not reserved (`now`/keywords) and does not shadow a builtin; and every
25840
+ * FREE identifier of the AST is covered by a binding or the injected `now`.
25861
25841
  */
25862
- var AccessoryKind = {
25863
- Siren: DeviceRole.Siren,
25864
- Floodlight: DeviceRole.Floodlight,
25865
- Spotlight: DeviceRole.Spotlight,
25866
- PirSensor: DeviceRole.PirSensor,
25867
- Chime: DeviceRole.Chime,
25868
- Autotrack: DeviceRole.Autotrack,
25869
- Nightvision: DeviceRole.Nightvision,
25870
- PrivacyMask: DeviceRole.PrivacyMask
25871
- };
25872
- AccessoryKind.Siren, AccessoryKind.Floodlight, AccessoryKind.Spotlight, AccessoryKind.PirSensor, AccessoryKind.Chime, AccessoryKind.Autotrack, AccessoryKind.Nightvision, AccessoryKind.PrivacyMask;
25873
- 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;
25874
- new Set(Object.values(DeviceType));
25875
- DeviceFeature.BatteryOperated;
25842
+ function validateExpressionSource(src) {
25843
+ const names = Object.keys(src.bindings);
25844
+ if (names.length > 32) return `too many bindings (${names.length} > 32)`;
25845
+ for (const name of names) {
25846
+ if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
25847
+ if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
25848
+ if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
25849
+ }
25850
+ const compiled = compileExpressionSafe(src.expr);
25851
+ if (!compiled.ok) return compiled.error;
25852
+ const bound = new Set(names);
25853
+ for (const id of compiled.parsed.identifiers) {
25854
+ if (id === "now") continue;
25855
+ if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
25856
+ }
25857
+ return null;
25858
+ }
25859
+ var ExpressionBindingSourceSchema = union([
25860
+ object({
25861
+ kind: literal("field").optional(),
25862
+ sourceKey: string(),
25863
+ cap: string(),
25864
+ fieldPath: string()
25865
+ }),
25866
+ object({
25867
+ kind: literal("literal"),
25868
+ value: union([
25869
+ string(),
25870
+ number(),
25871
+ boolean(),
25872
+ _null()
25873
+ ])
25874
+ }),
25875
+ object({
25876
+ kind: literal("global"),
25877
+ sourceStableId: string(),
25878
+ cap: string(),
25879
+ fieldPath: string()
25880
+ })
25881
+ ]);
25882
+ object({
25883
+ expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
25884
+ bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
25885
+ }).superRefine((src, ctx) => {
25886
+ const err = validateExpressionSource(src);
25887
+ if (err !== null) ctx.addIssue({
25888
+ code: "custom",
25889
+ message: err,
25890
+ path: ["expr"]
25891
+ });
25892
+ });
25876
25893
  Object.freeze({
25877
25894
  "accessories.setChildHidden": {
25878
25895
  capName: "accessories",
@@ -26696,6 +26713,12 @@ Object.freeze({
26696
26713
  addonId: null,
26697
26714
  access: "view"
26698
26715
  },
26716
+ "coreBlocks.restart": {
26717
+ capName: "core-blocks",
26718
+ capScope: "system",
26719
+ addonId: null,
26720
+ access: "create"
26721
+ },
26699
26722
  "coreBlocks.setEnabled": {
26700
26723
  capName: "core-blocks",
26701
26724
  capScope: "system",
@@ -27332,12 +27355,6 @@ Object.freeze({
27332
27355
  addonId: null,
27333
27356
  access: "create"
27334
27357
  },
27335
- "deviceManager.setDeviceLinks": {
27336
- capName: "device-manager",
27337
- capScope: "system",
27338
- addonId: null,
27339
- access: "create"
27340
- },
27341
27358
  "deviceManager.setDisabled": {
27342
27359
  capName: "device-manager",
27343
27360
  capScope: "system",
@@ -31318,249 +31335,62 @@ var DeviceIdsField = array(number()).min(1);
31318
31335
  var CadenceSecField = number().int().min(2).max(3600);
31319
31336
  var FramerateField = number().int().min(1).max(60);
31320
31337
  var TargetsField = array(NcRuleTargetSchema).min(1);
31321
- var PriorityField = number().int().min(1).max(5);
31322
- /**
31323
- * Client-supplied timelapse-rule fields. The server stamps id / createdBy /
31324
- * createdAt / updatedAt / ownerUserId / lastGeneratedAt — none of them appear
31325
- * here (see the ownership note above).
31326
- */
31327
- var TimelapseRuleInputSchema = object({
31328
- name: NameField,
31329
- enabled: boolean().default(true),
31330
- /** Cameras sampled by this rule — one scratch dir + one artifact per device. */
31331
- deviceIds: DeviceIdsField,
31332
- /**
31333
- * Activation window(s). REQUIRED (unlike `NcRule`, where an absent schedule
31334
- * means "always active"): a timelapse is defined by its window boundaries —
31335
- * open clears the scratch, close assembles and delivers.
31336
- */
31337
- schedule: NcScheduleSchema,
31338
- /** Force-snapshot cadence inside the window, seconds (predecessor parity). */
31339
- cadenceSec: CadenceSecField.default(15),
31340
- /** Output frames per second of the assembled mp4 (predecessor parity). */
31341
- framerate: FramerateField.default(10),
31342
- /** `notification-output` targets the finished video/thumbnail is sent to. */
31343
- targets: TargetsField,
31344
- template: TimelapseTemplateSchema.optional(),
31345
- /** Canonical notification priority ordinal (1..5); per-target overridable. */
31346
- priority: PriorityField.default(3)
31347
- });
31348
- object({
31349
- name: NameField.optional(),
31350
- enabled: boolean().optional(),
31351
- deviceIds: DeviceIdsField.optional(),
31352
- schedule: NcScheduleSchema.optional(),
31353
- cadenceSec: CadenceSecField.optional(),
31354
- framerate: FramerateField.optional(),
31355
- targets: TargetsField.optional(),
31356
- template: TimelapseTemplateSchema.nullable().optional(),
31357
- priority: PriorityField.optional()
31358
- });
31359
- TimelapseRuleInputSchema.extend({
31360
- id: string(),
31361
- /**
31362
- * Ownership/visibility key. Absent = admin/global rule (visible to all).
31363
- * Present = personal rule owned by this userId. Server-stamped from the
31364
- * resolved caller; never trusted from a client payload.
31365
- */
31366
- ownerUserId: string().optional(),
31367
- /**
31368
- * Epoch-ms of the last successful generation — the 1-hour re-generation
31369
- * guard's durable state (predecessor parity). Absent = never generated.
31370
- */
31371
- lastGeneratedAt: number().optional(),
31372
- /** userId of the caller who created the rule (server-stamped). */
31373
- createdBy: string(),
31374
- createdAt: number(),
31375
- updatedAt: number()
31376
- });
31377
- function hfModelUrl(repo, path) {
31378
- return `https://huggingface.co/${repo}/resolve/main/${path}`;
31379
- }
31380
- /**
31381
- * Names that, when used as URL query parameters, almost certainly carry
31382
- * a secret. Matching is case-insensitive and anchored to the full param
31383
- * name to avoid false positives like `user_id` (an entity reference, not
31384
- * a credential) or `topic=password-reset` (a value happens to contain
31385
- * the substring "password" but the param name is unrelated).
31386
- *
31387
- * Add new patterns here as we encounter cameras/APIs that leak through
31388
- * the existing list.
31389
- */
31390
- var CREDENTIAL_PARAM_PATTERNS = [
31391
- /^user$/i,
31392
- /^usr$/i,
31393
- /^username$/i,
31394
- /^password$/i,
31395
- /^pwd$/i,
31396
- /^pass$/i,
31397
- /^passwd$/i,
31398
- /^secret$/i,
31399
- /^token$/i,
31400
- /^auth$/i,
31401
- /^auth[_-]?token$/i,
31402
- /^bearer$/i,
31403
- /^api[_-]?key$/i,
31404
- /^access[_-]?token$/i,
31405
- /^refresh[_-]?token$/i
31406
- ];
31407
- function isCredentialParam(name) {
31408
- return CREDENTIAL_PARAM_PATTERNS.some((re) => re.test(name));
31409
- }
31410
- /**
31411
- * Redact secrets we may surface in logs or telemetry:
31412
- * 1. `userinfo` (`user:password@host`) — the classic RTSP shape.
31413
- * 2. Query-string credential params (`?user=...&password=...`) — the
31414
- * Reolink RTMP shape and several HTTP-pushed-stream APIs.
31415
- *
31416
- * Returns the original string verbatim when the input is not a parseable
31417
- * URL — a log helper must never throw. Param values are replaced with
31418
- * `***` so an operator reading logs still sees WHICH credential was
31419
- * present (useful for debugging "wrong password" issues) without
31420
- * exposing the value.
31421
- *
31422
- * The credential-name match is case-insensitive but anchored to the
31423
- * whole param name; values like `user_id=42` or `topic=password-reset`
31424
- * are NOT masked (those are entity references, not credentials).
31425
- */
31426
- function maskUrlCredentials(rawUrl) {
31427
- try {
31428
- const u = new URL(rawUrl);
31429
- let touched = false;
31430
- if (u.username || u.password) {
31431
- u.username = "";
31432
- u.password = "";
31433
- touched = true;
31434
- }
31435
- for (const key of Array.from(u.searchParams.keys())) if (isCredentialParam(key)) {
31436
- u.searchParams.set(key, "***");
31437
- touched = true;
31438
- }
31439
- return touched ? u.toString() : rawUrl;
31440
- } catch {
31441
- return rawUrl;
31442
- }
31443
- }
31444
- /**
31445
- * Fixed-capacity ring buffer. When full, push() overwrites the oldest entry.
31446
- * drain() returns up to maxCount items in FIFO order and removes them.
31447
- */
31448
- var RingBuffer = class {
31449
- capacity;
31450
- items;
31451
- head = 0;
31452
- tail = 0;
31453
- count = 0;
31454
- constructor(capacity) {
31455
- this.capacity = capacity;
31456
- this.items = Array.from({ length: capacity });
31457
- }
31458
- get size() {
31459
- return this.count;
31460
- }
31461
- push(item) {
31462
- this.items[this.tail] = item;
31463
- this.tail = (this.tail + 1) % this.capacity;
31464
- if (this.count < this.capacity) this.count++;
31465
- else this.head = (this.head + 1) % this.capacity;
31466
- }
31467
- drain(maxCount) {
31468
- const result = [];
31469
- const n = Math.min(maxCount, this.count);
31470
- for (let i = 0; i < n; i++) {
31471
- result.push(this.items[this.head]);
31472
- this.items[this.head] = void 0;
31473
- this.head = (this.head + 1) % this.capacity;
31474
- }
31475
- this.count -= n;
31476
- return result;
31477
- }
31478
- };
31479
- /**
31480
- * DEVICE-POOL backend → model format — the SSOT for the multi-device inference
31481
- * DESCRIPTOR path (Python per-device pools), distinct from {@link BACKEND_TO_FORMAT}
31482
- * above which is the legacy NODE runtime map (where `coreml → onnx` because
31483
- * onnxruntime-node's CoreML EP loads `.onnx`). Here a device pool runs the
31484
- * backend NATIVELY, so `coreml → coreml` and `edgetpu → tflite`. Consumed by
31485
- * `resolveDeviceEngine` (addon-pipeline) and the orchestrator's
31486
- * `parseDeviceKey`/`getNodeInferenceDevices` — the previously-duplicated
31487
- * `BACKEND_FORMAT` maps (R3/node-F2). `cpu`/`cuda` floor to onnx.
31488
- */
31489
- var DEVICE_BACKEND_TO_FORMAT = {
31490
- openvino: "openvino",
31491
- edgetpu: "tflite",
31492
- coreml: "coreml",
31493
- onnx: "onnx",
31494
- cpu: "onnx",
31495
- cuda: "onnx"
31496
- };
31497
- /**
31498
- * Evaluate `rules` against `items`. Returns the items partitioned
31499
- * into `passed` (kept by the gate) and `excluded` (dropped). When
31500
- * `rules` is empty or `zones` is empty everything passes — the gate
31501
- * is "off" without explicit configuration.
31502
- *
31503
- * `getCenter` returns the item's normalised (0–1) center point.
31504
- * `getClassName` is optional — when omitted, every rule's
31505
- * `classFilter` is treated as matching all classes (motion-stage
31506
- * semantics). Provide it for object detections so per-class rules
31507
- * narrow correctly.
31508
- */
31509
- function evaluateZoneRules(items, zones, rules, getCenter, getClassName) {
31510
- if (rules.length === 0 || zones.length === 0) return {
31511
- passed: items,
31512
- excluded: []
31513
- };
31514
- const activeRules = rules.filter((r) => r.enabled !== false);
31515
- const includeRules = activeRules.filter((r) => r.mode === "include");
31516
- const excludeRules = activeRules.filter((r) => r.mode === "exclude");
31517
- const whitelistMode = includeRules.length > 0;
31518
- const zonesById = new Map(zones.map((z) => [z.id, z]));
31519
- const passed = [];
31520
- const excluded = [];
31521
- for (const item of items) {
31522
- const center = getCenter(item);
31523
- const className = getClassName?.(item);
31524
- const inInclude = includeRules.some((r) => ruleApplies(r, center, className, zonesById));
31525
- const inExclude = excludeRules.some((r) => ruleApplies(r, center, className, zonesById));
31526
- if (whitelistMode) if (inInclude && !inExclude) passed.push(item);
31527
- else excluded.push(item);
31528
- else if (inExclude) excluded.push(item);
31529
- else passed.push(item);
31530
- }
31531
- return {
31532
- passed,
31533
- excluded
31534
- };
31535
- }
31536
- function ruleApplies(rule, center, className, zonesById) {
31537
- if (rule.classFilter && rule.classFilter.length > 0) {
31538
- if (className !== void 0 && !rule.classFilter.includes(className)) return false;
31539
- }
31540
- for (const zoneId of rule.zoneIds) {
31541
- const zone = zonesById.get(zoneId);
31542
- if (!zone) continue;
31543
- if (pointInPolygon(center, zone.polygon)) return true;
31544
- }
31545
- return false;
31546
- }
31338
+ var PriorityField = number().int().min(1).max(5);
31547
31339
  /**
31548
- * Standard ray-casting point-in-polygon. Polygon vertices are
31549
- * normalised (0–1); the test point is tooframe-resolution-
31550
- * agnostic. Boundary points count as inside (treats `<` as `<=` on
31551
- * the y-edge crossing).
31340
+ * Client-supplied timelapse-rule fields. The server stamps id / createdBy /
31341
+ * createdAt / updatedAt / ownerUserId / lastGeneratedAtnone of them appear
31342
+ * here (see the ownership note above).
31552
31343
  */
31553
- function pointInPolygon(point, polygon) {
31554
- if (polygon.length < 3) return false;
31555
- let inside = false;
31556
- const { x, y } = point;
31557
- for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
31558
- const a = polygon[i];
31559
- const b = polygon[j];
31560
- if (a.y > y !== b.y > y && x < (b.x - a.x) * (y - a.y) / (b.y - a.y || Number.EPSILON) + a.x) inside = !inside;
31561
- }
31562
- return inside;
31563
- }
31344
+ var TimelapseRuleInputSchema = object({
31345
+ name: NameField,
31346
+ enabled: boolean().default(true),
31347
+ /** Cameras sampled by this rule — one scratch dir + one artifact per device. */
31348
+ deviceIds: DeviceIdsField,
31349
+ /**
31350
+ * Activation window(s). REQUIRED (unlike `NcRule`, where an absent schedule
31351
+ * means "always active"): a timelapse is defined by its window boundaries
31352
+ * open clears the scratch, close assembles and delivers.
31353
+ */
31354
+ schedule: NcScheduleSchema,
31355
+ /** Force-snapshot cadence inside the window, seconds (predecessor parity). */
31356
+ cadenceSec: CadenceSecField.default(15),
31357
+ /** Output frames per second of the assembled mp4 (predecessor parity). */
31358
+ framerate: FramerateField.default(10),
31359
+ /** `notification-output` targets the finished video/thumbnail is sent to. */
31360
+ targets: TargetsField,
31361
+ template: TimelapseTemplateSchema.optional(),
31362
+ /** Canonical notification priority ordinal (1..5); per-target overridable. */
31363
+ priority: PriorityField.default(3)
31364
+ });
31365
+ object({
31366
+ name: NameField.optional(),
31367
+ enabled: boolean().optional(),
31368
+ deviceIds: DeviceIdsField.optional(),
31369
+ schedule: NcScheduleSchema.optional(),
31370
+ cadenceSec: CadenceSecField.optional(),
31371
+ framerate: FramerateField.optional(),
31372
+ targets: TargetsField.optional(),
31373
+ template: TimelapseTemplateSchema.nullable().optional(),
31374
+ priority: PriorityField.optional()
31375
+ });
31376
+ TimelapseRuleInputSchema.extend({
31377
+ id: string(),
31378
+ /**
31379
+ * Ownership/visibility key. Absent = admin/global rule (visible to all).
31380
+ * Present = personal rule owned by this userId. Server-stamped from the
31381
+ * resolved caller; never trusted from a client payload.
31382
+ */
31383
+ ownerUserId: string().optional(),
31384
+ /**
31385
+ * Epoch-ms of the last successful generation — the 1-hour re-generation
31386
+ * guard's durable state (predecessor parity). Absent = never generated.
31387
+ */
31388
+ lastGeneratedAt: number().optional(),
31389
+ /** userId of the caller who created the rule (server-stamped). */
31390
+ createdBy: string(),
31391
+ createdAt: number(),
31392
+ updatedAt: number()
31393
+ });
31564
31394
  var DETAIL_CROP_PADDING_KEY = "detailCropPaddingRatio";
31565
31395
  var DETAIL_CROP_SQUARE_KEY = "detailCropSquare";
31566
31396
  /**
@@ -31879,6 +31709,193 @@ function pickNativeLeaseOverride(view) {
31879
31709
  }
31880
31710
  return readNativeLeaseOverride(flat);
31881
31711
  }
31712
+ function hfModelUrl(repo, path) {
31713
+ return `https://huggingface.co/${repo}/resolve/main/${path}`;
31714
+ }
31715
+ /**
31716
+ * Names that, when used as URL query parameters, almost certainly carry
31717
+ * a secret. Matching is case-insensitive and anchored to the full param
31718
+ * name to avoid false positives like `user_id` (an entity reference, not
31719
+ * a credential) or `topic=password-reset` (a value happens to contain
31720
+ * the substring "password" but the param name is unrelated).
31721
+ *
31722
+ * Add new patterns here as we encounter cameras/APIs that leak through
31723
+ * the existing list.
31724
+ */
31725
+ var CREDENTIAL_PARAM_PATTERNS = [
31726
+ /^user$/i,
31727
+ /^usr$/i,
31728
+ /^username$/i,
31729
+ /^password$/i,
31730
+ /^pwd$/i,
31731
+ /^pass$/i,
31732
+ /^passwd$/i,
31733
+ /^secret$/i,
31734
+ /^token$/i,
31735
+ /^auth$/i,
31736
+ /^auth[_-]?token$/i,
31737
+ /^bearer$/i,
31738
+ /^api[_-]?key$/i,
31739
+ /^access[_-]?token$/i,
31740
+ /^refresh[_-]?token$/i
31741
+ ];
31742
+ function isCredentialParam(name) {
31743
+ return CREDENTIAL_PARAM_PATTERNS.some((re) => re.test(name));
31744
+ }
31745
+ /**
31746
+ * Redact secrets we may surface in logs or telemetry:
31747
+ * 1. `userinfo` (`user:password@host`) — the classic RTSP shape.
31748
+ * 2. Query-string credential params (`?user=...&password=...`) — the
31749
+ * Reolink RTMP shape and several HTTP-pushed-stream APIs.
31750
+ *
31751
+ * Returns the original string verbatim when the input is not a parseable
31752
+ * URL — a log helper must never throw. Param values are replaced with
31753
+ * `***` so an operator reading logs still sees WHICH credential was
31754
+ * present (useful for debugging "wrong password" issues) without
31755
+ * exposing the value.
31756
+ *
31757
+ * The credential-name match is case-insensitive but anchored to the
31758
+ * whole param name; values like `user_id=42` or `topic=password-reset`
31759
+ * are NOT masked (those are entity references, not credentials).
31760
+ */
31761
+ function maskUrlCredentials(rawUrl) {
31762
+ try {
31763
+ const u = new URL(rawUrl);
31764
+ let touched = false;
31765
+ if (u.username || u.password) {
31766
+ u.username = "";
31767
+ u.password = "";
31768
+ touched = true;
31769
+ }
31770
+ for (const key of Array.from(u.searchParams.keys())) if (isCredentialParam(key)) {
31771
+ u.searchParams.set(key, "***");
31772
+ touched = true;
31773
+ }
31774
+ return touched ? u.toString() : rawUrl;
31775
+ } catch {
31776
+ return rawUrl;
31777
+ }
31778
+ }
31779
+ /**
31780
+ * Fixed-capacity ring buffer. When full, push() overwrites the oldest entry.
31781
+ * drain() returns up to maxCount items in FIFO order and removes them.
31782
+ */
31783
+ var RingBuffer = class {
31784
+ capacity;
31785
+ items;
31786
+ head = 0;
31787
+ tail = 0;
31788
+ count = 0;
31789
+ constructor(capacity) {
31790
+ this.capacity = capacity;
31791
+ this.items = Array.from({ length: capacity });
31792
+ }
31793
+ get size() {
31794
+ return this.count;
31795
+ }
31796
+ push(item) {
31797
+ this.items[this.tail] = item;
31798
+ this.tail = (this.tail + 1) % this.capacity;
31799
+ if (this.count < this.capacity) this.count++;
31800
+ else this.head = (this.head + 1) % this.capacity;
31801
+ }
31802
+ drain(maxCount) {
31803
+ const result = [];
31804
+ const n = Math.min(maxCount, this.count);
31805
+ for (let i = 0; i < n; i++) {
31806
+ result.push(this.items[this.head]);
31807
+ this.items[this.head] = void 0;
31808
+ this.head = (this.head + 1) % this.capacity;
31809
+ }
31810
+ this.count -= n;
31811
+ return result;
31812
+ }
31813
+ };
31814
+ /**
31815
+ * DEVICE-POOL backend → model format — the SSOT for the multi-device inference
31816
+ * DESCRIPTOR path (Python per-device pools), distinct from {@link BACKEND_TO_FORMAT}
31817
+ * above which is the legacy NODE runtime map (where `coreml → onnx` because
31818
+ * onnxruntime-node's CoreML EP loads `.onnx`). Here a device pool runs the
31819
+ * backend NATIVELY, so `coreml → coreml` and `edgetpu → tflite`. Consumed by
31820
+ * `resolveDeviceEngine` (addon-pipeline) and the orchestrator's
31821
+ * `parseDeviceKey`/`getNodeInferenceDevices` — the previously-duplicated
31822
+ * `BACKEND_FORMAT` maps (R3/node-F2). `cpu`/`cuda` floor to onnx.
31823
+ */
31824
+ var DEVICE_BACKEND_TO_FORMAT = {
31825
+ openvino: "openvino",
31826
+ edgetpu: "tflite",
31827
+ coreml: "coreml",
31828
+ onnx: "onnx",
31829
+ cpu: "onnx",
31830
+ cuda: "onnx"
31831
+ };
31832
+ /**
31833
+ * Evaluate `rules` against `items`. Returns the items partitioned
31834
+ * into `passed` (kept by the gate) and `excluded` (dropped). When
31835
+ * `rules` is empty or `zones` is empty everything passes — the gate
31836
+ * is "off" without explicit configuration.
31837
+ *
31838
+ * `getCenter` returns the item's normalised (0–1) center point.
31839
+ * `getClassName` is optional — when omitted, every rule's
31840
+ * `classFilter` is treated as matching all classes (motion-stage
31841
+ * semantics). Provide it for object detections so per-class rules
31842
+ * narrow correctly.
31843
+ */
31844
+ function evaluateZoneRules(items, zones, rules, getCenter, getClassName) {
31845
+ if (rules.length === 0 || zones.length === 0) return {
31846
+ passed: items,
31847
+ excluded: []
31848
+ };
31849
+ const activeRules = rules.filter((r) => r.enabled !== false);
31850
+ const includeRules = activeRules.filter((r) => r.mode === "include");
31851
+ const excludeRules = activeRules.filter((r) => r.mode === "exclude");
31852
+ const whitelistMode = includeRules.length > 0;
31853
+ const zonesById = new Map(zones.map((z) => [z.id, z]));
31854
+ const passed = [];
31855
+ const excluded = [];
31856
+ for (const item of items) {
31857
+ const center = getCenter(item);
31858
+ const className = getClassName?.(item);
31859
+ const inInclude = includeRules.some((r) => ruleApplies(r, center, className, zonesById));
31860
+ const inExclude = excludeRules.some((r) => ruleApplies(r, center, className, zonesById));
31861
+ if (whitelistMode) if (inInclude && !inExclude) passed.push(item);
31862
+ else excluded.push(item);
31863
+ else if (inExclude) excluded.push(item);
31864
+ else passed.push(item);
31865
+ }
31866
+ return {
31867
+ passed,
31868
+ excluded
31869
+ };
31870
+ }
31871
+ function ruleApplies(rule, center, className, zonesById) {
31872
+ if (rule.classFilter && rule.classFilter.length > 0) {
31873
+ if (className !== void 0 && !rule.classFilter.includes(className)) return false;
31874
+ }
31875
+ for (const zoneId of rule.zoneIds) {
31876
+ const zone = zonesById.get(zoneId);
31877
+ if (!zone) continue;
31878
+ if (pointInPolygon(center, zone.polygon)) return true;
31879
+ }
31880
+ return false;
31881
+ }
31882
+ /**
31883
+ * Standard ray-casting point-in-polygon. Polygon vertices are
31884
+ * normalised (0–1); the test point is too — frame-resolution-
31885
+ * agnostic. Boundary points count as inside (treats `<` as `<=` on
31886
+ * the y-edge crossing).
31887
+ */
31888
+ function pointInPolygon(point, polygon) {
31889
+ if (polygon.length < 3) return false;
31890
+ let inside = false;
31891
+ const { x, y } = point;
31892
+ for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
31893
+ const a = polygon[i];
31894
+ const b = polygon[j];
31895
+ if (a.y > y !== b.y > y && x < (b.x - a.x) * (y - a.y) / (b.y - a.y || Number.EPSILON) + a.x) inside = !inside;
31896
+ }
31897
+ return inside;
31898
+ }
31882
31899
  var AUTO = {
31883
31900
  value: "auto",
31884
31901
  label: "Auto"