@camstack/addon-export-hap 1.2.126 → 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 +2669 -1370
- package/dist/hap-export.addon.mjs +2672 -1371
- package/package.json +1 -1
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
2
3
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
3
4
|
import * as path from "node:path";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { createWriteStream, readFileSync } from "node:fs";
|
|
6
7
|
import * as fs from "node:fs/promises";
|
|
8
|
+
import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
9
|
+
import { once } from "node:events";
|
|
7
10
|
import { createSocket } from "node:dgram";
|
|
8
11
|
import { networkInterfaces } from "node:os";
|
|
9
12
|
//#region \0rolldown/runtime.js
|
|
@@ -35,43 +38,6 @@ var __copyProps = (to, from, except, desc) => {
|
|
|
35
38
|
var __toCommonJS = (mod) => __hasOwnProp.call(mod, "module.exports") ? mod["module.exports"] : __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
36
39
|
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
37
40
|
//#endregion
|
|
38
|
-
//#region src/exposed-entry.ts
|
|
39
|
-
/**
|
|
40
|
-
* Carrying an exposed-device entry across a re-expose.
|
|
41
|
-
*
|
|
42
|
-
* `exposeDevice` rebuilds its entry from scratch — display name, mapper kind,
|
|
43
|
-
* timestamp — and then REPLACES the stored one. Anything the rebuilt object
|
|
44
|
-
* does not mention is therefore destroyed, and two things it never mentioned
|
|
45
|
-
* were the per-camera settings and the capability list.
|
|
46
|
-
*
|
|
47
|
-
* The visible cost: the operator's "Source stream (HomeKit)" selector writes
|
|
48
|
-
* `low`, the addon logs `streamPreference changed — refreshing accessory
|
|
49
|
-
* {from=auto to=low}`, and the accessory that comes back derives its
|
|
50
|
-
* advertisement from DEFAULTS — `streamPreference=auto` — because the settings
|
|
51
|
-
* were dropped between the write and the rebuild. The selector only ever took
|
|
52
|
-
* effect after a full addon restart, when the settings were loaded first. A
|
|
53
|
-
* second write after the re-expose hid this: the store ended up correct, so
|
|
54
|
-
* nothing looked wrong except the stream nobody could explain.
|
|
55
|
-
*/
|
|
56
|
-
/**
|
|
57
|
-
* Fill `base` from `existing` for the given keys, letting `base` win wherever
|
|
58
|
-
* it actually says something.
|
|
59
|
-
*
|
|
60
|
-
* That asymmetry is the point: a caller who passes `capabilities` is stating a
|
|
61
|
-
* new truth and must not be overruled by the stored copy, while a caller who
|
|
62
|
-
* says nothing about `settings` is not asking for them to be erased.
|
|
63
|
-
*/
|
|
64
|
-
function carryForward(base, existing, keys) {
|
|
65
|
-
if (existing === void 0) return base;
|
|
66
|
-
const out = { ...base };
|
|
67
|
-
for (const key of keys) {
|
|
68
|
-
if (out[key] !== void 0) continue;
|
|
69
|
-
const carried = existing[key];
|
|
70
|
-
if (carried !== void 0) out[key] = carried;
|
|
71
|
-
}
|
|
72
|
-
return out;
|
|
73
|
-
}
|
|
74
|
-
//#endregion
|
|
75
41
|
//#region ../types/dist/event-category-BVDXG4tB.mjs
|
|
76
42
|
var EventCategory = /* @__PURE__ */ function(EventCategory) {
|
|
77
43
|
EventCategory["SystemBoot"] = "system.boot";
|
|
@@ -5433,7 +5399,7 @@ var ZodIssueCode = {
|
|
|
5433
5399
|
var ZodFirstPartyTypeKind;
|
|
5434
5400
|
ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
|
|
5435
5401
|
//#endregion
|
|
5436
|
-
//#region ../types/dist/sleep-
|
|
5402
|
+
//#region ../types/dist/sleep-CiR4z7r9.mjs
|
|
5437
5403
|
/**
|
|
5438
5404
|
* The audio chunk plane's byte format, and the ONE expansion from a coded
|
|
5439
5405
|
* window to float samples (D455).
|
|
@@ -6575,6 +6541,17 @@ var CamProfileSchema = _enum([
|
|
|
6575
6541
|
"mid",
|
|
6576
6542
|
"low"
|
|
6577
6543
|
]);
|
|
6544
|
+
/**
|
|
6545
|
+
* Which rung a per-camera consumer listens on: an explicit profile, or `auto`.
|
|
6546
|
+
*
|
|
6547
|
+
* `auto` is a REAL value, not an absent one — it means "resolve it by the rule
|
|
6548
|
+
* that consumer owns". It lives beside `CamProfile` because a rung is the only
|
|
6549
|
+
* operator-facing way to name a stream: a cam-stream id is broker-internal and
|
|
6550
|
+
* moves when a slot is reassigned, so a stored id silently stops meaning what
|
|
6551
|
+
* the operator picked. Today's one user is the audio analyser, whose rule is
|
|
6552
|
+
* "the lowest AVAILABLE native rung" (D537).
|
|
6553
|
+
*/
|
|
6554
|
+
var AudioStreamPickSchema = union([CamProfileSchema, literal("auto")]);
|
|
6578
6555
|
var CamStreamKindSchema = _enum([
|
|
6579
6556
|
"pull-rtsp",
|
|
6580
6557
|
"pull-rtmp",
|
|
@@ -7434,7 +7411,7 @@ method(object({ deviceId: number() }), array(StreamSourceEntrySchema)), method(o
|
|
|
7434
7411
|
input: unknown()
|
|
7435
7412
|
}), unknown(), { kind: "mutation" }), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), unknown().nullable()), method(object({ deviceId: number() }), RawStateResultSchema.nullable(), { auth: "protected" });
|
|
7436
7413
|
//#endregion
|
|
7437
|
-
//#region ../types/dist/canonical-hash-
|
|
7414
|
+
//#region ../types/dist/canonical-hash-CPK2Dy60.mjs
|
|
7438
7415
|
var AUDIO_ENCODER_BY_CODEC = {
|
|
7439
7416
|
opus: "libopus",
|
|
7440
7417
|
aac: "aac",
|
|
@@ -7492,10 +7469,17 @@ function buildInputArgs(input, decodeHwAccel) {
|
|
|
7492
7469
|
const args = [];
|
|
7493
7470
|
if (!isSoftwareDecode(decodeHwAccel)) args.push("-hwaccel", String(decodeHwAccel));
|
|
7494
7471
|
if (input.extraArgs?.length) args.push(...input.extraArgs);
|
|
7472
|
+
if (input.decodeThreadCount !== void 0 && input.decodeThreadCount > 0) args.push("-threads", String(input.decodeThreadCount));
|
|
7495
7473
|
if (input.analyzeDurationUs !== void 0) args.push("-analyzeduration", String(input.analyzeDurationUs));
|
|
7496
7474
|
if (input.probeSizeBytes !== void 0) args.push("-probesize", String(input.probeSizeBytes));
|
|
7475
|
+
if (input.lowDelay === true) args.push("-flags", "low_delay");
|
|
7476
|
+
if (input.useWallclockTimestamps === true) args.push("-use_wallclock_as_timestamps", "1");
|
|
7497
7477
|
if (input.fflags?.length) for (const flag of input.fflags) args.push("-fflags", flag);
|
|
7498
7478
|
if (input.rtspTransport) args.push("-rtsp_transport", input.rtspTransport);
|
|
7479
|
+
if (input.rawVideo !== void 0) {
|
|
7480
|
+
const raw = input.rawVideo;
|
|
7481
|
+
args.push("-f", "rawvideo", "-pix_fmt", raw.pixelFormat, "-video_size", `${String(raw.width)}x${String(raw.height)}`, "-framerate", String(raw.framerate));
|
|
7482
|
+
}
|
|
7499
7483
|
args.push("-i", input.url);
|
|
7500
7484
|
return args;
|
|
7501
7485
|
}
|
|
@@ -7528,16 +7512,29 @@ function resolveStreamMaps(selection) {
|
|
|
7528
7512
|
};
|
|
7529
7513
|
}
|
|
7530
7514
|
/** The `-vf` filter args, or `[]` when a consumer `-vf` already claims the slot. */
|
|
7531
|
-
function buildVideoFilterArgs(scale, outputArgs, filterGraph) {
|
|
7515
|
+
function buildVideoFilterArgs(scale, outputArgs, filterGraph, hwUpload) {
|
|
7532
7516
|
if (filterGraph) {
|
|
7533
7517
|
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)");
|
|
7534
7518
|
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");
|
|
7535
7519
|
return [];
|
|
7536
7520
|
}
|
|
7537
|
-
if (!scale) return [];
|
|
7538
7521
|
if (outputArgs.some((a) => a === "-vf")) return [];
|
|
7539
|
-
if (scale.
|
|
7540
|
-
|
|
7522
|
+
if (!scale) return hwUpload.length > 0 ? ["-vf", hwUpload] : [];
|
|
7523
|
+
const suffix = hwUpload.length > 0 ? `,${hwUpload}` : "";
|
|
7524
|
+
if (scale.mode === "exact") return ["-vf", `scale=${scale.width}:${scale.height}${suffix}`];
|
|
7525
|
+
return ["-vf", `scale='min(${scale.width},iw)':'min(${scale.height},ih)':force_original_aspect_ratio=decrease:force_divisible_by=2${suffix}`];
|
|
7526
|
+
}
|
|
7527
|
+
/**
|
|
7528
|
+
* The upload filter a hardware encode needs, or `''`.
|
|
7529
|
+
*
|
|
7530
|
+
* Nothing is uploaded when the DECODE already left the frames on the GPU
|
|
7531
|
+
* (`-hwaccel vaapi`): they are surfaces already, and uploading a surface is an
|
|
7532
|
+
* error rather than a no-op.
|
|
7533
|
+
*/
|
|
7534
|
+
function resolveHwUpload(device, decodeHwAccel) {
|
|
7535
|
+
if (device === void 0) return "";
|
|
7536
|
+
if (!isSoftwareDecode(decodeHwAccel)) return "";
|
|
7537
|
+
return "format=nv12,hwupload";
|
|
7541
7538
|
}
|
|
7542
7539
|
/** Rate-control args for an encode plan. */
|
|
7543
7540
|
function buildRateControlArgs(video) {
|
|
@@ -7557,7 +7554,18 @@ function buildRateControlArgs(video) {
|
|
|
7557
7554
|
];
|
|
7558
7555
|
}
|
|
7559
7556
|
/** The whole video block (`-vf` … `-c:v` … knobs), after `-i`. */
|
|
7560
|
-
function buildVideoArgs(video, outputArgs, filterGraph) {
|
|
7557
|
+
function buildVideoArgs(video, outputArgs, filterGraph, hwEncode) {
|
|
7558
|
+
if (video.kind === "raw") {
|
|
7559
|
+
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");
|
|
7560
|
+
return [
|
|
7561
|
+
...video.filter !== void 0 ? ["-vf", video.filter] : [],
|
|
7562
|
+
"-c:v",
|
|
7563
|
+
"rawvideo",
|
|
7564
|
+
"-pix_fmt",
|
|
7565
|
+
video.pixelFormat,
|
|
7566
|
+
...video.fps !== void 0 ? ["-r", String(video.fps)] : []
|
|
7567
|
+
];
|
|
7568
|
+
}
|
|
7561
7569
|
if (video.kind === "copy") {
|
|
7562
7570
|
if (filterGraph) throw new Error("FfmpegInvocation: a filter graph produces a NEW picture, so video.kind must be `encode`");
|
|
7563
7571
|
return [
|
|
@@ -7566,8 +7574,10 @@ function buildVideoArgs(video, outputArgs, filterGraph) {
|
|
|
7566
7574
|
...video.bitstreamFilter ? ["-bsf:v", video.bitstreamFilter] : []
|
|
7567
7575
|
];
|
|
7568
7576
|
}
|
|
7577
|
+
const hwUpload = hwEncode === void 0 ? "" : resolveHwUpload(hwEncode.device, hwEncode.decodeHwAccel);
|
|
7578
|
+
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");
|
|
7569
7579
|
const args = [
|
|
7570
|
-
...buildVideoFilterArgs(video.scale, outputArgs, filterGraph),
|
|
7580
|
+
...buildVideoFilterArgs(video.scale, outputArgs, filterGraph, hwUpload),
|
|
7571
7581
|
"-c:v",
|
|
7572
7582
|
video.encoder
|
|
7573
7583
|
];
|
|
@@ -7737,15 +7747,23 @@ function buildAudioSidecarArgs(sidecar) {
|
|
|
7737
7747
|
* (`0:a:0?`): the sidecar is defined as the SOURCE's microphone on its own RTP
|
|
7738
7748
|
* output, not as whatever the main output happens to carry.
|
|
7739
7749
|
*/
|
|
7750
|
+
/** The hardware-encode context for this invocation, or `undefined`. */
|
|
7751
|
+
function hwEncodeFor(inv) {
|
|
7752
|
+
return inv.hwEncodeDevice === void 0 ? void 0 : {
|
|
7753
|
+
device: inv.hwEncodeDevice,
|
|
7754
|
+
decodeHwAccel: inv.decodeHwAccel
|
|
7755
|
+
};
|
|
7756
|
+
}
|
|
7740
7757
|
function buildFfmpegArgs(inv) {
|
|
7741
7758
|
const graph = inv.filterGraph ?? null;
|
|
7742
7759
|
const head = [
|
|
7743
7760
|
...logBannerArgs(inv.logLevel),
|
|
7761
|
+
...inv.hwEncodeDevice !== void 0 ? ["-vaapi_device", inv.hwEncodeDevice.renderNode] : [],
|
|
7744
7762
|
...buildInputArgs(inv.input, inv.decodeHwAccel),
|
|
7745
7763
|
...(inv.extraInputs ?? []).flatMap((extra) => buildInputArgs(extra, inv.decodeHwAccel)),
|
|
7746
7764
|
...graph ? ["-filter_complex", graph.graph] : []
|
|
7747
7765
|
];
|
|
7748
|
-
const threadArgs = inv.threadCount > 0 ? ["-threads", String(inv.threadCount)] : [];
|
|
7766
|
+
const threadArgs = [...inv.threadCount > 0 ? ["-threads", String(inv.threadCount)] : [], ...inv.filterThreadCount !== void 0 && inv.filterThreadCount > 0 ? ["-filter_threads", String(inv.filterThreadCount)] : []];
|
|
7749
7767
|
const maps = resolveStreamMaps(inv);
|
|
7750
7768
|
const videoMapArgs = maps.video ? ["-map", maps.video] : [];
|
|
7751
7769
|
const audioMapArgs = maps.audio ? ["-map", maps.audio] : [];
|
|
@@ -7754,7 +7772,7 @@ function buildFfmpegArgs(inv) {
|
|
|
7754
7772
|
"-an",
|
|
7755
7773
|
"-map",
|
|
7756
7774
|
maps.video ?? "0:v:0",
|
|
7757
|
-
...buildVideoArgs(inv.video, inv.outputArgs, graph),
|
|
7775
|
+
...buildVideoArgs(inv.video, inv.outputArgs, graph, hwEncodeFor(inv)),
|
|
7758
7776
|
...threadArgs,
|
|
7759
7777
|
...inv.outputArgs,
|
|
7760
7778
|
...buildRtpOutputArgs(inv.sink.video)
|
|
@@ -7777,7 +7795,7 @@ function buildFfmpegArgs(inv) {
|
|
|
7777
7795
|
return [
|
|
7778
7796
|
...head,
|
|
7779
7797
|
...videoMapArgs,
|
|
7780
|
-
...buildVideoArgs(inv.video, inv.outputArgs, graph),
|
|
7798
|
+
...buildVideoArgs(inv.video, inv.outputArgs, graph, hwEncodeFor(inv)),
|
|
7781
7799
|
...threadArgs,
|
|
7782
7800
|
...mutedAudio ? [] : audioMapArgs,
|
|
7783
7801
|
...audioArgs,
|
|
@@ -8456,6 +8474,7 @@ var CameraSwitchIdSchema = _enum([
|
|
|
8456
8474
|
"device-audio",
|
|
8457
8475
|
"broker-audio",
|
|
8458
8476
|
"audio-analysis",
|
|
8477
|
+
"event-saving",
|
|
8459
8478
|
"recording",
|
|
8460
8479
|
"notifications"
|
|
8461
8480
|
]);
|
|
@@ -8930,11 +8949,31 @@ var RecordingBandTriggersSchema = RecordingTriggersSchema;
|
|
|
8930
8949
|
* band engine's `applies` rule). `start`/`end` are `HH:MM`; an `end <= start`
|
|
8931
8950
|
* span wraps past midnight (handled by the band engine).
|
|
8932
8951
|
*/
|
|
8952
|
+
/**
|
|
8953
|
+
* Whether a band's footage carries sound.
|
|
8954
|
+
*
|
|
8955
|
+
* The recorder has always attached with `audio: 'aac'`, so every band of every
|
|
8956
|
+
* camera recorded sound whether anyone wanted it or not. The broker already
|
|
8957
|
+
* serves the other answer — `audio: 'none'` takes the video-only `/muted`
|
|
8958
|
+
* restream — so this was a choice the product could always have offered.
|
|
8959
|
+
*
|
|
8960
|
+
* It belongs on the BAND rather than the camera because one camera can want
|
|
8961
|
+
* both: silent across the shop floor during opening hours, sound overnight.
|
|
8962
|
+
* And it applies to BOTH modes — an events band firing on motion has the same
|
|
8963
|
+
* question to answer as a continuous one.
|
|
8964
|
+
*/
|
|
8965
|
+
var RecordingBandAudioSchema = _enum(["on", "muted"]);
|
|
8933
8966
|
var RecordingBandSchema = object({
|
|
8934
8967
|
days: array(RecordingWeekdaySchema),
|
|
8935
8968
|
start: string().regex(HHMM),
|
|
8936
8969
|
end: string().regex(HHMM),
|
|
8937
8970
|
mode: RecordingBandModeSchema,
|
|
8971
|
+
/**
|
|
8972
|
+
* Sound, or silence. OPTIONAL and absent-means-`on`: every recording
|
|
8973
|
+
* written before this existed carried sound, and a band whose key is
|
|
8974
|
+
* missing must keep meaning what it meant. See {@link RecordingBandAudioSchema}.
|
|
8975
|
+
*/
|
|
8976
|
+
audio: RecordingBandAudioSchema.optional(),
|
|
8938
8977
|
triggers: RecordingBandTriggersSchema.optional(),
|
|
8939
8978
|
/**
|
|
8940
8979
|
* RETIRED (D381). A band no longer carries a pre-buffer of its own.
|
|
@@ -20756,6 +20795,7 @@ var MediaFileKindEnum = _enum([
|
|
|
20756
20795
|
"keyFrameSmall",
|
|
20757
20796
|
"thumbnailSmall",
|
|
20758
20797
|
"confirmationCrop",
|
|
20798
|
+
"debugCrop",
|
|
20759
20799
|
"sceneFrame"
|
|
20760
20800
|
]);
|
|
20761
20801
|
/**
|
|
@@ -22420,6 +22460,18 @@ var RunnerCameraConfigSchema = object({
|
|
|
22420
22460
|
motionStreamId: string(),
|
|
22421
22461
|
detectionStreamId: string(),
|
|
22422
22462
|
/**
|
|
22463
|
+
* Which rung this camera's audio analysis listens on — `auto` (the lowest
|
|
22464
|
+
* AVAILABLE native rung) or an explicit one.
|
|
22465
|
+
*
|
|
22466
|
+
* Not consumed by the runner: carried here so it travels with the camera's
|
|
22467
|
+
* config the way `audioMotionWindowMs` does, and so the orchestrator's
|
|
22468
|
+
* watchdog resolves the same stream the audio subscription did. OPTIONAL
|
|
22469
|
+
* with no default — absent means `auto`, which is what every camera written
|
|
22470
|
+
* before this field had, and an absent optional adds nothing to the
|
|
22471
|
+
* generated cap-input defaults (D537).
|
|
22472
|
+
*/
|
|
22473
|
+
audioStreamPick: AudioStreamPickSchema.optional(),
|
|
22474
|
+
/**
|
|
22423
22475
|
* Motion sources that can activate the pipeline. At least one must
|
|
22424
22476
|
* be present. Multiple sources OR-ed: any one firing arms the
|
|
22425
22477
|
* cooldown timer and transitions watching → active. The same
|
|
@@ -23490,10 +23542,9 @@ var ServerBootModeSchema = _enum([
|
|
|
23490
23542
|
* - `idle` / `checking` / `staging` — steady / in-flight registry work.
|
|
23491
23543
|
* - `pending-restart` — a version is staged and the node has NOT yet
|
|
23492
23544
|
* restarted onto it (still running the OLD version).
|
|
23493
|
-
* - `awaiting-confirmation` —
|
|
23494
|
-
*
|
|
23495
|
-
*
|
|
23496
|
-
* manually restarted, or the probation boot auto-rolls-back.
|
|
23545
|
+
* - `awaiting-confirmation` — RETIRED with the probation boot (single-copy
|
|
23546
|
+
* collapse, 2026-07-18). Kept in the enum so an older client still parses;
|
|
23547
|
+
* no provider emits it.
|
|
23497
23548
|
*/
|
|
23498
23549
|
var ServerUpdateStateSchema = _enum([
|
|
23499
23550
|
"idle",
|
|
@@ -23582,12 +23633,35 @@ var ServerRollbackInfoSchema = object({
|
|
|
23582
23633
|
var ServerPackageStatusSchema = object({
|
|
23583
23634
|
/** Root package name (`@camstack/server` on the hub). */
|
|
23584
23635
|
packageName: string(),
|
|
23585
|
-
/**
|
|
23636
|
+
/**
|
|
23637
|
+
* Version of the code THIS PROCESS loaded — captured once, at boot, from the
|
|
23638
|
+
* package.json of the copy the code was loaded from, and never re-read. A
|
|
23639
|
+
* copy replaced on disk under a live process must not move this number;
|
|
23640
|
+
* that divergence is what `installedVersion` is for. Before 2026-09-20 this
|
|
23641
|
+
* was a lazy disk read that would have followed such a swap silently.
|
|
23642
|
+
*/
|
|
23586
23643
|
runningVersion: string().nullable(),
|
|
23587
23644
|
/** Node.js runtime version the node's process runs on (`process.versions.node`). */
|
|
23588
23645
|
nodeRuntimeVersion: string().nullable(),
|
|
23589
23646
|
/** Active data-dir root version; null when booted from seed/workspace. */
|
|
23590
23647
|
activeVersion: string().nullable(),
|
|
23648
|
+
/**
|
|
23649
|
+
* Version of the root package in the data-root `current/` copy RIGHT NOW —
|
|
23650
|
+
* what the next boot would load if nothing is staged. Normally equal to
|
|
23651
|
+
* `runningVersion`; differs only when the copy on disk changed under the
|
|
23652
|
+
* live process. `null` when there is no data-root copy (baked seed or
|
|
23653
|
+
* workspace boot). Optional for version skew: an older provider omits it.
|
|
23654
|
+
*/
|
|
23655
|
+
installedVersion: string().nullable().optional(),
|
|
23656
|
+
/**
|
|
23657
|
+
* True when the code that would run after a restart is NOT the code running
|
|
23658
|
+
* now: a version is staged (`pendingVersion`), or `installedVersion` differs
|
|
23659
|
+
* from `runningVersion`. Derived on every read, never stored. This is the
|
|
23660
|
+
* one field that lets an operator or a script say "1.2.353 is installed and
|
|
23661
|
+
* will run after a restart" instead of reading it off a number that means
|
|
23662
|
+
* "installed". Optional for version skew.
|
|
23663
|
+
*/
|
|
23664
|
+
restartRequired: boolean().optional(),
|
|
23591
23665
|
/** N-1 version kept for rollback; null when no previous version exists. */
|
|
23592
23666
|
previousVersion: string().nullable(),
|
|
23593
23667
|
/** Version of the immutable baked seed closure (image fallback). */
|
|
@@ -23622,19 +23696,71 @@ var ServerPackageStatusSchema = object({
|
|
|
23622
23696
|
});
|
|
23623
23697
|
var ServerUpdateCheckResultSchema = object({
|
|
23624
23698
|
packageName: string(),
|
|
23699
|
+
/** Same meaning as on the status payload: what THIS process loaded. */
|
|
23625
23700
|
runningVersion: string().nullable(),
|
|
23626
23701
|
latestVersion: string().nullable(),
|
|
23702
|
+
/**
|
|
23703
|
+
* `latestVersion` is newer than `runningVersion` AND is not the version
|
|
23704
|
+
* already staged on this node. A staged-and-waiting version is not "an
|
|
23705
|
+
* update available" — applying it again is refused (`already-staged`), and
|
|
23706
|
+
* a button that announced "updating…" on this boolean then got refused
|
|
23707
|
+
* would be lying. It is reported through `pendingVersion` instead.
|
|
23708
|
+
*/
|
|
23627
23709
|
updateAvailable: boolean(),
|
|
23628
23710
|
checkedAtMs: number(),
|
|
23629
23711
|
/** Non-null when the registry lookup failed (offline, bad registry, …). */
|
|
23630
|
-
error: string().nullable()
|
|
23712
|
+
error: string().nullable(),
|
|
23713
|
+
/**
|
|
23714
|
+
* Version staged on this node and awaiting its restart, when one is. Before
|
|
23715
|
+
* 2026-09-20 a check answered `{runningVersion, updateAvailable:false}` for a
|
|
23716
|
+
* node whose staged closure had never booted, and the caller had no way to
|
|
23717
|
+
* see the difference. Optional for version skew.
|
|
23718
|
+
*/
|
|
23719
|
+
pendingVersion: string().nullable().optional(),
|
|
23720
|
+
/** See the status payload: staged, or installed-on-disk ≠ running. Optional for skew. */
|
|
23721
|
+
restartRequired: boolean().optional()
|
|
23631
23722
|
});
|
|
23723
|
+
/**
|
|
23724
|
+
* What an action DID — the discriminant `accepted` cannot carry. Before
|
|
23725
|
+
* 2026-09-20 `applyServerUpdate` answered `accepted:false` both for a refusal
|
|
23726
|
+
* where nothing was wrong (already current, already staged) and for a staging
|
|
23727
|
+
* install that died on `npm error code ECONNRESET`; the only difference was
|
|
23728
|
+
* prose in `message`, and an operator restarted a container on the strength of
|
|
23729
|
+
* it, booting the previous version.
|
|
23730
|
+
*
|
|
23731
|
+
* - `staged` — the closure is installed and validated; the node
|
|
23732
|
+
* restarts to apply it (`restarting:true`). NOTHING
|
|
23733
|
+
* runs the new code until that restart completes.
|
|
23734
|
+
* - `restart-scheduled` — a plain, version-preserving restart was accepted.
|
|
23735
|
+
* - `already-current` — the target IS the running version; nothing done.
|
|
23736
|
+
* - `refused` — dropped by a gate; `reason` names which.
|
|
23737
|
+
* - `failed` — attempted and did not land; `error` says why.
|
|
23738
|
+
*/
|
|
23739
|
+
var ServerUpdateOutcomeSchema = _enum([
|
|
23740
|
+
"staged",
|
|
23741
|
+
"restart-scheduled",
|
|
23742
|
+
"already-current",
|
|
23743
|
+
"refused",
|
|
23744
|
+
"failed"
|
|
23745
|
+
]);
|
|
23632
23746
|
var ServerUpdateActionResultSchema = object({
|
|
23633
23747
|
accepted: boolean(),
|
|
23634
23748
|
targetVersion: string().nullable(),
|
|
23635
23749
|
/** True when a graceful restart was scheduled to apply the change. */
|
|
23636
23750
|
restarting: boolean(),
|
|
23637
|
-
message: string()
|
|
23751
|
+
message: string(),
|
|
23752
|
+
/** See {@link ServerUpdateOutcomeSchema}. Optional for version skew. */
|
|
23753
|
+
outcome: ServerUpdateOutcomeSchema.optional(),
|
|
23754
|
+
/**
|
|
23755
|
+
* Stable, greppable cause for `refused` / `failed` (the engine's
|
|
23756
|
+
* `UpdateRefusalReason`, `staging-failed`, …). `null` on success.
|
|
23757
|
+
*/
|
|
23758
|
+
reason: string().nullable().optional(),
|
|
23759
|
+
/**
|
|
23760
|
+
* The failure itself, verbatim enough to act on (the npm output for a
|
|
23761
|
+
* staging install). Non-null only for `failed`.
|
|
23762
|
+
*/
|
|
23763
|
+
error: string().nullable().optional()
|
|
23638
23764
|
});
|
|
23639
23765
|
method(_void(), ServerPackageStatusSchema, { auth: "admin" }), method(_void(), ServerUpdateCheckResultSchema, {
|
|
23640
23766
|
kind: "mutation",
|
|
@@ -24911,6 +25037,104 @@ var WebrtcStreamChoiceSchema = object({
|
|
|
24911
25037
|
/** Bitrate in kbps (null for adaptive). */
|
|
24912
25038
|
bitrateKbps: number().nullable()
|
|
24913
25039
|
});
|
|
25040
|
+
/**
|
|
25041
|
+
* The last move the adaptive ladder made on this session, and the numbers it
|
|
25042
|
+
* rested on. Mirrors `LadderDecision` in the stream-broker's
|
|
25043
|
+
* `adaptive-controller.ts` — the log line's evidence, made readable.
|
|
25044
|
+
*
|
|
25045
|
+
* Nullability is load-bearing: `lossRatio: null` means the loss tracker had
|
|
25046
|
+
* no window to report, which is NOT a link with zero loss.
|
|
25047
|
+
*/
|
|
25048
|
+
var WebrtcLadderDecisionSchema = object({
|
|
25049
|
+
/** `loss` — RTCP / client-reported packet loss; `stall` — the picture
|
|
25050
|
+
* stopped moving; `healthy` — a promotion. */
|
|
25051
|
+
cause: _enum([
|
|
25052
|
+
"loss",
|
|
25053
|
+
"stall",
|
|
25054
|
+
"healthy"
|
|
25055
|
+
]),
|
|
25056
|
+
/** The rung the ladder moved TO. Null for a move that changed no rung
|
|
25057
|
+
* (an MTU reduction, a forced transcode-down at the bottom). */
|
|
25058
|
+
tier: CamProfileSchema.nullable(),
|
|
25059
|
+
/** Whether that move forced the egress transcode-down rung. */
|
|
25060
|
+
forcedTranscodeDown: boolean(),
|
|
25061
|
+
/** Loss ratio over the tracker's window at the moment of the decision. */
|
|
25062
|
+
lossRatio: number().nullable(),
|
|
25063
|
+
/** The client's reported downlink at that moment, in kbps. */
|
|
25064
|
+
clientKbps: number().nullable(),
|
|
25065
|
+
/** How long the stall detector had been seeing nothing arrive. */
|
|
25066
|
+
stalledForMs: number().nullable(),
|
|
25067
|
+
/** Whether the broker WAS sending while that was measured — the half that
|
|
25068
|
+
* separates "the camera stopped" from "the link stopped". */
|
|
25069
|
+
serverSending: boolean().nullable(),
|
|
25070
|
+
/** Epoch ms. The overlay prints its age; a decision from 40 minutes ago
|
|
25071
|
+
* must not read like one from this second. */
|
|
25072
|
+
at: number()
|
|
25073
|
+
});
|
|
25074
|
+
/** Per-tier count of MEASURED downgrades this viewer session has suffered. */
|
|
25075
|
+
var WebrtcTierFailureSchema = object({
|
|
25076
|
+
tier: CamProfileSchema,
|
|
25077
|
+
failures: number().int().nonnegative()
|
|
25078
|
+
});
|
|
25079
|
+
/**
|
|
25080
|
+
* What the SERVER decided for one WebRTC session — the half the client cannot
|
|
25081
|
+
* measure. See `getSessionDebug`.
|
|
25082
|
+
*/
|
|
25083
|
+
var WebrtcSessionDebugSchema = object({
|
|
25084
|
+
sessionId: string(),
|
|
25085
|
+
/** Whether the session still exists here. Everything below is null when
|
|
25086
|
+
* it does not — a dead session has no measurements, not zero ones. */
|
|
25087
|
+
alive: boolean(),
|
|
25088
|
+
/** Epoch ms this snapshot was taken, so the overlay can show its age. */
|
|
25089
|
+
observedAt: number(),
|
|
25090
|
+
/** The broker actually feeding this session (`<deviceId>/<camStreamId>`). */
|
|
25091
|
+
brokerId: string().nullable(),
|
|
25092
|
+
camStreamId: string().nullable(),
|
|
25093
|
+
/** `derived` = this hub BUILT the rung being served (a transcode/scale of
|
|
25094
|
+
* another stream); `native` = the camera's own encode. */
|
|
25095
|
+
sourceKind: _enum([
|
|
25096
|
+
"native",
|
|
25097
|
+
"derived",
|
|
25098
|
+
"unknown"
|
|
25099
|
+
]),
|
|
25100
|
+
/** Whether the ladder is running at all (an `adaptive` target). */
|
|
25101
|
+
adaptive: boolean(),
|
|
25102
|
+
/** The rung `auto` ACTUALLY resolved to right now. Null when the broker
|
|
25103
|
+
* is bound to no profile slot (a manually-activated stream). */
|
|
25104
|
+
servedTier: CamProfileSchema.nullable(),
|
|
25105
|
+
/** Which node ingests the source. Null when unresolved. */
|
|
25106
|
+
ingestNodeId: string().nullable(),
|
|
25107
|
+
/** Whether that node is a remote one (the media crosses a node boundary
|
|
25108
|
+
* before it reaches this session). Null when unresolved. */
|
|
25109
|
+
ingestIsRemote: boolean().nullable(),
|
|
25110
|
+
/** Codec off the source, and the one negotiated with this client. */
|
|
25111
|
+
sourceCodec: string().nullable(),
|
|
25112
|
+
negotiatedCodec: string().nullable(),
|
|
25113
|
+
/** Whether the EGRESS leg re-encodes for this client or forwards the
|
|
25114
|
+
* source bytes untouched. */
|
|
25115
|
+
egressLeg: _enum([
|
|
25116
|
+
"copy",
|
|
25117
|
+
"transcode",
|
|
25118
|
+
"unknown"
|
|
25119
|
+
]),
|
|
25120
|
+
/** What the broker itself reports about the rung it is serving. */
|
|
25121
|
+
brokerStatus: string().nullable(),
|
|
25122
|
+
brokerCodec: string().nullable(),
|
|
25123
|
+
brokerBitrateKbps: number().nullable(),
|
|
25124
|
+
brokerInputFps: number().nullable(),
|
|
25125
|
+
brokerUptimeMs: number().nullable(),
|
|
25126
|
+
/** RTP packets this session has sent — the server's own "am I sending?". */
|
|
25127
|
+
rtpPacketsSent: number().nullable(),
|
|
25128
|
+
/** D42: the track may be carrying the recorded feeder, not live. The ladder
|
|
25129
|
+
* is suspended there, so a frozen picture in `recorded` mode means
|
|
25130
|
+
* something entirely different. */
|
|
25131
|
+
trackMode: _enum(["live", "recorded"]).nullable(),
|
|
25132
|
+
lastDecision: WebrtcLadderDecisionSchema.nullable(),
|
|
25133
|
+
tierFailures: array(WebrtcTierFailureSchema),
|
|
25134
|
+
/** Successful promotions in this viewer session (drives the progressive
|
|
25135
|
+
* healthy timer). Null when no ladder is running. */
|
|
25136
|
+
upgradeCount: number().nullable()
|
|
25137
|
+
});
|
|
24914
25138
|
DeviceType.Camera, method(object({ deviceId: number().int().nonnegative() }), array(WebrtcStreamChoiceSchema)), method(object({
|
|
24915
25139
|
deviceId: number().int().nonnegative(),
|
|
24916
25140
|
target: WebrtcStreamTargetSchema,
|
|
@@ -25075,7 +25299,10 @@ DeviceType.Camera, method(object({ deviceId: number().int().nonnegative() }), ar
|
|
|
25075
25299
|
* the same answer as "it ended": either way nothing is holding it up.
|
|
25076
25300
|
*/
|
|
25077
25301
|
alive: boolean()
|
|
25078
|
-
}))
|
|
25302
|
+
})), method(object({
|
|
25303
|
+
deviceId: number().int().nonnegative(),
|
|
25304
|
+
sessionId: string()
|
|
25305
|
+
}), WebrtcSessionDebugSchema, { kind: "query" });
|
|
25079
25306
|
object({
|
|
25080
25307
|
/** All accessory children of the parent. */
|
|
25081
25308
|
childDeviceIds: array(number()).readonly(),
|
|
@@ -28135,11 +28362,15 @@ DeviceType.Light, DeviceType.Siren, DeviceType.Switch, method(object({
|
|
|
28135
28362
|
*
|
|
28136
28363
|
* Adding a fifth name to that list would have been the wrong fix twice over:
|
|
28137
28364
|
* that page is per-camera DETECTION tuning, and a grid's geometry belongs
|
|
28138
|
-
* beside PTZ and motion zones on the camera itself
|
|
28139
|
-
*
|
|
28140
|
-
*
|
|
28141
|
-
*
|
|
28142
|
-
*
|
|
28365
|
+
* beside PTZ and motion zones on the camera itself. The device page is
|
|
28366
|
+
* BINDING-driven (D12), so the way in is a capability bound to the device —
|
|
28367
|
+
* and this cap carries its section the way `recording` does, by RETURNING it
|
|
28368
|
+
* from `getDeviceSettingsContribution`.
|
|
28369
|
+
*
|
|
28370
|
+
* Seven other widgets are still declared the other way, through a
|
|
28371
|
+
* `deviceConfig.ui` block the framework derives a section from. That route
|
|
28372
|
+
* gives the addon no say in where its own panel lands and no way to decline
|
|
28373
|
+
* for a device the panel does not suit, which is why this one does not use it.
|
|
28143
28374
|
*
|
|
28144
28375
|
* ## Why one addon may implement it
|
|
28145
28376
|
*
|
|
@@ -28195,16 +28426,37 @@ var GridProfileOfferSchema = object({
|
|
|
28195
28426
|
]),
|
|
28196
28427
|
offered: boolean(),
|
|
28197
28428
|
/** Sources that cannot serve it. Empty when it is offered, or when there are no cells. */
|
|
28198
|
-
missingSources: array(number().int().positive())
|
|
28429
|
+
missingSources: array(number().int().positive()),
|
|
28430
|
+
/**
|
|
28431
|
+
* The canvas this profile composes onto, `WxH`, or empty when it is not
|
|
28432
|
+
* offered. DERIVED from the cells and the sources' own size at this profile —
|
|
28433
|
+
* it is reported because nothing else in the system would ever say what the
|
|
28434
|
+
* grid came out as, and because it is the number an operator would otherwise
|
|
28435
|
+
* expect to type.
|
|
28436
|
+
*/
|
|
28437
|
+
canvas: string(),
|
|
28438
|
+
/**
|
|
28439
|
+
* Whether this profile is PUBLISHED, of the ones the grid could serve.
|
|
28440
|
+
*
|
|
28441
|
+
* A grid's `high` is composed of its sources' `high`, so on a 4K fleet it is
|
|
28442
|
+
* a 4K canvas built from 4K decodes — something to opt into, not something a
|
|
28443
|
+
* viewer's adaptive should be handed by climbing to the top rung it can see.
|
|
28444
|
+
* Default is `mid` + `low`.
|
|
28445
|
+
*/
|
|
28446
|
+
published: boolean()
|
|
28199
28447
|
});
|
|
28200
28448
|
var GridLayoutViewSchema = object({
|
|
28201
28449
|
/** The persisted grid row this camera was declared from. */
|
|
28202
28450
|
instanceId: string(),
|
|
28203
28451
|
deviceId: number().int().nonnegative(),
|
|
28204
28452
|
name: string(),
|
|
28205
|
-
/**
|
|
28206
|
-
|
|
28207
|
-
|
|
28453
|
+
/**
|
|
28454
|
+
* NO canvas size. A grid's resolution is not authored: each profile derives
|
|
28455
|
+
* its own from the cells and its sources' dimensions. The two numbers that
|
|
28456
|
+
* used to be here were a text field that silently decided both how much the
|
|
28457
|
+
* composite cost and how sharp it was — see `profiles[].canvas` for what it
|
|
28458
|
+
* came out as.
|
|
28459
|
+
*/
|
|
28208
28460
|
fps: number().int(),
|
|
28209
28461
|
cells: array(GridLayoutCellSchema),
|
|
28210
28462
|
/** What the catalog will publish, and what it refuses to. Read-only. */
|
|
@@ -28213,9 +28465,13 @@ var GridLayoutViewSchema = object({
|
|
|
28213
28465
|
var GridLayoutPatchSchema = object({
|
|
28214
28466
|
deviceId: number().int().nonnegative(),
|
|
28215
28467
|
name: string().min(1).max(160).optional(),
|
|
28216
|
-
width: number().int().min(160).max(7680).optional(),
|
|
28217
|
-
height: number().int().min(120).max(4320).optional(),
|
|
28218
28468
|
fps: number().int().min(1).max(60).optional(),
|
|
28469
|
+
/** Which profiles to publish. See `GridProfileOffer.published`. */
|
|
28470
|
+
publishedProfiles: array(_enum([
|
|
28471
|
+
"high",
|
|
28472
|
+
"mid",
|
|
28473
|
+
"low"
|
|
28474
|
+
])).max(3).optional(),
|
|
28219
28475
|
/**
|
|
28220
28476
|
* The whole cell list at once. A per-cell patch would need an ordering the
|
|
28221
28477
|
* editor does not have, and a half-applied layout is a picture nobody asked
|
|
@@ -38795,6 +39051,12 @@ Object.freeze({
|
|
|
38795
39051
|
addonId: null,
|
|
38796
39052
|
access: "view"
|
|
38797
39053
|
},
|
|
39054
|
+
"webrtcSession.getSessionDebug": {
|
|
39055
|
+
capName: "webrtc-session",
|
|
39056
|
+
capScope: "device",
|
|
39057
|
+
addonId: null,
|
|
39058
|
+
access: "view"
|
|
39059
|
+
},
|
|
38798
39060
|
"webrtcSession.getSessionState": {
|
|
38799
39061
|
capName: "webrtc-session",
|
|
38800
39062
|
capScope: "device",
|
|
@@ -40778,6 +41040,11 @@ Object.freeze({
|
|
|
40778
41040
|
form: "single",
|
|
40779
41041
|
optional: false
|
|
40780
41042
|
}],
|
|
41043
|
+
"webrtcSession.getSessionDebug": [{
|
|
41044
|
+
name: "deviceId",
|
|
41045
|
+
form: "single",
|
|
41046
|
+
optional: false
|
|
41047
|
+
}],
|
|
40781
41048
|
"webrtcSession.getSessionState": [{
|
|
40782
41049
|
name: "deviceId",
|
|
40783
41050
|
form: "single",
|
|
@@ -41396,6 +41663,60 @@ DEFAULT_NATIVE_LEASE_SETTINGS.sceneBudgetMb;
|
|
|
41396
41663
|
DEFAULT_NATIVE_LEASE_SETTINGS.admission;
|
|
41397
41664
|
var MB = 1024 * 1024;
|
|
41398
41665
|
1024 * MB, 3072 * MB;
|
|
41666
|
+
//#endregion
|
|
41667
|
+
//#region ../types/dist/node.mjs
|
|
41668
|
+
/**
|
|
41669
|
+
* The ffmpeg build every CamStack node downloads, per platform and arch.
|
|
41670
|
+
*
|
|
41671
|
+
* ## Why a download, on every node, including the container
|
|
41672
|
+
*
|
|
41673
|
+
* The version of ffmpeg used to be CODE: baked into the image, changeable only
|
|
41674
|
+
* by rebuilding and rolling it. Now it is DATA — this file states the release,
|
|
41675
|
+
* and a node that boots with a different one downloads it. Changing ffmpeg is a
|
|
41676
|
+
* framework bump, not an image rebuild.
|
|
41677
|
+
*
|
|
41678
|
+
* It also settles the licence question by removing it. FFmpeg's licence is
|
|
41679
|
+
* chosen at compile time: the core is LGPL 2.1+, `--enable-gpl` makes it GPL
|
|
41680
|
+
* (for x264/x265), `--enable-version3` makes that v3, and `--enable-nonfree`
|
|
41681
|
+
* makes the result undistributable. These are GPL v3 builds, and we do not
|
|
41682
|
+
* distribute them: the user's own node fetches the archive from the project
|
|
41683
|
+
* that publishes it, so we convey no GPL work and inherit none of the
|
|
41684
|
+
* obligations that come with conveying one. That is the whole reason the image
|
|
41685
|
+
* no longer installs ffmpeg at all — not a technical preference.
|
|
41686
|
+
*
|
|
41687
|
+
* ## Why jellyfin-ffmpeg
|
|
41688
|
+
*
|
|
41689
|
+
* One vendor covering linux-amd64, linux-arm64, darwin-arm64 and darwin-x64
|
|
41690
|
+
* from one release, with the widest hardware surface of anything maintained:
|
|
41691
|
+
* VAAPI, QSV, NVENC/NVDEC, AMF, Vulkan, OpenCL and DRM on amd64, plus Rockchip
|
|
41692
|
+
* MPP on arm64. It reaches the vendor libraries through dlopen trampolines,
|
|
41693
|
+
* so the binary is not linked against a driver stack it may not find.
|
|
41694
|
+
*
|
|
41695
|
+
* **What it still needs from the host, measured 2026-09-19.** On the Unraid
|
|
41696
|
+
* HOST, which has no libva, `-vaapi_device` aborts the process outright —
|
|
41697
|
+
* `implib-gen: libva-drm.so.2: failed to load library ... Assertion '0 &&
|
|
41698
|
+
* "Assertion in generated code"' failed`. Inside our container, which installs
|
|
41699
|
+
* libva plus the iHD driver, the same command encodes: verified end to end with
|
|
41700
|
+
* `testsrc → format=nv12,hwupload → h264_vaapi`. So the image drops ffmpeg and
|
|
41701
|
+
* KEEPS the Intel media stack; the drivers are the part a binary cannot bring.
|
|
41702
|
+
*
|
|
41703
|
+
* ## Why each artifact carries a checksum and a claim
|
|
41704
|
+
*
|
|
41705
|
+
* The download is now the only source, so `sha256` is verified before anything
|
|
41706
|
+
* is extracted — jellyfin publishes no checksums on its release assets, so
|
|
41707
|
+
* these are ours, taken at the version bump. And `expectedHwaccels` is what the
|
|
41708
|
+
* build is supposed to be able to do: a binary that probes short of its own
|
|
41709
|
+
* claim is a source that changed under us, which is precisely what went
|
|
41710
|
+
* unnoticed for months when a static build turned out to carry no VAAPI at all
|
|
41711
|
+
* (D536).
|
|
41712
|
+
*/
|
|
41713
|
+
/** The pinned jellyfin-ffmpeg release. Changing this is the version change. */
|
|
41714
|
+
var FFMPEG_RELEASE = "8.1.2-5";
|
|
41715
|
+
var BASE = `https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v${FFMPEG_RELEASE}`;
|
|
41716
|
+
function portable(target) {
|
|
41717
|
+
return `${BASE}/jellyfin-ffmpeg_${FFMPEG_RELEASE}_portable_${target}-gpl.tar.xz`;
|
|
41718
|
+
}
|
|
41719
|
+
portable("linux64"), portable("linuxarm64"), portable("macarm64"), portable("mac64");
|
|
41399
41720
|
var DEFAULT_FIRST_UNIT_TIMEOUT_MS = 12e3;
|
|
41400
41721
|
/** Heartbeat cadence — ~2 minutes of 4 s fragments. */
|
|
41401
41722
|
var FRAGMENT_LOG_EVERY = 30;
|
|
@@ -88377,6 +88698,43 @@ var BridgeHost = class {
|
|
|
88377
88698
|
}
|
|
88378
88699
|
};
|
|
88379
88700
|
//#endregion
|
|
88701
|
+
//#region src/exposed-entry.ts
|
|
88702
|
+
/**
|
|
88703
|
+
* Carrying an exposed-device entry across a re-expose.
|
|
88704
|
+
*
|
|
88705
|
+
* `exposeDevice` rebuilds its entry from scratch — display name, mapper kind,
|
|
88706
|
+
* timestamp — and then REPLACES the stored one. Anything the rebuilt object
|
|
88707
|
+
* does not mention is therefore destroyed, and two things it never mentioned
|
|
88708
|
+
* were the per-camera settings and the capability list.
|
|
88709
|
+
*
|
|
88710
|
+
* The visible cost: the operator's "Source stream (HomeKit)" selector writes
|
|
88711
|
+
* `low`, the addon logs `streamPreference changed — refreshing accessory
|
|
88712
|
+
* {from=auto to=low}`, and the accessory that comes back derives its
|
|
88713
|
+
* advertisement from DEFAULTS — `streamPreference=auto` — because the settings
|
|
88714
|
+
* were dropped between the write and the rebuild. The selector only ever took
|
|
88715
|
+
* effect after a full addon restart, when the settings were loaded first. A
|
|
88716
|
+
* second write after the re-expose hid this: the store ended up correct, so
|
|
88717
|
+
* nothing looked wrong except the stream nobody could explain.
|
|
88718
|
+
*/
|
|
88719
|
+
/**
|
|
88720
|
+
* Fill `base` from `existing` for the given keys, letting `base` win wherever
|
|
88721
|
+
* it actually says something.
|
|
88722
|
+
*
|
|
88723
|
+
* That asymmetry is the point: a caller who passes `capabilities` is stating a
|
|
88724
|
+
* new truth and must not be overruled by the stored copy, while a caller who
|
|
88725
|
+
* says nothing about `settings` is not asking for them to be erased.
|
|
88726
|
+
*/
|
|
88727
|
+
function carryForward(base, existing, keys) {
|
|
88728
|
+
if (existing === void 0) return base;
|
|
88729
|
+
const out = { ...base };
|
|
88730
|
+
for (const key of keys) {
|
|
88731
|
+
if (out[key] !== void 0) continue;
|
|
88732
|
+
const carried = existing[key];
|
|
88733
|
+
if (carried !== void 0) out[key] = carried;
|
|
88734
|
+
}
|
|
88735
|
+
return out;
|
|
88736
|
+
}
|
|
88737
|
+
//#endregion
|
|
88380
88738
|
//#region src/hap-setup-uri.ts
|
|
88381
88739
|
function errMsg$15(e) {
|
|
88382
88740
|
return e instanceof Error ? e.message : String(e);
|
|
@@ -88411,47 +88769,610 @@ function firstExposedAccessorySetupUri(exposed, logger) {
|
|
|
88411
88769
|
}
|
|
88412
88770
|
}
|
|
88413
88771
|
//#endregion
|
|
88414
|
-
//#region src/
|
|
88772
|
+
//#region src/hksv/clip-record.ts
|
|
88773
|
+
/**
|
|
88774
|
+
* What ONE teed HomeKit clip is, on disk.
|
|
88775
|
+
*
|
|
88776
|
+
* ## Why a sidecar at all
|
|
88777
|
+
*
|
|
88778
|
+
* HomeKit Secure Video is a SINK. HAP's `dataSend` is `hello/whoami/open/data/
|
|
88779
|
+
* ack/close` and `CameraRecordingDelegate` is push-only, so a clip "recorded by
|
|
88780
|
+
* HomeKit" can only ever be ours if we kept our own copy of what we SENT.
|
|
88781
|
+
* There is no read-back to reconcile against later, and there never will be.
|
|
88782
|
+
* Everything a later reader can know about a teed clip therefore has to be
|
|
88783
|
+
* written down at the moment it was written — by the only process that ever saw
|
|
88784
|
+
* it — or it is lost with the session.
|
|
88785
|
+
*
|
|
88786
|
+
* ## The two coverage limits, stated on the record rather than discovered
|
|
88787
|
+
*
|
|
88788
|
+
* - **Only some cameras record at all.** `pickRecordingSource` refuses anything
|
|
88789
|
+
* without an H.264 slot, `MAX_RECORDING_HEIGHT` prefers ≤ 1080p, and
|
|
88790
|
+
* `deriveFragmentLengthMs` refuses a GOP longer than 8 s. A camera the tee
|
|
88791
|
+
* never writes for is not a gap in this store, it is a camera HKSV never
|
|
88792
|
+
* recorded; {@link HksvClipRecord} exists only for the ones it did.
|
|
88793
|
+
* - **The duration is whatever iOS PULLED.** `durationMs` is the wall-clock
|
|
88794
|
+
* span of the HDS stream (prebuffer replay plus however long the hub kept it
|
|
88795
|
+
* open), not a window CamStack chose. A short clip is not a truncated one —
|
|
88796
|
+
* {@link HksvClipRecord.truncated} is the only thing that says truncated.
|
|
88797
|
+
*
|
|
88798
|
+
* ## `thumbnailFile` is VOUCHED
|
|
88799
|
+
*
|
|
88800
|
+
* It is written only after the JPEG has been stat'd on disk at non-zero size.
|
|
88801
|
+
* A minter that reports success and produced nothing leaves
|
|
88802
|
+
* {@link HksvClipRecord.thumbnailUnavailable} instead — the whole point of
|
|
88803
|
+
* vouching is that the promise and the file are not the same claim.
|
|
88804
|
+
*/
|
|
88805
|
+
/** The `videoclips` source id a later provider will list these under. */
|
|
88806
|
+
var HKSV_CLIP_SOURCE = "hksv";
|
|
88807
|
+
/** Why no still could be minted from the saved fMP4. Named, never blank. */
|
|
88808
|
+
var HksvThumbnailUnavailableReasonSchema = _enum([
|
|
88809
|
+
"decode-failed",
|
|
88810
|
+
"timed-out",
|
|
88811
|
+
"ffmpeg-missing",
|
|
88812
|
+
"no-init-segment",
|
|
88813
|
+
"not-attempted"
|
|
88814
|
+
]);
|
|
88815
|
+
/** Why a clip stopped short of the stream that fed it. */
|
|
88816
|
+
var HksvClipTruncationSchema = _enum([
|
|
88817
|
+
"size-cap",
|
|
88818
|
+
"slow-consumer",
|
|
88819
|
+
"write-failed"
|
|
88820
|
+
]);
|
|
88821
|
+
var HksvClipRecordSchema = object({
|
|
88822
|
+
/** Bumped when a field's MEANING changes; a reader refuses what it cannot read. */
|
|
88823
|
+
schemaVersion: literal(1),
|
|
88824
|
+
/** `hksv:<deviceId>:<startedAtMs>-<streamId>` — self-contained, so a later
|
|
88825
|
+
* provider addresses bytes and stills without re-listing. */
|
|
88826
|
+
clipId: string().min(1),
|
|
88827
|
+
source: literal(HKSV_CLIP_SOURCE),
|
|
88828
|
+
deviceId: number().int(),
|
|
88829
|
+
/** The HDS stream this clip was teed from — the join to the delegate's logs. */
|
|
88830
|
+
streamId: number().int(),
|
|
88831
|
+
startedAtMs: number().int(),
|
|
88832
|
+
endedAtMs: number().int(),
|
|
88833
|
+
/** `endedAtMs - startedAtMs`. What iOS pulled, never a window we chose. */
|
|
88834
|
+
durationMs: number().int().nonnegative(),
|
|
88835
|
+
/** How much of the clip's head came from the prebuffer ring, by ARRIVAL age. */
|
|
88836
|
+
prebufferSpanMs: number().int().nonnegative(),
|
|
88837
|
+
bytes: number().int().nonnegative(),
|
|
88838
|
+
fragments: number().int().nonnegative(),
|
|
88839
|
+
/** Basenames, relative to the clip's own device directory. */
|
|
88840
|
+
file: string().min(1),
|
|
88841
|
+
thumbnailFile: string().min(1).optional(),
|
|
88842
|
+
thumbnailUnavailable: object({ reason: HksvThumbnailUnavailableReasonSchema }).optional(),
|
|
88843
|
+
truncated: HksvClipTruncationSchema.optional(),
|
|
88844
|
+
/** iOS sent `ack` for this stream: HomeKit itself kept the clip. */
|
|
88845
|
+
acknowledgedByHomeKit: boolean(),
|
|
88846
|
+
width: number().int().positive(),
|
|
88847
|
+
height: number().int().positive(),
|
|
88848
|
+
/** The advertised fragment length the source was cutting at. */
|
|
88849
|
+
fragmentMs: number().int().positive()
|
|
88850
|
+
});
|
|
88415
88851
|
/**
|
|
88416
|
-
*
|
|
88417
|
-
*
|
|
88418
|
-
*
|
|
88419
|
-
* One implementation for both accessory shapes (camera and generic): the
|
|
88420
|
-
* fields come from the device's own metadata either way, and a second copy
|
|
88421
|
-
* would be a second answer to "what serial does this device publish".
|
|
88422
|
-
* Metadata is best-effort — a device that cannot answer still publishes.
|
|
88852
|
+
* One stem per clip, three extensions. Derived from the id and from nothing
|
|
88853
|
+
* else, so a pruner deleting a clip never has to guess which JPEG was its.
|
|
88423
88854
|
*/
|
|
88424
|
-
|
|
88425
|
-
const
|
|
88426
|
-
|
|
88855
|
+
function clipFileNames(clipId) {
|
|
88856
|
+
const stem = clipStem(clipId);
|
|
88857
|
+
return {
|
|
88858
|
+
clip: `${stem}.mp4`,
|
|
88859
|
+
thumbnail: `${stem}.jpg`,
|
|
88860
|
+
record: `${stem}.json`
|
|
88861
|
+
};
|
|
88862
|
+
}
|
|
88863
|
+
function clipIdFor(deviceId, startedAtMs, streamId) {
|
|
88864
|
+
return `${HKSV_CLIP_SOURCE}:${deviceId}:${startedAtMs}-${streamId}`;
|
|
88865
|
+
}
|
|
88866
|
+
/** The filesystem-safe tail of an id. `:` is legal on ext4 and hostile everywhere else. */
|
|
88867
|
+
function clipStem(clipId) {
|
|
88868
|
+
return clipId.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
88869
|
+
}
|
|
88870
|
+
//#endregion
|
|
88871
|
+
//#region src/hksv/clip-store.ts
|
|
88872
|
+
/**
|
|
88873
|
+
* `HksvClipStore` — who owns the teed clips, and for how long.
|
|
88874
|
+
*
|
|
88875
|
+
* ## The owner
|
|
88876
|
+
*
|
|
88877
|
+
* The **export-hap addon**, in its OWN data dir, and nothing else. Not the
|
|
88878
|
+
* recorder's storage locations and not the `eventMedia` class:
|
|
88879
|
+
*
|
|
88880
|
+
* - `export-hap` is hub-only and runs in its own runner. The recorder's
|
|
88881
|
+
* location machinery (D385/D386/D389) is another addon's, in another
|
|
88882
|
+
* process, and reaching it would be a cross-runner write on the frame path
|
|
88883
|
+
* of a live HomeKit session — exactly what D9/D18 forbid.
|
|
88884
|
+
* - A teed clip is a DERIVED artifact of a HomeKit session, with no track, no
|
|
88885
|
+
* detection and no event behind it. Filing it under `eventMedia` would put
|
|
88886
|
+
* it inside the post-analysis retention sweep, where its lifetime would be
|
|
88887
|
+
* decided by a policy written for a different question.
|
|
88888
|
+
* - The precedent is D549 decision 13: Reolink's clip thumbnails live in the
|
|
88889
|
+
* vendor addon's data dir under a per-device LRU, outside `eventMedia` and
|
|
88890
|
+
* outside the sweep, for the same reason.
|
|
88891
|
+
*
|
|
88892
|
+
* Layout: `<dataDir>/hksv-clips/<deviceId>/<stem>.{mp4,jpg,json}`. One stem per
|
|
88893
|
+
* clip, three extensions, so a pruner deleting a clip never has to guess which
|
|
88894
|
+
* JPEG was its.
|
|
88895
|
+
*
|
|
88896
|
+
* ## The retention, and why these numbers
|
|
88897
|
+
*
|
|
88898
|
+
* HomeKit's own retention is unreadable — HAP has no read-back, so CamStack can
|
|
88899
|
+
* never ask "does iOS still have this one?" and reconcile. The store therefore
|
|
88900
|
+
* cannot mirror HomeKit's lifetime and does not pretend to: it keeps a bounded
|
|
88901
|
+
* recent window and says, per clip, exactly when it started. Four bounds, all
|
|
88902
|
+
* enforced on every completion:
|
|
88903
|
+
*
|
|
88904
|
+
* - **{@link DEFAULT_CLIP_BOUNDS.maxClipBytes} per clip (256 MB).** A clip's
|
|
88905
|
+
* duration is whatever iOS pulled — there is no window we choose — so this
|
|
88906
|
+
* is the only ceiling on a session that never closes. At the ~64 KB/s a
|
|
88907
|
+
* 1080p copy branch produces it is over an hour of video.
|
|
88908
|
+
* - **{@link DEFAULT_CLIP_BOUNDS.maxClipsPerDevice} (200)** and
|
|
88909
|
+
* **{@link DEFAULT_CLIP_BOUNDS.maxBytesPerDevice} (512 MB)**, oldest first.
|
|
88910
|
+
* Measured fragment sizes (D84): ~255 KB per 8 s at 720p, ~500 KB at 1080p,
|
|
88911
|
+
* so a typical 20-40 s HKSV clip is 1.5-3 MB and 200 of them is ~400 MB.
|
|
88912
|
+
* Both bounds exist for the same reason the prebuffer ring has both: at 4K
|
|
88913
|
+
* a fragment is 6.35 MB, a 25x spread, and a count-only bound is a
|
|
88914
|
+
* per-camera disk figure nobody can predict.
|
|
88915
|
+
* - **{@link DEFAULT_CLIP_BOUNDS.maxAgeMs} (14 days)**, so a camera that
|
|
88916
|
+
* stopped recording in March does not hold half a gigabyte forever.
|
|
88917
|
+
* - **{@link DEFAULT_CLIP_BOUNDS.maxStoreBytes} (4 GB)** across every camera,
|
|
88918
|
+
* applied after the per-device pass. Per-device bounds alone multiply by a
|
|
88919
|
+
* camera count the operator changes without thinking about this store.
|
|
88920
|
+
*
|
|
88921
|
+
* A file nobody prunes is a disk that fills, and the pruner is the only thing
|
|
88922
|
+
* standing between a permanent prebuffer and that.
|
|
88923
|
+
*
|
|
88924
|
+
* ## The thumbnail is VOUCHED, never promised
|
|
88925
|
+
*
|
|
88926
|
+
* The minter is injected and its verdict is not believed: after it returns the
|
|
88927
|
+
* store stats the JPEG, and `thumbnailFile` is written only if a non-empty file
|
|
88928
|
+
* is really there. A minter that reports success and produced nothing gets
|
|
88929
|
+
* `thumbnailUnavailable.reason: 'decode-failed'` and its zero-byte leftover
|
|
88930
|
+
* deleted — this repo has twice shipped a green test because the fake supplied
|
|
88931
|
+
* what production forgot, and a promise is not a file.
|
|
88932
|
+
*/
|
|
88933
|
+
/** The directory under the addon's data dir. Ours, and only ours. */
|
|
88934
|
+
var CLIP_DIR_NAME = "hksv-clips";
|
|
88935
|
+
/** See the class docblock for what each number is, and where it comes from. */
|
|
88936
|
+
var DEFAULT_CLIP_BOUNDS = {
|
|
88937
|
+
maxClipBytes: 256 * 1024 * 1024,
|
|
88938
|
+
maxClipsPerDevice: 200,
|
|
88939
|
+
maxBytesPerDevice: 512 * 1024 * 1024,
|
|
88940
|
+
maxAgeMs: 336 * 60 * 60 * 1e3,
|
|
88941
|
+
maxStoreBytes: 4 * 1024 * 1024 * 1024
|
|
88942
|
+
};
|
|
88943
|
+
var HksvClipStore = class {
|
|
88944
|
+
input;
|
|
88945
|
+
log;
|
|
88946
|
+
bounds;
|
|
88947
|
+
now;
|
|
88948
|
+
inFlight = /* @__PURE__ */ new Map();
|
|
88949
|
+
constructor(input) {
|
|
88950
|
+
this.input = input;
|
|
88951
|
+
this.log = input.logger;
|
|
88952
|
+
this.bounds = {
|
|
88953
|
+
...DEFAULT_CLIP_BOUNDS,
|
|
88954
|
+
...input.bounds
|
|
88955
|
+
};
|
|
88956
|
+
this.now = input.now ?? Date.now;
|
|
88957
|
+
}
|
|
88958
|
+
/** The per-clip ceiling the tee enforces while writing. */
|
|
88959
|
+
get maxClipBytes() {
|
|
88960
|
+
return this.bounds.maxClipBytes;
|
|
88961
|
+
}
|
|
88962
|
+
/**
|
|
88963
|
+
* Open a file for one HDS stream. `null` when the directory cannot be made —
|
|
88964
|
+
* the caller must then skip the tee entirely and leave HKSV untouched, which
|
|
88965
|
+
* is why this returns rather than throws.
|
|
88966
|
+
*/
|
|
88967
|
+
async open(input) {
|
|
88968
|
+
const clipId = clipIdFor(input.deviceId, input.startedAtMs, input.streamId);
|
|
88969
|
+
const names = clipFileNames(clipId);
|
|
88970
|
+
const dir = this.deviceDir(input.deviceId);
|
|
88971
|
+
const tags = { deviceId: input.deviceId };
|
|
88972
|
+
try {
|
|
88973
|
+
await mkdir(dir, { recursive: true });
|
|
88974
|
+
const sink = await createFileSink(join(dir, names.clip));
|
|
88975
|
+
this.inFlight.set(clipId, {
|
|
88976
|
+
deviceId: input.deviceId,
|
|
88977
|
+
stem: stemOf(names.clip)
|
|
88978
|
+
});
|
|
88979
|
+
return {
|
|
88980
|
+
clipId,
|
|
88981
|
+
sink,
|
|
88982
|
+
complete: (outcome) => this.complete(clipId, input, names, outcome)
|
|
88983
|
+
};
|
|
88984
|
+
} catch (err) {
|
|
88985
|
+
this.inFlight.delete(clipId);
|
|
88986
|
+
this.log.warn("hksv clip store: could NOT open a clip file — nothing is teed for this stream", {
|
|
88987
|
+
tags,
|
|
88988
|
+
meta: {
|
|
88989
|
+
streamId: input.streamId,
|
|
88990
|
+
error: err instanceof Error ? err.message : String(err)
|
|
88991
|
+
}
|
|
88992
|
+
});
|
|
88993
|
+
return null;
|
|
88994
|
+
}
|
|
88995
|
+
}
|
|
88996
|
+
/** Every sidecar for a camera, newest first. What a later provider lists. */
|
|
88997
|
+
async listRecords(deviceId) {
|
|
88998
|
+
const dir = this.deviceDir(deviceId);
|
|
88999
|
+
let names;
|
|
89000
|
+
try {
|
|
89001
|
+
names = await readdir(dir);
|
|
89002
|
+
} catch {
|
|
89003
|
+
return [];
|
|
89004
|
+
}
|
|
89005
|
+
const records = [];
|
|
89006
|
+
for (const name of names) {
|
|
89007
|
+
if (!name.endsWith(".json")) continue;
|
|
89008
|
+
const record = await this.readRecord(join(dir, name), deviceId);
|
|
89009
|
+
if (record !== null) records.push(record);
|
|
89010
|
+
}
|
|
89011
|
+
return records.sort((a, b) => b.startedAtMs - a.startedAtMs);
|
|
89012
|
+
}
|
|
89013
|
+
/** iOS sent `ack`: HomeKit itself kept this clip. Recorded on the sidecar. */
|
|
89014
|
+
async acknowledge(deviceId, clipId) {
|
|
89015
|
+
const path = join(this.deviceDir(deviceId), clipFileNames(clipId).record);
|
|
89016
|
+
const record = await this.readRecord(path, deviceId);
|
|
89017
|
+
if (record === null) return;
|
|
89018
|
+
await this.writeRecord(path, {
|
|
89019
|
+
...record,
|
|
89020
|
+
acknowledgedByHomeKit: true
|
|
89021
|
+
});
|
|
89022
|
+
}
|
|
89023
|
+
deviceDir(deviceId) {
|
|
89024
|
+
return join(this.input.rootDir, String(deviceId));
|
|
89025
|
+
}
|
|
89026
|
+
async complete(clipId, input, names, outcome) {
|
|
89027
|
+
this.inFlight.delete(clipId);
|
|
89028
|
+
const dir = this.deviceDir(input.deviceId);
|
|
89029
|
+
const tags = { deviceId: input.deviceId };
|
|
89030
|
+
const clipPath = join(dir, names.clip);
|
|
89031
|
+
if (!outcome.sawInit || outcome.bytes === 0) {
|
|
89032
|
+
await rm(clipPath, { force: true });
|
|
89033
|
+
this.log.warn("hksv clip tee: DROPPED the clip — it carries no init segment, so nothing in it is decodable", {
|
|
89034
|
+
tags,
|
|
89035
|
+
meta: {
|
|
89036
|
+
clipId,
|
|
89037
|
+
streamId: input.streamId,
|
|
89038
|
+
bytes: outcome.bytes,
|
|
89039
|
+
fragments: outcome.fragments
|
|
89040
|
+
}
|
|
89041
|
+
});
|
|
89042
|
+
return null;
|
|
89043
|
+
}
|
|
89044
|
+
const thumbnail = await this.mintAndVouch({
|
|
89045
|
+
deviceId: input.deviceId,
|
|
89046
|
+
clipPath,
|
|
89047
|
+
jpegPath: join(dir, names.thumbnail),
|
|
89048
|
+
triggerOffsetSec: input.prebufferSpanMs / 1e3
|
|
89049
|
+
});
|
|
89050
|
+
const record = {
|
|
89051
|
+
schemaVersion: 1,
|
|
89052
|
+
clipId,
|
|
89053
|
+
source: HKSV_CLIP_SOURCE,
|
|
89054
|
+
deviceId: input.deviceId,
|
|
89055
|
+
streamId: input.streamId,
|
|
89056
|
+
startedAtMs: outcome.startedAtMs,
|
|
89057
|
+
endedAtMs: outcome.endedAtMs,
|
|
89058
|
+
durationMs: Math.max(0, outcome.endedAtMs - outcome.startedAtMs),
|
|
89059
|
+
prebufferSpanMs: input.prebufferSpanMs,
|
|
89060
|
+
bytes: outcome.bytes,
|
|
89061
|
+
fragments: outcome.fragments,
|
|
89062
|
+
file: names.clip,
|
|
89063
|
+
...thumbnail.ok ? { thumbnailFile: names.thumbnail } : { thumbnailUnavailable: { reason: thumbnail.reason } },
|
|
89064
|
+
...outcome.truncated === null ? {} : { truncated: outcome.truncated },
|
|
89065
|
+
acknowledgedByHomeKit: false,
|
|
89066
|
+
width: input.width,
|
|
89067
|
+
height: input.height,
|
|
89068
|
+
fragmentMs: input.fragmentMs
|
|
89069
|
+
};
|
|
89070
|
+
await this.writeRecord(join(dir, names.record), record);
|
|
89071
|
+
this.log.info("hksv clip tee: a HomeKit clip was KEPT", {
|
|
89072
|
+
tags,
|
|
89073
|
+
meta: {
|
|
89074
|
+
clipId,
|
|
89075
|
+
streamId: input.streamId,
|
|
89076
|
+
bytes: record.bytes,
|
|
89077
|
+
fragments: record.fragments,
|
|
89078
|
+
durationMs: record.durationMs,
|
|
89079
|
+
prebufferSpanMs: record.prebufferSpanMs,
|
|
89080
|
+
truncated: record.truncated ?? null,
|
|
89081
|
+
thumbnail: record.thumbnailFile ?? null,
|
|
89082
|
+
thumbnailUnavailable: record.thumbnailUnavailable?.reason ?? null
|
|
89083
|
+
}
|
|
89084
|
+
});
|
|
89085
|
+
await this.prune(input.deviceId);
|
|
89086
|
+
return record;
|
|
89087
|
+
}
|
|
89088
|
+
/**
|
|
89089
|
+
* Mint, then CHECK. The minter's own verdict decides nothing on its own: a
|
|
89090
|
+
* JPEG is vouched by `stat`, never by a return value.
|
|
89091
|
+
*/
|
|
89092
|
+
async mintAndVouch(mint) {
|
|
89093
|
+
const { deviceId, jpegPath } = mint;
|
|
89094
|
+
const minter = this.input.mintThumbnail;
|
|
89095
|
+
if (minter === void 0) return {
|
|
89096
|
+
ok: false,
|
|
89097
|
+
reason: "not-attempted"
|
|
89098
|
+
};
|
|
89099
|
+
let declared;
|
|
89100
|
+
try {
|
|
89101
|
+
declared = await minter(mint);
|
|
89102
|
+
} catch (err) {
|
|
89103
|
+
this.log.warn("hksv clip tee: the thumbnail minter threw — the clip is kept WITHOUT a still", {
|
|
89104
|
+
tags: { deviceId },
|
|
89105
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
89106
|
+
});
|
|
89107
|
+
declared = {
|
|
89108
|
+
ok: false,
|
|
89109
|
+
reason: "decode-failed"
|
|
89110
|
+
};
|
|
89111
|
+
}
|
|
89112
|
+
if (await fileSize(jpegPath) > 0) return { ok: true };
|
|
89113
|
+
await rm(jpegPath, { force: true });
|
|
89114
|
+
const reason = declared.ok ? "decode-failed" : declared.reason;
|
|
89115
|
+
this.log.warn("hksv clip tee: no thumbnail for this clip", {
|
|
89116
|
+
tags: { deviceId },
|
|
89117
|
+
meta: {
|
|
89118
|
+
reason,
|
|
89119
|
+
declaredOk: declared.ok
|
|
89120
|
+
}
|
|
89121
|
+
});
|
|
89122
|
+
return {
|
|
89123
|
+
ok: false,
|
|
89124
|
+
reason
|
|
89125
|
+
};
|
|
89126
|
+
}
|
|
89127
|
+
/**
|
|
89128
|
+
* Enforce every bound, oldest first. Enumerated from the MP4s rather than the
|
|
89129
|
+
* sidecars, so a stem whose sidecar never landed — a crash mid-write — is
|
|
89130
|
+
* seen and removed instead of occupying the disk invisibly forever.
|
|
89131
|
+
*/
|
|
89132
|
+
async prune(deviceId) {
|
|
89133
|
+
const cutoff = this.now() - this.bounds.maxAgeMs;
|
|
89134
|
+
const perDevice = await this.scanDevice(deviceId);
|
|
89135
|
+
await this.enforce(perDevice, cutoff, this.bounds.maxClipsPerDevice, this.bounds.maxBytesPerDevice);
|
|
89136
|
+
let deviceIds;
|
|
89137
|
+
try {
|
|
89138
|
+
deviceIds = (await readdir(this.input.rootDir, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => Number.parseInt(e.name, 10)).filter((id) => Number.isFinite(id));
|
|
89139
|
+
} catch {
|
|
89140
|
+
return;
|
|
89141
|
+
}
|
|
89142
|
+
const all = [];
|
|
89143
|
+
for (const id of deviceIds) all.push(...await this.scanDevice(id));
|
|
89144
|
+
await this.enforce(all, cutoff, Number.POSITIVE_INFINITY, this.bounds.maxStoreBytes);
|
|
89145
|
+
}
|
|
89146
|
+
async enforce(clips, ageCutoff, maxCount, maxBytes) {
|
|
89147
|
+
const ordered = [...clips].sort((a, b) => b.startedAtMs - a.startedAtMs);
|
|
89148
|
+
let kept = 0;
|
|
89149
|
+
let bytes = 0;
|
|
89150
|
+
for (const clip of ordered) {
|
|
89151
|
+
const tooOld = clip.startedAtMs < ageCutoff;
|
|
89152
|
+
const overCount = kept >= maxCount;
|
|
89153
|
+
const overBytes = bytes + clip.bytes > maxBytes;
|
|
89154
|
+
if (!tooOld && !overCount && !overBytes) {
|
|
89155
|
+
kept += 1;
|
|
89156
|
+
bytes += clip.bytes;
|
|
89157
|
+
continue;
|
|
89158
|
+
}
|
|
89159
|
+
await this.deleteClip(clip, tooOld ? "age" : overCount ? "count" : "bytes");
|
|
89160
|
+
}
|
|
89161
|
+
}
|
|
89162
|
+
async deleteClip(clip, reason) {
|
|
89163
|
+
for (const name of [
|
|
89164
|
+
`${clip.stem}.mp4`,
|
|
89165
|
+
`${clip.stem}.jpg`,
|
|
89166
|
+
`${clip.stem}.json`
|
|
89167
|
+
]) await rm(join(clip.dir, name), { force: true });
|
|
89168
|
+
this.log.info("hksv clip store: pruned a clip", {
|
|
89169
|
+
tags: { deviceId: clip.deviceId },
|
|
89170
|
+
meta: {
|
|
89171
|
+
stem: clip.stem,
|
|
89172
|
+
bytes: clip.bytes,
|
|
89173
|
+
reason,
|
|
89174
|
+
startedAtMs: clip.startedAtMs
|
|
89175
|
+
}
|
|
89176
|
+
});
|
|
89177
|
+
}
|
|
89178
|
+
async scanDevice(deviceId) {
|
|
89179
|
+
const dir = this.deviceDir(deviceId);
|
|
89180
|
+
let names;
|
|
89181
|
+
try {
|
|
89182
|
+
names = await readdir(dir);
|
|
89183
|
+
} catch {
|
|
89184
|
+
return [];
|
|
89185
|
+
}
|
|
89186
|
+
const clips = [];
|
|
89187
|
+
for (const name of names) {
|
|
89188
|
+
if (!name.endsWith(".mp4")) continue;
|
|
89189
|
+
const stem = stemOf(name);
|
|
89190
|
+
if (this.isInFlight(deviceId, stem)) continue;
|
|
89191
|
+
const bytes = await fileSize(join(dir, name));
|
|
89192
|
+
const record = await this.readRecord(join(dir, `${stem}.json`), deviceId);
|
|
89193
|
+
if (record === null) {
|
|
89194
|
+
await rm(join(dir, name), { force: true });
|
|
89195
|
+
this.log.warn("hksv clip store: removed an orphaned clip file (no sidecar, not being written)", {
|
|
89196
|
+
tags: { deviceId },
|
|
89197
|
+
meta: {
|
|
89198
|
+
stem,
|
|
89199
|
+
bytes
|
|
89200
|
+
}
|
|
89201
|
+
});
|
|
89202
|
+
continue;
|
|
89203
|
+
}
|
|
89204
|
+
clips.push({
|
|
89205
|
+
deviceId,
|
|
89206
|
+
dir,
|
|
89207
|
+
stem,
|
|
89208
|
+
bytes,
|
|
89209
|
+
startedAtMs: record.startedAtMs
|
|
89210
|
+
});
|
|
89211
|
+
}
|
|
89212
|
+
return clips;
|
|
89213
|
+
}
|
|
89214
|
+
isInFlight(deviceId, stem) {
|
|
89215
|
+
for (const entry of this.inFlight.values()) if (entry.deviceId === deviceId && entry.stem === stem) return true;
|
|
89216
|
+
return false;
|
|
89217
|
+
}
|
|
89218
|
+
async readRecord(path, deviceId) {
|
|
89219
|
+
let raw;
|
|
89220
|
+
try {
|
|
89221
|
+
raw = await readFile(path, "utf8");
|
|
89222
|
+
} catch {
|
|
89223
|
+
return null;
|
|
89224
|
+
}
|
|
89225
|
+
const parsed = HksvClipRecordSchema.safeParse(JSON.parse(raw));
|
|
89226
|
+
if (parsed.success) return parsed.data;
|
|
89227
|
+
this.log.warn("hksv clip store: a sidecar could not be read — the clip is not listed", {
|
|
89228
|
+
tags: { deviceId },
|
|
89229
|
+
meta: {
|
|
89230
|
+
path,
|
|
89231
|
+
error: parsed.error.message
|
|
89232
|
+
}
|
|
89233
|
+
});
|
|
89234
|
+
return null;
|
|
89235
|
+
}
|
|
89236
|
+
async writeRecord(path, record) {
|
|
89237
|
+
await writeFile(path, JSON.stringify(record, null, 2), "utf8");
|
|
89238
|
+
}
|
|
89239
|
+
};
|
|
89240
|
+
function stemOf(fileName) {
|
|
89241
|
+
return fileName.replace(/\.[^.]+$/, "");
|
|
89242
|
+
}
|
|
89243
|
+
async function fileSize(path) {
|
|
88427
89244
|
try {
|
|
88428
|
-
|
|
88429
|
-
|
|
88430
|
-
|
|
88431
|
-
|
|
88432
|
-
info.setCharacteristic(import_dist.Characteristic.Model, stringOr(metadata?.model, modelFallback));
|
|
88433
|
-
info.setCharacteristic(import_dist.Characteristic.FirmwareRevision, stringOr(metadata?.firmware, "0.0.0"));
|
|
88434
|
-
info.setCharacteristic(import_dist.Characteristic.SerialNumber, stringOr(metadata?.sn, `camstack-${proxy.deviceId}`));
|
|
88435
|
-
} catch {}
|
|
89245
|
+
return (await stat(path)).size;
|
|
89246
|
+
} catch {
|
|
89247
|
+
return 0;
|
|
89248
|
+
}
|
|
88436
89249
|
}
|
|
88437
|
-
/**
|
|
88438
|
-
*
|
|
88439
|
-
|
|
88440
|
-
|
|
88441
|
-
|
|
88442
|
-
|
|
89250
|
+
/**
|
|
89251
|
+
* A sink over a real file that HONOURS backpressure: `write()` returning false
|
|
89252
|
+
* means the kernel has not taken the bytes, and ignoring it is how a stream
|
|
89253
|
+
* grows a queue nobody bounded (D447).
|
|
89254
|
+
*/
|
|
89255
|
+
async function createFileSink(path) {
|
|
89256
|
+
const stream = createWriteStream(path);
|
|
89257
|
+
await once(stream, "open");
|
|
88443
89258
|
return {
|
|
88444
|
-
|
|
88445
|
-
|
|
88446
|
-
|
|
88447
|
-
|
|
89259
|
+
async write(chunk) {
|
|
89260
|
+
if (!stream.write(chunk)) await once(stream, "drain");
|
|
89261
|
+
},
|
|
89262
|
+
async close() {
|
|
89263
|
+
await new Promise((resolve, reject) => {
|
|
89264
|
+
stream.end((err) => err ? reject(err) : resolve());
|
|
89265
|
+
});
|
|
89266
|
+
}
|
|
88448
89267
|
};
|
|
88449
89268
|
}
|
|
88450
|
-
function
|
|
88451
|
-
return
|
|
89269
|
+
function buildClipThumbnailArgs(input) {
|
|
89270
|
+
return [
|
|
89271
|
+
"-hide_banner",
|
|
89272
|
+
"-loglevel",
|
|
89273
|
+
"error",
|
|
89274
|
+
"-nostdin",
|
|
89275
|
+
"-y",
|
|
89276
|
+
"-ss",
|
|
89277
|
+
String(Math.max(0, Math.floor(input.seekSec))),
|
|
89278
|
+
"-i",
|
|
89279
|
+
input.clipPath,
|
|
89280
|
+
"-frames:v",
|
|
89281
|
+
"1",
|
|
89282
|
+
"-vf",
|
|
89283
|
+
`scale='min(${input.maxWidth},iw)':-2`,
|
|
89284
|
+
"-f",
|
|
89285
|
+
"image2",
|
|
89286
|
+
"-vcodec",
|
|
89287
|
+
"mjpeg",
|
|
89288
|
+
input.jpegPath
|
|
89289
|
+
];
|
|
88452
89290
|
}
|
|
88453
|
-
function
|
|
88454
|
-
return
|
|
89291
|
+
function createClipThumbnailMinter(input) {
|
|
89292
|
+
return async ({ clipPath, jpegPath, triggerOffsetSec }) => {
|
|
89293
|
+
const args = buildClipThumbnailArgs({
|
|
89294
|
+
clipPath,
|
|
89295
|
+
jpegPath,
|
|
89296
|
+
seekSec: triggerOffsetSec,
|
|
89297
|
+
maxWidth: input.maxWidth ?? 640
|
|
89298
|
+
});
|
|
89299
|
+
let run;
|
|
89300
|
+
try {
|
|
89301
|
+
run = await input.runner(args);
|
|
89302
|
+
} catch {
|
|
89303
|
+
return {
|
|
89304
|
+
ok: false,
|
|
89305
|
+
reason: "decode-failed"
|
|
89306
|
+
};
|
|
89307
|
+
}
|
|
89308
|
+
if (run.spawnFailed) return {
|
|
89309
|
+
ok: false,
|
|
89310
|
+
reason: "ffmpeg-missing"
|
|
89311
|
+
};
|
|
89312
|
+
if (run.timedOut) return {
|
|
89313
|
+
ok: false,
|
|
89314
|
+
reason: "timed-out"
|
|
89315
|
+
};
|
|
89316
|
+
if (run.code !== 0) return {
|
|
89317
|
+
ok: false,
|
|
89318
|
+
reason: "decode-failed"
|
|
89319
|
+
};
|
|
89320
|
+
return { ok: true };
|
|
89321
|
+
};
|
|
89322
|
+
}
|
|
89323
|
+
/**
|
|
89324
|
+
* The production runner: one bounded ffmpeg, killed at the deadline.
|
|
89325
|
+
*
|
|
89326
|
+
* Bounded and killed, not merely awaited — an ffmpeg that never exits on a
|
|
89327
|
+
* clip it cannot parse would otherwise hold a process per clip on a hub that
|
|
89328
|
+
* already runs a permanent prebuffer per recorded camera.
|
|
89329
|
+
*/
|
|
89330
|
+
function createFfmpegClipThumbnailRunner(input) {
|
|
89331
|
+
const timeoutMs = input.timeoutMs ?? 1e4;
|
|
89332
|
+
return (args) => new Promise((resolve) => {
|
|
89333
|
+
let settled = false;
|
|
89334
|
+
const finish = (run) => {
|
|
89335
|
+
if (settled) return;
|
|
89336
|
+
settled = true;
|
|
89337
|
+
clearTimeout(timer);
|
|
89338
|
+
resolve(run);
|
|
89339
|
+
};
|
|
89340
|
+
const child = input.spawnFn(input.ffmpegBinaryPath, [...args], { stdio: [
|
|
89341
|
+
"ignore",
|
|
89342
|
+
"ignore",
|
|
89343
|
+
"pipe"
|
|
89344
|
+
] });
|
|
89345
|
+
let stderr = "";
|
|
89346
|
+
child.stderr?.on("data", (chunk) => {
|
|
89347
|
+
if (stderr.length < 2e3) stderr += chunk.toString("utf8");
|
|
89348
|
+
});
|
|
89349
|
+
const timer = setTimeout(() => {
|
|
89350
|
+
child.kill("SIGKILL");
|
|
89351
|
+
finish({
|
|
89352
|
+
code: null,
|
|
89353
|
+
timedOut: true,
|
|
89354
|
+
spawnFailed: false,
|
|
89355
|
+
stderr
|
|
89356
|
+
});
|
|
89357
|
+
}, timeoutMs);
|
|
89358
|
+
timer.unref?.();
|
|
89359
|
+
child.on("error", (err) => {
|
|
89360
|
+
finish({
|
|
89361
|
+
code: null,
|
|
89362
|
+
timedOut: false,
|
|
89363
|
+
spawnFailed: true,
|
|
89364
|
+
stderr: err.message
|
|
89365
|
+
});
|
|
89366
|
+
});
|
|
89367
|
+
child.on("close", (code) => {
|
|
89368
|
+
finish({
|
|
89369
|
+
code,
|
|
89370
|
+
timedOut: false,
|
|
89371
|
+
spawnFailed: false,
|
|
89372
|
+
stderr
|
|
89373
|
+
});
|
|
89374
|
+
});
|
|
89375
|
+
});
|
|
88455
89376
|
}
|
|
88456
89377
|
//#endregion
|
|
88457
89378
|
//#region src/mappers/builders/generic/characteristic-update.ts
|
|
@@ -88781,6 +89702,910 @@ function errMsg$14(err) {
|
|
|
88781
89702
|
return err instanceof Error ? err.message : String(err);
|
|
88782
89703
|
}
|
|
88783
89704
|
//#endregion
|
|
89705
|
+
//#region src/mappers/builders/child-switch.ts
|
|
89706
|
+
/**
|
|
89707
|
+
* Child-switch builder — turns a camstack accessory child device (siren,
|
|
89708
|
+
* floodlight, spotlight, …) into a HomeKit Service ON THE PARENT
|
|
89709
|
+
* camera Accessory.
|
|
89710
|
+
*
|
|
89711
|
+
* The orchestrator (`child-accessory.ts → buildChildServicesFor`)
|
|
89712
|
+
* hands this builder the PARENT accessory + the CHILD's proxy + a
|
|
89713
|
+
* unique subtype so each service round-trips deterministically through
|
|
89714
|
+
* HAP serialisation. The builder:
|
|
89715
|
+
* - Maps `switch` cap → `Service.Switch`.
|
|
89716
|
+
* - Maps `switch` + `brightness` cap → `Service.Lightbulb` with
|
|
89717
|
+
* Brightness characteristic (lighting devices only — see below).
|
|
89718
|
+
* - Hydrates initial state from `getStatus({})`.
|
|
89719
|
+
* - Wires `Characteristic.On` / `Characteristic.Brightness` through
|
|
89720
|
+
* the typed DeviceProxy.
|
|
89721
|
+
* - Subscribes to runtime-state slice updates so iOS Home reflects
|
|
89722
|
+
* external state changes (e.g. another integration toggling the
|
|
89723
|
+
* light).
|
|
89724
|
+
*
|
|
89725
|
+
* Service-kind selection: most cameras expose `brightness` on a siren
|
|
89726
|
+
* because it controls the alarm VOLUME, not luminosity. Mapping a
|
|
89727
|
+
* siren onto `Service.Lightbulb` makes it appear as a regular light in
|
|
89728
|
+
* iOS Home (wrong icon, wrong scenes, wrong Siri intents). We therefore
|
|
89729
|
+
* gate `Service.Lightbulb` on the child's `DeviceType` — only Light /
|
|
89730
|
+
* Generic devices keep the Lightbulb shape; Siren, Switch, and others
|
|
89731
|
+
* fall back to `Service.Switch`. Volume control for sirens is exposed
|
|
89732
|
+
* through the camstack admin UI; iOS Home only needs the on/off toggle.
|
|
89733
|
+
*
|
|
89734
|
+
* Child devices without `switch` / `brightness` cap (status sensors,
|
|
89735
|
+
* IR LED, etc.) return a no-op handle.
|
|
89736
|
+
*/
|
|
89737
|
+
async function buildChildSwitch(bctx, subtype, deviceType) {
|
|
89738
|
+
const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
|
|
89739
|
+
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
89740
|
+
const hasSwitch = !!proxy.switch;
|
|
89741
|
+
const hasBrightness = !!proxy.brightness;
|
|
89742
|
+
if (!hasSwitch && !hasBrightness) {
|
|
89743
|
+
log.debug("export-hap: child has no switch/brightness cap — skipping");
|
|
89744
|
+
return { async dispose() {} };
|
|
89745
|
+
}
|
|
89746
|
+
const isLightingDevice = deviceType === DeviceType.Light || deviceType === DeviceType.Generic;
|
|
89747
|
+
const useLightbulb = hasBrightness && isLightingDevice;
|
|
89748
|
+
const service = useLightbulb ? accessory.addService(import_dist.Service.Lightbulb, displayName, subtype) : accessory.addService(import_dist.Service.Switch, displayName, subtype);
|
|
89749
|
+
applyServiceLabel(service, displayName);
|
|
89750
|
+
try {
|
|
89751
|
+
const switchStatus = await proxy.switch?.getStatus({});
|
|
89752
|
+
if (switchStatus && typeof switchStatus.on === "boolean") service.updateCharacteristic(import_dist.Characteristic.On, switchStatus.on);
|
|
89753
|
+
} catch (err) {
|
|
89754
|
+
log.debug("export-hap: child switch.getStatus failed (non-fatal)", { meta: { error: errMsg$13(err) } });
|
|
89755
|
+
}
|
|
89756
|
+
if (useLightbulb) try {
|
|
89757
|
+
const status = await proxy.brightness?.getStatus({});
|
|
89758
|
+
if (status && typeof status.percentage === "number") service.updateCharacteristic(import_dist.Characteristic.Brightness, status.percentage);
|
|
89759
|
+
} catch (err) {
|
|
89760
|
+
log.debug("export-hap: child brightness.getStatus failed (non-fatal)", { meta: { error: errMsg$13(err) } });
|
|
89761
|
+
}
|
|
89762
|
+
if (hasSwitch) service.getCharacteristic(import_dist.Characteristic.On).onSet(async (value) => {
|
|
89763
|
+
const on = value === true;
|
|
89764
|
+
try {
|
|
89765
|
+
await proxy.switch?.setState({ on });
|
|
89766
|
+
} catch (err) {
|
|
89767
|
+
log.warn("export-hap: child switch.setState failed", { meta: {
|
|
89768
|
+
on,
|
|
89769
|
+
error: errMsg$13(err)
|
|
89770
|
+
} });
|
|
89771
|
+
}
|
|
89772
|
+
});
|
|
89773
|
+
if (useLightbulb) service.getCharacteristic(import_dist.Characteristic.Brightness).onSet(async (value) => {
|
|
89774
|
+
const percentage = typeof value === "number" ? value : Number(value);
|
|
89775
|
+
if (!Number.isFinite(percentage)) return;
|
|
89776
|
+
try {
|
|
89777
|
+
await proxy.brightness?.setBrightness({ percentage });
|
|
89778
|
+
} catch (err) {
|
|
89779
|
+
log.warn("export-hap: child brightness.setBrightness failed", { meta: {
|
|
89780
|
+
percentage,
|
|
89781
|
+
error: errMsg$13(err)
|
|
89782
|
+
} });
|
|
89783
|
+
}
|
|
89784
|
+
});
|
|
89785
|
+
const unsubscribes = [];
|
|
89786
|
+
if (proxy.state.switch) {
|
|
89787
|
+
const unsubSwitch = proxy.state.switch.subscribe((value) => {
|
|
89788
|
+
if (value && typeof value.on === "boolean") service.updateCharacteristic(import_dist.Characteristic.On, value.on);
|
|
89789
|
+
});
|
|
89790
|
+
unsubscribes.push(unsubSwitch);
|
|
89791
|
+
}
|
|
89792
|
+
if (useLightbulb && proxy.state.brightness) {
|
|
89793
|
+
const unsubBrightness = proxy.state.brightness.subscribe((value) => {
|
|
89794
|
+
if (value && typeof value.percentage === "number") service.updateCharacteristic(import_dist.Characteristic.Brightness, value.percentage);
|
|
89795
|
+
});
|
|
89796
|
+
unsubscribes.push(unsubBrightness);
|
|
89797
|
+
}
|
|
89798
|
+
return { async dispose() {
|
|
89799
|
+
for (const u of unsubscribes) try {
|
|
89800
|
+
u();
|
|
89801
|
+
} catch {}
|
|
89802
|
+
} };
|
|
89803
|
+
}
|
|
89804
|
+
function errMsg$13(err) {
|
|
89805
|
+
return err instanceof Error ? err.message : String(err);
|
|
89806
|
+
}
|
|
89807
|
+
//#endregion
|
|
89808
|
+
//#region src/mappers/builders/generic/lock.ts
|
|
89809
|
+
/**
|
|
89810
|
+
* `lock-control` → `Service.LockMechanism`.
|
|
89811
|
+
*
|
|
89812
|
+
* Not a sensor row: HomeKit models a lock as two characteristics, a CURRENT
|
|
89813
|
+
* state the accessory owns and a TARGET state the controller writes, and the
|
|
89814
|
+
* cap's five states do not map onto either one alone.
|
|
89815
|
+
*
|
|
89816
|
+
* ```
|
|
89817
|
+
* cap state LockCurrentState LockTargetState
|
|
89818
|
+
* locked SECURED SECURED
|
|
89819
|
+
* unlocked UNSECURED UNSECURED
|
|
89820
|
+
* locking UNKNOWN SECURED (in flight — do not claim SECURED)
|
|
89821
|
+
* unlocking UNKNOWN UNSECURED
|
|
89822
|
+
* jammed JAMMED unchanged (the controller's intent stands)
|
|
89823
|
+
* ```
|
|
89824
|
+
*
|
|
89825
|
+
* Reporting SECURED while the bolt is still moving is the failure worth naming:
|
|
89826
|
+
* iOS renders the lock as closed, the operator walks away, and the motor stalls
|
|
89827
|
+
* behind them. `UNKNOWN` is the honest answer for an in-flight transition.
|
|
89828
|
+
*
|
|
89829
|
+
* `open` (the cap's third method, for locks with a latch/buzzer) has no HomeKit
|
|
89830
|
+
* counterpart on this service and is deliberately not wired — a Switch that
|
|
89831
|
+
* silently buzzes a door open would be a second knob nobody asked for.
|
|
89832
|
+
*/
|
|
89833
|
+
var SUBTYPE$1 = "lock-control";
|
|
89834
|
+
var readLock = reader(LockControlStatusSchema.pick({ state: true }), (status) => {
|
|
89835
|
+
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;
|
|
89836
|
+
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;
|
|
89837
|
+
return [{
|
|
89838
|
+
characteristic: import_dist.Characteristic.LockCurrentState,
|
|
89839
|
+
value: current
|
|
89840
|
+
}, ...target === null ? [] : [{
|
|
89841
|
+
characteristic: import_dist.Characteristic.LockTargetState,
|
|
89842
|
+
value: target
|
|
89843
|
+
}]];
|
|
89844
|
+
});
|
|
89845
|
+
async function buildLockMechanism(bctx) {
|
|
89846
|
+
const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
|
|
89847
|
+
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
89848
|
+
const label = hapServiceName([displayName], `Lock ${numericDeviceId}`);
|
|
89849
|
+
const service = accessory.addService(import_dist.Service.LockMechanism, label, SUBTYPE$1);
|
|
89850
|
+
applyServiceLabel(service, label);
|
|
89851
|
+
const apply = (status, source) => {
|
|
89852
|
+
const updates = readLock(status);
|
|
89853
|
+
if (updates.length === 0) {
|
|
89854
|
+
log.debug("export-hap: lock status did not match the cap schema — no update", { meta: { source } });
|
|
89855
|
+
return;
|
|
89856
|
+
}
|
|
89857
|
+
applyUpdates(service, updates);
|
|
89858
|
+
};
|
|
89859
|
+
try {
|
|
89860
|
+
const status = await proxy.lockControl?.getStatus({});
|
|
89861
|
+
if (status !== void 0 && status !== null) apply(status, "getStatus");
|
|
89862
|
+
} catch (err) {
|
|
89863
|
+
log.debug("export-hap: lock getStatus hydrate failed (non-fatal)", { meta: { error: errMsg$12(err) } });
|
|
89864
|
+
}
|
|
89865
|
+
service.getCharacteristic(import_dist.Characteristic.LockTargetState).onSet(async (value) => {
|
|
89866
|
+
const secure = value === import_dist.Characteristic.LockTargetState.SECURED;
|
|
89867
|
+
try {
|
|
89868
|
+
if (secure) await proxy.lockControl?.lock({});
|
|
89869
|
+
else await proxy.lockControl?.unlock({});
|
|
89870
|
+
} catch (err) {
|
|
89871
|
+
log.warn("export-hap: lock command failed", { meta: {
|
|
89872
|
+
secure,
|
|
89873
|
+
error: errMsg$12(err)
|
|
89874
|
+
} });
|
|
89875
|
+
}
|
|
89876
|
+
});
|
|
89877
|
+
const unsubscribe = proxy.state.lockControl?.subscribe((value) => {
|
|
89878
|
+
if (value === void 0 || value === null) return;
|
|
89879
|
+
apply(value, "slice");
|
|
89880
|
+
}) ?? null;
|
|
89881
|
+
return { async dispose() {
|
|
89882
|
+
try {
|
|
89883
|
+
unsubscribe?.();
|
|
89884
|
+
} catch {}
|
|
89885
|
+
} };
|
|
89886
|
+
}
|
|
89887
|
+
function errMsg$12(err) {
|
|
89888
|
+
return err instanceof Error ? err.message : String(err);
|
|
89889
|
+
}
|
|
89890
|
+
//#endregion
|
|
89891
|
+
//#region src/mappers/builders/generic/security-state.ts
|
|
89892
|
+
/** Is this a settled arm state worth remembering? */
|
|
89893
|
+
function armedMemoOf(state) {
|
|
89894
|
+
return state === "armed_home" || state === "armed_away" || state === "armed_night" || state === "armed_vacation" || state === "armed_custom_bypass" ? state : null;
|
|
89895
|
+
}
|
|
89896
|
+
function currentForArmed(state) {
|
|
89897
|
+
switch (state) {
|
|
89898
|
+
case "armed_home":
|
|
89899
|
+
case "armed_custom_bypass": return import_dist.Characteristic.SecuritySystemCurrentState.STAY_ARM;
|
|
89900
|
+
case "armed_night": return import_dist.Characteristic.SecuritySystemCurrentState.NIGHT_ARM;
|
|
89901
|
+
default: return import_dist.Characteristic.SecuritySystemCurrentState.AWAY_ARM;
|
|
89902
|
+
}
|
|
89903
|
+
}
|
|
89904
|
+
function targetForArmed(state) {
|
|
89905
|
+
switch (state) {
|
|
89906
|
+
case "armed_home":
|
|
89907
|
+
case "armed_custom_bypass": return import_dist.Characteristic.SecuritySystemTargetState.STAY_ARM;
|
|
89908
|
+
case "armed_night": return import_dist.Characteristic.SecuritySystemTargetState.NIGHT_ARM;
|
|
89909
|
+
default: return import_dist.Characteristic.SecuritySystemTargetState.AWAY_ARM;
|
|
89910
|
+
}
|
|
89911
|
+
}
|
|
89912
|
+
function securityStateFor(state, memo) {
|
|
89913
|
+
const settled = armedMemoOf(state);
|
|
89914
|
+
if (settled !== null) return {
|
|
89915
|
+
current: currentForArmed(settled),
|
|
89916
|
+
target: targetForArmed(settled)
|
|
89917
|
+
};
|
|
89918
|
+
switch (state) {
|
|
89919
|
+
case "disarmed": return {
|
|
89920
|
+
current: import_dist.Characteristic.SecuritySystemCurrentState.DISARMED,
|
|
89921
|
+
target: import_dist.Characteristic.SecuritySystemTargetState.DISARM
|
|
89922
|
+
};
|
|
89923
|
+
case "arming": return {
|
|
89924
|
+
current: import_dist.Characteristic.SecuritySystemCurrentState.DISARMED,
|
|
89925
|
+
target: memo === null ? null : targetForArmed(memo)
|
|
89926
|
+
};
|
|
89927
|
+
case "disarming": return {
|
|
89928
|
+
current: memo === null ? import_dist.Characteristic.SecuritySystemCurrentState.DISARMED : currentForArmed(memo),
|
|
89929
|
+
target: import_dist.Characteristic.SecuritySystemTargetState.DISARM
|
|
89930
|
+
};
|
|
89931
|
+
case "pending": return {
|
|
89932
|
+
current: memo === null ? import_dist.Characteristic.SecuritySystemCurrentState.DISARMED : currentForArmed(memo),
|
|
89933
|
+
target: memo === null ? null : targetForArmed(memo)
|
|
89934
|
+
};
|
|
89935
|
+
default: return {
|
|
89936
|
+
current: import_dist.Characteristic.SecuritySystemCurrentState.ALARM_TRIGGERED,
|
|
89937
|
+
target: memo === null ? null : targetForArmed(memo)
|
|
89938
|
+
};
|
|
89939
|
+
}
|
|
89940
|
+
}
|
|
89941
|
+
/**
|
|
89942
|
+
* The arm mode behind a HomeKit target, or `null` for DISARM and for anything
|
|
89943
|
+
* outside the four values HomeKit defines.
|
|
89944
|
+
*
|
|
89945
|
+
* `null` is not an error the caller can ignore: DISARM is a different COMMAND,
|
|
89946
|
+
* and a value HomeKit never sends is a write this builder refuses to translate.
|
|
89947
|
+
*/
|
|
89948
|
+
function alarmArmModeFor(target) {
|
|
89949
|
+
switch (target) {
|
|
89950
|
+
case import_dist.Characteristic.SecuritySystemTargetState.STAY_ARM: return "home";
|
|
89951
|
+
case import_dist.Characteristic.SecuritySystemTargetState.AWAY_ARM: return "away";
|
|
89952
|
+
case import_dist.Characteristic.SecuritySystemTargetState.NIGHT_ARM: return "night";
|
|
89953
|
+
default: return null;
|
|
89954
|
+
}
|
|
89955
|
+
}
|
|
89956
|
+
//#endregion
|
|
89957
|
+
//#region src/mappers/builders/generic/security-target.ts
|
|
89958
|
+
/**
|
|
89959
|
+
* The panel modes behind each HomeKit target, PRIMARY first.
|
|
89960
|
+
*
|
|
89961
|
+
* The primary is the mode `alarmArmModeFor` names; the secondary is the other
|
|
89962
|
+
* mode `security-state.ts` reports through the same HomeKit value, so the two
|
|
89963
|
+
* modules agree in both directions by construction.
|
|
89964
|
+
*/
|
|
89965
|
+
var MODES_BY_TARGET = new Map([
|
|
89966
|
+
[import_dist.Characteristic.SecuritySystemTargetState.STAY_ARM, ["home", "custom_bypass"]],
|
|
89967
|
+
[import_dist.Characteristic.SecuritySystemTargetState.AWAY_ARM, ["away", "vacation"]],
|
|
89968
|
+
[import_dist.Characteristic.SecuritySystemTargetState.NIGHT_ARM, ["night"]]
|
|
89969
|
+
]);
|
|
89970
|
+
/** Arm targets in the order HomeKit numbers them; DISARM is appended last. */
|
|
89971
|
+
var ARM_TARGETS = [
|
|
89972
|
+
import_dist.Characteristic.SecuritySystemTargetState.STAY_ARM,
|
|
89973
|
+
import_dist.Characteristic.SecuritySystemTargetState.AWAY_ARM,
|
|
89974
|
+
import_dist.Characteristic.SecuritySystemTargetState.NIGHT_ARM
|
|
89975
|
+
];
|
|
89976
|
+
/**
|
|
89977
|
+
* The `validValues` for `SecuritySystemTargetState`, or `null` to leave
|
|
89978
|
+
* HomeKit's own four values in place.
|
|
89979
|
+
*
|
|
89980
|
+
* DISARM is always present: disarming is not one of the panel's arm modes and
|
|
89981
|
+
* a panel that cannot be disarmed is not a thing this cap can express.
|
|
89982
|
+
*/
|
|
89983
|
+
function targetValidValuesFor(availableModes) {
|
|
89984
|
+
if (availableModes.length === 0) return null;
|
|
89985
|
+
return [...ARM_TARGETS.filter((target) => (MODES_BY_TARGET.get(target) ?? []).some((mode) => availableModes.includes(mode))), import_dist.Characteristic.SecuritySystemTargetState.DISARM];
|
|
89986
|
+
}
|
|
89987
|
+
/**
|
|
89988
|
+
* The arm mode to send for a HomeKit target, or `null` when there is none.
|
|
89989
|
+
*
|
|
89990
|
+
* `null` is not a shrug the caller may swallow: DISARM is a different command,
|
|
89991
|
+
* and an arm target the panel accepts no mode for is a write this module
|
|
89992
|
+
* refuses to translate rather than guess at.
|
|
89993
|
+
*/
|
|
89994
|
+
function armModeForTarget(target, availableModes) {
|
|
89995
|
+
const primary = alarmArmModeFor(target);
|
|
89996
|
+
if (primary === null) return null;
|
|
89997
|
+
if (availableModes.length === 0) return primary;
|
|
89998
|
+
return (MODES_BY_TARGET.get(target) ?? [primary]).find((mode) => availableModes.includes(mode)) ?? null;
|
|
89999
|
+
}
|
|
90000
|
+
//#endregion
|
|
90001
|
+
//#region src/mappers/builders/generic/security-system.ts
|
|
90002
|
+
/**
|
|
90003
|
+
* `alarm-panel` → `Service.SecuritySystem`.
|
|
90004
|
+
*
|
|
90005
|
+
* The lock builder's shape (a current/target pair, a hydrate, an `onSet`, a
|
|
90006
|
+
* slice subscription, a dispose) applied to the one device on the fleet where
|
|
90007
|
+
* reporting a state that is not true has a consequence worse than a stalled
|
|
90008
|
+
* motor. The mapping itself is NOT here: `security-state.ts` owns what the
|
|
90009
|
+
* panel's state looks like to HomeKit and `security-target.ts` owns what
|
|
90010
|
+
* HomeKit may ask for, both pure and both tested on their own. This file is
|
|
90011
|
+
* the service around them.
|
|
90012
|
+
*
|
|
90013
|
+
* ## The memo
|
|
90014
|
+
*
|
|
90015
|
+
* `pending`, `disarming` and `triggered` do not carry the mode the panel was
|
|
90016
|
+
* armed to — only the last settled state knows it. This builder is the caller
|
|
90017
|
+
* `security-state.ts` describes: it remembers the last settled arm and forgets
|
|
90018
|
+
* it on `disarmed`, because a memo that outlives a disarm makes the next
|
|
90019
|
+
* `arming` claim a target nobody asked for.
|
|
90020
|
+
*
|
|
90021
|
+
* The memo is deliberately NOT seeded from the controller's own write. HomeKit
|
|
90022
|
+
* already holds the target the operator just set; not writing it back is
|
|
90023
|
+
* exactly right. The cost is a panel armed from its own keypad during an exit
|
|
90024
|
+
* delay, where the target stays at whatever HomeKit last knew — a stale target
|
|
90025
|
+
* for the length of the delay, against inventing an intent, and the invention
|
|
90026
|
+
* is worse.
|
|
90027
|
+
*
|
|
90028
|
+
* ## `requiresCode`: the arm is REFUSED, not attempted
|
|
90029
|
+
*
|
|
90030
|
+
* HomeKit's SecuritySystem has no PIN prompt — there is no characteristic for
|
|
90031
|
+
* it and no way to ask the operator mid-write. So a panel that requires a code
|
|
90032
|
+
* cannot be armed or disarmed from the Home app, and this builder says so
|
|
90033
|
+
* twice: the write fails with `INSUFFICIENT_AUTHORIZATION` (iOS renders the
|
|
90034
|
+
* tile's failure) and a WARN names the device, the command and the mode.
|
|
90035
|
+
*
|
|
90036
|
+
* Sending the command code-less and letting the panel reject it was the
|
|
90037
|
+
* alternative. It is worse: the refusal costs a round trip, it is
|
|
90038
|
+
* indistinguishable from a broken panel in the log, and repeated code-less
|
|
90039
|
+
* attempts are what locks a real alarm panel out.
|
|
90040
|
+
*/
|
|
90041
|
+
var SUBTYPE = "alarm-panel";
|
|
90042
|
+
/** The subset of the status this builder reads. */
|
|
90043
|
+
var PanelReadSchema = AlarmPanelStatusSchema.pick({
|
|
90044
|
+
state: true,
|
|
90045
|
+
availableModes: true,
|
|
90046
|
+
requiresCode: true
|
|
90047
|
+
});
|
|
90048
|
+
/**
|
|
90049
|
+
* Turn a panel status into characteristic writes, folding the memo.
|
|
90050
|
+
*
|
|
90051
|
+
* A payload that does not match the cap's own schema yields NO updates and
|
|
90052
|
+
* leaves the memo alone — it is a status we could not read, not a panel that
|
|
90053
|
+
* changed. The caller logs the drop; a frozen characteristic is otherwise
|
|
90054
|
+
* indistinguishable from a quiet panel.
|
|
90055
|
+
*/
|
|
90056
|
+
function securityUpdatesFor(status, memo) {
|
|
90057
|
+
const parsed = PanelReadSchema.safeParse(status);
|
|
90058
|
+
if (!parsed.success) return {
|
|
90059
|
+
updates: [],
|
|
90060
|
+
memo,
|
|
90061
|
+
panel: null
|
|
90062
|
+
};
|
|
90063
|
+
const nextMemo = parsed.data.state === "disarmed" ? null : armedMemoOf(parsed.data.state) ?? memo;
|
|
90064
|
+
const { current, target } = securityStateFor(parsed.data.state, nextMemo);
|
|
90065
|
+
return {
|
|
90066
|
+
updates: [{
|
|
90067
|
+
characteristic: import_dist.Characteristic.SecuritySystemCurrentState,
|
|
90068
|
+
value: current
|
|
90069
|
+
}, ...target === null ? [] : [{
|
|
90070
|
+
characteristic: import_dist.Characteristic.SecuritySystemTargetState,
|
|
90071
|
+
value: target
|
|
90072
|
+
}]],
|
|
90073
|
+
memo: nextMemo,
|
|
90074
|
+
panel: {
|
|
90075
|
+
availableModes: parsed.data.availableModes,
|
|
90076
|
+
requiresCode: parsed.data.requiresCode
|
|
90077
|
+
}
|
|
90078
|
+
};
|
|
90079
|
+
}
|
|
90080
|
+
async function buildSecuritySystem(bctx) {
|
|
90081
|
+
const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
|
|
90082
|
+
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
90083
|
+
const label = hapServiceName([displayName], `Alarm ${numericDeviceId}`);
|
|
90084
|
+
const service = accessory.addService(import_dist.Service.SecuritySystem, label, SUBTYPE);
|
|
90085
|
+
applyServiceLabel(service, label);
|
|
90086
|
+
let memo = null;
|
|
90087
|
+
let availableModes = [];
|
|
90088
|
+
let requiresCode = false;
|
|
90089
|
+
/** Only log the `validValues` narrowing when it actually changes. */
|
|
90090
|
+
let lastValidValues = "";
|
|
90091
|
+
const applyValidValues = () => {
|
|
90092
|
+
const validValues = targetValidValuesFor(availableModes);
|
|
90093
|
+
if (validValues === null) {
|
|
90094
|
+
if (lastValidValues !== "unconstrained") {
|
|
90095
|
+
lastValidValues = "unconstrained";
|
|
90096
|
+
log.info("export-hap: alarm panel named no arm modes — leaving HomeKit’s targets unconstrained", { meta: { subtype: SUBTYPE } });
|
|
90097
|
+
}
|
|
90098
|
+
return;
|
|
90099
|
+
}
|
|
90100
|
+
const key = validValues.join(",");
|
|
90101
|
+
if (key === lastValidValues) return;
|
|
90102
|
+
lastValidValues = key;
|
|
90103
|
+
service.getCharacteristic(import_dist.Characteristic.SecuritySystemTargetState).setProps({ validValues: [...validValues] });
|
|
90104
|
+
log.info("export-hap: alarm panel target constrained to the modes it accepts", { meta: {
|
|
90105
|
+
subtype: SUBTYPE,
|
|
90106
|
+
availableModes: [...availableModes],
|
|
90107
|
+
validValues
|
|
90108
|
+
} });
|
|
90109
|
+
};
|
|
90110
|
+
const apply = (status, source) => {
|
|
90111
|
+
const next = securityUpdatesFor(status, memo);
|
|
90112
|
+
if (next.updates.length === 0) {
|
|
90113
|
+
log.debug("export-hap: alarm panel status did not match the cap schema — no update", { meta: {
|
|
90114
|
+
subtype: SUBTYPE,
|
|
90115
|
+
source
|
|
90116
|
+
} });
|
|
90117
|
+
return;
|
|
90118
|
+
}
|
|
90119
|
+
memo = next.memo;
|
|
90120
|
+
if (next.panel !== null) {
|
|
90121
|
+
availableModes = next.panel.availableModes;
|
|
90122
|
+
requiresCode = next.panel.requiresCode;
|
|
90123
|
+
applyValidValues();
|
|
90124
|
+
}
|
|
90125
|
+
applyUpdates(service, next.updates);
|
|
90126
|
+
};
|
|
90127
|
+
try {
|
|
90128
|
+
const status = await proxy.alarmPanel?.getStatus({});
|
|
90129
|
+
if (status !== void 0 && status !== null) apply(status, "getStatus");
|
|
90130
|
+
} catch (err) {
|
|
90131
|
+
log.debug("export-hap: alarm panel getStatus hydrate failed (non-fatal)", { meta: { error: errMsg$11(err) } });
|
|
90132
|
+
}
|
|
90133
|
+
service.getCharacteristic(import_dist.Characteristic.SecuritySystemTargetState).onSet(async (value) => {
|
|
90134
|
+
const target = typeof value === "number" ? value : Number(value);
|
|
90135
|
+
const disarm = target === import_dist.Characteristic.SecuritySystemTargetState.DISARM;
|
|
90136
|
+
const mode = disarm ? null : armModeForTarget(target, availableModes);
|
|
90137
|
+
if (requiresCode) {
|
|
90138
|
+
log.warn("export-hap: alarm panel requires a PIN — HomeKit cannot supply one, command REFUSED", { meta: {
|
|
90139
|
+
subtype: SUBTYPE,
|
|
90140
|
+
command: disarm ? "disarm" : "arm",
|
|
90141
|
+
mode,
|
|
90142
|
+
target
|
|
90143
|
+
} });
|
|
90144
|
+
throw new import_dist.HapStatusError(import_dist.HAPStatus.INSUFFICIENT_AUTHORIZATION);
|
|
90145
|
+
}
|
|
90146
|
+
if (!disarm && mode === null) {
|
|
90147
|
+
log.warn("export-hap: alarm panel cannot honour this HomeKit arm target — REFUSED", { meta: {
|
|
90148
|
+
subtype: SUBTYPE,
|
|
90149
|
+
target,
|
|
90150
|
+
availableModes: [...availableModes]
|
|
90151
|
+
} });
|
|
90152
|
+
throw new import_dist.HapStatusError(import_dist.HAPStatus.INVALID_VALUE_IN_REQUEST);
|
|
90153
|
+
}
|
|
90154
|
+
try {
|
|
90155
|
+
if (mode === null) await proxy.alarmPanel?.disarm({});
|
|
90156
|
+
else await proxy.alarmPanel?.arm({ mode });
|
|
90157
|
+
} catch (err) {
|
|
90158
|
+
log.warn("export-hap: alarm panel command failed", { meta: {
|
|
90159
|
+
subtype: SUBTYPE,
|
|
90160
|
+
command: mode === null ? "disarm" : "arm",
|
|
90161
|
+
mode,
|
|
90162
|
+
error: errMsg$11(err)
|
|
90163
|
+
} });
|
|
90164
|
+
throw new import_dist.HapStatusError(import_dist.HAPStatus.SERVICE_COMMUNICATION_FAILURE);
|
|
90165
|
+
}
|
|
90166
|
+
});
|
|
90167
|
+
const unsubscribe = proxy.state.alarmPanel?.subscribe((value) => {
|
|
90168
|
+
if (value === void 0 || value === null) return;
|
|
90169
|
+
apply(value, "slice");
|
|
90170
|
+
}) ?? null;
|
|
90171
|
+
return { async dispose() {
|
|
90172
|
+
try {
|
|
90173
|
+
unsubscribe?.();
|
|
90174
|
+
} catch {}
|
|
90175
|
+
} };
|
|
90176
|
+
}
|
|
90177
|
+
function errMsg$11(err) {
|
|
90178
|
+
return err instanceof Error ? err.message : String(err);
|
|
90179
|
+
}
|
|
90180
|
+
//#endregion
|
|
90181
|
+
//#region src/mappers/builders/generic/sensor-service.ts
|
|
90182
|
+
async function buildSensorService(input) {
|
|
90183
|
+
const { bctx, spec, name, subtype } = input;
|
|
90184
|
+
const { ctx, accessory, proxy, numericDeviceId } = bctx;
|
|
90185
|
+
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
90186
|
+
const label = hapServiceName([name], spec.label);
|
|
90187
|
+
const service = spec.addService(accessory, label, subtype);
|
|
90188
|
+
applyServiceLabel(service, label);
|
|
90189
|
+
const apply = (status, source) => {
|
|
90190
|
+
const updates = spec.read(status);
|
|
90191
|
+
if (updates.length === 0) {
|
|
90192
|
+
log.debug("export-hap: sensor status did not match the cap schema — no update", { meta: {
|
|
90193
|
+
subtype,
|
|
90194
|
+
source
|
|
90195
|
+
} });
|
|
90196
|
+
return;
|
|
90197
|
+
}
|
|
90198
|
+
applyUpdates(service, updates);
|
|
90199
|
+
};
|
|
90200
|
+
try {
|
|
90201
|
+
const status = await spec.getStatus(proxy);
|
|
90202
|
+
if (status !== void 0 && status !== null) apply(status, "getStatus");
|
|
90203
|
+
} catch (err) {
|
|
90204
|
+
log.debug("export-hap: sensor getStatus hydrate failed (non-fatal)", { meta: {
|
|
90205
|
+
subtype,
|
|
90206
|
+
error: errMsg$10(err)
|
|
90207
|
+
} });
|
|
90208
|
+
}
|
|
90209
|
+
const unsubscribe = spec.subscribe(proxy, (value) => {
|
|
90210
|
+
if (value === void 0 || value === null) return;
|
|
90211
|
+
apply(value, "slice");
|
|
90212
|
+
});
|
|
90213
|
+
return { async dispose() {
|
|
90214
|
+
try {
|
|
90215
|
+
unsubscribe?.();
|
|
90216
|
+
} catch {}
|
|
90217
|
+
} };
|
|
90218
|
+
}
|
|
90219
|
+
function errMsg$10(err) {
|
|
90220
|
+
return err instanceof Error ? err.message : String(err);
|
|
90221
|
+
}
|
|
90222
|
+
//#endregion
|
|
90223
|
+
//#region src/mappers/builders/generic/cap-service-table.ts
|
|
90224
|
+
/**
|
|
90225
|
+
* The capability→HomeKit-service TABLE for non-camera devices.
|
|
90226
|
+
*
|
|
90227
|
+
* This is the whole coverage decision for the generic export path, and it is
|
|
90228
|
+
* deliberately a table rather than a `switch` on `DeviceType`. The doctrine is
|
|
90229
|
+
* the one the Home Assistant exporter already writes down: *coverage is decided
|
|
90230
|
+
* by a device's capabilities, not its type — restricting the picker by type is
|
|
90231
|
+
* what confined the previous exporter to cameras.* A `DeviceType` here is only
|
|
90232
|
+
* ever a UI filter (`HAP_EXPORTABLE_DEVICE_TYPES`) or an icon
|
|
90233
|
+
* (`ACCESSORY_CATEGORY_BY_TYPE`); it never decides whether something exports.
|
|
90234
|
+
*
|
|
90235
|
+
* The shape mirrors `CAP_ENTITY_MAP` in
|
|
90236
|
+
* `packages/addon-provider-homeassistant/src/ha-export/entity-catalog.ts` so
|
|
90237
|
+
* the two can be unified later — one row per capability, keyed by the cap name
|
|
90238
|
+
* exactly as it appears in `proxy.binding.entries[].capName`.
|
|
90239
|
+
*
|
|
90240
|
+
* ## Reading a status
|
|
90241
|
+
*
|
|
90242
|
+
* Every row parses the cap's OWN Zod status schema rather than duck-typing the
|
|
90243
|
+
* payload, and it `.pick()`s only the fields it reads: a provider that omits a
|
|
90244
|
+
* timestamp must not silence a sensor, and a provider that sends the wrong
|
|
90245
|
+
* shape must not write a garbage characteristic. A parse that fails yields no
|
|
90246
|
+
* updates, and the builder that owns the service logs the drop — silence reads
|
|
90247
|
+
* as "the sensor never changed".
|
|
90248
|
+
*
|
|
90249
|
+
* ## Tier
|
|
90250
|
+
*
|
|
90251
|
+
* Tier A, plus `alarm-panel`: the caps whose HomeKit service exists today and
|
|
90252
|
+
* needs no feature-dependent shape. `cover` still needs `cover-positionable`
|
|
90253
|
+
* and `climate-control` the dual-setpoint split; those are a separate task,
|
|
90254
|
+
* and adding a row here must never mean adding a service whose semantics this
|
|
90255
|
+
* file cannot fully honour.
|
|
90256
|
+
*
|
|
90257
|
+
* `alarm-panel` earned its row by answering the two questions that kept it
|
|
90258
|
+
* out: a target the panel would refuse is never offered (`availableModes` →
|
|
90259
|
+
* `validValues`, `security-target.ts`) and an arm a PIN-gated panel cannot
|
|
90260
|
+
* accept is refused out loud rather than attempted (`security-system.ts`).
|
|
90261
|
+
*/
|
|
90262
|
+
/** HomeKit's floor for `CurrentAmbientLightLevel`; 0 lux is out of range. */
|
|
90263
|
+
var MIN_LUX = 1e-4;
|
|
90264
|
+
var SENSOR_SPECS = {
|
|
90265
|
+
contact: {
|
|
90266
|
+
label: "Contact",
|
|
90267
|
+
addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.ContactSensor, name, subtype),
|
|
90268
|
+
getStatus: (proxy) => proxy.contact?.getStatus({}),
|
|
90269
|
+
subscribe: (proxy, onValue) => proxy.state.contact?.subscribe(onValue) ?? null,
|
|
90270
|
+
read: reader(ContactStatusSchema.pick({ entryOpen: true }), (status) => [{
|
|
90271
|
+
characteristic: import_dist.Characteristic.ContactSensorState,
|
|
90272
|
+
value: status.entryOpen ? import_dist.Characteristic.ContactSensorState.CONTACT_NOT_DETECTED : import_dist.Characteristic.ContactSensorState.CONTACT_DETECTED
|
|
90273
|
+
}])
|
|
90274
|
+
},
|
|
90275
|
+
motion: {
|
|
90276
|
+
label: "Motion",
|
|
90277
|
+
addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.MotionSensor, name, subtype),
|
|
90278
|
+
getStatus: (proxy) => proxy.motion?.getStatus({}),
|
|
90279
|
+
subscribe: (proxy, onValue) => proxy.state.motion?.subscribe(onValue) ?? null,
|
|
90280
|
+
read: reader(MotionStatusSchema.pick({ detected: true }), (status) => [{
|
|
90281
|
+
characteristic: import_dist.Characteristic.MotionDetected,
|
|
90282
|
+
value: status.detected
|
|
90283
|
+
}])
|
|
90284
|
+
},
|
|
90285
|
+
presence: {
|
|
90286
|
+
label: "Presence",
|
|
90287
|
+
addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.OccupancySensor, name, subtype),
|
|
90288
|
+
getStatus: (proxy) => proxy.presence?.getStatus({}),
|
|
90289
|
+
subscribe: (proxy, onValue) => proxy.state.presence?.subscribe(onValue) ?? null,
|
|
90290
|
+
read: reader(PresenceStatusSchema.pick({ state: true }), (status) => [{
|
|
90291
|
+
characteristic: import_dist.Characteristic.OccupancyDetected,
|
|
90292
|
+
value: status.state === "home" ? import_dist.Characteristic.OccupancyDetected.OCCUPANCY_DETECTED : import_dist.Characteristic.OccupancyDetected.OCCUPANCY_NOT_DETECTED
|
|
90293
|
+
}])
|
|
90294
|
+
},
|
|
90295
|
+
smoke: {
|
|
90296
|
+
label: "Smoke",
|
|
90297
|
+
addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.SmokeSensor, name, subtype),
|
|
90298
|
+
getStatus: (proxy) => proxy.smoke?.getStatus({}),
|
|
90299
|
+
subscribe: (proxy, onValue) => proxy.state.smoke?.subscribe(onValue) ?? null,
|
|
90300
|
+
read: reader(SmokeStatusSchema.pick({ detected: true }), (status) => [{
|
|
90301
|
+
characteristic: import_dist.Characteristic.SmokeDetected,
|
|
90302
|
+
value: status.detected ? import_dist.Characteristic.SmokeDetected.SMOKE_DETECTED : import_dist.Characteristic.SmokeDetected.SMOKE_NOT_DETECTED
|
|
90303
|
+
}])
|
|
90304
|
+
},
|
|
90305
|
+
"carbon-monoxide": {
|
|
90306
|
+
label: "Carbon monoxide",
|
|
90307
|
+
addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.CarbonMonoxideSensor, name, subtype),
|
|
90308
|
+
getStatus: (proxy) => proxy.carbonMonoxide?.getStatus({}),
|
|
90309
|
+
subscribe: (proxy, onValue) => proxy.state.carbonMonoxide?.subscribe(onValue) ?? null,
|
|
90310
|
+
read: reader(CarbonMonoxideStatusSchema.pick({ detected: true }), (status) => [{
|
|
90311
|
+
characteristic: import_dist.Characteristic.CarbonMonoxideDetected,
|
|
90312
|
+
value: status.detected ? import_dist.Characteristic.CarbonMonoxideDetected.CO_LEVELS_ABNORMAL : import_dist.Characteristic.CarbonMonoxideDetected.CO_LEVELS_NORMAL
|
|
90313
|
+
}])
|
|
90314
|
+
},
|
|
90315
|
+
flood: {
|
|
90316
|
+
label: "Leak",
|
|
90317
|
+
addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.LeakSensor, name, subtype),
|
|
90318
|
+
getStatus: (proxy) => proxy.flood?.getStatus({}),
|
|
90319
|
+
subscribe: (proxy, onValue) => proxy.state.flood?.subscribe(onValue) ?? null,
|
|
90320
|
+
read: reader(FloodStatusSchema.pick({ flooded: true }), (status) => [{
|
|
90321
|
+
characteristic: import_dist.Characteristic.LeakDetected,
|
|
90322
|
+
value: status.flooded ? import_dist.Characteristic.LeakDetected.LEAK_DETECTED : import_dist.Characteristic.LeakDetected.LEAK_NOT_DETECTED
|
|
90323
|
+
}])
|
|
90324
|
+
},
|
|
90325
|
+
"temperature-sensor": {
|
|
90326
|
+
label: "Temperature",
|
|
90327
|
+
addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.TemperatureSensor, name, subtype),
|
|
90328
|
+
getStatus: (proxy) => proxy.temperatureSensor?.getStatus({}),
|
|
90329
|
+
subscribe: (proxy, onValue) => proxy.state.temperatureSensor?.subscribe(onValue) ?? null,
|
|
90330
|
+
read: reader(TemperatureSensorStatusSchema.pick({ celsius: true }), (status) => [{
|
|
90331
|
+
characteristic: import_dist.Characteristic.CurrentTemperature,
|
|
90332
|
+
value: status.celsius
|
|
90333
|
+
}])
|
|
90334
|
+
},
|
|
90335
|
+
"humidity-sensor": {
|
|
90336
|
+
label: "Humidity",
|
|
90337
|
+
addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.HumiditySensor, name, subtype),
|
|
90338
|
+
getStatus: (proxy) => proxy.humiditySensor?.getStatus({}),
|
|
90339
|
+
subscribe: (proxy, onValue) => proxy.state.humiditySensor?.subscribe(onValue) ?? null,
|
|
90340
|
+
read: reader(HumiditySensorStatusSchema.pick({ percent: true }), (status) => [{
|
|
90341
|
+
characteristic: import_dist.Characteristic.CurrentRelativeHumidity,
|
|
90342
|
+
value: status.percent
|
|
90343
|
+
}])
|
|
90344
|
+
},
|
|
90345
|
+
"ambient-light-sensor": {
|
|
90346
|
+
label: "Light level",
|
|
90347
|
+
addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.LightSensor, name, subtype),
|
|
90348
|
+
getStatus: (proxy) => proxy.ambientLightSensor?.getStatus({}),
|
|
90349
|
+
subscribe: (proxy, onValue) => proxy.state.ambientLightSensor?.subscribe(onValue) ?? null,
|
|
90350
|
+
read: reader(AmbientLightSensorStatusSchema.pick({ lux: true }), (status) => [{
|
|
90351
|
+
characteristic: import_dist.Characteristic.CurrentAmbientLightLevel,
|
|
90352
|
+
value: Math.max(MIN_LUX, status.lux)
|
|
90353
|
+
}])
|
|
90354
|
+
}
|
|
90355
|
+
};
|
|
90356
|
+
/**
|
|
90357
|
+
* The `battery` row reads like a sensor but writes three characteristics from
|
|
90358
|
+
* one status, and the camera path publishes the SAME service from the same cap.
|
|
90359
|
+
* The derivation therefore lives once, in `battery.ts`, and both callers read
|
|
90360
|
+
* it from there.
|
|
90361
|
+
*/
|
|
90362
|
+
var BATTERY_SPEC = {
|
|
90363
|
+
label: "Battery",
|
|
90364
|
+
addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.Battery, name, subtype),
|
|
90365
|
+
getStatus: (proxy) => proxy.battery?.getStatus({}),
|
|
90366
|
+
subscribe: (proxy, onValue) => proxy.state.battery?.subscribe(onValue) ?? null,
|
|
90367
|
+
read: batteryCharacteristicUpdates
|
|
90368
|
+
};
|
|
90369
|
+
function sensorRow(capName, spec) {
|
|
90370
|
+
return {
|
|
90371
|
+
caps: [capName],
|
|
90372
|
+
label: spec.label,
|
|
90373
|
+
build: ({ bctx, name }) => buildSensorService({
|
|
90374
|
+
bctx,
|
|
90375
|
+
spec,
|
|
90376
|
+
name,
|
|
90377
|
+
subtype: capName
|
|
90378
|
+
})
|
|
90379
|
+
};
|
|
90380
|
+
}
|
|
90381
|
+
/**
|
|
90382
|
+
* THE table. Order matters only for one thing: the first row that matches a
|
|
90383
|
+
* device decides the accessory's category when the device's own type does not.
|
|
90384
|
+
*/
|
|
90385
|
+
var HAP_CAP_SERVICES = [
|
|
90386
|
+
{
|
|
90387
|
+
caps: ["switch", "brightness"],
|
|
90388
|
+
label: "Power",
|
|
90389
|
+
build: ({ bctx, name, deviceType }) => buildChildSwitch({
|
|
90390
|
+
...bctx,
|
|
90391
|
+
displayName: name
|
|
90392
|
+
}, "switch", deviceType)
|
|
90393
|
+
},
|
|
90394
|
+
{
|
|
90395
|
+
caps: ["lock-control"],
|
|
90396
|
+
label: "Lock",
|
|
90397
|
+
build: ({ bctx, name }) => buildLockMechanism({
|
|
90398
|
+
...bctx,
|
|
90399
|
+
displayName: name
|
|
90400
|
+
})
|
|
90401
|
+
},
|
|
90402
|
+
{
|
|
90403
|
+
caps: ["alarm-panel"],
|
|
90404
|
+
label: "Alarm",
|
|
90405
|
+
build: ({ bctx, name }) => buildSecuritySystem({
|
|
90406
|
+
...bctx,
|
|
90407
|
+
displayName: name
|
|
90408
|
+
})
|
|
90409
|
+
},
|
|
90410
|
+
{
|
|
90411
|
+
caps: ["battery"],
|
|
90412
|
+
label: BATTERY_SPEC.label,
|
|
90413
|
+
build: ({ bctx, name }) => buildSensorService({
|
|
90414
|
+
bctx,
|
|
90415
|
+
spec: BATTERY_SPEC,
|
|
90416
|
+
name,
|
|
90417
|
+
subtype: "battery"
|
|
90418
|
+
})
|
|
90419
|
+
},
|
|
90420
|
+
...Object.entries(SENSOR_SPECS).map(([capName, spec]) => sensorRow(capName, spec))
|
|
90421
|
+
];
|
|
90422
|
+
/**
|
|
90423
|
+
* The rows a device's bound capabilities select, in table order.
|
|
90424
|
+
*
|
|
90425
|
+
* A row matches when ANY of its caps is bound: a lamp with `switch` but no
|
|
90426
|
+
* `brightness` is still a Lightbulb-shaped row, and the builder degrades on its
|
|
90427
|
+
* own.
|
|
90428
|
+
*/
|
|
90429
|
+
function rowsForCaps(capNames) {
|
|
90430
|
+
return HAP_CAP_SERVICES.filter((row) => row.caps.some((cap) => capNames.has(cap)));
|
|
90431
|
+
}
|
|
90432
|
+
/**
|
|
90433
|
+
* The HomeKit accessory category per camstack device type — the icon iOS Home
|
|
90434
|
+
* draws and nothing else. Absent = `OTHER`, which is a valid accessory that
|
|
90435
|
+
* simply gets the generic tile.
|
|
90436
|
+
*
|
|
90437
|
+
* This is NOT a coverage decision. `HAP_EXPORTABLE_DEVICE_TYPES` (in
|
|
90438
|
+
* `mappers/index.ts`) filters the picker; whether a device exports anything is
|
|
90439
|
+
* decided by `rowsForCaps`.
|
|
90440
|
+
*/
|
|
90441
|
+
var ACCESSORY_CATEGORY_BY_TYPE = {
|
|
90442
|
+
[DeviceType.Switch]: import_dist.Categories.SWITCH,
|
|
90443
|
+
[DeviceType.Light]: import_dist.Categories.LIGHTBULB,
|
|
90444
|
+
[DeviceType.Siren]: import_dist.Categories.SWITCH,
|
|
90445
|
+
[DeviceType.Lock]: import_dist.Categories.DOOR_LOCK,
|
|
90446
|
+
[DeviceType.Sensor]: import_dist.Categories.SENSOR,
|
|
90447
|
+
[DeviceType.Presence]: import_dist.Categories.SENSOR,
|
|
90448
|
+
[DeviceType.AlarmPanel]: import_dist.Categories.SECURITY_SYSTEM
|
|
90449
|
+
};
|
|
90450
|
+
function accessoryCategoryFor(deviceType) {
|
|
90451
|
+
return ACCESSORY_CATEGORY_BY_TYPE[deviceType] ?? import_dist.Categories.OTHER;
|
|
90452
|
+
}
|
|
90453
|
+
//#endregion
|
|
90454
|
+
//#region src/mappers/builders/stream-hwaccel-memo.ts
|
|
90455
|
+
/**
|
|
90456
|
+
* The two bounded memos HomeKit's decode path owns.
|
|
90457
|
+
*
|
|
90458
|
+
* ## Why they exist
|
|
90459
|
+
*
|
|
90460
|
+
* Everything D67 was actually about — one argv builder, one set of constants,
|
|
90461
|
+
* one hwaccel authority — HomeKit already had. What it did NOT have were the
|
|
90462
|
+
* two things the broker gained alongside them:
|
|
90463
|
+
*
|
|
90464
|
+
* 1. **A memo.** `probeDecoderHwaccel` issued a cross-process
|
|
90465
|
+
* `decoder.getInfo` per SESSION. iOS starts sessions in bursts — one on
|
|
90466
|
+
* record was started three times in 16 s — and every one of those paid a
|
|
90467
|
+
* cap call on a hub whose main thread is the scarce resource.
|
|
90468
|
+
* 2. **Failure feedback.** When HomeKit's hardware child died at init and
|
|
90469
|
+
* `shouldRetryInSoftware` saved the session, HomeKit told nobody. The next
|
|
90470
|
+
* session re-picked the same corpse and paid the same two-second death.
|
|
90471
|
+
* `EgressTranscodeManager` fixed exactly this for its own children
|
|
90472
|
+
* (26a522cd5) by reporting the dead backend into the broker's 60 s memo.
|
|
90473
|
+
*
|
|
90474
|
+
* ## Both ride `HwAccelCache`, deliberately
|
|
90475
|
+
*
|
|
90476
|
+
* `createHwAccelCache` from `@camstack/types` is the primitive the broker's own
|
|
90477
|
+
* `egressHwAccelCache` is built from, and the discipline it encodes is the
|
|
90478
|
+
* point: **caller-owned, never a module global** — a module global would
|
|
90479
|
+
* outlive an addon respawn and survive an operator changing the decoder
|
|
90480
|
+
* backend. Same TTL as the broker's, so "has hardware come back yet" cannot
|
|
90481
|
+
* answer differently depending on which consumer asked.
|
|
90482
|
+
*
|
|
90483
|
+
* ## The cross-process gap, stated honestly
|
|
90484
|
+
*
|
|
90485
|
+
* These memos are scoped to the `export-hap` PROCESS. When HomeKit's vaapi
|
|
90486
|
+
* child dies, the broker's next child still pays its own two-second death, and
|
|
90487
|
+
* vice versa — because addons may never import each other and there is no
|
|
90488
|
+
* capability for "this backend is dead on this node right now". Closing that
|
|
90489
|
+
* would need a new cap surface, which Phase 0 explicitly does not take. What is
|
|
90490
|
+
* closed here is HomeKit's own repetition of the cost, across cameras and
|
|
90491
|
+
* across sessions.
|
|
90492
|
+
*/
|
|
90493
|
+
/**
|
|
90494
|
+
* The window both memos answer for.
|
|
90495
|
+
*
|
|
90496
|
+
* 60 s, the same as `stream-broker-manager`'s `egressHwAccelCache`. Long enough
|
|
90497
|
+
* that a burst of session restarts pays one read; short enough that an operator
|
|
90498
|
+
* who changes the decoder backend, or a host whose accelerator recovers, is
|
|
90499
|
+
* obeyed on the next session rather than after an addon respawn.
|
|
90500
|
+
*/
|
|
90501
|
+
var HAP_DECODE_MEMO_TTL_MS = 6e4;
|
|
90502
|
+
/**
|
|
90503
|
+
* Separator inside the encoded reading. A control character, because a backend
|
|
90504
|
+
* name is `[a-z0-9]+` and the decoder's non-backend choices are `auto` /
|
|
90505
|
+
* `none` / `''`, none of which can contain one — so the split is total.
|
|
90506
|
+
*/
|
|
90507
|
+
var READING_SEPARATOR = "";
|
|
90508
|
+
/**
|
|
90509
|
+
* Marks a `null` FIELD, distinct from an EMPTY one.
|
|
90510
|
+
*
|
|
90511
|
+
* `probedBestHwaccel: ''` means the decoder answered and has never probed
|
|
90512
|
+
* (=> `not-probed`); `null` means the field was absent altogether. Encoding
|
|
90513
|
+
* both as `''` would lose a distinction `selectHwDecode` acts on.
|
|
90514
|
+
*/
|
|
90515
|
+
var NULL_FIELD = "\0";
|
|
90516
|
+
function encodeField(value) {
|
|
90517
|
+
return value === null ? NULL_FIELD : value;
|
|
90518
|
+
}
|
|
90519
|
+
function decodeField(value) {
|
|
90520
|
+
return value === NULL_FIELD ? null : value;
|
|
90521
|
+
}
|
|
90522
|
+
/**
|
|
90523
|
+
* A reading as ONE `string | null`, which is what {@link HwAccelCache} stores.
|
|
90524
|
+
*
|
|
90525
|
+
* The cache's three states are exactly the three a memoised reading needs:
|
|
90526
|
+
* `undefined` (never asked, or expired), `null` (asked, and the decoder could
|
|
90527
|
+
* not be reached), and a value. Encoding into the one cache rather than
|
|
90528
|
+
* splitting across two is what keeps those three from skewing — two caches
|
|
90529
|
+
* written together can still be READ across an expiry boundary.
|
|
90530
|
+
*/
|
|
90531
|
+
function encodeDecoderReading(reading) {
|
|
90532
|
+
if (reading === null) return null;
|
|
90533
|
+
return `${encodeField(reading.hwaccel)}${READING_SEPARATOR}${encodeField(reading.probedBestHwaccel)}`;
|
|
90534
|
+
}
|
|
90535
|
+
function decodeDecoderReading(value) {
|
|
90536
|
+
if (value === null) return null;
|
|
90537
|
+
const [hwaccel = NULL_FIELD, probed = NULL_FIELD] = value.split(READING_SEPARATOR);
|
|
90538
|
+
return {
|
|
90539
|
+
hwaccel: decodeField(hwaccel),
|
|
90540
|
+
probedBestHwaccel: decodeField(probed)
|
|
90541
|
+
};
|
|
90542
|
+
}
|
|
90543
|
+
function createDecoderReadingMemo(options) {
|
|
90544
|
+
const cache = createHwAccelCache(options);
|
|
90545
|
+
return {
|
|
90546
|
+
read() {
|
|
90547
|
+
const cached = cache.read();
|
|
90548
|
+
return cached === void 0 ? void 0 : decodeDecoderReading(cached);
|
|
90549
|
+
},
|
|
90550
|
+
write(reading) {
|
|
90551
|
+
cache.write(encodeDecoderReading(reading));
|
|
90552
|
+
}
|
|
90553
|
+
};
|
|
90554
|
+
}
|
|
90555
|
+
function createHapDecodeMemos(now) {
|
|
90556
|
+
const options = {
|
|
90557
|
+
ttlMs: HAP_DECODE_MEMO_TTL_MS,
|
|
90558
|
+
...now ? { now } : {}
|
|
90559
|
+
};
|
|
90560
|
+
return {
|
|
90561
|
+
reading: createDecoderReadingMemo(options),
|
|
90562
|
+
failedBackend: createHwAccelCache(options)
|
|
90563
|
+
};
|
|
90564
|
+
}
|
|
90565
|
+
//#endregion
|
|
90566
|
+
//#region src/mappers/builders/accessory-info.ts
|
|
90567
|
+
/**
|
|
90568
|
+
* `Service.AccessoryInformation` — the manufacturer / model / firmware /
|
|
90569
|
+
* serial block every HomeKit accessory carries.
|
|
90570
|
+
*
|
|
90571
|
+
* One implementation for both accessory shapes (camera and generic): the
|
|
90572
|
+
* fields come from the device's own metadata either way, and a second copy
|
|
90573
|
+
* would be a second answer to "what serial does this device publish".
|
|
90574
|
+
* Metadata is best-effort — a device that cannot answer still publishes.
|
|
90575
|
+
*/
|
|
90576
|
+
async function populateAccessoryInfo(accessory, proxy, displayName, modelFallback) {
|
|
90577
|
+
const info = accessory.getService(import_dist.Service.AccessoryInformation);
|
|
90578
|
+
if (!info) return;
|
|
90579
|
+
try {
|
|
90580
|
+
const device = await proxy.deviceManager?.getDevice({});
|
|
90581
|
+
const metadata = readMetadata(device);
|
|
90582
|
+
info.setCharacteristic(import_dist.Characteristic.Name, device?.name ?? displayName);
|
|
90583
|
+
info.setCharacteristic(import_dist.Characteristic.Manufacturer, stringOr(metadata?.manufacturer, "CamStack"));
|
|
90584
|
+
info.setCharacteristic(import_dist.Characteristic.Model, stringOr(metadata?.model, modelFallback));
|
|
90585
|
+
info.setCharacteristic(import_dist.Characteristic.FirmwareRevision, stringOr(metadata?.firmware, "0.0.0"));
|
|
90586
|
+
info.setCharacteristic(import_dist.Characteristic.SerialNumber, stringOr(metadata?.sn, `camstack-${proxy.deviceId}`));
|
|
90587
|
+
} catch {}
|
|
90588
|
+
}
|
|
90589
|
+
/** The four fields this module reads, or `null` — the device record's
|
|
90590
|
+
* `metadata` is an open bag and nothing else here depends on its shape. */
|
|
90591
|
+
function readMetadata(device) {
|
|
90592
|
+
const metadata = device?.metadata;
|
|
90593
|
+
if (metadata === null || typeof metadata !== "object") return {};
|
|
90594
|
+
const entries = new Map(Object.entries(metadata));
|
|
90595
|
+
return {
|
|
90596
|
+
model: stringOrNull(entries.get("model")),
|
|
90597
|
+
manufacturer: stringOrNull(entries.get("manufacturer")),
|
|
90598
|
+
firmware: stringOrNull(entries.get("firmware")),
|
|
90599
|
+
sn: stringOrNull(entries.get("sn"))
|
|
90600
|
+
};
|
|
90601
|
+
}
|
|
90602
|
+
function stringOrNull(value) {
|
|
90603
|
+
return typeof value === "string" ? value : null;
|
|
90604
|
+
}
|
|
90605
|
+
function stringOr(value, fallback) {
|
|
90606
|
+
return typeof value === "string" && value.length > 0 ? value : fallback;
|
|
90607
|
+
}
|
|
90608
|
+
//#endregion
|
|
88784
90609
|
//#region ../../node_modules/werift-rtp/lib/rtp/src/srtp/const.js
|
|
88785
90610
|
var require_const = /* @__PURE__ */ __commonJSMin(((exports) => {
|
|
88786
90611
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
@@ -100097,7 +101922,7 @@ var VIDEO_LOOPBACK_RCVBUF_BYTES = 8 * 1024 * 1024;
|
|
|
100097
101922
|
* not burst size.
|
|
100098
101923
|
*/
|
|
100099
101924
|
var AUDIO_LOOPBACK_RCVBUF_BYTES = 1024 * 1024;
|
|
100100
|
-
function errMsg$
|
|
101925
|
+
function errMsg$9(err) {
|
|
100101
101926
|
return err instanceof Error ? err.message : String(err);
|
|
100102
101927
|
}
|
|
100103
101928
|
/**
|
|
@@ -100112,13 +101937,13 @@ function applyReceiveBuffer(socket, requestedBytes) {
|
|
|
100112
101937
|
try {
|
|
100113
101938
|
socket.setRecvBufferSize(requestedBytes);
|
|
100114
101939
|
} catch (err) {
|
|
100115
|
-
error = errMsg$
|
|
101940
|
+
error = errMsg$9(err);
|
|
100116
101941
|
}
|
|
100117
101942
|
let effectiveBytes = null;
|
|
100118
101943
|
try {
|
|
100119
101944
|
effectiveBytes = socket.getRecvBufferSize();
|
|
100120
101945
|
} catch (err) {
|
|
100121
|
-
if (error === null) error = errMsg$
|
|
101946
|
+
if (error === null) error = errMsg$9(err);
|
|
100122
101947
|
}
|
|
100123
101948
|
return {
|
|
100124
101949
|
requestedBytes,
|
|
@@ -100389,20 +102214,20 @@ function buildCameraStreamingDelegate(bctx, advertised) {
|
|
|
100389
102214
|
delegate: {
|
|
100390
102215
|
handleSnapshotRequest(request, callback) {
|
|
100391
102216
|
handleSnapshot(bctx, request).then((buf) => callback(void 0, buf)).catch((err) => {
|
|
100392
|
-
log.warn("export-hap: snapshot failed", { meta: { error: errMsg$
|
|
100393
|
-
callback(err instanceof Error ? err : new Error(errMsg$
|
|
102217
|
+
log.warn("export-hap: snapshot failed", { meta: { error: errMsg$8(err) } });
|
|
102218
|
+
callback(err instanceof Error ? err : new Error(errMsg$8(err)));
|
|
100394
102219
|
});
|
|
100395
102220
|
},
|
|
100396
102221
|
prepareStream(request, callback) {
|
|
100397
102222
|
prepareStream(request, sessions, bctx, "classic").then((resp) => callback(void 0, resp)).catch((err) => {
|
|
100398
|
-
log.warn("export-hap: prepareStream failed", { meta: { error: errMsg$
|
|
100399
|
-
callback(err instanceof Error ? err : new Error(errMsg$
|
|
102223
|
+
log.warn("export-hap: prepareStream failed", { meta: { error: errMsg$8(err) } });
|
|
102224
|
+
callback(err instanceof Error ? err : new Error(errMsg$8(err)));
|
|
100400
102225
|
});
|
|
100401
102226
|
},
|
|
100402
102227
|
handleStreamRequest(request, callback) {
|
|
100403
102228
|
handleStreamRequest(request, sessions, bctx, advertised).then(() => callback()).catch((err) => {
|
|
100404
|
-
log.warn("export-hap: handleStreamRequest failed", { meta: { error: errMsg$
|
|
100405
|
-
callback(err instanceof Error ? err : new Error(errMsg$
|
|
102229
|
+
log.warn("export-hap: handleStreamRequest failed", { meta: { error: errMsg$8(err) } });
|
|
102230
|
+
callback(err instanceof Error ? err : new Error(errMsg$8(err)));
|
|
100406
102231
|
});
|
|
100407
102232
|
}
|
|
100408
102233
|
},
|
|
@@ -100541,7 +102366,7 @@ async function prepareStream(request, sessions, bctx, negotiatedVia) {
|
|
|
100541
102366
|
closeSocket(audioUdp);
|
|
100542
102367
|
closeSocket(videoLoopUdp);
|
|
100543
102368
|
closeSocket(audioLoopUdp);
|
|
100544
|
-
throw new Error(`export-hap: outbound SrtpSession init failed: ${errMsg$
|
|
102369
|
+
throw new Error(`export-hap: outbound SrtpSession init failed: ${errMsg$8(err)}`, { cause: err });
|
|
100545
102370
|
}
|
|
100546
102371
|
let upstreamAudioSrtp = null;
|
|
100547
102372
|
try {
|
|
@@ -100555,7 +102380,7 @@ async function prepareStream(request, sessions, bctx, negotiatedVia) {
|
|
|
100555
102380
|
profile: import_src.ProtectionProfileAes128CmHmacSha1_80
|
|
100556
102381
|
});
|
|
100557
102382
|
} catch (err) {
|
|
100558
|
-
bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId }).warn("export-hap: SrtpSession init failed (upstream audio decrypt disabled)", { meta: { error: errMsg$
|
|
102383
|
+
bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId }).warn("export-hap: SrtpSession init failed (upstream audio decrypt disabled)", { meta: { error: errMsg$8(err) } });
|
|
100559
102384
|
}
|
|
100560
102385
|
const videoSsrc = randomSsrc();
|
|
100561
102386
|
const audioSsrc = randomSsrc();
|
|
@@ -100675,7 +102500,7 @@ async function prepareStream(request, sessions, bctx, negotiatedVia) {
|
|
|
100675
102500
|
});
|
|
100676
102501
|
audioUdp.on("message", (packet, rinfo) => {
|
|
100677
102502
|
handleIncomingAudioRtp(session, packet, rinfo.address, bctx).catch((err) => {
|
|
100678
|
-
bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId }).debug("export-hap: incoming-audio handler error (dropped)", { meta: { error: errMsg$
|
|
102503
|
+
bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId }).debug("export-hap: incoming-audio handler error (dropped)", { meta: { error: errMsg$8(err) } });
|
|
100679
102504
|
});
|
|
100680
102505
|
});
|
|
100681
102506
|
logLoopbackBuffer(tagLog, request.sessionID, "video", videoLoop.buffer);
|
|
@@ -100871,7 +102696,7 @@ function readControllerRtcp(session, leg, packet, log) {
|
|
|
100871
102696
|
} catch (err) {
|
|
100872
102697
|
drop(session, "inbound-rtcp-decrypt-failed");
|
|
100873
102698
|
storeReceiverReports(session, leg, recordUnreadableRtcp(tally));
|
|
100874
|
-
logUnreadableRtcp(session, leg, `decrypt: ${errMsg$
|
|
102699
|
+
logUnreadableRtcp(session, leg, `decrypt: ${errMsg$8(err)}`, log);
|
|
100875
102700
|
return;
|
|
100876
102701
|
}
|
|
100877
102702
|
const outcome = ingestDecryptedRtcp(plaintext, tally);
|
|
@@ -101689,7 +103514,7 @@ async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
|
|
|
101689
103514
|
session.upstreamRtpDecryptFailures += 1;
|
|
101690
103515
|
if (session.upstreamRtpDecryptFailures % 100 === 1) log.debug("export-hap: SRTP decrypt failed (rate-limited)", { meta: {
|
|
101691
103516
|
failures: session.upstreamRtpDecryptFailures,
|
|
101692
|
-
error: errMsg$
|
|
103517
|
+
error: errMsg$8(err)
|
|
101693
103518
|
} });
|
|
101694
103519
|
return;
|
|
101695
103520
|
}
|
|
@@ -101703,7 +103528,7 @@ async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
|
|
|
101703
103528
|
rtpPayloadType = parsedRtp.header.payloadType;
|
|
101704
103529
|
} catch (err) {
|
|
101705
103530
|
drop(session, "upstream-parse-failed");
|
|
101706
|
-
log.debug("export-hap: RTP parse failed after decrypt", { meta: { error: errMsg$
|
|
103531
|
+
log.debug("export-hap: RTP parse failed after decrypt", { meta: { error: errMsg$8(err) } });
|
|
101707
103532
|
return;
|
|
101708
103533
|
}
|
|
101709
103534
|
const negotiatedAudioPt = session.lastStartParams?.audioPt;
|
|
@@ -101727,7 +103552,7 @@ async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
|
|
|
101727
103552
|
if (session.intercomTalkSessionId === null) {
|
|
101728
103553
|
session.intercomTalkSessionId = "";
|
|
101729
103554
|
const opened = await openIntercomTalkSession(bctx).catch((err) => {
|
|
101730
|
-
log.warn("export-hap: intercom.startTalkSession failed", { meta: { error: errMsg$
|
|
103555
|
+
log.warn("export-hap: intercom.startTalkSession failed", { meta: { error: errMsg$8(err) } });
|
|
101731
103556
|
return null;
|
|
101732
103557
|
});
|
|
101733
103558
|
if (opened) {
|
|
@@ -101754,7 +103579,7 @@ async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
|
|
|
101754
103579
|
drop(session, "upstream-push-failed");
|
|
101755
103580
|
log.debug("export-hap: intercom.pushTalkAudio failed (will re-open on next frame)", { meta: {
|
|
101756
103581
|
sequenceNumber: session.intercomPcmSequence,
|
|
101757
|
-
error: errMsg$
|
|
103582
|
+
error: errMsg$8(err)
|
|
101758
103583
|
} });
|
|
101759
103584
|
session.intercomTalkSessionId = null;
|
|
101760
103585
|
}
|
|
@@ -101786,7 +103611,7 @@ async function closeIntercomTalkSession(session, bctx) {
|
|
|
101786
103611
|
} catch (err) {
|
|
101787
103612
|
log.debug("export-hap: intercom.endTalkSession failed (continuing)", { meta: {
|
|
101788
103613
|
sessionId,
|
|
101789
|
-
error: errMsg$
|
|
103614
|
+
error: errMsg$8(err)
|
|
101790
103615
|
} });
|
|
101791
103616
|
}
|
|
101792
103617
|
}
|
|
@@ -101811,7 +103636,7 @@ function closeSocket(udp) {
|
|
|
101811
103636
|
function randomSsrc() {
|
|
101812
103637
|
return Math.floor(Math.random() * 2147483646) + 1 | 0;
|
|
101813
103638
|
}
|
|
101814
|
-
function errMsg$
|
|
103639
|
+
function errMsg$8(err) {
|
|
101815
103640
|
return err instanceof Error ? err.message : String(err);
|
|
101816
103641
|
}
|
|
101817
103642
|
//#endregion
|
|
@@ -101895,14 +103720,14 @@ async function buildDoorbell(input) {
|
|
|
101895
103720
|
}
|
|
101896
103721
|
log.info("export-hap: doorbell SINGLE_PRESS pushed to HomeKit", { meta: { ...delivery } });
|
|
101897
103722
|
} catch (err) {
|
|
101898
|
-
log.warn("export-hap: ringDoorbell() failed", { meta: { error: errMsg$
|
|
103723
|
+
log.warn("export-hap: ringDoorbell() failed", { meta: { error: errMsg$7(err) } });
|
|
101899
103724
|
}
|
|
101900
103725
|
});
|
|
101901
103726
|
return { async dispose() {
|
|
101902
103727
|
unsubscribe();
|
|
101903
103728
|
} };
|
|
101904
103729
|
}
|
|
101905
|
-
function errMsg$
|
|
103730
|
+
function errMsg$7(err) {
|
|
101906
103731
|
return err instanceof Error ? err.message : String(err);
|
|
101907
103732
|
}
|
|
101908
103733
|
//#endregion
|
|
@@ -101950,7 +103775,7 @@ async function buildMotionSensor(bctx, existing = null) {
|
|
|
101950
103775
|
const detected = await proxy.motion?.isDetected({});
|
|
101951
103776
|
if (typeof detected === "boolean") motionService.updateCharacteristic(import_dist.Characteristic.MotionDetected, detected);
|
|
101952
103777
|
} catch (err) {
|
|
101953
|
-
ctx.logger.withTags({ deviceId: numericDeviceId }).debug("export-hap: initial motion hydrate failed (non-fatal)", { meta: { error: errMsg$
|
|
103778
|
+
ctx.logger.withTags({ deviceId: numericDeviceId }).debug("export-hap: initial motion hydrate failed (non-fatal)", { meta: { error: errMsg$6(err) } });
|
|
101954
103779
|
}
|
|
101955
103780
|
let resetTimer = null;
|
|
101956
103781
|
const armReset = () => {
|
|
@@ -101984,7 +103809,7 @@ async function buildMotionSensor(bctx, existing = null) {
|
|
|
101984
103809
|
}
|
|
101985
103810
|
} };
|
|
101986
103811
|
}
|
|
101987
|
-
function errMsg$
|
|
103812
|
+
function errMsg$6(err) {
|
|
101988
103813
|
return err instanceof Error ? err.message : String(err);
|
|
101989
103814
|
}
|
|
101990
103815
|
//#endregion
|
|
@@ -102015,7 +103840,7 @@ async function buildPrivacySwitch(bctx) {
|
|
|
102015
103840
|
const status = await proxy.privacyMask?.getStatus({});
|
|
102016
103841
|
if (status && typeof status.enabled === "boolean") service.updateCharacteristic(import_dist.Characteristic.On, status.enabled);
|
|
102017
103842
|
} catch (err) {
|
|
102018
|
-
log.debug("export-hap: privacy-mask getStatus hydrate failed (non-fatal)", { meta: { error: errMsg$
|
|
103843
|
+
log.debug("export-hap: privacy-mask getStatus hydrate failed (non-fatal)", { meta: { error: errMsg$5(err) } });
|
|
102019
103844
|
}
|
|
102020
103845
|
service.getCharacteristic(import_dist.Characteristic.On).onSet(async (value) => {
|
|
102021
103846
|
const enabled = value === true;
|
|
@@ -102024,7 +103849,7 @@ async function buildPrivacySwitch(bctx) {
|
|
|
102024
103849
|
} catch (err) {
|
|
102025
103850
|
log.warn("export-hap: privacy-mask setMask failed", { meta: {
|
|
102026
103851
|
enabled,
|
|
102027
|
-
error: errMsg$
|
|
103852
|
+
error: errMsg$5(err)
|
|
102028
103853
|
} });
|
|
102029
103854
|
}
|
|
102030
103855
|
});
|
|
@@ -102042,7 +103867,7 @@ async function buildPrivacySwitch(bctx) {
|
|
|
102042
103867
|
} catch {}
|
|
102043
103868
|
} };
|
|
102044
103869
|
}
|
|
102045
|
-
function errMsg$
|
|
103870
|
+
function errMsg$5(err) {
|
|
102046
103871
|
return err instanceof Error ? err.message : String(err);
|
|
102047
103872
|
}
|
|
102048
103873
|
//#endregion
|
|
@@ -102133,7 +103958,7 @@ async function buildPtz(bctx) {
|
|
|
102133
103958
|
} catch (err) {
|
|
102134
103959
|
log.warn("export-hap: ptz.goToPreset failed", { meta: {
|
|
102135
103960
|
presetId: preset.id,
|
|
102136
|
-
error: errMsg$
|
|
103961
|
+
error: errMsg$4(err)
|
|
102137
103962
|
} });
|
|
102138
103963
|
}
|
|
102139
103964
|
armReset(() => service.updateCharacteristic(import_dist.Characteristic.On, false), MOMENTARY_RESET_MS);
|
|
@@ -102151,14 +103976,14 @@ async function buildPtz(bctx) {
|
|
|
102151
103976
|
try {
|
|
102152
103977
|
await proxy.ptz?.stop({});
|
|
102153
103978
|
} catch (err) {
|
|
102154
|
-
log.debug("export-hap: ptz.stop failed (non-fatal)", { meta: { error: errMsg$
|
|
103979
|
+
log.debug("export-hap: ptz.stop failed (non-fatal)", { meta: { error: errMsg$4(err) } });
|
|
102155
103980
|
}
|
|
102156
103981
|
service.updateCharacteristic(import_dist.Characteristic.On, false);
|
|
102157
103982
|
}, options.ptzPulseMs);
|
|
102158
103983
|
} catch (err) {
|
|
102159
103984
|
log.warn("export-hap: ptz.continuousMove failed", { meta: {
|
|
102160
103985
|
dir: dir.label,
|
|
102161
|
-
error: errMsg$
|
|
103986
|
+
error: errMsg$4(err)
|
|
102162
103987
|
} });
|
|
102163
103988
|
service.updateCharacteristic(import_dist.Characteristic.On, false);
|
|
102164
103989
|
}
|
|
@@ -102190,7 +104015,7 @@ async function readPresets(bctx) {
|
|
|
102190
104015
|
}
|
|
102191
104016
|
return entries;
|
|
102192
104017
|
} catch (err) {
|
|
102193
|
-
ctx.logger.withTags({ deviceId: numericDeviceId }).debug("export-hap: ptz.getPresets failed (non-fatal)", { meta: { error: errMsg$
|
|
104018
|
+
ctx.logger.withTags({ deviceId: numericDeviceId }).debug("export-hap: ptz.getPresets failed (non-fatal)", { meta: { error: errMsg$4(err) } });
|
|
102194
104019
|
return [];
|
|
102195
104020
|
}
|
|
102196
104021
|
}
|
|
@@ -102205,7 +104030,7 @@ async function tryBuildAutotrack(bctx) {
|
|
|
102205
104030
|
const status = await proxy.ptzAutotrack.getStatus({});
|
|
102206
104031
|
if (status && typeof status.enabled === "boolean") service.updateCharacteristic(import_dist.Characteristic.On, status.enabled);
|
|
102207
104032
|
} catch (err) {
|
|
102208
|
-
log.debug("export-hap: ptzAutotrack.getStatus failed (non-fatal)", { meta: { error: errMsg$
|
|
104033
|
+
log.debug("export-hap: ptzAutotrack.getStatus failed (non-fatal)", { meta: { error: errMsg$4(err) } });
|
|
102209
104034
|
}
|
|
102210
104035
|
service.getCharacteristic(import_dist.Characteristic.On).onSet(async (value) => {
|
|
102211
104036
|
const enabled = value === true;
|
|
@@ -102214,16 +104039,218 @@ async function tryBuildAutotrack(bctx) {
|
|
|
102214
104039
|
} catch (err) {
|
|
102215
104040
|
log.warn("export-hap: ptzAutotrack.setEnabled failed", { meta: {
|
|
102216
104041
|
enabled,
|
|
102217
|
-
error: errMsg$
|
|
104042
|
+
error: errMsg$4(err)
|
|
102218
104043
|
} });
|
|
102219
104044
|
}
|
|
102220
104045
|
});
|
|
102221
104046
|
return { async dispose() {} };
|
|
102222
104047
|
}
|
|
102223
|
-
function errMsg$
|
|
104048
|
+
function errMsg$4(err) {
|
|
102224
104049
|
return err instanceof Error ? err.message : String(err);
|
|
102225
104050
|
}
|
|
102226
104051
|
//#endregion
|
|
104052
|
+
//#region src/hksv/clip-writer.ts
|
|
104053
|
+
var HksvClipWriter = class {
|
|
104054
|
+
input;
|
|
104055
|
+
log;
|
|
104056
|
+
started = false;
|
|
104057
|
+
settle = null;
|
|
104058
|
+
outcome;
|
|
104059
|
+
bytes = 0;
|
|
104060
|
+
fragments = 0;
|
|
104061
|
+
sawInit = false;
|
|
104062
|
+
truncated = null;
|
|
104063
|
+
startedAtMs;
|
|
104064
|
+
constructor(input) {
|
|
104065
|
+
this.input = input;
|
|
104066
|
+
this.log = input.logger;
|
|
104067
|
+
this.startedAtMs = input.now();
|
|
104068
|
+
this.outcome = new Promise((resolve) => {
|
|
104069
|
+
this.settle = resolve;
|
|
104070
|
+
});
|
|
104071
|
+
}
|
|
104072
|
+
/** Resolves once the file is closed. NEVER rejects — every failure is named. */
|
|
104073
|
+
get done() {
|
|
104074
|
+
return this.outcome;
|
|
104075
|
+
}
|
|
104076
|
+
/** Launch the loop. Returns immediately: the delegate awaits nothing here. */
|
|
104077
|
+
start() {
|
|
104078
|
+
if (this.started) return;
|
|
104079
|
+
this.started = true;
|
|
104080
|
+
this.run();
|
|
104081
|
+
}
|
|
104082
|
+
/**
|
|
104083
|
+
* The HDS stream ended. Releases the subscription, which drains whatever is
|
|
104084
|
+
* already queued and then completes the loop. Idempotent, synchronous, and
|
|
104085
|
+
* safe to call from the delegate's `finally`.
|
|
104086
|
+
*/
|
|
104087
|
+
finish() {
|
|
104088
|
+
this.input.subscription.release();
|
|
104089
|
+
}
|
|
104090
|
+
async run() {
|
|
104091
|
+
const { subscription, sink } = this.input;
|
|
104092
|
+
const tags = { deviceId: this.input.deviceId };
|
|
104093
|
+
try {
|
|
104094
|
+
for await (const packet of subscription.packets()) {
|
|
104095
|
+
if (packet.data.length === 0) continue;
|
|
104096
|
+
if (this.bytes + packet.data.length > this.input.maxBytes) {
|
|
104097
|
+
this.truncated = "size-cap";
|
|
104098
|
+
this.log.warn("hksv clip tee: the per-clip size ceiling was reached — TRUNCATING", {
|
|
104099
|
+
tags,
|
|
104100
|
+
meta: {
|
|
104101
|
+
streamId: this.input.streamId,
|
|
104102
|
+
bytes: this.bytes,
|
|
104103
|
+
maxBytes: this.input.maxBytes,
|
|
104104
|
+
fragments: this.fragments
|
|
104105
|
+
}
|
|
104106
|
+
});
|
|
104107
|
+
break;
|
|
104108
|
+
}
|
|
104109
|
+
await sink.write(packet.data);
|
|
104110
|
+
this.bytes += packet.data.length;
|
|
104111
|
+
if (packet.kind === "init") this.sawInit = true;
|
|
104112
|
+
else this.fragments += 1;
|
|
104113
|
+
}
|
|
104114
|
+
if (this.truncated === null && subscription.closedReason === "slow-consumer") {
|
|
104115
|
+
this.truncated = "slow-consumer";
|
|
104116
|
+
this.log.warn("hksv clip tee: the plane closed the tee for falling behind — the clip is TRUNCATED (HomeKit recording unaffected)", {
|
|
104117
|
+
tags,
|
|
104118
|
+
meta: {
|
|
104119
|
+
streamId: this.input.streamId,
|
|
104120
|
+
bytes: this.bytes,
|
|
104121
|
+
fragments: this.fragments
|
|
104122
|
+
}
|
|
104123
|
+
});
|
|
104124
|
+
}
|
|
104125
|
+
} catch (err) {
|
|
104126
|
+
this.truncated = "write-failed";
|
|
104127
|
+
this.log.warn("hksv clip tee: writing the clip FAILED — the HomeKit recording is unaffected", {
|
|
104128
|
+
tags,
|
|
104129
|
+
meta: {
|
|
104130
|
+
streamId: this.input.streamId,
|
|
104131
|
+
bytes: this.bytes,
|
|
104132
|
+
fragments: this.fragments,
|
|
104133
|
+
error: err instanceof Error ? err.message : String(err)
|
|
104134
|
+
}
|
|
104135
|
+
});
|
|
104136
|
+
} finally {
|
|
104137
|
+
subscription.release();
|
|
104138
|
+
try {
|
|
104139
|
+
await sink.close();
|
|
104140
|
+
} catch (err) {
|
|
104141
|
+
if (this.truncated === null) this.truncated = "write-failed";
|
|
104142
|
+
this.log.warn("hksv clip tee: closing the clip file failed", {
|
|
104143
|
+
tags,
|
|
104144
|
+
meta: {
|
|
104145
|
+
streamId: this.input.streamId,
|
|
104146
|
+
error: err instanceof Error ? err.message : String(err)
|
|
104147
|
+
}
|
|
104148
|
+
});
|
|
104149
|
+
}
|
|
104150
|
+
this.resolve();
|
|
104151
|
+
}
|
|
104152
|
+
}
|
|
104153
|
+
resolve() {
|
|
104154
|
+
const settle = this.settle;
|
|
104155
|
+
this.settle = null;
|
|
104156
|
+
settle?.({
|
|
104157
|
+
bytes: this.bytes,
|
|
104158
|
+
fragments: this.fragments,
|
|
104159
|
+
sawInit: this.sawInit,
|
|
104160
|
+
truncated: this.truncated,
|
|
104161
|
+
startedAtMs: this.startedAtMs,
|
|
104162
|
+
endedAtMs: this.input.now()
|
|
104163
|
+
});
|
|
104164
|
+
}
|
|
104165
|
+
};
|
|
104166
|
+
//#endregion
|
|
104167
|
+
//#region src/hksv/clip-tee.ts
|
|
104168
|
+
function createClipTeeFactory(input) {
|
|
104169
|
+
return (request) => new ClipTeeRunner(input, request);
|
|
104170
|
+
}
|
|
104171
|
+
var ClipTeeRunner = class {
|
|
104172
|
+
input;
|
|
104173
|
+
request;
|
|
104174
|
+
writer = null;
|
|
104175
|
+
/** `finish()` arriving before the file is open — the short stream case. */
|
|
104176
|
+
finishPending = false;
|
|
104177
|
+
acknowledgePending = false;
|
|
104178
|
+
clipId = null;
|
|
104179
|
+
completed;
|
|
104180
|
+
constructor(input, request) {
|
|
104181
|
+
this.input = input;
|
|
104182
|
+
this.request = request;
|
|
104183
|
+
this.completed = this.run();
|
|
104184
|
+
}
|
|
104185
|
+
finish() {
|
|
104186
|
+
this.finishPending = true;
|
|
104187
|
+
this.writer?.finish();
|
|
104188
|
+
}
|
|
104189
|
+
acknowledge() {
|
|
104190
|
+
this.acknowledgePending = true;
|
|
104191
|
+
this.completed.then(() => this.persistAcknowledgement());
|
|
104192
|
+
}
|
|
104193
|
+
async run() {
|
|
104194
|
+
const { store, deviceId } = this.input;
|
|
104195
|
+
const now = this.input.now ?? Date.now;
|
|
104196
|
+
const tags = { deviceId };
|
|
104197
|
+
const open = await store.open({
|
|
104198
|
+
deviceId,
|
|
104199
|
+
streamId: this.request.streamId,
|
|
104200
|
+
startedAtMs: now(),
|
|
104201
|
+
prebufferSpanMs: this.request.prebufferSpanMs,
|
|
104202
|
+
width: this.input.width,
|
|
104203
|
+
height: this.input.height,
|
|
104204
|
+
fragmentMs: this.input.fragmentMs
|
|
104205
|
+
});
|
|
104206
|
+
if (open === null) {
|
|
104207
|
+
this.request.subscription.release();
|
|
104208
|
+
return;
|
|
104209
|
+
}
|
|
104210
|
+
this.clipId = open.clipId;
|
|
104211
|
+
const writer = new HksvClipWriter({
|
|
104212
|
+
logger: this.input.logger,
|
|
104213
|
+
deviceId,
|
|
104214
|
+
streamId: this.request.streamId,
|
|
104215
|
+
subscription: this.request.subscription,
|
|
104216
|
+
sink: open.sink,
|
|
104217
|
+
maxBytes: store.maxClipBytes,
|
|
104218
|
+
now
|
|
104219
|
+
});
|
|
104220
|
+
this.writer = writer;
|
|
104221
|
+
writer.start();
|
|
104222
|
+
if (this.finishPending) writer.finish();
|
|
104223
|
+
const outcome = await writer.done;
|
|
104224
|
+
try {
|
|
104225
|
+
await open.complete(outcome);
|
|
104226
|
+
} catch (err) {
|
|
104227
|
+
this.input.logger.warn("hksv clip tee: the clip could not be completed", {
|
|
104228
|
+
tags,
|
|
104229
|
+
meta: {
|
|
104230
|
+
streamId: this.request.streamId,
|
|
104231
|
+
error: err instanceof Error ? err.message : String(err)
|
|
104232
|
+
}
|
|
104233
|
+
});
|
|
104234
|
+
}
|
|
104235
|
+
}
|
|
104236
|
+
async persistAcknowledgement() {
|
|
104237
|
+
const clipId = this.clipId;
|
|
104238
|
+
if (clipId === null || !this.acknowledgePending) return;
|
|
104239
|
+
this.acknowledgePending = false;
|
|
104240
|
+
try {
|
|
104241
|
+
await this.input.store.acknowledge(this.input.deviceId, clipId);
|
|
104242
|
+
} catch (err) {
|
|
104243
|
+
this.input.logger.warn("hksv clip tee: recording the acknowledgement failed", {
|
|
104244
|
+
tags: { deviceId: this.input.deviceId },
|
|
104245
|
+
meta: {
|
|
104246
|
+
clipId,
|
|
104247
|
+
error: err instanceof Error ? err.message : String(err)
|
|
104248
|
+
}
|
|
104249
|
+
});
|
|
104250
|
+
}
|
|
104251
|
+
}
|
|
104252
|
+
};
|
|
104253
|
+
//#endregion
|
|
102227
104254
|
//#region src/hksv/controller-census.ts
|
|
102228
104255
|
var EMPTY_CENSUS = {
|
|
102229
104256
|
serverPublished: false,
|
|
@@ -102545,8 +104572,13 @@ var HksvFragmentSource = class {
|
|
|
102545
104572
|
* Returns `null` when there is no plane — the caller MUST treat that as
|
|
102546
104573
|
* "cannot serve this recording" rather than opening an HDS stream it cannot
|
|
102547
104574
|
* feed, which is the D50 failure exactly.
|
|
104575
|
+
*
|
|
104576
|
+
* Called ONCE PER CONSUMER, never shared: the delegate takes one for HomeKit
|
|
104577
|
+
* and the clip tee takes a second. `queueCapacity` is how much slack this
|
|
104578
|
+
* subscriber gets before the plane closes it (D80 closes, never gaps) — the
|
|
104579
|
+
* tee asks for less than HomeKit's stream on purpose.
|
|
102548
104580
|
*/
|
|
102549
|
-
subscribe(tag) {
|
|
104581
|
+
subscribe(tag, queueCapacity) {
|
|
102550
104582
|
const plane = this.plane;
|
|
102551
104583
|
if (plane === null || plane.isEnded) return null;
|
|
102552
104584
|
const stats = plane.prebufferStats();
|
|
@@ -102561,7 +104593,8 @@ var HksvFragmentSource = class {
|
|
|
102561
104593
|
});
|
|
102562
104594
|
return plane.subscribe({
|
|
102563
104595
|
tag,
|
|
102564
|
-
withPrebuffer: true
|
|
104596
|
+
withPrebuffer: true,
|
|
104597
|
+
...queueCapacity === void 0 ? {} : { queueCapacity }
|
|
102565
104598
|
});
|
|
102566
104599
|
}
|
|
102567
104600
|
/** What the ring holds — surfaced so the delegate can log what it served. */
|
|
@@ -102685,6 +104718,12 @@ var HksvFragmentSource = class {
|
|
|
102685
104718
|
//#endregion
|
|
102686
104719
|
//#region src/hksv/recording-delegate.ts
|
|
102687
104720
|
/**
|
|
104721
|
+
* How many finished clips may wait for an acknowledgement iOS may never send.
|
|
104722
|
+
* Eight is more concurrent HKSV streams than one camera ever has; the number
|
|
104723
|
+
* exists so the map cannot grow, not to be tuned.
|
|
104724
|
+
*/
|
|
104725
|
+
var MAX_TEES_AWAITING_ACK = 8;
|
|
104726
|
+
/**
|
|
102688
104727
|
* `HDSProtocolSpecificErrorReason` is a `const enum`, so there is no reverse
|
|
102689
104728
|
* map to index — and a bare number in the log is the difference between "iOS
|
|
102690
104729
|
* closed it normally" and "iOS rejected our data", which is the whole reason
|
|
@@ -102713,6 +104752,12 @@ var HksvRecordingDelegate = class {
|
|
|
102713
104752
|
log;
|
|
102714
104753
|
/** The stream currently being yielded, so `closeRecordingStream` can end it. */
|
|
102715
104754
|
open = null;
|
|
104755
|
+
/**
|
|
104756
|
+
* Tees whose stream has ended, still waiting for iOS's `ack`. BOUNDED: the
|
|
104757
|
+
* ack may never come (a dropped connection looks identical), and a map that
|
|
104758
|
+
* only ever grows is the unbounded buffer D9/D18 names in its smallest form.
|
|
104759
|
+
*/
|
|
104760
|
+
tees = /* @__PURE__ */ new Map();
|
|
102716
104761
|
constructor(input) {
|
|
102717
104762
|
this.input = input;
|
|
102718
104763
|
this.log = input.logger;
|
|
@@ -102793,6 +104838,7 @@ var HksvRecordingDelegate = class {
|
|
|
102793
104838
|
};
|
|
102794
104839
|
const startedAt = this.input.now();
|
|
102795
104840
|
const prebufferSpanMs = source.prebufferSpanMs();
|
|
104841
|
+
const tee = this.openTee(streamId, source, prebufferSpanMs);
|
|
102796
104842
|
let packets = 0;
|
|
102797
104843
|
let bytes = 0;
|
|
102798
104844
|
let markedLast = false;
|
|
@@ -102844,6 +104890,7 @@ var HksvRecordingDelegate = class {
|
|
|
102844
104890
|
}
|
|
102845
104891
|
} finally {
|
|
102846
104892
|
subscription.release();
|
|
104893
|
+
this.finishTee(streamId, tee);
|
|
102847
104894
|
if (this.open?.streamId === streamId) this.open = null;
|
|
102848
104895
|
const fragmentOverrun = longestFragmentGapMs > this.input.advertisedFragmentMs * 1.5;
|
|
102849
104896
|
this.log.info("hksv: recording stream finished", {
|
|
@@ -102874,6 +104921,20 @@ var HksvRecordingDelegate = class {
|
|
|
102874
104921
|
tags: { deviceId: this.input.deviceId },
|
|
102875
104922
|
meta: { streamId }
|
|
102876
104923
|
});
|
|
104924
|
+
const tee = this.tees.get(streamId);
|
|
104925
|
+
this.tees.delete(streamId);
|
|
104926
|
+
if (tee === void 0) return;
|
|
104927
|
+
try {
|
|
104928
|
+
tee.acknowledge();
|
|
104929
|
+
} catch (err) {
|
|
104930
|
+
this.log.warn("hksv clip tee: recording the acknowledgement failed", {
|
|
104931
|
+
tags: { deviceId: this.input.deviceId },
|
|
104932
|
+
meta: {
|
|
104933
|
+
streamId,
|
|
104934
|
+
error: err instanceof Error ? err.message : String(err)
|
|
104935
|
+
}
|
|
104936
|
+
});
|
|
104937
|
+
}
|
|
102877
104938
|
}
|
|
102878
104939
|
closeRecordingStream(streamId, reason) {
|
|
102879
104940
|
this.log.info("hksv: the recording stream was closed by the controller", {
|
|
@@ -102893,11 +104954,82 @@ var HksvRecordingDelegate = class {
|
|
|
102893
104954
|
async dispose() {
|
|
102894
104955
|
this.open?.subscription.release();
|
|
102895
104956
|
this.open = null;
|
|
104957
|
+
this.tees.clear();
|
|
102896
104958
|
const source = this.source;
|
|
102897
104959
|
this.source = null;
|
|
102898
104960
|
if (source) await source.stop("accessory disposed");
|
|
102899
104961
|
}
|
|
102900
104962
|
/**
|
|
104963
|
+
* A second subscription on the same plane, handed to the tee.
|
|
104964
|
+
*
|
|
104965
|
+
* Every failure here returns `null`: no tee, a warn naming the camera, and a
|
|
104966
|
+
* HomeKit recording that proceeds exactly as it would have. The one thing
|
|
104967
|
+
* this must never do is let a clip problem reach the generator above.
|
|
104968
|
+
*/
|
|
104969
|
+
openTee(streamId, source, prebufferSpanMs) {
|
|
104970
|
+
const factory = this.input.openClipTee;
|
|
104971
|
+
if (factory === void 0) return null;
|
|
104972
|
+
let subscription = null;
|
|
104973
|
+
try {
|
|
104974
|
+
subscription = source.subscribe(`clip/${this.input.deviceId}#${streamId}`, 2);
|
|
104975
|
+
if (subscription === null) {
|
|
104976
|
+
this.log.warn("hksv clip tee: the plane refused a second subscription — this clip is NOT kept", {
|
|
104977
|
+
tags: { deviceId: this.input.deviceId },
|
|
104978
|
+
meta: { streamId }
|
|
104979
|
+
});
|
|
104980
|
+
return null;
|
|
104981
|
+
}
|
|
104982
|
+
const tee = factory({
|
|
104983
|
+
streamId,
|
|
104984
|
+
subscription,
|
|
104985
|
+
prebufferSpanMs
|
|
104986
|
+
});
|
|
104987
|
+
if (tee === null) {
|
|
104988
|
+
subscription.release();
|
|
104989
|
+
return null;
|
|
104990
|
+
}
|
|
104991
|
+
this.rememberTee(streamId, tee);
|
|
104992
|
+
return tee;
|
|
104993
|
+
} catch (err) {
|
|
104994
|
+
subscription?.release();
|
|
104995
|
+
this.log.warn("hksv clip tee: could NOT be opened — the HomeKit recording continues untouched", {
|
|
104996
|
+
tags: { deviceId: this.input.deviceId },
|
|
104997
|
+
meta: {
|
|
104998
|
+
streamId,
|
|
104999
|
+
error: err instanceof Error ? err.message : String(err)
|
|
105000
|
+
}
|
|
105001
|
+
});
|
|
105002
|
+
return null;
|
|
105003
|
+
}
|
|
105004
|
+
}
|
|
105005
|
+
finishTee(streamId, tee) {
|
|
105006
|
+
if (tee === null) return;
|
|
105007
|
+
try {
|
|
105008
|
+
tee.finish();
|
|
105009
|
+
} catch (err) {
|
|
105010
|
+
this.log.warn("hksv clip tee: closing the clip failed", {
|
|
105011
|
+
tags: { deviceId: this.input.deviceId },
|
|
105012
|
+
meta: {
|
|
105013
|
+
streamId,
|
|
105014
|
+
error: err instanceof Error ? err.message : String(err)
|
|
105015
|
+
}
|
|
105016
|
+
});
|
|
105017
|
+
}
|
|
105018
|
+
}
|
|
105019
|
+
/**
|
|
105020
|
+
* Hold the tee until iOS acknowledges, dropping the oldest once the map is
|
|
105021
|
+
* full. An ack that never arrives is the normal case for a dropped
|
|
105022
|
+
* connection, so this map's size is the only thing bounding it.
|
|
105023
|
+
*/
|
|
105024
|
+
rememberTee(streamId, tee) {
|
|
105025
|
+
this.tees.set(streamId, tee);
|
|
105026
|
+
while (this.tees.size > MAX_TEES_AWAITING_ACK) {
|
|
105027
|
+
const oldest = this.tees.keys().next();
|
|
105028
|
+
if (oldest.done === true) break;
|
|
105029
|
+
this.tees.delete(oldest.value);
|
|
105030
|
+
}
|
|
105031
|
+
}
|
|
105032
|
+
/**
|
|
102901
105033
|
* Start the prebuffer when iOS wants recording AND has chosen how, stop it
|
|
102902
105034
|
* otherwise. Called from every state edge rather than each edge deciding for
|
|
102903
105035
|
* itself: the two characteristics arrive in an order hap-nodejs explicitly
|
|
@@ -103067,10 +105199,20 @@ async function buildHksvRecording(input) {
|
|
|
103067
105199
|
fps,
|
|
103068
105200
|
fragmentLengthMs
|
|
103069
105201
|
});
|
|
105202
|
+
const clipStore = bctx.options.hksvClipStore;
|
|
105203
|
+
const openClipTee = clipStore === null ? void 0 : createClipTeeFactory({
|
|
105204
|
+
logger: log,
|
|
105205
|
+
deviceId: numericDeviceId,
|
|
105206
|
+
store: clipStore,
|
|
105207
|
+
width: source.width,
|
|
105208
|
+
height: source.height,
|
|
105209
|
+
fragmentMs: fragmentLengthMs
|
|
105210
|
+
});
|
|
103070
105211
|
const delegate = new HksvRecordingDelegate({
|
|
103071
105212
|
logger: log,
|
|
103072
105213
|
deviceId: numericDeviceId,
|
|
103073
105214
|
isAudioActive: input.isAudioActive,
|
|
105215
|
+
...openClipTee === void 0 ? {} : { openClipTee },
|
|
103074
105216
|
advertisedFragmentMs: fragmentLengthMs,
|
|
103075
105217
|
now: () => Date.now(),
|
|
103076
105218
|
describeControllers: () => describeHapControllers(bctx.accessory),
|
|
@@ -103092,7 +105234,8 @@ async function buildHksvRecording(input) {
|
|
|
103092
105234
|
measuredFps: fps,
|
|
103093
105235
|
advertisedFps: advertisedResolution?.[2] ?? null,
|
|
103094
105236
|
fragmentLengthMs,
|
|
103095
|
-
sourceGopMs: gopMs ?? "unknown"
|
|
105237
|
+
sourceGopMs: gopMs ?? "unknown",
|
|
105238
|
+
clipsKept: openClipTee !== void 0
|
|
103096
105239
|
} });
|
|
103097
105240
|
return {
|
|
103098
105241
|
options,
|
|
@@ -103117,133 +105260,30 @@ async function readProfileEntries(bctx) {
|
|
|
103117
105260
|
* on 615 `ext` is the 1280×720 slot the broker calls `mid` — a name-based match
|
|
103118
105261
|
* would silently read the 4K slot's GOP for a 720p recording.
|
|
103119
105262
|
*
|
|
103120
|
-
* `undefined` when the cap is not bound, which is most non-Hikvision providers.
|
|
103121
|
-
*/
|
|
103122
|
-
async function readSourceGopMs(bctx, width, height) {
|
|
103123
|
-
try {
|
|
103124
|
-
const status = await bctx.proxy.streamParams?.getStatus({});
|
|
103125
|
-
if (!status) return void 0;
|
|
103126
|
-
for (const profile of [
|
|
103127
|
-
status.main,
|
|
103128
|
-
status.sub,
|
|
103129
|
-
status.ext
|
|
103130
|
-
]) {
|
|
103131
|
-
if (!profile) continue;
|
|
103132
|
-
if (profile.width !== width || profile.height !== height) continue;
|
|
103133
|
-
const { gop, framerate } = profile;
|
|
103134
|
-
if (gop === void 0 || gop <= 0 || framerate <= 0) return void 0;
|
|
103135
|
-
return Math.round(gop / framerate * 1e3);
|
|
103136
|
-
}
|
|
103137
|
-
return;
|
|
103138
|
-
} catch {
|
|
103139
|
-
return;
|
|
103140
|
-
}
|
|
103141
|
-
}
|
|
103142
|
-
function resolveFps(fpsByProfile, profile) {
|
|
103143
|
-
return fpsByProfile.get(profile)?.fps ?? ASSUMED_RECORDING_FPS;
|
|
103144
|
-
}
|
|
103145
|
-
//#endregion
|
|
103146
|
-
//#region src/mappers/builders/child-switch.ts
|
|
103147
|
-
/**
|
|
103148
|
-
* Child-switch builder — turns a camstack accessory child device (siren,
|
|
103149
|
-
* floodlight, spotlight, …) into a HomeKit Service ON THE PARENT
|
|
103150
|
-
* camera Accessory.
|
|
103151
|
-
*
|
|
103152
|
-
* The orchestrator (`child-accessory.ts → buildChildServicesFor`)
|
|
103153
|
-
* hands this builder the PARENT accessory + the CHILD's proxy + a
|
|
103154
|
-
* unique subtype so each service round-trips deterministically through
|
|
103155
|
-
* HAP serialisation. The builder:
|
|
103156
|
-
* - Maps `switch` cap → `Service.Switch`.
|
|
103157
|
-
* - Maps `switch` + `brightness` cap → `Service.Lightbulb` with
|
|
103158
|
-
* Brightness characteristic (lighting devices only — see below).
|
|
103159
|
-
* - Hydrates initial state from `getStatus({})`.
|
|
103160
|
-
* - Wires `Characteristic.On` / `Characteristic.Brightness` through
|
|
103161
|
-
* the typed DeviceProxy.
|
|
103162
|
-
* - Subscribes to runtime-state slice updates so iOS Home reflects
|
|
103163
|
-
* external state changes (e.g. another integration toggling the
|
|
103164
|
-
* light).
|
|
103165
|
-
*
|
|
103166
|
-
* Service-kind selection: most cameras expose `brightness` on a siren
|
|
103167
|
-
* because it controls the alarm VOLUME, not luminosity. Mapping a
|
|
103168
|
-
* siren onto `Service.Lightbulb` makes it appear as a regular light in
|
|
103169
|
-
* iOS Home (wrong icon, wrong scenes, wrong Siri intents). We therefore
|
|
103170
|
-
* gate `Service.Lightbulb` on the child's `DeviceType` — only Light /
|
|
103171
|
-
* Generic devices keep the Lightbulb shape; Siren, Switch, and others
|
|
103172
|
-
* fall back to `Service.Switch`. Volume control for sirens is exposed
|
|
103173
|
-
* through the camstack admin UI; iOS Home only needs the on/off toggle.
|
|
103174
|
-
*
|
|
103175
|
-
* Child devices without `switch` / `brightness` cap (status sensors,
|
|
103176
|
-
* IR LED, etc.) return a no-op handle.
|
|
105263
|
+
* `undefined` when the cap is not bound, which is most non-Hikvision providers.
|
|
103177
105264
|
*/
|
|
103178
|
-
async function
|
|
103179
|
-
const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
|
|
103180
|
-
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
103181
|
-
const hasSwitch = !!proxy.switch;
|
|
103182
|
-
const hasBrightness = !!proxy.brightness;
|
|
103183
|
-
if (!hasSwitch && !hasBrightness) {
|
|
103184
|
-
log.debug("export-hap: child has no switch/brightness cap — skipping");
|
|
103185
|
-
return { async dispose() {} };
|
|
103186
|
-
}
|
|
103187
|
-
const isLightingDevice = deviceType === DeviceType.Light || deviceType === DeviceType.Generic;
|
|
103188
|
-
const useLightbulb = hasBrightness && isLightingDevice;
|
|
103189
|
-
const service = useLightbulb ? accessory.addService(import_dist.Service.Lightbulb, displayName, subtype) : accessory.addService(import_dist.Service.Switch, displayName, subtype);
|
|
103190
|
-
applyServiceLabel(service, displayName);
|
|
105265
|
+
async function readSourceGopMs(bctx, width, height) {
|
|
103191
105266
|
try {
|
|
103192
|
-
const
|
|
103193
|
-
if (
|
|
103194
|
-
|
|
103195
|
-
|
|
103196
|
-
|
|
103197
|
-
|
|
103198
|
-
|
|
103199
|
-
|
|
103200
|
-
|
|
103201
|
-
|
|
103202
|
-
|
|
103203
|
-
|
|
103204
|
-
const on = value === true;
|
|
103205
|
-
try {
|
|
103206
|
-
await proxy.switch?.setState({ on });
|
|
103207
|
-
} catch (err) {
|
|
103208
|
-
log.warn("export-hap: child switch.setState failed", { meta: {
|
|
103209
|
-
on,
|
|
103210
|
-
error: errMsg$7(err)
|
|
103211
|
-
} });
|
|
103212
|
-
}
|
|
103213
|
-
});
|
|
103214
|
-
if (useLightbulb) service.getCharacteristic(import_dist.Characteristic.Brightness).onSet(async (value) => {
|
|
103215
|
-
const percentage = typeof value === "number" ? value : Number(value);
|
|
103216
|
-
if (!Number.isFinite(percentage)) return;
|
|
103217
|
-
try {
|
|
103218
|
-
await proxy.brightness?.setBrightness({ percentage });
|
|
103219
|
-
} catch (err) {
|
|
103220
|
-
log.warn("export-hap: child brightness.setBrightness failed", { meta: {
|
|
103221
|
-
percentage,
|
|
103222
|
-
error: errMsg$7(err)
|
|
103223
|
-
} });
|
|
105267
|
+
const status = await bctx.proxy.streamParams?.getStatus({});
|
|
105268
|
+
if (!status) return void 0;
|
|
105269
|
+
for (const profile of [
|
|
105270
|
+
status.main,
|
|
105271
|
+
status.sub,
|
|
105272
|
+
status.ext
|
|
105273
|
+
]) {
|
|
105274
|
+
if (!profile) continue;
|
|
105275
|
+
if (profile.width !== width || profile.height !== height) continue;
|
|
105276
|
+
const { gop, framerate } = profile;
|
|
105277
|
+
if (gop === void 0 || gop <= 0 || framerate <= 0) return void 0;
|
|
105278
|
+
return Math.round(gop / framerate * 1e3);
|
|
103224
105279
|
}
|
|
103225
|
-
|
|
103226
|
-
|
|
103227
|
-
|
|
103228
|
-
const unsubSwitch = proxy.state.switch.subscribe((value) => {
|
|
103229
|
-
if (value && typeof value.on === "boolean") service.updateCharacteristic(import_dist.Characteristic.On, value.on);
|
|
103230
|
-
});
|
|
103231
|
-
unsubscribes.push(unsubSwitch);
|
|
103232
|
-
}
|
|
103233
|
-
if (useLightbulb && proxy.state.brightness) {
|
|
103234
|
-
const unsubBrightness = proxy.state.brightness.subscribe((value) => {
|
|
103235
|
-
if (value && typeof value.percentage === "number") service.updateCharacteristic(import_dist.Characteristic.Brightness, value.percentage);
|
|
103236
|
-
});
|
|
103237
|
-
unsubscribes.push(unsubBrightness);
|
|
105280
|
+
return;
|
|
105281
|
+
} catch {
|
|
105282
|
+
return;
|
|
103238
105283
|
}
|
|
103239
|
-
return { async dispose() {
|
|
103240
|
-
for (const u of unsubscribes) try {
|
|
103241
|
-
u();
|
|
103242
|
-
} catch {}
|
|
103243
|
-
} };
|
|
103244
105284
|
}
|
|
103245
|
-
function
|
|
103246
|
-
return
|
|
105285
|
+
function resolveFps(fpsByProfile, profile) {
|
|
105286
|
+
return fpsByProfile.get(profile)?.fps ?? ASSUMED_RECORDING_FPS;
|
|
103247
105287
|
}
|
|
103248
105288
|
//#endregion
|
|
103249
105289
|
//#region src/mappers/child-accessory.ts
|
|
@@ -103269,7 +105309,7 @@ async function buildChildServicesFor(input) {
|
|
|
103269
105309
|
for (const h of handles) try {
|
|
103270
105310
|
await h.dispose();
|
|
103271
105311
|
} catch (err) {
|
|
103272
|
-
ctx.logger.withTags({ deviceId: parentNumericId }).debug("export-hap: child service dispose failed (continuing)", { meta: { error: errMsg$
|
|
105312
|
+
ctx.logger.withTags({ deviceId: parentNumericId }).debug("export-hap: child service dispose failed (continuing)", { meta: { error: errMsg$3(err) } });
|
|
103273
105313
|
}
|
|
103274
105314
|
} };
|
|
103275
105315
|
}
|
|
@@ -103285,7 +105325,7 @@ async function listChildren(ctx, parentNumericId) {
|
|
|
103285
105325
|
features: Array.isArray(c.features) ? c.features.filter((f) => typeof f === "string") : []
|
|
103286
105326
|
}));
|
|
103287
105327
|
} catch (err) {
|
|
103288
|
-
ctx.logger.withTags({ deviceId: parentNumericId }).debug("export-hap: deviceManager.getChildren failed (non-fatal)", { meta: { error: errMsg$
|
|
105328
|
+
ctx.logger.withTags({ deviceId: parentNumericId }).debug("export-hap: deviceManager.getChildren failed (non-fatal)", { meta: { error: errMsg$3(err) } });
|
|
103289
105329
|
return [];
|
|
103290
105330
|
}
|
|
103291
105331
|
}
|
|
@@ -103301,7 +105341,7 @@ function asDeviceType(raw) {
|
|
|
103301
105341
|
for (const value of Object.values(DeviceType)) if (value === lower) return value;
|
|
103302
105342
|
return DeviceType.Generic;
|
|
103303
105343
|
}
|
|
103304
|
-
function errMsg$
|
|
105344
|
+
function errMsg$3(err) {
|
|
103305
105345
|
return err instanceof Error ? err.message : String(err);
|
|
103306
105346
|
}
|
|
103307
105347
|
//#endregion
|
|
@@ -103898,1035 +105938,389 @@ function buildMultiTierServices(input) {
|
|
|
103898
105938
|
logger.info("hap multi-tier: refused a setup-endpoints write", {
|
|
103899
105939
|
tags: { deviceId },
|
|
103900
105940
|
meta: { reason: input.negotiation === void 0 ? "no-negotiator" : "not-a-string" }
|
|
103901
|
-
});
|
|
103902
|
-
return;
|
|
103903
|
-
}
|
|
103904
|
-
await input.negotiation.handleSetupEndpointsWrite(value);
|
|
103905
|
-
});
|
|
103906
|
-
streamManagement.addCharacteristic(setupEndpoints);
|
|
103907
|
-
const streamingControl = new import_dist.Characteristic("RTP Streaming Control", CHAR_RTP_STREAMING_CONTROL, {
|
|
103908
|
-
format: import_dist.Formats.TLV8,
|
|
103909
|
-
perms: [import_dist.Perms.PAIRED_READ, import_dist.Perms.PAIRED_WRITE]
|
|
103910
|
-
});
|
|
103911
|
-
streamingControl.onGet(() => {
|
|
103912
|
-
note("rtp-streaming-control");
|
|
103913
|
-
return input.negotiation?.readStreamingControl() ?? encodeRtpStreamingControlStatus(null, RtpStreamingStatus.ERROR);
|
|
103914
|
-
});
|
|
103915
|
-
streamingControl.onSet(async (value) => {
|
|
103916
|
-
if (input.negotiation === void 0 || typeof value !== "string") {
|
|
103917
|
-
logger.info("hap multi-tier: refused an rtp streaming control write", {
|
|
103918
|
-
tags: { deviceId },
|
|
103919
|
-
meta: { reason: input.negotiation === void 0 ? "no-negotiator" : "not-a-string" }
|
|
103920
|
-
});
|
|
103921
|
-
return;
|
|
103922
|
-
}
|
|
103923
|
-
await input.negotiation.handleStreamingControlWrite(value);
|
|
103924
|
-
});
|
|
103925
|
-
streamManagement.addCharacteristic(streamingControl);
|
|
103926
|
-
const sensor = new import_dist.Characteristic("Sensor UUID", CHAR_SENSOR_UUID, {
|
|
103927
|
-
format: import_dist.Formats.DATA,
|
|
103928
|
-
perms: [import_dist.Perms.PAIRED_READ]
|
|
103929
|
-
});
|
|
103930
|
-
const sensorValue = sensorUuid.toString("base64");
|
|
103931
|
-
sensor.onGet(() => {
|
|
103932
|
-
note("sensor-uuid");
|
|
103933
|
-
return sensorValue;
|
|
103934
|
-
});
|
|
103935
|
-
streamManagement.addCharacteristic(sensor);
|
|
103936
|
-
return {
|
|
103937
|
-
capabilities,
|
|
103938
|
-
globalOperatingMode,
|
|
103939
|
-
streamManagement
|
|
103940
|
-
};
|
|
103941
|
-
}
|
|
103942
|
-
//#endregion
|
|
103943
|
-
//#region src/multi-tier/negotiation.ts
|
|
103944
|
-
/**
|
|
103945
|
-
* The adapter between Apple's new characteristics and the streaming engine this
|
|
103946
|
-
* addon already has.
|
|
103947
|
-
*
|
|
103948
|
-
* ## Why an adapter and not a second engine
|
|
103949
|
-
*
|
|
103950
|
-
* The multi-tier RTP service owns its OWN `Setup Endpoints` and `RTP Streaming
|
|
103951
|
-
* Control`, so hap-nodejs's `CameraController` never negotiates for it — and
|
|
103952
|
-
* the library's own handlers are all `private`, so its engine cannot be
|
|
103953
|
-
* borrowed either. But the sockets, the SRTP contexts, the ffmpeg pipeline and
|
|
103954
|
-
* the teardown are the same ones the classic service uses. A second copy would
|
|
103955
|
-
* be two engines to keep in agreement, and this repo has paid for that shape
|
|
103956
|
-
* before. So: new characteristics, one engine, through `StreamNegotiator`.
|
|
103957
|
-
*
|
|
103958
|
-
* ## What is different about the new flow
|
|
103959
|
-
*
|
|
103960
|
-
* The classic `Selected RTP Stream Configuration` carries the whole selected
|
|
103961
|
-
* configuration in its start — codec, profile, level, resolution, bitrate.
|
|
103962
|
-
* `RTP Streaming Control` carries a **tier identifier**, because the
|
|
103963
|
-
* configuration was agreed when we published `Supported Video Stream Tiers`.
|
|
103964
|
-
* So this maps an identifier back to the tier WE published, and a tier we never
|
|
103965
|
-
* published is `No Such Stream` — improvising a geometry there would serve a
|
|
103966
|
-
* stream the controller did not ask for and it would look like it worked.
|
|
103967
|
-
*
|
|
103968
|
-
* ## Every path answers
|
|
103969
|
-
*
|
|
103970
|
-
* A read of either characteristic always produces a valid TLV8. The Home app
|
|
103971
|
-
* reads a camera's characteristics in one batch, and one member that answers
|
|
103972
|
-
* nothing took the kitchen camera's whole read down and left it on "loading".
|
|
103973
|
-
* So an engine failure, a malformed write and "nothing has happened yet" are
|
|
103974
|
-
* all statuses, never silence.
|
|
103975
|
-
*/
|
|
103976
|
-
/** RTP payload types. 96 is the first dynamic one (RFC 3551); 110 is what our
|
|
103977
|
-
* own Opus offers use. Both are echoed in the tier declaration. */
|
|
103978
|
-
var VIDEO_PAYLOAD_TYPE = 96;
|
|
103979
|
-
var AUDIO_PAYLOAD_TYPE = 110;
|
|
103980
|
-
/** RTCP interval in SECONDS, which is the unit the engine reads. */
|
|
103981
|
-
var RTCP_INTERVAL_SECONDS = 5;
|
|
103982
|
-
/** The MTU the classic path uses for an Ethernet-sized path. */
|
|
103983
|
-
var DEFAULT_MTU = 1378;
|
|
103984
|
-
function errorMessage(error) {
|
|
103985
|
-
return error instanceof Error ? error.message : String(error);
|
|
103986
|
-
}
|
|
103987
|
-
function buildMultiTierNegotiation(input) {
|
|
103988
|
-
const { deviceId, logger, negotiator } = input;
|
|
103989
|
-
const log = logger.withTags({ deviceId });
|
|
103990
|
-
let setupResponse = encodeSetupEndpointsRefusal(SetupEndpointsStatus.ERROR);
|
|
103991
|
-
let controlResponse = encodeRtpStreamingControlStatus(null, RtpStreamingStatus.ERROR);
|
|
103992
|
-
/** Sessions we have STARTED, so an End can tell "not started" from "unknown". */
|
|
103993
|
-
const started = /* @__PURE__ */ new Set();
|
|
103994
|
-
async function handleSetupEndpointsWrite(base64) {
|
|
103995
|
-
let sessionId = null;
|
|
103996
|
-
try {
|
|
103997
|
-
const request = decodeSetupEndpointsRequest(base64);
|
|
103998
|
-
sessionId = request.sessionId;
|
|
103999
|
-
const prepareRequest = {
|
|
104000
|
-
sessionID: request.sessionId,
|
|
104001
|
-
sourceAddress: request.controllerAddress,
|
|
104002
|
-
targetAddress: request.controllerAddress,
|
|
104003
|
-
addressVersion: request.addressVersion,
|
|
104004
|
-
video: {
|
|
104005
|
-
port: request.videoPort,
|
|
104006
|
-
srtpCryptoSuite: import_dist.SRTPCryptoSuites.AES_CM_128_HMAC_SHA1_80,
|
|
104007
|
-
srtp_key: request.videoSrtp.key,
|
|
104008
|
-
srtp_salt: request.videoSrtp.salt
|
|
104009
|
-
},
|
|
104010
|
-
audio: {
|
|
104011
|
-
port: request.audioPort,
|
|
104012
|
-
srtpCryptoSuite: import_dist.SRTPCryptoSuites.AES_CM_128_HMAC_SHA1_80,
|
|
104013
|
-
srtp_key: request.audioSrtp.key,
|
|
104014
|
-
srtp_salt: request.audioSrtp.salt
|
|
104015
|
-
}
|
|
104016
|
-
};
|
|
104017
|
-
const prepared = await negotiator.prepare(prepareRequest);
|
|
104018
|
-
setupResponse = encodeSetupEndpointsResponse({
|
|
104019
|
-
sessionId: request.sessionId,
|
|
104020
|
-
addressVersion: request.addressVersion,
|
|
104021
|
-
accessoryAddress: prepared.localAddress,
|
|
104022
|
-
videoPort: prepared.videoPort,
|
|
104023
|
-
audioPort: prepared.audioPort,
|
|
104024
|
-
videoSrtp: {
|
|
104025
|
-
key: request.videoSrtp.key,
|
|
104026
|
-
salt: request.videoSrtp.salt
|
|
104027
|
-
},
|
|
104028
|
-
audioSrtp: {
|
|
104029
|
-
key: request.audioSrtp.key,
|
|
104030
|
-
salt: request.audioSrtp.salt
|
|
104031
|
-
},
|
|
104032
|
-
videoSsrc: prepared.videoSsrc,
|
|
104033
|
-
audioSsrc: prepared.audioSsrc
|
|
104034
|
-
});
|
|
104035
|
-
log.info("hap multi-tier: endpoints prepared", { meta: {
|
|
104036
|
-
sessionId: request.sessionId,
|
|
104037
|
-
controller: `${request.controllerAddress}:${request.videoPort}/${request.audioPort}`,
|
|
104038
|
-
accessory: `${prepared.localAddress}:${prepared.videoPort}/${prepared.audioPort}`,
|
|
104039
|
-
videoSsrc: prepared.videoSsrc,
|
|
104040
|
-
audioSsrc: prepared.audioSsrc
|
|
104041
|
-
} });
|
|
104042
|
-
} catch (error) {
|
|
104043
|
-
setupResponse = encodeSetupEndpointsRefusal(SetupEndpointsStatus.ERROR);
|
|
104044
|
-
log.warn("hap multi-tier: setup endpoints refused", { meta: {
|
|
104045
|
-
sessionId,
|
|
104046
|
-
error: errorMessage(error)
|
|
104047
|
-
} });
|
|
104048
|
-
}
|
|
104049
|
-
}
|
|
104050
|
-
async function handleStreamingControlWrite(base64) {
|
|
104051
|
-
let sessionId = null;
|
|
104052
|
-
try {
|
|
104053
|
-
const command = decodeRtpStreamingControl(base64);
|
|
104054
|
-
sessionId = command.sessionId;
|
|
104055
|
-
if (!negotiator.has(command.sessionId)) {
|
|
104056
|
-
controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.UNKNOWN_SESSION_IDENTIFIER);
|
|
104057
|
-
log.warn("hap multi-tier: control write for a session that was never prepared", { meta: {
|
|
104058
|
-
sessionId: command.sessionId,
|
|
104059
|
-
command: command.command
|
|
104060
|
-
} });
|
|
104061
|
-
return;
|
|
104062
|
-
}
|
|
104063
|
-
if (command.command === RtpStreamingCommand.END) {
|
|
104064
|
-
if (!started.has(command.sessionId)) {
|
|
104065
|
-
controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.NO_SUCH_STREAM);
|
|
104066
|
-
return;
|
|
104067
|
-
}
|
|
104068
|
-
const stop = {
|
|
104069
|
-
sessionID: command.sessionId,
|
|
104070
|
-
type: import_dist.StreamRequestTypes.STOP
|
|
104071
|
-
};
|
|
104072
|
-
await negotiator.control(stop);
|
|
104073
|
-
started.delete(command.sessionId);
|
|
104074
|
-
controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.SUCCESS);
|
|
104075
|
-
log.info("hap multi-tier: stream ended", { meta: { sessionId: command.sessionId } });
|
|
104076
|
-
return;
|
|
104077
|
-
}
|
|
104078
|
-
const tier = input.tiers.find((t) => t.identifier === command.videoTier);
|
|
104079
|
-
if (tier === void 0) {
|
|
104080
|
-
controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.NO_SUCH_STREAM);
|
|
104081
|
-
log.warn("hap multi-tier: start named a tier we never published", { meta: {
|
|
104082
|
-
sessionId: command.sessionId,
|
|
104083
|
-
requested: command.videoTier,
|
|
104084
|
-
published: input.tiers.map((t) => t.identifier)
|
|
104085
|
-
} });
|
|
104086
|
-
return;
|
|
104087
|
-
}
|
|
104088
|
-
log.info("hap multi-tier: start ssrc comparison", { meta: {
|
|
104089
|
-
sessionId: command.sessionId,
|
|
104090
|
-
controllerVideoSsrc: command.videoSsrc ?? null,
|
|
104091
|
-
controllerAudioSsrc: command.audioSsrc ?? null,
|
|
104092
|
-
note: "accessory keeps the ssrcs it advertised at setup"
|
|
104093
|
-
} });
|
|
104094
|
-
const start = {
|
|
104095
|
-
sessionID: command.sessionId,
|
|
104096
|
-
type: import_dist.StreamRequestTypes.START,
|
|
104097
|
-
video: {
|
|
104098
|
-
codec: import_dist.VideoCodecType.H264,
|
|
104099
|
-
profile: import_dist.H264Profile.HIGH,
|
|
104100
|
-
level: import_dist.H264Level.LEVEL4_0,
|
|
104101
|
-
packetizationMode: import_dist.VideoCodecPacketizationMode.NON_INTERLEAVED,
|
|
104102
|
-
width: tier.width,
|
|
104103
|
-
height: tier.height,
|
|
104104
|
-
fps: tier.frameRate,
|
|
104105
|
-
pt: VIDEO_PAYLOAD_TYPE,
|
|
104106
|
-
ssrc: command.videoSsrc ?? 0,
|
|
104107
|
-
max_bit_rate: tier.targetAverageBitrateKbps,
|
|
104108
|
-
rtcp_interval: RTCP_INTERVAL_SECONDS,
|
|
104109
|
-
mtu: DEFAULT_MTU
|
|
104110
|
-
},
|
|
104111
|
-
audio: {
|
|
104112
|
-
codec: import_dist.AudioStreamingCodecType.OPUS,
|
|
104113
|
-
channel: 1,
|
|
104114
|
-
bit_rate: 24,
|
|
104115
|
-
sample_rate: import_dist.AudioStreamingSamplerate.KHZ_24,
|
|
104116
|
-
packet_time: 20,
|
|
104117
|
-
pt: AUDIO_PAYLOAD_TYPE,
|
|
104118
|
-
ssrc: command.audioSsrc ?? 0,
|
|
104119
|
-
max_bit_rate: 24,
|
|
104120
|
-
rtcp_interval: RTCP_INTERVAL_SECONDS,
|
|
104121
|
-
comfort_pt: 13,
|
|
104122
|
-
comfortNoiseEnabled: false
|
|
104123
|
-
}
|
|
104124
|
-
};
|
|
104125
|
-
await negotiator.control(start);
|
|
104126
|
-
started.add(command.sessionId);
|
|
104127
|
-
controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.SUCCESS);
|
|
104128
|
-
log.info("hap multi-tier: stream started", { meta: {
|
|
104129
|
-
sessionId: command.sessionId,
|
|
104130
|
-
tier: tier.identifier,
|
|
104131
|
-
geometry: `${tier.width}x${tier.height}@${tier.frameRate}`,
|
|
104132
|
-
targetKbps: tier.targetAverageBitrateKbps,
|
|
104133
|
-
codec: input.codec
|
|
104134
|
-
} });
|
|
104135
|
-
} catch (error) {
|
|
104136
|
-
controlResponse = encodeRtpStreamingControlStatus(sessionId, RtpStreamingStatus.ERROR);
|
|
104137
|
-
log.warn("hap multi-tier: control write refused", { meta: {
|
|
104138
|
-
sessionId,
|
|
104139
|
-
error: errorMessage(error)
|
|
104140
|
-
} });
|
|
104141
|
-
}
|
|
104142
|
-
}
|
|
104143
|
-
return {
|
|
104144
|
-
handleSetupEndpointsWrite,
|
|
104145
|
-
readSetupEndpoints: () => setupResponse,
|
|
104146
|
-
handleStreamingControlWrite,
|
|
104147
|
-
readStreamingControl: () => controlResponse
|
|
104148
|
-
};
|
|
104149
|
-
}
|
|
104150
|
-
//#endregion
|
|
104151
|
-
//#region src/mappers/camera-accessory.ts
|
|
104152
|
-
/**
|
|
104153
|
-
* Camera accessory orchestrator — given a camstack deviceId, builds one
|
|
104154
|
-
* HomeKit Accessory representing the camera (or doorbell) plus a
|
|
104155
|
-
* separate Accessory per child device (siren, floodlight, …).
|
|
104156
|
-
*
|
|
104157
|
-
* Flow:
|
|
104158
|
-
* 1. Resolve a typed `DeviceProxy` via `ctx.fetchDevice(deviceId)`.
|
|
104159
|
-
* 2. Inspect `proxy.binding.entries` (or fall back to `getDevice`
|
|
104160
|
-
* features) to decide whether the device is a doorbell vs a
|
|
104161
|
-
* regular camera. Doorbells get `Categories.VIDEO_DOORBELL` +
|
|
104162
|
-
* `DoorbellController`; otherwise `Categories.IP_CAMERA` +
|
|
104163
|
-
* `CameraController`.
|
|
104164
|
-
* 3. Build the streaming delegate via the `camera-streams` builder.
|
|
104165
|
-
* 4. `configureController(controller)` to attach the streaming
|
|
104166
|
-
* services to the Accessory.
|
|
104167
|
-
* 5. Run each feature builder gated on cap presence (motion sensor,
|
|
104168
|
-
* intercom, PTZ, doorbell).
|
|
104169
|
-
* 6. Enumerate children via `proxy.deviceManager.getChildren({...})`
|
|
104170
|
-
* and build one Accessory per child (see `child-accessory.ts`).
|
|
104171
|
-
* 7. Return a `Mapper` whose `accessories[]` lists every accessory
|
|
104172
|
-
* this device produced so the bridge can register them all and
|
|
104173
|
-
* remove them all on unexpose.
|
|
104174
|
-
*/
|
|
104175
|
-
async function buildCameraAccessory(input) {
|
|
104176
|
-
const { ctx, deviceId, displayName, options } = input;
|
|
104177
|
-
const numericId = Number.parseInt(deviceId, 10);
|
|
104178
|
-
if (!Number.isFinite(numericId)) throw new Error(`export-hap: cannot map device '${deviceId}' — id is not numeric`);
|
|
104179
|
-
const proxy = await ctx.fetchDevice(numericId);
|
|
104180
|
-
const capNames = new Set(proxy.binding?.entries.map((e) => e.capName) ?? []);
|
|
104181
|
-
const isDoorbell = capNames.has("doorbell");
|
|
104182
|
-
const category = isDoorbell ? import_dist.Categories.VIDEO_DOORBELL : import_dist.Categories.IP_CAMERA;
|
|
104183
|
-
const accessory = new import_dist.Accessory(displayName, accessoryUuidFor("camera", numericId));
|
|
104184
|
-
accessory.category = category;
|
|
104185
|
-
await populateAccessoryInfo(accessory, proxy, displayName, "Camera");
|
|
104186
|
-
const bctx = {
|
|
104187
|
-
ctx,
|
|
104188
|
-
accessory,
|
|
104189
|
-
proxy,
|
|
104190
|
-
numericDeviceId: numericId,
|
|
104191
|
-
displayName,
|
|
104192
|
-
options
|
|
104193
|
-
};
|
|
104194
|
-
const advertisedVideo = await probeAdvertisedVideoProfile(bctx);
|
|
104195
|
-
const streams = buildCameraStreamingDelegate(bctx, advertisedVideo);
|
|
104196
|
-
const handles = [];
|
|
104197
|
-
if (capNames.has("intercom")) handles.push(await buildIntercom({
|
|
104198
|
-
bctx,
|
|
104199
|
-
streamingOptions: streams.streamingOptions
|
|
104200
|
-
}));
|
|
104201
|
-
const recordingEnabled = options.hapDeviceSettings.hksvRecording === true;
|
|
104202
|
-
let recordingAudioActive = true;
|
|
104203
|
-
const recording = recordingEnabled ? await buildHksvRecording({
|
|
104204
|
-
bctx,
|
|
104205
|
-
fpsByProfile: advertisedVideo.fpsByProfile,
|
|
104206
|
-
isAudioActive: () => recordingAudioActive
|
|
104207
|
-
}) : null;
|
|
104208
|
-
const controller = new (isDoorbell ? import_dist.DoorbellController : import_dist.CameraController)({
|
|
104209
|
-
delegate: streams.delegate,
|
|
104210
|
-
streamingOptions: streams.streamingOptions,
|
|
104211
|
-
cameraStreamCount: 2,
|
|
104212
|
-
...recording === null ? {} : { recording },
|
|
104213
|
-
...recording === null || !capNames.has("motion-detection") ? {} : { sensors: { motion: true } }
|
|
104214
|
-
});
|
|
104215
|
-
accessory.configureController(controller);
|
|
104216
|
-
if (options.hapDeviceSettings.multiTierService !== false) {
|
|
104217
|
-
const sensor = advertisedVideo.sensor;
|
|
104218
|
-
const services = buildMultiTierServices({
|
|
104219
|
-
deviceId: numericId,
|
|
104220
|
-
logger: ctx.logger,
|
|
104221
|
-
width: sensor.width,
|
|
104222
|
-
height: sensor.height,
|
|
104223
|
-
codec: sensor.codec,
|
|
104224
|
-
audioCaptureRateHz: 16e3,
|
|
104225
|
-
negotiation: buildMultiTierNegotiation({
|
|
104226
|
-
deviceId: numericId,
|
|
104227
|
-
logger: ctx.logger,
|
|
104228
|
-
negotiator: streams.negotiator,
|
|
104229
|
-
tiers: tiersForSensor(sensor.width, sensor.height),
|
|
104230
|
-
codec: sensor.codec
|
|
104231
|
-
})
|
|
104232
|
-
});
|
|
104233
|
-
accessory.addService(services.capabilities);
|
|
104234
|
-
accessory.addService(services.globalOperatingMode);
|
|
104235
|
-
accessory.addService(services.streamManagement);
|
|
104236
|
-
ctx.logger.info("hap multi-tier: services declared on this accessory", {
|
|
104237
|
-
tags: { deviceId: numericId },
|
|
104238
|
-
meta: {
|
|
104239
|
-
serviceUuid: MULTI_TIER_RTP_SERVICE_UUID,
|
|
104240
|
-
codec: sensor.codec === TierVideoCodec.H265 ? "h265" : "h264",
|
|
104241
|
-
sensor: `${sensor.width}x${sensor.height}`
|
|
104242
|
-
}
|
|
104243
|
-
});
|
|
104244
|
-
}
|
|
104245
|
-
if (recording !== null) {
|
|
104246
|
-
handles.push({ dispose: () => recording.dispose() });
|
|
104247
|
-
const audioCharacteristic = (controller.recordingManagement?.operatingModeService)?.getCharacteristic(import_dist.Characteristic.RecordingAudioActive);
|
|
104248
|
-
if (audioCharacteristic) {
|
|
104249
|
-
recordingAudioActive = audioCharacteristic.value !== 0 && audioCharacteristic.value !== false;
|
|
104250
|
-
audioCharacteristic.on("change", ({ newValue }) => {
|
|
104251
|
-
recordingAudioActive = newValue !== 0 && newValue !== false;
|
|
104252
|
-
});
|
|
104253
|
-
}
|
|
104254
|
-
}
|
|
104255
|
-
if (capNames.has("motion-detection")) handles.push(await buildMotionSensor(bctx, recording === null ? null : controller.motionService ?? null));
|
|
104256
|
-
if (isDoorbell && controller instanceof import_dist.DoorbellController) handles.push(await buildDoorbell({
|
|
104257
|
-
bctx,
|
|
104258
|
-
controller
|
|
104259
|
-
}));
|
|
104260
|
-
if (capNames.has("ptz") || capNames.has("ptz-autotrack")) handles.push(await buildPtz(bctx));
|
|
104261
|
-
if (capNames.has("battery")) handles.push(await buildBattery(bctx));
|
|
104262
|
-
if (capNames.has("privacy-mask")) handles.push(await buildPrivacySwitch(bctx));
|
|
104263
|
-
handles.push(await buildChildServicesFor({ parentCtx: bctx }));
|
|
104264
|
-
return {
|
|
104265
|
-
accessory,
|
|
104266
|
-
accessories: [accessory],
|
|
104267
|
-
async dispose() {
|
|
104268
|
-
const log = ctx.logger.withTags({ deviceId: numericId });
|
|
104269
|
-
for (const h of handles) try {
|
|
104270
|
-
await h.dispose();
|
|
104271
|
-
} catch (err) {
|
|
104272
|
-
log.debug("export-hap: builder dispose failed (continuing)", { meta: { error: errMsg$5(err) } });
|
|
104273
|
-
}
|
|
104274
|
-
try {
|
|
104275
|
-
await streams.dispose();
|
|
104276
|
-
} catch (err) {
|
|
104277
|
-
log.debug("export-hap: streams dispose failed", { meta: { error: errMsg$5(err) } });
|
|
104278
|
-
}
|
|
104279
|
-
}
|
|
104280
|
-
};
|
|
104281
|
-
}
|
|
104282
|
-
function errMsg$5(err) {
|
|
104283
|
-
return err instanceof Error ? err.message : String(err);
|
|
104284
|
-
}
|
|
104285
|
-
//#endregion
|
|
104286
|
-
//#region src/mappers/builders/generic/lock.ts
|
|
104287
|
-
/**
|
|
104288
|
-
* `lock-control` → `Service.LockMechanism`.
|
|
104289
|
-
*
|
|
104290
|
-
* Not a sensor row: HomeKit models a lock as two characteristics, a CURRENT
|
|
104291
|
-
* state the accessory owns and a TARGET state the controller writes, and the
|
|
104292
|
-
* cap's five states do not map onto either one alone.
|
|
104293
|
-
*
|
|
104294
|
-
* ```
|
|
104295
|
-
* cap state LockCurrentState LockTargetState
|
|
104296
|
-
* locked SECURED SECURED
|
|
104297
|
-
* unlocked UNSECURED UNSECURED
|
|
104298
|
-
* locking UNKNOWN SECURED (in flight — do not claim SECURED)
|
|
104299
|
-
* unlocking UNKNOWN UNSECURED
|
|
104300
|
-
* jammed JAMMED unchanged (the controller's intent stands)
|
|
104301
|
-
* ```
|
|
104302
|
-
*
|
|
104303
|
-
* Reporting SECURED while the bolt is still moving is the failure worth naming:
|
|
104304
|
-
* iOS renders the lock as closed, the operator walks away, and the motor stalls
|
|
104305
|
-
* behind them. `UNKNOWN` is the honest answer for an in-flight transition.
|
|
104306
|
-
*
|
|
104307
|
-
* `open` (the cap's third method, for locks with a latch/buzzer) has no HomeKit
|
|
104308
|
-
* counterpart on this service and is deliberately not wired — a Switch that
|
|
104309
|
-
* silently buzzes a door open would be a second knob nobody asked for.
|
|
104310
|
-
*/
|
|
104311
|
-
var SUBTYPE$1 = "lock-control";
|
|
104312
|
-
var readLock = reader(LockControlStatusSchema.pick({ state: true }), (status) => {
|
|
104313
|
-
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;
|
|
104314
|
-
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;
|
|
104315
|
-
return [{
|
|
104316
|
-
characteristic: import_dist.Characteristic.LockCurrentState,
|
|
104317
|
-
value: current
|
|
104318
|
-
}, ...target === null ? [] : [{
|
|
104319
|
-
characteristic: import_dist.Characteristic.LockTargetState,
|
|
104320
|
-
value: target
|
|
104321
|
-
}]];
|
|
104322
|
-
});
|
|
104323
|
-
async function buildLockMechanism(bctx) {
|
|
104324
|
-
const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
|
|
104325
|
-
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
104326
|
-
const label = hapServiceName([displayName], `Lock ${numericDeviceId}`);
|
|
104327
|
-
const service = accessory.addService(import_dist.Service.LockMechanism, label, SUBTYPE$1);
|
|
104328
|
-
applyServiceLabel(service, label);
|
|
104329
|
-
const apply = (status, source) => {
|
|
104330
|
-
const updates = readLock(status);
|
|
104331
|
-
if (updates.length === 0) {
|
|
104332
|
-
log.debug("export-hap: lock status did not match the cap schema — no update", { meta: { source } });
|
|
105941
|
+
});
|
|
104333
105942
|
return;
|
|
104334
105943
|
}
|
|
104335
|
-
|
|
104336
|
-
};
|
|
104337
|
-
|
|
104338
|
-
|
|
104339
|
-
|
|
104340
|
-
|
|
104341
|
-
|
|
104342
|
-
|
|
104343
|
-
|
|
104344
|
-
|
|
104345
|
-
|
|
104346
|
-
|
|
104347
|
-
|
|
104348
|
-
|
|
104349
|
-
|
|
104350
|
-
|
|
104351
|
-
|
|
104352
|
-
|
|
105944
|
+
await input.negotiation.handleSetupEndpointsWrite(value);
|
|
105945
|
+
});
|
|
105946
|
+
streamManagement.addCharacteristic(setupEndpoints);
|
|
105947
|
+
const streamingControl = new import_dist.Characteristic("RTP Streaming Control", CHAR_RTP_STREAMING_CONTROL, {
|
|
105948
|
+
format: import_dist.Formats.TLV8,
|
|
105949
|
+
perms: [import_dist.Perms.PAIRED_READ, import_dist.Perms.PAIRED_WRITE]
|
|
105950
|
+
});
|
|
105951
|
+
streamingControl.onGet(() => {
|
|
105952
|
+
note("rtp-streaming-control");
|
|
105953
|
+
return input.negotiation?.readStreamingControl() ?? encodeRtpStreamingControlStatus(null, RtpStreamingStatus.ERROR);
|
|
105954
|
+
});
|
|
105955
|
+
streamingControl.onSet(async (value) => {
|
|
105956
|
+
if (input.negotiation === void 0 || typeof value !== "string") {
|
|
105957
|
+
logger.info("hap multi-tier: refused an rtp streaming control write", {
|
|
105958
|
+
tags: { deviceId },
|
|
105959
|
+
meta: { reason: input.negotiation === void 0 ? "no-negotiator" : "not-a-string" }
|
|
105960
|
+
});
|
|
105961
|
+
return;
|
|
104353
105962
|
}
|
|
105963
|
+
await input.negotiation.handleStreamingControlWrite(value);
|
|
104354
105964
|
});
|
|
104355
|
-
|
|
104356
|
-
|
|
104357
|
-
|
|
104358
|
-
|
|
104359
|
-
|
|
104360
|
-
|
|
104361
|
-
|
|
104362
|
-
|
|
104363
|
-
|
|
104364
|
-
}
|
|
104365
|
-
|
|
104366
|
-
return
|
|
104367
|
-
|
|
104368
|
-
|
|
104369
|
-
|
|
104370
|
-
/** Is this a settled arm state worth remembering? */
|
|
104371
|
-
function armedMemoOf(state) {
|
|
104372
|
-
return state === "armed_home" || state === "armed_away" || state === "armed_night" || state === "armed_vacation" || state === "armed_custom_bypass" ? state : null;
|
|
104373
|
-
}
|
|
104374
|
-
function currentForArmed(state) {
|
|
104375
|
-
switch (state) {
|
|
104376
|
-
case "armed_home":
|
|
104377
|
-
case "armed_custom_bypass": return import_dist.Characteristic.SecuritySystemCurrentState.STAY_ARM;
|
|
104378
|
-
case "armed_night": return import_dist.Characteristic.SecuritySystemCurrentState.NIGHT_ARM;
|
|
104379
|
-
default: return import_dist.Characteristic.SecuritySystemCurrentState.AWAY_ARM;
|
|
104380
|
-
}
|
|
104381
|
-
}
|
|
104382
|
-
function targetForArmed(state) {
|
|
104383
|
-
switch (state) {
|
|
104384
|
-
case "armed_home":
|
|
104385
|
-
case "armed_custom_bypass": return import_dist.Characteristic.SecuritySystemTargetState.STAY_ARM;
|
|
104386
|
-
case "armed_night": return import_dist.Characteristic.SecuritySystemTargetState.NIGHT_ARM;
|
|
104387
|
-
default: return import_dist.Characteristic.SecuritySystemTargetState.AWAY_ARM;
|
|
104388
|
-
}
|
|
104389
|
-
}
|
|
104390
|
-
function securityStateFor(state, memo) {
|
|
104391
|
-
const settled = armedMemoOf(state);
|
|
104392
|
-
if (settled !== null) return {
|
|
104393
|
-
current: currentForArmed(settled),
|
|
104394
|
-
target: targetForArmed(settled)
|
|
105965
|
+
streamManagement.addCharacteristic(streamingControl);
|
|
105966
|
+
const sensor = new import_dist.Characteristic("Sensor UUID", CHAR_SENSOR_UUID, {
|
|
105967
|
+
format: import_dist.Formats.DATA,
|
|
105968
|
+
perms: [import_dist.Perms.PAIRED_READ]
|
|
105969
|
+
});
|
|
105970
|
+
const sensorValue = sensorUuid.toString("base64");
|
|
105971
|
+
sensor.onGet(() => {
|
|
105972
|
+
note("sensor-uuid");
|
|
105973
|
+
return sensorValue;
|
|
105974
|
+
});
|
|
105975
|
+
streamManagement.addCharacteristic(sensor);
|
|
105976
|
+
return {
|
|
105977
|
+
capabilities,
|
|
105978
|
+
globalOperatingMode,
|
|
105979
|
+
streamManagement
|
|
104395
105980
|
};
|
|
104396
|
-
switch (state) {
|
|
104397
|
-
case "disarmed": return {
|
|
104398
|
-
current: import_dist.Characteristic.SecuritySystemCurrentState.DISARMED,
|
|
104399
|
-
target: import_dist.Characteristic.SecuritySystemTargetState.DISARM
|
|
104400
|
-
};
|
|
104401
|
-
case "arming": return {
|
|
104402
|
-
current: import_dist.Characteristic.SecuritySystemCurrentState.DISARMED,
|
|
104403
|
-
target: memo === null ? null : targetForArmed(memo)
|
|
104404
|
-
};
|
|
104405
|
-
case "disarming": return {
|
|
104406
|
-
current: memo === null ? import_dist.Characteristic.SecuritySystemCurrentState.DISARMED : currentForArmed(memo),
|
|
104407
|
-
target: import_dist.Characteristic.SecuritySystemTargetState.DISARM
|
|
104408
|
-
};
|
|
104409
|
-
case "pending": return {
|
|
104410
|
-
current: memo === null ? import_dist.Characteristic.SecuritySystemCurrentState.DISARMED : currentForArmed(memo),
|
|
104411
|
-
target: memo === null ? null : targetForArmed(memo)
|
|
104412
|
-
};
|
|
104413
|
-
default: return {
|
|
104414
|
-
current: import_dist.Characteristic.SecuritySystemCurrentState.ALARM_TRIGGERED,
|
|
104415
|
-
target: memo === null ? null : targetForArmed(memo)
|
|
104416
|
-
};
|
|
104417
|
-
}
|
|
104418
|
-
}
|
|
104419
|
-
/**
|
|
104420
|
-
* The arm mode behind a HomeKit target, or `null` for DISARM and for anything
|
|
104421
|
-
* outside the four values HomeKit defines.
|
|
104422
|
-
*
|
|
104423
|
-
* `null` is not an error the caller can ignore: DISARM is a different COMMAND,
|
|
104424
|
-
* and a value HomeKit never sends is a write this builder refuses to translate.
|
|
104425
|
-
*/
|
|
104426
|
-
function alarmArmModeFor(target) {
|
|
104427
|
-
switch (target) {
|
|
104428
|
-
case import_dist.Characteristic.SecuritySystemTargetState.STAY_ARM: return "home";
|
|
104429
|
-
case import_dist.Characteristic.SecuritySystemTargetState.AWAY_ARM: return "away";
|
|
104430
|
-
case import_dist.Characteristic.SecuritySystemTargetState.NIGHT_ARM: return "night";
|
|
104431
|
-
default: return null;
|
|
104432
|
-
}
|
|
104433
|
-
}
|
|
104434
|
-
//#endregion
|
|
104435
|
-
//#region src/mappers/builders/generic/security-target.ts
|
|
104436
|
-
/**
|
|
104437
|
-
* The panel modes behind each HomeKit target, PRIMARY first.
|
|
104438
|
-
*
|
|
104439
|
-
* The primary is the mode `alarmArmModeFor` names; the secondary is the other
|
|
104440
|
-
* mode `security-state.ts` reports through the same HomeKit value, so the two
|
|
104441
|
-
* modules agree in both directions by construction.
|
|
104442
|
-
*/
|
|
104443
|
-
var MODES_BY_TARGET = new Map([
|
|
104444
|
-
[import_dist.Characteristic.SecuritySystemTargetState.STAY_ARM, ["home", "custom_bypass"]],
|
|
104445
|
-
[import_dist.Characteristic.SecuritySystemTargetState.AWAY_ARM, ["away", "vacation"]],
|
|
104446
|
-
[import_dist.Characteristic.SecuritySystemTargetState.NIGHT_ARM, ["night"]]
|
|
104447
|
-
]);
|
|
104448
|
-
/** Arm targets in the order HomeKit numbers them; DISARM is appended last. */
|
|
104449
|
-
var ARM_TARGETS = [
|
|
104450
|
-
import_dist.Characteristic.SecuritySystemTargetState.STAY_ARM,
|
|
104451
|
-
import_dist.Characteristic.SecuritySystemTargetState.AWAY_ARM,
|
|
104452
|
-
import_dist.Characteristic.SecuritySystemTargetState.NIGHT_ARM
|
|
104453
|
-
];
|
|
104454
|
-
/**
|
|
104455
|
-
* The `validValues` for `SecuritySystemTargetState`, or `null` to leave
|
|
104456
|
-
* HomeKit's own four values in place.
|
|
104457
|
-
*
|
|
104458
|
-
* DISARM is always present: disarming is not one of the panel's arm modes and
|
|
104459
|
-
* a panel that cannot be disarmed is not a thing this cap can express.
|
|
104460
|
-
*/
|
|
104461
|
-
function targetValidValuesFor(availableModes) {
|
|
104462
|
-
if (availableModes.length === 0) return null;
|
|
104463
|
-
return [...ARM_TARGETS.filter((target) => (MODES_BY_TARGET.get(target) ?? []).some((mode) => availableModes.includes(mode))), import_dist.Characteristic.SecuritySystemTargetState.DISARM];
|
|
104464
|
-
}
|
|
104465
|
-
/**
|
|
104466
|
-
* The arm mode to send for a HomeKit target, or `null` when there is none.
|
|
104467
|
-
*
|
|
104468
|
-
* `null` is not a shrug the caller may swallow: DISARM is a different command,
|
|
104469
|
-
* and an arm target the panel accepts no mode for is a write this module
|
|
104470
|
-
* refuses to translate rather than guess at.
|
|
104471
|
-
*/
|
|
104472
|
-
function armModeForTarget(target, availableModes) {
|
|
104473
|
-
const primary = alarmArmModeFor(target);
|
|
104474
|
-
if (primary === null) return null;
|
|
104475
|
-
if (availableModes.length === 0) return primary;
|
|
104476
|
-
return (MODES_BY_TARGET.get(target) ?? [primary]).find((mode) => availableModes.includes(mode)) ?? null;
|
|
104477
105981
|
}
|
|
104478
105982
|
//#endregion
|
|
104479
|
-
//#region src/
|
|
105983
|
+
//#region src/multi-tier/negotiation.ts
|
|
104480
105984
|
/**
|
|
104481
|
-
*
|
|
104482
|
-
*
|
|
104483
|
-
* The lock builder's shape (a current/target pair, a hydrate, an `onSet`, a
|
|
104484
|
-
* slice subscription, a dispose) applied to the one device on the fleet where
|
|
104485
|
-
* reporting a state that is not true has a consequence worse than a stalled
|
|
104486
|
-
* motor. The mapping itself is NOT here: `security-state.ts` owns what the
|
|
104487
|
-
* panel's state looks like to HomeKit and `security-target.ts` owns what
|
|
104488
|
-
* HomeKit may ask for, both pure and both tested on their own. This file is
|
|
104489
|
-
* the service around them.
|
|
104490
|
-
*
|
|
104491
|
-
* ## The memo
|
|
105985
|
+
* The adapter between Apple's new characteristics and the streaming engine this
|
|
105986
|
+
* addon already has.
|
|
104492
105987
|
*
|
|
104493
|
-
*
|
|
104494
|
-
* armed to — only the last settled state knows it. This builder is the caller
|
|
104495
|
-
* `security-state.ts` describes: it remembers the last settled arm and forgets
|
|
104496
|
-
* it on `disarmed`, because a memo that outlives a disarm makes the next
|
|
104497
|
-
* `arming` claim a target nobody asked for.
|
|
105988
|
+
* ## Why an adapter and not a second engine
|
|
104498
105989
|
*
|
|
104499
|
-
* The
|
|
104500
|
-
*
|
|
104501
|
-
*
|
|
104502
|
-
*
|
|
104503
|
-
*
|
|
104504
|
-
*
|
|
105990
|
+
* The multi-tier RTP service owns its OWN `Setup Endpoints` and `RTP Streaming
|
|
105991
|
+
* Control`, so hap-nodejs's `CameraController` never negotiates for it — and
|
|
105992
|
+
* the library's own handlers are all `private`, so its engine cannot be
|
|
105993
|
+
* borrowed either. But the sockets, the SRTP contexts, the ffmpeg pipeline and
|
|
105994
|
+
* the teardown are the same ones the classic service uses. A second copy would
|
|
105995
|
+
* be two engines to keep in agreement, and this repo has paid for that shape
|
|
105996
|
+
* before. So: new characteristics, one engine, through `StreamNegotiator`.
|
|
104505
105997
|
*
|
|
104506
|
-
* ##
|
|
105998
|
+
* ## What is different about the new flow
|
|
104507
105999
|
*
|
|
104508
|
-
*
|
|
104509
|
-
*
|
|
104510
|
-
*
|
|
104511
|
-
*
|
|
104512
|
-
*
|
|
106000
|
+
* The classic `Selected RTP Stream Configuration` carries the whole selected
|
|
106001
|
+
* configuration in its start — codec, profile, level, resolution, bitrate.
|
|
106002
|
+
* `RTP Streaming Control` carries a **tier identifier**, because the
|
|
106003
|
+
* configuration was agreed when we published `Supported Video Stream Tiers`.
|
|
106004
|
+
* So this maps an identifier back to the tier WE published, and a tier we never
|
|
106005
|
+
* published is `No Such Stream` — improvising a geometry there would serve a
|
|
106006
|
+
* stream the controller did not ask for and it would look like it worked.
|
|
104513
106007
|
*
|
|
104514
|
-
*
|
|
104515
|
-
* alternative. It is worse: the refusal costs a round trip, it is
|
|
104516
|
-
* indistinguishable from a broken panel in the log, and repeated code-less
|
|
104517
|
-
* attempts are what locks a real alarm panel out.
|
|
104518
|
-
*/
|
|
104519
|
-
var SUBTYPE = "alarm-panel";
|
|
104520
|
-
/** The subset of the status this builder reads. */
|
|
104521
|
-
var PanelReadSchema = AlarmPanelStatusSchema.pick({
|
|
104522
|
-
state: true,
|
|
104523
|
-
availableModes: true,
|
|
104524
|
-
requiresCode: true
|
|
104525
|
-
});
|
|
104526
|
-
/**
|
|
104527
|
-
* Turn a panel status into characteristic writes, folding the memo.
|
|
106008
|
+
* ## Every path answers
|
|
104528
106009
|
*
|
|
104529
|
-
* A
|
|
104530
|
-
*
|
|
104531
|
-
*
|
|
104532
|
-
*
|
|
106010
|
+
* A read of either characteristic always produces a valid TLV8. The Home app
|
|
106011
|
+
* reads a camera's characteristics in one batch, and one member that answers
|
|
106012
|
+
* nothing took the kitchen camera's whole read down and left it on "loading".
|
|
106013
|
+
* So an engine failure, a malformed write and "nothing has happened yet" are
|
|
106014
|
+
* all statuses, never silence.
|
|
104533
106015
|
*/
|
|
104534
|
-
|
|
104535
|
-
|
|
104536
|
-
|
|
104537
|
-
|
|
104538
|
-
|
|
104539
|
-
|
|
104540
|
-
|
|
104541
|
-
|
|
104542
|
-
|
|
104543
|
-
return
|
|
104544
|
-
updates: [{
|
|
104545
|
-
characteristic: import_dist.Characteristic.SecuritySystemCurrentState,
|
|
104546
|
-
value: current
|
|
104547
|
-
}, ...target === null ? [] : [{
|
|
104548
|
-
characteristic: import_dist.Characteristic.SecuritySystemTargetState,
|
|
104549
|
-
value: target
|
|
104550
|
-
}]],
|
|
104551
|
-
memo: nextMemo,
|
|
104552
|
-
panel: {
|
|
104553
|
-
availableModes: parsed.data.availableModes,
|
|
104554
|
-
requiresCode: parsed.data.requiresCode
|
|
104555
|
-
}
|
|
104556
|
-
};
|
|
106016
|
+
/** RTP payload types. 96 is the first dynamic one (RFC 3551); 110 is what our
|
|
106017
|
+
* own Opus offers use. Both are echoed in the tier declaration. */
|
|
106018
|
+
var VIDEO_PAYLOAD_TYPE = 96;
|
|
106019
|
+
var AUDIO_PAYLOAD_TYPE = 110;
|
|
106020
|
+
/** RTCP interval in SECONDS, which is the unit the engine reads. */
|
|
106021
|
+
var RTCP_INTERVAL_SECONDS = 5;
|
|
106022
|
+
/** The MTU the classic path uses for an Ethernet-sized path. */
|
|
106023
|
+
var DEFAULT_MTU = 1378;
|
|
106024
|
+
function errorMessage(error) {
|
|
106025
|
+
return error instanceof Error ? error.message : String(error);
|
|
104557
106026
|
}
|
|
104558
|
-
|
|
104559
|
-
const {
|
|
104560
|
-
const log =
|
|
104561
|
-
|
|
104562
|
-
|
|
104563
|
-
|
|
104564
|
-
|
|
104565
|
-
|
|
104566
|
-
|
|
104567
|
-
|
|
104568
|
-
|
|
104569
|
-
|
|
104570
|
-
|
|
104571
|
-
|
|
104572
|
-
|
|
104573
|
-
|
|
104574
|
-
|
|
104575
|
-
|
|
104576
|
-
|
|
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
|
-
const target = typeof value === "number" ? value : Number(value);
|
|
104613
|
-
const disarm = target === import_dist.Characteristic.SecuritySystemTargetState.DISARM;
|
|
104614
|
-
const mode = disarm ? null : armModeForTarget(target, availableModes);
|
|
104615
|
-
if (requiresCode) {
|
|
104616
|
-
log.warn("export-hap: alarm panel requires a PIN — HomeKit cannot supply one, command REFUSED", { meta: {
|
|
104617
|
-
subtype: SUBTYPE,
|
|
104618
|
-
command: disarm ? "disarm" : "arm",
|
|
104619
|
-
mode,
|
|
104620
|
-
target
|
|
106027
|
+
function buildMultiTierNegotiation(input) {
|
|
106028
|
+
const { deviceId, logger, negotiator } = input;
|
|
106029
|
+
const log = logger.withTags({ deviceId });
|
|
106030
|
+
let setupResponse = encodeSetupEndpointsRefusal(SetupEndpointsStatus.ERROR);
|
|
106031
|
+
let controlResponse = encodeRtpStreamingControlStatus(null, RtpStreamingStatus.ERROR);
|
|
106032
|
+
/** Sessions we have STARTED, so an End can tell "not started" from "unknown". */
|
|
106033
|
+
const started = /* @__PURE__ */ new Set();
|
|
106034
|
+
async function handleSetupEndpointsWrite(base64) {
|
|
106035
|
+
let sessionId = null;
|
|
106036
|
+
try {
|
|
106037
|
+
const request = decodeSetupEndpointsRequest(base64);
|
|
106038
|
+
sessionId = request.sessionId;
|
|
106039
|
+
const prepareRequest = {
|
|
106040
|
+
sessionID: request.sessionId,
|
|
106041
|
+
sourceAddress: request.controllerAddress,
|
|
106042
|
+
targetAddress: request.controllerAddress,
|
|
106043
|
+
addressVersion: request.addressVersion,
|
|
106044
|
+
video: {
|
|
106045
|
+
port: request.videoPort,
|
|
106046
|
+
srtpCryptoSuite: import_dist.SRTPCryptoSuites.AES_CM_128_HMAC_SHA1_80,
|
|
106047
|
+
srtp_key: request.videoSrtp.key,
|
|
106048
|
+
srtp_salt: request.videoSrtp.salt
|
|
106049
|
+
},
|
|
106050
|
+
audio: {
|
|
106051
|
+
port: request.audioPort,
|
|
106052
|
+
srtpCryptoSuite: import_dist.SRTPCryptoSuites.AES_CM_128_HMAC_SHA1_80,
|
|
106053
|
+
srtp_key: request.audioSrtp.key,
|
|
106054
|
+
srtp_salt: request.audioSrtp.salt
|
|
106055
|
+
}
|
|
106056
|
+
};
|
|
106057
|
+
const prepared = await negotiator.prepare(prepareRequest);
|
|
106058
|
+
setupResponse = encodeSetupEndpointsResponse({
|
|
106059
|
+
sessionId: request.sessionId,
|
|
106060
|
+
addressVersion: request.addressVersion,
|
|
106061
|
+
accessoryAddress: prepared.localAddress,
|
|
106062
|
+
videoPort: prepared.videoPort,
|
|
106063
|
+
audioPort: prepared.audioPort,
|
|
106064
|
+
videoSrtp: {
|
|
106065
|
+
key: request.videoSrtp.key,
|
|
106066
|
+
salt: request.videoSrtp.salt
|
|
106067
|
+
},
|
|
106068
|
+
audioSrtp: {
|
|
106069
|
+
key: request.audioSrtp.key,
|
|
106070
|
+
salt: request.audioSrtp.salt
|
|
106071
|
+
},
|
|
106072
|
+
videoSsrc: prepared.videoSsrc,
|
|
106073
|
+
audioSsrc: prepared.audioSsrc
|
|
106074
|
+
});
|
|
106075
|
+
log.info("hap multi-tier: endpoints prepared", { meta: {
|
|
106076
|
+
sessionId: request.sessionId,
|
|
106077
|
+
controller: `${request.controllerAddress}:${request.videoPort}/${request.audioPort}`,
|
|
106078
|
+
accessory: `${prepared.localAddress}:${prepared.videoPort}/${prepared.audioPort}`,
|
|
106079
|
+
videoSsrc: prepared.videoSsrc,
|
|
106080
|
+
audioSsrc: prepared.audioSsrc
|
|
104621
106081
|
} });
|
|
104622
|
-
|
|
104623
|
-
|
|
104624
|
-
|
|
104625
|
-
|
|
104626
|
-
|
|
104627
|
-
target,
|
|
104628
|
-
availableModes: [...availableModes]
|
|
106082
|
+
} catch (error) {
|
|
106083
|
+
setupResponse = encodeSetupEndpointsRefusal(SetupEndpointsStatus.ERROR);
|
|
106084
|
+
log.warn("hap multi-tier: setup endpoints refused", { meta: {
|
|
106085
|
+
sessionId,
|
|
106086
|
+
error: errorMessage(error)
|
|
104629
106087
|
} });
|
|
104630
|
-
throw new import_dist.HapStatusError(import_dist.HAPStatus.INVALID_VALUE_IN_REQUEST);
|
|
104631
106088
|
}
|
|
106089
|
+
}
|
|
106090
|
+
async function handleStreamingControlWrite(base64) {
|
|
106091
|
+
let sessionId = null;
|
|
104632
106092
|
try {
|
|
104633
|
-
|
|
104634
|
-
|
|
104635
|
-
|
|
104636
|
-
|
|
104637
|
-
|
|
104638
|
-
|
|
104639
|
-
|
|
104640
|
-
|
|
106093
|
+
const command = decodeRtpStreamingControl(base64);
|
|
106094
|
+
sessionId = command.sessionId;
|
|
106095
|
+
if (!negotiator.has(command.sessionId)) {
|
|
106096
|
+
controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.UNKNOWN_SESSION_IDENTIFIER);
|
|
106097
|
+
log.warn("hap multi-tier: control write for a session that was never prepared", { meta: {
|
|
106098
|
+
sessionId: command.sessionId,
|
|
106099
|
+
command: command.command
|
|
106100
|
+
} });
|
|
106101
|
+
return;
|
|
106102
|
+
}
|
|
106103
|
+
if (command.command === RtpStreamingCommand.END) {
|
|
106104
|
+
if (!started.has(command.sessionId)) {
|
|
106105
|
+
controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.NO_SUCH_STREAM);
|
|
106106
|
+
return;
|
|
106107
|
+
}
|
|
106108
|
+
const stop = {
|
|
106109
|
+
sessionID: command.sessionId,
|
|
106110
|
+
type: import_dist.StreamRequestTypes.STOP
|
|
106111
|
+
};
|
|
106112
|
+
await negotiator.control(stop);
|
|
106113
|
+
started.delete(command.sessionId);
|
|
106114
|
+
controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.SUCCESS);
|
|
106115
|
+
log.info("hap multi-tier: stream ended", { meta: { sessionId: command.sessionId } });
|
|
106116
|
+
return;
|
|
106117
|
+
}
|
|
106118
|
+
const tier = input.tiers.find((t) => t.identifier === command.videoTier);
|
|
106119
|
+
if (tier === void 0) {
|
|
106120
|
+
controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.NO_SUCH_STREAM);
|
|
106121
|
+
log.warn("hap multi-tier: start named a tier we never published", { meta: {
|
|
106122
|
+
sessionId: command.sessionId,
|
|
106123
|
+
requested: command.videoTier,
|
|
106124
|
+
published: input.tiers.map((t) => t.identifier)
|
|
106125
|
+
} });
|
|
106126
|
+
return;
|
|
106127
|
+
}
|
|
106128
|
+
log.info("hap multi-tier: start ssrc comparison", { meta: {
|
|
106129
|
+
sessionId: command.sessionId,
|
|
106130
|
+
controllerVideoSsrc: command.videoSsrc ?? null,
|
|
106131
|
+
controllerAudioSsrc: command.audioSsrc ?? null,
|
|
106132
|
+
note: "accessory keeps the ssrcs it advertised at setup"
|
|
104641
106133
|
} });
|
|
104642
|
-
|
|
104643
|
-
|
|
104644
|
-
|
|
104645
|
-
|
|
104646
|
-
|
|
104647
|
-
|
|
104648
|
-
|
|
104649
|
-
|
|
104650
|
-
|
|
104651
|
-
|
|
104652
|
-
|
|
104653
|
-
|
|
104654
|
-
|
|
104655
|
-
|
|
104656
|
-
|
|
104657
|
-
|
|
104658
|
-
|
|
104659
|
-
|
|
104660
|
-
|
|
104661
|
-
|
|
104662
|
-
|
|
104663
|
-
|
|
104664
|
-
|
|
104665
|
-
|
|
104666
|
-
|
|
104667
|
-
|
|
104668
|
-
|
|
104669
|
-
|
|
104670
|
-
|
|
104671
|
-
|
|
104672
|
-
|
|
106134
|
+
const start = {
|
|
106135
|
+
sessionID: command.sessionId,
|
|
106136
|
+
type: import_dist.StreamRequestTypes.START,
|
|
106137
|
+
video: {
|
|
106138
|
+
codec: import_dist.VideoCodecType.H264,
|
|
106139
|
+
profile: import_dist.H264Profile.HIGH,
|
|
106140
|
+
level: import_dist.H264Level.LEVEL4_0,
|
|
106141
|
+
packetizationMode: import_dist.VideoCodecPacketizationMode.NON_INTERLEAVED,
|
|
106142
|
+
width: tier.width,
|
|
106143
|
+
height: tier.height,
|
|
106144
|
+
fps: tier.frameRate,
|
|
106145
|
+
pt: VIDEO_PAYLOAD_TYPE,
|
|
106146
|
+
ssrc: command.videoSsrc ?? 0,
|
|
106147
|
+
max_bit_rate: tier.targetAverageBitrateKbps,
|
|
106148
|
+
rtcp_interval: RTCP_INTERVAL_SECONDS,
|
|
106149
|
+
mtu: DEFAULT_MTU
|
|
106150
|
+
},
|
|
106151
|
+
audio: {
|
|
106152
|
+
codec: import_dist.AudioStreamingCodecType.OPUS,
|
|
106153
|
+
channel: 1,
|
|
106154
|
+
bit_rate: 24,
|
|
106155
|
+
sample_rate: import_dist.AudioStreamingSamplerate.KHZ_24,
|
|
106156
|
+
packet_time: 20,
|
|
106157
|
+
pt: AUDIO_PAYLOAD_TYPE,
|
|
106158
|
+
ssrc: command.audioSsrc ?? 0,
|
|
106159
|
+
max_bit_rate: 24,
|
|
106160
|
+
rtcp_interval: RTCP_INTERVAL_SECONDS,
|
|
106161
|
+
comfort_pt: 13,
|
|
106162
|
+
comfortNoiseEnabled: false
|
|
106163
|
+
}
|
|
106164
|
+
};
|
|
106165
|
+
await negotiator.control(start);
|
|
106166
|
+
started.add(command.sessionId);
|
|
106167
|
+
controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.SUCCESS);
|
|
106168
|
+
log.info("hap multi-tier: stream started", { meta: {
|
|
106169
|
+
sessionId: command.sessionId,
|
|
106170
|
+
tier: tier.identifier,
|
|
106171
|
+
geometry: `${tier.width}x${tier.height}@${tier.frameRate}`,
|
|
106172
|
+
targetKbps: tier.targetAverageBitrateKbps,
|
|
106173
|
+
codec: input.codec
|
|
106174
|
+
} });
|
|
106175
|
+
} catch (error) {
|
|
106176
|
+
controlResponse = encodeRtpStreamingControlStatus(sessionId, RtpStreamingStatus.ERROR);
|
|
106177
|
+
log.warn("hap multi-tier: control write refused", { meta: {
|
|
106178
|
+
sessionId,
|
|
106179
|
+
error: errorMessage(error)
|
|
104673
106180
|
} });
|
|
104674
|
-
return;
|
|
104675
106181
|
}
|
|
104676
|
-
applyUpdates(service, updates);
|
|
104677
|
-
};
|
|
104678
|
-
try {
|
|
104679
|
-
const status = await spec.getStatus(proxy);
|
|
104680
|
-
if (status !== void 0 && status !== null) apply(status, "getStatus");
|
|
104681
|
-
} catch (err) {
|
|
104682
|
-
log.debug("export-hap: sensor getStatus hydrate failed (non-fatal)", { meta: {
|
|
104683
|
-
subtype,
|
|
104684
|
-
error: errMsg$2(err)
|
|
104685
|
-
} });
|
|
104686
106182
|
}
|
|
104687
|
-
|
|
104688
|
-
|
|
104689
|
-
|
|
104690
|
-
|
|
104691
|
-
|
|
104692
|
-
|
|
104693
|
-
unsubscribe?.();
|
|
104694
|
-
} catch {}
|
|
104695
|
-
} };
|
|
104696
|
-
}
|
|
104697
|
-
function errMsg$2(err) {
|
|
104698
|
-
return err instanceof Error ? err.message : String(err);
|
|
106183
|
+
return {
|
|
106184
|
+
handleSetupEndpointsWrite,
|
|
106185
|
+
readSetupEndpoints: () => setupResponse,
|
|
106186
|
+
handleStreamingControlWrite,
|
|
106187
|
+
readStreamingControl: () => controlResponse
|
|
106188
|
+
};
|
|
104699
106189
|
}
|
|
104700
106190
|
//#endregion
|
|
104701
|
-
//#region src/mappers/
|
|
106191
|
+
//#region src/mappers/camera-accessory.ts
|
|
104702
106192
|
/**
|
|
104703
|
-
*
|
|
104704
|
-
*
|
|
104705
|
-
*
|
|
104706
|
-
* deliberately a table rather than a `switch` on `DeviceType`. The doctrine is
|
|
104707
|
-
* the one the Home Assistant exporter already writes down: *coverage is decided
|
|
104708
|
-
* by a device's capabilities, not its type — restricting the picker by type is
|
|
104709
|
-
* what confined the previous exporter to cameras.* A `DeviceType` here is only
|
|
104710
|
-
* ever a UI filter (`HAP_EXPORTABLE_DEVICE_TYPES`) or an icon
|
|
104711
|
-
* (`ACCESSORY_CATEGORY_BY_TYPE`); it never decides whether something exports.
|
|
104712
|
-
*
|
|
104713
|
-
* The shape mirrors `CAP_ENTITY_MAP` in
|
|
104714
|
-
* `packages/addon-provider-homeassistant/src/ha-export/entity-catalog.ts` so
|
|
104715
|
-
* the two can be unified later — one row per capability, keyed by the cap name
|
|
104716
|
-
* exactly as it appears in `proxy.binding.entries[].capName`.
|
|
104717
|
-
*
|
|
104718
|
-
* ## Reading a status
|
|
104719
|
-
*
|
|
104720
|
-
* Every row parses the cap's OWN Zod status schema rather than duck-typing the
|
|
104721
|
-
* payload, and it `.pick()`s only the fields it reads: a provider that omits a
|
|
104722
|
-
* timestamp must not silence a sensor, and a provider that sends the wrong
|
|
104723
|
-
* shape must not write a garbage characteristic. A parse that fails yields no
|
|
104724
|
-
* updates, and the builder that owns the service logs the drop — silence reads
|
|
104725
|
-
* as "the sensor never changed".
|
|
104726
|
-
*
|
|
104727
|
-
* ## Tier
|
|
104728
|
-
*
|
|
104729
|
-
* Tier A, plus `alarm-panel`: the caps whose HomeKit service exists today and
|
|
104730
|
-
* needs no feature-dependent shape. `cover` still needs `cover-positionable`
|
|
104731
|
-
* and `climate-control` the dual-setpoint split; those are a separate task,
|
|
104732
|
-
* and adding a row here must never mean adding a service whose semantics this
|
|
104733
|
-
* file cannot fully honour.
|
|
106193
|
+
* Camera accessory orchestrator — given a camstack deviceId, builds one
|
|
106194
|
+
* HomeKit Accessory representing the camera (or doorbell) plus a
|
|
106195
|
+
* separate Accessory per child device (siren, floodlight, …).
|
|
104734
106196
|
*
|
|
104735
|
-
*
|
|
104736
|
-
*
|
|
104737
|
-
*
|
|
104738
|
-
*
|
|
106197
|
+
* Flow:
|
|
106198
|
+
* 1. Resolve a typed `DeviceProxy` via `ctx.fetchDevice(deviceId)`.
|
|
106199
|
+
* 2. Inspect `proxy.binding.entries` (or fall back to `getDevice`
|
|
106200
|
+
* features) to decide whether the device is a doorbell vs a
|
|
106201
|
+
* regular camera. Doorbells get `Categories.VIDEO_DOORBELL` +
|
|
106202
|
+
* `DoorbellController`; otherwise `Categories.IP_CAMERA` +
|
|
106203
|
+
* `CameraController`.
|
|
106204
|
+
* 3. Build the streaming delegate via the `camera-streams` builder.
|
|
106205
|
+
* 4. `configureController(controller)` to attach the streaming
|
|
106206
|
+
* services to the Accessory.
|
|
106207
|
+
* 5. Run each feature builder gated on cap presence (motion sensor,
|
|
106208
|
+
* intercom, PTZ, doorbell).
|
|
106209
|
+
* 6. Enumerate children via `proxy.deviceManager.getChildren({...})`
|
|
106210
|
+
* and build one Accessory per child (see `child-accessory.ts`).
|
|
106211
|
+
* 7. Return a `Mapper` whose `accessories[]` lists every accessory
|
|
106212
|
+
* this device produced so the bridge can register them all and
|
|
106213
|
+
* remove them all on unexpose.
|
|
104739
106214
|
*/
|
|
104740
|
-
|
|
104741
|
-
|
|
104742
|
-
|
|
104743
|
-
|
|
104744
|
-
|
|
104745
|
-
|
|
104746
|
-
|
|
104747
|
-
|
|
104748
|
-
|
|
104749
|
-
|
|
104750
|
-
|
|
104751
|
-
|
|
104752
|
-
|
|
104753
|
-
|
|
104754
|
-
|
|
104755
|
-
|
|
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
|
-
characteristic: import_dist.Characteristic.CurrentTemperature,
|
|
104810
|
-
value: status.celsius
|
|
104811
|
-
}])
|
|
104812
|
-
},
|
|
104813
|
-
"humidity-sensor": {
|
|
104814
|
-
label: "Humidity",
|
|
104815
|
-
addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.HumiditySensor, name, subtype),
|
|
104816
|
-
getStatus: (proxy) => proxy.humiditySensor?.getStatus({}),
|
|
104817
|
-
subscribe: (proxy, onValue) => proxy.state.humiditySensor?.subscribe(onValue) ?? null,
|
|
104818
|
-
read: reader(HumiditySensorStatusSchema.pick({ percent: true }), (status) => [{
|
|
104819
|
-
characteristic: import_dist.Characteristic.CurrentRelativeHumidity,
|
|
104820
|
-
value: status.percent
|
|
104821
|
-
}])
|
|
104822
|
-
},
|
|
104823
|
-
"ambient-light-sensor": {
|
|
104824
|
-
label: "Light level",
|
|
104825
|
-
addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.LightSensor, name, subtype),
|
|
104826
|
-
getStatus: (proxy) => proxy.ambientLightSensor?.getStatus({}),
|
|
104827
|
-
subscribe: (proxy, onValue) => proxy.state.ambientLightSensor?.subscribe(onValue) ?? null,
|
|
104828
|
-
read: reader(AmbientLightSensorStatusSchema.pick({ lux: true }), (status) => [{
|
|
104829
|
-
characteristic: import_dist.Characteristic.CurrentAmbientLightLevel,
|
|
104830
|
-
value: Math.max(MIN_LUX, status.lux)
|
|
104831
|
-
}])
|
|
106215
|
+
async function buildCameraAccessory(input) {
|
|
106216
|
+
const { ctx, deviceId, displayName, options } = input;
|
|
106217
|
+
const numericId = Number.parseInt(deviceId, 10);
|
|
106218
|
+
if (!Number.isFinite(numericId)) throw new Error(`export-hap: cannot map device '${deviceId}' — id is not numeric`);
|
|
106219
|
+
const proxy = await ctx.fetchDevice(numericId);
|
|
106220
|
+
const capNames = new Set(proxy.binding?.entries.map((e) => e.capName) ?? []);
|
|
106221
|
+
const isDoorbell = capNames.has("doorbell");
|
|
106222
|
+
const category = isDoorbell ? import_dist.Categories.VIDEO_DOORBELL : import_dist.Categories.IP_CAMERA;
|
|
106223
|
+
const accessory = new import_dist.Accessory(displayName, accessoryUuidFor("camera", numericId));
|
|
106224
|
+
accessory.category = category;
|
|
106225
|
+
await populateAccessoryInfo(accessory, proxy, displayName, "Camera");
|
|
106226
|
+
const bctx = {
|
|
106227
|
+
ctx,
|
|
106228
|
+
accessory,
|
|
106229
|
+
proxy,
|
|
106230
|
+
numericDeviceId: numericId,
|
|
106231
|
+
displayName,
|
|
106232
|
+
options
|
|
106233
|
+
};
|
|
106234
|
+
const advertisedVideo = await probeAdvertisedVideoProfile(bctx);
|
|
106235
|
+
const streams = buildCameraStreamingDelegate(bctx, advertisedVideo);
|
|
106236
|
+
const handles = [];
|
|
106237
|
+
if (capNames.has("intercom")) handles.push(await buildIntercom({
|
|
106238
|
+
bctx,
|
|
106239
|
+
streamingOptions: streams.streamingOptions
|
|
106240
|
+
}));
|
|
106241
|
+
const recordingEnabled = options.hapDeviceSettings.hksvRecording === true;
|
|
106242
|
+
let recordingAudioActive = true;
|
|
106243
|
+
const recording = recordingEnabled ? await buildHksvRecording({
|
|
106244
|
+
bctx,
|
|
106245
|
+
fpsByProfile: advertisedVideo.fpsByProfile,
|
|
106246
|
+
isAudioActive: () => recordingAudioActive
|
|
106247
|
+
}) : null;
|
|
106248
|
+
const controller = new (isDoorbell ? import_dist.DoorbellController : import_dist.CameraController)({
|
|
106249
|
+
delegate: streams.delegate,
|
|
106250
|
+
streamingOptions: streams.streamingOptions,
|
|
106251
|
+
cameraStreamCount: 2,
|
|
106252
|
+
...recording === null ? {} : { recording },
|
|
106253
|
+
...recording === null || !capNames.has("motion-detection") ? {} : { sensors: { motion: true } }
|
|
106254
|
+
});
|
|
106255
|
+
accessory.configureController(controller);
|
|
106256
|
+
if (options.hapDeviceSettings.multiTierService !== false) {
|
|
106257
|
+
const sensor = advertisedVideo.sensor;
|
|
106258
|
+
const services = buildMultiTierServices({
|
|
106259
|
+
deviceId: numericId,
|
|
106260
|
+
logger: ctx.logger,
|
|
106261
|
+
width: sensor.width,
|
|
106262
|
+
height: sensor.height,
|
|
106263
|
+
codec: sensor.codec,
|
|
106264
|
+
audioCaptureRateHz: 16e3,
|
|
106265
|
+
negotiation: buildMultiTierNegotiation({
|
|
106266
|
+
deviceId: numericId,
|
|
106267
|
+
logger: ctx.logger,
|
|
106268
|
+
negotiator: streams.negotiator,
|
|
106269
|
+
tiers: tiersForSensor(sensor.width, sensor.height),
|
|
106270
|
+
codec: sensor.codec
|
|
106271
|
+
})
|
|
106272
|
+
});
|
|
106273
|
+
accessory.addService(services.capabilities);
|
|
106274
|
+
accessory.addService(services.globalOperatingMode);
|
|
106275
|
+
accessory.addService(services.streamManagement);
|
|
106276
|
+
ctx.logger.info("hap multi-tier: services declared on this accessory", {
|
|
106277
|
+
tags: { deviceId: numericId },
|
|
106278
|
+
meta: {
|
|
106279
|
+
serviceUuid: MULTI_TIER_RTP_SERVICE_UUID,
|
|
106280
|
+
codec: sensor.codec === TierVideoCodec.H265 ? "h265" : "h264",
|
|
106281
|
+
sensor: `${sensor.width}x${sensor.height}`
|
|
106282
|
+
}
|
|
106283
|
+
});
|
|
104832
106284
|
}
|
|
104833
|
-
|
|
104834
|
-
|
|
104835
|
-
|
|
104836
|
-
|
|
104837
|
-
|
|
104838
|
-
|
|
104839
|
-
|
|
104840
|
-
|
|
104841
|
-
|
|
104842
|
-
|
|
104843
|
-
|
|
104844
|
-
|
|
104845
|
-
|
|
104846
|
-
|
|
104847
|
-
|
|
106285
|
+
if (recording !== null) {
|
|
106286
|
+
handles.push({ dispose: () => recording.dispose() });
|
|
106287
|
+
const audioCharacteristic = (controller.recordingManagement?.operatingModeService)?.getCharacteristic(import_dist.Characteristic.RecordingAudioActive);
|
|
106288
|
+
if (audioCharacteristic) {
|
|
106289
|
+
recordingAudioActive = audioCharacteristic.value !== 0 && audioCharacteristic.value !== false;
|
|
106290
|
+
audioCharacteristic.on("change", ({ newValue }) => {
|
|
106291
|
+
recordingAudioActive = newValue !== 0 && newValue !== false;
|
|
106292
|
+
});
|
|
106293
|
+
}
|
|
106294
|
+
}
|
|
106295
|
+
if (capNames.has("motion-detection")) handles.push(await buildMotionSensor(bctx, recording === null ? null : controller.motionService ?? null));
|
|
106296
|
+
if (isDoorbell && controller instanceof import_dist.DoorbellController) handles.push(await buildDoorbell({
|
|
106297
|
+
bctx,
|
|
106298
|
+
controller
|
|
106299
|
+
}));
|
|
106300
|
+
if (capNames.has("ptz") || capNames.has("ptz-autotrack")) handles.push(await buildPtz(bctx));
|
|
106301
|
+
if (capNames.has("battery")) handles.push(await buildBattery(bctx));
|
|
106302
|
+
if (capNames.has("privacy-mask")) handles.push(await buildPrivacySwitch(bctx));
|
|
106303
|
+
handles.push(await buildChildServicesFor({ parentCtx: bctx }));
|
|
104848
106304
|
return {
|
|
104849
|
-
|
|
104850
|
-
|
|
104851
|
-
|
|
104852
|
-
|
|
104853
|
-
|
|
104854
|
-
|
|
104855
|
-
|
|
104856
|
-
|
|
106305
|
+
accessory,
|
|
106306
|
+
accessories: [accessory],
|
|
106307
|
+
async dispose() {
|
|
106308
|
+
const log = ctx.logger.withTags({ deviceId: numericId });
|
|
106309
|
+
for (const h of handles) try {
|
|
106310
|
+
await h.dispose();
|
|
106311
|
+
} catch (err) {
|
|
106312
|
+
log.debug("export-hap: builder dispose failed (continuing)", { meta: { error: errMsg$2(err) } });
|
|
106313
|
+
}
|
|
106314
|
+
try {
|
|
106315
|
+
await streams.dispose();
|
|
106316
|
+
} catch (err) {
|
|
106317
|
+
log.debug("export-hap: streams dispose failed", { meta: { error: errMsg$2(err) } });
|
|
106318
|
+
}
|
|
106319
|
+
}
|
|
104857
106320
|
};
|
|
104858
106321
|
}
|
|
104859
|
-
|
|
104860
|
-
|
|
104861
|
-
* device decides the accessory's category when the device's own type does not.
|
|
104862
|
-
*/
|
|
104863
|
-
var HAP_CAP_SERVICES = [
|
|
104864
|
-
{
|
|
104865
|
-
caps: ["switch", "brightness"],
|
|
104866
|
-
label: "Power",
|
|
104867
|
-
build: ({ bctx, name, deviceType }) => buildChildSwitch({
|
|
104868
|
-
...bctx,
|
|
104869
|
-
displayName: name
|
|
104870
|
-
}, "switch", deviceType)
|
|
104871
|
-
},
|
|
104872
|
-
{
|
|
104873
|
-
caps: ["lock-control"],
|
|
104874
|
-
label: "Lock",
|
|
104875
|
-
build: ({ bctx, name }) => buildLockMechanism({
|
|
104876
|
-
...bctx,
|
|
104877
|
-
displayName: name
|
|
104878
|
-
})
|
|
104879
|
-
},
|
|
104880
|
-
{
|
|
104881
|
-
caps: ["alarm-panel"],
|
|
104882
|
-
label: "Alarm",
|
|
104883
|
-
build: ({ bctx, name }) => buildSecuritySystem({
|
|
104884
|
-
...bctx,
|
|
104885
|
-
displayName: name
|
|
104886
|
-
})
|
|
104887
|
-
},
|
|
104888
|
-
{
|
|
104889
|
-
caps: ["battery"],
|
|
104890
|
-
label: BATTERY_SPEC.label,
|
|
104891
|
-
build: ({ bctx, name }) => buildSensorService({
|
|
104892
|
-
bctx,
|
|
104893
|
-
spec: BATTERY_SPEC,
|
|
104894
|
-
name,
|
|
104895
|
-
subtype: "battery"
|
|
104896
|
-
})
|
|
104897
|
-
},
|
|
104898
|
-
...Object.entries(SENSOR_SPECS).map(([capName, spec]) => sensorRow(capName, spec))
|
|
104899
|
-
];
|
|
104900
|
-
/**
|
|
104901
|
-
* The rows a device's bound capabilities select, in table order.
|
|
104902
|
-
*
|
|
104903
|
-
* A row matches when ANY of its caps is bound: a lamp with `switch` but no
|
|
104904
|
-
* `brightness` is still a Lightbulb-shaped row, and the builder degrades on its
|
|
104905
|
-
* own.
|
|
104906
|
-
*/
|
|
104907
|
-
function rowsForCaps(capNames) {
|
|
104908
|
-
return HAP_CAP_SERVICES.filter((row) => row.caps.some((cap) => capNames.has(cap)));
|
|
104909
|
-
}
|
|
104910
|
-
/**
|
|
104911
|
-
* The HomeKit accessory category per camstack device type — the icon iOS Home
|
|
104912
|
-
* draws and nothing else. Absent = `OTHER`, which is a valid accessory that
|
|
104913
|
-
* simply gets the generic tile.
|
|
104914
|
-
*
|
|
104915
|
-
* This is NOT a coverage decision. `HAP_EXPORTABLE_DEVICE_TYPES` (in
|
|
104916
|
-
* `mappers/index.ts`) filters the picker; whether a device exports anything is
|
|
104917
|
-
* decided by `rowsForCaps`.
|
|
104918
|
-
*/
|
|
104919
|
-
var ACCESSORY_CATEGORY_BY_TYPE = {
|
|
104920
|
-
[DeviceType.Switch]: import_dist.Categories.SWITCH,
|
|
104921
|
-
[DeviceType.Light]: import_dist.Categories.LIGHTBULB,
|
|
104922
|
-
[DeviceType.Siren]: import_dist.Categories.SWITCH,
|
|
104923
|
-
[DeviceType.Lock]: import_dist.Categories.DOOR_LOCK,
|
|
104924
|
-
[DeviceType.Sensor]: import_dist.Categories.SENSOR,
|
|
104925
|
-
[DeviceType.Presence]: import_dist.Categories.SENSOR,
|
|
104926
|
-
[DeviceType.AlarmPanel]: import_dist.Categories.SECURITY_SYSTEM
|
|
104927
|
-
};
|
|
104928
|
-
function accessoryCategoryFor(deviceType) {
|
|
104929
|
-
return ACCESSORY_CATEGORY_BY_TYPE[deviceType] ?? import_dist.Categories.OTHER;
|
|
106322
|
+
function errMsg$2(err) {
|
|
106323
|
+
return err instanceof Error ? err.message : String(err);
|
|
104930
106324
|
}
|
|
104931
106325
|
//#endregion
|
|
104932
106326
|
//#region src/mappers/generic-accessory.ts
|
|
@@ -105030,118 +106424,6 @@ function getMapperFactory(kind) {
|
|
|
105030
106424
|
return factory;
|
|
105031
106425
|
}
|
|
105032
106426
|
//#endregion
|
|
105033
|
-
//#region src/mappers/builders/stream-hwaccel-memo.ts
|
|
105034
|
-
/**
|
|
105035
|
-
* The two bounded memos HomeKit's decode path owns.
|
|
105036
|
-
*
|
|
105037
|
-
* ## Why they exist
|
|
105038
|
-
*
|
|
105039
|
-
* Everything D67 was actually about — one argv builder, one set of constants,
|
|
105040
|
-
* one hwaccel authority — HomeKit already had. What it did NOT have were the
|
|
105041
|
-
* two things the broker gained alongside them:
|
|
105042
|
-
*
|
|
105043
|
-
* 1. **A memo.** `probeDecoderHwaccel` issued a cross-process
|
|
105044
|
-
* `decoder.getInfo` per SESSION. iOS starts sessions in bursts — one on
|
|
105045
|
-
* record was started three times in 16 s — and every one of those paid a
|
|
105046
|
-
* cap call on a hub whose main thread is the scarce resource.
|
|
105047
|
-
* 2. **Failure feedback.** When HomeKit's hardware child died at init and
|
|
105048
|
-
* `shouldRetryInSoftware` saved the session, HomeKit told nobody. The next
|
|
105049
|
-
* session re-picked the same corpse and paid the same two-second death.
|
|
105050
|
-
* `EgressTranscodeManager` fixed exactly this for its own children
|
|
105051
|
-
* (26a522cd5) by reporting the dead backend into the broker's 60 s memo.
|
|
105052
|
-
*
|
|
105053
|
-
* ## Both ride `HwAccelCache`, deliberately
|
|
105054
|
-
*
|
|
105055
|
-
* `createHwAccelCache` from `@camstack/types` is the primitive the broker's own
|
|
105056
|
-
* `egressHwAccelCache` is built from, and the discipline it encodes is the
|
|
105057
|
-
* point: **caller-owned, never a module global** — a module global would
|
|
105058
|
-
* outlive an addon respawn and survive an operator changing the decoder
|
|
105059
|
-
* backend. Same TTL as the broker's, so "has hardware come back yet" cannot
|
|
105060
|
-
* answer differently depending on which consumer asked.
|
|
105061
|
-
*
|
|
105062
|
-
* ## The cross-process gap, stated honestly
|
|
105063
|
-
*
|
|
105064
|
-
* These memos are scoped to the `export-hap` PROCESS. When HomeKit's vaapi
|
|
105065
|
-
* child dies, the broker's next child still pays its own two-second death, and
|
|
105066
|
-
* vice versa — because addons may never import each other and there is no
|
|
105067
|
-
* capability for "this backend is dead on this node right now". Closing that
|
|
105068
|
-
* would need a new cap surface, which Phase 0 explicitly does not take. What is
|
|
105069
|
-
* closed here is HomeKit's own repetition of the cost, across cameras and
|
|
105070
|
-
* across sessions.
|
|
105071
|
-
*/
|
|
105072
|
-
/**
|
|
105073
|
-
* The window both memos answer for.
|
|
105074
|
-
*
|
|
105075
|
-
* 60 s, the same as `stream-broker-manager`'s `egressHwAccelCache`. Long enough
|
|
105076
|
-
* that a burst of session restarts pays one read; short enough that an operator
|
|
105077
|
-
* who changes the decoder backend, or a host whose accelerator recovers, is
|
|
105078
|
-
* obeyed on the next session rather than after an addon respawn.
|
|
105079
|
-
*/
|
|
105080
|
-
var HAP_DECODE_MEMO_TTL_MS = 6e4;
|
|
105081
|
-
/**
|
|
105082
|
-
* Separator inside the encoded reading. A control character, because a backend
|
|
105083
|
-
* name is `[a-z0-9]+` and the decoder's non-backend choices are `auto` /
|
|
105084
|
-
* `none` / `''`, none of which can contain one — so the split is total.
|
|
105085
|
-
*/
|
|
105086
|
-
var READING_SEPARATOR = "";
|
|
105087
|
-
/**
|
|
105088
|
-
* Marks a `null` FIELD, distinct from an EMPTY one.
|
|
105089
|
-
*
|
|
105090
|
-
* `probedBestHwaccel: ''` means the decoder answered and has never probed
|
|
105091
|
-
* (=> `not-probed`); `null` means the field was absent altogether. Encoding
|
|
105092
|
-
* both as `''` would lose a distinction `selectHwDecode` acts on.
|
|
105093
|
-
*/
|
|
105094
|
-
var NULL_FIELD = "\0";
|
|
105095
|
-
function encodeField(value) {
|
|
105096
|
-
return value === null ? NULL_FIELD : value;
|
|
105097
|
-
}
|
|
105098
|
-
function decodeField(value) {
|
|
105099
|
-
return value === NULL_FIELD ? null : value;
|
|
105100
|
-
}
|
|
105101
|
-
/**
|
|
105102
|
-
* A reading as ONE `string | null`, which is what {@link HwAccelCache} stores.
|
|
105103
|
-
*
|
|
105104
|
-
* The cache's three states are exactly the three a memoised reading needs:
|
|
105105
|
-
* `undefined` (never asked, or expired), `null` (asked, and the decoder could
|
|
105106
|
-
* not be reached), and a value. Encoding into the one cache rather than
|
|
105107
|
-
* splitting across two is what keeps those three from skewing — two caches
|
|
105108
|
-
* written together can still be READ across an expiry boundary.
|
|
105109
|
-
*/
|
|
105110
|
-
function encodeDecoderReading(reading) {
|
|
105111
|
-
if (reading === null) return null;
|
|
105112
|
-
return `${encodeField(reading.hwaccel)}${READING_SEPARATOR}${encodeField(reading.probedBestHwaccel)}`;
|
|
105113
|
-
}
|
|
105114
|
-
function decodeDecoderReading(value) {
|
|
105115
|
-
if (value === null) return null;
|
|
105116
|
-
const [hwaccel = NULL_FIELD, probed = NULL_FIELD] = value.split(READING_SEPARATOR);
|
|
105117
|
-
return {
|
|
105118
|
-
hwaccel: decodeField(hwaccel),
|
|
105119
|
-
probedBestHwaccel: decodeField(probed)
|
|
105120
|
-
};
|
|
105121
|
-
}
|
|
105122
|
-
function createDecoderReadingMemo(options) {
|
|
105123
|
-
const cache = createHwAccelCache(options);
|
|
105124
|
-
return {
|
|
105125
|
-
read() {
|
|
105126
|
-
const cached = cache.read();
|
|
105127
|
-
return cached === void 0 ? void 0 : decodeDecoderReading(cached);
|
|
105128
|
-
},
|
|
105129
|
-
write(reading) {
|
|
105130
|
-
cache.write(encodeDecoderReading(reading));
|
|
105131
|
-
}
|
|
105132
|
-
};
|
|
105133
|
-
}
|
|
105134
|
-
function createHapDecodeMemos(now) {
|
|
105135
|
-
const options = {
|
|
105136
|
-
ttlMs: HAP_DECODE_MEMO_TTL_MS,
|
|
105137
|
-
...now ? { now } : {}
|
|
105138
|
-
};
|
|
105139
|
-
return {
|
|
105140
|
-
reading: createDecoderReadingMemo(options),
|
|
105141
|
-
failedBackend: createHwAccelCache(options)
|
|
105142
|
-
};
|
|
105143
|
-
}
|
|
105144
|
-
//#endregion
|
|
105145
106427
|
//#region src/reconcile/sync-state.ts
|
|
105146
106428
|
function syncStateFromJson(json) {
|
|
105147
106429
|
const map = /* @__PURE__ */ new Map();
|
|
@@ -105327,6 +106609,16 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
105327
106609
|
*/
|
|
105328
106610
|
decodeMemos = createHapDecodeMemos();
|
|
105329
106611
|
/**
|
|
106612
|
+
* Where HomeKit's own clips are kept.
|
|
106613
|
+
*
|
|
106614
|
+
* ONE per addon: HomeKit Secure Video is a SINK — HAP has no read-back, so a
|
|
106615
|
+
* clip is ours only if we kept a copy of what we SENT — and the copy has to
|
|
106616
|
+
* be pruned against bounds that span every camera, which a per-camera store
|
|
106617
|
+
* could not see. Created at boot rather than lazily so the directory's
|
|
106618
|
+
* failure to exist is one line at boot and not a surprise mid-recording.
|
|
106619
|
+
*/
|
|
106620
|
+
hksvClipStore = null;
|
|
106621
|
+
/**
|
|
105330
106622
|
* THE bridge. Lazily created and published the first time a non-camera
|
|
105331
106623
|
* accessory needs it; never torn down while the addon runs, because an
|
|
105332
106624
|
* unpublish would drop a pairing the operator already entered a code for.
|
|
@@ -105352,6 +106644,14 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
105352
106644
|
this.lastError = errMsg(err);
|
|
105353
106645
|
this.ctx.logger.error("export-hap: HAP storage init failed", { meta: { error: this.lastError } });
|
|
105354
106646
|
}
|
|
106647
|
+
this.hksvClipStore = new HksvClipStore({
|
|
106648
|
+
logger: this.ctx.logger.child("hksv-clips"),
|
|
106649
|
+
rootDir: path.join(this.ctx.dataDir, CLIP_DIR_NAME),
|
|
106650
|
+
mintThumbnail: createClipThumbnailMinter({ runner: createFfmpegClipThumbnailRunner({
|
|
106651
|
+
ffmpegBinaryPath: "ffmpeg",
|
|
106652
|
+
spawnFn: spawn
|
|
106653
|
+
}) })
|
|
106654
|
+
});
|
|
105355
106655
|
const validKinds = new Set(SUPPORTED_MAPPER_KINDS);
|
|
105356
106656
|
const cleaned = this.config.exposed.filter((entry) => {
|
|
105357
106657
|
if (validKinds.has(entry.mapperKind)) return true;
|
|
@@ -105544,6 +106844,7 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
105544
106844
|
options: {
|
|
105545
106845
|
ptzPulseMs: this.config.ptzPulseMs,
|
|
105546
106846
|
decodeMemos: this.decodeMemos,
|
|
106847
|
+
hksvClipStore: this.hksvClipStore,
|
|
105547
106848
|
hapDeviceSettings: {
|
|
105548
106849
|
streamPreference: entrySettings.streamPreference ?? "auto",
|
|
105549
106850
|
hksvRecording: resolveHksvRecording(entrySettings),
|