@camstack/addon-export-hap 1.2.15 → 1.2.17

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.
@@ -7080,7 +7080,7 @@ method(object({ deviceId: number() }), array(StreamSourceEntrySchema)), method(o
7080
7080
  input: unknown()
7081
7081
  }), unknown(), { kind: "mutation" }), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), unknown().nullable()), method(object({ deviceId: number() }), RawStateResultSchema.nullable(), { auth: "protected" });
7082
7082
  //#endregion
7083
- //#region ../types/dist/fmp4-box-splitter-B53u9-Nu.mjs
7083
+ //#region ../types/dist/canonical-hash-rO1sRmEK.mjs
7084
7084
  var AUDIO_ENCODER_BY_CODEC = {
7085
7085
  opus: "libopus",
7086
7086
  aac: "aac",
@@ -7374,37 +7374,6 @@ function buildFfmpegArgs(inv) {
7374
7374
  ...inv.audioSidecar ? buildAudioSidecarArgs(inv.audioSidecar) : []
7375
7375
  ];
7376
7376
  }
7377
- /**
7378
- * Deterministic SHA-256 hash of an arbitrary serialisable value. The
7379
- * canonical form sorts object keys alphabetically at every depth so two
7380
- * structurally-equal inputs with different key insertion orders produce
7381
- * the same hash. Returns a 64-char lowercase hex digest.
7382
- *
7383
- * Used by export adapters (Alexa, HAP) to short-circuit re-discovery /
7384
- * accessory-rebuild work when the upstream shape is byte-identical to
7385
- * the last applied state — preventing user-visible "re-discovery"
7386
- * notifications on every addon-runner respawn. Each respawn re-fires
7387
- * `DeviceBindingsChanged` for every cap registration, which without
7388
- * this guard would propagate redundant pushes.
7389
- *
7390
- * Note: this is a SYMPTOMATIC fix layered on top of the binding-change
7391
- * subscription. The proper fix is a single "device ready" lifecycle
7392
- * barrier so exports react only when the full cap set has landed —
7393
- * tracked separately for post-HA-integration work.
7394
- */
7395
- function canonicalHash(value) {
7396
- const canonical = JSON.stringify(value, replaceWithSortedKeys);
7397
- return (0, node_crypto.createHash)("sha256").update(canonical ?? "").digest("hex");
7398
- }
7399
- function replaceWithSortedKeys(_key, value) {
7400
- if (value && typeof value === "object" && !Array.isArray(value)) {
7401
- const obj = value;
7402
- const out = {};
7403
- for (const k of Object.keys(obj).toSorted()) out[k] = obj[k];
7404
- return out;
7405
- }
7406
- return value;
7407
- }
7408
7377
  var DEFAULT_MAX_UNIT_BYTES = 16 * 1024 * 1024;
7409
7378
  /** Header size for a normal box, and for one carrying a 64-bit `largesize`. */
7410
7379
  var BOX_HEADER_BYTES = 8;
@@ -7577,6 +7546,37 @@ var Fmp4BoxSplitter = class {
7577
7546
  return [];
7578
7547
  }
7579
7548
  };
7549
+ /**
7550
+ * Deterministic SHA-256 hash of an arbitrary serialisable value. The
7551
+ * canonical form sorts object keys alphabetically at every depth so two
7552
+ * structurally-equal inputs with different key insertion orders produce
7553
+ * the same hash. Returns a 64-char lowercase hex digest.
7554
+ *
7555
+ * Used by export adapters (Alexa, HAP) to short-circuit re-discovery /
7556
+ * accessory-rebuild work when the upstream shape is byte-identical to
7557
+ * the last applied state — preventing user-visible "re-discovery"
7558
+ * notifications on every addon-runner respawn. Each respawn re-fires
7559
+ * `DeviceBindingsChanged` for every cap registration, which without
7560
+ * this guard would propagate redundant pushes.
7561
+ *
7562
+ * Note: this is a SYMPTOMATIC fix layered on top of the binding-change
7563
+ * subscription. The proper fix is a single "device ready" lifecycle
7564
+ * barrier so exports react only when the full cap set has landed —
7565
+ * tracked separately for post-HA-integration work.
7566
+ */
7567
+ function canonicalHash(value) {
7568
+ const canonical = JSON.stringify(value, replaceWithSortedKeys);
7569
+ return (0, node_crypto.createHash)("sha256").update(canonical ?? "").digest("hex");
7570
+ }
7571
+ function replaceWithSortedKeys(_key, value) {
7572
+ if (value && typeof value === "object" && !Array.isArray(value)) {
7573
+ const obj = value;
7574
+ const out = {};
7575
+ for (const k of Object.keys(obj).toSorted()) out[k] = obj[k];
7576
+ return out;
7577
+ }
7578
+ return value;
7579
+ }
7580
7580
  //#endregion
7581
7581
  //#region ../types/dist/err-msg-IQTHeDzc.mjs
7582
7582
  /**
@@ -9656,6 +9656,69 @@ var StreamFormatSchema = _enum([
9656
9656
  "mjpeg",
9657
9657
  "rtsp"
9658
9658
  ]);
9659
+ /** A container `produceEventMedia` can emit. */
9660
+ var EventMediaKindSchema = _enum(["mp4", "gif"]);
9661
+ /**
9662
+ * One produced artifact, referenced by HANDLE.
9663
+ *
9664
+ * Never inline bytes: a produced clip is 200 KB–5 MB and every consumer of this
9665
+ * method is in another runner ([D9](../../../../docs/decisions/adr-0009.md),
9666
+ * [D18](../../../../docs/decisions/adr-0018.md) — cross-process media is fetched
9667
+ * on demand, compressed, by handle). `bytes` is here so a caller can decide
9668
+ * whether it wants the fetch at all.
9669
+ */
9670
+ var EventMediaArtifactSchema = object({
9671
+ kind: EventMediaKindSchema,
9672
+ /** Opaque, single-camera, short-lived. Redeem with `fetchEventMedia`. */
9673
+ handle: string(),
9674
+ /**
9675
+ * The node holding the bytes — the ROUTING key for `fetchEventMedia`.
9676
+ *
9677
+ * `stream-broker` is a singleton cap and an unpinned call never leaves the
9678
+ * hub, so a handle produced on an agent's broker would be redeemed against
9679
+ * the hub's store and come back `null`. Same contract, same field name and
9680
+ * the same reason as `FrameHandleSchema.nodeId`: the producer stamps where it
9681
+ * lives and the consumer pins to it.
9682
+ */
9683
+ nodeId: string(),
9684
+ mime: string(),
9685
+ bytes: number().int(),
9686
+ width: number().int(),
9687
+ height: number().int()
9688
+ });
9689
+ /**
9690
+ * What a production actually covered — the answer to the only question an
9691
+ * operator asks about a notification clip.
9692
+ *
9693
+ * `fromTs`/`toTs` are WALL CLOCK, derived from the ring's own packet timeline,
9694
+ * so a caller can state "this clip starts 4.1 s before the event" instead of
9695
+ * inferring it from a duration. A production whose `fromTs` is later than the
9696
+ * event is a production with no pre-roll, and that is exactly the defect this
9697
+ * method exists to make visible rather than plausible.
9698
+ */
9699
+ var EventMediaCoverageSchema = object({
9700
+ fromTs: number(),
9701
+ toTs: number(),
9702
+ /** Encoded packets in the muxed window. */
9703
+ packets: number().int()
9704
+ });
9705
+ /**
9706
+ * The result of ONE cut, in every container the caller asked for.
9707
+ *
9708
+ * Every artifact in `media` came out of the SAME window of the SAME rendition —
9709
+ * that is the whole reason this is one method rather than one call per format.
9710
+ * A consumer attaching a gif and a video can no longer show two different
9711
+ * moments, because it never chose two sources.
9712
+ */
9713
+ var EventMediaProductionSchema = object({
9714
+ media: array(EventMediaArtifactSchema).readonly(),
9715
+ coverage: EventMediaCoverageSchema,
9716
+ /** The rendition actually cut from — what the default or the fallback chose. */
9717
+ profile: CamProfileSchema,
9718
+ /** `copy` = the camera's own H.264, untouched. `encode` = re-encoded (H.265
9719
+ * source, a downscale, or a playback rate other than 1). */
9720
+ video: _enum(["copy", "encode"])
9721
+ });
9659
9722
  var RtspRestreamEntrySchema = object({
9660
9723
  brokerId: string(),
9661
9724
  url: string(),
@@ -10055,6 +10118,56 @@ method(object({
10055
10118
  }), {
10056
10119
  kind: "mutation",
10057
10120
  auth: "admin"
10121
+ }), method(object({
10122
+ deviceId: number(),
10123
+ /** Absent = the largest H.264 rendition at or below 1080p, which is
10124
+ * also the one that can be copied. Falls back to whatever the ring
10125
+ * actually retained, and the answer says which. */
10126
+ profile: CamProfileSchema.optional(),
10127
+ aroundMs: number(),
10128
+ preSeconds: number().min(0).max(20).default(4),
10129
+ postSeconds: number().min(0).max(20).default(6),
10130
+ kinds: array(EventMediaKindSchema).min(1).default(["mp4"]),
10131
+ /** GIF geometry. The video keeps the source's own. */
10132
+ gifMaxWidth: number().int().min(120).max(1280).default(640),
10133
+ /**
10134
+ * The gif's own PLAYBACK rate in frames per second — what the finished
10135
+ * gif runs at, not how many source frames feed it. The decimation that
10136
+ * feeds it samples `gifFps / gifSpeed` source frames per second, so at
10137
+ * the defaults a 12 fps gif is built out of 3 source frames a second.
10138
+ */
10139
+ gifFps: number().int().min(1).max(15).default(12),
10140
+ /**
10141
+ * How fast the GIF plays against real time, independent of `speed`.
10142
+ *
10143
+ * 4× by default, by operator request: a notification gif is glanced at
10144
+ * on a lock screen, so a ~12 s window has to be over in ~3 s. It stays
10145
+ * a separate knob from `speed` even though both now default to 4 —
10146
+ * a caller wanting a real-time video and a fast gif must not have to
10147
+ * choose.
10148
+ */
10149
+ gifSpeed: number().min(1).max(8).default(4),
10150
+ /**
10151
+ * Playback rate of the VIDEO. Also 4× by default, by operator decision.
10152
+ *
10153
+ * `1` is real time and is the ONLY value that allows the copy branch —
10154
+ * anything else forces `libx264` over the window. That was priced
10155
+ * before it was chosen: a per-event burst measured at 0.23 s and 254 KB
10156
+ * on a real 615 720p cut, against 922 KB for the copy it replaces. A
10157
+ * re-encode is capped at 720p (`EVENT_CLIP_ENCODE_MAX_WIDTH`), because
10158
+ * once the decode is forced the width stops being free.
10159
+ */
10160
+ speed: number().min(1).max(8).default(4)
10161
+ }), EventMediaProductionSchema, {
10162
+ kind: "mutation",
10163
+ auth: "admin"
10164
+ }), method(object({ handle: string() }), object({
10165
+ base64: string(),
10166
+ mime: string(),
10167
+ bytes: number().int()
10168
+ }).nullable(), {
10169
+ kind: "mutation",
10170
+ auth: "admin"
10058
10171
  }), method(_void(), array(CameraStreamSchema).readonly()), method(_void(), array(ProfileSlotSchema).readonly()), method(object({ brokerId: string() }), BrokerStatsSchema), method(object({ brokerId: string() }), object({
10059
10172
  probed: boolean(),
10060
10173
  summary: string()
@@ -10247,25 +10360,6 @@ DeviceType.Camera, method(object({ deviceId: number().int().nonnegative() }), ar
10247
10360
  }),
10248
10361
  lastChangedAt: number()
10249
10362
  });
10250
- /**
10251
- * core-blocks — user-authored TypeScript, stored in the kernel and executed in
10252
- * its own process.
10253
- *
10254
- * Spec: `docs/superpowers/specs/2026-08-04-core-blocks-and-synthetic-devices-design.md`.
10255
- *
10256
- * The first use is **owning devices without being a device provider**: a block
10257
- * declares devices under a system or custom integration and drives their state,
10258
- * with the same `ctx` an addon gets. Automations come later; nothing here
10259
- * models a trigger.
10260
- *
10261
- * **Stated plainly, because it does not change by being true:** a block has an
10262
- * addon's powers — devices, storage, the event bus, `ctx.api`. It is a plugin
10263
- * with no review step. What makes that survivable is not a sandbox, it is
10264
- * PROCESS ISOLATION: one process per block, supervised by `CrashSupervisor`,
10265
- * so a block that throws or never returns is marked `failed` and visible
10266
- * instead of taking the hub with it (D6). Every method here is admin-only, and
10267
- * must stay so.
10268
- */
10269
10363
  /** Where a block runs. The operator chooses — a block driving a device on an
10270
10364
  * agent is the reason placement is not fixed to the hub. */
10271
10365
  var CoreBlockPlacementSchema = union([literal("hub"), string().min(1)]);
@@ -10337,6 +10431,9 @@ method(object({}), object({ blocks: array(CoreBlockSchema) }), { auth: "admin" }
10337
10431
  }), object({ block: CoreBlockSchema }), {
10338
10432
  kind: "mutation",
10339
10433
  auth: "admin"
10434
+ }), method(object({ blockId: string() }), object({ block: CoreBlockSchema }), {
10435
+ kind: "mutation",
10436
+ auth: "admin"
10340
10437
  }), method(object({ code: string() }), CoreBlockCompileResultSchema, {
10341
10438
  kind: "mutation",
10342
10439
  auth: "admin"
@@ -11096,601 +11193,6 @@ var deviceExportCapability = {
11096
11193
  unexposeDevice: method(UnexposeInputSchema, _void(), { kind: "mutation" })
11097
11194
  }
11098
11195
  };
11099
- /**
11100
- * Resource-bound constants for the safe expression engine.
11101
- *
11102
- * Every bound is defense-in-depth: the grammar is non-Turing-complete (no
11103
- * loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
11104
- * O(nodeCount) by construction. These caps merely put a hard ceiling on the
11105
- * work a single author-supplied expression can request, so a hostile or
11106
- * accidental pathological string can never spend unbounded CPU/memory.
11107
- */
11108
- /** Max source length (chars) — checked BEFORE tokenizing so a huge string is
11109
- * rejected without allocation. */
11110
- var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
11111
- /** A legal binding / identifier name. */
11112
- var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
11113
- /** Binding names an author may NOT use: `now` is auto-injected; the literal
11114
- * keywords lex as values, not identifiers, so binding to them is meaningless. */
11115
- var RESERVED_BINDING_NAMES = new Set([
11116
- "now",
11117
- "true",
11118
- "false",
11119
- "null"
11120
- ]);
11121
- /**
11122
- * Error types for the safe expression engine. Two distinct classes so callers
11123
- * can tell a compile-time (grammar) failure from a runtime (evaluation)
11124
- * failure — both are non-fatal to the host: read paths degrade to "skip link".
11125
- */
11126
- /** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
11127
- * the failure is anchored to a character (author-facing inline feedback). */
11128
- var ExpressionParseError = class extends Error {
11129
- position;
11130
- constructor(message, position) {
11131
- super(message);
11132
- this.name = "ExpressionParseError";
11133
- this.position = position;
11134
- }
11135
- };
11136
- /** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
11137
- * result, unknown builtin, step-budget exceeded). */
11138
- var ExpressionEvalError = class extends Error {
11139
- constructor(message) {
11140
- super(message);
11141
- this.name = "ExpressionEvalError";
11142
- }
11143
- };
11144
- /**
11145
- * Frozen, null-prototype builtin function table for the expression engine
11146
- * (spec §4 rule 4). The table is the SOLE surface of callable functions: the
11147
- * parser rejects any callee not in it, and the evaluator gates each call on an
11148
- * own-property check against it.
11149
- *
11150
- * Because the object has a NULL prototype AND is `Object.freeze`d:
11151
- * - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
11152
- * - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
11153
- * (there is no `Object.prototype` in the chain), so those names are not
11154
- * callable — they are simply "unknown function" at parse time.
11155
- *
11156
- * Every numeric argument is validated as a finite number and every numeric
11157
- * RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
11158
- * (`pow(10,400)` → Infinity) all raise `ExpressionEvalError` and fail the link
11159
- * closed rather than emitting a garbage value.
11160
- */
11161
- function asFiniteNumber(value, name, index) {
11162
- if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
11163
- return value;
11164
- }
11165
- function asString$1(value, name, index) {
11166
- if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
11167
- return value;
11168
- }
11169
- function finiteResult(value, name) {
11170
- if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
11171
- return value;
11172
- }
11173
- function allFiniteNumbers(args, name) {
11174
- return args.map((a, idx) => asFiniteNumber(a, name, idx));
11175
- }
11176
- var INF = Number.POSITIVE_INFINITY;
11177
- var table = {
11178
- min: {
11179
- minArgs: 1,
11180
- maxArgs: INF,
11181
- apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
11182
- },
11183
- max: {
11184
- minArgs: 1,
11185
- maxArgs: INF,
11186
- apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
11187
- },
11188
- abs: {
11189
- minArgs: 1,
11190
- maxArgs: 1,
11191
- apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
11192
- },
11193
- floor: {
11194
- minArgs: 1,
11195
- maxArgs: 1,
11196
- apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
11197
- },
11198
- ceil: {
11199
- minArgs: 1,
11200
- maxArgs: 1,
11201
- apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
11202
- },
11203
- sqrt: {
11204
- minArgs: 1,
11205
- maxArgs: 1,
11206
- apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
11207
- },
11208
- round: {
11209
- minArgs: 1,
11210
- maxArgs: 2,
11211
- apply: (args) => {
11212
- const x = asFiniteNumber(args[0], "round", 0);
11213
- const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
11214
- if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
11215
- const factor = 10 ** digits;
11216
- return finiteResult(Math.round(x * factor) / factor, "round");
11217
- }
11218
- },
11219
- pow: {
11220
- minArgs: 2,
11221
- maxArgs: 2,
11222
- apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
11223
- },
11224
- clamp: {
11225
- minArgs: 3,
11226
- maxArgs: 3,
11227
- apply: (args) => {
11228
- const x = asFiniteNumber(args[0], "clamp", 0);
11229
- const lo = asFiniteNumber(args[1], "clamp", 1);
11230
- const hi = asFiniteNumber(args[2], "clamp", 2);
11231
- if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
11232
- return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
11233
- }
11234
- },
11235
- avg: {
11236
- minArgs: 1,
11237
- maxArgs: INF,
11238
- apply: (args) => {
11239
- const nums = allFiniteNumbers(args, "avg");
11240
- return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
11241
- }
11242
- },
11243
- sum: {
11244
- minArgs: 1,
11245
- maxArgs: INF,
11246
- apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
11247
- },
11248
- coalesce: {
11249
- minArgs: 1,
11250
- maxArgs: INF,
11251
- apply: (args) => {
11252
- for (const a of args) if (a !== null) return a;
11253
- return null;
11254
- }
11255
- },
11256
- age: {
11257
- minArgs: 2,
11258
- maxArgs: 2,
11259
- apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
11260
- },
11261
- convert: {
11262
- minArgs: 3,
11263
- maxArgs: 3,
11264
- apply: (args, hooks) => {
11265
- const x = asFiniteNumber(args[0], "convert", 0);
11266
- const from = asString$1(args[1], "convert", 1).trim();
11267
- const to = asString$1(args[2], "convert", 2).trim();
11268
- if (hooks.convert) {
11269
- const out = hooks.convert(x, from, to);
11270
- if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
11271
- return finiteResult(out, "convert");
11272
- }
11273
- if (from === to) return x;
11274
- throw new ExpressionEvalError("convert: unit conversion table not installed");
11275
- }
11276
- }
11277
- };
11278
- Object.freeze(Object.assign(Object.create(null), table));
11279
- /** The set of valid builtin names — used by the parser to reject unknown
11280
- * callees at parse time (immediate author feedback). */
11281
- var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
11282
- /**
11283
- * Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
11284
- * zero-dependency. The grammar is deliberately boring: decimal numbers,
11285
- * single/double-quoted strings with a tiny escape set, identifiers, the three
11286
- * value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
11287
- * outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
11288
- * is a parse error with a source position, so member access / assignment /
11289
- * template literals are lexically impossible.
11290
- */
11291
- var KEYWORDS = new Set([
11292
- "true",
11293
- "false",
11294
- "null"
11295
- ]);
11296
- function isDigit(ch) {
11297
- return ch >= "0" && ch <= "9";
11298
- }
11299
- function isIdentStart(ch) {
11300
- return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
11301
- }
11302
- function isIdentPart(ch) {
11303
- return isIdentStart(ch) || isDigit(ch);
11304
- }
11305
- function isWhitespace(ch) {
11306
- return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
11307
- }
11308
- /** Tokenize `source` into a flat token list ending with a single `eof` token.
11309
- * Throws `ExpressionParseError` on any illegal character or unterminated
11310
- * string. */
11311
- function tokenize(source) {
11312
- if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
11313
- const tokens = [];
11314
- let i = 0;
11315
- const n = source.length;
11316
- while (i < n) {
11317
- const ch = source[i];
11318
- if (isWhitespace(ch)) {
11319
- i += 1;
11320
- continue;
11321
- }
11322
- if (isDigit(ch)) {
11323
- const start = i;
11324
- while (i < n && isDigit(source[i])) i += 1;
11325
- if (i < n && source[i] === ".") {
11326
- if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
11327
- i += 1;
11328
- while (i < n && isDigit(source[i])) i += 1;
11329
- }
11330
- const text = source.slice(start, i);
11331
- const value = Number(text);
11332
- if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
11333
- tokens.push({
11334
- type: "number",
11335
- value,
11336
- pos: start
11337
- });
11338
- continue;
11339
- }
11340
- if (ch === "'" || ch === "\"") {
11341
- const quote = ch;
11342
- const start = i;
11343
- i += 1;
11344
- let out = "";
11345
- let closed = false;
11346
- while (i < n) {
11347
- const c = source[i];
11348
- if (c === "\\") {
11349
- const next = i + 1 < n ? source[i + 1] : "";
11350
- if (next === "\\" || next === "'" || next === "\"") {
11351
- out += next;
11352
- i += 2;
11353
- continue;
11354
- }
11355
- throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
11356
- }
11357
- if (c === quote) {
11358
- closed = true;
11359
- i += 1;
11360
- break;
11361
- }
11362
- out += c;
11363
- i += 1;
11364
- }
11365
- if (!closed) throw new ExpressionParseError("unterminated string literal", start);
11366
- tokens.push({
11367
- type: "string",
11368
- value: out,
11369
- pos: start
11370
- });
11371
- continue;
11372
- }
11373
- if (isIdentStart(ch)) {
11374
- const start = i;
11375
- while (i < n && isIdentPart(source[i])) i += 1;
11376
- const text = source.slice(start, i);
11377
- if (KEYWORDS.has(text)) tokens.push({
11378
- type: "keyword",
11379
- keyword: keywordOf(text),
11380
- pos: start
11381
- });
11382
- else tokens.push({
11383
- type: "identifier",
11384
- name: text,
11385
- pos: start
11386
- });
11387
- continue;
11388
- }
11389
- const two = i + 1 < n ? source.slice(i, i + 2) : "";
11390
- if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
11391
- tokens.push({
11392
- type: "punct",
11393
- punct: two,
11394
- pos: i
11395
- });
11396
- i += 2;
11397
- continue;
11398
- }
11399
- if (isSinglePunct(ch)) {
11400
- tokens.push({
11401
- type: "punct",
11402
- punct: ch,
11403
- pos: i
11404
- });
11405
- i += 1;
11406
- continue;
11407
- }
11408
- throw new ExpressionParseError(`unexpected character '${ch}'`, i);
11409
- }
11410
- tokens.push({
11411
- type: "eof",
11412
- pos: n
11413
- });
11414
- return tokens;
11415
- }
11416
- function keywordOf(text) {
11417
- if (text === "true") return "true";
11418
- if (text === "false") return "false";
11419
- return "null";
11420
- }
11421
- function isSinglePunct(ch) {
11422
- return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
11423
- }
11424
- /**
11425
- * Pratt (precedence-climbing) parser for the safe expression mini-language.
11426
- *
11427
- * Precedence (low → high): ternary `?:` (right-assoc) → `||` → `&&` → equality
11428
- * → relational → additive → multiplicative → unary `! -` → call / primary.
11429
- * Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
11430
- * string validated against the builtin table at parse time, so an unknown
11431
- * function is rejected immediately (author feedback) and a persisted expression
11432
- * that references a since-removed builtin degrades at read.
11433
- *
11434
- * A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
11435
- * arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
11436
- */
11437
- /** Binary/logical operator precedence (higher binds tighter). */
11438
- var BINARY_PRECEDENCE = {
11439
- "||": 1,
11440
- "&&": 2,
11441
- "==": 3,
11442
- "!=": 3,
11443
- "<": 4,
11444
- "<=": 4,
11445
- ">": 4,
11446
- ">=": 4,
11447
- "+": 5,
11448
- "-": 5,
11449
- "*": 6,
11450
- "/": 6,
11451
- "%": 6
11452
- };
11453
- function isLogicalOp(op) {
11454
- return op === "&&" || op === "||";
11455
- }
11456
- function isBinaryOp(op) {
11457
- return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
11458
- }
11459
- var Parser = class {
11460
- tokens;
11461
- pos = 0;
11462
- nodeCount = 0;
11463
- identifiers = /* @__PURE__ */ new Set();
11464
- callees = /* @__PURE__ */ new Set();
11465
- constructor(tokens) {
11466
- this.tokens = tokens;
11467
- }
11468
- parse() {
11469
- const ast = this.parseTernary();
11470
- const tok = this.peek();
11471
- if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
11472
- return {
11473
- ast,
11474
- identifiers: this.identifiers,
11475
- callees: this.callees,
11476
- nodeCount: this.nodeCount
11477
- };
11478
- }
11479
- peek() {
11480
- return this.tokens[this.pos];
11481
- }
11482
- next() {
11483
- return this.tokens[this.pos++];
11484
- }
11485
- /** Consume a punctuator token, erroring if the next token isn't it. */
11486
- expectPunct(punct) {
11487
- const tok = this.peek();
11488
- if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
11489
- this.pos += 1;
11490
- }
11491
- matchPunct(punct) {
11492
- const tok = this.peek();
11493
- if (tok.type === "punct" && tok.punct === punct) {
11494
- this.pos += 1;
11495
- return true;
11496
- }
11497
- return false;
11498
- }
11499
- countNode() {
11500
- this.nodeCount += 1;
11501
- if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
11502
- }
11503
- parseTernary() {
11504
- const test = this.parseBinary(1);
11505
- if (this.matchPunct("?")) {
11506
- const consequent = this.parseTernary();
11507
- this.expectPunct(":");
11508
- const alternate = this.parseTernary();
11509
- this.countNode();
11510
- return {
11511
- kind: "conditional",
11512
- test,
11513
- consequent,
11514
- alternate
11515
- };
11516
- }
11517
- return test;
11518
- }
11519
- parseBinary(minPrec) {
11520
- let left = this.parseUnary();
11521
- for (;;) {
11522
- const tok = this.peek();
11523
- if (tok.type !== "punct") break;
11524
- const prec = BINARY_PRECEDENCE[tok.punct];
11525
- if (prec === void 0 || prec < minPrec) break;
11526
- const op = tok.punct;
11527
- this.pos += 1;
11528
- const right = this.parseBinary(prec + 1);
11529
- this.countNode();
11530
- if (isLogicalOp(op)) left = {
11531
- kind: "logical",
11532
- op,
11533
- left,
11534
- right
11535
- };
11536
- else if (isBinaryOp(op)) left = {
11537
- kind: "binary",
11538
- op,
11539
- left,
11540
- right
11541
- };
11542
- else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
11543
- }
11544
- return left;
11545
- }
11546
- parseUnary() {
11547
- const tok = this.peek();
11548
- if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
11549
- const op = tok.punct;
11550
- this.pos += 1;
11551
- const operand = this.parseUnary();
11552
- this.countNode();
11553
- return {
11554
- kind: "unary",
11555
- op,
11556
- operand
11557
- };
11558
- }
11559
- return this.parsePrimary();
11560
- }
11561
- parsePrimary() {
11562
- const tok = this.next();
11563
- switch (tok.type) {
11564
- case "number":
11565
- this.countNode();
11566
- return {
11567
- kind: "literal",
11568
- value: tok.value
11569
- };
11570
- case "string":
11571
- this.countNode();
11572
- return {
11573
- kind: "literal",
11574
- value: tok.value
11575
- };
11576
- case "keyword":
11577
- this.countNode();
11578
- return {
11579
- kind: "literal",
11580
- value: tok.keyword === "null" ? null : tok.keyword === "true"
11581
- };
11582
- case "identifier": {
11583
- const nextTok = this.peek();
11584
- if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
11585
- this.identifiers.add(tok.name);
11586
- this.countNode();
11587
- return {
11588
- kind: "identifier",
11589
- name: tok.name
11590
- };
11591
- }
11592
- case "punct":
11593
- if (tok.punct === "(") {
11594
- const inner = this.parseTernary();
11595
- this.expectPunct(")");
11596
- return inner;
11597
- }
11598
- throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
11599
- case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
11600
- }
11601
- }
11602
- parseCall(callee, pos) {
11603
- if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
11604
- this.expectPunct("(");
11605
- const args = [];
11606
- if (!this.matchPunct(")")) for (;;) {
11607
- args.push(this.parseTernary());
11608
- if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
11609
- if (this.matchPunct(",")) continue;
11610
- this.expectPunct(")");
11611
- break;
11612
- }
11613
- this.callees.add(callee);
11614
- this.countNode();
11615
- return {
11616
- kind: "call",
11617
- callee,
11618
- args
11619
- };
11620
- }
11621
- };
11622
- /** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
11623
- * `ExpressionParseError` on any lexical or grammatical failure. */
11624
- function parseExpression(source) {
11625
- return new Parser(tokenize(source)).parse();
11626
- }
11627
- /**
11628
- * LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
11629
- * by expr"). The cache stores BOTH successes and failures (negative caching),
11630
- * so a corrupt persisted string costs exactly one tokenize+parse total — not
11631
- * one per read on a hot resolve path.
11632
- *
11633
- * The cache is a module-level singleton: entries are pure, content-addressed
11634
- * ASTs keyed by the raw source string, so sharing one instance across all
11635
- * callers is safe and maximises hit rate.
11636
- */
11637
- var cache = /* @__PURE__ */ new Map();
11638
- function getCached(source) {
11639
- const hit = cache.get(source);
11640
- if (hit !== void 0) {
11641
- cache.delete(source);
11642
- cache.set(source, hit);
11643
- return hit;
11644
- }
11645
- let result;
11646
- try {
11647
- result = {
11648
- ok: true,
11649
- parsed: parseExpression(source)
11650
- };
11651
- } catch (err) {
11652
- result = {
11653
- ok: false,
11654
- error: err instanceof ExpressionParseError ? err.message : String(err)
11655
- };
11656
- }
11657
- cache.set(source, result);
11658
- if (cache.size > 256) {
11659
- const oldest = cache.keys().next().value;
11660
- if (oldest !== void 0) cache.delete(oldest);
11661
- }
11662
- return result;
11663
- }
11664
- /** Compile `source`, returning a discriminated result instead of throwing.
11665
- * Used by read paths that must degrade rather than raise. LRU/negative-cached. */
11666
- function compileExpressionSafe(source) {
11667
- return getCached(source);
11668
- }
11669
- Object.freeze({});
11670
- /**
11671
- * Author-time validation. Returns `null` when the source is valid, else a
11672
- * human-readable error message. Checks: the expression compiles; binding count
11673
- * is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
11674
- * is not reserved (`now`/keywords) and does not shadow a builtin; and every
11675
- * FREE identifier of the AST is covered by a binding or the injected `now`.
11676
- */
11677
- function validateExpressionSource(src) {
11678
- const names = Object.keys(src.bindings);
11679
- if (names.length > 32) return `too many bindings (${names.length} > 32)`;
11680
- for (const name of names) {
11681
- if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
11682
- if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
11683
- if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
11684
- }
11685
- const compiled = compileExpressionSafe(src.expr);
11686
- if (!compiled.ok) return compiled.error;
11687
- const bound = new Set(names);
11688
- for (const id of compiled.parsed.identifiers) {
11689
- if (id === "now") continue;
11690
- if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
11691
- }
11692
- return null;
11693
- }
11694
11196
  var ProviderStatusSchema = object({
11695
11197
  connected: boolean(),
11696
11198
  deviceCount: number(),
@@ -11815,92 +11317,6 @@ var ChildLayoutEntrySchema = object({
11815
11317
  order: number().optional(),
11816
11318
  collapsed: boolean().optional()
11817
11319
  });
11818
- /** Cap-wire shape of a DeviceLink — structurally mirrors `DeviceLink` in
11819
- * `device-management.ts`. Source is a union: a FIELD source copies a sibling
11820
- * accessory's status field (`kind` optional/absent for wire compat); a
11821
- * LITERAL source carries a per-device constant (no sibling is read); a
11822
- * GLOBAL source (P2e) copies ANY device's status field, addressed by the
11823
- * source device's full re-sync-stable `stableId`. */
11824
- var DeviceLinkFieldSourceSchema = object({
11825
- kind: literal("field").optional(),
11826
- sourceKey: string(),
11827
- cap: string(),
11828
- fieldPath: string()
11829
- });
11830
- var DeviceLinkLiteralSourceSchema = object({
11831
- kind: literal("literal"),
11832
- value: union([
11833
- string(),
11834
- number(),
11835
- boolean(),
11836
- _null()
11837
- ])
11838
- });
11839
- var DeviceLinkGlobalSourceSchema = object({
11840
- kind: literal("global"),
11841
- sourceStableId: string(),
11842
- cap: string(),
11843
- fieldPath: string()
11844
- });
11845
- /** Expression source (Stage X): compute the target field from N named bindings
11846
- * via the safe expression engine. Bindings are field | literal | global — never
11847
- * another expression (no nesting). The `superRefine` runs the SAME author-time
11848
- * validation as `validateExpressionSource` (compiles the expr, checks binding
11849
- * names + identifier coverage) so every wire boundary that parses a DeviceLink
11850
- * (tRPC mount, kernel create pre-seed, projection output) validates-at-write.
11851
- * Compiles are LRU-cached, so repeated validation of the same expr is a hit. */
11852
- var DeviceLinkExpressionSourceSchema = object({
11853
- kind: literal("expression"),
11854
- expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
11855
- bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), union([
11856
- DeviceLinkFieldSourceSchema,
11857
- DeviceLinkLiteralSourceSchema,
11858
- DeviceLinkGlobalSourceSchema
11859
- ]))
11860
- }).superRefine((src, ctx) => {
11861
- const err = validateExpressionSource(src);
11862
- if (err !== null) ctx.addIssue({
11863
- code: "custom",
11864
- message: err,
11865
- path: ["expr"]
11866
- });
11867
- });
11868
- var DeviceLinkSchema = object({
11869
- id: string(),
11870
- source: union([
11871
- DeviceLinkFieldSourceSchema,
11872
- DeviceLinkLiteralSourceSchema,
11873
- DeviceLinkGlobalSourceSchema,
11874
- DeviceLinkExpressionSourceSchema
11875
- ]),
11876
- target: object({
11877
- cap: string(),
11878
- fieldPath: string(),
11879
- itemKey: string().optional()
11880
- }),
11881
- transform: discriminatedUnion("kind", [
11882
- object({ kind: literal("identity") }),
11883
- object({
11884
- kind: literal("enum-map"),
11885
- mapping: record(string(), union([
11886
- string(),
11887
- number(),
11888
- boolean()
11889
- ])),
11890
- fallback: union([
11891
- string(),
11892
- number(),
11893
- boolean()
11894
- ]).optional()
11895
- }),
11896
- object({
11897
- kind: literal("linear"),
11898
- scale: number(),
11899
- offset: number(),
11900
- clamp: tuple([number(), number()]).readonly().optional()
11901
- })
11902
- ]).optional()
11903
- });
11904
11320
  /** Cap-wire shape of a per-cap display refinement — mirrors
11905
11321
  * `DeviceCapDisplayOverride` in `device-management.ts`. */
11906
11322
  var DeviceCapDisplayOverrideSchema = object({
@@ -11980,8 +11396,6 @@ var DeviceInfoSchema = object({
11980
11396
  * named accordion sections (with optional intra-section order). See
11981
11397
  * `DeviceMeta.childLayout`. Absent ⇒ no layout declared. */
11982
11398
  childLayout: array(ChildLayoutEntrySchema).readonly().optional(),
11983
- /** Operator-authored cross-device field wirings. See `DeviceMeta.deviceLinks`. */
11984
- deviceLinks: array(DeviceLinkSchema).readonly().optional(),
11985
11399
  /** Operator-authored per-device display override. See `DeviceMeta.display`. */
11986
11400
  display: DeviceDisplayOverrideSchema.optional()
11987
11401
  });
@@ -11990,7 +11404,7 @@ var ConfigEntrySchema = object({
11990
11404
  value: unknown(),
11991
11405
  description: string().optional()
11992
11406
  });
11993
- var DeviceLinkModeSchema = _enum(["auto", "manual"]);
11407
+ var LinkedDevicesModeSchema = _enum(["auto", "manual"]);
11994
11408
  /** One resolved linked device — the compact projection consumers need. */
11995
11409
  var LinkedDeviceSchema = object({
11996
11410
  deviceId: number(),
@@ -12053,8 +11467,6 @@ var DeviceMetaSchema = object({
12053
11467
  * accordion sections (with optional intra-section order). See
12054
11468
  * `DeviceMeta.childLayout`. Absent ⇒ no layout declared. */
12055
11469
  childLayout: array(ChildLayoutEntrySchema).readonly().optional(),
12056
- /** Operator-authored cross-device field wirings. See `DeviceMeta.deviceLinks`. */
12057
- deviceLinks: array(DeviceLinkSchema).readonly().optional(),
12058
11470
  /** Semantic role string (`DeviceRole`) — propagated from the spawn pre-seed.
12059
11471
  * Optional: only present for accessory children that carry a known role. */
12060
11472
  role: string().nullable().optional(),
@@ -12147,12 +11559,6 @@ method(object({
12147
11559
  }), _void(), {
12148
11560
  kind: "mutation",
12149
11561
  auth: "admin"
12150
- }), method(object({
12151
- deviceId: number(),
12152
- deviceLinks: array(DeviceLinkSchema).readonly()
12153
- }), _void(), {
12154
- kind: "mutation",
12155
- auth: "admin"
12156
11562
  }), method(object({
12157
11563
  deviceId: number(),
12158
11564
  display: DeviceDisplayOverrideSchema.nullable()
@@ -12234,7 +11640,7 @@ method(object({
12234
11640
  * shipping 293 rows to find 12. */
12235
11641
  isCamera: boolean().optional()
12236
11642
  }), array(DeviceInfoSchema)), method(object({ deviceId: number() }), DeviceInfoSchema.nullable()), method(object({ parentDeviceId: number() }), array(DeviceInfoSchema)), method(object({ deviceId: number() }), object({
12237
- mode: DeviceLinkModeSchema,
11643
+ mode: LinkedDevicesModeSchema,
12238
11644
  devices: array(LinkedDeviceSchema)
12239
11645
  })), method(object({ deviceId: number() }), array(StreamSourceEntrySchema$1)), method(object({ deviceId: number() }), array(ConfigEntrySchema)), method(object({ deviceId: number() }), ConfigUISchemaOutput), method(object({
12240
11646
  deviceId: number(),
@@ -12267,11 +11673,7 @@ method(object({
12267
11673
  deviceId: number(),
12268
11674
  entries: array(object({
12269
11675
  capName: string(),
12270
- kind: _enum([
12271
- "native",
12272
- "wrapped",
12273
- "linked"
12274
- ]),
11676
+ kind: _enum(["native", "wrapped"]),
12275
11677
  providerAddonId: string(),
12276
11678
  providerNodeId: string(),
12277
11679
  nativeAddonId: string()
@@ -12280,11 +11682,7 @@ method(object({
12280
11682
  deviceId: number(),
12281
11683
  entries: array(object({
12282
11684
  capName: string(),
12283
- kind: _enum([
12284
- "native",
12285
- "wrapped",
12286
- "linked"
12287
- ]),
11685
+ kind: _enum(["native", "wrapped"]),
12288
11686
  providerAddonId: string(),
12289
11687
  providerNodeId: string(),
12290
11688
  nativeAddonId: string()
@@ -13087,7 +12485,7 @@ var MotionAnalysisResultSchema = object({
13087
12485
  frameHeight: number(),
13088
12486
  analysisMs: number()
13089
12487
  });
13090
- method(object({
12488
+ DeviceType.Camera, method(object({
13091
12489
  deviceId: number(),
13092
12490
  frame: FrameInputSchema.optional(),
13093
12491
  frameHandle: FrameHandleSchema.optional()
@@ -15276,6 +14674,18 @@ var OauthIntegrationDescriptorSchema = object({
15276
14674
  * redirect_uri that does not start with one of these. Required —
15277
14675
  * an empty list means the integration can never complete linking. */
15278
14676
  allowedRedirectPrefixes: array(string()).min(1),
14677
+ /** Paths accepted as a `redirect_uri` when the host is PRIVATE — loopback,
14678
+ * RFC1918, CGNAT (100.64/10, Tailscale), link-local, IPv6 ULA, or an
14679
+ * `.local` / `.internal` / `.ts.net` name. Exists for self-hosted clients
14680
+ * whose address the hub cannot know in advance (a Home Assistant at
14681
+ * `http://<lan-ip>:8123/auth/external/callback`). The PATH must match
14682
+ * exactly; a public host never satisfies this branch, so it is not a
14683
+ * wildcard prefix by another name. */
14684
+ allowedPrivateHostPaths: array(string()).optional(),
14685
+ /** When true this is a PUBLIC client (source is published, no secret can be
14686
+ * protected) and PKCE is mandatory: `/authorize` refuses without an S256
14687
+ * `code_challenge`, `/token` refuses without the matching `code_verifier`. */
14688
+ requiresPkce: boolean().optional(),
15279
14689
  /** Optional public origin (no trailing slash) that this integration's
15280
14690
  * issued codes/tokens should carry as the `hubUrl` claim — typically the
15281
14691
  * operator-selected external-access endpoint resolved by the addon. When
@@ -15426,7 +14836,7 @@ var TrackEnvelopeSchema = object({
15426
14836
  * `snapshots[]` references — megabytes across a page of tracks. `slim`
15427
14837
  * keeps every scalar the list surfaces actually render (ids, class(es),
15428
14838
  * label / audioLabels / importance enrichment, firstSeen/lastSeen, state,
15429
- * zonesVisited, bestEventId, envelope) and returns `positions` /
14839
+ * zonesVisited, bestEventId, envelope, hasFace) and returns `positions` /
15430
14840
  * `snapshots` as EMPTY arrays — detail views re-fetch the full row via
15431
14841
  * `getTrack`. Mirrors the event-store `projection` convention
15432
14842
  * (`getObjectEvents` et al.).
@@ -15546,6 +14956,60 @@ var TrackFlagsSchema = object({
15546
14956
  * `trained` without a re-fetch. */
15547
14957
  retrainStatus: RetrainStatusSchema
15548
14958
  });
14959
+ union([literal(1), literal(2)]);
14960
+ /**
14961
+ * WHO decided a label, and when. Carried per tier so a value can be traced to
14962
+ * the step and model that produced it — which is what makes the write rule
14963
+ * arguable after the fact ("why is 592's label `dog` and not `Canis lupus`?")
14964
+ * and what lets a migrated, UNATTRIBUTED value be told apart from a real one.
14965
+ *
14966
+ * `stepId` is the pipeline step id (`animal-classifier`, `bird-classifier`,
14967
+ * `plate-ocr`, `face-embedding`, `object-detection`), or the sentinel
14968
+ * `migration:4g` for a value the 4g migration moved from the single-slot era —
14969
+ * that value has no provenance, and the write rule lets ANY properly-attributed
14970
+ * write of the same tier replace it regardless of score.
14971
+ */
14972
+ var LabelAttributionSchema = object({
14973
+ stepId: string(),
14974
+ modelId: string().optional(),
14975
+ decidedAt: number()
14976
+ });
14977
+ /**
14978
+ * The TIERED label model (roadmap 4g), spread into `TrackSchema` and
14979
+ * `ObjectEventSchema` from ONE place so the two surfaces cannot drift — a
14980
+ * track and its events always answer the same question the same way.
14981
+ *
14982
+ * Two scalar columns, not an array: every consumer wants "the coarse one" or
14983
+ * "the fine one", and an array made both a scan. `label` is tier 1, `subLabel`
14984
+ * is tier 2, and each carries its own score + attribution.
14985
+ *
14986
+ * **Reading it.** What a human should be shown is `subLabel ?? label` — the
14987
+ * finest thing known. Before 4g the single `label` column held the finest
14988
+ * value, so a consumer that has not been updated reads the tier-1 slot and
14989
+ * shows nothing on a species-only row; that is why the migration puts every
14990
+ * pre-4g value in tier 2 (it cannot regress a display that reads the fallback)
14991
+ * and why the read surfaces were changed in the same train.
14992
+ *
14993
+ * **Writing it.** The slots are independent, which is the whole point: a
14994
+ * tier-1 write (`bird`) can never overwrite a tier-2 value (`Turdus
14995
+ * migratorius`), so fineness cannot regress by construction. Within a tier the
14996
+ * higher score wins. One rule, one implementation — see
14997
+ * `pipeline/label-tier.ts` in addon-post-analysis.
14998
+ */
14999
+ var TieredLabelFields = {
15000
+ /** Tier 1 — the sub-class. See {@link LabelTierSchema}. */
15001
+ label: string().optional(),
15002
+ /** Confidence of the tier-1 value, as reported by the deciding step. */
15003
+ labelScore: number().optional(),
15004
+ /** Provenance of the tier-1 value. See {@link LabelAttributionSchema}. */
15005
+ labelMeta: LabelAttributionSchema.optional(),
15006
+ /** Tier 2 — the instance. See {@link LabelTierSchema}. */
15007
+ subLabel: string().optional(),
15008
+ /** Confidence of the tier-2 value, as reported by the deciding step. */
15009
+ subLabelScore: number().optional(),
15010
+ /** Provenance of the tier-2 value. See {@link LabelAttributionSchema}. */
15011
+ subLabelMeta: LabelAttributionSchema.optional()
15012
+ };
15549
15013
  /** Per-camera slice of a training-export estimate. */
15550
15014
  var TrainingExportDeviceTotalsSchema = object({
15551
15015
  deviceId: number(),
@@ -15570,7 +15034,7 @@ var TrackSchema = object({
15570
15034
  trackId: string(),
15571
15035
  deviceId: number(),
15572
15036
  className: string(),
15573
- label: string().optional(),
15037
+ ...TieredLabelFields,
15574
15038
  producingDeviceName: string().optional(),
15575
15039
  /** Track provenance. Absent ⇒ `pipeline` (legacy rows). */
15576
15040
  source: TrackSourceSchema.optional(),
@@ -15609,6 +15073,24 @@ var TrackSchema = object({
15609
15073
  * Populated from the persisted envelope columns on historical reads;
15610
15074
  * absent on legacy rows, dims-less tracks and active (in-RAM) tracks. */
15611
15075
  envelope: TrackEnvelopeSchema.optional(),
15076
+ /**
15077
+ * A face DETECTOR found a face on this track — nothing more. It says the
15078
+ * detail plane produced a `face` detail; it does NOT say the face was
15079
+ * embedded, matched, above `minFacePx`, or that the recognizer was even
15080
+ * enabled. Set once and never cleared.
15081
+ *
15082
+ * **This exists so "face present but not recognised" is expressible.** A
15083
+ * recognised identity lands in `subLabel` (attributed to the face chain via
15084
+ * `subLabelMeta.stepId`), so before this field a track with an unmatched face
15085
+ * and a track with no face at all were byte-identical on the wire and no
15086
+ * surface could tell them apart. The read is `hasFace === true && subLabel
15087
+ * === undefined`.
15088
+ *
15089
+ * **Absent ≠ false.** Every row written before the column existed omits it,
15090
+ * and so does every server that predates the field — a consumer must test
15091
+ * `=== true` and render nothing otherwise, never infer "no face".
15092
+ */
15093
+ hasFace: boolean().optional(),
15612
15094
  ...TrackFlagFields,
15613
15095
  ...TrackRetrainFields
15614
15096
  });
@@ -15683,7 +15165,7 @@ var ObjectEventSchema = object({
15683
15165
  /** Omitted in slim projection. */
15684
15166
  trackId: string().optional(),
15685
15167
  className: string(),
15686
- label: string().optional(),
15168
+ ...TieredLabelFields,
15687
15169
  /** Omitted in slim projection. */
15688
15170
  confidence: number().optional(),
15689
15171
  /** Heavy JSON — omitted in slim projection. */
@@ -15764,6 +15246,173 @@ var MediaFileSchema = object({
15764
15246
  * stored blob and a `?variant=thumb` rendering without fetching either.
15765
15247
  */
15766
15248
  var MediaFileInfoSchema = MediaFileSchema.omit({ base64: true });
15249
+ /**
15250
+ * The MACRO tier of an annotation — a CLOSED set.
15251
+ *
15252
+ * This is what the exported detector predicts, so a typo here is a new class
15253
+ * with one example in it. `label` and `subLabel` are open strings by contrast:
15254
+ * the whole point of the page is teaching the model things it does not know
15255
+ * yet, and constraining that vocabulary would make it useless.
15256
+ *
15257
+ * A macro class is NEVER a label. The provider refuses a write whose `label` or
15258
+ * `subLabel` is one of these values, in any casing, because once `person`
15259
+ * exists in both tiers "every person box" stops being answerable without
15260
+ * knowing every string anyone ever typed — and the damage is retroactive.
15261
+ */
15262
+ var RetrainMacroClassSchema = _enum([
15263
+ "person",
15264
+ "vehicle",
15265
+ "animal",
15266
+ "package",
15267
+ "face",
15268
+ "plate"
15269
+ ]);
15270
+ /** A subject to learn, or a phantom to unlearn (taught by OMISSION). */
15271
+ var RetrainAnnotationKindSchema = _enum(["subject", "model_error"]);
15272
+ /** Did a human draw this box, or did the assist propose it? */
15273
+ var RetrainAnnotationSourceSchema = _enum(["operator", "assist"]);
15274
+ /** Normalised `[0,1]` rectangle against the FULL frame — the canonical form. */
15275
+ var RetrainBboxSchema = object({
15276
+ x: number(),
15277
+ y: number(),
15278
+ w: number(),
15279
+ h: number()
15280
+ });
15281
+ /**
15282
+ * One annotated subject.
15283
+ *
15284
+ * `bbox` is normalised against the full frame, ALWAYS. The per-model shapes
15285
+ * (letterboxed root / zone-cropped package / subject-cropped classifier) are
15286
+ * derived from it at export and never stored — storing them is how one feature
15287
+ * space ends up holding two crops of the same subject (D52).
15288
+ */
15289
+ var RetrainAnnotationSchema = object({
15290
+ id: string(),
15291
+ trackId: string(),
15292
+ deviceId: number(),
15293
+ /** The COPY in retrain storage — never the source track's media key. */
15294
+ mediaKey: string(),
15295
+ bbox: RetrainBboxSchema,
15296
+ macroClass: RetrainMacroClassSchema,
15297
+ label: string().optional(),
15298
+ subLabel: string().optional(),
15299
+ kind: RetrainAnnotationKindSchema,
15300
+ source: RetrainAnnotationSourceSchema,
15301
+ /** Which model proposed this box — or, on a `model_error`, drew the phantom. */
15302
+ assistModelId: string().optional(),
15303
+ assistScore: number().optional(),
15304
+ exportedInBatch: string().optional(),
15305
+ createdAt: number()
15306
+ });
15307
+ /** The write form — the server owns `id`, `createdAt` and the frame binding. */
15308
+ var RetrainAnnotationDraftSchema = RetrainAnnotationSchema.omit({
15309
+ id: true,
15310
+ trackId: true,
15311
+ deviceId: true,
15312
+ mediaKey: true,
15313
+ createdAt: true,
15314
+ exportedInBatch: true
15315
+ });
15316
+ /** A track sitting in `staging`, with everything the worklist needs to rank it. */
15317
+ var RetrainTrackSchema = object({
15318
+ trackId: string(),
15319
+ deviceId: number(),
15320
+ className: string(),
15321
+ label: string().optional(),
15322
+ firstSeen: number(),
15323
+ lastSeen: number(),
15324
+ /** How many frames the dataset already holds from this track. */
15325
+ frameCount: number().int(),
15326
+ /** How many subjects have been annotated on those frames. `0` with
15327
+ * `frameCount: 0` is exactly "staging, still to work". */
15328
+ annotationCount: number().int()
15329
+ });
15330
+ /** A frame the picker may offer — an index row, no blob was read to produce it. */
15331
+ var RetrainFrameCandidateSchema = object({
15332
+ mediaKey: string(),
15333
+ kind: MediaFileKindEnum,
15334
+ timestamp: number(),
15335
+ sizeBytes: number().int(),
15336
+ /** A copy of this original already exists — selecting it is free and cannot
15337
+ * fail, whatever became of the original. */
15338
+ copied: boolean()
15339
+ });
15340
+ /** A frame the dataset OWNS: bytes copied at selection time. */
15341
+ var RetrainFrameSchema = object({
15342
+ frameId: string(),
15343
+ deviceId: number(),
15344
+ trackId: string(),
15345
+ /** Provenance only. It may already point at nothing — that is expected. */
15346
+ sourceMediaKey: string(),
15347
+ sourceKind: MediaFileKindEnum,
15348
+ sizeBytes: number().int(),
15349
+ width: number().int(),
15350
+ height: number().int(),
15351
+ copiedAt: number()
15352
+ });
15353
+ /** Why a copy-on-select could not be honoured — named, never a silent skip. */
15354
+ var RetrainCopyRefusalSchema = _enum([
15355
+ "source-missing",
15356
+ "unreadable-image",
15357
+ "write-failed"
15358
+ ]);
15359
+ var RetrainFrameSelectionSchema = object({
15360
+ copied: array(RetrainFrameSchema).readonly(),
15361
+ refused: array(object({
15362
+ sourceMediaKey: string(),
15363
+ reason: RetrainCopyRefusalSchema
15364
+ })).readonly()
15365
+ });
15366
+ var RetrainFrameListSchema = object({
15367
+ candidates: array(RetrainFrameCandidateSchema).readonly(),
15368
+ copies: array(RetrainFrameSchema).readonly(),
15369
+ /** What the page pre-selects — the native key frame when one survives. */
15370
+ autoPickMediaKey: string().optional()
15371
+ });
15372
+ /** What the operator asked the assist to look for. */
15373
+ var RetrainAssistSubjectSchema = discriminatedUnion("kind", [object({
15374
+ kind: literal("package"),
15375
+ zone: RetrainBboxSchema.optional()
15376
+ }), object({
15377
+ kind: literal("objects"),
15378
+ modelId: string(),
15379
+ minScore: number().optional()
15380
+ })]);
15381
+ /**
15382
+ * The assist's answer — a discriminated union, because "the model saw nothing"
15383
+ * and "this node cannot run that model" lead to different next moves and a
15384
+ * nullable result cannot tell them apart.
15385
+ */
15386
+ var RetrainAssistResultSchema = discriminatedUnion("kind", [object({
15387
+ kind: literal("proposed"),
15388
+ modelId: string(),
15389
+ stepId: string(),
15390
+ minScore: number(),
15391
+ /** Drafts, ready to edit. `source: 'assist'` until the operator touches one. */
15392
+ proposals: array(RetrainAnnotationDraftSchema).readonly(),
15393
+ /** Returned by the runner but removed by the threshold. */
15394
+ belowThreshold: number().int()
15395
+ }), object({
15396
+ kind: literal("refused"),
15397
+ /** `no-zone` is ours; the rest are the runner's own refusal vocabulary. */
15398
+ reason: string(),
15399
+ detail: string().optional()
15400
+ })]);
15401
+ /** The outcome of a lifecycle move owned by the retrain page. */
15402
+ var RetrainTransitionResultSchema = object({
15403
+ trackId: string(),
15404
+ /** Where the track ended up, whatever happened. */
15405
+ retrainStatus: RetrainStatusSchema,
15406
+ /** `false` ⇒ the move was refused or was a no-op; `reason` says which. */
15407
+ changed: boolean(),
15408
+ reason: _enum([
15409
+ "unknown-track",
15410
+ "no-frames-copied",
15411
+ "not-staging",
15412
+ "not-trained",
15413
+ "unchanged"
15414
+ ]).optional()
15415
+ });
15767
15416
  var DEFAULT_EVENT_QUERY_LIMIT = 1e3;
15768
15417
  var MAX_EVENT_QUERY_LIMIT = 5e3;
15769
15418
  var DeviceEventQueryInput = object({
@@ -15818,7 +15467,7 @@ var KeyEventSchema = object({
15818
15467
  /** Track start time (firstSeen). */
15819
15468
  timestamp: number(),
15820
15469
  className: string(),
15821
- label: string().optional(),
15470
+ ...TieredLabelFields,
15822
15471
  importance: number(),
15823
15472
  /** Highest-confidence ObjectEvent id for the track (empty when none). */
15824
15473
  bestEventId: string(),
@@ -16092,6 +15741,79 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
16092
15741
  }), method(object({ deviceIds: array(number()).optional() }), object({ url: string() }), {
16093
15742
  kind: "query",
16094
15743
  auth: "admin"
15744
+ }), method(object({
15745
+ /** Empty ⇒ every camera that has staging tracks. A LIST, not a single
15746
+ * `deviceId`, deliberately: `deviceId` would make this device-bound and
15747
+ * route it at one camera's owner, and "every camera" would stop being
15748
+ * expressible at all. */
15749
+ deviceIds: array(number()).optional(),
15750
+ limit: number().int().min(1).max(500).optional()
15751
+ }), array(RetrainTrackSchema).readonly(), {
15752
+ kind: "query",
15753
+ auth: "admin"
15754
+ }), method(object({ trackId: string() }), RetrainFrameListSchema, {
15755
+ kind: "query",
15756
+ auth: "admin"
15757
+ }), method(object({
15758
+ deviceId: number(),
15759
+ trackId: string(),
15760
+ mediaKeys: array(string()).min(1)
15761
+ }), RetrainFrameSelectionSchema, {
15762
+ kind: "mutation",
15763
+ auth: "admin"
15764
+ }), method(object({
15765
+ deviceId: number(),
15766
+ trackId: string(),
15767
+ frameId: string()
15768
+ }), object({
15769
+ removed: boolean(),
15770
+ removedAnnotations: number().int()
15771
+ }), {
15772
+ kind: "mutation",
15773
+ auth: "admin"
15774
+ }), method(object({ frameId: string() }), object({
15775
+ base64: string(),
15776
+ width: number().int(),
15777
+ height: number().int()
15778
+ }), {
15779
+ kind: "query",
15780
+ auth: "admin"
15781
+ }), method(object({
15782
+ deviceId: number(),
15783
+ trackId: string(),
15784
+ frameId: string(),
15785
+ subject: RetrainAssistSubjectSchema,
15786
+ /** Which node runs it. Absent ⇒ wherever an unowned call lands. */
15787
+ nodeId: string().optional()
15788
+ }), RetrainAssistResultSchema, {
15789
+ kind: "mutation",
15790
+ auth: "admin"
15791
+ }), method(object({ trackId: string() }), array(RetrainAnnotationSchema).readonly(), {
15792
+ kind: "query",
15793
+ auth: "admin"
15794
+ }), method(object({
15795
+ deviceId: number(),
15796
+ trackId: string(),
15797
+ frameId: string(),
15798
+ annotations: array(RetrainAnnotationDraftSchema)
15799
+ }), array(RetrainAnnotationSchema).readonly(), {
15800
+ kind: "mutation",
15801
+ auth: "admin"
15802
+ }), method(object({
15803
+ deviceId: number(),
15804
+ trackId: string()
15805
+ }), RetrainTransitionResultSchema, {
15806
+ kind: "mutation",
15807
+ auth: "admin"
15808
+ }), method(object({
15809
+ deviceId: number(),
15810
+ trackId: string()
15811
+ }), RetrainTransitionResultSchema, {
15812
+ kind: "mutation",
15813
+ auth: "admin"
15814
+ }), method(object({ deviceIds: array(number()).optional() }), object({ url: string() }), {
15815
+ kind: "query",
15816
+ auth: "admin"
16095
15817
  }), method(object({
16096
15818
  eventId: string(),
16097
15819
  kind: MediaFileKindEnum.optional()
@@ -16671,6 +16393,22 @@ var DetailResultSchema = object({
16671
16393
  bbox: NativeCropBboxSchema.optional(),
16672
16394
  embedding: string().optional(),
16673
16395
  label: string().optional(),
16396
+ /**
16397
+ * The tier `label` occupies, copied VERBATIM from the producing step's
16398
+ * `StepDefinition.labelTier` (roadmap 4g). Present only when `label` is.
16399
+ *
16400
+ * It rides the wire rather than being resolved by the consumer because the
16401
+ * declaration lives with the step definition, which only the executing node
16402
+ * has: post-analysis holds no step registry, and re-deriving the tier from
16403
+ * `className` there would be exactly the inference this model exists to
16404
+ * forbid. A `label` that arrives WITHOUT this field is refused by the write
16405
+ * rule and logged (`label tier undeclared`) — an older runner therefore
16406
+ * stops enriching rather than guessing, which is why addon-pipeline is
16407
+ * deployed BEFORE addon-post-analysis.
16408
+ */
16409
+ labelTier: union([literal(1), literal(2)]).optional(),
16410
+ /** Model that produced `label` — carried into the tier's attribution. */
16411
+ labelModelId: string().optional(),
16674
16412
  alignedCropJpeg: string().optional(),
16675
16413
  /** Face short side (px) measured on the NATIVE crop surface. The `bbox`
16676
16414
  * above is detection-frame px (≈6× smaller on a 4K camera) — min-face-size
@@ -17967,6 +17705,10 @@ var SsoBridgeClaimsSchema = object({
17967
17705
  integrationId: string().optional(),
17968
17706
  /** JWT ID — unique per issued code; consumed-set enforces single-use. */
17969
17707
  jti: string().optional(),
17708
+ /** PKCE S256 challenge — set only on `oauth-code` tokens issued to a public
17709
+ * client. Its PRESENCE is what makes the verifier mandatory at exchange,
17710
+ * so the requirement travels with the code and not with mutable config. */
17711
+ codeChallenge: string().optional(),
17970
17712
  /** OAuth session registry id — set on `oauth-access`/`oauth-refresh`
17971
17713
  * tokens so the verify path can check the session is not revoked. */
17972
17714
  sessionId: string().optional()
@@ -18519,7 +18261,7 @@ var ClipPlaybackSchema = object({
18519
18261
  playbackEndpoints: array(string()).optional(),
18520
18262
  token: string().optional()
18521
18263
  });
18522
- method(object({
18264
+ DeviceType.Camera, method(object({
18523
18265
  deviceId: number(),
18524
18266
  since: number(),
18525
18267
  until: number(),
@@ -23595,13 +23337,18 @@ method(_void(), array(UserSummarySchema), { auth: "admin" }), method(CreateUserI
23595
23337
  username: string(),
23596
23338
  scopes: array(TokenScopeSchema),
23597
23339
  redirectUri: string(),
23598
- hubUrl: string()
23340
+ hubUrl: string(),
23341
+ /** PKCE (RFC 7636) S256 challenge. Baked into the signed code; a code
23342
+ * that carries one can ONLY be exchanged with the matching verifier. */
23343
+ codeChallenge: string().optional()
23599
23344
  }), object({ code: string() }), {
23600
23345
  kind: "mutation",
23601
23346
  access: "create"
23602
23347
  }), method(object({
23603
23348
  code: string(),
23604
- redirectUri: string()
23349
+ redirectUri: string(),
23350
+ /** PKCE verifier. REQUIRED when the code carries a challenge. */
23351
+ codeVerifier: string().optional()
23605
23352
  }), object({
23606
23353
  accessToken: string(),
23607
23354
  refreshToken: string(),
@@ -23941,76 +23688,705 @@ DeviceType.Camera, method(object({ deviceId: number() }), CameraOccupancySnapsho
23941
23688
  className: string().optional()
23942
23689
  }).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly());
23943
23690
  /**
23944
- * Stages a {@link ZoneRule} can apply to. Discriminator on the rules
23945
- * cap so a single CRUD surface backs every consumer; each stage has
23946
- * its own dev-state mirror slice (`motion-zone-rules`,
23947
- * `detection-zone-rules`, …) so consumer addons subscribe independently.
23691
+ * Stages a {@link ZoneRule} can apply to. Discriminator on the rules
23692
+ * cap so a single CRUD surface backs every consumer; each stage has
23693
+ * its own dev-state mirror slice (`motion-zone-rules`,
23694
+ * `detection-zone-rules`, …) so consumer addons subscribe independently.
23695
+ *
23696
+ * Extend the enum here when a new gating consumer comes online (audio
23697
+ * gating, alert filtering, …) — no other surface needs to change.
23698
+ */
23699
+ var ZoneRuleStageEnum = _enum([
23700
+ "motion",
23701
+ "detection",
23702
+ "package"
23703
+ ]);
23704
+ DeviceType.Camera, method(object({
23705
+ deviceId: number(),
23706
+ stage: ZoneRuleStageEnum
23707
+ }), array(ZoneRuleSchema).readonly()), method(object({
23708
+ deviceId: number(),
23709
+ stage: ZoneRuleStageEnum,
23710
+ rules: array(ZoneRuleSchema).readonly()
23711
+ }), _void(), {
23712
+ kind: "mutation",
23713
+ auth: "admin"
23714
+ }), object({
23715
+ motion: array(ZoneRuleSchema).readonly(),
23716
+ detection: array(ZoneRuleSchema).readonly(),
23717
+ package: array(ZoneRuleSchema).readonly()
23718
+ });
23719
+ /**
23720
+ * Accessory device helpers — shared across drivers.
23721
+ *
23722
+ * Many vendor-specific drivers register accessory child devices on
23723
+ * top of a parent (Reolink: siren / floodlight / PIR / autotrack /
23724
+ * chime; ONVIF: relay outputs; future: Tapo Hub child devices). Each
23725
+ * driver picks the right `DeviceType` + `DeviceRole` explicitly when
23726
+ * spawning, builds a name derived from the parent, and produces a
23727
+ * stableId tied to the parent so boot-restore can reconstruct the
23728
+ * relationship.
23729
+ *
23730
+ * Centralised `(kind → DeviceType)` mapping was dropped on purpose:
23731
+ * drivers may reasonably disagree on the right type for an accessory
23732
+ * (a Reolink PIR exposes a switch on/off + sensitivity, while a hypothetical
23733
+ * read-only motion-only sensor might be `DeviceType.Sensor`). Forcing
23734
+ * one canonical mapping was over-prescriptive and added a layer of
23735
+ * indirection without saving meaningful code at call sites — the
23736
+ * driver knows its own hardware best.
23737
+ */
23738
+ /**
23739
+ * Subset of `DeviceRole` values that drivers register as child
23740
+ * accessories of a parent device. Sourced verbatim from `DeviceRole`
23741
+ * — `AccessoryKind` is the alias drivers use when building accessory
23742
+ * children, so the call site reads as
23743
+ * `accessoryStableId(parent, AccessoryKind.Siren)` rather than
23744
+ * `accessoryStableId(parent, DeviceRole.Siren)` (which would imply
23745
+ * any role works, including non-accessory ones like Doorbell).
23746
+ */
23747
+ var AccessoryKind = {
23748
+ Siren: DeviceRole.Siren,
23749
+ Floodlight: DeviceRole.Floodlight,
23750
+ Spotlight: DeviceRole.Spotlight,
23751
+ PirSensor: DeviceRole.PirSensor,
23752
+ Chime: DeviceRole.Chime,
23753
+ Autotrack: DeviceRole.Autotrack,
23754
+ Nightvision: DeviceRole.Nightvision,
23755
+ PrivacyMask: DeviceRole.PrivacyMask
23756
+ };
23757
+ AccessoryKind.Siren, AccessoryKind.Floodlight, AccessoryKind.Spotlight, AccessoryKind.PirSensor, AccessoryKind.Chime, AccessoryKind.Autotrack, AccessoryKind.Nightvision, AccessoryKind.PrivacyMask;
23758
+ 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;
23759
+ new Set(Object.values(DeviceType));
23760
+ DeviceFeature.BatteryOperated;
23761
+ /**
23762
+ * Error types for the safe expression engine. Two distinct classes so callers
23763
+ * can tell a compile-time (grammar) failure from a runtime (evaluation)
23764
+ * failure — both are non-fatal to the host: read paths degrade to "skip link".
23765
+ */
23766
+ /** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
23767
+ * the failure is anchored to a character (author-facing inline feedback). */
23768
+ var ExpressionParseError = class extends Error {
23769
+ position;
23770
+ constructor(message, position) {
23771
+ super(message);
23772
+ this.name = "ExpressionParseError";
23773
+ this.position = position;
23774
+ }
23775
+ };
23776
+ /** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
23777
+ * result, unknown builtin, step-budget exceeded). */
23778
+ var ExpressionEvalError = class extends Error {
23779
+ constructor(message) {
23780
+ super(message);
23781
+ this.name = "ExpressionEvalError";
23782
+ }
23783
+ };
23784
+ /**
23785
+ * Frozen, null-prototype builtin function table for the expression engine
23786
+ * (spec §4 rule 4). The table is the SOLE surface of callable functions: the
23787
+ * parser rejects any callee not in it, and the evaluator gates each call on an
23788
+ * own-property check against it.
23789
+ *
23790
+ * Because the object has a NULL prototype AND is `Object.freeze`d:
23791
+ * - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
23792
+ * - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
23793
+ * (there is no `Object.prototype` in the chain), so those names are not
23794
+ * callable — they are simply "unknown function" at parse time.
23795
+ *
23796
+ * Every numeric argument is validated as a finite number and every numeric
23797
+ * RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
23798
+ * (`pow(10,400)` → Infinity) all raise `ExpressionEvalError` and fail the link
23799
+ * closed rather than emitting a garbage value.
23800
+ */
23801
+ function asFiniteNumber(value, name, index) {
23802
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
23803
+ return value;
23804
+ }
23805
+ function asString$1(value, name, index) {
23806
+ if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
23807
+ return value;
23808
+ }
23809
+ function finiteResult(value, name) {
23810
+ if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
23811
+ return value;
23812
+ }
23813
+ function allFiniteNumbers(args, name) {
23814
+ return args.map((a, idx) => asFiniteNumber(a, name, idx));
23815
+ }
23816
+ var INF = Number.POSITIVE_INFINITY;
23817
+ var table = {
23818
+ min: {
23819
+ minArgs: 1,
23820
+ maxArgs: INF,
23821
+ apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
23822
+ },
23823
+ max: {
23824
+ minArgs: 1,
23825
+ maxArgs: INF,
23826
+ apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
23827
+ },
23828
+ abs: {
23829
+ minArgs: 1,
23830
+ maxArgs: 1,
23831
+ apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
23832
+ },
23833
+ floor: {
23834
+ minArgs: 1,
23835
+ maxArgs: 1,
23836
+ apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
23837
+ },
23838
+ ceil: {
23839
+ minArgs: 1,
23840
+ maxArgs: 1,
23841
+ apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
23842
+ },
23843
+ sqrt: {
23844
+ minArgs: 1,
23845
+ maxArgs: 1,
23846
+ apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
23847
+ },
23848
+ round: {
23849
+ minArgs: 1,
23850
+ maxArgs: 2,
23851
+ apply: (args) => {
23852
+ const x = asFiniteNumber(args[0], "round", 0);
23853
+ const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
23854
+ if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
23855
+ const factor = 10 ** digits;
23856
+ return finiteResult(Math.round(x * factor) / factor, "round");
23857
+ }
23858
+ },
23859
+ pow: {
23860
+ minArgs: 2,
23861
+ maxArgs: 2,
23862
+ apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
23863
+ },
23864
+ clamp: {
23865
+ minArgs: 3,
23866
+ maxArgs: 3,
23867
+ apply: (args) => {
23868
+ const x = asFiniteNumber(args[0], "clamp", 0);
23869
+ const lo = asFiniteNumber(args[1], "clamp", 1);
23870
+ const hi = asFiniteNumber(args[2], "clamp", 2);
23871
+ if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
23872
+ return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
23873
+ }
23874
+ },
23875
+ avg: {
23876
+ minArgs: 1,
23877
+ maxArgs: INF,
23878
+ apply: (args) => {
23879
+ const nums = allFiniteNumbers(args, "avg");
23880
+ return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
23881
+ }
23882
+ },
23883
+ sum: {
23884
+ minArgs: 1,
23885
+ maxArgs: INF,
23886
+ apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
23887
+ },
23888
+ coalesce: {
23889
+ minArgs: 1,
23890
+ maxArgs: INF,
23891
+ apply: (args) => {
23892
+ for (const a of args) if (a !== null) return a;
23893
+ return null;
23894
+ }
23895
+ },
23896
+ age: {
23897
+ minArgs: 2,
23898
+ maxArgs: 2,
23899
+ apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
23900
+ },
23901
+ convert: {
23902
+ minArgs: 3,
23903
+ maxArgs: 3,
23904
+ apply: (args, hooks) => {
23905
+ const x = asFiniteNumber(args[0], "convert", 0);
23906
+ const from = asString$1(args[1], "convert", 1).trim();
23907
+ const to = asString$1(args[2], "convert", 2).trim();
23908
+ if (hooks.convert) {
23909
+ const out = hooks.convert(x, from, to);
23910
+ if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
23911
+ return finiteResult(out, "convert");
23912
+ }
23913
+ if (from === to) return x;
23914
+ throw new ExpressionEvalError("convert: unit conversion table not installed");
23915
+ }
23916
+ }
23917
+ };
23918
+ Object.freeze(Object.assign(Object.create(null), table));
23919
+ /** The set of valid builtin names — used by the parser to reject unknown
23920
+ * callees at parse time (immediate author feedback). */
23921
+ var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
23922
+ /**
23923
+ * Resource-bound constants for the safe expression engine.
23924
+ *
23925
+ * Every bound is defense-in-depth: the grammar is non-Turing-complete (no
23926
+ * loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
23927
+ * O(nodeCount) by construction. These caps merely put a hard ceiling on the
23928
+ * work a single author-supplied expression can request, so a hostile or
23929
+ * accidental pathological string can never spend unbounded CPU/memory.
23930
+ */
23931
+ /** Max source length (chars) — checked BEFORE tokenizing so a huge string is
23932
+ * rejected without allocation. */
23933
+ var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
23934
+ /** A legal binding / identifier name. */
23935
+ var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
23936
+ /** Binding names an author may NOT use: `now` is auto-injected; the literal
23937
+ * keywords lex as values, not identifiers, so binding to them is meaningless. */
23938
+ var RESERVED_BINDING_NAMES = new Set([
23939
+ "now",
23940
+ "true",
23941
+ "false",
23942
+ "null"
23943
+ ]);
23944
+ /**
23945
+ * Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
23946
+ * zero-dependency. The grammar is deliberately boring: decimal numbers,
23947
+ * single/double-quoted strings with a tiny escape set, identifiers, the three
23948
+ * value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
23949
+ * outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
23950
+ * is a parse error with a source position, so member access / assignment /
23951
+ * template literals are lexically impossible.
23952
+ */
23953
+ var KEYWORDS = new Set([
23954
+ "true",
23955
+ "false",
23956
+ "null"
23957
+ ]);
23958
+ function isDigit(ch) {
23959
+ return ch >= "0" && ch <= "9";
23960
+ }
23961
+ function isIdentStart(ch) {
23962
+ return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
23963
+ }
23964
+ function isIdentPart(ch) {
23965
+ return isIdentStart(ch) || isDigit(ch);
23966
+ }
23967
+ function isWhitespace(ch) {
23968
+ return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
23969
+ }
23970
+ /** Tokenize `source` into a flat token list ending with a single `eof` token.
23971
+ * Throws `ExpressionParseError` on any illegal character or unterminated
23972
+ * string. */
23973
+ function tokenize(source) {
23974
+ if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
23975
+ const tokens = [];
23976
+ let i = 0;
23977
+ const n = source.length;
23978
+ while (i < n) {
23979
+ const ch = source[i];
23980
+ if (isWhitespace(ch)) {
23981
+ i += 1;
23982
+ continue;
23983
+ }
23984
+ if (isDigit(ch)) {
23985
+ const start = i;
23986
+ while (i < n && isDigit(source[i])) i += 1;
23987
+ if (i < n && source[i] === ".") {
23988
+ if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
23989
+ i += 1;
23990
+ while (i < n && isDigit(source[i])) i += 1;
23991
+ }
23992
+ const text = source.slice(start, i);
23993
+ const value = Number(text);
23994
+ if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
23995
+ tokens.push({
23996
+ type: "number",
23997
+ value,
23998
+ pos: start
23999
+ });
24000
+ continue;
24001
+ }
24002
+ if (ch === "'" || ch === "\"") {
24003
+ const quote = ch;
24004
+ const start = i;
24005
+ i += 1;
24006
+ let out = "";
24007
+ let closed = false;
24008
+ while (i < n) {
24009
+ const c = source[i];
24010
+ if (c === "\\") {
24011
+ const next = i + 1 < n ? source[i + 1] : "";
24012
+ if (next === "\\" || next === "'" || next === "\"") {
24013
+ out += next;
24014
+ i += 2;
24015
+ continue;
24016
+ }
24017
+ throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
24018
+ }
24019
+ if (c === quote) {
24020
+ closed = true;
24021
+ i += 1;
24022
+ break;
24023
+ }
24024
+ out += c;
24025
+ i += 1;
24026
+ }
24027
+ if (!closed) throw new ExpressionParseError("unterminated string literal", start);
24028
+ tokens.push({
24029
+ type: "string",
24030
+ value: out,
24031
+ pos: start
24032
+ });
24033
+ continue;
24034
+ }
24035
+ if (isIdentStart(ch)) {
24036
+ const start = i;
24037
+ while (i < n && isIdentPart(source[i])) i += 1;
24038
+ const text = source.slice(start, i);
24039
+ if (KEYWORDS.has(text)) tokens.push({
24040
+ type: "keyword",
24041
+ keyword: keywordOf(text),
24042
+ pos: start
24043
+ });
24044
+ else tokens.push({
24045
+ type: "identifier",
24046
+ name: text,
24047
+ pos: start
24048
+ });
24049
+ continue;
24050
+ }
24051
+ const two = i + 1 < n ? source.slice(i, i + 2) : "";
24052
+ if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
24053
+ tokens.push({
24054
+ type: "punct",
24055
+ punct: two,
24056
+ pos: i
24057
+ });
24058
+ i += 2;
24059
+ continue;
24060
+ }
24061
+ if (isSinglePunct(ch)) {
24062
+ tokens.push({
24063
+ type: "punct",
24064
+ punct: ch,
24065
+ pos: i
24066
+ });
24067
+ i += 1;
24068
+ continue;
24069
+ }
24070
+ throw new ExpressionParseError(`unexpected character '${ch}'`, i);
24071
+ }
24072
+ tokens.push({
24073
+ type: "eof",
24074
+ pos: n
24075
+ });
24076
+ return tokens;
24077
+ }
24078
+ function keywordOf(text) {
24079
+ if (text === "true") return "true";
24080
+ if (text === "false") return "false";
24081
+ return "null";
24082
+ }
24083
+ function isSinglePunct(ch) {
24084
+ return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
24085
+ }
24086
+ /**
24087
+ * Pratt (precedence-climbing) parser for the safe expression mini-language.
23948
24088
  *
23949
- * Extend the enum here when a new gating consumer comes online (audio
23950
- * gating, alert filtering, …) no other surface needs to change.
24089
+ * Precedence (low high): ternary `?:` (right-assoc) `||` `&&` → equality
24090
+ * relational additive multiplicative unary `! -` → call / primary.
24091
+ * Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
24092
+ * string validated against the builtin table at parse time, so an unknown
24093
+ * function is rejected immediately (author feedback) and a persisted expression
24094
+ * that references a since-removed builtin degrades at read.
24095
+ *
24096
+ * A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
24097
+ * arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
23951
24098
  */
23952
- var ZoneRuleStageEnum = _enum([
23953
- "motion",
23954
- "detection",
23955
- "package"
23956
- ]);
23957
- DeviceType.Camera, method(object({
23958
- deviceId: number(),
23959
- stage: ZoneRuleStageEnum
23960
- }), array(ZoneRuleSchema).readonly()), method(object({
23961
- deviceId: number(),
23962
- stage: ZoneRuleStageEnum,
23963
- rules: array(ZoneRuleSchema).readonly()
23964
- }), _void(), {
23965
- kind: "mutation",
23966
- auth: "admin"
23967
- }), object({
23968
- motion: array(ZoneRuleSchema).readonly(),
23969
- detection: array(ZoneRuleSchema).readonly(),
23970
- package: array(ZoneRuleSchema).readonly()
23971
- });
24099
+ /** Binary/logical operator precedence (higher binds tighter). */
24100
+ var BINARY_PRECEDENCE = {
24101
+ "||": 1,
24102
+ "&&": 2,
24103
+ "==": 3,
24104
+ "!=": 3,
24105
+ "<": 4,
24106
+ "<=": 4,
24107
+ ">": 4,
24108
+ ">=": 4,
24109
+ "+": 5,
24110
+ "-": 5,
24111
+ "*": 6,
24112
+ "/": 6,
24113
+ "%": 6
24114
+ };
24115
+ function isLogicalOp(op) {
24116
+ return op === "&&" || op === "||";
24117
+ }
24118
+ function isBinaryOp(op) {
24119
+ return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
24120
+ }
24121
+ var Parser = class {
24122
+ tokens;
24123
+ pos = 0;
24124
+ nodeCount = 0;
24125
+ identifiers = /* @__PURE__ */ new Set();
24126
+ callees = /* @__PURE__ */ new Set();
24127
+ constructor(tokens) {
24128
+ this.tokens = tokens;
24129
+ }
24130
+ parse() {
24131
+ const ast = this.parseTernary();
24132
+ const tok = this.peek();
24133
+ if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
24134
+ return {
24135
+ ast,
24136
+ identifiers: this.identifiers,
24137
+ callees: this.callees,
24138
+ nodeCount: this.nodeCount
24139
+ };
24140
+ }
24141
+ peek() {
24142
+ return this.tokens[this.pos];
24143
+ }
24144
+ next() {
24145
+ return this.tokens[this.pos++];
24146
+ }
24147
+ /** Consume a punctuator token, erroring if the next token isn't it. */
24148
+ expectPunct(punct) {
24149
+ const tok = this.peek();
24150
+ if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
24151
+ this.pos += 1;
24152
+ }
24153
+ matchPunct(punct) {
24154
+ const tok = this.peek();
24155
+ if (tok.type === "punct" && tok.punct === punct) {
24156
+ this.pos += 1;
24157
+ return true;
24158
+ }
24159
+ return false;
24160
+ }
24161
+ countNode() {
24162
+ this.nodeCount += 1;
24163
+ if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
24164
+ }
24165
+ parseTernary() {
24166
+ const test = this.parseBinary(1);
24167
+ if (this.matchPunct("?")) {
24168
+ const consequent = this.parseTernary();
24169
+ this.expectPunct(":");
24170
+ const alternate = this.parseTernary();
24171
+ this.countNode();
24172
+ return {
24173
+ kind: "conditional",
24174
+ test,
24175
+ consequent,
24176
+ alternate
24177
+ };
24178
+ }
24179
+ return test;
24180
+ }
24181
+ parseBinary(minPrec) {
24182
+ let left = this.parseUnary();
24183
+ for (;;) {
24184
+ const tok = this.peek();
24185
+ if (tok.type !== "punct") break;
24186
+ const prec = BINARY_PRECEDENCE[tok.punct];
24187
+ if (prec === void 0 || prec < minPrec) break;
24188
+ const op = tok.punct;
24189
+ this.pos += 1;
24190
+ const right = this.parseBinary(prec + 1);
24191
+ this.countNode();
24192
+ if (isLogicalOp(op)) left = {
24193
+ kind: "logical",
24194
+ op,
24195
+ left,
24196
+ right
24197
+ };
24198
+ else if (isBinaryOp(op)) left = {
24199
+ kind: "binary",
24200
+ op,
24201
+ left,
24202
+ right
24203
+ };
24204
+ else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
24205
+ }
24206
+ return left;
24207
+ }
24208
+ parseUnary() {
24209
+ const tok = this.peek();
24210
+ if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
24211
+ const op = tok.punct;
24212
+ this.pos += 1;
24213
+ const operand = this.parseUnary();
24214
+ this.countNode();
24215
+ return {
24216
+ kind: "unary",
24217
+ op,
24218
+ operand
24219
+ };
24220
+ }
24221
+ return this.parsePrimary();
24222
+ }
24223
+ parsePrimary() {
24224
+ const tok = this.next();
24225
+ switch (tok.type) {
24226
+ case "number":
24227
+ this.countNode();
24228
+ return {
24229
+ kind: "literal",
24230
+ value: tok.value
24231
+ };
24232
+ case "string":
24233
+ this.countNode();
24234
+ return {
24235
+ kind: "literal",
24236
+ value: tok.value
24237
+ };
24238
+ case "keyword":
24239
+ this.countNode();
24240
+ return {
24241
+ kind: "literal",
24242
+ value: tok.keyword === "null" ? null : tok.keyword === "true"
24243
+ };
24244
+ case "identifier": {
24245
+ const nextTok = this.peek();
24246
+ if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
24247
+ this.identifiers.add(tok.name);
24248
+ this.countNode();
24249
+ return {
24250
+ kind: "identifier",
24251
+ name: tok.name
24252
+ };
24253
+ }
24254
+ case "punct":
24255
+ if (tok.punct === "(") {
24256
+ const inner = this.parseTernary();
24257
+ this.expectPunct(")");
24258
+ return inner;
24259
+ }
24260
+ throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
24261
+ case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
24262
+ }
24263
+ }
24264
+ parseCall(callee, pos) {
24265
+ if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
24266
+ this.expectPunct("(");
24267
+ const args = [];
24268
+ if (!this.matchPunct(")")) for (;;) {
24269
+ args.push(this.parseTernary());
24270
+ if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
24271
+ if (this.matchPunct(",")) continue;
24272
+ this.expectPunct(")");
24273
+ break;
24274
+ }
24275
+ this.callees.add(callee);
24276
+ this.countNode();
24277
+ return {
24278
+ kind: "call",
24279
+ callee,
24280
+ args
24281
+ };
24282
+ }
24283
+ };
24284
+ /** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
24285
+ * `ExpressionParseError` on any lexical or grammatical failure. */
24286
+ function parseExpression(source) {
24287
+ return new Parser(tokenize(source)).parse();
24288
+ }
23972
24289
  /**
23973
- * Accessory device helpers shared across drivers.
23974
- *
23975
- * Many vendor-specific drivers register accessory child devices on
23976
- * top of a parent (Reolink: siren / floodlight / PIR / autotrack /
23977
- * chime; ONVIF: relay outputs; future: Tapo Hub child devices). Each
23978
- * driver picks the right `DeviceType` + `DeviceRole` explicitly when
23979
- * spawning, builds a name derived from the parent, and produces a
23980
- * stableId tied to the parent so boot-restore can reconstruct the
23981
- * relationship.
24290
+ * LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
24291
+ * by expr"). The cache stores BOTH successes and failures (negative caching),
24292
+ * so a corrupt persisted string costs exactly one tokenize+parse total — not
24293
+ * one per read on a hot resolve path.
23982
24294
  *
23983
- * Centralised `(kind DeviceType)` mapping was dropped on purpose:
23984
- * drivers may reasonably disagree on the right type for an accessory
23985
- * (a Reolink PIR exposes a switch on/off + sensitivity, while a hypothetical
23986
- * read-only motion-only sensor might be `DeviceType.Sensor`). Forcing
23987
- * one canonical mapping was over-prescriptive and added a layer of
23988
- * indirection without saving meaningful code at call sites — the
23989
- * driver knows its own hardware best.
24295
+ * The cache is a module-level singleton: entries are pure, content-addressed
24296
+ * ASTs keyed by the raw source string, so sharing one instance across all
24297
+ * callers is safe and maximises hit rate.
23990
24298
  */
24299
+ var cache = /* @__PURE__ */ new Map();
24300
+ function getCached(source) {
24301
+ const hit = cache.get(source);
24302
+ if (hit !== void 0) {
24303
+ cache.delete(source);
24304
+ cache.set(source, hit);
24305
+ return hit;
24306
+ }
24307
+ let result;
24308
+ try {
24309
+ result = {
24310
+ ok: true,
24311
+ parsed: parseExpression(source)
24312
+ };
24313
+ } catch (err) {
24314
+ result = {
24315
+ ok: false,
24316
+ error: err instanceof ExpressionParseError ? err.message : String(err)
24317
+ };
24318
+ }
24319
+ cache.set(source, result);
24320
+ if (cache.size > 256) {
24321
+ const oldest = cache.keys().next().value;
24322
+ if (oldest !== void 0) cache.delete(oldest);
24323
+ }
24324
+ return result;
24325
+ }
24326
+ /** Compile `source`, returning a discriminated result instead of throwing.
24327
+ * Used by read paths that must degrade rather than raise. LRU/negative-cached. */
24328
+ function compileExpressionSafe(source) {
24329
+ return getCached(source);
24330
+ }
24331
+ Object.freeze({});
23991
24332
  /**
23992
- * Subset of `DeviceRole` values that drivers register as child
23993
- * accessories of a parent device. Sourced verbatim from `DeviceRole`
23994
- * `AccessoryKind` is the alias drivers use when building accessory
23995
- * children, so the call site reads as
23996
- * `accessoryStableId(parent, AccessoryKind.Siren)` rather than
23997
- * `accessoryStableId(parent, DeviceRole.Siren)` (which would imply
23998
- * any role works, including non-accessory ones like Doorbell).
24333
+ * Author-time validation. Returns `null` when the source is valid, else a
24334
+ * human-readable error message. Checks: the expression compiles; binding count
24335
+ * is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
24336
+ * is not reserved (`now`/keywords) and does not shadow a builtin; and every
24337
+ * FREE identifier of the AST is covered by a binding or the injected `now`.
23999
24338
  */
24000
- var AccessoryKind = {
24001
- Siren: DeviceRole.Siren,
24002
- Floodlight: DeviceRole.Floodlight,
24003
- Spotlight: DeviceRole.Spotlight,
24004
- PirSensor: DeviceRole.PirSensor,
24005
- Chime: DeviceRole.Chime,
24006
- Autotrack: DeviceRole.Autotrack,
24007
- Nightvision: DeviceRole.Nightvision,
24008
- PrivacyMask: DeviceRole.PrivacyMask
24009
- };
24010
- AccessoryKind.Siren, AccessoryKind.Floodlight, AccessoryKind.Spotlight, AccessoryKind.PirSensor, AccessoryKind.Chime, AccessoryKind.Autotrack, AccessoryKind.Nightvision, AccessoryKind.PrivacyMask;
24011
- 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;
24012
- new Set(Object.values(DeviceType));
24013
- DeviceFeature.BatteryOperated;
24339
+ function validateExpressionSource(src) {
24340
+ const names = Object.keys(src.bindings);
24341
+ if (names.length > 32) return `too many bindings (${names.length} > 32)`;
24342
+ for (const name of names) {
24343
+ if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
24344
+ if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
24345
+ if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
24346
+ }
24347
+ const compiled = compileExpressionSafe(src.expr);
24348
+ if (!compiled.ok) return compiled.error;
24349
+ const bound = new Set(names);
24350
+ for (const id of compiled.parsed.identifiers) {
24351
+ if (id === "now") continue;
24352
+ if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
24353
+ }
24354
+ return null;
24355
+ }
24356
+ var ExpressionBindingSourceSchema = union([
24357
+ object({
24358
+ kind: literal("field").optional(),
24359
+ sourceKey: string(),
24360
+ cap: string(),
24361
+ fieldPath: string()
24362
+ }),
24363
+ object({
24364
+ kind: literal("literal"),
24365
+ value: union([
24366
+ string(),
24367
+ number(),
24368
+ boolean(),
24369
+ _null()
24370
+ ])
24371
+ }),
24372
+ object({
24373
+ kind: literal("global"),
24374
+ sourceStableId: string(),
24375
+ cap: string(),
24376
+ fieldPath: string()
24377
+ })
24378
+ ]);
24379
+ object({
24380
+ expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
24381
+ bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
24382
+ }).superRefine((src, ctx) => {
24383
+ const err = validateExpressionSource(src);
24384
+ if (err !== null) ctx.addIssue({
24385
+ code: "custom",
24386
+ message: err,
24387
+ path: ["expr"]
24388
+ });
24389
+ });
24014
24390
  Object.freeze({
24015
24391
  "accessories.setChildHidden": {
24016
24392
  capName: "accessories",
@@ -24834,6 +25210,12 @@ Object.freeze({
24834
25210
  addonId: null,
24835
25211
  access: "view"
24836
25212
  },
25213
+ "coreBlocks.restart": {
25214
+ capName: "core-blocks",
25215
+ capScope: "system",
25216
+ addonId: null,
25217
+ access: "create"
25218
+ },
24837
25219
  "coreBlocks.setEnabled": {
24838
25220
  capName: "core-blocks",
24839
25221
  capScope: "system",
@@ -25470,12 +25852,6 @@ Object.freeze({
25470
25852
  addonId: null,
25471
25853
  access: "create"
25472
25854
  },
25473
- "deviceManager.setDeviceLinks": {
25474
- capName: "device-manager",
25475
- capScope: "system",
25476
- addonId: null,
25477
- access: "create"
25478
- },
25479
25855
  "deviceManager.setDisabled": {
25480
25856
  capName: "device-manager",
25481
25857
  capScope: "system",
@@ -26988,6 +27364,12 @@ Object.freeze({
26988
27364
  addonId: null,
26989
27365
  access: "delete"
26990
27366
  },
27367
+ "pipelineAnalytics.completeRetrainTrack": {
27368
+ capName: "pipeline-analytics",
27369
+ capScope: "device",
27370
+ addonId: null,
27371
+ access: "create"
27372
+ },
26991
27373
  "pipelineAnalytics.deleteDeviceEvents": {
26992
27374
  capName: "pipeline-analytics",
26993
27375
  capScope: "device",
@@ -27000,6 +27382,12 @@ Object.freeze({
27000
27382
  addonId: null,
27001
27383
  access: "delete"
27002
27384
  },
27385
+ "pipelineAnalytics.deselectRetrainFrame": {
27386
+ capName: "pipeline-analytics",
27387
+ capScope: "device",
27388
+ addonId: null,
27389
+ access: "create"
27390
+ },
27003
27391
  "pipelineAnalytics.getActiveTracks": {
27004
27392
  capName: "pipeline-analytics",
27005
27393
  capScope: "device",
@@ -27060,6 +27448,18 @@ Object.freeze({
27060
27448
  addonId: null,
27061
27449
  access: "view"
27062
27450
  },
27451
+ "pipelineAnalytics.getRetrainExportUrl": {
27452
+ capName: "pipeline-analytics",
27453
+ capScope: "device",
27454
+ addonId: null,
27455
+ access: "view"
27456
+ },
27457
+ "pipelineAnalytics.getRetrainFrameImage": {
27458
+ capName: "pipeline-analytics",
27459
+ capScope: "device",
27460
+ addonId: null,
27461
+ access: "view"
27462
+ },
27063
27463
  "pipelineAnalytics.getSensorEvents": {
27064
27464
  capName: "pipeline-analytics",
27065
27465
  capScope: "device",
@@ -27114,6 +27514,24 @@ Object.freeze({
27114
27514
  addonId: null,
27115
27515
  access: "view"
27116
27516
  },
27517
+ "pipelineAnalytics.listRetrainAnnotations": {
27518
+ capName: "pipeline-analytics",
27519
+ capScope: "device",
27520
+ addonId: null,
27521
+ access: "view"
27522
+ },
27523
+ "pipelineAnalytics.listRetrainFrames": {
27524
+ capName: "pipeline-analytics",
27525
+ capScope: "device",
27526
+ addonId: null,
27527
+ access: "view"
27528
+ },
27529
+ "pipelineAnalytics.listRetrainStaging": {
27530
+ capName: "pipeline-analytics",
27531
+ capScope: "device",
27532
+ addonId: null,
27533
+ access: "view"
27534
+ },
27117
27535
  "pipelineAnalytics.listTrackMedia": {
27118
27536
  capName: "pipeline-analytics",
27119
27537
  capScope: "device",
@@ -27126,6 +27544,12 @@ Object.freeze({
27126
27544
  addonId: null,
27127
27545
  access: "view"
27128
27546
  },
27547
+ "pipelineAnalytics.proposeRetrainAnnotations": {
27548
+ capName: "pipeline-analytics",
27549
+ capScope: "device",
27550
+ addonId: null,
27551
+ access: "create"
27552
+ },
27129
27553
  "pipelineAnalytics.pruneEvents": {
27130
27554
  capName: "pipeline-analytics",
27131
27555
  capScope: "device",
@@ -27156,12 +27580,30 @@ Object.freeze({
27156
27580
  addonId: null,
27157
27581
  access: "create"
27158
27582
  },
27583
+ "pipelineAnalytics.restageRetrainTrack": {
27584
+ capName: "pipeline-analytics",
27585
+ capScope: "device",
27586
+ addonId: null,
27587
+ access: "create"
27588
+ },
27589
+ "pipelineAnalytics.saveRetrainAnnotations": {
27590
+ capName: "pipeline-analytics",
27591
+ capScope: "device",
27592
+ addonId: null,
27593
+ access: "create"
27594
+ },
27159
27595
  "pipelineAnalytics.searchObjectEvents": {
27160
27596
  capName: "pipeline-analytics",
27161
27597
  capScope: "device",
27162
27598
  addonId: null,
27163
27599
  access: "view"
27164
27600
  },
27601
+ "pipelineAnalytics.selectRetrainFrames": {
27602
+ capName: "pipeline-analytics",
27603
+ capScope: "device",
27604
+ addonId: null,
27605
+ access: "create"
27606
+ },
27165
27607
  "pipelineAnalytics.setTrackFlags": {
27166
27608
  capName: "pipeline-analytics",
27167
27609
  capScope: "device",
@@ -28554,6 +28996,12 @@ Object.freeze({
28554
28996
  addonId: null,
28555
28997
  access: "create"
28556
28998
  },
28999
+ "streamBroker.fetchEventMedia": {
29000
+ capName: "stream-broker",
29001
+ capScope: "system",
29002
+ addonId: null,
29003
+ access: "create"
29004
+ },
28557
29005
  "streamBroker.getAllRtspEntries": {
28558
29006
  capName: "stream-broker",
28559
29007
  capScope: "system",
@@ -28638,6 +29086,12 @@ Object.freeze({
28638
29086
  addonId: null,
28639
29087
  access: "create"
28640
29088
  },
29089
+ "streamBroker.produceEventMedia": {
29090
+ capName: "stream-broker",
29091
+ capScope: "system",
29092
+ addonId: null,
29093
+ access: "create"
29094
+ },
28641
29095
  "streamBroker.publishCameraStream": {
28642
29096
  capName: "stream-broker",
28643
29097
  capScope: "system",
@@ -43607,37 +44061,37 @@ function errMsg$8(err) {
43607
44061
  }
43608
44062
  //#endregion
43609
44063
  //#region src/mappers/builders/doorbell-delivery.ts
43610
- function isRecord(value) {
44064
+ function isRecord$1(value) {
43611
44065
  return typeof value === "object" && value !== null;
43612
44066
  }
43613
44067
  function numberOrNull(value) {
43614
44068
  return typeof value === "number" ? value : null;
43615
44069
  }
43616
44070
  function isConnectionLike(value) {
43617
- return isRecord(value) && typeof value["hasEventNotifications"] === "function";
44071
+ return isRecord$1(value) && typeof value["hasEventNotifications"] === "function";
43618
44072
  }
43619
- function isIterable(value) {
43620
- return isRecord(value) && typeof value[Symbol.iterator] === "function";
44073
+ function isIterable$1(value) {
44074
+ return isRecord$1(value) && typeof value[Symbol.iterator] === "function";
43621
44075
  }
43622
44076
  /** `accessory._server.httpServer.connections`, or null at any missing hop. */
43623
- function readConnections(accessory) {
43624
- if (!isRecord(accessory)) return null;
44077
+ function readConnections$1(accessory) {
44078
+ if (!isRecord$1(accessory)) return null;
43625
44079
  const server = accessory["_server"];
43626
- if (!isRecord(server)) return null;
44080
+ if (!isRecord$1(server)) return null;
43627
44081
  const httpServer = server["httpServer"];
43628
- if (!isRecord(httpServer)) return null;
44082
+ if (!isRecord$1(httpServer)) return null;
43629
44083
  const connections = httpServer["connections"];
43630
- return isIterable(connections) ? connections : null;
44084
+ return isIterable$1(connections) ? connections : null;
43631
44085
  }
43632
44086
  /**
43633
44087
  * Probe how far a ring on `characteristic` of `accessory` can travel RIGHT NOW.
43634
44088
  * Pure with respect to HAP state — it only reads. Never throws.
43635
44089
  */
43636
44090
  function describeDoorbellDelivery(accessory, characteristic) {
43637
- const aid = isRecord(accessory) ? numberOrNull(accessory["aid"]) : null;
43638
- const iid = isRecord(characteristic) ? numberOrNull(characteristic["iid"]) : null;
43639
- const serverPublished = isRecord(accessory) && isRecord(accessory["_server"]);
43640
- const connections = readConnections(accessory);
44091
+ const aid = isRecord$1(accessory) ? numberOrNull(accessory["aid"]) : null;
44092
+ const iid = isRecord$1(characteristic) ? numberOrNull(characteristic["iid"]) : null;
44093
+ const serverPublished = isRecord$1(accessory) && isRecord$1(accessory["_server"]);
44094
+ const connections = readConnections$1(accessory);
43641
44095
  if (connections === null) return {
43642
44096
  aid,
43643
44097
  iid,
@@ -43749,10 +44203,16 @@ async function buildMotionSensor(bctx, existing = null) {
43749
44203
  resetTimer = null;
43750
44204
  }, RESET_DEBOUNCE_MS);
43751
44205
  };
44206
+ const motionLog = ctx.logger.withTags({ deviceId: numericDeviceId });
44207
+ const hksvTrigger = existing !== null;
43752
44208
  const unsubscribe = ctx.eventBus.subscribe({ category: EventCategory.MotionOnMotionChanged }, (event) => {
43753
44209
  if (event.data.deviceId !== numericDeviceId) return;
43754
44210
  const detected = event.data.detected === true;
43755
44211
  motionService.updateCharacteristic(_homebridge_hap_nodejs.Characteristic.MotionDetected, detected);
44212
+ motionLog.debug("export-hap: motion pushed to HomeKit", { meta: {
44213
+ detected,
44214
+ hksvTrigger
44215
+ } });
43756
44216
  if (detected) armReset();
43757
44217
  else if (resetTimer) {
43758
44218
  clearTimeout(resetTimer);
@@ -44129,36 +44589,84 @@ async function probe(call, label, log) {
44129
44589
  }
44130
44590
  }
44131
44591
  //#endregion
44132
- //#region src/hksv/recording-options.ts
44592
+ //#region src/hksv/controller-census.ts
44593
+ var EMPTY_CENSUS = {
44594
+ serverPublished: false,
44595
+ connections: 0,
44596
+ adminConnections: 0,
44597
+ nonAdminConnections: 0,
44598
+ unverifiedConnections: 0,
44599
+ pairedControllers: 0,
44600
+ pairedAdmins: 0
44601
+ };
44602
+ function isRecord(value) {
44603
+ return typeof value === "object" && value !== null;
44604
+ }
44605
+ function isIterable(value) {
44606
+ return isRecord(value) && typeof value[Symbol.iterator] === "function";
44607
+ }
44608
+ function isAccessoryInfoLike(value) {
44609
+ return isRecord(value) && isRecord(value["pairedClients"]) && typeof value["hasAdminPermissions"] === "function";
44610
+ }
44611
+ /** `accessory._server.httpServer.connections`, or null at any missing hop. */
44612
+ function readConnections(accessory) {
44613
+ const server = accessory["_server"];
44614
+ if (!isRecord(server)) return null;
44615
+ const httpServer = server["httpServer"];
44616
+ if (!isRecord(httpServer)) return null;
44617
+ const connections = httpServer["connections"];
44618
+ return isIterable(connections) ? connections : null;
44619
+ }
44133
44620
  /**
44134
- * The HomeKit Secure Video ADVERTISEMENT`CameraRecordingOptions`, derived
44135
- * from what the fMP4 sink will actually produce for THIS camera.
44136
- *
44137
- * ## The rule this file exists to enforce
44138
- *
44139
- * Never advertise something we cannot serve. That is not a slogan here: it is
44140
- * the diagnosis of [D50](../../../../docs/decisions/adr-0050.md) — an
44141
- * advertised `recording` whose delegate yielded nothing put every motion-capable
44142
- * camera into a ~12 s timeout loop every 20-60 s, all day. So every number below
44143
- * is derived from the picked source (`recording-source.ts`) or from a measured
44144
- * property of the sink, and none of them is a plausible-looking constant.
44145
- *
44146
- * ## The fragment length is the subtle one
44147
- *
44148
- * HKSV requires every media fragment to be **no longer** than the length the
44149
- * controller selected. On the copy branch the fragment length is the SOURCE's
44150
- * key-frame cadence ([D80](../../../../docs/decisions/adr-0080.md)) — we do not
44151
- * get to choose it, we can only be honest about it. So:
44152
- *
44153
- * - when the camera reports its GOP (`stream-params`), the advertised length is
44154
- * the smallest offered value that COVERS it;
44155
- * - when it does not, we advertise the 4000 ms every HKSV camera uses and the
44156
- * delegate warns at `warn` with `tags: { deviceId }` if the fragments that
44157
- * actually arrive are longer.
44158
- *
44159
- * A camera whose GOP exceeds the longest value we offer does not advertise
44160
- * recording at all. See {@link deriveFragmentLengthMs}.
44621
+ * Census the accessory's HAP connections. Pure with respect to HAP state it
44622
+ * only reads and never throws.
44161
44623
  */
44624
+ function describeHapControllers(accessory) {
44625
+ if (!isRecord(accessory)) return EMPTY_CENSUS;
44626
+ const info = accessory["_accessoryInfo"];
44627
+ const paired = isAccessoryInfoLike(info) ? Object.keys(info.pairedClients) : [];
44628
+ const pairedAdmins = isAccessoryInfoLike(info) ? paired.filter((username) => info.hasAdminPermissions(username)).length : 0;
44629
+ const connections = readConnections(accessory);
44630
+ if (connections === null) return {
44631
+ ...EMPTY_CENSUS,
44632
+ pairedControllers: paired.length,
44633
+ pairedAdmins
44634
+ };
44635
+ let open = 0;
44636
+ let admins = 0;
44637
+ let nonAdmins = 0;
44638
+ let unverified = 0;
44639
+ for (const connection of connections) {
44640
+ open += 1;
44641
+ const username = isRecord(connection) ? connection["username"] : void 0;
44642
+ if (typeof username !== "string" || !isAccessoryInfoLike(info)) {
44643
+ unverified += 1;
44644
+ continue;
44645
+ }
44646
+ if (info.hasAdminPermissions(username)) admins += 1;
44647
+ else nonAdmins += 1;
44648
+ }
44649
+ return {
44650
+ serverPublished: true,
44651
+ connections: open,
44652
+ adminConnections: admins,
44653
+ nonAdminConnections: nonAdmins,
44654
+ unverifiedConnections: unverified,
44655
+ pairedControllers: paired.length,
44656
+ pairedAdmins
44657
+ };
44658
+ }
44659
+ /**
44660
+ * True when NO connected controller may write HKSV state. Every
44661
+ * `SelectedCameraRecordingConfiguration` write such a controller sends is
44662
+ * refused before it reaches us, so the recording configuration can never
44663
+ * arrive and the caller must say so out loud.
44664
+ */
44665
+ function noAdminControllerConnected(census) {
44666
+ return census.serverPublished && census.connections > 0 && census.adminConnections === 0;
44667
+ }
44668
+ //#endregion
44669
+ //#region src/hksv/recording-options.ts
44162
44670
  /**
44163
44671
  * The prebuffer we promise. HAP's floor is 4000 ms and its documented sensible
44164
44672
  * range is [4000, 8000]; the plane's ring is sized from this, so the two cannot
@@ -44181,6 +44689,16 @@ var HKSV_FRAGMENT_LENGTHS_MS = [4e3, 8e3];
44181
44689
  */
44182
44690
  var HKSV_AUDIO_SAMPLE_RATE_HZ = 24e3;
44183
44691
  /**
44692
+ * The frame rates the advertised {@link Resolution} may carry, and the only
44693
+ * ones. See {@link normaliseAdvertisedFps} for why the measured rate does not
44694
+ * go in raw.
44695
+ */
44696
+ var HKSV_ADVERTISED_FRAME_RATES = [
44697
+ 15,
44698
+ 24,
44699
+ 30
44700
+ ];
44701
+ /**
44184
44702
  * The advertised fragment length for a camera whose key-frame cadence is
44185
44703
  * `sourceGopMs`, or `null` when no offered length covers it.
44186
44704
  *
@@ -44196,6 +44714,46 @@ function deriveFragmentLengthMs(sourceGopMs) {
44196
44714
  return HKSV_FRAGMENT_LENGTHS_MS.find((ms) => ms >= sourceGopMs) ?? null;
44197
44715
  }
44198
44716
  /**
44717
+ * The frame rate to ADVERTISE for a slot that was measured at `measuredFps` —
44718
+ * the nearest member of {@link HKSV_ADVERTISED_FRAME_RATES}, ties going to the
44719
+ * lower rate.
44720
+ *
44721
+ * The measured rate does not go into the advertisement raw, for two reasons,
44722
+ * and the second one is the serious one.
44723
+ *
44724
+ * **It is the list the controller chooses from.** `[1280, 720, 10]` — 615's
44725
+ * measured 720p slot — is a frame rate no shipping HKSV camera offers, and the
44726
+ * controller has to find an acceptable configuration in what we advertise
44727
+ * before it will write one back.
44728
+ *
44729
+ * **A measurement makes the advertisement UNSTABLE, and hap-nodejs punishes
44730
+ * that by discarding the controller's selection.** `RecordingManagement`
44731
+ * hashes the supported-configuration TLVs and, on restore, keeps the persisted
44732
+ * `selectedConfiguration` only while the hash still matches — otherwise
44733
+ * `deserialize: discarding saved selectedConfiguration`, after which the
44734
+ * accessory answers every HDS `DATA_SEND OPEN` with `INVALID_CONFIGURATION`
44735
+ * and records nothing until the controller happens to re-select. The
44736
+ * advertised resolution is the one hashed input that came from a probe:
44737
+ * camera 590 measured 9 fps on one restart and 10 on the next, on
44738
+ * 2026-08-07/08, so this was a self-inflicted outage waiting on a reboot.
44739
+ * Quantising gives the probe a wide band to move inside without the
44740
+ * advertisement changing at all.
44741
+ */
44742
+ function normaliseAdvertisedFps(measuredFps) {
44743
+ const fallback = HKSV_ADVERTISED_FRAME_RATES[0] ?? 15;
44744
+ if (!Number.isFinite(measuredFps) || measuredFps <= 0) return fallback;
44745
+ let best = fallback;
44746
+ let bestDistance = Number.POSITIVE_INFINITY;
44747
+ for (const candidate of HKSV_ADVERTISED_FRAME_RATES) {
44748
+ const distance = Math.abs(candidate - measuredFps);
44749
+ if (distance < bestDistance) {
44750
+ best = candidate;
44751
+ bestDistance = distance;
44752
+ }
44753
+ }
44754
+ return best;
44755
+ }
44756
+ /**
44199
44757
  * Build the advertisement.
44200
44758
  *
44201
44759
  * ONE resolution is advertised — the one slot the recording child pulls. HAP's
@@ -44208,7 +44766,7 @@ function buildRecordingOptions(input) {
44208
44766
  const resolution = [
44209
44767
  input.width,
44210
44768
  input.height,
44211
- Math.max(1, Math.round(input.fps))
44769
+ normaliseAdvertisedFps(input.fps)
44212
44770
  ];
44213
44771
  return {
44214
44772
  prebufferLength: HKSV_PREBUFFER_MS,
@@ -44531,13 +45089,19 @@ var HksvRecordingDelegate = class {
44531
45089
  updateRecordingActive(active) {
44532
45090
  if (active === this.active) return;
44533
45091
  this.active = active;
45092
+ const census = this.input.describeControllers();
44534
45093
  this.log.info("hksv: recording active changed", {
44535
45094
  tags: { deviceId: this.input.deviceId },
44536
45095
  meta: {
44537
45096
  active,
44538
- hasConfiguration: this.configuration !== void 0
45097
+ hasConfiguration: this.configuration !== void 0,
45098
+ ...census
44539
45099
  }
44540
45100
  });
45101
+ if (active && this.configuration === void 0 && noAdminControllerConnected(census)) this.log.warn("hksv: recording is ON but NO connected controller holds admin — the recording configuration can never arrive", {
45102
+ tags: { deviceId: this.input.deviceId },
45103
+ meta: { ...census }
45104
+ });
44541
45105
  this.reconcile("recording-active");
44542
45106
  }
44543
45107
  updateRecordingConfiguration(configuration) {
@@ -44592,13 +45156,14 @@ var HksvRecordingDelegate = class {
44592
45156
  streamId,
44593
45157
  subscription
44594
45158
  };
44595
- const startedAt = Date.now();
45159
+ const startedAt = this.input.now();
44596
45160
  const prebufferSpanMs = source.prebufferSpanMs();
44597
45161
  let packets = 0;
44598
45162
  let bytes = 0;
44599
45163
  let markedLast = false;
44600
45164
  let longestFragmentGapMs = 0;
44601
- let lastPacketAt = startedAt;
45165
+ let firstFragmentAt = null;
45166
+ let lastFragmentAt = null;
44602
45167
  try {
44603
45168
  for await (const packet of subscription.packets()) {
44604
45169
  if (signal?.aborted === true) {
@@ -44614,9 +45179,10 @@ var HksvRecordingDelegate = class {
44614
45179
  packets += 1;
44615
45180
  bytes += packet.data.length;
44616
45181
  if (packet.kind === "fragment") {
44617
- const now = Date.now();
44618
- longestFragmentGapMs = Math.max(longestFragmentGapMs, now - lastPacketAt);
44619
- lastPacketAt = now;
45182
+ const now = this.input.now();
45183
+ if (lastFragmentAt === null) firstFragmentAt = now;
45184
+ else longestFragmentGapMs = Math.max(longestFragmentGapMs, now - lastFragmentAt);
45185
+ lastFragmentAt = now;
44620
45186
  }
44621
45187
  markedLast = markedLast || packet.isLast;
44622
45188
  yield {
@@ -44651,9 +45217,10 @@ var HksvRecordingDelegate = class {
44651
45217
  streamId,
44652
45218
  packets,
44653
45219
  bytes,
44654
- durationMs: Date.now() - startedAt,
45220
+ durationMs: this.input.now() - startedAt,
44655
45221
  prebufferSpanMs,
44656
45222
  longestFragmentGapMs,
45223
+ msToFirstFragmentMs: firstFragmentAt === null ? null : firstFragmentAt - startedAt,
44657
45224
  closedReason: subscription.closedReason,
44658
45225
  markedLast
44659
45226
  }
@@ -44870,6 +45437,8 @@ async function buildHksvRecording(input) {
44870
45437
  deviceId: numericDeviceId,
44871
45438
  isAudioActive: input.isAudioActive,
44872
45439
  advertisedFragmentMs: fragmentLengthMs,
45440
+ now: () => Date.now(),
45441
+ describeControllers: () => describeHapControllers(bctx.accessory),
44873
45442
  createSource: ({ fragmentMs, audioActive }) => new HksvFragmentSource({
44874
45443
  logger: log,
44875
45444
  deviceId: numericDeviceId,
@@ -44880,11 +45449,13 @@ async function buildHksvRecording(input) {
44880
45449
  audioActive
44881
45450
  })
44882
45451
  });
45452
+ const advertisedResolution = options.video.resolutions[0];
44883
45453
  log.info("export-hap: HKSV ADVERTISED — recording is offered for this camera", { meta: {
44884
45454
  brokerId: source.brokerId,
44885
45455
  profile: source.profile,
44886
45456
  resolution: `${source.width}x${source.height}`,
44887
- fps,
45457
+ measuredFps: fps,
45458
+ advertisedFps: advertisedResolution?.[2] ?? null,
44888
45459
  fragmentLengthMs,
44889
45460
  sourceGopMs: gopMs ?? "unknown"
44890
45461
  } });
@@ -45421,8 +45992,21 @@ function syncStateToJson(map) {
45421
45992
  */
45422
45993
  var DEFAULT_DEVICE_SETTINGS = {
45423
45994
  streamPreference: "auto",
45424
- hksvRecording: false
45995
+ hksvRecording: true
45425
45996
  };
45997
+ /**
45998
+ * ON unless explicitly switched off — operator decision 2026-08-08 (flipped
45999
+ * from the launch default of off). ABSENT must resolve to ON or the flip is a
46000
+ * lie for every entry persisted before the field existed, so every read goes
46001
+ * through this one resolver (`!== false`), never a scattered `=== true`. The
46002
+ * cost that made off-by-default look prudent is measured and small on the only
46003
+ * branch the recorder accepts (copy: 0.7 % of a core / ~30 MB RSS, D84), and a
46004
+ * camera the recorder cannot copy refuses recording with a logged reason
46005
+ * rather than paying for a transcode.
46006
+ */
46007
+ function resolveHksvRecording(settings) {
46008
+ return settings?.hksvRecording !== false;
46009
+ }
45426
46010
  var HAP_STREAM_PREFERENCE_OPTIONS = [
45427
46011
  {
45428
46012
  value: "auto",
@@ -45699,7 +46283,7 @@ var ExportHapAddon = class extends BaseAddon {
45699
46283
  decodeMemos: this.decodeMemos,
45700
46284
  hapDeviceSettings: {
45701
46285
  streamPreference: entrySettings.streamPreference ?? "auto",
45702
- hksvRecording: entrySettings.hksvRecording === true
46286
+ hksvRecording: resolveHksvRecording(entrySettings)
45703
46287
  }
45704
46288
  }
45705
46289
  });
@@ -46050,7 +46634,7 @@ var ExportHapAddon = class extends BaseAddon {
46050
46634
  label: "HomeKit recording (Secure Video)",
46051
46635
  description: "Offer “Stream and Allow Recording” in iOS Home. Requires iCloud+ and a home hub. Keeps a continuous 8s prebuffer for this camera (~0.7% of one CPU core, H.264 sources only).",
46052
46636
  style: "switch",
46053
- value: settings.hksvRecording === true,
46637
+ value: resolveHksvRecording(settings),
46054
46638
  showWhen: {
46055
46639
  field: enabledKey,
46056
46640
  equals: true
@@ -46084,7 +46668,7 @@ var ExportHapAddon = class extends BaseAddon {
46084
46668
  const enabledValue = enabledKey in patch ? Boolean(patch[enabledKey]) : wasEnabled;
46085
46669
  const streamPreferenceRaw = streamPreferenceKey in patch ? patch[streamPreferenceKey] : current?.settings?.streamPreference;
46086
46670
  const streamPreference = typeof streamPreferenceRaw === "string" && streamPreferenceRaw.trim().length > 0 ? streamPreferenceRaw : "auto";
46087
- const hksvRecording = hksvKey in patch ? Boolean(patch[hksvKey]) : current?.settings?.hksvRecording === true;
46671
+ const hksvRecording = hksvKey in patch ? Boolean(patch[hksvKey]) : resolveHksvRecording(current?.settings);
46088
46672
  const nextSettings = {
46089
46673
  ...current?.settings ?? DEFAULT_DEVICE_SETTINGS,
46090
46674
  streamPreference,
@@ -46100,7 +46684,7 @@ var ExportHapAddon = class extends BaseAddon {
46100
46684
  return { success: true };
46101
46685
  }
46102
46686
  const currentPref = current?.settings?.streamPreference ?? "auto";
46103
- const currentHksv = current?.settings?.hksvRecording === true;
46687
+ const currentHksv = resolveHksvRecording(current?.settings);
46104
46688
  await this.updateEntrySettings(deviceIdStr, nextSettings);
46105
46689
  if (currentPref !== streamPreference || currentHksv !== hksvRecording) {
46106
46690
  log.info("export-hap: per-camera export settings changed — refreshing accessory", { meta: {
@@ -46160,4 +46744,5 @@ exports.default = ExportHapAddon;
46160
46744
  exports.deriveUsername = deriveUsername;
46161
46745
  exports.initHapStorage = initHapStorage;
46162
46746
  exports.publishStandalone = publishStandalone;
46747
+ exports.resolveHksvRecording = resolveHksvRecording;
46163
46748
  exports.unpublishAccessory = unpublishAccessory;