@camstack/addon-osd-manager 0.1.5 → 0.1.6

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.
package/dist/index.js CHANGED
@@ -7280,8 +7280,31 @@ var AdoptionJobSchema = object({
7280
7280
  error: string().nullable()
7281
7281
  });
7282
7282
  /**
7283
- * Per-camera FUNCTION SWITCHES — the one coherent on/off surface over the
7284
- * pipeline functions an operator thinks in terms of.
7283
+ * Per-camera FUNCTION SWITCHES.
7284
+ *
7285
+ * ## The aggregate group is being withdrawn — the BADGE is not (D113)
7286
+ *
7287
+ * This file shipped as "the one coherent on/off surface over the pipeline
7288
+ * functions an operator thinks in terms of". The operator's verdict on
7289
+ * 2026-08-12 was that the coherent surface bought complexity and no clarity:
7290
+ * every function already had a settings page of its own, and a second place to
7291
+ * turn it off is a second place to look. Each switch is going back to its own
7292
+ * component's original options — detection to the detection-pipeline wrapper
7293
+ * binding, audio analysis to its own, recording to `RecordingConfig.enabled`
7294
+ * (which was always first-class; the switch was a veneer over
7295
+ * `recording.setDeviceConfig`), notifications to a notification-center
7296
+ * per-device setting, the two camera planes to their own components.
7297
+ *
7298
+ * What survives is {@link composeSwitchedOff}: `CameraStatus.switchedOff`, the
7299
+ * thing that lets a status surface say DISABLED instead of BROKEN, recomposed
7300
+ * straight from the authorities with no group in the middle. That rule was
7301
+ * never about a control panel.
7302
+ *
7303
+ * Everything else here — {@link CAMERA_SWITCH_CATALOG}, {@link CameraSwitch},
7304
+ * {@link deriveCameraSwitches}, the `pipelineOrchestrator.getCameraSwitches` /
7305
+ * `setCameraSwitch` pair — is a COMPATIBILITY surface for as long as deployed
7306
+ * viewers (v1.0.305) and the admin UI still call it. It is deleted when they
7307
+ * stop; nothing new may be built on it.
7285
7308
  *
7286
7309
  * ## This file adds no state
7287
7310
  *
@@ -7632,7 +7655,15 @@ var RecordingConfigSchema = object({
7632
7655
  * Each completed/failed run also lands one durable ops-log row on its owning
7633
7656
  * addon surface.
7634
7657
  */
7658
+ /**
7659
+ * `queued` exists because the recorder mover is SINGLE-FLIGHT and an operator
7660
+ * rebalance enqueues one job per (camera, profile). Refusing the second job —
7661
+ * what the engine did before — turned a fifteen-camera rebalance into fifteen
7662
+ * manual retries. Queued jobs run FIFO; a queued job that is cancelled never
7663
+ * runs at all.
7664
+ */
7635
7665
  var RelocateJobStateSchema = _enum([
7666
+ "queued",
7636
7667
  "running",
7637
7668
  "done",
7638
7669
  "failed",
@@ -7667,6 +7698,15 @@ var RelocateFootageInputSchema = object({
7667
7698
  /** Limits relocation to the logical profile class. Omit only for the
7668
7699
  * pre-orchestration compatibility path. */
7669
7700
  footageClass: RelocateFootageClassSchema.optional(),
7701
+ /** Scope the move to ONE camera. Absent = every camera on the source, which
7702
+ * is what a whole-disk drain means. The rebalance path always sets it: its
7703
+ * unit is a (camera, profile) pile, not a disk. */
7704
+ deviceId: number().int().optional(),
7705
+ /** Scope the move to specific segment profiles (`high` / `mid` / `low`).
7706
+ * Finer than `footageClass`, which cannot separate high from mid — and the
7707
+ * placement plan assigns those two independently, so a rebalance that could
7708
+ * only say "recordings" would move footage the plan never asked to move. */
7709
+ profiles: array(string()).optional(),
7670
7710
  /** Copy throttle in MB/s (default 40) — the drain is a background chore,
7671
7711
  * never allowed to starve live writers. */
7672
7712
  throttleMbps: number().min(1).max(1e3).optional()
@@ -7802,6 +7842,21 @@ var StorageLocationSchema = object({
7802
7842
  nodeId: string().optional(),
7803
7843
  isDefault: boolean().default(false),
7804
7844
  isSystem: boolean().default(false),
7845
+ /**
7846
+ * Operator opt-in: whether consumers that BALANCE across several locations
7847
+ * of a type may write here. Recordings reads it today; event media and
7848
+ * backups are the next consumers, which is why the flag lives on the
7849
+ * location rather than in any one addon's store — nothing has to be
7850
+ * extended to add the next consumer.
7851
+ *
7852
+ * OPTIONAL, and ABSENT MEANS ACTIVE. Every location persisted before the
7853
+ * flag existed reads back with no flag and keeps working exactly as before;
7854
+ * that is the whole compat story, and it is why no migration ships with it.
7855
+ * A newly CREATED sibling is stamped `false` by the orchestrator (creating a
7856
+ * disk must not silently start writing to it); the default of a type is
7857
+ * always stamped `true`.
7858
+ */
7859
+ enabled: boolean().optional(),
7805
7860
  /** COMPUTED at read time by the orchestrator (statfs of the backing volume
7806
7861
  * for node-local locations it can reach) — never persisted, absent when the
7807
7862
  * volume is remote/unreachable. The single capacity truth every UI reads. */
@@ -13601,7 +13656,8 @@ var embeddingEncoderCapability = {
13601
13656
  };
13602
13657
  /**
13603
13658
  * filesystem-browse — per-node capability for browsing the node's local
13604
- * filesystem, sandboxed to operator-configured allowed roots. Used by the
13659
+ * filesystem. Reads are unconfined (whole filesystem, from `/` down); WRITES
13660
+ * are sandboxed to operator-configured allowed roots (D115). Used by the
13605
13661
  * admin "Add filesystem location" flow to pick a node + path. `mode:'per-node'`
13606
13662
  * (one provider per node); the hub calls it with `{nodeId}` so the codegen
13607
13663
  * routes to that exact node (default `nodeIdMode:'routing'`).
@@ -13622,9 +13678,9 @@ var filesystemBrowseCapability = {
13622
13678
  mode: "singleton",
13623
13679
  internal: true,
13624
13680
  methods: {
13625
- /** The allowed roots browsing is sandboxed to on this node. */
13681
+ /** The roots `createDir` is sandboxed to on this node. Browsing is not confined to them. */
13626
13682
  listAllowedRoots: method(_void(), array(string()).readonly(), { auth: "admin" }),
13627
- /** Immediate subdirectories of `path` (must be within an allowed root) + free/total bytes. */
13683
+ /** Immediate subdirectories of any absolute `path` + free/total bytes. Read-only. */
13628
13684
  browse: method(object({ path: string() }), BrowseResultSchema, { auth: "admin" }),
13629
13685
  /** Create a subdirectory (within an allowed root). Returns its absolute path. */
13630
13686
  createDir: method(object({ path: string() }), object({ path: string() }), {
@@ -15655,6 +15711,13 @@ var MaskGridDimsSchema = object({
15655
15711
  * `package-event` are pure trigger kinds (no urgency dimension). Extending
15656
15712
  * this one field keeps the schema additive — a rule still declares exactly
15657
15713
  * one trigger.
15714
+ *
15715
+ * AUDIO rules add no member here, for the reason occupancy added none: the
15716
+ * enum is mirrored by hand in the viewer (`scripts/check-viewer-condition-
15717
+ * mirror.ts` fails the build on a member the app cannot render) and every
15718
+ * member costs a release train. A sustained-sound rule is therefore an
15719
+ * `immediate` rule carrying {@link NcConditions.audio} — the condition is the
15720
+ * trigger discriminator, exactly as `occupancy` is on `device-event`.
15658
15721
  */
15659
15722
  var NcDeliverySchema = _enum([
15660
15723
  "immediate",
@@ -15669,15 +15732,32 @@ var NcDeliverySchema = _enum([
15669
15732
  * depend on a provider's raw event name or payload shape.
15670
15733
  */
15671
15734
  var NcSystemEventKindSchema = _enum([
15672
- "camera-online",
15673
- "camera-offline",
15735
+ "device-online",
15736
+ "device-offline",
15737
+ "device-disabled",
15738
+ "device-enabled",
15674
15739
  "stream-online",
15675
15740
  "stream-offline",
15676
15741
  "node-online",
15677
15742
  "node-offline",
15678
15743
  "addon-update-available",
15679
- "server-update-available"
15744
+ "server-update-available",
15745
+ "alarm-triggered",
15746
+ "alarm-armed",
15747
+ "alarm-disarmed",
15748
+ "camera-online",
15749
+ "camera-offline",
15750
+ "camera-disabled",
15751
+ "camera-enabled"
15680
15752
  ]);
15753
+ /** The legacy tail of {@link NcSystemEventKindSchema} — see its docblock. */
15754
+ var NC_LEGACY_SYSTEM_EVENT_KINDS = new Set([
15755
+ "camera-online",
15756
+ "camera-offline",
15757
+ "camera-disabled",
15758
+ "camera-enabled"
15759
+ ]);
15760
+ NcSystemEventKindSchema.options.filter((kind) => !NC_LEGACY_SYSTEM_EVENT_KINDS.has(kind));
15681
15761
  /**
15682
15762
  * One coherent system-event condition. `kinds` is the required opt-in safety
15683
15763
  * gate; the remaining lists are optional narrowing filters relevant to the
@@ -15686,6 +15766,18 @@ var NcSystemEventKindSchema = _enum([
15686
15766
  var NcSystemEventConditionSchema = object({
15687
15767
  kinds: array(NcSystemEventKindSchema).min(1),
15688
15768
  deviceIds: array(number().int()).min(1).optional(),
15769
+ /**
15770
+ * Narrow a `device-*` kind to these device TYPES (`DeviceType` values —
15771
+ * `camera`, `switch`, `sensor`, `container`, …). Absent = every type, which
15772
+ * is what a liveness rule means when nobody said otherwise.
15773
+ *
15774
+ * This is where "only my cameras" is expressed, and it lives on the rule for
15775
+ * one reason: the intake cannot know which devices this household cares
15776
+ * about, and a producer-side filter is one no operator can change. Fails
15777
+ * CLOSED — a subject whose device type is unknown (an id the device mirror
15778
+ * does not carry) matches no `deviceTypes` list.
15779
+ */
15780
+ deviceTypes: array(string().min(1)).min(1).optional(),
15689
15781
  nodeIds: array(string().min(1)).min(1).optional(),
15690
15782
  packageNames: array(string().min(1)).min(1).optional()
15691
15783
  });
@@ -15736,6 +15828,47 @@ var NcOccupancyConditionSchema = object({
15736
15828
  sustainSeconds: number().int().min(0).max(3600).default(15)
15737
15829
  });
15738
15830
  /**
15831
+ * Audio condition (IMMEDIATE trigger) — a rule on SOUND, not on a picture.
15832
+ *
15833
+ * Operator-approved vocabulary (2026-08-12, option A — the same one the
15834
+ * reference notifier uses, so an operator moving between them re-uses what
15835
+ * they already know): a rule matches when, over a sampling window of
15836
+ * `samplingSeconds`, at least `hitPercent`% of the audio samples in that
15837
+ * window are HITS. A sample is a hit when it satisfies BOTH present filters:
15838
+ *
15839
+ * - `dbThreshold` — its level is at or above this many dBFS (see
15840
+ * {@link NC_AUDIO_DBFS_FLOOR}: negative-going, `0` = full scale);
15841
+ * - `labels` — the classifier put at least one of these labels on it.
15842
+ *
15843
+ * Both are OPTIONAL and independent, which is the point of the shape: a
15844
+ * loudness rule ("something loud at 3am") needs no model to be right, and a
15845
+ * label rule ("a dog barked") needs no threshold. **Fail-closed when NEITHER
15846
+ * is given** — a window in which every sample is trivially a hit would fire on
15847
+ * silence, so the engine refuses such a condition rather than notifying on
15848
+ * nothing (the schema cannot express "at least one of" without becoming a
15849
+ * ZodEffects the cap path would have to special-case).
15850
+ *
15851
+ * `hitPercent` is over the samples the window actually HOLDS, and the window
15852
+ * must be FULL before it can match — a window that has been open for two
15853
+ * seconds of its ten is 100% of nothing, and firing on it would make
15854
+ * `samplingSeconds` decorative.
15855
+ *
15856
+ * Labels are the audio macro classes (`AUDIO_MACRO_LABELS` / the NC taxonomy's
15857
+ * `audio-*` ids). Both spellings are accepted — the matcher normalizes the
15858
+ * `audio-` prefix away on both sides, so a picker that offers taxonomy ids and
15859
+ * an operator who typed `dog` mean the same thing.
15860
+ */
15861
+ var NcAudioConditionSchema = object({
15862
+ /** Audio macro labels; absent = any sound (level-only rule). */
15863
+ labels: array(string().min(1)).min(1).optional(),
15864
+ /** Level floor in dBFS (negative-going, `0` = full scale); absent = any level. */
15865
+ dbThreshold: number().min(-96).max(0).optional(),
15866
+ /** Percentage of the window's samples that must be hits (1–100). */
15867
+ hitPercent: number().int().min(1).max(100).default(60),
15868
+ /** Length of the sampling window in seconds. */
15869
+ samplingSeconds: number().int().min(1).max(300).default(10)
15870
+ });
15871
+ /**
15739
15872
  * Which zone-crossing DIRECTION a rule accepts (`ObjectEvent.zoneCrossing`).
15740
15873
  *
15741
15874
  * The values are not symmetric, and deliberately so — the absent value has to
@@ -16008,7 +16141,33 @@ var NcConditionsSchema = object({
16008
16141
  * threshold and holds for `sustainSeconds`. Fail-closed on missing
16009
16142
  * substrate (no snapshot / missing zone). See {@link NcOccupancyCondition}.
16010
16143
  */
16011
- occupancy: NcOccupancyConditionSchema.optional()
16144
+ occupancy: NcOccupancyConditionSchema.optional(),
16145
+ /**
16146
+ * IMMEDIATE only. Sustained-sound matcher — `hitPercent` of the samples in a
16147
+ * `samplingSeconds` window clear the optional `dbThreshold` and carry one of
16148
+ * the optional `labels`. Fail-closed on missing substrate (no audio samples,
16149
+ * a window that is not full yet, neither filter given). See
16150
+ * {@link NcAudioCondition}.
16151
+ *
16152
+ * Presence of this key is what makes a rule an AUDIO rule: the engine fires
16153
+ * it ONLY on a confirmed audio window, and a rule carrying it never fires on
16154
+ * a detection, a track or a device event (the same fail-closed pairing
16155
+ * `occupancy` has with the `device-event` trigger). That is how audio labels
16156
+ * leave `classes`: an audio rule names its sounds HERE, and the legacy path
16157
+ * (an `immediate` rule naming an `audio-*` class, one notification per
16158
+ * classified sample) stays exactly as it was for rules that already use it.
16159
+ *
16160
+ * NOT in {@link NC_CONDITION_CATALOG} yet, and that is the sequencing rule
16161
+ * rather than an oversight: the viewer mirrors the descriptor enums BY HAND
16162
+ * (`camstack/src/data/notification-center.ts`, guarded by
16163
+ * `scripts/check-viewer-condition-mirror.ts`) and its rule editor STRIPS the
16164
+ * condition fields it does not know when a rule is saved from the phone.
16165
+ * Publishing an editor for a condition the app cannot round-trip is how an
16166
+ * operator loses a rule's conditions by opening it — so the descriptor, the
16167
+ * admin widget and the viewer mirror land together (P2 + P3), and only then
16168
+ * does an audio rule become authorable.
16169
+ */
16170
+ audio: NcAudioConditionSchema.optional()
16012
16171
  });
16013
16172
  /** One delivery target: a `notification-output` Target ref + passthrough params. */
16014
16173
  var NcRuleTargetSchema = object({
@@ -16122,6 +16281,73 @@ var NcThrottleSchema = object({
16122
16281
  */
16123
16282
  granularity: NcThrottleGranularitySchema.optional()
16124
16283
  });
16284
+ /**
16285
+ * How long the confirm gate may hold ONE notification, and how big the picture
16286
+ * it judges may be.
16287
+ *
16288
+ * The clamp is the product decision, not a coincidence of the model: p50 was
16289
+ * 4.9 s warm and 15.3 s cold against qwen3-vl-8b, and a notification that
16290
+ * arrives after the visitor has gone is not a notification. 448 px was enough
16291
+ * to score 16/16 on the operator's parking scenario — bigger costs latency and
16292
+ * tokens for pixels the model pools away.
16293
+ */
16294
+ var NC_CONFIRM_MIN_TIMEOUT_MS = 1e3;
16295
+ var NC_CONFIRM_MAX_TIMEOUT_MS = 2e4;
16296
+ var NC_CONFIRM_DEFAULT_TIMEOUT_MS = 8e3;
16297
+ /** Comparison the model's COUNT must satisfy for the notification to fire. */
16298
+ var NcConfirmExpectSchema = object({
16299
+ op: _enum([
16300
+ ">=",
16301
+ ">",
16302
+ "<=",
16303
+ "<",
16304
+ "=="
16305
+ ]),
16306
+ count: number().int().min(0).max(1e3)
16307
+ });
16308
+ /**
16309
+ * AI CONFIRM — a vision model looks at the picture this notification is about
16310
+ * to ship and says whether it agrees with the rule.
16311
+ *
16312
+ * It runs AFTER the attachments resolve, deliberately: the crop JUDGED is the
16313
+ * crop SHIPPED (D52). A verdict about a different pixel rectangle than the one
16314
+ * on the operator's phone is not a verdict about this notification.
16315
+ *
16316
+ * FAIL-OPEN is the only safe default. A gate that cannot reach its model, or
16317
+ * whose model is cold, must not silence a camera — so `onTimeout: 'fire'` is
16318
+ * the default and every fail-open is COUNTED, because a gate that always fails
16319
+ * open looks in the log exactly like a gate that works.
16320
+ *
16321
+ * Every field is `.optional()` rather than relied on as a Zod default at the
16322
+ * runtime seam: a Zod default does NOT run on the addon→addon cap path (three
16323
+ * production failures in one day), so the gate reads absent as the constant
16324
+ * above rather than trusting a parse it may never have seen.
16325
+ */
16326
+ var NcConfirmSchema = object({
16327
+ /** Off unless asked for. An absent `confirm` and `enabled:false` are the
16328
+ * same thing, and both mean "deliver exactly as before". */
16329
+ enabled: boolean().default(false),
16330
+ /** Explicit vision profile; absent = the `purpose:'vision'` cluster default. */
16331
+ profileId: string().optional(),
16332
+ /**
16333
+ * The operator's question, in his own words. Absent = a question derived
16334
+ * from the rule (its class and its expectation).
16335
+ *
16336
+ * NEVER composed with text READ OUT OF THE FRAME. The model reads OSD
16337
+ * banners, signage and plates as instructions if you let them reach the
16338
+ * prompt — proven live — so the authoritative contract stays in the system
16339
+ * turn and only rule-authored words land here.
16340
+ */
16341
+ prompt: string().max(1e3).optional(),
16342
+ /** Fire only when the model's count satisfies this. Absent = the model's
16343
+ * own boolean verdict decides. */
16344
+ expect: NcConfirmExpectSchema.optional(),
16345
+ timeoutMs: number().int().min(NC_CONFIRM_MIN_TIMEOUT_MS).max(NC_CONFIRM_MAX_TIMEOUT_MS).default(NC_CONFIRM_DEFAULT_TIMEOUT_MS),
16346
+ /** What a timeout / unreachable model MEANS. `fire` (default) = fail-open. */
16347
+ onTimeout: _enum(["fire", "suppress"]).default("fire"),
16348
+ /** Longest edge the judged image is downscaled to before it is sent. */
16349
+ maxImagePx: number().int().min(64).max(2048).default(448)
16350
+ });
16125
16351
  /** Client-supplied rule fields (server stamps id/createdBy/createdAt/updatedAt). */
16126
16352
  var NcRuleInputSchema = object({
16127
16353
  name: string().min(1).max(200),
@@ -16182,7 +16408,13 @@ var NcRuleInputSchema = object({
16182
16408
  * `{ cap: 'alarm-panel', method: 'arm', args: { mode: 'away' } }`, the same
16183
16409
  * shape as every other actuation.
16184
16410
  */
16185
- actions: NcRuleActionsSchema.optional()
16411
+ actions: NcRuleActionsSchema.optional(),
16412
+ /**
16413
+ * AI CONFIRM — see {@link NcConfirmSchema}. `.optional()`, never defaulted:
16414
+ * a rule that predates the gate must keep delivering byte-for-byte as it
16415
+ * did, and absent is the only way to say that without a migration.
16416
+ */
16417
+ confirm: NcConfirmSchema.optional()
16186
16418
  });
16187
16419
  /**
16188
16420
  * Partial patch for `updateRule` — any subset of the input fields, plus the
@@ -16193,7 +16425,37 @@ var NcRuleInputSchema = object({
16193
16425
  * still flow through `nc.setRuleTargetEnabled` (owner-checked), never a raw
16194
16426
  * `updateRule` patch.
16195
16427
  */
16196
- var NcRulePatchSchema = NcRuleInputSchema.partial().extend({ disabledTargetIds: array(string()).optional() });
16428
+ var NcRulePatchSchema = NcRuleInputSchema.partial().extend({
16429
+ disabledTargetIds: array(string()).optional(),
16430
+ /**
16431
+ * `.partial()` DOES NOT REMOVE A FIELD'S `.default()`.
16432
+ *
16433
+ * It makes the key optional to SUPPLY; the parse still materialises the
16434
+ * default when the key is absent. And `NcRuleStore.update` merges with
16435
+ * `{ ...existing, ...patch }`, so a materialised key OVERWRITES the stored
16436
+ * one — which made every partial edit destructive:
16437
+ *
16438
+ * nc.updateRule({ throttle }) → conditions reset to `{}`
16439
+ * nc.setRuleTargetEnabled(...) → conditions reset to `{}`
16440
+ * setEnabled(ruleId, false) → conditions reset to `{}`
16441
+ *
16442
+ * A rule scoped to one camera and one zone silently became a rule that
16443
+ * matches EVERY event on EVERY camera, and lost its `media` policy
16444
+ * (zoneCrop / gif / clip / frame) and its `priority` at the same time. Seen
16445
+ * live on 2026-08-12: a rule scoped to device 617 fired on 590 and 615
16446
+ * within a minute of a two-field patch.
16447
+ *
16448
+ * So every defaulted field is re-declared here WITHOUT its default. The
16449
+ * inner defaults still apply when the caller DOES send the key — `{}` for
16450
+ * conditions remains a real instruction ("clear them") — and only the
16451
+ * absent key is now genuinely absent.
16452
+ */
16453
+ enabled: boolean().optional(),
16454
+ conditions: NcConditionsSchema.optional(),
16455
+ media: NcMediaPolicySchema.optional(),
16456
+ throttle: NcThrottleSchema.optional(),
16457
+ priority: number().int().min(1).max(5).optional()
16458
+ });
16197
16459
  /** A persisted rule. */
16198
16460
  var NcRuleSchema = NcRuleInputSchema.extend({
16199
16461
  id: string(),
@@ -16306,12 +16568,20 @@ var NC_CONDITION_CATALOG = [
16306
16568
  valueType: "systemEvent",
16307
16569
  options: [
16308
16570
  {
16309
- value: "camera-online",
16310
- label: "Camera online"
16571
+ value: "device-online",
16572
+ label: "Device online"
16573
+ },
16574
+ {
16575
+ value: "device-offline",
16576
+ label: "Device offline"
16311
16577
  },
16312
16578
  {
16313
- value: "camera-offline",
16314
- label: "Camera offline"
16579
+ value: "device-disabled",
16580
+ label: "Device switched off"
16581
+ },
16582
+ {
16583
+ value: "device-enabled",
16584
+ label: "Device switched on"
16315
16585
  },
16316
16586
  {
16317
16587
  value: "stream-online",
@@ -16336,12 +16606,24 @@ var NC_CONDITION_CATALOG = [
16336
16606
  {
16337
16607
  value: "server-update-available",
16338
16608
  label: "Server update available"
16609
+ },
16610
+ {
16611
+ value: "alarm-triggered",
16612
+ label: "Alarm triggered"
16613
+ },
16614
+ {
16615
+ value: "alarm-armed",
16616
+ label: "Alarm armed"
16617
+ },
16618
+ {
16619
+ value: "alarm-disarmed",
16620
+ label: "Alarm disarmed"
16339
16621
  }
16340
16622
  ],
16341
16623
  operator: "in",
16342
16624
  appliesTo: ["system-event"],
16343
16625
  phase: "P1",
16344
- description: "Infrastructure and update events. Optionally narrow camera/stream events by device, node events by node id, and addon updates by package name."
16626
+ description: "Infrastructure and update events. Device liveness covers EVERY device type — narrow it by device type (cameras only, say) and/or by device, node events by node id, and addon updates by package name."
16345
16627
  },
16346
16628
  {
16347
16629
  id: "devices",
@@ -16864,6 +17146,25 @@ var NcAlarmSettingsPatchSchema = NcAlarmSettingsSchema.partial();
16864
17146
  * Never authored, never stored — see `alarm-mode-coverage.ts` for why a stored
16865
17147
  * copy would lie the first time a rule is disabled.
16866
17148
  */
17149
+ /**
17150
+ * Why a device a mode NAMES is nonetheless not armed by it.
17151
+ *
17152
+ * Each value is an existing authority, never a new flag (D62): `muted` is the
17153
+ * per-camera notification switch the Notification Center already owns,
17154
+ * `detection-off` is the device's own detection binding being inactive, and
17155
+ * `offline` is the device manager's liveness. A fourth reason would mean a
17156
+ * fourth authority, and inventing one here is how a panel starts disagreeing
17157
+ * with the switches the operator actually used.
17158
+ */
17159
+ var NcAlarmSkipReasonSchema = _enum([
17160
+ "muted",
17161
+ "detection-off",
17162
+ "offline"
17163
+ ]);
17164
+ var NcAlarmSkippedDeviceSchema = object({
17165
+ deviceId: number().int(),
17166
+ reason: NcAlarmSkipReasonSchema
17167
+ });
16867
17168
  var NcAlarmModeCoverageSchema = object({
16868
17169
  mode: AlarmArmModeSchema,
16869
17170
  /** Enabled rules gated on `armed_<mode>`. Zero means the mode does nothing. */
@@ -16871,7 +17172,18 @@ var NcAlarmModeCoverageSchema = object({
16871
17172
  /** At least one covering rule has no device scope, so the mode covers all. */
16872
17173
  allDevices: boolean(),
16873
17174
  /** Ids named by the covering rules. A SUBSET when `allDevices` is true. */
16874
- deviceIds: array(number().int())
17175
+ deviceIds: array(number().int()),
17176
+ /**
17177
+ * Devices this mode NAMES but cannot actually arm, each with the switch that
17178
+ * excludes it.
17179
+ *
17180
+ * "Away armed — 12 cameras" is a promise, and a muted camera among those
17181
+ * twelve makes it false in exactly the way nobody notices until an incident.
17182
+ * Defaulted to `[]` so a coverage answer computed before this field existed
17183
+ * still parses as "nothing known to be skipped" rather than failing the whole
17184
+ * alarm tab.
17185
+ */
17186
+ skippedDevices: array(NcAlarmSkippedDeviceSchema).default([])
16875
17187
  });
16876
17188
  var NcAlarmConfigSchema = object({
16877
17189
  /**
@@ -20498,9 +20810,16 @@ var CameraStatusSchema = object({
20498
20810
  audio: CameraAudioStatusSchema.nullable(),
20499
20811
  recording: CameraRecordingStatusSchema.nullable(),
20500
20812
  /**
20501
- * Per-camera function switches an OPERATOR has turned off
20813
+ * Per-camera functions an OPERATOR has turned off
20502
20814
  * ([D61](../../../../docs/decisions/adr-0067.md)).
20503
20815
  *
20816
+ * Composed from the AUTHORITIES themselves — the wrapper bindings,
20817
+ * `RecordingConfig.enabled`, the notification mute, the broker's audio
20818
+ * policy, the camera's own microphone — via `composeSwitchedOff`, not from
20819
+ * the deprecated `getCameraSwitches` group ([D113](../../../../docs/decisions/adr-0113.md)).
20820
+ * The badge outlives the control panel: the panel was a convenience, this is
20821
+ * the difference between a camera being off and a camera being dead.
20822
+ *
20504
20823
  * This is the difference between DISABLED and BROKEN. A camera whose
20505
20824
  * `detection` block reports zero fps and whose `switchedOff` contains
20506
20825
  * `'object-detection'` was switched off by a person; the same camera with an
@@ -20939,6 +21258,10 @@ var pipelineOrchestratorCapability = {
20939
21258
  agentNodeId: string().optional()
20940
21259
  }), CameraPipelineConfigSchema),
20941
21260
  /**
21261
+ * @deprecated The aggregate switch group is being withdrawn
21262
+ * ([D113](../../../../docs/decisions/adr-0113.md)). Build nothing new on
21263
+ * this pair; read the authority directly.
21264
+ *
20942
21265
  * The whole per-camera function switch group, DERIVED — never a stored
20943
21266
  * list ([D61](../../../../docs/decisions/adr-0067.md)).
20944
21267
  *
@@ -20952,9 +21275,23 @@ var pipelineOrchestratorCapability = {
20952
21275
  * `auth: 'view'` deliberately — a NON-admin must be able to see that a
20953
21276
  * camera is quiet because somebody switched it off. Only the mutation is
20954
21277
  * admin-gated.
21278
+ *
21279
+ * **Removal plan.** It stays and it KEEPS WORKING while shipped viewers
21280
+ * (v1.0.305) and the admin UI still call it — removing it now is a broken
21281
+ * app on a device nobody can redeploy from here. It is served by a thin
21282
+ * shim over the same authorities (`camera-switch-service.ts`), so the
21283
+ * behaviour of the pair is the behaviour of the authorities by
21284
+ * construction. It is deleted once every surface reaches its own
21285
+ * component's options and the last caller is gone. Nothing on this server
21286
+ * reads it: `CameraStatus.switchedOff` is composed from the authorities
21287
+ * directly via `composeSwitchedOff`.
20955
21288
  */
20956
21289
  getCameraSwitches: method(object({ deviceId: number() }), CameraSwitchGroupSchema),
20957
21290
  /**
21291
+ * @deprecated See {@link getCameraSwitches}. Write the authority — the
21292
+ * wrapper binding, `RecordingConfig.enabled`, the notification mute — not
21293
+ * this ([D113](../../../../docs/decisions/adr-0113.md)).
21294
+ *
20958
21295
  * Flip ONE switch, routed to its existing authority.
20959
21296
  *
20960
21297
  * Never writes a parallel map: `recording` patches `RecordingConfig.enabled`
@@ -21436,24 +21773,28 @@ var snapshotCapability = {
21436
21773
  *
21437
21774
  * `getSnapshotOverview` is cache-only by contract: it answers from whatever
21438
21775
  * the wrapper happens to hold and never captures. Under D93 the client
21439
- * versions its image URL on that answer, and an image REQUEST is what enrols
21440
- * a camera in the keep-warm loop. Both of those are satisfiable by the
21441
- * client's own image cache — `expo-image` is URL-keyed and never revalidates
21442
- * — so a URL painted in a previous session comes off disk with no network,
21443
- * no enrolment, and nothing warming. Measured on the live hub: reopening
21444
- * after two minutes idle painted 15 of 16 tiles at **168 s old** with zero
21445
- * HTTP requests, and the fleet only recovered because a later poll happened
21446
- * to observe a different identity.
21776
+ * versions its image URL on that answer, and an image REQUEST was the only
21777
+ * demand signal. Both of those are satisfiable by the client's own image
21778
+ * cache — `expo-image` is URL-keyed and never revalidates — so a URL painted
21779
+ * in a previous session comes off disk with no network, no demand, and no
21780
+ * capture. Measured on the live hub: reopening after two minutes idle
21781
+ * painted 15 of 16 tiles at **168 s old** with zero HTTP requests, and the
21782
+ * fleet only recovered because a later poll happened to observe a different
21783
+ * identity.
21447
21784
  *
21448
21785
  * ## The two properties that fix it
21449
21786
  *
21450
21787
  * **It is an RPC, so no client cache can answer it.** The demand signal
21451
- * always reaches the wrapper. This method therefore MAY create keep-warm
21452
- * subscriptions, where `getSnapshotOverview` must never (D93) — the
21453
- * distinction is not "one is newer" but that the overview poll is app-wide
21454
- * (a creating overview would warm every camera on the install) while this is
21455
- * called by a rendered surface naming the tiles it is actually painting, at
21456
- * the width it is painting them.
21788
+ * always reaches the wrapper. This method therefore CAPTURES, where
21789
+ * `getSnapshotOverview` must never (D93) — the distinction is not "one is
21790
+ * newer" but that the overview poll is app-wide (a capturing overview would
21791
+ * dial every camera on the install) while this is called by a rendered
21792
+ * surface naming the tiles it is actually painting, at the width it is
21793
+ * painting them.
21794
+ *
21795
+ * Since 2026-08-11 this is the ONLY thing that refreshes a snapshot: the
21796
+ * server-side keep-warm loop was removed (operator directive — on-demand,
21797
+ * always), so a camera nobody is looking at costs nothing at all.
21457
21798
  *
21458
21799
  * **It waits, briefly and boundedly, for the capture it triggered.** The
21459
21800
  * returned `capturedAt` is the frame the link will serve, not the frame the
@@ -28763,7 +29104,19 @@ var RecordingManifestSchema = object({
28763
29104
  * profiles/subtrees/locations on this node). */
28764
29105
  var RecordingDeviceUsageSchema = object({
28765
29106
  deviceId: number(),
28766
- usedBytes: number()
29107
+ usedBytes: number(),
29108
+ /**
29109
+ * Start of this camera's OLDEST indexed segment, across every profile and
29110
+ * location — the "Oldest footage" column in Recordings → Storage, and the
29111
+ * only honest answer to "is retention actually holding?" per camera.
29112
+ *
29113
+ * `null` = the camera has no footage. OPTIONAL because a recorder that
29114
+ * predates this field omits it entirely, and a hub whose types carry the
29115
+ * field must keep validating that older provider's payload: the framework
29116
+ * (types) and the addon ship on different trains, and the addon is usually
29117
+ * the later of the two.
29118
+ */
29119
+ oldestMs: number().nullable().optional()
28767
29120
  });
28768
29121
  /** Recording storage usage + capacity for one storage location. */
28769
29122
  var RecordingLocationUsageSchema = object({
@@ -28791,6 +29144,57 @@ var RecordingStorageUsageSchema = object({
28791
29144
  locations: array(RecordingLocationUsageSchema)
28792
29145
  });
28793
29146
  /**
29147
+ * The OPERATOR-ARMED half of multi-location recordings (D116).
29148
+ *
29149
+ * The placement plan decides where NEW writes go and moves nothing. A rebalance
29150
+ * is the operator asking for the EXISTING archive to be brought into line with
29151
+ * that plan: one relocate job per (camera, profile) pile that sits on the wrong
29152
+ * location, run FIFO behind the single-flight mover.
29153
+ *
29154
+ * `plan…` and `start…` return the SAME shape deliberately — what the operator
29155
+ * confirms is exactly what gets enqueued, and `jobIds` is the only difference
29156
+ * (empty on the plan).
29157
+ */
29158
+ var RecordingRebalanceMoveSchema = object({
29159
+ deviceId: number(),
29160
+ profile: string(),
29161
+ fromLocationId: string(),
29162
+ toLocationId: string(),
29163
+ bytes: number(),
29164
+ files: number().int()
29165
+ });
29166
+ /** Why a pile that is out of place is staying there. Every refusal is
29167
+ * reported: a rebalance that silently drops a camera reads exactly like one
29168
+ * that had nothing to do. */
29169
+ var RecordingRebalanceSkipReasonSchema = _enum([
29170
+ "unassigned",
29171
+ "target-not-writable",
29172
+ "below-threshold",
29173
+ "no-headroom"
29174
+ ]);
29175
+ var RecordingRebalanceSkipSchema = object({
29176
+ deviceId: number(),
29177
+ profile: string(),
29178
+ fromLocationId: string(),
29179
+ /** The location the plan wants; null when the camera has no assignment. */
29180
+ toLocationId: string().nullable(),
29181
+ bytes: number(),
29182
+ reason: RecordingRebalanceSkipReasonSchema
29183
+ });
29184
+ var RecordingRebalancePlanSchema = object({
29185
+ moves: array(RecordingRebalanceMoveSchema),
29186
+ skipped: array(RecordingRebalanceSkipSchema),
29187
+ bytesToMove: number(),
29188
+ /** Relocate job ids enqueued. Always empty for the plan (dry-run) call. */
29189
+ jobIds: array(string())
29190
+ });
29191
+ var RecordingRebalanceInputSchema = object({
29192
+ /** Copy throttle in MB/s (default 40) — a rebalance is a background chore. */
29193
+ throttleMbps: number().min(1).max(1e3).optional(),
29194
+ /** Ignore piles smaller than this (default 1 GB). */
29195
+ minMoveGb: number().min(0).optional()
29196
+ });
29197
+ /**
28794
29198
  * Result of locating footage at a wall-clock instant for one device/profile.
28795
29199
  * `segment` carries the covering segment's window; `gap` reports the forward
28796
29200
  * nearest covered edge (`null` past the end of footage / when none exists)
@@ -29034,11 +29438,41 @@ var recordingCapability = {
29034
29438
  cancelStorageMigrationMove: method(object({ jobId: string() }), object({ cancelled: boolean() }), {
29035
29439
  kind: "mutation",
29036
29440
  auth: "admin"
29441
+ }),
29442
+ /**
29443
+ * Move footage between locations — the OPERATOR's mover, scoped to one
29444
+ * camera (and optionally to specific profiles) rather than a whole disk.
29445
+ * Queued FIFO behind the single-flight engine, so arming several is safe.
29446
+ */
29447
+ relocateFootage: method(RelocateFootageInputSchema, object({ jobId: string() }), {
29448
+ kind: "mutation",
29449
+ auth: "admin"
29450
+ }),
29451
+ /** Every relocate job this recorder knows about, newest first (in RAM: the
29452
+ * move is resumable, so a lost list costs nothing but the display). */
29453
+ listRelocateJobs: method(object({}), array(RelocateJobSchema).readonly(), {
29454
+ kind: "query",
29455
+ auth: "admin"
29456
+ }),
29457
+ /** Cancel a running or queued relocate job. A queued job never runs. */
29458
+ cancelRelocateJob: method(object({ jobId: string() }), object({ cancelled: boolean() }), {
29459
+ kind: "mutation",
29460
+ auth: "admin"
29461
+ }),
29462
+ /** What a rebalance WOULD move, and what it would refuse. Moves nothing. */
29463
+ planStorageRebalance: method(RecordingRebalanceInputSchema, RecordingRebalancePlanSchema, {
29464
+ kind: "query",
29465
+ auth: "admin"
29466
+ }),
29467
+ /** Arm the rebalance: enqueue one relocate job per planned move. */
29468
+ startStorageRebalance: method(RecordingRebalanceInputSchema, RecordingRebalancePlanSchema, {
29469
+ kind: "mutation",
29470
+ auth: "admin"
29037
29471
  })
29038
29472
  }
29039
29473
  };
29040
29474
  /**
29041
- * `recordingExport` cap — render a footage time range into a single downloadable
29475
+ * `recording-export` cap — render a footage time range into a single downloadable
29042
29476
  * MP4 (regular / accelerated / decelerated / timelapse, ± audio), kept for a
29043
29477
  * bounded lifetime with a durable history, auto-expiry, and optional
29044
29478
  * delete-after-download.
@@ -29053,10 +29487,42 @@ var recordingCapability = {
29053
29487
  */
29054
29488
  /** Playback-speed multiplier for the render (1 = realtime). */
29055
29489
  var ExportSpeedSchema = number().min(.25).max(32);
29490
+ /**
29491
+ * One dense interval, in SECONDS FROM THE EXPORT'S OWN `fromMs`.
29492
+ *
29493
+ * Relative and not absolute epoch on purpose: the renderer's frame-select
29494
+ * expression sees ffmpeg's `t`, which starts at 0 for the export's source
29495
+ * playlist. Handing it absolute epochs would make every call site responsible
29496
+ * for the same subtraction, and the one that forgot would emit a filter that
29497
+ * selects nothing — silently, as a uniform timelapse.
29498
+ */
29499
+ var ExportDenseRangeSchema = object({
29500
+ fromSec: number().nonnegative(),
29501
+ toSec: number().nonnegative()
29502
+ }).refine((r) => r.toSec > r.fromSec, { message: "dense range must have toSec > fromSec" });
29503
+ /**
29504
+ * Dense-interval overlay for a timelapse: sample at `dense.everyMs` INSIDE the
29505
+ * listed ranges and at the base `everyMs` everywhere else.
29506
+ *
29507
+ * `everyMs` must be strictly smaller than the base cadence — a dense rate that
29508
+ * is not denser renders a uniform timelapse the operator believes is two-rate.
29509
+ */
29510
+ var ExportDenseSchema = object({
29511
+ everyMs: number().int().positive(),
29512
+ ranges: array(ExportDenseRangeSchema).min(1).max(200)
29513
+ });
29056
29514
  /** Timelapse cadence — sample one source frame per `everyMs`, output at `outputFps`. */
29057
29515
  var ExportTimelapseSchema = object({
29058
29516
  everyMs: number().int().positive(),
29059
- outputFps: number().int().min(1).max(60).optional()
29517
+ outputFps: number().int().min(1).max(60).optional(),
29518
+ /** Optional second, FASTER rate over the intervals that matter. */
29519
+ dense: ExportDenseSchema.optional()
29520
+ }).superRefine((v, ctx) => {
29521
+ if (v.dense !== void 0 && v.dense.everyMs >= v.everyMs) ctx.addIssue({
29522
+ code: ZodIssueCode.custom,
29523
+ message: "dense.everyMs must be strictly smaller than the base everyMs",
29524
+ path: ["dense", "everyMs"]
29525
+ });
29060
29526
  });
29061
29527
  /**
29062
29528
  * Render options. `speed` and `timelapse` are mutually exclusive. `includeAudio`
@@ -29114,8 +29580,21 @@ var ExportDownloadSchema = object({
29114
29580
  url: string(),
29115
29581
  endpoints: array(string())
29116
29582
  });
29583
+ /**
29584
+ * A finished export's bytes, inline.
29585
+ *
29586
+ * `bytes` is the DECODED length — the number the caller bounds and logs
29587
+ * against, so nobody has to infer it from the base64 length.
29588
+ */
29589
+ var ExportBytesSchema = object({
29590
+ base64: string(),
29591
+ contentType: string(),
29592
+ /** Suggested filename, extension included. */
29593
+ name: string(),
29594
+ bytes: number().int().nonnegative()
29595
+ });
29117
29596
  var recordingExportCapability = {
29118
- name: "recordingExport",
29597
+ name: "recording-export",
29119
29598
  scope: "system",
29120
29599
  mode: "singleton",
29121
29600
  methods: {
@@ -29153,6 +29632,27 @@ var recordingExportCapability = {
29153
29632
  getDownloadUrl: method(object({ exportId: string() }), ExportDownloadSchema, {
29154
29633
  kind: "query",
29155
29634
  auth: "protected"
29635
+ }),
29636
+ /**
29637
+ * The finished file's BYTES, base64, for a caller that must republish them
29638
+ * somewhere a session-less fetcher can reach.
29639
+ *
29640
+ * `getDownloadUrl` is the right answer for a human: the download route is
29641
+ * served `access: 'authenticated'`, which a browser satisfies and a
29642
+ * notifier BACKEND does not. It answers a RELATIVE path, so it is not even
29643
+ * a URL an outside fetcher could try. This method exists for the one case
29644
+ * that needs the other thing — a scheduled timelapse whose video has to
29645
+ * become a public attachment on the notification artifact plane.
29646
+ *
29647
+ * Deliberately narrow: `ready` only (a queued, rendering, failed, expired
29648
+ * or deleted export has no file, and answering "0 bytes" for one is how a
29649
+ * caller ships an empty attachment), still inside its lifetime, and under
29650
+ * {@link RECORDING_EXPORT_MAX_READ_BYTES}. Every refusal throws with the
29651
+ * reason — none of them is silent.
29652
+ */
29653
+ readExportBytes: method(object({ exportId: string() }), ExportBytesSchema, {
29654
+ kind: "query",
29655
+ auth: "protected"
29156
29656
  })
29157
29657
  }
29158
29658
  };
@@ -35089,6 +35589,12 @@ Object.freeze({
35089
35589
  addonId: null,
35090
35590
  access: "create"
35091
35591
  },
35592
+ "recording.cancelRelocateJob": {
35593
+ capName: "recording",
35594
+ capScope: "system",
35595
+ addonId: null,
35596
+ access: "create"
35597
+ },
35092
35598
  "recording.cancelStorageMigrationMove": {
35093
35599
  capName: "recording",
35094
35600
  capScope: "system",
@@ -35143,6 +35649,12 @@ Object.freeze({
35143
35649
  addonId: null,
35144
35650
  access: "view"
35145
35651
  },
35652
+ "recording.listRelocateJobs": {
35653
+ capName: "recording",
35654
+ capScope: "system",
35655
+ addonId: null,
35656
+ access: "view"
35657
+ },
35146
35658
  "recording.locateSegment": {
35147
35659
  capName: "recording",
35148
35660
  capScope: "system",
@@ -35155,6 +35667,12 @@ Object.freeze({
35155
35667
  addonId: null,
35156
35668
  access: "create"
35157
35669
  },
35670
+ "recording.planStorageRebalance": {
35671
+ capName: "recording",
35672
+ capScope: "system",
35673
+ addonId: null,
35674
+ access: "view"
35675
+ },
35158
35676
  "recording.pruneFootage": {
35159
35677
  capName: "recording",
35160
35678
  capScope: "system",
@@ -35179,6 +35697,12 @@ Object.freeze({
35179
35697
  addonId: null,
35180
35698
  access: "create"
35181
35699
  },
35700
+ "recording.relocateFootage": {
35701
+ capName: "recording",
35702
+ capScope: "system",
35703
+ addonId: null,
35704
+ access: "create"
35705
+ },
35182
35706
  "recording.renderClip": {
35183
35707
  capName: "recording",
35184
35708
  capScope: "system",
@@ -35215,38 +35739,50 @@ Object.freeze({
35215
35739
  addonId: null,
35216
35740
  access: "create"
35217
35741
  },
35742
+ "recording.startStorageRebalance": {
35743
+ capName: "recording",
35744
+ capScope: "system",
35745
+ addonId: null,
35746
+ access: "create"
35747
+ },
35218
35748
  "recordingExport.cancelExport": {
35219
- capName: "recordingExport",
35749
+ capName: "recording-export",
35220
35750
  capScope: "system",
35221
35751
  addonId: null,
35222
35752
  access: "create"
35223
35753
  },
35224
35754
  "recordingExport.createExport": {
35225
- capName: "recordingExport",
35755
+ capName: "recording-export",
35226
35756
  capScope: "system",
35227
35757
  addonId: null,
35228
35758
  access: "create"
35229
35759
  },
35230
35760
  "recordingExport.deleteExport": {
35231
- capName: "recordingExport",
35761
+ capName: "recording-export",
35232
35762
  capScope: "system",
35233
35763
  addonId: null,
35234
35764
  access: "delete"
35235
35765
  },
35236
35766
  "recordingExport.getDownloadUrl": {
35237
- capName: "recordingExport",
35767
+ capName: "recording-export",
35238
35768
  capScope: "system",
35239
35769
  addonId: null,
35240
35770
  access: "view"
35241
35771
  },
35242
35772
  "recordingExport.getExport": {
35243
- capName: "recordingExport",
35773
+ capName: "recording-export",
35244
35774
  capScope: "system",
35245
35775
  addonId: null,
35246
35776
  access: "view"
35247
35777
  },
35248
35778
  "recordingExport.listExports": {
35249
- capName: "recordingExport",
35779
+ capName: "recording-export",
35780
+ capScope: "system",
35781
+ addonId: null,
35782
+ access: "view"
35783
+ },
35784
+ "recordingExport.readExportBytes": {
35785
+ capName: "recording-export",
35250
35786
  capScope: "system",
35251
35787
  addonId: null,
35252
35788
  access: "view"
@@ -36683,6 +37219,104 @@ var FramerateField = number().int().min(1).max(60);
36683
37219
  var TargetsField = array(NcRuleTargetSchema).min(1);
36684
37220
  var PriorityField = number().int().min(1).max(5);
36685
37221
  /**
37222
+ * Explicit override of the DENSE sampling cadence, seconds.
37223
+ *
37224
+ * Absent ⇒ derived as `max(1s, cadenceSec / 30)` — a 30 s base samples every
37225
+ * 1 s inside a detection range. (It was `base / 10` until 2026-08-12, which
37226
+ * made that same base 3 s and rendered a person pass as two frames.)
37227
+ *
37228
+ * THE ARITHMETIC. A detection range of `rangeSec` seconds sampled every
37229
+ * `denseCadenceSec` and played at `framerate` occupies
37230
+ *
37231
+ * outputSeconds = (rangeSec / denseCadenceSec) / framerate
37232
+ *
37233
+ * so a 7 s pass at 1 s / 12 fps is 0.58 s of video, and at 0.5 s it is 1.17 s.
37234
+ * Halving this field doubles the frames INSIDE ranges only — the base cadence,
37235
+ * and therefore the length of a quiet night, does not move.
37236
+ *
37237
+ * Floored at {@link TIMELAPSE_DENSE_FLOOR_SEC}: asking for frames faster than
37238
+ * the recording has them returns the same frames, requested twice. Must be
37239
+ * STRICTLY smaller than `cadenceSec` — a dense rate that is not denser renders
37240
+ * a uniform video the operator believes is two-rate — and upsert refuses it
37241
+ * rather than letting the export cap reject the render hours after the window.
37242
+ */
37243
+ var DenseCadenceSecField = number().min(.1).max(3600);
37244
+ /**
37245
+ * Minimum seconds of OUTPUT video each detection range must occupy.
37246
+ *
37247
+ * The operator-facing form of the arithmetic above: instead of solving for a
37248
+ * cadence, state the dwell and let the renderer solve. `minDwellSec: 1` on a
37249
+ * 30 s base at 12 fps turns a 7 s pass into a full second of video by sampling
37250
+ * that range every ~583 ms.
37251
+ *
37252
+ * ONE CADENCE SERVES EVERY RANGE. `ExportDenseSchema.everyMs` is global — the
37253
+ * ranges are terms of a single ffmpeg `select` expression that cannot vary rate
37254
+ * per term — so the MOST DEMANDING (shortest) range sets the rate and longer
37255
+ * ranges are sampled denser than they need. Per-range cadences require a cap
37256
+ * schema change and are the tracked follow-up.
37257
+ *
37258
+ * A range too short to reach the dwell even at {@link TIMELAPSE_DENSE_FLOOR_SEC}
37259
+ * is rendered with every frame that EXISTS and no more: the guarantee is capped
37260
+ * by real footage, never met by duplicating frames into motion that never
37261
+ * happened.
37262
+ */
37263
+ var MinDwellSecField = number().min(0).max(60);
37264
+ /**
37265
+ * Caption burned into the notification's preview frame.
37266
+ *
37267
+ * Same `{{var}}` vocabulary as {@link TimelapseTemplateSchema} (`camera`,
37268
+ * `rule`, `from`, `to`) and rendered by the SAME renderer — a second
37269
+ * templating dialect for one field would be a second thing to explain.
37270
+ *
37271
+ * Absent ⇒ {@link DEFAULT_TIMELAPSE_PREVIEW_TEXT}. An EMPTY STRING is the
37272
+ * operator saying "the frame, no caption" — a distinct, reachable answer, and
37273
+ * the reason this is not `.min(1)`.
37274
+ */
37275
+ var PreviewTextField = string().max(200);
37276
+ /**
37277
+ * Whether the notification's preview is a STILL or a short animation.
37278
+ *
37279
+ * The operator's ask, verbatim: *"inviato come gif o video (come per le altre
37280
+ * rule)"* — his Scrypted advanced-notifier has a `gifRule`, and a ten-hour
37281
+ * night reads better as three seconds of motion than as one frame of it. Both
37282
+ * modes get the SAME treatment (blurred frame, large centred title); `'gif'`
37283
+ * simply applies it to a dozen frames sampled across the render and assembles
37284
+ * them.
37285
+ *
37286
+ * `'image'` is the default and stays the default: a GIF costs a dozen ffmpeg
37287
+ * seeks and a palette pass, and no rule that never asked for one should start
37288
+ * paying that on the deploy that shipped it.
37289
+ *
37290
+ * A GIF that cannot be assembled DEGRADES to the still — never to nothing.
37291
+ */
37292
+ var PreviewModeField = _enum(["image", "gif"]);
37293
+ /**
37294
+ * Which detection classes the notification reports counts for.
37295
+ *
37296
+ * The counts come from the tracks the render ALREADY fetched for its dense-range
37297
+ * plan — no second query — aggregated per class. Absent or empty means "every
37298
+ * class the window actually contained", which is what an operator who never
37299
+ * opened the field wants; a list narrows it (`['person']` on a driveway that
37300
+ * counts cars all night).
37301
+ *
37302
+ * Class names are the tracker's own (`person`, `vehicle`, `animal`, `package`,
37303
+ * …). An unknown name simply never matches and reports nothing — it is not an
37304
+ * error, because a rule may legitimately name a class this camera's model does
37305
+ * not emit.
37306
+ *
37307
+ * The counts are exposed to {@link TimelapseTemplateSchema} as:
37308
+ * - `{{detections}}` — total over the reported classes
37309
+ * - `{{detectionSummary}}` — `2 persone, 1 veicolo`
37310
+ * - `{{count_person}}` / `{{count_vehicle}}` / `{{count_animal}}` / … —
37311
+ * one per class, `count_` + the class name
37312
+ *
37313
+ * With NO custom body template the summary is appended to the derived body, and
37314
+ * only when the total is non-zero: a nightly `0 rilevamenti` is a line nobody
37315
+ * reads. With a custom template the operator owns every word — nothing is
37316
+ * appended, so `{{detectionSummary}}` is how he asks for it.
37317
+ */
37318
+ var ReportClassesField = array(string().min(1).max(40)).max(20);
37319
+ /**
36686
37320
  * Client-supplied timelapse-rule fields. The server stamps id / createdBy /
36687
37321
  * createdAt / updatedAt / ownerUserId / lastGeneratedAt — none of them appear
36688
37322
  * here (see the ownership note above).
@@ -36702,9 +37336,30 @@ var TimelapseRuleInputSchema = object({
36702
37336
  cadenceSec: CadenceSecField.default(15),
36703
37337
  /** Output frames per second of the assembled mp4 (predecessor parity). */
36704
37338
  framerate: FramerateField.default(10),
37339
+ /**
37340
+ * Explicit dense cadence — see {@link DenseCadenceSecField}. Absent ⇒ derived
37341
+ * as `max(1s, cadenceSec / 30)`, which is what every rule written before this
37342
+ * field gets.
37343
+ */
37344
+ denseCadenceSec: DenseCadenceSecField.optional(),
37345
+ /** Output-seconds guarantee per detection range — see {@link MinDwellSecField}. */
37346
+ minDwellSec: MinDwellSecField.optional(),
36705
37347
  /** `notification-output` targets the finished video/thumbnail is sent to. */
36706
37348
  targets: TargetsField,
36707
37349
  template: TimelapseTemplateSchema.optional(),
37350
+ /**
37351
+ * Caption on the notification's PREVIEW FRAME — see {@link PreviewTextField}
37352
+ * and {@link DEFAULT_TIMELAPSE_PREVIEW_TEXT}.
37353
+ *
37354
+ * Deliberately NOT part of {@link TimelapseTemplateSchema}: that object is
37355
+ * the notification's title/body, and clearing it (`template: null`) must not
37356
+ * silently clear the caption too.
37357
+ */
37358
+ previewText: PreviewTextField.optional(),
37359
+ /** Still or animation — see {@link PreviewModeField}. */
37360
+ previewMode: PreviewModeField.default("image"),
37361
+ /** Classes the notification counts — see {@link ReportClassesField}. */
37362
+ reportClasses: ReportClassesField.optional(),
36708
37363
  /** Canonical notification priority ordinal (1..5); per-target overridable. */
36709
37364
  priority: PriorityField.default(3)
36710
37365
  });
@@ -36715,8 +37370,13 @@ object({
36715
37370
  schedule: NcScheduleSchema.optional(),
36716
37371
  cadenceSec: CadenceSecField.optional(),
36717
37372
  framerate: FramerateField.optional(),
37373
+ denseCadenceSec: DenseCadenceSecField.optional(),
37374
+ minDwellSec: MinDwellSecField.optional(),
36718
37375
  targets: TargetsField.optional(),
36719
37376
  template: TimelapseTemplateSchema.nullable().optional(),
37377
+ previewText: PreviewTextField.optional(),
37378
+ previewMode: PreviewModeField.optional(),
37379
+ reportClasses: ReportClassesField.optional(),
36720
37380
  priority: PriorityField.optional()
36721
37381
  });
36722
37382
  TimelapseRuleInputSchema.extend({
@@ -36728,10 +37388,28 @@ TimelapseRuleInputSchema.extend({
36728
37388
  */
36729
37389
  ownerUserId: string().optional(),
36730
37390
  /**
36731
- * Epoch-ms of the last successful generation the 1-hour re-generation
36732
- * guard's durable state (predecessor parity). Absent = never generated.
37391
+ * Epoch-ms of the NEWEST successful generation across every camera of this
37392
+ * rule. What a UI shows, and the compatibility floor for
37393
+ * {@link readTimelapseGeneratedAt}. Absent = never generated.
36733
37394
  */
36734
37395
  lastGeneratedAt: number().optional(),
37396
+ /**
37397
+ * PER-CAMERA generation state, keyed by `String(deviceId)` — the
37398
+ * re-generation guard's real durable state.
37399
+ *
37400
+ * One rule covers several cameras and each renders its own video, so a rule
37401
+ * -wide stamp is wrong in the direction that DESTROYS work: camera A
37402
+ * succeeding at 06:05 tells camera B, whose render failed, that it is
37403
+ * already done — and B's night is gone for good, because the window will not
37404
+ * come back.
37405
+ *
37406
+ * ADDITIVE, so the migration is free: a row written before this field simply
37407
+ * has no map, and {@link readTimelapseGeneratedAt} falls back to
37408
+ * {@link TimelapseRuleSchema.shape.lastGeneratedAt}. Reading an old row as
37409
+ * "never generated" would re-render and re-notify every camera of every rule
37410
+ * once, on the deploy that shipped the map.
37411
+ */
37412
+ generatedByDevice: record(string(), number()).optional(),
36735
37413
  /** userId of the caller who created the rule (server-stamped). */
36736
37414
  createdBy: string(),
36737
37415
  createdAt: number(),