@camstack/addon-provider-amcrest 0.2.118 → 0.2.119
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/addon.js +3753 -129
- package/dist/addon.mjs +3753 -129
- package/package.json +7 -1
package/dist/addon.mjs
CHANGED
|
@@ -2,6 +2,12 @@ import { createRequire } from "node:module";
|
|
|
2
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
3
|
import { request } from "node:http";
|
|
4
4
|
import { request as request$1 } from "node:https";
|
|
5
|
+
import { mkdir, readFile, readdir, rename, rm, stat } from "node:fs/promises";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { createReadStream, createWriteStream } from "node:fs";
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { Readable } from "node:stream";
|
|
10
|
+
import { pipeline } from "node:stream/promises";
|
|
5
11
|
import { networkInterfaces } from "node:os";
|
|
6
12
|
//#region \0rolldown/runtime.js
|
|
7
13
|
var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
|
|
@@ -5368,7 +5374,7 @@ var ZodIssueCode = {
|
|
|
5368
5374
|
var ZodFirstPartyTypeKind;
|
|
5369
5375
|
ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
|
|
5370
5376
|
//#endregion
|
|
5371
|
-
//#region ../types/dist/sleep-
|
|
5377
|
+
//#region ../types/dist/sleep-COWaSCAi.mjs
|
|
5372
5378
|
/**
|
|
5373
5379
|
* The audio chunk plane's byte format, and the ONE expansion from a coded
|
|
5374
5380
|
* window to float samples (D455).
|
|
@@ -7526,6 +7532,24 @@ object({
|
|
|
7526
7532
|
unreachable: number()
|
|
7527
7533
|
})
|
|
7528
7534
|
});
|
|
7535
|
+
/** The wire shape of {@link PeerBytesTicket} — see the type for what it is. */
|
|
7536
|
+
var PeerBytesTicketSchema = object({
|
|
7537
|
+
/** `http://127.0.0.1:<port>/<token>`. One `GET` takes it. */
|
|
7538
|
+
url: string().min(1),
|
|
7539
|
+
/**
|
|
7540
|
+
* The HOST node this URL means something on — the hub or a named agent,
|
|
7541
|
+
* never a runner. {@link AddonPeerBytes.open} compares it to its own and
|
|
7542
|
+
* refuses `cross-node` by name when they differ, without dialling.
|
|
7543
|
+
*/
|
|
7544
|
+
hostNodeId: string().min(1),
|
|
7545
|
+
expiresAtMs: number().int().nonnegative(),
|
|
7546
|
+
/**
|
|
7547
|
+
* What the producer DECLARED the body to be, when it knows — `null` when it
|
|
7548
|
+
* does not. Never `0` for unknown (D393): a consumer sizing a bound off this
|
|
7549
|
+
* must be able to tell "the producer did not say" from "the body is empty".
|
|
7550
|
+
*/
|
|
7551
|
+
declaredBytes: number().int().nonnegative().nullable()
|
|
7552
|
+
});
|
|
7529
7553
|
/**
|
|
7530
7554
|
* Adoption job — the background form of `device-adoption.adopt`.
|
|
7531
7555
|
*
|
|
@@ -24699,6 +24723,7 @@ _enum([
|
|
|
24699
24723
|
"sleeping",
|
|
24700
24724
|
"camera-refused",
|
|
24701
24725
|
"no-keyframe",
|
|
24726
|
+
"decode-failed",
|
|
24702
24727
|
"no-catalog-row",
|
|
24703
24728
|
"unsupported",
|
|
24704
24729
|
"unknown-device",
|
|
@@ -24942,6 +24967,49 @@ var ClipPlaybackSchema = object({
|
|
|
24942
24967
|
*/
|
|
24943
24968
|
var ClipWakeSchema = _enum(["authorised"]);
|
|
24944
24969
|
/**
|
|
24970
|
+
* Hard ceiling on ONE {@link videoclipsCapability.methods.readClipBytes} — the
|
|
24971
|
+
* same 50 MiB `RECORDING_EXPORT_MAX_READ_BYTES` uses, and for the same second
|
|
24972
|
+
* reason: the envelope is unary, so a base64 payload is held whole (~1.33× its
|
|
24973
|
+
* size) in the provider AND in the caller, on a hub this repo has already
|
|
24974
|
+
* OOM'd once (D9/D18).
|
|
24975
|
+
*
|
|
24976
|
+
* Measured clips sit far below it — 92 KB–2.29 MB for a sub twin, 455 KB for a
|
|
24977
|
+
* 16 s main clip — so the bound bites rarely. "Rarely" is not "never": a long
|
|
24978
|
+
* 4K main twin can exceed it, and above the bound the provider REFUSES with
|
|
24979
|
+
* the size in the message, never truncates. Half a video is worse than an
|
|
24980
|
+
* honest refusal.
|
|
24981
|
+
*
|
|
24982
|
+
* **Superseded as the way a clip export gets its bytes** (D613). The bound is
|
|
24983
|
+
* a property of the ENVELOPE, not of clips, so the cure was a transport with
|
|
24984
|
+
* no envelope: {@link videoclipsCapability.methods.offerClipBytes} hands back
|
|
24985
|
+
* a one-shot `peerBytes` ticket and the consumer streams it straight to disk,
|
|
24986
|
+
* holding nothing. This method stays for a consumer that genuinely wants the
|
|
24987
|
+
* bytes in hand, and for a provider not yet redeployed — with this bound,
|
|
24988
|
+
* which for an inline read is not going to move.
|
|
24989
|
+
*/
|
|
24990
|
+
var VIDEOCLIPS_MAX_READ_BYTES = 50 * 1024 * 1024;
|
|
24991
|
+
/**
|
|
24992
|
+
* Hard ceiling on ONE {@link videoclipsCapability.methods.offerClipBytes}
|
|
24993
|
+
* transfer — ten times {@link VIDEOCLIPS_MAX_READ_BYTES}, and the reasoning is
|
|
24994
|
+
* not "ten times more comfortable".
|
|
24995
|
+
*
|
|
24996
|
+
* The inline bound is set by what is HELD: a base64 envelope is the payload at
|
|
24997
|
+
* ~1.33× in the provider AND the same again in the caller, so 50 MiB of clip
|
|
24998
|
+
* is ~133 MiB of heap across two processes. Over `peerBytes` the consumer
|
|
24999
|
+
* holds one socket chunk at a time and writes straight through to the export
|
|
25000
|
+
* file, so its contribution is flat whatever the clip weighs. What is left is
|
|
25001
|
+
* the PRODUCER's own copy, and that is a property of how each provider makes a
|
|
25002
|
+
* clip rather than of this transport: Hikvision's fetch writes to a scratch
|
|
25003
|
+
* FILE and offers it (nothing is held), Reolink's cmd-5 transfer is collected
|
|
25004
|
+
* in memory and offered from there (one copy, the one it already had).
|
|
25005
|
+
*
|
|
25006
|
+
* So this number bounds the worst provider, not the transport, and it is named
|
|
25007
|
+
* separately so that lifting it is a statement about a provider rather than
|
|
25008
|
+
* about clips. Above it the provider REFUSES with the size in the message and
|
|
25009
|
+
* never truncates: half a video is worse than an honest refusal.
|
|
25010
|
+
*/
|
|
25011
|
+
var VIDEOCLIPS_MAX_OFFER_BYTES = 500 * 1024 * 1024;
|
|
25012
|
+
/**
|
|
24945
25013
|
* A clip's finished bytes, inline — the twin of `recordingExport.readExportBytes`.
|
|
24946
25014
|
*
|
|
24947
25015
|
* `bytes` is the DECODED length, so nobody infers it from the base64 length,
|
|
@@ -24973,6 +25041,32 @@ var ClipBytesSchema = object({
|
|
|
24973
25041
|
durationMs: number().positive().optional()
|
|
24974
25042
|
});
|
|
24975
25043
|
/**
|
|
25044
|
+
* Where a clip's finished bytes can be TAKEN (D613) — the answer to
|
|
25045
|
+
* {@link videoclipsCapability.methods.offerClipBytes}.
|
|
25046
|
+
*
|
|
25047
|
+
* Everything {@link ClipBytesSchema} carries except the bytes themselves, plus
|
|
25048
|
+
* the one-shot ticket that leads to them. The metadata is answered BEFORE the
|
|
25049
|
+
* transfer on purpose: a consumer learns which twin it got, what to call the
|
|
25050
|
+
* file and how long the clip runs without having to read a byte, so a decision
|
|
25051
|
+
* it would make on that metadata (a wrong twin, an implausible duration) costs
|
|
25052
|
+
* no transfer at all.
|
|
25053
|
+
*/
|
|
25054
|
+
var ClipBytesOfferSchema = object({
|
|
25055
|
+
/**
|
|
25056
|
+
* One shot, seconds-long, loopback, on the PROVIDER's own host. Open it with
|
|
25057
|
+
* `ctx.peerBytes.open(...)`, which refuses a ticket from another node by
|
|
25058
|
+
* name rather than dialling a port that means something else here.
|
|
25059
|
+
*/
|
|
25060
|
+
ticket: PeerBytesTicketSchema,
|
|
25061
|
+
contentType: string(),
|
|
25062
|
+
/** Suggested filename, extension included. */
|
|
25063
|
+
name: string(),
|
|
25064
|
+
/** Which twin was actually served — see {@link ClipBytesSchema.served}. */
|
|
25065
|
+
served: CamProfileSchema,
|
|
25066
|
+
/** See {@link ClipBytesSchema.durationMs}. Absent when nothing measured it. */
|
|
25067
|
+
durationMs: number().positive().optional()
|
|
25068
|
+
});
|
|
25069
|
+
/**
|
|
24976
25070
|
* Where a clip's STREAM can be dialled (D597) — the answer to
|
|
24977
25071
|
* {@link videoclipsCapability.methods.dialClipStream}.
|
|
24978
25072
|
*
|
|
@@ -25048,6 +25142,105 @@ var ClipStreamDialSchema = object({
|
|
|
25048
25142
|
/** Why `servedAudio` is `none` although sound was asked for. */
|
|
25049
25143
|
audioReason: ClipStreamAudioReasonSchema.optional()
|
|
25050
25144
|
});
|
|
25145
|
+
/**
|
|
25146
|
+
* The playback rates a clip can be DELIVERED at, ascending, always with `1`.
|
|
25147
|
+
*
|
|
25148
|
+
* These are the BROKER's: it re-paces frames it has already demuxed — the
|
|
25149
|
+
* `MonotonicClock` divides source elapsed time by the factor and the pacer
|
|
25150
|
+
* pushes that much faster — so the domain is the recorded one, {0} ∪ [0.25, 4],
|
|
25151
|
+
* and this is the discrete ladder drawn from it. `1.5` is in it because a
|
|
25152
|
+
* re-pacer has no reason to refuse it.
|
|
25153
|
+
*
|
|
25154
|
+
* `8` and `16` are deliberately NOT here. They are the CAMERA's own
|
|
25155
|
+
* `<playSpeed>` (Reolink cmd-5 replay), a different mechanism, still unwired
|
|
25156
|
+
* (D597, D600) — a rate change there costs a new dial and a new stream, and
|
|
25157
|
+
* the camera's 8x would need a resample the clip audio path has no decoder
|
|
25158
|
+
* for. Offering them today accepts a rate and delivers 1x, which is the whole
|
|
25159
|
+
* defect this list exists to end (D612). When `playSpeed` IS wired its rates
|
|
25160
|
+
* join THIS array — a second list elsewhere is the second authority D62
|
|
25161
|
+
* forbids.
|
|
25162
|
+
*
|
|
25163
|
+
* Every entry must survive the broker's `clampPlaybackRate` unchanged: a set
|
|
25164
|
+
* that offers what the clamp then moves is the same lie one step later.
|
|
25165
|
+
*/
|
|
25166
|
+
var CLIP_BROKER_PACED_RATES = [
|
|
25167
|
+
.25,
|
|
25168
|
+
.5,
|
|
25169
|
+
1,
|
|
25170
|
+
1.5,
|
|
25171
|
+
2,
|
|
25172
|
+
4
|
|
25173
|
+
];
|
|
25174
|
+
/**
|
|
25175
|
+
* The rates a REALTIME-BOUND stream can be delivered at.
|
|
25176
|
+
*
|
|
25177
|
+
* **Measured, and it is the whole reason this second ladder exists.** A
|
|
25178
|
+
* Hikvision RTSP replay is not a fast fetch: it delivers the recording at the
|
|
25179
|
+
* speed it was recorded. On 1436, 2026-09-23, one stream carried 176 access
|
|
25180
|
+
* units in 14 274 ms of wall clock at a measured 12.5 fps — 14.08 s of media
|
|
25181
|
+
* in 14.27 s, **1.01× realtime** — and a whole 18 s clip took 22.3 s to fetch
|
|
25182
|
+
* end to end. Reolink's cmd-5 transfer, which {@link CLIP_BROKER_PACED_RATES}
|
|
25183
|
+
* was written for, lands the same bytes at 49–62× and therefore has the entire
|
|
25184
|
+
* clip in hand within the first second.
|
|
25185
|
+
*
|
|
25186
|
+
* The broker's pacer consumes `rate` seconds of media per second of wall
|
|
25187
|
+
* clock while the socket supplies one. So at any rate above 1 the buffer
|
|
25188
|
+
* drains at `(rate − 1)×` and a stream that is only seconds old runs dry
|
|
25189
|
+
* almost at once — the viewer stops, the queue empties, and the provider's
|
|
25190
|
+
* own drain gate eventually reports `peer-stalled`. Nothing about that is a
|
|
25191
|
+
* bug to be fixed with a bigger buffer: there is no buffer, because the bytes
|
|
25192
|
+
* do not exist yet.
|
|
25193
|
+
*
|
|
25194
|
+
* So the honest answer is the DECLARATION (D612's own rule, applied to the
|
|
25195
|
+
* case it did not yet have): a rate this transport cannot deliver is never
|
|
25196
|
+
* offered. Below 1 is free — a slower pacer only lets the buffer grow.
|
|
25197
|
+
*
|
|
25198
|
+
* When a clip is served from a FILE the constraint is gone with the transport,
|
|
25199
|
+
* which is why `file` keeps the full ladder.
|
|
25200
|
+
*/
|
|
25201
|
+
var CLIP_REALTIME_SOURCE_RATES = [
|
|
25202
|
+
.25,
|
|
25203
|
+
.5,
|
|
25204
|
+
1
|
|
25205
|
+
];
|
|
25206
|
+
/**
|
|
25207
|
+
* What a surface may DRAW for this provider's clips — the answer to
|
|
25208
|
+
* {@link videoclipsCapability.methods.getPlaybackOptions} (D612).
|
|
25209
|
+
*
|
|
25210
|
+
* The envelope is a PROVIDER fact, not a clip fact, and that is measured, not
|
|
25211
|
+
* assumed: the broker's `chooseClipPath` reads exactly two inputs — whether
|
|
25212
|
+
* `dialClipStream` and `readClipBytes` are wired — and both are constants of
|
|
25213
|
+
* the broker's own closure over the provider's methods. The `profile` it is
|
|
25214
|
+
* handed is explicitly not read. So every clip of a provider is served the
|
|
25215
|
+
* same way, and a per-clip channel carried a value that could not vary. The
|
|
25216
|
+
* per-clip `clipTransport` server message was removed for exactly that reason.
|
|
25217
|
+
*
|
|
25218
|
+
* Queried per camera, before a clip is picked, so a control is rendered or
|
|
25219
|
+
* DISABLED rather than offered and refused at play time (D62: a disabled
|
|
25220
|
+
* control reads as unavailable, one that undoes the gesture reads as broken).
|
|
25221
|
+
*/
|
|
25222
|
+
var ClipPlaybackOptionsSchema = object({
|
|
25223
|
+
/**
|
|
25224
|
+
* How this provider's clips reach the player. `stream` is the provider's
|
|
25225
|
+
* forward-only fMP4 (D597); `file` is one bounded by-handle fetch of the
|
|
25226
|
+
* whole clip, `stbl` indexed (D575).
|
|
25227
|
+
*/
|
|
25228
|
+
transport: _enum(["stream", "file"]),
|
|
25229
|
+
/** `forward` = only ahead of the playhead. `free` = anywhere. */
|
|
25230
|
+
seek: _enum(["forward", "free"]),
|
|
25231
|
+
/** Frame-step BACKWARD is meaningful. Forward always is. */
|
|
25232
|
+
stepBack: boolean(),
|
|
25233
|
+
/** Whether the scrub gesture is served, as opposed to refused by name. */
|
|
25234
|
+
scrub: boolean(),
|
|
25235
|
+
/**
|
|
25236
|
+
* The rates that can be delivered, ascending, always containing `1`. The
|
|
25237
|
+
* viewer draws its picker from this and from nothing else — a constant it
|
|
25238
|
+
* keeps instead is the second authority that produced the defect: `8` and
|
|
25239
|
+
* `16` were offered, the broker clamped them to `4`, and no line anywhere
|
|
25240
|
+
* said so. `0` is not a member: pause is the absence of a rate.
|
|
25241
|
+
*/
|
|
25242
|
+
rates: array(number().positive()).min(1).readonly()
|
|
25243
|
+
});
|
|
25051
25244
|
var ClipSourceAvailabilitySchema = object({
|
|
25052
25245
|
state: _enum([
|
|
25053
25246
|
"ok",
|
|
@@ -25097,138 +25290,389 @@ var ClipSourceSchema = object({
|
|
|
25097
25290
|
addonId: string().optional(),
|
|
25098
25291
|
availability: ClipSourceAvailabilitySchema
|
|
25099
25292
|
});
|
|
25100
|
-
|
|
25101
|
-
|
|
25102
|
-
|
|
25103
|
-
|
|
25104
|
-
|
|
25105
|
-
|
|
25106
|
-
|
|
25107
|
-
*
|
|
25108
|
-
*
|
|
25109
|
-
|
|
25110
|
-
* answered by another one (D552's `rejectUnresolvedAddonPin` rule).
|
|
25111
|
-
*
|
|
25112
|
-
* It was optional, documented as "absent means the device's BOUND
|
|
25113
|
-
* provider, which is CamStack on every camera". No code implemented
|
|
25114
|
-
* that. Measured on the live hub 2026-09-20 — device 592, bound to
|
|
25115
|
-
* `recorder` AND `provider-reolink` — a bare call with `limit: 3`
|
|
25116
|
-
* answered SIX rows, three from each source, merged newest-first:
|
|
25117
|
-
* `device-collection-dispatch.ts` simply left the fan-out un-narrowed,
|
|
25118
|
-
* so absence bought the union this method exists not to be, and
|
|
25119
|
-
* `limit` meant `limit × sources`.
|
|
25120
|
-
*
|
|
25121
|
-
* There is nothing to restore the default to. `getBindings` answers a
|
|
25122
|
-
* collection cap with a DERIVED, PLURAL set (D554 amended, step 0);
|
|
25123
|
-
* `setWrapperActive` — the singleton authority that could name one —
|
|
25124
|
-
* throws for a collection cap by design. Naming CamStack here instead
|
|
25125
|
-
* would privilege one addon by id inside a surface whose premise is
|
|
25126
|
-
* that sources are peers, and would be wrong on the first camera with
|
|
25127
|
-
* no recorder binding. So absence is REFUSED, in the schema, where the
|
|
25128
|
-
* generated types make it unomittable rather than merely discouraged.
|
|
25129
|
-
*
|
|
25130
|
-
* The default belongs to the SURFACE, which has the `listSources` rows
|
|
25131
|
-
* and can say which one it picked (D569 § 1–3; the viewer already
|
|
25132
|
-
* always sends this).
|
|
25133
|
-
*
|
|
25134
|
-
* It replaced `sources?: string[]`, a VIEW filter over source ids that
|
|
25135
|
-
* assumed the answer was a fan-out over everything a camera has. The
|
|
25136
|
-
* operator settled otherwise on 2026-09-20 — *"l'utilizzatore è uno
|
|
25137
|
-
* solo"* — so the list is asked of one provider at a time and there is
|
|
25138
|
-
* nothing to filter out of it.
|
|
25139
|
-
*/
|
|
25140
|
-
provider: string().min(1)
|
|
25141
|
-
}), array(ClipSchema).readonly(), {
|
|
25142
|
-
kind: "query",
|
|
25143
|
-
auth: "protected"
|
|
25144
|
-
}), method(object({ deviceId: number() }), array(ClipSourceSchema).readonly(), {
|
|
25145
|
-
kind: "query",
|
|
25146
|
-
auth: "protected"
|
|
25147
|
-
}), method(object({
|
|
25148
|
-
deviceId: number(),
|
|
25149
|
-
clipId: string(),
|
|
25150
|
-
/**
|
|
25151
|
-
* Which twin to serve, on the ONE quality scale the system already has
|
|
25152
|
-
* (`CamProfileSchema`). `low | mid` → the sub file, `high` → the main
|
|
25153
|
-
* twin; both ids are already on the row so this never re-searches the
|
|
25154
|
-
* camera. Absent means the provider's own default (the sub file, which
|
|
25155
|
-
* every source is measured to hold).
|
|
25156
|
-
*
|
|
25157
|
-
* `auto` is deliberately NOT accepted here: a stored file has no
|
|
25158
|
-
* broker session, so the adaptive tier cannot be resolved for it. The
|
|
25159
|
-
* viewer resolves `auto` to a profile the same way live does, before
|
|
25160
|
-
* it calls (D549 15).
|
|
25161
|
-
*/
|
|
25162
|
-
profile: CamProfileSchema.optional()
|
|
25163
|
-
}), ClipPlaybackSchema, {
|
|
25164
|
-
kind: "query",
|
|
25165
|
-
auth: "protected"
|
|
25166
|
-
}), optionalMethod(object({
|
|
25167
|
-
deviceId: number(),
|
|
25168
|
-
clipId: string().min(1),
|
|
25169
|
-
/**
|
|
25170
|
-
* WHICH provider holds the bytes — the `addonId` a
|
|
25171
|
-
* {@link ClipSourceSchema} row carries. **Required**, for the reason
|
|
25172
|
-
* `listClips` states one method above and for a second one this method
|
|
25173
|
-
* learned the hard way.
|
|
25174
|
-
*
|
|
25175
|
-
* The caller is the stream broker, and the broker lives in
|
|
25176
|
-
* `addon-pipeline` — the same addon that registers the RECORDER's
|
|
25177
|
-
* videoclips provider. With no provider named, the in-process
|
|
25178
|
-
* resolution handed it its own sibling, which implements this method
|
|
25179
|
-
* solely to explain that an analytics visit has no file behind it. So
|
|
25180
|
-
* every onboard clip answered with that sentence, a flash of video and
|
|
25181
|
-
* nothing more, while the SAME call from outside the addon reached the
|
|
25182
|
-
* right provider and returned the bytes (measured on 3628,
|
|
25183
|
-
* 2026-09-21).
|
|
25184
|
-
*/
|
|
25185
|
-
provider: string().min(1),
|
|
25186
|
-
/**
|
|
25187
|
-
* Which twin to fetch, on the mapping D549 15 already fixed:
|
|
25188
|
-
* `low | mid` → the sub file, `high` → the main twin. `auto` is not
|
|
25189
|
-
* accepted here for the same reason it is not accepted by
|
|
25190
|
-
* `getClipPlayback` — a stored file has no broker session, so the
|
|
25191
|
-
* adaptive tier cannot be resolved for it.
|
|
25192
|
-
*/
|
|
25193
|
-
profile: CamProfileSchema.optional(),
|
|
25194
|
-
/**
|
|
25195
|
-
* The CALLER's byte bound, so an over-size clip is refused before it is
|
|
25196
|
-
* read and encoded rather than after. Capped by
|
|
25197
|
-
* {@link VIDEOCLIPS_MAX_READ_BYTES} whatever is passed; absent means
|
|
25198
|
-
* that ceiling.
|
|
25199
|
-
*/
|
|
25200
|
-
maxBytes: number().int().positive().optional(),
|
|
25293
|
+
var videoclipsCapability = {
|
|
25294
|
+
name: "videoclips",
|
|
25295
|
+
scope: "device",
|
|
25296
|
+
mode: "collection",
|
|
25297
|
+
kind: "wrapper",
|
|
25298
|
+
defaultActive: true,
|
|
25299
|
+
/** A clip is a window over a camera's footage — the cap is meaningless on a
|
|
25300
|
+
* sensor, a button or an event emitter, and the `defaultActive` auto-bind
|
|
25301
|
+
* reads this to decide which devices it may claim. */
|
|
25302
|
+
deviceTypes: [DeviceType.Camera],
|
|
25201
25303
|
/**
|
|
25202
|
-
* The
|
|
25203
|
-
*
|
|
25204
|
-
*
|
|
25304
|
+
* The Clips section of a camera's device details is FRAMEWORK-DERIVED (D14):
|
|
25305
|
+
* the aggregator turns this declaration into the `type:'widget'` section and
|
|
25306
|
+
* `DeviceDetail.tsx` is never edited. `videoclips` is the first WRAPPER cap
|
|
25307
|
+
* to declare one — every previous `host/` widget cap is `deviceNative` — so
|
|
25308
|
+
* `device-config-widget-wrapped-binding.spec.ts` pins that a `kind:'wrapped'`
|
|
25309
|
+
* binding entry derives the same section a native one does.
|
|
25310
|
+
*
|
|
25311
|
+
* **It is a SECTION of the Recording tab, at the end of it — not a tab of
|
|
25312
|
+
* its own.** It shipped as a `clips` top-tab and the operator rejected the
|
|
25313
|
+
* placement: *"utilizzerei la stessa tab recordings, lì abbiamo già tutto il
|
|
25314
|
+
* necessario, una nuova sezione alla fine per le clips"*. The Recording tab
|
|
25315
|
+
* already holds the recorder's panel, the schedule bands and the unified
|
|
25316
|
+
* retention policy; footage the camera itself holds is the same question,
|
|
25317
|
+
* asked of a different store. `order: 100` puts it after all of them with
|
|
25318
|
+
* room left in front. The `clips` entry in `WELL_KNOWN_TABS` went with it —
|
|
25319
|
+
* a well-known id nobody declares is an invitation to mint the tab again.
|
|
25320
|
+
*
|
|
25321
|
+
* `topTab` stays: the browser owns a pane (a day's tiles, a source list and
|
|
25322
|
+
* a player) and the Config tab's inner bar has no room for one. The widget
|
|
25323
|
+
* itself is `host/clips-browser` in ui-library's `HOST_WIDGETS`, and because
|
|
25324
|
+
* the admin Recordings page renders every `location:'top-tab'` +
|
|
25325
|
+
* `tab:'recording'` section behind its camera picker
|
|
25326
|
+
* (`CameraRecordingSettingsSection`), this declaration lands the section on
|
|
25327
|
+
* BOTH surfaces with no second wiring.
|
|
25205
25328
|
*/
|
|
25206
|
-
|
|
25207
|
-
|
|
25208
|
-
|
|
25209
|
-
|
|
25210
|
-
|
|
25211
|
-
|
|
25212
|
-
|
|
25213
|
-
|
|
25214
|
-
|
|
25215
|
-
|
|
25216
|
-
|
|
25329
|
+
deviceConfig: { ui: {
|
|
25330
|
+
kind: "widget",
|
|
25331
|
+
widgetId: "host/clips-browser",
|
|
25332
|
+
tab: "recording",
|
|
25333
|
+
topTab: true,
|
|
25334
|
+
label: "Clips",
|
|
25335
|
+
order: 100
|
|
25336
|
+
} },
|
|
25337
|
+
methods: {
|
|
25338
|
+
listClips: method(object({
|
|
25339
|
+
deviceId: number(),
|
|
25340
|
+
since: number(),
|
|
25341
|
+
until: number(),
|
|
25342
|
+
limit: number().int().positive().optional(),
|
|
25343
|
+
/**
|
|
25344
|
+
* WHICH provider to ask — the `addonId` a {@link ClipSourceSchema} row
|
|
25345
|
+
* carries, never a source id and never a list. **Required** (D554 amended).
|
|
25346
|
+
*
|
|
25347
|
+
* A provider the device is not bound to is refused by name rather than
|
|
25348
|
+
* answered by another one (D552's `rejectUnresolvedAddonPin` rule).
|
|
25349
|
+
*
|
|
25350
|
+
* It was optional, documented as "absent means the device's BOUND
|
|
25351
|
+
* provider, which is CamStack on every camera". No code implemented
|
|
25352
|
+
* that. Measured on the live hub 2026-09-20 — device 592, bound to
|
|
25353
|
+
* `recorder` AND `provider-reolink` — a bare call with `limit: 3`
|
|
25354
|
+
* answered SIX rows, three from each source, merged newest-first:
|
|
25355
|
+
* `device-collection-dispatch.ts` simply left the fan-out un-narrowed,
|
|
25356
|
+
* so absence bought the union this method exists not to be, and
|
|
25357
|
+
* `limit` meant `limit × sources`.
|
|
25358
|
+
*
|
|
25359
|
+
* There is nothing to restore the default to. `getBindings` answers a
|
|
25360
|
+
* collection cap with a DERIVED, PLURAL set (D554 amended, step 0);
|
|
25361
|
+
* `setWrapperActive` — the singleton authority that could name one —
|
|
25362
|
+
* throws for a collection cap by design. Naming CamStack here instead
|
|
25363
|
+
* would privilege one addon by id inside a surface whose premise is
|
|
25364
|
+
* that sources are peers, and would be wrong on the first camera with
|
|
25365
|
+
* no recorder binding. So absence is REFUSED, in the schema, where the
|
|
25366
|
+
* generated types make it unomittable rather than merely discouraged.
|
|
25367
|
+
*
|
|
25368
|
+
* The default belongs to the SURFACE, which has the `listSources` rows
|
|
25369
|
+
* and can say which one it picked (D569 § 1–3; the viewer already
|
|
25370
|
+
* always sends this).
|
|
25371
|
+
*
|
|
25372
|
+
* It replaced `sources?: string[]`, a VIEW filter over source ids that
|
|
25373
|
+
* assumed the answer was a fan-out over everything a camera has. The
|
|
25374
|
+
* operator settled otherwise on 2026-09-20 — *"l'utilizzatore è uno
|
|
25375
|
+
* solo"* — so the list is asked of one provider at a time and there is
|
|
25376
|
+
* nothing to filter out of it.
|
|
25377
|
+
*/
|
|
25378
|
+
provider: string().min(1)
|
|
25379
|
+
}), array(ClipSchema).readonly(), {
|
|
25380
|
+
kind: "query",
|
|
25381
|
+
auth: "protected"
|
|
25382
|
+
}),
|
|
25383
|
+
/**
|
|
25384
|
+
* The sources this camera has, WITH the reason any of them cannot answer.
|
|
25385
|
+
*
|
|
25386
|
+
* Asked separately from `listClips` because an empty clip list is
|
|
25387
|
+
* ambiguous and this is the only place the ambiguity is resolved: every
|
|
25388
|
+
* bound provider contributes its own rows, and a provider that could not
|
|
25389
|
+
* be reached at all still produces one row saying so. A surface that draws
|
|
25390
|
+
* "no clips" without reading this is drawing a guess.
|
|
25391
|
+
*/
|
|
25392
|
+
listSources: method(object({ deviceId: number() }), array(ClipSourceSchema).readonly(), {
|
|
25393
|
+
kind: "query",
|
|
25394
|
+
auth: "protected"
|
|
25395
|
+
}),
|
|
25396
|
+
getClipPlayback: method(object({
|
|
25397
|
+
deviceId: number(),
|
|
25398
|
+
clipId: string(),
|
|
25399
|
+
/**
|
|
25400
|
+
* Which twin to serve, on the ONE quality scale the system already has
|
|
25401
|
+
* (`CamProfileSchema`). `low | mid` → the sub file, `high` → the main
|
|
25402
|
+
* twin; both ids are already on the row so this never re-searches the
|
|
25403
|
+
* camera. Absent means the provider's own default (the sub file, which
|
|
25404
|
+
* every source is measured to hold).
|
|
25405
|
+
*
|
|
25406
|
+
* `auto` is deliberately NOT accepted here: a stored file has no
|
|
25407
|
+
* broker session, so the adaptive tier cannot be resolved for it. The
|
|
25408
|
+
* viewer resolves `auto` to a profile the same way live does, before
|
|
25409
|
+
* it calls (D549 15).
|
|
25410
|
+
*/
|
|
25411
|
+
profile: CamProfileSchema.optional()
|
|
25412
|
+
}), ClipPlaybackSchema, {
|
|
25413
|
+
kind: "query",
|
|
25414
|
+
auth: "protected"
|
|
25415
|
+
}),
|
|
25416
|
+
/**
|
|
25417
|
+
* This clip's BYTES, base64, bounded — the by-handle read a clip EXPORT
|
|
25418
|
+
* pulls once (D558).
|
|
25419
|
+
*
|
|
25420
|
+
* `getClipPlayback` is the right answer for a player: it hands back a URL
|
|
25421
|
+
* on a plane the hub serves `access:'authenticated'`, which a browser and a
|
|
25422
|
+
* viewer session satisfy. It is the wrong answer for another ADDON: the
|
|
25423
|
+
* hub's proxy in front of that plane takes only a user credential, which
|
|
25424
|
+
* an addon does not hold, so a recorder that wants a camera's clip cannot
|
|
25425
|
+
* fetch that URL. This method is the shape that answer forced — the same
|
|
25426
|
+
* one (and the same bound) as `recordingExport.readExportBytes`.
|
|
25427
|
+
*
|
|
25428
|
+
* **It is no longer the only seam.** Until D613 there was no addon→addon
|
|
25429
|
+
* byte transport at all; there is now
|
|
25430
|
+
* ({@link offerClipBytes}, over `ctx.peerBytes`), it holds nothing on
|
|
25431
|
+
* either side, and it is what a clip EXPORT uses. This method remains for
|
|
25432
|
+
* a consumer that genuinely wants the bytes in hand, and as the named
|
|
25433
|
+
* fallback for a provider not yet redeployed.
|
|
25434
|
+
*
|
|
25435
|
+
* Routing needs no `provider` pin: the id is source-prefixed and
|
|
25436
|
+
* self-contained, so `device-collection-dispatch.ts` rule 3 hands the call
|
|
25437
|
+
* to the source that claims it — and an id nobody claims is REFUSED rather
|
|
25438
|
+
* than answered by another source.
|
|
25439
|
+
*
|
|
25440
|
+
* The producer reuses the fetch path it already has, completion rules
|
|
25441
|
+
* included: a clip is taken by cmd 5 and finished on a short idle window
|
|
25442
|
+
* whose result is PROVED against the catalog row's own span, retried once
|
|
25443
|
+
* when it comes up materially short, and served-and-named when it is still
|
|
25444
|
+
* short (D568). A second fetch with different completion rules is exactly
|
|
25445
|
+
* the second authority D558 refuses to create.
|
|
25446
|
+
*
|
|
25447
|
+
* Every refusal THROWS with its reason and none of them is silent — the
|
|
25448
|
+
* sleep gate (decided before `getApi()`, liftable only by
|
|
25449
|
+
* {@link ClipWakeSchema}), a catalog row nobody claims, a clip with no
|
|
25450
|
+
* bytes behind it, a mux that failed, and the size bound. The caller turns
|
|
25451
|
+
* that reason into an operator-facing one; a truncated file is never an
|
|
25452
|
+
* answer.
|
|
25453
|
+
*/
|
|
25454
|
+
readClipBytes: optionalMethod(object({
|
|
25455
|
+
deviceId: number(),
|
|
25456
|
+
clipId: string().min(1),
|
|
25457
|
+
/**
|
|
25458
|
+
* WHICH provider holds the bytes — the `addonId` a
|
|
25459
|
+
* {@link ClipSourceSchema} row carries. **Required**, for the reason
|
|
25460
|
+
* `listClips` states one method above and for a second one this method
|
|
25461
|
+
* learned the hard way.
|
|
25462
|
+
*
|
|
25463
|
+
* The caller is the stream broker, and the broker lives in
|
|
25464
|
+
* `addon-pipeline` — the same addon that registers the RECORDER's
|
|
25465
|
+
* videoclips provider. With no provider named, the in-process
|
|
25466
|
+
* resolution handed it its own sibling, which implements this method
|
|
25467
|
+
* solely to explain that an analytics visit has no file behind it. So
|
|
25468
|
+
* every onboard clip answered with that sentence, a flash of video and
|
|
25469
|
+
* nothing more, while the SAME call from outside the addon reached the
|
|
25470
|
+
* right provider and returned the bytes (measured on 3628,
|
|
25471
|
+
* 2026-09-21).
|
|
25472
|
+
*/
|
|
25473
|
+
provider: string().min(1),
|
|
25474
|
+
/**
|
|
25475
|
+
* Which twin to fetch, on the mapping D549 15 already fixed:
|
|
25476
|
+
* `low | mid` → the sub file, `high` → the main twin. `auto` is not
|
|
25477
|
+
* accepted here for the same reason it is not accepted by
|
|
25478
|
+
* `getClipPlayback` — a stored file has no broker session, so the
|
|
25479
|
+
* adaptive tier cannot be resolved for it.
|
|
25480
|
+
*/
|
|
25481
|
+
profile: CamProfileSchema.optional(),
|
|
25482
|
+
/**
|
|
25483
|
+
* The CALLER's byte bound, so an over-size clip is refused before it is
|
|
25484
|
+
* read and encoded rather than after. Capped by
|
|
25485
|
+
* {@link VIDEOCLIPS_MAX_READ_BYTES} whatever is passed; absent means
|
|
25486
|
+
* that ceiling.
|
|
25487
|
+
*/
|
|
25488
|
+
maxBytes: number().int().positive().optional(),
|
|
25489
|
+
/**
|
|
25490
|
+
* The operator's authorisation to wake a sleeping camera for this
|
|
25491
|
+
* read. Absent — the default — means a sleeping standalone battery
|
|
25492
|
+
* camera is REFUSED by name, before any session is opened.
|
|
25493
|
+
*/
|
|
25494
|
+
wake: ClipWakeSchema.optional()
|
|
25495
|
+
}), ClipBytesSchema, {
|
|
25496
|
+
kind: "query",
|
|
25497
|
+
auth: "protected"
|
|
25498
|
+
}),
|
|
25499
|
+
/**
|
|
25500
|
+
* Where this clip's finished bytes can be TAKEN — the by-handle read a
|
|
25501
|
+
* clip EXPORT pulls, over the addon→addon byte transport (D613).
|
|
25502
|
+
*
|
|
25503
|
+
* This is {@link readClipBytes} with the envelope removed. Same gates,
|
|
25504
|
+
* same vocabulary, same completion rules, same `served` contract — the
|
|
25505
|
+
* only difference is that the bytes travel over a one-shot loopback
|
|
25506
|
+
* socket instead of inside a base64 field, so neither side holds the
|
|
25507
|
+
* payload whole and the 50 MiB refusal on a long `high` twin stops
|
|
25508
|
+
* existing. The bound that remains is
|
|
25509
|
+
* {@link VIDEOCLIPS_MAX_OFFER_BYTES}, and it bounds the PRODUCER's own
|
|
25510
|
+
* copy rather than the transport.
|
|
25511
|
+
*
|
|
25512
|
+
* **The ticket is loopback and same-host.** A provider on an agent mints a
|
|
25513
|
+
* URL that means nothing on the hub, and `ctx.peerBytes.open` refuses it
|
|
25514
|
+
* `cross-node` by name rather than dialling whatever else holds that port
|
|
25515
|
+
* here. A consumer that can be on the other side of a node boundary from
|
|
25516
|
+
* its provider must be able to read that refusal and say so; it must not
|
|
25517
|
+
* treat it as "no bytes".
|
|
25518
|
+
*
|
|
25519
|
+
* **A ticket is a one-shot bearer credential with a seconds-long life.**
|
|
25520
|
+
* Take it immediately, never persist it, never log its `url`. An untaken
|
|
25521
|
+
* ticket costs the provider one map entry until its TTL, and outstanding
|
|
25522
|
+
* tickets are bounded — which is also what makes a per-frame misuse of
|
|
25523
|
+
* this method refuse by name rather than work slowly (D9/D18: this is a
|
|
25524
|
+
* by-handle fetch of finished media, not a frame pipe).
|
|
25525
|
+
*
|
|
25526
|
+
* Optional on the provider for the same reason `readClipBytes` is: a
|
|
25527
|
+
* source with no camera socket behind it has no bytes. A provider that
|
|
25528
|
+
* predates this method answers `NOT_IMPLEMENTED`, and a consumer may fall
|
|
25529
|
+
* back to `readClipBytes` — but it says so in the log, with the deploy
|
|
25530
|
+
* hint, because that fallback re-imposes the 50 MiB refusal and an
|
|
25531
|
+
* operator who sees `too-large-to-transfer` after this shipped is looking
|
|
25532
|
+
* at a stale addon, not at a clip that cannot be exported.
|
|
25533
|
+
*/
|
|
25534
|
+
offerClipBytes: optionalMethod(object({
|
|
25535
|
+
deviceId: number(),
|
|
25536
|
+
clipId: string().min(1),
|
|
25537
|
+
/** WHICH provider holds the bytes — see `readClipBytes.provider`. */
|
|
25538
|
+
provider: string().min(1),
|
|
25539
|
+
/** Which twin — `low | mid` → the sub file, `high` → the main twin. */
|
|
25540
|
+
profile: CamProfileSchema.optional(),
|
|
25541
|
+
/**
|
|
25542
|
+
* The CALLER's byte bound, so an over-size clip is refused before the
|
|
25543
|
+
* camera is touched rather than after. Capped by
|
|
25544
|
+
* {@link VIDEOCLIPS_MAX_OFFER_BYTES} whatever is passed; absent means
|
|
25545
|
+
* that ceiling.
|
|
25546
|
+
*/
|
|
25547
|
+
maxBytes: number().int().positive().optional(),
|
|
25548
|
+
/**
|
|
25549
|
+
* The operator's authorisation to wake a sleeping camera for this
|
|
25550
|
+
* read. Absent — the default — means a sleeping standalone battery
|
|
25551
|
+
* camera is REFUSED by name, before any session is opened.
|
|
25552
|
+
*/
|
|
25553
|
+
wake: ClipWakeSchema.optional()
|
|
25554
|
+
}), ClipBytesOfferSchema, {
|
|
25555
|
+
kind: "query",
|
|
25556
|
+
auth: "protected"
|
|
25557
|
+
}),
|
|
25558
|
+
/**
|
|
25559
|
+
* Where this clip's STREAM can be dialled (D597) — the forward-only
|
|
25560
|
+
* fMP4 the provider writes from the first muxed byte, for the broker to
|
|
25561
|
+
* play through the same WebRTC session as recorded footage, with the
|
|
25562
|
+
* first frame at the first fragment instead of after the last byte.
|
|
25563
|
+
*
|
|
25564
|
+
* Nothing is fetched here: the answer is a one-shot URL on the
|
|
25565
|
+
* provider's own listener, and the camera is touched when it is dialled.
|
|
25566
|
+
* The gates the file path runs (`readClipBytes`) run on the dial too —
|
|
25567
|
+
* the id, the row, a clip still being written — and refuse in the same
|
|
25568
|
+
* vocabulary, THROWN with `clipExportFailure(<token>, <prose>)`. Every
|
|
25569
|
+
* profile is served (D599) and the answer states which twin it carries;
|
|
25570
|
+
* `high` is not refused. Optional on the provider for the same reason
|
|
25571
|
+
* `readClipBytes` is: a source with no camera socket behind it (the
|
|
25572
|
+
* recorder's analytics window, a HomeKit tee) has no stream.
|
|
25573
|
+
*/
|
|
25574
|
+
dialClipStream: optionalMethod(object({
|
|
25575
|
+
deviceId: number(),
|
|
25576
|
+
clipId: string().min(1),
|
|
25577
|
+
/** WHICH provider holds the camera — see `readClipBytes.provider`. */
|
|
25578
|
+
provider: string().min(1),
|
|
25579
|
+
/** Which twin — `high` → the main file, everything else the sub (D599). */
|
|
25580
|
+
profile: CamProfileSchema.optional(),
|
|
25581
|
+
/**
|
|
25582
|
+
* Which audio codec the caller's session negotiated, so the provider
|
|
25583
|
+
* can mux it (D600). The ask travels on the DIAL because the mux is
|
|
25584
|
+
* spawned once, before the first byte, and cannot gain a track later.
|
|
25585
|
+
*
|
|
25586
|
+
* ABSENT IS `none`, never a guess: a caller that does not state an
|
|
25587
|
+
* ask is served the video-only stream that shipped first, so a broker
|
|
25588
|
+
* older than this field keeps working unchanged (D382 — an unstated
|
|
25589
|
+
* ask is zero).
|
|
25590
|
+
*/
|
|
25591
|
+
audio: ClipStreamAudioSchema.optional()
|
|
25592
|
+
}), ClipStreamDialSchema, {
|
|
25593
|
+
kind: "query",
|
|
25594
|
+
auth: "protected"
|
|
25595
|
+
}),
|
|
25596
|
+
/**
|
|
25597
|
+
* What a surface may DRAW for this provider's clips: the rates it can be
|
|
25598
|
+
* played at, whether scrub is served, how far a position may be moved,
|
|
25599
|
+
* and whether a backward frame-step means anything (D612).
|
|
25600
|
+
*
|
|
25601
|
+
* **The only authority.** The per-clip `clipTransport` server message
|
|
25602
|
+
* that used to carry the same answer was removed: the broker's transport
|
|
25603
|
+
* choice reads nothing that varies per clip, so the clip level had no
|
|
25604
|
+
* information the provider does not already have, and two channels that
|
|
25605
|
+
* can disagree are worse than one (D62).
|
|
25606
|
+
*
|
|
25607
|
+
* Asked per camera and per provider, so it must stay CHEAP — it is a
|
|
25608
|
+
* statement about wiring, answered from a constant, never a call to the
|
|
25609
|
+
* camera. A provider answers with one of {@link CLIP_PLAYBACK_OPTIONS}
|
|
25610
|
+
* and never composes an envelope of its own.
|
|
25611
|
+
*
|
|
25612
|
+
* Optional, and absence is load-bearing: a provider that has not answered
|
|
25613
|
+
* has not restricted anything, and a viewer reads it as the freedom it
|
|
25614
|
+
* always had. See D612 on the rollout order that absence implies.
|
|
25615
|
+
*/
|
|
25616
|
+
getPlaybackOptions: optionalMethod(object({
|
|
25617
|
+
deviceId: number(),
|
|
25618
|
+
/** WHICH provider to ask — the `addonId` a {@link ClipSourceSchema}
|
|
25619
|
+
* row carries. Required for the same reason `listClips` requires it:
|
|
25620
|
+
* a collection cap has no "the bound one" to resolve to (D554). */
|
|
25621
|
+
provider: string().min(1)
|
|
25622
|
+
}), ClipPlaybackOptionsSchema, {
|
|
25623
|
+
kind: "query",
|
|
25624
|
+
auth: "protected"
|
|
25625
|
+
})
|
|
25626
|
+
}
|
|
25627
|
+
};
|
|
25628
|
+
/**
|
|
25629
|
+
* NOTE ON PLACEMENT: this block lives AFTER the capability definition on
|
|
25630
|
+
* purpose. `scripts/lib/parse-cap.ts` finds a cap by the FIRST
|
|
25631
|
+
* `export const <X> = {` in the file, so an object literal declared above
|
|
25632
|
+
* `videoclipsCapability` is silently taken for the capability itself and
|
|
25633
|
+
* codegen emits a `DeviceProxy` entry that does not type-check. Found the
|
|
25634
|
+
* hard way, 2026-09-23.
|
|
25635
|
+
*/
|
|
25636
|
+
/**
|
|
25637
|
+
* The two envelopes, spelled ONCE.
|
|
25638
|
+
*
|
|
25639
|
+
* A provider answers with the entry for the transport its own wiring buys —
|
|
25640
|
+
* `stream` if it implements `dialClipStream`, `file` if it implements only
|
|
25641
|
+
* `readClipBytes` — and never composes one of its own. One table is what
|
|
25642
|
+
* makes "the provider may not promise what no clip can be given" structural
|
|
25643
|
+
* instead of a discipline: there is nothing else to promise.
|
|
25644
|
+
*/
|
|
25645
|
+
var CLIP_PLAYBACK_OPTIONS = {
|
|
25646
|
+
stream: {
|
|
25647
|
+
transport: "stream",
|
|
25648
|
+
seek: "forward",
|
|
25649
|
+
stepBack: false,
|
|
25650
|
+
scrub: false,
|
|
25651
|
+
rates: CLIP_BROKER_PACED_RATES
|
|
25652
|
+
},
|
|
25217
25653
|
/**
|
|
25218
|
-
*
|
|
25219
|
-
* can mux it (D600). The ask travels on the DIAL because the mux is
|
|
25220
|
-
* spawned once, before the first byte, and cannot gain a track later.
|
|
25654
|
+
* A `stream` whose SOURCE runs at realtime — a Hikvision RTSP replay.
|
|
25221
25655
|
*
|
|
25222
|
-
*
|
|
25223
|
-
*
|
|
25224
|
-
*
|
|
25225
|
-
*
|
|
25656
|
+
* Same verbs as `stream`, a shorter rate ladder, and the difference is a
|
|
25657
|
+
* measurement rather than a preference: see
|
|
25658
|
+
* {@link CLIP_REALTIME_SOURCE_RATES}. A third entry rather than a parameter,
|
|
25659
|
+
* because a provider must still be able to do nothing but NAME one of these.
|
|
25226
25660
|
*/
|
|
25227
|
-
|
|
25228
|
-
|
|
25229
|
-
|
|
25230
|
-
|
|
25231
|
-
|
|
25661
|
+
realtimeStream: {
|
|
25662
|
+
transport: "stream",
|
|
25663
|
+
seek: "forward",
|
|
25664
|
+
stepBack: false,
|
|
25665
|
+
scrub: false,
|
|
25666
|
+
rates: CLIP_REALTIME_SOURCE_RATES
|
|
25667
|
+
},
|
|
25668
|
+
file: {
|
|
25669
|
+
transport: "file",
|
|
25670
|
+
seek: "free",
|
|
25671
|
+
stepBack: true,
|
|
25672
|
+
scrub: true,
|
|
25673
|
+
rates: CLIP_BROKER_PACED_RATES
|
|
25674
|
+
}
|
|
25675
|
+
};
|
|
25232
25676
|
/**
|
|
25233
25677
|
* Optional client-side hints sent at session creation to help the provider
|
|
25234
25678
|
* pick the best native source. All fields optional — a viewer that knows
|
|
@@ -27729,6 +28173,108 @@ var recordingOnboardCapability = {
|
|
|
27729
28173
|
* whether persisting is worth a SQLite commit. */
|
|
27730
28174
|
volatileStateFields: ["lastFetchedAt"]
|
|
27731
28175
|
};
|
|
28176
|
+
/** Minutes in a day. `endMinute === 1440` means "to end of day". */
|
|
28177
|
+
var MINUTES_PER_DAY = 1440;
|
|
28178
|
+
/**
|
|
28179
|
+
* Day-of-week names in the cap's index order (0 = Monday), for messages
|
|
28180
|
+
* an operator reads and for Hikvision's `<DayOfWeek>` element.
|
|
28181
|
+
*/
|
|
28182
|
+
var DAY_NAMES = [
|
|
28183
|
+
"Monday",
|
|
28184
|
+
"Tuesday",
|
|
28185
|
+
"Wednesday",
|
|
28186
|
+
"Thursday",
|
|
28187
|
+
"Friday",
|
|
28188
|
+
"Saturday",
|
|
28189
|
+
"Sunday"
|
|
28190
|
+
];
|
|
28191
|
+
/**
|
|
28192
|
+
* Does `patch` ask this camera for something it cannot do?
|
|
28193
|
+
*
|
|
28194
|
+
* Returns the operator-readable reason, or `null` when every field in
|
|
28195
|
+
* the patch is within what `options` says this camera accepts. A
|
|
28196
|
+
* provider calls this BEFORE touching the camera and throws the string:
|
|
28197
|
+
* refusing is the point, and refusing identically on both vendors is why
|
|
28198
|
+
* this is one function.
|
|
28199
|
+
*
|
|
28200
|
+
* The order of checks is the order an operator would read them: the
|
|
28201
|
+
* scalar knobs first, then the schedule, because a schedule complaint is
|
|
28202
|
+
* longer and a scalar one is usually the real problem.
|
|
28203
|
+
*/
|
|
28204
|
+
function describeOnboardRefusal(options, patch) {
|
|
28205
|
+
if (patch.enabled !== void 0 && !options.enabled.writable) return unwritable("the master recording switch", options.enabled.reason);
|
|
28206
|
+
if (patch.overwriteWhenFull !== void 0 && !options.overwriteWhenFull.writable) return unwritable("overwrite-when-full", options.overwriteWhenFull.reason);
|
|
28207
|
+
const preRefusal = refuseNumeric("pre-record seconds", patch.preRecordSec, options.preRecordSec, options.preRecordSecRange, options.preRecordSecAllowed);
|
|
28208
|
+
if (preRefusal) return preRefusal;
|
|
28209
|
+
const postRefusal = refuseNumeric("post-record seconds", patch.postRecordSec, options.postRecordSec, options.postRecordSecRange, options.postRecordSecAllowed);
|
|
28210
|
+
if (postRefusal) return postRefusal;
|
|
28211
|
+
const segmentRefusal = refuseNumeric("segment length (minutes)", patch.segmentMinutes, options.segmentMinutes, options.segmentMinutesRange, options.segmentMinutesAllowed);
|
|
28212
|
+
if (segmentRefusal) return segmentRefusal;
|
|
28213
|
+
if (patch.windows !== void 0) {
|
|
28214
|
+
const scheduleRefusal = refuseSchedule(options, patch.windows);
|
|
28215
|
+
if (scheduleRefusal) return scheduleRefusal;
|
|
28216
|
+
}
|
|
28217
|
+
return null;
|
|
28218
|
+
}
|
|
28219
|
+
function unwritable(what, reason) {
|
|
28220
|
+
return reason ? `this camera cannot change ${what}: ${reason}` : `this camera cannot change ${what}`;
|
|
28221
|
+
}
|
|
28222
|
+
function refuseNumeric(what, value, support, range, allowed) {
|
|
28223
|
+
if (value === void 0) return null;
|
|
28224
|
+
if (!support.writable) return unwritable(what, support.reason);
|
|
28225
|
+
if (!Number.isFinite(value)) return `${what} must be a number, got ${String(value)}`;
|
|
28226
|
+
if (allowed) {
|
|
28227
|
+
if (allowed.values.includes(value)) return null;
|
|
28228
|
+
const say = (v) => allowed.sentinel !== void 0 && v === allowed.sentinel.value ? allowed.sentinel.meaning === "no-limit" ? "no limit" : "off" : String(v);
|
|
28229
|
+
return `this camera accepts ${what} only as ${allowed.values.map(say).join(", ")}; ${say(value)} is not one of them`;
|
|
28230
|
+
}
|
|
28231
|
+
if (!range) return null;
|
|
28232
|
+
if (value < range.min || value > range.max) return `this camera accepts ${what} between ${String(range.min)} and ${String(range.max)}; ${String(value)} is outside that`;
|
|
28233
|
+
if (range.step > 0 && (value - range.min) % range.step !== 0) return `this camera accepts ${what} in steps of ${String(range.step)} from ${String(range.min)}; ${String(value)} is not one of them`;
|
|
28234
|
+
return null;
|
|
28235
|
+
}
|
|
28236
|
+
function refuseSchedule(options, windows) {
|
|
28237
|
+
const schedule = options.schedule;
|
|
28238
|
+
if (!schedule.support.writable) return unwritable("the recording schedule", schedule.support.reason);
|
|
28239
|
+
const allowed = new Set(schedule.triggers);
|
|
28240
|
+
for (const window of windows) {
|
|
28241
|
+
if (!allowed.has(window.trigger)) {
|
|
28242
|
+
const offer = schedule.triggers.length > 0 ? schedule.triggers.join(", ") : "none";
|
|
28243
|
+
return `this camera cannot record on "${window.trigger}"; it records on: ${offer}`;
|
|
28244
|
+
}
|
|
28245
|
+
if (window.endMinute <= window.startMinute) return `a window on ${dayName(window.day)} ends at or before it starts (${String(window.startMinute)} → ${String(window.endMinute)})`;
|
|
28246
|
+
const step = schedule.granularityMinutes;
|
|
28247
|
+
if (step > 1 && (window.startMinute % step !== 0 || window.endMinute % step !== 0)) return `this camera's schedule only moves in ${String(step)}-minute steps; the ${dayName(window.day)} window ${formatMinutes(window.startMinute)}–${formatMinutes(window.endMinute)} is not aligned to them`;
|
|
28248
|
+
}
|
|
28249
|
+
if (!schedule.supportsOverlappingTriggers) {
|
|
28250
|
+
const clash = findOverlapWithDifferentTrigger(windows);
|
|
28251
|
+
if (clash) return `this camera stores one trigger per time range, so "${clash.a.trigger}" and "${clash.b.trigger}" cannot both cover ${dayName(clash.a.day)} ${formatMinutes(Math.max(clash.a.startMinute, clash.b.startMinute))}–${formatMinutes(Math.min(clash.a.endMinute, clash.b.endMinute))}`;
|
|
28252
|
+
}
|
|
28253
|
+
return null;
|
|
28254
|
+
}
|
|
28255
|
+
function findOverlapWithDifferentTrigger(windows) {
|
|
28256
|
+
for (let i = 0; i < windows.length; i += 1) for (let j = i + 1; j < windows.length; j += 1) {
|
|
28257
|
+
const a = windows[i];
|
|
28258
|
+
const b = windows[j];
|
|
28259
|
+
if (!a || !b) continue;
|
|
28260
|
+
if (a.day !== b.day) continue;
|
|
28261
|
+
if (a.trigger === b.trigger) continue;
|
|
28262
|
+
if (a.startMinute < b.endMinute && b.startMinute < a.endMinute) return {
|
|
28263
|
+
a,
|
|
28264
|
+
b
|
|
28265
|
+
};
|
|
28266
|
+
}
|
|
28267
|
+
return null;
|
|
28268
|
+
}
|
|
28269
|
+
function dayName(day) {
|
|
28270
|
+
return DAY_NAMES[day] ?? `day ${String(day)}`;
|
|
28271
|
+
}
|
|
28272
|
+
/** `510` → `08:30`. For messages, not for the wire. */
|
|
28273
|
+
function formatMinutes(minute) {
|
|
28274
|
+
const hour = Math.floor(minute / 60);
|
|
28275
|
+
const rest = minute % 60;
|
|
28276
|
+
return `${String(hour).padStart(2, "0")}:${String(rest).padStart(2, "0")}`;
|
|
28277
|
+
}
|
|
27732
28278
|
/**
|
|
27733
28279
|
* Generic device-level status snapshot. Auto-registered by `BaseDevice`
|
|
27734
28280
|
* for every device, regardless of provider — the kernel needs a uniform
|
|
@@ -33668,6 +34214,10 @@ _enum([
|
|
|
33668
34214
|
"too-large-to-transfer",
|
|
33669
34215
|
"wake-expired"
|
|
33670
34216
|
]);
|
|
34217
|
+
/** `<token>: <prose>` — the ONE composition of a clip export's failure string. */
|
|
34218
|
+
function clipExportFailure(code, detail) {
|
|
34219
|
+
return detail.length > 0 ? `${code}: ${detail}` : code;
|
|
34220
|
+
}
|
|
33671
34221
|
/** Candidate download URLs (LAN first, then operator extra hosts). */
|
|
33672
34222
|
var ExportDownloadSchema = object({
|
|
33673
34223
|
url: string(),
|
|
@@ -46259,6 +46809,12 @@ Object.freeze({
|
|
|
46259
46809
|
addonId: null,
|
|
46260
46810
|
access: "view"
|
|
46261
46811
|
},
|
|
46812
|
+
"videoclips.getPlaybackOptions": {
|
|
46813
|
+
capName: "videoclips",
|
|
46814
|
+
capScope: "device",
|
|
46815
|
+
addonId: null,
|
|
46816
|
+
access: "view"
|
|
46817
|
+
},
|
|
46262
46818
|
"videoclips.listClips": {
|
|
46263
46819
|
capName: "videoclips",
|
|
46264
46820
|
capScope: "device",
|
|
@@ -46271,6 +46827,12 @@ Object.freeze({
|
|
|
46271
46827
|
addonId: null,
|
|
46272
46828
|
access: "view"
|
|
46273
46829
|
},
|
|
46830
|
+
"videoclips.offerClipBytes": {
|
|
46831
|
+
capName: "videoclips",
|
|
46832
|
+
capScope: "device",
|
|
46833
|
+
addonId: null,
|
|
46834
|
+
access: "view"
|
|
46835
|
+
},
|
|
46274
46836
|
"videoclips.readClipBytes": {
|
|
46275
46837
|
capName: "videoclips",
|
|
46276
46838
|
capScope: "device",
|
|
@@ -48317,6 +48879,11 @@ Object.freeze({
|
|
|
48317
48879
|
form: "single",
|
|
48318
48880
|
optional: false
|
|
48319
48881
|
}],
|
|
48882
|
+
"videoclips.getPlaybackOptions": [{
|
|
48883
|
+
name: "deviceId",
|
|
48884
|
+
form: "single",
|
|
48885
|
+
optional: false
|
|
48886
|
+
}],
|
|
48320
48887
|
"videoclips.listClips": [{
|
|
48321
48888
|
name: "deviceId",
|
|
48322
48889
|
form: "single",
|
|
@@ -48327,6 +48894,11 @@ Object.freeze({
|
|
|
48327
48894
|
form: "single",
|
|
48328
48895
|
optional: false
|
|
48329
48896
|
}],
|
|
48897
|
+
"videoclips.offerClipBytes": [{
|
|
48898
|
+
name: "deviceId",
|
|
48899
|
+
form: "single",
|
|
48900
|
+
optional: false
|
|
48901
|
+
}],
|
|
48330
48902
|
"videoclips.readClipBytes": [{
|
|
48331
48903
|
name: "deviceId",
|
|
48332
48904
|
form: "single",
|
|
@@ -49237,6 +49809,14 @@ var AmcrestCgiClient = class AmcrestCgiClient {
|
|
|
49237
49809
|
* Write config values. Keys are raw Dahua paths (e.g.
|
|
49238
49810
|
* `VideoColor[0][0].Brightness`); brackets are passed through unescaped
|
|
49239
49811
|
* (Dahua requires them literal) and only values are URL-encoded.
|
|
49812
|
+
*
|
|
49813
|
+
* **Every key must belong to the SAME config table.** Measured on an
|
|
49814
|
+
* IP4M-1041B (2026-09-23): `MediaGlobal.PacketLength=6&Record[0].PreRecord=4`
|
|
49815
|
+
* answered `200 OK` and applied only `MediaGlobal`; with the operands
|
|
49816
|
+
* swapped, only `Record` changed. Within one table the write IS atomic —
|
|
49817
|
+
* two keys both land, and one bad key rejects the pair with a body of
|
|
49818
|
+
* `Error` under HTTP 200, which is why success is decided on the body
|
|
49819
|
+
* and never on the status code.
|
|
49240
49820
|
*/
|
|
49241
49821
|
async setConfig(params) {
|
|
49242
49822
|
const query = Object.entries(params).map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`).join("&");
|
|
@@ -49244,6 +49824,78 @@ var AmcrestCgiClient = class AmcrestCgiClient {
|
|
|
49244
49824
|
if (!/\bOK\b/i.test(body)) throw new Error(`setConfig rejected: ${body.trim().slice(0, 200)}`);
|
|
49245
49825
|
}
|
|
49246
49826
|
/**
|
|
49827
|
+
* Read the camera's own storage inventory.
|
|
49828
|
+
*
|
|
49829
|
+
* `storageDevice.cgi?action=getDeviceAllInfo` — verified 200 on an
|
|
49830
|
+
* IP4M-1041B, serving the same rows RPC2's `storage.getDeviceAllInfo`
|
|
49831
|
+
* returns, so this cap needs no RPC2 session. (`action=getCaps` on the
|
|
49832
|
+
* same CGI is 501 on this firmware; the record caps come from
|
|
49833
|
+
* {@link getRecordManagerCaps} instead.)
|
|
49834
|
+
*/
|
|
49835
|
+
async getStorageDeviceAllInfo() {
|
|
49836
|
+
return AmcrestCgiClient.parseTable(await this.getText("/cgi-bin/storageDevice.cgi?action=getDeviceAllInfo"));
|
|
49837
|
+
}
|
|
49838
|
+
/**
|
|
49839
|
+
* Open a recorded FILE for reading — `GET /cgi-bin/RPC_Loadfile<FilePath>`.
|
|
49840
|
+
*
|
|
49841
|
+
* Measured on device 3836 (2026-09-23): **200**, a `content-length` that is
|
|
49842
|
+
* byte-exact against the catalog row's `Length`, TTFB 142–211 ms, 2.87–3.5
|
|
49843
|
+
* MB/s, and a genuine self-contained ISO MP4 with an AAC track. Three
|
|
49844
|
+
* simultaneous pulls of three files all completed byte-exact and nothing
|
|
49845
|
+
* wedged afterwards — the opposite of the Reolink behaviour D611 records.
|
|
49846
|
+
*
|
|
49847
|
+
* Two hazards a caller MUST handle, both measured:
|
|
49848
|
+
*
|
|
49849
|
+
* - **`Range` is ignored.** 200 with the whole body and no `content-range`,
|
|
49850
|
+
* on every form tried. There is no partial read of a recorded file.
|
|
49851
|
+
* - **A path that does not exist closes the TCP socket with zero bytes and
|
|
49852
|
+
* NO HTTP status.** A real path answers 200 on the same endpoint, so the
|
|
49853
|
+
* bare close specifically means "not found" — and it reaches the caller as
|
|
49854
|
+
* a thrown transport error, which must become a NAMED refusal and never an
|
|
49855
|
+
* absence (D611).
|
|
49856
|
+
*
|
|
49857
|
+
* `content-type` is `application/http` — not a media type. It is deliberately
|
|
49858
|
+
* not returned: a consumer that trusted it would mislabel an MP4.
|
|
49859
|
+
*/
|
|
49860
|
+
async openRecordedFile(filePath, timeoutMs) {
|
|
49861
|
+
const encoded = filePath.split("/").map((segment) => encodeURIComponent(segment)).join("/");
|
|
49862
|
+
const res = await this.send(`/cgi-bin/RPC_Loadfile${encoded}`, timeoutMs);
|
|
49863
|
+
const declared = res.headers.get("content-length");
|
|
49864
|
+
const contentLength = declared === null ? null : Number(declared);
|
|
49865
|
+
return {
|
|
49866
|
+
status: res.status,
|
|
49867
|
+
contentLength: contentLength !== null && Number.isFinite(contentLength) && contentLength > 0 ? contentLength : null,
|
|
49868
|
+
body: res.body
|
|
49869
|
+
};
|
|
49870
|
+
}
|
|
49871
|
+
/**
|
|
49872
|
+
* What time the camera thinks it is, VERBATIM — `result=2026-09-23 11:29:27`.
|
|
49873
|
+
*
|
|
49874
|
+
* Its own LOCAL wall clock, with no zone on it, and the only thing that can
|
|
49875
|
+
* say what that clock is: measured 2026-09-23 answering `11:29:27` against a
|
|
49876
|
+
* host UTC of `09:29:27`. Everything the clip catalog sends or reads is in
|
|
49877
|
+
* this clock, so `clip-clock.ts` turns this string into the one offset the
|
|
49878
|
+
* whole clip path uses. Parsed there, never here: a read that failed must
|
|
49879
|
+
* reach the caller as a failure, not as a zero offset that looks like UTC.
|
|
49880
|
+
*/
|
|
49881
|
+
async getCurrentTime() {
|
|
49882
|
+
return await this.getText("/cgi-bin/global.cgi?action=getCurrentTime");
|
|
49883
|
+
}
|
|
49884
|
+
/**
|
|
49885
|
+
* The camera's own statement of what it accepts for recording —
|
|
49886
|
+
* `MaxPreRecordTime`, `PacketLengthRange[0..1]`, `SupportHoliday`.
|
|
49887
|
+
*/
|
|
49888
|
+
async getRecordManagerCaps() {
|
|
49889
|
+
return AmcrestCgiClient.parseTable(await this.getText("/cgi-bin/recordManager.cgi?action=getCaps"));
|
|
49890
|
+
}
|
|
49891
|
+
/**
|
|
49892
|
+
* The camera's own statement of what an event handler accepts —
|
|
49893
|
+
* `RecordLatch[0..1]` is the post-record bound.
|
|
49894
|
+
*/
|
|
49895
|
+
async getEventManagerCaps() {
|
|
49896
|
+
return AmcrestCgiClient.parseTable(await this.getText("/cgi-bin/eventManager.cgi?action=getCaps"));
|
|
49897
|
+
}
|
|
49898
|
+
/**
|
|
49247
49899
|
* Open the Dahua CGI event-manager attach stream — a long-lived
|
|
49248
49900
|
* `multipart/x-mixed-replace` GET that pushes camera events
|
|
49249
49901
|
* (`VideoMotion` Start/Stop, heartbeats, …) until the connection drops
|
|
@@ -49628,6 +50280,689 @@ function capExposureModeToAmcrest(mode) {
|
|
|
49628
50280
|
return mode === "manual" ? 4 : 0;
|
|
49629
50281
|
}
|
|
49630
50282
|
//#endregion
|
|
50283
|
+
//#region src/clips/clip-availability.ts
|
|
50284
|
+
/**
|
|
50285
|
+
* What the storage read means for this SOURCE.
|
|
50286
|
+
*
|
|
50287
|
+
* `null` means "nothing to report" — the card is there and readable, so the
|
|
50288
|
+
* next question may be asked. Every other answer is terminal and the camera is
|
|
50289
|
+
* not searched.
|
|
50290
|
+
*/
|
|
50291
|
+
function availabilityFromStorage(storage) {
|
|
50292
|
+
switch (storage.kind) {
|
|
50293
|
+
case "absent": return {
|
|
50294
|
+
state: "no-storage",
|
|
50295
|
+
reason: storage.reason
|
|
50296
|
+
};
|
|
50297
|
+
case "unknown": return {
|
|
50298
|
+
state: "unreachable",
|
|
50299
|
+
reason: storage.reason
|
|
50300
|
+
};
|
|
50301
|
+
case "present": return null;
|
|
50302
|
+
}
|
|
50303
|
+
}
|
|
50304
|
+
/**
|
|
50305
|
+
* The second gate, and it is not optional.
|
|
50306
|
+
*
|
|
50307
|
+
* Every time this provider sends the camera travels as the camera's LOCAL wall
|
|
50308
|
+
* clock. Without a measured offset there is no window to ask for — and the
|
|
50309
|
+
* failure mode of guessing is not an error, it is footage from two hours away
|
|
50310
|
+
* presented as the footage that was asked for. So a clock that could not be
|
|
50311
|
+
* read is `unreachable` by name, before a search is built.
|
|
50312
|
+
*/
|
|
50313
|
+
function availabilityFromClock(clock) {
|
|
50314
|
+
if (clock !== null) return null;
|
|
50315
|
+
return {
|
|
50316
|
+
state: "unreachable",
|
|
50317
|
+
reason: "The camera did not answer what time it thinks it is, and its recordings are stamped in its own local clock — a window asked for without that offset would return the wrong footage."
|
|
50318
|
+
};
|
|
50319
|
+
}
|
|
50320
|
+
//#endregion
|
|
50321
|
+
//#region src/recording-onboard-mapping.ts
|
|
50322
|
+
/**
|
|
50323
|
+
* Bit position → the cap's trigger vocabulary.
|
|
50324
|
+
*
|
|
50325
|
+
* Every `TimeSection` entry is `"<mask> HH:MM:SS-HH:MM:SS"`, and `<mask>`
|
|
50326
|
+
* is the SET of triggers that time range records on. 3836 carries
|
|
50327
|
+
* `393222` = `0x60006` on all seven weekday rows: bits 1, 2, 17 and 18.
|
|
50328
|
+
*
|
|
50329
|
+
* Only these three bits are NAMED here, because only these three are
|
|
50330
|
+
* defensible from the Dahua CGI specification. Bits 17 and 18 are real
|
|
50331
|
+
* on this firmware and this repo cannot say what they mean — most likely
|
|
50332
|
+
* two IVS/smart kinds, and "most likely" is exactly the guess D608
|
|
50333
|
+
* forbids. An unnamed bit is therefore COUNTED as an unreadable window
|
|
50334
|
+
* and the schedule is reported unwritable (D391), which on 3836 it is.
|
|
50335
|
+
*
|
|
50336
|
+
* To lift that: identify bits 17 and 18 — set one of them alone on a
|
|
50337
|
+
* slot, observe which event actually starts a recording — and add them
|
|
50338
|
+
* here. Nothing else in this module changes.
|
|
50339
|
+
*/
|
|
50340
|
+
var TRIGGER_BY_BIT = {
|
|
50341
|
+
0: "continuous",
|
|
50342
|
+
1: "motion",
|
|
50343
|
+
2: "alarmInput"
|
|
50344
|
+
};
|
|
50345
|
+
/** Triggers an Amcrest schedule can be written with. */
|
|
50346
|
+
var AMCREST_TRIGGERS = [
|
|
50347
|
+
"continuous",
|
|
50348
|
+
"motion",
|
|
50349
|
+
"alarmInput"
|
|
50350
|
+
];
|
|
50351
|
+
/** The cap's trigger vocabulary → its bit position. */
|
|
50352
|
+
var BIT_BY_TRIGGER = {
|
|
50353
|
+
continuous: 0,
|
|
50354
|
+
motion: 1,
|
|
50355
|
+
alarmInput: 2
|
|
50356
|
+
};
|
|
50357
|
+
/**
|
|
50358
|
+
* Dahua row index → the cap's day index.
|
|
50359
|
+
*
|
|
50360
|
+
* **Layout: row 0 is SUNDAY**, which the Dahua CGI specification states
|
|
50361
|
+
* and every Dahua/Amcrest UI renders Sunday-first; the cap's day 0 is
|
|
50362
|
+
* Monday (ISO). This is an ASSUMPTION in exactly the sense D608 records
|
|
50363
|
+
* for Reolink's mask: all seven weekday rows on 3836 are identical, and a
|
|
50364
|
+
* uniform schedule reads the same under either layout, so the reads
|
|
50365
|
+
* shipped here are exact and the assumption is unfalsifiable from this
|
|
50366
|
+
* camera. {@link weekdayRowsAreUniform} names the case. To settle it: set
|
|
50367
|
+
* one weekday only from the Amcrest app and re-read `Record`.
|
|
50368
|
+
*/
|
|
50369
|
+
function rowToCapDay(row) {
|
|
50370
|
+
return (row + 6) % 7;
|
|
50371
|
+
}
|
|
50372
|
+
/** The cap's day index → the Dahua row index. Inverse of {@link rowToCapDay}. */
|
|
50373
|
+
function capDayToRow(day) {
|
|
50374
|
+
return (day + 1) % 7;
|
|
50375
|
+
}
|
|
50376
|
+
/** The Dahua key for one schedule slot. */
|
|
50377
|
+
function timeSectionKey(channel, row, slot) {
|
|
50378
|
+
return `Record[${String(channel)}].TimeSection[${String(row)}][${String(slot)}]`;
|
|
50379
|
+
}
|
|
50380
|
+
/**
|
|
50381
|
+
* A number the camera stated, or null when it did not state one.
|
|
50382
|
+
*
|
|
50383
|
+
* Never 0 for a missing or unparseable value: a measurement that failed
|
|
50384
|
+
* is not a measurement (D393). `TotalBytes` arrives as
|
|
50385
|
+
* `134202327040.000000` over CGI, so this parses a float and does not
|
|
50386
|
+
* demand an integer spelling.
|
|
50387
|
+
*/
|
|
50388
|
+
function numberAt(table, key) {
|
|
50389
|
+
const raw = table.get(key);
|
|
50390
|
+
if (raw === void 0 || raw.trim() === "") return null;
|
|
50391
|
+
const value = Number(raw);
|
|
50392
|
+
return Number.isFinite(value) ? value : null;
|
|
50393
|
+
}
|
|
50394
|
+
/** A boolean the camera stated, or null when it did not state one. */
|
|
50395
|
+
function booleanAt(table, key) {
|
|
50396
|
+
const raw = table.get(key);
|
|
50397
|
+
if (raw === void 0) return null;
|
|
50398
|
+
if (raw === "true") return true;
|
|
50399
|
+
if (raw === "false") return false;
|
|
50400
|
+
return null;
|
|
50401
|
+
}
|
|
50402
|
+
var BYTES_PER_MB = 1024 * 1024;
|
|
50403
|
+
/** Bytes → whole MB, or null when the byte count was never stated (D393). */
|
|
50404
|
+
function toMegabytes(bytes) {
|
|
50405
|
+
if (bytes === null) return null;
|
|
50406
|
+
return Math.round(bytes / BYTES_PER_MB);
|
|
50407
|
+
}
|
|
50408
|
+
var VOLUME_STATUS_BY_STATE = {
|
|
50409
|
+
Success: "ok",
|
|
50410
|
+
Running: "ok",
|
|
50411
|
+
Error: "error",
|
|
50412
|
+
Unformatted: "unformatted",
|
|
50413
|
+
NoExist: "offline",
|
|
50414
|
+
Offline: "offline",
|
|
50415
|
+
Sleeping: "ok"
|
|
50416
|
+
};
|
|
50417
|
+
/**
|
|
50418
|
+
* Read `storageDevice.cgi?action=getDeviceAllInfo`.
|
|
50419
|
+
*
|
|
50420
|
+
* Verbatim from 3836 with the card fitted:
|
|
50421
|
+
*
|
|
50422
|
+
* ```
|
|
50423
|
+
* list.info[0].Name=/dev/mmc0
|
|
50424
|
+
* list.info[0].State=Success
|
|
50425
|
+
* list.info[0].Detail[0].Path=/mnt/sd
|
|
50426
|
+
* list.info[0].Detail[0].TotalBytes=134202327040.000000
|
|
50427
|
+
* list.info[0].Detail[0].UsedBytes=345309184.000000
|
|
50428
|
+
* list.info[0].Detail[0].Type=ReadWrite
|
|
50429
|
+
* list.info[0].Detail[0].IsError=false
|
|
50430
|
+
* ```
|
|
50431
|
+
*
|
|
50432
|
+
* EVERY detail row is returned, not the first usable one — an operator
|
|
50433
|
+
* asking what is in this camera is owed the camera's whole answer. A
|
|
50434
|
+
* table with no `info` row at all is `absent`: that is what a camera with
|
|
50435
|
+
* no card answers, and it is the read that turns
|
|
50436
|
+
* {@link RecordingOnboardStatus.recordingToNowhere} back on. An
|
|
50437
|
+
* unreadable answer is `unknown`, never folded into `absent` (D393).
|
|
50438
|
+
*/
|
|
50439
|
+
function parseOnboardStorage(table) {
|
|
50440
|
+
const deviceIndices = /* @__PURE__ */ new Set();
|
|
50441
|
+
for (const key of table.keys()) {
|
|
50442
|
+
const match = /^list\.info\[(\d+)\]\./.exec(key);
|
|
50443
|
+
if (match?.[1] !== void 0) deviceIndices.add(Number(match[1]));
|
|
50444
|
+
}
|
|
50445
|
+
if (deviceIndices.size === 0) return {
|
|
50446
|
+
kind: "absent",
|
|
50447
|
+
reason: "The camera reports no storage device — there is no card in it."
|
|
50448
|
+
};
|
|
50449
|
+
const volumes = [];
|
|
50450
|
+
for (const device of [...deviceIndices].toSorted((a, b) => a - b)) {
|
|
50451
|
+
const prefix = `list.info[${String(device)}]`;
|
|
50452
|
+
const name = table.get(`${prefix}.Name`) ?? `device ${String(device)}`;
|
|
50453
|
+
const state = table.get(`${prefix}.State`);
|
|
50454
|
+
const detailIndices = /* @__PURE__ */ new Set();
|
|
50455
|
+
for (const key of table.keys()) {
|
|
50456
|
+
const match = new RegExp(`^list\\.info\\[${String(device)}\\]\\.Detail\\[(\\d+)\\]\\.`).exec(key);
|
|
50457
|
+
if (match?.[1] !== void 0) detailIndices.add(Number(match[1]));
|
|
50458
|
+
}
|
|
50459
|
+
if (detailIndices.size === 0) {
|
|
50460
|
+
volumes.push({
|
|
50461
|
+
id: name,
|
|
50462
|
+
label: name,
|
|
50463
|
+
status: statusOf(state, null),
|
|
50464
|
+
capacityMb: null,
|
|
50465
|
+
freeMb: null
|
|
50466
|
+
});
|
|
50467
|
+
continue;
|
|
50468
|
+
}
|
|
50469
|
+
for (const detail of [...detailIndices].toSorted((a, b) => a - b)) {
|
|
50470
|
+
const dPrefix = `${prefix}.Detail[${String(detail)}]`;
|
|
50471
|
+
const path = table.get(`${dPrefix}.Path`);
|
|
50472
|
+
const total = numberAt(table, `${dPrefix}.TotalBytes`);
|
|
50473
|
+
const used = numberAt(table, `${dPrefix}.UsedBytes`);
|
|
50474
|
+
const type = table.get(`${dPrefix}.Type`);
|
|
50475
|
+
const isError = booleanAt(table, `${dPrefix}.IsError`);
|
|
50476
|
+
const volume = {
|
|
50477
|
+
id: path === void 0 ? `${name}#${String(detail)}` : `${name}:${path}`,
|
|
50478
|
+
...path === void 0 ? {} : { label: path },
|
|
50479
|
+
status: statusOf(state, isError),
|
|
50480
|
+
capacityMb: toMegabytes(total),
|
|
50481
|
+
freeMb: total === null || used === null ? null : toMegabytes(total - used),
|
|
50482
|
+
...type === void 0 ? {} : { writable: type === "ReadWrite" }
|
|
50483
|
+
};
|
|
50484
|
+
volumes.push(volume);
|
|
50485
|
+
}
|
|
50486
|
+
}
|
|
50487
|
+
if (!volumes.some((volume) => volume.status === "ok")) return {
|
|
50488
|
+
kind: "absent",
|
|
50489
|
+
reason: "The camera lists storage, but no volume it can write to — the card is missing, unformatted or failed."
|
|
50490
|
+
};
|
|
50491
|
+
return {
|
|
50492
|
+
kind: "present",
|
|
50493
|
+
volumes
|
|
50494
|
+
};
|
|
50495
|
+
}
|
|
50496
|
+
function statusOf(state, isError) {
|
|
50497
|
+
if (isError === true) return "error";
|
|
50498
|
+
if (state === void 0) return "unknown";
|
|
50499
|
+
return VOLUME_STATUS_BY_STATE[state] ?? "unknown";
|
|
50500
|
+
}
|
|
50501
|
+
/**
|
|
50502
|
+
* `"393222 00:00:00-23:59:59"` → its three parts, or null when the slot
|
|
50503
|
+
* is not something this reader understands.
|
|
50504
|
+
*
|
|
50505
|
+
* `23:59:59` is the firmware's spelling of end-of-day and becomes 1440,
|
|
50506
|
+
* exactly as Hikvision's `24:00` does — collapsing it to 1439 would shave
|
|
50507
|
+
* a minute off every whole-day window, and to 0 would empty it. Any OTHER
|
|
50508
|
+
* non-zero seconds value is refused rather than rounded: the cap's
|
|
50509
|
+
* resolution is minutes and rounding is how an operator's 06:30:30
|
|
50510
|
+
* becomes 06:30 with nothing saying so.
|
|
50511
|
+
*/
|
|
50512
|
+
function parseTimeSectionSlot(raw) {
|
|
50513
|
+
const match = /^\s*(-?\d+)\s+(\d{2}):(\d{2}):(\d{2})-(\d{2}):(\d{2}):(\d{2})\s*$/.exec(raw);
|
|
50514
|
+
if (!match) return null;
|
|
50515
|
+
const mask = Number(match[1]);
|
|
50516
|
+
if (!Number.isInteger(mask) || mask < 0) return null;
|
|
50517
|
+
const start = clockToMinutes(match[2], match[3], match[4]);
|
|
50518
|
+
const end = clockToMinutes(match[5], match[6], match[7]);
|
|
50519
|
+
if (start === null || end === null) return null;
|
|
50520
|
+
return {
|
|
50521
|
+
mask,
|
|
50522
|
+
startMinute: start,
|
|
50523
|
+
endMinute: end
|
|
50524
|
+
};
|
|
50525
|
+
}
|
|
50526
|
+
function clockToMinutes(hour, minute, second) {
|
|
50527
|
+
if (hour === void 0 || minute === void 0 || second === void 0) return null;
|
|
50528
|
+
const h = Number(hour);
|
|
50529
|
+
const m = Number(minute);
|
|
50530
|
+
const s = Number(second);
|
|
50531
|
+
if (h > 23 || m > 59 || s > 59) return null;
|
|
50532
|
+
if (h === 23 && m === 59 && s === 59) return MINUTES_PER_DAY;
|
|
50533
|
+
if (s !== 0) return null;
|
|
50534
|
+
return h * 60 + m;
|
|
50535
|
+
}
|
|
50536
|
+
/** Every bit set in `mask`, low to high. */
|
|
50537
|
+
function setBits(mask) {
|
|
50538
|
+
const out = [];
|
|
50539
|
+
for (let bit = 0; bit < 32; bit += 1) if ((mask & 1 << bit) !== 0) out.push(bit);
|
|
50540
|
+
return out;
|
|
50541
|
+
}
|
|
50542
|
+
/**
|
|
50543
|
+
* Expand the `Record[ch].TimeSection[row][slot]` grid into windows.
|
|
50544
|
+
*
|
|
50545
|
+
* One slot can name SEVERAL triggers at once — that is what the bitfield
|
|
50546
|
+
* is — so one slot becomes one window per NAMED bit. An unnamed bit, a
|
|
50547
|
+
* slot that will not parse, and every bit of the holiday row are counted
|
|
50548
|
+
* as unreadable: each is coverage the camera holds and the operator is
|
|
50549
|
+
* not being shown, and a schedule that silently shows fewer rows than the
|
|
50550
|
+
* camera holds is how a shorter one gets saved back (D391).
|
|
50551
|
+
*/
|
|
50552
|
+
function parseSchedule(table, channel) {
|
|
50553
|
+
const windows = [];
|
|
50554
|
+
let unreadable = 0;
|
|
50555
|
+
let anyCoverage = false;
|
|
50556
|
+
for (let row = 0; row <= 7; row += 1) for (let slot = 0; slot < 6; slot += 1) {
|
|
50557
|
+
const raw = table.get(timeSectionKey(channel, row, slot));
|
|
50558
|
+
if (raw === void 0) continue;
|
|
50559
|
+
const parsed = parseTimeSectionSlot(raw);
|
|
50560
|
+
if (parsed === null) {
|
|
50561
|
+
unreadable += 1;
|
|
50562
|
+
anyCoverage = true;
|
|
50563
|
+
continue;
|
|
50564
|
+
}
|
|
50565
|
+
if (parsed.mask === 0) continue;
|
|
50566
|
+
anyCoverage = true;
|
|
50567
|
+
const bits = setBits(parsed.mask);
|
|
50568
|
+
if (row === 7) {
|
|
50569
|
+
unreadable += bits.length;
|
|
50570
|
+
continue;
|
|
50571
|
+
}
|
|
50572
|
+
if (parsed.endMinute <= parsed.startMinute) {
|
|
50573
|
+
unreadable += bits.length;
|
|
50574
|
+
continue;
|
|
50575
|
+
}
|
|
50576
|
+
for (const bit of bits) {
|
|
50577
|
+
const trigger = TRIGGER_BY_BIT[bit];
|
|
50578
|
+
if (trigger === void 0) {
|
|
50579
|
+
unreadable += 1;
|
|
50580
|
+
continue;
|
|
50581
|
+
}
|
|
50582
|
+
windows.push({
|
|
50583
|
+
trigger,
|
|
50584
|
+
day: rowToCapDay(row),
|
|
50585
|
+
startMinute: parsed.startMinute,
|
|
50586
|
+
endMinute: parsed.endMinute
|
|
50587
|
+
});
|
|
50588
|
+
}
|
|
50589
|
+
}
|
|
50590
|
+
return {
|
|
50591
|
+
windows,
|
|
50592
|
+
unreadable,
|
|
50593
|
+
anyCoverage
|
|
50594
|
+
};
|
|
50595
|
+
}
|
|
50596
|
+
/** Read both caps tables into {@link AmcrestRecordLimits}. */
|
|
50597
|
+
function parseRecordLimits(input) {
|
|
50598
|
+
const record = input.recordCaps;
|
|
50599
|
+
const event = input.eventCaps;
|
|
50600
|
+
const pair = (table, key) => {
|
|
50601
|
+
if (table === null) return null;
|
|
50602
|
+
const min = numberAt(table, `${key}[0]`);
|
|
50603
|
+
const max = numberAt(table, `${key}[1]`);
|
|
50604
|
+
if (min === null || max === null || max < min) return null;
|
|
50605
|
+
return {
|
|
50606
|
+
min,
|
|
50607
|
+
max
|
|
50608
|
+
};
|
|
50609
|
+
};
|
|
50610
|
+
return {
|
|
50611
|
+
maxPreRecordSec: record === null ? null : numberAt(record, "MaxPreRecordTime"),
|
|
50612
|
+
packetLengthMinutes: pair(record, "PacketLengthRange"),
|
|
50613
|
+
recordLatchSec: pair(event, "RecordLatch")
|
|
50614
|
+
};
|
|
50615
|
+
}
|
|
50616
|
+
/** Index of the local storage group, or null when the camera named none. */
|
|
50617
|
+
function findLocalStorageGroup(table) {
|
|
50618
|
+
for (const [key, value] of table) {
|
|
50619
|
+
const match = /^StorageGroup\[(\d+)\]\.Name$/.exec(key);
|
|
50620
|
+
if (match?.[1] !== void 0 && value === "ReadWrite") return Number(match[1]);
|
|
50621
|
+
}
|
|
50622
|
+
return null;
|
|
50623
|
+
}
|
|
50624
|
+
/**
|
|
50625
|
+
* Overwrite-when-full, from BOTH places this firmware keeps it.
|
|
50626
|
+
*
|
|
50627
|
+
* Measured: `MediaGlobal.OverWrite` and `StorageGroup[0].OverWrite` are
|
|
50628
|
+
* INDEPENDENT — setting the first to `false` left the second `true` — and
|
|
50629
|
+
* nothing on the camera says which one the recorder honours. The card is
|
|
50630
|
+
* 134 GB and settling it by filling it is not an experiment this repo is
|
|
50631
|
+
* going to run.
|
|
50632
|
+
*
|
|
50633
|
+
* So the read is the two authorities' AGREEMENT: equal → that value;
|
|
50634
|
+
* different → null, which the status renders as "the camera did not say"
|
|
50635
|
+
* rather than picking a winner. The WRITE sets both (one request per
|
|
50636
|
+
* table), so a value CamStack wrote can never be the disagreeing case.
|
|
50637
|
+
*/
|
|
50638
|
+
function readOverwriteWhenFull(input) {
|
|
50639
|
+
const global = input.mediaGlobal === null ? null : booleanAt(input.mediaGlobal, "MediaGlobal.OverWrite");
|
|
50640
|
+
const groupIndex = input.storageGroup === null ? null : findLocalStorageGroup(input.storageGroup);
|
|
50641
|
+
const group = input.storageGroup === null || groupIndex === null ? null : booleanAt(input.storageGroup, `StorageGroup[${String(groupIndex)}].OverWrite`);
|
|
50642
|
+
if (global === null) return group;
|
|
50643
|
+
if (group === null) return global;
|
|
50644
|
+
return global === group ? global : null;
|
|
50645
|
+
}
|
|
50646
|
+
/**
|
|
50647
|
+
* Post-record seconds, from every event handler that records.
|
|
50648
|
+
*
|
|
50649
|
+
* Dahua keeps post-record per EVENT HANDLER (`…EventHandler.RecordLatch`),
|
|
50650
|
+
* not once per channel. 3836 exposes exactly one — `MotionDetect[0]`;
|
|
50651
|
+
* `SmartMotionDetect`, `AlarmLocal`, `VideoBlind` and `VideoLoss` all
|
|
50652
|
+
* answer HTTP 400 on this firmware, and its single `VideoAnalyseRule`
|
|
50653
|
+
* (`LeTrack`, disabled) carries no `EventHandler`.
|
|
50654
|
+
*
|
|
50655
|
+
* Where several DO exist and disagree, there is no single number to
|
|
50656
|
+
* report: this returns null and the field says why, rather than picking
|
|
50657
|
+
* one handler's value and calling it the camera's.
|
|
50658
|
+
*/
|
|
50659
|
+
function readPostRecordSec(motionDetect) {
|
|
50660
|
+
if (motionDetect === null) return {
|
|
50661
|
+
value: null,
|
|
50662
|
+
handlers: 0,
|
|
50663
|
+
disagree: false
|
|
50664
|
+
};
|
|
50665
|
+
const values = [];
|
|
50666
|
+
for (const [key, raw] of motionDetect) {
|
|
50667
|
+
if (!key.endsWith(".EventHandler.RecordLatch")) continue;
|
|
50668
|
+
const value = Number(raw);
|
|
50669
|
+
if (Number.isFinite(value)) values.push(value);
|
|
50670
|
+
}
|
|
50671
|
+
if (values.length === 0) return {
|
|
50672
|
+
value: null,
|
|
50673
|
+
handlers: 0,
|
|
50674
|
+
disagree: false
|
|
50675
|
+
};
|
|
50676
|
+
const first = values[0] ?? null;
|
|
50677
|
+
const disagree = values.some((value) => value !== first);
|
|
50678
|
+
return {
|
|
50679
|
+
value: disagree ? null : first,
|
|
50680
|
+
handlers: values.length,
|
|
50681
|
+
disagree
|
|
50682
|
+
};
|
|
50683
|
+
}
|
|
50684
|
+
/**
|
|
50685
|
+
* Fold the reads into the cap's status.
|
|
50686
|
+
*
|
|
50687
|
+
* `tracks` is empty and `primaryTrackId` null, as on Reolink: a Dahua
|
|
50688
|
+
* channel exposes no track list, and inventing one synthetic row would
|
|
50689
|
+
* put a fact in the UI the camera never stated.
|
|
50690
|
+
*
|
|
50691
|
+
* `enabled` is the CONJUNCTION of the two switches that both have to be
|
|
50692
|
+
* on for the camera to record on its schedule — `RecordMode[ch].Mode`
|
|
50693
|
+
* (0 auto / 1 manual / 2 off) and `Record[ch].Enable`. Either one off
|
|
50694
|
+
* means nothing is recorded, so reporting only one of them would call a
|
|
50695
|
+
* silent camera enabled.
|
|
50696
|
+
*/
|
|
50697
|
+
function buildOnboardStatus(input) {
|
|
50698
|
+
const { reads } = input;
|
|
50699
|
+
const channel = reads.channel;
|
|
50700
|
+
const schedule = reads.record === null ? {
|
|
50701
|
+
windows: [],
|
|
50702
|
+
unreadable: 0,
|
|
50703
|
+
anyCoverage: false
|
|
50704
|
+
} : parseSchedule(reads.record, channel);
|
|
50705
|
+
const mode = reads.recordMode === null ? null : numberAt(reads.recordMode, `RecordMode[${String(channel)}].Mode`);
|
|
50706
|
+
const recordEnable = reads.record === null ? null : booleanAt(reads.record, `Record[${String(channel)}].Enable`);
|
|
50707
|
+
const enabled = mode === null || recordEnable === null ? null : mode !== 2 && recordEnable;
|
|
50708
|
+
const post = readPostRecordSec(reads.motionDetect);
|
|
50709
|
+
return {
|
|
50710
|
+
storage: reads.storage,
|
|
50711
|
+
tracks: [],
|
|
50712
|
+
primaryTrackId: null,
|
|
50713
|
+
enabled,
|
|
50714
|
+
overwriteWhenFull: readOverwriteWhenFull({
|
|
50715
|
+
mediaGlobal: reads.mediaGlobal,
|
|
50716
|
+
storageGroup: reads.storageGroup
|
|
50717
|
+
}),
|
|
50718
|
+
preRecordSec: reads.record === null ? null : numberAt(reads.record, `Record[${String(channel)}].PreRecord`),
|
|
50719
|
+
postRecordSec: post.value,
|
|
50720
|
+
segmentMinutes: reads.mediaGlobal === null ? null : numberAt(reads.mediaGlobal, "MediaGlobal.PacketLength"),
|
|
50721
|
+
windows: [...schedule.windows],
|
|
50722
|
+
unreadableWindows: schedule.unreadable,
|
|
50723
|
+
recordingToNowhere: enabled === true && schedule.anyCoverage && reads.storage.kind === "absent",
|
|
50724
|
+
lastFetchedAt: input.now
|
|
50725
|
+
};
|
|
50726
|
+
}
|
|
50727
|
+
/** Why nothing can be written before CamStack has read the camera. */
|
|
50728
|
+
var AMCREST_NOT_READ_REASON = "CamStack has not read this camera yet";
|
|
50729
|
+
/** Why a field is closed when the camera never stated its limits. */
|
|
50730
|
+
var AMCREST_NO_LIMITS_REASON = "this camera did not state the values it accepts for this setting, and it was measured storing values outside its own limits without complaint";
|
|
50731
|
+
/** Why the schedule is closed when part of it could not be read. */
|
|
50732
|
+
var AMCREST_PARTIAL_SCHEDULE_REASON = "CamStack could not read every recording window this camera holds — it records on triggers this version cannot name — so replacing them would delete the ones it could not show";
|
|
50733
|
+
/** Why post-record is closed when the handlers disagree. */
|
|
50734
|
+
var AMCREST_POST_RECORD_SPLIT_REASON = "this camera keeps post-record separately per event type and they do not agree, so there is no single value to show or write";
|
|
50735
|
+
/**
|
|
50736
|
+
* What THIS Amcrest accepts.
|
|
50737
|
+
*
|
|
50738
|
+
* Writability comes from the camera's OWN statement, never a vendor
|
|
50739
|
+
* default: `recordManager.cgi?action=getCaps` bounds pre-record and
|
|
50740
|
+
* segment length, `eventManager.cgi?action=getCaps` bounds post-record.
|
|
50741
|
+
* A caps read that failed closes its field, because this firmware was
|
|
50742
|
+
* measured answering `200 OK` to `PacketLength=99` and `RecordLatch=999`
|
|
50743
|
+
* — values its own caps forbid. Nothing but the refusal before the wire
|
|
50744
|
+
* stops those.
|
|
50745
|
+
*/
|
|
50746
|
+
function buildOnboardOptions(input) {
|
|
50747
|
+
const reached = input.status !== null;
|
|
50748
|
+
const closed = (reason) => ({
|
|
50749
|
+
readable: reached,
|
|
50750
|
+
writable: false,
|
|
50751
|
+
reason: reached ? reason : AMCREST_NOT_READ_REASON
|
|
50752
|
+
});
|
|
50753
|
+
const open = {
|
|
50754
|
+
readable: true,
|
|
50755
|
+
writable: true
|
|
50756
|
+
};
|
|
50757
|
+
const gate = (allowed, reason) => reached && allowed ? open : closed(reason);
|
|
50758
|
+
const limits = input.limits;
|
|
50759
|
+
const preOk = limits.maxPreRecordSec !== null;
|
|
50760
|
+
const segmentOk = limits.packetLengthMinutes !== null;
|
|
50761
|
+
const postOk = limits.recordLatchSec !== null && input.postRecord.handlers > 0 && !input.postRecord.disagree;
|
|
50762
|
+
const scheduleReadable = (input.status?.unreadableWindows ?? 0) === 0;
|
|
50763
|
+
return {
|
|
50764
|
+
enabled: gate(true, AMCREST_NOT_READ_REASON),
|
|
50765
|
+
overwriteWhenFull: gate(true, AMCREST_NOT_READ_REASON),
|
|
50766
|
+
preRecordSec: gate(preOk, AMCREST_NO_LIMITS_REASON),
|
|
50767
|
+
...limits.maxPreRecordSec === null ? {} : { preRecordSecRange: {
|
|
50768
|
+
min: 0,
|
|
50769
|
+
max: limits.maxPreRecordSec,
|
|
50770
|
+
step: 1
|
|
50771
|
+
} },
|
|
50772
|
+
postRecordSec: gate(postOk, input.postRecord.disagree ? AMCREST_POST_RECORD_SPLIT_REASON : AMCREST_NO_LIMITS_REASON),
|
|
50773
|
+
...limits.recordLatchSec === null ? {} : { postRecordSecRange: {
|
|
50774
|
+
min: limits.recordLatchSec.min,
|
|
50775
|
+
max: limits.recordLatchSec.max,
|
|
50776
|
+
step: 1
|
|
50777
|
+
} },
|
|
50778
|
+
segmentMinutes: gate(segmentOk, AMCREST_NO_LIMITS_REASON),
|
|
50779
|
+
...limits.packetLengthMinutes === null ? {} : { segmentMinutesRange: {
|
|
50780
|
+
min: limits.packetLengthMinutes.min,
|
|
50781
|
+
max: limits.packetLengthMinutes.max,
|
|
50782
|
+
step: 1
|
|
50783
|
+
} },
|
|
50784
|
+
schedule: {
|
|
50785
|
+
support: gate(scheduleReadable, AMCREST_PARTIAL_SCHEDULE_REASON),
|
|
50786
|
+
granularityMinutes: 1,
|
|
50787
|
+
triggers: [...AMCREST_TRIGGERS],
|
|
50788
|
+
supportsOverlappingTriggers: true
|
|
50789
|
+
}
|
|
50790
|
+
};
|
|
50791
|
+
}
|
|
50792
|
+
/**
|
|
50793
|
+
* Turn a cap patch into one request per config table.
|
|
50794
|
+
*
|
|
50795
|
+
* `windows` is the COMPLETE new set, so every weekday row is rewritten —
|
|
50796
|
+
* including a day the new set does not mention, whose slots must be
|
|
50797
|
+
* written back as `0 00:00:00-23:59:59` or the camera keeps recording on
|
|
50798
|
+
* a day the operator just cleared. Slots beyond the new windows are
|
|
50799
|
+
* cleared the same way. The HOLIDAY row is never touched: the cap cannot
|
|
50800
|
+
* express it, so rewriting it would delete coverage nobody was shown.
|
|
50801
|
+
*
|
|
50802
|
+
* `enabled` is read-modify-write on `RecordMode`: turning recording back
|
|
50803
|
+
* ON restores `auto` only when the camera was `off`, so a camera the
|
|
50804
|
+
* operator had put in `manual` stays in `manual`.
|
|
50805
|
+
*/
|
|
50806
|
+
function buildOnboardWrites(patch, current) {
|
|
50807
|
+
const ch = String(current.channel);
|
|
50808
|
+
const writes = [];
|
|
50809
|
+
const recordParams = {};
|
|
50810
|
+
const recordVerify = {};
|
|
50811
|
+
if (patch.enabled !== void 0) {
|
|
50812
|
+
recordParams[`Record[${ch}].Enable`] = patch.enabled;
|
|
50813
|
+
recordVerify["enabled"] = {
|
|
50814
|
+
key: `Record[${ch}].Enable`,
|
|
50815
|
+
expect: patch.enabled ? "true" : "false"
|
|
50816
|
+
};
|
|
50817
|
+
}
|
|
50818
|
+
if (patch.preRecordSec !== void 0) {
|
|
50819
|
+
recordParams[`Record[${ch}].PreRecord`] = patch.preRecordSec;
|
|
50820
|
+
recordVerify["preRecordSec"] = {
|
|
50821
|
+
key: `Record[${ch}].PreRecord`,
|
|
50822
|
+
expect: String(patch.preRecordSec)
|
|
50823
|
+
};
|
|
50824
|
+
}
|
|
50825
|
+
if (patch.windows !== void 0) {
|
|
50826
|
+
const slots = windowsToTimeSections(patch.windows);
|
|
50827
|
+
for (let row = 0; row < 7; row += 1) for (let slot = 0; slot < 6; slot += 1) {
|
|
50828
|
+
const value = slots[row]?.[slot] ?? "0 00:00:00-23:59:59";
|
|
50829
|
+
const key = timeSectionKey(current.channel, row, slot);
|
|
50830
|
+
recordParams[key] = value;
|
|
50831
|
+
recordVerify[`windows[${String(row)}][${String(slot)}]`] = {
|
|
50832
|
+
key,
|
|
50833
|
+
expect: value
|
|
50834
|
+
};
|
|
50835
|
+
}
|
|
50836
|
+
}
|
|
50837
|
+
if (Object.keys(recordParams).length > 0) writes.push({
|
|
50838
|
+
table: "Record",
|
|
50839
|
+
params: recordParams,
|
|
50840
|
+
verify: recordVerify
|
|
50841
|
+
});
|
|
50842
|
+
if (patch.enabled !== void 0) {
|
|
50843
|
+
const mode = patch.enabled ? current.recordMode === 2 || current.recordMode === null ? 0 : current.recordMode : 2;
|
|
50844
|
+
writes.push({
|
|
50845
|
+
table: "RecordMode",
|
|
50846
|
+
params: { [`RecordMode[${ch}].Mode`]: mode },
|
|
50847
|
+
verify: { enabledMode: {
|
|
50848
|
+
key: `RecordMode[${ch}].Mode`,
|
|
50849
|
+
expect: String(mode)
|
|
50850
|
+
} }
|
|
50851
|
+
});
|
|
50852
|
+
}
|
|
50853
|
+
const mediaParams = {};
|
|
50854
|
+
const mediaVerify = {};
|
|
50855
|
+
if (patch.overwriteWhenFull !== void 0) {
|
|
50856
|
+
mediaParams["MediaGlobal.OverWrite"] = patch.overwriteWhenFull;
|
|
50857
|
+
mediaVerify["overwriteWhenFull"] = {
|
|
50858
|
+
key: "MediaGlobal.OverWrite",
|
|
50859
|
+
expect: patch.overwriteWhenFull ? "true" : "false"
|
|
50860
|
+
};
|
|
50861
|
+
}
|
|
50862
|
+
if (patch.segmentMinutes !== void 0) {
|
|
50863
|
+
mediaParams["MediaGlobal.PacketLength"] = patch.segmentMinutes;
|
|
50864
|
+
mediaVerify["segmentMinutes"] = {
|
|
50865
|
+
key: "MediaGlobal.PacketLength",
|
|
50866
|
+
expect: String(patch.segmentMinutes)
|
|
50867
|
+
};
|
|
50868
|
+
}
|
|
50869
|
+
if (Object.keys(mediaParams).length > 0) writes.push({
|
|
50870
|
+
table: "MediaGlobal",
|
|
50871
|
+
params: mediaParams,
|
|
50872
|
+
verify: mediaVerify
|
|
50873
|
+
});
|
|
50874
|
+
if (patch.overwriteWhenFull !== void 0 && current.storageGroupIndex !== null) {
|
|
50875
|
+
const key = `StorageGroup[${String(current.storageGroupIndex)}].OverWrite`;
|
|
50876
|
+
writes.push({
|
|
50877
|
+
table: "StorageGroup",
|
|
50878
|
+
params: { [key]: patch.overwriteWhenFull },
|
|
50879
|
+
verify: { overwriteWhenFullGroup: {
|
|
50880
|
+
key,
|
|
50881
|
+
expect: patch.overwriteWhenFull ? "true" : "false"
|
|
50882
|
+
} }
|
|
50883
|
+
});
|
|
50884
|
+
}
|
|
50885
|
+
if (patch.postRecordSec !== void 0) {
|
|
50886
|
+
const key = `MotionDetect[${ch}].EventHandler.RecordLatch`;
|
|
50887
|
+
writes.push({
|
|
50888
|
+
table: "MotionDetect",
|
|
50889
|
+
params: { [key]: patch.postRecordSec },
|
|
50890
|
+
verify: { postRecordSec: {
|
|
50891
|
+
key,
|
|
50892
|
+
expect: String(patch.postRecordSec)
|
|
50893
|
+
} }
|
|
50894
|
+
});
|
|
50895
|
+
}
|
|
50896
|
+
return writes;
|
|
50897
|
+
}
|
|
50898
|
+
/**
|
|
50899
|
+
* Windows → the weekday rows' slots.
|
|
50900
|
+
*
|
|
50901
|
+
* Windows sharing a day and a time range are MERGED into one slot whose
|
|
50902
|
+
* mask carries every trigger — that is what the bitfield is, and emitting
|
|
50903
|
+
* one slot per trigger would burn through the six the camera has.
|
|
50904
|
+
*
|
|
50905
|
+
* A day needing more than {@link SLOTS_PER_ROW} distinct ranges yields
|
|
50906
|
+
* only the first six; the caller refuses that case before the wire rather
|
|
50907
|
+
* than letting the extra ones fall off here. A trigger with no bit is
|
|
50908
|
+
* skipped for the same reason — `describeOnboardRefusal` has already
|
|
50909
|
+
* refused it against `options.schedule.triggers`.
|
|
50910
|
+
*/
|
|
50911
|
+
function windowsToTimeSections(windows) {
|
|
50912
|
+
const rows = Array.from({ length: 7 }, () => []);
|
|
50913
|
+
for (let day = 0; day < 7; day += 1) {
|
|
50914
|
+
const byRange = /* @__PURE__ */ new Map();
|
|
50915
|
+
for (const window of windows) {
|
|
50916
|
+
if (window.day !== day) continue;
|
|
50917
|
+
const bit = BIT_BY_TRIGGER[window.trigger];
|
|
50918
|
+
if (bit === void 0) continue;
|
|
50919
|
+
const range = `${minutesToClock(window.startMinute)}-${minutesToClock(window.endMinute)}`;
|
|
50920
|
+
byRange.set(range, (byRange.get(range) ?? 0) | 1 << bit);
|
|
50921
|
+
}
|
|
50922
|
+
const row = capDayToRow(day);
|
|
50923
|
+
rows[row] = [...byRange.entries()].toSorted((a, b) => a[0].localeCompare(b[0])).map(([range, mask]) => `${String(mask)} ${range}`);
|
|
50924
|
+
}
|
|
50925
|
+
return rows;
|
|
50926
|
+
}
|
|
50927
|
+
/**
|
|
50928
|
+
* The one refusal `describeOnboardRefusal` cannot make for this vendor.
|
|
50929
|
+
*
|
|
50930
|
+
* A Dahua weekday row holds {@link SLOTS_PER_ROW} time ranges and no
|
|
50931
|
+
* more. A day asking for more would be written as its first six and the
|
|
50932
|
+
* rest would vanish into a `200 OK` — the silent drop this whole cap
|
|
50933
|
+
* exists to prevent — so it is refused by name, before the wire, with the
|
|
50934
|
+
* same voice the shared vocabulary uses.
|
|
50935
|
+
*
|
|
50936
|
+
* Returns null when the set fits.
|
|
50937
|
+
*/
|
|
50938
|
+
function describeAmcrestScheduleRefusal(windows) {
|
|
50939
|
+
for (const [day, needed] of slotsNeededPerDay(windows)) if (needed > 6) return `this camera holds ${String(6)} recording periods per day and ${DAY_NAMES[day] ?? `day ${String(day)}`} asks for ${String(needed)}`;
|
|
50940
|
+
return null;
|
|
50941
|
+
}
|
|
50942
|
+
/** How many distinct time ranges a day would need. For the refusal. */
|
|
50943
|
+
function slotsNeededPerDay(windows) {
|
|
50944
|
+
const out = /* @__PURE__ */ new Map();
|
|
50945
|
+
for (let day = 0; day < 7; day += 1) {
|
|
50946
|
+
const ranges = /* @__PURE__ */ new Set();
|
|
50947
|
+
for (const window of windows) {
|
|
50948
|
+
if (window.day !== day) continue;
|
|
50949
|
+
ranges.add(`${String(window.startMinute)}-${String(window.endMinute)}`);
|
|
50950
|
+
}
|
|
50951
|
+
out.set(day, ranges.size);
|
|
50952
|
+
}
|
|
50953
|
+
return out;
|
|
50954
|
+
}
|
|
50955
|
+
/**
|
|
50956
|
+
* `1440` → `23:59:59`, the firmware's own end-of-day; everything else
|
|
50957
|
+
* `HH:MM:00`.
|
|
50958
|
+
*/
|
|
50959
|
+
function minutesToClock(minute) {
|
|
50960
|
+
if (minute >= 1440) return "23:59:59";
|
|
50961
|
+
const hour = Math.floor(minute / 60);
|
|
50962
|
+
const rest = minute % 60;
|
|
50963
|
+
return `${String(hour).padStart(2, "0")}:${String(rest).padStart(2, "0")}:00`;
|
|
50964
|
+
}
|
|
50965
|
+
//#endregion
|
|
49631
50966
|
//#region src/extra-settings.ts
|
|
49632
50967
|
/**
|
|
49633
50968
|
* Driver-authored device-settings sections for the camera facts that no
|
|
@@ -50107,6 +51442,7 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
|
|
|
50107
51442
|
this.registerOsdCap();
|
|
50108
51443
|
this.registerDayNightCap();
|
|
50109
51444
|
this.registerImageSettingsCap();
|
|
51445
|
+
this.registerRecordingOnboardCap();
|
|
50110
51446
|
}
|
|
50111
51447
|
/** Dahua CGI `eventManager` attach codes — VideoMotion is the base
|
|
50112
51448
|
* onboard-motion signal (Start/Stop). Smart sub-codes
|
|
@@ -50146,6 +51482,8 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
|
|
|
50146
51482
|
dayNightRefreshInFlight = null;
|
|
50147
51483
|
/** Single-flight guard for the `image-settings` camera refresh. */
|
|
50148
51484
|
imageSettingsRefreshInFlight = null;
|
|
51485
|
+
/** Single-flight guard for the `recording-onboard` camera refresh. */
|
|
51486
|
+
recordingOnboardRefreshInFlight = null;
|
|
50149
51487
|
/** Channel is 1-based; defaults to the schema's configured value. */
|
|
50150
51488
|
get channel() {
|
|
50151
51489
|
return this.config.get("channel") ?? 1;
|
|
@@ -50168,6 +51506,47 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
|
|
|
50168
51506
|
source: "operator"
|
|
50169
51507
|
};
|
|
50170
51508
|
}
|
|
51509
|
+
/**
|
|
51510
|
+
* What the clip service needs to reach this camera's own recordings.
|
|
51511
|
+
*
|
|
51512
|
+
* The CGI client is the DEVICE's, not a second one: it carries a warm
|
|
51513
|
+
* digest state whose nonce-count is monotonic, and a second client for the
|
|
51514
|
+
* clip path would advance a second one against the same camera.
|
|
51515
|
+
*
|
|
51516
|
+
* `readStorage` is the SAME read and the SAME parser the `recording-onboard`
|
|
51517
|
+
* cap uses (D614) — `storageDevice.cgi?action=getDeviceAllInfo` through
|
|
51518
|
+
* `parseOnboardStorage` — mapped into the clip cap's own three states. There
|
|
51519
|
+
* is one storage reader in this addon and this is a caller of it, not a
|
|
51520
|
+
* second one. It is read live rather than taken off the cap's runtime slice
|
|
51521
|
+
* because that slice is `unknown` until the cap's first refresh, and
|
|
51522
|
+
* "CamStack has not read this camera yet" must not reach an operator as
|
|
51523
|
+
* "this camera cannot be reached".
|
|
51524
|
+
*/
|
|
51525
|
+
getClipCamera() {
|
|
51526
|
+
const https = this.config.get("https") ?? false;
|
|
51527
|
+
return {
|
|
51528
|
+
deviceId: this.id,
|
|
51529
|
+
host: this.config.get("host"),
|
|
51530
|
+
port: this.config.get("port") ?? (https ? 443 : 80),
|
|
51531
|
+
https,
|
|
51532
|
+
username: this.config.get("username") ?? "admin",
|
|
51533
|
+
password: this.config.get("password"),
|
|
51534
|
+
channel: this.channel,
|
|
51535
|
+
client: this.ensureClient(),
|
|
51536
|
+
readStorage: async () => {
|
|
51537
|
+
let table;
|
|
51538
|
+
try {
|
|
51539
|
+
table = await this.ensureClient().getStorageDeviceAllInfo();
|
|
51540
|
+
} catch (err) {
|
|
51541
|
+
return {
|
|
51542
|
+
state: "unreachable",
|
|
51543
|
+
reason: `The camera did not answer the storage read: ${err instanceof Error ? err.message : String(err)}`
|
|
51544
|
+
};
|
|
51545
|
+
}
|
|
51546
|
+
return availabilityFromStorage(parseOnboardStorage(table));
|
|
51547
|
+
}
|
|
51548
|
+
};
|
|
51549
|
+
}
|
|
50171
51550
|
ensureClient() {
|
|
50172
51551
|
if (this.client) return this.client;
|
|
50173
51552
|
this.client = this.buildClient();
|
|
@@ -51674,6 +53053,238 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
|
|
|
51674
53053
|
this.ctx.registerNativeCap(imageSettingsCapability, provider);
|
|
51675
53054
|
this.ctx.logger.info("amcrest: image-settings cap registered", { tags: { deviceId: this.id } });
|
|
51676
53055
|
}
|
|
53056
|
+
/**
|
|
53057
|
+
* Register the vendor-neutral `recording-onboard` cap.
|
|
53058
|
+
*
|
|
53059
|
+
* The camera's own card and the camera's own schedule — not CamStack's
|
|
53060
|
+
* recorder. The whole surface rides the existing digest CGI client:
|
|
53061
|
+
* `storageDevice.cgi?action=getDeviceAllInfo` serves the card,
|
|
53062
|
+
* `configManager.cgi` serves `Record` / `RecordMode` / `MediaGlobal` /
|
|
53063
|
+
* `StorageGroup` / `MotionDetect`, and `recordManager.cgi` +
|
|
53064
|
+
* `eventManager.cgi` serve the limits. No RPC2 session is needed.
|
|
53065
|
+
*
|
|
53066
|
+
* Writes follow D610 whole, and this firmware earns every word of it:
|
|
53067
|
+
*
|
|
53068
|
+
* - **Refused before the wire**, against what the camera STATED —
|
|
53069
|
+
* because `PacketLength=99` and `RecordLatch=999` were both stored
|
|
53070
|
+
* although the camera's own caps forbid them.
|
|
53071
|
+
* - **One request per config TABLE**, because a `setConfig` naming
|
|
53072
|
+
* two answers `200 OK` and applies only the first.
|
|
53073
|
+
* - **Verified after it by re-reading**, per field. Success is the
|
|
53074
|
+
* read-back agreeing, never the absence of a thrown error — the
|
|
53075
|
+
* body reads `Error` under HTTP 200 when a key is refused.
|
|
53076
|
+
*/
|
|
53077
|
+
registerRecordingOnboardCap() {
|
|
53078
|
+
const CAP_NAME = "recording-onboard";
|
|
53079
|
+
const configChannel = Math.max(0, this.channel - 1);
|
|
53080
|
+
const emptyStatus = () => ({
|
|
53081
|
+
storage: {
|
|
53082
|
+
kind: "unknown",
|
|
53083
|
+
reason: "CamStack has not read this camera yet."
|
|
53084
|
+
},
|
|
53085
|
+
tracks: [],
|
|
53086
|
+
primaryTrackId: null,
|
|
53087
|
+
enabled: null,
|
|
53088
|
+
overwriteWhenFull: null,
|
|
53089
|
+
preRecordSec: null,
|
|
53090
|
+
postRecordSec: null,
|
|
53091
|
+
segmentMinutes: null,
|
|
53092
|
+
windows: [],
|
|
53093
|
+
unreadableWindows: 0,
|
|
53094
|
+
recordingToNowhere: false,
|
|
53095
|
+
lastFetchedAt: 0
|
|
53096
|
+
});
|
|
53097
|
+
/** The camera's own statement of what it accepts. */
|
|
53098
|
+
let lastLimits = {
|
|
53099
|
+
maxPreRecordSec: null,
|
|
53100
|
+
packetLengthMinutes: null,
|
|
53101
|
+
recordLatchSec: null
|
|
53102
|
+
};
|
|
53103
|
+
/** How many event handlers hold a post-record, and whether they agree. */
|
|
53104
|
+
let lastPostRecord = {
|
|
53105
|
+
handlers: 0,
|
|
53106
|
+
disagree: false
|
|
53107
|
+
};
|
|
53108
|
+
/** `RecordMode[ch].Mode`, so turning recording back ON can preserve `manual`. */
|
|
53109
|
+
let lastRecordMode = null;
|
|
53110
|
+
/** Which `StorageGroup` row is the local card. */
|
|
53111
|
+
let lastStorageGroupIndex = null;
|
|
53112
|
+
/** Read every table this cap needs. A table that failed is null, never empty. */
|
|
53113
|
+
const readAll = async () => {
|
|
53114
|
+
const client = this.ensureClient();
|
|
53115
|
+
const [storageTable, record, recordMode, mediaGlobal, storageGroup, motionDetect] = await Promise.all([
|
|
53116
|
+
client.getStorageDeviceAllInfo().catch(() => null),
|
|
53117
|
+
client.getConfig("Record").catch(() => null),
|
|
53118
|
+
client.getConfig("RecordMode").catch(() => null),
|
|
53119
|
+
client.getConfig("MediaGlobal").catch(() => null),
|
|
53120
|
+
client.getConfig("StorageGroup").catch(() => null),
|
|
53121
|
+
client.getConfig("MotionDetect").catch(() => null)
|
|
53122
|
+
]);
|
|
53123
|
+
return {
|
|
53124
|
+
storage: storageTable === null ? {
|
|
53125
|
+
kind: "unknown",
|
|
53126
|
+
reason: "The camera did not answer the storage read."
|
|
53127
|
+
} : parseOnboardStorage(storageTable),
|
|
53128
|
+
record,
|
|
53129
|
+
recordMode,
|
|
53130
|
+
mediaGlobal,
|
|
53131
|
+
storageGroup,
|
|
53132
|
+
motionDetect,
|
|
53133
|
+
channel: configChannel
|
|
53134
|
+
};
|
|
53135
|
+
};
|
|
53136
|
+
const applyReads = (reads) => {
|
|
53137
|
+
lastRecordMode = reads.recordMode === null ? null : Number(reads.recordMode.get(`RecordMode[${String(configChannel)}].Mode`) ?? NaN);
|
|
53138
|
+
if (lastRecordMode !== null && !Number.isFinite(lastRecordMode)) lastRecordMode = null;
|
|
53139
|
+
lastStorageGroupIndex = reads.storageGroup === null ? null : findLocalStorageGroup(reads.storageGroup);
|
|
53140
|
+
const post = readPostRecordSec(reads.motionDetect);
|
|
53141
|
+
lastPostRecord = {
|
|
53142
|
+
handlers: post.handlers,
|
|
53143
|
+
disagree: post.disagree
|
|
53144
|
+
};
|
|
53145
|
+
return buildOnboardStatus({
|
|
53146
|
+
reads,
|
|
53147
|
+
now: Date.now()
|
|
53148
|
+
});
|
|
53149
|
+
};
|
|
53150
|
+
const refreshFromCamera = async () => {
|
|
53151
|
+
if (this.recordingOnboardRefreshInFlight) return this.recordingOnboardRefreshInFlight;
|
|
53152
|
+
const promise = (async () => {
|
|
53153
|
+
try {
|
|
53154
|
+
const client = this.ensureClient();
|
|
53155
|
+
const [reads, recordCaps, eventCaps] = await Promise.all([
|
|
53156
|
+
readAll(),
|
|
53157
|
+
client.getRecordManagerCaps().catch(() => null),
|
|
53158
|
+
client.getEventManagerCaps().catch(() => null)
|
|
53159
|
+
]);
|
|
53160
|
+
lastLimits = parseRecordLimits({
|
|
53161
|
+
recordCaps,
|
|
53162
|
+
eventCaps
|
|
53163
|
+
});
|
|
53164
|
+
const next = applyReads(reads);
|
|
53165
|
+
this.runtimeState.setCapState(CAP_NAME, next);
|
|
53166
|
+
if (next.recordingToNowhere) this.ctx.logger.warn("amcrest onboard recording: camera is scheduled to record and has no usable storage", {
|
|
53167
|
+
tags: { deviceId: this.id },
|
|
53168
|
+
meta: {
|
|
53169
|
+
windows: next.windows.length,
|
|
53170
|
+
unreadable: next.unreadableWindows
|
|
53171
|
+
}
|
|
53172
|
+
});
|
|
53173
|
+
if (next.unreadableWindows > 0) this.ctx.logger.warn("amcrest onboard recording: some schedule slots could not be read — the schedule shown is incomplete and cannot be written", {
|
|
53174
|
+
tags: { deviceId: this.id },
|
|
53175
|
+
meta: {
|
|
53176
|
+
unreadableWindows: next.unreadableWindows,
|
|
53177
|
+
readWindows: next.windows.length
|
|
53178
|
+
}
|
|
53179
|
+
});
|
|
53180
|
+
} catch (err) {
|
|
53181
|
+
this.ctx.logger.debug("amcrest onboard recording refresh failed — keeping last slice", {
|
|
53182
|
+
tags: { deviceId: this.id },
|
|
53183
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
53184
|
+
});
|
|
53185
|
+
}
|
|
53186
|
+
})();
|
|
53187
|
+
this.recordingOnboardRefreshInFlight = promise;
|
|
53188
|
+
try {
|
|
53189
|
+
await promise;
|
|
53190
|
+
} finally {
|
|
53191
|
+
this.recordingOnboardRefreshInFlight = null;
|
|
53192
|
+
}
|
|
53193
|
+
};
|
|
53194
|
+
const bridge = createRuntimeStateBridge({
|
|
53195
|
+
runtimeState: this.runtimeState,
|
|
53196
|
+
cap: recordingOnboardCapability,
|
|
53197
|
+
ownDeviceId: this.id,
|
|
53198
|
+
refresh: refreshFromCamera,
|
|
53199
|
+
staleMs: OPERATOR_WRITTEN_STALE_MS,
|
|
53200
|
+
staleRead: "serve-and-revalidate",
|
|
53201
|
+
logger: this.ctx.logger,
|
|
53202
|
+
empty: emptyStatus
|
|
53203
|
+
});
|
|
53204
|
+
const readOptions = () => buildOnboardOptions({
|
|
53205
|
+
status: this.runtimeState.getCapState(CAP_NAME) ?? null,
|
|
53206
|
+
limits: lastLimits,
|
|
53207
|
+
postRecord: lastPostRecord
|
|
53208
|
+
});
|
|
53209
|
+
const provider = {
|
|
53210
|
+
getStatus: bridge.getStatus,
|
|
53211
|
+
getOptions: async ({ deviceId }) => {
|
|
53212
|
+
if (deviceId !== this.id) throw new Error(`AmcrestCamera: deviceId mismatch, expected ${this.id}, got ${deviceId}`);
|
|
53213
|
+
await bridge.ensureFresh();
|
|
53214
|
+
return readOptions();
|
|
53215
|
+
},
|
|
53216
|
+
setSettings: async ({ deviceId, settings }) => {
|
|
53217
|
+
if (deviceId !== this.id) return;
|
|
53218
|
+
if (Object.keys(settings).length === 0) return;
|
|
53219
|
+
await bridge.ensureFresh();
|
|
53220
|
+
const refusal = describeOnboardRefusal(readOptions(), settings) ?? (settings.windows === void 0 ? null : describeAmcrestScheduleRefusal(settings.windows));
|
|
53221
|
+
if (refusal !== null) {
|
|
53222
|
+
this.ctx.logger.warn("amcrest onboard recording: refusing a write this camera cannot take", {
|
|
53223
|
+
tags: { deviceId: this.id },
|
|
53224
|
+
meta: {
|
|
53225
|
+
refusal,
|
|
53226
|
+
fields: Object.keys(settings)
|
|
53227
|
+
}
|
|
53228
|
+
});
|
|
53229
|
+
throw new Error(`device ${String(this.id)}: ${refusal}`);
|
|
53230
|
+
}
|
|
53231
|
+
const writes = buildOnboardWrites(settings, {
|
|
53232
|
+
channel: configChannel,
|
|
53233
|
+
recordMode: lastRecordMode,
|
|
53234
|
+
storageGroupIndex: lastStorageGroupIndex
|
|
53235
|
+
});
|
|
53236
|
+
const client = this.ensureClient();
|
|
53237
|
+
const rejected = [];
|
|
53238
|
+
for (const write of writes) try {
|
|
53239
|
+
await client.setConfig(write.params);
|
|
53240
|
+
} catch (err) {
|
|
53241
|
+
rejected.push(`${write.table}: ${err instanceof Error ? err.message : String(err)}`);
|
|
53242
|
+
}
|
|
53243
|
+
const after = await readAll();
|
|
53244
|
+
this.runtimeState.setCapState(CAP_NAME, applyReads(after));
|
|
53245
|
+
const byTable = {
|
|
53246
|
+
Record: after.record,
|
|
53247
|
+
RecordMode: after.recordMode,
|
|
53248
|
+
MediaGlobal: after.mediaGlobal,
|
|
53249
|
+
StorageGroup: after.storageGroup,
|
|
53250
|
+
MotionDetect: after.motionDetect
|
|
53251
|
+
};
|
|
53252
|
+
const discarded = [];
|
|
53253
|
+
for (const write of writes) {
|
|
53254
|
+
const table = byTable[write.table] ?? null;
|
|
53255
|
+
if (table === null) {
|
|
53256
|
+
discarded.push(`${write.table}: the camera would not say what it stored`);
|
|
53257
|
+
continue;
|
|
53258
|
+
}
|
|
53259
|
+
for (const [field, check] of Object.entries(write.verify)) {
|
|
53260
|
+
const stored = table.get(check.key);
|
|
53261
|
+
if (stored === check.expect) continue;
|
|
53262
|
+
discarded.push(`${field}: asked ${check.expect}, camera holds ${stored ?? "(nothing)"}`);
|
|
53263
|
+
}
|
|
53264
|
+
}
|
|
53265
|
+
if (rejected.length > 0 || discarded.length > 0) {
|
|
53266
|
+
this.ctx.logger.warn("amcrest onboard recording: the camera did not keep part of the write", {
|
|
53267
|
+
tags: { deviceId: this.id },
|
|
53268
|
+
meta: {
|
|
53269
|
+
rejected,
|
|
53270
|
+
discarded,
|
|
53271
|
+
fields: Object.keys(settings)
|
|
53272
|
+
}
|
|
53273
|
+
});
|
|
53274
|
+
throw new Error(`device ${String(this.id)}: the camera kept its own values — ${[...rejected, ...discarded].join("; ")}`);
|
|
53275
|
+
}
|
|
53276
|
+
this.ctx.logger.info("amcrest onboard recording: write applied and verified", {
|
|
53277
|
+
tags: { deviceId: this.id },
|
|
53278
|
+
meta: {
|
|
53279
|
+
fields: Object.keys(settings),
|
|
53280
|
+
requests: writes.length
|
|
53281
|
+
}
|
|
53282
|
+
});
|
|
53283
|
+
}
|
|
53284
|
+
};
|
|
53285
|
+
this.ctx.registerNativeCap(recordingOnboardCapability, provider);
|
|
53286
|
+
this.ctx.logger.info("amcrest: recording-onboard cap registered", { tags: { deviceId: this.id } });
|
|
53287
|
+
}
|
|
51677
53288
|
};
|
|
51678
53289
|
/** Normalize Dahua's `Video.Compression` string (`'H.264'`, `'H.265'`,
|
|
51679
53290
|
* `'MJPG'`, …) into the cap's `'h264' | 'h265'` enum. Returns
|
|
@@ -51706,6 +53317,1987 @@ function collectAudioEnableKeys(table) {
|
|
|
51706
53317
|
for (const key of table.keys()) if (/^Encode\[0\]\.(?:MainFormat|ExtraFormat)\[\d+\]\.AudioEnable$/.test(key)) out.push(key);
|
|
51707
53318
|
return out.toSorted();
|
|
51708
53319
|
}
|
|
53320
|
+
var EMPTY_PARAMS = Object.freeze({});
|
|
53321
|
+
function isRecord$1(value) {
|
|
53322
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
53323
|
+
}
|
|
53324
|
+
/**
|
|
53325
|
+
* Error codes that mean "your session is gone", so one silent re-login is the
|
|
53326
|
+
* right answer rather than a refusal the operator reads.
|
|
53327
|
+
*
|
|
53328
|
+
* Deliberately a SHORT list. An unknown code is a refusal — guessing that any
|
|
53329
|
+
* failure is a stale session turns one camera refusal into two round-trips and
|
|
53330
|
+
* the same refusal, and `findFile`'s 285409284 (which means "nothing matched,
|
|
53331
|
+
* or your condition was malformed") would then be retried on every empty day.
|
|
53332
|
+
*/
|
|
53333
|
+
var RPC2_SESSION_LOST_CODES = new Set([268632064, 268632081]);
|
|
53334
|
+
function md5Upper(input) {
|
|
53335
|
+
return createHash("md5").update(input, "utf8").digest("hex").toUpperCase();
|
|
53336
|
+
}
|
|
53337
|
+
/**
|
|
53338
|
+
* A minimal Dahua RPC2 client. One per camera.
|
|
53339
|
+
*
|
|
53340
|
+
* It exposes exactly what the clip catalog needs — `call`, plus the login
|
|
53341
|
+
* `call` performs for itself. There is no generic "raw request" escape hatch:
|
|
53342
|
+
* a second way into this protocol is a second place to get the session
|
|
53343
|
+
* discipline wrong.
|
|
53344
|
+
*/
|
|
53345
|
+
var AmcrestRpc2Client = class {
|
|
53346
|
+
base;
|
|
53347
|
+
username;
|
|
53348
|
+
password;
|
|
53349
|
+
timeoutMs;
|
|
53350
|
+
fetchImpl;
|
|
53351
|
+
now;
|
|
53352
|
+
session = null;
|
|
53353
|
+
nextId = 0;
|
|
53354
|
+
loginInFlight = null;
|
|
53355
|
+
constructor(opts) {
|
|
53356
|
+
this.base = `${opts.https ? "https" : "http"}://${opts.host}:${String(opts.port)}`;
|
|
53357
|
+
this.username = opts.username;
|
|
53358
|
+
this.password = opts.password;
|
|
53359
|
+
this.timeoutMs = opts.defaultTimeoutMs ?? 15e3;
|
|
53360
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
53361
|
+
this.now = opts.now ?? (() => Date.now());
|
|
53362
|
+
}
|
|
53363
|
+
/**
|
|
53364
|
+
* One RPC2 call, logging in first when there is no live session.
|
|
53365
|
+
*
|
|
53366
|
+
* `object` is the find-object handle for the `mediaFileFind.*` methods that
|
|
53367
|
+
* take one; absent for the factory and for `global.*`.
|
|
53368
|
+
*/
|
|
53369
|
+
async call(method, params, object) {
|
|
53370
|
+
const session = await this.ensureSession();
|
|
53371
|
+
if (session.kind === "unreachable") return {
|
|
53372
|
+
kind: "unreachable",
|
|
53373
|
+
detail: session.detail
|
|
53374
|
+
};
|
|
53375
|
+
if (session.kind === "refused") return {
|
|
53376
|
+
kind: "refused",
|
|
53377
|
+
error: session.error,
|
|
53378
|
+
params: EMPTY_PARAMS,
|
|
53379
|
+
session: null
|
|
53380
|
+
};
|
|
53381
|
+
const first = await this.post("/RPC2", method, params, object, session.session);
|
|
53382
|
+
if (first.kind === "refused" && first.error.code !== null && RPC2_SESSION_LOST_CODES.has(first.error.code)) {
|
|
53383
|
+
this.session = null;
|
|
53384
|
+
const fresh = await this.ensureSession();
|
|
53385
|
+
if (fresh.kind !== "ok") return first;
|
|
53386
|
+
return await this.post("/RPC2", method, params, object, fresh.session);
|
|
53387
|
+
}
|
|
53388
|
+
return first;
|
|
53389
|
+
}
|
|
53390
|
+
/** Drop the session and tell the camera, best-effort. For a client whose
|
|
53391
|
+
* device is going away. */
|
|
53392
|
+
async logout() {
|
|
53393
|
+
const session = this.session;
|
|
53394
|
+
this.session = null;
|
|
53395
|
+
if (session === null) return;
|
|
53396
|
+
await this.post("/RPC2", "global.logout", null, void 0, session.id);
|
|
53397
|
+
}
|
|
53398
|
+
/** Forget the cached session WITHOUT telling the camera — for a client whose
|
|
53399
|
+
* host or credentials just changed under it, where the old session id means
|
|
53400
|
+
* nothing at the new address. */
|
|
53401
|
+
reset() {
|
|
53402
|
+
this.session = null;
|
|
53403
|
+
}
|
|
53404
|
+
async ensureSession() {
|
|
53405
|
+
const live = this.session;
|
|
53406
|
+
if (live !== null && this.now() - live.mintedAtMs < 3e4) return {
|
|
53407
|
+
kind: "ok",
|
|
53408
|
+
session: live.id
|
|
53409
|
+
};
|
|
53410
|
+
this.session = null;
|
|
53411
|
+
const inFlight = this.loginInFlight;
|
|
53412
|
+
if (inFlight !== null) return await inFlight;
|
|
53413
|
+
const promise = this.login();
|
|
53414
|
+
this.loginInFlight = promise;
|
|
53415
|
+
try {
|
|
53416
|
+
return await promise;
|
|
53417
|
+
} finally {
|
|
53418
|
+
this.loginInFlight = null;
|
|
53419
|
+
}
|
|
53420
|
+
}
|
|
53421
|
+
async login() {
|
|
53422
|
+
const challenge = await this.post("/RPC2_Login", "global.login", {
|
|
53423
|
+
userName: this.username,
|
|
53424
|
+
password: "",
|
|
53425
|
+
clientType: "Web3.0"
|
|
53426
|
+
}, void 0, null);
|
|
53427
|
+
if (challenge.kind === "unreachable") return {
|
|
53428
|
+
kind: "unreachable",
|
|
53429
|
+
detail: challenge.detail
|
|
53430
|
+
};
|
|
53431
|
+
const sessionId = challenge.session;
|
|
53432
|
+
const realm = challenge.params["realm"];
|
|
53433
|
+
const random = challenge.params["random"];
|
|
53434
|
+
if (sessionId === null || typeof realm !== "string" || typeof random !== "string" || realm === "" || random === "") return {
|
|
53435
|
+
kind: "refused",
|
|
53436
|
+
error: challenge.kind === "refused" ? challenge.error : {
|
|
53437
|
+
code: null,
|
|
53438
|
+
message: "the login challenge carried no realm/random"
|
|
53439
|
+
}
|
|
53440
|
+
};
|
|
53441
|
+
const inner = md5Upper(`${this.username}:${realm}:${this.password}`);
|
|
53442
|
+
const hashed = md5Upper(`${this.username}:${random}:${inner}`);
|
|
53443
|
+
const confirmed = await this.post("/RPC2_Login", "global.login", {
|
|
53444
|
+
userName: this.username,
|
|
53445
|
+
password: hashed,
|
|
53446
|
+
clientType: "Web3.0",
|
|
53447
|
+
loginType: "Direct",
|
|
53448
|
+
authorityType: "Default",
|
|
53449
|
+
passwordType: "Default"
|
|
53450
|
+
}, void 0, sessionId);
|
|
53451
|
+
if (confirmed.kind === "unreachable") return {
|
|
53452
|
+
kind: "unreachable",
|
|
53453
|
+
detail: confirmed.detail
|
|
53454
|
+
};
|
|
53455
|
+
if (confirmed.kind === "refused") return {
|
|
53456
|
+
kind: "refused",
|
|
53457
|
+
error: confirmed.error
|
|
53458
|
+
};
|
|
53459
|
+
this.session = {
|
|
53460
|
+
id: sessionId,
|
|
53461
|
+
mintedAtMs: this.now()
|
|
53462
|
+
};
|
|
53463
|
+
return {
|
|
53464
|
+
kind: "ok",
|
|
53465
|
+
session: sessionId
|
|
53466
|
+
};
|
|
53467
|
+
}
|
|
53468
|
+
async post(path, method, params, object, session) {
|
|
53469
|
+
this.nextId += 1;
|
|
53470
|
+
const body = {
|
|
53471
|
+
method,
|
|
53472
|
+
params,
|
|
53473
|
+
id: this.nextId
|
|
53474
|
+
};
|
|
53475
|
+
if (object !== void 0) body["object"] = object;
|
|
53476
|
+
if (session !== null) body["session"] = session;
|
|
53477
|
+
const controller = new AbortController();
|
|
53478
|
+
const timer = setTimeout(() => {
|
|
53479
|
+
controller.abort();
|
|
53480
|
+
}, this.timeoutMs);
|
|
53481
|
+
try {
|
|
53482
|
+
const res = await this.fetchImpl(`${this.base}${path}`, {
|
|
53483
|
+
method: "POST",
|
|
53484
|
+
headers: { "content-type": "application/json" },
|
|
53485
|
+
body: JSON.stringify(body),
|
|
53486
|
+
signal: controller.signal
|
|
53487
|
+
});
|
|
53488
|
+
const text = await res.text();
|
|
53489
|
+
let parsed;
|
|
53490
|
+
try {
|
|
53491
|
+
parsed = JSON.parse(text);
|
|
53492
|
+
} catch {
|
|
53493
|
+
return {
|
|
53494
|
+
kind: "unreachable",
|
|
53495
|
+
detail: `RPC2 ${method} answered HTTP ${String(res.status)} with a body that is not JSON`
|
|
53496
|
+
};
|
|
53497
|
+
}
|
|
53498
|
+
if (!isRecord$1(parsed)) return {
|
|
53499
|
+
kind: "unreachable",
|
|
53500
|
+
detail: `RPC2 ${method} answered a non-object body`
|
|
53501
|
+
};
|
|
53502
|
+
const answeredParams = isRecord$1(parsed["params"]) ? parsed["params"] : EMPTY_PARAMS;
|
|
53503
|
+
const answeredSession = readSessionId(parsed["session"]);
|
|
53504
|
+
const answeredResult = parsed["result"];
|
|
53505
|
+
if (answeredResult !== false && answeredResult !== void 0 && answeredResult !== null) return {
|
|
53506
|
+
kind: "ok",
|
|
53507
|
+
params: answeredParams,
|
|
53508
|
+
session: answeredSession,
|
|
53509
|
+
result: answeredResult
|
|
53510
|
+
};
|
|
53511
|
+
return {
|
|
53512
|
+
kind: "refused",
|
|
53513
|
+
error: readError(parsed["error"]),
|
|
53514
|
+
params: answeredParams,
|
|
53515
|
+
session: answeredSession
|
|
53516
|
+
};
|
|
53517
|
+
} catch (err) {
|
|
53518
|
+
return {
|
|
53519
|
+
kind: "unreachable",
|
|
53520
|
+
detail: err instanceof Error ? err.message : String(err)
|
|
53521
|
+
};
|
|
53522
|
+
} finally {
|
|
53523
|
+
clearTimeout(timer);
|
|
53524
|
+
}
|
|
53525
|
+
}
|
|
53526
|
+
};
|
|
53527
|
+
function readSessionId(raw) {
|
|
53528
|
+
if (typeof raw === "string" && raw !== "") return raw;
|
|
53529
|
+
if (typeof raw === "number") return String(raw);
|
|
53530
|
+
return null;
|
|
53531
|
+
}
|
|
53532
|
+
function readError(raw) {
|
|
53533
|
+
if (!isRecord$1(raw)) return {
|
|
53534
|
+
code: null,
|
|
53535
|
+
message: ""
|
|
53536
|
+
};
|
|
53537
|
+
return {
|
|
53538
|
+
code: typeof raw["code"] === "number" ? raw["code"] : null,
|
|
53539
|
+
message: typeof raw["message"] === "string" ? raw["message"] : ""
|
|
53540
|
+
};
|
|
53541
|
+
}
|
|
53542
|
+
/**
|
|
53543
|
+
* A clip key is `<startSec>-<endSec>` (`clip-identity.ts`). Anything else is
|
|
53544
|
+
* REFUSED rather than joined: a key reaches this from a URL path segment, and
|
|
53545
|
+
* a `..` in it is a write outside the scratch.
|
|
53546
|
+
*/
|
|
53547
|
+
var SAFE_KEY = /^[0-9]{1,20}-[0-9]{1,20}$/;
|
|
53548
|
+
var KIND = "clip-media";
|
|
53549
|
+
var EXTENSION = ".mp4";
|
|
53550
|
+
var ClipArtifactStore = class {
|
|
53551
|
+
options;
|
|
53552
|
+
maxFiles;
|
|
53553
|
+
maxBytes;
|
|
53554
|
+
constructor(options) {
|
|
53555
|
+
this.options = options;
|
|
53556
|
+
this.maxFiles = options.maxFilesPerDevice ?? 8;
|
|
53557
|
+
this.maxBytes = options.maxBytesPerDevice ?? 419430400;
|
|
53558
|
+
}
|
|
53559
|
+
/** Is this a key this store will touch at all? Exposed so a ROUTE can refuse
|
|
53560
|
+
* a bad segment by name before it reaches the filesystem. */
|
|
53561
|
+
static isSafeKey(clipKey) {
|
|
53562
|
+
return SAFE_KEY.test(clipKey);
|
|
53563
|
+
}
|
|
53564
|
+
dirFor(deviceId) {
|
|
53565
|
+
return join(this.options.root, KIND, String(deviceId));
|
|
53566
|
+
}
|
|
53567
|
+
pathFor(deviceId, clipKey) {
|
|
53568
|
+
return join(this.dirFor(deviceId), `${clipKey}${EXTENSION}`);
|
|
53569
|
+
}
|
|
53570
|
+
/** A scratch path to write INTO, with its directory made. */
|
|
53571
|
+
async reserve(deviceId, clipKey) {
|
|
53572
|
+
if (!SAFE_KEY.test(clipKey)) throw new Error(`clip artifact store: refused key "${clipKey}" — not a safe artifact key`);
|
|
53573
|
+
await mkdir(this.dirFor(deviceId), { recursive: true });
|
|
53574
|
+
return `${this.pathFor(deviceId, clipKey)}.${String(process.pid)}.part`;
|
|
53575
|
+
}
|
|
53576
|
+
/** Present AND carrying bytes — never a memory of having written one. */
|
|
53577
|
+
async pathIfPresent(deviceId, clipKey) {
|
|
53578
|
+
if (!SAFE_KEY.test(clipKey)) return null;
|
|
53579
|
+
const path = this.pathFor(deviceId, clipKey);
|
|
53580
|
+
try {
|
|
53581
|
+
const info = await stat(path);
|
|
53582
|
+
return info.isFile() && info.size > 0 ? path : null;
|
|
53583
|
+
} catch {
|
|
53584
|
+
return null;
|
|
53585
|
+
}
|
|
53586
|
+
}
|
|
53587
|
+
/**
|
|
53588
|
+
* Publish a reserved path under its key and enforce the bounds.
|
|
53589
|
+
*
|
|
53590
|
+
* A key already present wins: artifacts are immutable, and the temp file is
|
|
53591
|
+
* removed rather than renamed over it.
|
|
53592
|
+
*/
|
|
53593
|
+
async publish(deviceId, clipKey, tempPath) {
|
|
53594
|
+
const existing = await this.pathIfPresent(deviceId, clipKey);
|
|
53595
|
+
if (existing !== null) {
|
|
53596
|
+
await rm(tempPath, { force: true });
|
|
53597
|
+
return existing;
|
|
53598
|
+
}
|
|
53599
|
+
const path = this.pathFor(deviceId, clipKey);
|
|
53600
|
+
await rename(tempPath, path);
|
|
53601
|
+
await this.enforceBounds(deviceId);
|
|
53602
|
+
return path;
|
|
53603
|
+
}
|
|
53604
|
+
async forgetDevice(deviceId) {
|
|
53605
|
+
await rm(this.dirFor(deviceId), {
|
|
53606
|
+
recursive: true,
|
|
53607
|
+
force: true
|
|
53608
|
+
});
|
|
53609
|
+
}
|
|
53610
|
+
async listEntries(deviceId) {
|
|
53611
|
+
let names;
|
|
53612
|
+
try {
|
|
53613
|
+
names = await readdir(this.dirFor(deviceId));
|
|
53614
|
+
} catch {
|
|
53615
|
+
return [];
|
|
53616
|
+
}
|
|
53617
|
+
const out = [];
|
|
53618
|
+
for (const name of names) {
|
|
53619
|
+
if (!name.endsWith(EXTENSION)) continue;
|
|
53620
|
+
const path = join(this.dirFor(deviceId), name);
|
|
53621
|
+
try {
|
|
53622
|
+
const info = await stat(path);
|
|
53623
|
+
if (info.isFile()) out.push({
|
|
53624
|
+
path,
|
|
53625
|
+
bytes: info.size,
|
|
53626
|
+
mtimeMs: info.mtimeMs
|
|
53627
|
+
});
|
|
53628
|
+
} catch {}
|
|
53629
|
+
}
|
|
53630
|
+
return out;
|
|
53631
|
+
}
|
|
53632
|
+
/** Oldest-first eviction until BOTH bounds hold. */
|
|
53633
|
+
async enforceBounds(deviceId) {
|
|
53634
|
+
const entries = [...await this.listEntries(deviceId)].toSorted((a, b) => a.mtimeMs - b.mtimeMs);
|
|
53635
|
+
let files = entries.length;
|
|
53636
|
+
let bytes = entries.reduce((sum, entry) => sum + entry.bytes, 0);
|
|
53637
|
+
for (const entry of entries) {
|
|
53638
|
+
if (files <= this.maxFiles && bytes <= this.maxBytes) break;
|
|
53639
|
+
try {
|
|
53640
|
+
await rm(entry.path, { force: true });
|
|
53641
|
+
files -= 1;
|
|
53642
|
+
bytes -= entry.bytes;
|
|
53643
|
+
} catch {}
|
|
53644
|
+
}
|
|
53645
|
+
}
|
|
53646
|
+
};
|
|
53647
|
+
//#endregion
|
|
53648
|
+
//#region src/clips/clip-clock.ts
|
|
53649
|
+
/**
|
|
53650
|
+
* The camera's clock, and the ONE place a time crosses between it and ours.
|
|
53651
|
+
*
|
|
53652
|
+
* **This is the trap that would put every clip two hours away.** Hikvision
|
|
53653
|
+
* stamps its catalog in UTC with a `Z`. Dahua does not: `mediaFileFind`'s
|
|
53654
|
+
* `StartTime`/`EndTime`, and the `starttime`/`endtime` that `/cam/playback`
|
|
53655
|
+
* accepts, are the camera's own LOCAL wall clock with no zone on them.
|
|
53656
|
+
* Measured 2026-09-23 on device 3836: `global.cgi?action=getCurrentTime`
|
|
53657
|
+
* answered `2026-09-23 11:29:27` while the host's UTC was `09:29:27.711`.
|
|
53658
|
+
*
|
|
53659
|
+
* ## Why the offset is READ and never inferred
|
|
53660
|
+
*
|
|
53661
|
+
* Two shortcuts both fail:
|
|
53662
|
+
*
|
|
53663
|
+
* - **The node's own timezone.** The addon runs wherever the runner runs; a
|
|
53664
|
+
* hub in a UTC container would read the camera's strings two hours early
|
|
53665
|
+
* and ask `/cam/playback` for footage that does not exist yet.
|
|
53666
|
+
* - **The camera's configured `NTP.TimeZone`.** It is a Dahua zone INDEX, it
|
|
53667
|
+
* says nothing about DST on the day in question, and this addon already
|
|
53668
|
+
* knows one Dahua index table is not a fact about the wire.
|
|
53669
|
+
*
|
|
53670
|
+
* So the offset is MEASURED: `getCurrentTime` against our own `Date.now()`.
|
|
53671
|
+
*
|
|
53672
|
+
* ## The offset is RAW, not rounded to a timezone
|
|
53673
|
+
*
|
|
53674
|
+
* It is tempting to round the measured difference to the nearest 15 minutes
|
|
53675
|
+
* and call it a UTC offset. That is wrong for this purpose. What the catalog
|
|
53676
|
+
* needs is a map between *the camera's clock* and *ours* — and a camera whose
|
|
53677
|
+
* clock has drifted ten minutes stamps its files ten minutes off. Carrying the
|
|
53678
|
+
* drift is what makes "the window the operator is looking at" and "the window
|
|
53679
|
+
* the camera will serve" the same window. Rounding it away would reintroduce
|
|
53680
|
+
* exactly the error it was meant to remove.
|
|
53681
|
+
*
|
|
53682
|
+
* A LARGE disagreement is still worth saying out loud, because it is an
|
|
53683
|
+
* operator-visible fault on the camera and not a fact about clips — see
|
|
53684
|
+
* {@link CAMERA_CLOCK_DISAGREEMENT_WARN_MS}. It is not a refusal: the map is
|
|
53685
|
+
* self-consistent at any offset, and refusing would break a working camera
|
|
53686
|
+
* over a cosmetic complaint.
|
|
53687
|
+
*
|
|
53688
|
+
* Pure. No I/O — the caller does the read and hands the two numbers in.
|
|
53689
|
+
*/
|
|
53690
|
+
/**
|
|
53691
|
+
* How long one clock read is trusted.
|
|
53692
|
+
*
|
|
53693
|
+
* Long enough that a listing does not pay for it every time, short enough that
|
|
53694
|
+
* a DST rollover — which moves the offset by a whole hour — is corrected
|
|
53695
|
+
* within one refresh rather than sending every subsequent search an hour
|
|
53696
|
+
* astray.
|
|
53697
|
+
*/
|
|
53698
|
+
var CAMERA_CLOCK_STALE_MS = 10 * 6e4;
|
|
53699
|
+
/**
|
|
53700
|
+
* Past this, the camera's clock and ours disagree by more than any timezone
|
|
53701
|
+
* plus a plausible drift, so the difference is a camera fault worth a line.
|
|
53702
|
+
* 15 hours: the widest real UTC offset is +14, and this leaves room for it.
|
|
53703
|
+
*/
|
|
53704
|
+
var CAMERA_CLOCK_DISAGREEMENT_WARN_MS = 900 * 6e4;
|
|
53705
|
+
/**
|
|
53706
|
+
* The raw body of `global.cgi?action=getCurrentTime`, which answers
|
|
53707
|
+
* `result=2026-09-23 11:29:27`. `null` when there is no timestamp in it — a
|
|
53708
|
+
* read that failed is not a reading (D393), and the caller must refuse rather
|
|
53709
|
+
* than fall back to its own clock.
|
|
53710
|
+
*/
|
|
53711
|
+
function parseCurrentTimeBody(body) {
|
|
53712
|
+
return /result\s*=\s*(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2})/.exec(body)?.[1]?.replace("T", " ") ?? null;
|
|
53713
|
+
}
|
|
53714
|
+
/**
|
|
53715
|
+
* A Dahua wall-clock string read AS IF it were UTC — the intermediate every
|
|
53716
|
+
* conversion goes through. `null` on anything that is not the exact shape,
|
|
53717
|
+
* including the empty string and a date the calendar does not hold.
|
|
53718
|
+
*/
|
|
53719
|
+
function parseWallClockAsUtcMs(wallClock) {
|
|
53720
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})$/.exec(wallClock.trim());
|
|
53721
|
+
if (match === null) return null;
|
|
53722
|
+
const [, y, mo, d, h, mi, s] = match;
|
|
53723
|
+
if (y === void 0 || mo === void 0 || d === void 0) return null;
|
|
53724
|
+
if (h === void 0 || mi === void 0 || s === void 0) return null;
|
|
53725
|
+
const year = Number(y);
|
|
53726
|
+
const month = Number(mo);
|
|
53727
|
+
const day = Number(d);
|
|
53728
|
+
const hour = Number(h);
|
|
53729
|
+
const minute = Number(mi);
|
|
53730
|
+
const second = Number(s);
|
|
53731
|
+
if (month < 1 || month > 12 || day < 1 || day > 31) return null;
|
|
53732
|
+
if (hour > 23 || minute > 59 || second > 59) return null;
|
|
53733
|
+
const ms = Date.UTC(year, month - 1, day, hour, minute, second);
|
|
53734
|
+
const back = new Date(ms);
|
|
53735
|
+
if (back.getUTCFullYear() !== year || back.getUTCMonth() !== month - 1) return null;
|
|
53736
|
+
if (back.getUTCDate() !== day) return null;
|
|
53737
|
+
return ms;
|
|
53738
|
+
}
|
|
53739
|
+
/**
|
|
53740
|
+
* The measured map. `null` when the camera's answer could not be parsed —
|
|
53741
|
+
* never a zero offset, which is the same shape as "the camera is on UTC" and
|
|
53742
|
+
* would silently move an archive by the timezone it really has.
|
|
53743
|
+
*/
|
|
53744
|
+
function deriveCameraClock(input) {
|
|
53745
|
+
const wall = parseCurrentTimeBody(input.currentTimeBody);
|
|
53746
|
+
if (wall === null) return null;
|
|
53747
|
+
const asUtc = parseWallClockAsUtcMs(wall);
|
|
53748
|
+
if (asUtc === null) return null;
|
|
53749
|
+
const hostAt = ((input.hostUtcBeforeMs ?? input.hostUtcAfterMs) + input.hostUtcAfterMs) / 2;
|
|
53750
|
+
return {
|
|
53751
|
+
offsetMs: Math.round((asUtc + 500 - hostAt) / 1e3) * 1e3,
|
|
53752
|
+
readAtMs: input.hostUtcAfterMs
|
|
53753
|
+
};
|
|
53754
|
+
}
|
|
53755
|
+
/** Is this measurement still worth using? */
|
|
53756
|
+
function cameraClockIsFresh(clock, nowMs) {
|
|
53757
|
+
return nowMs - clock.readAtMs < CAMERA_CLOCK_STALE_MS;
|
|
53758
|
+
}
|
|
53759
|
+
/** Does the difference deserve an operator-facing line? */
|
|
53760
|
+
function cameraClockDisagrees(clock) {
|
|
53761
|
+
return Math.abs(clock.offsetMs) > CAMERA_CLOCK_DISAGREEMENT_WARN_MS;
|
|
53762
|
+
}
|
|
53763
|
+
/** An epoch instant as the camera's own wall clock — what a search condition
|
|
53764
|
+
* and a `/cam/playback` query string both take. */
|
|
53765
|
+
function toCameraWallClock(epochMs, clock) {
|
|
53766
|
+
const shifted = new Date(epochMs + clock.offsetMs);
|
|
53767
|
+
const pad = (value, width) => String(value).padStart(width, "0");
|
|
53768
|
+
return `${pad(shifted.getUTCFullYear(), 4)}-${pad(shifted.getUTCMonth() + 1, 2)}-${pad(shifted.getUTCDate(), 2)} ${pad(shifted.getUTCHours(), 2)}:${pad(shifted.getUTCMinutes(), 2)}:${pad(shifted.getUTCSeconds(), 2)}`;
|
|
53769
|
+
}
|
|
53770
|
+
/** The camera's own wall clock as an epoch instant. `null` on a string this
|
|
53771
|
+
* provider could not read — a row with an unreadable time is dropped BY NAME
|
|
53772
|
+
* rather than placed at the epoch. */
|
|
53773
|
+
function fromCameraWallClock(wallClock, clock) {
|
|
53774
|
+
const asUtc = parseWallClockAsUtcMs(wallClock);
|
|
53775
|
+
if (asUtc === null) return null;
|
|
53776
|
+
return asUtc - clock.offsetMs;
|
|
53777
|
+
}
|
|
53778
|
+
//#endregion
|
|
53779
|
+
//#region src/clips/clip-identity.ts
|
|
53780
|
+
/**
|
|
53781
|
+
* An Amcrest clip's IDENTITY — and it is a TIME WINDOW, which is the one thing
|
|
53782
|
+
* that makes this provider different from its two siblings.
|
|
53783
|
+
*
|
|
53784
|
+
* `native:amcrest:onboard:<startSec>:<endSec>`, epoch seconds, OUR clock.
|
|
53785
|
+
*
|
|
53786
|
+
* ## Why the window and not the file name
|
|
53787
|
+
*
|
|
53788
|
+
* Because the camera accepts two different handles for the same recording and
|
|
53789
|
+
* they are not interchangeable (measured 2026-09-23, device 3836):
|
|
53790
|
+
*
|
|
53791
|
+
* - **`/cam/playback?channel=1&starttime=…&endtime=…`** — RTSP playback. The
|
|
53792
|
+
* ONLY form that serves: `?filename=<FilePath>` answers 404 and so does the
|
|
53793
|
+
* bare path form `rtsp://…/mnt/sd/…mp4`.
|
|
53794
|
+
* - **`/cgi-bin/RPC_Loadfile<FilePath>`** — the bytes. Takes the file path and
|
|
53795
|
+
* nothing else.
|
|
53796
|
+
*
|
|
53797
|
+
* So the window is the identity and the path is a property of the row. A
|
|
53798
|
+
* provider that conflated them would address playback by a handle playback
|
|
53799
|
+
* refuses.
|
|
53800
|
+
*
|
|
53801
|
+
* ## The id is self-contained and the row is still required
|
|
53802
|
+
*
|
|
53803
|
+
* The window in the id is enough to build a playback URI, which makes a
|
|
53804
|
+
* deep-link readable and a log line diagnosable. It deliberately does NOT make
|
|
53805
|
+
* the id sufficient: every method still resolves the catalog ROW, and an id
|
|
53806
|
+
* whose row this node has not listed is `catalog-miss` — the truth ("I have
|
|
53807
|
+
* never heard of it"), not a failure. Otherwise a caller could mint an id for
|
|
53808
|
+
* a window nobody checked exists, and the camera would answer a `DESCRIBE`
|
|
53809
|
+
* 404 that this firmware ALSO uses for "all three playback slots are busy".
|
|
53810
|
+
*
|
|
53811
|
+
* Pure. No I/O, no clock.
|
|
53812
|
+
*/
|
|
53813
|
+
/** The camera's own recordings — the one Amcrest source. */
|
|
53814
|
+
var CLIP_SOURCE_ONBOARD = "native:amcrest:onboard";
|
|
53815
|
+
/** Every source this provider can name. Order is the picker's order. */
|
|
53816
|
+
var AMCREST_CLIP_SOURCES = [CLIP_SOURCE_ONBOARD];
|
|
53817
|
+
/**
|
|
53818
|
+
* The key a row is filed under, and the segment a data-plane URL carries.
|
|
53819
|
+
*
|
|
53820
|
+
* `<startSec>-<endSec>` rather than a hash: it is already short, already
|
|
53821
|
+
* unique (two files cannot own the same window on one channel), URL-safe
|
|
53822
|
+
* without encoding, and readable in a log line — which a sha1 is not, on a
|
|
53823
|
+
* provider whose whole identity question is "which window".
|
|
53824
|
+
*/
|
|
53825
|
+
function clipKeyFor(startMs, endMs) {
|
|
53826
|
+
return `${String(Math.floor(startMs / 1e3))}-${String(Math.floor(endMs / 1e3))}`;
|
|
53827
|
+
}
|
|
53828
|
+
function mintClipId(parts) {
|
|
53829
|
+
return `${parts.source}:${clipKeyFor(parts.startMs, parts.endMs)}`;
|
|
53830
|
+
}
|
|
53831
|
+
/**
|
|
53832
|
+
* Parse an id this provider minted. `null` for anything else — including an id
|
|
53833
|
+
* from the analytics, Reolink or Hikvision source, which is how the collection
|
|
53834
|
+
* dispatcher's "nobody claimed this id" branch stays honest.
|
|
53835
|
+
*/
|
|
53836
|
+
function parseClipId(clipId) {
|
|
53837
|
+
const source = AMCREST_CLIP_SOURCES.find((candidate) => clipId.startsWith(`${candidate}:`));
|
|
53838
|
+
if (source === void 0) return null;
|
|
53839
|
+
const rest = clipId.slice(source.length + 1);
|
|
53840
|
+
const match = /^(\d+)-(\d+)$/.exec(rest);
|
|
53841
|
+
if (match === null) return null;
|
|
53842
|
+
const startSec = Number(match[1]);
|
|
53843
|
+
const endSec = Number(match[2]);
|
|
53844
|
+
if (!Number.isSafeInteger(startSec) || !Number.isSafeInteger(endSec)) return null;
|
|
53845
|
+
if (endSec <= startSec) return null;
|
|
53846
|
+
return {
|
|
53847
|
+
source,
|
|
53848
|
+
startMs: startSec * 1e3,
|
|
53849
|
+
endMs: endSec * 1e3
|
|
53850
|
+
};
|
|
53851
|
+
}
|
|
53852
|
+
/** The key a clip id names, without re-deriving the arithmetic. */
|
|
53853
|
+
function clipKeyOf(parts) {
|
|
53854
|
+
return clipKeyFor(parts.startMs, parts.endMs);
|
|
53855
|
+
}
|
|
53856
|
+
//#endregion
|
|
53857
|
+
//#region src/clips/clip-search.ts
|
|
53858
|
+
/**
|
|
53859
|
+
* One `mediaFileFind` row, read. Pure: no RPC, no clock read, no logger.
|
|
53860
|
+
*
|
|
53861
|
+
* ## `Summary` is never read, and that is a hard rule
|
|
53862
|
+
*
|
|
53863
|
+
* Every row this camera returns carries, on a plain `VideoMotion` recording in
|
|
53864
|
+
* a living room, on a camera with no ANPR licence:
|
|
53865
|
+
*
|
|
53866
|
+
* ```json
|
|
53867
|
+
* "Summary": { "TrafficCar": { "PlateNumber": " ", "PlateColor": "Yellow",
|
|
53868
|
+
* "PlateType": "Yellow", "Speed": 60,
|
|
53869
|
+
* "VehicleColor": "White" } }
|
|
53870
|
+
* ```
|
|
53871
|
+
*
|
|
53872
|
+
* `PlateNumber` is a single space and `Speed` is a constant 60 on all 60 rows
|
|
53873
|
+
* measured. It is an uninitialised struct serialised as data. A provider that
|
|
53874
|
+
* surfaced it would invent a licence-plate read and a 60 km/h vehicle on every
|
|
53875
|
+
* motion clip. {@link parseClipRow} reads `Events` and `Flags` and nothing
|
|
53876
|
+
* else about what happened.
|
|
53877
|
+
*
|
|
53878
|
+
* ## `Type` is an ECHO of the filter, not a fact about the file
|
|
53879
|
+
*
|
|
53880
|
+
* Measured 2026-09-23: the SAME recording comes back as `Type: "dav"` when the
|
|
53881
|
+
* search asked `Types: ["dav"]` and as `Type: "mp4"` when it asked
|
|
53882
|
+
* `Types: ["mp4"]` — identical `FilePath` in both, which really ends `.mp4`
|
|
53883
|
+
* and really is an ISO MP4. So the field is not carried onto the row at all.
|
|
53884
|
+
* The filter still matters enormously — see {@link buildFindCondition}.
|
|
53885
|
+
*
|
|
53886
|
+
* ## `Length` is the file, `Duration` is the WINDOW
|
|
53887
|
+
*
|
|
53888
|
+
* `Duration` is the wall-clock span between `StartTime` and `EndTime`. The
|
|
53889
|
+
* container inside runs about 7.5 % shorter (74 s row → 68.433 s media,
|
|
53890
|
+
* measured with `ffprobe`). Both are carried, separately, and a completion
|
|
53891
|
+
* check that compares the delivered media against the ROW naively would fire
|
|
53892
|
+
* on every clip this camera makes.
|
|
53893
|
+
*/
|
|
53894
|
+
/**
|
|
53895
|
+
* The file kinds a CLIP may be.
|
|
53896
|
+
*
|
|
53897
|
+
* **This filter is not cosmetic.** An unfiltered `Flags: ["Event"]` search
|
|
53898
|
+
* over the same window returned 60 rows of which **45 were single JPEG
|
|
53899
|
+
* snapshots** — `Duration: 0`, `StartTime === EndTime`, ~250 KB each. Listing
|
|
53900
|
+
* them would hand the operator three quarters of a catalog that cannot be
|
|
53901
|
+
* played, with a zero-length window that `/cam/playback` has nothing to serve.
|
|
53902
|
+
* `Types: ["dav"]` returned exactly the 15 recordings.
|
|
53903
|
+
*/
|
|
53904
|
+
var CLIP_FIND_TYPES = ["dav"];
|
|
53905
|
+
/**
|
|
53906
|
+
* The record reasons a clip may carry.
|
|
53907
|
+
*
|
|
53908
|
+
* All three, and not just `Event`, because the filter is what the camera
|
|
53909
|
+
* matches on: a camera scheduled for CONTINUOUS recording writes `Timing`
|
|
53910
|
+
* rows, and an `Event`-only search on such a camera would report an empty card
|
|
53911
|
+
* as confidently as a real one. Measured: a three-flag condition returns the
|
|
53912
|
+
* same 15 rows an `Event`-only one does on this camera, so the wider filter
|
|
53913
|
+
* costs nothing where it is not needed.
|
|
53914
|
+
*/
|
|
53915
|
+
var CLIP_FIND_FLAGS = [
|
|
53916
|
+
"Timing",
|
|
53917
|
+
"Event",
|
|
53918
|
+
"Manual"
|
|
53919
|
+
];
|
|
53920
|
+
function isRecord(value) {
|
|
53921
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
53922
|
+
}
|
|
53923
|
+
function readStrings(value) {
|
|
53924
|
+
if (!Array.isArray(value)) return [];
|
|
53925
|
+
const out = [];
|
|
53926
|
+
for (const entry of value) if (typeof entry === "string" && entry !== "") out.push(entry);
|
|
53927
|
+
return out;
|
|
53928
|
+
}
|
|
53929
|
+
/**
|
|
53930
|
+
* A positive finite number, or `null`.
|
|
53931
|
+
*
|
|
53932
|
+
* `0` becomes `null` on purpose for `Length`: a zero-byte recording is not a
|
|
53933
|
+
* measurement, and a bound check that reads it as "comfortably under the
|
|
53934
|
+
* ceiling" would let an unmeasurable file through the one gate that exists to
|
|
53935
|
+
* stop it.
|
|
53936
|
+
*/
|
|
53937
|
+
function readPositiveNumber(value) {
|
|
53938
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return null;
|
|
53939
|
+
return value;
|
|
53940
|
+
}
|
|
53941
|
+
function parseClipRow(raw, clock) {
|
|
53942
|
+
if (!isRecord(raw)) return {
|
|
53943
|
+
kind: "rejected",
|
|
53944
|
+
reason: "no-file-path"
|
|
53945
|
+
};
|
|
53946
|
+
const filePath = raw["FilePath"];
|
|
53947
|
+
if (typeof filePath !== "string" || filePath === "") return {
|
|
53948
|
+
kind: "rejected",
|
|
53949
|
+
reason: "no-file-path"
|
|
53950
|
+
};
|
|
53951
|
+
const startRaw = raw["StartTime"];
|
|
53952
|
+
const endRaw = raw["EndTime"];
|
|
53953
|
+
const startMs = typeof startRaw === "string" ? fromCameraWallClock(startRaw, clock) : null;
|
|
53954
|
+
if (startMs === null) return {
|
|
53955
|
+
kind: "rejected",
|
|
53956
|
+
reason: "unreadable-start"
|
|
53957
|
+
};
|
|
53958
|
+
const endMs = typeof endRaw === "string" ? fromCameraWallClock(endRaw, clock) : null;
|
|
53959
|
+
if (endMs === null) return {
|
|
53960
|
+
kind: "rejected",
|
|
53961
|
+
reason: "unreadable-end"
|
|
53962
|
+
};
|
|
53963
|
+
if (endMs <= startMs) return {
|
|
53964
|
+
kind: "rejected",
|
|
53965
|
+
reason: "not-a-window"
|
|
53966
|
+
};
|
|
53967
|
+
const declaredDuration = readPositiveNumber(raw["Duration"]);
|
|
53968
|
+
const channelRaw = raw["Channel"];
|
|
53969
|
+
return {
|
|
53970
|
+
kind: "ok",
|
|
53971
|
+
row: {
|
|
53972
|
+
clipKey: clipKeyFor(startMs, endMs),
|
|
53973
|
+
startMs,
|
|
53974
|
+
endMs,
|
|
53975
|
+
windowMs: declaredDuration !== null ? declaredDuration * 1e3 : endMs - startMs,
|
|
53976
|
+
sizeBytes: readPositiveNumber(raw["Length"]),
|
|
53977
|
+
filePath,
|
|
53978
|
+
events: readStrings(raw["Events"]),
|
|
53979
|
+
flags: readStrings(raw["Flags"]),
|
|
53980
|
+
channel: typeof channelRaw === "number" && Number.isInteger(channelRaw) ? channelRaw : 0
|
|
53981
|
+
}
|
|
53982
|
+
};
|
|
53983
|
+
}
|
|
53984
|
+
function buildFindCondition(input) {
|
|
53985
|
+
return {
|
|
53986
|
+
Channel: input.channel,
|
|
53987
|
+
StartTime: input.startWallClock,
|
|
53988
|
+
EndTime: input.endWallClock,
|
|
53989
|
+
Flags: CLIP_FIND_FLAGS,
|
|
53990
|
+
Types: CLIP_FIND_TYPES
|
|
53991
|
+
};
|
|
53992
|
+
}
|
|
53993
|
+
/**
|
|
53994
|
+
* Has the walk reached the end?
|
|
53995
|
+
*
|
|
53996
|
+
* `found < count` and nothing else. **`mediaFileFind.getCount` must never be
|
|
53997
|
+
* called**: over CGI it is `501 Not Implemented`, and over RPC2 it is worse —
|
|
53998
|
+
* measured answering `{"params":{"count":0},"result":true}` on a find object
|
|
53999
|
+
* whose `findFile` had just succeeded and from which rows were then
|
|
54000
|
+
* enumerated. That is an authoritative-looking zero from a method that
|
|
54001
|
+
* answered successfully, which is the exact shape D393 forbids, produced by
|
|
54002
|
+
* the camera itself.
|
|
54003
|
+
*/
|
|
54004
|
+
function findWalkIsComplete(found, asked) {
|
|
54005
|
+
return found < asked;
|
|
54006
|
+
}
|
|
54007
|
+
//#endregion
|
|
54008
|
+
//#region src/clips/clip-catalog.ts
|
|
54009
|
+
/**
|
|
54010
|
+
* The control window — the camera's own wall clock, wide enough that no card
|
|
54011
|
+
* can fall outside it. Measured accepted on this firmware (`findFile` true in
|
|
54012
|
+
* 361 ms).
|
|
54013
|
+
*/
|
|
54014
|
+
var CLIP_CONTROL_WINDOW = {
|
|
54015
|
+
start: "1970-01-01 00:00:00",
|
|
54016
|
+
end: "2100-01-01 00:00:00"
|
|
54017
|
+
};
|
|
54018
|
+
function describeError(error) {
|
|
54019
|
+
const code = error.code === null ? "no code" : `code ${String(error.code)}`;
|
|
54020
|
+
return error.message === "" ? `${code}, no message` : `${code}: ${error.message}`;
|
|
54021
|
+
}
|
|
54022
|
+
async function runSearch(deps, input) {
|
|
54023
|
+
const tags = { deviceId: deps.deviceId };
|
|
54024
|
+
const created = await deps.rpc.call("mediaFileFind.factory.create", null);
|
|
54025
|
+
if (created.kind === "unreachable") return {
|
|
54026
|
+
kind: "unreachable",
|
|
54027
|
+
detail: created.detail
|
|
54028
|
+
};
|
|
54029
|
+
if (created.kind === "refused") return {
|
|
54030
|
+
kind: "unreachable",
|
|
54031
|
+
detail: `the camera refused a find object (${describeError(created.error)})`
|
|
54032
|
+
};
|
|
54033
|
+
const handle = created.result;
|
|
54034
|
+
if (typeof handle !== "number" || !Number.isFinite(handle)) return {
|
|
54035
|
+
kind: "unreachable",
|
|
54036
|
+
detail: "the camera answered a find-object handle this provider could not read"
|
|
54037
|
+
};
|
|
54038
|
+
try {
|
|
54039
|
+
const condition = buildFindCondition({
|
|
54040
|
+
channel: input.channel,
|
|
54041
|
+
startWallClock: input.startWallClock,
|
|
54042
|
+
endWallClock: input.endWallClock
|
|
54043
|
+
});
|
|
54044
|
+
const started = await deps.rpc.call("mediaFileFind.findFile", { condition }, handle);
|
|
54045
|
+
if (started.kind === "unreachable") return {
|
|
54046
|
+
kind: "unreachable",
|
|
54047
|
+
detail: started.detail
|
|
54048
|
+
};
|
|
54049
|
+
if (started.kind === "refused") return {
|
|
54050
|
+
kind: "refused",
|
|
54051
|
+
detail: describeError(started.error)
|
|
54052
|
+
};
|
|
54053
|
+
if (!input.enumerate) return {
|
|
54054
|
+
kind: "ok",
|
|
54055
|
+
rows: [],
|
|
54056
|
+
pages: 0,
|
|
54057
|
+
rejected: {},
|
|
54058
|
+
rejectedCount: 0
|
|
54059
|
+
};
|
|
54060
|
+
const rows = [];
|
|
54061
|
+
const rejected = {};
|
|
54062
|
+
let rejectedCount = 0;
|
|
54063
|
+
let pages = 0;
|
|
54064
|
+
for (; pages < 64;) {
|
|
54065
|
+
const page = await deps.rpc.call("mediaFileFind.findNextFile", { count: 64 }, handle);
|
|
54066
|
+
pages += 1;
|
|
54067
|
+
if (page.kind === "unreachable") return {
|
|
54068
|
+
kind: "unreachable",
|
|
54069
|
+
detail: page.detail
|
|
54070
|
+
};
|
|
54071
|
+
if (page.kind === "refused") return {
|
|
54072
|
+
kind: "unreachable",
|
|
54073
|
+
detail: `page ${String(pages)} of the search refused (${describeError(page.error)})`
|
|
54074
|
+
};
|
|
54075
|
+
const foundRaw = page.params["found"];
|
|
54076
|
+
const found = typeof foundRaw === "number" && Number.isFinite(foundRaw) ? foundRaw : 0;
|
|
54077
|
+
const infos = page.params["infos"];
|
|
54078
|
+
const list = Array.isArray(infos) ? infos : [];
|
|
54079
|
+
for (const raw of list) {
|
|
54080
|
+
const parsed = parseClipRow(raw, input.clock);
|
|
54081
|
+
if (parsed.kind === "ok") {
|
|
54082
|
+
rows.push(parsed.row);
|
|
54083
|
+
continue;
|
|
54084
|
+
}
|
|
54085
|
+
rejected[parsed.reason] = (rejected[parsed.reason] ?? 0) + 1;
|
|
54086
|
+
rejectedCount += 1;
|
|
54087
|
+
}
|
|
54088
|
+
if (findWalkIsComplete(found, 64)) break;
|
|
54089
|
+
if (list.length === 0) {
|
|
54090
|
+
deps.logger.warn("videoclips: a search page claimed more rows and carried none", {
|
|
54091
|
+
tags,
|
|
54092
|
+
meta: {
|
|
54093
|
+
branch: "find-empty-page",
|
|
54094
|
+
page: pages,
|
|
54095
|
+
found
|
|
54096
|
+
}
|
|
54097
|
+
});
|
|
54098
|
+
break;
|
|
54099
|
+
}
|
|
54100
|
+
}
|
|
54101
|
+
if (pages >= 64) deps.logger.warn("videoclips: a search never terminated — stopping at the page bound", {
|
|
54102
|
+
tags,
|
|
54103
|
+
meta: {
|
|
54104
|
+
branch: "find-page-bound",
|
|
54105
|
+
pages,
|
|
54106
|
+
rows: rows.length
|
|
54107
|
+
}
|
|
54108
|
+
});
|
|
54109
|
+
return {
|
|
54110
|
+
kind: "ok",
|
|
54111
|
+
rows,
|
|
54112
|
+
pages,
|
|
54113
|
+
rejected,
|
|
54114
|
+
rejectedCount
|
|
54115
|
+
};
|
|
54116
|
+
} finally {
|
|
54117
|
+
await releaseFindObject(deps, handle);
|
|
54118
|
+
}
|
|
54119
|
+
}
|
|
54120
|
+
async function releaseFindObject(deps, handle) {
|
|
54121
|
+
for (const method of ["mediaFileFind.close", "mediaFileFind.destroy"]) {
|
|
54122
|
+
const released = await deps.rpc.call(method, null, handle);
|
|
54123
|
+
if (released.kind === "ok") continue;
|
|
54124
|
+
deps.logger.warn("videoclips: a find object was not released", {
|
|
54125
|
+
tags: { deviceId: deps.deviceId },
|
|
54126
|
+
meta: {
|
|
54127
|
+
branch: "find-object-leak",
|
|
54128
|
+
method,
|
|
54129
|
+
detail: released.kind === "refused" ? describeError(released.error) : released.detail
|
|
54130
|
+
}
|
|
54131
|
+
});
|
|
54132
|
+
}
|
|
54133
|
+
}
|
|
54134
|
+
/**
|
|
54135
|
+
* Every row the camera holds in a window.
|
|
54136
|
+
*
|
|
54137
|
+
* The window travels as the CAMERA's wall clock, converted through the
|
|
54138
|
+
* measured offset. A UTC window would be two hours off in summer and one in
|
|
54139
|
+
* winter, and would silently return the wrong footage rather than an error.
|
|
54140
|
+
*/
|
|
54141
|
+
async function readClipRows(deps, input) {
|
|
54142
|
+
const tags = { deviceId: deps.deviceId };
|
|
54143
|
+
const startWallClock = toCameraWallClock(input.sinceMs, input.clock);
|
|
54144
|
+
const endWallClock = toCameraWallClock(input.untilMs, input.clock);
|
|
54145
|
+
const asked = await runSearch(deps, {
|
|
54146
|
+
startWallClock,
|
|
54147
|
+
endWallClock,
|
|
54148
|
+
channel: input.channel,
|
|
54149
|
+
clock: input.clock,
|
|
54150
|
+
enumerate: true
|
|
54151
|
+
});
|
|
54152
|
+
if (asked.kind === "ok") {
|
|
54153
|
+
if (asked.rejectedCount > 0) deps.logger.warn("videoclips: the camera sent rows this provider could not read", {
|
|
54154
|
+
tags,
|
|
54155
|
+
meta: {
|
|
54156
|
+
branch: "rows-rejected",
|
|
54157
|
+
rejected: asked.rejected,
|
|
54158
|
+
rejectedCount: asked.rejectedCount,
|
|
54159
|
+
kept: asked.rows.length
|
|
54160
|
+
}
|
|
54161
|
+
});
|
|
54162
|
+
return asked;
|
|
54163
|
+
}
|
|
54164
|
+
if (asked.kind === "unreachable") return {
|
|
54165
|
+
kind: "unreachable",
|
|
54166
|
+
detail: asked.detail
|
|
54167
|
+
};
|
|
54168
|
+
const control = await runSearch(deps, {
|
|
54169
|
+
startWallClock: CLIP_CONTROL_WINDOW.start,
|
|
54170
|
+
endWallClock: CLIP_CONTROL_WINDOW.end,
|
|
54171
|
+
channel: input.channel,
|
|
54172
|
+
clock: input.clock,
|
|
54173
|
+
enumerate: false
|
|
54174
|
+
});
|
|
54175
|
+
if (control.kind === "unreachable") return {
|
|
54176
|
+
kind: "unreachable",
|
|
54177
|
+
detail: `the camera refused the search for ${startWallClock} … ${endWallClock} (${asked.detail}) and did not answer the control search (${control.detail})`
|
|
54178
|
+
};
|
|
54179
|
+
if (control.kind === "ok") {
|
|
54180
|
+
deps.logger.info("videoclips: the window holds nothing, proved against a control search", {
|
|
54181
|
+
tags,
|
|
54182
|
+
meta: {
|
|
54183
|
+
branch: "empty-window",
|
|
54184
|
+
startWallClock,
|
|
54185
|
+
endWallClock,
|
|
54186
|
+
refusal: asked.detail
|
|
54187
|
+
}
|
|
54188
|
+
});
|
|
54189
|
+
return { kind: "empty-window" };
|
|
54190
|
+
}
|
|
54191
|
+
return {
|
|
54192
|
+
kind: "index-empty",
|
|
54193
|
+
detail: `the camera refused both the asked window (${startWallClock} … ${endWallClock}: ${asked.detail}) and an unbounded control search (${CLIP_CONTROL_WINDOW.start} … ${CLIP_CONTROL_WINDOW.end}: ${control.detail}) — its index answers nothing anywhere`
|
|
54194
|
+
};
|
|
54195
|
+
}
|
|
54196
|
+
//#endregion
|
|
54197
|
+
//#region src/clips/clip-data-plane.ts
|
|
54198
|
+
/**
|
|
54199
|
+
* The addon's clip FILE route.
|
|
54200
|
+
*
|
|
54201
|
+
* Hosted by `ctx.dataPlane.serve({ access: 'authenticated' })`, so the hub
|
|
54202
|
+
* authenticates and reverse-proxies and the addon produces the bytes. **No
|
|
54203
|
+
* token in the URL**: the hub's `/addon/<id>/<prefix>` proxy reads the
|
|
54204
|
+
* credential from `authorization` or the session cookie, so a clip URL may sit
|
|
54205
|
+
* in history, in a `Referer` or in a shared log and open nothing (D549 22).
|
|
54206
|
+
*
|
|
54207
|
+
* It serves the re-indexed MP4 — `moov` first — with `Range`, which is what a
|
|
54208
|
+
* `<video>` element needs to show a duration and to seek. The camera's own
|
|
54209
|
+
* copy has the `moov` LAST and ignores `Range` outright, so the seekability
|
|
54210
|
+
* this route offers is bought by `clip-file.ts`'s one `-c copy` pass and is
|
|
54211
|
+
* not a property of the wire.
|
|
54212
|
+
*
|
|
54213
|
+
* There is deliberately **no STREAM route here**. The camera's forward-only
|
|
54214
|
+
* `/cam/playback` replay is real and is not wired — see the ADR: it is a
|
|
54215
|
+
* separate slice, it needs a provider-owned slot gate because the camera's own
|
|
54216
|
+
* exhaustion signal is a 404 that means "gone", and it cannot honestly answer
|
|
54217
|
+
* the broker's rate ladder.
|
|
54218
|
+
*
|
|
54219
|
+
* `HEAD` answers headers and touches no camera.
|
|
54220
|
+
*/
|
|
54221
|
+
/** URL namespace under `/addon/provider-amcrest/`. */
|
|
54222
|
+
var CLIP_MEDIA_PREFIX = "clip";
|
|
54223
|
+
/**
|
|
54224
|
+
* A clip key is `<startSec>-<endSec>` and nothing else may reach the
|
|
54225
|
+
* filesystem. The store refuses the same shape a second time — two walls,
|
|
54226
|
+
* because this one is reachable from a URL.
|
|
54227
|
+
*/
|
|
54228
|
+
var MEDIA_PATH = /^\/(\d+)\/(\d{1,20}-\d{1,20})\.mp4$/;
|
|
54229
|
+
function pathnameOf(req) {
|
|
54230
|
+
const raw = req.url ?? "/";
|
|
54231
|
+
const query = raw.indexOf("?");
|
|
54232
|
+
return query === -1 ? raw : raw.slice(0, query);
|
|
54233
|
+
}
|
|
54234
|
+
/**
|
|
54235
|
+
* `bytes=a-b` / `bytes=a-` / `bytes=-n`.
|
|
54236
|
+
*
|
|
54237
|
+
* `'unsatisfiable'` is a 416 rather than a silently empty 206: a player that
|
|
54238
|
+
* asked past the end must learn it did, not receive zero bytes that look like
|
|
54239
|
+
* the end of the file.
|
|
54240
|
+
*/
|
|
54241
|
+
function parseRange(header, size) {
|
|
54242
|
+
if (header === void 0) return null;
|
|
54243
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
|
|
54244
|
+
if (match === null) return null;
|
|
54245
|
+
const [, rawStart, rawEnd] = match;
|
|
54246
|
+
if (rawStart === "" && rawEnd === "") return "unsatisfiable";
|
|
54247
|
+
if (rawStart === "") {
|
|
54248
|
+
const length = Number(rawEnd);
|
|
54249
|
+
if (length <= 0) return "unsatisfiable";
|
|
54250
|
+
return {
|
|
54251
|
+
start: Math.max(0, size - length),
|
|
54252
|
+
end: size - 1
|
|
54253
|
+
};
|
|
54254
|
+
}
|
|
54255
|
+
const start = Number(rawStart);
|
|
54256
|
+
if (start >= size) return "unsatisfiable";
|
|
54257
|
+
const end = rawEnd === "" ? size - 1 : Math.min(Number(rawEnd), size - 1);
|
|
54258
|
+
if (end < start) return "unsatisfiable";
|
|
54259
|
+
return {
|
|
54260
|
+
start,
|
|
54261
|
+
end
|
|
54262
|
+
};
|
|
54263
|
+
}
|
|
54264
|
+
function createClipPlaybackHandler(deps) {
|
|
54265
|
+
return async (req, res) => {
|
|
54266
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
54267
|
+
res.writeHead(405, { allow: "GET, HEAD" });
|
|
54268
|
+
res.end();
|
|
54269
|
+
return;
|
|
54270
|
+
}
|
|
54271
|
+
const match = MEDIA_PATH.exec(pathnameOf(req));
|
|
54272
|
+
if (match === null) {
|
|
54273
|
+
res.writeHead(400);
|
|
54274
|
+
res.end();
|
|
54275
|
+
return;
|
|
54276
|
+
}
|
|
54277
|
+
const deviceId = Number(match[1]);
|
|
54278
|
+
const clipKey = match[2] ?? "";
|
|
54279
|
+
let path;
|
|
54280
|
+
try {
|
|
54281
|
+
path = await deps.resolvePath(deviceId, clipKey);
|
|
54282
|
+
} catch (err) {
|
|
54283
|
+
deps.logger.warn("videoclips: could not prepare a clip for playback", {
|
|
54284
|
+
tags: { deviceId },
|
|
54285
|
+
meta: {
|
|
54286
|
+
clipKey,
|
|
54287
|
+
branch: "prepare-threw",
|
|
54288
|
+
error: err instanceof Error ? err.message : String(err)
|
|
54289
|
+
}
|
|
54290
|
+
});
|
|
54291
|
+
res.writeHead(500);
|
|
54292
|
+
res.end();
|
|
54293
|
+
return;
|
|
54294
|
+
}
|
|
54295
|
+
if (path === null) {
|
|
54296
|
+
res.writeHead(404);
|
|
54297
|
+
res.end();
|
|
54298
|
+
return;
|
|
54299
|
+
}
|
|
54300
|
+
const size = (await stat(path)).size;
|
|
54301
|
+
const base = {
|
|
54302
|
+
"content-type": "video/mp4",
|
|
54303
|
+
"accept-ranges": "bytes",
|
|
54304
|
+
"cache-control": "private, max-age=0, must-revalidate"
|
|
54305
|
+
};
|
|
54306
|
+
const range = parseRange(req.headers.range, size);
|
|
54307
|
+
if (range === "unsatisfiable") {
|
|
54308
|
+
res.writeHead(416, {
|
|
54309
|
+
...base,
|
|
54310
|
+
"content-range": `bytes */${String(size)}`
|
|
54311
|
+
});
|
|
54312
|
+
res.end();
|
|
54313
|
+
return;
|
|
54314
|
+
}
|
|
54315
|
+
if (range === null) {
|
|
54316
|
+
res.writeHead(200, {
|
|
54317
|
+
...base,
|
|
54318
|
+
"content-length": String(size)
|
|
54319
|
+
});
|
|
54320
|
+
if (req.method === "HEAD") {
|
|
54321
|
+
res.end();
|
|
54322
|
+
return;
|
|
54323
|
+
}
|
|
54324
|
+
createReadStream(path).pipe(res);
|
|
54325
|
+
return;
|
|
54326
|
+
}
|
|
54327
|
+
res.writeHead(206, {
|
|
54328
|
+
...base,
|
|
54329
|
+
"content-range": `bytes ${String(range.start)}-${String(range.end)}/${String(size)}`,
|
|
54330
|
+
"content-length": String(range.end - range.start + 1)
|
|
54331
|
+
});
|
|
54332
|
+
if (req.method === "HEAD") {
|
|
54333
|
+
res.end();
|
|
54334
|
+
return;
|
|
54335
|
+
}
|
|
54336
|
+
createReadStream(path, {
|
|
54337
|
+
start: range.start,
|
|
54338
|
+
end: range.end
|
|
54339
|
+
}).pipe(res);
|
|
54340
|
+
};
|
|
54341
|
+
}
|
|
54342
|
+
/** How long one remux may run before it is killed. A `-c copy` pass over
|
|
54343
|
+
* 56 MB is seconds; a minute is a bound on a hang, not a budget. */
|
|
54344
|
+
var CLIP_FILE_REMUX_TIMEOUT_MS = 6e4;
|
|
54345
|
+
/**
|
|
54346
|
+
* ffmpeg's own line for the container it opened —
|
|
54347
|
+
* `Duration: 00:01:08.43, start: …`. MEASURED, off the file we are about to
|
|
54348
|
+
* serve, which is the only thing that can say how long it really runs.
|
|
54349
|
+
*/
|
|
54350
|
+
function parseFfmpegDurationMs(stderr) {
|
|
54351
|
+
const match = /Duration:\s*(\d+):(\d{2}):(\d{2})\.(\d{1,3})/.exec(stderr);
|
|
54352
|
+
if (match === null) return null;
|
|
54353
|
+
const [, h, m, s, frac] = match;
|
|
54354
|
+
if (h === void 0 || m === void 0 || s === void 0 || frac === void 0) return null;
|
|
54355
|
+
const ms = Number(frac.padEnd(3, "0"));
|
|
54356
|
+
const total = ((Number(h) * 60 + Number(m)) * 60 + Number(s)) * 1e3 + ms;
|
|
54357
|
+
return total > 0 ? total : null;
|
|
54358
|
+
}
|
|
54359
|
+
/** The `-c copy -movflags +faststart` pass, spelled once. */
|
|
54360
|
+
function buildClipFaststartArgs(source, target) {
|
|
54361
|
+
return [
|
|
54362
|
+
"-hide_banner",
|
|
54363
|
+
"-loglevel",
|
|
54364
|
+
"info",
|
|
54365
|
+
"-y",
|
|
54366
|
+
"-i",
|
|
54367
|
+
source,
|
|
54368
|
+
"-c",
|
|
54369
|
+
"copy",
|
|
54370
|
+
"-movflags",
|
|
54371
|
+
"+faststart",
|
|
54372
|
+
"-f",
|
|
54373
|
+
"mp4",
|
|
54374
|
+
target
|
|
54375
|
+
];
|
|
54376
|
+
}
|
|
54377
|
+
async function fetchRecording(input, deps, target, maxFetchBytes) {
|
|
54378
|
+
let download;
|
|
54379
|
+
try {
|
|
54380
|
+
download = await deps.client.openRecordedFile(input.row.filePath);
|
|
54381
|
+
} catch (err) {
|
|
54382
|
+
return {
|
|
54383
|
+
kind: "refused",
|
|
54384
|
+
bytes: 0,
|
|
54385
|
+
code: "camera-refused",
|
|
54386
|
+
detail: `the camera closed the connection for "${input.row.filePath}" without answering (${err instanceof Error ? err.message : String(err)}) — on this firmware that is how a recording it no longer holds is refused`
|
|
54387
|
+
};
|
|
54388
|
+
}
|
|
54389
|
+
if (download.status !== 200) return {
|
|
54390
|
+
kind: "refused",
|
|
54391
|
+
bytes: 0,
|
|
54392
|
+
code: "camera-refused",
|
|
54393
|
+
detail: `the camera answered HTTP ${String(download.status)} for "${input.row.filePath}"`
|
|
54394
|
+
};
|
|
54395
|
+
if (download.contentLength !== null && download.contentLength > maxFetchBytes) return {
|
|
54396
|
+
kind: "refused",
|
|
54397
|
+
bytes: 0,
|
|
54398
|
+
code: "too-large-to-transfer",
|
|
54399
|
+
detail: `the camera declares "${input.row.filePath}" as ${String(download.contentLength)} bytes, over the ${String(maxFetchBytes)}-byte fetch ceiling`
|
|
54400
|
+
};
|
|
54401
|
+
const body = download.body;
|
|
54402
|
+
if (body === null) return {
|
|
54403
|
+
kind: "refused",
|
|
54404
|
+
bytes: 0,
|
|
54405
|
+
code: "fetch-failed",
|
|
54406
|
+
detail: `the camera answered 200 for "${input.row.filePath}" and sent no body`
|
|
54407
|
+
};
|
|
54408
|
+
let bytes = 0;
|
|
54409
|
+
let overflowed = false;
|
|
54410
|
+
const source = Readable.fromWeb(body);
|
|
54411
|
+
source.on("data", (chunk) => {
|
|
54412
|
+
bytes += chunk.byteLength;
|
|
54413
|
+
if (bytes > maxFetchBytes && !overflowed) {
|
|
54414
|
+
overflowed = true;
|
|
54415
|
+
source.destroy(/* @__PURE__ */ new Error(`fetch ceiling of ${String(maxFetchBytes)} bytes passed`));
|
|
54416
|
+
}
|
|
54417
|
+
});
|
|
54418
|
+
try {
|
|
54419
|
+
await pipeline(source, createWriteStream(target));
|
|
54420
|
+
} catch (err) {
|
|
54421
|
+
if (overflowed) return {
|
|
54422
|
+
kind: "refused",
|
|
54423
|
+
bytes,
|
|
54424
|
+
code: "too-large-to-transfer",
|
|
54425
|
+
detail: `"${input.row.filePath}" passed the ${String(maxFetchBytes)}-byte fetch ceiling before the camera finished it`
|
|
54426
|
+
};
|
|
54427
|
+
return {
|
|
54428
|
+
kind: "refused",
|
|
54429
|
+
bytes,
|
|
54430
|
+
code: "fetch-failed",
|
|
54431
|
+
detail: `the transfer of "${input.row.filePath}" ended after ${String(bytes)} bytes: ${err instanceof Error ? err.message : String(err)}`
|
|
54432
|
+
};
|
|
54433
|
+
}
|
|
54434
|
+
if (bytes === 0) return {
|
|
54435
|
+
kind: "refused",
|
|
54436
|
+
bytes: 0,
|
|
54437
|
+
code: "fetch-failed",
|
|
54438
|
+
detail: `the camera answered 200 for "${input.row.filePath}" and delivered no bytes`
|
|
54439
|
+
};
|
|
54440
|
+
if (download.contentLength !== null && bytes !== download.contentLength) return {
|
|
54441
|
+
kind: "refused",
|
|
54442
|
+
bytes,
|
|
54443
|
+
code: "fetch-failed",
|
|
54444
|
+
detail: `the camera declared ${String(download.contentLength)} bytes for "${input.row.filePath}" and delivered ${String(bytes)}`
|
|
54445
|
+
};
|
|
54446
|
+
return {
|
|
54447
|
+
kind: "ok",
|
|
54448
|
+
bytes
|
|
54449
|
+
};
|
|
54450
|
+
}
|
|
54451
|
+
/**
|
|
54452
|
+
* Fetch a whole recording and re-index it, or answer the cached file.
|
|
54453
|
+
*
|
|
54454
|
+
* Never throws for a gated branch: a refusal carries its code and its prose so
|
|
54455
|
+
* the caller can turn it into the cap's own vocabulary.
|
|
54456
|
+
*/
|
|
54457
|
+
async function prepareClipFile(input, deps) {
|
|
54458
|
+
const tags = { deviceId: input.deviceId };
|
|
54459
|
+
const meta = { clipKey: input.row.clipKey };
|
|
54460
|
+
const cached = await deps.store.pathIfPresent(input.deviceId, input.row.clipKey);
|
|
54461
|
+
if (cached !== null) {
|
|
54462
|
+
const info = await stat(cached);
|
|
54463
|
+
deps.logger.debug("videoclips: served a clip file from the scratch", {
|
|
54464
|
+
tags,
|
|
54465
|
+
meta: {
|
|
54466
|
+
...meta,
|
|
54467
|
+
bytes: info.size
|
|
54468
|
+
}
|
|
54469
|
+
});
|
|
54470
|
+
return {
|
|
54471
|
+
kind: "ok",
|
|
54472
|
+
path: cached,
|
|
54473
|
+
bytes: info.size,
|
|
54474
|
+
durationMs: null
|
|
54475
|
+
};
|
|
54476
|
+
}
|
|
54477
|
+
const maxFetchBytes = deps.maxFetchBytes ?? 536870912;
|
|
54478
|
+
const startedAt = Date.now();
|
|
54479
|
+
const raw = `${await deps.store.reserve(input.deviceId, input.row.clipKey)}.raw`;
|
|
54480
|
+
const temp = await deps.store.reserve(input.deviceId, input.row.clipKey);
|
|
54481
|
+
try {
|
|
54482
|
+
const fetched = await fetchRecording(input, deps, raw, maxFetchBytes);
|
|
54483
|
+
if (fetched.kind === "refused") {
|
|
54484
|
+
deps.logger.warn("videoclips: a clip fetch was refused", {
|
|
54485
|
+
tags,
|
|
54486
|
+
meta: {
|
|
54487
|
+
...meta,
|
|
54488
|
+
branch: fetched.code ?? "fetch-failed",
|
|
54489
|
+
bytes: fetched.bytes
|
|
54490
|
+
}
|
|
54491
|
+
});
|
|
54492
|
+
return {
|
|
54493
|
+
kind: "refused",
|
|
54494
|
+
code: fetched.code ?? "fetch-failed",
|
|
54495
|
+
detail: fetched.detail ?? "the fetch failed"
|
|
54496
|
+
};
|
|
54497
|
+
}
|
|
54498
|
+
const fetchedAt = Date.now();
|
|
54499
|
+
const args = buildClipFaststartArgs(raw, temp);
|
|
54500
|
+
const child = deps.spawnMux?.([...args]) ?? spawn(deps.ffmpegBinaryPath ?? "ffmpeg", [...args], { stdio: [
|
|
54501
|
+
"ignore",
|
|
54502
|
+
"ignore",
|
|
54503
|
+
"pipe"
|
|
54504
|
+
] });
|
|
54505
|
+
let stderr = "";
|
|
54506
|
+
child.stderr?.on("data", (chunk) => {
|
|
54507
|
+
stderr = `${stderr}${chunk.toString()}`.slice(-4e3);
|
|
54508
|
+
});
|
|
54509
|
+
const killer = setTimeout(() => {
|
|
54510
|
+
child.kill("SIGKILL");
|
|
54511
|
+
}, CLIP_FILE_REMUX_TIMEOUT_MS);
|
|
54512
|
+
const exit = await new Promise((resolve) => {
|
|
54513
|
+
child.on("close", (code) => {
|
|
54514
|
+
resolve(code);
|
|
54515
|
+
});
|
|
54516
|
+
child.on("error", () => {
|
|
54517
|
+
resolve(-1);
|
|
54518
|
+
});
|
|
54519
|
+
});
|
|
54520
|
+
clearTimeout(killer);
|
|
54521
|
+
if (exit !== 0) {
|
|
54522
|
+
deps.logger.warn("videoclips: a clip re-index failed", {
|
|
54523
|
+
tags,
|
|
54524
|
+
meta: {
|
|
54525
|
+
...meta,
|
|
54526
|
+
branch: "fetch-failed",
|
|
54527
|
+
exit,
|
|
54528
|
+
stderr: stderr.trim().slice(-300)
|
|
54529
|
+
}
|
|
54530
|
+
});
|
|
54531
|
+
return {
|
|
54532
|
+
kind: "refused",
|
|
54533
|
+
code: "fetch-failed",
|
|
54534
|
+
detail: `ffmpeg exited ${String(exit)}: ${stderr.trim().slice(-300)}`
|
|
54535
|
+
};
|
|
54536
|
+
}
|
|
54537
|
+
const durationMs = parseFfmpegDurationMs(stderr);
|
|
54538
|
+
const path = await deps.store.publish(input.deviceId, input.row.clipKey, temp);
|
|
54539
|
+
const info = await stat(path);
|
|
54540
|
+
deps.logger.info("videoclips: prepared an Amcrest clip file", {
|
|
54541
|
+
tags,
|
|
54542
|
+
meta: {
|
|
54543
|
+
...meta,
|
|
54544
|
+
fetchedBytes: fetched.bytes,
|
|
54545
|
+
listedBytes: input.row.sizeBytes,
|
|
54546
|
+
bytes: info.size,
|
|
54547
|
+
durationMs,
|
|
54548
|
+
rowWindowMs: input.row.windowMs,
|
|
54549
|
+
fetchMs: fetchedAt - startedAt,
|
|
54550
|
+
remuxMs: Date.now() - fetchedAt
|
|
54551
|
+
}
|
|
54552
|
+
});
|
|
54553
|
+
return {
|
|
54554
|
+
kind: "ok",
|
|
54555
|
+
path,
|
|
54556
|
+
bytes: info.size,
|
|
54557
|
+
durationMs
|
|
54558
|
+
};
|
|
54559
|
+
} finally {
|
|
54560
|
+
await rm(raw, { force: true });
|
|
54561
|
+
await rm(temp, { force: true });
|
|
54562
|
+
}
|
|
54563
|
+
}
|
|
54564
|
+
//#endregion
|
|
54565
|
+
//#region src/clips/clip-twins.ts
|
|
54566
|
+
/**
|
|
54567
|
+
* The one mapping. An ABSENT profile is not an upgrade: a caller that stated
|
|
54568
|
+
* no preference stated no ask, and D382's rule is that an unstated ask is
|
|
54569
|
+
* zero — there is nothing for a line to be about.
|
|
54570
|
+
*/
|
|
54571
|
+
function chooseClipQuality(profile) {
|
|
54572
|
+
return {
|
|
54573
|
+
twin: "main",
|
|
54574
|
+
served: "high",
|
|
54575
|
+
upgraded: profile !== void 0 && profile !== "high"
|
|
54576
|
+
};
|
|
54577
|
+
}
|
|
54578
|
+
/**
|
|
54579
|
+
* One-per-(camera, asked profile) bookkeeping for the upgrade line.
|
|
54580
|
+
*
|
|
54581
|
+
* Deliberately a plain object rather than a module-level map: a second runner
|
|
54582
|
+
* of this addon must not inherit another's "already said that".
|
|
54583
|
+
*/
|
|
54584
|
+
var ClipUpgradeNotes = class {
|
|
54585
|
+
said = /* @__PURE__ */ new Map();
|
|
54586
|
+
/** Note one upgraded ask and say whether it is worth a line. */
|
|
54587
|
+
note(deviceId, asked) {
|
|
54588
|
+
const key = `${String(deviceId)}|${asked}`;
|
|
54589
|
+
const seen = this.said.get(key);
|
|
54590
|
+
if (seen === void 0) {
|
|
54591
|
+
this.said.set(key, 0);
|
|
54592
|
+
return {
|
|
54593
|
+
kind: "say",
|
|
54594
|
+
asked,
|
|
54595
|
+
suppressedSince: 0
|
|
54596
|
+
};
|
|
54597
|
+
}
|
|
54598
|
+
this.said.set(key, seen + 1);
|
|
54599
|
+
return { kind: "silent" };
|
|
54600
|
+
}
|
|
54601
|
+
/** How many asks this camera/profile pair has made since its one line. Read
|
|
54602
|
+
* by the listing log, so the quiet carries its own denominator. */
|
|
54603
|
+
suppressed(deviceId, asked) {
|
|
54604
|
+
return this.said.get(`${String(deviceId)}|${asked}`) ?? 0;
|
|
54605
|
+
}
|
|
54606
|
+
};
|
|
54607
|
+
//#endregion
|
|
54608
|
+
//#region src/clips/clip-window.ts
|
|
54609
|
+
/**
|
|
54610
|
+
* A row whose `endMs` is not after its `startMs` is treated as an INSTANT at
|
|
54611
|
+
* `startMs` — judging it by a bad end would drop a recording rather than
|
|
54612
|
+
* misplace it, and one of those is recoverable.
|
|
54613
|
+
*/
|
|
54614
|
+
function clipOverlapsWindow(row, window) {
|
|
54615
|
+
const end = row.endMs > row.startMs ? row.endMs : row.startMs;
|
|
54616
|
+
return row.startMs < window.until && end > window.since;
|
|
54617
|
+
}
|
|
54618
|
+
function narrowClipsToWindow(rows, window) {
|
|
54619
|
+
const kept = rows.filter((row) => clipOverlapsWindow(row, window));
|
|
54620
|
+
return {
|
|
54621
|
+
kept,
|
|
54622
|
+
outOfWindow: rows.length - kept.length
|
|
54623
|
+
};
|
|
54624
|
+
}
|
|
54625
|
+
//#endregion
|
|
54626
|
+
//#region src/clips/amcrest-videoclips-provider.ts
|
|
54627
|
+
/**
|
|
54628
|
+
* The name an export carries away.
|
|
54629
|
+
*
|
|
54630
|
+
* The camera's own file name is `10.23.27-10.24.41[M][0@0][0].mp4` — square
|
|
54631
|
+
* brackets, an `@`, and a clock with no date. Every one of those is a hazard in
|
|
54632
|
+
* a downloads folder and none of them says WHEN. So the name is minted from the
|
|
54633
|
+
* window, in ISO, and the extension is re-stated because what we hand over is
|
|
54634
|
+
* an MP4 whatever the camera called it.
|
|
54635
|
+
*/
|
|
54636
|
+
function clipFileLabel(row) {
|
|
54637
|
+
return `amcrest_${new Date(row.startMs).toISOString().replace(/[:.]/g, "-").slice(0, 19)}.mp4`;
|
|
54638
|
+
}
|
|
54639
|
+
/**
|
|
54640
|
+
* What the camera's own event strings mean in the cap's vocabulary.
|
|
54641
|
+
*
|
|
54642
|
+
* `VideoMotion` is the only value 60 of 60 measured rows carried, so this is a
|
|
54643
|
+
* mapping written against ONE observed string and it says so. Everything else
|
|
54644
|
+
* is `native` — the camera's words survive verbatim on `nativeTypes`, which is
|
|
54645
|
+
* the lossless copy D549 20 requires precisely because this table is lossy.
|
|
54646
|
+
*/
|
|
54647
|
+
function clipKindFor(row) {
|
|
54648
|
+
if (row.events.some((event) => event.toLowerCase().includes("motion"))) return "motion";
|
|
54649
|
+
return "native";
|
|
54650
|
+
}
|
|
54651
|
+
function createAmcrestVideoclipsProvider(deps) {
|
|
54652
|
+
/** Last known availability per device, written by every read. */
|
|
54653
|
+
const health = /* @__PURE__ */ new Map();
|
|
54654
|
+
/** Every row this provider has listed, by `deviceId|clipKey`, so a byte
|
|
54655
|
+
* offer resolves without re-searching the camera. */
|
|
54656
|
+
const rowsByKey = /* @__PURE__ */ new Map();
|
|
54657
|
+
const upgrades = new ClipUpgradeNotes();
|
|
54658
|
+
const rowKey = (deviceId, clipKey) => `${String(deviceId)}|${clipKey}`;
|
|
54659
|
+
const remember = (deviceId, rows) => {
|
|
54660
|
+
for (const row of rows) rowsByKey.set(rowKey(deviceId, row.clipKey), row);
|
|
54661
|
+
};
|
|
54662
|
+
const toClip = (row, catalogAsOf) => {
|
|
54663
|
+
const id = mintClipId({
|
|
54664
|
+
source: CLIP_SOURCE_ONBOARD,
|
|
54665
|
+
startMs: row.startMs,
|
|
54666
|
+
endMs: row.endMs
|
|
54667
|
+
});
|
|
54668
|
+
return {
|
|
54669
|
+
id,
|
|
54670
|
+
source: CLIP_SOURCE_ONBOARD,
|
|
54671
|
+
kind: clipKindFor(row),
|
|
54672
|
+
timeRange: {
|
|
54673
|
+
startMs: row.startMs,
|
|
54674
|
+
endMs: row.endMs
|
|
54675
|
+
},
|
|
54676
|
+
...row.events.length > 0 ? { nativeTypes: [...row.events] } : {},
|
|
54677
|
+
thumbnailUnavailable: { reason: "unsupported" },
|
|
54678
|
+
catalogAsOf,
|
|
54679
|
+
streams: { main: {
|
|
54680
|
+
id,
|
|
54681
|
+
...row.sizeBytes !== null ? { bytes: row.sizeBytes } : {}
|
|
54682
|
+
} },
|
|
54683
|
+
supportSub: false
|
|
54684
|
+
};
|
|
54685
|
+
};
|
|
54686
|
+
const noteUpgrade = (deviceId, profile) => {
|
|
54687
|
+
if (profile === void 0) return;
|
|
54688
|
+
if (upgrades.note(deviceId, profile).kind !== "say") return;
|
|
54689
|
+
deps.logger.warn("videoclips: this camera archives only the main copy — every ask is served it", {
|
|
54690
|
+
tags: { deviceId },
|
|
54691
|
+
meta: {
|
|
54692
|
+
branch: "quality-upgraded",
|
|
54693
|
+
asked: profile,
|
|
54694
|
+
served: "high",
|
|
54695
|
+
note: "further upgraded asks for this camera and profile are not logged"
|
|
54696
|
+
}
|
|
54697
|
+
});
|
|
54698
|
+
};
|
|
54699
|
+
return {
|
|
54700
|
+
listSources: async ({ deviceId }) => {
|
|
54701
|
+
const device = await deps.resolveDevice(deviceId);
|
|
54702
|
+
if (device === null) return [];
|
|
54703
|
+
const availability = await deps.probeAvailability(device);
|
|
54704
|
+
health.set(deviceId, availability);
|
|
54705
|
+
return [{
|
|
54706
|
+
source: CLIP_SOURCE_ONBOARD,
|
|
54707
|
+
addonId: AMCREST_ADDON_ID,
|
|
54708
|
+
label: "Camera storage",
|
|
54709
|
+
availability
|
|
54710
|
+
}];
|
|
54711
|
+
},
|
|
54712
|
+
listClips: async ({ deviceId, since, until, limit }) => {
|
|
54713
|
+
const device = await deps.resolveDevice(deviceId);
|
|
54714
|
+
if (device === null) return [];
|
|
54715
|
+
const tags = { deviceId };
|
|
54716
|
+
const outcome = await deps.readCatalog({
|
|
54717
|
+
device,
|
|
54718
|
+
sinceMs: since,
|
|
54719
|
+
untilMs: until
|
|
54720
|
+
});
|
|
54721
|
+
if (outcome.kind === "refused") {
|
|
54722
|
+
health.set(deviceId, outcome.availability);
|
|
54723
|
+
deps.logger.warn("videoclips: nothing to list, and the source cannot answer", {
|
|
54724
|
+
tags,
|
|
54725
|
+
meta: {
|
|
54726
|
+
branch: "refused-empty",
|
|
54727
|
+
state: outcome.availability.state,
|
|
54728
|
+
reason: outcome.availability.reason ?? null
|
|
54729
|
+
}
|
|
54730
|
+
});
|
|
54731
|
+
throw new Error(`videoclips: device ${String(deviceId)} listed no clips, and that is not an empty window — the source is "${outcome.availability.state}"` + (outcome.availability.reason !== void 0 ? `: ${outcome.availability.reason}` : "."));
|
|
54732
|
+
}
|
|
54733
|
+
const catalogAsOf = deps.now();
|
|
54734
|
+
health.set(deviceId, {
|
|
54735
|
+
state: "ok",
|
|
54736
|
+
catalogAsOf
|
|
54737
|
+
});
|
|
54738
|
+
if (outcome.kind === "empty") {
|
|
54739
|
+
deps.logger.info("videoclips: the window holds no clips", {
|
|
54740
|
+
tags,
|
|
54741
|
+
meta: {
|
|
54742
|
+
branch: "empty-window",
|
|
54743
|
+
source: CLIP_SOURCE_ONBOARD,
|
|
54744
|
+
since,
|
|
54745
|
+
until
|
|
54746
|
+
}
|
|
54747
|
+
});
|
|
54748
|
+
return [];
|
|
54749
|
+
}
|
|
54750
|
+
remember(deviceId, outcome.rows);
|
|
54751
|
+
const narrowed = narrowClipsToWindow(outcome.rows, {
|
|
54752
|
+
since,
|
|
54753
|
+
until
|
|
54754
|
+
});
|
|
54755
|
+
if (narrowed.outOfWindow > 0) deps.logger.info("videoclips: narrowed a listing to the window it was asked for", {
|
|
54756
|
+
tags,
|
|
54757
|
+
meta: {
|
|
54758
|
+
branch: "listing-narrowed",
|
|
54759
|
+
listed: outcome.rows.length,
|
|
54760
|
+
kept: narrowed.kept.length,
|
|
54761
|
+
outOfWindow: narrowed.outOfWindow,
|
|
54762
|
+
since,
|
|
54763
|
+
until
|
|
54764
|
+
}
|
|
54765
|
+
});
|
|
54766
|
+
const ordered = narrowed.kept.toSorted((a, b) => b.startMs - a.startMs);
|
|
54767
|
+
const capped = limit !== void 0 ? ordered.slice(0, limit) : ordered;
|
|
54768
|
+
deps.logger.info("videoclips: listed an Amcrest catalog", {
|
|
54769
|
+
tags,
|
|
54770
|
+
meta: {
|
|
54771
|
+
source: CLIP_SOURCE_ONBOARD,
|
|
54772
|
+
clips: capped.length,
|
|
54773
|
+
truncated: limit !== void 0 && ordered.length > limit,
|
|
54774
|
+
findChannel: device.findChannel
|
|
54775
|
+
}
|
|
54776
|
+
});
|
|
54777
|
+
return capped.map((row) => toClip(row, catalogAsOf));
|
|
54778
|
+
},
|
|
54779
|
+
/**
|
|
54780
|
+
* The player's URL — a finished, `moov`-first MP4 on this addon's own
|
|
54781
|
+
* media plane, with `Range`, because the surface hands it to a `<video>`
|
|
54782
|
+
* element that has to show a duration and seek.
|
|
54783
|
+
*/
|
|
54784
|
+
getClipPlayback: async ({ deviceId, clipId, profile }) => {
|
|
54785
|
+
const parsed = parseClipId(clipId);
|
|
54786
|
+
if (parsed === null) throw new Error(`videoclips: clip id "${clipId}" was not minted by the Amcrest provider`);
|
|
54787
|
+
if (await deps.resolveDevice(deviceId) === null) throw new Error(`videoclips: device ${String(deviceId)} is not an Amcrest camera`);
|
|
54788
|
+
const clipKey = clipKeyOf(parsed);
|
|
54789
|
+
if (!rowsByKey.has(rowKey(deviceId, clipKey))) throw new Error(clipExportFailure("catalog-miss", `clip "${clipId}" is not in this node's catalog for device ${String(deviceId)} — list the window that holds it first`));
|
|
54790
|
+
const quality = chooseClipQuality(profile);
|
|
54791
|
+
if (quality.upgraded) noteUpgrade(deviceId, profile);
|
|
54792
|
+
return {
|
|
54793
|
+
playbackUrl: deps.playbackUrl(deviceId, clipKey),
|
|
54794
|
+
format: "mp4",
|
|
54795
|
+
served: quality.served
|
|
54796
|
+
};
|
|
54797
|
+
},
|
|
54798
|
+
/**
|
|
54799
|
+
* This clip's BYTES, base64, bounded — the broker's file feeder (D575).
|
|
54800
|
+
*
|
|
54801
|
+
* **It is wired, and it will refuse about a fifth of this camera's
|
|
54802
|
+
* catalog, by name.** Ten measured clips ran 33.4–72.9 MiB and 2 of them
|
|
54803
|
+
* exceed `VIDEOCLIPS_MAX_READ_BYTES` (50 MiB); the worst the camera can
|
|
54804
|
+
* structurally produce — one motion episode cut at the 5-minute
|
|
54805
|
+
* `PacketLength` cap, at the peak achieved 6.64 Mbit/s — is 238 MiB. The
|
|
54806
|
+
* envelope is unary, so that payload is held at ~1.33× in the provider AND
|
|
54807
|
+
* in the caller, on a hub this repo has already OOM'd (D9/D18), and the
|
|
54808
|
+
* bound is not going to move for an inline read.
|
|
54809
|
+
*
|
|
54810
|
+
* It is wired anyway, and the alternative was measured rather than
|
|
54811
|
+
* assumed: the broker's `chooseClipPath` reads whether *the broker* has a
|
|
54812
|
+
* file read wired, not whether this provider implements one. Leaving the
|
|
54813
|
+
* method off would make the broker choose the file path and then call a
|
|
54814
|
+
* method that answers `NOT_IMPLEMENTED` — an unnamed failure in place of
|
|
54815
|
+
* the named refusal this returns. A named `too-large-to-transfer` that an
|
|
54816
|
+
* operator can read is strictly better than an error nobody can, and it is
|
|
54817
|
+
* also the honest measurement that argues for the camera's own
|
|
54818
|
+
* forward-only replay as the next slice.
|
|
54819
|
+
*
|
|
54820
|
+
* {@link offerClipBytes} is the path an EXPORT takes, holds nothing on
|
|
54821
|
+
* either side, and reaches 500 MiB — every clip this camera can make.
|
|
54822
|
+
*
|
|
54823
|
+
* The cheap questions come first and none of them touches the camera: the
|
|
54824
|
+
* id, the row, and the SIZE the catalog already declared (D54).
|
|
54825
|
+
*/
|
|
54826
|
+
readClipBytes: async ({ deviceId, clipId, profile, maxBytes }) => {
|
|
54827
|
+
const tags = { deviceId };
|
|
54828
|
+
const refuse = (code, detail, meta) => {
|
|
54829
|
+
deps.logger.warn("videoclips: refusing a clip byte read", {
|
|
54830
|
+
tags,
|
|
54831
|
+
meta: {
|
|
54832
|
+
clipId,
|
|
54833
|
+
branch: code,
|
|
54834
|
+
...meta
|
|
54835
|
+
}
|
|
54836
|
+
});
|
|
54837
|
+
throw new Error(clipExportFailure(code, detail));
|
|
54838
|
+
};
|
|
54839
|
+
const device = await deps.resolveDevice(deviceId);
|
|
54840
|
+
if (device === null) return refuse("catalog-miss", `device ${String(deviceId)} is not an Amcrest camera`, {});
|
|
54841
|
+
const parsed = parseClipId(clipId);
|
|
54842
|
+
if (parsed === null) return refuse("catalog-miss", `clip id "${clipId}" was not minted by the Amcrest provider`, {});
|
|
54843
|
+
const clipKey = clipKeyOf(parsed);
|
|
54844
|
+
const row = rowsByKey.get(rowKey(deviceId, clipKey));
|
|
54845
|
+
if (row === void 0) return refuse("catalog-miss", `clip "${clipId}" is not in this node's catalog for device ${String(deviceId)} — list the window that holds it first`, { clipKey });
|
|
54846
|
+
const quality = chooseClipQuality(profile);
|
|
54847
|
+
if (quality.upgraded) noteUpgrade(deviceId, profile);
|
|
54848
|
+
const bound = Math.min(maxBytes ?? 52428800, VIDEOCLIPS_MAX_READ_BYTES);
|
|
54849
|
+
if (row.sizeBytes !== null && row.sizeBytes > bound) return refuse("too-large-to-transfer", `the camera lists clip "${clipId}" as ${String(row.sizeBytes)} bytes, over the ${String(bound)}-byte bound for an inline read — play it from its playback URL instead`, {
|
|
54850
|
+
clipKey,
|
|
54851
|
+
listedBytes: row.sizeBytes,
|
|
54852
|
+
bound
|
|
54853
|
+
});
|
|
54854
|
+
const outcome = await deps.prepareBytes({
|
|
54855
|
+
device,
|
|
54856
|
+
row,
|
|
54857
|
+
maxBytes: bound
|
|
54858
|
+
});
|
|
54859
|
+
if (outcome.kind === "refused") throw new Error(clipExportFailure(outcome.code, outcome.detail));
|
|
54860
|
+
if (outcome.bytes.byteLength > bound) return refuse("too-large-to-transfer", `clip "${clipId}" re-indexed to ${String(outcome.bytes.byteLength)} bytes, over the ${String(bound)}-byte bound for an inline read`, {
|
|
54861
|
+
clipKey,
|
|
54862
|
+
bytes: outcome.bytes.byteLength,
|
|
54863
|
+
bound
|
|
54864
|
+
});
|
|
54865
|
+
deps.logger.info("videoclips: served an Amcrest clip inline", {
|
|
54866
|
+
tags,
|
|
54867
|
+
meta: {
|
|
54868
|
+
clipId,
|
|
54869
|
+
clipKey,
|
|
54870
|
+
served: quality.served,
|
|
54871
|
+
asked: profile ?? null,
|
|
54872
|
+
bytes: outcome.bytes.byteLength,
|
|
54873
|
+
durationMs: outcome.durationMs
|
|
54874
|
+
}
|
|
54875
|
+
});
|
|
54876
|
+
return {
|
|
54877
|
+
base64: outcome.bytes.toString("base64"),
|
|
54878
|
+
contentType: "video/mp4",
|
|
54879
|
+
name: clipFileLabel(row),
|
|
54880
|
+
bytes: outcome.bytes.byteLength,
|
|
54881
|
+
served: quality.served,
|
|
54882
|
+
...outcome.durationMs !== null ? { durationMs: outcome.durationMs } : {}
|
|
54883
|
+
};
|
|
54884
|
+
},
|
|
54885
|
+
/**
|
|
54886
|
+
* Where this clip's finished bytes can be TAKEN (D613) — the by-handle
|
|
54887
|
+
* read a clip EXPORT pulls.
|
|
54888
|
+
*
|
|
54889
|
+
* {@link readClipBytes} with the envelope removed, and on this camera that
|
|
54890
|
+
* is the difference between exporting a fifth of the catalog and all of
|
|
54891
|
+
* it: the bound here is `VIDEOCLIPS_MAX_OFFER_BYTES` (500 MiB), which
|
|
54892
|
+
* every measured clip (33.4–72.9 MiB) and the worst structurally possible
|
|
54893
|
+
* one (238 MiB) fit with room. Nothing is held on either side — the bytes
|
|
54894
|
+
* leave over a one-shot loopback socket, straight off the scratch file.
|
|
54895
|
+
*
|
|
54896
|
+
* The cheap questions come first and none of them touches the camera: the
|
|
54897
|
+
* id, the row, and the SIZE the catalog already declared (D54).
|
|
54898
|
+
*/
|
|
54899
|
+
offerClipBytes: async ({ deviceId, clipId, profile, maxBytes }) => {
|
|
54900
|
+
const tags = { deviceId };
|
|
54901
|
+
const refuse = (code, detail, meta) => {
|
|
54902
|
+
deps.logger.warn("videoclips: refusing a clip byte offer", {
|
|
54903
|
+
tags,
|
|
54904
|
+
meta: {
|
|
54905
|
+
clipId,
|
|
54906
|
+
branch: code,
|
|
54907
|
+
...meta
|
|
54908
|
+
}
|
|
54909
|
+
});
|
|
54910
|
+
throw new Error(clipExportFailure(code, detail));
|
|
54911
|
+
};
|
|
54912
|
+
const device = await deps.resolveDevice(deviceId);
|
|
54913
|
+
if (device === null) return refuse("catalog-miss", `device ${String(deviceId)} is not an Amcrest camera`, {});
|
|
54914
|
+
const parsed = parseClipId(clipId);
|
|
54915
|
+
if (parsed === null) return refuse("catalog-miss", `clip id "${clipId}" was not minted by the Amcrest provider`, {});
|
|
54916
|
+
const clipKey = clipKeyOf(parsed);
|
|
54917
|
+
const row = rowsByKey.get(rowKey(deviceId, clipKey));
|
|
54918
|
+
if (row === void 0) return refuse("catalog-miss", `clip "${clipId}" is not in this node's catalog for device ${String(deviceId)} — list the window that holds it first`, { clipKey });
|
|
54919
|
+
const quality = chooseClipQuality(profile);
|
|
54920
|
+
if (quality.upgraded) noteUpgrade(deviceId, profile);
|
|
54921
|
+
const bound = Math.min(maxBytes ?? 524288e3, VIDEOCLIPS_MAX_OFFER_BYTES);
|
|
54922
|
+
if (row.sizeBytes !== null && row.sizeBytes > bound) return refuse("too-large-to-transfer", `the camera lists clip "${clipId}" as ${String(row.sizeBytes)} bytes, over the ${String(bound)}-byte bound for one transfer`, {
|
|
54923
|
+
clipKey,
|
|
54924
|
+
listedBytes: row.sizeBytes,
|
|
54925
|
+
bound
|
|
54926
|
+
});
|
|
54927
|
+
const outcome = await deps.prepareOffer({
|
|
54928
|
+
device,
|
|
54929
|
+
row,
|
|
54930
|
+
maxBytes: bound
|
|
54931
|
+
});
|
|
54932
|
+
if (outcome.kind === "refused") throw new Error(clipExportFailure(outcome.code, outcome.detail));
|
|
54933
|
+
deps.logger.info("videoclips: offered an Amcrest clip by handle", {
|
|
54934
|
+
tags,
|
|
54935
|
+
meta: {
|
|
54936
|
+
clipId,
|
|
54937
|
+
clipKey,
|
|
54938
|
+
served: quality.served,
|
|
54939
|
+
asked: profile ?? null,
|
|
54940
|
+
bytes: outcome.bytes,
|
|
54941
|
+
durationMs: outcome.durationMs,
|
|
54942
|
+
expiresInMs: outcome.ticket.expiresAtMs - deps.now()
|
|
54943
|
+
}
|
|
54944
|
+
});
|
|
54945
|
+
return {
|
|
54946
|
+
ticket: outcome.ticket,
|
|
54947
|
+
contentType: "video/mp4",
|
|
54948
|
+
name: clipFileLabel(row),
|
|
54949
|
+
served: quality.served,
|
|
54950
|
+
...outcome.durationMs !== null ? { durationMs: outcome.durationMs } : {}
|
|
54951
|
+
};
|
|
54952
|
+
},
|
|
54953
|
+
/**
|
|
54954
|
+
* What a surface may DRAW for this camera's clips (D612).
|
|
54955
|
+
*
|
|
54956
|
+
* A CONSTANT, and one of the two the contract spells — a provider never
|
|
54957
|
+
* composes an envelope of its own. `file`, because this provider reaches
|
|
54958
|
+
* the player through one bounded by-handle fetch of a whole, `stbl`-indexed
|
|
54959
|
+
* MP4: the camera hands over a self-contained ISO MP4 with the `moov` at
|
|
54960
|
+
* the end and no ranged read, so the clip is pulled once, made seekable
|
|
54961
|
+
* once, and then everything the `file` envelope promises — free seek,
|
|
54962
|
+
* backward frame-step, scrub, the broker's whole rate ladder — is true of
|
|
54963
|
+
* it.
|
|
54964
|
+
*
|
|
54965
|
+
* The camera's OWN forward-only replay (`/cam/playback` over RTSP) is real
|
|
54966
|
+
* and is deliberately NOT what this answers, because it is not wired: it
|
|
54967
|
+
* would need `dialClipStream`, and the one rate it could honestly offer is
|
|
54968
|
+
* `[1]` — `Scale` is accepted, never echoed, and a cold `Scale: 4.0` drain
|
|
54969
|
+
* delivered 10 % of the packets in 42 % of the time with the audio dropped
|
|
54970
|
+
* entirely. Answering `stream` before that path exists would promise a
|
|
54971
|
+
* transport with no producer behind it.
|
|
54972
|
+
*/
|
|
54973
|
+
getPlaybackOptions: async () => CLIP_PLAYBACK_OPTIONS.file
|
|
54974
|
+
};
|
|
54975
|
+
}
|
|
54976
|
+
//#endregion
|
|
54977
|
+
//#region src/clips/clip-service.ts
|
|
54978
|
+
function createClipService(deps) {
|
|
54979
|
+
const now = deps.now ?? (() => Date.now());
|
|
54980
|
+
const logger = deps.logger;
|
|
54981
|
+
const store = new ClipArtifactStore({ root: deps.dataDir });
|
|
54982
|
+
/** One RPC2 client per camera. Minted lazily: a camera nobody asks for clips
|
|
54983
|
+
* about never logs in. */
|
|
54984
|
+
const rpcByDevice = /* @__PURE__ */ new Map();
|
|
54985
|
+
/** The measured camera-clock offset, per device. */
|
|
54986
|
+
const clockByDevice = /* @__PURE__ */ new Map();
|
|
54987
|
+
/** Every row the provider has listed, mirrored here so the media ROUTE —
|
|
54988
|
+
* which is reached without a cap call — resolves a key without the camera. */
|
|
54989
|
+
const rowsByKey = /* @__PURE__ */ new Map();
|
|
54990
|
+
const rowKey = (deviceId, clipKey) => `${String(deviceId)}|${clipKey}`;
|
|
54991
|
+
const rpcFor = (camera) => {
|
|
54992
|
+
const existing = rpcByDevice.get(camera.deviceId);
|
|
54993
|
+
if (existing !== void 0) return existing;
|
|
54994
|
+
const client = new AmcrestRpc2Client({
|
|
54995
|
+
host: camera.host,
|
|
54996
|
+
port: camera.port,
|
|
54997
|
+
https: camera.https,
|
|
54998
|
+
username: camera.username,
|
|
54999
|
+
password: camera.password,
|
|
55000
|
+
now
|
|
55001
|
+
});
|
|
55002
|
+
rpcByDevice.set(camera.deviceId, client);
|
|
55003
|
+
return client;
|
|
55004
|
+
};
|
|
55005
|
+
/**
|
|
55006
|
+
* The camera's clock, measured and cached.
|
|
55007
|
+
*
|
|
55008
|
+
* `null` when the read failed — never a zero offset, which is the same shape
|
|
55009
|
+
* as "this camera runs on UTC" and would move its whole archive by the
|
|
55010
|
+
* timezone it really has.
|
|
55011
|
+
*/
|
|
55012
|
+
const clockFor = async (camera) => {
|
|
55013
|
+
const cached = clockByDevice.get(camera.deviceId);
|
|
55014
|
+
if (cached !== void 0 && cameraClockIsFresh(cached, now())) return cached;
|
|
55015
|
+
let body;
|
|
55016
|
+
const sentAt = now();
|
|
55017
|
+
try {
|
|
55018
|
+
body = await camera.client.getCurrentTime();
|
|
55019
|
+
} catch (err) {
|
|
55020
|
+
logger.warn("videoclips: the camera did not say what time it thinks it is", {
|
|
55021
|
+
tags: { deviceId: camera.deviceId },
|
|
55022
|
+
meta: {
|
|
55023
|
+
branch: "clock-unread",
|
|
55024
|
+
error: err instanceof Error ? err.message : String(err)
|
|
55025
|
+
}
|
|
55026
|
+
});
|
|
55027
|
+
return null;
|
|
55028
|
+
}
|
|
55029
|
+
const clock = deriveCameraClock({
|
|
55030
|
+
currentTimeBody: body,
|
|
55031
|
+
hostUtcBeforeMs: sentAt,
|
|
55032
|
+
hostUtcAfterMs: now()
|
|
55033
|
+
});
|
|
55034
|
+
if (clock === null) {
|
|
55035
|
+
logger.warn("videoclips: the camera answered a time this provider could not read", {
|
|
55036
|
+
tags: { deviceId: camera.deviceId },
|
|
55037
|
+
meta: { branch: "clock-unparsed" }
|
|
55038
|
+
});
|
|
55039
|
+
return null;
|
|
55040
|
+
}
|
|
55041
|
+
clockByDevice.set(camera.deviceId, clock);
|
|
55042
|
+
if (cameraClockDisagrees(clock)) logger.warn("videoclips: the camera's clock disagrees with the hub by more than any timezone", {
|
|
55043
|
+
tags: { deviceId: camera.deviceId },
|
|
55044
|
+
meta: {
|
|
55045
|
+
branch: "clock-disagrees",
|
|
55046
|
+
offsetMs: clock.offsetMs
|
|
55047
|
+
}
|
|
55048
|
+
});
|
|
55049
|
+
else logger.debug("videoclips: measured this camera’s clock offset", {
|
|
55050
|
+
tags: { deviceId: camera.deviceId },
|
|
55051
|
+
meta: { offsetMs: clock.offsetMs }
|
|
55052
|
+
});
|
|
55053
|
+
return clock;
|
|
55054
|
+
};
|
|
55055
|
+
const resolveDevice = async (deviceId) => {
|
|
55056
|
+
const camera = await deps.resolveCamera(deviceId);
|
|
55057
|
+
if (camera === null) return null;
|
|
55058
|
+
return {
|
|
55059
|
+
deviceId,
|
|
55060
|
+
findChannel: Math.max(0, camera.channel - 1)
|
|
55061
|
+
};
|
|
55062
|
+
};
|
|
55063
|
+
const fileFor = async (deviceId, clipKey) => {
|
|
55064
|
+
const camera = await deps.resolveCamera(deviceId);
|
|
55065
|
+
if (camera === null) return {
|
|
55066
|
+
kind: "refused",
|
|
55067
|
+
code: "fetch-failed",
|
|
55068
|
+
detail: `device ${String(deviceId)} is not an Amcrest camera`
|
|
55069
|
+
};
|
|
55070
|
+
const row = rowsByKey.get(rowKey(deviceId, clipKey));
|
|
55071
|
+
if (row === void 0) return {
|
|
55072
|
+
kind: "refused",
|
|
55073
|
+
code: "fetch-failed",
|
|
55074
|
+
detail: "no-catalog-row: list the window that holds this clip first"
|
|
55075
|
+
};
|
|
55076
|
+
return await prepareClipFile({
|
|
55077
|
+
deviceId,
|
|
55078
|
+
row
|
|
55079
|
+
}, {
|
|
55080
|
+
logger,
|
|
55081
|
+
store,
|
|
55082
|
+
client: camera.client,
|
|
55083
|
+
...deps.ffmpegBinaryPath !== void 0 ? { ffmpegBinaryPath: deps.ffmpegBinaryPath } : {}
|
|
55084
|
+
});
|
|
55085
|
+
};
|
|
55086
|
+
return {
|
|
55087
|
+
provider: createAmcrestVideoclipsProvider({
|
|
55088
|
+
now,
|
|
55089
|
+
logger,
|
|
55090
|
+
resolveDevice,
|
|
55091
|
+
probeAvailability: async (device) => {
|
|
55092
|
+
const camera = await deps.resolveCamera(device.deviceId);
|
|
55093
|
+
if (camera === null) return {
|
|
55094
|
+
state: "unreachable",
|
|
55095
|
+
reason: "this device is not an Amcrest camera"
|
|
55096
|
+
};
|
|
55097
|
+
const tags = { deviceId: device.deviceId };
|
|
55098
|
+
const storage = await camera.readStorage();
|
|
55099
|
+
if (storage !== null) {
|
|
55100
|
+
logger.info("videoclips: the storage read settled this source", {
|
|
55101
|
+
tags,
|
|
55102
|
+
meta: {
|
|
55103
|
+
branch: storage.state,
|
|
55104
|
+
reason: storage.reason ?? null
|
|
55105
|
+
}
|
|
55106
|
+
});
|
|
55107
|
+
return storage;
|
|
55108
|
+
}
|
|
55109
|
+
const clockRefusal = availabilityFromClock(await clockFor(camera));
|
|
55110
|
+
if (clockRefusal !== null) return clockRefusal;
|
|
55111
|
+
return {
|
|
55112
|
+
state: "ok",
|
|
55113
|
+
catalogAsOf: now()
|
|
55114
|
+
};
|
|
55115
|
+
},
|
|
55116
|
+
readCatalog: async ({ device, sinceMs, untilMs }) => {
|
|
55117
|
+
const camera = await deps.resolveCamera(device.deviceId);
|
|
55118
|
+
if (camera === null) return {
|
|
55119
|
+
kind: "refused",
|
|
55120
|
+
availability: {
|
|
55121
|
+
state: "unreachable",
|
|
55122
|
+
reason: "this device is not an Amcrest camera"
|
|
55123
|
+
}
|
|
55124
|
+
};
|
|
55125
|
+
const storage = await camera.readStorage();
|
|
55126
|
+
if (storage !== null) return {
|
|
55127
|
+
kind: "refused",
|
|
55128
|
+
availability: storage
|
|
55129
|
+
};
|
|
55130
|
+
const clock = await clockFor(camera);
|
|
55131
|
+
const clockRefusal = availabilityFromClock(clock);
|
|
55132
|
+
if (clockRefusal !== null || clock === null) return {
|
|
55133
|
+
kind: "refused",
|
|
55134
|
+
availability: clockRefusal ?? {
|
|
55135
|
+
state: "unreachable",
|
|
55136
|
+
reason: "no camera clock"
|
|
55137
|
+
}
|
|
55138
|
+
};
|
|
55139
|
+
const outcome = await readClipRows({
|
|
55140
|
+
rpc: rpcFor(camera),
|
|
55141
|
+
logger,
|
|
55142
|
+
deviceId: device.deviceId
|
|
55143
|
+
}, {
|
|
55144
|
+
channel: device.findChannel,
|
|
55145
|
+
sinceMs,
|
|
55146
|
+
untilMs,
|
|
55147
|
+
clock
|
|
55148
|
+
});
|
|
55149
|
+
switch (outcome.kind) {
|
|
55150
|
+
case "unreachable":
|
|
55151
|
+
logger.warn("videoclips: the catalog search did not complete", {
|
|
55152
|
+
tags: { deviceId: device.deviceId },
|
|
55153
|
+
meta: {
|
|
55154
|
+
branch: "camera-refused",
|
|
55155
|
+
error: outcome.detail
|
|
55156
|
+
}
|
|
55157
|
+
});
|
|
55158
|
+
return {
|
|
55159
|
+
kind: "refused",
|
|
55160
|
+
availability: {
|
|
55161
|
+
state: "unreachable",
|
|
55162
|
+
reason: outcome.detail
|
|
55163
|
+
}
|
|
55164
|
+
};
|
|
55165
|
+
case "index-empty":
|
|
55166
|
+
logger.warn("videoclips: the camera’s index answers nothing anywhere", {
|
|
55167
|
+
tags: { deviceId: device.deviceId },
|
|
55168
|
+
meta: {
|
|
55169
|
+
branch: "index-empty",
|
|
55170
|
+
error: outcome.detail
|
|
55171
|
+
}
|
|
55172
|
+
});
|
|
55173
|
+
return {
|
|
55174
|
+
kind: "refused",
|
|
55175
|
+
availability: {
|
|
55176
|
+
state: "index-empty",
|
|
55177
|
+
reason: outcome.detail
|
|
55178
|
+
}
|
|
55179
|
+
};
|
|
55180
|
+
case "empty-window": return { kind: "empty" };
|
|
55181
|
+
case "ok":
|
|
55182
|
+
for (const row of outcome.rows) rowsByKey.set(rowKey(device.deviceId, row.clipKey), row);
|
|
55183
|
+
logger.info("videoclips: the camera answered a catalog search", {
|
|
55184
|
+
tags: { deviceId: device.deviceId },
|
|
55185
|
+
meta: {
|
|
55186
|
+
rows: outcome.rows.length,
|
|
55187
|
+
pages: outcome.pages,
|
|
55188
|
+
rejected: outcome.rejectedCount,
|
|
55189
|
+
findChannel: device.findChannel,
|
|
55190
|
+
oldestStartMs: outcome.rows[0]?.startMs ?? null,
|
|
55191
|
+
newestStartMs: outcome.rows[outcome.rows.length - 1]?.startMs ?? null
|
|
55192
|
+
}
|
|
55193
|
+
});
|
|
55194
|
+
return {
|
|
55195
|
+
kind: "ok",
|
|
55196
|
+
rows: outcome.rows
|
|
55197
|
+
};
|
|
55198
|
+
}
|
|
55199
|
+
},
|
|
55200
|
+
playbackUrl: (deviceId, clipKey) => `/addon/${AMCREST_ADDON_ID}/${CLIP_MEDIA_PREFIX}/${String(deviceId)}/${clipKey}.mp4`,
|
|
55201
|
+
prepareBytes: async ({ device, row, maxBytes }) => {
|
|
55202
|
+
rowsByKey.set(rowKey(device.deviceId, row.clipKey), row);
|
|
55203
|
+
const file = await fileFor(device.deviceId, row.clipKey);
|
|
55204
|
+
if (file.kind === "refused") return {
|
|
55205
|
+
kind: "refused",
|
|
55206
|
+
code: file.code,
|
|
55207
|
+
detail: file.detail
|
|
55208
|
+
};
|
|
55209
|
+
if (file.bytes > maxBytes) return {
|
|
55210
|
+
kind: "refused",
|
|
55211
|
+
code: "too-large-to-transfer",
|
|
55212
|
+
detail: `clip "${row.clipKey}" is ${String(file.bytes)} bytes, over the ${String(maxBytes)}-byte bound for an inline read — play it from its playback URL`
|
|
55213
|
+
};
|
|
55214
|
+
return {
|
|
55215
|
+
kind: "ok",
|
|
55216
|
+
bytes: await readFile(file.path),
|
|
55217
|
+
durationMs: file.durationMs
|
|
55218
|
+
};
|
|
55219
|
+
},
|
|
55220
|
+
prepareOffer: async ({ device, row, maxBytes }) => {
|
|
55221
|
+
const peerBytes = deps.peerBytes;
|
|
55222
|
+
if (peerBytes === void 0) {
|
|
55223
|
+
logger.warn("videoclips: no peer-bytes transport on this node", {
|
|
55224
|
+
tags: { deviceId: device.deviceId },
|
|
55225
|
+
meta: {
|
|
55226
|
+
clipKey: row.clipKey,
|
|
55227
|
+
branch: "no-peer-bytes"
|
|
55228
|
+
}
|
|
55229
|
+
});
|
|
55230
|
+
return {
|
|
55231
|
+
kind: "refused",
|
|
55232
|
+
code: "fetch-failed",
|
|
55233
|
+
detail: "this node has no addon-to-addon byte transport wired"
|
|
55234
|
+
};
|
|
55235
|
+
}
|
|
55236
|
+
rowsByKey.set(rowKey(device.deviceId, row.clipKey), row);
|
|
55237
|
+
const file = await fileFor(device.deviceId, row.clipKey);
|
|
55238
|
+
if (file.kind === "refused") return {
|
|
55239
|
+
kind: "refused",
|
|
55240
|
+
code: file.code,
|
|
55241
|
+
detail: file.detail
|
|
55242
|
+
};
|
|
55243
|
+
if (file.bytes > maxBytes) return {
|
|
55244
|
+
kind: "refused",
|
|
55245
|
+
code: "too-large-to-transfer",
|
|
55246
|
+
detail: `clip "${row.clipKey}" is ${String(file.bytes)} bytes, over the ${String(maxBytes)}-byte bound for one transfer`
|
|
55247
|
+
};
|
|
55248
|
+
const offered = await peerBytes.offerFile({
|
|
55249
|
+
path: file.path,
|
|
55250
|
+
contentType: "video/mp4",
|
|
55251
|
+
label: "amcrest-clip",
|
|
55252
|
+
deviceId: device.deviceId
|
|
55253
|
+
});
|
|
55254
|
+
if (offered.kind === "refused") {
|
|
55255
|
+
logger.warn("videoclips: the clip byte offer could not be minted", {
|
|
55256
|
+
tags: { deviceId: device.deviceId },
|
|
55257
|
+
meta: {
|
|
55258
|
+
clipKey: row.clipKey,
|
|
55259
|
+
branch: offered.code,
|
|
55260
|
+
detail: offered.detail
|
|
55261
|
+
}
|
|
55262
|
+
});
|
|
55263
|
+
return {
|
|
55264
|
+
kind: "refused",
|
|
55265
|
+
code: "fetch-failed",
|
|
55266
|
+
detail: `${offered.code}: ${offered.detail}`
|
|
55267
|
+
};
|
|
55268
|
+
}
|
|
55269
|
+
return {
|
|
55270
|
+
kind: "ok",
|
|
55271
|
+
ticket: offered.ticket,
|
|
55272
|
+
durationMs: file.durationMs,
|
|
55273
|
+
bytes: file.bytes
|
|
55274
|
+
};
|
|
55275
|
+
}
|
|
55276
|
+
}),
|
|
55277
|
+
mediaPrefix: CLIP_MEDIA_PREFIX,
|
|
55278
|
+
mediaHandler: createClipPlaybackHandler({
|
|
55279
|
+
logger,
|
|
55280
|
+
resolvePath: async (deviceId, clipKey) => {
|
|
55281
|
+
const file = await fileFor(deviceId, clipKey);
|
|
55282
|
+
if (file.kind === "ok") return file.path;
|
|
55283
|
+
logger.warn("videoclips: a clip file was refused", {
|
|
55284
|
+
tags: { deviceId },
|
|
55285
|
+
meta: {
|
|
55286
|
+
clipKey,
|
|
55287
|
+
branch: file.code,
|
|
55288
|
+
reason: file.detail
|
|
55289
|
+
}
|
|
55290
|
+
});
|
|
55291
|
+
return null;
|
|
55292
|
+
}
|
|
55293
|
+
}),
|
|
55294
|
+
forgetDevice: (deviceId) => {
|
|
55295
|
+
rpcByDevice.get(deviceId)?.reset();
|
|
55296
|
+
rpcByDevice.delete(deviceId);
|
|
55297
|
+
clockByDevice.delete(deviceId);
|
|
55298
|
+
}
|
|
55299
|
+
};
|
|
55300
|
+
}
|
|
51709
55301
|
//#endregion
|
|
51710
55302
|
//#region ../../node_modules/xml2js/lib/defaults.js
|
|
51711
55303
|
var require_defaults = /* @__PURE__ */ __commonJSMin(((exports) => {
|
|
@@ -60771,7 +64363,39 @@ var AmcrestProviderAddon = class extends BaseDeviceProvider {
|
|
|
60771
64363
|
throw new Error(`Amcrest: probe on ${host || "(unknown host)"} resolved neither mac nor host address — cannot persist a stable row key. Verify network reachability + credentials, then retry.`);
|
|
60772
64364
|
}
|
|
60773
64365
|
async onInitialize() {
|
|
60774
|
-
|
|
64366
|
+
const regs = await super.onInitialize();
|
|
64367
|
+
this.clipService = createClipService({
|
|
64368
|
+
dataDir: this.ctx.dataDir,
|
|
64369
|
+
logger: this.ctx.logger,
|
|
64370
|
+
...this.ctx.peerBytes !== void 0 ? { peerBytes: this.ctx.peerBytes } : {},
|
|
64371
|
+
resolveCamera: async (deviceId) => {
|
|
64372
|
+
const device = this.ctx.kernel.deviceRegistry?.getById(deviceId);
|
|
64373
|
+
return device instanceof AmcrestCamera ? device.getClipCamera() : null;
|
|
64374
|
+
}
|
|
64375
|
+
});
|
|
64376
|
+
regs.push({
|
|
64377
|
+
capability: videoclipsCapability,
|
|
64378
|
+
provider: this.clipService.provider
|
|
64379
|
+
});
|
|
64380
|
+
await this.serveClipPlane(this.clipService);
|
|
64381
|
+
return regs;
|
|
64382
|
+
}
|
|
64383
|
+
/** The clip service: catalog, the player's file route, the export's bytes. */
|
|
64384
|
+
clipService = null;
|
|
64385
|
+
async serveClipPlane(service) {
|
|
64386
|
+
try {
|
|
64387
|
+
const media = await this.ctx.dataPlane?.serve({
|
|
64388
|
+
prefix: service.mediaPrefix,
|
|
64389
|
+
access: "authenticated",
|
|
64390
|
+
handler: service.mediaHandler
|
|
64391
|
+
});
|
|
64392
|
+
this.ctx.logger.info("Amcrest clip data plane served", { meta: {
|
|
64393
|
+
mediaPath: `/addon/${AMCREST_ADDON_ID}/${service.mediaPrefix}`,
|
|
64394
|
+
mediaServed: media !== void 0
|
|
64395
|
+
} });
|
|
64396
|
+
} catch (err) {
|
|
64397
|
+
this.ctx.logger.warn("Amcrest clip data plane failed to serve — clips will not play", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
64398
|
+
}
|
|
60775
64399
|
}
|
|
60776
64400
|
async supportsDiscovery() {
|
|
60777
64401
|
return true;
|