@camstack/addon-pipeline 1.2.199 → 1.2.201

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.
Files changed (43) hide show
  1. package/dist/audio-analyzer/index.js +2 -2
  2. package/dist/audio-analyzer/index.mjs +2 -2
  3. package/dist/{default-detection-model-O1uBusV2.mjs → default-detection-model-fUMLQHB2.mjs} +1 -1
  4. package/dist/{default-detection-model-CMe44Kid.js → default-detection-model-nKgB-_mg.js} +1 -1
  5. package/dist/detection-pipeline/index.js +5 -5
  6. package/dist/detection-pipeline/index.mjs +4 -4
  7. package/dist/{dist-DmCrC2yx.js → dist-Bd875pfj.js} +912 -606
  8. package/dist/{dist-B23u1tmN.mjs → dist-D2jcEuZD.mjs} +865 -607
  9. package/dist/{lazy-sharp-DBHsD2lh.js → lazy-sharp-B4KrLp0u.js} +1 -1
  10. package/dist/motion-wasm/index.js +2 -2
  11. package/dist/motion-wasm/index.mjs +1 -1
  12. package/dist/{node-D1MLn6oV.mjs → node-CmfXSgr3.mjs} +420 -381
  13. package/dist/{node-DCSTbt6C.js → node-dwhLC9Fh.js} +423 -378
  14. package/dist/pipeline-runner/index.js +8 -7
  15. package/dist/pipeline-runner/index.mjs +7 -6
  16. package/dist/{process-memory-9lvvhHym.js → process-memory-BDRJK5Vq.js} +1 -1
  17. package/dist/{process-memory-P24NTDb1.mjs → process-memory-CH5qO7AX.mjs} +1 -1
  18. package/dist/recorder/index.js +1208 -1015
  19. package/dist/recorder/index.mjs +1203 -1010
  20. package/dist/restream-intent-B1Difcnq.js +313 -0
  21. package/dist/restream-intent-DWe2kInY.mjs +248 -0
  22. package/dist/{prebuffer-DmBYRQWg.mjs → retire-root-keys-Csb8z2f3.js} +12 -64
  23. package/dist/{prebuffer-DqdeLSsk.js → retire-root-keys-DZvlso2l.mjs} +1 -99
  24. package/dist/{segment-demux-js-Bm0woPkn.mjs → segment-demux-js-BHTuEBhC.mjs} +1 -1
  25. package/dist/{segment-demux-js-Ck_F5bsO.js → segment-demux-js-cmfLROII.js} +1 -1
  26. package/dist/session-decode/decode-worker-child.js +25 -5
  27. package/dist/session-decode/decode-worker-child.mjs +24 -4
  28. package/dist/stream-broker/_stub.js +2 -2
  29. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-Sq93hI-g.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DZcCQR5t.mjs} +3 -3
  30. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CrzkEz67.mjs +26 -0
  31. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-Dj_qoc1e.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-cYr6Swud.mjs} +1 -1
  32. package/dist/stream-broker/demux-worker-child.js +1 -1
  33. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  34. package/dist/stream-broker/{hostInit-COt259tU.mjs → hostInit-CZt5Lq_M.mjs} +3 -3
  35. package/dist/stream-broker/index.js +181 -48
  36. package/dist/stream-broker/index.mjs +172 -39
  37. package/dist/stream-broker/remoteEntry.js +1 -1
  38. package/dist/{worker-protocol-c1r1Yddg.js → worker-protocol--PcFsRrE.js} +1 -1
  39. package/dist/{worker-protocol-BxpGZ0Dt.mjs → worker-protocol-D4GyRNnY.mjs} +1 -1
  40. package/package.json +5 -1
  41. package/dist/restream-intent-B4BXZra7.mjs +0 -72
  42. package/dist/restream-intent-Cv9x3jmu.js +0 -89
  43. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BcjLrxhF.mjs +0 -26
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- //#region ../types/dist/event-category-zAv7pMUz.mjs
2
+ //#region ../types/dist/event-category-CnLqLOKs.mjs
3
3
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
4
4
  EventCategory["SystemBoot"] = "system.boot";
5
5
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -194,6 +194,19 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
194
194
  EventCategory["ProcessCrashed"] = "process.crashed";
195
195
  EventCategory["ProcessRestartScheduled"] = "process.restart_scheduled";
196
196
  EventCategory["ProcessRestarted"] = "process.restarted";
197
+ /**
198
+ * The SET of storage locations changed — one was created, edited, enabled,
199
+ * disabled or deleted through `storage.upsertLocation` / `deleteLocation`.
200
+ *
201
+ * Telemetry, not a transaction (D8/D11): every consumer that re-resolves on
202
+ * it must also converge on its own periodic path, because a dropped event
203
+ * must not leave a node writing to yesterday's disk set forever. It exists
204
+ * because there was NO signal at all — an operator who added a second
205
+ * recordings disk in the admin UI got nothing, and the recorder kept its
206
+ * resolved locations until something else happened to re-resolve them
207
+ * (D387). Payload `StorageLocationsChangedPayload`.
208
+ */
209
+ EventCategory["StorageLocationsChanged"] = "storage.locations-changed";
197
210
  EventCategory["RecordingStarted"] = "recording.started";
198
211
  EventCategory["RecordingStopped"] = "recording.stopped";
199
212
  EventCategory["RecordingError"] = "recording.error";
@@ -8594,362 +8607,6 @@ var CameraSwitchGroupSchema = object({
8594
8607
  fetchedAt: number()
8595
8608
  });
8596
8609
  /**
8597
- * Per-component log CHANNELS — the gate a hot path consults, and the registry
8598
- * an addon declares its channels in.
8599
- *
8600
- * ## Two axes, deliberately separated
8601
- *
8602
- * - **DECLARATION** — which channels exist. Only the addon knows:
8603
- * `stream-broker` knows webrtc/ICE/RTP, `provider-reolink` knows
8604
- * baichuan/handshake. A hand-wired central list rots at the first addition,
8605
- * and rots silently. So a channel is declared where it is consulted, and the
8606
- * `log-channels` capability enumerates the declarations.
8607
- * - **VALUE** — at which level, for which scope, until when. That stays ONE
8608
- * thing: the logging settings document on the `system` cap. Two authorities
8609
- * over the values is the exact defect
8610
- * `docs/design/plans/2026-08-26-logging-per-componente.md` was written to
8611
- * remove; re-introducing it from the cure side would be grotesque.
8612
- *
8613
- * Nothing in this file reads a clock, an env var or a store. The registry is
8614
- * a MIRROR: it is moved only by {@link LogChannelRegistry.apply}, called off
8615
- * the hot path with a value somebody actually read, and by
8616
- * {@link LogChannelRegistry.tick}, called on a timer. A store read that fails
8617
- * never reaches here, so it can neither disarm an armed channel nor arm a
8618
- * disarmed one (D49).
8619
- *
8620
- * ## The canonical call shape
8621
- *
8622
- * ```ts
8623
- * if (CH_RTP.on && CH_RTP.wants(deviceId)) {
8624
- * CH_RTP.log(logger, 'rtp subscriber added', { tags: { deviceId }, meta: { ssrc } })
8625
- * }
8626
- * ```
8627
- *
8628
- * `on` is a plain boolean FIELD — never a getter — and it is the FIRST thing
8629
- * read. Disarmed, a call site costs one load and one branch, and the `extras`
8630
- * object literal is never constructed because it lives inside the branch. It
8631
- * is the same shape already proven in production at `stream-broker.ts:1650`,
8632
- * and the same discipline `LoggingGate.allowsDestination` uses for the
8633
- * destination floor (measured at 1.93 ns/call when off).
8634
- *
8635
- * ## Why a channel emits at `info`
8636
- *
8637
- * `loki-logging.addon.ts` pins the destination default at `info` and
8638
- * `loki-destination.ts` drops everything below it, so a line emitted at
8639
- * `debug` never reaches Loki and the hub's in-memory ring only holds ~35
8640
- * minutes. A diagnostic that cannot be read an hour later is worse than no
8641
- * diagnostic, because it looks done. {@link LogChannelGate.log} therefore
8642
- * emits at the channel's declared level, whose schema floor is `info`.
8643
- */
8644
- /**
8645
- * The level a channel writes at once armed.
8646
- *
8647
- * `debug` is absent ON PURPOSE and not by omission: below `info` the line does
8648
- * not leave the process for Loki, and the whole point of arming a channel is
8649
- * to read it later.
8650
- */
8651
- var LogChannelLevelSchema = _enum([
8652
- "info",
8653
- "warn",
8654
- "error"
8655
- ]);
8656
- /**
8657
- * What an addon declares about one channel. No value, no state — a
8658
- * declaration is inert.
8659
- */
8660
- var LogChannelDescriptorSchema = object({
8661
- /**
8662
- * Dotted `area.thing`, unique across the workspace. `area` is conventionally
8663
- * the addon's short name so an operator reading a channel list can tell who
8664
- * owns it without a second lookup.
8665
- */
8666
- name: string().min(3).regex(/^[a-z0-9-]+(\.[a-z0-9-]+)+$/, "a channel name is dotted lower-kebab, e.g. area.thing"),
8667
- /** One sentence: what the operator will SEE after arming it. */
8668
- description: string().min(1),
8669
- /** The level its lines are emitted at. Never below `info`. */
8670
- defaultLevel: LogChannelLevelSchema,
8671
- /**
8672
- * Whether this channel can be narrowed to a camera.
8673
- *
8674
- * `true` is a PROMISE with two halves, and both must hold: the gate is
8675
- * consulted with the numeric device id, AND every line the channel admits
8676
- * carries `tags: { deviceId }` with that same numeric id. The second half is
8677
- * what makes `| json | deviceId="617"` work in Loki — `loki-payload.ts`
8678
- * keeps `deviceId` out of the stream labels for cardinality, so the tag in
8679
- * the body is the only way to filter.
8680
- *
8681
- * A channel whose lines carry the device only in `meta` (or not at all) is
8682
- * declared `false`. Declaring it `true` anyway would be a lie the UI repeats:
8683
- * the operator narrows to one camera, sees nothing, and concludes the code
8684
- * path was never taken.
8685
- */
8686
- perDevice: boolean()
8687
- });
8688
- /**
8689
- * An armed window over one channel, as the document hands it to a mirror.
8690
- *
8691
- * A window is a DEADLINE, never a flag (ADR-0244): a channel somebody forgot
8692
- * expires by itself, which is the one failure a boolean cannot avoid.
8693
- */
8694
- var LogChannelWindowSchema = object({
8695
- channel: string().min(1),
8696
- /** Epoch ms the window closes at. */
8697
- armedUntilMs: number(),
8698
- /** `null` = every camera. A non-empty list narrows to those numeric ids. */
8699
- deviceIds: array(number().int()).readonly().nullable()
8700
- });
8701
- /**
8702
- * The gate a hot path holds.
8703
- *
8704
- * Obtain it ONCE — at module scope or in a constructor — and keep the
8705
- * reference. Looking a channel up by name per line would put a Map lookup on
8706
- * the path this class exists to keep free.
8707
- */
8708
- var LogChannelGate = class {
8709
- descriptor;
8710
- /**
8711
- * HOT PATH GUARD. A plain data FIELD, and it must stay one.
8712
- *
8713
- * `log-channel.spec.ts` asserts the property descriptor has no getter and
8714
- * booby-traps the device set, so turning this into an accessor — or reading
8715
- * anything before it — fails the spec instead of taxing every line the
8716
- * process emits.
8717
- */
8718
- on = false;
8719
- /** `null` while armed for every camera. Never read while `on` is false. */
8720
- devices = null;
8721
- level;
8722
- closesAtMs = 0;
8723
- constructor(descriptor) {
8724
- this.descriptor = descriptor;
8725
- this.level = descriptor.defaultLevel;
8726
- }
8727
- /** Epoch ms this channel disarms itself at. 0 when disarmed. */
8728
- get armedUntilMs() {
8729
- return this.on ? this.closesAtMs : 0;
8730
- }
8731
- /**
8732
- * Does this channel want a line about `deviceId`?
8733
- *
8734
- * Call it only behind `gate.on &&`. On its own it is still correct — the
8735
- * guard is repeated inside — but the point of the prefix is that a disarmed
8736
- * channel must not pay the call at all.
8737
- */
8738
- wants(deviceId) {
8739
- if (!this.on) return false;
8740
- return this.devices === null || this.devices.has(deviceId);
8741
- }
8742
- /**
8743
- * Emit one line on this channel, at the channel's declared level.
8744
- *
8745
- * The channel name is added as `tags.logChannel` so LogQL can select the
8746
- * channel without matching on the message text, and whatever `tags` the
8747
- * caller passed — `deviceId` above all — is preserved.
8748
- */
8749
- log(logger, message, extras) {
8750
- if (!this.on) return;
8751
- const tags = {
8752
- ...extras.tags,
8753
- logChannel: this.descriptor.name
8754
- };
8755
- const line = {
8756
- ...extras,
8757
- tags
8758
- };
8759
- if (this.level === "error") logger.error(message, line);
8760
- else if (this.level === "warn") logger.warn(message, line);
8761
- else logger.info(message, line);
8762
- }
8763
- /**
8764
- * Arm (or RE-arm, restarting) this channel. Off the hot path only.
8765
- *
8766
- * An empty `deviceIds` list is treated as "every camera" rather than "no
8767
- * camera": a window that matches nothing is indistinguishable from a
8768
- * disarmed one, and the operator who asked for it would wait for lines that
8769
- * can never come.
8770
- */
8771
- arm(window) {
8772
- const ids = window.deviceIds;
8773
- this.devices = ids === null || ids.length === 0 ? null : new Set(ids);
8774
- this.closesAtMs = window.armedUntilMs;
8775
- this.on = true;
8776
- }
8777
- /** Disarm. Off the hot path only. */
8778
- disarm() {
8779
- this.on = false;
8780
- this.devices = null;
8781
- this.closesAtMs = 0;
8782
- }
8783
- };
8784
- /**
8785
- * Every channel this PROCESS declares, and the mirror of what is armed on it.
8786
- *
8787
- * One per process. A forked runner has its own, and it is refreshed through
8788
- * the `log-channels` capability by the hub that owns the document — the
8789
- * registry never reaches for a value itself.
8790
- */
8791
- var LogChannelRegistry = class {
8792
- gates = /* @__PURE__ */ new Map();
8793
- /**
8794
- * Declare a channel and get its gate.
8795
- *
8796
- * A duplicate name throws. Two declarations of one name is a programming
8797
- * error, not a merge: the operator would arm one and the other would stay
8798
- * dark, which is the dead-knob shape (D62) with an extra step.
8799
- */
8800
- declare(descriptor) {
8801
- const parsed = LogChannelDescriptorSchema.parse(descriptor);
8802
- if (this.gates.get(parsed.name) !== void 0) throw new Error(`log channel "${parsed.name}" is already declared in this process — two declarations of one name is a programming error, not a merge`);
8803
- const gate = new LogChannelGate(parsed);
8804
- this.gates.set(parsed.name, gate);
8805
- return gate;
8806
- }
8807
- /** The declarations, sorted by name so a list is stable to read and diff. */
8808
- list() {
8809
- return [...this.gates.values()].map((gate) => gate.descriptor).sort((a, b) => a.name.localeCompare(b.name));
8810
- }
8811
- /** The gate for a declared channel, or `undefined`. */
8812
- gate(name) {
8813
- return this.gates.get(name);
8814
- }
8815
- /**
8816
- * Apply the FULL set of armed windows. Off the hot path.
8817
- *
8818
- * Full, not incremental, and that is the whole design: the document is the
8819
- * authority, so a channel the document does not name is disarmed here. An
8820
- * incremental apply would let a disarm get lost in transit and leave a
8821
- * channel running that nobody can see is running.
8822
- *
8823
- * A window already past its deadline is ignored rather than armed — a
8824
- * restore that re-armed an expired window would make a forgotten diagnostic
8825
- * immortal across restarts.
8826
- *
8827
- * Returns the names it could not place, so the caller can log them: a
8828
- * channel named in the document that this process does not declare is
8829
- * either a typo or an addon that has not booted yet, and both deserve a
8830
- * line rather than silence.
8831
- */
8832
- apply(windows, nowMs) {
8833
- const wanted = /* @__PURE__ */ new Map();
8834
- const unknown = [];
8835
- for (const window of windows) {
8836
- if (window.armedUntilMs <= nowMs) continue;
8837
- if (!this.gates.has(window.channel)) {
8838
- unknown.push(window.channel);
8839
- continue;
8840
- }
8841
- wanted.set(window.channel, window);
8842
- }
8843
- for (const [name, gate] of this.gates) {
8844
- const window = wanted.get(name);
8845
- if (window === void 0) gate.disarm();
8846
- else gate.arm(window);
8847
- }
8848
- return unknown;
8849
- }
8850
- /**
8851
- * Disarm whatever has run out. Called on a timer, NEVER from a log path — a
8852
- * diagnostic that adds a `Date.now()` to the path it is measuring measures
8853
- * itself.
8854
- *
8855
- * Returns the names it closed, so the caller can write the one line that
8856
- * says a window ended and stops "it went quiet" from reading as "the branch
8857
- * was not taken".
8858
- */
8859
- tick(nowMs) {
8860
- const closed = [];
8861
- for (const [name, gate] of this.gates) if (gate.on && gate.armedUntilMs <= nowMs) {
8862
- gate.disarm();
8863
- closed.push(name);
8864
- }
8865
- return closed;
8866
- }
8867
- /** The channels armed right now, as the document would describe them. */
8868
- armed() {
8869
- const out = [];
8870
- for (const [name, gate] of this.gates) if (gate.on) out.push({
8871
- channel: name,
8872
- armedUntilMs: gate.armedUntilMs,
8873
- deviceIds: null
8874
- });
8875
- return out;
8876
- }
8877
- };
8878
- /**
8879
- * Process-wide holder for the {@link LogChannelRegistry}.
8880
- *
8881
- * Three call sites that never meet need the SAME instance: the hot paths that
8882
- * declare a gate at module scope, the `log-channels` provider that enumerates
8883
- * the declarations for the hub, and the same provider applying the windows the
8884
- * document hands down. A registry built inside any one of them would be
8885
- * refreshed and collected — the shape of a knob that never does anything.
8886
- *
8887
- * Same idiom as `logging-gate.singleton.ts` and
8888
- * `http-request-census.singleton.ts`.
8889
- */
8890
- var instance = null;
8891
- /** The process-wide log channel registry. Created empty on first use. */
8892
- function getLogChannelRegistry() {
8893
- instance ??= new LogChannelRegistry();
8894
- return instance;
8895
- }
8896
- /**
8897
- * Declare a channel on the process-wide registry and get its gate.
8898
- *
8899
- * The one call an addon makes. Keep the returned gate in a module-scope
8900
- * `const`: looking a channel up by name per line would put a Map lookup on
8901
- * exactly the path this mechanism exists to keep free.
8902
- *
8903
- * `scripts/check-log-channel-gated.ts` reads these call sites. It pairs the
8904
- * declared name with the binding it is assigned to and refuses to let a
8905
- * channel ship that no `<binding>.on` anywhere consults — a declared channel
8906
- * nobody reads is a knob the operator turns with nothing happening, forever,
8907
- * and without a line. That is D62, and this repo has now shipped it three
8908
- * times (`audioThresholdDbfs`, the HA entities with no source, the second
8909
- * per-camera switch that wrote a store nobody read).
8910
- */
8911
- function declareLogChannel(descriptor) {
8912
- return getLogChannelRegistry().declare(descriptor);
8913
- }
8914
- /**
8915
- * Build the `log-channels` provider for this process.
8916
- *
8917
- * `logger` is used ONLY off the hot path — for the arm/expiry lines — so a
8918
- * channel that is never armed costs this module nothing but a timer.
8919
- */
8920
- function createLogChannelsProvider(logger, options = {}) {
8921
- const registry = getLogChannelRegistry();
8922
- const now = options.now ?? Date.now;
8923
- const tickMs = options.tickMs ?? 5e3;
8924
- const timer = setInterval(() => {
8925
- const closed = registry.tick(now());
8926
- for (const name of closed) logger.info("log channel window closed", {
8927
- tags: { logChannel: name },
8928
- meta: { channel: name }
8929
- });
8930
- }, tickMs);
8931
- timer.unref?.();
8932
- return {
8933
- list: () => registry.list(),
8934
- apply: (input) => {
8935
- const unknown = registry.apply(input.windows, now());
8936
- const armed = registry.armed();
8937
- logger.info("log channels applied", { meta: {
8938
- armed: armed.map((window) => window.channel),
8939
- unknown,
8940
- declared: registry.list().length
8941
- } });
8942
- return {
8943
- armed: armed.length,
8944
- unknown
8945
- };
8946
- },
8947
- stop: () => {
8948
- clearInterval(timer);
8949
- }
8950
- };
8951
- }
8952
- /**
8953
8610
  * Ops-log — the durable, append-only operations audit shared by the
8954
8611
  * recordings and events management surfaces.
8955
8612
  *
@@ -10064,6 +9721,110 @@ var StorageCleanupJobSchema = object({
10064
9721
  });
10065
9722
  var StorageCleanupStatusInputSchema = object({ jobId: string().optional() });
10066
9723
  /**
9724
+ * The storage-location STATE MODEL (D385) — one typed state, one policy module.
9725
+ *
9726
+ * A location's state used to be split across two authorities: the typed
9727
+ * `enabled` field (THE write switch since D383) and an untyped `config.readOnly`
9728
+ * key. They did not mean the same thing — `enabled: false` was still evicted
9729
+ * under disk pressure while `config.readOnly` was deliberately excluded — and
9730
+ * neither name said which. Every consumer re-derived the difference, and the
9731
+ * three questions that actually matter were answered in six places.
9732
+ *
9733
+ * This module is the ONLY place in the repo allowed to interpret the state. It
9734
+ * answers three questions and nothing else:
9735
+ *
9736
+ * - may this location be WRITTEN to? {@link modeMayWrite}
9737
+ * - may this location be READ? {@link modeMayRead}
9738
+ * - what is its eviction policy? {@link evictionPolicyForMode}
9739
+ *
9740
+ * | mode | write | read | eviction |
9741
+ * | ---------- | ----- | ---- | ------------------------------ |
9742
+ * | `active` | yes | yes | `normal` (pressure + usage cap) |
9743
+ * | `readonly` | no | yes | `never` |
9744
+ * | `drain` | no | yes | `drain` (paced, until empty) |
9745
+ * | `disabled` | no | no | `never` |
9746
+ *
9747
+ * `scripts/check-storage-location-mode-single-owner.ts` fails the build when
9748
+ * anything outside this module reads `config['readOnly']` or compares `enabled`
9749
+ * directly. A rule nothing checks has already been broken somewhere.
9750
+ */
9751
+ var STORAGE_LOCATION_MODES = [
9752
+ "active",
9753
+ "readonly",
9754
+ "drain",
9755
+ "disabled"
9756
+ ];
9757
+ /**
9758
+ * The one typed state of a storage location. Authoritative Zod schema — the TS
9759
+ * alias below is `z.infer<>` of it, never a second spelling.
9760
+ */
9761
+ var StorageLocationModeSchema = _enum(STORAGE_LOCATION_MODES);
9762
+ _enum([
9763
+ "normal",
9764
+ "never",
9765
+ "drain"
9766
+ ]);
9767
+ /** Is this mode a write target? Only `active` is. */
9768
+ function modeMayWrite(mode) {
9769
+ return mode === "active";
9770
+ }
9771
+ /** May this mode be read (playback, timeline, scrub, relocate source)? */
9772
+ function modeMayRead(mode) {
9773
+ return mode !== "disabled";
9774
+ }
9775
+ /** What eviction may do here. See {@link StorageEvictionPolicy}. */
9776
+ function evictionPolicyForMode(mode) {
9777
+ switch (mode) {
9778
+ case "active": return "normal";
9779
+ case "drain": return "drain";
9780
+ case "readonly":
9781
+ case "disabled": return "never";
9782
+ }
9783
+ }
9784
+ /**
9785
+ * The mode a LEGACY row implies, or `null` when it implies nothing — the row is
9786
+ * already stamped, or it carried neither flag.
9787
+ *
9788
+ * Both legacy flags fold to `readonly`, which is the CONSERVATIVE direction: a
9789
+ * state change must never start deleting footage on its own, and it must never
9790
+ * make footage that was still being served disappear. `enabled: false` used to
9791
+ * leave the location evictable under pressure; folding it to `readonly` stops
9792
+ * that, which is a strictly safer answer than the one it replaces.
9793
+ */
9794
+ function legacyModeOf(location) {
9795
+ if (location.mode !== void 0) return null;
9796
+ if (location.config["readOnly"] === true) return "readonly";
9797
+ if (location.enabled === false) return "readonly";
9798
+ return null;
9799
+ }
9800
+ /**
9801
+ * The state of a location, stamped or folded. THE one interpretation: a row
9802
+ * that predates D385 is never ambiguous, and a stamped `mode` always wins over
9803
+ * whatever the legacy pair still says.
9804
+ */
9805
+ function resolveLocationMode(location) {
9806
+ return (isStorageLocationMode(location.mode) ? location.mode : void 0) ?? legacyModeOf(location) ?? "active";
9807
+ }
9808
+ /** Is this one of the four states? The stamped value crosses a wire, and a
9809
+ * value nobody defined must not be rendered as if it were a state. */
9810
+ function isStorageLocationMode(value) {
9811
+ return STORAGE_LOCATION_MODES.some((mode) => mode === value);
9812
+ }
9813
+ /** May this location be written to? */
9814
+ function mayWriteToLocation(location) {
9815
+ return modeMayWrite(resolveLocationMode(location));
9816
+ }
9817
+ /** May this location be read? A `disabled` one may not — and that is an
9818
+ * operator CHOICE, which callers must report as unavailable rather than as an
9819
+ * unknown-location fault. */
9820
+ function mayReadLocation(location) {
9821
+ return modeMayRead(resolveLocationMode(location));
9822
+ }
9823
+ /** What eviction may do to this location. */
9824
+ function evictionPolicyOfLocation(location) {
9825
+ return evictionPolicyForMode(resolveLocationMode(location));
9826
+ }
9827
+ /**
10067
9828
  * `StorageLocationType` — an addon-declared id that identifies the *kind* of
10068
9829
  * storage a location serves. Defined here (not in `capabilities/storage.cap.ts`)
10069
9830
  * so the persisted record schema and the consumer-facing cap can both consume it
@@ -10088,8 +9849,11 @@ var StorageLocationTypeSchema = string().regex(/^[a-z][a-zA-Z0-9-]*$/);
10088
9849
  * `STORAGE_LOCATION_CARDINALITY` map has been removed.
10089
9850
  *
10090
9851
  * `id` is a stable namespaced string of the form `<type>:<slug>`.
10091
- * The default location for a type uses `id === <type>:default` by
10092
- * convention (the bare type ref like `'backups'` resolves to it).
9852
+ * The seed names its first instance `<type>:default` — a NAME, not a flag.
9853
+ * There is no default location any more (D383): `enabled` is the whole write
9854
+ * model, and a bare type ref resolves to the sole location of the type, or —
9855
+ * transitionally, only while legacy NULL-stamped rows exist — to the row whose
9856
+ * slug is `default`.
10093
9857
  *
10094
9858
  * `isSystem` is a legacy persisted flag. Seed still creates the initial
10095
9859
  * `<type>:default` locations; the flag is no longer a lock, a badge, or a
@@ -10110,23 +9874,37 @@ var StorageLocationSchema = object({
10110
9874
  * flag at upsert time, not here (the schema is provider-agnostic).
10111
9875
  */
10112
9876
  nodeId: string().optional(),
10113
- isDefault: boolean().default(false),
10114
9877
  isSystem: boolean().default(false),
10115
9878
  /**
10116
- * Operator opt-in: whether consumers that BALANCE across several locations
10117
- * of a type may write here. Recordings reads it today; event media and
10118
- * backups are the next consumers, which is why the flag lives on the
10119
- * location rather than in any one addon's store — nothing has to be
10120
- * extended to add the next consumer.
9879
+ * THE write switch, and the only one (D383). `enabled: true` means every
9880
+ * consumer that chooses a write target for this type may write here, and all
9881
+ * enabled locations of a type are used TOGETHER; `false` means read-only —
9882
+ * still read, still played back, still age-swept, still drained, never
9883
+ * written.
10121
9884
  *
10122
- * OPTIONAL, and ABSENT MEANS ACTIVE. Every location persisted before the
10123
- * flag existed reads back with no flag and keeps working exactly as before;
10124
- * that is the whole compat story, and it is why no migration ships with it.
10125
- * A newly CREATED sibling is stamped `false` by the orchestrator (creating a
10126
- * disk must not silently start writing to it); the default of a type is
10127
- * always stamped `true`.
9885
+ * OPTIONAL only for the wire: an upsert that omits it means "leave what is
9886
+ * stored" on an update and "born inert unless it is the first location of its
9887
+ * type" on a create. On a PERSISTED row absence is legacy and it means
9888
+ * enabled — {@link isLocationEnabled} is the one place that says so, and the
9889
+ * orchestrator stamps every flagless row `true` once at hydrate so absence
9890
+ * stops existing rather than being re-derived on every read.
10128
9891
  */
10129
9892
  enabled: boolean().optional(),
9893
+ /**
9894
+ * THE state of this location (D385), and the only authority on what may be
9895
+ * written, read or evicted here. Interpreted in exactly one place —
9896
+ * `storage-location-mode.ts` — which also folds the legacy
9897
+ * `enabled` / `config.readOnly` pair into a mode so an old row is never
9898
+ * ambiguous.
9899
+ *
9900
+ * OPTIONAL only for the wire and for rows written before D385: absence is
9901
+ * resolved by `resolveLocationMode`, and the orchestrator stamps every
9902
+ * unstamped row ONCE at hydrate so absence stops existing rather than being
9903
+ * re-derived on every read. `enabled` survives one release as a DERIVED
9904
+ * mirror (`mode === 'active'`); `withLocationMode` is the only writer of
9905
+ * either, so the two cannot disagree.
9906
+ */
9907
+ mode: StorageLocationModeSchema.optional(),
10130
9908
  /** COMPUTED at read time by the orchestrator (statfs of the backing volume
10131
9909
  * for node-local locations it can reach) — never persisted, absent when the
10132
9910
  * volume is remote/unreachable. The single capacity truth every UI reads. */
@@ -10134,13 +9912,66 @@ var StorageLocationSchema = object({
10134
9912
  totalBytes: number(),
10135
9913
  availableBytes: number()
10136
9914
  }).nullable().optional(),
9915
+ /**
9916
+ * How much of that volume CamStack ITSELF holds on this location (D388) —
9917
+ * COMPUTED at read time from the `storage-occupancy` providers' own figures,
9918
+ * never persisted, never a filesystem walk.
9919
+ *
9920
+ * **ABSENT MEANS UNKNOWN, never zero.** No provider has reported for this
9921
+ * location yet — nobody stores here, the owning addon is down, or the first
9922
+ * refresh has not completed. A UI must omit the segment rather than draw it
9923
+ * at zero, which would claim we occupy nothing (D315). It is an OBJECT and
9924
+ * not a bare number precisely so that a `?? 0` on the consuming side has to
9925
+ * be spelled out loud instead of appearing by accident.
9926
+ *
9927
+ * `measuredAtMs` is the OLDEST contributing measurement, so it is honest
9928
+ * about the whole figure rather than about its freshest part.
9929
+ */
9930
+ owned: object({
9931
+ bytes: number().int().nonnegative(),
9932
+ measuredAtMs: number().int().nonnegative()
9933
+ }).optional(),
10137
9934
  createdAt: number(),
10138
9935
  updatedAt: number()
10139
9936
  });
9937
+ object({ isDefault: boolean().optional() });
9938
+ /**
9939
+ * Bytes in one GB, for every storage figure an operator types.
9940
+ *
9941
+ * BINARY (1024³), everywhere. The repo had both: the orchestrator's
9942
+ * `maxUsedGb` → bytes conversion used 1024³ while the recorder's placement
9943
+ * headroom used 10⁹ for the SAME stored key, so a location with a cap set was
9944
+ * silently 7.4% off depending on which side of the pipe asked. The persisted
9945
+ * values were entered against the binary unit and the admin UI reads it, so
9946
+ * that is the one that stays. Every GB knob — `maxUsedGb`, `minFreeGb` —
9947
+ * converts through {@link gbToBytes} and nowhere else.
9948
+ */
9949
+ var STORAGE_BYTES_PER_GB = 1024 ** 3;
9950
+ /** GB → bytes, binary. Fractional GB is admitted and floored. */
9951
+ function gbToBytes(gb) {
9952
+ return Math.floor(gb * STORAGE_BYTES_PER_GB);
9953
+ }
9954
+ /**
9955
+ * How far a `drain` has got (D386) — the read a UI renders, and nothing more.
9956
+ *
9957
+ * `estimatedEmptyAtMs` is derived from the growth the ratchet has actually
9958
+ * OBSERVED and is `null` when it has observed none. Never a fabricated date: a
9959
+ * drain with no observed growth has no honest ETA, and inventing one is how an
9960
+ * operator learns not to believe the screen.
9961
+ */
9962
+ var StorageDrainProgressSchema = object({
9963
+ locationId: string(),
9964
+ startedAtMs: number(),
9965
+ startBytes: number(),
9966
+ bytesRemaining: number(),
9967
+ drained: boolean(),
9968
+ estimatedEmptyAtMs: number().nullable()
9969
+ });
10140
9970
  /**
10141
9971
  * Reference accepted by consumer-facing `api.storage.*` calls.
10142
9972
  * Either:
10143
- * - a `StorageLocationType` (e.g. `'backups'`) → orchestrator resolves to the default of that type
9973
+ * - a `StorageLocationType` (e.g. `'backups'`) → the sole location of that type
9974
+ * (transitionally, the `<type>:default`-slugged row when several exist)
10144
9975
  * - a fully-qualified id (e.g. `'backups:nas-01'`) → addresses a specific instance
10145
9976
  *
10146
9977
  * The orchestrator's `resolveRef(ref)` handles both cases.
@@ -10317,6 +10148,362 @@ var DecoderSessionConfigSchema = object({
10317
10148
  debug: boolean().optional()
10318
10149
  });
10319
10150
  /**
10151
+ * Per-component log CHANNELS — the gate a hot path consults, and the registry
10152
+ * an addon declares its channels in.
10153
+ *
10154
+ * ## Two axes, deliberately separated
10155
+ *
10156
+ * - **DECLARATION** — which channels exist. Only the addon knows:
10157
+ * `stream-broker` knows webrtc/ICE/RTP, `provider-reolink` knows
10158
+ * baichuan/handshake. A hand-wired central list rots at the first addition,
10159
+ * and rots silently. So a channel is declared where it is consulted, and the
10160
+ * `log-channels` capability enumerates the declarations.
10161
+ * - **VALUE** — at which level, for which scope, until when. That stays ONE
10162
+ * thing: the logging settings document on the `system` cap. Two authorities
10163
+ * over the values is the exact defect
10164
+ * `docs/design/plans/2026-08-26-logging-per-componente.md` was written to
10165
+ * remove; re-introducing it from the cure side would be grotesque.
10166
+ *
10167
+ * Nothing in this file reads a clock, an env var or a store. The registry is
10168
+ * a MIRROR: it is moved only by {@link LogChannelRegistry.apply}, called off
10169
+ * the hot path with a value somebody actually read, and by
10170
+ * {@link LogChannelRegistry.tick}, called on a timer. A store read that fails
10171
+ * never reaches here, so it can neither disarm an armed channel nor arm a
10172
+ * disarmed one (D49).
10173
+ *
10174
+ * ## The canonical call shape
10175
+ *
10176
+ * ```ts
10177
+ * if (CH_RTP.on && CH_RTP.wants(deviceId)) {
10178
+ * CH_RTP.log(logger, 'rtp subscriber added', { tags: { deviceId }, meta: { ssrc } })
10179
+ * }
10180
+ * ```
10181
+ *
10182
+ * `on` is a plain boolean FIELD — never a getter — and it is the FIRST thing
10183
+ * read. Disarmed, a call site costs one load and one branch, and the `extras`
10184
+ * object literal is never constructed because it lives inside the branch. It
10185
+ * is the same shape already proven in production at `stream-broker.ts:1650`,
10186
+ * and the same discipline `LoggingGate.allowsDestination` uses for the
10187
+ * destination floor (measured at 1.93 ns/call when off).
10188
+ *
10189
+ * ## Why a channel emits at `info`
10190
+ *
10191
+ * `loki-logging.addon.ts` pins the destination default at `info` and
10192
+ * `loki-destination.ts` drops everything below it, so a line emitted at
10193
+ * `debug` never reaches Loki and the hub's in-memory ring only holds ~35
10194
+ * minutes. A diagnostic that cannot be read an hour later is worse than no
10195
+ * diagnostic, because it looks done. {@link LogChannelGate.log} therefore
10196
+ * emits at the channel's declared level, whose schema floor is `info`.
10197
+ */
10198
+ /**
10199
+ * The level a channel writes at once armed.
10200
+ *
10201
+ * `debug` is absent ON PURPOSE and not by omission: below `info` the line does
10202
+ * not leave the process for Loki, and the whole point of arming a channel is
10203
+ * to read it later.
10204
+ */
10205
+ var LogChannelLevelSchema = _enum([
10206
+ "info",
10207
+ "warn",
10208
+ "error"
10209
+ ]);
10210
+ /**
10211
+ * What an addon declares about one channel. No value, no state — a
10212
+ * declaration is inert.
10213
+ */
10214
+ var LogChannelDescriptorSchema = object({
10215
+ /**
10216
+ * Dotted `area.thing`, unique across the workspace. `area` is conventionally
10217
+ * the addon's short name so an operator reading a channel list can tell who
10218
+ * owns it without a second lookup.
10219
+ */
10220
+ name: string().min(3).regex(/^[a-z0-9-]+(\.[a-z0-9-]+)+$/, "a channel name is dotted lower-kebab, e.g. area.thing"),
10221
+ /** One sentence: what the operator will SEE after arming it. */
10222
+ description: string().min(1),
10223
+ /** The level its lines are emitted at. Never below `info`. */
10224
+ defaultLevel: LogChannelLevelSchema,
10225
+ /**
10226
+ * Whether this channel can be narrowed to a camera.
10227
+ *
10228
+ * `true` is a PROMISE with two halves, and both must hold: the gate is
10229
+ * consulted with the numeric device id, AND every line the channel admits
10230
+ * carries `tags: { deviceId }` with that same numeric id. The second half is
10231
+ * what makes `| json | deviceId="617"` work in Loki — `loki-payload.ts`
10232
+ * keeps `deviceId` out of the stream labels for cardinality, so the tag in
10233
+ * the body is the only way to filter.
10234
+ *
10235
+ * A channel whose lines carry the device only in `meta` (or not at all) is
10236
+ * declared `false`. Declaring it `true` anyway would be a lie the UI repeats:
10237
+ * the operator narrows to one camera, sees nothing, and concludes the code
10238
+ * path was never taken.
10239
+ */
10240
+ perDevice: boolean()
10241
+ });
10242
+ /**
10243
+ * An armed window over one channel, as the document hands it to a mirror.
10244
+ *
10245
+ * A window is a DEADLINE, never a flag (ADR-0244): a channel somebody forgot
10246
+ * expires by itself, which is the one failure a boolean cannot avoid.
10247
+ */
10248
+ var LogChannelWindowSchema = object({
10249
+ channel: string().min(1),
10250
+ /** Epoch ms the window closes at. */
10251
+ armedUntilMs: number(),
10252
+ /** `null` = every camera. A non-empty list narrows to those numeric ids. */
10253
+ deviceIds: array(number().int()).readonly().nullable()
10254
+ });
10255
+ /**
10256
+ * The gate a hot path holds.
10257
+ *
10258
+ * Obtain it ONCE — at module scope or in a constructor — and keep the
10259
+ * reference. Looking a channel up by name per line would put a Map lookup on
10260
+ * the path this class exists to keep free.
10261
+ */
10262
+ var LogChannelGate = class {
10263
+ descriptor;
10264
+ /**
10265
+ * HOT PATH GUARD. A plain data FIELD, and it must stay one.
10266
+ *
10267
+ * `log-channel.spec.ts` asserts the property descriptor has no getter and
10268
+ * booby-traps the device set, so turning this into an accessor — or reading
10269
+ * anything before it — fails the spec instead of taxing every line the
10270
+ * process emits.
10271
+ */
10272
+ on = false;
10273
+ /** `null` while armed for every camera. Never read while `on` is false. */
10274
+ devices = null;
10275
+ level;
10276
+ closesAtMs = 0;
10277
+ constructor(descriptor) {
10278
+ this.descriptor = descriptor;
10279
+ this.level = descriptor.defaultLevel;
10280
+ }
10281
+ /** Epoch ms this channel disarms itself at. 0 when disarmed. */
10282
+ get armedUntilMs() {
10283
+ return this.on ? this.closesAtMs : 0;
10284
+ }
10285
+ /**
10286
+ * Does this channel want a line about `deviceId`?
10287
+ *
10288
+ * Call it only behind `gate.on &&`. On its own it is still correct — the
10289
+ * guard is repeated inside — but the point of the prefix is that a disarmed
10290
+ * channel must not pay the call at all.
10291
+ */
10292
+ wants(deviceId) {
10293
+ if (!this.on) return false;
10294
+ return this.devices === null || this.devices.has(deviceId);
10295
+ }
10296
+ /**
10297
+ * Emit one line on this channel, at the channel's declared level.
10298
+ *
10299
+ * The channel name is added as `tags.logChannel` so LogQL can select the
10300
+ * channel without matching on the message text, and whatever `tags` the
10301
+ * caller passed — `deviceId` above all — is preserved.
10302
+ */
10303
+ log(logger, message, extras) {
10304
+ if (!this.on) return;
10305
+ const tags = {
10306
+ ...extras.tags,
10307
+ logChannel: this.descriptor.name
10308
+ };
10309
+ const line = {
10310
+ ...extras,
10311
+ tags
10312
+ };
10313
+ if (this.level === "error") logger.error(message, line);
10314
+ else if (this.level === "warn") logger.warn(message, line);
10315
+ else logger.info(message, line);
10316
+ }
10317
+ /**
10318
+ * Arm (or RE-arm, restarting) this channel. Off the hot path only.
10319
+ *
10320
+ * An empty `deviceIds` list is treated as "every camera" rather than "no
10321
+ * camera": a window that matches nothing is indistinguishable from a
10322
+ * disarmed one, and the operator who asked for it would wait for lines that
10323
+ * can never come.
10324
+ */
10325
+ arm(window) {
10326
+ const ids = window.deviceIds;
10327
+ this.devices = ids === null || ids.length === 0 ? null : new Set(ids);
10328
+ this.closesAtMs = window.armedUntilMs;
10329
+ this.on = true;
10330
+ }
10331
+ /** Disarm. Off the hot path only. */
10332
+ disarm() {
10333
+ this.on = false;
10334
+ this.devices = null;
10335
+ this.closesAtMs = 0;
10336
+ }
10337
+ };
10338
+ /**
10339
+ * Every channel this PROCESS declares, and the mirror of what is armed on it.
10340
+ *
10341
+ * One per process. A forked runner has its own, and it is refreshed through
10342
+ * the `log-channels` capability by the hub that owns the document — the
10343
+ * registry never reaches for a value itself.
10344
+ */
10345
+ var LogChannelRegistry = class {
10346
+ gates = /* @__PURE__ */ new Map();
10347
+ /**
10348
+ * Declare a channel and get its gate.
10349
+ *
10350
+ * A duplicate name throws. Two declarations of one name is a programming
10351
+ * error, not a merge: the operator would arm one and the other would stay
10352
+ * dark, which is the dead-knob shape (D62) with an extra step.
10353
+ */
10354
+ declare(descriptor) {
10355
+ const parsed = LogChannelDescriptorSchema.parse(descriptor);
10356
+ if (this.gates.get(parsed.name) !== void 0) throw new Error(`log channel "${parsed.name}" is already declared in this process — two declarations of one name is a programming error, not a merge`);
10357
+ const gate = new LogChannelGate(parsed);
10358
+ this.gates.set(parsed.name, gate);
10359
+ return gate;
10360
+ }
10361
+ /** The declarations, sorted by name so a list is stable to read and diff. */
10362
+ list() {
10363
+ return [...this.gates.values()].map((gate) => gate.descriptor).sort((a, b) => a.name.localeCompare(b.name));
10364
+ }
10365
+ /** The gate for a declared channel, or `undefined`. */
10366
+ gate(name) {
10367
+ return this.gates.get(name);
10368
+ }
10369
+ /**
10370
+ * Apply the FULL set of armed windows. Off the hot path.
10371
+ *
10372
+ * Full, not incremental, and that is the whole design: the document is the
10373
+ * authority, so a channel the document does not name is disarmed here. An
10374
+ * incremental apply would let a disarm get lost in transit and leave a
10375
+ * channel running that nobody can see is running.
10376
+ *
10377
+ * A window already past its deadline is ignored rather than armed — a
10378
+ * restore that re-armed an expired window would make a forgotten diagnostic
10379
+ * immortal across restarts.
10380
+ *
10381
+ * Returns the names it could not place, so the caller can log them: a
10382
+ * channel named in the document that this process does not declare is
10383
+ * either a typo or an addon that has not booted yet, and both deserve a
10384
+ * line rather than silence.
10385
+ */
10386
+ apply(windows, nowMs) {
10387
+ const wanted = /* @__PURE__ */ new Map();
10388
+ const unknown = [];
10389
+ for (const window of windows) {
10390
+ if (window.armedUntilMs <= nowMs) continue;
10391
+ if (!this.gates.has(window.channel)) {
10392
+ unknown.push(window.channel);
10393
+ continue;
10394
+ }
10395
+ wanted.set(window.channel, window);
10396
+ }
10397
+ for (const [name, gate] of this.gates) {
10398
+ const window = wanted.get(name);
10399
+ if (window === void 0) gate.disarm();
10400
+ else gate.arm(window);
10401
+ }
10402
+ return unknown;
10403
+ }
10404
+ /**
10405
+ * Disarm whatever has run out. Called on a timer, NEVER from a log path — a
10406
+ * diagnostic that adds a `Date.now()` to the path it is measuring measures
10407
+ * itself.
10408
+ *
10409
+ * Returns the names it closed, so the caller can write the one line that
10410
+ * says a window ended and stops "it went quiet" from reading as "the branch
10411
+ * was not taken".
10412
+ */
10413
+ tick(nowMs) {
10414
+ const closed = [];
10415
+ for (const [name, gate] of this.gates) if (gate.on && gate.armedUntilMs <= nowMs) {
10416
+ gate.disarm();
10417
+ closed.push(name);
10418
+ }
10419
+ return closed;
10420
+ }
10421
+ /** The channels armed right now, as the document would describe them. */
10422
+ armed() {
10423
+ const out = [];
10424
+ for (const [name, gate] of this.gates) if (gate.on) out.push({
10425
+ channel: name,
10426
+ armedUntilMs: gate.armedUntilMs,
10427
+ deviceIds: null
10428
+ });
10429
+ return out;
10430
+ }
10431
+ };
10432
+ /**
10433
+ * Process-wide holder for the {@link LogChannelRegistry}.
10434
+ *
10435
+ * Three call sites that never meet need the SAME instance: the hot paths that
10436
+ * declare a gate at module scope, the `log-channels` provider that enumerates
10437
+ * the declarations for the hub, and the same provider applying the windows the
10438
+ * document hands down. A registry built inside any one of them would be
10439
+ * refreshed and collected — the shape of a knob that never does anything.
10440
+ *
10441
+ * Same idiom as `logging-gate.singleton.ts` and
10442
+ * `http-request-census.singleton.ts`.
10443
+ */
10444
+ var instance = null;
10445
+ /** The process-wide log channel registry. Created empty on first use. */
10446
+ function getLogChannelRegistry() {
10447
+ instance ??= new LogChannelRegistry();
10448
+ return instance;
10449
+ }
10450
+ /**
10451
+ * Declare a channel on the process-wide registry and get its gate.
10452
+ *
10453
+ * The one call an addon makes. Keep the returned gate in a module-scope
10454
+ * `const`: looking a channel up by name per line would put a Map lookup on
10455
+ * exactly the path this mechanism exists to keep free.
10456
+ *
10457
+ * `scripts/check-log-channel-gated.ts` reads these call sites. It pairs the
10458
+ * declared name with the binding it is assigned to and refuses to let a
10459
+ * channel ship that no `<binding>.on` anywhere consults — a declared channel
10460
+ * nobody reads is a knob the operator turns with nothing happening, forever,
10461
+ * and without a line. That is D62, and this repo has now shipped it three
10462
+ * times (`audioThresholdDbfs`, the HA entities with no source, the second
10463
+ * per-camera switch that wrote a store nobody read).
10464
+ */
10465
+ function declareLogChannel(descriptor) {
10466
+ return getLogChannelRegistry().declare(descriptor);
10467
+ }
10468
+ /**
10469
+ * Build the `log-channels` provider for this process.
10470
+ *
10471
+ * `logger` is used ONLY off the hot path — for the arm/expiry lines — so a
10472
+ * channel that is never armed costs this module nothing but a timer.
10473
+ */
10474
+ function createLogChannelsProvider(logger, options = {}) {
10475
+ const registry = getLogChannelRegistry();
10476
+ const now = options.now ?? Date.now;
10477
+ const tickMs = options.tickMs ?? 5e3;
10478
+ const timer = setInterval(() => {
10479
+ const closed = registry.tick(now());
10480
+ for (const name of closed) logger.info("log channel window closed", {
10481
+ tags: { logChannel: name },
10482
+ meta: { channel: name }
10483
+ });
10484
+ }, tickMs);
10485
+ timer.unref?.();
10486
+ return {
10487
+ list: () => registry.list(),
10488
+ apply: (input) => {
10489
+ const unknown = registry.apply(input.windows, now());
10490
+ const armed = registry.armed();
10491
+ logger.info("log channels applied", { meta: {
10492
+ armed: armed.map((window) => window.channel),
10493
+ unknown,
10494
+ declared: registry.list().length
10495
+ } });
10496
+ return {
10497
+ armed: armed.length,
10498
+ unknown
10499
+ };
10500
+ },
10501
+ stop: () => {
10502
+ clearInterval(timer);
10503
+ }
10504
+ };
10505
+ }
10506
+ /**
10320
10507
  * Distinct (device, family, variant) counters one instance will hold.
10321
10508
  *
10322
10509
  * A large fleet x the handful of families any single addon reports, with
@@ -24308,7 +24495,7 @@ method(object({
24308
24495
  downloadId: string(),
24309
24496
  offset: number(),
24310
24497
  length: number()
24311
- }), _instanceof(Uint8Array)), method(object({ downloadId: string() }), _void(), { kind: "mutation" }), method(object({ type: StorageLocationTypeSchema.optional() }), array(StorageLocationSchema).readonly()), method(object({ type: StorageLocationTypeSchema }), StorageLocationSchema.nullable()), method(_void(), array(StorageLocationDeclarationSchema).readonly()), method(StorageLocationSchema.omit({
24498
+ }), _instanceof(Uint8Array)), method(object({ downloadId: string() }), _void(), { kind: "mutation" }), method(object({ type: StorageLocationTypeSchema.optional() }), array(StorageLocationSchema).readonly()), method(_void(), array(StorageLocationDeclarationSchema).readonly()), method(StorageLocationSchema.omit({
24312
24499
  createdAt: true,
24313
24500
  updatedAt: true
24314
24501
  }), StorageLocationSchema, {
@@ -24320,7 +24507,7 @@ method(object({
24320
24507
  }), _void(), {
24321
24508
  kind: "mutation",
24322
24509
  auth: "admin"
24323
- }), method(object({ id: string() }), object({
24510
+ }), method(_void(), array(StorageDrainProgressSchema).readonly()), method(object({ id: string() }), object({
24324
24511
  ok: boolean(),
24325
24512
  error: string().optional()
24326
24513
  }), { auth: "admin" }), method(_void(), array(ProviderListEntrySchema).readonly()), method(object({
@@ -24412,6 +24599,71 @@ method(StorageMigrationInputSchema, StorageMigrationPlanSchema, { auth: "admin"
24412
24599
  kind: "mutation",
24413
24600
  auth: "admin"
24414
24601
  }), method(object({}), array(StorageMigrationJobSchema).readonly(), { auth: "admin" });
24602
+ /**
24603
+ * `storage-occupancy` — how many bytes an addon actually HOLDS on a storage
24604
+ * location (D388).
24605
+ *
24606
+ * ## Why this is not `storage-evictable`
24607
+ *
24608
+ * `storage-evictable.getEvictableUsage` looks like the same question and is
24609
+ * not, in two ways that both matter and both bite hardest on the locations an
24610
+ * operator most wants a figure for:
24611
+ *
24612
+ * - it reports the whole eviction DOMAIN, not the location. `recordings:default`
24613
+ * and `recordingsLow:default` deliberately share one root and evict as one
24614
+ * oldest-first pool, so both answer with the SAME combined total. As an
24615
+ * occupancy figure that double-counts the disk.
24616
+ * - it reports ZERO for a location whose eviction policy is `never` (D385) —
24617
+ * a `readonly` or `disabled` disk. Those are exactly the disks an operator
24618
+ * is retiring and staring at.
24619
+ *
24620
+ * So this is its own contract with its own quantity, and the quantity is
24621
+ * OCCUPIED: every byte the addon holds on that location, whether or not it
24622
+ * would ever be willing to delete it. A provider that can only answer
24623
+ * "evictable" must not register here — a number that silently means different
24624
+ * things per class is worse than no number.
24625
+ *
24626
+ * ## Absence is an answer
24627
+ *
24628
+ * A location nobody reports for is UNKNOWN, never zero (D315). The orchestrator
24629
+ * stamps `StorageLocation.owned` only for locations it has a report for, and
24630
+ * the field is an OBJECT rather than a bare number so that a `?? 0` on the
24631
+ * consuming side has to be written out loud instead of appearing by accident.
24632
+ *
24633
+ * `internal: true` — consumed by the orchestrator's `listLocations` stamp, never
24634
+ * a public client surface. Clients read the stamped `StorageLocation.owned`.
24635
+ */
24636
+ /** One provider's occupancy answer for one location. */
24637
+ var StorageOccupancyReportSchema = object({
24638
+ locationId: string(),
24639
+ /** Bytes this provider holds on THAT location — not its eviction domain, and
24640
+ * not net of what it is willing to delete. */
24641
+ ownedBytes: number().int().nonnegative(),
24642
+ /** When the provider last actually measured this. The orchestrator carries it
24643
+ * through so a UI can say how old the figure is instead of implying "now". */
24644
+ measuredAtMs: number().int().nonnegative()
24645
+ });
24646
+ var storageOccupancyCapability = {
24647
+ name: "storage-occupancy",
24648
+ scope: "system",
24649
+ mode: "collection",
24650
+ internal: true,
24651
+ methods: {
24652
+ /**
24653
+ * Occupancy for the given locations, in ONE round trip.
24654
+ *
24655
+ * A provider answers only for the locations it actually holds bytes on and
24656
+ * OMITS the rest — an omitted location is "I hold nothing measurable here",
24657
+ * which the orchestrator merges as a contribution of nothing rather than as
24658
+ * a claim that the location is empty. Only a location no provider reports
24659
+ * at all stays unknown.
24660
+ *
24661
+ * This must be CHEAP and must never walk a filesystem: it is on the admin
24662
+ * UI's `listLocations` path. The owner keeps its own figure fresh (D224) and
24663
+ * answers from what it already has.
24664
+ */
24665
+ getOccupancy: method(object({ locationIds: array(string()).readonly() }), array(StorageOccupancyReportSchema).readonly(), { auth: "admin" }) }
24666
+ };
24415
24667
  var ProviderInfoSchema = discriminatedUnion("shouldSaveDiskSpace", [object({
24416
24668
  providerId: string().min(1),
24417
24669
  displayName: string().min(1),
@@ -26194,39 +26446,6 @@ onStatusChanged: { data: object({
26194
26446
  * whether persisting is worth a SQLite commit. */
26195
26447
  volatileStateFields: ["lastUpdated"]
26196
26448
  };
26197
- /**
26198
- * Network-link snapshot. Same shape for every provider (a Reolink wifi
26199
- * camera, a Home Assistant device with a signal-strength sensor, a Tapo
26200
- * plug): one slice under `device.runtimeState['network-link']`, one badge,
26201
- * one Home Assistant projection.
26202
- */
26203
- var NetworkLinkStatusSchema = object({
26204
- /** The link the device is on. `'unknown'` = not read yet, not "no link". */
26205
- type: _enum([
26206
- "wifi",
26207
- "ethernet",
26208
- "cellular",
26209
- "unknown"
26210
- ]),
26211
- /**
26212
- * Link quality, 0..100 inclusive, normalised by the provider from whatever
26213
- * the firmware reports (bars, RSSI, a vendor scale). **`null` means NOT
26214
- * KNOWN or NOT APPLICABLE** — a wired link has no signal, and a wireless
26215
- * one whose reading has not landed must not be drawn at 0 %. Consumers
26216
- * SKIP a null rather than coerce it.
26217
- */
26218
- signalPercent: number().min(0).max(100).nullable(),
26219
- /** Raw received signal strength in dBm, when the firmware reports one. */
26220
- rssiDbm: number().optional(),
26221
- /** Network name of a wireless link, when the firmware reports it. */
26222
- ssid: string().optional(),
26223
- /** Ms epoch of the last observation. Lets consumers reason about freshness. */
26224
- lastUpdated: number()
26225
- });
26226
- DeviceType.Camera, DeviceType.Sensor, DeviceType.Button, DeviceType.Switch, DeviceType.Light, DeviceType.Lock, DeviceType.Siren, object({
26227
- deviceId: number(),
26228
- status: NetworkLinkStatusSchema
26229
- });
26230
26449
  object({
26231
26450
  on: boolean(),
26232
26451
  /** Ms epoch of the last transition. 0 if never observed. */
@@ -28580,6 +28799,236 @@ DeviceType.Camera, method(object({
28580
28799
  detection: NativeDetectionSchema
28581
28800
  });
28582
28801
  /**
28802
+ * `navigation` — a device-scoped capability that natively expresses the FULL
28803
+ * navigation / action surface of a robot that DRIVES ITSELF and carries an
28804
+ * on-board camera (the Dreame robot-vacuum camera is the first provider).
28805
+ *
28806
+ * Why a NEW cap rather than overloading `ptz`:
28807
+ * - `ptz` moves a *gimbal* on a fixed camera (pan/tilt/zoom of the lens). A
28808
+ * robot vacuum has no gimbal — the whole chassis drives, turns and spins.
28809
+ * The two are different physical models: PTZ is absolute-position + presets,
28810
+ * navigation is momentary drive nudges + discrete robot ACTIONS
28811
+ * (dock / spot-clean / follow-pet / go-to-point / …).
28812
+ * - This cap is the SOURCE OF TRUTH. Two consumers adapt from it rather than
28813
+ * the reverse:
28814
+ * 1. a native CamStack navigation panel (data-driven from `listActions`
28815
+ * / `getOptions`), and
28816
+ * 2. the PTZ surface — a thin adapter mimics `ptz` from `navigation` so a
28817
+ * robot camera shows up in the existing PTZ control path without every
28818
+ * PTZ provider learning about robots. The mapping lives in the adapter,
28819
+ * not here (see the addon design note):
28820
+ * ptz.continuousMove({pan,tilt}) → navigation.move({pan,tilt})
28821
+ * ptz.stop() → navigation.stop()
28822
+ * ptz.goHome() → navigation.runAction('goHome')
28823
+ * ptz.getPresets() → navigation.listActions() (id→preset)
28824
+ * ptz.goToPreset(id) → navigation.runAction(id)
28825
+ *
28826
+ * ## Continuous drive
28827
+ *
28828
+ * `move` is MOMENTARY. Fluid navigation comes from the UI (or the PTZ adapter)
28829
+ * sending `move({pan,tilt})` REPEATEDLY at ~1 Hz while a direction is held, and
28830
+ * one `stop()` on release — exactly like the robot app's remote-drive joystick.
28831
+ * The provider forwards EACH `move` to one drive write; it must NOT debounce or
28832
+ * coalesce them. The UI owns the cadence.
28833
+ *
28834
+ * ## The action dictionary
28835
+ *
28836
+ * The discrete controls (dock / locate / spot-clean / follow / flash / sounds)
28837
+ * are a DATA-DRIVEN dictionary the cap exposes via `listActions()`. Each entry
28838
+ * carries `{ id, kind, label, icon }` (plus `soundId` for sound entries) so the
28839
+ * native panel AND the PTZ mimic render buttons WITHOUT hardcoding a
28840
+ * vendor-specific list. `kind: 'action'` entries are triggered with
28841
+ * `runAction({ actionId })`; `kind: 'sound'` entries with `playSound({ soundId })`
28842
+ * (the entry carries the `soundId` to pass). The general primitives — `move`,
28843
+ * `stop`, `goToPoint` — stay as first-class methods, not dictionary entries.
28844
+ *
28845
+ * NOTE (provider wiring): the first provider (Dreame) drives this with the RAW
28846
+ * `callAction(siid,aiid,in)` / `setProperty({siid,piid,value})` MIoT primitives
28847
+ * that the currently-published `@apocaliss92/nodedreame` already exposes on
28848
+ * every device handle. A future nodedreame publish adds a typed
28849
+ * `DreameCameraController` (drive / playPetSound / spotClean / findPet / …); the
28850
+ * provider can then swap the raw calls for the typed methods with no change to
28851
+ * THIS contract.
28852
+ */
28853
+ /**
28854
+ * A momentary drive nudge. The robot MOVES (no gimbal) for as long as the caller
28855
+ * keeps sending nudges (~1 Hz); an explicit `stop` (or letting the nudges lapse)
28856
+ * halts it.
28857
+ *
28858
+ * - `pan` — turn: negative = left, positive = right, 0 = straight.
28859
+ * - `tilt` — throttle: positive = forward, negative = spin / turn-around.
28860
+ * - `speed` — optional intensity hint [0, 1]; the provider may scale the drive
28861
+ * vector by it (drivers without proportional drive ignore it).
28862
+ *
28863
+ * `pan` / `tilt` are normalized [-1, 1]. Both optional so a caller can nudge one
28864
+ * axis alone; an all-undefined nudge is a no-op.
28865
+ */
28866
+ var NavigationMoveCommandSchema = object({
28867
+ pan: number().min(-1).max(1).optional(),
28868
+ tilt: number().min(-1).max(1).optional(),
28869
+ speed: number().min(0).max(1).optional()
28870
+ });
28871
+ /**
28872
+ * The enumerated discrete actions a navigation-capable robot can perform via
28873
+ * `runAction`. This is the CLOSED vocabulary; a given device advertises the
28874
+ * subset it supports through `listActions`. Sounds are NOT here — they go through
28875
+ * `playSound` (see the `sound` dictionary entries).
28876
+ */
28877
+ var NavigationActionIdSchema = _enum([
28878
+ "goHome",
28879
+ "locate",
28880
+ "spotClean",
28881
+ "findPet",
28882
+ "personFollow",
28883
+ "stop",
28884
+ "startClean",
28885
+ "pauseClean",
28886
+ "dockWash",
28887
+ "autoEmpty",
28888
+ "flashOn",
28889
+ "flashOff"
28890
+ ]);
28891
+ /** Whether a dictionary entry is a `runAction` action or a `playSound` sound. */
28892
+ var NavigationEntryKindSchema = _enum(["action", "sound"]);
28893
+ /**
28894
+ * One entry in the navigation action dictionary — the DATA-DRIVEN unit both the
28895
+ * native panel and the PTZ mimic render as a button.
28896
+ *
28897
+ * - `id` — stable id. For `kind:'action'` it is a {@link NavigationActionId}
28898
+ * (pass to `runAction`); for `kind:'sound'` it is a namespaced id
28899
+ * (`sound:meow`) whose `soundId` is passed to `playSound`.
28900
+ * - `icon` — icon HINT (lucide-style name; the UI maps it to its own set).
28901
+ * - `label` — operator-facing English label.
28902
+ * - `soundId` — wire sound id, present only on `kind:'sound'` entries.
28903
+ * - `enabled` — per-device FEATURE FLAG. `listActions` reports it so the UI /
28904
+ * PTZ render ONLY enabled entries. Data-driven: the provider
28905
+ * flips it from config, never by editing code.
28906
+ */
28907
+ var NavigationActionEntrySchema = object({
28908
+ id: string(),
28909
+ kind: NavigationEntryKindSchema,
28910
+ label: string(),
28911
+ icon: string(),
28912
+ /** Present only on `kind:'sound'` entries — the id to pass to `playSound`. */
28913
+ soundId: number().int().optional(),
28914
+ /** Per-device feature flag — render this entry only when true. */
28915
+ enabled: boolean()
28916
+ });
28917
+ /** Coordinates for `goToPoint` — a point on the robot's live map. */
28918
+ var NavigationPointSchema = object({
28919
+ x: number(),
28920
+ y: number()
28921
+ });
28922
+ /**
28923
+ * Per-device FEATURE-FLAG report for the general (non-dictionary) primitives.
28924
+ * The cap reports which are enabled so the UI / PTZ render only the controls
28925
+ * that are turned on for THIS device. Data-driven: the provider derives these
28926
+ * from config + probe, never hardcoded in the UI. The per-DICTIONARY-entry flags
28927
+ * live on {@link NavigationActionEntrySchema.enabled}; these gate the primitives
28928
+ * that are not dictionary entries.
28929
+ *
28930
+ * - `move` / `stop` — the momentary drive joystick.
28931
+ * - `goToPoint` — send-to-map-coordinate. Ships OFF on Dreame until the
28932
+ * map-coordinate plumbing is wired.
28933
+ * - `runAction` — the discrete action buttons (dictionary `kind:'action'`).
28934
+ * - `playSound` — the sound buttons (dictionary `kind:'sound'`).
28935
+ * - `light` — the on/off fill-light toggle (works anytime).
28936
+ * - `lightMode` — the auto/manual selector + manual level slider (a
28937
+ * camera-service control; needs an active stream).
28938
+ */
28939
+ var NavigationFeaturesSchema = object({
28940
+ move: boolean(),
28941
+ stop: boolean(),
28942
+ goToPoint: boolean(),
28943
+ runAction: boolean(),
28944
+ playSound: boolean(),
28945
+ light: boolean(),
28946
+ lightMode: boolean()
28947
+ });
28948
+ /** Light mode: `auto` lets the camera choose brightness; `manual` uses `level`. */
28949
+ var NavigationLightModeSchema = _enum(["auto", "manual"]);
28950
+ /**
28951
+ * Live navigation state so the UI can reflect what the robot is doing:
28952
+ * - `mode` — coarse activity (idle / cleaning / following / …).
28953
+ * - `following` — person/pet follow is currently armed.
28954
+ * - `flash` — the on-camera fill light is on.
28955
+ * - `lightMode` — auto vs manual fill-light mode.
28956
+ * - `lightLevel` — manual fill-light level (40..100); meaningful when
28957
+ * `lightMode === 'manual'`.
28958
+ */
28959
+ var NavigationStatusSchema = object({
28960
+ mode: _enum([
28961
+ "idle",
28962
+ "cleaning",
28963
+ "spot",
28964
+ "following",
28965
+ "goto",
28966
+ "returning",
28967
+ "paused",
28968
+ "unknown"
28969
+ ]),
28970
+ following: boolean(),
28971
+ flash: boolean(),
28972
+ lightMode: NavigationLightModeSchema,
28973
+ lightLevel: number().min(40).max(100),
28974
+ /** Ms epoch when the slice was last updated. */
28975
+ lastChangedAt: number()
28976
+ });
28977
+ NavigationStatusSchema.extend({ lastFetchedAt: number() });
28978
+ DeviceType.Camera, method(NavigationMoveCommandSchema.extend({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(NavigationPointSchema.extend({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), array(NavigationActionEntrySchema)), method(object({
28979
+ deviceId: number(),
28980
+ actionId: NavigationActionIdSchema
28981
+ }), _void(), { kind: "mutation" }), method(object({
28982
+ deviceId: number(),
28983
+ soundId: number().int()
28984
+ }), _void(), { kind: "mutation" }), method(object({
28985
+ deviceId: number(),
28986
+ on: boolean()
28987
+ }), _void(), { kind: "mutation" }), method(object({
28988
+ deviceId: number(),
28989
+ mode: NavigationLightModeSchema,
28990
+ level: number().min(40).max(100).optional()
28991
+ }), _void(), { kind: "mutation" }), method(object({
28992
+ deviceId: number(),
28993
+ level: number().min(40).max(100)
28994
+ }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), NavigationFeaturesSchema), object({
28995
+ deviceId: number(),
28996
+ status: NavigationStatusSchema
28997
+ });
28998
+ /**
28999
+ * Network-link snapshot. Same shape for every provider (a Reolink wifi
29000
+ * camera, a Home Assistant device with a signal-strength sensor, a Tapo
29001
+ * plug): one slice under `device.runtimeState['network-link']`, one badge,
29002
+ * one Home Assistant projection.
29003
+ */
29004
+ var NetworkLinkStatusSchema = object({
29005
+ /** The link the device is on. `'unknown'` = not read yet, not "no link". */
29006
+ type: _enum([
29007
+ "wifi",
29008
+ "ethernet",
29009
+ "cellular",
29010
+ "unknown"
29011
+ ]),
29012
+ /**
29013
+ * Link quality, 0..100 inclusive, normalised by the provider from whatever
29014
+ * the firmware reports (bars, RSSI, a vendor scale). **`null` means NOT
29015
+ * KNOWN or NOT APPLICABLE** — a wired link has no signal, and a wireless
29016
+ * one whose reading has not landed must not be drawn at 0 %. Consumers
29017
+ * SKIP a null rather than coerce it.
29018
+ */
29019
+ signalPercent: number().min(0).max(100).nullable(),
29020
+ /** Raw received signal strength in dBm, when the firmware reports one. */
29021
+ rssiDbm: number().optional(),
29022
+ /** Network name of a wireless link, when the firmware reports it. */
29023
+ ssid: string().optional(),
29024
+ /** Ms epoch of the last observation. Lets consumers reason about freshness. */
29025
+ lastUpdated: number()
29026
+ });
29027
+ DeviceType.Camera, DeviceType.Sensor, DeviceType.Button, DeviceType.Switch, DeviceType.Light, DeviceType.Lock, DeviceType.Siren, object({
29028
+ deviceId: number(),
29029
+ status: NetworkLinkStatusSchema
29030
+ });
29031
+ /**
28583
29032
  * network-quality — system-scoped singleton capability tracking RTT,
28584
29033
  * jitter, and observed/peak bandwidth per device + per client.
28585
29034
  *
@@ -29821,203 +30270,6 @@ DeviceType.Camera, method(object({ deviceId: number() }), PtzAutotrackStatusSche
29821
30270
  deviceId: number(),
29822
30271
  status: PtzAutotrackStatusSchema
29823
30272
  });
29824
- /**
29825
- * `navigation` — a device-scoped capability that natively expresses the FULL
29826
- * navigation / action surface of a robot that DRIVES ITSELF and carries an
29827
- * on-board camera (the Dreame robot-vacuum camera is the first provider).
29828
- *
29829
- * Why a NEW cap rather than overloading `ptz`:
29830
- * - `ptz` moves a *gimbal* on a fixed camera (pan/tilt/zoom of the lens). A
29831
- * robot vacuum has no gimbal — the whole chassis drives, turns and spins.
29832
- * The two are different physical models: PTZ is absolute-position + presets,
29833
- * navigation is momentary drive nudges + discrete robot ACTIONS
29834
- * (dock / spot-clean / follow-pet / go-to-point / …).
29835
- * - This cap is the SOURCE OF TRUTH. Two consumers adapt from it rather than
29836
- * the reverse:
29837
- * 1. a native CamStack navigation panel (data-driven from `listActions`
29838
- * / `getOptions`), and
29839
- * 2. the PTZ surface — a thin adapter mimics `ptz` from `navigation` so a
29840
- * robot camera shows up in the existing PTZ control path without every
29841
- * PTZ provider learning about robots. The mapping lives in the adapter,
29842
- * not here (see the addon design note):
29843
- * ptz.continuousMove({pan,tilt}) → navigation.move({pan,tilt})
29844
- * ptz.stop() → navigation.stop()
29845
- * ptz.goHome() → navigation.runAction('goHome')
29846
- * ptz.getPresets() → navigation.listActions() (id→preset)
29847
- * ptz.goToPreset(id) → navigation.runAction(id)
29848
- *
29849
- * ## Continuous drive
29850
- *
29851
- * `move` is MOMENTARY. Fluid navigation comes from the UI (or the PTZ adapter)
29852
- * sending `move({pan,tilt})` REPEATEDLY at ~1 Hz while a direction is held, and
29853
- * one `stop()` on release — exactly like the robot app's remote-drive joystick.
29854
- * The provider forwards EACH `move` to one drive write; it must NOT debounce or
29855
- * coalesce them. The UI owns the cadence.
29856
- *
29857
- * ## The action dictionary
29858
- *
29859
- * The discrete controls (dock / locate / spot-clean / follow / flash / sounds)
29860
- * are a DATA-DRIVEN dictionary the cap exposes via `listActions()`. Each entry
29861
- * carries `{ id, kind, label, icon }` (plus `soundId` for sound entries) so the
29862
- * native panel AND the PTZ mimic render buttons WITHOUT hardcoding a
29863
- * vendor-specific list. `kind: 'action'` entries are triggered with
29864
- * `runAction({ actionId })`; `kind: 'sound'` entries with `playSound({ soundId })`
29865
- * (the entry carries the `soundId` to pass). The general primitives — `move`,
29866
- * `stop`, `goToPoint` — stay as first-class methods, not dictionary entries.
29867
- *
29868
- * NOTE (provider wiring): the first provider (Dreame) drives this with the RAW
29869
- * `callAction(siid,aiid,in)` / `setProperty({siid,piid,value})` MIoT primitives
29870
- * that the currently-published `@apocaliss92/nodedreame` already exposes on
29871
- * every device handle. A future nodedreame publish adds a typed
29872
- * `DreameCameraController` (drive / playPetSound / spotClean / findPet / …); the
29873
- * provider can then swap the raw calls for the typed methods with no change to
29874
- * THIS contract.
29875
- */
29876
- /**
29877
- * A momentary drive nudge. The robot MOVES (no gimbal) for as long as the caller
29878
- * keeps sending nudges (~1 Hz); an explicit `stop` (or letting the nudges lapse)
29879
- * halts it.
29880
- *
29881
- * - `pan` — turn: negative = left, positive = right, 0 = straight.
29882
- * - `tilt` — throttle: positive = forward, negative = spin / turn-around.
29883
- * - `speed` — optional intensity hint [0, 1]; the provider may scale the drive
29884
- * vector by it (drivers without proportional drive ignore it).
29885
- *
29886
- * `pan` / `tilt` are normalized [-1, 1]. Both optional so a caller can nudge one
29887
- * axis alone; an all-undefined nudge is a no-op.
29888
- */
29889
- var NavigationMoveCommandSchema = object({
29890
- pan: number().min(-1).max(1).optional(),
29891
- tilt: number().min(-1).max(1).optional(),
29892
- speed: number().min(0).max(1).optional()
29893
- });
29894
- /**
29895
- * The enumerated discrete actions a navigation-capable robot can perform via
29896
- * `runAction`. This is the CLOSED vocabulary; a given device advertises the
29897
- * subset it supports through `listActions`. Sounds are NOT here — they go through
29898
- * `playSound` (see the `sound` dictionary entries).
29899
- */
29900
- var NavigationActionIdSchema = _enum([
29901
- "goHome",
29902
- "locate",
29903
- "spotClean",
29904
- "findPet",
29905
- "personFollow",
29906
- "stop",
29907
- "startClean",
29908
- "pauseClean",
29909
- "dockWash",
29910
- "autoEmpty",
29911
- "flashOn",
29912
- "flashOff"
29913
- ]);
29914
- /** Whether a dictionary entry is a `runAction` action or a `playSound` sound. */
29915
- var NavigationEntryKindSchema = _enum(["action", "sound"]);
29916
- /**
29917
- * One entry in the navigation action dictionary — the DATA-DRIVEN unit both the
29918
- * native panel and the PTZ mimic render as a button.
29919
- *
29920
- * - `id` — stable id. For `kind:'action'` it is a {@link NavigationActionId}
29921
- * (pass to `runAction`); for `kind:'sound'` it is a namespaced id
29922
- * (`sound:meow`) whose `soundId` is passed to `playSound`.
29923
- * - `icon` — icon HINT (lucide-style name; the UI maps it to its own set).
29924
- * - `label` — operator-facing English label.
29925
- * - `soundId` — wire sound id, present only on `kind:'sound'` entries.
29926
- * - `enabled` — per-device FEATURE FLAG. `listActions` reports it so the UI /
29927
- * PTZ render ONLY enabled entries. Data-driven: the provider
29928
- * flips it from config, never by editing code.
29929
- */
29930
- var NavigationActionEntrySchema = object({
29931
- id: string(),
29932
- kind: NavigationEntryKindSchema,
29933
- label: string(),
29934
- icon: string(),
29935
- /** Present only on `kind:'sound'` entries — the id to pass to `playSound`. */
29936
- soundId: number().int().optional(),
29937
- /** Per-device feature flag — render this entry only when true. */
29938
- enabled: boolean()
29939
- });
29940
- /** Coordinates for `goToPoint` — a point on the robot's live map. */
29941
- var NavigationPointSchema = object({
29942
- x: number(),
29943
- y: number()
29944
- });
29945
- /**
29946
- * Per-device FEATURE-FLAG report for the general (non-dictionary) primitives.
29947
- * The cap reports which are enabled so the UI / PTZ render only the controls
29948
- * that are turned on for THIS device. Data-driven: the provider derives these
29949
- * from config + probe, never hardcoded in the UI. The per-DICTIONARY-entry flags
29950
- * live on {@link NavigationActionEntrySchema.enabled}; these gate the primitives
29951
- * that are not dictionary entries.
29952
- *
29953
- * - `move` / `stop` — the momentary drive joystick.
29954
- * - `goToPoint` — send-to-map-coordinate. Ships OFF on Dreame until the
29955
- * map-coordinate plumbing is wired.
29956
- * - `runAction` — the discrete action buttons (dictionary `kind:'action'`).
29957
- * - `playSound` — the sound buttons (dictionary `kind:'sound'`).
29958
- * - `light` — the on/off fill-light toggle (works anytime).
29959
- * - `lightMode` — the auto/manual selector + manual level slider (a
29960
- * camera-service control; needs an active stream).
29961
- */
29962
- var NavigationFeaturesSchema = object({
29963
- move: boolean(),
29964
- stop: boolean(),
29965
- goToPoint: boolean(),
29966
- runAction: boolean(),
29967
- playSound: boolean(),
29968
- light: boolean(),
29969
- lightMode: boolean()
29970
- });
29971
- /** Light mode: `auto` lets the camera choose brightness; `manual` uses `level`. */
29972
- var NavigationLightModeSchema = _enum(["auto", "manual"]);
29973
- /**
29974
- * Live navigation state so the UI can reflect what the robot is doing:
29975
- * - `mode` — coarse activity (idle / cleaning / following / …).
29976
- * - `following` — person/pet follow is currently armed.
29977
- * - `flash` — the on-camera fill light is on.
29978
- * - `lightMode` — auto vs manual fill-light mode.
29979
- * - `lightLevel` — manual fill-light level (40..100); meaningful when
29980
- * `lightMode === 'manual'`.
29981
- */
29982
- var NavigationStatusSchema = object({
29983
- mode: _enum([
29984
- "idle",
29985
- "cleaning",
29986
- "spot",
29987
- "following",
29988
- "goto",
29989
- "returning",
29990
- "paused",
29991
- "unknown"
29992
- ]),
29993
- following: boolean(),
29994
- flash: boolean(),
29995
- lightMode: NavigationLightModeSchema,
29996
- lightLevel: number().min(40).max(100),
29997
- /** Ms epoch when the slice was last updated. */
29998
- lastChangedAt: number()
29999
- });
30000
- NavigationStatusSchema.extend({ lastFetchedAt: number() });
30001
- DeviceType.Camera, method(NavigationMoveCommandSchema.extend({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(NavigationPointSchema.extend({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), array(NavigationActionEntrySchema)), method(object({
30002
- deviceId: number(),
30003
- actionId: NavigationActionIdSchema
30004
- }), _void(), { kind: "mutation" }), method(object({
30005
- deviceId: number(),
30006
- soundId: number().int()
30007
- }), _void(), { kind: "mutation" }), method(object({
30008
- deviceId: number(),
30009
- on: boolean()
30010
- }), _void(), { kind: "mutation" }), method(object({
30011
- deviceId: number(),
30012
- mode: NavigationLightModeSchema,
30013
- level: number().min(40).max(100).optional()
30014
- }), _void(), { kind: "mutation" }), method(object({
30015
- deviceId: number(),
30016
- level: number().min(40).max(100)
30017
- }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), NavigationFeaturesSchema), object({
30018
- deviceId: number(),
30019
- status: NavigationStatusSchema
30020
- });
30021
30273
  DeviceType.Camera, DeviceType.Sensor, DeviceType.Switch, method(object({ deviceId: number().int().nonnegative() }), object({ success: literal(true) }), {
30022
30274
  kind: "mutation",
30023
30275
  auth: "admin"
@@ -37740,13 +37992,13 @@ Object.freeze({
37740
37992
  addonId: null,
37741
37993
  access: "view"
37742
37994
  },
37743
- "storage.getDefaultLocation": {
37995
+ "storage.list": {
37744
37996
  capName: "storage",
37745
37997
  capScope: "system",
37746
37998
  addonId: null,
37747
37999
  access: "view"
37748
38000
  },
37749
- "storage.list": {
38001
+ "storage.listDrainProgress": {
37750
38002
  capName: "storage",
37751
38003
  capScope: "system",
37752
38004
  addonId: null,
@@ -37896,6 +38148,12 @@ Object.freeze({
37896
38148
  addonId: null,
37897
38149
  access: "view"
37898
38150
  },
38151
+ "storageOccupancy.getOccupancy": {
38152
+ capName: "storage-occupancy",
38153
+ capScope: "system",
38154
+ addonId: null,
38155
+ access: "view"
38156
+ },
37899
38157
  "storageProvider.abortUpload": {
37900
38158
  capName: "storage-provider",
37901
38159
  capScope: "system",
@@ -42377,4 +42635,4 @@ function enumerateInferenceDevices(hw) {
42377
42635
  return out;
42378
42636
  }
42379
42637
  //#endregion
42380
- export { inferModelProvider as $, lazy as $t, addonWidgetsSourceCapability as A, Fmp4BoxSplitter as At, defaultDeviceFor as B, hydrateSchema as Bt, RATE_CONTROL_RELAXED as C, storageEvictableCapability as Ct, RecordingSignalStatusSchema as D, webrtcSessionCapability as Dt, RecordingConfigSchema as E, supportedRuntimes as Et, cameraStreamsCapability as F, BaseAddon as Ft, detectionPipelineCapability as G, parseJsonUnknown as Gt, deriveBatteryPresence as H, makeProfileBrokerId as Ht, createHwAccelCache as I, CAM_PROFILE_ORDER as It, enumerateInferenceDevices as J, sleep as Jt, egressTranscodeSharingKey as K, parseProfileBrokerId as Kt, createLogChannelsProvider as L, DeviceFeature as Lt, audioAnalyzerCapability as M, invocationFromEncodeProfile as Mt, audioKindId as N, isSoftwareDecode as Nt, RingBuffer as O, errMsg as Ot, batteryCapability as P, BOOT_RECOVERY_BACKOFF_MS as Pt, hfModelUrl as Q, discriminatedUnion as Qt, customAction as R, DeviceType as Rt, PoolMemoryWatchdog as S, runtimeDevices as St, RECORDING_EXPORT_MAX_READ_BYTES as T, stripRetiredBandPreBufferSec as Tt, deriveDetailCropRect as U, makeSourceBrokerId as Ut, defineCustomActions as V, isEvent as Vt, deriveRecordingMode as W, nodePin as Wt, evaluateZoneRules as X, array as Xt, evaluateSensorEdge as Y, _enum as Yt, failureContributionCapability as Z, boolean as Zt, FailureCounters as _, recordingSignalCapability as _t, COCO_80_LABELS as a, string as an, migrateLegacyDeviceSignalConfig as at, NativeLeaseSettingsSchema as b, resolvePoolMemoryPolicy as bt, DEFAULT_CLUSTER_STEP_MODELS as c, parseProcStatus as ct, DEFAULT_EVENTS_BAND_BUFFER_SEC as d, pickDetailCropConvention as dt, literal as en, isFirstLevelMacroClass as et, DEFAULT_FIRST_SIGHTING_FRESHNESS_MS as f, pickNativeLeaseOverride as ft, ExportRecordSchema as g, recordingExportCapability as gt, EncodeProfileSchema as h, recordingCapability as ht, BatteryStatusSchema as i, record as in, maskUrlCredentials as it, audioAnalysisCapability as j, buildFfmpegArgs as jt, YAMNET_TO_MACRO as k, AUDIO_PRESETS as kt, DEFAULT_CLUSTER_STEP_SETTINGS as l, pickClusterStepModels as lt, DEVICE_BACKEND_TO_FORMAT as m, pipelineRunnerCapability as mt, AUDIO_BACKEND_CHOICES as n, object as nn, logChannelsCapability as nt, COCO_TO_MACRO as o, union as on, motionDetectionCapability as ot, DEFAULT_NATIVE_LEASE_SETTINGS as p, pipelineExecutorCapability as pt, egressTransportFromRequest as q, selectAssignedProfileSlots as qt, AUDIO_MACRO_LABELS as r, preprocess as rn, mapAudioLabelToMacro as rt, DEFAULT_AUDIO_ANALYZER_CONFIG as s, EventCategory as sn, overlayClusterStepSettings as st, APPLE_SA_TO_MACRO as t, number as tn, loadContributionCapability as tt, DEFAULT_DETAIL_CROP_CONVENTION as u, pickClusterStepSettings as ut, HF_BASE_URL as v, resolveClusterStepModelId as vt, RATE_CONTROL_TIGHT as w, streamBrokerCapability as wt, OpsLogEntrySchema as x, resolveRecordingProfiles as xt, NativeLeaseAdmissionSchema as y, resolveEgressDecodeHwAccel as yt, declareLogChannel as z, createEvent as zt };
42638
+ export { failureContributionCapability as $, parseJsonUnknown as $t, addonWidgetsSourceCapability as A, storageEvictableCapability as At, defaultDeviceFor as B, invocationFromEncodeProfile as Bt, RATE_CONTROL_RELAXED as C, recordingSignalCapability as Ct, RecordingSignalStatusSchema as D, resolvePoolMemoryPolicy as Dt, RecordingConfigSchema as E, resolveLocationMode as Et, cameraStreamsCapability as F, webrtcSessionCapability as Ft, detectionPipelineCapability as G, DeviceFeature as Gt, deriveBatteryPresence as H, BOOT_RECOVERY_BACKOFF_MS as Ht, createHwAccelCache as I, errMsg as It, enumerateInferenceDevices as J, hydrateSchema as Jt, egressTranscodeSharingKey as K, DeviceType as Kt, createLogChannelsProvider as L, AUDIO_PRESETS as Lt, audioAnalyzerCapability as M, streamBrokerCapability as Mt, audioKindId as N, stripRetiredBandPreBufferSec as Nt, RingBuffer as O, resolveRecordingProfiles as Ot, batteryCapability as P, supportedRuntimes as Pt, evictionPolicyOfLocation as Q, nodePin as Qt, customAction as R, Fmp4BoxSplitter as Rt, PoolMemoryWatchdog as S, recordingExportCapability as St, RECORDING_EXPORT_MAX_READ_BYTES as T, resolveEgressDecodeHwAccel as Tt, deriveDetailCropRect as U, BaseAddon as Ut, defineCustomActions as V, isSoftwareDecode as Vt, deriveRecordingMode as W, CAM_PROFILE_ORDER as Wt, evaluateZoneRules as X, makeProfileBrokerId as Xt, evaluateSensorEdge as Y, isEvent as Yt, evictionPolicyForMode as Z, makeSourceBrokerId as Zt, FailureCounters as _, pickDetailCropConvention as _t, COCO_80_LABELS as a, boolean as an, logChannelsCapability as at, NativeLeaseSettingsSchema as b, pipelineRunnerCapability as bt, DEFAULT_CLUSTER_STEP_MODELS as c, literal as cn, mayReadLocation as ct, DEFAULT_EVENTS_BAND_BUFFER_SEC as d, preprocess as dn, modeMayWrite as dt, parseProfileBrokerId as en, gbToBytes as et, DEFAULT_FIRST_SIGHTING_FRESHNESS_MS as f, record as fn, motionDetectionCapability as ft, ExportRecordSchema as g, pickClusterStepSettings as gt, EncodeProfileSchema as h, EventCategory as hn, pickClusterStepModels as ht, BatteryStatusSchema as i, array as in, loadContributionCapability as it, audioAnalysisCapability as j, storageOccupancyCapability as jt, YAMNET_TO_MACRO as k, runtimeDevices as kt, DEFAULT_CLUSTER_STEP_SETTINGS as l, number as ln, mayWriteToLocation as lt, DEVICE_BACKEND_TO_FORMAT as m, union as mn, parseProcStatus as mt, AUDIO_BACKEND_CHOICES as n, sleep as nn, inferModelProvider as nt, COCO_TO_MACRO as o, discriminatedUnion as on, mapAudioLabelToMacro as ot, DEFAULT_NATIVE_LEASE_SETTINGS as p, string as pn, overlayClusterStepSettings as pt, egressTransportFromRequest as q, createEvent as qt, AUDIO_MACRO_LABELS as r, _enum as rn, isFirstLevelMacroClass as rt, DEFAULT_AUDIO_ANALYZER_CONFIG as s, lazy as sn, maskUrlCredentials as st, APPLE_SA_TO_MACRO as t, selectAssignedProfileSlots as tn, hfModelUrl as tt, DEFAULT_DETAIL_CROP_CONVENTION as u, object as un, migrateLegacyDeviceSignalConfig as ut, HF_BASE_URL as v, pickNativeLeaseOverride as vt, RATE_CONTROL_TIGHT as w, resolveClusterStepModelId as wt, OpsLogEntrySchema as x, recordingCapability as xt, NativeLeaseAdmissionSchema as y, pipelineExecutorCapability as yt, declareLogChannel as z, buildFfmpegArgs as zt };