@camstack/addon-export-hap 1.2.128 → 1.2.129

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.
@@ -5409,7 +5409,7 @@ var ZodIssueCode = {
5409
5409
  var ZodFirstPartyTypeKind;
5410
5410
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5411
5411
  //#endregion
5412
- //#region ../types/dist/sleep-CiR4z7r9.mjs
5412
+ //#region ../types/dist/sleep-CopaBJss.mjs
5413
5413
  /**
5414
5414
  * The audio chunk plane's byte format, and the ONE expansion from a coded
5415
5415
  * window to float samples (D455).
@@ -5609,6 +5609,12 @@ Object.fromEntries([
5609
5609
  icon: "circle-dot",
5610
5610
  order: 40
5611
5611
  },
5612
+ {
5613
+ id: "clips",
5614
+ label: "Clips",
5615
+ icon: "clapperboard",
5616
+ order: 41
5617
+ },
5612
5618
  {
5613
5619
  id: "engine",
5614
5620
  label: "Inference Engine",
@@ -9675,20 +9681,86 @@ var StorageCleanupJobSchema = object({
9675
9681
  });
9676
9682
  var StorageCleanupStatusInputSchema = object({ jobId: string().optional() });
9677
9683
  /**
9678
- * The one typed state of a storage location. Authoritative Zod schema — the TS
9679
- * alias below is `z.infer<>` of it, never a second spelling.
9684
+ * The storage-location STATE MODEL (D385) — one typed state, one policy module.
9685
+ *
9686
+ * A location's state used to be split across two authorities: the typed
9687
+ * `enabled` field (THE write switch since D383) and an untyped `config.readOnly`
9688
+ * key. They did not mean the same thing — `enabled: false` was still evicted
9689
+ * under disk pressure while `config.readOnly` was deliberately excluded — and
9690
+ * neither name said which. Every consumer re-derived the difference, and the
9691
+ * three questions that actually matter were answered in six places.
9692
+ *
9693
+ * This module is the ONLY place in the repo allowed to interpret the state. It
9694
+ * answers three questions and nothing else:
9695
+ *
9696
+ * - may this location be WRITTEN to? {@link modeMayWrite}
9697
+ * - may this location be READ? {@link modeMayRead}
9698
+ * - what is its eviction policy? {@link evictionPolicyForMode}
9699
+ *
9700
+ * | mode | write | read | eviction |
9701
+ * | ---------- | ----- | ---- | ------------------------------ |
9702
+ * | `active` | yes | yes | `normal` (pressure + usage cap) |
9703
+ * | `readonly` | no | yes | `never` |
9704
+ * | `drain` | no | yes | `drain` (paced, until empty) |
9705
+ * | `disabled` | no | no | `never` |
9706
+ *
9707
+ * `scripts/check-storage-location-mode-single-owner.ts` fails the build when
9708
+ * anything outside this module reads `config['readOnly']` or compares `enabled`
9709
+ * directly. A rule nothing checks has already been broken somewhere.
9680
9710
  */
9681
- var StorageLocationModeSchema = _enum([
9711
+ var STORAGE_LOCATION_MODES = [
9682
9712
  "active",
9683
9713
  "readonly",
9684
9714
  "drain",
9685
9715
  "disabled"
9686
- ]);
9716
+ ];
9717
+ /**
9718
+ * The one typed state of a storage location. Authoritative Zod schema — the TS
9719
+ * alias below is `z.infer<>` of it, never a second spelling.
9720
+ */
9721
+ var StorageLocationModeSchema = _enum(STORAGE_LOCATION_MODES);
9687
9722
  _enum([
9688
9723
  "normal",
9689
9724
  "never",
9690
9725
  "drain"
9691
9726
  ]);
9727
+ /** Is this mode a write target? Only `active` is. */
9728
+ function modeMayWrite(mode) {
9729
+ return mode === "active";
9730
+ }
9731
+ /**
9732
+ * The mode a LEGACY row implies, or `null` when it implies nothing — the row is
9733
+ * already stamped, or it carried neither flag.
9734
+ *
9735
+ * Both legacy flags fold to `readonly`, which is the CONSERVATIVE direction: a
9736
+ * state change must never start deleting footage on its own, and it must never
9737
+ * make footage that was still being served disappear. `enabled: false` used to
9738
+ * leave the location evictable under pressure; folding it to `readonly` stops
9739
+ * that, which is a strictly safer answer than the one it replaces.
9740
+ */
9741
+ function legacyModeOf(location) {
9742
+ if (location.mode !== void 0) return null;
9743
+ if (location.config["readOnly"] === true) return "readonly";
9744
+ if (location.enabled === false) return "readonly";
9745
+ return null;
9746
+ }
9747
+ /**
9748
+ * The state of a location, stamped or folded. THE one interpretation: a row
9749
+ * that predates D385 is never ambiguous, and a stamped `mode` always wins over
9750
+ * whatever the legacy pair still says.
9751
+ */
9752
+ function resolveLocationMode(location) {
9753
+ return (isStorageLocationMode(location.mode) ? location.mode : void 0) ?? legacyModeOf(location) ?? "active";
9754
+ }
9755
+ /** Is this one of the four states? The stamped value crosses a wire, and a
9756
+ * value nobody defined must not be rendered as if it were a state. */
9757
+ function isStorageLocationMode(value) {
9758
+ return STORAGE_LOCATION_MODES.some((mode) => mode === value);
9759
+ }
9760
+ /** May this location be written to? */
9761
+ function mayWriteToLocation(location) {
9762
+ return modeMayWrite(resolveLocationMode(location));
9763
+ }
9692
9764
  /**
9693
9765
  * `StorageLocationType` — an addon-declared id that identifies the *kind* of
9694
9766
  * storage a location serves. Defined here (not in `capabilities/storage.cap.ts`)
@@ -14975,6 +15047,8 @@ method(object({
14975
15047
  deviceId: number(),
14976
15048
  capName: string(),
14977
15049
  wrapperAddonId: string(),
15050
+ /** The `ClipSource.source` id to toggle. Absent = all of the addon's. */
15051
+ sourceId: string().optional(),
14978
15052
  active: boolean()
14979
15053
  }), _void(), {
14980
15054
  kind: "mutation",
@@ -24953,29 +25027,257 @@ var ClipSchema = object({
24953
25027
  holes: array(object({
24954
25028
  startMs: number(),
24955
25029
  endMs: number()
24956
- })).optional()
25030
+ })).optional(),
25031
+ /**
25032
+ * A URL that MAY produce this clip's still, asked only for rows actually on
25033
+ * screen. The opposite of {@link ClipSchema.thumbnail}: that one is VOUCHED
25034
+ * (a JPEG is already on disk), this one is an offer. The route answers 200
25035
+ * with the image, or 204 with `x-camstack-reason` when it could not mint one
25036
+ * — a surface latches that refusal to the instant rather than retrying.
25037
+ *
25038
+ * Never both: a clip with a vouched `thumbnail` needs no mint.
25039
+ */
25040
+ thumbnailMint: string().optional(),
25041
+ /**
25042
+ * Why no still will be produced for this clip RIGHT NOW — set when the
25043
+ * provider already knows, so the surface draws the glyph and the reason
25044
+ * instead of firing a mint that cannot succeed.
25045
+ *
25046
+ * `sleeping` — a standalone battery camera; a read would be a wake (D549 4).
25047
+ * `camera-refused` — the camera answered the CoverPreview with a refusal.
25048
+ * `no-keyframe` — the window holds no decodable I-frame (an event that sits
25049
+ * inside no file is the measured case).
25050
+ * `unsupported` — this source cannot mint stills at all.
25051
+ */
25052
+ thumbnailUnavailable: object({ reason: _enum([
25053
+ "sleeping",
25054
+ "camera-refused",
25055
+ "no-keyframe",
25056
+ "unsupported"
25057
+ ]) }).optional(),
25058
+ /**
25059
+ * When the catalog this row came from was last CONFIRMED against the device.
25060
+ * Absent means "this row was read live". A persisted catalog served while a
25061
+ * camera sleeps carries the age it really has — a cached list is never drawn
25062
+ * as current (D549 13).
25063
+ */
25064
+ catalogAsOf: number().optional(),
25065
+ /**
25066
+ * The camera's OWN type strings for this clip, all of them, unmapped
25067
+ * (`md`, `people`, `dog_cat`, `sched`, …). Kept beside {@link labels}
25068
+ * because a firmware inventing a type must not vanish: the mapping into our
25069
+ * filter vocabulary is lossy on purpose and this is the lossless copy
25070
+ * (D549 20).
25071
+ */
25072
+ nativeTypes: array(string()).optional(),
25073
+ /**
25074
+ * What the subject DID — `crossline`, `intrude`, `loitering`. A behaviour
25075
+ * travels BESIDE a class, never instead of one, and the class filter ignores
25076
+ * it: "a person crossed a line" is still a person (D549 20).
25077
+ */
25078
+ behaviours: array(string()).optional(),
25079
+ /**
25080
+ * The same recording as two files — the sub twin (what the row and its
25081
+ * thumbnail are) and its main twin, matched at listing time so a quality
25082
+ * change never re-searches the camera (D549 15). `getClipPlayback`'s
25083
+ * `profile` picks between them: `low | mid` → sub, `high` → main.
25084
+ */
25085
+ streams: object({
25086
+ sub: object({
25087
+ id: string(),
25088
+ bytes: number().optional()
25089
+ }).optional(),
25090
+ main: object({
25091
+ id: string(),
25092
+ bytes: number().optional()
25093
+ }).optional()
25094
+ }).optional(),
25095
+ /** The camera says it holds a sub-stream copy of this recording. */
25096
+ supportSub: boolean().optional(),
25097
+ /**
25098
+ * Whether this row has bytes behind it. ABSENT means yes — every clip that
25099
+ * IS a file is playable, and only a source that lists EVENTS can produce a
25100
+ * row with nothing to play (the measured case: 1 of 29 hub events on 3628
25101
+ * fell inside no file). Such a row is shown, never dropped and never offered
25102
+ * as playable-then-failing (D549 19).
25103
+ */
25104
+ playable: boolean().optional(),
25105
+ /** Why {@link playable} is false, verbatim (`no-file-for-window`). */
25106
+ unplayableReason: string().optional()
24957
25107
  });
24958
25108
  var ClipPlaybackSchema = object({
24959
- /** HLS master URL through the hub data-plane (Range + token in path). */
25109
+ /**
25110
+ * A media URL through the hub data-plane. {@link ClipPlaybackSchema.format}
25111
+ * says what kind — an HLS master playlist for a recording-derived clip, a
25112
+ * progressive MP4 for a native file the vendor addon muxed and serves with
25113
+ * `Range`. The URL is NOT a credential: both planes are registered
25114
+ * `access: 'authenticated'` and the hub's `/addon/<id>/<prefix>` proxy takes
25115
+ * the session cookie, so nothing is appended to it (D549 22).
25116
+ */
24960
25117
  playbackUrl: string(),
25118
+ /**
25119
+ * How to play {@link playbackUrl}. Absent means `hls` — the shape every
25120
+ * existing consumer already assumes. A player that branches on this is the
25121
+ * one change a native clip needs; a player that ignores it will hand an MP4
25122
+ * to hls.js and fail parsing it as a manifest.
25123
+ */
25124
+ format: _enum(["hls", "mp4"]).optional(),
25125
+ /**
25126
+ * Which twin was actually served. A clip that holds only one stream answers
25127
+ * with the one it has, and the surface SAYS so — a missing main twin is
25128
+ * never served silently as if it were the asked-for quality (D549 15).
25129
+ */
25130
+ served: CamProfileSchema.optional(),
24961
25131
  /** Optional LAN/remote alternates for the same clip. */
24962
25132
  playbackEndpoints: array(string()).optional(),
24963
25133
  token: string().optional()
24964
25134
  });
25135
+ /**
25136
+ * Why a source cannot answer right now — per SOURCE, never fleet-wide.
25137
+ *
25138
+ * `ok` is the only state whose clip list may be read as complete. The other
25139
+ * three exist because an empty list from a sleeping camera reads as "this
25140
+ * camera has no recordings", which is the defect this whole line of work is
25141
+ * about: a battery camera nobody woke (`sleeping`), a camera that could not be
25142
+ * reached at all (`unreachable`, also what a provider that THREW reports), a
25143
+ * camera whose SD card is not mounted (`no-storage`, measured on 640 —
25144
+ * `HddInfo mount 0`, honestly nothing to list rather than "no clips"), and a
25145
+ * camera whose OWN index disagrees with its OWN calendar (`index-empty`,
25146
+ * measured on 618: the calendar marks 3–20 September, days 6–20 list zero
25147
+ * files on both streams and both filters, with 177 GB free). That last one is
25148
+ * a defect ON THE CAMERA, and the only honest thing a provider can do is say
25149
+ * which days it asked for and got nothing — "no clips" would be a lie about a
25150
+ * card that is full of them.
25151
+ */
25152
+ var ClipSourceAvailabilitySchema = object({
25153
+ state: _enum([
25154
+ "ok",
25155
+ "sleeping",
25156
+ "unreachable",
25157
+ "no-storage",
25158
+ "index-empty"
25159
+ ]),
25160
+ /** Free text, shown verbatim. Names the camera's own refusal when there is one. */
25161
+ reason: string().optional(),
25162
+ /** When this source's catalog was last CONFIRMED. A cached list is never
25163
+ * drawn as current: the surface shows the age whenever it is older than the
25164
+ * refresh interval. */
25165
+ catalogAsOf: number().optional()
25166
+ });
25167
+ /**
25168
+ * One SOURCE of clips for a camera — a row of the picker, and the namespace
25169
+ * every clip id from it is prefixed with (`analytics`,
25170
+ * `native:reolink:onboard`, `hksv`, …).
25171
+ *
25172
+ * A provider lists the sources IT serves for that device, and answers for each
25173
+ * of them whether it can answer at all. A provider with nothing to offer on a
25174
+ * camera returns `[]` — it is not that camera's business.
25175
+ */
25176
+ var ClipSourceSchema = object({
25177
+ /** The value this source stamps on {@link ClipSchema.source}, and the prefix
25178
+ * of every clip id it mints. `getClipPlayback` routes on it. */
25179
+ source: string(),
25180
+ /** Operator-facing name of the source ("CamStack recordings", "SD card"). */
25181
+ label: string(),
25182
+ /**
25183
+ * The addon that SERVES this row.
25184
+ *
25185
+ * A surface cannot otherwise resolve a source to who answers it, and the
25186
+ * alternative — a `native:reolink:* → provider-reolink` table inside the
25187
+ * widget — is a second authority on provider identity living in the one
25188
+ * package with no business knowing it, wrong the day a third source appears
25189
+ * (D557). One addon may serve SEVERAL sources: `addon-provider-reolink`
25190
+ * answers a hub child with both `native:reolink:onboard` and
25191
+ * `native:reolink:hub`, which is why the per-device switch is keyed by
25192
+ * SOURCE and not by this (D555).
25193
+ *
25194
+ * Optional for version skew only. The collection dispatcher stamps it from
25195
+ * the registry, so a row that travelled through the fan-out carries the
25196
+ * authoritative id whatever the provider filled in.
25197
+ */
25198
+ addonId: string().optional(),
25199
+ /**
25200
+ * Which API this source resolved to FOR THIS CAMERA, when it has a choice.
25201
+ *
25202
+ * A source may cover one store through more than one surface — the Reolink
25203
+ * provider reads a hub child through the parent's event log and a standalone
25204
+ * through its own file list, because that is what each camera answers. The
25205
+ * CHOICE is the provider's, made from what the camera is, and is never a row
25206
+ * the operator has to understand; but it is REPORTED, because a source that
25207
+ * silently reads a different API on two cameras and then behaves differently
25208
+ * is the thing nobody can debug later. Absent when the source has only one
25209
+ * way to read its store.
25210
+ */
25211
+ via: string().optional(),
25212
+ availability: ClipSourceAvailabilitySchema,
25213
+ /**
25214
+ * The operator switched this source OFF for this camera.
25215
+ *
25216
+ * Deliberately NOT a member of {@link ClipSourceAvailabilitySchema}'s
25217
+ * vocabulary. That enum models what the source CAN do — a sleeping camera, an
25218
+ * unmounted card, an index that disagrees with its own calendar — and a
25219
+ * switched-off source could answer perfectly well; the operator decided it
25220
+ * should not. Folding the choice in is how `disabled` and `broken` stop being
25221
+ * distinguishable, which is the D62 rule this repo has already paid for twice:
25222
+ * an off switch is REPORTED off (`CameraStatus.switchedOff`, the same word),
25223
+ * and disabled must never look like broken. It is also what lets every
25224
+ * exhaustive consumer of the availability enum keep compiling.
25225
+ *
25226
+ * A switched-off source contributes NO clips (`listClips` never calls it) and
25227
+ * its row carries no `catalogAsOf`: nothing confirms a catalog it is not
25228
+ * allowed to serve, and a frozen age that can only grow draws a stalling
25229
+ * source rather than an off switch.
25230
+ *
25231
+ * The row survives BECAUSE it is the control the operator switches the source
25232
+ * back on from — D554 decision 4's rule ("a source that cannot answer
25233
+ * produces a ROW, not an absence") applied to the one case D556 carved out of
25234
+ * it, and D557's own kept property ("a source is never hidden, only its
25235
+ * rows"). Absent means on.
25236
+ *
25237
+ * A provider never sets this — like {@link ClipSourceSchema.addonId} it is
25238
+ * stamped by the collection dispatcher, which holds the registry's projection
25239
+ * of the persisted authority (D556) and is the only place that knows it.
25240
+ */
25241
+ switchedOff: boolean().optional()
25242
+ });
24965
25243
  DeviceType.Camera, method(object({
24966
25244
  deviceId: number(),
24967
25245
  since: number(),
24968
25246
  until: number(),
24969
- limit: number().int().positive().optional()
25247
+ limit: number().int().positive().optional(),
25248
+ /**
25249
+ * View filter over {@link ClipSourceSchema.source} values — the
25250
+ * picker's selection, forwarded so a provider need not list what
25251
+ * nobody is looking at. ABSENT means every source this camera has,
25252
+ * which is the honest default for a surface whose whole point is that
25253
+ * nothing is hidden (D554 3). A provider with one source ignores it.
25254
+ */
25255
+ sources: array(string()).optional()
24970
25256
  }), array(ClipSchema).readonly(), {
24971
25257
  kind: "query",
24972
- auth: "admin"
25258
+ auth: "protected"
25259
+ }), method(object({ deviceId: number() }), array(ClipSourceSchema).readonly(), {
25260
+ kind: "query",
25261
+ auth: "protected"
24973
25262
  }), method(object({
24974
25263
  deviceId: number(),
24975
- clipId: string()
25264
+ clipId: string(),
25265
+ /**
25266
+ * Which twin to serve, on the ONE quality scale the system already has
25267
+ * (`CamProfileSchema`). `low | mid` → the sub file, `high` → the main
25268
+ * twin; both ids are already on the row so this never re-searches the
25269
+ * camera. Absent means the provider's own default (the sub file, which
25270
+ * every source is measured to hold).
25271
+ *
25272
+ * `auto` is deliberately NOT accepted here: a stored file has no
25273
+ * broker session, so the adaptive tier cannot be resolved for it. The
25274
+ * viewer resolves `auto` to a profile the same way live does, before
25275
+ * it calls (D549 15).
25276
+ */
25277
+ profile: CamProfileSchema.optional()
24976
25278
  }), ClipPlaybackSchema, {
24977
25279
  kind: "query",
24978
- auth: "admin"
25280
+ auth: "protected"
24979
25281
  });
24980
25282
  /**
24981
25283
  * Optional client-side hints sent at session creation to help the provider
@@ -39007,6 +39309,12 @@ Object.freeze({
39007
39309
  addonId: null,
39008
39310
  access: "view"
39009
39311
  },
39312
+ "videoclips.listSources": {
39313
+ capName: "videoclips",
39314
+ capScope: "device",
39315
+ addonId: null,
39316
+ access: "view"
39317
+ },
39010
39318
  "viewerUi.getStaticDir": {
39011
39319
  capName: "viewer-ui",
39012
39320
  capScope: "system",
@@ -41015,6 +41323,11 @@ Object.freeze({
41015
41323
  form: "single",
41016
41324
  optional: false
41017
41325
  }],
41326
+ "videoclips.listSources": [{
41327
+ name: "deviceId",
41328
+ form: "single",
41329
+ optional: false
41330
+ }],
41018
41331
  "waterHeater.setAway": [{
41019
41332
  name: "deviceId",
41020
41333
  form: "single",
@@ -88779,6 +89092,124 @@ function firstExposedAccessorySetupUri(exposed, logger) {
88779
89092
  }
88780
89093
  }
88781
89094
  //#endregion
89095
+ //#region src/hksv/clip-location.ts
89096
+ /** The declared id. Deployment-wide; see the docblock for why it is new. */
89097
+ var HOMEKIT_CLIPS_LOCATION_TYPE = "homekitClips";
89098
+ /** The class subtree under the resolved root. Never written at the root (D327). */
89099
+ var HOMEKIT_CLIPS_SUBTREE = "homekit-clips";
89100
+ /**
89101
+ * The cap calls, HERE and not in the addon.
89102
+ *
89103
+ * Deliberately co-located with the `mayWriteToLocation` filter below: a file
89104
+ * that enumerates storage locations must visibly decide whether it is choosing
89105
+ * a WRITE target (`scripts/check-storage-write-target-enabled.ts` enforces
89106
+ * exactly that, and it fired when the closure lived in the addon). Splitting
89107
+ * the enumeration from the decision is how the backup fan-out grew a second
89108
+ * enabled flag.
89109
+ */
89110
+ function createClipLocationPorts(api) {
89111
+ return {
89112
+ listLocations: () => api.storage.listLocations.query({}),
89113
+ resolvePath: (locationId) => api.storage.resolve.query({
89114
+ location: locationId,
89115
+ relativePath: ""
89116
+ })
89117
+ };
89118
+ }
89119
+ var HksvClipLocation = class {
89120
+ input;
89121
+ log;
89122
+ now;
89123
+ revalidateMs;
89124
+ cachedRoot = null;
89125
+ resolvedAt = Number.NEGATIVE_INFINITY;
89126
+ refusal = null;
89127
+ /** What the last warn said, so a per-clip refusal is not a per-clip log line. */
89128
+ announced = null;
89129
+ inFlight = null;
89130
+ constructor(input) {
89131
+ this.input = input;
89132
+ this.log = input.logger;
89133
+ this.now = input.now ?? Date.now;
89134
+ this.revalidateMs = input.revalidateMs ?? 6e4;
89135
+ }
89136
+ /** Why the last resolution produced no root. `null` once one succeeded. */
89137
+ get lastRefusal() {
89138
+ return this.refusal;
89139
+ }
89140
+ /** The doorbell rang (`StorageLocationsChanged`): drop the mirror. */
89141
+ invalidate() {
89142
+ this.resolvedAt = Number.NEGATIVE_INFINITY;
89143
+ }
89144
+ /**
89145
+ * The directory clips are written under, or `null` when there is none right
89146
+ * now. Never throws: the caller's correct move for every refusal is the same
89147
+ * — do not tee this clip, and leave HomeKit alone.
89148
+ */
89149
+ async root() {
89150
+ if (this.now() - this.resolvedAt < this.revalidateMs) return this.cachedRoot;
89151
+ const inflight = this.inFlight;
89152
+ if (inflight !== null) return inflight;
89153
+ const attempt = this.resolve();
89154
+ this.inFlight = attempt;
89155
+ try {
89156
+ return await attempt;
89157
+ } finally {
89158
+ this.inFlight = null;
89159
+ }
89160
+ }
89161
+ async resolve() {
89162
+ try {
89163
+ const rows = (await this.input.ports.listLocations()).filter((l) => l.type === HOMEKIT_CLIPS_LOCATION_TYPE);
89164
+ if (rows.length === 0) return this.refuse("no-location", {});
89165
+ if (rows.length > 1) this.warnOnce("hksv clip store: more than one HomeKit clips location exists — it is declared single", { ids: rows.map((l) => l.id) });
89166
+ const writable = rows.find((l) => mayWriteToLocation(l));
89167
+ if (writable === void 0) {
89168
+ const first = rows[0];
89169
+ return this.refuse("not-writable", {
89170
+ locationId: first?.id ?? null,
89171
+ mode: first === void 0 ? null : resolveLocationMode(first)
89172
+ });
89173
+ }
89174
+ const root = `${(await this.input.ports.resolvePath(writable.id)).replace(/\/+$/, "")}/${HOMEKIT_CLIPS_SUBTREE}`;
89175
+ this.cachedRoot = root;
89176
+ this.resolvedAt = this.now();
89177
+ this.refusal = null;
89178
+ if (this.announced !== null) {
89179
+ this.announced = null;
89180
+ this.log.info("hksv clip store: the clips location is writable again", { meta: {
89181
+ locationId: writable.id,
89182
+ root
89183
+ } });
89184
+ }
89185
+ return root;
89186
+ } catch (err) {
89187
+ return this.refuse("unreachable", { error: err instanceof Error ? err.message : String(err) });
89188
+ }
89189
+ }
89190
+ refuse(reason, meta) {
89191
+ this.cachedRoot = null;
89192
+ this.resolvedAt = this.now();
89193
+ this.refusal = reason;
89194
+ this.warnOnce("hksv clip store: no writable HomeKit clips location — clips are NOT being kept (HomeKit recording is unaffected)", {
89195
+ reason,
89196
+ ...meta
89197
+ });
89198
+ return null;
89199
+ }
89200
+ /**
89201
+ * One line per distinct situation. A refusal that reprinted on every clip
89202
+ * would drown the line that says it CHANGED, and the operator reads the log
89203
+ * to find out which of the two is happening.
89204
+ */
89205
+ warnOnce(message, meta) {
89206
+ const key = `${message}|${JSON.stringify(meta)}`;
89207
+ if (this.announced === key) return;
89208
+ this.announced = key;
89209
+ this.log.warn(message, { meta });
89210
+ }
89211
+ };
89212
+ //#endregion
88782
89213
  //#region src/hksv/clip-record.ts
88783
89214
  /**
88784
89215
  * What ONE teed HomeKit clip is, on disk.
@@ -88884,32 +89315,39 @@ function clipStem(clipId) {
88884
89315
  *
88885
89316
  * ## The owner
88886
89317
  *
88887
- * The **export-hap addon**, in its OWN data dir, and nothing else. Not the
88888
- * recorder's storage locations and not the `eventMedia` class:
89318
+ * The **export-hap addon**, on its own DECLARED storage location
89319
+ * (`homekitClips`, see `clip-location.ts`) — the operator picks the disk. Not
89320
+ * the `eventMedia` class and not `recordings`:
88889
89321
  *
88890
- * - `export-hap` is hub-only and runs in its own runner. The recorder's
88891
- * location machinery (D385/D386/D389) is another addon's, in another
88892
- * process, and reaching it would be a cross-runner write on the frame path
88893
- * of a live HomeKit session — exactly what D9/D18 forbid.
88894
89322
  * - A teed clip is a DERIVED artifact of a HomeKit session, with no track, no
88895
89323
  * detection and no event behind it. Filing it under `eventMedia` would put
88896
89324
  * it inside the post-analysis retention sweep, where its lifetime would be
88897
89325
  * decided by a policy written for a different question.
88898
- * - The precedent is D549 decision 13: Reolink's clip thumbnails live in the
88899
- * vendor addon's data dir under a per-device LRU, outside `eventMedia` and
88900
- * outside the sweep, for the same reason.
89326
+ * - `recordings` rows belong to the recorder's placement planner, its evictor
89327
+ * and its drain ratchet, and these files are not segments its index knows.
89328
+ * - The precedent for a class of its own is D549 decision 13: Reolink's clip
89329
+ * thumbnails sit outside `eventMedia` and outside the sweep for the same
89330
+ * reason.
88901
89331
  *
88902
- * Layout: `<dataDir>/hksv-clips/<deviceId>/<stem>.{mp4,jpg,json}`. One stem per
88903
- * clip, three extensions, so a pruner deleting a clip never has to guess which
88904
- * JPEG was its.
89332
+ * Layout: `<location root>/homekit-clips/<deviceId>/<stem>.{mp4,jpg,json}`. One
89333
+ * stem per clip, three extensions, so a pruner deleting a clip never has to
89334
+ * guess which JPEG was its; the `homekit-clips/` subtree is mandatory because
89335
+ * the location's seeded default SHARES the recordings root (D327).
88905
89336
  *
88906
- * ## The retention, and why these numbers
89337
+ * The first cut wrote to `<dataDir>/hksv-clips/`. It was defensible and gave
89338
+ * the operator no say, which was the first thing he asked for after seeing it.
89339
+ *
89340
+ * ## The retention: one preference, three rails
88907
89341
  *
88908
89342
  * HomeKit's own retention is unreadable — HAP has no read-back, so CamStack can
88909
89343
  * never ask "does iOS still have this one?" and reconcile. The store therefore
88910
89344
  * cannot mirror HomeKit's lifetime and does not pretend to: it keeps a bounded
88911
- * recent window and says, per clip, exactly when it started. Four bounds, all
88912
- * enforced on every completion:
89345
+ * recent window and says, per clip, exactly when it started.
89346
+ *
89347
+ * **The AGE is the operator's, per camera**
89348
+ * ({@link HksvClipStoreInput.maxAgeMsFor}, cascaded global-default plus
89349
+ * per-device override by the addon), defaulting to 14 days. The other three are
89350
+ * RAILS, not preferences, and stay fixed:
88913
89351
  *
88914
89352
  * - **{@link DEFAULT_CLIP_BOUNDS.maxClipBytes} per clip (256 MB).** A clip's
88915
89353
  * duration is whatever iOS pulled — there is no window we choose — so this
@@ -88922,12 +89360,18 @@ function clipStem(clipId) {
88922
89360
  * Both bounds exist for the same reason the prebuffer ring has both: at 4K
88923
89361
  * a fragment is 6.35 MB, a 25x spread, and a count-only bound is a
88924
89362
  * per-camera disk figure nobody can predict.
88925
- * - **{@link DEFAULT_CLIP_BOUNDS.maxAgeMs} (14 days)**, so a camera that
88926
- * stopped recording in March does not hold half a gigabyte forever.
88927
89363
  * - **{@link DEFAULT_CLIP_BOUNDS.maxStoreBytes} (4 GB)** across every camera,
88928
89364
  * applied after the per-device pass. Per-device bounds alone multiply by a
88929
89365
  * camera count the operator changes without thinking about this store.
88930
89366
  *
89367
+ * An operator able to set the first to zero could turn the tee into a silent
89368
+ * no-op, and one able to raise the last without limit could fill the disk; the
89369
+ * age changes neither. The consequence is stated rather than left to be
89370
+ * discovered: a retention LONGER than the rails allow does not reach — a busy
89371
+ * camera hits 200 clips or 512 MB first. Every prune logs which bound fired
89372
+ * (`reason: age | count | bytes`), so "my 30 days did nothing" has an answer in
89373
+ * the log instead of a theory.
89374
+ *
88931
89375
  * A file nobody prunes is a disk that fills, and the pruner is the only thing
88932
89376
  * standing between a permanent prebuffer and that.
88933
89377
  *
@@ -88940,8 +89384,6 @@ function clipStem(clipId) {
88940
89384
  * deleted — this repo has twice shipped a green test because the fake supplied
88941
89385
  * what production forgot, and a promise is not a file.
88942
89386
  */
88943
- /** The directory under the addon's data dir. Ours, and only ours. */
88944
- var CLIP_DIR_NAME = "hksv-clips";
88945
89387
  /** See the class docblock for what each number is, and where it comes from. */
88946
89388
  var DEFAULT_CLIP_BOUNDS = {
88947
89389
  maxClipBytes: 256 * 1024 * 1024,
@@ -88965,6 +89407,11 @@ var HksvClipStore = class {
88965
89407
  };
88966
89408
  this.now = input.now ?? Date.now;
88967
89409
  }
89410
+ /** This camera's retention. One authority, asked at every prune. */
89411
+ maxAgeMsFor(deviceId) {
89412
+ const resolved = this.input.maxAgeMsFor?.(deviceId);
89413
+ return resolved === void 0 || !Number.isFinite(resolved) || resolved <= 0 ? this.bounds.maxAgeMs : resolved;
89414
+ }
88968
89415
  /** The per-clip ceiling the tee enforces while writing. */
88969
89416
  get maxClipBytes() {
88970
89417
  return this.bounds.maxClipBytes;
@@ -88977,8 +89424,10 @@ var HksvClipStore = class {
88977
89424
  async open(input) {
88978
89425
  const clipId = clipIdFor(input.deviceId, input.startedAtMs, input.streamId);
88979
89426
  const names = clipFileNames(clipId);
88980
- const dir = this.deviceDir(input.deviceId);
88981
89427
  const tags = { deviceId: input.deviceId };
89428
+ const root = await this.resolveRootOrWarn(tags);
89429
+ if (root === null) return null;
89430
+ const dir = this.deviceDir(root, input.deviceId);
88982
89431
  try {
88983
89432
  await (0, node_fs_promises.mkdir)(dir, { recursive: true });
88984
89433
  const sink = await createFileSink((0, node_path.join)(dir, names.clip));
@@ -88989,7 +89438,7 @@ var HksvClipStore = class {
88989
89438
  return {
88990
89439
  clipId,
88991
89440
  sink,
88992
- complete: (outcome) => this.complete(clipId, input, names, outcome)
89441
+ complete: (outcome) => this.complete(clipId, input, names, outcome, root)
88993
89442
  };
88994
89443
  } catch (err) {
88995
89444
  this.inFlight.delete(clipId);
@@ -89005,7 +89454,9 @@ var HksvClipStore = class {
89005
89454
  }
89006
89455
  /** Every sidecar for a camera, newest first. What a later provider lists. */
89007
89456
  async listRecords(deviceId) {
89008
- const dir = this.deviceDir(deviceId);
89457
+ const root = await this.rootOrNull();
89458
+ if (root === null) return [];
89459
+ const dir = this.deviceDir(root, deviceId);
89009
89460
  let names;
89010
89461
  try {
89011
89462
  names = await (0, node_fs_promises.readdir)(dir);
@@ -89022,7 +89473,9 @@ var HksvClipStore = class {
89022
89473
  }
89023
89474
  /** iOS sent `ack`: HomeKit itself kept this clip. Recorded on the sidecar. */
89024
89475
  async acknowledge(deviceId, clipId) {
89025
- const path = (0, node_path.join)(this.deviceDir(deviceId), clipFileNames(clipId).record);
89476
+ const root = await this.rootOrNull();
89477
+ if (root === null) return;
89478
+ const path = (0, node_path.join)(this.deviceDir(root, deviceId), clipFileNames(clipId).record);
89026
89479
  const record = await this.readRecord(path, deviceId);
89027
89480
  if (record === null) return;
89028
89481
  await this.writeRecord(path, {
@@ -89030,12 +89483,41 @@ var HksvClipStore = class {
89030
89483
  acknowledgedByHomeKit: true
89031
89484
  });
89032
89485
  }
89033
- deviceDir(deviceId) {
89034
- return (0, node_path.join)(this.input.rootDir, String(deviceId));
89486
+ /**
89487
+ * The root, or `null` with ONE warn. Never throws: the caller's correct move
89488
+ * for every refusal is identical — no clip, HomeKit untouched.
89489
+ */
89490
+ async resolveRootOrWarn(tags) {
89491
+ try {
89492
+ const root = await this.input.resolveRoot();
89493
+ if (root !== null) return root;
89494
+ } catch (err) {
89495
+ this.log.warn("hksv clip store: resolving the clips location FAILED — nothing is teed", {
89496
+ tags,
89497
+ meta: { error: err instanceof Error ? err.message : String(err) }
89498
+ });
89499
+ return null;
89500
+ }
89501
+ this.log.warn("hksv clip store: no writable clips location — this camera keeps no HomeKit clips", {
89502
+ tags,
89503
+ meta: {}
89504
+ });
89505
+ return null;
89506
+ }
89507
+ /** The root for a READ. Quiet: a read with no location is simply empty. */
89508
+ async rootOrNull() {
89509
+ try {
89510
+ return await this.input.resolveRoot();
89511
+ } catch {
89512
+ return null;
89513
+ }
89514
+ }
89515
+ deviceDir(root, deviceId) {
89516
+ return (0, node_path.join)(root, String(deviceId));
89035
89517
  }
89036
- async complete(clipId, input, names, outcome) {
89518
+ async complete(clipId, input, names, outcome, root) {
89037
89519
  this.inFlight.delete(clipId);
89038
- const dir = this.deviceDir(input.deviceId);
89520
+ const dir = this.deviceDir(root, input.deviceId);
89039
89521
  const tags = { deviceId: input.deviceId };
89040
89522
  const clipPath = (0, node_path.join)(dir, names.clip);
89041
89523
  if (!outcome.sawInit || outcome.bytes === 0) {
@@ -89092,7 +89574,7 @@ var HksvClipStore = class {
89092
89574
  thumbnailUnavailable: record.thumbnailUnavailable?.reason ?? null
89093
89575
  }
89094
89576
  });
89095
- await this.prune(input.deviceId);
89577
+ await this.prune(root, input.deviceId);
89096
89578
  return record;
89097
89579
  }
89098
89580
  /**
@@ -89139,26 +89621,31 @@ var HksvClipStore = class {
89139
89621
  * sidecars, so a stem whose sidecar never landed — a crash mid-write — is
89140
89622
  * seen and removed instead of occupying the disk invisibly forever.
89141
89623
  */
89142
- async prune(deviceId) {
89143
- const cutoff = this.now() - this.bounds.maxAgeMs;
89144
- const perDevice = await this.scanDevice(deviceId);
89145
- await this.enforce(perDevice, cutoff, this.bounds.maxClipsPerDevice, this.bounds.maxBytesPerDevice);
89624
+ async prune(root, deviceId) {
89625
+ const perDevice = await this.scanDevice(root, deviceId);
89626
+ await this.enforce(perDevice, this.bounds.maxClipsPerDevice, this.bounds.maxBytesPerDevice);
89146
89627
  let deviceIds;
89147
89628
  try {
89148
- deviceIds = (await (0, node_fs_promises.readdir)(this.input.rootDir, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => Number.parseInt(e.name, 10)).filter((id) => Number.isFinite(id));
89629
+ deviceIds = (await (0, node_fs_promises.readdir)(root, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => Number.parseInt(e.name, 10)).filter((id) => Number.isFinite(id));
89149
89630
  } catch {
89150
89631
  return;
89151
89632
  }
89152
89633
  const all = [];
89153
- for (const id of deviceIds) all.push(...await this.scanDevice(id));
89154
- await this.enforce(all, cutoff, Number.POSITIVE_INFINITY, this.bounds.maxStoreBytes);
89634
+ for (const id of deviceIds) all.push(...await this.scanDevice(root, id));
89635
+ await this.enforce(all, Number.POSITIVE_INFINITY, this.bounds.maxStoreBytes);
89155
89636
  }
89156
- async enforce(clips, ageCutoff, maxCount, maxBytes) {
89637
+ /**
89638
+ * Enforce the bounds, oldest first. The AGE is asked per clip's CAMERA — two
89639
+ * cameras with different retentions share this pass, and folding them into
89640
+ * one cutoff would silently give every camera the shortest.
89641
+ */
89642
+ async enforce(clips, maxCount, maxBytes) {
89643
+ const now = this.now();
89157
89644
  const ordered = [...clips].sort((a, b) => b.startedAtMs - a.startedAtMs);
89158
89645
  let kept = 0;
89159
89646
  let bytes = 0;
89160
89647
  for (const clip of ordered) {
89161
- const tooOld = clip.startedAtMs < ageCutoff;
89648
+ const tooOld = clip.startedAtMs < now - this.maxAgeMsFor(clip.deviceId);
89162
89649
  const overCount = kept >= maxCount;
89163
89650
  const overBytes = bytes + clip.bytes > maxBytes;
89164
89651
  if (!tooOld && !overCount && !overBytes) {
@@ -89185,8 +89672,8 @@ var HksvClipStore = class {
89185
89672
  }
89186
89673
  });
89187
89674
  }
89188
- async scanDevice(deviceId) {
89189
- const dir = this.deviceDir(deviceId);
89675
+ async scanDevice(root, deviceId) {
89676
+ const dir = this.deviceDir(root, deviceId);
89190
89677
  let names;
89191
89678
  try {
89192
89679
  names = await (0, node_fs_promises.readdir)(dir);
@@ -89232,7 +89719,19 @@ var HksvClipStore = class {
89232
89719
  } catch {
89233
89720
  return null;
89234
89721
  }
89235
- const parsed = HksvClipRecordSchema.safeParse(JSON.parse(raw));
89722
+ let parsed;
89723
+ try {
89724
+ parsed = HksvClipRecordSchema.safeParse(JSON.parse(raw));
89725
+ } catch (err) {
89726
+ this.log.warn("hksv clip store: a sidecar is not readable JSON — the clip is not listed", {
89727
+ tags: { deviceId },
89728
+ meta: {
89729
+ path,
89730
+ error: err instanceof Error ? err.message : String(err)
89731
+ }
89732
+ });
89733
+ return null;
89734
+ }
89236
89735
  if (parsed.success) return parsed.data;
89237
89736
  this.log.warn("hksv clip store: a sidecar could not be read — the clip is not listed", {
89238
89737
  tags: { deviceId },
@@ -89243,8 +89742,19 @@ var HksvClipStore = class {
89243
89742
  });
89244
89743
  return null;
89245
89744
  }
89745
+ /**
89746
+ * ATOMICALLY: a temp file plus a rename, never a `writeFile` in place.
89747
+ *
89748
+ * The sidecar is the only thing that says a clip exists, and it is read
89749
+ * concurrently — by the pruner, and later by whatever lists these clips. A
89750
+ * plain write is visible half-finished, and a half-finished sidecar reads as
89751
+ * a corrupt clip rather than a clip being written. `rename` within one
89752
+ * directory is atomic, so a reader sees the old record or the new one.
89753
+ */
89246
89754
  async writeRecord(path, record) {
89247
- await (0, node_fs_promises.writeFile)(path, JSON.stringify(record, null, 2), "utf8");
89755
+ const staging = `${path}.tmp`;
89756
+ await (0, node_fs_promises.writeFile)(staging, JSON.stringify(record, null, 2), "utf8");
89757
+ await (0, node_fs_promises.rename)(staging, path);
89248
89758
  }
89249
89759
  };
89250
89760
  function stemOf(fileName) {
@@ -105209,7 +105719,8 @@ async function buildHksvRecording(input) {
105209
105719
  fps,
105210
105720
  fragmentLengthMs
105211
105721
  });
105212
- const clipStore = bctx.options.hksvClipStore;
105722
+ const keepClips = bctx.options.hapDeviceSettings.keepHomekitClips !== false;
105723
+ const clipStore = keepClips ? bctx.options.hksvClipStore : null;
105213
105724
  const openClipTee = clipStore === null ? void 0 : createClipTeeFactory({
105214
105725
  logger: log,
105215
105726
  deviceId: numericDeviceId,
@@ -105245,7 +105756,8 @@ async function buildHksvRecording(input) {
105245
105756
  advertisedFps: advertisedResolution?.[2] ?? null,
105246
105757
  fragmentLengthMs,
105247
105758
  sourceGopMs: gopMs ?? "unknown",
105248
- clipsKept: openClipTee !== void 0
105759
+ clipsKept: openClipTee !== void 0,
105760
+ clipsSwitchedOn: keepClips
105249
105761
  } });
105250
105762
  return {
105251
105763
  options,
@@ -106529,6 +107041,39 @@ function resolveHksvRecording(settings) {
106529
107041
  * negotiation is built we say `streaming-enabled: false`, so declining is the
106530
107042
  * only thing a controller can do with it.
106531
107043
  */
107044
+ /**
107045
+ * Keep a copy of what we send HomeKit, for this camera.
107046
+ *
107047
+ * ABSENT MEANS ON, for the same reason as `resolveHksvRecording` and one
107048
+ * stronger: HKSV has no read-back, so every recording made while this said
107049
+ * "off by accident" is gone permanently. Only an explicit `false` turns it off.
107050
+ */
107051
+ function resolveKeepHomekitClips(settings) {
107052
+ return settings?.keepClips !== false;
107053
+ }
107054
+ /** What ships, and what an unset global resolves to. */
107055
+ var DEFAULT_CLIP_RETENTION_DAYS = 14;
107056
+ /** The rails on the retention itself. Zero would silently disable the tee. */
107057
+ var MIN_CLIP_RETENTION_DAYS = 1;
107058
+ var MAX_CLIP_RETENTION_DAYS = 365;
107059
+ /**
107060
+ * This camera's retention in days: per-device override → global default →
107061
+ * {@link DEFAULT_CLIP_RETENTION_DAYS}.
107062
+ *
107063
+ * The cascade rule the repo already pays for (`stationary-settings.ts`):
107064
+ * **reset == unset**. A cleared override and a key that was never written must
107065
+ * resolve to the same number, or the Reset button is a lie. A value outside the
107066
+ * rails is not stored as a lie either — it falls back rather than turning the
107067
+ * tee into a no-op (0 days) or an unbounded archive.
107068
+ */
107069
+ function resolveClipRetentionDays(settings, globalDefaultDays) {
107070
+ const fleet = clampRetentionDays(globalDefaultDays) ?? 14;
107071
+ return clampRetentionDays(settings?.clipRetentionDays) ?? fleet;
107072
+ }
107073
+ function clampRetentionDays(value) {
107074
+ if (typeof value !== "number" || !Number.isFinite(value) || value < MIN_CLIP_RETENTION_DAYS) return null;
107075
+ return Math.min(Math.floor(value), MAX_CLIP_RETENTION_DAYS);
107076
+ }
106532
107077
  function resolveMultiTierService(settings) {
106533
107078
  return settings?.multiTierService === true;
106534
107079
  }
@@ -106556,6 +107101,7 @@ var DEFAULT_CONFIG = {
106556
107101
  fixedPin: "",
106557
107102
  interfaceName: "",
106558
107103
  ptzPulseMs: 400,
107104
+ clipRetentionDays: 14,
106559
107105
  identity: {
106560
107106
  username: "",
106561
107107
  pincode: "",
@@ -106628,6 +107174,8 @@ var ExportHapAddon = class extends BaseAddon {
106628
107174
  * failure to exist is one line at boot and not a surprise mid-recording.
106629
107175
  */
106630
107176
  hksvClipStore = null;
107177
+ /** Resolves the declared `homekitClips` location; the doorbell invalidates it. */
107178
+ hksvClipLocation = null;
106631
107179
  /**
106632
107180
  * THE bridge. Lazily created and published the first time a non-camera
106633
107181
  * accessory needs it; never torn down while the addon runs, because an
@@ -106654,9 +107202,14 @@ var ExportHapAddon = class extends BaseAddon {
106654
107202
  this.lastError = errMsg(err);
106655
107203
  this.ctx.logger.error("export-hap: HAP storage init failed", { meta: { error: this.lastError } });
106656
107204
  }
107205
+ this.hksvClipLocation = new HksvClipLocation({
107206
+ logger: this.ctx.logger.child("hksv-clips"),
107207
+ ports: createClipLocationPorts(this.ctx.api)
107208
+ });
106657
107209
  this.hksvClipStore = new HksvClipStore({
106658
107210
  logger: this.ctx.logger.child("hksv-clips"),
106659
- rootDir: node_path.join(this.ctx.dataDir, CLIP_DIR_NAME),
107211
+ resolveRoot: () => this.hksvClipLocation?.root() ?? Promise.resolve(null),
107212
+ maxAgeMsFor: (deviceId) => resolveClipRetentionDays(this.findEntry(deviceId)?.settings, this.config.clipRetentionDays) * 24 * 60 * 60 * 1e3,
106660
107213
  mintThumbnail: createClipThumbnailMinter({ runner: createFfmpegClipThumbnailRunner({
106661
107214
  ffmpegBinaryPath: "ffmpeg",
106662
107215
  spawnFn: node_child_process.spawn
@@ -106688,6 +107241,7 @@ var ExportHapAddon = class extends BaseAddon {
106688
107241
  });
106689
107242
  this.subscribeDeviceProvisionedForReconcile();
106690
107243
  this.subscribeDeviceReadyForReconcile();
107244
+ this.subscribeStorageLocationsForClips();
106691
107245
  this.disposeSharedRebuildTimers();
106692
107246
  return [{
106693
107247
  capability: deviceExportCapability,
@@ -106858,6 +107412,7 @@ var ExportHapAddon = class extends BaseAddon {
106858
107412
  hapDeviceSettings: {
106859
107413
  streamPreference: entrySettings.streamPreference ?? "auto",
106860
107414
  hksvRecording: resolveHksvRecording(entrySettings),
107415
+ keepHomekitClips: resolveKeepHomekitClips(entrySettings),
106861
107416
  multiTierService: resolveMultiTierService(entrySettings),
106862
107417
  allowTranscode: entrySettings.allowTranscode !== false
106863
107418
  }
@@ -106971,6 +107526,21 @@ var ExportHapAddon = class extends BaseAddon {
106971
107526
  * whether the reconcile actually rebuilds anything. Shared by the two
106972
107527
  * triggers below.
106973
107528
  */
107529
+ /**
107530
+ * The clips location moved. A DOORBELL, not a payload: the event names which
107531
+ * row changed and the re-read is the authority (`StorageLocationsChangedPayload`
107532
+ * says so itself). Dropping the mirror is all this does — the next clip
107533
+ * resolves it again, and a missed event self-heals on the revalidation
107534
+ * window, so nothing here gates on a fallible read (D49).
107535
+ */
107536
+ subscribeStorageLocationsForClips() {
107537
+ const unsubscribe = this.ctx.eventBus.subscribe({ category: EventCategory.StorageLocationsChanged }, () => {
107538
+ this.hksvClipLocation?.invalidate();
107539
+ });
107540
+ this.ctx.addDisposer(async () => {
107541
+ unsubscribe();
107542
+ });
107543
+ }
106974
107544
  subscribeDeviceLifecycleForReconcile(category) {
106975
107545
  const unsubscribe = this.ctx.eventBus.subscribe({ category }, (event) => {
106976
107546
  const data = event.data;
@@ -107169,6 +107739,14 @@ var ExportHapAddon = class extends BaseAddon {
107169
107739
  requiresRestart: true,
107170
107740
  placement: { tab: "advanced" }
107171
107741
  }),
107742
+ this.field({
107743
+ type: "number",
107744
+ key: "clipRetentionDays",
107745
+ label: "Keep HomeKit clips for (days)",
107746
+ description: "CamStack keeps its own copy of every clip it sends to HomeKit Secure Video — HomeKit itself never gives one back. This is the fleet default; each camera can override it, and each can switch the copies off entirely. Clips are also capped at 200 per camera, 512 MB per camera and 4 GB overall — whichever limit is reached first wins, and the log names which one.",
107747
+ default: DEFAULT_CONFIG.clipRetentionDays,
107748
+ placement: { tab: "advanced" }
107749
+ }),
107172
107750
  this.field({
107173
107751
  type: "number",
107174
107752
  key: "ptzPulseMs",
@@ -107192,6 +107770,8 @@ var ExportHapAddon = class extends BaseAddon {
107192
107770
  const multiTierKey = `hap:${deviceId}:multiTierService`;
107193
107771
  const useBridgeKey = `hap:${deviceId}:useBridge`;
107194
107772
  const allowTranscodeKey = `hap:${deviceId}:allowTranscode`;
107773
+ const keepClipsKey = `hap:${deviceId}:keepClips`;
107774
+ const clipRetentionKey = `hap:${deviceId}:clipRetentionDays`;
107195
107775
  const mapper = this.exposed.get(String(deviceId)) ?? null;
107196
107776
  const paired = mapper ? accessoryPaired(mapper.accessory) : false;
107197
107777
  const bridged = mapper !== null && this.bridgeHost?.holds(mapper.accessory) === true;
@@ -107262,6 +107842,31 @@ var ExportHapAddon = class extends BaseAddon {
107262
107842
  },
107263
107843
  immediate: true
107264
107844
  },
107845
+ {
107846
+ type: "boolean",
107847
+ key: keepClipsKey,
107848
+ label: "Keep CamStack copies of HomeKit clips",
107849
+ description: "Save a copy of every clip sent to HomeKit Secure Video, on the “HomeKit Clips” storage location. HomeKit never gives a clip back, so a recording made with this off cannot be recovered later. Off saves the disk and one thumbnail decode per clip; the HomeKit recording itself is unchanged either way.",
107850
+ style: "switch",
107851
+ value: resolveKeepHomekitClips(settings),
107852
+ showWhen: {
107853
+ field: hksvKey,
107854
+ equals: true
107855
+ },
107856
+ immediate: true
107857
+ },
107858
+ {
107859
+ type: "number",
107860
+ key: clipRetentionKey,
107861
+ label: "Keep this camera’s clips for (days)",
107862
+ description: `How long this camera’s kept clips survive before they are pruned, oldest first. Leave at ${this.config.clipRetentionDays} to follow the fleet default. The per-camera caps (200 clips, 512 MB) and the 4 GB overall cap still apply — whichever is reached first wins, and the prune log names it.`,
107863
+ value: resolveClipRetentionDays(settings, this.config.clipRetentionDays),
107864
+ showWhen: {
107865
+ field: keepClipsKey,
107866
+ equals: true
107867
+ },
107868
+ immediate: true
107869
+ },
107265
107870
  {
107266
107871
  type: "boolean",
107267
107872
  key: allowTranscodeKey,
@@ -107349,6 +107954,8 @@ var ExportHapAddon = class extends BaseAddon {
107349
107954
  const useBridgeKey = `hap:${deviceId}:useBridge`;
107350
107955
  const allowTranscodeKey = `hap:${deviceId}:allowTranscode`;
107351
107956
  const multiTierKey = `hap:${deviceId}:multiTierService`;
107957
+ const keepClipsKey = `hap:${deviceId}:keepClips`;
107958
+ const clipRetentionKey = `hap:${deviceId}:clipRetentionDays`;
107352
107959
  const enabledValue = enabledKey in patch ? Boolean(patch[enabledKey]) : wasEnabled;
107353
107960
  const streamPreferenceRaw = streamPreferenceKey in patch ? patch[streamPreferenceKey] : current?.settings?.streamPreference;
107354
107961
  const streamPreference = typeof streamPreferenceRaw === "string" && streamPreferenceRaw.trim().length > 0 ? streamPreferenceRaw : "auto";
@@ -107356,13 +107963,20 @@ var ExportHapAddon = class extends BaseAddon {
107356
107963
  const useBridge = useBridgeKey in patch ? Boolean(patch[useBridgeKey]) : current?.settings?.useBridge !== false;
107357
107964
  const allowTranscode = allowTranscodeKey in patch ? Boolean(patch[allowTranscodeKey]) : current?.settings?.allowTranscode !== false;
107358
107965
  const multiTierService = multiTierKey in patch ? Boolean(patch[multiTierKey]) : resolveMultiTierService(current?.settings);
107966
+ const keepClips = keepClipsKey in patch ? Boolean(patch[keepClipsKey]) : resolveKeepHomekitClips(current?.settings);
107967
+ const clipRetentionDays = clipRetentionKey in patch ? resolveClipRetentionDays({
107968
+ streamPreference,
107969
+ clipRetentionDays: Number(patch[clipRetentionKey])
107970
+ }, this.config.clipRetentionDays) : resolveClipRetentionDays(current?.settings, this.config.clipRetentionDays);
107359
107971
  const nextSettings = {
107360
107972
  ...current?.settings ?? DEFAULT_DEVICE_SETTINGS,
107361
107973
  streamPreference,
107362
107974
  hksvRecording,
107363
107975
  useBridge,
107364
107976
  allowTranscode,
107365
- multiTierService
107977
+ multiTierService,
107978
+ keepClips,
107979
+ clipRetentionDays
107366
107980
  };
107367
107981
  if (!enabledValue) {
107368
107982
  if (wasEnabled) await this.unexposeDevice(deviceIdStr);
@@ -107375,6 +107989,7 @@ var ExportHapAddon = class extends BaseAddon {
107375
107989
  }
107376
107990
  const currentPref = current?.settings?.streamPreference ?? "auto";
107377
107991
  const currentHksv = resolveHksvRecording(current?.settings);
107992
+ const currentKeepClips = resolveKeepHomekitClips(current?.settings);
107378
107993
  const mapperKind = current?.mapperKind ?? "generic";
107379
107994
  const wasBridged = shouldBridge({
107380
107995
  mapperKind,
@@ -107385,7 +108000,7 @@ var ExportHapAddon = class extends BaseAddon {
107385
108000
  useBridge
107386
108001
  });
107387
108002
  await this.updateEntrySettings(deviceIdStr, nextSettings);
107388
- if (currentPref !== streamPreference || currentHksv !== hksvRecording || wasBridged !== willBridge) {
108003
+ if (currentPref !== streamPreference || currentHksv !== hksvRecording || currentKeepClips !== keepClips || wasBridged !== willBridge) {
107389
108004
  log.info("export-hap: per-camera export settings changed — refreshing accessory", { meta: {
107390
108005
  streamPreference: {
107391
108006
  from: currentPref,
@@ -107395,6 +108010,10 @@ var ExportHapAddon = class extends BaseAddon {
107395
108010
  from: currentHksv,
107396
108011
  to: hksvRecording
107397
108012
  },
108013
+ keepClips: {
108014
+ from: currentKeepClips,
108015
+ to: keepClips
108016
+ },
107398
108017
  bridged: {
107399
108018
  from: wasBridged,
107400
108019
  to: willBridge
@@ -107425,12 +108044,15 @@ function errMsg(err) {
107425
108044
  return err instanceof Error ? err.message : String(err);
107426
108045
  }
107427
108046
  //#endregion
108047
+ exports.DEFAULT_CLIP_RETENTION_DAYS = DEFAULT_CLIP_RETENTION_DAYS;
107428
108048
  exports.ExportHapAddon = ExportHapAddon;
107429
108049
  exports.default = ExportHapAddon;
107430
108050
  exports.__toCommonJS = __toCommonJS;
107431
108051
  exports.deriveUsername = deriveUsername;
107432
108052
  exports.initHapStorage = initHapStorage;
107433
108053
  exports.publishStandalone = publishStandalone;
108054
+ exports.resolveClipRetentionDays = resolveClipRetentionDays;
107434
108055
  exports.resolveHksvRecording = resolveHksvRecording;
108056
+ exports.resolveKeepHomekitClips = resolveKeepHomekitClips;
107435
108057
  exports.resolveMultiTierService = resolveMultiTierService;
107436
108058
  exports.unpublishAccessory = unpublishAccessory;