@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
package/dist/export-hap.addon.js
CHANGED
|
@@ -799,7 +799,7 @@ var propertyKeyTypes = /* @__PURE__*/ new Set([
|
|
|
799
799
|
"number",
|
|
800
800
|
"symbol"
|
|
801
801
|
]);
|
|
802
|
-
function escapeRegex
|
|
802
|
+
function escapeRegex(str) {
|
|
803
803
|
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
804
804
|
}
|
|
805
805
|
function clone(inst, def, params) {
|
|
@@ -1529,7 +1529,7 @@ var $ZodCheckUpperCase = /*@__PURE__*/ $constructor("$ZodCheckUpperCase", (inst,
|
|
|
1529
1529
|
});
|
|
1530
1530
|
var $ZodCheckIncludes = /*@__PURE__*/ $constructor("$ZodCheckIncludes", (inst, def) => {
|
|
1531
1531
|
$ZodCheck.init(inst, def);
|
|
1532
|
-
const escapedRegex = escapeRegex
|
|
1532
|
+
const escapedRegex = escapeRegex(def.includes);
|
|
1533
1533
|
const pattern = new RegExp(typeof def.position === "number" ? `^.{${def.position}}${escapedRegex}` : escapedRegex);
|
|
1534
1534
|
def.pattern = pattern;
|
|
1535
1535
|
inst._zod.onattach.push((inst) => {
|
|
@@ -1552,7 +1552,7 @@ var $ZodCheckIncludes = /*@__PURE__*/ $constructor("$ZodCheckIncludes", (inst, d
|
|
|
1552
1552
|
});
|
|
1553
1553
|
var $ZodCheckStartsWith = /*@__PURE__*/ $constructor("$ZodCheckStartsWith", (inst, def) => {
|
|
1554
1554
|
$ZodCheck.init(inst, def);
|
|
1555
|
-
const pattern = new RegExp(`^${escapeRegex
|
|
1555
|
+
const pattern = new RegExp(`^${escapeRegex(def.prefix)}.*`);
|
|
1556
1556
|
def.pattern ?? (def.pattern = pattern);
|
|
1557
1557
|
inst._zod.onattach.push((inst) => {
|
|
1558
1558
|
const bag = inst._zod.bag;
|
|
@@ -1574,7 +1574,7 @@ var $ZodCheckStartsWith = /*@__PURE__*/ $constructor("$ZodCheckStartsWith", (ins
|
|
|
1574
1574
|
});
|
|
1575
1575
|
var $ZodCheckEndsWith = /*@__PURE__*/ $constructor("$ZodCheckEndsWith", (inst, def) => {
|
|
1576
1576
|
$ZodCheck.init(inst, def);
|
|
1577
|
-
const pattern = new RegExp(`.*${escapeRegex
|
|
1577
|
+
const pattern = new RegExp(`.*${escapeRegex(def.suffix)}$`);
|
|
1578
1578
|
def.pattern ?? (def.pattern = pattern);
|
|
1579
1579
|
inst._zod.onattach.push((inst) => {
|
|
1580
1580
|
const bag = inst._zod.bag;
|
|
@@ -2810,7 +2810,7 @@ var $ZodEnum = /*@__PURE__*/ $constructor("$ZodEnum", (inst, def) => {
|
|
|
2810
2810
|
const values = getEnumValues(def.entries);
|
|
2811
2811
|
const valuesSet = new Set(values);
|
|
2812
2812
|
inst._zod.values = valuesSet;
|
|
2813
|
-
inst._zod.pattern = new RegExp(`^(${values.filter((k) => propertyKeyTypes.has(typeof k)).map((o) => typeof o === "string" ? escapeRegex
|
|
2813
|
+
inst._zod.pattern = new RegExp(`^(${values.filter((k) => propertyKeyTypes.has(typeof k)).map((o) => typeof o === "string" ? escapeRegex(o) : o.toString()).join("|")})$`);
|
|
2814
2814
|
inst._zod.parse = (payload, _ctx) => {
|
|
2815
2815
|
const input = payload.value;
|
|
2816
2816
|
if (valuesSet.has(input)) return payload;
|
|
@@ -2828,7 +2828,7 @@ var $ZodLiteral = /*@__PURE__*/ $constructor("$ZodLiteral", (inst, def) => {
|
|
|
2828
2828
|
if (def.values.length === 0) throw new Error("Cannot create literal schema with no valid values");
|
|
2829
2829
|
const values = new Set(def.values);
|
|
2830
2830
|
inst._zod.values = values;
|
|
2831
|
-
inst._zod.pattern = new RegExp(`^(${def.values.map((o) => typeof o === "string" ? escapeRegex
|
|
2831
|
+
inst._zod.pattern = new RegExp(`^(${def.values.map((o) => typeof o === "string" ? escapeRegex(o) : o ? escapeRegex(o.toString()) : String(o)).join("|")})$`);
|
|
2832
2832
|
inst._zod.parse = (payload, _ctx) => {
|
|
2833
2833
|
const input = payload.value;
|
|
2834
2834
|
if (values.has(input)) return payload;
|
|
@@ -6521,6 +6521,36 @@ var ProfileRtspEntrySchema = object({
|
|
|
6521
6521
|
resolution: CamStreamResolutionSchema.optional()
|
|
6522
6522
|
});
|
|
6523
6523
|
/**
|
|
6524
|
+
* Per-call node pinning for `ctx.api` capability calls.
|
|
6525
|
+
*
|
|
6526
|
+
* A capability call normally resolves to its DEFAULT provider — a `singleton`
|
|
6527
|
+
* cap resolves to the hub, a device-scoped cap to the device's owning node. To
|
|
6528
|
+
* query a SPECIFIC node's provider instead (e.g. a remote agent's own
|
|
6529
|
+
* in-process `platform-probe` hardware, which the hub cannot probe), pin the
|
|
6530
|
+
* call to that node.
|
|
6531
|
+
*
|
|
6532
|
+
* The nodeId rides OUT-OF-BAND in the tRPC call context (NOT in the validated
|
|
6533
|
+
* method args), so capability method signatures stay `nodeId`-free — node
|
|
6534
|
+
* targeting is a property of the CALL, not of the method. The transport lifts
|
|
6535
|
+
* it from `op.context` onto the `CapCallInput.nodeId` field (`ipcParentLink`),
|
|
6536
|
+
* and the hub parent's `onUnownedCall` passes it to the `CapRouteResolver`,
|
|
6537
|
+
* which classifies a pinned agent node as `agent-child-forward`
|
|
6538
|
+
* (`$agent-cap-fwd.forward` → the agent's in-process provider).
|
|
6539
|
+
*
|
|
6540
|
+
* Usage at a call site:
|
|
6541
|
+
*
|
|
6542
|
+
* await api.platformProbe.getCapabilities.query(undefined, nodePin(nodeId))
|
|
6543
|
+
*/
|
|
6544
|
+
/** tRPC `op.context` key carrying a per-call node pin. */
|
|
6545
|
+
var CAP_NODE_PIN_CONTEXT_KEY = "__camstackNodePin";
|
|
6546
|
+
/**
|
|
6547
|
+
* Build the tRPC request options that pin a single capability call to `nodeId`.
|
|
6548
|
+
* Pass as the second argument to `.query(input, …)` / `.mutate(input, …)`.
|
|
6549
|
+
*/
|
|
6550
|
+
function nodePin(nodeId) {
|
|
6551
|
+
return { context: { [CAP_NODE_PIN_CONTEXT_KEY]: nodeId } };
|
|
6552
|
+
}
|
|
6553
|
+
/**
|
|
6524
6554
|
* Output schema shared by the contribution + live methods.
|
|
6525
6555
|
*
|
|
6526
6556
|
* Mirrors the `ConfigUISchemaWithValues` shape (sections[] + optional
|
|
@@ -6968,6 +6998,39 @@ method(object({ deviceId: number() }), array(StreamSourceEntrySchema)), method(o
|
|
|
6968
6998
|
action: string().min(1),
|
|
6969
6999
|
input: unknown()
|
|
6970
7000
|
}), unknown(), { kind: "mutation" }), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), unknown().nullable()), method(object({ deviceId: number() }), RawStateResultSchema.nullable(), { auth: "protected" });
|
|
7001
|
+
//#endregion
|
|
7002
|
+
//#region ../types/dist/canonical-hash-7nfBbEqR.mjs
|
|
7003
|
+
/**
|
|
7004
|
+
* Deterministic SHA-256 hash of an arbitrary serialisable value. The
|
|
7005
|
+
* canonical form sorts object keys alphabetically at every depth so two
|
|
7006
|
+
* structurally-equal inputs with different key insertion orders produce
|
|
7007
|
+
* the same hash. Returns a 64-char lowercase hex digest.
|
|
7008
|
+
*
|
|
7009
|
+
* Used by export adapters (Alexa, HAP) to short-circuit re-discovery /
|
|
7010
|
+
* accessory-rebuild work when the upstream shape is byte-identical to
|
|
7011
|
+
* the last applied state — preventing user-visible "re-discovery"
|
|
7012
|
+
* notifications on every addon-runner respawn. Each respawn re-fires
|
|
7013
|
+
* `DeviceBindingsChanged` for every cap registration, which without
|
|
7014
|
+
* this guard would propagate redundant pushes.
|
|
7015
|
+
*
|
|
7016
|
+
* Note: this is a SYMPTOMATIC fix layered on top of the binding-change
|
|
7017
|
+
* subscription. The proper fix is a single "device ready" lifecycle
|
|
7018
|
+
* barrier so exports react only when the full cap set has landed —
|
|
7019
|
+
* tracked separately for post-HA-integration work.
|
|
7020
|
+
*/
|
|
7021
|
+
function canonicalHash(value) {
|
|
7022
|
+
const canonical = JSON.stringify(value, replaceWithSortedKeys);
|
|
7023
|
+
return (0, node_crypto.createHash)("sha256").update(canonical ?? "").digest("hex");
|
|
7024
|
+
}
|
|
7025
|
+
function replaceWithSortedKeys(_key, value) {
|
|
7026
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
7027
|
+
const obj = value;
|
|
7028
|
+
const out = {};
|
|
7029
|
+
for (const k of Object.keys(obj).toSorted()) out[k] = obj[k];
|
|
7030
|
+
return out;
|
|
7031
|
+
}
|
|
7032
|
+
return value;
|
|
7033
|
+
}
|
|
6971
7034
|
var EncodeProfileSchema = object({
|
|
6972
7035
|
video: object({
|
|
6973
7036
|
codec: _enum([
|
|
@@ -6980,6 +7043,14 @@ var EncodeProfileSchema = object({
|
|
|
6980
7043
|
"main",
|
|
6981
7044
|
"high"
|
|
6982
7045
|
]).optional(),
|
|
7046
|
+
/**
|
|
7047
|
+
* `-level`, e.g. `'3.1'`. A consumer that ADVERTISES a level in its SDP
|
|
7048
|
+
* (`profile-level-id=42e01f` is Baseline 3.1) must constrain the encoder to
|
|
7049
|
+
* it, or it ships a stream that does not match its own advertisement — the
|
|
7050
|
+
* defect class that kept HomeKit black for a year and that Alexa carried
|
|
7051
|
+
* silently. Optional because a browser negotiates the level itself.
|
|
7052
|
+
*/
|
|
7053
|
+
level: string().optional(),
|
|
6983
7054
|
width: number().int().positive().optional(),
|
|
6984
7055
|
height: number().int().positive().optional(),
|
|
6985
7056
|
fps: number().positive().optional(),
|
|
@@ -7027,6 +7098,29 @@ var EncodeProfileSchema = object({
|
|
|
7027
7098
|
outputArgs: array(string()).optional()
|
|
7028
7099
|
});
|
|
7029
7100
|
/**
|
|
7101
|
+
* The shape every live egress starts from: H.264 Baseline 3.1 at 720p25.
|
|
7102
|
+
* Baseline because it is the one profile every consumer in this repo decodes
|
|
7103
|
+
* (Echo, iOS, an old browser); 3.1 because that is what the SDPs advertise.
|
|
7104
|
+
*/
|
|
7105
|
+
var BASE_LIVE_EGRESS_PROFILE = {
|
|
7106
|
+
video: {
|
|
7107
|
+
codec: "h264",
|
|
7108
|
+
profile: "baseline",
|
|
7109
|
+
level: "3.1",
|
|
7110
|
+
width: 1280,
|
|
7111
|
+
height: 720,
|
|
7112
|
+
fps: 25,
|
|
7113
|
+
bitrateKbps: 2500,
|
|
7114
|
+
gopFrames: 25,
|
|
7115
|
+
bf: 0,
|
|
7116
|
+
preset: "veryfast",
|
|
7117
|
+
tune: "zerolatency"
|
|
7118
|
+
},
|
|
7119
|
+
audio: "passthrough"
|
|
7120
|
+
};
|
|
7121
|
+
({ ...BASE_LIVE_EGRESS_PROFILE }), { ...BASE_LIVE_EGRESS_PROFILE.video };
|
|
7122
|
+
({ ...BASE_LIVE_EGRESS_PROFILE });
|
|
7123
|
+
/**
|
|
7030
7124
|
* Deep wiring healthcheck — snapshot of active reachability probes across
|
|
7031
7125
|
* every declared capability + widget of every installed plugin, on every
|
|
7032
7126
|
* node. Produced by the backend `WiringHealthService` and surfaced via
|
|
@@ -7076,6 +7170,105 @@ object({
|
|
|
7076
7170
|
})
|
|
7077
7171
|
});
|
|
7078
7172
|
/**
|
|
7173
|
+
* Per-camera FUNCTION SWITCHES — the one coherent on/off surface over the
|
|
7174
|
+
* pipeline functions an operator thinks in terms of.
|
|
7175
|
+
*
|
|
7176
|
+
* ## This file adds no state
|
|
7177
|
+
*
|
|
7178
|
+
* Every switch here is a VIEW onto an authority that already existed
|
|
7179
|
+
* ([D61](../../../../docs/decisions/adr-0062.md)). The whole point of the
|
|
7180
|
+
* group is that there is exactly one place each function is turned off, and
|
|
7181
|
+
* the group routes to it:
|
|
7182
|
+
*
|
|
7183
|
+
* | Switch | Authority | Proven "off stops the work" gate |
|
|
7184
|
+
* | --- | --- | --- |
|
|
7185
|
+
* | `stream-broker` | `deviceManager.setDisabled` | `StreamBrokerManager.reconcileAllCatalogs` releases the brokers; `ensureBroker` refuses re-creation |
|
|
7186
|
+
* | `object-detection` | `deviceManager.setWrapperActive('detection-pipeline')` | `PipelineSettingsStore.resolvePipelineForDevice` returns `{ steps: [], audio: null }` |
|
|
7187
|
+
* | `audio-analysis` | `deviceManager.setWrapperActive('audio-analysis')` | `AudioSubscriptionController.subscribeAudioStream` returns `null` before opening the stream |
|
|
7188
|
+
* | `recording` | `recording.setDeviceConfig` → `RecordingConfig.enabled` | `band-decision.shouldRecord` returns false; the controller detaches the device |
|
|
7189
|
+
* | `notifications` | `notificationRules.setDeviceMuted` | `NotificationCenter.evaluateAndEnqueue` returns before any rule is evaluated |
|
|
7190
|
+
*
|
|
7191
|
+
* The wrapper-binding pair is not a new idea: `legacy-migrations.ts` already
|
|
7192
|
+
* migrated the legacy `audioEnabled` / `pipelineEnabled` /
|
|
7193
|
+
* `motionDetectionEnabled` booleans ONTO `setWrapperActive`. The group is the
|
|
7194
|
+
* surface that decision never got.
|
|
7195
|
+
*
|
|
7196
|
+
* ## Two rules that are load-bearing
|
|
7197
|
+
*
|
|
7198
|
+
* - **Recording's switch is `enabled`, never the bands.** `bands` is the only
|
|
7199
|
+
* authored intent and `mode` is derived from it (`deriveRecordingMode`).
|
|
7200
|
+
* Expressing "off" by clearing bands destroys the operator's schedule and
|
|
7201
|
+
* turning the camera back on would then silently record nothing.
|
|
7202
|
+
* - **A switch that is off must be reported as off**, not merely produce
|
|
7203
|
+
* nothing. {@link CameraSwitch.enabled} is what a status surface renders as
|
|
7204
|
+
* "disabled by an operator" instead of "broken" — see
|
|
7205
|
+
* `CameraStatus.switchedOff`.
|
|
7206
|
+
*/
|
|
7207
|
+
/**
|
|
7208
|
+
* The five functions the operator named (2026-08-05). Deliberately NOT one id
|
|
7209
|
+
* per pipeline step: face recognition and plate/LPR are per-step toggles on
|
|
7210
|
+
* `pipelineOrchestrator.setCameraStepToggle` and belong in the pipeline
|
|
7211
|
+
* editor, not in a five-button safety group.
|
|
7212
|
+
*/
|
|
7213
|
+
var CameraSwitchIdSchema = _enum([
|
|
7214
|
+
"stream-broker",
|
|
7215
|
+
"object-detection",
|
|
7216
|
+
"audio-analysis",
|
|
7217
|
+
"recording",
|
|
7218
|
+
"notifications"
|
|
7219
|
+
]);
|
|
7220
|
+
/**
|
|
7221
|
+
* WHERE the switch's state actually lives. A discriminated union rather than a
|
|
7222
|
+
* string so both the writer (the orchestrator's `setCameraSwitch`) and any
|
|
7223
|
+
* reader can exhaustively narrow — and so "the group added a parallel map" is
|
|
7224
|
+
* a compile error rather than a review comment.
|
|
7225
|
+
*/
|
|
7226
|
+
var CameraSwitchAuthoritySchema = discriminatedUnion("kind", [
|
|
7227
|
+
object({ kind: literal("device-disabled") }),
|
|
7228
|
+
object({
|
|
7229
|
+
kind: literal("wrapper-binding"),
|
|
7230
|
+
capName: string()
|
|
7231
|
+
}),
|
|
7232
|
+
object({ kind: literal("recording-config") }),
|
|
7233
|
+
object({ kind: literal("notification-mute") })
|
|
7234
|
+
]);
|
|
7235
|
+
/**
|
|
7236
|
+
* Why a switch is not offered for this camera. Rendered instead of the
|
|
7237
|
+
* control, never as a dead control — an absent function and a broken one must
|
|
7238
|
+
* not look the same.
|
|
7239
|
+
*/
|
|
7240
|
+
var CameraSwitchUnavailableReasonSchema = _enum(["no-provider", "source-unreachable"]);
|
|
7241
|
+
/**
|
|
7242
|
+
* One switch, resolved for one camera.
|
|
7243
|
+
*
|
|
7244
|
+
* `label` and `costWhenOff` travel ON THE WIRE rather than being looked up
|
|
7245
|
+
* client-side: the viewer is a separate repository that does not import
|
|
7246
|
+
* `@camstack/types`, and a cost line duplicated in two clients is a cost line
|
|
7247
|
+
* that will disagree with itself. Five rows per camera is nothing.
|
|
7248
|
+
*/
|
|
7249
|
+
var CameraSwitchSchema = object({
|
|
7250
|
+
id: CameraSwitchIdSchema,
|
|
7251
|
+
label: string(),
|
|
7252
|
+
/**
|
|
7253
|
+
* What the operator LOSES while this is off, in one sentence. Required, not
|
|
7254
|
+
* optional: a switch that cannot say what it costs should not ship.
|
|
7255
|
+
*/
|
|
7256
|
+
costWhenOff: string(),
|
|
7257
|
+
/** False = do not render a control. `unavailableReason` says why. */
|
|
7258
|
+
available: boolean(),
|
|
7259
|
+
unavailableReason: CameraSwitchUnavailableReasonSchema.optional(),
|
|
7260
|
+
/** Current state. Meaningless when `available` is false — read it as `true`. */
|
|
7261
|
+
enabled: boolean(),
|
|
7262
|
+
authority: CameraSwitchAuthoritySchema
|
|
7263
|
+
});
|
|
7264
|
+
/** The whole group for one camera. */
|
|
7265
|
+
var CameraSwitchGroupSchema = object({
|
|
7266
|
+
deviceId: number().int(),
|
|
7267
|
+
switches: array(CameraSwitchSchema).readonly(),
|
|
7268
|
+
/** Unix ms when the group was composed server-side. */
|
|
7269
|
+
fetchedAt: number()
|
|
7270
|
+
});
|
|
7271
|
+
/**
|
|
7079
7272
|
* Ops-log — the durable, append-only operations audit shared by the
|
|
7080
7273
|
* recordings and events management surfaces.
|
|
7081
7274
|
*
|
|
@@ -7094,14 +7287,16 @@ var OpsLogOpSchema = _enum([
|
|
|
7094
7287
|
"manual-delete",
|
|
7095
7288
|
"rescan",
|
|
7096
7289
|
"retention-run",
|
|
7097
|
-
"relocate"
|
|
7290
|
+
"relocate",
|
|
7291
|
+
"orphan-audit"
|
|
7098
7292
|
]);
|
|
7099
7293
|
/** Why the operation ran. */
|
|
7100
7294
|
var OpsLogReasonSchema = _enum([
|
|
7101
7295
|
"retention",
|
|
7102
7296
|
"quota",
|
|
7103
7297
|
"manual",
|
|
7104
|
-
"operator"
|
|
7298
|
+
"operator",
|
|
7299
|
+
"maintenance"
|
|
7105
7300
|
]);
|
|
7106
7301
|
/** One audit row, shared verbatim by both domains. */
|
|
7107
7302
|
var OpsLogEntrySchema = object({
|
|
@@ -9016,6 +9211,100 @@ var RtpSourceSchema = object({
|
|
|
9016
9211
|
encoder: string(),
|
|
9017
9212
|
pipelineKey: string()
|
|
9018
9213
|
});
|
|
9214
|
+
/**
|
|
9215
|
+
* The encode request — **structured and serialisable, with NO raw-flag escape
|
|
9216
|
+
* hatch.** This is deliberate and it is the one lesson taken from
|
|
9217
|
+
* `getStreamWithCodec`: that method's `outputArgs: string[]` is simultaneously
|
|
9218
|
+
* its extensibility mechanism AND part of `pipelineKeyFor`'s sharing key, so
|
|
9219
|
+
* adding a flag silently forks the shared child, and two consumers that mean
|
|
9220
|
+
* the same thing but spell it differently never share. Here every knob is a
|
|
9221
|
+
* NAMED field: a new requirement becomes a schema field (and a codegen run),
|
|
9222
|
+
* never an opaque array.
|
|
9223
|
+
*
|
|
9224
|
+
* `inputArgs` / `outputArgs` are omitted from the profile for the same reason.
|
|
9225
|
+
* The operator-facing derived-stream transform editor still has them — that is
|
|
9226
|
+
* a different surface (`publishCameraStream({ kind: 'derived' })`) with a
|
|
9227
|
+
* different purpose (reshaping a badly-behaved SOURCE), and it is unchanged.
|
|
9228
|
+
*/
|
|
9229
|
+
var EgressEncodeSchema = EncodeProfileSchema.omit({
|
|
9230
|
+
inputArgs: true,
|
|
9231
|
+
outputArgs: true
|
|
9232
|
+
});
|
|
9233
|
+
/**
|
|
9234
|
+
* How the encoder is bounded. `'tight'` is a one-second VBV window for a
|
|
9235
|
+
* consumer whose budget is enforced per second (HomeKit); `'relaxed'` is two
|
|
9236
|
+
* seconds, letting a keyframe spike borrow from the next second (a browser,
|
|
9237
|
+
* an Echo). Named rather than numeric so the INTENT survives.
|
|
9238
|
+
*/
|
|
9239
|
+
var EgressRateControlSchema = _enum(["tight", "relaxed"]);
|
|
9240
|
+
var EgressTranscodeRequestSchema = object({
|
|
9241
|
+
deviceId: number().int().nonnegative(),
|
|
9242
|
+
/** Which published stream to read. */
|
|
9243
|
+
source: discriminatedUnion("kind", [object({
|
|
9244
|
+
kind: literal("profile"),
|
|
9245
|
+
profile: CamProfileSchema
|
|
9246
|
+
}), object({
|
|
9247
|
+
kind: literal("cam-stream"),
|
|
9248
|
+
camStreamId: string().min(1)
|
|
9249
|
+
})]),
|
|
9250
|
+
encode: EgressEncodeSchema,
|
|
9251
|
+
rateControl: EgressRateControlSchema.optional(),
|
|
9252
|
+
/**
|
|
9253
|
+
* `-bsf:v`. A consumer that negotiates its OWN SDP (HomeKit) cannot carry
|
|
9254
|
+
* out-of-band extradata and needs `dump_extra` on both the copy and encode
|
|
9255
|
+
* branches. Enumerated, not free text.
|
|
9256
|
+
*/
|
|
9257
|
+
bitstreamFilter: _enum([
|
|
9258
|
+
"dump_extra",
|
|
9259
|
+
"h264_mp4toannexb",
|
|
9260
|
+
"hevc_mp4toannexb"
|
|
9261
|
+
]).optional(),
|
|
9262
|
+
pixelFormat: _enum(["yuv420p", "nv12"]).optional(),
|
|
9263
|
+
/**
|
|
9264
|
+
* Operator/consumer override for decode hardware. ABSENT is the normal case
|
|
9265
|
+
* and the one that matters: the broker then resolves the backend from the
|
|
9266
|
+
* DECODER ADDON's per-node `probedBestHwaccel` (see
|
|
9267
|
+
* `@camstack/types` `ffmpeg/hwaccel.ts`), which is the ranking known to work
|
|
9268
|
+
* on this hardware — never the raw kernel resolver's qsv-first order.
|
|
9269
|
+
*/
|
|
9270
|
+
decodeHwAccel: _enum([
|
|
9271
|
+
"auto",
|
|
9272
|
+
"none",
|
|
9273
|
+
"videotoolbox",
|
|
9274
|
+
"vaapi",
|
|
9275
|
+
"qsv",
|
|
9276
|
+
"cuda"
|
|
9277
|
+
]).optional(),
|
|
9278
|
+
/**
|
|
9279
|
+
* Host to embed in the returned restream `url`. The broker mints hub-local
|
|
9280
|
+
* `127.0.0.1` URLs; a consumer on another node passes a cluster-resolvable
|
|
9281
|
+
* host (`NodeTopologyService.reachableHostByNode`) so the returned URL is
|
|
9282
|
+
* dialable from there. Same contract as `getStreamWithCodec.hostname` —
|
|
9283
|
+
* `substituteRtspHost` rewrites only the dial address, never the restreamer.
|
|
9284
|
+
*/
|
|
9285
|
+
hostname: string().optional(),
|
|
9286
|
+
/** Attribution for the broker panel. Never part of the sharing key. */
|
|
9287
|
+
tag: string().optional()
|
|
9288
|
+
});
|
|
9289
|
+
var EgressTranscodeSchema = object({
|
|
9290
|
+
/** Dial-able RTSP url (host-substituted when `hostname` was supplied). */
|
|
9291
|
+
url: string(),
|
|
9292
|
+
/** Release handle. Refcounted — the child dies when the last holder releases. */
|
|
9293
|
+
pipelineKey: string(),
|
|
9294
|
+
videoCodec: _enum(["H264", "H265"]),
|
|
9295
|
+
resolution: object({
|
|
9296
|
+
width: number().int().positive(),
|
|
9297
|
+
height: number().int().positive()
|
|
9298
|
+
}),
|
|
9299
|
+
transcoded: boolean(),
|
|
9300
|
+
encoder: string(),
|
|
9301
|
+
/**
|
|
9302
|
+
* The decode backend the child ACTUALLY ran with — `null` for software.
|
|
9303
|
+
* Returned rather than assumed: a consumer that asked for hardware and got
|
|
9304
|
+
* software needs to be able to see that without reading the broker's logs.
|
|
9305
|
+
*/
|
|
9306
|
+
decodeHwAccel: string().nullable()
|
|
9307
|
+
});
|
|
9019
9308
|
method(object({
|
|
9020
9309
|
deviceId: number().int().nonnegative(),
|
|
9021
9310
|
camStreamId: string().min(1),
|
|
@@ -9125,6 +9414,15 @@ method(object({
|
|
|
9125
9414
|
}), {
|
|
9126
9415
|
kind: "mutation",
|
|
9127
9416
|
auth: "admin"
|
|
9417
|
+
}), method(EgressTranscodeRequestSchema, EgressTranscodeSchema, {
|
|
9418
|
+
kind: "mutation",
|
|
9419
|
+
auth: "admin"
|
|
9420
|
+
}), method(object({ pipelineKey: string() }), object({
|
|
9421
|
+
released: boolean(),
|
|
9422
|
+
refcount: number().int().nonnegative()
|
|
9423
|
+
}), {
|
|
9424
|
+
kind: "mutation",
|
|
9425
|
+
auth: "admin"
|
|
9128
9426
|
}), method(SubscribeAudioChunksInputSchema, SubscribeAudioChunksResultSchema, { kind: "mutation" }), method(object({
|
|
9129
9427
|
subscriptionId: string(),
|
|
9130
9428
|
maxCount: number().int().positive().default(8)
|
|
@@ -9579,6 +9877,62 @@ method(_void(), EngineInfoSchema), method(object({
|
|
|
9579
9877
|
indexes: array(CollectionIndexSchema).readonly().optional()
|
|
9580
9878
|
}), _void(), { kind: "mutation" });
|
|
9581
9879
|
/**
|
|
9880
|
+
* Stable UI option list for the `hwaccel` setting. Decoder addons
|
|
9881
|
+
* reuse this for `globalSettingsSchema()` so the dropdown is
|
|
9882
|
+
* identical everywhere. Order: auto → off → common backends by
|
|
9883
|
+
* platform affinity (macOS, NVIDIA, Intel/AMD, Windows, Linux).
|
|
9884
|
+
*/
|
|
9885
|
+
var HWACCEL_OPTIONS = [
|
|
9886
|
+
{
|
|
9887
|
+
value: "auto",
|
|
9888
|
+
label: "Auto (defer to probed best)"
|
|
9889
|
+
},
|
|
9890
|
+
{
|
|
9891
|
+
value: "none",
|
|
9892
|
+
label: "Off (software)"
|
|
9893
|
+
},
|
|
9894
|
+
{
|
|
9895
|
+
value: "videotoolbox",
|
|
9896
|
+
label: "VideoToolbox (macOS)"
|
|
9897
|
+
},
|
|
9898
|
+
{
|
|
9899
|
+
value: "cuda",
|
|
9900
|
+
label: "CUDA (NVIDIA)"
|
|
9901
|
+
},
|
|
9902
|
+
{
|
|
9903
|
+
value: "nvdec",
|
|
9904
|
+
label: "NVDEC (NVIDIA legacy)"
|
|
9905
|
+
},
|
|
9906
|
+
{
|
|
9907
|
+
value: "vaapi",
|
|
9908
|
+
label: "VAAPI (Linux Intel/AMD)"
|
|
9909
|
+
},
|
|
9910
|
+
{
|
|
9911
|
+
value: "qsv",
|
|
9912
|
+
label: "QuickSync (Intel)"
|
|
9913
|
+
},
|
|
9914
|
+
{
|
|
9915
|
+
value: "d3d11va",
|
|
9916
|
+
label: "D3D11VA (Windows)"
|
|
9917
|
+
},
|
|
9918
|
+
{
|
|
9919
|
+
value: "dxva2",
|
|
9920
|
+
label: "DXVA2 (Windows legacy)"
|
|
9921
|
+
},
|
|
9922
|
+
{
|
|
9923
|
+
value: "amf",
|
|
9924
|
+
label: "AMF (AMD)"
|
|
9925
|
+
},
|
|
9926
|
+
{
|
|
9927
|
+
value: "vdpau",
|
|
9928
|
+
label: "VDPAU (Linux NVIDIA legacy)"
|
|
9929
|
+
},
|
|
9930
|
+
{
|
|
9931
|
+
value: "drm",
|
|
9932
|
+
label: "DRM (Linux generic)"
|
|
9933
|
+
}
|
|
9934
|
+
];
|
|
9935
|
+
/**
|
|
9582
9936
|
* shm ring usage stats for a `frameSink: 'shm'` decoder session —
|
|
9583
9937
|
* exposed via `decoder.getShmStats` so downstream consumers can
|
|
9584
9938
|
* observe ring pressure (slot count, byte budget, hit/miss ratio).
|
|
@@ -12928,7 +13282,7 @@ var DETECTION_SUB_COLORS = {
|
|
|
12928
13282
|
zebra: "#404040",
|
|
12929
13283
|
giraffe: "#d4a373"
|
|
12930
13284
|
};
|
|
12931
|
-
function titleCase(id) {
|
|
13285
|
+
function titleCase$1(id) {
|
|
12932
13286
|
return id.split(/[-_ ]/).filter((p) => p.length > 0).map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(" ");
|
|
12933
13287
|
}
|
|
12934
13288
|
var entries = /* @__PURE__ */ new Map();
|
|
@@ -12967,7 +13321,7 @@ macro("control", "control", TAXONOMY_COLORS.control, "control", "Control");
|
|
|
12967
13321
|
for (const [cocoClass, macroClass] of Object.entries(COCO_TO_MACRO.mapping)) {
|
|
12968
13322
|
if (macroClass !== "vehicle" && macroClass !== "animal") continue;
|
|
12969
13323
|
if (entries.has(cocoClass)) continue;
|
|
12970
|
-
sub(cocoClass, macroClass, "detection", DETECTION_SUB_COLORS[cocoClass] ?? TAXONOMY_COLORS.genericDetection, cocoClass, titleCase(cocoClass));
|
|
13324
|
+
sub(cocoClass, macroClass, "detection", DETECTION_SUB_COLORS[cocoClass] ?? TAXONOMY_COLORS.genericDetection, cocoClass, titleCase$1(cocoClass));
|
|
12971
13325
|
}
|
|
12972
13326
|
sub("package-delivered", "package", "package", TAXONOMY_COLORS.package, "package", "Package delivered");
|
|
12973
13327
|
sub("package-picked-up", "package", "package", TAXONOMY_COLORS.package, "package", "Package picked up");
|
|
@@ -13476,12 +13830,13 @@ var NcConditionsSchema = object({
|
|
|
13476
13830
|
* source; otherwise the subject's source must equal it. Legacy records
|
|
13477
13831
|
* with no stamped source are treated as `pipeline`. The union spans both
|
|
13478
13832
|
* record kinds — object events carry `pipeline` | `onboard`, synthetic
|
|
13479
|
-
* tracks carry `sensor
|
|
13833
|
+
* tracks carry `sensor` (a linked device) or `audio` (a D62 audio marker).
|
|
13480
13834
|
*/
|
|
13481
13835
|
source: _enum([
|
|
13482
13836
|
"pipeline",
|
|
13483
13837
|
"onboard",
|
|
13484
13838
|
"sensor",
|
|
13839
|
+
"audio",
|
|
13485
13840
|
"any"
|
|
13486
13841
|
]).optional(),
|
|
13487
13842
|
/**
|
|
@@ -14057,6 +14412,12 @@ method(object({}), object({ rules: array(NcRuleSchema) }), { auth: "admin" }), m
|
|
|
14057
14412
|
}), object({ success: literal(true) }), {
|
|
14058
14413
|
kind: "mutation",
|
|
14059
14414
|
auth: "admin"
|
|
14415
|
+
}), method(object({}), object({ mutedDeviceIds: array(number().int()).readonly() }), { auth: "admin" }), method(object({
|
|
14416
|
+
deviceId: number().int(),
|
|
14417
|
+
muted: boolean()
|
|
14418
|
+
}), object({ success: literal(true) }), {
|
|
14419
|
+
kind: "mutation",
|
|
14420
|
+
auth: "admin"
|
|
14060
14421
|
}), method(object({
|
|
14061
14422
|
rule: NcRuleInputSchema,
|
|
14062
14423
|
lookbackMinutes: number().int().min(1).max(1440).default(60)
|
|
@@ -14395,12 +14756,60 @@ var TrackAudioLabelSchema = object({
|
|
|
14395
14756
|
});
|
|
14396
14757
|
/**
|
|
14397
14758
|
* How a track was produced. `pipeline` (default / absent) = the spatial
|
|
14398
|
-
* detection+tracking pipeline.
|
|
14399
|
-
*
|
|
14400
|
-
*
|
|
14401
|
-
*
|
|
14759
|
+
* detection+tracking pipeline. Every OTHER value is a SYNTHETIC projection —
|
|
14760
|
+
* no positions, a single snapshot, and no bbox trajectory at all:
|
|
14761
|
+
*
|
|
14762
|
+
* - `sensor` — a linked sensor/control device state change.
|
|
14763
|
+
* - `audio` — an audio event on the camera itself that was anomalous for
|
|
14764
|
+
* THAT camera, loud, and heard while nothing visual was happening (D62).
|
|
14765
|
+
*
|
|
14766
|
+
* The spatial subsystems (tracker association, occupancy count, re-id /
|
|
14767
|
+
* embedding, resurrection) MUST skip every synthetic source. Test for that
|
|
14768
|
+
* with `isSpatialTrack`, which allow-lists `pipeline` — a `!== 'sensor'`
|
|
14769
|
+
* check silently readmits every source added after it was written.
|
|
14770
|
+
*/
|
|
14771
|
+
var TrackSourceSchema = _enum([
|
|
14772
|
+
"pipeline",
|
|
14773
|
+
"sensor",
|
|
14774
|
+
"audio"
|
|
14775
|
+
]);
|
|
14776
|
+
/**
|
|
14777
|
+
* Per-track OPERATOR flags — set by hand from the admin UI or the viewer, never
|
|
14778
|
+
* by the pipeline. Spread into `TrackSchema` and `KeyEventSchema` from one place
|
|
14779
|
+
* so the two surfaces cannot drift.
|
|
14780
|
+
*
|
|
14781
|
+
* **Absent ≠ false.** A track that has never been touched omits the field; an
|
|
14782
|
+
* explicitly un-flagged track carries `false`. Legacy rows written before the
|
|
14783
|
+
* columns existed read as absent, and a consumer that needs a boolean should say
|
|
14784
|
+
* `flag === true`, not `flag !== false`.
|
|
14785
|
+
*
|
|
14786
|
+
* What the flags DO is deliberately UNDEFINED at the time of writing: they are
|
|
14787
|
+
* operator curation, and the behaviour they drive will be specified separately.
|
|
14788
|
+
* In particular a `markForTrain` track is NOT pinned against retention — see
|
|
14789
|
+
* `docs/decisions/adr-0059.md` for why that is a store-level change, not a flag.
|
|
14790
|
+
*/
|
|
14791
|
+
var TrackFlagFields = {
|
|
14792
|
+
/** Operator marked this track as training material. */
|
|
14793
|
+
markForTrain: boolean().optional(),
|
|
14794
|
+
/** Operator marked this track for diagnostic attention. */
|
|
14795
|
+
debug: boolean().optional()
|
|
14796
|
+
};
|
|
14797
|
+
/**
|
|
14798
|
+
* The write half: a PARTIAL patch. An omitted key is left untouched, so setting
|
|
14799
|
+
* one flag can never clear the other — the toggles are independent and are
|
|
14800
|
+
* driven from three surfaces that do not know about each other.
|
|
14801
|
+
*/
|
|
14802
|
+
var TrackFlagsPatchSchema = object(TrackFlagFields);
|
|
14803
|
+
/**
|
|
14804
|
+
* The resolved flag state after a write. Both fields are REQUIRED here (absent
|
|
14805
|
+
* collapses to `false`) so a caller can drive a toggle's checked state off the
|
|
14806
|
+
* mutation result without a re-fetch.
|
|
14402
14807
|
*/
|
|
14403
|
-
var
|
|
14808
|
+
var TrackFlagsSchema = object({
|
|
14809
|
+
trackId: string(),
|
|
14810
|
+
markForTrain: boolean(),
|
|
14811
|
+
debug: boolean()
|
|
14812
|
+
});
|
|
14404
14813
|
var TrackSchema = object({
|
|
14405
14814
|
trackId: string(),
|
|
14406
14815
|
deviceId: number(),
|
|
@@ -14443,7 +14852,8 @@ var TrackSchema = object({
|
|
|
14443
14852
|
/** Normalized 0..1 trajectory envelope (see {@link TrackEnvelopeSchema}).
|
|
14444
14853
|
* Populated from the persisted envelope columns on historical reads;
|
|
14445
14854
|
* absent on legacy rows, dims-less tracks and active (in-RAM) tracks. */
|
|
14446
|
-
envelope: TrackEnvelopeSchema.optional()
|
|
14855
|
+
envelope: TrackEnvelopeSchema.optional(),
|
|
14856
|
+
...TrackFlagFields
|
|
14447
14857
|
});
|
|
14448
14858
|
var BaseEventFields = {
|
|
14449
14859
|
id: string(),
|
|
@@ -14656,7 +15066,8 @@ var KeyEventSchema = object({
|
|
|
14656
15066
|
/** Highest-confidence ObjectEvent id for the track (empty when none). */
|
|
14657
15067
|
bestEventId: string(),
|
|
14658
15068
|
/** Track lifetime in ms (lastSeen - firstSeen). */
|
|
14659
|
-
windowMs: number().optional()
|
|
15069
|
+
windowMs: number().optional(),
|
|
15070
|
+
...TrackFlagFields
|
|
14660
15071
|
});
|
|
14661
15072
|
object({
|
|
14662
15073
|
trackId: string(),
|
|
@@ -14727,6 +15138,104 @@ var EventPruneCountsSchema = object({
|
|
|
14727
15138
|
object: number().int(),
|
|
14728
15139
|
audio: number().int()
|
|
14729
15140
|
});
|
|
15141
|
+
/**
|
|
15142
|
+
* Re-embed stored tracks from their key frames.
|
|
15143
|
+
*
|
|
15144
|
+
* The reason this is an operator-callable method and not a migration script:
|
|
15145
|
+
* every knob that decides what a vector MEANS — encoder model, crop margin,
|
|
15146
|
+
* squaring — is only changeable if the existing vectors can be regenerated.
|
|
15147
|
+
* Mixing feature spaces in one index makes cosine scores incomparable, and the
|
|
15148
|
+
* symptom is a quality regression with no visible cause.
|
|
15149
|
+
*/
|
|
15150
|
+
var RebuildObjectEmbeddingsInput = object({
|
|
15151
|
+
/** Restrict to one camera. Omit for the whole fleet. */
|
|
15152
|
+
deviceId: number().optional(),
|
|
15153
|
+
since: number().optional(),
|
|
15154
|
+
until: number().optional(),
|
|
15155
|
+
/** Stop after this many tracks; the result reports whether more remain. */
|
|
15156
|
+
maxTracks: number().int().positive().optional(),
|
|
15157
|
+
/**
|
|
15158
|
+
* Run every embedding on THIS node instead of round-robining the fleet.
|
|
15159
|
+
*
|
|
15160
|
+
* Named `executeOnNodeId` and not `nodeId` on purpose: an inline `nodeId`
|
|
15161
|
+
* field in cap args is read by `parent-unowned-call.ts` as a ROUTING PIN, so
|
|
15162
|
+
* calling it that would pin the rebuild REQUEST itself to that node — the
|
|
15163
|
+
* rebuild orchestration lives on the hub, and only the per-track step runs
|
|
15164
|
+
* remotely. This field is data; the per-track pin is applied inside.
|
|
15165
|
+
*
|
|
15166
|
+
* Absent ⇒ round-robin over every online node whose runner can serve the
|
|
15167
|
+
* pinned model.
|
|
15168
|
+
*/
|
|
15169
|
+
executeOnNodeId: string().optional(),
|
|
15170
|
+
/**
|
|
15171
|
+
* Milliseconds to wait between tracks; omit for the built-in default, `0` to
|
|
15172
|
+
* run flat out.
|
|
15173
|
+
*
|
|
15174
|
+
* A rebuild is bulk maintenance on hub-main's single thread. Measured
|
|
15175
|
+
* 2026-08-06, an unpaced pass held that thread busy 82.2 s out of 120 and
|
|
15176
|
+
* pushed `nodes.topology` from 0.25 s to 26 s for 43 minutes. The value in
|
|
15177
|
+
* force is logged at start and finish so a deliberately slow pass reads
|
|
15178
|
+
* differently from a stalled one.
|
|
15179
|
+
*/
|
|
15180
|
+
pacingMs: number().int().nonnegative().optional()
|
|
15181
|
+
});
|
|
15182
|
+
/**
|
|
15183
|
+
* Result of emptying the CLIP index.
|
|
15184
|
+
*
|
|
15185
|
+
* The clean slate before a policy change: a new crop margin or encoder model
|
|
15186
|
+
* leaves two feature spaces in one index whose cosine scores are not
|
|
15187
|
+
* comparable, so wiping and rebuilding is the only way to be sure every vector
|
|
15188
|
+
* means the same thing.
|
|
15189
|
+
*/
|
|
15190
|
+
var WipeObjectEmbeddingsResultSchema = object({ deleted: number() });
|
|
15191
|
+
/**
|
|
15192
|
+
* Acknowledgement that a rebuild STARTED.
|
|
15193
|
+
*
|
|
15194
|
+
* The pass costs ~1s per track — 45 minutes for a 2,600-track fleet — so it
|
|
15195
|
+
* runs detached and this returns immediately. Waiting for it made the client
|
|
15196
|
+
* time out while the work carried on server-side, which is the worst of both:
|
|
15197
|
+
* no result and no way to know it was still going. Poll
|
|
15198
|
+
* `getObjectEmbeddingRebuildStatus` for progress.
|
|
15199
|
+
*/
|
|
15200
|
+
var RebuildObjectEmbeddingsResultSchema = object({
|
|
15201
|
+
started: boolean(),
|
|
15202
|
+
/** True when a pass was already running; the new request is ignored. */
|
|
15203
|
+
alreadyRunning: boolean()
|
|
15204
|
+
});
|
|
15205
|
+
var RebuildStatusSchema = object({
|
|
15206
|
+
running: boolean(),
|
|
15207
|
+
scanned: number(),
|
|
15208
|
+
rebuilt: number(),
|
|
15209
|
+
/** Tracks whose key frame is gone — nothing to re-embed from. */
|
|
15210
|
+
missingKeyFrame: number(),
|
|
15211
|
+
/** Tracks with no usable detection box. */
|
|
15212
|
+
missingBbox: number(),
|
|
15213
|
+
/**
|
|
15214
|
+
* Tracks an executing node REFUSED rather than broke on — an unreadable key
|
|
15215
|
+
* frame, a step that threw. Separate from `failed` because the remedy is
|
|
15216
|
+
* different, and because a whole camera silently contributing zero vectors
|
|
15217
|
+
* is the shape of failure a rebuild must never hide.
|
|
15218
|
+
*/
|
|
15219
|
+
notRunnable: number(),
|
|
15220
|
+
/**
|
|
15221
|
+
* The pass stopped because NO node could serve the pinned model.
|
|
15222
|
+
*
|
|
15223
|
+
* Distinct from `notRunnable` on purpose: that one says "this track was
|
|
15224
|
+
* refused", this one says "the cluster cannot do this work at all" — every
|
|
15225
|
+
* candidate node either lacks the `clip-embedding` step, lacks a build of the
|
|
15226
|
+
* pinned model for its engine format, or dropped out. The remedy is a model /
|
|
15227
|
+
* engine change, not a per-camera one. Non-zero here always comes with
|
|
15228
|
+
* `complete: false`.
|
|
15229
|
+
*/
|
|
15230
|
+
noCapableNode: number(),
|
|
15231
|
+
failed: number(),
|
|
15232
|
+
/** Set once a pass ends: true only when EVERYTHING was covered. */
|
|
15233
|
+
complete: boolean().nullable(),
|
|
15234
|
+
startedAtMs: number().nullable(),
|
|
15235
|
+
finishedAtMs: number().nullable(),
|
|
15236
|
+
/** Present when the pass ended by throwing. */
|
|
15237
|
+
error: string().nullable()
|
|
15238
|
+
});
|
|
14730
15239
|
DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).readonly()), method(object({
|
|
14731
15240
|
deviceId: number(),
|
|
14732
15241
|
trackId: string()
|
|
@@ -14790,7 +15299,12 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
|
|
|
14790
15299
|
}), {
|
|
14791
15300
|
kind: "mutation",
|
|
14792
15301
|
auth: "admin"
|
|
14793
|
-
}), method(object({
|
|
15302
|
+
}), method(object({
|
|
15303
|
+
/** Log/audit scope only — the trackId is globally unique on its own. */
|
|
15304
|
+
deviceId: number(),
|
|
15305
|
+
trackId: string(),
|
|
15306
|
+
flags: TrackFlagsPatchSchema
|
|
15307
|
+
}), TrackFlagsSchema, { kind: "mutation" }), method(object({}), EventStoreFootprintSchema, {
|
|
14794
15308
|
kind: "query",
|
|
14795
15309
|
auth: "admin"
|
|
14796
15310
|
}), method(object({
|
|
@@ -14820,7 +15334,13 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
|
|
|
14820
15334
|
}), array(MediaFileSchema).readonly()), method(object({
|
|
14821
15335
|
trackId: string(),
|
|
14822
15336
|
kinds: array(MediaFileKindEnum).optional()
|
|
14823
|
-
}), array(MediaFileSchema).readonly()), method(object({ trackId: string() }), array(MediaFileInfoSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), object({
|
|
15337
|
+
}), array(MediaFileSchema).readonly()), method(object({ trackId: string() }), array(MediaFileInfoSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), method(object({}), WipeObjectEmbeddingsResultSchema, {
|
|
15338
|
+
kind: "mutation",
|
|
15339
|
+
auth: "admin"
|
|
15340
|
+
}), method(RebuildObjectEmbeddingsInput, RebuildObjectEmbeddingsResultSchema, {
|
|
15341
|
+
kind: "mutation",
|
|
15342
|
+
auth: "admin"
|
|
15343
|
+
}), method(object({}), RebuildStatusSchema), object({
|
|
14824
15344
|
deviceId: number(),
|
|
14825
15345
|
timestamp: number(),
|
|
14826
15346
|
frameWidth: number(),
|
|
@@ -15394,6 +15914,53 @@ var DetailResultSchema = object({
|
|
|
15394
15914
|
nativeFaceShortSidePx: number().optional()
|
|
15395
15915
|
});
|
|
15396
15916
|
/**
|
|
15917
|
+
* Why an executing node REFUSED a stateless step run (`runStatelessStep`).
|
|
15918
|
+
*
|
|
15919
|
+
* A refusal is a first-class answer, not an error, because the caller's next
|
|
15920
|
+
* move depends on WHICH one it is — and because "the pass produced nothing"
|
|
15921
|
+
* must never be reachable without a named, counted cause. The two tiers:
|
|
15922
|
+
*
|
|
15923
|
+
* - **node-level** (`unknown-step`, `model-not-servable`) — this node can
|
|
15924
|
+
* never serve this (step, model) pair. The caller drops it from its rotation
|
|
15925
|
+
* and retries the same work elsewhere; nothing about the work changes.
|
|
15926
|
+
* - **work-level** (`unreadable-frame`, `execution-failed`) — this node is
|
|
15927
|
+
* fine, this one request is not. Retrying it on another node would only
|
|
15928
|
+
* spread the same failure.
|
|
15929
|
+
*/
|
|
15930
|
+
var StatelessStepRefusalSchema = _enum([
|
|
15931
|
+
"unknown-step",
|
|
15932
|
+
"model-not-servable",
|
|
15933
|
+
"unreadable-frame",
|
|
15934
|
+
"execution-failed"
|
|
15935
|
+
]);
|
|
15936
|
+
/**
|
|
15937
|
+
* Answer to `runStatelessStep` — a discriminated union rather than a nullable
|
|
15938
|
+
* result, because `null` is exactly what made the camera-bound detail path
|
|
15939
|
+
* unable to tell "refused" from "never asked".
|
|
15940
|
+
*/
|
|
15941
|
+
var RunStatelessStepResultSchema = discriminatedUnion("kind", [object({
|
|
15942
|
+
kind: literal("ran"),
|
|
15943
|
+
/** The node that actually executed it — the pin, echoed back for the log. */
|
|
15944
|
+
nodeId: string(),
|
|
15945
|
+
/**
|
|
15946
|
+
* The model the step ran with.
|
|
15947
|
+
*
|
|
15948
|
+
* The node verified this exact id has a build for the format it dispatched
|
|
15949
|
+
* on BEFORE running, so the executor's format resolution returns it
|
|
15950
|
+
* unchanged. A caller that pinned a model must compare this field and
|
|
15951
|
+
* treat a mismatch as a refusal — the whole point of the pin is that a
|
|
15952
|
+
* pass writes one feature space.
|
|
15953
|
+
*/
|
|
15954
|
+
modelId: string(),
|
|
15955
|
+
details: array(DetailResultSchema)
|
|
15956
|
+
}), object({
|
|
15957
|
+
kind: literal("refused"),
|
|
15958
|
+
nodeId: string(),
|
|
15959
|
+
reason: StatelessStepRefusalSchema,
|
|
15960
|
+
/** Human-readable specifics — the format tried, the formats shipped, etc. */
|
|
15961
|
+
detail: string()
|
|
15962
|
+
})]);
|
|
15963
|
+
/**
|
|
15397
15964
|
* Per-camera tunable ranges + defaults. Single source of truth used
|
|
15398
15965
|
* by both the Zod data schema (validation + default fallback) and
|
|
15399
15966
|
* the device settings UI (slider min/max/step). Touch one place and
|
|
@@ -15729,10 +16296,46 @@ method(RunnerCameraConfigSchema, object({ success: literal(true) }), { kind: "mu
|
|
|
15729
16296
|
}), NativeCropResultSchema.nullable()), method(object({
|
|
15730
16297
|
deviceId: number(),
|
|
15731
16298
|
frameHandle: FrameHandleSchema.optional(),
|
|
16299
|
+
/**
|
|
16300
|
+
* FULL FRAME (base64 JPEG). The runner derives the crop rectangle from
|
|
16301
|
+
* `parent.bbox` with the cluster crop convention and cuts it itself —
|
|
16302
|
+
* do NOT pre-crop for this field, that is what `cropJpeg` is.
|
|
16303
|
+
*/
|
|
16304
|
+
frameJpeg: string().optional(),
|
|
16305
|
+
/**
|
|
16306
|
+
* PRE-CUT tile (base64 JPEG), used verbatim — NO padding is applied.
|
|
16307
|
+
* The fallback when the lease/session backing the frame is gone and the
|
|
16308
|
+
* caller already holds a crop.
|
|
16309
|
+
*/
|
|
15732
16310
|
cropJpeg: string().optional(),
|
|
15733
16311
|
parent: DetailParentSchema,
|
|
15734
16312
|
steps: array(string()).optional()
|
|
15735
|
-
}), object({ details: array(DetailResultSchema) }).nullable(), { kind: "mutation" })
|
|
16313
|
+
}), object({ details: array(DetailResultSchema) }).nullable(), { kind: "mutation" }), method(object({
|
|
16314
|
+
/** Catalog step id, e.g. `clip-embedding`. */
|
|
16315
|
+
stepId: string(),
|
|
16316
|
+
/**
|
|
16317
|
+
* REQUIRED model pin. The node runs this exact model or refuses with
|
|
16318
|
+
* `model-not-servable` — it never substitutes a format default, because
|
|
16319
|
+
* a fleet pass that round-robins across nodes would then fill one index
|
|
16320
|
+
* from several encoders.
|
|
16321
|
+
*/
|
|
16322
|
+
modelId: string(),
|
|
16323
|
+
/** FULL FRAME, base64 JPEG. The runner cuts — do NOT pre-crop. */
|
|
16324
|
+
frameJpeg: string(),
|
|
16325
|
+
/**
|
|
16326
|
+
* The subject box, NORMALISED [0,1] against `frameJpeg`. Normalised on
|
|
16327
|
+
* purpose: the caller stores boxes against a downscaled analysis frame
|
|
16328
|
+
* while the stored key frame is native-resolution, and the only side
|
|
16329
|
+
* that reliably knows the image's pixel dimensions is the side that
|
|
16330
|
+
* decodes it. Denormalising here removes a second reader of the
|
|
16331
|
+
* dimensions and the class of mismatch that comes with it.
|
|
16332
|
+
*/
|
|
16333
|
+
bbox: NativeCropBboxSchema,
|
|
16334
|
+
/** Parent class of the subject (`person`, `vehicle`, …) — carried into the result. */
|
|
16335
|
+
className: string(),
|
|
16336
|
+
/** Camera the pixels came from. Diagnostics + log tags ONLY — never routing. */
|
|
16337
|
+
sourceDeviceId: number()
|
|
16338
|
+
}), RunStatelessStepResultSchema, { kind: "mutation" });
|
|
15736
16339
|
var CameraPipelineConfigSchema = object({
|
|
15737
16340
|
engine: PipelineEngineChoiceSchema.optional(),
|
|
15738
16341
|
steps: array(PipelineStepInputSchema).readonly(),
|
|
@@ -16030,6 +16633,20 @@ var CameraStatusSchema = object({
|
|
|
16030
16633
|
detection: CameraDetectionStatusSchema.nullable(),
|
|
16031
16634
|
audio: CameraAudioStatusSchema.nullable(),
|
|
16032
16635
|
recording: CameraRecordingStatusSchema.nullable(),
|
|
16636
|
+
/**
|
|
16637
|
+
* Per-camera function switches an OPERATOR has turned off
|
|
16638
|
+
* ([D61](../../../../docs/decisions/adr-0067.md)).
|
|
16639
|
+
*
|
|
16640
|
+
* This is the difference between DISABLED and BROKEN. A camera whose
|
|
16641
|
+
* `detection` block reports zero fps and whose `switchedOff` contains
|
|
16642
|
+
* `'object-detection'` was switched off by a person; the same camera with an
|
|
16643
|
+
* empty list is failing. Every status surface must render the two
|
|
16644
|
+
* differently — a quiet camera that looks identical to a dead one is the
|
|
16645
|
+
* silence-reads-as-never-happened trap this repo keeps paying for.
|
|
16646
|
+
*
|
|
16647
|
+
* Empty when nothing is off. Never contains a switch no provider offers.
|
|
16648
|
+
*/
|
|
16649
|
+
switchedOff: array(CameraSwitchIdSchema).readonly(),
|
|
16033
16650
|
/** Unix timestamp (ms) when this snapshot was composed server-side. */
|
|
16034
16651
|
fetchedAt: number()
|
|
16035
16652
|
});
|
|
@@ -16198,7 +16815,14 @@ method(object({
|
|
|
16198
16815
|
}), method(object({
|
|
16199
16816
|
deviceId: number(),
|
|
16200
16817
|
agentNodeId: string().optional()
|
|
16201
|
-
}), CameraPipelineConfigSchema), method(object({ deviceId: number() }),
|
|
16818
|
+
}), CameraPipelineConfigSchema), method(object({ deviceId: number() }), CameraSwitchGroupSchema), method(object({
|
|
16819
|
+
deviceId: number(),
|
|
16820
|
+
switchId: CameraSwitchIdSchema,
|
|
16821
|
+
enabled: boolean()
|
|
16822
|
+
}), CameraSwitchGroupSchema, {
|
|
16823
|
+
kind: "mutation",
|
|
16824
|
+
auth: "admin"
|
|
16825
|
+
}), method(object({ deviceId: number() }), CameraStatusSchema), method(object({ deviceIds: array(number()).optional() }), array(CameraStatusSchema).readonly()), method(_void(), array(PipelineTemplateSchema).readonly()), method(object({
|
|
16202
16826
|
name: string(),
|
|
16203
16827
|
description: string().optional(),
|
|
16204
16828
|
config: CameraPipelineConfigSchema
|
|
@@ -17054,6 +17678,24 @@ var VectorDeleteByFilterInputSchema = object({
|
|
|
17054
17678
|
filter: VectorFilterSchema
|
|
17055
17679
|
});
|
|
17056
17680
|
var VectorDeleteResultSchema = object({ deleted: number() });
|
|
17681
|
+
var VectorGetInputSchema = object({
|
|
17682
|
+
index: string(),
|
|
17683
|
+
ids: array(string())
|
|
17684
|
+
});
|
|
17685
|
+
/**
|
|
17686
|
+
* Metadata for the requested ids, WITHOUT their vectors.
|
|
17687
|
+
*
|
|
17688
|
+
* The only caller is a best-of gate that compares a candidate's confidence
|
|
17689
|
+
* against the stored one, and shipping 512 floats back to answer "is 0.91 >
|
|
17690
|
+
* 0.87" would undo the point of the compact encoding. Ids with no row are
|
|
17691
|
+
* simply absent — a caller distinguishing "not stored" from "stored" reads the
|
|
17692
|
+
* length, and a null placeholder would invite a `?? 0` that treats a missing
|
|
17693
|
+
* row as confidence zero.
|
|
17694
|
+
*/
|
|
17695
|
+
var VectorGetResultSchema = object({ items: array(object({
|
|
17696
|
+
id: string(),
|
|
17697
|
+
metadata: VectorMetadataSchema
|
|
17698
|
+
})) });
|
|
17057
17699
|
var VectorStatsInputSchema = object({ index: string() });
|
|
17058
17700
|
var VectorStatsResultSchema = object({
|
|
17059
17701
|
/** Provider id, so an operator can tell brute force from an ANN index. */
|
|
@@ -17066,7 +17708,7 @@ var VectorStatsResultSchema = object({
|
|
|
17066
17708
|
/** False when the backend ranks approximately. */
|
|
17067
17709
|
exact: boolean()
|
|
17068
17710
|
});
|
|
17069
|
-
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);
|
|
17711
|
+
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);
|
|
17070
17712
|
/**
|
|
17071
17713
|
* `videoclips` — the unified, navigable-clip surface for a camera.
|
|
17072
17714
|
*
|
|
@@ -25137,6 +25779,12 @@ Object.freeze({
|
|
|
25137
25779
|
addonId: null,
|
|
25138
25780
|
access: "view"
|
|
25139
25781
|
},
|
|
25782
|
+
"notificationRules.listDeviceMutes": {
|
|
25783
|
+
capName: "notification-rules",
|
|
25784
|
+
capScope: "system",
|
|
25785
|
+
addonId: null,
|
|
25786
|
+
access: "view"
|
|
25787
|
+
},
|
|
25140
25788
|
"notificationRules.listRules": {
|
|
25141
25789
|
capName: "notification-rules",
|
|
25142
25790
|
capScope: "system",
|
|
@@ -25155,6 +25803,12 @@ Object.freeze({
|
|
|
25155
25803
|
addonId: null,
|
|
25156
25804
|
access: "create"
|
|
25157
25805
|
},
|
|
25806
|
+
"notificationRules.setDeviceMuted": {
|
|
25807
|
+
capName: "notification-rules",
|
|
25808
|
+
capScope: "system",
|
|
25809
|
+
addonId: null,
|
|
25810
|
+
access: "create"
|
|
25811
|
+
},
|
|
25158
25812
|
"notificationRules.setRuleEnabled": {
|
|
25159
25813
|
capName: "notification-rules",
|
|
25160
25814
|
capScope: "system",
|
|
@@ -25329,6 +25983,12 @@ Object.freeze({
|
|
|
25329
25983
|
addonId: null,
|
|
25330
25984
|
access: "view"
|
|
25331
25985
|
},
|
|
25986
|
+
"pipelineAnalytics.getObjectEmbeddingRebuildStatus": {
|
|
25987
|
+
capName: "pipeline-analytics",
|
|
25988
|
+
capScope: "device",
|
|
25989
|
+
addonId: null,
|
|
25990
|
+
access: "view"
|
|
25991
|
+
},
|
|
25332
25992
|
"pipelineAnalytics.getObjectEvents": {
|
|
25333
25993
|
capName: "pipeline-analytics",
|
|
25334
25994
|
capScope: "device",
|
|
@@ -25407,6 +26067,12 @@ Object.freeze({
|
|
|
25407
26067
|
addonId: null,
|
|
25408
26068
|
access: "create"
|
|
25409
26069
|
},
|
|
26070
|
+
"pipelineAnalytics.rebuildObjectEmbeddings": {
|
|
26071
|
+
capName: "pipeline-analytics",
|
|
26072
|
+
capScope: "device",
|
|
26073
|
+
addonId: null,
|
|
26074
|
+
access: "create"
|
|
26075
|
+
},
|
|
25410
26076
|
"pipelineAnalytics.relocateMedia": {
|
|
25411
26077
|
capName: "pipeline-analytics",
|
|
25412
26078
|
capScope: "device",
|
|
@@ -25419,12 +26085,24 @@ Object.freeze({
|
|
|
25419
26085
|
addonId: null,
|
|
25420
26086
|
access: "view"
|
|
25421
26087
|
},
|
|
26088
|
+
"pipelineAnalytics.setTrackFlags": {
|
|
26089
|
+
capName: "pipeline-analytics",
|
|
26090
|
+
capScope: "device",
|
|
26091
|
+
addonId: null,
|
|
26092
|
+
access: "create"
|
|
26093
|
+
},
|
|
25422
26094
|
"pipelineAnalytics.wipeAllAnalytics": {
|
|
25423
26095
|
capName: "pipeline-analytics",
|
|
25424
26096
|
capScope: "device",
|
|
25425
26097
|
addonId: null,
|
|
25426
26098
|
access: "delete"
|
|
25427
26099
|
},
|
|
26100
|
+
"pipelineAnalytics.wipeObjectEmbeddings": {
|
|
26101
|
+
capName: "pipeline-analytics",
|
|
26102
|
+
capScope: "device",
|
|
26103
|
+
addonId: null,
|
|
26104
|
+
access: "delete"
|
|
26105
|
+
},
|
|
25428
26106
|
"pipelineExecutor.cacheFrameInPool": {
|
|
25429
26107
|
capName: "pipeline-executor",
|
|
25430
26108
|
capScope: "system",
|
|
@@ -25719,6 +26397,12 @@ Object.freeze({
|
|
|
25719
26397
|
addonId: null,
|
|
25720
26398
|
access: "view"
|
|
25721
26399
|
},
|
|
26400
|
+
"pipelineOrchestrator.getCameraSwitches": {
|
|
26401
|
+
capName: "pipeline-orchestrator",
|
|
26402
|
+
capScope: "system",
|
|
26403
|
+
addonId: null,
|
|
26404
|
+
access: "view"
|
|
26405
|
+
},
|
|
25722
26406
|
"pipelineOrchestrator.getCapabilityBindings": {
|
|
25723
26407
|
capName: "pipeline-orchestrator",
|
|
25724
26408
|
capScope: "system",
|
|
@@ -25851,6 +26535,12 @@ Object.freeze({
|
|
|
25851
26535
|
addonId: null,
|
|
25852
26536
|
access: "create"
|
|
25853
26537
|
},
|
|
26538
|
+
"pipelineOrchestrator.setCameraSwitch": {
|
|
26539
|
+
capName: "pipeline-orchestrator",
|
|
26540
|
+
capScope: "system",
|
|
26541
|
+
addonId: null,
|
|
26542
|
+
access: "create"
|
|
26543
|
+
},
|
|
25854
26544
|
"pipelineOrchestrator.setCapabilityBinding": {
|
|
25855
26545
|
capName: "pipeline-orchestrator",
|
|
25856
26546
|
capScope: "system",
|
|
@@ -25941,6 +26631,12 @@ Object.freeze({
|
|
|
25941
26631
|
addonId: null,
|
|
25942
26632
|
access: "create"
|
|
25943
26633
|
},
|
|
26634
|
+
"pipelineRunner.runStatelessStep": {
|
|
26635
|
+
capName: "pipeline-runner",
|
|
26636
|
+
capScope: "system",
|
|
26637
|
+
addonId: null,
|
|
26638
|
+
access: "create"
|
|
26639
|
+
},
|
|
25944
26640
|
"plateGallery.assignPlate": {
|
|
25945
26641
|
capName: "plate-gallery",
|
|
25946
26642
|
capScope: "system",
|
|
@@ -26757,6 +27453,12 @@ Object.freeze({
|
|
|
26757
27453
|
addonId: null,
|
|
26758
27454
|
access: "create"
|
|
26759
27455
|
},
|
|
27456
|
+
"streamBroker.acquireEgressTranscode": {
|
|
27457
|
+
capName: "stream-broker",
|
|
27458
|
+
capScope: "system",
|
|
27459
|
+
addonId: null,
|
|
27460
|
+
access: "create"
|
|
27461
|
+
},
|
|
26760
27462
|
"streamBroker.assignProfile": {
|
|
26761
27463
|
capName: "stream-broker",
|
|
26762
27464
|
capScope: "system",
|
|
@@ -26865,6 +27567,12 @@ Object.freeze({
|
|
|
26865
27567
|
addonId: null,
|
|
26866
27568
|
access: "create"
|
|
26867
27569
|
},
|
|
27570
|
+
"streamBroker.releaseEgressTranscode": {
|
|
27571
|
+
capName: "stream-broker",
|
|
27572
|
+
capScope: "system",
|
|
27573
|
+
addonId: null,
|
|
27574
|
+
access: "create"
|
|
27575
|
+
},
|
|
26868
27576
|
"streamBroker.releaseStreamWithCodec": {
|
|
26869
27577
|
capName: "stream-broker",
|
|
26870
27578
|
capScope: "system",
|
|
@@ -27345,6 +28053,12 @@ Object.freeze({
|
|
|
27345
28053
|
addonId: null,
|
|
27346
28054
|
access: "delete"
|
|
27347
28055
|
},
|
|
28056
|
+
"vectorStore.getByIds": {
|
|
28057
|
+
capName: "vector-store",
|
|
28058
|
+
capScope: "system",
|
|
28059
|
+
addonId: null,
|
|
28060
|
+
access: "view"
|
|
28061
|
+
},
|
|
27348
28062
|
"vectorStore.query": {
|
|
27349
28063
|
capName: "vector-store",
|
|
27350
28064
|
capScope: "system",
|
|
@@ -27619,37 +28333,30 @@ TimelapseRuleInputSchema.extend({
|
|
|
27619
28333
|
createdAt: number(),
|
|
27620
28334
|
updatedAt: number()
|
|
27621
28335
|
});
|
|
27622
|
-
|
|
27623
|
-
|
|
27624
|
-
*
|
|
27625
|
-
*
|
|
27626
|
-
*
|
|
27627
|
-
*
|
|
27628
|
-
*
|
|
27629
|
-
*
|
|
27630
|
-
|
|
27631
|
-
|
|
27632
|
-
|
|
27633
|
-
*
|
|
27634
|
-
*
|
|
27635
|
-
*
|
|
27636
|
-
*
|
|
27637
|
-
*
|
|
27638
|
-
*
|
|
27639
|
-
*/
|
|
27640
|
-
|
|
27641
|
-
|
|
27642
|
-
|
|
27643
|
-
|
|
27644
|
-
|
|
27645
|
-
|
|
27646
|
-
const obj = value;
|
|
27647
|
-
const out = {};
|
|
27648
|
-
for (const k of Object.keys(obj).toSorted()) out[k] = obj[k];
|
|
27649
|
-
return out;
|
|
27650
|
-
}
|
|
27651
|
-
return value;
|
|
27652
|
-
}
|
|
28336
|
+
object({
|
|
28337
|
+
/**
|
|
28338
|
+
* Fraction of the box's own size added on EACH side before cutting.
|
|
28339
|
+
*
|
|
28340
|
+
* CLIP is trained on natural images WITH surroundings; a pixel-tight crop
|
|
28341
|
+
* removes exactly the context it is strongest on (a dog cut to its outline
|
|
28342
|
+
* is a dark blob). The right value is an empirical question, which is why it
|
|
28343
|
+
* is a setting: 0 / 0.15 / 0.2 / 0.5 are the interesting points.
|
|
28344
|
+
*/
|
|
28345
|
+
paddingRatio: number().min(0).max(4),
|
|
28346
|
+
/**
|
|
28347
|
+
* Square the window (in PIXELS) before cutting.
|
|
28348
|
+
*
|
|
28349
|
+
* CLIP's input is square, so a tall bbox resized straight to NxN is squashed
|
|
28350
|
+
* — a standing person becomes a shape the model never saw. Squaring costs
|
|
28351
|
+
* extra background, which is context the model wants anyway. Off by default
|
|
28352
|
+
* because the live path has never squared and the stored index reflects that.
|
|
28353
|
+
*/
|
|
28354
|
+
square: boolean()
|
|
28355
|
+
});
|
|
28356
|
+
({
|
|
28357
|
+
paddingRatio: .15,
|
|
28358
|
+
square: false
|
|
28359
|
+
}).paddingRatio;
|
|
27653
28360
|
/**
|
|
27654
28361
|
* Compute the stable 64-char lowercase-hex fingerprint of a device's
|
|
27655
28362
|
* export-relevant shape. Two structurally-equal shapes (any feature order,
|
|
@@ -27821,6 +28528,110 @@ function firstExposedAccessorySetupUri(exposed, logger) {
|
|
|
27821
28528
|
}
|
|
27822
28529
|
}
|
|
27823
28530
|
}
|
|
28531
|
+
/**
|
|
28532
|
+
* hap-nodejs' `checkName` regex, verbatim.
|
|
28533
|
+
*
|
|
28534
|
+
* Duplicated rather than imported because it is `@private Private API` in that
|
|
28535
|
+
* package and not exported. Duplicating a private regex is a liability, so the
|
|
28536
|
+
* guard against drift is behavioural, not textual: `service-naming.spec.ts`
|
|
28537
|
+
* builds real services and asserts hap-nodejs emits ZERO characteristic
|
|
28538
|
+
* warnings — if this expression ever diverges from theirs, that test fails.
|
|
28539
|
+
*/
|
|
28540
|
+
var HAP_NAME_PATTERN = /^[\p{L}\p{N}][\p{L}\p{N}\p{Zs}’'&!._:;()/,-]*[\p{L}\p{N}]$/u;
|
|
28541
|
+
/** Characters HAP tolerates INSIDE a name. Anything else becomes a space. */
|
|
28542
|
+
var HAP_NAME_INNER = /[^\p{L}\p{N}\p{Zs}’'&!._:;()/,-]/gu;
|
|
28543
|
+
/** Would hap-nodejs accept this as a `Name` characteristic value? */
|
|
28544
|
+
function isHapServiceName(value) {
|
|
28545
|
+
return HAP_NAME_PATTERN.test(value);
|
|
28546
|
+
}
|
|
28547
|
+
/**
|
|
28548
|
+
* Build one HAP-valid service name from device-derived parts.
|
|
28549
|
+
*
|
|
28550
|
+
* Empty and absent parts are dropped rather than joined, so a missing role
|
|
28551
|
+
* never produces a double space. `fallback` is used ONLY when nothing
|
|
28552
|
+
* device-derived survives sanitisation — it is the last resort, not the
|
|
28553
|
+
* default, because a name that says nothing about the device is the defect
|
|
28554
|
+
* this module exists to end.
|
|
28555
|
+
*/
|
|
28556
|
+
function hapServiceName(parts, fallback) {
|
|
28557
|
+
const trimmed = trimToHapName(parts.map((part) => typeof part === "string" ? part : "").map((part) => part.replace(HAP_NAME_INNER, " ")).join(" ").replace(/\s+/gu, " ").trim());
|
|
28558
|
+
if (trimmed !== null) return trimmed;
|
|
28559
|
+
return trimToHapName(fallback.replace(HAP_NAME_INNER, " ").replace(/\s+/gu, " ").trim()) ?? "";
|
|
28560
|
+
}
|
|
28561
|
+
/**
|
|
28562
|
+
* The privacy-mask switch.
|
|
28563
|
+
*
|
|
28564
|
+
* The camera's own name carries the meaning; "Privacy" only says which of the
|
|
28565
|
+
* camera's switches this is. It used to be the WHOLE name, which is why the
|
|
28566
|
+
* operator saw a switch that named neither its camera nor, with two cameras
|
|
28567
|
+
* exposed, which camera it belonged to.
|
|
28568
|
+
*/
|
|
28569
|
+
function privacyServiceName(deviceName) {
|
|
28570
|
+
return hapServiceName([deviceName, PRIVACY_SUFFIX], PRIVACY_SUFFIX);
|
|
28571
|
+
}
|
|
28572
|
+
/**
|
|
28573
|
+
* Deliberately not localised, and deliberately not a translation table.
|
|
28574
|
+
*
|
|
28575
|
+
* The device half of the name is the operator's own text and arrives in the
|
|
28576
|
+
* operator's language. This half names a camstack capability (`privacy-mask`)
|
|
28577
|
+
* and there is no locale in an addon's context to resolve it against; the word
|
|
28578
|
+
* is also identical in the operator's language. A translation layer for one
|
|
28579
|
+
* word would be the kind of leftover that reads as verification.
|
|
28580
|
+
*/
|
|
28581
|
+
var PRIVACY_SUFFIX = "Privacy";
|
|
28582
|
+
/**
|
|
28583
|
+
* An accessory child (siren, floodlight, spotlight) rendered as a service on
|
|
28584
|
+
* the parent camera.
|
|
28585
|
+
*
|
|
28586
|
+
* The child's OWN stored name wins. It is the string the operator typed, in
|
|
28587
|
+
* the operator's language, and the previous rule threw it away: `role` was
|
|
28588
|
+
* consulted first and title-cased, so every siren on the fleet published as
|
|
28589
|
+
* the English word "Siren" no matter what the operator had called it.
|
|
28590
|
+
*
|
|
28591
|
+
* The parent name is prefixed only when the child's name does not already
|
|
28592
|
+
* carry it — providers name children both ways ("Sirena" and "Videocamera
|
|
28593
|
+
* cucina Sirena") and the result must be one form, not two.
|
|
28594
|
+
*/
|
|
28595
|
+
function childServiceName(parentName, child) {
|
|
28596
|
+
const own = child.name.trim();
|
|
28597
|
+
if (own.length > 0) return mentions(own, parentName) ? hapServiceName([own], parentName) : hapServiceName([parentName, own], own);
|
|
28598
|
+
return hapServiceName([parentName, typeof child.role === "string" ? titleCase(child.role) : ""], parentName);
|
|
28599
|
+
}
|
|
28600
|
+
/**
|
|
28601
|
+
* A PTZ action switch.
|
|
28602
|
+
*
|
|
28603
|
+
* The action labels themselves stay in `ptz-labels.ts` — they name a HomeKit
|
|
28604
|
+
* control, not a device — but the service is still qualified by the camera, so
|
|
28605
|
+
* a home with two PTZ cameras does not publish two switches called "Preset
|
|
28606
|
+
* ingresso".
|
|
28607
|
+
*/
|
|
28608
|
+
function ptzServiceName(deviceName, actionLabel) {
|
|
28609
|
+
return hapServiceName([deviceName, actionLabel], actionLabel);
|
|
28610
|
+
}
|
|
28611
|
+
/** Does `name` already contain `parentName` as a whole word? */
|
|
28612
|
+
function mentions(name, parentName) {
|
|
28613
|
+
const needle = parentName.trim().toLowerCase();
|
|
28614
|
+
if (needle.length === 0) return true;
|
|
28615
|
+
return name.toLowerCase().includes(needle);
|
|
28616
|
+
}
|
|
28617
|
+
/**
|
|
28618
|
+
* Truncate to the HAP ceiling and shave any leading/trailing character the
|
|
28619
|
+
* pattern forbids. Returns `null` when nothing usable is left — the caller
|
|
28620
|
+
* decides what to do with that, because "fall back" and "drop the part" are
|
|
28621
|
+
* different answers.
|
|
28622
|
+
*/
|
|
28623
|
+
function trimToHapName(value) {
|
|
28624
|
+
let out = value.length > 64 ? value.slice(0, 64) : value;
|
|
28625
|
+
while (out.length > 0 && !isAlphanumeric(out[out.length - 1])) out = out.slice(0, -1);
|
|
28626
|
+
while (out.length > 0 && !isAlphanumeric(out[0])) out = out.slice(1);
|
|
28627
|
+
return isHapServiceName(out) ? out : null;
|
|
28628
|
+
}
|
|
28629
|
+
function isAlphanumeric(ch) {
|
|
28630
|
+
return ch !== void 0 && /[\p{L}\p{N}]/u.test(ch);
|
|
28631
|
+
}
|
|
28632
|
+
function titleCase(raw) {
|
|
28633
|
+
return raw.split(/[-_\s]+/u).filter((part) => part.length > 0).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
|
|
28634
|
+
}
|
|
27824
28635
|
//#endregion
|
|
27825
28636
|
//#region src/mappers/builders/battery.ts
|
|
27826
28637
|
/**
|
|
@@ -27845,7 +28656,7 @@ var LOW_BATTERY_THRESHOLD_PCT = 20;
|
|
|
27845
28656
|
async function buildBattery(bctx) {
|
|
27846
28657
|
const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
|
|
27847
28658
|
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
27848
|
-
const service = accessory.addService(_homebridge_hap_nodejs.Service.Battery, displayName);
|
|
28659
|
+
const service = accessory.addService(_homebridge_hap_nodejs.Service.Battery, hapServiceName([displayName], `Camera ${numericDeviceId}`));
|
|
27849
28660
|
try {
|
|
27850
28661
|
const status = await proxy.battery?.getStatus({});
|
|
27851
28662
|
if (status) applyToService(service, status);
|
|
@@ -38175,13 +38986,1035 @@ var require_src = /* @__PURE__ */ __commonJSMin(((exports) => {
|
|
|
38175
38986
|
__exportStar(require_util(), exports);
|
|
38176
38987
|
}));
|
|
38177
38988
|
//#endregion
|
|
38178
|
-
//#region src/mappers/builders/
|
|
38989
|
+
//#region src/mappers/builders/rtcp-gate.ts
|
|
38179
38990
|
var import_src = require_src();
|
|
38991
|
+
/** `packet@42ms` / `timeout@1000ms` — one compact log field. */
|
|
38992
|
+
function formatRtcpGate(resolution) {
|
|
38993
|
+
if (resolution === null) return "pending";
|
|
38994
|
+
return `${resolution.reason}@${resolution.waitedMs}ms`;
|
|
38995
|
+
}
|
|
38996
|
+
/**
|
|
38997
|
+
* Resolve as soon as ANY packet arrives on `socket`, or after `timeoutMs`.
|
|
38998
|
+
* One-shot: the listener is detached on either path.
|
|
38999
|
+
*/
|
|
39000
|
+
function makeRtcpGate(socket, timeoutMs) {
|
|
39001
|
+
const armedAt = Date.now();
|
|
39002
|
+
return new Promise((resolve) => {
|
|
39003
|
+
const onMessage = () => {
|
|
39004
|
+
socket.removeListener("message", onMessage);
|
|
39005
|
+
clearTimeout(timer);
|
|
39006
|
+
resolve({
|
|
39007
|
+
reason: "packet",
|
|
39008
|
+
waitedMs: Date.now() - armedAt
|
|
39009
|
+
});
|
|
39010
|
+
};
|
|
39011
|
+
const timer = setTimeout(() => {
|
|
39012
|
+
socket.removeListener("message", onMessage);
|
|
39013
|
+
resolve({
|
|
39014
|
+
reason: "timeout",
|
|
39015
|
+
waitedMs: Date.now() - armedAt
|
|
39016
|
+
});
|
|
39017
|
+
}, timeoutMs);
|
|
39018
|
+
socket.on("message", onMessage);
|
|
39019
|
+
});
|
|
39020
|
+
}
|
|
39021
|
+
/** V/P/RC byte + PT byte + 16-bit length. */
|
|
39022
|
+
var RTCP_HEADER_BYTES = 4;
|
|
39023
|
+
/** SSRC of the packet sender, first field of both SR and RR bodies. */
|
|
39024
|
+
var RTCP_SENDER_SSRC_BYTES = 4;
|
|
39025
|
+
/** NTP + RTP timestamp, packet count, octet count — present only on an SR. */
|
|
39026
|
+
var RTCP_SENDER_INFO_BYTES = 20;
|
|
39027
|
+
/** One report block: SSRC_n, loss, highest seq, jitter, LSR, DLSR. */
|
|
39028
|
+
var RTCP_REPORT_BLOCK_BYTES = 24;
|
|
39029
|
+
/** `fraction lost` is an 8-bit fixed-point fraction of 256. */
|
|
39030
|
+
var FRACTION_LOST_DENOMINATOR = 256;
|
|
39031
|
+
/** `delay since last SR` counts 1/65536 of a second. */
|
|
39032
|
+
var DLSR_UNITS_PER_SECOND = 65536;
|
|
39033
|
+
/** `cumulative number of packets lost` is a SIGNED 24-bit field. */
|
|
39034
|
+
var SIGNED_24_SIGN_BIT = 8388608;
|
|
39035
|
+
var SIGNED_24_MODULUS = 16777216;
|
|
39036
|
+
function readSigned24(packet, at) {
|
|
39037
|
+
const raw = packet[at] << 16 | packet[at + 1] << 8 | packet[at + 2];
|
|
39038
|
+
return raw >= SIGNED_24_SIGN_BIT ? raw - SIGNED_24_MODULUS : raw;
|
|
39039
|
+
}
|
|
39040
|
+
function readReportBlock(packet, at) {
|
|
39041
|
+
const fractionLostRaw = packet[at + 4];
|
|
39042
|
+
const dlsr = packet.readUInt32BE(at + 20);
|
|
39043
|
+
return {
|
|
39044
|
+
aboutSsrc: packet.readUInt32BE(at),
|
|
39045
|
+
fractionLostRaw,
|
|
39046
|
+
fractionLostPct: Math.round(fractionLostRaw / FRACTION_LOST_DENOMINATOR * 1e3) / 10,
|
|
39047
|
+
cumulativePacketsLost: readSigned24(packet, at + 5),
|
|
39048
|
+
extendedHighestSequence: packet.readUInt32BE(at + 8),
|
|
39049
|
+
jitter: packet.readUInt32BE(at + 12),
|
|
39050
|
+
lastSrTimestamp: packet.readUInt32BE(at + 16),
|
|
39051
|
+
delaySinceLastSrMs: Math.round(dlsr / DLSR_UNITS_PER_SECOND * 1e3)
|
|
39052
|
+
};
|
|
39053
|
+
}
|
|
39054
|
+
/**
|
|
39055
|
+
* Walk a (possibly compound) RTCP datagram and extract every report block.
|
|
39056
|
+
*
|
|
39057
|
+
* An RR is routinely bundled behind an SDES, and a controller with nothing to
|
|
39058
|
+
* report yet sends an RR with a reception-report count of zero. Both are
|
|
39059
|
+
* normal; neither is a failure. What IS a failure is a packet whose declared
|
|
39060
|
+
* length runs past the buffer or whose report count exceeds its own body —
|
|
39061
|
+
* those get named so a decrypt fault cannot masquerade as silence.
|
|
39062
|
+
*/
|
|
39063
|
+
function parseCompoundRtcp(packet) {
|
|
39064
|
+
if (packet.length === 0) return {
|
|
39065
|
+
ok: false,
|
|
39066
|
+
failure: "empty",
|
|
39067
|
+
atOffset: 0
|
|
39068
|
+
};
|
|
39069
|
+
const packetTypes = [];
|
|
39070
|
+
const reports = [];
|
|
39071
|
+
let offset = 0;
|
|
39072
|
+
while (offset < packet.length) {
|
|
39073
|
+
if (packet.length - offset < RTCP_HEADER_BYTES) return {
|
|
39074
|
+
ok: false,
|
|
39075
|
+
failure: "short-header",
|
|
39076
|
+
atOffset: offset
|
|
39077
|
+
};
|
|
39078
|
+
const firstByte = packet[offset];
|
|
39079
|
+
if ((firstByte >> 6 & 3) !== 2) return {
|
|
39080
|
+
ok: false,
|
|
39081
|
+
failure: "bad-version",
|
|
39082
|
+
atOffset: offset
|
|
39083
|
+
};
|
|
39084
|
+
const reportCount = firstByte & 31;
|
|
39085
|
+
const packetType = packet[offset + 1];
|
|
39086
|
+
const totalBytes = (packet.readUInt16BE(offset + 2) + 1) * 4;
|
|
39087
|
+
if (offset + totalBytes > packet.length) return {
|
|
39088
|
+
ok: false,
|
|
39089
|
+
failure: "length-overrun",
|
|
39090
|
+
atOffset: offset
|
|
39091
|
+
};
|
|
39092
|
+
packetTypes.push(packetType);
|
|
39093
|
+
if (packetType === 201 || packetType === 200) {
|
|
39094
|
+
const senderSsrcAt = offset + RTCP_HEADER_BYTES;
|
|
39095
|
+
const blocksAt = senderSsrcAt + RTCP_SENDER_SSRC_BYTES + (packetType === 200 ? RTCP_SENDER_INFO_BYTES : 0);
|
|
39096
|
+
if (blocksAt + reportCount * RTCP_REPORT_BLOCK_BYTES > offset + totalBytes) return {
|
|
39097
|
+
ok: false,
|
|
39098
|
+
failure: "truncated-body",
|
|
39099
|
+
atOffset: offset
|
|
39100
|
+
};
|
|
39101
|
+
const blocks = [];
|
|
39102
|
+
for (let index = 0; index < reportCount; index += 1) blocks.push(readReportBlock(packet, blocksAt + index * RTCP_REPORT_BLOCK_BYTES));
|
|
39103
|
+
reports.push({
|
|
39104
|
+
packetType,
|
|
39105
|
+
reporterSsrc: packet.readUInt32BE(senderSsrcAt),
|
|
39106
|
+
blocks
|
|
39107
|
+
});
|
|
39108
|
+
}
|
|
39109
|
+
offset += totalBytes;
|
|
39110
|
+
}
|
|
39111
|
+
return {
|
|
39112
|
+
ok: true,
|
|
39113
|
+
packetTypes,
|
|
39114
|
+
reports
|
|
39115
|
+
};
|
|
39116
|
+
}
|
|
39117
|
+
function emptyReceiverReportTally() {
|
|
39118
|
+
return {
|
|
39119
|
+
reportsParsed: 0,
|
|
39120
|
+
blocksParsed: 0,
|
|
39121
|
+
unreadable: 0,
|
|
39122
|
+
lastFractionLostPct: null,
|
|
39123
|
+
worstFractionLostPct: null,
|
|
39124
|
+
lastCumulativePacketsLost: null,
|
|
39125
|
+
maxCumulativePacketsLost: null,
|
|
39126
|
+
lastJitter: null,
|
|
39127
|
+
maxJitter: null,
|
|
39128
|
+
lastExtendedHighestSequence: null
|
|
39129
|
+
};
|
|
39130
|
+
}
|
|
39131
|
+
function maxOrValue(previous, next) {
|
|
39132
|
+
return previous === null ? next : Math.max(previous, next);
|
|
39133
|
+
}
|
|
39134
|
+
/** Fold one report block into the tally. Returns a new tally. */
|
|
39135
|
+
function applyReceiverReportBlock(tally, block) {
|
|
39136
|
+
return {
|
|
39137
|
+
...tally,
|
|
39138
|
+
blocksParsed: tally.blocksParsed + 1,
|
|
39139
|
+
lastFractionLostPct: block.fractionLostPct,
|
|
39140
|
+
worstFractionLostPct: maxOrValue(tally.worstFractionLostPct, block.fractionLostPct),
|
|
39141
|
+
lastCumulativePacketsLost: block.cumulativePacketsLost,
|
|
39142
|
+
maxCumulativePacketsLost: maxOrValue(tally.maxCumulativePacketsLost, block.cumulativePacketsLost),
|
|
39143
|
+
lastJitter: block.jitter,
|
|
39144
|
+
maxJitter: maxOrValue(tally.maxJitter, block.jitter),
|
|
39145
|
+
lastExtendedHighestSequence: block.extendedHighestSequence
|
|
39146
|
+
};
|
|
39147
|
+
}
|
|
39148
|
+
/** Book an RTCP datagram we could not read. Counted, never discarded silently. */
|
|
39149
|
+
function recordUnreadableRtcp(tally) {
|
|
39150
|
+
return {
|
|
39151
|
+
...tally,
|
|
39152
|
+
unreadable: tally.unreadable + 1
|
|
39153
|
+
};
|
|
39154
|
+
}
|
|
39155
|
+
/**
|
|
39156
|
+
* Parse one decrypted RTCP datagram and fold it into a leg's tally.
|
|
39157
|
+
*
|
|
39158
|
+
* This is the seam the delegate calls: it owns SRTCP decryption and logging,
|
|
39159
|
+
* this owns everything that can be asserted from bytes alone. A malformed
|
|
39160
|
+
* packet returns a failure and an incremented `unreadable` — it never throws,
|
|
39161
|
+
* because this runs on a UDP `message` handler where a throw would take the
|
|
39162
|
+
* session with it.
|
|
39163
|
+
*/
|
|
39164
|
+
function ingestDecryptedRtcp(plaintext, tally) {
|
|
39165
|
+
const parsed = parseCompoundRtcp(plaintext);
|
|
39166
|
+
if (!parsed.ok) return {
|
|
39167
|
+
tally: recordUnreadableRtcp(tally),
|
|
39168
|
+
reports: [],
|
|
39169
|
+
failure: parsed.failure
|
|
39170
|
+
};
|
|
39171
|
+
let next = tally;
|
|
39172
|
+
for (const report of parsed.reports) {
|
|
39173
|
+
if (report.blocks.length === 0) continue;
|
|
39174
|
+
next = {
|
|
39175
|
+
...next,
|
|
39176
|
+
reportsParsed: next.reportsParsed + 1
|
|
39177
|
+
};
|
|
39178
|
+
for (const block of report.blocks) next = applyReceiverReportBlock(next, block);
|
|
39179
|
+
}
|
|
39180
|
+
return {
|
|
39181
|
+
tally: next,
|
|
39182
|
+
reports: parsed.reports,
|
|
39183
|
+
failure: null
|
|
39184
|
+
};
|
|
39185
|
+
}
|
|
39186
|
+
//#endregion
|
|
39187
|
+
//#region src/mappers/builders/stream-bitrate.ts
|
|
39188
|
+
/**
|
|
39189
|
+
* Fraction of the negotiated ceiling we actually aim the encoder at.
|
|
39190
|
+
*
|
|
39191
|
+
* `max_bit_rate` is what the controller budgeted for the stream; what crosses
|
|
39192
|
+
* the wire is the encoded payload PLUS its packetisation. At `mtu = 1378` each
|
|
39193
|
+
* packet carries a 12-byte RTP header and a 10-byte SRTP auth tag, and the
|
|
39194
|
+
* datagram adds 8 (UDP) + 20 (IPv4) — 50 bytes on ~1378, i.e. **3.6 %**. The
|
|
39195
|
+
* remaining ~6 % is margin for a VBV overshoot inside the buffer window.
|
|
39196
|
+
* Reserving it is the difference between "at the ceiling" and "over it".
|
|
39197
|
+
*/
|
|
39198
|
+
var BITRATE_HEADROOM = .9;
|
|
39199
|
+
/** Encoder slots a `stream-params` provider exposes, in the cap's own order. */
|
|
39200
|
+
var ENCODER_PROFILE_KEYS = [
|
|
39201
|
+
"main",
|
|
39202
|
+
"sub",
|
|
39203
|
+
"ext"
|
|
39204
|
+
];
|
|
39205
|
+
/**
|
|
39206
|
+
* Resolve every profile slot's rate from the camera's configuration, with the
|
|
39207
|
+
* broker's flow reading kept alongside it as a lower bound.
|
|
39208
|
+
*
|
|
39209
|
+
* A slot with neither simply carries two nulls — absence is never rendered as
|
|
39210
|
+
* a number, because a wrong number here silently re-creates the overshoot this
|
|
39211
|
+
* module exists to end.
|
|
39212
|
+
*/
|
|
39213
|
+
function resolveProfileBitrates(input) {
|
|
39214
|
+
const camStreamById = /* @__PURE__ */ new Map();
|
|
39215
|
+
for (const stream of input.camStreams) camStreamById.set(stream.camStreamId, stream);
|
|
39216
|
+
const measuredByProfile = /* @__PURE__ */ new Map();
|
|
39217
|
+
for (const choice of input.choices) {
|
|
39218
|
+
if (choice.target.kind !== "profile") continue;
|
|
39219
|
+
const plausible = plausibleMeasured(choice.bitrateKbps);
|
|
39220
|
+
if (plausible !== null) measuredByProfile.set(choice.target.profile, plausible);
|
|
39221
|
+
}
|
|
39222
|
+
const out = /* @__PURE__ */ new Map();
|
|
39223
|
+
for (const slot of input.slots) {
|
|
39224
|
+
const source = slot.sourceCamStreamId === null ? void 0 : camStreamById.get(slot.sourceCamStreamId);
|
|
39225
|
+
out.set(slot.profile, {
|
|
39226
|
+
profile: slot.profile,
|
|
39227
|
+
publishedKbps: publishedRateFor(slot, source, input.streamParams),
|
|
39228
|
+
measuredKbps: measuredByProfile.get(slot.profile) ?? null
|
|
39229
|
+
});
|
|
39230
|
+
}
|
|
39231
|
+
for (const [profile, measured] of measuredByProfile) {
|
|
39232
|
+
if (out.has(profile)) continue;
|
|
39233
|
+
out.set(profile, {
|
|
39234
|
+
profile,
|
|
39235
|
+
publishedKbps: null,
|
|
39236
|
+
measuredKbps: measured
|
|
39237
|
+
});
|
|
39238
|
+
}
|
|
39239
|
+
return out;
|
|
39240
|
+
}
|
|
39241
|
+
/**
|
|
39242
|
+
* The rate the encoder may actually use, or `null` when the controller
|
|
39243
|
+
* negotiated no usable ceiling.
|
|
39244
|
+
*/
|
|
39245
|
+
function budgetForNegotiatedRate(negotiatedMaxBitrateKbps) {
|
|
39246
|
+
if (!Number.isFinite(negotiatedMaxBitrateKbps) || negotiatedMaxBitrateKbps <= 0) return null;
|
|
39247
|
+
const budget = Math.floor(negotiatedMaxBitrateKbps * BITRATE_HEADROOM);
|
|
39248
|
+
return budget > 0 ? budget : null;
|
|
39249
|
+
}
|
|
39250
|
+
/**
|
|
39251
|
+
* Does this slot fit?
|
|
39252
|
+
*
|
|
39253
|
+
* Only the CONFIGURED rate can answer yes. The measured rate is a lower bound,
|
|
39254
|
+
* so it is allowed to answer no — including against an optimistic publication.
|
|
39255
|
+
*/
|
|
39256
|
+
function classifyBitrateFit(evidence, budgetKbps) {
|
|
39257
|
+
if (evidence === void 0) return "unknown";
|
|
39258
|
+
const { publishedKbps, measuredKbps } = evidence;
|
|
39259
|
+
if (measuredKbps !== null && measuredKbps > budgetKbps) return "over-budget";
|
|
39260
|
+
if (publishedKbps === null) return "unknown";
|
|
39261
|
+
return publishedKbps <= budgetKbps ? "fits" : "over-budget";
|
|
39262
|
+
}
|
|
39263
|
+
/**
|
|
39264
|
+
* Pick the stream to serve, and decide whether it can be passed through.
|
|
39265
|
+
*
|
|
39266
|
+
* The selection runs the SAME `pickPreferredRtspEntry` the advertisement is
|
|
39267
|
+
* derived from (D51) — only the candidate set narrows. When at least one
|
|
39268
|
+
* pass-through-capable slot fits the budget, the picker resolves the target
|
|
39269
|
+
* resolution among those and we copy. Otherwise the picker resolves among ALL
|
|
39270
|
+
* entries — so the transcode decodes the slot closest to the negotiated
|
|
39271
|
+
* resolution rather than the largest one on the camera — and we re-encode.
|
|
39272
|
+
*
|
|
39273
|
+
* A pinned `streamPreference` is never overridden by the budget: the pinned
|
|
39274
|
+
* slot is transcoded rather than swapped for a cheaper one.
|
|
39275
|
+
*
|
|
39276
|
+
* Returns `null` when nothing is publishable at all.
|
|
39277
|
+
*/
|
|
39278
|
+
function selectStreamForBudget(input) {
|
|
39279
|
+
const budgetKbps = budgetForNegotiatedRate(input.negotiatedMaxBitrateKbps);
|
|
39280
|
+
const notes = fitNotes(input.entries, input.bitrates, budgetKbps);
|
|
39281
|
+
const fallback = pickPreferredRtspEntry(input.entries, input.pref, input.deviceId, { targetResolution: input.targetResolution });
|
|
39282
|
+
if (fallback === null) return null;
|
|
39283
|
+
const fallbackProfile = toCamProfile$1(fallback.profileId);
|
|
39284
|
+
if (budgetKbps === null) {
|
|
39285
|
+
const base = {
|
|
39286
|
+
picked: fallback,
|
|
39287
|
+
profile: fallbackProfile,
|
|
39288
|
+
budgetKbps: null,
|
|
39289
|
+
notes
|
|
39290
|
+
};
|
|
39291
|
+
return canPassThrough(fallback.codec) ? {
|
|
39292
|
+
kind: "copy",
|
|
39293
|
+
reason: "no-negotiated-budget",
|
|
39294
|
+
...base
|
|
39295
|
+
} : {
|
|
39296
|
+
kind: "transcode",
|
|
39297
|
+
reason: "source-codec",
|
|
39298
|
+
...base
|
|
39299
|
+
};
|
|
39300
|
+
}
|
|
39301
|
+
const affordable = (input.pref !== "auto" && fallbackProfile === input.pref ? input.entries.filter((entry) => entry.profile === fallbackProfile) : input.entries).filter((entry) => {
|
|
39302
|
+
if (!canPassThrough(entry.codec)) return false;
|
|
39303
|
+
return classifyBitrateFit(input.bitrates.get(entry.profile), budgetKbps) === "fits";
|
|
39304
|
+
});
|
|
39305
|
+
if (affordable.length > 0) {
|
|
39306
|
+
const picked = pickPreferredRtspEntry(affordable, input.pref, input.deviceId, { targetResolution: input.targetResolution });
|
|
39307
|
+
if (picked !== null) return {
|
|
39308
|
+
kind: "copy",
|
|
39309
|
+
reason: "source-fits-budget",
|
|
39310
|
+
picked,
|
|
39311
|
+
profile: toCamProfile$1(picked.profileId),
|
|
39312
|
+
budgetKbps,
|
|
39313
|
+
notes
|
|
39314
|
+
};
|
|
39315
|
+
}
|
|
39316
|
+
if (canPassThrough(fallback.codec)) return {
|
|
39317
|
+
kind: "copy",
|
|
39318
|
+
reason: "over-budget-tolerated",
|
|
39319
|
+
picked: fallback,
|
|
39320
|
+
profile: fallbackProfile,
|
|
39321
|
+
budgetKbps,
|
|
39322
|
+
notes
|
|
39323
|
+
};
|
|
39324
|
+
return {
|
|
39325
|
+
kind: "transcode",
|
|
39326
|
+
reason: transcodeReason(fallback, fallbackProfile, input.bitrates, budgetKbps),
|
|
39327
|
+
picked: fallback,
|
|
39328
|
+
profile: fallbackProfile,
|
|
39329
|
+
budgetKbps,
|
|
39330
|
+
notes
|
|
39331
|
+
};
|
|
39332
|
+
}
|
|
39333
|
+
/**
|
|
39334
|
+
* Fill in a codec the profile restream entry did not carry, from its broker
|
|
39335
|
+
* slot. `getProfileRtspEntries` has historically omitted it for legacy
|
|
39336
|
+
* entries, and a slot whose codec is unknown must not be mistaken for H.264.
|
|
39337
|
+
*/
|
|
39338
|
+
function withSlotCodecs(entries, slots) {
|
|
39339
|
+
const codecByProfile = /* @__PURE__ */ new Map();
|
|
39340
|
+
for (const slot of slots) if (slot.codec !== void 0) codecByProfile.set(slot.profile, slot.codec);
|
|
39341
|
+
return entries.map((entry) => {
|
|
39342
|
+
if (entry.codec !== void 0) return entry;
|
|
39343
|
+
const codec = codecByProfile.get(entry.profile);
|
|
39344
|
+
return codec === void 0 ? entry : {
|
|
39345
|
+
...entry,
|
|
39346
|
+
codec
|
|
39347
|
+
};
|
|
39348
|
+
});
|
|
39349
|
+
}
|
|
39350
|
+
/**
|
|
39351
|
+
* The rate we will actually deliver: never more than the controller asked for,
|
|
39352
|
+
* and never more than the source produces. `-r` above the source rate makes
|
|
39353
|
+
* ffmpeg DUPLICATE frames, which spends the budget on nothing.
|
|
39354
|
+
*/
|
|
39355
|
+
function deliverableFps(negotiatedFps, slotFps) {
|
|
39356
|
+
if (slotFps === null || !Number.isFinite(slotFps) || slotFps <= 0) return negotiatedFps;
|
|
39357
|
+
return Math.min(negotiatedFps, Math.floor(slotFps));
|
|
39358
|
+
}
|
|
39359
|
+
/**
|
|
39360
|
+
* The ffmpeg video-output arguments.
|
|
39361
|
+
*
|
|
39362
|
+
* Pass-through carries `-bsf:v dump_extra` so every IDR inlines its own
|
|
39363
|
+
* SPS/PPS — sources that publish parameter sets only in the RTSP SDP hand iOS
|
|
39364
|
+
* a keyframe it cannot decode otherwise.
|
|
39365
|
+
*
|
|
39366
|
+
* The transcode's cap is three flags, not one: `-b:v` is an average and on its
|
|
39367
|
+
* own permits exactly the burst that was measured. `-maxrate` plus a
|
|
39368
|
+
* **one-second** `-bufsize` bounds any one-second window at the negotiated
|
|
39369
|
+
* rate, which is also the only lever available on the 3.03 s peak jitter — the
|
|
39370
|
+
* VBV window is what forces x264 to size a key frame to fit rather than
|
|
39371
|
+
* emitting it as one tight burst.
|
|
39372
|
+
*/
|
|
39373
|
+
/**
|
|
39374
|
+
* Seconds between forced IDRs on the transcode path.
|
|
39375
|
+
*
|
|
39376
|
+
* A join can only start decoding at a key frame, so this is the worst-case
|
|
39377
|
+
* wait a controller pays before the first picture — and the bound the RTSP
|
|
39378
|
+
* join-burst withhold falls back on when it declines to replay a wide GOP.
|
|
39379
|
+
*/
|
|
39380
|
+
var KEYFRAME_INTERVAL_SEC = 4;
|
|
39381
|
+
function buildVideoEncodeArgs(input) {
|
|
39382
|
+
if (!input.transcode) return [
|
|
39383
|
+
"-c:v",
|
|
39384
|
+
"copy",
|
|
39385
|
+
"-bsf:v",
|
|
39386
|
+
"dump_extra"
|
|
39387
|
+
];
|
|
39388
|
+
const rate = input.budgetKbps === null ? [] : [
|
|
39389
|
+
"-b:v",
|
|
39390
|
+
`${input.budgetKbps}k`,
|
|
39391
|
+
"-maxrate",
|
|
39392
|
+
`${input.budgetKbps}k`,
|
|
39393
|
+
"-bufsize",
|
|
39394
|
+
`${input.budgetKbps}k`
|
|
39395
|
+
];
|
|
39396
|
+
return [
|
|
39397
|
+
"-c:v",
|
|
39398
|
+
"libx264",
|
|
39399
|
+
"-preset",
|
|
39400
|
+
"ultrafast",
|
|
39401
|
+
"-tune",
|
|
39402
|
+
"zerolatency",
|
|
39403
|
+
"-pix_fmt",
|
|
39404
|
+
"yuv420p",
|
|
39405
|
+
"-r",
|
|
39406
|
+
String(input.fps),
|
|
39407
|
+
"-s",
|
|
39408
|
+
`${input.width}x${input.height}`,
|
|
39409
|
+
"-g",
|
|
39410
|
+
String(Math.max(1, Math.round(input.fps * KEYFRAME_INTERVAL_SEC))),
|
|
39411
|
+
...rate,
|
|
39412
|
+
"-profile:v",
|
|
39413
|
+
"baseline",
|
|
39414
|
+
"-level",
|
|
39415
|
+
"3.1",
|
|
39416
|
+
"-bsf:v",
|
|
39417
|
+
"dump_extra"
|
|
39418
|
+
];
|
|
39419
|
+
}
|
|
39420
|
+
/** Compact `mid=2048pub/9meas:over-budget` rendering for a single log field. */
|
|
39421
|
+
function formatFitNotes(notes) {
|
|
39422
|
+
return notes.map((n) => `${n.profile}=${n.publishedKbps ?? "?"}pub/${n.measuredKbps ?? "?"}meas:${n.verdict}`);
|
|
39423
|
+
}
|
|
39424
|
+
function fitNotes(entries, bitrates, budgetKbps) {
|
|
39425
|
+
return entries.map((entry) => {
|
|
39426
|
+
const evidence = bitrates.get(entry.profile);
|
|
39427
|
+
return {
|
|
39428
|
+
profile: entry.profile,
|
|
39429
|
+
verdict: budgetKbps === null ? "unknown" : classifyBitrateFit(evidence, budgetKbps),
|
|
39430
|
+
publishedKbps: evidence?.publishedKbps ?? null,
|
|
39431
|
+
measuredKbps: evidence?.measuredKbps ?? null
|
|
39432
|
+
};
|
|
39433
|
+
});
|
|
39434
|
+
}
|
|
39435
|
+
function transcodeReason(picked, profile, bitrates, budgetKbps) {
|
|
39436
|
+
if (!canPassThrough(picked.codec)) return "source-codec";
|
|
39437
|
+
if (profile === null) return "unknown-bitrate";
|
|
39438
|
+
return classifyBitrateFit(bitrates.get(profile), budgetKbps) === "over-budget" ? "over-budget" : "unknown-bitrate";
|
|
39439
|
+
}
|
|
39440
|
+
/**
|
|
39441
|
+
* iOS Home renders only H.264 over the classic HAP SRTP path, so an H.265
|
|
39442
|
+
* source can never be passed through. An UNKNOWN codec is treated the same
|
|
39443
|
+
* way: guessing H.264 is how a camera that changed its encoder ends up
|
|
39444
|
+
* shipping bytes no controller can decode.
|
|
39445
|
+
*/
|
|
39446
|
+
function canPassThrough(codec) {
|
|
39447
|
+
if (codec === void 0) return false;
|
|
39448
|
+
const lower = codec.toLowerCase();
|
|
39449
|
+
if (lower.includes("h265") || lower.includes("hevc")) return false;
|
|
39450
|
+
return lower.includes("h264") || lower.includes("avc");
|
|
39451
|
+
}
|
|
39452
|
+
function plausibleMeasured(value) {
|
|
39453
|
+
if (value === null || !Number.isFinite(value)) return null;
|
|
39454
|
+
return value >= 64 ? value : null;
|
|
39455
|
+
}
|
|
39456
|
+
/**
|
|
39457
|
+
* Map a profile slot onto the camera encoder feeding it, and read that
|
|
39458
|
+
* encoder's configured bitrate.
|
|
39459
|
+
*
|
|
39460
|
+
* The link is the slot's assigned cam-stream: its resolution (and frame rate,
|
|
39461
|
+
* when two encoders share a resolution) identifies which of `main`/`sub`/`ext`
|
|
39462
|
+
* produces it. Vendor-neutral on purpose — the cam-stream ids (`native:main`,
|
|
39463
|
+
* `native:slot-3`, …) are provider strings and matching on them would work for
|
|
39464
|
+
* exactly one provider. An ambiguous match returns `null`.
|
|
39465
|
+
*/
|
|
39466
|
+
function publishedRateFor(slot, source, streamParams) {
|
|
39467
|
+
if (streamParams === null) return null;
|
|
39468
|
+
const resolution = source?.resolution ?? slot.resolution;
|
|
39469
|
+
if (resolution === void 0) return null;
|
|
39470
|
+
const byResolution = ENCODER_PROFILE_KEYS.map((key) => encoderConfig(streamParams, key)).filter((cfg) => cfg !== null && cfg.width === resolution.width && cfg.height === resolution.height);
|
|
39471
|
+
if (byResolution.length === 1) return positiveOrNull(byResolution[0]?.bitrate);
|
|
39472
|
+
const fps = source?.fps;
|
|
39473
|
+
if (fps === void 0) return null;
|
|
39474
|
+
const byFps = byResolution.filter((cfg) => cfg !== null && Math.floor(cfg.framerate) === Math.floor(fps));
|
|
39475
|
+
return byFps.length === 1 ? positiveOrNull(byFps[0]?.bitrate) : null;
|
|
39476
|
+
}
|
|
39477
|
+
function encoderConfig(status, key) {
|
|
39478
|
+
return status[key] ?? null;
|
|
39479
|
+
}
|
|
39480
|
+
function positiveOrNull(value) {
|
|
39481
|
+
if (value === void 0 || !Number.isFinite(value) || value <= 0) return null;
|
|
39482
|
+
return value;
|
|
39483
|
+
}
|
|
39484
|
+
var CAM_PROFILES$1 = [
|
|
39485
|
+
"high",
|
|
39486
|
+
"mid",
|
|
39487
|
+
"low"
|
|
39488
|
+
];
|
|
39489
|
+
/**
|
|
39490
|
+
* `PickedStream.profileId` is the brokerId suffix, which for a profile-keyed
|
|
39491
|
+
* entry IS the profile name. Anything else addresses a raw cam-stream and must
|
|
39492
|
+
* not be coerced into a profile.
|
|
39493
|
+
*/
|
|
39494
|
+
function toCamProfile$1(profileId) {
|
|
39495
|
+
return CAM_PROFILES$1.find((p) => p === profileId) ?? null;
|
|
39496
|
+
}
|
|
39497
|
+
//#endregion
|
|
39498
|
+
//#region src/mappers/builders/stream-bitrate-probe.ts
|
|
39499
|
+
/**
|
|
39500
|
+
* Resolve every profile slot's rate. Never throws: an unresolvable slot ends
|
|
39501
|
+
* up `unknown`, which the selector treats as "cannot prove it fits" and
|
|
39502
|
+
* therefore transcodes — the safe direction on the wire.
|
|
39503
|
+
*/
|
|
39504
|
+
async function probeProfileBitrates(input) {
|
|
39505
|
+
const { proxy } = input.bctx;
|
|
39506
|
+
const camStreams = await probe$1(() => proxy.cameraStreams?.getCameraStreams({}), "cameraStreams.getCameraStreams", input.log);
|
|
39507
|
+
const streamParams = await probe$1(() => proxy.streamParams?.getStatus({}), "streamParams.getStatus", input.log);
|
|
39508
|
+
const choices = await probe$1(() => proxy.webrtcSession?.listStreams({}), "webrtcSession.listStreams", input.log);
|
|
39509
|
+
return resolveProfileBitrates({
|
|
39510
|
+
slots: input.slots,
|
|
39511
|
+
camStreams: camStreams ?? [],
|
|
39512
|
+
streamParams,
|
|
39513
|
+
choices: choices ?? []
|
|
39514
|
+
});
|
|
39515
|
+
}
|
|
39516
|
+
async function probe$1(call, label, log) {
|
|
39517
|
+
try {
|
|
39518
|
+
const pending = call();
|
|
39519
|
+
if (pending === void 0) {
|
|
39520
|
+
log.info("export-hap: bitrate probe skipped — cap not bound on this device", { meta: { call: label } });
|
|
39521
|
+
return null;
|
|
39522
|
+
}
|
|
39523
|
+
return await pending;
|
|
39524
|
+
} catch (err) {
|
|
39525
|
+
log.warn("export-hap: bitrate probe failed — the slot rate stays UNKNOWN", { meta: {
|
|
39526
|
+
call: label,
|
|
39527
|
+
error: err instanceof Error ? err.message : String(err)
|
|
39528
|
+
} });
|
|
39529
|
+
return null;
|
|
39530
|
+
}
|
|
39531
|
+
}
|
|
39532
|
+
/**
|
|
39533
|
+
* Two outputs from one input: video SRTP and audio SRTP, one process, one
|
|
39534
|
+
* lifetime, one kill signal. ffmpeg-internal stream selection (`-vn` / `-an`
|
|
39535
|
+
* plus per-output codec args) keeps both flowing through it.
|
|
39536
|
+
*/
|
|
39537
|
+
function buildSessionFfmpegArgs(input) {
|
|
39538
|
+
return [
|
|
39539
|
+
"-hide_banner",
|
|
39540
|
+
"-loglevel",
|
|
39541
|
+
"warning",
|
|
39542
|
+
...input.decodeArgs,
|
|
39543
|
+
"-rtsp_transport",
|
|
39544
|
+
"tcp",
|
|
39545
|
+
"-i",
|
|
39546
|
+
input.rtspUrl,
|
|
39547
|
+
"-an",
|
|
39548
|
+
"-map",
|
|
39549
|
+
"0:v:0",
|
|
39550
|
+
...input.videoArgs,
|
|
39551
|
+
"-payload_type",
|
|
39552
|
+
String(input.videoPayloadType),
|
|
39553
|
+
"-ssrc",
|
|
39554
|
+
String(input.videoSsrcSigned),
|
|
39555
|
+
"-f",
|
|
39556
|
+
"rtp",
|
|
39557
|
+
input.videoTarget,
|
|
39558
|
+
"-vn",
|
|
39559
|
+
"-map",
|
|
39560
|
+
"0:a:0?",
|
|
39561
|
+
"-af",
|
|
39562
|
+
"aresample=async=1000:first_pts=0",
|
|
39563
|
+
"-c:a",
|
|
39564
|
+
"libopus",
|
|
39565
|
+
"-application",
|
|
39566
|
+
"lowdelay",
|
|
39567
|
+
"-frame_duration",
|
|
39568
|
+
String(input.audioPacketTimeMs),
|
|
39569
|
+
"-flags",
|
|
39570
|
+
"+global_header",
|
|
39571
|
+
"-ar",
|
|
39572
|
+
String(input.audioSampleRateKhz * 1e3),
|
|
39573
|
+
"-b:a",
|
|
39574
|
+
`24k`,
|
|
39575
|
+
"-bufsize",
|
|
39576
|
+
`96k`,
|
|
39577
|
+
"-ac",
|
|
39578
|
+
String(1),
|
|
39579
|
+
"-payload_type",
|
|
39580
|
+
String(input.audioPayloadType),
|
|
39581
|
+
"-ssrc",
|
|
39582
|
+
String(input.audioSsrcSigned),
|
|
39583
|
+
"-f",
|
|
39584
|
+
"rtp",
|
|
39585
|
+
input.audioTarget
|
|
39586
|
+
];
|
|
39587
|
+
}
|
|
39588
|
+
/**
|
|
39589
|
+
* The resolutions we offer, before rates are attached. Same list the delegate
|
|
39590
|
+
* advertised before R2 — only the frame rate changes, so a controller that had
|
|
39591
|
+
* already negotiated a resolution keeps finding it.
|
|
39592
|
+
*/
|
|
39593
|
+
var CANDIDATE_RESOLUTIONS = [
|
|
39594
|
+
[1920, 1080],
|
|
39595
|
+
[1280, 720],
|
|
39596
|
+
[1024, 768],
|
|
39597
|
+
[640, 480],
|
|
39598
|
+
[640, 360],
|
|
39599
|
+
[480, 360],
|
|
39600
|
+
[480, 270],
|
|
39601
|
+
[320, 240],
|
|
39602
|
+
[320, 180]
|
|
39603
|
+
];
|
|
39604
|
+
/**
|
|
39605
|
+
* Resolve the real frame rate of each profile slot.
|
|
39606
|
+
*
|
|
39607
|
+
* A slot with no measurement, no publication and no assignment simply does not
|
|
39608
|
+
* appear in the map — the caller then advertises {@link ASSUMED_FPS} and says
|
|
39609
|
+
* so. Absence is never silently rendered as a number.
|
|
39610
|
+
*/
|
|
39611
|
+
function resolveProfileFps(input) {
|
|
39612
|
+
const publishedByProfile = publishedFpsByProfile(input.slots, input.camStreams);
|
|
39613
|
+
const out = /* @__PURE__ */ new Map();
|
|
39614
|
+
for (const choice of input.choices) {
|
|
39615
|
+
if (choice.target.kind !== "profile") continue;
|
|
39616
|
+
const profile = choice.target.profile;
|
|
39617
|
+
const measured = clampFps(choice.inputFps);
|
|
39618
|
+
if (measured !== null) {
|
|
39619
|
+
out.set(profile, {
|
|
39620
|
+
profile,
|
|
39621
|
+
fps: measured,
|
|
39622
|
+
source: "measured"
|
|
39623
|
+
});
|
|
39624
|
+
continue;
|
|
39625
|
+
}
|
|
39626
|
+
const published = clampFps(publishedByProfile.get(profile) ?? null);
|
|
39627
|
+
out.set(profile, published !== null ? {
|
|
39628
|
+
profile,
|
|
39629
|
+
fps: published,
|
|
39630
|
+
source: "published"
|
|
39631
|
+
} : {
|
|
39632
|
+
profile,
|
|
39633
|
+
fps: 30,
|
|
39634
|
+
source: "assumed"
|
|
39635
|
+
});
|
|
39636
|
+
}
|
|
39637
|
+
for (const [profile, fps] of publishedByProfile) {
|
|
39638
|
+
if (out.has(profile)) continue;
|
|
39639
|
+
const published = clampFps(fps);
|
|
39640
|
+
if (published !== null) out.set(profile, {
|
|
39641
|
+
profile,
|
|
39642
|
+
fps: published,
|
|
39643
|
+
source: "published"
|
|
39644
|
+
});
|
|
39645
|
+
}
|
|
39646
|
+
return out;
|
|
39647
|
+
}
|
|
39648
|
+
/**
|
|
39649
|
+
* Attach a frame rate to each candidate resolution by asking the REAL picker
|
|
39650
|
+
* which slot the START path would dial for it. That coupling is the point: if
|
|
39651
|
+
* the picker's steering changes, the advertisement changes with it instead of
|
|
39652
|
+
* drifting into a second, silently different opinion.
|
|
39653
|
+
*/
|
|
39654
|
+
function deriveAdvertisedResolutions(input) {
|
|
39655
|
+
const candidates = input.candidates.length > 0 ? input.candidates : CANDIDATE_RESOLUTIONS;
|
|
39656
|
+
const seen = /* @__PURE__ */ new Set();
|
|
39657
|
+
const out = [];
|
|
39658
|
+
for (const [width, height] of candidates) {
|
|
39659
|
+
const picked = pickPreferredRtspEntry(input.entries, input.pref, input.deviceId, { targetResolution: {
|
|
39660
|
+
width,
|
|
39661
|
+
height
|
|
39662
|
+
} });
|
|
39663
|
+
const profile = picked === null ? null : toCamProfile(picked.profileId);
|
|
39664
|
+
const resolved = profile === null ? void 0 : input.fpsByProfile.get(profile);
|
|
39665
|
+
const advertised = {
|
|
39666
|
+
width,
|
|
39667
|
+
height,
|
|
39668
|
+
fps: resolved?.fps ?? 30,
|
|
39669
|
+
profile,
|
|
39670
|
+
source: resolved?.source ?? "assumed"
|
|
39671
|
+
};
|
|
39672
|
+
const key = `${advertised.width}x${advertised.height}@${advertised.fps}`;
|
|
39673
|
+
if (seen.has(key)) continue;
|
|
39674
|
+
seen.add(key);
|
|
39675
|
+
out.push(advertised);
|
|
39676
|
+
}
|
|
39677
|
+
return out;
|
|
39678
|
+
}
|
|
39679
|
+
/** Project onto the `[width, height, fps]` triples hap-nodejs expects. */
|
|
39680
|
+
function toHapResolutions(advertised) {
|
|
39681
|
+
return advertised.map((a) => [
|
|
39682
|
+
a.width,
|
|
39683
|
+
a.height,
|
|
39684
|
+
a.fps
|
|
39685
|
+
]);
|
|
39686
|
+
}
|
|
39687
|
+
/** Compact `1280x720@10(measured)` rendering for a single log field. */
|
|
39688
|
+
function formatAdvertisedResolutions(advertised) {
|
|
39689
|
+
return advertised.map((a) => `${a.width}x${a.height}@${a.fps}/${a.profile ?? "-"}(${a.source})`);
|
|
39690
|
+
}
|
|
39691
|
+
function publishedFpsByProfile(slots, camStreams) {
|
|
39692
|
+
const fpsByCamStream = /* @__PURE__ */ new Map();
|
|
39693
|
+
for (const stream of camStreams) if (typeof stream.fps === "number") fpsByCamStream.set(stream.camStreamId, stream.fps);
|
|
39694
|
+
const out = /* @__PURE__ */ new Map();
|
|
39695
|
+
for (const slot of slots) {
|
|
39696
|
+
if (slot.sourceCamStreamId === null) continue;
|
|
39697
|
+
const fps = fpsByCamStream.get(slot.sourceCamStreamId);
|
|
39698
|
+
if (fps !== void 0) out.set(slot.profile, fps);
|
|
39699
|
+
}
|
|
39700
|
+
return out;
|
|
39701
|
+
}
|
|
39702
|
+
/**
|
|
39703
|
+
* Coerce a probe reading into an advertisable integer rate, or null when the
|
|
39704
|
+
* reading carries no information (absent, zero because the broker is idle,
|
|
39705
|
+
* negative, NaN, or below the representable floor).
|
|
39706
|
+
*/
|
|
39707
|
+
function clampFps(value) {
|
|
39708
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return null;
|
|
39709
|
+
const floored = Math.floor(value);
|
|
39710
|
+
if (floored < 1) return null;
|
|
39711
|
+
return Math.min(floored, 30);
|
|
39712
|
+
}
|
|
39713
|
+
var CAM_PROFILES = [
|
|
39714
|
+
"high",
|
|
39715
|
+
"mid",
|
|
39716
|
+
"low"
|
|
39717
|
+
];
|
|
39718
|
+
/**
|
|
39719
|
+
* `PickedStream.profileId` is the brokerId suffix, which for a profile-keyed
|
|
39720
|
+
* entry IS the profile name. Anything else (a raw cam-stream id) is not a
|
|
39721
|
+
* profile and must not be coerced into one.
|
|
39722
|
+
*/
|
|
39723
|
+
function toCamProfile(profileId) {
|
|
39724
|
+
return CAM_PROFILES.find((p) => p === profileId) ?? null;
|
|
39725
|
+
}
|
|
39726
|
+
/**
|
|
39727
|
+
* How long after spawn an exit still counts as "hardware init failed".
|
|
39728
|
+
*
|
|
39729
|
+
* Same window the ffmpeg decoder addon uses for its own cascade. A hardware
|
|
39730
|
+
* context that cannot be created fails within milliseconds; anything that ran
|
|
39731
|
+
* longer produced no frames for a different reason, and re-spawning it in
|
|
39732
|
+
* software would just hide that reason.
|
|
39733
|
+
*/
|
|
39734
|
+
var HW_DECODE_FALLBACK_WINDOW_MS = 4e3;
|
|
39735
|
+
/** Backends whose decode binds to a DRM render node. */
|
|
39736
|
+
var RENDER_NODE_BACKENDS = ["vaapi", "qsv"];
|
|
39737
|
+
/**
|
|
39738
|
+
* Values of the decoder cap's `hwaccel` field that are operator CHOICES rather
|
|
39739
|
+
* than devices.
|
|
39740
|
+
*/
|
|
39741
|
+
var HWACCEL_NON_BACKEND_CHOICES = ["auto", "none"];
|
|
39742
|
+
/**
|
|
39743
|
+
* Every backend the decoder cap can publish, taken from the cap's own UI option
|
|
39744
|
+
* list so this module cannot drift from it. A hand-written copy of the union is
|
|
39745
|
+
* exactly the "hand-written cap interface that rots silently" this repo has
|
|
39746
|
+
* been bitten by.
|
|
39747
|
+
*/
|
|
39748
|
+
var HWACCEL_BACKENDS = new Set(HWACCEL_OPTIONS.map((option) => option.value).filter((value) => !HWACCEL_NON_BACKEND_CHOICES.includes(value)));
|
|
39749
|
+
/** Is this string a hardware backend, as opposed to `auto`, `none` or junk? */
|
|
39750
|
+
function isHwAccelBackend(value) {
|
|
39751
|
+
return HWACCEL_BACKENDS.has(value);
|
|
39752
|
+
}
|
|
39753
|
+
/**
|
|
39754
|
+
* Decide how this session decodes, and produce the ffmpeg input-side flags.
|
|
39755
|
+
*
|
|
39756
|
+
* Every path that ends in software carries a named reason, because a session
|
|
39757
|
+
* that quietly stopped using the GPU and a session that never had one look
|
|
39758
|
+
* identical in a CPU graph.
|
|
39759
|
+
*/
|
|
39760
|
+
function selectHwDecode(input) {
|
|
39761
|
+
if (!input.transcode) return software("pass-through");
|
|
39762
|
+
if (input.hardwareAlreadyFailed === true) return software("hardware-attempt-failed");
|
|
39763
|
+
if (input.reading === null) return software("no-decoder-reading");
|
|
39764
|
+
const chosen = (input.reading.hwaccel ?? "").trim();
|
|
39765
|
+
if (chosen === "none") return software("operator-disabled");
|
|
39766
|
+
if (chosen !== "" && chosen !== "auto") return isHwAccelBackend(chosen) ? hardware(chosen, "operator", input) : software("unrecognised-backend");
|
|
39767
|
+
const probed = (input.reading.probedBestHwaccel ?? "").trim();
|
|
39768
|
+
if (probed === "" || probed === "none") return software("not-probed");
|
|
39769
|
+
return isHwAccelBackend(probed) ? hardware(probed, "probed", input) : software("unrecognised-backend");
|
|
39770
|
+
}
|
|
39771
|
+
/**
|
|
39772
|
+
* Did this ffmpeg exit look like a failed hardware init, rather than a
|
|
39773
|
+
* teardown or a fault software would hit too?
|
|
39774
|
+
*
|
|
39775
|
+
* All five conditions are necessary. Dropping the controller-stop check in
|
|
39776
|
+
* particular would respawn on every normal teardown, because iOS restarts a
|
|
39777
|
+
* session it is not enjoying and that is indistinguishable at the exit code.
|
|
39778
|
+
*/
|
|
39779
|
+
function shouldRetryInSoftware(input) {
|
|
39780
|
+
if (!input.usedHardware) return false;
|
|
39781
|
+
if (input.hardwareAlreadyFailed) return false;
|
|
39782
|
+
if (input.stopRequestedByController) return false;
|
|
39783
|
+
if (input.videoPacketsForwarded > 0) return false;
|
|
39784
|
+
return input.runtimeMs <= HW_DECODE_FALLBACK_WINDOW_MS;
|
|
39785
|
+
}
|
|
39786
|
+
function software(reason) {
|
|
39787
|
+
return {
|
|
39788
|
+
kind: "software",
|
|
39789
|
+
reason,
|
|
39790
|
+
args: []
|
|
39791
|
+
};
|
|
39792
|
+
}
|
|
39793
|
+
function hardware(backend, source, input) {
|
|
39794
|
+
return {
|
|
39795
|
+
kind: "hardware",
|
|
39796
|
+
backend,
|
|
39797
|
+
source,
|
|
39798
|
+
args: decodeArgs(backend, input)
|
|
39799
|
+
};
|
|
39800
|
+
}
|
|
39801
|
+
/**
|
|
39802
|
+
* The input-side flags, and nothing else.
|
|
39803
|
+
*
|
|
39804
|
+
* No `-hwaccel_output_format`: the decoded frames have to land in system
|
|
39805
|
+
* memory for libx264 to scale and encode them. Setting it would keep them on
|
|
39806
|
+
* the GPU, which only pays off with a GPU scale filter — and that is the
|
|
39807
|
+
* decoder addon's job, not a two-output SRTP session's.
|
|
39808
|
+
*/
|
|
39809
|
+
function decodeArgs(backend, input) {
|
|
39810
|
+
if (backend === "videotoolbox" && input.platform === "darwin") return ["-hwaccel", "auto"];
|
|
39811
|
+
const args = ["-hwaccel", backend];
|
|
39812
|
+
if (RENDER_NODE_BACKENDS.includes(backend)) args.push("-hwaccel_device", input.renderDevice ?? "/dev/dri/renderD128");
|
|
39813
|
+
return args;
|
|
39814
|
+
}
|
|
39815
|
+
//#endregion
|
|
39816
|
+
//#region src/mappers/builders/stream-hwaccel-probe.ts
|
|
39817
|
+
/**
|
|
39818
|
+
* Read this node's decode-hwaccel state, or `null` when nothing answered.
|
|
39819
|
+
*
|
|
39820
|
+
* Never throws. `null` means "we do not know", which
|
|
39821
|
+
* {@link import('./stream-hwaccel.js').selectHwDecode} turns into software —
|
|
39822
|
+
* the safe direction, because a guess here costs the whole stream.
|
|
39823
|
+
*/
|
|
39824
|
+
async function probeDecoderHwaccel(input) {
|
|
39825
|
+
const { ctx, log } = input;
|
|
39826
|
+
const nodeId = ctx.kernel?.localNodeId;
|
|
39827
|
+
if (nodeId === void 0 || nodeId.length === 0) {
|
|
39828
|
+
log.warn("export-hap: hwaccel probe skipped — no local node id, decoding in SOFTWARE");
|
|
39829
|
+
return null;
|
|
39830
|
+
}
|
|
39831
|
+
try {
|
|
39832
|
+
const info = await ctx.api.decoder.getInfo.query(void 0, nodePin(nodeId));
|
|
39833
|
+
if (info === null || info === void 0) {
|
|
39834
|
+
log.info("export-hap: hwaccel probe returned nothing — decoding in SOFTWARE", { meta: { nodeId } });
|
|
39835
|
+
return null;
|
|
39836
|
+
}
|
|
39837
|
+
return {
|
|
39838
|
+
hwaccel: info.hwaccel ?? null,
|
|
39839
|
+
probedBestHwaccel: info.probedBestHwaccel ?? null
|
|
39840
|
+
};
|
|
39841
|
+
} catch (err) {
|
|
39842
|
+
log.warn("export-hap: hwaccel probe failed — decoding in SOFTWARE", { meta: {
|
|
39843
|
+
nodeId,
|
|
39844
|
+
error: err instanceof Error ? err.message : String(err)
|
|
39845
|
+
} });
|
|
39846
|
+
return null;
|
|
39847
|
+
}
|
|
39848
|
+
}
|
|
39849
|
+
//#endregion
|
|
39850
|
+
//#region src/mappers/builders/stream-telemetry.ts
|
|
39851
|
+
/**
|
|
39852
|
+
* Every branch on the streaming path that discards work, and its starting
|
|
39853
|
+
* count. This object is the single source of the reason set: the union below
|
|
39854
|
+
* is its `keyof`, so adding a silent `return` without adding a reason here is
|
|
39855
|
+
* a compile error rather than an invisible hole.
|
|
39856
|
+
*/
|
|
39857
|
+
var ZERO_DROP_COUNTERS = {
|
|
39858
|
+
/** Outbound: the leg has no SRTP session (prepareStream init failed). */
|
|
39859
|
+
"no-srtp-session": 0,
|
|
39860
|
+
/** Outbound: the leg has no return-path gate — forwarding was never armed. */
|
|
39861
|
+
"no-gate": 0,
|
|
39862
|
+
/** Outbound: werift refused to encrypt the packet. */
|
|
39863
|
+
"srtp-encrypt-failed": 0,
|
|
39864
|
+
/** Outbound: the UDP send itself errored. */
|
|
39865
|
+
"srtp-send-failed": 0,
|
|
39866
|
+
/** Outbound RTCP: the leg has no SRTCP context, so no Sender Report can go out. */
|
|
39867
|
+
"rtcp-no-srtcp": 0,
|
|
39868
|
+
/** Outbound RTCP: building or encrypting the Sender Report failed. */
|
|
39869
|
+
"rtcp-encrypt-failed": 0,
|
|
39870
|
+
/** Inbound RTCP: the leg has no SRTCP context, so the controller's report is unreadable. */
|
|
39871
|
+
"inbound-rtcp-no-srtcp": 0,
|
|
39872
|
+
/** Inbound RTCP: SRTCP decryption of a controller packet failed. */
|
|
39873
|
+
"inbound-rtcp-decrypt-failed": 0,
|
|
39874
|
+
/** Inbound RTCP: the decrypted bytes did not parse as RTCP. */
|
|
39875
|
+
"inbound-rtcp-parse-failed": 0,
|
|
39876
|
+
/** Upstream: packet shorter than an RTP header. */
|
|
39877
|
+
"upstream-short-packet": 0,
|
|
39878
|
+
/** Upstream: no inbound SRTP context (init failed at prepareStream). */
|
|
39879
|
+
"upstream-no-srtp": 0,
|
|
39880
|
+
/** Upstream: SRTP authentication/decrypt failed. */
|
|
39881
|
+
"upstream-decrypt-failed": 0,
|
|
39882
|
+
/** Upstream: decrypted bytes did not parse as RTP. */
|
|
39883
|
+
"upstream-parse-failed": 0,
|
|
39884
|
+
/** Upstream: audio arrived before `handleStreamRequest('start')` settled. */
|
|
39885
|
+
"upstream-before-start": 0,
|
|
39886
|
+
/** Upstream: payload type is not the negotiated audio PT (RTCP / keepalive). */
|
|
39887
|
+
"upstream-pt-mismatch": 0,
|
|
39888
|
+
/** Upstream: RTP carried a zero-length payload. */
|
|
39889
|
+
"upstream-empty-payload": 0,
|
|
39890
|
+
/** Upstream: no camera-side talk session, so the frame has nowhere to go. */
|
|
39891
|
+
"upstream-no-talk-session": 0,
|
|
39892
|
+
/** Upstream: the camera-side `pushTalkAudio` rejected the frame. */
|
|
39893
|
+
"upstream-push-failed": 0,
|
|
39894
|
+
/** Snapshot: the `snapshot` cap returned nothing. */
|
|
39895
|
+
"snapshot-unavailable": 0
|
|
39896
|
+
};
|
|
39897
|
+
function emptyDropCounters() {
|
|
39898
|
+
return { ...ZERO_DROP_COUNTERS };
|
|
39899
|
+
}
|
|
39900
|
+
/** Immutable increment — returns a new record, never touches the input. */
|
|
39901
|
+
function recordDrop(counters, reason) {
|
|
39902
|
+
return {
|
|
39903
|
+
...counters,
|
|
39904
|
+
[reason]: counters[reason] + 1
|
|
39905
|
+
};
|
|
39906
|
+
}
|
|
39907
|
+
/** Only the reasons that actually fired, so a clean session logs `{}`. */
|
|
39908
|
+
function nonZeroDrops(counters) {
|
|
39909
|
+
const out = {};
|
|
39910
|
+
for (const [reason, count] of Object.entries(counters)) if (count > 0) out[reason] = count;
|
|
39911
|
+
return out;
|
|
39912
|
+
}
|
|
39913
|
+
/**
|
|
39914
|
+
* Classify a packet received on one of our advertised return ports.
|
|
39915
|
+
*
|
|
39916
|
+
* RTP and RTCP arrive on the same symmetric port here. RTCP packet types are
|
|
39917
|
+
* 200..207, which sit inside the 192..223 band RFC 5761 §4 reserves precisely
|
|
39918
|
+
* so the two can be told apart on a shared socket (an RTP packet's marker bit
|
|
39919
|
+
* plus payload type can never land there for any payload type we negotiate).
|
|
39920
|
+
* The payloads are SRTP/SRTCP-encrypted, but both keep the first two bytes in
|
|
39921
|
+
* the clear.
|
|
39922
|
+
*/
|
|
39923
|
+
function classifyInboundPacket(packet) {
|
|
39924
|
+
if (packet.length < 2) return "malformed";
|
|
39925
|
+
if ((packet[0] >> 6 & 3) !== 2) return "malformed";
|
|
39926
|
+
const typeByte = packet[1];
|
|
39927
|
+
if (typeByte >= 192 && typeByte <= 223) return "rtcp";
|
|
39928
|
+
return "rtp";
|
|
39929
|
+
}
|
|
39930
|
+
/**
|
|
39931
|
+
* Loss at or above this is not a start-up transient.
|
|
39932
|
+
*
|
|
39933
|
+
* A stream whose first key frame is slow reliably produces one report in the
|
|
39934
|
+
* low single digits; a transmit-side defect produces tens of percent, because
|
|
39935
|
+
* whatever makes a packet unusable makes most packets unusable. The threshold
|
|
39936
|
+
* sits between those two regimes rather than at any measured boundary — it is
|
|
39937
|
+
* a reading aid, and the raw `worstFractionLostPct` is always in the line
|
|
39938
|
+
* beside it.
|
|
39939
|
+
*/
|
|
39940
|
+
var TRANSMIT_SUSPECT_FRACTION_LOST_PCT = 5;
|
|
39941
|
+
function lossVerdict(tally) {
|
|
39942
|
+
if (tally.blocksParsed === 0 || tally.worstFractionLostPct === null) return "no-reports";
|
|
39943
|
+
return tally.worstFractionLostPct >= TRANSMIT_SUSPECT_FRACTION_LOST_PCT ? "transmit-suspect" : "decode-suspect";
|
|
39944
|
+
}
|
|
39945
|
+
/**
|
|
39946
|
+
* Build the meta for `export-hap: stream session summary` — the single line a
|
|
39947
|
+
* future session greps to answer "why did this session die".
|
|
39948
|
+
*/
|
|
39949
|
+
function summariseSession(snapshot) {
|
|
39950
|
+
const durationMs = snapshot.startedAtMs !== null && snapshot.endedAtMs !== null ? snapshot.endedAtMs - snapshot.startedAtMs : null;
|
|
39951
|
+
const negotiated = snapshot.negotiated;
|
|
39952
|
+
const slot = snapshot.selectedSlot;
|
|
39953
|
+
return {
|
|
39954
|
+
sessionId: snapshot.sessionId,
|
|
39955
|
+
durationMs,
|
|
39956
|
+
stopRequestedByController: snapshot.stopRequestedByController,
|
|
39957
|
+
ffmpegExitCode: snapshot.ffmpegExit?.code ?? null,
|
|
39958
|
+
ffmpegExitSignal: snapshot.ffmpegExit?.signal ?? null,
|
|
39959
|
+
negotiatedResolution: negotiated ? `${negotiated.width}x${negotiated.height}` : null,
|
|
39960
|
+
negotiatedFps: negotiated?.fps ?? null,
|
|
39961
|
+
negotiatedMaxBitrateKbps: negotiated?.maxBitrateKbps ?? null,
|
|
39962
|
+
selectedProfile: slot?.profile ?? null,
|
|
39963
|
+
selectedBrokerId: slot?.brokerId ?? null,
|
|
39964
|
+
advertisedFps: slot?.advertisedFps ?? null,
|
|
39965
|
+
advertisedFpsSource: slot?.advertisedFpsSource ?? null,
|
|
39966
|
+
deliveredFps: slot?.deliveredFps ?? null,
|
|
39967
|
+
transcode: slot?.transcode ?? null,
|
|
39968
|
+
fitReason: slot?.fitReason ?? null,
|
|
39969
|
+
slotPublishedKbps: slot?.publishedKbps ?? null,
|
|
39970
|
+
slotMeasuredKbps: slot?.measuredKbps ?? null,
|
|
39971
|
+
encodeBudgetKbps: slot?.budgetKbps ?? null,
|
|
39972
|
+
fitNotes: slot?.fitNotes ?? [],
|
|
39973
|
+
videoPacketsForwarded: snapshot.videoPacketsForwarded,
|
|
39974
|
+
audioPacketsForwarded: snapshot.audioPacketsForwarded,
|
|
39975
|
+
videoRtcpSrSent: snapshot.videoRtcpSrSent,
|
|
39976
|
+
audioRtcpSrSent: snapshot.audioRtcpSrSent,
|
|
39977
|
+
videoRtcpReceived: snapshot.videoRtcpReceived,
|
|
39978
|
+
audioRtcpReceived: snapshot.audioRtcpReceived,
|
|
39979
|
+
videoRtpReceived: snapshot.videoRtpReceived,
|
|
39980
|
+
audioRtpReceived: snapshot.audioRtpReceived,
|
|
39981
|
+
videoGate: formatRtcpGate(snapshot.videoGate),
|
|
39982
|
+
audioGate: formatRtcpGate(snapshot.audioGate),
|
|
39983
|
+
videoReceiverReports: snapshot.videoReceiverReports,
|
|
39984
|
+
audioReceiverReports: snapshot.audioReceiverReports,
|
|
39985
|
+
videoLossVerdict: lossVerdict(snapshot.videoReceiverReports),
|
|
39986
|
+
audioLossVerdict: lossVerdict(snapshot.audioReceiverReports),
|
|
39987
|
+
mediaStarved: snapshot.videoPacketsForwarded === 0,
|
|
39988
|
+
drops: nonZeroDrops(snapshot.drops)
|
|
39989
|
+
};
|
|
39990
|
+
}
|
|
39991
|
+
//#endregion
|
|
39992
|
+
//#region src/mappers/builders/camera-streams.ts
|
|
38180
39993
|
var SRTP_KEY_LEN = 16;
|
|
38181
39994
|
var SRTP_SALT_LEN = 14;
|
|
38182
|
-
|
|
38183
|
-
|
|
38184
|
-
|
|
39995
|
+
/**
|
|
39996
|
+
* Cadence of the per-session heartbeat log.
|
|
39997
|
+
*
|
|
39998
|
+
* This timer LOGS ONLY. It never kills ffmpeg, never closes a socket and never
|
|
39999
|
+
* ends a session. That distinction matters: the two mainstream open-source
|
|
40000
|
+
* HomeKit camera stacks arm an idle watchdog that tears the stream down at
|
|
40001
|
+
* exactly 30 000 ms, and the ~31 s teardown here was misdiagnosed as one for a
|
|
40002
|
+
* long time before it was proven that this accessory has no such timer. Do not
|
|
40003
|
+
* make this one act.
|
|
40004
|
+
*/
|
|
40005
|
+
var SESSION_HEARTBEAT_MS = 5e3;
|
|
40006
|
+
/**
|
|
40007
|
+
* Floor between two `export-hap: controller receiver report` lines on one leg.
|
|
40008
|
+
*
|
|
40009
|
+
* The controller sends several Receiver Reports a second — 21 and 34 in two
|
|
40010
|
+
* ~10 s sessions on 2026-08-06 — and every one of them at `info` would drown
|
|
40011
|
+
* the very line it is meant to make findable. The FIRST report on each leg is
|
|
40012
|
+
* always logged, in full and under its own message; after that this throttles
|
|
40013
|
+
* to the heartbeat's cadence so a session still produces a running record of
|
|
40014
|
+
* what the controller thinks without becoming one.
|
|
40015
|
+
*/
|
|
40016
|
+
var RECEIVER_REPORT_LOG_INTERVAL_MS = 5e3;
|
|
40017
|
+
function buildCameraStreamingDelegate(bctx, advertised) {
|
|
38185
40018
|
const { ctx, numericDeviceId } = bctx;
|
|
38186
40019
|
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
38187
40020
|
const sessions = /* @__PURE__ */ new Map();
|
|
@@ -38200,7 +40033,7 @@ function buildCameraStreamingDelegate(bctx) {
|
|
|
38200
40033
|
});
|
|
38201
40034
|
},
|
|
38202
40035
|
handleStreamRequest(request, callback) {
|
|
38203
|
-
handleStreamRequest(request, sessions, bctx).then(() => callback()).catch((err) => {
|
|
40036
|
+
handleStreamRequest(request, sessions, bctx, advertised).then(() => callback()).catch((err) => {
|
|
38204
40037
|
log.warn("export-hap: handleStreamRequest failed", { meta: { error: errMsg$8(err) } });
|
|
38205
40038
|
callback(err instanceof Error ? err : new Error(errMsg$8(err)));
|
|
38206
40039
|
});
|
|
@@ -38221,58 +40054,7 @@ function buildCameraStreamingDelegate(bctx) {
|
|
|
38221
40054
|
_homebridge_hap_nodejs.H264Level.LEVEL4_0
|
|
38222
40055
|
]
|
|
38223
40056
|
},
|
|
38224
|
-
resolutions:
|
|
38225
|
-
[
|
|
38226
|
-
1920,
|
|
38227
|
-
1080,
|
|
38228
|
-
30
|
|
38229
|
-
],
|
|
38230
|
-
[
|
|
38231
|
-
1280,
|
|
38232
|
-
720,
|
|
38233
|
-
30
|
|
38234
|
-
],
|
|
38235
|
-
[
|
|
38236
|
-
1024,
|
|
38237
|
-
768,
|
|
38238
|
-
30
|
|
38239
|
-
],
|
|
38240
|
-
[
|
|
38241
|
-
640,
|
|
38242
|
-
480,
|
|
38243
|
-
30
|
|
38244
|
-
],
|
|
38245
|
-
[
|
|
38246
|
-
640,
|
|
38247
|
-
360,
|
|
38248
|
-
30
|
|
38249
|
-
],
|
|
38250
|
-
[
|
|
38251
|
-
480,
|
|
38252
|
-
360,
|
|
38253
|
-
30
|
|
38254
|
-
],
|
|
38255
|
-
[
|
|
38256
|
-
480,
|
|
38257
|
-
270,
|
|
38258
|
-
30
|
|
38259
|
-
],
|
|
38260
|
-
[
|
|
38261
|
-
320,
|
|
38262
|
-
240,
|
|
38263
|
-
30
|
|
38264
|
-
],
|
|
38265
|
-
[
|
|
38266
|
-
320,
|
|
38267
|
-
240,
|
|
38268
|
-
15
|
|
38269
|
-
],
|
|
38270
|
-
[
|
|
38271
|
-
320,
|
|
38272
|
-
180,
|
|
38273
|
-
30
|
|
38274
|
-
]
|
|
38275
|
-
]
|
|
40057
|
+
resolutions: toHapResolutions(advertised.resolutions)
|
|
38276
40058
|
},
|
|
38277
40059
|
audio: {
|
|
38278
40060
|
codecs: [{
|
|
@@ -38287,10 +40069,16 @@ function buildCameraStreamingDelegate(bctx) {
|
|
|
38287
40069
|
},
|
|
38288
40070
|
dispose: async () => {
|
|
38289
40071
|
for (const session of sessions.values()) {
|
|
40072
|
+
session.teardownTrigger = "accessory-dispose";
|
|
40073
|
+
const hadFfmpeg = session.ffmpeg !== null;
|
|
38290
40074
|
killFfmpeg(session, ctx, numericDeviceId);
|
|
40075
|
+
stopHeartbeat(session);
|
|
40076
|
+
if (!hadFfmpeg) logSessionSummary(session, log, "accessory-dispose-no-ffmpeg");
|
|
38291
40077
|
await closeIntercomTalkSession(session, bctx).catch(() => void 0);
|
|
38292
40078
|
closeSocket(session.videoUdp);
|
|
38293
40079
|
closeSocket(session.audioUdp);
|
|
40080
|
+
closeSocket(session.videoLoopUdp);
|
|
40081
|
+
closeSocket(session.audioLoopUdp);
|
|
38294
40082
|
}
|
|
38295
40083
|
sessions.clear();
|
|
38296
40084
|
}
|
|
@@ -38300,7 +40088,10 @@ async function handleSnapshot(bctx, request) {
|
|
|
38300
40088
|
const { proxy, ctx, numericDeviceId } = bctx;
|
|
38301
40089
|
const result = await proxy.snapshot?.getSnapshot({});
|
|
38302
40090
|
if (!result || typeof result.base64 !== "string") {
|
|
38303
|
-
ctx.logger.withTags({ deviceId: numericDeviceId }).
|
|
40091
|
+
ctx.logger.withTags({ deviceId: numericDeviceId }).info("export-hap: snapshot dropped", { meta: {
|
|
40092
|
+
reason: "snapshot-unavailable",
|
|
40093
|
+
capBound: proxy.snapshot !== void 0
|
|
40094
|
+
} });
|
|
38304
40095
|
throw new Error("snapshot unavailable");
|
|
38305
40096
|
}
|
|
38306
40097
|
return Buffer.from(result.base64, "base64");
|
|
@@ -38330,7 +40121,7 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
38330
40121
|
},
|
|
38331
40122
|
profile: import_src.ProtectionProfileAes128CmHmacSha1_80
|
|
38332
40123
|
});
|
|
38333
|
-
const
|
|
40124
|
+
const makeSrtcp = (key, salt) => new import_src.SrtcpSession({
|
|
38334
40125
|
keys: {
|
|
38335
40126
|
localMasterKey: key,
|
|
38336
40127
|
localMasterSalt: salt,
|
|
@@ -38341,13 +40132,17 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
38341
40132
|
});
|
|
38342
40133
|
let videoOutSrtp;
|
|
38343
40134
|
let videoOutSrtcp;
|
|
40135
|
+
let videoInSrtcp;
|
|
38344
40136
|
let audioOutSrtp;
|
|
38345
40137
|
let audioOutSrtcp;
|
|
40138
|
+
let audioInSrtcp;
|
|
38346
40139
|
try {
|
|
38347
40140
|
videoOutSrtp = makeOutSrtp(request.video.srtp_key, request.video.srtp_salt);
|
|
38348
|
-
videoOutSrtcp =
|
|
40141
|
+
videoOutSrtcp = makeSrtcp(request.video.srtp_key, request.video.srtp_salt);
|
|
40142
|
+
videoInSrtcp = makeSrtcp(request.video.srtp_key, request.video.srtp_salt);
|
|
38349
40143
|
audioOutSrtp = makeOutSrtp(request.audio.srtp_key, request.audio.srtp_salt);
|
|
38350
|
-
audioOutSrtcp =
|
|
40144
|
+
audioOutSrtcp = makeSrtcp(request.audio.srtp_key, request.audio.srtp_salt);
|
|
40145
|
+
audioInSrtcp = makeSrtcp(request.audio.srtp_key, request.audio.srtp_salt);
|
|
38351
40146
|
} catch (err) {
|
|
38352
40147
|
closeSocket(videoUdp);
|
|
38353
40148
|
closeSocket(audioUdp);
|
|
@@ -38372,6 +40167,30 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
38372
40167
|
const videoSsrc = randomSsrc();
|
|
38373
40168
|
const audioSsrc = randomSsrc();
|
|
38374
40169
|
const session = {
|
|
40170
|
+
sessionId: request.sessionID,
|
|
40171
|
+
startedAtMs: null,
|
|
40172
|
+
endedAtMs: null,
|
|
40173
|
+
negotiated: null,
|
|
40174
|
+
selectedSlot: null,
|
|
40175
|
+
ffmpegExit: null,
|
|
40176
|
+
stopRequestedByController: false,
|
|
40177
|
+
teardownTrigger: null,
|
|
40178
|
+
drops: emptyDropCounters(),
|
|
40179
|
+
videoPacketsForwarded: 0,
|
|
40180
|
+
audioPacketsForwarded: 0,
|
|
40181
|
+
videoRtcpSrSent: 0,
|
|
40182
|
+
audioRtcpSrSent: 0,
|
|
40183
|
+
videoRtcpReceived: 0,
|
|
40184
|
+
audioRtcpReceived: 0,
|
|
40185
|
+
videoRtpReceived: 0,
|
|
40186
|
+
audioRtpReceived: 0,
|
|
40187
|
+
videoReceiverReports: emptyReceiverReportTally(),
|
|
40188
|
+
audioReceiverReports: emptyReceiverReportTally(),
|
|
40189
|
+
videoRrLoggedAt: 0,
|
|
40190
|
+
audioRrLoggedAt: 0,
|
|
40191
|
+
videoGate: null,
|
|
40192
|
+
audioGate: null,
|
|
40193
|
+
heartbeat: null,
|
|
38375
40194
|
hapVideoPort: request.video.port,
|
|
38376
40195
|
hapAddress: request.targetAddress,
|
|
38377
40196
|
videoSrtpKey: request.video.srtp_key,
|
|
@@ -38380,6 +40199,7 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
38380
40199
|
videoLoopUdp,
|
|
38381
40200
|
videoOutSrtp,
|
|
38382
40201
|
videoOutSrtcp,
|
|
40202
|
+
videoInSrtcp,
|
|
38383
40203
|
videoOutPacketCount: 0,
|
|
38384
40204
|
videoOutOctetCount: 0,
|
|
38385
40205
|
videoOutLastRtpTimestamp: 0,
|
|
@@ -38394,6 +40214,7 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
38394
40214
|
audioLoopUdp,
|
|
38395
40215
|
audioOutSrtp,
|
|
38396
40216
|
audioOutSrtcp,
|
|
40217
|
+
audioInSrtcp,
|
|
38397
40218
|
audioSendGate: null,
|
|
38398
40219
|
ipVersion,
|
|
38399
40220
|
ffmpeg: null,
|
|
@@ -38416,19 +40237,33 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
38416
40237
|
audioRtcpIntervalMs: 5e3
|
|
38417
40238
|
};
|
|
38418
40239
|
sessions.set(request.sessionID, session);
|
|
40240
|
+
const tagLog = bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId });
|
|
38419
40241
|
session.videoSendGate = makeRtcpGate(videoUdp, 1e3);
|
|
38420
40242
|
session.audioSendGate = makeRtcpGate(audioUdp, 1e3);
|
|
38421
|
-
|
|
38422
|
-
|
|
38423
|
-
|
|
40243
|
+
session.videoSendGate.then((resolution) => {
|
|
40244
|
+
session.videoGate = resolution;
|
|
40245
|
+
logGateResolved(session, "video", resolution, tagLog);
|
|
40246
|
+
});
|
|
40247
|
+
session.audioSendGate.then((resolution) => {
|
|
40248
|
+
session.audioGate = resolution;
|
|
40249
|
+
logGateResolved(session, "audio", resolution, tagLog);
|
|
40250
|
+
});
|
|
40251
|
+
videoUdp.on("message", (packet) => {
|
|
40252
|
+
countInbound(session, "video", packet, tagLog);
|
|
40253
|
+
});
|
|
40254
|
+
audioUdp.on("message", (packet) => {
|
|
40255
|
+
countInbound(session, "audio", packet, tagLog);
|
|
40256
|
+
});
|
|
38424
40257
|
videoLoopUdp.on("message", (rtpPacket) => {
|
|
38425
|
-
videoPacketsForwarded += 1;
|
|
38426
|
-
if (videoPacketsForwarded === 1
|
|
40258
|
+
session.videoPacketsForwarded += 1;
|
|
40259
|
+
if (session.videoPacketsForwarded === 1) tagLog.info("export-hap: first video packet from ffmpeg", { meta: {
|
|
40260
|
+
sessionId: session.sessionId,
|
|
40261
|
+
bytes: rtpPacket.length
|
|
40262
|
+
} });
|
|
38427
40263
|
forwardEncryptedRtp(session, rtpPacket, "video", tagLog);
|
|
38428
40264
|
});
|
|
38429
40265
|
audioLoopUdp.on("message", (rtpPacket) => {
|
|
38430
|
-
audioPacketsForwarded += 1;
|
|
38431
|
-
if (audioPacketsForwarded === 1 || audioPacketsForwarded % 100 === 0) tagLog.info("export-hap: audio loopback packets forwarded", { meta: { count: audioPacketsForwarded } });
|
|
40266
|
+
session.audioPacketsForwarded += 1;
|
|
38432
40267
|
forwardEncryptedRtp(session, rtpPacket, "audio", tagLog);
|
|
38433
40268
|
});
|
|
38434
40269
|
audioUdp.on("message", (packet, rinfo) => {
|
|
@@ -38436,6 +40271,19 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
38436
40271
|
bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId }).debug("export-hap: incoming-audio handler error (dropped)", { meta: { error: errMsg$8(err) } });
|
|
38437
40272
|
});
|
|
38438
40273
|
});
|
|
40274
|
+
tagLog.info("export-hap: stream prepared", { meta: {
|
|
40275
|
+
sessionId: request.sessionID,
|
|
40276
|
+
controllerAddress: request.targetAddress,
|
|
40277
|
+
addressVersion: ipVersion,
|
|
40278
|
+
localIp,
|
|
40279
|
+
advertisedVideoPort: localVideoPort,
|
|
40280
|
+
advertisedAudioPort: localAudioPort,
|
|
40281
|
+
controllerVideoPort: request.video.port,
|
|
40282
|
+
controllerAudioPort: request.audio.port,
|
|
40283
|
+
videoSsrc,
|
|
40284
|
+
audioSsrc,
|
|
40285
|
+
upstreamAudioDecrypt: upstreamAudioSrtp !== null
|
|
40286
|
+
} });
|
|
38439
40287
|
return {
|
|
38440
40288
|
video: {
|
|
38441
40289
|
port: localVideoPort,
|
|
@@ -38510,30 +40358,225 @@ function sameIpv4Subnet(a, mask, b) {
|
|
|
38510
40358
|
async function bindLoopback(ipVersion) {
|
|
38511
40359
|
return bindUdp(ipVersion, ipVersion === "ipv6" ? "::1" : "127.0.0.1");
|
|
38512
40360
|
}
|
|
40361
|
+
/** Book a named drop. Every silent `return` on the streaming path routes here. */
|
|
40362
|
+
function drop(session, reason) {
|
|
40363
|
+
session.drops = recordDrop(session.drops, reason);
|
|
40364
|
+
}
|
|
38513
40365
|
/**
|
|
38514
|
-
*
|
|
38515
|
-
*
|
|
38516
|
-
*
|
|
38517
|
-
* iOS sees a return-path handshake before our actual stream begins.
|
|
38518
|
-
*
|
|
38519
|
-
* One-shot: the listener is removed on the first message OR on
|
|
38520
|
-
* timeout — the gate then stays resolved for the rest of the session.
|
|
40366
|
+
* Report a gate resolution. `timeout` is the interesting one: it means we are
|
|
40367
|
+
* about to stream into a port the controller never touched, and it was
|
|
40368
|
+
* indistinguishable from a successful probe until now.
|
|
38521
40369
|
*/
|
|
38522
|
-
function
|
|
38523
|
-
|
|
38524
|
-
|
|
38525
|
-
|
|
38526
|
-
|
|
38527
|
-
|
|
38528
|
-
|
|
38529
|
-
|
|
38530
|
-
|
|
38531
|
-
|
|
38532
|
-
|
|
38533
|
-
|
|
40370
|
+
function logGateResolved(session, leg, resolution, log) {
|
|
40371
|
+
const meta = {
|
|
40372
|
+
sessionId: session.sessionId,
|
|
40373
|
+
leg,
|
|
40374
|
+
reason: resolution.reason,
|
|
40375
|
+
waitedMs: resolution.waitedMs
|
|
40376
|
+
};
|
|
40377
|
+
if (resolution.reason === "packet") log.info("export-hap: RTCP gate opened on a REAL controller packet", { meta });
|
|
40378
|
+
else log.warn("export-hap: RTCP gate opened on the 1s FALLBACK — controller never probed", { meta });
|
|
40379
|
+
}
|
|
40380
|
+
/** Count and classify one packet the controller sent to a return port. */
|
|
40381
|
+
function countInbound(session, leg, packet, log) {
|
|
40382
|
+
const kind = classifyInboundPacket(packet);
|
|
40383
|
+
const firstRtcp = kind === "rtcp" && (leg === "video" ? session.videoRtcpReceived : session.audioRtcpReceived) === 0;
|
|
40384
|
+
if (leg === "video") {
|
|
40385
|
+
if (kind === "rtcp") session.videoRtcpReceived += 1;
|
|
40386
|
+
else if (kind === "rtp") session.videoRtpReceived += 1;
|
|
40387
|
+
} else if (kind === "rtcp") session.audioRtcpReceived += 1;
|
|
40388
|
+
else if (kind === "rtp") session.audioRtpReceived += 1;
|
|
40389
|
+
if (firstRtcp) log.info("export-hap: first inbound RTCP from controller", { meta: {
|
|
40390
|
+
sessionId: session.sessionId,
|
|
40391
|
+
leg,
|
|
40392
|
+
bytes: packet.length
|
|
40393
|
+
} });
|
|
40394
|
+
if (kind === "rtcp") readControllerRtcp(session, leg, packet, log);
|
|
40395
|
+
}
|
|
40396
|
+
/** Fold a decrypted RTCP datagram into the leg's tally. Immutable, per `ReceiverReportTally`. */
|
|
40397
|
+
function storeReceiverReports(session, leg, tally) {
|
|
40398
|
+
if (leg === "video") session.videoReceiverReports = tally;
|
|
40399
|
+
else session.audioReceiverReports = tally;
|
|
40400
|
+
}
|
|
40401
|
+
/**
|
|
40402
|
+
* Decrypt one inbound RTCP datagram and read the controller's Receiver Report.
|
|
40403
|
+
*
|
|
40404
|
+
* This is the datum the 2026-08-06 telemetry left missing. That round proved
|
|
40405
|
+
* iOS DOES send us RTCP on the video leg — 21 and 34 packets across two
|
|
40406
|
+
* sessions, the gate opening on a real controller packet at ~550 ms — which
|
|
40407
|
+
* killed the long-standing "iOS never probes us" belief. But counting packets
|
|
40408
|
+
* says only that the controller spoke; a Receiver Report says WHAT it said,
|
|
40409
|
+
* and that splits the remaining causes into two disjoint families that nothing
|
|
40410
|
+
* else in this system can tell apart. See `LossVerdict`.
|
|
40411
|
+
*
|
|
40412
|
+
* Runs on a UDP `message` handler, so nothing here may throw: a controller
|
|
40413
|
+
* that sends one deformed datagram must not take the session with it. Every
|
|
40414
|
+
* failure books a named drop and increments `unreadable`, because a report we
|
|
40415
|
+
* could not read and a report that never came must never look alike.
|
|
40416
|
+
*/
|
|
40417
|
+
function readControllerRtcp(session, leg, packet, log) {
|
|
40418
|
+
const srtcp = leg === "video" ? session.videoInSrtcp : session.audioInSrtcp;
|
|
40419
|
+
const tally = leg === "video" ? session.videoReceiverReports : session.audioReceiverReports;
|
|
40420
|
+
if (!srtcp) {
|
|
40421
|
+
drop(session, "inbound-rtcp-no-srtcp");
|
|
40422
|
+
storeReceiverReports(session, leg, recordUnreadableRtcp(tally));
|
|
40423
|
+
logUnreadableRtcp(session, leg, "no-srtcp-context", log);
|
|
40424
|
+
return;
|
|
40425
|
+
}
|
|
40426
|
+
let plaintext;
|
|
40427
|
+
try {
|
|
40428
|
+
plaintext = srtcp.decrypt(packet);
|
|
40429
|
+
} catch (err) {
|
|
40430
|
+
drop(session, "inbound-rtcp-decrypt-failed");
|
|
40431
|
+
storeReceiverReports(session, leg, recordUnreadableRtcp(tally));
|
|
40432
|
+
logUnreadableRtcp(session, leg, `decrypt: ${errMsg$8(err)}`, log);
|
|
40433
|
+
return;
|
|
40434
|
+
}
|
|
40435
|
+
const outcome = ingestDecryptedRtcp(plaintext, tally);
|
|
40436
|
+
storeReceiverReports(session, leg, outcome.tally);
|
|
40437
|
+
if (outcome.failure !== null) {
|
|
40438
|
+
drop(session, "inbound-rtcp-parse-failed");
|
|
40439
|
+
logUnreadableRtcp(session, leg, `parse: ${outcome.failure}`, log);
|
|
40440
|
+
return;
|
|
40441
|
+
}
|
|
40442
|
+
const isFirst = tally.reportsParsed === 0 && outcome.tally.reportsParsed > 0;
|
|
40443
|
+
logReceiverReports(session, leg, outcome.reports, isFirst, log);
|
|
40444
|
+
}
|
|
40445
|
+
/** Throttled per leg — a controller sending nothing but garbage must not become the log. */
|
|
40446
|
+
function logUnreadableRtcp(session, leg, reason, log) {
|
|
40447
|
+
const tally = leg === "video" ? session.videoReceiverReports : session.audioReceiverReports;
|
|
40448
|
+
if (!shouldLogReceiverReport(session, leg)) return;
|
|
40449
|
+
log.warn("export-hap: inbound RTCP could not be read", { meta: {
|
|
40450
|
+
sessionId: session.sessionId,
|
|
40451
|
+
leg,
|
|
40452
|
+
reason,
|
|
40453
|
+
unreadable: tally.unreadable
|
|
40454
|
+
} });
|
|
40455
|
+
}
|
|
40456
|
+
/**
|
|
40457
|
+
* True at most once per `RECEIVER_REPORT_LOG_INTERVAL_MS` per leg. Stamps the
|
|
40458
|
+
* leg on the way out so the caller cannot forget to.
|
|
40459
|
+
*/
|
|
40460
|
+
function shouldLogReceiverReport(session, leg) {
|
|
40461
|
+
const now = Date.now();
|
|
40462
|
+
if (now < (leg === "video" ? session.videoRrLoggedAt : session.audioRrLoggedAt) + RECEIVER_REPORT_LOG_INTERVAL_MS) return false;
|
|
40463
|
+
if (leg === "video") session.videoRrLoggedAt = now;
|
|
40464
|
+
else session.audioRrLoggedAt = now;
|
|
40465
|
+
return true;
|
|
40466
|
+
}
|
|
40467
|
+
/**
|
|
40468
|
+
* Emit the controller's own numbers.
|
|
40469
|
+
*
|
|
40470
|
+
* The FIRST report on a leg gets its own message: that one arriving at all is
|
|
40471
|
+
* the proof iOS is engaged with the stream, and it was worth a year of
|
|
40472
|
+
* argument. Everything after it is throttled to the heartbeat's cadence.
|
|
40473
|
+
*/
|
|
40474
|
+
function logReceiverReports(session, leg, reports, isFirst, log) {
|
|
40475
|
+
const tally = leg === "video" ? session.videoReceiverReports : session.audioReceiverReports;
|
|
40476
|
+
const block = reports.flatMap((report) => report.blocks).at(-1);
|
|
40477
|
+
if (!block) return;
|
|
40478
|
+
const reporter = reports.find((report) => report.blocks.length > 0);
|
|
40479
|
+
const meta = {
|
|
40480
|
+
sessionId: session.sessionId,
|
|
40481
|
+
leg,
|
|
40482
|
+
reporterSsrc: reporter?.reporterSsrc ?? null,
|
|
40483
|
+
aboutSsrc: block.aboutSsrc,
|
|
40484
|
+
fractionLostPct: block.fractionLostPct,
|
|
40485
|
+
cumulativePacketsLost: block.cumulativePacketsLost,
|
|
40486
|
+
extendedHighestSequence: block.extendedHighestSequence,
|
|
40487
|
+
jitter: block.jitter,
|
|
40488
|
+
delaySinceLastSrMs: block.delaySinceLastSrMs,
|
|
40489
|
+
reportsParsed: tally.reportsParsed,
|
|
40490
|
+
worstFractionLostPct: tally.worstFractionLostPct
|
|
40491
|
+
};
|
|
40492
|
+
if (isFirst) {
|
|
40493
|
+
shouldLogReceiverReport(session, leg);
|
|
40494
|
+
log.info("export-hap: FIRST RTCP Receiver Report from controller — iOS is receiving and reporting", { meta });
|
|
40495
|
+
return;
|
|
40496
|
+
}
|
|
40497
|
+
if (!shouldLogReceiverReport(session, leg)) return;
|
|
40498
|
+
log.info("export-hap: controller receiver report", { meta });
|
|
40499
|
+
}
|
|
40500
|
+
/** Snapshot every counter into the summary meta. */
|
|
40501
|
+
function sessionSummaryMeta(session) {
|
|
40502
|
+
return summariseSession({
|
|
40503
|
+
sessionId: session.sessionId,
|
|
40504
|
+
startedAtMs: session.startedAtMs,
|
|
40505
|
+
endedAtMs: session.endedAtMs,
|
|
40506
|
+
negotiated: session.negotiated,
|
|
40507
|
+
selectedSlot: session.selectedSlot,
|
|
40508
|
+
videoPacketsForwarded: session.videoPacketsForwarded,
|
|
40509
|
+
audioPacketsForwarded: session.audioPacketsForwarded,
|
|
40510
|
+
videoRtcpSrSent: session.videoRtcpSrSent,
|
|
40511
|
+
audioRtcpSrSent: session.audioRtcpSrSent,
|
|
40512
|
+
videoRtcpReceived: session.videoRtcpReceived,
|
|
40513
|
+
audioRtcpReceived: session.audioRtcpReceived,
|
|
40514
|
+
videoRtpReceived: session.videoRtpReceived,
|
|
40515
|
+
audioRtpReceived: session.audioRtpReceived,
|
|
40516
|
+
videoGate: session.videoGate,
|
|
40517
|
+
audioGate: session.audioGate,
|
|
40518
|
+
videoReceiverReports: session.videoReceiverReports,
|
|
40519
|
+
audioReceiverReports: session.audioReceiverReports,
|
|
40520
|
+
drops: session.drops,
|
|
40521
|
+
ffmpegExit: session.ffmpegExit,
|
|
40522
|
+
stopRequestedByController: session.stopRequestedByController
|
|
38534
40523
|
});
|
|
38535
40524
|
}
|
|
38536
40525
|
/**
|
|
40526
|
+
* Arm the per-session heartbeat. LOGS ONLY — see `SESSION_HEARTBEAT_MS`. It
|
|
40527
|
+
* exists so a session that stops producing media says so while it is still
|
|
40528
|
+
* alive, instead of being reconstructed after the fact from its final line.
|
|
40529
|
+
*/
|
|
40530
|
+
function armHeartbeat(session, log) {
|
|
40531
|
+
if (session.heartbeat) clearInterval(session.heartbeat);
|
|
40532
|
+
let lastVideo = session.videoPacketsForwarded;
|
|
40533
|
+
const timer = setInterval(() => {
|
|
40534
|
+
const forwarded = session.videoPacketsForwarded - lastVideo;
|
|
40535
|
+
lastVideo = session.videoPacketsForwarded;
|
|
40536
|
+
log.info("export-hap: stream heartbeat", { meta: {
|
|
40537
|
+
...sessionSummaryMeta(session),
|
|
40538
|
+
videoPacketsSinceLastBeat: forwarded,
|
|
40539
|
+
videoStalled: forwarded === 0
|
|
40540
|
+
} });
|
|
40541
|
+
}, SESSION_HEARTBEAT_MS);
|
|
40542
|
+
timer.unref();
|
|
40543
|
+
session.heartbeat = timer;
|
|
40544
|
+
}
|
|
40545
|
+
function stopHeartbeat(session) {
|
|
40546
|
+
if (!session.heartbeat) return;
|
|
40547
|
+
clearInterval(session.heartbeat);
|
|
40548
|
+
session.heartbeat = null;
|
|
40549
|
+
}
|
|
40550
|
+
/**
|
|
40551
|
+
* **`export-hap: stream session summary` is the line to grep** when asking why
|
|
40552
|
+
* a HomeKit session died. It carries the negotiated parameters, the slot we
|
|
40553
|
+
* dialled and the rate we had promised for it, packet counts in both
|
|
40554
|
+
* directions on both legs, each leg's gate outcome, ffmpeg's exit, who asked
|
|
40555
|
+
* for the teardown, every named drop — and, since 2026-08-06, what the
|
|
40556
|
+
* controller itself reported receiving.
|
|
40557
|
+
*
|
|
40558
|
+
* Read `videoLossVerdict` first. `transmit-suspect` says the controller is not
|
|
40559
|
+
* getting our packets intact and nothing past the wire matters;
|
|
40560
|
+
* `decode-suspect` says it got them and rendered nothing anyway;
|
|
40561
|
+
* `no-reports` says we still cannot tell, and `videoReceiverReports.unreadable`
|
|
40562
|
+
* then distinguishes "the controller said nothing" from "we could not read what
|
|
40563
|
+
* it said".
|
|
40564
|
+
*
|
|
40565
|
+
* Emitted from the ffmpeg `exit` handler — the only place the exit code is
|
|
40566
|
+
* known — and directly from the teardown paths when there is no ffmpeg to wait
|
|
40567
|
+
* for. It deliberately does NOT touch the heartbeat: iOS restarts a session
|
|
40568
|
+
* in place when it is not getting a picture (one on record was started three
|
|
40569
|
+
* times in 16 s), and stopping the heartbeat on the superseded process's exit
|
|
40570
|
+
* would silence the replacement.
|
|
40571
|
+
*/
|
|
40572
|
+
function logSessionSummary(session, log, trigger) {
|
|
40573
|
+
session.endedAtMs = Date.now();
|
|
40574
|
+
log.info("export-hap: stream session summary", { meta: {
|
|
40575
|
+
...sessionSummaryMeta(session),
|
|
40576
|
+
trigger
|
|
40577
|
+
} });
|
|
40578
|
+
}
|
|
40579
|
+
/**
|
|
38537
40580
|
* Build the NTP timestamp expected in an RTCP Sender Report.
|
|
38538
40581
|
*
|
|
38539
40582
|
* NTP timestamps are 64-bit: upper 32 bits = seconds since 1900-01-01,
|
|
@@ -38566,7 +40609,10 @@ function ntpTime() {
|
|
|
38566
40609
|
*/
|
|
38567
40610
|
function sendRtcpSr(session, kind, log) {
|
|
38568
40611
|
const srtcp = kind === "video" ? session.videoOutSrtcp : session.audioOutSrtcp;
|
|
38569
|
-
if (!srtcp)
|
|
40612
|
+
if (!srtcp) {
|
|
40613
|
+
drop(session, "rtcp-no-srtcp");
|
|
40614
|
+
return;
|
|
40615
|
+
}
|
|
38570
40616
|
const sink = kind === "video" ? session.videoUdp : session.audioUdp;
|
|
38571
40617
|
const port = kind === "video" ? session.hapVideoPort : session.hapAudioPort;
|
|
38572
40618
|
try {
|
|
@@ -38581,15 +40627,31 @@ function sendRtcpSr(session, kind, log) {
|
|
|
38581
40627
|
});
|
|
38582
40628
|
const encrypted = srtcp.encrypt(sr.serialize());
|
|
38583
40629
|
sink.send(encrypted, port, session.hapAddress, (err) => {
|
|
38584
|
-
if (err)
|
|
38585
|
-
|
|
38586
|
-
error:
|
|
38587
|
-
|
|
40630
|
+
if (err) {
|
|
40631
|
+
drop(session, "srtp-send-failed");
|
|
40632
|
+
log.warn("export-hap: RTCP send error", { meta: {
|
|
40633
|
+
sessionId: session.sessionId,
|
|
40634
|
+
kind,
|
|
40635
|
+
error: err.message
|
|
40636
|
+
} });
|
|
40637
|
+
}
|
|
38588
40638
|
});
|
|
38589
|
-
if (kind === "video")
|
|
38590
|
-
|
|
40639
|
+
if (kind === "video") {
|
|
40640
|
+
session.videoOutLastRtcpAt = Date.now();
|
|
40641
|
+
session.videoRtcpSrSent += 1;
|
|
40642
|
+
if (session.videoRtcpSrSent === 1) log.info("export-hap: first VIDEO RTCP Sender Report sent", { meta: {
|
|
40643
|
+
sessionId: session.sessionId,
|
|
40644
|
+
rtpTimestamp: session.videoOutLastRtpTimestamp,
|
|
40645
|
+
intervalMs: session.videoRtcpIntervalMs
|
|
40646
|
+
} });
|
|
40647
|
+
} else {
|
|
40648
|
+
session.audioOutLastRtcpAt = Date.now();
|
|
40649
|
+
session.audioRtcpSrSent += 1;
|
|
40650
|
+
}
|
|
38591
40651
|
} catch (err) {
|
|
38592
|
-
|
|
40652
|
+
drop(session, "rtcp-encrypt-failed");
|
|
40653
|
+
log.warn("export-hap: RTCP SR build/encrypt failed", { meta: {
|
|
40654
|
+
sessionId: session.sessionId,
|
|
38593
40655
|
kind,
|
|
38594
40656
|
error: err instanceof Error ? err.message : String(err)
|
|
38595
40657
|
} });
|
|
@@ -38611,7 +40673,14 @@ function forwardEncryptedRtp(session, rtpPacket, kind, log) {
|
|
|
38611
40673
|
const sink = kind === "video" ? session.videoUdp : session.audioUdp;
|
|
38612
40674
|
const port = kind === "video" ? session.hapVideoPort : session.hapAudioPort;
|
|
38613
40675
|
const gate = kind === "video" ? session.videoSendGate : session.audioSendGate;
|
|
38614
|
-
if (!srtp
|
|
40676
|
+
if (!srtp) {
|
|
40677
|
+
drop(session, "no-srtp-session");
|
|
40678
|
+
return;
|
|
40679
|
+
}
|
|
40680
|
+
if (!gate) {
|
|
40681
|
+
drop(session, "no-gate");
|
|
40682
|
+
return;
|
|
40683
|
+
}
|
|
38615
40684
|
gate.then(() => {
|
|
38616
40685
|
try {
|
|
38617
40686
|
const parsed = import_src.RtpPacket.deSerialize(rtpPacket);
|
|
@@ -38647,32 +40716,46 @@ function forwardEncryptedRtp(session, rtpPacket, kind, log) {
|
|
|
38647
40716
|
}
|
|
38648
40717
|
const encrypted = srtp.encrypt(parsed.payload, parsed.header);
|
|
38649
40718
|
sink.send(encrypted, port, session.hapAddress, (err) => {
|
|
38650
|
-
if (err)
|
|
38651
|
-
|
|
38652
|
-
error:
|
|
38653
|
-
|
|
40719
|
+
if (err) {
|
|
40720
|
+
drop(session, "srtp-send-failed");
|
|
40721
|
+
log.debug("export-hap: SRTP send error", { meta: {
|
|
40722
|
+
sessionId: session.sessionId,
|
|
40723
|
+
kind,
|
|
40724
|
+
error: err.message
|
|
40725
|
+
} });
|
|
40726
|
+
}
|
|
38654
40727
|
});
|
|
38655
40728
|
const now = Date.now();
|
|
38656
40729
|
if (kind === "video") {
|
|
38657
40730
|
if (firstVideo || now > session.videoOutLastRtcpAt + session.videoRtcpIntervalMs) sendRtcpSr(session, "video", log);
|
|
38658
40731
|
} else if (firstAudio || now > session.audioOutLastRtcpAt + session.audioRtcpIntervalMs) sendRtcpSr(session, "audio", log);
|
|
38659
40732
|
} catch (err) {
|
|
40733
|
+
drop(session, "srtp-encrypt-failed");
|
|
38660
40734
|
log.debug("export-hap: SRTP encrypt failed", { meta: {
|
|
40735
|
+
sessionId: session.sessionId,
|
|
38661
40736
|
kind,
|
|
38662
40737
|
error: err instanceof Error ? err.message : String(err)
|
|
38663
40738
|
} });
|
|
38664
40739
|
}
|
|
38665
40740
|
});
|
|
38666
40741
|
}
|
|
38667
|
-
async function handleStreamRequest(request, sessions, bctx) {
|
|
40742
|
+
async function handleStreamRequest(request, sessions, bctx, advertised) {
|
|
38668
40743
|
const log = bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId });
|
|
38669
40744
|
const session = sessions.get(request.sessionID);
|
|
38670
40745
|
if (!session) {
|
|
38671
|
-
log.warn("export-hap: stream request for unknown session", { meta: {
|
|
40746
|
+
log.warn("export-hap: stream request for unknown session", { meta: {
|
|
40747
|
+
sessionID: request.sessionID,
|
|
40748
|
+
type: request.type
|
|
40749
|
+
} });
|
|
38672
40750
|
return;
|
|
38673
40751
|
}
|
|
38674
40752
|
if (request.type === "stop") {
|
|
40753
|
+
session.stopRequestedByController = true;
|
|
40754
|
+
session.teardownTrigger = "controller-stop";
|
|
40755
|
+
const hadFfmpeg = session.ffmpeg !== null;
|
|
38675
40756
|
killFfmpeg(session, bctx.ctx, bctx.numericDeviceId);
|
|
40757
|
+
stopHeartbeat(session);
|
|
40758
|
+
if (!hadFfmpeg) logSessionSummary(session, log, "controller-stop-no-ffmpeg");
|
|
38676
40759
|
await closeIntercomTalkSession(session, bctx).catch(() => void 0);
|
|
38677
40760
|
closeSocket(session.videoUdp);
|
|
38678
40761
|
closeSocket(session.audioUdp);
|
|
@@ -38698,6 +40781,27 @@ async function handleStreamRequest(request, sessions, bctx) {
|
|
|
38698
40781
|
session.videoOutOctetCount = 0;
|
|
38699
40782
|
session.videoOutLastRtpTimestamp = 0;
|
|
38700
40783
|
session.videoOutLastRtcpAt = 0;
|
|
40784
|
+
session.negotiated = {
|
|
40785
|
+
width: request.video.width,
|
|
40786
|
+
height: request.video.height,
|
|
40787
|
+
fps: request.video.fps,
|
|
40788
|
+
maxBitrateKbps: request.video.max_bit_rate,
|
|
40789
|
+
mtu: request.video.mtu,
|
|
40790
|
+
videoPt: request.video.pt,
|
|
40791
|
+
videoSsrc: session.videoSsrc,
|
|
40792
|
+
videoRtcpIntervalMs: session.videoRtcpIntervalMs,
|
|
40793
|
+
audioPt: request.audio.pt,
|
|
40794
|
+
audioSsrc: session.audioOutSsrc,
|
|
40795
|
+
audioSampleRateKhz: request.audio.sample_rate,
|
|
40796
|
+
audioPacketTimeMs: packetTimeMs,
|
|
40797
|
+
audioRtcpIntervalMs: session.audioRtcpIntervalMs,
|
|
40798
|
+
audioMaxBitrateKbps: request.audio.max_bit_rate
|
|
40799
|
+
};
|
|
40800
|
+
session.startedAtMs = Date.now();
|
|
40801
|
+
log.info("export-hap: stream negotiated", { meta: {
|
|
40802
|
+
sessionId: request.sessionID,
|
|
40803
|
+
...session.negotiated
|
|
40804
|
+
} });
|
|
38701
40805
|
session.lastStartParams = {
|
|
38702
40806
|
pt: request.video.pt,
|
|
38703
40807
|
mtu: request.video.mtu,
|
|
@@ -38718,7 +40822,8 @@ async function handleStreamRequest(request, sessions, bctx) {
|
|
|
38718
40822
|
sample_rate: request.audio.sample_rate,
|
|
38719
40823
|
packet_time: packetTimeMs
|
|
38720
40824
|
};
|
|
38721
|
-
await startFfmpegForSession(bctx, session, request.sessionID, startParams);
|
|
40825
|
+
await startFfmpegForSession(bctx, session, request.sessionID, startParams, advertised);
|
|
40826
|
+
armHeartbeat(session, log);
|
|
38722
40827
|
return;
|
|
38723
40828
|
}
|
|
38724
40829
|
if (request.type === "reconfigure") {
|
|
@@ -38745,147 +40850,266 @@ async function handleStreamRequest(request, sessions, bctx) {
|
|
|
38745
40850
|
sample_rate: session.lastStartParams.audioSampleRateEnum,
|
|
38746
40851
|
packet_time: session.lastStartParams.audioPacketTimeMs
|
|
38747
40852
|
};
|
|
38748
|
-
|
|
40853
|
+
log.info("export-hap: stream reconfigured", { meta: {
|
|
40854
|
+
sessionId: request.sessionID,
|
|
40855
|
+
width: request.video.width,
|
|
40856
|
+
height: request.video.height,
|
|
40857
|
+
fps: request.video.fps,
|
|
40858
|
+
maxBitrateKbps: request.video.max_bit_rate
|
|
40859
|
+
} });
|
|
40860
|
+
await startFfmpegForSession(bctx, session, request.sessionID, startParams, advertised);
|
|
40861
|
+
armHeartbeat(session, log);
|
|
38749
40862
|
}
|
|
38750
40863
|
}
|
|
38751
|
-
async function startFfmpegForSession(bctx, session, sessionId, video) {
|
|
40864
|
+
async function startFfmpegForSession(bctx, session, sessionId, video, advertised) {
|
|
38752
40865
|
const { ctx, proxy, numericDeviceId, options } = bctx;
|
|
40866
|
+
const startLog = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
38753
40867
|
const entries = await proxy.cameraStreams?.getProfileRtspEntries({}) ?? [];
|
|
38754
|
-
if (entries.length === 0)
|
|
40868
|
+
if (entries.length === 0) {
|
|
40869
|
+
startLog.warn("export-hap: stream start DROPPED — device publishes no profile RTSP entries", { meta: {
|
|
40870
|
+
sessionId,
|
|
40871
|
+
capBound: proxy.cameraStreams !== void 0
|
|
40872
|
+
} });
|
|
40873
|
+
throw new Error(`export-hap: no profile RTSP entries for device ${numericDeviceId}`);
|
|
40874
|
+
}
|
|
38755
40875
|
const pref = options.hapDeviceSettings.streamPreference;
|
|
38756
|
-
const
|
|
38757
|
-
|
|
38758
|
-
|
|
38759
|
-
|
|
38760
|
-
|
|
40876
|
+
const brokerStreams = await proxy.cameraStreams?.getBrokerStreams({}) ?? [];
|
|
40877
|
+
const bitrates = await probeProfileBitrates({
|
|
40878
|
+
bctx,
|
|
40879
|
+
slots: brokerStreams,
|
|
40880
|
+
log: startLog
|
|
40881
|
+
});
|
|
40882
|
+
const fit = selectStreamForBudget({
|
|
40883
|
+
entries: withSlotCodecs(entries, brokerStreams),
|
|
40884
|
+
deviceId: numericDeviceId,
|
|
40885
|
+
pref,
|
|
40886
|
+
targetResolution: {
|
|
40887
|
+
width: video.width,
|
|
40888
|
+
height: video.height
|
|
40889
|
+
},
|
|
40890
|
+
negotiatedMaxBitrateKbps: video.max_bit_rate,
|
|
40891
|
+
bitrates
|
|
40892
|
+
});
|
|
40893
|
+
if (fit === null) {
|
|
40894
|
+
startLog.warn("export-hap: stream start DROPPED — no ENABLED profile RTSP entry", { meta: {
|
|
40895
|
+
sessionId,
|
|
40896
|
+
streamPreference: pref,
|
|
40897
|
+
targetResolution: `${video.width}x${video.height}`,
|
|
40898
|
+
entries: entries.map((e) => `${e.profile}:${e.enabled ? "enabled" : "disabled"}`)
|
|
40899
|
+
} });
|
|
40900
|
+
throw new Error(`export-hap: no enabled RTSP entries for device ${numericDeviceId}`);
|
|
40901
|
+
}
|
|
40902
|
+
const picked = fit.picked;
|
|
38761
40903
|
const rtspUrl = picked.url;
|
|
38762
|
-
const slot =
|
|
40904
|
+
const slot = brokerStreams.find((s) => s.profile === fit.profile);
|
|
38763
40905
|
const codec = (picked.codec ?? slot?.codec ?? "").toLowerCase();
|
|
38764
|
-
const needsTranscode =
|
|
40906
|
+
const needsTranscode = fit.kind === "transcode";
|
|
40907
|
+
const pickedProfile = toKnownProfile(picked.profileId);
|
|
40908
|
+
const resolvedFps = pickedProfile === null ? void 0 : advertised.fpsByProfile.get(pickedProfile);
|
|
40909
|
+
const advertisedFps = resolvedFps?.fps ?? video.fps;
|
|
40910
|
+
const advertisedFpsSource = resolvedFps?.source ?? "assumed";
|
|
40911
|
+
const deliveredFps = needsTranscode ? deliverableFps(video.fps, resolvedFps?.fps ?? null) : advertisedFps;
|
|
40912
|
+
const slotEvidence = pickedProfile === null ? void 0 : bitrates.get(pickedProfile);
|
|
40913
|
+
const fitNotes = formatFitNotes(fit.notes);
|
|
40914
|
+
session.selectedSlot = {
|
|
40915
|
+
profile: pickedProfile,
|
|
40916
|
+
brokerId: picked.brokerId,
|
|
40917
|
+
width: picked.resolution?.width ?? null,
|
|
40918
|
+
height: picked.resolution?.height ?? null,
|
|
40919
|
+
advertisedFps,
|
|
40920
|
+
advertisedFpsSource,
|
|
40921
|
+
deliveredFps,
|
|
40922
|
+
codec: codec.length > 0 ? codec : "unknown",
|
|
40923
|
+
transcode: needsTranscode,
|
|
40924
|
+
fitReason: fit.reason,
|
|
40925
|
+
publishedKbps: slotEvidence?.publishedKbps ?? null,
|
|
40926
|
+
measuredKbps: slotEvidence?.measuredKbps ?? null,
|
|
40927
|
+
budgetKbps: fit.budgetKbps,
|
|
40928
|
+
fitNotes
|
|
40929
|
+
};
|
|
40930
|
+
startLog.info("export-hap: stream bitrate fit resolved", { meta: {
|
|
40931
|
+
sessionId,
|
|
40932
|
+
decision: fit.kind,
|
|
40933
|
+
reason: fit.reason,
|
|
40934
|
+
negotiatedMaxBitrateKbps: video.max_bit_rate,
|
|
40935
|
+
encodeBudgetKbps: fit.budgetKbps,
|
|
40936
|
+
profile: pickedProfile,
|
|
40937
|
+
brokerId: picked.brokerId,
|
|
40938
|
+
slotPublishedKbps: slotEvidence?.publishedKbps ?? null,
|
|
40939
|
+
slotMeasuredKbps: slotEvidence?.measuredKbps ?? null,
|
|
40940
|
+
candidates: fitNotes
|
|
40941
|
+
} });
|
|
40942
|
+
if (deliveredFps !== video.fps) startLog.warn("export-hap: negotiated fps does NOT match the rate we are about to deliver", { meta: {
|
|
40943
|
+
sessionId,
|
|
40944
|
+
negotiatedFps: video.fps,
|
|
40945
|
+
slotFps: advertisedFps,
|
|
40946
|
+
slotFpsSource: advertisedFpsSource,
|
|
40947
|
+
deliveredFps,
|
|
40948
|
+
profile: pickedProfile,
|
|
40949
|
+
brokerId: picked.brokerId,
|
|
40950
|
+
transcode: needsTranscode
|
|
40951
|
+
} });
|
|
38765
40952
|
const videoLoopPort = session.videoLoopUdp.address().port;
|
|
38766
40953
|
const audioLoopPort = session.audioLoopUdp.address().port;
|
|
38767
40954
|
const videoTarget = `rtp://127.0.0.1:${videoLoopPort}?pkt_size=${video.mtu}`;
|
|
38768
40955
|
const audioTarget = `rtp://127.0.0.1:${audioLoopPort}?pkt_size=${video.mtu}`;
|
|
38769
|
-
const videoArgs =
|
|
38770
|
-
|
|
38771
|
-
|
|
38772
|
-
|
|
38773
|
-
|
|
38774
|
-
|
|
38775
|
-
|
|
38776
|
-
|
|
38777
|
-
|
|
38778
|
-
|
|
38779
|
-
|
|
38780
|
-
|
|
38781
|
-
|
|
38782
|
-
|
|
38783
|
-
|
|
38784
|
-
|
|
38785
|
-
`${video.max_bit_rate * 2}k`,
|
|
38786
|
-
"-maxrate",
|
|
38787
|
-
`${video.max_bit_rate}k`,
|
|
38788
|
-
"-profile:v",
|
|
38789
|
-
"baseline",
|
|
38790
|
-
"-level",
|
|
38791
|
-
"3.1"
|
|
38792
|
-
] : [
|
|
38793
|
-
"-c:v",
|
|
38794
|
-
"copy",
|
|
38795
|
-
"-bsf:v",
|
|
38796
|
-
"dump_extra"
|
|
38797
|
-
];
|
|
40956
|
+
const videoArgs = buildVideoEncodeArgs({
|
|
40957
|
+
transcode: needsTranscode,
|
|
40958
|
+
width: video.width,
|
|
40959
|
+
height: video.height,
|
|
40960
|
+
fps: deliveredFps,
|
|
40961
|
+
budgetKbps: fit.budgetKbps
|
|
40962
|
+
});
|
|
40963
|
+
const hwDecode = selectHwDecode({
|
|
40964
|
+
transcode: needsTranscode,
|
|
40965
|
+
reading: needsTranscode ? await probeDecoderHwaccel({
|
|
40966
|
+
ctx,
|
|
40967
|
+
log: startLog
|
|
40968
|
+
}) : null,
|
|
40969
|
+
platform: process.platform
|
|
40970
|
+
});
|
|
40971
|
+
logDecodePath(startLog, sessionId, hwDecode, needsTranscode);
|
|
38798
40972
|
const videoSsrcSigned = session.videoSsrc | 0;
|
|
38799
40973
|
const audioSsrcSigned = video.audio_ssrc | 0;
|
|
38800
|
-
const
|
|
38801
|
-
|
|
38802
|
-
"-loglevel",
|
|
38803
|
-
"warning",
|
|
38804
|
-
"-rtsp_transport",
|
|
38805
|
-
"tcp",
|
|
38806
|
-
"-i",
|
|
40974
|
+
const buildArgs = (decodeArgs) => buildSessionFfmpegArgs({
|
|
40975
|
+
decodeArgs,
|
|
38807
40976
|
rtspUrl,
|
|
38808
|
-
|
|
38809
|
-
"-map",
|
|
38810
|
-
"0:v:0",
|
|
38811
|
-
...videoArgs,
|
|
38812
|
-
"-payload_type",
|
|
38813
|
-
String(video.pt),
|
|
38814
|
-
"-ssrc",
|
|
38815
|
-
String(videoSsrcSigned),
|
|
38816
|
-
"-f",
|
|
38817
|
-
"rtp",
|
|
40977
|
+
videoArgs,
|
|
38818
40978
|
videoTarget,
|
|
38819
|
-
|
|
38820
|
-
|
|
38821
|
-
|
|
38822
|
-
|
|
38823
|
-
|
|
38824
|
-
|
|
38825
|
-
|
|
38826
|
-
"-application",
|
|
38827
|
-
"lowdelay",
|
|
38828
|
-
"-frame_duration",
|
|
38829
|
-
String(video.packet_time ?? 20),
|
|
38830
|
-
"-flags",
|
|
38831
|
-
"+global_header",
|
|
38832
|
-
"-ar",
|
|
38833
|
-
String((video.sample_rate ?? 16) * 1e3),
|
|
38834
|
-
"-b:a",
|
|
38835
|
-
`${OPUS_BITRATE_KBPS}k`,
|
|
38836
|
-
"-bufsize",
|
|
38837
|
-
`${OPUS_BITRATE_KBPS * 4}k`,
|
|
38838
|
-
"-ac",
|
|
38839
|
-
String(OPUS_CHANNELS),
|
|
38840
|
-
"-payload_type",
|
|
38841
|
-
String(video.audio_pt),
|
|
38842
|
-
"-ssrc",
|
|
38843
|
-
String(audioSsrcSigned),
|
|
38844
|
-
"-f",
|
|
38845
|
-
"rtp",
|
|
38846
|
-
audioTarget
|
|
38847
|
-
];
|
|
38848
|
-
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
38849
|
-
const proc = (0, node_child_process.spawn)("ffmpeg", args, { stdio: [
|
|
38850
|
-
"ignore",
|
|
38851
|
-
"ignore",
|
|
38852
|
-
"pipe"
|
|
38853
|
-
] });
|
|
38854
|
-
session.ffmpeg = proc;
|
|
38855
|
-
proc.stderr?.on("data", (chunk) => {
|
|
38856
|
-
const line = chunk.toString("utf8").trim();
|
|
38857
|
-
if (!line) return;
|
|
38858
|
-
log.info("export-hap: ffmpeg", { meta: {
|
|
38859
|
-
sessionId,
|
|
38860
|
-
line
|
|
38861
|
-
} });
|
|
40979
|
+
audioTarget,
|
|
40980
|
+
videoPayloadType: video.pt,
|
|
40981
|
+
videoSsrcSigned,
|
|
40982
|
+
audioPayloadType: video.audio_pt,
|
|
40983
|
+
audioSsrcSigned,
|
|
40984
|
+
audioPacketTimeMs: video.packet_time ?? 20,
|
|
40985
|
+
audioSampleRateKhz: video.sample_rate ?? 16
|
|
38862
40986
|
});
|
|
38863
|
-
|
|
40987
|
+
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
40988
|
+
let hardwareAlreadyFailed = false;
|
|
40989
|
+
const spawnFfmpeg = (decision) => {
|
|
40990
|
+
const spawnedAtMs = Date.now();
|
|
40991
|
+
const usedHardware = decision.kind === "hardware";
|
|
40992
|
+
const proc = (0, node_child_process.spawn)("ffmpeg", buildArgs(decision.args), { stdio: [
|
|
40993
|
+
"ignore",
|
|
40994
|
+
"ignore",
|
|
40995
|
+
"pipe"
|
|
40996
|
+
] });
|
|
40997
|
+
session.ffmpeg = proc;
|
|
40998
|
+
proc.stderr?.on("data", (chunk) => {
|
|
40999
|
+
const line = chunk.toString("utf8").trim();
|
|
41000
|
+
if (!line) return;
|
|
41001
|
+
log.info("export-hap: ffmpeg", { meta: {
|
|
41002
|
+
sessionId,
|
|
41003
|
+
line
|
|
41004
|
+
} });
|
|
41005
|
+
});
|
|
41006
|
+
proc.once("exit", (code, signal) => {
|
|
41007
|
+
if (shouldRetryInSoftware({
|
|
41008
|
+
usedHardware,
|
|
41009
|
+
hardwareAlreadyFailed,
|
|
41010
|
+
stopRequestedByController: session.stopRequestedByController,
|
|
41011
|
+
videoPacketsForwarded: session.videoPacketsForwarded,
|
|
41012
|
+
runtimeMs: Date.now() - spawnedAtMs
|
|
41013
|
+
})) {
|
|
41014
|
+
hardwareAlreadyFailed = true;
|
|
41015
|
+
log.warn("export-hap: hardware decode FAILED at init — respawning ffmpeg in software", { meta: {
|
|
41016
|
+
sessionId,
|
|
41017
|
+
backend: decision.kind === "hardware" ? decision.backend : null,
|
|
41018
|
+
backendSource: decision.kind === "hardware" ? decision.source : null,
|
|
41019
|
+
code,
|
|
41020
|
+
signal,
|
|
41021
|
+
runtimeMs: Date.now() - spawnedAtMs
|
|
41022
|
+
} });
|
|
41023
|
+
if (session.ffmpeg === proc) session.ffmpeg = null;
|
|
41024
|
+
spawnFfmpeg({
|
|
41025
|
+
kind: "software",
|
|
41026
|
+
reason: "hardware-attempt-failed",
|
|
41027
|
+
args: []
|
|
41028
|
+
});
|
|
41029
|
+
return;
|
|
41030
|
+
}
|
|
41031
|
+
onFfmpegExit(proc, code, signal);
|
|
41032
|
+
});
|
|
41033
|
+
proc.once("error", (err) => {
|
|
41034
|
+
log.warn("export-hap: ffmpeg spawn failed", { meta: {
|
|
41035
|
+
sessionId,
|
|
41036
|
+
error: err.message
|
|
41037
|
+
} });
|
|
41038
|
+
});
|
|
41039
|
+
};
|
|
41040
|
+
const onFfmpegExit = (proc, code, signal) => {
|
|
41041
|
+
session.ffmpegExit = {
|
|
41042
|
+
code,
|
|
41043
|
+
signal
|
|
41044
|
+
};
|
|
38864
41045
|
const ok = code === 0 && signal === null;
|
|
38865
41046
|
const meta = {
|
|
38866
41047
|
sessionId,
|
|
38867
41048
|
code,
|
|
38868
|
-
signal
|
|
41049
|
+
signal,
|
|
41050
|
+
stopRequestedByController: session.stopRequestedByController,
|
|
41051
|
+
videoPacketsForwarded: session.videoPacketsForwarded
|
|
38869
41052
|
};
|
|
38870
|
-
if (ok) log.info("export-hap: ffmpeg exited", { meta });
|
|
38871
|
-
else log.warn("export-hap: ffmpeg exited
|
|
41053
|
+
if (ok || session.stopRequestedByController) log.info("export-hap: ffmpeg exited", { meta });
|
|
41054
|
+
else log.warn("export-hap: ffmpeg exited without a controller stop", { meta });
|
|
38872
41055
|
if (session.ffmpeg === proc) session.ffmpeg = null;
|
|
38873
|
-
|
|
38874
|
-
|
|
38875
|
-
|
|
38876
|
-
sessionId,
|
|
38877
|
-
error: err.message
|
|
38878
|
-
} });
|
|
38879
|
-
});
|
|
41056
|
+
logSessionSummary(session, log, session.teardownTrigger ?? "ffmpeg-exit");
|
|
41057
|
+
};
|
|
41058
|
+
spawnFfmpeg(hwDecode);
|
|
38880
41059
|
log.info("export-hap: stream started", { meta: {
|
|
38881
41060
|
sessionId,
|
|
38882
41061
|
transcode: needsTranscode,
|
|
38883
|
-
|
|
41062
|
+
brokerId: picked.brokerId,
|
|
41063
|
+
profile: pickedProfile,
|
|
41064
|
+
sourceResolution: picked.resolution === void 0 ? null : `${picked.resolution.width}x${picked.resolution.height}`,
|
|
41065
|
+
sourceCodec: codec.length > 0 ? codec : "unknown",
|
|
41066
|
+
negotiated: `${video.width}x${video.height}@${video.fps} ${video.max_bit_rate}kbps`,
|
|
41067
|
+
slotFps: advertisedFps,
|
|
41068
|
+
slotFpsSource: advertisedFpsSource,
|
|
41069
|
+
deliveredFps,
|
|
41070
|
+
fitReason: fit.reason,
|
|
41071
|
+
encodeBudgetKbps: fit.budgetKbps,
|
|
38884
41072
|
audioCodec: "opus",
|
|
38885
|
-
audioBitrateKbps:
|
|
41073
|
+
audioBitrateKbps: 24,
|
|
41074
|
+
videoDecode: hwDecode.kind === "hardware" ? hwDecode.backend : "software"
|
|
41075
|
+
} });
|
|
41076
|
+
}
|
|
41077
|
+
/**
|
|
41078
|
+
* Say which decode path was resolved, and WHY, once per session.
|
|
41079
|
+
*
|
|
41080
|
+
* At `info` on purpose — Loki carries `info+`, and "did this session use the
|
|
41081
|
+
* GPU" is the first question anyone asks of a transcoding hub. The software
|
|
41082
|
+
* branch is the one that must never be silent: it is a real cost being paid,
|
|
41083
|
+
* and every reason it can be reached is a different fix.
|
|
41084
|
+
*/
|
|
41085
|
+
function logDecodePath(log, sessionId, decision, transcode) {
|
|
41086
|
+
if (decision.kind === "hardware") {
|
|
41087
|
+
log.info("export-hap: video decode path resolved — HARDWARE", { meta: {
|
|
41088
|
+
sessionId,
|
|
41089
|
+
backend: decision.backend,
|
|
41090
|
+
backendSource: decision.source,
|
|
41091
|
+
decodeArgs: decision.args
|
|
41092
|
+
} });
|
|
41093
|
+
return;
|
|
41094
|
+
}
|
|
41095
|
+
const level = transcode ? "warn" : "info";
|
|
41096
|
+
const message = level === "warn" ? "export-hap: video decode path resolved — SOFTWARE, this transcode costs a core" : "export-hap: video decode path resolved — none needed";
|
|
41097
|
+
log[level](message, { meta: {
|
|
41098
|
+
sessionId,
|
|
41099
|
+
reason: decision.reason,
|
|
41100
|
+
transcode
|
|
38886
41101
|
} });
|
|
38887
41102
|
}
|
|
38888
41103
|
/**
|
|
41104
|
+
* `PickedStream.profileId` is the brokerId suffix; for a profile-keyed entry
|
|
41105
|
+
* that IS the profile name. Anything else addresses a raw cam-stream and must
|
|
41106
|
+
* not be coerced into a profile.
|
|
41107
|
+
*/
|
|
41108
|
+
function toKnownProfile(profileId) {
|
|
41109
|
+
if (profileId === "high" || profileId === "mid" || profileId === "low") return profileId;
|
|
41110
|
+
return null;
|
|
41111
|
+
}
|
|
41112
|
+
/**
|
|
38889
41113
|
* Handle one incoming SRTP audio packet from iOS Home.
|
|
38890
41114
|
*
|
|
38891
41115
|
* End-to-end flow:
|
|
@@ -38906,15 +41130,22 @@ async function startFfmpegForSession(bctx, session, sessionId, video) {
|
|
|
38906
41130
|
* stays codec-agnostic.
|
|
38907
41131
|
*/
|
|
38908
41132
|
async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
|
|
38909
|
-
if (packet.length < 12)
|
|
41133
|
+
if (packet.length < 12) {
|
|
41134
|
+
drop(session, "upstream-short-packet");
|
|
41135
|
+
return;
|
|
41136
|
+
}
|
|
38910
41137
|
const log = bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId });
|
|
38911
41138
|
session.upstreamRtpPacketsReceived += 1;
|
|
38912
|
-
if (!session.upstreamAudioSrtp)
|
|
41139
|
+
if (!session.upstreamAudioSrtp) {
|
|
41140
|
+
drop(session, "upstream-no-srtp");
|
|
41141
|
+
return;
|
|
41142
|
+
}
|
|
38913
41143
|
let decryptedPacket;
|
|
38914
41144
|
try {
|
|
38915
41145
|
decryptedPacket = session.upstreamAudioSrtp.decrypt(packet);
|
|
38916
41146
|
session.upstreamRtpPacketsDecrypted += 1;
|
|
38917
41147
|
} catch (err) {
|
|
41148
|
+
drop(session, "upstream-decrypt-failed");
|
|
38918
41149
|
session.upstreamRtpDecryptFailures += 1;
|
|
38919
41150
|
if (session.upstreamRtpDecryptFailures % 100 === 1) log.debug("export-hap: SRTP decrypt failed (rate-limited)", { meta: {
|
|
38920
41151
|
failures: session.upstreamRtpDecryptFailures,
|
|
@@ -38931,18 +41162,28 @@ async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
|
|
|
38931
41162
|
rtpTimestamp = parsedRtp.header.timestamp;
|
|
38932
41163
|
rtpPayloadType = parsedRtp.header.payloadType;
|
|
38933
41164
|
} catch (err) {
|
|
41165
|
+
drop(session, "upstream-parse-failed");
|
|
38934
41166
|
log.debug("export-hap: RTP parse failed after decrypt", { meta: { error: errMsg$8(err) } });
|
|
38935
41167
|
return;
|
|
38936
41168
|
}
|
|
38937
41169
|
const negotiatedAudioPt = session.lastStartParams?.audioPt;
|
|
38938
|
-
if (negotiatedAudioPt === void 0)
|
|
38939
|
-
|
|
41170
|
+
if (negotiatedAudioPt === void 0) {
|
|
41171
|
+
drop(session, "upstream-before-start");
|
|
41172
|
+
return;
|
|
41173
|
+
}
|
|
41174
|
+
if (rtpPayloadType !== negotiatedAudioPt) {
|
|
41175
|
+
drop(session, "upstream-pt-mismatch");
|
|
41176
|
+
return;
|
|
41177
|
+
}
|
|
38940
41178
|
if (session.firstUpstreamRtpTimestamp === null) {
|
|
38941
41179
|
session.firstUpstreamRtpTimestamp = rtpTimestamp;
|
|
38942
41180
|
log.info("export-hap: first upstream RTP packet received");
|
|
38943
41181
|
}
|
|
38944
41182
|
const payload = decryptedPayload;
|
|
38945
|
-
if (payload.length === 0)
|
|
41183
|
+
if (payload.length === 0) {
|
|
41184
|
+
drop(session, "upstream-empty-payload");
|
|
41185
|
+
return;
|
|
41186
|
+
}
|
|
38946
41187
|
if (session.intercomTalkSessionId === null) {
|
|
38947
41188
|
session.intercomTalkSessionId = "";
|
|
38948
41189
|
const opened = await openIntercomTalkSession(bctx).catch((err) => {
|
|
@@ -38954,7 +41195,10 @@ async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
|
|
|
38954
41195
|
log.info("export-hap: intercom talk session opened", { meta: { sessionId: opened.sessionId } });
|
|
38955
41196
|
}
|
|
38956
41197
|
}
|
|
38957
|
-
if (!session.intercomTalkSessionId)
|
|
41198
|
+
if (!session.intercomTalkSessionId) {
|
|
41199
|
+
drop(session, "upstream-no-talk-session");
|
|
41200
|
+
return;
|
|
41201
|
+
}
|
|
38958
41202
|
const opusSampleRateHz = (session.lastStartParams?.audioSampleRateEnum ?? 16) * 1e3;
|
|
38959
41203
|
session.intercomPcmSequence += 1;
|
|
38960
41204
|
session.upstreamPcmFramesDecoded += 1;
|
|
@@ -38967,6 +41211,7 @@ async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
|
|
|
38967
41211
|
sequenceNumber: session.intercomPcmSequence
|
|
38968
41212
|
});
|
|
38969
41213
|
} catch (err) {
|
|
41214
|
+
drop(session, "upstream-push-failed");
|
|
38970
41215
|
log.debug("export-hap: intercom.pushTalkAudio failed (will re-open on next frame)", { meta: {
|
|
38971
41216
|
sequenceNumber: session.intercomPcmSequence,
|
|
38972
41217
|
error: errMsg$8(err)
|
|
@@ -39050,140 +41295,6 @@ function errMsg$7(err) {
|
|
|
39050
41295
|
return err instanceof Error ? err.message : String(err);
|
|
39051
41296
|
}
|
|
39052
41297
|
//#endregion
|
|
39053
|
-
//#region src/mappers/builders/hksv.ts
|
|
39054
|
-
/**
|
|
39055
|
-
* HomeKit Secure Video (HKSV) — stub recording delegate.
|
|
39056
|
-
*
|
|
39057
|
-
* Wires the camera as HKSV-capable so iOS Home shows the "Activity"
|
|
39058
|
-
* tab, the per-camera recording settings UI ("Stream and Allow
|
|
39059
|
-
* Recording" / "Off"), and notifications. The delegate stub:
|
|
39060
|
-
* - logs every protocol callback (active toggle, config change,
|
|
39061
|
-
* stream request, close);
|
|
39062
|
-
* - returns from `handleRecordingStreamRequest` immediately without
|
|
39063
|
-
* yielding any fragment — iOS sees "no recording available" but
|
|
39064
|
-
* the camera otherwise behaves like an HKSV camera in the UI.
|
|
39065
|
-
*
|
|
39066
|
-
* Why a stub: a real implementation needs an fMP4 segmenter that runs
|
|
39067
|
-
* a per-camera ffmpeg with a sustained pre-buffer, key/IV-derived
|
|
39068
|
-
* encrypter, motion-anchored fragment alignment, and storage for the
|
|
39069
|
-
* resulting clips. That work belongs behind the `recording` cap
|
|
39070
|
-
* (recording + playback-manifest surface) once it grows fragment export,
|
|
39071
|
-
* so the segmenter lives in the recorder addon and HKSV becomes a thin
|
|
39072
|
-
* wrapper that asks the cap for "fragments since T" and streams them to iOS.
|
|
39073
|
-
*
|
|
39074
|
-
* Until that cap lands this stub keeps HKSV characteristics visible
|
|
39075
|
-
* (a) so the operator can flip "Allow Recording" without HomeKit
|
|
39076
|
-
* complaining the camera lacks the service, and (b) so the
|
|
39077
|
-
* `CameraController` advertises a `RecordingManagement` service for
|
|
39078
|
-
* iOS-side analytics + Activity-tab UX scaffolding.
|
|
39079
|
-
*
|
|
39080
|
-
* Constructor returns a `{ options, delegate }` pair the caller
|
|
39081
|
-
* passes verbatim into `new CameraController({ ..., recording })`.
|
|
39082
|
-
*/
|
|
39083
|
-
var RECORDING_OPTIONS = {
|
|
39084
|
-
prebufferLength: 4e3,
|
|
39085
|
-
mediaContainerConfiguration: [{
|
|
39086
|
-
type: _homebridge_hap_nodejs.MediaContainerType.FRAGMENTED_MP4,
|
|
39087
|
-
fragmentLength: 4e3
|
|
39088
|
-
}],
|
|
39089
|
-
video: {
|
|
39090
|
-
type: _homebridge_hap_nodejs.VideoCodecType.H264,
|
|
39091
|
-
parameters: {
|
|
39092
|
-
profiles: [
|
|
39093
|
-
_homebridge_hap_nodejs.H264Profile.BASELINE,
|
|
39094
|
-
_homebridge_hap_nodejs.H264Profile.MAIN,
|
|
39095
|
-
_homebridge_hap_nodejs.H264Profile.HIGH
|
|
39096
|
-
],
|
|
39097
|
-
levels: [
|
|
39098
|
-
_homebridge_hap_nodejs.H264Level.LEVEL3_1,
|
|
39099
|
-
_homebridge_hap_nodejs.H264Level.LEVEL3_2,
|
|
39100
|
-
_homebridge_hap_nodejs.H264Level.LEVEL4_0
|
|
39101
|
-
]
|
|
39102
|
-
},
|
|
39103
|
-
resolutions: [
|
|
39104
|
-
[
|
|
39105
|
-
1920,
|
|
39106
|
-
1080,
|
|
39107
|
-
30
|
|
39108
|
-
],
|
|
39109
|
-
[
|
|
39110
|
-
1920,
|
|
39111
|
-
1080,
|
|
39112
|
-
24
|
|
39113
|
-
],
|
|
39114
|
-
[
|
|
39115
|
-
1920,
|
|
39116
|
-
1080,
|
|
39117
|
-
15
|
|
39118
|
-
],
|
|
39119
|
-
[
|
|
39120
|
-
1280,
|
|
39121
|
-
720,
|
|
39122
|
-
30
|
|
39123
|
-
],
|
|
39124
|
-
[
|
|
39125
|
-
1280,
|
|
39126
|
-
720,
|
|
39127
|
-
24
|
|
39128
|
-
],
|
|
39129
|
-
[
|
|
39130
|
-
1280,
|
|
39131
|
-
720,
|
|
39132
|
-
15
|
|
39133
|
-
]
|
|
39134
|
-
]
|
|
39135
|
-
},
|
|
39136
|
-
audio: { codecs: [{
|
|
39137
|
-
type: _homebridge_hap_nodejs.AudioRecordingCodecType.AAC_LC,
|
|
39138
|
-
bitrateMode: _homebridge_hap_nodejs.AudioBitrate.VARIABLE,
|
|
39139
|
-
audioChannels: 1,
|
|
39140
|
-
samplerate: [_homebridge_hap_nodejs.AudioRecordingSamplerate.KHZ_16, _homebridge_hap_nodejs.AudioRecordingSamplerate.KHZ_24]
|
|
39141
|
-
}] },
|
|
39142
|
-
overrideEventTriggerOptions: [_homebridge_hap_nodejs.EventTriggerOption.MOTION]
|
|
39143
|
-
};
|
|
39144
|
-
function buildHksvStub(bctx) {
|
|
39145
|
-
const { ctx, numericDeviceId } = bctx;
|
|
39146
|
-
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
39147
|
-
let activeStreamId = null;
|
|
39148
|
-
return {
|
|
39149
|
-
options: RECORDING_OPTIONS,
|
|
39150
|
-
delegate: {
|
|
39151
|
-
updateRecordingActive(active) {
|
|
39152
|
-
log.info("export-hap: HKSV recording active changed", { meta: { active } });
|
|
39153
|
-
},
|
|
39154
|
-
updateRecordingConfiguration(configuration) {
|
|
39155
|
-
if (!configuration) {
|
|
39156
|
-
log.info("export-hap: HKSV recording configuration cleared");
|
|
39157
|
-
return;
|
|
39158
|
-
}
|
|
39159
|
-
log.info("export-hap: HKSV recording configuration applied", { meta: {
|
|
39160
|
-
prebufferLength: configuration.prebufferLength,
|
|
39161
|
-
fragmentLength: configuration.mediaContainerConfiguration.fragmentLength,
|
|
39162
|
-
videoResolution: `${configuration.videoCodec.resolution[0]}x${configuration.videoCodec.resolution[1]}@${configuration.videoCodec.resolution[2]}`,
|
|
39163
|
-
videoBitrate: configuration.videoCodec.parameters.bitRate,
|
|
39164
|
-
eventTriggers: configuration.eventTriggerTypes
|
|
39165
|
-
} });
|
|
39166
|
-
},
|
|
39167
|
-
async *handleRecordingStreamRequest(streamId, _signal) {
|
|
39168
|
-
activeStreamId = streamId;
|
|
39169
|
-
log.warn("export-hap: HKSV stream requested — stub returns empty (no clip source yet)", { meta: { streamId } });
|
|
39170
|
-
},
|
|
39171
|
-
acknowledgeStream(streamId) {
|
|
39172
|
-
if (activeStreamId === streamId) activeStreamId = null;
|
|
39173
|
-
log.debug("export-hap: HKSV stream acknowledged", { meta: { streamId } });
|
|
39174
|
-
},
|
|
39175
|
-
closeRecordingStream(streamId, reason) {
|
|
39176
|
-
if (activeStreamId === streamId) activeStreamId = null;
|
|
39177
|
-
log.info("export-hap: HKSV stream closed", { meta: {
|
|
39178
|
-
streamId,
|
|
39179
|
-
reason: reason ?? null
|
|
39180
|
-
} });
|
|
39181
|
-
}
|
|
39182
|
-
},
|
|
39183
|
-
handle: { async dispose() {} }
|
|
39184
|
-
};
|
|
39185
|
-
}
|
|
39186
|
-
//#endregion
|
|
39187
41298
|
//#region src/mappers/builders/intercom.ts
|
|
39188
41299
|
async function buildIntercom(input) {
|
|
39189
41300
|
const { bctx, streamingOptions } = input;
|
|
@@ -39211,7 +41322,7 @@ async function buildIntercom(input) {
|
|
|
39211
41322
|
var RESET_DEBOUNCE_MS = 5e3;
|
|
39212
41323
|
async function buildMotionSensor(bctx) {
|
|
39213
41324
|
const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
|
|
39214
|
-
const motionService = accessory.addService(_homebridge_hap_nodejs.Service.MotionSensor, displayName);
|
|
41325
|
+
const motionService = accessory.addService(_homebridge_hap_nodejs.Service.MotionSensor, hapServiceName([displayName], `Camera ${numericDeviceId}`));
|
|
39215
41326
|
motionService.setCharacteristic(_homebridge_hap_nodejs.Characteristic.MotionDetected, false);
|
|
39216
41327
|
try {
|
|
39217
41328
|
const detected = await proxy.motion?.isDetected({});
|
|
@@ -39266,12 +41377,12 @@ function errMsg$6(err) {
|
|
|
39266
41377
|
* camera-enabled switch — distinct from privacy-mask).
|
|
39267
41378
|
*/
|
|
39268
41379
|
async function buildPrivacySwitch(bctx) {
|
|
39269
|
-
const { ctx, accessory, proxy, numericDeviceId } = bctx;
|
|
41380
|
+
const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
|
|
39270
41381
|
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
39271
41382
|
const subtype = "privacy-mask";
|
|
39272
|
-
const
|
|
39273
|
-
const service = accessory.addService(_homebridge_hap_nodejs.Service.Switch,
|
|
39274
|
-
service.setCharacteristic(_homebridge_hap_nodejs.Characteristic.
|
|
41383
|
+
const serviceName = privacyServiceName(displayName);
|
|
41384
|
+
const service = accessory.addService(_homebridge_hap_nodejs.Service.Switch, serviceName, subtype);
|
|
41385
|
+
service.setCharacteristic(_homebridge_hap_nodejs.Characteristic.Name, serviceName);
|
|
39275
41386
|
try {
|
|
39276
41387
|
const status = await proxy.privacyMask?.getStatus({});
|
|
39277
41388
|
if (status && typeof status.enabled === "boolean") service.updateCharacteristic(_homebridge_hap_nodejs.Characteristic.On, status.enabled);
|
|
@@ -39361,18 +41472,23 @@ function ptzPresetLabel(presetName) {
|
|
|
39361
41472
|
* `proxy.ptzAutotrack.setEnabled({enabled})`. Initial value is
|
|
39362
41473
|
* hydrated from `getStatus({})`.
|
|
39363
41474
|
*
|
|
39364
|
-
* Naming:
|
|
39365
|
-
*
|
|
39366
|
-
*
|
|
39367
|
-
*
|
|
39368
|
-
*
|
|
39369
|
-
*
|
|
39370
|
-
*
|
|
39371
|
-
* "Interruttore N".
|
|
41475
|
+
* Naming: `<camera> <action>` — "Videocamera ingresso Preset stanza" — built
|
|
41476
|
+
* by `ptzServiceName` and written to the `Name` characteristic.
|
|
41477
|
+
*
|
|
41478
|
+
* TWO separate naming defects have been through this file, and both are fixed
|
|
41479
|
+
* by that one line:
|
|
41480
|
+
* - `${displayName} — <action>` embedded an em-dash (U+2014), which is
|
|
41481
|
+
* outside Apple's permitted set. hap-nodejs' `checkName` warned, iOS
|
|
41482
|
+
* discarded the name and showed "Interruttore N". A previous round dropped
|
|
41483
|
+
* the camera prefix along with the em-dash; only the em-dash was the fault.
|
|
41484
|
+
* - The bare label that replaced it was then written to `ConfiguredName`,
|
|
41485
|
+
* which `Service.Switch` does not list, so hap-nodejs rejected the
|
|
41486
|
+
* characteristic outright — SIX rejections per PTZ camera per build, never
|
|
41487
|
+
* reported because only the two switches on the non-PTZ camera were noticed.
|
|
39372
41488
|
*/
|
|
39373
41489
|
var MOMENTARY_RESET_MS = 1e3;
|
|
39374
41490
|
async function buildPtz(bctx) {
|
|
39375
|
-
const { ctx, accessory, proxy, numericDeviceId, options } = bctx;
|
|
41491
|
+
const { ctx, accessory, proxy, numericDeviceId, displayName, options } = bctx;
|
|
39376
41492
|
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
39377
41493
|
const timers = /* @__PURE__ */ new Set();
|
|
39378
41494
|
const armReset = (cb, delay) => {
|
|
@@ -39384,10 +41500,10 @@ async function buildPtz(bctx) {
|
|
|
39384
41500
|
};
|
|
39385
41501
|
const presets = await readPresets(bctx);
|
|
39386
41502
|
for (const preset of presets) {
|
|
39387
|
-
const label = ptzPresetLabel(preset.name);
|
|
41503
|
+
const label = ptzServiceName(displayName, ptzPresetLabel(preset.name));
|
|
39388
41504
|
const subtype = `ptz-preset-${preset.id}`;
|
|
39389
41505
|
const service = accessory.addService(_homebridge_hap_nodejs.Service.Switch, label, subtype);
|
|
39390
|
-
service.setCharacteristic(_homebridge_hap_nodejs.Characteristic.
|
|
41506
|
+
service.setCharacteristic(_homebridge_hap_nodejs.Characteristic.Name, label);
|
|
39391
41507
|
service.getCharacteristic(_homebridge_hap_nodejs.Characteristic.On).onSet(async (value) => {
|
|
39392
41508
|
if (value !== true) return;
|
|
39393
41509
|
try {
|
|
@@ -39402,9 +41518,9 @@ async function buildPtz(bctx) {
|
|
|
39402
41518
|
});
|
|
39403
41519
|
}
|
|
39404
41520
|
if (proxy.ptz) for (const dir of PTZ_DIRECTIONS) {
|
|
39405
|
-
const label = dir.label;
|
|
41521
|
+
const label = ptzServiceName(displayName, dir.label);
|
|
39406
41522
|
const service = accessory.addService(_homebridge_hap_nodejs.Service.Switch, label, dir.subtype);
|
|
39407
|
-
service.setCharacteristic(_homebridge_hap_nodejs.Characteristic.
|
|
41523
|
+
service.setCharacteristic(_homebridge_hap_nodejs.Characteristic.Name, label);
|
|
39408
41524
|
service.getCharacteristic(_homebridge_hap_nodejs.Characteristic.On).onSet(async (value) => {
|
|
39409
41525
|
if (value !== true) return;
|
|
39410
41526
|
try {
|
|
@@ -39448,12 +41564,12 @@ async function readPresets(bctx) {
|
|
|
39448
41564
|
}
|
|
39449
41565
|
}
|
|
39450
41566
|
async function tryBuildAutotrack(bctx) {
|
|
39451
|
-
const { ctx, accessory, proxy, numericDeviceId } = bctx;
|
|
41567
|
+
const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
|
|
39452
41568
|
if (!proxy.ptzAutotrack) return { async dispose() {} };
|
|
39453
41569
|
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
39454
|
-
const label = PTZ_AUTOTRACK_LABEL;
|
|
41570
|
+
const label = ptzServiceName(displayName, PTZ_AUTOTRACK_LABEL);
|
|
39455
41571
|
const service = accessory.addService(_homebridge_hap_nodejs.Service.Switch, label, "ptz-autotrack");
|
|
39456
|
-
service.setCharacteristic(_homebridge_hap_nodejs.Characteristic.
|
|
41572
|
+
service.setCharacteristic(_homebridge_hap_nodejs.Characteristic.Name, label);
|
|
39457
41573
|
try {
|
|
39458
41574
|
const status = await proxy.ptzAutotrack.getStatus({});
|
|
39459
41575
|
if (status && typeof status.enabled === "boolean") service.updateCharacteristic(_homebridge_hap_nodejs.Characteristic.On, status.enabled);
|
|
@@ -39477,6 +41593,65 @@ function errMsg$4(err) {
|
|
|
39477
41593
|
return err instanceof Error ? err.message : String(err);
|
|
39478
41594
|
}
|
|
39479
41595
|
//#endregion
|
|
41596
|
+
//#region src/mappers/builders/stream-fps-probe.ts
|
|
41597
|
+
async function probeAdvertisedVideoProfile(bctx) {
|
|
41598
|
+
const { ctx, proxy, numericDeviceId, options } = bctx;
|
|
41599
|
+
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
41600
|
+
const entries = await probe(() => proxy.cameraStreams?.getProfileRtspEntries({}), "cameraStreams.getProfileRtspEntries", log);
|
|
41601
|
+
const slots = await probe(() => proxy.cameraStreams?.getBrokerStreams({}), "cameraStreams.getBrokerStreams", log);
|
|
41602
|
+
const camStreams = await probe(() => proxy.cameraStreams?.getCameraStreams({}), "cameraStreams.getCameraStreams", log);
|
|
41603
|
+
const choices = await probe(() => proxy.webrtcSession?.listStreams({}), "webrtcSession.listStreams", log);
|
|
41604
|
+
const fpsByProfile = resolveProfileFps({
|
|
41605
|
+
choices: choices ?? [],
|
|
41606
|
+
slots: slots ?? [],
|
|
41607
|
+
camStreams: camStreams ?? []
|
|
41608
|
+
});
|
|
41609
|
+
const resolutions = deriveAdvertisedResolutions({
|
|
41610
|
+
candidates: CANDIDATE_RESOLUTIONS,
|
|
41611
|
+
entries: entries ?? [],
|
|
41612
|
+
deviceId: numericDeviceId,
|
|
41613
|
+
pref: options.hapDeviceSettings.streamPreference,
|
|
41614
|
+
fpsByProfile
|
|
41615
|
+
});
|
|
41616
|
+
const assumed = resolutions.filter((r) => r.source === "assumed").length;
|
|
41617
|
+
log.info("export-hap: advertised video profile derived", { meta: {
|
|
41618
|
+
streamPreference: options.hapDeviceSettings.streamPreference,
|
|
41619
|
+
resolutions: formatAdvertisedResolutions(resolutions),
|
|
41620
|
+
profileFps: [...fpsByProfile.values()].map((f) => `${f.profile}=${f.fps}(${f.source})`),
|
|
41621
|
+
assumedCount: assumed,
|
|
41622
|
+
previouslyAdvertisedFps: 30
|
|
41623
|
+
} });
|
|
41624
|
+
if (assumed === resolutions.length) log.warn("export-hap: no measured or published frame rate for ANY profile — advertising the assumed rate", { meta: {
|
|
41625
|
+
entries: entries?.length ?? 0,
|
|
41626
|
+
choices: choices?.length ?? 0
|
|
41627
|
+
} });
|
|
41628
|
+
return {
|
|
41629
|
+
resolutions,
|
|
41630
|
+
fpsByProfile
|
|
41631
|
+
};
|
|
41632
|
+
}
|
|
41633
|
+
/**
|
|
41634
|
+
* Run one cap read. Returns null both when the cap is not bound and when the
|
|
41635
|
+
* call throws — and LOGS which, because "the camera has no telemetry" and "the
|
|
41636
|
+
* telemetry call failed" lead to different fixes.
|
|
41637
|
+
*/
|
|
41638
|
+
async function probe(call, label, log) {
|
|
41639
|
+
try {
|
|
41640
|
+
const pending = call();
|
|
41641
|
+
if (pending === void 0) {
|
|
41642
|
+
log.info("export-hap: fps probe skipped — cap not bound on this device", { meta: { call: label } });
|
|
41643
|
+
return null;
|
|
41644
|
+
}
|
|
41645
|
+
return await pending;
|
|
41646
|
+
} catch (err) {
|
|
41647
|
+
log.warn("export-hap: fps probe failed — falling back to a lower-authority source", { meta: {
|
|
41648
|
+
call: label,
|
|
41649
|
+
error: err instanceof Error ? err.message : String(err)
|
|
41650
|
+
} });
|
|
41651
|
+
return null;
|
|
41652
|
+
}
|
|
41653
|
+
}
|
|
41654
|
+
//#endregion
|
|
39480
41655
|
//#region src/mappers/builders/child-switch.ts
|
|
39481
41656
|
/**
|
|
39482
41657
|
* Child-switch builder — turns a camstack accessory child device (siren,
|
|
@@ -39521,7 +41696,7 @@ async function buildChildSwitch(bctx, subtype, deviceType) {
|
|
|
39521
41696
|
const isLightingDevice = deviceType === DeviceType.Light || deviceType === DeviceType.Generic;
|
|
39522
41697
|
const useLightbulb = hasBrightness && isLightingDevice;
|
|
39523
41698
|
const service = useLightbulb ? accessory.addService(_homebridge_hap_nodejs.Service.Lightbulb, displayName, subtype) : accessory.addService(_homebridge_hap_nodejs.Service.Switch, displayName, subtype);
|
|
39524
|
-
service.setCharacteristic(_homebridge_hap_nodejs.Characteristic.
|
|
41699
|
+
service.setCharacteristic(_homebridge_hap_nodejs.Characteristic.Name, displayName);
|
|
39525
41700
|
try {
|
|
39526
41701
|
const switchStatus = await proxy.switch?.getStatus({});
|
|
39527
41702
|
if (switchStatus && typeof switchStatus.on === "boolean") service.updateCharacteristic(_homebridge_hap_nodejs.Characteristic.On, switchStatus.on);
|
|
@@ -39593,7 +41768,7 @@ async function buildChildServicesFor(input) {
|
|
|
39593
41768
|
accessory: parentCtx.accessory,
|
|
39594
41769
|
proxy: childProxy,
|
|
39595
41770
|
numericDeviceId: child.id,
|
|
39596
|
-
displayName:
|
|
41771
|
+
displayName: childServiceName(parentDisplayName, child),
|
|
39597
41772
|
options
|
|
39598
41773
|
};
|
|
39599
41774
|
const subtype = `child-${child.id}`;
|
|
@@ -39624,21 +41799,6 @@ async function listChildren(ctx, parentNumericId) {
|
|
|
39624
41799
|
}
|
|
39625
41800
|
}
|
|
39626
41801
|
/**
|
|
39627
|
-
* Service name displayed inside the camera tile detail in iOS Home.
|
|
39628
|
-
* Prefer the child's own role ("Siren", "Floodlight") when meaningful
|
|
39629
|
-
* — the camera name is already implied by the surrounding Accessory.
|
|
39630
|
-
* Falls back to the child's stored device name when role is empty.
|
|
39631
|
-
*/
|
|
39632
|
-
function formatChildServiceName(parentName, child) {
|
|
39633
|
-
const role = child.role && child.role.length > 0 ? toTitleCase(child.role) : null;
|
|
39634
|
-
if (role) return role;
|
|
39635
|
-
if (child.name.toLowerCase().includes(parentName.toLowerCase())) {
|
|
39636
|
-
const stripped = child.name.replace(new RegExp(`\\b${escapeRegex(parentName)}\\b`, "i"), "").replace(/\s+[—-]\s+/, " ").trim();
|
|
39637
|
-
if (stripped.length > 0) return stripped;
|
|
39638
|
-
}
|
|
39639
|
-
return child.name;
|
|
39640
|
-
}
|
|
39641
|
-
/**
|
|
39642
41802
|
* Coerce the raw `child.type` string (from `deviceManager.getChildren`)
|
|
39643
41803
|
* to a `DeviceType` enum value. Unknown / mis-cased values fall back to
|
|
39644
41804
|
* `Generic` so an unrecognised driver behaves like the safest existing
|
|
@@ -39650,12 +41810,6 @@ function asDeviceType(raw) {
|
|
|
39650
41810
|
for (const value of Object.values(DeviceType)) if (value === lower) return value;
|
|
39651
41811
|
return DeviceType.Generic;
|
|
39652
41812
|
}
|
|
39653
|
-
function escapeRegex(s) {
|
|
39654
|
-
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
39655
|
-
}
|
|
39656
|
-
function toTitleCase(raw) {
|
|
39657
|
-
return raw.split("-").filter((part) => part.length > 0).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
|
|
39658
|
-
}
|
|
39659
41813
|
function errMsg$2(err) {
|
|
39660
41814
|
return err instanceof Error ? err.message : String(err);
|
|
39661
41815
|
}
|
|
@@ -39703,24 +41857,18 @@ async function buildCameraAccessory(input) {
|
|
|
39703
41857
|
displayName,
|
|
39704
41858
|
options
|
|
39705
41859
|
};
|
|
39706
|
-
const streams = buildCameraStreamingDelegate(bctx);
|
|
41860
|
+
const streams = buildCameraStreamingDelegate(bctx, await probeAdvertisedVideoProfile(bctx));
|
|
39707
41861
|
const handles = [];
|
|
39708
41862
|
if (capNames.has("intercom")) handles.push(await buildIntercom({
|
|
39709
41863
|
bctx,
|
|
39710
41864
|
streamingOptions: streams.streamingOptions
|
|
39711
41865
|
}));
|
|
39712
|
-
const hksv = capNames.has("motion-detection") ? buildHksvStub(bctx) : null;
|
|
39713
41866
|
const controller = new (isDoorbell ? _homebridge_hap_nodejs.DoorbellController : _homebridge_hap_nodejs.CameraController)({
|
|
39714
41867
|
delegate: streams.delegate,
|
|
39715
41868
|
streamingOptions: streams.streamingOptions,
|
|
39716
|
-
cameraStreamCount: 2
|
|
39717
|
-
...hksv ? { recording: {
|
|
39718
|
-
options: hksv.options,
|
|
39719
|
-
delegate: hksv.delegate
|
|
39720
|
-
} } : {}
|
|
41869
|
+
cameraStreamCount: 2
|
|
39721
41870
|
});
|
|
39722
41871
|
accessory.configureController(controller);
|
|
39723
|
-
if (hksv) handles.push(hksv.handle);
|
|
39724
41872
|
if (capNames.has("motion-detection")) handles.push(await buildMotionSensor(bctx));
|
|
39725
41873
|
if (isDoorbell && controller instanceof _homebridge_hap_nodejs.DoorbellController) handles.push(await buildDoorbell({
|
|
39726
41874
|
bctx,
|