@camstack/addon-post-analysis 1.2.51 → 1.2.53

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.
@@ -19,7 +19,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
19
19
  value: mod,
20
20
  enumerable: true
21
21
  }) : target, mod));
22
- require("node:crypto");
22
+ //#endregion
23
23
  //#region ../types/dist/event-category-41fKf-q9.mjs
24
24
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
25
25
  EventCategory["SystemBoot"] = "system.boot";
@@ -7157,473 +7157,6 @@ function sleep(ms) {
7157
7157
  return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
7158
7158
  }
7159
7159
  //#endregion
7160
- //#region ../types/dist/fmp4-box-splitter-B53u9-Nu.mjs
7161
- var AUDIO_ENCODER_BY_CODEC = {
7162
- opus: "libopus",
7163
- aac: "aac",
7164
- pcmu: "pcm_mulaw",
7165
- pcma: "pcm_alaw"
7166
- };
7167
- /**
7168
- * Camera-microphone audio, per codec. Lives HERE rather than in
7169
- * `encode-defaults.ts` only to avoid an import cycle (`encode-defaults` depends
7170
- * on these types); it is re-exported from there, which is where to read it.
7171
- *
7172
- * Every source in this repo is a mono camera mic. The former broker preset
7173
- * encoded Opus at `channels: 2`, spending bitrate duplicating one channel —
7174
- * that is the value this consolidation changed.
7175
- */
7176
- var AUDIO_PRESETS = {
7177
- aac: {
7178
- kind: "encode",
7179
- codec: "aac",
7180
- bitrateKbps: 128,
7181
- sampleRateHz: 48e3,
7182
- channels: 1
7183
- },
7184
- opus: {
7185
- kind: "encode",
7186
- codec: "opus",
7187
- bitrateKbps: 64,
7188
- sampleRateHz: 48e3,
7189
- channels: 1
7190
- },
7191
- pcmu: {
7192
- kind: "encode",
7193
- codec: "pcmu",
7194
- sampleRateHz: 8e3,
7195
- channels: 1
7196
- }
7197
- };
7198
- /** `-hide_banner -loglevel <level>` — every ffmpeg site opens with this. */
7199
- function logBannerArgs(level) {
7200
- return [
7201
- "-hide_banner",
7202
- "-loglevel",
7203
- level
7204
- ];
7205
- }
7206
- /** `true` when the resolved value means "decode in software" (⇒ no `-hwaccel`). */
7207
- function isSoftwareDecode(decodeHwAccel) {
7208
- return !decodeHwAccel || decodeHwAccel === "none" || decodeHwAccel === "copy";
7209
- }
7210
- /**
7211
- * Every INPUT option, in order, terminated by `-i <url>`. Nothing may be
7212
- * appended to this list by a caller — that is the whole point of the function.
7213
- */
7214
- function buildInputArgs(input, decodeHwAccel) {
7215
- const args = [];
7216
- if (!isSoftwareDecode(decodeHwAccel)) args.push("-hwaccel", String(decodeHwAccel));
7217
- if (input.extraArgs?.length) args.push(...input.extraArgs);
7218
- if (input.analyzeDurationUs !== void 0) args.push("-analyzeduration", String(input.analyzeDurationUs));
7219
- if (input.probeSizeBytes !== void 0) args.push("-probesize", String(input.probeSizeBytes));
7220
- if (input.fflags?.length) for (const flag of input.fflags) args.push("-fflags", flag);
7221
- if (input.rtspTransport) args.push("-rtsp_transport", input.rtspTransport);
7222
- args.push("-i", input.url);
7223
- return args;
7224
- }
7225
- /** The `-vf` filter args, or `[]` when a consumer `-vf` already claims the slot. */
7226
- function buildVideoFilterArgs(scale, outputArgs) {
7227
- if (!scale) return [];
7228
- if (outputArgs.some((a) => a === "-vf")) return [];
7229
- if (scale.mode === "exact") return ["-vf", `scale=${scale.width}:${scale.height}`];
7230
- return ["-vf", `scale='min(${scale.width},iw)':'min(${scale.height},ih)':force_original_aspect_ratio=decrease:force_divisible_by=2`];
7231
- }
7232
- /** Rate-control args for an encode plan. */
7233
- function buildRateControlArgs(video) {
7234
- const kbps = video.bitrateKbps;
7235
- if (kbps === void 0) return [];
7236
- const rc = video.rateControl ?? {
7237
- kind: "cap",
7238
- vbvSeconds: 2
7239
- };
7240
- const bufsize = Math.max(1, Math.round(kbps * rc.vbvSeconds));
7241
- return [
7242
- ...rc.kind === "cbr" ? ["-b:v", `${kbps}k`] : [],
7243
- "-maxrate",
7244
- `${kbps}k`,
7245
- "-bufsize",
7246
- `${bufsize}k`
7247
- ];
7248
- }
7249
- /** The whole video block (`-vf` … `-c:v` … knobs), after `-i`. */
7250
- function buildVideoArgs(video, outputArgs) {
7251
- if (video.kind === "copy") return [
7252
- "-c:v",
7253
- "copy",
7254
- ...video.bitstreamFilter ? ["-bsf:v", video.bitstreamFilter] : []
7255
- ];
7256
- const args = [
7257
- ...buildVideoFilterArgs(video.scale, outputArgs),
7258
- "-c:v",
7259
- video.encoder
7260
- ];
7261
- if (video.preset !== void 0) args.push("-preset", video.preset);
7262
- if (video.tune !== void 0) args.push("-tune", video.tune);
7263
- if (video.profile !== void 0) args.push("-profile:v", video.profile);
7264
- if (video.level !== void 0) args.push("-level", video.level);
7265
- if (video.pixelFormat !== void 0) args.push("-pix_fmt", video.pixelFormat);
7266
- if (video.fps !== void 0) args.push("-r", String(video.fps));
7267
- if (video.gopFrames !== void 0) args.push("-g", String(video.gopFrames));
7268
- if (video.forceKeyFramesSeconds !== void 0) args.push("-force_key_frames", `expr:gte(t,n_forced*${video.forceKeyFramesSeconds})`);
7269
- if (video.bf !== void 0) args.push("-bf", String(video.bf));
7270
- args.push(...buildRateControlArgs(video));
7271
- if (video.bitstreamFilter !== void 0) args.push("-bsf:v", video.bitstreamFilter);
7272
- return args;
7273
- }
7274
- /** The whole audio block, after `-i`. */
7275
- function buildAudioArgs(audio) {
7276
- if (audio.kind === "none") return ["-an"];
7277
- if (audio.kind === "copy") return ["-c:a", "copy"];
7278
- const args = [];
7279
- if (audio.filter !== void 0) args.push("-af", audio.filter);
7280
- args.push("-c:a", AUDIO_ENCODER_BY_CODEC[audio.codec]);
7281
- if (audio.application !== void 0) args.push("-application", audio.application);
7282
- if (audio.frameDurationMs !== void 0) args.push("-frame_duration", String(audio.frameDurationMs));
7283
- if (audio.globalHeader === true) args.push("-flags", "+global_header");
7284
- if (audio.sampleRateHz !== void 0) args.push("-ar", String(audio.sampleRateHz));
7285
- if (audio.bitrateKbps !== void 0) args.push("-b:a", `${audio.bitrateKbps}k`);
7286
- if (audio.vbvBufferKbits !== void 0) args.push("-bufsize", `${audio.vbvBufferKbits}k`);
7287
- if (audio.channels !== void 0) args.push("-ac", String(audio.channels));
7288
- return args;
7289
- }
7290
- /** RTP output-leg args (`-payload_type`, `-ssrc`, `-sdp_file`, `-f rtp <url>`). */
7291
- function buildRtpOutputArgs(out) {
7292
- const args = [];
7293
- if (out.payloadType !== void 0) args.push("-payload_type", String(out.payloadType));
7294
- if (out.ssrc !== void 0) args.push("-ssrc", String(out.ssrc));
7295
- if (out.sdpFile !== void 0) args.push("-sdp_file", out.sdpFile);
7296
- args.push("-f", "rtp", out.url);
7297
- return args;
7298
- }
7299
- /** `true` when the sink is a raw elementary bytestream that cannot mux audio. */
7300
- function isElementaryVideoSink(sink) {
7301
- return sink.kind === "stdout" && (sink.container === "h264" || sink.container === "hevc");
7302
- }
7303
- /**
7304
- * The fragmented-MP4 muxer flags, in the order the recorder has proven them
7305
- * (`recorder/addon/ffmpeg-args.ts` passes the same `movflags` string through
7306
- * `-segment_format_options`, across every vendor in the fleet):
7307
- *
7308
- * - `frag_keyframe` — cut a fragment at each key frame, so every fragment
7309
- * opens on a sync sample. HKSV's whole requirement.
7310
- * - `empty_moov` — write `ftyp`+`moov` up front with no samples in it, which
7311
- * is what makes the head a standalone INITIALISATION segment.
7312
- * - `default_base_moof` — fragment offsets are self-relative, so a fragment is
7313
- * demuxable without the bytes that preceded it. D31's byte-range read path
7314
- * depends on exactly this property of the recorder's segments.
7315
- */
7316
- var FMP4_MOVFLAGS = "+frag_keyframe+empty_moov+default_base_moof";
7317
- /**
7318
- * The terminal sink args for every non-`rtp-outputs` sink. Exhaustive over the
7319
- * union so a new member cannot fall through to `['-f', container, 'pipe:1']`,
7320
- * which is what a plain `container` read would have done for `mp4` — a valid
7321
- * argv that writes a NON-fragmented, unseekable-to-a-pipe MP4 and produces one
7322
- * unusable byte stream.
7323
- */
7324
- function buildStdoutOrRtspSinkArgs(sink) {
7325
- if (sink.kind === "rtsp-listen") return [
7326
- "-f",
7327
- "rtsp",
7328
- "-rtsp_transport",
7329
- "tcp",
7330
- "-rtsp_flags",
7331
- "listen",
7332
- sink.url
7333
- ];
7334
- if (sink.kind === "rtp-outputs") return [];
7335
- return sink.container === "mp4" ? buildFmp4SinkArgs(sink) : [
7336
- "-f",
7337
- sink.container,
7338
- "pipe:1"
7339
- ];
7340
- }
7341
- /**
7342
- * How far BELOW the negotiated fragment length `-min_frag_duration` is set.
7343
- *
7344
- * `-min_frag_duration` refuses to cut before that much media has accumulated,
7345
- * and then waits for the next key frame. Set to exactly `fragmentMs`, the
7346
- * commonest camera configuration in existence — a key-frame grid EQUAL to the
7347
- * requested fragment length — lands the deadline on the same instant as the key
7348
- * frame, loses the race, and skips to the following one: **every fragment comes
7349
- * out at twice the requested length.**
7350
- *
7351
- * Measured on the live fleet 2026-08-07, camera 615, `-c:v copy` (D84):
7352
- *
7353
- * | slot | GOP | `-min_frag_duration` | median gap |
7354
- * | --- | --- | --- | --- |
7355
- * | 1280×720 | 40 f @ 10 fps = 4.0 s | 4000 ms | **7944 ms** |
7356
- * | 1280×720 | 40 f @ 10 fps = 4.0 s | 3600 ms | 3973 ms |
7357
- * | 3840×2160 | 100 f @ 25 fps = 4.0 s | 4000 ms | 8042 ms |
7358
- * | 3840×2160 | 100 f @ 25 fps = 4.0 s | 3600 ms | 3998 ms |
7359
- *
7360
- * A doubled fragment is not a cosmetic overshoot: HKSV requires every fragment
7361
- * to be no longer than the length the controller SELECTED, so the shipped-but-
7362
- * inert phase-1 sink would have violated the contract on its first real clip.
7363
- *
7364
- * 10 % is chosen against the two failures either side of it. Too small and
7365
- * ordinary jitter (measured spread 3953-4096 ms) re-loses the race; too large
7366
- * and a source with a key frame slightly EARLY than the grid gets cut there,
7367
- * yielding a short fragment for no reason.
7368
- */
7369
- var FMP4_MIN_FRAG_MARGIN = .9;
7370
- /** `-movflags … -min_frag_duration <us> -f mp4 pipe:1`. */
7371
- function buildFmp4SinkArgs(sink) {
7372
- return [
7373
- "-movflags",
7374
- FMP4_MOVFLAGS,
7375
- "-min_frag_duration",
7376
- String(Math.max(0, Math.round(sink.fragmentMs * FMP4_MIN_FRAG_MARGIN * 1e3))),
7377
- "-f",
7378
- "mp4",
7379
- "pipe:1"
7380
- ];
7381
- }
7382
- /**
7383
- * A second output mapping source audio to RTP-over-UDP. `0:a:0?` makes the
7384
- * audio optional so a source with no audio skips it instead of failing the
7385
- * whole invocation.
7386
- */
7387
- function buildAudioSidecarArgs(sidecar) {
7388
- return [
7389
- "-map",
7390
- "0:a:0?",
7391
- ...buildAudioArgs(sidecar.codec === "pcma" ? {
7392
- kind: "encode",
7393
- codec: "pcma",
7394
- sampleRateHz: 8e3,
7395
- channels: 1
7396
- } : AUDIO_PRESETS[sidecar.codec]),
7397
- ...buildRtpOutputArgs({
7398
- url: sidecar.rtpUrl,
7399
- sdpFile: sidecar.sdpFile
7400
- })
7401
- ];
7402
- }
7403
- /**
7404
- * Assemble the full ffmpeg argument list. Layout:
7405
- *
7406
- * -hide_banner -loglevel <level>
7407
- * [-hwaccel <backend|auto>] ─┐ INPUT options — strictly before -i.
7408
- * [<input.extraArgs>] │
7409
- * [-fflags <flag>…] │
7410
- * [-rtsp_transport tcp] │
7411
- * -i <url> ─┘
7412
- * <video block> <threads> <audio block> ─┐ OUTPUT options.
7413
- * <consumer outputArgs verbatim> │
7414
- * <sink> ─┘ terminal
7415
- */
7416
- function buildFfmpegArgs(inv) {
7417
- const head = [...logBannerArgs(inv.logLevel), ...buildInputArgs(inv.input, inv.decodeHwAccel)];
7418
- const threadArgs = inv.threadCount > 0 ? ["-threads", String(inv.threadCount)] : [];
7419
- if (inv.sink.kind === "rtp-outputs") {
7420
- const videoLeg = inv.sink.video ? [
7421
- "-an",
7422
- "-map",
7423
- "0:v:0",
7424
- ...buildVideoArgs(inv.video, inv.outputArgs),
7425
- ...threadArgs,
7426
- ...inv.outputArgs,
7427
- ...buildRtpOutputArgs(inv.sink.video)
7428
- ] : [];
7429
- const audioLeg = inv.sink.audio ? [
7430
- "-vn",
7431
- "-map",
7432
- "0:a:0?",
7433
- ...buildAudioArgs(inv.audio),
7434
- ...buildRtpOutputArgs(inv.sink.audio)
7435
- ] : [];
7436
- return [
7437
- ...head,
7438
- ...videoLeg,
7439
- ...audioLeg
7440
- ];
7441
- }
7442
- const audioArgs = isElementaryVideoSink(inv.sink) ? ["-an"] : buildAudioArgs(inv.audio);
7443
- const sinkArgs = buildStdoutOrRtspSinkArgs(inv.sink);
7444
- return [
7445
- ...head,
7446
- ...buildVideoArgs(inv.video, inv.outputArgs),
7447
- ...threadArgs,
7448
- ...audioArgs,
7449
- ...inv.outputArgs,
7450
- ...sinkArgs,
7451
- ...inv.audioSidecar ? buildAudioSidecarArgs(inv.audioSidecar) : []
7452
- ];
7453
- }
7454
- var DEFAULT_MAX_UNIT_BYTES = 16 * 1024 * 1024;
7455
- /** Header size for a normal box, and for one carrying a 64-bit `largesize`. */
7456
- var BOX_HEADER_BYTES = 8;
7457
- var LARGE_BOX_HEADER_BYTES = 16;
7458
- var Fmp4BoxSplitter = class {
7459
- maxUnitBytes;
7460
- /** Bytes of the CURRENT unit plus any partial box after it. */
7461
- buffer = new Uint8Array(0);
7462
- /** Where the current unit starts inside {@link buffer}. */
7463
- unitStart = 0;
7464
- /** Where the box scanner has reached inside {@link buffer}. */
7465
- cursor = 0;
7466
- state = "init";
7467
- nextSequence = 0;
7468
- faultReason = null;
7469
- interstitial = /* @__PURE__ */ new Set();
7470
- constructor(options = {}) {
7471
- this.maxUnitBytes = options.maxUnitBytes ?? DEFAULT_MAX_UNIT_BYTES;
7472
- }
7473
- /**
7474
- * Non-null once the stream cannot be split. The splitter emits nothing
7475
- * further, so a caller polls this to kill the child rather than watching a
7476
- * silent stall — a fragmenter that quietly stops producing looks exactly like
7477
- * a camera with no motion.
7478
- */
7479
- get fault() {
7480
- return this.faultReason;
7481
- }
7482
- /** Bytes currently held. The memory bound, observable rather than asserted. */
7483
- get pendingBytes() {
7484
- return this.buffer.length - this.unitStart;
7485
- }
7486
- /**
7487
- * Top-level box types seen BETWEEN fragments and discarded — `mfra`, `free`,
7488
- * a stray `sidx`. Reported rather than dropped in silence: they are legal and
7489
- * useless to a fragment consumer, but a type nobody expected showing up here
7490
- * is the first symptom of a muxer that is not writing what we think it is.
7491
- */
7492
- get discardedInterstitialTypes() {
7493
- return [...this.interstitial];
7494
- }
7495
- /**
7496
- * Feed bytes; get back whatever units completed. Returns `[]` once faulted.
7497
- */
7498
- push(chunk) {
7499
- if (this.faultReason !== null || chunk.length === 0) return [];
7500
- this.append(chunk);
7501
- if (this.pendingBytes > this.maxUnitBytes) return this.fail(`a single fMP4 unit exceeded ${this.maxUnitBytes} bytes — this stream is not fragmented`);
7502
- return this.drainBoxes();
7503
- }
7504
- append(chunk) {
7505
- if (this.buffer.length === 0) {
7506
- this.buffer = chunk.slice();
7507
- return;
7508
- }
7509
- const next = new Uint8Array(this.buffer.length + chunk.length);
7510
- next.set(this.buffer, 0);
7511
- next.set(chunk, this.buffer.length);
7512
- this.buffer = next;
7513
- }
7514
- /** Consume every COMPLETE top-level box now in the buffer. */
7515
- drainBoxes() {
7516
- const units = [];
7517
- for (;;) {
7518
- const header = this.readHeader();
7519
- if (this.faultReason !== null) return units;
7520
- if (header === null) break;
7521
- if (this.cursor + header.totalBytes > this.buffer.length) break;
7522
- const boxStart = this.cursor;
7523
- const boxEnd = boxStart + header.totalBytes;
7524
- this.cursor = boxEnd;
7525
- const unit = this.consumeBox(header.type, boxStart, boxEnd);
7526
- if (this.faultReason !== null) return units;
7527
- if (unit !== null) units.push(unit);
7528
- }
7529
- this.compact();
7530
- return units;
7531
- }
7532
- /**
7533
- * Apply one box to the state machine. Returns a unit when this box CLOSED
7534
- * one, `null` otherwise.
7535
- */
7536
- consumeBox(type, boxStart, boxEnd) {
7537
- if (this.state === "init") {
7538
- if (type !== "moof") return null;
7539
- if (boxStart === this.unitStart) {
7540
- this.fail("a moof arrived before any initialisation box — there is no ftyp/moov to send");
7541
- return null;
7542
- }
7543
- const init = this.emit("init", this.unitStart, boxStart);
7544
- this.unitStart = boxStart;
7545
- this.state = "fragment";
7546
- return init;
7547
- }
7548
- if (this.state === "idle") {
7549
- if (type !== "moof") {
7550
- this.interstitial.add(type);
7551
- this.unitStart = boxEnd;
7552
- return null;
7553
- }
7554
- this.unitStart = boxStart;
7555
- this.state = "fragment";
7556
- return null;
7557
- }
7558
- if (type !== "mdat") return null;
7559
- const fragment = this.emit("fragment", this.unitStart, boxEnd);
7560
- this.unitStart = boxEnd;
7561
- this.state = "idle";
7562
- return fragment;
7563
- }
7564
- /**
7565
- * Parse the header at {@link cursor}, or `null` when too few bytes have
7566
- * arrived to know. Faults on a size the splitter cannot honour.
7567
- */
7568
- readHeader() {
7569
- const available = this.buffer.length - this.cursor;
7570
- if (available < BOX_HEADER_BYTES) return null;
7571
- const view = new DataView(this.buffer.buffer, this.buffer.byteOffset, this.buffer.byteLength);
7572
- const size = view.getUint32(this.cursor);
7573
- const type = String.fromCharCode(this.buffer[this.cursor + 4] ?? 0, this.buffer[this.cursor + 5] ?? 0, this.buffer[this.cursor + 6] ?? 0, this.buffer[this.cursor + 7] ?? 0);
7574
- if (size === 0) {
7575
- this.fail(`box "${type}" declares size 0 (to EOF) — an unbounded box cannot be fragmented`);
7576
- return null;
7577
- }
7578
- if (size === 1) {
7579
- if (available < LARGE_BOX_HEADER_BYTES) return null;
7580
- const large = view.getBigUint64(this.cursor + BOX_HEADER_BYTES);
7581
- if (large > BigInt(this.maxUnitBytes)) {
7582
- this.fail(`box "${type}" declares ${large} bytes, over the ${this.maxUnitBytes} byte bound`);
7583
- return null;
7584
- }
7585
- return {
7586
- type,
7587
- totalBytes: Number(large)
7588
- };
7589
- }
7590
- if (size < BOX_HEADER_BYTES) {
7591
- this.fail(`box "${type}" declares an impossible size of ${size} bytes`);
7592
- return null;
7593
- }
7594
- return {
7595
- type,
7596
- totalBytes: size
7597
- };
7598
- }
7599
- emit(kind, start, end) {
7600
- const sequence = this.nextSequence;
7601
- this.nextSequence += 1;
7602
- return {
7603
- kind,
7604
- data: this.buffer.slice(start, end),
7605
- sequence
7606
- };
7607
- }
7608
- /**
7609
- * Drop everything already emitted or discarded. Without this the buffer is
7610
- * the whole stream and the process dies in hours, not minutes.
7611
- */
7612
- compact() {
7613
- if (this.unitStart === 0) return;
7614
- this.buffer = this.buffer.slice(this.unitStart);
7615
- this.cursor -= this.unitStart;
7616
- this.unitStart = 0;
7617
- }
7618
- fail(reason) {
7619
- this.faultReason = reason;
7620
- this.buffer = new Uint8Array(0);
7621
- this.unitStart = 0;
7622
- this.cursor = 0;
7623
- return [];
7624
- }
7625
- };
7626
- //#endregion
7627
7160
  //#region ../types/dist/err-msg-IQTHeDzc.mjs
7628
7161
  /**
7629
7162
  import { errMsg } from '@camstack/types'
@@ -9645,6 +9178,69 @@ var StreamFormatSchema = _enum([
9645
9178
  "mjpeg",
9646
9179
  "rtsp"
9647
9180
  ]);
9181
+ /** A container `produceEventMedia` can emit. */
9182
+ var EventMediaKindSchema = _enum(["mp4", "gif"]);
9183
+ /**
9184
+ * One produced artifact, referenced by HANDLE.
9185
+ *
9186
+ * Never inline bytes: a produced clip is 200 KB–5 MB and every consumer of this
9187
+ * method is in another runner ([D9](../../../../docs/decisions/adr-0009.md),
9188
+ * [D18](../../../../docs/decisions/adr-0018.md) — cross-process media is fetched
9189
+ * on demand, compressed, by handle). `bytes` is here so a caller can decide
9190
+ * whether it wants the fetch at all.
9191
+ */
9192
+ var EventMediaArtifactSchema = object({
9193
+ kind: EventMediaKindSchema,
9194
+ /** Opaque, single-camera, short-lived. Redeem with `fetchEventMedia`. */
9195
+ handle: string(),
9196
+ /**
9197
+ * The node holding the bytes — the ROUTING key for `fetchEventMedia`.
9198
+ *
9199
+ * `stream-broker` is a singleton cap and an unpinned call never leaves the
9200
+ * hub, so a handle produced on an agent's broker would be redeemed against
9201
+ * the hub's store and come back `null`. Same contract, same field name and
9202
+ * the same reason as `FrameHandleSchema.nodeId`: the producer stamps where it
9203
+ * lives and the consumer pins to it.
9204
+ */
9205
+ nodeId: string(),
9206
+ mime: string(),
9207
+ bytes: number().int(),
9208
+ width: number().int(),
9209
+ height: number().int()
9210
+ });
9211
+ /**
9212
+ * What a production actually covered — the answer to the only question an
9213
+ * operator asks about a notification clip.
9214
+ *
9215
+ * `fromTs`/`toTs` are WALL CLOCK, derived from the ring's own packet timeline,
9216
+ * so a caller can state "this clip starts 4.1 s before the event" instead of
9217
+ * inferring it from a duration. A production whose `fromTs` is later than the
9218
+ * event is a production with no pre-roll, and that is exactly the defect this
9219
+ * method exists to make visible rather than plausible.
9220
+ */
9221
+ var EventMediaCoverageSchema = object({
9222
+ fromTs: number(),
9223
+ toTs: number(),
9224
+ /** Encoded packets in the muxed window. */
9225
+ packets: number().int()
9226
+ });
9227
+ /**
9228
+ * The result of ONE cut, in every container the caller asked for.
9229
+ *
9230
+ * Every artifact in `media` came out of the SAME window of the SAME rendition —
9231
+ * that is the whole reason this is one method rather than one call per format.
9232
+ * A consumer attaching a gif and a video can no longer show two different
9233
+ * moments, because it never chose two sources.
9234
+ */
9235
+ var EventMediaProductionSchema = object({
9236
+ media: array(EventMediaArtifactSchema).readonly(),
9237
+ coverage: EventMediaCoverageSchema,
9238
+ /** The rendition actually cut from — what the default or the fallback chose. */
9239
+ profile: CamProfileSchema,
9240
+ /** `copy` = the camera's own H.264, untouched. `encode` = re-encoded (H.265
9241
+ * source, a downscale, or a playback rate other than 1). */
9242
+ video: _enum(["copy", "encode"])
9243
+ });
9648
9244
  var RtspRestreamEntrySchema = object({
9649
9245
  brokerId: string(),
9650
9246
  url: string(),
@@ -10044,6 +9640,56 @@ method(object({
10044
9640
  }), {
10045
9641
  kind: "mutation",
10046
9642
  auth: "admin"
9643
+ }), method(object({
9644
+ deviceId: number(),
9645
+ /** Absent = the largest H.264 rendition at or below 1080p, which is
9646
+ * also the one that can be copied. Falls back to whatever the ring
9647
+ * actually retained, and the answer says which. */
9648
+ profile: CamProfileSchema.optional(),
9649
+ aroundMs: number(),
9650
+ preSeconds: number().min(0).max(20).default(4),
9651
+ postSeconds: number().min(0).max(20).default(6),
9652
+ kinds: array(EventMediaKindSchema).min(1).default(["mp4"]),
9653
+ /** GIF geometry. The video keeps the source's own. */
9654
+ gifMaxWidth: number().int().min(120).max(1280).default(640),
9655
+ /**
9656
+ * The gif's own PLAYBACK rate in frames per second — what the finished
9657
+ * gif runs at, not how many source frames feed it. The decimation that
9658
+ * feeds it samples `gifFps / gifSpeed` source frames per second, so at
9659
+ * the defaults a 12 fps gif is built out of 3 source frames a second.
9660
+ */
9661
+ gifFps: number().int().min(1).max(15).default(12),
9662
+ /**
9663
+ * How fast the GIF plays against real time, independent of `speed`.
9664
+ *
9665
+ * 4× by default, by operator request: a notification gif is glanced at
9666
+ * on a lock screen, so a ~12 s window has to be over in ~3 s. It stays
9667
+ * a separate knob from `speed` even though both now default to 4 —
9668
+ * a caller wanting a real-time video and a fast gif must not have to
9669
+ * choose.
9670
+ */
9671
+ gifSpeed: number().min(1).max(8).default(4),
9672
+ /**
9673
+ * Playback rate of the VIDEO. Also 4× by default, by operator decision.
9674
+ *
9675
+ * `1` is real time and is the ONLY value that allows the copy branch —
9676
+ * anything else forces `libx264` over the window. That was priced
9677
+ * before it was chosen: a per-event burst measured at 0.23 s and 254 KB
9678
+ * on a real 615 720p cut, against 922 KB for the copy it replaces. A
9679
+ * re-encode is capped at 720p (`EVENT_CLIP_ENCODE_MAX_WIDTH`), because
9680
+ * once the decode is forced the width stops being free.
9681
+ */
9682
+ speed: number().min(1).max(8).default(4)
9683
+ }), EventMediaProductionSchema, {
9684
+ kind: "mutation",
9685
+ auth: "admin"
9686
+ }), method(object({ handle: string() }), object({
9687
+ base64: string(),
9688
+ mime: string(),
9689
+ bytes: number().int()
9690
+ }).nullable(), {
9691
+ kind: "mutation",
9692
+ auth: "admin"
10047
9693
  }), method(_void(), array(CameraStreamSchema).readonly()), method(_void(), array(ProfileSlotSchema).readonly()), method(object({ brokerId: string() }), BrokerStatsSchema), method(object({ brokerId: string() }), object({
10048
9694
  probed: boolean(),
10049
9695
  summary: string()
@@ -10335,25 +9981,6 @@ var cameraStreamsCapability = {
10335
9981
  function kebabToCamel(s) {
10336
9982
  return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
10337
9983
  }
10338
- /**
10339
- * core-blocks — user-authored TypeScript, stored in the kernel and executed in
10340
- * its own process.
10341
- *
10342
- * Spec: `docs/superpowers/specs/2026-08-04-core-blocks-and-synthetic-devices-design.md`.
10343
- *
10344
- * The first use is **owning devices without being a device provider**: a block
10345
- * declares devices under a system or custom integration and drives their state,
10346
- * with the same `ctx` an addon gets. Automations come later; nothing here
10347
- * models a trigger.
10348
- *
10349
- * **Stated plainly, because it does not change by being true:** a block has an
10350
- * addon's powers — devices, storage, the event bus, `ctx.api`. It is a plugin
10351
- * with no review step. What makes that survivable is not a sandbox, it is
10352
- * PROCESS ISOLATION: one process per block, supervised by `CrashSupervisor`,
10353
- * so a block that throws or never returns is marked `failed` and visible
10354
- * instead of taking the hub with it (D6). Every method here is admin-only, and
10355
- * must stay so.
10356
- */
10357
9984
  /** Where a block runs. The operator chooses — a block driving a device on an
10358
9985
  * agent is the reason placement is not fixed to the hub. */
10359
9986
  var CoreBlockPlacementSchema = union([literal("hub"), string().min(1)]);
@@ -10425,6 +10052,9 @@ method(object({}), object({ blocks: array(CoreBlockSchema) }), { auth: "admin" }
10425
10052
  }), object({ block: CoreBlockSchema }), {
10426
10053
  kind: "mutation",
10427
10054
  auth: "admin"
10055
+ }), method(object({ blockId: string() }), object({ block: CoreBlockSchema }), {
10056
+ kind: "mutation",
10057
+ auth: "admin"
10428
10058
  }), method(object({ code: string() }), CoreBlockCompileResultSchema, {
10429
10059
  kind: "mutation",
10430
10060
  auth: "admin"
@@ -11225,840 +10855,159 @@ var ExposeInputSchema = object({
11225
10855
  });
11226
10856
  var UnexposeInputSchema = object({ deviceId: string() });
11227
10857
  method(_void(), DeviceExportStatusSchema), method(_void(), array(DeviceKindSchema)), method(_void(), array(ExposedDeviceSchema)), method(ExposeInputSchema, _void(), { kind: "mutation" }), method(UnexposeInputSchema, _void(), { kind: "mutation" });
10858
+ var ProviderStatusSchema = object({
10859
+ connected: boolean(),
10860
+ deviceCount: number(),
10861
+ error: string().optional()
10862
+ });
10863
+ object({
10864
+ externalId: string(),
10865
+ name: string(),
10866
+ type: string(),
10867
+ metadata: record(string(), unknown()).optional()
10868
+ });
11228
10869
  /**
11229
- * Resource-bound constants for the safe expression engine.
11230
- *
11231
- * Every bound is defense-in-depth: the grammar is non-Turing-complete (no
11232
- * loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
11233
- * O(nodeCount) by construction. These caps merely put a hard ceiling on the
11234
- * work a single author-supplied expression can request, so a hostile or
11235
- * accidental pathological string can never spend unbounded CPU/memory.
10870
+ * Candidate handed back from discovery and accepted by
10871
+ * `adoptDiscoveredDevice`. Shape mirrors the in-process
10872
+ * `DiscoveredDevice` interface used by `DeviceDiscovery`.
11236
10873
  */
11237
- /** Max source length (chars) — checked BEFORE tokenizing so a huge string is
11238
- * rejected without allocation. */
11239
- var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
11240
- /** A legal binding / identifier name. */
11241
- var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
11242
- /** Binding names an author may NOT use: `now` is auto-injected; the literal
11243
- * keywords lex as values, not identifiers, so binding to them is meaningless. */
11244
- var RESERVED_BINDING_NAMES = new Set([
11245
- "now",
11246
- "true",
11247
- "false",
11248
- "null"
11249
- ]);
10874
+ var DiscoveryCandidateSchema = object({
10875
+ stableId: string(),
10876
+ type: _enum(DeviceType),
10877
+ suggestedName: string(),
10878
+ prefilledConfig: record(string(), unknown()),
10879
+ /**
10880
+ * Optional upstream-system identity (HA entity_id, vendor MAC, …).
10881
+ * Discovery pre-populates this for systems that know the upstream
10882
+ * identity ahead of adoption. Rendering metadata (unit, precision)
10883
+ * flows live through the cap STATUS SLICE after adoption.
10884
+ */
10885
+ sourceInfo: SourceInfoSchema.optional()
10886
+ });
11250
10887
  /**
11251
- * Error types for the safe expression engine. Two distinct classes so callers
11252
- * can tell a compile-time (grammar) failure from a runtime (evaluation)
11253
- * failure — both are non-fatal to the host: read paths degrade to "skip link".
10888
+ * Flat device summary returned by `createDevice` / `adoptDiscoveredDevice`.
10889
+ * Mirrors `toDeviceShape()` output in `device-management.router.ts` so the
10890
+ * tRPC layer can pass it through without reshaping.
11254
10891
  */
11255
- /** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
11256
- * the failure is anchored to a character (author-facing inline feedback). */
11257
- var ExpressionParseError = class extends Error {
11258
- position;
11259
- constructor(message, position) {
11260
- super(message);
11261
- this.name = "ExpressionParseError";
11262
- this.position = position;
11263
- }
11264
- };
11265
- /** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
11266
- * result, unknown builtin, step-budget exceeded). */
11267
- var ExpressionEvalError = class extends Error {
11268
- constructor(message) {
11269
- super(message);
11270
- this.name = "ExpressionEvalError";
11271
- }
11272
- };
10892
+ var DeviceSummarySchema = object({
10893
+ id: number(),
10894
+ stableId: string(),
10895
+ addonId: string(),
10896
+ type: string(),
10897
+ name: string(),
10898
+ parentDeviceId: number().nullable(),
10899
+ online: boolean(),
10900
+ features: array(string()),
10901
+ config: record(string(), unknown()),
10902
+ /** Optional upstream-system identity (dispatch key + system tag).
10903
+ * See `SourceInfo`. Present when the device has a non-synthetic
10904
+ * source identifier (HA entities, vendor MAC, …); omitted when the
10905
+ * synthetic backfill is in effect. */
10906
+ sourceInfo: SourceInfoSchema.optional()
10907
+ });
11273
10908
  /**
11274
- * Frozen, null-prototype builtin function table for the expression engine
11275
- * (spec §4 rule 4). The table is the SOLE surface of callable functions: the
11276
- * parser rejects any callee not in it, and the evaluator gates each call on an
11277
- * own-property check against it.
10909
+ * Result of a live field test (e.g. probing an RTSP URL during device
10910
+ * creation). Matches the UI-side `FieldProbeResult` in
10911
+ * `interfaces/config-ui.ts` — the admin `FormBuilder` renders the
10912
+ * returned `labels` as chips next to the input.
10913
+ */
10914
+ var FieldProbeResultSchema = object({
10915
+ status: _enum(["ok", "error"]),
10916
+ labels: array(string()).optional(),
10917
+ error: string().optional()
10918
+ });
10919
+ /**
10920
+ * The output of `getChildCreationSchema` is a UI schema tree. We store
10921
+ * it as `unknown` at the capability layer — the router just passes it
10922
+ * through and the admin UI renders it via `FormBuilder`. The actual
10923
+ * type is `ConfigUISchema` (see `packages/types/src/interfaces/config-ui.ts`),
10924
+ * but we deliberately avoid a Zod mirror because the union is large and
10925
+ * not meant for runtime validation at this seam.
10926
+ */
10927
+ var CreationSchemaOutputSchema = unknown();
10928
+ method(_void(), _void(), { kind: "mutation" }), method(_void(), _void(), { kind: "mutation" }), method(_void(), ProviderStatusSchema), method(_void(), array(object({
10929
+ id: string(),
10930
+ name: string(),
10931
+ type: string()
10932
+ }))), method(object({}), boolean()), method(object({ params: record(string(), unknown()).optional() }), array(DiscoveryCandidateSchema), {
10933
+ kind: "mutation",
10934
+ auth: "admin"
10935
+ }), method(object({}), CreationSchemaOutputSchema), method(object({}), object({ deviceType: _enum(DeviceType).nullable() })), method(object({ candidate: DiscoveryCandidateSchema }), DeviceSummarySchema, {
10936
+ kind: "mutation",
10937
+ auth: "admin"
10938
+ }), method(object({}), boolean()), method(object({ type: _enum(DeviceType) }), CreationSchemaOutputSchema), method(object({
10939
+ type: _enum(DeviceType),
10940
+ config: record(string(), unknown())
10941
+ }), DeviceSummarySchema, {
10942
+ kind: "mutation",
10943
+ auth: "admin"
10944
+ }), method(object({
10945
+ type: _enum(DeviceType),
10946
+ key: string(),
10947
+ value: unknown(),
10948
+ formValues: record(string(), unknown()).optional()
10949
+ }), FieldProbeResultSchema, {
10950
+ kind: "mutation",
10951
+ auth: "admin"
10952
+ });
10953
+ /**
10954
+ * Device Manager capability — hub-side singleton that unifies device persistence,
10955
+ * live registry access, and all management operations into a single tRPC surface.
11278
10956
  *
11279
- * Because the object has a NULL prototype AND is `Object.freeze`d:
11280
- * - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
11281
- * - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
11282
- * (there is no `Object.prototype` in the chain), so those names are not
11283
- * callable — they are simply "unknown function" at parse time.
10957
+ * Replaces:
10958
+ * - `device-persistence` capability (persistence methods absorbed here)
10959
+ * - `device-management.router.ts` (deleted in Phase 2)
10960
+ * - `device-ops.router.ts` (compat layer — deleted; device-provider ops absorbed here)
11284
10961
  *
11285
- * Every numeric argument is validated as a finite number and every numeric
11286
- * RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
11287
- * (`pow(10,400)` → Infinity) all raise `ExpressionEvalError` and fail the link
11288
- * closed rather than emitting a garbage value.
10962
+ * All device provider addons (rtsp, onvif, frigate, …) are hub-local: they may
10963
+ * fork into separate processes but never run on remote cluster agents. Therefore:
10964
+ * - No nodeId routing needed — this is a pure hub singleton.
10965
+ * - The hub's DeviceRegistry is the single source of truth for all live devices.
10966
+ * - No shadow registry or cross-node aggregation required.
10967
+ *
10968
+ * Forked workers register devices back to the hub via `ctx.devices`
10969
+ * (DeviceManagerApi → ctx.api.deviceManager.registerDevice), same as today.
11289
10970
  */
11290
- function asFiniteNumber(value, name, index) {
11291
- if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
11292
- return value;
11293
- }
11294
- function asString$1(value, name, index) {
11295
- if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
11296
- return value;
11297
- }
11298
- function finiteResult(value, name) {
11299
- if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
11300
- return value;
11301
- }
11302
- function allFiniteNumbers(args, name) {
11303
- return args.map((a, idx) => asFiniteNumber(a, name, idx));
11304
- }
11305
- var INF = Number.POSITIVE_INFINITY;
11306
- var table = {
11307
- min: {
11308
- minArgs: 1,
11309
- maxArgs: INF,
11310
- apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
11311
- },
11312
- max: {
11313
- minArgs: 1,
11314
- maxArgs: INF,
11315
- apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
11316
- },
11317
- abs: {
11318
- minArgs: 1,
11319
- maxArgs: 1,
11320
- apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
11321
- },
11322
- floor: {
11323
- minArgs: 1,
11324
- maxArgs: 1,
11325
- apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
11326
- },
11327
- ceil: {
11328
- minArgs: 1,
11329
- maxArgs: 1,
11330
- apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
11331
- },
11332
- sqrt: {
11333
- minArgs: 1,
11334
- maxArgs: 1,
11335
- apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
11336
- },
11337
- round: {
11338
- minArgs: 1,
11339
- maxArgs: 2,
11340
- apply: (args) => {
11341
- const x = asFiniteNumber(args[0], "round", 0);
11342
- const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
11343
- if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
11344
- const factor = 10 ** digits;
11345
- return finiteResult(Math.round(x * factor) / factor, "round");
11346
- }
11347
- },
11348
- pow: {
11349
- minArgs: 2,
11350
- maxArgs: 2,
11351
- apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
11352
- },
11353
- clamp: {
11354
- minArgs: 3,
11355
- maxArgs: 3,
11356
- apply: (args) => {
11357
- const x = asFiniteNumber(args[0], "clamp", 0);
11358
- const lo = asFiniteNumber(args[1], "clamp", 1);
11359
- const hi = asFiniteNumber(args[2], "clamp", 2);
11360
- if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
11361
- return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
11362
- }
11363
- },
11364
- avg: {
11365
- minArgs: 1,
11366
- maxArgs: INF,
11367
- apply: (args) => {
11368
- const nums = allFiniteNumbers(args, "avg");
11369
- return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
11370
- }
11371
- },
11372
- sum: {
11373
- minArgs: 1,
11374
- maxArgs: INF,
11375
- apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
11376
- },
11377
- coalesce: {
11378
- minArgs: 1,
11379
- maxArgs: INF,
11380
- apply: (args) => {
11381
- for (const a of args) if (a !== null) return a;
11382
- return null;
11383
- }
11384
- },
11385
- age: {
11386
- minArgs: 2,
11387
- maxArgs: 2,
11388
- apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
11389
- },
11390
- convert: {
11391
- minArgs: 3,
11392
- maxArgs: 3,
11393
- apply: (args, hooks) => {
11394
- const x = asFiniteNumber(args[0], "convert", 0);
11395
- const from = asString$1(args[1], "convert", 1).trim();
11396
- const to = asString$1(args[2], "convert", 2).trim();
11397
- if (hooks.convert) {
11398
- const out = hooks.convert(x, from, to);
11399
- if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
11400
- return finiteResult(out, "convert");
11401
- }
11402
- if (from === to) return x;
11403
- throw new ExpressionEvalError("convert: unit conversion table not installed");
11404
- }
11405
- }
11406
- };
11407
- Object.freeze(Object.assign(Object.create(null), table));
11408
- /** The set of valid builtin names — used by the parser to reject unknown
11409
- * callees at parse time (immediate author feedback). */
11410
- var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
10971
+ /** One child-placement directive on a container's `childLayout`. Structurally
10972
+ * identical to `ChildLayoutEntry` in `device-management.ts` — the cap wire
10973
+ * shape for the same field. The child is identified by its re-sync-stable
10974
+ * accessory `stableIdSuffix` (`childKey`); listed children are grouped into
10975
+ * named accordion sections (with optional intra-section order). */
10976
+ var ChildLayoutEntrySchema = object({
10977
+ childKey: string(),
10978
+ section: string(),
10979
+ order: number().optional(),
10980
+ collapsed: boolean().optional()
10981
+ });
10982
+ /** Cap-wire shape of a per-cap display refinement — mirrors
10983
+ * `DeviceCapDisplayOverride` in `device-management.ts`. */
10984
+ var DeviceCapDisplayOverrideSchema = object({
10985
+ unit: string().min(1).optional(),
10986
+ precision: number().int().min(0).max(10).optional()
10987
+ });
10988
+ /** Cap-wire shape of an operator-authored per-device display override —
10989
+ * mirrors `DeviceDisplayOverride` in `device-management.ts`. `precision`
10990
+ * bounds mirror `numeric-sensor.cap.ts` (`int 0-10`). */
10991
+ var DeviceDisplayOverrideSchema = object({
10992
+ icon: string().min(1).optional(),
10993
+ label: string().min(1).optional(),
10994
+ unit: string().min(1).optional(),
10995
+ precision: number().int().min(0).max(10).optional(),
10996
+ hidden: boolean().optional(),
10997
+ perCap: record(string(), DeviceCapDisplayOverrideSchema).optional()
10998
+ });
10999
+ /** Cap-wire shape of a per-role display default — mirrors `RoleDisplayDefault`
11000
+ * in `device-management.ts`. Keyed by `DeviceRole` string (role strings cross
11001
+ * the wire as plain strings everywhere else — cf. `DeviceInfoSchema.role`). */
11002
+ var RoleDisplayDefaultSchema = object({
11003
+ unit: string().min(1).optional(),
11004
+ precision: number().int().min(0).max(10).optional(),
11005
+ icon: string().min(1).optional()
11006
+ });
11411
11007
  /**
11412
- * Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
11413
- * zero-dependency. The grammar is deliberately boring: decimal numbers,
11414
- * single/double-quoted strings with a tiny escape set, identifiers, the three
11415
- * value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
11416
- * outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
11417
- * is a parse error with a source position, so member access / assignment /
11418
- * template literals are lexically impossible.
11419
- */
11420
- var KEYWORDS = new Set([
11421
- "true",
11422
- "false",
11423
- "null"
11424
- ]);
11425
- function isDigit(ch) {
11426
- return ch >= "0" && ch <= "9";
11427
- }
11428
- function isIdentStart(ch) {
11429
- return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
11430
- }
11431
- function isIdentPart(ch) {
11432
- return isIdentStart(ch) || isDigit(ch);
11433
- }
11434
- function isWhitespace(ch) {
11435
- return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
11436
- }
11437
- /** Tokenize `source` into a flat token list ending with a single `eof` token.
11438
- * Throws `ExpressionParseError` on any illegal character or unterminated
11439
- * string. */
11440
- function tokenize(source) {
11441
- if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
11442
- const tokens = [];
11443
- let i = 0;
11444
- const n = source.length;
11445
- while (i < n) {
11446
- const ch = source[i];
11447
- if (isWhitespace(ch)) {
11448
- i += 1;
11449
- continue;
11450
- }
11451
- if (isDigit(ch)) {
11452
- const start = i;
11453
- while (i < n && isDigit(source[i])) i += 1;
11454
- if (i < n && source[i] === ".") {
11455
- if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
11456
- i += 1;
11457
- while (i < n && isDigit(source[i])) i += 1;
11458
- }
11459
- const text = source.slice(start, i);
11460
- const value = Number(text);
11461
- if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
11462
- tokens.push({
11463
- type: "number",
11464
- value,
11465
- pos: start
11466
- });
11467
- continue;
11468
- }
11469
- if (ch === "'" || ch === "\"") {
11470
- const quote = ch;
11471
- const start = i;
11472
- i += 1;
11473
- let out = "";
11474
- let closed = false;
11475
- while (i < n) {
11476
- const c = source[i];
11477
- if (c === "\\") {
11478
- const next = i + 1 < n ? source[i + 1] : "";
11479
- if (next === "\\" || next === "'" || next === "\"") {
11480
- out += next;
11481
- i += 2;
11482
- continue;
11483
- }
11484
- throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
11485
- }
11486
- if (c === quote) {
11487
- closed = true;
11488
- i += 1;
11489
- break;
11490
- }
11491
- out += c;
11492
- i += 1;
11493
- }
11494
- if (!closed) throw new ExpressionParseError("unterminated string literal", start);
11495
- tokens.push({
11496
- type: "string",
11497
- value: out,
11498
- pos: start
11499
- });
11500
- continue;
11501
- }
11502
- if (isIdentStart(ch)) {
11503
- const start = i;
11504
- while (i < n && isIdentPart(source[i])) i += 1;
11505
- const text = source.slice(start, i);
11506
- if (KEYWORDS.has(text)) tokens.push({
11507
- type: "keyword",
11508
- keyword: keywordOf(text),
11509
- pos: start
11510
- });
11511
- else tokens.push({
11512
- type: "identifier",
11513
- name: text,
11514
- pos: start
11515
- });
11516
- continue;
11517
- }
11518
- const two = i + 1 < n ? source.slice(i, i + 2) : "";
11519
- if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
11520
- tokens.push({
11521
- type: "punct",
11522
- punct: two,
11523
- pos: i
11524
- });
11525
- i += 2;
11526
- continue;
11527
- }
11528
- if (isSinglePunct(ch)) {
11529
- tokens.push({
11530
- type: "punct",
11531
- punct: ch,
11532
- pos: i
11533
- });
11534
- i += 1;
11535
- continue;
11536
- }
11537
- throw new ExpressionParseError(`unexpected character '${ch}'`, i);
11538
- }
11539
- tokens.push({
11540
- type: "eof",
11541
- pos: n
11542
- });
11543
- return tokens;
11544
- }
11545
- function keywordOf(text) {
11546
- if (text === "true") return "true";
11547
- if (text === "false") return "false";
11548
- return "null";
11549
- }
11550
- function isSinglePunct(ch) {
11551
- return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
11552
- }
11553
- /**
11554
- * Pratt (precedence-climbing) parser for the safe expression mini-language.
11555
- *
11556
- * Precedence (low → high): ternary `?:` (right-assoc) → `||` → `&&` → equality
11557
- * → relational → additive → multiplicative → unary `! -` → call / primary.
11558
- * Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
11559
- * string validated against the builtin table at parse time, so an unknown
11560
- * function is rejected immediately (author feedback) and a persisted expression
11561
- * that references a since-removed builtin degrades at read.
11562
- *
11563
- * A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
11564
- * arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
11565
- */
11566
- /** Binary/logical operator precedence (higher binds tighter). */
11567
- var BINARY_PRECEDENCE = {
11568
- "||": 1,
11569
- "&&": 2,
11570
- "==": 3,
11571
- "!=": 3,
11572
- "<": 4,
11573
- "<=": 4,
11574
- ">": 4,
11575
- ">=": 4,
11576
- "+": 5,
11577
- "-": 5,
11578
- "*": 6,
11579
- "/": 6,
11580
- "%": 6
11581
- };
11582
- function isLogicalOp(op) {
11583
- return op === "&&" || op === "||";
11584
- }
11585
- function isBinaryOp(op) {
11586
- return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
11587
- }
11588
- var Parser = class {
11589
- tokens;
11590
- pos = 0;
11591
- nodeCount = 0;
11592
- identifiers = /* @__PURE__ */ new Set();
11593
- callees = /* @__PURE__ */ new Set();
11594
- constructor(tokens) {
11595
- this.tokens = tokens;
11596
- }
11597
- parse() {
11598
- const ast = this.parseTernary();
11599
- const tok = this.peek();
11600
- if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
11601
- return {
11602
- ast,
11603
- identifiers: this.identifiers,
11604
- callees: this.callees,
11605
- nodeCount: this.nodeCount
11606
- };
11607
- }
11608
- peek() {
11609
- return this.tokens[this.pos];
11610
- }
11611
- next() {
11612
- return this.tokens[this.pos++];
11613
- }
11614
- /** Consume a punctuator token, erroring if the next token isn't it. */
11615
- expectPunct(punct) {
11616
- const tok = this.peek();
11617
- if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
11618
- this.pos += 1;
11619
- }
11620
- matchPunct(punct) {
11621
- const tok = this.peek();
11622
- if (tok.type === "punct" && tok.punct === punct) {
11623
- this.pos += 1;
11624
- return true;
11625
- }
11626
- return false;
11627
- }
11628
- countNode() {
11629
- this.nodeCount += 1;
11630
- if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
11631
- }
11632
- parseTernary() {
11633
- const test = this.parseBinary(1);
11634
- if (this.matchPunct("?")) {
11635
- const consequent = this.parseTernary();
11636
- this.expectPunct(":");
11637
- const alternate = this.parseTernary();
11638
- this.countNode();
11639
- return {
11640
- kind: "conditional",
11641
- test,
11642
- consequent,
11643
- alternate
11644
- };
11645
- }
11646
- return test;
11647
- }
11648
- parseBinary(minPrec) {
11649
- let left = this.parseUnary();
11650
- for (;;) {
11651
- const tok = this.peek();
11652
- if (tok.type !== "punct") break;
11653
- const prec = BINARY_PRECEDENCE[tok.punct];
11654
- if (prec === void 0 || prec < minPrec) break;
11655
- const op = tok.punct;
11656
- this.pos += 1;
11657
- const right = this.parseBinary(prec + 1);
11658
- this.countNode();
11659
- if (isLogicalOp(op)) left = {
11660
- kind: "logical",
11661
- op,
11662
- left,
11663
- right
11664
- };
11665
- else if (isBinaryOp(op)) left = {
11666
- kind: "binary",
11667
- op,
11668
- left,
11669
- right
11670
- };
11671
- else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
11672
- }
11673
- return left;
11674
- }
11675
- parseUnary() {
11676
- const tok = this.peek();
11677
- if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
11678
- const op = tok.punct;
11679
- this.pos += 1;
11680
- const operand = this.parseUnary();
11681
- this.countNode();
11682
- return {
11683
- kind: "unary",
11684
- op,
11685
- operand
11686
- };
11687
- }
11688
- return this.parsePrimary();
11689
- }
11690
- parsePrimary() {
11691
- const tok = this.next();
11692
- switch (tok.type) {
11693
- case "number":
11694
- this.countNode();
11695
- return {
11696
- kind: "literal",
11697
- value: tok.value
11698
- };
11699
- case "string":
11700
- this.countNode();
11701
- return {
11702
- kind: "literal",
11703
- value: tok.value
11704
- };
11705
- case "keyword":
11706
- this.countNode();
11707
- return {
11708
- kind: "literal",
11709
- value: tok.keyword === "null" ? null : tok.keyword === "true"
11710
- };
11711
- case "identifier": {
11712
- const nextTok = this.peek();
11713
- if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
11714
- this.identifiers.add(tok.name);
11715
- this.countNode();
11716
- return {
11717
- kind: "identifier",
11718
- name: tok.name
11719
- };
11720
- }
11721
- case "punct":
11722
- if (tok.punct === "(") {
11723
- const inner = this.parseTernary();
11724
- this.expectPunct(")");
11725
- return inner;
11726
- }
11727
- throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
11728
- case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
11729
- }
11730
- }
11731
- parseCall(callee, pos) {
11732
- if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
11733
- this.expectPunct("(");
11734
- const args = [];
11735
- if (!this.matchPunct(")")) for (;;) {
11736
- args.push(this.parseTernary());
11737
- if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
11738
- if (this.matchPunct(",")) continue;
11739
- this.expectPunct(")");
11740
- break;
11741
- }
11742
- this.callees.add(callee);
11743
- this.countNode();
11744
- return {
11745
- kind: "call",
11746
- callee,
11747
- args
11748
- };
11749
- }
11750
- };
11751
- /** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
11752
- * `ExpressionParseError` on any lexical or grammatical failure. */
11753
- function parseExpression(source) {
11754
- return new Parser(tokenize(source)).parse();
11755
- }
11756
- /**
11757
- * LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
11758
- * by expr"). The cache stores BOTH successes and failures (negative caching),
11759
- * so a corrupt persisted string costs exactly one tokenize+parse total — not
11760
- * one per read on a hot resolve path.
11761
- *
11762
- * The cache is a module-level singleton: entries are pure, content-addressed
11763
- * ASTs keyed by the raw source string, so sharing one instance across all
11764
- * callers is safe and maximises hit rate.
11765
- */
11766
- var cache = /* @__PURE__ */ new Map();
11767
- function getCached(source) {
11768
- const hit = cache.get(source);
11769
- if (hit !== void 0) {
11770
- cache.delete(source);
11771
- cache.set(source, hit);
11772
- return hit;
11773
- }
11774
- let result;
11775
- try {
11776
- result = {
11777
- ok: true,
11778
- parsed: parseExpression(source)
11779
- };
11780
- } catch (err) {
11781
- result = {
11782
- ok: false,
11783
- error: err instanceof ExpressionParseError ? err.message : String(err)
11784
- };
11785
- }
11786
- cache.set(source, result);
11787
- if (cache.size > 256) {
11788
- const oldest = cache.keys().next().value;
11789
- if (oldest !== void 0) cache.delete(oldest);
11790
- }
11791
- return result;
11792
- }
11793
- /** Compile `source`, returning a discriminated result instead of throwing.
11794
- * Used by read paths that must degrade rather than raise. LRU/negative-cached. */
11795
- function compileExpressionSafe(source) {
11796
- return getCached(source);
11797
- }
11798
- Object.freeze({});
11799
- /**
11800
- * Author-time validation. Returns `null` when the source is valid, else a
11801
- * human-readable error message. Checks: the expression compiles; binding count
11802
- * is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
11803
- * is not reserved (`now`/keywords) and does not shadow a builtin; and every
11804
- * FREE identifier of the AST is covered by a binding or the injected `now`.
11805
- */
11806
- function validateExpressionSource(src) {
11807
- const names = Object.keys(src.bindings);
11808
- if (names.length > 32) return `too many bindings (${names.length} > 32)`;
11809
- for (const name of names) {
11810
- if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
11811
- if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
11812
- if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
11813
- }
11814
- const compiled = compileExpressionSafe(src.expr);
11815
- if (!compiled.ok) return compiled.error;
11816
- const bound = new Set(names);
11817
- for (const id of compiled.parsed.identifiers) {
11818
- if (id === "now") continue;
11819
- if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
11820
- }
11821
- return null;
11822
- }
11823
- var ProviderStatusSchema = object({
11824
- connected: boolean(),
11825
- deviceCount: number(),
11826
- error: string().optional()
11827
- });
11828
- object({
11829
- externalId: string(),
11830
- name: string(),
11831
- type: string(),
11832
- metadata: record(string(), unknown()).optional()
11833
- });
11834
- /**
11835
- * Candidate handed back from discovery and accepted by
11836
- * `adoptDiscoveredDevice`. Shape mirrors the in-process
11837
- * `DiscoveredDevice` interface used by `DeviceDiscovery`.
11838
- */
11839
- var DiscoveryCandidateSchema = object({
11840
- stableId: string(),
11841
- type: _enum(DeviceType),
11842
- suggestedName: string(),
11843
- prefilledConfig: record(string(), unknown()),
11844
- /**
11845
- * Optional upstream-system identity (HA entity_id, vendor MAC, …).
11846
- * Discovery pre-populates this for systems that know the upstream
11847
- * identity ahead of adoption. Rendering metadata (unit, precision)
11848
- * flows live through the cap STATUS SLICE after adoption.
11849
- */
11850
- sourceInfo: SourceInfoSchema.optional()
11851
- });
11852
- /**
11853
- * Flat device summary returned by `createDevice` / `adoptDiscoveredDevice`.
11854
- * Mirrors `toDeviceShape()` output in `device-management.router.ts` so the
11855
- * tRPC layer can pass it through without reshaping.
11856
- */
11857
- var DeviceSummarySchema = object({
11858
- id: number(),
11859
- stableId: string(),
11860
- addonId: string(),
11861
- type: string(),
11862
- name: string(),
11863
- parentDeviceId: number().nullable(),
11864
- online: boolean(),
11865
- features: array(string()),
11866
- config: record(string(), unknown()),
11867
- /** Optional upstream-system identity (dispatch key + system tag).
11868
- * See `SourceInfo`. Present when the device has a non-synthetic
11869
- * source identifier (HA entities, vendor MAC, …); omitted when the
11870
- * synthetic backfill is in effect. */
11871
- sourceInfo: SourceInfoSchema.optional()
11872
- });
11873
- /**
11874
- * Result of a live field test (e.g. probing an RTSP URL during device
11875
- * creation). Matches the UI-side `FieldProbeResult` in
11876
- * `interfaces/config-ui.ts` — the admin `FormBuilder` renders the
11877
- * returned `labels` as chips next to the input.
11878
- */
11879
- var FieldProbeResultSchema = object({
11880
- status: _enum(["ok", "error"]),
11881
- labels: array(string()).optional(),
11882
- error: string().optional()
11883
- });
11884
- /**
11885
- * The output of `getChildCreationSchema` is a UI schema tree. We store
11886
- * it as `unknown` at the capability layer — the router just passes it
11887
- * through and the admin UI renders it via `FormBuilder`. The actual
11888
- * type is `ConfigUISchema` (see `packages/types/src/interfaces/config-ui.ts`),
11889
- * but we deliberately avoid a Zod mirror because the union is large and
11890
- * not meant for runtime validation at this seam.
11891
- */
11892
- var CreationSchemaOutputSchema = unknown();
11893
- method(_void(), _void(), { kind: "mutation" }), method(_void(), _void(), { kind: "mutation" }), method(_void(), ProviderStatusSchema), method(_void(), array(object({
11894
- id: string(),
11895
- name: string(),
11896
- type: string()
11897
- }))), method(object({}), boolean()), method(object({ params: record(string(), unknown()).optional() }), array(DiscoveryCandidateSchema), {
11898
- kind: "mutation",
11899
- auth: "admin"
11900
- }), method(object({}), CreationSchemaOutputSchema), method(object({}), object({ deviceType: _enum(DeviceType).nullable() })), method(object({ candidate: DiscoveryCandidateSchema }), DeviceSummarySchema, {
11901
- kind: "mutation",
11902
- auth: "admin"
11903
- }), method(object({}), boolean()), method(object({ type: _enum(DeviceType) }), CreationSchemaOutputSchema), method(object({
11904
- type: _enum(DeviceType),
11905
- config: record(string(), unknown())
11906
- }), DeviceSummarySchema, {
11907
- kind: "mutation",
11908
- auth: "admin"
11909
- }), method(object({
11910
- type: _enum(DeviceType),
11911
- key: string(),
11912
- value: unknown(),
11913
- formValues: record(string(), unknown()).optional()
11914
- }), FieldProbeResultSchema, {
11915
- kind: "mutation",
11916
- auth: "admin"
11917
- });
11918
- /**
11919
- * Device Manager capability — hub-side singleton that unifies device persistence,
11920
- * live registry access, and all management operations into a single tRPC surface.
11921
- *
11922
- * Replaces:
11923
- * - `device-persistence` capability (persistence methods absorbed here)
11924
- * - `device-management.router.ts` (deleted in Phase 2)
11925
- * - `device-ops.router.ts` (compat layer — deleted; device-provider ops absorbed here)
11926
- *
11927
- * All device provider addons (rtsp, onvif, frigate, …) are hub-local: they may
11928
- * fork into separate processes but never run on remote cluster agents. Therefore:
11929
- * - No nodeId routing needed — this is a pure hub singleton.
11930
- * - The hub's DeviceRegistry is the single source of truth for all live devices.
11931
- * - No shadow registry or cross-node aggregation required.
11932
- *
11933
- * Forked workers register devices back to the hub via `ctx.devices`
11934
- * (DeviceManagerApi → ctx.api.deviceManager.registerDevice), same as today.
11935
- */
11936
- /** One child-placement directive on a container's `childLayout`. Structurally
11937
- * identical to `ChildLayoutEntry` in `device-management.ts` — the cap wire
11938
- * shape for the same field. The child is identified by its re-sync-stable
11939
- * accessory `stableIdSuffix` (`childKey`); listed children are grouped into
11940
- * named accordion sections (with optional intra-section order). */
11941
- var ChildLayoutEntrySchema = object({
11942
- childKey: string(),
11943
- section: string(),
11944
- order: number().optional(),
11945
- collapsed: boolean().optional()
11946
- });
11947
- /** Cap-wire shape of a DeviceLink — structurally mirrors `DeviceLink` in
11948
- * `device-management.ts`. Source is a union: a FIELD source copies a sibling
11949
- * accessory's status field (`kind` optional/absent for wire compat); a
11950
- * LITERAL source carries a per-device constant (no sibling is read); a
11951
- * GLOBAL source (P2e) copies ANY device's status field, addressed by the
11952
- * source device's full re-sync-stable `stableId`. */
11953
- var DeviceLinkFieldSourceSchema = object({
11954
- kind: literal("field").optional(),
11955
- sourceKey: string(),
11956
- cap: string(),
11957
- fieldPath: string()
11958
- });
11959
- var DeviceLinkLiteralSourceSchema = object({
11960
- kind: literal("literal"),
11961
- value: union([
11962
- string(),
11963
- number(),
11964
- boolean(),
11965
- _null()
11966
- ])
11967
- });
11968
- var DeviceLinkGlobalSourceSchema = object({
11969
- kind: literal("global"),
11970
- sourceStableId: string(),
11971
- cap: string(),
11972
- fieldPath: string()
11973
- });
11974
- /** Expression source (Stage X): compute the target field from N named bindings
11975
- * via the safe expression engine. Bindings are field | literal | global — never
11976
- * another expression (no nesting). The `superRefine` runs the SAME author-time
11977
- * validation as `validateExpressionSource` (compiles the expr, checks binding
11978
- * names + identifier coverage) so every wire boundary that parses a DeviceLink
11979
- * (tRPC mount, kernel create pre-seed, projection output) validates-at-write.
11980
- * Compiles are LRU-cached, so repeated validation of the same expr is a hit. */
11981
- var DeviceLinkExpressionSourceSchema = object({
11982
- kind: literal("expression"),
11983
- expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
11984
- bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), union([
11985
- DeviceLinkFieldSourceSchema,
11986
- DeviceLinkLiteralSourceSchema,
11987
- DeviceLinkGlobalSourceSchema
11988
- ]))
11989
- }).superRefine((src, ctx) => {
11990
- const err = validateExpressionSource(src);
11991
- if (err !== null) ctx.addIssue({
11992
- code: "custom",
11993
- message: err,
11994
- path: ["expr"]
11995
- });
11996
- });
11997
- var DeviceLinkSchema = object({
11998
- id: string(),
11999
- source: union([
12000
- DeviceLinkFieldSourceSchema,
12001
- DeviceLinkLiteralSourceSchema,
12002
- DeviceLinkGlobalSourceSchema,
12003
- DeviceLinkExpressionSourceSchema
12004
- ]),
12005
- target: object({
12006
- cap: string(),
12007
- fieldPath: string(),
12008
- itemKey: string().optional()
12009
- }),
12010
- transform: discriminatedUnion("kind", [
12011
- object({ kind: literal("identity") }),
12012
- object({
12013
- kind: literal("enum-map"),
12014
- mapping: record(string(), union([
12015
- string(),
12016
- number(),
12017
- boolean()
12018
- ])),
12019
- fallback: union([
12020
- string(),
12021
- number(),
12022
- boolean()
12023
- ]).optional()
12024
- }),
12025
- object({
12026
- kind: literal("linear"),
12027
- scale: number(),
12028
- offset: number(),
12029
- clamp: tuple([number(), number()]).readonly().optional()
12030
- })
12031
- ]).optional()
12032
- });
12033
- /** Cap-wire shape of a per-cap display refinement — mirrors
12034
- * `DeviceCapDisplayOverride` in `device-management.ts`. */
12035
- var DeviceCapDisplayOverrideSchema = object({
12036
- unit: string().min(1).optional(),
12037
- precision: number().int().min(0).max(10).optional()
12038
- });
12039
- /** Cap-wire shape of an operator-authored per-device display override —
12040
- * mirrors `DeviceDisplayOverride` in `device-management.ts`. `precision`
12041
- * bounds mirror `numeric-sensor.cap.ts` (`int 0-10`). */
12042
- var DeviceDisplayOverrideSchema = object({
12043
- icon: string().min(1).optional(),
12044
- label: string().min(1).optional(),
12045
- unit: string().min(1).optional(),
12046
- precision: number().int().min(0).max(10).optional(),
12047
- hidden: boolean().optional(),
12048
- perCap: record(string(), DeviceCapDisplayOverrideSchema).optional()
12049
- });
12050
- /** Cap-wire shape of a per-role display default — mirrors `RoleDisplayDefault`
12051
- * in `device-management.ts`. Keyed by `DeviceRole` string (role strings cross
12052
- * the wire as plain strings everywhere else — cf. `DeviceInfoSchema.role`). */
12053
- var RoleDisplayDefaultSchema = object({
12054
- unit: string().min(1).optional(),
12055
- precision: number().int().min(0).max(10).optional(),
12056
- icon: string().min(1).optional()
12057
- });
12058
- /**
12059
- * Serializable projection of a live IDevice.
12060
- * Returned by listAll, getDevice, getChildren.
12061
- * Live methods (getStreamSources, getConfigSchema) are separate calls.
11008
+ * Serializable projection of a live IDevice.
11009
+ * Returned by listAll, getDevice, getChildren.
11010
+ * Live methods (getStreamSources, getConfigSchema) are separate calls.
12062
11011
  */
12063
11012
  var DeviceInfoSchema = object({
12064
11013
  /** Progressive, system-wide unique number. Allocated synchronously by
@@ -12109,8 +11058,6 @@ var DeviceInfoSchema = object({
12109
11058
  * named accordion sections (with optional intra-section order). See
12110
11059
  * `DeviceMeta.childLayout`. Absent ⇒ no layout declared. */
12111
11060
  childLayout: array(ChildLayoutEntrySchema).readonly().optional(),
12112
- /** Operator-authored cross-device field wirings. See `DeviceMeta.deviceLinks`. */
12113
- deviceLinks: array(DeviceLinkSchema).readonly().optional(),
12114
11061
  /** Operator-authored per-device display override. See `DeviceMeta.display`. */
12115
11062
  display: DeviceDisplayOverrideSchema.optional()
12116
11063
  });
@@ -12119,7 +11066,7 @@ var ConfigEntrySchema = object({
12119
11066
  value: unknown(),
12120
11067
  description: string().optional()
12121
11068
  });
12122
- var DeviceLinkModeSchema = _enum(["auto", "manual"]);
11069
+ var LinkedDevicesModeSchema = _enum(["auto", "manual"]);
12123
11070
  /** One resolved linked device — the compact projection consumers need. */
12124
11071
  var LinkedDeviceSchema = object({
12125
11072
  deviceId: number(),
@@ -12182,8 +11129,6 @@ var DeviceMetaSchema = object({
12182
11129
  * accordion sections (with optional intra-section order). See
12183
11130
  * `DeviceMeta.childLayout`. Absent ⇒ no layout declared. */
12184
11131
  childLayout: array(ChildLayoutEntrySchema).readonly().optional(),
12185
- /** Operator-authored cross-device field wirings. See `DeviceMeta.deviceLinks`. */
12186
- deviceLinks: array(DeviceLinkSchema).readonly().optional(),
12187
11132
  /** Semantic role string (`DeviceRole`) — propagated from the spawn pre-seed.
12188
11133
  * Optional: only present for accessory children that carry a known role. */
12189
11134
  role: string().nullable().optional(),
@@ -12276,12 +11221,6 @@ method(object({
12276
11221
  }), _void(), {
12277
11222
  kind: "mutation",
12278
11223
  auth: "admin"
12279
- }), method(object({
12280
- deviceId: number(),
12281
- deviceLinks: array(DeviceLinkSchema).readonly()
12282
- }), _void(), {
12283
- kind: "mutation",
12284
- auth: "admin"
12285
11224
  }), method(object({
12286
11225
  deviceId: number(),
12287
11226
  display: DeviceDisplayOverrideSchema.nullable()
@@ -12363,7 +11302,7 @@ method(object({
12363
11302
  * shipping 293 rows to find 12. */
12364
11303
  isCamera: boolean().optional()
12365
11304
  }), array(DeviceInfoSchema)), method(object({ deviceId: number() }), DeviceInfoSchema.nullable()), method(object({ parentDeviceId: number() }), array(DeviceInfoSchema)), method(object({ deviceId: number() }), object({
12366
- mode: DeviceLinkModeSchema,
11305
+ mode: LinkedDevicesModeSchema,
12367
11306
  devices: array(LinkedDeviceSchema)
12368
11307
  })), method(object({ deviceId: number() }), array(StreamSourceEntrySchema$1)), method(object({ deviceId: number() }), array(ConfigEntrySchema)), method(object({ deviceId: number() }), ConfigUISchemaOutput), method(object({
12369
11308
  deviceId: number(),
@@ -12396,11 +11335,7 @@ method(object({
12396
11335
  deviceId: number(),
12397
11336
  entries: array(object({
12398
11337
  capName: string(),
12399
- kind: _enum([
12400
- "native",
12401
- "wrapped",
12402
- "linked"
12403
- ]),
11338
+ kind: _enum(["native", "wrapped"]),
12404
11339
  providerAddonId: string(),
12405
11340
  providerNodeId: string(),
12406
11341
  nativeAddonId: string()
@@ -12409,11 +11344,7 @@ method(object({
12409
11344
  deviceId: number(),
12410
11345
  entries: array(object({
12411
11346
  capName: string(),
12412
- kind: _enum([
12413
- "native",
12414
- "wrapped",
12415
- "linked"
12416
- ]),
11347
+ kind: _enum(["native", "wrapped"]),
12417
11348
  providerAddonId: string(),
12418
11349
  providerNodeId: string(),
12419
11350
  nativeAddonId: string()
@@ -13226,7 +12157,7 @@ var MotionAnalysisResultSchema = object({
13226
12157
  frameHeight: number(),
13227
12158
  analysisMs: number()
13228
12159
  });
13229
- method(object({
12160
+ DeviceType.Camera, method(object({
13230
12161
  deviceId: number(),
13231
12162
  frame: FrameInputSchema.optional(),
13232
12163
  frameHandle: FrameHandleSchema.optional()
@@ -15898,6 +14829,18 @@ var OauthIntegrationDescriptorSchema = object({
15898
14829
  * redirect_uri that does not start with one of these. Required —
15899
14830
  * an empty list means the integration can never complete linking. */
15900
14831
  allowedRedirectPrefixes: array(string()).min(1),
14832
+ /** Paths accepted as a `redirect_uri` when the host is PRIVATE — loopback,
14833
+ * RFC1918, CGNAT (100.64/10, Tailscale), link-local, IPv6 ULA, or an
14834
+ * `.local` / `.internal` / `.ts.net` name. Exists for self-hosted clients
14835
+ * whose address the hub cannot know in advance (a Home Assistant at
14836
+ * `http://<lan-ip>:8123/auth/external/callback`). The PATH must match
14837
+ * exactly; a public host never satisfies this branch, so it is not a
14838
+ * wildcard prefix by another name. */
14839
+ allowedPrivateHostPaths: array(string()).optional(),
14840
+ /** When true this is a PUBLIC client (source is published, no secret can be
14841
+ * protected) and PKCE is mandatory: `/authorize` refuses without an S256
14842
+ * `code_challenge`, `/token` refuses without the matching `code_verifier`. */
14843
+ requiresPkce: boolean().optional(),
15901
14844
  /** Optional public origin (no trailing slash) that this integration's
15902
14845
  * issued codes/tokens should carry as the `hubUrl` claim — typically the
15903
14846
  * operator-selected external-access endpoint resolved by the addon. When
@@ -16048,7 +14991,7 @@ var TrackEnvelopeSchema = object({
16048
14991
  * `snapshots[]` references — megabytes across a page of tracks. `slim`
16049
14992
  * keeps every scalar the list surfaces actually render (ids, class(es),
16050
14993
  * label / audioLabels / importance enrichment, firstSeen/lastSeen, state,
16051
- * zonesVisited, bestEventId, envelope) and returns `positions` /
14994
+ * zonesVisited, bestEventId, envelope, hasFace) and returns `positions` /
16052
14995
  * `snapshots` as EMPTY arrays — detail views re-fetch the full row via
16053
14996
  * `getTrack`. Mirrors the event-store `projection` convention
16054
14997
  * (`getObjectEvents` et al.).
@@ -16285,6 +15228,24 @@ var TrackSchema = object({
16285
15228
  * Populated from the persisted envelope columns on historical reads;
16286
15229
  * absent on legacy rows, dims-less tracks and active (in-RAM) tracks. */
16287
15230
  envelope: TrackEnvelopeSchema.optional(),
15231
+ /**
15232
+ * A face DETECTOR found a face on this track — nothing more. It says the
15233
+ * detail plane produced a `face` detail; it does NOT say the face was
15234
+ * embedded, matched, above `minFacePx`, or that the recognizer was even
15235
+ * enabled. Set once and never cleared.
15236
+ *
15237
+ * **This exists so "face present but not recognised" is expressible.** A
15238
+ * recognised identity lands in `subLabel` (attributed to the face chain via
15239
+ * `subLabelMeta.stepId`), so before this field a track with an unmatched face
15240
+ * and a track with no face at all were byte-identical on the wire and no
15241
+ * surface could tell them apart. The read is `hasFace === true && subLabel
15242
+ * === undefined`.
15243
+ *
15244
+ * **Absent ≠ false.** Every row written before the column existed omits it,
15245
+ * and so does every server that predates the field — a consumer must test
15246
+ * `=== true` and render nothing otherwise, never infer "no face".
15247
+ */
15248
+ hasFace: boolean().optional(),
16288
15249
  ...TrackFlagFields,
16289
15250
  ...TrackRetrainFields
16290
15251
  });
@@ -19353,6 +18314,10 @@ var SsoBridgeClaimsSchema = object({
19353
18314
  integrationId: string().optional(),
19354
18315
  /** JWT ID — unique per issued code; consumed-set enforces single-use. */
19355
18316
  jti: string().optional(),
18317
+ /** PKCE S256 challenge — set only on `oauth-code` tokens issued to a public
18318
+ * client. Its PRESENCE is what makes the verifier mandatory at exchange,
18319
+ * so the requirement travels with the code and not with mutable config. */
18320
+ codeChallenge: string().optional(),
19356
18321
  /** OAuth session registry id — set on `oauth-access`/`oauth-refresh`
19357
18322
  * tokens so the verify path can check the session is not revoked. */
19358
18323
  sessionId: string().optional()
@@ -19911,6 +18876,10 @@ var videoclipsCapability = {
19911
18876
  mode: "singleton",
19912
18877
  kind: "wrapper",
19913
18878
  defaultActive: true,
18879
+ /** A clip is a window over a camera's footage — the cap is meaningless on a
18880
+ * sensor, a button or an event emitter, and the `defaultActive` auto-bind
18881
+ * reads this to decide which devices it may claim. */
18882
+ deviceTypes: [DeviceType.Camera],
19914
18883
  methods: {
19915
18884
  listClips: method(object({
19916
18885
  deviceId: number(),
@@ -26507,13 +25476,18 @@ method(_void(), array(UserSummarySchema), { auth: "admin" }), method(CreateUserI
26507
25476
  username: string(),
26508
25477
  scopes: array(TokenScopeSchema),
26509
25478
  redirectUri: string(),
26510
- hubUrl: string()
25479
+ hubUrl: string(),
25480
+ /** PKCE (RFC 7636) S256 challenge. Baked into the signed code; a code
25481
+ * that carries one can ONLY be exchanged with the matching verifier. */
25482
+ codeChallenge: string().optional()
26511
25483
  }), object({ code: string() }), {
26512
25484
  kind: "mutation",
26513
25485
  access: "create"
26514
25486
  }), method(object({
26515
25487
  code: string(),
26516
- redirectUri: string()
25488
+ redirectUri: string(),
25489
+ /** PKCE verifier. REQUIRED when the code carries a challenge. */
25490
+ codeVerifier: string().optional()
26517
25491
  }), object({
26518
25492
  accessToken: string(),
26519
25493
  refreshToken: string(),
@@ -27726,279 +26700,1116 @@ var BaseDevice = class {
27726
26700
  return this._sourceInfoCache;
27727
26701
  }
27728
26702
  /**
27729
- * Convenience accessor for the upstream dispatch key. Equivalent to
27730
- * `this.sourceInfo.id` — providers use this to keep a
27731
- * `Map<sourceId, IDevice>` for routing inbound push events.
27732
- */
27733
- get sourceId() {
27734
- return this.sourceInfo.id;
26703
+ * Convenience accessor for the upstream dispatch key. Equivalent to
26704
+ * `this.sourceInfo.id` — providers use this to keep a
26705
+ * `Map<sourceId, IDevice>` for routing inbound push events.
26706
+ */
26707
+ get sourceId() {
26708
+ return this.sourceInfo.id;
26709
+ }
26710
+ /**
26711
+ * Patch the device's `SourceInfo`. Shallow-merges `patch` over the
26712
+ * current value, persists the merged result under
26713
+ * `metadata.sourceInfo` via the `device-manager.setMetadata` cap, and
26714
+ * emits `EventCategory.DeviceSourceInfoChanged` for live consumers.
26715
+ *
26716
+ * Safe to call from anywhere in the device's lifetime — the call is
26717
+ * idempotent for `undefined` patch values (ignored) and best-effort
26718
+ * for persistence (a transient device-manager error doesn't unwind
26719
+ * the local cache update, so subsequent reads still see the patch).
26720
+ *
26721
+ * Drivers populate this on adoption + on every metadata change push
26722
+ * from the upstream source. Subscribers (UI, export adapters) react
26723
+ * via the `DeviceSourceInfoChanged` event without polling.
26724
+ */
26725
+ async updateSourceInfo(patch) {
26726
+ const next = mergeSourceInfo(this.sourceInfo, patch);
26727
+ this._sourceInfoCache = Object.freeze({ ...next });
26728
+ const action = this.ctx.api?.deviceManager?.setMetadata;
26729
+ if (action) try {
26730
+ await action.mutate({
26731
+ deviceId: this.id,
26732
+ patch: { [SOURCE_INFO_METADATA_KEY]: next }
26733
+ });
26734
+ } catch {}
26735
+ this.ctx.eventBus.emit(createEvent("device.source-info-changed", {
26736
+ type: "device",
26737
+ id: this.stableId
26738
+ }, {
26739
+ deviceId: this.id,
26740
+ sourceInfo: next
26741
+ }));
26742
+ }
26743
+ /**
26744
+ * Re-publish the device's current `features` array to the persisted
26745
+ * meta blob. Drivers call this after a probe finishes when the live
26746
+ * `features` getter has gained new flags (e.g. `hasIntercom` flips
26747
+ * to true → `DeviceFeature.TwoWayAudio` joins the list).
26748
+ *
26749
+ * Without this, only the construction-time snapshot is written —
26750
+ * `deviceManager.registerDevice` is invoked once per boot, so probe-
26751
+ * driven additions don't reach the persisted index until the next
26752
+ * server restart, and `getDevice` / `listAll` keep returning the
26753
+ * stale list for forked-worker devices (whose live IDevice instance
26754
+ * is invisible to the hub registry).
26755
+ *
26756
+ * Idempotent: re-calling with the same features just no-ops on the
26757
+ * persisted meta. Best-effort: lookup or write failures are logged
26758
+ * at debug and swallowed — the live `device.features` getter is
26759
+ * still authoritative within this process, so callers never block
26760
+ * device boot on a meta refresh.
26761
+ */
26762
+ async refreshFeatures() {
26763
+ const action = this.ctx.api?.deviceManager?.registerDevice;
26764
+ if (!action) return;
26765
+ try {
26766
+ await action.mutate({
26767
+ addonId: this.ctx.deviceMeta.addonId,
26768
+ stableId: this.stableId,
26769
+ id: this.id,
26770
+ type: this.type,
26771
+ name: this.name,
26772
+ parentDeviceId: this.parentDeviceId,
26773
+ features: [...this.features],
26774
+ config: {}
26775
+ });
26776
+ } catch (err) {}
26777
+ }
26778
+ /**
26779
+ * Typed read-through to a cap-keyed runtime-state slice. Drivers
26780
+ * call `this.getCapSlice(batteryCapability)` and the return type
26781
+ * is inferred from the cap's `runtimeState` Zod schema — no string
26782
+ * key, no manual generic. Returns `null` when the slice hasn't
26783
+ * been written yet (e.g. driver hasn't seeded battery yet).
26784
+ */
26785
+ getCapSlice(cap) {
26786
+ return this.runtimeState.getCapState(cap.name) ?? null;
26787
+ }
26788
+ /**
26789
+ * Typed writer to a cap-keyed runtime-state slice. Routes through
26790
+ * the runtime-state writer (validate → persist → emit cap event).
26791
+ * Equivalent to `this.runtimeState.setCapState(cap.name, value)`
26792
+ * but with the cap's `runtimeState` schema enforcing the value
26793
+ * shape at compile time. Mirrors the symmetry of
26794
+ * `getCapSlice` / `setCapSlice` for cross-cap consistency.
26795
+ */
26796
+ setCapSlice(cap, value) {
26797
+ this.runtimeState.setCapState(cap.name, value);
26798
+ }
26799
+ /**
26800
+ * Field-level read/write proxy over a cap's runtime-state slice.
26801
+ * Drivers that want ergonomic per-field access declare:
26802
+ *
26803
+ * ```ts
26804
+ * protected battery = this.sliceProxy(batteryCapability)
26805
+ * // …
26806
+ * this.battery.sleeping = true // patches the slice
26807
+ * const charging = this.battery.charging // reads the slice
26808
+ * ```
26809
+ *
26810
+ * Reads return `undefined` when the slice hasn't been seeded yet
26811
+ * (cap not registered, or seeded but the field is absent). Writes
26812
+ * route through `runtimeState.patchCapState` so the cap's `runtimeState`
26813
+ * schema validates the merged result and the cap event fires.
26814
+ *
26815
+ * Pattern is generic — same shape works for `battery`, `device-status`,
26816
+ * `motion`, `doorbell`, anything with a `runtimeState:` schema. Drivers
26817
+ * declare one proxy per cap they read/write directly.
26818
+ */
26819
+ sliceProxy(cap) {
26820
+ return new Proxy({}, {
26821
+ get: (_, key) => {
26822
+ return this.runtimeState.getCapState(cap.name)?.[key];
26823
+ },
26824
+ set: (_, key, value) => {
26825
+ this.runtimeState.patchCapState(cap.name, { [key]: value });
26826
+ return true;
26827
+ },
26828
+ has: (_, key) => {
26829
+ const slice = this.runtimeState.getCapState(cap.name);
26830
+ return slice ? key in slice : false;
26831
+ },
26832
+ ownKeys: () => {
26833
+ const slice = this.runtimeState.getCapState(cap.name);
26834
+ return slice ? Object.keys(slice) : [];
26835
+ },
26836
+ getOwnPropertyDescriptor: (_, key) => {
26837
+ const slice = this.runtimeState.getCapState(cap.name);
26838
+ if (!slice || !(key in slice)) return void 0;
26839
+ return {
26840
+ configurable: true,
26841
+ enumerable: true,
26842
+ value: slice[key]
26843
+ };
26844
+ }
26845
+ });
26846
+ }
26847
+ /**
26848
+ * Default empty settings UI. Drivers override this to expose an
26849
+ * editable form in the device-details page. Returning an empty sections
26850
+ * array signals "nothing to contribute" — the aggregator drops the
26851
+ * contribution entirely rather than rendering a blank panel.
26852
+ */
26853
+ getSettingsUISchema() {
26854
+ return { sections: [] };
26855
+ }
26856
+ /**
26857
+ * Default write path: forward the flat patch directly to storage.
26858
+ * Drivers that project a UI shape different from storage (e.g. `RtspCamera`
26859
+ * exposing `mainStreamUrl`/`subStreamUrl` over `streams[]`) override this
26860
+ * to reshape before `config.setAll`.
26861
+ */
26862
+ async applySettingsPatch(patch) {
26863
+ await this.config.setAll(patch);
26864
+ }
26865
+ /**
26866
+ * Phase 3 — populate device-scoped state needed by downstream phases
26867
+ * (accessory reconciliation, public `features` array, optional cap
26868
+ * registration). Called ONCE per construction, after register but
26869
+ * before `getAccessoryChildren()`.
26870
+ *
26871
+ * Drivers write the `feature-probe` runtime-state slice via
26872
+ * `this.runtimeState.setCapState('feature-probe', {...})` — flag bag
26873
+ * is open (Reolink writes `hasPtz/hasIntercom`, Hikvision writes
26874
+ * `hasSupplementalLight/hasAlarmIo`, etc).
26875
+ *
26876
+ * Default: nothing to probe → mark the device PROBED (set `lastProbedAt`) so
26877
+ * the kernel treats it as ready immediately. A device that derives its shape
26878
+ * from a spec (a container, or an accessory sensor) rather than from a
26879
+ * hardware probe has no probe to "complete"; without stamping `lastProbedAt`
26880
+ * it would look perpetually un-probed — logging "Initial probe did not
26881
+ * complete" on every boot and spinning a pointless retry chain. Drivers that
26882
+ * DO probe override this and write their own `feature-probe` slice (including
26883
+ * `lastProbedAt`) once their probe actually succeeds.
26884
+ */
26885
+ async onProbe() {
26886
+ const base = this.runtimeState.getCapState("feature-probe") ?? {
26887
+ flags: {},
26888
+ deviceType: null,
26889
+ model: null,
26890
+ channelCount: null,
26891
+ lastProbedAt: 0,
26892
+ lastFetchedAt: 0
26893
+ };
26894
+ this.runtimeState.setCapState("feature-probe", {
26895
+ ...base,
26896
+ lastProbedAt: Date.now()
26897
+ });
26898
+ }
26899
+ /**
26900
+ * Phase 5 — fired after the device + its accessories are registered.
26901
+ * Drivers publish streams to the broker, kick off background tasks,
26902
+ * or subscribe to lib events that need a fully-registered device id.
26903
+ *
26904
+ * Default: no-op.
26905
+ *
26906
+ * RENAMED FROM `onCreated` (which still exists for back-compat in this
26907
+ * pass). The new name reflects the post-probe, post-accessory contract.
26908
+ */
26909
+ async onActivate() {}
26910
+ /**
26911
+ * Re-run the probe + reconcile accessories + refresh features meta.
26912
+ * Drivers call this when device-side state changes (battery cam wakes,
26913
+ * firmware update, manual operator trigger).
26914
+ *
26915
+ * The kernel injects `_kernelReprobe` on registration so this method
26916
+ * delegates to the same orchestrator that runs the boot-time phase
26917
+ * 3 + 4 sequence. Drivers should NOT override this — they override
26918
+ * `onProbe()` instead.
26919
+ */
26920
+ async reprobe() {
26921
+ if (this._kernelReprobe) await this._kernelReprobe();
26922
+ else await this.onProbe();
26923
+ }
26924
+ /**
26925
+ * Kernel-injected callback that runs the full post-probe orchestration
26926
+ * (onProbe → registerDevice meta refresh → accessory reconciliation).
26927
+ * Set by `device-cap-proxy.register()`. Drivers should not touch this
26928
+ * directly — call `reprobe()` instead.
26929
+ */
26930
+ _kernelReprobe;
26931
+ /**
26932
+ * Declare accessory child devices the kernel should auto-spawn
26933
+ * after `onProbe()` resolves. Each spec fully describes one child
26934
+ * — stableId suffix (deterministic per kind for restore-safety),
26935
+ * meta (type / name / location), config (initial blob the child
26936
+ * self-hydrates), and a factory that constructs the concrete
26937
+ * class with whatever closure-captured refs it needs (typically
26938
+ * `this` for the parent reference).
26939
+ *
26940
+ * The kernel handles the rest: allocateDeviceId, persistInitialConfig
26941
+ * (skipped on restore when the row already exists),
26942
+ * persistInitialMeta, createContext, factory invocation, register,
26943
+ * and recursive lifecycle (probe + accessories + activate).
26944
+ *
26945
+ * Implementations should derive children from
26946
+ * `this.runtimeState.getCapState('feature-probe')` (post-probe truth).
26947
+ * Drivers can use the `getProbeFlags()` helper to read the flag bag
26948
+ * with a typed cast.
26949
+ *
26950
+ * Default: no children.
26951
+ */
26952
+ getAccessoryChildren() {
26953
+ return [];
26954
+ }
26955
+ /**
26956
+ * Read the current feature-probe flag bag with a typed cast. Helper
26957
+ * for `getAccessoryChildren()` and `features` getters that derive
26958
+ * outputs from the probe results.
26959
+ */
26960
+ getProbeFlags() {
26961
+ return this.runtimeState.getCapState("feature-probe")?.flags ?? {};
26962
+ }
26963
+ /**
26964
+ * Returns true once `onProbe` has completed at least once
26965
+ * (`lastProbedAt > 0`). Drivers gate `getAccessoryChildren()` on this
26966
+ * to avoid spawning stale accessories on a fresh device whose probe
26967
+ * hasn't landed yet.
26968
+ */
26969
+ hasProbed() {
26970
+ return (this.runtimeState.getCapState("feature-probe")?.lastProbedAt ?? 0) > 0;
26971
+ }
26972
+ };
26973
+ /** Marker written to a declared integration's `info`. */
26974
+ var DECLARED_INTEGRATION_FIXED_KEY = "fixed";
26975
+ /**
26976
+ * Strip the `<node>/<addon>` suffix a forked child carries.
26977
+ *
26978
+ * Comparing `ctx.kernel.localNodeId` raw skipped EVERY node — including the one
26979
+ * that was supposed to act — because on the hub it reads `hub/<addon>`.
26980
+ */
26981
+ function declarationOwnerNodeId(localNodeId) {
26982
+ const raw = localNodeId ?? "hub";
26983
+ if (!raw.includes("/")) return raw;
26984
+ return raw.split("/")[0] ?? "hub";
26985
+ }
26986
+ /**
26987
+ * The one way an addon owns a device it declares.
26988
+ *
26989
+ * Construct once with the addon's ports, then call {@link reconcile} on boot and
26990
+ * on every convergence tick. There is no second get-or-create helper — a guard
26991
+ * in `scripts/` enforces that.
26992
+ */
26993
+ var DeclaredDevices = class {
26994
+ ports;
26995
+ constructor(ports) {
26996
+ this.ports = ports;
26997
+ }
26998
+ /**
26999
+ * Converge the declared set. Idempotent, and safe to call repeatedly.
27000
+ *
27001
+ * Throws only what the ports throw on the FIRST index read; every other
27002
+ * failure is per-device and logged, so one bad declaration never takes the
27003
+ * others down.
27004
+ */
27005
+ async reconcile(spec) {
27006
+ if ((spec.placement ?? "hub") === "hub") {
27007
+ const nodeId = declarationOwnerNodeId(this.ports.localNodeId);
27008
+ if (nodeId !== "hub") {
27009
+ this.ports.logger.info("declared devices are hub-owned — skipping on this node", { meta: {
27010
+ nodeId,
27011
+ rawNodeId: this.ports.localNodeId ?? null
27012
+ } });
27013
+ return {
27014
+ integrationId: null,
27015
+ devices: [],
27016
+ removed: [],
27017
+ owned: false
27018
+ };
27019
+ }
27020
+ }
27021
+ const integrationId = await this.ensureIntegration(spec.integrationName);
27022
+ const index = await this.readIndex();
27023
+ const outcomes = [];
27024
+ for (const declaration of spec.devices) {
27025
+ const outcome = await this.applyDeclaration(declaration, integrationId, index);
27026
+ if (outcome !== null) outcomes.push(outcome);
27027
+ }
27028
+ return {
27029
+ integrationId,
27030
+ devices: outcomes,
27031
+ removed: await this.sweepWithdrawn(spec.devices, integrationId, index),
27032
+ owned: true
27033
+ };
27735
27034
  }
27736
27035
  /**
27737
- * Patch the device's `SourceInfo`. Shallow-merges `patch` over the
27738
- * current value, persists the merged result under
27739
- * `metadata.sourceInfo` via the `device-manager.setMetadata` cap, and
27740
- * emits `EventCategory.DeviceSourceInfoChanged` for live consumers.
27036
+ * Get-or-create the FIXED integration, and RE-ASSERT the flag every pass.
27741
27037
  *
27742
- * Safe to call from anywhere in the device's lifetime — the call is
27743
- * idempotent for `undefined` patch values (ignored) and best-effort
27744
- * for persistence (a transient device-manager error doesn't unwind
27745
- * the local cache update, so subsequent reads still see the patch).
27038
+ * The re-assertion is the fix for the defect the hand-rolled version shipped
27039
+ * with: writing `info.fixed` only on the create path left every pre-existing
27040
+ * install without it, and the kernel kept offering to delete an integration
27041
+ * the addon owns.
27042
+ */
27043
+ async ensureIntegration(integrationName) {
27044
+ const existing = await this.ports.getIntegration(this.ports.addonId);
27045
+ if (existing === null) {
27046
+ const created = await this.ports.createIntegration({
27047
+ addonId: this.ports.addonId,
27048
+ name: integrationName,
27049
+ info: { [DECLARED_INTEGRATION_FIXED_KEY]: true }
27050
+ });
27051
+ this.ports.logger.info("declared a fixed integration", { meta: {
27052
+ integrationId: created.id,
27053
+ name: integrationName
27054
+ } });
27055
+ return created.id;
27056
+ }
27057
+ if (existing.info?.["fixed"] !== true) {
27058
+ await this.ports.updateIntegration({
27059
+ id: existing.id,
27060
+ info: { [DECLARED_INTEGRATION_FIXED_KEY]: true }
27061
+ });
27062
+ this.ports.logger.info("re-asserted `fixed` on a declared integration", { meta: { integrationId: existing.id } });
27063
+ }
27064
+ return existing.id;
27065
+ }
27066
+ async readIndex() {
27067
+ const rows = await this.ports.listOwnDevices();
27068
+ return new Map(rows.map((row) => [row.stableId, row]));
27069
+ }
27070
+ /**
27071
+ * One declaration: adopt what exists, create what does not.
27746
27072
  *
27747
- * Drivers populate this on adoption + on every metadata change push
27748
- * from the upstream source. Subscribers (UI, export adapters) react
27749
- * via the `DeviceSourceInfoChanged` event without polling.
27073
+ * The create branch is the destructive one — it seeds `initialMeta`, and
27074
+ * `initialMeta.name` lands as an unconditional `setName`. A transiently empty
27075
+ * index therefore looks exactly like a first boot and would silently re-stamp
27076
+ * the declared name over the operator's rename. D49: that branch needs a
27077
+ * second read to agree.
27750
27078
  */
27751
- async updateSourceInfo(patch) {
27752
- const next = mergeSourceInfo(this.sourceInfo, patch);
27753
- this._sourceInfoCache = Object.freeze({ ...next });
27754
- const action = this.ctx.api?.deviceManager?.setMetadata;
27755
- if (action) try {
27756
- await action.mutate({
27757
- deviceId: this.id,
27758
- patch: { [SOURCE_INFO_METADATA_KEY]: next }
27079
+ async applyDeclaration(declaration, integrationId, index) {
27080
+ try {
27081
+ let existing = index.get(declaration.stableId);
27082
+ if (existing === void 0) {
27083
+ existing = (await this.readIndex()).get(declaration.stableId);
27084
+ if (existing !== void 0) this.ports.logger.warn("device index disagreed with itself — adopting instead of re-creating", {
27085
+ tags: { deviceId: existing.id },
27086
+ meta: {
27087
+ stableId: declaration.stableId,
27088
+ addonId: this.ports.addonId
27089
+ }
27090
+ });
27091
+ }
27092
+ if (existing !== void 0) {
27093
+ const device = await this.ports.devices.create(declaration.stableId, declaration.DeviceClass, {}, null, void 0);
27094
+ this.ports.logger.info("declared device adopted", {
27095
+ tags: { deviceId: device.id },
27096
+ meta: {
27097
+ stableId: declaration.stableId,
27098
+ integrationId
27099
+ }
27100
+ });
27101
+ return {
27102
+ stableId: declaration.stableId,
27103
+ deviceId: device.id,
27104
+ device,
27105
+ created: false
27106
+ };
27107
+ }
27108
+ const device = await this.ports.devices.create(declaration.stableId, declaration.DeviceClass, declaration.config ?? {}, null, {
27109
+ type: declaration.type,
27110
+ name: declaration.name,
27111
+ integrationId,
27112
+ ...declaration.role === void 0 ? {} : { role: declaration.role }
27759
27113
  });
27760
- } catch {}
27761
- this.ctx.eventBus.emit(createEvent("device.source-info-changed", {
27762
- type: "device",
27763
- id: this.stableId
27764
- }, {
27765
- deviceId: this.id,
27766
- sourceInfo: next
27767
- }));
27114
+ this.ports.logger.info("declared device created", {
27115
+ tags: { deviceId: device.id },
27116
+ meta: {
27117
+ stableId: declaration.stableId,
27118
+ integrationId
27119
+ }
27120
+ });
27121
+ return {
27122
+ stableId: declaration.stableId,
27123
+ deviceId: device.id,
27124
+ device,
27125
+ created: true
27126
+ };
27127
+ } catch (err) {
27128
+ this.ports.logger.warn("a declared device could not be brought up", { meta: {
27129
+ stableId: declaration.stableId,
27130
+ error: err instanceof Error ? err.message : String(err)
27131
+ } });
27132
+ return null;
27133
+ }
27768
27134
  }
27769
27135
  /**
27770
- * Re-publish the device's current `features` array to the persisted
27771
- * meta blob. Drivers call this after a probe finishes when the live
27772
- * `features` getter has gained new flags (e.g. `hasIntercom` flips
27773
- * to true → `DeviceFeature.TwoWayAudio` joins the list).
27136
+ * Remove rows under the addon's FIXED integration whose declaration is gone.
27774
27137
  *
27775
- * Without this, only the construction-time snapshot is written —
27776
- * `deviceManager.registerDevice` is invoked once per boot, so probe-
27777
- * driven additions don't reach the persisted index until the next
27778
- * server restart, and `getDevice` / `listAll` keep returning the
27779
- * stale list for forked-worker devices (whose live IDevice instance
27780
- * is invisible to the hub registry).
27138
+ * Bounded to that integration: a declared integration has no operator
27139
+ * add-flow, so every row under it got there by declaration. Devices this
27140
+ * addon owns OUTSIDE it (a provider's adopted devices) are never candidates.
27781
27141
  *
27782
- * Idempotent: re-calling with the same features just no-ops on the
27783
- * persisted meta. Best-effort: lookup or write failures are logged
27784
- * at debug and swallowed — the live `device.features` getter is
27785
- * still authoritative within this process, so callers never block
27786
- * device boot on a meta refresh.
27787
- */
27788
- async refreshFeatures() {
27789
- const action = this.ctx.api?.deviceManager?.registerDevice;
27790
- if (!action) return;
27791
- try {
27792
- await action.mutate({
27793
- addonId: this.ctx.deviceMeta.addonId,
27794
- stableId: this.stableId,
27795
- id: this.id,
27796
- type: this.type,
27797
- name: this.name,
27798
- parentDeviceId: this.parentDeviceId,
27799
- features: [...this.features],
27800
- config: {}
27142
+ * Bounded in count, and every deletion is logged with its `deviceId` — a
27143
+ * withdrawal that removes an operator-visible row silently is the failure
27144
+ * mode, not the removal itself.
27145
+ */
27146
+ async sweepWithdrawn(declarations, integrationId, index) {
27147
+ const declared = new Set(declarations.map((d) => d.stableId));
27148
+ const candidates = [...index.values()].filter((row) => row.integrationId === integrationId && !declared.has(row.stableId));
27149
+ if (candidates.length === 0) return [];
27150
+ if (candidates.length > 32) {
27151
+ this.ports.logger.warn("withdrawal sweep exceeded its bound — removing nothing", { meta: {
27152
+ integrationId,
27153
+ candidates: candidates.length,
27154
+ bound: 32
27155
+ } });
27156
+ return [];
27157
+ }
27158
+ const removed = [];
27159
+ for (const row of candidates) try {
27160
+ await this.ports.devices.remove(row.id);
27161
+ removed.push(row.id);
27162
+ this.ports.logger.info("declared device removed — its declaration was withdrawn", {
27163
+ tags: { deviceId: row.id },
27164
+ meta: {
27165
+ stableId: row.stableId,
27166
+ integrationId
27167
+ }
27801
27168
  });
27802
- } catch (err) {}
27169
+ } catch (err) {
27170
+ this.ports.logger.warn("a withdrawn declared device could not be removed", {
27171
+ tags: { deviceId: row.id },
27172
+ meta: {
27173
+ stableId: row.stableId,
27174
+ error: err instanceof Error ? err.message : String(err)
27175
+ }
27176
+ });
27177
+ }
27178
+ return removed;
27179
+ }
27180
+ };
27181
+ 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;
27182
+ new Set(Object.values(DeviceType));
27183
+ DeviceFeature.BatteryOperated;
27184
+ /**
27185
+ * Error types for the safe expression engine. Two distinct classes so callers
27186
+ * can tell a compile-time (grammar) failure from a runtime (evaluation)
27187
+ * failure — both are non-fatal to the host: read paths degrade to "skip link".
27188
+ */
27189
+ /** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
27190
+ * the failure is anchored to a character (author-facing inline feedback). */
27191
+ var ExpressionParseError = class extends Error {
27192
+ position;
27193
+ constructor(message, position) {
27194
+ super(message);
27195
+ this.name = "ExpressionParseError";
27196
+ this.position = position;
27197
+ }
27198
+ };
27199
+ /** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
27200
+ * result, unknown builtin, step-budget exceeded). */
27201
+ var ExpressionEvalError = class extends Error {
27202
+ constructor(message) {
27203
+ super(message);
27204
+ this.name = "ExpressionEvalError";
27205
+ }
27206
+ };
27207
+ /**
27208
+ * Frozen, null-prototype builtin function table for the expression engine
27209
+ * (spec §4 rule 4). The table is the SOLE surface of callable functions: the
27210
+ * parser rejects any callee not in it, and the evaluator gates each call on an
27211
+ * own-property check against it.
27212
+ *
27213
+ * Because the object has a NULL prototype AND is `Object.freeze`d:
27214
+ * - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
27215
+ * - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
27216
+ * (there is no `Object.prototype` in the chain), so those names are not
27217
+ * callable — they are simply "unknown function" at parse time.
27218
+ *
27219
+ * Every numeric argument is validated as a finite number and every numeric
27220
+ * RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
27221
+ * (`pow(10,400)` → Infinity) all raise `ExpressionEvalError` and fail the link
27222
+ * closed rather than emitting a garbage value.
27223
+ */
27224
+ function asFiniteNumber(value, name, index) {
27225
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
27226
+ return value;
27227
+ }
27228
+ function asString$1(value, name, index) {
27229
+ if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
27230
+ return value;
27231
+ }
27232
+ function finiteResult(value, name) {
27233
+ if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
27234
+ return value;
27235
+ }
27236
+ function allFiniteNumbers(args, name) {
27237
+ return args.map((a, idx) => asFiniteNumber(a, name, idx));
27238
+ }
27239
+ var INF = Number.POSITIVE_INFINITY;
27240
+ var table = {
27241
+ min: {
27242
+ minArgs: 1,
27243
+ maxArgs: INF,
27244
+ apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
27245
+ },
27246
+ max: {
27247
+ minArgs: 1,
27248
+ maxArgs: INF,
27249
+ apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
27250
+ },
27251
+ abs: {
27252
+ minArgs: 1,
27253
+ maxArgs: 1,
27254
+ apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
27255
+ },
27256
+ floor: {
27257
+ minArgs: 1,
27258
+ maxArgs: 1,
27259
+ apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
27260
+ },
27261
+ ceil: {
27262
+ minArgs: 1,
27263
+ maxArgs: 1,
27264
+ apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
27265
+ },
27266
+ sqrt: {
27267
+ minArgs: 1,
27268
+ maxArgs: 1,
27269
+ apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
27270
+ },
27271
+ round: {
27272
+ minArgs: 1,
27273
+ maxArgs: 2,
27274
+ apply: (args) => {
27275
+ const x = asFiniteNumber(args[0], "round", 0);
27276
+ const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
27277
+ if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
27278
+ const factor = 10 ** digits;
27279
+ return finiteResult(Math.round(x * factor) / factor, "round");
27280
+ }
27281
+ },
27282
+ pow: {
27283
+ minArgs: 2,
27284
+ maxArgs: 2,
27285
+ apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
27286
+ },
27287
+ clamp: {
27288
+ minArgs: 3,
27289
+ maxArgs: 3,
27290
+ apply: (args) => {
27291
+ const x = asFiniteNumber(args[0], "clamp", 0);
27292
+ const lo = asFiniteNumber(args[1], "clamp", 1);
27293
+ const hi = asFiniteNumber(args[2], "clamp", 2);
27294
+ if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
27295
+ return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
27296
+ }
27297
+ },
27298
+ avg: {
27299
+ minArgs: 1,
27300
+ maxArgs: INF,
27301
+ apply: (args) => {
27302
+ const nums = allFiniteNumbers(args, "avg");
27303
+ return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
27304
+ }
27305
+ },
27306
+ sum: {
27307
+ minArgs: 1,
27308
+ maxArgs: INF,
27309
+ apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
27310
+ },
27311
+ coalesce: {
27312
+ minArgs: 1,
27313
+ maxArgs: INF,
27314
+ apply: (args) => {
27315
+ for (const a of args) if (a !== null) return a;
27316
+ return null;
27317
+ }
27318
+ },
27319
+ age: {
27320
+ minArgs: 2,
27321
+ maxArgs: 2,
27322
+ apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
27323
+ },
27324
+ convert: {
27325
+ minArgs: 3,
27326
+ maxArgs: 3,
27327
+ apply: (args, hooks) => {
27328
+ const x = asFiniteNumber(args[0], "convert", 0);
27329
+ const from = asString$1(args[1], "convert", 1).trim();
27330
+ const to = asString$1(args[2], "convert", 2).trim();
27331
+ if (hooks.convert) {
27332
+ const out = hooks.convert(x, from, to);
27333
+ if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
27334
+ return finiteResult(out, "convert");
27335
+ }
27336
+ if (from === to) return x;
27337
+ throw new ExpressionEvalError("convert: unit conversion table not installed");
27338
+ }
27339
+ }
27340
+ };
27341
+ Object.freeze(Object.assign(Object.create(null), table));
27342
+ /** The set of valid builtin names — used by the parser to reject unknown
27343
+ * callees at parse time (immediate author feedback). */
27344
+ var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
27345
+ /**
27346
+ * Resource-bound constants for the safe expression engine.
27347
+ *
27348
+ * Every bound is defense-in-depth: the grammar is non-Turing-complete (no
27349
+ * loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
27350
+ * O(nodeCount) by construction. These caps merely put a hard ceiling on the
27351
+ * work a single author-supplied expression can request, so a hostile or
27352
+ * accidental pathological string can never spend unbounded CPU/memory.
27353
+ */
27354
+ /** Max source length (chars) — checked BEFORE tokenizing so a huge string is
27355
+ * rejected without allocation. */
27356
+ var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
27357
+ /** A legal binding / identifier name. */
27358
+ var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
27359
+ /** Binding names an author may NOT use: `now` is auto-injected; the literal
27360
+ * keywords lex as values, not identifiers, so binding to them is meaningless. */
27361
+ var RESERVED_BINDING_NAMES = new Set([
27362
+ "now",
27363
+ "true",
27364
+ "false",
27365
+ "null"
27366
+ ]);
27367
+ /**
27368
+ * Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
27369
+ * zero-dependency. The grammar is deliberately boring: decimal numbers,
27370
+ * single/double-quoted strings with a tiny escape set, identifiers, the three
27371
+ * value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
27372
+ * outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
27373
+ * is a parse error with a source position, so member access / assignment /
27374
+ * template literals are lexically impossible.
27375
+ */
27376
+ var KEYWORDS = new Set([
27377
+ "true",
27378
+ "false",
27379
+ "null"
27380
+ ]);
27381
+ function isDigit(ch) {
27382
+ return ch >= "0" && ch <= "9";
27383
+ }
27384
+ function isIdentStart(ch) {
27385
+ return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
27386
+ }
27387
+ function isIdentPart(ch) {
27388
+ return isIdentStart(ch) || isDigit(ch);
27389
+ }
27390
+ function isWhitespace(ch) {
27391
+ return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
27392
+ }
27393
+ /** Tokenize `source` into a flat token list ending with a single `eof` token.
27394
+ * Throws `ExpressionParseError` on any illegal character or unterminated
27395
+ * string. */
27396
+ function tokenize(source) {
27397
+ if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
27398
+ const tokens = [];
27399
+ let i = 0;
27400
+ const n = source.length;
27401
+ while (i < n) {
27402
+ const ch = source[i];
27403
+ if (isWhitespace(ch)) {
27404
+ i += 1;
27405
+ continue;
27406
+ }
27407
+ if (isDigit(ch)) {
27408
+ const start = i;
27409
+ while (i < n && isDigit(source[i])) i += 1;
27410
+ if (i < n && source[i] === ".") {
27411
+ if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
27412
+ i += 1;
27413
+ while (i < n && isDigit(source[i])) i += 1;
27414
+ }
27415
+ const text = source.slice(start, i);
27416
+ const value = Number(text);
27417
+ if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
27418
+ tokens.push({
27419
+ type: "number",
27420
+ value,
27421
+ pos: start
27422
+ });
27423
+ continue;
27424
+ }
27425
+ if (ch === "'" || ch === "\"") {
27426
+ const quote = ch;
27427
+ const start = i;
27428
+ i += 1;
27429
+ let out = "";
27430
+ let closed = false;
27431
+ while (i < n) {
27432
+ const c = source[i];
27433
+ if (c === "\\") {
27434
+ const next = i + 1 < n ? source[i + 1] : "";
27435
+ if (next === "\\" || next === "'" || next === "\"") {
27436
+ out += next;
27437
+ i += 2;
27438
+ continue;
27439
+ }
27440
+ throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
27441
+ }
27442
+ if (c === quote) {
27443
+ closed = true;
27444
+ i += 1;
27445
+ break;
27446
+ }
27447
+ out += c;
27448
+ i += 1;
27449
+ }
27450
+ if (!closed) throw new ExpressionParseError("unterminated string literal", start);
27451
+ tokens.push({
27452
+ type: "string",
27453
+ value: out,
27454
+ pos: start
27455
+ });
27456
+ continue;
27457
+ }
27458
+ if (isIdentStart(ch)) {
27459
+ const start = i;
27460
+ while (i < n && isIdentPart(source[i])) i += 1;
27461
+ const text = source.slice(start, i);
27462
+ if (KEYWORDS.has(text)) tokens.push({
27463
+ type: "keyword",
27464
+ keyword: keywordOf(text),
27465
+ pos: start
27466
+ });
27467
+ else tokens.push({
27468
+ type: "identifier",
27469
+ name: text,
27470
+ pos: start
27471
+ });
27472
+ continue;
27473
+ }
27474
+ const two = i + 1 < n ? source.slice(i, i + 2) : "";
27475
+ if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
27476
+ tokens.push({
27477
+ type: "punct",
27478
+ punct: two,
27479
+ pos: i
27480
+ });
27481
+ i += 2;
27482
+ continue;
27483
+ }
27484
+ if (isSinglePunct(ch)) {
27485
+ tokens.push({
27486
+ type: "punct",
27487
+ punct: ch,
27488
+ pos: i
27489
+ });
27490
+ i += 1;
27491
+ continue;
27492
+ }
27493
+ throw new ExpressionParseError(`unexpected character '${ch}'`, i);
27494
+ }
27495
+ tokens.push({
27496
+ type: "eof",
27497
+ pos: n
27498
+ });
27499
+ return tokens;
27500
+ }
27501
+ function keywordOf(text) {
27502
+ if (text === "true") return "true";
27503
+ if (text === "false") return "false";
27504
+ return "null";
27505
+ }
27506
+ function isSinglePunct(ch) {
27507
+ return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
27508
+ }
27509
+ /**
27510
+ * Pratt (precedence-climbing) parser for the safe expression mini-language.
27511
+ *
27512
+ * Precedence (low → high): ternary `?:` (right-assoc) → `||` → `&&` → equality
27513
+ * → relational → additive → multiplicative → unary `! -` → call / primary.
27514
+ * Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
27515
+ * string validated against the builtin table at parse time, so an unknown
27516
+ * function is rejected immediately (author feedback) and a persisted expression
27517
+ * that references a since-removed builtin degrades at read.
27518
+ *
27519
+ * A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
27520
+ * arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
27521
+ */
27522
+ /** Binary/logical operator precedence (higher binds tighter). */
27523
+ var BINARY_PRECEDENCE = {
27524
+ "||": 1,
27525
+ "&&": 2,
27526
+ "==": 3,
27527
+ "!=": 3,
27528
+ "<": 4,
27529
+ "<=": 4,
27530
+ ">": 4,
27531
+ ">=": 4,
27532
+ "+": 5,
27533
+ "-": 5,
27534
+ "*": 6,
27535
+ "/": 6,
27536
+ "%": 6
27537
+ };
27538
+ function isLogicalOp(op) {
27539
+ return op === "&&" || op === "||";
27540
+ }
27541
+ function isBinaryOp(op) {
27542
+ return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
27543
+ }
27544
+ var Parser = class {
27545
+ tokens;
27546
+ pos = 0;
27547
+ nodeCount = 0;
27548
+ identifiers = /* @__PURE__ */ new Set();
27549
+ callees = /* @__PURE__ */ new Set();
27550
+ constructor(tokens) {
27551
+ this.tokens = tokens;
27552
+ }
27553
+ parse() {
27554
+ const ast = this.parseTernary();
27555
+ const tok = this.peek();
27556
+ if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
27557
+ return {
27558
+ ast,
27559
+ identifiers: this.identifiers,
27560
+ callees: this.callees,
27561
+ nodeCount: this.nodeCount
27562
+ };
27563
+ }
27564
+ peek() {
27565
+ return this.tokens[this.pos];
27566
+ }
27567
+ next() {
27568
+ return this.tokens[this.pos++];
27569
+ }
27570
+ /** Consume a punctuator token, erroring if the next token isn't it. */
27571
+ expectPunct(punct) {
27572
+ const tok = this.peek();
27573
+ if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
27574
+ this.pos += 1;
27575
+ }
27576
+ matchPunct(punct) {
27577
+ const tok = this.peek();
27578
+ if (tok.type === "punct" && tok.punct === punct) {
27579
+ this.pos += 1;
27580
+ return true;
27581
+ }
27582
+ return false;
27803
27583
  }
27804
- /**
27805
- * Typed read-through to a cap-keyed runtime-state slice. Drivers
27806
- * call `this.getCapSlice(batteryCapability)` and the return type
27807
- * is inferred from the cap's `runtimeState` Zod schema — no string
27808
- * key, no manual generic. Returns `null` when the slice hasn't
27809
- * been written yet (e.g. driver hasn't seeded battery yet).
27810
- */
27811
- getCapSlice(cap) {
27812
- return this.runtimeState.getCapState(cap.name) ?? null;
27584
+ countNode() {
27585
+ this.nodeCount += 1;
27586
+ if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
27813
27587
  }
27814
- /**
27815
- * Typed writer to a cap-keyed runtime-state slice. Routes through
27816
- * the runtime-state writer (validate → persist → emit cap event).
27817
- * Equivalent to `this.runtimeState.setCapState(cap.name, value)`
27818
- * but with the cap's `runtimeState` schema enforcing the value
27819
- * shape at compile time. Mirrors the symmetry of
27820
- * `getCapSlice` / `setCapSlice` for cross-cap consistency.
27821
- */
27822
- setCapSlice(cap, value) {
27823
- this.runtimeState.setCapState(cap.name, value);
27588
+ parseTernary() {
27589
+ const test = this.parseBinary(1);
27590
+ if (this.matchPunct("?")) {
27591
+ const consequent = this.parseTernary();
27592
+ this.expectPunct(":");
27593
+ const alternate = this.parseTernary();
27594
+ this.countNode();
27595
+ return {
27596
+ kind: "conditional",
27597
+ test,
27598
+ consequent,
27599
+ alternate
27600
+ };
27601
+ }
27602
+ return test;
27824
27603
  }
27825
- /**
27826
- * Field-level read/write proxy over a cap's runtime-state slice.
27827
- * Drivers that want ergonomic per-field access declare:
27828
- *
27829
- * ```ts
27830
- * protected battery = this.sliceProxy(batteryCapability)
27831
- * // …
27832
- * this.battery.sleeping = true // patches the slice
27833
- * const charging = this.battery.charging // reads the slice
27834
- * ```
27835
- *
27836
- * Reads return `undefined` when the slice hasn't been seeded yet
27837
- * (cap not registered, or seeded but the field is absent). Writes
27838
- * route through `runtimeState.patchCapState` so the cap's `runtimeState`
27839
- * schema validates the merged result and the cap event fires.
27840
- *
27841
- * Pattern is generic — same shape works for `battery`, `device-status`,
27842
- * `motion`, `doorbell`, anything with a `runtimeState:` schema. Drivers
27843
- * declare one proxy per cap they read/write directly.
27844
- */
27845
- sliceProxy(cap) {
27846
- return new Proxy({}, {
27847
- get: (_, key) => {
27848
- return this.runtimeState.getCapState(cap.name)?.[key];
27849
- },
27850
- set: (_, key, value) => {
27851
- this.runtimeState.patchCapState(cap.name, { [key]: value });
27852
- return true;
27853
- },
27854
- has: (_, key) => {
27855
- const slice = this.runtimeState.getCapState(cap.name);
27856
- return slice ? key in slice : false;
27857
- },
27858
- ownKeys: () => {
27859
- const slice = this.runtimeState.getCapState(cap.name);
27860
- return slice ? Object.keys(slice) : [];
27861
- },
27862
- getOwnPropertyDescriptor: (_, key) => {
27863
- const slice = this.runtimeState.getCapState(cap.name);
27864
- if (!slice || !(key in slice)) return void 0;
27604
+ parseBinary(minPrec) {
27605
+ let left = this.parseUnary();
27606
+ for (;;) {
27607
+ const tok = this.peek();
27608
+ if (tok.type !== "punct") break;
27609
+ const prec = BINARY_PRECEDENCE[tok.punct];
27610
+ if (prec === void 0 || prec < minPrec) break;
27611
+ const op = tok.punct;
27612
+ this.pos += 1;
27613
+ const right = this.parseBinary(prec + 1);
27614
+ this.countNode();
27615
+ if (isLogicalOp(op)) left = {
27616
+ kind: "logical",
27617
+ op,
27618
+ left,
27619
+ right
27620
+ };
27621
+ else if (isBinaryOp(op)) left = {
27622
+ kind: "binary",
27623
+ op,
27624
+ left,
27625
+ right
27626
+ };
27627
+ else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
27628
+ }
27629
+ return left;
27630
+ }
27631
+ parseUnary() {
27632
+ const tok = this.peek();
27633
+ if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
27634
+ const op = tok.punct;
27635
+ this.pos += 1;
27636
+ const operand = this.parseUnary();
27637
+ this.countNode();
27638
+ return {
27639
+ kind: "unary",
27640
+ op,
27641
+ operand
27642
+ };
27643
+ }
27644
+ return this.parsePrimary();
27645
+ }
27646
+ parsePrimary() {
27647
+ const tok = this.next();
27648
+ switch (tok.type) {
27649
+ case "number":
27650
+ this.countNode();
27865
27651
  return {
27866
- configurable: true,
27867
- enumerable: true,
27868
- value: slice[key]
27652
+ kind: "literal",
27653
+ value: tok.value
27654
+ };
27655
+ case "string":
27656
+ this.countNode();
27657
+ return {
27658
+ kind: "literal",
27659
+ value: tok.value
27660
+ };
27661
+ case "keyword":
27662
+ this.countNode();
27663
+ return {
27664
+ kind: "literal",
27665
+ value: tok.keyword === "null" ? null : tok.keyword === "true"
27666
+ };
27667
+ case "identifier": {
27668
+ const nextTok = this.peek();
27669
+ if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
27670
+ this.identifiers.add(tok.name);
27671
+ this.countNode();
27672
+ return {
27673
+ kind: "identifier",
27674
+ name: tok.name
27869
27675
  };
27870
27676
  }
27871
- });
27677
+ case "punct":
27678
+ if (tok.punct === "(") {
27679
+ const inner = this.parseTernary();
27680
+ this.expectPunct(")");
27681
+ return inner;
27682
+ }
27683
+ throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
27684
+ case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
27685
+ }
27872
27686
  }
27873
- /**
27874
- * Default empty settings UI. Drivers override this to expose an
27875
- * editable form in the device-details page. Returning an empty sections
27876
- * array signals "nothing to contribute" — the aggregator drops the
27877
- * contribution entirely rather than rendering a blank panel.
27878
- */
27879
- getSettingsUISchema() {
27880
- return { sections: [] };
27687
+ parseCall(callee, pos) {
27688
+ if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
27689
+ this.expectPunct("(");
27690
+ const args = [];
27691
+ if (!this.matchPunct(")")) for (;;) {
27692
+ args.push(this.parseTernary());
27693
+ if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
27694
+ if (this.matchPunct(",")) continue;
27695
+ this.expectPunct(")");
27696
+ break;
27697
+ }
27698
+ this.callees.add(callee);
27699
+ this.countNode();
27700
+ return {
27701
+ kind: "call",
27702
+ callee,
27703
+ args
27704
+ };
27881
27705
  }
27882
- /**
27883
- * Default write path: forward the flat patch directly to storage.
27884
- * Drivers that project a UI shape different from storage (e.g. `RtspCamera`
27885
- * exposing `mainStreamUrl`/`subStreamUrl` over `streams[]`) override this
27886
- * to reshape before `config.setAll`.
27887
- */
27888
- async applySettingsPatch(patch) {
27889
- await this.config.setAll(patch);
27706
+ };
27707
+ /** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
27708
+ * `ExpressionParseError` on any lexical or grammatical failure. */
27709
+ function parseExpression(source) {
27710
+ return new Parser(tokenize(source)).parse();
27711
+ }
27712
+ /**
27713
+ * LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
27714
+ * by expr"). The cache stores BOTH successes and failures (negative caching),
27715
+ * so a corrupt persisted string costs exactly one tokenize+parse total — not
27716
+ * one per read on a hot resolve path.
27717
+ *
27718
+ * The cache is a module-level singleton: entries are pure, content-addressed
27719
+ * ASTs keyed by the raw source string, so sharing one instance across all
27720
+ * callers is safe and maximises hit rate.
27721
+ */
27722
+ var cache = /* @__PURE__ */ new Map();
27723
+ function getCached(source) {
27724
+ const hit = cache.get(source);
27725
+ if (hit !== void 0) {
27726
+ cache.delete(source);
27727
+ cache.set(source, hit);
27728
+ return hit;
27890
27729
  }
27891
- /**
27892
- * Phase 3 — populate device-scoped state needed by downstream phases
27893
- * (accessory reconciliation, public `features` array, optional cap
27894
- * registration). Called ONCE per construction, after register but
27895
- * before `getAccessoryChildren()`.
27896
- *
27897
- * Drivers write the `feature-probe` runtime-state slice via
27898
- * `this.runtimeState.setCapState('feature-probe', {...})` — flag bag
27899
- * is open (Reolink writes `hasPtz/hasIntercom`, Hikvision writes
27900
- * `hasSupplementalLight/hasAlarmIo`, etc).
27901
- *
27902
- * Default: nothing to probe → mark the device PROBED (set `lastProbedAt`) so
27903
- * the kernel treats it as ready immediately. A device that derives its shape
27904
- * from a spec (a container, or an accessory sensor) rather than from a
27905
- * hardware probe has no probe to "complete"; without stamping `lastProbedAt`
27906
- * it would look perpetually un-probed — logging "Initial probe did not
27907
- * complete" on every boot and spinning a pointless retry chain. Drivers that
27908
- * DO probe override this and write their own `feature-probe` slice (including
27909
- * `lastProbedAt`) once their probe actually succeeds.
27910
- */
27911
- async onProbe() {
27912
- const base = this.runtimeState.getCapState("feature-probe") ?? {
27913
- flags: {},
27914
- deviceType: null,
27915
- model: null,
27916
- channelCount: null,
27917
- lastProbedAt: 0,
27918
- lastFetchedAt: 0
27730
+ let result;
27731
+ try {
27732
+ result = {
27733
+ ok: true,
27734
+ parsed: parseExpression(source)
27735
+ };
27736
+ } catch (err) {
27737
+ result = {
27738
+ ok: false,
27739
+ error: err instanceof ExpressionParseError ? err.message : String(err)
27919
27740
  };
27920
- this.runtimeState.setCapState("feature-probe", {
27921
- ...base,
27922
- lastProbedAt: Date.now()
27923
- });
27924
- }
27925
- /**
27926
- * Phase 5 — fired after the device + its accessories are registered.
27927
- * Drivers publish streams to the broker, kick off background tasks,
27928
- * or subscribe to lib events that need a fully-registered device id.
27929
- *
27930
- * Default: no-op.
27931
- *
27932
- * RENAMED FROM `onCreated` (which still exists for back-compat in this
27933
- * pass). The new name reflects the post-probe, post-accessory contract.
27934
- */
27935
- async onActivate() {}
27936
- /**
27937
- * Re-run the probe + reconcile accessories + refresh features meta.
27938
- * Drivers call this when device-side state changes (battery cam wakes,
27939
- * firmware update, manual operator trigger).
27940
- *
27941
- * The kernel injects `_kernelReprobe` on registration so this method
27942
- * delegates to the same orchestrator that runs the boot-time phase
27943
- * 3 + 4 sequence. Drivers should NOT override this — they override
27944
- * `onProbe()` instead.
27945
- */
27946
- async reprobe() {
27947
- if (this._kernelReprobe) await this._kernelReprobe();
27948
- else await this.onProbe();
27949
27741
  }
27950
- /**
27951
- * Kernel-injected callback that runs the full post-probe orchestration
27952
- * (onProbe → registerDevice meta refresh → accessory reconciliation).
27953
- * Set by `device-cap-proxy.register()`. Drivers should not touch this
27954
- * directly — call `reprobe()` instead.
27955
- */
27956
- _kernelReprobe;
27957
- /**
27958
- * Declare accessory child devices the kernel should auto-spawn
27959
- * after `onProbe()` resolves. Each spec fully describes one child
27960
- * — stableId suffix (deterministic per kind for restore-safety),
27961
- * meta (type / name / location), config (initial blob the child
27962
- * self-hydrates), and a factory that constructs the concrete
27963
- * class with whatever closure-captured refs it needs (typically
27964
- * `this` for the parent reference).
27965
- *
27966
- * The kernel handles the rest: allocateDeviceId, persistInitialConfig
27967
- * (skipped on restore when the row already exists),
27968
- * persistInitialMeta, createContext, factory invocation, register,
27969
- * and recursive lifecycle (probe + accessories + activate).
27970
- *
27971
- * Implementations should derive children from
27972
- * `this.runtimeState.getCapState('feature-probe')` (post-probe truth).
27973
- * Drivers can use the `getProbeFlags()` helper to read the flag bag
27974
- * with a typed cast.
27975
- *
27976
- * Default: no children.
27977
- */
27978
- getAccessoryChildren() {
27979
- return [];
27742
+ cache.set(source, result);
27743
+ if (cache.size > 256) {
27744
+ const oldest = cache.keys().next().value;
27745
+ if (oldest !== void 0) cache.delete(oldest);
27980
27746
  }
27981
- /**
27982
- * Read the current feature-probe flag bag with a typed cast. Helper
27983
- * for `getAccessoryChildren()` and `features` getters that derive
27984
- * outputs from the probe results.
27985
- */
27986
- getProbeFlags() {
27987
- return this.runtimeState.getCapState("feature-probe")?.flags ?? {};
27747
+ return result;
27748
+ }
27749
+ /** Compile `source`, returning a discriminated result instead of throwing.
27750
+ * Used by read paths that must degrade rather than raise. LRU/negative-cached. */
27751
+ function compileExpressionSafe(source) {
27752
+ return getCached(source);
27753
+ }
27754
+ Object.freeze({});
27755
+ /**
27756
+ * Author-time validation. Returns `null` when the source is valid, else a
27757
+ * human-readable error message. Checks: the expression compiles; binding count
27758
+ * is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
27759
+ * is not reserved (`now`/keywords) and does not shadow a builtin; and every
27760
+ * FREE identifier of the AST is covered by a binding or the injected `now`.
27761
+ */
27762
+ function validateExpressionSource(src) {
27763
+ const names = Object.keys(src.bindings);
27764
+ if (names.length > 32) return `too many bindings (${names.length} > 32)`;
27765
+ for (const name of names) {
27766
+ if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
27767
+ if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
27768
+ if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
27988
27769
  }
27989
- /**
27990
- * Returns true once `onProbe` has completed at least once
27991
- * (`lastProbedAt > 0`). Drivers gate `getAccessoryChildren()` on this
27992
- * to avoid spawning stale accessories on a fresh device whose probe
27993
- * hasn't landed yet.
27994
- */
27995
- hasProbed() {
27996
- return (this.runtimeState.getCapState("feature-probe")?.lastProbedAt ?? 0) > 0;
27770
+ const compiled = compileExpressionSafe(src.expr);
27771
+ if (!compiled.ok) return compiled.error;
27772
+ const bound = new Set(names);
27773
+ for (const id of compiled.parsed.identifiers) {
27774
+ if (id === "now") continue;
27775
+ if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
27997
27776
  }
27998
- };
27999
- 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;
28000
- new Set(Object.values(DeviceType));
28001
- DeviceFeature.BatteryOperated;
27777
+ return null;
27778
+ }
27779
+ var ExpressionBindingSourceSchema = union([
27780
+ object({
27781
+ kind: literal("field").optional(),
27782
+ sourceKey: string(),
27783
+ cap: string(),
27784
+ fieldPath: string()
27785
+ }),
27786
+ object({
27787
+ kind: literal("literal"),
27788
+ value: union([
27789
+ string(),
27790
+ number(),
27791
+ boolean(),
27792
+ _null()
27793
+ ])
27794
+ }),
27795
+ object({
27796
+ kind: literal("global"),
27797
+ sourceStableId: string(),
27798
+ cap: string(),
27799
+ fieldPath: string()
27800
+ })
27801
+ ]);
27802
+ object({
27803
+ expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
27804
+ bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
27805
+ }).superRefine((src, ctx) => {
27806
+ const err = validateExpressionSource(src);
27807
+ if (err !== null) ctx.addIssue({
27808
+ code: "custom",
27809
+ message: err,
27810
+ path: ["expr"]
27811
+ });
27812
+ });
28002
27813
  Object.freeze({
28003
27814
  "accessories.setChildHidden": {
28004
27815
  capName: "accessories",
@@ -28822,6 +28633,12 @@ Object.freeze({
28822
28633
  addonId: null,
28823
28634
  access: "view"
28824
28635
  },
28636
+ "coreBlocks.restart": {
28637
+ capName: "core-blocks",
28638
+ capScope: "system",
28639
+ addonId: null,
28640
+ access: "create"
28641
+ },
28825
28642
  "coreBlocks.setEnabled": {
28826
28643
  capName: "core-blocks",
28827
28644
  capScope: "system",
@@ -29458,12 +29275,6 @@ Object.freeze({
29458
29275
  addonId: null,
29459
29276
  access: "create"
29460
29277
  },
29461
- "deviceManager.setDeviceLinks": {
29462
- capName: "device-manager",
29463
- capScope: "system",
29464
- addonId: null,
29465
- access: "create"
29466
- },
29467
29278
  "deviceManager.setDisabled": {
29468
29279
  capName: "device-manager",
29469
29280
  capScope: "system",
@@ -32608,6 +32419,12 @@ Object.freeze({
32608
32419
  addonId: null,
32609
32420
  access: "create"
32610
32421
  },
32422
+ "streamBroker.fetchEventMedia": {
32423
+ capName: "stream-broker",
32424
+ capScope: "system",
32425
+ addonId: null,
32426
+ access: "create"
32427
+ },
32611
32428
  "streamBroker.getAllRtspEntries": {
32612
32429
  capName: "stream-broker",
32613
32430
  capScope: "system",
@@ -32692,6 +32509,12 @@ Object.freeze({
32692
32509
  addonId: null,
32693
32510
  access: "create"
32694
32511
  },
32512
+ "streamBroker.produceEventMedia": {
32513
+ capName: "stream-broker",
32514
+ capScope: "system",
32515
+ addonId: null,
32516
+ access: "create"
32517
+ },
32695
32518
  "streamBroker.publishCameraStream": {
32696
32519
  capName: "stream-broker",
32697
32520
  capScope: "system",
@@ -33549,51 +33372,6 @@ var TimelapseRuleSchema = TimelapseRuleInputSchema.extend({
33549
33372
  createdAt: number(),
33550
33373
  updatedAt: number()
33551
33374
  });
33552
- /** Cosine similarity between two embedding vectors */
33553
- function cosineSimilarity(a, b) {
33554
- if (a.length !== b.length) return 0;
33555
- let dotProduct = 0;
33556
- let normA = 0;
33557
- let normB = 0;
33558
- for (let i = 0; i < a.length; i++) {
33559
- dotProduct += a[i] * b[i];
33560
- normA += a[i] * a[i];
33561
- normB += b[i] * b[i];
33562
- }
33563
- const denom = Math.sqrt(normA) * Math.sqrt(normB);
33564
- return denom === 0 ? 0 : dotProduct / denom;
33565
- }
33566
- function hfModelUrl(repo, path) {
33567
- return `https://huggingface.co/${repo}/resolve/main/${path}`;
33568
- }
33569
- /**
33570
- * Vector wire codec — base64 of little-endian Float32.
33571
- *
33572
- * The `vector-store` capability carries vectors as base64 rather than
33573
- * `number[]` or a typed array, and both rejected alternatives have a scar here:
33574
- *
33575
- * - a `Float32Array` does NOT survive msgpack across the UDS transport (it
33576
- * arrived as an empty object and froze audio dBFS until the payload was
33577
- * changed to carry bytes);
33578
- * - `number[]` is the ~5x-larger encoding the capability exists to stop paying
33579
- * — a 512-dim vector is 2,048 bytes raw and 2,732 as base64, against roughly
33580
- * 10-12 KB rendered as JSON text.
33581
- *
33582
- * Endianness is pinned to little-endian explicitly instead of inheriting the
33583
- * platform's, so a vector written on one node decodes correctly on another.
33584
- */
33585
- /** Encode a vector as base64 of little-endian Float32. */
33586
- function encodeVectorBase64(vector) {
33587
- const floats = vector instanceof Float32Array ? vector : Float32Array.from(vector);
33588
- const bytes = new Uint8Array(floats.length * 4);
33589
- const view = new DataView(bytes.buffer);
33590
- for (let i = 0; i < floats.length; i += 1) view.setFloat32(i * 4, floats[i] ?? 0, true);
33591
- return Buffer.from(bytes).toString("base64");
33592
- }
33593
- /** Vector length implied by a base64 payload, without decoding it. */
33594
- function vectorDimFromBase64(encoded) {
33595
- return Math.floor(Buffer.from(encoded, "base64").byteLength / 4);
33596
- }
33597
33375
  object({
33598
33376
  /**
33599
33377
  * Fraction of the box's own size added on EACH side before cutting.
@@ -33700,6 +33478,51 @@ DEFAULT_NATIVE_LEASE_SETTINGS.ttlMs;
33700
33478
  DEFAULT_NATIVE_LEASE_SETTINGS.budgetMb;
33701
33479
  DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
33702
33480
  DEFAULT_NATIVE_LEASE_SETTINGS.admission;
33481
+ /** Cosine similarity between two embedding vectors */
33482
+ function cosineSimilarity(a, b) {
33483
+ if (a.length !== b.length) return 0;
33484
+ let dotProduct = 0;
33485
+ let normA = 0;
33486
+ let normB = 0;
33487
+ for (let i = 0; i < a.length; i++) {
33488
+ dotProduct += a[i] * b[i];
33489
+ normA += a[i] * a[i];
33490
+ normB += b[i] * b[i];
33491
+ }
33492
+ const denom = Math.sqrt(normA) * Math.sqrt(normB);
33493
+ return denom === 0 ? 0 : dotProduct / denom;
33494
+ }
33495
+ function hfModelUrl(repo, path) {
33496
+ return `https://huggingface.co/${repo}/resolve/main/${path}`;
33497
+ }
33498
+ /**
33499
+ * Vector wire codec — base64 of little-endian Float32.
33500
+ *
33501
+ * The `vector-store` capability carries vectors as base64 rather than
33502
+ * `number[]` or a typed array, and both rejected alternatives have a scar here:
33503
+ *
33504
+ * - a `Float32Array` does NOT survive msgpack across the UDS transport (it
33505
+ * arrived as an empty object and froze audio dBFS until the payload was
33506
+ * changed to carry bytes);
33507
+ * - `number[]` is the ~5x-larger encoding the capability exists to stop paying
33508
+ * — a 512-dim vector is 2,048 bytes raw and 2,732 as base64, against roughly
33509
+ * 10-12 KB rendered as JSON text.
33510
+ *
33511
+ * Endianness is pinned to little-endian explicitly instead of inheriting the
33512
+ * platform's, so a vector written on one node decodes correctly on another.
33513
+ */
33514
+ /** Encode a vector as base64 of little-endian Float32. */
33515
+ function encodeVectorBase64(vector) {
33516
+ const floats = vector instanceof Float32Array ? vector : Float32Array.from(vector);
33517
+ const bytes = new Uint8Array(floats.length * 4);
33518
+ const view = new DataView(bytes.buffer);
33519
+ for (let i = 0; i < floats.length; i += 1) view.setFloat32(i * 4, floats[i] ?? 0, true);
33520
+ return Buffer.from(bytes).toString("base64");
33521
+ }
33522
+ /** Vector length implied by a base64 payload, without decoding it. */
33523
+ function vectorDimFromBase64(encoded) {
33524
+ return Math.floor(Buffer.from(encoded, "base64").byteLength / 4);
33525
+ }
33703
33526
  //#endregion
33704
33527
  Object.defineProperty(exports, "BaseAddon", {
33705
33528
  enumerable: true,
@@ -33719,6 +33542,12 @@ Object.defineProperty(exports, "DEFAULT_EVENT_COLOR", {
33719
33542
  return DEFAULT_EVENT_COLOR;
33720
33543
  }
33721
33544
  });
33545
+ Object.defineProperty(exports, "DeclaredDevices", {
33546
+ enumerable: true,
33547
+ get: function() {
33548
+ return DeclaredDevices;
33549
+ }
33550
+ });
33722
33551
  Object.defineProperty(exports, "DeviceType", {
33723
33552
  enumerable: true,
33724
33553
  get: function() {
@@ -33743,12 +33572,6 @@ Object.defineProperty(exports, "EventCategory", {
33743
33572
  return EventCategory;
33744
33573
  }
33745
33574
  });
33746
- Object.defineProperty(exports, "Fmp4BoxSplitter", {
33747
- enumerable: true,
33748
- get: function() {
33749
- return Fmp4BoxSplitter;
33750
- }
33751
- });
33752
33575
  Object.defineProperty(exports, "LabelAttributionSchema", {
33753
33576
  enumerable: true,
33754
33577
  get: function() {
@@ -33899,12 +33722,6 @@ Object.defineProperty(exports, "buildEventKindDescriptor", {
33899
33722
  return buildEventKindDescriptor;
33900
33723
  }
33901
33724
  });
33902
- Object.defineProperty(exports, "buildFfmpegArgs", {
33903
- enumerable: true,
33904
- get: function() {
33905
- return buildFfmpegArgs;
33906
- }
33907
- });
33908
33725
  Object.defineProperty(exports, "cosineSimilarity", {
33909
33726
  enumerable: true,
33910
33727
  get: function() {
@@ -33977,12 +33794,6 @@ Object.defineProperty(exports, "isScheduleActive", {
33977
33794
  return isScheduleActive;
33978
33795
  }
33979
33796
  });
33980
- Object.defineProperty(exports, "isSoftwareDecode", {
33981
- enumerable: true,
33982
- get: function() {
33983
- return isSoftwareDecode;
33984
- }
33985
- });
33986
33797
  Object.defineProperty(exports, "kebabToCamel", {
33987
33798
  enumerable: true,
33988
33799
  get: function() {