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