@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.
@@ -6460,7 +6460,20 @@ var BrokerStatsSchema = object({
6460
6460
  sampleRate: number(),
6461
6461
  channels: number(),
6462
6462
  supported: boolean()
6463
- }).nullable().optional()
6463
+ }).nullable().optional(),
6464
+ /**
6465
+ * BROKER-SIDE AUDIO MUTE (D83). `true` = this broker is deliberately
6466
+ * distributing none of the device's audio, on live or recording.
6467
+ *
6468
+ * Present so a silent camera can be told apart from a broken one on the
6469
+ * stream panel itself, without cross-referencing the switch group: a
6470
+ * broker holding an `audio` track descriptor while `audioMuted` is true is
6471
+ * working exactly as asked. `audioMutedDropped` counts the audio units
6472
+ * thrown away since the current dial — it is how you confirm from stats
6473
+ * alone that the mute is on the packet path and not merely persisted.
6474
+ */
6475
+ audioMuted: boolean().optional(),
6476
+ audioMutedDropped: number().optional()
6464
6477
  });
6465
6478
  /**
6466
6479
  * Exporter-facing "profile restream" entry. Returned by
@@ -6509,9 +6522,38 @@ var CAP_NODE_PIN_CONTEXT_KEY = "__camstackNodePin";
6509
6522
  /**
6510
6523
  * Build the tRPC request options that pin a single capability call to `nodeId`.
6511
6524
  * Pass as the second argument to `.query(input, …)` / `.mutate(input, …)`.
6525
+ *
6526
+ * ## The id is normalised here, and it has to be
6527
+ *
6528
+ * A forked addon reads its own node from `ctx.kernel.localNodeId`, and inside a
6529
+ * worker that value is a RUNNER id — `hub/export-hap`, not `hub`. Routing
6530
+ * compares a pin against real node ids, so such a pin matches nothing and the
6531
+ * call fails with `no provider registered for cap "…"`. The local-first
6532
+ * resolver already guarded against this (`localNodeId.split('/')[0]`), which
6533
+ * made the hazard invisible: unpinned calls worked, and only an explicit pin —
6534
+ * the thing you reach for when you specifically need THIS node — silently
6535
+ * addressed a node that does not exist.
6536
+ *
6537
+ * Cost of it being missing: `addon-export-hap` pinned `decoder.getInfo` to its
6538
+ * own node to read the host's hardware-decode backend. It never once answered,
6539
+ * so every HomeKit egress transcode decoded in SOFTWARE — including 4K H.265 —
6540
+ * while D67's whole premise was that the decoder addon is the authority on
6541
+ * hardware. The warn said `decoding in SOFTWARE` and read as "this node has no
6542
+ * hardware", which was false.
6543
+ *
6544
+ * Normalising in the ONE constructor fixes every caller at once, which is why
6545
+ * it is here and not at the call sites.
6512
6546
  */
6513
6547
  function nodePin(nodeId) {
6514
- return { context: { [CAP_NODE_PIN_CONTEXT_KEY]: nodeId } };
6548
+ return { context: { [CAP_NODE_PIN_CONTEXT_KEY]: toNodeId(nodeId) } };
6549
+ }
6550
+ /**
6551
+ * A runner id is `<nodeId>/<addonId>`; a node id has no slash. Taking the head
6552
+ * is idempotent, so passing an already-clean id costs nothing.
6553
+ */
6554
+ function toNodeId(idOrRunnerId) {
6555
+ const head = idOrRunnerId.split("/")[0];
6556
+ return head === void 0 || head.length === 0 ? idOrRunnerId : head;
6515
6557
  }
6516
6558
  /**
6517
6559
  * Output schema shared by the contribution + live methods.
@@ -7249,7 +7291,7 @@ object({
7249
7291
  * ## This file adds no state
7250
7292
  *
7251
7293
  * Every switch here is a VIEW onto an authority that already existed
7252
- * ([D61](../../../../docs/decisions/adr-0062.md)). The whole point of the
7294
+ * ([D62](../../../../docs/decisions/adr-0062.md)). The whole point of the
7253
7295
  * group is that there is exactly one place each function is turned off, and
7254
7296
  * the group routes to it:
7255
7297
  *
@@ -7260,6 +7302,53 @@ object({
7260
7302
  * | `audio-analysis` | `deviceManager.setWrapperActive('audio-analysis')` | `AudioSubscriptionController.subscribeAudioStream` returns `null` before opening the stream |
7261
7303
  * | `recording` | `recording.setDeviceConfig` → `RecordingConfig.enabled` | `band-decision.shouldRecord` returns false; the controller detaches the device |
7262
7304
  * | `notifications` | `notificationRules.setDeviceMuted` | `NotificationCenter.evaluateAndEnqueue` returns before any rule is evaluated |
7305
+ * | `privacy-mask` | `privacyMask.setMask({ enabled })` → the CAMERA | the camera blanks the masked regions itself; every stream and recording carries the black boxes |
7306
+ * | `device-audio` | `privacyMask.setAudioEnabled` → the CAMERA | the camera stops encoding an audio track at all; every consumer sees silent video |
7307
+ * | `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 |
7308
+ *
7309
+ * ## `device-audio` and `broker-audio` are two functions, not two knobs
7310
+ *
7311
+ * They look adjacent and they are not the same control ([D83](../../../../docs/decisions/adr-0083.md)):
7312
+ * `device-audio` writes the CAMERA, so it is hardware privacy — the microphone
7313
+ * genuinely stops, it survives CamStack entirely, and it costs a multi-second
7314
+ * encoder restart on every flip. `broker-audio` writes THIS server, so it is
7315
+ * instant, vendor-independent and reversible without touching the camera, and
7316
+ * a camera that ignores or lacks the ISAPI/Reolink control is still silenced.
7317
+ * D62 forbids a second switch that *disagrees* with the first; these two
7318
+ * cannot disagree, because neither reads the other's store — the camera holds
7319
+ * one, the broker holds the other, and each reports its own fact.
7320
+ *
7321
+ * ## The two switches whose authority is not on this server
7322
+ *
7323
+ * `privacy-mask` and `device-audio` write the CAMERA. That is not a loophole
7324
+ * in "the group stores nothing" — it is the purest form of it: the camera
7325
+ * holds the fact, every read is a read-through, and there is no server-side
7326
+ * copy that could drift. Their availability therefore cannot come from
7327
+ * `listBindableCapsForDeviceType` (a device-NATIVE cap carries no wrappers and
7328
+ * is filtered out there); it comes from the cap's own camera-probed
7329
+ * `privacyMask.getOptions()`, which is strictly more honest — it answers for
7330
+ * THIS camera rather than for the device type
7331
+ * ([D74](../../../../docs/decisions/adr-0074.md)).
7332
+ *
7333
+ * ## `privacy-mask` is the one row whose ON is not "the function is working"
7334
+ *
7335
+ * Every other switch means *this camera's function is doing its job*, so
7336
+ * `enabled: false` is a thing an operator took away. `privacy-mask` means **the
7337
+ * MASK is active** — `enabled: true` is video deliberately obscured. The
7338
+ * polarity is not a choice made here: `addon-export-hap`'s privacy `Switch`
7339
+ * (`builders/privacy-switch.ts`) already mirrors `patch.enabled` verbatim, and
7340
+ * a HomeKit toggle that disagreed with the app's toggle for the same camera is
7341
+ * worse than either surface not having one.
7342
+ *
7343
+ * Two consequences follow and both are load-bearing:
7344
+ *
7345
+ * - **It never counts as `switchedOff`.** `countsAsSwitchedOff` is `false` for
7346
+ * exactly this row. With the polarity above, every camera that has NOT drawn
7347
+ * a privacy mask would otherwise report `switchedOff: ['privacy-mask']` — the
7348
+ * normal, healthy state of most cameras rendered as an operator disablement.
7349
+ * - **Its cost line names BOTH directions.** `costWhenOff` is rendered
7350
+ * unconditionally by both clients, so for this row it has to read correctly
7351
+ * whichever way the switch is sitting.
7263
7352
  *
7264
7353
  * The wrapper-binding pair is not a new idea: `legacy-migrations.ts` already
7265
7354
  * migrated the legacy `audioEnabled` / `pipelineEnabled` /
@@ -7278,14 +7367,18 @@ object({
7278
7367
  * `CameraStatus.switchedOff`.
7279
7368
  */
7280
7369
  /**
7281
- * The five functions the operator named (2026-08-05). Deliberately NOT one id
7282
- * per pipeline step: face recognition and plate/LPR are per-step toggles on
7370
+ * The functions the operator named — five on 2026-08-05, plus the camera's own
7371
+ * microphone on 2026-08-07. Deliberately NOT one id per pipeline step: face
7372
+ * recognition and plate/LPR are per-step toggles on
7283
7373
  * `pipelineOrchestrator.setCameraStepToggle` and belong in the pipeline
7284
- * editor, not in a five-button safety group.
7374
+ * editor, not in a safety group.
7285
7375
  */
7286
7376
  var CameraSwitchIdSchema = _enum([
7287
7377
  "stream-broker",
7288
7378
  "object-detection",
7379
+ "privacy-mask",
7380
+ "device-audio",
7381
+ "broker-audio",
7289
7382
  "audio-analysis",
7290
7383
  "recording",
7291
7384
  "notifications"
@@ -7303,14 +7396,27 @@ var CameraSwitchAuthoritySchema = discriminatedUnion("kind", [
7303
7396
  capName: string()
7304
7397
  }),
7305
7398
  object({ kind: literal("recording-config") }),
7306
- object({ kind: literal("notification-mute") })
7399
+ object({ kind: literal("notification-mute") }),
7400
+ object({
7401
+ kind: literal("camera-audio"),
7402
+ capName: string()
7403
+ }),
7404
+ object({
7405
+ kind: literal("camera-mask"),
7406
+ capName: string()
7407
+ }),
7408
+ object({ kind: literal("broker-audio-mute") })
7307
7409
  ]);
7308
7410
  /**
7309
7411
  * Why a switch is not offered for this camera. Rendered instead of the
7310
7412
  * control, never as a dead control — an absent function and a broken one must
7311
7413
  * not look the same.
7312
7414
  */
7313
- var CameraSwitchUnavailableReasonSchema = _enum(["no-provider", "source-unreachable"]);
7415
+ var CameraSwitchUnavailableReasonSchema = _enum([
7416
+ "no-provider",
7417
+ "source-unreachable",
7418
+ "not-configured"
7419
+ ]);
7314
7420
  /**
7315
7421
  * One switch, resolved for one camera.
7316
7422
  *
@@ -9303,6 +9409,26 @@ var EgressTranscodeRequestSchema = object({
9303
9409
  "h264_mp4toannexb",
9304
9410
  "hevc_mp4toannexb"
9305
9411
  ]).optional(),
9412
+ /**
9413
+ * Publish the transcode as a LOCAL push cam stream, instead of leaving the
9414
+ * consumer to dial the returned url. The broker picks the id and returns it
9415
+ * as `camStreamId` — a caller-supplied one would be circular, since the
9416
+ * sharing key is computed FROM this request.
9417
+ *
9418
+ * The url is still returned and still the contract for a transcode pinned to
9419
+ * another node. But dialling it locally costs an RTSP round trip that changes
9420
+ * the transport underneath the consumer: a dialled stream is an RTP source,
9421
+ * so `isRtpSource()` is true and the session takes the RTP-passthrough +
9422
+ * repacketizer branch. The push branch — the one the derived mechanism has
9423
+ * live hours on — is never reached. Measured on Alexa: broker registered, RTP
9424
+ * arriving, key frame arriving, black screen, on a chain healthy at every
9425
+ * other point.
9426
+ *
9427
+ * Same idea the transport already applies to CALLS, where `classifyCapRoute`
9428
+ * gives priority to `hub-in-process` so a local call never leaves the node.
9429
+ * This is that rule for media.
9430
+ */
9431
+ publishLocally: boolean().optional(),
9306
9432
  pixelFormat: _enum(["yuv420p", "nv12"]).optional(),
9307
9433
  /**
9308
9434
  * Operator/consumer override for decode hardware. ABSENT is the normal case
@@ -9347,7 +9473,13 @@ var EgressTranscodeSchema = object({
9347
9473
  * Returned rather than assumed: a consumer that asked for hardware and got
9348
9474
  * software needs to be able to see that without reading the broker's logs.
9349
9475
  */
9350
- decodeHwAccel: string().nullable()
9476
+ decodeHwAccel: string().nullable(),
9477
+ /**
9478
+ * Set when `publishLocally` was honoured: attach to THIS instead of dialling
9479
+ * `url`, and the session takes the push/deframe transport rather than the
9480
+ * RTP-passthrough one. `null` means the consumer must dial.
9481
+ */
9482
+ camStreamId: string().nullable()
9351
9483
  });
9352
9484
  method(object({
9353
9485
  deviceId: number().int().nonnegative(),
@@ -9495,7 +9627,25 @@ method(object({
9495
9627
  }), _void(), {
9496
9628
  kind: "mutation",
9497
9629
  auth: "admin"
9498
- }), method(object({ brokerId: string() }), boolean()), object({
9630
+ }), method(object({ brokerId: string() }), boolean()), method(object({ deviceId: number().int() }), object({
9631
+ muted: boolean(),
9632
+ /**
9633
+ * How many live non-derived brokers currently hold the mute. Purely
9634
+ * diagnostic: `muted` is the policy and is authoritative on its own
9635
+ * (it applies to brokers that do not exist yet), while this says
9636
+ * whether anything is presently being silenced.
9637
+ */
9638
+ appliedBrokers: number().int().nonnegative()
9639
+ })), method(object({
9640
+ deviceId: number().int(),
9641
+ muted: boolean()
9642
+ }), object({
9643
+ muted: boolean(),
9644
+ appliedBrokers: number().int().nonnegative()
9645
+ }), {
9646
+ kind: "mutation",
9647
+ auth: "admin"
9648
+ }), object({
9499
9649
  deviceId: number().int().nonnegative(),
9500
9650
  camStreamId: string(),
9501
9651
  profile: CamProfileSchema
@@ -15451,6 +15601,30 @@ var TrackSourceSchema = _enum([
15451
15601
  "audio"
15452
15602
  ]);
15453
15603
  /**
15604
+ * Where a track sits in the RETRAIN lifecycle (D81).
15605
+ *
15606
+ * - `none` — never marked, or un-marked. Evictable.
15607
+ * - `staging` — the operator wants this track as training material and has not
15608
+ * finished with it. **This is the only state retention holds**: the track and
15609
+ * everything it owns (object events, crops, keyframes, CLIP vector) survive
15610
+ * the device's age window.
15611
+ * - `trained` — the retrain page has taken what it needed. The frames it chose
15612
+ * were COPIED into the retrain dataset at selection time, so the dataset no
15613
+ * longer depends on the track's media and the track becomes EVICTABLE again.
15614
+ * Terminal for the plain `markForTrain` toggle: returning it to `staging` is
15615
+ * a deliberate action of the retrain page, not a side effect of a checkbox.
15616
+ *
15617
+ * There is no `null`. The state is stored `TEXT NOT NULL DEFAULT 'none'` because
15618
+ * the store's filter language has only positive equality and `whereIn` — no
15619
+ * negation, no IS NULL — so a NULL would be unselectable by ANY predicate and
15620
+ * would make the entire pre-column history immortal in one deploy.
15621
+ */
15622
+ var RetrainStatusSchema = _enum([
15623
+ "none",
15624
+ "staging",
15625
+ "trained"
15626
+ ]);
15627
+ /**
15454
15628
  * Per-track OPERATOR flags — set by hand from the admin UI or the viewer, never
15455
15629
  * by the pipeline. Spread into `TrackSchema` and `KeyEventSchema` from one place
15456
15630
  * so the two surfaces cannot drift.
@@ -15460,18 +15634,31 @@ var TrackSourceSchema = _enum([
15460
15634
  * columns existed read as absent, and a consumer that needs a boolean should say
15461
15635
  * `flag === true`, not `flag !== false`.
15462
15636
  *
15463
- * What the flags DO is deliberately UNDEFINED at the time of writing: they are
15464
- * operator curation, and the behaviour they drive will be specified separately.
15465
- * In particular a `markForTrain` track is NOT pinned against retention — see
15466
- * `docs/decisions/adr-0059.md` for why that is a store-level change, not a flag.
15637
+ * `markForTrain` is the WIRE FACE of {@link RetrainStatusSchema}, not a column:
15638
+ * it is exactly `retrainStatus === 'staging'`, in both directions. Writing
15639
+ * `true` moves `none → staging`, writing `false` moves `staging → none`, and a
15640
+ * `trained` track reports `false` while refusing both writes. The boolean is
15641
+ * kept because three surfaces drive a toggle off it; anything that needs to tell
15642
+ * "never marked" from "already trained" must read `retrainStatus`.
15643
+ *
15644
+ * `debug` does NOT pin; it is attention, not durability.
15467
15645
  */
15468
15646
  var TrackFlagFields = {
15469
- /** Operator marked this track as training material. */
15647
+ /** Operator marked this track as training material — i.e. `retrainStatus` is
15648
+ * `'staging'`. */
15470
15649
  markForTrain: boolean().optional(),
15471
15650
  /** Operator marked this track for diagnostic attention. */
15472
15651
  debug: boolean().optional()
15473
15652
  };
15474
15653
  /**
15654
+ * The lifecycle field itself, on the READ surfaces only (`Track`, `KeyEvent`).
15655
+ * Deliberately NOT part of {@link TrackFlagFields}: that group also builds the
15656
+ * write patch, and the status is not something the toggle sets — it is what the
15657
+ * toggle's boolean is derived from. Absent on an in-RAM track never touched;
15658
+ * always present on a persisted row (the column default materialises `'none'`).
15659
+ */
15660
+ var TrackRetrainFields = { retrainStatus: RetrainStatusSchema.optional() };
15661
+ /**
15475
15662
  * The write half: a PARTIAL patch. An omitted key is left untouched, so setting
15476
15663
  * one flag can never clear the other — the toggles are independent and are
15477
15664
  * driven from three surfaces that do not know about each other.
@@ -15485,7 +15672,32 @@ var TrackFlagsPatchSchema = object(TrackFlagFields);
15485
15672
  var TrackFlagsSchema = object({
15486
15673
  trackId: string(),
15487
15674
  markForTrain: boolean(),
15488
- debug: boolean()
15675
+ debug: boolean(),
15676
+ /** The lifecycle state the boolean was derived from. Required here (unlike on
15677
+ * a track row) because this shape is only ever produced by the write body,
15678
+ * which always knows it — and a surface that has just written needs to render
15679
+ * `trained` without a re-fetch. */
15680
+ retrainStatus: RetrainStatusSchema
15681
+ });
15682
+ /** Per-camera slice of a training-export estimate. */
15683
+ var TrainingExportDeviceTotalsSchema = object({
15684
+ deviceId: number(),
15685
+ tracks: number().int(),
15686
+ files: number().int(),
15687
+ bytes: number().int()
15688
+ });
15689
+ /**
15690
+ * What a training export WOULD contain. Computed from media index rows only —
15691
+ * no blob is read to produce this.
15692
+ */
15693
+ var TrainingExportSummarySchema = object({
15694
+ generatedAt: number(),
15695
+ trackCount: number().int(),
15696
+ fileCount: number().int(),
15697
+ byteCount: number().int(),
15698
+ /** More marked tracks exist than a single pass carries. */
15699
+ truncated: boolean(),
15700
+ devices: array(TrainingExportDeviceTotalsSchema).readonly()
15489
15701
  });
15490
15702
  var TrackSchema = object({
15491
15703
  trackId: string(),
@@ -15530,7 +15742,8 @@ var TrackSchema = object({
15530
15742
  * Populated from the persisted envelope columns on historical reads;
15531
15743
  * absent on legacy rows, dims-less tracks and active (in-RAM) tracks. */
15532
15744
  envelope: TrackEnvelopeSchema.optional(),
15533
- ...TrackFlagFields
15745
+ ...TrackFlagFields,
15746
+ ...TrackRetrainFields
15534
15747
  });
15535
15748
  var BaseEventFields = {
15536
15749
  id: string(),
@@ -15744,7 +15957,8 @@ var KeyEventSchema = object({
15744
15957
  bestEventId: string(),
15745
15958
  /** Track lifetime in ms (lastSeen - firstSeen). */
15746
15959
  windowMs: number().optional(),
15747
- ...TrackFlagFields
15960
+ ...TrackFlagFields,
15961
+ ...TrackRetrainFields
15748
15962
  });
15749
15963
  object({
15750
15964
  trackId: string(),
@@ -16106,11 +16320,29 @@ var pipelineAnalyticsCapability = {
16106
16320
  *
16107
16321
  * `auth: 'protected'` (the default), NOT `admin`: the viewer is an
16108
16322
  * authenticated non-admin surface and two of the three call sites are
16109
- * there. Revisit if a flag ever gains an effect that costs storage —
16110
- * `deleteTracks` next door is admin for exactly that reason.
16323
+ * there. The note that used to sit here said to revisit this the day a flag
16324
+ * gained an effect that costs storage, and D81 is that day — `markForTrain`
16325
+ * now pins. It STAYS protected, and the reason is that the alternative
16326
+ * makes the feature pointless: marking a track is something you do while
16327
+ * looking at it, on the surface you were already looking at it on, and that
16328
+ * surface is the viewer. What the storage cost gets instead is a BOUND — a
16329
+ * per-device pin budget enforced in the body, refusing a new pin past the
16330
+ * limit while always allowing un-marking. `deleteTracks` next door is still
16331
+ * admin, because destroying evidence and preserving it are not symmetric.
16332
+ *
16333
+ * `markForTrain` writes the retrain LIFECYCLE, not a boolean column: `true`
16334
+ * is `none → staging`, `false` is `staging → none`. A track already
16335
+ * `trained` refuses BOTH — its frames are copies inside the retrain dataset
16336
+ * and re-staging it from a generic toggle is how the same material gets
16337
+ * annotated twice under two ground truths. Returning a trained track to
16338
+ * staging is a deliberate action of the retrain page, which is also the only
16339
+ * thing that produces `trained` in the first place.
16111
16340
  *
16112
- * Returns the RESOLVED state of both flags (absent → `false`) so a caller
16113
- * can drive its toggle without a re-fetch. Rejects an unknown track.
16341
+ * Returns the RESOLVED state of both flags (absent → `false`) plus the
16342
+ * `retrainStatus` they were derived from, so a caller can drive its toggle
16343
+ * — and render a `trained` badge — without a re-fetch. Rejects an unknown
16344
+ * track, a new staging mark on a device already holding its full budget, and
16345
+ * any `markForTrain` write against a trained track.
16114
16346
  */
16115
16347
  setTrackFlags: method(object({
16116
16348
  /** Log/audit scope only — the trackId is globally unique on its own. */
@@ -16174,6 +16406,42 @@ var pipelineAnalyticsCapability = {
16174
16406
  kind: "query",
16175
16407
  auth: "admin"
16176
16408
  }),
16409
+ /**
16410
+ * The CHEAP QUESTION, asked before any media moves: how big is the dataset
16411
+ * the marked (`markForTrain`) tracks would produce?
16412
+ *
16413
+ * Answered from media INDEX rows only — key, kind, size, timestamp — so it
16414
+ * costs ~2 KB of reads per track and no blob reads at all. The measured harm
16415
+ * behind D56 was a bulk pass that read and base64'd every blob a track owned
16416
+ * before deciding anything, taking hub-main to 82 s busy out of 120; an
16417
+ * export is that same I/O shape, so it inherits the same discipline: know
16418
+ * the size, then decide.
16419
+ *
16420
+ * `truncated` reports that more marked tracks exist than one pass carries.
16421
+ * Empty `deviceIds` ⇒ every device that has marked tracks.
16422
+ */
16423
+ getTrainingExportSummary: method(object({ deviceIds: array(number()).optional() }), TrainingExportSummarySchema, {
16424
+ kind: "query",
16425
+ auth: "admin"
16426
+ }),
16427
+ /**
16428
+ * Where to download the dataset archive.
16429
+ *
16430
+ * The BYTES do not come back through this cap — they come from the returned
16431
+ * data-plane URL, which streams a tar built entry by entry. A multi-gigabyte
16432
+ * archive base64'd through a unary RPC envelope would be held whole in
16433
+ * memory twice on a hub this repo has already OOM'd once (D9/D18 are the
16434
+ * same lesson about frames). `getDownloadUrl` on `recordingExport` is the
16435
+ * precedent, and this follows it deliberately.
16436
+ *
16437
+ * The archive contains a `manifest.json` FIRST, then the stored media
16438
+ * VERBATIM under `tracks/<deviceId>/<trackId>/…`. No crop is derived and no
16439
+ * model is run: a training set's pixels must be the pixels the pipeline saw.
16440
+ */
16441
+ getTrainingExportUrl: method(object({ deviceIds: array(number()).optional() }), object({ url: string() }), {
16442
+ kind: "query",
16443
+ auth: "admin"
16444
+ }),
16177
16445
  getEventMedia: method(object({
16178
16446
  eventId: string(),
16179
16447
  kind: MediaFileKindEnum.optional()
@@ -18106,9 +18374,15 @@ DeviceType.Camera, method(object({
18106
18374
  * Bypass the cache freshness check and fetch directly from the
18107
18375
  * native (or stream-broker fallback). Triggered by the UI's
18108
18376
  * "refresh" button so an operator can force a fresh frame
18109
- * even when the cache is well within `snapshotMaxAgeMs`.
18110
- * On battery cams this WILL wake the camera — accept the
18111
- * cost only when the user explicitly asks for it.
18377
+ * even when the cache is well within the device's
18378
+ * `snapshotMaxAgeS` window.
18379
+ *
18380
+ * **`force` is an OPERATOR signal, not a freshness preference.** On a
18381
+ * battery camera it is the one thing that walks past the wrapper's
18382
+ * sleep gate and wakes the camera, so a background caller — a poller,
18383
+ * an event handler, a thumbnail — must NEVER set it. Every such caller
18384
+ * gets the cached frame, which on a sleeping battery camera is the
18385
+ * correct answer: stale but honest beats woken.
18112
18386
  */
18113
18387
  force: boolean().optional()
18114
18388
  }), SnapshotImageSchema.nullable()), method(object({ deviceId: number() }), _void(), {
@@ -23000,6 +23274,173 @@ DeviceType.Camera, method(object({
23000
23274
  status: OsdStatusSchema
23001
23275
  });
23002
23276
  /**
23277
+ * `osd-manager` — the ORCHESTRATOR over the device-scope `osd` cap.
23278
+ *
23279
+ * The `osd` cap is the firmware contract: it probes a camera's overlay
23280
+ * SLOTS and writes literal text into one. It has no idea WHERE that text
23281
+ * comes from, and it must not — a driver that grew a "show the temperature
23282
+ * here" feature would grow it once per vendor.
23283
+ *
23284
+ * This cap owns the other half: a per-(camera, slot) BINDING that says
23285
+ * which value feeds the slot, how it is formatted, and under which
23286
+ * conditions it is shown at all. One addon renders every binding on every
23287
+ * camera, so a new source costs zero driver code.
23288
+ *
23289
+ * Three deliberate choices, each with a rejected alternative:
23290
+ *
23291
+ * 1. A source is `(capName, valuePath)` over the kernel's device
23292
+ * runtime-state mirror — NOT a closed enum of source kinds. Every
23293
+ * cap-keyed slice a device publishes is bindable the day the cap
23294
+ * ships. The rejected alternative (one enum member per source, with
23295
+ * a resolver branch each) is what makes "add the humidity too" a
23296
+ * code change.
23297
+ * 2. The display gate reuses `NcConditionsSchema` verbatim — the
23298
+ * notification centre's condition vocabulary — rather than a parallel
23299
+ * model. An operator who has learned one condition editor has learned
23300
+ * both.
23301
+ * 3. Because the renderer's facts are device STATE and not a detection
23302
+ * record, only a SUBSET of that vocabulary can be answered here.
23303
+ * `setSlotBinding` REJECTS the rest at write time (see
23304
+ * `getConditionSupport`). It does not accept-then-fail-closed: a
23305
+ * condition that can never be true renders a permanently blank
23306
+ * overlay, and a blank overlay looks exactly like a broken camera.
23307
+ */
23308
+ /** Where a slot's value comes from. */
23309
+ var OsdSourceSchema = discriminatedUnion("kind", [
23310
+ object({
23311
+ kind: literal("static"),
23312
+ text: string().max(64)
23313
+ }),
23314
+ object({
23315
+ kind: literal("clock"),
23316
+ /** Token pattern: `YYYY MM DD HH mm ss`. Everything else is literal. */
23317
+ pattern: string().min(1).max(32).default("HH:mm"),
23318
+ /** IANA zone. Omitted = the server's zone. */
23319
+ timezone: string().min(1).max(64).optional()
23320
+ }),
23321
+ object({
23322
+ kind: literal("device-state"),
23323
+ deviceId: number().int().optional(),
23324
+ capName: string().min(1).max(64),
23325
+ /** Dot path inside the slice, e.g. `detected`, `value`, `mode`. */
23326
+ valuePath: string().min(1).max(64)
23327
+ })
23328
+ ]);
23329
+ var OsdSlotBindingSchema = object({
23330
+ /** Off = the manager stops driving this slot. It does NOT clear it. */
23331
+ enabled: boolean().default(true),
23332
+ source: OsdSourceSchema,
23333
+ /** `${value}` and `${unit}` are substituted; every occurrence. */
23334
+ template: string().max(96).default("${value}"),
23335
+ /** Truncate with an ellipsis past this length. Absent = no limit. */
23336
+ maxCharacters: number().int().min(4).max(64).optional(),
23337
+ /**
23338
+ * Decimal places for a numeric value. `0` yields an integer — the
23339
+ * documented workaround for firmwares that reject `.` in overlay text.
23340
+ */
23341
+ maxDecimals: number().int().min(0).max(4).default(1),
23342
+ /** Appended via `${unit}`. The state mirror does not carry units. */
23343
+ unitLabel: string().max(8).optional(),
23344
+ /** Raw value → display text, e.g. `{"true":"MOTION","false":""}`. */
23345
+ valueMap: record(string(), string()).optional(),
23346
+ /** Time windows in which the slot is shown. Absent = always. */
23347
+ schedule: NcScheduleSchema.optional(),
23348
+ /**
23349
+ * Display gate, in the notification centre's condition vocabulary.
23350
+ * Only the keys reported by `getConditionSupport` are accepted.
23351
+ */
23352
+ conditions: NcConditionsSchema.optional(),
23353
+ /** Rendered when the gate is closed or the value unreadable. Empty = hide. */
23354
+ fallbackText: string().max(64).default("")
23355
+ });
23356
+ /** One camera slot, as the operator sees it: firmware truth + our binding. */
23357
+ var OsdSlotViewSchema = object({
23358
+ slotId: string(),
23359
+ kind: OsdOverlayKindEnum,
23360
+ /** Firmware refuses text edits (a timestamp, the channel name). */
23361
+ readOnly: boolean(),
23362
+ cameraEnabled: boolean(),
23363
+ cameraText: string().optional(),
23364
+ binding: OsdSlotBindingSchema.nullable()
23365
+ });
23366
+ /**
23367
+ * What happened to one slot on one render pass. `unchanged` exists so the
23368
+ * operator can tell "we are driving this and the value is steady" from
23369
+ * "we never got there" — and so the loop can prove it is not rewriting
23370
+ * identical text to the camera every tick.
23371
+ */
23372
+ var OsdRenderOutcomeEnum = _enum([
23373
+ "written",
23374
+ "unchanged",
23375
+ "gated",
23376
+ "unreadable",
23377
+ "disabled",
23378
+ "unbound",
23379
+ "failed"
23380
+ ]);
23381
+ var OsdRenderResultSchema = object({
23382
+ slotId: string(),
23383
+ outcome: OsdRenderOutcomeEnum,
23384
+ /** The text the slot should carry. Empty = the slot is switched off. */
23385
+ text: string(),
23386
+ /** Why, whenever the outcome is not a plain write. Never silent. */
23387
+ reason: string().optional()
23388
+ });
23389
+ var OsdSourceValueTypeEnum = _enum([
23390
+ "number",
23391
+ "boolean",
23392
+ "string",
23393
+ "enum"
23394
+ ]);
23395
+ /**
23396
+ * One bindable value, derived from a cap's `runtimeState` schema — never
23397
+ * hand-listed. The editor renders from this, so a cap that ships a new
23398
+ * state field becomes bindable with no UI change.
23399
+ */
23400
+ var OsdSourceOptionSchema = object({
23401
+ deviceId: number().int(),
23402
+ deviceName: string(),
23403
+ capName: string(),
23404
+ valuePath: string(),
23405
+ label: string(),
23406
+ valueType: OsdSourceValueTypeEnum,
23407
+ /** Present for `enum`; the editor offers these as `valueMap` keys. */
23408
+ enumValues: array(string()).readonly().optional()
23409
+ });
23410
+ method(object({ deviceId: number().int() }), object({
23411
+ supported: boolean(),
23412
+ slots: array(OsdSlotViewSchema)
23413
+ }), { auth: "admin" }), method(object({ deviceId: number().int() }), object({ sources: array(OsdSourceOptionSchema) }), { auth: "admin" }), method(object({}), object({
23414
+ supported: array(string()),
23415
+ catalog: array(NcConditionDescriptorSchema)
23416
+ }), { auth: "admin" }), method(object({
23417
+ deviceId: number().int(),
23418
+ slotId: string().min(1),
23419
+ binding: OsdSlotBindingSchema
23420
+ }), object({
23421
+ slot: OsdSlotViewSchema,
23422
+ render: OsdRenderResultSchema
23423
+ }), {
23424
+ kind: "mutation",
23425
+ auth: "admin"
23426
+ }), method(object({
23427
+ deviceId: number().int(),
23428
+ slotId: string().min(1)
23429
+ }), object({ success: literal(true) }), {
23430
+ kind: "mutation",
23431
+ auth: "admin"
23432
+ }), method(object({
23433
+ deviceId: number().int(),
23434
+ slotId: string().min(1),
23435
+ binding: OsdSlotBindingSchema.optional()
23436
+ }), OsdRenderResultSchema, {
23437
+ kind: "mutation",
23438
+ auth: "admin"
23439
+ }), method(object({ deviceId: number().int() }), object({ results: array(OsdRenderResultSchema) }), {
23440
+ kind: "mutation",
23441
+ auth: "admin"
23442
+ });
23443
+ /**
23003
23444
  * Feeder connectivity / power status — mirrors the HA petkit device-status
23004
23445
  * enum: `normal` (online, mains), `offline` (not reaching PetKit cloud),
23005
23446
  * `on_batteries` (running on battery backup). `null` until first reported.
@@ -23590,12 +24031,30 @@ var pressureSensorCapability = {
23590
24031
  runtimeState: PressureSensorStatusSchema
23591
24032
  };
23592
24033
  /**
23593
- * Privacy mask = up to `maxRegions` SHAPES the camera blanks out (NOT a
23594
- * cell grid). Reolink `<shelterList>` zones are rectangles; Hikvision
23595
- * ISAPI `<RegionCoordinatesList>` zones are free polygons (this camera:
23596
- * exactly 4 vertices, not necessarily axis-aligned). The cap composes the
23597
- * shared rect|polygon subset of the MaskShape vocabulary. All coords are
23598
- * normalized 0..1 (top-left origin).
24034
+ * PRIVACY — what the camera deliberately does not capture. Two planes:
24035
+ *
24036
+ * - **video**: up to `maxRegions` SHAPES the camera blanks out (NOT a cell
24037
+ * grid). Reolink `<shelterList>` zones are rectangles; Hikvision ISAPI
24038
+ * `<RegionCoordinatesList>` zones are free polygons (this camera: exactly
24039
+ * 4 vertices, not necessarily axis-aligned). The cap composes the shared
24040
+ * rect|polygon subset of the MaskShape vocabulary. All coords are
24041
+ * normalized 0..1 (top-left origin).
24042
+ * - **audio**: the camera's microphone. `setAudioEnabled(false)` stops the
24043
+ * camera encoding an audio track at all, so EVERY consumer — live view,
24044
+ * recording, the audio analyzer, an export — sees silent video. There is
24045
+ * no server-side copy of this fact; the camera is the store and every read
24046
+ * is a read-through, which is why a switch over it cannot drift
24047
+ * ([D62](../../../../docs/decisions/adr-0062.md)).
24048
+ *
24049
+ * Both belong here for one reason: they are the two things an operator turns
24050
+ * off when the answer to "what is this camera allowed to record" changes, and
24051
+ * both are applied ON the device, before anything leaves it.
24052
+ *
24053
+ * **The audio flag has exactly one writer.** `stream-params` used to carry a
24054
+ * per-profile `audio` in its patch schema — reachable from no UI and honoured
24055
+ * by one provider — and it was removed when this landed. A second writer onto
24056
+ * one device register is the shape of every knob this repo has shipped that
24057
+ * disagreed with the one the reader read.
23599
24058
  */
23600
24059
  /** A privacy-mask region's geometry — rectangle or free polygon. */
23601
24060
  var PrivacyMaskShapeSchema = discriminatedUnion("kind", [MaskRectShapeSchema, MaskPolygonShapeSchema]);
@@ -23607,21 +24066,45 @@ var PrivacyMaskRegionSchema = object({
23607
24066
  enabled: boolean(),
23608
24067
  shape: PrivacyMaskShapeSchema
23609
24068
  });
23610
- /** Current on-camera privacy-mask state — master enable + zones. */
24069
+ /** Current on-camera privacy state — mask master enable + zones + microphone. */
23611
24070
  var PrivacyMaskStatusSchema = object({
23612
24071
  enabled: boolean(),
23613
24072
  /** Active zones (normalized 0..1). Length ≤ maxRegions. */
23614
24073
  regions: array(PrivacyMaskRegionSchema),
24074
+ /**
24075
+ * Is the camera capturing sound right now? Read from the camera, never from
24076
+ * a server-side mirror.
24077
+ *
24078
+ * `null` means "no answer" — either this camera exposes no controllable
24079
+ * microphone (`getOptions().supportsAudioMute === false`) or the read
24080
+ * failed. A consumer must render `null` as UNKNOWN and never as `false`:
24081
+ * "the microphone is off" and "we could not ask" look identical to an
24082
+ * operator only until one of them is wrong.
24083
+ *
24084
+ * On a camera whose profiles carry the flag independently (Reolink writes
24085
+ * it per stream), `true` means AT LEAST ONE profile still carries audio —
24086
+ * privacy is only satisfied when every one of them is silent.
24087
+ */
24088
+ audioEnabled: boolean().nullable(),
23615
24089
  lastFetchedAt: number()
23616
24090
  });
23617
- /** Per-camera availability. */
24091
+ /** Per-camera availability. Probed, never assumed from the model name. */
23618
24092
  var PrivacyMaskOptionsSchema = object({
23619
24093
  /** Maximum number of supported zones. */
23620
24094
  maxRegions: number(),
23621
24095
  /** Shape kinds this camera accepts — Reolink: ['rect']; Hikvision: ['rect','polygon']. */
23622
24096
  supportedShapes: array(MaskShapeKindSchema),
23623
24097
  /** Polygon vertex bounds when 'polygon' is supported (Hikvision: {min:4,max:4}). */
23624
- polygonVertices: MaskPolygonVerticesSchema.optional()
24098
+ polygonVertices: MaskPolygonVerticesSchema.optional(),
24099
+ /**
24100
+ * Does this camera expose a microphone switch we can actually write?
24101
+ *
24102
+ * Camera-probed: `true` only when the firmware answered with an audio flag
24103
+ * we know how to patch. A camera that never answered is `false` — a control
24104
+ * the operator can press that changes nothing is worse than no control, and
24105
+ * the switch group renders "not available" instead.
24106
+ */
24107
+ supportsAudioMute: boolean()
23625
24108
  });
23626
24109
  /** Partial change — every field optional. */
23627
24110
  var PrivacyMaskPatchSchema = object({
@@ -23648,6 +24131,27 @@ var privacyMaskCapability = {
23648
24131
  }), _void(), {
23649
24132
  kind: "mutation",
23650
24133
  auth: "admin"
24134
+ }),
24135
+ /**
24136
+ * Turn the camera's microphone on or off, at the camera.
24137
+ *
24138
+ * Deliberately its OWN mutation rather than a field on
24139
+ * {@link PrivacyMaskPatchSchema}: `patch.enabled` already means "the video
24140
+ * mask master switch", and overloading it would make one boolean mean two
24141
+ * unrelated things on the same call. It is also the only method here whose
24142
+ * write leaves the device in a state a later `getStatus` reads back
24143
+ * verbatim, which is what makes it safe as a switch authority.
24144
+ *
24145
+ * A camera whose `getOptions().supportsAudioMute` is false must REJECT
24146
+ * this rather than silently accept it — a write nothing applies is exactly
24147
+ * what the switch group exists to remove.
24148
+ */
24149
+ setAudioEnabled: method(object({
24150
+ deviceId: number(),
24151
+ enabled: boolean()
24152
+ }), _void(), {
24153
+ kind: "mutation",
24154
+ auth: "admin"
23651
24155
  })
23652
24156
  },
23653
24157
  status: {
@@ -23930,6 +24434,21 @@ var LocateSegmentResultSchema = discriminatedUnion("kind", [object({
23930
24434
  })]);
23931
24435
  /** Raw bytes of one finalized footage segment (read off disk on the recording node). */
23932
24436
  var ReadSegmentBytesResultSchema = object({ data: _instanceof(Uint8Array) });
24437
+ /**
24438
+ * One GOP of a finalized segment, cut by byte range through the segment's own
24439
+ * `mfra` (D31 on the D42 feeder path). `data` is the `ftyp`+`moov` head plus
24440
+ * the single `moof`+`mdat` covering the requested instant — standalone-
24441
+ * demuxable, never the whole file. When the segment's index cannot be parsed
24442
+ * the provider degrades INSIDE the mechanism to the whole segment (still one
24443
+ * `data`, `gopStartMs` = the segment start) — a worse read, not another path.
24444
+ */
24445
+ var ReadGopBytesResultSchema = object({
24446
+ data: _instanceof(Uint8Array),
24447
+ /** Absolute epoch ms of the returned fragment's first sample. */
24448
+ gopStartMs: number(),
24449
+ /** Media ms the returned fragment covers. */
24450
+ gopDurMs: number()
24451
+ });
23933
24452
  method(object({
23934
24453
  deviceId: number(),
23935
24454
  fromMs: number(),
@@ -23972,6 +24491,14 @@ method(object({
23972
24491
  }), ReadSegmentBytesResultSchema, {
23973
24492
  kind: "query",
23974
24493
  auth: "admin"
24494
+ }), method(object({
24495
+ deviceId: number(),
24496
+ profile: string(),
24497
+ startMs: number(),
24498
+ epochMs: number()
24499
+ }), ReadGopBytesResultSchema, {
24500
+ kind: "query",
24501
+ auth: "admin"
23975
24502
  }), method(object({
23976
24503
  deviceId: number(),
23977
24504
  config: RecordingConfigSchema
@@ -24531,6 +25058,16 @@ var StreamProfileConfigSchema = object({
24531
25058
  "baseline"
24532
25059
  ]).optional(),
24533
25060
  gop: number().optional(),
25061
+ /**
25062
+ * Whether THIS profile currently carries an audio track. READ-ONLY here.
25063
+ *
25064
+ * There is no matching field on {@link StreamProfilePatchSchema}: the
25065
+ * camera's microphone is owned by `privacy-mask` (`setAudioEnabled`), which
25066
+ * writes every profile at once so "audio off" means silent everywhere. A
25067
+ * per-profile writer beside it would let a camera be half-muted and would be
25068
+ * a second knob onto one device register — the failure D62 exists to
25069
+ * prevent. Absent when the firmware does not report the flag.
25070
+ */
24534
25071
  audio: boolean().optional()
24535
25072
  });
24536
25073
  var StreamParamsStatusSchema = object({
@@ -24571,7 +25108,13 @@ var StreamParamsOptionsSchema = object({
24571
25108
  ext: StreamProfileOptionsSchema.optional()
24572
25109
  });
24573
25110
  /** A partial change to one profile — every field optional; a provider
24574
- * ignores fields it doesn't support. */
25111
+ * ignores fields it doesn't support.
25112
+ *
25113
+ * There is deliberately NO `audio` here. It existed until 2026-08-07,
25114
+ * reachable from no form and honoured by exactly one provider, while the
25115
+ * camera's microphone is a whole-device fact. It now has one writer,
25116
+ * `privacyMask.setAudioEnabled`, which writes every profile — see
25117
+ * `privacy-mask.cap.ts`. */
24575
25118
  var StreamProfilePatchSchema = object({
24576
25119
  width: number().optional(),
24577
25120
  height: number().optional(),
@@ -24584,8 +25127,7 @@ var StreamProfilePatchSchema = object({
24584
25127
  "main",
24585
25128
  "baseline"
24586
25129
  ]).optional(),
24587
- gop: number().optional(),
24588
- audio: boolean().optional()
25130
+ gop: number().optional()
24589
25131
  });
24590
25132
  var streamParamsCapability = {
24591
25133
  name: "stream-params",
@@ -29399,6 +29941,48 @@ Object.freeze({
29399
29941
  addonId: null,
29400
29942
  access: "create"
29401
29943
  },
29944
+ "osdManager.clearSlotBinding": {
29945
+ capName: "osd-manager",
29946
+ capScope: "system",
29947
+ addonId: null,
29948
+ access: "delete"
29949
+ },
29950
+ "osdManager.getConditionSupport": {
29951
+ capName: "osd-manager",
29952
+ capScope: "system",
29953
+ addonId: null,
29954
+ access: "view"
29955
+ },
29956
+ "osdManager.getDeviceOsd": {
29957
+ capName: "osd-manager",
29958
+ capScope: "system",
29959
+ addonId: null,
29960
+ access: "view"
29961
+ },
29962
+ "osdManager.getSourceCatalog": {
29963
+ capName: "osd-manager",
29964
+ capScope: "system",
29965
+ addonId: null,
29966
+ access: "view"
29967
+ },
29968
+ "osdManager.previewSlot": {
29969
+ capName: "osd-manager",
29970
+ capScope: "system",
29971
+ addonId: null,
29972
+ access: "create"
29973
+ },
29974
+ "osdManager.renderDevice": {
29975
+ capName: "osd-manager",
29976
+ capScope: "system",
29977
+ addonId: null,
29978
+ access: "create"
29979
+ },
29980
+ "osdManager.setSlotBinding": {
29981
+ capName: "osd-manager",
29982
+ capScope: "system",
29983
+ addonId: null,
29984
+ access: "create"
29985
+ },
29402
29986
  "petFeeder.callPet": {
29403
29987
  capName: "pet-feeder",
29404
29988
  capScope: "device",
@@ -29561,6 +30145,18 @@ Object.freeze({
29561
30145
  addonId: null,
29562
30146
  access: "view"
29563
30147
  },
30148
+ "pipelineAnalytics.getTrainingExportSummary": {
30149
+ capName: "pipeline-analytics",
30150
+ capScope: "device",
30151
+ addonId: null,
30152
+ access: "view"
30153
+ },
30154
+ "pipelineAnalytics.getTrainingExportUrl": {
30155
+ capName: "pipeline-analytics",
30156
+ capScope: "device",
30157
+ addonId: null,
30158
+ access: "view"
30159
+ },
29564
30160
  "pipelineAnalytics.listEventKinds": {
29565
30161
  capName: "pipeline-analytics",
29566
30162
  capScope: "device",
@@ -30317,6 +30913,12 @@ Object.freeze({
30317
30913
  addonId: null,
30318
30914
  access: "view"
30319
30915
  },
30916
+ "privacyMask.setAudioEnabled": {
30917
+ capName: "privacy-mask",
30918
+ capScope: "device",
30919
+ addonId: null,
30920
+ access: "create"
30921
+ },
30320
30922
  "privacyMask.setMask": {
30321
30923
  capName: "privacy-mask",
30322
30924
  capScope: "device",
@@ -30485,6 +31087,12 @@ Object.freeze({
30485
31087
  addonId: null,
30486
31088
  access: "create"
30487
31089
  },
31090
+ "recording.readGopBytes": {
31091
+ capName: "recording",
31092
+ capScope: "system",
31093
+ addonId: null,
31094
+ access: "view"
31095
+ },
30488
31096
  "recording.readSegmentBytes": {
30489
31097
  capName: "recording",
30490
31098
  capScope: "system",
@@ -31025,6 +31633,12 @@ Object.freeze({
31025
31633
  addonId: null,
31026
31634
  access: "view"
31027
31635
  },
31636
+ "streamBroker.getDeviceAudioMute": {
31637
+ capName: "stream-broker",
31638
+ capScope: "system",
31639
+ addonId: null,
31640
+ access: "view"
31641
+ },
31028
31642
  "streamBroker.getPreBufferInfo": {
31029
31643
  capName: "stream-broker",
31030
31644
  capScope: "system",
@@ -31145,6 +31759,12 @@ Object.freeze({
31145
31759
  addonId: null,
31146
31760
  access: "create"
31147
31761
  },
31762
+ "streamBroker.setDeviceAudioMute": {
31763
+ capName: "stream-broker",
31764
+ capScope: "system",
31765
+ addonId: null,
31766
+ access: "create"
31767
+ },
31148
31768
  "streamBroker.setPreBufferDuration": {
31149
31769
  capName: "stream-broker",
31150
31770
  capScope: "system",
@@ -31792,6 +32412,66 @@ Object.freeze({
31792
32412
  "smtp-provider": "email"
31793
32413
  });
31794
32414
  new Set(["devices", "classes"]);
32415
+ var WEEKDAY_TO_DAY = {
32416
+ Sun: 0,
32417
+ Mon: 1,
32418
+ Tue: 2,
32419
+ Wed: 3,
32420
+ Thu: 4,
32421
+ Fri: 5,
32422
+ Sat: 6
32423
+ };
32424
+ /** Resolve (weekday, minute-of-day) of `atMs` in the schedule's timezone.
32425
+ * An invalid/unknown IANA name falls back to the host timezone. */
32426
+ function localDayMinute(atMs, timezone) {
32427
+ const d = new Date(atMs);
32428
+ if (timezone !== void 0) try {
32429
+ const parts = new Intl.DateTimeFormat("en-US", {
32430
+ timeZone: timezone,
32431
+ weekday: "short",
32432
+ hour: "numeric",
32433
+ minute: "numeric",
32434
+ hourCycle: "h23"
32435
+ }).formatToParts(d);
32436
+ let weekday;
32437
+ let hour;
32438
+ let minute;
32439
+ for (const p of parts) if (p.type === "weekday") weekday = p.value;
32440
+ else if (p.type === "hour") hour = Number(p.value);
32441
+ else if (p.type === "minute") minute = Number(p.value);
32442
+ const day = weekday !== void 0 ? WEEKDAY_TO_DAY[weekday] : void 0;
32443
+ if (day !== void 0 && hour !== void 0 && minute !== void 0) return {
32444
+ day,
32445
+ minute: hour * 60 + minute
32446
+ };
32447
+ } catch {}
32448
+ return {
32449
+ day: d.getDay(),
32450
+ minute: d.getHours() * 60 + d.getMinutes()
32451
+ };
32452
+ }
32453
+ /**
32454
+ * Is the schedule active at `atMs`? No schedule = always active. Windows
32455
+ * are OR'd; a window with `startMinute > endMinute` crosses midnight (it
32456
+ * starts on a listed day and spills into the next). `invert` flips the
32457
+ * result (active OUTSIDE the windows).
32458
+ */
32459
+ function isScheduleActive(schedule, atMs) {
32460
+ if (schedule === void 0) return true;
32461
+ const { day, minute } = localDayMinute(atMs, schedule.timezone);
32462
+ const prevDay = (day + 6) % 7;
32463
+ let inside = false;
32464
+ for (const w of schedule.windows) if (w.startMinute <= w.endMinute) {
32465
+ if (w.days.includes(day) && minute >= w.startMinute && minute < w.endMinute) {
32466
+ inside = true;
32467
+ break;
32468
+ }
32469
+ } else if (w.days.includes(day) && minute >= w.startMinute || w.days.includes(prevDay) && minute < w.endMinute) {
32470
+ inside = true;
32471
+ break;
32472
+ }
32473
+ return schedule.invert === true ? !inside : inside;
32474
+ }
31795
32475
  /**
31796
32476
  * TimelapseRule — the STANDALONE scheduled timelapse producer's rule model.
31797
32477
  *
@@ -31951,5 +32631,87 @@ object({
31951
32631
  paddingRatio: .15,
31952
32632
  square: false
31953
32633
  }).paddingRatio;
32634
+ /**
32635
+ * WHICH delivered frames the decode worker retains a native copy of.
32636
+ *
32637
+ * - `all` — every frame the worker delivered to the runner. The shipped
32638
+ * behaviour, and the only correct one if something can ask for a crop of a
32639
+ * frame the runner never sent to inference.
32640
+ * - `inferred` — only the frames the runner ADMITTED to its detection queue.
32641
+ * A native-crop request always names a `frameId` that rode an inference
32642
+ * result, so that is the only set a request can name. How much it drops is
32643
+ * the two-plane governor's admit ratio and nothing else: measured at ~50% on
32644
+ * this cluster, not the ~80% the design sketch assumed, because the governor
32645
+ * was not throttling as hard as the sketch supposed. Read
32646
+ * `leaseAdmitted`/`leaseOffered` off the metrics line for the camera in front
32647
+ * of you rather than quoting a number from here. The newest delivered frame is
32648
+ * croppable regardless — it is still the worker's reserved slot, not a lease —
32649
+ * which covers the one-frame race between a mark and the supersede that
32650
+ * consumes it.
32651
+ */
32652
+ var NativeLeaseAdmissionSchema = _enum(["all", "inferred"]);
32653
+ object({
32654
+ /**
32655
+ * How long a retained native frame is served before it counts as a miss.
32656
+ *
32657
+ * Must cover the FULL late-crop horizon: detection inference + the
32658
+ * cross-process inference-result hop to hub post-analysis + tracking + the
32659
+ * tRPC crop round-trip back. Below ~500 ms the busiest cameras' subject crops
32660
+ * outrun it and fall back to the ≤640 detection frame; above ~3 s the resident
32661
+ * RAM per busy camera grows linearly with no measured hit-rate gain.
32662
+ */
32663
+ ttlMs: number().int().min(250).max(1e4),
32664
+ /**
32665
+ * Hard per-decode-worker RAM ceiling for retained native frames, in MB.
32666
+ *
32667
+ * Intended as a SAFETY ceiling with the TTL as the effective cap — but check
32668
+ * which one is actually binding before reasoning from that. At the shipped
32669
+ * 1024 MB and a 2 800 ms TTL, a 4K camera hits the CEILING first (~43 frames
32670
+ * at ~24 MB each) and the TTL never gets to expire anything; `leaseMb` /
32671
+ * `leaseFrames` on the metrics line say which. When the ceiling binds, a
32672
+ * change that admits fewer frames buys retention WINDOW at constant RAM
32673
+ * rather than giving RAM back — lower this knob if RAM is what you wanted.
32674
+ * `0` DISABLES the lease entirely and falls the worker back to the tiny
32675
+ * leak-prone GPU surface ring (~85% crop miss; that is what the lease exists
32676
+ * to replace).
32677
+ */
32678
+ budgetMb: number().int().min(0).max(4096),
32679
+ /**
32680
+ * Demand window: eager per-frame native retention runs only within this many
32681
+ * ms of the last native-crop request (or of the dial starting).
32682
+ *
32683
+ * `0` means ALWAYS ON — it disables the gate, it does not disable retention.
32684
+ * That is the legacy behaviour that saturated an N100 (24 native-4K downloads
32685
+ * per second on a camera with zero crop demand), so leave it non-zero unless
32686
+ * you are reproducing that.
32687
+ */
32688
+ activityMs: number().int().min(0).max(12e4),
32689
+ /**
32690
+ * Which delivered frames are retained at all — see
32691
+ * {@link NativeLeaseAdmissionSchema}. This is the only knob of the four that
32692
+ * changes WHAT is kept rather than for how long, so it is also the only one
32693
+ * that can turn a crop that used to hit into a miss. The worker counts every
32694
+ * crop request naming a frame it did NOT see marked
32695
+ * (`leaseUnmarkedCrops` on the session-decode metrics line): a non-zero value
32696
+ * there is the signal that some caller names frames outside the inference set
32697
+ * and that this must go back to `all`.
32698
+ */
32699
+ admission: NativeLeaseAdmissionSchema
32700
+ });
32701
+ /**
32702
+ * The values in force when the operator has set nothing — byte-for-byte the
32703
+ * constants the decode worker shipped with as env-var defaults, so making these
32704
+ * settings changed no behaviour on the day it landed.
32705
+ */
32706
+ var DEFAULT_NATIVE_LEASE_SETTINGS = {
32707
+ ttlMs: 1200,
32708
+ budgetMb: 1024,
32709
+ activityMs: 15e3,
32710
+ admission: "inferred"
32711
+ };
32712
+ DEFAULT_NATIVE_LEASE_SETTINGS.ttlMs;
32713
+ DEFAULT_NATIVE_LEASE_SETTINGS.budgetMb;
32714
+ DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
32715
+ DEFAULT_NATIVE_LEASE_SETTINGS.admission;
31954
32716
  //#endregion
31955
- export { object as $, hfModelUrl as A, errMsg as B, buildEventKindDescriptor as C, embeddingEncoderCapability as D, defineCustomActions as E, readDeviceStateFrom as F, isDeviceScopedCap as G, DeviceType as H, subKindsOf as I, _enum as J, nodePin as K, vectorDimFromBase64 as L, notificationRulesCapability as M, pipelineAnalyticsCapability as N, encodeVectorBase64 as O, plateGalleryCapability as P, number as Q, videoclipsCapability as R, audioMetricsCapability as S, customAction as T, createEvent as U, BaseAddon as V, hydrateSchema as W, boolean as X, array as Y, literal as Z, TimelapseRuleInputSchema as _, MACRO_LABELS as a, addonWidgetsSourceCapability as b, NcConditionDescriptorSchema as c, NcRuleSchema as d, record as et, NcSnoozeInputSchema as f, OpsLogEntrySchema as g, NcTaxonomySchema as h, EVENT_PAD_MS as i, kebabToCamel as j, faceGalleryCapability as k, NcRuleInputSchema as l, NcSnoozeSuppressedSchema as m, DEFAULT_EVENT_COLOR as n, unknown as nt, NC_CONDITION_CATALOG as o, NcSnoozeSchema as p, sleep as q, EVENT_KIND_BY_CAP as r, EventCategory as rt, NC_TAXONOMY as s, BaseDevice as t, string as tt, NcRulePatchSchema as u, TimelapseRuleSchema as v, cosineSimilarity as w, alarmPanelCapability as x, TrackSourceSchema as y, zoneAnalyticsCapability as z };
32717
+ export { literal as $, faceGalleryCapability as A, videoclipsCapability as B, audioMetricsCapability as C, defineCustomActions as D, customAction as E, pipelineAnalyticsCapability as F, createEvent as G, errMsg as H, plateGalleryCapability as I, nodePin as J, hydrateSchema as K, readDeviceStateFrom as L, isScheduleActive as M, kebabToCamel as N, embeddingEncoderCapability as O, notificationRulesCapability as P, boolean as Q, subKindsOf as R, alarmPanelCapability as S, cosineSimilarity as T, BaseAddon as U, zoneAnalyticsCapability as V, DeviceType as W, _enum as X, sleep as Y, array as Z, RetrainStatusSchema as _, MACRO_LABELS as a, EventCategory as at, TrackSourceSchema as b, NcConditionDescriptorSchema as c, NcRuleSchema as d, number as et, NcSnoozeInputSchema as f, OpsLogEntrySchema as g, NcTaxonomySchema as h, EVENT_PAD_MS as i, unknown as it, hfModelUrl as j, encodeVectorBase64 as k, NcRuleInputSchema as l, NcSnoozeSuppressedSchema as m, DEFAULT_EVENT_COLOR as n, record as nt, NC_CONDITION_CATALOG as o, NcSnoozeSchema as p, isDeviceScopedCap as q, EVENT_KIND_BY_CAP as r, string as rt, NC_TAXONOMY as s, BaseDevice as t, object as tt, NcRulePatchSchema as u, TimelapseRuleInputSchema as v, buildEventKindDescriptor as w, addonWidgetsSourceCapability as x, TimelapseRuleSchema as y, vectorDimFromBase64 as z };