@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.
- package/dist/hap-export.addon.js +679 -57
- package/dist/hap-export.addon.mjs +678 -59
- package/package.json +11 -1
package/dist/hap-export.addon.js
CHANGED
|
@@ -5409,7 +5409,7 @@ var ZodIssueCode = {
|
|
|
5409
5409
|
var ZodFirstPartyTypeKind;
|
|
5410
5410
|
ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
|
|
5411
5411
|
//#endregion
|
|
5412
|
-
//#region ../types/dist/sleep-
|
|
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
|
|
9679
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
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: "
|
|
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: "
|
|
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**,
|
|
88888
|
-
*
|
|
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
|
-
* -
|
|
88899
|
-
*
|
|
88900
|
-
*
|
|
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: `<
|
|
88903
|
-
* clip, three extensions, so a pruner deleting a clip never has to
|
|
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
|
-
*
|
|
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.
|
|
88912
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
89034
|
-
|
|
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
|
|
89144
|
-
|
|
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)(
|
|
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,
|
|
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
|
-
|
|
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 <
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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;
|