@camstack/addon-export-hap 1.2.127 → 1.2.128
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/hap-export.addon.js +2660 -1365
- package/dist/hap-export.addon.mjs +2663 -1366
- package/package.json +1 -1
package/dist/hap-export.addon.js
CHANGED
|
@@ -36,54 +36,18 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
36
36
|
}) : target, mod));
|
|
37
37
|
var __toCommonJS = (mod) => __hasOwnProp.call(mod, "module.exports") ? mod["module.exports"] : __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
38
38
|
//#endregion
|
|
39
|
+
let node_child_process = require("node:child_process");
|
|
39
40
|
let node_crypto = require("node:crypto");
|
|
40
41
|
let node_path = require("node:path");
|
|
41
42
|
__toESM(node_path, 1);
|
|
42
43
|
node_path = __toESM(node_path);
|
|
43
44
|
let node_fs = require("node:fs");
|
|
44
45
|
node_fs = __toESM(node_fs, 1);
|
|
45
|
-
let node_child_process = require("node:child_process");
|
|
46
46
|
let node_fs_promises = require("node:fs/promises");
|
|
47
47
|
node_fs_promises = __toESM(node_fs_promises, 1);
|
|
48
|
+
let node_events = require("node:events");
|
|
48
49
|
let node_dgram = require("node:dgram");
|
|
49
50
|
let node_os = require("node:os");
|
|
50
|
-
//#region src/exposed-entry.ts
|
|
51
|
-
/**
|
|
52
|
-
* Carrying an exposed-device entry across a re-expose.
|
|
53
|
-
*
|
|
54
|
-
* `exposeDevice` rebuilds its entry from scratch — display name, mapper kind,
|
|
55
|
-
* timestamp — and then REPLACES the stored one. Anything the rebuilt object
|
|
56
|
-
* does not mention is therefore destroyed, and two things it never mentioned
|
|
57
|
-
* were the per-camera settings and the capability list.
|
|
58
|
-
*
|
|
59
|
-
* The visible cost: the operator's "Source stream (HomeKit)" selector writes
|
|
60
|
-
* `low`, the addon logs `streamPreference changed — refreshing accessory
|
|
61
|
-
* {from=auto to=low}`, and the accessory that comes back derives its
|
|
62
|
-
* advertisement from DEFAULTS — `streamPreference=auto` — because the settings
|
|
63
|
-
* were dropped between the write and the rebuild. The selector only ever took
|
|
64
|
-
* effect after a full addon restart, when the settings were loaded first. A
|
|
65
|
-
* second write after the re-expose hid this: the store ended up correct, so
|
|
66
|
-
* nothing looked wrong except the stream nobody could explain.
|
|
67
|
-
*/
|
|
68
|
-
/**
|
|
69
|
-
* Fill `base` from `existing` for the given keys, letting `base` win wherever
|
|
70
|
-
* it actually says something.
|
|
71
|
-
*
|
|
72
|
-
* That asymmetry is the point: a caller who passes `capabilities` is stating a
|
|
73
|
-
* new truth and must not be overruled by the stored copy, while a caller who
|
|
74
|
-
* says nothing about `settings` is not asking for them to be erased.
|
|
75
|
-
*/
|
|
76
|
-
function carryForward(base, existing, keys) {
|
|
77
|
-
if (existing === void 0) return base;
|
|
78
|
-
const out = { ...base };
|
|
79
|
-
for (const key of keys) {
|
|
80
|
-
if (out[key] !== void 0) continue;
|
|
81
|
-
const carried = existing[key];
|
|
82
|
-
if (carried !== void 0) out[key] = carried;
|
|
83
|
-
}
|
|
84
|
-
return out;
|
|
85
|
-
}
|
|
86
|
-
//#endregion
|
|
87
51
|
//#region ../types/dist/event-category-BVDXG4tB.mjs
|
|
88
52
|
var EventCategory = /* @__PURE__ */ function(EventCategory) {
|
|
89
53
|
EventCategory["SystemBoot"] = "system.boot";
|
|
@@ -5445,7 +5409,7 @@ var ZodIssueCode = {
|
|
|
5445
5409
|
var ZodFirstPartyTypeKind;
|
|
5446
5410
|
ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
|
|
5447
5411
|
//#endregion
|
|
5448
|
-
//#region ../types/dist/sleep-
|
|
5412
|
+
//#region ../types/dist/sleep-CiR4z7r9.mjs
|
|
5449
5413
|
/**
|
|
5450
5414
|
* The audio chunk plane's byte format, and the ONE expansion from a coded
|
|
5451
5415
|
* window to float samples (D455).
|
|
@@ -6587,6 +6551,17 @@ var CamProfileSchema = _enum([
|
|
|
6587
6551
|
"mid",
|
|
6588
6552
|
"low"
|
|
6589
6553
|
]);
|
|
6554
|
+
/**
|
|
6555
|
+
* Which rung a per-camera consumer listens on: an explicit profile, or `auto`.
|
|
6556
|
+
*
|
|
6557
|
+
* `auto` is a REAL value, not an absent one — it means "resolve it by the rule
|
|
6558
|
+
* that consumer owns". It lives beside `CamProfile` because a rung is the only
|
|
6559
|
+
* operator-facing way to name a stream: a cam-stream id is broker-internal and
|
|
6560
|
+
* moves when a slot is reassigned, so a stored id silently stops meaning what
|
|
6561
|
+
* the operator picked. Today's one user is the audio analyser, whose rule is
|
|
6562
|
+
* "the lowest AVAILABLE native rung" (D537).
|
|
6563
|
+
*/
|
|
6564
|
+
var AudioStreamPickSchema = union([CamProfileSchema, literal("auto")]);
|
|
6590
6565
|
var CamStreamKindSchema = _enum([
|
|
6591
6566
|
"pull-rtsp",
|
|
6592
6567
|
"pull-rtmp",
|
|
@@ -7446,7 +7421,7 @@ method(object({ deviceId: number() }), array(StreamSourceEntrySchema)), method(o
|
|
|
7446
7421
|
input: unknown()
|
|
7447
7422
|
}), unknown(), { kind: "mutation" }), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), unknown().nullable()), method(object({ deviceId: number() }), RawStateResultSchema.nullable(), { auth: "protected" });
|
|
7448
7423
|
//#endregion
|
|
7449
|
-
//#region ../types/dist/canonical-hash-
|
|
7424
|
+
//#region ../types/dist/canonical-hash-CPK2Dy60.mjs
|
|
7450
7425
|
var AUDIO_ENCODER_BY_CODEC = {
|
|
7451
7426
|
opus: "libopus",
|
|
7452
7427
|
aac: "aac",
|
|
@@ -7504,10 +7479,17 @@ function buildInputArgs(input, decodeHwAccel) {
|
|
|
7504
7479
|
const args = [];
|
|
7505
7480
|
if (!isSoftwareDecode(decodeHwAccel)) args.push("-hwaccel", String(decodeHwAccel));
|
|
7506
7481
|
if (input.extraArgs?.length) args.push(...input.extraArgs);
|
|
7482
|
+
if (input.decodeThreadCount !== void 0 && input.decodeThreadCount > 0) args.push("-threads", String(input.decodeThreadCount));
|
|
7507
7483
|
if (input.analyzeDurationUs !== void 0) args.push("-analyzeduration", String(input.analyzeDurationUs));
|
|
7508
7484
|
if (input.probeSizeBytes !== void 0) args.push("-probesize", String(input.probeSizeBytes));
|
|
7485
|
+
if (input.lowDelay === true) args.push("-flags", "low_delay");
|
|
7486
|
+
if (input.useWallclockTimestamps === true) args.push("-use_wallclock_as_timestamps", "1");
|
|
7509
7487
|
if (input.fflags?.length) for (const flag of input.fflags) args.push("-fflags", flag);
|
|
7510
7488
|
if (input.rtspTransport) args.push("-rtsp_transport", input.rtspTransport);
|
|
7489
|
+
if (input.rawVideo !== void 0) {
|
|
7490
|
+
const raw = input.rawVideo;
|
|
7491
|
+
args.push("-f", "rawvideo", "-pix_fmt", raw.pixelFormat, "-video_size", `${String(raw.width)}x${String(raw.height)}`, "-framerate", String(raw.framerate));
|
|
7492
|
+
}
|
|
7511
7493
|
args.push("-i", input.url);
|
|
7512
7494
|
return args;
|
|
7513
7495
|
}
|
|
@@ -7540,16 +7522,29 @@ function resolveStreamMaps(selection) {
|
|
|
7540
7522
|
};
|
|
7541
7523
|
}
|
|
7542
7524
|
/** The `-vf` filter args, or `[]` when a consumer `-vf` already claims the slot. */
|
|
7543
|
-
function buildVideoFilterArgs(scale, outputArgs, filterGraph) {
|
|
7525
|
+
function buildVideoFilterArgs(scale, outputArgs, filterGraph, hwUpload) {
|
|
7544
7526
|
if (filterGraph) {
|
|
7545
7527
|
if (scale) throw new Error("FfmpegInvocation: video.scale cannot be combined with a filter graph — scale inside the graph instead (the -vf would feed an output the graph already feeds)");
|
|
7546
7528
|
if (outputArgs.some((a) => a === "-vf")) throw new Error("FfmpegInvocation: outputArgs carries a -vf while a filter graph feeds the same output — fold the filter into the graph");
|
|
7547
7529
|
return [];
|
|
7548
7530
|
}
|
|
7549
|
-
if (!scale) return [];
|
|
7550
7531
|
if (outputArgs.some((a) => a === "-vf")) return [];
|
|
7551
|
-
if (scale.
|
|
7552
|
-
|
|
7532
|
+
if (!scale) return hwUpload.length > 0 ? ["-vf", hwUpload] : [];
|
|
7533
|
+
const suffix = hwUpload.length > 0 ? `,${hwUpload}` : "";
|
|
7534
|
+
if (scale.mode === "exact") return ["-vf", `scale=${scale.width}:${scale.height}${suffix}`];
|
|
7535
|
+
return ["-vf", `scale='min(${scale.width},iw)':'min(${scale.height},ih)':force_original_aspect_ratio=decrease:force_divisible_by=2${suffix}`];
|
|
7536
|
+
}
|
|
7537
|
+
/**
|
|
7538
|
+
* The upload filter a hardware encode needs, or `''`.
|
|
7539
|
+
*
|
|
7540
|
+
* Nothing is uploaded when the DECODE already left the frames on the GPU
|
|
7541
|
+
* (`-hwaccel vaapi`): they are surfaces already, and uploading a surface is an
|
|
7542
|
+
* error rather than a no-op.
|
|
7543
|
+
*/
|
|
7544
|
+
function resolveHwUpload(device, decodeHwAccel) {
|
|
7545
|
+
if (device === void 0) return "";
|
|
7546
|
+
if (!isSoftwareDecode(decodeHwAccel)) return "";
|
|
7547
|
+
return "format=nv12,hwupload";
|
|
7553
7548
|
}
|
|
7554
7549
|
/** Rate-control args for an encode plan. */
|
|
7555
7550
|
function buildRateControlArgs(video) {
|
|
@@ -7569,7 +7564,18 @@ function buildRateControlArgs(video) {
|
|
|
7569
7564
|
];
|
|
7570
7565
|
}
|
|
7571
7566
|
/** The whole video block (`-vf` … `-c:v` … knobs), after `-i`. */
|
|
7572
|
-
function buildVideoArgs(video, outputArgs, filterGraph) {
|
|
7567
|
+
function buildVideoArgs(video, outputArgs, filterGraph, hwEncode) {
|
|
7568
|
+
if (video.kind === "raw") {
|
|
7569
|
+
if (filterGraph && video.filter !== void 0) throw new Error("FfmpegInvocation: video.filter cannot be combined with a filter graph — fold the filter into the graph");
|
|
7570
|
+
return [
|
|
7571
|
+
...video.filter !== void 0 ? ["-vf", video.filter] : [],
|
|
7572
|
+
"-c:v",
|
|
7573
|
+
"rawvideo",
|
|
7574
|
+
"-pix_fmt",
|
|
7575
|
+
video.pixelFormat,
|
|
7576
|
+
...video.fps !== void 0 ? ["-r", String(video.fps)] : []
|
|
7577
|
+
];
|
|
7578
|
+
}
|
|
7573
7579
|
if (video.kind === "copy") {
|
|
7574
7580
|
if (filterGraph) throw new Error("FfmpegInvocation: a filter graph produces a NEW picture, so video.kind must be `encode`");
|
|
7575
7581
|
return [
|
|
@@ -7578,8 +7584,10 @@ function buildVideoArgs(video, outputArgs, filterGraph) {
|
|
|
7578
7584
|
...video.bitstreamFilter ? ["-bsf:v", video.bitstreamFilter] : []
|
|
7579
7585
|
];
|
|
7580
7586
|
}
|
|
7587
|
+
const hwUpload = hwEncode === void 0 ? "" : resolveHwUpload(hwEncode.device, hwEncode.decodeHwAccel);
|
|
7588
|
+
if (hwEncode !== void 0 && filterGraph) throw new Error("FfmpegInvocation: a hardware encode fed by a filter graph must upload INSIDE the graph — hwEncodeDevice cannot be combined with filterGraph");
|
|
7581
7589
|
const args = [
|
|
7582
|
-
...buildVideoFilterArgs(video.scale, outputArgs, filterGraph),
|
|
7590
|
+
...buildVideoFilterArgs(video.scale, outputArgs, filterGraph, hwUpload),
|
|
7583
7591
|
"-c:v",
|
|
7584
7592
|
video.encoder
|
|
7585
7593
|
];
|
|
@@ -7749,15 +7757,23 @@ function buildAudioSidecarArgs(sidecar) {
|
|
|
7749
7757
|
* (`0:a:0?`): the sidecar is defined as the SOURCE's microphone on its own RTP
|
|
7750
7758
|
* output, not as whatever the main output happens to carry.
|
|
7751
7759
|
*/
|
|
7760
|
+
/** The hardware-encode context for this invocation, or `undefined`. */
|
|
7761
|
+
function hwEncodeFor(inv) {
|
|
7762
|
+
return inv.hwEncodeDevice === void 0 ? void 0 : {
|
|
7763
|
+
device: inv.hwEncodeDevice,
|
|
7764
|
+
decodeHwAccel: inv.decodeHwAccel
|
|
7765
|
+
};
|
|
7766
|
+
}
|
|
7752
7767
|
function buildFfmpegArgs(inv) {
|
|
7753
7768
|
const graph = inv.filterGraph ?? null;
|
|
7754
7769
|
const head = [
|
|
7755
7770
|
...logBannerArgs(inv.logLevel),
|
|
7771
|
+
...inv.hwEncodeDevice !== void 0 ? ["-vaapi_device", inv.hwEncodeDevice.renderNode] : [],
|
|
7756
7772
|
...buildInputArgs(inv.input, inv.decodeHwAccel),
|
|
7757
7773
|
...(inv.extraInputs ?? []).flatMap((extra) => buildInputArgs(extra, inv.decodeHwAccel)),
|
|
7758
7774
|
...graph ? ["-filter_complex", graph.graph] : []
|
|
7759
7775
|
];
|
|
7760
|
-
const threadArgs = inv.threadCount > 0 ? ["-threads", String(inv.threadCount)] : [];
|
|
7776
|
+
const threadArgs = [...inv.threadCount > 0 ? ["-threads", String(inv.threadCount)] : [], ...inv.filterThreadCount !== void 0 && inv.filterThreadCount > 0 ? ["-filter_threads", String(inv.filterThreadCount)] : []];
|
|
7761
7777
|
const maps = resolveStreamMaps(inv);
|
|
7762
7778
|
const videoMapArgs = maps.video ? ["-map", maps.video] : [];
|
|
7763
7779
|
const audioMapArgs = maps.audio ? ["-map", maps.audio] : [];
|
|
@@ -7766,7 +7782,7 @@ function buildFfmpegArgs(inv) {
|
|
|
7766
7782
|
"-an",
|
|
7767
7783
|
"-map",
|
|
7768
7784
|
maps.video ?? "0:v:0",
|
|
7769
|
-
...buildVideoArgs(inv.video, inv.outputArgs, graph),
|
|
7785
|
+
...buildVideoArgs(inv.video, inv.outputArgs, graph, hwEncodeFor(inv)),
|
|
7770
7786
|
...threadArgs,
|
|
7771
7787
|
...inv.outputArgs,
|
|
7772
7788
|
...buildRtpOutputArgs(inv.sink.video)
|
|
@@ -7789,7 +7805,7 @@ function buildFfmpegArgs(inv) {
|
|
|
7789
7805
|
return [
|
|
7790
7806
|
...head,
|
|
7791
7807
|
...videoMapArgs,
|
|
7792
|
-
...buildVideoArgs(inv.video, inv.outputArgs, graph),
|
|
7808
|
+
...buildVideoArgs(inv.video, inv.outputArgs, graph, hwEncodeFor(inv)),
|
|
7793
7809
|
...threadArgs,
|
|
7794
7810
|
...mutedAudio ? [] : audioMapArgs,
|
|
7795
7811
|
...audioArgs,
|
|
@@ -8468,6 +8484,7 @@ var CameraSwitchIdSchema = _enum([
|
|
|
8468
8484
|
"device-audio",
|
|
8469
8485
|
"broker-audio",
|
|
8470
8486
|
"audio-analysis",
|
|
8487
|
+
"event-saving",
|
|
8471
8488
|
"recording",
|
|
8472
8489
|
"notifications"
|
|
8473
8490
|
]);
|
|
@@ -8942,11 +8959,31 @@ var RecordingBandTriggersSchema = RecordingTriggersSchema;
|
|
|
8942
8959
|
* band engine's `applies` rule). `start`/`end` are `HH:MM`; an `end <= start`
|
|
8943
8960
|
* span wraps past midnight (handled by the band engine).
|
|
8944
8961
|
*/
|
|
8962
|
+
/**
|
|
8963
|
+
* Whether a band's footage carries sound.
|
|
8964
|
+
*
|
|
8965
|
+
* The recorder has always attached with `audio: 'aac'`, so every band of every
|
|
8966
|
+
* camera recorded sound whether anyone wanted it or not. The broker already
|
|
8967
|
+
* serves the other answer — `audio: 'none'` takes the video-only `/muted`
|
|
8968
|
+
* restream — so this was a choice the product could always have offered.
|
|
8969
|
+
*
|
|
8970
|
+
* It belongs on the BAND rather than the camera because one camera can want
|
|
8971
|
+
* both: silent across the shop floor during opening hours, sound overnight.
|
|
8972
|
+
* And it applies to BOTH modes — an events band firing on motion has the same
|
|
8973
|
+
* question to answer as a continuous one.
|
|
8974
|
+
*/
|
|
8975
|
+
var RecordingBandAudioSchema = _enum(["on", "muted"]);
|
|
8945
8976
|
var RecordingBandSchema = object({
|
|
8946
8977
|
days: array(RecordingWeekdaySchema),
|
|
8947
8978
|
start: string().regex(HHMM),
|
|
8948
8979
|
end: string().regex(HHMM),
|
|
8949
8980
|
mode: RecordingBandModeSchema,
|
|
8981
|
+
/**
|
|
8982
|
+
* Sound, or silence. OPTIONAL and absent-means-`on`: every recording
|
|
8983
|
+
* written before this existed carried sound, and a band whose key is
|
|
8984
|
+
* missing must keep meaning what it meant. See {@link RecordingBandAudioSchema}.
|
|
8985
|
+
*/
|
|
8986
|
+
audio: RecordingBandAudioSchema.optional(),
|
|
8950
8987
|
triggers: RecordingBandTriggersSchema.optional(),
|
|
8951
8988
|
/**
|
|
8952
8989
|
* RETIRED (D381). A band no longer carries a pre-buffer of its own.
|
|
@@ -20768,6 +20805,7 @@ var MediaFileKindEnum = _enum([
|
|
|
20768
20805
|
"keyFrameSmall",
|
|
20769
20806
|
"thumbnailSmall",
|
|
20770
20807
|
"confirmationCrop",
|
|
20808
|
+
"debugCrop",
|
|
20771
20809
|
"sceneFrame"
|
|
20772
20810
|
]);
|
|
20773
20811
|
/**
|
|
@@ -22432,6 +22470,18 @@ var RunnerCameraConfigSchema = object({
|
|
|
22432
22470
|
motionStreamId: string(),
|
|
22433
22471
|
detectionStreamId: string(),
|
|
22434
22472
|
/**
|
|
22473
|
+
* Which rung this camera's audio analysis listens on — `auto` (the lowest
|
|
22474
|
+
* AVAILABLE native rung) or an explicit one.
|
|
22475
|
+
*
|
|
22476
|
+
* Not consumed by the runner: carried here so it travels with the camera's
|
|
22477
|
+
* config the way `audioMotionWindowMs` does, and so the orchestrator's
|
|
22478
|
+
* watchdog resolves the same stream the audio subscription did. OPTIONAL
|
|
22479
|
+
* with no default — absent means `auto`, which is what every camera written
|
|
22480
|
+
* before this field had, and an absent optional adds nothing to the
|
|
22481
|
+
* generated cap-input defaults (D537).
|
|
22482
|
+
*/
|
|
22483
|
+
audioStreamPick: AudioStreamPickSchema.optional(),
|
|
22484
|
+
/**
|
|
22435
22485
|
* Motion sources that can activate the pipeline. At least one must
|
|
22436
22486
|
* be present. Multiple sources OR-ed: any one firing arms the
|
|
22437
22487
|
* cooldown timer and transitions watching → active. The same
|
|
@@ -23502,10 +23552,9 @@ var ServerBootModeSchema = _enum([
|
|
|
23502
23552
|
* - `idle` / `checking` / `staging` — steady / in-flight registry work.
|
|
23503
23553
|
* - `pending-restart` — a version is staged and the node has NOT yet
|
|
23504
23554
|
* restarted onto it (still running the OLD version).
|
|
23505
|
-
* - `awaiting-confirmation` —
|
|
23506
|
-
*
|
|
23507
|
-
*
|
|
23508
|
-
* manually restarted, or the probation boot auto-rolls-back.
|
|
23555
|
+
* - `awaiting-confirmation` — RETIRED with the probation boot (single-copy
|
|
23556
|
+
* collapse, 2026-07-18). Kept in the enum so an older client still parses;
|
|
23557
|
+
* no provider emits it.
|
|
23509
23558
|
*/
|
|
23510
23559
|
var ServerUpdateStateSchema = _enum([
|
|
23511
23560
|
"idle",
|
|
@@ -23594,12 +23643,35 @@ var ServerRollbackInfoSchema = object({
|
|
|
23594
23643
|
var ServerPackageStatusSchema = object({
|
|
23595
23644
|
/** Root package name (`@camstack/server` on the hub). */
|
|
23596
23645
|
packageName: string(),
|
|
23597
|
-
/**
|
|
23646
|
+
/**
|
|
23647
|
+
* Version of the code THIS PROCESS loaded — captured once, at boot, from the
|
|
23648
|
+
* package.json of the copy the code was loaded from, and never re-read. A
|
|
23649
|
+
* copy replaced on disk under a live process must not move this number;
|
|
23650
|
+
* that divergence is what `installedVersion` is for. Before 2026-09-20 this
|
|
23651
|
+
* was a lazy disk read that would have followed such a swap silently.
|
|
23652
|
+
*/
|
|
23598
23653
|
runningVersion: string().nullable(),
|
|
23599
23654
|
/** Node.js runtime version the node's process runs on (`process.versions.node`). */
|
|
23600
23655
|
nodeRuntimeVersion: string().nullable(),
|
|
23601
23656
|
/** Active data-dir root version; null when booted from seed/workspace. */
|
|
23602
23657
|
activeVersion: string().nullable(),
|
|
23658
|
+
/**
|
|
23659
|
+
* Version of the root package in the data-root `current/` copy RIGHT NOW —
|
|
23660
|
+
* what the next boot would load if nothing is staged. Normally equal to
|
|
23661
|
+
* `runningVersion`; differs only when the copy on disk changed under the
|
|
23662
|
+
* live process. `null` when there is no data-root copy (baked seed or
|
|
23663
|
+
* workspace boot). Optional for version skew: an older provider omits it.
|
|
23664
|
+
*/
|
|
23665
|
+
installedVersion: string().nullable().optional(),
|
|
23666
|
+
/**
|
|
23667
|
+
* True when the code that would run after a restart is NOT the code running
|
|
23668
|
+
* now: a version is staged (`pendingVersion`), or `installedVersion` differs
|
|
23669
|
+
* from `runningVersion`. Derived on every read, never stored. This is the
|
|
23670
|
+
* one field that lets an operator or a script say "1.2.353 is installed and
|
|
23671
|
+
* will run after a restart" instead of reading it off a number that means
|
|
23672
|
+
* "installed". Optional for version skew.
|
|
23673
|
+
*/
|
|
23674
|
+
restartRequired: boolean().optional(),
|
|
23603
23675
|
/** N-1 version kept for rollback; null when no previous version exists. */
|
|
23604
23676
|
previousVersion: string().nullable(),
|
|
23605
23677
|
/** Version of the immutable baked seed closure (image fallback). */
|
|
@@ -23634,19 +23706,71 @@ var ServerPackageStatusSchema = object({
|
|
|
23634
23706
|
});
|
|
23635
23707
|
var ServerUpdateCheckResultSchema = object({
|
|
23636
23708
|
packageName: string(),
|
|
23709
|
+
/** Same meaning as on the status payload: what THIS process loaded. */
|
|
23637
23710
|
runningVersion: string().nullable(),
|
|
23638
23711
|
latestVersion: string().nullable(),
|
|
23712
|
+
/**
|
|
23713
|
+
* `latestVersion` is newer than `runningVersion` AND is not the version
|
|
23714
|
+
* already staged on this node. A staged-and-waiting version is not "an
|
|
23715
|
+
* update available" — applying it again is refused (`already-staged`), and
|
|
23716
|
+
* a button that announced "updating…" on this boolean then got refused
|
|
23717
|
+
* would be lying. It is reported through `pendingVersion` instead.
|
|
23718
|
+
*/
|
|
23639
23719
|
updateAvailable: boolean(),
|
|
23640
23720
|
checkedAtMs: number(),
|
|
23641
23721
|
/** Non-null when the registry lookup failed (offline, bad registry, …). */
|
|
23642
|
-
error: string().nullable()
|
|
23722
|
+
error: string().nullable(),
|
|
23723
|
+
/**
|
|
23724
|
+
* Version staged on this node and awaiting its restart, when one is. Before
|
|
23725
|
+
* 2026-09-20 a check answered `{runningVersion, updateAvailable:false}` for a
|
|
23726
|
+
* node whose staged closure had never booted, and the caller had no way to
|
|
23727
|
+
* see the difference. Optional for version skew.
|
|
23728
|
+
*/
|
|
23729
|
+
pendingVersion: string().nullable().optional(),
|
|
23730
|
+
/** See the status payload: staged, or installed-on-disk ≠ running. Optional for skew. */
|
|
23731
|
+
restartRequired: boolean().optional()
|
|
23643
23732
|
});
|
|
23733
|
+
/**
|
|
23734
|
+
* What an action DID — the discriminant `accepted` cannot carry. Before
|
|
23735
|
+
* 2026-09-20 `applyServerUpdate` answered `accepted:false` both for a refusal
|
|
23736
|
+
* where nothing was wrong (already current, already staged) and for a staging
|
|
23737
|
+
* install that died on `npm error code ECONNRESET`; the only difference was
|
|
23738
|
+
* prose in `message`, and an operator restarted a container on the strength of
|
|
23739
|
+
* it, booting the previous version.
|
|
23740
|
+
*
|
|
23741
|
+
* - `staged` — the closure is installed and validated; the node
|
|
23742
|
+
* restarts to apply it (`restarting:true`). NOTHING
|
|
23743
|
+
* runs the new code until that restart completes.
|
|
23744
|
+
* - `restart-scheduled` — a plain, version-preserving restart was accepted.
|
|
23745
|
+
* - `already-current` — the target IS the running version; nothing done.
|
|
23746
|
+
* - `refused` — dropped by a gate; `reason` names which.
|
|
23747
|
+
* - `failed` — attempted and did not land; `error` says why.
|
|
23748
|
+
*/
|
|
23749
|
+
var ServerUpdateOutcomeSchema = _enum([
|
|
23750
|
+
"staged",
|
|
23751
|
+
"restart-scheduled",
|
|
23752
|
+
"already-current",
|
|
23753
|
+
"refused",
|
|
23754
|
+
"failed"
|
|
23755
|
+
]);
|
|
23644
23756
|
var ServerUpdateActionResultSchema = object({
|
|
23645
23757
|
accepted: boolean(),
|
|
23646
23758
|
targetVersion: string().nullable(),
|
|
23647
23759
|
/** True when a graceful restart was scheduled to apply the change. */
|
|
23648
23760
|
restarting: boolean(),
|
|
23649
|
-
message: string()
|
|
23761
|
+
message: string(),
|
|
23762
|
+
/** See {@link ServerUpdateOutcomeSchema}. Optional for version skew. */
|
|
23763
|
+
outcome: ServerUpdateOutcomeSchema.optional(),
|
|
23764
|
+
/**
|
|
23765
|
+
* Stable, greppable cause for `refused` / `failed` (the engine's
|
|
23766
|
+
* `UpdateRefusalReason`, `staging-failed`, …). `null` on success.
|
|
23767
|
+
*/
|
|
23768
|
+
reason: string().nullable().optional(),
|
|
23769
|
+
/**
|
|
23770
|
+
* The failure itself, verbatim enough to act on (the npm output for a
|
|
23771
|
+
* staging install). Non-null only for `failed`.
|
|
23772
|
+
*/
|
|
23773
|
+
error: string().nullable().optional()
|
|
23650
23774
|
});
|
|
23651
23775
|
method(_void(), ServerPackageStatusSchema, { auth: "admin" }), method(_void(), ServerUpdateCheckResultSchema, {
|
|
23652
23776
|
kind: "mutation",
|
|
@@ -24923,6 +25047,104 @@ var WebrtcStreamChoiceSchema = object({
|
|
|
24923
25047
|
/** Bitrate in kbps (null for adaptive). */
|
|
24924
25048
|
bitrateKbps: number().nullable()
|
|
24925
25049
|
});
|
|
25050
|
+
/**
|
|
25051
|
+
* The last move the adaptive ladder made on this session, and the numbers it
|
|
25052
|
+
* rested on. Mirrors `LadderDecision` in the stream-broker's
|
|
25053
|
+
* `adaptive-controller.ts` — the log line's evidence, made readable.
|
|
25054
|
+
*
|
|
25055
|
+
* Nullability is load-bearing: `lossRatio: null` means the loss tracker had
|
|
25056
|
+
* no window to report, which is NOT a link with zero loss.
|
|
25057
|
+
*/
|
|
25058
|
+
var WebrtcLadderDecisionSchema = object({
|
|
25059
|
+
/** `loss` — RTCP / client-reported packet loss; `stall` — the picture
|
|
25060
|
+
* stopped moving; `healthy` — a promotion. */
|
|
25061
|
+
cause: _enum([
|
|
25062
|
+
"loss",
|
|
25063
|
+
"stall",
|
|
25064
|
+
"healthy"
|
|
25065
|
+
]),
|
|
25066
|
+
/** The rung the ladder moved TO. Null for a move that changed no rung
|
|
25067
|
+
* (an MTU reduction, a forced transcode-down at the bottom). */
|
|
25068
|
+
tier: CamProfileSchema.nullable(),
|
|
25069
|
+
/** Whether that move forced the egress transcode-down rung. */
|
|
25070
|
+
forcedTranscodeDown: boolean(),
|
|
25071
|
+
/** Loss ratio over the tracker's window at the moment of the decision. */
|
|
25072
|
+
lossRatio: number().nullable(),
|
|
25073
|
+
/** The client's reported downlink at that moment, in kbps. */
|
|
25074
|
+
clientKbps: number().nullable(),
|
|
25075
|
+
/** How long the stall detector had been seeing nothing arrive. */
|
|
25076
|
+
stalledForMs: number().nullable(),
|
|
25077
|
+
/** Whether the broker WAS sending while that was measured — the half that
|
|
25078
|
+
* separates "the camera stopped" from "the link stopped". */
|
|
25079
|
+
serverSending: boolean().nullable(),
|
|
25080
|
+
/** Epoch ms. The overlay prints its age; a decision from 40 minutes ago
|
|
25081
|
+
* must not read like one from this second. */
|
|
25082
|
+
at: number()
|
|
25083
|
+
});
|
|
25084
|
+
/** Per-tier count of MEASURED downgrades this viewer session has suffered. */
|
|
25085
|
+
var WebrtcTierFailureSchema = object({
|
|
25086
|
+
tier: CamProfileSchema,
|
|
25087
|
+
failures: number().int().nonnegative()
|
|
25088
|
+
});
|
|
25089
|
+
/**
|
|
25090
|
+
* What the SERVER decided for one WebRTC session — the half the client cannot
|
|
25091
|
+
* measure. See `getSessionDebug`.
|
|
25092
|
+
*/
|
|
25093
|
+
var WebrtcSessionDebugSchema = object({
|
|
25094
|
+
sessionId: string(),
|
|
25095
|
+
/** Whether the session still exists here. Everything below is null when
|
|
25096
|
+
* it does not — a dead session has no measurements, not zero ones. */
|
|
25097
|
+
alive: boolean(),
|
|
25098
|
+
/** Epoch ms this snapshot was taken, so the overlay can show its age. */
|
|
25099
|
+
observedAt: number(),
|
|
25100
|
+
/** The broker actually feeding this session (`<deviceId>/<camStreamId>`). */
|
|
25101
|
+
brokerId: string().nullable(),
|
|
25102
|
+
camStreamId: string().nullable(),
|
|
25103
|
+
/** `derived` = this hub BUILT the rung being served (a transcode/scale of
|
|
25104
|
+
* another stream); `native` = the camera's own encode. */
|
|
25105
|
+
sourceKind: _enum([
|
|
25106
|
+
"native",
|
|
25107
|
+
"derived",
|
|
25108
|
+
"unknown"
|
|
25109
|
+
]),
|
|
25110
|
+
/** Whether the ladder is running at all (an `adaptive` target). */
|
|
25111
|
+
adaptive: boolean(),
|
|
25112
|
+
/** The rung `auto` ACTUALLY resolved to right now. Null when the broker
|
|
25113
|
+
* is bound to no profile slot (a manually-activated stream). */
|
|
25114
|
+
servedTier: CamProfileSchema.nullable(),
|
|
25115
|
+
/** Which node ingests the source. Null when unresolved. */
|
|
25116
|
+
ingestNodeId: string().nullable(),
|
|
25117
|
+
/** Whether that node is a remote one (the media crosses a node boundary
|
|
25118
|
+
* before it reaches this session). Null when unresolved. */
|
|
25119
|
+
ingestIsRemote: boolean().nullable(),
|
|
25120
|
+
/** Codec off the source, and the one negotiated with this client. */
|
|
25121
|
+
sourceCodec: string().nullable(),
|
|
25122
|
+
negotiatedCodec: string().nullable(),
|
|
25123
|
+
/** Whether the EGRESS leg re-encodes for this client or forwards the
|
|
25124
|
+
* source bytes untouched. */
|
|
25125
|
+
egressLeg: _enum([
|
|
25126
|
+
"copy",
|
|
25127
|
+
"transcode",
|
|
25128
|
+
"unknown"
|
|
25129
|
+
]),
|
|
25130
|
+
/** What the broker itself reports about the rung it is serving. */
|
|
25131
|
+
brokerStatus: string().nullable(),
|
|
25132
|
+
brokerCodec: string().nullable(),
|
|
25133
|
+
brokerBitrateKbps: number().nullable(),
|
|
25134
|
+
brokerInputFps: number().nullable(),
|
|
25135
|
+
brokerUptimeMs: number().nullable(),
|
|
25136
|
+
/** RTP packets this session has sent — the server's own "am I sending?". */
|
|
25137
|
+
rtpPacketsSent: number().nullable(),
|
|
25138
|
+
/** D42: the track may be carrying the recorded feeder, not live. The ladder
|
|
25139
|
+
* is suspended there, so a frozen picture in `recorded` mode means
|
|
25140
|
+
* something entirely different. */
|
|
25141
|
+
trackMode: _enum(["live", "recorded"]).nullable(),
|
|
25142
|
+
lastDecision: WebrtcLadderDecisionSchema.nullable(),
|
|
25143
|
+
tierFailures: array(WebrtcTierFailureSchema),
|
|
25144
|
+
/** Successful promotions in this viewer session (drives the progressive
|
|
25145
|
+
* healthy timer). Null when no ladder is running. */
|
|
25146
|
+
upgradeCount: number().nullable()
|
|
25147
|
+
});
|
|
24926
25148
|
DeviceType.Camera, method(object({ deviceId: number().int().nonnegative() }), array(WebrtcStreamChoiceSchema)), method(object({
|
|
24927
25149
|
deviceId: number().int().nonnegative(),
|
|
24928
25150
|
target: WebrtcStreamTargetSchema,
|
|
@@ -25087,7 +25309,10 @@ DeviceType.Camera, method(object({ deviceId: number().int().nonnegative() }), ar
|
|
|
25087
25309
|
* the same answer as "it ended": either way nothing is holding it up.
|
|
25088
25310
|
*/
|
|
25089
25311
|
alive: boolean()
|
|
25090
|
-
}))
|
|
25312
|
+
})), method(object({
|
|
25313
|
+
deviceId: number().int().nonnegative(),
|
|
25314
|
+
sessionId: string()
|
|
25315
|
+
}), WebrtcSessionDebugSchema, { kind: "query" });
|
|
25091
25316
|
object({
|
|
25092
25317
|
/** All accessory children of the parent. */
|
|
25093
25318
|
childDeviceIds: array(number()).readonly(),
|
|
@@ -28211,16 +28436,37 @@ var GridProfileOfferSchema = object({
|
|
|
28211
28436
|
]),
|
|
28212
28437
|
offered: boolean(),
|
|
28213
28438
|
/** Sources that cannot serve it. Empty when it is offered, or when there are no cells. */
|
|
28214
|
-
missingSources: array(number().int().positive())
|
|
28439
|
+
missingSources: array(number().int().positive()),
|
|
28440
|
+
/**
|
|
28441
|
+
* The canvas this profile composes onto, `WxH`, or empty when it is not
|
|
28442
|
+
* offered. DERIVED from the cells and the sources' own size at this profile —
|
|
28443
|
+
* it is reported because nothing else in the system would ever say what the
|
|
28444
|
+
* grid came out as, and because it is the number an operator would otherwise
|
|
28445
|
+
* expect to type.
|
|
28446
|
+
*/
|
|
28447
|
+
canvas: string(),
|
|
28448
|
+
/**
|
|
28449
|
+
* Whether this profile is PUBLISHED, of the ones the grid could serve.
|
|
28450
|
+
*
|
|
28451
|
+
* A grid's `high` is composed of its sources' `high`, so on a 4K fleet it is
|
|
28452
|
+
* a 4K canvas built from 4K decodes — something to opt into, not something a
|
|
28453
|
+
* viewer's adaptive should be handed by climbing to the top rung it can see.
|
|
28454
|
+
* Default is `mid` + `low`.
|
|
28455
|
+
*/
|
|
28456
|
+
published: boolean()
|
|
28215
28457
|
});
|
|
28216
28458
|
var GridLayoutViewSchema = object({
|
|
28217
28459
|
/** The persisted grid row this camera was declared from. */
|
|
28218
28460
|
instanceId: string(),
|
|
28219
28461
|
deviceId: number().int().nonnegative(),
|
|
28220
28462
|
name: string(),
|
|
28221
|
-
/**
|
|
28222
|
-
|
|
28223
|
-
|
|
28463
|
+
/**
|
|
28464
|
+
* NO canvas size. A grid's resolution is not authored: each profile derives
|
|
28465
|
+
* its own from the cells and its sources' dimensions. The two numbers that
|
|
28466
|
+
* used to be here were a text field that silently decided both how much the
|
|
28467
|
+
* composite cost and how sharp it was — see `profiles[].canvas` for what it
|
|
28468
|
+
* came out as.
|
|
28469
|
+
*/
|
|
28224
28470
|
fps: number().int(),
|
|
28225
28471
|
cells: array(GridLayoutCellSchema),
|
|
28226
28472
|
/** What the catalog will publish, and what it refuses to. Read-only. */
|
|
@@ -28229,9 +28475,13 @@ var GridLayoutViewSchema = object({
|
|
|
28229
28475
|
var GridLayoutPatchSchema = object({
|
|
28230
28476
|
deviceId: number().int().nonnegative(),
|
|
28231
28477
|
name: string().min(1).max(160).optional(),
|
|
28232
|
-
width: number().int().min(160).max(7680).optional(),
|
|
28233
|
-
height: number().int().min(120).max(4320).optional(),
|
|
28234
28478
|
fps: number().int().min(1).max(60).optional(),
|
|
28479
|
+
/** Which profiles to publish. See `GridProfileOffer.published`. */
|
|
28480
|
+
publishedProfiles: array(_enum([
|
|
28481
|
+
"high",
|
|
28482
|
+
"mid",
|
|
28483
|
+
"low"
|
|
28484
|
+
])).max(3).optional(),
|
|
28235
28485
|
/**
|
|
28236
28486
|
* The whole cell list at once. A per-cell patch would need an ordering the
|
|
28237
28487
|
* editor does not have, and a half-applied layout is a picture nobody asked
|
|
@@ -38811,6 +39061,12 @@ Object.freeze({
|
|
|
38811
39061
|
addonId: null,
|
|
38812
39062
|
access: "view"
|
|
38813
39063
|
},
|
|
39064
|
+
"webrtcSession.getSessionDebug": {
|
|
39065
|
+
capName: "webrtc-session",
|
|
39066
|
+
capScope: "device",
|
|
39067
|
+
addonId: null,
|
|
39068
|
+
access: "view"
|
|
39069
|
+
},
|
|
38814
39070
|
"webrtcSession.getSessionState": {
|
|
38815
39071
|
capName: "webrtc-session",
|
|
38816
39072
|
capScope: "device",
|
|
@@ -40794,6 +41050,11 @@ Object.freeze({
|
|
|
40794
41050
|
form: "single",
|
|
40795
41051
|
optional: false
|
|
40796
41052
|
}],
|
|
41053
|
+
"webrtcSession.getSessionDebug": [{
|
|
41054
|
+
name: "deviceId",
|
|
41055
|
+
form: "single",
|
|
41056
|
+
optional: false
|
|
41057
|
+
}],
|
|
40797
41058
|
"webrtcSession.getSessionState": [{
|
|
40798
41059
|
name: "deviceId",
|
|
40799
41060
|
form: "single",
|
|
@@ -41412,6 +41673,60 @@ DEFAULT_NATIVE_LEASE_SETTINGS.sceneBudgetMb;
|
|
|
41412
41673
|
DEFAULT_NATIVE_LEASE_SETTINGS.admission;
|
|
41413
41674
|
var MB = 1024 * 1024;
|
|
41414
41675
|
1024 * MB, 3072 * MB;
|
|
41676
|
+
//#endregion
|
|
41677
|
+
//#region ../types/dist/node.mjs
|
|
41678
|
+
/**
|
|
41679
|
+
* The ffmpeg build every CamStack node downloads, per platform and arch.
|
|
41680
|
+
*
|
|
41681
|
+
* ## Why a download, on every node, including the container
|
|
41682
|
+
*
|
|
41683
|
+
* The version of ffmpeg used to be CODE: baked into the image, changeable only
|
|
41684
|
+
* by rebuilding and rolling it. Now it is DATA — this file states the release,
|
|
41685
|
+
* and a node that boots with a different one downloads it. Changing ffmpeg is a
|
|
41686
|
+
* framework bump, not an image rebuild.
|
|
41687
|
+
*
|
|
41688
|
+
* It also settles the licence question by removing it. FFmpeg's licence is
|
|
41689
|
+
* chosen at compile time: the core is LGPL 2.1+, `--enable-gpl` makes it GPL
|
|
41690
|
+
* (for x264/x265), `--enable-version3` makes that v3, and `--enable-nonfree`
|
|
41691
|
+
* makes the result undistributable. These are GPL v3 builds, and we do not
|
|
41692
|
+
* distribute them: the user's own node fetches the archive from the project
|
|
41693
|
+
* that publishes it, so we convey no GPL work and inherit none of the
|
|
41694
|
+
* obligations that come with conveying one. That is the whole reason the image
|
|
41695
|
+
* no longer installs ffmpeg at all — not a technical preference.
|
|
41696
|
+
*
|
|
41697
|
+
* ## Why jellyfin-ffmpeg
|
|
41698
|
+
*
|
|
41699
|
+
* One vendor covering linux-amd64, linux-arm64, darwin-arm64 and darwin-x64
|
|
41700
|
+
* from one release, with the widest hardware surface of anything maintained:
|
|
41701
|
+
* VAAPI, QSV, NVENC/NVDEC, AMF, Vulkan, OpenCL and DRM on amd64, plus Rockchip
|
|
41702
|
+
* MPP on arm64. It reaches the vendor libraries through dlopen trampolines,
|
|
41703
|
+
* so the binary is not linked against a driver stack it may not find.
|
|
41704
|
+
*
|
|
41705
|
+
* **What it still needs from the host, measured 2026-09-19.** On the Unraid
|
|
41706
|
+
* HOST, which has no libva, `-vaapi_device` aborts the process outright —
|
|
41707
|
+
* `implib-gen: libva-drm.so.2: failed to load library ... Assertion '0 &&
|
|
41708
|
+
* "Assertion in generated code"' failed`. Inside our container, which installs
|
|
41709
|
+
* libva plus the iHD driver, the same command encodes: verified end to end with
|
|
41710
|
+
* `testsrc → format=nv12,hwupload → h264_vaapi`. So the image drops ffmpeg and
|
|
41711
|
+
* KEEPS the Intel media stack; the drivers are the part a binary cannot bring.
|
|
41712
|
+
*
|
|
41713
|
+
* ## Why each artifact carries a checksum and a claim
|
|
41714
|
+
*
|
|
41715
|
+
* The download is now the only source, so `sha256` is verified before anything
|
|
41716
|
+
* is extracted — jellyfin publishes no checksums on its release assets, so
|
|
41717
|
+
* these are ours, taken at the version bump. And `expectedHwaccels` is what the
|
|
41718
|
+
* build is supposed to be able to do: a binary that probes short of its own
|
|
41719
|
+
* claim is a source that changed under us, which is precisely what went
|
|
41720
|
+
* unnoticed for months when a static build turned out to carry no VAAPI at all
|
|
41721
|
+
* (D536).
|
|
41722
|
+
*/
|
|
41723
|
+
/** The pinned jellyfin-ffmpeg release. Changing this is the version change. */
|
|
41724
|
+
var FFMPEG_RELEASE = "8.1.2-5";
|
|
41725
|
+
var BASE = `https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v${FFMPEG_RELEASE}`;
|
|
41726
|
+
function portable(target) {
|
|
41727
|
+
return `${BASE}/jellyfin-ffmpeg_${FFMPEG_RELEASE}_portable_${target}-gpl.tar.xz`;
|
|
41728
|
+
}
|
|
41729
|
+
portable("linux64"), portable("linuxarm64"), portable("macarm64"), portable("mac64");
|
|
41415
41730
|
var DEFAULT_FIRST_UNIT_TIMEOUT_MS = 12e3;
|
|
41416
41731
|
/** Heartbeat cadence — ~2 minutes of 4 s fragments. */
|
|
41417
41732
|
var FRAGMENT_LOG_EVERY = 30;
|
|
@@ -88393,6 +88708,43 @@ var BridgeHost = class {
|
|
|
88393
88708
|
}
|
|
88394
88709
|
};
|
|
88395
88710
|
//#endregion
|
|
88711
|
+
//#region src/exposed-entry.ts
|
|
88712
|
+
/**
|
|
88713
|
+
* Carrying an exposed-device entry across a re-expose.
|
|
88714
|
+
*
|
|
88715
|
+
* `exposeDevice` rebuilds its entry from scratch — display name, mapper kind,
|
|
88716
|
+
* timestamp — and then REPLACES the stored one. Anything the rebuilt object
|
|
88717
|
+
* does not mention is therefore destroyed, and two things it never mentioned
|
|
88718
|
+
* were the per-camera settings and the capability list.
|
|
88719
|
+
*
|
|
88720
|
+
* The visible cost: the operator's "Source stream (HomeKit)" selector writes
|
|
88721
|
+
* `low`, the addon logs `streamPreference changed — refreshing accessory
|
|
88722
|
+
* {from=auto to=low}`, and the accessory that comes back derives its
|
|
88723
|
+
* advertisement from DEFAULTS — `streamPreference=auto` — because the settings
|
|
88724
|
+
* were dropped between the write and the rebuild. The selector only ever took
|
|
88725
|
+
* effect after a full addon restart, when the settings were loaded first. A
|
|
88726
|
+
* second write after the re-expose hid this: the store ended up correct, so
|
|
88727
|
+
* nothing looked wrong except the stream nobody could explain.
|
|
88728
|
+
*/
|
|
88729
|
+
/**
|
|
88730
|
+
* Fill `base` from `existing` for the given keys, letting `base` win wherever
|
|
88731
|
+
* it actually says something.
|
|
88732
|
+
*
|
|
88733
|
+
* That asymmetry is the point: a caller who passes `capabilities` is stating a
|
|
88734
|
+
* new truth and must not be overruled by the stored copy, while a caller who
|
|
88735
|
+
* says nothing about `settings` is not asking for them to be erased.
|
|
88736
|
+
*/
|
|
88737
|
+
function carryForward(base, existing, keys) {
|
|
88738
|
+
if (existing === void 0) return base;
|
|
88739
|
+
const out = { ...base };
|
|
88740
|
+
for (const key of keys) {
|
|
88741
|
+
if (out[key] !== void 0) continue;
|
|
88742
|
+
const carried = existing[key];
|
|
88743
|
+
if (carried !== void 0) out[key] = carried;
|
|
88744
|
+
}
|
|
88745
|
+
return out;
|
|
88746
|
+
}
|
|
88747
|
+
//#endregion
|
|
88396
88748
|
//#region src/hap-setup-uri.ts
|
|
88397
88749
|
function errMsg$15(e) {
|
|
88398
88750
|
return e instanceof Error ? e.message : String(e);
|
|
@@ -88427,47 +88779,610 @@ function firstExposedAccessorySetupUri(exposed, logger) {
|
|
|
88427
88779
|
}
|
|
88428
88780
|
}
|
|
88429
88781
|
//#endregion
|
|
88430
|
-
//#region src/
|
|
88782
|
+
//#region src/hksv/clip-record.ts
|
|
88783
|
+
/**
|
|
88784
|
+
* What ONE teed HomeKit clip is, on disk.
|
|
88785
|
+
*
|
|
88786
|
+
* ## Why a sidecar at all
|
|
88787
|
+
*
|
|
88788
|
+
* HomeKit Secure Video is a SINK. HAP's `dataSend` is `hello/whoami/open/data/
|
|
88789
|
+
* ack/close` and `CameraRecordingDelegate` is push-only, so a clip "recorded by
|
|
88790
|
+
* HomeKit" can only ever be ours if we kept our own copy of what we SENT.
|
|
88791
|
+
* There is no read-back to reconcile against later, and there never will be.
|
|
88792
|
+
* Everything a later reader can know about a teed clip therefore has to be
|
|
88793
|
+
* written down at the moment it was written — by the only process that ever saw
|
|
88794
|
+
* it — or it is lost with the session.
|
|
88795
|
+
*
|
|
88796
|
+
* ## The two coverage limits, stated on the record rather than discovered
|
|
88797
|
+
*
|
|
88798
|
+
* - **Only some cameras record at all.** `pickRecordingSource` refuses anything
|
|
88799
|
+
* without an H.264 slot, `MAX_RECORDING_HEIGHT` prefers ≤ 1080p, and
|
|
88800
|
+
* `deriveFragmentLengthMs` refuses a GOP longer than 8 s. A camera the tee
|
|
88801
|
+
* never writes for is not a gap in this store, it is a camera HKSV never
|
|
88802
|
+
* recorded; {@link HksvClipRecord} exists only for the ones it did.
|
|
88803
|
+
* - **The duration is whatever iOS PULLED.** `durationMs` is the wall-clock
|
|
88804
|
+
* span of the HDS stream (prebuffer replay plus however long the hub kept it
|
|
88805
|
+
* open), not a window CamStack chose. A short clip is not a truncated one —
|
|
88806
|
+
* {@link HksvClipRecord.truncated} is the only thing that says truncated.
|
|
88807
|
+
*
|
|
88808
|
+
* ## `thumbnailFile` is VOUCHED
|
|
88809
|
+
*
|
|
88810
|
+
* It is written only after the JPEG has been stat'd on disk at non-zero size.
|
|
88811
|
+
* A minter that reports success and produced nothing leaves
|
|
88812
|
+
* {@link HksvClipRecord.thumbnailUnavailable} instead — the whole point of
|
|
88813
|
+
* vouching is that the promise and the file are not the same claim.
|
|
88814
|
+
*/
|
|
88815
|
+
/** The `videoclips` source id a later provider will list these under. */
|
|
88816
|
+
var HKSV_CLIP_SOURCE = "hksv";
|
|
88817
|
+
/** Why no still could be minted from the saved fMP4. Named, never blank. */
|
|
88818
|
+
var HksvThumbnailUnavailableReasonSchema = _enum([
|
|
88819
|
+
"decode-failed",
|
|
88820
|
+
"timed-out",
|
|
88821
|
+
"ffmpeg-missing",
|
|
88822
|
+
"no-init-segment",
|
|
88823
|
+
"not-attempted"
|
|
88824
|
+
]);
|
|
88825
|
+
/** Why a clip stopped short of the stream that fed it. */
|
|
88826
|
+
var HksvClipTruncationSchema = _enum([
|
|
88827
|
+
"size-cap",
|
|
88828
|
+
"slow-consumer",
|
|
88829
|
+
"write-failed"
|
|
88830
|
+
]);
|
|
88831
|
+
var HksvClipRecordSchema = object({
|
|
88832
|
+
/** Bumped when a field's MEANING changes; a reader refuses what it cannot read. */
|
|
88833
|
+
schemaVersion: literal(1),
|
|
88834
|
+
/** `hksv:<deviceId>:<startedAtMs>-<streamId>` — self-contained, so a later
|
|
88835
|
+
* provider addresses bytes and stills without re-listing. */
|
|
88836
|
+
clipId: string().min(1),
|
|
88837
|
+
source: literal(HKSV_CLIP_SOURCE),
|
|
88838
|
+
deviceId: number().int(),
|
|
88839
|
+
/** The HDS stream this clip was teed from — the join to the delegate's logs. */
|
|
88840
|
+
streamId: number().int(),
|
|
88841
|
+
startedAtMs: number().int(),
|
|
88842
|
+
endedAtMs: number().int(),
|
|
88843
|
+
/** `endedAtMs - startedAtMs`. What iOS pulled, never a window we chose. */
|
|
88844
|
+
durationMs: number().int().nonnegative(),
|
|
88845
|
+
/** How much of the clip's head came from the prebuffer ring, by ARRIVAL age. */
|
|
88846
|
+
prebufferSpanMs: number().int().nonnegative(),
|
|
88847
|
+
bytes: number().int().nonnegative(),
|
|
88848
|
+
fragments: number().int().nonnegative(),
|
|
88849
|
+
/** Basenames, relative to the clip's own device directory. */
|
|
88850
|
+
file: string().min(1),
|
|
88851
|
+
thumbnailFile: string().min(1).optional(),
|
|
88852
|
+
thumbnailUnavailable: object({ reason: HksvThumbnailUnavailableReasonSchema }).optional(),
|
|
88853
|
+
truncated: HksvClipTruncationSchema.optional(),
|
|
88854
|
+
/** iOS sent `ack` for this stream: HomeKit itself kept the clip. */
|
|
88855
|
+
acknowledgedByHomeKit: boolean(),
|
|
88856
|
+
width: number().int().positive(),
|
|
88857
|
+
height: number().int().positive(),
|
|
88858
|
+
/** The advertised fragment length the source was cutting at. */
|
|
88859
|
+
fragmentMs: number().int().positive()
|
|
88860
|
+
});
|
|
88431
88861
|
/**
|
|
88432
|
-
*
|
|
88433
|
-
*
|
|
88434
|
-
*
|
|
88435
|
-
* One implementation for both accessory shapes (camera and generic): the
|
|
88436
|
-
* fields come from the device's own metadata either way, and a second copy
|
|
88437
|
-
* would be a second answer to "what serial does this device publish".
|
|
88438
|
-
* Metadata is best-effort — a device that cannot answer still publishes.
|
|
88862
|
+
* One stem per clip, three extensions. Derived from the id and from nothing
|
|
88863
|
+
* else, so a pruner deleting a clip never has to guess which JPEG was its.
|
|
88439
88864
|
*/
|
|
88440
|
-
|
|
88441
|
-
const
|
|
88442
|
-
|
|
88865
|
+
function clipFileNames(clipId) {
|
|
88866
|
+
const stem = clipStem(clipId);
|
|
88867
|
+
return {
|
|
88868
|
+
clip: `${stem}.mp4`,
|
|
88869
|
+
thumbnail: `${stem}.jpg`,
|
|
88870
|
+
record: `${stem}.json`
|
|
88871
|
+
};
|
|
88872
|
+
}
|
|
88873
|
+
function clipIdFor(deviceId, startedAtMs, streamId) {
|
|
88874
|
+
return `${HKSV_CLIP_SOURCE}:${deviceId}:${startedAtMs}-${streamId}`;
|
|
88875
|
+
}
|
|
88876
|
+
/** The filesystem-safe tail of an id. `:` is legal on ext4 and hostile everywhere else. */
|
|
88877
|
+
function clipStem(clipId) {
|
|
88878
|
+
return clipId.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
88879
|
+
}
|
|
88880
|
+
//#endregion
|
|
88881
|
+
//#region src/hksv/clip-store.ts
|
|
88882
|
+
/**
|
|
88883
|
+
* `HksvClipStore` — who owns the teed clips, and for how long.
|
|
88884
|
+
*
|
|
88885
|
+
* ## The owner
|
|
88886
|
+
*
|
|
88887
|
+
* The **export-hap addon**, in its OWN data dir, and nothing else. Not the
|
|
88888
|
+
* recorder's storage locations and not the `eventMedia` class:
|
|
88889
|
+
*
|
|
88890
|
+
* - `export-hap` is hub-only and runs in its own runner. The recorder's
|
|
88891
|
+
* location machinery (D385/D386/D389) is another addon's, in another
|
|
88892
|
+
* process, and reaching it would be a cross-runner write on the frame path
|
|
88893
|
+
* of a live HomeKit session — exactly what D9/D18 forbid.
|
|
88894
|
+
* - A teed clip is a DERIVED artifact of a HomeKit session, with no track, no
|
|
88895
|
+
* detection and no event behind it. Filing it under `eventMedia` would put
|
|
88896
|
+
* it inside the post-analysis retention sweep, where its lifetime would be
|
|
88897
|
+
* decided by a policy written for a different question.
|
|
88898
|
+
* - The precedent is D549 decision 13: Reolink's clip thumbnails live in the
|
|
88899
|
+
* vendor addon's data dir under a per-device LRU, outside `eventMedia` and
|
|
88900
|
+
* outside the sweep, for the same reason.
|
|
88901
|
+
*
|
|
88902
|
+
* Layout: `<dataDir>/hksv-clips/<deviceId>/<stem>.{mp4,jpg,json}`. One stem per
|
|
88903
|
+
* clip, three extensions, so a pruner deleting a clip never has to guess which
|
|
88904
|
+
* JPEG was its.
|
|
88905
|
+
*
|
|
88906
|
+
* ## The retention, and why these numbers
|
|
88907
|
+
*
|
|
88908
|
+
* HomeKit's own retention is unreadable — HAP has no read-back, so CamStack can
|
|
88909
|
+
* never ask "does iOS still have this one?" and reconcile. The store therefore
|
|
88910
|
+
* cannot mirror HomeKit's lifetime and does not pretend to: it keeps a bounded
|
|
88911
|
+
* recent window and says, per clip, exactly when it started. Four bounds, all
|
|
88912
|
+
* enforced on every completion:
|
|
88913
|
+
*
|
|
88914
|
+
* - **{@link DEFAULT_CLIP_BOUNDS.maxClipBytes} per clip (256 MB).** A clip's
|
|
88915
|
+
* duration is whatever iOS pulled — there is no window we choose — so this
|
|
88916
|
+
* is the only ceiling on a session that never closes. At the ~64 KB/s a
|
|
88917
|
+
* 1080p copy branch produces it is over an hour of video.
|
|
88918
|
+
* - **{@link DEFAULT_CLIP_BOUNDS.maxClipsPerDevice} (200)** and
|
|
88919
|
+
* **{@link DEFAULT_CLIP_BOUNDS.maxBytesPerDevice} (512 MB)**, oldest first.
|
|
88920
|
+
* Measured fragment sizes (D84): ~255 KB per 8 s at 720p, ~500 KB at 1080p,
|
|
88921
|
+
* so a typical 20-40 s HKSV clip is 1.5-3 MB and 200 of them is ~400 MB.
|
|
88922
|
+
* Both bounds exist for the same reason the prebuffer ring has both: at 4K
|
|
88923
|
+
* a fragment is 6.35 MB, a 25x spread, and a count-only bound is a
|
|
88924
|
+
* per-camera disk figure nobody can predict.
|
|
88925
|
+
* - **{@link DEFAULT_CLIP_BOUNDS.maxAgeMs} (14 days)**, so a camera that
|
|
88926
|
+
* stopped recording in March does not hold half a gigabyte forever.
|
|
88927
|
+
* - **{@link DEFAULT_CLIP_BOUNDS.maxStoreBytes} (4 GB)** across every camera,
|
|
88928
|
+
* applied after the per-device pass. Per-device bounds alone multiply by a
|
|
88929
|
+
* camera count the operator changes without thinking about this store.
|
|
88930
|
+
*
|
|
88931
|
+
* A file nobody prunes is a disk that fills, and the pruner is the only thing
|
|
88932
|
+
* standing between a permanent prebuffer and that.
|
|
88933
|
+
*
|
|
88934
|
+
* ## The thumbnail is VOUCHED, never promised
|
|
88935
|
+
*
|
|
88936
|
+
* The minter is injected and its verdict is not believed: after it returns the
|
|
88937
|
+
* store stats the JPEG, and `thumbnailFile` is written only if a non-empty file
|
|
88938
|
+
* is really there. A minter that reports success and produced nothing gets
|
|
88939
|
+
* `thumbnailUnavailable.reason: 'decode-failed'` and its zero-byte leftover
|
|
88940
|
+
* deleted — this repo has twice shipped a green test because the fake supplied
|
|
88941
|
+
* what production forgot, and a promise is not a file.
|
|
88942
|
+
*/
|
|
88943
|
+
/** The directory under the addon's data dir. Ours, and only ours. */
|
|
88944
|
+
var CLIP_DIR_NAME = "hksv-clips";
|
|
88945
|
+
/** See the class docblock for what each number is, and where it comes from. */
|
|
88946
|
+
var DEFAULT_CLIP_BOUNDS = {
|
|
88947
|
+
maxClipBytes: 256 * 1024 * 1024,
|
|
88948
|
+
maxClipsPerDevice: 200,
|
|
88949
|
+
maxBytesPerDevice: 512 * 1024 * 1024,
|
|
88950
|
+
maxAgeMs: 336 * 60 * 60 * 1e3,
|
|
88951
|
+
maxStoreBytes: 4 * 1024 * 1024 * 1024
|
|
88952
|
+
};
|
|
88953
|
+
var HksvClipStore = class {
|
|
88954
|
+
input;
|
|
88955
|
+
log;
|
|
88956
|
+
bounds;
|
|
88957
|
+
now;
|
|
88958
|
+
inFlight = /* @__PURE__ */ new Map();
|
|
88959
|
+
constructor(input) {
|
|
88960
|
+
this.input = input;
|
|
88961
|
+
this.log = input.logger;
|
|
88962
|
+
this.bounds = {
|
|
88963
|
+
...DEFAULT_CLIP_BOUNDS,
|
|
88964
|
+
...input.bounds
|
|
88965
|
+
};
|
|
88966
|
+
this.now = input.now ?? Date.now;
|
|
88967
|
+
}
|
|
88968
|
+
/** The per-clip ceiling the tee enforces while writing. */
|
|
88969
|
+
get maxClipBytes() {
|
|
88970
|
+
return this.bounds.maxClipBytes;
|
|
88971
|
+
}
|
|
88972
|
+
/**
|
|
88973
|
+
* Open a file for one HDS stream. `null` when the directory cannot be made —
|
|
88974
|
+
* the caller must then skip the tee entirely and leave HKSV untouched, which
|
|
88975
|
+
* is why this returns rather than throws.
|
|
88976
|
+
*/
|
|
88977
|
+
async open(input) {
|
|
88978
|
+
const clipId = clipIdFor(input.deviceId, input.startedAtMs, input.streamId);
|
|
88979
|
+
const names = clipFileNames(clipId);
|
|
88980
|
+
const dir = this.deviceDir(input.deviceId);
|
|
88981
|
+
const tags = { deviceId: input.deviceId };
|
|
88982
|
+
try {
|
|
88983
|
+
await (0, node_fs_promises.mkdir)(dir, { recursive: true });
|
|
88984
|
+
const sink = await createFileSink((0, node_path.join)(dir, names.clip));
|
|
88985
|
+
this.inFlight.set(clipId, {
|
|
88986
|
+
deviceId: input.deviceId,
|
|
88987
|
+
stem: stemOf(names.clip)
|
|
88988
|
+
});
|
|
88989
|
+
return {
|
|
88990
|
+
clipId,
|
|
88991
|
+
sink,
|
|
88992
|
+
complete: (outcome) => this.complete(clipId, input, names, outcome)
|
|
88993
|
+
};
|
|
88994
|
+
} catch (err) {
|
|
88995
|
+
this.inFlight.delete(clipId);
|
|
88996
|
+
this.log.warn("hksv clip store: could NOT open a clip file — nothing is teed for this stream", {
|
|
88997
|
+
tags,
|
|
88998
|
+
meta: {
|
|
88999
|
+
streamId: input.streamId,
|
|
89000
|
+
error: err instanceof Error ? err.message : String(err)
|
|
89001
|
+
}
|
|
89002
|
+
});
|
|
89003
|
+
return null;
|
|
89004
|
+
}
|
|
89005
|
+
}
|
|
89006
|
+
/** Every sidecar for a camera, newest first. What a later provider lists. */
|
|
89007
|
+
async listRecords(deviceId) {
|
|
89008
|
+
const dir = this.deviceDir(deviceId);
|
|
89009
|
+
let names;
|
|
89010
|
+
try {
|
|
89011
|
+
names = await (0, node_fs_promises.readdir)(dir);
|
|
89012
|
+
} catch {
|
|
89013
|
+
return [];
|
|
89014
|
+
}
|
|
89015
|
+
const records = [];
|
|
89016
|
+
for (const name of names) {
|
|
89017
|
+
if (!name.endsWith(".json")) continue;
|
|
89018
|
+
const record = await this.readRecord((0, node_path.join)(dir, name), deviceId);
|
|
89019
|
+
if (record !== null) records.push(record);
|
|
89020
|
+
}
|
|
89021
|
+
return records.sort((a, b) => b.startedAtMs - a.startedAtMs);
|
|
89022
|
+
}
|
|
89023
|
+
/** iOS sent `ack`: HomeKit itself kept this clip. Recorded on the sidecar. */
|
|
89024
|
+
async acknowledge(deviceId, clipId) {
|
|
89025
|
+
const path = (0, node_path.join)(this.deviceDir(deviceId), clipFileNames(clipId).record);
|
|
89026
|
+
const record = await this.readRecord(path, deviceId);
|
|
89027
|
+
if (record === null) return;
|
|
89028
|
+
await this.writeRecord(path, {
|
|
89029
|
+
...record,
|
|
89030
|
+
acknowledgedByHomeKit: true
|
|
89031
|
+
});
|
|
89032
|
+
}
|
|
89033
|
+
deviceDir(deviceId) {
|
|
89034
|
+
return (0, node_path.join)(this.input.rootDir, String(deviceId));
|
|
89035
|
+
}
|
|
89036
|
+
async complete(clipId, input, names, outcome) {
|
|
89037
|
+
this.inFlight.delete(clipId);
|
|
89038
|
+
const dir = this.deviceDir(input.deviceId);
|
|
89039
|
+
const tags = { deviceId: input.deviceId };
|
|
89040
|
+
const clipPath = (0, node_path.join)(dir, names.clip);
|
|
89041
|
+
if (!outcome.sawInit || outcome.bytes === 0) {
|
|
89042
|
+
await (0, node_fs_promises.rm)(clipPath, { force: true });
|
|
89043
|
+
this.log.warn("hksv clip tee: DROPPED the clip — it carries no init segment, so nothing in it is decodable", {
|
|
89044
|
+
tags,
|
|
89045
|
+
meta: {
|
|
89046
|
+
clipId,
|
|
89047
|
+
streamId: input.streamId,
|
|
89048
|
+
bytes: outcome.bytes,
|
|
89049
|
+
fragments: outcome.fragments
|
|
89050
|
+
}
|
|
89051
|
+
});
|
|
89052
|
+
return null;
|
|
89053
|
+
}
|
|
89054
|
+
const thumbnail = await this.mintAndVouch({
|
|
89055
|
+
deviceId: input.deviceId,
|
|
89056
|
+
clipPath,
|
|
89057
|
+
jpegPath: (0, node_path.join)(dir, names.thumbnail),
|
|
89058
|
+
triggerOffsetSec: input.prebufferSpanMs / 1e3
|
|
89059
|
+
});
|
|
89060
|
+
const record = {
|
|
89061
|
+
schemaVersion: 1,
|
|
89062
|
+
clipId,
|
|
89063
|
+
source: HKSV_CLIP_SOURCE,
|
|
89064
|
+
deviceId: input.deviceId,
|
|
89065
|
+
streamId: input.streamId,
|
|
89066
|
+
startedAtMs: outcome.startedAtMs,
|
|
89067
|
+
endedAtMs: outcome.endedAtMs,
|
|
89068
|
+
durationMs: Math.max(0, outcome.endedAtMs - outcome.startedAtMs),
|
|
89069
|
+
prebufferSpanMs: input.prebufferSpanMs,
|
|
89070
|
+
bytes: outcome.bytes,
|
|
89071
|
+
fragments: outcome.fragments,
|
|
89072
|
+
file: names.clip,
|
|
89073
|
+
...thumbnail.ok ? { thumbnailFile: names.thumbnail } : { thumbnailUnavailable: { reason: thumbnail.reason } },
|
|
89074
|
+
...outcome.truncated === null ? {} : { truncated: outcome.truncated },
|
|
89075
|
+
acknowledgedByHomeKit: false,
|
|
89076
|
+
width: input.width,
|
|
89077
|
+
height: input.height,
|
|
89078
|
+
fragmentMs: input.fragmentMs
|
|
89079
|
+
};
|
|
89080
|
+
await this.writeRecord((0, node_path.join)(dir, names.record), record);
|
|
89081
|
+
this.log.info("hksv clip tee: a HomeKit clip was KEPT", {
|
|
89082
|
+
tags,
|
|
89083
|
+
meta: {
|
|
89084
|
+
clipId,
|
|
89085
|
+
streamId: input.streamId,
|
|
89086
|
+
bytes: record.bytes,
|
|
89087
|
+
fragments: record.fragments,
|
|
89088
|
+
durationMs: record.durationMs,
|
|
89089
|
+
prebufferSpanMs: record.prebufferSpanMs,
|
|
89090
|
+
truncated: record.truncated ?? null,
|
|
89091
|
+
thumbnail: record.thumbnailFile ?? null,
|
|
89092
|
+
thumbnailUnavailable: record.thumbnailUnavailable?.reason ?? null
|
|
89093
|
+
}
|
|
89094
|
+
});
|
|
89095
|
+
await this.prune(input.deviceId);
|
|
89096
|
+
return record;
|
|
89097
|
+
}
|
|
89098
|
+
/**
|
|
89099
|
+
* Mint, then CHECK. The minter's own verdict decides nothing on its own: a
|
|
89100
|
+
* JPEG is vouched by `stat`, never by a return value.
|
|
89101
|
+
*/
|
|
89102
|
+
async mintAndVouch(mint) {
|
|
89103
|
+
const { deviceId, jpegPath } = mint;
|
|
89104
|
+
const minter = this.input.mintThumbnail;
|
|
89105
|
+
if (minter === void 0) return {
|
|
89106
|
+
ok: false,
|
|
89107
|
+
reason: "not-attempted"
|
|
89108
|
+
};
|
|
89109
|
+
let declared;
|
|
89110
|
+
try {
|
|
89111
|
+
declared = await minter(mint);
|
|
89112
|
+
} catch (err) {
|
|
89113
|
+
this.log.warn("hksv clip tee: the thumbnail minter threw — the clip is kept WITHOUT a still", {
|
|
89114
|
+
tags: { deviceId },
|
|
89115
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
89116
|
+
});
|
|
89117
|
+
declared = {
|
|
89118
|
+
ok: false,
|
|
89119
|
+
reason: "decode-failed"
|
|
89120
|
+
};
|
|
89121
|
+
}
|
|
89122
|
+
if (await fileSize(jpegPath) > 0) return { ok: true };
|
|
89123
|
+
await (0, node_fs_promises.rm)(jpegPath, { force: true });
|
|
89124
|
+
const reason = declared.ok ? "decode-failed" : declared.reason;
|
|
89125
|
+
this.log.warn("hksv clip tee: no thumbnail for this clip", {
|
|
89126
|
+
tags: { deviceId },
|
|
89127
|
+
meta: {
|
|
89128
|
+
reason,
|
|
89129
|
+
declaredOk: declared.ok
|
|
89130
|
+
}
|
|
89131
|
+
});
|
|
89132
|
+
return {
|
|
89133
|
+
ok: false,
|
|
89134
|
+
reason
|
|
89135
|
+
};
|
|
89136
|
+
}
|
|
89137
|
+
/**
|
|
89138
|
+
* Enforce every bound, oldest first. Enumerated from the MP4s rather than the
|
|
89139
|
+
* sidecars, so a stem whose sidecar never landed — a crash mid-write — is
|
|
89140
|
+
* seen and removed instead of occupying the disk invisibly forever.
|
|
89141
|
+
*/
|
|
89142
|
+
async prune(deviceId) {
|
|
89143
|
+
const cutoff = this.now() - this.bounds.maxAgeMs;
|
|
89144
|
+
const perDevice = await this.scanDevice(deviceId);
|
|
89145
|
+
await this.enforce(perDevice, cutoff, this.bounds.maxClipsPerDevice, this.bounds.maxBytesPerDevice);
|
|
89146
|
+
let deviceIds;
|
|
89147
|
+
try {
|
|
89148
|
+
deviceIds = (await (0, node_fs_promises.readdir)(this.input.rootDir, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => Number.parseInt(e.name, 10)).filter((id) => Number.isFinite(id));
|
|
89149
|
+
} catch {
|
|
89150
|
+
return;
|
|
89151
|
+
}
|
|
89152
|
+
const all = [];
|
|
89153
|
+
for (const id of deviceIds) all.push(...await this.scanDevice(id));
|
|
89154
|
+
await this.enforce(all, cutoff, Number.POSITIVE_INFINITY, this.bounds.maxStoreBytes);
|
|
89155
|
+
}
|
|
89156
|
+
async enforce(clips, ageCutoff, maxCount, maxBytes) {
|
|
89157
|
+
const ordered = [...clips].sort((a, b) => b.startedAtMs - a.startedAtMs);
|
|
89158
|
+
let kept = 0;
|
|
89159
|
+
let bytes = 0;
|
|
89160
|
+
for (const clip of ordered) {
|
|
89161
|
+
const tooOld = clip.startedAtMs < ageCutoff;
|
|
89162
|
+
const overCount = kept >= maxCount;
|
|
89163
|
+
const overBytes = bytes + clip.bytes > maxBytes;
|
|
89164
|
+
if (!tooOld && !overCount && !overBytes) {
|
|
89165
|
+
kept += 1;
|
|
89166
|
+
bytes += clip.bytes;
|
|
89167
|
+
continue;
|
|
89168
|
+
}
|
|
89169
|
+
await this.deleteClip(clip, tooOld ? "age" : overCount ? "count" : "bytes");
|
|
89170
|
+
}
|
|
89171
|
+
}
|
|
89172
|
+
async deleteClip(clip, reason) {
|
|
89173
|
+
for (const name of [
|
|
89174
|
+
`${clip.stem}.mp4`,
|
|
89175
|
+
`${clip.stem}.jpg`,
|
|
89176
|
+
`${clip.stem}.json`
|
|
89177
|
+
]) await (0, node_fs_promises.rm)((0, node_path.join)(clip.dir, name), { force: true });
|
|
89178
|
+
this.log.info("hksv clip store: pruned a clip", {
|
|
89179
|
+
tags: { deviceId: clip.deviceId },
|
|
89180
|
+
meta: {
|
|
89181
|
+
stem: clip.stem,
|
|
89182
|
+
bytes: clip.bytes,
|
|
89183
|
+
reason,
|
|
89184
|
+
startedAtMs: clip.startedAtMs
|
|
89185
|
+
}
|
|
89186
|
+
});
|
|
89187
|
+
}
|
|
89188
|
+
async scanDevice(deviceId) {
|
|
89189
|
+
const dir = this.deviceDir(deviceId);
|
|
89190
|
+
let names;
|
|
89191
|
+
try {
|
|
89192
|
+
names = await (0, node_fs_promises.readdir)(dir);
|
|
89193
|
+
} catch {
|
|
89194
|
+
return [];
|
|
89195
|
+
}
|
|
89196
|
+
const clips = [];
|
|
89197
|
+
for (const name of names) {
|
|
89198
|
+
if (!name.endsWith(".mp4")) continue;
|
|
89199
|
+
const stem = stemOf(name);
|
|
89200
|
+
if (this.isInFlight(deviceId, stem)) continue;
|
|
89201
|
+
const bytes = await fileSize((0, node_path.join)(dir, name));
|
|
89202
|
+
const record = await this.readRecord((0, node_path.join)(dir, `${stem}.json`), deviceId);
|
|
89203
|
+
if (record === null) {
|
|
89204
|
+
await (0, node_fs_promises.rm)((0, node_path.join)(dir, name), { force: true });
|
|
89205
|
+
this.log.warn("hksv clip store: removed an orphaned clip file (no sidecar, not being written)", {
|
|
89206
|
+
tags: { deviceId },
|
|
89207
|
+
meta: {
|
|
89208
|
+
stem,
|
|
89209
|
+
bytes
|
|
89210
|
+
}
|
|
89211
|
+
});
|
|
89212
|
+
continue;
|
|
89213
|
+
}
|
|
89214
|
+
clips.push({
|
|
89215
|
+
deviceId,
|
|
89216
|
+
dir,
|
|
89217
|
+
stem,
|
|
89218
|
+
bytes,
|
|
89219
|
+
startedAtMs: record.startedAtMs
|
|
89220
|
+
});
|
|
89221
|
+
}
|
|
89222
|
+
return clips;
|
|
89223
|
+
}
|
|
89224
|
+
isInFlight(deviceId, stem) {
|
|
89225
|
+
for (const entry of this.inFlight.values()) if (entry.deviceId === deviceId && entry.stem === stem) return true;
|
|
89226
|
+
return false;
|
|
89227
|
+
}
|
|
89228
|
+
async readRecord(path, deviceId) {
|
|
89229
|
+
let raw;
|
|
89230
|
+
try {
|
|
89231
|
+
raw = await (0, node_fs_promises.readFile)(path, "utf8");
|
|
89232
|
+
} catch {
|
|
89233
|
+
return null;
|
|
89234
|
+
}
|
|
89235
|
+
const parsed = HksvClipRecordSchema.safeParse(JSON.parse(raw));
|
|
89236
|
+
if (parsed.success) return parsed.data;
|
|
89237
|
+
this.log.warn("hksv clip store: a sidecar could not be read — the clip is not listed", {
|
|
89238
|
+
tags: { deviceId },
|
|
89239
|
+
meta: {
|
|
89240
|
+
path,
|
|
89241
|
+
error: parsed.error.message
|
|
89242
|
+
}
|
|
89243
|
+
});
|
|
89244
|
+
return null;
|
|
89245
|
+
}
|
|
89246
|
+
async writeRecord(path, record) {
|
|
89247
|
+
await (0, node_fs_promises.writeFile)(path, JSON.stringify(record, null, 2), "utf8");
|
|
89248
|
+
}
|
|
89249
|
+
};
|
|
89250
|
+
function stemOf(fileName) {
|
|
89251
|
+
return fileName.replace(/\.[^.]+$/, "");
|
|
89252
|
+
}
|
|
89253
|
+
async function fileSize(path) {
|
|
88443
89254
|
try {
|
|
88444
|
-
|
|
88445
|
-
|
|
88446
|
-
|
|
88447
|
-
|
|
88448
|
-
info.setCharacteristic(import_dist.Characteristic.Model, stringOr(metadata?.model, modelFallback));
|
|
88449
|
-
info.setCharacteristic(import_dist.Characteristic.FirmwareRevision, stringOr(metadata?.firmware, "0.0.0"));
|
|
88450
|
-
info.setCharacteristic(import_dist.Characteristic.SerialNumber, stringOr(metadata?.sn, `camstack-${proxy.deviceId}`));
|
|
88451
|
-
} catch {}
|
|
89255
|
+
return (await (0, node_fs_promises.stat)(path)).size;
|
|
89256
|
+
} catch {
|
|
89257
|
+
return 0;
|
|
89258
|
+
}
|
|
88452
89259
|
}
|
|
88453
|
-
/**
|
|
88454
|
-
*
|
|
88455
|
-
|
|
88456
|
-
|
|
88457
|
-
|
|
88458
|
-
|
|
89260
|
+
/**
|
|
89261
|
+
* A sink over a real file that HONOURS backpressure: `write()` returning false
|
|
89262
|
+
* means the kernel has not taken the bytes, and ignoring it is how a stream
|
|
89263
|
+
* grows a queue nobody bounded (D447).
|
|
89264
|
+
*/
|
|
89265
|
+
async function createFileSink(path) {
|
|
89266
|
+
const stream = (0, node_fs.createWriteStream)(path);
|
|
89267
|
+
await (0, node_events.once)(stream, "open");
|
|
88459
89268
|
return {
|
|
88460
|
-
|
|
88461
|
-
|
|
88462
|
-
|
|
88463
|
-
|
|
89269
|
+
async write(chunk) {
|
|
89270
|
+
if (!stream.write(chunk)) await (0, node_events.once)(stream, "drain");
|
|
89271
|
+
},
|
|
89272
|
+
async close() {
|
|
89273
|
+
await new Promise((resolve, reject) => {
|
|
89274
|
+
stream.end((err) => err ? reject(err) : resolve());
|
|
89275
|
+
});
|
|
89276
|
+
}
|
|
88464
89277
|
};
|
|
88465
89278
|
}
|
|
88466
|
-
function
|
|
88467
|
-
return
|
|
89279
|
+
function buildClipThumbnailArgs(input) {
|
|
89280
|
+
return [
|
|
89281
|
+
"-hide_banner",
|
|
89282
|
+
"-loglevel",
|
|
89283
|
+
"error",
|
|
89284
|
+
"-nostdin",
|
|
89285
|
+
"-y",
|
|
89286
|
+
"-ss",
|
|
89287
|
+
String(Math.max(0, Math.floor(input.seekSec))),
|
|
89288
|
+
"-i",
|
|
89289
|
+
input.clipPath,
|
|
89290
|
+
"-frames:v",
|
|
89291
|
+
"1",
|
|
89292
|
+
"-vf",
|
|
89293
|
+
`scale='min(${input.maxWidth},iw)':-2`,
|
|
89294
|
+
"-f",
|
|
89295
|
+
"image2",
|
|
89296
|
+
"-vcodec",
|
|
89297
|
+
"mjpeg",
|
|
89298
|
+
input.jpegPath
|
|
89299
|
+
];
|
|
88468
89300
|
}
|
|
88469
|
-
function
|
|
88470
|
-
return
|
|
89301
|
+
function createClipThumbnailMinter(input) {
|
|
89302
|
+
return async ({ clipPath, jpegPath, triggerOffsetSec }) => {
|
|
89303
|
+
const args = buildClipThumbnailArgs({
|
|
89304
|
+
clipPath,
|
|
89305
|
+
jpegPath,
|
|
89306
|
+
seekSec: triggerOffsetSec,
|
|
89307
|
+
maxWidth: input.maxWidth ?? 640
|
|
89308
|
+
});
|
|
89309
|
+
let run;
|
|
89310
|
+
try {
|
|
89311
|
+
run = await input.runner(args);
|
|
89312
|
+
} catch {
|
|
89313
|
+
return {
|
|
89314
|
+
ok: false,
|
|
89315
|
+
reason: "decode-failed"
|
|
89316
|
+
};
|
|
89317
|
+
}
|
|
89318
|
+
if (run.spawnFailed) return {
|
|
89319
|
+
ok: false,
|
|
89320
|
+
reason: "ffmpeg-missing"
|
|
89321
|
+
};
|
|
89322
|
+
if (run.timedOut) return {
|
|
89323
|
+
ok: false,
|
|
89324
|
+
reason: "timed-out"
|
|
89325
|
+
};
|
|
89326
|
+
if (run.code !== 0) return {
|
|
89327
|
+
ok: false,
|
|
89328
|
+
reason: "decode-failed"
|
|
89329
|
+
};
|
|
89330
|
+
return { ok: true };
|
|
89331
|
+
};
|
|
89332
|
+
}
|
|
89333
|
+
/**
|
|
89334
|
+
* The production runner: one bounded ffmpeg, killed at the deadline.
|
|
89335
|
+
*
|
|
89336
|
+
* Bounded and killed, not merely awaited — an ffmpeg that never exits on a
|
|
89337
|
+
* clip it cannot parse would otherwise hold a process per clip on a hub that
|
|
89338
|
+
* already runs a permanent prebuffer per recorded camera.
|
|
89339
|
+
*/
|
|
89340
|
+
function createFfmpegClipThumbnailRunner(input) {
|
|
89341
|
+
const timeoutMs = input.timeoutMs ?? 1e4;
|
|
89342
|
+
return (args) => new Promise((resolve) => {
|
|
89343
|
+
let settled = false;
|
|
89344
|
+
const finish = (run) => {
|
|
89345
|
+
if (settled) return;
|
|
89346
|
+
settled = true;
|
|
89347
|
+
clearTimeout(timer);
|
|
89348
|
+
resolve(run);
|
|
89349
|
+
};
|
|
89350
|
+
const child = input.spawnFn(input.ffmpegBinaryPath, [...args], { stdio: [
|
|
89351
|
+
"ignore",
|
|
89352
|
+
"ignore",
|
|
89353
|
+
"pipe"
|
|
89354
|
+
] });
|
|
89355
|
+
let stderr = "";
|
|
89356
|
+
child.stderr?.on("data", (chunk) => {
|
|
89357
|
+
if (stderr.length < 2e3) stderr += chunk.toString("utf8");
|
|
89358
|
+
});
|
|
89359
|
+
const timer = setTimeout(() => {
|
|
89360
|
+
child.kill("SIGKILL");
|
|
89361
|
+
finish({
|
|
89362
|
+
code: null,
|
|
89363
|
+
timedOut: true,
|
|
89364
|
+
spawnFailed: false,
|
|
89365
|
+
stderr
|
|
89366
|
+
});
|
|
89367
|
+
}, timeoutMs);
|
|
89368
|
+
timer.unref?.();
|
|
89369
|
+
child.on("error", (err) => {
|
|
89370
|
+
finish({
|
|
89371
|
+
code: null,
|
|
89372
|
+
timedOut: false,
|
|
89373
|
+
spawnFailed: true,
|
|
89374
|
+
stderr: err.message
|
|
89375
|
+
});
|
|
89376
|
+
});
|
|
89377
|
+
child.on("close", (code) => {
|
|
89378
|
+
finish({
|
|
89379
|
+
code,
|
|
89380
|
+
timedOut: false,
|
|
89381
|
+
spawnFailed: false,
|
|
89382
|
+
stderr
|
|
89383
|
+
});
|
|
89384
|
+
});
|
|
89385
|
+
});
|
|
88471
89386
|
}
|
|
88472
89387
|
//#endregion
|
|
88473
89388
|
//#region src/mappers/builders/generic/characteristic-update.ts
|
|
@@ -88797,6 +89712,910 @@ function errMsg$14(err) {
|
|
|
88797
89712
|
return err instanceof Error ? err.message : String(err);
|
|
88798
89713
|
}
|
|
88799
89714
|
//#endregion
|
|
89715
|
+
//#region src/mappers/builders/child-switch.ts
|
|
89716
|
+
/**
|
|
89717
|
+
* Child-switch builder — turns a camstack accessory child device (siren,
|
|
89718
|
+
* floodlight, spotlight, …) into a HomeKit Service ON THE PARENT
|
|
89719
|
+
* camera Accessory.
|
|
89720
|
+
*
|
|
89721
|
+
* The orchestrator (`child-accessory.ts → buildChildServicesFor`)
|
|
89722
|
+
* hands this builder the PARENT accessory + the CHILD's proxy + a
|
|
89723
|
+
* unique subtype so each service round-trips deterministically through
|
|
89724
|
+
* HAP serialisation. The builder:
|
|
89725
|
+
* - Maps `switch` cap → `Service.Switch`.
|
|
89726
|
+
* - Maps `switch` + `brightness` cap → `Service.Lightbulb` with
|
|
89727
|
+
* Brightness characteristic (lighting devices only — see below).
|
|
89728
|
+
* - Hydrates initial state from `getStatus({})`.
|
|
89729
|
+
* - Wires `Characteristic.On` / `Characteristic.Brightness` through
|
|
89730
|
+
* the typed DeviceProxy.
|
|
89731
|
+
* - Subscribes to runtime-state slice updates so iOS Home reflects
|
|
89732
|
+
* external state changes (e.g. another integration toggling the
|
|
89733
|
+
* light).
|
|
89734
|
+
*
|
|
89735
|
+
* Service-kind selection: most cameras expose `brightness` on a siren
|
|
89736
|
+
* because it controls the alarm VOLUME, not luminosity. Mapping a
|
|
89737
|
+
* siren onto `Service.Lightbulb` makes it appear as a regular light in
|
|
89738
|
+
* iOS Home (wrong icon, wrong scenes, wrong Siri intents). We therefore
|
|
89739
|
+
* gate `Service.Lightbulb` on the child's `DeviceType` — only Light /
|
|
89740
|
+
* Generic devices keep the Lightbulb shape; Siren, Switch, and others
|
|
89741
|
+
* fall back to `Service.Switch`. Volume control for sirens is exposed
|
|
89742
|
+
* through the camstack admin UI; iOS Home only needs the on/off toggle.
|
|
89743
|
+
*
|
|
89744
|
+
* Child devices without `switch` / `brightness` cap (status sensors,
|
|
89745
|
+
* IR LED, etc.) return a no-op handle.
|
|
89746
|
+
*/
|
|
89747
|
+
async function buildChildSwitch(bctx, subtype, deviceType) {
|
|
89748
|
+
const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
|
|
89749
|
+
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
89750
|
+
const hasSwitch = !!proxy.switch;
|
|
89751
|
+
const hasBrightness = !!proxy.brightness;
|
|
89752
|
+
if (!hasSwitch && !hasBrightness) {
|
|
89753
|
+
log.debug("export-hap: child has no switch/brightness cap — skipping");
|
|
89754
|
+
return { async dispose() {} };
|
|
89755
|
+
}
|
|
89756
|
+
const isLightingDevice = deviceType === DeviceType.Light || deviceType === DeviceType.Generic;
|
|
89757
|
+
const useLightbulb = hasBrightness && isLightingDevice;
|
|
89758
|
+
const service = useLightbulb ? accessory.addService(import_dist.Service.Lightbulb, displayName, subtype) : accessory.addService(import_dist.Service.Switch, displayName, subtype);
|
|
89759
|
+
applyServiceLabel(service, displayName);
|
|
89760
|
+
try {
|
|
89761
|
+
const switchStatus = await proxy.switch?.getStatus({});
|
|
89762
|
+
if (switchStatus && typeof switchStatus.on === "boolean") service.updateCharacteristic(import_dist.Characteristic.On, switchStatus.on);
|
|
89763
|
+
} catch (err) {
|
|
89764
|
+
log.debug("export-hap: child switch.getStatus failed (non-fatal)", { meta: { error: errMsg$13(err) } });
|
|
89765
|
+
}
|
|
89766
|
+
if (useLightbulb) try {
|
|
89767
|
+
const status = await proxy.brightness?.getStatus({});
|
|
89768
|
+
if (status && typeof status.percentage === "number") service.updateCharacteristic(import_dist.Characteristic.Brightness, status.percentage);
|
|
89769
|
+
} catch (err) {
|
|
89770
|
+
log.debug("export-hap: child brightness.getStatus failed (non-fatal)", { meta: { error: errMsg$13(err) } });
|
|
89771
|
+
}
|
|
89772
|
+
if (hasSwitch) service.getCharacteristic(import_dist.Characteristic.On).onSet(async (value) => {
|
|
89773
|
+
const on = value === true;
|
|
89774
|
+
try {
|
|
89775
|
+
await proxy.switch?.setState({ on });
|
|
89776
|
+
} catch (err) {
|
|
89777
|
+
log.warn("export-hap: child switch.setState failed", { meta: {
|
|
89778
|
+
on,
|
|
89779
|
+
error: errMsg$13(err)
|
|
89780
|
+
} });
|
|
89781
|
+
}
|
|
89782
|
+
});
|
|
89783
|
+
if (useLightbulb) service.getCharacteristic(import_dist.Characteristic.Brightness).onSet(async (value) => {
|
|
89784
|
+
const percentage = typeof value === "number" ? value : Number(value);
|
|
89785
|
+
if (!Number.isFinite(percentage)) return;
|
|
89786
|
+
try {
|
|
89787
|
+
await proxy.brightness?.setBrightness({ percentage });
|
|
89788
|
+
} catch (err) {
|
|
89789
|
+
log.warn("export-hap: child brightness.setBrightness failed", { meta: {
|
|
89790
|
+
percentage,
|
|
89791
|
+
error: errMsg$13(err)
|
|
89792
|
+
} });
|
|
89793
|
+
}
|
|
89794
|
+
});
|
|
89795
|
+
const unsubscribes = [];
|
|
89796
|
+
if (proxy.state.switch) {
|
|
89797
|
+
const unsubSwitch = proxy.state.switch.subscribe((value) => {
|
|
89798
|
+
if (value && typeof value.on === "boolean") service.updateCharacteristic(import_dist.Characteristic.On, value.on);
|
|
89799
|
+
});
|
|
89800
|
+
unsubscribes.push(unsubSwitch);
|
|
89801
|
+
}
|
|
89802
|
+
if (useLightbulb && proxy.state.brightness) {
|
|
89803
|
+
const unsubBrightness = proxy.state.brightness.subscribe((value) => {
|
|
89804
|
+
if (value && typeof value.percentage === "number") service.updateCharacteristic(import_dist.Characteristic.Brightness, value.percentage);
|
|
89805
|
+
});
|
|
89806
|
+
unsubscribes.push(unsubBrightness);
|
|
89807
|
+
}
|
|
89808
|
+
return { async dispose() {
|
|
89809
|
+
for (const u of unsubscribes) try {
|
|
89810
|
+
u();
|
|
89811
|
+
} catch {}
|
|
89812
|
+
} };
|
|
89813
|
+
}
|
|
89814
|
+
function errMsg$13(err) {
|
|
89815
|
+
return err instanceof Error ? err.message : String(err);
|
|
89816
|
+
}
|
|
89817
|
+
//#endregion
|
|
89818
|
+
//#region src/mappers/builders/generic/lock.ts
|
|
89819
|
+
/**
|
|
89820
|
+
* `lock-control` → `Service.LockMechanism`.
|
|
89821
|
+
*
|
|
89822
|
+
* Not a sensor row: HomeKit models a lock as two characteristics, a CURRENT
|
|
89823
|
+
* state the accessory owns and a TARGET state the controller writes, and the
|
|
89824
|
+
* cap's five states do not map onto either one alone.
|
|
89825
|
+
*
|
|
89826
|
+
* ```
|
|
89827
|
+
* cap state LockCurrentState LockTargetState
|
|
89828
|
+
* locked SECURED SECURED
|
|
89829
|
+
* unlocked UNSECURED UNSECURED
|
|
89830
|
+
* locking UNKNOWN SECURED (in flight — do not claim SECURED)
|
|
89831
|
+
* unlocking UNKNOWN UNSECURED
|
|
89832
|
+
* jammed JAMMED unchanged (the controller's intent stands)
|
|
89833
|
+
* ```
|
|
89834
|
+
*
|
|
89835
|
+
* Reporting SECURED while the bolt is still moving is the failure worth naming:
|
|
89836
|
+
* iOS renders the lock as closed, the operator walks away, and the motor stalls
|
|
89837
|
+
* behind them. `UNKNOWN` is the honest answer for an in-flight transition.
|
|
89838
|
+
*
|
|
89839
|
+
* `open` (the cap's third method, for locks with a latch/buzzer) has no HomeKit
|
|
89840
|
+
* counterpart on this service and is deliberately not wired — a Switch that
|
|
89841
|
+
* silently buzzes a door open would be a second knob nobody asked for.
|
|
89842
|
+
*/
|
|
89843
|
+
var SUBTYPE$1 = "lock-control";
|
|
89844
|
+
var readLock = reader(LockControlStatusSchema.pick({ state: true }), (status) => {
|
|
89845
|
+
const current = status.state === "locked" ? import_dist.Characteristic.LockCurrentState.SECURED : status.state === "unlocked" ? import_dist.Characteristic.LockCurrentState.UNSECURED : status.state === "jammed" ? import_dist.Characteristic.LockCurrentState.JAMMED : import_dist.Characteristic.LockCurrentState.UNKNOWN;
|
|
89846
|
+
const target = status.state === "locked" || status.state === "locking" ? import_dist.Characteristic.LockTargetState.SECURED : status.state === "unlocked" || status.state === "unlocking" ? import_dist.Characteristic.LockTargetState.UNSECURED : null;
|
|
89847
|
+
return [{
|
|
89848
|
+
characteristic: import_dist.Characteristic.LockCurrentState,
|
|
89849
|
+
value: current
|
|
89850
|
+
}, ...target === null ? [] : [{
|
|
89851
|
+
characteristic: import_dist.Characteristic.LockTargetState,
|
|
89852
|
+
value: target
|
|
89853
|
+
}]];
|
|
89854
|
+
});
|
|
89855
|
+
async function buildLockMechanism(bctx) {
|
|
89856
|
+
const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
|
|
89857
|
+
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
89858
|
+
const label = hapServiceName([displayName], `Lock ${numericDeviceId}`);
|
|
89859
|
+
const service = accessory.addService(import_dist.Service.LockMechanism, label, SUBTYPE$1);
|
|
89860
|
+
applyServiceLabel(service, label);
|
|
89861
|
+
const apply = (status, source) => {
|
|
89862
|
+
const updates = readLock(status);
|
|
89863
|
+
if (updates.length === 0) {
|
|
89864
|
+
log.debug("export-hap: lock status did not match the cap schema — no update", { meta: { source } });
|
|
89865
|
+
return;
|
|
89866
|
+
}
|
|
89867
|
+
applyUpdates(service, updates);
|
|
89868
|
+
};
|
|
89869
|
+
try {
|
|
89870
|
+
const status = await proxy.lockControl?.getStatus({});
|
|
89871
|
+
if (status !== void 0 && status !== null) apply(status, "getStatus");
|
|
89872
|
+
} catch (err) {
|
|
89873
|
+
log.debug("export-hap: lock getStatus hydrate failed (non-fatal)", { meta: { error: errMsg$12(err) } });
|
|
89874
|
+
}
|
|
89875
|
+
service.getCharacteristic(import_dist.Characteristic.LockTargetState).onSet(async (value) => {
|
|
89876
|
+
const secure = value === import_dist.Characteristic.LockTargetState.SECURED;
|
|
89877
|
+
try {
|
|
89878
|
+
if (secure) await proxy.lockControl?.lock({});
|
|
89879
|
+
else await proxy.lockControl?.unlock({});
|
|
89880
|
+
} catch (err) {
|
|
89881
|
+
log.warn("export-hap: lock command failed", { meta: {
|
|
89882
|
+
secure,
|
|
89883
|
+
error: errMsg$12(err)
|
|
89884
|
+
} });
|
|
89885
|
+
}
|
|
89886
|
+
});
|
|
89887
|
+
const unsubscribe = proxy.state.lockControl?.subscribe((value) => {
|
|
89888
|
+
if (value === void 0 || value === null) return;
|
|
89889
|
+
apply(value, "slice");
|
|
89890
|
+
}) ?? null;
|
|
89891
|
+
return { async dispose() {
|
|
89892
|
+
try {
|
|
89893
|
+
unsubscribe?.();
|
|
89894
|
+
} catch {}
|
|
89895
|
+
} };
|
|
89896
|
+
}
|
|
89897
|
+
function errMsg$12(err) {
|
|
89898
|
+
return err instanceof Error ? err.message : String(err);
|
|
89899
|
+
}
|
|
89900
|
+
//#endregion
|
|
89901
|
+
//#region src/mappers/builders/generic/security-state.ts
|
|
89902
|
+
/** Is this a settled arm state worth remembering? */
|
|
89903
|
+
function armedMemoOf(state) {
|
|
89904
|
+
return state === "armed_home" || state === "armed_away" || state === "armed_night" || state === "armed_vacation" || state === "armed_custom_bypass" ? state : null;
|
|
89905
|
+
}
|
|
89906
|
+
function currentForArmed(state) {
|
|
89907
|
+
switch (state) {
|
|
89908
|
+
case "armed_home":
|
|
89909
|
+
case "armed_custom_bypass": return import_dist.Characteristic.SecuritySystemCurrentState.STAY_ARM;
|
|
89910
|
+
case "armed_night": return import_dist.Characteristic.SecuritySystemCurrentState.NIGHT_ARM;
|
|
89911
|
+
default: return import_dist.Characteristic.SecuritySystemCurrentState.AWAY_ARM;
|
|
89912
|
+
}
|
|
89913
|
+
}
|
|
89914
|
+
function targetForArmed(state) {
|
|
89915
|
+
switch (state) {
|
|
89916
|
+
case "armed_home":
|
|
89917
|
+
case "armed_custom_bypass": return import_dist.Characteristic.SecuritySystemTargetState.STAY_ARM;
|
|
89918
|
+
case "armed_night": return import_dist.Characteristic.SecuritySystemTargetState.NIGHT_ARM;
|
|
89919
|
+
default: return import_dist.Characteristic.SecuritySystemTargetState.AWAY_ARM;
|
|
89920
|
+
}
|
|
89921
|
+
}
|
|
89922
|
+
function securityStateFor(state, memo) {
|
|
89923
|
+
const settled = armedMemoOf(state);
|
|
89924
|
+
if (settled !== null) return {
|
|
89925
|
+
current: currentForArmed(settled),
|
|
89926
|
+
target: targetForArmed(settled)
|
|
89927
|
+
};
|
|
89928
|
+
switch (state) {
|
|
89929
|
+
case "disarmed": return {
|
|
89930
|
+
current: import_dist.Characteristic.SecuritySystemCurrentState.DISARMED,
|
|
89931
|
+
target: import_dist.Characteristic.SecuritySystemTargetState.DISARM
|
|
89932
|
+
};
|
|
89933
|
+
case "arming": return {
|
|
89934
|
+
current: import_dist.Characteristic.SecuritySystemCurrentState.DISARMED,
|
|
89935
|
+
target: memo === null ? null : targetForArmed(memo)
|
|
89936
|
+
};
|
|
89937
|
+
case "disarming": return {
|
|
89938
|
+
current: memo === null ? import_dist.Characteristic.SecuritySystemCurrentState.DISARMED : currentForArmed(memo),
|
|
89939
|
+
target: import_dist.Characteristic.SecuritySystemTargetState.DISARM
|
|
89940
|
+
};
|
|
89941
|
+
case "pending": return {
|
|
89942
|
+
current: memo === null ? import_dist.Characteristic.SecuritySystemCurrentState.DISARMED : currentForArmed(memo),
|
|
89943
|
+
target: memo === null ? null : targetForArmed(memo)
|
|
89944
|
+
};
|
|
89945
|
+
default: return {
|
|
89946
|
+
current: import_dist.Characteristic.SecuritySystemCurrentState.ALARM_TRIGGERED,
|
|
89947
|
+
target: memo === null ? null : targetForArmed(memo)
|
|
89948
|
+
};
|
|
89949
|
+
}
|
|
89950
|
+
}
|
|
89951
|
+
/**
|
|
89952
|
+
* The arm mode behind a HomeKit target, or `null` for DISARM and for anything
|
|
89953
|
+
* outside the four values HomeKit defines.
|
|
89954
|
+
*
|
|
89955
|
+
* `null` is not an error the caller can ignore: DISARM is a different COMMAND,
|
|
89956
|
+
* and a value HomeKit never sends is a write this builder refuses to translate.
|
|
89957
|
+
*/
|
|
89958
|
+
function alarmArmModeFor(target) {
|
|
89959
|
+
switch (target) {
|
|
89960
|
+
case import_dist.Characteristic.SecuritySystemTargetState.STAY_ARM: return "home";
|
|
89961
|
+
case import_dist.Characteristic.SecuritySystemTargetState.AWAY_ARM: return "away";
|
|
89962
|
+
case import_dist.Characteristic.SecuritySystemTargetState.NIGHT_ARM: return "night";
|
|
89963
|
+
default: return null;
|
|
89964
|
+
}
|
|
89965
|
+
}
|
|
89966
|
+
//#endregion
|
|
89967
|
+
//#region src/mappers/builders/generic/security-target.ts
|
|
89968
|
+
/**
|
|
89969
|
+
* The panel modes behind each HomeKit target, PRIMARY first.
|
|
89970
|
+
*
|
|
89971
|
+
* The primary is the mode `alarmArmModeFor` names; the secondary is the other
|
|
89972
|
+
* mode `security-state.ts` reports through the same HomeKit value, so the two
|
|
89973
|
+
* modules agree in both directions by construction.
|
|
89974
|
+
*/
|
|
89975
|
+
var MODES_BY_TARGET = new Map([
|
|
89976
|
+
[import_dist.Characteristic.SecuritySystemTargetState.STAY_ARM, ["home", "custom_bypass"]],
|
|
89977
|
+
[import_dist.Characteristic.SecuritySystemTargetState.AWAY_ARM, ["away", "vacation"]],
|
|
89978
|
+
[import_dist.Characteristic.SecuritySystemTargetState.NIGHT_ARM, ["night"]]
|
|
89979
|
+
]);
|
|
89980
|
+
/** Arm targets in the order HomeKit numbers them; DISARM is appended last. */
|
|
89981
|
+
var ARM_TARGETS = [
|
|
89982
|
+
import_dist.Characteristic.SecuritySystemTargetState.STAY_ARM,
|
|
89983
|
+
import_dist.Characteristic.SecuritySystemTargetState.AWAY_ARM,
|
|
89984
|
+
import_dist.Characteristic.SecuritySystemTargetState.NIGHT_ARM
|
|
89985
|
+
];
|
|
89986
|
+
/**
|
|
89987
|
+
* The `validValues` for `SecuritySystemTargetState`, or `null` to leave
|
|
89988
|
+
* HomeKit's own four values in place.
|
|
89989
|
+
*
|
|
89990
|
+
* DISARM is always present: disarming is not one of the panel's arm modes and
|
|
89991
|
+
* a panel that cannot be disarmed is not a thing this cap can express.
|
|
89992
|
+
*/
|
|
89993
|
+
function targetValidValuesFor(availableModes) {
|
|
89994
|
+
if (availableModes.length === 0) return null;
|
|
89995
|
+
return [...ARM_TARGETS.filter((target) => (MODES_BY_TARGET.get(target) ?? []).some((mode) => availableModes.includes(mode))), import_dist.Characteristic.SecuritySystemTargetState.DISARM];
|
|
89996
|
+
}
|
|
89997
|
+
/**
|
|
89998
|
+
* The arm mode to send for a HomeKit target, or `null` when there is none.
|
|
89999
|
+
*
|
|
90000
|
+
* `null` is not a shrug the caller may swallow: DISARM is a different command,
|
|
90001
|
+
* and an arm target the panel accepts no mode for is a write this module
|
|
90002
|
+
* refuses to translate rather than guess at.
|
|
90003
|
+
*/
|
|
90004
|
+
function armModeForTarget(target, availableModes) {
|
|
90005
|
+
const primary = alarmArmModeFor(target);
|
|
90006
|
+
if (primary === null) return null;
|
|
90007
|
+
if (availableModes.length === 0) return primary;
|
|
90008
|
+
return (MODES_BY_TARGET.get(target) ?? [primary]).find((mode) => availableModes.includes(mode)) ?? null;
|
|
90009
|
+
}
|
|
90010
|
+
//#endregion
|
|
90011
|
+
//#region src/mappers/builders/generic/security-system.ts
|
|
90012
|
+
/**
|
|
90013
|
+
* `alarm-panel` → `Service.SecuritySystem`.
|
|
90014
|
+
*
|
|
90015
|
+
* The lock builder's shape (a current/target pair, a hydrate, an `onSet`, a
|
|
90016
|
+
* slice subscription, a dispose) applied to the one device on the fleet where
|
|
90017
|
+
* reporting a state that is not true has a consequence worse than a stalled
|
|
90018
|
+
* motor. The mapping itself is NOT here: `security-state.ts` owns what the
|
|
90019
|
+
* panel's state looks like to HomeKit and `security-target.ts` owns what
|
|
90020
|
+
* HomeKit may ask for, both pure and both tested on their own. This file is
|
|
90021
|
+
* the service around them.
|
|
90022
|
+
*
|
|
90023
|
+
* ## The memo
|
|
90024
|
+
*
|
|
90025
|
+
* `pending`, `disarming` and `triggered` do not carry the mode the panel was
|
|
90026
|
+
* armed to — only the last settled state knows it. This builder is the caller
|
|
90027
|
+
* `security-state.ts` describes: it remembers the last settled arm and forgets
|
|
90028
|
+
* it on `disarmed`, because a memo that outlives a disarm makes the next
|
|
90029
|
+
* `arming` claim a target nobody asked for.
|
|
90030
|
+
*
|
|
90031
|
+
* The memo is deliberately NOT seeded from the controller's own write. HomeKit
|
|
90032
|
+
* already holds the target the operator just set; not writing it back is
|
|
90033
|
+
* exactly right. The cost is a panel armed from its own keypad during an exit
|
|
90034
|
+
* delay, where the target stays at whatever HomeKit last knew — a stale target
|
|
90035
|
+
* for the length of the delay, against inventing an intent, and the invention
|
|
90036
|
+
* is worse.
|
|
90037
|
+
*
|
|
90038
|
+
* ## `requiresCode`: the arm is REFUSED, not attempted
|
|
90039
|
+
*
|
|
90040
|
+
* HomeKit's SecuritySystem has no PIN prompt — there is no characteristic for
|
|
90041
|
+
* it and no way to ask the operator mid-write. So a panel that requires a code
|
|
90042
|
+
* cannot be armed or disarmed from the Home app, and this builder says so
|
|
90043
|
+
* twice: the write fails with `INSUFFICIENT_AUTHORIZATION` (iOS renders the
|
|
90044
|
+
* tile's failure) and a WARN names the device, the command and the mode.
|
|
90045
|
+
*
|
|
90046
|
+
* Sending the command code-less and letting the panel reject it was the
|
|
90047
|
+
* alternative. It is worse: the refusal costs a round trip, it is
|
|
90048
|
+
* indistinguishable from a broken panel in the log, and repeated code-less
|
|
90049
|
+
* attempts are what locks a real alarm panel out.
|
|
90050
|
+
*/
|
|
90051
|
+
var SUBTYPE = "alarm-panel";
|
|
90052
|
+
/** The subset of the status this builder reads. */
|
|
90053
|
+
var PanelReadSchema = AlarmPanelStatusSchema.pick({
|
|
90054
|
+
state: true,
|
|
90055
|
+
availableModes: true,
|
|
90056
|
+
requiresCode: true
|
|
90057
|
+
});
|
|
90058
|
+
/**
|
|
90059
|
+
* Turn a panel status into characteristic writes, folding the memo.
|
|
90060
|
+
*
|
|
90061
|
+
* A payload that does not match the cap's own schema yields NO updates and
|
|
90062
|
+
* leaves the memo alone — it is a status we could not read, not a panel that
|
|
90063
|
+
* changed. The caller logs the drop; a frozen characteristic is otherwise
|
|
90064
|
+
* indistinguishable from a quiet panel.
|
|
90065
|
+
*/
|
|
90066
|
+
function securityUpdatesFor(status, memo) {
|
|
90067
|
+
const parsed = PanelReadSchema.safeParse(status);
|
|
90068
|
+
if (!parsed.success) return {
|
|
90069
|
+
updates: [],
|
|
90070
|
+
memo,
|
|
90071
|
+
panel: null
|
|
90072
|
+
};
|
|
90073
|
+
const nextMemo = parsed.data.state === "disarmed" ? null : armedMemoOf(parsed.data.state) ?? memo;
|
|
90074
|
+
const { current, target } = securityStateFor(parsed.data.state, nextMemo);
|
|
90075
|
+
return {
|
|
90076
|
+
updates: [{
|
|
90077
|
+
characteristic: import_dist.Characteristic.SecuritySystemCurrentState,
|
|
90078
|
+
value: current
|
|
90079
|
+
}, ...target === null ? [] : [{
|
|
90080
|
+
characteristic: import_dist.Characteristic.SecuritySystemTargetState,
|
|
90081
|
+
value: target
|
|
90082
|
+
}]],
|
|
90083
|
+
memo: nextMemo,
|
|
90084
|
+
panel: {
|
|
90085
|
+
availableModes: parsed.data.availableModes,
|
|
90086
|
+
requiresCode: parsed.data.requiresCode
|
|
90087
|
+
}
|
|
90088
|
+
};
|
|
90089
|
+
}
|
|
90090
|
+
async function buildSecuritySystem(bctx) {
|
|
90091
|
+
const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
|
|
90092
|
+
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
90093
|
+
const label = hapServiceName([displayName], `Alarm ${numericDeviceId}`);
|
|
90094
|
+
const service = accessory.addService(import_dist.Service.SecuritySystem, label, SUBTYPE);
|
|
90095
|
+
applyServiceLabel(service, label);
|
|
90096
|
+
let memo = null;
|
|
90097
|
+
let availableModes = [];
|
|
90098
|
+
let requiresCode = false;
|
|
90099
|
+
/** Only log the `validValues` narrowing when it actually changes. */
|
|
90100
|
+
let lastValidValues = "";
|
|
90101
|
+
const applyValidValues = () => {
|
|
90102
|
+
const validValues = targetValidValuesFor(availableModes);
|
|
90103
|
+
if (validValues === null) {
|
|
90104
|
+
if (lastValidValues !== "unconstrained") {
|
|
90105
|
+
lastValidValues = "unconstrained";
|
|
90106
|
+
log.info("export-hap: alarm panel named no arm modes — leaving HomeKit’s targets unconstrained", { meta: { subtype: SUBTYPE } });
|
|
90107
|
+
}
|
|
90108
|
+
return;
|
|
90109
|
+
}
|
|
90110
|
+
const key = validValues.join(",");
|
|
90111
|
+
if (key === lastValidValues) return;
|
|
90112
|
+
lastValidValues = key;
|
|
90113
|
+
service.getCharacteristic(import_dist.Characteristic.SecuritySystemTargetState).setProps({ validValues: [...validValues] });
|
|
90114
|
+
log.info("export-hap: alarm panel target constrained to the modes it accepts", { meta: {
|
|
90115
|
+
subtype: SUBTYPE,
|
|
90116
|
+
availableModes: [...availableModes],
|
|
90117
|
+
validValues
|
|
90118
|
+
} });
|
|
90119
|
+
};
|
|
90120
|
+
const apply = (status, source) => {
|
|
90121
|
+
const next = securityUpdatesFor(status, memo);
|
|
90122
|
+
if (next.updates.length === 0) {
|
|
90123
|
+
log.debug("export-hap: alarm panel status did not match the cap schema — no update", { meta: {
|
|
90124
|
+
subtype: SUBTYPE,
|
|
90125
|
+
source
|
|
90126
|
+
} });
|
|
90127
|
+
return;
|
|
90128
|
+
}
|
|
90129
|
+
memo = next.memo;
|
|
90130
|
+
if (next.panel !== null) {
|
|
90131
|
+
availableModes = next.panel.availableModes;
|
|
90132
|
+
requiresCode = next.panel.requiresCode;
|
|
90133
|
+
applyValidValues();
|
|
90134
|
+
}
|
|
90135
|
+
applyUpdates(service, next.updates);
|
|
90136
|
+
};
|
|
90137
|
+
try {
|
|
90138
|
+
const status = await proxy.alarmPanel?.getStatus({});
|
|
90139
|
+
if (status !== void 0 && status !== null) apply(status, "getStatus");
|
|
90140
|
+
} catch (err) {
|
|
90141
|
+
log.debug("export-hap: alarm panel getStatus hydrate failed (non-fatal)", { meta: { error: errMsg$11(err) } });
|
|
90142
|
+
}
|
|
90143
|
+
service.getCharacteristic(import_dist.Characteristic.SecuritySystemTargetState).onSet(async (value) => {
|
|
90144
|
+
const target = typeof value === "number" ? value : Number(value);
|
|
90145
|
+
const disarm = target === import_dist.Characteristic.SecuritySystemTargetState.DISARM;
|
|
90146
|
+
const mode = disarm ? null : armModeForTarget(target, availableModes);
|
|
90147
|
+
if (requiresCode) {
|
|
90148
|
+
log.warn("export-hap: alarm panel requires a PIN — HomeKit cannot supply one, command REFUSED", { meta: {
|
|
90149
|
+
subtype: SUBTYPE,
|
|
90150
|
+
command: disarm ? "disarm" : "arm",
|
|
90151
|
+
mode,
|
|
90152
|
+
target
|
|
90153
|
+
} });
|
|
90154
|
+
throw new import_dist.HapStatusError(import_dist.HAPStatus.INSUFFICIENT_AUTHORIZATION);
|
|
90155
|
+
}
|
|
90156
|
+
if (!disarm && mode === null) {
|
|
90157
|
+
log.warn("export-hap: alarm panel cannot honour this HomeKit arm target — REFUSED", { meta: {
|
|
90158
|
+
subtype: SUBTYPE,
|
|
90159
|
+
target,
|
|
90160
|
+
availableModes: [...availableModes]
|
|
90161
|
+
} });
|
|
90162
|
+
throw new import_dist.HapStatusError(import_dist.HAPStatus.INVALID_VALUE_IN_REQUEST);
|
|
90163
|
+
}
|
|
90164
|
+
try {
|
|
90165
|
+
if (mode === null) await proxy.alarmPanel?.disarm({});
|
|
90166
|
+
else await proxy.alarmPanel?.arm({ mode });
|
|
90167
|
+
} catch (err) {
|
|
90168
|
+
log.warn("export-hap: alarm panel command failed", { meta: {
|
|
90169
|
+
subtype: SUBTYPE,
|
|
90170
|
+
command: mode === null ? "disarm" : "arm",
|
|
90171
|
+
mode,
|
|
90172
|
+
error: errMsg$11(err)
|
|
90173
|
+
} });
|
|
90174
|
+
throw new import_dist.HapStatusError(import_dist.HAPStatus.SERVICE_COMMUNICATION_FAILURE);
|
|
90175
|
+
}
|
|
90176
|
+
});
|
|
90177
|
+
const unsubscribe = proxy.state.alarmPanel?.subscribe((value) => {
|
|
90178
|
+
if (value === void 0 || value === null) return;
|
|
90179
|
+
apply(value, "slice");
|
|
90180
|
+
}) ?? null;
|
|
90181
|
+
return { async dispose() {
|
|
90182
|
+
try {
|
|
90183
|
+
unsubscribe?.();
|
|
90184
|
+
} catch {}
|
|
90185
|
+
} };
|
|
90186
|
+
}
|
|
90187
|
+
function errMsg$11(err) {
|
|
90188
|
+
return err instanceof Error ? err.message : String(err);
|
|
90189
|
+
}
|
|
90190
|
+
//#endregion
|
|
90191
|
+
//#region src/mappers/builders/generic/sensor-service.ts
|
|
90192
|
+
async function buildSensorService(input) {
|
|
90193
|
+
const { bctx, spec, name, subtype } = input;
|
|
90194
|
+
const { ctx, accessory, proxy, numericDeviceId } = bctx;
|
|
90195
|
+
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
90196
|
+
const label = hapServiceName([name], spec.label);
|
|
90197
|
+
const service = spec.addService(accessory, label, subtype);
|
|
90198
|
+
applyServiceLabel(service, label);
|
|
90199
|
+
const apply = (status, source) => {
|
|
90200
|
+
const updates = spec.read(status);
|
|
90201
|
+
if (updates.length === 0) {
|
|
90202
|
+
log.debug("export-hap: sensor status did not match the cap schema — no update", { meta: {
|
|
90203
|
+
subtype,
|
|
90204
|
+
source
|
|
90205
|
+
} });
|
|
90206
|
+
return;
|
|
90207
|
+
}
|
|
90208
|
+
applyUpdates(service, updates);
|
|
90209
|
+
};
|
|
90210
|
+
try {
|
|
90211
|
+
const status = await spec.getStatus(proxy);
|
|
90212
|
+
if (status !== void 0 && status !== null) apply(status, "getStatus");
|
|
90213
|
+
} catch (err) {
|
|
90214
|
+
log.debug("export-hap: sensor getStatus hydrate failed (non-fatal)", { meta: {
|
|
90215
|
+
subtype,
|
|
90216
|
+
error: errMsg$10(err)
|
|
90217
|
+
} });
|
|
90218
|
+
}
|
|
90219
|
+
const unsubscribe = spec.subscribe(proxy, (value) => {
|
|
90220
|
+
if (value === void 0 || value === null) return;
|
|
90221
|
+
apply(value, "slice");
|
|
90222
|
+
});
|
|
90223
|
+
return { async dispose() {
|
|
90224
|
+
try {
|
|
90225
|
+
unsubscribe?.();
|
|
90226
|
+
} catch {}
|
|
90227
|
+
} };
|
|
90228
|
+
}
|
|
90229
|
+
function errMsg$10(err) {
|
|
90230
|
+
return err instanceof Error ? err.message : String(err);
|
|
90231
|
+
}
|
|
90232
|
+
//#endregion
|
|
90233
|
+
//#region src/mappers/builders/generic/cap-service-table.ts
|
|
90234
|
+
/**
|
|
90235
|
+
* The capability→HomeKit-service TABLE for non-camera devices.
|
|
90236
|
+
*
|
|
90237
|
+
* This is the whole coverage decision for the generic export path, and it is
|
|
90238
|
+
* deliberately a table rather than a `switch` on `DeviceType`. The doctrine is
|
|
90239
|
+
* the one the Home Assistant exporter already writes down: *coverage is decided
|
|
90240
|
+
* by a device's capabilities, not its type — restricting the picker by type is
|
|
90241
|
+
* what confined the previous exporter to cameras.* A `DeviceType` here is only
|
|
90242
|
+
* ever a UI filter (`HAP_EXPORTABLE_DEVICE_TYPES`) or an icon
|
|
90243
|
+
* (`ACCESSORY_CATEGORY_BY_TYPE`); it never decides whether something exports.
|
|
90244
|
+
*
|
|
90245
|
+
* The shape mirrors `CAP_ENTITY_MAP` in
|
|
90246
|
+
* `packages/addon-provider-homeassistant/src/ha-export/entity-catalog.ts` so
|
|
90247
|
+
* the two can be unified later — one row per capability, keyed by the cap name
|
|
90248
|
+
* exactly as it appears in `proxy.binding.entries[].capName`.
|
|
90249
|
+
*
|
|
90250
|
+
* ## Reading a status
|
|
90251
|
+
*
|
|
90252
|
+
* Every row parses the cap's OWN Zod status schema rather than duck-typing the
|
|
90253
|
+
* payload, and it `.pick()`s only the fields it reads: a provider that omits a
|
|
90254
|
+
* timestamp must not silence a sensor, and a provider that sends the wrong
|
|
90255
|
+
* shape must not write a garbage characteristic. A parse that fails yields no
|
|
90256
|
+
* updates, and the builder that owns the service logs the drop — silence reads
|
|
90257
|
+
* as "the sensor never changed".
|
|
90258
|
+
*
|
|
90259
|
+
* ## Tier
|
|
90260
|
+
*
|
|
90261
|
+
* Tier A, plus `alarm-panel`: the caps whose HomeKit service exists today and
|
|
90262
|
+
* needs no feature-dependent shape. `cover` still needs `cover-positionable`
|
|
90263
|
+
* and `climate-control` the dual-setpoint split; those are a separate task,
|
|
90264
|
+
* and adding a row here must never mean adding a service whose semantics this
|
|
90265
|
+
* file cannot fully honour.
|
|
90266
|
+
*
|
|
90267
|
+
* `alarm-panel` earned its row by answering the two questions that kept it
|
|
90268
|
+
* out: a target the panel would refuse is never offered (`availableModes` →
|
|
90269
|
+
* `validValues`, `security-target.ts`) and an arm a PIN-gated panel cannot
|
|
90270
|
+
* accept is refused out loud rather than attempted (`security-system.ts`).
|
|
90271
|
+
*/
|
|
90272
|
+
/** HomeKit's floor for `CurrentAmbientLightLevel`; 0 lux is out of range. */
|
|
90273
|
+
var MIN_LUX = 1e-4;
|
|
90274
|
+
var SENSOR_SPECS = {
|
|
90275
|
+
contact: {
|
|
90276
|
+
label: "Contact",
|
|
90277
|
+
addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.ContactSensor, name, subtype),
|
|
90278
|
+
getStatus: (proxy) => proxy.contact?.getStatus({}),
|
|
90279
|
+
subscribe: (proxy, onValue) => proxy.state.contact?.subscribe(onValue) ?? null,
|
|
90280
|
+
read: reader(ContactStatusSchema.pick({ entryOpen: true }), (status) => [{
|
|
90281
|
+
characteristic: import_dist.Characteristic.ContactSensorState,
|
|
90282
|
+
value: status.entryOpen ? import_dist.Characteristic.ContactSensorState.CONTACT_NOT_DETECTED : import_dist.Characteristic.ContactSensorState.CONTACT_DETECTED
|
|
90283
|
+
}])
|
|
90284
|
+
},
|
|
90285
|
+
motion: {
|
|
90286
|
+
label: "Motion",
|
|
90287
|
+
addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.MotionSensor, name, subtype),
|
|
90288
|
+
getStatus: (proxy) => proxy.motion?.getStatus({}),
|
|
90289
|
+
subscribe: (proxy, onValue) => proxy.state.motion?.subscribe(onValue) ?? null,
|
|
90290
|
+
read: reader(MotionStatusSchema.pick({ detected: true }), (status) => [{
|
|
90291
|
+
characteristic: import_dist.Characteristic.MotionDetected,
|
|
90292
|
+
value: status.detected
|
|
90293
|
+
}])
|
|
90294
|
+
},
|
|
90295
|
+
presence: {
|
|
90296
|
+
label: "Presence",
|
|
90297
|
+
addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.OccupancySensor, name, subtype),
|
|
90298
|
+
getStatus: (proxy) => proxy.presence?.getStatus({}),
|
|
90299
|
+
subscribe: (proxy, onValue) => proxy.state.presence?.subscribe(onValue) ?? null,
|
|
90300
|
+
read: reader(PresenceStatusSchema.pick({ state: true }), (status) => [{
|
|
90301
|
+
characteristic: import_dist.Characteristic.OccupancyDetected,
|
|
90302
|
+
value: status.state === "home" ? import_dist.Characteristic.OccupancyDetected.OCCUPANCY_DETECTED : import_dist.Characteristic.OccupancyDetected.OCCUPANCY_NOT_DETECTED
|
|
90303
|
+
}])
|
|
90304
|
+
},
|
|
90305
|
+
smoke: {
|
|
90306
|
+
label: "Smoke",
|
|
90307
|
+
addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.SmokeSensor, name, subtype),
|
|
90308
|
+
getStatus: (proxy) => proxy.smoke?.getStatus({}),
|
|
90309
|
+
subscribe: (proxy, onValue) => proxy.state.smoke?.subscribe(onValue) ?? null,
|
|
90310
|
+
read: reader(SmokeStatusSchema.pick({ detected: true }), (status) => [{
|
|
90311
|
+
characteristic: import_dist.Characteristic.SmokeDetected,
|
|
90312
|
+
value: status.detected ? import_dist.Characteristic.SmokeDetected.SMOKE_DETECTED : import_dist.Characteristic.SmokeDetected.SMOKE_NOT_DETECTED
|
|
90313
|
+
}])
|
|
90314
|
+
},
|
|
90315
|
+
"carbon-monoxide": {
|
|
90316
|
+
label: "Carbon monoxide",
|
|
90317
|
+
addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.CarbonMonoxideSensor, name, subtype),
|
|
90318
|
+
getStatus: (proxy) => proxy.carbonMonoxide?.getStatus({}),
|
|
90319
|
+
subscribe: (proxy, onValue) => proxy.state.carbonMonoxide?.subscribe(onValue) ?? null,
|
|
90320
|
+
read: reader(CarbonMonoxideStatusSchema.pick({ detected: true }), (status) => [{
|
|
90321
|
+
characteristic: import_dist.Characteristic.CarbonMonoxideDetected,
|
|
90322
|
+
value: status.detected ? import_dist.Characteristic.CarbonMonoxideDetected.CO_LEVELS_ABNORMAL : import_dist.Characteristic.CarbonMonoxideDetected.CO_LEVELS_NORMAL
|
|
90323
|
+
}])
|
|
90324
|
+
},
|
|
90325
|
+
flood: {
|
|
90326
|
+
label: "Leak",
|
|
90327
|
+
addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.LeakSensor, name, subtype),
|
|
90328
|
+
getStatus: (proxy) => proxy.flood?.getStatus({}),
|
|
90329
|
+
subscribe: (proxy, onValue) => proxy.state.flood?.subscribe(onValue) ?? null,
|
|
90330
|
+
read: reader(FloodStatusSchema.pick({ flooded: true }), (status) => [{
|
|
90331
|
+
characteristic: import_dist.Characteristic.LeakDetected,
|
|
90332
|
+
value: status.flooded ? import_dist.Characteristic.LeakDetected.LEAK_DETECTED : import_dist.Characteristic.LeakDetected.LEAK_NOT_DETECTED
|
|
90333
|
+
}])
|
|
90334
|
+
},
|
|
90335
|
+
"temperature-sensor": {
|
|
90336
|
+
label: "Temperature",
|
|
90337
|
+
addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.TemperatureSensor, name, subtype),
|
|
90338
|
+
getStatus: (proxy) => proxy.temperatureSensor?.getStatus({}),
|
|
90339
|
+
subscribe: (proxy, onValue) => proxy.state.temperatureSensor?.subscribe(onValue) ?? null,
|
|
90340
|
+
read: reader(TemperatureSensorStatusSchema.pick({ celsius: true }), (status) => [{
|
|
90341
|
+
characteristic: import_dist.Characteristic.CurrentTemperature,
|
|
90342
|
+
value: status.celsius
|
|
90343
|
+
}])
|
|
90344
|
+
},
|
|
90345
|
+
"humidity-sensor": {
|
|
90346
|
+
label: "Humidity",
|
|
90347
|
+
addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.HumiditySensor, name, subtype),
|
|
90348
|
+
getStatus: (proxy) => proxy.humiditySensor?.getStatus({}),
|
|
90349
|
+
subscribe: (proxy, onValue) => proxy.state.humiditySensor?.subscribe(onValue) ?? null,
|
|
90350
|
+
read: reader(HumiditySensorStatusSchema.pick({ percent: true }), (status) => [{
|
|
90351
|
+
characteristic: import_dist.Characteristic.CurrentRelativeHumidity,
|
|
90352
|
+
value: status.percent
|
|
90353
|
+
}])
|
|
90354
|
+
},
|
|
90355
|
+
"ambient-light-sensor": {
|
|
90356
|
+
label: "Light level",
|
|
90357
|
+
addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.LightSensor, name, subtype),
|
|
90358
|
+
getStatus: (proxy) => proxy.ambientLightSensor?.getStatus({}),
|
|
90359
|
+
subscribe: (proxy, onValue) => proxy.state.ambientLightSensor?.subscribe(onValue) ?? null,
|
|
90360
|
+
read: reader(AmbientLightSensorStatusSchema.pick({ lux: true }), (status) => [{
|
|
90361
|
+
characteristic: import_dist.Characteristic.CurrentAmbientLightLevel,
|
|
90362
|
+
value: Math.max(MIN_LUX, status.lux)
|
|
90363
|
+
}])
|
|
90364
|
+
}
|
|
90365
|
+
};
|
|
90366
|
+
/**
|
|
90367
|
+
* The `battery` row reads like a sensor but writes three characteristics from
|
|
90368
|
+
* one status, and the camera path publishes the SAME service from the same cap.
|
|
90369
|
+
* The derivation therefore lives once, in `battery.ts`, and both callers read
|
|
90370
|
+
* it from there.
|
|
90371
|
+
*/
|
|
90372
|
+
var BATTERY_SPEC = {
|
|
90373
|
+
label: "Battery",
|
|
90374
|
+
addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.Battery, name, subtype),
|
|
90375
|
+
getStatus: (proxy) => proxy.battery?.getStatus({}),
|
|
90376
|
+
subscribe: (proxy, onValue) => proxy.state.battery?.subscribe(onValue) ?? null,
|
|
90377
|
+
read: batteryCharacteristicUpdates
|
|
90378
|
+
};
|
|
90379
|
+
function sensorRow(capName, spec) {
|
|
90380
|
+
return {
|
|
90381
|
+
caps: [capName],
|
|
90382
|
+
label: spec.label,
|
|
90383
|
+
build: ({ bctx, name }) => buildSensorService({
|
|
90384
|
+
bctx,
|
|
90385
|
+
spec,
|
|
90386
|
+
name,
|
|
90387
|
+
subtype: capName
|
|
90388
|
+
})
|
|
90389
|
+
};
|
|
90390
|
+
}
|
|
90391
|
+
/**
|
|
90392
|
+
* THE table. Order matters only for one thing: the first row that matches a
|
|
90393
|
+
* device decides the accessory's category when the device's own type does not.
|
|
90394
|
+
*/
|
|
90395
|
+
var HAP_CAP_SERVICES = [
|
|
90396
|
+
{
|
|
90397
|
+
caps: ["switch", "brightness"],
|
|
90398
|
+
label: "Power",
|
|
90399
|
+
build: ({ bctx, name, deviceType }) => buildChildSwitch({
|
|
90400
|
+
...bctx,
|
|
90401
|
+
displayName: name
|
|
90402
|
+
}, "switch", deviceType)
|
|
90403
|
+
},
|
|
90404
|
+
{
|
|
90405
|
+
caps: ["lock-control"],
|
|
90406
|
+
label: "Lock",
|
|
90407
|
+
build: ({ bctx, name }) => buildLockMechanism({
|
|
90408
|
+
...bctx,
|
|
90409
|
+
displayName: name
|
|
90410
|
+
})
|
|
90411
|
+
},
|
|
90412
|
+
{
|
|
90413
|
+
caps: ["alarm-panel"],
|
|
90414
|
+
label: "Alarm",
|
|
90415
|
+
build: ({ bctx, name }) => buildSecuritySystem({
|
|
90416
|
+
...bctx,
|
|
90417
|
+
displayName: name
|
|
90418
|
+
})
|
|
90419
|
+
},
|
|
90420
|
+
{
|
|
90421
|
+
caps: ["battery"],
|
|
90422
|
+
label: BATTERY_SPEC.label,
|
|
90423
|
+
build: ({ bctx, name }) => buildSensorService({
|
|
90424
|
+
bctx,
|
|
90425
|
+
spec: BATTERY_SPEC,
|
|
90426
|
+
name,
|
|
90427
|
+
subtype: "battery"
|
|
90428
|
+
})
|
|
90429
|
+
},
|
|
90430
|
+
...Object.entries(SENSOR_SPECS).map(([capName, spec]) => sensorRow(capName, spec))
|
|
90431
|
+
];
|
|
90432
|
+
/**
|
|
90433
|
+
* The rows a device's bound capabilities select, in table order.
|
|
90434
|
+
*
|
|
90435
|
+
* A row matches when ANY of its caps is bound: a lamp with `switch` but no
|
|
90436
|
+
* `brightness` is still a Lightbulb-shaped row, and the builder degrades on its
|
|
90437
|
+
* own.
|
|
90438
|
+
*/
|
|
90439
|
+
function rowsForCaps(capNames) {
|
|
90440
|
+
return HAP_CAP_SERVICES.filter((row) => row.caps.some((cap) => capNames.has(cap)));
|
|
90441
|
+
}
|
|
90442
|
+
/**
|
|
90443
|
+
* The HomeKit accessory category per camstack device type — the icon iOS Home
|
|
90444
|
+
* draws and nothing else. Absent = `OTHER`, which is a valid accessory that
|
|
90445
|
+
* simply gets the generic tile.
|
|
90446
|
+
*
|
|
90447
|
+
* This is NOT a coverage decision. `HAP_EXPORTABLE_DEVICE_TYPES` (in
|
|
90448
|
+
* `mappers/index.ts`) filters the picker; whether a device exports anything is
|
|
90449
|
+
* decided by `rowsForCaps`.
|
|
90450
|
+
*/
|
|
90451
|
+
var ACCESSORY_CATEGORY_BY_TYPE = {
|
|
90452
|
+
[DeviceType.Switch]: import_dist.Categories.SWITCH,
|
|
90453
|
+
[DeviceType.Light]: import_dist.Categories.LIGHTBULB,
|
|
90454
|
+
[DeviceType.Siren]: import_dist.Categories.SWITCH,
|
|
90455
|
+
[DeviceType.Lock]: import_dist.Categories.DOOR_LOCK,
|
|
90456
|
+
[DeviceType.Sensor]: import_dist.Categories.SENSOR,
|
|
90457
|
+
[DeviceType.Presence]: import_dist.Categories.SENSOR,
|
|
90458
|
+
[DeviceType.AlarmPanel]: import_dist.Categories.SECURITY_SYSTEM
|
|
90459
|
+
};
|
|
90460
|
+
function accessoryCategoryFor(deviceType) {
|
|
90461
|
+
return ACCESSORY_CATEGORY_BY_TYPE[deviceType] ?? import_dist.Categories.OTHER;
|
|
90462
|
+
}
|
|
90463
|
+
//#endregion
|
|
90464
|
+
//#region src/mappers/builders/stream-hwaccel-memo.ts
|
|
90465
|
+
/**
|
|
90466
|
+
* The two bounded memos HomeKit's decode path owns.
|
|
90467
|
+
*
|
|
90468
|
+
* ## Why they exist
|
|
90469
|
+
*
|
|
90470
|
+
* Everything D67 was actually about — one argv builder, one set of constants,
|
|
90471
|
+
* one hwaccel authority — HomeKit already had. What it did NOT have were the
|
|
90472
|
+
* two things the broker gained alongside them:
|
|
90473
|
+
*
|
|
90474
|
+
* 1. **A memo.** `probeDecoderHwaccel` issued a cross-process
|
|
90475
|
+
* `decoder.getInfo` per SESSION. iOS starts sessions in bursts — one on
|
|
90476
|
+
* record was started three times in 16 s — and every one of those paid a
|
|
90477
|
+
* cap call on a hub whose main thread is the scarce resource.
|
|
90478
|
+
* 2. **Failure feedback.** When HomeKit's hardware child died at init and
|
|
90479
|
+
* `shouldRetryInSoftware` saved the session, HomeKit told nobody. The next
|
|
90480
|
+
* session re-picked the same corpse and paid the same two-second death.
|
|
90481
|
+
* `EgressTranscodeManager` fixed exactly this for its own children
|
|
90482
|
+
* (26a522cd5) by reporting the dead backend into the broker's 60 s memo.
|
|
90483
|
+
*
|
|
90484
|
+
* ## Both ride `HwAccelCache`, deliberately
|
|
90485
|
+
*
|
|
90486
|
+
* `createHwAccelCache` from `@camstack/types` is the primitive the broker's own
|
|
90487
|
+
* `egressHwAccelCache` is built from, and the discipline it encodes is the
|
|
90488
|
+
* point: **caller-owned, never a module global** — a module global would
|
|
90489
|
+
* outlive an addon respawn and survive an operator changing the decoder
|
|
90490
|
+
* backend. Same TTL as the broker's, so "has hardware come back yet" cannot
|
|
90491
|
+
* answer differently depending on which consumer asked.
|
|
90492
|
+
*
|
|
90493
|
+
* ## The cross-process gap, stated honestly
|
|
90494
|
+
*
|
|
90495
|
+
* These memos are scoped to the `export-hap` PROCESS. When HomeKit's vaapi
|
|
90496
|
+
* child dies, the broker's next child still pays its own two-second death, and
|
|
90497
|
+
* vice versa — because addons may never import each other and there is no
|
|
90498
|
+
* capability for "this backend is dead on this node right now". Closing that
|
|
90499
|
+
* would need a new cap surface, which Phase 0 explicitly does not take. What is
|
|
90500
|
+
* closed here is HomeKit's own repetition of the cost, across cameras and
|
|
90501
|
+
* across sessions.
|
|
90502
|
+
*/
|
|
90503
|
+
/**
|
|
90504
|
+
* The window both memos answer for.
|
|
90505
|
+
*
|
|
90506
|
+
* 60 s, the same as `stream-broker-manager`'s `egressHwAccelCache`. Long enough
|
|
90507
|
+
* that a burst of session restarts pays one read; short enough that an operator
|
|
90508
|
+
* who changes the decoder backend, or a host whose accelerator recovers, is
|
|
90509
|
+
* obeyed on the next session rather than after an addon respawn.
|
|
90510
|
+
*/
|
|
90511
|
+
var HAP_DECODE_MEMO_TTL_MS = 6e4;
|
|
90512
|
+
/**
|
|
90513
|
+
* Separator inside the encoded reading. A control character, because a backend
|
|
90514
|
+
* name is `[a-z0-9]+` and the decoder's non-backend choices are `auto` /
|
|
90515
|
+
* `none` / `''`, none of which can contain one — so the split is total.
|
|
90516
|
+
*/
|
|
90517
|
+
var READING_SEPARATOR = "";
|
|
90518
|
+
/**
|
|
90519
|
+
* Marks a `null` FIELD, distinct from an EMPTY one.
|
|
90520
|
+
*
|
|
90521
|
+
* `probedBestHwaccel: ''` means the decoder answered and has never probed
|
|
90522
|
+
* (=> `not-probed`); `null` means the field was absent altogether. Encoding
|
|
90523
|
+
* both as `''` would lose a distinction `selectHwDecode` acts on.
|
|
90524
|
+
*/
|
|
90525
|
+
var NULL_FIELD = "\0";
|
|
90526
|
+
function encodeField(value) {
|
|
90527
|
+
return value === null ? NULL_FIELD : value;
|
|
90528
|
+
}
|
|
90529
|
+
function decodeField(value) {
|
|
90530
|
+
return value === NULL_FIELD ? null : value;
|
|
90531
|
+
}
|
|
90532
|
+
/**
|
|
90533
|
+
* A reading as ONE `string | null`, which is what {@link HwAccelCache} stores.
|
|
90534
|
+
*
|
|
90535
|
+
* The cache's three states are exactly the three a memoised reading needs:
|
|
90536
|
+
* `undefined` (never asked, or expired), `null` (asked, and the decoder could
|
|
90537
|
+
* not be reached), and a value. Encoding into the one cache rather than
|
|
90538
|
+
* splitting across two is what keeps those three from skewing — two caches
|
|
90539
|
+
* written together can still be READ across an expiry boundary.
|
|
90540
|
+
*/
|
|
90541
|
+
function encodeDecoderReading(reading) {
|
|
90542
|
+
if (reading === null) return null;
|
|
90543
|
+
return `${encodeField(reading.hwaccel)}${READING_SEPARATOR}${encodeField(reading.probedBestHwaccel)}`;
|
|
90544
|
+
}
|
|
90545
|
+
function decodeDecoderReading(value) {
|
|
90546
|
+
if (value === null) return null;
|
|
90547
|
+
const [hwaccel = NULL_FIELD, probed = NULL_FIELD] = value.split(READING_SEPARATOR);
|
|
90548
|
+
return {
|
|
90549
|
+
hwaccel: decodeField(hwaccel),
|
|
90550
|
+
probedBestHwaccel: decodeField(probed)
|
|
90551
|
+
};
|
|
90552
|
+
}
|
|
90553
|
+
function createDecoderReadingMemo(options) {
|
|
90554
|
+
const cache = createHwAccelCache(options);
|
|
90555
|
+
return {
|
|
90556
|
+
read() {
|
|
90557
|
+
const cached = cache.read();
|
|
90558
|
+
return cached === void 0 ? void 0 : decodeDecoderReading(cached);
|
|
90559
|
+
},
|
|
90560
|
+
write(reading) {
|
|
90561
|
+
cache.write(encodeDecoderReading(reading));
|
|
90562
|
+
}
|
|
90563
|
+
};
|
|
90564
|
+
}
|
|
90565
|
+
function createHapDecodeMemos(now) {
|
|
90566
|
+
const options = {
|
|
90567
|
+
ttlMs: HAP_DECODE_MEMO_TTL_MS,
|
|
90568
|
+
...now ? { now } : {}
|
|
90569
|
+
};
|
|
90570
|
+
return {
|
|
90571
|
+
reading: createDecoderReadingMemo(options),
|
|
90572
|
+
failedBackend: createHwAccelCache(options)
|
|
90573
|
+
};
|
|
90574
|
+
}
|
|
90575
|
+
//#endregion
|
|
90576
|
+
//#region src/mappers/builders/accessory-info.ts
|
|
90577
|
+
/**
|
|
90578
|
+
* `Service.AccessoryInformation` — the manufacturer / model / firmware /
|
|
90579
|
+
* serial block every HomeKit accessory carries.
|
|
90580
|
+
*
|
|
90581
|
+
* One implementation for both accessory shapes (camera and generic): the
|
|
90582
|
+
* fields come from the device's own metadata either way, and a second copy
|
|
90583
|
+
* would be a second answer to "what serial does this device publish".
|
|
90584
|
+
* Metadata is best-effort — a device that cannot answer still publishes.
|
|
90585
|
+
*/
|
|
90586
|
+
async function populateAccessoryInfo(accessory, proxy, displayName, modelFallback) {
|
|
90587
|
+
const info = accessory.getService(import_dist.Service.AccessoryInformation);
|
|
90588
|
+
if (!info) return;
|
|
90589
|
+
try {
|
|
90590
|
+
const device = await proxy.deviceManager?.getDevice({});
|
|
90591
|
+
const metadata = readMetadata(device);
|
|
90592
|
+
info.setCharacteristic(import_dist.Characteristic.Name, device?.name ?? displayName);
|
|
90593
|
+
info.setCharacteristic(import_dist.Characteristic.Manufacturer, stringOr(metadata?.manufacturer, "CamStack"));
|
|
90594
|
+
info.setCharacteristic(import_dist.Characteristic.Model, stringOr(metadata?.model, modelFallback));
|
|
90595
|
+
info.setCharacteristic(import_dist.Characteristic.FirmwareRevision, stringOr(metadata?.firmware, "0.0.0"));
|
|
90596
|
+
info.setCharacteristic(import_dist.Characteristic.SerialNumber, stringOr(metadata?.sn, `camstack-${proxy.deviceId}`));
|
|
90597
|
+
} catch {}
|
|
90598
|
+
}
|
|
90599
|
+
/** The four fields this module reads, or `null` — the device record's
|
|
90600
|
+
* `metadata` is an open bag and nothing else here depends on its shape. */
|
|
90601
|
+
function readMetadata(device) {
|
|
90602
|
+
const metadata = device?.metadata;
|
|
90603
|
+
if (metadata === null || typeof metadata !== "object") return {};
|
|
90604
|
+
const entries = new Map(Object.entries(metadata));
|
|
90605
|
+
return {
|
|
90606
|
+
model: stringOrNull(entries.get("model")),
|
|
90607
|
+
manufacturer: stringOrNull(entries.get("manufacturer")),
|
|
90608
|
+
firmware: stringOrNull(entries.get("firmware")),
|
|
90609
|
+
sn: stringOrNull(entries.get("sn"))
|
|
90610
|
+
};
|
|
90611
|
+
}
|
|
90612
|
+
function stringOrNull(value) {
|
|
90613
|
+
return typeof value === "string" ? value : null;
|
|
90614
|
+
}
|
|
90615
|
+
function stringOr(value, fallback) {
|
|
90616
|
+
return typeof value === "string" && value.length > 0 ? value : fallback;
|
|
90617
|
+
}
|
|
90618
|
+
//#endregion
|
|
88800
90619
|
//#region ../../node_modules/werift-rtp/lib/rtp/src/srtp/const.js
|
|
88801
90620
|
var require_const = /* @__PURE__ */ __commonJSMin(((exports) => {
|
|
88802
90621
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
@@ -100113,7 +101932,7 @@ var VIDEO_LOOPBACK_RCVBUF_BYTES = 8 * 1024 * 1024;
|
|
|
100113
101932
|
* not burst size.
|
|
100114
101933
|
*/
|
|
100115
101934
|
var AUDIO_LOOPBACK_RCVBUF_BYTES = 1024 * 1024;
|
|
100116
|
-
function errMsg$
|
|
101935
|
+
function errMsg$9(err) {
|
|
100117
101936
|
return err instanceof Error ? err.message : String(err);
|
|
100118
101937
|
}
|
|
100119
101938
|
/**
|
|
@@ -100128,13 +101947,13 @@ function applyReceiveBuffer(socket, requestedBytes) {
|
|
|
100128
101947
|
try {
|
|
100129
101948
|
socket.setRecvBufferSize(requestedBytes);
|
|
100130
101949
|
} catch (err) {
|
|
100131
|
-
error = errMsg$
|
|
101950
|
+
error = errMsg$9(err);
|
|
100132
101951
|
}
|
|
100133
101952
|
let effectiveBytes = null;
|
|
100134
101953
|
try {
|
|
100135
101954
|
effectiveBytes = socket.getRecvBufferSize();
|
|
100136
101955
|
} catch (err) {
|
|
100137
|
-
if (error === null) error = errMsg$
|
|
101956
|
+
if (error === null) error = errMsg$9(err);
|
|
100138
101957
|
}
|
|
100139
101958
|
return {
|
|
100140
101959
|
requestedBytes,
|
|
@@ -100405,20 +102224,20 @@ function buildCameraStreamingDelegate(bctx, advertised) {
|
|
|
100405
102224
|
delegate: {
|
|
100406
102225
|
handleSnapshotRequest(request, callback) {
|
|
100407
102226
|
handleSnapshot(bctx, request).then((buf) => callback(void 0, buf)).catch((err) => {
|
|
100408
|
-
log.warn("export-hap: snapshot failed", { meta: { error: errMsg$
|
|
100409
|
-
callback(err instanceof Error ? err : new Error(errMsg$
|
|
102227
|
+
log.warn("export-hap: snapshot failed", { meta: { error: errMsg$8(err) } });
|
|
102228
|
+
callback(err instanceof Error ? err : new Error(errMsg$8(err)));
|
|
100410
102229
|
});
|
|
100411
102230
|
},
|
|
100412
102231
|
prepareStream(request, callback) {
|
|
100413
102232
|
prepareStream(request, sessions, bctx, "classic").then((resp) => callback(void 0, resp)).catch((err) => {
|
|
100414
|
-
log.warn("export-hap: prepareStream failed", { meta: { error: errMsg$
|
|
100415
|
-
callback(err instanceof Error ? err : new Error(errMsg$
|
|
102233
|
+
log.warn("export-hap: prepareStream failed", { meta: { error: errMsg$8(err) } });
|
|
102234
|
+
callback(err instanceof Error ? err : new Error(errMsg$8(err)));
|
|
100416
102235
|
});
|
|
100417
102236
|
},
|
|
100418
102237
|
handleStreamRequest(request, callback) {
|
|
100419
102238
|
handleStreamRequest(request, sessions, bctx, advertised).then(() => callback()).catch((err) => {
|
|
100420
|
-
log.warn("export-hap: handleStreamRequest failed", { meta: { error: errMsg$
|
|
100421
|
-
callback(err instanceof Error ? err : new Error(errMsg$
|
|
102239
|
+
log.warn("export-hap: handleStreamRequest failed", { meta: { error: errMsg$8(err) } });
|
|
102240
|
+
callback(err instanceof Error ? err : new Error(errMsg$8(err)));
|
|
100422
102241
|
});
|
|
100423
102242
|
}
|
|
100424
102243
|
},
|
|
@@ -100557,7 +102376,7 @@ async function prepareStream(request, sessions, bctx, negotiatedVia) {
|
|
|
100557
102376
|
closeSocket(audioUdp);
|
|
100558
102377
|
closeSocket(videoLoopUdp);
|
|
100559
102378
|
closeSocket(audioLoopUdp);
|
|
100560
|
-
throw new Error(`export-hap: outbound SrtpSession init failed: ${errMsg$
|
|
102379
|
+
throw new Error(`export-hap: outbound SrtpSession init failed: ${errMsg$8(err)}`, { cause: err });
|
|
100561
102380
|
}
|
|
100562
102381
|
let upstreamAudioSrtp = null;
|
|
100563
102382
|
try {
|
|
@@ -100571,7 +102390,7 @@ async function prepareStream(request, sessions, bctx, negotiatedVia) {
|
|
|
100571
102390
|
profile: import_src.ProtectionProfileAes128CmHmacSha1_80
|
|
100572
102391
|
});
|
|
100573
102392
|
} catch (err) {
|
|
100574
|
-
bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId }).warn("export-hap: SrtpSession init failed (upstream audio decrypt disabled)", { meta: { error: errMsg$
|
|
102393
|
+
bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId }).warn("export-hap: SrtpSession init failed (upstream audio decrypt disabled)", { meta: { error: errMsg$8(err) } });
|
|
100575
102394
|
}
|
|
100576
102395
|
const videoSsrc = randomSsrc();
|
|
100577
102396
|
const audioSsrc = randomSsrc();
|
|
@@ -100691,7 +102510,7 @@ async function prepareStream(request, sessions, bctx, negotiatedVia) {
|
|
|
100691
102510
|
});
|
|
100692
102511
|
audioUdp.on("message", (packet, rinfo) => {
|
|
100693
102512
|
handleIncomingAudioRtp(session, packet, rinfo.address, bctx).catch((err) => {
|
|
100694
|
-
bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId }).debug("export-hap: incoming-audio handler error (dropped)", { meta: { error: errMsg$
|
|
102513
|
+
bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId }).debug("export-hap: incoming-audio handler error (dropped)", { meta: { error: errMsg$8(err) } });
|
|
100695
102514
|
});
|
|
100696
102515
|
});
|
|
100697
102516
|
logLoopbackBuffer(tagLog, request.sessionID, "video", videoLoop.buffer);
|
|
@@ -100887,7 +102706,7 @@ function readControllerRtcp(session, leg, packet, log) {
|
|
|
100887
102706
|
} catch (err) {
|
|
100888
102707
|
drop(session, "inbound-rtcp-decrypt-failed");
|
|
100889
102708
|
storeReceiverReports(session, leg, recordUnreadableRtcp(tally));
|
|
100890
|
-
logUnreadableRtcp(session, leg, `decrypt: ${errMsg$
|
|
102709
|
+
logUnreadableRtcp(session, leg, `decrypt: ${errMsg$8(err)}`, log);
|
|
100891
102710
|
return;
|
|
100892
102711
|
}
|
|
100893
102712
|
const outcome = ingestDecryptedRtcp(plaintext, tally);
|
|
@@ -101705,7 +103524,7 @@ async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
|
|
|
101705
103524
|
session.upstreamRtpDecryptFailures += 1;
|
|
101706
103525
|
if (session.upstreamRtpDecryptFailures % 100 === 1) log.debug("export-hap: SRTP decrypt failed (rate-limited)", { meta: {
|
|
101707
103526
|
failures: session.upstreamRtpDecryptFailures,
|
|
101708
|
-
error: errMsg$
|
|
103527
|
+
error: errMsg$8(err)
|
|
101709
103528
|
} });
|
|
101710
103529
|
return;
|
|
101711
103530
|
}
|
|
@@ -101719,7 +103538,7 @@ async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
|
|
|
101719
103538
|
rtpPayloadType = parsedRtp.header.payloadType;
|
|
101720
103539
|
} catch (err) {
|
|
101721
103540
|
drop(session, "upstream-parse-failed");
|
|
101722
|
-
log.debug("export-hap: RTP parse failed after decrypt", { meta: { error: errMsg$
|
|
103541
|
+
log.debug("export-hap: RTP parse failed after decrypt", { meta: { error: errMsg$8(err) } });
|
|
101723
103542
|
return;
|
|
101724
103543
|
}
|
|
101725
103544
|
const negotiatedAudioPt = session.lastStartParams?.audioPt;
|
|
@@ -101743,7 +103562,7 @@ async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
|
|
|
101743
103562
|
if (session.intercomTalkSessionId === null) {
|
|
101744
103563
|
session.intercomTalkSessionId = "";
|
|
101745
103564
|
const opened = await openIntercomTalkSession(bctx).catch((err) => {
|
|
101746
|
-
log.warn("export-hap: intercom.startTalkSession failed", { meta: { error: errMsg$
|
|
103565
|
+
log.warn("export-hap: intercom.startTalkSession failed", { meta: { error: errMsg$8(err) } });
|
|
101747
103566
|
return null;
|
|
101748
103567
|
});
|
|
101749
103568
|
if (opened) {
|
|
@@ -101770,7 +103589,7 @@ async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
|
|
|
101770
103589
|
drop(session, "upstream-push-failed");
|
|
101771
103590
|
log.debug("export-hap: intercom.pushTalkAudio failed (will re-open on next frame)", { meta: {
|
|
101772
103591
|
sequenceNumber: session.intercomPcmSequence,
|
|
101773
|
-
error: errMsg$
|
|
103592
|
+
error: errMsg$8(err)
|
|
101774
103593
|
} });
|
|
101775
103594
|
session.intercomTalkSessionId = null;
|
|
101776
103595
|
}
|
|
@@ -101802,7 +103621,7 @@ async function closeIntercomTalkSession(session, bctx) {
|
|
|
101802
103621
|
} catch (err) {
|
|
101803
103622
|
log.debug("export-hap: intercom.endTalkSession failed (continuing)", { meta: {
|
|
101804
103623
|
sessionId,
|
|
101805
|
-
error: errMsg$
|
|
103624
|
+
error: errMsg$8(err)
|
|
101806
103625
|
} });
|
|
101807
103626
|
}
|
|
101808
103627
|
}
|
|
@@ -101827,7 +103646,7 @@ function closeSocket(udp) {
|
|
|
101827
103646
|
function randomSsrc() {
|
|
101828
103647
|
return Math.floor(Math.random() * 2147483646) + 1 | 0;
|
|
101829
103648
|
}
|
|
101830
|
-
function errMsg$
|
|
103649
|
+
function errMsg$8(err) {
|
|
101831
103650
|
return err instanceof Error ? err.message : String(err);
|
|
101832
103651
|
}
|
|
101833
103652
|
//#endregion
|
|
@@ -101911,14 +103730,14 @@ async function buildDoorbell(input) {
|
|
|
101911
103730
|
}
|
|
101912
103731
|
log.info("export-hap: doorbell SINGLE_PRESS pushed to HomeKit", { meta: { ...delivery } });
|
|
101913
103732
|
} catch (err) {
|
|
101914
|
-
log.warn("export-hap: ringDoorbell() failed", { meta: { error: errMsg$
|
|
103733
|
+
log.warn("export-hap: ringDoorbell() failed", { meta: { error: errMsg$7(err) } });
|
|
101915
103734
|
}
|
|
101916
103735
|
});
|
|
101917
103736
|
return { async dispose() {
|
|
101918
103737
|
unsubscribe();
|
|
101919
103738
|
} };
|
|
101920
103739
|
}
|
|
101921
|
-
function errMsg$
|
|
103740
|
+
function errMsg$7(err) {
|
|
101922
103741
|
return err instanceof Error ? err.message : String(err);
|
|
101923
103742
|
}
|
|
101924
103743
|
//#endregion
|
|
@@ -101966,7 +103785,7 @@ async function buildMotionSensor(bctx, existing = null) {
|
|
|
101966
103785
|
const detected = await proxy.motion?.isDetected({});
|
|
101967
103786
|
if (typeof detected === "boolean") motionService.updateCharacteristic(import_dist.Characteristic.MotionDetected, detected);
|
|
101968
103787
|
} catch (err) {
|
|
101969
|
-
ctx.logger.withTags({ deviceId: numericDeviceId }).debug("export-hap: initial motion hydrate failed (non-fatal)", { meta: { error: errMsg$
|
|
103788
|
+
ctx.logger.withTags({ deviceId: numericDeviceId }).debug("export-hap: initial motion hydrate failed (non-fatal)", { meta: { error: errMsg$6(err) } });
|
|
101970
103789
|
}
|
|
101971
103790
|
let resetTimer = null;
|
|
101972
103791
|
const armReset = () => {
|
|
@@ -102000,7 +103819,7 @@ async function buildMotionSensor(bctx, existing = null) {
|
|
|
102000
103819
|
}
|
|
102001
103820
|
} };
|
|
102002
103821
|
}
|
|
102003
|
-
function errMsg$
|
|
103822
|
+
function errMsg$6(err) {
|
|
102004
103823
|
return err instanceof Error ? err.message : String(err);
|
|
102005
103824
|
}
|
|
102006
103825
|
//#endregion
|
|
@@ -102031,7 +103850,7 @@ async function buildPrivacySwitch(bctx) {
|
|
|
102031
103850
|
const status = await proxy.privacyMask?.getStatus({});
|
|
102032
103851
|
if (status && typeof status.enabled === "boolean") service.updateCharacteristic(import_dist.Characteristic.On, status.enabled);
|
|
102033
103852
|
} catch (err) {
|
|
102034
|
-
log.debug("export-hap: privacy-mask getStatus hydrate failed (non-fatal)", { meta: { error: errMsg$
|
|
103853
|
+
log.debug("export-hap: privacy-mask getStatus hydrate failed (non-fatal)", { meta: { error: errMsg$5(err) } });
|
|
102035
103854
|
}
|
|
102036
103855
|
service.getCharacteristic(import_dist.Characteristic.On).onSet(async (value) => {
|
|
102037
103856
|
const enabled = value === true;
|
|
@@ -102040,7 +103859,7 @@ async function buildPrivacySwitch(bctx) {
|
|
|
102040
103859
|
} catch (err) {
|
|
102041
103860
|
log.warn("export-hap: privacy-mask setMask failed", { meta: {
|
|
102042
103861
|
enabled,
|
|
102043
|
-
error: errMsg$
|
|
103862
|
+
error: errMsg$5(err)
|
|
102044
103863
|
} });
|
|
102045
103864
|
}
|
|
102046
103865
|
});
|
|
@@ -102058,7 +103877,7 @@ async function buildPrivacySwitch(bctx) {
|
|
|
102058
103877
|
} catch {}
|
|
102059
103878
|
} };
|
|
102060
103879
|
}
|
|
102061
|
-
function errMsg$
|
|
103880
|
+
function errMsg$5(err) {
|
|
102062
103881
|
return err instanceof Error ? err.message : String(err);
|
|
102063
103882
|
}
|
|
102064
103883
|
//#endregion
|
|
@@ -102149,7 +103968,7 @@ async function buildPtz(bctx) {
|
|
|
102149
103968
|
} catch (err) {
|
|
102150
103969
|
log.warn("export-hap: ptz.goToPreset failed", { meta: {
|
|
102151
103970
|
presetId: preset.id,
|
|
102152
|
-
error: errMsg$
|
|
103971
|
+
error: errMsg$4(err)
|
|
102153
103972
|
} });
|
|
102154
103973
|
}
|
|
102155
103974
|
armReset(() => service.updateCharacteristic(import_dist.Characteristic.On, false), MOMENTARY_RESET_MS);
|
|
@@ -102167,14 +103986,14 @@ async function buildPtz(bctx) {
|
|
|
102167
103986
|
try {
|
|
102168
103987
|
await proxy.ptz?.stop({});
|
|
102169
103988
|
} catch (err) {
|
|
102170
|
-
log.debug("export-hap: ptz.stop failed (non-fatal)", { meta: { error: errMsg$
|
|
103989
|
+
log.debug("export-hap: ptz.stop failed (non-fatal)", { meta: { error: errMsg$4(err) } });
|
|
102171
103990
|
}
|
|
102172
103991
|
service.updateCharacteristic(import_dist.Characteristic.On, false);
|
|
102173
103992
|
}, options.ptzPulseMs);
|
|
102174
103993
|
} catch (err) {
|
|
102175
103994
|
log.warn("export-hap: ptz.continuousMove failed", { meta: {
|
|
102176
103995
|
dir: dir.label,
|
|
102177
|
-
error: errMsg$
|
|
103996
|
+
error: errMsg$4(err)
|
|
102178
103997
|
} });
|
|
102179
103998
|
service.updateCharacteristic(import_dist.Characteristic.On, false);
|
|
102180
103999
|
}
|
|
@@ -102206,7 +104025,7 @@ async function readPresets(bctx) {
|
|
|
102206
104025
|
}
|
|
102207
104026
|
return entries;
|
|
102208
104027
|
} catch (err) {
|
|
102209
|
-
ctx.logger.withTags({ deviceId: numericDeviceId }).debug("export-hap: ptz.getPresets failed (non-fatal)", { meta: { error: errMsg$
|
|
104028
|
+
ctx.logger.withTags({ deviceId: numericDeviceId }).debug("export-hap: ptz.getPresets failed (non-fatal)", { meta: { error: errMsg$4(err) } });
|
|
102210
104029
|
return [];
|
|
102211
104030
|
}
|
|
102212
104031
|
}
|
|
@@ -102221,7 +104040,7 @@ async function tryBuildAutotrack(bctx) {
|
|
|
102221
104040
|
const status = await proxy.ptzAutotrack.getStatus({});
|
|
102222
104041
|
if (status && typeof status.enabled === "boolean") service.updateCharacteristic(import_dist.Characteristic.On, status.enabled);
|
|
102223
104042
|
} catch (err) {
|
|
102224
|
-
log.debug("export-hap: ptzAutotrack.getStatus failed (non-fatal)", { meta: { error: errMsg$
|
|
104043
|
+
log.debug("export-hap: ptzAutotrack.getStatus failed (non-fatal)", { meta: { error: errMsg$4(err) } });
|
|
102225
104044
|
}
|
|
102226
104045
|
service.getCharacteristic(import_dist.Characteristic.On).onSet(async (value) => {
|
|
102227
104046
|
const enabled = value === true;
|
|
@@ -102230,16 +104049,218 @@ async function tryBuildAutotrack(bctx) {
|
|
|
102230
104049
|
} catch (err) {
|
|
102231
104050
|
log.warn("export-hap: ptzAutotrack.setEnabled failed", { meta: {
|
|
102232
104051
|
enabled,
|
|
102233
|
-
error: errMsg$
|
|
104052
|
+
error: errMsg$4(err)
|
|
102234
104053
|
} });
|
|
102235
104054
|
}
|
|
102236
104055
|
});
|
|
102237
104056
|
return { async dispose() {} };
|
|
102238
104057
|
}
|
|
102239
|
-
function errMsg$
|
|
104058
|
+
function errMsg$4(err) {
|
|
102240
104059
|
return err instanceof Error ? err.message : String(err);
|
|
102241
104060
|
}
|
|
102242
104061
|
//#endregion
|
|
104062
|
+
//#region src/hksv/clip-writer.ts
|
|
104063
|
+
var HksvClipWriter = class {
|
|
104064
|
+
input;
|
|
104065
|
+
log;
|
|
104066
|
+
started = false;
|
|
104067
|
+
settle = null;
|
|
104068
|
+
outcome;
|
|
104069
|
+
bytes = 0;
|
|
104070
|
+
fragments = 0;
|
|
104071
|
+
sawInit = false;
|
|
104072
|
+
truncated = null;
|
|
104073
|
+
startedAtMs;
|
|
104074
|
+
constructor(input) {
|
|
104075
|
+
this.input = input;
|
|
104076
|
+
this.log = input.logger;
|
|
104077
|
+
this.startedAtMs = input.now();
|
|
104078
|
+
this.outcome = new Promise((resolve) => {
|
|
104079
|
+
this.settle = resolve;
|
|
104080
|
+
});
|
|
104081
|
+
}
|
|
104082
|
+
/** Resolves once the file is closed. NEVER rejects — every failure is named. */
|
|
104083
|
+
get done() {
|
|
104084
|
+
return this.outcome;
|
|
104085
|
+
}
|
|
104086
|
+
/** Launch the loop. Returns immediately: the delegate awaits nothing here. */
|
|
104087
|
+
start() {
|
|
104088
|
+
if (this.started) return;
|
|
104089
|
+
this.started = true;
|
|
104090
|
+
this.run();
|
|
104091
|
+
}
|
|
104092
|
+
/**
|
|
104093
|
+
* The HDS stream ended. Releases the subscription, which drains whatever is
|
|
104094
|
+
* already queued and then completes the loop. Idempotent, synchronous, and
|
|
104095
|
+
* safe to call from the delegate's `finally`.
|
|
104096
|
+
*/
|
|
104097
|
+
finish() {
|
|
104098
|
+
this.input.subscription.release();
|
|
104099
|
+
}
|
|
104100
|
+
async run() {
|
|
104101
|
+
const { subscription, sink } = this.input;
|
|
104102
|
+
const tags = { deviceId: this.input.deviceId };
|
|
104103
|
+
try {
|
|
104104
|
+
for await (const packet of subscription.packets()) {
|
|
104105
|
+
if (packet.data.length === 0) continue;
|
|
104106
|
+
if (this.bytes + packet.data.length > this.input.maxBytes) {
|
|
104107
|
+
this.truncated = "size-cap";
|
|
104108
|
+
this.log.warn("hksv clip tee: the per-clip size ceiling was reached — TRUNCATING", {
|
|
104109
|
+
tags,
|
|
104110
|
+
meta: {
|
|
104111
|
+
streamId: this.input.streamId,
|
|
104112
|
+
bytes: this.bytes,
|
|
104113
|
+
maxBytes: this.input.maxBytes,
|
|
104114
|
+
fragments: this.fragments
|
|
104115
|
+
}
|
|
104116
|
+
});
|
|
104117
|
+
break;
|
|
104118
|
+
}
|
|
104119
|
+
await sink.write(packet.data);
|
|
104120
|
+
this.bytes += packet.data.length;
|
|
104121
|
+
if (packet.kind === "init") this.sawInit = true;
|
|
104122
|
+
else this.fragments += 1;
|
|
104123
|
+
}
|
|
104124
|
+
if (this.truncated === null && subscription.closedReason === "slow-consumer") {
|
|
104125
|
+
this.truncated = "slow-consumer";
|
|
104126
|
+
this.log.warn("hksv clip tee: the plane closed the tee for falling behind — the clip is TRUNCATED (HomeKit recording unaffected)", {
|
|
104127
|
+
tags,
|
|
104128
|
+
meta: {
|
|
104129
|
+
streamId: this.input.streamId,
|
|
104130
|
+
bytes: this.bytes,
|
|
104131
|
+
fragments: this.fragments
|
|
104132
|
+
}
|
|
104133
|
+
});
|
|
104134
|
+
}
|
|
104135
|
+
} catch (err) {
|
|
104136
|
+
this.truncated = "write-failed";
|
|
104137
|
+
this.log.warn("hksv clip tee: writing the clip FAILED — the HomeKit recording is unaffected", {
|
|
104138
|
+
tags,
|
|
104139
|
+
meta: {
|
|
104140
|
+
streamId: this.input.streamId,
|
|
104141
|
+
bytes: this.bytes,
|
|
104142
|
+
fragments: this.fragments,
|
|
104143
|
+
error: err instanceof Error ? err.message : String(err)
|
|
104144
|
+
}
|
|
104145
|
+
});
|
|
104146
|
+
} finally {
|
|
104147
|
+
subscription.release();
|
|
104148
|
+
try {
|
|
104149
|
+
await sink.close();
|
|
104150
|
+
} catch (err) {
|
|
104151
|
+
if (this.truncated === null) this.truncated = "write-failed";
|
|
104152
|
+
this.log.warn("hksv clip tee: closing the clip file failed", {
|
|
104153
|
+
tags,
|
|
104154
|
+
meta: {
|
|
104155
|
+
streamId: this.input.streamId,
|
|
104156
|
+
error: err instanceof Error ? err.message : String(err)
|
|
104157
|
+
}
|
|
104158
|
+
});
|
|
104159
|
+
}
|
|
104160
|
+
this.resolve();
|
|
104161
|
+
}
|
|
104162
|
+
}
|
|
104163
|
+
resolve() {
|
|
104164
|
+
const settle = this.settle;
|
|
104165
|
+
this.settle = null;
|
|
104166
|
+
settle?.({
|
|
104167
|
+
bytes: this.bytes,
|
|
104168
|
+
fragments: this.fragments,
|
|
104169
|
+
sawInit: this.sawInit,
|
|
104170
|
+
truncated: this.truncated,
|
|
104171
|
+
startedAtMs: this.startedAtMs,
|
|
104172
|
+
endedAtMs: this.input.now()
|
|
104173
|
+
});
|
|
104174
|
+
}
|
|
104175
|
+
};
|
|
104176
|
+
//#endregion
|
|
104177
|
+
//#region src/hksv/clip-tee.ts
|
|
104178
|
+
function createClipTeeFactory(input) {
|
|
104179
|
+
return (request) => new ClipTeeRunner(input, request);
|
|
104180
|
+
}
|
|
104181
|
+
var ClipTeeRunner = class {
|
|
104182
|
+
input;
|
|
104183
|
+
request;
|
|
104184
|
+
writer = null;
|
|
104185
|
+
/** `finish()` arriving before the file is open — the short stream case. */
|
|
104186
|
+
finishPending = false;
|
|
104187
|
+
acknowledgePending = false;
|
|
104188
|
+
clipId = null;
|
|
104189
|
+
completed;
|
|
104190
|
+
constructor(input, request) {
|
|
104191
|
+
this.input = input;
|
|
104192
|
+
this.request = request;
|
|
104193
|
+
this.completed = this.run();
|
|
104194
|
+
}
|
|
104195
|
+
finish() {
|
|
104196
|
+
this.finishPending = true;
|
|
104197
|
+
this.writer?.finish();
|
|
104198
|
+
}
|
|
104199
|
+
acknowledge() {
|
|
104200
|
+
this.acknowledgePending = true;
|
|
104201
|
+
this.completed.then(() => this.persistAcknowledgement());
|
|
104202
|
+
}
|
|
104203
|
+
async run() {
|
|
104204
|
+
const { store, deviceId } = this.input;
|
|
104205
|
+
const now = this.input.now ?? Date.now;
|
|
104206
|
+
const tags = { deviceId };
|
|
104207
|
+
const open = await store.open({
|
|
104208
|
+
deviceId,
|
|
104209
|
+
streamId: this.request.streamId,
|
|
104210
|
+
startedAtMs: now(),
|
|
104211
|
+
prebufferSpanMs: this.request.prebufferSpanMs,
|
|
104212
|
+
width: this.input.width,
|
|
104213
|
+
height: this.input.height,
|
|
104214
|
+
fragmentMs: this.input.fragmentMs
|
|
104215
|
+
});
|
|
104216
|
+
if (open === null) {
|
|
104217
|
+
this.request.subscription.release();
|
|
104218
|
+
return;
|
|
104219
|
+
}
|
|
104220
|
+
this.clipId = open.clipId;
|
|
104221
|
+
const writer = new HksvClipWriter({
|
|
104222
|
+
logger: this.input.logger,
|
|
104223
|
+
deviceId,
|
|
104224
|
+
streamId: this.request.streamId,
|
|
104225
|
+
subscription: this.request.subscription,
|
|
104226
|
+
sink: open.sink,
|
|
104227
|
+
maxBytes: store.maxClipBytes,
|
|
104228
|
+
now
|
|
104229
|
+
});
|
|
104230
|
+
this.writer = writer;
|
|
104231
|
+
writer.start();
|
|
104232
|
+
if (this.finishPending) writer.finish();
|
|
104233
|
+
const outcome = await writer.done;
|
|
104234
|
+
try {
|
|
104235
|
+
await open.complete(outcome);
|
|
104236
|
+
} catch (err) {
|
|
104237
|
+
this.input.logger.warn("hksv clip tee: the clip could not be completed", {
|
|
104238
|
+
tags,
|
|
104239
|
+
meta: {
|
|
104240
|
+
streamId: this.request.streamId,
|
|
104241
|
+
error: err instanceof Error ? err.message : String(err)
|
|
104242
|
+
}
|
|
104243
|
+
});
|
|
104244
|
+
}
|
|
104245
|
+
}
|
|
104246
|
+
async persistAcknowledgement() {
|
|
104247
|
+
const clipId = this.clipId;
|
|
104248
|
+
if (clipId === null || !this.acknowledgePending) return;
|
|
104249
|
+
this.acknowledgePending = false;
|
|
104250
|
+
try {
|
|
104251
|
+
await this.input.store.acknowledge(this.input.deviceId, clipId);
|
|
104252
|
+
} catch (err) {
|
|
104253
|
+
this.input.logger.warn("hksv clip tee: recording the acknowledgement failed", {
|
|
104254
|
+
tags: { deviceId: this.input.deviceId },
|
|
104255
|
+
meta: {
|
|
104256
|
+
clipId,
|
|
104257
|
+
error: err instanceof Error ? err.message : String(err)
|
|
104258
|
+
}
|
|
104259
|
+
});
|
|
104260
|
+
}
|
|
104261
|
+
}
|
|
104262
|
+
};
|
|
104263
|
+
//#endregion
|
|
102243
104264
|
//#region src/hksv/controller-census.ts
|
|
102244
104265
|
var EMPTY_CENSUS = {
|
|
102245
104266
|
serverPublished: false,
|
|
@@ -102561,8 +104582,13 @@ var HksvFragmentSource = class {
|
|
|
102561
104582
|
* Returns `null` when there is no plane — the caller MUST treat that as
|
|
102562
104583
|
* "cannot serve this recording" rather than opening an HDS stream it cannot
|
|
102563
104584
|
* feed, which is the D50 failure exactly.
|
|
104585
|
+
*
|
|
104586
|
+
* Called ONCE PER CONSUMER, never shared: the delegate takes one for HomeKit
|
|
104587
|
+
* and the clip tee takes a second. `queueCapacity` is how much slack this
|
|
104588
|
+
* subscriber gets before the plane closes it (D80 closes, never gaps) — the
|
|
104589
|
+
* tee asks for less than HomeKit's stream on purpose.
|
|
102564
104590
|
*/
|
|
102565
|
-
subscribe(tag) {
|
|
104591
|
+
subscribe(tag, queueCapacity) {
|
|
102566
104592
|
const plane = this.plane;
|
|
102567
104593
|
if (plane === null || plane.isEnded) return null;
|
|
102568
104594
|
const stats = plane.prebufferStats();
|
|
@@ -102577,7 +104603,8 @@ var HksvFragmentSource = class {
|
|
|
102577
104603
|
});
|
|
102578
104604
|
return plane.subscribe({
|
|
102579
104605
|
tag,
|
|
102580
|
-
withPrebuffer: true
|
|
104606
|
+
withPrebuffer: true,
|
|
104607
|
+
...queueCapacity === void 0 ? {} : { queueCapacity }
|
|
102581
104608
|
});
|
|
102582
104609
|
}
|
|
102583
104610
|
/** What the ring holds — surfaced so the delegate can log what it served. */
|
|
@@ -102701,6 +104728,12 @@ var HksvFragmentSource = class {
|
|
|
102701
104728
|
//#endregion
|
|
102702
104729
|
//#region src/hksv/recording-delegate.ts
|
|
102703
104730
|
/**
|
|
104731
|
+
* How many finished clips may wait for an acknowledgement iOS may never send.
|
|
104732
|
+
* Eight is more concurrent HKSV streams than one camera ever has; the number
|
|
104733
|
+
* exists so the map cannot grow, not to be tuned.
|
|
104734
|
+
*/
|
|
104735
|
+
var MAX_TEES_AWAITING_ACK = 8;
|
|
104736
|
+
/**
|
|
102704
104737
|
* `HDSProtocolSpecificErrorReason` is a `const enum`, so there is no reverse
|
|
102705
104738
|
* map to index — and a bare number in the log is the difference between "iOS
|
|
102706
104739
|
* closed it normally" and "iOS rejected our data", which is the whole reason
|
|
@@ -102729,6 +104762,12 @@ var HksvRecordingDelegate = class {
|
|
|
102729
104762
|
log;
|
|
102730
104763
|
/** The stream currently being yielded, so `closeRecordingStream` can end it. */
|
|
102731
104764
|
open = null;
|
|
104765
|
+
/**
|
|
104766
|
+
* Tees whose stream has ended, still waiting for iOS's `ack`. BOUNDED: the
|
|
104767
|
+
* ack may never come (a dropped connection looks identical), and a map that
|
|
104768
|
+
* only ever grows is the unbounded buffer D9/D18 names in its smallest form.
|
|
104769
|
+
*/
|
|
104770
|
+
tees = /* @__PURE__ */ new Map();
|
|
102732
104771
|
constructor(input) {
|
|
102733
104772
|
this.input = input;
|
|
102734
104773
|
this.log = input.logger;
|
|
@@ -102809,6 +104848,7 @@ var HksvRecordingDelegate = class {
|
|
|
102809
104848
|
};
|
|
102810
104849
|
const startedAt = this.input.now();
|
|
102811
104850
|
const prebufferSpanMs = source.prebufferSpanMs();
|
|
104851
|
+
const tee = this.openTee(streamId, source, prebufferSpanMs);
|
|
102812
104852
|
let packets = 0;
|
|
102813
104853
|
let bytes = 0;
|
|
102814
104854
|
let markedLast = false;
|
|
@@ -102860,6 +104900,7 @@ var HksvRecordingDelegate = class {
|
|
|
102860
104900
|
}
|
|
102861
104901
|
} finally {
|
|
102862
104902
|
subscription.release();
|
|
104903
|
+
this.finishTee(streamId, tee);
|
|
102863
104904
|
if (this.open?.streamId === streamId) this.open = null;
|
|
102864
104905
|
const fragmentOverrun = longestFragmentGapMs > this.input.advertisedFragmentMs * 1.5;
|
|
102865
104906
|
this.log.info("hksv: recording stream finished", {
|
|
@@ -102890,6 +104931,20 @@ var HksvRecordingDelegate = class {
|
|
|
102890
104931
|
tags: { deviceId: this.input.deviceId },
|
|
102891
104932
|
meta: { streamId }
|
|
102892
104933
|
});
|
|
104934
|
+
const tee = this.tees.get(streamId);
|
|
104935
|
+
this.tees.delete(streamId);
|
|
104936
|
+
if (tee === void 0) return;
|
|
104937
|
+
try {
|
|
104938
|
+
tee.acknowledge();
|
|
104939
|
+
} catch (err) {
|
|
104940
|
+
this.log.warn("hksv clip tee: recording the acknowledgement failed", {
|
|
104941
|
+
tags: { deviceId: this.input.deviceId },
|
|
104942
|
+
meta: {
|
|
104943
|
+
streamId,
|
|
104944
|
+
error: err instanceof Error ? err.message : String(err)
|
|
104945
|
+
}
|
|
104946
|
+
});
|
|
104947
|
+
}
|
|
102893
104948
|
}
|
|
102894
104949
|
closeRecordingStream(streamId, reason) {
|
|
102895
104950
|
this.log.info("hksv: the recording stream was closed by the controller", {
|
|
@@ -102909,11 +104964,82 @@ var HksvRecordingDelegate = class {
|
|
|
102909
104964
|
async dispose() {
|
|
102910
104965
|
this.open?.subscription.release();
|
|
102911
104966
|
this.open = null;
|
|
104967
|
+
this.tees.clear();
|
|
102912
104968
|
const source = this.source;
|
|
102913
104969
|
this.source = null;
|
|
102914
104970
|
if (source) await source.stop("accessory disposed");
|
|
102915
104971
|
}
|
|
102916
104972
|
/**
|
|
104973
|
+
* A second subscription on the same plane, handed to the tee.
|
|
104974
|
+
*
|
|
104975
|
+
* Every failure here returns `null`: no tee, a warn naming the camera, and a
|
|
104976
|
+
* HomeKit recording that proceeds exactly as it would have. The one thing
|
|
104977
|
+
* this must never do is let a clip problem reach the generator above.
|
|
104978
|
+
*/
|
|
104979
|
+
openTee(streamId, source, prebufferSpanMs) {
|
|
104980
|
+
const factory = this.input.openClipTee;
|
|
104981
|
+
if (factory === void 0) return null;
|
|
104982
|
+
let subscription = null;
|
|
104983
|
+
try {
|
|
104984
|
+
subscription = source.subscribe(`clip/${this.input.deviceId}#${streamId}`, 2);
|
|
104985
|
+
if (subscription === null) {
|
|
104986
|
+
this.log.warn("hksv clip tee: the plane refused a second subscription — this clip is NOT kept", {
|
|
104987
|
+
tags: { deviceId: this.input.deviceId },
|
|
104988
|
+
meta: { streamId }
|
|
104989
|
+
});
|
|
104990
|
+
return null;
|
|
104991
|
+
}
|
|
104992
|
+
const tee = factory({
|
|
104993
|
+
streamId,
|
|
104994
|
+
subscription,
|
|
104995
|
+
prebufferSpanMs
|
|
104996
|
+
});
|
|
104997
|
+
if (tee === null) {
|
|
104998
|
+
subscription.release();
|
|
104999
|
+
return null;
|
|
105000
|
+
}
|
|
105001
|
+
this.rememberTee(streamId, tee);
|
|
105002
|
+
return tee;
|
|
105003
|
+
} catch (err) {
|
|
105004
|
+
subscription?.release();
|
|
105005
|
+
this.log.warn("hksv clip tee: could NOT be opened — the HomeKit recording continues untouched", {
|
|
105006
|
+
tags: { deviceId: this.input.deviceId },
|
|
105007
|
+
meta: {
|
|
105008
|
+
streamId,
|
|
105009
|
+
error: err instanceof Error ? err.message : String(err)
|
|
105010
|
+
}
|
|
105011
|
+
});
|
|
105012
|
+
return null;
|
|
105013
|
+
}
|
|
105014
|
+
}
|
|
105015
|
+
finishTee(streamId, tee) {
|
|
105016
|
+
if (tee === null) return;
|
|
105017
|
+
try {
|
|
105018
|
+
tee.finish();
|
|
105019
|
+
} catch (err) {
|
|
105020
|
+
this.log.warn("hksv clip tee: closing the clip failed", {
|
|
105021
|
+
tags: { deviceId: this.input.deviceId },
|
|
105022
|
+
meta: {
|
|
105023
|
+
streamId,
|
|
105024
|
+
error: err instanceof Error ? err.message : String(err)
|
|
105025
|
+
}
|
|
105026
|
+
});
|
|
105027
|
+
}
|
|
105028
|
+
}
|
|
105029
|
+
/**
|
|
105030
|
+
* Hold the tee until iOS acknowledges, dropping the oldest once the map is
|
|
105031
|
+
* full. An ack that never arrives is the normal case for a dropped
|
|
105032
|
+
* connection, so this map's size is the only thing bounding it.
|
|
105033
|
+
*/
|
|
105034
|
+
rememberTee(streamId, tee) {
|
|
105035
|
+
this.tees.set(streamId, tee);
|
|
105036
|
+
while (this.tees.size > MAX_TEES_AWAITING_ACK) {
|
|
105037
|
+
const oldest = this.tees.keys().next();
|
|
105038
|
+
if (oldest.done === true) break;
|
|
105039
|
+
this.tees.delete(oldest.value);
|
|
105040
|
+
}
|
|
105041
|
+
}
|
|
105042
|
+
/**
|
|
102917
105043
|
* Start the prebuffer when iOS wants recording AND has chosen how, stop it
|
|
102918
105044
|
* otherwise. Called from every state edge rather than each edge deciding for
|
|
102919
105045
|
* itself: the two characteristics arrive in an order hap-nodejs explicitly
|
|
@@ -103083,10 +105209,20 @@ async function buildHksvRecording(input) {
|
|
|
103083
105209
|
fps,
|
|
103084
105210
|
fragmentLengthMs
|
|
103085
105211
|
});
|
|
105212
|
+
const clipStore = bctx.options.hksvClipStore;
|
|
105213
|
+
const openClipTee = clipStore === null ? void 0 : createClipTeeFactory({
|
|
105214
|
+
logger: log,
|
|
105215
|
+
deviceId: numericDeviceId,
|
|
105216
|
+
store: clipStore,
|
|
105217
|
+
width: source.width,
|
|
105218
|
+
height: source.height,
|
|
105219
|
+
fragmentMs: fragmentLengthMs
|
|
105220
|
+
});
|
|
103086
105221
|
const delegate = new HksvRecordingDelegate({
|
|
103087
105222
|
logger: log,
|
|
103088
105223
|
deviceId: numericDeviceId,
|
|
103089
105224
|
isAudioActive: input.isAudioActive,
|
|
105225
|
+
...openClipTee === void 0 ? {} : { openClipTee },
|
|
103090
105226
|
advertisedFragmentMs: fragmentLengthMs,
|
|
103091
105227
|
now: () => Date.now(),
|
|
103092
105228
|
describeControllers: () => describeHapControllers(bctx.accessory),
|
|
@@ -103108,7 +105244,8 @@ async function buildHksvRecording(input) {
|
|
|
103108
105244
|
measuredFps: fps,
|
|
103109
105245
|
advertisedFps: advertisedResolution?.[2] ?? null,
|
|
103110
105246
|
fragmentLengthMs,
|
|
103111
|
-
sourceGopMs: gopMs ?? "unknown"
|
|
105247
|
+
sourceGopMs: gopMs ?? "unknown",
|
|
105248
|
+
clipsKept: openClipTee !== void 0
|
|
103112
105249
|
} });
|
|
103113
105250
|
return {
|
|
103114
105251
|
options,
|
|
@@ -103133,133 +105270,30 @@ async function readProfileEntries(bctx) {
|
|
|
103133
105270
|
* on 615 `ext` is the 1280×720 slot the broker calls `mid` — a name-based match
|
|
103134
105271
|
* would silently read the 4K slot's GOP for a 720p recording.
|
|
103135
105272
|
*
|
|
103136
|
-
* `undefined` when the cap is not bound, which is most non-Hikvision providers.
|
|
103137
|
-
*/
|
|
103138
|
-
async function readSourceGopMs(bctx, width, height) {
|
|
103139
|
-
try {
|
|
103140
|
-
const status = await bctx.proxy.streamParams?.getStatus({});
|
|
103141
|
-
if (!status) return void 0;
|
|
103142
|
-
for (const profile of [
|
|
103143
|
-
status.main,
|
|
103144
|
-
status.sub,
|
|
103145
|
-
status.ext
|
|
103146
|
-
]) {
|
|
103147
|
-
if (!profile) continue;
|
|
103148
|
-
if (profile.width !== width || profile.height !== height) continue;
|
|
103149
|
-
const { gop, framerate } = profile;
|
|
103150
|
-
if (gop === void 0 || gop <= 0 || framerate <= 0) return void 0;
|
|
103151
|
-
return Math.round(gop / framerate * 1e3);
|
|
103152
|
-
}
|
|
103153
|
-
return;
|
|
103154
|
-
} catch {
|
|
103155
|
-
return;
|
|
103156
|
-
}
|
|
103157
|
-
}
|
|
103158
|
-
function resolveFps(fpsByProfile, profile) {
|
|
103159
|
-
return fpsByProfile.get(profile)?.fps ?? ASSUMED_RECORDING_FPS;
|
|
103160
|
-
}
|
|
103161
|
-
//#endregion
|
|
103162
|
-
//#region src/mappers/builders/child-switch.ts
|
|
103163
|
-
/**
|
|
103164
|
-
* Child-switch builder — turns a camstack accessory child device (siren,
|
|
103165
|
-
* floodlight, spotlight, …) into a HomeKit Service ON THE PARENT
|
|
103166
|
-
* camera Accessory.
|
|
103167
|
-
*
|
|
103168
|
-
* The orchestrator (`child-accessory.ts → buildChildServicesFor`)
|
|
103169
|
-
* hands this builder the PARENT accessory + the CHILD's proxy + a
|
|
103170
|
-
* unique subtype so each service round-trips deterministically through
|
|
103171
|
-
* HAP serialisation. The builder:
|
|
103172
|
-
* - Maps `switch` cap → `Service.Switch`.
|
|
103173
|
-
* - Maps `switch` + `brightness` cap → `Service.Lightbulb` with
|
|
103174
|
-
* Brightness characteristic (lighting devices only — see below).
|
|
103175
|
-
* - Hydrates initial state from `getStatus({})`.
|
|
103176
|
-
* - Wires `Characteristic.On` / `Characteristic.Brightness` through
|
|
103177
|
-
* the typed DeviceProxy.
|
|
103178
|
-
* - Subscribes to runtime-state slice updates so iOS Home reflects
|
|
103179
|
-
* external state changes (e.g. another integration toggling the
|
|
103180
|
-
* light).
|
|
103181
|
-
*
|
|
103182
|
-
* Service-kind selection: most cameras expose `brightness` on a siren
|
|
103183
|
-
* because it controls the alarm VOLUME, not luminosity. Mapping a
|
|
103184
|
-
* siren onto `Service.Lightbulb` makes it appear as a regular light in
|
|
103185
|
-
* iOS Home (wrong icon, wrong scenes, wrong Siri intents). We therefore
|
|
103186
|
-
* gate `Service.Lightbulb` on the child's `DeviceType` — only Light /
|
|
103187
|
-
* Generic devices keep the Lightbulb shape; Siren, Switch, and others
|
|
103188
|
-
* fall back to `Service.Switch`. Volume control for sirens is exposed
|
|
103189
|
-
* through the camstack admin UI; iOS Home only needs the on/off toggle.
|
|
103190
|
-
*
|
|
103191
|
-
* Child devices without `switch` / `brightness` cap (status sensors,
|
|
103192
|
-
* IR LED, etc.) return a no-op handle.
|
|
105273
|
+
* `undefined` when the cap is not bound, which is most non-Hikvision providers.
|
|
103193
105274
|
*/
|
|
103194
|
-
async function
|
|
103195
|
-
const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
|
|
103196
|
-
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
103197
|
-
const hasSwitch = !!proxy.switch;
|
|
103198
|
-
const hasBrightness = !!proxy.brightness;
|
|
103199
|
-
if (!hasSwitch && !hasBrightness) {
|
|
103200
|
-
log.debug("export-hap: child has no switch/brightness cap — skipping");
|
|
103201
|
-
return { async dispose() {} };
|
|
103202
|
-
}
|
|
103203
|
-
const isLightingDevice = deviceType === DeviceType.Light || deviceType === DeviceType.Generic;
|
|
103204
|
-
const useLightbulb = hasBrightness && isLightingDevice;
|
|
103205
|
-
const service = useLightbulb ? accessory.addService(import_dist.Service.Lightbulb, displayName, subtype) : accessory.addService(import_dist.Service.Switch, displayName, subtype);
|
|
103206
|
-
applyServiceLabel(service, displayName);
|
|
105275
|
+
async function readSourceGopMs(bctx, width, height) {
|
|
103207
105276
|
try {
|
|
103208
|
-
const
|
|
103209
|
-
if (
|
|
103210
|
-
|
|
103211
|
-
|
|
103212
|
-
|
|
103213
|
-
|
|
103214
|
-
|
|
103215
|
-
|
|
103216
|
-
|
|
103217
|
-
|
|
103218
|
-
|
|
103219
|
-
|
|
103220
|
-
const on = value === true;
|
|
103221
|
-
try {
|
|
103222
|
-
await proxy.switch?.setState({ on });
|
|
103223
|
-
} catch (err) {
|
|
103224
|
-
log.warn("export-hap: child switch.setState failed", { meta: {
|
|
103225
|
-
on,
|
|
103226
|
-
error: errMsg$7(err)
|
|
103227
|
-
} });
|
|
103228
|
-
}
|
|
103229
|
-
});
|
|
103230
|
-
if (useLightbulb) service.getCharacteristic(import_dist.Characteristic.Brightness).onSet(async (value) => {
|
|
103231
|
-
const percentage = typeof value === "number" ? value : Number(value);
|
|
103232
|
-
if (!Number.isFinite(percentage)) return;
|
|
103233
|
-
try {
|
|
103234
|
-
await proxy.brightness?.setBrightness({ percentage });
|
|
103235
|
-
} catch (err) {
|
|
103236
|
-
log.warn("export-hap: child brightness.setBrightness failed", { meta: {
|
|
103237
|
-
percentage,
|
|
103238
|
-
error: errMsg$7(err)
|
|
103239
|
-
} });
|
|
105277
|
+
const status = await bctx.proxy.streamParams?.getStatus({});
|
|
105278
|
+
if (!status) return void 0;
|
|
105279
|
+
for (const profile of [
|
|
105280
|
+
status.main,
|
|
105281
|
+
status.sub,
|
|
105282
|
+
status.ext
|
|
105283
|
+
]) {
|
|
105284
|
+
if (!profile) continue;
|
|
105285
|
+
if (profile.width !== width || profile.height !== height) continue;
|
|
105286
|
+
const { gop, framerate } = profile;
|
|
105287
|
+
if (gop === void 0 || gop <= 0 || framerate <= 0) return void 0;
|
|
105288
|
+
return Math.round(gop / framerate * 1e3);
|
|
103240
105289
|
}
|
|
103241
|
-
|
|
103242
|
-
|
|
103243
|
-
|
|
103244
|
-
const unsubSwitch = proxy.state.switch.subscribe((value) => {
|
|
103245
|
-
if (value && typeof value.on === "boolean") service.updateCharacteristic(import_dist.Characteristic.On, value.on);
|
|
103246
|
-
});
|
|
103247
|
-
unsubscribes.push(unsubSwitch);
|
|
103248
|
-
}
|
|
103249
|
-
if (useLightbulb && proxy.state.brightness) {
|
|
103250
|
-
const unsubBrightness = proxy.state.brightness.subscribe((value) => {
|
|
103251
|
-
if (value && typeof value.percentage === "number") service.updateCharacteristic(import_dist.Characteristic.Brightness, value.percentage);
|
|
103252
|
-
});
|
|
103253
|
-
unsubscribes.push(unsubBrightness);
|
|
105290
|
+
return;
|
|
105291
|
+
} catch {
|
|
105292
|
+
return;
|
|
103254
105293
|
}
|
|
103255
|
-
return { async dispose() {
|
|
103256
|
-
for (const u of unsubscribes) try {
|
|
103257
|
-
u();
|
|
103258
|
-
} catch {}
|
|
103259
|
-
} };
|
|
103260
105294
|
}
|
|
103261
|
-
function
|
|
103262
|
-
return
|
|
105295
|
+
function resolveFps(fpsByProfile, profile) {
|
|
105296
|
+
return fpsByProfile.get(profile)?.fps ?? ASSUMED_RECORDING_FPS;
|
|
103263
105297
|
}
|
|
103264
105298
|
//#endregion
|
|
103265
105299
|
//#region src/mappers/child-accessory.ts
|
|
@@ -103285,7 +105319,7 @@ async function buildChildServicesFor(input) {
|
|
|
103285
105319
|
for (const h of handles) try {
|
|
103286
105320
|
await h.dispose();
|
|
103287
105321
|
} catch (err) {
|
|
103288
|
-
ctx.logger.withTags({ deviceId: parentNumericId }).debug("export-hap: child service dispose failed (continuing)", { meta: { error: errMsg$
|
|
105322
|
+
ctx.logger.withTags({ deviceId: parentNumericId }).debug("export-hap: child service dispose failed (continuing)", { meta: { error: errMsg$3(err) } });
|
|
103289
105323
|
}
|
|
103290
105324
|
} };
|
|
103291
105325
|
}
|
|
@@ -103301,7 +105335,7 @@ async function listChildren(ctx, parentNumericId) {
|
|
|
103301
105335
|
features: Array.isArray(c.features) ? c.features.filter((f) => typeof f === "string") : []
|
|
103302
105336
|
}));
|
|
103303
105337
|
} catch (err) {
|
|
103304
|
-
ctx.logger.withTags({ deviceId: parentNumericId }).debug("export-hap: deviceManager.getChildren failed (non-fatal)", { meta: { error: errMsg$
|
|
105338
|
+
ctx.logger.withTags({ deviceId: parentNumericId }).debug("export-hap: deviceManager.getChildren failed (non-fatal)", { meta: { error: errMsg$3(err) } });
|
|
103305
105339
|
return [];
|
|
103306
105340
|
}
|
|
103307
105341
|
}
|
|
@@ -103317,7 +105351,7 @@ function asDeviceType(raw) {
|
|
|
103317
105351
|
for (const value of Object.values(DeviceType)) if (value === lower) return value;
|
|
103318
105352
|
return DeviceType.Generic;
|
|
103319
105353
|
}
|
|
103320
|
-
function errMsg$
|
|
105354
|
+
function errMsg$3(err) {
|
|
103321
105355
|
return err instanceof Error ? err.message : String(err);
|
|
103322
105356
|
}
|
|
103323
105357
|
//#endregion
|
|
@@ -103914,1035 +105948,389 @@ function buildMultiTierServices(input) {
|
|
|
103914
105948
|
logger.info("hap multi-tier: refused a setup-endpoints write", {
|
|
103915
105949
|
tags: { deviceId },
|
|
103916
105950
|
meta: { reason: input.negotiation === void 0 ? "no-negotiator" : "not-a-string" }
|
|
103917
|
-
});
|
|
103918
|
-
return;
|
|
103919
|
-
}
|
|
103920
|
-
await input.negotiation.handleSetupEndpointsWrite(value);
|
|
103921
|
-
});
|
|
103922
|
-
streamManagement.addCharacteristic(setupEndpoints);
|
|
103923
|
-
const streamingControl = new import_dist.Characteristic("RTP Streaming Control", CHAR_RTP_STREAMING_CONTROL, {
|
|
103924
|
-
format: import_dist.Formats.TLV8,
|
|
103925
|
-
perms: [import_dist.Perms.PAIRED_READ, import_dist.Perms.PAIRED_WRITE]
|
|
103926
|
-
});
|
|
103927
|
-
streamingControl.onGet(() => {
|
|
103928
|
-
note("rtp-streaming-control");
|
|
103929
|
-
return input.negotiation?.readStreamingControl() ?? encodeRtpStreamingControlStatus(null, RtpStreamingStatus.ERROR);
|
|
103930
|
-
});
|
|
103931
|
-
streamingControl.onSet(async (value) => {
|
|
103932
|
-
if (input.negotiation === void 0 || typeof value !== "string") {
|
|
103933
|
-
logger.info("hap multi-tier: refused an rtp streaming control write", {
|
|
103934
|
-
tags: { deviceId },
|
|
103935
|
-
meta: { reason: input.negotiation === void 0 ? "no-negotiator" : "not-a-string" }
|
|
103936
|
-
});
|
|
103937
|
-
return;
|
|
103938
|
-
}
|
|
103939
|
-
await input.negotiation.handleStreamingControlWrite(value);
|
|
103940
|
-
});
|
|
103941
|
-
streamManagement.addCharacteristic(streamingControl);
|
|
103942
|
-
const sensor = new import_dist.Characteristic("Sensor UUID", CHAR_SENSOR_UUID, {
|
|
103943
|
-
format: import_dist.Formats.DATA,
|
|
103944
|
-
perms: [import_dist.Perms.PAIRED_READ]
|
|
103945
|
-
});
|
|
103946
|
-
const sensorValue = sensorUuid.toString("base64");
|
|
103947
|
-
sensor.onGet(() => {
|
|
103948
|
-
note("sensor-uuid");
|
|
103949
|
-
return sensorValue;
|
|
103950
|
-
});
|
|
103951
|
-
streamManagement.addCharacteristic(sensor);
|
|
103952
|
-
return {
|
|
103953
|
-
capabilities,
|
|
103954
|
-
globalOperatingMode,
|
|
103955
|
-
streamManagement
|
|
103956
|
-
};
|
|
103957
|
-
}
|
|
103958
|
-
//#endregion
|
|
103959
|
-
//#region src/multi-tier/negotiation.ts
|
|
103960
|
-
/**
|
|
103961
|
-
* The adapter between Apple's new characteristics and the streaming engine this
|
|
103962
|
-
* addon already has.
|
|
103963
|
-
*
|
|
103964
|
-
* ## Why an adapter and not a second engine
|
|
103965
|
-
*
|
|
103966
|
-
* The multi-tier RTP service owns its OWN `Setup Endpoints` and `RTP Streaming
|
|
103967
|
-
* Control`, so hap-nodejs's `CameraController` never negotiates for it — and
|
|
103968
|
-
* the library's own handlers are all `private`, so its engine cannot be
|
|
103969
|
-
* borrowed either. But the sockets, the SRTP contexts, the ffmpeg pipeline and
|
|
103970
|
-
* the teardown are the same ones the classic service uses. A second copy would
|
|
103971
|
-
* be two engines to keep in agreement, and this repo has paid for that shape
|
|
103972
|
-
* before. So: new characteristics, one engine, through `StreamNegotiator`.
|
|
103973
|
-
*
|
|
103974
|
-
* ## What is different about the new flow
|
|
103975
|
-
*
|
|
103976
|
-
* The classic `Selected RTP Stream Configuration` carries the whole selected
|
|
103977
|
-
* configuration in its start — codec, profile, level, resolution, bitrate.
|
|
103978
|
-
* `RTP Streaming Control` carries a **tier identifier**, because the
|
|
103979
|
-
* configuration was agreed when we published `Supported Video Stream Tiers`.
|
|
103980
|
-
* So this maps an identifier back to the tier WE published, and a tier we never
|
|
103981
|
-
* published is `No Such Stream` — improvising a geometry there would serve a
|
|
103982
|
-
* stream the controller did not ask for and it would look like it worked.
|
|
103983
|
-
*
|
|
103984
|
-
* ## Every path answers
|
|
103985
|
-
*
|
|
103986
|
-
* A read of either characteristic always produces a valid TLV8. The Home app
|
|
103987
|
-
* reads a camera's characteristics in one batch, and one member that answers
|
|
103988
|
-
* nothing took the kitchen camera's whole read down and left it on "loading".
|
|
103989
|
-
* So an engine failure, a malformed write and "nothing has happened yet" are
|
|
103990
|
-
* all statuses, never silence.
|
|
103991
|
-
*/
|
|
103992
|
-
/** RTP payload types. 96 is the first dynamic one (RFC 3551); 110 is what our
|
|
103993
|
-
* own Opus offers use. Both are echoed in the tier declaration. */
|
|
103994
|
-
var VIDEO_PAYLOAD_TYPE = 96;
|
|
103995
|
-
var AUDIO_PAYLOAD_TYPE = 110;
|
|
103996
|
-
/** RTCP interval in SECONDS, which is the unit the engine reads. */
|
|
103997
|
-
var RTCP_INTERVAL_SECONDS = 5;
|
|
103998
|
-
/** The MTU the classic path uses for an Ethernet-sized path. */
|
|
103999
|
-
var DEFAULT_MTU = 1378;
|
|
104000
|
-
function errorMessage(error) {
|
|
104001
|
-
return error instanceof Error ? error.message : String(error);
|
|
104002
|
-
}
|
|
104003
|
-
function buildMultiTierNegotiation(input) {
|
|
104004
|
-
const { deviceId, logger, negotiator } = input;
|
|
104005
|
-
const log = logger.withTags({ deviceId });
|
|
104006
|
-
let setupResponse = encodeSetupEndpointsRefusal(SetupEndpointsStatus.ERROR);
|
|
104007
|
-
let controlResponse = encodeRtpStreamingControlStatus(null, RtpStreamingStatus.ERROR);
|
|
104008
|
-
/** Sessions we have STARTED, so an End can tell "not started" from "unknown". */
|
|
104009
|
-
const started = /* @__PURE__ */ new Set();
|
|
104010
|
-
async function handleSetupEndpointsWrite(base64) {
|
|
104011
|
-
let sessionId = null;
|
|
104012
|
-
try {
|
|
104013
|
-
const request = decodeSetupEndpointsRequest(base64);
|
|
104014
|
-
sessionId = request.sessionId;
|
|
104015
|
-
const prepareRequest = {
|
|
104016
|
-
sessionID: request.sessionId,
|
|
104017
|
-
sourceAddress: request.controllerAddress,
|
|
104018
|
-
targetAddress: request.controllerAddress,
|
|
104019
|
-
addressVersion: request.addressVersion,
|
|
104020
|
-
video: {
|
|
104021
|
-
port: request.videoPort,
|
|
104022
|
-
srtpCryptoSuite: import_dist.SRTPCryptoSuites.AES_CM_128_HMAC_SHA1_80,
|
|
104023
|
-
srtp_key: request.videoSrtp.key,
|
|
104024
|
-
srtp_salt: request.videoSrtp.salt
|
|
104025
|
-
},
|
|
104026
|
-
audio: {
|
|
104027
|
-
port: request.audioPort,
|
|
104028
|
-
srtpCryptoSuite: import_dist.SRTPCryptoSuites.AES_CM_128_HMAC_SHA1_80,
|
|
104029
|
-
srtp_key: request.audioSrtp.key,
|
|
104030
|
-
srtp_salt: request.audioSrtp.salt
|
|
104031
|
-
}
|
|
104032
|
-
};
|
|
104033
|
-
const prepared = await negotiator.prepare(prepareRequest);
|
|
104034
|
-
setupResponse = encodeSetupEndpointsResponse({
|
|
104035
|
-
sessionId: request.sessionId,
|
|
104036
|
-
addressVersion: request.addressVersion,
|
|
104037
|
-
accessoryAddress: prepared.localAddress,
|
|
104038
|
-
videoPort: prepared.videoPort,
|
|
104039
|
-
audioPort: prepared.audioPort,
|
|
104040
|
-
videoSrtp: {
|
|
104041
|
-
key: request.videoSrtp.key,
|
|
104042
|
-
salt: request.videoSrtp.salt
|
|
104043
|
-
},
|
|
104044
|
-
audioSrtp: {
|
|
104045
|
-
key: request.audioSrtp.key,
|
|
104046
|
-
salt: request.audioSrtp.salt
|
|
104047
|
-
},
|
|
104048
|
-
videoSsrc: prepared.videoSsrc,
|
|
104049
|
-
audioSsrc: prepared.audioSsrc
|
|
104050
|
-
});
|
|
104051
|
-
log.info("hap multi-tier: endpoints prepared", { meta: {
|
|
104052
|
-
sessionId: request.sessionId,
|
|
104053
|
-
controller: `${request.controllerAddress}:${request.videoPort}/${request.audioPort}`,
|
|
104054
|
-
accessory: `${prepared.localAddress}:${prepared.videoPort}/${prepared.audioPort}`,
|
|
104055
|
-
videoSsrc: prepared.videoSsrc,
|
|
104056
|
-
audioSsrc: prepared.audioSsrc
|
|
104057
|
-
} });
|
|
104058
|
-
} catch (error) {
|
|
104059
|
-
setupResponse = encodeSetupEndpointsRefusal(SetupEndpointsStatus.ERROR);
|
|
104060
|
-
log.warn("hap multi-tier: setup endpoints refused", { meta: {
|
|
104061
|
-
sessionId,
|
|
104062
|
-
error: errorMessage(error)
|
|
104063
|
-
} });
|
|
104064
|
-
}
|
|
104065
|
-
}
|
|
104066
|
-
async function handleStreamingControlWrite(base64) {
|
|
104067
|
-
let sessionId = null;
|
|
104068
|
-
try {
|
|
104069
|
-
const command = decodeRtpStreamingControl(base64);
|
|
104070
|
-
sessionId = command.sessionId;
|
|
104071
|
-
if (!negotiator.has(command.sessionId)) {
|
|
104072
|
-
controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.UNKNOWN_SESSION_IDENTIFIER);
|
|
104073
|
-
log.warn("hap multi-tier: control write for a session that was never prepared", { meta: {
|
|
104074
|
-
sessionId: command.sessionId,
|
|
104075
|
-
command: command.command
|
|
104076
|
-
} });
|
|
104077
|
-
return;
|
|
104078
|
-
}
|
|
104079
|
-
if (command.command === RtpStreamingCommand.END) {
|
|
104080
|
-
if (!started.has(command.sessionId)) {
|
|
104081
|
-
controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.NO_SUCH_STREAM);
|
|
104082
|
-
return;
|
|
104083
|
-
}
|
|
104084
|
-
const stop = {
|
|
104085
|
-
sessionID: command.sessionId,
|
|
104086
|
-
type: import_dist.StreamRequestTypes.STOP
|
|
104087
|
-
};
|
|
104088
|
-
await negotiator.control(stop);
|
|
104089
|
-
started.delete(command.sessionId);
|
|
104090
|
-
controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.SUCCESS);
|
|
104091
|
-
log.info("hap multi-tier: stream ended", { meta: { sessionId: command.sessionId } });
|
|
104092
|
-
return;
|
|
104093
|
-
}
|
|
104094
|
-
const tier = input.tiers.find((t) => t.identifier === command.videoTier);
|
|
104095
|
-
if (tier === void 0) {
|
|
104096
|
-
controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.NO_SUCH_STREAM);
|
|
104097
|
-
log.warn("hap multi-tier: start named a tier we never published", { meta: {
|
|
104098
|
-
sessionId: command.sessionId,
|
|
104099
|
-
requested: command.videoTier,
|
|
104100
|
-
published: input.tiers.map((t) => t.identifier)
|
|
104101
|
-
} });
|
|
104102
|
-
return;
|
|
104103
|
-
}
|
|
104104
|
-
log.info("hap multi-tier: start ssrc comparison", { meta: {
|
|
104105
|
-
sessionId: command.sessionId,
|
|
104106
|
-
controllerVideoSsrc: command.videoSsrc ?? null,
|
|
104107
|
-
controllerAudioSsrc: command.audioSsrc ?? null,
|
|
104108
|
-
note: "accessory keeps the ssrcs it advertised at setup"
|
|
104109
|
-
} });
|
|
104110
|
-
const start = {
|
|
104111
|
-
sessionID: command.sessionId,
|
|
104112
|
-
type: import_dist.StreamRequestTypes.START,
|
|
104113
|
-
video: {
|
|
104114
|
-
codec: import_dist.VideoCodecType.H264,
|
|
104115
|
-
profile: import_dist.H264Profile.HIGH,
|
|
104116
|
-
level: import_dist.H264Level.LEVEL4_0,
|
|
104117
|
-
packetizationMode: import_dist.VideoCodecPacketizationMode.NON_INTERLEAVED,
|
|
104118
|
-
width: tier.width,
|
|
104119
|
-
height: tier.height,
|
|
104120
|
-
fps: tier.frameRate,
|
|
104121
|
-
pt: VIDEO_PAYLOAD_TYPE,
|
|
104122
|
-
ssrc: command.videoSsrc ?? 0,
|
|
104123
|
-
max_bit_rate: tier.targetAverageBitrateKbps,
|
|
104124
|
-
rtcp_interval: RTCP_INTERVAL_SECONDS,
|
|
104125
|
-
mtu: DEFAULT_MTU
|
|
104126
|
-
},
|
|
104127
|
-
audio: {
|
|
104128
|
-
codec: import_dist.AudioStreamingCodecType.OPUS,
|
|
104129
|
-
channel: 1,
|
|
104130
|
-
bit_rate: 24,
|
|
104131
|
-
sample_rate: import_dist.AudioStreamingSamplerate.KHZ_24,
|
|
104132
|
-
packet_time: 20,
|
|
104133
|
-
pt: AUDIO_PAYLOAD_TYPE,
|
|
104134
|
-
ssrc: command.audioSsrc ?? 0,
|
|
104135
|
-
max_bit_rate: 24,
|
|
104136
|
-
rtcp_interval: RTCP_INTERVAL_SECONDS,
|
|
104137
|
-
comfort_pt: 13,
|
|
104138
|
-
comfortNoiseEnabled: false
|
|
104139
|
-
}
|
|
104140
|
-
};
|
|
104141
|
-
await negotiator.control(start);
|
|
104142
|
-
started.add(command.sessionId);
|
|
104143
|
-
controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.SUCCESS);
|
|
104144
|
-
log.info("hap multi-tier: stream started", { meta: {
|
|
104145
|
-
sessionId: command.sessionId,
|
|
104146
|
-
tier: tier.identifier,
|
|
104147
|
-
geometry: `${tier.width}x${tier.height}@${tier.frameRate}`,
|
|
104148
|
-
targetKbps: tier.targetAverageBitrateKbps,
|
|
104149
|
-
codec: input.codec
|
|
104150
|
-
} });
|
|
104151
|
-
} catch (error) {
|
|
104152
|
-
controlResponse = encodeRtpStreamingControlStatus(sessionId, RtpStreamingStatus.ERROR);
|
|
104153
|
-
log.warn("hap multi-tier: control write refused", { meta: {
|
|
104154
|
-
sessionId,
|
|
104155
|
-
error: errorMessage(error)
|
|
104156
|
-
} });
|
|
104157
|
-
}
|
|
104158
|
-
}
|
|
104159
|
-
return {
|
|
104160
|
-
handleSetupEndpointsWrite,
|
|
104161
|
-
readSetupEndpoints: () => setupResponse,
|
|
104162
|
-
handleStreamingControlWrite,
|
|
104163
|
-
readStreamingControl: () => controlResponse
|
|
104164
|
-
};
|
|
104165
|
-
}
|
|
104166
|
-
//#endregion
|
|
104167
|
-
//#region src/mappers/camera-accessory.ts
|
|
104168
|
-
/**
|
|
104169
|
-
* Camera accessory orchestrator — given a camstack deviceId, builds one
|
|
104170
|
-
* HomeKit Accessory representing the camera (or doorbell) plus a
|
|
104171
|
-
* separate Accessory per child device (siren, floodlight, …).
|
|
104172
|
-
*
|
|
104173
|
-
* Flow:
|
|
104174
|
-
* 1. Resolve a typed `DeviceProxy` via `ctx.fetchDevice(deviceId)`.
|
|
104175
|
-
* 2. Inspect `proxy.binding.entries` (or fall back to `getDevice`
|
|
104176
|
-
* features) to decide whether the device is a doorbell vs a
|
|
104177
|
-
* regular camera. Doorbells get `Categories.VIDEO_DOORBELL` +
|
|
104178
|
-
* `DoorbellController`; otherwise `Categories.IP_CAMERA` +
|
|
104179
|
-
* `CameraController`.
|
|
104180
|
-
* 3. Build the streaming delegate via the `camera-streams` builder.
|
|
104181
|
-
* 4. `configureController(controller)` to attach the streaming
|
|
104182
|
-
* services to the Accessory.
|
|
104183
|
-
* 5. Run each feature builder gated on cap presence (motion sensor,
|
|
104184
|
-
* intercom, PTZ, doorbell).
|
|
104185
|
-
* 6. Enumerate children via `proxy.deviceManager.getChildren({...})`
|
|
104186
|
-
* and build one Accessory per child (see `child-accessory.ts`).
|
|
104187
|
-
* 7. Return a `Mapper` whose `accessories[]` lists every accessory
|
|
104188
|
-
* this device produced so the bridge can register them all and
|
|
104189
|
-
* remove them all on unexpose.
|
|
104190
|
-
*/
|
|
104191
|
-
async function buildCameraAccessory(input) {
|
|
104192
|
-
const { ctx, deviceId, displayName, options } = input;
|
|
104193
|
-
const numericId = Number.parseInt(deviceId, 10);
|
|
104194
|
-
if (!Number.isFinite(numericId)) throw new Error(`export-hap: cannot map device '${deviceId}' — id is not numeric`);
|
|
104195
|
-
const proxy = await ctx.fetchDevice(numericId);
|
|
104196
|
-
const capNames = new Set(proxy.binding?.entries.map((e) => e.capName) ?? []);
|
|
104197
|
-
const isDoorbell = capNames.has("doorbell");
|
|
104198
|
-
const category = isDoorbell ? import_dist.Categories.VIDEO_DOORBELL : import_dist.Categories.IP_CAMERA;
|
|
104199
|
-
const accessory = new import_dist.Accessory(displayName, accessoryUuidFor("camera", numericId));
|
|
104200
|
-
accessory.category = category;
|
|
104201
|
-
await populateAccessoryInfo(accessory, proxy, displayName, "Camera");
|
|
104202
|
-
const bctx = {
|
|
104203
|
-
ctx,
|
|
104204
|
-
accessory,
|
|
104205
|
-
proxy,
|
|
104206
|
-
numericDeviceId: numericId,
|
|
104207
|
-
displayName,
|
|
104208
|
-
options
|
|
104209
|
-
};
|
|
104210
|
-
const advertisedVideo = await probeAdvertisedVideoProfile(bctx);
|
|
104211
|
-
const streams = buildCameraStreamingDelegate(bctx, advertisedVideo);
|
|
104212
|
-
const handles = [];
|
|
104213
|
-
if (capNames.has("intercom")) handles.push(await buildIntercom({
|
|
104214
|
-
bctx,
|
|
104215
|
-
streamingOptions: streams.streamingOptions
|
|
104216
|
-
}));
|
|
104217
|
-
const recordingEnabled = options.hapDeviceSettings.hksvRecording === true;
|
|
104218
|
-
let recordingAudioActive = true;
|
|
104219
|
-
const recording = recordingEnabled ? await buildHksvRecording({
|
|
104220
|
-
bctx,
|
|
104221
|
-
fpsByProfile: advertisedVideo.fpsByProfile,
|
|
104222
|
-
isAudioActive: () => recordingAudioActive
|
|
104223
|
-
}) : null;
|
|
104224
|
-
const controller = new (isDoorbell ? import_dist.DoorbellController : import_dist.CameraController)({
|
|
104225
|
-
delegate: streams.delegate,
|
|
104226
|
-
streamingOptions: streams.streamingOptions,
|
|
104227
|
-
cameraStreamCount: 2,
|
|
104228
|
-
...recording === null ? {} : { recording },
|
|
104229
|
-
...recording === null || !capNames.has("motion-detection") ? {} : { sensors: { motion: true } }
|
|
104230
|
-
});
|
|
104231
|
-
accessory.configureController(controller);
|
|
104232
|
-
if (options.hapDeviceSettings.multiTierService !== false) {
|
|
104233
|
-
const sensor = advertisedVideo.sensor;
|
|
104234
|
-
const services = buildMultiTierServices({
|
|
104235
|
-
deviceId: numericId,
|
|
104236
|
-
logger: ctx.logger,
|
|
104237
|
-
width: sensor.width,
|
|
104238
|
-
height: sensor.height,
|
|
104239
|
-
codec: sensor.codec,
|
|
104240
|
-
audioCaptureRateHz: 16e3,
|
|
104241
|
-
negotiation: buildMultiTierNegotiation({
|
|
104242
|
-
deviceId: numericId,
|
|
104243
|
-
logger: ctx.logger,
|
|
104244
|
-
negotiator: streams.negotiator,
|
|
104245
|
-
tiers: tiersForSensor(sensor.width, sensor.height),
|
|
104246
|
-
codec: sensor.codec
|
|
104247
|
-
})
|
|
104248
|
-
});
|
|
104249
|
-
accessory.addService(services.capabilities);
|
|
104250
|
-
accessory.addService(services.globalOperatingMode);
|
|
104251
|
-
accessory.addService(services.streamManagement);
|
|
104252
|
-
ctx.logger.info("hap multi-tier: services declared on this accessory", {
|
|
104253
|
-
tags: { deviceId: numericId },
|
|
104254
|
-
meta: {
|
|
104255
|
-
serviceUuid: MULTI_TIER_RTP_SERVICE_UUID,
|
|
104256
|
-
codec: sensor.codec === TierVideoCodec.H265 ? "h265" : "h264",
|
|
104257
|
-
sensor: `${sensor.width}x${sensor.height}`
|
|
104258
|
-
}
|
|
104259
|
-
});
|
|
104260
|
-
}
|
|
104261
|
-
if (recording !== null) {
|
|
104262
|
-
handles.push({ dispose: () => recording.dispose() });
|
|
104263
|
-
const audioCharacteristic = (controller.recordingManagement?.operatingModeService)?.getCharacteristic(import_dist.Characteristic.RecordingAudioActive);
|
|
104264
|
-
if (audioCharacteristic) {
|
|
104265
|
-
recordingAudioActive = audioCharacteristic.value !== 0 && audioCharacteristic.value !== false;
|
|
104266
|
-
audioCharacteristic.on("change", ({ newValue }) => {
|
|
104267
|
-
recordingAudioActive = newValue !== 0 && newValue !== false;
|
|
104268
|
-
});
|
|
104269
|
-
}
|
|
104270
|
-
}
|
|
104271
|
-
if (capNames.has("motion-detection")) handles.push(await buildMotionSensor(bctx, recording === null ? null : controller.motionService ?? null));
|
|
104272
|
-
if (isDoorbell && controller instanceof import_dist.DoorbellController) handles.push(await buildDoorbell({
|
|
104273
|
-
bctx,
|
|
104274
|
-
controller
|
|
104275
|
-
}));
|
|
104276
|
-
if (capNames.has("ptz") || capNames.has("ptz-autotrack")) handles.push(await buildPtz(bctx));
|
|
104277
|
-
if (capNames.has("battery")) handles.push(await buildBattery(bctx));
|
|
104278
|
-
if (capNames.has("privacy-mask")) handles.push(await buildPrivacySwitch(bctx));
|
|
104279
|
-
handles.push(await buildChildServicesFor({ parentCtx: bctx }));
|
|
104280
|
-
return {
|
|
104281
|
-
accessory,
|
|
104282
|
-
accessories: [accessory],
|
|
104283
|
-
async dispose() {
|
|
104284
|
-
const log = ctx.logger.withTags({ deviceId: numericId });
|
|
104285
|
-
for (const h of handles) try {
|
|
104286
|
-
await h.dispose();
|
|
104287
|
-
} catch (err) {
|
|
104288
|
-
log.debug("export-hap: builder dispose failed (continuing)", { meta: { error: errMsg$5(err) } });
|
|
104289
|
-
}
|
|
104290
|
-
try {
|
|
104291
|
-
await streams.dispose();
|
|
104292
|
-
} catch (err) {
|
|
104293
|
-
log.debug("export-hap: streams dispose failed", { meta: { error: errMsg$5(err) } });
|
|
104294
|
-
}
|
|
104295
|
-
}
|
|
104296
|
-
};
|
|
104297
|
-
}
|
|
104298
|
-
function errMsg$5(err) {
|
|
104299
|
-
return err instanceof Error ? err.message : String(err);
|
|
104300
|
-
}
|
|
104301
|
-
//#endregion
|
|
104302
|
-
//#region src/mappers/builders/generic/lock.ts
|
|
104303
|
-
/**
|
|
104304
|
-
* `lock-control` → `Service.LockMechanism`.
|
|
104305
|
-
*
|
|
104306
|
-
* Not a sensor row: HomeKit models a lock as two characteristics, a CURRENT
|
|
104307
|
-
* state the accessory owns and a TARGET state the controller writes, and the
|
|
104308
|
-
* cap's five states do not map onto either one alone.
|
|
104309
|
-
*
|
|
104310
|
-
* ```
|
|
104311
|
-
* cap state LockCurrentState LockTargetState
|
|
104312
|
-
* locked SECURED SECURED
|
|
104313
|
-
* unlocked UNSECURED UNSECURED
|
|
104314
|
-
* locking UNKNOWN SECURED (in flight — do not claim SECURED)
|
|
104315
|
-
* unlocking UNKNOWN UNSECURED
|
|
104316
|
-
* jammed JAMMED unchanged (the controller's intent stands)
|
|
104317
|
-
* ```
|
|
104318
|
-
*
|
|
104319
|
-
* Reporting SECURED while the bolt is still moving is the failure worth naming:
|
|
104320
|
-
* iOS renders the lock as closed, the operator walks away, and the motor stalls
|
|
104321
|
-
* behind them. `UNKNOWN` is the honest answer for an in-flight transition.
|
|
104322
|
-
*
|
|
104323
|
-
* `open` (the cap's third method, for locks with a latch/buzzer) has no HomeKit
|
|
104324
|
-
* counterpart on this service and is deliberately not wired — a Switch that
|
|
104325
|
-
* silently buzzes a door open would be a second knob nobody asked for.
|
|
104326
|
-
*/
|
|
104327
|
-
var SUBTYPE$1 = "lock-control";
|
|
104328
|
-
var readLock = reader(LockControlStatusSchema.pick({ state: true }), (status) => {
|
|
104329
|
-
const current = status.state === "locked" ? import_dist.Characteristic.LockCurrentState.SECURED : status.state === "unlocked" ? import_dist.Characteristic.LockCurrentState.UNSECURED : status.state === "jammed" ? import_dist.Characteristic.LockCurrentState.JAMMED : import_dist.Characteristic.LockCurrentState.UNKNOWN;
|
|
104330
|
-
const target = status.state === "locked" || status.state === "locking" ? import_dist.Characteristic.LockTargetState.SECURED : status.state === "unlocked" || status.state === "unlocking" ? import_dist.Characteristic.LockTargetState.UNSECURED : null;
|
|
104331
|
-
return [{
|
|
104332
|
-
characteristic: import_dist.Characteristic.LockCurrentState,
|
|
104333
|
-
value: current
|
|
104334
|
-
}, ...target === null ? [] : [{
|
|
104335
|
-
characteristic: import_dist.Characteristic.LockTargetState,
|
|
104336
|
-
value: target
|
|
104337
|
-
}]];
|
|
104338
|
-
});
|
|
104339
|
-
async function buildLockMechanism(bctx) {
|
|
104340
|
-
const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
|
|
104341
|
-
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
104342
|
-
const label = hapServiceName([displayName], `Lock ${numericDeviceId}`);
|
|
104343
|
-
const service = accessory.addService(import_dist.Service.LockMechanism, label, SUBTYPE$1);
|
|
104344
|
-
applyServiceLabel(service, label);
|
|
104345
|
-
const apply = (status, source) => {
|
|
104346
|
-
const updates = readLock(status);
|
|
104347
|
-
if (updates.length === 0) {
|
|
104348
|
-
log.debug("export-hap: lock status did not match the cap schema — no update", { meta: { source } });
|
|
105951
|
+
});
|
|
104349
105952
|
return;
|
|
104350
105953
|
}
|
|
104351
|
-
|
|
104352
|
-
};
|
|
104353
|
-
|
|
104354
|
-
|
|
104355
|
-
|
|
104356
|
-
|
|
104357
|
-
|
|
104358
|
-
|
|
104359
|
-
|
|
104360
|
-
|
|
104361
|
-
|
|
104362
|
-
|
|
104363
|
-
|
|
104364
|
-
|
|
104365
|
-
|
|
104366
|
-
|
|
104367
|
-
|
|
104368
|
-
|
|
105954
|
+
await input.negotiation.handleSetupEndpointsWrite(value);
|
|
105955
|
+
});
|
|
105956
|
+
streamManagement.addCharacteristic(setupEndpoints);
|
|
105957
|
+
const streamingControl = new import_dist.Characteristic("RTP Streaming Control", CHAR_RTP_STREAMING_CONTROL, {
|
|
105958
|
+
format: import_dist.Formats.TLV8,
|
|
105959
|
+
perms: [import_dist.Perms.PAIRED_READ, import_dist.Perms.PAIRED_WRITE]
|
|
105960
|
+
});
|
|
105961
|
+
streamingControl.onGet(() => {
|
|
105962
|
+
note("rtp-streaming-control");
|
|
105963
|
+
return input.negotiation?.readStreamingControl() ?? encodeRtpStreamingControlStatus(null, RtpStreamingStatus.ERROR);
|
|
105964
|
+
});
|
|
105965
|
+
streamingControl.onSet(async (value) => {
|
|
105966
|
+
if (input.negotiation === void 0 || typeof value !== "string") {
|
|
105967
|
+
logger.info("hap multi-tier: refused an rtp streaming control write", {
|
|
105968
|
+
tags: { deviceId },
|
|
105969
|
+
meta: { reason: input.negotiation === void 0 ? "no-negotiator" : "not-a-string" }
|
|
105970
|
+
});
|
|
105971
|
+
return;
|
|
104369
105972
|
}
|
|
105973
|
+
await input.negotiation.handleStreamingControlWrite(value);
|
|
104370
105974
|
});
|
|
104371
|
-
|
|
104372
|
-
|
|
104373
|
-
|
|
104374
|
-
|
|
104375
|
-
|
|
104376
|
-
|
|
104377
|
-
|
|
104378
|
-
|
|
104379
|
-
|
|
104380
|
-
}
|
|
104381
|
-
|
|
104382
|
-
return
|
|
104383
|
-
|
|
104384
|
-
|
|
104385
|
-
|
|
104386
|
-
/** Is this a settled arm state worth remembering? */
|
|
104387
|
-
function armedMemoOf(state) {
|
|
104388
|
-
return state === "armed_home" || state === "armed_away" || state === "armed_night" || state === "armed_vacation" || state === "armed_custom_bypass" ? state : null;
|
|
104389
|
-
}
|
|
104390
|
-
function currentForArmed(state) {
|
|
104391
|
-
switch (state) {
|
|
104392
|
-
case "armed_home":
|
|
104393
|
-
case "armed_custom_bypass": return import_dist.Characteristic.SecuritySystemCurrentState.STAY_ARM;
|
|
104394
|
-
case "armed_night": return import_dist.Characteristic.SecuritySystemCurrentState.NIGHT_ARM;
|
|
104395
|
-
default: return import_dist.Characteristic.SecuritySystemCurrentState.AWAY_ARM;
|
|
104396
|
-
}
|
|
104397
|
-
}
|
|
104398
|
-
function targetForArmed(state) {
|
|
104399
|
-
switch (state) {
|
|
104400
|
-
case "armed_home":
|
|
104401
|
-
case "armed_custom_bypass": return import_dist.Characteristic.SecuritySystemTargetState.STAY_ARM;
|
|
104402
|
-
case "armed_night": return import_dist.Characteristic.SecuritySystemTargetState.NIGHT_ARM;
|
|
104403
|
-
default: return import_dist.Characteristic.SecuritySystemTargetState.AWAY_ARM;
|
|
104404
|
-
}
|
|
104405
|
-
}
|
|
104406
|
-
function securityStateFor(state, memo) {
|
|
104407
|
-
const settled = armedMemoOf(state);
|
|
104408
|
-
if (settled !== null) return {
|
|
104409
|
-
current: currentForArmed(settled),
|
|
104410
|
-
target: targetForArmed(settled)
|
|
105975
|
+
streamManagement.addCharacteristic(streamingControl);
|
|
105976
|
+
const sensor = new import_dist.Characteristic("Sensor UUID", CHAR_SENSOR_UUID, {
|
|
105977
|
+
format: import_dist.Formats.DATA,
|
|
105978
|
+
perms: [import_dist.Perms.PAIRED_READ]
|
|
105979
|
+
});
|
|
105980
|
+
const sensorValue = sensorUuid.toString("base64");
|
|
105981
|
+
sensor.onGet(() => {
|
|
105982
|
+
note("sensor-uuid");
|
|
105983
|
+
return sensorValue;
|
|
105984
|
+
});
|
|
105985
|
+
streamManagement.addCharacteristic(sensor);
|
|
105986
|
+
return {
|
|
105987
|
+
capabilities,
|
|
105988
|
+
globalOperatingMode,
|
|
105989
|
+
streamManagement
|
|
104411
105990
|
};
|
|
104412
|
-
switch (state) {
|
|
104413
|
-
case "disarmed": return {
|
|
104414
|
-
current: import_dist.Characteristic.SecuritySystemCurrentState.DISARMED,
|
|
104415
|
-
target: import_dist.Characteristic.SecuritySystemTargetState.DISARM
|
|
104416
|
-
};
|
|
104417
|
-
case "arming": return {
|
|
104418
|
-
current: import_dist.Characteristic.SecuritySystemCurrentState.DISARMED,
|
|
104419
|
-
target: memo === null ? null : targetForArmed(memo)
|
|
104420
|
-
};
|
|
104421
|
-
case "disarming": return {
|
|
104422
|
-
current: memo === null ? import_dist.Characteristic.SecuritySystemCurrentState.DISARMED : currentForArmed(memo),
|
|
104423
|
-
target: import_dist.Characteristic.SecuritySystemTargetState.DISARM
|
|
104424
|
-
};
|
|
104425
|
-
case "pending": return {
|
|
104426
|
-
current: memo === null ? import_dist.Characteristic.SecuritySystemCurrentState.DISARMED : currentForArmed(memo),
|
|
104427
|
-
target: memo === null ? null : targetForArmed(memo)
|
|
104428
|
-
};
|
|
104429
|
-
default: return {
|
|
104430
|
-
current: import_dist.Characteristic.SecuritySystemCurrentState.ALARM_TRIGGERED,
|
|
104431
|
-
target: memo === null ? null : targetForArmed(memo)
|
|
104432
|
-
};
|
|
104433
|
-
}
|
|
104434
|
-
}
|
|
104435
|
-
/**
|
|
104436
|
-
* The arm mode behind a HomeKit target, or `null` for DISARM and for anything
|
|
104437
|
-
* outside the four values HomeKit defines.
|
|
104438
|
-
*
|
|
104439
|
-
* `null` is not an error the caller can ignore: DISARM is a different COMMAND,
|
|
104440
|
-
* and a value HomeKit never sends is a write this builder refuses to translate.
|
|
104441
|
-
*/
|
|
104442
|
-
function alarmArmModeFor(target) {
|
|
104443
|
-
switch (target) {
|
|
104444
|
-
case import_dist.Characteristic.SecuritySystemTargetState.STAY_ARM: return "home";
|
|
104445
|
-
case import_dist.Characteristic.SecuritySystemTargetState.AWAY_ARM: return "away";
|
|
104446
|
-
case import_dist.Characteristic.SecuritySystemTargetState.NIGHT_ARM: return "night";
|
|
104447
|
-
default: return null;
|
|
104448
|
-
}
|
|
104449
|
-
}
|
|
104450
|
-
//#endregion
|
|
104451
|
-
//#region src/mappers/builders/generic/security-target.ts
|
|
104452
|
-
/**
|
|
104453
|
-
* The panel modes behind each HomeKit target, PRIMARY first.
|
|
104454
|
-
*
|
|
104455
|
-
* The primary is the mode `alarmArmModeFor` names; the secondary is the other
|
|
104456
|
-
* mode `security-state.ts` reports through the same HomeKit value, so the two
|
|
104457
|
-
* modules agree in both directions by construction.
|
|
104458
|
-
*/
|
|
104459
|
-
var MODES_BY_TARGET = new Map([
|
|
104460
|
-
[import_dist.Characteristic.SecuritySystemTargetState.STAY_ARM, ["home", "custom_bypass"]],
|
|
104461
|
-
[import_dist.Characteristic.SecuritySystemTargetState.AWAY_ARM, ["away", "vacation"]],
|
|
104462
|
-
[import_dist.Characteristic.SecuritySystemTargetState.NIGHT_ARM, ["night"]]
|
|
104463
|
-
]);
|
|
104464
|
-
/** Arm targets in the order HomeKit numbers them; DISARM is appended last. */
|
|
104465
|
-
var ARM_TARGETS = [
|
|
104466
|
-
import_dist.Characteristic.SecuritySystemTargetState.STAY_ARM,
|
|
104467
|
-
import_dist.Characteristic.SecuritySystemTargetState.AWAY_ARM,
|
|
104468
|
-
import_dist.Characteristic.SecuritySystemTargetState.NIGHT_ARM
|
|
104469
|
-
];
|
|
104470
|
-
/**
|
|
104471
|
-
* The `validValues` for `SecuritySystemTargetState`, or `null` to leave
|
|
104472
|
-
* HomeKit's own four values in place.
|
|
104473
|
-
*
|
|
104474
|
-
* DISARM is always present: disarming is not one of the panel's arm modes and
|
|
104475
|
-
* a panel that cannot be disarmed is not a thing this cap can express.
|
|
104476
|
-
*/
|
|
104477
|
-
function targetValidValuesFor(availableModes) {
|
|
104478
|
-
if (availableModes.length === 0) return null;
|
|
104479
|
-
return [...ARM_TARGETS.filter((target) => (MODES_BY_TARGET.get(target) ?? []).some((mode) => availableModes.includes(mode))), import_dist.Characteristic.SecuritySystemTargetState.DISARM];
|
|
104480
|
-
}
|
|
104481
|
-
/**
|
|
104482
|
-
* The arm mode to send for a HomeKit target, or `null` when there is none.
|
|
104483
|
-
*
|
|
104484
|
-
* `null` is not a shrug the caller may swallow: DISARM is a different command,
|
|
104485
|
-
* and an arm target the panel accepts no mode for is a write this module
|
|
104486
|
-
* refuses to translate rather than guess at.
|
|
104487
|
-
*/
|
|
104488
|
-
function armModeForTarget(target, availableModes) {
|
|
104489
|
-
const primary = alarmArmModeFor(target);
|
|
104490
|
-
if (primary === null) return null;
|
|
104491
|
-
if (availableModes.length === 0) return primary;
|
|
104492
|
-
return (MODES_BY_TARGET.get(target) ?? [primary]).find((mode) => availableModes.includes(mode)) ?? null;
|
|
104493
105991
|
}
|
|
104494
105992
|
//#endregion
|
|
104495
|
-
//#region src/
|
|
105993
|
+
//#region src/multi-tier/negotiation.ts
|
|
104496
105994
|
/**
|
|
104497
|
-
*
|
|
104498
|
-
*
|
|
104499
|
-
* The lock builder's shape (a current/target pair, a hydrate, an `onSet`, a
|
|
104500
|
-
* slice subscription, a dispose) applied to the one device on the fleet where
|
|
104501
|
-
* reporting a state that is not true has a consequence worse than a stalled
|
|
104502
|
-
* motor. The mapping itself is NOT here: `security-state.ts` owns what the
|
|
104503
|
-
* panel's state looks like to HomeKit and `security-target.ts` owns what
|
|
104504
|
-
* HomeKit may ask for, both pure and both tested on their own. This file is
|
|
104505
|
-
* the service around them.
|
|
104506
|
-
*
|
|
104507
|
-
* ## The memo
|
|
105995
|
+
* The adapter between Apple's new characteristics and the streaming engine this
|
|
105996
|
+
* addon already has.
|
|
104508
105997
|
*
|
|
104509
|
-
*
|
|
104510
|
-
* armed to — only the last settled state knows it. This builder is the caller
|
|
104511
|
-
* `security-state.ts` describes: it remembers the last settled arm and forgets
|
|
104512
|
-
* it on `disarmed`, because a memo that outlives a disarm makes the next
|
|
104513
|
-
* `arming` claim a target nobody asked for.
|
|
105998
|
+
* ## Why an adapter and not a second engine
|
|
104514
105999
|
*
|
|
104515
|
-
* The
|
|
104516
|
-
*
|
|
104517
|
-
*
|
|
104518
|
-
*
|
|
104519
|
-
*
|
|
104520
|
-
*
|
|
106000
|
+
* The multi-tier RTP service owns its OWN `Setup Endpoints` and `RTP Streaming
|
|
106001
|
+
* Control`, so hap-nodejs's `CameraController` never negotiates for it — and
|
|
106002
|
+
* the library's own handlers are all `private`, so its engine cannot be
|
|
106003
|
+
* borrowed either. But the sockets, the SRTP contexts, the ffmpeg pipeline and
|
|
106004
|
+
* the teardown are the same ones the classic service uses. A second copy would
|
|
106005
|
+
* be two engines to keep in agreement, and this repo has paid for that shape
|
|
106006
|
+
* before. So: new characteristics, one engine, through `StreamNegotiator`.
|
|
104521
106007
|
*
|
|
104522
|
-
* ##
|
|
106008
|
+
* ## What is different about the new flow
|
|
104523
106009
|
*
|
|
104524
|
-
*
|
|
104525
|
-
*
|
|
104526
|
-
*
|
|
104527
|
-
*
|
|
104528
|
-
*
|
|
106010
|
+
* The classic `Selected RTP Stream Configuration` carries the whole selected
|
|
106011
|
+
* configuration in its start — codec, profile, level, resolution, bitrate.
|
|
106012
|
+
* `RTP Streaming Control` carries a **tier identifier**, because the
|
|
106013
|
+
* configuration was agreed when we published `Supported Video Stream Tiers`.
|
|
106014
|
+
* So this maps an identifier back to the tier WE published, and a tier we never
|
|
106015
|
+
* published is `No Such Stream` — improvising a geometry there would serve a
|
|
106016
|
+
* stream the controller did not ask for and it would look like it worked.
|
|
104529
106017
|
*
|
|
104530
|
-
*
|
|
104531
|
-
* alternative. It is worse: the refusal costs a round trip, it is
|
|
104532
|
-
* indistinguishable from a broken panel in the log, and repeated code-less
|
|
104533
|
-
* attempts are what locks a real alarm panel out.
|
|
104534
|
-
*/
|
|
104535
|
-
var SUBTYPE = "alarm-panel";
|
|
104536
|
-
/** The subset of the status this builder reads. */
|
|
104537
|
-
var PanelReadSchema = AlarmPanelStatusSchema.pick({
|
|
104538
|
-
state: true,
|
|
104539
|
-
availableModes: true,
|
|
104540
|
-
requiresCode: true
|
|
104541
|
-
});
|
|
104542
|
-
/**
|
|
104543
|
-
* Turn a panel status into characteristic writes, folding the memo.
|
|
106018
|
+
* ## Every path answers
|
|
104544
106019
|
*
|
|
104545
|
-
* A
|
|
104546
|
-
*
|
|
104547
|
-
*
|
|
104548
|
-
*
|
|
106020
|
+
* A read of either characteristic always produces a valid TLV8. The Home app
|
|
106021
|
+
* reads a camera's characteristics in one batch, and one member that answers
|
|
106022
|
+
* nothing took the kitchen camera's whole read down and left it on "loading".
|
|
106023
|
+
* So an engine failure, a malformed write and "nothing has happened yet" are
|
|
106024
|
+
* all statuses, never silence.
|
|
104549
106025
|
*/
|
|
104550
|
-
|
|
104551
|
-
|
|
104552
|
-
|
|
104553
|
-
|
|
104554
|
-
|
|
104555
|
-
|
|
104556
|
-
|
|
104557
|
-
|
|
104558
|
-
|
|
104559
|
-
return
|
|
104560
|
-
updates: [{
|
|
104561
|
-
characteristic: import_dist.Characteristic.SecuritySystemCurrentState,
|
|
104562
|
-
value: current
|
|
104563
|
-
}, ...target === null ? [] : [{
|
|
104564
|
-
characteristic: import_dist.Characteristic.SecuritySystemTargetState,
|
|
104565
|
-
value: target
|
|
104566
|
-
}]],
|
|
104567
|
-
memo: nextMemo,
|
|
104568
|
-
panel: {
|
|
104569
|
-
availableModes: parsed.data.availableModes,
|
|
104570
|
-
requiresCode: parsed.data.requiresCode
|
|
104571
|
-
}
|
|
104572
|
-
};
|
|
106026
|
+
/** RTP payload types. 96 is the first dynamic one (RFC 3551); 110 is what our
|
|
106027
|
+
* own Opus offers use. Both are echoed in the tier declaration. */
|
|
106028
|
+
var VIDEO_PAYLOAD_TYPE = 96;
|
|
106029
|
+
var AUDIO_PAYLOAD_TYPE = 110;
|
|
106030
|
+
/** RTCP interval in SECONDS, which is the unit the engine reads. */
|
|
106031
|
+
var RTCP_INTERVAL_SECONDS = 5;
|
|
106032
|
+
/** The MTU the classic path uses for an Ethernet-sized path. */
|
|
106033
|
+
var DEFAULT_MTU = 1378;
|
|
106034
|
+
function errorMessage(error) {
|
|
106035
|
+
return error instanceof Error ? error.message : String(error);
|
|
104573
106036
|
}
|
|
104574
|
-
|
|
104575
|
-
const {
|
|
104576
|
-
const log =
|
|
104577
|
-
|
|
104578
|
-
|
|
104579
|
-
|
|
104580
|
-
|
|
104581
|
-
|
|
104582
|
-
|
|
104583
|
-
|
|
104584
|
-
|
|
104585
|
-
|
|
104586
|
-
|
|
104587
|
-
|
|
104588
|
-
|
|
104589
|
-
|
|
104590
|
-
|
|
104591
|
-
|
|
104592
|
-
|
|
104593
|
-
|
|
104594
|
-
|
|
104595
|
-
|
|
104596
|
-
|
|
104597
|
-
|
|
104598
|
-
|
|
104599
|
-
|
|
104600
|
-
|
|
104601
|
-
|
|
104602
|
-
|
|
104603
|
-
|
|
104604
|
-
|
|
104605
|
-
|
|
104606
|
-
|
|
104607
|
-
|
|
104608
|
-
|
|
104609
|
-
|
|
104610
|
-
|
|
104611
|
-
|
|
104612
|
-
|
|
104613
|
-
|
|
104614
|
-
|
|
104615
|
-
|
|
104616
|
-
|
|
104617
|
-
|
|
104618
|
-
|
|
104619
|
-
|
|
104620
|
-
|
|
104621
|
-
|
|
104622
|
-
|
|
104623
|
-
|
|
104624
|
-
|
|
104625
|
-
|
|
104626
|
-
|
|
104627
|
-
|
|
104628
|
-
const target = typeof value === "number" ? value : Number(value);
|
|
104629
|
-
const disarm = target === import_dist.Characteristic.SecuritySystemTargetState.DISARM;
|
|
104630
|
-
const mode = disarm ? null : armModeForTarget(target, availableModes);
|
|
104631
|
-
if (requiresCode) {
|
|
104632
|
-
log.warn("export-hap: alarm panel requires a PIN — HomeKit cannot supply one, command REFUSED", { meta: {
|
|
104633
|
-
subtype: SUBTYPE,
|
|
104634
|
-
command: disarm ? "disarm" : "arm",
|
|
104635
|
-
mode,
|
|
104636
|
-
target
|
|
106037
|
+
function buildMultiTierNegotiation(input) {
|
|
106038
|
+
const { deviceId, logger, negotiator } = input;
|
|
106039
|
+
const log = logger.withTags({ deviceId });
|
|
106040
|
+
let setupResponse = encodeSetupEndpointsRefusal(SetupEndpointsStatus.ERROR);
|
|
106041
|
+
let controlResponse = encodeRtpStreamingControlStatus(null, RtpStreamingStatus.ERROR);
|
|
106042
|
+
/** Sessions we have STARTED, so an End can tell "not started" from "unknown". */
|
|
106043
|
+
const started = /* @__PURE__ */ new Set();
|
|
106044
|
+
async function handleSetupEndpointsWrite(base64) {
|
|
106045
|
+
let sessionId = null;
|
|
106046
|
+
try {
|
|
106047
|
+
const request = decodeSetupEndpointsRequest(base64);
|
|
106048
|
+
sessionId = request.sessionId;
|
|
106049
|
+
const prepareRequest = {
|
|
106050
|
+
sessionID: request.sessionId,
|
|
106051
|
+
sourceAddress: request.controllerAddress,
|
|
106052
|
+
targetAddress: request.controllerAddress,
|
|
106053
|
+
addressVersion: request.addressVersion,
|
|
106054
|
+
video: {
|
|
106055
|
+
port: request.videoPort,
|
|
106056
|
+
srtpCryptoSuite: import_dist.SRTPCryptoSuites.AES_CM_128_HMAC_SHA1_80,
|
|
106057
|
+
srtp_key: request.videoSrtp.key,
|
|
106058
|
+
srtp_salt: request.videoSrtp.salt
|
|
106059
|
+
},
|
|
106060
|
+
audio: {
|
|
106061
|
+
port: request.audioPort,
|
|
106062
|
+
srtpCryptoSuite: import_dist.SRTPCryptoSuites.AES_CM_128_HMAC_SHA1_80,
|
|
106063
|
+
srtp_key: request.audioSrtp.key,
|
|
106064
|
+
srtp_salt: request.audioSrtp.salt
|
|
106065
|
+
}
|
|
106066
|
+
};
|
|
106067
|
+
const prepared = await negotiator.prepare(prepareRequest);
|
|
106068
|
+
setupResponse = encodeSetupEndpointsResponse({
|
|
106069
|
+
sessionId: request.sessionId,
|
|
106070
|
+
addressVersion: request.addressVersion,
|
|
106071
|
+
accessoryAddress: prepared.localAddress,
|
|
106072
|
+
videoPort: prepared.videoPort,
|
|
106073
|
+
audioPort: prepared.audioPort,
|
|
106074
|
+
videoSrtp: {
|
|
106075
|
+
key: request.videoSrtp.key,
|
|
106076
|
+
salt: request.videoSrtp.salt
|
|
106077
|
+
},
|
|
106078
|
+
audioSrtp: {
|
|
106079
|
+
key: request.audioSrtp.key,
|
|
106080
|
+
salt: request.audioSrtp.salt
|
|
106081
|
+
},
|
|
106082
|
+
videoSsrc: prepared.videoSsrc,
|
|
106083
|
+
audioSsrc: prepared.audioSsrc
|
|
106084
|
+
});
|
|
106085
|
+
log.info("hap multi-tier: endpoints prepared", { meta: {
|
|
106086
|
+
sessionId: request.sessionId,
|
|
106087
|
+
controller: `${request.controllerAddress}:${request.videoPort}/${request.audioPort}`,
|
|
106088
|
+
accessory: `${prepared.localAddress}:${prepared.videoPort}/${prepared.audioPort}`,
|
|
106089
|
+
videoSsrc: prepared.videoSsrc,
|
|
106090
|
+
audioSsrc: prepared.audioSsrc
|
|
104637
106091
|
} });
|
|
104638
|
-
|
|
104639
|
-
|
|
104640
|
-
|
|
104641
|
-
|
|
104642
|
-
|
|
104643
|
-
target,
|
|
104644
|
-
availableModes: [...availableModes]
|
|
106092
|
+
} catch (error) {
|
|
106093
|
+
setupResponse = encodeSetupEndpointsRefusal(SetupEndpointsStatus.ERROR);
|
|
106094
|
+
log.warn("hap multi-tier: setup endpoints refused", { meta: {
|
|
106095
|
+
sessionId,
|
|
106096
|
+
error: errorMessage(error)
|
|
104645
106097
|
} });
|
|
104646
|
-
throw new import_dist.HapStatusError(import_dist.HAPStatus.INVALID_VALUE_IN_REQUEST);
|
|
104647
106098
|
}
|
|
106099
|
+
}
|
|
106100
|
+
async function handleStreamingControlWrite(base64) {
|
|
106101
|
+
let sessionId = null;
|
|
104648
106102
|
try {
|
|
104649
|
-
|
|
104650
|
-
|
|
104651
|
-
|
|
104652
|
-
|
|
104653
|
-
|
|
104654
|
-
|
|
104655
|
-
|
|
104656
|
-
|
|
106103
|
+
const command = decodeRtpStreamingControl(base64);
|
|
106104
|
+
sessionId = command.sessionId;
|
|
106105
|
+
if (!negotiator.has(command.sessionId)) {
|
|
106106
|
+
controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.UNKNOWN_SESSION_IDENTIFIER);
|
|
106107
|
+
log.warn("hap multi-tier: control write for a session that was never prepared", { meta: {
|
|
106108
|
+
sessionId: command.sessionId,
|
|
106109
|
+
command: command.command
|
|
106110
|
+
} });
|
|
106111
|
+
return;
|
|
106112
|
+
}
|
|
106113
|
+
if (command.command === RtpStreamingCommand.END) {
|
|
106114
|
+
if (!started.has(command.sessionId)) {
|
|
106115
|
+
controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.NO_SUCH_STREAM);
|
|
106116
|
+
return;
|
|
106117
|
+
}
|
|
106118
|
+
const stop = {
|
|
106119
|
+
sessionID: command.sessionId,
|
|
106120
|
+
type: import_dist.StreamRequestTypes.STOP
|
|
106121
|
+
};
|
|
106122
|
+
await negotiator.control(stop);
|
|
106123
|
+
started.delete(command.sessionId);
|
|
106124
|
+
controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.SUCCESS);
|
|
106125
|
+
log.info("hap multi-tier: stream ended", { meta: { sessionId: command.sessionId } });
|
|
106126
|
+
return;
|
|
106127
|
+
}
|
|
106128
|
+
const tier = input.tiers.find((t) => t.identifier === command.videoTier);
|
|
106129
|
+
if (tier === void 0) {
|
|
106130
|
+
controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.NO_SUCH_STREAM);
|
|
106131
|
+
log.warn("hap multi-tier: start named a tier we never published", { meta: {
|
|
106132
|
+
sessionId: command.sessionId,
|
|
106133
|
+
requested: command.videoTier,
|
|
106134
|
+
published: input.tiers.map((t) => t.identifier)
|
|
106135
|
+
} });
|
|
106136
|
+
return;
|
|
106137
|
+
}
|
|
106138
|
+
log.info("hap multi-tier: start ssrc comparison", { meta: {
|
|
106139
|
+
sessionId: command.sessionId,
|
|
106140
|
+
controllerVideoSsrc: command.videoSsrc ?? null,
|
|
106141
|
+
controllerAudioSsrc: command.audioSsrc ?? null,
|
|
106142
|
+
note: "accessory keeps the ssrcs it advertised at setup"
|
|
104657
106143
|
} });
|
|
104658
|
-
|
|
104659
|
-
|
|
104660
|
-
|
|
104661
|
-
|
|
104662
|
-
|
|
104663
|
-
|
|
104664
|
-
|
|
104665
|
-
|
|
104666
|
-
|
|
104667
|
-
|
|
104668
|
-
|
|
104669
|
-
|
|
104670
|
-
|
|
104671
|
-
|
|
104672
|
-
|
|
104673
|
-
|
|
104674
|
-
|
|
104675
|
-
|
|
104676
|
-
|
|
104677
|
-
|
|
104678
|
-
|
|
104679
|
-
|
|
104680
|
-
|
|
104681
|
-
|
|
104682
|
-
|
|
104683
|
-
|
|
104684
|
-
|
|
104685
|
-
|
|
104686
|
-
|
|
104687
|
-
|
|
104688
|
-
|
|
106144
|
+
const start = {
|
|
106145
|
+
sessionID: command.sessionId,
|
|
106146
|
+
type: import_dist.StreamRequestTypes.START,
|
|
106147
|
+
video: {
|
|
106148
|
+
codec: import_dist.VideoCodecType.H264,
|
|
106149
|
+
profile: import_dist.H264Profile.HIGH,
|
|
106150
|
+
level: import_dist.H264Level.LEVEL4_0,
|
|
106151
|
+
packetizationMode: import_dist.VideoCodecPacketizationMode.NON_INTERLEAVED,
|
|
106152
|
+
width: tier.width,
|
|
106153
|
+
height: tier.height,
|
|
106154
|
+
fps: tier.frameRate,
|
|
106155
|
+
pt: VIDEO_PAYLOAD_TYPE,
|
|
106156
|
+
ssrc: command.videoSsrc ?? 0,
|
|
106157
|
+
max_bit_rate: tier.targetAverageBitrateKbps,
|
|
106158
|
+
rtcp_interval: RTCP_INTERVAL_SECONDS,
|
|
106159
|
+
mtu: DEFAULT_MTU
|
|
106160
|
+
},
|
|
106161
|
+
audio: {
|
|
106162
|
+
codec: import_dist.AudioStreamingCodecType.OPUS,
|
|
106163
|
+
channel: 1,
|
|
106164
|
+
bit_rate: 24,
|
|
106165
|
+
sample_rate: import_dist.AudioStreamingSamplerate.KHZ_24,
|
|
106166
|
+
packet_time: 20,
|
|
106167
|
+
pt: AUDIO_PAYLOAD_TYPE,
|
|
106168
|
+
ssrc: command.audioSsrc ?? 0,
|
|
106169
|
+
max_bit_rate: 24,
|
|
106170
|
+
rtcp_interval: RTCP_INTERVAL_SECONDS,
|
|
106171
|
+
comfort_pt: 13,
|
|
106172
|
+
comfortNoiseEnabled: false
|
|
106173
|
+
}
|
|
106174
|
+
};
|
|
106175
|
+
await negotiator.control(start);
|
|
106176
|
+
started.add(command.sessionId);
|
|
106177
|
+
controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.SUCCESS);
|
|
106178
|
+
log.info("hap multi-tier: stream started", { meta: {
|
|
106179
|
+
sessionId: command.sessionId,
|
|
106180
|
+
tier: tier.identifier,
|
|
106181
|
+
geometry: `${tier.width}x${tier.height}@${tier.frameRate}`,
|
|
106182
|
+
targetKbps: tier.targetAverageBitrateKbps,
|
|
106183
|
+
codec: input.codec
|
|
106184
|
+
} });
|
|
106185
|
+
} catch (error) {
|
|
106186
|
+
controlResponse = encodeRtpStreamingControlStatus(sessionId, RtpStreamingStatus.ERROR);
|
|
106187
|
+
log.warn("hap multi-tier: control write refused", { meta: {
|
|
106188
|
+
sessionId,
|
|
106189
|
+
error: errorMessage(error)
|
|
104689
106190
|
} });
|
|
104690
|
-
return;
|
|
104691
106191
|
}
|
|
104692
|
-
applyUpdates(service, updates);
|
|
104693
|
-
};
|
|
104694
|
-
try {
|
|
104695
|
-
const status = await spec.getStatus(proxy);
|
|
104696
|
-
if (status !== void 0 && status !== null) apply(status, "getStatus");
|
|
104697
|
-
} catch (err) {
|
|
104698
|
-
log.debug("export-hap: sensor getStatus hydrate failed (non-fatal)", { meta: {
|
|
104699
|
-
subtype,
|
|
104700
|
-
error: errMsg$2(err)
|
|
104701
|
-
} });
|
|
104702
106192
|
}
|
|
104703
|
-
|
|
104704
|
-
|
|
104705
|
-
|
|
104706
|
-
|
|
104707
|
-
|
|
104708
|
-
|
|
104709
|
-
unsubscribe?.();
|
|
104710
|
-
} catch {}
|
|
104711
|
-
} };
|
|
104712
|
-
}
|
|
104713
|
-
function errMsg$2(err) {
|
|
104714
|
-
return err instanceof Error ? err.message : String(err);
|
|
106193
|
+
return {
|
|
106194
|
+
handleSetupEndpointsWrite,
|
|
106195
|
+
readSetupEndpoints: () => setupResponse,
|
|
106196
|
+
handleStreamingControlWrite,
|
|
106197
|
+
readStreamingControl: () => controlResponse
|
|
106198
|
+
};
|
|
104715
106199
|
}
|
|
104716
106200
|
//#endregion
|
|
104717
|
-
//#region src/mappers/
|
|
106201
|
+
//#region src/mappers/camera-accessory.ts
|
|
104718
106202
|
/**
|
|
104719
|
-
*
|
|
104720
|
-
*
|
|
104721
|
-
*
|
|
104722
|
-
* deliberately a table rather than a `switch` on `DeviceType`. The doctrine is
|
|
104723
|
-
* the one the Home Assistant exporter already writes down: *coverage is decided
|
|
104724
|
-
* by a device's capabilities, not its type — restricting the picker by type is
|
|
104725
|
-
* what confined the previous exporter to cameras.* A `DeviceType` here is only
|
|
104726
|
-
* ever a UI filter (`HAP_EXPORTABLE_DEVICE_TYPES`) or an icon
|
|
104727
|
-
* (`ACCESSORY_CATEGORY_BY_TYPE`); it never decides whether something exports.
|
|
104728
|
-
*
|
|
104729
|
-
* The shape mirrors `CAP_ENTITY_MAP` in
|
|
104730
|
-
* `packages/addon-provider-homeassistant/src/ha-export/entity-catalog.ts` so
|
|
104731
|
-
* the two can be unified later — one row per capability, keyed by the cap name
|
|
104732
|
-
* exactly as it appears in `proxy.binding.entries[].capName`.
|
|
104733
|
-
*
|
|
104734
|
-
* ## Reading a status
|
|
104735
|
-
*
|
|
104736
|
-
* Every row parses the cap's OWN Zod status schema rather than duck-typing the
|
|
104737
|
-
* payload, and it `.pick()`s only the fields it reads: a provider that omits a
|
|
104738
|
-
* timestamp must not silence a sensor, and a provider that sends the wrong
|
|
104739
|
-
* shape must not write a garbage characteristic. A parse that fails yields no
|
|
104740
|
-
* updates, and the builder that owns the service logs the drop — silence reads
|
|
104741
|
-
* as "the sensor never changed".
|
|
104742
|
-
*
|
|
104743
|
-
* ## Tier
|
|
104744
|
-
*
|
|
104745
|
-
* Tier A, plus `alarm-panel`: the caps whose HomeKit service exists today and
|
|
104746
|
-
* needs no feature-dependent shape. `cover` still needs `cover-positionable`
|
|
104747
|
-
* and `climate-control` the dual-setpoint split; those are a separate task,
|
|
104748
|
-
* and adding a row here must never mean adding a service whose semantics this
|
|
104749
|
-
* file cannot fully honour.
|
|
106203
|
+
* Camera accessory orchestrator — given a camstack deviceId, builds one
|
|
106204
|
+
* HomeKit Accessory representing the camera (or doorbell) plus a
|
|
106205
|
+
* separate Accessory per child device (siren, floodlight, …).
|
|
104750
106206
|
*
|
|
104751
|
-
*
|
|
104752
|
-
*
|
|
104753
|
-
*
|
|
104754
|
-
*
|
|
106207
|
+
* Flow:
|
|
106208
|
+
* 1. Resolve a typed `DeviceProxy` via `ctx.fetchDevice(deviceId)`.
|
|
106209
|
+
* 2. Inspect `proxy.binding.entries` (or fall back to `getDevice`
|
|
106210
|
+
* features) to decide whether the device is a doorbell vs a
|
|
106211
|
+
* regular camera. Doorbells get `Categories.VIDEO_DOORBELL` +
|
|
106212
|
+
* `DoorbellController`; otherwise `Categories.IP_CAMERA` +
|
|
106213
|
+
* `CameraController`.
|
|
106214
|
+
* 3. Build the streaming delegate via the `camera-streams` builder.
|
|
106215
|
+
* 4. `configureController(controller)` to attach the streaming
|
|
106216
|
+
* services to the Accessory.
|
|
106217
|
+
* 5. Run each feature builder gated on cap presence (motion sensor,
|
|
106218
|
+
* intercom, PTZ, doorbell).
|
|
106219
|
+
* 6. Enumerate children via `proxy.deviceManager.getChildren({...})`
|
|
106220
|
+
* and build one Accessory per child (see `child-accessory.ts`).
|
|
106221
|
+
* 7. Return a `Mapper` whose `accessories[]` lists every accessory
|
|
106222
|
+
* this device produced so the bridge can register them all and
|
|
106223
|
+
* remove them all on unexpose.
|
|
104755
106224
|
*/
|
|
104756
|
-
|
|
104757
|
-
|
|
104758
|
-
|
|
104759
|
-
|
|
104760
|
-
|
|
104761
|
-
|
|
104762
|
-
|
|
104763
|
-
|
|
104764
|
-
|
|
104765
|
-
|
|
104766
|
-
|
|
104767
|
-
|
|
104768
|
-
|
|
104769
|
-
|
|
104770
|
-
|
|
104771
|
-
|
|
104772
|
-
|
|
104773
|
-
|
|
104774
|
-
|
|
104775
|
-
|
|
104776
|
-
|
|
104777
|
-
|
|
104778
|
-
|
|
104779
|
-
|
|
104780
|
-
|
|
104781
|
-
|
|
104782
|
-
|
|
104783
|
-
|
|
104784
|
-
|
|
104785
|
-
|
|
104786
|
-
|
|
104787
|
-
|
|
104788
|
-
}
|
|
104789
|
-
|
|
104790
|
-
|
|
104791
|
-
|
|
104792
|
-
|
|
104793
|
-
|
|
104794
|
-
|
|
104795
|
-
|
|
104796
|
-
|
|
104797
|
-
|
|
104798
|
-
|
|
104799
|
-
|
|
104800
|
-
|
|
104801
|
-
|
|
104802
|
-
|
|
104803
|
-
|
|
104804
|
-
|
|
104805
|
-
|
|
104806
|
-
|
|
104807
|
-
|
|
104808
|
-
|
|
104809
|
-
|
|
104810
|
-
|
|
104811
|
-
|
|
104812
|
-
|
|
104813
|
-
|
|
104814
|
-
|
|
104815
|
-
|
|
104816
|
-
|
|
104817
|
-
|
|
104818
|
-
|
|
104819
|
-
|
|
104820
|
-
|
|
104821
|
-
|
|
104822
|
-
|
|
104823
|
-
|
|
104824
|
-
|
|
104825
|
-
characteristic: import_dist.Characteristic.CurrentTemperature,
|
|
104826
|
-
value: status.celsius
|
|
104827
|
-
}])
|
|
104828
|
-
},
|
|
104829
|
-
"humidity-sensor": {
|
|
104830
|
-
label: "Humidity",
|
|
104831
|
-
addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.HumiditySensor, name, subtype),
|
|
104832
|
-
getStatus: (proxy) => proxy.humiditySensor?.getStatus({}),
|
|
104833
|
-
subscribe: (proxy, onValue) => proxy.state.humiditySensor?.subscribe(onValue) ?? null,
|
|
104834
|
-
read: reader(HumiditySensorStatusSchema.pick({ percent: true }), (status) => [{
|
|
104835
|
-
characteristic: import_dist.Characteristic.CurrentRelativeHumidity,
|
|
104836
|
-
value: status.percent
|
|
104837
|
-
}])
|
|
104838
|
-
},
|
|
104839
|
-
"ambient-light-sensor": {
|
|
104840
|
-
label: "Light level",
|
|
104841
|
-
addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.LightSensor, name, subtype),
|
|
104842
|
-
getStatus: (proxy) => proxy.ambientLightSensor?.getStatus({}),
|
|
104843
|
-
subscribe: (proxy, onValue) => proxy.state.ambientLightSensor?.subscribe(onValue) ?? null,
|
|
104844
|
-
read: reader(AmbientLightSensorStatusSchema.pick({ lux: true }), (status) => [{
|
|
104845
|
-
characteristic: import_dist.Characteristic.CurrentAmbientLightLevel,
|
|
104846
|
-
value: Math.max(MIN_LUX, status.lux)
|
|
104847
|
-
}])
|
|
106225
|
+
async function buildCameraAccessory(input) {
|
|
106226
|
+
const { ctx, deviceId, displayName, options } = input;
|
|
106227
|
+
const numericId = Number.parseInt(deviceId, 10);
|
|
106228
|
+
if (!Number.isFinite(numericId)) throw new Error(`export-hap: cannot map device '${deviceId}' — id is not numeric`);
|
|
106229
|
+
const proxy = await ctx.fetchDevice(numericId);
|
|
106230
|
+
const capNames = new Set(proxy.binding?.entries.map((e) => e.capName) ?? []);
|
|
106231
|
+
const isDoorbell = capNames.has("doorbell");
|
|
106232
|
+
const category = isDoorbell ? import_dist.Categories.VIDEO_DOORBELL : import_dist.Categories.IP_CAMERA;
|
|
106233
|
+
const accessory = new import_dist.Accessory(displayName, accessoryUuidFor("camera", numericId));
|
|
106234
|
+
accessory.category = category;
|
|
106235
|
+
await populateAccessoryInfo(accessory, proxy, displayName, "Camera");
|
|
106236
|
+
const bctx = {
|
|
106237
|
+
ctx,
|
|
106238
|
+
accessory,
|
|
106239
|
+
proxy,
|
|
106240
|
+
numericDeviceId: numericId,
|
|
106241
|
+
displayName,
|
|
106242
|
+
options
|
|
106243
|
+
};
|
|
106244
|
+
const advertisedVideo = await probeAdvertisedVideoProfile(bctx);
|
|
106245
|
+
const streams = buildCameraStreamingDelegate(bctx, advertisedVideo);
|
|
106246
|
+
const handles = [];
|
|
106247
|
+
if (capNames.has("intercom")) handles.push(await buildIntercom({
|
|
106248
|
+
bctx,
|
|
106249
|
+
streamingOptions: streams.streamingOptions
|
|
106250
|
+
}));
|
|
106251
|
+
const recordingEnabled = options.hapDeviceSettings.hksvRecording === true;
|
|
106252
|
+
let recordingAudioActive = true;
|
|
106253
|
+
const recording = recordingEnabled ? await buildHksvRecording({
|
|
106254
|
+
bctx,
|
|
106255
|
+
fpsByProfile: advertisedVideo.fpsByProfile,
|
|
106256
|
+
isAudioActive: () => recordingAudioActive
|
|
106257
|
+
}) : null;
|
|
106258
|
+
const controller = new (isDoorbell ? import_dist.DoorbellController : import_dist.CameraController)({
|
|
106259
|
+
delegate: streams.delegate,
|
|
106260
|
+
streamingOptions: streams.streamingOptions,
|
|
106261
|
+
cameraStreamCount: 2,
|
|
106262
|
+
...recording === null ? {} : { recording },
|
|
106263
|
+
...recording === null || !capNames.has("motion-detection") ? {} : { sensors: { motion: true } }
|
|
106264
|
+
});
|
|
106265
|
+
accessory.configureController(controller);
|
|
106266
|
+
if (options.hapDeviceSettings.multiTierService !== false) {
|
|
106267
|
+
const sensor = advertisedVideo.sensor;
|
|
106268
|
+
const services = buildMultiTierServices({
|
|
106269
|
+
deviceId: numericId,
|
|
106270
|
+
logger: ctx.logger,
|
|
106271
|
+
width: sensor.width,
|
|
106272
|
+
height: sensor.height,
|
|
106273
|
+
codec: sensor.codec,
|
|
106274
|
+
audioCaptureRateHz: 16e3,
|
|
106275
|
+
negotiation: buildMultiTierNegotiation({
|
|
106276
|
+
deviceId: numericId,
|
|
106277
|
+
logger: ctx.logger,
|
|
106278
|
+
negotiator: streams.negotiator,
|
|
106279
|
+
tiers: tiersForSensor(sensor.width, sensor.height),
|
|
106280
|
+
codec: sensor.codec
|
|
106281
|
+
})
|
|
106282
|
+
});
|
|
106283
|
+
accessory.addService(services.capabilities);
|
|
106284
|
+
accessory.addService(services.globalOperatingMode);
|
|
106285
|
+
accessory.addService(services.streamManagement);
|
|
106286
|
+
ctx.logger.info("hap multi-tier: services declared on this accessory", {
|
|
106287
|
+
tags: { deviceId: numericId },
|
|
106288
|
+
meta: {
|
|
106289
|
+
serviceUuid: MULTI_TIER_RTP_SERVICE_UUID,
|
|
106290
|
+
codec: sensor.codec === TierVideoCodec.H265 ? "h265" : "h264",
|
|
106291
|
+
sensor: `${sensor.width}x${sensor.height}`
|
|
106292
|
+
}
|
|
106293
|
+
});
|
|
104848
106294
|
}
|
|
104849
|
-
|
|
104850
|
-
|
|
104851
|
-
|
|
104852
|
-
|
|
104853
|
-
|
|
104854
|
-
|
|
104855
|
-
|
|
104856
|
-
|
|
104857
|
-
|
|
104858
|
-
|
|
104859
|
-
|
|
104860
|
-
|
|
104861
|
-
|
|
104862
|
-
|
|
104863
|
-
|
|
106295
|
+
if (recording !== null) {
|
|
106296
|
+
handles.push({ dispose: () => recording.dispose() });
|
|
106297
|
+
const audioCharacteristic = (controller.recordingManagement?.operatingModeService)?.getCharacteristic(import_dist.Characteristic.RecordingAudioActive);
|
|
106298
|
+
if (audioCharacteristic) {
|
|
106299
|
+
recordingAudioActive = audioCharacteristic.value !== 0 && audioCharacteristic.value !== false;
|
|
106300
|
+
audioCharacteristic.on("change", ({ newValue }) => {
|
|
106301
|
+
recordingAudioActive = newValue !== 0 && newValue !== false;
|
|
106302
|
+
});
|
|
106303
|
+
}
|
|
106304
|
+
}
|
|
106305
|
+
if (capNames.has("motion-detection")) handles.push(await buildMotionSensor(bctx, recording === null ? null : controller.motionService ?? null));
|
|
106306
|
+
if (isDoorbell && controller instanceof import_dist.DoorbellController) handles.push(await buildDoorbell({
|
|
106307
|
+
bctx,
|
|
106308
|
+
controller
|
|
106309
|
+
}));
|
|
106310
|
+
if (capNames.has("ptz") || capNames.has("ptz-autotrack")) handles.push(await buildPtz(bctx));
|
|
106311
|
+
if (capNames.has("battery")) handles.push(await buildBattery(bctx));
|
|
106312
|
+
if (capNames.has("privacy-mask")) handles.push(await buildPrivacySwitch(bctx));
|
|
106313
|
+
handles.push(await buildChildServicesFor({ parentCtx: bctx }));
|
|
104864
106314
|
return {
|
|
104865
|
-
|
|
104866
|
-
|
|
104867
|
-
|
|
104868
|
-
|
|
104869
|
-
|
|
104870
|
-
|
|
104871
|
-
|
|
104872
|
-
|
|
106315
|
+
accessory,
|
|
106316
|
+
accessories: [accessory],
|
|
106317
|
+
async dispose() {
|
|
106318
|
+
const log = ctx.logger.withTags({ deviceId: numericId });
|
|
106319
|
+
for (const h of handles) try {
|
|
106320
|
+
await h.dispose();
|
|
106321
|
+
} catch (err) {
|
|
106322
|
+
log.debug("export-hap: builder dispose failed (continuing)", { meta: { error: errMsg$2(err) } });
|
|
106323
|
+
}
|
|
106324
|
+
try {
|
|
106325
|
+
await streams.dispose();
|
|
106326
|
+
} catch (err) {
|
|
106327
|
+
log.debug("export-hap: streams dispose failed", { meta: { error: errMsg$2(err) } });
|
|
106328
|
+
}
|
|
106329
|
+
}
|
|
104873
106330
|
};
|
|
104874
106331
|
}
|
|
104875
|
-
|
|
104876
|
-
|
|
104877
|
-
* device decides the accessory's category when the device's own type does not.
|
|
104878
|
-
*/
|
|
104879
|
-
var HAP_CAP_SERVICES = [
|
|
104880
|
-
{
|
|
104881
|
-
caps: ["switch", "brightness"],
|
|
104882
|
-
label: "Power",
|
|
104883
|
-
build: ({ bctx, name, deviceType }) => buildChildSwitch({
|
|
104884
|
-
...bctx,
|
|
104885
|
-
displayName: name
|
|
104886
|
-
}, "switch", deviceType)
|
|
104887
|
-
},
|
|
104888
|
-
{
|
|
104889
|
-
caps: ["lock-control"],
|
|
104890
|
-
label: "Lock",
|
|
104891
|
-
build: ({ bctx, name }) => buildLockMechanism({
|
|
104892
|
-
...bctx,
|
|
104893
|
-
displayName: name
|
|
104894
|
-
})
|
|
104895
|
-
},
|
|
104896
|
-
{
|
|
104897
|
-
caps: ["alarm-panel"],
|
|
104898
|
-
label: "Alarm",
|
|
104899
|
-
build: ({ bctx, name }) => buildSecuritySystem({
|
|
104900
|
-
...bctx,
|
|
104901
|
-
displayName: name
|
|
104902
|
-
})
|
|
104903
|
-
},
|
|
104904
|
-
{
|
|
104905
|
-
caps: ["battery"],
|
|
104906
|
-
label: BATTERY_SPEC.label,
|
|
104907
|
-
build: ({ bctx, name }) => buildSensorService({
|
|
104908
|
-
bctx,
|
|
104909
|
-
spec: BATTERY_SPEC,
|
|
104910
|
-
name,
|
|
104911
|
-
subtype: "battery"
|
|
104912
|
-
})
|
|
104913
|
-
},
|
|
104914
|
-
...Object.entries(SENSOR_SPECS).map(([capName, spec]) => sensorRow(capName, spec))
|
|
104915
|
-
];
|
|
104916
|
-
/**
|
|
104917
|
-
* The rows a device's bound capabilities select, in table order.
|
|
104918
|
-
*
|
|
104919
|
-
* A row matches when ANY of its caps is bound: a lamp with `switch` but no
|
|
104920
|
-
* `brightness` is still a Lightbulb-shaped row, and the builder degrades on its
|
|
104921
|
-
* own.
|
|
104922
|
-
*/
|
|
104923
|
-
function rowsForCaps(capNames) {
|
|
104924
|
-
return HAP_CAP_SERVICES.filter((row) => row.caps.some((cap) => capNames.has(cap)));
|
|
104925
|
-
}
|
|
104926
|
-
/**
|
|
104927
|
-
* The HomeKit accessory category per camstack device type — the icon iOS Home
|
|
104928
|
-
* draws and nothing else. Absent = `OTHER`, which is a valid accessory that
|
|
104929
|
-
* simply gets the generic tile.
|
|
104930
|
-
*
|
|
104931
|
-
* This is NOT a coverage decision. `HAP_EXPORTABLE_DEVICE_TYPES` (in
|
|
104932
|
-
* `mappers/index.ts`) filters the picker; whether a device exports anything is
|
|
104933
|
-
* decided by `rowsForCaps`.
|
|
104934
|
-
*/
|
|
104935
|
-
var ACCESSORY_CATEGORY_BY_TYPE = {
|
|
104936
|
-
[DeviceType.Switch]: import_dist.Categories.SWITCH,
|
|
104937
|
-
[DeviceType.Light]: import_dist.Categories.LIGHTBULB,
|
|
104938
|
-
[DeviceType.Siren]: import_dist.Categories.SWITCH,
|
|
104939
|
-
[DeviceType.Lock]: import_dist.Categories.DOOR_LOCK,
|
|
104940
|
-
[DeviceType.Sensor]: import_dist.Categories.SENSOR,
|
|
104941
|
-
[DeviceType.Presence]: import_dist.Categories.SENSOR,
|
|
104942
|
-
[DeviceType.AlarmPanel]: import_dist.Categories.SECURITY_SYSTEM
|
|
104943
|
-
};
|
|
104944
|
-
function accessoryCategoryFor(deviceType) {
|
|
104945
|
-
return ACCESSORY_CATEGORY_BY_TYPE[deviceType] ?? import_dist.Categories.OTHER;
|
|
106332
|
+
function errMsg$2(err) {
|
|
106333
|
+
return err instanceof Error ? err.message : String(err);
|
|
104946
106334
|
}
|
|
104947
106335
|
//#endregion
|
|
104948
106336
|
//#region src/mappers/generic-accessory.ts
|
|
@@ -105046,118 +106434,6 @@ function getMapperFactory(kind) {
|
|
|
105046
106434
|
return factory;
|
|
105047
106435
|
}
|
|
105048
106436
|
//#endregion
|
|
105049
|
-
//#region src/mappers/builders/stream-hwaccel-memo.ts
|
|
105050
|
-
/**
|
|
105051
|
-
* The two bounded memos HomeKit's decode path owns.
|
|
105052
|
-
*
|
|
105053
|
-
* ## Why they exist
|
|
105054
|
-
*
|
|
105055
|
-
* Everything D67 was actually about — one argv builder, one set of constants,
|
|
105056
|
-
* one hwaccel authority — HomeKit already had. What it did NOT have were the
|
|
105057
|
-
* two things the broker gained alongside them:
|
|
105058
|
-
*
|
|
105059
|
-
* 1. **A memo.** `probeDecoderHwaccel` issued a cross-process
|
|
105060
|
-
* `decoder.getInfo` per SESSION. iOS starts sessions in bursts — one on
|
|
105061
|
-
* record was started three times in 16 s — and every one of those paid a
|
|
105062
|
-
* cap call on a hub whose main thread is the scarce resource.
|
|
105063
|
-
* 2. **Failure feedback.** When HomeKit's hardware child died at init and
|
|
105064
|
-
* `shouldRetryInSoftware` saved the session, HomeKit told nobody. The next
|
|
105065
|
-
* session re-picked the same corpse and paid the same two-second death.
|
|
105066
|
-
* `EgressTranscodeManager` fixed exactly this for its own children
|
|
105067
|
-
* (26a522cd5) by reporting the dead backend into the broker's 60 s memo.
|
|
105068
|
-
*
|
|
105069
|
-
* ## Both ride `HwAccelCache`, deliberately
|
|
105070
|
-
*
|
|
105071
|
-
* `createHwAccelCache` from `@camstack/types` is the primitive the broker's own
|
|
105072
|
-
* `egressHwAccelCache` is built from, and the discipline it encodes is the
|
|
105073
|
-
* point: **caller-owned, never a module global** — a module global would
|
|
105074
|
-
* outlive an addon respawn and survive an operator changing the decoder
|
|
105075
|
-
* backend. Same TTL as the broker's, so "has hardware come back yet" cannot
|
|
105076
|
-
* answer differently depending on which consumer asked.
|
|
105077
|
-
*
|
|
105078
|
-
* ## The cross-process gap, stated honestly
|
|
105079
|
-
*
|
|
105080
|
-
* These memos are scoped to the `export-hap` PROCESS. When HomeKit's vaapi
|
|
105081
|
-
* child dies, the broker's next child still pays its own two-second death, and
|
|
105082
|
-
* vice versa — because addons may never import each other and there is no
|
|
105083
|
-
* capability for "this backend is dead on this node right now". Closing that
|
|
105084
|
-
* would need a new cap surface, which Phase 0 explicitly does not take. What is
|
|
105085
|
-
* closed here is HomeKit's own repetition of the cost, across cameras and
|
|
105086
|
-
* across sessions.
|
|
105087
|
-
*/
|
|
105088
|
-
/**
|
|
105089
|
-
* The window both memos answer for.
|
|
105090
|
-
*
|
|
105091
|
-
* 60 s, the same as `stream-broker-manager`'s `egressHwAccelCache`. Long enough
|
|
105092
|
-
* that a burst of session restarts pays one read; short enough that an operator
|
|
105093
|
-
* who changes the decoder backend, or a host whose accelerator recovers, is
|
|
105094
|
-
* obeyed on the next session rather than after an addon respawn.
|
|
105095
|
-
*/
|
|
105096
|
-
var HAP_DECODE_MEMO_TTL_MS = 6e4;
|
|
105097
|
-
/**
|
|
105098
|
-
* Separator inside the encoded reading. A control character, because a backend
|
|
105099
|
-
* name is `[a-z0-9]+` and the decoder's non-backend choices are `auto` /
|
|
105100
|
-
* `none` / `''`, none of which can contain one — so the split is total.
|
|
105101
|
-
*/
|
|
105102
|
-
var READING_SEPARATOR = "";
|
|
105103
|
-
/**
|
|
105104
|
-
* Marks a `null` FIELD, distinct from an EMPTY one.
|
|
105105
|
-
*
|
|
105106
|
-
* `probedBestHwaccel: ''` means the decoder answered and has never probed
|
|
105107
|
-
* (=> `not-probed`); `null` means the field was absent altogether. Encoding
|
|
105108
|
-
* both as `''` would lose a distinction `selectHwDecode` acts on.
|
|
105109
|
-
*/
|
|
105110
|
-
var NULL_FIELD = "\0";
|
|
105111
|
-
function encodeField(value) {
|
|
105112
|
-
return value === null ? NULL_FIELD : value;
|
|
105113
|
-
}
|
|
105114
|
-
function decodeField(value) {
|
|
105115
|
-
return value === NULL_FIELD ? null : value;
|
|
105116
|
-
}
|
|
105117
|
-
/**
|
|
105118
|
-
* A reading as ONE `string | null`, which is what {@link HwAccelCache} stores.
|
|
105119
|
-
*
|
|
105120
|
-
* The cache's three states are exactly the three a memoised reading needs:
|
|
105121
|
-
* `undefined` (never asked, or expired), `null` (asked, and the decoder could
|
|
105122
|
-
* not be reached), and a value. Encoding into the one cache rather than
|
|
105123
|
-
* splitting across two is what keeps those three from skewing — two caches
|
|
105124
|
-
* written together can still be READ across an expiry boundary.
|
|
105125
|
-
*/
|
|
105126
|
-
function encodeDecoderReading(reading) {
|
|
105127
|
-
if (reading === null) return null;
|
|
105128
|
-
return `${encodeField(reading.hwaccel)}${READING_SEPARATOR}${encodeField(reading.probedBestHwaccel)}`;
|
|
105129
|
-
}
|
|
105130
|
-
function decodeDecoderReading(value) {
|
|
105131
|
-
if (value === null) return null;
|
|
105132
|
-
const [hwaccel = NULL_FIELD, probed = NULL_FIELD] = value.split(READING_SEPARATOR);
|
|
105133
|
-
return {
|
|
105134
|
-
hwaccel: decodeField(hwaccel),
|
|
105135
|
-
probedBestHwaccel: decodeField(probed)
|
|
105136
|
-
};
|
|
105137
|
-
}
|
|
105138
|
-
function createDecoderReadingMemo(options) {
|
|
105139
|
-
const cache = createHwAccelCache(options);
|
|
105140
|
-
return {
|
|
105141
|
-
read() {
|
|
105142
|
-
const cached = cache.read();
|
|
105143
|
-
return cached === void 0 ? void 0 : decodeDecoderReading(cached);
|
|
105144
|
-
},
|
|
105145
|
-
write(reading) {
|
|
105146
|
-
cache.write(encodeDecoderReading(reading));
|
|
105147
|
-
}
|
|
105148
|
-
};
|
|
105149
|
-
}
|
|
105150
|
-
function createHapDecodeMemos(now) {
|
|
105151
|
-
const options = {
|
|
105152
|
-
ttlMs: HAP_DECODE_MEMO_TTL_MS,
|
|
105153
|
-
...now ? { now } : {}
|
|
105154
|
-
};
|
|
105155
|
-
return {
|
|
105156
|
-
reading: createDecoderReadingMemo(options),
|
|
105157
|
-
failedBackend: createHwAccelCache(options)
|
|
105158
|
-
};
|
|
105159
|
-
}
|
|
105160
|
-
//#endregion
|
|
105161
106437
|
//#region src/reconcile/sync-state.ts
|
|
105162
106438
|
function syncStateFromJson(json) {
|
|
105163
106439
|
const map = /* @__PURE__ */ new Map();
|
|
@@ -105343,6 +106619,16 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
105343
106619
|
*/
|
|
105344
106620
|
decodeMemos = createHapDecodeMemos();
|
|
105345
106621
|
/**
|
|
106622
|
+
* Where HomeKit's own clips are kept.
|
|
106623
|
+
*
|
|
106624
|
+
* ONE per addon: HomeKit Secure Video is a SINK — HAP has no read-back, so a
|
|
106625
|
+
* clip is ours only if we kept a copy of what we SENT — and the copy has to
|
|
106626
|
+
* be pruned against bounds that span every camera, which a per-camera store
|
|
106627
|
+
* could not see. Created at boot rather than lazily so the directory's
|
|
106628
|
+
* failure to exist is one line at boot and not a surprise mid-recording.
|
|
106629
|
+
*/
|
|
106630
|
+
hksvClipStore = null;
|
|
106631
|
+
/**
|
|
105346
106632
|
* THE bridge. Lazily created and published the first time a non-camera
|
|
105347
106633
|
* accessory needs it; never torn down while the addon runs, because an
|
|
105348
106634
|
* unpublish would drop a pairing the operator already entered a code for.
|
|
@@ -105368,6 +106654,14 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
105368
106654
|
this.lastError = errMsg(err);
|
|
105369
106655
|
this.ctx.logger.error("export-hap: HAP storage init failed", { meta: { error: this.lastError } });
|
|
105370
106656
|
}
|
|
106657
|
+
this.hksvClipStore = new HksvClipStore({
|
|
106658
|
+
logger: this.ctx.logger.child("hksv-clips"),
|
|
106659
|
+
rootDir: node_path.join(this.ctx.dataDir, CLIP_DIR_NAME),
|
|
106660
|
+
mintThumbnail: createClipThumbnailMinter({ runner: createFfmpegClipThumbnailRunner({
|
|
106661
|
+
ffmpegBinaryPath: "ffmpeg",
|
|
106662
|
+
spawnFn: node_child_process.spawn
|
|
106663
|
+
}) })
|
|
106664
|
+
});
|
|
105371
106665
|
const validKinds = new Set(SUPPORTED_MAPPER_KINDS);
|
|
105372
106666
|
const cleaned = this.config.exposed.filter((entry) => {
|
|
105373
106667
|
if (validKinds.has(entry.mapperKind)) return true;
|
|
@@ -105560,6 +106854,7 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
105560
106854
|
options: {
|
|
105561
106855
|
ptzPulseMs: this.config.ptzPulseMs,
|
|
105562
106856
|
decodeMemos: this.decodeMemos,
|
|
106857
|
+
hksvClipStore: this.hksvClipStore,
|
|
105563
106858
|
hapDeviceSettings: {
|
|
105564
106859
|
streamPreference: entrySettings.streamPreference ?? "auto",
|
|
105565
106860
|
hksvRecording: resolveHksvRecording(entrySettings),
|