@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
|
@@ -774,7 +774,7 @@ var propertyKeyTypes = /* @__PURE__*/ new Set([
|
|
|
774
774
|
"number",
|
|
775
775
|
"symbol"
|
|
776
776
|
]);
|
|
777
|
-
function escapeRegex
|
|
777
|
+
function escapeRegex(str) {
|
|
778
778
|
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
779
779
|
}
|
|
780
780
|
function clone(inst, def, params) {
|
|
@@ -1504,7 +1504,7 @@ var $ZodCheckUpperCase = /*@__PURE__*/ $constructor("$ZodCheckUpperCase", (inst,
|
|
|
1504
1504
|
});
|
|
1505
1505
|
var $ZodCheckIncludes = /*@__PURE__*/ $constructor("$ZodCheckIncludes", (inst, def) => {
|
|
1506
1506
|
$ZodCheck.init(inst, def);
|
|
1507
|
-
const escapedRegex = escapeRegex
|
|
1507
|
+
const escapedRegex = escapeRegex(def.includes);
|
|
1508
1508
|
const pattern = new RegExp(typeof def.position === "number" ? `^.{${def.position}}${escapedRegex}` : escapedRegex);
|
|
1509
1509
|
def.pattern = pattern;
|
|
1510
1510
|
inst._zod.onattach.push((inst) => {
|
|
@@ -1527,7 +1527,7 @@ var $ZodCheckIncludes = /*@__PURE__*/ $constructor("$ZodCheckIncludes", (inst, d
|
|
|
1527
1527
|
});
|
|
1528
1528
|
var $ZodCheckStartsWith = /*@__PURE__*/ $constructor("$ZodCheckStartsWith", (inst, def) => {
|
|
1529
1529
|
$ZodCheck.init(inst, def);
|
|
1530
|
-
const pattern = new RegExp(`^${escapeRegex
|
|
1530
|
+
const pattern = new RegExp(`^${escapeRegex(def.prefix)}.*`);
|
|
1531
1531
|
def.pattern ?? (def.pattern = pattern);
|
|
1532
1532
|
inst._zod.onattach.push((inst) => {
|
|
1533
1533
|
const bag = inst._zod.bag;
|
|
@@ -1549,7 +1549,7 @@ var $ZodCheckStartsWith = /*@__PURE__*/ $constructor("$ZodCheckStartsWith", (ins
|
|
|
1549
1549
|
});
|
|
1550
1550
|
var $ZodCheckEndsWith = /*@__PURE__*/ $constructor("$ZodCheckEndsWith", (inst, def) => {
|
|
1551
1551
|
$ZodCheck.init(inst, def);
|
|
1552
|
-
const pattern = new RegExp(`.*${escapeRegex
|
|
1552
|
+
const pattern = new RegExp(`.*${escapeRegex(def.suffix)}$`);
|
|
1553
1553
|
def.pattern ?? (def.pattern = pattern);
|
|
1554
1554
|
inst._zod.onattach.push((inst) => {
|
|
1555
1555
|
const bag = inst._zod.bag;
|
|
@@ -2785,7 +2785,7 @@ var $ZodEnum = /*@__PURE__*/ $constructor("$ZodEnum", (inst, def) => {
|
|
|
2785
2785
|
const values = getEnumValues(def.entries);
|
|
2786
2786
|
const valuesSet = new Set(values);
|
|
2787
2787
|
inst._zod.values = valuesSet;
|
|
2788
|
-
inst._zod.pattern = new RegExp(`^(${values.filter((k) => propertyKeyTypes.has(typeof k)).map((o) => typeof o === "string" ? escapeRegex
|
|
2788
|
+
inst._zod.pattern = new RegExp(`^(${values.filter((k) => propertyKeyTypes.has(typeof k)).map((o) => typeof o === "string" ? escapeRegex(o) : o.toString()).join("|")})$`);
|
|
2789
2789
|
inst._zod.parse = (payload, _ctx) => {
|
|
2790
2790
|
const input = payload.value;
|
|
2791
2791
|
if (valuesSet.has(input)) return payload;
|
|
@@ -2803,7 +2803,7 @@ var $ZodLiteral = /*@__PURE__*/ $constructor("$ZodLiteral", (inst, def) => {
|
|
|
2803
2803
|
if (def.values.length === 0) throw new Error("Cannot create literal schema with no valid values");
|
|
2804
2804
|
const values = new Set(def.values);
|
|
2805
2805
|
inst._zod.values = values;
|
|
2806
|
-
inst._zod.pattern = new RegExp(`^(${def.values.map((o) => typeof o === "string" ? escapeRegex
|
|
2806
|
+
inst._zod.pattern = new RegExp(`^(${def.values.map((o) => typeof o === "string" ? escapeRegex(o) : o ? escapeRegex(o.toString()) : String(o)).join("|")})$`);
|
|
2807
2807
|
inst._zod.parse = (payload, _ctx) => {
|
|
2808
2808
|
const input = payload.value;
|
|
2809
2809
|
if (values.has(input)) return payload;
|
|
@@ -6496,6 +6496,36 @@ var ProfileRtspEntrySchema = object({
|
|
|
6496
6496
|
resolution: CamStreamResolutionSchema.optional()
|
|
6497
6497
|
});
|
|
6498
6498
|
/**
|
|
6499
|
+
* Per-call node pinning for `ctx.api` capability calls.
|
|
6500
|
+
*
|
|
6501
|
+
* A capability call normally resolves to its DEFAULT provider — a `singleton`
|
|
6502
|
+
* cap resolves to the hub, a device-scoped cap to the device's owning node. To
|
|
6503
|
+
* query a SPECIFIC node's provider instead (e.g. a remote agent's own
|
|
6504
|
+
* in-process `platform-probe` hardware, which the hub cannot probe), pin the
|
|
6505
|
+
* call to that node.
|
|
6506
|
+
*
|
|
6507
|
+
* The nodeId rides OUT-OF-BAND in the tRPC call context (NOT in the validated
|
|
6508
|
+
* method args), so capability method signatures stay `nodeId`-free — node
|
|
6509
|
+
* targeting is a property of the CALL, not of the method. The transport lifts
|
|
6510
|
+
* it from `op.context` onto the `CapCallInput.nodeId` field (`ipcParentLink`),
|
|
6511
|
+
* and the hub parent's `onUnownedCall` passes it to the `CapRouteResolver`,
|
|
6512
|
+
* which classifies a pinned agent node as `agent-child-forward`
|
|
6513
|
+
* (`$agent-cap-fwd.forward` → the agent's in-process provider).
|
|
6514
|
+
*
|
|
6515
|
+
* Usage at a call site:
|
|
6516
|
+
*
|
|
6517
|
+
* await api.platformProbe.getCapabilities.query(undefined, nodePin(nodeId))
|
|
6518
|
+
*/
|
|
6519
|
+
/** tRPC `op.context` key carrying a per-call node pin. */
|
|
6520
|
+
var CAP_NODE_PIN_CONTEXT_KEY = "__camstackNodePin";
|
|
6521
|
+
/**
|
|
6522
|
+
* Build the tRPC request options that pin a single capability call to `nodeId`.
|
|
6523
|
+
* Pass as the second argument to `.query(input, …)` / `.mutate(input, …)`.
|
|
6524
|
+
*/
|
|
6525
|
+
function nodePin(nodeId) {
|
|
6526
|
+
return { context: { [CAP_NODE_PIN_CONTEXT_KEY]: nodeId } };
|
|
6527
|
+
}
|
|
6528
|
+
/**
|
|
6499
6529
|
* Output schema shared by the contribution + live methods.
|
|
6500
6530
|
*
|
|
6501
6531
|
* Mirrors the `ConfigUISchemaWithValues` shape (sections[] + optional
|
|
@@ -6943,6 +6973,39 @@ method(object({ deviceId: number() }), array(StreamSourceEntrySchema)), method(o
|
|
|
6943
6973
|
action: string().min(1),
|
|
6944
6974
|
input: unknown()
|
|
6945
6975
|
}), unknown(), { kind: "mutation" }), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), unknown().nullable()), method(object({ deviceId: number() }), RawStateResultSchema.nullable(), { auth: "protected" });
|
|
6976
|
+
//#endregion
|
|
6977
|
+
//#region ../types/dist/canonical-hash-7nfBbEqR.mjs
|
|
6978
|
+
/**
|
|
6979
|
+
* Deterministic SHA-256 hash of an arbitrary serialisable value. The
|
|
6980
|
+
* canonical form sorts object keys alphabetically at every depth so two
|
|
6981
|
+
* structurally-equal inputs with different key insertion orders produce
|
|
6982
|
+
* the same hash. Returns a 64-char lowercase hex digest.
|
|
6983
|
+
*
|
|
6984
|
+
* Used by export adapters (Alexa, HAP) to short-circuit re-discovery /
|
|
6985
|
+
* accessory-rebuild work when the upstream shape is byte-identical to
|
|
6986
|
+
* the last applied state — preventing user-visible "re-discovery"
|
|
6987
|
+
* notifications on every addon-runner respawn. Each respawn re-fires
|
|
6988
|
+
* `DeviceBindingsChanged` for every cap registration, which without
|
|
6989
|
+
* this guard would propagate redundant pushes.
|
|
6990
|
+
*
|
|
6991
|
+
* Note: this is a SYMPTOMATIC fix layered on top of the binding-change
|
|
6992
|
+
* subscription. The proper fix is a single "device ready" lifecycle
|
|
6993
|
+
* barrier so exports react only when the full cap set has landed —
|
|
6994
|
+
* tracked separately for post-HA-integration work.
|
|
6995
|
+
*/
|
|
6996
|
+
function canonicalHash(value) {
|
|
6997
|
+
const canonical = JSON.stringify(value, replaceWithSortedKeys);
|
|
6998
|
+
return createHash("sha256").update(canonical ?? "").digest("hex");
|
|
6999
|
+
}
|
|
7000
|
+
function replaceWithSortedKeys(_key, value) {
|
|
7001
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
7002
|
+
const obj = value;
|
|
7003
|
+
const out = {};
|
|
7004
|
+
for (const k of Object.keys(obj).toSorted()) out[k] = obj[k];
|
|
7005
|
+
return out;
|
|
7006
|
+
}
|
|
7007
|
+
return value;
|
|
7008
|
+
}
|
|
6946
7009
|
var EncodeProfileSchema = object({
|
|
6947
7010
|
video: object({
|
|
6948
7011
|
codec: _enum([
|
|
@@ -6955,6 +7018,14 @@ var EncodeProfileSchema = object({
|
|
|
6955
7018
|
"main",
|
|
6956
7019
|
"high"
|
|
6957
7020
|
]).optional(),
|
|
7021
|
+
/**
|
|
7022
|
+
* `-level`, e.g. `'3.1'`. A consumer that ADVERTISES a level in its SDP
|
|
7023
|
+
* (`profile-level-id=42e01f` is Baseline 3.1) must constrain the encoder to
|
|
7024
|
+
* it, or it ships a stream that does not match its own advertisement — the
|
|
7025
|
+
* defect class that kept HomeKit black for a year and that Alexa carried
|
|
7026
|
+
* silently. Optional because a browser negotiates the level itself.
|
|
7027
|
+
*/
|
|
7028
|
+
level: string().optional(),
|
|
6958
7029
|
width: number().int().positive().optional(),
|
|
6959
7030
|
height: number().int().positive().optional(),
|
|
6960
7031
|
fps: number().positive().optional(),
|
|
@@ -7002,6 +7073,29 @@ var EncodeProfileSchema = object({
|
|
|
7002
7073
|
outputArgs: array(string()).optional()
|
|
7003
7074
|
});
|
|
7004
7075
|
/**
|
|
7076
|
+
* The shape every live egress starts from: H.264 Baseline 3.1 at 720p25.
|
|
7077
|
+
* Baseline because it is the one profile every consumer in this repo decodes
|
|
7078
|
+
* (Echo, iOS, an old browser); 3.1 because that is what the SDPs advertise.
|
|
7079
|
+
*/
|
|
7080
|
+
var BASE_LIVE_EGRESS_PROFILE = {
|
|
7081
|
+
video: {
|
|
7082
|
+
codec: "h264",
|
|
7083
|
+
profile: "baseline",
|
|
7084
|
+
level: "3.1",
|
|
7085
|
+
width: 1280,
|
|
7086
|
+
height: 720,
|
|
7087
|
+
fps: 25,
|
|
7088
|
+
bitrateKbps: 2500,
|
|
7089
|
+
gopFrames: 25,
|
|
7090
|
+
bf: 0,
|
|
7091
|
+
preset: "veryfast",
|
|
7092
|
+
tune: "zerolatency"
|
|
7093
|
+
},
|
|
7094
|
+
audio: "passthrough"
|
|
7095
|
+
};
|
|
7096
|
+
({ ...BASE_LIVE_EGRESS_PROFILE }), { ...BASE_LIVE_EGRESS_PROFILE.video };
|
|
7097
|
+
({ ...BASE_LIVE_EGRESS_PROFILE });
|
|
7098
|
+
/**
|
|
7005
7099
|
* Deep wiring healthcheck — snapshot of active reachability probes across
|
|
7006
7100
|
* every declared capability + widget of every installed plugin, on every
|
|
7007
7101
|
* node. Produced by the backend `WiringHealthService` and surfaced via
|
|
@@ -7051,6 +7145,105 @@ object({
|
|
|
7051
7145
|
})
|
|
7052
7146
|
});
|
|
7053
7147
|
/**
|
|
7148
|
+
* Per-camera FUNCTION SWITCHES — the one coherent on/off surface over the
|
|
7149
|
+
* pipeline functions an operator thinks in terms of.
|
|
7150
|
+
*
|
|
7151
|
+
* ## This file adds no state
|
|
7152
|
+
*
|
|
7153
|
+
* Every switch here is a VIEW onto an authority that already existed
|
|
7154
|
+
* ([D61](../../../../docs/decisions/adr-0062.md)). The whole point of the
|
|
7155
|
+
* group is that there is exactly one place each function is turned off, and
|
|
7156
|
+
* the group routes to it:
|
|
7157
|
+
*
|
|
7158
|
+
* | Switch | Authority | Proven "off stops the work" gate |
|
|
7159
|
+
* | --- | --- | --- |
|
|
7160
|
+
* | `stream-broker` | `deviceManager.setDisabled` | `StreamBrokerManager.reconcileAllCatalogs` releases the brokers; `ensureBroker` refuses re-creation |
|
|
7161
|
+
* | `object-detection` | `deviceManager.setWrapperActive('detection-pipeline')` | `PipelineSettingsStore.resolvePipelineForDevice` returns `{ steps: [], audio: null }` |
|
|
7162
|
+
* | `audio-analysis` | `deviceManager.setWrapperActive('audio-analysis')` | `AudioSubscriptionController.subscribeAudioStream` returns `null` before opening the stream |
|
|
7163
|
+
* | `recording` | `recording.setDeviceConfig` → `RecordingConfig.enabled` | `band-decision.shouldRecord` returns false; the controller detaches the device |
|
|
7164
|
+
* | `notifications` | `notificationRules.setDeviceMuted` | `NotificationCenter.evaluateAndEnqueue` returns before any rule is evaluated |
|
|
7165
|
+
*
|
|
7166
|
+
* The wrapper-binding pair is not a new idea: `legacy-migrations.ts` already
|
|
7167
|
+
* migrated the legacy `audioEnabled` / `pipelineEnabled` /
|
|
7168
|
+
* `motionDetectionEnabled` booleans ONTO `setWrapperActive`. The group is the
|
|
7169
|
+
* surface that decision never got.
|
|
7170
|
+
*
|
|
7171
|
+
* ## Two rules that are load-bearing
|
|
7172
|
+
*
|
|
7173
|
+
* - **Recording's switch is `enabled`, never the bands.** `bands` is the only
|
|
7174
|
+
* authored intent and `mode` is derived from it (`deriveRecordingMode`).
|
|
7175
|
+
* Expressing "off" by clearing bands destroys the operator's schedule and
|
|
7176
|
+
* turning the camera back on would then silently record nothing.
|
|
7177
|
+
* - **A switch that is off must be reported as off**, not merely produce
|
|
7178
|
+
* nothing. {@link CameraSwitch.enabled} is what a status surface renders as
|
|
7179
|
+
* "disabled by an operator" instead of "broken" — see
|
|
7180
|
+
* `CameraStatus.switchedOff`.
|
|
7181
|
+
*/
|
|
7182
|
+
/**
|
|
7183
|
+
* The five functions the operator named (2026-08-05). Deliberately NOT one id
|
|
7184
|
+
* per pipeline step: face recognition and plate/LPR are per-step toggles on
|
|
7185
|
+
* `pipelineOrchestrator.setCameraStepToggle` and belong in the pipeline
|
|
7186
|
+
* editor, not in a five-button safety group.
|
|
7187
|
+
*/
|
|
7188
|
+
var CameraSwitchIdSchema = _enum([
|
|
7189
|
+
"stream-broker",
|
|
7190
|
+
"object-detection",
|
|
7191
|
+
"audio-analysis",
|
|
7192
|
+
"recording",
|
|
7193
|
+
"notifications"
|
|
7194
|
+
]);
|
|
7195
|
+
/**
|
|
7196
|
+
* WHERE the switch's state actually lives. A discriminated union rather than a
|
|
7197
|
+
* string so both the writer (the orchestrator's `setCameraSwitch`) and any
|
|
7198
|
+
* reader can exhaustively narrow — and so "the group added a parallel map" is
|
|
7199
|
+
* a compile error rather than a review comment.
|
|
7200
|
+
*/
|
|
7201
|
+
var CameraSwitchAuthoritySchema = discriminatedUnion("kind", [
|
|
7202
|
+
object({ kind: literal("device-disabled") }),
|
|
7203
|
+
object({
|
|
7204
|
+
kind: literal("wrapper-binding"),
|
|
7205
|
+
capName: string()
|
|
7206
|
+
}),
|
|
7207
|
+
object({ kind: literal("recording-config") }),
|
|
7208
|
+
object({ kind: literal("notification-mute") })
|
|
7209
|
+
]);
|
|
7210
|
+
/**
|
|
7211
|
+
* Why a switch is not offered for this camera. Rendered instead of the
|
|
7212
|
+
* control, never as a dead control — an absent function and a broken one must
|
|
7213
|
+
* not look the same.
|
|
7214
|
+
*/
|
|
7215
|
+
var CameraSwitchUnavailableReasonSchema = _enum(["no-provider", "source-unreachable"]);
|
|
7216
|
+
/**
|
|
7217
|
+
* One switch, resolved for one camera.
|
|
7218
|
+
*
|
|
7219
|
+
* `label` and `costWhenOff` travel ON THE WIRE rather than being looked up
|
|
7220
|
+
* client-side: the viewer is a separate repository that does not import
|
|
7221
|
+
* `@camstack/types`, and a cost line duplicated in two clients is a cost line
|
|
7222
|
+
* that will disagree with itself. Five rows per camera is nothing.
|
|
7223
|
+
*/
|
|
7224
|
+
var CameraSwitchSchema = object({
|
|
7225
|
+
id: CameraSwitchIdSchema,
|
|
7226
|
+
label: string(),
|
|
7227
|
+
/**
|
|
7228
|
+
* What the operator LOSES while this is off, in one sentence. Required, not
|
|
7229
|
+
* optional: a switch that cannot say what it costs should not ship.
|
|
7230
|
+
*/
|
|
7231
|
+
costWhenOff: string(),
|
|
7232
|
+
/** False = do not render a control. `unavailableReason` says why. */
|
|
7233
|
+
available: boolean(),
|
|
7234
|
+
unavailableReason: CameraSwitchUnavailableReasonSchema.optional(),
|
|
7235
|
+
/** Current state. Meaningless when `available` is false — read it as `true`. */
|
|
7236
|
+
enabled: boolean(),
|
|
7237
|
+
authority: CameraSwitchAuthoritySchema
|
|
7238
|
+
});
|
|
7239
|
+
/** The whole group for one camera. */
|
|
7240
|
+
var CameraSwitchGroupSchema = object({
|
|
7241
|
+
deviceId: number().int(),
|
|
7242
|
+
switches: array(CameraSwitchSchema).readonly(),
|
|
7243
|
+
/** Unix ms when the group was composed server-side. */
|
|
7244
|
+
fetchedAt: number()
|
|
7245
|
+
});
|
|
7246
|
+
/**
|
|
7054
7247
|
* Ops-log — the durable, append-only operations audit shared by the
|
|
7055
7248
|
* recordings and events management surfaces.
|
|
7056
7249
|
*
|
|
@@ -7069,14 +7262,16 @@ var OpsLogOpSchema = _enum([
|
|
|
7069
7262
|
"manual-delete",
|
|
7070
7263
|
"rescan",
|
|
7071
7264
|
"retention-run",
|
|
7072
|
-
"relocate"
|
|
7265
|
+
"relocate",
|
|
7266
|
+
"orphan-audit"
|
|
7073
7267
|
]);
|
|
7074
7268
|
/** Why the operation ran. */
|
|
7075
7269
|
var OpsLogReasonSchema = _enum([
|
|
7076
7270
|
"retention",
|
|
7077
7271
|
"quota",
|
|
7078
7272
|
"manual",
|
|
7079
|
-
"operator"
|
|
7273
|
+
"operator",
|
|
7274
|
+
"maintenance"
|
|
7080
7275
|
]);
|
|
7081
7276
|
/** One audit row, shared verbatim by both domains. */
|
|
7082
7277
|
var OpsLogEntrySchema = object({
|
|
@@ -8991,6 +9186,100 @@ var RtpSourceSchema = object({
|
|
|
8991
9186
|
encoder: string(),
|
|
8992
9187
|
pipelineKey: string()
|
|
8993
9188
|
});
|
|
9189
|
+
/**
|
|
9190
|
+
* The encode request — **structured and serialisable, with NO raw-flag escape
|
|
9191
|
+
* hatch.** This is deliberate and it is the one lesson taken from
|
|
9192
|
+
* `getStreamWithCodec`: that method's `outputArgs: string[]` is simultaneously
|
|
9193
|
+
* its extensibility mechanism AND part of `pipelineKeyFor`'s sharing key, so
|
|
9194
|
+
* adding a flag silently forks the shared child, and two consumers that mean
|
|
9195
|
+
* the same thing but spell it differently never share. Here every knob is a
|
|
9196
|
+
* NAMED field: a new requirement becomes a schema field (and a codegen run),
|
|
9197
|
+
* never an opaque array.
|
|
9198
|
+
*
|
|
9199
|
+
* `inputArgs` / `outputArgs` are omitted from the profile for the same reason.
|
|
9200
|
+
* The operator-facing derived-stream transform editor still has them — that is
|
|
9201
|
+
* a different surface (`publishCameraStream({ kind: 'derived' })`) with a
|
|
9202
|
+
* different purpose (reshaping a badly-behaved SOURCE), and it is unchanged.
|
|
9203
|
+
*/
|
|
9204
|
+
var EgressEncodeSchema = EncodeProfileSchema.omit({
|
|
9205
|
+
inputArgs: true,
|
|
9206
|
+
outputArgs: true
|
|
9207
|
+
});
|
|
9208
|
+
/**
|
|
9209
|
+
* How the encoder is bounded. `'tight'` is a one-second VBV window for a
|
|
9210
|
+
* consumer whose budget is enforced per second (HomeKit); `'relaxed'` is two
|
|
9211
|
+
* seconds, letting a keyframe spike borrow from the next second (a browser,
|
|
9212
|
+
* an Echo). Named rather than numeric so the INTENT survives.
|
|
9213
|
+
*/
|
|
9214
|
+
var EgressRateControlSchema = _enum(["tight", "relaxed"]);
|
|
9215
|
+
var EgressTranscodeRequestSchema = object({
|
|
9216
|
+
deviceId: number().int().nonnegative(),
|
|
9217
|
+
/** Which published stream to read. */
|
|
9218
|
+
source: discriminatedUnion("kind", [object({
|
|
9219
|
+
kind: literal("profile"),
|
|
9220
|
+
profile: CamProfileSchema
|
|
9221
|
+
}), object({
|
|
9222
|
+
kind: literal("cam-stream"),
|
|
9223
|
+
camStreamId: string().min(1)
|
|
9224
|
+
})]),
|
|
9225
|
+
encode: EgressEncodeSchema,
|
|
9226
|
+
rateControl: EgressRateControlSchema.optional(),
|
|
9227
|
+
/**
|
|
9228
|
+
* `-bsf:v`. A consumer that negotiates its OWN SDP (HomeKit) cannot carry
|
|
9229
|
+
* out-of-band extradata and needs `dump_extra` on both the copy and encode
|
|
9230
|
+
* branches. Enumerated, not free text.
|
|
9231
|
+
*/
|
|
9232
|
+
bitstreamFilter: _enum([
|
|
9233
|
+
"dump_extra",
|
|
9234
|
+
"h264_mp4toannexb",
|
|
9235
|
+
"hevc_mp4toannexb"
|
|
9236
|
+
]).optional(),
|
|
9237
|
+
pixelFormat: _enum(["yuv420p", "nv12"]).optional(),
|
|
9238
|
+
/**
|
|
9239
|
+
* Operator/consumer override for decode hardware. ABSENT is the normal case
|
|
9240
|
+
* and the one that matters: the broker then resolves the backend from the
|
|
9241
|
+
* DECODER ADDON's per-node `probedBestHwaccel` (see
|
|
9242
|
+
* `@camstack/types` `ffmpeg/hwaccel.ts`), which is the ranking known to work
|
|
9243
|
+
* on this hardware — never the raw kernel resolver's qsv-first order.
|
|
9244
|
+
*/
|
|
9245
|
+
decodeHwAccel: _enum([
|
|
9246
|
+
"auto",
|
|
9247
|
+
"none",
|
|
9248
|
+
"videotoolbox",
|
|
9249
|
+
"vaapi",
|
|
9250
|
+
"qsv",
|
|
9251
|
+
"cuda"
|
|
9252
|
+
]).optional(),
|
|
9253
|
+
/**
|
|
9254
|
+
* Host to embed in the returned restream `url`. The broker mints hub-local
|
|
9255
|
+
* `127.0.0.1` URLs; a consumer on another node passes a cluster-resolvable
|
|
9256
|
+
* host (`NodeTopologyService.reachableHostByNode`) so the returned URL is
|
|
9257
|
+
* dialable from there. Same contract as `getStreamWithCodec.hostname` —
|
|
9258
|
+
* `substituteRtspHost` rewrites only the dial address, never the restreamer.
|
|
9259
|
+
*/
|
|
9260
|
+
hostname: string().optional(),
|
|
9261
|
+
/** Attribution for the broker panel. Never part of the sharing key. */
|
|
9262
|
+
tag: string().optional()
|
|
9263
|
+
});
|
|
9264
|
+
var EgressTranscodeSchema = object({
|
|
9265
|
+
/** Dial-able RTSP url (host-substituted when `hostname` was supplied). */
|
|
9266
|
+
url: string(),
|
|
9267
|
+
/** Release handle. Refcounted — the child dies when the last holder releases. */
|
|
9268
|
+
pipelineKey: string(),
|
|
9269
|
+
videoCodec: _enum(["H264", "H265"]),
|
|
9270
|
+
resolution: object({
|
|
9271
|
+
width: number().int().positive(),
|
|
9272
|
+
height: number().int().positive()
|
|
9273
|
+
}),
|
|
9274
|
+
transcoded: boolean(),
|
|
9275
|
+
encoder: string(),
|
|
9276
|
+
/**
|
|
9277
|
+
* The decode backend the child ACTUALLY ran with — `null` for software.
|
|
9278
|
+
* Returned rather than assumed: a consumer that asked for hardware and got
|
|
9279
|
+
* software needs to be able to see that without reading the broker's logs.
|
|
9280
|
+
*/
|
|
9281
|
+
decodeHwAccel: string().nullable()
|
|
9282
|
+
});
|
|
8994
9283
|
method(object({
|
|
8995
9284
|
deviceId: number().int().nonnegative(),
|
|
8996
9285
|
camStreamId: string().min(1),
|
|
@@ -9100,6 +9389,15 @@ method(object({
|
|
|
9100
9389
|
}), {
|
|
9101
9390
|
kind: "mutation",
|
|
9102
9391
|
auth: "admin"
|
|
9392
|
+
}), method(EgressTranscodeRequestSchema, EgressTranscodeSchema, {
|
|
9393
|
+
kind: "mutation",
|
|
9394
|
+
auth: "admin"
|
|
9395
|
+
}), method(object({ pipelineKey: string() }), object({
|
|
9396
|
+
released: boolean(),
|
|
9397
|
+
refcount: number().int().nonnegative()
|
|
9398
|
+
}), {
|
|
9399
|
+
kind: "mutation",
|
|
9400
|
+
auth: "admin"
|
|
9103
9401
|
}), method(SubscribeAudioChunksInputSchema, SubscribeAudioChunksResultSchema, { kind: "mutation" }), method(object({
|
|
9104
9402
|
subscriptionId: string(),
|
|
9105
9403
|
maxCount: number().int().positive().default(8)
|
|
@@ -9554,6 +9852,62 @@ method(_void(), EngineInfoSchema), method(object({
|
|
|
9554
9852
|
indexes: array(CollectionIndexSchema).readonly().optional()
|
|
9555
9853
|
}), _void(), { kind: "mutation" });
|
|
9556
9854
|
/**
|
|
9855
|
+
* Stable UI option list for the `hwaccel` setting. Decoder addons
|
|
9856
|
+
* reuse this for `globalSettingsSchema()` so the dropdown is
|
|
9857
|
+
* identical everywhere. Order: auto → off → common backends by
|
|
9858
|
+
* platform affinity (macOS, NVIDIA, Intel/AMD, Windows, Linux).
|
|
9859
|
+
*/
|
|
9860
|
+
var HWACCEL_OPTIONS = [
|
|
9861
|
+
{
|
|
9862
|
+
value: "auto",
|
|
9863
|
+
label: "Auto (defer to probed best)"
|
|
9864
|
+
},
|
|
9865
|
+
{
|
|
9866
|
+
value: "none",
|
|
9867
|
+
label: "Off (software)"
|
|
9868
|
+
},
|
|
9869
|
+
{
|
|
9870
|
+
value: "videotoolbox",
|
|
9871
|
+
label: "VideoToolbox (macOS)"
|
|
9872
|
+
},
|
|
9873
|
+
{
|
|
9874
|
+
value: "cuda",
|
|
9875
|
+
label: "CUDA (NVIDIA)"
|
|
9876
|
+
},
|
|
9877
|
+
{
|
|
9878
|
+
value: "nvdec",
|
|
9879
|
+
label: "NVDEC (NVIDIA legacy)"
|
|
9880
|
+
},
|
|
9881
|
+
{
|
|
9882
|
+
value: "vaapi",
|
|
9883
|
+
label: "VAAPI (Linux Intel/AMD)"
|
|
9884
|
+
},
|
|
9885
|
+
{
|
|
9886
|
+
value: "qsv",
|
|
9887
|
+
label: "QuickSync (Intel)"
|
|
9888
|
+
},
|
|
9889
|
+
{
|
|
9890
|
+
value: "d3d11va",
|
|
9891
|
+
label: "D3D11VA (Windows)"
|
|
9892
|
+
},
|
|
9893
|
+
{
|
|
9894
|
+
value: "dxva2",
|
|
9895
|
+
label: "DXVA2 (Windows legacy)"
|
|
9896
|
+
},
|
|
9897
|
+
{
|
|
9898
|
+
value: "amf",
|
|
9899
|
+
label: "AMF (AMD)"
|
|
9900
|
+
},
|
|
9901
|
+
{
|
|
9902
|
+
value: "vdpau",
|
|
9903
|
+
label: "VDPAU (Linux NVIDIA legacy)"
|
|
9904
|
+
},
|
|
9905
|
+
{
|
|
9906
|
+
value: "drm",
|
|
9907
|
+
label: "DRM (Linux generic)"
|
|
9908
|
+
}
|
|
9909
|
+
];
|
|
9910
|
+
/**
|
|
9557
9911
|
* shm ring usage stats for a `frameSink: 'shm'` decoder session —
|
|
9558
9912
|
* exposed via `decoder.getShmStats` so downstream consumers can
|
|
9559
9913
|
* observe ring pressure (slot count, byte budget, hit/miss ratio).
|
|
@@ -12903,7 +13257,7 @@ var DETECTION_SUB_COLORS = {
|
|
|
12903
13257
|
zebra: "#404040",
|
|
12904
13258
|
giraffe: "#d4a373"
|
|
12905
13259
|
};
|
|
12906
|
-
function titleCase(id) {
|
|
13260
|
+
function titleCase$1(id) {
|
|
12907
13261
|
return id.split(/[-_ ]/).filter((p) => p.length > 0).map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(" ");
|
|
12908
13262
|
}
|
|
12909
13263
|
var entries = /* @__PURE__ */ new Map();
|
|
@@ -12942,7 +13296,7 @@ macro("control", "control", TAXONOMY_COLORS.control, "control", "Control");
|
|
|
12942
13296
|
for (const [cocoClass, macroClass] of Object.entries(COCO_TO_MACRO.mapping)) {
|
|
12943
13297
|
if (macroClass !== "vehicle" && macroClass !== "animal") continue;
|
|
12944
13298
|
if (entries.has(cocoClass)) continue;
|
|
12945
|
-
sub(cocoClass, macroClass, "detection", DETECTION_SUB_COLORS[cocoClass] ?? TAXONOMY_COLORS.genericDetection, cocoClass, titleCase(cocoClass));
|
|
13299
|
+
sub(cocoClass, macroClass, "detection", DETECTION_SUB_COLORS[cocoClass] ?? TAXONOMY_COLORS.genericDetection, cocoClass, titleCase$1(cocoClass));
|
|
12946
13300
|
}
|
|
12947
13301
|
sub("package-delivered", "package", "package", TAXONOMY_COLORS.package, "package", "Package delivered");
|
|
12948
13302
|
sub("package-picked-up", "package", "package", TAXONOMY_COLORS.package, "package", "Package picked up");
|
|
@@ -13451,12 +13805,13 @@ var NcConditionsSchema = object({
|
|
|
13451
13805
|
* source; otherwise the subject's source must equal it. Legacy records
|
|
13452
13806
|
* with no stamped source are treated as `pipeline`. The union spans both
|
|
13453
13807
|
* record kinds — object events carry `pipeline` | `onboard`, synthetic
|
|
13454
|
-
* tracks carry `sensor
|
|
13808
|
+
* tracks carry `sensor` (a linked device) or `audio` (a D62 audio marker).
|
|
13455
13809
|
*/
|
|
13456
13810
|
source: _enum([
|
|
13457
13811
|
"pipeline",
|
|
13458
13812
|
"onboard",
|
|
13459
13813
|
"sensor",
|
|
13814
|
+
"audio",
|
|
13460
13815
|
"any"
|
|
13461
13816
|
]).optional(),
|
|
13462
13817
|
/**
|
|
@@ -14032,6 +14387,12 @@ method(object({}), object({ rules: array(NcRuleSchema) }), { auth: "admin" }), m
|
|
|
14032
14387
|
}), object({ success: literal(true) }), {
|
|
14033
14388
|
kind: "mutation",
|
|
14034
14389
|
auth: "admin"
|
|
14390
|
+
}), method(object({}), object({ mutedDeviceIds: array(number().int()).readonly() }), { auth: "admin" }), method(object({
|
|
14391
|
+
deviceId: number().int(),
|
|
14392
|
+
muted: boolean()
|
|
14393
|
+
}), object({ success: literal(true) }), {
|
|
14394
|
+
kind: "mutation",
|
|
14395
|
+
auth: "admin"
|
|
14035
14396
|
}), method(object({
|
|
14036
14397
|
rule: NcRuleInputSchema,
|
|
14037
14398
|
lookbackMinutes: number().int().min(1).max(1440).default(60)
|
|
@@ -14370,12 +14731,60 @@ var TrackAudioLabelSchema = object({
|
|
|
14370
14731
|
});
|
|
14371
14732
|
/**
|
|
14372
14733
|
* How a track was produced. `pipeline` (default / absent) = the spatial
|
|
14373
|
-
* detection+tracking pipeline.
|
|
14374
|
-
*
|
|
14375
|
-
*
|
|
14376
|
-
*
|
|
14734
|
+
* detection+tracking pipeline. Every OTHER value is a SYNTHETIC projection —
|
|
14735
|
+
* no positions, a single snapshot, and no bbox trajectory at all:
|
|
14736
|
+
*
|
|
14737
|
+
* - `sensor` — a linked sensor/control device state change.
|
|
14738
|
+
* - `audio` — an audio event on the camera itself that was anomalous for
|
|
14739
|
+
* THAT camera, loud, and heard while nothing visual was happening (D62).
|
|
14740
|
+
*
|
|
14741
|
+
* The spatial subsystems (tracker association, occupancy count, re-id /
|
|
14742
|
+
* embedding, resurrection) MUST skip every synthetic source. Test for that
|
|
14743
|
+
* with `isSpatialTrack`, which allow-lists `pipeline` — a `!== 'sensor'`
|
|
14744
|
+
* check silently readmits every source added after it was written.
|
|
14745
|
+
*/
|
|
14746
|
+
var TrackSourceSchema = _enum([
|
|
14747
|
+
"pipeline",
|
|
14748
|
+
"sensor",
|
|
14749
|
+
"audio"
|
|
14750
|
+
]);
|
|
14751
|
+
/**
|
|
14752
|
+
* Per-track OPERATOR flags — set by hand from the admin UI or the viewer, never
|
|
14753
|
+
* by the pipeline. Spread into `TrackSchema` and `KeyEventSchema` from one place
|
|
14754
|
+
* so the two surfaces cannot drift.
|
|
14755
|
+
*
|
|
14756
|
+
* **Absent ≠ false.** A track that has never been touched omits the field; an
|
|
14757
|
+
* explicitly un-flagged track carries `false`. Legacy rows written before the
|
|
14758
|
+
* columns existed read as absent, and a consumer that needs a boolean should say
|
|
14759
|
+
* `flag === true`, not `flag !== false`.
|
|
14760
|
+
*
|
|
14761
|
+
* What the flags DO is deliberately UNDEFINED at the time of writing: they are
|
|
14762
|
+
* operator curation, and the behaviour they drive will be specified separately.
|
|
14763
|
+
* In particular a `markForTrain` track is NOT pinned against retention — see
|
|
14764
|
+
* `docs/decisions/adr-0059.md` for why that is a store-level change, not a flag.
|
|
14765
|
+
*/
|
|
14766
|
+
var TrackFlagFields = {
|
|
14767
|
+
/** Operator marked this track as training material. */
|
|
14768
|
+
markForTrain: boolean().optional(),
|
|
14769
|
+
/** Operator marked this track for diagnostic attention. */
|
|
14770
|
+
debug: boolean().optional()
|
|
14771
|
+
};
|
|
14772
|
+
/**
|
|
14773
|
+
* The write half: a PARTIAL patch. An omitted key is left untouched, so setting
|
|
14774
|
+
* one flag can never clear the other — the toggles are independent and are
|
|
14775
|
+
* driven from three surfaces that do not know about each other.
|
|
14377
14776
|
*/
|
|
14378
|
-
var
|
|
14777
|
+
var TrackFlagsPatchSchema = object(TrackFlagFields);
|
|
14778
|
+
/**
|
|
14779
|
+
* The resolved flag state after a write. Both fields are REQUIRED here (absent
|
|
14780
|
+
* collapses to `false`) so a caller can drive a toggle's checked state off the
|
|
14781
|
+
* mutation result without a re-fetch.
|
|
14782
|
+
*/
|
|
14783
|
+
var TrackFlagsSchema = object({
|
|
14784
|
+
trackId: string(),
|
|
14785
|
+
markForTrain: boolean(),
|
|
14786
|
+
debug: boolean()
|
|
14787
|
+
});
|
|
14379
14788
|
var TrackSchema = object({
|
|
14380
14789
|
trackId: string(),
|
|
14381
14790
|
deviceId: number(),
|
|
@@ -14418,7 +14827,8 @@ var TrackSchema = object({
|
|
|
14418
14827
|
/** Normalized 0..1 trajectory envelope (see {@link TrackEnvelopeSchema}).
|
|
14419
14828
|
* Populated from the persisted envelope columns on historical reads;
|
|
14420
14829
|
* absent on legacy rows, dims-less tracks and active (in-RAM) tracks. */
|
|
14421
|
-
envelope: TrackEnvelopeSchema.optional()
|
|
14830
|
+
envelope: TrackEnvelopeSchema.optional(),
|
|
14831
|
+
...TrackFlagFields
|
|
14422
14832
|
});
|
|
14423
14833
|
var BaseEventFields = {
|
|
14424
14834
|
id: string(),
|
|
@@ -14631,7 +15041,8 @@ var KeyEventSchema = object({
|
|
|
14631
15041
|
/** Highest-confidence ObjectEvent id for the track (empty when none). */
|
|
14632
15042
|
bestEventId: string(),
|
|
14633
15043
|
/** Track lifetime in ms (lastSeen - firstSeen). */
|
|
14634
|
-
windowMs: number().optional()
|
|
15044
|
+
windowMs: number().optional(),
|
|
15045
|
+
...TrackFlagFields
|
|
14635
15046
|
});
|
|
14636
15047
|
object({
|
|
14637
15048
|
trackId: string(),
|
|
@@ -14717,7 +15128,31 @@ var RebuildObjectEmbeddingsInput = object({
|
|
|
14717
15128
|
since: number().optional(),
|
|
14718
15129
|
until: number().optional(),
|
|
14719
15130
|
/** Stop after this many tracks; the result reports whether more remain. */
|
|
14720
|
-
maxTracks: number().int().positive().optional()
|
|
15131
|
+
maxTracks: number().int().positive().optional(),
|
|
15132
|
+
/**
|
|
15133
|
+
* Run every embedding on THIS node instead of round-robining the fleet.
|
|
15134
|
+
*
|
|
15135
|
+
* Named `executeOnNodeId` and not `nodeId` on purpose: an inline `nodeId`
|
|
15136
|
+
* field in cap args is read by `parent-unowned-call.ts` as a ROUTING PIN, so
|
|
15137
|
+
* calling it that would pin the rebuild REQUEST itself to that node — the
|
|
15138
|
+
* rebuild orchestration lives on the hub, and only the per-track step runs
|
|
15139
|
+
* remotely. This field is data; the per-track pin is applied inside.
|
|
15140
|
+
*
|
|
15141
|
+
* Absent ⇒ round-robin over every online node whose runner can serve the
|
|
15142
|
+
* pinned model.
|
|
15143
|
+
*/
|
|
15144
|
+
executeOnNodeId: string().optional(),
|
|
15145
|
+
/**
|
|
15146
|
+
* Milliseconds to wait between tracks; omit for the built-in default, `0` to
|
|
15147
|
+
* run flat out.
|
|
15148
|
+
*
|
|
15149
|
+
* A rebuild is bulk maintenance on hub-main's single thread. Measured
|
|
15150
|
+
* 2026-08-06, an unpaced pass held that thread busy 82.2 s out of 120 and
|
|
15151
|
+
* pushed `nodes.topology` from 0.25 s to 26 s for 43 minutes. The value in
|
|
15152
|
+
* force is logged at start and finish so a deliberately slow pass reads
|
|
15153
|
+
* differently from a stalled one.
|
|
15154
|
+
*/
|
|
15155
|
+
pacingMs: number().int().nonnegative().optional()
|
|
14721
15156
|
});
|
|
14722
15157
|
/**
|
|
14723
15158
|
* Result of emptying the CLIP index.
|
|
@@ -14751,13 +15186,23 @@ var RebuildStatusSchema = object({
|
|
|
14751
15186
|
/** Tracks with no usable detection box. */
|
|
14752
15187
|
missingBbox: number(),
|
|
14753
15188
|
/**
|
|
14754
|
-
* Tracks
|
|
14755
|
-
*
|
|
14756
|
-
*
|
|
14757
|
-
*
|
|
14758
|
-
* otherwise read as a total engine outage.
|
|
15189
|
+
* Tracks an executing node REFUSED rather than broke on — an unreadable key
|
|
15190
|
+
* frame, a step that threw. Separate from `failed` because the remedy is
|
|
15191
|
+
* different, and because a whole camera silently contributing zero vectors
|
|
15192
|
+
* is the shape of failure a rebuild must never hide.
|
|
14759
15193
|
*/
|
|
14760
15194
|
notRunnable: number(),
|
|
15195
|
+
/**
|
|
15196
|
+
* The pass stopped because NO node could serve the pinned model.
|
|
15197
|
+
*
|
|
15198
|
+
* Distinct from `notRunnable` on purpose: that one says "this track was
|
|
15199
|
+
* refused", this one says "the cluster cannot do this work at all" — every
|
|
15200
|
+
* candidate node either lacks the `clip-embedding` step, lacks a build of the
|
|
15201
|
+
* pinned model for its engine format, or dropped out. The remedy is a model /
|
|
15202
|
+
* engine change, not a per-camera one. Non-zero here always comes with
|
|
15203
|
+
* `complete: false`.
|
|
15204
|
+
*/
|
|
15205
|
+
noCapableNode: number(),
|
|
14761
15206
|
failed: number(),
|
|
14762
15207
|
/** Set once a pass ends: true only when EVERYTHING was covered. */
|
|
14763
15208
|
complete: boolean().nullable(),
|
|
@@ -14829,7 +15274,12 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
|
|
|
14829
15274
|
}), {
|
|
14830
15275
|
kind: "mutation",
|
|
14831
15276
|
auth: "admin"
|
|
14832
|
-
}), method(object({
|
|
15277
|
+
}), method(object({
|
|
15278
|
+
/** Log/audit scope only — the trackId is globally unique on its own. */
|
|
15279
|
+
deviceId: number(),
|
|
15280
|
+
trackId: string(),
|
|
15281
|
+
flags: TrackFlagsPatchSchema
|
|
15282
|
+
}), TrackFlagsSchema, { kind: "mutation" }), method(object({}), EventStoreFootprintSchema, {
|
|
14833
15283
|
kind: "query",
|
|
14834
15284
|
auth: "admin"
|
|
14835
15285
|
}), method(object({
|
|
@@ -15439,6 +15889,53 @@ var DetailResultSchema = object({
|
|
|
15439
15889
|
nativeFaceShortSidePx: number().optional()
|
|
15440
15890
|
});
|
|
15441
15891
|
/**
|
|
15892
|
+
* Why an executing node REFUSED a stateless step run (`runStatelessStep`).
|
|
15893
|
+
*
|
|
15894
|
+
* A refusal is a first-class answer, not an error, because the caller's next
|
|
15895
|
+
* move depends on WHICH one it is — and because "the pass produced nothing"
|
|
15896
|
+
* must never be reachable without a named, counted cause. The two tiers:
|
|
15897
|
+
*
|
|
15898
|
+
* - **node-level** (`unknown-step`, `model-not-servable`) — this node can
|
|
15899
|
+
* never serve this (step, model) pair. The caller drops it from its rotation
|
|
15900
|
+
* and retries the same work elsewhere; nothing about the work changes.
|
|
15901
|
+
* - **work-level** (`unreadable-frame`, `execution-failed`) — this node is
|
|
15902
|
+
* fine, this one request is not. Retrying it on another node would only
|
|
15903
|
+
* spread the same failure.
|
|
15904
|
+
*/
|
|
15905
|
+
var StatelessStepRefusalSchema = _enum([
|
|
15906
|
+
"unknown-step",
|
|
15907
|
+
"model-not-servable",
|
|
15908
|
+
"unreadable-frame",
|
|
15909
|
+
"execution-failed"
|
|
15910
|
+
]);
|
|
15911
|
+
/**
|
|
15912
|
+
* Answer to `runStatelessStep` — a discriminated union rather than a nullable
|
|
15913
|
+
* result, because `null` is exactly what made the camera-bound detail path
|
|
15914
|
+
* unable to tell "refused" from "never asked".
|
|
15915
|
+
*/
|
|
15916
|
+
var RunStatelessStepResultSchema = discriminatedUnion("kind", [object({
|
|
15917
|
+
kind: literal("ran"),
|
|
15918
|
+
/** The node that actually executed it — the pin, echoed back for the log. */
|
|
15919
|
+
nodeId: string(),
|
|
15920
|
+
/**
|
|
15921
|
+
* The model the step ran with.
|
|
15922
|
+
*
|
|
15923
|
+
* The node verified this exact id has a build for the format it dispatched
|
|
15924
|
+
* on BEFORE running, so the executor's format resolution returns it
|
|
15925
|
+
* unchanged. A caller that pinned a model must compare this field and
|
|
15926
|
+
* treat a mismatch as a refusal — the whole point of the pin is that a
|
|
15927
|
+
* pass writes one feature space.
|
|
15928
|
+
*/
|
|
15929
|
+
modelId: string(),
|
|
15930
|
+
details: array(DetailResultSchema)
|
|
15931
|
+
}), object({
|
|
15932
|
+
kind: literal("refused"),
|
|
15933
|
+
nodeId: string(),
|
|
15934
|
+
reason: StatelessStepRefusalSchema,
|
|
15935
|
+
/** Human-readable specifics — the format tried, the formats shipped, etc. */
|
|
15936
|
+
detail: string()
|
|
15937
|
+
})]);
|
|
15938
|
+
/**
|
|
15442
15939
|
* Per-camera tunable ranges + defaults. Single source of truth used
|
|
15443
15940
|
* by both the Zod data schema (validation + default fallback) and
|
|
15444
15941
|
* the device settings UI (slider min/max/step). Touch one place and
|
|
@@ -15788,7 +16285,32 @@ method(RunnerCameraConfigSchema, object({ success: literal(true) }), { kind: "mu
|
|
|
15788
16285
|
cropJpeg: string().optional(),
|
|
15789
16286
|
parent: DetailParentSchema,
|
|
15790
16287
|
steps: array(string()).optional()
|
|
15791
|
-
}), object({ details: array(DetailResultSchema) }).nullable(), { kind: "mutation" })
|
|
16288
|
+
}), object({ details: array(DetailResultSchema) }).nullable(), { kind: "mutation" }), method(object({
|
|
16289
|
+
/** Catalog step id, e.g. `clip-embedding`. */
|
|
16290
|
+
stepId: string(),
|
|
16291
|
+
/**
|
|
16292
|
+
* REQUIRED model pin. The node runs this exact model or refuses with
|
|
16293
|
+
* `model-not-servable` — it never substitutes a format default, because
|
|
16294
|
+
* a fleet pass that round-robins across nodes would then fill one index
|
|
16295
|
+
* from several encoders.
|
|
16296
|
+
*/
|
|
16297
|
+
modelId: string(),
|
|
16298
|
+
/** FULL FRAME, base64 JPEG. The runner cuts — do NOT pre-crop. */
|
|
16299
|
+
frameJpeg: string(),
|
|
16300
|
+
/**
|
|
16301
|
+
* The subject box, NORMALISED [0,1] against `frameJpeg`. Normalised on
|
|
16302
|
+
* purpose: the caller stores boxes against a downscaled analysis frame
|
|
16303
|
+
* while the stored key frame is native-resolution, and the only side
|
|
16304
|
+
* that reliably knows the image's pixel dimensions is the side that
|
|
16305
|
+
* decodes it. Denormalising here removes a second reader of the
|
|
16306
|
+
* dimensions and the class of mismatch that comes with it.
|
|
16307
|
+
*/
|
|
16308
|
+
bbox: NativeCropBboxSchema,
|
|
16309
|
+
/** Parent class of the subject (`person`, `vehicle`, …) — carried into the result. */
|
|
16310
|
+
className: string(),
|
|
16311
|
+
/** Camera the pixels came from. Diagnostics + log tags ONLY — never routing. */
|
|
16312
|
+
sourceDeviceId: number()
|
|
16313
|
+
}), RunStatelessStepResultSchema, { kind: "mutation" });
|
|
15792
16314
|
var CameraPipelineConfigSchema = object({
|
|
15793
16315
|
engine: PipelineEngineChoiceSchema.optional(),
|
|
15794
16316
|
steps: array(PipelineStepInputSchema).readonly(),
|
|
@@ -16086,6 +16608,20 @@ var CameraStatusSchema = object({
|
|
|
16086
16608
|
detection: CameraDetectionStatusSchema.nullable(),
|
|
16087
16609
|
audio: CameraAudioStatusSchema.nullable(),
|
|
16088
16610
|
recording: CameraRecordingStatusSchema.nullable(),
|
|
16611
|
+
/**
|
|
16612
|
+
* Per-camera function switches an OPERATOR has turned off
|
|
16613
|
+
* ([D61](../../../../docs/decisions/adr-0067.md)).
|
|
16614
|
+
*
|
|
16615
|
+
* This is the difference between DISABLED and BROKEN. A camera whose
|
|
16616
|
+
* `detection` block reports zero fps and whose `switchedOff` contains
|
|
16617
|
+
* `'object-detection'` was switched off by a person; the same camera with an
|
|
16618
|
+
* empty list is failing. Every status surface must render the two
|
|
16619
|
+
* differently — a quiet camera that looks identical to a dead one is the
|
|
16620
|
+
* silence-reads-as-never-happened trap this repo keeps paying for.
|
|
16621
|
+
*
|
|
16622
|
+
* Empty when nothing is off. Never contains a switch no provider offers.
|
|
16623
|
+
*/
|
|
16624
|
+
switchedOff: array(CameraSwitchIdSchema).readonly(),
|
|
16089
16625
|
/** Unix timestamp (ms) when this snapshot was composed server-side. */
|
|
16090
16626
|
fetchedAt: number()
|
|
16091
16627
|
});
|
|
@@ -16254,7 +16790,14 @@ method(object({
|
|
|
16254
16790
|
}), method(object({
|
|
16255
16791
|
deviceId: number(),
|
|
16256
16792
|
agentNodeId: string().optional()
|
|
16257
|
-
}), CameraPipelineConfigSchema), method(object({ deviceId: number() }),
|
|
16793
|
+
}), CameraPipelineConfigSchema), method(object({ deviceId: number() }), CameraSwitchGroupSchema), method(object({
|
|
16794
|
+
deviceId: number(),
|
|
16795
|
+
switchId: CameraSwitchIdSchema,
|
|
16796
|
+
enabled: boolean()
|
|
16797
|
+
}), CameraSwitchGroupSchema, {
|
|
16798
|
+
kind: "mutation",
|
|
16799
|
+
auth: "admin"
|
|
16800
|
+
}), method(object({ deviceId: number() }), CameraStatusSchema), method(object({ deviceIds: array(number()).optional() }), array(CameraStatusSchema).readonly()), method(_void(), array(PipelineTemplateSchema).readonly()), method(object({
|
|
16258
16801
|
name: string(),
|
|
16259
16802
|
description: string().optional(),
|
|
16260
16803
|
config: CameraPipelineConfigSchema
|
|
@@ -25211,6 +25754,12 @@ Object.freeze({
|
|
|
25211
25754
|
addonId: null,
|
|
25212
25755
|
access: "view"
|
|
25213
25756
|
},
|
|
25757
|
+
"notificationRules.listDeviceMutes": {
|
|
25758
|
+
capName: "notification-rules",
|
|
25759
|
+
capScope: "system",
|
|
25760
|
+
addonId: null,
|
|
25761
|
+
access: "view"
|
|
25762
|
+
},
|
|
25214
25763
|
"notificationRules.listRules": {
|
|
25215
25764
|
capName: "notification-rules",
|
|
25216
25765
|
capScope: "system",
|
|
@@ -25229,6 +25778,12 @@ Object.freeze({
|
|
|
25229
25778
|
addonId: null,
|
|
25230
25779
|
access: "create"
|
|
25231
25780
|
},
|
|
25781
|
+
"notificationRules.setDeviceMuted": {
|
|
25782
|
+
capName: "notification-rules",
|
|
25783
|
+
capScope: "system",
|
|
25784
|
+
addonId: null,
|
|
25785
|
+
access: "create"
|
|
25786
|
+
},
|
|
25232
25787
|
"notificationRules.setRuleEnabled": {
|
|
25233
25788
|
capName: "notification-rules",
|
|
25234
25789
|
capScope: "system",
|
|
@@ -25505,6 +26060,12 @@ Object.freeze({
|
|
|
25505
26060
|
addonId: null,
|
|
25506
26061
|
access: "view"
|
|
25507
26062
|
},
|
|
26063
|
+
"pipelineAnalytics.setTrackFlags": {
|
|
26064
|
+
capName: "pipeline-analytics",
|
|
26065
|
+
capScope: "device",
|
|
26066
|
+
addonId: null,
|
|
26067
|
+
access: "create"
|
|
26068
|
+
},
|
|
25508
26069
|
"pipelineAnalytics.wipeAllAnalytics": {
|
|
25509
26070
|
capName: "pipeline-analytics",
|
|
25510
26071
|
capScope: "device",
|
|
@@ -25811,6 +26372,12 @@ Object.freeze({
|
|
|
25811
26372
|
addonId: null,
|
|
25812
26373
|
access: "view"
|
|
25813
26374
|
},
|
|
26375
|
+
"pipelineOrchestrator.getCameraSwitches": {
|
|
26376
|
+
capName: "pipeline-orchestrator",
|
|
26377
|
+
capScope: "system",
|
|
26378
|
+
addonId: null,
|
|
26379
|
+
access: "view"
|
|
26380
|
+
},
|
|
25814
26381
|
"pipelineOrchestrator.getCapabilityBindings": {
|
|
25815
26382
|
capName: "pipeline-orchestrator",
|
|
25816
26383
|
capScope: "system",
|
|
@@ -25943,6 +26510,12 @@ Object.freeze({
|
|
|
25943
26510
|
addonId: null,
|
|
25944
26511
|
access: "create"
|
|
25945
26512
|
},
|
|
26513
|
+
"pipelineOrchestrator.setCameraSwitch": {
|
|
26514
|
+
capName: "pipeline-orchestrator",
|
|
26515
|
+
capScope: "system",
|
|
26516
|
+
addonId: null,
|
|
26517
|
+
access: "create"
|
|
26518
|
+
},
|
|
25946
26519
|
"pipelineOrchestrator.setCapabilityBinding": {
|
|
25947
26520
|
capName: "pipeline-orchestrator",
|
|
25948
26521
|
capScope: "system",
|
|
@@ -26033,6 +26606,12 @@ Object.freeze({
|
|
|
26033
26606
|
addonId: null,
|
|
26034
26607
|
access: "create"
|
|
26035
26608
|
},
|
|
26609
|
+
"pipelineRunner.runStatelessStep": {
|
|
26610
|
+
capName: "pipeline-runner",
|
|
26611
|
+
capScope: "system",
|
|
26612
|
+
addonId: null,
|
|
26613
|
+
access: "create"
|
|
26614
|
+
},
|
|
26036
26615
|
"plateGallery.assignPlate": {
|
|
26037
26616
|
capName: "plate-gallery",
|
|
26038
26617
|
capScope: "system",
|
|
@@ -26849,6 +27428,12 @@ Object.freeze({
|
|
|
26849
27428
|
addonId: null,
|
|
26850
27429
|
access: "create"
|
|
26851
27430
|
},
|
|
27431
|
+
"streamBroker.acquireEgressTranscode": {
|
|
27432
|
+
capName: "stream-broker",
|
|
27433
|
+
capScope: "system",
|
|
27434
|
+
addonId: null,
|
|
27435
|
+
access: "create"
|
|
27436
|
+
},
|
|
26852
27437
|
"streamBroker.assignProfile": {
|
|
26853
27438
|
capName: "stream-broker",
|
|
26854
27439
|
capScope: "system",
|
|
@@ -26957,6 +27542,12 @@ Object.freeze({
|
|
|
26957
27542
|
addonId: null,
|
|
26958
27543
|
access: "create"
|
|
26959
27544
|
},
|
|
27545
|
+
"streamBroker.releaseEgressTranscode": {
|
|
27546
|
+
capName: "stream-broker",
|
|
27547
|
+
capScope: "system",
|
|
27548
|
+
addonId: null,
|
|
27549
|
+
access: "create"
|
|
27550
|
+
},
|
|
26960
27551
|
"streamBroker.releaseStreamWithCodec": {
|
|
26961
27552
|
capName: "stream-broker",
|
|
26962
27553
|
capScope: "system",
|
|
@@ -27742,37 +28333,6 @@ object({
|
|
|
27742
28333
|
square: false
|
|
27743
28334
|
}).paddingRatio;
|
|
27744
28335
|
/**
|
|
27745
|
-
* Deterministic SHA-256 hash of an arbitrary serialisable value. The
|
|
27746
|
-
* canonical form sorts object keys alphabetically at every depth so two
|
|
27747
|
-
* structurally-equal inputs with different key insertion orders produce
|
|
27748
|
-
* the same hash. Returns a 64-char lowercase hex digest.
|
|
27749
|
-
*
|
|
27750
|
-
* Used by export adapters (Alexa, HAP) to short-circuit re-discovery /
|
|
27751
|
-
* accessory-rebuild work when the upstream shape is byte-identical to
|
|
27752
|
-
* the last applied state — preventing user-visible "re-discovery"
|
|
27753
|
-
* notifications on every addon-runner respawn. Each respawn re-fires
|
|
27754
|
-
* `DeviceBindingsChanged` for every cap registration, which without
|
|
27755
|
-
* this guard would propagate redundant pushes.
|
|
27756
|
-
*
|
|
27757
|
-
* Note: this is a SYMPTOMATIC fix layered on top of the binding-change
|
|
27758
|
-
* subscription. The proper fix is a single "device ready" lifecycle
|
|
27759
|
-
* barrier so exports react only when the full cap set has landed —
|
|
27760
|
-
* tracked separately for post-HA-integration work.
|
|
27761
|
-
*/
|
|
27762
|
-
function canonicalHash(value) {
|
|
27763
|
-
const canonical = JSON.stringify(value, replaceWithSortedKeys);
|
|
27764
|
-
return createHash("sha256").update(canonical ?? "").digest("hex");
|
|
27765
|
-
}
|
|
27766
|
-
function replaceWithSortedKeys(_key, value) {
|
|
27767
|
-
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
27768
|
-
const obj = value;
|
|
27769
|
-
const out = {};
|
|
27770
|
-
for (const k of Object.keys(obj).toSorted()) out[k] = obj[k];
|
|
27771
|
-
return out;
|
|
27772
|
-
}
|
|
27773
|
-
return value;
|
|
27774
|
-
}
|
|
27775
|
-
/**
|
|
27776
28336
|
* Compute the stable 64-char lowercase-hex fingerprint of a device's
|
|
27777
28337
|
* export-relevant shape. Two structurally-equal shapes (any feature order,
|
|
27778
28338
|
* any duplicates, any deviceId) hash identically.
|
|
@@ -27943,6 +28503,110 @@ function firstExposedAccessorySetupUri(exposed, logger) {
|
|
|
27943
28503
|
}
|
|
27944
28504
|
}
|
|
27945
28505
|
}
|
|
28506
|
+
/**
|
|
28507
|
+
* hap-nodejs' `checkName` regex, verbatim.
|
|
28508
|
+
*
|
|
28509
|
+
* Duplicated rather than imported because it is `@private Private API` in that
|
|
28510
|
+
* package and not exported. Duplicating a private regex is a liability, so the
|
|
28511
|
+
* guard against drift is behavioural, not textual: `service-naming.spec.ts`
|
|
28512
|
+
* builds real services and asserts hap-nodejs emits ZERO characteristic
|
|
28513
|
+
* warnings — if this expression ever diverges from theirs, that test fails.
|
|
28514
|
+
*/
|
|
28515
|
+
var HAP_NAME_PATTERN = /^[\p{L}\p{N}][\p{L}\p{N}\p{Zs}’'&!._:;()/,-]*[\p{L}\p{N}]$/u;
|
|
28516
|
+
/** Characters HAP tolerates INSIDE a name. Anything else becomes a space. */
|
|
28517
|
+
var HAP_NAME_INNER = /[^\p{L}\p{N}\p{Zs}’'&!._:;()/,-]/gu;
|
|
28518
|
+
/** Would hap-nodejs accept this as a `Name` characteristic value? */
|
|
28519
|
+
function isHapServiceName(value) {
|
|
28520
|
+
return HAP_NAME_PATTERN.test(value);
|
|
28521
|
+
}
|
|
28522
|
+
/**
|
|
28523
|
+
* Build one HAP-valid service name from device-derived parts.
|
|
28524
|
+
*
|
|
28525
|
+
* Empty and absent parts are dropped rather than joined, so a missing role
|
|
28526
|
+
* never produces a double space. `fallback` is used ONLY when nothing
|
|
28527
|
+
* device-derived survives sanitisation — it is the last resort, not the
|
|
28528
|
+
* default, because a name that says nothing about the device is the defect
|
|
28529
|
+
* this module exists to end.
|
|
28530
|
+
*/
|
|
28531
|
+
function hapServiceName(parts, fallback) {
|
|
28532
|
+
const trimmed = trimToHapName(parts.map((part) => typeof part === "string" ? part : "").map((part) => part.replace(HAP_NAME_INNER, " ")).join(" ").replace(/\s+/gu, " ").trim());
|
|
28533
|
+
if (trimmed !== null) return trimmed;
|
|
28534
|
+
return trimToHapName(fallback.replace(HAP_NAME_INNER, " ").replace(/\s+/gu, " ").trim()) ?? "";
|
|
28535
|
+
}
|
|
28536
|
+
/**
|
|
28537
|
+
* The privacy-mask switch.
|
|
28538
|
+
*
|
|
28539
|
+
* The camera's own name carries the meaning; "Privacy" only says which of the
|
|
28540
|
+
* camera's switches this is. It used to be the WHOLE name, which is why the
|
|
28541
|
+
* operator saw a switch that named neither its camera nor, with two cameras
|
|
28542
|
+
* exposed, which camera it belonged to.
|
|
28543
|
+
*/
|
|
28544
|
+
function privacyServiceName(deviceName) {
|
|
28545
|
+
return hapServiceName([deviceName, PRIVACY_SUFFIX], PRIVACY_SUFFIX);
|
|
28546
|
+
}
|
|
28547
|
+
/**
|
|
28548
|
+
* Deliberately not localised, and deliberately not a translation table.
|
|
28549
|
+
*
|
|
28550
|
+
* The device half of the name is the operator's own text and arrives in the
|
|
28551
|
+
* operator's language. This half names a camstack capability (`privacy-mask`)
|
|
28552
|
+
* and there is no locale in an addon's context to resolve it against; the word
|
|
28553
|
+
* is also identical in the operator's language. A translation layer for one
|
|
28554
|
+
* word would be the kind of leftover that reads as verification.
|
|
28555
|
+
*/
|
|
28556
|
+
var PRIVACY_SUFFIX = "Privacy";
|
|
28557
|
+
/**
|
|
28558
|
+
* An accessory child (siren, floodlight, spotlight) rendered as a service on
|
|
28559
|
+
* the parent camera.
|
|
28560
|
+
*
|
|
28561
|
+
* The child's OWN stored name wins. It is the string the operator typed, in
|
|
28562
|
+
* the operator's language, and the previous rule threw it away: `role` was
|
|
28563
|
+
* consulted first and title-cased, so every siren on the fleet published as
|
|
28564
|
+
* the English word "Siren" no matter what the operator had called it.
|
|
28565
|
+
*
|
|
28566
|
+
* The parent name is prefixed only when the child's name does not already
|
|
28567
|
+
* carry it — providers name children both ways ("Sirena" and "Videocamera
|
|
28568
|
+
* cucina Sirena") and the result must be one form, not two.
|
|
28569
|
+
*/
|
|
28570
|
+
function childServiceName(parentName, child) {
|
|
28571
|
+
const own = child.name.trim();
|
|
28572
|
+
if (own.length > 0) return mentions(own, parentName) ? hapServiceName([own], parentName) : hapServiceName([parentName, own], own);
|
|
28573
|
+
return hapServiceName([parentName, typeof child.role === "string" ? titleCase(child.role) : ""], parentName);
|
|
28574
|
+
}
|
|
28575
|
+
/**
|
|
28576
|
+
* A PTZ action switch.
|
|
28577
|
+
*
|
|
28578
|
+
* The action labels themselves stay in `ptz-labels.ts` — they name a HomeKit
|
|
28579
|
+
* control, not a device — but the service is still qualified by the camera, so
|
|
28580
|
+
* a home with two PTZ cameras does not publish two switches called "Preset
|
|
28581
|
+
* ingresso".
|
|
28582
|
+
*/
|
|
28583
|
+
function ptzServiceName(deviceName, actionLabel) {
|
|
28584
|
+
return hapServiceName([deviceName, actionLabel], actionLabel);
|
|
28585
|
+
}
|
|
28586
|
+
/** Does `name` already contain `parentName` as a whole word? */
|
|
28587
|
+
function mentions(name, parentName) {
|
|
28588
|
+
const needle = parentName.trim().toLowerCase();
|
|
28589
|
+
if (needle.length === 0) return true;
|
|
28590
|
+
return name.toLowerCase().includes(needle);
|
|
28591
|
+
}
|
|
28592
|
+
/**
|
|
28593
|
+
* Truncate to the HAP ceiling and shave any leading/trailing character the
|
|
28594
|
+
* pattern forbids. Returns `null` when nothing usable is left — the caller
|
|
28595
|
+
* decides what to do with that, because "fall back" and "drop the part" are
|
|
28596
|
+
* different answers.
|
|
28597
|
+
*/
|
|
28598
|
+
function trimToHapName(value) {
|
|
28599
|
+
let out = value.length > 64 ? value.slice(0, 64) : value;
|
|
28600
|
+
while (out.length > 0 && !isAlphanumeric(out[out.length - 1])) out = out.slice(0, -1);
|
|
28601
|
+
while (out.length > 0 && !isAlphanumeric(out[0])) out = out.slice(1);
|
|
28602
|
+
return isHapServiceName(out) ? out : null;
|
|
28603
|
+
}
|
|
28604
|
+
function isAlphanumeric(ch) {
|
|
28605
|
+
return ch !== void 0 && /[\p{L}\p{N}]/u.test(ch);
|
|
28606
|
+
}
|
|
28607
|
+
function titleCase(raw) {
|
|
28608
|
+
return raw.split(/[-_\s]+/u).filter((part) => part.length > 0).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
|
|
28609
|
+
}
|
|
27946
28610
|
//#endregion
|
|
27947
28611
|
//#region src/mappers/builders/battery.ts
|
|
27948
28612
|
/**
|
|
@@ -27967,7 +28631,7 @@ var LOW_BATTERY_THRESHOLD_PCT = 20;
|
|
|
27967
28631
|
async function buildBattery(bctx) {
|
|
27968
28632
|
const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
|
|
27969
28633
|
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
27970
|
-
const service = accessory.addService(Service.Battery, displayName);
|
|
28634
|
+
const service = accessory.addService(Service.Battery, hapServiceName([displayName], `Camera ${numericDeviceId}`));
|
|
27971
28635
|
try {
|
|
27972
28636
|
const status = await proxy.battery?.getStatus({});
|
|
27973
28637
|
if (status) applyToService(service, status);
|
|
@@ -38329,6 +38993,573 @@ function makeRtcpGate(socket, timeoutMs) {
|
|
|
38329
38993
|
socket.on("message", onMessage);
|
|
38330
38994
|
});
|
|
38331
38995
|
}
|
|
38996
|
+
/** V/P/RC byte + PT byte + 16-bit length. */
|
|
38997
|
+
var RTCP_HEADER_BYTES = 4;
|
|
38998
|
+
/** SSRC of the packet sender, first field of both SR and RR bodies. */
|
|
38999
|
+
var RTCP_SENDER_SSRC_BYTES = 4;
|
|
39000
|
+
/** NTP + RTP timestamp, packet count, octet count — present only on an SR. */
|
|
39001
|
+
var RTCP_SENDER_INFO_BYTES = 20;
|
|
39002
|
+
/** One report block: SSRC_n, loss, highest seq, jitter, LSR, DLSR. */
|
|
39003
|
+
var RTCP_REPORT_BLOCK_BYTES = 24;
|
|
39004
|
+
/** `fraction lost` is an 8-bit fixed-point fraction of 256. */
|
|
39005
|
+
var FRACTION_LOST_DENOMINATOR = 256;
|
|
39006
|
+
/** `delay since last SR` counts 1/65536 of a second. */
|
|
39007
|
+
var DLSR_UNITS_PER_SECOND = 65536;
|
|
39008
|
+
/** `cumulative number of packets lost` is a SIGNED 24-bit field. */
|
|
39009
|
+
var SIGNED_24_SIGN_BIT = 8388608;
|
|
39010
|
+
var SIGNED_24_MODULUS = 16777216;
|
|
39011
|
+
function readSigned24(packet, at) {
|
|
39012
|
+
const raw = packet[at] << 16 | packet[at + 1] << 8 | packet[at + 2];
|
|
39013
|
+
return raw >= SIGNED_24_SIGN_BIT ? raw - SIGNED_24_MODULUS : raw;
|
|
39014
|
+
}
|
|
39015
|
+
function readReportBlock(packet, at) {
|
|
39016
|
+
const fractionLostRaw = packet[at + 4];
|
|
39017
|
+
const dlsr = packet.readUInt32BE(at + 20);
|
|
39018
|
+
return {
|
|
39019
|
+
aboutSsrc: packet.readUInt32BE(at),
|
|
39020
|
+
fractionLostRaw,
|
|
39021
|
+
fractionLostPct: Math.round(fractionLostRaw / FRACTION_LOST_DENOMINATOR * 1e3) / 10,
|
|
39022
|
+
cumulativePacketsLost: readSigned24(packet, at + 5),
|
|
39023
|
+
extendedHighestSequence: packet.readUInt32BE(at + 8),
|
|
39024
|
+
jitter: packet.readUInt32BE(at + 12),
|
|
39025
|
+
lastSrTimestamp: packet.readUInt32BE(at + 16),
|
|
39026
|
+
delaySinceLastSrMs: Math.round(dlsr / DLSR_UNITS_PER_SECOND * 1e3)
|
|
39027
|
+
};
|
|
39028
|
+
}
|
|
39029
|
+
/**
|
|
39030
|
+
* Walk a (possibly compound) RTCP datagram and extract every report block.
|
|
39031
|
+
*
|
|
39032
|
+
* An RR is routinely bundled behind an SDES, and a controller with nothing to
|
|
39033
|
+
* report yet sends an RR with a reception-report count of zero. Both are
|
|
39034
|
+
* normal; neither is a failure. What IS a failure is a packet whose declared
|
|
39035
|
+
* length runs past the buffer or whose report count exceeds its own body —
|
|
39036
|
+
* those get named so a decrypt fault cannot masquerade as silence.
|
|
39037
|
+
*/
|
|
39038
|
+
function parseCompoundRtcp(packet) {
|
|
39039
|
+
if (packet.length === 0) return {
|
|
39040
|
+
ok: false,
|
|
39041
|
+
failure: "empty",
|
|
39042
|
+
atOffset: 0
|
|
39043
|
+
};
|
|
39044
|
+
const packetTypes = [];
|
|
39045
|
+
const reports = [];
|
|
39046
|
+
let offset = 0;
|
|
39047
|
+
while (offset < packet.length) {
|
|
39048
|
+
if (packet.length - offset < RTCP_HEADER_BYTES) return {
|
|
39049
|
+
ok: false,
|
|
39050
|
+
failure: "short-header",
|
|
39051
|
+
atOffset: offset
|
|
39052
|
+
};
|
|
39053
|
+
const firstByte = packet[offset];
|
|
39054
|
+
if ((firstByte >> 6 & 3) !== 2) return {
|
|
39055
|
+
ok: false,
|
|
39056
|
+
failure: "bad-version",
|
|
39057
|
+
atOffset: offset
|
|
39058
|
+
};
|
|
39059
|
+
const reportCount = firstByte & 31;
|
|
39060
|
+
const packetType = packet[offset + 1];
|
|
39061
|
+
const totalBytes = (packet.readUInt16BE(offset + 2) + 1) * 4;
|
|
39062
|
+
if (offset + totalBytes > packet.length) return {
|
|
39063
|
+
ok: false,
|
|
39064
|
+
failure: "length-overrun",
|
|
39065
|
+
atOffset: offset
|
|
39066
|
+
};
|
|
39067
|
+
packetTypes.push(packetType);
|
|
39068
|
+
if (packetType === 201 || packetType === 200) {
|
|
39069
|
+
const senderSsrcAt = offset + RTCP_HEADER_BYTES;
|
|
39070
|
+
const blocksAt = senderSsrcAt + RTCP_SENDER_SSRC_BYTES + (packetType === 200 ? RTCP_SENDER_INFO_BYTES : 0);
|
|
39071
|
+
if (blocksAt + reportCount * RTCP_REPORT_BLOCK_BYTES > offset + totalBytes) return {
|
|
39072
|
+
ok: false,
|
|
39073
|
+
failure: "truncated-body",
|
|
39074
|
+
atOffset: offset
|
|
39075
|
+
};
|
|
39076
|
+
const blocks = [];
|
|
39077
|
+
for (let index = 0; index < reportCount; index += 1) blocks.push(readReportBlock(packet, blocksAt + index * RTCP_REPORT_BLOCK_BYTES));
|
|
39078
|
+
reports.push({
|
|
39079
|
+
packetType,
|
|
39080
|
+
reporterSsrc: packet.readUInt32BE(senderSsrcAt),
|
|
39081
|
+
blocks
|
|
39082
|
+
});
|
|
39083
|
+
}
|
|
39084
|
+
offset += totalBytes;
|
|
39085
|
+
}
|
|
39086
|
+
return {
|
|
39087
|
+
ok: true,
|
|
39088
|
+
packetTypes,
|
|
39089
|
+
reports
|
|
39090
|
+
};
|
|
39091
|
+
}
|
|
39092
|
+
function emptyReceiverReportTally() {
|
|
39093
|
+
return {
|
|
39094
|
+
reportsParsed: 0,
|
|
39095
|
+
blocksParsed: 0,
|
|
39096
|
+
unreadable: 0,
|
|
39097
|
+
lastFractionLostPct: null,
|
|
39098
|
+
worstFractionLostPct: null,
|
|
39099
|
+
lastCumulativePacketsLost: null,
|
|
39100
|
+
maxCumulativePacketsLost: null,
|
|
39101
|
+
lastJitter: null,
|
|
39102
|
+
maxJitter: null,
|
|
39103
|
+
lastExtendedHighestSequence: null
|
|
39104
|
+
};
|
|
39105
|
+
}
|
|
39106
|
+
function maxOrValue(previous, next) {
|
|
39107
|
+
return previous === null ? next : Math.max(previous, next);
|
|
39108
|
+
}
|
|
39109
|
+
/** Fold one report block into the tally. Returns a new tally. */
|
|
39110
|
+
function applyReceiverReportBlock(tally, block) {
|
|
39111
|
+
return {
|
|
39112
|
+
...tally,
|
|
39113
|
+
blocksParsed: tally.blocksParsed + 1,
|
|
39114
|
+
lastFractionLostPct: block.fractionLostPct,
|
|
39115
|
+
worstFractionLostPct: maxOrValue(tally.worstFractionLostPct, block.fractionLostPct),
|
|
39116
|
+
lastCumulativePacketsLost: block.cumulativePacketsLost,
|
|
39117
|
+
maxCumulativePacketsLost: maxOrValue(tally.maxCumulativePacketsLost, block.cumulativePacketsLost),
|
|
39118
|
+
lastJitter: block.jitter,
|
|
39119
|
+
maxJitter: maxOrValue(tally.maxJitter, block.jitter),
|
|
39120
|
+
lastExtendedHighestSequence: block.extendedHighestSequence
|
|
39121
|
+
};
|
|
39122
|
+
}
|
|
39123
|
+
/** Book an RTCP datagram we could not read. Counted, never discarded silently. */
|
|
39124
|
+
function recordUnreadableRtcp(tally) {
|
|
39125
|
+
return {
|
|
39126
|
+
...tally,
|
|
39127
|
+
unreadable: tally.unreadable + 1
|
|
39128
|
+
};
|
|
39129
|
+
}
|
|
39130
|
+
/**
|
|
39131
|
+
* Parse one decrypted RTCP datagram and fold it into a leg's tally.
|
|
39132
|
+
*
|
|
39133
|
+
* This is the seam the delegate calls: it owns SRTCP decryption and logging,
|
|
39134
|
+
* this owns everything that can be asserted from bytes alone. A malformed
|
|
39135
|
+
* packet returns a failure and an incremented `unreadable` — it never throws,
|
|
39136
|
+
* because this runs on a UDP `message` handler where a throw would take the
|
|
39137
|
+
* session with it.
|
|
39138
|
+
*/
|
|
39139
|
+
function ingestDecryptedRtcp(plaintext, tally) {
|
|
39140
|
+
const parsed = parseCompoundRtcp(plaintext);
|
|
39141
|
+
if (!parsed.ok) return {
|
|
39142
|
+
tally: recordUnreadableRtcp(tally),
|
|
39143
|
+
reports: [],
|
|
39144
|
+
failure: parsed.failure
|
|
39145
|
+
};
|
|
39146
|
+
let next = tally;
|
|
39147
|
+
for (const report of parsed.reports) {
|
|
39148
|
+
if (report.blocks.length === 0) continue;
|
|
39149
|
+
next = {
|
|
39150
|
+
...next,
|
|
39151
|
+
reportsParsed: next.reportsParsed + 1
|
|
39152
|
+
};
|
|
39153
|
+
for (const block of report.blocks) next = applyReceiverReportBlock(next, block);
|
|
39154
|
+
}
|
|
39155
|
+
return {
|
|
39156
|
+
tally: next,
|
|
39157
|
+
reports: parsed.reports,
|
|
39158
|
+
failure: null
|
|
39159
|
+
};
|
|
39160
|
+
}
|
|
39161
|
+
//#endregion
|
|
39162
|
+
//#region src/mappers/builders/stream-bitrate.ts
|
|
39163
|
+
/**
|
|
39164
|
+
* Fraction of the negotiated ceiling we actually aim the encoder at.
|
|
39165
|
+
*
|
|
39166
|
+
* `max_bit_rate` is what the controller budgeted for the stream; what crosses
|
|
39167
|
+
* the wire is the encoded payload PLUS its packetisation. At `mtu = 1378` each
|
|
39168
|
+
* packet carries a 12-byte RTP header and a 10-byte SRTP auth tag, and the
|
|
39169
|
+
* datagram adds 8 (UDP) + 20 (IPv4) — 50 bytes on ~1378, i.e. **3.6 %**. The
|
|
39170
|
+
* remaining ~6 % is margin for a VBV overshoot inside the buffer window.
|
|
39171
|
+
* Reserving it is the difference between "at the ceiling" and "over it".
|
|
39172
|
+
*/
|
|
39173
|
+
var BITRATE_HEADROOM = .9;
|
|
39174
|
+
/** Encoder slots a `stream-params` provider exposes, in the cap's own order. */
|
|
39175
|
+
var ENCODER_PROFILE_KEYS = [
|
|
39176
|
+
"main",
|
|
39177
|
+
"sub",
|
|
39178
|
+
"ext"
|
|
39179
|
+
];
|
|
39180
|
+
/**
|
|
39181
|
+
* Resolve every profile slot's rate from the camera's configuration, with the
|
|
39182
|
+
* broker's flow reading kept alongside it as a lower bound.
|
|
39183
|
+
*
|
|
39184
|
+
* A slot with neither simply carries two nulls — absence is never rendered as
|
|
39185
|
+
* a number, because a wrong number here silently re-creates the overshoot this
|
|
39186
|
+
* module exists to end.
|
|
39187
|
+
*/
|
|
39188
|
+
function resolveProfileBitrates(input) {
|
|
39189
|
+
const camStreamById = /* @__PURE__ */ new Map();
|
|
39190
|
+
for (const stream of input.camStreams) camStreamById.set(stream.camStreamId, stream);
|
|
39191
|
+
const measuredByProfile = /* @__PURE__ */ new Map();
|
|
39192
|
+
for (const choice of input.choices) {
|
|
39193
|
+
if (choice.target.kind !== "profile") continue;
|
|
39194
|
+
const plausible = plausibleMeasured(choice.bitrateKbps);
|
|
39195
|
+
if (plausible !== null) measuredByProfile.set(choice.target.profile, plausible);
|
|
39196
|
+
}
|
|
39197
|
+
const out = /* @__PURE__ */ new Map();
|
|
39198
|
+
for (const slot of input.slots) {
|
|
39199
|
+
const source = slot.sourceCamStreamId === null ? void 0 : camStreamById.get(slot.sourceCamStreamId);
|
|
39200
|
+
out.set(slot.profile, {
|
|
39201
|
+
profile: slot.profile,
|
|
39202
|
+
publishedKbps: publishedRateFor(slot, source, input.streamParams),
|
|
39203
|
+
measuredKbps: measuredByProfile.get(slot.profile) ?? null
|
|
39204
|
+
});
|
|
39205
|
+
}
|
|
39206
|
+
for (const [profile, measured] of measuredByProfile) {
|
|
39207
|
+
if (out.has(profile)) continue;
|
|
39208
|
+
out.set(profile, {
|
|
39209
|
+
profile,
|
|
39210
|
+
publishedKbps: null,
|
|
39211
|
+
measuredKbps: measured
|
|
39212
|
+
});
|
|
39213
|
+
}
|
|
39214
|
+
return out;
|
|
39215
|
+
}
|
|
39216
|
+
/**
|
|
39217
|
+
* The rate the encoder may actually use, or `null` when the controller
|
|
39218
|
+
* negotiated no usable ceiling.
|
|
39219
|
+
*/
|
|
39220
|
+
function budgetForNegotiatedRate(negotiatedMaxBitrateKbps) {
|
|
39221
|
+
if (!Number.isFinite(negotiatedMaxBitrateKbps) || negotiatedMaxBitrateKbps <= 0) return null;
|
|
39222
|
+
const budget = Math.floor(negotiatedMaxBitrateKbps * BITRATE_HEADROOM);
|
|
39223
|
+
return budget > 0 ? budget : null;
|
|
39224
|
+
}
|
|
39225
|
+
/**
|
|
39226
|
+
* Does this slot fit?
|
|
39227
|
+
*
|
|
39228
|
+
* Only the CONFIGURED rate can answer yes. The measured rate is a lower bound,
|
|
39229
|
+
* so it is allowed to answer no — including against an optimistic publication.
|
|
39230
|
+
*/
|
|
39231
|
+
function classifyBitrateFit(evidence, budgetKbps) {
|
|
39232
|
+
if (evidence === void 0) return "unknown";
|
|
39233
|
+
const { publishedKbps, measuredKbps } = evidence;
|
|
39234
|
+
if (measuredKbps !== null && measuredKbps > budgetKbps) return "over-budget";
|
|
39235
|
+
if (publishedKbps === null) return "unknown";
|
|
39236
|
+
return publishedKbps <= budgetKbps ? "fits" : "over-budget";
|
|
39237
|
+
}
|
|
39238
|
+
/**
|
|
39239
|
+
* Pick the stream to serve, and decide whether it can be passed through.
|
|
39240
|
+
*
|
|
39241
|
+
* The selection runs the SAME `pickPreferredRtspEntry` the advertisement is
|
|
39242
|
+
* derived from (D51) — only the candidate set narrows. When at least one
|
|
39243
|
+
* pass-through-capable slot fits the budget, the picker resolves the target
|
|
39244
|
+
* resolution among those and we copy. Otherwise the picker resolves among ALL
|
|
39245
|
+
* entries — so the transcode decodes the slot closest to the negotiated
|
|
39246
|
+
* resolution rather than the largest one on the camera — and we re-encode.
|
|
39247
|
+
*
|
|
39248
|
+
* A pinned `streamPreference` is never overridden by the budget: the pinned
|
|
39249
|
+
* slot is transcoded rather than swapped for a cheaper one.
|
|
39250
|
+
*
|
|
39251
|
+
* Returns `null` when nothing is publishable at all.
|
|
39252
|
+
*/
|
|
39253
|
+
function selectStreamForBudget(input) {
|
|
39254
|
+
const budgetKbps = budgetForNegotiatedRate(input.negotiatedMaxBitrateKbps);
|
|
39255
|
+
const notes = fitNotes(input.entries, input.bitrates, budgetKbps);
|
|
39256
|
+
const fallback = pickPreferredRtspEntry(input.entries, input.pref, input.deviceId, { targetResolution: input.targetResolution });
|
|
39257
|
+
if (fallback === null) return null;
|
|
39258
|
+
const fallbackProfile = toCamProfile$1(fallback.profileId);
|
|
39259
|
+
if (budgetKbps === null) {
|
|
39260
|
+
const base = {
|
|
39261
|
+
picked: fallback,
|
|
39262
|
+
profile: fallbackProfile,
|
|
39263
|
+
budgetKbps: null,
|
|
39264
|
+
notes
|
|
39265
|
+
};
|
|
39266
|
+
return canPassThrough(fallback.codec) ? {
|
|
39267
|
+
kind: "copy",
|
|
39268
|
+
reason: "no-negotiated-budget",
|
|
39269
|
+
...base
|
|
39270
|
+
} : {
|
|
39271
|
+
kind: "transcode",
|
|
39272
|
+
reason: "source-codec",
|
|
39273
|
+
...base
|
|
39274
|
+
};
|
|
39275
|
+
}
|
|
39276
|
+
const affordable = (input.pref !== "auto" && fallbackProfile === input.pref ? input.entries.filter((entry) => entry.profile === fallbackProfile) : input.entries).filter((entry) => {
|
|
39277
|
+
if (!canPassThrough(entry.codec)) return false;
|
|
39278
|
+
return classifyBitrateFit(input.bitrates.get(entry.profile), budgetKbps) === "fits";
|
|
39279
|
+
});
|
|
39280
|
+
if (affordable.length > 0) {
|
|
39281
|
+
const picked = pickPreferredRtspEntry(affordable, input.pref, input.deviceId, { targetResolution: input.targetResolution });
|
|
39282
|
+
if (picked !== null) return {
|
|
39283
|
+
kind: "copy",
|
|
39284
|
+
reason: "source-fits-budget",
|
|
39285
|
+
picked,
|
|
39286
|
+
profile: toCamProfile$1(picked.profileId),
|
|
39287
|
+
budgetKbps,
|
|
39288
|
+
notes
|
|
39289
|
+
};
|
|
39290
|
+
}
|
|
39291
|
+
if (canPassThrough(fallback.codec)) return {
|
|
39292
|
+
kind: "copy",
|
|
39293
|
+
reason: "over-budget-tolerated",
|
|
39294
|
+
picked: fallback,
|
|
39295
|
+
profile: fallbackProfile,
|
|
39296
|
+
budgetKbps,
|
|
39297
|
+
notes
|
|
39298
|
+
};
|
|
39299
|
+
return {
|
|
39300
|
+
kind: "transcode",
|
|
39301
|
+
reason: transcodeReason(fallback, fallbackProfile, input.bitrates, budgetKbps),
|
|
39302
|
+
picked: fallback,
|
|
39303
|
+
profile: fallbackProfile,
|
|
39304
|
+
budgetKbps,
|
|
39305
|
+
notes
|
|
39306
|
+
};
|
|
39307
|
+
}
|
|
39308
|
+
/**
|
|
39309
|
+
* Fill in a codec the profile restream entry did not carry, from its broker
|
|
39310
|
+
* slot. `getProfileRtspEntries` has historically omitted it for legacy
|
|
39311
|
+
* entries, and a slot whose codec is unknown must not be mistaken for H.264.
|
|
39312
|
+
*/
|
|
39313
|
+
function withSlotCodecs(entries, slots) {
|
|
39314
|
+
const codecByProfile = /* @__PURE__ */ new Map();
|
|
39315
|
+
for (const slot of slots) if (slot.codec !== void 0) codecByProfile.set(slot.profile, slot.codec);
|
|
39316
|
+
return entries.map((entry) => {
|
|
39317
|
+
if (entry.codec !== void 0) return entry;
|
|
39318
|
+
const codec = codecByProfile.get(entry.profile);
|
|
39319
|
+
return codec === void 0 ? entry : {
|
|
39320
|
+
...entry,
|
|
39321
|
+
codec
|
|
39322
|
+
};
|
|
39323
|
+
});
|
|
39324
|
+
}
|
|
39325
|
+
/**
|
|
39326
|
+
* The rate we will actually deliver: never more than the controller asked for,
|
|
39327
|
+
* and never more than the source produces. `-r` above the source rate makes
|
|
39328
|
+
* ffmpeg DUPLICATE frames, which spends the budget on nothing.
|
|
39329
|
+
*/
|
|
39330
|
+
function deliverableFps(negotiatedFps, slotFps) {
|
|
39331
|
+
if (slotFps === null || !Number.isFinite(slotFps) || slotFps <= 0) return negotiatedFps;
|
|
39332
|
+
return Math.min(negotiatedFps, Math.floor(slotFps));
|
|
39333
|
+
}
|
|
39334
|
+
/**
|
|
39335
|
+
* The ffmpeg video-output arguments.
|
|
39336
|
+
*
|
|
39337
|
+
* Pass-through carries `-bsf:v dump_extra` so every IDR inlines its own
|
|
39338
|
+
* SPS/PPS — sources that publish parameter sets only in the RTSP SDP hand iOS
|
|
39339
|
+
* a keyframe it cannot decode otherwise.
|
|
39340
|
+
*
|
|
39341
|
+
* The transcode's cap is three flags, not one: `-b:v` is an average and on its
|
|
39342
|
+
* own permits exactly the burst that was measured. `-maxrate` plus a
|
|
39343
|
+
* **one-second** `-bufsize` bounds any one-second window at the negotiated
|
|
39344
|
+
* rate, which is also the only lever available on the 3.03 s peak jitter — the
|
|
39345
|
+
* VBV window is what forces x264 to size a key frame to fit rather than
|
|
39346
|
+
* emitting it as one tight burst.
|
|
39347
|
+
*/
|
|
39348
|
+
/**
|
|
39349
|
+
* Seconds between forced IDRs on the transcode path.
|
|
39350
|
+
*
|
|
39351
|
+
* A join can only start decoding at a key frame, so this is the worst-case
|
|
39352
|
+
* wait a controller pays before the first picture — and the bound the RTSP
|
|
39353
|
+
* join-burst withhold falls back on when it declines to replay a wide GOP.
|
|
39354
|
+
*/
|
|
39355
|
+
var KEYFRAME_INTERVAL_SEC = 4;
|
|
39356
|
+
function buildVideoEncodeArgs(input) {
|
|
39357
|
+
if (!input.transcode) return [
|
|
39358
|
+
"-c:v",
|
|
39359
|
+
"copy",
|
|
39360
|
+
"-bsf:v",
|
|
39361
|
+
"dump_extra"
|
|
39362
|
+
];
|
|
39363
|
+
const rate = input.budgetKbps === null ? [] : [
|
|
39364
|
+
"-b:v",
|
|
39365
|
+
`${input.budgetKbps}k`,
|
|
39366
|
+
"-maxrate",
|
|
39367
|
+
`${input.budgetKbps}k`,
|
|
39368
|
+
"-bufsize",
|
|
39369
|
+
`${input.budgetKbps}k`
|
|
39370
|
+
];
|
|
39371
|
+
return [
|
|
39372
|
+
"-c:v",
|
|
39373
|
+
"libx264",
|
|
39374
|
+
"-preset",
|
|
39375
|
+
"ultrafast",
|
|
39376
|
+
"-tune",
|
|
39377
|
+
"zerolatency",
|
|
39378
|
+
"-pix_fmt",
|
|
39379
|
+
"yuv420p",
|
|
39380
|
+
"-r",
|
|
39381
|
+
String(input.fps),
|
|
39382
|
+
"-s",
|
|
39383
|
+
`${input.width}x${input.height}`,
|
|
39384
|
+
"-g",
|
|
39385
|
+
String(Math.max(1, Math.round(input.fps * KEYFRAME_INTERVAL_SEC))),
|
|
39386
|
+
...rate,
|
|
39387
|
+
"-profile:v",
|
|
39388
|
+
"baseline",
|
|
39389
|
+
"-level",
|
|
39390
|
+
"3.1",
|
|
39391
|
+
"-bsf:v",
|
|
39392
|
+
"dump_extra"
|
|
39393
|
+
];
|
|
39394
|
+
}
|
|
39395
|
+
/** Compact `mid=2048pub/9meas:over-budget` rendering for a single log field. */
|
|
39396
|
+
function formatFitNotes(notes) {
|
|
39397
|
+
return notes.map((n) => `${n.profile}=${n.publishedKbps ?? "?"}pub/${n.measuredKbps ?? "?"}meas:${n.verdict}`);
|
|
39398
|
+
}
|
|
39399
|
+
function fitNotes(entries, bitrates, budgetKbps) {
|
|
39400
|
+
return entries.map((entry) => {
|
|
39401
|
+
const evidence = bitrates.get(entry.profile);
|
|
39402
|
+
return {
|
|
39403
|
+
profile: entry.profile,
|
|
39404
|
+
verdict: budgetKbps === null ? "unknown" : classifyBitrateFit(evidence, budgetKbps),
|
|
39405
|
+
publishedKbps: evidence?.publishedKbps ?? null,
|
|
39406
|
+
measuredKbps: evidence?.measuredKbps ?? null
|
|
39407
|
+
};
|
|
39408
|
+
});
|
|
39409
|
+
}
|
|
39410
|
+
function transcodeReason(picked, profile, bitrates, budgetKbps) {
|
|
39411
|
+
if (!canPassThrough(picked.codec)) return "source-codec";
|
|
39412
|
+
if (profile === null) return "unknown-bitrate";
|
|
39413
|
+
return classifyBitrateFit(bitrates.get(profile), budgetKbps) === "over-budget" ? "over-budget" : "unknown-bitrate";
|
|
39414
|
+
}
|
|
39415
|
+
/**
|
|
39416
|
+
* iOS Home renders only H.264 over the classic HAP SRTP path, so an H.265
|
|
39417
|
+
* source can never be passed through. An UNKNOWN codec is treated the same
|
|
39418
|
+
* way: guessing H.264 is how a camera that changed its encoder ends up
|
|
39419
|
+
* shipping bytes no controller can decode.
|
|
39420
|
+
*/
|
|
39421
|
+
function canPassThrough(codec) {
|
|
39422
|
+
if (codec === void 0) return false;
|
|
39423
|
+
const lower = codec.toLowerCase();
|
|
39424
|
+
if (lower.includes("h265") || lower.includes("hevc")) return false;
|
|
39425
|
+
return lower.includes("h264") || lower.includes("avc");
|
|
39426
|
+
}
|
|
39427
|
+
function plausibleMeasured(value) {
|
|
39428
|
+
if (value === null || !Number.isFinite(value)) return null;
|
|
39429
|
+
return value >= 64 ? value : null;
|
|
39430
|
+
}
|
|
39431
|
+
/**
|
|
39432
|
+
* Map a profile slot onto the camera encoder feeding it, and read that
|
|
39433
|
+
* encoder's configured bitrate.
|
|
39434
|
+
*
|
|
39435
|
+
* The link is the slot's assigned cam-stream: its resolution (and frame rate,
|
|
39436
|
+
* when two encoders share a resolution) identifies which of `main`/`sub`/`ext`
|
|
39437
|
+
* produces it. Vendor-neutral on purpose — the cam-stream ids (`native:main`,
|
|
39438
|
+
* `native:slot-3`, …) are provider strings and matching on them would work for
|
|
39439
|
+
* exactly one provider. An ambiguous match returns `null`.
|
|
39440
|
+
*/
|
|
39441
|
+
function publishedRateFor(slot, source, streamParams) {
|
|
39442
|
+
if (streamParams === null) return null;
|
|
39443
|
+
const resolution = source?.resolution ?? slot.resolution;
|
|
39444
|
+
if (resolution === void 0) return null;
|
|
39445
|
+
const byResolution = ENCODER_PROFILE_KEYS.map((key) => encoderConfig(streamParams, key)).filter((cfg) => cfg !== null && cfg.width === resolution.width && cfg.height === resolution.height);
|
|
39446
|
+
if (byResolution.length === 1) return positiveOrNull(byResolution[0]?.bitrate);
|
|
39447
|
+
const fps = source?.fps;
|
|
39448
|
+
if (fps === void 0) return null;
|
|
39449
|
+
const byFps = byResolution.filter((cfg) => cfg !== null && Math.floor(cfg.framerate) === Math.floor(fps));
|
|
39450
|
+
return byFps.length === 1 ? positiveOrNull(byFps[0]?.bitrate) : null;
|
|
39451
|
+
}
|
|
39452
|
+
function encoderConfig(status, key) {
|
|
39453
|
+
return status[key] ?? null;
|
|
39454
|
+
}
|
|
39455
|
+
function positiveOrNull(value) {
|
|
39456
|
+
if (value === void 0 || !Number.isFinite(value) || value <= 0) return null;
|
|
39457
|
+
return value;
|
|
39458
|
+
}
|
|
39459
|
+
var CAM_PROFILES$1 = [
|
|
39460
|
+
"high",
|
|
39461
|
+
"mid",
|
|
39462
|
+
"low"
|
|
39463
|
+
];
|
|
39464
|
+
/**
|
|
39465
|
+
* `PickedStream.profileId` is the brokerId suffix, which for a profile-keyed
|
|
39466
|
+
* entry IS the profile name. Anything else addresses a raw cam-stream and must
|
|
39467
|
+
* not be coerced into a profile.
|
|
39468
|
+
*/
|
|
39469
|
+
function toCamProfile$1(profileId) {
|
|
39470
|
+
return CAM_PROFILES$1.find((p) => p === profileId) ?? null;
|
|
39471
|
+
}
|
|
39472
|
+
//#endregion
|
|
39473
|
+
//#region src/mappers/builders/stream-bitrate-probe.ts
|
|
39474
|
+
/**
|
|
39475
|
+
* Resolve every profile slot's rate. Never throws: an unresolvable slot ends
|
|
39476
|
+
* up `unknown`, which the selector treats as "cannot prove it fits" and
|
|
39477
|
+
* therefore transcodes — the safe direction on the wire.
|
|
39478
|
+
*/
|
|
39479
|
+
async function probeProfileBitrates(input) {
|
|
39480
|
+
const { proxy } = input.bctx;
|
|
39481
|
+
const camStreams = await probe$1(() => proxy.cameraStreams?.getCameraStreams({}), "cameraStreams.getCameraStreams", input.log);
|
|
39482
|
+
const streamParams = await probe$1(() => proxy.streamParams?.getStatus({}), "streamParams.getStatus", input.log);
|
|
39483
|
+
const choices = await probe$1(() => proxy.webrtcSession?.listStreams({}), "webrtcSession.listStreams", input.log);
|
|
39484
|
+
return resolveProfileBitrates({
|
|
39485
|
+
slots: input.slots,
|
|
39486
|
+
camStreams: camStreams ?? [],
|
|
39487
|
+
streamParams,
|
|
39488
|
+
choices: choices ?? []
|
|
39489
|
+
});
|
|
39490
|
+
}
|
|
39491
|
+
async function probe$1(call, label, log) {
|
|
39492
|
+
try {
|
|
39493
|
+
const pending = call();
|
|
39494
|
+
if (pending === void 0) {
|
|
39495
|
+
log.info("export-hap: bitrate probe skipped — cap not bound on this device", { meta: { call: label } });
|
|
39496
|
+
return null;
|
|
39497
|
+
}
|
|
39498
|
+
return await pending;
|
|
39499
|
+
} catch (err) {
|
|
39500
|
+
log.warn("export-hap: bitrate probe failed — the slot rate stays UNKNOWN", { meta: {
|
|
39501
|
+
call: label,
|
|
39502
|
+
error: err instanceof Error ? err.message : String(err)
|
|
39503
|
+
} });
|
|
39504
|
+
return null;
|
|
39505
|
+
}
|
|
39506
|
+
}
|
|
39507
|
+
/**
|
|
39508
|
+
* Two outputs from one input: video SRTP and audio SRTP, one process, one
|
|
39509
|
+
* lifetime, one kill signal. ffmpeg-internal stream selection (`-vn` / `-an`
|
|
39510
|
+
* plus per-output codec args) keeps both flowing through it.
|
|
39511
|
+
*/
|
|
39512
|
+
function buildSessionFfmpegArgs(input) {
|
|
39513
|
+
return [
|
|
39514
|
+
"-hide_banner",
|
|
39515
|
+
"-loglevel",
|
|
39516
|
+
"warning",
|
|
39517
|
+
...input.decodeArgs,
|
|
39518
|
+
"-rtsp_transport",
|
|
39519
|
+
"tcp",
|
|
39520
|
+
"-i",
|
|
39521
|
+
input.rtspUrl,
|
|
39522
|
+
"-an",
|
|
39523
|
+
"-map",
|
|
39524
|
+
"0:v:0",
|
|
39525
|
+
...input.videoArgs,
|
|
39526
|
+
"-payload_type",
|
|
39527
|
+
String(input.videoPayloadType),
|
|
39528
|
+
"-ssrc",
|
|
39529
|
+
String(input.videoSsrcSigned),
|
|
39530
|
+
"-f",
|
|
39531
|
+
"rtp",
|
|
39532
|
+
input.videoTarget,
|
|
39533
|
+
"-vn",
|
|
39534
|
+
"-map",
|
|
39535
|
+
"0:a:0?",
|
|
39536
|
+
"-af",
|
|
39537
|
+
"aresample=async=1000:first_pts=0",
|
|
39538
|
+
"-c:a",
|
|
39539
|
+
"libopus",
|
|
39540
|
+
"-application",
|
|
39541
|
+
"lowdelay",
|
|
39542
|
+
"-frame_duration",
|
|
39543
|
+
String(input.audioPacketTimeMs),
|
|
39544
|
+
"-flags",
|
|
39545
|
+
"+global_header",
|
|
39546
|
+
"-ar",
|
|
39547
|
+
String(input.audioSampleRateKhz * 1e3),
|
|
39548
|
+
"-b:a",
|
|
39549
|
+
`24k`,
|
|
39550
|
+
"-bufsize",
|
|
39551
|
+
`96k`,
|
|
39552
|
+
"-ac",
|
|
39553
|
+
String(1),
|
|
39554
|
+
"-payload_type",
|
|
39555
|
+
String(input.audioPayloadType),
|
|
39556
|
+
"-ssrc",
|
|
39557
|
+
String(input.audioSsrcSigned),
|
|
39558
|
+
"-f",
|
|
39559
|
+
"rtp",
|
|
39560
|
+
input.audioTarget
|
|
39561
|
+
];
|
|
39562
|
+
}
|
|
38332
39563
|
/**
|
|
38333
39564
|
* The resolutions we offer, before rates are attached. Same list the delegate
|
|
38334
39565
|
* advertised before R2 — only the frame rate changes, so a controller that had
|
|
@@ -38467,6 +39698,129 @@ var CAM_PROFILES = [
|
|
|
38467
39698
|
function toCamProfile(profileId) {
|
|
38468
39699
|
return CAM_PROFILES.find((p) => p === profileId) ?? null;
|
|
38469
39700
|
}
|
|
39701
|
+
/**
|
|
39702
|
+
* How long after spawn an exit still counts as "hardware init failed".
|
|
39703
|
+
*
|
|
39704
|
+
* Same window the ffmpeg decoder addon uses for its own cascade. A hardware
|
|
39705
|
+
* context that cannot be created fails within milliseconds; anything that ran
|
|
39706
|
+
* longer produced no frames for a different reason, and re-spawning it in
|
|
39707
|
+
* software would just hide that reason.
|
|
39708
|
+
*/
|
|
39709
|
+
var HW_DECODE_FALLBACK_WINDOW_MS = 4e3;
|
|
39710
|
+
/** Backends whose decode binds to a DRM render node. */
|
|
39711
|
+
var RENDER_NODE_BACKENDS = ["vaapi", "qsv"];
|
|
39712
|
+
/**
|
|
39713
|
+
* Values of the decoder cap's `hwaccel` field that are operator CHOICES rather
|
|
39714
|
+
* than devices.
|
|
39715
|
+
*/
|
|
39716
|
+
var HWACCEL_NON_BACKEND_CHOICES = ["auto", "none"];
|
|
39717
|
+
/**
|
|
39718
|
+
* Every backend the decoder cap can publish, taken from the cap's own UI option
|
|
39719
|
+
* list so this module cannot drift from it. A hand-written copy of the union is
|
|
39720
|
+
* exactly the "hand-written cap interface that rots silently" this repo has
|
|
39721
|
+
* been bitten by.
|
|
39722
|
+
*/
|
|
39723
|
+
var HWACCEL_BACKENDS = new Set(HWACCEL_OPTIONS.map((option) => option.value).filter((value) => !HWACCEL_NON_BACKEND_CHOICES.includes(value)));
|
|
39724
|
+
/** Is this string a hardware backend, as opposed to `auto`, `none` or junk? */
|
|
39725
|
+
function isHwAccelBackend(value) {
|
|
39726
|
+
return HWACCEL_BACKENDS.has(value);
|
|
39727
|
+
}
|
|
39728
|
+
/**
|
|
39729
|
+
* Decide how this session decodes, and produce the ffmpeg input-side flags.
|
|
39730
|
+
*
|
|
39731
|
+
* Every path that ends in software carries a named reason, because a session
|
|
39732
|
+
* that quietly stopped using the GPU and a session that never had one look
|
|
39733
|
+
* identical in a CPU graph.
|
|
39734
|
+
*/
|
|
39735
|
+
function selectHwDecode(input) {
|
|
39736
|
+
if (!input.transcode) return software("pass-through");
|
|
39737
|
+
if (input.hardwareAlreadyFailed === true) return software("hardware-attempt-failed");
|
|
39738
|
+
if (input.reading === null) return software("no-decoder-reading");
|
|
39739
|
+
const chosen = (input.reading.hwaccel ?? "").trim();
|
|
39740
|
+
if (chosen === "none") return software("operator-disabled");
|
|
39741
|
+
if (chosen !== "" && chosen !== "auto") return isHwAccelBackend(chosen) ? hardware(chosen, "operator", input) : software("unrecognised-backend");
|
|
39742
|
+
const probed = (input.reading.probedBestHwaccel ?? "").trim();
|
|
39743
|
+
if (probed === "" || probed === "none") return software("not-probed");
|
|
39744
|
+
return isHwAccelBackend(probed) ? hardware(probed, "probed", input) : software("unrecognised-backend");
|
|
39745
|
+
}
|
|
39746
|
+
/**
|
|
39747
|
+
* Did this ffmpeg exit look like a failed hardware init, rather than a
|
|
39748
|
+
* teardown or a fault software would hit too?
|
|
39749
|
+
*
|
|
39750
|
+
* All five conditions are necessary. Dropping the controller-stop check in
|
|
39751
|
+
* particular would respawn on every normal teardown, because iOS restarts a
|
|
39752
|
+
* session it is not enjoying and that is indistinguishable at the exit code.
|
|
39753
|
+
*/
|
|
39754
|
+
function shouldRetryInSoftware(input) {
|
|
39755
|
+
if (!input.usedHardware) return false;
|
|
39756
|
+
if (input.hardwareAlreadyFailed) return false;
|
|
39757
|
+
if (input.stopRequestedByController) return false;
|
|
39758
|
+
if (input.videoPacketsForwarded > 0) return false;
|
|
39759
|
+
return input.runtimeMs <= HW_DECODE_FALLBACK_WINDOW_MS;
|
|
39760
|
+
}
|
|
39761
|
+
function software(reason) {
|
|
39762
|
+
return {
|
|
39763
|
+
kind: "software",
|
|
39764
|
+
reason,
|
|
39765
|
+
args: []
|
|
39766
|
+
};
|
|
39767
|
+
}
|
|
39768
|
+
function hardware(backend, source, input) {
|
|
39769
|
+
return {
|
|
39770
|
+
kind: "hardware",
|
|
39771
|
+
backend,
|
|
39772
|
+
source,
|
|
39773
|
+
args: decodeArgs(backend, input)
|
|
39774
|
+
};
|
|
39775
|
+
}
|
|
39776
|
+
/**
|
|
39777
|
+
* The input-side flags, and nothing else.
|
|
39778
|
+
*
|
|
39779
|
+
* No `-hwaccel_output_format`: the decoded frames have to land in system
|
|
39780
|
+
* memory for libx264 to scale and encode them. Setting it would keep them on
|
|
39781
|
+
* the GPU, which only pays off with a GPU scale filter — and that is the
|
|
39782
|
+
* decoder addon's job, not a two-output SRTP session's.
|
|
39783
|
+
*/
|
|
39784
|
+
function decodeArgs(backend, input) {
|
|
39785
|
+
if (backend === "videotoolbox" && input.platform === "darwin") return ["-hwaccel", "auto"];
|
|
39786
|
+
const args = ["-hwaccel", backend];
|
|
39787
|
+
if (RENDER_NODE_BACKENDS.includes(backend)) args.push("-hwaccel_device", input.renderDevice ?? "/dev/dri/renderD128");
|
|
39788
|
+
return args;
|
|
39789
|
+
}
|
|
39790
|
+
//#endregion
|
|
39791
|
+
//#region src/mappers/builders/stream-hwaccel-probe.ts
|
|
39792
|
+
/**
|
|
39793
|
+
* Read this node's decode-hwaccel state, or `null` when nothing answered.
|
|
39794
|
+
*
|
|
39795
|
+
* Never throws. `null` means "we do not know", which
|
|
39796
|
+
* {@link import('./stream-hwaccel.js').selectHwDecode} turns into software —
|
|
39797
|
+
* the safe direction, because a guess here costs the whole stream.
|
|
39798
|
+
*/
|
|
39799
|
+
async function probeDecoderHwaccel(input) {
|
|
39800
|
+
const { ctx, log } = input;
|
|
39801
|
+
const nodeId = ctx.kernel?.localNodeId;
|
|
39802
|
+
if (nodeId === void 0 || nodeId.length === 0) {
|
|
39803
|
+
log.warn("export-hap: hwaccel probe skipped — no local node id, decoding in SOFTWARE");
|
|
39804
|
+
return null;
|
|
39805
|
+
}
|
|
39806
|
+
try {
|
|
39807
|
+
const info = await ctx.api.decoder.getInfo.query(void 0, nodePin(nodeId));
|
|
39808
|
+
if (info === null || info === void 0) {
|
|
39809
|
+
log.info("export-hap: hwaccel probe returned nothing — decoding in SOFTWARE", { meta: { nodeId } });
|
|
39810
|
+
return null;
|
|
39811
|
+
}
|
|
39812
|
+
return {
|
|
39813
|
+
hwaccel: info.hwaccel ?? null,
|
|
39814
|
+
probedBestHwaccel: info.probedBestHwaccel ?? null
|
|
39815
|
+
};
|
|
39816
|
+
} catch (err) {
|
|
39817
|
+
log.warn("export-hap: hwaccel probe failed — decoding in SOFTWARE", { meta: {
|
|
39818
|
+
nodeId,
|
|
39819
|
+
error: err instanceof Error ? err.message : String(err)
|
|
39820
|
+
} });
|
|
39821
|
+
return null;
|
|
39822
|
+
}
|
|
39823
|
+
}
|
|
38470
39824
|
//#endregion
|
|
38471
39825
|
//#region src/mappers/builders/stream-telemetry.ts
|
|
38472
39826
|
/**
|
|
@@ -38488,6 +39842,12 @@ var ZERO_DROP_COUNTERS = {
|
|
|
38488
39842
|
"rtcp-no-srtcp": 0,
|
|
38489
39843
|
/** Outbound RTCP: building or encrypting the Sender Report failed. */
|
|
38490
39844
|
"rtcp-encrypt-failed": 0,
|
|
39845
|
+
/** Inbound RTCP: the leg has no SRTCP context, so the controller's report is unreadable. */
|
|
39846
|
+
"inbound-rtcp-no-srtcp": 0,
|
|
39847
|
+
/** Inbound RTCP: SRTCP decryption of a controller packet failed. */
|
|
39848
|
+
"inbound-rtcp-decrypt-failed": 0,
|
|
39849
|
+
/** Inbound RTCP: the decrypted bytes did not parse as RTCP. */
|
|
39850
|
+
"inbound-rtcp-parse-failed": 0,
|
|
38491
39851
|
/** Upstream: packet shorter than an RTP header. */
|
|
38492
39852
|
"upstream-short-packet": 0,
|
|
38493
39853
|
/** Upstream: no inbound SRTP context (init failed at prepareStream). */
|
|
@@ -38543,6 +39903,21 @@ function classifyInboundPacket(packet) {
|
|
|
38543
39903
|
return "rtp";
|
|
38544
39904
|
}
|
|
38545
39905
|
/**
|
|
39906
|
+
* Loss at or above this is not a start-up transient.
|
|
39907
|
+
*
|
|
39908
|
+
* A stream whose first key frame is slow reliably produces one report in the
|
|
39909
|
+
* low single digits; a transmit-side defect produces tens of percent, because
|
|
39910
|
+
* whatever makes a packet unusable makes most packets unusable. The threshold
|
|
39911
|
+
* sits between those two regimes rather than at any measured boundary — it is
|
|
39912
|
+
* a reading aid, and the raw `worstFractionLostPct` is always in the line
|
|
39913
|
+
* beside it.
|
|
39914
|
+
*/
|
|
39915
|
+
var TRANSMIT_SUSPECT_FRACTION_LOST_PCT = 5;
|
|
39916
|
+
function lossVerdict(tally) {
|
|
39917
|
+
if (tally.blocksParsed === 0 || tally.worstFractionLostPct === null) return "no-reports";
|
|
39918
|
+
return tally.worstFractionLostPct >= TRANSMIT_SUSPECT_FRACTION_LOST_PCT ? "transmit-suspect" : "decode-suspect";
|
|
39919
|
+
}
|
|
39920
|
+
/**
|
|
38546
39921
|
* Build the meta for `export-hap: stream session summary` — the single line a
|
|
38547
39922
|
* future session greps to answer "why did this session die".
|
|
38548
39923
|
*/
|
|
@@ -38563,7 +39938,13 @@ function summariseSession(snapshot) {
|
|
|
38563
39938
|
selectedBrokerId: slot?.brokerId ?? null,
|
|
38564
39939
|
advertisedFps: slot?.advertisedFps ?? null,
|
|
38565
39940
|
advertisedFpsSource: slot?.advertisedFpsSource ?? null,
|
|
39941
|
+
deliveredFps: slot?.deliveredFps ?? null,
|
|
38566
39942
|
transcode: slot?.transcode ?? null,
|
|
39943
|
+
fitReason: slot?.fitReason ?? null,
|
|
39944
|
+
slotPublishedKbps: slot?.publishedKbps ?? null,
|
|
39945
|
+
slotMeasuredKbps: slot?.measuredKbps ?? null,
|
|
39946
|
+
encodeBudgetKbps: slot?.budgetKbps ?? null,
|
|
39947
|
+
fitNotes: slot?.fitNotes ?? [],
|
|
38567
39948
|
videoPacketsForwarded: snapshot.videoPacketsForwarded,
|
|
38568
39949
|
audioPacketsForwarded: snapshot.audioPacketsForwarded,
|
|
38569
39950
|
videoRtcpSrSent: snapshot.videoRtcpSrSent,
|
|
@@ -38574,6 +39955,10 @@ function summariseSession(snapshot) {
|
|
|
38574
39955
|
audioRtpReceived: snapshot.audioRtpReceived,
|
|
38575
39956
|
videoGate: formatRtcpGate(snapshot.videoGate),
|
|
38576
39957
|
audioGate: formatRtcpGate(snapshot.audioGate),
|
|
39958
|
+
videoReceiverReports: snapshot.videoReceiverReports,
|
|
39959
|
+
audioReceiverReports: snapshot.audioReceiverReports,
|
|
39960
|
+
videoLossVerdict: lossVerdict(snapshot.videoReceiverReports),
|
|
39961
|
+
audioLossVerdict: lossVerdict(snapshot.audioReceiverReports),
|
|
38577
39962
|
mediaStarved: snapshot.videoPacketsForwarded === 0,
|
|
38578
39963
|
drops: nonZeroDrops(snapshot.drops)
|
|
38579
39964
|
};
|
|
@@ -38593,8 +39978,17 @@ var SRTP_SALT_LEN = 14;
|
|
|
38593
39978
|
* make this one act.
|
|
38594
39979
|
*/
|
|
38595
39980
|
var SESSION_HEARTBEAT_MS = 5e3;
|
|
38596
|
-
|
|
38597
|
-
|
|
39981
|
+
/**
|
|
39982
|
+
* Floor between two `export-hap: controller receiver report` lines on one leg.
|
|
39983
|
+
*
|
|
39984
|
+
* The controller sends several Receiver Reports a second — 21 and 34 in two
|
|
39985
|
+
* ~10 s sessions on 2026-08-06 — and every one of them at `info` would drown
|
|
39986
|
+
* the very line it is meant to make findable. The FIRST report on each leg is
|
|
39987
|
+
* always logged, in full and under its own message; after that this throttles
|
|
39988
|
+
* to the heartbeat's cadence so a session still produces a running record of
|
|
39989
|
+
* what the controller thinks without becoming one.
|
|
39990
|
+
*/
|
|
39991
|
+
var RECEIVER_REPORT_LOG_INTERVAL_MS = 5e3;
|
|
38598
39992
|
function buildCameraStreamingDelegate(bctx, advertised) {
|
|
38599
39993
|
const { ctx, numericDeviceId } = bctx;
|
|
38600
39994
|
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
@@ -38702,7 +40096,7 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
38702
40096
|
},
|
|
38703
40097
|
profile: import_src.ProtectionProfileAes128CmHmacSha1_80
|
|
38704
40098
|
});
|
|
38705
|
-
const
|
|
40099
|
+
const makeSrtcp = (key, salt) => new import_src.SrtcpSession({
|
|
38706
40100
|
keys: {
|
|
38707
40101
|
localMasterKey: key,
|
|
38708
40102
|
localMasterSalt: salt,
|
|
@@ -38713,13 +40107,17 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
38713
40107
|
});
|
|
38714
40108
|
let videoOutSrtp;
|
|
38715
40109
|
let videoOutSrtcp;
|
|
40110
|
+
let videoInSrtcp;
|
|
38716
40111
|
let audioOutSrtp;
|
|
38717
40112
|
let audioOutSrtcp;
|
|
40113
|
+
let audioInSrtcp;
|
|
38718
40114
|
try {
|
|
38719
40115
|
videoOutSrtp = makeOutSrtp(request.video.srtp_key, request.video.srtp_salt);
|
|
38720
|
-
videoOutSrtcp =
|
|
40116
|
+
videoOutSrtcp = makeSrtcp(request.video.srtp_key, request.video.srtp_salt);
|
|
40117
|
+
videoInSrtcp = makeSrtcp(request.video.srtp_key, request.video.srtp_salt);
|
|
38721
40118
|
audioOutSrtp = makeOutSrtp(request.audio.srtp_key, request.audio.srtp_salt);
|
|
38722
|
-
audioOutSrtcp =
|
|
40119
|
+
audioOutSrtcp = makeSrtcp(request.audio.srtp_key, request.audio.srtp_salt);
|
|
40120
|
+
audioInSrtcp = makeSrtcp(request.audio.srtp_key, request.audio.srtp_salt);
|
|
38723
40121
|
} catch (err) {
|
|
38724
40122
|
closeSocket(videoUdp);
|
|
38725
40123
|
closeSocket(audioUdp);
|
|
@@ -38761,6 +40159,10 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
38761
40159
|
audioRtcpReceived: 0,
|
|
38762
40160
|
videoRtpReceived: 0,
|
|
38763
40161
|
audioRtpReceived: 0,
|
|
40162
|
+
videoReceiverReports: emptyReceiverReportTally(),
|
|
40163
|
+
audioReceiverReports: emptyReceiverReportTally(),
|
|
40164
|
+
videoRrLoggedAt: 0,
|
|
40165
|
+
audioRrLoggedAt: 0,
|
|
38764
40166
|
videoGate: null,
|
|
38765
40167
|
audioGate: null,
|
|
38766
40168
|
heartbeat: null,
|
|
@@ -38772,6 +40174,7 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
38772
40174
|
videoLoopUdp,
|
|
38773
40175
|
videoOutSrtp,
|
|
38774
40176
|
videoOutSrtcp,
|
|
40177
|
+
videoInSrtcp,
|
|
38775
40178
|
videoOutPacketCount: 0,
|
|
38776
40179
|
videoOutOctetCount: 0,
|
|
38777
40180
|
videoOutLastRtpTimestamp: 0,
|
|
@@ -38786,6 +40189,7 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
38786
40189
|
audioLoopUdp,
|
|
38787
40190
|
audioOutSrtp,
|
|
38788
40191
|
audioOutSrtcp,
|
|
40192
|
+
audioInSrtcp,
|
|
38789
40193
|
audioSendGate: null,
|
|
38790
40194
|
ipVersion,
|
|
38791
40195
|
ffmpeg: null,
|
|
@@ -38962,6 +40366,111 @@ function countInbound(session, leg, packet, log) {
|
|
|
38962
40366
|
leg,
|
|
38963
40367
|
bytes: packet.length
|
|
38964
40368
|
} });
|
|
40369
|
+
if (kind === "rtcp") readControllerRtcp(session, leg, packet, log);
|
|
40370
|
+
}
|
|
40371
|
+
/** Fold a decrypted RTCP datagram into the leg's tally. Immutable, per `ReceiverReportTally`. */
|
|
40372
|
+
function storeReceiverReports(session, leg, tally) {
|
|
40373
|
+
if (leg === "video") session.videoReceiverReports = tally;
|
|
40374
|
+
else session.audioReceiverReports = tally;
|
|
40375
|
+
}
|
|
40376
|
+
/**
|
|
40377
|
+
* Decrypt one inbound RTCP datagram and read the controller's Receiver Report.
|
|
40378
|
+
*
|
|
40379
|
+
* This is the datum the 2026-08-06 telemetry left missing. That round proved
|
|
40380
|
+
* iOS DOES send us RTCP on the video leg — 21 and 34 packets across two
|
|
40381
|
+
* sessions, the gate opening on a real controller packet at ~550 ms — which
|
|
40382
|
+
* killed the long-standing "iOS never probes us" belief. But counting packets
|
|
40383
|
+
* says only that the controller spoke; a Receiver Report says WHAT it said,
|
|
40384
|
+
* and that splits the remaining causes into two disjoint families that nothing
|
|
40385
|
+
* else in this system can tell apart. See `LossVerdict`.
|
|
40386
|
+
*
|
|
40387
|
+
* Runs on a UDP `message` handler, so nothing here may throw: a controller
|
|
40388
|
+
* that sends one deformed datagram must not take the session with it. Every
|
|
40389
|
+
* failure books a named drop and increments `unreadable`, because a report we
|
|
40390
|
+
* could not read and a report that never came must never look alike.
|
|
40391
|
+
*/
|
|
40392
|
+
function readControllerRtcp(session, leg, packet, log) {
|
|
40393
|
+
const srtcp = leg === "video" ? session.videoInSrtcp : session.audioInSrtcp;
|
|
40394
|
+
const tally = leg === "video" ? session.videoReceiverReports : session.audioReceiverReports;
|
|
40395
|
+
if (!srtcp) {
|
|
40396
|
+
drop(session, "inbound-rtcp-no-srtcp");
|
|
40397
|
+
storeReceiverReports(session, leg, recordUnreadableRtcp(tally));
|
|
40398
|
+
logUnreadableRtcp(session, leg, "no-srtcp-context", log);
|
|
40399
|
+
return;
|
|
40400
|
+
}
|
|
40401
|
+
let plaintext;
|
|
40402
|
+
try {
|
|
40403
|
+
plaintext = srtcp.decrypt(packet);
|
|
40404
|
+
} catch (err) {
|
|
40405
|
+
drop(session, "inbound-rtcp-decrypt-failed");
|
|
40406
|
+
storeReceiverReports(session, leg, recordUnreadableRtcp(tally));
|
|
40407
|
+
logUnreadableRtcp(session, leg, `decrypt: ${errMsg$8(err)}`, log);
|
|
40408
|
+
return;
|
|
40409
|
+
}
|
|
40410
|
+
const outcome = ingestDecryptedRtcp(plaintext, tally);
|
|
40411
|
+
storeReceiverReports(session, leg, outcome.tally);
|
|
40412
|
+
if (outcome.failure !== null) {
|
|
40413
|
+
drop(session, "inbound-rtcp-parse-failed");
|
|
40414
|
+
logUnreadableRtcp(session, leg, `parse: ${outcome.failure}`, log);
|
|
40415
|
+
return;
|
|
40416
|
+
}
|
|
40417
|
+
const isFirst = tally.reportsParsed === 0 && outcome.tally.reportsParsed > 0;
|
|
40418
|
+
logReceiverReports(session, leg, outcome.reports, isFirst, log);
|
|
40419
|
+
}
|
|
40420
|
+
/** Throttled per leg — a controller sending nothing but garbage must not become the log. */
|
|
40421
|
+
function logUnreadableRtcp(session, leg, reason, log) {
|
|
40422
|
+
const tally = leg === "video" ? session.videoReceiverReports : session.audioReceiverReports;
|
|
40423
|
+
if (!shouldLogReceiverReport(session, leg)) return;
|
|
40424
|
+
log.warn("export-hap: inbound RTCP could not be read", { meta: {
|
|
40425
|
+
sessionId: session.sessionId,
|
|
40426
|
+
leg,
|
|
40427
|
+
reason,
|
|
40428
|
+
unreadable: tally.unreadable
|
|
40429
|
+
} });
|
|
40430
|
+
}
|
|
40431
|
+
/**
|
|
40432
|
+
* True at most once per `RECEIVER_REPORT_LOG_INTERVAL_MS` per leg. Stamps the
|
|
40433
|
+
* leg on the way out so the caller cannot forget to.
|
|
40434
|
+
*/
|
|
40435
|
+
function shouldLogReceiverReport(session, leg) {
|
|
40436
|
+
const now = Date.now();
|
|
40437
|
+
if (now < (leg === "video" ? session.videoRrLoggedAt : session.audioRrLoggedAt) + RECEIVER_REPORT_LOG_INTERVAL_MS) return false;
|
|
40438
|
+
if (leg === "video") session.videoRrLoggedAt = now;
|
|
40439
|
+
else session.audioRrLoggedAt = now;
|
|
40440
|
+
return true;
|
|
40441
|
+
}
|
|
40442
|
+
/**
|
|
40443
|
+
* Emit the controller's own numbers.
|
|
40444
|
+
*
|
|
40445
|
+
* The FIRST report on a leg gets its own message: that one arriving at all is
|
|
40446
|
+
* the proof iOS is engaged with the stream, and it was worth a year of
|
|
40447
|
+
* argument. Everything after it is throttled to the heartbeat's cadence.
|
|
40448
|
+
*/
|
|
40449
|
+
function logReceiverReports(session, leg, reports, isFirst, log) {
|
|
40450
|
+
const tally = leg === "video" ? session.videoReceiverReports : session.audioReceiverReports;
|
|
40451
|
+
const block = reports.flatMap((report) => report.blocks).at(-1);
|
|
40452
|
+
if (!block) return;
|
|
40453
|
+
const reporter = reports.find((report) => report.blocks.length > 0);
|
|
40454
|
+
const meta = {
|
|
40455
|
+
sessionId: session.sessionId,
|
|
40456
|
+
leg,
|
|
40457
|
+
reporterSsrc: reporter?.reporterSsrc ?? null,
|
|
40458
|
+
aboutSsrc: block.aboutSsrc,
|
|
40459
|
+
fractionLostPct: block.fractionLostPct,
|
|
40460
|
+
cumulativePacketsLost: block.cumulativePacketsLost,
|
|
40461
|
+
extendedHighestSequence: block.extendedHighestSequence,
|
|
40462
|
+
jitter: block.jitter,
|
|
40463
|
+
delaySinceLastSrMs: block.delaySinceLastSrMs,
|
|
40464
|
+
reportsParsed: tally.reportsParsed,
|
|
40465
|
+
worstFractionLostPct: tally.worstFractionLostPct
|
|
40466
|
+
};
|
|
40467
|
+
if (isFirst) {
|
|
40468
|
+
shouldLogReceiverReport(session, leg);
|
|
40469
|
+
log.info("export-hap: FIRST RTCP Receiver Report from controller — iOS is receiving and reporting", { meta });
|
|
40470
|
+
return;
|
|
40471
|
+
}
|
|
40472
|
+
if (!shouldLogReceiverReport(session, leg)) return;
|
|
40473
|
+
log.info("export-hap: controller receiver report", { meta });
|
|
38965
40474
|
}
|
|
38966
40475
|
/** Snapshot every counter into the summary meta. */
|
|
38967
40476
|
function sessionSummaryMeta(session) {
|
|
@@ -38981,6 +40490,8 @@ function sessionSummaryMeta(session) {
|
|
|
38981
40490
|
audioRtpReceived: session.audioRtpReceived,
|
|
38982
40491
|
videoGate: session.videoGate,
|
|
38983
40492
|
audioGate: session.audioGate,
|
|
40493
|
+
videoReceiverReports: session.videoReceiverReports,
|
|
40494
|
+
audioReceiverReports: session.audioReceiverReports,
|
|
38984
40495
|
drops: session.drops,
|
|
38985
40496
|
ffmpegExit: session.ffmpegExit,
|
|
38986
40497
|
stopRequestedByController: session.stopRequestedByController
|
|
@@ -39016,7 +40527,15 @@ function stopHeartbeat(session) {
|
|
|
39016
40527
|
* a HomeKit session died. It carries the negotiated parameters, the slot we
|
|
39017
40528
|
* dialled and the rate we had promised for it, packet counts in both
|
|
39018
40529
|
* directions on both legs, each leg's gate outcome, ffmpeg's exit, who asked
|
|
39019
|
-
* for the teardown,
|
|
40530
|
+
* for the teardown, every named drop — and, since 2026-08-06, what the
|
|
40531
|
+
* controller itself reported receiving.
|
|
40532
|
+
*
|
|
40533
|
+
* Read `videoLossVerdict` first. `transmit-suspect` says the controller is not
|
|
40534
|
+
* getting our packets intact and nothing past the wire matters;
|
|
40535
|
+
* `decode-suspect` says it got them and rendered nothing anyway;
|
|
40536
|
+
* `no-reports` says we still cannot tell, and `videoReceiverReports.unreadable`
|
|
40537
|
+
* then distinguishes "the controller said nothing" from "we could not read what
|
|
40538
|
+
* it said".
|
|
39020
40539
|
*
|
|
39021
40540
|
* Emitted from the ffmpeg `exit` handler — the only place the exit code is
|
|
39022
40541
|
* known — and directly from the teardown paths when there is no ffmpeg to wait
|
|
@@ -39329,11 +40848,24 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
|
|
|
39329
40848
|
throw new Error(`export-hap: no profile RTSP entries for device ${numericDeviceId}`);
|
|
39330
40849
|
}
|
|
39331
40850
|
const pref = options.hapDeviceSettings.streamPreference;
|
|
39332
|
-
const
|
|
39333
|
-
|
|
39334
|
-
|
|
39335
|
-
|
|
39336
|
-
|
|
40851
|
+
const brokerStreams = await proxy.cameraStreams?.getBrokerStreams({}) ?? [];
|
|
40852
|
+
const bitrates = await probeProfileBitrates({
|
|
40853
|
+
bctx,
|
|
40854
|
+
slots: brokerStreams,
|
|
40855
|
+
log: startLog
|
|
40856
|
+
});
|
|
40857
|
+
const fit = selectStreamForBudget({
|
|
40858
|
+
entries: withSlotCodecs(entries, brokerStreams),
|
|
40859
|
+
deviceId: numericDeviceId,
|
|
40860
|
+
pref,
|
|
40861
|
+
targetResolution: {
|
|
40862
|
+
width: video.width,
|
|
40863
|
+
height: video.height
|
|
40864
|
+
},
|
|
40865
|
+
negotiatedMaxBitrateKbps: video.max_bit_rate,
|
|
40866
|
+
bitrates
|
|
40867
|
+
});
|
|
40868
|
+
if (fit === null) {
|
|
39337
40869
|
startLog.warn("export-hap: stream start DROPPED — no ENABLED profile RTSP entry", { meta: {
|
|
39338
40870
|
sessionId,
|
|
39339
40871
|
streamPreference: pref,
|
|
@@ -39342,14 +40874,18 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
|
|
|
39342
40874
|
} });
|
|
39343
40875
|
throw new Error(`export-hap: no enabled RTSP entries for device ${numericDeviceId}`);
|
|
39344
40876
|
}
|
|
40877
|
+
const picked = fit.picked;
|
|
39345
40878
|
const rtspUrl = picked.url;
|
|
39346
|
-
const slot =
|
|
40879
|
+
const slot = brokerStreams.find((s) => s.profile === fit.profile);
|
|
39347
40880
|
const codec = (picked.codec ?? slot?.codec ?? "").toLowerCase();
|
|
39348
|
-
const needsTranscode =
|
|
40881
|
+
const needsTranscode = fit.kind === "transcode";
|
|
39349
40882
|
const pickedProfile = toKnownProfile(picked.profileId);
|
|
39350
40883
|
const resolvedFps = pickedProfile === null ? void 0 : advertised.fpsByProfile.get(pickedProfile);
|
|
39351
40884
|
const advertisedFps = resolvedFps?.fps ?? video.fps;
|
|
39352
40885
|
const advertisedFpsSource = resolvedFps?.source ?? "assumed";
|
|
40886
|
+
const deliveredFps = needsTranscode ? deliverableFps(video.fps, resolvedFps?.fps ?? null) : advertisedFps;
|
|
40887
|
+
const slotEvidence = pickedProfile === null ? void 0 : bitrates.get(pickedProfile);
|
|
40888
|
+
const fitNotes = formatFitNotes(fit.notes);
|
|
39353
40889
|
session.selectedSlot = {
|
|
39354
40890
|
profile: pickedProfile,
|
|
39355
40891
|
brokerId: picked.brokerId,
|
|
@@ -39357,14 +40893,33 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
|
|
|
39357
40893
|
height: picked.resolution?.height ?? null,
|
|
39358
40894
|
advertisedFps,
|
|
39359
40895
|
advertisedFpsSource,
|
|
40896
|
+
deliveredFps,
|
|
39360
40897
|
codec: codec.length > 0 ? codec : "unknown",
|
|
39361
|
-
transcode: needsTranscode
|
|
40898
|
+
transcode: needsTranscode,
|
|
40899
|
+
fitReason: fit.reason,
|
|
40900
|
+
publishedKbps: slotEvidence?.publishedKbps ?? null,
|
|
40901
|
+
measuredKbps: slotEvidence?.measuredKbps ?? null,
|
|
40902
|
+
budgetKbps: fit.budgetKbps,
|
|
40903
|
+
fitNotes
|
|
39362
40904
|
};
|
|
39363
|
-
|
|
40905
|
+
startLog.info("export-hap: stream bitrate fit resolved", { meta: {
|
|
40906
|
+
sessionId,
|
|
40907
|
+
decision: fit.kind,
|
|
40908
|
+
reason: fit.reason,
|
|
40909
|
+
negotiatedMaxBitrateKbps: video.max_bit_rate,
|
|
40910
|
+
encodeBudgetKbps: fit.budgetKbps,
|
|
40911
|
+
profile: pickedProfile,
|
|
40912
|
+
brokerId: picked.brokerId,
|
|
40913
|
+
slotPublishedKbps: slotEvidence?.publishedKbps ?? null,
|
|
40914
|
+
slotMeasuredKbps: slotEvidence?.measuredKbps ?? null,
|
|
40915
|
+
candidates: fitNotes
|
|
40916
|
+
} });
|
|
40917
|
+
if (deliveredFps !== video.fps) startLog.warn("export-hap: negotiated fps does NOT match the rate we are about to deliver", { meta: {
|
|
39364
40918
|
sessionId,
|
|
39365
40919
|
negotiatedFps: video.fps,
|
|
39366
40920
|
slotFps: advertisedFps,
|
|
39367
40921
|
slotFpsSource: advertisedFpsSource,
|
|
40922
|
+
deliveredFps,
|
|
39368
40923
|
profile: pickedProfile,
|
|
39369
40924
|
brokerId: picked.brokerId,
|
|
39370
40925
|
transcode: needsTranscode
|
|
@@ -39373,101 +40928,91 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
|
|
|
39373
40928
|
const audioLoopPort = session.audioLoopUdp.address().port;
|
|
39374
40929
|
const videoTarget = `rtp://127.0.0.1:${videoLoopPort}?pkt_size=${video.mtu}`;
|
|
39375
40930
|
const audioTarget = `rtp://127.0.0.1:${audioLoopPort}?pkt_size=${video.mtu}`;
|
|
39376
|
-
const videoArgs =
|
|
39377
|
-
|
|
39378
|
-
|
|
39379
|
-
|
|
39380
|
-
|
|
39381
|
-
|
|
39382
|
-
|
|
39383
|
-
|
|
39384
|
-
|
|
39385
|
-
|
|
39386
|
-
|
|
39387
|
-
|
|
39388
|
-
|
|
39389
|
-
|
|
39390
|
-
|
|
39391
|
-
|
|
39392
|
-
`${video.max_bit_rate * 2}k`,
|
|
39393
|
-
"-maxrate",
|
|
39394
|
-
`${video.max_bit_rate}k`,
|
|
39395
|
-
"-profile:v",
|
|
39396
|
-
"baseline",
|
|
39397
|
-
"-level",
|
|
39398
|
-
"3.1"
|
|
39399
|
-
] : [
|
|
39400
|
-
"-c:v",
|
|
39401
|
-
"copy",
|
|
39402
|
-
"-bsf:v",
|
|
39403
|
-
"dump_extra"
|
|
39404
|
-
];
|
|
40931
|
+
const videoArgs = buildVideoEncodeArgs({
|
|
40932
|
+
transcode: needsTranscode,
|
|
40933
|
+
width: video.width,
|
|
40934
|
+
height: video.height,
|
|
40935
|
+
fps: deliveredFps,
|
|
40936
|
+
budgetKbps: fit.budgetKbps
|
|
40937
|
+
});
|
|
40938
|
+
const hwDecode = selectHwDecode({
|
|
40939
|
+
transcode: needsTranscode,
|
|
40940
|
+
reading: needsTranscode ? await probeDecoderHwaccel({
|
|
40941
|
+
ctx,
|
|
40942
|
+
log: startLog
|
|
40943
|
+
}) : null,
|
|
40944
|
+
platform: process.platform
|
|
40945
|
+
});
|
|
40946
|
+
logDecodePath(startLog, sessionId, hwDecode, needsTranscode);
|
|
39405
40947
|
const videoSsrcSigned = session.videoSsrc | 0;
|
|
39406
40948
|
const audioSsrcSigned = video.audio_ssrc | 0;
|
|
39407
|
-
const
|
|
39408
|
-
|
|
39409
|
-
"-loglevel",
|
|
39410
|
-
"warning",
|
|
39411
|
-
"-rtsp_transport",
|
|
39412
|
-
"tcp",
|
|
39413
|
-
"-i",
|
|
40949
|
+
const buildArgs = (decodeArgs) => buildSessionFfmpegArgs({
|
|
40950
|
+
decodeArgs,
|
|
39414
40951
|
rtspUrl,
|
|
39415
|
-
|
|
39416
|
-
"-map",
|
|
39417
|
-
"0:v:0",
|
|
39418
|
-
...videoArgs,
|
|
39419
|
-
"-payload_type",
|
|
39420
|
-
String(video.pt),
|
|
39421
|
-
"-ssrc",
|
|
39422
|
-
String(videoSsrcSigned),
|
|
39423
|
-
"-f",
|
|
39424
|
-
"rtp",
|
|
40952
|
+
videoArgs,
|
|
39425
40953
|
videoTarget,
|
|
39426
|
-
|
|
39427
|
-
|
|
39428
|
-
|
|
39429
|
-
|
|
39430
|
-
|
|
39431
|
-
|
|
39432
|
-
|
|
39433
|
-
"-application",
|
|
39434
|
-
"lowdelay",
|
|
39435
|
-
"-frame_duration",
|
|
39436
|
-
String(video.packet_time ?? 20),
|
|
39437
|
-
"-flags",
|
|
39438
|
-
"+global_header",
|
|
39439
|
-
"-ar",
|
|
39440
|
-
String((video.sample_rate ?? 16) * 1e3),
|
|
39441
|
-
"-b:a",
|
|
39442
|
-
`${OPUS_BITRATE_KBPS}k`,
|
|
39443
|
-
"-bufsize",
|
|
39444
|
-
`${OPUS_BITRATE_KBPS * 4}k`,
|
|
39445
|
-
"-ac",
|
|
39446
|
-
String(OPUS_CHANNELS),
|
|
39447
|
-
"-payload_type",
|
|
39448
|
-
String(video.audio_pt),
|
|
39449
|
-
"-ssrc",
|
|
39450
|
-
String(audioSsrcSigned),
|
|
39451
|
-
"-f",
|
|
39452
|
-
"rtp",
|
|
39453
|
-
audioTarget
|
|
39454
|
-
];
|
|
39455
|
-
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
39456
|
-
const proc = spawn("ffmpeg", args, { stdio: [
|
|
39457
|
-
"ignore",
|
|
39458
|
-
"ignore",
|
|
39459
|
-
"pipe"
|
|
39460
|
-
] });
|
|
39461
|
-
session.ffmpeg = proc;
|
|
39462
|
-
proc.stderr?.on("data", (chunk) => {
|
|
39463
|
-
const line = chunk.toString("utf8").trim();
|
|
39464
|
-
if (!line) return;
|
|
39465
|
-
log.info("export-hap: ffmpeg", { meta: {
|
|
39466
|
-
sessionId,
|
|
39467
|
-
line
|
|
39468
|
-
} });
|
|
40954
|
+
audioTarget,
|
|
40955
|
+
videoPayloadType: video.pt,
|
|
40956
|
+
videoSsrcSigned,
|
|
40957
|
+
audioPayloadType: video.audio_pt,
|
|
40958
|
+
audioSsrcSigned,
|
|
40959
|
+
audioPacketTimeMs: video.packet_time ?? 20,
|
|
40960
|
+
audioSampleRateKhz: video.sample_rate ?? 16
|
|
39469
40961
|
});
|
|
39470
|
-
|
|
40962
|
+
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
40963
|
+
let hardwareAlreadyFailed = false;
|
|
40964
|
+
const spawnFfmpeg = (decision) => {
|
|
40965
|
+
const spawnedAtMs = Date.now();
|
|
40966
|
+
const usedHardware = decision.kind === "hardware";
|
|
40967
|
+
const proc = spawn("ffmpeg", buildArgs(decision.args), { stdio: [
|
|
40968
|
+
"ignore",
|
|
40969
|
+
"ignore",
|
|
40970
|
+
"pipe"
|
|
40971
|
+
] });
|
|
40972
|
+
session.ffmpeg = proc;
|
|
40973
|
+
proc.stderr?.on("data", (chunk) => {
|
|
40974
|
+
const line = chunk.toString("utf8").trim();
|
|
40975
|
+
if (!line) return;
|
|
40976
|
+
log.info("export-hap: ffmpeg", { meta: {
|
|
40977
|
+
sessionId,
|
|
40978
|
+
line
|
|
40979
|
+
} });
|
|
40980
|
+
});
|
|
40981
|
+
proc.once("exit", (code, signal) => {
|
|
40982
|
+
if (shouldRetryInSoftware({
|
|
40983
|
+
usedHardware,
|
|
40984
|
+
hardwareAlreadyFailed,
|
|
40985
|
+
stopRequestedByController: session.stopRequestedByController,
|
|
40986
|
+
videoPacketsForwarded: session.videoPacketsForwarded,
|
|
40987
|
+
runtimeMs: Date.now() - spawnedAtMs
|
|
40988
|
+
})) {
|
|
40989
|
+
hardwareAlreadyFailed = true;
|
|
40990
|
+
log.warn("export-hap: hardware decode FAILED at init — respawning ffmpeg in software", { meta: {
|
|
40991
|
+
sessionId,
|
|
40992
|
+
backend: decision.kind === "hardware" ? decision.backend : null,
|
|
40993
|
+
backendSource: decision.kind === "hardware" ? decision.source : null,
|
|
40994
|
+
code,
|
|
40995
|
+
signal,
|
|
40996
|
+
runtimeMs: Date.now() - spawnedAtMs
|
|
40997
|
+
} });
|
|
40998
|
+
if (session.ffmpeg === proc) session.ffmpeg = null;
|
|
40999
|
+
spawnFfmpeg({
|
|
41000
|
+
kind: "software",
|
|
41001
|
+
reason: "hardware-attempt-failed",
|
|
41002
|
+
args: []
|
|
41003
|
+
});
|
|
41004
|
+
return;
|
|
41005
|
+
}
|
|
41006
|
+
onFfmpegExit(proc, code, signal);
|
|
41007
|
+
});
|
|
41008
|
+
proc.once("error", (err) => {
|
|
41009
|
+
log.warn("export-hap: ffmpeg spawn failed", { meta: {
|
|
41010
|
+
sessionId,
|
|
41011
|
+
error: err.message
|
|
41012
|
+
} });
|
|
41013
|
+
});
|
|
41014
|
+
};
|
|
41015
|
+
const onFfmpegExit = (proc, code, signal) => {
|
|
39471
41016
|
session.ffmpegExit = {
|
|
39472
41017
|
code,
|
|
39473
41018
|
signal
|
|
@@ -39484,13 +41029,8 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
|
|
|
39484
41029
|
else log.warn("export-hap: ffmpeg exited without a controller stop", { meta });
|
|
39485
41030
|
if (session.ffmpeg === proc) session.ffmpeg = null;
|
|
39486
41031
|
logSessionSummary(session, log, session.teardownTrigger ?? "ffmpeg-exit");
|
|
39487
|
-
}
|
|
39488
|
-
|
|
39489
|
-
log.warn("export-hap: ffmpeg spawn failed", { meta: {
|
|
39490
|
-
sessionId,
|
|
39491
|
-
error: err.message
|
|
39492
|
-
} });
|
|
39493
|
-
});
|
|
41032
|
+
};
|
|
41033
|
+
spawnFfmpeg(hwDecode);
|
|
39494
41034
|
log.info("export-hap: stream started", { meta: {
|
|
39495
41035
|
sessionId,
|
|
39496
41036
|
transcode: needsTranscode,
|
|
@@ -39501,8 +41041,38 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
|
|
|
39501
41041
|
negotiated: `${video.width}x${video.height}@${video.fps} ${video.max_bit_rate}kbps`,
|
|
39502
41042
|
slotFps: advertisedFps,
|
|
39503
41043
|
slotFpsSource: advertisedFpsSource,
|
|
41044
|
+
deliveredFps,
|
|
41045
|
+
fitReason: fit.reason,
|
|
41046
|
+
encodeBudgetKbps: fit.budgetKbps,
|
|
39504
41047
|
audioCodec: "opus",
|
|
39505
|
-
audioBitrateKbps:
|
|
41048
|
+
audioBitrateKbps: 24,
|
|
41049
|
+
videoDecode: hwDecode.kind === "hardware" ? hwDecode.backend : "software"
|
|
41050
|
+
} });
|
|
41051
|
+
}
|
|
41052
|
+
/**
|
|
41053
|
+
* Say which decode path was resolved, and WHY, once per session.
|
|
41054
|
+
*
|
|
41055
|
+
* At `info` on purpose — Loki carries `info+`, and "did this session use the
|
|
41056
|
+
* GPU" is the first question anyone asks of a transcoding hub. The software
|
|
41057
|
+
* branch is the one that must never be silent: it is a real cost being paid,
|
|
41058
|
+
* and every reason it can be reached is a different fix.
|
|
41059
|
+
*/
|
|
41060
|
+
function logDecodePath(log, sessionId, decision, transcode) {
|
|
41061
|
+
if (decision.kind === "hardware") {
|
|
41062
|
+
log.info("export-hap: video decode path resolved — HARDWARE", { meta: {
|
|
41063
|
+
sessionId,
|
|
41064
|
+
backend: decision.backend,
|
|
41065
|
+
backendSource: decision.source,
|
|
41066
|
+
decodeArgs: decision.args
|
|
41067
|
+
} });
|
|
41068
|
+
return;
|
|
41069
|
+
}
|
|
41070
|
+
const level = transcode ? "warn" : "info";
|
|
41071
|
+
const message = level === "warn" ? "export-hap: video decode path resolved — SOFTWARE, this transcode costs a core" : "export-hap: video decode path resolved — none needed";
|
|
41072
|
+
log[level](message, { meta: {
|
|
41073
|
+
sessionId,
|
|
41074
|
+
reason: decision.reason,
|
|
41075
|
+
transcode
|
|
39506
41076
|
} });
|
|
39507
41077
|
}
|
|
39508
41078
|
/**
|
|
@@ -39727,7 +41297,7 @@ async function buildIntercom(input) {
|
|
|
39727
41297
|
var RESET_DEBOUNCE_MS = 5e3;
|
|
39728
41298
|
async function buildMotionSensor(bctx) {
|
|
39729
41299
|
const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
|
|
39730
|
-
const motionService = accessory.addService(Service.MotionSensor, displayName);
|
|
41300
|
+
const motionService = accessory.addService(Service.MotionSensor, hapServiceName([displayName], `Camera ${numericDeviceId}`));
|
|
39731
41301
|
motionService.setCharacteristic(Characteristic.MotionDetected, false);
|
|
39732
41302
|
try {
|
|
39733
41303
|
const detected = await proxy.motion?.isDetected({});
|
|
@@ -39782,12 +41352,12 @@ function errMsg$6(err) {
|
|
|
39782
41352
|
* camera-enabled switch — distinct from privacy-mask).
|
|
39783
41353
|
*/
|
|
39784
41354
|
async function buildPrivacySwitch(bctx) {
|
|
39785
|
-
const { ctx, accessory, proxy, numericDeviceId } = bctx;
|
|
41355
|
+
const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
|
|
39786
41356
|
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
39787
41357
|
const subtype = "privacy-mask";
|
|
39788
|
-
const
|
|
39789
|
-
const service = accessory.addService(Service.Switch,
|
|
39790
|
-
service.setCharacteristic(Characteristic.
|
|
41358
|
+
const serviceName = privacyServiceName(displayName);
|
|
41359
|
+
const service = accessory.addService(Service.Switch, serviceName, subtype);
|
|
41360
|
+
service.setCharacteristic(Characteristic.Name, serviceName);
|
|
39791
41361
|
try {
|
|
39792
41362
|
const status = await proxy.privacyMask?.getStatus({});
|
|
39793
41363
|
if (status && typeof status.enabled === "boolean") service.updateCharacteristic(Characteristic.On, status.enabled);
|
|
@@ -39877,18 +41447,23 @@ function ptzPresetLabel(presetName) {
|
|
|
39877
41447
|
* `proxy.ptzAutotrack.setEnabled({enabled})`. Initial value is
|
|
39878
41448
|
* hydrated from `getStatus({})`.
|
|
39879
41449
|
*
|
|
39880
|
-
* Naming:
|
|
39881
|
-
*
|
|
39882
|
-
*
|
|
39883
|
-
*
|
|
39884
|
-
*
|
|
39885
|
-
*
|
|
39886
|
-
*
|
|
39887
|
-
* "Interruttore N".
|
|
41450
|
+
* Naming: `<camera> <action>` — "Videocamera ingresso Preset stanza" — built
|
|
41451
|
+
* by `ptzServiceName` and written to the `Name` characteristic.
|
|
41452
|
+
*
|
|
41453
|
+
* TWO separate naming defects have been through this file, and both are fixed
|
|
41454
|
+
* by that one line:
|
|
41455
|
+
* - `${displayName} — <action>` embedded an em-dash (U+2014), which is
|
|
41456
|
+
* outside Apple's permitted set. hap-nodejs' `checkName` warned, iOS
|
|
41457
|
+
* discarded the name and showed "Interruttore N". A previous round dropped
|
|
41458
|
+
* the camera prefix along with the em-dash; only the em-dash was the fault.
|
|
41459
|
+
* - The bare label that replaced it was then written to `ConfiguredName`,
|
|
41460
|
+
* which `Service.Switch` does not list, so hap-nodejs rejected the
|
|
41461
|
+
* characteristic outright — SIX rejections per PTZ camera per build, never
|
|
41462
|
+
* reported because only the two switches on the non-PTZ camera were noticed.
|
|
39888
41463
|
*/
|
|
39889
41464
|
var MOMENTARY_RESET_MS = 1e3;
|
|
39890
41465
|
async function buildPtz(bctx) {
|
|
39891
|
-
const { ctx, accessory, proxy, numericDeviceId, options } = bctx;
|
|
41466
|
+
const { ctx, accessory, proxy, numericDeviceId, displayName, options } = bctx;
|
|
39892
41467
|
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
39893
41468
|
const timers = /* @__PURE__ */ new Set();
|
|
39894
41469
|
const armReset = (cb, delay) => {
|
|
@@ -39900,10 +41475,10 @@ async function buildPtz(bctx) {
|
|
|
39900
41475
|
};
|
|
39901
41476
|
const presets = await readPresets(bctx);
|
|
39902
41477
|
for (const preset of presets) {
|
|
39903
|
-
const label = ptzPresetLabel(preset.name);
|
|
41478
|
+
const label = ptzServiceName(displayName, ptzPresetLabel(preset.name));
|
|
39904
41479
|
const subtype = `ptz-preset-${preset.id}`;
|
|
39905
41480
|
const service = accessory.addService(Service.Switch, label, subtype);
|
|
39906
|
-
service.setCharacteristic(Characteristic.
|
|
41481
|
+
service.setCharacteristic(Characteristic.Name, label);
|
|
39907
41482
|
service.getCharacteristic(Characteristic.On).onSet(async (value) => {
|
|
39908
41483
|
if (value !== true) return;
|
|
39909
41484
|
try {
|
|
@@ -39918,9 +41493,9 @@ async function buildPtz(bctx) {
|
|
|
39918
41493
|
});
|
|
39919
41494
|
}
|
|
39920
41495
|
if (proxy.ptz) for (const dir of PTZ_DIRECTIONS) {
|
|
39921
|
-
const label = dir.label;
|
|
41496
|
+
const label = ptzServiceName(displayName, dir.label);
|
|
39922
41497
|
const service = accessory.addService(Service.Switch, label, dir.subtype);
|
|
39923
|
-
service.setCharacteristic(Characteristic.
|
|
41498
|
+
service.setCharacteristic(Characteristic.Name, label);
|
|
39924
41499
|
service.getCharacteristic(Characteristic.On).onSet(async (value) => {
|
|
39925
41500
|
if (value !== true) return;
|
|
39926
41501
|
try {
|
|
@@ -39964,12 +41539,12 @@ async function readPresets(bctx) {
|
|
|
39964
41539
|
}
|
|
39965
41540
|
}
|
|
39966
41541
|
async function tryBuildAutotrack(bctx) {
|
|
39967
|
-
const { ctx, accessory, proxy, numericDeviceId } = bctx;
|
|
41542
|
+
const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
|
|
39968
41543
|
if (!proxy.ptzAutotrack) return { async dispose() {} };
|
|
39969
41544
|
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
39970
|
-
const label = PTZ_AUTOTRACK_LABEL;
|
|
41545
|
+
const label = ptzServiceName(displayName, PTZ_AUTOTRACK_LABEL);
|
|
39971
41546
|
const service = accessory.addService(Service.Switch, label, "ptz-autotrack");
|
|
39972
|
-
service.setCharacteristic(Characteristic.
|
|
41547
|
+
service.setCharacteristic(Characteristic.Name, label);
|
|
39973
41548
|
try {
|
|
39974
41549
|
const status = await proxy.ptzAutotrack.getStatus({});
|
|
39975
41550
|
if (status && typeof status.enabled === "boolean") service.updateCharacteristic(Characteristic.On, status.enabled);
|
|
@@ -40096,7 +41671,7 @@ async function buildChildSwitch(bctx, subtype, deviceType) {
|
|
|
40096
41671
|
const isLightingDevice = deviceType === DeviceType.Light || deviceType === DeviceType.Generic;
|
|
40097
41672
|
const useLightbulb = hasBrightness && isLightingDevice;
|
|
40098
41673
|
const service = useLightbulb ? accessory.addService(Service.Lightbulb, displayName, subtype) : accessory.addService(Service.Switch, displayName, subtype);
|
|
40099
|
-
service.setCharacteristic(Characteristic.
|
|
41674
|
+
service.setCharacteristic(Characteristic.Name, displayName);
|
|
40100
41675
|
try {
|
|
40101
41676
|
const switchStatus = await proxy.switch?.getStatus({});
|
|
40102
41677
|
if (switchStatus && typeof switchStatus.on === "boolean") service.updateCharacteristic(Characteristic.On, switchStatus.on);
|
|
@@ -40168,7 +41743,7 @@ async function buildChildServicesFor(input) {
|
|
|
40168
41743
|
accessory: parentCtx.accessory,
|
|
40169
41744
|
proxy: childProxy,
|
|
40170
41745
|
numericDeviceId: child.id,
|
|
40171
|
-
displayName:
|
|
41746
|
+
displayName: childServiceName(parentDisplayName, child),
|
|
40172
41747
|
options
|
|
40173
41748
|
};
|
|
40174
41749
|
const subtype = `child-${child.id}`;
|
|
@@ -40199,21 +41774,6 @@ async function listChildren(ctx, parentNumericId) {
|
|
|
40199
41774
|
}
|
|
40200
41775
|
}
|
|
40201
41776
|
/**
|
|
40202
|
-
* Service name displayed inside the camera tile detail in iOS Home.
|
|
40203
|
-
* Prefer the child's own role ("Siren", "Floodlight") when meaningful
|
|
40204
|
-
* — the camera name is already implied by the surrounding Accessory.
|
|
40205
|
-
* Falls back to the child's stored device name when role is empty.
|
|
40206
|
-
*/
|
|
40207
|
-
function formatChildServiceName(parentName, child) {
|
|
40208
|
-
const role = child.role && child.role.length > 0 ? toTitleCase(child.role) : null;
|
|
40209
|
-
if (role) return role;
|
|
40210
|
-
if (child.name.toLowerCase().includes(parentName.toLowerCase())) {
|
|
40211
|
-
const stripped = child.name.replace(new RegExp(`\\b${escapeRegex(parentName)}\\b`, "i"), "").replace(/\s+[—-]\s+/, " ").trim();
|
|
40212
|
-
if (stripped.length > 0) return stripped;
|
|
40213
|
-
}
|
|
40214
|
-
return child.name;
|
|
40215
|
-
}
|
|
40216
|
-
/**
|
|
40217
41777
|
* Coerce the raw `child.type` string (from `deviceManager.getChildren`)
|
|
40218
41778
|
* to a `DeviceType` enum value. Unknown / mis-cased values fall back to
|
|
40219
41779
|
* `Generic` so an unrecognised driver behaves like the safest existing
|
|
@@ -40225,12 +41785,6 @@ function asDeviceType(raw) {
|
|
|
40225
41785
|
for (const value of Object.values(DeviceType)) if (value === lower) return value;
|
|
40226
41786
|
return DeviceType.Generic;
|
|
40227
41787
|
}
|
|
40228
|
-
function escapeRegex(s) {
|
|
40229
|
-
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
40230
|
-
}
|
|
40231
|
-
function toTitleCase(raw) {
|
|
40232
|
-
return raw.split("-").filter((part) => part.length > 0).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
|
|
40233
|
-
}
|
|
40234
41788
|
function errMsg$2(err) {
|
|
40235
41789
|
return err instanceof Error ? err.message : String(err);
|
|
40236
41790
|
}
|