@camstack/addon-post-analysis 1.2.48 → 1.2.50

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.
@@ -6482,7 +6482,20 @@ var BrokerStatsSchema = object({
6482
6482
  sampleRate: number(),
6483
6483
  channels: number(),
6484
6484
  supported: boolean()
6485
- }).nullable().optional()
6485
+ }).nullable().optional(),
6486
+ /**
6487
+ * BROKER-SIDE AUDIO MUTE (D83). `true` = this broker is deliberately
6488
+ * distributing none of the device's audio, on live or recording.
6489
+ *
6490
+ * Present so a silent camera can be told apart from a broken one on the
6491
+ * stream panel itself, without cross-referencing the switch group: a
6492
+ * broker holding an `audio` track descriptor while `audioMuted` is true is
6493
+ * working exactly as asked. `audioMutedDropped` counts the audio units
6494
+ * thrown away since the current dial — it is how you confirm from stats
6495
+ * alone that the mute is on the packet path and not merely persisted.
6496
+ */
6497
+ audioMuted: boolean().optional(),
6498
+ audioMutedDropped: number().optional()
6486
6499
  });
6487
6500
  /**
6488
6501
  * Exporter-facing "profile restream" entry. Returned by
@@ -6531,9 +6544,38 @@ var CAP_NODE_PIN_CONTEXT_KEY = "__camstackNodePin";
6531
6544
  /**
6532
6545
  * Build the tRPC request options that pin a single capability call to `nodeId`.
6533
6546
  * Pass as the second argument to `.query(input, …)` / `.mutate(input, …)`.
6547
+ *
6548
+ * ## The id is normalised here, and it has to be
6549
+ *
6550
+ * A forked addon reads its own node from `ctx.kernel.localNodeId`, and inside a
6551
+ * worker that value is a RUNNER id — `hub/export-hap`, not `hub`. Routing
6552
+ * compares a pin against real node ids, so such a pin matches nothing and the
6553
+ * call fails with `no provider registered for cap "…"`. The local-first
6554
+ * resolver already guarded against this (`localNodeId.split('/')[0]`), which
6555
+ * made the hazard invisible: unpinned calls worked, and only an explicit pin —
6556
+ * the thing you reach for when you specifically need THIS node — silently
6557
+ * addressed a node that does not exist.
6558
+ *
6559
+ * Cost of it being missing: `addon-export-hap` pinned `decoder.getInfo` to its
6560
+ * own node to read the host's hardware-decode backend. It never once answered,
6561
+ * so every HomeKit egress transcode decoded in SOFTWARE — including 4K H.265 —
6562
+ * while D67's whole premise was that the decoder addon is the authority on
6563
+ * hardware. The warn said `decoding in SOFTWARE` and read as "this node has no
6564
+ * hardware", which was false.
6565
+ *
6566
+ * Normalising in the ONE constructor fixes every caller at once, which is why
6567
+ * it is here and not at the call sites.
6534
6568
  */
6535
6569
  function nodePin(nodeId) {
6536
- return { context: { [CAP_NODE_PIN_CONTEXT_KEY]: nodeId } };
6570
+ return { context: { [CAP_NODE_PIN_CONTEXT_KEY]: toNodeId(nodeId) } };
6571
+ }
6572
+ /**
6573
+ * A runner id is `<nodeId>/<addonId>`; a node id has no slash. Taking the head
6574
+ * is idempotent, so passing an already-clean id costs nothing.
6575
+ */
6576
+ function toNodeId(idOrRunnerId) {
6577
+ const head = idOrRunnerId.split("/")[0];
6578
+ return head === void 0 || head.length === 0 ? idOrRunnerId : head;
6537
6579
  }
6538
6580
  /**
6539
6581
  * Output schema shared by the contribution + live methods.
@@ -7271,7 +7313,7 @@ object({
7271
7313
  * ## This file adds no state
7272
7314
  *
7273
7315
  * Every switch here is a VIEW onto an authority that already existed
7274
- * ([D61](../../../../docs/decisions/adr-0062.md)). The whole point of the
7316
+ * ([D62](../../../../docs/decisions/adr-0062.md)). The whole point of the
7275
7317
  * group is that there is exactly one place each function is turned off, and
7276
7318
  * the group routes to it:
7277
7319
  *
@@ -7282,6 +7324,53 @@ object({
7282
7324
  * | `audio-analysis` | `deviceManager.setWrapperActive('audio-analysis')` | `AudioSubscriptionController.subscribeAudioStream` returns `null` before opening the stream |
7283
7325
  * | `recording` | `recording.setDeviceConfig` → `RecordingConfig.enabled` | `band-decision.shouldRecord` returns false; the controller detaches the device |
7284
7326
  * | `notifications` | `notificationRules.setDeviceMuted` | `NotificationCenter.evaluateAndEnqueue` returns before any rule is evaluated |
7327
+ * | `privacy-mask` | `privacyMask.setMask({ enabled })` → the CAMERA | the camera blanks the masked regions itself; every stream and recording carries the black boxes |
7328
+ * | `device-audio` | `privacyMask.setAudioEnabled` → the CAMERA | the camera stops encoding an audio track at all; every consumer sees silent video |
7329
+ * | `broker-audio` | `streamBroker.setDeviceAudioMute` → `DeviceOverride.audioMuted` | `StreamBroker.setAudioMuted` drops the audio plane at the source: no `type:'audio'` packet leaves `fanOutEncoded`, no RTP reaches the restreamer, and the restreamer serves the video-only SDP |
7330
+ *
7331
+ * ## `device-audio` and `broker-audio` are two functions, not two knobs
7332
+ *
7333
+ * They look adjacent and they are not the same control ([D83](../../../../docs/decisions/adr-0083.md)):
7334
+ * `device-audio` writes the CAMERA, so it is hardware privacy — the microphone
7335
+ * genuinely stops, it survives CamStack entirely, and it costs a multi-second
7336
+ * encoder restart on every flip. `broker-audio` writes THIS server, so it is
7337
+ * instant, vendor-independent and reversible without touching the camera, and
7338
+ * a camera that ignores or lacks the ISAPI/Reolink control is still silenced.
7339
+ * D62 forbids a second switch that *disagrees* with the first; these two
7340
+ * cannot disagree, because neither reads the other's store — the camera holds
7341
+ * one, the broker holds the other, and each reports its own fact.
7342
+ *
7343
+ * ## The two switches whose authority is not on this server
7344
+ *
7345
+ * `privacy-mask` and `device-audio` write the CAMERA. That is not a loophole
7346
+ * in "the group stores nothing" — it is the purest form of it: the camera
7347
+ * holds the fact, every read is a read-through, and there is no server-side
7348
+ * copy that could drift. Their availability therefore cannot come from
7349
+ * `listBindableCapsForDeviceType` (a device-NATIVE cap carries no wrappers and
7350
+ * is filtered out there); it comes from the cap's own camera-probed
7351
+ * `privacyMask.getOptions()`, which is strictly more honest — it answers for
7352
+ * THIS camera rather than for the device type
7353
+ * ([D74](../../../../docs/decisions/adr-0074.md)).
7354
+ *
7355
+ * ## `privacy-mask` is the one row whose ON is not "the function is working"
7356
+ *
7357
+ * Every other switch means *this camera's function is doing its job*, so
7358
+ * `enabled: false` is a thing an operator took away. `privacy-mask` means **the
7359
+ * MASK is active** — `enabled: true` is video deliberately obscured. The
7360
+ * polarity is not a choice made here: `addon-export-hap`'s privacy `Switch`
7361
+ * (`builders/privacy-switch.ts`) already mirrors `patch.enabled` verbatim, and
7362
+ * a HomeKit toggle that disagreed with the app's toggle for the same camera is
7363
+ * worse than either surface not having one.
7364
+ *
7365
+ * Two consequences follow and both are load-bearing:
7366
+ *
7367
+ * - **It never counts as `switchedOff`.** `countsAsSwitchedOff` is `false` for
7368
+ * exactly this row. With the polarity above, every camera that has NOT drawn
7369
+ * a privacy mask would otherwise report `switchedOff: ['privacy-mask']` — the
7370
+ * normal, healthy state of most cameras rendered as an operator disablement.
7371
+ * - **Its cost line names BOTH directions.** `costWhenOff` is rendered
7372
+ * unconditionally by both clients, so for this row it has to read correctly
7373
+ * whichever way the switch is sitting.
7285
7374
  *
7286
7375
  * The wrapper-binding pair is not a new idea: `legacy-migrations.ts` already
7287
7376
  * migrated the legacy `audioEnabled` / `pipelineEnabled` /
@@ -7300,14 +7389,18 @@ object({
7300
7389
  * `CameraStatus.switchedOff`.
7301
7390
  */
7302
7391
  /**
7303
- * The five functions the operator named (2026-08-05). Deliberately NOT one id
7304
- * per pipeline step: face recognition and plate/LPR are per-step toggles on
7392
+ * The functions the operator named — five on 2026-08-05, plus the camera's own
7393
+ * microphone on 2026-08-07. Deliberately NOT one id per pipeline step: face
7394
+ * recognition and plate/LPR are per-step toggles on
7305
7395
  * `pipelineOrchestrator.setCameraStepToggle` and belong in the pipeline
7306
- * editor, not in a five-button safety group.
7396
+ * editor, not in a safety group.
7307
7397
  */
7308
7398
  var CameraSwitchIdSchema = _enum([
7309
7399
  "stream-broker",
7310
7400
  "object-detection",
7401
+ "privacy-mask",
7402
+ "device-audio",
7403
+ "broker-audio",
7311
7404
  "audio-analysis",
7312
7405
  "recording",
7313
7406
  "notifications"
@@ -7325,14 +7418,27 @@ var CameraSwitchAuthoritySchema = discriminatedUnion("kind", [
7325
7418
  capName: string()
7326
7419
  }),
7327
7420
  object({ kind: literal("recording-config") }),
7328
- object({ kind: literal("notification-mute") })
7421
+ object({ kind: literal("notification-mute") }),
7422
+ object({
7423
+ kind: literal("camera-audio"),
7424
+ capName: string()
7425
+ }),
7426
+ object({
7427
+ kind: literal("camera-mask"),
7428
+ capName: string()
7429
+ }),
7430
+ object({ kind: literal("broker-audio-mute") })
7329
7431
  ]);
7330
7432
  /**
7331
7433
  * Why a switch is not offered for this camera. Rendered instead of the
7332
7434
  * control, never as a dead control — an absent function and a broken one must
7333
7435
  * not look the same.
7334
7436
  */
7335
- var CameraSwitchUnavailableReasonSchema = _enum(["no-provider", "source-unreachable"]);
7437
+ var CameraSwitchUnavailableReasonSchema = _enum([
7438
+ "no-provider",
7439
+ "source-unreachable",
7440
+ "not-configured"
7441
+ ]);
7336
7442
  /**
7337
7443
  * One switch, resolved for one camera.
7338
7444
  *
@@ -9325,6 +9431,26 @@ var EgressTranscodeRequestSchema = object({
9325
9431
  "h264_mp4toannexb",
9326
9432
  "hevc_mp4toannexb"
9327
9433
  ]).optional(),
9434
+ /**
9435
+ * Publish the transcode as a LOCAL push cam stream, instead of leaving the
9436
+ * consumer to dial the returned url. The broker picks the id and returns it
9437
+ * as `camStreamId` — a caller-supplied one would be circular, since the
9438
+ * sharing key is computed FROM this request.
9439
+ *
9440
+ * The url is still returned and still the contract for a transcode pinned to
9441
+ * another node. But dialling it locally costs an RTSP round trip that changes
9442
+ * the transport underneath the consumer: a dialled stream is an RTP source,
9443
+ * so `isRtpSource()` is true and the session takes the RTP-passthrough +
9444
+ * repacketizer branch. The push branch — the one the derived mechanism has
9445
+ * live hours on — is never reached. Measured on Alexa: broker registered, RTP
9446
+ * arriving, key frame arriving, black screen, on a chain healthy at every
9447
+ * other point.
9448
+ *
9449
+ * Same idea the transport already applies to CALLS, where `classifyCapRoute`
9450
+ * gives priority to `hub-in-process` so a local call never leaves the node.
9451
+ * This is that rule for media.
9452
+ */
9453
+ publishLocally: boolean().optional(),
9328
9454
  pixelFormat: _enum(["yuv420p", "nv12"]).optional(),
9329
9455
  /**
9330
9456
  * Operator/consumer override for decode hardware. ABSENT is the normal case
@@ -9369,7 +9495,13 @@ var EgressTranscodeSchema = object({
9369
9495
  * Returned rather than assumed: a consumer that asked for hardware and got
9370
9496
  * software needs to be able to see that without reading the broker's logs.
9371
9497
  */
9372
- decodeHwAccel: string().nullable()
9498
+ decodeHwAccel: string().nullable(),
9499
+ /**
9500
+ * Set when `publishLocally` was honoured: attach to THIS instead of dialling
9501
+ * `url`, and the session takes the push/deframe transport rather than the
9502
+ * RTP-passthrough one. `null` means the consumer must dial.
9503
+ */
9504
+ camStreamId: string().nullable()
9373
9505
  });
9374
9506
  method(object({
9375
9507
  deviceId: number().int().nonnegative(),
@@ -9517,7 +9649,25 @@ method(object({
9517
9649
  }), _void(), {
9518
9650
  kind: "mutation",
9519
9651
  auth: "admin"
9520
- }), method(object({ brokerId: string() }), boolean()), object({
9652
+ }), method(object({ brokerId: string() }), boolean()), method(object({ deviceId: number().int() }), object({
9653
+ muted: boolean(),
9654
+ /**
9655
+ * How many live non-derived brokers currently hold the mute. Purely
9656
+ * diagnostic: `muted` is the policy and is authoritative on its own
9657
+ * (it applies to brokers that do not exist yet), while this says
9658
+ * whether anything is presently being silenced.
9659
+ */
9660
+ appliedBrokers: number().int().nonnegative()
9661
+ })), method(object({
9662
+ deviceId: number().int(),
9663
+ muted: boolean()
9664
+ }), object({
9665
+ muted: boolean(),
9666
+ appliedBrokers: number().int().nonnegative()
9667
+ }), {
9668
+ kind: "mutation",
9669
+ auth: "admin"
9670
+ }), object({
9521
9671
  deviceId: number().int().nonnegative(),
9522
9672
  camStreamId: string(),
9523
9673
  profile: CamProfileSchema
@@ -15473,6 +15623,30 @@ var TrackSourceSchema = _enum([
15473
15623
  "audio"
15474
15624
  ]);
15475
15625
  /**
15626
+ * Where a track sits in the RETRAIN lifecycle (D81).
15627
+ *
15628
+ * - `none` — never marked, or un-marked. Evictable.
15629
+ * - `staging` — the operator wants this track as training material and has not
15630
+ * finished with it. **This is the only state retention holds**: the track and
15631
+ * everything it owns (object events, crops, keyframes, CLIP vector) survive
15632
+ * the device's age window.
15633
+ * - `trained` — the retrain page has taken what it needed. The frames it chose
15634
+ * were COPIED into the retrain dataset at selection time, so the dataset no
15635
+ * longer depends on the track's media and the track becomes EVICTABLE again.
15636
+ * Terminal for the plain `markForTrain` toggle: returning it to `staging` is
15637
+ * a deliberate action of the retrain page, not a side effect of a checkbox.
15638
+ *
15639
+ * There is no `null`. The state is stored `TEXT NOT NULL DEFAULT 'none'` because
15640
+ * the store's filter language has only positive equality and `whereIn` — no
15641
+ * negation, no IS NULL — so a NULL would be unselectable by ANY predicate and
15642
+ * would make the entire pre-column history immortal in one deploy.
15643
+ */
15644
+ var RetrainStatusSchema = _enum([
15645
+ "none",
15646
+ "staging",
15647
+ "trained"
15648
+ ]);
15649
+ /**
15476
15650
  * Per-track OPERATOR flags — set by hand from the admin UI or the viewer, never
15477
15651
  * by the pipeline. Spread into `TrackSchema` and `KeyEventSchema` from one place
15478
15652
  * so the two surfaces cannot drift.
@@ -15482,18 +15656,31 @@ var TrackSourceSchema = _enum([
15482
15656
  * columns existed read as absent, and a consumer that needs a boolean should say
15483
15657
  * `flag === true`, not `flag !== false`.
15484
15658
  *
15485
- * What the flags DO is deliberately UNDEFINED at the time of writing: they are
15486
- * operator curation, and the behaviour they drive will be specified separately.
15487
- * In particular a `markForTrain` track is NOT pinned against retention — see
15488
- * `docs/decisions/adr-0059.md` for why that is a store-level change, not a flag.
15659
+ * `markForTrain` is the WIRE FACE of {@link RetrainStatusSchema}, not a column:
15660
+ * it is exactly `retrainStatus === 'staging'`, in both directions. Writing
15661
+ * `true` moves `none → staging`, writing `false` moves `staging → none`, and a
15662
+ * `trained` track reports `false` while refusing both writes. The boolean is
15663
+ * kept because three surfaces drive a toggle off it; anything that needs to tell
15664
+ * "never marked" from "already trained" must read `retrainStatus`.
15665
+ *
15666
+ * `debug` does NOT pin; it is attention, not durability.
15489
15667
  */
15490
15668
  var TrackFlagFields = {
15491
- /** Operator marked this track as training material. */
15669
+ /** Operator marked this track as training material — i.e. `retrainStatus` is
15670
+ * `'staging'`. */
15492
15671
  markForTrain: boolean().optional(),
15493
15672
  /** Operator marked this track for diagnostic attention. */
15494
15673
  debug: boolean().optional()
15495
15674
  };
15496
15675
  /**
15676
+ * The lifecycle field itself, on the READ surfaces only (`Track`, `KeyEvent`).
15677
+ * Deliberately NOT part of {@link TrackFlagFields}: that group also builds the
15678
+ * write patch, and the status is not something the toggle sets — it is what the
15679
+ * toggle's boolean is derived from. Absent on an in-RAM track never touched;
15680
+ * always present on a persisted row (the column default materialises `'none'`).
15681
+ */
15682
+ var TrackRetrainFields = { retrainStatus: RetrainStatusSchema.optional() };
15683
+ /**
15497
15684
  * The write half: a PARTIAL patch. An omitted key is left untouched, so setting
15498
15685
  * one flag can never clear the other — the toggles are independent and are
15499
15686
  * driven from three surfaces that do not know about each other.
@@ -15507,7 +15694,32 @@ var TrackFlagsPatchSchema = object(TrackFlagFields);
15507
15694
  var TrackFlagsSchema = object({
15508
15695
  trackId: string(),
15509
15696
  markForTrain: boolean(),
15510
- debug: boolean()
15697
+ debug: boolean(),
15698
+ /** The lifecycle state the boolean was derived from. Required here (unlike on
15699
+ * a track row) because this shape is only ever produced by the write body,
15700
+ * which always knows it — and a surface that has just written needs to render
15701
+ * `trained` without a re-fetch. */
15702
+ retrainStatus: RetrainStatusSchema
15703
+ });
15704
+ /** Per-camera slice of a training-export estimate. */
15705
+ var TrainingExportDeviceTotalsSchema = object({
15706
+ deviceId: number(),
15707
+ tracks: number().int(),
15708
+ files: number().int(),
15709
+ bytes: number().int()
15710
+ });
15711
+ /**
15712
+ * What a training export WOULD contain. Computed from media index rows only —
15713
+ * no blob is read to produce this.
15714
+ */
15715
+ var TrainingExportSummarySchema = object({
15716
+ generatedAt: number(),
15717
+ trackCount: number().int(),
15718
+ fileCount: number().int(),
15719
+ byteCount: number().int(),
15720
+ /** More marked tracks exist than a single pass carries. */
15721
+ truncated: boolean(),
15722
+ devices: array(TrainingExportDeviceTotalsSchema).readonly()
15511
15723
  });
15512
15724
  var TrackSchema = object({
15513
15725
  trackId: string(),
@@ -15552,7 +15764,8 @@ var TrackSchema = object({
15552
15764
  * Populated from the persisted envelope columns on historical reads;
15553
15765
  * absent on legacy rows, dims-less tracks and active (in-RAM) tracks. */
15554
15766
  envelope: TrackEnvelopeSchema.optional(),
15555
- ...TrackFlagFields
15767
+ ...TrackFlagFields,
15768
+ ...TrackRetrainFields
15556
15769
  });
15557
15770
  var BaseEventFields = {
15558
15771
  id: string(),
@@ -15766,7 +15979,8 @@ var KeyEventSchema = object({
15766
15979
  bestEventId: string(),
15767
15980
  /** Track lifetime in ms (lastSeen - firstSeen). */
15768
15981
  windowMs: number().optional(),
15769
- ...TrackFlagFields
15982
+ ...TrackFlagFields,
15983
+ ...TrackRetrainFields
15770
15984
  });
15771
15985
  object({
15772
15986
  trackId: string(),
@@ -16128,11 +16342,29 @@ var pipelineAnalyticsCapability = {
16128
16342
  *
16129
16343
  * `auth: 'protected'` (the default), NOT `admin`: the viewer is an
16130
16344
  * authenticated non-admin surface and two of the three call sites are
16131
- * there. Revisit if a flag ever gains an effect that costs storage —
16132
- * `deleteTracks` next door is admin for exactly that reason.
16345
+ * there. The note that used to sit here said to revisit this the day a flag
16346
+ * gained an effect that costs storage, and D81 is that day — `markForTrain`
16347
+ * now pins. It STAYS protected, and the reason is that the alternative
16348
+ * makes the feature pointless: marking a track is something you do while
16349
+ * looking at it, on the surface you were already looking at it on, and that
16350
+ * surface is the viewer. What the storage cost gets instead is a BOUND — a
16351
+ * per-device pin budget enforced in the body, refusing a new pin past the
16352
+ * limit while always allowing un-marking. `deleteTracks` next door is still
16353
+ * admin, because destroying evidence and preserving it are not symmetric.
16354
+ *
16355
+ * `markForTrain` writes the retrain LIFECYCLE, not a boolean column: `true`
16356
+ * is `none → staging`, `false` is `staging → none`. A track already
16357
+ * `trained` refuses BOTH — its frames are copies inside the retrain dataset
16358
+ * and re-staging it from a generic toggle is how the same material gets
16359
+ * annotated twice under two ground truths. Returning a trained track to
16360
+ * staging is a deliberate action of the retrain page, which is also the only
16361
+ * thing that produces `trained` in the first place.
16133
16362
  *
16134
- * Returns the RESOLVED state of both flags (absent → `false`) so a caller
16135
- * can drive its toggle without a re-fetch. Rejects an unknown track.
16363
+ * Returns the RESOLVED state of both flags (absent → `false`) plus the
16364
+ * `retrainStatus` they were derived from, so a caller can drive its toggle
16365
+ * — and render a `trained` badge — without a re-fetch. Rejects an unknown
16366
+ * track, a new staging mark on a device already holding its full budget, and
16367
+ * any `markForTrain` write against a trained track.
16136
16368
  */
16137
16369
  setTrackFlags: method(object({
16138
16370
  /** Log/audit scope only — the trackId is globally unique on its own. */
@@ -16196,6 +16428,42 @@ var pipelineAnalyticsCapability = {
16196
16428
  kind: "query",
16197
16429
  auth: "admin"
16198
16430
  }),
16431
+ /**
16432
+ * The CHEAP QUESTION, asked before any media moves: how big is the dataset
16433
+ * the marked (`markForTrain`) tracks would produce?
16434
+ *
16435
+ * Answered from media INDEX rows only — key, kind, size, timestamp — so it
16436
+ * costs ~2 KB of reads per track and no blob reads at all. The measured harm
16437
+ * behind D56 was a bulk pass that read and base64'd every blob a track owned
16438
+ * before deciding anything, taking hub-main to 82 s busy out of 120; an
16439
+ * export is that same I/O shape, so it inherits the same discipline: know
16440
+ * the size, then decide.
16441
+ *
16442
+ * `truncated` reports that more marked tracks exist than one pass carries.
16443
+ * Empty `deviceIds` ⇒ every device that has marked tracks.
16444
+ */
16445
+ getTrainingExportSummary: method(object({ deviceIds: array(number()).optional() }), TrainingExportSummarySchema, {
16446
+ kind: "query",
16447
+ auth: "admin"
16448
+ }),
16449
+ /**
16450
+ * Where to download the dataset archive.
16451
+ *
16452
+ * The BYTES do not come back through this cap — they come from the returned
16453
+ * data-plane URL, which streams a tar built entry by entry. A multi-gigabyte
16454
+ * archive base64'd through a unary RPC envelope would be held whole in
16455
+ * memory twice on a hub this repo has already OOM'd once (D9/D18 are the
16456
+ * same lesson about frames). `getDownloadUrl` on `recordingExport` is the
16457
+ * precedent, and this follows it deliberately.
16458
+ *
16459
+ * The archive contains a `manifest.json` FIRST, then the stored media
16460
+ * VERBATIM under `tracks/<deviceId>/<trackId>/…`. No crop is derived and no
16461
+ * model is run: a training set's pixels must be the pixels the pipeline saw.
16462
+ */
16463
+ getTrainingExportUrl: method(object({ deviceIds: array(number()).optional() }), object({ url: string() }), {
16464
+ kind: "query",
16465
+ auth: "admin"
16466
+ }),
16199
16467
  getEventMedia: method(object({
16200
16468
  eventId: string(),
16201
16469
  kind: MediaFileKindEnum.optional()
@@ -18128,9 +18396,15 @@ DeviceType.Camera, method(object({
18128
18396
  * Bypass the cache freshness check and fetch directly from the
18129
18397
  * native (or stream-broker fallback). Triggered by the UI's
18130
18398
  * "refresh" button so an operator can force a fresh frame
18131
- * even when the cache is well within `snapshotMaxAgeMs`.
18132
- * On battery cams this WILL wake the camera — accept the
18133
- * cost only when the user explicitly asks for it.
18399
+ * even when the cache is well within the device's
18400
+ * `snapshotMaxAgeS` window.
18401
+ *
18402
+ * **`force` is an OPERATOR signal, not a freshness preference.** On a
18403
+ * battery camera it is the one thing that walks past the wrapper's
18404
+ * sleep gate and wakes the camera, so a background caller — a poller,
18405
+ * an event handler, a thumbnail — must NEVER set it. Every such caller
18406
+ * gets the cached frame, which on a sleeping battery camera is the
18407
+ * correct answer: stale but honest beats woken.
18134
18408
  */
18135
18409
  force: boolean().optional()
18136
18410
  }), SnapshotImageSchema.nullable()), method(object({ deviceId: number() }), _void(), {
@@ -23022,6 +23296,173 @@ DeviceType.Camera, method(object({
23022
23296
  status: OsdStatusSchema
23023
23297
  });
23024
23298
  /**
23299
+ * `osd-manager` — the ORCHESTRATOR over the device-scope `osd` cap.
23300
+ *
23301
+ * The `osd` cap is the firmware contract: it probes a camera's overlay
23302
+ * SLOTS and writes literal text into one. It has no idea WHERE that text
23303
+ * comes from, and it must not — a driver that grew a "show the temperature
23304
+ * here" feature would grow it once per vendor.
23305
+ *
23306
+ * This cap owns the other half: a per-(camera, slot) BINDING that says
23307
+ * which value feeds the slot, how it is formatted, and under which
23308
+ * conditions it is shown at all. One addon renders every binding on every
23309
+ * camera, so a new source costs zero driver code.
23310
+ *
23311
+ * Three deliberate choices, each with a rejected alternative:
23312
+ *
23313
+ * 1. A source is `(capName, valuePath)` over the kernel's device
23314
+ * runtime-state mirror — NOT a closed enum of source kinds. Every
23315
+ * cap-keyed slice a device publishes is bindable the day the cap
23316
+ * ships. The rejected alternative (one enum member per source, with
23317
+ * a resolver branch each) is what makes "add the humidity too" a
23318
+ * code change.
23319
+ * 2. The display gate reuses `NcConditionsSchema` verbatim — the
23320
+ * notification centre's condition vocabulary — rather than a parallel
23321
+ * model. An operator who has learned one condition editor has learned
23322
+ * both.
23323
+ * 3. Because the renderer's facts are device STATE and not a detection
23324
+ * record, only a SUBSET of that vocabulary can be answered here.
23325
+ * `setSlotBinding` REJECTS the rest at write time (see
23326
+ * `getConditionSupport`). It does not accept-then-fail-closed: a
23327
+ * condition that can never be true renders a permanently blank
23328
+ * overlay, and a blank overlay looks exactly like a broken camera.
23329
+ */
23330
+ /** Where a slot's value comes from. */
23331
+ var OsdSourceSchema = discriminatedUnion("kind", [
23332
+ object({
23333
+ kind: literal("static"),
23334
+ text: string().max(64)
23335
+ }),
23336
+ object({
23337
+ kind: literal("clock"),
23338
+ /** Token pattern: `YYYY MM DD HH mm ss`. Everything else is literal. */
23339
+ pattern: string().min(1).max(32).default("HH:mm"),
23340
+ /** IANA zone. Omitted = the server's zone. */
23341
+ timezone: string().min(1).max(64).optional()
23342
+ }),
23343
+ object({
23344
+ kind: literal("device-state"),
23345
+ deviceId: number().int().optional(),
23346
+ capName: string().min(1).max(64),
23347
+ /** Dot path inside the slice, e.g. `detected`, `value`, `mode`. */
23348
+ valuePath: string().min(1).max(64)
23349
+ })
23350
+ ]);
23351
+ var OsdSlotBindingSchema = object({
23352
+ /** Off = the manager stops driving this slot. It does NOT clear it. */
23353
+ enabled: boolean().default(true),
23354
+ source: OsdSourceSchema,
23355
+ /** `${value}` and `${unit}` are substituted; every occurrence. */
23356
+ template: string().max(96).default("${value}"),
23357
+ /** Truncate with an ellipsis past this length. Absent = no limit. */
23358
+ maxCharacters: number().int().min(4).max(64).optional(),
23359
+ /**
23360
+ * Decimal places for a numeric value. `0` yields an integer — the
23361
+ * documented workaround for firmwares that reject `.` in overlay text.
23362
+ */
23363
+ maxDecimals: number().int().min(0).max(4).default(1),
23364
+ /** Appended via `${unit}`. The state mirror does not carry units. */
23365
+ unitLabel: string().max(8).optional(),
23366
+ /** Raw value → display text, e.g. `{"true":"MOTION","false":""}`. */
23367
+ valueMap: record(string(), string()).optional(),
23368
+ /** Time windows in which the slot is shown. Absent = always. */
23369
+ schedule: NcScheduleSchema.optional(),
23370
+ /**
23371
+ * Display gate, in the notification centre's condition vocabulary.
23372
+ * Only the keys reported by `getConditionSupport` are accepted.
23373
+ */
23374
+ conditions: NcConditionsSchema.optional(),
23375
+ /** Rendered when the gate is closed or the value unreadable. Empty = hide. */
23376
+ fallbackText: string().max(64).default("")
23377
+ });
23378
+ /** One camera slot, as the operator sees it: firmware truth + our binding. */
23379
+ var OsdSlotViewSchema = object({
23380
+ slotId: string(),
23381
+ kind: OsdOverlayKindEnum,
23382
+ /** Firmware refuses text edits (a timestamp, the channel name). */
23383
+ readOnly: boolean(),
23384
+ cameraEnabled: boolean(),
23385
+ cameraText: string().optional(),
23386
+ binding: OsdSlotBindingSchema.nullable()
23387
+ });
23388
+ /**
23389
+ * What happened to one slot on one render pass. `unchanged` exists so the
23390
+ * operator can tell "we are driving this and the value is steady" from
23391
+ * "we never got there" — and so the loop can prove it is not rewriting
23392
+ * identical text to the camera every tick.
23393
+ */
23394
+ var OsdRenderOutcomeEnum = _enum([
23395
+ "written",
23396
+ "unchanged",
23397
+ "gated",
23398
+ "unreadable",
23399
+ "disabled",
23400
+ "unbound",
23401
+ "failed"
23402
+ ]);
23403
+ var OsdRenderResultSchema = object({
23404
+ slotId: string(),
23405
+ outcome: OsdRenderOutcomeEnum,
23406
+ /** The text the slot should carry. Empty = the slot is switched off. */
23407
+ text: string(),
23408
+ /** Why, whenever the outcome is not a plain write. Never silent. */
23409
+ reason: string().optional()
23410
+ });
23411
+ var OsdSourceValueTypeEnum = _enum([
23412
+ "number",
23413
+ "boolean",
23414
+ "string",
23415
+ "enum"
23416
+ ]);
23417
+ /**
23418
+ * One bindable value, derived from a cap's `runtimeState` schema — never
23419
+ * hand-listed. The editor renders from this, so a cap that ships a new
23420
+ * state field becomes bindable with no UI change.
23421
+ */
23422
+ var OsdSourceOptionSchema = object({
23423
+ deviceId: number().int(),
23424
+ deviceName: string(),
23425
+ capName: string(),
23426
+ valuePath: string(),
23427
+ label: string(),
23428
+ valueType: OsdSourceValueTypeEnum,
23429
+ /** Present for `enum`; the editor offers these as `valueMap` keys. */
23430
+ enumValues: array(string()).readonly().optional()
23431
+ });
23432
+ method(object({ deviceId: number().int() }), object({
23433
+ supported: boolean(),
23434
+ slots: array(OsdSlotViewSchema)
23435
+ }), { auth: "admin" }), method(object({ deviceId: number().int() }), object({ sources: array(OsdSourceOptionSchema) }), { auth: "admin" }), method(object({}), object({
23436
+ supported: array(string()),
23437
+ catalog: array(NcConditionDescriptorSchema)
23438
+ }), { auth: "admin" }), method(object({
23439
+ deviceId: number().int(),
23440
+ slotId: string().min(1),
23441
+ binding: OsdSlotBindingSchema
23442
+ }), object({
23443
+ slot: OsdSlotViewSchema,
23444
+ render: OsdRenderResultSchema
23445
+ }), {
23446
+ kind: "mutation",
23447
+ auth: "admin"
23448
+ }), method(object({
23449
+ deviceId: number().int(),
23450
+ slotId: string().min(1)
23451
+ }), object({ success: literal(true) }), {
23452
+ kind: "mutation",
23453
+ auth: "admin"
23454
+ }), method(object({
23455
+ deviceId: number().int(),
23456
+ slotId: string().min(1),
23457
+ binding: OsdSlotBindingSchema.optional()
23458
+ }), OsdRenderResultSchema, {
23459
+ kind: "mutation",
23460
+ auth: "admin"
23461
+ }), method(object({ deviceId: number().int() }), object({ results: array(OsdRenderResultSchema) }), {
23462
+ kind: "mutation",
23463
+ auth: "admin"
23464
+ });
23465
+ /**
23025
23466
  * Feeder connectivity / power status — mirrors the HA petkit device-status
23026
23467
  * enum: `normal` (online, mains), `offline` (not reaching PetKit cloud),
23027
23468
  * `on_batteries` (running on battery backup). `null` until first reported.
@@ -23612,12 +24053,30 @@ var pressureSensorCapability = {
23612
24053
  runtimeState: PressureSensorStatusSchema
23613
24054
  };
23614
24055
  /**
23615
- * Privacy mask = up to `maxRegions` SHAPES the camera blanks out (NOT a
23616
- * cell grid). Reolink `<shelterList>` zones are rectangles; Hikvision
23617
- * ISAPI `<RegionCoordinatesList>` zones are free polygons (this camera:
23618
- * exactly 4 vertices, not necessarily axis-aligned). The cap composes the
23619
- * shared rect|polygon subset of the MaskShape vocabulary. All coords are
23620
- * normalized 0..1 (top-left origin).
24056
+ * PRIVACY — what the camera deliberately does not capture. Two planes:
24057
+ *
24058
+ * - **video**: up to `maxRegions` SHAPES the camera blanks out (NOT a cell
24059
+ * grid). Reolink `<shelterList>` zones are rectangles; Hikvision ISAPI
24060
+ * `<RegionCoordinatesList>` zones are free polygons (this camera: exactly
24061
+ * 4 vertices, not necessarily axis-aligned). The cap composes the shared
24062
+ * rect|polygon subset of the MaskShape vocabulary. All coords are
24063
+ * normalized 0..1 (top-left origin).
24064
+ * - **audio**: the camera's microphone. `setAudioEnabled(false)` stops the
24065
+ * camera encoding an audio track at all, so EVERY consumer — live view,
24066
+ * recording, the audio analyzer, an export — sees silent video. There is
24067
+ * no server-side copy of this fact; the camera is the store and every read
24068
+ * is a read-through, which is why a switch over it cannot drift
24069
+ * ([D62](../../../../docs/decisions/adr-0062.md)).
24070
+ *
24071
+ * Both belong here for one reason: they are the two things an operator turns
24072
+ * off when the answer to "what is this camera allowed to record" changes, and
24073
+ * both are applied ON the device, before anything leaves it.
24074
+ *
24075
+ * **The audio flag has exactly one writer.** `stream-params` used to carry a
24076
+ * per-profile `audio` in its patch schema — reachable from no UI and honoured
24077
+ * by one provider — and it was removed when this landed. A second writer onto
24078
+ * one device register is the shape of every knob this repo has shipped that
24079
+ * disagreed with the one the reader read.
23621
24080
  */
23622
24081
  /** A privacy-mask region's geometry — rectangle or free polygon. */
23623
24082
  var PrivacyMaskShapeSchema = discriminatedUnion("kind", [MaskRectShapeSchema, MaskPolygonShapeSchema]);
@@ -23629,21 +24088,45 @@ var PrivacyMaskRegionSchema = object({
23629
24088
  enabled: boolean(),
23630
24089
  shape: PrivacyMaskShapeSchema
23631
24090
  });
23632
- /** Current on-camera privacy-mask state — master enable + zones. */
24091
+ /** Current on-camera privacy state — mask master enable + zones + microphone. */
23633
24092
  var PrivacyMaskStatusSchema = object({
23634
24093
  enabled: boolean(),
23635
24094
  /** Active zones (normalized 0..1). Length ≤ maxRegions. */
23636
24095
  regions: array(PrivacyMaskRegionSchema),
24096
+ /**
24097
+ * Is the camera capturing sound right now? Read from the camera, never from
24098
+ * a server-side mirror.
24099
+ *
24100
+ * `null` means "no answer" — either this camera exposes no controllable
24101
+ * microphone (`getOptions().supportsAudioMute === false`) or the read
24102
+ * failed. A consumer must render `null` as UNKNOWN and never as `false`:
24103
+ * "the microphone is off" and "we could not ask" look identical to an
24104
+ * operator only until one of them is wrong.
24105
+ *
24106
+ * On a camera whose profiles carry the flag independently (Reolink writes
24107
+ * it per stream), `true` means AT LEAST ONE profile still carries audio —
24108
+ * privacy is only satisfied when every one of them is silent.
24109
+ */
24110
+ audioEnabled: boolean().nullable(),
23637
24111
  lastFetchedAt: number()
23638
24112
  });
23639
- /** Per-camera availability. */
24113
+ /** Per-camera availability. Probed, never assumed from the model name. */
23640
24114
  var PrivacyMaskOptionsSchema = object({
23641
24115
  /** Maximum number of supported zones. */
23642
24116
  maxRegions: number(),
23643
24117
  /** Shape kinds this camera accepts — Reolink: ['rect']; Hikvision: ['rect','polygon']. */
23644
24118
  supportedShapes: array(MaskShapeKindSchema),
23645
24119
  /** Polygon vertex bounds when 'polygon' is supported (Hikvision: {min:4,max:4}). */
23646
- polygonVertices: MaskPolygonVerticesSchema.optional()
24120
+ polygonVertices: MaskPolygonVerticesSchema.optional(),
24121
+ /**
24122
+ * Does this camera expose a microphone switch we can actually write?
24123
+ *
24124
+ * Camera-probed: `true` only when the firmware answered with an audio flag
24125
+ * we know how to patch. A camera that never answered is `false` — a control
24126
+ * the operator can press that changes nothing is worse than no control, and
24127
+ * the switch group renders "not available" instead.
24128
+ */
24129
+ supportsAudioMute: boolean()
23647
24130
  });
23648
24131
  /** Partial change — every field optional. */
23649
24132
  var PrivacyMaskPatchSchema = object({
@@ -23670,6 +24153,27 @@ var privacyMaskCapability = {
23670
24153
  }), _void(), {
23671
24154
  kind: "mutation",
23672
24155
  auth: "admin"
24156
+ }),
24157
+ /**
24158
+ * Turn the camera's microphone on or off, at the camera.
24159
+ *
24160
+ * Deliberately its OWN mutation rather than a field on
24161
+ * {@link PrivacyMaskPatchSchema}: `patch.enabled` already means "the video
24162
+ * mask master switch", and overloading it would make one boolean mean two
24163
+ * unrelated things on the same call. It is also the only method here whose
24164
+ * write leaves the device in a state a later `getStatus` reads back
24165
+ * verbatim, which is what makes it safe as a switch authority.
24166
+ *
24167
+ * A camera whose `getOptions().supportsAudioMute` is false must REJECT
24168
+ * this rather than silently accept it — a write nothing applies is exactly
24169
+ * what the switch group exists to remove.
24170
+ */
24171
+ setAudioEnabled: method(object({
24172
+ deviceId: number(),
24173
+ enabled: boolean()
24174
+ }), _void(), {
24175
+ kind: "mutation",
24176
+ auth: "admin"
23673
24177
  })
23674
24178
  },
23675
24179
  status: {
@@ -23952,6 +24456,21 @@ var LocateSegmentResultSchema = discriminatedUnion("kind", [object({
23952
24456
  })]);
23953
24457
  /** Raw bytes of one finalized footage segment (read off disk on the recording node). */
23954
24458
  var ReadSegmentBytesResultSchema = object({ data: _instanceof(Uint8Array) });
24459
+ /**
24460
+ * One GOP of a finalized segment, cut by byte range through the segment's own
24461
+ * `mfra` (D31 on the D42 feeder path). `data` is the `ftyp`+`moov` head plus
24462
+ * the single `moof`+`mdat` covering the requested instant — standalone-
24463
+ * demuxable, never the whole file. When the segment's index cannot be parsed
24464
+ * the provider degrades INSIDE the mechanism to the whole segment (still one
24465
+ * `data`, `gopStartMs` = the segment start) — a worse read, not another path.
24466
+ */
24467
+ var ReadGopBytesResultSchema = object({
24468
+ data: _instanceof(Uint8Array),
24469
+ /** Absolute epoch ms of the returned fragment's first sample. */
24470
+ gopStartMs: number(),
24471
+ /** Media ms the returned fragment covers. */
24472
+ gopDurMs: number()
24473
+ });
23955
24474
  method(object({
23956
24475
  deviceId: number(),
23957
24476
  fromMs: number(),
@@ -23994,6 +24513,14 @@ method(object({
23994
24513
  }), ReadSegmentBytesResultSchema, {
23995
24514
  kind: "query",
23996
24515
  auth: "admin"
24516
+ }), method(object({
24517
+ deviceId: number(),
24518
+ profile: string(),
24519
+ startMs: number(),
24520
+ epochMs: number()
24521
+ }), ReadGopBytesResultSchema, {
24522
+ kind: "query",
24523
+ auth: "admin"
23997
24524
  }), method(object({
23998
24525
  deviceId: number(),
23999
24526
  config: RecordingConfigSchema
@@ -24553,6 +25080,16 @@ var StreamProfileConfigSchema = object({
24553
25080
  "baseline"
24554
25081
  ]).optional(),
24555
25082
  gop: number().optional(),
25083
+ /**
25084
+ * Whether THIS profile currently carries an audio track. READ-ONLY here.
25085
+ *
25086
+ * There is no matching field on {@link StreamProfilePatchSchema}: the
25087
+ * camera's microphone is owned by `privacy-mask` (`setAudioEnabled`), which
25088
+ * writes every profile at once so "audio off" means silent everywhere. A
25089
+ * per-profile writer beside it would let a camera be half-muted and would be
25090
+ * a second knob onto one device register — the failure D62 exists to
25091
+ * prevent. Absent when the firmware does not report the flag.
25092
+ */
24556
25093
  audio: boolean().optional()
24557
25094
  });
24558
25095
  var StreamParamsStatusSchema = object({
@@ -24593,7 +25130,13 @@ var StreamParamsOptionsSchema = object({
24593
25130
  ext: StreamProfileOptionsSchema.optional()
24594
25131
  });
24595
25132
  /** A partial change to one profile — every field optional; a provider
24596
- * ignores fields it doesn't support. */
25133
+ * ignores fields it doesn't support.
25134
+ *
25135
+ * There is deliberately NO `audio` here. It existed until 2026-08-07,
25136
+ * reachable from no form and honoured by exactly one provider, while the
25137
+ * camera's microphone is a whole-device fact. It now has one writer,
25138
+ * `privacyMask.setAudioEnabled`, which writes every profile — see
25139
+ * `privacy-mask.cap.ts`. */
24597
25140
  var StreamProfilePatchSchema = object({
24598
25141
  width: number().optional(),
24599
25142
  height: number().optional(),
@@ -24606,8 +25149,7 @@ var StreamProfilePatchSchema = object({
24606
25149
  "main",
24607
25150
  "baseline"
24608
25151
  ]).optional(),
24609
- gop: number().optional(),
24610
- audio: boolean().optional()
25152
+ gop: number().optional()
24611
25153
  });
24612
25154
  var streamParamsCapability = {
24613
25155
  name: "stream-params",
@@ -29421,6 +29963,48 @@ Object.freeze({
29421
29963
  addonId: null,
29422
29964
  access: "create"
29423
29965
  },
29966
+ "osdManager.clearSlotBinding": {
29967
+ capName: "osd-manager",
29968
+ capScope: "system",
29969
+ addonId: null,
29970
+ access: "delete"
29971
+ },
29972
+ "osdManager.getConditionSupport": {
29973
+ capName: "osd-manager",
29974
+ capScope: "system",
29975
+ addonId: null,
29976
+ access: "view"
29977
+ },
29978
+ "osdManager.getDeviceOsd": {
29979
+ capName: "osd-manager",
29980
+ capScope: "system",
29981
+ addonId: null,
29982
+ access: "view"
29983
+ },
29984
+ "osdManager.getSourceCatalog": {
29985
+ capName: "osd-manager",
29986
+ capScope: "system",
29987
+ addonId: null,
29988
+ access: "view"
29989
+ },
29990
+ "osdManager.previewSlot": {
29991
+ capName: "osd-manager",
29992
+ capScope: "system",
29993
+ addonId: null,
29994
+ access: "create"
29995
+ },
29996
+ "osdManager.renderDevice": {
29997
+ capName: "osd-manager",
29998
+ capScope: "system",
29999
+ addonId: null,
30000
+ access: "create"
30001
+ },
30002
+ "osdManager.setSlotBinding": {
30003
+ capName: "osd-manager",
30004
+ capScope: "system",
30005
+ addonId: null,
30006
+ access: "create"
30007
+ },
29424
30008
  "petFeeder.callPet": {
29425
30009
  capName: "pet-feeder",
29426
30010
  capScope: "device",
@@ -29583,6 +30167,18 @@ Object.freeze({
29583
30167
  addonId: null,
29584
30168
  access: "view"
29585
30169
  },
30170
+ "pipelineAnalytics.getTrainingExportSummary": {
30171
+ capName: "pipeline-analytics",
30172
+ capScope: "device",
30173
+ addonId: null,
30174
+ access: "view"
30175
+ },
30176
+ "pipelineAnalytics.getTrainingExportUrl": {
30177
+ capName: "pipeline-analytics",
30178
+ capScope: "device",
30179
+ addonId: null,
30180
+ access: "view"
30181
+ },
29586
30182
  "pipelineAnalytics.listEventKinds": {
29587
30183
  capName: "pipeline-analytics",
29588
30184
  capScope: "device",
@@ -30339,6 +30935,12 @@ Object.freeze({
30339
30935
  addonId: null,
30340
30936
  access: "view"
30341
30937
  },
30938
+ "privacyMask.setAudioEnabled": {
30939
+ capName: "privacy-mask",
30940
+ capScope: "device",
30941
+ addonId: null,
30942
+ access: "create"
30943
+ },
30342
30944
  "privacyMask.setMask": {
30343
30945
  capName: "privacy-mask",
30344
30946
  capScope: "device",
@@ -30507,6 +31109,12 @@ Object.freeze({
30507
31109
  addonId: null,
30508
31110
  access: "create"
30509
31111
  },
31112
+ "recording.readGopBytes": {
31113
+ capName: "recording",
31114
+ capScope: "system",
31115
+ addonId: null,
31116
+ access: "view"
31117
+ },
30510
31118
  "recording.readSegmentBytes": {
30511
31119
  capName: "recording",
30512
31120
  capScope: "system",
@@ -31047,6 +31655,12 @@ Object.freeze({
31047
31655
  addonId: null,
31048
31656
  access: "view"
31049
31657
  },
31658
+ "streamBroker.getDeviceAudioMute": {
31659
+ capName: "stream-broker",
31660
+ capScope: "system",
31661
+ addonId: null,
31662
+ access: "view"
31663
+ },
31050
31664
  "streamBroker.getPreBufferInfo": {
31051
31665
  capName: "stream-broker",
31052
31666
  capScope: "system",
@@ -31167,6 +31781,12 @@ Object.freeze({
31167
31781
  addonId: null,
31168
31782
  access: "create"
31169
31783
  },
31784
+ "streamBroker.setDeviceAudioMute": {
31785
+ capName: "stream-broker",
31786
+ capScope: "system",
31787
+ addonId: null,
31788
+ access: "create"
31789
+ },
31170
31790
  "streamBroker.setPreBufferDuration": {
31171
31791
  capName: "stream-broker",
31172
31792
  capScope: "system",
@@ -31814,6 +32434,66 @@ Object.freeze({
31814
32434
  "smtp-provider": "email"
31815
32435
  });
31816
32436
  new Set(["devices", "classes"]);
32437
+ var WEEKDAY_TO_DAY = {
32438
+ Sun: 0,
32439
+ Mon: 1,
32440
+ Tue: 2,
32441
+ Wed: 3,
32442
+ Thu: 4,
32443
+ Fri: 5,
32444
+ Sat: 6
32445
+ };
32446
+ /** Resolve (weekday, minute-of-day) of `atMs` in the schedule's timezone.
32447
+ * An invalid/unknown IANA name falls back to the host timezone. */
32448
+ function localDayMinute(atMs, timezone) {
32449
+ const d = new Date(atMs);
32450
+ if (timezone !== void 0) try {
32451
+ const parts = new Intl.DateTimeFormat("en-US", {
32452
+ timeZone: timezone,
32453
+ weekday: "short",
32454
+ hour: "numeric",
32455
+ minute: "numeric",
32456
+ hourCycle: "h23"
32457
+ }).formatToParts(d);
32458
+ let weekday;
32459
+ let hour;
32460
+ let minute;
32461
+ for (const p of parts) if (p.type === "weekday") weekday = p.value;
32462
+ else if (p.type === "hour") hour = Number(p.value);
32463
+ else if (p.type === "minute") minute = Number(p.value);
32464
+ const day = weekday !== void 0 ? WEEKDAY_TO_DAY[weekday] : void 0;
32465
+ if (day !== void 0 && hour !== void 0 && minute !== void 0) return {
32466
+ day,
32467
+ minute: hour * 60 + minute
32468
+ };
32469
+ } catch {}
32470
+ return {
32471
+ day: d.getDay(),
32472
+ minute: d.getHours() * 60 + d.getMinutes()
32473
+ };
32474
+ }
32475
+ /**
32476
+ * Is the schedule active at `atMs`? No schedule = always active. Windows
32477
+ * are OR'd; a window with `startMinute > endMinute` crosses midnight (it
32478
+ * starts on a listed day and spills into the next). `invert` flips the
32479
+ * result (active OUTSIDE the windows).
32480
+ */
32481
+ function isScheduleActive(schedule, atMs) {
32482
+ if (schedule === void 0) return true;
32483
+ const { day, minute } = localDayMinute(atMs, schedule.timezone);
32484
+ const prevDay = (day + 6) % 7;
32485
+ let inside = false;
32486
+ for (const w of schedule.windows) if (w.startMinute <= w.endMinute) {
32487
+ if (w.days.includes(day) && minute >= w.startMinute && minute < w.endMinute) {
32488
+ inside = true;
32489
+ break;
32490
+ }
32491
+ } else if (w.days.includes(day) && minute >= w.startMinute || w.days.includes(prevDay) && minute < w.endMinute) {
32492
+ inside = true;
32493
+ break;
32494
+ }
32495
+ return schedule.invert === true ? !inside : inside;
32496
+ }
31817
32497
  /**
31818
32498
  * TimelapseRule — the STANDALONE scheduled timelapse producer's rule model.
31819
32499
  *
@@ -31973,6 +32653,88 @@ object({
31973
32653
  paddingRatio: .15,
31974
32654
  square: false
31975
32655
  }).paddingRatio;
32656
+ /**
32657
+ * WHICH delivered frames the decode worker retains a native copy of.
32658
+ *
32659
+ * - `all` — every frame the worker delivered to the runner. The shipped
32660
+ * behaviour, and the only correct one if something can ask for a crop of a
32661
+ * frame the runner never sent to inference.
32662
+ * - `inferred` — only the frames the runner ADMITTED to its detection queue.
32663
+ * A native-crop request always names a `frameId` that rode an inference
32664
+ * result, so that is the only set a request can name. How much it drops is
32665
+ * the two-plane governor's admit ratio and nothing else: measured at ~50% on
32666
+ * this cluster, not the ~80% the design sketch assumed, because the governor
32667
+ * was not throttling as hard as the sketch supposed. Read
32668
+ * `leaseAdmitted`/`leaseOffered` off the metrics line for the camera in front
32669
+ * of you rather than quoting a number from here. The newest delivered frame is
32670
+ * croppable regardless — it is still the worker's reserved slot, not a lease —
32671
+ * which covers the one-frame race between a mark and the supersede that
32672
+ * consumes it.
32673
+ */
32674
+ var NativeLeaseAdmissionSchema = _enum(["all", "inferred"]);
32675
+ object({
32676
+ /**
32677
+ * How long a retained native frame is served before it counts as a miss.
32678
+ *
32679
+ * Must cover the FULL late-crop horizon: detection inference + the
32680
+ * cross-process inference-result hop to hub post-analysis + tracking + the
32681
+ * tRPC crop round-trip back. Below ~500 ms the busiest cameras' subject crops
32682
+ * outrun it and fall back to the ≤640 detection frame; above ~3 s the resident
32683
+ * RAM per busy camera grows linearly with no measured hit-rate gain.
32684
+ */
32685
+ ttlMs: number().int().min(250).max(1e4),
32686
+ /**
32687
+ * Hard per-decode-worker RAM ceiling for retained native frames, in MB.
32688
+ *
32689
+ * Intended as a SAFETY ceiling with the TTL as the effective cap — but check
32690
+ * which one is actually binding before reasoning from that. At the shipped
32691
+ * 1024 MB and a 2 800 ms TTL, a 4K camera hits the CEILING first (~43 frames
32692
+ * at ~24 MB each) and the TTL never gets to expire anything; `leaseMb` /
32693
+ * `leaseFrames` on the metrics line say which. When the ceiling binds, a
32694
+ * change that admits fewer frames buys retention WINDOW at constant RAM
32695
+ * rather than giving RAM back — lower this knob if RAM is what you wanted.
32696
+ * `0` DISABLES the lease entirely and falls the worker back to the tiny
32697
+ * leak-prone GPU surface ring (~85% crop miss; that is what the lease exists
32698
+ * to replace).
32699
+ */
32700
+ budgetMb: number().int().min(0).max(4096),
32701
+ /**
32702
+ * Demand window: eager per-frame native retention runs only within this many
32703
+ * ms of the last native-crop request (or of the dial starting).
32704
+ *
32705
+ * `0` means ALWAYS ON — it disables the gate, it does not disable retention.
32706
+ * That is the legacy behaviour that saturated an N100 (24 native-4K downloads
32707
+ * per second on a camera with zero crop demand), so leave it non-zero unless
32708
+ * you are reproducing that.
32709
+ */
32710
+ activityMs: number().int().min(0).max(12e4),
32711
+ /**
32712
+ * Which delivered frames are retained at all — see
32713
+ * {@link NativeLeaseAdmissionSchema}. This is the only knob of the four that
32714
+ * changes WHAT is kept rather than for how long, so it is also the only one
32715
+ * that can turn a crop that used to hit into a miss. The worker counts every
32716
+ * crop request naming a frame it did NOT see marked
32717
+ * (`leaseUnmarkedCrops` on the session-decode metrics line): a non-zero value
32718
+ * there is the signal that some caller names frames outside the inference set
32719
+ * and that this must go back to `all`.
32720
+ */
32721
+ admission: NativeLeaseAdmissionSchema
32722
+ });
32723
+ /**
32724
+ * The values in force when the operator has set nothing — byte-for-byte the
32725
+ * constants the decode worker shipped with as env-var defaults, so making these
32726
+ * settings changed no behaviour on the day it landed.
32727
+ */
32728
+ var DEFAULT_NATIVE_LEASE_SETTINGS = {
32729
+ ttlMs: 1200,
32730
+ budgetMb: 1024,
32731
+ activityMs: 15e3,
32732
+ admission: "inferred"
32733
+ };
32734
+ DEFAULT_NATIVE_LEASE_SETTINGS.ttlMs;
32735
+ DEFAULT_NATIVE_LEASE_SETTINGS.budgetMb;
32736
+ DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
32737
+ DEFAULT_NATIVE_LEASE_SETTINGS.admission;
31976
32738
  //#endregion
31977
32739
  Object.defineProperty(exports, "BaseAddon", {
31978
32740
  enumerable: true,
@@ -32088,6 +32850,12 @@ Object.defineProperty(exports, "OpsLogEntrySchema", {
32088
32850
  return OpsLogEntrySchema;
32089
32851
  }
32090
32852
  });
32853
+ Object.defineProperty(exports, "RetrainStatusSchema", {
32854
+ enumerable: true,
32855
+ get: function() {
32856
+ return RetrainStatusSchema;
32857
+ }
32858
+ });
32091
32859
  Object.defineProperty(exports, "TimelapseRuleInputSchema", {
32092
32860
  enumerable: true,
32093
32861
  get: function() {
@@ -32220,6 +32988,12 @@ Object.defineProperty(exports, "isDeviceScopedCap", {
32220
32988
  return isDeviceScopedCap;
32221
32989
  }
32222
32990
  });
32991
+ Object.defineProperty(exports, "isScheduleActive", {
32992
+ enumerable: true,
32993
+ get: function() {
32994
+ return isScheduleActive;
32995
+ }
32996
+ });
32223
32997
  Object.defineProperty(exports, "kebabToCamel", {
32224
32998
  enumerable: true,
32225
32999
  get: function() {