@camstack/addon-export-hap 1.2.11 → 1.2.13
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 +2610 -462
- package/dist/export-hap.addon.mjs +2611 -463
- package/package.json +1 -1
|
@@ -2,7 +2,7 @@ import { createRequire } from "node:module";
|
|
|
2
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
|
-
import { Accessory, AudioBitrate,
|
|
5
|
+
import { Accessory, AudioBitrate, AudioStreamingCodecType, AudioStreamingSamplerate, CameraController, Categories, Characteristic, DoorbellController, H264Level, H264Profile, HAPStorage, SRTPCryptoSuites, Service, uuid } from "@homebridge/hap-nodejs";
|
|
6
6
|
import * as fs from "node:fs/promises";
|
|
7
7
|
import { createSocket } from "node:dgram";
|
|
8
8
|
import { networkInterfaces } from "node:os";
|
|
@@ -774,7 +774,7 @@ var propertyKeyTypes = /* @__PURE__*/ new Set([
|
|
|
774
774
|
"number",
|
|
775
775
|
"symbol"
|
|
776
776
|
]);
|
|
777
|
-
function escapeRegex
|
|
777
|
+
function escapeRegex(str) {
|
|
778
778
|
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
779
779
|
}
|
|
780
780
|
function clone(inst, def, params) {
|
|
@@ -1504,7 +1504,7 @@ var $ZodCheckUpperCase = /*@__PURE__*/ $constructor("$ZodCheckUpperCase", (inst,
|
|
|
1504
1504
|
});
|
|
1505
1505
|
var $ZodCheckIncludes = /*@__PURE__*/ $constructor("$ZodCheckIncludes", (inst, def) => {
|
|
1506
1506
|
$ZodCheck.init(inst, def);
|
|
1507
|
-
const escapedRegex = escapeRegex
|
|
1507
|
+
const escapedRegex = escapeRegex(def.includes);
|
|
1508
1508
|
const pattern = new RegExp(typeof def.position === "number" ? `^.{${def.position}}${escapedRegex}` : escapedRegex);
|
|
1509
1509
|
def.pattern = pattern;
|
|
1510
1510
|
inst._zod.onattach.push((inst) => {
|
|
@@ -1527,7 +1527,7 @@ var $ZodCheckIncludes = /*@__PURE__*/ $constructor("$ZodCheckIncludes", (inst, d
|
|
|
1527
1527
|
});
|
|
1528
1528
|
var $ZodCheckStartsWith = /*@__PURE__*/ $constructor("$ZodCheckStartsWith", (inst, def) => {
|
|
1529
1529
|
$ZodCheck.init(inst, def);
|
|
1530
|
-
const pattern = new RegExp(`^${escapeRegex
|
|
1530
|
+
const pattern = new RegExp(`^${escapeRegex(def.prefix)}.*`);
|
|
1531
1531
|
def.pattern ?? (def.pattern = pattern);
|
|
1532
1532
|
inst._zod.onattach.push((inst) => {
|
|
1533
1533
|
const bag = inst._zod.bag;
|
|
@@ -1549,7 +1549,7 @@ var $ZodCheckStartsWith = /*@__PURE__*/ $constructor("$ZodCheckStartsWith", (ins
|
|
|
1549
1549
|
});
|
|
1550
1550
|
var $ZodCheckEndsWith = /*@__PURE__*/ $constructor("$ZodCheckEndsWith", (inst, def) => {
|
|
1551
1551
|
$ZodCheck.init(inst, def);
|
|
1552
|
-
const pattern = new RegExp(`.*${escapeRegex
|
|
1552
|
+
const pattern = new RegExp(`.*${escapeRegex(def.suffix)}$`);
|
|
1553
1553
|
def.pattern ?? (def.pattern = pattern);
|
|
1554
1554
|
inst._zod.onattach.push((inst) => {
|
|
1555
1555
|
const bag = inst._zod.bag;
|
|
@@ -2785,7 +2785,7 @@ var $ZodEnum = /*@__PURE__*/ $constructor("$ZodEnum", (inst, def) => {
|
|
|
2785
2785
|
const values = getEnumValues(def.entries);
|
|
2786
2786
|
const valuesSet = new Set(values);
|
|
2787
2787
|
inst._zod.values = valuesSet;
|
|
2788
|
-
inst._zod.pattern = new RegExp(`^(${values.filter((k) => propertyKeyTypes.has(typeof k)).map((o) => typeof o === "string" ? escapeRegex
|
|
2788
|
+
inst._zod.pattern = new RegExp(`^(${values.filter((k) => propertyKeyTypes.has(typeof k)).map((o) => typeof o === "string" ? escapeRegex(o) : o.toString()).join("|")})$`);
|
|
2789
2789
|
inst._zod.parse = (payload, _ctx) => {
|
|
2790
2790
|
const input = payload.value;
|
|
2791
2791
|
if (valuesSet.has(input)) return payload;
|
|
@@ -2803,7 +2803,7 @@ var $ZodLiteral = /*@__PURE__*/ $constructor("$ZodLiteral", (inst, def) => {
|
|
|
2803
2803
|
if (def.values.length === 0) throw new Error("Cannot create literal schema with no valid values");
|
|
2804
2804
|
const values = new Set(def.values);
|
|
2805
2805
|
inst._zod.values = values;
|
|
2806
|
-
inst._zod.pattern = new RegExp(`^(${def.values.map((o) => typeof o === "string" ? escapeRegex
|
|
2806
|
+
inst._zod.pattern = new RegExp(`^(${def.values.map((o) => typeof o === "string" ? escapeRegex(o) : o ? escapeRegex(o.toString()) : String(o)).join("|")})$`);
|
|
2807
2807
|
inst._zod.parse = (payload, _ctx) => {
|
|
2808
2808
|
const input = payload.value;
|
|
2809
2809
|
if (values.has(input)) return payload;
|
|
@@ -6496,6 +6496,36 @@ var ProfileRtspEntrySchema = object({
|
|
|
6496
6496
|
resolution: CamStreamResolutionSchema.optional()
|
|
6497
6497
|
});
|
|
6498
6498
|
/**
|
|
6499
|
+
* Per-call node pinning for `ctx.api` capability calls.
|
|
6500
|
+
*
|
|
6501
|
+
* A capability call normally resolves to its DEFAULT provider — a `singleton`
|
|
6502
|
+
* cap resolves to the hub, a device-scoped cap to the device's owning node. To
|
|
6503
|
+
* query a SPECIFIC node's provider instead (e.g. a remote agent's own
|
|
6504
|
+
* in-process `platform-probe` hardware, which the hub cannot probe), pin the
|
|
6505
|
+
* call to that node.
|
|
6506
|
+
*
|
|
6507
|
+
* The nodeId rides OUT-OF-BAND in the tRPC call context (NOT in the validated
|
|
6508
|
+
* method args), so capability method signatures stay `nodeId`-free — node
|
|
6509
|
+
* targeting is a property of the CALL, not of the method. The transport lifts
|
|
6510
|
+
* it from `op.context` onto the `CapCallInput.nodeId` field (`ipcParentLink`),
|
|
6511
|
+
* and the hub parent's `onUnownedCall` passes it to the `CapRouteResolver`,
|
|
6512
|
+
* which classifies a pinned agent node as `agent-child-forward`
|
|
6513
|
+
* (`$agent-cap-fwd.forward` → the agent's in-process provider).
|
|
6514
|
+
*
|
|
6515
|
+
* Usage at a call site:
|
|
6516
|
+
*
|
|
6517
|
+
* await api.platformProbe.getCapabilities.query(undefined, nodePin(nodeId))
|
|
6518
|
+
*/
|
|
6519
|
+
/** tRPC `op.context` key carrying a per-call node pin. */
|
|
6520
|
+
var CAP_NODE_PIN_CONTEXT_KEY = "__camstackNodePin";
|
|
6521
|
+
/**
|
|
6522
|
+
* Build the tRPC request options that pin a single capability call to `nodeId`.
|
|
6523
|
+
* Pass as the second argument to `.query(input, …)` / `.mutate(input, …)`.
|
|
6524
|
+
*/
|
|
6525
|
+
function nodePin(nodeId) {
|
|
6526
|
+
return { context: { [CAP_NODE_PIN_CONTEXT_KEY]: nodeId } };
|
|
6527
|
+
}
|
|
6528
|
+
/**
|
|
6499
6529
|
* Output schema shared by the contribution + live methods.
|
|
6500
6530
|
*
|
|
6501
6531
|
* Mirrors the `ConfigUISchemaWithValues` shape (sections[] + optional
|
|
@@ -6943,6 +6973,39 @@ method(object({ deviceId: number() }), array(StreamSourceEntrySchema)), method(o
|
|
|
6943
6973
|
action: string().min(1),
|
|
6944
6974
|
input: unknown()
|
|
6945
6975
|
}), unknown(), { kind: "mutation" }), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), unknown().nullable()), method(object({ deviceId: number() }), RawStateResultSchema.nullable(), { auth: "protected" });
|
|
6976
|
+
//#endregion
|
|
6977
|
+
//#region ../types/dist/canonical-hash-7nfBbEqR.mjs
|
|
6978
|
+
/**
|
|
6979
|
+
* Deterministic SHA-256 hash of an arbitrary serialisable value. The
|
|
6980
|
+
* canonical form sorts object keys alphabetically at every depth so two
|
|
6981
|
+
* structurally-equal inputs with different key insertion orders produce
|
|
6982
|
+
* the same hash. Returns a 64-char lowercase hex digest.
|
|
6983
|
+
*
|
|
6984
|
+
* Used by export adapters (Alexa, HAP) to short-circuit re-discovery /
|
|
6985
|
+
* accessory-rebuild work when the upstream shape is byte-identical to
|
|
6986
|
+
* the last applied state — preventing user-visible "re-discovery"
|
|
6987
|
+
* notifications on every addon-runner respawn. Each respawn re-fires
|
|
6988
|
+
* `DeviceBindingsChanged` for every cap registration, which without
|
|
6989
|
+
* this guard would propagate redundant pushes.
|
|
6990
|
+
*
|
|
6991
|
+
* Note: this is a SYMPTOMATIC fix layered on top of the binding-change
|
|
6992
|
+
* subscription. The proper fix is a single "device ready" lifecycle
|
|
6993
|
+
* barrier so exports react only when the full cap set has landed —
|
|
6994
|
+
* tracked separately for post-HA-integration work.
|
|
6995
|
+
*/
|
|
6996
|
+
function canonicalHash(value) {
|
|
6997
|
+
const canonical = JSON.stringify(value, replaceWithSortedKeys);
|
|
6998
|
+
return createHash("sha256").update(canonical ?? "").digest("hex");
|
|
6999
|
+
}
|
|
7000
|
+
function replaceWithSortedKeys(_key, value) {
|
|
7001
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
7002
|
+
const obj = value;
|
|
7003
|
+
const out = {};
|
|
7004
|
+
for (const k of Object.keys(obj).toSorted()) out[k] = obj[k];
|
|
7005
|
+
return out;
|
|
7006
|
+
}
|
|
7007
|
+
return value;
|
|
7008
|
+
}
|
|
6946
7009
|
var EncodeProfileSchema = object({
|
|
6947
7010
|
video: object({
|
|
6948
7011
|
codec: _enum([
|
|
@@ -6955,6 +7018,14 @@ var EncodeProfileSchema = object({
|
|
|
6955
7018
|
"main",
|
|
6956
7019
|
"high"
|
|
6957
7020
|
]).optional(),
|
|
7021
|
+
/**
|
|
7022
|
+
* `-level`, e.g. `'3.1'`. A consumer that ADVERTISES a level in its SDP
|
|
7023
|
+
* (`profile-level-id=42e01f` is Baseline 3.1) must constrain the encoder to
|
|
7024
|
+
* it, or it ships a stream that does not match its own advertisement — the
|
|
7025
|
+
* defect class that kept HomeKit black for a year and that Alexa carried
|
|
7026
|
+
* silently. Optional because a browser negotiates the level itself.
|
|
7027
|
+
*/
|
|
7028
|
+
level: string().optional(),
|
|
6958
7029
|
width: number().int().positive().optional(),
|
|
6959
7030
|
height: number().int().positive().optional(),
|
|
6960
7031
|
fps: number().positive().optional(),
|
|
@@ -7002,6 +7073,29 @@ var EncodeProfileSchema = object({
|
|
|
7002
7073
|
outputArgs: array(string()).optional()
|
|
7003
7074
|
});
|
|
7004
7075
|
/**
|
|
7076
|
+
* The shape every live egress starts from: H.264 Baseline 3.1 at 720p25.
|
|
7077
|
+
* Baseline because it is the one profile every consumer in this repo decodes
|
|
7078
|
+
* (Echo, iOS, an old browser); 3.1 because that is what the SDPs advertise.
|
|
7079
|
+
*/
|
|
7080
|
+
var BASE_LIVE_EGRESS_PROFILE = {
|
|
7081
|
+
video: {
|
|
7082
|
+
codec: "h264",
|
|
7083
|
+
profile: "baseline",
|
|
7084
|
+
level: "3.1",
|
|
7085
|
+
width: 1280,
|
|
7086
|
+
height: 720,
|
|
7087
|
+
fps: 25,
|
|
7088
|
+
bitrateKbps: 2500,
|
|
7089
|
+
gopFrames: 25,
|
|
7090
|
+
bf: 0,
|
|
7091
|
+
preset: "veryfast",
|
|
7092
|
+
tune: "zerolatency"
|
|
7093
|
+
},
|
|
7094
|
+
audio: "passthrough"
|
|
7095
|
+
};
|
|
7096
|
+
({ ...BASE_LIVE_EGRESS_PROFILE }), { ...BASE_LIVE_EGRESS_PROFILE.video };
|
|
7097
|
+
({ ...BASE_LIVE_EGRESS_PROFILE });
|
|
7098
|
+
/**
|
|
7005
7099
|
* Deep wiring healthcheck — snapshot of active reachability probes across
|
|
7006
7100
|
* every declared capability + widget of every installed plugin, on every
|
|
7007
7101
|
* node. Produced by the backend `WiringHealthService` and surfaced via
|
|
@@ -7051,6 +7145,105 @@ object({
|
|
|
7051
7145
|
})
|
|
7052
7146
|
});
|
|
7053
7147
|
/**
|
|
7148
|
+
* Per-camera FUNCTION SWITCHES — the one coherent on/off surface over the
|
|
7149
|
+
* pipeline functions an operator thinks in terms of.
|
|
7150
|
+
*
|
|
7151
|
+
* ## This file adds no state
|
|
7152
|
+
*
|
|
7153
|
+
* Every switch here is a VIEW onto an authority that already existed
|
|
7154
|
+
* ([D61](../../../../docs/decisions/adr-0062.md)). The whole point of the
|
|
7155
|
+
* group is that there is exactly one place each function is turned off, and
|
|
7156
|
+
* the group routes to it:
|
|
7157
|
+
*
|
|
7158
|
+
* | Switch | Authority | Proven "off stops the work" gate |
|
|
7159
|
+
* | --- | --- | --- |
|
|
7160
|
+
* | `stream-broker` | `deviceManager.setDisabled` | `StreamBrokerManager.reconcileAllCatalogs` releases the brokers; `ensureBroker` refuses re-creation |
|
|
7161
|
+
* | `object-detection` | `deviceManager.setWrapperActive('detection-pipeline')` | `PipelineSettingsStore.resolvePipelineForDevice` returns `{ steps: [], audio: null }` |
|
|
7162
|
+
* | `audio-analysis` | `deviceManager.setWrapperActive('audio-analysis')` | `AudioSubscriptionController.subscribeAudioStream` returns `null` before opening the stream |
|
|
7163
|
+
* | `recording` | `recording.setDeviceConfig` → `RecordingConfig.enabled` | `band-decision.shouldRecord` returns false; the controller detaches the device |
|
|
7164
|
+
* | `notifications` | `notificationRules.setDeviceMuted` | `NotificationCenter.evaluateAndEnqueue` returns before any rule is evaluated |
|
|
7165
|
+
*
|
|
7166
|
+
* The wrapper-binding pair is not a new idea: `legacy-migrations.ts` already
|
|
7167
|
+
* migrated the legacy `audioEnabled` / `pipelineEnabled` /
|
|
7168
|
+
* `motionDetectionEnabled` booleans ONTO `setWrapperActive`. The group is the
|
|
7169
|
+
* surface that decision never got.
|
|
7170
|
+
*
|
|
7171
|
+
* ## Two rules that are load-bearing
|
|
7172
|
+
*
|
|
7173
|
+
* - **Recording's switch is `enabled`, never the bands.** `bands` is the only
|
|
7174
|
+
* authored intent and `mode` is derived from it (`deriveRecordingMode`).
|
|
7175
|
+
* Expressing "off" by clearing bands destroys the operator's schedule and
|
|
7176
|
+
* turning the camera back on would then silently record nothing.
|
|
7177
|
+
* - **A switch that is off must be reported as off**, not merely produce
|
|
7178
|
+
* nothing. {@link CameraSwitch.enabled} is what a status surface renders as
|
|
7179
|
+
* "disabled by an operator" instead of "broken" — see
|
|
7180
|
+
* `CameraStatus.switchedOff`.
|
|
7181
|
+
*/
|
|
7182
|
+
/**
|
|
7183
|
+
* The five functions the operator named (2026-08-05). Deliberately NOT one id
|
|
7184
|
+
* per pipeline step: face recognition and plate/LPR are per-step toggles on
|
|
7185
|
+
* `pipelineOrchestrator.setCameraStepToggle` and belong in the pipeline
|
|
7186
|
+
* editor, not in a five-button safety group.
|
|
7187
|
+
*/
|
|
7188
|
+
var CameraSwitchIdSchema = _enum([
|
|
7189
|
+
"stream-broker",
|
|
7190
|
+
"object-detection",
|
|
7191
|
+
"audio-analysis",
|
|
7192
|
+
"recording",
|
|
7193
|
+
"notifications"
|
|
7194
|
+
]);
|
|
7195
|
+
/**
|
|
7196
|
+
* WHERE the switch's state actually lives. A discriminated union rather than a
|
|
7197
|
+
* string so both the writer (the orchestrator's `setCameraSwitch`) and any
|
|
7198
|
+
* reader can exhaustively narrow — and so "the group added a parallel map" is
|
|
7199
|
+
* a compile error rather than a review comment.
|
|
7200
|
+
*/
|
|
7201
|
+
var CameraSwitchAuthoritySchema = discriminatedUnion("kind", [
|
|
7202
|
+
object({ kind: literal("device-disabled") }),
|
|
7203
|
+
object({
|
|
7204
|
+
kind: literal("wrapper-binding"),
|
|
7205
|
+
capName: string()
|
|
7206
|
+
}),
|
|
7207
|
+
object({ kind: literal("recording-config") }),
|
|
7208
|
+
object({ kind: literal("notification-mute") })
|
|
7209
|
+
]);
|
|
7210
|
+
/**
|
|
7211
|
+
* Why a switch is not offered for this camera. Rendered instead of the
|
|
7212
|
+
* control, never as a dead control — an absent function and a broken one must
|
|
7213
|
+
* not look the same.
|
|
7214
|
+
*/
|
|
7215
|
+
var CameraSwitchUnavailableReasonSchema = _enum(["no-provider", "source-unreachable"]);
|
|
7216
|
+
/**
|
|
7217
|
+
* One switch, resolved for one camera.
|
|
7218
|
+
*
|
|
7219
|
+
* `label` and `costWhenOff` travel ON THE WIRE rather than being looked up
|
|
7220
|
+
* client-side: the viewer is a separate repository that does not import
|
|
7221
|
+
* `@camstack/types`, and a cost line duplicated in two clients is a cost line
|
|
7222
|
+
* that will disagree with itself. Five rows per camera is nothing.
|
|
7223
|
+
*/
|
|
7224
|
+
var CameraSwitchSchema = object({
|
|
7225
|
+
id: CameraSwitchIdSchema,
|
|
7226
|
+
label: string(),
|
|
7227
|
+
/**
|
|
7228
|
+
* What the operator LOSES while this is off, in one sentence. Required, not
|
|
7229
|
+
* optional: a switch that cannot say what it costs should not ship.
|
|
7230
|
+
*/
|
|
7231
|
+
costWhenOff: string(),
|
|
7232
|
+
/** False = do not render a control. `unavailableReason` says why. */
|
|
7233
|
+
available: boolean(),
|
|
7234
|
+
unavailableReason: CameraSwitchUnavailableReasonSchema.optional(),
|
|
7235
|
+
/** Current state. Meaningless when `available` is false — read it as `true`. */
|
|
7236
|
+
enabled: boolean(),
|
|
7237
|
+
authority: CameraSwitchAuthoritySchema
|
|
7238
|
+
});
|
|
7239
|
+
/** The whole group for one camera. */
|
|
7240
|
+
var CameraSwitchGroupSchema = object({
|
|
7241
|
+
deviceId: number().int(),
|
|
7242
|
+
switches: array(CameraSwitchSchema).readonly(),
|
|
7243
|
+
/** Unix ms when the group was composed server-side. */
|
|
7244
|
+
fetchedAt: number()
|
|
7245
|
+
});
|
|
7246
|
+
/**
|
|
7054
7247
|
* Ops-log — the durable, append-only operations audit shared by the
|
|
7055
7248
|
* recordings and events management surfaces.
|
|
7056
7249
|
*
|
|
@@ -7069,14 +7262,16 @@ var OpsLogOpSchema = _enum([
|
|
|
7069
7262
|
"manual-delete",
|
|
7070
7263
|
"rescan",
|
|
7071
7264
|
"retention-run",
|
|
7072
|
-
"relocate"
|
|
7265
|
+
"relocate",
|
|
7266
|
+
"orphan-audit"
|
|
7073
7267
|
]);
|
|
7074
7268
|
/** Why the operation ran. */
|
|
7075
7269
|
var OpsLogReasonSchema = _enum([
|
|
7076
7270
|
"retention",
|
|
7077
7271
|
"quota",
|
|
7078
7272
|
"manual",
|
|
7079
|
-
"operator"
|
|
7273
|
+
"operator",
|
|
7274
|
+
"maintenance"
|
|
7080
7275
|
]);
|
|
7081
7276
|
/** One audit row, shared verbatim by both domains. */
|
|
7082
7277
|
var OpsLogEntrySchema = object({
|
|
@@ -8991,6 +9186,100 @@ var RtpSourceSchema = object({
|
|
|
8991
9186
|
encoder: string(),
|
|
8992
9187
|
pipelineKey: string()
|
|
8993
9188
|
});
|
|
9189
|
+
/**
|
|
9190
|
+
* The encode request — **structured and serialisable, with NO raw-flag escape
|
|
9191
|
+
* hatch.** This is deliberate and it is the one lesson taken from
|
|
9192
|
+
* `getStreamWithCodec`: that method's `outputArgs: string[]` is simultaneously
|
|
9193
|
+
* its extensibility mechanism AND part of `pipelineKeyFor`'s sharing key, so
|
|
9194
|
+
* adding a flag silently forks the shared child, and two consumers that mean
|
|
9195
|
+
* the same thing but spell it differently never share. Here every knob is a
|
|
9196
|
+
* NAMED field: a new requirement becomes a schema field (and a codegen run),
|
|
9197
|
+
* never an opaque array.
|
|
9198
|
+
*
|
|
9199
|
+
* `inputArgs` / `outputArgs` are omitted from the profile for the same reason.
|
|
9200
|
+
* The operator-facing derived-stream transform editor still has them — that is
|
|
9201
|
+
* a different surface (`publishCameraStream({ kind: 'derived' })`) with a
|
|
9202
|
+
* different purpose (reshaping a badly-behaved SOURCE), and it is unchanged.
|
|
9203
|
+
*/
|
|
9204
|
+
var EgressEncodeSchema = EncodeProfileSchema.omit({
|
|
9205
|
+
inputArgs: true,
|
|
9206
|
+
outputArgs: true
|
|
9207
|
+
});
|
|
9208
|
+
/**
|
|
9209
|
+
* How the encoder is bounded. `'tight'` is a one-second VBV window for a
|
|
9210
|
+
* consumer whose budget is enforced per second (HomeKit); `'relaxed'` is two
|
|
9211
|
+
* seconds, letting a keyframe spike borrow from the next second (a browser,
|
|
9212
|
+
* an Echo). Named rather than numeric so the INTENT survives.
|
|
9213
|
+
*/
|
|
9214
|
+
var EgressRateControlSchema = _enum(["tight", "relaxed"]);
|
|
9215
|
+
var EgressTranscodeRequestSchema = object({
|
|
9216
|
+
deviceId: number().int().nonnegative(),
|
|
9217
|
+
/** Which published stream to read. */
|
|
9218
|
+
source: discriminatedUnion("kind", [object({
|
|
9219
|
+
kind: literal("profile"),
|
|
9220
|
+
profile: CamProfileSchema
|
|
9221
|
+
}), object({
|
|
9222
|
+
kind: literal("cam-stream"),
|
|
9223
|
+
camStreamId: string().min(1)
|
|
9224
|
+
})]),
|
|
9225
|
+
encode: EgressEncodeSchema,
|
|
9226
|
+
rateControl: EgressRateControlSchema.optional(),
|
|
9227
|
+
/**
|
|
9228
|
+
* `-bsf:v`. A consumer that negotiates its OWN SDP (HomeKit) cannot carry
|
|
9229
|
+
* out-of-band extradata and needs `dump_extra` on both the copy and encode
|
|
9230
|
+
* branches. Enumerated, not free text.
|
|
9231
|
+
*/
|
|
9232
|
+
bitstreamFilter: _enum([
|
|
9233
|
+
"dump_extra",
|
|
9234
|
+
"h264_mp4toannexb",
|
|
9235
|
+
"hevc_mp4toannexb"
|
|
9236
|
+
]).optional(),
|
|
9237
|
+
pixelFormat: _enum(["yuv420p", "nv12"]).optional(),
|
|
9238
|
+
/**
|
|
9239
|
+
* Operator/consumer override for decode hardware. ABSENT is the normal case
|
|
9240
|
+
* and the one that matters: the broker then resolves the backend from the
|
|
9241
|
+
* DECODER ADDON's per-node `probedBestHwaccel` (see
|
|
9242
|
+
* `@camstack/types` `ffmpeg/hwaccel.ts`), which is the ranking known to work
|
|
9243
|
+
* on this hardware — never the raw kernel resolver's qsv-first order.
|
|
9244
|
+
*/
|
|
9245
|
+
decodeHwAccel: _enum([
|
|
9246
|
+
"auto",
|
|
9247
|
+
"none",
|
|
9248
|
+
"videotoolbox",
|
|
9249
|
+
"vaapi",
|
|
9250
|
+
"qsv",
|
|
9251
|
+
"cuda"
|
|
9252
|
+
]).optional(),
|
|
9253
|
+
/**
|
|
9254
|
+
* Host to embed in the returned restream `url`. The broker mints hub-local
|
|
9255
|
+
* `127.0.0.1` URLs; a consumer on another node passes a cluster-resolvable
|
|
9256
|
+
* host (`NodeTopologyService.reachableHostByNode`) so the returned URL is
|
|
9257
|
+
* dialable from there. Same contract as `getStreamWithCodec.hostname` —
|
|
9258
|
+
* `substituteRtspHost` rewrites only the dial address, never the restreamer.
|
|
9259
|
+
*/
|
|
9260
|
+
hostname: string().optional(),
|
|
9261
|
+
/** Attribution for the broker panel. Never part of the sharing key. */
|
|
9262
|
+
tag: string().optional()
|
|
9263
|
+
});
|
|
9264
|
+
var EgressTranscodeSchema = object({
|
|
9265
|
+
/** Dial-able RTSP url (host-substituted when `hostname` was supplied). */
|
|
9266
|
+
url: string(),
|
|
9267
|
+
/** Release handle. Refcounted — the child dies when the last holder releases. */
|
|
9268
|
+
pipelineKey: string(),
|
|
9269
|
+
videoCodec: _enum(["H264", "H265"]),
|
|
9270
|
+
resolution: object({
|
|
9271
|
+
width: number().int().positive(),
|
|
9272
|
+
height: number().int().positive()
|
|
9273
|
+
}),
|
|
9274
|
+
transcoded: boolean(),
|
|
9275
|
+
encoder: string(),
|
|
9276
|
+
/**
|
|
9277
|
+
* The decode backend the child ACTUALLY ran with — `null` for software.
|
|
9278
|
+
* Returned rather than assumed: a consumer that asked for hardware and got
|
|
9279
|
+
* software needs to be able to see that without reading the broker's logs.
|
|
9280
|
+
*/
|
|
9281
|
+
decodeHwAccel: string().nullable()
|
|
9282
|
+
});
|
|
8994
9283
|
method(object({
|
|
8995
9284
|
deviceId: number().int().nonnegative(),
|
|
8996
9285
|
camStreamId: string().min(1),
|
|
@@ -9100,6 +9389,15 @@ method(object({
|
|
|
9100
9389
|
}), {
|
|
9101
9390
|
kind: "mutation",
|
|
9102
9391
|
auth: "admin"
|
|
9392
|
+
}), method(EgressTranscodeRequestSchema, EgressTranscodeSchema, {
|
|
9393
|
+
kind: "mutation",
|
|
9394
|
+
auth: "admin"
|
|
9395
|
+
}), method(object({ pipelineKey: string() }), object({
|
|
9396
|
+
released: boolean(),
|
|
9397
|
+
refcount: number().int().nonnegative()
|
|
9398
|
+
}), {
|
|
9399
|
+
kind: "mutation",
|
|
9400
|
+
auth: "admin"
|
|
9103
9401
|
}), method(SubscribeAudioChunksInputSchema, SubscribeAudioChunksResultSchema, { kind: "mutation" }), method(object({
|
|
9104
9402
|
subscriptionId: string(),
|
|
9105
9403
|
maxCount: number().int().positive().default(8)
|
|
@@ -9554,6 +9852,62 @@ method(_void(), EngineInfoSchema), method(object({
|
|
|
9554
9852
|
indexes: array(CollectionIndexSchema).readonly().optional()
|
|
9555
9853
|
}), _void(), { kind: "mutation" });
|
|
9556
9854
|
/**
|
|
9855
|
+
* Stable UI option list for the `hwaccel` setting. Decoder addons
|
|
9856
|
+
* reuse this for `globalSettingsSchema()` so the dropdown is
|
|
9857
|
+
* identical everywhere. Order: auto → off → common backends by
|
|
9858
|
+
* platform affinity (macOS, NVIDIA, Intel/AMD, Windows, Linux).
|
|
9859
|
+
*/
|
|
9860
|
+
var HWACCEL_OPTIONS = [
|
|
9861
|
+
{
|
|
9862
|
+
value: "auto",
|
|
9863
|
+
label: "Auto (defer to probed best)"
|
|
9864
|
+
},
|
|
9865
|
+
{
|
|
9866
|
+
value: "none",
|
|
9867
|
+
label: "Off (software)"
|
|
9868
|
+
},
|
|
9869
|
+
{
|
|
9870
|
+
value: "videotoolbox",
|
|
9871
|
+
label: "VideoToolbox (macOS)"
|
|
9872
|
+
},
|
|
9873
|
+
{
|
|
9874
|
+
value: "cuda",
|
|
9875
|
+
label: "CUDA (NVIDIA)"
|
|
9876
|
+
},
|
|
9877
|
+
{
|
|
9878
|
+
value: "nvdec",
|
|
9879
|
+
label: "NVDEC (NVIDIA legacy)"
|
|
9880
|
+
},
|
|
9881
|
+
{
|
|
9882
|
+
value: "vaapi",
|
|
9883
|
+
label: "VAAPI (Linux Intel/AMD)"
|
|
9884
|
+
},
|
|
9885
|
+
{
|
|
9886
|
+
value: "qsv",
|
|
9887
|
+
label: "QuickSync (Intel)"
|
|
9888
|
+
},
|
|
9889
|
+
{
|
|
9890
|
+
value: "d3d11va",
|
|
9891
|
+
label: "D3D11VA (Windows)"
|
|
9892
|
+
},
|
|
9893
|
+
{
|
|
9894
|
+
value: "dxva2",
|
|
9895
|
+
label: "DXVA2 (Windows legacy)"
|
|
9896
|
+
},
|
|
9897
|
+
{
|
|
9898
|
+
value: "amf",
|
|
9899
|
+
label: "AMF (AMD)"
|
|
9900
|
+
},
|
|
9901
|
+
{
|
|
9902
|
+
value: "vdpau",
|
|
9903
|
+
label: "VDPAU (Linux NVIDIA legacy)"
|
|
9904
|
+
},
|
|
9905
|
+
{
|
|
9906
|
+
value: "drm",
|
|
9907
|
+
label: "DRM (Linux generic)"
|
|
9908
|
+
}
|
|
9909
|
+
];
|
|
9910
|
+
/**
|
|
9557
9911
|
* shm ring usage stats for a `frameSink: 'shm'` decoder session —
|
|
9558
9912
|
* exposed via `decoder.getShmStats` so downstream consumers can
|
|
9559
9913
|
* observe ring pressure (slot count, byte budget, hit/miss ratio).
|
|
@@ -12903,7 +13257,7 @@ var DETECTION_SUB_COLORS = {
|
|
|
12903
13257
|
zebra: "#404040",
|
|
12904
13258
|
giraffe: "#d4a373"
|
|
12905
13259
|
};
|
|
12906
|
-
function titleCase(id) {
|
|
13260
|
+
function titleCase$1(id) {
|
|
12907
13261
|
return id.split(/[-_ ]/).filter((p) => p.length > 0).map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(" ");
|
|
12908
13262
|
}
|
|
12909
13263
|
var entries = /* @__PURE__ */ new Map();
|
|
@@ -12942,7 +13296,7 @@ macro("control", "control", TAXONOMY_COLORS.control, "control", "Control");
|
|
|
12942
13296
|
for (const [cocoClass, macroClass] of Object.entries(COCO_TO_MACRO.mapping)) {
|
|
12943
13297
|
if (macroClass !== "vehicle" && macroClass !== "animal") continue;
|
|
12944
13298
|
if (entries.has(cocoClass)) continue;
|
|
12945
|
-
sub(cocoClass, macroClass, "detection", DETECTION_SUB_COLORS[cocoClass] ?? TAXONOMY_COLORS.genericDetection, cocoClass, titleCase(cocoClass));
|
|
13299
|
+
sub(cocoClass, macroClass, "detection", DETECTION_SUB_COLORS[cocoClass] ?? TAXONOMY_COLORS.genericDetection, cocoClass, titleCase$1(cocoClass));
|
|
12946
13300
|
}
|
|
12947
13301
|
sub("package-delivered", "package", "package", TAXONOMY_COLORS.package, "package", "Package delivered");
|
|
12948
13302
|
sub("package-picked-up", "package", "package", TAXONOMY_COLORS.package, "package", "Package picked up");
|
|
@@ -13451,12 +13805,13 @@ var NcConditionsSchema = object({
|
|
|
13451
13805
|
* source; otherwise the subject's source must equal it. Legacy records
|
|
13452
13806
|
* with no stamped source are treated as `pipeline`. The union spans both
|
|
13453
13807
|
* record kinds — object events carry `pipeline` | `onboard`, synthetic
|
|
13454
|
-
* tracks carry `sensor
|
|
13808
|
+
* tracks carry `sensor` (a linked device) or `audio` (a D62 audio marker).
|
|
13455
13809
|
*/
|
|
13456
13810
|
source: _enum([
|
|
13457
13811
|
"pipeline",
|
|
13458
13812
|
"onboard",
|
|
13459
13813
|
"sensor",
|
|
13814
|
+
"audio",
|
|
13460
13815
|
"any"
|
|
13461
13816
|
]).optional(),
|
|
13462
13817
|
/**
|
|
@@ -14032,6 +14387,12 @@ method(object({}), object({ rules: array(NcRuleSchema) }), { auth: "admin" }), m
|
|
|
14032
14387
|
}), object({ success: literal(true) }), {
|
|
14033
14388
|
kind: "mutation",
|
|
14034
14389
|
auth: "admin"
|
|
14390
|
+
}), method(object({}), object({ mutedDeviceIds: array(number().int()).readonly() }), { auth: "admin" }), method(object({
|
|
14391
|
+
deviceId: number().int(),
|
|
14392
|
+
muted: boolean()
|
|
14393
|
+
}), object({ success: literal(true) }), {
|
|
14394
|
+
kind: "mutation",
|
|
14395
|
+
auth: "admin"
|
|
14035
14396
|
}), method(object({
|
|
14036
14397
|
rule: NcRuleInputSchema,
|
|
14037
14398
|
lookbackMinutes: number().int().min(1).max(1440).default(60)
|
|
@@ -14370,12 +14731,60 @@ var TrackAudioLabelSchema = object({
|
|
|
14370
14731
|
});
|
|
14371
14732
|
/**
|
|
14372
14733
|
* How a track was produced. `pipeline` (default / absent) = the spatial
|
|
14373
|
-
* detection+tracking pipeline.
|
|
14374
|
-
*
|
|
14375
|
-
*
|
|
14376
|
-
*
|
|
14734
|
+
* detection+tracking pipeline. Every OTHER value is a SYNTHETIC projection —
|
|
14735
|
+
* no positions, a single snapshot, and no bbox trajectory at all:
|
|
14736
|
+
*
|
|
14737
|
+
* - `sensor` — a linked sensor/control device state change.
|
|
14738
|
+
* - `audio` — an audio event on the camera itself that was anomalous for
|
|
14739
|
+
* THAT camera, loud, and heard while nothing visual was happening (D62).
|
|
14740
|
+
*
|
|
14741
|
+
* The spatial subsystems (tracker association, occupancy count, re-id /
|
|
14742
|
+
* embedding, resurrection) MUST skip every synthetic source. Test for that
|
|
14743
|
+
* with `isSpatialTrack`, which allow-lists `pipeline` — a `!== 'sensor'`
|
|
14744
|
+
* check silently readmits every source added after it was written.
|
|
14745
|
+
*/
|
|
14746
|
+
var TrackSourceSchema = _enum([
|
|
14747
|
+
"pipeline",
|
|
14748
|
+
"sensor",
|
|
14749
|
+
"audio"
|
|
14750
|
+
]);
|
|
14751
|
+
/**
|
|
14752
|
+
* Per-track OPERATOR flags — set by hand from the admin UI or the viewer, never
|
|
14753
|
+
* by the pipeline. Spread into `TrackSchema` and `KeyEventSchema` from one place
|
|
14754
|
+
* so the two surfaces cannot drift.
|
|
14755
|
+
*
|
|
14756
|
+
* **Absent ≠ false.** A track that has never been touched omits the field; an
|
|
14757
|
+
* explicitly un-flagged track carries `false`. Legacy rows written before the
|
|
14758
|
+
* columns existed read as absent, and a consumer that needs a boolean should say
|
|
14759
|
+
* `flag === true`, not `flag !== false`.
|
|
14760
|
+
*
|
|
14761
|
+
* What the flags DO is deliberately UNDEFINED at the time of writing: they are
|
|
14762
|
+
* operator curation, and the behaviour they drive will be specified separately.
|
|
14763
|
+
* In particular a `markForTrain` track is NOT pinned against retention — see
|
|
14764
|
+
* `docs/decisions/adr-0059.md` for why that is a store-level change, not a flag.
|
|
14765
|
+
*/
|
|
14766
|
+
var TrackFlagFields = {
|
|
14767
|
+
/** Operator marked this track as training material. */
|
|
14768
|
+
markForTrain: boolean().optional(),
|
|
14769
|
+
/** Operator marked this track for diagnostic attention. */
|
|
14770
|
+
debug: boolean().optional()
|
|
14771
|
+
};
|
|
14772
|
+
/**
|
|
14773
|
+
* The write half: a PARTIAL patch. An omitted key is left untouched, so setting
|
|
14774
|
+
* one flag can never clear the other — the toggles are independent and are
|
|
14775
|
+
* driven from three surfaces that do not know about each other.
|
|
14776
|
+
*/
|
|
14777
|
+
var TrackFlagsPatchSchema = object(TrackFlagFields);
|
|
14778
|
+
/**
|
|
14779
|
+
* The resolved flag state after a write. Both fields are REQUIRED here (absent
|
|
14780
|
+
* collapses to `false`) so a caller can drive a toggle's checked state off the
|
|
14781
|
+
* mutation result without a re-fetch.
|
|
14377
14782
|
*/
|
|
14378
|
-
var
|
|
14783
|
+
var TrackFlagsSchema = object({
|
|
14784
|
+
trackId: string(),
|
|
14785
|
+
markForTrain: boolean(),
|
|
14786
|
+
debug: boolean()
|
|
14787
|
+
});
|
|
14379
14788
|
var TrackSchema = object({
|
|
14380
14789
|
trackId: string(),
|
|
14381
14790
|
deviceId: number(),
|
|
@@ -14418,7 +14827,8 @@ var TrackSchema = object({
|
|
|
14418
14827
|
/** Normalized 0..1 trajectory envelope (see {@link TrackEnvelopeSchema}).
|
|
14419
14828
|
* Populated from the persisted envelope columns on historical reads;
|
|
14420
14829
|
* absent on legacy rows, dims-less tracks and active (in-RAM) tracks. */
|
|
14421
|
-
envelope: TrackEnvelopeSchema.optional()
|
|
14830
|
+
envelope: TrackEnvelopeSchema.optional(),
|
|
14831
|
+
...TrackFlagFields
|
|
14422
14832
|
});
|
|
14423
14833
|
var BaseEventFields = {
|
|
14424
14834
|
id: string(),
|
|
@@ -14631,7 +15041,8 @@ var KeyEventSchema = object({
|
|
|
14631
15041
|
/** Highest-confidence ObjectEvent id for the track (empty when none). */
|
|
14632
15042
|
bestEventId: string(),
|
|
14633
15043
|
/** Track lifetime in ms (lastSeen - firstSeen). */
|
|
14634
|
-
windowMs: number().optional()
|
|
15044
|
+
windowMs: number().optional(),
|
|
15045
|
+
...TrackFlagFields
|
|
14635
15046
|
});
|
|
14636
15047
|
object({
|
|
14637
15048
|
trackId: string(),
|
|
@@ -14702,6 +15113,104 @@ var EventPruneCountsSchema = object({
|
|
|
14702
15113
|
object: number().int(),
|
|
14703
15114
|
audio: number().int()
|
|
14704
15115
|
});
|
|
15116
|
+
/**
|
|
15117
|
+
* Re-embed stored tracks from their key frames.
|
|
15118
|
+
*
|
|
15119
|
+
* The reason this is an operator-callable method and not a migration script:
|
|
15120
|
+
* every knob that decides what a vector MEANS — encoder model, crop margin,
|
|
15121
|
+
* squaring — is only changeable if the existing vectors can be regenerated.
|
|
15122
|
+
* Mixing feature spaces in one index makes cosine scores incomparable, and the
|
|
15123
|
+
* symptom is a quality regression with no visible cause.
|
|
15124
|
+
*/
|
|
15125
|
+
var RebuildObjectEmbeddingsInput = object({
|
|
15126
|
+
/** Restrict to one camera. Omit for the whole fleet. */
|
|
15127
|
+
deviceId: number().optional(),
|
|
15128
|
+
since: number().optional(),
|
|
15129
|
+
until: number().optional(),
|
|
15130
|
+
/** Stop after this many tracks; the result reports whether more remain. */
|
|
15131
|
+
maxTracks: number().int().positive().optional(),
|
|
15132
|
+
/**
|
|
15133
|
+
* Run every embedding on THIS node instead of round-robining the fleet.
|
|
15134
|
+
*
|
|
15135
|
+
* Named `executeOnNodeId` and not `nodeId` on purpose: an inline `nodeId`
|
|
15136
|
+
* field in cap args is read by `parent-unowned-call.ts` as a ROUTING PIN, so
|
|
15137
|
+
* calling it that would pin the rebuild REQUEST itself to that node — the
|
|
15138
|
+
* rebuild orchestration lives on the hub, and only the per-track step runs
|
|
15139
|
+
* remotely. This field is data; the per-track pin is applied inside.
|
|
15140
|
+
*
|
|
15141
|
+
* Absent ⇒ round-robin over every online node whose runner can serve the
|
|
15142
|
+
* pinned model.
|
|
15143
|
+
*/
|
|
15144
|
+
executeOnNodeId: string().optional(),
|
|
15145
|
+
/**
|
|
15146
|
+
* Milliseconds to wait between tracks; omit for the built-in default, `0` to
|
|
15147
|
+
* run flat out.
|
|
15148
|
+
*
|
|
15149
|
+
* A rebuild is bulk maintenance on hub-main's single thread. Measured
|
|
15150
|
+
* 2026-08-06, an unpaced pass held that thread busy 82.2 s out of 120 and
|
|
15151
|
+
* pushed `nodes.topology` from 0.25 s to 26 s for 43 minutes. The value in
|
|
15152
|
+
* force is logged at start and finish so a deliberately slow pass reads
|
|
15153
|
+
* differently from a stalled one.
|
|
15154
|
+
*/
|
|
15155
|
+
pacingMs: number().int().nonnegative().optional()
|
|
15156
|
+
});
|
|
15157
|
+
/**
|
|
15158
|
+
* Result of emptying the CLIP index.
|
|
15159
|
+
*
|
|
15160
|
+
* The clean slate before a policy change: a new crop margin or encoder model
|
|
15161
|
+
* leaves two feature spaces in one index whose cosine scores are not
|
|
15162
|
+
* comparable, so wiping and rebuilding is the only way to be sure every vector
|
|
15163
|
+
* means the same thing.
|
|
15164
|
+
*/
|
|
15165
|
+
var WipeObjectEmbeddingsResultSchema = object({ deleted: number() });
|
|
15166
|
+
/**
|
|
15167
|
+
* Acknowledgement that a rebuild STARTED.
|
|
15168
|
+
*
|
|
15169
|
+
* The pass costs ~1s per track — 45 minutes for a 2,600-track fleet — so it
|
|
15170
|
+
* runs detached and this returns immediately. Waiting for it made the client
|
|
15171
|
+
* time out while the work carried on server-side, which is the worst of both:
|
|
15172
|
+
* no result and no way to know it was still going. Poll
|
|
15173
|
+
* `getObjectEmbeddingRebuildStatus` for progress.
|
|
15174
|
+
*/
|
|
15175
|
+
var RebuildObjectEmbeddingsResultSchema = object({
|
|
15176
|
+
started: boolean(),
|
|
15177
|
+
/** True when a pass was already running; the new request is ignored. */
|
|
15178
|
+
alreadyRunning: boolean()
|
|
15179
|
+
});
|
|
15180
|
+
var RebuildStatusSchema = object({
|
|
15181
|
+
running: boolean(),
|
|
15182
|
+
scanned: number(),
|
|
15183
|
+
rebuilt: number(),
|
|
15184
|
+
/** Tracks whose key frame is gone — nothing to re-embed from. */
|
|
15185
|
+
missingKeyFrame: number(),
|
|
15186
|
+
/** Tracks with no usable detection box. */
|
|
15187
|
+
missingBbox: number(),
|
|
15188
|
+
/**
|
|
15189
|
+
* Tracks an executing node REFUSED rather than broke on — an unreadable key
|
|
15190
|
+
* frame, a step that threw. Separate from `failed` because the remedy is
|
|
15191
|
+
* different, and because a whole camera silently contributing zero vectors
|
|
15192
|
+
* is the shape of failure a rebuild must never hide.
|
|
15193
|
+
*/
|
|
15194
|
+
notRunnable: number(),
|
|
15195
|
+
/**
|
|
15196
|
+
* The pass stopped because NO node could serve the pinned model.
|
|
15197
|
+
*
|
|
15198
|
+
* Distinct from `notRunnable` on purpose: that one says "this track was
|
|
15199
|
+
* refused", this one says "the cluster cannot do this work at all" — every
|
|
15200
|
+
* candidate node either lacks the `clip-embedding` step, lacks a build of the
|
|
15201
|
+
* pinned model for its engine format, or dropped out. The remedy is a model /
|
|
15202
|
+
* engine change, not a per-camera one. Non-zero here always comes with
|
|
15203
|
+
* `complete: false`.
|
|
15204
|
+
*/
|
|
15205
|
+
noCapableNode: number(),
|
|
15206
|
+
failed: number(),
|
|
15207
|
+
/** Set once a pass ends: true only when EVERYTHING was covered. */
|
|
15208
|
+
complete: boolean().nullable(),
|
|
15209
|
+
startedAtMs: number().nullable(),
|
|
15210
|
+
finishedAtMs: number().nullable(),
|
|
15211
|
+
/** Present when the pass ended by throwing. */
|
|
15212
|
+
error: string().nullable()
|
|
15213
|
+
});
|
|
14705
15214
|
DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).readonly()), method(object({
|
|
14706
15215
|
deviceId: number(),
|
|
14707
15216
|
trackId: string()
|
|
@@ -14765,7 +15274,12 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
|
|
|
14765
15274
|
}), {
|
|
14766
15275
|
kind: "mutation",
|
|
14767
15276
|
auth: "admin"
|
|
14768
|
-
}), method(object({
|
|
15277
|
+
}), method(object({
|
|
15278
|
+
/** Log/audit scope only — the trackId is globally unique on its own. */
|
|
15279
|
+
deviceId: number(),
|
|
15280
|
+
trackId: string(),
|
|
15281
|
+
flags: TrackFlagsPatchSchema
|
|
15282
|
+
}), TrackFlagsSchema, { kind: "mutation" }), method(object({}), EventStoreFootprintSchema, {
|
|
14769
15283
|
kind: "query",
|
|
14770
15284
|
auth: "admin"
|
|
14771
15285
|
}), method(object({
|
|
@@ -14795,7 +15309,13 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
|
|
|
14795
15309
|
}), array(MediaFileSchema).readonly()), method(object({
|
|
14796
15310
|
trackId: string(),
|
|
14797
15311
|
kinds: array(MediaFileKindEnum).optional()
|
|
14798
|
-
}), array(MediaFileSchema).readonly()), method(object({ trackId: string() }), array(MediaFileInfoSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), object({
|
|
15312
|
+
}), array(MediaFileSchema).readonly()), method(object({ trackId: string() }), array(MediaFileInfoSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), method(object({}), WipeObjectEmbeddingsResultSchema, {
|
|
15313
|
+
kind: "mutation",
|
|
15314
|
+
auth: "admin"
|
|
15315
|
+
}), method(RebuildObjectEmbeddingsInput, RebuildObjectEmbeddingsResultSchema, {
|
|
15316
|
+
kind: "mutation",
|
|
15317
|
+
auth: "admin"
|
|
15318
|
+
}), method(object({}), RebuildStatusSchema), object({
|
|
14799
15319
|
deviceId: number(),
|
|
14800
15320
|
timestamp: number(),
|
|
14801
15321
|
frameWidth: number(),
|
|
@@ -15369,6 +15889,53 @@ var DetailResultSchema = object({
|
|
|
15369
15889
|
nativeFaceShortSidePx: number().optional()
|
|
15370
15890
|
});
|
|
15371
15891
|
/**
|
|
15892
|
+
* Why an executing node REFUSED a stateless step run (`runStatelessStep`).
|
|
15893
|
+
*
|
|
15894
|
+
* A refusal is a first-class answer, not an error, because the caller's next
|
|
15895
|
+
* move depends on WHICH one it is — and because "the pass produced nothing"
|
|
15896
|
+
* must never be reachable without a named, counted cause. The two tiers:
|
|
15897
|
+
*
|
|
15898
|
+
* - **node-level** (`unknown-step`, `model-not-servable`) — this node can
|
|
15899
|
+
* never serve this (step, model) pair. The caller drops it from its rotation
|
|
15900
|
+
* and retries the same work elsewhere; nothing about the work changes.
|
|
15901
|
+
* - **work-level** (`unreadable-frame`, `execution-failed`) — this node is
|
|
15902
|
+
* fine, this one request is not. Retrying it on another node would only
|
|
15903
|
+
* spread the same failure.
|
|
15904
|
+
*/
|
|
15905
|
+
var StatelessStepRefusalSchema = _enum([
|
|
15906
|
+
"unknown-step",
|
|
15907
|
+
"model-not-servable",
|
|
15908
|
+
"unreadable-frame",
|
|
15909
|
+
"execution-failed"
|
|
15910
|
+
]);
|
|
15911
|
+
/**
|
|
15912
|
+
* Answer to `runStatelessStep` — a discriminated union rather than a nullable
|
|
15913
|
+
* result, because `null` is exactly what made the camera-bound detail path
|
|
15914
|
+
* unable to tell "refused" from "never asked".
|
|
15915
|
+
*/
|
|
15916
|
+
var RunStatelessStepResultSchema = discriminatedUnion("kind", [object({
|
|
15917
|
+
kind: literal("ran"),
|
|
15918
|
+
/** The node that actually executed it — the pin, echoed back for the log. */
|
|
15919
|
+
nodeId: string(),
|
|
15920
|
+
/**
|
|
15921
|
+
* The model the step ran with.
|
|
15922
|
+
*
|
|
15923
|
+
* The node verified this exact id has a build for the format it dispatched
|
|
15924
|
+
* on BEFORE running, so the executor's format resolution returns it
|
|
15925
|
+
* unchanged. A caller that pinned a model must compare this field and
|
|
15926
|
+
* treat a mismatch as a refusal — the whole point of the pin is that a
|
|
15927
|
+
* pass writes one feature space.
|
|
15928
|
+
*/
|
|
15929
|
+
modelId: string(),
|
|
15930
|
+
details: array(DetailResultSchema)
|
|
15931
|
+
}), object({
|
|
15932
|
+
kind: literal("refused"),
|
|
15933
|
+
nodeId: string(),
|
|
15934
|
+
reason: StatelessStepRefusalSchema,
|
|
15935
|
+
/** Human-readable specifics — the format tried, the formats shipped, etc. */
|
|
15936
|
+
detail: string()
|
|
15937
|
+
})]);
|
|
15938
|
+
/**
|
|
15372
15939
|
* Per-camera tunable ranges + defaults. Single source of truth used
|
|
15373
15940
|
* by both the Zod data schema (validation + default fallback) and
|
|
15374
15941
|
* the device settings UI (slider min/max/step). Touch one place and
|
|
@@ -15704,10 +16271,46 @@ method(RunnerCameraConfigSchema, object({ success: literal(true) }), { kind: "mu
|
|
|
15704
16271
|
}), NativeCropResultSchema.nullable()), method(object({
|
|
15705
16272
|
deviceId: number(),
|
|
15706
16273
|
frameHandle: FrameHandleSchema.optional(),
|
|
16274
|
+
/**
|
|
16275
|
+
* FULL FRAME (base64 JPEG). The runner derives the crop rectangle from
|
|
16276
|
+
* `parent.bbox` with the cluster crop convention and cuts it itself —
|
|
16277
|
+
* do NOT pre-crop for this field, that is what `cropJpeg` is.
|
|
16278
|
+
*/
|
|
16279
|
+
frameJpeg: string().optional(),
|
|
16280
|
+
/**
|
|
16281
|
+
* PRE-CUT tile (base64 JPEG), used verbatim — NO padding is applied.
|
|
16282
|
+
* The fallback when the lease/session backing the frame is gone and the
|
|
16283
|
+
* caller already holds a crop.
|
|
16284
|
+
*/
|
|
15707
16285
|
cropJpeg: string().optional(),
|
|
15708
16286
|
parent: DetailParentSchema,
|
|
15709
16287
|
steps: array(string()).optional()
|
|
15710
|
-
}), object({ details: array(DetailResultSchema) }).nullable(), { kind: "mutation" })
|
|
16288
|
+
}), object({ details: array(DetailResultSchema) }).nullable(), { kind: "mutation" }), method(object({
|
|
16289
|
+
/** Catalog step id, e.g. `clip-embedding`. */
|
|
16290
|
+
stepId: string(),
|
|
16291
|
+
/**
|
|
16292
|
+
* REQUIRED model pin. The node runs this exact model or refuses with
|
|
16293
|
+
* `model-not-servable` — it never substitutes a format default, because
|
|
16294
|
+
* a fleet pass that round-robins across nodes would then fill one index
|
|
16295
|
+
* from several encoders.
|
|
16296
|
+
*/
|
|
16297
|
+
modelId: string(),
|
|
16298
|
+
/** FULL FRAME, base64 JPEG. The runner cuts — do NOT pre-crop. */
|
|
16299
|
+
frameJpeg: string(),
|
|
16300
|
+
/**
|
|
16301
|
+
* The subject box, NORMALISED [0,1] against `frameJpeg`. Normalised on
|
|
16302
|
+
* purpose: the caller stores boxes against a downscaled analysis frame
|
|
16303
|
+
* while the stored key frame is native-resolution, and the only side
|
|
16304
|
+
* that reliably knows the image's pixel dimensions is the side that
|
|
16305
|
+
* decodes it. Denormalising here removes a second reader of the
|
|
16306
|
+
* dimensions and the class of mismatch that comes with it.
|
|
16307
|
+
*/
|
|
16308
|
+
bbox: NativeCropBboxSchema,
|
|
16309
|
+
/** Parent class of the subject (`person`, `vehicle`, …) — carried into the result. */
|
|
16310
|
+
className: string(),
|
|
16311
|
+
/** Camera the pixels came from. Diagnostics + log tags ONLY — never routing. */
|
|
16312
|
+
sourceDeviceId: number()
|
|
16313
|
+
}), RunStatelessStepResultSchema, { kind: "mutation" });
|
|
15711
16314
|
var CameraPipelineConfigSchema = object({
|
|
15712
16315
|
engine: PipelineEngineChoiceSchema.optional(),
|
|
15713
16316
|
steps: array(PipelineStepInputSchema).readonly(),
|
|
@@ -16005,6 +16608,20 @@ var CameraStatusSchema = object({
|
|
|
16005
16608
|
detection: CameraDetectionStatusSchema.nullable(),
|
|
16006
16609
|
audio: CameraAudioStatusSchema.nullable(),
|
|
16007
16610
|
recording: CameraRecordingStatusSchema.nullable(),
|
|
16611
|
+
/**
|
|
16612
|
+
* Per-camera function switches an OPERATOR has turned off
|
|
16613
|
+
* ([D61](../../../../docs/decisions/adr-0067.md)).
|
|
16614
|
+
*
|
|
16615
|
+
* This is the difference between DISABLED and BROKEN. A camera whose
|
|
16616
|
+
* `detection` block reports zero fps and whose `switchedOff` contains
|
|
16617
|
+
* `'object-detection'` was switched off by a person; the same camera with an
|
|
16618
|
+
* empty list is failing. Every status surface must render the two
|
|
16619
|
+
* differently — a quiet camera that looks identical to a dead one is the
|
|
16620
|
+
* silence-reads-as-never-happened trap this repo keeps paying for.
|
|
16621
|
+
*
|
|
16622
|
+
* Empty when nothing is off. Never contains a switch no provider offers.
|
|
16623
|
+
*/
|
|
16624
|
+
switchedOff: array(CameraSwitchIdSchema).readonly(),
|
|
16008
16625
|
/** Unix timestamp (ms) when this snapshot was composed server-side. */
|
|
16009
16626
|
fetchedAt: number()
|
|
16010
16627
|
});
|
|
@@ -16173,7 +16790,14 @@ method(object({
|
|
|
16173
16790
|
}), method(object({
|
|
16174
16791
|
deviceId: number(),
|
|
16175
16792
|
agentNodeId: string().optional()
|
|
16176
|
-
}), CameraPipelineConfigSchema), method(object({ deviceId: number() }),
|
|
16793
|
+
}), CameraPipelineConfigSchema), method(object({ deviceId: number() }), CameraSwitchGroupSchema), method(object({
|
|
16794
|
+
deviceId: number(),
|
|
16795
|
+
switchId: CameraSwitchIdSchema,
|
|
16796
|
+
enabled: boolean()
|
|
16797
|
+
}), CameraSwitchGroupSchema, {
|
|
16798
|
+
kind: "mutation",
|
|
16799
|
+
auth: "admin"
|
|
16800
|
+
}), method(object({ deviceId: number() }), CameraStatusSchema), method(object({ deviceIds: array(number()).optional() }), array(CameraStatusSchema).readonly()), method(_void(), array(PipelineTemplateSchema).readonly()), method(object({
|
|
16177
16801
|
name: string(),
|
|
16178
16802
|
description: string().optional(),
|
|
16179
16803
|
config: CameraPipelineConfigSchema
|
|
@@ -17029,6 +17653,24 @@ var VectorDeleteByFilterInputSchema = object({
|
|
|
17029
17653
|
filter: VectorFilterSchema
|
|
17030
17654
|
});
|
|
17031
17655
|
var VectorDeleteResultSchema = object({ deleted: number() });
|
|
17656
|
+
var VectorGetInputSchema = object({
|
|
17657
|
+
index: string(),
|
|
17658
|
+
ids: array(string())
|
|
17659
|
+
});
|
|
17660
|
+
/**
|
|
17661
|
+
* Metadata for the requested ids, WITHOUT their vectors.
|
|
17662
|
+
*
|
|
17663
|
+
* The only caller is a best-of gate that compares a candidate's confidence
|
|
17664
|
+
* against the stored one, and shipping 512 floats back to answer "is 0.91 >
|
|
17665
|
+
* 0.87" would undo the point of the compact encoding. Ids with no row are
|
|
17666
|
+
* simply absent — a caller distinguishing "not stored" from "stored" reads the
|
|
17667
|
+
* length, and a null placeholder would invite a `?? 0` that treats a missing
|
|
17668
|
+
* row as confidence zero.
|
|
17669
|
+
*/
|
|
17670
|
+
var VectorGetResultSchema = object({ items: array(object({
|
|
17671
|
+
id: string(),
|
|
17672
|
+
metadata: VectorMetadataSchema
|
|
17673
|
+
})) });
|
|
17032
17674
|
var VectorStatsInputSchema = object({ index: string() });
|
|
17033
17675
|
var VectorStatsResultSchema = object({
|
|
17034
17676
|
/** Provider id, so an operator can tell brute force from an ANN index. */
|
|
@@ -17041,7 +17683,7 @@ var VectorStatsResultSchema = object({
|
|
|
17041
17683
|
/** False when the backend ranks approximately. */
|
|
17042
17684
|
exact: boolean()
|
|
17043
17685
|
});
|
|
17044
|
-
method(VectorDeclareIndexInputSchema, _void(), { kind: "mutation" }), method(VectorUpsertInputSchema, VectorUpsertResultSchema, { kind: "mutation" }), method(VectorQueryInputSchema, VectorQueryResultSchema), method(VectorDeleteInputSchema, VectorDeleteResultSchema, { kind: "mutation" }), method(VectorDeleteByFilterInputSchema, VectorDeleteResultSchema, { kind: "mutation" }), method(VectorStatsInputSchema, VectorStatsResultSchema);
|
|
17686
|
+
method(VectorDeclareIndexInputSchema, _void(), { kind: "mutation" }), method(VectorUpsertInputSchema, VectorUpsertResultSchema, { kind: "mutation" }), method(VectorQueryInputSchema, VectorQueryResultSchema), method(VectorGetInputSchema, VectorGetResultSchema), method(VectorDeleteInputSchema, VectorDeleteResultSchema, { kind: "mutation" }), method(VectorDeleteByFilterInputSchema, VectorDeleteResultSchema, { kind: "mutation" }), method(VectorStatsInputSchema, VectorStatsResultSchema);
|
|
17045
17687
|
/**
|
|
17046
17688
|
* `videoclips` — the unified, navigable-clip surface for a camera.
|
|
17047
17689
|
*
|
|
@@ -25112,6 +25754,12 @@ Object.freeze({
|
|
|
25112
25754
|
addonId: null,
|
|
25113
25755
|
access: "view"
|
|
25114
25756
|
},
|
|
25757
|
+
"notificationRules.listDeviceMutes": {
|
|
25758
|
+
capName: "notification-rules",
|
|
25759
|
+
capScope: "system",
|
|
25760
|
+
addonId: null,
|
|
25761
|
+
access: "view"
|
|
25762
|
+
},
|
|
25115
25763
|
"notificationRules.listRules": {
|
|
25116
25764
|
capName: "notification-rules",
|
|
25117
25765
|
capScope: "system",
|
|
@@ -25130,6 +25778,12 @@ Object.freeze({
|
|
|
25130
25778
|
addonId: null,
|
|
25131
25779
|
access: "create"
|
|
25132
25780
|
},
|
|
25781
|
+
"notificationRules.setDeviceMuted": {
|
|
25782
|
+
capName: "notification-rules",
|
|
25783
|
+
capScope: "system",
|
|
25784
|
+
addonId: null,
|
|
25785
|
+
access: "create"
|
|
25786
|
+
},
|
|
25133
25787
|
"notificationRules.setRuleEnabled": {
|
|
25134
25788
|
capName: "notification-rules",
|
|
25135
25789
|
capScope: "system",
|
|
@@ -25304,6 +25958,12 @@ Object.freeze({
|
|
|
25304
25958
|
addonId: null,
|
|
25305
25959
|
access: "view"
|
|
25306
25960
|
},
|
|
25961
|
+
"pipelineAnalytics.getObjectEmbeddingRebuildStatus": {
|
|
25962
|
+
capName: "pipeline-analytics",
|
|
25963
|
+
capScope: "device",
|
|
25964
|
+
addonId: null,
|
|
25965
|
+
access: "view"
|
|
25966
|
+
},
|
|
25307
25967
|
"pipelineAnalytics.getObjectEvents": {
|
|
25308
25968
|
capName: "pipeline-analytics",
|
|
25309
25969
|
capScope: "device",
|
|
@@ -25382,6 +26042,12 @@ Object.freeze({
|
|
|
25382
26042
|
addonId: null,
|
|
25383
26043
|
access: "create"
|
|
25384
26044
|
},
|
|
26045
|
+
"pipelineAnalytics.rebuildObjectEmbeddings": {
|
|
26046
|
+
capName: "pipeline-analytics",
|
|
26047
|
+
capScope: "device",
|
|
26048
|
+
addonId: null,
|
|
26049
|
+
access: "create"
|
|
26050
|
+
},
|
|
25385
26051
|
"pipelineAnalytics.relocateMedia": {
|
|
25386
26052
|
capName: "pipeline-analytics",
|
|
25387
26053
|
capScope: "device",
|
|
@@ -25394,12 +26060,24 @@ Object.freeze({
|
|
|
25394
26060
|
addonId: null,
|
|
25395
26061
|
access: "view"
|
|
25396
26062
|
},
|
|
26063
|
+
"pipelineAnalytics.setTrackFlags": {
|
|
26064
|
+
capName: "pipeline-analytics",
|
|
26065
|
+
capScope: "device",
|
|
26066
|
+
addonId: null,
|
|
26067
|
+
access: "create"
|
|
26068
|
+
},
|
|
25397
26069
|
"pipelineAnalytics.wipeAllAnalytics": {
|
|
25398
26070
|
capName: "pipeline-analytics",
|
|
25399
26071
|
capScope: "device",
|
|
25400
26072
|
addonId: null,
|
|
25401
26073
|
access: "delete"
|
|
25402
26074
|
},
|
|
26075
|
+
"pipelineAnalytics.wipeObjectEmbeddings": {
|
|
26076
|
+
capName: "pipeline-analytics",
|
|
26077
|
+
capScope: "device",
|
|
26078
|
+
addonId: null,
|
|
26079
|
+
access: "delete"
|
|
26080
|
+
},
|
|
25403
26081
|
"pipelineExecutor.cacheFrameInPool": {
|
|
25404
26082
|
capName: "pipeline-executor",
|
|
25405
26083
|
capScope: "system",
|
|
@@ -25694,6 +26372,12 @@ Object.freeze({
|
|
|
25694
26372
|
addonId: null,
|
|
25695
26373
|
access: "view"
|
|
25696
26374
|
},
|
|
26375
|
+
"pipelineOrchestrator.getCameraSwitches": {
|
|
26376
|
+
capName: "pipeline-orchestrator",
|
|
26377
|
+
capScope: "system",
|
|
26378
|
+
addonId: null,
|
|
26379
|
+
access: "view"
|
|
26380
|
+
},
|
|
25697
26381
|
"pipelineOrchestrator.getCapabilityBindings": {
|
|
25698
26382
|
capName: "pipeline-orchestrator",
|
|
25699
26383
|
capScope: "system",
|
|
@@ -25826,6 +26510,12 @@ Object.freeze({
|
|
|
25826
26510
|
addonId: null,
|
|
25827
26511
|
access: "create"
|
|
25828
26512
|
},
|
|
26513
|
+
"pipelineOrchestrator.setCameraSwitch": {
|
|
26514
|
+
capName: "pipeline-orchestrator",
|
|
26515
|
+
capScope: "system",
|
|
26516
|
+
addonId: null,
|
|
26517
|
+
access: "create"
|
|
26518
|
+
},
|
|
25829
26519
|
"pipelineOrchestrator.setCapabilityBinding": {
|
|
25830
26520
|
capName: "pipeline-orchestrator",
|
|
25831
26521
|
capScope: "system",
|
|
@@ -25916,6 +26606,12 @@ Object.freeze({
|
|
|
25916
26606
|
addonId: null,
|
|
25917
26607
|
access: "create"
|
|
25918
26608
|
},
|
|
26609
|
+
"pipelineRunner.runStatelessStep": {
|
|
26610
|
+
capName: "pipeline-runner",
|
|
26611
|
+
capScope: "system",
|
|
26612
|
+
addonId: null,
|
|
26613
|
+
access: "create"
|
|
26614
|
+
},
|
|
25919
26615
|
"plateGallery.assignPlate": {
|
|
25920
26616
|
capName: "plate-gallery",
|
|
25921
26617
|
capScope: "system",
|
|
@@ -26732,6 +27428,12 @@ Object.freeze({
|
|
|
26732
27428
|
addonId: null,
|
|
26733
27429
|
access: "create"
|
|
26734
27430
|
},
|
|
27431
|
+
"streamBroker.acquireEgressTranscode": {
|
|
27432
|
+
capName: "stream-broker",
|
|
27433
|
+
capScope: "system",
|
|
27434
|
+
addonId: null,
|
|
27435
|
+
access: "create"
|
|
27436
|
+
},
|
|
26735
27437
|
"streamBroker.assignProfile": {
|
|
26736
27438
|
capName: "stream-broker",
|
|
26737
27439
|
capScope: "system",
|
|
@@ -26840,6 +27542,12 @@ Object.freeze({
|
|
|
26840
27542
|
addonId: null,
|
|
26841
27543
|
access: "create"
|
|
26842
27544
|
},
|
|
27545
|
+
"streamBroker.releaseEgressTranscode": {
|
|
27546
|
+
capName: "stream-broker",
|
|
27547
|
+
capScope: "system",
|
|
27548
|
+
addonId: null,
|
|
27549
|
+
access: "create"
|
|
27550
|
+
},
|
|
26843
27551
|
"streamBroker.releaseStreamWithCodec": {
|
|
26844
27552
|
capName: "stream-broker",
|
|
26845
27553
|
capScope: "system",
|
|
@@ -27320,6 +28028,12 @@ Object.freeze({
|
|
|
27320
28028
|
addonId: null,
|
|
27321
28029
|
access: "delete"
|
|
27322
28030
|
},
|
|
28031
|
+
"vectorStore.getByIds": {
|
|
28032
|
+
capName: "vector-store",
|
|
28033
|
+
capScope: "system",
|
|
28034
|
+
addonId: null,
|
|
28035
|
+
access: "view"
|
|
28036
|
+
},
|
|
27323
28037
|
"vectorStore.query": {
|
|
27324
28038
|
capName: "vector-store",
|
|
27325
28039
|
capScope: "system",
|
|
@@ -27594,37 +28308,30 @@ TimelapseRuleInputSchema.extend({
|
|
|
27594
28308
|
createdAt: number(),
|
|
27595
28309
|
updatedAt: number()
|
|
27596
28310
|
});
|
|
27597
|
-
|
|
27598
|
-
|
|
27599
|
-
*
|
|
27600
|
-
*
|
|
27601
|
-
*
|
|
27602
|
-
*
|
|
27603
|
-
*
|
|
27604
|
-
*
|
|
27605
|
-
|
|
27606
|
-
|
|
27607
|
-
|
|
27608
|
-
*
|
|
27609
|
-
*
|
|
27610
|
-
*
|
|
27611
|
-
*
|
|
27612
|
-
*
|
|
27613
|
-
*
|
|
27614
|
-
*/
|
|
27615
|
-
|
|
27616
|
-
|
|
27617
|
-
|
|
27618
|
-
|
|
27619
|
-
|
|
27620
|
-
|
|
27621
|
-
const obj = value;
|
|
27622
|
-
const out = {};
|
|
27623
|
-
for (const k of Object.keys(obj).toSorted()) out[k] = obj[k];
|
|
27624
|
-
return out;
|
|
27625
|
-
}
|
|
27626
|
-
return value;
|
|
27627
|
-
}
|
|
28311
|
+
object({
|
|
28312
|
+
/**
|
|
28313
|
+
* Fraction of the box's own size added on EACH side before cutting.
|
|
28314
|
+
*
|
|
28315
|
+
* CLIP is trained on natural images WITH surroundings; a pixel-tight crop
|
|
28316
|
+
* removes exactly the context it is strongest on (a dog cut to its outline
|
|
28317
|
+
* is a dark blob). The right value is an empirical question, which is why it
|
|
28318
|
+
* is a setting: 0 / 0.15 / 0.2 / 0.5 are the interesting points.
|
|
28319
|
+
*/
|
|
28320
|
+
paddingRatio: number().min(0).max(4),
|
|
28321
|
+
/**
|
|
28322
|
+
* Square the window (in PIXELS) before cutting.
|
|
28323
|
+
*
|
|
28324
|
+
* CLIP's input is square, so a tall bbox resized straight to NxN is squashed
|
|
28325
|
+
* — a standing person becomes a shape the model never saw. Squaring costs
|
|
28326
|
+
* extra background, which is context the model wants anyway. Off by default
|
|
28327
|
+
* because the live path has never squared and the stored index reflects that.
|
|
28328
|
+
*/
|
|
28329
|
+
square: boolean()
|
|
28330
|
+
});
|
|
28331
|
+
({
|
|
28332
|
+
paddingRatio: .15,
|
|
28333
|
+
square: false
|
|
28334
|
+
}).paddingRatio;
|
|
27628
28335
|
/**
|
|
27629
28336
|
* Compute the stable 64-char lowercase-hex fingerprint of a device's
|
|
27630
28337
|
* export-relevant shape. Two structurally-equal shapes (any feature order,
|
|
@@ -27796,6 +28503,110 @@ function firstExposedAccessorySetupUri(exposed, logger) {
|
|
|
27796
28503
|
}
|
|
27797
28504
|
}
|
|
27798
28505
|
}
|
|
28506
|
+
/**
|
|
28507
|
+
* hap-nodejs' `checkName` regex, verbatim.
|
|
28508
|
+
*
|
|
28509
|
+
* Duplicated rather than imported because it is `@private Private API` in that
|
|
28510
|
+
* package and not exported. Duplicating a private regex is a liability, so the
|
|
28511
|
+
* guard against drift is behavioural, not textual: `service-naming.spec.ts`
|
|
28512
|
+
* builds real services and asserts hap-nodejs emits ZERO characteristic
|
|
28513
|
+
* warnings — if this expression ever diverges from theirs, that test fails.
|
|
28514
|
+
*/
|
|
28515
|
+
var HAP_NAME_PATTERN = /^[\p{L}\p{N}][\p{L}\p{N}\p{Zs}’'&!._:;()/,-]*[\p{L}\p{N}]$/u;
|
|
28516
|
+
/** Characters HAP tolerates INSIDE a name. Anything else becomes a space. */
|
|
28517
|
+
var HAP_NAME_INNER = /[^\p{L}\p{N}\p{Zs}’'&!._:;()/,-]/gu;
|
|
28518
|
+
/** Would hap-nodejs accept this as a `Name` characteristic value? */
|
|
28519
|
+
function isHapServiceName(value) {
|
|
28520
|
+
return HAP_NAME_PATTERN.test(value);
|
|
28521
|
+
}
|
|
28522
|
+
/**
|
|
28523
|
+
* Build one HAP-valid service name from device-derived parts.
|
|
28524
|
+
*
|
|
28525
|
+
* Empty and absent parts are dropped rather than joined, so a missing role
|
|
28526
|
+
* never produces a double space. `fallback` is used ONLY when nothing
|
|
28527
|
+
* device-derived survives sanitisation — it is the last resort, not the
|
|
28528
|
+
* default, because a name that says nothing about the device is the defect
|
|
28529
|
+
* this module exists to end.
|
|
28530
|
+
*/
|
|
28531
|
+
function hapServiceName(parts, fallback) {
|
|
28532
|
+
const trimmed = trimToHapName(parts.map((part) => typeof part === "string" ? part : "").map((part) => part.replace(HAP_NAME_INNER, " ")).join(" ").replace(/\s+/gu, " ").trim());
|
|
28533
|
+
if (trimmed !== null) return trimmed;
|
|
28534
|
+
return trimToHapName(fallback.replace(HAP_NAME_INNER, " ").replace(/\s+/gu, " ").trim()) ?? "";
|
|
28535
|
+
}
|
|
28536
|
+
/**
|
|
28537
|
+
* The privacy-mask switch.
|
|
28538
|
+
*
|
|
28539
|
+
* The camera's own name carries the meaning; "Privacy" only says which of the
|
|
28540
|
+
* camera's switches this is. It used to be the WHOLE name, which is why the
|
|
28541
|
+
* operator saw a switch that named neither its camera nor, with two cameras
|
|
28542
|
+
* exposed, which camera it belonged to.
|
|
28543
|
+
*/
|
|
28544
|
+
function privacyServiceName(deviceName) {
|
|
28545
|
+
return hapServiceName([deviceName, PRIVACY_SUFFIX], PRIVACY_SUFFIX);
|
|
28546
|
+
}
|
|
28547
|
+
/**
|
|
28548
|
+
* Deliberately not localised, and deliberately not a translation table.
|
|
28549
|
+
*
|
|
28550
|
+
* The device half of the name is the operator's own text and arrives in the
|
|
28551
|
+
* operator's language. This half names a camstack capability (`privacy-mask`)
|
|
28552
|
+
* and there is no locale in an addon's context to resolve it against; the word
|
|
28553
|
+
* is also identical in the operator's language. A translation layer for one
|
|
28554
|
+
* word would be the kind of leftover that reads as verification.
|
|
28555
|
+
*/
|
|
28556
|
+
var PRIVACY_SUFFIX = "Privacy";
|
|
28557
|
+
/**
|
|
28558
|
+
* An accessory child (siren, floodlight, spotlight) rendered as a service on
|
|
28559
|
+
* the parent camera.
|
|
28560
|
+
*
|
|
28561
|
+
* The child's OWN stored name wins. It is the string the operator typed, in
|
|
28562
|
+
* the operator's language, and the previous rule threw it away: `role` was
|
|
28563
|
+
* consulted first and title-cased, so every siren on the fleet published as
|
|
28564
|
+
* the English word "Siren" no matter what the operator had called it.
|
|
28565
|
+
*
|
|
28566
|
+
* The parent name is prefixed only when the child's name does not already
|
|
28567
|
+
* carry it — providers name children both ways ("Sirena" and "Videocamera
|
|
28568
|
+
* cucina Sirena") and the result must be one form, not two.
|
|
28569
|
+
*/
|
|
28570
|
+
function childServiceName(parentName, child) {
|
|
28571
|
+
const own = child.name.trim();
|
|
28572
|
+
if (own.length > 0) return mentions(own, parentName) ? hapServiceName([own], parentName) : hapServiceName([parentName, own], own);
|
|
28573
|
+
return hapServiceName([parentName, typeof child.role === "string" ? titleCase(child.role) : ""], parentName);
|
|
28574
|
+
}
|
|
28575
|
+
/**
|
|
28576
|
+
* A PTZ action switch.
|
|
28577
|
+
*
|
|
28578
|
+
* The action labels themselves stay in `ptz-labels.ts` — they name a HomeKit
|
|
28579
|
+
* control, not a device — but the service is still qualified by the camera, so
|
|
28580
|
+
* a home with two PTZ cameras does not publish two switches called "Preset
|
|
28581
|
+
* ingresso".
|
|
28582
|
+
*/
|
|
28583
|
+
function ptzServiceName(deviceName, actionLabel) {
|
|
28584
|
+
return hapServiceName([deviceName, actionLabel], actionLabel);
|
|
28585
|
+
}
|
|
28586
|
+
/** Does `name` already contain `parentName` as a whole word? */
|
|
28587
|
+
function mentions(name, parentName) {
|
|
28588
|
+
const needle = parentName.trim().toLowerCase();
|
|
28589
|
+
if (needle.length === 0) return true;
|
|
28590
|
+
return name.toLowerCase().includes(needle);
|
|
28591
|
+
}
|
|
28592
|
+
/**
|
|
28593
|
+
* Truncate to the HAP ceiling and shave any leading/trailing character the
|
|
28594
|
+
* pattern forbids. Returns `null` when nothing usable is left — the caller
|
|
28595
|
+
* decides what to do with that, because "fall back" and "drop the part" are
|
|
28596
|
+
* different answers.
|
|
28597
|
+
*/
|
|
28598
|
+
function trimToHapName(value) {
|
|
28599
|
+
let out = value.length > 64 ? value.slice(0, 64) : value;
|
|
28600
|
+
while (out.length > 0 && !isAlphanumeric(out[out.length - 1])) out = out.slice(0, -1);
|
|
28601
|
+
while (out.length > 0 && !isAlphanumeric(out[0])) out = out.slice(1);
|
|
28602
|
+
return isHapServiceName(out) ? out : null;
|
|
28603
|
+
}
|
|
28604
|
+
function isAlphanumeric(ch) {
|
|
28605
|
+
return ch !== void 0 && /[\p{L}\p{N}]/u.test(ch);
|
|
28606
|
+
}
|
|
28607
|
+
function titleCase(raw) {
|
|
28608
|
+
return raw.split(/[-_\s]+/u).filter((part) => part.length > 0).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
|
|
28609
|
+
}
|
|
27799
28610
|
//#endregion
|
|
27800
28611
|
//#region src/mappers/builders/battery.ts
|
|
27801
28612
|
/**
|
|
@@ -27820,7 +28631,7 @@ var LOW_BATTERY_THRESHOLD_PCT = 20;
|
|
|
27820
28631
|
async function buildBattery(bctx) {
|
|
27821
28632
|
const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
|
|
27822
28633
|
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
27823
|
-
const service = accessory.addService(Service.Battery, displayName);
|
|
28634
|
+
const service = accessory.addService(Service.Battery, hapServiceName([displayName], `Camera ${numericDeviceId}`));
|
|
27824
28635
|
try {
|
|
27825
28636
|
const status = await proxy.battery?.getStatus({});
|
|
27826
28637
|
if (status) applyToService(service, status);
|
|
@@ -38150,13 +38961,1035 @@ var require_src = /* @__PURE__ */ __commonJSMin(((exports) => {
|
|
|
38150
38961
|
__exportStar(require_util(), exports);
|
|
38151
38962
|
}));
|
|
38152
38963
|
//#endregion
|
|
38153
|
-
//#region src/mappers/builders/
|
|
38964
|
+
//#region src/mappers/builders/rtcp-gate.ts
|
|
38154
38965
|
var import_src = require_src();
|
|
38966
|
+
/** `packet@42ms` / `timeout@1000ms` — one compact log field. */
|
|
38967
|
+
function formatRtcpGate(resolution) {
|
|
38968
|
+
if (resolution === null) return "pending";
|
|
38969
|
+
return `${resolution.reason}@${resolution.waitedMs}ms`;
|
|
38970
|
+
}
|
|
38971
|
+
/**
|
|
38972
|
+
* Resolve as soon as ANY packet arrives on `socket`, or after `timeoutMs`.
|
|
38973
|
+
* One-shot: the listener is detached on either path.
|
|
38974
|
+
*/
|
|
38975
|
+
function makeRtcpGate(socket, timeoutMs) {
|
|
38976
|
+
const armedAt = Date.now();
|
|
38977
|
+
return new Promise((resolve) => {
|
|
38978
|
+
const onMessage = () => {
|
|
38979
|
+
socket.removeListener("message", onMessage);
|
|
38980
|
+
clearTimeout(timer);
|
|
38981
|
+
resolve({
|
|
38982
|
+
reason: "packet",
|
|
38983
|
+
waitedMs: Date.now() - armedAt
|
|
38984
|
+
});
|
|
38985
|
+
};
|
|
38986
|
+
const timer = setTimeout(() => {
|
|
38987
|
+
socket.removeListener("message", onMessage);
|
|
38988
|
+
resolve({
|
|
38989
|
+
reason: "timeout",
|
|
38990
|
+
waitedMs: Date.now() - armedAt
|
|
38991
|
+
});
|
|
38992
|
+
}, timeoutMs);
|
|
38993
|
+
socket.on("message", onMessage);
|
|
38994
|
+
});
|
|
38995
|
+
}
|
|
38996
|
+
/** V/P/RC byte + PT byte + 16-bit length. */
|
|
38997
|
+
var RTCP_HEADER_BYTES = 4;
|
|
38998
|
+
/** SSRC of the packet sender, first field of both SR and RR bodies. */
|
|
38999
|
+
var RTCP_SENDER_SSRC_BYTES = 4;
|
|
39000
|
+
/** NTP + RTP timestamp, packet count, octet count — present only on an SR. */
|
|
39001
|
+
var RTCP_SENDER_INFO_BYTES = 20;
|
|
39002
|
+
/** One report block: SSRC_n, loss, highest seq, jitter, LSR, DLSR. */
|
|
39003
|
+
var RTCP_REPORT_BLOCK_BYTES = 24;
|
|
39004
|
+
/** `fraction lost` is an 8-bit fixed-point fraction of 256. */
|
|
39005
|
+
var FRACTION_LOST_DENOMINATOR = 256;
|
|
39006
|
+
/** `delay since last SR` counts 1/65536 of a second. */
|
|
39007
|
+
var DLSR_UNITS_PER_SECOND = 65536;
|
|
39008
|
+
/** `cumulative number of packets lost` is a SIGNED 24-bit field. */
|
|
39009
|
+
var SIGNED_24_SIGN_BIT = 8388608;
|
|
39010
|
+
var SIGNED_24_MODULUS = 16777216;
|
|
39011
|
+
function readSigned24(packet, at) {
|
|
39012
|
+
const raw = packet[at] << 16 | packet[at + 1] << 8 | packet[at + 2];
|
|
39013
|
+
return raw >= SIGNED_24_SIGN_BIT ? raw - SIGNED_24_MODULUS : raw;
|
|
39014
|
+
}
|
|
39015
|
+
function readReportBlock(packet, at) {
|
|
39016
|
+
const fractionLostRaw = packet[at + 4];
|
|
39017
|
+
const dlsr = packet.readUInt32BE(at + 20);
|
|
39018
|
+
return {
|
|
39019
|
+
aboutSsrc: packet.readUInt32BE(at),
|
|
39020
|
+
fractionLostRaw,
|
|
39021
|
+
fractionLostPct: Math.round(fractionLostRaw / FRACTION_LOST_DENOMINATOR * 1e3) / 10,
|
|
39022
|
+
cumulativePacketsLost: readSigned24(packet, at + 5),
|
|
39023
|
+
extendedHighestSequence: packet.readUInt32BE(at + 8),
|
|
39024
|
+
jitter: packet.readUInt32BE(at + 12),
|
|
39025
|
+
lastSrTimestamp: packet.readUInt32BE(at + 16),
|
|
39026
|
+
delaySinceLastSrMs: Math.round(dlsr / DLSR_UNITS_PER_SECOND * 1e3)
|
|
39027
|
+
};
|
|
39028
|
+
}
|
|
39029
|
+
/**
|
|
39030
|
+
* Walk a (possibly compound) RTCP datagram and extract every report block.
|
|
39031
|
+
*
|
|
39032
|
+
* An RR is routinely bundled behind an SDES, and a controller with nothing to
|
|
39033
|
+
* report yet sends an RR with a reception-report count of zero. Both are
|
|
39034
|
+
* normal; neither is a failure. What IS a failure is a packet whose declared
|
|
39035
|
+
* length runs past the buffer or whose report count exceeds its own body —
|
|
39036
|
+
* those get named so a decrypt fault cannot masquerade as silence.
|
|
39037
|
+
*/
|
|
39038
|
+
function parseCompoundRtcp(packet) {
|
|
39039
|
+
if (packet.length === 0) return {
|
|
39040
|
+
ok: false,
|
|
39041
|
+
failure: "empty",
|
|
39042
|
+
atOffset: 0
|
|
39043
|
+
};
|
|
39044
|
+
const packetTypes = [];
|
|
39045
|
+
const reports = [];
|
|
39046
|
+
let offset = 0;
|
|
39047
|
+
while (offset < packet.length) {
|
|
39048
|
+
if (packet.length - offset < RTCP_HEADER_BYTES) return {
|
|
39049
|
+
ok: false,
|
|
39050
|
+
failure: "short-header",
|
|
39051
|
+
atOffset: offset
|
|
39052
|
+
};
|
|
39053
|
+
const firstByte = packet[offset];
|
|
39054
|
+
if ((firstByte >> 6 & 3) !== 2) return {
|
|
39055
|
+
ok: false,
|
|
39056
|
+
failure: "bad-version",
|
|
39057
|
+
atOffset: offset
|
|
39058
|
+
};
|
|
39059
|
+
const reportCount = firstByte & 31;
|
|
39060
|
+
const packetType = packet[offset + 1];
|
|
39061
|
+
const totalBytes = (packet.readUInt16BE(offset + 2) + 1) * 4;
|
|
39062
|
+
if (offset + totalBytes > packet.length) return {
|
|
39063
|
+
ok: false,
|
|
39064
|
+
failure: "length-overrun",
|
|
39065
|
+
atOffset: offset
|
|
39066
|
+
};
|
|
39067
|
+
packetTypes.push(packetType);
|
|
39068
|
+
if (packetType === 201 || packetType === 200) {
|
|
39069
|
+
const senderSsrcAt = offset + RTCP_HEADER_BYTES;
|
|
39070
|
+
const blocksAt = senderSsrcAt + RTCP_SENDER_SSRC_BYTES + (packetType === 200 ? RTCP_SENDER_INFO_BYTES : 0);
|
|
39071
|
+
if (blocksAt + reportCount * RTCP_REPORT_BLOCK_BYTES > offset + totalBytes) return {
|
|
39072
|
+
ok: false,
|
|
39073
|
+
failure: "truncated-body",
|
|
39074
|
+
atOffset: offset
|
|
39075
|
+
};
|
|
39076
|
+
const blocks = [];
|
|
39077
|
+
for (let index = 0; index < reportCount; index += 1) blocks.push(readReportBlock(packet, blocksAt + index * RTCP_REPORT_BLOCK_BYTES));
|
|
39078
|
+
reports.push({
|
|
39079
|
+
packetType,
|
|
39080
|
+
reporterSsrc: packet.readUInt32BE(senderSsrcAt),
|
|
39081
|
+
blocks
|
|
39082
|
+
});
|
|
39083
|
+
}
|
|
39084
|
+
offset += totalBytes;
|
|
39085
|
+
}
|
|
39086
|
+
return {
|
|
39087
|
+
ok: true,
|
|
39088
|
+
packetTypes,
|
|
39089
|
+
reports
|
|
39090
|
+
};
|
|
39091
|
+
}
|
|
39092
|
+
function emptyReceiverReportTally() {
|
|
39093
|
+
return {
|
|
39094
|
+
reportsParsed: 0,
|
|
39095
|
+
blocksParsed: 0,
|
|
39096
|
+
unreadable: 0,
|
|
39097
|
+
lastFractionLostPct: null,
|
|
39098
|
+
worstFractionLostPct: null,
|
|
39099
|
+
lastCumulativePacketsLost: null,
|
|
39100
|
+
maxCumulativePacketsLost: null,
|
|
39101
|
+
lastJitter: null,
|
|
39102
|
+
maxJitter: null,
|
|
39103
|
+
lastExtendedHighestSequence: null
|
|
39104
|
+
};
|
|
39105
|
+
}
|
|
39106
|
+
function maxOrValue(previous, next) {
|
|
39107
|
+
return previous === null ? next : Math.max(previous, next);
|
|
39108
|
+
}
|
|
39109
|
+
/** Fold one report block into the tally. Returns a new tally. */
|
|
39110
|
+
function applyReceiverReportBlock(tally, block) {
|
|
39111
|
+
return {
|
|
39112
|
+
...tally,
|
|
39113
|
+
blocksParsed: tally.blocksParsed + 1,
|
|
39114
|
+
lastFractionLostPct: block.fractionLostPct,
|
|
39115
|
+
worstFractionLostPct: maxOrValue(tally.worstFractionLostPct, block.fractionLostPct),
|
|
39116
|
+
lastCumulativePacketsLost: block.cumulativePacketsLost,
|
|
39117
|
+
maxCumulativePacketsLost: maxOrValue(tally.maxCumulativePacketsLost, block.cumulativePacketsLost),
|
|
39118
|
+
lastJitter: block.jitter,
|
|
39119
|
+
maxJitter: maxOrValue(tally.maxJitter, block.jitter),
|
|
39120
|
+
lastExtendedHighestSequence: block.extendedHighestSequence
|
|
39121
|
+
};
|
|
39122
|
+
}
|
|
39123
|
+
/** Book an RTCP datagram we could not read. Counted, never discarded silently. */
|
|
39124
|
+
function recordUnreadableRtcp(tally) {
|
|
39125
|
+
return {
|
|
39126
|
+
...tally,
|
|
39127
|
+
unreadable: tally.unreadable + 1
|
|
39128
|
+
};
|
|
39129
|
+
}
|
|
39130
|
+
/**
|
|
39131
|
+
* Parse one decrypted RTCP datagram and fold it into a leg's tally.
|
|
39132
|
+
*
|
|
39133
|
+
* This is the seam the delegate calls: it owns SRTCP decryption and logging,
|
|
39134
|
+
* this owns everything that can be asserted from bytes alone. A malformed
|
|
39135
|
+
* packet returns a failure and an incremented `unreadable` — it never throws,
|
|
39136
|
+
* because this runs on a UDP `message` handler where a throw would take the
|
|
39137
|
+
* session with it.
|
|
39138
|
+
*/
|
|
39139
|
+
function ingestDecryptedRtcp(plaintext, tally) {
|
|
39140
|
+
const parsed = parseCompoundRtcp(plaintext);
|
|
39141
|
+
if (!parsed.ok) return {
|
|
39142
|
+
tally: recordUnreadableRtcp(tally),
|
|
39143
|
+
reports: [],
|
|
39144
|
+
failure: parsed.failure
|
|
39145
|
+
};
|
|
39146
|
+
let next = tally;
|
|
39147
|
+
for (const report of parsed.reports) {
|
|
39148
|
+
if (report.blocks.length === 0) continue;
|
|
39149
|
+
next = {
|
|
39150
|
+
...next,
|
|
39151
|
+
reportsParsed: next.reportsParsed + 1
|
|
39152
|
+
};
|
|
39153
|
+
for (const block of report.blocks) next = applyReceiverReportBlock(next, block);
|
|
39154
|
+
}
|
|
39155
|
+
return {
|
|
39156
|
+
tally: next,
|
|
39157
|
+
reports: parsed.reports,
|
|
39158
|
+
failure: null
|
|
39159
|
+
};
|
|
39160
|
+
}
|
|
39161
|
+
//#endregion
|
|
39162
|
+
//#region src/mappers/builders/stream-bitrate.ts
|
|
39163
|
+
/**
|
|
39164
|
+
* Fraction of the negotiated ceiling we actually aim the encoder at.
|
|
39165
|
+
*
|
|
39166
|
+
* `max_bit_rate` is what the controller budgeted for the stream; what crosses
|
|
39167
|
+
* the wire is the encoded payload PLUS its packetisation. At `mtu = 1378` each
|
|
39168
|
+
* packet carries a 12-byte RTP header and a 10-byte SRTP auth tag, and the
|
|
39169
|
+
* datagram adds 8 (UDP) + 20 (IPv4) — 50 bytes on ~1378, i.e. **3.6 %**. The
|
|
39170
|
+
* remaining ~6 % is margin for a VBV overshoot inside the buffer window.
|
|
39171
|
+
* Reserving it is the difference between "at the ceiling" and "over it".
|
|
39172
|
+
*/
|
|
39173
|
+
var BITRATE_HEADROOM = .9;
|
|
39174
|
+
/** Encoder slots a `stream-params` provider exposes, in the cap's own order. */
|
|
39175
|
+
var ENCODER_PROFILE_KEYS = [
|
|
39176
|
+
"main",
|
|
39177
|
+
"sub",
|
|
39178
|
+
"ext"
|
|
39179
|
+
];
|
|
39180
|
+
/**
|
|
39181
|
+
* Resolve every profile slot's rate from the camera's configuration, with the
|
|
39182
|
+
* broker's flow reading kept alongside it as a lower bound.
|
|
39183
|
+
*
|
|
39184
|
+
* A slot with neither simply carries two nulls — absence is never rendered as
|
|
39185
|
+
* a number, because a wrong number here silently re-creates the overshoot this
|
|
39186
|
+
* module exists to end.
|
|
39187
|
+
*/
|
|
39188
|
+
function resolveProfileBitrates(input) {
|
|
39189
|
+
const camStreamById = /* @__PURE__ */ new Map();
|
|
39190
|
+
for (const stream of input.camStreams) camStreamById.set(stream.camStreamId, stream);
|
|
39191
|
+
const measuredByProfile = /* @__PURE__ */ new Map();
|
|
39192
|
+
for (const choice of input.choices) {
|
|
39193
|
+
if (choice.target.kind !== "profile") continue;
|
|
39194
|
+
const plausible = plausibleMeasured(choice.bitrateKbps);
|
|
39195
|
+
if (plausible !== null) measuredByProfile.set(choice.target.profile, plausible);
|
|
39196
|
+
}
|
|
39197
|
+
const out = /* @__PURE__ */ new Map();
|
|
39198
|
+
for (const slot of input.slots) {
|
|
39199
|
+
const source = slot.sourceCamStreamId === null ? void 0 : camStreamById.get(slot.sourceCamStreamId);
|
|
39200
|
+
out.set(slot.profile, {
|
|
39201
|
+
profile: slot.profile,
|
|
39202
|
+
publishedKbps: publishedRateFor(slot, source, input.streamParams),
|
|
39203
|
+
measuredKbps: measuredByProfile.get(slot.profile) ?? null
|
|
39204
|
+
});
|
|
39205
|
+
}
|
|
39206
|
+
for (const [profile, measured] of measuredByProfile) {
|
|
39207
|
+
if (out.has(profile)) continue;
|
|
39208
|
+
out.set(profile, {
|
|
39209
|
+
profile,
|
|
39210
|
+
publishedKbps: null,
|
|
39211
|
+
measuredKbps: measured
|
|
39212
|
+
});
|
|
39213
|
+
}
|
|
39214
|
+
return out;
|
|
39215
|
+
}
|
|
39216
|
+
/**
|
|
39217
|
+
* The rate the encoder may actually use, or `null` when the controller
|
|
39218
|
+
* negotiated no usable ceiling.
|
|
39219
|
+
*/
|
|
39220
|
+
function budgetForNegotiatedRate(negotiatedMaxBitrateKbps) {
|
|
39221
|
+
if (!Number.isFinite(negotiatedMaxBitrateKbps) || negotiatedMaxBitrateKbps <= 0) return null;
|
|
39222
|
+
const budget = Math.floor(negotiatedMaxBitrateKbps * BITRATE_HEADROOM);
|
|
39223
|
+
return budget > 0 ? budget : null;
|
|
39224
|
+
}
|
|
39225
|
+
/**
|
|
39226
|
+
* Does this slot fit?
|
|
39227
|
+
*
|
|
39228
|
+
* Only the CONFIGURED rate can answer yes. The measured rate is a lower bound,
|
|
39229
|
+
* so it is allowed to answer no — including against an optimistic publication.
|
|
39230
|
+
*/
|
|
39231
|
+
function classifyBitrateFit(evidence, budgetKbps) {
|
|
39232
|
+
if (evidence === void 0) return "unknown";
|
|
39233
|
+
const { publishedKbps, measuredKbps } = evidence;
|
|
39234
|
+
if (measuredKbps !== null && measuredKbps > budgetKbps) return "over-budget";
|
|
39235
|
+
if (publishedKbps === null) return "unknown";
|
|
39236
|
+
return publishedKbps <= budgetKbps ? "fits" : "over-budget";
|
|
39237
|
+
}
|
|
39238
|
+
/**
|
|
39239
|
+
* Pick the stream to serve, and decide whether it can be passed through.
|
|
39240
|
+
*
|
|
39241
|
+
* The selection runs the SAME `pickPreferredRtspEntry` the advertisement is
|
|
39242
|
+
* derived from (D51) — only the candidate set narrows. When at least one
|
|
39243
|
+
* pass-through-capable slot fits the budget, the picker resolves the target
|
|
39244
|
+
* resolution among those and we copy. Otherwise the picker resolves among ALL
|
|
39245
|
+
* entries — so the transcode decodes the slot closest to the negotiated
|
|
39246
|
+
* resolution rather than the largest one on the camera — and we re-encode.
|
|
39247
|
+
*
|
|
39248
|
+
* A pinned `streamPreference` is never overridden by the budget: the pinned
|
|
39249
|
+
* slot is transcoded rather than swapped for a cheaper one.
|
|
39250
|
+
*
|
|
39251
|
+
* Returns `null` when nothing is publishable at all.
|
|
39252
|
+
*/
|
|
39253
|
+
function selectStreamForBudget(input) {
|
|
39254
|
+
const budgetKbps = budgetForNegotiatedRate(input.negotiatedMaxBitrateKbps);
|
|
39255
|
+
const notes = fitNotes(input.entries, input.bitrates, budgetKbps);
|
|
39256
|
+
const fallback = pickPreferredRtspEntry(input.entries, input.pref, input.deviceId, { targetResolution: input.targetResolution });
|
|
39257
|
+
if (fallback === null) return null;
|
|
39258
|
+
const fallbackProfile = toCamProfile$1(fallback.profileId);
|
|
39259
|
+
if (budgetKbps === null) {
|
|
39260
|
+
const base = {
|
|
39261
|
+
picked: fallback,
|
|
39262
|
+
profile: fallbackProfile,
|
|
39263
|
+
budgetKbps: null,
|
|
39264
|
+
notes
|
|
39265
|
+
};
|
|
39266
|
+
return canPassThrough(fallback.codec) ? {
|
|
39267
|
+
kind: "copy",
|
|
39268
|
+
reason: "no-negotiated-budget",
|
|
39269
|
+
...base
|
|
39270
|
+
} : {
|
|
39271
|
+
kind: "transcode",
|
|
39272
|
+
reason: "source-codec",
|
|
39273
|
+
...base
|
|
39274
|
+
};
|
|
39275
|
+
}
|
|
39276
|
+
const affordable = (input.pref !== "auto" && fallbackProfile === input.pref ? input.entries.filter((entry) => entry.profile === fallbackProfile) : input.entries).filter((entry) => {
|
|
39277
|
+
if (!canPassThrough(entry.codec)) return false;
|
|
39278
|
+
return classifyBitrateFit(input.bitrates.get(entry.profile), budgetKbps) === "fits";
|
|
39279
|
+
});
|
|
39280
|
+
if (affordable.length > 0) {
|
|
39281
|
+
const picked = pickPreferredRtspEntry(affordable, input.pref, input.deviceId, { targetResolution: input.targetResolution });
|
|
39282
|
+
if (picked !== null) return {
|
|
39283
|
+
kind: "copy",
|
|
39284
|
+
reason: "source-fits-budget",
|
|
39285
|
+
picked,
|
|
39286
|
+
profile: toCamProfile$1(picked.profileId),
|
|
39287
|
+
budgetKbps,
|
|
39288
|
+
notes
|
|
39289
|
+
};
|
|
39290
|
+
}
|
|
39291
|
+
if (canPassThrough(fallback.codec)) return {
|
|
39292
|
+
kind: "copy",
|
|
39293
|
+
reason: "over-budget-tolerated",
|
|
39294
|
+
picked: fallback,
|
|
39295
|
+
profile: fallbackProfile,
|
|
39296
|
+
budgetKbps,
|
|
39297
|
+
notes
|
|
39298
|
+
};
|
|
39299
|
+
return {
|
|
39300
|
+
kind: "transcode",
|
|
39301
|
+
reason: transcodeReason(fallback, fallbackProfile, input.bitrates, budgetKbps),
|
|
39302
|
+
picked: fallback,
|
|
39303
|
+
profile: fallbackProfile,
|
|
39304
|
+
budgetKbps,
|
|
39305
|
+
notes
|
|
39306
|
+
};
|
|
39307
|
+
}
|
|
39308
|
+
/**
|
|
39309
|
+
* Fill in a codec the profile restream entry did not carry, from its broker
|
|
39310
|
+
* slot. `getProfileRtspEntries` has historically omitted it for legacy
|
|
39311
|
+
* entries, and a slot whose codec is unknown must not be mistaken for H.264.
|
|
39312
|
+
*/
|
|
39313
|
+
function withSlotCodecs(entries, slots) {
|
|
39314
|
+
const codecByProfile = /* @__PURE__ */ new Map();
|
|
39315
|
+
for (const slot of slots) if (slot.codec !== void 0) codecByProfile.set(slot.profile, slot.codec);
|
|
39316
|
+
return entries.map((entry) => {
|
|
39317
|
+
if (entry.codec !== void 0) return entry;
|
|
39318
|
+
const codec = codecByProfile.get(entry.profile);
|
|
39319
|
+
return codec === void 0 ? entry : {
|
|
39320
|
+
...entry,
|
|
39321
|
+
codec
|
|
39322
|
+
};
|
|
39323
|
+
});
|
|
39324
|
+
}
|
|
39325
|
+
/**
|
|
39326
|
+
* The rate we will actually deliver: never more than the controller asked for,
|
|
39327
|
+
* and never more than the source produces. `-r` above the source rate makes
|
|
39328
|
+
* ffmpeg DUPLICATE frames, which spends the budget on nothing.
|
|
39329
|
+
*/
|
|
39330
|
+
function deliverableFps(negotiatedFps, slotFps) {
|
|
39331
|
+
if (slotFps === null || !Number.isFinite(slotFps) || slotFps <= 0) return negotiatedFps;
|
|
39332
|
+
return Math.min(negotiatedFps, Math.floor(slotFps));
|
|
39333
|
+
}
|
|
39334
|
+
/**
|
|
39335
|
+
* The ffmpeg video-output arguments.
|
|
39336
|
+
*
|
|
39337
|
+
* Pass-through carries `-bsf:v dump_extra` so every IDR inlines its own
|
|
39338
|
+
* SPS/PPS — sources that publish parameter sets only in the RTSP SDP hand iOS
|
|
39339
|
+
* a keyframe it cannot decode otherwise.
|
|
39340
|
+
*
|
|
39341
|
+
* The transcode's cap is three flags, not one: `-b:v` is an average and on its
|
|
39342
|
+
* own permits exactly the burst that was measured. `-maxrate` plus a
|
|
39343
|
+
* **one-second** `-bufsize` bounds any one-second window at the negotiated
|
|
39344
|
+
* rate, which is also the only lever available on the 3.03 s peak jitter — the
|
|
39345
|
+
* VBV window is what forces x264 to size a key frame to fit rather than
|
|
39346
|
+
* emitting it as one tight burst.
|
|
39347
|
+
*/
|
|
39348
|
+
/**
|
|
39349
|
+
* Seconds between forced IDRs on the transcode path.
|
|
39350
|
+
*
|
|
39351
|
+
* A join can only start decoding at a key frame, so this is the worst-case
|
|
39352
|
+
* wait a controller pays before the first picture — and the bound the RTSP
|
|
39353
|
+
* join-burst withhold falls back on when it declines to replay a wide GOP.
|
|
39354
|
+
*/
|
|
39355
|
+
var KEYFRAME_INTERVAL_SEC = 4;
|
|
39356
|
+
function buildVideoEncodeArgs(input) {
|
|
39357
|
+
if (!input.transcode) return [
|
|
39358
|
+
"-c:v",
|
|
39359
|
+
"copy",
|
|
39360
|
+
"-bsf:v",
|
|
39361
|
+
"dump_extra"
|
|
39362
|
+
];
|
|
39363
|
+
const rate = input.budgetKbps === null ? [] : [
|
|
39364
|
+
"-b:v",
|
|
39365
|
+
`${input.budgetKbps}k`,
|
|
39366
|
+
"-maxrate",
|
|
39367
|
+
`${input.budgetKbps}k`,
|
|
39368
|
+
"-bufsize",
|
|
39369
|
+
`${input.budgetKbps}k`
|
|
39370
|
+
];
|
|
39371
|
+
return [
|
|
39372
|
+
"-c:v",
|
|
39373
|
+
"libx264",
|
|
39374
|
+
"-preset",
|
|
39375
|
+
"ultrafast",
|
|
39376
|
+
"-tune",
|
|
39377
|
+
"zerolatency",
|
|
39378
|
+
"-pix_fmt",
|
|
39379
|
+
"yuv420p",
|
|
39380
|
+
"-r",
|
|
39381
|
+
String(input.fps),
|
|
39382
|
+
"-s",
|
|
39383
|
+
`${input.width}x${input.height}`,
|
|
39384
|
+
"-g",
|
|
39385
|
+
String(Math.max(1, Math.round(input.fps * KEYFRAME_INTERVAL_SEC))),
|
|
39386
|
+
...rate,
|
|
39387
|
+
"-profile:v",
|
|
39388
|
+
"baseline",
|
|
39389
|
+
"-level",
|
|
39390
|
+
"3.1",
|
|
39391
|
+
"-bsf:v",
|
|
39392
|
+
"dump_extra"
|
|
39393
|
+
];
|
|
39394
|
+
}
|
|
39395
|
+
/** Compact `mid=2048pub/9meas:over-budget` rendering for a single log field. */
|
|
39396
|
+
function formatFitNotes(notes) {
|
|
39397
|
+
return notes.map((n) => `${n.profile}=${n.publishedKbps ?? "?"}pub/${n.measuredKbps ?? "?"}meas:${n.verdict}`);
|
|
39398
|
+
}
|
|
39399
|
+
function fitNotes(entries, bitrates, budgetKbps) {
|
|
39400
|
+
return entries.map((entry) => {
|
|
39401
|
+
const evidence = bitrates.get(entry.profile);
|
|
39402
|
+
return {
|
|
39403
|
+
profile: entry.profile,
|
|
39404
|
+
verdict: budgetKbps === null ? "unknown" : classifyBitrateFit(evidence, budgetKbps),
|
|
39405
|
+
publishedKbps: evidence?.publishedKbps ?? null,
|
|
39406
|
+
measuredKbps: evidence?.measuredKbps ?? null
|
|
39407
|
+
};
|
|
39408
|
+
});
|
|
39409
|
+
}
|
|
39410
|
+
function transcodeReason(picked, profile, bitrates, budgetKbps) {
|
|
39411
|
+
if (!canPassThrough(picked.codec)) return "source-codec";
|
|
39412
|
+
if (profile === null) return "unknown-bitrate";
|
|
39413
|
+
return classifyBitrateFit(bitrates.get(profile), budgetKbps) === "over-budget" ? "over-budget" : "unknown-bitrate";
|
|
39414
|
+
}
|
|
39415
|
+
/**
|
|
39416
|
+
* iOS Home renders only H.264 over the classic HAP SRTP path, so an H.265
|
|
39417
|
+
* source can never be passed through. An UNKNOWN codec is treated the same
|
|
39418
|
+
* way: guessing H.264 is how a camera that changed its encoder ends up
|
|
39419
|
+
* shipping bytes no controller can decode.
|
|
39420
|
+
*/
|
|
39421
|
+
function canPassThrough(codec) {
|
|
39422
|
+
if (codec === void 0) return false;
|
|
39423
|
+
const lower = codec.toLowerCase();
|
|
39424
|
+
if (lower.includes("h265") || lower.includes("hevc")) return false;
|
|
39425
|
+
return lower.includes("h264") || lower.includes("avc");
|
|
39426
|
+
}
|
|
39427
|
+
function plausibleMeasured(value) {
|
|
39428
|
+
if (value === null || !Number.isFinite(value)) return null;
|
|
39429
|
+
return value >= 64 ? value : null;
|
|
39430
|
+
}
|
|
39431
|
+
/**
|
|
39432
|
+
* Map a profile slot onto the camera encoder feeding it, and read that
|
|
39433
|
+
* encoder's configured bitrate.
|
|
39434
|
+
*
|
|
39435
|
+
* The link is the slot's assigned cam-stream: its resolution (and frame rate,
|
|
39436
|
+
* when two encoders share a resolution) identifies which of `main`/`sub`/`ext`
|
|
39437
|
+
* produces it. Vendor-neutral on purpose — the cam-stream ids (`native:main`,
|
|
39438
|
+
* `native:slot-3`, …) are provider strings and matching on them would work for
|
|
39439
|
+
* exactly one provider. An ambiguous match returns `null`.
|
|
39440
|
+
*/
|
|
39441
|
+
function publishedRateFor(slot, source, streamParams) {
|
|
39442
|
+
if (streamParams === null) return null;
|
|
39443
|
+
const resolution = source?.resolution ?? slot.resolution;
|
|
39444
|
+
if (resolution === void 0) return null;
|
|
39445
|
+
const byResolution = ENCODER_PROFILE_KEYS.map((key) => encoderConfig(streamParams, key)).filter((cfg) => cfg !== null && cfg.width === resolution.width && cfg.height === resolution.height);
|
|
39446
|
+
if (byResolution.length === 1) return positiveOrNull(byResolution[0]?.bitrate);
|
|
39447
|
+
const fps = source?.fps;
|
|
39448
|
+
if (fps === void 0) return null;
|
|
39449
|
+
const byFps = byResolution.filter((cfg) => cfg !== null && Math.floor(cfg.framerate) === Math.floor(fps));
|
|
39450
|
+
return byFps.length === 1 ? positiveOrNull(byFps[0]?.bitrate) : null;
|
|
39451
|
+
}
|
|
39452
|
+
function encoderConfig(status, key) {
|
|
39453
|
+
return status[key] ?? null;
|
|
39454
|
+
}
|
|
39455
|
+
function positiveOrNull(value) {
|
|
39456
|
+
if (value === void 0 || !Number.isFinite(value) || value <= 0) return null;
|
|
39457
|
+
return value;
|
|
39458
|
+
}
|
|
39459
|
+
var CAM_PROFILES$1 = [
|
|
39460
|
+
"high",
|
|
39461
|
+
"mid",
|
|
39462
|
+
"low"
|
|
39463
|
+
];
|
|
39464
|
+
/**
|
|
39465
|
+
* `PickedStream.profileId` is the brokerId suffix, which for a profile-keyed
|
|
39466
|
+
* entry IS the profile name. Anything else addresses a raw cam-stream and must
|
|
39467
|
+
* not be coerced into a profile.
|
|
39468
|
+
*/
|
|
39469
|
+
function toCamProfile$1(profileId) {
|
|
39470
|
+
return CAM_PROFILES$1.find((p) => p === profileId) ?? null;
|
|
39471
|
+
}
|
|
39472
|
+
//#endregion
|
|
39473
|
+
//#region src/mappers/builders/stream-bitrate-probe.ts
|
|
39474
|
+
/**
|
|
39475
|
+
* Resolve every profile slot's rate. Never throws: an unresolvable slot ends
|
|
39476
|
+
* up `unknown`, which the selector treats as "cannot prove it fits" and
|
|
39477
|
+
* therefore transcodes — the safe direction on the wire.
|
|
39478
|
+
*/
|
|
39479
|
+
async function probeProfileBitrates(input) {
|
|
39480
|
+
const { proxy } = input.bctx;
|
|
39481
|
+
const camStreams = await probe$1(() => proxy.cameraStreams?.getCameraStreams({}), "cameraStreams.getCameraStreams", input.log);
|
|
39482
|
+
const streamParams = await probe$1(() => proxy.streamParams?.getStatus({}), "streamParams.getStatus", input.log);
|
|
39483
|
+
const choices = await probe$1(() => proxy.webrtcSession?.listStreams({}), "webrtcSession.listStreams", input.log);
|
|
39484
|
+
return resolveProfileBitrates({
|
|
39485
|
+
slots: input.slots,
|
|
39486
|
+
camStreams: camStreams ?? [],
|
|
39487
|
+
streamParams,
|
|
39488
|
+
choices: choices ?? []
|
|
39489
|
+
});
|
|
39490
|
+
}
|
|
39491
|
+
async function probe$1(call, label, log) {
|
|
39492
|
+
try {
|
|
39493
|
+
const pending = call();
|
|
39494
|
+
if (pending === void 0) {
|
|
39495
|
+
log.info("export-hap: bitrate probe skipped — cap not bound on this device", { meta: { call: label } });
|
|
39496
|
+
return null;
|
|
39497
|
+
}
|
|
39498
|
+
return await pending;
|
|
39499
|
+
} catch (err) {
|
|
39500
|
+
log.warn("export-hap: bitrate probe failed — the slot rate stays UNKNOWN", { meta: {
|
|
39501
|
+
call: label,
|
|
39502
|
+
error: err instanceof Error ? err.message : String(err)
|
|
39503
|
+
} });
|
|
39504
|
+
return null;
|
|
39505
|
+
}
|
|
39506
|
+
}
|
|
39507
|
+
/**
|
|
39508
|
+
* Two outputs from one input: video SRTP and audio SRTP, one process, one
|
|
39509
|
+
* lifetime, one kill signal. ffmpeg-internal stream selection (`-vn` / `-an`
|
|
39510
|
+
* plus per-output codec args) keeps both flowing through it.
|
|
39511
|
+
*/
|
|
39512
|
+
function buildSessionFfmpegArgs(input) {
|
|
39513
|
+
return [
|
|
39514
|
+
"-hide_banner",
|
|
39515
|
+
"-loglevel",
|
|
39516
|
+
"warning",
|
|
39517
|
+
...input.decodeArgs,
|
|
39518
|
+
"-rtsp_transport",
|
|
39519
|
+
"tcp",
|
|
39520
|
+
"-i",
|
|
39521
|
+
input.rtspUrl,
|
|
39522
|
+
"-an",
|
|
39523
|
+
"-map",
|
|
39524
|
+
"0:v:0",
|
|
39525
|
+
...input.videoArgs,
|
|
39526
|
+
"-payload_type",
|
|
39527
|
+
String(input.videoPayloadType),
|
|
39528
|
+
"-ssrc",
|
|
39529
|
+
String(input.videoSsrcSigned),
|
|
39530
|
+
"-f",
|
|
39531
|
+
"rtp",
|
|
39532
|
+
input.videoTarget,
|
|
39533
|
+
"-vn",
|
|
39534
|
+
"-map",
|
|
39535
|
+
"0:a:0?",
|
|
39536
|
+
"-af",
|
|
39537
|
+
"aresample=async=1000:first_pts=0",
|
|
39538
|
+
"-c:a",
|
|
39539
|
+
"libopus",
|
|
39540
|
+
"-application",
|
|
39541
|
+
"lowdelay",
|
|
39542
|
+
"-frame_duration",
|
|
39543
|
+
String(input.audioPacketTimeMs),
|
|
39544
|
+
"-flags",
|
|
39545
|
+
"+global_header",
|
|
39546
|
+
"-ar",
|
|
39547
|
+
String(input.audioSampleRateKhz * 1e3),
|
|
39548
|
+
"-b:a",
|
|
39549
|
+
`24k`,
|
|
39550
|
+
"-bufsize",
|
|
39551
|
+
`96k`,
|
|
39552
|
+
"-ac",
|
|
39553
|
+
String(1),
|
|
39554
|
+
"-payload_type",
|
|
39555
|
+
String(input.audioPayloadType),
|
|
39556
|
+
"-ssrc",
|
|
39557
|
+
String(input.audioSsrcSigned),
|
|
39558
|
+
"-f",
|
|
39559
|
+
"rtp",
|
|
39560
|
+
input.audioTarget
|
|
39561
|
+
];
|
|
39562
|
+
}
|
|
39563
|
+
/**
|
|
39564
|
+
* The resolutions we offer, before rates are attached. Same list the delegate
|
|
39565
|
+
* advertised before R2 — only the frame rate changes, so a controller that had
|
|
39566
|
+
* already negotiated a resolution keeps finding it.
|
|
39567
|
+
*/
|
|
39568
|
+
var CANDIDATE_RESOLUTIONS = [
|
|
39569
|
+
[1920, 1080],
|
|
39570
|
+
[1280, 720],
|
|
39571
|
+
[1024, 768],
|
|
39572
|
+
[640, 480],
|
|
39573
|
+
[640, 360],
|
|
39574
|
+
[480, 360],
|
|
39575
|
+
[480, 270],
|
|
39576
|
+
[320, 240],
|
|
39577
|
+
[320, 180]
|
|
39578
|
+
];
|
|
39579
|
+
/**
|
|
39580
|
+
* Resolve the real frame rate of each profile slot.
|
|
39581
|
+
*
|
|
39582
|
+
* A slot with no measurement, no publication and no assignment simply does not
|
|
39583
|
+
* appear in the map — the caller then advertises {@link ASSUMED_FPS} and says
|
|
39584
|
+
* so. Absence is never silently rendered as a number.
|
|
39585
|
+
*/
|
|
39586
|
+
function resolveProfileFps(input) {
|
|
39587
|
+
const publishedByProfile = publishedFpsByProfile(input.slots, input.camStreams);
|
|
39588
|
+
const out = /* @__PURE__ */ new Map();
|
|
39589
|
+
for (const choice of input.choices) {
|
|
39590
|
+
if (choice.target.kind !== "profile") continue;
|
|
39591
|
+
const profile = choice.target.profile;
|
|
39592
|
+
const measured = clampFps(choice.inputFps);
|
|
39593
|
+
if (measured !== null) {
|
|
39594
|
+
out.set(profile, {
|
|
39595
|
+
profile,
|
|
39596
|
+
fps: measured,
|
|
39597
|
+
source: "measured"
|
|
39598
|
+
});
|
|
39599
|
+
continue;
|
|
39600
|
+
}
|
|
39601
|
+
const published = clampFps(publishedByProfile.get(profile) ?? null);
|
|
39602
|
+
out.set(profile, published !== null ? {
|
|
39603
|
+
profile,
|
|
39604
|
+
fps: published,
|
|
39605
|
+
source: "published"
|
|
39606
|
+
} : {
|
|
39607
|
+
profile,
|
|
39608
|
+
fps: 30,
|
|
39609
|
+
source: "assumed"
|
|
39610
|
+
});
|
|
39611
|
+
}
|
|
39612
|
+
for (const [profile, fps] of publishedByProfile) {
|
|
39613
|
+
if (out.has(profile)) continue;
|
|
39614
|
+
const published = clampFps(fps);
|
|
39615
|
+
if (published !== null) out.set(profile, {
|
|
39616
|
+
profile,
|
|
39617
|
+
fps: published,
|
|
39618
|
+
source: "published"
|
|
39619
|
+
});
|
|
39620
|
+
}
|
|
39621
|
+
return out;
|
|
39622
|
+
}
|
|
39623
|
+
/**
|
|
39624
|
+
* Attach a frame rate to each candidate resolution by asking the REAL picker
|
|
39625
|
+
* which slot the START path would dial for it. That coupling is the point: if
|
|
39626
|
+
* the picker's steering changes, the advertisement changes with it instead of
|
|
39627
|
+
* drifting into a second, silently different opinion.
|
|
39628
|
+
*/
|
|
39629
|
+
function deriveAdvertisedResolutions(input) {
|
|
39630
|
+
const candidates = input.candidates.length > 0 ? input.candidates : CANDIDATE_RESOLUTIONS;
|
|
39631
|
+
const seen = /* @__PURE__ */ new Set();
|
|
39632
|
+
const out = [];
|
|
39633
|
+
for (const [width, height] of candidates) {
|
|
39634
|
+
const picked = pickPreferredRtspEntry(input.entries, input.pref, input.deviceId, { targetResolution: {
|
|
39635
|
+
width,
|
|
39636
|
+
height
|
|
39637
|
+
} });
|
|
39638
|
+
const profile = picked === null ? null : toCamProfile(picked.profileId);
|
|
39639
|
+
const resolved = profile === null ? void 0 : input.fpsByProfile.get(profile);
|
|
39640
|
+
const advertised = {
|
|
39641
|
+
width,
|
|
39642
|
+
height,
|
|
39643
|
+
fps: resolved?.fps ?? 30,
|
|
39644
|
+
profile,
|
|
39645
|
+
source: resolved?.source ?? "assumed"
|
|
39646
|
+
};
|
|
39647
|
+
const key = `${advertised.width}x${advertised.height}@${advertised.fps}`;
|
|
39648
|
+
if (seen.has(key)) continue;
|
|
39649
|
+
seen.add(key);
|
|
39650
|
+
out.push(advertised);
|
|
39651
|
+
}
|
|
39652
|
+
return out;
|
|
39653
|
+
}
|
|
39654
|
+
/** Project onto the `[width, height, fps]` triples hap-nodejs expects. */
|
|
39655
|
+
function toHapResolutions(advertised) {
|
|
39656
|
+
return advertised.map((a) => [
|
|
39657
|
+
a.width,
|
|
39658
|
+
a.height,
|
|
39659
|
+
a.fps
|
|
39660
|
+
]);
|
|
39661
|
+
}
|
|
39662
|
+
/** Compact `1280x720@10(measured)` rendering for a single log field. */
|
|
39663
|
+
function formatAdvertisedResolutions(advertised) {
|
|
39664
|
+
return advertised.map((a) => `${a.width}x${a.height}@${a.fps}/${a.profile ?? "-"}(${a.source})`);
|
|
39665
|
+
}
|
|
39666
|
+
function publishedFpsByProfile(slots, camStreams) {
|
|
39667
|
+
const fpsByCamStream = /* @__PURE__ */ new Map();
|
|
39668
|
+
for (const stream of camStreams) if (typeof stream.fps === "number") fpsByCamStream.set(stream.camStreamId, stream.fps);
|
|
39669
|
+
const out = /* @__PURE__ */ new Map();
|
|
39670
|
+
for (const slot of slots) {
|
|
39671
|
+
if (slot.sourceCamStreamId === null) continue;
|
|
39672
|
+
const fps = fpsByCamStream.get(slot.sourceCamStreamId);
|
|
39673
|
+
if (fps !== void 0) out.set(slot.profile, fps);
|
|
39674
|
+
}
|
|
39675
|
+
return out;
|
|
39676
|
+
}
|
|
39677
|
+
/**
|
|
39678
|
+
* Coerce a probe reading into an advertisable integer rate, or null when the
|
|
39679
|
+
* reading carries no information (absent, zero because the broker is idle,
|
|
39680
|
+
* negative, NaN, or below the representable floor).
|
|
39681
|
+
*/
|
|
39682
|
+
function clampFps(value) {
|
|
39683
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return null;
|
|
39684
|
+
const floored = Math.floor(value);
|
|
39685
|
+
if (floored < 1) return null;
|
|
39686
|
+
return Math.min(floored, 30);
|
|
39687
|
+
}
|
|
39688
|
+
var CAM_PROFILES = [
|
|
39689
|
+
"high",
|
|
39690
|
+
"mid",
|
|
39691
|
+
"low"
|
|
39692
|
+
];
|
|
39693
|
+
/**
|
|
39694
|
+
* `PickedStream.profileId` is the brokerId suffix, which for a profile-keyed
|
|
39695
|
+
* entry IS the profile name. Anything else (a raw cam-stream id) is not a
|
|
39696
|
+
* profile and must not be coerced into one.
|
|
39697
|
+
*/
|
|
39698
|
+
function toCamProfile(profileId) {
|
|
39699
|
+
return CAM_PROFILES.find((p) => p === profileId) ?? null;
|
|
39700
|
+
}
|
|
39701
|
+
/**
|
|
39702
|
+
* How long after spawn an exit still counts as "hardware init failed".
|
|
39703
|
+
*
|
|
39704
|
+
* Same window the ffmpeg decoder addon uses for its own cascade. A hardware
|
|
39705
|
+
* context that cannot be created fails within milliseconds; anything that ran
|
|
39706
|
+
* longer produced no frames for a different reason, and re-spawning it in
|
|
39707
|
+
* software would just hide that reason.
|
|
39708
|
+
*/
|
|
39709
|
+
var HW_DECODE_FALLBACK_WINDOW_MS = 4e3;
|
|
39710
|
+
/** Backends whose decode binds to a DRM render node. */
|
|
39711
|
+
var RENDER_NODE_BACKENDS = ["vaapi", "qsv"];
|
|
39712
|
+
/**
|
|
39713
|
+
* Values of the decoder cap's `hwaccel` field that are operator CHOICES rather
|
|
39714
|
+
* than devices.
|
|
39715
|
+
*/
|
|
39716
|
+
var HWACCEL_NON_BACKEND_CHOICES = ["auto", "none"];
|
|
39717
|
+
/**
|
|
39718
|
+
* Every backend the decoder cap can publish, taken from the cap's own UI option
|
|
39719
|
+
* list so this module cannot drift from it. A hand-written copy of the union is
|
|
39720
|
+
* exactly the "hand-written cap interface that rots silently" this repo has
|
|
39721
|
+
* been bitten by.
|
|
39722
|
+
*/
|
|
39723
|
+
var HWACCEL_BACKENDS = new Set(HWACCEL_OPTIONS.map((option) => option.value).filter((value) => !HWACCEL_NON_BACKEND_CHOICES.includes(value)));
|
|
39724
|
+
/** Is this string a hardware backend, as opposed to `auto`, `none` or junk? */
|
|
39725
|
+
function isHwAccelBackend(value) {
|
|
39726
|
+
return HWACCEL_BACKENDS.has(value);
|
|
39727
|
+
}
|
|
39728
|
+
/**
|
|
39729
|
+
* Decide how this session decodes, and produce the ffmpeg input-side flags.
|
|
39730
|
+
*
|
|
39731
|
+
* Every path that ends in software carries a named reason, because a session
|
|
39732
|
+
* that quietly stopped using the GPU and a session that never had one look
|
|
39733
|
+
* identical in a CPU graph.
|
|
39734
|
+
*/
|
|
39735
|
+
function selectHwDecode(input) {
|
|
39736
|
+
if (!input.transcode) return software("pass-through");
|
|
39737
|
+
if (input.hardwareAlreadyFailed === true) return software("hardware-attempt-failed");
|
|
39738
|
+
if (input.reading === null) return software("no-decoder-reading");
|
|
39739
|
+
const chosen = (input.reading.hwaccel ?? "").trim();
|
|
39740
|
+
if (chosen === "none") return software("operator-disabled");
|
|
39741
|
+
if (chosen !== "" && chosen !== "auto") return isHwAccelBackend(chosen) ? hardware(chosen, "operator", input) : software("unrecognised-backend");
|
|
39742
|
+
const probed = (input.reading.probedBestHwaccel ?? "").trim();
|
|
39743
|
+
if (probed === "" || probed === "none") return software("not-probed");
|
|
39744
|
+
return isHwAccelBackend(probed) ? hardware(probed, "probed", input) : software("unrecognised-backend");
|
|
39745
|
+
}
|
|
39746
|
+
/**
|
|
39747
|
+
* Did this ffmpeg exit look like a failed hardware init, rather than a
|
|
39748
|
+
* teardown or a fault software would hit too?
|
|
39749
|
+
*
|
|
39750
|
+
* All five conditions are necessary. Dropping the controller-stop check in
|
|
39751
|
+
* particular would respawn on every normal teardown, because iOS restarts a
|
|
39752
|
+
* session it is not enjoying and that is indistinguishable at the exit code.
|
|
39753
|
+
*/
|
|
39754
|
+
function shouldRetryInSoftware(input) {
|
|
39755
|
+
if (!input.usedHardware) return false;
|
|
39756
|
+
if (input.hardwareAlreadyFailed) return false;
|
|
39757
|
+
if (input.stopRequestedByController) return false;
|
|
39758
|
+
if (input.videoPacketsForwarded > 0) return false;
|
|
39759
|
+
return input.runtimeMs <= HW_DECODE_FALLBACK_WINDOW_MS;
|
|
39760
|
+
}
|
|
39761
|
+
function software(reason) {
|
|
39762
|
+
return {
|
|
39763
|
+
kind: "software",
|
|
39764
|
+
reason,
|
|
39765
|
+
args: []
|
|
39766
|
+
};
|
|
39767
|
+
}
|
|
39768
|
+
function hardware(backend, source, input) {
|
|
39769
|
+
return {
|
|
39770
|
+
kind: "hardware",
|
|
39771
|
+
backend,
|
|
39772
|
+
source,
|
|
39773
|
+
args: decodeArgs(backend, input)
|
|
39774
|
+
};
|
|
39775
|
+
}
|
|
39776
|
+
/**
|
|
39777
|
+
* The input-side flags, and nothing else.
|
|
39778
|
+
*
|
|
39779
|
+
* No `-hwaccel_output_format`: the decoded frames have to land in system
|
|
39780
|
+
* memory for libx264 to scale and encode them. Setting it would keep them on
|
|
39781
|
+
* the GPU, which only pays off with a GPU scale filter — and that is the
|
|
39782
|
+
* decoder addon's job, not a two-output SRTP session's.
|
|
39783
|
+
*/
|
|
39784
|
+
function decodeArgs(backend, input) {
|
|
39785
|
+
if (backend === "videotoolbox" && input.platform === "darwin") return ["-hwaccel", "auto"];
|
|
39786
|
+
const args = ["-hwaccel", backend];
|
|
39787
|
+
if (RENDER_NODE_BACKENDS.includes(backend)) args.push("-hwaccel_device", input.renderDevice ?? "/dev/dri/renderD128");
|
|
39788
|
+
return args;
|
|
39789
|
+
}
|
|
39790
|
+
//#endregion
|
|
39791
|
+
//#region src/mappers/builders/stream-hwaccel-probe.ts
|
|
39792
|
+
/**
|
|
39793
|
+
* Read this node's decode-hwaccel state, or `null` when nothing answered.
|
|
39794
|
+
*
|
|
39795
|
+
* Never throws. `null` means "we do not know", which
|
|
39796
|
+
* {@link import('./stream-hwaccel.js').selectHwDecode} turns into software —
|
|
39797
|
+
* the safe direction, because a guess here costs the whole stream.
|
|
39798
|
+
*/
|
|
39799
|
+
async function probeDecoderHwaccel(input) {
|
|
39800
|
+
const { ctx, log } = input;
|
|
39801
|
+
const nodeId = ctx.kernel?.localNodeId;
|
|
39802
|
+
if (nodeId === void 0 || nodeId.length === 0) {
|
|
39803
|
+
log.warn("export-hap: hwaccel probe skipped — no local node id, decoding in SOFTWARE");
|
|
39804
|
+
return null;
|
|
39805
|
+
}
|
|
39806
|
+
try {
|
|
39807
|
+
const info = await ctx.api.decoder.getInfo.query(void 0, nodePin(nodeId));
|
|
39808
|
+
if (info === null || info === void 0) {
|
|
39809
|
+
log.info("export-hap: hwaccel probe returned nothing — decoding in SOFTWARE", { meta: { nodeId } });
|
|
39810
|
+
return null;
|
|
39811
|
+
}
|
|
39812
|
+
return {
|
|
39813
|
+
hwaccel: info.hwaccel ?? null,
|
|
39814
|
+
probedBestHwaccel: info.probedBestHwaccel ?? null
|
|
39815
|
+
};
|
|
39816
|
+
} catch (err) {
|
|
39817
|
+
log.warn("export-hap: hwaccel probe failed — decoding in SOFTWARE", { meta: {
|
|
39818
|
+
nodeId,
|
|
39819
|
+
error: err instanceof Error ? err.message : String(err)
|
|
39820
|
+
} });
|
|
39821
|
+
return null;
|
|
39822
|
+
}
|
|
39823
|
+
}
|
|
39824
|
+
//#endregion
|
|
39825
|
+
//#region src/mappers/builders/stream-telemetry.ts
|
|
39826
|
+
/**
|
|
39827
|
+
* Every branch on the streaming path that discards work, and its starting
|
|
39828
|
+
* count. This object is the single source of the reason set: the union below
|
|
39829
|
+
* is its `keyof`, so adding a silent `return` without adding a reason here is
|
|
39830
|
+
* a compile error rather than an invisible hole.
|
|
39831
|
+
*/
|
|
39832
|
+
var ZERO_DROP_COUNTERS = {
|
|
39833
|
+
/** Outbound: the leg has no SRTP session (prepareStream init failed). */
|
|
39834
|
+
"no-srtp-session": 0,
|
|
39835
|
+
/** Outbound: the leg has no return-path gate — forwarding was never armed. */
|
|
39836
|
+
"no-gate": 0,
|
|
39837
|
+
/** Outbound: werift refused to encrypt the packet. */
|
|
39838
|
+
"srtp-encrypt-failed": 0,
|
|
39839
|
+
/** Outbound: the UDP send itself errored. */
|
|
39840
|
+
"srtp-send-failed": 0,
|
|
39841
|
+
/** Outbound RTCP: the leg has no SRTCP context, so no Sender Report can go out. */
|
|
39842
|
+
"rtcp-no-srtcp": 0,
|
|
39843
|
+
/** Outbound RTCP: building or encrypting the Sender Report failed. */
|
|
39844
|
+
"rtcp-encrypt-failed": 0,
|
|
39845
|
+
/** Inbound RTCP: the leg has no SRTCP context, so the controller's report is unreadable. */
|
|
39846
|
+
"inbound-rtcp-no-srtcp": 0,
|
|
39847
|
+
/** Inbound RTCP: SRTCP decryption of a controller packet failed. */
|
|
39848
|
+
"inbound-rtcp-decrypt-failed": 0,
|
|
39849
|
+
/** Inbound RTCP: the decrypted bytes did not parse as RTCP. */
|
|
39850
|
+
"inbound-rtcp-parse-failed": 0,
|
|
39851
|
+
/** Upstream: packet shorter than an RTP header. */
|
|
39852
|
+
"upstream-short-packet": 0,
|
|
39853
|
+
/** Upstream: no inbound SRTP context (init failed at prepareStream). */
|
|
39854
|
+
"upstream-no-srtp": 0,
|
|
39855
|
+
/** Upstream: SRTP authentication/decrypt failed. */
|
|
39856
|
+
"upstream-decrypt-failed": 0,
|
|
39857
|
+
/** Upstream: decrypted bytes did not parse as RTP. */
|
|
39858
|
+
"upstream-parse-failed": 0,
|
|
39859
|
+
/** Upstream: audio arrived before `handleStreamRequest('start')` settled. */
|
|
39860
|
+
"upstream-before-start": 0,
|
|
39861
|
+
/** Upstream: payload type is not the negotiated audio PT (RTCP / keepalive). */
|
|
39862
|
+
"upstream-pt-mismatch": 0,
|
|
39863
|
+
/** Upstream: RTP carried a zero-length payload. */
|
|
39864
|
+
"upstream-empty-payload": 0,
|
|
39865
|
+
/** Upstream: no camera-side talk session, so the frame has nowhere to go. */
|
|
39866
|
+
"upstream-no-talk-session": 0,
|
|
39867
|
+
/** Upstream: the camera-side `pushTalkAudio` rejected the frame. */
|
|
39868
|
+
"upstream-push-failed": 0,
|
|
39869
|
+
/** Snapshot: the `snapshot` cap returned nothing. */
|
|
39870
|
+
"snapshot-unavailable": 0
|
|
39871
|
+
};
|
|
39872
|
+
function emptyDropCounters() {
|
|
39873
|
+
return { ...ZERO_DROP_COUNTERS };
|
|
39874
|
+
}
|
|
39875
|
+
/** Immutable increment — returns a new record, never touches the input. */
|
|
39876
|
+
function recordDrop(counters, reason) {
|
|
39877
|
+
return {
|
|
39878
|
+
...counters,
|
|
39879
|
+
[reason]: counters[reason] + 1
|
|
39880
|
+
};
|
|
39881
|
+
}
|
|
39882
|
+
/** Only the reasons that actually fired, so a clean session logs `{}`. */
|
|
39883
|
+
function nonZeroDrops(counters) {
|
|
39884
|
+
const out = {};
|
|
39885
|
+
for (const [reason, count] of Object.entries(counters)) if (count > 0) out[reason] = count;
|
|
39886
|
+
return out;
|
|
39887
|
+
}
|
|
39888
|
+
/**
|
|
39889
|
+
* Classify a packet received on one of our advertised return ports.
|
|
39890
|
+
*
|
|
39891
|
+
* RTP and RTCP arrive on the same symmetric port here. RTCP packet types are
|
|
39892
|
+
* 200..207, which sit inside the 192..223 band RFC 5761 §4 reserves precisely
|
|
39893
|
+
* so the two can be told apart on a shared socket (an RTP packet's marker bit
|
|
39894
|
+
* plus payload type can never land there for any payload type we negotiate).
|
|
39895
|
+
* The payloads are SRTP/SRTCP-encrypted, but both keep the first two bytes in
|
|
39896
|
+
* the clear.
|
|
39897
|
+
*/
|
|
39898
|
+
function classifyInboundPacket(packet) {
|
|
39899
|
+
if (packet.length < 2) return "malformed";
|
|
39900
|
+
if ((packet[0] >> 6 & 3) !== 2) return "malformed";
|
|
39901
|
+
const typeByte = packet[1];
|
|
39902
|
+
if (typeByte >= 192 && typeByte <= 223) return "rtcp";
|
|
39903
|
+
return "rtp";
|
|
39904
|
+
}
|
|
39905
|
+
/**
|
|
39906
|
+
* Loss at or above this is not a start-up transient.
|
|
39907
|
+
*
|
|
39908
|
+
* A stream whose first key frame is slow reliably produces one report in the
|
|
39909
|
+
* low single digits; a transmit-side defect produces tens of percent, because
|
|
39910
|
+
* whatever makes a packet unusable makes most packets unusable. The threshold
|
|
39911
|
+
* sits between those two regimes rather than at any measured boundary — it is
|
|
39912
|
+
* a reading aid, and the raw `worstFractionLostPct` is always in the line
|
|
39913
|
+
* beside it.
|
|
39914
|
+
*/
|
|
39915
|
+
var TRANSMIT_SUSPECT_FRACTION_LOST_PCT = 5;
|
|
39916
|
+
function lossVerdict(tally) {
|
|
39917
|
+
if (tally.blocksParsed === 0 || tally.worstFractionLostPct === null) return "no-reports";
|
|
39918
|
+
return tally.worstFractionLostPct >= TRANSMIT_SUSPECT_FRACTION_LOST_PCT ? "transmit-suspect" : "decode-suspect";
|
|
39919
|
+
}
|
|
39920
|
+
/**
|
|
39921
|
+
* Build the meta for `export-hap: stream session summary` — the single line a
|
|
39922
|
+
* future session greps to answer "why did this session die".
|
|
39923
|
+
*/
|
|
39924
|
+
function summariseSession(snapshot) {
|
|
39925
|
+
const durationMs = snapshot.startedAtMs !== null && snapshot.endedAtMs !== null ? snapshot.endedAtMs - snapshot.startedAtMs : null;
|
|
39926
|
+
const negotiated = snapshot.negotiated;
|
|
39927
|
+
const slot = snapshot.selectedSlot;
|
|
39928
|
+
return {
|
|
39929
|
+
sessionId: snapshot.sessionId,
|
|
39930
|
+
durationMs,
|
|
39931
|
+
stopRequestedByController: snapshot.stopRequestedByController,
|
|
39932
|
+
ffmpegExitCode: snapshot.ffmpegExit?.code ?? null,
|
|
39933
|
+
ffmpegExitSignal: snapshot.ffmpegExit?.signal ?? null,
|
|
39934
|
+
negotiatedResolution: negotiated ? `${negotiated.width}x${negotiated.height}` : null,
|
|
39935
|
+
negotiatedFps: negotiated?.fps ?? null,
|
|
39936
|
+
negotiatedMaxBitrateKbps: negotiated?.maxBitrateKbps ?? null,
|
|
39937
|
+
selectedProfile: slot?.profile ?? null,
|
|
39938
|
+
selectedBrokerId: slot?.brokerId ?? null,
|
|
39939
|
+
advertisedFps: slot?.advertisedFps ?? null,
|
|
39940
|
+
advertisedFpsSource: slot?.advertisedFpsSource ?? null,
|
|
39941
|
+
deliveredFps: slot?.deliveredFps ?? null,
|
|
39942
|
+
transcode: slot?.transcode ?? null,
|
|
39943
|
+
fitReason: slot?.fitReason ?? null,
|
|
39944
|
+
slotPublishedKbps: slot?.publishedKbps ?? null,
|
|
39945
|
+
slotMeasuredKbps: slot?.measuredKbps ?? null,
|
|
39946
|
+
encodeBudgetKbps: slot?.budgetKbps ?? null,
|
|
39947
|
+
fitNotes: slot?.fitNotes ?? [],
|
|
39948
|
+
videoPacketsForwarded: snapshot.videoPacketsForwarded,
|
|
39949
|
+
audioPacketsForwarded: snapshot.audioPacketsForwarded,
|
|
39950
|
+
videoRtcpSrSent: snapshot.videoRtcpSrSent,
|
|
39951
|
+
audioRtcpSrSent: snapshot.audioRtcpSrSent,
|
|
39952
|
+
videoRtcpReceived: snapshot.videoRtcpReceived,
|
|
39953
|
+
audioRtcpReceived: snapshot.audioRtcpReceived,
|
|
39954
|
+
videoRtpReceived: snapshot.videoRtpReceived,
|
|
39955
|
+
audioRtpReceived: snapshot.audioRtpReceived,
|
|
39956
|
+
videoGate: formatRtcpGate(snapshot.videoGate),
|
|
39957
|
+
audioGate: formatRtcpGate(snapshot.audioGate),
|
|
39958
|
+
videoReceiverReports: snapshot.videoReceiverReports,
|
|
39959
|
+
audioReceiverReports: snapshot.audioReceiverReports,
|
|
39960
|
+
videoLossVerdict: lossVerdict(snapshot.videoReceiverReports),
|
|
39961
|
+
audioLossVerdict: lossVerdict(snapshot.audioReceiverReports),
|
|
39962
|
+
mediaStarved: snapshot.videoPacketsForwarded === 0,
|
|
39963
|
+
drops: nonZeroDrops(snapshot.drops)
|
|
39964
|
+
};
|
|
39965
|
+
}
|
|
39966
|
+
//#endregion
|
|
39967
|
+
//#region src/mappers/builders/camera-streams.ts
|
|
38155
39968
|
var SRTP_KEY_LEN = 16;
|
|
38156
39969
|
var SRTP_SALT_LEN = 14;
|
|
38157
|
-
|
|
38158
|
-
|
|
38159
|
-
|
|
39970
|
+
/**
|
|
39971
|
+
* Cadence of the per-session heartbeat log.
|
|
39972
|
+
*
|
|
39973
|
+
* This timer LOGS ONLY. It never kills ffmpeg, never closes a socket and never
|
|
39974
|
+
* ends a session. That distinction matters: the two mainstream open-source
|
|
39975
|
+
* HomeKit camera stacks arm an idle watchdog that tears the stream down at
|
|
39976
|
+
* exactly 30 000 ms, and the ~31 s teardown here was misdiagnosed as one for a
|
|
39977
|
+
* long time before it was proven that this accessory has no such timer. Do not
|
|
39978
|
+
* make this one act.
|
|
39979
|
+
*/
|
|
39980
|
+
var SESSION_HEARTBEAT_MS = 5e3;
|
|
39981
|
+
/**
|
|
39982
|
+
* Floor between two `export-hap: controller receiver report` lines on one leg.
|
|
39983
|
+
*
|
|
39984
|
+
* The controller sends several Receiver Reports a second — 21 and 34 in two
|
|
39985
|
+
* ~10 s sessions on 2026-08-06 — and every one of them at `info` would drown
|
|
39986
|
+
* the very line it is meant to make findable. The FIRST report on each leg is
|
|
39987
|
+
* always logged, in full and under its own message; after that this throttles
|
|
39988
|
+
* to the heartbeat's cadence so a session still produces a running record of
|
|
39989
|
+
* what the controller thinks without becoming one.
|
|
39990
|
+
*/
|
|
39991
|
+
var RECEIVER_REPORT_LOG_INTERVAL_MS = 5e3;
|
|
39992
|
+
function buildCameraStreamingDelegate(bctx, advertised) {
|
|
38160
39993
|
const { ctx, numericDeviceId } = bctx;
|
|
38161
39994
|
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
38162
39995
|
const sessions = /* @__PURE__ */ new Map();
|
|
@@ -38175,7 +40008,7 @@ function buildCameraStreamingDelegate(bctx) {
|
|
|
38175
40008
|
});
|
|
38176
40009
|
},
|
|
38177
40010
|
handleStreamRequest(request, callback) {
|
|
38178
|
-
handleStreamRequest(request, sessions, bctx).then(() => callback()).catch((err) => {
|
|
40011
|
+
handleStreamRequest(request, sessions, bctx, advertised).then(() => callback()).catch((err) => {
|
|
38179
40012
|
log.warn("export-hap: handleStreamRequest failed", { meta: { error: errMsg$8(err) } });
|
|
38180
40013
|
callback(err instanceof Error ? err : new Error(errMsg$8(err)));
|
|
38181
40014
|
});
|
|
@@ -38196,58 +40029,7 @@ function buildCameraStreamingDelegate(bctx) {
|
|
|
38196
40029
|
H264Level.LEVEL4_0
|
|
38197
40030
|
]
|
|
38198
40031
|
},
|
|
38199
|
-
resolutions:
|
|
38200
|
-
[
|
|
38201
|
-
1920,
|
|
38202
|
-
1080,
|
|
38203
|
-
30
|
|
38204
|
-
],
|
|
38205
|
-
[
|
|
38206
|
-
1280,
|
|
38207
|
-
720,
|
|
38208
|
-
30
|
|
38209
|
-
],
|
|
38210
|
-
[
|
|
38211
|
-
1024,
|
|
38212
|
-
768,
|
|
38213
|
-
30
|
|
38214
|
-
],
|
|
38215
|
-
[
|
|
38216
|
-
640,
|
|
38217
|
-
480,
|
|
38218
|
-
30
|
|
38219
|
-
],
|
|
38220
|
-
[
|
|
38221
|
-
640,
|
|
38222
|
-
360,
|
|
38223
|
-
30
|
|
38224
|
-
],
|
|
38225
|
-
[
|
|
38226
|
-
480,
|
|
38227
|
-
360,
|
|
38228
|
-
30
|
|
38229
|
-
],
|
|
38230
|
-
[
|
|
38231
|
-
480,
|
|
38232
|
-
270,
|
|
38233
|
-
30
|
|
38234
|
-
],
|
|
38235
|
-
[
|
|
38236
|
-
320,
|
|
38237
|
-
240,
|
|
38238
|
-
30
|
|
38239
|
-
],
|
|
38240
|
-
[
|
|
38241
|
-
320,
|
|
38242
|
-
240,
|
|
38243
|
-
15
|
|
38244
|
-
],
|
|
38245
|
-
[
|
|
38246
|
-
320,
|
|
38247
|
-
180,
|
|
38248
|
-
30
|
|
38249
|
-
]
|
|
38250
|
-
]
|
|
40032
|
+
resolutions: toHapResolutions(advertised.resolutions)
|
|
38251
40033
|
},
|
|
38252
40034
|
audio: {
|
|
38253
40035
|
codecs: [{
|
|
@@ -38262,10 +40044,16 @@ function buildCameraStreamingDelegate(bctx) {
|
|
|
38262
40044
|
},
|
|
38263
40045
|
dispose: async () => {
|
|
38264
40046
|
for (const session of sessions.values()) {
|
|
40047
|
+
session.teardownTrigger = "accessory-dispose";
|
|
40048
|
+
const hadFfmpeg = session.ffmpeg !== null;
|
|
38265
40049
|
killFfmpeg(session, ctx, numericDeviceId);
|
|
40050
|
+
stopHeartbeat(session);
|
|
40051
|
+
if (!hadFfmpeg) logSessionSummary(session, log, "accessory-dispose-no-ffmpeg");
|
|
38266
40052
|
await closeIntercomTalkSession(session, bctx).catch(() => void 0);
|
|
38267
40053
|
closeSocket(session.videoUdp);
|
|
38268
40054
|
closeSocket(session.audioUdp);
|
|
40055
|
+
closeSocket(session.videoLoopUdp);
|
|
40056
|
+
closeSocket(session.audioLoopUdp);
|
|
38269
40057
|
}
|
|
38270
40058
|
sessions.clear();
|
|
38271
40059
|
}
|
|
@@ -38275,7 +40063,10 @@ async function handleSnapshot(bctx, request) {
|
|
|
38275
40063
|
const { proxy, ctx, numericDeviceId } = bctx;
|
|
38276
40064
|
const result = await proxy.snapshot?.getSnapshot({});
|
|
38277
40065
|
if (!result || typeof result.base64 !== "string") {
|
|
38278
|
-
ctx.logger.withTags({ deviceId: numericDeviceId }).
|
|
40066
|
+
ctx.logger.withTags({ deviceId: numericDeviceId }).info("export-hap: snapshot dropped", { meta: {
|
|
40067
|
+
reason: "snapshot-unavailable",
|
|
40068
|
+
capBound: proxy.snapshot !== void 0
|
|
40069
|
+
} });
|
|
38279
40070
|
throw new Error("snapshot unavailable");
|
|
38280
40071
|
}
|
|
38281
40072
|
return Buffer.from(result.base64, "base64");
|
|
@@ -38305,7 +40096,7 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
38305
40096
|
},
|
|
38306
40097
|
profile: import_src.ProtectionProfileAes128CmHmacSha1_80
|
|
38307
40098
|
});
|
|
38308
|
-
const
|
|
40099
|
+
const makeSrtcp = (key, salt) => new import_src.SrtcpSession({
|
|
38309
40100
|
keys: {
|
|
38310
40101
|
localMasterKey: key,
|
|
38311
40102
|
localMasterSalt: salt,
|
|
@@ -38316,13 +40107,17 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
38316
40107
|
});
|
|
38317
40108
|
let videoOutSrtp;
|
|
38318
40109
|
let videoOutSrtcp;
|
|
40110
|
+
let videoInSrtcp;
|
|
38319
40111
|
let audioOutSrtp;
|
|
38320
40112
|
let audioOutSrtcp;
|
|
40113
|
+
let audioInSrtcp;
|
|
38321
40114
|
try {
|
|
38322
40115
|
videoOutSrtp = makeOutSrtp(request.video.srtp_key, request.video.srtp_salt);
|
|
38323
|
-
videoOutSrtcp =
|
|
40116
|
+
videoOutSrtcp = makeSrtcp(request.video.srtp_key, request.video.srtp_salt);
|
|
40117
|
+
videoInSrtcp = makeSrtcp(request.video.srtp_key, request.video.srtp_salt);
|
|
38324
40118
|
audioOutSrtp = makeOutSrtp(request.audio.srtp_key, request.audio.srtp_salt);
|
|
38325
|
-
audioOutSrtcp =
|
|
40119
|
+
audioOutSrtcp = makeSrtcp(request.audio.srtp_key, request.audio.srtp_salt);
|
|
40120
|
+
audioInSrtcp = makeSrtcp(request.audio.srtp_key, request.audio.srtp_salt);
|
|
38326
40121
|
} catch (err) {
|
|
38327
40122
|
closeSocket(videoUdp);
|
|
38328
40123
|
closeSocket(audioUdp);
|
|
@@ -38347,6 +40142,30 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
38347
40142
|
const videoSsrc = randomSsrc();
|
|
38348
40143
|
const audioSsrc = randomSsrc();
|
|
38349
40144
|
const session = {
|
|
40145
|
+
sessionId: request.sessionID,
|
|
40146
|
+
startedAtMs: null,
|
|
40147
|
+
endedAtMs: null,
|
|
40148
|
+
negotiated: null,
|
|
40149
|
+
selectedSlot: null,
|
|
40150
|
+
ffmpegExit: null,
|
|
40151
|
+
stopRequestedByController: false,
|
|
40152
|
+
teardownTrigger: null,
|
|
40153
|
+
drops: emptyDropCounters(),
|
|
40154
|
+
videoPacketsForwarded: 0,
|
|
40155
|
+
audioPacketsForwarded: 0,
|
|
40156
|
+
videoRtcpSrSent: 0,
|
|
40157
|
+
audioRtcpSrSent: 0,
|
|
40158
|
+
videoRtcpReceived: 0,
|
|
40159
|
+
audioRtcpReceived: 0,
|
|
40160
|
+
videoRtpReceived: 0,
|
|
40161
|
+
audioRtpReceived: 0,
|
|
40162
|
+
videoReceiverReports: emptyReceiverReportTally(),
|
|
40163
|
+
audioReceiverReports: emptyReceiverReportTally(),
|
|
40164
|
+
videoRrLoggedAt: 0,
|
|
40165
|
+
audioRrLoggedAt: 0,
|
|
40166
|
+
videoGate: null,
|
|
40167
|
+
audioGate: null,
|
|
40168
|
+
heartbeat: null,
|
|
38350
40169
|
hapVideoPort: request.video.port,
|
|
38351
40170
|
hapAddress: request.targetAddress,
|
|
38352
40171
|
videoSrtpKey: request.video.srtp_key,
|
|
@@ -38355,6 +40174,7 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
38355
40174
|
videoLoopUdp,
|
|
38356
40175
|
videoOutSrtp,
|
|
38357
40176
|
videoOutSrtcp,
|
|
40177
|
+
videoInSrtcp,
|
|
38358
40178
|
videoOutPacketCount: 0,
|
|
38359
40179
|
videoOutOctetCount: 0,
|
|
38360
40180
|
videoOutLastRtpTimestamp: 0,
|
|
@@ -38369,6 +40189,7 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
38369
40189
|
audioLoopUdp,
|
|
38370
40190
|
audioOutSrtp,
|
|
38371
40191
|
audioOutSrtcp,
|
|
40192
|
+
audioInSrtcp,
|
|
38372
40193
|
audioSendGate: null,
|
|
38373
40194
|
ipVersion,
|
|
38374
40195
|
ffmpeg: null,
|
|
@@ -38391,19 +40212,33 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
38391
40212
|
audioRtcpIntervalMs: 5e3
|
|
38392
40213
|
};
|
|
38393
40214
|
sessions.set(request.sessionID, session);
|
|
40215
|
+
const tagLog = bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId });
|
|
38394
40216
|
session.videoSendGate = makeRtcpGate(videoUdp, 1e3);
|
|
38395
40217
|
session.audioSendGate = makeRtcpGate(audioUdp, 1e3);
|
|
38396
|
-
|
|
38397
|
-
|
|
38398
|
-
|
|
40218
|
+
session.videoSendGate.then((resolution) => {
|
|
40219
|
+
session.videoGate = resolution;
|
|
40220
|
+
logGateResolved(session, "video", resolution, tagLog);
|
|
40221
|
+
});
|
|
40222
|
+
session.audioSendGate.then((resolution) => {
|
|
40223
|
+
session.audioGate = resolution;
|
|
40224
|
+
logGateResolved(session, "audio", resolution, tagLog);
|
|
40225
|
+
});
|
|
40226
|
+
videoUdp.on("message", (packet) => {
|
|
40227
|
+
countInbound(session, "video", packet, tagLog);
|
|
40228
|
+
});
|
|
40229
|
+
audioUdp.on("message", (packet) => {
|
|
40230
|
+
countInbound(session, "audio", packet, tagLog);
|
|
40231
|
+
});
|
|
38399
40232
|
videoLoopUdp.on("message", (rtpPacket) => {
|
|
38400
|
-
videoPacketsForwarded += 1;
|
|
38401
|
-
if (videoPacketsForwarded === 1
|
|
40233
|
+
session.videoPacketsForwarded += 1;
|
|
40234
|
+
if (session.videoPacketsForwarded === 1) tagLog.info("export-hap: first video packet from ffmpeg", { meta: {
|
|
40235
|
+
sessionId: session.sessionId,
|
|
40236
|
+
bytes: rtpPacket.length
|
|
40237
|
+
} });
|
|
38402
40238
|
forwardEncryptedRtp(session, rtpPacket, "video", tagLog);
|
|
38403
40239
|
});
|
|
38404
40240
|
audioLoopUdp.on("message", (rtpPacket) => {
|
|
38405
|
-
audioPacketsForwarded += 1;
|
|
38406
|
-
if (audioPacketsForwarded === 1 || audioPacketsForwarded % 100 === 0) tagLog.info("export-hap: audio loopback packets forwarded", { meta: { count: audioPacketsForwarded } });
|
|
40241
|
+
session.audioPacketsForwarded += 1;
|
|
38407
40242
|
forwardEncryptedRtp(session, rtpPacket, "audio", tagLog);
|
|
38408
40243
|
});
|
|
38409
40244
|
audioUdp.on("message", (packet, rinfo) => {
|
|
@@ -38411,6 +40246,19 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
38411
40246
|
bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId }).debug("export-hap: incoming-audio handler error (dropped)", { meta: { error: errMsg$8(err) } });
|
|
38412
40247
|
});
|
|
38413
40248
|
});
|
|
40249
|
+
tagLog.info("export-hap: stream prepared", { meta: {
|
|
40250
|
+
sessionId: request.sessionID,
|
|
40251
|
+
controllerAddress: request.targetAddress,
|
|
40252
|
+
addressVersion: ipVersion,
|
|
40253
|
+
localIp,
|
|
40254
|
+
advertisedVideoPort: localVideoPort,
|
|
40255
|
+
advertisedAudioPort: localAudioPort,
|
|
40256
|
+
controllerVideoPort: request.video.port,
|
|
40257
|
+
controllerAudioPort: request.audio.port,
|
|
40258
|
+
videoSsrc,
|
|
40259
|
+
audioSsrc,
|
|
40260
|
+
upstreamAudioDecrypt: upstreamAudioSrtp !== null
|
|
40261
|
+
} });
|
|
38414
40262
|
return {
|
|
38415
40263
|
video: {
|
|
38416
40264
|
port: localVideoPort,
|
|
@@ -38485,30 +40333,225 @@ function sameIpv4Subnet(a, mask, b) {
|
|
|
38485
40333
|
async function bindLoopback(ipVersion) {
|
|
38486
40334
|
return bindUdp(ipVersion, ipVersion === "ipv6" ? "::1" : "127.0.0.1");
|
|
38487
40335
|
}
|
|
40336
|
+
/** Book a named drop. Every silent `return` on the streaming path routes here. */
|
|
40337
|
+
function drop(session, reason) {
|
|
40338
|
+
session.drops = recordDrop(session.drops, reason);
|
|
40339
|
+
}
|
|
38488
40340
|
/**
|
|
38489
|
-
*
|
|
38490
|
-
*
|
|
38491
|
-
*
|
|
38492
|
-
* iOS sees a return-path handshake before our actual stream begins.
|
|
38493
|
-
*
|
|
38494
|
-
* One-shot: the listener is removed on the first message OR on
|
|
38495
|
-
* timeout — the gate then stays resolved for the rest of the session.
|
|
40341
|
+
* Report a gate resolution. `timeout` is the interesting one: it means we are
|
|
40342
|
+
* about to stream into a port the controller never touched, and it was
|
|
40343
|
+
* indistinguishable from a successful probe until now.
|
|
38496
40344
|
*/
|
|
38497
|
-
function
|
|
38498
|
-
|
|
38499
|
-
|
|
38500
|
-
|
|
38501
|
-
|
|
38502
|
-
|
|
38503
|
-
|
|
38504
|
-
|
|
38505
|
-
|
|
38506
|
-
|
|
38507
|
-
|
|
38508
|
-
|
|
40345
|
+
function logGateResolved(session, leg, resolution, log) {
|
|
40346
|
+
const meta = {
|
|
40347
|
+
sessionId: session.sessionId,
|
|
40348
|
+
leg,
|
|
40349
|
+
reason: resolution.reason,
|
|
40350
|
+
waitedMs: resolution.waitedMs
|
|
40351
|
+
};
|
|
40352
|
+
if (resolution.reason === "packet") log.info("export-hap: RTCP gate opened on a REAL controller packet", { meta });
|
|
40353
|
+
else log.warn("export-hap: RTCP gate opened on the 1s FALLBACK — controller never probed", { meta });
|
|
40354
|
+
}
|
|
40355
|
+
/** Count and classify one packet the controller sent to a return port. */
|
|
40356
|
+
function countInbound(session, leg, packet, log) {
|
|
40357
|
+
const kind = classifyInboundPacket(packet);
|
|
40358
|
+
const firstRtcp = kind === "rtcp" && (leg === "video" ? session.videoRtcpReceived : session.audioRtcpReceived) === 0;
|
|
40359
|
+
if (leg === "video") {
|
|
40360
|
+
if (kind === "rtcp") session.videoRtcpReceived += 1;
|
|
40361
|
+
else if (kind === "rtp") session.videoRtpReceived += 1;
|
|
40362
|
+
} else if (kind === "rtcp") session.audioRtcpReceived += 1;
|
|
40363
|
+
else if (kind === "rtp") session.audioRtpReceived += 1;
|
|
40364
|
+
if (firstRtcp) log.info("export-hap: first inbound RTCP from controller", { meta: {
|
|
40365
|
+
sessionId: session.sessionId,
|
|
40366
|
+
leg,
|
|
40367
|
+
bytes: packet.length
|
|
40368
|
+
} });
|
|
40369
|
+
if (kind === "rtcp") readControllerRtcp(session, leg, packet, log);
|
|
40370
|
+
}
|
|
40371
|
+
/** Fold a decrypted RTCP datagram into the leg's tally. Immutable, per `ReceiverReportTally`. */
|
|
40372
|
+
function storeReceiverReports(session, leg, tally) {
|
|
40373
|
+
if (leg === "video") session.videoReceiverReports = tally;
|
|
40374
|
+
else session.audioReceiverReports = tally;
|
|
40375
|
+
}
|
|
40376
|
+
/**
|
|
40377
|
+
* Decrypt one inbound RTCP datagram and read the controller's Receiver Report.
|
|
40378
|
+
*
|
|
40379
|
+
* This is the datum the 2026-08-06 telemetry left missing. That round proved
|
|
40380
|
+
* iOS DOES send us RTCP on the video leg — 21 and 34 packets across two
|
|
40381
|
+
* sessions, the gate opening on a real controller packet at ~550 ms — which
|
|
40382
|
+
* killed the long-standing "iOS never probes us" belief. But counting packets
|
|
40383
|
+
* says only that the controller spoke; a Receiver Report says WHAT it said,
|
|
40384
|
+
* and that splits the remaining causes into two disjoint families that nothing
|
|
40385
|
+
* else in this system can tell apart. See `LossVerdict`.
|
|
40386
|
+
*
|
|
40387
|
+
* Runs on a UDP `message` handler, so nothing here may throw: a controller
|
|
40388
|
+
* that sends one deformed datagram must not take the session with it. Every
|
|
40389
|
+
* failure books a named drop and increments `unreadable`, because a report we
|
|
40390
|
+
* could not read and a report that never came must never look alike.
|
|
40391
|
+
*/
|
|
40392
|
+
function readControllerRtcp(session, leg, packet, log) {
|
|
40393
|
+
const srtcp = leg === "video" ? session.videoInSrtcp : session.audioInSrtcp;
|
|
40394
|
+
const tally = leg === "video" ? session.videoReceiverReports : session.audioReceiverReports;
|
|
40395
|
+
if (!srtcp) {
|
|
40396
|
+
drop(session, "inbound-rtcp-no-srtcp");
|
|
40397
|
+
storeReceiverReports(session, leg, recordUnreadableRtcp(tally));
|
|
40398
|
+
logUnreadableRtcp(session, leg, "no-srtcp-context", log);
|
|
40399
|
+
return;
|
|
40400
|
+
}
|
|
40401
|
+
let plaintext;
|
|
40402
|
+
try {
|
|
40403
|
+
plaintext = srtcp.decrypt(packet);
|
|
40404
|
+
} catch (err) {
|
|
40405
|
+
drop(session, "inbound-rtcp-decrypt-failed");
|
|
40406
|
+
storeReceiverReports(session, leg, recordUnreadableRtcp(tally));
|
|
40407
|
+
logUnreadableRtcp(session, leg, `decrypt: ${errMsg$8(err)}`, log);
|
|
40408
|
+
return;
|
|
40409
|
+
}
|
|
40410
|
+
const outcome = ingestDecryptedRtcp(plaintext, tally);
|
|
40411
|
+
storeReceiverReports(session, leg, outcome.tally);
|
|
40412
|
+
if (outcome.failure !== null) {
|
|
40413
|
+
drop(session, "inbound-rtcp-parse-failed");
|
|
40414
|
+
logUnreadableRtcp(session, leg, `parse: ${outcome.failure}`, log);
|
|
40415
|
+
return;
|
|
40416
|
+
}
|
|
40417
|
+
const isFirst = tally.reportsParsed === 0 && outcome.tally.reportsParsed > 0;
|
|
40418
|
+
logReceiverReports(session, leg, outcome.reports, isFirst, log);
|
|
40419
|
+
}
|
|
40420
|
+
/** Throttled per leg — a controller sending nothing but garbage must not become the log. */
|
|
40421
|
+
function logUnreadableRtcp(session, leg, reason, log) {
|
|
40422
|
+
const tally = leg === "video" ? session.videoReceiverReports : session.audioReceiverReports;
|
|
40423
|
+
if (!shouldLogReceiverReport(session, leg)) return;
|
|
40424
|
+
log.warn("export-hap: inbound RTCP could not be read", { meta: {
|
|
40425
|
+
sessionId: session.sessionId,
|
|
40426
|
+
leg,
|
|
40427
|
+
reason,
|
|
40428
|
+
unreadable: tally.unreadable
|
|
40429
|
+
} });
|
|
40430
|
+
}
|
|
40431
|
+
/**
|
|
40432
|
+
* True at most once per `RECEIVER_REPORT_LOG_INTERVAL_MS` per leg. Stamps the
|
|
40433
|
+
* leg on the way out so the caller cannot forget to.
|
|
40434
|
+
*/
|
|
40435
|
+
function shouldLogReceiverReport(session, leg) {
|
|
40436
|
+
const now = Date.now();
|
|
40437
|
+
if (now < (leg === "video" ? session.videoRrLoggedAt : session.audioRrLoggedAt) + RECEIVER_REPORT_LOG_INTERVAL_MS) return false;
|
|
40438
|
+
if (leg === "video") session.videoRrLoggedAt = now;
|
|
40439
|
+
else session.audioRrLoggedAt = now;
|
|
40440
|
+
return true;
|
|
40441
|
+
}
|
|
40442
|
+
/**
|
|
40443
|
+
* Emit the controller's own numbers.
|
|
40444
|
+
*
|
|
40445
|
+
* The FIRST report on a leg gets its own message: that one arriving at all is
|
|
40446
|
+
* the proof iOS is engaged with the stream, and it was worth a year of
|
|
40447
|
+
* argument. Everything after it is throttled to the heartbeat's cadence.
|
|
40448
|
+
*/
|
|
40449
|
+
function logReceiverReports(session, leg, reports, isFirst, log) {
|
|
40450
|
+
const tally = leg === "video" ? session.videoReceiverReports : session.audioReceiverReports;
|
|
40451
|
+
const block = reports.flatMap((report) => report.blocks).at(-1);
|
|
40452
|
+
if (!block) return;
|
|
40453
|
+
const reporter = reports.find((report) => report.blocks.length > 0);
|
|
40454
|
+
const meta = {
|
|
40455
|
+
sessionId: session.sessionId,
|
|
40456
|
+
leg,
|
|
40457
|
+
reporterSsrc: reporter?.reporterSsrc ?? null,
|
|
40458
|
+
aboutSsrc: block.aboutSsrc,
|
|
40459
|
+
fractionLostPct: block.fractionLostPct,
|
|
40460
|
+
cumulativePacketsLost: block.cumulativePacketsLost,
|
|
40461
|
+
extendedHighestSequence: block.extendedHighestSequence,
|
|
40462
|
+
jitter: block.jitter,
|
|
40463
|
+
delaySinceLastSrMs: block.delaySinceLastSrMs,
|
|
40464
|
+
reportsParsed: tally.reportsParsed,
|
|
40465
|
+
worstFractionLostPct: tally.worstFractionLostPct
|
|
40466
|
+
};
|
|
40467
|
+
if (isFirst) {
|
|
40468
|
+
shouldLogReceiverReport(session, leg);
|
|
40469
|
+
log.info("export-hap: FIRST RTCP Receiver Report from controller — iOS is receiving and reporting", { meta });
|
|
40470
|
+
return;
|
|
40471
|
+
}
|
|
40472
|
+
if (!shouldLogReceiverReport(session, leg)) return;
|
|
40473
|
+
log.info("export-hap: controller receiver report", { meta });
|
|
40474
|
+
}
|
|
40475
|
+
/** Snapshot every counter into the summary meta. */
|
|
40476
|
+
function sessionSummaryMeta(session) {
|
|
40477
|
+
return summariseSession({
|
|
40478
|
+
sessionId: session.sessionId,
|
|
40479
|
+
startedAtMs: session.startedAtMs,
|
|
40480
|
+
endedAtMs: session.endedAtMs,
|
|
40481
|
+
negotiated: session.negotiated,
|
|
40482
|
+
selectedSlot: session.selectedSlot,
|
|
40483
|
+
videoPacketsForwarded: session.videoPacketsForwarded,
|
|
40484
|
+
audioPacketsForwarded: session.audioPacketsForwarded,
|
|
40485
|
+
videoRtcpSrSent: session.videoRtcpSrSent,
|
|
40486
|
+
audioRtcpSrSent: session.audioRtcpSrSent,
|
|
40487
|
+
videoRtcpReceived: session.videoRtcpReceived,
|
|
40488
|
+
audioRtcpReceived: session.audioRtcpReceived,
|
|
40489
|
+
videoRtpReceived: session.videoRtpReceived,
|
|
40490
|
+
audioRtpReceived: session.audioRtpReceived,
|
|
40491
|
+
videoGate: session.videoGate,
|
|
40492
|
+
audioGate: session.audioGate,
|
|
40493
|
+
videoReceiverReports: session.videoReceiverReports,
|
|
40494
|
+
audioReceiverReports: session.audioReceiverReports,
|
|
40495
|
+
drops: session.drops,
|
|
40496
|
+
ffmpegExit: session.ffmpegExit,
|
|
40497
|
+
stopRequestedByController: session.stopRequestedByController
|
|
38509
40498
|
});
|
|
38510
40499
|
}
|
|
38511
40500
|
/**
|
|
40501
|
+
* Arm the per-session heartbeat. LOGS ONLY — see `SESSION_HEARTBEAT_MS`. It
|
|
40502
|
+
* exists so a session that stops producing media says so while it is still
|
|
40503
|
+
* alive, instead of being reconstructed after the fact from its final line.
|
|
40504
|
+
*/
|
|
40505
|
+
function armHeartbeat(session, log) {
|
|
40506
|
+
if (session.heartbeat) clearInterval(session.heartbeat);
|
|
40507
|
+
let lastVideo = session.videoPacketsForwarded;
|
|
40508
|
+
const timer = setInterval(() => {
|
|
40509
|
+
const forwarded = session.videoPacketsForwarded - lastVideo;
|
|
40510
|
+
lastVideo = session.videoPacketsForwarded;
|
|
40511
|
+
log.info("export-hap: stream heartbeat", { meta: {
|
|
40512
|
+
...sessionSummaryMeta(session),
|
|
40513
|
+
videoPacketsSinceLastBeat: forwarded,
|
|
40514
|
+
videoStalled: forwarded === 0
|
|
40515
|
+
} });
|
|
40516
|
+
}, SESSION_HEARTBEAT_MS);
|
|
40517
|
+
timer.unref();
|
|
40518
|
+
session.heartbeat = timer;
|
|
40519
|
+
}
|
|
40520
|
+
function stopHeartbeat(session) {
|
|
40521
|
+
if (!session.heartbeat) return;
|
|
40522
|
+
clearInterval(session.heartbeat);
|
|
40523
|
+
session.heartbeat = null;
|
|
40524
|
+
}
|
|
40525
|
+
/**
|
|
40526
|
+
* **`export-hap: stream session summary` is the line to grep** when asking why
|
|
40527
|
+
* a HomeKit session died. It carries the negotiated parameters, the slot we
|
|
40528
|
+
* dialled and the rate we had promised for it, packet counts in both
|
|
40529
|
+
* directions on both legs, each leg's gate outcome, ffmpeg's exit, who asked
|
|
40530
|
+
* for the teardown, every named drop — and, since 2026-08-06, what the
|
|
40531
|
+
* controller itself reported receiving.
|
|
40532
|
+
*
|
|
40533
|
+
* Read `videoLossVerdict` first. `transmit-suspect` says the controller is not
|
|
40534
|
+
* getting our packets intact and nothing past the wire matters;
|
|
40535
|
+
* `decode-suspect` says it got them and rendered nothing anyway;
|
|
40536
|
+
* `no-reports` says we still cannot tell, and `videoReceiverReports.unreadable`
|
|
40537
|
+
* then distinguishes "the controller said nothing" from "we could not read what
|
|
40538
|
+
* it said".
|
|
40539
|
+
*
|
|
40540
|
+
* Emitted from the ffmpeg `exit` handler — the only place the exit code is
|
|
40541
|
+
* known — and directly from the teardown paths when there is no ffmpeg to wait
|
|
40542
|
+
* for. It deliberately does NOT touch the heartbeat: iOS restarts a session
|
|
40543
|
+
* in place when it is not getting a picture (one on record was started three
|
|
40544
|
+
* times in 16 s), and stopping the heartbeat on the superseded process's exit
|
|
40545
|
+
* would silence the replacement.
|
|
40546
|
+
*/
|
|
40547
|
+
function logSessionSummary(session, log, trigger) {
|
|
40548
|
+
session.endedAtMs = Date.now();
|
|
40549
|
+
log.info("export-hap: stream session summary", { meta: {
|
|
40550
|
+
...sessionSummaryMeta(session),
|
|
40551
|
+
trigger
|
|
40552
|
+
} });
|
|
40553
|
+
}
|
|
40554
|
+
/**
|
|
38512
40555
|
* Build the NTP timestamp expected in an RTCP Sender Report.
|
|
38513
40556
|
*
|
|
38514
40557
|
* NTP timestamps are 64-bit: upper 32 bits = seconds since 1900-01-01,
|
|
@@ -38541,7 +40584,10 @@ function ntpTime() {
|
|
|
38541
40584
|
*/
|
|
38542
40585
|
function sendRtcpSr(session, kind, log) {
|
|
38543
40586
|
const srtcp = kind === "video" ? session.videoOutSrtcp : session.audioOutSrtcp;
|
|
38544
|
-
if (!srtcp)
|
|
40587
|
+
if (!srtcp) {
|
|
40588
|
+
drop(session, "rtcp-no-srtcp");
|
|
40589
|
+
return;
|
|
40590
|
+
}
|
|
38545
40591
|
const sink = kind === "video" ? session.videoUdp : session.audioUdp;
|
|
38546
40592
|
const port = kind === "video" ? session.hapVideoPort : session.hapAudioPort;
|
|
38547
40593
|
try {
|
|
@@ -38556,15 +40602,31 @@ function sendRtcpSr(session, kind, log) {
|
|
|
38556
40602
|
});
|
|
38557
40603
|
const encrypted = srtcp.encrypt(sr.serialize());
|
|
38558
40604
|
sink.send(encrypted, port, session.hapAddress, (err) => {
|
|
38559
|
-
if (err)
|
|
38560
|
-
|
|
38561
|
-
error:
|
|
38562
|
-
|
|
40605
|
+
if (err) {
|
|
40606
|
+
drop(session, "srtp-send-failed");
|
|
40607
|
+
log.warn("export-hap: RTCP send error", { meta: {
|
|
40608
|
+
sessionId: session.sessionId,
|
|
40609
|
+
kind,
|
|
40610
|
+
error: err.message
|
|
40611
|
+
} });
|
|
40612
|
+
}
|
|
38563
40613
|
});
|
|
38564
|
-
if (kind === "video")
|
|
38565
|
-
|
|
40614
|
+
if (kind === "video") {
|
|
40615
|
+
session.videoOutLastRtcpAt = Date.now();
|
|
40616
|
+
session.videoRtcpSrSent += 1;
|
|
40617
|
+
if (session.videoRtcpSrSent === 1) log.info("export-hap: first VIDEO RTCP Sender Report sent", { meta: {
|
|
40618
|
+
sessionId: session.sessionId,
|
|
40619
|
+
rtpTimestamp: session.videoOutLastRtpTimestamp,
|
|
40620
|
+
intervalMs: session.videoRtcpIntervalMs
|
|
40621
|
+
} });
|
|
40622
|
+
} else {
|
|
40623
|
+
session.audioOutLastRtcpAt = Date.now();
|
|
40624
|
+
session.audioRtcpSrSent += 1;
|
|
40625
|
+
}
|
|
38566
40626
|
} catch (err) {
|
|
38567
|
-
|
|
40627
|
+
drop(session, "rtcp-encrypt-failed");
|
|
40628
|
+
log.warn("export-hap: RTCP SR build/encrypt failed", { meta: {
|
|
40629
|
+
sessionId: session.sessionId,
|
|
38568
40630
|
kind,
|
|
38569
40631
|
error: err instanceof Error ? err.message : String(err)
|
|
38570
40632
|
} });
|
|
@@ -38586,7 +40648,14 @@ function forwardEncryptedRtp(session, rtpPacket, kind, log) {
|
|
|
38586
40648
|
const sink = kind === "video" ? session.videoUdp : session.audioUdp;
|
|
38587
40649
|
const port = kind === "video" ? session.hapVideoPort : session.hapAudioPort;
|
|
38588
40650
|
const gate = kind === "video" ? session.videoSendGate : session.audioSendGate;
|
|
38589
|
-
if (!srtp
|
|
40651
|
+
if (!srtp) {
|
|
40652
|
+
drop(session, "no-srtp-session");
|
|
40653
|
+
return;
|
|
40654
|
+
}
|
|
40655
|
+
if (!gate) {
|
|
40656
|
+
drop(session, "no-gate");
|
|
40657
|
+
return;
|
|
40658
|
+
}
|
|
38590
40659
|
gate.then(() => {
|
|
38591
40660
|
try {
|
|
38592
40661
|
const parsed = import_src.RtpPacket.deSerialize(rtpPacket);
|
|
@@ -38622,32 +40691,46 @@ function forwardEncryptedRtp(session, rtpPacket, kind, log) {
|
|
|
38622
40691
|
}
|
|
38623
40692
|
const encrypted = srtp.encrypt(parsed.payload, parsed.header);
|
|
38624
40693
|
sink.send(encrypted, port, session.hapAddress, (err) => {
|
|
38625
|
-
if (err)
|
|
38626
|
-
|
|
38627
|
-
error:
|
|
38628
|
-
|
|
40694
|
+
if (err) {
|
|
40695
|
+
drop(session, "srtp-send-failed");
|
|
40696
|
+
log.debug("export-hap: SRTP send error", { meta: {
|
|
40697
|
+
sessionId: session.sessionId,
|
|
40698
|
+
kind,
|
|
40699
|
+
error: err.message
|
|
40700
|
+
} });
|
|
40701
|
+
}
|
|
38629
40702
|
});
|
|
38630
40703
|
const now = Date.now();
|
|
38631
40704
|
if (kind === "video") {
|
|
38632
40705
|
if (firstVideo || now > session.videoOutLastRtcpAt + session.videoRtcpIntervalMs) sendRtcpSr(session, "video", log);
|
|
38633
40706
|
} else if (firstAudio || now > session.audioOutLastRtcpAt + session.audioRtcpIntervalMs) sendRtcpSr(session, "audio", log);
|
|
38634
40707
|
} catch (err) {
|
|
40708
|
+
drop(session, "srtp-encrypt-failed");
|
|
38635
40709
|
log.debug("export-hap: SRTP encrypt failed", { meta: {
|
|
40710
|
+
sessionId: session.sessionId,
|
|
38636
40711
|
kind,
|
|
38637
40712
|
error: err instanceof Error ? err.message : String(err)
|
|
38638
40713
|
} });
|
|
38639
40714
|
}
|
|
38640
40715
|
});
|
|
38641
40716
|
}
|
|
38642
|
-
async function handleStreamRequest(request, sessions, bctx) {
|
|
40717
|
+
async function handleStreamRequest(request, sessions, bctx, advertised) {
|
|
38643
40718
|
const log = bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId });
|
|
38644
40719
|
const session = sessions.get(request.sessionID);
|
|
38645
40720
|
if (!session) {
|
|
38646
|
-
log.warn("export-hap: stream request for unknown session", { meta: {
|
|
40721
|
+
log.warn("export-hap: stream request for unknown session", { meta: {
|
|
40722
|
+
sessionID: request.sessionID,
|
|
40723
|
+
type: request.type
|
|
40724
|
+
} });
|
|
38647
40725
|
return;
|
|
38648
40726
|
}
|
|
38649
40727
|
if (request.type === "stop") {
|
|
40728
|
+
session.stopRequestedByController = true;
|
|
40729
|
+
session.teardownTrigger = "controller-stop";
|
|
40730
|
+
const hadFfmpeg = session.ffmpeg !== null;
|
|
38650
40731
|
killFfmpeg(session, bctx.ctx, bctx.numericDeviceId);
|
|
40732
|
+
stopHeartbeat(session);
|
|
40733
|
+
if (!hadFfmpeg) logSessionSummary(session, log, "controller-stop-no-ffmpeg");
|
|
38651
40734
|
await closeIntercomTalkSession(session, bctx).catch(() => void 0);
|
|
38652
40735
|
closeSocket(session.videoUdp);
|
|
38653
40736
|
closeSocket(session.audioUdp);
|
|
@@ -38673,6 +40756,27 @@ async function handleStreamRequest(request, sessions, bctx) {
|
|
|
38673
40756
|
session.videoOutOctetCount = 0;
|
|
38674
40757
|
session.videoOutLastRtpTimestamp = 0;
|
|
38675
40758
|
session.videoOutLastRtcpAt = 0;
|
|
40759
|
+
session.negotiated = {
|
|
40760
|
+
width: request.video.width,
|
|
40761
|
+
height: request.video.height,
|
|
40762
|
+
fps: request.video.fps,
|
|
40763
|
+
maxBitrateKbps: request.video.max_bit_rate,
|
|
40764
|
+
mtu: request.video.mtu,
|
|
40765
|
+
videoPt: request.video.pt,
|
|
40766
|
+
videoSsrc: session.videoSsrc,
|
|
40767
|
+
videoRtcpIntervalMs: session.videoRtcpIntervalMs,
|
|
40768
|
+
audioPt: request.audio.pt,
|
|
40769
|
+
audioSsrc: session.audioOutSsrc,
|
|
40770
|
+
audioSampleRateKhz: request.audio.sample_rate,
|
|
40771
|
+
audioPacketTimeMs: packetTimeMs,
|
|
40772
|
+
audioRtcpIntervalMs: session.audioRtcpIntervalMs,
|
|
40773
|
+
audioMaxBitrateKbps: request.audio.max_bit_rate
|
|
40774
|
+
};
|
|
40775
|
+
session.startedAtMs = Date.now();
|
|
40776
|
+
log.info("export-hap: stream negotiated", { meta: {
|
|
40777
|
+
sessionId: request.sessionID,
|
|
40778
|
+
...session.negotiated
|
|
40779
|
+
} });
|
|
38676
40780
|
session.lastStartParams = {
|
|
38677
40781
|
pt: request.video.pt,
|
|
38678
40782
|
mtu: request.video.mtu,
|
|
@@ -38693,7 +40797,8 @@ async function handleStreamRequest(request, sessions, bctx) {
|
|
|
38693
40797
|
sample_rate: request.audio.sample_rate,
|
|
38694
40798
|
packet_time: packetTimeMs
|
|
38695
40799
|
};
|
|
38696
|
-
await startFfmpegForSession(bctx, session, request.sessionID, startParams);
|
|
40800
|
+
await startFfmpegForSession(bctx, session, request.sessionID, startParams, advertised);
|
|
40801
|
+
armHeartbeat(session, log);
|
|
38697
40802
|
return;
|
|
38698
40803
|
}
|
|
38699
40804
|
if (request.type === "reconfigure") {
|
|
@@ -38720,147 +40825,266 @@ async function handleStreamRequest(request, sessions, bctx) {
|
|
|
38720
40825
|
sample_rate: session.lastStartParams.audioSampleRateEnum,
|
|
38721
40826
|
packet_time: session.lastStartParams.audioPacketTimeMs
|
|
38722
40827
|
};
|
|
38723
|
-
|
|
40828
|
+
log.info("export-hap: stream reconfigured", { meta: {
|
|
40829
|
+
sessionId: request.sessionID,
|
|
40830
|
+
width: request.video.width,
|
|
40831
|
+
height: request.video.height,
|
|
40832
|
+
fps: request.video.fps,
|
|
40833
|
+
maxBitrateKbps: request.video.max_bit_rate
|
|
40834
|
+
} });
|
|
40835
|
+
await startFfmpegForSession(bctx, session, request.sessionID, startParams, advertised);
|
|
40836
|
+
armHeartbeat(session, log);
|
|
38724
40837
|
}
|
|
38725
40838
|
}
|
|
38726
|
-
async function startFfmpegForSession(bctx, session, sessionId, video) {
|
|
40839
|
+
async function startFfmpegForSession(bctx, session, sessionId, video, advertised) {
|
|
38727
40840
|
const { ctx, proxy, numericDeviceId, options } = bctx;
|
|
40841
|
+
const startLog = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
38728
40842
|
const entries = await proxy.cameraStreams?.getProfileRtspEntries({}) ?? [];
|
|
38729
|
-
if (entries.length === 0)
|
|
40843
|
+
if (entries.length === 0) {
|
|
40844
|
+
startLog.warn("export-hap: stream start DROPPED — device publishes no profile RTSP entries", { meta: {
|
|
40845
|
+
sessionId,
|
|
40846
|
+
capBound: proxy.cameraStreams !== void 0
|
|
40847
|
+
} });
|
|
40848
|
+
throw new Error(`export-hap: no profile RTSP entries for device ${numericDeviceId}`);
|
|
40849
|
+
}
|
|
38730
40850
|
const pref = options.hapDeviceSettings.streamPreference;
|
|
38731
|
-
const
|
|
38732
|
-
|
|
38733
|
-
|
|
38734
|
-
|
|
38735
|
-
|
|
40851
|
+
const brokerStreams = await proxy.cameraStreams?.getBrokerStreams({}) ?? [];
|
|
40852
|
+
const bitrates = await probeProfileBitrates({
|
|
40853
|
+
bctx,
|
|
40854
|
+
slots: brokerStreams,
|
|
40855
|
+
log: startLog
|
|
40856
|
+
});
|
|
40857
|
+
const fit = selectStreamForBudget({
|
|
40858
|
+
entries: withSlotCodecs(entries, brokerStreams),
|
|
40859
|
+
deviceId: numericDeviceId,
|
|
40860
|
+
pref,
|
|
40861
|
+
targetResolution: {
|
|
40862
|
+
width: video.width,
|
|
40863
|
+
height: video.height
|
|
40864
|
+
},
|
|
40865
|
+
negotiatedMaxBitrateKbps: video.max_bit_rate,
|
|
40866
|
+
bitrates
|
|
40867
|
+
});
|
|
40868
|
+
if (fit === null) {
|
|
40869
|
+
startLog.warn("export-hap: stream start DROPPED — no ENABLED profile RTSP entry", { meta: {
|
|
40870
|
+
sessionId,
|
|
40871
|
+
streamPreference: pref,
|
|
40872
|
+
targetResolution: `${video.width}x${video.height}`,
|
|
40873
|
+
entries: entries.map((e) => `${e.profile}:${e.enabled ? "enabled" : "disabled"}`)
|
|
40874
|
+
} });
|
|
40875
|
+
throw new Error(`export-hap: no enabled RTSP entries for device ${numericDeviceId}`);
|
|
40876
|
+
}
|
|
40877
|
+
const picked = fit.picked;
|
|
38736
40878
|
const rtspUrl = picked.url;
|
|
38737
|
-
const slot =
|
|
40879
|
+
const slot = brokerStreams.find((s) => s.profile === fit.profile);
|
|
38738
40880
|
const codec = (picked.codec ?? slot?.codec ?? "").toLowerCase();
|
|
38739
|
-
const needsTranscode =
|
|
40881
|
+
const needsTranscode = fit.kind === "transcode";
|
|
40882
|
+
const pickedProfile = toKnownProfile(picked.profileId);
|
|
40883
|
+
const resolvedFps = pickedProfile === null ? void 0 : advertised.fpsByProfile.get(pickedProfile);
|
|
40884
|
+
const advertisedFps = resolvedFps?.fps ?? video.fps;
|
|
40885
|
+
const advertisedFpsSource = resolvedFps?.source ?? "assumed";
|
|
40886
|
+
const deliveredFps = needsTranscode ? deliverableFps(video.fps, resolvedFps?.fps ?? null) : advertisedFps;
|
|
40887
|
+
const slotEvidence = pickedProfile === null ? void 0 : bitrates.get(pickedProfile);
|
|
40888
|
+
const fitNotes = formatFitNotes(fit.notes);
|
|
40889
|
+
session.selectedSlot = {
|
|
40890
|
+
profile: pickedProfile,
|
|
40891
|
+
brokerId: picked.brokerId,
|
|
40892
|
+
width: picked.resolution?.width ?? null,
|
|
40893
|
+
height: picked.resolution?.height ?? null,
|
|
40894
|
+
advertisedFps,
|
|
40895
|
+
advertisedFpsSource,
|
|
40896
|
+
deliveredFps,
|
|
40897
|
+
codec: codec.length > 0 ? codec : "unknown",
|
|
40898
|
+
transcode: needsTranscode,
|
|
40899
|
+
fitReason: fit.reason,
|
|
40900
|
+
publishedKbps: slotEvidence?.publishedKbps ?? null,
|
|
40901
|
+
measuredKbps: slotEvidence?.measuredKbps ?? null,
|
|
40902
|
+
budgetKbps: fit.budgetKbps,
|
|
40903
|
+
fitNotes
|
|
40904
|
+
};
|
|
40905
|
+
startLog.info("export-hap: stream bitrate fit resolved", { meta: {
|
|
40906
|
+
sessionId,
|
|
40907
|
+
decision: fit.kind,
|
|
40908
|
+
reason: fit.reason,
|
|
40909
|
+
negotiatedMaxBitrateKbps: video.max_bit_rate,
|
|
40910
|
+
encodeBudgetKbps: fit.budgetKbps,
|
|
40911
|
+
profile: pickedProfile,
|
|
40912
|
+
brokerId: picked.brokerId,
|
|
40913
|
+
slotPublishedKbps: slotEvidence?.publishedKbps ?? null,
|
|
40914
|
+
slotMeasuredKbps: slotEvidence?.measuredKbps ?? null,
|
|
40915
|
+
candidates: fitNotes
|
|
40916
|
+
} });
|
|
40917
|
+
if (deliveredFps !== video.fps) startLog.warn("export-hap: negotiated fps does NOT match the rate we are about to deliver", { meta: {
|
|
40918
|
+
sessionId,
|
|
40919
|
+
negotiatedFps: video.fps,
|
|
40920
|
+
slotFps: advertisedFps,
|
|
40921
|
+
slotFpsSource: advertisedFpsSource,
|
|
40922
|
+
deliveredFps,
|
|
40923
|
+
profile: pickedProfile,
|
|
40924
|
+
brokerId: picked.brokerId,
|
|
40925
|
+
transcode: needsTranscode
|
|
40926
|
+
} });
|
|
38740
40927
|
const videoLoopPort = session.videoLoopUdp.address().port;
|
|
38741
40928
|
const audioLoopPort = session.audioLoopUdp.address().port;
|
|
38742
40929
|
const videoTarget = `rtp://127.0.0.1:${videoLoopPort}?pkt_size=${video.mtu}`;
|
|
38743
40930
|
const audioTarget = `rtp://127.0.0.1:${audioLoopPort}?pkt_size=${video.mtu}`;
|
|
38744
|
-
const videoArgs =
|
|
38745
|
-
|
|
38746
|
-
|
|
38747
|
-
|
|
38748
|
-
|
|
38749
|
-
|
|
38750
|
-
|
|
38751
|
-
|
|
38752
|
-
|
|
38753
|
-
|
|
38754
|
-
|
|
38755
|
-
|
|
38756
|
-
|
|
38757
|
-
|
|
38758
|
-
|
|
38759
|
-
|
|
38760
|
-
`${video.max_bit_rate * 2}k`,
|
|
38761
|
-
"-maxrate",
|
|
38762
|
-
`${video.max_bit_rate}k`,
|
|
38763
|
-
"-profile:v",
|
|
38764
|
-
"baseline",
|
|
38765
|
-
"-level",
|
|
38766
|
-
"3.1"
|
|
38767
|
-
] : [
|
|
38768
|
-
"-c:v",
|
|
38769
|
-
"copy",
|
|
38770
|
-
"-bsf:v",
|
|
38771
|
-
"dump_extra"
|
|
38772
|
-
];
|
|
40931
|
+
const videoArgs = buildVideoEncodeArgs({
|
|
40932
|
+
transcode: needsTranscode,
|
|
40933
|
+
width: video.width,
|
|
40934
|
+
height: video.height,
|
|
40935
|
+
fps: deliveredFps,
|
|
40936
|
+
budgetKbps: fit.budgetKbps
|
|
40937
|
+
});
|
|
40938
|
+
const hwDecode = selectHwDecode({
|
|
40939
|
+
transcode: needsTranscode,
|
|
40940
|
+
reading: needsTranscode ? await probeDecoderHwaccel({
|
|
40941
|
+
ctx,
|
|
40942
|
+
log: startLog
|
|
40943
|
+
}) : null,
|
|
40944
|
+
platform: process.platform
|
|
40945
|
+
});
|
|
40946
|
+
logDecodePath(startLog, sessionId, hwDecode, needsTranscode);
|
|
38773
40947
|
const videoSsrcSigned = session.videoSsrc | 0;
|
|
38774
40948
|
const audioSsrcSigned = video.audio_ssrc | 0;
|
|
38775
|
-
const
|
|
38776
|
-
|
|
38777
|
-
"-loglevel",
|
|
38778
|
-
"warning",
|
|
38779
|
-
"-rtsp_transport",
|
|
38780
|
-
"tcp",
|
|
38781
|
-
"-i",
|
|
40949
|
+
const buildArgs = (decodeArgs) => buildSessionFfmpegArgs({
|
|
40950
|
+
decodeArgs,
|
|
38782
40951
|
rtspUrl,
|
|
38783
|
-
|
|
38784
|
-
"-map",
|
|
38785
|
-
"0:v:0",
|
|
38786
|
-
...videoArgs,
|
|
38787
|
-
"-payload_type",
|
|
38788
|
-
String(video.pt),
|
|
38789
|
-
"-ssrc",
|
|
38790
|
-
String(videoSsrcSigned),
|
|
38791
|
-
"-f",
|
|
38792
|
-
"rtp",
|
|
40952
|
+
videoArgs,
|
|
38793
40953
|
videoTarget,
|
|
38794
|
-
|
|
38795
|
-
|
|
38796
|
-
|
|
38797
|
-
|
|
38798
|
-
|
|
38799
|
-
|
|
38800
|
-
|
|
38801
|
-
"-application",
|
|
38802
|
-
"lowdelay",
|
|
38803
|
-
"-frame_duration",
|
|
38804
|
-
String(video.packet_time ?? 20),
|
|
38805
|
-
"-flags",
|
|
38806
|
-
"+global_header",
|
|
38807
|
-
"-ar",
|
|
38808
|
-
String((video.sample_rate ?? 16) * 1e3),
|
|
38809
|
-
"-b:a",
|
|
38810
|
-
`${OPUS_BITRATE_KBPS}k`,
|
|
38811
|
-
"-bufsize",
|
|
38812
|
-
`${OPUS_BITRATE_KBPS * 4}k`,
|
|
38813
|
-
"-ac",
|
|
38814
|
-
String(OPUS_CHANNELS),
|
|
38815
|
-
"-payload_type",
|
|
38816
|
-
String(video.audio_pt),
|
|
38817
|
-
"-ssrc",
|
|
38818
|
-
String(audioSsrcSigned),
|
|
38819
|
-
"-f",
|
|
38820
|
-
"rtp",
|
|
38821
|
-
audioTarget
|
|
38822
|
-
];
|
|
38823
|
-
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
38824
|
-
const proc = spawn("ffmpeg", args, { stdio: [
|
|
38825
|
-
"ignore",
|
|
38826
|
-
"ignore",
|
|
38827
|
-
"pipe"
|
|
38828
|
-
] });
|
|
38829
|
-
session.ffmpeg = proc;
|
|
38830
|
-
proc.stderr?.on("data", (chunk) => {
|
|
38831
|
-
const line = chunk.toString("utf8").trim();
|
|
38832
|
-
if (!line) return;
|
|
38833
|
-
log.info("export-hap: ffmpeg", { meta: {
|
|
38834
|
-
sessionId,
|
|
38835
|
-
line
|
|
38836
|
-
} });
|
|
40954
|
+
audioTarget,
|
|
40955
|
+
videoPayloadType: video.pt,
|
|
40956
|
+
videoSsrcSigned,
|
|
40957
|
+
audioPayloadType: video.audio_pt,
|
|
40958
|
+
audioSsrcSigned,
|
|
40959
|
+
audioPacketTimeMs: video.packet_time ?? 20,
|
|
40960
|
+
audioSampleRateKhz: video.sample_rate ?? 16
|
|
38837
40961
|
});
|
|
38838
|
-
|
|
40962
|
+
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
40963
|
+
let hardwareAlreadyFailed = false;
|
|
40964
|
+
const spawnFfmpeg = (decision) => {
|
|
40965
|
+
const spawnedAtMs = Date.now();
|
|
40966
|
+
const usedHardware = decision.kind === "hardware";
|
|
40967
|
+
const proc = spawn("ffmpeg", buildArgs(decision.args), { stdio: [
|
|
40968
|
+
"ignore",
|
|
40969
|
+
"ignore",
|
|
40970
|
+
"pipe"
|
|
40971
|
+
] });
|
|
40972
|
+
session.ffmpeg = proc;
|
|
40973
|
+
proc.stderr?.on("data", (chunk) => {
|
|
40974
|
+
const line = chunk.toString("utf8").trim();
|
|
40975
|
+
if (!line) return;
|
|
40976
|
+
log.info("export-hap: ffmpeg", { meta: {
|
|
40977
|
+
sessionId,
|
|
40978
|
+
line
|
|
40979
|
+
} });
|
|
40980
|
+
});
|
|
40981
|
+
proc.once("exit", (code, signal) => {
|
|
40982
|
+
if (shouldRetryInSoftware({
|
|
40983
|
+
usedHardware,
|
|
40984
|
+
hardwareAlreadyFailed,
|
|
40985
|
+
stopRequestedByController: session.stopRequestedByController,
|
|
40986
|
+
videoPacketsForwarded: session.videoPacketsForwarded,
|
|
40987
|
+
runtimeMs: Date.now() - spawnedAtMs
|
|
40988
|
+
})) {
|
|
40989
|
+
hardwareAlreadyFailed = true;
|
|
40990
|
+
log.warn("export-hap: hardware decode FAILED at init — respawning ffmpeg in software", { meta: {
|
|
40991
|
+
sessionId,
|
|
40992
|
+
backend: decision.kind === "hardware" ? decision.backend : null,
|
|
40993
|
+
backendSource: decision.kind === "hardware" ? decision.source : null,
|
|
40994
|
+
code,
|
|
40995
|
+
signal,
|
|
40996
|
+
runtimeMs: Date.now() - spawnedAtMs
|
|
40997
|
+
} });
|
|
40998
|
+
if (session.ffmpeg === proc) session.ffmpeg = null;
|
|
40999
|
+
spawnFfmpeg({
|
|
41000
|
+
kind: "software",
|
|
41001
|
+
reason: "hardware-attempt-failed",
|
|
41002
|
+
args: []
|
|
41003
|
+
});
|
|
41004
|
+
return;
|
|
41005
|
+
}
|
|
41006
|
+
onFfmpegExit(proc, code, signal);
|
|
41007
|
+
});
|
|
41008
|
+
proc.once("error", (err) => {
|
|
41009
|
+
log.warn("export-hap: ffmpeg spawn failed", { meta: {
|
|
41010
|
+
sessionId,
|
|
41011
|
+
error: err.message
|
|
41012
|
+
} });
|
|
41013
|
+
});
|
|
41014
|
+
};
|
|
41015
|
+
const onFfmpegExit = (proc, code, signal) => {
|
|
41016
|
+
session.ffmpegExit = {
|
|
41017
|
+
code,
|
|
41018
|
+
signal
|
|
41019
|
+
};
|
|
38839
41020
|
const ok = code === 0 && signal === null;
|
|
38840
41021
|
const meta = {
|
|
38841
41022
|
sessionId,
|
|
38842
41023
|
code,
|
|
38843
|
-
signal
|
|
41024
|
+
signal,
|
|
41025
|
+
stopRequestedByController: session.stopRequestedByController,
|
|
41026
|
+
videoPacketsForwarded: session.videoPacketsForwarded
|
|
38844
41027
|
};
|
|
38845
|
-
if (ok) log.info("export-hap: ffmpeg exited", { meta });
|
|
38846
|
-
else log.warn("export-hap: ffmpeg exited
|
|
41028
|
+
if (ok || session.stopRequestedByController) log.info("export-hap: ffmpeg exited", { meta });
|
|
41029
|
+
else log.warn("export-hap: ffmpeg exited without a controller stop", { meta });
|
|
38847
41030
|
if (session.ffmpeg === proc) session.ffmpeg = null;
|
|
38848
|
-
|
|
38849
|
-
|
|
38850
|
-
|
|
38851
|
-
sessionId,
|
|
38852
|
-
error: err.message
|
|
38853
|
-
} });
|
|
38854
|
-
});
|
|
41031
|
+
logSessionSummary(session, log, session.teardownTrigger ?? "ffmpeg-exit");
|
|
41032
|
+
};
|
|
41033
|
+
spawnFfmpeg(hwDecode);
|
|
38855
41034
|
log.info("export-hap: stream started", { meta: {
|
|
38856
41035
|
sessionId,
|
|
38857
41036
|
transcode: needsTranscode,
|
|
38858
|
-
|
|
41037
|
+
brokerId: picked.brokerId,
|
|
41038
|
+
profile: pickedProfile,
|
|
41039
|
+
sourceResolution: picked.resolution === void 0 ? null : `${picked.resolution.width}x${picked.resolution.height}`,
|
|
41040
|
+
sourceCodec: codec.length > 0 ? codec : "unknown",
|
|
41041
|
+
negotiated: `${video.width}x${video.height}@${video.fps} ${video.max_bit_rate}kbps`,
|
|
41042
|
+
slotFps: advertisedFps,
|
|
41043
|
+
slotFpsSource: advertisedFpsSource,
|
|
41044
|
+
deliveredFps,
|
|
41045
|
+
fitReason: fit.reason,
|
|
41046
|
+
encodeBudgetKbps: fit.budgetKbps,
|
|
38859
41047
|
audioCodec: "opus",
|
|
38860
|
-
audioBitrateKbps:
|
|
41048
|
+
audioBitrateKbps: 24,
|
|
41049
|
+
videoDecode: hwDecode.kind === "hardware" ? hwDecode.backend : "software"
|
|
41050
|
+
} });
|
|
41051
|
+
}
|
|
41052
|
+
/**
|
|
41053
|
+
* Say which decode path was resolved, and WHY, once per session.
|
|
41054
|
+
*
|
|
41055
|
+
* At `info` on purpose — Loki carries `info+`, and "did this session use the
|
|
41056
|
+
* GPU" is the first question anyone asks of a transcoding hub. The software
|
|
41057
|
+
* branch is the one that must never be silent: it is a real cost being paid,
|
|
41058
|
+
* and every reason it can be reached is a different fix.
|
|
41059
|
+
*/
|
|
41060
|
+
function logDecodePath(log, sessionId, decision, transcode) {
|
|
41061
|
+
if (decision.kind === "hardware") {
|
|
41062
|
+
log.info("export-hap: video decode path resolved — HARDWARE", { meta: {
|
|
41063
|
+
sessionId,
|
|
41064
|
+
backend: decision.backend,
|
|
41065
|
+
backendSource: decision.source,
|
|
41066
|
+
decodeArgs: decision.args
|
|
41067
|
+
} });
|
|
41068
|
+
return;
|
|
41069
|
+
}
|
|
41070
|
+
const level = transcode ? "warn" : "info";
|
|
41071
|
+
const message = level === "warn" ? "export-hap: video decode path resolved — SOFTWARE, this transcode costs a core" : "export-hap: video decode path resolved — none needed";
|
|
41072
|
+
log[level](message, { meta: {
|
|
41073
|
+
sessionId,
|
|
41074
|
+
reason: decision.reason,
|
|
41075
|
+
transcode
|
|
38861
41076
|
} });
|
|
38862
41077
|
}
|
|
38863
41078
|
/**
|
|
41079
|
+
* `PickedStream.profileId` is the brokerId suffix; for a profile-keyed entry
|
|
41080
|
+
* that IS the profile name. Anything else addresses a raw cam-stream and must
|
|
41081
|
+
* not be coerced into a profile.
|
|
41082
|
+
*/
|
|
41083
|
+
function toKnownProfile(profileId) {
|
|
41084
|
+
if (profileId === "high" || profileId === "mid" || profileId === "low") return profileId;
|
|
41085
|
+
return null;
|
|
41086
|
+
}
|
|
41087
|
+
/**
|
|
38864
41088
|
* Handle one incoming SRTP audio packet from iOS Home.
|
|
38865
41089
|
*
|
|
38866
41090
|
* End-to-end flow:
|
|
@@ -38881,15 +41105,22 @@ async function startFfmpegForSession(bctx, session, sessionId, video) {
|
|
|
38881
41105
|
* stays codec-agnostic.
|
|
38882
41106
|
*/
|
|
38883
41107
|
async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
|
|
38884
|
-
if (packet.length < 12)
|
|
41108
|
+
if (packet.length < 12) {
|
|
41109
|
+
drop(session, "upstream-short-packet");
|
|
41110
|
+
return;
|
|
41111
|
+
}
|
|
38885
41112
|
const log = bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId });
|
|
38886
41113
|
session.upstreamRtpPacketsReceived += 1;
|
|
38887
|
-
if (!session.upstreamAudioSrtp)
|
|
41114
|
+
if (!session.upstreamAudioSrtp) {
|
|
41115
|
+
drop(session, "upstream-no-srtp");
|
|
41116
|
+
return;
|
|
41117
|
+
}
|
|
38888
41118
|
let decryptedPacket;
|
|
38889
41119
|
try {
|
|
38890
41120
|
decryptedPacket = session.upstreamAudioSrtp.decrypt(packet);
|
|
38891
41121
|
session.upstreamRtpPacketsDecrypted += 1;
|
|
38892
41122
|
} catch (err) {
|
|
41123
|
+
drop(session, "upstream-decrypt-failed");
|
|
38893
41124
|
session.upstreamRtpDecryptFailures += 1;
|
|
38894
41125
|
if (session.upstreamRtpDecryptFailures % 100 === 1) log.debug("export-hap: SRTP decrypt failed (rate-limited)", { meta: {
|
|
38895
41126
|
failures: session.upstreamRtpDecryptFailures,
|
|
@@ -38906,18 +41137,28 @@ async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
|
|
|
38906
41137
|
rtpTimestamp = parsedRtp.header.timestamp;
|
|
38907
41138
|
rtpPayloadType = parsedRtp.header.payloadType;
|
|
38908
41139
|
} catch (err) {
|
|
41140
|
+
drop(session, "upstream-parse-failed");
|
|
38909
41141
|
log.debug("export-hap: RTP parse failed after decrypt", { meta: { error: errMsg$8(err) } });
|
|
38910
41142
|
return;
|
|
38911
41143
|
}
|
|
38912
41144
|
const negotiatedAudioPt = session.lastStartParams?.audioPt;
|
|
38913
|
-
if (negotiatedAudioPt === void 0)
|
|
38914
|
-
|
|
41145
|
+
if (negotiatedAudioPt === void 0) {
|
|
41146
|
+
drop(session, "upstream-before-start");
|
|
41147
|
+
return;
|
|
41148
|
+
}
|
|
41149
|
+
if (rtpPayloadType !== negotiatedAudioPt) {
|
|
41150
|
+
drop(session, "upstream-pt-mismatch");
|
|
41151
|
+
return;
|
|
41152
|
+
}
|
|
38915
41153
|
if (session.firstUpstreamRtpTimestamp === null) {
|
|
38916
41154
|
session.firstUpstreamRtpTimestamp = rtpTimestamp;
|
|
38917
41155
|
log.info("export-hap: first upstream RTP packet received");
|
|
38918
41156
|
}
|
|
38919
41157
|
const payload = decryptedPayload;
|
|
38920
|
-
if (payload.length === 0)
|
|
41158
|
+
if (payload.length === 0) {
|
|
41159
|
+
drop(session, "upstream-empty-payload");
|
|
41160
|
+
return;
|
|
41161
|
+
}
|
|
38921
41162
|
if (session.intercomTalkSessionId === null) {
|
|
38922
41163
|
session.intercomTalkSessionId = "";
|
|
38923
41164
|
const opened = await openIntercomTalkSession(bctx).catch((err) => {
|
|
@@ -38929,7 +41170,10 @@ async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
|
|
|
38929
41170
|
log.info("export-hap: intercom talk session opened", { meta: { sessionId: opened.sessionId } });
|
|
38930
41171
|
}
|
|
38931
41172
|
}
|
|
38932
|
-
if (!session.intercomTalkSessionId)
|
|
41173
|
+
if (!session.intercomTalkSessionId) {
|
|
41174
|
+
drop(session, "upstream-no-talk-session");
|
|
41175
|
+
return;
|
|
41176
|
+
}
|
|
38933
41177
|
const opusSampleRateHz = (session.lastStartParams?.audioSampleRateEnum ?? 16) * 1e3;
|
|
38934
41178
|
session.intercomPcmSequence += 1;
|
|
38935
41179
|
session.upstreamPcmFramesDecoded += 1;
|
|
@@ -38942,6 +41186,7 @@ async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
|
|
|
38942
41186
|
sequenceNumber: session.intercomPcmSequence
|
|
38943
41187
|
});
|
|
38944
41188
|
} catch (err) {
|
|
41189
|
+
drop(session, "upstream-push-failed");
|
|
38945
41190
|
log.debug("export-hap: intercom.pushTalkAudio failed (will re-open on next frame)", { meta: {
|
|
38946
41191
|
sequenceNumber: session.intercomPcmSequence,
|
|
38947
41192
|
error: errMsg$8(err)
|
|
@@ -39025,140 +41270,6 @@ function errMsg$7(err) {
|
|
|
39025
41270
|
return err instanceof Error ? err.message : String(err);
|
|
39026
41271
|
}
|
|
39027
41272
|
//#endregion
|
|
39028
|
-
//#region src/mappers/builders/hksv.ts
|
|
39029
|
-
/**
|
|
39030
|
-
* HomeKit Secure Video (HKSV) — stub recording delegate.
|
|
39031
|
-
*
|
|
39032
|
-
* Wires the camera as HKSV-capable so iOS Home shows the "Activity"
|
|
39033
|
-
* tab, the per-camera recording settings UI ("Stream and Allow
|
|
39034
|
-
* Recording" / "Off"), and notifications. The delegate stub:
|
|
39035
|
-
* - logs every protocol callback (active toggle, config change,
|
|
39036
|
-
* stream request, close);
|
|
39037
|
-
* - returns from `handleRecordingStreamRequest` immediately without
|
|
39038
|
-
* yielding any fragment — iOS sees "no recording available" but
|
|
39039
|
-
* the camera otherwise behaves like an HKSV camera in the UI.
|
|
39040
|
-
*
|
|
39041
|
-
* Why a stub: a real implementation needs an fMP4 segmenter that runs
|
|
39042
|
-
* a per-camera ffmpeg with a sustained pre-buffer, key/IV-derived
|
|
39043
|
-
* encrypter, motion-anchored fragment alignment, and storage for the
|
|
39044
|
-
* resulting clips. That work belongs behind the `recording` cap
|
|
39045
|
-
* (recording + playback-manifest surface) once it grows fragment export,
|
|
39046
|
-
* so the segmenter lives in the recorder addon and HKSV becomes a thin
|
|
39047
|
-
* wrapper that asks the cap for "fragments since T" and streams them to iOS.
|
|
39048
|
-
*
|
|
39049
|
-
* Until that cap lands this stub keeps HKSV characteristics visible
|
|
39050
|
-
* (a) so the operator can flip "Allow Recording" without HomeKit
|
|
39051
|
-
* complaining the camera lacks the service, and (b) so the
|
|
39052
|
-
* `CameraController` advertises a `RecordingManagement` service for
|
|
39053
|
-
* iOS-side analytics + Activity-tab UX scaffolding.
|
|
39054
|
-
*
|
|
39055
|
-
* Constructor returns a `{ options, delegate }` pair the caller
|
|
39056
|
-
* passes verbatim into `new CameraController({ ..., recording })`.
|
|
39057
|
-
*/
|
|
39058
|
-
var RECORDING_OPTIONS = {
|
|
39059
|
-
prebufferLength: 4e3,
|
|
39060
|
-
mediaContainerConfiguration: [{
|
|
39061
|
-
type: MediaContainerType.FRAGMENTED_MP4,
|
|
39062
|
-
fragmentLength: 4e3
|
|
39063
|
-
}],
|
|
39064
|
-
video: {
|
|
39065
|
-
type: VideoCodecType.H264,
|
|
39066
|
-
parameters: {
|
|
39067
|
-
profiles: [
|
|
39068
|
-
H264Profile.BASELINE,
|
|
39069
|
-
H264Profile.MAIN,
|
|
39070
|
-
H264Profile.HIGH
|
|
39071
|
-
],
|
|
39072
|
-
levels: [
|
|
39073
|
-
H264Level.LEVEL3_1,
|
|
39074
|
-
H264Level.LEVEL3_2,
|
|
39075
|
-
H264Level.LEVEL4_0
|
|
39076
|
-
]
|
|
39077
|
-
},
|
|
39078
|
-
resolutions: [
|
|
39079
|
-
[
|
|
39080
|
-
1920,
|
|
39081
|
-
1080,
|
|
39082
|
-
30
|
|
39083
|
-
],
|
|
39084
|
-
[
|
|
39085
|
-
1920,
|
|
39086
|
-
1080,
|
|
39087
|
-
24
|
|
39088
|
-
],
|
|
39089
|
-
[
|
|
39090
|
-
1920,
|
|
39091
|
-
1080,
|
|
39092
|
-
15
|
|
39093
|
-
],
|
|
39094
|
-
[
|
|
39095
|
-
1280,
|
|
39096
|
-
720,
|
|
39097
|
-
30
|
|
39098
|
-
],
|
|
39099
|
-
[
|
|
39100
|
-
1280,
|
|
39101
|
-
720,
|
|
39102
|
-
24
|
|
39103
|
-
],
|
|
39104
|
-
[
|
|
39105
|
-
1280,
|
|
39106
|
-
720,
|
|
39107
|
-
15
|
|
39108
|
-
]
|
|
39109
|
-
]
|
|
39110
|
-
},
|
|
39111
|
-
audio: { codecs: [{
|
|
39112
|
-
type: AudioRecordingCodecType.AAC_LC,
|
|
39113
|
-
bitrateMode: AudioBitrate.VARIABLE,
|
|
39114
|
-
audioChannels: 1,
|
|
39115
|
-
samplerate: [AudioRecordingSamplerate.KHZ_16, AudioRecordingSamplerate.KHZ_24]
|
|
39116
|
-
}] },
|
|
39117
|
-
overrideEventTriggerOptions: [EventTriggerOption.MOTION]
|
|
39118
|
-
};
|
|
39119
|
-
function buildHksvStub(bctx) {
|
|
39120
|
-
const { ctx, numericDeviceId } = bctx;
|
|
39121
|
-
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
39122
|
-
let activeStreamId = null;
|
|
39123
|
-
return {
|
|
39124
|
-
options: RECORDING_OPTIONS,
|
|
39125
|
-
delegate: {
|
|
39126
|
-
updateRecordingActive(active) {
|
|
39127
|
-
log.info("export-hap: HKSV recording active changed", { meta: { active } });
|
|
39128
|
-
},
|
|
39129
|
-
updateRecordingConfiguration(configuration) {
|
|
39130
|
-
if (!configuration) {
|
|
39131
|
-
log.info("export-hap: HKSV recording configuration cleared");
|
|
39132
|
-
return;
|
|
39133
|
-
}
|
|
39134
|
-
log.info("export-hap: HKSV recording configuration applied", { meta: {
|
|
39135
|
-
prebufferLength: configuration.prebufferLength,
|
|
39136
|
-
fragmentLength: configuration.mediaContainerConfiguration.fragmentLength,
|
|
39137
|
-
videoResolution: `${configuration.videoCodec.resolution[0]}x${configuration.videoCodec.resolution[1]}@${configuration.videoCodec.resolution[2]}`,
|
|
39138
|
-
videoBitrate: configuration.videoCodec.parameters.bitRate,
|
|
39139
|
-
eventTriggers: configuration.eventTriggerTypes
|
|
39140
|
-
} });
|
|
39141
|
-
},
|
|
39142
|
-
async *handleRecordingStreamRequest(streamId, _signal) {
|
|
39143
|
-
activeStreamId = streamId;
|
|
39144
|
-
log.warn("export-hap: HKSV stream requested — stub returns empty (no clip source yet)", { meta: { streamId } });
|
|
39145
|
-
},
|
|
39146
|
-
acknowledgeStream(streamId) {
|
|
39147
|
-
if (activeStreamId === streamId) activeStreamId = null;
|
|
39148
|
-
log.debug("export-hap: HKSV stream acknowledged", { meta: { streamId } });
|
|
39149
|
-
},
|
|
39150
|
-
closeRecordingStream(streamId, reason) {
|
|
39151
|
-
if (activeStreamId === streamId) activeStreamId = null;
|
|
39152
|
-
log.info("export-hap: HKSV stream closed", { meta: {
|
|
39153
|
-
streamId,
|
|
39154
|
-
reason: reason ?? null
|
|
39155
|
-
} });
|
|
39156
|
-
}
|
|
39157
|
-
},
|
|
39158
|
-
handle: { async dispose() {} }
|
|
39159
|
-
};
|
|
39160
|
-
}
|
|
39161
|
-
//#endregion
|
|
39162
41273
|
//#region src/mappers/builders/intercom.ts
|
|
39163
41274
|
async function buildIntercom(input) {
|
|
39164
41275
|
const { bctx, streamingOptions } = input;
|
|
@@ -39186,7 +41297,7 @@ async function buildIntercom(input) {
|
|
|
39186
41297
|
var RESET_DEBOUNCE_MS = 5e3;
|
|
39187
41298
|
async function buildMotionSensor(bctx) {
|
|
39188
41299
|
const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
|
|
39189
|
-
const motionService = accessory.addService(Service.MotionSensor, displayName);
|
|
41300
|
+
const motionService = accessory.addService(Service.MotionSensor, hapServiceName([displayName], `Camera ${numericDeviceId}`));
|
|
39190
41301
|
motionService.setCharacteristic(Characteristic.MotionDetected, false);
|
|
39191
41302
|
try {
|
|
39192
41303
|
const detected = await proxy.motion?.isDetected({});
|
|
@@ -39241,12 +41352,12 @@ function errMsg$6(err) {
|
|
|
39241
41352
|
* camera-enabled switch — distinct from privacy-mask).
|
|
39242
41353
|
*/
|
|
39243
41354
|
async function buildPrivacySwitch(bctx) {
|
|
39244
|
-
const { ctx, accessory, proxy, numericDeviceId } = bctx;
|
|
41355
|
+
const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
|
|
39245
41356
|
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
39246
41357
|
const subtype = "privacy-mask";
|
|
39247
|
-
const
|
|
39248
|
-
const service = accessory.addService(Service.Switch,
|
|
39249
|
-
service.setCharacteristic(Characteristic.
|
|
41358
|
+
const serviceName = privacyServiceName(displayName);
|
|
41359
|
+
const service = accessory.addService(Service.Switch, serviceName, subtype);
|
|
41360
|
+
service.setCharacteristic(Characteristic.Name, serviceName);
|
|
39250
41361
|
try {
|
|
39251
41362
|
const status = await proxy.privacyMask?.getStatus({});
|
|
39252
41363
|
if (status && typeof status.enabled === "boolean") service.updateCharacteristic(Characteristic.On, status.enabled);
|
|
@@ -39336,18 +41447,23 @@ function ptzPresetLabel(presetName) {
|
|
|
39336
41447
|
* `proxy.ptzAutotrack.setEnabled({enabled})`. Initial value is
|
|
39337
41448
|
* hydrated from `getStatus({})`.
|
|
39338
41449
|
*
|
|
39339
|
-
* Naming:
|
|
39340
|
-
*
|
|
39341
|
-
*
|
|
39342
|
-
*
|
|
39343
|
-
*
|
|
39344
|
-
*
|
|
39345
|
-
*
|
|
39346
|
-
* "Interruttore N".
|
|
41450
|
+
* Naming: `<camera> <action>` — "Videocamera ingresso Preset stanza" — built
|
|
41451
|
+
* by `ptzServiceName` and written to the `Name` characteristic.
|
|
41452
|
+
*
|
|
41453
|
+
* TWO separate naming defects have been through this file, and both are fixed
|
|
41454
|
+
* by that one line:
|
|
41455
|
+
* - `${displayName} — <action>` embedded an em-dash (U+2014), which is
|
|
41456
|
+
* outside Apple's permitted set. hap-nodejs' `checkName` warned, iOS
|
|
41457
|
+
* discarded the name and showed "Interruttore N". A previous round dropped
|
|
41458
|
+
* the camera prefix along with the em-dash; only the em-dash was the fault.
|
|
41459
|
+
* - The bare label that replaced it was then written to `ConfiguredName`,
|
|
41460
|
+
* which `Service.Switch` does not list, so hap-nodejs rejected the
|
|
41461
|
+
* characteristic outright — SIX rejections per PTZ camera per build, never
|
|
41462
|
+
* reported because only the two switches on the non-PTZ camera were noticed.
|
|
39347
41463
|
*/
|
|
39348
41464
|
var MOMENTARY_RESET_MS = 1e3;
|
|
39349
41465
|
async function buildPtz(bctx) {
|
|
39350
|
-
const { ctx, accessory, proxy, numericDeviceId, options } = bctx;
|
|
41466
|
+
const { ctx, accessory, proxy, numericDeviceId, displayName, options } = bctx;
|
|
39351
41467
|
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
39352
41468
|
const timers = /* @__PURE__ */ new Set();
|
|
39353
41469
|
const armReset = (cb, delay) => {
|
|
@@ -39359,10 +41475,10 @@ async function buildPtz(bctx) {
|
|
|
39359
41475
|
};
|
|
39360
41476
|
const presets = await readPresets(bctx);
|
|
39361
41477
|
for (const preset of presets) {
|
|
39362
|
-
const label = ptzPresetLabel(preset.name);
|
|
41478
|
+
const label = ptzServiceName(displayName, ptzPresetLabel(preset.name));
|
|
39363
41479
|
const subtype = `ptz-preset-${preset.id}`;
|
|
39364
41480
|
const service = accessory.addService(Service.Switch, label, subtype);
|
|
39365
|
-
service.setCharacteristic(Characteristic.
|
|
41481
|
+
service.setCharacteristic(Characteristic.Name, label);
|
|
39366
41482
|
service.getCharacteristic(Characteristic.On).onSet(async (value) => {
|
|
39367
41483
|
if (value !== true) return;
|
|
39368
41484
|
try {
|
|
@@ -39377,9 +41493,9 @@ async function buildPtz(bctx) {
|
|
|
39377
41493
|
});
|
|
39378
41494
|
}
|
|
39379
41495
|
if (proxy.ptz) for (const dir of PTZ_DIRECTIONS) {
|
|
39380
|
-
const label = dir.label;
|
|
41496
|
+
const label = ptzServiceName(displayName, dir.label);
|
|
39381
41497
|
const service = accessory.addService(Service.Switch, label, dir.subtype);
|
|
39382
|
-
service.setCharacteristic(Characteristic.
|
|
41498
|
+
service.setCharacteristic(Characteristic.Name, label);
|
|
39383
41499
|
service.getCharacteristic(Characteristic.On).onSet(async (value) => {
|
|
39384
41500
|
if (value !== true) return;
|
|
39385
41501
|
try {
|
|
@@ -39423,12 +41539,12 @@ async function readPresets(bctx) {
|
|
|
39423
41539
|
}
|
|
39424
41540
|
}
|
|
39425
41541
|
async function tryBuildAutotrack(bctx) {
|
|
39426
|
-
const { ctx, accessory, proxy, numericDeviceId } = bctx;
|
|
41542
|
+
const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
|
|
39427
41543
|
if (!proxy.ptzAutotrack) return { async dispose() {} };
|
|
39428
41544
|
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
39429
|
-
const label = PTZ_AUTOTRACK_LABEL;
|
|
41545
|
+
const label = ptzServiceName(displayName, PTZ_AUTOTRACK_LABEL);
|
|
39430
41546
|
const service = accessory.addService(Service.Switch, label, "ptz-autotrack");
|
|
39431
|
-
service.setCharacteristic(Characteristic.
|
|
41547
|
+
service.setCharacteristic(Characteristic.Name, label);
|
|
39432
41548
|
try {
|
|
39433
41549
|
const status = await proxy.ptzAutotrack.getStatus({});
|
|
39434
41550
|
if (status && typeof status.enabled === "boolean") service.updateCharacteristic(Characteristic.On, status.enabled);
|
|
@@ -39452,6 +41568,65 @@ function errMsg$4(err) {
|
|
|
39452
41568
|
return err instanceof Error ? err.message : String(err);
|
|
39453
41569
|
}
|
|
39454
41570
|
//#endregion
|
|
41571
|
+
//#region src/mappers/builders/stream-fps-probe.ts
|
|
41572
|
+
async function probeAdvertisedVideoProfile(bctx) {
|
|
41573
|
+
const { ctx, proxy, numericDeviceId, options } = bctx;
|
|
41574
|
+
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
41575
|
+
const entries = await probe(() => proxy.cameraStreams?.getProfileRtspEntries({}), "cameraStreams.getProfileRtspEntries", log);
|
|
41576
|
+
const slots = await probe(() => proxy.cameraStreams?.getBrokerStreams({}), "cameraStreams.getBrokerStreams", log);
|
|
41577
|
+
const camStreams = await probe(() => proxy.cameraStreams?.getCameraStreams({}), "cameraStreams.getCameraStreams", log);
|
|
41578
|
+
const choices = await probe(() => proxy.webrtcSession?.listStreams({}), "webrtcSession.listStreams", log);
|
|
41579
|
+
const fpsByProfile = resolveProfileFps({
|
|
41580
|
+
choices: choices ?? [],
|
|
41581
|
+
slots: slots ?? [],
|
|
41582
|
+
camStreams: camStreams ?? []
|
|
41583
|
+
});
|
|
41584
|
+
const resolutions = deriveAdvertisedResolutions({
|
|
41585
|
+
candidates: CANDIDATE_RESOLUTIONS,
|
|
41586
|
+
entries: entries ?? [],
|
|
41587
|
+
deviceId: numericDeviceId,
|
|
41588
|
+
pref: options.hapDeviceSettings.streamPreference,
|
|
41589
|
+
fpsByProfile
|
|
41590
|
+
});
|
|
41591
|
+
const assumed = resolutions.filter((r) => r.source === "assumed").length;
|
|
41592
|
+
log.info("export-hap: advertised video profile derived", { meta: {
|
|
41593
|
+
streamPreference: options.hapDeviceSettings.streamPreference,
|
|
41594
|
+
resolutions: formatAdvertisedResolutions(resolutions),
|
|
41595
|
+
profileFps: [...fpsByProfile.values()].map((f) => `${f.profile}=${f.fps}(${f.source})`),
|
|
41596
|
+
assumedCount: assumed,
|
|
41597
|
+
previouslyAdvertisedFps: 30
|
|
41598
|
+
} });
|
|
41599
|
+
if (assumed === resolutions.length) log.warn("export-hap: no measured or published frame rate for ANY profile — advertising the assumed rate", { meta: {
|
|
41600
|
+
entries: entries?.length ?? 0,
|
|
41601
|
+
choices: choices?.length ?? 0
|
|
41602
|
+
} });
|
|
41603
|
+
return {
|
|
41604
|
+
resolutions,
|
|
41605
|
+
fpsByProfile
|
|
41606
|
+
};
|
|
41607
|
+
}
|
|
41608
|
+
/**
|
|
41609
|
+
* Run one cap read. Returns null both when the cap is not bound and when the
|
|
41610
|
+
* call throws — and LOGS which, because "the camera has no telemetry" and "the
|
|
41611
|
+
* telemetry call failed" lead to different fixes.
|
|
41612
|
+
*/
|
|
41613
|
+
async function probe(call, label, log) {
|
|
41614
|
+
try {
|
|
41615
|
+
const pending = call();
|
|
41616
|
+
if (pending === void 0) {
|
|
41617
|
+
log.info("export-hap: fps probe skipped — cap not bound on this device", { meta: { call: label } });
|
|
41618
|
+
return null;
|
|
41619
|
+
}
|
|
41620
|
+
return await pending;
|
|
41621
|
+
} catch (err) {
|
|
41622
|
+
log.warn("export-hap: fps probe failed — falling back to a lower-authority source", { meta: {
|
|
41623
|
+
call: label,
|
|
41624
|
+
error: err instanceof Error ? err.message : String(err)
|
|
41625
|
+
} });
|
|
41626
|
+
return null;
|
|
41627
|
+
}
|
|
41628
|
+
}
|
|
41629
|
+
//#endregion
|
|
39455
41630
|
//#region src/mappers/builders/child-switch.ts
|
|
39456
41631
|
/**
|
|
39457
41632
|
* Child-switch builder — turns a camstack accessory child device (siren,
|
|
@@ -39496,7 +41671,7 @@ async function buildChildSwitch(bctx, subtype, deviceType) {
|
|
|
39496
41671
|
const isLightingDevice = deviceType === DeviceType.Light || deviceType === DeviceType.Generic;
|
|
39497
41672
|
const useLightbulb = hasBrightness && isLightingDevice;
|
|
39498
41673
|
const service = useLightbulb ? accessory.addService(Service.Lightbulb, displayName, subtype) : accessory.addService(Service.Switch, displayName, subtype);
|
|
39499
|
-
service.setCharacteristic(Characteristic.
|
|
41674
|
+
service.setCharacteristic(Characteristic.Name, displayName);
|
|
39500
41675
|
try {
|
|
39501
41676
|
const switchStatus = await proxy.switch?.getStatus({});
|
|
39502
41677
|
if (switchStatus && typeof switchStatus.on === "boolean") service.updateCharacteristic(Characteristic.On, switchStatus.on);
|
|
@@ -39568,7 +41743,7 @@ async function buildChildServicesFor(input) {
|
|
|
39568
41743
|
accessory: parentCtx.accessory,
|
|
39569
41744
|
proxy: childProxy,
|
|
39570
41745
|
numericDeviceId: child.id,
|
|
39571
|
-
displayName:
|
|
41746
|
+
displayName: childServiceName(parentDisplayName, child),
|
|
39572
41747
|
options
|
|
39573
41748
|
};
|
|
39574
41749
|
const subtype = `child-${child.id}`;
|
|
@@ -39599,21 +41774,6 @@ async function listChildren(ctx, parentNumericId) {
|
|
|
39599
41774
|
}
|
|
39600
41775
|
}
|
|
39601
41776
|
/**
|
|
39602
|
-
* Service name displayed inside the camera tile detail in iOS Home.
|
|
39603
|
-
* Prefer the child's own role ("Siren", "Floodlight") when meaningful
|
|
39604
|
-
* — the camera name is already implied by the surrounding Accessory.
|
|
39605
|
-
* Falls back to the child's stored device name when role is empty.
|
|
39606
|
-
*/
|
|
39607
|
-
function formatChildServiceName(parentName, child) {
|
|
39608
|
-
const role = child.role && child.role.length > 0 ? toTitleCase(child.role) : null;
|
|
39609
|
-
if (role) return role;
|
|
39610
|
-
if (child.name.toLowerCase().includes(parentName.toLowerCase())) {
|
|
39611
|
-
const stripped = child.name.replace(new RegExp(`\\b${escapeRegex(parentName)}\\b`, "i"), "").replace(/\s+[—-]\s+/, " ").trim();
|
|
39612
|
-
if (stripped.length > 0) return stripped;
|
|
39613
|
-
}
|
|
39614
|
-
return child.name;
|
|
39615
|
-
}
|
|
39616
|
-
/**
|
|
39617
41777
|
* Coerce the raw `child.type` string (from `deviceManager.getChildren`)
|
|
39618
41778
|
* to a `DeviceType` enum value. Unknown / mis-cased values fall back to
|
|
39619
41779
|
* `Generic` so an unrecognised driver behaves like the safest existing
|
|
@@ -39625,12 +41785,6 @@ function asDeviceType(raw) {
|
|
|
39625
41785
|
for (const value of Object.values(DeviceType)) if (value === lower) return value;
|
|
39626
41786
|
return DeviceType.Generic;
|
|
39627
41787
|
}
|
|
39628
|
-
function escapeRegex(s) {
|
|
39629
|
-
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
39630
|
-
}
|
|
39631
|
-
function toTitleCase(raw) {
|
|
39632
|
-
return raw.split("-").filter((part) => part.length > 0).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
|
|
39633
|
-
}
|
|
39634
41788
|
function errMsg$2(err) {
|
|
39635
41789
|
return err instanceof Error ? err.message : String(err);
|
|
39636
41790
|
}
|
|
@@ -39678,24 +41832,18 @@ async function buildCameraAccessory(input) {
|
|
|
39678
41832
|
displayName,
|
|
39679
41833
|
options
|
|
39680
41834
|
};
|
|
39681
|
-
const streams = buildCameraStreamingDelegate(bctx);
|
|
41835
|
+
const streams = buildCameraStreamingDelegate(bctx, await probeAdvertisedVideoProfile(bctx));
|
|
39682
41836
|
const handles = [];
|
|
39683
41837
|
if (capNames.has("intercom")) handles.push(await buildIntercom({
|
|
39684
41838
|
bctx,
|
|
39685
41839
|
streamingOptions: streams.streamingOptions
|
|
39686
41840
|
}));
|
|
39687
|
-
const hksv = capNames.has("motion-detection") ? buildHksvStub(bctx) : null;
|
|
39688
41841
|
const controller = new (isDoorbell ? DoorbellController : CameraController)({
|
|
39689
41842
|
delegate: streams.delegate,
|
|
39690
41843
|
streamingOptions: streams.streamingOptions,
|
|
39691
|
-
cameraStreamCount: 2
|
|
39692
|
-
...hksv ? { recording: {
|
|
39693
|
-
options: hksv.options,
|
|
39694
|
-
delegate: hksv.delegate
|
|
39695
|
-
} } : {}
|
|
41844
|
+
cameraStreamCount: 2
|
|
39696
41845
|
});
|
|
39697
41846
|
accessory.configureController(controller);
|
|
39698
|
-
if (hksv) handles.push(hksv.handle);
|
|
39699
41847
|
if (capNames.has("motion-detection")) handles.push(await buildMotionSensor(bctx));
|
|
39700
41848
|
if (isDoorbell && controller instanceof DoorbellController) handles.push(await buildDoorbell({
|
|
39701
41849
|
bctx,
|