@camstack/addon-osd-manager 0.1.130 → 0.1.132

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 (18) hide show
  1. package/dist/{MotionZonesSettings-BdgknhPk.mjs → MotionZonesSettings-BVKF49rs.mjs} +2 -2
  2. package/dist/{PrivacyMaskSettings-Daia4FHS.mjs → PrivacyMaskSettings-DoIRAuP_.mjs} +4 -4
  3. package/dist/{SceneMonitorEditor-CmH-9VEG.mjs → SceneMonitorEditor-3ApQNn3a.mjs} +3 -3
  4. package/dist/_stub.js +12 -12
  5. package/dist/{_virtual_mf-localSharedImportMap___mfe_internal__addon_osd_manager_page-CT5rLNrc.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_osd_manager_page-mIgOkpXI.mjs} +3 -3
  6. package/dist/_virtual_mf___mfe_internal__addon_osd_manager_page__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-D47POQ8_.mjs +26 -0
  7. package/dist/{hostInit-CGDQxGQr.mjs → hostInit-vbrbPLQg.mjs} +2 -2
  8. package/dist/index.js +700 -9
  9. package/dist/index.mjs +700 -9
  10. package/dist/{player-overlays-SETUglb5.mjs → player-overlays-BPpRI9aB.mjs} +1 -1
  11. package/dist/remoteEntry.js +1 -1
  12. package/dist/{responsive-dnBm2QK5.mjs → responsive-BEcoyxvC.mjs} +1 -1
  13. package/dist/{scene-monitor-copy--fsvQxHQ.mjs → scene-monitor-copy-1LhlrkfK.mjs} +1 -1
  14. package/dist/{square-DPw7AhAw.mjs → square-Bw9o8y6p.mjs} +1 -1
  15. package/dist/{trash-2-DNiVd6l-.mjs → trash-2-ClX9SCS6.mjs} +1 -1
  16. package/dist/{virtual_mf-REMOTE_ENTRY_ID___mfe_internal__addon_osd_manager_page__remoteEntry_js-DLJN80iK.mjs → virtual_mf-REMOTE_ENTRY_ID___mfe_internal__addon_osd_manager_page__remoteEntry_js-BNiwvK5v.mjs} +1 -1
  17. package/package.json +1 -1
  18. package/dist/_virtual_mf___mfe_internal__addon_osd_manager_page__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DY9EOcFz.mjs +0 -26
package/dist/index.js CHANGED
@@ -5363,7 +5363,7 @@ var ZodIssueCode = {
5363
5363
  var ZodFirstPartyTypeKind;
5364
5364
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5365
5365
  //#endregion
5366
- //#region ../types/dist/sleep-De1BvmWo.mjs
5366
+ //#region ../types/dist/sleep-COWaSCAi.mjs
5367
5367
  /**
5368
5368
  * The audio chunk plane's byte format, and the ONE expansion from a coded
5369
5369
  * window to float samples (D455).
@@ -7629,6 +7629,24 @@ object({
7629
7629
  unreachable: number()
7630
7630
  })
7631
7631
  });
7632
+ /** The wire shape of {@link PeerBytesTicket} — see the type for what it is. */
7633
+ var PeerBytesTicketSchema = object({
7634
+ /** `http://127.0.0.1:<port>/<token>`. One `GET` takes it. */
7635
+ url: string().min(1),
7636
+ /**
7637
+ * The HOST node this URL means something on — the hub or a named agent,
7638
+ * never a runner. {@link AddonPeerBytes.open} compares it to its own and
7639
+ * refuses `cross-node` by name when they differ, without dialling.
7640
+ */
7641
+ hostNodeId: string().min(1),
7642
+ expiresAtMs: number().int().nonnegative(),
7643
+ /**
7644
+ * What the producer DECLARED the body to be, when it knows — `null` when it
7645
+ * does not. Never `0` for unknown (D393): a consumer sizing a bound off this
7646
+ * must be able to tell "the producer did not say" from "the body is empty".
7647
+ */
7648
+ declaredBytes: number().int().nonnegative().nullable()
7649
+ });
7632
7650
  /**
7633
7651
  * Adoption job — the background form of `device-adoption.adopt`.
7634
7652
  *
@@ -14682,6 +14700,118 @@ var deviceAdoptionCapability = {
14682
14700
  }
14683
14701
  };
14684
14702
  /**
14703
+ * device-admin-link — "this device has a management page of its own, and here
14704
+ * is its address".
14705
+ *
14706
+ * ## Why this is not a `deviceConfig` cap
14707
+ *
14708
+ * There is nothing to edit. A `deviceConfig` cap (D14) exists so the framework
14709
+ * can DERIVE a settings form from `getOptions` + `getStatus` and route a flat
14710
+ * patch back through a setter; it costs a `builderId` reducer in
14711
+ * `device-config-contribution.ts` and a `*-config-schema.ts` beside it, and it
14712
+ * renders a form section. This cap answers ONE question with ONE read and
14713
+ * renders a button. Nothing about it is a form, so it carries no `deviceConfig`
14714
+ * block, no `settings`, no `runtimeState` and no reducer — exactly like
14715
+ * `reboot`, the other pure-RPC device-native cap.
14716
+ *
14717
+ * ## Absent, and the difference between "no page" and "we cannot say"
14718
+ *
14719
+ * The two are answered at DIFFERENT layers, on purpose:
14720
+ *
14721
+ * - **"We cannot say"** → the provider never registers the cap for that
14722
+ * device. A VeSync humidifier, a Petkit feeder, a Dreame vacuum and a Dreo
14723
+ * fan are reached only through a vendor cloud; there is no address to hand
14724
+ * out and no page to open. A Tuya plug, a Wyze camera and a Gree air
14725
+ * conditioner DO have a LAN IP, and still have no HTTP management page
14726
+ * behind it. None of them register, so `deviceManager.getBindings` never
14727
+ * lists the cap and no surface asks.
14728
+ * - **"This device has no page, and I know that"** → the provider registers
14729
+ * and `getAdminLink` returns `null`. This is the answer for a device whose
14730
+ * sibling DOES have a page: a Reolink battery camera reached over UDP by
14731
+ * `uid` with a blank `host`, an Ecowitt gateway configured in `listener`
14732
+ * transport, a Home Assistant broker authenticated by supervisor token
14733
+ * (which carries no `baseUrl` at all).
14734
+ *
14735
+ * Both draw NOTHING. A button that opens a browser error is worse than no
14736
+ * button, and D62 is the same rule from the other side: an off switch is
14737
+ * reported off, never made to look broken. There is no third state where the
14738
+ * UI renders a disabled button "because the device might have a page".
14739
+ *
14740
+ * ## The URL never carries credentials
14741
+ *
14742
+ * Not in userinfo, not in a query string. Every provider builds through
14743
+ * `buildDeviceAdminUrl` (`device-admin-link-url.ts`), which takes host, port,
14744
+ * scheme and path as separate arguments — there is no parameter a secret could
14745
+ * arrive in — and re-checks its own output for the `scheme://user:pass@` shape
14746
+ * that `scripts/check-no-credential-urls-in-fixtures.ts` bans from recorded
14747
+ * output. `scripts/check-admin-link-builder-is-the-only-url-source.ts` is what
14748
+ * keeps providers from hand-rolling one anyway.
14749
+ *
14750
+ * This matters here more than anywhere else in the repo, because every provider
14751
+ * that knows a device's host knows its PASSWORD too: `{ host, port, username,
14752
+ * password }` sit in one object on Hikvision, Amcrest, Reolink and ONVIF alike,
14753
+ * and `http://admin:hunter2@192.168.50.139/` is a URL a browser accepts. The
14754
+ * camera's own page will ask for its own login. That is correct, and pre-
14755
+ * filling it is the operator's business, not ours.
14756
+ *
14757
+ * ## It is a LAN fact
14758
+ *
14759
+ * The URL addresses the device where the NODE can see it. It is not proxied,
14760
+ * not made reachable from outside, and not sent anywhere. A surface renders it
14761
+ * as a link the operator's own browser follows, on the operator's own network,
14762
+ * or renders nothing.
14763
+ */
14764
+ /**
14765
+ * Whose page is it. The distinction is for the OPERATOR, who needs to know
14766
+ * before clicking whether he is about to land on a camera's own web server or
14767
+ * inside Home Assistant.
14768
+ */
14769
+ var AdminLinkTargetEnum = _enum(["device", "integration"]);
14770
+ var DeviceAdminLinkSchema = object({
14771
+ /**
14772
+ * Absolute `http(s)://` URL. Built by `buildDeviceAdminUrl` and therefore
14773
+ * free of userinfo and of any credential-shaped query key.
14774
+ */
14775
+ url: string(),
14776
+ /**
14777
+ * What the surface calls it — "Web UI", "Home Assistant", "UniFi controller".
14778
+ * The PROVIDER names it, because only the provider knows what the page is;
14779
+ * a UI that invented the label from the addon id would call the Home
14780
+ * Assistant device page "Provider Homeassistant".
14781
+ */
14782
+ label: string(),
14783
+ target: AdminLinkTargetEnum,
14784
+ /**
14785
+ * Host the URL points at, without scheme, port or path — for the tooltip, so
14786
+ * an operator can see WHERE the button goes before he follows it. Redundant
14787
+ * with `url` by construction; carried separately so no surface has to parse
14788
+ * a URL to show it.
14789
+ */
14790
+ host: string()
14791
+ });
14792
+ var deviceAdminLinkCapability = {
14793
+ name: "device-admin-link",
14794
+ scope: "device",
14795
+ deviceNative: true,
14796
+ mode: "singleton",
14797
+ methods: {
14798
+ /**
14799
+ * The device's management page, or `null` when this device has none.
14800
+ *
14801
+ * `auth: 'admin'` deliberately. This is administration, not actuation —
14802
+ * the same bucket as `reboot` and `camera-credentials`, and explicitly NOT
14803
+ * the actuation set `scripts/check-actuation-not-admin.ts` protects (D403).
14804
+ * The URL is also a statement about the LAN, which a household member with
14805
+ * a `view` grant on a light has no reason to be handed.
14806
+ *
14807
+ * The surfaces gate on the QUERY, never on a role they guessed: a caller
14808
+ * without the right loses the query and draws nothing, which is the same
14809
+ * thing a device with no page draws. There is no path on which a button
14810
+ * appears and then fails — the D403 failure mode, from the other end.
14811
+ */
14812
+ getAdminLink: method(object({ deviceId: number().int().nonnegative() }), DeviceAdminLinkSchema.nullable(), { auth: "admin" }) }
14813
+ };
14814
+ /**
14685
14815
  * `device-export` — collection cap for addons that export camstack
14686
14816
  * devices to external ecosystems (HomeAssistant via MQTT discovery,
14687
14817
  * HomeKit/HAP, Alexa Smart Home, …).
@@ -29024,6 +29154,7 @@ _enum([
29024
29154
  "sleeping",
29025
29155
  "camera-refused",
29026
29156
  "no-keyframe",
29157
+ "decode-failed",
29027
29158
  "no-catalog-row",
29028
29159
  "unsupported",
29029
29160
  "unknown-device",
@@ -29298,6 +29429,32 @@ var ClipBytesSchema = object({
29298
29429
  durationMs: number().positive().optional()
29299
29430
  });
29300
29431
  /**
29432
+ * Where a clip's finished bytes can be TAKEN (D613) — the answer to
29433
+ * {@link videoclipsCapability.methods.offerClipBytes}.
29434
+ *
29435
+ * Everything {@link ClipBytesSchema} carries except the bytes themselves, plus
29436
+ * the one-shot ticket that leads to them. The metadata is answered BEFORE the
29437
+ * transfer on purpose: a consumer learns which twin it got, what to call the
29438
+ * file and how long the clip runs without having to read a byte, so a decision
29439
+ * it would make on that metadata (a wrong twin, an implausible duration) costs
29440
+ * no transfer at all.
29441
+ */
29442
+ var ClipBytesOfferSchema = object({
29443
+ /**
29444
+ * One shot, seconds-long, loopback, on the PROVIDER's own host. Open it with
29445
+ * `ctx.peerBytes.open(...)`, which refuses a ticket from another node by
29446
+ * name rather than dialling a port that means something else here.
29447
+ */
29448
+ ticket: PeerBytesTicketSchema,
29449
+ contentType: string(),
29450
+ /** Suggested filename, extension included. */
29451
+ name: string(),
29452
+ /** Which twin was actually served — see {@link ClipBytesSchema.served}. */
29453
+ served: CamProfileSchema,
29454
+ /** See {@link ClipBytesSchema.durationMs}. Absent when nothing measured it. */
29455
+ durationMs: number().positive().optional()
29456
+ });
29457
+ /**
29301
29458
  * Where a clip's STREAM can be dialled (D597) — the answer to
29302
29459
  * {@link videoclipsCapability.methods.dialClipStream}.
29303
29460
  *
@@ -29373,6 +29530,44 @@ var ClipStreamDialSchema = object({
29373
29530
  /** Why `servedAudio` is `none` although sound was asked for. */
29374
29531
  audioReason: ClipStreamAudioReasonSchema.optional()
29375
29532
  });
29533
+ /**
29534
+ * What a surface may DRAW for this provider's clips — the answer to
29535
+ * {@link videoclipsCapability.methods.getPlaybackOptions} (D612).
29536
+ *
29537
+ * The envelope is a PROVIDER fact, not a clip fact, and that is measured, not
29538
+ * assumed: the broker's `chooseClipPath` reads exactly two inputs — whether
29539
+ * `dialClipStream` and `readClipBytes` are wired — and both are constants of
29540
+ * the broker's own closure over the provider's methods. The `profile` it is
29541
+ * handed is explicitly not read. So every clip of a provider is served the
29542
+ * same way, and a per-clip channel carried a value that could not vary. The
29543
+ * per-clip `clipTransport` server message was removed for exactly that reason.
29544
+ *
29545
+ * Queried per camera, before a clip is picked, so a control is rendered or
29546
+ * DISABLED rather than offered and refused at play time (D62: a disabled
29547
+ * control reads as unavailable, one that undoes the gesture reads as broken).
29548
+ */
29549
+ var ClipPlaybackOptionsSchema = object({
29550
+ /**
29551
+ * How this provider's clips reach the player. `stream` is the provider's
29552
+ * forward-only fMP4 (D597); `file` is one bounded by-handle fetch of the
29553
+ * whole clip, `stbl` indexed (D575).
29554
+ */
29555
+ transport: _enum(["stream", "file"]),
29556
+ /** `forward` = only ahead of the playhead. `free` = anywhere. */
29557
+ seek: _enum(["forward", "free"]),
29558
+ /** Frame-step BACKWARD is meaningful. Forward always is. */
29559
+ stepBack: boolean(),
29560
+ /** Whether the scrub gesture is served, as opposed to refused by name. */
29561
+ scrub: boolean(),
29562
+ /**
29563
+ * The rates that can be delivered, ascending, always containing `1`. The
29564
+ * viewer draws its picker from this and from nothing else — a constant it
29565
+ * keeps instead is the second authority that produced the defect: `8` and
29566
+ * `16` were offered, the broker clamped them to `4`, and no line anywhere
29567
+ * said so. `0` is not a member: pause is the absence of a rate.
29568
+ */
29569
+ rates: array(number().positive()).min(1).readonly()
29570
+ });
29376
29571
  var ClipSourceAvailabilitySchema = object({
29377
29572
  state: _enum([
29378
29573
  "ok",
@@ -29551,14 +29746,18 @@ var videoclipsCapability = {
29551
29746
  *
29552
29747
  * `getClipPlayback` is the right answer for a player: it hands back a URL
29553
29748
  * on a plane the hub serves `access:'authenticated'`, which a browser and a
29554
- * viewer session satisfy. It is the wrong answer for another ADDON. There
29555
- * is no addon→addon byte transport in this framework — `AddonDataPlane`
29556
- * only lets an addon SERVE, on `127.0.0.1` behind a per-listener secret
29557
- * only the hub may present — so a recorder that wants a camera's clip
29558
- * cannot fetch that URL. This method is the one seam that exists for it,
29559
- * and it is deliberately the same shape (and the same bound) as
29560
- * `recordingExport.readExportBytes`, which exists for the mirror-image
29561
- * reason.
29749
+ * viewer session satisfy. It is the wrong answer for another ADDON: the
29750
+ * hub's proxy in front of that plane takes only a user credential, which
29751
+ * an addon does not hold, so a recorder that wants a camera's clip cannot
29752
+ * fetch that URL. This method is the shape that answer forced — the same
29753
+ * one (and the same bound) as `recordingExport.readExportBytes`.
29754
+ *
29755
+ * **It is no longer the only seam.** Until D613 there was no addon→addon
29756
+ * byte transport at all; there is now
29757
+ * ({@link offerClipBytes}, over `ctx.peerBytes`), it holds nothing on
29758
+ * either side, and it is what a clip EXPORT uses. This method remains for
29759
+ * a consumer that genuinely wants the bytes in hand, and as the named
29760
+ * fallback for a provider not yet redeployed.
29562
29761
  *
29563
29762
  * Routing needs no `provider` pin: the id is source-prefixed and
29564
29763
  * self-contained, so `device-collection-dispatch.ts` rule 3 hands the call
@@ -29625,6 +29824,65 @@ var videoclipsCapability = {
29625
29824
  auth: "protected"
29626
29825
  }),
29627
29826
  /**
29827
+ * Where this clip's finished bytes can be TAKEN — the by-handle read a
29828
+ * clip EXPORT pulls, over the addon→addon byte transport (D613).
29829
+ *
29830
+ * This is {@link readClipBytes} with the envelope removed. Same gates,
29831
+ * same vocabulary, same completion rules, same `served` contract — the
29832
+ * only difference is that the bytes travel over a one-shot loopback
29833
+ * socket instead of inside a base64 field, so neither side holds the
29834
+ * payload whole and the 50 MiB refusal on a long `high` twin stops
29835
+ * existing. The bound that remains is
29836
+ * {@link VIDEOCLIPS_MAX_OFFER_BYTES}, and it bounds the PRODUCER's own
29837
+ * copy rather than the transport.
29838
+ *
29839
+ * **The ticket is loopback and same-host.** A provider on an agent mints a
29840
+ * URL that means nothing on the hub, and `ctx.peerBytes.open` refuses it
29841
+ * `cross-node` by name rather than dialling whatever else holds that port
29842
+ * here. A consumer that can be on the other side of a node boundary from
29843
+ * its provider must be able to read that refusal and say so; it must not
29844
+ * treat it as "no bytes".
29845
+ *
29846
+ * **A ticket is a one-shot bearer credential with a seconds-long life.**
29847
+ * Take it immediately, never persist it, never log its `url`. An untaken
29848
+ * ticket costs the provider one map entry until its TTL, and outstanding
29849
+ * tickets are bounded — which is also what makes a per-frame misuse of
29850
+ * this method refuse by name rather than work slowly (D9/D18: this is a
29851
+ * by-handle fetch of finished media, not a frame pipe).
29852
+ *
29853
+ * Optional on the provider for the same reason `readClipBytes` is: a
29854
+ * source with no camera socket behind it has no bytes. A provider that
29855
+ * predates this method answers `NOT_IMPLEMENTED`, and a consumer may fall
29856
+ * back to `readClipBytes` — but it says so in the log, with the deploy
29857
+ * hint, because that fallback re-imposes the 50 MiB refusal and an
29858
+ * operator who sees `too-large-to-transfer` after this shipped is looking
29859
+ * at a stale addon, not at a clip that cannot be exported.
29860
+ */
29861
+ offerClipBytes: optionalMethod(object({
29862
+ deviceId: number(),
29863
+ clipId: string().min(1),
29864
+ /** WHICH provider holds the bytes — see `readClipBytes.provider`. */
29865
+ provider: string().min(1),
29866
+ /** Which twin — `low | mid` → the sub file, `high` → the main twin. */
29867
+ profile: CamProfileSchema.optional(),
29868
+ /**
29869
+ * The CALLER's byte bound, so an over-size clip is refused before the
29870
+ * camera is touched rather than after. Capped by
29871
+ * {@link VIDEOCLIPS_MAX_OFFER_BYTES} whatever is passed; absent means
29872
+ * that ceiling.
29873
+ */
29874
+ maxBytes: number().int().positive().optional(),
29875
+ /**
29876
+ * The operator's authorisation to wake a sleeping camera for this
29877
+ * read. Absent — the default — means a sleeping standalone battery
29878
+ * camera is REFUSED by name, before any session is opened.
29879
+ */
29880
+ wake: ClipWakeSchema.optional()
29881
+ }), ClipBytesOfferSchema, {
29882
+ kind: "query",
29883
+ auth: "protected"
29884
+ }),
29885
+ /**
29628
29886
  * Where this clip's STREAM can be dialled (D597) — the forward-only
29629
29887
  * fMP4 the provider writes from the first muxed byte, for the broker to
29630
29888
  * play through the same WebRTC session as recorded footage, with the
@@ -29661,6 +29919,36 @@ var videoclipsCapability = {
29661
29919
  }), ClipStreamDialSchema, {
29662
29920
  kind: "query",
29663
29921
  auth: "protected"
29922
+ }),
29923
+ /**
29924
+ * What a surface may DRAW for this provider's clips: the rates it can be
29925
+ * played at, whether scrub is served, how far a position may be moved,
29926
+ * and whether a backward frame-step means anything (D612).
29927
+ *
29928
+ * **The only authority.** The per-clip `clipTransport` server message
29929
+ * that used to carry the same answer was removed: the broker's transport
29930
+ * choice reads nothing that varies per clip, so the clip level had no
29931
+ * information the provider does not already have, and two channels that
29932
+ * can disagree are worse than one (D62).
29933
+ *
29934
+ * Asked per camera and per provider, so it must stay CHEAP — it is a
29935
+ * statement about wiring, answered from a constant, never a call to the
29936
+ * camera. A provider answers with one of {@link CLIP_PLAYBACK_OPTIONS}
29937
+ * and never composes an envelope of its own.
29938
+ *
29939
+ * Optional, and absence is load-bearing: a provider that has not answered
29940
+ * has not restricted anything, and a viewer reads it as the freedom it
29941
+ * always had. See D612 on the rollout order that absence implies.
29942
+ */
29943
+ getPlaybackOptions: optionalMethod(object({
29944
+ deviceId: number(),
29945
+ /** WHICH provider to ask — the `addonId` a {@link ClipSourceSchema}
29946
+ * row carries. Required for the same reason `listClips` requires it:
29947
+ * a collection cap has no "the bound one" to resolve to (D554). */
29948
+ provider: string().min(1)
29949
+ }), ClipPlaybackOptionsSchema, {
29950
+ kind: "query",
29951
+ auth: "protected"
29664
29952
  })
29665
29953
  }
29666
29954
  };
@@ -32207,6 +32495,346 @@ var dayNightCapability = {
32207
32495
  volatileStateFields: ["lastFetchedAt"]
32208
32496
  };
32209
32497
  /**
32498
+ * Vendor-neutral **onboard** recording + storage cap — what the CAMERA
32499
+ * writes to the CAMERA's own card, on the camera's own schedule.
32500
+ *
32501
+ * This is NOT `recording.cap.ts`. That one is CamStack's recorder: our
32502
+ * footage ledger, our storage locations, our retention. This one has a
32503
+ * different authority — the camera's firmware — and per D62 it stores
32504
+ * nothing of its own. Every value here is read from the camera and every
32505
+ * write goes back to the camera; there is no CamStack-side mirror that
32506
+ * could disagree with the device.
32507
+ *
32508
+ * ## One shape, two firmwares
32509
+ *
32510
+ * Measured 2026-09-22 against the live fleet:
32511
+ *
32512
+ * | fact | Hikvision (ISAPI) | Reolink (Baichuan) |
32513
+ * | --- | --- | --- |
32514
+ * | storage | `ContentMgmt/Storage` `<hdd>` rows: status, capacity, freeSpace (MB) | `getHddInfoList` (cmd 102): `mount`, `format`, `capacity` GB + `capacityM` MB remainder |
32515
+ * | tracks | several (101 **and** 103 on both 1436 and 3833), each with its own schedule | one per channel |
32516
+ * | schedule | per track, 7 `ScheduleAction` blocks: DayOfWeek + TimeOfDay range + ONE `ActionRecordingMode` | per trigger type, a 168-char weekly HOUR mask |
32517
+ * | triggers | `CMR`, `MOTION` | `Normal`, `MD`, `people`, `vehicle`, `dog_cat`, `crossline`, `intrude`, `loitering` |
32518
+ * | pre-record | `PreRecordTimeSeconds` | `preRecordTime` |
32519
+ * | post-record | `PostRecordTimeSeconds` | `recordDelayTime` |
32520
+ * | overwrite | per track `LoopEnable` | `cycle`, with `cycleList` enumerating the accepted values |
32521
+ * | segment length | not exposed on V5.7.1 | `packageTime` (minutes) |
32522
+ *
32523
+ * The two schedule models look different and are the same thing in
32524
+ * different coordinates: both answer "for this trigger, during which
32525
+ * weekly windows does the camera record". {@link RecordWindow} is that
32526
+ * question in one shape — Hikvision's ranges map straight onto it,
32527
+ * Reolink's mask expands into hour-aligned windows.
32528
+ *
32529
+ * ## Union, not intersection
32530
+ *
32531
+ * **The same fields exist on every camera.** What differs per device is
32532
+ * which VALUES that device accepts, and that is what {@link
32533
+ * RecordingOnboardOptions} reports — a `{ readable, writable, reason }`
32534
+ * per field plus the schedule's own limits. A control a camera cannot
32535
+ * honour is rendered DISABLED WITH ITS REASON, never missing and never
32536
+ * dead: disabled must not look like broken.
32537
+ *
32538
+ * ## Refusal by name
32539
+ *
32540
+ * A write a camera cannot honour is refused with a sentence the operator
32541
+ * can read — never accepted and dropped. Both providers refuse through
32542
+ * {@link describeOnboardRefusal}, so the vocabulary is one function and
32543
+ * one test, not two hand-written vendor opinions.
32544
+ *
32545
+ * Follows the D14 `deviceConfig` archetype (see `stream-params.cap.ts`):
32546
+ * `getOptions` advertises per-camera availability, `getStatus` (auto-
32547
+ * injected from `status`) reports the live values, and a single
32548
+ * `setSettings` mutation applies a partial change. No hand-written
32549
+ * settings-contribution methods.
32550
+ */
32551
+ /**
32552
+ * What makes the camera start recording during a window.
32553
+ *
32554
+ * The union of both vendors' vocabularies. `continuous` is Hikvision's
32555
+ * `CMR` and Reolink's `Normal`; `motion` is `MOTION` / `MD`. The
32556
+ * object-class triggers are Reolink-only today and the smart-event ones
32557
+ * (`lineCrossing`, `intrusion`, `loitering`) are Reolink-only on the
32558
+ * firmwares measured — a camera that cannot record on a trigger simply
32559
+ * does not list it in `options.schedule.triggers`, and a window naming
32560
+ * it is REFUSED, not dropped.
32561
+ */
32562
+ var RecordTriggerSchema = _enum([
32563
+ "continuous",
32564
+ "motion",
32565
+ "person",
32566
+ "vehicle",
32567
+ "animal",
32568
+ "lineCrossing",
32569
+ "intrusion",
32570
+ "loitering",
32571
+ "alarmInput"
32572
+ ]);
32573
+ /**
32574
+ * One weekly recording window: "on `day`, from `startMinute` to
32575
+ * `endMinute`, record on `trigger`".
32576
+ *
32577
+ * `day` is 0 = Monday … 6 = Sunday (ISO order, which is also the order
32578
+ * both firmwares enumerate). Minutes are local camera time since
32579
+ * midnight; `endMinute` may be 1440, meaning end of day — that is
32580
+ * Hikvision's literal `24:00` and Reolink's 24th mask slot, and
32581
+ * collapsing it to 0 would turn a whole-day window into an empty one.
32582
+ */
32583
+ var RecordWindowSchema = object({
32584
+ trigger: RecordTriggerSchema,
32585
+ day: number().int().min(0).max(6),
32586
+ startMinute: number().int().min(0).max(1439),
32587
+ endMinute: number().int().min(1).max(1440)
32588
+ });
32589
+ /** Status of one physical volume, as the camera itself describes it. */
32590
+ var OnboardStorageVolumeSchema = object({
32591
+ /** The camera's own id for the volume (`hdd/id`, Reolink `HddInfo/number`). */
32592
+ id: string(),
32593
+ /** The camera's own name for it, when it gives one (`hddName`). */
32594
+ label: string().optional(),
32595
+ status: _enum([
32596
+ "ok",
32597
+ "unformatted",
32598
+ "error",
32599
+ "offline",
32600
+ "unknown"
32601
+ ]),
32602
+ /**
32603
+ * Total size in MB, or **null when the camera did not say**.
32604
+ *
32605
+ * Never 0 for an unreadable value: a measurement that failed is not a
32606
+ * measurement (D393), and a card whose size is unknown must not be
32607
+ * rendered as a card of size zero.
32608
+ */
32609
+ capacityMb: number().nullable(),
32610
+ /**
32611
+ * Free space in MB, or null when unknown.
32612
+ *
32613
+ * **Not a proxy for "has footage".** Measured 2026-09-22: 1436 and
32614
+ * 1439 both report exactly 11776 MB free — the fixed reserve a looping
32615
+ * card converges on once it has wrapped. At loop steady state the
32616
+ * number is identical whether the camera recorded yesterday or stopped
32617
+ * a month ago.
32618
+ */
32619
+ freeMb: number().nullable(),
32620
+ /** True when the camera reports the volume writable (`property` RW). */
32621
+ writable: boolean().optional()
32622
+ });
32623
+ /**
32624
+ * What the camera is doing with its own storage, right now.
32625
+ *
32626
+ * Every scalar is nullable and **null means the camera did not answer**,
32627
+ * never a default. A form that seeds `0` from an unanswered read invites
32628
+ * the operator to save that 0 back onto the camera.
32629
+ */
32630
+ var RecordingOnboardStatusSchema = object({
32631
+ storage: discriminatedUnion("kind", [
32632
+ object({
32633
+ kind: literal("present"),
32634
+ volumes: array(OnboardStorageVolumeSchema)
32635
+ }),
32636
+ object({
32637
+ kind: literal("absent"),
32638
+ reason: string()
32639
+ }),
32640
+ object({
32641
+ kind: literal("unknown"),
32642
+ reason: string()
32643
+ })
32644
+ ]),
32645
+ tracks: array(object({
32646
+ id: string(),
32647
+ enabled: boolean(),
32648
+ isVideo: boolean(),
32649
+ /** From the camera's own track description. Null when it does not say. */
32650
+ codec: string().nullable(),
32651
+ resolution: string().nullable(),
32652
+ /** Per-track overwrite flag, where the firmware keeps it per track. */
32653
+ overwriteWhenFull: boolean().nullable()
32654
+ })),
32655
+ /**
32656
+ * The track the write path targets — the enabled VIDEO one. Null when
32657
+ * no track could be identified, which is itself a refusal reason.
32658
+ */
32659
+ primaryTrackId: string().nullable(),
32660
+ /** Master "record to the card at all" switch. */
32661
+ enabled: boolean().nullable(),
32662
+ overwriteWhenFull: boolean().nullable(),
32663
+ preRecordSec: number().nullable(),
32664
+ postRecordSec: number().nullable(),
32665
+ /** Length of one recorded file, in minutes. */
32666
+ segmentMinutes: number().nullable(),
32667
+ /** The primary track's weekly windows, flattened. */
32668
+ windows: array(RecordWindowSchema),
32669
+ /**
32670
+ * How many windows the camera described that CamStack could NOT read —
32671
+ * an unrecognised trigger, an unparseable clock, a weekday it does not
32672
+ * name.
32673
+ *
32674
+ * A dropped window is work the reader threw away, and a schedule that
32675
+ * silently shows fewer rows than the camera holds is how an operator
32676
+ * saves back a schedule shorter than the one they were looking at
32677
+ * (D391). Non-zero means the window list is INCOMPLETE and a write
32678
+ * that replaces it would delete what was not shown — which is why a
32679
+ * provider reporting a non-zero count also reports the schedule as not
32680
+ * writable.
32681
+ */
32682
+ unreadableWindows: number(),
32683
+ /**
32684
+ * The camera is scheduled to record and has NO usable storage.
32685
+ *
32686
+ * A first-class fact because it is the fleet's most common silent
32687
+ * defect: measured 2026-09-22, 1441 and 3831 are both motion-recording
32688
+ * to a card that is not there. Neither the schedule nor the storage
32689
+ * read says anything wrong on its own; only the pair does.
32690
+ */
32691
+ recordingToNowhere: boolean(),
32692
+ lastFetchedAt: number()
32693
+ });
32694
+ /** Numeric range descriptor — `{ min, max, step }` per the getOptions convention. */
32695
+ var RangeSchema = object({
32696
+ min: number(),
32697
+ max: number(),
32698
+ step: number()
32699
+ });
32700
+ /**
32701
+ * The values a camera actually takes for a numeric field, when they are a SET
32702
+ * rather than a range.
32703
+ *
32704
+ * `{min,max,step}` cannot say what these two firmwares do. Measured on 1436
32705
+ * (I91DN) on 2026-09-22 by writing each value and reading it back:
32706
+ *
32707
+ * - pre-record: `0, 5, 10, 15, 20, 25, 30` and `2147483647` (INT32_MAX, the
32708
+ * camera's "no limit" — `-1` and `4294967295` both land on it);
32709
+ * - post-record: `5, 10, 30, 60, 120, 300, 600`.
32710
+ *
32711
+ * Neither is expressible as a step: the first has a sentinel two billion away
32712
+ * from its neighbours, the second doubles and then jumps. A range that tried
32713
+ * would forbid values the camera takes AND permit values it silently replaces
32714
+ * with 5 — wrong in both directions at once.
32715
+ *
32716
+ * `sentinel` names the member that is not a duration, so a surface can render
32717
+ * "no limit" instead of `2147483647` seconds.
32718
+ */
32719
+ var AllowedValuesSchema = object({
32720
+ values: array(number()).min(1),
32721
+ sentinel: object({
32722
+ value: number(),
32723
+ meaning: _enum(["no-limit", "disabled"])
32724
+ }).optional()
32725
+ });
32726
+ /**
32727
+ * Per-field availability on ONE camera.
32728
+ *
32729
+ * The field exists on every camera — this says whether this one can be
32730
+ * read and whether it can be written, and `reason` says why not when
32731
+ * either is false. The UI renders the control DISABLED with the reason
32732
+ * rather than hiding it, so a limitation is legible instead of looking
32733
+ * like a missing feature.
32734
+ */
32735
+ var OnboardFieldSupportSchema = object({
32736
+ readable: boolean(),
32737
+ writable: boolean(),
32738
+ /** Required whenever `readable` or `writable` is false. */
32739
+ reason: string().optional()
32740
+ });
32741
+ /** What this camera's schedule model can express. */
32742
+ var OnboardScheduleSupportSchema = object({
32743
+ support: OnboardFieldSupportSchema,
32744
+ /**
32745
+ * The smallest time step the camera can express, in minutes.
32746
+ *
32747
+ * Hikvision takes arbitrary minutes (`00:05:00`–`23:57:00` observed on
32748
+ * 1436's track 103). Reolink's schedule is a 7×24 HOUR mask, so 60. A
32749
+ * window whose edges are not a multiple of this is REFUSED rather than
32750
+ * quietly rounded — rounding is how an operator's 06:30 becomes 06:00
32751
+ * and nothing says so.
32752
+ */
32753
+ granularityMinutes: number(),
32754
+ /** Triggers this camera can record on. A window naming another is refused. */
32755
+ triggers: array(RecordTriggerSchema),
32756
+ /**
32757
+ * False when the camera stores ONE trigger per time range, so two
32758
+ * windows overlapping on the same day cannot carry different triggers.
32759
+ * True on Reolink, whose mask is per-trigger and independent.
32760
+ */
32761
+ supportsOverlappingTriggers: boolean()
32762
+ });
32763
+ var RecordingOnboardOptionsSchema = object({
32764
+ enabled: OnboardFieldSupportSchema,
32765
+ overwriteWhenFull: OnboardFieldSupportSchema,
32766
+ preRecordSec: OnboardFieldSupportSchema,
32767
+ preRecordSecRange: RangeSchema.optional(),
32768
+ /** Preferred over the range when the camera takes a SET, not a span. */
32769
+ preRecordSecAllowed: AllowedValuesSchema.optional(),
32770
+ postRecordSec: OnboardFieldSupportSchema,
32771
+ postRecordSecRange: RangeSchema.optional(),
32772
+ /** Preferred over the range when the camera takes a SET, not a span. */
32773
+ postRecordSecAllowed: AllowedValuesSchema.optional(),
32774
+ segmentMinutes: OnboardFieldSupportSchema,
32775
+ segmentMinutesRange: RangeSchema.optional(),
32776
+ /** Preferred over the range when the camera takes a SET, not a span. */
32777
+ segmentMinutesAllowed: AllowedValuesSchema.optional(),
32778
+ schedule: OnboardScheduleSupportSchema
32779
+ });
32780
+ /**
32781
+ * A partial change. Every field optional.
32782
+ *
32783
+ * Unlike the other `deviceConfig` caps, a provider here does **NOT**
32784
+ * silently ignore a field it cannot support — it refuses, by name,
32785
+ * through {@link describeOnboardRefusal}. Silence on a recording setting
32786
+ * is the failure D62 exists to prevent: the operator believes the camera
32787
+ * is recording the way the form says, and it is not.
32788
+ */
32789
+ var RecordingOnboardPatchSchema = object({
32790
+ enabled: boolean().optional(),
32791
+ overwriteWhenFull: boolean().optional(),
32792
+ preRecordSec: number().optional(),
32793
+ postRecordSec: number().optional(),
32794
+ segmentMinutes: number().optional(),
32795
+ /** The complete new window set for the primary track — not a delta. */
32796
+ windows: array(RecordWindowSchema).optional()
32797
+ });
32798
+ var recordingOnboardCapability = {
32799
+ name: "recording-onboard",
32800
+ scope: "device",
32801
+ deviceNative: true,
32802
+ mode: "singleton",
32803
+ deviceTypes: [DeviceType.Camera],
32804
+ deviceConfig: { ui: {
32805
+ kind: "derived-form",
32806
+ builderId: "recording-onboard",
32807
+ tab: "recording"
32808
+ } },
32809
+ methods: {
32810
+ getOptions: method(object({ deviceId: number() }), RecordingOnboardOptionsSchema),
32811
+ setSettings: method(object({
32812
+ deviceId: number(),
32813
+ settings: RecordingOnboardPatchSchema
32814
+ }), _void(), {
32815
+ kind: "mutation",
32816
+ auth: "admin"
32817
+ })
32818
+ },
32819
+ status: {
32820
+ schema: RecordingOnboardStatusSchema,
32821
+ kind: "poll"
32822
+ },
32823
+ runtimeState: RecordingOnboardStatusSchema,
32824
+ /**
32825
+ * Runtime-state durability: **restored** — operator-set camera-side
32826
+ * recording config; mutation-driven, and the storage half is the last
32827
+ * thing the camera said about its own card.
32828
+ *
32829
+ * See `RuntimeStateDurability`. Enforced by
32830
+ * `scripts/check-runtime-state-durability.ts`.
32831
+ */
32832
+ durability: "restored",
32833
+ /** Clock fields: written, but excluded from the compare that decides
32834
+ * whether persisting is worth a SQLite commit. */
32835
+ volatileStateFields: ["lastFetchedAt"]
32836
+ };
32837
+ /**
32210
32838
  * Generic device-level status snapshot. Auto-registered by `BaseDevice`
32211
32839
  * for every device, regardless of provider — the kernel needs a uniform
32212
32840
  * cap-keyed slice for the basic device flags every consumer expects to
@@ -42946,6 +43574,7 @@ var ALL_CAPABILITY_DEFINITIONS = [
42946
43574
  dayNightCapability,
42947
43575
  decoderCapability,
42948
43576
  detectionPipelineCapability,
43577
+ deviceAdminLinkCapability,
42949
43578
  deviceAdoptionCapability,
42950
43579
  deviceDiscoveryCapability,
42951
43580
  deviceExportCapability,
@@ -43020,6 +43649,7 @@ var ALL_CAPABILITY_DEFINITIONS = [
43020
43649
  rebootCapability,
43021
43650
  recordingCapability,
43022
43651
  recordingExportCapability,
43652
+ recordingOnboardCapability,
43023
43653
  recordingSignalCapability,
43024
43654
  sceneMonitorCapability,
43025
43655
  scriptRunnerCapability,
@@ -44364,6 +44994,12 @@ Object.freeze({
44364
44994
  addonId: null,
44365
44995
  access: "view"
44366
44996
  },
44997
+ "deviceAdminLink.getAdminLink": {
44998
+ capName: "device-admin-link",
44999
+ capScope: "device",
45000
+ addonId: null,
45001
+ access: "view"
45002
+ },
44367
45003
  "deviceAdoption.adopt": {
44368
45004
  capName: "device-adoption",
44369
45005
  capScope: "system",
@@ -48402,6 +49038,24 @@ Object.freeze({
48402
49038
  addonId: null,
48403
49039
  access: "view"
48404
49040
  },
49041
+ "recordingOnboard.getOptions": {
49042
+ capName: "recording-onboard",
49043
+ capScope: "device",
49044
+ addonId: null,
49045
+ access: "view"
49046
+ },
49047
+ "recordingOnboard.getStatus": {
49048
+ capName: "recording-onboard",
49049
+ capScope: "device",
49050
+ addonId: null,
49051
+ access: "view"
49052
+ },
49053
+ "recordingOnboard.setSettings": {
49054
+ capName: "recording-onboard",
49055
+ capScope: "device",
49056
+ addonId: null,
49057
+ access: "create"
49058
+ },
48405
49059
  "recordingSignal.getStatus": {
48406
49060
  capName: "recording-signal",
48407
49061
  capScope: "device",
@@ -49866,6 +50520,12 @@ Object.freeze({
49866
50520
  addonId: null,
49867
50521
  access: "view"
49868
50522
  },
50523
+ "videoclips.getPlaybackOptions": {
50524
+ capName: "videoclips",
50525
+ capScope: "device",
50526
+ addonId: null,
50527
+ access: "view"
50528
+ },
49869
50529
  "videoclips.listClips": {
49870
50530
  capName: "videoclips",
49871
50531
  capScope: "device",
@@ -49878,6 +50538,12 @@ Object.freeze({
49878
50538
  addonId: null,
49879
50539
  access: "view"
49880
50540
  },
50541
+ "videoclips.offerClipBytes": {
50542
+ capName: "videoclips",
50543
+ capScope: "device",
50544
+ addonId: null,
50545
+ access: "view"
50546
+ },
49881
50547
  "videoclips.readClipBytes": {
49882
50548
  capName: "videoclips",
49883
50549
  capScope: "device",
@@ -50288,6 +50954,11 @@ Object.freeze({
50288
50954
  form: "single",
50289
50955
  optional: true
50290
50956
  }],
50957
+ "deviceAdminLink.getAdminLink": [{
50958
+ name: "deviceId",
50959
+ form: "single",
50960
+ optional: false
50961
+ }],
50291
50962
  "deviceAdoption.release": [{
50292
50963
  name: "camDeviceId",
50293
50964
  form: "single",
@@ -51673,6 +52344,16 @@ Object.freeze({
51673
52344
  form: "single",
51674
52345
  optional: true
51675
52346
  }],
52347
+ "recordingOnboard.getOptions": [{
52348
+ name: "deviceId",
52349
+ form: "single",
52350
+ optional: false
52351
+ }],
52352
+ "recordingOnboard.setSettings": [{
52353
+ name: "deviceId",
52354
+ form: "single",
52355
+ optional: false
52356
+ }],
51676
52357
  "recordingSignal.getStatus": [{
51677
52358
  name: "deviceId",
51678
52359
  form: "single",
@@ -51909,6 +52590,11 @@ Object.freeze({
51909
52590
  form: "single",
51910
52591
  optional: false
51911
52592
  }],
52593
+ "videoclips.getPlaybackOptions": [{
52594
+ name: "deviceId",
52595
+ form: "single",
52596
+ optional: false
52597
+ }],
51912
52598
  "videoclips.listClips": [{
51913
52599
  name: "deviceId",
51914
52600
  form: "single",
@@ -51919,6 +52605,11 @@ Object.freeze({
51919
52605
  form: "single",
51920
52606
  optional: false
51921
52607
  }],
52608
+ "videoclips.offerClipBytes": [{
52609
+ name: "deviceId",
52610
+ form: "single",
52611
+ optional: false
52612
+ }],
51922
52613
  "videoclips.readClipBytes": [{
51923
52614
  name: "deviceId",
51924
52615
  form: "single",