@camstack/addon-export-hap 1.2.12 → 1.2.14
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/export-hap.addon.js +3330 -245
- package/dist/export-hap.addon.mjs +3329 -245
- package/package.json +1 -1
package/dist/export-hap.addon.js
CHANGED
|
@@ -29,12 +29,51 @@ let node_crypto = require("node:crypto");
|
|
|
29
29
|
let node_path = require("node:path");
|
|
30
30
|
__toESM(node_path, 1);
|
|
31
31
|
node_path = __toESM(node_path);
|
|
32
|
+
let node_fs = require("node:fs");
|
|
33
|
+
node_fs = __toESM(node_fs, 1);
|
|
32
34
|
let node_child_process = require("node:child_process");
|
|
33
35
|
let _homebridge_hap_nodejs = require("@homebridge/hap-nodejs");
|
|
34
36
|
let node_fs_promises = require("node:fs/promises");
|
|
35
37
|
node_fs_promises = __toESM(node_fs_promises);
|
|
36
38
|
let node_dgram = require("node:dgram");
|
|
37
39
|
let node_os = require("node:os");
|
|
40
|
+
//#region src/exposed-entry.ts
|
|
41
|
+
/**
|
|
42
|
+
* Carrying an exposed-device entry across a re-expose.
|
|
43
|
+
*
|
|
44
|
+
* `exposeDevice` rebuilds its entry from scratch — display name, mapper kind,
|
|
45
|
+
* timestamp — and then REPLACES the stored one. Anything the rebuilt object
|
|
46
|
+
* does not mention is therefore destroyed, and two things it never mentioned
|
|
47
|
+
* were the per-camera settings and the capability list.
|
|
48
|
+
*
|
|
49
|
+
* The visible cost: the operator's "Source stream (HomeKit)" selector writes
|
|
50
|
+
* `low`, the addon logs `streamPreference changed — refreshing accessory
|
|
51
|
+
* {from=auto to=low}`, and the accessory that comes back derives its
|
|
52
|
+
* advertisement from DEFAULTS — `streamPreference=auto` — because the settings
|
|
53
|
+
* were dropped between the write and the rebuild. The selector only ever took
|
|
54
|
+
* effect after a full addon restart, when the settings were loaded first. A
|
|
55
|
+
* second write after the re-expose hid this: the store ended up correct, so
|
|
56
|
+
* nothing looked wrong except the stream nobody could explain.
|
|
57
|
+
*/
|
|
58
|
+
/**
|
|
59
|
+
* Fill `base` from `existing` for the given keys, letting `base` win wherever
|
|
60
|
+
* it actually says something.
|
|
61
|
+
*
|
|
62
|
+
* That asymmetry is the point: a caller who passes `capabilities` is stating a
|
|
63
|
+
* new truth and must not be overruled by the stored copy, while a caller who
|
|
64
|
+
* says nothing about `settings` is not asking for them to be erased.
|
|
65
|
+
*/
|
|
66
|
+
function carryForward(base, existing, keys) {
|
|
67
|
+
if (existing === void 0) return base;
|
|
68
|
+
const out = { ...base };
|
|
69
|
+
for (const key of keys) {
|
|
70
|
+
if (out[key] !== void 0) continue;
|
|
71
|
+
const carried = existing[key];
|
|
72
|
+
if (carried !== void 0) out[key] = carried;
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
//#endregion
|
|
38
77
|
//#region ../types/dist/event-category-41fKf-q9.mjs
|
|
39
78
|
var EventCategory = /* @__PURE__ */ function(EventCategory) {
|
|
40
79
|
EventCategory["SystemBoot"] = "system.boot";
|
|
@@ -799,7 +838,7 @@ var propertyKeyTypes = /* @__PURE__*/ new Set([
|
|
|
799
838
|
"number",
|
|
800
839
|
"symbol"
|
|
801
840
|
]);
|
|
802
|
-
function escapeRegex
|
|
841
|
+
function escapeRegex(str) {
|
|
803
842
|
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
804
843
|
}
|
|
805
844
|
function clone(inst, def, params) {
|
|
@@ -1529,7 +1568,7 @@ var $ZodCheckUpperCase = /*@__PURE__*/ $constructor("$ZodCheckUpperCase", (inst,
|
|
|
1529
1568
|
});
|
|
1530
1569
|
var $ZodCheckIncludes = /*@__PURE__*/ $constructor("$ZodCheckIncludes", (inst, def) => {
|
|
1531
1570
|
$ZodCheck.init(inst, def);
|
|
1532
|
-
const escapedRegex = escapeRegex
|
|
1571
|
+
const escapedRegex = escapeRegex(def.includes);
|
|
1533
1572
|
const pattern = new RegExp(typeof def.position === "number" ? `^.{${def.position}}${escapedRegex}` : escapedRegex);
|
|
1534
1573
|
def.pattern = pattern;
|
|
1535
1574
|
inst._zod.onattach.push((inst) => {
|
|
@@ -1552,7 +1591,7 @@ var $ZodCheckIncludes = /*@__PURE__*/ $constructor("$ZodCheckIncludes", (inst, d
|
|
|
1552
1591
|
});
|
|
1553
1592
|
var $ZodCheckStartsWith = /*@__PURE__*/ $constructor("$ZodCheckStartsWith", (inst, def) => {
|
|
1554
1593
|
$ZodCheck.init(inst, def);
|
|
1555
|
-
const pattern = new RegExp(`^${escapeRegex
|
|
1594
|
+
const pattern = new RegExp(`^${escapeRegex(def.prefix)}.*`);
|
|
1556
1595
|
def.pattern ?? (def.pattern = pattern);
|
|
1557
1596
|
inst._zod.onattach.push((inst) => {
|
|
1558
1597
|
const bag = inst._zod.bag;
|
|
@@ -1574,7 +1613,7 @@ var $ZodCheckStartsWith = /*@__PURE__*/ $constructor("$ZodCheckStartsWith", (ins
|
|
|
1574
1613
|
});
|
|
1575
1614
|
var $ZodCheckEndsWith = /*@__PURE__*/ $constructor("$ZodCheckEndsWith", (inst, def) => {
|
|
1576
1615
|
$ZodCheck.init(inst, def);
|
|
1577
|
-
const pattern = new RegExp(`.*${escapeRegex
|
|
1616
|
+
const pattern = new RegExp(`.*${escapeRegex(def.suffix)}$`);
|
|
1578
1617
|
def.pattern ?? (def.pattern = pattern);
|
|
1579
1618
|
inst._zod.onattach.push((inst) => {
|
|
1580
1619
|
const bag = inst._zod.bag;
|
|
@@ -2810,7 +2849,7 @@ var $ZodEnum = /*@__PURE__*/ $constructor("$ZodEnum", (inst, def) => {
|
|
|
2810
2849
|
const values = getEnumValues(def.entries);
|
|
2811
2850
|
const valuesSet = new Set(values);
|
|
2812
2851
|
inst._zod.values = valuesSet;
|
|
2813
|
-
inst._zod.pattern = new RegExp(`^(${values.filter((k) => propertyKeyTypes.has(typeof k)).map((o) => typeof o === "string" ? escapeRegex
|
|
2852
|
+
inst._zod.pattern = new RegExp(`^(${values.filter((k) => propertyKeyTypes.has(typeof k)).map((o) => typeof o === "string" ? escapeRegex(o) : o.toString()).join("|")})$`);
|
|
2814
2853
|
inst._zod.parse = (payload, _ctx) => {
|
|
2815
2854
|
const input = payload.value;
|
|
2816
2855
|
if (valuesSet.has(input)) return payload;
|
|
@@ -2828,7 +2867,7 @@ var $ZodLiteral = /*@__PURE__*/ $constructor("$ZodLiteral", (inst, def) => {
|
|
|
2828
2867
|
if (def.values.length === 0) throw new Error("Cannot create literal schema with no valid values");
|
|
2829
2868
|
const values = new Set(def.values);
|
|
2830
2869
|
inst._zod.values = values;
|
|
2831
|
-
inst._zod.pattern = new RegExp(`^(${def.values.map((o) => typeof o === "string" ? escapeRegex
|
|
2870
|
+
inst._zod.pattern = new RegExp(`^(${def.values.map((o) => typeof o === "string" ? escapeRegex(o) : o ? escapeRegex(o.toString()) : String(o)).join("|")})$`);
|
|
2832
2871
|
inst._zod.parse = (payload, _ctx) => {
|
|
2833
2872
|
const input = payload.value;
|
|
2834
2873
|
if (values.has(input)) return payload;
|
|
@@ -6521,6 +6560,65 @@ var ProfileRtspEntrySchema = object({
|
|
|
6521
6560
|
resolution: CamStreamResolutionSchema.optional()
|
|
6522
6561
|
});
|
|
6523
6562
|
/**
|
|
6563
|
+
* Per-call node pinning for `ctx.api` capability calls.
|
|
6564
|
+
*
|
|
6565
|
+
* A capability call normally resolves to its DEFAULT provider — a `singleton`
|
|
6566
|
+
* cap resolves to the hub, a device-scoped cap to the device's owning node. To
|
|
6567
|
+
* query a SPECIFIC node's provider instead (e.g. a remote agent's own
|
|
6568
|
+
* in-process `platform-probe` hardware, which the hub cannot probe), pin the
|
|
6569
|
+
* call to that node.
|
|
6570
|
+
*
|
|
6571
|
+
* The nodeId rides OUT-OF-BAND in the tRPC call context (NOT in the validated
|
|
6572
|
+
* method args), so capability method signatures stay `nodeId`-free — node
|
|
6573
|
+
* targeting is a property of the CALL, not of the method. The transport lifts
|
|
6574
|
+
* it from `op.context` onto the `CapCallInput.nodeId` field (`ipcParentLink`),
|
|
6575
|
+
* and the hub parent's `onUnownedCall` passes it to the `CapRouteResolver`,
|
|
6576
|
+
* which classifies a pinned agent node as `agent-child-forward`
|
|
6577
|
+
* (`$agent-cap-fwd.forward` → the agent's in-process provider).
|
|
6578
|
+
*
|
|
6579
|
+
* Usage at a call site:
|
|
6580
|
+
*
|
|
6581
|
+
* await api.platformProbe.getCapabilities.query(undefined, nodePin(nodeId))
|
|
6582
|
+
*/
|
|
6583
|
+
/** tRPC `op.context` key carrying a per-call node pin. */
|
|
6584
|
+
var CAP_NODE_PIN_CONTEXT_KEY = "__camstackNodePin";
|
|
6585
|
+
/**
|
|
6586
|
+
* Build the tRPC request options that pin a single capability call to `nodeId`.
|
|
6587
|
+
* Pass as the second argument to `.query(input, …)` / `.mutate(input, …)`.
|
|
6588
|
+
*
|
|
6589
|
+
* ## The id is normalised here, and it has to be
|
|
6590
|
+
*
|
|
6591
|
+
* A forked addon reads its own node from `ctx.kernel.localNodeId`, and inside a
|
|
6592
|
+
* worker that value is a RUNNER id — `hub/export-hap`, not `hub`. Routing
|
|
6593
|
+
* compares a pin against real node ids, so such a pin matches nothing and the
|
|
6594
|
+
* call fails with `no provider registered for cap "…"`. The local-first
|
|
6595
|
+
* resolver already guarded against this (`localNodeId.split('/')[0]`), which
|
|
6596
|
+
* made the hazard invisible: unpinned calls worked, and only an explicit pin —
|
|
6597
|
+
* the thing you reach for when you specifically need THIS node — silently
|
|
6598
|
+
* addressed a node that does not exist.
|
|
6599
|
+
*
|
|
6600
|
+
* Cost of it being missing: `addon-export-hap` pinned `decoder.getInfo` to its
|
|
6601
|
+
* own node to read the host's hardware-decode backend. It never once answered,
|
|
6602
|
+
* so every HomeKit egress transcode decoded in SOFTWARE — including 4K H.265 —
|
|
6603
|
+
* while D67's whole premise was that the decoder addon is the authority on
|
|
6604
|
+
* hardware. The warn said `decoding in SOFTWARE` and read as "this node has no
|
|
6605
|
+
* hardware", which was false.
|
|
6606
|
+
*
|
|
6607
|
+
* Normalising in the ONE constructor fixes every caller at once, which is why
|
|
6608
|
+
* it is here and not at the call sites.
|
|
6609
|
+
*/
|
|
6610
|
+
function nodePin(nodeId) {
|
|
6611
|
+
return { context: { [CAP_NODE_PIN_CONTEXT_KEY]: toNodeId(nodeId) } };
|
|
6612
|
+
}
|
|
6613
|
+
/**
|
|
6614
|
+
* A runner id is `<nodeId>/<addonId>`; a node id has no slash. Taking the head
|
|
6615
|
+
* is idempotent, so passing an already-clean id costs nothing.
|
|
6616
|
+
*/
|
|
6617
|
+
function toNodeId(idOrRunnerId) {
|
|
6618
|
+
const head = idOrRunnerId.split("/")[0];
|
|
6619
|
+
return head === void 0 || head.length === 0 ? idOrRunnerId : head;
|
|
6620
|
+
}
|
|
6621
|
+
/**
|
|
6524
6622
|
* Output schema shared by the contribution + live methods.
|
|
6525
6623
|
*
|
|
6526
6624
|
* Mirrors the `ConfigUISchemaWithValues` shape (sections[] + optional
|
|
@@ -6968,6 +7066,39 @@ method(object({ deviceId: number() }), array(StreamSourceEntrySchema)), method(o
|
|
|
6968
7066
|
action: string().min(1),
|
|
6969
7067
|
input: unknown()
|
|
6970
7068
|
}), unknown(), { kind: "mutation" }), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), unknown().nullable()), method(object({ deviceId: number() }), RawStateResultSchema.nullable(), { auth: "protected" });
|
|
7069
|
+
//#endregion
|
|
7070
|
+
//#region ../types/dist/canonical-hash-7nfBbEqR.mjs
|
|
7071
|
+
/**
|
|
7072
|
+
* Deterministic SHA-256 hash of an arbitrary serialisable value. The
|
|
7073
|
+
* canonical form sorts object keys alphabetically at every depth so two
|
|
7074
|
+
* structurally-equal inputs with different key insertion orders produce
|
|
7075
|
+
* the same hash. Returns a 64-char lowercase hex digest.
|
|
7076
|
+
*
|
|
7077
|
+
* Used by export adapters (Alexa, HAP) to short-circuit re-discovery /
|
|
7078
|
+
* accessory-rebuild work when the upstream shape is byte-identical to
|
|
7079
|
+
* the last applied state — preventing user-visible "re-discovery"
|
|
7080
|
+
* notifications on every addon-runner respawn. Each respawn re-fires
|
|
7081
|
+
* `DeviceBindingsChanged` for every cap registration, which without
|
|
7082
|
+
* this guard would propagate redundant pushes.
|
|
7083
|
+
*
|
|
7084
|
+
* Note: this is a SYMPTOMATIC fix layered on top of the binding-change
|
|
7085
|
+
* subscription. The proper fix is a single "device ready" lifecycle
|
|
7086
|
+
* barrier so exports react only when the full cap set has landed —
|
|
7087
|
+
* tracked separately for post-HA-integration work.
|
|
7088
|
+
*/
|
|
7089
|
+
function canonicalHash(value) {
|
|
7090
|
+
const canonical = JSON.stringify(value, replaceWithSortedKeys);
|
|
7091
|
+
return (0, node_crypto.createHash)("sha256").update(canonical ?? "").digest("hex");
|
|
7092
|
+
}
|
|
7093
|
+
function replaceWithSortedKeys(_key, value) {
|
|
7094
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
7095
|
+
const obj = value;
|
|
7096
|
+
const out = {};
|
|
7097
|
+
for (const k of Object.keys(obj).toSorted()) out[k] = obj[k];
|
|
7098
|
+
return out;
|
|
7099
|
+
}
|
|
7100
|
+
return value;
|
|
7101
|
+
}
|
|
6971
7102
|
var EncodeProfileSchema = object({
|
|
6972
7103
|
video: object({
|
|
6973
7104
|
codec: _enum([
|
|
@@ -6980,6 +7111,14 @@ var EncodeProfileSchema = object({
|
|
|
6980
7111
|
"main",
|
|
6981
7112
|
"high"
|
|
6982
7113
|
]).optional(),
|
|
7114
|
+
/**
|
|
7115
|
+
* `-level`, e.g. `'3.1'`. A consumer that ADVERTISES a level in its SDP
|
|
7116
|
+
* (`profile-level-id=42e01f` is Baseline 3.1) must constrain the encoder to
|
|
7117
|
+
* it, or it ships a stream that does not match its own advertisement — the
|
|
7118
|
+
* defect class that kept HomeKit black for a year and that Alexa carried
|
|
7119
|
+
* silently. Optional because a browser negotiates the level itself.
|
|
7120
|
+
*/
|
|
7121
|
+
level: string().optional(),
|
|
6983
7122
|
width: number().int().positive().optional(),
|
|
6984
7123
|
height: number().int().positive().optional(),
|
|
6985
7124
|
fps: number().positive().optional(),
|
|
@@ -7026,6 +7165,321 @@ var EncodeProfileSchema = object({
|
|
|
7026
7165
|
*/
|
|
7027
7166
|
outputArgs: array(string()).optional()
|
|
7028
7167
|
});
|
|
7168
|
+
var AUDIO_ENCODER_BY_CODEC = {
|
|
7169
|
+
opus: "libopus",
|
|
7170
|
+
aac: "aac",
|
|
7171
|
+
pcmu: "pcm_mulaw",
|
|
7172
|
+
pcma: "pcm_alaw"
|
|
7173
|
+
};
|
|
7174
|
+
/**
|
|
7175
|
+
* Camera-microphone audio, per codec. Lives HERE rather than in
|
|
7176
|
+
* `encode-defaults.ts` only to avoid an import cycle (`encode-defaults` depends
|
|
7177
|
+
* on these types); it is re-exported from there, which is where to read it.
|
|
7178
|
+
*
|
|
7179
|
+
* Every source in this repo is a mono camera mic. The former broker preset
|
|
7180
|
+
* encoded Opus at `channels: 2`, spending bitrate duplicating one channel —
|
|
7181
|
+
* that is the value this consolidation changed.
|
|
7182
|
+
*/
|
|
7183
|
+
var AUDIO_PRESETS = {
|
|
7184
|
+
aac: {
|
|
7185
|
+
kind: "encode",
|
|
7186
|
+
codec: "aac",
|
|
7187
|
+
bitrateKbps: 128,
|
|
7188
|
+
sampleRateHz: 48e3,
|
|
7189
|
+
channels: 1
|
|
7190
|
+
},
|
|
7191
|
+
opus: {
|
|
7192
|
+
kind: "encode",
|
|
7193
|
+
codec: "opus",
|
|
7194
|
+
bitrateKbps: 64,
|
|
7195
|
+
sampleRateHz: 48e3,
|
|
7196
|
+
channels: 1
|
|
7197
|
+
},
|
|
7198
|
+
pcmu: {
|
|
7199
|
+
kind: "encode",
|
|
7200
|
+
codec: "pcmu",
|
|
7201
|
+
sampleRateHz: 8e3,
|
|
7202
|
+
channels: 1
|
|
7203
|
+
}
|
|
7204
|
+
};
|
|
7205
|
+
/** `-hide_banner -loglevel <level>` — every ffmpeg site opens with this. */
|
|
7206
|
+
function logBannerArgs(level) {
|
|
7207
|
+
return [
|
|
7208
|
+
"-hide_banner",
|
|
7209
|
+
"-loglevel",
|
|
7210
|
+
level
|
|
7211
|
+
];
|
|
7212
|
+
}
|
|
7213
|
+
/** `true` when the resolved value means "decode in software" (⇒ no `-hwaccel`). */
|
|
7214
|
+
function isSoftwareDecode(decodeHwAccel) {
|
|
7215
|
+
return !decodeHwAccel || decodeHwAccel === "none" || decodeHwAccel === "copy";
|
|
7216
|
+
}
|
|
7217
|
+
/**
|
|
7218
|
+
* Every INPUT option, in order, terminated by `-i <url>`. Nothing may be
|
|
7219
|
+
* appended to this list by a caller — that is the whole point of the function.
|
|
7220
|
+
*/
|
|
7221
|
+
function buildInputArgs(input, decodeHwAccel) {
|
|
7222
|
+
const args = [];
|
|
7223
|
+
if (!isSoftwareDecode(decodeHwAccel)) args.push("-hwaccel", String(decodeHwAccel));
|
|
7224
|
+
if (input.extraArgs?.length) args.push(...input.extraArgs);
|
|
7225
|
+
if (input.analyzeDurationUs !== void 0) args.push("-analyzeduration", String(input.analyzeDurationUs));
|
|
7226
|
+
if (input.probeSizeBytes !== void 0) args.push("-probesize", String(input.probeSizeBytes));
|
|
7227
|
+
if (input.fflags?.length) for (const flag of input.fflags) args.push("-fflags", flag);
|
|
7228
|
+
if (input.rtspTransport) args.push("-rtsp_transport", input.rtspTransport);
|
|
7229
|
+
args.push("-i", input.url);
|
|
7230
|
+
return args;
|
|
7231
|
+
}
|
|
7232
|
+
/** The `-vf` filter args, or `[]` when a consumer `-vf` already claims the slot. */
|
|
7233
|
+
function buildVideoFilterArgs(scale, outputArgs) {
|
|
7234
|
+
if (!scale) return [];
|
|
7235
|
+
if (outputArgs.some((a) => a === "-vf")) return [];
|
|
7236
|
+
if (scale.mode === "exact") return ["-vf", `scale=${scale.width}:${scale.height}`];
|
|
7237
|
+
return ["-vf", `scale='min(${scale.width},iw)':'min(${scale.height},ih)':force_original_aspect_ratio=decrease:force_divisible_by=2`];
|
|
7238
|
+
}
|
|
7239
|
+
/** Rate-control args for an encode plan. */
|
|
7240
|
+
function buildRateControlArgs(video) {
|
|
7241
|
+
const kbps = video.bitrateKbps;
|
|
7242
|
+
if (kbps === void 0) return [];
|
|
7243
|
+
const rc = video.rateControl ?? {
|
|
7244
|
+
kind: "cap",
|
|
7245
|
+
vbvSeconds: 2
|
|
7246
|
+
};
|
|
7247
|
+
const bufsize = Math.max(1, Math.round(kbps * rc.vbvSeconds));
|
|
7248
|
+
return [
|
|
7249
|
+
...rc.kind === "cbr" ? ["-b:v", `${kbps}k`] : [],
|
|
7250
|
+
"-maxrate",
|
|
7251
|
+
`${kbps}k`,
|
|
7252
|
+
"-bufsize",
|
|
7253
|
+
`${bufsize}k`
|
|
7254
|
+
];
|
|
7255
|
+
}
|
|
7256
|
+
/** The whole video block (`-vf` … `-c:v` … knobs), after `-i`. */
|
|
7257
|
+
function buildVideoArgs(video, outputArgs) {
|
|
7258
|
+
if (video.kind === "copy") return [
|
|
7259
|
+
"-c:v",
|
|
7260
|
+
"copy",
|
|
7261
|
+
...video.bitstreamFilter ? ["-bsf:v", video.bitstreamFilter] : []
|
|
7262
|
+
];
|
|
7263
|
+
const args = [
|
|
7264
|
+
...buildVideoFilterArgs(video.scale, outputArgs),
|
|
7265
|
+
"-c:v",
|
|
7266
|
+
video.encoder
|
|
7267
|
+
];
|
|
7268
|
+
if (video.preset !== void 0) args.push("-preset", video.preset);
|
|
7269
|
+
if (video.tune !== void 0) args.push("-tune", video.tune);
|
|
7270
|
+
if (video.profile !== void 0) args.push("-profile:v", video.profile);
|
|
7271
|
+
if (video.level !== void 0) args.push("-level", video.level);
|
|
7272
|
+
if (video.pixelFormat !== void 0) args.push("-pix_fmt", video.pixelFormat);
|
|
7273
|
+
if (video.fps !== void 0) args.push("-r", String(video.fps));
|
|
7274
|
+
if (video.gopFrames !== void 0) args.push("-g", String(video.gopFrames));
|
|
7275
|
+
if (video.forceKeyFramesSeconds !== void 0) args.push("-force_key_frames", `expr:gte(t,n_forced*${video.forceKeyFramesSeconds})`);
|
|
7276
|
+
if (video.bf !== void 0) args.push("-bf", String(video.bf));
|
|
7277
|
+
args.push(...buildRateControlArgs(video));
|
|
7278
|
+
if (video.bitstreamFilter !== void 0) args.push("-bsf:v", video.bitstreamFilter);
|
|
7279
|
+
return args;
|
|
7280
|
+
}
|
|
7281
|
+
/** The whole audio block, after `-i`. */
|
|
7282
|
+
function buildAudioArgs(audio) {
|
|
7283
|
+
if (audio.kind === "none") return ["-an"];
|
|
7284
|
+
if (audio.kind === "copy") return ["-c:a", "copy"];
|
|
7285
|
+
const args = [];
|
|
7286
|
+
if (audio.filter !== void 0) args.push("-af", audio.filter);
|
|
7287
|
+
args.push("-c:a", AUDIO_ENCODER_BY_CODEC[audio.codec]);
|
|
7288
|
+
if (audio.application !== void 0) args.push("-application", audio.application);
|
|
7289
|
+
if (audio.frameDurationMs !== void 0) args.push("-frame_duration", String(audio.frameDurationMs));
|
|
7290
|
+
if (audio.globalHeader === true) args.push("-flags", "+global_header");
|
|
7291
|
+
if (audio.sampleRateHz !== void 0) args.push("-ar", String(audio.sampleRateHz));
|
|
7292
|
+
if (audio.bitrateKbps !== void 0) args.push("-b:a", `${audio.bitrateKbps}k`);
|
|
7293
|
+
if (audio.vbvBufferKbits !== void 0) args.push("-bufsize", `${audio.vbvBufferKbits}k`);
|
|
7294
|
+
if (audio.channels !== void 0) args.push("-ac", String(audio.channels));
|
|
7295
|
+
return args;
|
|
7296
|
+
}
|
|
7297
|
+
/** RTP output-leg args (`-payload_type`, `-ssrc`, `-sdp_file`, `-f rtp <url>`). */
|
|
7298
|
+
function buildRtpOutputArgs(out) {
|
|
7299
|
+
const args = [];
|
|
7300
|
+
if (out.payloadType !== void 0) args.push("-payload_type", String(out.payloadType));
|
|
7301
|
+
if (out.ssrc !== void 0) args.push("-ssrc", String(out.ssrc));
|
|
7302
|
+
if (out.sdpFile !== void 0) args.push("-sdp_file", out.sdpFile);
|
|
7303
|
+
args.push("-f", "rtp", out.url);
|
|
7304
|
+
return args;
|
|
7305
|
+
}
|
|
7306
|
+
/** `true` when the sink is a raw elementary bytestream that cannot mux audio. */
|
|
7307
|
+
function isElementaryVideoSink(sink) {
|
|
7308
|
+
return sink.kind === "stdout" && (sink.container === "h264" || sink.container === "hevc");
|
|
7309
|
+
}
|
|
7310
|
+
/**
|
|
7311
|
+
* The fragmented-MP4 muxer flags, in the order the recorder has proven them
|
|
7312
|
+
* (`recorder/addon/ffmpeg-args.ts` passes the same `movflags` string through
|
|
7313
|
+
* `-segment_format_options`, across every vendor in the fleet):
|
|
7314
|
+
*
|
|
7315
|
+
* - `frag_keyframe` — cut a fragment at each key frame, so every fragment
|
|
7316
|
+
* opens on a sync sample. HKSV's whole requirement.
|
|
7317
|
+
* - `empty_moov` — write `ftyp`+`moov` up front with no samples in it, which
|
|
7318
|
+
* is what makes the head a standalone INITIALISATION segment.
|
|
7319
|
+
* - `default_base_moof` — fragment offsets are self-relative, so a fragment is
|
|
7320
|
+
* demuxable without the bytes that preceded it. D31's byte-range read path
|
|
7321
|
+
* depends on exactly this property of the recorder's segments.
|
|
7322
|
+
*/
|
|
7323
|
+
var FMP4_MOVFLAGS = "+frag_keyframe+empty_moov+default_base_moof";
|
|
7324
|
+
/**
|
|
7325
|
+
* The terminal sink args for every non-`rtp-outputs` sink. Exhaustive over the
|
|
7326
|
+
* union so a new member cannot fall through to `['-f', container, 'pipe:1']`,
|
|
7327
|
+
* which is what a plain `container` read would have done for `mp4` — a valid
|
|
7328
|
+
* argv that writes a NON-fragmented, unseekable-to-a-pipe MP4 and produces one
|
|
7329
|
+
* unusable byte stream.
|
|
7330
|
+
*/
|
|
7331
|
+
function buildStdoutOrRtspSinkArgs(sink) {
|
|
7332
|
+
if (sink.kind === "rtsp-listen") return [
|
|
7333
|
+
"-f",
|
|
7334
|
+
"rtsp",
|
|
7335
|
+
"-rtsp_transport",
|
|
7336
|
+
"tcp",
|
|
7337
|
+
"-rtsp_flags",
|
|
7338
|
+
"listen",
|
|
7339
|
+
sink.url
|
|
7340
|
+
];
|
|
7341
|
+
if (sink.kind === "rtp-outputs") return [];
|
|
7342
|
+
return sink.container === "mp4" ? buildFmp4SinkArgs(sink) : [
|
|
7343
|
+
"-f",
|
|
7344
|
+
sink.container,
|
|
7345
|
+
"pipe:1"
|
|
7346
|
+
];
|
|
7347
|
+
}
|
|
7348
|
+
/** `-movflags … -min_frag_duration <us> -f mp4 pipe:1`. */
|
|
7349
|
+
function buildFmp4SinkArgs(sink) {
|
|
7350
|
+
return [
|
|
7351
|
+
"-movflags",
|
|
7352
|
+
FMP4_MOVFLAGS,
|
|
7353
|
+
"-min_frag_duration",
|
|
7354
|
+
String(Math.max(0, Math.round(sink.fragmentMs * 1e3))),
|
|
7355
|
+
"-f",
|
|
7356
|
+
"mp4",
|
|
7357
|
+
"pipe:1"
|
|
7358
|
+
];
|
|
7359
|
+
}
|
|
7360
|
+
/**
|
|
7361
|
+
* A second output mapping source audio to RTP-over-UDP. `0:a:0?` makes the
|
|
7362
|
+
* audio optional so a source with no audio skips it instead of failing the
|
|
7363
|
+
* whole invocation.
|
|
7364
|
+
*/
|
|
7365
|
+
function buildAudioSidecarArgs(sidecar) {
|
|
7366
|
+
return [
|
|
7367
|
+
"-map",
|
|
7368
|
+
"0:a:0?",
|
|
7369
|
+
...buildAudioArgs(sidecar.codec === "pcma" ? {
|
|
7370
|
+
kind: "encode",
|
|
7371
|
+
codec: "pcma",
|
|
7372
|
+
sampleRateHz: 8e3,
|
|
7373
|
+
channels: 1
|
|
7374
|
+
} : AUDIO_PRESETS[sidecar.codec]),
|
|
7375
|
+
...buildRtpOutputArgs({
|
|
7376
|
+
url: sidecar.rtpUrl,
|
|
7377
|
+
sdpFile: sidecar.sdpFile
|
|
7378
|
+
})
|
|
7379
|
+
];
|
|
7380
|
+
}
|
|
7381
|
+
/**
|
|
7382
|
+
* Assemble the full ffmpeg argument list. Layout:
|
|
7383
|
+
*
|
|
7384
|
+
* -hide_banner -loglevel <level>
|
|
7385
|
+
* [-hwaccel <backend|auto>] ─┐ INPUT options — strictly before -i.
|
|
7386
|
+
* [<input.extraArgs>] │
|
|
7387
|
+
* [-fflags <flag>…] │
|
|
7388
|
+
* [-rtsp_transport tcp] │
|
|
7389
|
+
* -i <url> ─┘
|
|
7390
|
+
* <video block> <threads> <audio block> ─┐ OUTPUT options.
|
|
7391
|
+
* <consumer outputArgs verbatim> │
|
|
7392
|
+
* <sink> ─┘ terminal
|
|
7393
|
+
*/
|
|
7394
|
+
function buildFfmpegArgs(inv) {
|
|
7395
|
+
const head = [...logBannerArgs(inv.logLevel), ...buildInputArgs(inv.input, inv.decodeHwAccel)];
|
|
7396
|
+
const threadArgs = inv.threadCount > 0 ? ["-threads", String(inv.threadCount)] : [];
|
|
7397
|
+
if (inv.sink.kind === "rtp-outputs") {
|
|
7398
|
+
const videoLeg = inv.sink.video ? [
|
|
7399
|
+
"-an",
|
|
7400
|
+
"-map",
|
|
7401
|
+
"0:v:0",
|
|
7402
|
+
...buildVideoArgs(inv.video, inv.outputArgs),
|
|
7403
|
+
...threadArgs,
|
|
7404
|
+
...inv.outputArgs,
|
|
7405
|
+
...buildRtpOutputArgs(inv.sink.video)
|
|
7406
|
+
] : [];
|
|
7407
|
+
const audioLeg = inv.sink.audio ? [
|
|
7408
|
+
"-vn",
|
|
7409
|
+
"-map",
|
|
7410
|
+
"0:a:0?",
|
|
7411
|
+
...buildAudioArgs(inv.audio),
|
|
7412
|
+
...buildRtpOutputArgs(inv.sink.audio)
|
|
7413
|
+
] : [];
|
|
7414
|
+
return [
|
|
7415
|
+
...head,
|
|
7416
|
+
...videoLeg,
|
|
7417
|
+
...audioLeg
|
|
7418
|
+
];
|
|
7419
|
+
}
|
|
7420
|
+
const audioArgs = isElementaryVideoSink(inv.sink) ? ["-an"] : buildAudioArgs(inv.audio);
|
|
7421
|
+
const sinkArgs = buildStdoutOrRtspSinkArgs(inv.sink);
|
|
7422
|
+
return [
|
|
7423
|
+
...head,
|
|
7424
|
+
...buildVideoArgs(inv.video, inv.outputArgs),
|
|
7425
|
+
...threadArgs,
|
|
7426
|
+
...audioArgs,
|
|
7427
|
+
...inv.outputArgs,
|
|
7428
|
+
...sinkArgs,
|
|
7429
|
+
...inv.audioSidecar ? buildAudioSidecarArgs(inv.audioSidecar) : []
|
|
7430
|
+
];
|
|
7431
|
+
}
|
|
7432
|
+
/**
|
|
7433
|
+
* The shape every live egress starts from: H.264 Baseline 3.1 at 720p25.
|
|
7434
|
+
* Baseline because it is the one profile every consumer in this repo decodes
|
|
7435
|
+
* (Echo, iOS, an old browser); 3.1 because that is what the SDPs advertise.
|
|
7436
|
+
*/
|
|
7437
|
+
var BASE_LIVE_EGRESS_PROFILE = {
|
|
7438
|
+
video: {
|
|
7439
|
+
codec: "h264",
|
|
7440
|
+
profile: "baseline",
|
|
7441
|
+
level: "3.1",
|
|
7442
|
+
width: 1280,
|
|
7443
|
+
height: 720,
|
|
7444
|
+
fps: 25,
|
|
7445
|
+
bitrateKbps: 2500,
|
|
7446
|
+
gopFrames: 25,
|
|
7447
|
+
bf: 0,
|
|
7448
|
+
preset: "veryfast",
|
|
7449
|
+
tune: "zerolatency"
|
|
7450
|
+
},
|
|
7451
|
+
audio: "passthrough"
|
|
7452
|
+
};
|
|
7453
|
+
({ ...BASE_LIVE_EGRESS_PROFILE }), { ...BASE_LIVE_EGRESS_PROFILE.video };
|
|
7454
|
+
({ ...BASE_LIVE_EGRESS_PROFILE });
|
|
7455
|
+
/** VBV window for a consumer whose budget is enforced per second (HomeKit). */
|
|
7456
|
+
var RATE_CONTROL_TIGHT = {
|
|
7457
|
+
kind: "cbr",
|
|
7458
|
+
vbvSeconds: 1
|
|
7459
|
+
};
|
|
7460
|
+
var HAP_AUDIO_BASE = {
|
|
7461
|
+
kind: "encode",
|
|
7462
|
+
codec: "opus",
|
|
7463
|
+
bitrateKbps: 24,
|
|
7464
|
+
channels: 1,
|
|
7465
|
+
application: "lowdelay",
|
|
7466
|
+
globalHeader: true,
|
|
7467
|
+
filter: "aresample=async=1000:first_pts=0"
|
|
7468
|
+
};
|
|
7469
|
+
function createHwAccelCache(options) {
|
|
7470
|
+
const now = options.now ?? (() => Date.now());
|
|
7471
|
+
let value = null;
|
|
7472
|
+
let writtenAt = Number.NEGATIVE_INFINITY;
|
|
7473
|
+
return {
|
|
7474
|
+
read() {
|
|
7475
|
+
return now() - writtenAt < options.ttlMs ? value : void 0;
|
|
7476
|
+
},
|
|
7477
|
+
write(next) {
|
|
7478
|
+
value = next;
|
|
7479
|
+
writtenAt = now();
|
|
7480
|
+
}
|
|
7481
|
+
};
|
|
7482
|
+
}
|
|
7029
7483
|
/**
|
|
7030
7484
|
* Deep wiring healthcheck — snapshot of active reachability probes across
|
|
7031
7485
|
* every declared capability + widget of every installed plugin, on every
|
|
@@ -7076,6 +7530,154 @@ object({
|
|
|
7076
7530
|
})
|
|
7077
7531
|
});
|
|
7078
7532
|
/**
|
|
7533
|
+
* Per-camera FUNCTION SWITCHES — the one coherent on/off surface over the
|
|
7534
|
+
* pipeline functions an operator thinks in terms of.
|
|
7535
|
+
*
|
|
7536
|
+
* ## This file adds no state
|
|
7537
|
+
*
|
|
7538
|
+
* Every switch here is a VIEW onto an authority that already existed
|
|
7539
|
+
* ([D62](../../../../docs/decisions/adr-0062.md)). The whole point of the
|
|
7540
|
+
* group is that there is exactly one place each function is turned off, and
|
|
7541
|
+
* the group routes to it:
|
|
7542
|
+
*
|
|
7543
|
+
* | Switch | Authority | Proven "off stops the work" gate |
|
|
7544
|
+
* | --- | --- | --- |
|
|
7545
|
+
* | `stream-broker` | `deviceManager.setDisabled` | `StreamBrokerManager.reconcileAllCatalogs` releases the brokers; `ensureBroker` refuses re-creation |
|
|
7546
|
+
* | `object-detection` | `deviceManager.setWrapperActive('detection-pipeline')` | `PipelineSettingsStore.resolvePipelineForDevice` returns `{ steps: [], audio: null }` |
|
|
7547
|
+
* | `audio-analysis` | `deviceManager.setWrapperActive('audio-analysis')` | `AudioSubscriptionController.subscribeAudioStream` returns `null` before opening the stream |
|
|
7548
|
+
* | `recording` | `recording.setDeviceConfig` → `RecordingConfig.enabled` | `band-decision.shouldRecord` returns false; the controller detaches the device |
|
|
7549
|
+
* | `notifications` | `notificationRules.setDeviceMuted` | `NotificationCenter.evaluateAndEnqueue` returns before any rule is evaluated |
|
|
7550
|
+
* | `privacy-mask` | `privacyMask.setMask({ enabled })` → the CAMERA | the camera blanks the masked regions itself; every stream and recording carries the black boxes |
|
|
7551
|
+
* | `device-audio` | `privacyMask.setAudioEnabled` → the CAMERA | the camera stops encoding an audio track at all; every consumer sees silent video |
|
|
7552
|
+
*
|
|
7553
|
+
* ## The two switches whose authority is not on this server
|
|
7554
|
+
*
|
|
7555
|
+
* `privacy-mask` and `device-audio` write the CAMERA. That is not a loophole
|
|
7556
|
+
* in "the group stores nothing" — it is the purest form of it: the camera
|
|
7557
|
+
* holds the fact, every read is a read-through, and there is no server-side
|
|
7558
|
+
* copy that could drift. Their availability therefore cannot come from
|
|
7559
|
+
* `listBindableCapsForDeviceType` (a device-NATIVE cap carries no wrappers and
|
|
7560
|
+
* is filtered out there); it comes from the cap's own camera-probed
|
|
7561
|
+
* `privacyMask.getOptions()`, which is strictly more honest — it answers for
|
|
7562
|
+
* THIS camera rather than for the device type
|
|
7563
|
+
* ([D74](../../../../docs/decisions/adr-0074.md)).
|
|
7564
|
+
*
|
|
7565
|
+
* ## `privacy-mask` is the one row whose ON is not "the function is working"
|
|
7566
|
+
*
|
|
7567
|
+
* Every other switch means *this camera's function is doing its job*, so
|
|
7568
|
+
* `enabled: false` is a thing an operator took away. `privacy-mask` means **the
|
|
7569
|
+
* MASK is active** — `enabled: true` is video deliberately obscured. The
|
|
7570
|
+
* polarity is not a choice made here: `addon-export-hap`'s privacy `Switch`
|
|
7571
|
+
* (`builders/privacy-switch.ts`) already mirrors `patch.enabled` verbatim, and
|
|
7572
|
+
* a HomeKit toggle that disagreed with the app's toggle for the same camera is
|
|
7573
|
+
* worse than either surface not having one.
|
|
7574
|
+
*
|
|
7575
|
+
* Two consequences follow and both are load-bearing:
|
|
7576
|
+
*
|
|
7577
|
+
* - **It never counts as `switchedOff`.** `countsAsSwitchedOff` is `false` for
|
|
7578
|
+
* exactly this row. With the polarity above, every camera that has NOT drawn
|
|
7579
|
+
* a privacy mask would otherwise report `switchedOff: ['privacy-mask']` — the
|
|
7580
|
+
* normal, healthy state of most cameras rendered as an operator disablement.
|
|
7581
|
+
* - **Its cost line names BOTH directions.** `costWhenOff` is rendered
|
|
7582
|
+
* unconditionally by both clients, so for this row it has to read correctly
|
|
7583
|
+
* whichever way the switch is sitting.
|
|
7584
|
+
*
|
|
7585
|
+
* The wrapper-binding pair is not a new idea: `legacy-migrations.ts` already
|
|
7586
|
+
* migrated the legacy `audioEnabled` / `pipelineEnabled` /
|
|
7587
|
+
* `motionDetectionEnabled` booleans ONTO `setWrapperActive`. The group is the
|
|
7588
|
+
* surface that decision never got.
|
|
7589
|
+
*
|
|
7590
|
+
* ## Two rules that are load-bearing
|
|
7591
|
+
*
|
|
7592
|
+
* - **Recording's switch is `enabled`, never the bands.** `bands` is the only
|
|
7593
|
+
* authored intent and `mode` is derived from it (`deriveRecordingMode`).
|
|
7594
|
+
* Expressing "off" by clearing bands destroys the operator's schedule and
|
|
7595
|
+
* turning the camera back on would then silently record nothing.
|
|
7596
|
+
* - **A switch that is off must be reported as off**, not merely produce
|
|
7597
|
+
* nothing. {@link CameraSwitch.enabled} is what a status surface renders as
|
|
7598
|
+
* "disabled by an operator" instead of "broken" — see
|
|
7599
|
+
* `CameraStatus.switchedOff`.
|
|
7600
|
+
*/
|
|
7601
|
+
/**
|
|
7602
|
+
* The functions the operator named — five on 2026-08-05, plus the camera's own
|
|
7603
|
+
* microphone on 2026-08-07. Deliberately NOT one id per pipeline step: face
|
|
7604
|
+
* recognition and plate/LPR are per-step toggles on
|
|
7605
|
+
* `pipelineOrchestrator.setCameraStepToggle` and belong in the pipeline
|
|
7606
|
+
* editor, not in a safety group.
|
|
7607
|
+
*/
|
|
7608
|
+
var CameraSwitchIdSchema = _enum([
|
|
7609
|
+
"stream-broker",
|
|
7610
|
+
"object-detection",
|
|
7611
|
+
"privacy-mask",
|
|
7612
|
+
"device-audio",
|
|
7613
|
+
"audio-analysis",
|
|
7614
|
+
"recording",
|
|
7615
|
+
"notifications"
|
|
7616
|
+
]);
|
|
7617
|
+
/**
|
|
7618
|
+
* WHERE the switch's state actually lives. A discriminated union rather than a
|
|
7619
|
+
* string so both the writer (the orchestrator's `setCameraSwitch`) and any
|
|
7620
|
+
* reader can exhaustively narrow — and so "the group added a parallel map" is
|
|
7621
|
+
* a compile error rather than a review comment.
|
|
7622
|
+
*/
|
|
7623
|
+
var CameraSwitchAuthoritySchema = discriminatedUnion("kind", [
|
|
7624
|
+
object({ kind: literal("device-disabled") }),
|
|
7625
|
+
object({
|
|
7626
|
+
kind: literal("wrapper-binding"),
|
|
7627
|
+
capName: string()
|
|
7628
|
+
}),
|
|
7629
|
+
object({ kind: literal("recording-config") }),
|
|
7630
|
+
object({ kind: literal("notification-mute") }),
|
|
7631
|
+
object({
|
|
7632
|
+
kind: literal("camera-audio"),
|
|
7633
|
+
capName: string()
|
|
7634
|
+
}),
|
|
7635
|
+
object({
|
|
7636
|
+
kind: literal("camera-mask"),
|
|
7637
|
+
capName: string()
|
|
7638
|
+
})
|
|
7639
|
+
]);
|
|
7640
|
+
/**
|
|
7641
|
+
* Why a switch is not offered for this camera. Rendered instead of the
|
|
7642
|
+
* control, never as a dead control — an absent function and a broken one must
|
|
7643
|
+
* not look the same.
|
|
7644
|
+
*/
|
|
7645
|
+
var CameraSwitchUnavailableReasonSchema = _enum([
|
|
7646
|
+
"no-provider",
|
|
7647
|
+
"source-unreachable",
|
|
7648
|
+
"not-configured"
|
|
7649
|
+
]);
|
|
7650
|
+
/**
|
|
7651
|
+
* One switch, resolved for one camera.
|
|
7652
|
+
*
|
|
7653
|
+
* `label` and `costWhenOff` travel ON THE WIRE rather than being looked up
|
|
7654
|
+
* client-side: the viewer is a separate repository that does not import
|
|
7655
|
+
* `@camstack/types`, and a cost line duplicated in two clients is a cost line
|
|
7656
|
+
* that will disagree with itself. Five rows per camera is nothing.
|
|
7657
|
+
*/
|
|
7658
|
+
var CameraSwitchSchema = object({
|
|
7659
|
+
id: CameraSwitchIdSchema,
|
|
7660
|
+
label: string(),
|
|
7661
|
+
/**
|
|
7662
|
+
* What the operator LOSES while this is off, in one sentence. Required, not
|
|
7663
|
+
* optional: a switch that cannot say what it costs should not ship.
|
|
7664
|
+
*/
|
|
7665
|
+
costWhenOff: string(),
|
|
7666
|
+
/** False = do not render a control. `unavailableReason` says why. */
|
|
7667
|
+
available: boolean(),
|
|
7668
|
+
unavailableReason: CameraSwitchUnavailableReasonSchema.optional(),
|
|
7669
|
+
/** Current state. Meaningless when `available` is false — read it as `true`. */
|
|
7670
|
+
enabled: boolean(),
|
|
7671
|
+
authority: CameraSwitchAuthoritySchema
|
|
7672
|
+
});
|
|
7673
|
+
/** The whole group for one camera. */
|
|
7674
|
+
var CameraSwitchGroupSchema = object({
|
|
7675
|
+
deviceId: number().int(),
|
|
7676
|
+
switches: array(CameraSwitchSchema).readonly(),
|
|
7677
|
+
/** Unix ms when the group was composed server-side. */
|
|
7678
|
+
fetchedAt: number()
|
|
7679
|
+
});
|
|
7680
|
+
/**
|
|
7079
7681
|
* Ops-log — the durable, append-only operations audit shared by the
|
|
7080
7682
|
* recordings and events management surfaces.
|
|
7081
7683
|
*
|
|
@@ -7094,14 +7696,16 @@ var OpsLogOpSchema = _enum([
|
|
|
7094
7696
|
"manual-delete",
|
|
7095
7697
|
"rescan",
|
|
7096
7698
|
"retention-run",
|
|
7097
|
-
"relocate"
|
|
7699
|
+
"relocate",
|
|
7700
|
+
"orphan-audit"
|
|
7098
7701
|
]);
|
|
7099
7702
|
/** Why the operation ran. */
|
|
7100
7703
|
var OpsLogReasonSchema = _enum([
|
|
7101
7704
|
"retention",
|
|
7102
7705
|
"quota",
|
|
7103
7706
|
"manual",
|
|
7104
|
-
"operator"
|
|
7707
|
+
"operator",
|
|
7708
|
+
"maintenance"
|
|
7105
7709
|
]);
|
|
7106
7710
|
/** One audit row, shared verbatim by both domains. */
|
|
7107
7711
|
var OpsLogEntrySchema = object({
|
|
@@ -9016,6 +9620,126 @@ var RtpSourceSchema = object({
|
|
|
9016
9620
|
encoder: string(),
|
|
9017
9621
|
pipelineKey: string()
|
|
9018
9622
|
});
|
|
9623
|
+
/**
|
|
9624
|
+
* The encode request — **structured and serialisable, with NO raw-flag escape
|
|
9625
|
+
* hatch.** This is deliberate and it is the one lesson taken from
|
|
9626
|
+
* `getStreamWithCodec`: that method's `outputArgs: string[]` is simultaneously
|
|
9627
|
+
* its extensibility mechanism AND part of `pipelineKeyFor`'s sharing key, so
|
|
9628
|
+
* adding a flag silently forks the shared child, and two consumers that mean
|
|
9629
|
+
* the same thing but spell it differently never share. Here every knob is a
|
|
9630
|
+
* NAMED field: a new requirement becomes a schema field (and a codegen run),
|
|
9631
|
+
* never an opaque array.
|
|
9632
|
+
*
|
|
9633
|
+
* `inputArgs` / `outputArgs` are omitted from the profile for the same reason.
|
|
9634
|
+
* The operator-facing derived-stream transform editor still has them — that is
|
|
9635
|
+
* a different surface (`publishCameraStream({ kind: 'derived' })`) with a
|
|
9636
|
+
* different purpose (reshaping a badly-behaved SOURCE), and it is unchanged.
|
|
9637
|
+
*/
|
|
9638
|
+
var EgressEncodeSchema = EncodeProfileSchema.omit({
|
|
9639
|
+
inputArgs: true,
|
|
9640
|
+
outputArgs: true
|
|
9641
|
+
});
|
|
9642
|
+
/**
|
|
9643
|
+
* How the encoder is bounded. `'tight'` is a one-second VBV window for a
|
|
9644
|
+
* consumer whose budget is enforced per second (HomeKit); `'relaxed'` is two
|
|
9645
|
+
* seconds, letting a keyframe spike borrow from the next second (a browser,
|
|
9646
|
+
* an Echo). Named rather than numeric so the INTENT survives.
|
|
9647
|
+
*/
|
|
9648
|
+
var EgressRateControlSchema = _enum(["tight", "relaxed"]);
|
|
9649
|
+
var EgressTranscodeRequestSchema = object({
|
|
9650
|
+
deviceId: number().int().nonnegative(),
|
|
9651
|
+
/** Which published stream to read. */
|
|
9652
|
+
source: discriminatedUnion("kind", [object({
|
|
9653
|
+
kind: literal("profile"),
|
|
9654
|
+
profile: CamProfileSchema
|
|
9655
|
+
}), object({
|
|
9656
|
+
kind: literal("cam-stream"),
|
|
9657
|
+
camStreamId: string().min(1)
|
|
9658
|
+
})]),
|
|
9659
|
+
encode: EgressEncodeSchema,
|
|
9660
|
+
rateControl: EgressRateControlSchema.optional(),
|
|
9661
|
+
/**
|
|
9662
|
+
* `-bsf:v`. A consumer that negotiates its OWN SDP (HomeKit) cannot carry
|
|
9663
|
+
* out-of-band extradata and needs `dump_extra` on both the copy and encode
|
|
9664
|
+
* branches. Enumerated, not free text.
|
|
9665
|
+
*/
|
|
9666
|
+
bitstreamFilter: _enum([
|
|
9667
|
+
"dump_extra",
|
|
9668
|
+
"h264_mp4toannexb",
|
|
9669
|
+
"hevc_mp4toannexb"
|
|
9670
|
+
]).optional(),
|
|
9671
|
+
/**
|
|
9672
|
+
* Publish the transcode as a LOCAL push cam stream, instead of leaving the
|
|
9673
|
+
* consumer to dial the returned url. The broker picks the id and returns it
|
|
9674
|
+
* as `camStreamId` — a caller-supplied one would be circular, since the
|
|
9675
|
+
* sharing key is computed FROM this request.
|
|
9676
|
+
*
|
|
9677
|
+
* The url is still returned and still the contract for a transcode pinned to
|
|
9678
|
+
* another node. But dialling it locally costs an RTSP round trip that changes
|
|
9679
|
+
* the transport underneath the consumer: a dialled stream is an RTP source,
|
|
9680
|
+
* so `isRtpSource()` is true and the session takes the RTP-passthrough +
|
|
9681
|
+
* repacketizer branch. The push branch — the one the derived mechanism has
|
|
9682
|
+
* live hours on — is never reached. Measured on Alexa: broker registered, RTP
|
|
9683
|
+
* arriving, key frame arriving, black screen, on a chain healthy at every
|
|
9684
|
+
* other point.
|
|
9685
|
+
*
|
|
9686
|
+
* Same idea the transport already applies to CALLS, where `classifyCapRoute`
|
|
9687
|
+
* gives priority to `hub-in-process` so a local call never leaves the node.
|
|
9688
|
+
* This is that rule for media.
|
|
9689
|
+
*/
|
|
9690
|
+
publishLocally: boolean().optional(),
|
|
9691
|
+
pixelFormat: _enum(["yuv420p", "nv12"]).optional(),
|
|
9692
|
+
/**
|
|
9693
|
+
* Operator/consumer override for decode hardware. ABSENT is the normal case
|
|
9694
|
+
* and the one that matters: the broker then resolves the backend from the
|
|
9695
|
+
* DECODER ADDON's per-node `probedBestHwaccel` (see
|
|
9696
|
+
* `@camstack/types` `ffmpeg/hwaccel.ts`), which is the ranking known to work
|
|
9697
|
+
* on this hardware — never the raw kernel resolver's qsv-first order.
|
|
9698
|
+
*/
|
|
9699
|
+
decodeHwAccel: _enum([
|
|
9700
|
+
"auto",
|
|
9701
|
+
"none",
|
|
9702
|
+
"videotoolbox",
|
|
9703
|
+
"vaapi",
|
|
9704
|
+
"qsv",
|
|
9705
|
+
"cuda"
|
|
9706
|
+
]).optional(),
|
|
9707
|
+
/**
|
|
9708
|
+
* Host to embed in the returned restream `url`. The broker mints hub-local
|
|
9709
|
+
* `127.0.0.1` URLs; a consumer on another node passes a cluster-resolvable
|
|
9710
|
+
* host (`NodeTopologyService.reachableHostByNode`) so the returned URL is
|
|
9711
|
+
* dialable from there. Same contract as `getStreamWithCodec.hostname` —
|
|
9712
|
+
* `substituteRtspHost` rewrites only the dial address, never the restreamer.
|
|
9713
|
+
*/
|
|
9714
|
+
hostname: string().optional(),
|
|
9715
|
+
/** Attribution for the broker panel. Never part of the sharing key. */
|
|
9716
|
+
tag: string().optional()
|
|
9717
|
+
});
|
|
9718
|
+
var EgressTranscodeSchema = object({
|
|
9719
|
+
/** Dial-able RTSP url (host-substituted when `hostname` was supplied). */
|
|
9720
|
+
url: string(),
|
|
9721
|
+
/** Release handle. Refcounted — the child dies when the last holder releases. */
|
|
9722
|
+
pipelineKey: string(),
|
|
9723
|
+
videoCodec: _enum(["H264", "H265"]),
|
|
9724
|
+
resolution: object({
|
|
9725
|
+
width: number().int().positive(),
|
|
9726
|
+
height: number().int().positive()
|
|
9727
|
+
}),
|
|
9728
|
+
transcoded: boolean(),
|
|
9729
|
+
encoder: string(),
|
|
9730
|
+
/**
|
|
9731
|
+
* The decode backend the child ACTUALLY ran with — `null` for software.
|
|
9732
|
+
* Returned rather than assumed: a consumer that asked for hardware and got
|
|
9733
|
+
* software needs to be able to see that without reading the broker's logs.
|
|
9734
|
+
*/
|
|
9735
|
+
decodeHwAccel: string().nullable(),
|
|
9736
|
+
/**
|
|
9737
|
+
* Set when `publishLocally` was honoured: attach to THIS instead of dialling
|
|
9738
|
+
* `url`, and the session takes the push/deframe transport rather than the
|
|
9739
|
+
* RTP-passthrough one. `null` means the consumer must dial.
|
|
9740
|
+
*/
|
|
9741
|
+
camStreamId: string().nullable()
|
|
9742
|
+
});
|
|
9019
9743
|
method(object({
|
|
9020
9744
|
deviceId: number().int().nonnegative(),
|
|
9021
9745
|
camStreamId: string().min(1),
|
|
@@ -9125,6 +9849,15 @@ method(object({
|
|
|
9125
9849
|
}), {
|
|
9126
9850
|
kind: "mutation",
|
|
9127
9851
|
auth: "admin"
|
|
9852
|
+
}), method(EgressTranscodeRequestSchema, EgressTranscodeSchema, {
|
|
9853
|
+
kind: "mutation",
|
|
9854
|
+
auth: "admin"
|
|
9855
|
+
}), method(object({ pipelineKey: string() }), object({
|
|
9856
|
+
released: boolean(),
|
|
9857
|
+
refcount: number().int().nonnegative()
|
|
9858
|
+
}), {
|
|
9859
|
+
kind: "mutation",
|
|
9860
|
+
auth: "admin"
|
|
9128
9861
|
}), method(SubscribeAudioChunksInputSchema, SubscribeAudioChunksResultSchema, { kind: "mutation" }), method(object({
|
|
9129
9862
|
subscriptionId: string(),
|
|
9130
9863
|
maxCount: number().int().positive().default(8)
|
|
@@ -9579,6 +10312,62 @@ method(_void(), EngineInfoSchema), method(object({
|
|
|
9579
10312
|
indexes: array(CollectionIndexSchema).readonly().optional()
|
|
9580
10313
|
}), _void(), { kind: "mutation" });
|
|
9581
10314
|
/**
|
|
10315
|
+
* Stable UI option list for the `hwaccel` setting. Decoder addons
|
|
10316
|
+
* reuse this for `globalSettingsSchema()` so the dropdown is
|
|
10317
|
+
* identical everywhere. Order: auto → off → common backends by
|
|
10318
|
+
* platform affinity (macOS, NVIDIA, Intel/AMD, Windows, Linux).
|
|
10319
|
+
*/
|
|
10320
|
+
var HWACCEL_OPTIONS = [
|
|
10321
|
+
{
|
|
10322
|
+
value: "auto",
|
|
10323
|
+
label: "Auto (defer to probed best)"
|
|
10324
|
+
},
|
|
10325
|
+
{
|
|
10326
|
+
value: "none",
|
|
10327
|
+
label: "Off (software)"
|
|
10328
|
+
},
|
|
10329
|
+
{
|
|
10330
|
+
value: "videotoolbox",
|
|
10331
|
+
label: "VideoToolbox (macOS)"
|
|
10332
|
+
},
|
|
10333
|
+
{
|
|
10334
|
+
value: "cuda",
|
|
10335
|
+
label: "CUDA (NVIDIA)"
|
|
10336
|
+
},
|
|
10337
|
+
{
|
|
10338
|
+
value: "nvdec",
|
|
10339
|
+
label: "NVDEC (NVIDIA legacy)"
|
|
10340
|
+
},
|
|
10341
|
+
{
|
|
10342
|
+
value: "vaapi",
|
|
10343
|
+
label: "VAAPI (Linux Intel/AMD)"
|
|
10344
|
+
},
|
|
10345
|
+
{
|
|
10346
|
+
value: "qsv",
|
|
10347
|
+
label: "QuickSync (Intel)"
|
|
10348
|
+
},
|
|
10349
|
+
{
|
|
10350
|
+
value: "d3d11va",
|
|
10351
|
+
label: "D3D11VA (Windows)"
|
|
10352
|
+
},
|
|
10353
|
+
{
|
|
10354
|
+
value: "dxva2",
|
|
10355
|
+
label: "DXVA2 (Windows legacy)"
|
|
10356
|
+
},
|
|
10357
|
+
{
|
|
10358
|
+
value: "amf",
|
|
10359
|
+
label: "AMF (AMD)"
|
|
10360
|
+
},
|
|
10361
|
+
{
|
|
10362
|
+
value: "vdpau",
|
|
10363
|
+
label: "VDPAU (Linux NVIDIA legacy)"
|
|
10364
|
+
},
|
|
10365
|
+
{
|
|
10366
|
+
value: "drm",
|
|
10367
|
+
label: "DRM (Linux generic)"
|
|
10368
|
+
}
|
|
10369
|
+
];
|
|
10370
|
+
/**
|
|
9582
10371
|
* shm ring usage stats for a `frameSink: 'shm'` decoder session —
|
|
9583
10372
|
* exposed via `decoder.getShmStats` so downstream consumers can
|
|
9584
10373
|
* observe ring pressure (slot count, byte budget, hit/miss ratio).
|
|
@@ -12928,7 +13717,7 @@ var DETECTION_SUB_COLORS = {
|
|
|
12928
13717
|
zebra: "#404040",
|
|
12929
13718
|
giraffe: "#d4a373"
|
|
12930
13719
|
};
|
|
12931
|
-
function titleCase(id) {
|
|
13720
|
+
function titleCase$1(id) {
|
|
12932
13721
|
return id.split(/[-_ ]/).filter((p) => p.length > 0).map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(" ");
|
|
12933
13722
|
}
|
|
12934
13723
|
var entries = /* @__PURE__ */ new Map();
|
|
@@ -12967,7 +13756,7 @@ macro("control", "control", TAXONOMY_COLORS.control, "control", "Control");
|
|
|
12967
13756
|
for (const [cocoClass, macroClass] of Object.entries(COCO_TO_MACRO.mapping)) {
|
|
12968
13757
|
if (macroClass !== "vehicle" && macroClass !== "animal") continue;
|
|
12969
13758
|
if (entries.has(cocoClass)) continue;
|
|
12970
|
-
sub(cocoClass, macroClass, "detection", DETECTION_SUB_COLORS[cocoClass] ?? TAXONOMY_COLORS.genericDetection, cocoClass, titleCase(cocoClass));
|
|
13759
|
+
sub(cocoClass, macroClass, "detection", DETECTION_SUB_COLORS[cocoClass] ?? TAXONOMY_COLORS.genericDetection, cocoClass, titleCase$1(cocoClass));
|
|
12971
13760
|
}
|
|
12972
13761
|
sub("package-delivered", "package", "package", TAXONOMY_COLORS.package, "package", "Package delivered");
|
|
12973
13762
|
sub("package-picked-up", "package", "package", TAXONOMY_COLORS.package, "package", "Package picked up");
|
|
@@ -13476,12 +14265,13 @@ var NcConditionsSchema = object({
|
|
|
13476
14265
|
* source; otherwise the subject's source must equal it. Legacy records
|
|
13477
14266
|
* with no stamped source are treated as `pipeline`. The union spans both
|
|
13478
14267
|
* record kinds — object events carry `pipeline` | `onboard`, synthetic
|
|
13479
|
-
* tracks carry `sensor
|
|
14268
|
+
* tracks carry `sensor` (a linked device) or `audio` (a D62 audio marker).
|
|
13480
14269
|
*/
|
|
13481
14270
|
source: _enum([
|
|
13482
14271
|
"pipeline",
|
|
13483
14272
|
"onboard",
|
|
13484
14273
|
"sensor",
|
|
14274
|
+
"audio",
|
|
13485
14275
|
"any"
|
|
13486
14276
|
]).optional(),
|
|
13487
14277
|
/**
|
|
@@ -14057,6 +14847,12 @@ method(object({}), object({ rules: array(NcRuleSchema) }), { auth: "admin" }), m
|
|
|
14057
14847
|
}), object({ success: literal(true) }), {
|
|
14058
14848
|
kind: "mutation",
|
|
14059
14849
|
auth: "admin"
|
|
14850
|
+
}), method(object({}), object({ mutedDeviceIds: array(number().int()).readonly() }), { auth: "admin" }), method(object({
|
|
14851
|
+
deviceId: number().int(),
|
|
14852
|
+
muted: boolean()
|
|
14853
|
+
}), object({ success: literal(true) }), {
|
|
14854
|
+
kind: "mutation",
|
|
14855
|
+
auth: "admin"
|
|
14060
14856
|
}), method(object({
|
|
14061
14857
|
rule: NcRuleInputSchema,
|
|
14062
14858
|
lookbackMinutes: number().int().min(1).max(1440).default(60)
|
|
@@ -14395,12 +15191,60 @@ var TrackAudioLabelSchema = object({
|
|
|
14395
15191
|
});
|
|
14396
15192
|
/**
|
|
14397
15193
|
* How a track was produced. `pipeline` (default / absent) = the spatial
|
|
14398
|
-
* detection+tracking pipeline.
|
|
14399
|
-
*
|
|
14400
|
-
*
|
|
14401
|
-
*
|
|
15194
|
+
* detection+tracking pipeline. Every OTHER value is a SYNTHETIC projection —
|
|
15195
|
+
* no positions, a single snapshot, and no bbox trajectory at all:
|
|
15196
|
+
*
|
|
15197
|
+
* - `sensor` — a linked sensor/control device state change.
|
|
15198
|
+
* - `audio` — an audio event on the camera itself that was anomalous for
|
|
15199
|
+
* THAT camera, loud, and heard while nothing visual was happening (D62).
|
|
15200
|
+
*
|
|
15201
|
+
* The spatial subsystems (tracker association, occupancy count, re-id /
|
|
15202
|
+
* embedding, resurrection) MUST skip every synthetic source. Test for that
|
|
15203
|
+
* with `isSpatialTrack`, which allow-lists `pipeline` — a `!== 'sensor'`
|
|
15204
|
+
* check silently readmits every source added after it was written.
|
|
15205
|
+
*/
|
|
15206
|
+
var TrackSourceSchema = _enum([
|
|
15207
|
+
"pipeline",
|
|
15208
|
+
"sensor",
|
|
15209
|
+
"audio"
|
|
15210
|
+
]);
|
|
15211
|
+
/**
|
|
15212
|
+
* Per-track OPERATOR flags — set by hand from the admin UI or the viewer, never
|
|
15213
|
+
* by the pipeline. Spread into `TrackSchema` and `KeyEventSchema` from one place
|
|
15214
|
+
* so the two surfaces cannot drift.
|
|
15215
|
+
*
|
|
15216
|
+
* **Absent ≠ false.** A track that has never been touched omits the field; an
|
|
15217
|
+
* explicitly un-flagged track carries `false`. Legacy rows written before the
|
|
15218
|
+
* columns existed read as absent, and a consumer that needs a boolean should say
|
|
15219
|
+
* `flag === true`, not `flag !== false`.
|
|
15220
|
+
*
|
|
15221
|
+
* What the flags DO is deliberately UNDEFINED at the time of writing: they are
|
|
15222
|
+
* operator curation, and the behaviour they drive will be specified separately.
|
|
15223
|
+
* In particular a `markForTrain` track is NOT pinned against retention — see
|
|
15224
|
+
* `docs/decisions/adr-0059.md` for why that is a store-level change, not a flag.
|
|
15225
|
+
*/
|
|
15226
|
+
var TrackFlagFields = {
|
|
15227
|
+
/** Operator marked this track as training material. */
|
|
15228
|
+
markForTrain: boolean().optional(),
|
|
15229
|
+
/** Operator marked this track for diagnostic attention. */
|
|
15230
|
+
debug: boolean().optional()
|
|
15231
|
+
};
|
|
15232
|
+
/**
|
|
15233
|
+
* The write half: a PARTIAL patch. An omitted key is left untouched, so setting
|
|
15234
|
+
* one flag can never clear the other — the toggles are independent and are
|
|
15235
|
+
* driven from three surfaces that do not know about each other.
|
|
15236
|
+
*/
|
|
15237
|
+
var TrackFlagsPatchSchema = object(TrackFlagFields);
|
|
15238
|
+
/**
|
|
15239
|
+
* The resolved flag state after a write. Both fields are REQUIRED here (absent
|
|
15240
|
+
* collapses to `false`) so a caller can drive a toggle's checked state off the
|
|
15241
|
+
* mutation result without a re-fetch.
|
|
14402
15242
|
*/
|
|
14403
|
-
var
|
|
15243
|
+
var TrackFlagsSchema = object({
|
|
15244
|
+
trackId: string(),
|
|
15245
|
+
markForTrain: boolean(),
|
|
15246
|
+
debug: boolean()
|
|
15247
|
+
});
|
|
14404
15248
|
var TrackSchema = object({
|
|
14405
15249
|
trackId: string(),
|
|
14406
15250
|
deviceId: number(),
|
|
@@ -14443,7 +15287,8 @@ var TrackSchema = object({
|
|
|
14443
15287
|
/** Normalized 0..1 trajectory envelope (see {@link TrackEnvelopeSchema}).
|
|
14444
15288
|
* Populated from the persisted envelope columns on historical reads;
|
|
14445
15289
|
* absent on legacy rows, dims-less tracks and active (in-RAM) tracks. */
|
|
14446
|
-
envelope: TrackEnvelopeSchema.optional()
|
|
15290
|
+
envelope: TrackEnvelopeSchema.optional(),
|
|
15291
|
+
...TrackFlagFields
|
|
14447
15292
|
});
|
|
14448
15293
|
var BaseEventFields = {
|
|
14449
15294
|
id: string(),
|
|
@@ -14656,7 +15501,8 @@ var KeyEventSchema = object({
|
|
|
14656
15501
|
/** Highest-confidence ObjectEvent id for the track (empty when none). */
|
|
14657
15502
|
bestEventId: string(),
|
|
14658
15503
|
/** Track lifetime in ms (lastSeen - firstSeen). */
|
|
14659
|
-
windowMs: number().optional()
|
|
15504
|
+
windowMs: number().optional(),
|
|
15505
|
+
...TrackFlagFields
|
|
14660
15506
|
});
|
|
14661
15507
|
object({
|
|
14662
15508
|
trackId: string(),
|
|
@@ -14742,7 +15588,31 @@ var RebuildObjectEmbeddingsInput = object({
|
|
|
14742
15588
|
since: number().optional(),
|
|
14743
15589
|
until: number().optional(),
|
|
14744
15590
|
/** Stop after this many tracks; the result reports whether more remain. */
|
|
14745
|
-
maxTracks: number().int().positive().optional()
|
|
15591
|
+
maxTracks: number().int().positive().optional(),
|
|
15592
|
+
/**
|
|
15593
|
+
* Run every embedding on THIS node instead of round-robining the fleet.
|
|
15594
|
+
*
|
|
15595
|
+
* Named `executeOnNodeId` and not `nodeId` on purpose: an inline `nodeId`
|
|
15596
|
+
* field in cap args is read by `parent-unowned-call.ts` as a ROUTING PIN, so
|
|
15597
|
+
* calling it that would pin the rebuild REQUEST itself to that node — the
|
|
15598
|
+
* rebuild orchestration lives on the hub, and only the per-track step runs
|
|
15599
|
+
* remotely. This field is data; the per-track pin is applied inside.
|
|
15600
|
+
*
|
|
15601
|
+
* Absent ⇒ round-robin over every online node whose runner can serve the
|
|
15602
|
+
* pinned model.
|
|
15603
|
+
*/
|
|
15604
|
+
executeOnNodeId: string().optional(),
|
|
15605
|
+
/**
|
|
15606
|
+
* Milliseconds to wait between tracks; omit for the built-in default, `0` to
|
|
15607
|
+
* run flat out.
|
|
15608
|
+
*
|
|
15609
|
+
* A rebuild is bulk maintenance on hub-main's single thread. Measured
|
|
15610
|
+
* 2026-08-06, an unpaced pass held that thread busy 82.2 s out of 120 and
|
|
15611
|
+
* pushed `nodes.topology` from 0.25 s to 26 s for 43 minutes. The value in
|
|
15612
|
+
* force is logged at start and finish so a deliberately slow pass reads
|
|
15613
|
+
* differently from a stalled one.
|
|
15614
|
+
*/
|
|
15615
|
+
pacingMs: number().int().nonnegative().optional()
|
|
14746
15616
|
});
|
|
14747
15617
|
/**
|
|
14748
15618
|
* Result of emptying the CLIP index.
|
|
@@ -14776,13 +15646,23 @@ var RebuildStatusSchema = object({
|
|
|
14776
15646
|
/** Tracks with no usable detection box. */
|
|
14777
15647
|
missingBbox: number(),
|
|
14778
15648
|
/**
|
|
14779
|
-
* Tracks
|
|
14780
|
-
*
|
|
14781
|
-
*
|
|
14782
|
-
*
|
|
14783
|
-
* otherwise read as a total engine outage.
|
|
15649
|
+
* Tracks an executing node REFUSED rather than broke on — an unreadable key
|
|
15650
|
+
* frame, a step that threw. Separate from `failed` because the remedy is
|
|
15651
|
+
* different, and because a whole camera silently contributing zero vectors
|
|
15652
|
+
* is the shape of failure a rebuild must never hide.
|
|
14784
15653
|
*/
|
|
14785
15654
|
notRunnable: number(),
|
|
15655
|
+
/**
|
|
15656
|
+
* The pass stopped because NO node could serve the pinned model.
|
|
15657
|
+
*
|
|
15658
|
+
* Distinct from `notRunnable` on purpose: that one says "this track was
|
|
15659
|
+
* refused", this one says "the cluster cannot do this work at all" — every
|
|
15660
|
+
* candidate node either lacks the `clip-embedding` step, lacks a build of the
|
|
15661
|
+
* pinned model for its engine format, or dropped out. The remedy is a model /
|
|
15662
|
+
* engine change, not a per-camera one. Non-zero here always comes with
|
|
15663
|
+
* `complete: false`.
|
|
15664
|
+
*/
|
|
15665
|
+
noCapableNode: number(),
|
|
14786
15666
|
failed: number(),
|
|
14787
15667
|
/** Set once a pass ends: true only when EVERYTHING was covered. */
|
|
14788
15668
|
complete: boolean().nullable(),
|
|
@@ -14854,7 +15734,12 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
|
|
|
14854
15734
|
}), {
|
|
14855
15735
|
kind: "mutation",
|
|
14856
15736
|
auth: "admin"
|
|
14857
|
-
}), method(object({
|
|
15737
|
+
}), method(object({
|
|
15738
|
+
/** Log/audit scope only — the trackId is globally unique on its own. */
|
|
15739
|
+
deviceId: number(),
|
|
15740
|
+
trackId: string(),
|
|
15741
|
+
flags: TrackFlagsPatchSchema
|
|
15742
|
+
}), TrackFlagsSchema, { kind: "mutation" }), method(object({}), EventStoreFootprintSchema, {
|
|
14858
15743
|
kind: "query",
|
|
14859
15744
|
auth: "admin"
|
|
14860
15745
|
}), method(object({
|
|
@@ -15464,6 +16349,53 @@ var DetailResultSchema = object({
|
|
|
15464
16349
|
nativeFaceShortSidePx: number().optional()
|
|
15465
16350
|
});
|
|
15466
16351
|
/**
|
|
16352
|
+
* Why an executing node REFUSED a stateless step run (`runStatelessStep`).
|
|
16353
|
+
*
|
|
16354
|
+
* A refusal is a first-class answer, not an error, because the caller's next
|
|
16355
|
+
* move depends on WHICH one it is — and because "the pass produced nothing"
|
|
16356
|
+
* must never be reachable without a named, counted cause. The two tiers:
|
|
16357
|
+
*
|
|
16358
|
+
* - **node-level** (`unknown-step`, `model-not-servable`) — this node can
|
|
16359
|
+
* never serve this (step, model) pair. The caller drops it from its rotation
|
|
16360
|
+
* and retries the same work elsewhere; nothing about the work changes.
|
|
16361
|
+
* - **work-level** (`unreadable-frame`, `execution-failed`) — this node is
|
|
16362
|
+
* fine, this one request is not. Retrying it on another node would only
|
|
16363
|
+
* spread the same failure.
|
|
16364
|
+
*/
|
|
16365
|
+
var StatelessStepRefusalSchema = _enum([
|
|
16366
|
+
"unknown-step",
|
|
16367
|
+
"model-not-servable",
|
|
16368
|
+
"unreadable-frame",
|
|
16369
|
+
"execution-failed"
|
|
16370
|
+
]);
|
|
16371
|
+
/**
|
|
16372
|
+
* Answer to `runStatelessStep` — a discriminated union rather than a nullable
|
|
16373
|
+
* result, because `null` is exactly what made the camera-bound detail path
|
|
16374
|
+
* unable to tell "refused" from "never asked".
|
|
16375
|
+
*/
|
|
16376
|
+
var RunStatelessStepResultSchema = discriminatedUnion("kind", [object({
|
|
16377
|
+
kind: literal("ran"),
|
|
16378
|
+
/** The node that actually executed it — the pin, echoed back for the log. */
|
|
16379
|
+
nodeId: string(),
|
|
16380
|
+
/**
|
|
16381
|
+
* The model the step ran with.
|
|
16382
|
+
*
|
|
16383
|
+
* The node verified this exact id has a build for the format it dispatched
|
|
16384
|
+
* on BEFORE running, so the executor's format resolution returns it
|
|
16385
|
+
* unchanged. A caller that pinned a model must compare this field and
|
|
16386
|
+
* treat a mismatch as a refusal — the whole point of the pin is that a
|
|
16387
|
+
* pass writes one feature space.
|
|
16388
|
+
*/
|
|
16389
|
+
modelId: string(),
|
|
16390
|
+
details: array(DetailResultSchema)
|
|
16391
|
+
}), object({
|
|
16392
|
+
kind: literal("refused"),
|
|
16393
|
+
nodeId: string(),
|
|
16394
|
+
reason: StatelessStepRefusalSchema,
|
|
16395
|
+
/** Human-readable specifics — the format tried, the formats shipped, etc. */
|
|
16396
|
+
detail: string()
|
|
16397
|
+
})]);
|
|
16398
|
+
/**
|
|
15467
16399
|
* Per-camera tunable ranges + defaults. Single source of truth used
|
|
15468
16400
|
* by both the Zod data schema (validation + default fallback) and
|
|
15469
16401
|
* the device settings UI (slider min/max/step). Touch one place and
|
|
@@ -15813,7 +16745,32 @@ method(RunnerCameraConfigSchema, object({ success: literal(true) }), { kind: "mu
|
|
|
15813
16745
|
cropJpeg: string().optional(),
|
|
15814
16746
|
parent: DetailParentSchema,
|
|
15815
16747
|
steps: array(string()).optional()
|
|
15816
|
-
}), object({ details: array(DetailResultSchema) }).nullable(), { kind: "mutation" })
|
|
16748
|
+
}), object({ details: array(DetailResultSchema) }).nullable(), { kind: "mutation" }), method(object({
|
|
16749
|
+
/** Catalog step id, e.g. `clip-embedding`. */
|
|
16750
|
+
stepId: string(),
|
|
16751
|
+
/**
|
|
16752
|
+
* REQUIRED model pin. The node runs this exact model or refuses with
|
|
16753
|
+
* `model-not-servable` — it never substitutes a format default, because
|
|
16754
|
+
* a fleet pass that round-robins across nodes would then fill one index
|
|
16755
|
+
* from several encoders.
|
|
16756
|
+
*/
|
|
16757
|
+
modelId: string(),
|
|
16758
|
+
/** FULL FRAME, base64 JPEG. The runner cuts — do NOT pre-crop. */
|
|
16759
|
+
frameJpeg: string(),
|
|
16760
|
+
/**
|
|
16761
|
+
* The subject box, NORMALISED [0,1] against `frameJpeg`. Normalised on
|
|
16762
|
+
* purpose: the caller stores boxes against a downscaled analysis frame
|
|
16763
|
+
* while the stored key frame is native-resolution, and the only side
|
|
16764
|
+
* that reliably knows the image's pixel dimensions is the side that
|
|
16765
|
+
* decodes it. Denormalising here removes a second reader of the
|
|
16766
|
+
* dimensions and the class of mismatch that comes with it.
|
|
16767
|
+
*/
|
|
16768
|
+
bbox: NativeCropBboxSchema,
|
|
16769
|
+
/** Parent class of the subject (`person`, `vehicle`, …) — carried into the result. */
|
|
16770
|
+
className: string(),
|
|
16771
|
+
/** Camera the pixels came from. Diagnostics + log tags ONLY — never routing. */
|
|
16772
|
+
sourceDeviceId: number()
|
|
16773
|
+
}), RunStatelessStepResultSchema, { kind: "mutation" });
|
|
15817
16774
|
var CameraPipelineConfigSchema = object({
|
|
15818
16775
|
engine: PipelineEngineChoiceSchema.optional(),
|
|
15819
16776
|
steps: array(PipelineStepInputSchema).readonly(),
|
|
@@ -16111,6 +17068,20 @@ var CameraStatusSchema = object({
|
|
|
16111
17068
|
detection: CameraDetectionStatusSchema.nullable(),
|
|
16112
17069
|
audio: CameraAudioStatusSchema.nullable(),
|
|
16113
17070
|
recording: CameraRecordingStatusSchema.nullable(),
|
|
17071
|
+
/**
|
|
17072
|
+
* Per-camera function switches an OPERATOR has turned off
|
|
17073
|
+
* ([D61](../../../../docs/decisions/adr-0067.md)).
|
|
17074
|
+
*
|
|
17075
|
+
* This is the difference between DISABLED and BROKEN. A camera whose
|
|
17076
|
+
* `detection` block reports zero fps and whose `switchedOff` contains
|
|
17077
|
+
* `'object-detection'` was switched off by a person; the same camera with an
|
|
17078
|
+
* empty list is failing. Every status surface must render the two
|
|
17079
|
+
* differently — a quiet camera that looks identical to a dead one is the
|
|
17080
|
+
* silence-reads-as-never-happened trap this repo keeps paying for.
|
|
17081
|
+
*
|
|
17082
|
+
* Empty when nothing is off. Never contains a switch no provider offers.
|
|
17083
|
+
*/
|
|
17084
|
+
switchedOff: array(CameraSwitchIdSchema).readonly(),
|
|
16114
17085
|
/** Unix timestamp (ms) when this snapshot was composed server-side. */
|
|
16115
17086
|
fetchedAt: number()
|
|
16116
17087
|
});
|
|
@@ -16279,7 +17250,14 @@ method(object({
|
|
|
16279
17250
|
}), method(object({
|
|
16280
17251
|
deviceId: number(),
|
|
16281
17252
|
agentNodeId: string().optional()
|
|
16282
|
-
}), CameraPipelineConfigSchema), method(object({ deviceId: number() }),
|
|
17253
|
+
}), CameraPipelineConfigSchema), method(object({ deviceId: number() }), CameraSwitchGroupSchema), method(object({
|
|
17254
|
+
deviceId: number(),
|
|
17255
|
+
switchId: CameraSwitchIdSchema,
|
|
17256
|
+
enabled: boolean()
|
|
17257
|
+
}), CameraSwitchGroupSchema, {
|
|
17258
|
+
kind: "mutation",
|
|
17259
|
+
auth: "admin"
|
|
17260
|
+
}), method(object({ deviceId: number() }), CameraStatusSchema), method(object({ deviceIds: array(number()).optional() }), array(CameraStatusSchema).readonly()), method(_void(), array(PipelineTemplateSchema).readonly()), method(object({
|
|
16283
17261
|
name: string(),
|
|
16284
17262
|
description: string().optional(),
|
|
16285
17263
|
config: CameraPipelineConfigSchema
|
|
@@ -16602,9 +17580,15 @@ DeviceType.Camera, method(object({
|
|
|
16602
17580
|
* Bypass the cache freshness check and fetch directly from the
|
|
16603
17581
|
* native (or stream-broker fallback). Triggered by the UI's
|
|
16604
17582
|
* "refresh" button so an operator can force a fresh frame
|
|
16605
|
-
* even when the cache is well within
|
|
16606
|
-
*
|
|
16607
|
-
*
|
|
17583
|
+
* even when the cache is well within the device's
|
|
17584
|
+
* `snapshotMaxAgeS` window.
|
|
17585
|
+
*
|
|
17586
|
+
* **`force` is an OPERATOR signal, not a freshness preference.** On a
|
|
17587
|
+
* battery camera it is the one thing that walks past the wrapper's
|
|
17588
|
+
* sleep gate and wakes the camera, so a background caller — a poller,
|
|
17589
|
+
* an event handler, a thumbnail — must NEVER set it. Every such caller
|
|
17590
|
+
* gets the cached frame, which on a sleeping battery camera is the
|
|
17591
|
+
* correct answer: stale but honest beats woken.
|
|
16608
17592
|
*/
|
|
16609
17593
|
force: boolean().optional()
|
|
16610
17594
|
}), SnapshotImageSchema.nullable()), method(object({ deviceId: number() }), _void(), {
|
|
@@ -20886,12 +21870,30 @@ object({
|
|
|
20886
21870
|
});
|
|
20887
21871
|
DeviceType.Sensor;
|
|
20888
21872
|
/**
|
|
20889
|
-
*
|
|
20890
|
-
*
|
|
20891
|
-
*
|
|
20892
|
-
*
|
|
20893
|
-
*
|
|
20894
|
-
*
|
|
21873
|
+
* PRIVACY — what the camera deliberately does not capture. Two planes:
|
|
21874
|
+
*
|
|
21875
|
+
* - **video**: up to `maxRegions` SHAPES the camera blanks out (NOT a cell
|
|
21876
|
+
* grid). Reolink `<shelterList>` zones are rectangles; Hikvision ISAPI
|
|
21877
|
+
* `<RegionCoordinatesList>` zones are free polygons (this camera: exactly
|
|
21878
|
+
* 4 vertices, not necessarily axis-aligned). The cap composes the shared
|
|
21879
|
+
* rect|polygon subset of the MaskShape vocabulary. All coords are
|
|
21880
|
+
* normalized 0..1 (top-left origin).
|
|
21881
|
+
* - **audio**: the camera's microphone. `setAudioEnabled(false)` stops the
|
|
21882
|
+
* camera encoding an audio track at all, so EVERY consumer — live view,
|
|
21883
|
+
* recording, the audio analyzer, an export — sees silent video. There is
|
|
21884
|
+
* no server-side copy of this fact; the camera is the store and every read
|
|
21885
|
+
* is a read-through, which is why a switch over it cannot drift
|
|
21886
|
+
* ([D62](../../../../docs/decisions/adr-0062.md)).
|
|
21887
|
+
*
|
|
21888
|
+
* Both belong here for one reason: they are the two things an operator turns
|
|
21889
|
+
* off when the answer to "what is this camera allowed to record" changes, and
|
|
21890
|
+
* both are applied ON the device, before anything leaves it.
|
|
21891
|
+
*
|
|
21892
|
+
* **The audio flag has exactly one writer.** `stream-params` used to carry a
|
|
21893
|
+
* per-profile `audio` in its patch schema — reachable from no UI and honoured
|
|
21894
|
+
* by one provider — and it was removed when this landed. A second writer onto
|
|
21895
|
+
* one device register is the shape of every knob this repo has shipped that
|
|
21896
|
+
* disagreed with the one the reader read.
|
|
20895
21897
|
*/
|
|
20896
21898
|
/** A privacy-mask region's geometry — rectangle or free polygon. */
|
|
20897
21899
|
var PrivacyMaskShapeSchema = discriminatedUnion("kind", [MaskRectShapeSchema, MaskPolygonShapeSchema]);
|
|
@@ -20907,16 +21909,40 @@ object({
|
|
|
20907
21909
|
enabled: boolean(),
|
|
20908
21910
|
/** Active zones (normalized 0..1). Length ≤ maxRegions. */
|
|
20909
21911
|
regions: array(PrivacyMaskRegionSchema),
|
|
21912
|
+
/**
|
|
21913
|
+
* Is the camera capturing sound right now? Read from the camera, never from
|
|
21914
|
+
* a server-side mirror.
|
|
21915
|
+
*
|
|
21916
|
+
* `null` means "no answer" — either this camera exposes no controllable
|
|
21917
|
+
* microphone (`getOptions().supportsAudioMute === false`) or the read
|
|
21918
|
+
* failed. A consumer must render `null` as UNKNOWN and never as `false`:
|
|
21919
|
+
* "the microphone is off" and "we could not ask" look identical to an
|
|
21920
|
+
* operator only until one of them is wrong.
|
|
21921
|
+
*
|
|
21922
|
+
* On a camera whose profiles carry the flag independently (Reolink writes
|
|
21923
|
+
* it per stream), `true` means AT LEAST ONE profile still carries audio —
|
|
21924
|
+
* privacy is only satisfied when every one of them is silent.
|
|
21925
|
+
*/
|
|
21926
|
+
audioEnabled: boolean().nullable(),
|
|
20910
21927
|
lastFetchedAt: number()
|
|
20911
21928
|
});
|
|
20912
|
-
/** Per-camera availability. */
|
|
21929
|
+
/** Per-camera availability. Probed, never assumed from the model name. */
|
|
20913
21930
|
var PrivacyMaskOptionsSchema = object({
|
|
20914
21931
|
/** Maximum number of supported zones. */
|
|
20915
21932
|
maxRegions: number(),
|
|
20916
21933
|
/** Shape kinds this camera accepts — Reolink: ['rect']; Hikvision: ['rect','polygon']. */
|
|
20917
21934
|
supportedShapes: array(MaskShapeKindSchema),
|
|
20918
21935
|
/** Polygon vertex bounds when 'polygon' is supported (Hikvision: {min:4,max:4}). */
|
|
20919
|
-
polygonVertices: MaskPolygonVerticesSchema.optional()
|
|
21936
|
+
polygonVertices: MaskPolygonVerticesSchema.optional(),
|
|
21937
|
+
/**
|
|
21938
|
+
* Does this camera expose a microphone switch we can actually write?
|
|
21939
|
+
*
|
|
21940
|
+
* Camera-probed: `true` only when the firmware answered with an audio flag
|
|
21941
|
+
* we know how to patch. A camera that never answered is `false` — a control
|
|
21942
|
+
* the operator can press that changes nothing is worse than no control, and
|
|
21943
|
+
* the switch group renders "not available" instead.
|
|
21944
|
+
*/
|
|
21945
|
+
supportsAudioMute: boolean()
|
|
20920
21946
|
});
|
|
20921
21947
|
/** Partial change — every field optional. */
|
|
20922
21948
|
var PrivacyMaskPatchSchema = object({
|
|
@@ -20929,6 +21955,12 @@ DeviceType.Camera, method(object({ deviceId: number() }), PrivacyMaskOptionsSche
|
|
|
20929
21955
|
}), _void(), {
|
|
20930
21956
|
kind: "mutation",
|
|
20931
21957
|
auth: "admin"
|
|
21958
|
+
}), method(object({
|
|
21959
|
+
deviceId: number(),
|
|
21960
|
+
enabled: boolean()
|
|
21961
|
+
}), _void(), {
|
|
21962
|
+
kind: "mutation",
|
|
21963
|
+
auth: "admin"
|
|
20932
21964
|
});
|
|
20933
21965
|
var PtzPresetSchema = object({
|
|
20934
21966
|
id: string(),
|
|
@@ -21138,6 +22170,21 @@ var LocateSegmentResultSchema = discriminatedUnion("kind", [object({
|
|
|
21138
22170
|
})]);
|
|
21139
22171
|
/** Raw bytes of one finalized footage segment (read off disk on the recording node). */
|
|
21140
22172
|
var ReadSegmentBytesResultSchema = object({ data: _instanceof(Uint8Array) });
|
|
22173
|
+
/**
|
|
22174
|
+
* One GOP of a finalized segment, cut by byte range through the segment's own
|
|
22175
|
+
* `mfra` (D31 on the D42 feeder path). `data` is the `ftyp`+`moov` head plus
|
|
22176
|
+
* the single `moof`+`mdat` covering the requested instant — standalone-
|
|
22177
|
+
* demuxable, never the whole file. When the segment's index cannot be parsed
|
|
22178
|
+
* the provider degrades INSIDE the mechanism to the whole segment (still one
|
|
22179
|
+
* `data`, `gopStartMs` = the segment start) — a worse read, not another path.
|
|
22180
|
+
*/
|
|
22181
|
+
var ReadGopBytesResultSchema = object({
|
|
22182
|
+
data: _instanceof(Uint8Array),
|
|
22183
|
+
/** Absolute epoch ms of the returned fragment's first sample. */
|
|
22184
|
+
gopStartMs: number(),
|
|
22185
|
+
/** Media ms the returned fragment covers. */
|
|
22186
|
+
gopDurMs: number()
|
|
22187
|
+
});
|
|
21141
22188
|
method(object({
|
|
21142
22189
|
deviceId: number(),
|
|
21143
22190
|
fromMs: number(),
|
|
@@ -21180,6 +22227,14 @@ method(object({
|
|
|
21180
22227
|
}), ReadSegmentBytesResultSchema, {
|
|
21181
22228
|
kind: "query",
|
|
21182
22229
|
auth: "admin"
|
|
22230
|
+
}), method(object({
|
|
22231
|
+
deviceId: number(),
|
|
22232
|
+
profile: string(),
|
|
22233
|
+
startMs: number(),
|
|
22234
|
+
epochMs: number()
|
|
22235
|
+
}), ReadGopBytesResultSchema, {
|
|
22236
|
+
kind: "query",
|
|
22237
|
+
auth: "admin"
|
|
21183
22238
|
}), method(object({
|
|
21184
22239
|
deviceId: number(),
|
|
21185
22240
|
config: RecordingConfigSchema
|
|
@@ -21660,6 +22715,16 @@ var StreamProfileConfigSchema = object({
|
|
|
21660
22715
|
"baseline"
|
|
21661
22716
|
]).optional(),
|
|
21662
22717
|
gop: number().optional(),
|
|
22718
|
+
/**
|
|
22719
|
+
* Whether THIS profile currently carries an audio track. READ-ONLY here.
|
|
22720
|
+
*
|
|
22721
|
+
* There is no matching field on {@link StreamProfilePatchSchema}: the
|
|
22722
|
+
* camera's microphone is owned by `privacy-mask` (`setAudioEnabled`), which
|
|
22723
|
+
* writes every profile at once so "audio off" means silent everywhere. A
|
|
22724
|
+
* per-profile writer beside it would let a camera be half-muted and would be
|
|
22725
|
+
* a second knob onto one device register — the failure D62 exists to
|
|
22726
|
+
* prevent. Absent when the firmware does not report the flag.
|
|
22727
|
+
*/
|
|
21663
22728
|
audio: boolean().optional()
|
|
21664
22729
|
});
|
|
21665
22730
|
object({
|
|
@@ -21700,7 +22765,13 @@ var StreamParamsOptionsSchema = object({
|
|
|
21700
22765
|
ext: StreamProfileOptionsSchema.optional()
|
|
21701
22766
|
});
|
|
21702
22767
|
/** A partial change to one profile — every field optional; a provider
|
|
21703
|
-
* ignores fields it doesn't support.
|
|
22768
|
+
* ignores fields it doesn't support.
|
|
22769
|
+
*
|
|
22770
|
+
* There is deliberately NO `audio` here. It existed until 2026-08-07,
|
|
22771
|
+
* reachable from no form and honoured by exactly one provider, while the
|
|
22772
|
+
* camera's microphone is a whole-device fact. It now has one writer,
|
|
22773
|
+
* `privacyMask.setAudioEnabled`, which writes every profile — see
|
|
22774
|
+
* `privacy-mask.cap.ts`. */
|
|
21704
22775
|
var StreamProfilePatchSchema = object({
|
|
21705
22776
|
width: number().optional(),
|
|
21706
22777
|
height: number().optional(),
|
|
@@ -21713,8 +22784,7 @@ var StreamProfilePatchSchema = object({
|
|
|
21713
22784
|
"main",
|
|
21714
22785
|
"baseline"
|
|
21715
22786
|
]).optional(),
|
|
21716
|
-
gop: number().optional()
|
|
21717
|
-
audio: boolean().optional()
|
|
22787
|
+
gop: number().optional()
|
|
21718
22788
|
});
|
|
21719
22789
|
DeviceType.Camera, method(object({ deviceId: number() }), StreamParamsOptionsSchema), method(object({
|
|
21720
22790
|
deviceId: number(),
|
|
@@ -25236,6 +26306,12 @@ Object.freeze({
|
|
|
25236
26306
|
addonId: null,
|
|
25237
26307
|
access: "view"
|
|
25238
26308
|
},
|
|
26309
|
+
"notificationRules.listDeviceMutes": {
|
|
26310
|
+
capName: "notification-rules",
|
|
26311
|
+
capScope: "system",
|
|
26312
|
+
addonId: null,
|
|
26313
|
+
access: "view"
|
|
26314
|
+
},
|
|
25239
26315
|
"notificationRules.listRules": {
|
|
25240
26316
|
capName: "notification-rules",
|
|
25241
26317
|
capScope: "system",
|
|
@@ -25254,6 +26330,12 @@ Object.freeze({
|
|
|
25254
26330
|
addonId: null,
|
|
25255
26331
|
access: "create"
|
|
25256
26332
|
},
|
|
26333
|
+
"notificationRules.setDeviceMuted": {
|
|
26334
|
+
capName: "notification-rules",
|
|
26335
|
+
capScope: "system",
|
|
26336
|
+
addonId: null,
|
|
26337
|
+
access: "create"
|
|
26338
|
+
},
|
|
25257
26339
|
"notificationRules.setRuleEnabled": {
|
|
25258
26340
|
capName: "notification-rules",
|
|
25259
26341
|
capScope: "system",
|
|
@@ -25530,6 +26612,12 @@ Object.freeze({
|
|
|
25530
26612
|
addonId: null,
|
|
25531
26613
|
access: "view"
|
|
25532
26614
|
},
|
|
26615
|
+
"pipelineAnalytics.setTrackFlags": {
|
|
26616
|
+
capName: "pipeline-analytics",
|
|
26617
|
+
capScope: "device",
|
|
26618
|
+
addonId: null,
|
|
26619
|
+
access: "create"
|
|
26620
|
+
},
|
|
25533
26621
|
"pipelineAnalytics.wipeAllAnalytics": {
|
|
25534
26622
|
capName: "pipeline-analytics",
|
|
25535
26623
|
capScope: "device",
|
|
@@ -25836,6 +26924,12 @@ Object.freeze({
|
|
|
25836
26924
|
addonId: null,
|
|
25837
26925
|
access: "view"
|
|
25838
26926
|
},
|
|
26927
|
+
"pipelineOrchestrator.getCameraSwitches": {
|
|
26928
|
+
capName: "pipeline-orchestrator",
|
|
26929
|
+
capScope: "system",
|
|
26930
|
+
addonId: null,
|
|
26931
|
+
access: "view"
|
|
26932
|
+
},
|
|
25839
26933
|
"pipelineOrchestrator.getCapabilityBindings": {
|
|
25840
26934
|
capName: "pipeline-orchestrator",
|
|
25841
26935
|
capScope: "system",
|
|
@@ -25968,6 +27062,12 @@ Object.freeze({
|
|
|
25968
27062
|
addonId: null,
|
|
25969
27063
|
access: "create"
|
|
25970
27064
|
},
|
|
27065
|
+
"pipelineOrchestrator.setCameraSwitch": {
|
|
27066
|
+
capName: "pipeline-orchestrator",
|
|
27067
|
+
capScope: "system",
|
|
27068
|
+
addonId: null,
|
|
27069
|
+
access: "create"
|
|
27070
|
+
},
|
|
25971
27071
|
"pipelineOrchestrator.setCapabilityBinding": {
|
|
25972
27072
|
capName: "pipeline-orchestrator",
|
|
25973
27073
|
capScope: "system",
|
|
@@ -26058,6 +27158,12 @@ Object.freeze({
|
|
|
26058
27158
|
addonId: null,
|
|
26059
27159
|
access: "create"
|
|
26060
27160
|
},
|
|
27161
|
+
"pipelineRunner.runStatelessStep": {
|
|
27162
|
+
capName: "pipeline-runner",
|
|
27163
|
+
capScope: "system",
|
|
27164
|
+
addonId: null,
|
|
27165
|
+
access: "create"
|
|
27166
|
+
},
|
|
26061
27167
|
"plateGallery.assignPlate": {
|
|
26062
27168
|
capName: "plate-gallery",
|
|
26063
27169
|
capScope: "system",
|
|
@@ -26190,6 +27296,12 @@ Object.freeze({
|
|
|
26190
27296
|
addonId: null,
|
|
26191
27297
|
access: "view"
|
|
26192
27298
|
},
|
|
27299
|
+
"privacyMask.setAudioEnabled": {
|
|
27300
|
+
capName: "privacy-mask",
|
|
27301
|
+
capScope: "device",
|
|
27302
|
+
addonId: null,
|
|
27303
|
+
access: "create"
|
|
27304
|
+
},
|
|
26193
27305
|
"privacyMask.setMask": {
|
|
26194
27306
|
capName: "privacy-mask",
|
|
26195
27307
|
capScope: "device",
|
|
@@ -26358,6 +27470,12 @@ Object.freeze({
|
|
|
26358
27470
|
addonId: null,
|
|
26359
27471
|
access: "create"
|
|
26360
27472
|
},
|
|
27473
|
+
"recording.readGopBytes": {
|
|
27474
|
+
capName: "recording",
|
|
27475
|
+
capScope: "system",
|
|
27476
|
+
addonId: null,
|
|
27477
|
+
access: "view"
|
|
27478
|
+
},
|
|
26361
27479
|
"recording.readSegmentBytes": {
|
|
26362
27480
|
capName: "recording",
|
|
26363
27481
|
capScope: "system",
|
|
@@ -26874,6 +27992,12 @@ Object.freeze({
|
|
|
26874
27992
|
addonId: null,
|
|
26875
27993
|
access: "create"
|
|
26876
27994
|
},
|
|
27995
|
+
"streamBroker.acquireEgressTranscode": {
|
|
27996
|
+
capName: "stream-broker",
|
|
27997
|
+
capScope: "system",
|
|
27998
|
+
addonId: null,
|
|
27999
|
+
access: "create"
|
|
28000
|
+
},
|
|
26877
28001
|
"streamBroker.assignProfile": {
|
|
26878
28002
|
capName: "stream-broker",
|
|
26879
28003
|
capScope: "system",
|
|
@@ -26982,6 +28106,12 @@ Object.freeze({
|
|
|
26982
28106
|
addonId: null,
|
|
26983
28107
|
access: "create"
|
|
26984
28108
|
},
|
|
28109
|
+
"streamBroker.releaseEgressTranscode": {
|
|
28110
|
+
capName: "stream-broker",
|
|
28111
|
+
capScope: "system",
|
|
28112
|
+
addonId: null,
|
|
28113
|
+
access: "create"
|
|
28114
|
+
},
|
|
26985
28115
|
"streamBroker.releaseStreamWithCodec": {
|
|
26986
28116
|
capName: "stream-broker",
|
|
26987
28117
|
capScope: "system",
|
|
@@ -27767,36 +28897,87 @@ object({
|
|
|
27767
28897
|
square: false
|
|
27768
28898
|
}).paddingRatio;
|
|
27769
28899
|
/**
|
|
27770
|
-
*
|
|
27771
|
-
*
|
|
27772
|
-
*
|
|
27773
|
-
* the
|
|
27774
|
-
*
|
|
27775
|
-
*
|
|
27776
|
-
*
|
|
27777
|
-
* the
|
|
27778
|
-
*
|
|
27779
|
-
*
|
|
27780
|
-
*
|
|
27781
|
-
*
|
|
27782
|
-
*
|
|
27783
|
-
*
|
|
27784
|
-
*
|
|
27785
|
-
*
|
|
27786
|
-
*/
|
|
27787
|
-
|
|
27788
|
-
|
|
27789
|
-
|
|
27790
|
-
|
|
27791
|
-
|
|
27792
|
-
|
|
27793
|
-
|
|
27794
|
-
|
|
27795
|
-
|
|
27796
|
-
|
|
27797
|
-
|
|
27798
|
-
|
|
27799
|
-
|
|
28900
|
+
* WHICH delivered frames the decode worker retains a native copy of.
|
|
28901
|
+
*
|
|
28902
|
+
* - `all` — every frame the worker delivered to the runner. The shipped
|
|
28903
|
+
* behaviour, and the only correct one if something can ask for a crop of a
|
|
28904
|
+
* frame the runner never sent to inference.
|
|
28905
|
+
* - `inferred` — only the frames the runner ADMITTED to its detection queue.
|
|
28906
|
+
* A native-crop request always names a `frameId` that rode an inference
|
|
28907
|
+
* result, so that is the only set a request can name. How much it drops is
|
|
28908
|
+
* the two-plane governor's admit ratio and nothing else: measured at ~50% on
|
|
28909
|
+
* this cluster, not the ~80% the design sketch assumed, because the governor
|
|
28910
|
+
* was not throttling as hard as the sketch supposed. Read
|
|
28911
|
+
* `leaseAdmitted`/`leaseOffered` off the metrics line for the camera in front
|
|
28912
|
+
* of you rather than quoting a number from here. The newest delivered frame is
|
|
28913
|
+
* croppable regardless — it is still the worker's reserved slot, not a lease —
|
|
28914
|
+
* which covers the one-frame race between a mark and the supersede that
|
|
28915
|
+
* consumes it.
|
|
28916
|
+
*/
|
|
28917
|
+
var NativeLeaseAdmissionSchema = _enum(["all", "inferred"]);
|
|
28918
|
+
object({
|
|
28919
|
+
/**
|
|
28920
|
+
* How long a retained native frame is served before it counts as a miss.
|
|
28921
|
+
*
|
|
28922
|
+
* Must cover the FULL late-crop horizon: detection inference + the
|
|
28923
|
+
* cross-process inference-result hop to hub post-analysis + tracking + the
|
|
28924
|
+
* tRPC crop round-trip back. Below ~500 ms the busiest cameras' subject crops
|
|
28925
|
+
* outrun it and fall back to the ≤640 detection frame; above ~3 s the resident
|
|
28926
|
+
* RAM per busy camera grows linearly with no measured hit-rate gain.
|
|
28927
|
+
*/
|
|
28928
|
+
ttlMs: number().int().min(250).max(1e4),
|
|
28929
|
+
/**
|
|
28930
|
+
* Hard per-decode-worker RAM ceiling for retained native frames, in MB.
|
|
28931
|
+
*
|
|
28932
|
+
* Intended as a SAFETY ceiling with the TTL as the effective cap — but check
|
|
28933
|
+
* which one is actually binding before reasoning from that. At the shipped
|
|
28934
|
+
* 1024 MB and a 2 800 ms TTL, a 4K camera hits the CEILING first (~43 frames
|
|
28935
|
+
* at ~24 MB each) and the TTL never gets to expire anything; `leaseMb` /
|
|
28936
|
+
* `leaseFrames` on the metrics line say which. When the ceiling binds, a
|
|
28937
|
+
* change that admits fewer frames buys retention WINDOW at constant RAM
|
|
28938
|
+
* rather than giving RAM back — lower this knob if RAM is what you wanted.
|
|
28939
|
+
* `0` DISABLES the lease entirely and falls the worker back to the tiny
|
|
28940
|
+
* leak-prone GPU surface ring (~85% crop miss; that is what the lease exists
|
|
28941
|
+
* to replace).
|
|
28942
|
+
*/
|
|
28943
|
+
budgetMb: number().int().min(0).max(4096),
|
|
28944
|
+
/**
|
|
28945
|
+
* Demand window: eager per-frame native retention runs only within this many
|
|
28946
|
+
* ms of the last native-crop request (or of the dial starting).
|
|
28947
|
+
*
|
|
28948
|
+
* `0` means ALWAYS ON — it disables the gate, it does not disable retention.
|
|
28949
|
+
* That is the legacy behaviour that saturated an N100 (24 native-4K downloads
|
|
28950
|
+
* per second on a camera with zero crop demand), so leave it non-zero unless
|
|
28951
|
+
* you are reproducing that.
|
|
28952
|
+
*/
|
|
28953
|
+
activityMs: number().int().min(0).max(12e4),
|
|
28954
|
+
/**
|
|
28955
|
+
* Which delivered frames are retained at all — see
|
|
28956
|
+
* {@link NativeLeaseAdmissionSchema}. This is the only knob of the four that
|
|
28957
|
+
* changes WHAT is kept rather than for how long, so it is also the only one
|
|
28958
|
+
* that can turn a crop that used to hit into a miss. The worker counts every
|
|
28959
|
+
* crop request naming a frame it did NOT see marked
|
|
28960
|
+
* (`leaseUnmarkedCrops` on the session-decode metrics line): a non-zero value
|
|
28961
|
+
* there is the signal that some caller names frames outside the inference set
|
|
28962
|
+
* and that this must go back to `all`.
|
|
28963
|
+
*/
|
|
28964
|
+
admission: NativeLeaseAdmissionSchema
|
|
28965
|
+
});
|
|
28966
|
+
/**
|
|
28967
|
+
* The values in force when the operator has set nothing — byte-for-byte the
|
|
28968
|
+
* constants the decode worker shipped with as env-var defaults, so making these
|
|
28969
|
+
* settings changed no behaviour on the day it landed.
|
|
28970
|
+
*/
|
|
28971
|
+
var DEFAULT_NATIVE_LEASE_SETTINGS = {
|
|
28972
|
+
ttlMs: 1200,
|
|
28973
|
+
budgetMb: 1024,
|
|
28974
|
+
activityMs: 15e3,
|
|
28975
|
+
admission: "inferred"
|
|
28976
|
+
};
|
|
28977
|
+
DEFAULT_NATIVE_LEASE_SETTINGS.ttlMs;
|
|
28978
|
+
DEFAULT_NATIVE_LEASE_SETTINGS.budgetMb;
|
|
28979
|
+
DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
|
|
28980
|
+
DEFAULT_NATIVE_LEASE_SETTINGS.admission;
|
|
27800
28981
|
/**
|
|
27801
28982
|
* Compute the stable 64-char lowercase-hex fingerprint of a device's
|
|
27802
28983
|
* export-relevant shape. Two structurally-equal shapes (any feature order,
|
|
@@ -27936,7 +29117,7 @@ function clearPairingFiles(accessoryUuid, logger) {
|
|
|
27936
29117
|
}
|
|
27937
29118
|
//#endregion
|
|
27938
29119
|
//#region src/hap-setup-uri.ts
|
|
27939
|
-
function errMsg$
|
|
29120
|
+
function errMsg$11(e) {
|
|
27940
29121
|
return e instanceof Error ? e.message : String(e);
|
|
27941
29122
|
}
|
|
27942
29123
|
/**
|
|
@@ -27963,11 +29144,146 @@ function firstExposedAccessorySetupUri(exposed, logger) {
|
|
|
27963
29144
|
try {
|
|
27964
29145
|
return first.setupURI();
|
|
27965
29146
|
} catch (err) {
|
|
27966
|
-
logger.debug("export-hap: setupURI failed on first exposed accessory", { meta: { error: errMsg$
|
|
29147
|
+
logger.debug("export-hap: setupURI failed on first exposed accessory", { meta: { error: errMsg$11(err) } });
|
|
27967
29148
|
return;
|
|
27968
29149
|
}
|
|
27969
29150
|
}
|
|
27970
29151
|
}
|
|
29152
|
+
/**
|
|
29153
|
+
* hap-nodejs' `checkName` regex, verbatim.
|
|
29154
|
+
*
|
|
29155
|
+
* Duplicated rather than imported because it is `@private Private API` in that
|
|
29156
|
+
* package and not exported. Duplicating a private regex is a liability, so the
|
|
29157
|
+
* guard against drift is behavioural, not textual: `service-naming.spec.ts`
|
|
29158
|
+
* builds real services and asserts hap-nodejs emits ZERO characteristic
|
|
29159
|
+
* warnings — if this expression ever diverges from theirs, that test fails.
|
|
29160
|
+
*/
|
|
29161
|
+
var HAP_NAME_PATTERN = /^[\p{L}\p{N}][\p{L}\p{N}\p{Zs}’'&!._:;()/,-]*[\p{L}\p{N}]$/u;
|
|
29162
|
+
/** Characters HAP tolerates INSIDE a name. Anything else becomes a space. */
|
|
29163
|
+
var HAP_NAME_INNER = /[^\p{L}\p{N}\p{Zs}’'&!._:;()/,-]/gu;
|
|
29164
|
+
/** Would hap-nodejs accept this as a `Name` characteristic value? */
|
|
29165
|
+
function isHapServiceName(value) {
|
|
29166
|
+
return HAP_NAME_PATTERN.test(value);
|
|
29167
|
+
}
|
|
29168
|
+
/**
|
|
29169
|
+
* Build one HAP-valid service name from device-derived parts.
|
|
29170
|
+
*
|
|
29171
|
+
* Empty and absent parts are dropped rather than joined, so a missing role
|
|
29172
|
+
* never produces a double space. `fallback` is used ONLY when nothing
|
|
29173
|
+
* device-derived survives sanitisation — it is the last resort, not the
|
|
29174
|
+
* default, because a name that says nothing about the device is the defect
|
|
29175
|
+
* this module exists to end.
|
|
29176
|
+
*/
|
|
29177
|
+
function hapServiceName(parts, fallback) {
|
|
29178
|
+
const trimmed = trimToHapName(parts.map((part) => typeof part === "string" ? part : "").map((part) => part.replace(HAP_NAME_INNER, " ")).join(" ").replace(/\s+/gu, " ").trim());
|
|
29179
|
+
if (trimmed !== null) return trimmed;
|
|
29180
|
+
return trimToHapName(fallback.replace(HAP_NAME_INNER, " ").replace(/\s+/gu, " ").trim()) ?? "";
|
|
29181
|
+
}
|
|
29182
|
+
/**
|
|
29183
|
+
* The privacy-mask switch.
|
|
29184
|
+
*
|
|
29185
|
+
* Just "Privacy". The camera name is NOT prefixed: this service lives inside
|
|
29186
|
+
* the camera's own accessory, iOS already renders it under the camera, and a
|
|
29187
|
+
* round that prefixed it gave the operator "Videocamera ingresso Privacy"
|
|
29188
|
+
* sitting inside a tile titled "Videocamera ingresso".
|
|
29189
|
+
*
|
|
29190
|
+
* The prefix was added for a real reason — two cameras publishing a switch
|
|
29191
|
+
* called "Privacy" — but that was a symptom of the label being the ONLY thing
|
|
29192
|
+
* shown, which stopped being true once `ConfiguredName` made the service
|
|
29193
|
+
* render in its accessory's context. Uniqueness is required WITHIN one
|
|
29194
|
+
* accessory, not across the bridge, and one camera has one privacy switch.
|
|
29195
|
+
*/
|
|
29196
|
+
function privacyServiceName() {
|
|
29197
|
+
return hapServiceName([PRIVACY_SUFFIX], PRIVACY_SUFFIX);
|
|
29198
|
+
}
|
|
29199
|
+
/**
|
|
29200
|
+
* Deliberately not localised, and deliberately not a translation table.
|
|
29201
|
+
*
|
|
29202
|
+
* The device half of the name is the operator's own text and arrives in the
|
|
29203
|
+
* operator's language. This half names a camstack capability (`privacy-mask`)
|
|
29204
|
+
* and there is no locale in an addon's context to resolve it against; the word
|
|
29205
|
+
* is also identical in the operator's language. A translation layer for one
|
|
29206
|
+
* word would be the kind of leftover that reads as verification.
|
|
29207
|
+
*/
|
|
29208
|
+
var PRIVACY_SUFFIX = "Privacy";
|
|
29209
|
+
/**
|
|
29210
|
+
* An accessory child (siren, floodlight, spotlight) rendered as a service on
|
|
29211
|
+
* the parent camera.
|
|
29212
|
+
*
|
|
29213
|
+
* The child's OWN stored name wins. It is the string the operator typed, in
|
|
29214
|
+
* the operator's language, and an early rule threw it away: `role` was
|
|
29215
|
+
* consulted first and title-cased, so every siren on the fleet published as
|
|
29216
|
+
* the English word "Siren" no matter what the operator had called it.
|
|
29217
|
+
*
|
|
29218
|
+
* Providers name children both ways — "Sirena" and "Videocamera cucina
|
|
29219
|
+
* Sirena". The parent half is now REMOVED rather than added, because the
|
|
29220
|
+
* service is published inside the parent camera's own accessory and iOS
|
|
29221
|
+
* already shows it there. The result must be one form, not two.
|
|
29222
|
+
*/
|
|
29223
|
+
function childServiceName(parentName, child) {
|
|
29224
|
+
const own = withoutParent(child.name.trim(), parentName);
|
|
29225
|
+
if (own.length > 0) return hapServiceName([own], own);
|
|
29226
|
+
return hapServiceName([typeof child.role === "string" ? titleCase(child.role) : ""], CHILD_FALLBACK);
|
|
29227
|
+
}
|
|
29228
|
+
/**
|
|
29229
|
+
* Last resort for a child that carries neither a name nor a role. Better than
|
|
29230
|
+
* the parent's name, which would publish a service indistinguishable from the
|
|
29231
|
+
* accessory holding it — the exact defect this module keeps being asked to fix.
|
|
29232
|
+
*
|
|
29233
|
+
* English, like the role slugs it stands in for ("Floodlight", "Siren"): the
|
|
29234
|
+
* only strings this module invents are English, and inventing one Italian word
|
|
29235
|
+
* would be a localisation layer that localises nothing.
|
|
29236
|
+
*/
|
|
29237
|
+
var CHILD_FALLBACK = "Accessory";
|
|
29238
|
+
/**
|
|
29239
|
+
* A PTZ action switch: the bare action, "Preset ingresso" / "Pan Left" /
|
|
29240
|
+
* "Autotrack".
|
|
29241
|
+
*
|
|
29242
|
+
* The labels themselves stay in `ptz-labels.ts` — they name a HomeKit control,
|
|
29243
|
+
* not a device. This function exists only to put the operator-typed half of a
|
|
29244
|
+
* preset name through the same sanitisation everything else gets; it no longer
|
|
29245
|
+
* qualifies the label with the camera, because all eight PTZ services live on
|
|
29246
|
+
* that camera's accessory and are unique among themselves.
|
|
29247
|
+
*/
|
|
29248
|
+
function ptzServiceName(actionLabel) {
|
|
29249
|
+
return hapServiceName([actionLabel], actionLabel);
|
|
29250
|
+
}
|
|
29251
|
+
/**
|
|
29252
|
+
* Drop `parentName` from the front of `name`.
|
|
29253
|
+
*
|
|
29254
|
+
* A PREFIX only. "Videocamera cucina Sirena" → "Sirena"; "Sirena" is already
|
|
29255
|
+
* bare and untouched. A parent name appearing anywhere else in the child's
|
|
29256
|
+
* name is left alone — cutting from the middle of a string the operator typed
|
|
29257
|
+
* would mangle it, and this function must never make a label WORSE.
|
|
29258
|
+
*
|
|
29259
|
+
* Returns `name` unchanged when stripping would leave nothing: a child the
|
|
29260
|
+
* operator called exactly what the camera is called still needs a label.
|
|
29261
|
+
*/
|
|
29262
|
+
function withoutParent(name, parentName) {
|
|
29263
|
+
const needle = parentName.trim();
|
|
29264
|
+
if (needle.length === 0) return name;
|
|
29265
|
+
if (!name.toLowerCase().startsWith(needle.toLowerCase())) return name;
|
|
29266
|
+
const rest = name.slice(needle.length).trim();
|
|
29267
|
+
return rest.length > 0 ? rest : name;
|
|
29268
|
+
}
|
|
29269
|
+
/**
|
|
29270
|
+
* Truncate to the HAP ceiling and shave any leading/trailing character the
|
|
29271
|
+
* pattern forbids. Returns `null` when nothing usable is left — the caller
|
|
29272
|
+
* decides what to do with that, because "fall back" and "drop the part" are
|
|
29273
|
+
* different answers.
|
|
29274
|
+
*/
|
|
29275
|
+
function trimToHapName(value) {
|
|
29276
|
+
let out = value.length > 64 ? value.slice(0, 64) : value;
|
|
29277
|
+
while (out.length > 0 && !isAlphanumeric(out[out.length - 1])) out = out.slice(0, -1);
|
|
29278
|
+
while (out.length > 0 && !isAlphanumeric(out[0])) out = out.slice(1);
|
|
29279
|
+
return isHapServiceName(out) ? out : null;
|
|
29280
|
+
}
|
|
29281
|
+
function isAlphanumeric(ch) {
|
|
29282
|
+
return ch !== void 0 && /[\p{L}\p{N}]/u.test(ch);
|
|
29283
|
+
}
|
|
29284
|
+
function titleCase(raw) {
|
|
29285
|
+
return raw.split(/[-_\s]+/u).filter((part) => part.length > 0).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
|
|
29286
|
+
}
|
|
27971
29287
|
//#endregion
|
|
27972
29288
|
//#region src/mappers/builders/battery.ts
|
|
27973
29289
|
/**
|
|
@@ -27992,12 +29308,12 @@ var LOW_BATTERY_THRESHOLD_PCT = 20;
|
|
|
27992
29308
|
async function buildBattery(bctx) {
|
|
27993
29309
|
const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
|
|
27994
29310
|
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
27995
|
-
const service = accessory.addService(_homebridge_hap_nodejs.Service.Battery, displayName);
|
|
29311
|
+
const service = accessory.addService(_homebridge_hap_nodejs.Service.Battery, hapServiceName([displayName], `Camera ${numericDeviceId}`));
|
|
27996
29312
|
try {
|
|
27997
29313
|
const status = await proxy.battery?.getStatus({});
|
|
27998
29314
|
if (status) applyToService(service, status);
|
|
27999
29315
|
} catch (err) {
|
|
28000
|
-
log.debug("export-hap: battery getStatus hydrate failed (non-fatal)", { meta: { error: errMsg$
|
|
29316
|
+
log.debug("export-hap: battery getStatus hydrate failed (non-fatal)", { meta: { error: errMsg$10(err) } });
|
|
28001
29317
|
}
|
|
28002
29318
|
const unsubscribes = [];
|
|
28003
29319
|
if (proxy.state.battery) {
|
|
@@ -28023,7 +29339,7 @@ function applyToService(service, status) {
|
|
|
28023
29339
|
const lowBattery = pct <= LOW_BATTERY_THRESHOLD_PCT ? _homebridge_hap_nodejs.Characteristic.StatusLowBattery.BATTERY_LEVEL_LOW : _homebridge_hap_nodejs.Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL;
|
|
28024
29340
|
service.updateCharacteristic(_homebridge_hap_nodejs.Characteristic.StatusLowBattery, lowBattery);
|
|
28025
29341
|
}
|
|
28026
|
-
function errMsg$
|
|
29342
|
+
function errMsg$10(err) {
|
|
28027
29343
|
return err instanceof Error ? err.message : String(err);
|
|
28028
29344
|
}
|
|
28029
29345
|
//#endregion
|
|
@@ -38354,6 +39670,856 @@ function makeRtcpGate(socket, timeoutMs) {
|
|
|
38354
39670
|
socket.on("message", onMessage);
|
|
38355
39671
|
});
|
|
38356
39672
|
}
|
|
39673
|
+
/** V/P/RC byte + PT byte + 16-bit length. */
|
|
39674
|
+
var RTCP_HEADER_BYTES = 4;
|
|
39675
|
+
/** SSRC of the packet sender, first field of both SR and RR bodies. */
|
|
39676
|
+
var RTCP_SENDER_SSRC_BYTES = 4;
|
|
39677
|
+
/** NTP + RTP timestamp, packet count, octet count — present only on an SR. */
|
|
39678
|
+
var RTCP_SENDER_INFO_BYTES = 20;
|
|
39679
|
+
/** One report block: SSRC_n, loss, highest seq, jitter, LSR, DLSR. */
|
|
39680
|
+
var RTCP_REPORT_BLOCK_BYTES = 24;
|
|
39681
|
+
/** `fraction lost` is an 8-bit fixed-point fraction of 256. */
|
|
39682
|
+
var FRACTION_LOST_DENOMINATOR = 256;
|
|
39683
|
+
/** `delay since last SR` counts 1/65536 of a second. */
|
|
39684
|
+
var DLSR_UNITS_PER_SECOND = 65536;
|
|
39685
|
+
/** `cumulative number of packets lost` is a SIGNED 24-bit field. */
|
|
39686
|
+
var SIGNED_24_SIGN_BIT = 8388608;
|
|
39687
|
+
var SIGNED_24_MODULUS = 16777216;
|
|
39688
|
+
function readSigned24(packet, at) {
|
|
39689
|
+
const raw = packet[at] << 16 | packet[at + 1] << 8 | packet[at + 2];
|
|
39690
|
+
return raw >= SIGNED_24_SIGN_BIT ? raw - SIGNED_24_MODULUS : raw;
|
|
39691
|
+
}
|
|
39692
|
+
function readReportBlock(packet, at) {
|
|
39693
|
+
const fractionLostRaw = packet[at + 4];
|
|
39694
|
+
const dlsr = packet.readUInt32BE(at + 20);
|
|
39695
|
+
return {
|
|
39696
|
+
aboutSsrc: packet.readUInt32BE(at),
|
|
39697
|
+
fractionLostRaw,
|
|
39698
|
+
fractionLostPct: Math.round(fractionLostRaw / FRACTION_LOST_DENOMINATOR * 1e3) / 10,
|
|
39699
|
+
cumulativePacketsLost: readSigned24(packet, at + 5),
|
|
39700
|
+
extendedHighestSequence: packet.readUInt32BE(at + 8),
|
|
39701
|
+
jitter: packet.readUInt32BE(at + 12),
|
|
39702
|
+
lastSrTimestamp: packet.readUInt32BE(at + 16),
|
|
39703
|
+
delaySinceLastSrMs: Math.round(dlsr / DLSR_UNITS_PER_SECOND * 1e3)
|
|
39704
|
+
};
|
|
39705
|
+
}
|
|
39706
|
+
/**
|
|
39707
|
+
* Walk a (possibly compound) RTCP datagram and extract every report block.
|
|
39708
|
+
*
|
|
39709
|
+
* An RR is routinely bundled behind an SDES, and a controller with nothing to
|
|
39710
|
+
* report yet sends an RR with a reception-report count of zero. Both are
|
|
39711
|
+
* normal; neither is a failure. What IS a failure is a packet whose declared
|
|
39712
|
+
* length runs past the buffer or whose report count exceeds its own body —
|
|
39713
|
+
* those get named so a decrypt fault cannot masquerade as silence.
|
|
39714
|
+
*/
|
|
39715
|
+
function parseCompoundRtcp(packet) {
|
|
39716
|
+
if (packet.length === 0) return {
|
|
39717
|
+
ok: false,
|
|
39718
|
+
failure: "empty",
|
|
39719
|
+
atOffset: 0
|
|
39720
|
+
};
|
|
39721
|
+
const packetTypes = [];
|
|
39722
|
+
const reports = [];
|
|
39723
|
+
let offset = 0;
|
|
39724
|
+
while (offset < packet.length) {
|
|
39725
|
+
if (packet.length - offset < RTCP_HEADER_BYTES) return {
|
|
39726
|
+
ok: false,
|
|
39727
|
+
failure: "short-header",
|
|
39728
|
+
atOffset: offset
|
|
39729
|
+
};
|
|
39730
|
+
const firstByte = packet[offset];
|
|
39731
|
+
if ((firstByte >> 6 & 3) !== 2) return {
|
|
39732
|
+
ok: false,
|
|
39733
|
+
failure: "bad-version",
|
|
39734
|
+
atOffset: offset
|
|
39735
|
+
};
|
|
39736
|
+
const reportCount = firstByte & 31;
|
|
39737
|
+
const packetType = packet[offset + 1];
|
|
39738
|
+
const totalBytes = (packet.readUInt16BE(offset + 2) + 1) * 4;
|
|
39739
|
+
if (offset + totalBytes > packet.length) return {
|
|
39740
|
+
ok: false,
|
|
39741
|
+
failure: "length-overrun",
|
|
39742
|
+
atOffset: offset
|
|
39743
|
+
};
|
|
39744
|
+
packetTypes.push(packetType);
|
|
39745
|
+
if (packetType === 201 || packetType === 200) {
|
|
39746
|
+
const senderSsrcAt = offset + RTCP_HEADER_BYTES;
|
|
39747
|
+
const blocksAt = senderSsrcAt + RTCP_SENDER_SSRC_BYTES + (packetType === 200 ? RTCP_SENDER_INFO_BYTES : 0);
|
|
39748
|
+
if (blocksAt + reportCount * RTCP_REPORT_BLOCK_BYTES > offset + totalBytes) return {
|
|
39749
|
+
ok: false,
|
|
39750
|
+
failure: "truncated-body",
|
|
39751
|
+
atOffset: offset
|
|
39752
|
+
};
|
|
39753
|
+
const blocks = [];
|
|
39754
|
+
for (let index = 0; index < reportCount; index += 1) blocks.push(readReportBlock(packet, blocksAt + index * RTCP_REPORT_BLOCK_BYTES));
|
|
39755
|
+
reports.push({
|
|
39756
|
+
packetType,
|
|
39757
|
+
reporterSsrc: packet.readUInt32BE(senderSsrcAt),
|
|
39758
|
+
blocks
|
|
39759
|
+
});
|
|
39760
|
+
}
|
|
39761
|
+
offset += totalBytes;
|
|
39762
|
+
}
|
|
39763
|
+
return {
|
|
39764
|
+
ok: true,
|
|
39765
|
+
packetTypes,
|
|
39766
|
+
reports
|
|
39767
|
+
};
|
|
39768
|
+
}
|
|
39769
|
+
function emptyReceiverReportTally() {
|
|
39770
|
+
return {
|
|
39771
|
+
reportsParsed: 0,
|
|
39772
|
+
blocksParsed: 0,
|
|
39773
|
+
unreadable: 0,
|
|
39774
|
+
lastFractionLostPct: null,
|
|
39775
|
+
worstFractionLostPct: null,
|
|
39776
|
+
lastCumulativePacketsLost: null,
|
|
39777
|
+
maxCumulativePacketsLost: null,
|
|
39778
|
+
lastJitter: null,
|
|
39779
|
+
maxJitter: null,
|
|
39780
|
+
lastExtendedHighestSequence: null
|
|
39781
|
+
};
|
|
39782
|
+
}
|
|
39783
|
+
function maxOrValue(previous, next) {
|
|
39784
|
+
return previous === null ? next : Math.max(previous, next);
|
|
39785
|
+
}
|
|
39786
|
+
/** Fold one report block into the tally. Returns a new tally. */
|
|
39787
|
+
function applyReceiverReportBlock(tally, block) {
|
|
39788
|
+
return {
|
|
39789
|
+
...tally,
|
|
39790
|
+
blocksParsed: tally.blocksParsed + 1,
|
|
39791
|
+
lastFractionLostPct: block.fractionLostPct,
|
|
39792
|
+
worstFractionLostPct: maxOrValue(tally.worstFractionLostPct, block.fractionLostPct),
|
|
39793
|
+
lastCumulativePacketsLost: block.cumulativePacketsLost,
|
|
39794
|
+
maxCumulativePacketsLost: maxOrValue(tally.maxCumulativePacketsLost, block.cumulativePacketsLost),
|
|
39795
|
+
lastJitter: block.jitter,
|
|
39796
|
+
maxJitter: maxOrValue(tally.maxJitter, block.jitter),
|
|
39797
|
+
lastExtendedHighestSequence: block.extendedHighestSequence
|
|
39798
|
+
};
|
|
39799
|
+
}
|
|
39800
|
+
/** Book an RTCP datagram we could not read. Counted, never discarded silently. */
|
|
39801
|
+
function recordUnreadableRtcp(tally) {
|
|
39802
|
+
return {
|
|
39803
|
+
...tally,
|
|
39804
|
+
unreadable: tally.unreadable + 1
|
|
39805
|
+
};
|
|
39806
|
+
}
|
|
39807
|
+
/**
|
|
39808
|
+
* Parse one decrypted RTCP datagram and fold it into a leg's tally.
|
|
39809
|
+
*
|
|
39810
|
+
* This is the seam the delegate calls: it owns SRTCP decryption and logging,
|
|
39811
|
+
* this owns everything that can be asserted from bytes alone. A malformed
|
|
39812
|
+
* packet returns a failure and an incremented `unreadable` — it never throws,
|
|
39813
|
+
* because this runs on a UDP `message` handler where a throw would take the
|
|
39814
|
+
* session with it.
|
|
39815
|
+
*/
|
|
39816
|
+
function ingestDecryptedRtcp(plaintext, tally) {
|
|
39817
|
+
const parsed = parseCompoundRtcp(plaintext);
|
|
39818
|
+
if (!parsed.ok) return {
|
|
39819
|
+
tally: recordUnreadableRtcp(tally),
|
|
39820
|
+
reports: [],
|
|
39821
|
+
failure: parsed.failure
|
|
39822
|
+
};
|
|
39823
|
+
let next = tally;
|
|
39824
|
+
for (const report of parsed.reports) {
|
|
39825
|
+
if (report.blocks.length === 0) continue;
|
|
39826
|
+
next = {
|
|
39827
|
+
...next,
|
|
39828
|
+
reportsParsed: next.reportsParsed + 1
|
|
39829
|
+
};
|
|
39830
|
+
for (const block of report.blocks) next = applyReceiverReportBlock(next, block);
|
|
39831
|
+
}
|
|
39832
|
+
return {
|
|
39833
|
+
tally: next,
|
|
39834
|
+
reports: parsed.reports,
|
|
39835
|
+
failure: null
|
|
39836
|
+
};
|
|
39837
|
+
}
|
|
39838
|
+
function classifyConnection(input) {
|
|
39839
|
+
if (input.negotiatedWidth < 640) return "watch";
|
|
39840
|
+
if (input.audioPacketTimeMs >= 60) return "remote";
|
|
39841
|
+
return input.viaHomeHub ? "home-hub" : "local";
|
|
39842
|
+
}
|
|
39843
|
+
/**
|
|
39844
|
+
* The slot each class asks for.
|
|
39845
|
+
*
|
|
39846
|
+
* ## `local` takes the camera's best stream — settled by measurement
|
|
39847
|
+
*
|
|
39848
|
+
* This function was pinned to `low` for EVERY class by one number. On 615/high,
|
|
39849
|
+
* 3840x2160 pass-through:
|
|
39850
|
+
*
|
|
39851
|
+
* durationMs=30820 videoPacketsForwarded=93 videoKeyframes=1
|
|
39852
|
+
* audioPacketsForwarded=1497 lost=0
|
|
39853
|
+
*
|
|
39854
|
+
* Three video datagrams a second, one key frame in half a minute, while the
|
|
39855
|
+
* AUDIO leg of the *same* ffmpeg ran perfectly. It read as "our path cannot
|
|
39856
|
+
* carry a high-bitrate stream".
|
|
39857
|
+
*
|
|
39858
|
+
* **It was not HomeKit, not 4K and not SRTP. The loopback UDP socket ffmpeg
|
|
39859
|
+
* writes its RTP into had no `SO_RCVBUF` at all** (2026-08-07). It ran on
|
|
39860
|
+
* `net.core.rmem_default`, 212 992 B — about a fifth of one 4K IDR, which
|
|
39861
|
+
* arrives as ~750 datagrams at `pkt_size=1378` in a single burst. The kernel
|
|
39862
|
+
* discarded the overflow, and a datagram dropped there never reaches a
|
|
39863
|
+
* `message` handler, so it lowered the forwarded count exactly like a packet
|
|
39864
|
+
* ffmpeg never wrote and the controller reported no loss for it either. Audio,
|
|
39865
|
+
* a few hundred bytes every 20 ms, never filled the buffer. That is the whole
|
|
39866
|
+
* asymmetry. See `stream-socket-buffer.ts`.
|
|
39867
|
+
*
|
|
39868
|
+
* With an 8 MiB buffer (granted — this hub's `net.core.rmem_max` is 16 MiB),
|
|
39869
|
+
* the same camera and the same slot, session
|
|
39870
|
+
* `12308100-7dbe-4ad1-b277-1f046ba54ec2` on 2026-08-07:
|
|
39871
|
+
*
|
|
39872
|
+
* selectedProfile=high transcode=false slotMeasuredKbps=5097
|
|
39873
|
+
* videoPacketsForwarded=6512 durationMs=9867 (~660/s, was ~3/s)
|
|
39874
|
+
* msToFirstKeyframe=858 deliveredFps=24 worstFractionLostPct=0.4
|
|
39875
|
+
* videoLoopRcvbufBytes=16777216 clamped=false
|
|
39876
|
+
*
|
|
39877
|
+
* Operator: loaded instantly, and visibly not the low stream. A 220x increase
|
|
39878
|
+
* in delivered packet rate from sizing one socket.
|
|
39879
|
+
*
|
|
39880
|
+
* ## Why the remote classes stay `low`
|
|
39881
|
+
*
|
|
39882
|
+
* Not caution left over from the freeze — a different, UNMEASURED question.
|
|
39883
|
+
* `watch`, `remote` and `home-hub` all send video across a link whose budget
|
|
39884
|
+
* nothing here has measured; the buffer fix says something about a loopback hop
|
|
39885
|
+
* inside one host and nothing whatsoever about a WAN. 4K pass-through at ~5 Mbps
|
|
39886
|
+
* to a phone on LTE is a decision that needs its own evidence, and `watch` has
|
|
39887
|
+
* a panel under 640 px wide that could not use the pixels anyway. Raise these
|
|
39888
|
+
* only with a measurement of the remote link, not by analogy with this one.
|
|
39889
|
+
*
|
|
39890
|
+
* `mid` remains excluded from every class, unrelated to all of the above: it is
|
|
39891
|
+
* a 10 fps stream on this fleet and it has never rendered under any combination
|
|
39892
|
+
* tried.
|
|
39893
|
+
*/
|
|
39894
|
+
function slotForConnection(connection) {
|
|
39895
|
+
switch (connection) {
|
|
39896
|
+
case "watch": return "low";
|
|
39897
|
+
case "remote": return "low";
|
|
39898
|
+
case "home-hub": return "low";
|
|
39899
|
+
case "local": return "high";
|
|
39900
|
+
}
|
|
39901
|
+
}
|
|
39902
|
+
//#endregion
|
|
39903
|
+
//#region src/mappers/builders/stream-bitrate.ts
|
|
39904
|
+
/**
|
|
39905
|
+
* Send a stream that FITS the rate HomeKit negotiated. (R5)
|
|
39906
|
+
*
|
|
39907
|
+
* The controller's own Receiver Reports, read on the live hub on 2026-08-06,
|
|
39908
|
+
* closed a year of guessing: one 19.27 s session on `615/mid` forwarded 737
|
|
39909
|
+
* video packets at `mtu=1378` — roughly **421 kbps** — against a negotiated
|
|
39910
|
+
* `max_bit_rate` of **299**, and iOS reported losing **450 of those 737
|
|
39911
|
+
* packets (61 %)**, worst fraction lost 51.2 %, peak jitter 3.03 s. Under
|
|
39912
|
+
* `-c:v copy` the accessory has no lever at all: it forwards whatever the
|
|
39913
|
+
* camera's encoder produces, at whatever cadence it produces it.
|
|
39914
|
+
*
|
|
39915
|
+
* So the fix has two halves, and this module owns both:
|
|
39916
|
+
*
|
|
39917
|
+
* 1. **Choose a slot that fits.** Among the slots that can be passed through
|
|
39918
|
+
* (H.264) and whose rate is known to be within budget, the existing
|
|
39919
|
+
* resolution-closest picker decides — so the "which slot serves which
|
|
39920
|
+
* resolution" opinion stays single, exactly as D51 requires.
|
|
39921
|
+
* 2. **Transcode only when none does**, with a real cap
|
|
39922
|
+
* (`-b:v` / `-maxrate` / `-bufsize`) at the negotiated rate.
|
|
39923
|
+
*
|
|
39924
|
+
* ## Where the authoritative rate comes from, and why it is NOT the obvious one
|
|
39925
|
+
*
|
|
39926
|
+
* `webrtcSession.listStreams` reports a `bitrateKbps` per slot and it is a
|
|
39927
|
+
* **measured flow rate**, which is meaningless for a slot nobody is consuming.
|
|
39928
|
+
* Live on 2026-08-06 it reported `mid = 9 kbps` for the very slot that had just
|
|
39929
|
+
* delivered ~421 kbps, `low = 5 kbps`, and `high = 5441 kbps` (high was being
|
|
39930
|
+
* consumed, hence plausible). Selecting on that reading would admit every slot.
|
|
39931
|
+
*
|
|
39932
|
+
* The authority is therefore the camera's **configured** encoder rate, from
|
|
39933
|
+
* `streamParams.getStatus` — `main` / `sub` / `ext`, each carrying the
|
|
39934
|
+
* `bitrate` the operator (or the vendor default) set. On 615 that is
|
|
39935
|
+
* `main 8192`, `sub 2048`, `ext 2048` kbps. It is mapped onto a profile slot
|
|
39936
|
+
* through the slot's assigned cam-stream, matched on resolution and frame
|
|
39937
|
+
* rate; an ambiguous or absent match is reported as **unknown**, never as a
|
|
39938
|
+
* number.
|
|
39939
|
+
*
|
|
39940
|
+
* This inverts D51's ordering — there, `measured` outranks `published` — and
|
|
39941
|
+
* the inversion is deliberate:
|
|
39942
|
+
*
|
|
39943
|
+
* - a frame rate is a stable property of the source and a measurement of it
|
|
39944
|
+
* is the *best* evidence;
|
|
39945
|
+
* - a bitrate under VBR is an envelope. A measurement is a **lower bound**
|
|
39946
|
+
* on it, and a lower bound can prove a slot does NOT fit but can never
|
|
39947
|
+
* prove that it does.
|
|
39948
|
+
*
|
|
39949
|
+
* So `measured` is kept, and used only in the direction it is sound in.
|
|
39950
|
+
* Everything here is pure; the cap reads live in `stream-bitrate-probe.ts`.
|
|
39951
|
+
*/
|
|
39952
|
+
/**
|
|
39953
|
+
* Fraction of the negotiated ceiling we actually aim the encoder at.
|
|
39954
|
+
*
|
|
39955
|
+
* `max_bit_rate` is what the controller budgeted for the stream; what crosses
|
|
39956
|
+
* the wire is the encoded payload PLUS its packetisation. At `mtu = 1378` each
|
|
39957
|
+
* packet carries a 12-byte RTP header and a 10-byte SRTP auth tag, and the
|
|
39958
|
+
* datagram adds 8 (UDP) + 20 (IPv4) — 50 bytes on ~1378, i.e. **3.6 %**. The
|
|
39959
|
+
* remaining ~6 % is margin for a VBV overshoot inside the buffer window.
|
|
39960
|
+
* Reserving it is the difference between "at the ceiling" and "over it".
|
|
39961
|
+
*/
|
|
39962
|
+
var BITRATE_HEADROOM = .9;
|
|
39963
|
+
/** Encoder slots a `stream-params` provider exposes, in the cap's own order. */
|
|
39964
|
+
var ENCODER_PROFILE_KEYS = [
|
|
39965
|
+
"main",
|
|
39966
|
+
"sub",
|
|
39967
|
+
"ext"
|
|
39968
|
+
];
|
|
39969
|
+
/**
|
|
39970
|
+
* Resolve every profile slot's rate from the camera's configuration, with the
|
|
39971
|
+
* broker's flow reading kept alongside it as a lower bound.
|
|
39972
|
+
*
|
|
39973
|
+
* A slot with neither simply carries two nulls — absence is never rendered as
|
|
39974
|
+
* a number, because a wrong number here silently re-creates the overshoot this
|
|
39975
|
+
* module exists to end.
|
|
39976
|
+
*/
|
|
39977
|
+
function resolveProfileBitrates(input) {
|
|
39978
|
+
const camStreamById = /* @__PURE__ */ new Map();
|
|
39979
|
+
for (const stream of input.camStreams) camStreamById.set(stream.camStreamId, stream);
|
|
39980
|
+
const measuredByProfile = /* @__PURE__ */ new Map();
|
|
39981
|
+
for (const choice of input.choices) {
|
|
39982
|
+
if (choice.target.kind !== "profile") continue;
|
|
39983
|
+
const plausible = plausibleMeasured(choice.bitrateKbps);
|
|
39984
|
+
if (plausible !== null) measuredByProfile.set(choice.target.profile, plausible);
|
|
39985
|
+
}
|
|
39986
|
+
const out = /* @__PURE__ */ new Map();
|
|
39987
|
+
for (const slot of input.slots) {
|
|
39988
|
+
const source = slot.sourceCamStreamId === null ? void 0 : camStreamById.get(slot.sourceCamStreamId);
|
|
39989
|
+
out.set(slot.profile, {
|
|
39990
|
+
profile: slot.profile,
|
|
39991
|
+
publishedKbps: publishedRateFor(slot, source, input.streamParams),
|
|
39992
|
+
measuredKbps: measuredByProfile.get(slot.profile) ?? null
|
|
39993
|
+
});
|
|
39994
|
+
}
|
|
39995
|
+
for (const [profile, measured] of measuredByProfile) {
|
|
39996
|
+
if (out.has(profile)) continue;
|
|
39997
|
+
out.set(profile, {
|
|
39998
|
+
profile,
|
|
39999
|
+
publishedKbps: null,
|
|
40000
|
+
measuredKbps: measured
|
|
40001
|
+
});
|
|
40002
|
+
}
|
|
40003
|
+
return out;
|
|
40004
|
+
}
|
|
40005
|
+
/**
|
|
40006
|
+
* The rate the encoder may actually use, or `null` when the controller
|
|
40007
|
+
* negotiated no usable ceiling.
|
|
40008
|
+
*/
|
|
40009
|
+
function budgetForNegotiatedRate(negotiatedMaxBitrateKbps) {
|
|
40010
|
+
if (!Number.isFinite(negotiatedMaxBitrateKbps) || negotiatedMaxBitrateKbps <= 0) return null;
|
|
40011
|
+
const budget = Math.floor(negotiatedMaxBitrateKbps * BITRATE_HEADROOM);
|
|
40012
|
+
return budget > 0 ? budget : null;
|
|
40013
|
+
}
|
|
40014
|
+
/**
|
|
40015
|
+
* Does this slot fit?
|
|
40016
|
+
*
|
|
40017
|
+
* Only the CONFIGURED rate can answer yes. The measured rate is a lower bound,
|
|
40018
|
+
* so it is allowed to answer no — including against an optimistic publication.
|
|
40019
|
+
*/
|
|
40020
|
+
function classifyBitrateFit(evidence, budgetKbps) {
|
|
40021
|
+
if (evidence === void 0) return "unknown";
|
|
40022
|
+
const { publishedKbps, measuredKbps } = evidence;
|
|
40023
|
+
if (measuredKbps !== null && measuredKbps > budgetKbps) return "over-budget";
|
|
40024
|
+
if (publishedKbps === null) return "unknown";
|
|
40025
|
+
return publishedKbps <= budgetKbps ? "fits" : "over-budget";
|
|
40026
|
+
}
|
|
40027
|
+
/**
|
|
40028
|
+
* Pick the stream to serve, and decide whether it can be passed through.
|
|
40029
|
+
*
|
|
40030
|
+
* The selection runs the SAME `pickPreferredRtspEntry` the advertisement is
|
|
40031
|
+
* derived from (D51) — only the candidate set narrows. When at least one
|
|
40032
|
+
* pass-through-capable slot fits the budget, the picker resolves the target
|
|
40033
|
+
* resolution among those and we copy. Otherwise the picker resolves among ALL
|
|
40034
|
+
* entries — so the transcode decodes the slot closest to the negotiated
|
|
40035
|
+
* resolution rather than the largest one on the camera — and we re-encode.
|
|
40036
|
+
*
|
|
40037
|
+
* A pinned `streamPreference` is never overridden by the budget: the pinned
|
|
40038
|
+
* slot is transcoded rather than swapped for a cheaper one.
|
|
40039
|
+
*
|
|
40040
|
+
* Returns `null` when nothing is publishable at all.
|
|
40041
|
+
*/
|
|
40042
|
+
function selectStreamForBudget(input) {
|
|
40043
|
+
const budgetKbps = budgetForNegotiatedRate(input.negotiatedMaxBitrateKbps);
|
|
40044
|
+
const notes = fitNotes(input.entries, input.bitrates, budgetKbps);
|
|
40045
|
+
const effectivePref = input.pref === "auto" ? slotForConnection(input.connection) : input.pref;
|
|
40046
|
+
const fallback = pickPreferredRtspEntry(input.entries, effectivePref, input.deviceId, { targetResolution: input.targetResolution });
|
|
40047
|
+
if (fallback === null) return null;
|
|
40048
|
+
const fallbackProfile = toCamProfile$1(fallback.profileId);
|
|
40049
|
+
if (budgetKbps === null) {
|
|
40050
|
+
const base = {
|
|
40051
|
+
picked: fallback,
|
|
40052
|
+
profile: fallbackProfile,
|
|
40053
|
+
budgetKbps: null,
|
|
40054
|
+
notes
|
|
40055
|
+
};
|
|
40056
|
+
return canPassThrough(fallback.codec) ? {
|
|
40057
|
+
kind: "copy",
|
|
40058
|
+
reason: "no-negotiated-budget",
|
|
40059
|
+
...base
|
|
40060
|
+
} : {
|
|
40061
|
+
kind: "transcode",
|
|
40062
|
+
reason: "source-codec",
|
|
40063
|
+
...base
|
|
40064
|
+
};
|
|
40065
|
+
}
|
|
40066
|
+
const affordable = (input.pref !== "auto" && fallbackProfile === input.pref ? input.entries.filter((entry) => entry.profile === fallbackProfile) : input.entries).filter((entry) => {
|
|
40067
|
+
if (!canPassThrough(entry.codec)) return false;
|
|
40068
|
+
return classifyBitrateFit(input.bitrates.get(entry.profile), budgetKbps) === "fits";
|
|
40069
|
+
});
|
|
40070
|
+
if (affordable.length > 0) {
|
|
40071
|
+
const picked = pickPreferredRtspEntry(affordable, effectivePref, input.deviceId, { targetResolution: input.targetResolution });
|
|
40072
|
+
if (picked !== null) return {
|
|
40073
|
+
kind: "copy",
|
|
40074
|
+
reason: "source-fits-budget",
|
|
40075
|
+
picked,
|
|
40076
|
+
profile: toCamProfile$1(picked.profileId),
|
|
40077
|
+
budgetKbps,
|
|
40078
|
+
notes
|
|
40079
|
+
};
|
|
40080
|
+
}
|
|
40081
|
+
if (canPassThrough(fallback.codec)) return {
|
|
40082
|
+
kind: "copy",
|
|
40083
|
+
reason: "over-budget-tolerated",
|
|
40084
|
+
picked: fallback,
|
|
40085
|
+
profile: fallbackProfile,
|
|
40086
|
+
budgetKbps,
|
|
40087
|
+
notes
|
|
40088
|
+
};
|
|
40089
|
+
return {
|
|
40090
|
+
kind: "transcode",
|
|
40091
|
+
reason: transcodeReason(fallback, fallbackProfile, input.bitrates, budgetKbps),
|
|
40092
|
+
picked: fallback,
|
|
40093
|
+
profile: fallbackProfile,
|
|
40094
|
+
budgetKbps,
|
|
40095
|
+
notes
|
|
40096
|
+
};
|
|
40097
|
+
}
|
|
40098
|
+
/**
|
|
40099
|
+
* Fill in a codec the profile restream entry did not carry, from its broker
|
|
40100
|
+
* slot. `getProfileRtspEntries` has historically omitted it for legacy
|
|
40101
|
+
* entries, and a slot whose codec is unknown must not be mistaken for H.264.
|
|
40102
|
+
*/
|
|
40103
|
+
function withSlotCodecs(entries, slots) {
|
|
40104
|
+
const codecByProfile = /* @__PURE__ */ new Map();
|
|
40105
|
+
for (const slot of slots) if (slot.codec !== void 0) codecByProfile.set(slot.profile, slot.codec);
|
|
40106
|
+
return entries.map((entry) => {
|
|
40107
|
+
if (entry.codec !== void 0) return entry;
|
|
40108
|
+
const codec = codecByProfile.get(entry.profile);
|
|
40109
|
+
return codec === void 0 ? entry : {
|
|
40110
|
+
...entry,
|
|
40111
|
+
codec
|
|
40112
|
+
};
|
|
40113
|
+
});
|
|
40114
|
+
}
|
|
40115
|
+
/**
|
|
40116
|
+
* The video half of the ffmpeg plan, in the SHARED vocabulary
|
|
40117
|
+
* (`@camstack/types` `ffmpeg/invocation.ts`). This function used to emit
|
|
40118
|
+
* arguments; it now describes them, and `buildFfmpegArgs` emits every one — the
|
|
40119
|
+
* repo keeps exactly one argv builder, and HomeKit stopped being an exception
|
|
40120
|
+
* to that (D67, `scripts/check-ffmpeg-primitive.ts` Rule 1).
|
|
40121
|
+
*
|
|
40122
|
+
* Nothing about the RESULT changed except the rescale spelling: `-s WxH` became
|
|
40123
|
+
* `-vf scale=W:H`. Equivalent for a plain rescale, and worth knowing because
|
|
40124
|
+
* the two are NOT interchangeable once another `-vf` is in play.
|
|
40125
|
+
*
|
|
40126
|
+
* Pass-through carries `-bsf:v dump_extra` so every IDR inlines its own
|
|
40127
|
+
* SPS/PPS — sources that publish parameter sets only in the RTSP SDP hand iOS
|
|
40128
|
+
* a keyframe it cannot decode otherwise.
|
|
40129
|
+
*
|
|
40130
|
+
* The transcode's cap is three flags, not one: `-b:v` is an average and on its
|
|
40131
|
+
* own permits exactly the burst that was measured. `-maxrate` plus a
|
|
40132
|
+
* **one-second** `-bufsize` bounds any one-second window at the negotiated
|
|
40133
|
+
* rate, which is also the only lever available on the 3.03 s peak jitter — the
|
|
40134
|
+
* VBV window is what forces x264 to size a key frame to fit rather than
|
|
40135
|
+
* emitting it as one tight burst. That window is {@link RATE_CONTROL_TIGHT},
|
|
40136
|
+
* the shared constant whose whole reason to exist is HomeKit's per-second
|
|
40137
|
+
* budget; the browser and Echo use the relaxed two-second one.
|
|
40138
|
+
*
|
|
40139
|
+
* **The encoder stays `libx264`, deliberately.** `h264_vaapi` / `h264_qsv`
|
|
40140
|
+
* carry their own rate-control model, do not accept `-profile:v baseline`, and
|
|
40141
|
+
* emit parameter sets on their own schedule rather than x264's — which puts the
|
|
40142
|
+
* two load-bearing flags below back in play, with no hardware here to prove
|
|
40143
|
+
* they still hold. Hardware DECODE is where the measured cost is.
|
|
40144
|
+
*/
|
|
40145
|
+
function buildVideoPlan(input) {
|
|
40146
|
+
if (!input.transcode) return {
|
|
40147
|
+
kind: "copy",
|
|
40148
|
+
bitstreamFilter: "dump_extra"
|
|
40149
|
+
};
|
|
40150
|
+
return {
|
|
40151
|
+
kind: "encode",
|
|
40152
|
+
encoder: "libx264",
|
|
40153
|
+
scale: {
|
|
40154
|
+
mode: "exact",
|
|
40155
|
+
width: input.width,
|
|
40156
|
+
height: input.height
|
|
40157
|
+
},
|
|
40158
|
+
preset: "ultrafast",
|
|
40159
|
+
tune: "zerolatency",
|
|
40160
|
+
profile: "baseline",
|
|
40161
|
+
level: "3.1",
|
|
40162
|
+
pixelFormat: "yuv420p",
|
|
40163
|
+
fps: input.fps,
|
|
40164
|
+
gopFrames: Math.max(1, Math.round(input.fps * 4)),
|
|
40165
|
+
...input.budgetKbps === null ? {} : {
|
|
40166
|
+
bitrateKbps: input.budgetKbps,
|
|
40167
|
+
rateControl: RATE_CONTROL_TIGHT
|
|
40168
|
+
},
|
|
40169
|
+
bitstreamFilter: "dump_extra"
|
|
40170
|
+
};
|
|
40171
|
+
}
|
|
40172
|
+
/** Compact `mid=2048pub/9meas:over-budget` rendering for a single log field. */
|
|
40173
|
+
function formatFitNotes(notes) {
|
|
40174
|
+
return notes.map((n) => `${n.profile}=${n.publishedKbps ?? "?"}pub/${n.measuredKbps ?? "?"}meas:${n.verdict}`);
|
|
40175
|
+
}
|
|
40176
|
+
function fitNotes(entries, bitrates, budgetKbps) {
|
|
40177
|
+
return entries.map((entry) => {
|
|
40178
|
+
const evidence = bitrates.get(entry.profile);
|
|
40179
|
+
return {
|
|
40180
|
+
profile: entry.profile,
|
|
40181
|
+
verdict: budgetKbps === null ? "unknown" : classifyBitrateFit(evidence, budgetKbps),
|
|
40182
|
+
publishedKbps: evidence?.publishedKbps ?? null,
|
|
40183
|
+
measuredKbps: evidence?.measuredKbps ?? null
|
|
40184
|
+
};
|
|
40185
|
+
});
|
|
40186
|
+
}
|
|
40187
|
+
function transcodeReason(picked, profile, bitrates, budgetKbps) {
|
|
40188
|
+
if (!canPassThrough(picked.codec)) return "source-codec";
|
|
40189
|
+
if (profile === null) return "unknown-bitrate";
|
|
40190
|
+
return classifyBitrateFit(bitrates.get(profile), budgetKbps) === "over-budget" ? "over-budget" : "unknown-bitrate";
|
|
40191
|
+
}
|
|
40192
|
+
/**
|
|
40193
|
+
* iOS Home renders only H.264 over the classic HAP SRTP path, so an H.265
|
|
40194
|
+
* source can never be passed through. An UNKNOWN codec is treated the same
|
|
40195
|
+
* way: guessing H.264 is how a camera that changed its encoder ends up
|
|
40196
|
+
* shipping bytes no controller can decode.
|
|
40197
|
+
*/
|
|
40198
|
+
function canPassThrough(codec) {
|
|
40199
|
+
if (codec === void 0) return false;
|
|
40200
|
+
const lower = codec.toLowerCase();
|
|
40201
|
+
if (lower.includes("h265") || lower.includes("hevc")) return false;
|
|
40202
|
+
return lower.includes("h264") || lower.includes("avc");
|
|
40203
|
+
}
|
|
40204
|
+
function plausibleMeasured(value) {
|
|
40205
|
+
if (value === null || !Number.isFinite(value)) return null;
|
|
40206
|
+
return value >= 64 ? value : null;
|
|
40207
|
+
}
|
|
40208
|
+
/**
|
|
40209
|
+
* Map a profile slot onto the camera encoder feeding it, and read that
|
|
40210
|
+
* encoder's configured bitrate.
|
|
40211
|
+
*
|
|
40212
|
+
* The link is the slot's assigned cam-stream: its resolution (and frame rate,
|
|
40213
|
+
* when two encoders share a resolution) identifies which of `main`/`sub`/`ext`
|
|
40214
|
+
* produces it. Vendor-neutral on purpose — the cam-stream ids (`native:main`,
|
|
40215
|
+
* `native:slot-3`, …) are provider strings and matching on them would work for
|
|
40216
|
+
* exactly one provider. An ambiguous match returns `null`.
|
|
40217
|
+
*/
|
|
40218
|
+
function publishedRateFor(slot, source, streamParams) {
|
|
40219
|
+
if (streamParams === null) return null;
|
|
40220
|
+
const resolution = source?.resolution ?? slot.resolution;
|
|
40221
|
+
if (resolution === void 0) return null;
|
|
40222
|
+
const byResolution = ENCODER_PROFILE_KEYS.map((key) => encoderConfig(streamParams, key)).filter((cfg) => cfg !== null && cfg.width === resolution.width && cfg.height === resolution.height);
|
|
40223
|
+
if (byResolution.length === 1) return positiveOrNull(byResolution[0]?.bitrate);
|
|
40224
|
+
const fps = source?.fps;
|
|
40225
|
+
if (fps === void 0) return null;
|
|
40226
|
+
const byFps = byResolution.filter((cfg) => cfg !== null && Math.floor(cfg.framerate) === Math.floor(fps));
|
|
40227
|
+
return byFps.length === 1 ? positiveOrNull(byFps[0]?.bitrate) : null;
|
|
40228
|
+
}
|
|
40229
|
+
function encoderConfig(status, key) {
|
|
40230
|
+
return status[key] ?? null;
|
|
40231
|
+
}
|
|
40232
|
+
function positiveOrNull(value) {
|
|
40233
|
+
if (value === void 0 || !Number.isFinite(value) || value <= 0) return null;
|
|
40234
|
+
return value;
|
|
40235
|
+
}
|
|
40236
|
+
var CAM_PROFILES$1 = [
|
|
40237
|
+
"high",
|
|
40238
|
+
"mid",
|
|
40239
|
+
"low"
|
|
40240
|
+
];
|
|
40241
|
+
/**
|
|
40242
|
+
* `PickedStream.profileId` is the brokerId suffix, which for a profile-keyed
|
|
40243
|
+
* entry IS the profile name. Anything else addresses a raw cam-stream and must
|
|
40244
|
+
* not be coerced into a profile.
|
|
40245
|
+
*/
|
|
40246
|
+
function toCamProfile$1(profileId) {
|
|
40247
|
+
return CAM_PROFILES$1.find((p) => p === profileId) ?? null;
|
|
40248
|
+
}
|
|
40249
|
+
//#endregion
|
|
40250
|
+
//#region src/mappers/builders/deadline.ts
|
|
40251
|
+
/**
|
|
40252
|
+
* Bound a piece of optional work in time.
|
|
40253
|
+
*
|
|
40254
|
+
* HomeKit answers `Selected RTP Stream Configuration` inside a write handler
|
|
40255
|
+
* hap-nodejs expects back quickly, and the start path behind it makes six
|
|
40256
|
+
* sequential cross-process cap calls into a stream-broker that regularly
|
|
40257
|
+
* freezes for two to three seconds at a time. Measured on the live hub: the
|
|
40258
|
+
* controller negotiated at :11, gave up at 9.1 s, and the bitrate fit resolved
|
|
40259
|
+
* at :32 — twenty-one seconds — with the start then failing on `Not running`
|
|
40260
|
+
* because the session it was preparing no longer existed.
|
|
40261
|
+
*
|
|
40262
|
+
* The evidence those calls gather is genuinely optional: an absent reading
|
|
40263
|
+
* classifies as `unknown`, and the tolerated branch still picks a slot. So the
|
|
40264
|
+
* right trade under load is to answer with less evidence rather than late, and
|
|
40265
|
+
* this makes that trade explicit at each call site instead of leaving it to
|
|
40266
|
+
* whatever the broker's latency happens to be.
|
|
40267
|
+
*
|
|
40268
|
+
* A late failure from work we stopped waiting on is swallowed on purpose: the
|
|
40269
|
+
* probe keeps running after the deadline fires, and an unhandled rejection
|
|
40270
|
+
* from an abandoned probe would take the process down over a reading nobody is
|
|
40271
|
+
* using any more.
|
|
40272
|
+
*/
|
|
40273
|
+
var TIMED_OUT = Symbol("deadline:timed-out");
|
|
40274
|
+
var FAILED = Symbol("deadline:failed");
|
|
40275
|
+
async function withDeadline(work, ms, fallback, onTimeout) {
|
|
40276
|
+
let timer;
|
|
40277
|
+
const guard = new Promise((resolve) => {
|
|
40278
|
+
timer = setTimeout(() => resolve(TIMED_OUT), ms);
|
|
40279
|
+
});
|
|
40280
|
+
try {
|
|
40281
|
+
const settled = await Promise.race([work.catch(() => FAILED), guard]);
|
|
40282
|
+
if (settled === TIMED_OUT) {
|
|
40283
|
+
onTimeout();
|
|
40284
|
+
return fallback;
|
|
40285
|
+
}
|
|
40286
|
+
return settled === FAILED ? fallback : settled;
|
|
40287
|
+
} finally {
|
|
40288
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
40289
|
+
work.catch(() => void 0);
|
|
40290
|
+
}
|
|
40291
|
+
}
|
|
40292
|
+
//#endregion
|
|
40293
|
+
//#region src/mappers/builders/stream-bitrate-probe.ts
|
|
40294
|
+
/**
|
|
40295
|
+
* Total budget for the rate evidence, not per call — the point is to bound
|
|
40296
|
+
* what the CONTROLLER waits for, and it waits for the sum.
|
|
40297
|
+
*/
|
|
40298
|
+
var BITRATE_EVIDENCE_BUDGET_MS = 1500;
|
|
40299
|
+
var NO_EVIDENCE = {
|
|
40300
|
+
camStreams: null,
|
|
40301
|
+
streamParams: null,
|
|
40302
|
+
choices: null
|
|
40303
|
+
};
|
|
40304
|
+
/**
|
|
40305
|
+
* The last evidence that actually arrived, per device.
|
|
40306
|
+
*
|
|
40307
|
+
* Falling back to NO evidence on a slow read was not a neutral degradation: it
|
|
40308
|
+
* changed WHICH SLOT the picker chose. Measured on 615 within forty seconds,
|
|
40309
|
+
* same camera, same negotiated 1280x720:
|
|
40310
|
+
*
|
|
40311
|
+
* 10:22:11 mid 10 fps
|
|
40312
|
+
* 10:22:17 low 24 fps
|
|
40313
|
+
* 10:22:26 mid 10 fps
|
|
40314
|
+
* 10:22:50 low 24 fps
|
|
40315
|
+
*
|
|
40316
|
+
* With evidence, `low` classifies as a fit and wins; without it every slot is
|
|
40317
|
+
* `unknown` and the fallback takes `mid`. So the stream a controller received
|
|
40318
|
+
* depended on whether a cap read beat a 1500 ms timer — a coin flip, and one
|
|
40319
|
+
* that hands iOS a different profile on each retry.
|
|
40320
|
+
*
|
|
40321
|
+
* A rate is a property of the camera's encoder configuration, which changes
|
|
40322
|
+
* when an operator changes it and not otherwise. Yesterday's reading is a far
|
|
40323
|
+
* better answer than no reading, and the ONE case that must still see fresh
|
|
40324
|
+
* numbers — the operator lowering a substream — is a deliberate act followed
|
|
40325
|
+
* by a new session, by which time the background read has long landed.
|
|
40326
|
+
*/
|
|
40327
|
+
var lastGoodEvidence = /* @__PURE__ */ new Map();
|
|
40328
|
+
/**
|
|
40329
|
+
* Resolve every profile slot's rate. Never throws and never outlives its
|
|
40330
|
+
* budget: a slow read falls back to this device's last good reading, and only
|
|
40331
|
+
* a device that has never answered at all ends up `unknown`.
|
|
40332
|
+
*/
|
|
40333
|
+
async function probeProfileBitrates(input) {
|
|
40334
|
+
const deviceId = input.bctx.numericDeviceId;
|
|
40335
|
+
const evidence = await gatherRateEvidence(input.bctx.proxy, input.log, lastGoodEvidence.get(deviceId));
|
|
40336
|
+
if (evidence.camStreams !== null || evidence.streamParams !== null) lastGoodEvidence.set(deviceId, evidence);
|
|
40337
|
+
return resolveProfileBitrates({
|
|
40338
|
+
slots: input.slots,
|
|
40339
|
+
camStreams: evidence.camStreams ?? [],
|
|
40340
|
+
streamParams: evidence.streamParams,
|
|
40341
|
+
choices: evidence.choices ?? []
|
|
40342
|
+
});
|
|
40343
|
+
}
|
|
40344
|
+
/**
|
|
40345
|
+
* Issue the three reads CONCURRENTLY under one budget.
|
|
40346
|
+
*
|
|
40347
|
+
* Exported so the concurrency and the budget can be asserted directly: run in
|
|
40348
|
+
* sequence these latencies add, and adding them is what cost a session.
|
|
40349
|
+
*/
|
|
40350
|
+
async function gatherRateEvidence(proxy, log, lastGood) {
|
|
40351
|
+
const startedAt = Date.now();
|
|
40352
|
+
const evidence = await withDeadline(Promise.all([
|
|
40353
|
+
probe$1(() => proxy.cameraStreams?.getCameraStreams({}), "cameraStreams.getCameraStreams", log),
|
|
40354
|
+
probe$1(() => proxy.streamParams?.getStatus({}), "streamParams.getStatus", log),
|
|
40355
|
+
probe$1(() => proxy.webrtcSession?.listStreams({}), "webrtcSession.listStreams", log)
|
|
40356
|
+
]).then(([camStreams, streamParams, choices]) => ({
|
|
40357
|
+
camStreams,
|
|
40358
|
+
streamParams,
|
|
40359
|
+
choices
|
|
40360
|
+
})), BITRATE_EVIDENCE_BUDGET_MS, lastGood ?? NO_EVIDENCE, () => {
|
|
40361
|
+
log.warn("export-hap: rate evidence ABANDONED on its budget", { meta: {
|
|
40362
|
+
budgetMs: BITRATE_EVIDENCE_BUDGET_MS,
|
|
40363
|
+
fellBackTo: lastGood === void 0 ? "no-evidence" : "last-good",
|
|
40364
|
+
consequence: lastGood === void 0 ? "every slot rate reads UNKNOWN; the picker loses its rate preference" : "the previous reading decides the fit, so the chosen slot stays STABLE"
|
|
40365
|
+
} });
|
|
40366
|
+
});
|
|
40367
|
+
const elapsedMs = Date.now() - startedAt;
|
|
40368
|
+
if (elapsedMs > 1500 / 2) log.info("export-hap: rate evidence was slow", { meta: {
|
|
40369
|
+
elapsedMs,
|
|
40370
|
+
budgetMs: BITRATE_EVIDENCE_BUDGET_MS
|
|
40371
|
+
} });
|
|
40372
|
+
return evidence;
|
|
40373
|
+
}
|
|
40374
|
+
async function probe$1(call, label, log) {
|
|
40375
|
+
try {
|
|
40376
|
+
const pending = call();
|
|
40377
|
+
if (pending === void 0) {
|
|
40378
|
+
log.info("export-hap: bitrate probe skipped — cap not bound on this device", { meta: { call: label } });
|
|
40379
|
+
return null;
|
|
40380
|
+
}
|
|
40381
|
+
return await pending;
|
|
40382
|
+
} catch (err) {
|
|
40383
|
+
log.warn("export-hap: bitrate probe failed — the slot rate stays UNKNOWN", { meta: {
|
|
40384
|
+
call: label,
|
|
40385
|
+
error: err instanceof Error ? err.message : String(err)
|
|
40386
|
+
} });
|
|
40387
|
+
return null;
|
|
40388
|
+
}
|
|
40389
|
+
}
|
|
40390
|
+
//#endregion
|
|
40391
|
+
//#region src/mappers/builders/stream-ffmpeg-args.ts
|
|
40392
|
+
/**
|
|
40393
|
+
* The ffmpeg PLAN for one HomeKit streaming session.
|
|
40394
|
+
*
|
|
40395
|
+
* This file used to assemble the argument vector by hand. It no longer emits a
|
|
40396
|
+
* single argument: it describes the session as an {@link FfmpegInvocation} and
|
|
40397
|
+
* `buildFfmpegArgs` (`@camstack/types` `ffmpeg/invocation.ts`) emits every one.
|
|
40398
|
+
* The repo keeps exactly ONE argv builder — `scripts/check-ffmpeg-primitive.ts`
|
|
40399
|
+
* Rule 1 refuses a second, and HomeKit was the last exception (D67).
|
|
40400
|
+
*
|
|
40401
|
+
* ## What moved behind the primitive, and what stayed here
|
|
40402
|
+
*
|
|
40403
|
+
* MOVED — everything that describes an ENCODE, because it is the same job every
|
|
40404
|
+
* other live egress does and the repo had five disagreeing copies of it: the
|
|
40405
|
+
* encoder, preset, tune, profile, level, pixel format, rate, GOP, the tight VBV
|
|
40406
|
+
* window ({@link RATE_CONTROL_TIGHT}), the bitstream filter, and the Opus block
|
|
40407
|
+
* ({@link HAP_AUDIO_BASE}).
|
|
40408
|
+
*
|
|
40409
|
+
* STAYED — everything that is a HAP PROTOCOL fact and belongs to no other
|
|
40410
|
+
* consumer: the payload types, the SSRCs (and their signed-int32 coercion), the
|
|
40411
|
+
* MTU baked into each `rtp://…?pkt_size=` target, the loopback ports the
|
|
40412
|
+
* JS-side SRTP encrypt reads from, and the negotiated audio sample rate and
|
|
40413
|
+
* packet time.
|
|
40414
|
+
*
|
|
40415
|
+
* ## The two flags this file exists to protect
|
|
40416
|
+
*
|
|
40417
|
+
* `-g` and `-bsf:v dump_extra` were two of the four causes of the year-long
|
|
40418
|
+
* failure, and both live in the encode plan now. They are asserted by token
|
|
40419
|
+
* AND by position in `__tests__/stream-ffmpeg-argv.spec.ts`, on both the copy
|
|
40420
|
+
* and the encode branch, so the move behind the primitive cannot quietly drop
|
|
40421
|
+
* either. Every other comment below records something learned the expensive
|
|
40422
|
+
* way; deleting one loses the reason a flag is there.
|
|
40423
|
+
*/
|
|
40424
|
+
/**
|
|
40425
|
+
* Opus encoder targets — kept low because:
|
|
40426
|
+
* - Camera audio is overwhelmingly speech / ambient noise; 24 kbps mono
|
|
40427
|
+
* is the published "fullband speech" sweet spot for libopus (well
|
|
40428
|
+
* above the 20 kbps "wideband speech" floor).
|
|
40429
|
+
* - HAP audio is one-shot live (no buffering on the controller side),
|
|
40430
|
+
* so under-shooting the bitrate is cheaper than over-shooting it and
|
|
40431
|
+
* hitting jitter.
|
|
40432
|
+
* - Mono / low-delay profile matches Apple Home's published Opus decoder
|
|
40433
|
+
* expectations for camera accessories.
|
|
40434
|
+
*
|
|
40435
|
+
* The numbers themselves live in `@camstack/types` `ffmpeg/encode-defaults.ts`
|
|
40436
|
+
* now, alongside every other live-egress constant, so the five sets that used
|
|
40437
|
+
* to disagree about Opus channel count can be diffed in one place. Re-exported
|
|
40438
|
+
* here because the session telemetry reports the bitrate it dialled.
|
|
40439
|
+
*/
|
|
40440
|
+
var OPUS_BITRATE_KBPS = 24;
|
|
40441
|
+
/**
|
|
40442
|
+
* The Opus plane, per session.
|
|
40443
|
+
*
|
|
40444
|
+
* Re-encoded regardless of source codec: the source pool is a mix of
|
|
40445
|
+
* PCM_MULAW, PCM_ALAW, G.711 and AAC depending on driver, and Apple Home
|
|
40446
|
+
* expects Opus on the wire.
|
|
40447
|
+
*
|
|
40448
|
+
* `sampleRateHz` and `frameDurationMs` are NEGOTIATED — the controller picks
|
|
40449
|
+
* them — which is why the shared {@link HAP_AUDIO_BASE} leaves both out and
|
|
40450
|
+
* they are filled in here.
|
|
40451
|
+
*
|
|
40452
|
+
* CRITICAL on the sample rate: encode at the rate iOS asked for, never a
|
|
40453
|
+
* constant. iOS's `AudioStreamingSamplerate` enum surfaces as 8 / 16 / 24 kHz;
|
|
40454
|
+
* encoding at 24 when iOS asked for 16 produces RTP timestamps stepping by 480
|
|
40455
|
+
* samples/packet against a clock expecting 320 — the SRTP frames decrypt
|
|
40456
|
+
* cleanly but the speaker stays mute, because the timestamps slide out of the
|
|
40457
|
+
* AV-sync window before the first Opus frame renders. The same request value
|
|
40458
|
+
* drives `audioIntervalScale` in the re-stamping pass, so the two MUST come
|
|
40459
|
+
* from one source.
|
|
40460
|
+
*
|
|
40461
|
+
* On the frame duration: libopus emits exactly one RTP packet per Opus frame at
|
|
40462
|
+
* that duration, and matching HAP's `packet_time` (20 ms on LAN, 30/40/60 on
|
|
40463
|
+
* LTE) is what keeps the 1:1 frame↔packet mapping the controller expects.
|
|
40464
|
+
*/
|
|
40465
|
+
function audioPlan(input) {
|
|
40466
|
+
return {
|
|
40467
|
+
...HAP_AUDIO_BASE,
|
|
40468
|
+
sampleRateHz: input.audioSampleRateKhz * 1e3,
|
|
40469
|
+
frameDurationMs: input.audioPacketTimeMs,
|
|
40470
|
+
vbvBufferKbits: 96
|
|
40471
|
+
};
|
|
40472
|
+
}
|
|
40473
|
+
/**
|
|
40474
|
+
* Two outputs from one input: video SRTP and audio SRTP, one process, one
|
|
40475
|
+
* lifetime, one kill signal. The shared builder's `rtp-outputs` sink maps each
|
|
40476
|
+
* plane explicitly (`-an -map 0:v:0` / `-vn -map 0:a:0?`) so ffmpeg never
|
|
40477
|
+
* guesses which stream belongs where, and `0:a:0?` makes the audio optional so
|
|
40478
|
+
* a source with no microphone skips it instead of failing the invocation.
|
|
40479
|
+
*/
|
|
40480
|
+
/**
|
|
40481
|
+
* How long ffmpeg may inspect the broker's restream before emitting.
|
|
40482
|
+
*
|
|
40483
|
+
* Not zero. A zero-length probe makes ffmpeg trust the SDP completely, and an
|
|
40484
|
+
* RTSP source that announces a track it then never sends would leave the
|
|
40485
|
+
* mapping wrong with no way to notice. 200 ms and 64 KB is far below the
|
|
40486
|
+
* shortest key-frame interval on this fleet while still letting the demuxer
|
|
40487
|
+
* see real packets — enough to be honest, short enough that nobody watches it.
|
|
40488
|
+
*/
|
|
40489
|
+
var HAP_INPUT_PROBE = {
|
|
40490
|
+
analyzeDurationUs: 2e5,
|
|
40491
|
+
probeSizeBytes: 64 * 1024
|
|
40492
|
+
};
|
|
40493
|
+
function buildSessionInvocation(input) {
|
|
40494
|
+
return {
|
|
40495
|
+
logLevel: "warning",
|
|
40496
|
+
decodeHwAccel: input.decode.hwaccel,
|
|
40497
|
+
input: {
|
|
40498
|
+
url: input.rtspUrl,
|
|
40499
|
+
rtspTransport: "tcp",
|
|
40500
|
+
analyzeDurationUs: HAP_INPUT_PROBE.analyzeDurationUs,
|
|
40501
|
+
probeSizeBytes: HAP_INPUT_PROBE.probeSizeBytes,
|
|
40502
|
+
...input.decode.extraInputArgs.length > 0 ? { extraArgs: input.decode.extraInputArgs } : {}
|
|
40503
|
+
},
|
|
40504
|
+
video: input.video,
|
|
40505
|
+
audio: audioPlan(input),
|
|
40506
|
+
threadCount: 0,
|
|
40507
|
+
outputArgs: [],
|
|
40508
|
+
sink: {
|
|
40509
|
+
kind: "rtp-outputs",
|
|
40510
|
+
video: {
|
|
40511
|
+
url: input.videoTarget,
|
|
40512
|
+
payloadType: input.videoPayloadType,
|
|
40513
|
+
ssrc: input.videoSsrcSigned
|
|
40514
|
+
},
|
|
40515
|
+
audio: {
|
|
40516
|
+
url: input.audioTarget,
|
|
40517
|
+
payloadType: input.audioPayloadType,
|
|
40518
|
+
ssrc: input.audioSsrcSigned
|
|
40519
|
+
}
|
|
40520
|
+
}
|
|
40521
|
+
};
|
|
40522
|
+
}
|
|
38357
40523
|
/**
|
|
38358
40524
|
* The resolutions we offer, before rates are attached. Same list the delegate
|
|
38359
40525
|
* advertised before R2 — only the frame rate changes, so a controller that had
|
|
@@ -38493,6 +40659,355 @@ function toCamProfile(profileId) {
|
|
|
38493
40659
|
return CAM_PROFILES.find((p) => p === profileId) ?? null;
|
|
38494
40660
|
}
|
|
38495
40661
|
//#endregion
|
|
40662
|
+
//#region src/mappers/builders/h264-idr.ts
|
|
40663
|
+
/**
|
|
40664
|
+
* Does this RTP packet carry the start of an H.264 IDR?
|
|
40665
|
+
*
|
|
40666
|
+
* A pass-through session cannot manufacture a key frame on demand — it can
|
|
40667
|
+
* only forward the one the camera decides to emit. So the number that decides
|
|
40668
|
+
* whether a controller sees a picture or a loader is *how long it waited for
|
|
40669
|
+
* the first IDR*, and until now nothing measured it: a session could report
|
|
40670
|
+
* a thousand packets forwarded, zero loss, and a blank screen, with no field
|
|
40671
|
+
* distinguishing "the stream is broken" from "the next key frame is 20
|
|
40672
|
+
* seconds away".
|
|
40673
|
+
*
|
|
40674
|
+
* That is the whole reason this exists, so it is deliberately narrow: a
|
|
40675
|
+
* boolean per packet, no state, no allocation, and it never throws. It runs on
|
|
40676
|
+
* every forwarded video packet, and a parser that throws on a malformed packet
|
|
40677
|
+
* would take the media path down with it.
|
|
40678
|
+
*/
|
|
40679
|
+
/** NAL unit type carrying a coded slice of an IDR picture (RFC 6184 §5.2). */
|
|
40680
|
+
var NAL_TYPE_IDR = 5;
|
|
40681
|
+
/** Single-time aggregation packet — several NALs in one RTP payload. */
|
|
40682
|
+
var NAL_TYPE_STAP_A = 24;
|
|
40683
|
+
/** Fragmentation units: one NAL spread over several RTP payloads. */
|
|
40684
|
+
var NAL_TYPE_FU_A = 28;
|
|
40685
|
+
var NAL_TYPE_FU_B = 29;
|
|
40686
|
+
var RTP_MIN_HEADER_BYTES = 12;
|
|
40687
|
+
var NAL_TYPE_MASK = 31;
|
|
40688
|
+
/** FU header start bit — set only on the FIRST fragment of a fragmented NAL. */
|
|
40689
|
+
var FU_START_BIT = 128;
|
|
40690
|
+
function rtpPacketCarriesIdr(packet) {
|
|
40691
|
+
const payloadStart = rtpPayloadOffset(packet);
|
|
40692
|
+
if (payloadStart === null) return false;
|
|
40693
|
+
const firstPayloadByte = packet[payloadStart];
|
|
40694
|
+
if (firstPayloadByte === void 0) return false;
|
|
40695
|
+
const nalType = firstPayloadByte & NAL_TYPE_MASK;
|
|
40696
|
+
if (nalType === NAL_TYPE_FU_A || nalType === NAL_TYPE_FU_B) {
|
|
40697
|
+
const fuHeader = packet[payloadStart + 1];
|
|
40698
|
+
if (fuHeader === void 0) return false;
|
|
40699
|
+
if ((fuHeader & FU_START_BIT) === 0) return false;
|
|
40700
|
+
return (fuHeader & NAL_TYPE_MASK) === NAL_TYPE_IDR;
|
|
40701
|
+
}
|
|
40702
|
+
if (nalType === NAL_TYPE_STAP_A) return stapContainsIdr(packet, payloadStart + 1);
|
|
40703
|
+
return nalType === NAL_TYPE_IDR;
|
|
40704
|
+
}
|
|
40705
|
+
/**
|
|
40706
|
+
* Byte offset of the RTP payload, or `null` when the packet is too short to
|
|
40707
|
+
* hold one. The variable-length parts are what make this worth a function:
|
|
40708
|
+
* a fixed offset of 12 is right for every packet ffmpeg emits today and wrong
|
|
40709
|
+
* the moment one carries a CSRC list or a header extension.
|
|
40710
|
+
*/
|
|
40711
|
+
function rtpPayloadOffset(packet) {
|
|
40712
|
+
if (packet.length <= RTP_MIN_HEADER_BYTES) return null;
|
|
40713
|
+
const flags = packet[0];
|
|
40714
|
+
if (flags === void 0) return null;
|
|
40715
|
+
const csrcCount = flags & 15;
|
|
40716
|
+
const hasExtension = (flags & 16) !== 0;
|
|
40717
|
+
let offset = RTP_MIN_HEADER_BYTES + csrcCount * 4;
|
|
40718
|
+
if (hasExtension) {
|
|
40719
|
+
if (offset + 4 > packet.length) return null;
|
|
40720
|
+
const words = packet.readUInt16BE(offset + 2);
|
|
40721
|
+
offset += 4 + words * 4;
|
|
40722
|
+
}
|
|
40723
|
+
return offset < packet.length ? offset : null;
|
|
40724
|
+
}
|
|
40725
|
+
/** Walk a STAP-A's `[size][nal]` pairs looking for an IDR. */
|
|
40726
|
+
function stapContainsIdr(packet, start) {
|
|
40727
|
+
let offset = start;
|
|
40728
|
+
while (offset + 2 <= packet.length) {
|
|
40729
|
+
const size = packet.readUInt16BE(offset);
|
|
40730
|
+
offset += 2;
|
|
40731
|
+
if (size === 0 || offset + size > packet.length) return false;
|
|
40732
|
+
const nalHeader = packet[offset];
|
|
40733
|
+
if (nalHeader === void 0) return false;
|
|
40734
|
+
if ((nalHeader & NAL_TYPE_MASK) === NAL_TYPE_IDR) return true;
|
|
40735
|
+
offset += size;
|
|
40736
|
+
}
|
|
40737
|
+
return false;
|
|
40738
|
+
}
|
|
40739
|
+
/**
|
|
40740
|
+
* How long after spawn an exit still counts as "hardware init failed".
|
|
40741
|
+
*
|
|
40742
|
+
* Same window the ffmpeg decoder addon uses for its own cascade. A hardware
|
|
40743
|
+
* context that cannot be created fails within milliseconds; anything that ran
|
|
40744
|
+
* longer produced no frames for a different reason, and re-spawning it in
|
|
40745
|
+
* software would just hide that reason.
|
|
40746
|
+
*/
|
|
40747
|
+
var HW_DECODE_FALLBACK_WINDOW_MS = 4e3;
|
|
40748
|
+
/** Backends whose decode binds to a DRM render node. */
|
|
40749
|
+
var RENDER_NODE_BACKENDS = ["vaapi", "qsv"];
|
|
40750
|
+
/**
|
|
40751
|
+
* Values of the decoder cap's `hwaccel` field that are operator CHOICES rather
|
|
40752
|
+
* than devices.
|
|
40753
|
+
*/
|
|
40754
|
+
var HWACCEL_NON_BACKEND_CHOICES = ["auto", "none"];
|
|
40755
|
+
/**
|
|
40756
|
+
* Every backend the decoder cap can publish, taken from the cap's own UI option
|
|
40757
|
+
* list so this module cannot drift from it. A hand-written copy of the union is
|
|
40758
|
+
* exactly the "hand-written cap interface that rots silently" this repo has
|
|
40759
|
+
* been bitten by.
|
|
40760
|
+
*/
|
|
40761
|
+
var HWACCEL_BACKENDS = new Set(HWACCEL_OPTIONS.map((option) => option.value).filter((value) => !HWACCEL_NON_BACKEND_CHOICES.includes(value)));
|
|
40762
|
+
/** Is this string a hardware backend, as opposed to `auto`, `none` or junk? */
|
|
40763
|
+
function isHwAccelBackend(value) {
|
|
40764
|
+
return HWACCEL_BACKENDS.has(value);
|
|
40765
|
+
}
|
|
40766
|
+
/**
|
|
40767
|
+
* Decide how this session decodes, and produce the ffmpeg input-side flags.
|
|
40768
|
+
*
|
|
40769
|
+
* Every path that ends in software carries a named reason, because a session
|
|
40770
|
+
* that quietly stopped using the GPU and a session that never had one look
|
|
40771
|
+
* identical in a CPU graph.
|
|
40772
|
+
*/
|
|
40773
|
+
function selectHwDecode(input) {
|
|
40774
|
+
if (!input.transcode) return software("pass-through");
|
|
40775
|
+
if (input.hardwareAlreadyFailed === true) return software("hardware-attempt-failed");
|
|
40776
|
+
if (input.reading === null) return software("no-decoder-reading");
|
|
40777
|
+
const chosen = (input.reading.hwaccel ?? "").trim();
|
|
40778
|
+
if (chosen === "none") return software("operator-disabled");
|
|
40779
|
+
if (chosen !== "" && chosen !== "auto") return isHwAccelBackend(chosen) ? hardware(chosen, "operator", input) : software("unrecognised-backend");
|
|
40780
|
+
const probed = (input.reading.probedBestHwaccel ?? "").trim();
|
|
40781
|
+
if (probed === "" || probed === "none") return software("not-probed");
|
|
40782
|
+
return isHwAccelBackend(probed) ? hardware(probed, "probed", input) : software("unrecognised-backend");
|
|
40783
|
+
}
|
|
40784
|
+
/**
|
|
40785
|
+
* Did this ffmpeg exit look like a failed hardware init, rather than a
|
|
40786
|
+
* teardown or a fault software would hit too?
|
|
40787
|
+
*
|
|
40788
|
+
* All five conditions are necessary. Dropping the controller-stop check in
|
|
40789
|
+
* particular would respawn on every normal teardown, because iOS restarts a
|
|
40790
|
+
* session it is not enjoying and that is indistinguishable at the exit code.
|
|
40791
|
+
*/
|
|
40792
|
+
function shouldRetryInSoftware(input) {
|
|
40793
|
+
if (!input.usedHardware) return false;
|
|
40794
|
+
if (input.hardwareAlreadyFailed) return false;
|
|
40795
|
+
if (input.stopRequestedByController) return false;
|
|
40796
|
+
if (input.videoPacketsForwarded > 0) return false;
|
|
40797
|
+
return input.runtimeMs <= HW_DECODE_FALLBACK_WINDOW_MS;
|
|
40798
|
+
}
|
|
40799
|
+
function software(reason) {
|
|
40800
|
+
return {
|
|
40801
|
+
kind: "software",
|
|
40802
|
+
reason,
|
|
40803
|
+
hwaccel: null,
|
|
40804
|
+
extraInputArgs: [],
|
|
40805
|
+
args: []
|
|
40806
|
+
};
|
|
40807
|
+
}
|
|
40808
|
+
function hardware(backend, source, input) {
|
|
40809
|
+
if (input.recentlyFailedBackend === backend) return software("hardware-attempt-failed");
|
|
40810
|
+
const { hwaccel, extraInputArgs } = decodePlan(backend, input);
|
|
40811
|
+
return {
|
|
40812
|
+
kind: "hardware",
|
|
40813
|
+
backend,
|
|
40814
|
+
source,
|
|
40815
|
+
hwaccel,
|
|
40816
|
+
extraInputArgs,
|
|
40817
|
+
args: [
|
|
40818
|
+
"-hwaccel",
|
|
40819
|
+
hwaccel,
|
|
40820
|
+
...extraInputArgs
|
|
40821
|
+
]
|
|
40822
|
+
};
|
|
40823
|
+
}
|
|
40824
|
+
/**
|
|
40825
|
+
* The input-side decode configuration, and nothing else.
|
|
40826
|
+
*
|
|
40827
|
+
* No `-hwaccel_output_format`: the decoded frames have to land in system
|
|
40828
|
+
* memory for libx264 to scale and encode them. Setting it would keep them on
|
|
40829
|
+
* the GPU, which only pays off with a GPU scale filter — and that is the
|
|
40830
|
+
* decoder addon's job, not a two-output SRTP session's.
|
|
40831
|
+
*
|
|
40832
|
+
* The two halves are returned SEPARATELY because the shared argv builder emits
|
|
40833
|
+
* `-hwaccel` itself (it is the only function allowed to, so the flag cannot
|
|
40834
|
+
* drift past `-i`) and takes everything else as the input plan's `extraArgs`.
|
|
40835
|
+
*/
|
|
40836
|
+
function decodePlan(backend, input) {
|
|
40837
|
+
if (backend === "videotoolbox" && input.platform === "darwin") return {
|
|
40838
|
+
hwaccel: "auto",
|
|
40839
|
+
extraInputArgs: []
|
|
40840
|
+
};
|
|
40841
|
+
return {
|
|
40842
|
+
hwaccel: backend,
|
|
40843
|
+
extraInputArgs: RENDER_NODE_BACKENDS.includes(backend) ? ["-hwaccel_device", input.renderDevice ?? "/dev/dri/renderD128"] : []
|
|
40844
|
+
};
|
|
40845
|
+
}
|
|
40846
|
+
//#endregion
|
|
40847
|
+
//#region src/mappers/builders/stream-hwaccel-probe.ts
|
|
40848
|
+
/**
|
|
40849
|
+
* The real read: `decoder.getInfo`, pinned to the LOCAL node.
|
|
40850
|
+
*
|
|
40851
|
+
* Pinned explicitly rather than left to routing, because an unpinned singleton
|
|
40852
|
+
* cap answers from whichever node owns it and would report the WRONG host's
|
|
40853
|
+
* hardware.
|
|
40854
|
+
*/
|
|
40855
|
+
function decoderInfoSourceFromContext(ctx) {
|
|
40856
|
+
return {
|
|
40857
|
+
localNodeId: ctx.kernel?.localNodeId,
|
|
40858
|
+
readInfo: (nodeId) => ctx.api.decoder.getInfo.query(void 0, nodePin(nodeId))
|
|
40859
|
+
};
|
|
40860
|
+
}
|
|
40861
|
+
/**
|
|
40862
|
+
* Read this node's decode-hwaccel state, or `null` when nothing answered.
|
|
40863
|
+
*
|
|
40864
|
+
* Never throws. `null` means "we do not know", which
|
|
40865
|
+
* {@link import('./stream-hwaccel.js').selectHwDecode} turns into software —
|
|
40866
|
+
* the safe direction, because a guess here costs the whole stream.
|
|
40867
|
+
*/
|
|
40868
|
+
async function probeDecoderHwaccel(input) {
|
|
40869
|
+
const { source, log, memo } = input;
|
|
40870
|
+
const memoised = memo.read();
|
|
40871
|
+
if (memoised !== void 0) return memoised;
|
|
40872
|
+
const nodeId = source.localNodeId;
|
|
40873
|
+
if (nodeId === void 0 || nodeId.length === 0) {
|
|
40874
|
+
log.warn("export-hap: hwaccel probe skipped — no local node id, decoding in SOFTWARE");
|
|
40875
|
+
return null;
|
|
40876
|
+
}
|
|
40877
|
+
try {
|
|
40878
|
+
const info = await source.readInfo(nodeId);
|
|
40879
|
+
if (info === null || info === void 0) {
|
|
40880
|
+
log.info("export-hap: hwaccel probe returned nothing — decoding in SOFTWARE", { meta: { nodeId } });
|
|
40881
|
+
memo.write(null);
|
|
40882
|
+
return null;
|
|
40883
|
+
}
|
|
40884
|
+
const reading = {
|
|
40885
|
+
hwaccel: info.hwaccel ?? null,
|
|
40886
|
+
probedBestHwaccel: info.probedBestHwaccel ?? null
|
|
40887
|
+
};
|
|
40888
|
+
memo.write(reading);
|
|
40889
|
+
return reading;
|
|
40890
|
+
} catch (err) {
|
|
40891
|
+
log.warn("export-hap: hwaccel probe failed — decoding in SOFTWARE", { meta: {
|
|
40892
|
+
nodeId,
|
|
40893
|
+
error: err instanceof Error ? err.message : String(err)
|
|
40894
|
+
} });
|
|
40895
|
+
memo.write(null);
|
|
40896
|
+
return null;
|
|
40897
|
+
}
|
|
40898
|
+
}
|
|
40899
|
+
/**
|
|
40900
|
+
* What we ask for on the VIDEO loopback socket.
|
|
40901
|
+
*
|
|
40902
|
+
* Generous on purpose: the cost is virtual address space the kernel only
|
|
40903
|
+
* commits as datagrams actually queue, and the failure it prevents is a black
|
|
40904
|
+
* tile. Sized well above {@link KEYFRAME_BURST_FLOOR_BYTES} so a slow drain
|
|
40905
|
+
* (the JS forwarder is on the same event loop as everything else this addon
|
|
40906
|
+
* does) still has headroom.
|
|
40907
|
+
*/
|
|
40908
|
+
var VIDEO_LOOPBACK_RCVBUF_BYTES = 8 * 1024 * 1024;
|
|
40909
|
+
/**
|
|
40910
|
+
* What we ask for on the AUDIO loopback socket.
|
|
40911
|
+
*
|
|
40912
|
+
* Audio never bursts — that is the control in this experiment, and it is why
|
|
40913
|
+
* the two legs get different numbers rather than one shared constant. If audio
|
|
40914
|
+
* ever starts dropping at the same buffer that carries video fine, the cause is
|
|
40915
|
+
* not burst size.
|
|
40916
|
+
*/
|
|
40917
|
+
var AUDIO_LOOPBACK_RCVBUF_BYTES = 1024 * 1024;
|
|
40918
|
+
function errMsg$9(err) {
|
|
40919
|
+
return err instanceof Error ? err.message : String(err);
|
|
40920
|
+
}
|
|
40921
|
+
/**
|
|
40922
|
+
* Set `SO_RCVBUF` and READ IT BACK.
|
|
40923
|
+
*
|
|
40924
|
+
* Never throws: a platform that refuses the option must cost the buffer, never
|
|
40925
|
+
* the session. The read-back is the point — a request the kernel clamped and a
|
|
40926
|
+
* request it honoured are indistinguishable at the call site.
|
|
40927
|
+
*/
|
|
40928
|
+
function applyReceiveBuffer(socket, requestedBytes) {
|
|
40929
|
+
let error = null;
|
|
40930
|
+
try {
|
|
40931
|
+
socket.setRecvBufferSize(requestedBytes);
|
|
40932
|
+
} catch (err) {
|
|
40933
|
+
error = errMsg$9(err);
|
|
40934
|
+
}
|
|
40935
|
+
let effectiveBytes = null;
|
|
40936
|
+
try {
|
|
40937
|
+
effectiveBytes = socket.getRecvBufferSize();
|
|
40938
|
+
} catch (err) {
|
|
40939
|
+
if (error === null) error = errMsg$9(err);
|
|
40940
|
+
}
|
|
40941
|
+
return {
|
|
40942
|
+
requestedBytes,
|
|
40943
|
+
effectiveBytes,
|
|
40944
|
+
clamped: effectiveBytes !== null && effectiveBytes < requestedBytes,
|
|
40945
|
+
sufficientForKeyframeBurst: effectiveBytes !== null && effectiveBytes >= 2097152,
|
|
40946
|
+
error
|
|
40947
|
+
};
|
|
40948
|
+
}
|
|
40949
|
+
/** Where the kernel publishes per-socket UDP counters, by address family. */
|
|
40950
|
+
var PROC_NET_UDP = {
|
|
40951
|
+
ipv4: "/proc/net/udp",
|
|
40952
|
+
ipv6: "/proc/net/udp6"
|
|
40953
|
+
};
|
|
40954
|
+
/**
|
|
40955
|
+
* The per-socket `drops` count for `port`, out of a `/proc/net/udp` table.
|
|
40956
|
+
*
|
|
40957
|
+
* Pure so the format assumption is pinned by a test rather than by a live
|
|
40958
|
+
* kernel. Returns `null` when the port has no row — which is NOT the same as
|
|
40959
|
+
* zero drops, and the two must never collapse: `0` is evidence the buffer held,
|
|
40960
|
+
* `null` is the absence of evidence.
|
|
40961
|
+
*/
|
|
40962
|
+
function parseUdpSocketDrops(table, port) {
|
|
40963
|
+
const lines = table.split("\n");
|
|
40964
|
+
for (const line of lines) {
|
|
40965
|
+
const fields = line.trim().split(/\s+/);
|
|
40966
|
+
if (fields.length < 13) continue;
|
|
40967
|
+
const local = fields[1];
|
|
40968
|
+
if (local === void 0) continue;
|
|
40969
|
+
const hexPort = local.split(":")[1];
|
|
40970
|
+
if (hexPort === void 0) continue;
|
|
40971
|
+
const parsedPort = Number.parseInt(hexPort, 16);
|
|
40972
|
+
if (!Number.isFinite(parsedPort) || parsedPort !== port) continue;
|
|
40973
|
+
const drops = Number(fields[fields.length - 1]);
|
|
40974
|
+
return Number.isFinite(drops) ? drops : null;
|
|
40975
|
+
}
|
|
40976
|
+
return null;
|
|
40977
|
+
}
|
|
40978
|
+
/**
|
|
40979
|
+
* Fold a fresh drop sample into the one already held.
|
|
40980
|
+
*
|
|
40981
|
+
* A socket that has been CLOSED disappears from `/proc/net/udp`, so a resample
|
|
40982
|
+
* after teardown returns `null` — "I can no longer look", which must never
|
|
40983
|
+
* erase "I looked and it was 0". The first live session that proved the buffer
|
|
40984
|
+
* fix reported `videoLoopKernelDrops=null` for exactly this reason: on a
|
|
40985
|
+
* controller `stop` the sockets are closed synchronously while the summary is
|
|
40986
|
+
* emitted later from ffmpeg's `exit` handler.
|
|
40987
|
+
*/
|
|
40988
|
+
function mergeDropSample(previous, sampled) {
|
|
40989
|
+
return sampled ?? previous;
|
|
40990
|
+
}
|
|
40991
|
+
/**
|
|
40992
|
+
* Read the kernel's drop counter for a bound local UDP port.
|
|
40993
|
+
*
|
|
40994
|
+
* Linux only — `null` on every other platform and on every read failure, which
|
|
40995
|
+
* is honest: "we could not look" and "nothing was dropped" are different
|
|
40996
|
+
* answers and this returns the first as `null`.
|
|
40997
|
+
*
|
|
40998
|
+
* Synchronous on purpose. It is called at the session heartbeat (5 s) and once
|
|
40999
|
+
* at teardown, against a memory-backed pseudo-file; making it async would mean
|
|
41000
|
+
* the SUMMARY line — the one line this experiment is read from — could not
|
|
41001
|
+
* carry a fresh count, which is the only reason it exists.
|
|
41002
|
+
*/
|
|
41003
|
+
function readUdpSocketDrops(port, ipVersion) {
|
|
41004
|
+
try {
|
|
41005
|
+
return parseUdpSocketDrops((0, node_fs.readFileSync)(PROC_NET_UDP[ipVersion], "utf8"), port);
|
|
41006
|
+
} catch {
|
|
41007
|
+
return null;
|
|
41008
|
+
}
|
|
41009
|
+
}
|
|
41010
|
+
//#endregion
|
|
38496
41011
|
//#region src/mappers/builders/stream-telemetry.ts
|
|
38497
41012
|
/**
|
|
38498
41013
|
* Every branch on the streaming path that discards work, and its starting
|
|
@@ -38513,6 +41028,12 @@ var ZERO_DROP_COUNTERS = {
|
|
|
38513
41028
|
"rtcp-no-srtcp": 0,
|
|
38514
41029
|
/** Outbound RTCP: building or encrypting the Sender Report failed. */
|
|
38515
41030
|
"rtcp-encrypt-failed": 0,
|
|
41031
|
+
/** Inbound RTCP: the leg has no SRTCP context, so the controller's report is unreadable. */
|
|
41032
|
+
"inbound-rtcp-no-srtcp": 0,
|
|
41033
|
+
/** Inbound RTCP: SRTCP decryption of a controller packet failed. */
|
|
41034
|
+
"inbound-rtcp-decrypt-failed": 0,
|
|
41035
|
+
/** Inbound RTCP: the decrypted bytes did not parse as RTCP. */
|
|
41036
|
+
"inbound-rtcp-parse-failed": 0,
|
|
38516
41037
|
/** Upstream: packet shorter than an RTP header. */
|
|
38517
41038
|
"upstream-short-packet": 0,
|
|
38518
41039
|
/** Upstream: no inbound SRTP context (init failed at prepareStream). */
|
|
@@ -38568,6 +41089,21 @@ function classifyInboundPacket(packet) {
|
|
|
38568
41089
|
return "rtp";
|
|
38569
41090
|
}
|
|
38570
41091
|
/**
|
|
41092
|
+
* Loss at or above this is not a start-up transient.
|
|
41093
|
+
*
|
|
41094
|
+
* A stream whose first key frame is slow reliably produces one report in the
|
|
41095
|
+
* low single digits; a transmit-side defect produces tens of percent, because
|
|
41096
|
+
* whatever makes a packet unusable makes most packets unusable. The threshold
|
|
41097
|
+
* sits between those two regimes rather than at any measured boundary — it is
|
|
41098
|
+
* a reading aid, and the raw `worstFractionLostPct` is always in the line
|
|
41099
|
+
* beside it.
|
|
41100
|
+
*/
|
|
41101
|
+
var TRANSMIT_SUSPECT_FRACTION_LOST_PCT = 5;
|
|
41102
|
+
function lossVerdict(tally) {
|
|
41103
|
+
if (tally.blocksParsed === 0 || tally.worstFractionLostPct === null) return "no-reports";
|
|
41104
|
+
return tally.worstFractionLostPct >= TRANSMIT_SUSPECT_FRACTION_LOST_PCT ? "transmit-suspect" : "decode-suspect";
|
|
41105
|
+
}
|
|
41106
|
+
/**
|
|
38571
41107
|
* Build the meta for `export-hap: stream session summary` — the single line a
|
|
38572
41108
|
* future session greps to answer "why did this session die".
|
|
38573
41109
|
*/
|
|
@@ -38588,8 +41124,17 @@ function summariseSession(snapshot) {
|
|
|
38588
41124
|
selectedBrokerId: slot?.brokerId ?? null,
|
|
38589
41125
|
advertisedFps: slot?.advertisedFps ?? null,
|
|
38590
41126
|
advertisedFpsSource: slot?.advertisedFpsSource ?? null,
|
|
41127
|
+
deliveredFps: slot?.deliveredFps ?? null,
|
|
38591
41128
|
transcode: slot?.transcode ?? null,
|
|
41129
|
+
fitReason: slot?.fitReason ?? null,
|
|
41130
|
+
slotPublishedKbps: slot?.publishedKbps ?? null,
|
|
41131
|
+
slotMeasuredKbps: slot?.measuredKbps ?? null,
|
|
41132
|
+
encodeBudgetKbps: slot?.budgetKbps ?? null,
|
|
41133
|
+
fitNotes: slot?.fitNotes ?? [],
|
|
38592
41134
|
videoPacketsForwarded: snapshot.videoPacketsForwarded,
|
|
41135
|
+
msToFirstKeyframe: snapshot.firstKeyframeAtMs === null || snapshot.startedAtMs === null ? null : snapshot.firstKeyframeAtMs - snapshot.startedAtMs,
|
|
41136
|
+
videoKeyframes: snapshot.videoKeyframes,
|
|
41137
|
+
maxKeyframeGapMs: snapshot.maxKeyframeGapMs,
|
|
38593
41138
|
audioPacketsForwarded: snapshot.audioPacketsForwarded,
|
|
38594
41139
|
videoRtcpSrSent: snapshot.videoRtcpSrSent,
|
|
38595
41140
|
audioRtcpSrSent: snapshot.audioRtcpSrSent,
|
|
@@ -38599,12 +41144,26 @@ function summariseSession(snapshot) {
|
|
|
38599
41144
|
audioRtpReceived: snapshot.audioRtpReceived,
|
|
38600
41145
|
videoGate: formatRtcpGate(snapshot.videoGate),
|
|
38601
41146
|
audioGate: formatRtcpGate(snapshot.audioGate),
|
|
41147
|
+
videoReceiverReports: snapshot.videoReceiverReports,
|
|
41148
|
+
audioReceiverReports: snapshot.audioReceiverReports,
|
|
41149
|
+
videoLossVerdict: lossVerdict(snapshot.videoReceiverReports),
|
|
41150
|
+
audioLossVerdict: lossVerdict(snapshot.audioReceiverReports),
|
|
38602
41151
|
mediaStarved: snapshot.videoPacketsForwarded === 0,
|
|
41152
|
+
videoLoopRcvbufRequestedBytes: snapshot.videoLoopback.rcvbufRequestedBytes,
|
|
41153
|
+
videoLoopRcvbufBytes: snapshot.videoLoopback.rcvbufEffectiveBytes,
|
|
41154
|
+
videoLoopRcvbufClamped: snapshot.videoLoopback.rcvbufClamped,
|
|
41155
|
+
videoLoopKernelDrops: snapshot.videoLoopback.kernelDrops,
|
|
41156
|
+
videoLoopKernelDropped: (snapshot.videoLoopback.kernelDrops ?? 0) > 0,
|
|
41157
|
+
audioLoopRcvbufBytes: snapshot.audioLoopback.rcvbufEffectiveBytes,
|
|
41158
|
+
audioLoopKernelDrops: snapshot.audioLoopback.kernelDrops,
|
|
41159
|
+
audioLoopKernelDropped: (snapshot.audioLoopback.kernelDrops ?? 0) > 0,
|
|
38603
41160
|
drops: nonZeroDrops(snapshot.drops)
|
|
38604
41161
|
};
|
|
38605
41162
|
}
|
|
38606
41163
|
//#endregion
|
|
38607
41164
|
//#region src/mappers/builders/camera-streams.ts
|
|
41165
|
+
/** A decoder that is slow to describe itself costs hardware decode, not the session. */
|
|
41166
|
+
var HWACCEL_PROBE_BUDGET_MS = 1e3;
|
|
38608
41167
|
var SRTP_KEY_LEN = 16;
|
|
38609
41168
|
var SRTP_SALT_LEN = 14;
|
|
38610
41169
|
/**
|
|
@@ -38618,8 +41177,17 @@ var SRTP_SALT_LEN = 14;
|
|
|
38618
41177
|
* make this one act.
|
|
38619
41178
|
*/
|
|
38620
41179
|
var SESSION_HEARTBEAT_MS = 5e3;
|
|
38621
|
-
|
|
38622
|
-
|
|
41180
|
+
/**
|
|
41181
|
+
* Floor between two `export-hap: controller receiver report` lines on one leg.
|
|
41182
|
+
*
|
|
41183
|
+
* The controller sends several Receiver Reports a second — 21 and 34 in two
|
|
41184
|
+
* ~10 s sessions on 2026-08-06 — and every one of them at `info` would drown
|
|
41185
|
+
* the very line it is meant to make findable. The FIRST report on each leg is
|
|
41186
|
+
* always logged, in full and under its own message; after that this throttles
|
|
41187
|
+
* to the heartbeat's cadence so a session still produces a running record of
|
|
41188
|
+
* what the controller thinks without becoming one.
|
|
41189
|
+
*/
|
|
41190
|
+
var RECEIVER_REPORT_LOG_INTERVAL_MS = 5e3;
|
|
38623
41191
|
function buildCameraStreamingDelegate(bctx, advertised) {
|
|
38624
41192
|
const { ctx, numericDeviceId } = bctx;
|
|
38625
41193
|
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
@@ -38679,6 +41247,7 @@ function buildCameraStreamingDelegate(bctx, advertised) {
|
|
|
38679
41247
|
const hadFfmpeg = session.ffmpeg !== null;
|
|
38680
41248
|
killFfmpeg(session, ctx, numericDeviceId);
|
|
38681
41249
|
stopHeartbeat(session);
|
|
41250
|
+
sampleLoopbackDrops(session);
|
|
38682
41251
|
if (!hadFfmpeg) logSessionSummary(session, log, "accessory-dispose-no-ffmpeg");
|
|
38683
41252
|
await closeIntercomTalkSession(session, bctx).catch(() => void 0);
|
|
38684
41253
|
closeSocket(session.videoUdp);
|
|
@@ -38707,8 +41276,10 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
38707
41276
|
const localIp = pickLocalInterfaceIp(request.targetAddress, ipVersion);
|
|
38708
41277
|
const videoUdp = await bindUdp(ipVersion, localIp);
|
|
38709
41278
|
const audioUdp = await bindUdp(ipVersion, localIp);
|
|
38710
|
-
const
|
|
38711
|
-
const
|
|
41279
|
+
const videoLoop = await bindLoopback(ipVersion, VIDEO_LOOPBACK_RCVBUF_BYTES);
|
|
41280
|
+
const audioLoop = await bindLoopback(ipVersion, AUDIO_LOOPBACK_RCVBUF_BYTES);
|
|
41281
|
+
const videoLoopUdp = videoLoop.socket;
|
|
41282
|
+
const audioLoopUdp = audioLoop.socket;
|
|
38712
41283
|
const localVideoPort = videoUdp.address().port;
|
|
38713
41284
|
const localAudioPort = audioUdp.address().port;
|
|
38714
41285
|
if (request.video.srtp_key.length !== SRTP_KEY_LEN || request.video.srtp_salt.length !== SRTP_SALT_LEN || request.audio.srtp_key.length !== SRTP_KEY_LEN || request.audio.srtp_salt.length !== SRTP_SALT_LEN) {
|
|
@@ -38727,7 +41298,7 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
38727
41298
|
},
|
|
38728
41299
|
profile: import_src.ProtectionProfileAes128CmHmacSha1_80
|
|
38729
41300
|
});
|
|
38730
|
-
const
|
|
41301
|
+
const makeSrtcp = (key, salt) => new import_src.SrtcpSession({
|
|
38731
41302
|
keys: {
|
|
38732
41303
|
localMasterKey: key,
|
|
38733
41304
|
localMasterSalt: salt,
|
|
@@ -38738,13 +41309,17 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
38738
41309
|
});
|
|
38739
41310
|
let videoOutSrtp;
|
|
38740
41311
|
let videoOutSrtcp;
|
|
41312
|
+
let videoInSrtcp;
|
|
38741
41313
|
let audioOutSrtp;
|
|
38742
41314
|
let audioOutSrtcp;
|
|
41315
|
+
let audioInSrtcp;
|
|
38743
41316
|
try {
|
|
38744
41317
|
videoOutSrtp = makeOutSrtp(request.video.srtp_key, request.video.srtp_salt);
|
|
38745
|
-
videoOutSrtcp =
|
|
41318
|
+
videoOutSrtcp = makeSrtcp(request.video.srtp_key, request.video.srtp_salt);
|
|
41319
|
+
videoInSrtcp = makeSrtcp(request.video.srtp_key, request.video.srtp_salt);
|
|
38746
41320
|
audioOutSrtp = makeOutSrtp(request.audio.srtp_key, request.audio.srtp_salt);
|
|
38747
|
-
audioOutSrtcp =
|
|
41321
|
+
audioOutSrtcp = makeSrtcp(request.audio.srtp_key, request.audio.srtp_salt);
|
|
41322
|
+
audioInSrtcp = makeSrtcp(request.audio.srtp_key, request.audio.srtp_salt);
|
|
38748
41323
|
} catch (err) {
|
|
38749
41324
|
closeSocket(videoUdp);
|
|
38750
41325
|
closeSocket(audioUdp);
|
|
@@ -38780,12 +41355,20 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
38780
41355
|
drops: emptyDropCounters(),
|
|
38781
41356
|
videoPacketsForwarded: 0,
|
|
38782
41357
|
audioPacketsForwarded: 0,
|
|
41358
|
+
videoKeyframes: 0,
|
|
41359
|
+
firstKeyframeAtMs: null,
|
|
41360
|
+
lastKeyframeAtMs: null,
|
|
41361
|
+
maxKeyframeGapMs: 0,
|
|
38783
41362
|
videoRtcpSrSent: 0,
|
|
38784
41363
|
audioRtcpSrSent: 0,
|
|
38785
41364
|
videoRtcpReceived: 0,
|
|
38786
41365
|
audioRtcpReceived: 0,
|
|
38787
41366
|
videoRtpReceived: 0,
|
|
38788
41367
|
audioRtpReceived: 0,
|
|
41368
|
+
videoReceiverReports: emptyReceiverReportTally(),
|
|
41369
|
+
audioReceiverReports: emptyReceiverReportTally(),
|
|
41370
|
+
videoRrLoggedAt: 0,
|
|
41371
|
+
audioRrLoggedAt: 0,
|
|
38789
41372
|
videoGate: null,
|
|
38790
41373
|
audioGate: null,
|
|
38791
41374
|
heartbeat: null,
|
|
@@ -38797,6 +41380,7 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
38797
41380
|
videoLoopUdp,
|
|
38798
41381
|
videoOutSrtp,
|
|
38799
41382
|
videoOutSrtcp,
|
|
41383
|
+
videoInSrtcp,
|
|
38800
41384
|
videoOutPacketCount: 0,
|
|
38801
41385
|
videoOutOctetCount: 0,
|
|
38802
41386
|
videoOutLastRtpTimestamp: 0,
|
|
@@ -38811,8 +41395,15 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
38811
41395
|
audioLoopUdp,
|
|
38812
41396
|
audioOutSrtp,
|
|
38813
41397
|
audioOutSrtcp,
|
|
41398
|
+
audioInSrtcp,
|
|
38814
41399
|
audioSendGate: null,
|
|
38815
41400
|
ipVersion,
|
|
41401
|
+
videoLoopRcvbuf: videoLoop.buffer,
|
|
41402
|
+
audioLoopRcvbuf: audioLoop.buffer,
|
|
41403
|
+
videoLoopPort: videoLoopUdp.address().port,
|
|
41404
|
+
audioLoopPort: audioLoopUdp.address().port,
|
|
41405
|
+
videoLoopKernelDrops: null,
|
|
41406
|
+
audioLoopKernelDrops: null,
|
|
38816
41407
|
ffmpeg: null,
|
|
38817
41408
|
lastStartParams: null,
|
|
38818
41409
|
upstreamAudioSrtp,
|
|
@@ -38852,6 +41443,7 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
38852
41443
|
});
|
|
38853
41444
|
videoLoopUdp.on("message", (rtpPacket) => {
|
|
38854
41445
|
session.videoPacketsForwarded += 1;
|
|
41446
|
+
if (rtpPacketCarriesIdr(rtpPacket)) recordKeyframe(session);
|
|
38855
41447
|
if (session.videoPacketsForwarded === 1) tagLog.info("export-hap: first video packet from ffmpeg", { meta: {
|
|
38856
41448
|
sessionId: session.sessionId,
|
|
38857
41449
|
bytes: rtpPacket.length
|
|
@@ -38867,6 +41459,8 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
38867
41459
|
bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId }).debug("export-hap: incoming-audio handler error (dropped)", { meta: { error: errMsg$8(err) } });
|
|
38868
41460
|
});
|
|
38869
41461
|
});
|
|
41462
|
+
logLoopbackBuffer(tagLog, request.sessionID, "video", videoLoop.buffer);
|
|
41463
|
+
logLoopbackBuffer(tagLog, request.sessionID, "audio", audioLoop.buffer);
|
|
38870
41464
|
tagLog.info("export-hap: stream prepared", { meta: {
|
|
38871
41465
|
sessionId: request.sessionID,
|
|
38872
41466
|
controllerAddress: request.targetAddress,
|
|
@@ -38947,12 +41541,45 @@ function sameIpv4Subnet(a, mask, b) {
|
|
|
38947
41541
|
return true;
|
|
38948
41542
|
}
|
|
38949
41543
|
/**
|
|
41544
|
+
* Report one loopback socket's receive buffer.
|
|
41545
|
+
*
|
|
41546
|
+
* `warn` when the video leg cannot hold a 4K key-frame burst: that is the state
|
|
41547
|
+
* in which this addon silently drops most of a key frame and the tile stays
|
|
41548
|
+
* black, and it was invisible for the whole life of this code path.
|
|
41549
|
+
*/
|
|
41550
|
+
function logLoopbackBuffer(log, sessionId, leg, outcome) {
|
|
41551
|
+
const meta = {
|
|
41552
|
+
sessionId,
|
|
41553
|
+
leg,
|
|
41554
|
+
requestedBytes: outcome.requestedBytes,
|
|
41555
|
+
effectiveBytes: outcome.effectiveBytes,
|
|
41556
|
+
clamped: outcome.clamped,
|
|
41557
|
+
sufficientForKeyframeBurst: outcome.sufficientForKeyframeBurst,
|
|
41558
|
+
error: outcome.error
|
|
41559
|
+
};
|
|
41560
|
+
if (leg === "video" && !outcome.sufficientForKeyframeBurst) {
|
|
41561
|
+
log.warn("export-hap: loopback receive buffer is TOO SMALL for a key-frame burst — raise net.core.rmem_max on the host", { meta });
|
|
41562
|
+
return;
|
|
41563
|
+
}
|
|
41564
|
+
log.info("export-hap: loopback receive buffer", { meta });
|
|
41565
|
+
}
|
|
41566
|
+
/**
|
|
38950
41567
|
* Resolve once-per-session: the local IP we bind iOS-facing sockets to
|
|
38951
41568
|
* AND its mate on `127.0.0.1` for the ffmpeg loopback path. Both go
|
|
38952
41569
|
* through the bounded-wait `dgram.bind` pattern.
|
|
41570
|
+
*
|
|
41571
|
+
* `SO_RCVBUF` is set AFTER the bind and read back, never assumed. Until
|
|
41572
|
+
* 2026-08-07 nothing set it at all, so these sockets ran on
|
|
41573
|
+
* `net.core.rmem_default` (212 992 B on this hub) — about a fifth of one 4K
|
|
41574
|
+
* key frame, which arrives as ~750 datagrams in one burst. See
|
|
41575
|
+
* `stream-socket-buffer.ts` for the measurement.
|
|
38953
41576
|
*/
|
|
38954
|
-
async function bindLoopback(ipVersion) {
|
|
38955
|
-
|
|
41577
|
+
async function bindLoopback(ipVersion, requestedRcvbufBytes) {
|
|
41578
|
+
const socket = await bindUdp(ipVersion, ipVersion === "ipv6" ? "::1" : "127.0.0.1");
|
|
41579
|
+
return {
|
|
41580
|
+
socket,
|
|
41581
|
+
buffer: applyReceiveBuffer(socket, requestedRcvbufBytes)
|
|
41582
|
+
};
|
|
38956
41583
|
}
|
|
38957
41584
|
/** Book a named drop. Every silent `return` on the streaming path routes here. */
|
|
38958
41585
|
function drop(session, reason) {
|
|
@@ -38987,6 +41614,135 @@ function countInbound(session, leg, packet, log) {
|
|
|
38987
41614
|
leg,
|
|
38988
41615
|
bytes: packet.length
|
|
38989
41616
|
} });
|
|
41617
|
+
if (kind === "rtcp") readControllerRtcp(session, leg, packet, log);
|
|
41618
|
+
}
|
|
41619
|
+
/** Fold a decrypted RTCP datagram into the leg's tally. Immutable, per `ReceiverReportTally`. */
|
|
41620
|
+
function storeReceiverReports(session, leg, tally) {
|
|
41621
|
+
if (leg === "video") session.videoReceiverReports = tally;
|
|
41622
|
+
else session.audioReceiverReports = tally;
|
|
41623
|
+
}
|
|
41624
|
+
/**
|
|
41625
|
+
* Decrypt one inbound RTCP datagram and read the controller's Receiver Report.
|
|
41626
|
+
*
|
|
41627
|
+
* This is the datum the 2026-08-06 telemetry left missing. That round proved
|
|
41628
|
+
* iOS DOES send us RTCP on the video leg — 21 and 34 packets across two
|
|
41629
|
+
* sessions, the gate opening on a real controller packet at ~550 ms — which
|
|
41630
|
+
* killed the long-standing "iOS never probes us" belief. But counting packets
|
|
41631
|
+
* says only that the controller spoke; a Receiver Report says WHAT it said,
|
|
41632
|
+
* and that splits the remaining causes into two disjoint families that nothing
|
|
41633
|
+
* else in this system can tell apart. See `LossVerdict`.
|
|
41634
|
+
*
|
|
41635
|
+
* Runs on a UDP `message` handler, so nothing here may throw: a controller
|
|
41636
|
+
* that sends one deformed datagram must not take the session with it. Every
|
|
41637
|
+
* failure books a named drop and increments `unreadable`, because a report we
|
|
41638
|
+
* could not read and a report that never came must never look alike.
|
|
41639
|
+
*/
|
|
41640
|
+
function readControllerRtcp(session, leg, packet, log) {
|
|
41641
|
+
const srtcp = leg === "video" ? session.videoInSrtcp : session.audioInSrtcp;
|
|
41642
|
+
const tally = leg === "video" ? session.videoReceiverReports : session.audioReceiverReports;
|
|
41643
|
+
if (!srtcp) {
|
|
41644
|
+
drop(session, "inbound-rtcp-no-srtcp");
|
|
41645
|
+
storeReceiverReports(session, leg, recordUnreadableRtcp(tally));
|
|
41646
|
+
logUnreadableRtcp(session, leg, "no-srtcp-context", log);
|
|
41647
|
+
return;
|
|
41648
|
+
}
|
|
41649
|
+
let plaintext;
|
|
41650
|
+
try {
|
|
41651
|
+
plaintext = srtcp.decrypt(packet);
|
|
41652
|
+
} catch (err) {
|
|
41653
|
+
drop(session, "inbound-rtcp-decrypt-failed");
|
|
41654
|
+
storeReceiverReports(session, leg, recordUnreadableRtcp(tally));
|
|
41655
|
+
logUnreadableRtcp(session, leg, `decrypt: ${errMsg$8(err)}`, log);
|
|
41656
|
+
return;
|
|
41657
|
+
}
|
|
41658
|
+
const outcome = ingestDecryptedRtcp(plaintext, tally);
|
|
41659
|
+
storeReceiverReports(session, leg, outcome.tally);
|
|
41660
|
+
if (outcome.failure !== null) {
|
|
41661
|
+
drop(session, "inbound-rtcp-parse-failed");
|
|
41662
|
+
logUnreadableRtcp(session, leg, `parse: ${outcome.failure}`, log);
|
|
41663
|
+
return;
|
|
41664
|
+
}
|
|
41665
|
+
const isFirst = tally.reportsParsed === 0 && outcome.tally.reportsParsed > 0;
|
|
41666
|
+
logReceiverReports(session, leg, outcome.reports, isFirst, log);
|
|
41667
|
+
}
|
|
41668
|
+
/** Throttled per leg — a controller sending nothing but garbage must not become the log. */
|
|
41669
|
+
function logUnreadableRtcp(session, leg, reason, log) {
|
|
41670
|
+
const tally = leg === "video" ? session.videoReceiverReports : session.audioReceiverReports;
|
|
41671
|
+
if (!shouldLogReceiverReport(session, leg)) return;
|
|
41672
|
+
log.warn("export-hap: inbound RTCP could not be read", { meta: {
|
|
41673
|
+
sessionId: session.sessionId,
|
|
41674
|
+
leg,
|
|
41675
|
+
reason,
|
|
41676
|
+
unreadable: tally.unreadable
|
|
41677
|
+
} });
|
|
41678
|
+
}
|
|
41679
|
+
/**
|
|
41680
|
+
* True at most once per `RECEIVER_REPORT_LOG_INTERVAL_MS` per leg. Stamps the
|
|
41681
|
+
* leg on the way out so the caller cannot forget to.
|
|
41682
|
+
*/
|
|
41683
|
+
function shouldLogReceiverReport(session, leg) {
|
|
41684
|
+
const now = Date.now();
|
|
41685
|
+
if (now < (leg === "video" ? session.videoRrLoggedAt : session.audioRrLoggedAt) + RECEIVER_REPORT_LOG_INTERVAL_MS) return false;
|
|
41686
|
+
if (leg === "video") session.videoRrLoggedAt = now;
|
|
41687
|
+
else session.audioRrLoggedAt = now;
|
|
41688
|
+
return true;
|
|
41689
|
+
}
|
|
41690
|
+
/**
|
|
41691
|
+
* Emit the controller's own numbers.
|
|
41692
|
+
*
|
|
41693
|
+
* The FIRST report on a leg gets its own message: that one arriving at all is
|
|
41694
|
+
* the proof iOS is engaged with the stream, and it was worth a year of
|
|
41695
|
+
* argument. Everything after it is throttled to the heartbeat's cadence.
|
|
41696
|
+
*/
|
|
41697
|
+
function logReceiverReports(session, leg, reports, isFirst, log) {
|
|
41698
|
+
const tally = leg === "video" ? session.videoReceiverReports : session.audioReceiverReports;
|
|
41699
|
+
const block = reports.flatMap((report) => report.blocks).at(-1);
|
|
41700
|
+
if (!block) return;
|
|
41701
|
+
const reporter = reports.find((report) => report.blocks.length > 0);
|
|
41702
|
+
const meta = {
|
|
41703
|
+
sessionId: session.sessionId,
|
|
41704
|
+
leg,
|
|
41705
|
+
reporterSsrc: reporter?.reporterSsrc ?? null,
|
|
41706
|
+
aboutSsrc: block.aboutSsrc,
|
|
41707
|
+
fractionLostPct: block.fractionLostPct,
|
|
41708
|
+
cumulativePacketsLost: block.cumulativePacketsLost,
|
|
41709
|
+
extendedHighestSequence: block.extendedHighestSequence,
|
|
41710
|
+
jitter: block.jitter,
|
|
41711
|
+
delaySinceLastSrMs: block.delaySinceLastSrMs,
|
|
41712
|
+
reportsParsed: tally.reportsParsed,
|
|
41713
|
+
worstFractionLostPct: tally.worstFractionLostPct
|
|
41714
|
+
};
|
|
41715
|
+
if (isFirst) {
|
|
41716
|
+
shouldLogReceiverReport(session, leg);
|
|
41717
|
+
log.info("export-hap: FIRST RTCP Receiver Report from controller — iOS is receiving and reporting", { meta });
|
|
41718
|
+
return;
|
|
41719
|
+
}
|
|
41720
|
+
if (!shouldLogReceiverReport(session, leg)) return;
|
|
41721
|
+
log.info("export-hap: controller receiver report", { meta });
|
|
41722
|
+
}
|
|
41723
|
+
/**
|
|
41724
|
+
* Record a forwarded key frame. Kept separate from the packet counter because
|
|
41725
|
+
* the interesting quantity is TIMING, not a tally: the first arrival dates the
|
|
41726
|
+
* moment the controller could begin decoding, and the widest gap says how long
|
|
41727
|
+
* a mid-GOP join can be expected to stare at a loader.
|
|
41728
|
+
*/
|
|
41729
|
+
function recordKeyframe(session) {
|
|
41730
|
+
const now = Date.now();
|
|
41731
|
+
session.videoKeyframes += 1;
|
|
41732
|
+
if (session.firstKeyframeAtMs === null) session.firstKeyframeAtMs = now;
|
|
41733
|
+
else if (session.lastKeyframeAtMs !== null) session.maxKeyframeGapMs = Math.max(session.maxKeyframeGapMs, now - session.lastKeyframeAtMs);
|
|
41734
|
+
session.lastKeyframeAtMs = now;
|
|
41735
|
+
}
|
|
41736
|
+
/**
|
|
41737
|
+
* Refresh the kernel's per-socket drop counters.
|
|
41738
|
+
*
|
|
41739
|
+
* Called immediately before every line that reports them, because a stale
|
|
41740
|
+
* sample on the summary would answer the experiment's central question with
|
|
41741
|
+
* data from five seconds earlier. Cheap: `/proc/net/udp` is memory-backed.
|
|
41742
|
+
*/
|
|
41743
|
+
function sampleLoopbackDrops(session) {
|
|
41744
|
+
session.videoLoopKernelDrops = mergeDropSample(session.videoLoopKernelDrops, readUdpSocketDrops(session.videoLoopPort, session.ipVersion));
|
|
41745
|
+
session.audioLoopKernelDrops = mergeDropSample(session.audioLoopKernelDrops, readUdpSocketDrops(session.audioLoopPort, session.ipVersion));
|
|
38990
41746
|
}
|
|
38991
41747
|
/** Snapshot every counter into the summary meta. */
|
|
38992
41748
|
function sessionSummaryMeta(session) {
|
|
@@ -38998,6 +41754,9 @@ function sessionSummaryMeta(session) {
|
|
|
38998
41754
|
selectedSlot: session.selectedSlot,
|
|
38999
41755
|
videoPacketsForwarded: session.videoPacketsForwarded,
|
|
39000
41756
|
audioPacketsForwarded: session.audioPacketsForwarded,
|
|
41757
|
+
videoKeyframes: session.videoKeyframes,
|
|
41758
|
+
firstKeyframeAtMs: session.firstKeyframeAtMs,
|
|
41759
|
+
maxKeyframeGapMs: session.maxKeyframeGapMs,
|
|
39001
41760
|
videoRtcpSrSent: session.videoRtcpSrSent,
|
|
39002
41761
|
audioRtcpSrSent: session.audioRtcpSrSent,
|
|
39003
41762
|
videoRtcpReceived: session.videoRtcpReceived,
|
|
@@ -39006,9 +41765,23 @@ function sessionSummaryMeta(session) {
|
|
|
39006
41765
|
audioRtpReceived: session.audioRtpReceived,
|
|
39007
41766
|
videoGate: session.videoGate,
|
|
39008
41767
|
audioGate: session.audioGate,
|
|
41768
|
+
videoReceiverReports: session.videoReceiverReports,
|
|
41769
|
+
audioReceiverReports: session.audioReceiverReports,
|
|
39009
41770
|
drops: session.drops,
|
|
39010
41771
|
ffmpegExit: session.ffmpegExit,
|
|
39011
|
-
stopRequestedByController: session.stopRequestedByController
|
|
41772
|
+
stopRequestedByController: session.stopRequestedByController,
|
|
41773
|
+
videoLoopback: {
|
|
41774
|
+
rcvbufRequestedBytes: session.videoLoopRcvbuf.requestedBytes,
|
|
41775
|
+
rcvbufEffectiveBytes: session.videoLoopRcvbuf.effectiveBytes,
|
|
41776
|
+
rcvbufClamped: session.videoLoopRcvbuf.clamped,
|
|
41777
|
+
kernelDrops: session.videoLoopKernelDrops
|
|
41778
|
+
},
|
|
41779
|
+
audioLoopback: {
|
|
41780
|
+
rcvbufRequestedBytes: session.audioLoopRcvbuf.requestedBytes,
|
|
41781
|
+
rcvbufEffectiveBytes: session.audioLoopRcvbuf.effectiveBytes,
|
|
41782
|
+
rcvbufClamped: session.audioLoopRcvbuf.clamped,
|
|
41783
|
+
kernelDrops: session.audioLoopKernelDrops
|
|
41784
|
+
}
|
|
39012
41785
|
});
|
|
39013
41786
|
}
|
|
39014
41787
|
/**
|
|
@@ -39022,6 +41795,7 @@ function armHeartbeat(session, log) {
|
|
|
39022
41795
|
const timer = setInterval(() => {
|
|
39023
41796
|
const forwarded = session.videoPacketsForwarded - lastVideo;
|
|
39024
41797
|
lastVideo = session.videoPacketsForwarded;
|
|
41798
|
+
sampleLoopbackDrops(session);
|
|
39025
41799
|
log.info("export-hap: stream heartbeat", { meta: {
|
|
39026
41800
|
...sessionSummaryMeta(session),
|
|
39027
41801
|
videoPacketsSinceLastBeat: forwarded,
|
|
@@ -39041,7 +41815,15 @@ function stopHeartbeat(session) {
|
|
|
39041
41815
|
* a HomeKit session died. It carries the negotiated parameters, the slot we
|
|
39042
41816
|
* dialled and the rate we had promised for it, packet counts in both
|
|
39043
41817
|
* directions on both legs, each leg's gate outcome, ffmpeg's exit, who asked
|
|
39044
|
-
* for the teardown,
|
|
41818
|
+
* for the teardown, every named drop — and, since 2026-08-06, what the
|
|
41819
|
+
* controller itself reported receiving.
|
|
41820
|
+
*
|
|
41821
|
+
* Read `videoLossVerdict` first. `transmit-suspect` says the controller is not
|
|
41822
|
+
* getting our packets intact and nothing past the wire matters;
|
|
41823
|
+
* `decode-suspect` says it got them and rendered nothing anyway;
|
|
41824
|
+
* `no-reports` says we still cannot tell, and `videoReceiverReports.unreadable`
|
|
41825
|
+
* then distinguishes "the controller said nothing" from "we could not read what
|
|
41826
|
+
* it said".
|
|
39045
41827
|
*
|
|
39046
41828
|
* Emitted from the ffmpeg `exit` handler — the only place the exit code is
|
|
39047
41829
|
* known — and directly from the teardown paths when there is no ffmpeg to wait
|
|
@@ -39052,6 +41834,7 @@ function stopHeartbeat(session) {
|
|
|
39052
41834
|
*/
|
|
39053
41835
|
function logSessionSummary(session, log, trigger) {
|
|
39054
41836
|
session.endedAtMs = Date.now();
|
|
41837
|
+
sampleLoopbackDrops(session);
|
|
39055
41838
|
log.info("export-hap: stream session summary", { meta: {
|
|
39056
41839
|
...sessionSummaryMeta(session),
|
|
39057
41840
|
trigger
|
|
@@ -39236,6 +42019,7 @@ async function handleStreamRequest(request, sessions, bctx, advertised) {
|
|
|
39236
42019
|
const hadFfmpeg = session.ffmpeg !== null;
|
|
39237
42020
|
killFfmpeg(session, bctx.ctx, bctx.numericDeviceId);
|
|
39238
42021
|
stopHeartbeat(session);
|
|
42022
|
+
sampleLoopbackDrops(session);
|
|
39239
42023
|
if (!hadFfmpeg) logSessionSummary(session, log, "controller-stop-no-ffmpeg");
|
|
39240
42024
|
await closeIntercomTalkSession(session, bctx).catch(() => void 0);
|
|
39241
42025
|
closeSocket(session.videoUdp);
|
|
@@ -39345,7 +42129,7 @@ async function handleStreamRequest(request, sessions, bctx, advertised) {
|
|
|
39345
42129
|
async function startFfmpegForSession(bctx, session, sessionId, video, advertised) {
|
|
39346
42130
|
const { ctx, proxy, numericDeviceId, options } = bctx;
|
|
39347
42131
|
const startLog = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
39348
|
-
const entries = await proxy.cameraStreams?.getProfileRtspEntries({}) ?? [];
|
|
42132
|
+
const [entries, brokerStreams] = await Promise.all([(async () => await proxy.cameraStreams?.getProfileRtspEntries({}) ?? [])(), (async () => await proxy.cameraStreams?.getBrokerStreams({}) ?? [])()]);
|
|
39349
42133
|
if (entries.length === 0) {
|
|
39350
42134
|
startLog.warn("export-hap: stream start DROPPED — device publishes no profile RTSP entries", { meta: {
|
|
39351
42135
|
sessionId,
|
|
@@ -39354,11 +42138,29 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
|
|
|
39354
42138
|
throw new Error(`export-hap: no profile RTSP entries for device ${numericDeviceId}`);
|
|
39355
42139
|
}
|
|
39356
42140
|
const pref = options.hapDeviceSettings.streamPreference;
|
|
39357
|
-
const
|
|
39358
|
-
|
|
39359
|
-
|
|
39360
|
-
|
|
39361
|
-
|
|
42141
|
+
const bitrates = await probeProfileBitrates({
|
|
42142
|
+
bctx,
|
|
42143
|
+
slots: brokerStreams,
|
|
42144
|
+
log: startLog
|
|
42145
|
+
});
|
|
42146
|
+
const connection = classifyConnection({
|
|
42147
|
+
negotiatedWidth: video.width,
|
|
42148
|
+
audioPacketTimeMs: session.negotiated?.audioPacketTimeMs ?? 20,
|
|
42149
|
+
viaHomeHub: false
|
|
42150
|
+
});
|
|
42151
|
+
const fit = selectStreamForBudget({
|
|
42152
|
+
entries: withSlotCodecs(entries, brokerStreams),
|
|
42153
|
+
deviceId: numericDeviceId,
|
|
42154
|
+
pref,
|
|
42155
|
+
connection,
|
|
42156
|
+
targetResolution: {
|
|
42157
|
+
width: video.width,
|
|
42158
|
+
height: video.height
|
|
42159
|
+
},
|
|
42160
|
+
negotiatedMaxBitrateKbps: video.max_bit_rate,
|
|
42161
|
+
bitrates
|
|
42162
|
+
});
|
|
42163
|
+
if (fit === null) {
|
|
39362
42164
|
startLog.warn("export-hap: stream start DROPPED — no ENABLED profile RTSP entry", { meta: {
|
|
39363
42165
|
sessionId,
|
|
39364
42166
|
streamPreference: pref,
|
|
@@ -39367,14 +42169,18 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
|
|
|
39367
42169
|
} });
|
|
39368
42170
|
throw new Error(`export-hap: no enabled RTSP entries for device ${numericDeviceId}`);
|
|
39369
42171
|
}
|
|
42172
|
+
const picked = fit.picked;
|
|
39370
42173
|
const rtspUrl = picked.url;
|
|
39371
|
-
const slot =
|
|
42174
|
+
const slot = brokerStreams.find((s) => s.profile === fit.profile);
|
|
39372
42175
|
const codec = (picked.codec ?? slot?.codec ?? "").toLowerCase();
|
|
39373
|
-
const needsTranscode =
|
|
42176
|
+
const needsTranscode = fit.kind === "transcode";
|
|
39374
42177
|
const pickedProfile = toKnownProfile(picked.profileId);
|
|
39375
42178
|
const resolvedFps = pickedProfile === null ? void 0 : advertised.fpsByProfile.get(pickedProfile);
|
|
39376
42179
|
const advertisedFps = resolvedFps?.fps ?? video.fps;
|
|
39377
42180
|
const advertisedFpsSource = resolvedFps?.source ?? "assumed";
|
|
42181
|
+
const deliveredFps = needsTranscode ? video.fps : advertisedFps;
|
|
42182
|
+
const slotEvidence = pickedProfile === null ? void 0 : bitrates.get(pickedProfile);
|
|
42183
|
+
const fitNotes = formatFitNotes(fit.notes);
|
|
39378
42184
|
session.selectedSlot = {
|
|
39379
42185
|
profile: pickedProfile,
|
|
39380
42186
|
brokerId: picked.brokerId,
|
|
@@ -39382,14 +42188,33 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
|
|
|
39382
42188
|
height: picked.resolution?.height ?? null,
|
|
39383
42189
|
advertisedFps,
|
|
39384
42190
|
advertisedFpsSource,
|
|
42191
|
+
deliveredFps,
|
|
39385
42192
|
codec: codec.length > 0 ? codec : "unknown",
|
|
39386
|
-
transcode: needsTranscode
|
|
42193
|
+
transcode: needsTranscode,
|
|
42194
|
+
fitReason: fit.reason,
|
|
42195
|
+
publishedKbps: slotEvidence?.publishedKbps ?? null,
|
|
42196
|
+
measuredKbps: slotEvidence?.measuredKbps ?? null,
|
|
42197
|
+
budgetKbps: fit.budgetKbps,
|
|
42198
|
+
fitNotes
|
|
39387
42199
|
};
|
|
39388
|
-
|
|
42200
|
+
startLog.info("export-hap: stream bitrate fit resolved", { meta: {
|
|
42201
|
+
sessionId,
|
|
42202
|
+
decision: fit.kind,
|
|
42203
|
+
reason: fit.reason,
|
|
42204
|
+
negotiatedMaxBitrateKbps: video.max_bit_rate,
|
|
42205
|
+
encodeBudgetKbps: fit.budgetKbps,
|
|
42206
|
+
profile: pickedProfile,
|
|
42207
|
+
brokerId: picked.brokerId,
|
|
42208
|
+
slotPublishedKbps: slotEvidence?.publishedKbps ?? null,
|
|
42209
|
+
slotMeasuredKbps: slotEvidence?.measuredKbps ?? null,
|
|
42210
|
+
candidates: fitNotes
|
|
42211
|
+
} });
|
|
42212
|
+
if (deliveredFps !== video.fps) startLog.warn("export-hap: negotiated fps does NOT match the rate we are about to deliver", { meta: {
|
|
39389
42213
|
sessionId,
|
|
39390
42214
|
negotiatedFps: video.fps,
|
|
39391
42215
|
slotFps: advertisedFps,
|
|
39392
42216
|
slotFpsSource: advertisedFpsSource,
|
|
42217
|
+
deliveredFps,
|
|
39393
42218
|
profile: pickedProfile,
|
|
39394
42219
|
brokerId: picked.brokerId,
|
|
39395
42220
|
transcode: needsTranscode
|
|
@@ -39398,101 +42223,90 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
|
|
|
39398
42223
|
const audioLoopPort = session.audioLoopUdp.address().port;
|
|
39399
42224
|
const videoTarget = `rtp://127.0.0.1:${videoLoopPort}?pkt_size=${video.mtu}`;
|
|
39400
42225
|
const audioTarget = `rtp://127.0.0.1:${audioLoopPort}?pkt_size=${video.mtu}`;
|
|
39401
|
-
const
|
|
39402
|
-
|
|
39403
|
-
|
|
39404
|
-
|
|
39405
|
-
|
|
39406
|
-
|
|
39407
|
-
|
|
39408
|
-
|
|
39409
|
-
|
|
39410
|
-
|
|
39411
|
-
|
|
39412
|
-
|
|
39413
|
-
|
|
39414
|
-
"-
|
|
39415
|
-
|
|
39416
|
-
|
|
39417
|
-
|
|
39418
|
-
|
|
39419
|
-
`${video.max_bit_rate}k`,
|
|
39420
|
-
"-profile:v",
|
|
39421
|
-
"baseline",
|
|
39422
|
-
"-level",
|
|
39423
|
-
"3.1"
|
|
39424
|
-
] : [
|
|
39425
|
-
"-c:v",
|
|
39426
|
-
"copy",
|
|
39427
|
-
"-bsf:v",
|
|
39428
|
-
"dump_extra"
|
|
39429
|
-
];
|
|
42226
|
+
const videoPlan = buildVideoPlan({
|
|
42227
|
+
transcode: needsTranscode,
|
|
42228
|
+
width: video.width,
|
|
42229
|
+
height: video.height,
|
|
42230
|
+
fps: deliveredFps,
|
|
42231
|
+
budgetKbps: fit.budgetKbps
|
|
42232
|
+
});
|
|
42233
|
+
const hwDecode = selectHwDecode({
|
|
42234
|
+
transcode: needsTranscode,
|
|
42235
|
+
reading: needsTranscode ? await withDeadline(probeDecoderHwaccel({
|
|
42236
|
+
source: decoderInfoSourceFromContext(ctx),
|
|
42237
|
+
log: startLog,
|
|
42238
|
+
memo: options.decodeMemos.reading
|
|
42239
|
+
}), HWACCEL_PROBE_BUDGET_MS, null, () => startLog.warn("export-hap: hwaccel probe ABANDONED on its budget — decoding in SOFTWARE", { meta: { budgetMs: HWACCEL_PROBE_BUDGET_MS } })) : null,
|
|
42240
|
+
platform: process.platform,
|
|
42241
|
+
recentlyFailedBackend: options.decodeMemos.failedBackend.read() ?? null
|
|
42242
|
+
});
|
|
42243
|
+
logDecodePath(startLog, sessionId, hwDecode, needsTranscode);
|
|
39430
42244
|
const videoSsrcSigned = session.videoSsrc | 0;
|
|
39431
42245
|
const audioSsrcSigned = video.audio_ssrc | 0;
|
|
39432
|
-
const
|
|
39433
|
-
|
|
39434
|
-
"-loglevel",
|
|
39435
|
-
"warning",
|
|
39436
|
-
"-rtsp_transport",
|
|
39437
|
-
"tcp",
|
|
39438
|
-
"-i",
|
|
42246
|
+
const buildArgs = (decode) => buildFfmpegArgs(buildSessionInvocation({
|
|
42247
|
+
decode,
|
|
39439
42248
|
rtspUrl,
|
|
39440
|
-
|
|
39441
|
-
"-map",
|
|
39442
|
-
"0:v:0",
|
|
39443
|
-
...videoArgs,
|
|
39444
|
-
"-payload_type",
|
|
39445
|
-
String(video.pt),
|
|
39446
|
-
"-ssrc",
|
|
39447
|
-
String(videoSsrcSigned),
|
|
39448
|
-
"-f",
|
|
39449
|
-
"rtp",
|
|
42249
|
+
video: videoPlan,
|
|
39450
42250
|
videoTarget,
|
|
39451
|
-
|
|
39452
|
-
|
|
39453
|
-
|
|
39454
|
-
|
|
39455
|
-
|
|
39456
|
-
|
|
39457
|
-
|
|
39458
|
-
|
|
39459
|
-
"lowdelay",
|
|
39460
|
-
"-frame_duration",
|
|
39461
|
-
String(video.packet_time ?? 20),
|
|
39462
|
-
"-flags",
|
|
39463
|
-
"+global_header",
|
|
39464
|
-
"-ar",
|
|
39465
|
-
String((video.sample_rate ?? 16) * 1e3),
|
|
39466
|
-
"-b:a",
|
|
39467
|
-
`${OPUS_BITRATE_KBPS}k`,
|
|
39468
|
-
"-bufsize",
|
|
39469
|
-
`${OPUS_BITRATE_KBPS * 4}k`,
|
|
39470
|
-
"-ac",
|
|
39471
|
-
String(OPUS_CHANNELS),
|
|
39472
|
-
"-payload_type",
|
|
39473
|
-
String(video.audio_pt),
|
|
39474
|
-
"-ssrc",
|
|
39475
|
-
String(audioSsrcSigned),
|
|
39476
|
-
"-f",
|
|
39477
|
-
"rtp",
|
|
39478
|
-
audioTarget
|
|
39479
|
-
];
|
|
42251
|
+
audioTarget,
|
|
42252
|
+
videoPayloadType: video.pt,
|
|
42253
|
+
videoSsrcSigned,
|
|
42254
|
+
audioPayloadType: video.audio_pt,
|
|
42255
|
+
audioSsrcSigned,
|
|
42256
|
+
audioPacketTimeMs: video.packet_time ?? 20,
|
|
42257
|
+
audioSampleRateKhz: video.sample_rate ?? 16
|
|
42258
|
+
}));
|
|
39480
42259
|
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
39481
|
-
|
|
39482
|
-
|
|
39483
|
-
|
|
39484
|
-
"
|
|
39485
|
-
|
|
39486
|
-
|
|
39487
|
-
|
|
39488
|
-
|
|
39489
|
-
|
|
39490
|
-
|
|
39491
|
-
|
|
39492
|
-
line
|
|
39493
|
-
|
|
39494
|
-
|
|
39495
|
-
|
|
42260
|
+
let hardwareAlreadyFailed = false;
|
|
42261
|
+
const spawnFfmpeg = (decision) => {
|
|
42262
|
+
const spawnedAtMs = Date.now();
|
|
42263
|
+
const usedHardware = decision.kind === "hardware";
|
|
42264
|
+
const proc = (0, node_child_process.spawn)("ffmpeg", buildArgs(decision), { stdio: [
|
|
42265
|
+
"ignore",
|
|
42266
|
+
"ignore",
|
|
42267
|
+
"pipe"
|
|
42268
|
+
] });
|
|
42269
|
+
session.ffmpeg = proc;
|
|
42270
|
+
proc.stderr?.on("data", (chunk) => {
|
|
42271
|
+
const line = chunk.toString("utf8").trim();
|
|
42272
|
+
if (!line) return;
|
|
42273
|
+
log.info("export-hap: ffmpeg", { meta: {
|
|
42274
|
+
sessionId,
|
|
42275
|
+
line
|
|
42276
|
+
} });
|
|
42277
|
+
});
|
|
42278
|
+
proc.once("exit", (code, signal) => {
|
|
42279
|
+
if (shouldRetryInSoftware({
|
|
42280
|
+
usedHardware,
|
|
42281
|
+
hardwareAlreadyFailed,
|
|
42282
|
+
stopRequestedByController: session.stopRequestedByController,
|
|
42283
|
+
videoPacketsForwarded: session.videoPacketsForwarded,
|
|
42284
|
+
runtimeMs: Date.now() - spawnedAtMs
|
|
42285
|
+
})) {
|
|
42286
|
+
hardwareAlreadyFailed = true;
|
|
42287
|
+
if (decision.kind === "hardware") options.decodeMemos.failedBackend.write(decision.backend);
|
|
42288
|
+
log.warn("export-hap: hardware decode FAILED at init — respawning ffmpeg in software", { meta: {
|
|
42289
|
+
sessionId,
|
|
42290
|
+
backend: decision.kind === "hardware" ? decision.backend : null,
|
|
42291
|
+
backendSource: decision.kind === "hardware" ? decision.source : null,
|
|
42292
|
+
code,
|
|
42293
|
+
signal,
|
|
42294
|
+
runtimeMs: Date.now() - spawnedAtMs
|
|
42295
|
+
} });
|
|
42296
|
+
if (session.ffmpeg === proc) session.ffmpeg = null;
|
|
42297
|
+
spawnFfmpeg(software("hardware-attempt-failed"));
|
|
42298
|
+
return;
|
|
42299
|
+
}
|
|
42300
|
+
onFfmpegExit(proc, code, signal);
|
|
42301
|
+
});
|
|
42302
|
+
proc.once("error", (err) => {
|
|
42303
|
+
log.warn("export-hap: ffmpeg spawn failed", { meta: {
|
|
42304
|
+
sessionId,
|
|
42305
|
+
error: err.message
|
|
42306
|
+
} });
|
|
42307
|
+
});
|
|
42308
|
+
};
|
|
42309
|
+
const onFfmpegExit = (proc, code, signal) => {
|
|
39496
42310
|
session.ffmpegExit = {
|
|
39497
42311
|
code,
|
|
39498
42312
|
signal
|
|
@@ -39509,13 +42323,8 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
|
|
|
39509
42323
|
else log.warn("export-hap: ffmpeg exited without a controller stop", { meta });
|
|
39510
42324
|
if (session.ffmpeg === proc) session.ffmpeg = null;
|
|
39511
42325
|
logSessionSummary(session, log, session.teardownTrigger ?? "ffmpeg-exit");
|
|
39512
|
-
}
|
|
39513
|
-
|
|
39514
|
-
log.warn("export-hap: ffmpeg spawn failed", { meta: {
|
|
39515
|
-
sessionId,
|
|
39516
|
-
error: err.message
|
|
39517
|
-
} });
|
|
39518
|
-
});
|
|
42326
|
+
};
|
|
42327
|
+
spawnFfmpeg(hwDecode);
|
|
39519
42328
|
log.info("export-hap: stream started", { meta: {
|
|
39520
42329
|
sessionId,
|
|
39521
42330
|
transcode: needsTranscode,
|
|
@@ -39526,8 +42335,38 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
|
|
|
39526
42335
|
negotiated: `${video.width}x${video.height}@${video.fps} ${video.max_bit_rate}kbps`,
|
|
39527
42336
|
slotFps: advertisedFps,
|
|
39528
42337
|
slotFpsSource: advertisedFpsSource,
|
|
42338
|
+
deliveredFps,
|
|
42339
|
+
fitReason: fit.reason,
|
|
42340
|
+
encodeBudgetKbps: fit.budgetKbps,
|
|
39529
42341
|
audioCodec: "opus",
|
|
39530
|
-
audioBitrateKbps: OPUS_BITRATE_KBPS
|
|
42342
|
+
audioBitrateKbps: OPUS_BITRATE_KBPS,
|
|
42343
|
+
videoDecode: hwDecode.kind === "hardware" ? hwDecode.backend : "software"
|
|
42344
|
+
} });
|
|
42345
|
+
}
|
|
42346
|
+
/**
|
|
42347
|
+
* Say which decode path was resolved, and WHY, once per session.
|
|
42348
|
+
*
|
|
42349
|
+
* At `info` on purpose — Loki carries `info+`, and "did this session use the
|
|
42350
|
+
* GPU" is the first question anyone asks of a transcoding hub. The software
|
|
42351
|
+
* branch is the one that must never be silent: it is a real cost being paid,
|
|
42352
|
+
* and every reason it can be reached is a different fix.
|
|
42353
|
+
*/
|
|
42354
|
+
function logDecodePath(log, sessionId, decision, transcode) {
|
|
42355
|
+
if (decision.kind === "hardware") {
|
|
42356
|
+
log.info("export-hap: video decode path resolved — HARDWARE", { meta: {
|
|
42357
|
+
sessionId,
|
|
42358
|
+
backend: decision.backend,
|
|
42359
|
+
backendSource: decision.source,
|
|
42360
|
+
decodeArgs: decision.args
|
|
42361
|
+
} });
|
|
42362
|
+
return;
|
|
42363
|
+
}
|
|
42364
|
+
const level = transcode ? "warn" : "info";
|
|
42365
|
+
const message = level === "warn" ? "export-hap: video decode path resolved — SOFTWARE, this transcode costs a core" : "export-hap: video decode path resolved — none needed";
|
|
42366
|
+
log[level](message, { meta: {
|
|
42367
|
+
sessionId,
|
|
42368
|
+
reason: decision.reason,
|
|
42369
|
+
transcode
|
|
39531
42370
|
} });
|
|
39532
42371
|
}
|
|
39533
42372
|
/**
|
|
@@ -39705,16 +42544,87 @@ function errMsg$8(err) {
|
|
|
39705
42544
|
return err instanceof Error ? err.message : String(err);
|
|
39706
42545
|
}
|
|
39707
42546
|
//#endregion
|
|
42547
|
+
//#region src/mappers/builders/doorbell-delivery.ts
|
|
42548
|
+
function isRecord(value) {
|
|
42549
|
+
return typeof value === "object" && value !== null;
|
|
42550
|
+
}
|
|
42551
|
+
function numberOrNull(value) {
|
|
42552
|
+
return typeof value === "number" ? value : null;
|
|
42553
|
+
}
|
|
42554
|
+
function isConnectionLike(value) {
|
|
42555
|
+
return isRecord(value) && typeof value["hasEventNotifications"] === "function";
|
|
42556
|
+
}
|
|
42557
|
+
function isIterable(value) {
|
|
42558
|
+
return isRecord(value) && typeof value[Symbol.iterator] === "function";
|
|
42559
|
+
}
|
|
42560
|
+
/** `accessory._server.httpServer.connections`, or null at any missing hop. */
|
|
42561
|
+
function readConnections(accessory) {
|
|
42562
|
+
if (!isRecord(accessory)) return null;
|
|
42563
|
+
const server = accessory["_server"];
|
|
42564
|
+
if (!isRecord(server)) return null;
|
|
42565
|
+
const httpServer = server["httpServer"];
|
|
42566
|
+
if (!isRecord(httpServer)) return null;
|
|
42567
|
+
const connections = httpServer["connections"];
|
|
42568
|
+
return isIterable(connections) ? connections : null;
|
|
42569
|
+
}
|
|
42570
|
+
/**
|
|
42571
|
+
* Probe how far a ring on `characteristic` of `accessory` can travel RIGHT NOW.
|
|
42572
|
+
* Pure with respect to HAP state — it only reads. Never throws.
|
|
42573
|
+
*/
|
|
42574
|
+
function describeDoorbellDelivery(accessory, characteristic) {
|
|
42575
|
+
const aid = isRecord(accessory) ? numberOrNull(accessory["aid"]) : null;
|
|
42576
|
+
const iid = isRecord(characteristic) ? numberOrNull(characteristic["iid"]) : null;
|
|
42577
|
+
const serverPublished = isRecord(accessory) && isRecord(accessory["_server"]);
|
|
42578
|
+
const connections = readConnections(accessory);
|
|
42579
|
+
if (connections === null) return {
|
|
42580
|
+
aid,
|
|
42581
|
+
iid,
|
|
42582
|
+
serverPublished,
|
|
42583
|
+
connectionCount: 0,
|
|
42584
|
+
subscriberCount: 0
|
|
42585
|
+
};
|
|
42586
|
+
let connectionCount = 0;
|
|
42587
|
+
let subscriberCount = 0;
|
|
42588
|
+
for (const connection of connections) {
|
|
42589
|
+
connectionCount += 1;
|
|
42590
|
+
if (aid === null || iid === null) continue;
|
|
42591
|
+
if (isConnectionLike(connection) && connection.hasEventNotifications(aid, iid)) subscriberCount += 1;
|
|
42592
|
+
}
|
|
42593
|
+
return {
|
|
42594
|
+
aid,
|
|
42595
|
+
iid,
|
|
42596
|
+
serverPublished,
|
|
42597
|
+
connectionCount,
|
|
42598
|
+
subscriberCount
|
|
42599
|
+
};
|
|
42600
|
+
}
|
|
42601
|
+
/**
|
|
42602
|
+
* True when the ring provably reached nobody: no connection is subscribed to
|
|
42603
|
+
* the characteristic, so hap-nodejs dropped every event frame silently. The
|
|
42604
|
+
* caller must say so out loud — this is a branch that discards work.
|
|
42605
|
+
*/
|
|
42606
|
+
function ringReachedNobody(report) {
|
|
42607
|
+
return report.subscriberCount === 0;
|
|
42608
|
+
}
|
|
42609
|
+
//#endregion
|
|
39708
42610
|
//#region src/mappers/builders/doorbell.ts
|
|
39709
42611
|
async function buildDoorbell(input) {
|
|
39710
42612
|
const { bctx, controller } = input;
|
|
39711
42613
|
const { ctx, numericDeviceId } = bctx;
|
|
42614
|
+
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
42615
|
+
log.info("export-hap: doorbell forward armed — HomeKit will ring on doorbell.onPressed");
|
|
39712
42616
|
const unsubscribe = ctx.eventBus.subscribe({ category: EventCategory.DoorbellOnPressed }, (event) => {
|
|
39713
|
-
if (event.data
|
|
42617
|
+
if (event.data?.deviceId !== numericDeviceId) return;
|
|
39714
42618
|
try {
|
|
42619
|
+
const delivery = describeDoorbellDelivery(bctx.accessory, bctx.accessory.getService(_homebridge_hap_nodejs.Service.Doorbell)?.getCharacteristic(_homebridge_hap_nodejs.Characteristic.ProgrammableSwitchEvent) ?? null);
|
|
39715
42620
|
controller.ringDoorbell();
|
|
42621
|
+
if (ringReachedNobody(delivery)) {
|
|
42622
|
+
log.warn("export-hap: doorbell rang but NO HomeKit controller is subscribed — the press was dropped before it left the hub (no home hub connected, or the accessory was republished and iOS has not re-subscribed yet)", { meta: { ...delivery } });
|
|
42623
|
+
return;
|
|
42624
|
+
}
|
|
42625
|
+
log.info("export-hap: doorbell SINGLE_PRESS pushed to HomeKit", { meta: { ...delivery } });
|
|
39716
42626
|
} catch (err) {
|
|
39717
|
-
|
|
42627
|
+
log.warn("export-hap: ringDoorbell() failed", { meta: { error: errMsg$7(err) } });
|
|
39718
42628
|
}
|
|
39719
42629
|
});
|
|
39720
42630
|
return { async dispose() {
|
|
@@ -39752,7 +42662,7 @@ async function buildIntercom(input) {
|
|
|
39752
42662
|
var RESET_DEBOUNCE_MS = 5e3;
|
|
39753
42663
|
async function buildMotionSensor(bctx) {
|
|
39754
42664
|
const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
|
|
39755
|
-
const motionService = accessory.addService(_homebridge_hap_nodejs.Service.MotionSensor, displayName);
|
|
42665
|
+
const motionService = accessory.addService(_homebridge_hap_nodejs.Service.MotionSensor, hapServiceName([displayName], `Camera ${numericDeviceId}`));
|
|
39756
42666
|
motionService.setCharacteristic(_homebridge_hap_nodejs.Characteristic.MotionDetected, false);
|
|
39757
42667
|
try {
|
|
39758
42668
|
const detected = await proxy.motion?.isDetected({});
|
|
@@ -39790,6 +42700,78 @@ function errMsg$6(err) {
|
|
|
39790
42700
|
return err instanceof Error ? err.message : String(err);
|
|
39791
42701
|
}
|
|
39792
42702
|
//#endregion
|
|
42703
|
+
//#region src/mappers/builders/service-label.ts
|
|
42704
|
+
/**
|
|
42705
|
+
* The ONE place a secondary service on the camera accessory gets its label.
|
|
42706
|
+
*
|
|
42707
|
+
* A "secondary service" here is a Switch or Lightbulb published alongside the
|
|
42708
|
+
* camera on the same accessory — the privacy switch, each accessory child
|
|
42709
|
+
* (siren, floodlight), each PTZ action. iOS Home renders these as their own
|
|
42710
|
+
* controls, and the operator has seen them as "Interruttore 1", "Interruttore
|
|
42711
|
+
* 2" through three separate rounds of fixes.
|
|
42712
|
+
*
|
|
42713
|
+
* ## Why `Name` alone cannot rename anything
|
|
42714
|
+
*
|
|
42715
|
+
* Two facts about hap-nodejs 2.1.7, both measured against the installed copy
|
|
42716
|
+
* rather than reasoned about:
|
|
42717
|
+
*
|
|
42718
|
+
* 1. `accessory.addService(Type, displayName, subtype)` ALREADY writes
|
|
42719
|
+
* `displayName` to `Characteristic.Name` (`Service` constructor). So every
|
|
42720
|
+
* round of this bug — including the one that moved the label onto
|
|
42721
|
+
* `ConfiguredName` — shipped with `Name` correctly set. "iOS had no name
|
|
42722
|
+
* to render" was never true.
|
|
42723
|
+
* 2. The mDNS configuration number (`c#`) is a sha1 over
|
|
42724
|
+
* `internalHAPRepresentation(false)`, which OMITS characteristic VALUES.
|
|
42725
|
+
* Changing the string in `Name` therefore does not bump `c#`, a paired
|
|
42726
|
+
* controller gets no signal to re-read `/accessories`, and the name it
|
|
42727
|
+
* cached at first enumeration stands forever.
|
|
42728
|
+
*
|
|
42729
|
+
* `Name` is also declared `pr` only — paired read, no write, no notify. It is
|
|
42730
|
+
* the seed a controller seeds its database from once; it is not a channel.
|
|
42731
|
+
*
|
|
42732
|
+
* ## Why `ConfiguredName`
|
|
42733
|
+
*
|
|
42734
|
+
* `ConfiguredName` (`000000E3`) is declared `pr | pw | ev` — the only name
|
|
42735
|
+
* characteristic a controller may write and may subscribe to. It is what iOS
|
|
42736
|
+
* 16+ reads for a service the user can rename, and adding it CHANGES the
|
|
42737
|
+
* accessory structure, so `c#` does bump and the controller re-reads.
|
|
42738
|
+
*
|
|
42739
|
+
* It was removed once because hap-nodejs logged
|
|
42740
|
+
*
|
|
42741
|
+
* ```
|
|
42742
|
+
* Characteristic not in required or optional characteristic section for
|
|
42743
|
+
* service Switch. Adding anyway.
|
|
42744
|
+
* ```
|
|
42745
|
+
*
|
|
42746
|
+
* That line is a WARNING, not a rejection: `Service.getCharacteristic` calls
|
|
42747
|
+
* `addCharacteristic` unconditionally and only then emits the warning. The
|
|
42748
|
+
* characteristic was always present and always published. hap-nodejs'
|
|
42749
|
+
* per-service optional lists simply predate `ConfiguredName` being valid on
|
|
42750
|
+
* any service.
|
|
42751
|
+
*
|
|
42752
|
+
* Registering it with {@link Service.addOptionalCharacteristic} first takes
|
|
42753
|
+
* the branch above the warning, so the accessory still builds with ZERO
|
|
42754
|
+
* characteristic warnings — which is what `service-naming.spec.ts` asserts.
|
|
42755
|
+
*
|
|
42756
|
+
* ## Scope
|
|
42757
|
+
*
|
|
42758
|
+
* Switch- and Lightbulb-shaped services only. `Service.MotionSensor` on a
|
|
42759
|
+
* camera accessory is not a separately named tile in iOS Home, so giving it a
|
|
42760
|
+
* writable name would be a guess, and this module does not guess.
|
|
42761
|
+
*/
|
|
42762
|
+
/**
|
|
42763
|
+
* Publish `name` as both the immutable `Name` and the controller-visible
|
|
42764
|
+
* `ConfiguredName` of `service`.
|
|
42765
|
+
*
|
|
42766
|
+
* `name` must already be HAP-valid — build it with `service-names.ts`, which
|
|
42767
|
+
* cannot return a string hap-nodejs' `checkName` would warn about.
|
|
42768
|
+
*/
|
|
42769
|
+
function applyServiceLabel(service, name) {
|
|
42770
|
+
service.setCharacteristic(_homebridge_hap_nodejs.Characteristic.Name, name);
|
|
42771
|
+
if (!service.optionalCharacteristics.some((characteristic) => characteristic.UUID === _homebridge_hap_nodejs.Characteristic.ConfiguredName.UUID)) service.addOptionalCharacteristic(_homebridge_hap_nodejs.Characteristic.ConfiguredName);
|
|
42772
|
+
service.setCharacteristic(_homebridge_hap_nodejs.Characteristic.ConfiguredName, name);
|
|
42773
|
+
}
|
|
42774
|
+
//#endregion
|
|
39793
42775
|
//#region src/mappers/builders/privacy-switch.ts
|
|
39794
42776
|
/**
|
|
39795
42777
|
* Privacy-mask switch builder — turns the camstack `privacy-mask` cap's
|
|
@@ -39810,9 +42792,9 @@ async function buildPrivacySwitch(bctx) {
|
|
|
39810
42792
|
const { ctx, accessory, proxy, numericDeviceId } = bctx;
|
|
39811
42793
|
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
39812
42794
|
const subtype = "privacy-mask";
|
|
39813
|
-
const
|
|
39814
|
-
const service = accessory.addService(_homebridge_hap_nodejs.Service.Switch,
|
|
39815
|
-
service
|
|
42795
|
+
const serviceName = privacyServiceName();
|
|
42796
|
+
const service = accessory.addService(_homebridge_hap_nodejs.Service.Switch, serviceName, subtype);
|
|
42797
|
+
applyServiceLabel(service, serviceName);
|
|
39816
42798
|
try {
|
|
39817
42799
|
const status = await proxy.privacyMask?.getStatus({});
|
|
39818
42800
|
if (status && typeof status.enabled === "boolean") service.updateCharacteristic(_homebridge_hap_nodejs.Characteristic.On, status.enabled);
|
|
@@ -39902,14 +42884,13 @@ function ptzPresetLabel(presetName) {
|
|
|
39902
42884
|
* `proxy.ptzAutotrack.setEnabled({enabled})`. Initial value is
|
|
39903
42885
|
* hydrated from `getStatus({})`.
|
|
39904
42886
|
*
|
|
39905
|
-
* Naming:
|
|
39906
|
-
*
|
|
39907
|
-
*
|
|
39908
|
-
*
|
|
39909
|
-
*
|
|
39910
|
-
*
|
|
39911
|
-
*
|
|
39912
|
-
* "Interruttore N".
|
|
42887
|
+
* Naming: the bare action — "Preset stanza", "Pan Left", "Autotrack" — built
|
|
42888
|
+
* by `ptzServiceName` and published through `applyServiceLabel`, which writes
|
|
42889
|
+
* it to BOTH `Name` and `ConfiguredName`. The camera name is deliberately not
|
|
42890
|
+
* prefixed — these eight services live on that camera's accessory and iOS
|
|
42891
|
+
* shows them there. THREE rounds of this bug have been through this file;
|
|
42892
|
+
* `service-label.ts` records what each got wrong, and why only the writable
|
|
42893
|
+
* characteristic can rename a service after pairing.
|
|
39913
42894
|
*/
|
|
39914
42895
|
var MOMENTARY_RESET_MS = 1e3;
|
|
39915
42896
|
async function buildPtz(bctx) {
|
|
@@ -39925,10 +42906,10 @@ async function buildPtz(bctx) {
|
|
|
39925
42906
|
};
|
|
39926
42907
|
const presets = await readPresets(bctx);
|
|
39927
42908
|
for (const preset of presets) {
|
|
39928
|
-
const label = ptzPresetLabel(preset.name);
|
|
42909
|
+
const label = ptzServiceName(ptzPresetLabel(preset.name));
|
|
39929
42910
|
const subtype = `ptz-preset-${preset.id}`;
|
|
39930
42911
|
const service = accessory.addService(_homebridge_hap_nodejs.Service.Switch, label, subtype);
|
|
39931
|
-
service
|
|
42912
|
+
applyServiceLabel(service, label);
|
|
39932
42913
|
service.getCharacteristic(_homebridge_hap_nodejs.Characteristic.On).onSet(async (value) => {
|
|
39933
42914
|
if (value !== true) return;
|
|
39934
42915
|
try {
|
|
@@ -39943,9 +42924,9 @@ async function buildPtz(bctx) {
|
|
|
39943
42924
|
});
|
|
39944
42925
|
}
|
|
39945
42926
|
if (proxy.ptz) for (const dir of PTZ_DIRECTIONS) {
|
|
39946
|
-
const label = dir.label;
|
|
42927
|
+
const label = ptzServiceName(dir.label);
|
|
39947
42928
|
const service = accessory.addService(_homebridge_hap_nodejs.Service.Switch, label, dir.subtype);
|
|
39948
|
-
service
|
|
42929
|
+
applyServiceLabel(service, label);
|
|
39949
42930
|
service.getCharacteristic(_homebridge_hap_nodejs.Characteristic.On).onSet(async (value) => {
|
|
39950
42931
|
if (value !== true) return;
|
|
39951
42932
|
try {
|
|
@@ -39992,9 +42973,9 @@ async function tryBuildAutotrack(bctx) {
|
|
|
39992
42973
|
const { ctx, accessory, proxy, numericDeviceId } = bctx;
|
|
39993
42974
|
if (!proxy.ptzAutotrack) return { async dispose() {} };
|
|
39994
42975
|
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
39995
|
-
const label = PTZ_AUTOTRACK_LABEL;
|
|
42976
|
+
const label = ptzServiceName(PTZ_AUTOTRACK_LABEL);
|
|
39996
42977
|
const service = accessory.addService(_homebridge_hap_nodejs.Service.Switch, label, "ptz-autotrack");
|
|
39997
|
-
service
|
|
42978
|
+
applyServiceLabel(service, label);
|
|
39998
42979
|
try {
|
|
39999
42980
|
const status = await proxy.ptzAutotrack.getStatus({});
|
|
40000
42981
|
if (status && typeof status.enabled === "boolean") service.updateCharacteristic(_homebridge_hap_nodejs.Characteristic.On, status.enabled);
|
|
@@ -40121,7 +43102,7 @@ async function buildChildSwitch(bctx, subtype, deviceType) {
|
|
|
40121
43102
|
const isLightingDevice = deviceType === DeviceType.Light || deviceType === DeviceType.Generic;
|
|
40122
43103
|
const useLightbulb = hasBrightness && isLightingDevice;
|
|
40123
43104
|
const service = useLightbulb ? accessory.addService(_homebridge_hap_nodejs.Service.Lightbulb, displayName, subtype) : accessory.addService(_homebridge_hap_nodejs.Service.Switch, displayName, subtype);
|
|
40124
|
-
service
|
|
43105
|
+
applyServiceLabel(service, displayName);
|
|
40125
43106
|
try {
|
|
40126
43107
|
const switchStatus = await proxy.switch?.getStatus({});
|
|
40127
43108
|
if (switchStatus && typeof switchStatus.on === "boolean") service.updateCharacteristic(_homebridge_hap_nodejs.Characteristic.On, switchStatus.on);
|
|
@@ -40193,7 +43174,7 @@ async function buildChildServicesFor(input) {
|
|
|
40193
43174
|
accessory: parentCtx.accessory,
|
|
40194
43175
|
proxy: childProxy,
|
|
40195
43176
|
numericDeviceId: child.id,
|
|
40196
|
-
displayName:
|
|
43177
|
+
displayName: childServiceName(parentDisplayName, child),
|
|
40197
43178
|
options
|
|
40198
43179
|
};
|
|
40199
43180
|
const subtype = `child-${child.id}`;
|
|
@@ -40224,21 +43205,6 @@ async function listChildren(ctx, parentNumericId) {
|
|
|
40224
43205
|
}
|
|
40225
43206
|
}
|
|
40226
43207
|
/**
|
|
40227
|
-
* Service name displayed inside the camera tile detail in iOS Home.
|
|
40228
|
-
* Prefer the child's own role ("Siren", "Floodlight") when meaningful
|
|
40229
|
-
* — the camera name is already implied by the surrounding Accessory.
|
|
40230
|
-
* Falls back to the child's stored device name when role is empty.
|
|
40231
|
-
*/
|
|
40232
|
-
function formatChildServiceName(parentName, child) {
|
|
40233
|
-
const role = child.role && child.role.length > 0 ? toTitleCase(child.role) : null;
|
|
40234
|
-
if (role) return role;
|
|
40235
|
-
if (child.name.toLowerCase().includes(parentName.toLowerCase())) {
|
|
40236
|
-
const stripped = child.name.replace(new RegExp(`\\b${escapeRegex(parentName)}\\b`, "i"), "").replace(/\s+[—-]\s+/, " ").trim();
|
|
40237
|
-
if (stripped.length > 0) return stripped;
|
|
40238
|
-
}
|
|
40239
|
-
return child.name;
|
|
40240
|
-
}
|
|
40241
|
-
/**
|
|
40242
43208
|
* Coerce the raw `child.type` string (from `deviceManager.getChildren`)
|
|
40243
43209
|
* to a `DeviceType` enum value. Unknown / mis-cased values fall back to
|
|
40244
43210
|
* `Generic` so an unrecognised driver behaves like the safest existing
|
|
@@ -40250,12 +43216,6 @@ function asDeviceType(raw) {
|
|
|
40250
43216
|
for (const value of Object.values(DeviceType)) if (value === lower) return value;
|
|
40251
43217
|
return DeviceType.Generic;
|
|
40252
43218
|
}
|
|
40253
|
-
function escapeRegex(s) {
|
|
40254
|
-
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
40255
|
-
}
|
|
40256
|
-
function toTitleCase(raw) {
|
|
40257
|
-
return raw.split("-").filter((part) => part.length > 0).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
|
|
40258
|
-
}
|
|
40259
43219
|
function errMsg$2(err) {
|
|
40260
43220
|
return err instanceof Error ? err.message : String(err);
|
|
40261
43221
|
}
|
|
@@ -40382,6 +43342,118 @@ function pickMapperKind(_capabilities) {
|
|
|
40382
43342
|
return "camera";
|
|
40383
43343
|
}
|
|
40384
43344
|
//#endregion
|
|
43345
|
+
//#region src/mappers/builders/stream-hwaccel-memo.ts
|
|
43346
|
+
/**
|
|
43347
|
+
* The two bounded memos HomeKit's decode path owns.
|
|
43348
|
+
*
|
|
43349
|
+
* ## Why they exist
|
|
43350
|
+
*
|
|
43351
|
+
* Everything D67 was actually about — one argv builder, one set of constants,
|
|
43352
|
+
* one hwaccel authority — HomeKit already had. What it did NOT have were the
|
|
43353
|
+
* two things the broker gained alongside them:
|
|
43354
|
+
*
|
|
43355
|
+
* 1. **A memo.** `probeDecoderHwaccel` issued a cross-process
|
|
43356
|
+
* `decoder.getInfo` per SESSION. iOS starts sessions in bursts — one on
|
|
43357
|
+
* record was started three times in 16 s — and every one of those paid a
|
|
43358
|
+
* cap call on a hub whose main thread is the scarce resource.
|
|
43359
|
+
* 2. **Failure feedback.** When HomeKit's hardware child died at init and
|
|
43360
|
+
* `shouldRetryInSoftware` saved the session, HomeKit told nobody. The next
|
|
43361
|
+
* session re-picked the same corpse and paid the same two-second death.
|
|
43362
|
+
* `EgressTranscodeManager` fixed exactly this for its own children
|
|
43363
|
+
* (26a522cd5) by reporting the dead backend into the broker's 60 s memo.
|
|
43364
|
+
*
|
|
43365
|
+
* ## Both ride `HwAccelCache`, deliberately
|
|
43366
|
+
*
|
|
43367
|
+
* `createHwAccelCache` from `@camstack/types` is the primitive the broker's own
|
|
43368
|
+
* `egressHwAccelCache` is built from, and the discipline it encodes is the
|
|
43369
|
+
* point: **caller-owned, never a module global** — a module global would
|
|
43370
|
+
* outlive an addon respawn and survive an operator changing the decoder
|
|
43371
|
+
* backend. Same TTL as the broker's, so "has hardware come back yet" cannot
|
|
43372
|
+
* answer differently depending on which consumer asked.
|
|
43373
|
+
*
|
|
43374
|
+
* ## The cross-process gap, stated honestly
|
|
43375
|
+
*
|
|
43376
|
+
* These memos are scoped to the `export-hap` PROCESS. When HomeKit's vaapi
|
|
43377
|
+
* child dies, the broker's next child still pays its own two-second death, and
|
|
43378
|
+
* vice versa — because addons may never import each other and there is no
|
|
43379
|
+
* capability for "this backend is dead on this node right now". Closing that
|
|
43380
|
+
* would need a new cap surface, which Phase 0 explicitly does not take. What is
|
|
43381
|
+
* closed here is HomeKit's own repetition of the cost, across cameras and
|
|
43382
|
+
* across sessions.
|
|
43383
|
+
*/
|
|
43384
|
+
/**
|
|
43385
|
+
* The window both memos answer for.
|
|
43386
|
+
*
|
|
43387
|
+
* 60 s, the same as `stream-broker-manager`'s `egressHwAccelCache`. Long enough
|
|
43388
|
+
* that a burst of session restarts pays one read; short enough that an operator
|
|
43389
|
+
* who changes the decoder backend, or a host whose accelerator recovers, is
|
|
43390
|
+
* obeyed on the next session rather than after an addon respawn.
|
|
43391
|
+
*/
|
|
43392
|
+
var HAP_DECODE_MEMO_TTL_MS = 6e4;
|
|
43393
|
+
/**
|
|
43394
|
+
* Separator inside the encoded reading. A control character, because a backend
|
|
43395
|
+
* name is `[a-z0-9]+` and the decoder's non-backend choices are `auto` /
|
|
43396
|
+
* `none` / `''`, none of which can contain one — so the split is total.
|
|
43397
|
+
*/
|
|
43398
|
+
var READING_SEPARATOR = "";
|
|
43399
|
+
/**
|
|
43400
|
+
* Marks a `null` FIELD, distinct from an EMPTY one.
|
|
43401
|
+
*
|
|
43402
|
+
* `probedBestHwaccel: ''` means the decoder answered and has never probed
|
|
43403
|
+
* (=> `not-probed`); `null` means the field was absent altogether. Encoding
|
|
43404
|
+
* both as `''` would lose a distinction `selectHwDecode` acts on.
|
|
43405
|
+
*/
|
|
43406
|
+
var NULL_FIELD = "\0";
|
|
43407
|
+
function encodeField(value) {
|
|
43408
|
+
return value === null ? NULL_FIELD : value;
|
|
43409
|
+
}
|
|
43410
|
+
function decodeField(value) {
|
|
43411
|
+
return value === NULL_FIELD ? null : value;
|
|
43412
|
+
}
|
|
43413
|
+
/**
|
|
43414
|
+
* A reading as ONE `string | null`, which is what {@link HwAccelCache} stores.
|
|
43415
|
+
*
|
|
43416
|
+
* The cache's three states are exactly the three a memoised reading needs:
|
|
43417
|
+
* `undefined` (never asked, or expired), `null` (asked, and the decoder could
|
|
43418
|
+
* not be reached), and a value. Encoding into the one cache rather than
|
|
43419
|
+
* splitting across two is what keeps those three from skewing — two caches
|
|
43420
|
+
* written together can still be READ across an expiry boundary.
|
|
43421
|
+
*/
|
|
43422
|
+
function encodeDecoderReading(reading) {
|
|
43423
|
+
if (reading === null) return null;
|
|
43424
|
+
return `${encodeField(reading.hwaccel)}${READING_SEPARATOR}${encodeField(reading.probedBestHwaccel)}`;
|
|
43425
|
+
}
|
|
43426
|
+
function decodeDecoderReading(value) {
|
|
43427
|
+
if (value === null) return null;
|
|
43428
|
+
const [hwaccel = NULL_FIELD, probed = NULL_FIELD] = value.split(READING_SEPARATOR);
|
|
43429
|
+
return {
|
|
43430
|
+
hwaccel: decodeField(hwaccel),
|
|
43431
|
+
probedBestHwaccel: decodeField(probed)
|
|
43432
|
+
};
|
|
43433
|
+
}
|
|
43434
|
+
function createDecoderReadingMemo(options) {
|
|
43435
|
+
const cache = createHwAccelCache(options);
|
|
43436
|
+
return {
|
|
43437
|
+
read() {
|
|
43438
|
+
const cached = cache.read();
|
|
43439
|
+
return cached === void 0 ? void 0 : decodeDecoderReading(cached);
|
|
43440
|
+
},
|
|
43441
|
+
write(reading) {
|
|
43442
|
+
cache.write(encodeDecoderReading(reading));
|
|
43443
|
+
}
|
|
43444
|
+
};
|
|
43445
|
+
}
|
|
43446
|
+
function createHapDecodeMemos(now) {
|
|
43447
|
+
const options = {
|
|
43448
|
+
ttlMs: HAP_DECODE_MEMO_TTL_MS,
|
|
43449
|
+
...now ? { now } : {}
|
|
43450
|
+
};
|
|
43451
|
+
return {
|
|
43452
|
+
reading: createDecoderReadingMemo(options),
|
|
43453
|
+
failedBackend: createHwAccelCache(options)
|
|
43454
|
+
};
|
|
43455
|
+
}
|
|
43456
|
+
//#endregion
|
|
40385
43457
|
//#region src/reconcile/sync-state.ts
|
|
40386
43458
|
function syncStateFromJson(json) {
|
|
40387
43459
|
const map = /* @__PURE__ */ new Map();
|
|
@@ -40524,6 +43596,18 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
40524
43596
|
pincode = "";
|
|
40525
43597
|
/** Optional mDNS/bind interface (config.interfaceName), or undefined. */
|
|
40526
43598
|
bind;
|
|
43599
|
+
/**
|
|
43600
|
+
* What this PROCESS remembers about decode hardware, shared by every camera
|
|
43601
|
+
* mapper: the decoder addon's per-node reading (60 s), and the backend that
|
|
43602
|
+
* last died at init (60 s).
|
|
43603
|
+
*
|
|
43604
|
+
* Owned here rather than as a module global for the reason `HwAccelCache`
|
|
43605
|
+
* itself records — a module global outlives an addon respawn and survives an
|
|
43606
|
+
* operator changing the decoder backend. Owned here rather than per mapper
|
|
43607
|
+
* because the whole point is that camera B does not re-pay camera A's failed
|
|
43608
|
+
* hardware init.
|
|
43609
|
+
*/
|
|
43610
|
+
decodeMemos = createHapDecodeMemos();
|
|
40527
43611
|
constructor() {
|
|
40528
43612
|
super({ ...DEFAULT_CONFIG });
|
|
40529
43613
|
}
|
|
@@ -40665,13 +43749,14 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
40665
43749
|
const mapperKind = pickMapperKind(capabilities);
|
|
40666
43750
|
if (!mapperKind) throw new Error(`export-hap: no mapper for capabilities ${JSON.stringify(capabilities ?? [])}`);
|
|
40667
43751
|
const displayName = await this.resolveDisplayName(deviceId);
|
|
40668
|
-
const
|
|
43752
|
+
const previous = this.config.exposed.find((e) => e.deviceId === deviceId);
|
|
43753
|
+
const baseEntry = carryForward({
|
|
40669
43754
|
deviceId,
|
|
40670
43755
|
displayName,
|
|
40671
43756
|
mapperKind,
|
|
40672
|
-
addedAt: Date.now(),
|
|
43757
|
+
addedAt: previous?.addedAt ?? Date.now(),
|
|
40673
43758
|
...capabilities ? { capabilities: [...capabilities] } : {}
|
|
40674
|
-
};
|
|
43759
|
+
}, previous, ["settings", "capabilities"]);
|
|
40675
43760
|
const attached = await this.attachMapper(baseEntry);
|
|
40676
43761
|
const finalEntry = {
|
|
40677
43762
|
...baseEntry,
|
|
@@ -40690,13 +43775,13 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
40690
43775
|
childCount: attached.childAccessoryUuids.length
|
|
40691
43776
|
} });
|
|
40692
43777
|
}
|
|
40693
|
-
async unexposeDevice(deviceId) {
|
|
43778
|
+
async unexposeDevice(deviceId, options = {}) {
|
|
40694
43779
|
const numericId = Number.parseInt(deviceId, 10);
|
|
40695
43780
|
const log = this.ctx.logger.withTags({ deviceId: numericId });
|
|
40696
43781
|
await this.detachMapper(deviceId);
|
|
40697
43782
|
const next = this.config.exposed.filter((e) => e.deviceId !== deviceId);
|
|
40698
43783
|
if (next.length !== this.config.exposed.length) await this.updateGlobalSettings({ exposed: next });
|
|
40699
|
-
clearPairingFiles(_homebridge_hap_nodejs.uuid.generate(`camstack:camera:${numericId}`), this.ctx.logger);
|
|
43784
|
+
if (options.clearPairing !== false) clearPairingFiles(_homebridge_hap_nodejs.uuid.generate(`camstack:camera:${numericId}`), this.ctx.logger);
|
|
40700
43785
|
await this.forgetFingerprint(numericId);
|
|
40701
43786
|
log.info("export-hap: unexposed device");
|
|
40702
43787
|
}
|
|
@@ -40709,6 +43794,7 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
40709
43794
|
displayName: entry.displayName,
|
|
40710
43795
|
options: {
|
|
40711
43796
|
ptzPulseMs: this.config.ptzPulseMs,
|
|
43797
|
+
decodeMemos: this.decodeMemos,
|
|
40712
43798
|
hapDeviceSettings: { streamPreference: entrySettings.streamPreference ?? "auto" }
|
|
40713
43799
|
}
|
|
40714
43800
|
});
|
|
@@ -41099,9 +44185,8 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
41099
44185
|
to: streamPreference
|
|
41100
44186
|
} });
|
|
41101
44187
|
try {
|
|
41102
|
-
await this.unexposeDevice(deviceIdStr);
|
|
44188
|
+
await this.unexposeDevice(deviceIdStr, { clearPairing: false });
|
|
41103
44189
|
await this.exposeDevice(deviceIdStr);
|
|
41104
|
-
await this.updateEntrySettings(deviceIdStr, nextSettings);
|
|
41105
44190
|
} catch (err) {
|
|
41106
44191
|
log.warn("export-hap: failed to refresh accessory after streamPreference change", { meta: { error: errMsg(err) } });
|
|
41107
44192
|
}
|