@camstack/addon-export-hap 1.2.12 → 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 +1770 -216
- package/dist/export-hap.addon.mjs +1770 -216
- 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.
|
|
14402
14801
|
*/
|
|
14403
|
-
var
|
|
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.
|
|
14807
|
+
*/
|
|
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(),
|
|
@@ -14742,7 +15153,31 @@ var RebuildObjectEmbeddingsInput = object({
|
|
|
14742
15153
|
since: number().optional(),
|
|
14743
15154
|
until: number().optional(),
|
|
14744
15155
|
/** Stop after this many tracks; the result reports whether more remain. */
|
|
14745
|
-
maxTracks: number().int().positive().optional()
|
|
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()
|
|
14746
15181
|
});
|
|
14747
15182
|
/**
|
|
14748
15183
|
* Result of emptying the CLIP index.
|
|
@@ -14776,13 +15211,23 @@ var RebuildStatusSchema = object({
|
|
|
14776
15211
|
/** Tracks with no usable detection box. */
|
|
14777
15212
|
missingBbox: number(),
|
|
14778
15213
|
/**
|
|
14779
|
-
* Tracks
|
|
14780
|
-
*
|
|
14781
|
-
*
|
|
14782
|
-
*
|
|
14783
|
-
* otherwise read as a total engine outage.
|
|
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.
|
|
14784
15218
|
*/
|
|
14785
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(),
|
|
14786
15231
|
failed: number(),
|
|
14787
15232
|
/** Set once a pass ends: true only when EVERYTHING was covered. */
|
|
14788
15233
|
complete: boolean().nullable(),
|
|
@@ -14854,7 +15299,12 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
|
|
|
14854
15299
|
}), {
|
|
14855
15300
|
kind: "mutation",
|
|
14856
15301
|
auth: "admin"
|
|
14857
|
-
}), 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, {
|
|
14858
15308
|
kind: "query",
|
|
14859
15309
|
auth: "admin"
|
|
14860
15310
|
}), method(object({
|
|
@@ -15464,6 +15914,53 @@ var DetailResultSchema = object({
|
|
|
15464
15914
|
nativeFaceShortSidePx: number().optional()
|
|
15465
15915
|
});
|
|
15466
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
|
+
/**
|
|
15467
15964
|
* Per-camera tunable ranges + defaults. Single source of truth used
|
|
15468
15965
|
* by both the Zod data schema (validation + default fallback) and
|
|
15469
15966
|
* the device settings UI (slider min/max/step). Touch one place and
|
|
@@ -15813,7 +16310,32 @@ method(RunnerCameraConfigSchema, object({ success: literal(true) }), { kind: "mu
|
|
|
15813
16310
|
cropJpeg: string().optional(),
|
|
15814
16311
|
parent: DetailParentSchema,
|
|
15815
16312
|
steps: array(string()).optional()
|
|
15816
|
-
}), 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" });
|
|
15817
16339
|
var CameraPipelineConfigSchema = object({
|
|
15818
16340
|
engine: PipelineEngineChoiceSchema.optional(),
|
|
15819
16341
|
steps: array(PipelineStepInputSchema).readonly(),
|
|
@@ -16111,6 +16633,20 @@ var CameraStatusSchema = object({
|
|
|
16111
16633
|
detection: CameraDetectionStatusSchema.nullable(),
|
|
16112
16634
|
audio: CameraAudioStatusSchema.nullable(),
|
|
16113
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(),
|
|
16114
16650
|
/** Unix timestamp (ms) when this snapshot was composed server-side. */
|
|
16115
16651
|
fetchedAt: number()
|
|
16116
16652
|
});
|
|
@@ -16279,7 +16815,14 @@ method(object({
|
|
|
16279
16815
|
}), method(object({
|
|
16280
16816
|
deviceId: number(),
|
|
16281
16817
|
agentNodeId: string().optional()
|
|
16282
|
-
}), 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({
|
|
16283
16826
|
name: string(),
|
|
16284
16827
|
description: string().optional(),
|
|
16285
16828
|
config: CameraPipelineConfigSchema
|
|
@@ -25236,6 +25779,12 @@ Object.freeze({
|
|
|
25236
25779
|
addonId: null,
|
|
25237
25780
|
access: "view"
|
|
25238
25781
|
},
|
|
25782
|
+
"notificationRules.listDeviceMutes": {
|
|
25783
|
+
capName: "notification-rules",
|
|
25784
|
+
capScope: "system",
|
|
25785
|
+
addonId: null,
|
|
25786
|
+
access: "view"
|
|
25787
|
+
},
|
|
25239
25788
|
"notificationRules.listRules": {
|
|
25240
25789
|
capName: "notification-rules",
|
|
25241
25790
|
capScope: "system",
|
|
@@ -25254,6 +25803,12 @@ Object.freeze({
|
|
|
25254
25803
|
addonId: null,
|
|
25255
25804
|
access: "create"
|
|
25256
25805
|
},
|
|
25806
|
+
"notificationRules.setDeviceMuted": {
|
|
25807
|
+
capName: "notification-rules",
|
|
25808
|
+
capScope: "system",
|
|
25809
|
+
addonId: null,
|
|
25810
|
+
access: "create"
|
|
25811
|
+
},
|
|
25257
25812
|
"notificationRules.setRuleEnabled": {
|
|
25258
25813
|
capName: "notification-rules",
|
|
25259
25814
|
capScope: "system",
|
|
@@ -25530,6 +26085,12 @@ Object.freeze({
|
|
|
25530
26085
|
addonId: null,
|
|
25531
26086
|
access: "view"
|
|
25532
26087
|
},
|
|
26088
|
+
"pipelineAnalytics.setTrackFlags": {
|
|
26089
|
+
capName: "pipeline-analytics",
|
|
26090
|
+
capScope: "device",
|
|
26091
|
+
addonId: null,
|
|
26092
|
+
access: "create"
|
|
26093
|
+
},
|
|
25533
26094
|
"pipelineAnalytics.wipeAllAnalytics": {
|
|
25534
26095
|
capName: "pipeline-analytics",
|
|
25535
26096
|
capScope: "device",
|
|
@@ -25836,6 +26397,12 @@ Object.freeze({
|
|
|
25836
26397
|
addonId: null,
|
|
25837
26398
|
access: "view"
|
|
25838
26399
|
},
|
|
26400
|
+
"pipelineOrchestrator.getCameraSwitches": {
|
|
26401
|
+
capName: "pipeline-orchestrator",
|
|
26402
|
+
capScope: "system",
|
|
26403
|
+
addonId: null,
|
|
26404
|
+
access: "view"
|
|
26405
|
+
},
|
|
25839
26406
|
"pipelineOrchestrator.getCapabilityBindings": {
|
|
25840
26407
|
capName: "pipeline-orchestrator",
|
|
25841
26408
|
capScope: "system",
|
|
@@ -25968,6 +26535,12 @@ Object.freeze({
|
|
|
25968
26535
|
addonId: null,
|
|
25969
26536
|
access: "create"
|
|
25970
26537
|
},
|
|
26538
|
+
"pipelineOrchestrator.setCameraSwitch": {
|
|
26539
|
+
capName: "pipeline-orchestrator",
|
|
26540
|
+
capScope: "system",
|
|
26541
|
+
addonId: null,
|
|
26542
|
+
access: "create"
|
|
26543
|
+
},
|
|
25971
26544
|
"pipelineOrchestrator.setCapabilityBinding": {
|
|
25972
26545
|
capName: "pipeline-orchestrator",
|
|
25973
26546
|
capScope: "system",
|
|
@@ -26058,6 +26631,12 @@ Object.freeze({
|
|
|
26058
26631
|
addonId: null,
|
|
26059
26632
|
access: "create"
|
|
26060
26633
|
},
|
|
26634
|
+
"pipelineRunner.runStatelessStep": {
|
|
26635
|
+
capName: "pipeline-runner",
|
|
26636
|
+
capScope: "system",
|
|
26637
|
+
addonId: null,
|
|
26638
|
+
access: "create"
|
|
26639
|
+
},
|
|
26061
26640
|
"plateGallery.assignPlate": {
|
|
26062
26641
|
capName: "plate-gallery",
|
|
26063
26642
|
capScope: "system",
|
|
@@ -26874,6 +27453,12 @@ Object.freeze({
|
|
|
26874
27453
|
addonId: null,
|
|
26875
27454
|
access: "create"
|
|
26876
27455
|
},
|
|
27456
|
+
"streamBroker.acquireEgressTranscode": {
|
|
27457
|
+
capName: "stream-broker",
|
|
27458
|
+
capScope: "system",
|
|
27459
|
+
addonId: null,
|
|
27460
|
+
access: "create"
|
|
27461
|
+
},
|
|
26877
27462
|
"streamBroker.assignProfile": {
|
|
26878
27463
|
capName: "stream-broker",
|
|
26879
27464
|
capScope: "system",
|
|
@@ -26982,6 +27567,12 @@ Object.freeze({
|
|
|
26982
27567
|
addonId: null,
|
|
26983
27568
|
access: "create"
|
|
26984
27569
|
},
|
|
27570
|
+
"streamBroker.releaseEgressTranscode": {
|
|
27571
|
+
capName: "stream-broker",
|
|
27572
|
+
capScope: "system",
|
|
27573
|
+
addonId: null,
|
|
27574
|
+
access: "create"
|
|
27575
|
+
},
|
|
26985
27576
|
"streamBroker.releaseStreamWithCodec": {
|
|
26986
27577
|
capName: "stream-broker",
|
|
26987
27578
|
capScope: "system",
|
|
@@ -27767,37 +28358,6 @@ object({
|
|
|
27767
28358
|
square: false
|
|
27768
28359
|
}).paddingRatio;
|
|
27769
28360
|
/**
|
|
27770
|
-
* Deterministic SHA-256 hash of an arbitrary serialisable value. The
|
|
27771
|
-
* canonical form sorts object keys alphabetically at every depth so two
|
|
27772
|
-
* structurally-equal inputs with different key insertion orders produce
|
|
27773
|
-
* the same hash. Returns a 64-char lowercase hex digest.
|
|
27774
|
-
*
|
|
27775
|
-
* Used by export adapters (Alexa, HAP) to short-circuit re-discovery /
|
|
27776
|
-
* accessory-rebuild work when the upstream shape is byte-identical to
|
|
27777
|
-
* the last applied state — preventing user-visible "re-discovery"
|
|
27778
|
-
* notifications on every addon-runner respawn. Each respawn re-fires
|
|
27779
|
-
* `DeviceBindingsChanged` for every cap registration, which without
|
|
27780
|
-
* this guard would propagate redundant pushes.
|
|
27781
|
-
*
|
|
27782
|
-
* Note: this is a SYMPTOMATIC fix layered on top of the binding-change
|
|
27783
|
-
* subscription. The proper fix is a single "device ready" lifecycle
|
|
27784
|
-
* barrier so exports react only when the full cap set has landed —
|
|
27785
|
-
* tracked separately for post-HA-integration work.
|
|
27786
|
-
*/
|
|
27787
|
-
function canonicalHash(value) {
|
|
27788
|
-
const canonical = JSON.stringify(value, replaceWithSortedKeys);
|
|
27789
|
-
return (0, node_crypto.createHash)("sha256").update(canonical ?? "").digest("hex");
|
|
27790
|
-
}
|
|
27791
|
-
function replaceWithSortedKeys(_key, value) {
|
|
27792
|
-
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
27793
|
-
const obj = value;
|
|
27794
|
-
const out = {};
|
|
27795
|
-
for (const k of Object.keys(obj).toSorted()) out[k] = obj[k];
|
|
27796
|
-
return out;
|
|
27797
|
-
}
|
|
27798
|
-
return value;
|
|
27799
|
-
}
|
|
27800
|
-
/**
|
|
27801
28361
|
* Compute the stable 64-char lowercase-hex fingerprint of a device's
|
|
27802
28362
|
* export-relevant shape. Two structurally-equal shapes (any feature order,
|
|
27803
28363
|
* any duplicates, any deviceId) hash identically.
|
|
@@ -27968,6 +28528,110 @@ function firstExposedAccessorySetupUri(exposed, logger) {
|
|
|
27968
28528
|
}
|
|
27969
28529
|
}
|
|
27970
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
|
+
}
|
|
27971
28635
|
//#endregion
|
|
27972
28636
|
//#region src/mappers/builders/battery.ts
|
|
27973
28637
|
/**
|
|
@@ -27992,7 +28656,7 @@ var LOW_BATTERY_THRESHOLD_PCT = 20;
|
|
|
27992
28656
|
async function buildBattery(bctx) {
|
|
27993
28657
|
const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
|
|
27994
28658
|
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
27995
|
-
const service = accessory.addService(_homebridge_hap_nodejs.Service.Battery, displayName);
|
|
28659
|
+
const service = accessory.addService(_homebridge_hap_nodejs.Service.Battery, hapServiceName([displayName], `Camera ${numericDeviceId}`));
|
|
27996
28660
|
try {
|
|
27997
28661
|
const status = await proxy.battery?.getStatus({});
|
|
27998
28662
|
if (status) applyToService(service, status);
|
|
@@ -38354,6 +39018,573 @@ function makeRtcpGate(socket, timeoutMs) {
|
|
|
38354
39018
|
socket.on("message", onMessage);
|
|
38355
39019
|
});
|
|
38356
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
|
+
}
|
|
38357
39588
|
/**
|
|
38358
39589
|
* The resolutions we offer, before rates are attached. Same list the delegate
|
|
38359
39590
|
* advertised before R2 — only the frame rate changes, so a controller that had
|
|
@@ -38492,6 +39723,129 @@ var CAM_PROFILES = [
|
|
|
38492
39723
|
function toCamProfile(profileId) {
|
|
38493
39724
|
return CAM_PROFILES.find((p) => p === profileId) ?? null;
|
|
38494
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
|
+
}
|
|
38495
39849
|
//#endregion
|
|
38496
39850
|
//#region src/mappers/builders/stream-telemetry.ts
|
|
38497
39851
|
/**
|
|
@@ -38513,6 +39867,12 @@ var ZERO_DROP_COUNTERS = {
|
|
|
38513
39867
|
"rtcp-no-srtcp": 0,
|
|
38514
39868
|
/** Outbound RTCP: building or encrypting the Sender Report failed. */
|
|
38515
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,
|
|
38516
39876
|
/** Upstream: packet shorter than an RTP header. */
|
|
38517
39877
|
"upstream-short-packet": 0,
|
|
38518
39878
|
/** Upstream: no inbound SRTP context (init failed at prepareStream). */
|
|
@@ -38568,6 +39928,21 @@ function classifyInboundPacket(packet) {
|
|
|
38568
39928
|
return "rtp";
|
|
38569
39929
|
}
|
|
38570
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
|
+
/**
|
|
38571
39946
|
* Build the meta for `export-hap: stream session summary` — the single line a
|
|
38572
39947
|
* future session greps to answer "why did this session die".
|
|
38573
39948
|
*/
|
|
@@ -38588,7 +39963,13 @@ function summariseSession(snapshot) {
|
|
|
38588
39963
|
selectedBrokerId: slot?.brokerId ?? null,
|
|
38589
39964
|
advertisedFps: slot?.advertisedFps ?? null,
|
|
38590
39965
|
advertisedFpsSource: slot?.advertisedFpsSource ?? null,
|
|
39966
|
+
deliveredFps: slot?.deliveredFps ?? null,
|
|
38591
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 ?? [],
|
|
38592
39973
|
videoPacketsForwarded: snapshot.videoPacketsForwarded,
|
|
38593
39974
|
audioPacketsForwarded: snapshot.audioPacketsForwarded,
|
|
38594
39975
|
videoRtcpSrSent: snapshot.videoRtcpSrSent,
|
|
@@ -38599,6 +39980,10 @@ function summariseSession(snapshot) {
|
|
|
38599
39980
|
audioRtpReceived: snapshot.audioRtpReceived,
|
|
38600
39981
|
videoGate: formatRtcpGate(snapshot.videoGate),
|
|
38601
39982
|
audioGate: formatRtcpGate(snapshot.audioGate),
|
|
39983
|
+
videoReceiverReports: snapshot.videoReceiverReports,
|
|
39984
|
+
audioReceiverReports: snapshot.audioReceiverReports,
|
|
39985
|
+
videoLossVerdict: lossVerdict(snapshot.videoReceiverReports),
|
|
39986
|
+
audioLossVerdict: lossVerdict(snapshot.audioReceiverReports),
|
|
38602
39987
|
mediaStarved: snapshot.videoPacketsForwarded === 0,
|
|
38603
39988
|
drops: nonZeroDrops(snapshot.drops)
|
|
38604
39989
|
};
|
|
@@ -38618,8 +40003,17 @@ var SRTP_SALT_LEN = 14;
|
|
|
38618
40003
|
* make this one act.
|
|
38619
40004
|
*/
|
|
38620
40005
|
var SESSION_HEARTBEAT_MS = 5e3;
|
|
38621
|
-
|
|
38622
|
-
|
|
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;
|
|
38623
40017
|
function buildCameraStreamingDelegate(bctx, advertised) {
|
|
38624
40018
|
const { ctx, numericDeviceId } = bctx;
|
|
38625
40019
|
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
@@ -38727,7 +40121,7 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
38727
40121
|
},
|
|
38728
40122
|
profile: import_src.ProtectionProfileAes128CmHmacSha1_80
|
|
38729
40123
|
});
|
|
38730
|
-
const
|
|
40124
|
+
const makeSrtcp = (key, salt) => new import_src.SrtcpSession({
|
|
38731
40125
|
keys: {
|
|
38732
40126
|
localMasterKey: key,
|
|
38733
40127
|
localMasterSalt: salt,
|
|
@@ -38738,13 +40132,17 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
38738
40132
|
});
|
|
38739
40133
|
let videoOutSrtp;
|
|
38740
40134
|
let videoOutSrtcp;
|
|
40135
|
+
let videoInSrtcp;
|
|
38741
40136
|
let audioOutSrtp;
|
|
38742
40137
|
let audioOutSrtcp;
|
|
40138
|
+
let audioInSrtcp;
|
|
38743
40139
|
try {
|
|
38744
40140
|
videoOutSrtp = makeOutSrtp(request.video.srtp_key, request.video.srtp_salt);
|
|
38745
|
-
videoOutSrtcp =
|
|
40141
|
+
videoOutSrtcp = makeSrtcp(request.video.srtp_key, request.video.srtp_salt);
|
|
40142
|
+
videoInSrtcp = makeSrtcp(request.video.srtp_key, request.video.srtp_salt);
|
|
38746
40143
|
audioOutSrtp = makeOutSrtp(request.audio.srtp_key, request.audio.srtp_salt);
|
|
38747
|
-
audioOutSrtcp =
|
|
40144
|
+
audioOutSrtcp = makeSrtcp(request.audio.srtp_key, request.audio.srtp_salt);
|
|
40145
|
+
audioInSrtcp = makeSrtcp(request.audio.srtp_key, request.audio.srtp_salt);
|
|
38748
40146
|
} catch (err) {
|
|
38749
40147
|
closeSocket(videoUdp);
|
|
38750
40148
|
closeSocket(audioUdp);
|
|
@@ -38786,6 +40184,10 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
38786
40184
|
audioRtcpReceived: 0,
|
|
38787
40185
|
videoRtpReceived: 0,
|
|
38788
40186
|
audioRtpReceived: 0,
|
|
40187
|
+
videoReceiverReports: emptyReceiverReportTally(),
|
|
40188
|
+
audioReceiverReports: emptyReceiverReportTally(),
|
|
40189
|
+
videoRrLoggedAt: 0,
|
|
40190
|
+
audioRrLoggedAt: 0,
|
|
38789
40191
|
videoGate: null,
|
|
38790
40192
|
audioGate: null,
|
|
38791
40193
|
heartbeat: null,
|
|
@@ -38797,6 +40199,7 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
38797
40199
|
videoLoopUdp,
|
|
38798
40200
|
videoOutSrtp,
|
|
38799
40201
|
videoOutSrtcp,
|
|
40202
|
+
videoInSrtcp,
|
|
38800
40203
|
videoOutPacketCount: 0,
|
|
38801
40204
|
videoOutOctetCount: 0,
|
|
38802
40205
|
videoOutLastRtpTimestamp: 0,
|
|
@@ -38811,6 +40214,7 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
38811
40214
|
audioLoopUdp,
|
|
38812
40215
|
audioOutSrtp,
|
|
38813
40216
|
audioOutSrtcp,
|
|
40217
|
+
audioInSrtcp,
|
|
38814
40218
|
audioSendGate: null,
|
|
38815
40219
|
ipVersion,
|
|
38816
40220
|
ffmpeg: null,
|
|
@@ -38987,6 +40391,111 @@ function countInbound(session, leg, packet, log) {
|
|
|
38987
40391
|
leg,
|
|
38988
40392
|
bytes: packet.length
|
|
38989
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 });
|
|
38990
40499
|
}
|
|
38991
40500
|
/** Snapshot every counter into the summary meta. */
|
|
38992
40501
|
function sessionSummaryMeta(session) {
|
|
@@ -39006,6 +40515,8 @@ function sessionSummaryMeta(session) {
|
|
|
39006
40515
|
audioRtpReceived: session.audioRtpReceived,
|
|
39007
40516
|
videoGate: session.videoGate,
|
|
39008
40517
|
audioGate: session.audioGate,
|
|
40518
|
+
videoReceiverReports: session.videoReceiverReports,
|
|
40519
|
+
audioReceiverReports: session.audioReceiverReports,
|
|
39009
40520
|
drops: session.drops,
|
|
39010
40521
|
ffmpegExit: session.ffmpegExit,
|
|
39011
40522
|
stopRequestedByController: session.stopRequestedByController
|
|
@@ -39041,7 +40552,15 @@ function stopHeartbeat(session) {
|
|
|
39041
40552
|
* a HomeKit session died. It carries the negotiated parameters, the slot we
|
|
39042
40553
|
* dialled and the rate we had promised for it, packet counts in both
|
|
39043
40554
|
* directions on both legs, each leg's gate outcome, ffmpeg's exit, who asked
|
|
39044
|
-
* for the teardown,
|
|
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".
|
|
39045
40564
|
*
|
|
39046
40565
|
* Emitted from the ffmpeg `exit` handler — the only place the exit code is
|
|
39047
40566
|
* known — and directly from the teardown paths when there is no ffmpeg to wait
|
|
@@ -39354,11 +40873,24 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
|
|
|
39354
40873
|
throw new Error(`export-hap: no profile RTSP entries for device ${numericDeviceId}`);
|
|
39355
40874
|
}
|
|
39356
40875
|
const pref = options.hapDeviceSettings.streamPreference;
|
|
39357
|
-
const
|
|
39358
|
-
|
|
39359
|
-
|
|
39360
|
-
|
|
39361
|
-
|
|
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) {
|
|
39362
40894
|
startLog.warn("export-hap: stream start DROPPED — no ENABLED profile RTSP entry", { meta: {
|
|
39363
40895
|
sessionId,
|
|
39364
40896
|
streamPreference: pref,
|
|
@@ -39367,14 +40899,18 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
|
|
|
39367
40899
|
} });
|
|
39368
40900
|
throw new Error(`export-hap: no enabled RTSP entries for device ${numericDeviceId}`);
|
|
39369
40901
|
}
|
|
40902
|
+
const picked = fit.picked;
|
|
39370
40903
|
const rtspUrl = picked.url;
|
|
39371
|
-
const slot =
|
|
40904
|
+
const slot = brokerStreams.find((s) => s.profile === fit.profile);
|
|
39372
40905
|
const codec = (picked.codec ?? slot?.codec ?? "").toLowerCase();
|
|
39373
|
-
const needsTranscode =
|
|
40906
|
+
const needsTranscode = fit.kind === "transcode";
|
|
39374
40907
|
const pickedProfile = toKnownProfile(picked.profileId);
|
|
39375
40908
|
const resolvedFps = pickedProfile === null ? void 0 : advertised.fpsByProfile.get(pickedProfile);
|
|
39376
40909
|
const advertisedFps = resolvedFps?.fps ?? video.fps;
|
|
39377
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);
|
|
39378
40914
|
session.selectedSlot = {
|
|
39379
40915
|
profile: pickedProfile,
|
|
39380
40916
|
brokerId: picked.brokerId,
|
|
@@ -39382,14 +40918,33 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
|
|
|
39382
40918
|
height: picked.resolution?.height ?? null,
|
|
39383
40919
|
advertisedFps,
|
|
39384
40920
|
advertisedFpsSource,
|
|
40921
|
+
deliveredFps,
|
|
39385
40922
|
codec: codec.length > 0 ? codec : "unknown",
|
|
39386
|
-
transcode: needsTranscode
|
|
40923
|
+
transcode: needsTranscode,
|
|
40924
|
+
fitReason: fit.reason,
|
|
40925
|
+
publishedKbps: slotEvidence?.publishedKbps ?? null,
|
|
40926
|
+
measuredKbps: slotEvidence?.measuredKbps ?? null,
|
|
40927
|
+
budgetKbps: fit.budgetKbps,
|
|
40928
|
+
fitNotes
|
|
39387
40929
|
};
|
|
39388
|
-
|
|
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: {
|
|
39389
40943
|
sessionId,
|
|
39390
40944
|
negotiatedFps: video.fps,
|
|
39391
40945
|
slotFps: advertisedFps,
|
|
39392
40946
|
slotFpsSource: advertisedFpsSource,
|
|
40947
|
+
deliveredFps,
|
|
39393
40948
|
profile: pickedProfile,
|
|
39394
40949
|
brokerId: picked.brokerId,
|
|
39395
40950
|
transcode: needsTranscode
|
|
@@ -39398,101 +40953,91 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
|
|
|
39398
40953
|
const audioLoopPort = session.audioLoopUdp.address().port;
|
|
39399
40954
|
const videoTarget = `rtp://127.0.0.1:${videoLoopPort}?pkt_size=${video.mtu}`;
|
|
39400
40955
|
const audioTarget = `rtp://127.0.0.1:${audioLoopPort}?pkt_size=${video.mtu}`;
|
|
39401
|
-
const videoArgs =
|
|
39402
|
-
|
|
39403
|
-
|
|
39404
|
-
|
|
39405
|
-
|
|
39406
|
-
|
|
39407
|
-
|
|
39408
|
-
|
|
39409
|
-
|
|
39410
|
-
|
|
39411
|
-
|
|
39412
|
-
|
|
39413
|
-
|
|
39414
|
-
|
|
39415
|
-
|
|
39416
|
-
|
|
39417
|
-
`${video.max_bit_rate * 2}k`,
|
|
39418
|
-
"-maxrate",
|
|
39419
|
-
`${video.max_bit_rate}k`,
|
|
39420
|
-
"-profile:v",
|
|
39421
|
-
"baseline",
|
|
39422
|
-
"-level",
|
|
39423
|
-
"3.1"
|
|
39424
|
-
] : [
|
|
39425
|
-
"-c:v",
|
|
39426
|
-
"copy",
|
|
39427
|
-
"-bsf:v",
|
|
39428
|
-
"dump_extra"
|
|
39429
|
-
];
|
|
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);
|
|
39430
40972
|
const videoSsrcSigned = session.videoSsrc | 0;
|
|
39431
40973
|
const audioSsrcSigned = video.audio_ssrc | 0;
|
|
39432
|
-
const
|
|
39433
|
-
|
|
39434
|
-
"-loglevel",
|
|
39435
|
-
"warning",
|
|
39436
|
-
"-rtsp_transport",
|
|
39437
|
-
"tcp",
|
|
39438
|
-
"-i",
|
|
40974
|
+
const buildArgs = (decodeArgs) => buildSessionFfmpegArgs({
|
|
40975
|
+
decodeArgs,
|
|
39439
40976
|
rtspUrl,
|
|
39440
|
-
|
|
39441
|
-
"-map",
|
|
39442
|
-
"0:v:0",
|
|
39443
|
-
...videoArgs,
|
|
39444
|
-
"-payload_type",
|
|
39445
|
-
String(video.pt),
|
|
39446
|
-
"-ssrc",
|
|
39447
|
-
String(videoSsrcSigned),
|
|
39448
|
-
"-f",
|
|
39449
|
-
"rtp",
|
|
40977
|
+
videoArgs,
|
|
39450
40978
|
videoTarget,
|
|
39451
|
-
|
|
39452
|
-
|
|
39453
|
-
|
|
39454
|
-
|
|
39455
|
-
|
|
39456
|
-
|
|
39457
|
-
|
|
39458
|
-
"-application",
|
|
39459
|
-
"lowdelay",
|
|
39460
|
-
"-frame_duration",
|
|
39461
|
-
String(video.packet_time ?? 20),
|
|
39462
|
-
"-flags",
|
|
39463
|
-
"+global_header",
|
|
39464
|
-
"-ar",
|
|
39465
|
-
String((video.sample_rate ?? 16) * 1e3),
|
|
39466
|
-
"-b:a",
|
|
39467
|
-
`${OPUS_BITRATE_KBPS}k`,
|
|
39468
|
-
"-bufsize",
|
|
39469
|
-
`${OPUS_BITRATE_KBPS * 4}k`,
|
|
39470
|
-
"-ac",
|
|
39471
|
-
String(OPUS_CHANNELS),
|
|
39472
|
-
"-payload_type",
|
|
39473
|
-
String(video.audio_pt),
|
|
39474
|
-
"-ssrc",
|
|
39475
|
-
String(audioSsrcSigned),
|
|
39476
|
-
"-f",
|
|
39477
|
-
"rtp",
|
|
39478
|
-
audioTarget
|
|
39479
|
-
];
|
|
39480
|
-
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
39481
|
-
const proc = (0, node_child_process.spawn)("ffmpeg", args, { stdio: [
|
|
39482
|
-
"ignore",
|
|
39483
|
-
"ignore",
|
|
39484
|
-
"pipe"
|
|
39485
|
-
] });
|
|
39486
|
-
session.ffmpeg = proc;
|
|
39487
|
-
proc.stderr?.on("data", (chunk) => {
|
|
39488
|
-
const line = chunk.toString("utf8").trim();
|
|
39489
|
-
if (!line) return;
|
|
39490
|
-
log.info("export-hap: ffmpeg", { meta: {
|
|
39491
|
-
sessionId,
|
|
39492
|
-
line
|
|
39493
|
-
} });
|
|
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
|
|
39494
40986
|
});
|
|
39495
|
-
|
|
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) => {
|
|
39496
41041
|
session.ffmpegExit = {
|
|
39497
41042
|
code,
|
|
39498
41043
|
signal
|
|
@@ -39509,13 +41054,8 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
|
|
|
39509
41054
|
else log.warn("export-hap: ffmpeg exited without a controller stop", { meta });
|
|
39510
41055
|
if (session.ffmpeg === proc) session.ffmpeg = null;
|
|
39511
41056
|
logSessionSummary(session, log, session.teardownTrigger ?? "ffmpeg-exit");
|
|
39512
|
-
}
|
|
39513
|
-
|
|
39514
|
-
log.warn("export-hap: ffmpeg spawn failed", { meta: {
|
|
39515
|
-
sessionId,
|
|
39516
|
-
error: err.message
|
|
39517
|
-
} });
|
|
39518
|
-
});
|
|
41057
|
+
};
|
|
41058
|
+
spawnFfmpeg(hwDecode);
|
|
39519
41059
|
log.info("export-hap: stream started", { meta: {
|
|
39520
41060
|
sessionId,
|
|
39521
41061
|
transcode: needsTranscode,
|
|
@@ -39526,8 +41066,38 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
|
|
|
39526
41066
|
negotiated: `${video.width}x${video.height}@${video.fps} ${video.max_bit_rate}kbps`,
|
|
39527
41067
|
slotFps: advertisedFps,
|
|
39528
41068
|
slotFpsSource: advertisedFpsSource,
|
|
41069
|
+
deliveredFps,
|
|
41070
|
+
fitReason: fit.reason,
|
|
41071
|
+
encodeBudgetKbps: fit.budgetKbps,
|
|
39529
41072
|
audioCodec: "opus",
|
|
39530
|
-
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
|
|
39531
41101
|
} });
|
|
39532
41102
|
}
|
|
39533
41103
|
/**
|
|
@@ -39752,7 +41322,7 @@ async function buildIntercom(input) {
|
|
|
39752
41322
|
var RESET_DEBOUNCE_MS = 5e3;
|
|
39753
41323
|
async function buildMotionSensor(bctx) {
|
|
39754
41324
|
const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
|
|
39755
|
-
const motionService = accessory.addService(_homebridge_hap_nodejs.Service.MotionSensor, displayName);
|
|
41325
|
+
const motionService = accessory.addService(_homebridge_hap_nodejs.Service.MotionSensor, hapServiceName([displayName], `Camera ${numericDeviceId}`));
|
|
39756
41326
|
motionService.setCharacteristic(_homebridge_hap_nodejs.Characteristic.MotionDetected, false);
|
|
39757
41327
|
try {
|
|
39758
41328
|
const detected = await proxy.motion?.isDetected({});
|
|
@@ -39807,12 +41377,12 @@ function errMsg$6(err) {
|
|
|
39807
41377
|
* camera-enabled switch — distinct from privacy-mask).
|
|
39808
41378
|
*/
|
|
39809
41379
|
async function buildPrivacySwitch(bctx) {
|
|
39810
|
-
const { ctx, accessory, proxy, numericDeviceId } = bctx;
|
|
41380
|
+
const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
|
|
39811
41381
|
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
39812
41382
|
const subtype = "privacy-mask";
|
|
39813
|
-
const
|
|
39814
|
-
const service = accessory.addService(_homebridge_hap_nodejs.Service.Switch,
|
|
39815
|
-
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);
|
|
39816
41386
|
try {
|
|
39817
41387
|
const status = await proxy.privacyMask?.getStatus({});
|
|
39818
41388
|
if (status && typeof status.enabled === "boolean") service.updateCharacteristic(_homebridge_hap_nodejs.Characteristic.On, status.enabled);
|
|
@@ -39902,18 +41472,23 @@ function ptzPresetLabel(presetName) {
|
|
|
39902
41472
|
* `proxy.ptzAutotrack.setEnabled({enabled})`. Initial value is
|
|
39903
41473
|
* hydrated from `getStatus({})`.
|
|
39904
41474
|
*
|
|
39905
|
-
* Naming:
|
|
39906
|
-
*
|
|
39907
|
-
*
|
|
39908
|
-
*
|
|
39909
|
-
*
|
|
39910
|
-
*
|
|
39911
|
-
*
|
|
39912
|
-
* "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.
|
|
39913
41488
|
*/
|
|
39914
41489
|
var MOMENTARY_RESET_MS = 1e3;
|
|
39915
41490
|
async function buildPtz(bctx) {
|
|
39916
|
-
const { ctx, accessory, proxy, numericDeviceId, options } = bctx;
|
|
41491
|
+
const { ctx, accessory, proxy, numericDeviceId, displayName, options } = bctx;
|
|
39917
41492
|
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
39918
41493
|
const timers = /* @__PURE__ */ new Set();
|
|
39919
41494
|
const armReset = (cb, delay) => {
|
|
@@ -39925,10 +41500,10 @@ async function buildPtz(bctx) {
|
|
|
39925
41500
|
};
|
|
39926
41501
|
const presets = await readPresets(bctx);
|
|
39927
41502
|
for (const preset of presets) {
|
|
39928
|
-
const label = ptzPresetLabel(preset.name);
|
|
41503
|
+
const label = ptzServiceName(displayName, ptzPresetLabel(preset.name));
|
|
39929
41504
|
const subtype = `ptz-preset-${preset.id}`;
|
|
39930
41505
|
const service = accessory.addService(_homebridge_hap_nodejs.Service.Switch, label, subtype);
|
|
39931
|
-
service.setCharacteristic(_homebridge_hap_nodejs.Characteristic.
|
|
41506
|
+
service.setCharacteristic(_homebridge_hap_nodejs.Characteristic.Name, label);
|
|
39932
41507
|
service.getCharacteristic(_homebridge_hap_nodejs.Characteristic.On).onSet(async (value) => {
|
|
39933
41508
|
if (value !== true) return;
|
|
39934
41509
|
try {
|
|
@@ -39943,9 +41518,9 @@ async function buildPtz(bctx) {
|
|
|
39943
41518
|
});
|
|
39944
41519
|
}
|
|
39945
41520
|
if (proxy.ptz) for (const dir of PTZ_DIRECTIONS) {
|
|
39946
|
-
const label = dir.label;
|
|
41521
|
+
const label = ptzServiceName(displayName, dir.label);
|
|
39947
41522
|
const service = accessory.addService(_homebridge_hap_nodejs.Service.Switch, label, dir.subtype);
|
|
39948
|
-
service.setCharacteristic(_homebridge_hap_nodejs.Characteristic.
|
|
41523
|
+
service.setCharacteristic(_homebridge_hap_nodejs.Characteristic.Name, label);
|
|
39949
41524
|
service.getCharacteristic(_homebridge_hap_nodejs.Characteristic.On).onSet(async (value) => {
|
|
39950
41525
|
if (value !== true) return;
|
|
39951
41526
|
try {
|
|
@@ -39989,12 +41564,12 @@ async function readPresets(bctx) {
|
|
|
39989
41564
|
}
|
|
39990
41565
|
}
|
|
39991
41566
|
async function tryBuildAutotrack(bctx) {
|
|
39992
|
-
const { ctx, accessory, proxy, numericDeviceId } = bctx;
|
|
41567
|
+
const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
|
|
39993
41568
|
if (!proxy.ptzAutotrack) return { async dispose() {} };
|
|
39994
41569
|
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
39995
|
-
const label = PTZ_AUTOTRACK_LABEL;
|
|
41570
|
+
const label = ptzServiceName(displayName, PTZ_AUTOTRACK_LABEL);
|
|
39996
41571
|
const service = accessory.addService(_homebridge_hap_nodejs.Service.Switch, label, "ptz-autotrack");
|
|
39997
|
-
service.setCharacteristic(_homebridge_hap_nodejs.Characteristic.
|
|
41572
|
+
service.setCharacteristic(_homebridge_hap_nodejs.Characteristic.Name, label);
|
|
39998
41573
|
try {
|
|
39999
41574
|
const status = await proxy.ptzAutotrack.getStatus({});
|
|
40000
41575
|
if (status && typeof status.enabled === "boolean") service.updateCharacteristic(_homebridge_hap_nodejs.Characteristic.On, status.enabled);
|
|
@@ -40121,7 +41696,7 @@ async function buildChildSwitch(bctx, subtype, deviceType) {
|
|
|
40121
41696
|
const isLightingDevice = deviceType === DeviceType.Light || deviceType === DeviceType.Generic;
|
|
40122
41697
|
const useLightbulb = hasBrightness && isLightingDevice;
|
|
40123
41698
|
const service = useLightbulb ? accessory.addService(_homebridge_hap_nodejs.Service.Lightbulb, displayName, subtype) : accessory.addService(_homebridge_hap_nodejs.Service.Switch, displayName, subtype);
|
|
40124
|
-
service.setCharacteristic(_homebridge_hap_nodejs.Characteristic.
|
|
41699
|
+
service.setCharacteristic(_homebridge_hap_nodejs.Characteristic.Name, displayName);
|
|
40125
41700
|
try {
|
|
40126
41701
|
const switchStatus = await proxy.switch?.getStatus({});
|
|
40127
41702
|
if (switchStatus && typeof switchStatus.on === "boolean") service.updateCharacteristic(_homebridge_hap_nodejs.Characteristic.On, switchStatus.on);
|
|
@@ -40193,7 +41768,7 @@ async function buildChildServicesFor(input) {
|
|
|
40193
41768
|
accessory: parentCtx.accessory,
|
|
40194
41769
|
proxy: childProxy,
|
|
40195
41770
|
numericDeviceId: child.id,
|
|
40196
|
-
displayName:
|
|
41771
|
+
displayName: childServiceName(parentDisplayName, child),
|
|
40197
41772
|
options
|
|
40198
41773
|
};
|
|
40199
41774
|
const subtype = `child-${child.id}`;
|
|
@@ -40224,21 +41799,6 @@ async function listChildren(ctx, parentNumericId) {
|
|
|
40224
41799
|
}
|
|
40225
41800
|
}
|
|
40226
41801
|
/**
|
|
40227
|
-
* Service name displayed inside the camera tile detail in iOS Home.
|
|
40228
|
-
* Prefer the child's own role ("Siren", "Floodlight") when meaningful
|
|
40229
|
-
* — the camera name is already implied by the surrounding Accessory.
|
|
40230
|
-
* Falls back to the child's stored device name when role is empty.
|
|
40231
|
-
*/
|
|
40232
|
-
function formatChildServiceName(parentName, child) {
|
|
40233
|
-
const role = child.role && child.role.length > 0 ? toTitleCase(child.role) : null;
|
|
40234
|
-
if (role) return role;
|
|
40235
|
-
if (child.name.toLowerCase().includes(parentName.toLowerCase())) {
|
|
40236
|
-
const stripped = child.name.replace(new RegExp(`\\b${escapeRegex(parentName)}\\b`, "i"), "").replace(/\s+[—-]\s+/, " ").trim();
|
|
40237
|
-
if (stripped.length > 0) return stripped;
|
|
40238
|
-
}
|
|
40239
|
-
return child.name;
|
|
40240
|
-
}
|
|
40241
|
-
/**
|
|
40242
41802
|
* Coerce the raw `child.type` string (from `deviceManager.getChildren`)
|
|
40243
41803
|
* to a `DeviceType` enum value. Unknown / mis-cased values fall back to
|
|
40244
41804
|
* `Generic` so an unrecognised driver behaves like the safest existing
|
|
@@ -40250,12 +41810,6 @@ function asDeviceType(raw) {
|
|
|
40250
41810
|
for (const value of Object.values(DeviceType)) if (value === lower) return value;
|
|
40251
41811
|
return DeviceType.Generic;
|
|
40252
41812
|
}
|
|
40253
|
-
function escapeRegex(s) {
|
|
40254
|
-
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
40255
|
-
}
|
|
40256
|
-
function toTitleCase(raw) {
|
|
40257
|
-
return raw.split("-").filter((part) => part.length > 0).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
|
|
40258
|
-
}
|
|
40259
41813
|
function errMsg$2(err) {
|
|
40260
41814
|
return err instanceof Error ? err.message : String(err);
|
|
40261
41815
|
}
|