@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
|
@@ -5,7 +5,7 @@ import * as path from "node:path";
|
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { createWriteStream, readFileSync } from "node:fs";
|
|
7
7
|
import * as fs from "node:fs/promises";
|
|
8
|
-
import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
8
|
+
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
9
9
|
import { once } from "node:events";
|
|
10
10
|
import { createSocket } from "node:dgram";
|
|
11
11
|
import { networkInterfaces } from "node:os";
|
|
@@ -5399,7 +5399,7 @@ var ZodIssueCode = {
|
|
|
5399
5399
|
var ZodFirstPartyTypeKind;
|
|
5400
5400
|
ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
|
|
5401
5401
|
//#endregion
|
|
5402
|
-
//#region ../types/dist/sleep-
|
|
5402
|
+
//#region ../types/dist/sleep-CopaBJss.mjs
|
|
5403
5403
|
/**
|
|
5404
5404
|
* The audio chunk plane's byte format, and the ONE expansion from a coded
|
|
5405
5405
|
* window to float samples (D455).
|
|
@@ -5599,6 +5599,12 @@ Object.fromEntries([
|
|
|
5599
5599
|
icon: "circle-dot",
|
|
5600
5600
|
order: 40
|
|
5601
5601
|
},
|
|
5602
|
+
{
|
|
5603
|
+
id: "clips",
|
|
5604
|
+
label: "Clips",
|
|
5605
|
+
icon: "clapperboard",
|
|
5606
|
+
order: 41
|
|
5607
|
+
},
|
|
5602
5608
|
{
|
|
5603
5609
|
id: "engine",
|
|
5604
5610
|
label: "Inference Engine",
|
|
@@ -9665,20 +9671,86 @@ var StorageCleanupJobSchema = object({
|
|
|
9665
9671
|
});
|
|
9666
9672
|
var StorageCleanupStatusInputSchema = object({ jobId: string().optional() });
|
|
9667
9673
|
/**
|
|
9668
|
-
* The
|
|
9669
|
-
*
|
|
9674
|
+
* The storage-location STATE MODEL (D385) — one typed state, one policy module.
|
|
9675
|
+
*
|
|
9676
|
+
* A location's state used to be split across two authorities: the typed
|
|
9677
|
+
* `enabled` field (THE write switch since D383) and an untyped `config.readOnly`
|
|
9678
|
+
* key. They did not mean the same thing — `enabled: false` was still evicted
|
|
9679
|
+
* under disk pressure while `config.readOnly` was deliberately excluded — and
|
|
9680
|
+
* neither name said which. Every consumer re-derived the difference, and the
|
|
9681
|
+
* three questions that actually matter were answered in six places.
|
|
9682
|
+
*
|
|
9683
|
+
* This module is the ONLY place in the repo allowed to interpret the state. It
|
|
9684
|
+
* answers three questions and nothing else:
|
|
9685
|
+
*
|
|
9686
|
+
* - may this location be WRITTEN to? {@link modeMayWrite}
|
|
9687
|
+
* - may this location be READ? {@link modeMayRead}
|
|
9688
|
+
* - what is its eviction policy? {@link evictionPolicyForMode}
|
|
9689
|
+
*
|
|
9690
|
+
* | mode | write | read | eviction |
|
|
9691
|
+
* | ---------- | ----- | ---- | ------------------------------ |
|
|
9692
|
+
* | `active` | yes | yes | `normal` (pressure + usage cap) |
|
|
9693
|
+
* | `readonly` | no | yes | `never` |
|
|
9694
|
+
* | `drain` | no | yes | `drain` (paced, until empty) |
|
|
9695
|
+
* | `disabled` | no | no | `never` |
|
|
9696
|
+
*
|
|
9697
|
+
* `scripts/check-storage-location-mode-single-owner.ts` fails the build when
|
|
9698
|
+
* anything outside this module reads `config['readOnly']` or compares `enabled`
|
|
9699
|
+
* directly. A rule nothing checks has already been broken somewhere.
|
|
9670
9700
|
*/
|
|
9671
|
-
var
|
|
9701
|
+
var STORAGE_LOCATION_MODES = [
|
|
9672
9702
|
"active",
|
|
9673
9703
|
"readonly",
|
|
9674
9704
|
"drain",
|
|
9675
9705
|
"disabled"
|
|
9676
|
-
]
|
|
9706
|
+
];
|
|
9707
|
+
/**
|
|
9708
|
+
* The one typed state of a storage location. Authoritative Zod schema — the TS
|
|
9709
|
+
* alias below is `z.infer<>` of it, never a second spelling.
|
|
9710
|
+
*/
|
|
9711
|
+
var StorageLocationModeSchema = _enum(STORAGE_LOCATION_MODES);
|
|
9677
9712
|
_enum([
|
|
9678
9713
|
"normal",
|
|
9679
9714
|
"never",
|
|
9680
9715
|
"drain"
|
|
9681
9716
|
]);
|
|
9717
|
+
/** Is this mode a write target? Only `active` is. */
|
|
9718
|
+
function modeMayWrite(mode) {
|
|
9719
|
+
return mode === "active";
|
|
9720
|
+
}
|
|
9721
|
+
/**
|
|
9722
|
+
* The mode a LEGACY row implies, or `null` when it implies nothing — the row is
|
|
9723
|
+
* already stamped, or it carried neither flag.
|
|
9724
|
+
*
|
|
9725
|
+
* Both legacy flags fold to `readonly`, which is the CONSERVATIVE direction: a
|
|
9726
|
+
* state change must never start deleting footage on its own, and it must never
|
|
9727
|
+
* make footage that was still being served disappear. `enabled: false` used to
|
|
9728
|
+
* leave the location evictable under pressure; folding it to `readonly` stops
|
|
9729
|
+
* that, which is a strictly safer answer than the one it replaces.
|
|
9730
|
+
*/
|
|
9731
|
+
function legacyModeOf(location) {
|
|
9732
|
+
if (location.mode !== void 0) return null;
|
|
9733
|
+
if (location.config["readOnly"] === true) return "readonly";
|
|
9734
|
+
if (location.enabled === false) return "readonly";
|
|
9735
|
+
return null;
|
|
9736
|
+
}
|
|
9737
|
+
/**
|
|
9738
|
+
* The state of a location, stamped or folded. THE one interpretation: a row
|
|
9739
|
+
* that predates D385 is never ambiguous, and a stamped `mode` always wins over
|
|
9740
|
+
* whatever the legacy pair still says.
|
|
9741
|
+
*/
|
|
9742
|
+
function resolveLocationMode(location) {
|
|
9743
|
+
return (isStorageLocationMode(location.mode) ? location.mode : void 0) ?? legacyModeOf(location) ?? "active";
|
|
9744
|
+
}
|
|
9745
|
+
/** Is this one of the four states? The stamped value crosses a wire, and a
|
|
9746
|
+
* value nobody defined must not be rendered as if it were a state. */
|
|
9747
|
+
function isStorageLocationMode(value) {
|
|
9748
|
+
return STORAGE_LOCATION_MODES.some((mode) => mode === value);
|
|
9749
|
+
}
|
|
9750
|
+
/** May this location be written to? */
|
|
9751
|
+
function mayWriteToLocation(location) {
|
|
9752
|
+
return modeMayWrite(resolveLocationMode(location));
|
|
9753
|
+
}
|
|
9682
9754
|
/**
|
|
9683
9755
|
* `StorageLocationType` — an addon-declared id that identifies the *kind* of
|
|
9684
9756
|
* storage a location serves. Defined here (not in `capabilities/storage.cap.ts`)
|
|
@@ -14965,6 +15037,8 @@ method(object({
|
|
|
14965
15037
|
deviceId: number(),
|
|
14966
15038
|
capName: string(),
|
|
14967
15039
|
wrapperAddonId: string(),
|
|
15040
|
+
/** The `ClipSource.source` id to toggle. Absent = all of the addon's. */
|
|
15041
|
+
sourceId: string().optional(),
|
|
14968
15042
|
active: boolean()
|
|
14969
15043
|
}), _void(), {
|
|
14970
15044
|
kind: "mutation",
|
|
@@ -24943,29 +25017,257 @@ var ClipSchema = object({
|
|
|
24943
25017
|
holes: array(object({
|
|
24944
25018
|
startMs: number(),
|
|
24945
25019
|
endMs: number()
|
|
24946
|
-
})).optional()
|
|
25020
|
+
})).optional(),
|
|
25021
|
+
/**
|
|
25022
|
+
* A URL that MAY produce this clip's still, asked only for rows actually on
|
|
25023
|
+
* screen. The opposite of {@link ClipSchema.thumbnail}: that one is VOUCHED
|
|
25024
|
+
* (a JPEG is already on disk), this one is an offer. The route answers 200
|
|
25025
|
+
* with the image, or 204 with `x-camstack-reason` when it could not mint one
|
|
25026
|
+
* — a surface latches that refusal to the instant rather than retrying.
|
|
25027
|
+
*
|
|
25028
|
+
* Never both: a clip with a vouched `thumbnail` needs no mint.
|
|
25029
|
+
*/
|
|
25030
|
+
thumbnailMint: string().optional(),
|
|
25031
|
+
/**
|
|
25032
|
+
* Why no still will be produced for this clip RIGHT NOW — set when the
|
|
25033
|
+
* provider already knows, so the surface draws the glyph and the reason
|
|
25034
|
+
* instead of firing a mint that cannot succeed.
|
|
25035
|
+
*
|
|
25036
|
+
* `sleeping` — a standalone battery camera; a read would be a wake (D549 4).
|
|
25037
|
+
* `camera-refused` — the camera answered the CoverPreview with a refusal.
|
|
25038
|
+
* `no-keyframe` — the window holds no decodable I-frame (an event that sits
|
|
25039
|
+
* inside no file is the measured case).
|
|
25040
|
+
* `unsupported` — this source cannot mint stills at all.
|
|
25041
|
+
*/
|
|
25042
|
+
thumbnailUnavailable: object({ reason: _enum([
|
|
25043
|
+
"sleeping",
|
|
25044
|
+
"camera-refused",
|
|
25045
|
+
"no-keyframe",
|
|
25046
|
+
"unsupported"
|
|
25047
|
+
]) }).optional(),
|
|
25048
|
+
/**
|
|
25049
|
+
* When the catalog this row came from was last CONFIRMED against the device.
|
|
25050
|
+
* Absent means "this row was read live". A persisted catalog served while a
|
|
25051
|
+
* camera sleeps carries the age it really has — a cached list is never drawn
|
|
25052
|
+
* as current (D549 13).
|
|
25053
|
+
*/
|
|
25054
|
+
catalogAsOf: number().optional(),
|
|
25055
|
+
/**
|
|
25056
|
+
* The camera's OWN type strings for this clip, all of them, unmapped
|
|
25057
|
+
* (`md`, `people`, `dog_cat`, `sched`, …). Kept beside {@link labels}
|
|
25058
|
+
* because a firmware inventing a type must not vanish: the mapping into our
|
|
25059
|
+
* filter vocabulary is lossy on purpose and this is the lossless copy
|
|
25060
|
+
* (D549 20).
|
|
25061
|
+
*/
|
|
25062
|
+
nativeTypes: array(string()).optional(),
|
|
25063
|
+
/**
|
|
25064
|
+
* What the subject DID — `crossline`, `intrude`, `loitering`. A behaviour
|
|
25065
|
+
* travels BESIDE a class, never instead of one, and the class filter ignores
|
|
25066
|
+
* it: "a person crossed a line" is still a person (D549 20).
|
|
25067
|
+
*/
|
|
25068
|
+
behaviours: array(string()).optional(),
|
|
25069
|
+
/**
|
|
25070
|
+
* The same recording as two files — the sub twin (what the row and its
|
|
25071
|
+
* thumbnail are) and its main twin, matched at listing time so a quality
|
|
25072
|
+
* change never re-searches the camera (D549 15). `getClipPlayback`'s
|
|
25073
|
+
* `profile` picks between them: `low | mid` → sub, `high` → main.
|
|
25074
|
+
*/
|
|
25075
|
+
streams: object({
|
|
25076
|
+
sub: object({
|
|
25077
|
+
id: string(),
|
|
25078
|
+
bytes: number().optional()
|
|
25079
|
+
}).optional(),
|
|
25080
|
+
main: object({
|
|
25081
|
+
id: string(),
|
|
25082
|
+
bytes: number().optional()
|
|
25083
|
+
}).optional()
|
|
25084
|
+
}).optional(),
|
|
25085
|
+
/** The camera says it holds a sub-stream copy of this recording. */
|
|
25086
|
+
supportSub: boolean().optional(),
|
|
25087
|
+
/**
|
|
25088
|
+
* Whether this row has bytes behind it. ABSENT means yes — every clip that
|
|
25089
|
+
* IS a file is playable, and only a source that lists EVENTS can produce a
|
|
25090
|
+
* row with nothing to play (the measured case: 1 of 29 hub events on 3628
|
|
25091
|
+
* fell inside no file). Such a row is shown, never dropped and never offered
|
|
25092
|
+
* as playable-then-failing (D549 19).
|
|
25093
|
+
*/
|
|
25094
|
+
playable: boolean().optional(),
|
|
25095
|
+
/** Why {@link playable} is false, verbatim (`no-file-for-window`). */
|
|
25096
|
+
unplayableReason: string().optional()
|
|
24947
25097
|
});
|
|
24948
25098
|
var ClipPlaybackSchema = object({
|
|
24949
|
-
/**
|
|
25099
|
+
/**
|
|
25100
|
+
* A media URL through the hub data-plane. {@link ClipPlaybackSchema.format}
|
|
25101
|
+
* says what kind — an HLS master playlist for a recording-derived clip, a
|
|
25102
|
+
* progressive MP4 for a native file the vendor addon muxed and serves with
|
|
25103
|
+
* `Range`. The URL is NOT a credential: both planes are registered
|
|
25104
|
+
* `access: 'authenticated'` and the hub's `/addon/<id>/<prefix>` proxy takes
|
|
25105
|
+
* the session cookie, so nothing is appended to it (D549 22).
|
|
25106
|
+
*/
|
|
24950
25107
|
playbackUrl: string(),
|
|
25108
|
+
/**
|
|
25109
|
+
* How to play {@link playbackUrl}. Absent means `hls` — the shape every
|
|
25110
|
+
* existing consumer already assumes. A player that branches on this is the
|
|
25111
|
+
* one change a native clip needs; a player that ignores it will hand an MP4
|
|
25112
|
+
* to hls.js and fail parsing it as a manifest.
|
|
25113
|
+
*/
|
|
25114
|
+
format: _enum(["hls", "mp4"]).optional(),
|
|
25115
|
+
/**
|
|
25116
|
+
* Which twin was actually served. A clip that holds only one stream answers
|
|
25117
|
+
* with the one it has, and the surface SAYS so — a missing main twin is
|
|
25118
|
+
* never served silently as if it were the asked-for quality (D549 15).
|
|
25119
|
+
*/
|
|
25120
|
+
served: CamProfileSchema.optional(),
|
|
24951
25121
|
/** Optional LAN/remote alternates for the same clip. */
|
|
24952
25122
|
playbackEndpoints: array(string()).optional(),
|
|
24953
25123
|
token: string().optional()
|
|
24954
25124
|
});
|
|
25125
|
+
/**
|
|
25126
|
+
* Why a source cannot answer right now — per SOURCE, never fleet-wide.
|
|
25127
|
+
*
|
|
25128
|
+
* `ok` is the only state whose clip list may be read as complete. The other
|
|
25129
|
+
* three exist because an empty list from a sleeping camera reads as "this
|
|
25130
|
+
* camera has no recordings", which is the defect this whole line of work is
|
|
25131
|
+
* about: a battery camera nobody woke (`sleeping`), a camera that could not be
|
|
25132
|
+
* reached at all (`unreachable`, also what a provider that THREW reports), a
|
|
25133
|
+
* camera whose SD card is not mounted (`no-storage`, measured on 640 —
|
|
25134
|
+
* `HddInfo mount 0`, honestly nothing to list rather than "no clips"), and a
|
|
25135
|
+
* camera whose OWN index disagrees with its OWN calendar (`index-empty`,
|
|
25136
|
+
* measured on 618: the calendar marks 3–20 September, days 6–20 list zero
|
|
25137
|
+
* files on both streams and both filters, with 177 GB free). That last one is
|
|
25138
|
+
* a defect ON THE CAMERA, and the only honest thing a provider can do is say
|
|
25139
|
+
* which days it asked for and got nothing — "no clips" would be a lie about a
|
|
25140
|
+
* card that is full of them.
|
|
25141
|
+
*/
|
|
25142
|
+
var ClipSourceAvailabilitySchema = object({
|
|
25143
|
+
state: _enum([
|
|
25144
|
+
"ok",
|
|
25145
|
+
"sleeping",
|
|
25146
|
+
"unreachable",
|
|
25147
|
+
"no-storage",
|
|
25148
|
+
"index-empty"
|
|
25149
|
+
]),
|
|
25150
|
+
/** Free text, shown verbatim. Names the camera's own refusal when there is one. */
|
|
25151
|
+
reason: string().optional(),
|
|
25152
|
+
/** When this source's catalog was last CONFIRMED. A cached list is never
|
|
25153
|
+
* drawn as current: the surface shows the age whenever it is older than the
|
|
25154
|
+
* refresh interval. */
|
|
25155
|
+
catalogAsOf: number().optional()
|
|
25156
|
+
});
|
|
25157
|
+
/**
|
|
25158
|
+
* One SOURCE of clips for a camera — a row of the picker, and the namespace
|
|
25159
|
+
* every clip id from it is prefixed with (`analytics`,
|
|
25160
|
+
* `native:reolink:onboard`, `hksv`, …).
|
|
25161
|
+
*
|
|
25162
|
+
* A provider lists the sources IT serves for that device, and answers for each
|
|
25163
|
+
* of them whether it can answer at all. A provider with nothing to offer on a
|
|
25164
|
+
* camera returns `[]` — it is not that camera's business.
|
|
25165
|
+
*/
|
|
25166
|
+
var ClipSourceSchema = object({
|
|
25167
|
+
/** The value this source stamps on {@link ClipSchema.source}, and the prefix
|
|
25168
|
+
* of every clip id it mints. `getClipPlayback` routes on it. */
|
|
25169
|
+
source: string(),
|
|
25170
|
+
/** Operator-facing name of the source ("CamStack recordings", "SD card"). */
|
|
25171
|
+
label: string(),
|
|
25172
|
+
/**
|
|
25173
|
+
* The addon that SERVES this row.
|
|
25174
|
+
*
|
|
25175
|
+
* A surface cannot otherwise resolve a source to who answers it, and the
|
|
25176
|
+
* alternative — a `native:reolink:* → provider-reolink` table inside the
|
|
25177
|
+
* widget — is a second authority on provider identity living in the one
|
|
25178
|
+
* package with no business knowing it, wrong the day a third source appears
|
|
25179
|
+
* (D557). One addon may serve SEVERAL sources: `addon-provider-reolink`
|
|
25180
|
+
* answers a hub child with both `native:reolink:onboard` and
|
|
25181
|
+
* `native:reolink:hub`, which is why the per-device switch is keyed by
|
|
25182
|
+
* SOURCE and not by this (D555).
|
|
25183
|
+
*
|
|
25184
|
+
* Optional for version skew only. The collection dispatcher stamps it from
|
|
25185
|
+
* the registry, so a row that travelled through the fan-out carries the
|
|
25186
|
+
* authoritative id whatever the provider filled in.
|
|
25187
|
+
*/
|
|
25188
|
+
addonId: string().optional(),
|
|
25189
|
+
/**
|
|
25190
|
+
* Which API this source resolved to FOR THIS CAMERA, when it has a choice.
|
|
25191
|
+
*
|
|
25192
|
+
* A source may cover one store through more than one surface — the Reolink
|
|
25193
|
+
* provider reads a hub child through the parent's event log and a standalone
|
|
25194
|
+
* through its own file list, because that is what each camera answers. The
|
|
25195
|
+
* CHOICE is the provider's, made from what the camera is, and is never a row
|
|
25196
|
+
* the operator has to understand; but it is REPORTED, because a source that
|
|
25197
|
+
* silently reads a different API on two cameras and then behaves differently
|
|
25198
|
+
* is the thing nobody can debug later. Absent when the source has only one
|
|
25199
|
+
* way to read its store.
|
|
25200
|
+
*/
|
|
25201
|
+
via: string().optional(),
|
|
25202
|
+
availability: ClipSourceAvailabilitySchema,
|
|
25203
|
+
/**
|
|
25204
|
+
* The operator switched this source OFF for this camera.
|
|
25205
|
+
*
|
|
25206
|
+
* Deliberately NOT a member of {@link ClipSourceAvailabilitySchema}'s
|
|
25207
|
+
* vocabulary. That enum models what the source CAN do — a sleeping camera, an
|
|
25208
|
+
* unmounted card, an index that disagrees with its own calendar — and a
|
|
25209
|
+
* switched-off source could answer perfectly well; the operator decided it
|
|
25210
|
+
* should not. Folding the choice in is how `disabled` and `broken` stop being
|
|
25211
|
+
* distinguishable, which is the D62 rule this repo has already paid for twice:
|
|
25212
|
+
* an off switch is REPORTED off (`CameraStatus.switchedOff`, the same word),
|
|
25213
|
+
* and disabled must never look like broken. It is also what lets every
|
|
25214
|
+
* exhaustive consumer of the availability enum keep compiling.
|
|
25215
|
+
*
|
|
25216
|
+
* A switched-off source contributes NO clips (`listClips` never calls it) and
|
|
25217
|
+
* its row carries no `catalogAsOf`: nothing confirms a catalog it is not
|
|
25218
|
+
* allowed to serve, and a frozen age that can only grow draws a stalling
|
|
25219
|
+
* source rather than an off switch.
|
|
25220
|
+
*
|
|
25221
|
+
* The row survives BECAUSE it is the control the operator switches the source
|
|
25222
|
+
* back on from — D554 decision 4's rule ("a source that cannot answer
|
|
25223
|
+
* produces a ROW, not an absence") applied to the one case D556 carved out of
|
|
25224
|
+
* it, and D557's own kept property ("a source is never hidden, only its
|
|
25225
|
+
* rows"). Absent means on.
|
|
25226
|
+
*
|
|
25227
|
+
* A provider never sets this — like {@link ClipSourceSchema.addonId} it is
|
|
25228
|
+
* stamped by the collection dispatcher, which holds the registry's projection
|
|
25229
|
+
* of the persisted authority (D556) and is the only place that knows it.
|
|
25230
|
+
*/
|
|
25231
|
+
switchedOff: boolean().optional()
|
|
25232
|
+
});
|
|
24955
25233
|
DeviceType.Camera, method(object({
|
|
24956
25234
|
deviceId: number(),
|
|
24957
25235
|
since: number(),
|
|
24958
25236
|
until: number(),
|
|
24959
|
-
limit: number().int().positive().optional()
|
|
25237
|
+
limit: number().int().positive().optional(),
|
|
25238
|
+
/**
|
|
25239
|
+
* View filter over {@link ClipSourceSchema.source} values — the
|
|
25240
|
+
* picker's selection, forwarded so a provider need not list what
|
|
25241
|
+
* nobody is looking at. ABSENT means every source this camera has,
|
|
25242
|
+
* which is the honest default for a surface whose whole point is that
|
|
25243
|
+
* nothing is hidden (D554 3). A provider with one source ignores it.
|
|
25244
|
+
*/
|
|
25245
|
+
sources: array(string()).optional()
|
|
24960
25246
|
}), array(ClipSchema).readonly(), {
|
|
24961
25247
|
kind: "query",
|
|
24962
|
-
auth: "
|
|
25248
|
+
auth: "protected"
|
|
25249
|
+
}), method(object({ deviceId: number() }), array(ClipSourceSchema).readonly(), {
|
|
25250
|
+
kind: "query",
|
|
25251
|
+
auth: "protected"
|
|
24963
25252
|
}), method(object({
|
|
24964
25253
|
deviceId: number(),
|
|
24965
|
-
clipId: string()
|
|
25254
|
+
clipId: string(),
|
|
25255
|
+
/**
|
|
25256
|
+
* Which twin to serve, on the ONE quality scale the system already has
|
|
25257
|
+
* (`CamProfileSchema`). `low | mid` → the sub file, `high` → the main
|
|
25258
|
+
* twin; both ids are already on the row so this never re-searches the
|
|
25259
|
+
* camera. Absent means the provider's own default (the sub file, which
|
|
25260
|
+
* every source is measured to hold).
|
|
25261
|
+
*
|
|
25262
|
+
* `auto` is deliberately NOT accepted here: a stored file has no
|
|
25263
|
+
* broker session, so the adaptive tier cannot be resolved for it. The
|
|
25264
|
+
* viewer resolves `auto` to a profile the same way live does, before
|
|
25265
|
+
* it calls (D549 15).
|
|
25266
|
+
*/
|
|
25267
|
+
profile: CamProfileSchema.optional()
|
|
24966
25268
|
}), ClipPlaybackSchema, {
|
|
24967
25269
|
kind: "query",
|
|
24968
|
-
auth: "
|
|
25270
|
+
auth: "protected"
|
|
24969
25271
|
});
|
|
24970
25272
|
/**
|
|
24971
25273
|
* Optional client-side hints sent at session creation to help the provider
|
|
@@ -38997,6 +39299,12 @@ Object.freeze({
|
|
|
38997
39299
|
addonId: null,
|
|
38998
39300
|
access: "view"
|
|
38999
39301
|
},
|
|
39302
|
+
"videoclips.listSources": {
|
|
39303
|
+
capName: "videoclips",
|
|
39304
|
+
capScope: "device",
|
|
39305
|
+
addonId: null,
|
|
39306
|
+
access: "view"
|
|
39307
|
+
},
|
|
39000
39308
|
"viewerUi.getStaticDir": {
|
|
39001
39309
|
capName: "viewer-ui",
|
|
39002
39310
|
capScope: "system",
|
|
@@ -41005,6 +41313,11 @@ Object.freeze({
|
|
|
41005
41313
|
form: "single",
|
|
41006
41314
|
optional: false
|
|
41007
41315
|
}],
|
|
41316
|
+
"videoclips.listSources": [{
|
|
41317
|
+
name: "deviceId",
|
|
41318
|
+
form: "single",
|
|
41319
|
+
optional: false
|
|
41320
|
+
}],
|
|
41008
41321
|
"waterHeater.setAway": [{
|
|
41009
41322
|
name: "deviceId",
|
|
41010
41323
|
form: "single",
|
|
@@ -88769,6 +89082,124 @@ function firstExposedAccessorySetupUri(exposed, logger) {
|
|
|
88769
89082
|
}
|
|
88770
89083
|
}
|
|
88771
89084
|
//#endregion
|
|
89085
|
+
//#region src/hksv/clip-location.ts
|
|
89086
|
+
/** The declared id. Deployment-wide; see the docblock for why it is new. */
|
|
89087
|
+
var HOMEKIT_CLIPS_LOCATION_TYPE = "homekitClips";
|
|
89088
|
+
/** The class subtree under the resolved root. Never written at the root (D327). */
|
|
89089
|
+
var HOMEKIT_CLIPS_SUBTREE = "homekit-clips";
|
|
89090
|
+
/**
|
|
89091
|
+
* The cap calls, HERE and not in the addon.
|
|
89092
|
+
*
|
|
89093
|
+
* Deliberately co-located with the `mayWriteToLocation` filter below: a file
|
|
89094
|
+
* that enumerates storage locations must visibly decide whether it is choosing
|
|
89095
|
+
* a WRITE target (`scripts/check-storage-write-target-enabled.ts` enforces
|
|
89096
|
+
* exactly that, and it fired when the closure lived in the addon). Splitting
|
|
89097
|
+
* the enumeration from the decision is how the backup fan-out grew a second
|
|
89098
|
+
* enabled flag.
|
|
89099
|
+
*/
|
|
89100
|
+
function createClipLocationPorts(api) {
|
|
89101
|
+
return {
|
|
89102
|
+
listLocations: () => api.storage.listLocations.query({}),
|
|
89103
|
+
resolvePath: (locationId) => api.storage.resolve.query({
|
|
89104
|
+
location: locationId,
|
|
89105
|
+
relativePath: ""
|
|
89106
|
+
})
|
|
89107
|
+
};
|
|
89108
|
+
}
|
|
89109
|
+
var HksvClipLocation = class {
|
|
89110
|
+
input;
|
|
89111
|
+
log;
|
|
89112
|
+
now;
|
|
89113
|
+
revalidateMs;
|
|
89114
|
+
cachedRoot = null;
|
|
89115
|
+
resolvedAt = Number.NEGATIVE_INFINITY;
|
|
89116
|
+
refusal = null;
|
|
89117
|
+
/** What the last warn said, so a per-clip refusal is not a per-clip log line. */
|
|
89118
|
+
announced = null;
|
|
89119
|
+
inFlight = null;
|
|
89120
|
+
constructor(input) {
|
|
89121
|
+
this.input = input;
|
|
89122
|
+
this.log = input.logger;
|
|
89123
|
+
this.now = input.now ?? Date.now;
|
|
89124
|
+
this.revalidateMs = input.revalidateMs ?? 6e4;
|
|
89125
|
+
}
|
|
89126
|
+
/** Why the last resolution produced no root. `null` once one succeeded. */
|
|
89127
|
+
get lastRefusal() {
|
|
89128
|
+
return this.refusal;
|
|
89129
|
+
}
|
|
89130
|
+
/** The doorbell rang (`StorageLocationsChanged`): drop the mirror. */
|
|
89131
|
+
invalidate() {
|
|
89132
|
+
this.resolvedAt = Number.NEGATIVE_INFINITY;
|
|
89133
|
+
}
|
|
89134
|
+
/**
|
|
89135
|
+
* The directory clips are written under, or `null` when there is none right
|
|
89136
|
+
* now. Never throws: the caller's correct move for every refusal is the same
|
|
89137
|
+
* — do not tee this clip, and leave HomeKit alone.
|
|
89138
|
+
*/
|
|
89139
|
+
async root() {
|
|
89140
|
+
if (this.now() - this.resolvedAt < this.revalidateMs) return this.cachedRoot;
|
|
89141
|
+
const inflight = this.inFlight;
|
|
89142
|
+
if (inflight !== null) return inflight;
|
|
89143
|
+
const attempt = this.resolve();
|
|
89144
|
+
this.inFlight = attempt;
|
|
89145
|
+
try {
|
|
89146
|
+
return await attempt;
|
|
89147
|
+
} finally {
|
|
89148
|
+
this.inFlight = null;
|
|
89149
|
+
}
|
|
89150
|
+
}
|
|
89151
|
+
async resolve() {
|
|
89152
|
+
try {
|
|
89153
|
+
const rows = (await this.input.ports.listLocations()).filter((l) => l.type === HOMEKIT_CLIPS_LOCATION_TYPE);
|
|
89154
|
+
if (rows.length === 0) return this.refuse("no-location", {});
|
|
89155
|
+
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) });
|
|
89156
|
+
const writable = rows.find((l) => mayWriteToLocation(l));
|
|
89157
|
+
if (writable === void 0) {
|
|
89158
|
+
const first = rows[0];
|
|
89159
|
+
return this.refuse("not-writable", {
|
|
89160
|
+
locationId: first?.id ?? null,
|
|
89161
|
+
mode: first === void 0 ? null : resolveLocationMode(first)
|
|
89162
|
+
});
|
|
89163
|
+
}
|
|
89164
|
+
const root = `${(await this.input.ports.resolvePath(writable.id)).replace(/\/+$/, "")}/${HOMEKIT_CLIPS_SUBTREE}`;
|
|
89165
|
+
this.cachedRoot = root;
|
|
89166
|
+
this.resolvedAt = this.now();
|
|
89167
|
+
this.refusal = null;
|
|
89168
|
+
if (this.announced !== null) {
|
|
89169
|
+
this.announced = null;
|
|
89170
|
+
this.log.info("hksv clip store: the clips location is writable again", { meta: {
|
|
89171
|
+
locationId: writable.id,
|
|
89172
|
+
root
|
|
89173
|
+
} });
|
|
89174
|
+
}
|
|
89175
|
+
return root;
|
|
89176
|
+
} catch (err) {
|
|
89177
|
+
return this.refuse("unreachable", { error: err instanceof Error ? err.message : String(err) });
|
|
89178
|
+
}
|
|
89179
|
+
}
|
|
89180
|
+
refuse(reason, meta) {
|
|
89181
|
+
this.cachedRoot = null;
|
|
89182
|
+
this.resolvedAt = this.now();
|
|
89183
|
+
this.refusal = reason;
|
|
89184
|
+
this.warnOnce("hksv clip store: no writable HomeKit clips location — clips are NOT being kept (HomeKit recording is unaffected)", {
|
|
89185
|
+
reason,
|
|
89186
|
+
...meta
|
|
89187
|
+
});
|
|
89188
|
+
return null;
|
|
89189
|
+
}
|
|
89190
|
+
/**
|
|
89191
|
+
* One line per distinct situation. A refusal that reprinted on every clip
|
|
89192
|
+
* would drown the line that says it CHANGED, and the operator reads the log
|
|
89193
|
+
* to find out which of the two is happening.
|
|
89194
|
+
*/
|
|
89195
|
+
warnOnce(message, meta) {
|
|
89196
|
+
const key = `${message}|${JSON.stringify(meta)}`;
|
|
89197
|
+
if (this.announced === key) return;
|
|
89198
|
+
this.announced = key;
|
|
89199
|
+
this.log.warn(message, { meta });
|
|
89200
|
+
}
|
|
89201
|
+
};
|
|
89202
|
+
//#endregion
|
|
88772
89203
|
//#region src/hksv/clip-record.ts
|
|
88773
89204
|
/**
|
|
88774
89205
|
* What ONE teed HomeKit clip is, on disk.
|
|
@@ -88874,32 +89305,39 @@ function clipStem(clipId) {
|
|
|
88874
89305
|
*
|
|
88875
89306
|
* ## The owner
|
|
88876
89307
|
*
|
|
88877
|
-
* The **export-hap addon**,
|
|
88878
|
-
*
|
|
89308
|
+
* The **export-hap addon**, on its own DECLARED storage location
|
|
89309
|
+
* (`homekitClips`, see `clip-location.ts`) — the operator picks the disk. Not
|
|
89310
|
+
* the `eventMedia` class and not `recordings`:
|
|
88879
89311
|
*
|
|
88880
|
-
* - `export-hap` is hub-only and runs in its own runner. The recorder's
|
|
88881
|
-
* location machinery (D385/D386/D389) is another addon's, in another
|
|
88882
|
-
* process, and reaching it would be a cross-runner write on the frame path
|
|
88883
|
-
* of a live HomeKit session — exactly what D9/D18 forbid.
|
|
88884
89312
|
* - A teed clip is a DERIVED artifact of a HomeKit session, with no track, no
|
|
88885
89313
|
* detection and no event behind it. Filing it under `eventMedia` would put
|
|
88886
89314
|
* it inside the post-analysis retention sweep, where its lifetime would be
|
|
88887
89315
|
* decided by a policy written for a different question.
|
|
88888
|
-
* -
|
|
88889
|
-
*
|
|
88890
|
-
*
|
|
89316
|
+
* - `recordings` rows belong to the recorder's placement planner, its evictor
|
|
89317
|
+
* and its drain ratchet, and these files are not segments its index knows.
|
|
89318
|
+
* - The precedent for a class of its own is D549 decision 13: Reolink's clip
|
|
89319
|
+
* thumbnails sit outside `eventMedia` and outside the sweep for the same
|
|
89320
|
+
* reason.
|
|
88891
89321
|
*
|
|
88892
|
-
* Layout: `<
|
|
88893
|
-
* clip, three extensions, so a pruner deleting a clip never has to
|
|
88894
|
-
* JPEG was its
|
|
89322
|
+
* Layout: `<location root>/homekit-clips/<deviceId>/<stem>.{mp4,jpg,json}`. One
|
|
89323
|
+
* stem per clip, three extensions, so a pruner deleting a clip never has to
|
|
89324
|
+
* guess which JPEG was its; the `homekit-clips/` subtree is mandatory because
|
|
89325
|
+
* the location's seeded default SHARES the recordings root (D327).
|
|
88895
89326
|
*
|
|
88896
|
-
*
|
|
89327
|
+
* The first cut wrote to `<dataDir>/hksv-clips/`. It was defensible and gave
|
|
89328
|
+
* the operator no say, which was the first thing he asked for after seeing it.
|
|
89329
|
+
*
|
|
89330
|
+
* ## The retention: one preference, three rails
|
|
88897
89331
|
*
|
|
88898
89332
|
* HomeKit's own retention is unreadable — HAP has no read-back, so CamStack can
|
|
88899
89333
|
* never ask "does iOS still have this one?" and reconcile. The store therefore
|
|
88900
89334
|
* cannot mirror HomeKit's lifetime and does not pretend to: it keeps a bounded
|
|
88901
|
-
* recent window and says, per clip, exactly when it started.
|
|
88902
|
-
*
|
|
89335
|
+
* recent window and says, per clip, exactly when it started.
|
|
89336
|
+
*
|
|
89337
|
+
* **The AGE is the operator's, per camera**
|
|
89338
|
+
* ({@link HksvClipStoreInput.maxAgeMsFor}, cascaded global-default plus
|
|
89339
|
+
* per-device override by the addon), defaulting to 14 days. The other three are
|
|
89340
|
+
* RAILS, not preferences, and stay fixed:
|
|
88903
89341
|
*
|
|
88904
89342
|
* - **{@link DEFAULT_CLIP_BOUNDS.maxClipBytes} per clip (256 MB).** A clip's
|
|
88905
89343
|
* duration is whatever iOS pulled — there is no window we choose — so this
|
|
@@ -88912,12 +89350,18 @@ function clipStem(clipId) {
|
|
|
88912
89350
|
* Both bounds exist for the same reason the prebuffer ring has both: at 4K
|
|
88913
89351
|
* a fragment is 6.35 MB, a 25x spread, and a count-only bound is a
|
|
88914
89352
|
* per-camera disk figure nobody can predict.
|
|
88915
|
-
* - **{@link DEFAULT_CLIP_BOUNDS.maxAgeMs} (14 days)**, so a camera that
|
|
88916
|
-
* stopped recording in March does not hold half a gigabyte forever.
|
|
88917
89353
|
* - **{@link DEFAULT_CLIP_BOUNDS.maxStoreBytes} (4 GB)** across every camera,
|
|
88918
89354
|
* applied after the per-device pass. Per-device bounds alone multiply by a
|
|
88919
89355
|
* camera count the operator changes without thinking about this store.
|
|
88920
89356
|
*
|
|
89357
|
+
* An operator able to set the first to zero could turn the tee into a silent
|
|
89358
|
+
* no-op, and one able to raise the last without limit could fill the disk; the
|
|
89359
|
+
* age changes neither. The consequence is stated rather than left to be
|
|
89360
|
+
* discovered: a retention LONGER than the rails allow does not reach — a busy
|
|
89361
|
+
* camera hits 200 clips or 512 MB first. Every prune logs which bound fired
|
|
89362
|
+
* (`reason: age | count | bytes`), so "my 30 days did nothing" has an answer in
|
|
89363
|
+
* the log instead of a theory.
|
|
89364
|
+
*
|
|
88921
89365
|
* A file nobody prunes is a disk that fills, and the pruner is the only thing
|
|
88922
89366
|
* standing between a permanent prebuffer and that.
|
|
88923
89367
|
*
|
|
@@ -88930,8 +89374,6 @@ function clipStem(clipId) {
|
|
|
88930
89374
|
* deleted — this repo has twice shipped a green test because the fake supplied
|
|
88931
89375
|
* what production forgot, and a promise is not a file.
|
|
88932
89376
|
*/
|
|
88933
|
-
/** The directory under the addon's data dir. Ours, and only ours. */
|
|
88934
|
-
var CLIP_DIR_NAME = "hksv-clips";
|
|
88935
89377
|
/** See the class docblock for what each number is, and where it comes from. */
|
|
88936
89378
|
var DEFAULT_CLIP_BOUNDS = {
|
|
88937
89379
|
maxClipBytes: 256 * 1024 * 1024,
|
|
@@ -88955,6 +89397,11 @@ var HksvClipStore = class {
|
|
|
88955
89397
|
};
|
|
88956
89398
|
this.now = input.now ?? Date.now;
|
|
88957
89399
|
}
|
|
89400
|
+
/** This camera's retention. One authority, asked at every prune. */
|
|
89401
|
+
maxAgeMsFor(deviceId) {
|
|
89402
|
+
const resolved = this.input.maxAgeMsFor?.(deviceId);
|
|
89403
|
+
return resolved === void 0 || !Number.isFinite(resolved) || resolved <= 0 ? this.bounds.maxAgeMs : resolved;
|
|
89404
|
+
}
|
|
88958
89405
|
/** The per-clip ceiling the tee enforces while writing. */
|
|
88959
89406
|
get maxClipBytes() {
|
|
88960
89407
|
return this.bounds.maxClipBytes;
|
|
@@ -88967,8 +89414,10 @@ var HksvClipStore = class {
|
|
|
88967
89414
|
async open(input) {
|
|
88968
89415
|
const clipId = clipIdFor(input.deviceId, input.startedAtMs, input.streamId);
|
|
88969
89416
|
const names = clipFileNames(clipId);
|
|
88970
|
-
const dir = this.deviceDir(input.deviceId);
|
|
88971
89417
|
const tags = { deviceId: input.deviceId };
|
|
89418
|
+
const root = await this.resolveRootOrWarn(tags);
|
|
89419
|
+
if (root === null) return null;
|
|
89420
|
+
const dir = this.deviceDir(root, input.deviceId);
|
|
88972
89421
|
try {
|
|
88973
89422
|
await mkdir(dir, { recursive: true });
|
|
88974
89423
|
const sink = await createFileSink(join(dir, names.clip));
|
|
@@ -88979,7 +89428,7 @@ var HksvClipStore = class {
|
|
|
88979
89428
|
return {
|
|
88980
89429
|
clipId,
|
|
88981
89430
|
sink,
|
|
88982
|
-
complete: (outcome) => this.complete(clipId, input, names, outcome)
|
|
89431
|
+
complete: (outcome) => this.complete(clipId, input, names, outcome, root)
|
|
88983
89432
|
};
|
|
88984
89433
|
} catch (err) {
|
|
88985
89434
|
this.inFlight.delete(clipId);
|
|
@@ -88995,7 +89444,9 @@ var HksvClipStore = class {
|
|
|
88995
89444
|
}
|
|
88996
89445
|
/** Every sidecar for a camera, newest first. What a later provider lists. */
|
|
88997
89446
|
async listRecords(deviceId) {
|
|
88998
|
-
const
|
|
89447
|
+
const root = await this.rootOrNull();
|
|
89448
|
+
if (root === null) return [];
|
|
89449
|
+
const dir = this.deviceDir(root, deviceId);
|
|
88999
89450
|
let names;
|
|
89000
89451
|
try {
|
|
89001
89452
|
names = await readdir(dir);
|
|
@@ -89012,7 +89463,9 @@ var HksvClipStore = class {
|
|
|
89012
89463
|
}
|
|
89013
89464
|
/** iOS sent `ack`: HomeKit itself kept this clip. Recorded on the sidecar. */
|
|
89014
89465
|
async acknowledge(deviceId, clipId) {
|
|
89015
|
-
const
|
|
89466
|
+
const root = await this.rootOrNull();
|
|
89467
|
+
if (root === null) return;
|
|
89468
|
+
const path = join(this.deviceDir(root, deviceId), clipFileNames(clipId).record);
|
|
89016
89469
|
const record = await this.readRecord(path, deviceId);
|
|
89017
89470
|
if (record === null) return;
|
|
89018
89471
|
await this.writeRecord(path, {
|
|
@@ -89020,12 +89473,41 @@ var HksvClipStore = class {
|
|
|
89020
89473
|
acknowledgedByHomeKit: true
|
|
89021
89474
|
});
|
|
89022
89475
|
}
|
|
89023
|
-
|
|
89024
|
-
|
|
89476
|
+
/**
|
|
89477
|
+
* The root, or `null` with ONE warn. Never throws: the caller's correct move
|
|
89478
|
+
* for every refusal is identical — no clip, HomeKit untouched.
|
|
89479
|
+
*/
|
|
89480
|
+
async resolveRootOrWarn(tags) {
|
|
89481
|
+
try {
|
|
89482
|
+
const root = await this.input.resolveRoot();
|
|
89483
|
+
if (root !== null) return root;
|
|
89484
|
+
} catch (err) {
|
|
89485
|
+
this.log.warn("hksv clip store: resolving the clips location FAILED — nothing is teed", {
|
|
89486
|
+
tags,
|
|
89487
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
89488
|
+
});
|
|
89489
|
+
return null;
|
|
89490
|
+
}
|
|
89491
|
+
this.log.warn("hksv clip store: no writable clips location — this camera keeps no HomeKit clips", {
|
|
89492
|
+
tags,
|
|
89493
|
+
meta: {}
|
|
89494
|
+
});
|
|
89495
|
+
return null;
|
|
89496
|
+
}
|
|
89497
|
+
/** The root for a READ. Quiet: a read with no location is simply empty. */
|
|
89498
|
+
async rootOrNull() {
|
|
89499
|
+
try {
|
|
89500
|
+
return await this.input.resolveRoot();
|
|
89501
|
+
} catch {
|
|
89502
|
+
return null;
|
|
89503
|
+
}
|
|
89504
|
+
}
|
|
89505
|
+
deviceDir(root, deviceId) {
|
|
89506
|
+
return join(root, String(deviceId));
|
|
89025
89507
|
}
|
|
89026
|
-
async complete(clipId, input, names, outcome) {
|
|
89508
|
+
async complete(clipId, input, names, outcome, root) {
|
|
89027
89509
|
this.inFlight.delete(clipId);
|
|
89028
|
-
const dir = this.deviceDir(input.deviceId);
|
|
89510
|
+
const dir = this.deviceDir(root, input.deviceId);
|
|
89029
89511
|
const tags = { deviceId: input.deviceId };
|
|
89030
89512
|
const clipPath = join(dir, names.clip);
|
|
89031
89513
|
if (!outcome.sawInit || outcome.bytes === 0) {
|
|
@@ -89082,7 +89564,7 @@ var HksvClipStore = class {
|
|
|
89082
89564
|
thumbnailUnavailable: record.thumbnailUnavailable?.reason ?? null
|
|
89083
89565
|
}
|
|
89084
89566
|
});
|
|
89085
|
-
await this.prune(input.deviceId);
|
|
89567
|
+
await this.prune(root, input.deviceId);
|
|
89086
89568
|
return record;
|
|
89087
89569
|
}
|
|
89088
89570
|
/**
|
|
@@ -89129,26 +89611,31 @@ var HksvClipStore = class {
|
|
|
89129
89611
|
* sidecars, so a stem whose sidecar never landed — a crash mid-write — is
|
|
89130
89612
|
* seen and removed instead of occupying the disk invisibly forever.
|
|
89131
89613
|
*/
|
|
89132
|
-
async prune(deviceId) {
|
|
89133
|
-
const
|
|
89134
|
-
|
|
89135
|
-
await this.enforce(perDevice, cutoff, this.bounds.maxClipsPerDevice, this.bounds.maxBytesPerDevice);
|
|
89614
|
+
async prune(root, deviceId) {
|
|
89615
|
+
const perDevice = await this.scanDevice(root, deviceId);
|
|
89616
|
+
await this.enforce(perDevice, this.bounds.maxClipsPerDevice, this.bounds.maxBytesPerDevice);
|
|
89136
89617
|
let deviceIds;
|
|
89137
89618
|
try {
|
|
89138
|
-
deviceIds = (await readdir(
|
|
89619
|
+
deviceIds = (await readdir(root, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => Number.parseInt(e.name, 10)).filter((id) => Number.isFinite(id));
|
|
89139
89620
|
} catch {
|
|
89140
89621
|
return;
|
|
89141
89622
|
}
|
|
89142
89623
|
const all = [];
|
|
89143
|
-
for (const id of deviceIds) all.push(...await this.scanDevice(id));
|
|
89144
|
-
await this.enforce(all,
|
|
89624
|
+
for (const id of deviceIds) all.push(...await this.scanDevice(root, id));
|
|
89625
|
+
await this.enforce(all, Number.POSITIVE_INFINITY, this.bounds.maxStoreBytes);
|
|
89145
89626
|
}
|
|
89146
|
-
|
|
89627
|
+
/**
|
|
89628
|
+
* Enforce the bounds, oldest first. The AGE is asked per clip's CAMERA — two
|
|
89629
|
+
* cameras with different retentions share this pass, and folding them into
|
|
89630
|
+
* one cutoff would silently give every camera the shortest.
|
|
89631
|
+
*/
|
|
89632
|
+
async enforce(clips, maxCount, maxBytes) {
|
|
89633
|
+
const now = this.now();
|
|
89147
89634
|
const ordered = [...clips].sort((a, b) => b.startedAtMs - a.startedAtMs);
|
|
89148
89635
|
let kept = 0;
|
|
89149
89636
|
let bytes = 0;
|
|
89150
89637
|
for (const clip of ordered) {
|
|
89151
|
-
const tooOld = clip.startedAtMs <
|
|
89638
|
+
const tooOld = clip.startedAtMs < now - this.maxAgeMsFor(clip.deviceId);
|
|
89152
89639
|
const overCount = kept >= maxCount;
|
|
89153
89640
|
const overBytes = bytes + clip.bytes > maxBytes;
|
|
89154
89641
|
if (!tooOld && !overCount && !overBytes) {
|
|
@@ -89175,8 +89662,8 @@ var HksvClipStore = class {
|
|
|
89175
89662
|
}
|
|
89176
89663
|
});
|
|
89177
89664
|
}
|
|
89178
|
-
async scanDevice(deviceId) {
|
|
89179
|
-
const dir = this.deviceDir(deviceId);
|
|
89665
|
+
async scanDevice(root, deviceId) {
|
|
89666
|
+
const dir = this.deviceDir(root, deviceId);
|
|
89180
89667
|
let names;
|
|
89181
89668
|
try {
|
|
89182
89669
|
names = await readdir(dir);
|
|
@@ -89222,7 +89709,19 @@ var HksvClipStore = class {
|
|
|
89222
89709
|
} catch {
|
|
89223
89710
|
return null;
|
|
89224
89711
|
}
|
|
89225
|
-
|
|
89712
|
+
let parsed;
|
|
89713
|
+
try {
|
|
89714
|
+
parsed = HksvClipRecordSchema.safeParse(JSON.parse(raw));
|
|
89715
|
+
} catch (err) {
|
|
89716
|
+
this.log.warn("hksv clip store: a sidecar is not readable JSON — the clip is not listed", {
|
|
89717
|
+
tags: { deviceId },
|
|
89718
|
+
meta: {
|
|
89719
|
+
path,
|
|
89720
|
+
error: err instanceof Error ? err.message : String(err)
|
|
89721
|
+
}
|
|
89722
|
+
});
|
|
89723
|
+
return null;
|
|
89724
|
+
}
|
|
89226
89725
|
if (parsed.success) return parsed.data;
|
|
89227
89726
|
this.log.warn("hksv clip store: a sidecar could not be read — the clip is not listed", {
|
|
89228
89727
|
tags: { deviceId },
|
|
@@ -89233,8 +89732,19 @@ var HksvClipStore = class {
|
|
|
89233
89732
|
});
|
|
89234
89733
|
return null;
|
|
89235
89734
|
}
|
|
89735
|
+
/**
|
|
89736
|
+
* ATOMICALLY: a temp file plus a rename, never a `writeFile` in place.
|
|
89737
|
+
*
|
|
89738
|
+
* The sidecar is the only thing that says a clip exists, and it is read
|
|
89739
|
+
* concurrently — by the pruner, and later by whatever lists these clips. A
|
|
89740
|
+
* plain write is visible half-finished, and a half-finished sidecar reads as
|
|
89741
|
+
* a corrupt clip rather than a clip being written. `rename` within one
|
|
89742
|
+
* directory is atomic, so a reader sees the old record or the new one.
|
|
89743
|
+
*/
|
|
89236
89744
|
async writeRecord(path, record) {
|
|
89237
|
-
|
|
89745
|
+
const staging = `${path}.tmp`;
|
|
89746
|
+
await writeFile(staging, JSON.stringify(record, null, 2), "utf8");
|
|
89747
|
+
await rename(staging, path);
|
|
89238
89748
|
}
|
|
89239
89749
|
};
|
|
89240
89750
|
function stemOf(fileName) {
|
|
@@ -105199,7 +105709,8 @@ async function buildHksvRecording(input) {
|
|
|
105199
105709
|
fps,
|
|
105200
105710
|
fragmentLengthMs
|
|
105201
105711
|
});
|
|
105202
|
-
const
|
|
105712
|
+
const keepClips = bctx.options.hapDeviceSettings.keepHomekitClips !== false;
|
|
105713
|
+
const clipStore = keepClips ? bctx.options.hksvClipStore : null;
|
|
105203
105714
|
const openClipTee = clipStore === null ? void 0 : createClipTeeFactory({
|
|
105204
105715
|
logger: log,
|
|
105205
105716
|
deviceId: numericDeviceId,
|
|
@@ -105235,7 +105746,8 @@ async function buildHksvRecording(input) {
|
|
|
105235
105746
|
advertisedFps: advertisedResolution?.[2] ?? null,
|
|
105236
105747
|
fragmentLengthMs,
|
|
105237
105748
|
sourceGopMs: gopMs ?? "unknown",
|
|
105238
|
-
clipsKept: openClipTee !== void 0
|
|
105749
|
+
clipsKept: openClipTee !== void 0,
|
|
105750
|
+
clipsSwitchedOn: keepClips
|
|
105239
105751
|
} });
|
|
105240
105752
|
return {
|
|
105241
105753
|
options,
|
|
@@ -106519,6 +107031,39 @@ function resolveHksvRecording(settings) {
|
|
|
106519
107031
|
* negotiation is built we say `streaming-enabled: false`, so declining is the
|
|
106520
107032
|
* only thing a controller can do with it.
|
|
106521
107033
|
*/
|
|
107034
|
+
/**
|
|
107035
|
+
* Keep a copy of what we send HomeKit, for this camera.
|
|
107036
|
+
*
|
|
107037
|
+
* ABSENT MEANS ON, for the same reason as `resolveHksvRecording` and one
|
|
107038
|
+
* stronger: HKSV has no read-back, so every recording made while this said
|
|
107039
|
+
* "off by accident" is gone permanently. Only an explicit `false` turns it off.
|
|
107040
|
+
*/
|
|
107041
|
+
function resolveKeepHomekitClips(settings) {
|
|
107042
|
+
return settings?.keepClips !== false;
|
|
107043
|
+
}
|
|
107044
|
+
/** What ships, and what an unset global resolves to. */
|
|
107045
|
+
var DEFAULT_CLIP_RETENTION_DAYS = 14;
|
|
107046
|
+
/** The rails on the retention itself. Zero would silently disable the tee. */
|
|
107047
|
+
var MIN_CLIP_RETENTION_DAYS = 1;
|
|
107048
|
+
var MAX_CLIP_RETENTION_DAYS = 365;
|
|
107049
|
+
/**
|
|
107050
|
+
* This camera's retention in days: per-device override → global default →
|
|
107051
|
+
* {@link DEFAULT_CLIP_RETENTION_DAYS}.
|
|
107052
|
+
*
|
|
107053
|
+
* The cascade rule the repo already pays for (`stationary-settings.ts`):
|
|
107054
|
+
* **reset == unset**. A cleared override and a key that was never written must
|
|
107055
|
+
* resolve to the same number, or the Reset button is a lie. A value outside the
|
|
107056
|
+
* rails is not stored as a lie either — it falls back rather than turning the
|
|
107057
|
+
* tee into a no-op (0 days) or an unbounded archive.
|
|
107058
|
+
*/
|
|
107059
|
+
function resolveClipRetentionDays(settings, globalDefaultDays) {
|
|
107060
|
+
const fleet = clampRetentionDays(globalDefaultDays) ?? 14;
|
|
107061
|
+
return clampRetentionDays(settings?.clipRetentionDays) ?? fleet;
|
|
107062
|
+
}
|
|
107063
|
+
function clampRetentionDays(value) {
|
|
107064
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < MIN_CLIP_RETENTION_DAYS) return null;
|
|
107065
|
+
return Math.min(Math.floor(value), MAX_CLIP_RETENTION_DAYS);
|
|
107066
|
+
}
|
|
106522
107067
|
function resolveMultiTierService(settings) {
|
|
106523
107068
|
return settings?.multiTierService === true;
|
|
106524
107069
|
}
|
|
@@ -106546,6 +107091,7 @@ var DEFAULT_CONFIG = {
|
|
|
106546
107091
|
fixedPin: "",
|
|
106547
107092
|
interfaceName: "",
|
|
106548
107093
|
ptzPulseMs: 400,
|
|
107094
|
+
clipRetentionDays: 14,
|
|
106549
107095
|
identity: {
|
|
106550
107096
|
username: "",
|
|
106551
107097
|
pincode: "",
|
|
@@ -106618,6 +107164,8 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
106618
107164
|
* failure to exist is one line at boot and not a surprise mid-recording.
|
|
106619
107165
|
*/
|
|
106620
107166
|
hksvClipStore = null;
|
|
107167
|
+
/** Resolves the declared `homekitClips` location; the doorbell invalidates it. */
|
|
107168
|
+
hksvClipLocation = null;
|
|
106621
107169
|
/**
|
|
106622
107170
|
* THE bridge. Lazily created and published the first time a non-camera
|
|
106623
107171
|
* accessory needs it; never torn down while the addon runs, because an
|
|
@@ -106644,9 +107192,14 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
106644
107192
|
this.lastError = errMsg(err);
|
|
106645
107193
|
this.ctx.logger.error("export-hap: HAP storage init failed", { meta: { error: this.lastError } });
|
|
106646
107194
|
}
|
|
107195
|
+
this.hksvClipLocation = new HksvClipLocation({
|
|
107196
|
+
logger: this.ctx.logger.child("hksv-clips"),
|
|
107197
|
+
ports: createClipLocationPorts(this.ctx.api)
|
|
107198
|
+
});
|
|
106647
107199
|
this.hksvClipStore = new HksvClipStore({
|
|
106648
107200
|
logger: this.ctx.logger.child("hksv-clips"),
|
|
106649
|
-
|
|
107201
|
+
resolveRoot: () => this.hksvClipLocation?.root() ?? Promise.resolve(null),
|
|
107202
|
+
maxAgeMsFor: (deviceId) => resolveClipRetentionDays(this.findEntry(deviceId)?.settings, this.config.clipRetentionDays) * 24 * 60 * 60 * 1e3,
|
|
106650
107203
|
mintThumbnail: createClipThumbnailMinter({ runner: createFfmpegClipThumbnailRunner({
|
|
106651
107204
|
ffmpegBinaryPath: "ffmpeg",
|
|
106652
107205
|
spawnFn: spawn
|
|
@@ -106678,6 +107231,7 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
106678
107231
|
});
|
|
106679
107232
|
this.subscribeDeviceProvisionedForReconcile();
|
|
106680
107233
|
this.subscribeDeviceReadyForReconcile();
|
|
107234
|
+
this.subscribeStorageLocationsForClips();
|
|
106681
107235
|
this.disposeSharedRebuildTimers();
|
|
106682
107236
|
return [{
|
|
106683
107237
|
capability: deviceExportCapability,
|
|
@@ -106848,6 +107402,7 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
106848
107402
|
hapDeviceSettings: {
|
|
106849
107403
|
streamPreference: entrySettings.streamPreference ?? "auto",
|
|
106850
107404
|
hksvRecording: resolveHksvRecording(entrySettings),
|
|
107405
|
+
keepHomekitClips: resolveKeepHomekitClips(entrySettings),
|
|
106851
107406
|
multiTierService: resolveMultiTierService(entrySettings),
|
|
106852
107407
|
allowTranscode: entrySettings.allowTranscode !== false
|
|
106853
107408
|
}
|
|
@@ -106961,6 +107516,21 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
106961
107516
|
* whether the reconcile actually rebuilds anything. Shared by the two
|
|
106962
107517
|
* triggers below.
|
|
106963
107518
|
*/
|
|
107519
|
+
/**
|
|
107520
|
+
* The clips location moved. A DOORBELL, not a payload: the event names which
|
|
107521
|
+
* row changed and the re-read is the authority (`StorageLocationsChangedPayload`
|
|
107522
|
+
* says so itself). Dropping the mirror is all this does — the next clip
|
|
107523
|
+
* resolves it again, and a missed event self-heals on the revalidation
|
|
107524
|
+
* window, so nothing here gates on a fallible read (D49).
|
|
107525
|
+
*/
|
|
107526
|
+
subscribeStorageLocationsForClips() {
|
|
107527
|
+
const unsubscribe = this.ctx.eventBus.subscribe({ category: EventCategory.StorageLocationsChanged }, () => {
|
|
107528
|
+
this.hksvClipLocation?.invalidate();
|
|
107529
|
+
});
|
|
107530
|
+
this.ctx.addDisposer(async () => {
|
|
107531
|
+
unsubscribe();
|
|
107532
|
+
});
|
|
107533
|
+
}
|
|
106964
107534
|
subscribeDeviceLifecycleForReconcile(category) {
|
|
106965
107535
|
const unsubscribe = this.ctx.eventBus.subscribe({ category }, (event) => {
|
|
106966
107536
|
const data = event.data;
|
|
@@ -107159,6 +107729,14 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
107159
107729
|
requiresRestart: true,
|
|
107160
107730
|
placement: { tab: "advanced" }
|
|
107161
107731
|
}),
|
|
107732
|
+
this.field({
|
|
107733
|
+
type: "number",
|
|
107734
|
+
key: "clipRetentionDays",
|
|
107735
|
+
label: "Keep HomeKit clips for (days)",
|
|
107736
|
+
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.",
|
|
107737
|
+
default: DEFAULT_CONFIG.clipRetentionDays,
|
|
107738
|
+
placement: { tab: "advanced" }
|
|
107739
|
+
}),
|
|
107162
107740
|
this.field({
|
|
107163
107741
|
type: "number",
|
|
107164
107742
|
key: "ptzPulseMs",
|
|
@@ -107182,6 +107760,8 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
107182
107760
|
const multiTierKey = `hap:${deviceId}:multiTierService`;
|
|
107183
107761
|
const useBridgeKey = `hap:${deviceId}:useBridge`;
|
|
107184
107762
|
const allowTranscodeKey = `hap:${deviceId}:allowTranscode`;
|
|
107763
|
+
const keepClipsKey = `hap:${deviceId}:keepClips`;
|
|
107764
|
+
const clipRetentionKey = `hap:${deviceId}:clipRetentionDays`;
|
|
107185
107765
|
const mapper = this.exposed.get(String(deviceId)) ?? null;
|
|
107186
107766
|
const paired = mapper ? accessoryPaired(mapper.accessory) : false;
|
|
107187
107767
|
const bridged = mapper !== null && this.bridgeHost?.holds(mapper.accessory) === true;
|
|
@@ -107252,6 +107832,31 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
107252
107832
|
},
|
|
107253
107833
|
immediate: true
|
|
107254
107834
|
},
|
|
107835
|
+
{
|
|
107836
|
+
type: "boolean",
|
|
107837
|
+
key: keepClipsKey,
|
|
107838
|
+
label: "Keep CamStack copies of HomeKit clips",
|
|
107839
|
+
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.",
|
|
107840
|
+
style: "switch",
|
|
107841
|
+
value: resolveKeepHomekitClips(settings),
|
|
107842
|
+
showWhen: {
|
|
107843
|
+
field: hksvKey,
|
|
107844
|
+
equals: true
|
|
107845
|
+
},
|
|
107846
|
+
immediate: true
|
|
107847
|
+
},
|
|
107848
|
+
{
|
|
107849
|
+
type: "number",
|
|
107850
|
+
key: clipRetentionKey,
|
|
107851
|
+
label: "Keep this camera’s clips for (days)",
|
|
107852
|
+
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.`,
|
|
107853
|
+
value: resolveClipRetentionDays(settings, this.config.clipRetentionDays),
|
|
107854
|
+
showWhen: {
|
|
107855
|
+
field: keepClipsKey,
|
|
107856
|
+
equals: true
|
|
107857
|
+
},
|
|
107858
|
+
immediate: true
|
|
107859
|
+
},
|
|
107255
107860
|
{
|
|
107256
107861
|
type: "boolean",
|
|
107257
107862
|
key: allowTranscodeKey,
|
|
@@ -107339,6 +107944,8 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
107339
107944
|
const useBridgeKey = `hap:${deviceId}:useBridge`;
|
|
107340
107945
|
const allowTranscodeKey = `hap:${deviceId}:allowTranscode`;
|
|
107341
107946
|
const multiTierKey = `hap:${deviceId}:multiTierService`;
|
|
107947
|
+
const keepClipsKey = `hap:${deviceId}:keepClips`;
|
|
107948
|
+
const clipRetentionKey = `hap:${deviceId}:clipRetentionDays`;
|
|
107342
107949
|
const enabledValue = enabledKey in patch ? Boolean(patch[enabledKey]) : wasEnabled;
|
|
107343
107950
|
const streamPreferenceRaw = streamPreferenceKey in patch ? patch[streamPreferenceKey] : current?.settings?.streamPreference;
|
|
107344
107951
|
const streamPreference = typeof streamPreferenceRaw === "string" && streamPreferenceRaw.trim().length > 0 ? streamPreferenceRaw : "auto";
|
|
@@ -107346,13 +107953,20 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
107346
107953
|
const useBridge = useBridgeKey in patch ? Boolean(patch[useBridgeKey]) : current?.settings?.useBridge !== false;
|
|
107347
107954
|
const allowTranscode = allowTranscodeKey in patch ? Boolean(patch[allowTranscodeKey]) : current?.settings?.allowTranscode !== false;
|
|
107348
107955
|
const multiTierService = multiTierKey in patch ? Boolean(patch[multiTierKey]) : resolveMultiTierService(current?.settings);
|
|
107956
|
+
const keepClips = keepClipsKey in patch ? Boolean(patch[keepClipsKey]) : resolveKeepHomekitClips(current?.settings);
|
|
107957
|
+
const clipRetentionDays = clipRetentionKey in patch ? resolveClipRetentionDays({
|
|
107958
|
+
streamPreference,
|
|
107959
|
+
clipRetentionDays: Number(patch[clipRetentionKey])
|
|
107960
|
+
}, this.config.clipRetentionDays) : resolveClipRetentionDays(current?.settings, this.config.clipRetentionDays);
|
|
107349
107961
|
const nextSettings = {
|
|
107350
107962
|
...current?.settings ?? DEFAULT_DEVICE_SETTINGS,
|
|
107351
107963
|
streamPreference,
|
|
107352
107964
|
hksvRecording,
|
|
107353
107965
|
useBridge,
|
|
107354
107966
|
allowTranscode,
|
|
107355
|
-
multiTierService
|
|
107967
|
+
multiTierService,
|
|
107968
|
+
keepClips,
|
|
107969
|
+
clipRetentionDays
|
|
107356
107970
|
};
|
|
107357
107971
|
if (!enabledValue) {
|
|
107358
107972
|
if (wasEnabled) await this.unexposeDevice(deviceIdStr);
|
|
@@ -107365,6 +107979,7 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
107365
107979
|
}
|
|
107366
107980
|
const currentPref = current?.settings?.streamPreference ?? "auto";
|
|
107367
107981
|
const currentHksv = resolveHksvRecording(current?.settings);
|
|
107982
|
+
const currentKeepClips = resolveKeepHomekitClips(current?.settings);
|
|
107368
107983
|
const mapperKind = current?.mapperKind ?? "generic";
|
|
107369
107984
|
const wasBridged = shouldBridge({
|
|
107370
107985
|
mapperKind,
|
|
@@ -107375,7 +107990,7 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
107375
107990
|
useBridge
|
|
107376
107991
|
});
|
|
107377
107992
|
await this.updateEntrySettings(deviceIdStr, nextSettings);
|
|
107378
|
-
if (currentPref !== streamPreference || currentHksv !== hksvRecording || wasBridged !== willBridge) {
|
|
107993
|
+
if (currentPref !== streamPreference || currentHksv !== hksvRecording || currentKeepClips !== keepClips || wasBridged !== willBridge) {
|
|
107379
107994
|
log.info("export-hap: per-camera export settings changed — refreshing accessory", { meta: {
|
|
107380
107995
|
streamPreference: {
|
|
107381
107996
|
from: currentPref,
|
|
@@ -107385,6 +108000,10 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
107385
108000
|
from: currentHksv,
|
|
107386
108001
|
to: hksvRecording
|
|
107387
108002
|
},
|
|
108003
|
+
keepClips: {
|
|
108004
|
+
from: currentKeepClips,
|
|
108005
|
+
to: keepClips
|
|
108006
|
+
},
|
|
107388
108007
|
bridged: {
|
|
107389
108008
|
from: wasBridged,
|
|
107390
108009
|
to: willBridge
|
|
@@ -107415,4 +108034,4 @@ function errMsg(err) {
|
|
|
107415
108034
|
return err instanceof Error ? err.message : String(err);
|
|
107416
108035
|
}
|
|
107417
108036
|
//#endregion
|
|
107418
|
-
export { ExportHapAddon, ExportHapAddon as default, unpublishAccessory as i, initHapStorage as n, publishStandalone as r, resolveHksvRecording, resolveMultiTierService, deriveUsername as t };
|
|
108037
|
+
export { DEFAULT_CLIP_RETENTION_DAYS, ExportHapAddon, ExportHapAddon as default, unpublishAccessory as i, initHapStorage as n, publishStandalone as r, resolveClipRetentionDays, resolveHksvRecording, resolveKeepHomekitClips, resolveMultiTierService, deriveUsername as t };
|