@camstack/addon-export-hap 1.2.16 → 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.
@@ -7054,7 +7054,7 @@ method(object({ deviceId: number() }), array(StreamSourceEntrySchema)), method(o
7054
7054
  input: unknown()
7055
7055
  }), unknown(), { kind: "mutation" }), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), unknown().nullable()), method(object({ deviceId: number() }), RawStateResultSchema.nullable(), { auth: "protected" });
7056
7056
  //#endregion
7057
- //#region ../types/dist/fmp4-box-splitter-B53u9-Nu.mjs
7057
+ //#region ../types/dist/canonical-hash-rO1sRmEK.mjs
7058
7058
  var AUDIO_ENCODER_BY_CODEC = {
7059
7059
  opus: "libopus",
7060
7060
  aac: "aac",
@@ -7348,37 +7348,6 @@ function buildFfmpegArgs(inv) {
7348
7348
  ...inv.audioSidecar ? buildAudioSidecarArgs(inv.audioSidecar) : []
7349
7349
  ];
7350
7350
  }
7351
- /**
7352
- * Deterministic SHA-256 hash of an arbitrary serialisable value. The
7353
- * canonical form sorts object keys alphabetically at every depth so two
7354
- * structurally-equal inputs with different key insertion orders produce
7355
- * the same hash. Returns a 64-char lowercase hex digest.
7356
- *
7357
- * Used by export adapters (Alexa, HAP) to short-circuit re-discovery /
7358
- * accessory-rebuild work when the upstream shape is byte-identical to
7359
- * the last applied state — preventing user-visible "re-discovery"
7360
- * notifications on every addon-runner respawn. Each respawn re-fires
7361
- * `DeviceBindingsChanged` for every cap registration, which without
7362
- * this guard would propagate redundant pushes.
7363
- *
7364
- * Note: this is a SYMPTOMATIC fix layered on top of the binding-change
7365
- * subscription. The proper fix is a single "device ready" lifecycle
7366
- * barrier so exports react only when the full cap set has landed —
7367
- * tracked separately for post-HA-integration work.
7368
- */
7369
- function canonicalHash(value) {
7370
- const canonical = JSON.stringify(value, replaceWithSortedKeys);
7371
- return createHash("sha256").update(canonical ?? "").digest("hex");
7372
- }
7373
- function replaceWithSortedKeys(_key, value) {
7374
- if (value && typeof value === "object" && !Array.isArray(value)) {
7375
- const obj = value;
7376
- const out = {};
7377
- for (const k of Object.keys(obj).toSorted()) out[k] = obj[k];
7378
- return out;
7379
- }
7380
- return value;
7381
- }
7382
7351
  var DEFAULT_MAX_UNIT_BYTES = 16 * 1024 * 1024;
7383
7352
  /** Header size for a normal box, and for one carrying a 64-bit `largesize`. */
7384
7353
  var BOX_HEADER_BYTES = 8;
@@ -7551,6 +7520,37 @@ var Fmp4BoxSplitter = class {
7551
7520
  return [];
7552
7521
  }
7553
7522
  };
7523
+ /**
7524
+ * Deterministic SHA-256 hash of an arbitrary serialisable value. The
7525
+ * canonical form sorts object keys alphabetically at every depth so two
7526
+ * structurally-equal inputs with different key insertion orders produce
7527
+ * the same hash. Returns a 64-char lowercase hex digest.
7528
+ *
7529
+ * Used by export adapters (Alexa, HAP) to short-circuit re-discovery /
7530
+ * accessory-rebuild work when the upstream shape is byte-identical to
7531
+ * the last applied state — preventing user-visible "re-discovery"
7532
+ * notifications on every addon-runner respawn. Each respawn re-fires
7533
+ * `DeviceBindingsChanged` for every cap registration, which without
7534
+ * this guard would propagate redundant pushes.
7535
+ *
7536
+ * Note: this is a SYMPTOMATIC fix layered on top of the binding-change
7537
+ * subscription. The proper fix is a single "device ready" lifecycle
7538
+ * barrier so exports react only when the full cap set has landed —
7539
+ * tracked separately for post-HA-integration work.
7540
+ */
7541
+ function canonicalHash(value) {
7542
+ const canonical = JSON.stringify(value, replaceWithSortedKeys);
7543
+ return createHash("sha256").update(canonical ?? "").digest("hex");
7544
+ }
7545
+ function replaceWithSortedKeys(_key, value) {
7546
+ if (value && typeof value === "object" && !Array.isArray(value)) {
7547
+ const obj = value;
7548
+ const out = {};
7549
+ for (const k of Object.keys(obj).toSorted()) out[k] = obj[k];
7550
+ return out;
7551
+ }
7552
+ return value;
7553
+ }
7554
7554
  //#endregion
7555
7555
  //#region ../types/dist/err-msg-IQTHeDzc.mjs
7556
7556
  /**
@@ -9630,6 +9630,69 @@ var StreamFormatSchema = _enum([
9630
9630
  "mjpeg",
9631
9631
  "rtsp"
9632
9632
  ]);
9633
+ /** A container `produceEventMedia` can emit. */
9634
+ var EventMediaKindSchema = _enum(["mp4", "gif"]);
9635
+ /**
9636
+ * One produced artifact, referenced by HANDLE.
9637
+ *
9638
+ * Never inline bytes: a produced clip is 200 KB–5 MB and every consumer of this
9639
+ * method is in another runner ([D9](../../../../docs/decisions/adr-0009.md),
9640
+ * [D18](../../../../docs/decisions/adr-0018.md) — cross-process media is fetched
9641
+ * on demand, compressed, by handle). `bytes` is here so a caller can decide
9642
+ * whether it wants the fetch at all.
9643
+ */
9644
+ var EventMediaArtifactSchema = object({
9645
+ kind: EventMediaKindSchema,
9646
+ /** Opaque, single-camera, short-lived. Redeem with `fetchEventMedia`. */
9647
+ handle: string(),
9648
+ /**
9649
+ * The node holding the bytes — the ROUTING key for `fetchEventMedia`.
9650
+ *
9651
+ * `stream-broker` is a singleton cap and an unpinned call never leaves the
9652
+ * hub, so a handle produced on an agent's broker would be redeemed against
9653
+ * the hub's store and come back `null`. Same contract, same field name and
9654
+ * the same reason as `FrameHandleSchema.nodeId`: the producer stamps where it
9655
+ * lives and the consumer pins to it.
9656
+ */
9657
+ nodeId: string(),
9658
+ mime: string(),
9659
+ bytes: number().int(),
9660
+ width: number().int(),
9661
+ height: number().int()
9662
+ });
9663
+ /**
9664
+ * What a production actually covered — the answer to the only question an
9665
+ * operator asks about a notification clip.
9666
+ *
9667
+ * `fromTs`/`toTs` are WALL CLOCK, derived from the ring's own packet timeline,
9668
+ * so a caller can state "this clip starts 4.1 s before the event" instead of
9669
+ * inferring it from a duration. A production whose `fromTs` is later than the
9670
+ * event is a production with no pre-roll, and that is exactly the defect this
9671
+ * method exists to make visible rather than plausible.
9672
+ */
9673
+ var EventMediaCoverageSchema = object({
9674
+ fromTs: number(),
9675
+ toTs: number(),
9676
+ /** Encoded packets in the muxed window. */
9677
+ packets: number().int()
9678
+ });
9679
+ /**
9680
+ * The result of ONE cut, in every container the caller asked for.
9681
+ *
9682
+ * Every artifact in `media` came out of the SAME window of the SAME rendition —
9683
+ * that is the whole reason this is one method rather than one call per format.
9684
+ * A consumer attaching a gif and a video can no longer show two different
9685
+ * moments, because it never chose two sources.
9686
+ */
9687
+ var EventMediaProductionSchema = object({
9688
+ media: array(EventMediaArtifactSchema).readonly(),
9689
+ coverage: EventMediaCoverageSchema,
9690
+ /** The rendition actually cut from — what the default or the fallback chose. */
9691
+ profile: CamProfileSchema,
9692
+ /** `copy` = the camera's own H.264, untouched. `encode` = re-encoded (H.265
9693
+ * source, a downscale, or a playback rate other than 1). */
9694
+ video: _enum(["copy", "encode"])
9695
+ });
9633
9696
  var RtspRestreamEntrySchema = object({
9634
9697
  brokerId: string(),
9635
9698
  url: string(),
@@ -10029,6 +10092,56 @@ method(object({
10029
10092
  }), {
10030
10093
  kind: "mutation",
10031
10094
  auth: "admin"
10095
+ }), method(object({
10096
+ deviceId: number(),
10097
+ /** Absent = the largest H.264 rendition at or below 1080p, which is
10098
+ * also the one that can be copied. Falls back to whatever the ring
10099
+ * actually retained, and the answer says which. */
10100
+ profile: CamProfileSchema.optional(),
10101
+ aroundMs: number(),
10102
+ preSeconds: number().min(0).max(20).default(4),
10103
+ postSeconds: number().min(0).max(20).default(6),
10104
+ kinds: array(EventMediaKindSchema).min(1).default(["mp4"]),
10105
+ /** GIF geometry. The video keeps the source's own. */
10106
+ gifMaxWidth: number().int().min(120).max(1280).default(640),
10107
+ /**
10108
+ * The gif's own PLAYBACK rate in frames per second — what the finished
10109
+ * gif runs at, not how many source frames feed it. The decimation that
10110
+ * feeds it samples `gifFps / gifSpeed` source frames per second, so at
10111
+ * the defaults a 12 fps gif is built out of 3 source frames a second.
10112
+ */
10113
+ gifFps: number().int().min(1).max(15).default(12),
10114
+ /**
10115
+ * How fast the GIF plays against real time, independent of `speed`.
10116
+ *
10117
+ * 4× by default, by operator request: a notification gif is glanced at
10118
+ * on a lock screen, so a ~12 s window has to be over in ~3 s. It stays
10119
+ * a separate knob from `speed` even though both now default to 4 —
10120
+ * a caller wanting a real-time video and a fast gif must not have to
10121
+ * choose.
10122
+ */
10123
+ gifSpeed: number().min(1).max(8).default(4),
10124
+ /**
10125
+ * Playback rate of the VIDEO. Also 4× by default, by operator decision.
10126
+ *
10127
+ * `1` is real time and is the ONLY value that allows the copy branch —
10128
+ * anything else forces `libx264` over the window. That was priced
10129
+ * before it was chosen: a per-event burst measured at 0.23 s and 254 KB
10130
+ * on a real 615 720p cut, against 922 KB for the copy it replaces. A
10131
+ * re-encode is capped at 720p (`EVENT_CLIP_ENCODE_MAX_WIDTH`), because
10132
+ * once the decode is forced the width stops being free.
10133
+ */
10134
+ speed: number().min(1).max(8).default(4)
10135
+ }), EventMediaProductionSchema, {
10136
+ kind: "mutation",
10137
+ auth: "admin"
10138
+ }), method(object({ handle: string() }), object({
10139
+ base64: string(),
10140
+ mime: string(),
10141
+ bytes: number().int()
10142
+ }).nullable(), {
10143
+ kind: "mutation",
10144
+ auth: "admin"
10032
10145
  }), method(_void(), array(CameraStreamSchema).readonly()), method(_void(), array(ProfileSlotSchema).readonly()), method(object({ brokerId: string() }), BrokerStatsSchema), method(object({ brokerId: string() }), object({
10033
10146
  probed: boolean(),
10034
10147
  summary: string()
@@ -10221,25 +10334,6 @@ DeviceType.Camera, method(object({ deviceId: number().int().nonnegative() }), ar
10221
10334
  }),
10222
10335
  lastChangedAt: number()
10223
10336
  });
10224
- /**
10225
- * core-blocks — user-authored TypeScript, stored in the kernel and executed in
10226
- * its own process.
10227
- *
10228
- * Spec: `docs/superpowers/specs/2026-08-04-core-blocks-and-synthetic-devices-design.md`.
10229
- *
10230
- * The first use is **owning devices without being a device provider**: a block
10231
- * declares devices under a system or custom integration and drives their state,
10232
- * with the same `ctx` an addon gets. Automations come later; nothing here
10233
- * models a trigger.
10234
- *
10235
- * **Stated plainly, because it does not change by being true:** a block has an
10236
- * addon's powers — devices, storage, the event bus, `ctx.api`. It is a plugin
10237
- * with no review step. What makes that survivable is not a sandbox, it is
10238
- * PROCESS ISOLATION: one process per block, supervised by `CrashSupervisor`,
10239
- * so a block that throws or never returns is marked `failed` and visible
10240
- * instead of taking the hub with it (D6). Every method here is admin-only, and
10241
- * must stay so.
10242
- */
10243
10337
  /** Where a block runs. The operator chooses — a block driving a device on an
10244
10338
  * agent is the reason placement is not fixed to the hub. */
10245
10339
  var CoreBlockPlacementSchema = union([literal("hub"), string().min(1)]);
@@ -10311,6 +10405,9 @@ method(object({}), object({ blocks: array(CoreBlockSchema) }), { auth: "admin" }
10311
10405
  }), object({ block: CoreBlockSchema }), {
10312
10406
  kind: "mutation",
10313
10407
  auth: "admin"
10408
+ }), method(object({ blockId: string() }), object({ block: CoreBlockSchema }), {
10409
+ kind: "mutation",
10410
+ auth: "admin"
10314
10411
  }), method(object({ code: string() }), CoreBlockCompileResultSchema, {
10315
10412
  kind: "mutation",
10316
10413
  auth: "admin"
@@ -11070,601 +11167,6 @@ var deviceExportCapability = {
11070
11167
  unexposeDevice: method(UnexposeInputSchema, _void(), { kind: "mutation" })
11071
11168
  }
11072
11169
  };
11073
- /**
11074
- * Resource-bound constants for the safe expression engine.
11075
- *
11076
- * Every bound is defense-in-depth: the grammar is non-Turing-complete (no
11077
- * loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
11078
- * O(nodeCount) by construction. These caps merely put a hard ceiling on the
11079
- * work a single author-supplied expression can request, so a hostile or
11080
- * accidental pathological string can never spend unbounded CPU/memory.
11081
- */
11082
- /** Max source length (chars) — checked BEFORE tokenizing so a huge string is
11083
- * rejected without allocation. */
11084
- var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
11085
- /** A legal binding / identifier name. */
11086
- var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
11087
- /** Binding names an author may NOT use: `now` is auto-injected; the literal
11088
- * keywords lex as values, not identifiers, so binding to them is meaningless. */
11089
- var RESERVED_BINDING_NAMES = new Set([
11090
- "now",
11091
- "true",
11092
- "false",
11093
- "null"
11094
- ]);
11095
- /**
11096
- * Error types for the safe expression engine. Two distinct classes so callers
11097
- * can tell a compile-time (grammar) failure from a runtime (evaluation)
11098
- * failure — both are non-fatal to the host: read paths degrade to "skip link".
11099
- */
11100
- /** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
11101
- * the failure is anchored to a character (author-facing inline feedback). */
11102
- var ExpressionParseError = class extends Error {
11103
- position;
11104
- constructor(message, position) {
11105
- super(message);
11106
- this.name = "ExpressionParseError";
11107
- this.position = position;
11108
- }
11109
- };
11110
- /** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
11111
- * result, unknown builtin, step-budget exceeded). */
11112
- var ExpressionEvalError = class extends Error {
11113
- constructor(message) {
11114
- super(message);
11115
- this.name = "ExpressionEvalError";
11116
- }
11117
- };
11118
- /**
11119
- * Frozen, null-prototype builtin function table for the expression engine
11120
- * (spec §4 rule 4). The table is the SOLE surface of callable functions: the
11121
- * parser rejects any callee not in it, and the evaluator gates each call on an
11122
- * own-property check against it.
11123
- *
11124
- * Because the object has a NULL prototype AND is `Object.freeze`d:
11125
- * - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
11126
- * - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
11127
- * (there is no `Object.prototype` in the chain), so those names are not
11128
- * callable — they are simply "unknown function" at parse time.
11129
- *
11130
- * Every numeric argument is validated as a finite number and every numeric
11131
- * RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
11132
- * (`pow(10,400)` → Infinity) all raise `ExpressionEvalError` and fail the link
11133
- * closed rather than emitting a garbage value.
11134
- */
11135
- function asFiniteNumber(value, name, index) {
11136
- if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
11137
- return value;
11138
- }
11139
- function asString$1(value, name, index) {
11140
- if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
11141
- return value;
11142
- }
11143
- function finiteResult(value, name) {
11144
- if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
11145
- return value;
11146
- }
11147
- function allFiniteNumbers(args, name) {
11148
- return args.map((a, idx) => asFiniteNumber(a, name, idx));
11149
- }
11150
- var INF = Number.POSITIVE_INFINITY;
11151
- var table = {
11152
- min: {
11153
- minArgs: 1,
11154
- maxArgs: INF,
11155
- apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
11156
- },
11157
- max: {
11158
- minArgs: 1,
11159
- maxArgs: INF,
11160
- apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
11161
- },
11162
- abs: {
11163
- minArgs: 1,
11164
- maxArgs: 1,
11165
- apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
11166
- },
11167
- floor: {
11168
- minArgs: 1,
11169
- maxArgs: 1,
11170
- apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
11171
- },
11172
- ceil: {
11173
- minArgs: 1,
11174
- maxArgs: 1,
11175
- apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
11176
- },
11177
- sqrt: {
11178
- minArgs: 1,
11179
- maxArgs: 1,
11180
- apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
11181
- },
11182
- round: {
11183
- minArgs: 1,
11184
- maxArgs: 2,
11185
- apply: (args) => {
11186
- const x = asFiniteNumber(args[0], "round", 0);
11187
- const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
11188
- if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
11189
- const factor = 10 ** digits;
11190
- return finiteResult(Math.round(x * factor) / factor, "round");
11191
- }
11192
- },
11193
- pow: {
11194
- minArgs: 2,
11195
- maxArgs: 2,
11196
- apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
11197
- },
11198
- clamp: {
11199
- minArgs: 3,
11200
- maxArgs: 3,
11201
- apply: (args) => {
11202
- const x = asFiniteNumber(args[0], "clamp", 0);
11203
- const lo = asFiniteNumber(args[1], "clamp", 1);
11204
- const hi = asFiniteNumber(args[2], "clamp", 2);
11205
- if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
11206
- return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
11207
- }
11208
- },
11209
- avg: {
11210
- minArgs: 1,
11211
- maxArgs: INF,
11212
- apply: (args) => {
11213
- const nums = allFiniteNumbers(args, "avg");
11214
- return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
11215
- }
11216
- },
11217
- sum: {
11218
- minArgs: 1,
11219
- maxArgs: INF,
11220
- apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
11221
- },
11222
- coalesce: {
11223
- minArgs: 1,
11224
- maxArgs: INF,
11225
- apply: (args) => {
11226
- for (const a of args) if (a !== null) return a;
11227
- return null;
11228
- }
11229
- },
11230
- age: {
11231
- minArgs: 2,
11232
- maxArgs: 2,
11233
- apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
11234
- },
11235
- convert: {
11236
- minArgs: 3,
11237
- maxArgs: 3,
11238
- apply: (args, hooks) => {
11239
- const x = asFiniteNumber(args[0], "convert", 0);
11240
- const from = asString$1(args[1], "convert", 1).trim();
11241
- const to = asString$1(args[2], "convert", 2).trim();
11242
- if (hooks.convert) {
11243
- const out = hooks.convert(x, from, to);
11244
- if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
11245
- return finiteResult(out, "convert");
11246
- }
11247
- if (from === to) return x;
11248
- throw new ExpressionEvalError("convert: unit conversion table not installed");
11249
- }
11250
- }
11251
- };
11252
- Object.freeze(Object.assign(Object.create(null), table));
11253
- /** The set of valid builtin names — used by the parser to reject unknown
11254
- * callees at parse time (immediate author feedback). */
11255
- var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
11256
- /**
11257
- * Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
11258
- * zero-dependency. The grammar is deliberately boring: decimal numbers,
11259
- * single/double-quoted strings with a tiny escape set, identifiers, the three
11260
- * value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
11261
- * outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
11262
- * is a parse error with a source position, so member access / assignment /
11263
- * template literals are lexically impossible.
11264
- */
11265
- var KEYWORDS = new Set([
11266
- "true",
11267
- "false",
11268
- "null"
11269
- ]);
11270
- function isDigit(ch) {
11271
- return ch >= "0" && ch <= "9";
11272
- }
11273
- function isIdentStart(ch) {
11274
- return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
11275
- }
11276
- function isIdentPart(ch) {
11277
- return isIdentStart(ch) || isDigit(ch);
11278
- }
11279
- function isWhitespace(ch) {
11280
- return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
11281
- }
11282
- /** Tokenize `source` into a flat token list ending with a single `eof` token.
11283
- * Throws `ExpressionParseError` on any illegal character or unterminated
11284
- * string. */
11285
- function tokenize(source) {
11286
- if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
11287
- const tokens = [];
11288
- let i = 0;
11289
- const n = source.length;
11290
- while (i < n) {
11291
- const ch = source[i];
11292
- if (isWhitespace(ch)) {
11293
- i += 1;
11294
- continue;
11295
- }
11296
- if (isDigit(ch)) {
11297
- const start = i;
11298
- while (i < n && isDigit(source[i])) i += 1;
11299
- if (i < n && source[i] === ".") {
11300
- if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
11301
- i += 1;
11302
- while (i < n && isDigit(source[i])) i += 1;
11303
- }
11304
- const text = source.slice(start, i);
11305
- const value = Number(text);
11306
- if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
11307
- tokens.push({
11308
- type: "number",
11309
- value,
11310
- pos: start
11311
- });
11312
- continue;
11313
- }
11314
- if (ch === "'" || ch === "\"") {
11315
- const quote = ch;
11316
- const start = i;
11317
- i += 1;
11318
- let out = "";
11319
- let closed = false;
11320
- while (i < n) {
11321
- const c = source[i];
11322
- if (c === "\\") {
11323
- const next = i + 1 < n ? source[i + 1] : "";
11324
- if (next === "\\" || next === "'" || next === "\"") {
11325
- out += next;
11326
- i += 2;
11327
- continue;
11328
- }
11329
- throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
11330
- }
11331
- if (c === quote) {
11332
- closed = true;
11333
- i += 1;
11334
- break;
11335
- }
11336
- out += c;
11337
- i += 1;
11338
- }
11339
- if (!closed) throw new ExpressionParseError("unterminated string literal", start);
11340
- tokens.push({
11341
- type: "string",
11342
- value: out,
11343
- pos: start
11344
- });
11345
- continue;
11346
- }
11347
- if (isIdentStart(ch)) {
11348
- const start = i;
11349
- while (i < n && isIdentPart(source[i])) i += 1;
11350
- const text = source.slice(start, i);
11351
- if (KEYWORDS.has(text)) tokens.push({
11352
- type: "keyword",
11353
- keyword: keywordOf(text),
11354
- pos: start
11355
- });
11356
- else tokens.push({
11357
- type: "identifier",
11358
- name: text,
11359
- pos: start
11360
- });
11361
- continue;
11362
- }
11363
- const two = i + 1 < n ? source.slice(i, i + 2) : "";
11364
- if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
11365
- tokens.push({
11366
- type: "punct",
11367
- punct: two,
11368
- pos: i
11369
- });
11370
- i += 2;
11371
- continue;
11372
- }
11373
- if (isSinglePunct(ch)) {
11374
- tokens.push({
11375
- type: "punct",
11376
- punct: ch,
11377
- pos: i
11378
- });
11379
- i += 1;
11380
- continue;
11381
- }
11382
- throw new ExpressionParseError(`unexpected character '${ch}'`, i);
11383
- }
11384
- tokens.push({
11385
- type: "eof",
11386
- pos: n
11387
- });
11388
- return tokens;
11389
- }
11390
- function keywordOf(text) {
11391
- if (text === "true") return "true";
11392
- if (text === "false") return "false";
11393
- return "null";
11394
- }
11395
- function isSinglePunct(ch) {
11396
- return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
11397
- }
11398
- /**
11399
- * Pratt (precedence-climbing) parser for the safe expression mini-language.
11400
- *
11401
- * Precedence (low → high): ternary `?:` (right-assoc) → `||` → `&&` → equality
11402
- * → relational → additive → multiplicative → unary `! -` → call / primary.
11403
- * Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
11404
- * string validated against the builtin table at parse time, so an unknown
11405
- * function is rejected immediately (author feedback) and a persisted expression
11406
- * that references a since-removed builtin degrades at read.
11407
- *
11408
- * A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
11409
- * arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
11410
- */
11411
- /** Binary/logical operator precedence (higher binds tighter). */
11412
- var BINARY_PRECEDENCE = {
11413
- "||": 1,
11414
- "&&": 2,
11415
- "==": 3,
11416
- "!=": 3,
11417
- "<": 4,
11418
- "<=": 4,
11419
- ">": 4,
11420
- ">=": 4,
11421
- "+": 5,
11422
- "-": 5,
11423
- "*": 6,
11424
- "/": 6,
11425
- "%": 6
11426
- };
11427
- function isLogicalOp(op) {
11428
- return op === "&&" || op === "||";
11429
- }
11430
- function isBinaryOp(op) {
11431
- return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
11432
- }
11433
- var Parser = class {
11434
- tokens;
11435
- pos = 0;
11436
- nodeCount = 0;
11437
- identifiers = /* @__PURE__ */ new Set();
11438
- callees = /* @__PURE__ */ new Set();
11439
- constructor(tokens) {
11440
- this.tokens = tokens;
11441
- }
11442
- parse() {
11443
- const ast = this.parseTernary();
11444
- const tok = this.peek();
11445
- if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
11446
- return {
11447
- ast,
11448
- identifiers: this.identifiers,
11449
- callees: this.callees,
11450
- nodeCount: this.nodeCount
11451
- };
11452
- }
11453
- peek() {
11454
- return this.tokens[this.pos];
11455
- }
11456
- next() {
11457
- return this.tokens[this.pos++];
11458
- }
11459
- /** Consume a punctuator token, erroring if the next token isn't it. */
11460
- expectPunct(punct) {
11461
- const tok = this.peek();
11462
- if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
11463
- this.pos += 1;
11464
- }
11465
- matchPunct(punct) {
11466
- const tok = this.peek();
11467
- if (tok.type === "punct" && tok.punct === punct) {
11468
- this.pos += 1;
11469
- return true;
11470
- }
11471
- return false;
11472
- }
11473
- countNode() {
11474
- this.nodeCount += 1;
11475
- if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
11476
- }
11477
- parseTernary() {
11478
- const test = this.parseBinary(1);
11479
- if (this.matchPunct("?")) {
11480
- const consequent = this.parseTernary();
11481
- this.expectPunct(":");
11482
- const alternate = this.parseTernary();
11483
- this.countNode();
11484
- return {
11485
- kind: "conditional",
11486
- test,
11487
- consequent,
11488
- alternate
11489
- };
11490
- }
11491
- return test;
11492
- }
11493
- parseBinary(minPrec) {
11494
- let left = this.parseUnary();
11495
- for (;;) {
11496
- const tok = this.peek();
11497
- if (tok.type !== "punct") break;
11498
- const prec = BINARY_PRECEDENCE[tok.punct];
11499
- if (prec === void 0 || prec < minPrec) break;
11500
- const op = tok.punct;
11501
- this.pos += 1;
11502
- const right = this.parseBinary(prec + 1);
11503
- this.countNode();
11504
- if (isLogicalOp(op)) left = {
11505
- kind: "logical",
11506
- op,
11507
- left,
11508
- right
11509
- };
11510
- else if (isBinaryOp(op)) left = {
11511
- kind: "binary",
11512
- op,
11513
- left,
11514
- right
11515
- };
11516
- else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
11517
- }
11518
- return left;
11519
- }
11520
- parseUnary() {
11521
- const tok = this.peek();
11522
- if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
11523
- const op = tok.punct;
11524
- this.pos += 1;
11525
- const operand = this.parseUnary();
11526
- this.countNode();
11527
- return {
11528
- kind: "unary",
11529
- op,
11530
- operand
11531
- };
11532
- }
11533
- return this.parsePrimary();
11534
- }
11535
- parsePrimary() {
11536
- const tok = this.next();
11537
- switch (tok.type) {
11538
- case "number":
11539
- this.countNode();
11540
- return {
11541
- kind: "literal",
11542
- value: tok.value
11543
- };
11544
- case "string":
11545
- this.countNode();
11546
- return {
11547
- kind: "literal",
11548
- value: tok.value
11549
- };
11550
- case "keyword":
11551
- this.countNode();
11552
- return {
11553
- kind: "literal",
11554
- value: tok.keyword === "null" ? null : tok.keyword === "true"
11555
- };
11556
- case "identifier": {
11557
- const nextTok = this.peek();
11558
- if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
11559
- this.identifiers.add(tok.name);
11560
- this.countNode();
11561
- return {
11562
- kind: "identifier",
11563
- name: tok.name
11564
- };
11565
- }
11566
- case "punct":
11567
- if (tok.punct === "(") {
11568
- const inner = this.parseTernary();
11569
- this.expectPunct(")");
11570
- return inner;
11571
- }
11572
- throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
11573
- case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
11574
- }
11575
- }
11576
- parseCall(callee, pos) {
11577
- if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
11578
- this.expectPunct("(");
11579
- const args = [];
11580
- if (!this.matchPunct(")")) for (;;) {
11581
- args.push(this.parseTernary());
11582
- if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
11583
- if (this.matchPunct(",")) continue;
11584
- this.expectPunct(")");
11585
- break;
11586
- }
11587
- this.callees.add(callee);
11588
- this.countNode();
11589
- return {
11590
- kind: "call",
11591
- callee,
11592
- args
11593
- };
11594
- }
11595
- };
11596
- /** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
11597
- * `ExpressionParseError` on any lexical or grammatical failure. */
11598
- function parseExpression(source) {
11599
- return new Parser(tokenize(source)).parse();
11600
- }
11601
- /**
11602
- * LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
11603
- * by expr"). The cache stores BOTH successes and failures (negative caching),
11604
- * so a corrupt persisted string costs exactly one tokenize+parse total — not
11605
- * one per read on a hot resolve path.
11606
- *
11607
- * The cache is a module-level singleton: entries are pure, content-addressed
11608
- * ASTs keyed by the raw source string, so sharing one instance across all
11609
- * callers is safe and maximises hit rate.
11610
- */
11611
- var cache = /* @__PURE__ */ new Map();
11612
- function getCached(source) {
11613
- const hit = cache.get(source);
11614
- if (hit !== void 0) {
11615
- cache.delete(source);
11616
- cache.set(source, hit);
11617
- return hit;
11618
- }
11619
- let result;
11620
- try {
11621
- result = {
11622
- ok: true,
11623
- parsed: parseExpression(source)
11624
- };
11625
- } catch (err) {
11626
- result = {
11627
- ok: false,
11628
- error: err instanceof ExpressionParseError ? err.message : String(err)
11629
- };
11630
- }
11631
- cache.set(source, result);
11632
- if (cache.size > 256) {
11633
- const oldest = cache.keys().next().value;
11634
- if (oldest !== void 0) cache.delete(oldest);
11635
- }
11636
- return result;
11637
- }
11638
- /** Compile `source`, returning a discriminated result instead of throwing.
11639
- * Used by read paths that must degrade rather than raise. LRU/negative-cached. */
11640
- function compileExpressionSafe(source) {
11641
- return getCached(source);
11642
- }
11643
- Object.freeze({});
11644
- /**
11645
- * Author-time validation. Returns `null` when the source is valid, else a
11646
- * human-readable error message. Checks: the expression compiles; binding count
11647
- * is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
11648
- * is not reserved (`now`/keywords) and does not shadow a builtin; and every
11649
- * FREE identifier of the AST is covered by a binding or the injected `now`.
11650
- */
11651
- function validateExpressionSource(src) {
11652
- const names = Object.keys(src.bindings);
11653
- if (names.length > 32) return `too many bindings (${names.length} > 32)`;
11654
- for (const name of names) {
11655
- if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
11656
- if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
11657
- if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
11658
- }
11659
- const compiled = compileExpressionSafe(src.expr);
11660
- if (!compiled.ok) return compiled.error;
11661
- const bound = new Set(names);
11662
- for (const id of compiled.parsed.identifiers) {
11663
- if (id === "now") continue;
11664
- if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
11665
- }
11666
- return null;
11667
- }
11668
11170
  var ProviderStatusSchema = object({
11669
11171
  connected: boolean(),
11670
11172
  deviceCount: number(),
@@ -11789,92 +11291,6 @@ var ChildLayoutEntrySchema = object({
11789
11291
  order: number().optional(),
11790
11292
  collapsed: boolean().optional()
11791
11293
  });
11792
- /** Cap-wire shape of a DeviceLink — structurally mirrors `DeviceLink` in
11793
- * `device-management.ts`. Source is a union: a FIELD source copies a sibling
11794
- * accessory's status field (`kind` optional/absent for wire compat); a
11795
- * LITERAL source carries a per-device constant (no sibling is read); a
11796
- * GLOBAL source (P2e) copies ANY device's status field, addressed by the
11797
- * source device's full re-sync-stable `stableId`. */
11798
- var DeviceLinkFieldSourceSchema = object({
11799
- kind: literal("field").optional(),
11800
- sourceKey: string(),
11801
- cap: string(),
11802
- fieldPath: string()
11803
- });
11804
- var DeviceLinkLiteralSourceSchema = object({
11805
- kind: literal("literal"),
11806
- value: union([
11807
- string(),
11808
- number(),
11809
- boolean(),
11810
- _null()
11811
- ])
11812
- });
11813
- var DeviceLinkGlobalSourceSchema = object({
11814
- kind: literal("global"),
11815
- sourceStableId: string(),
11816
- cap: string(),
11817
- fieldPath: string()
11818
- });
11819
- /** Expression source (Stage X): compute the target field from N named bindings
11820
- * via the safe expression engine. Bindings are field | literal | global — never
11821
- * another expression (no nesting). The `superRefine` runs the SAME author-time
11822
- * validation as `validateExpressionSource` (compiles the expr, checks binding
11823
- * names + identifier coverage) so every wire boundary that parses a DeviceLink
11824
- * (tRPC mount, kernel create pre-seed, projection output) validates-at-write.
11825
- * Compiles are LRU-cached, so repeated validation of the same expr is a hit. */
11826
- var DeviceLinkExpressionSourceSchema = object({
11827
- kind: literal("expression"),
11828
- expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
11829
- bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), union([
11830
- DeviceLinkFieldSourceSchema,
11831
- DeviceLinkLiteralSourceSchema,
11832
- DeviceLinkGlobalSourceSchema
11833
- ]))
11834
- }).superRefine((src, ctx) => {
11835
- const err = validateExpressionSource(src);
11836
- if (err !== null) ctx.addIssue({
11837
- code: "custom",
11838
- message: err,
11839
- path: ["expr"]
11840
- });
11841
- });
11842
- var DeviceLinkSchema = object({
11843
- id: string(),
11844
- source: union([
11845
- DeviceLinkFieldSourceSchema,
11846
- DeviceLinkLiteralSourceSchema,
11847
- DeviceLinkGlobalSourceSchema,
11848
- DeviceLinkExpressionSourceSchema
11849
- ]),
11850
- target: object({
11851
- cap: string(),
11852
- fieldPath: string(),
11853
- itemKey: string().optional()
11854
- }),
11855
- transform: discriminatedUnion("kind", [
11856
- object({ kind: literal("identity") }),
11857
- object({
11858
- kind: literal("enum-map"),
11859
- mapping: record(string(), union([
11860
- string(),
11861
- number(),
11862
- boolean()
11863
- ])),
11864
- fallback: union([
11865
- string(),
11866
- number(),
11867
- boolean()
11868
- ]).optional()
11869
- }),
11870
- object({
11871
- kind: literal("linear"),
11872
- scale: number(),
11873
- offset: number(),
11874
- clamp: tuple([number(), number()]).readonly().optional()
11875
- })
11876
- ]).optional()
11877
- });
11878
11294
  /** Cap-wire shape of a per-cap display refinement — mirrors
11879
11295
  * `DeviceCapDisplayOverride` in `device-management.ts`. */
11880
11296
  var DeviceCapDisplayOverrideSchema = object({
@@ -11954,8 +11370,6 @@ var DeviceInfoSchema = object({
11954
11370
  * named accordion sections (with optional intra-section order). See
11955
11371
  * `DeviceMeta.childLayout`. Absent ⇒ no layout declared. */
11956
11372
  childLayout: array(ChildLayoutEntrySchema).readonly().optional(),
11957
- /** Operator-authored cross-device field wirings. See `DeviceMeta.deviceLinks`. */
11958
- deviceLinks: array(DeviceLinkSchema).readonly().optional(),
11959
11373
  /** Operator-authored per-device display override. See `DeviceMeta.display`. */
11960
11374
  display: DeviceDisplayOverrideSchema.optional()
11961
11375
  });
@@ -11964,7 +11378,7 @@ var ConfigEntrySchema = object({
11964
11378
  value: unknown(),
11965
11379
  description: string().optional()
11966
11380
  });
11967
- var DeviceLinkModeSchema = _enum(["auto", "manual"]);
11381
+ var LinkedDevicesModeSchema = _enum(["auto", "manual"]);
11968
11382
  /** One resolved linked device — the compact projection consumers need. */
11969
11383
  var LinkedDeviceSchema = object({
11970
11384
  deviceId: number(),
@@ -12027,8 +11441,6 @@ var DeviceMetaSchema = object({
12027
11441
  * accordion sections (with optional intra-section order). See
12028
11442
  * `DeviceMeta.childLayout`. Absent ⇒ no layout declared. */
12029
11443
  childLayout: array(ChildLayoutEntrySchema).readonly().optional(),
12030
- /** Operator-authored cross-device field wirings. See `DeviceMeta.deviceLinks`. */
12031
- deviceLinks: array(DeviceLinkSchema).readonly().optional(),
12032
11444
  /** Semantic role string (`DeviceRole`) — propagated from the spawn pre-seed.
12033
11445
  * Optional: only present for accessory children that carry a known role. */
12034
11446
  role: string().nullable().optional(),
@@ -12121,12 +11533,6 @@ method(object({
12121
11533
  }), _void(), {
12122
11534
  kind: "mutation",
12123
11535
  auth: "admin"
12124
- }), method(object({
12125
- deviceId: number(),
12126
- deviceLinks: array(DeviceLinkSchema).readonly()
12127
- }), _void(), {
12128
- kind: "mutation",
12129
- auth: "admin"
12130
11536
  }), method(object({
12131
11537
  deviceId: number(),
12132
11538
  display: DeviceDisplayOverrideSchema.nullable()
@@ -12208,7 +11614,7 @@ method(object({
12208
11614
  * shipping 293 rows to find 12. */
12209
11615
  isCamera: boolean().optional()
12210
11616
  }), array(DeviceInfoSchema)), method(object({ deviceId: number() }), DeviceInfoSchema.nullable()), method(object({ parentDeviceId: number() }), array(DeviceInfoSchema)), method(object({ deviceId: number() }), object({
12211
- mode: DeviceLinkModeSchema,
11617
+ mode: LinkedDevicesModeSchema,
12212
11618
  devices: array(LinkedDeviceSchema)
12213
11619
  })), method(object({ deviceId: number() }), array(StreamSourceEntrySchema$1)), method(object({ deviceId: number() }), array(ConfigEntrySchema)), method(object({ deviceId: number() }), ConfigUISchemaOutput), method(object({
12214
11620
  deviceId: number(),
@@ -12241,11 +11647,7 @@ method(object({
12241
11647
  deviceId: number(),
12242
11648
  entries: array(object({
12243
11649
  capName: string(),
12244
- kind: _enum([
12245
- "native",
12246
- "wrapped",
12247
- "linked"
12248
- ]),
11650
+ kind: _enum(["native", "wrapped"]),
12249
11651
  providerAddonId: string(),
12250
11652
  providerNodeId: string(),
12251
11653
  nativeAddonId: string()
@@ -12254,11 +11656,7 @@ method(object({
12254
11656
  deviceId: number(),
12255
11657
  entries: array(object({
12256
11658
  capName: string(),
12257
- kind: _enum([
12258
- "native",
12259
- "wrapped",
12260
- "linked"
12261
- ]),
11659
+ kind: _enum(["native", "wrapped"]),
12262
11660
  providerAddonId: string(),
12263
11661
  providerNodeId: string(),
12264
11662
  nativeAddonId: string()
@@ -13061,7 +12459,7 @@ var MotionAnalysisResultSchema = object({
13061
12459
  frameHeight: number(),
13062
12460
  analysisMs: number()
13063
12461
  });
13064
- method(object({
12462
+ DeviceType.Camera, method(object({
13065
12463
  deviceId: number(),
13066
12464
  frame: FrameInputSchema.optional(),
13067
12465
  frameHandle: FrameHandleSchema.optional()
@@ -15250,6 +14648,18 @@ var OauthIntegrationDescriptorSchema = object({
15250
14648
  * redirect_uri that does not start with one of these. Required —
15251
14649
  * an empty list means the integration can never complete linking. */
15252
14650
  allowedRedirectPrefixes: array(string()).min(1),
14651
+ /** Paths accepted as a `redirect_uri` when the host is PRIVATE — loopback,
14652
+ * RFC1918, CGNAT (100.64/10, Tailscale), link-local, IPv6 ULA, or an
14653
+ * `.local` / `.internal` / `.ts.net` name. Exists for self-hosted clients
14654
+ * whose address the hub cannot know in advance (a Home Assistant at
14655
+ * `http://<lan-ip>:8123/auth/external/callback`). The PATH must match
14656
+ * exactly; a public host never satisfies this branch, so it is not a
14657
+ * wildcard prefix by another name. */
14658
+ allowedPrivateHostPaths: array(string()).optional(),
14659
+ /** When true this is a PUBLIC client (source is published, no secret can be
14660
+ * protected) and PKCE is mandatory: `/authorize` refuses without an S256
14661
+ * `code_challenge`, `/token` refuses without the matching `code_verifier`. */
14662
+ requiresPkce: boolean().optional(),
15253
14663
  /** Optional public origin (no trailing slash) that this integration's
15254
14664
  * issued codes/tokens should carry as the `hubUrl` claim — typically the
15255
14665
  * operator-selected external-access endpoint resolved by the addon. When
@@ -15400,7 +14810,7 @@ var TrackEnvelopeSchema = object({
15400
14810
  * `snapshots[]` references — megabytes across a page of tracks. `slim`
15401
14811
  * keeps every scalar the list surfaces actually render (ids, class(es),
15402
14812
  * label / audioLabels / importance enrichment, firstSeen/lastSeen, state,
15403
- * zonesVisited, bestEventId, envelope) and returns `positions` /
14813
+ * zonesVisited, bestEventId, envelope, hasFace) and returns `positions` /
15404
14814
  * `snapshots` as EMPTY arrays — detail views re-fetch the full row via
15405
14815
  * `getTrack`. Mirrors the event-store `projection` convention
15406
14816
  * (`getObjectEvents` et al.).
@@ -15637,6 +15047,24 @@ var TrackSchema = object({
15637
15047
  * Populated from the persisted envelope columns on historical reads;
15638
15048
  * absent on legacy rows, dims-less tracks and active (in-RAM) tracks. */
15639
15049
  envelope: TrackEnvelopeSchema.optional(),
15050
+ /**
15051
+ * A face DETECTOR found a face on this track — nothing more. It says the
15052
+ * detail plane produced a `face` detail; it does NOT say the face was
15053
+ * embedded, matched, above `minFacePx`, or that the recognizer was even
15054
+ * enabled. Set once and never cleared.
15055
+ *
15056
+ * **This exists so "face present but not recognised" is expressible.** A
15057
+ * recognised identity lands in `subLabel` (attributed to the face chain via
15058
+ * `subLabelMeta.stepId`), so before this field a track with an unmatched face
15059
+ * and a track with no face at all were byte-identical on the wire and no
15060
+ * surface could tell them apart. The read is `hasFace === true && subLabel
15061
+ * === undefined`.
15062
+ *
15063
+ * **Absent ≠ false.** Every row written before the column existed omits it,
15064
+ * and so does every server that predates the field — a consumer must test
15065
+ * `=== true` and render nothing otherwise, never infer "no face".
15066
+ */
15067
+ hasFace: boolean().optional(),
15640
15068
  ...TrackFlagFields,
15641
15069
  ...TrackRetrainFields
15642
15070
  });
@@ -18251,6 +17679,10 @@ var SsoBridgeClaimsSchema = object({
18251
17679
  integrationId: string().optional(),
18252
17680
  /** JWT ID — unique per issued code; consumed-set enforces single-use. */
18253
17681
  jti: string().optional(),
17682
+ /** PKCE S256 challenge — set only on `oauth-code` tokens issued to a public
17683
+ * client. Its PRESENCE is what makes the verifier mandatory at exchange,
17684
+ * so the requirement travels with the code and not with mutable config. */
17685
+ codeChallenge: string().optional(),
18254
17686
  /** OAuth session registry id — set on `oauth-access`/`oauth-refresh`
18255
17687
  * tokens so the verify path can check the session is not revoked. */
18256
17688
  sessionId: string().optional()
@@ -18803,7 +18235,7 @@ var ClipPlaybackSchema = object({
18803
18235
  playbackEndpoints: array(string()).optional(),
18804
18236
  token: string().optional()
18805
18237
  });
18806
- method(object({
18238
+ DeviceType.Camera, method(object({
18807
18239
  deviceId: number(),
18808
18240
  since: number(),
18809
18241
  until: number(),
@@ -23879,13 +23311,18 @@ method(_void(), array(UserSummarySchema), { auth: "admin" }), method(CreateUserI
23879
23311
  username: string(),
23880
23312
  scopes: array(TokenScopeSchema),
23881
23313
  redirectUri: string(),
23882
- hubUrl: string()
23314
+ hubUrl: string(),
23315
+ /** PKCE (RFC 7636) S256 challenge. Baked into the signed code; a code
23316
+ * that carries one can ONLY be exchanged with the matching verifier. */
23317
+ codeChallenge: string().optional()
23883
23318
  }), object({ code: string() }), {
23884
23319
  kind: "mutation",
23885
23320
  access: "create"
23886
23321
  }), method(object({
23887
23322
  code: string(),
23888
- redirectUri: string()
23323
+ redirectUri: string(),
23324
+ /** PKCE verifier. REQUIRED when the code carries a challenge. */
23325
+ codeVerifier: string().optional()
23889
23326
  }), object({
23890
23327
  accessToken: string(),
23891
23328
  refreshToken: string(),
@@ -24225,76 +23662,705 @@ DeviceType.Camera, method(object({ deviceId: number() }), CameraOccupancySnapsho
24225
23662
  className: string().optional()
24226
23663
  }).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly());
24227
23664
  /**
24228
- * Stages a {@link ZoneRule} can apply to. Discriminator on the rules
24229
- * cap so a single CRUD surface backs every consumer; each stage has
24230
- * its own dev-state mirror slice (`motion-zone-rules`,
24231
- * `detection-zone-rules`, …) so consumer addons subscribe independently.
23665
+ * Stages a {@link ZoneRule} can apply to. Discriminator on the rules
23666
+ * cap so a single CRUD surface backs every consumer; each stage has
23667
+ * its own dev-state mirror slice (`motion-zone-rules`,
23668
+ * `detection-zone-rules`, …) so consumer addons subscribe independently.
23669
+ *
23670
+ * Extend the enum here when a new gating consumer comes online (audio
23671
+ * gating, alert filtering, …) — no other surface needs to change.
23672
+ */
23673
+ var ZoneRuleStageEnum = _enum([
23674
+ "motion",
23675
+ "detection",
23676
+ "package"
23677
+ ]);
23678
+ DeviceType.Camera, method(object({
23679
+ deviceId: number(),
23680
+ stage: ZoneRuleStageEnum
23681
+ }), array(ZoneRuleSchema).readonly()), method(object({
23682
+ deviceId: number(),
23683
+ stage: ZoneRuleStageEnum,
23684
+ rules: array(ZoneRuleSchema).readonly()
23685
+ }), _void(), {
23686
+ kind: "mutation",
23687
+ auth: "admin"
23688
+ }), object({
23689
+ motion: array(ZoneRuleSchema).readonly(),
23690
+ detection: array(ZoneRuleSchema).readonly(),
23691
+ package: array(ZoneRuleSchema).readonly()
23692
+ });
23693
+ /**
23694
+ * Accessory device helpers — shared across drivers.
23695
+ *
23696
+ * Many vendor-specific drivers register accessory child devices on
23697
+ * top of a parent (Reolink: siren / floodlight / PIR / autotrack /
23698
+ * chime; ONVIF: relay outputs; future: Tapo Hub child devices). Each
23699
+ * driver picks the right `DeviceType` + `DeviceRole` explicitly when
23700
+ * spawning, builds a name derived from the parent, and produces a
23701
+ * stableId tied to the parent so boot-restore can reconstruct the
23702
+ * relationship.
23703
+ *
23704
+ * Centralised `(kind → DeviceType)` mapping was dropped on purpose:
23705
+ * drivers may reasonably disagree on the right type for an accessory
23706
+ * (a Reolink PIR exposes a switch on/off + sensitivity, while a hypothetical
23707
+ * read-only motion-only sensor might be `DeviceType.Sensor`). Forcing
23708
+ * one canonical mapping was over-prescriptive and added a layer of
23709
+ * indirection without saving meaningful code at call sites — the
23710
+ * driver knows its own hardware best.
23711
+ */
23712
+ /**
23713
+ * Subset of `DeviceRole` values that drivers register as child
23714
+ * accessories of a parent device. Sourced verbatim from `DeviceRole`
23715
+ * — `AccessoryKind` is the alias drivers use when building accessory
23716
+ * children, so the call site reads as
23717
+ * `accessoryStableId(parent, AccessoryKind.Siren)` rather than
23718
+ * `accessoryStableId(parent, DeviceRole.Siren)` (which would imply
23719
+ * any role works, including non-accessory ones like Doorbell).
23720
+ */
23721
+ var AccessoryKind = {
23722
+ Siren: DeviceRole.Siren,
23723
+ Floodlight: DeviceRole.Floodlight,
23724
+ Spotlight: DeviceRole.Spotlight,
23725
+ PirSensor: DeviceRole.PirSensor,
23726
+ Chime: DeviceRole.Chime,
23727
+ Autotrack: DeviceRole.Autotrack,
23728
+ Nightvision: DeviceRole.Nightvision,
23729
+ PrivacyMask: DeviceRole.PrivacyMask
23730
+ };
23731
+ AccessoryKind.Siren, AccessoryKind.Floodlight, AccessoryKind.Spotlight, AccessoryKind.PirSensor, AccessoryKind.Chime, AccessoryKind.Autotrack, AccessoryKind.Nightvision, AccessoryKind.PrivacyMask;
23732
+ 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;
23733
+ new Set(Object.values(DeviceType));
23734
+ DeviceFeature.BatteryOperated;
23735
+ /**
23736
+ * Error types for the safe expression engine. Two distinct classes so callers
23737
+ * can tell a compile-time (grammar) failure from a runtime (evaluation)
23738
+ * failure — both are non-fatal to the host: read paths degrade to "skip link".
23739
+ */
23740
+ /** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
23741
+ * the failure is anchored to a character (author-facing inline feedback). */
23742
+ var ExpressionParseError = class extends Error {
23743
+ position;
23744
+ constructor(message, position) {
23745
+ super(message);
23746
+ this.name = "ExpressionParseError";
23747
+ this.position = position;
23748
+ }
23749
+ };
23750
+ /** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
23751
+ * result, unknown builtin, step-budget exceeded). */
23752
+ var ExpressionEvalError = class extends Error {
23753
+ constructor(message) {
23754
+ super(message);
23755
+ this.name = "ExpressionEvalError";
23756
+ }
23757
+ };
23758
+ /**
23759
+ * Frozen, null-prototype builtin function table for the expression engine
23760
+ * (spec §4 rule 4). The table is the SOLE surface of callable functions: the
23761
+ * parser rejects any callee not in it, and the evaluator gates each call on an
23762
+ * own-property check against it.
23763
+ *
23764
+ * Because the object has a NULL prototype AND is `Object.freeze`d:
23765
+ * - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
23766
+ * - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
23767
+ * (there is no `Object.prototype` in the chain), so those names are not
23768
+ * callable — they are simply "unknown function" at parse time.
23769
+ *
23770
+ * Every numeric argument is validated as a finite number and every numeric
23771
+ * RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
23772
+ * (`pow(10,400)` → Infinity) all raise `ExpressionEvalError` and fail the link
23773
+ * closed rather than emitting a garbage value.
23774
+ */
23775
+ function asFiniteNumber(value, name, index) {
23776
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
23777
+ return value;
23778
+ }
23779
+ function asString$1(value, name, index) {
23780
+ if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
23781
+ return value;
23782
+ }
23783
+ function finiteResult(value, name) {
23784
+ if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
23785
+ return value;
23786
+ }
23787
+ function allFiniteNumbers(args, name) {
23788
+ return args.map((a, idx) => asFiniteNumber(a, name, idx));
23789
+ }
23790
+ var INF = Number.POSITIVE_INFINITY;
23791
+ var table = {
23792
+ min: {
23793
+ minArgs: 1,
23794
+ maxArgs: INF,
23795
+ apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
23796
+ },
23797
+ max: {
23798
+ minArgs: 1,
23799
+ maxArgs: INF,
23800
+ apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
23801
+ },
23802
+ abs: {
23803
+ minArgs: 1,
23804
+ maxArgs: 1,
23805
+ apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
23806
+ },
23807
+ floor: {
23808
+ minArgs: 1,
23809
+ maxArgs: 1,
23810
+ apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
23811
+ },
23812
+ ceil: {
23813
+ minArgs: 1,
23814
+ maxArgs: 1,
23815
+ apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
23816
+ },
23817
+ sqrt: {
23818
+ minArgs: 1,
23819
+ maxArgs: 1,
23820
+ apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
23821
+ },
23822
+ round: {
23823
+ minArgs: 1,
23824
+ maxArgs: 2,
23825
+ apply: (args) => {
23826
+ const x = asFiniteNumber(args[0], "round", 0);
23827
+ const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
23828
+ if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
23829
+ const factor = 10 ** digits;
23830
+ return finiteResult(Math.round(x * factor) / factor, "round");
23831
+ }
23832
+ },
23833
+ pow: {
23834
+ minArgs: 2,
23835
+ maxArgs: 2,
23836
+ apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
23837
+ },
23838
+ clamp: {
23839
+ minArgs: 3,
23840
+ maxArgs: 3,
23841
+ apply: (args) => {
23842
+ const x = asFiniteNumber(args[0], "clamp", 0);
23843
+ const lo = asFiniteNumber(args[1], "clamp", 1);
23844
+ const hi = asFiniteNumber(args[2], "clamp", 2);
23845
+ if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
23846
+ return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
23847
+ }
23848
+ },
23849
+ avg: {
23850
+ minArgs: 1,
23851
+ maxArgs: INF,
23852
+ apply: (args) => {
23853
+ const nums = allFiniteNumbers(args, "avg");
23854
+ return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
23855
+ }
23856
+ },
23857
+ sum: {
23858
+ minArgs: 1,
23859
+ maxArgs: INF,
23860
+ apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
23861
+ },
23862
+ coalesce: {
23863
+ minArgs: 1,
23864
+ maxArgs: INF,
23865
+ apply: (args) => {
23866
+ for (const a of args) if (a !== null) return a;
23867
+ return null;
23868
+ }
23869
+ },
23870
+ age: {
23871
+ minArgs: 2,
23872
+ maxArgs: 2,
23873
+ apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
23874
+ },
23875
+ convert: {
23876
+ minArgs: 3,
23877
+ maxArgs: 3,
23878
+ apply: (args, hooks) => {
23879
+ const x = asFiniteNumber(args[0], "convert", 0);
23880
+ const from = asString$1(args[1], "convert", 1).trim();
23881
+ const to = asString$1(args[2], "convert", 2).trim();
23882
+ if (hooks.convert) {
23883
+ const out = hooks.convert(x, from, to);
23884
+ if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
23885
+ return finiteResult(out, "convert");
23886
+ }
23887
+ if (from === to) return x;
23888
+ throw new ExpressionEvalError("convert: unit conversion table not installed");
23889
+ }
23890
+ }
23891
+ };
23892
+ Object.freeze(Object.assign(Object.create(null), table));
23893
+ /** The set of valid builtin names — used by the parser to reject unknown
23894
+ * callees at parse time (immediate author feedback). */
23895
+ var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
23896
+ /**
23897
+ * Resource-bound constants for the safe expression engine.
23898
+ *
23899
+ * Every bound is defense-in-depth: the grammar is non-Turing-complete (no
23900
+ * loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
23901
+ * O(nodeCount) by construction. These caps merely put a hard ceiling on the
23902
+ * work a single author-supplied expression can request, so a hostile or
23903
+ * accidental pathological string can never spend unbounded CPU/memory.
23904
+ */
23905
+ /** Max source length (chars) — checked BEFORE tokenizing so a huge string is
23906
+ * rejected without allocation. */
23907
+ var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
23908
+ /** A legal binding / identifier name. */
23909
+ var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
23910
+ /** Binding names an author may NOT use: `now` is auto-injected; the literal
23911
+ * keywords lex as values, not identifiers, so binding to them is meaningless. */
23912
+ var RESERVED_BINDING_NAMES = new Set([
23913
+ "now",
23914
+ "true",
23915
+ "false",
23916
+ "null"
23917
+ ]);
23918
+ /**
23919
+ * Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
23920
+ * zero-dependency. The grammar is deliberately boring: decimal numbers,
23921
+ * single/double-quoted strings with a tiny escape set, identifiers, the three
23922
+ * value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
23923
+ * outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
23924
+ * is a parse error with a source position, so member access / assignment /
23925
+ * template literals are lexically impossible.
23926
+ */
23927
+ var KEYWORDS = new Set([
23928
+ "true",
23929
+ "false",
23930
+ "null"
23931
+ ]);
23932
+ function isDigit(ch) {
23933
+ return ch >= "0" && ch <= "9";
23934
+ }
23935
+ function isIdentStart(ch) {
23936
+ return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
23937
+ }
23938
+ function isIdentPart(ch) {
23939
+ return isIdentStart(ch) || isDigit(ch);
23940
+ }
23941
+ function isWhitespace(ch) {
23942
+ return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
23943
+ }
23944
+ /** Tokenize `source` into a flat token list ending with a single `eof` token.
23945
+ * Throws `ExpressionParseError` on any illegal character or unterminated
23946
+ * string. */
23947
+ function tokenize(source) {
23948
+ if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
23949
+ const tokens = [];
23950
+ let i = 0;
23951
+ const n = source.length;
23952
+ while (i < n) {
23953
+ const ch = source[i];
23954
+ if (isWhitespace(ch)) {
23955
+ i += 1;
23956
+ continue;
23957
+ }
23958
+ if (isDigit(ch)) {
23959
+ const start = i;
23960
+ while (i < n && isDigit(source[i])) i += 1;
23961
+ if (i < n && source[i] === ".") {
23962
+ if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
23963
+ i += 1;
23964
+ while (i < n && isDigit(source[i])) i += 1;
23965
+ }
23966
+ const text = source.slice(start, i);
23967
+ const value = Number(text);
23968
+ if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
23969
+ tokens.push({
23970
+ type: "number",
23971
+ value,
23972
+ pos: start
23973
+ });
23974
+ continue;
23975
+ }
23976
+ if (ch === "'" || ch === "\"") {
23977
+ const quote = ch;
23978
+ const start = i;
23979
+ i += 1;
23980
+ let out = "";
23981
+ let closed = false;
23982
+ while (i < n) {
23983
+ const c = source[i];
23984
+ if (c === "\\") {
23985
+ const next = i + 1 < n ? source[i + 1] : "";
23986
+ if (next === "\\" || next === "'" || next === "\"") {
23987
+ out += next;
23988
+ i += 2;
23989
+ continue;
23990
+ }
23991
+ throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
23992
+ }
23993
+ if (c === quote) {
23994
+ closed = true;
23995
+ i += 1;
23996
+ break;
23997
+ }
23998
+ out += c;
23999
+ i += 1;
24000
+ }
24001
+ if (!closed) throw new ExpressionParseError("unterminated string literal", start);
24002
+ tokens.push({
24003
+ type: "string",
24004
+ value: out,
24005
+ pos: start
24006
+ });
24007
+ continue;
24008
+ }
24009
+ if (isIdentStart(ch)) {
24010
+ const start = i;
24011
+ while (i < n && isIdentPart(source[i])) i += 1;
24012
+ const text = source.slice(start, i);
24013
+ if (KEYWORDS.has(text)) tokens.push({
24014
+ type: "keyword",
24015
+ keyword: keywordOf(text),
24016
+ pos: start
24017
+ });
24018
+ else tokens.push({
24019
+ type: "identifier",
24020
+ name: text,
24021
+ pos: start
24022
+ });
24023
+ continue;
24024
+ }
24025
+ const two = i + 1 < n ? source.slice(i, i + 2) : "";
24026
+ if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
24027
+ tokens.push({
24028
+ type: "punct",
24029
+ punct: two,
24030
+ pos: i
24031
+ });
24032
+ i += 2;
24033
+ continue;
24034
+ }
24035
+ if (isSinglePunct(ch)) {
24036
+ tokens.push({
24037
+ type: "punct",
24038
+ punct: ch,
24039
+ pos: i
24040
+ });
24041
+ i += 1;
24042
+ continue;
24043
+ }
24044
+ throw new ExpressionParseError(`unexpected character '${ch}'`, i);
24045
+ }
24046
+ tokens.push({
24047
+ type: "eof",
24048
+ pos: n
24049
+ });
24050
+ return tokens;
24051
+ }
24052
+ function keywordOf(text) {
24053
+ if (text === "true") return "true";
24054
+ if (text === "false") return "false";
24055
+ return "null";
24056
+ }
24057
+ function isSinglePunct(ch) {
24058
+ return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
24059
+ }
24060
+ /**
24061
+ * Pratt (precedence-climbing) parser for the safe expression mini-language.
24232
24062
  *
24233
- * Extend the enum here when a new gating consumer comes online (audio
24234
- * gating, alert filtering, …) no other surface needs to change.
24063
+ * Precedence (low high): ternary `?:` (right-assoc) `||` `&&` → equality
24064
+ * relational additive multiplicative unary `! -` → call / primary.
24065
+ * Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
24066
+ * string validated against the builtin table at parse time, so an unknown
24067
+ * function is rejected immediately (author feedback) and a persisted expression
24068
+ * that references a since-removed builtin degrades at read.
24069
+ *
24070
+ * A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
24071
+ * arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
24235
24072
  */
24236
- var ZoneRuleStageEnum = _enum([
24237
- "motion",
24238
- "detection",
24239
- "package"
24240
- ]);
24241
- DeviceType.Camera, method(object({
24242
- deviceId: number(),
24243
- stage: ZoneRuleStageEnum
24244
- }), array(ZoneRuleSchema).readonly()), method(object({
24245
- deviceId: number(),
24246
- stage: ZoneRuleStageEnum,
24247
- rules: array(ZoneRuleSchema).readonly()
24248
- }), _void(), {
24249
- kind: "mutation",
24250
- auth: "admin"
24251
- }), object({
24252
- motion: array(ZoneRuleSchema).readonly(),
24253
- detection: array(ZoneRuleSchema).readonly(),
24254
- package: array(ZoneRuleSchema).readonly()
24255
- });
24073
+ /** Binary/logical operator precedence (higher binds tighter). */
24074
+ var BINARY_PRECEDENCE = {
24075
+ "||": 1,
24076
+ "&&": 2,
24077
+ "==": 3,
24078
+ "!=": 3,
24079
+ "<": 4,
24080
+ "<=": 4,
24081
+ ">": 4,
24082
+ ">=": 4,
24083
+ "+": 5,
24084
+ "-": 5,
24085
+ "*": 6,
24086
+ "/": 6,
24087
+ "%": 6
24088
+ };
24089
+ function isLogicalOp(op) {
24090
+ return op === "&&" || op === "||";
24091
+ }
24092
+ function isBinaryOp(op) {
24093
+ return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
24094
+ }
24095
+ var Parser = class {
24096
+ tokens;
24097
+ pos = 0;
24098
+ nodeCount = 0;
24099
+ identifiers = /* @__PURE__ */ new Set();
24100
+ callees = /* @__PURE__ */ new Set();
24101
+ constructor(tokens) {
24102
+ this.tokens = tokens;
24103
+ }
24104
+ parse() {
24105
+ const ast = this.parseTernary();
24106
+ const tok = this.peek();
24107
+ if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
24108
+ return {
24109
+ ast,
24110
+ identifiers: this.identifiers,
24111
+ callees: this.callees,
24112
+ nodeCount: this.nodeCount
24113
+ };
24114
+ }
24115
+ peek() {
24116
+ return this.tokens[this.pos];
24117
+ }
24118
+ next() {
24119
+ return this.tokens[this.pos++];
24120
+ }
24121
+ /** Consume a punctuator token, erroring if the next token isn't it. */
24122
+ expectPunct(punct) {
24123
+ const tok = this.peek();
24124
+ if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
24125
+ this.pos += 1;
24126
+ }
24127
+ matchPunct(punct) {
24128
+ const tok = this.peek();
24129
+ if (tok.type === "punct" && tok.punct === punct) {
24130
+ this.pos += 1;
24131
+ return true;
24132
+ }
24133
+ return false;
24134
+ }
24135
+ countNode() {
24136
+ this.nodeCount += 1;
24137
+ if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
24138
+ }
24139
+ parseTernary() {
24140
+ const test = this.parseBinary(1);
24141
+ if (this.matchPunct("?")) {
24142
+ const consequent = this.parseTernary();
24143
+ this.expectPunct(":");
24144
+ const alternate = this.parseTernary();
24145
+ this.countNode();
24146
+ return {
24147
+ kind: "conditional",
24148
+ test,
24149
+ consequent,
24150
+ alternate
24151
+ };
24152
+ }
24153
+ return test;
24154
+ }
24155
+ parseBinary(minPrec) {
24156
+ let left = this.parseUnary();
24157
+ for (;;) {
24158
+ const tok = this.peek();
24159
+ if (tok.type !== "punct") break;
24160
+ const prec = BINARY_PRECEDENCE[tok.punct];
24161
+ if (prec === void 0 || prec < minPrec) break;
24162
+ const op = tok.punct;
24163
+ this.pos += 1;
24164
+ const right = this.parseBinary(prec + 1);
24165
+ this.countNode();
24166
+ if (isLogicalOp(op)) left = {
24167
+ kind: "logical",
24168
+ op,
24169
+ left,
24170
+ right
24171
+ };
24172
+ else if (isBinaryOp(op)) left = {
24173
+ kind: "binary",
24174
+ op,
24175
+ left,
24176
+ right
24177
+ };
24178
+ else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
24179
+ }
24180
+ return left;
24181
+ }
24182
+ parseUnary() {
24183
+ const tok = this.peek();
24184
+ if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
24185
+ const op = tok.punct;
24186
+ this.pos += 1;
24187
+ const operand = this.parseUnary();
24188
+ this.countNode();
24189
+ return {
24190
+ kind: "unary",
24191
+ op,
24192
+ operand
24193
+ };
24194
+ }
24195
+ return this.parsePrimary();
24196
+ }
24197
+ parsePrimary() {
24198
+ const tok = this.next();
24199
+ switch (tok.type) {
24200
+ case "number":
24201
+ this.countNode();
24202
+ return {
24203
+ kind: "literal",
24204
+ value: tok.value
24205
+ };
24206
+ case "string":
24207
+ this.countNode();
24208
+ return {
24209
+ kind: "literal",
24210
+ value: tok.value
24211
+ };
24212
+ case "keyword":
24213
+ this.countNode();
24214
+ return {
24215
+ kind: "literal",
24216
+ value: tok.keyword === "null" ? null : tok.keyword === "true"
24217
+ };
24218
+ case "identifier": {
24219
+ const nextTok = this.peek();
24220
+ if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
24221
+ this.identifiers.add(tok.name);
24222
+ this.countNode();
24223
+ return {
24224
+ kind: "identifier",
24225
+ name: tok.name
24226
+ };
24227
+ }
24228
+ case "punct":
24229
+ if (tok.punct === "(") {
24230
+ const inner = this.parseTernary();
24231
+ this.expectPunct(")");
24232
+ return inner;
24233
+ }
24234
+ throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
24235
+ case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
24236
+ }
24237
+ }
24238
+ parseCall(callee, pos) {
24239
+ if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
24240
+ this.expectPunct("(");
24241
+ const args = [];
24242
+ if (!this.matchPunct(")")) for (;;) {
24243
+ args.push(this.parseTernary());
24244
+ if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
24245
+ if (this.matchPunct(",")) continue;
24246
+ this.expectPunct(")");
24247
+ break;
24248
+ }
24249
+ this.callees.add(callee);
24250
+ this.countNode();
24251
+ return {
24252
+ kind: "call",
24253
+ callee,
24254
+ args
24255
+ };
24256
+ }
24257
+ };
24258
+ /** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
24259
+ * `ExpressionParseError` on any lexical or grammatical failure. */
24260
+ function parseExpression(source) {
24261
+ return new Parser(tokenize(source)).parse();
24262
+ }
24256
24263
  /**
24257
- * Accessory device helpers shared across drivers.
24258
- *
24259
- * Many vendor-specific drivers register accessory child devices on
24260
- * top of a parent (Reolink: siren / floodlight / PIR / autotrack /
24261
- * chime; ONVIF: relay outputs; future: Tapo Hub child devices). Each
24262
- * driver picks the right `DeviceType` + `DeviceRole` explicitly when
24263
- * spawning, builds a name derived from the parent, and produces a
24264
- * stableId tied to the parent so boot-restore can reconstruct the
24265
- * relationship.
24264
+ * LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
24265
+ * by expr"). The cache stores BOTH successes and failures (negative caching),
24266
+ * so a corrupt persisted string costs exactly one tokenize+parse total — not
24267
+ * one per read on a hot resolve path.
24266
24268
  *
24267
- * Centralised `(kind DeviceType)` mapping was dropped on purpose:
24268
- * drivers may reasonably disagree on the right type for an accessory
24269
- * (a Reolink PIR exposes a switch on/off + sensitivity, while a hypothetical
24270
- * read-only motion-only sensor might be `DeviceType.Sensor`). Forcing
24271
- * one canonical mapping was over-prescriptive and added a layer of
24272
- * indirection without saving meaningful code at call sites — the
24273
- * driver knows its own hardware best.
24269
+ * The cache is a module-level singleton: entries are pure, content-addressed
24270
+ * ASTs keyed by the raw source string, so sharing one instance across all
24271
+ * callers is safe and maximises hit rate.
24274
24272
  */
24273
+ var cache = /* @__PURE__ */ new Map();
24274
+ function getCached(source) {
24275
+ const hit = cache.get(source);
24276
+ if (hit !== void 0) {
24277
+ cache.delete(source);
24278
+ cache.set(source, hit);
24279
+ return hit;
24280
+ }
24281
+ let result;
24282
+ try {
24283
+ result = {
24284
+ ok: true,
24285
+ parsed: parseExpression(source)
24286
+ };
24287
+ } catch (err) {
24288
+ result = {
24289
+ ok: false,
24290
+ error: err instanceof ExpressionParseError ? err.message : String(err)
24291
+ };
24292
+ }
24293
+ cache.set(source, result);
24294
+ if (cache.size > 256) {
24295
+ const oldest = cache.keys().next().value;
24296
+ if (oldest !== void 0) cache.delete(oldest);
24297
+ }
24298
+ return result;
24299
+ }
24300
+ /** Compile `source`, returning a discriminated result instead of throwing.
24301
+ * Used by read paths that must degrade rather than raise. LRU/negative-cached. */
24302
+ function compileExpressionSafe(source) {
24303
+ return getCached(source);
24304
+ }
24305
+ Object.freeze({});
24275
24306
  /**
24276
- * Subset of `DeviceRole` values that drivers register as child
24277
- * accessories of a parent device. Sourced verbatim from `DeviceRole`
24278
- * `AccessoryKind` is the alias drivers use when building accessory
24279
- * children, so the call site reads as
24280
- * `accessoryStableId(parent, AccessoryKind.Siren)` rather than
24281
- * `accessoryStableId(parent, DeviceRole.Siren)` (which would imply
24282
- * any role works, including non-accessory ones like Doorbell).
24307
+ * Author-time validation. Returns `null` when the source is valid, else a
24308
+ * human-readable error message. Checks: the expression compiles; binding count
24309
+ * is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
24310
+ * is not reserved (`now`/keywords) and does not shadow a builtin; and every
24311
+ * FREE identifier of the AST is covered by a binding or the injected `now`.
24283
24312
  */
24284
- var AccessoryKind = {
24285
- Siren: DeviceRole.Siren,
24286
- Floodlight: DeviceRole.Floodlight,
24287
- Spotlight: DeviceRole.Spotlight,
24288
- PirSensor: DeviceRole.PirSensor,
24289
- Chime: DeviceRole.Chime,
24290
- Autotrack: DeviceRole.Autotrack,
24291
- Nightvision: DeviceRole.Nightvision,
24292
- PrivacyMask: DeviceRole.PrivacyMask
24293
- };
24294
- AccessoryKind.Siren, AccessoryKind.Floodlight, AccessoryKind.Spotlight, AccessoryKind.PirSensor, AccessoryKind.Chime, AccessoryKind.Autotrack, AccessoryKind.Nightvision, AccessoryKind.PrivacyMask;
24295
- 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;
24296
- new Set(Object.values(DeviceType));
24297
- DeviceFeature.BatteryOperated;
24313
+ function validateExpressionSource(src) {
24314
+ const names = Object.keys(src.bindings);
24315
+ if (names.length > 32) return `too many bindings (${names.length} > 32)`;
24316
+ for (const name of names) {
24317
+ if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
24318
+ if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
24319
+ if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
24320
+ }
24321
+ const compiled = compileExpressionSafe(src.expr);
24322
+ if (!compiled.ok) return compiled.error;
24323
+ const bound = new Set(names);
24324
+ for (const id of compiled.parsed.identifiers) {
24325
+ if (id === "now") continue;
24326
+ if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
24327
+ }
24328
+ return null;
24329
+ }
24330
+ var ExpressionBindingSourceSchema = union([
24331
+ object({
24332
+ kind: literal("field").optional(),
24333
+ sourceKey: string(),
24334
+ cap: string(),
24335
+ fieldPath: string()
24336
+ }),
24337
+ object({
24338
+ kind: literal("literal"),
24339
+ value: union([
24340
+ string(),
24341
+ number(),
24342
+ boolean(),
24343
+ _null()
24344
+ ])
24345
+ }),
24346
+ object({
24347
+ kind: literal("global"),
24348
+ sourceStableId: string(),
24349
+ cap: string(),
24350
+ fieldPath: string()
24351
+ })
24352
+ ]);
24353
+ object({
24354
+ expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
24355
+ bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
24356
+ }).superRefine((src, ctx) => {
24357
+ const err = validateExpressionSource(src);
24358
+ if (err !== null) ctx.addIssue({
24359
+ code: "custom",
24360
+ message: err,
24361
+ path: ["expr"]
24362
+ });
24363
+ });
24298
24364
  Object.freeze({
24299
24365
  "accessories.setChildHidden": {
24300
24366
  capName: "accessories",
@@ -25118,6 +25184,12 @@ Object.freeze({
25118
25184
  addonId: null,
25119
25185
  access: "view"
25120
25186
  },
25187
+ "coreBlocks.restart": {
25188
+ capName: "core-blocks",
25189
+ capScope: "system",
25190
+ addonId: null,
25191
+ access: "create"
25192
+ },
25121
25193
  "coreBlocks.setEnabled": {
25122
25194
  capName: "core-blocks",
25123
25195
  capScope: "system",
@@ -25754,12 +25826,6 @@ Object.freeze({
25754
25826
  addonId: null,
25755
25827
  access: "create"
25756
25828
  },
25757
- "deviceManager.setDeviceLinks": {
25758
- capName: "device-manager",
25759
- capScope: "system",
25760
- addonId: null,
25761
- access: "create"
25762
- },
25763
25829
  "deviceManager.setDisabled": {
25764
25830
  capName: "device-manager",
25765
25831
  capScope: "system",
@@ -28904,6 +28970,12 @@ Object.freeze({
28904
28970
  addonId: null,
28905
28971
  access: "create"
28906
28972
  },
28973
+ "streamBroker.fetchEventMedia": {
28974
+ capName: "stream-broker",
28975
+ capScope: "system",
28976
+ addonId: null,
28977
+ access: "create"
28978
+ },
28907
28979
  "streamBroker.getAllRtspEntries": {
28908
28980
  capName: "stream-broker",
28909
28981
  capScope: "system",
@@ -28988,6 +29060,12 @@ Object.freeze({
28988
29060
  addonId: null,
28989
29061
  access: "create"
28990
29062
  },
29063
+ "streamBroker.produceEventMedia": {
29064
+ capName: "stream-broker",
29065
+ capScope: "system",
29066
+ addonId: null,
29067
+ access: "create"
29068
+ },
28991
29069
  "streamBroker.publishCameraStream": {
28992
29070
  capName: "stream-broker",
28993
29071
  capScope: "system",
@@ -43957,37 +44035,37 @@ function errMsg$8(err) {
43957
44035
  }
43958
44036
  //#endregion
43959
44037
  //#region src/mappers/builders/doorbell-delivery.ts
43960
- function isRecord(value) {
44038
+ function isRecord$1(value) {
43961
44039
  return typeof value === "object" && value !== null;
43962
44040
  }
43963
44041
  function numberOrNull(value) {
43964
44042
  return typeof value === "number" ? value : null;
43965
44043
  }
43966
44044
  function isConnectionLike(value) {
43967
- return isRecord(value) && typeof value["hasEventNotifications"] === "function";
44045
+ return isRecord$1(value) && typeof value["hasEventNotifications"] === "function";
43968
44046
  }
43969
- function isIterable(value) {
43970
- return isRecord(value) && typeof value[Symbol.iterator] === "function";
44047
+ function isIterable$1(value) {
44048
+ return isRecord$1(value) && typeof value[Symbol.iterator] === "function";
43971
44049
  }
43972
44050
  /** `accessory._server.httpServer.connections`, or null at any missing hop. */
43973
- function readConnections(accessory) {
43974
- if (!isRecord(accessory)) return null;
44051
+ function readConnections$1(accessory) {
44052
+ if (!isRecord$1(accessory)) return null;
43975
44053
  const server = accessory["_server"];
43976
- if (!isRecord(server)) return null;
44054
+ if (!isRecord$1(server)) return null;
43977
44055
  const httpServer = server["httpServer"];
43978
- if (!isRecord(httpServer)) return null;
44056
+ if (!isRecord$1(httpServer)) return null;
43979
44057
  const connections = httpServer["connections"];
43980
- return isIterable(connections) ? connections : null;
44058
+ return isIterable$1(connections) ? connections : null;
43981
44059
  }
43982
44060
  /**
43983
44061
  * Probe how far a ring on `characteristic` of `accessory` can travel RIGHT NOW.
43984
44062
  * Pure with respect to HAP state — it only reads. Never throws.
43985
44063
  */
43986
44064
  function describeDoorbellDelivery(accessory, characteristic) {
43987
- const aid = isRecord(accessory) ? numberOrNull(accessory["aid"]) : null;
43988
- const iid = isRecord(characteristic) ? numberOrNull(characteristic["iid"]) : null;
43989
- const serverPublished = isRecord(accessory) && isRecord(accessory["_server"]);
43990
- const connections = readConnections(accessory);
44065
+ const aid = isRecord$1(accessory) ? numberOrNull(accessory["aid"]) : null;
44066
+ const iid = isRecord$1(characteristic) ? numberOrNull(characteristic["iid"]) : null;
44067
+ const serverPublished = isRecord$1(accessory) && isRecord$1(accessory["_server"]);
44068
+ const connections = readConnections$1(accessory);
43991
44069
  if (connections === null) return {
43992
44070
  aid,
43993
44071
  iid,
@@ -44099,10 +44177,16 @@ async function buildMotionSensor(bctx, existing = null) {
44099
44177
  resetTimer = null;
44100
44178
  }, RESET_DEBOUNCE_MS);
44101
44179
  };
44180
+ const motionLog = ctx.logger.withTags({ deviceId: numericDeviceId });
44181
+ const hksvTrigger = existing !== null;
44102
44182
  const unsubscribe = ctx.eventBus.subscribe({ category: EventCategory.MotionOnMotionChanged }, (event) => {
44103
44183
  if (event.data.deviceId !== numericDeviceId) return;
44104
44184
  const detected = event.data.detected === true;
44105
44185
  motionService.updateCharacteristic(Characteristic.MotionDetected, detected);
44186
+ motionLog.debug("export-hap: motion pushed to HomeKit", { meta: {
44187
+ detected,
44188
+ hksvTrigger
44189
+ } });
44106
44190
  if (detected) armReset();
44107
44191
  else if (resetTimer) {
44108
44192
  clearTimeout(resetTimer);
@@ -44479,36 +44563,84 @@ async function probe(call, label, log) {
44479
44563
  }
44480
44564
  }
44481
44565
  //#endregion
44482
- //#region src/hksv/recording-options.ts
44566
+ //#region src/hksv/controller-census.ts
44567
+ var EMPTY_CENSUS = {
44568
+ serverPublished: false,
44569
+ connections: 0,
44570
+ adminConnections: 0,
44571
+ nonAdminConnections: 0,
44572
+ unverifiedConnections: 0,
44573
+ pairedControllers: 0,
44574
+ pairedAdmins: 0
44575
+ };
44576
+ function isRecord(value) {
44577
+ return typeof value === "object" && value !== null;
44578
+ }
44579
+ function isIterable(value) {
44580
+ return isRecord(value) && typeof value[Symbol.iterator] === "function";
44581
+ }
44582
+ function isAccessoryInfoLike(value) {
44583
+ return isRecord(value) && isRecord(value["pairedClients"]) && typeof value["hasAdminPermissions"] === "function";
44584
+ }
44585
+ /** `accessory._server.httpServer.connections`, or null at any missing hop. */
44586
+ function readConnections(accessory) {
44587
+ const server = accessory["_server"];
44588
+ if (!isRecord(server)) return null;
44589
+ const httpServer = server["httpServer"];
44590
+ if (!isRecord(httpServer)) return null;
44591
+ const connections = httpServer["connections"];
44592
+ return isIterable(connections) ? connections : null;
44593
+ }
44483
44594
  /**
44484
- * The HomeKit Secure Video ADVERTISEMENT`CameraRecordingOptions`, derived
44485
- * from what the fMP4 sink will actually produce for THIS camera.
44486
- *
44487
- * ## The rule this file exists to enforce
44488
- *
44489
- * Never advertise something we cannot serve. That is not a slogan here: it is
44490
- * the diagnosis of [D50](../../../../docs/decisions/adr-0050.md) an
44491
- * advertised `recording` whose delegate yielded nothing put every motion-capable
44492
- * camera into a ~12 s timeout loop every 20-60 s, all day. So every number below
44493
- * is derived from the picked source (`recording-source.ts`) or from a measured
44494
- * property of the sink, and none of them is a plausible-looking constant.
44495
- *
44496
- * ## The fragment length is the subtle one
44497
- *
44498
- * HKSV requires every media fragment to be **no longer** than the length the
44499
- * controller selected. On the copy branch the fragment length is the SOURCE's
44500
- * key-frame cadence ([D80](../../../../docs/decisions/adr-0080.md)) — we do not
44501
- * get to choose it, we can only be honest about it. So:
44502
- *
44503
- * - when the camera reports its GOP (`stream-params`), the advertised length is
44504
- * the smallest offered value that COVERS it;
44505
- * - when it does not, we advertise the 4000 ms every HKSV camera uses and the
44506
- * delegate warns at `warn` with `tags: { deviceId }` if the fragments that
44507
- * actually arrive are longer.
44508
- *
44509
- * A camera whose GOP exceeds the longest value we offer does not advertise
44510
- * recording at all. See {@link deriveFragmentLengthMs}.
44595
+ * Census the accessory's HAP connections. Pure with respect to HAP state it
44596
+ * only reads and never throws.
44597
+ */
44598
+ function describeHapControllers(accessory) {
44599
+ if (!isRecord(accessory)) return EMPTY_CENSUS;
44600
+ const info = accessory["_accessoryInfo"];
44601
+ const paired = isAccessoryInfoLike(info) ? Object.keys(info.pairedClients) : [];
44602
+ const pairedAdmins = isAccessoryInfoLike(info) ? paired.filter((username) => info.hasAdminPermissions(username)).length : 0;
44603
+ const connections = readConnections(accessory);
44604
+ if (connections === null) return {
44605
+ ...EMPTY_CENSUS,
44606
+ pairedControllers: paired.length,
44607
+ pairedAdmins
44608
+ };
44609
+ let open = 0;
44610
+ let admins = 0;
44611
+ let nonAdmins = 0;
44612
+ let unverified = 0;
44613
+ for (const connection of connections) {
44614
+ open += 1;
44615
+ const username = isRecord(connection) ? connection["username"] : void 0;
44616
+ if (typeof username !== "string" || !isAccessoryInfoLike(info)) {
44617
+ unverified += 1;
44618
+ continue;
44619
+ }
44620
+ if (info.hasAdminPermissions(username)) admins += 1;
44621
+ else nonAdmins += 1;
44622
+ }
44623
+ return {
44624
+ serverPublished: true,
44625
+ connections: open,
44626
+ adminConnections: admins,
44627
+ nonAdminConnections: nonAdmins,
44628
+ unverifiedConnections: unverified,
44629
+ pairedControllers: paired.length,
44630
+ pairedAdmins
44631
+ };
44632
+ }
44633
+ /**
44634
+ * True when NO connected controller may write HKSV state. Every
44635
+ * `SelectedCameraRecordingConfiguration` write such a controller sends is
44636
+ * refused before it reaches us, so the recording configuration can never
44637
+ * arrive and the caller must say so out loud.
44511
44638
  */
44639
+ function noAdminControllerConnected(census) {
44640
+ return census.serverPublished && census.connections > 0 && census.adminConnections === 0;
44641
+ }
44642
+ //#endregion
44643
+ //#region src/hksv/recording-options.ts
44512
44644
  /**
44513
44645
  * The prebuffer we promise. HAP's floor is 4000 ms and its documented sensible
44514
44646
  * range is [4000, 8000]; the plane's ring is sized from this, so the two cannot
@@ -44531,6 +44663,16 @@ var HKSV_FRAGMENT_LENGTHS_MS = [4e3, 8e3];
44531
44663
  */
44532
44664
  var HKSV_AUDIO_SAMPLE_RATE_HZ = 24e3;
44533
44665
  /**
44666
+ * The frame rates the advertised {@link Resolution} may carry, and the only
44667
+ * ones. See {@link normaliseAdvertisedFps} for why the measured rate does not
44668
+ * go in raw.
44669
+ */
44670
+ var HKSV_ADVERTISED_FRAME_RATES = [
44671
+ 15,
44672
+ 24,
44673
+ 30
44674
+ ];
44675
+ /**
44534
44676
  * The advertised fragment length for a camera whose key-frame cadence is
44535
44677
  * `sourceGopMs`, or `null` when no offered length covers it.
44536
44678
  *
@@ -44546,6 +44688,46 @@ function deriveFragmentLengthMs(sourceGopMs) {
44546
44688
  return HKSV_FRAGMENT_LENGTHS_MS.find((ms) => ms >= sourceGopMs) ?? null;
44547
44689
  }
44548
44690
  /**
44691
+ * The frame rate to ADVERTISE for a slot that was measured at `measuredFps` —
44692
+ * the nearest member of {@link HKSV_ADVERTISED_FRAME_RATES}, ties going to the
44693
+ * lower rate.
44694
+ *
44695
+ * The measured rate does not go into the advertisement raw, for two reasons,
44696
+ * and the second one is the serious one.
44697
+ *
44698
+ * **It is the list the controller chooses from.** `[1280, 720, 10]` — 615's
44699
+ * measured 720p slot — is a frame rate no shipping HKSV camera offers, and the
44700
+ * controller has to find an acceptable configuration in what we advertise
44701
+ * before it will write one back.
44702
+ *
44703
+ * **A measurement makes the advertisement UNSTABLE, and hap-nodejs punishes
44704
+ * that by discarding the controller's selection.** `RecordingManagement`
44705
+ * hashes the supported-configuration TLVs and, on restore, keeps the persisted
44706
+ * `selectedConfiguration` only while the hash still matches — otherwise
44707
+ * `deserialize: discarding saved selectedConfiguration`, after which the
44708
+ * accessory answers every HDS `DATA_SEND OPEN` with `INVALID_CONFIGURATION`
44709
+ * and records nothing until the controller happens to re-select. The
44710
+ * advertised resolution is the one hashed input that came from a probe:
44711
+ * camera 590 measured 9 fps on one restart and 10 on the next, on
44712
+ * 2026-08-07/08, so this was a self-inflicted outage waiting on a reboot.
44713
+ * Quantising gives the probe a wide band to move inside without the
44714
+ * advertisement changing at all.
44715
+ */
44716
+ function normaliseAdvertisedFps(measuredFps) {
44717
+ const fallback = HKSV_ADVERTISED_FRAME_RATES[0] ?? 15;
44718
+ if (!Number.isFinite(measuredFps) || measuredFps <= 0) return fallback;
44719
+ let best = fallback;
44720
+ let bestDistance = Number.POSITIVE_INFINITY;
44721
+ for (const candidate of HKSV_ADVERTISED_FRAME_RATES) {
44722
+ const distance = Math.abs(candidate - measuredFps);
44723
+ if (distance < bestDistance) {
44724
+ best = candidate;
44725
+ bestDistance = distance;
44726
+ }
44727
+ }
44728
+ return best;
44729
+ }
44730
+ /**
44549
44731
  * Build the advertisement.
44550
44732
  *
44551
44733
  * ONE resolution is advertised — the one slot the recording child pulls. HAP's
@@ -44558,7 +44740,7 @@ function buildRecordingOptions(input) {
44558
44740
  const resolution = [
44559
44741
  input.width,
44560
44742
  input.height,
44561
- Math.max(1, Math.round(input.fps))
44743
+ normaliseAdvertisedFps(input.fps)
44562
44744
  ];
44563
44745
  return {
44564
44746
  prebufferLength: HKSV_PREBUFFER_MS,
@@ -44881,13 +45063,19 @@ var HksvRecordingDelegate = class {
44881
45063
  updateRecordingActive(active) {
44882
45064
  if (active === this.active) return;
44883
45065
  this.active = active;
45066
+ const census = this.input.describeControllers();
44884
45067
  this.log.info("hksv: recording active changed", {
44885
45068
  tags: { deviceId: this.input.deviceId },
44886
45069
  meta: {
44887
45070
  active,
44888
- hasConfiguration: this.configuration !== void 0
45071
+ hasConfiguration: this.configuration !== void 0,
45072
+ ...census
44889
45073
  }
44890
45074
  });
45075
+ 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", {
45076
+ tags: { deviceId: this.input.deviceId },
45077
+ meta: { ...census }
45078
+ });
44891
45079
  this.reconcile("recording-active");
44892
45080
  }
44893
45081
  updateRecordingConfiguration(configuration) {
@@ -44942,13 +45130,14 @@ var HksvRecordingDelegate = class {
44942
45130
  streamId,
44943
45131
  subscription
44944
45132
  };
44945
- const startedAt = Date.now();
45133
+ const startedAt = this.input.now();
44946
45134
  const prebufferSpanMs = source.prebufferSpanMs();
44947
45135
  let packets = 0;
44948
45136
  let bytes = 0;
44949
45137
  let markedLast = false;
44950
45138
  let longestFragmentGapMs = 0;
44951
- let lastPacketAt = startedAt;
45139
+ let firstFragmentAt = null;
45140
+ let lastFragmentAt = null;
44952
45141
  try {
44953
45142
  for await (const packet of subscription.packets()) {
44954
45143
  if (signal?.aborted === true) {
@@ -44964,9 +45153,10 @@ var HksvRecordingDelegate = class {
44964
45153
  packets += 1;
44965
45154
  bytes += packet.data.length;
44966
45155
  if (packet.kind === "fragment") {
44967
- const now = Date.now();
44968
- longestFragmentGapMs = Math.max(longestFragmentGapMs, now - lastPacketAt);
44969
- lastPacketAt = now;
45156
+ const now = this.input.now();
45157
+ if (lastFragmentAt === null) firstFragmentAt = now;
45158
+ else longestFragmentGapMs = Math.max(longestFragmentGapMs, now - lastFragmentAt);
45159
+ lastFragmentAt = now;
44970
45160
  }
44971
45161
  markedLast = markedLast || packet.isLast;
44972
45162
  yield {
@@ -45001,9 +45191,10 @@ var HksvRecordingDelegate = class {
45001
45191
  streamId,
45002
45192
  packets,
45003
45193
  bytes,
45004
- durationMs: Date.now() - startedAt,
45194
+ durationMs: this.input.now() - startedAt,
45005
45195
  prebufferSpanMs,
45006
45196
  longestFragmentGapMs,
45197
+ msToFirstFragmentMs: firstFragmentAt === null ? null : firstFragmentAt - startedAt,
45007
45198
  closedReason: subscription.closedReason,
45008
45199
  markedLast
45009
45200
  }
@@ -45220,6 +45411,8 @@ async function buildHksvRecording(input) {
45220
45411
  deviceId: numericDeviceId,
45221
45412
  isAudioActive: input.isAudioActive,
45222
45413
  advertisedFragmentMs: fragmentLengthMs,
45414
+ now: () => Date.now(),
45415
+ describeControllers: () => describeHapControllers(bctx.accessory),
45223
45416
  createSource: ({ fragmentMs, audioActive }) => new HksvFragmentSource({
45224
45417
  logger: log,
45225
45418
  deviceId: numericDeviceId,
@@ -45230,11 +45423,13 @@ async function buildHksvRecording(input) {
45230
45423
  audioActive
45231
45424
  })
45232
45425
  });
45426
+ const advertisedResolution = options.video.resolutions[0];
45233
45427
  log.info("export-hap: HKSV ADVERTISED — recording is offered for this camera", { meta: {
45234
45428
  brokerId: source.brokerId,
45235
45429
  profile: source.profile,
45236
45430
  resolution: `${source.width}x${source.height}`,
45237
- fps,
45431
+ measuredFps: fps,
45432
+ advertisedFps: advertisedResolution?.[2] ?? null,
45238
45433
  fragmentLengthMs,
45239
45434
  sourceGopMs: gopMs ?? "unknown"
45240
45435
  } });