@camstack/addon-export-hap 1.2.11 → 1.2.12
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 +850 -256
- package/dist/export-hap.addon.mjs +851 -257
- package/package.json +1 -1
package/dist/export-hap.addon.js
CHANGED
|
@@ -14727,6 +14727,70 @@ var EventPruneCountsSchema = object({
|
|
|
14727
14727
|
object: number().int(),
|
|
14728
14728
|
audio: number().int()
|
|
14729
14729
|
});
|
|
14730
|
+
/**
|
|
14731
|
+
* Re-embed stored tracks from their key frames.
|
|
14732
|
+
*
|
|
14733
|
+
* The reason this is an operator-callable method and not a migration script:
|
|
14734
|
+
* every knob that decides what a vector MEANS — encoder model, crop margin,
|
|
14735
|
+
* squaring — is only changeable if the existing vectors can be regenerated.
|
|
14736
|
+
* Mixing feature spaces in one index makes cosine scores incomparable, and the
|
|
14737
|
+
* symptom is a quality regression with no visible cause.
|
|
14738
|
+
*/
|
|
14739
|
+
var RebuildObjectEmbeddingsInput = object({
|
|
14740
|
+
/** Restrict to one camera. Omit for the whole fleet. */
|
|
14741
|
+
deviceId: number().optional(),
|
|
14742
|
+
since: number().optional(),
|
|
14743
|
+
until: number().optional(),
|
|
14744
|
+
/** Stop after this many tracks; the result reports whether more remain. */
|
|
14745
|
+
maxTracks: number().int().positive().optional()
|
|
14746
|
+
});
|
|
14747
|
+
/**
|
|
14748
|
+
* Result of emptying the CLIP index.
|
|
14749
|
+
*
|
|
14750
|
+
* The clean slate before a policy change: a new crop margin or encoder model
|
|
14751
|
+
* leaves two feature spaces in one index whose cosine scores are not
|
|
14752
|
+
* comparable, so wiping and rebuilding is the only way to be sure every vector
|
|
14753
|
+
* means the same thing.
|
|
14754
|
+
*/
|
|
14755
|
+
var WipeObjectEmbeddingsResultSchema = object({ deleted: number() });
|
|
14756
|
+
/**
|
|
14757
|
+
* Acknowledgement that a rebuild STARTED.
|
|
14758
|
+
*
|
|
14759
|
+
* The pass costs ~1s per track — 45 minutes for a 2,600-track fleet — so it
|
|
14760
|
+
* runs detached and this returns immediately. Waiting for it made the client
|
|
14761
|
+
* time out while the work carried on server-side, which is the worst of both:
|
|
14762
|
+
* no result and no way to know it was still going. Poll
|
|
14763
|
+
* `getObjectEmbeddingRebuildStatus` for progress.
|
|
14764
|
+
*/
|
|
14765
|
+
var RebuildObjectEmbeddingsResultSchema = object({
|
|
14766
|
+
started: boolean(),
|
|
14767
|
+
/** True when a pass was already running; the new request is ignored. */
|
|
14768
|
+
alreadyRunning: boolean()
|
|
14769
|
+
});
|
|
14770
|
+
var RebuildStatusSchema = object({
|
|
14771
|
+
running: boolean(),
|
|
14772
|
+
scanned: number(),
|
|
14773
|
+
rebuilt: number(),
|
|
14774
|
+
/** Tracks whose key frame is gone — nothing to re-embed from. */
|
|
14775
|
+
missingKeyFrame: number(),
|
|
14776
|
+
/** Tracks with no usable detection box. */
|
|
14777
|
+
missingBbox: number(),
|
|
14778
|
+
/**
|
|
14779
|
+
* Tracks the pipeline REFUSED rather than broke on: the camera is not
|
|
14780
|
+
* attached, or `clip-embedding` is not enabled in its step tree. Separate
|
|
14781
|
+
* from `failed` because the remedy is a configuration change, not an engine
|
|
14782
|
+
* investigation — and because a pass over decommissioned cameras would
|
|
14783
|
+
* otherwise read as a total engine outage.
|
|
14784
|
+
*/
|
|
14785
|
+
notRunnable: number(),
|
|
14786
|
+
failed: number(),
|
|
14787
|
+
/** Set once a pass ends: true only when EVERYTHING was covered. */
|
|
14788
|
+
complete: boolean().nullable(),
|
|
14789
|
+
startedAtMs: number().nullable(),
|
|
14790
|
+
finishedAtMs: number().nullable(),
|
|
14791
|
+
/** Present when the pass ended by throwing. */
|
|
14792
|
+
error: string().nullable()
|
|
14793
|
+
});
|
|
14730
14794
|
DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).readonly()), method(object({
|
|
14731
14795
|
deviceId: number(),
|
|
14732
14796
|
trackId: string()
|
|
@@ -14820,7 +14884,13 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
|
|
|
14820
14884
|
}), array(MediaFileSchema).readonly()), method(object({
|
|
14821
14885
|
trackId: string(),
|
|
14822
14886
|
kinds: array(MediaFileKindEnum).optional()
|
|
14823
|
-
}), array(MediaFileSchema).readonly()), method(object({ trackId: string() }), array(MediaFileInfoSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), object({
|
|
14887
|
+
}), array(MediaFileSchema).readonly()), method(object({ trackId: string() }), array(MediaFileInfoSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), method(object({}), WipeObjectEmbeddingsResultSchema, {
|
|
14888
|
+
kind: "mutation",
|
|
14889
|
+
auth: "admin"
|
|
14890
|
+
}), method(RebuildObjectEmbeddingsInput, RebuildObjectEmbeddingsResultSchema, {
|
|
14891
|
+
kind: "mutation",
|
|
14892
|
+
auth: "admin"
|
|
14893
|
+
}), method(object({}), RebuildStatusSchema), object({
|
|
14824
14894
|
deviceId: number(),
|
|
14825
14895
|
timestamp: number(),
|
|
14826
14896
|
frameWidth: number(),
|
|
@@ -15729,6 +15799,17 @@ method(RunnerCameraConfigSchema, object({ success: literal(true) }), { kind: "mu
|
|
|
15729
15799
|
}), NativeCropResultSchema.nullable()), method(object({
|
|
15730
15800
|
deviceId: number(),
|
|
15731
15801
|
frameHandle: FrameHandleSchema.optional(),
|
|
15802
|
+
/**
|
|
15803
|
+
* FULL FRAME (base64 JPEG). The runner derives the crop rectangle from
|
|
15804
|
+
* `parent.bbox` with the cluster crop convention and cuts it itself —
|
|
15805
|
+
* do NOT pre-crop for this field, that is what `cropJpeg` is.
|
|
15806
|
+
*/
|
|
15807
|
+
frameJpeg: string().optional(),
|
|
15808
|
+
/**
|
|
15809
|
+
* PRE-CUT tile (base64 JPEG), used verbatim — NO padding is applied.
|
|
15810
|
+
* The fallback when the lease/session backing the frame is gone and the
|
|
15811
|
+
* caller already holds a crop.
|
|
15812
|
+
*/
|
|
15732
15813
|
cropJpeg: string().optional(),
|
|
15733
15814
|
parent: DetailParentSchema,
|
|
15734
15815
|
steps: array(string()).optional()
|
|
@@ -17054,6 +17135,24 @@ var VectorDeleteByFilterInputSchema = object({
|
|
|
17054
17135
|
filter: VectorFilterSchema
|
|
17055
17136
|
});
|
|
17056
17137
|
var VectorDeleteResultSchema = object({ deleted: number() });
|
|
17138
|
+
var VectorGetInputSchema = object({
|
|
17139
|
+
index: string(),
|
|
17140
|
+
ids: array(string())
|
|
17141
|
+
});
|
|
17142
|
+
/**
|
|
17143
|
+
* Metadata for the requested ids, WITHOUT their vectors.
|
|
17144
|
+
*
|
|
17145
|
+
* The only caller is a best-of gate that compares a candidate's confidence
|
|
17146
|
+
* against the stored one, and shipping 512 floats back to answer "is 0.91 >
|
|
17147
|
+
* 0.87" would undo the point of the compact encoding. Ids with no row are
|
|
17148
|
+
* simply absent — a caller distinguishing "not stored" from "stored" reads the
|
|
17149
|
+
* length, and a null placeholder would invite a `?? 0` that treats a missing
|
|
17150
|
+
* row as confidence zero.
|
|
17151
|
+
*/
|
|
17152
|
+
var VectorGetResultSchema = object({ items: array(object({
|
|
17153
|
+
id: string(),
|
|
17154
|
+
metadata: VectorMetadataSchema
|
|
17155
|
+
})) });
|
|
17057
17156
|
var VectorStatsInputSchema = object({ index: string() });
|
|
17058
17157
|
var VectorStatsResultSchema = object({
|
|
17059
17158
|
/** Provider id, so an operator can tell brute force from an ANN index. */
|
|
@@ -17066,7 +17165,7 @@ var VectorStatsResultSchema = object({
|
|
|
17066
17165
|
/** False when the backend ranks approximately. */
|
|
17067
17166
|
exact: boolean()
|
|
17068
17167
|
});
|
|
17069
|
-
method(VectorDeclareIndexInputSchema, _void(), { kind: "mutation" }), method(VectorUpsertInputSchema, VectorUpsertResultSchema, { kind: "mutation" }), method(VectorQueryInputSchema, VectorQueryResultSchema), method(VectorDeleteInputSchema, VectorDeleteResultSchema, { kind: "mutation" }), method(VectorDeleteByFilterInputSchema, VectorDeleteResultSchema, { kind: "mutation" }), method(VectorStatsInputSchema, VectorStatsResultSchema);
|
|
17168
|
+
method(VectorDeclareIndexInputSchema, _void(), { kind: "mutation" }), method(VectorUpsertInputSchema, VectorUpsertResultSchema, { kind: "mutation" }), method(VectorQueryInputSchema, VectorQueryResultSchema), method(VectorGetInputSchema, VectorGetResultSchema), method(VectorDeleteInputSchema, VectorDeleteResultSchema, { kind: "mutation" }), method(VectorDeleteByFilterInputSchema, VectorDeleteResultSchema, { kind: "mutation" }), method(VectorStatsInputSchema, VectorStatsResultSchema);
|
|
17070
17169
|
/**
|
|
17071
17170
|
* `videoclips` — the unified, navigable-clip surface for a camera.
|
|
17072
17171
|
*
|
|
@@ -25329,6 +25428,12 @@ Object.freeze({
|
|
|
25329
25428
|
addonId: null,
|
|
25330
25429
|
access: "view"
|
|
25331
25430
|
},
|
|
25431
|
+
"pipelineAnalytics.getObjectEmbeddingRebuildStatus": {
|
|
25432
|
+
capName: "pipeline-analytics",
|
|
25433
|
+
capScope: "device",
|
|
25434
|
+
addonId: null,
|
|
25435
|
+
access: "view"
|
|
25436
|
+
},
|
|
25332
25437
|
"pipelineAnalytics.getObjectEvents": {
|
|
25333
25438
|
capName: "pipeline-analytics",
|
|
25334
25439
|
capScope: "device",
|
|
@@ -25407,6 +25512,12 @@ Object.freeze({
|
|
|
25407
25512
|
addonId: null,
|
|
25408
25513
|
access: "create"
|
|
25409
25514
|
},
|
|
25515
|
+
"pipelineAnalytics.rebuildObjectEmbeddings": {
|
|
25516
|
+
capName: "pipeline-analytics",
|
|
25517
|
+
capScope: "device",
|
|
25518
|
+
addonId: null,
|
|
25519
|
+
access: "create"
|
|
25520
|
+
},
|
|
25410
25521
|
"pipelineAnalytics.relocateMedia": {
|
|
25411
25522
|
capName: "pipeline-analytics",
|
|
25412
25523
|
capScope: "device",
|
|
@@ -25425,6 +25536,12 @@ Object.freeze({
|
|
|
25425
25536
|
addonId: null,
|
|
25426
25537
|
access: "delete"
|
|
25427
25538
|
},
|
|
25539
|
+
"pipelineAnalytics.wipeObjectEmbeddings": {
|
|
25540
|
+
capName: "pipeline-analytics",
|
|
25541
|
+
capScope: "device",
|
|
25542
|
+
addonId: null,
|
|
25543
|
+
access: "delete"
|
|
25544
|
+
},
|
|
25428
25545
|
"pipelineExecutor.cacheFrameInPool": {
|
|
25429
25546
|
capName: "pipeline-executor",
|
|
25430
25547
|
capScope: "system",
|
|
@@ -27345,6 +27462,12 @@ Object.freeze({
|
|
|
27345
27462
|
addonId: null,
|
|
27346
27463
|
access: "delete"
|
|
27347
27464
|
},
|
|
27465
|
+
"vectorStore.getByIds": {
|
|
27466
|
+
capName: "vector-store",
|
|
27467
|
+
capScope: "system",
|
|
27468
|
+
addonId: null,
|
|
27469
|
+
access: "view"
|
|
27470
|
+
},
|
|
27348
27471
|
"vectorStore.query": {
|
|
27349
27472
|
capName: "vector-store",
|
|
27350
27473
|
capScope: "system",
|
|
@@ -27619,6 +27742,30 @@ TimelapseRuleInputSchema.extend({
|
|
|
27619
27742
|
createdAt: number(),
|
|
27620
27743
|
updatedAt: number()
|
|
27621
27744
|
});
|
|
27745
|
+
object({
|
|
27746
|
+
/**
|
|
27747
|
+
* Fraction of the box's own size added on EACH side before cutting.
|
|
27748
|
+
*
|
|
27749
|
+
* CLIP is trained on natural images WITH surroundings; a pixel-tight crop
|
|
27750
|
+
* removes exactly the context it is strongest on (a dog cut to its outline
|
|
27751
|
+
* is a dark blob). The right value is an empirical question, which is why it
|
|
27752
|
+
* is a setting: 0 / 0.15 / 0.2 / 0.5 are the interesting points.
|
|
27753
|
+
*/
|
|
27754
|
+
paddingRatio: number().min(0).max(4),
|
|
27755
|
+
/**
|
|
27756
|
+
* Square the window (in PIXELS) before cutting.
|
|
27757
|
+
*
|
|
27758
|
+
* CLIP's input is square, so a tall bbox resized straight to NxN is squashed
|
|
27759
|
+
* — a standing person becomes a shape the model never saw. Squaring costs
|
|
27760
|
+
* extra background, which is context the model wants anyway. Off by default
|
|
27761
|
+
* because the live path has never squared and the stored index reflects that.
|
|
27762
|
+
*/
|
|
27763
|
+
square: boolean()
|
|
27764
|
+
});
|
|
27765
|
+
({
|
|
27766
|
+
paddingRatio: .15,
|
|
27767
|
+
square: false
|
|
27768
|
+
}).paddingRatio;
|
|
27622
27769
|
/**
|
|
27623
27770
|
* Deterministic SHA-256 hash of an arbitrary serialisable value. The
|
|
27624
27771
|
* canonical form sorts object keys alphabetically at every depth so two
|
|
@@ -38175,13 +38322,305 @@ var require_src = /* @__PURE__ */ __commonJSMin(((exports) => {
|
|
|
38175
38322
|
__exportStar(require_util(), exports);
|
|
38176
38323
|
}));
|
|
38177
38324
|
//#endregion
|
|
38178
|
-
//#region src/mappers/builders/
|
|
38325
|
+
//#region src/mappers/builders/rtcp-gate.ts
|
|
38179
38326
|
var import_src = require_src();
|
|
38327
|
+
/** `packet@42ms` / `timeout@1000ms` — one compact log field. */
|
|
38328
|
+
function formatRtcpGate(resolution) {
|
|
38329
|
+
if (resolution === null) return "pending";
|
|
38330
|
+
return `${resolution.reason}@${resolution.waitedMs}ms`;
|
|
38331
|
+
}
|
|
38332
|
+
/**
|
|
38333
|
+
* Resolve as soon as ANY packet arrives on `socket`, or after `timeoutMs`.
|
|
38334
|
+
* One-shot: the listener is detached on either path.
|
|
38335
|
+
*/
|
|
38336
|
+
function makeRtcpGate(socket, timeoutMs) {
|
|
38337
|
+
const armedAt = Date.now();
|
|
38338
|
+
return new Promise((resolve) => {
|
|
38339
|
+
const onMessage = () => {
|
|
38340
|
+
socket.removeListener("message", onMessage);
|
|
38341
|
+
clearTimeout(timer);
|
|
38342
|
+
resolve({
|
|
38343
|
+
reason: "packet",
|
|
38344
|
+
waitedMs: Date.now() - armedAt
|
|
38345
|
+
});
|
|
38346
|
+
};
|
|
38347
|
+
const timer = setTimeout(() => {
|
|
38348
|
+
socket.removeListener("message", onMessage);
|
|
38349
|
+
resolve({
|
|
38350
|
+
reason: "timeout",
|
|
38351
|
+
waitedMs: Date.now() - armedAt
|
|
38352
|
+
});
|
|
38353
|
+
}, timeoutMs);
|
|
38354
|
+
socket.on("message", onMessage);
|
|
38355
|
+
});
|
|
38356
|
+
}
|
|
38357
|
+
/**
|
|
38358
|
+
* The resolutions we offer, before rates are attached. Same list the delegate
|
|
38359
|
+
* advertised before R2 — only the frame rate changes, so a controller that had
|
|
38360
|
+
* already negotiated a resolution keeps finding it.
|
|
38361
|
+
*/
|
|
38362
|
+
var CANDIDATE_RESOLUTIONS = [
|
|
38363
|
+
[1920, 1080],
|
|
38364
|
+
[1280, 720],
|
|
38365
|
+
[1024, 768],
|
|
38366
|
+
[640, 480],
|
|
38367
|
+
[640, 360],
|
|
38368
|
+
[480, 360],
|
|
38369
|
+
[480, 270],
|
|
38370
|
+
[320, 240],
|
|
38371
|
+
[320, 180]
|
|
38372
|
+
];
|
|
38373
|
+
/**
|
|
38374
|
+
* Resolve the real frame rate of each profile slot.
|
|
38375
|
+
*
|
|
38376
|
+
* A slot with no measurement, no publication and no assignment simply does not
|
|
38377
|
+
* appear in the map — the caller then advertises {@link ASSUMED_FPS} and says
|
|
38378
|
+
* so. Absence is never silently rendered as a number.
|
|
38379
|
+
*/
|
|
38380
|
+
function resolveProfileFps(input) {
|
|
38381
|
+
const publishedByProfile = publishedFpsByProfile(input.slots, input.camStreams);
|
|
38382
|
+
const out = /* @__PURE__ */ new Map();
|
|
38383
|
+
for (const choice of input.choices) {
|
|
38384
|
+
if (choice.target.kind !== "profile") continue;
|
|
38385
|
+
const profile = choice.target.profile;
|
|
38386
|
+
const measured = clampFps(choice.inputFps);
|
|
38387
|
+
if (measured !== null) {
|
|
38388
|
+
out.set(profile, {
|
|
38389
|
+
profile,
|
|
38390
|
+
fps: measured,
|
|
38391
|
+
source: "measured"
|
|
38392
|
+
});
|
|
38393
|
+
continue;
|
|
38394
|
+
}
|
|
38395
|
+
const published = clampFps(publishedByProfile.get(profile) ?? null);
|
|
38396
|
+
out.set(profile, published !== null ? {
|
|
38397
|
+
profile,
|
|
38398
|
+
fps: published,
|
|
38399
|
+
source: "published"
|
|
38400
|
+
} : {
|
|
38401
|
+
profile,
|
|
38402
|
+
fps: 30,
|
|
38403
|
+
source: "assumed"
|
|
38404
|
+
});
|
|
38405
|
+
}
|
|
38406
|
+
for (const [profile, fps] of publishedByProfile) {
|
|
38407
|
+
if (out.has(profile)) continue;
|
|
38408
|
+
const published = clampFps(fps);
|
|
38409
|
+
if (published !== null) out.set(profile, {
|
|
38410
|
+
profile,
|
|
38411
|
+
fps: published,
|
|
38412
|
+
source: "published"
|
|
38413
|
+
});
|
|
38414
|
+
}
|
|
38415
|
+
return out;
|
|
38416
|
+
}
|
|
38417
|
+
/**
|
|
38418
|
+
* Attach a frame rate to each candidate resolution by asking the REAL picker
|
|
38419
|
+
* which slot the START path would dial for it. That coupling is the point: if
|
|
38420
|
+
* the picker's steering changes, the advertisement changes with it instead of
|
|
38421
|
+
* drifting into a second, silently different opinion.
|
|
38422
|
+
*/
|
|
38423
|
+
function deriveAdvertisedResolutions(input) {
|
|
38424
|
+
const candidates = input.candidates.length > 0 ? input.candidates : CANDIDATE_RESOLUTIONS;
|
|
38425
|
+
const seen = /* @__PURE__ */ new Set();
|
|
38426
|
+
const out = [];
|
|
38427
|
+
for (const [width, height] of candidates) {
|
|
38428
|
+
const picked = pickPreferredRtspEntry(input.entries, input.pref, input.deviceId, { targetResolution: {
|
|
38429
|
+
width,
|
|
38430
|
+
height
|
|
38431
|
+
} });
|
|
38432
|
+
const profile = picked === null ? null : toCamProfile(picked.profileId);
|
|
38433
|
+
const resolved = profile === null ? void 0 : input.fpsByProfile.get(profile);
|
|
38434
|
+
const advertised = {
|
|
38435
|
+
width,
|
|
38436
|
+
height,
|
|
38437
|
+
fps: resolved?.fps ?? 30,
|
|
38438
|
+
profile,
|
|
38439
|
+
source: resolved?.source ?? "assumed"
|
|
38440
|
+
};
|
|
38441
|
+
const key = `${advertised.width}x${advertised.height}@${advertised.fps}`;
|
|
38442
|
+
if (seen.has(key)) continue;
|
|
38443
|
+
seen.add(key);
|
|
38444
|
+
out.push(advertised);
|
|
38445
|
+
}
|
|
38446
|
+
return out;
|
|
38447
|
+
}
|
|
38448
|
+
/** Project onto the `[width, height, fps]` triples hap-nodejs expects. */
|
|
38449
|
+
function toHapResolutions(advertised) {
|
|
38450
|
+
return advertised.map((a) => [
|
|
38451
|
+
a.width,
|
|
38452
|
+
a.height,
|
|
38453
|
+
a.fps
|
|
38454
|
+
]);
|
|
38455
|
+
}
|
|
38456
|
+
/** Compact `1280x720@10(measured)` rendering for a single log field. */
|
|
38457
|
+
function formatAdvertisedResolutions(advertised) {
|
|
38458
|
+
return advertised.map((a) => `${a.width}x${a.height}@${a.fps}/${a.profile ?? "-"}(${a.source})`);
|
|
38459
|
+
}
|
|
38460
|
+
function publishedFpsByProfile(slots, camStreams) {
|
|
38461
|
+
const fpsByCamStream = /* @__PURE__ */ new Map();
|
|
38462
|
+
for (const stream of camStreams) if (typeof stream.fps === "number") fpsByCamStream.set(stream.camStreamId, stream.fps);
|
|
38463
|
+
const out = /* @__PURE__ */ new Map();
|
|
38464
|
+
for (const slot of slots) {
|
|
38465
|
+
if (slot.sourceCamStreamId === null) continue;
|
|
38466
|
+
const fps = fpsByCamStream.get(slot.sourceCamStreamId);
|
|
38467
|
+
if (fps !== void 0) out.set(slot.profile, fps);
|
|
38468
|
+
}
|
|
38469
|
+
return out;
|
|
38470
|
+
}
|
|
38471
|
+
/**
|
|
38472
|
+
* Coerce a probe reading into an advertisable integer rate, or null when the
|
|
38473
|
+
* reading carries no information (absent, zero because the broker is idle,
|
|
38474
|
+
* negative, NaN, or below the representable floor).
|
|
38475
|
+
*/
|
|
38476
|
+
function clampFps(value) {
|
|
38477
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return null;
|
|
38478
|
+
const floored = Math.floor(value);
|
|
38479
|
+
if (floored < 1) return null;
|
|
38480
|
+
return Math.min(floored, 30);
|
|
38481
|
+
}
|
|
38482
|
+
var CAM_PROFILES = [
|
|
38483
|
+
"high",
|
|
38484
|
+
"mid",
|
|
38485
|
+
"low"
|
|
38486
|
+
];
|
|
38487
|
+
/**
|
|
38488
|
+
* `PickedStream.profileId` is the brokerId suffix, which for a profile-keyed
|
|
38489
|
+
* entry IS the profile name. Anything else (a raw cam-stream id) is not a
|
|
38490
|
+
* profile and must not be coerced into one.
|
|
38491
|
+
*/
|
|
38492
|
+
function toCamProfile(profileId) {
|
|
38493
|
+
return CAM_PROFILES.find((p) => p === profileId) ?? null;
|
|
38494
|
+
}
|
|
38495
|
+
//#endregion
|
|
38496
|
+
//#region src/mappers/builders/stream-telemetry.ts
|
|
38497
|
+
/**
|
|
38498
|
+
* Every branch on the streaming path that discards work, and its starting
|
|
38499
|
+
* count. This object is the single source of the reason set: the union below
|
|
38500
|
+
* is its `keyof`, so adding a silent `return` without adding a reason here is
|
|
38501
|
+
* a compile error rather than an invisible hole.
|
|
38502
|
+
*/
|
|
38503
|
+
var ZERO_DROP_COUNTERS = {
|
|
38504
|
+
/** Outbound: the leg has no SRTP session (prepareStream init failed). */
|
|
38505
|
+
"no-srtp-session": 0,
|
|
38506
|
+
/** Outbound: the leg has no return-path gate — forwarding was never armed. */
|
|
38507
|
+
"no-gate": 0,
|
|
38508
|
+
/** Outbound: werift refused to encrypt the packet. */
|
|
38509
|
+
"srtp-encrypt-failed": 0,
|
|
38510
|
+
/** Outbound: the UDP send itself errored. */
|
|
38511
|
+
"srtp-send-failed": 0,
|
|
38512
|
+
/** Outbound RTCP: the leg has no SRTCP context, so no Sender Report can go out. */
|
|
38513
|
+
"rtcp-no-srtcp": 0,
|
|
38514
|
+
/** Outbound RTCP: building or encrypting the Sender Report failed. */
|
|
38515
|
+
"rtcp-encrypt-failed": 0,
|
|
38516
|
+
/** Upstream: packet shorter than an RTP header. */
|
|
38517
|
+
"upstream-short-packet": 0,
|
|
38518
|
+
/** Upstream: no inbound SRTP context (init failed at prepareStream). */
|
|
38519
|
+
"upstream-no-srtp": 0,
|
|
38520
|
+
/** Upstream: SRTP authentication/decrypt failed. */
|
|
38521
|
+
"upstream-decrypt-failed": 0,
|
|
38522
|
+
/** Upstream: decrypted bytes did not parse as RTP. */
|
|
38523
|
+
"upstream-parse-failed": 0,
|
|
38524
|
+
/** Upstream: audio arrived before `handleStreamRequest('start')` settled. */
|
|
38525
|
+
"upstream-before-start": 0,
|
|
38526
|
+
/** Upstream: payload type is not the negotiated audio PT (RTCP / keepalive). */
|
|
38527
|
+
"upstream-pt-mismatch": 0,
|
|
38528
|
+
/** Upstream: RTP carried a zero-length payload. */
|
|
38529
|
+
"upstream-empty-payload": 0,
|
|
38530
|
+
/** Upstream: no camera-side talk session, so the frame has nowhere to go. */
|
|
38531
|
+
"upstream-no-talk-session": 0,
|
|
38532
|
+
/** Upstream: the camera-side `pushTalkAudio` rejected the frame. */
|
|
38533
|
+
"upstream-push-failed": 0,
|
|
38534
|
+
/** Snapshot: the `snapshot` cap returned nothing. */
|
|
38535
|
+
"snapshot-unavailable": 0
|
|
38536
|
+
};
|
|
38537
|
+
function emptyDropCounters() {
|
|
38538
|
+
return { ...ZERO_DROP_COUNTERS };
|
|
38539
|
+
}
|
|
38540
|
+
/** Immutable increment — returns a new record, never touches the input. */
|
|
38541
|
+
function recordDrop(counters, reason) {
|
|
38542
|
+
return {
|
|
38543
|
+
...counters,
|
|
38544
|
+
[reason]: counters[reason] + 1
|
|
38545
|
+
};
|
|
38546
|
+
}
|
|
38547
|
+
/** Only the reasons that actually fired, so a clean session logs `{}`. */
|
|
38548
|
+
function nonZeroDrops(counters) {
|
|
38549
|
+
const out = {};
|
|
38550
|
+
for (const [reason, count] of Object.entries(counters)) if (count > 0) out[reason] = count;
|
|
38551
|
+
return out;
|
|
38552
|
+
}
|
|
38553
|
+
/**
|
|
38554
|
+
* Classify a packet received on one of our advertised return ports.
|
|
38555
|
+
*
|
|
38556
|
+
* RTP and RTCP arrive on the same symmetric port here. RTCP packet types are
|
|
38557
|
+
* 200..207, which sit inside the 192..223 band RFC 5761 §4 reserves precisely
|
|
38558
|
+
* so the two can be told apart on a shared socket (an RTP packet's marker bit
|
|
38559
|
+
* plus payload type can never land there for any payload type we negotiate).
|
|
38560
|
+
* The payloads are SRTP/SRTCP-encrypted, but both keep the first two bytes in
|
|
38561
|
+
* the clear.
|
|
38562
|
+
*/
|
|
38563
|
+
function classifyInboundPacket(packet) {
|
|
38564
|
+
if (packet.length < 2) return "malformed";
|
|
38565
|
+
if ((packet[0] >> 6 & 3) !== 2) return "malformed";
|
|
38566
|
+
const typeByte = packet[1];
|
|
38567
|
+
if (typeByte >= 192 && typeByte <= 223) return "rtcp";
|
|
38568
|
+
return "rtp";
|
|
38569
|
+
}
|
|
38570
|
+
/**
|
|
38571
|
+
* Build the meta for `export-hap: stream session summary` — the single line a
|
|
38572
|
+
* future session greps to answer "why did this session die".
|
|
38573
|
+
*/
|
|
38574
|
+
function summariseSession(snapshot) {
|
|
38575
|
+
const durationMs = snapshot.startedAtMs !== null && snapshot.endedAtMs !== null ? snapshot.endedAtMs - snapshot.startedAtMs : null;
|
|
38576
|
+
const negotiated = snapshot.negotiated;
|
|
38577
|
+
const slot = snapshot.selectedSlot;
|
|
38578
|
+
return {
|
|
38579
|
+
sessionId: snapshot.sessionId,
|
|
38580
|
+
durationMs,
|
|
38581
|
+
stopRequestedByController: snapshot.stopRequestedByController,
|
|
38582
|
+
ffmpegExitCode: snapshot.ffmpegExit?.code ?? null,
|
|
38583
|
+
ffmpegExitSignal: snapshot.ffmpegExit?.signal ?? null,
|
|
38584
|
+
negotiatedResolution: negotiated ? `${negotiated.width}x${negotiated.height}` : null,
|
|
38585
|
+
negotiatedFps: negotiated?.fps ?? null,
|
|
38586
|
+
negotiatedMaxBitrateKbps: negotiated?.maxBitrateKbps ?? null,
|
|
38587
|
+
selectedProfile: slot?.profile ?? null,
|
|
38588
|
+
selectedBrokerId: slot?.brokerId ?? null,
|
|
38589
|
+
advertisedFps: slot?.advertisedFps ?? null,
|
|
38590
|
+
advertisedFpsSource: slot?.advertisedFpsSource ?? null,
|
|
38591
|
+
transcode: slot?.transcode ?? null,
|
|
38592
|
+
videoPacketsForwarded: snapshot.videoPacketsForwarded,
|
|
38593
|
+
audioPacketsForwarded: snapshot.audioPacketsForwarded,
|
|
38594
|
+
videoRtcpSrSent: snapshot.videoRtcpSrSent,
|
|
38595
|
+
audioRtcpSrSent: snapshot.audioRtcpSrSent,
|
|
38596
|
+
videoRtcpReceived: snapshot.videoRtcpReceived,
|
|
38597
|
+
audioRtcpReceived: snapshot.audioRtcpReceived,
|
|
38598
|
+
videoRtpReceived: snapshot.videoRtpReceived,
|
|
38599
|
+
audioRtpReceived: snapshot.audioRtpReceived,
|
|
38600
|
+
videoGate: formatRtcpGate(snapshot.videoGate),
|
|
38601
|
+
audioGate: formatRtcpGate(snapshot.audioGate),
|
|
38602
|
+
mediaStarved: snapshot.videoPacketsForwarded === 0,
|
|
38603
|
+
drops: nonZeroDrops(snapshot.drops)
|
|
38604
|
+
};
|
|
38605
|
+
}
|
|
38606
|
+
//#endregion
|
|
38607
|
+
//#region src/mappers/builders/camera-streams.ts
|
|
38180
38608
|
var SRTP_KEY_LEN = 16;
|
|
38181
38609
|
var SRTP_SALT_LEN = 14;
|
|
38610
|
+
/**
|
|
38611
|
+
* Cadence of the per-session heartbeat log.
|
|
38612
|
+
*
|
|
38613
|
+
* This timer LOGS ONLY. It never kills ffmpeg, never closes a socket and never
|
|
38614
|
+
* ends a session. That distinction matters: the two mainstream open-source
|
|
38615
|
+
* HomeKit camera stacks arm an idle watchdog that tears the stream down at
|
|
38616
|
+
* exactly 30 000 ms, and the ~31 s teardown here was misdiagnosed as one for a
|
|
38617
|
+
* long time before it was proven that this accessory has no such timer. Do not
|
|
38618
|
+
* make this one act.
|
|
38619
|
+
*/
|
|
38620
|
+
var SESSION_HEARTBEAT_MS = 5e3;
|
|
38182
38621
|
var OPUS_BITRATE_KBPS = 24;
|
|
38183
38622
|
var OPUS_CHANNELS = 1;
|
|
38184
|
-
function buildCameraStreamingDelegate(bctx) {
|
|
38623
|
+
function buildCameraStreamingDelegate(bctx, advertised) {
|
|
38185
38624
|
const { ctx, numericDeviceId } = bctx;
|
|
38186
38625
|
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
38187
38626
|
const sessions = /* @__PURE__ */ new Map();
|
|
@@ -38200,7 +38639,7 @@ function buildCameraStreamingDelegate(bctx) {
|
|
|
38200
38639
|
});
|
|
38201
38640
|
},
|
|
38202
38641
|
handleStreamRequest(request, callback) {
|
|
38203
|
-
handleStreamRequest(request, sessions, bctx).then(() => callback()).catch((err) => {
|
|
38642
|
+
handleStreamRequest(request, sessions, bctx, advertised).then(() => callback()).catch((err) => {
|
|
38204
38643
|
log.warn("export-hap: handleStreamRequest failed", { meta: { error: errMsg$8(err) } });
|
|
38205
38644
|
callback(err instanceof Error ? err : new Error(errMsg$8(err)));
|
|
38206
38645
|
});
|
|
@@ -38221,58 +38660,7 @@ function buildCameraStreamingDelegate(bctx) {
|
|
|
38221
38660
|
_homebridge_hap_nodejs.H264Level.LEVEL4_0
|
|
38222
38661
|
]
|
|
38223
38662
|
},
|
|
38224
|
-
resolutions:
|
|
38225
|
-
[
|
|
38226
|
-
1920,
|
|
38227
|
-
1080,
|
|
38228
|
-
30
|
|
38229
|
-
],
|
|
38230
|
-
[
|
|
38231
|
-
1280,
|
|
38232
|
-
720,
|
|
38233
|
-
30
|
|
38234
|
-
],
|
|
38235
|
-
[
|
|
38236
|
-
1024,
|
|
38237
|
-
768,
|
|
38238
|
-
30
|
|
38239
|
-
],
|
|
38240
|
-
[
|
|
38241
|
-
640,
|
|
38242
|
-
480,
|
|
38243
|
-
30
|
|
38244
|
-
],
|
|
38245
|
-
[
|
|
38246
|
-
640,
|
|
38247
|
-
360,
|
|
38248
|
-
30
|
|
38249
|
-
],
|
|
38250
|
-
[
|
|
38251
|
-
480,
|
|
38252
|
-
360,
|
|
38253
|
-
30
|
|
38254
|
-
],
|
|
38255
|
-
[
|
|
38256
|
-
480,
|
|
38257
|
-
270,
|
|
38258
|
-
30
|
|
38259
|
-
],
|
|
38260
|
-
[
|
|
38261
|
-
320,
|
|
38262
|
-
240,
|
|
38263
|
-
30
|
|
38264
|
-
],
|
|
38265
|
-
[
|
|
38266
|
-
320,
|
|
38267
|
-
240,
|
|
38268
|
-
15
|
|
38269
|
-
],
|
|
38270
|
-
[
|
|
38271
|
-
320,
|
|
38272
|
-
180,
|
|
38273
|
-
30
|
|
38274
|
-
]
|
|
38275
|
-
]
|
|
38663
|
+
resolutions: toHapResolutions(advertised.resolutions)
|
|
38276
38664
|
},
|
|
38277
38665
|
audio: {
|
|
38278
38666
|
codecs: [{
|
|
@@ -38287,10 +38675,16 @@ function buildCameraStreamingDelegate(bctx) {
|
|
|
38287
38675
|
},
|
|
38288
38676
|
dispose: async () => {
|
|
38289
38677
|
for (const session of sessions.values()) {
|
|
38678
|
+
session.teardownTrigger = "accessory-dispose";
|
|
38679
|
+
const hadFfmpeg = session.ffmpeg !== null;
|
|
38290
38680
|
killFfmpeg(session, ctx, numericDeviceId);
|
|
38681
|
+
stopHeartbeat(session);
|
|
38682
|
+
if (!hadFfmpeg) logSessionSummary(session, log, "accessory-dispose-no-ffmpeg");
|
|
38291
38683
|
await closeIntercomTalkSession(session, bctx).catch(() => void 0);
|
|
38292
38684
|
closeSocket(session.videoUdp);
|
|
38293
38685
|
closeSocket(session.audioUdp);
|
|
38686
|
+
closeSocket(session.videoLoopUdp);
|
|
38687
|
+
closeSocket(session.audioLoopUdp);
|
|
38294
38688
|
}
|
|
38295
38689
|
sessions.clear();
|
|
38296
38690
|
}
|
|
@@ -38300,7 +38694,10 @@ async function handleSnapshot(bctx, request) {
|
|
|
38300
38694
|
const { proxy, ctx, numericDeviceId } = bctx;
|
|
38301
38695
|
const result = await proxy.snapshot?.getSnapshot({});
|
|
38302
38696
|
if (!result || typeof result.base64 !== "string") {
|
|
38303
|
-
ctx.logger.withTags({ deviceId: numericDeviceId }).
|
|
38697
|
+
ctx.logger.withTags({ deviceId: numericDeviceId }).info("export-hap: snapshot dropped", { meta: {
|
|
38698
|
+
reason: "snapshot-unavailable",
|
|
38699
|
+
capBound: proxy.snapshot !== void 0
|
|
38700
|
+
} });
|
|
38304
38701
|
throw new Error("snapshot unavailable");
|
|
38305
38702
|
}
|
|
38306
38703
|
return Buffer.from(result.base64, "base64");
|
|
@@ -38372,6 +38769,26 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
38372
38769
|
const videoSsrc = randomSsrc();
|
|
38373
38770
|
const audioSsrc = randomSsrc();
|
|
38374
38771
|
const session = {
|
|
38772
|
+
sessionId: request.sessionID,
|
|
38773
|
+
startedAtMs: null,
|
|
38774
|
+
endedAtMs: null,
|
|
38775
|
+
negotiated: null,
|
|
38776
|
+
selectedSlot: null,
|
|
38777
|
+
ffmpegExit: null,
|
|
38778
|
+
stopRequestedByController: false,
|
|
38779
|
+
teardownTrigger: null,
|
|
38780
|
+
drops: emptyDropCounters(),
|
|
38781
|
+
videoPacketsForwarded: 0,
|
|
38782
|
+
audioPacketsForwarded: 0,
|
|
38783
|
+
videoRtcpSrSent: 0,
|
|
38784
|
+
audioRtcpSrSent: 0,
|
|
38785
|
+
videoRtcpReceived: 0,
|
|
38786
|
+
audioRtcpReceived: 0,
|
|
38787
|
+
videoRtpReceived: 0,
|
|
38788
|
+
audioRtpReceived: 0,
|
|
38789
|
+
videoGate: null,
|
|
38790
|
+
audioGate: null,
|
|
38791
|
+
heartbeat: null,
|
|
38375
38792
|
hapVideoPort: request.video.port,
|
|
38376
38793
|
hapAddress: request.targetAddress,
|
|
38377
38794
|
videoSrtpKey: request.video.srtp_key,
|
|
@@ -38416,19 +38833,33 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
38416
38833
|
audioRtcpIntervalMs: 5e3
|
|
38417
38834
|
};
|
|
38418
38835
|
sessions.set(request.sessionID, session);
|
|
38836
|
+
const tagLog = bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId });
|
|
38419
38837
|
session.videoSendGate = makeRtcpGate(videoUdp, 1e3);
|
|
38420
38838
|
session.audioSendGate = makeRtcpGate(audioUdp, 1e3);
|
|
38421
|
-
|
|
38422
|
-
|
|
38423
|
-
|
|
38839
|
+
session.videoSendGate.then((resolution) => {
|
|
38840
|
+
session.videoGate = resolution;
|
|
38841
|
+
logGateResolved(session, "video", resolution, tagLog);
|
|
38842
|
+
});
|
|
38843
|
+
session.audioSendGate.then((resolution) => {
|
|
38844
|
+
session.audioGate = resolution;
|
|
38845
|
+
logGateResolved(session, "audio", resolution, tagLog);
|
|
38846
|
+
});
|
|
38847
|
+
videoUdp.on("message", (packet) => {
|
|
38848
|
+
countInbound(session, "video", packet, tagLog);
|
|
38849
|
+
});
|
|
38850
|
+
audioUdp.on("message", (packet) => {
|
|
38851
|
+
countInbound(session, "audio", packet, tagLog);
|
|
38852
|
+
});
|
|
38424
38853
|
videoLoopUdp.on("message", (rtpPacket) => {
|
|
38425
|
-
videoPacketsForwarded += 1;
|
|
38426
|
-
if (videoPacketsForwarded === 1
|
|
38854
|
+
session.videoPacketsForwarded += 1;
|
|
38855
|
+
if (session.videoPacketsForwarded === 1) tagLog.info("export-hap: first video packet from ffmpeg", { meta: {
|
|
38856
|
+
sessionId: session.sessionId,
|
|
38857
|
+
bytes: rtpPacket.length
|
|
38858
|
+
} });
|
|
38427
38859
|
forwardEncryptedRtp(session, rtpPacket, "video", tagLog);
|
|
38428
38860
|
});
|
|
38429
38861
|
audioLoopUdp.on("message", (rtpPacket) => {
|
|
38430
|
-
audioPacketsForwarded += 1;
|
|
38431
|
-
if (audioPacketsForwarded === 1 || audioPacketsForwarded % 100 === 0) tagLog.info("export-hap: audio loopback packets forwarded", { meta: { count: audioPacketsForwarded } });
|
|
38862
|
+
session.audioPacketsForwarded += 1;
|
|
38432
38863
|
forwardEncryptedRtp(session, rtpPacket, "audio", tagLog);
|
|
38433
38864
|
});
|
|
38434
38865
|
audioUdp.on("message", (packet, rinfo) => {
|
|
@@ -38436,6 +38867,19 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
38436
38867
|
bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId }).debug("export-hap: incoming-audio handler error (dropped)", { meta: { error: errMsg$8(err) } });
|
|
38437
38868
|
});
|
|
38438
38869
|
});
|
|
38870
|
+
tagLog.info("export-hap: stream prepared", { meta: {
|
|
38871
|
+
sessionId: request.sessionID,
|
|
38872
|
+
controllerAddress: request.targetAddress,
|
|
38873
|
+
addressVersion: ipVersion,
|
|
38874
|
+
localIp,
|
|
38875
|
+
advertisedVideoPort: localVideoPort,
|
|
38876
|
+
advertisedAudioPort: localAudioPort,
|
|
38877
|
+
controllerVideoPort: request.video.port,
|
|
38878
|
+
controllerAudioPort: request.audio.port,
|
|
38879
|
+
videoSsrc,
|
|
38880
|
+
audioSsrc,
|
|
38881
|
+
upstreamAudioDecrypt: upstreamAudioSrtp !== null
|
|
38882
|
+
} });
|
|
38439
38883
|
return {
|
|
38440
38884
|
video: {
|
|
38441
38885
|
port: localVideoPort,
|
|
@@ -38510,30 +38954,110 @@ function sameIpv4Subnet(a, mask, b) {
|
|
|
38510
38954
|
async function bindLoopback(ipVersion) {
|
|
38511
38955
|
return bindUdp(ipVersion, ipVersion === "ipv6" ? "::1" : "127.0.0.1");
|
|
38512
38956
|
}
|
|
38957
|
+
/** Book a named drop. Every silent `return` on the streaming path routes here. */
|
|
38958
|
+
function drop(session, reason) {
|
|
38959
|
+
session.drops = recordDrop(session.drops, reason);
|
|
38960
|
+
}
|
|
38513
38961
|
/**
|
|
38514
|
-
*
|
|
38515
|
-
*
|
|
38516
|
-
*
|
|
38517
|
-
* iOS sees a return-path handshake before our actual stream begins.
|
|
38518
|
-
*
|
|
38519
|
-
* One-shot: the listener is removed on the first message OR on
|
|
38520
|
-
* timeout — the gate then stays resolved for the rest of the session.
|
|
38962
|
+
* Report a gate resolution. `timeout` is the interesting one: it means we are
|
|
38963
|
+
* about to stream into a port the controller never touched, and it was
|
|
38964
|
+
* indistinguishable from a successful probe until now.
|
|
38521
38965
|
*/
|
|
38522
|
-
function
|
|
38523
|
-
|
|
38524
|
-
|
|
38525
|
-
|
|
38526
|
-
|
|
38527
|
-
|
|
38528
|
-
|
|
38529
|
-
|
|
38530
|
-
|
|
38531
|
-
|
|
38532
|
-
|
|
38533
|
-
|
|
38966
|
+
function logGateResolved(session, leg, resolution, log) {
|
|
38967
|
+
const meta = {
|
|
38968
|
+
sessionId: session.sessionId,
|
|
38969
|
+
leg,
|
|
38970
|
+
reason: resolution.reason,
|
|
38971
|
+
waitedMs: resolution.waitedMs
|
|
38972
|
+
};
|
|
38973
|
+
if (resolution.reason === "packet") log.info("export-hap: RTCP gate opened on a REAL controller packet", { meta });
|
|
38974
|
+
else log.warn("export-hap: RTCP gate opened on the 1s FALLBACK — controller never probed", { meta });
|
|
38975
|
+
}
|
|
38976
|
+
/** Count and classify one packet the controller sent to a return port. */
|
|
38977
|
+
function countInbound(session, leg, packet, log) {
|
|
38978
|
+
const kind = classifyInboundPacket(packet);
|
|
38979
|
+
const firstRtcp = kind === "rtcp" && (leg === "video" ? session.videoRtcpReceived : session.audioRtcpReceived) === 0;
|
|
38980
|
+
if (leg === "video") {
|
|
38981
|
+
if (kind === "rtcp") session.videoRtcpReceived += 1;
|
|
38982
|
+
else if (kind === "rtp") session.videoRtpReceived += 1;
|
|
38983
|
+
} else if (kind === "rtcp") session.audioRtcpReceived += 1;
|
|
38984
|
+
else if (kind === "rtp") session.audioRtpReceived += 1;
|
|
38985
|
+
if (firstRtcp) log.info("export-hap: first inbound RTCP from controller", { meta: {
|
|
38986
|
+
sessionId: session.sessionId,
|
|
38987
|
+
leg,
|
|
38988
|
+
bytes: packet.length
|
|
38989
|
+
} });
|
|
38990
|
+
}
|
|
38991
|
+
/** Snapshot every counter into the summary meta. */
|
|
38992
|
+
function sessionSummaryMeta(session) {
|
|
38993
|
+
return summariseSession({
|
|
38994
|
+
sessionId: session.sessionId,
|
|
38995
|
+
startedAtMs: session.startedAtMs,
|
|
38996
|
+
endedAtMs: session.endedAtMs,
|
|
38997
|
+
negotiated: session.negotiated,
|
|
38998
|
+
selectedSlot: session.selectedSlot,
|
|
38999
|
+
videoPacketsForwarded: session.videoPacketsForwarded,
|
|
39000
|
+
audioPacketsForwarded: session.audioPacketsForwarded,
|
|
39001
|
+
videoRtcpSrSent: session.videoRtcpSrSent,
|
|
39002
|
+
audioRtcpSrSent: session.audioRtcpSrSent,
|
|
39003
|
+
videoRtcpReceived: session.videoRtcpReceived,
|
|
39004
|
+
audioRtcpReceived: session.audioRtcpReceived,
|
|
39005
|
+
videoRtpReceived: session.videoRtpReceived,
|
|
39006
|
+
audioRtpReceived: session.audioRtpReceived,
|
|
39007
|
+
videoGate: session.videoGate,
|
|
39008
|
+
audioGate: session.audioGate,
|
|
39009
|
+
drops: session.drops,
|
|
39010
|
+
ffmpegExit: session.ffmpegExit,
|
|
39011
|
+
stopRequestedByController: session.stopRequestedByController
|
|
38534
39012
|
});
|
|
38535
39013
|
}
|
|
38536
39014
|
/**
|
|
39015
|
+
* Arm the per-session heartbeat. LOGS ONLY — see `SESSION_HEARTBEAT_MS`. It
|
|
39016
|
+
* exists so a session that stops producing media says so while it is still
|
|
39017
|
+
* alive, instead of being reconstructed after the fact from its final line.
|
|
39018
|
+
*/
|
|
39019
|
+
function armHeartbeat(session, log) {
|
|
39020
|
+
if (session.heartbeat) clearInterval(session.heartbeat);
|
|
39021
|
+
let lastVideo = session.videoPacketsForwarded;
|
|
39022
|
+
const timer = setInterval(() => {
|
|
39023
|
+
const forwarded = session.videoPacketsForwarded - lastVideo;
|
|
39024
|
+
lastVideo = session.videoPacketsForwarded;
|
|
39025
|
+
log.info("export-hap: stream heartbeat", { meta: {
|
|
39026
|
+
...sessionSummaryMeta(session),
|
|
39027
|
+
videoPacketsSinceLastBeat: forwarded,
|
|
39028
|
+
videoStalled: forwarded === 0
|
|
39029
|
+
} });
|
|
39030
|
+
}, SESSION_HEARTBEAT_MS);
|
|
39031
|
+
timer.unref();
|
|
39032
|
+
session.heartbeat = timer;
|
|
39033
|
+
}
|
|
39034
|
+
function stopHeartbeat(session) {
|
|
39035
|
+
if (!session.heartbeat) return;
|
|
39036
|
+
clearInterval(session.heartbeat);
|
|
39037
|
+
session.heartbeat = null;
|
|
39038
|
+
}
|
|
39039
|
+
/**
|
|
39040
|
+
* **`export-hap: stream session summary` is the line to grep** when asking why
|
|
39041
|
+
* a HomeKit session died. It carries the negotiated parameters, the slot we
|
|
39042
|
+
* dialled and the rate we had promised for it, packet counts in both
|
|
39043
|
+
* directions on both legs, each leg's gate outcome, ffmpeg's exit, who asked
|
|
39044
|
+
* for the teardown, and every named drop.
|
|
39045
|
+
*
|
|
39046
|
+
* Emitted from the ffmpeg `exit` handler — the only place the exit code is
|
|
39047
|
+
* known — and directly from the teardown paths when there is no ffmpeg to wait
|
|
39048
|
+
* for. It deliberately does NOT touch the heartbeat: iOS restarts a session
|
|
39049
|
+
* in place when it is not getting a picture (one on record was started three
|
|
39050
|
+
* times in 16 s), and stopping the heartbeat on the superseded process's exit
|
|
39051
|
+
* would silence the replacement.
|
|
39052
|
+
*/
|
|
39053
|
+
function logSessionSummary(session, log, trigger) {
|
|
39054
|
+
session.endedAtMs = Date.now();
|
|
39055
|
+
log.info("export-hap: stream session summary", { meta: {
|
|
39056
|
+
...sessionSummaryMeta(session),
|
|
39057
|
+
trigger
|
|
39058
|
+
} });
|
|
39059
|
+
}
|
|
39060
|
+
/**
|
|
38537
39061
|
* Build the NTP timestamp expected in an RTCP Sender Report.
|
|
38538
39062
|
*
|
|
38539
39063
|
* NTP timestamps are 64-bit: upper 32 bits = seconds since 1900-01-01,
|
|
@@ -38566,7 +39090,10 @@ function ntpTime() {
|
|
|
38566
39090
|
*/
|
|
38567
39091
|
function sendRtcpSr(session, kind, log) {
|
|
38568
39092
|
const srtcp = kind === "video" ? session.videoOutSrtcp : session.audioOutSrtcp;
|
|
38569
|
-
if (!srtcp)
|
|
39093
|
+
if (!srtcp) {
|
|
39094
|
+
drop(session, "rtcp-no-srtcp");
|
|
39095
|
+
return;
|
|
39096
|
+
}
|
|
38570
39097
|
const sink = kind === "video" ? session.videoUdp : session.audioUdp;
|
|
38571
39098
|
const port = kind === "video" ? session.hapVideoPort : session.hapAudioPort;
|
|
38572
39099
|
try {
|
|
@@ -38581,15 +39108,31 @@ function sendRtcpSr(session, kind, log) {
|
|
|
38581
39108
|
});
|
|
38582
39109
|
const encrypted = srtcp.encrypt(sr.serialize());
|
|
38583
39110
|
sink.send(encrypted, port, session.hapAddress, (err) => {
|
|
38584
|
-
if (err)
|
|
38585
|
-
|
|
38586
|
-
error:
|
|
38587
|
-
|
|
39111
|
+
if (err) {
|
|
39112
|
+
drop(session, "srtp-send-failed");
|
|
39113
|
+
log.warn("export-hap: RTCP send error", { meta: {
|
|
39114
|
+
sessionId: session.sessionId,
|
|
39115
|
+
kind,
|
|
39116
|
+
error: err.message
|
|
39117
|
+
} });
|
|
39118
|
+
}
|
|
38588
39119
|
});
|
|
38589
|
-
if (kind === "video")
|
|
38590
|
-
|
|
39120
|
+
if (kind === "video") {
|
|
39121
|
+
session.videoOutLastRtcpAt = Date.now();
|
|
39122
|
+
session.videoRtcpSrSent += 1;
|
|
39123
|
+
if (session.videoRtcpSrSent === 1) log.info("export-hap: first VIDEO RTCP Sender Report sent", { meta: {
|
|
39124
|
+
sessionId: session.sessionId,
|
|
39125
|
+
rtpTimestamp: session.videoOutLastRtpTimestamp,
|
|
39126
|
+
intervalMs: session.videoRtcpIntervalMs
|
|
39127
|
+
} });
|
|
39128
|
+
} else {
|
|
39129
|
+
session.audioOutLastRtcpAt = Date.now();
|
|
39130
|
+
session.audioRtcpSrSent += 1;
|
|
39131
|
+
}
|
|
38591
39132
|
} catch (err) {
|
|
38592
|
-
|
|
39133
|
+
drop(session, "rtcp-encrypt-failed");
|
|
39134
|
+
log.warn("export-hap: RTCP SR build/encrypt failed", { meta: {
|
|
39135
|
+
sessionId: session.sessionId,
|
|
38593
39136
|
kind,
|
|
38594
39137
|
error: err instanceof Error ? err.message : String(err)
|
|
38595
39138
|
} });
|
|
@@ -38611,7 +39154,14 @@ function forwardEncryptedRtp(session, rtpPacket, kind, log) {
|
|
|
38611
39154
|
const sink = kind === "video" ? session.videoUdp : session.audioUdp;
|
|
38612
39155
|
const port = kind === "video" ? session.hapVideoPort : session.hapAudioPort;
|
|
38613
39156
|
const gate = kind === "video" ? session.videoSendGate : session.audioSendGate;
|
|
38614
|
-
if (!srtp
|
|
39157
|
+
if (!srtp) {
|
|
39158
|
+
drop(session, "no-srtp-session");
|
|
39159
|
+
return;
|
|
39160
|
+
}
|
|
39161
|
+
if (!gate) {
|
|
39162
|
+
drop(session, "no-gate");
|
|
39163
|
+
return;
|
|
39164
|
+
}
|
|
38615
39165
|
gate.then(() => {
|
|
38616
39166
|
try {
|
|
38617
39167
|
const parsed = import_src.RtpPacket.deSerialize(rtpPacket);
|
|
@@ -38647,32 +39197,46 @@ function forwardEncryptedRtp(session, rtpPacket, kind, log) {
|
|
|
38647
39197
|
}
|
|
38648
39198
|
const encrypted = srtp.encrypt(parsed.payload, parsed.header);
|
|
38649
39199
|
sink.send(encrypted, port, session.hapAddress, (err) => {
|
|
38650
|
-
if (err)
|
|
38651
|
-
|
|
38652
|
-
error:
|
|
38653
|
-
|
|
39200
|
+
if (err) {
|
|
39201
|
+
drop(session, "srtp-send-failed");
|
|
39202
|
+
log.debug("export-hap: SRTP send error", { meta: {
|
|
39203
|
+
sessionId: session.sessionId,
|
|
39204
|
+
kind,
|
|
39205
|
+
error: err.message
|
|
39206
|
+
} });
|
|
39207
|
+
}
|
|
38654
39208
|
});
|
|
38655
39209
|
const now = Date.now();
|
|
38656
39210
|
if (kind === "video") {
|
|
38657
39211
|
if (firstVideo || now > session.videoOutLastRtcpAt + session.videoRtcpIntervalMs) sendRtcpSr(session, "video", log);
|
|
38658
39212
|
} else if (firstAudio || now > session.audioOutLastRtcpAt + session.audioRtcpIntervalMs) sendRtcpSr(session, "audio", log);
|
|
38659
39213
|
} catch (err) {
|
|
39214
|
+
drop(session, "srtp-encrypt-failed");
|
|
38660
39215
|
log.debug("export-hap: SRTP encrypt failed", { meta: {
|
|
39216
|
+
sessionId: session.sessionId,
|
|
38661
39217
|
kind,
|
|
38662
39218
|
error: err instanceof Error ? err.message : String(err)
|
|
38663
39219
|
} });
|
|
38664
39220
|
}
|
|
38665
39221
|
});
|
|
38666
39222
|
}
|
|
38667
|
-
async function handleStreamRequest(request, sessions, bctx) {
|
|
39223
|
+
async function handleStreamRequest(request, sessions, bctx, advertised) {
|
|
38668
39224
|
const log = bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId });
|
|
38669
39225
|
const session = sessions.get(request.sessionID);
|
|
38670
39226
|
if (!session) {
|
|
38671
|
-
log.warn("export-hap: stream request for unknown session", { meta: {
|
|
39227
|
+
log.warn("export-hap: stream request for unknown session", { meta: {
|
|
39228
|
+
sessionID: request.sessionID,
|
|
39229
|
+
type: request.type
|
|
39230
|
+
} });
|
|
38672
39231
|
return;
|
|
38673
39232
|
}
|
|
38674
39233
|
if (request.type === "stop") {
|
|
39234
|
+
session.stopRequestedByController = true;
|
|
39235
|
+
session.teardownTrigger = "controller-stop";
|
|
39236
|
+
const hadFfmpeg = session.ffmpeg !== null;
|
|
38675
39237
|
killFfmpeg(session, bctx.ctx, bctx.numericDeviceId);
|
|
39238
|
+
stopHeartbeat(session);
|
|
39239
|
+
if (!hadFfmpeg) logSessionSummary(session, log, "controller-stop-no-ffmpeg");
|
|
38676
39240
|
await closeIntercomTalkSession(session, bctx).catch(() => void 0);
|
|
38677
39241
|
closeSocket(session.videoUdp);
|
|
38678
39242
|
closeSocket(session.audioUdp);
|
|
@@ -38698,6 +39262,27 @@ async function handleStreamRequest(request, sessions, bctx) {
|
|
|
38698
39262
|
session.videoOutOctetCount = 0;
|
|
38699
39263
|
session.videoOutLastRtpTimestamp = 0;
|
|
38700
39264
|
session.videoOutLastRtcpAt = 0;
|
|
39265
|
+
session.negotiated = {
|
|
39266
|
+
width: request.video.width,
|
|
39267
|
+
height: request.video.height,
|
|
39268
|
+
fps: request.video.fps,
|
|
39269
|
+
maxBitrateKbps: request.video.max_bit_rate,
|
|
39270
|
+
mtu: request.video.mtu,
|
|
39271
|
+
videoPt: request.video.pt,
|
|
39272
|
+
videoSsrc: session.videoSsrc,
|
|
39273
|
+
videoRtcpIntervalMs: session.videoRtcpIntervalMs,
|
|
39274
|
+
audioPt: request.audio.pt,
|
|
39275
|
+
audioSsrc: session.audioOutSsrc,
|
|
39276
|
+
audioSampleRateKhz: request.audio.sample_rate,
|
|
39277
|
+
audioPacketTimeMs: packetTimeMs,
|
|
39278
|
+
audioRtcpIntervalMs: session.audioRtcpIntervalMs,
|
|
39279
|
+
audioMaxBitrateKbps: request.audio.max_bit_rate
|
|
39280
|
+
};
|
|
39281
|
+
session.startedAtMs = Date.now();
|
|
39282
|
+
log.info("export-hap: stream negotiated", { meta: {
|
|
39283
|
+
sessionId: request.sessionID,
|
|
39284
|
+
...session.negotiated
|
|
39285
|
+
} });
|
|
38701
39286
|
session.lastStartParams = {
|
|
38702
39287
|
pt: request.video.pt,
|
|
38703
39288
|
mtu: request.video.mtu,
|
|
@@ -38718,7 +39303,8 @@ async function handleStreamRequest(request, sessions, bctx) {
|
|
|
38718
39303
|
sample_rate: request.audio.sample_rate,
|
|
38719
39304
|
packet_time: packetTimeMs
|
|
38720
39305
|
};
|
|
38721
|
-
await startFfmpegForSession(bctx, session, request.sessionID, startParams);
|
|
39306
|
+
await startFfmpegForSession(bctx, session, request.sessionID, startParams, advertised);
|
|
39307
|
+
armHeartbeat(session, log);
|
|
38722
39308
|
return;
|
|
38723
39309
|
}
|
|
38724
39310
|
if (request.type === "reconfigure") {
|
|
@@ -38745,23 +39331,69 @@ async function handleStreamRequest(request, sessions, bctx) {
|
|
|
38745
39331
|
sample_rate: session.lastStartParams.audioSampleRateEnum,
|
|
38746
39332
|
packet_time: session.lastStartParams.audioPacketTimeMs
|
|
38747
39333
|
};
|
|
38748
|
-
|
|
39334
|
+
log.info("export-hap: stream reconfigured", { meta: {
|
|
39335
|
+
sessionId: request.sessionID,
|
|
39336
|
+
width: request.video.width,
|
|
39337
|
+
height: request.video.height,
|
|
39338
|
+
fps: request.video.fps,
|
|
39339
|
+
maxBitrateKbps: request.video.max_bit_rate
|
|
39340
|
+
} });
|
|
39341
|
+
await startFfmpegForSession(bctx, session, request.sessionID, startParams, advertised);
|
|
39342
|
+
armHeartbeat(session, log);
|
|
38749
39343
|
}
|
|
38750
39344
|
}
|
|
38751
|
-
async function startFfmpegForSession(bctx, session, sessionId, video) {
|
|
39345
|
+
async function startFfmpegForSession(bctx, session, sessionId, video, advertised) {
|
|
38752
39346
|
const { ctx, proxy, numericDeviceId, options } = bctx;
|
|
39347
|
+
const startLog = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
38753
39348
|
const entries = await proxy.cameraStreams?.getProfileRtspEntries({}) ?? [];
|
|
38754
|
-
if (entries.length === 0)
|
|
39349
|
+
if (entries.length === 0) {
|
|
39350
|
+
startLog.warn("export-hap: stream start DROPPED — device publishes no profile RTSP entries", { meta: {
|
|
39351
|
+
sessionId,
|
|
39352
|
+
capBound: proxy.cameraStreams !== void 0
|
|
39353
|
+
} });
|
|
39354
|
+
throw new Error(`export-hap: no profile RTSP entries for device ${numericDeviceId}`);
|
|
39355
|
+
}
|
|
38755
39356
|
const pref = options.hapDeviceSettings.streamPreference;
|
|
38756
39357
|
const picked = pickPreferredRtspEntry(entries, pref, numericDeviceId, { targetResolution: {
|
|
38757
39358
|
width: video.width,
|
|
38758
39359
|
height: video.height
|
|
38759
39360
|
} });
|
|
38760
|
-
if (!picked)
|
|
39361
|
+
if (!picked) {
|
|
39362
|
+
startLog.warn("export-hap: stream start DROPPED — no ENABLED profile RTSP entry", { meta: {
|
|
39363
|
+
sessionId,
|
|
39364
|
+
streamPreference: pref,
|
|
39365
|
+
targetResolution: `${video.width}x${video.height}`,
|
|
39366
|
+
entries: entries.map((e) => `${e.profile}:${e.enabled ? "enabled" : "disabled"}`)
|
|
39367
|
+
} });
|
|
39368
|
+
throw new Error(`export-hap: no enabled RTSP entries for device ${numericDeviceId}`);
|
|
39369
|
+
}
|
|
38761
39370
|
const rtspUrl = picked.url;
|
|
38762
39371
|
const slot = (await proxy.cameraStreams?.getBrokerStreams({}) ?? []).find((s) => s.brokerId === picked.brokerId);
|
|
38763
39372
|
const codec = (picked.codec ?? slot?.codec ?? "").toLowerCase();
|
|
38764
39373
|
const needsTranscode = codec.includes("h265") || codec.includes("hevc");
|
|
39374
|
+
const pickedProfile = toKnownProfile(picked.profileId);
|
|
39375
|
+
const resolvedFps = pickedProfile === null ? void 0 : advertised.fpsByProfile.get(pickedProfile);
|
|
39376
|
+
const advertisedFps = resolvedFps?.fps ?? video.fps;
|
|
39377
|
+
const advertisedFpsSource = resolvedFps?.source ?? "assumed";
|
|
39378
|
+
session.selectedSlot = {
|
|
39379
|
+
profile: pickedProfile,
|
|
39380
|
+
brokerId: picked.brokerId,
|
|
39381
|
+
width: picked.resolution?.width ?? null,
|
|
39382
|
+
height: picked.resolution?.height ?? null,
|
|
39383
|
+
advertisedFps,
|
|
39384
|
+
advertisedFpsSource,
|
|
39385
|
+
codec: codec.length > 0 ? codec : "unknown",
|
|
39386
|
+
transcode: needsTranscode
|
|
39387
|
+
};
|
|
39388
|
+
if (advertisedFps !== video.fps) startLog.warn("export-hap: negotiated fps does NOT match the slot we are about to dial", { meta: {
|
|
39389
|
+
sessionId,
|
|
39390
|
+
negotiatedFps: video.fps,
|
|
39391
|
+
slotFps: advertisedFps,
|
|
39392
|
+
slotFpsSource: advertisedFpsSource,
|
|
39393
|
+
profile: pickedProfile,
|
|
39394
|
+
brokerId: picked.brokerId,
|
|
39395
|
+
transcode: needsTranscode
|
|
39396
|
+
} });
|
|
38765
39397
|
const videoLoopPort = session.videoLoopUdp.address().port;
|
|
38766
39398
|
const audioLoopPort = session.audioLoopUdp.address().port;
|
|
38767
39399
|
const videoTarget = `rtp://127.0.0.1:${videoLoopPort}?pkt_size=${video.mtu}`;
|
|
@@ -38861,15 +39493,22 @@ async function startFfmpegForSession(bctx, session, sessionId, video) {
|
|
|
38861
39493
|
} });
|
|
38862
39494
|
});
|
|
38863
39495
|
proc.once("exit", (code, signal) => {
|
|
39496
|
+
session.ffmpegExit = {
|
|
39497
|
+
code,
|
|
39498
|
+
signal
|
|
39499
|
+
};
|
|
38864
39500
|
const ok = code === 0 && signal === null;
|
|
38865
39501
|
const meta = {
|
|
38866
39502
|
sessionId,
|
|
38867
39503
|
code,
|
|
38868
|
-
signal
|
|
39504
|
+
signal,
|
|
39505
|
+
stopRequestedByController: session.stopRequestedByController,
|
|
39506
|
+
videoPacketsForwarded: session.videoPacketsForwarded
|
|
38869
39507
|
};
|
|
38870
|
-
if (ok) log.info("export-hap: ffmpeg exited", { meta });
|
|
38871
|
-
else log.warn("export-hap: ffmpeg exited
|
|
39508
|
+
if (ok || session.stopRequestedByController) log.info("export-hap: ffmpeg exited", { meta });
|
|
39509
|
+
else log.warn("export-hap: ffmpeg exited without a controller stop", { meta });
|
|
38872
39510
|
if (session.ffmpeg === proc) session.ffmpeg = null;
|
|
39511
|
+
logSessionSummary(session, log, session.teardownTrigger ?? "ffmpeg-exit");
|
|
38873
39512
|
});
|
|
38874
39513
|
proc.once("error", (err) => {
|
|
38875
39514
|
log.warn("export-hap: ffmpeg spawn failed", { meta: {
|
|
@@ -38880,12 +39519,27 @@ async function startFfmpegForSession(bctx, session, sessionId, video) {
|
|
|
38880
39519
|
log.info("export-hap: stream started", { meta: {
|
|
38881
39520
|
sessionId,
|
|
38882
39521
|
transcode: needsTranscode,
|
|
38883
|
-
|
|
39522
|
+
brokerId: picked.brokerId,
|
|
39523
|
+
profile: pickedProfile,
|
|
39524
|
+
sourceResolution: picked.resolution === void 0 ? null : `${picked.resolution.width}x${picked.resolution.height}`,
|
|
39525
|
+
sourceCodec: codec.length > 0 ? codec : "unknown",
|
|
39526
|
+
negotiated: `${video.width}x${video.height}@${video.fps} ${video.max_bit_rate}kbps`,
|
|
39527
|
+
slotFps: advertisedFps,
|
|
39528
|
+
slotFpsSource: advertisedFpsSource,
|
|
38884
39529
|
audioCodec: "opus",
|
|
38885
39530
|
audioBitrateKbps: OPUS_BITRATE_KBPS
|
|
38886
39531
|
} });
|
|
38887
39532
|
}
|
|
38888
39533
|
/**
|
|
39534
|
+
* `PickedStream.profileId` is the brokerId suffix; for a profile-keyed entry
|
|
39535
|
+
* that IS the profile name. Anything else addresses a raw cam-stream and must
|
|
39536
|
+
* not be coerced into a profile.
|
|
39537
|
+
*/
|
|
39538
|
+
function toKnownProfile(profileId) {
|
|
39539
|
+
if (profileId === "high" || profileId === "mid" || profileId === "low") return profileId;
|
|
39540
|
+
return null;
|
|
39541
|
+
}
|
|
39542
|
+
/**
|
|
38889
39543
|
* Handle one incoming SRTP audio packet from iOS Home.
|
|
38890
39544
|
*
|
|
38891
39545
|
* End-to-end flow:
|
|
@@ -38906,15 +39560,22 @@ async function startFfmpegForSession(bctx, session, sessionId, video) {
|
|
|
38906
39560
|
* stays codec-agnostic.
|
|
38907
39561
|
*/
|
|
38908
39562
|
async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
|
|
38909
|
-
if (packet.length < 12)
|
|
39563
|
+
if (packet.length < 12) {
|
|
39564
|
+
drop(session, "upstream-short-packet");
|
|
39565
|
+
return;
|
|
39566
|
+
}
|
|
38910
39567
|
const log = bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId });
|
|
38911
39568
|
session.upstreamRtpPacketsReceived += 1;
|
|
38912
|
-
if (!session.upstreamAudioSrtp)
|
|
39569
|
+
if (!session.upstreamAudioSrtp) {
|
|
39570
|
+
drop(session, "upstream-no-srtp");
|
|
39571
|
+
return;
|
|
39572
|
+
}
|
|
38913
39573
|
let decryptedPacket;
|
|
38914
39574
|
try {
|
|
38915
39575
|
decryptedPacket = session.upstreamAudioSrtp.decrypt(packet);
|
|
38916
39576
|
session.upstreamRtpPacketsDecrypted += 1;
|
|
38917
39577
|
} catch (err) {
|
|
39578
|
+
drop(session, "upstream-decrypt-failed");
|
|
38918
39579
|
session.upstreamRtpDecryptFailures += 1;
|
|
38919
39580
|
if (session.upstreamRtpDecryptFailures % 100 === 1) log.debug("export-hap: SRTP decrypt failed (rate-limited)", { meta: {
|
|
38920
39581
|
failures: session.upstreamRtpDecryptFailures,
|
|
@@ -38931,18 +39592,28 @@ async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
|
|
|
38931
39592
|
rtpTimestamp = parsedRtp.header.timestamp;
|
|
38932
39593
|
rtpPayloadType = parsedRtp.header.payloadType;
|
|
38933
39594
|
} catch (err) {
|
|
39595
|
+
drop(session, "upstream-parse-failed");
|
|
38934
39596
|
log.debug("export-hap: RTP parse failed after decrypt", { meta: { error: errMsg$8(err) } });
|
|
38935
39597
|
return;
|
|
38936
39598
|
}
|
|
38937
39599
|
const negotiatedAudioPt = session.lastStartParams?.audioPt;
|
|
38938
|
-
if (negotiatedAudioPt === void 0)
|
|
38939
|
-
|
|
39600
|
+
if (negotiatedAudioPt === void 0) {
|
|
39601
|
+
drop(session, "upstream-before-start");
|
|
39602
|
+
return;
|
|
39603
|
+
}
|
|
39604
|
+
if (rtpPayloadType !== negotiatedAudioPt) {
|
|
39605
|
+
drop(session, "upstream-pt-mismatch");
|
|
39606
|
+
return;
|
|
39607
|
+
}
|
|
38940
39608
|
if (session.firstUpstreamRtpTimestamp === null) {
|
|
38941
39609
|
session.firstUpstreamRtpTimestamp = rtpTimestamp;
|
|
38942
39610
|
log.info("export-hap: first upstream RTP packet received");
|
|
38943
39611
|
}
|
|
38944
39612
|
const payload = decryptedPayload;
|
|
38945
|
-
if (payload.length === 0)
|
|
39613
|
+
if (payload.length === 0) {
|
|
39614
|
+
drop(session, "upstream-empty-payload");
|
|
39615
|
+
return;
|
|
39616
|
+
}
|
|
38946
39617
|
if (session.intercomTalkSessionId === null) {
|
|
38947
39618
|
session.intercomTalkSessionId = "";
|
|
38948
39619
|
const opened = await openIntercomTalkSession(bctx).catch((err) => {
|
|
@@ -38954,7 +39625,10 @@ async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
|
|
|
38954
39625
|
log.info("export-hap: intercom talk session opened", { meta: { sessionId: opened.sessionId } });
|
|
38955
39626
|
}
|
|
38956
39627
|
}
|
|
38957
|
-
if (!session.intercomTalkSessionId)
|
|
39628
|
+
if (!session.intercomTalkSessionId) {
|
|
39629
|
+
drop(session, "upstream-no-talk-session");
|
|
39630
|
+
return;
|
|
39631
|
+
}
|
|
38958
39632
|
const opusSampleRateHz = (session.lastStartParams?.audioSampleRateEnum ?? 16) * 1e3;
|
|
38959
39633
|
session.intercomPcmSequence += 1;
|
|
38960
39634
|
session.upstreamPcmFramesDecoded += 1;
|
|
@@ -38967,6 +39641,7 @@ async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
|
|
|
38967
39641
|
sequenceNumber: session.intercomPcmSequence
|
|
38968
39642
|
});
|
|
38969
39643
|
} catch (err) {
|
|
39644
|
+
drop(session, "upstream-push-failed");
|
|
38970
39645
|
log.debug("export-hap: intercom.pushTalkAudio failed (will re-open on next frame)", { meta: {
|
|
38971
39646
|
sequenceNumber: session.intercomPcmSequence,
|
|
38972
39647
|
error: errMsg$8(err)
|
|
@@ -39050,140 +39725,6 @@ function errMsg$7(err) {
|
|
|
39050
39725
|
return err instanceof Error ? err.message : String(err);
|
|
39051
39726
|
}
|
|
39052
39727
|
//#endregion
|
|
39053
|
-
//#region src/mappers/builders/hksv.ts
|
|
39054
|
-
/**
|
|
39055
|
-
* HomeKit Secure Video (HKSV) — stub recording delegate.
|
|
39056
|
-
*
|
|
39057
|
-
* Wires the camera as HKSV-capable so iOS Home shows the "Activity"
|
|
39058
|
-
* tab, the per-camera recording settings UI ("Stream and Allow
|
|
39059
|
-
* Recording" / "Off"), and notifications. The delegate stub:
|
|
39060
|
-
* - logs every protocol callback (active toggle, config change,
|
|
39061
|
-
* stream request, close);
|
|
39062
|
-
* - returns from `handleRecordingStreamRequest` immediately without
|
|
39063
|
-
* yielding any fragment — iOS sees "no recording available" but
|
|
39064
|
-
* the camera otherwise behaves like an HKSV camera in the UI.
|
|
39065
|
-
*
|
|
39066
|
-
* Why a stub: a real implementation needs an fMP4 segmenter that runs
|
|
39067
|
-
* a per-camera ffmpeg with a sustained pre-buffer, key/IV-derived
|
|
39068
|
-
* encrypter, motion-anchored fragment alignment, and storage for the
|
|
39069
|
-
* resulting clips. That work belongs behind the `recording` cap
|
|
39070
|
-
* (recording + playback-manifest surface) once it grows fragment export,
|
|
39071
|
-
* so the segmenter lives in the recorder addon and HKSV becomes a thin
|
|
39072
|
-
* wrapper that asks the cap for "fragments since T" and streams them to iOS.
|
|
39073
|
-
*
|
|
39074
|
-
* Until that cap lands this stub keeps HKSV characteristics visible
|
|
39075
|
-
* (a) so the operator can flip "Allow Recording" without HomeKit
|
|
39076
|
-
* complaining the camera lacks the service, and (b) so the
|
|
39077
|
-
* `CameraController` advertises a `RecordingManagement` service for
|
|
39078
|
-
* iOS-side analytics + Activity-tab UX scaffolding.
|
|
39079
|
-
*
|
|
39080
|
-
* Constructor returns a `{ options, delegate }` pair the caller
|
|
39081
|
-
* passes verbatim into `new CameraController({ ..., recording })`.
|
|
39082
|
-
*/
|
|
39083
|
-
var RECORDING_OPTIONS = {
|
|
39084
|
-
prebufferLength: 4e3,
|
|
39085
|
-
mediaContainerConfiguration: [{
|
|
39086
|
-
type: _homebridge_hap_nodejs.MediaContainerType.FRAGMENTED_MP4,
|
|
39087
|
-
fragmentLength: 4e3
|
|
39088
|
-
}],
|
|
39089
|
-
video: {
|
|
39090
|
-
type: _homebridge_hap_nodejs.VideoCodecType.H264,
|
|
39091
|
-
parameters: {
|
|
39092
|
-
profiles: [
|
|
39093
|
-
_homebridge_hap_nodejs.H264Profile.BASELINE,
|
|
39094
|
-
_homebridge_hap_nodejs.H264Profile.MAIN,
|
|
39095
|
-
_homebridge_hap_nodejs.H264Profile.HIGH
|
|
39096
|
-
],
|
|
39097
|
-
levels: [
|
|
39098
|
-
_homebridge_hap_nodejs.H264Level.LEVEL3_1,
|
|
39099
|
-
_homebridge_hap_nodejs.H264Level.LEVEL3_2,
|
|
39100
|
-
_homebridge_hap_nodejs.H264Level.LEVEL4_0
|
|
39101
|
-
]
|
|
39102
|
-
},
|
|
39103
|
-
resolutions: [
|
|
39104
|
-
[
|
|
39105
|
-
1920,
|
|
39106
|
-
1080,
|
|
39107
|
-
30
|
|
39108
|
-
],
|
|
39109
|
-
[
|
|
39110
|
-
1920,
|
|
39111
|
-
1080,
|
|
39112
|
-
24
|
|
39113
|
-
],
|
|
39114
|
-
[
|
|
39115
|
-
1920,
|
|
39116
|
-
1080,
|
|
39117
|
-
15
|
|
39118
|
-
],
|
|
39119
|
-
[
|
|
39120
|
-
1280,
|
|
39121
|
-
720,
|
|
39122
|
-
30
|
|
39123
|
-
],
|
|
39124
|
-
[
|
|
39125
|
-
1280,
|
|
39126
|
-
720,
|
|
39127
|
-
24
|
|
39128
|
-
],
|
|
39129
|
-
[
|
|
39130
|
-
1280,
|
|
39131
|
-
720,
|
|
39132
|
-
15
|
|
39133
|
-
]
|
|
39134
|
-
]
|
|
39135
|
-
},
|
|
39136
|
-
audio: { codecs: [{
|
|
39137
|
-
type: _homebridge_hap_nodejs.AudioRecordingCodecType.AAC_LC,
|
|
39138
|
-
bitrateMode: _homebridge_hap_nodejs.AudioBitrate.VARIABLE,
|
|
39139
|
-
audioChannels: 1,
|
|
39140
|
-
samplerate: [_homebridge_hap_nodejs.AudioRecordingSamplerate.KHZ_16, _homebridge_hap_nodejs.AudioRecordingSamplerate.KHZ_24]
|
|
39141
|
-
}] },
|
|
39142
|
-
overrideEventTriggerOptions: [_homebridge_hap_nodejs.EventTriggerOption.MOTION]
|
|
39143
|
-
};
|
|
39144
|
-
function buildHksvStub(bctx) {
|
|
39145
|
-
const { ctx, numericDeviceId } = bctx;
|
|
39146
|
-
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
39147
|
-
let activeStreamId = null;
|
|
39148
|
-
return {
|
|
39149
|
-
options: RECORDING_OPTIONS,
|
|
39150
|
-
delegate: {
|
|
39151
|
-
updateRecordingActive(active) {
|
|
39152
|
-
log.info("export-hap: HKSV recording active changed", { meta: { active } });
|
|
39153
|
-
},
|
|
39154
|
-
updateRecordingConfiguration(configuration) {
|
|
39155
|
-
if (!configuration) {
|
|
39156
|
-
log.info("export-hap: HKSV recording configuration cleared");
|
|
39157
|
-
return;
|
|
39158
|
-
}
|
|
39159
|
-
log.info("export-hap: HKSV recording configuration applied", { meta: {
|
|
39160
|
-
prebufferLength: configuration.prebufferLength,
|
|
39161
|
-
fragmentLength: configuration.mediaContainerConfiguration.fragmentLength,
|
|
39162
|
-
videoResolution: `${configuration.videoCodec.resolution[0]}x${configuration.videoCodec.resolution[1]}@${configuration.videoCodec.resolution[2]}`,
|
|
39163
|
-
videoBitrate: configuration.videoCodec.parameters.bitRate,
|
|
39164
|
-
eventTriggers: configuration.eventTriggerTypes
|
|
39165
|
-
} });
|
|
39166
|
-
},
|
|
39167
|
-
async *handleRecordingStreamRequest(streamId, _signal) {
|
|
39168
|
-
activeStreamId = streamId;
|
|
39169
|
-
log.warn("export-hap: HKSV stream requested — stub returns empty (no clip source yet)", { meta: { streamId } });
|
|
39170
|
-
},
|
|
39171
|
-
acknowledgeStream(streamId) {
|
|
39172
|
-
if (activeStreamId === streamId) activeStreamId = null;
|
|
39173
|
-
log.debug("export-hap: HKSV stream acknowledged", { meta: { streamId } });
|
|
39174
|
-
},
|
|
39175
|
-
closeRecordingStream(streamId, reason) {
|
|
39176
|
-
if (activeStreamId === streamId) activeStreamId = null;
|
|
39177
|
-
log.info("export-hap: HKSV stream closed", { meta: {
|
|
39178
|
-
streamId,
|
|
39179
|
-
reason: reason ?? null
|
|
39180
|
-
} });
|
|
39181
|
-
}
|
|
39182
|
-
},
|
|
39183
|
-
handle: { async dispose() {} }
|
|
39184
|
-
};
|
|
39185
|
-
}
|
|
39186
|
-
//#endregion
|
|
39187
39728
|
//#region src/mappers/builders/intercom.ts
|
|
39188
39729
|
async function buildIntercom(input) {
|
|
39189
39730
|
const { bctx, streamingOptions } = input;
|
|
@@ -39477,6 +40018,65 @@ function errMsg$4(err) {
|
|
|
39477
40018
|
return err instanceof Error ? err.message : String(err);
|
|
39478
40019
|
}
|
|
39479
40020
|
//#endregion
|
|
40021
|
+
//#region src/mappers/builders/stream-fps-probe.ts
|
|
40022
|
+
async function probeAdvertisedVideoProfile(bctx) {
|
|
40023
|
+
const { ctx, proxy, numericDeviceId, options } = bctx;
|
|
40024
|
+
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
40025
|
+
const entries = await probe(() => proxy.cameraStreams?.getProfileRtspEntries({}), "cameraStreams.getProfileRtspEntries", log);
|
|
40026
|
+
const slots = await probe(() => proxy.cameraStreams?.getBrokerStreams({}), "cameraStreams.getBrokerStreams", log);
|
|
40027
|
+
const camStreams = await probe(() => proxy.cameraStreams?.getCameraStreams({}), "cameraStreams.getCameraStreams", log);
|
|
40028
|
+
const choices = await probe(() => proxy.webrtcSession?.listStreams({}), "webrtcSession.listStreams", log);
|
|
40029
|
+
const fpsByProfile = resolveProfileFps({
|
|
40030
|
+
choices: choices ?? [],
|
|
40031
|
+
slots: slots ?? [],
|
|
40032
|
+
camStreams: camStreams ?? []
|
|
40033
|
+
});
|
|
40034
|
+
const resolutions = deriveAdvertisedResolutions({
|
|
40035
|
+
candidates: CANDIDATE_RESOLUTIONS,
|
|
40036
|
+
entries: entries ?? [],
|
|
40037
|
+
deviceId: numericDeviceId,
|
|
40038
|
+
pref: options.hapDeviceSettings.streamPreference,
|
|
40039
|
+
fpsByProfile
|
|
40040
|
+
});
|
|
40041
|
+
const assumed = resolutions.filter((r) => r.source === "assumed").length;
|
|
40042
|
+
log.info("export-hap: advertised video profile derived", { meta: {
|
|
40043
|
+
streamPreference: options.hapDeviceSettings.streamPreference,
|
|
40044
|
+
resolutions: formatAdvertisedResolutions(resolutions),
|
|
40045
|
+
profileFps: [...fpsByProfile.values()].map((f) => `${f.profile}=${f.fps}(${f.source})`),
|
|
40046
|
+
assumedCount: assumed,
|
|
40047
|
+
previouslyAdvertisedFps: 30
|
|
40048
|
+
} });
|
|
40049
|
+
if (assumed === resolutions.length) log.warn("export-hap: no measured or published frame rate for ANY profile — advertising the assumed rate", { meta: {
|
|
40050
|
+
entries: entries?.length ?? 0,
|
|
40051
|
+
choices: choices?.length ?? 0
|
|
40052
|
+
} });
|
|
40053
|
+
return {
|
|
40054
|
+
resolutions,
|
|
40055
|
+
fpsByProfile
|
|
40056
|
+
};
|
|
40057
|
+
}
|
|
40058
|
+
/**
|
|
40059
|
+
* Run one cap read. Returns null both when the cap is not bound and when the
|
|
40060
|
+
* call throws — and LOGS which, because "the camera has no telemetry" and "the
|
|
40061
|
+
* telemetry call failed" lead to different fixes.
|
|
40062
|
+
*/
|
|
40063
|
+
async function probe(call, label, log) {
|
|
40064
|
+
try {
|
|
40065
|
+
const pending = call();
|
|
40066
|
+
if (pending === void 0) {
|
|
40067
|
+
log.info("export-hap: fps probe skipped — cap not bound on this device", { meta: { call: label } });
|
|
40068
|
+
return null;
|
|
40069
|
+
}
|
|
40070
|
+
return await pending;
|
|
40071
|
+
} catch (err) {
|
|
40072
|
+
log.warn("export-hap: fps probe failed — falling back to a lower-authority source", { meta: {
|
|
40073
|
+
call: label,
|
|
40074
|
+
error: err instanceof Error ? err.message : String(err)
|
|
40075
|
+
} });
|
|
40076
|
+
return null;
|
|
40077
|
+
}
|
|
40078
|
+
}
|
|
40079
|
+
//#endregion
|
|
39480
40080
|
//#region src/mappers/builders/child-switch.ts
|
|
39481
40081
|
/**
|
|
39482
40082
|
* Child-switch builder — turns a camstack accessory child device (siren,
|
|
@@ -39703,24 +40303,18 @@ async function buildCameraAccessory(input) {
|
|
|
39703
40303
|
displayName,
|
|
39704
40304
|
options
|
|
39705
40305
|
};
|
|
39706
|
-
const streams = buildCameraStreamingDelegate(bctx);
|
|
40306
|
+
const streams = buildCameraStreamingDelegate(bctx, await probeAdvertisedVideoProfile(bctx));
|
|
39707
40307
|
const handles = [];
|
|
39708
40308
|
if (capNames.has("intercom")) handles.push(await buildIntercom({
|
|
39709
40309
|
bctx,
|
|
39710
40310
|
streamingOptions: streams.streamingOptions
|
|
39711
40311
|
}));
|
|
39712
|
-
const hksv = capNames.has("motion-detection") ? buildHksvStub(bctx) : null;
|
|
39713
40312
|
const controller = new (isDoorbell ? _homebridge_hap_nodejs.DoorbellController : _homebridge_hap_nodejs.CameraController)({
|
|
39714
40313
|
delegate: streams.delegate,
|
|
39715
40314
|
streamingOptions: streams.streamingOptions,
|
|
39716
|
-
cameraStreamCount: 2
|
|
39717
|
-
...hksv ? { recording: {
|
|
39718
|
-
options: hksv.options,
|
|
39719
|
-
delegate: hksv.delegate
|
|
39720
|
-
} } : {}
|
|
40315
|
+
cameraStreamCount: 2
|
|
39721
40316
|
});
|
|
39722
40317
|
accessory.configureController(controller);
|
|
39723
|
-
if (hksv) handles.push(hksv.handle);
|
|
39724
40318
|
if (capNames.has("motion-detection")) handles.push(await buildMotionSensor(bctx));
|
|
39725
40319
|
if (isDoorbell && controller instanceof _homebridge_hap_nodejs.DoorbellController) handles.push(await buildDoorbell({
|
|
39726
40320
|
bctx,
|