@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
|
@@ -2,7 +2,7 @@ import { createRequire } from "node:module";
|
|
|
2
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
|
-
import { Accessory, AudioBitrate,
|
|
5
|
+
import { Accessory, AudioBitrate, AudioStreamingCodecType, AudioStreamingSamplerate, CameraController, Categories, Characteristic, DoorbellController, H264Level, H264Profile, HAPStorage, SRTPCryptoSuites, Service, uuid } from "@homebridge/hap-nodejs";
|
|
6
6
|
import * as fs from "node:fs/promises";
|
|
7
7
|
import { createSocket } from "node:dgram";
|
|
8
8
|
import { networkInterfaces } from "node:os";
|
|
@@ -14702,6 +14702,70 @@ var EventPruneCountsSchema = object({
|
|
|
14702
14702
|
object: number().int(),
|
|
14703
14703
|
audio: number().int()
|
|
14704
14704
|
});
|
|
14705
|
+
/**
|
|
14706
|
+
* Re-embed stored tracks from their key frames.
|
|
14707
|
+
*
|
|
14708
|
+
* The reason this is an operator-callable method and not a migration script:
|
|
14709
|
+
* every knob that decides what a vector MEANS — encoder model, crop margin,
|
|
14710
|
+
* squaring — is only changeable if the existing vectors can be regenerated.
|
|
14711
|
+
* Mixing feature spaces in one index makes cosine scores incomparable, and the
|
|
14712
|
+
* symptom is a quality regression with no visible cause.
|
|
14713
|
+
*/
|
|
14714
|
+
var RebuildObjectEmbeddingsInput = object({
|
|
14715
|
+
/** Restrict to one camera. Omit for the whole fleet. */
|
|
14716
|
+
deviceId: number().optional(),
|
|
14717
|
+
since: number().optional(),
|
|
14718
|
+
until: number().optional(),
|
|
14719
|
+
/** Stop after this many tracks; the result reports whether more remain. */
|
|
14720
|
+
maxTracks: number().int().positive().optional()
|
|
14721
|
+
});
|
|
14722
|
+
/**
|
|
14723
|
+
* Result of emptying the CLIP index.
|
|
14724
|
+
*
|
|
14725
|
+
* The clean slate before a policy change: a new crop margin or encoder model
|
|
14726
|
+
* leaves two feature spaces in one index whose cosine scores are not
|
|
14727
|
+
* comparable, so wiping and rebuilding is the only way to be sure every vector
|
|
14728
|
+
* means the same thing.
|
|
14729
|
+
*/
|
|
14730
|
+
var WipeObjectEmbeddingsResultSchema = object({ deleted: number() });
|
|
14731
|
+
/**
|
|
14732
|
+
* Acknowledgement that a rebuild STARTED.
|
|
14733
|
+
*
|
|
14734
|
+
* The pass costs ~1s per track — 45 minutes for a 2,600-track fleet — so it
|
|
14735
|
+
* runs detached and this returns immediately. Waiting for it made the client
|
|
14736
|
+
* time out while the work carried on server-side, which is the worst of both:
|
|
14737
|
+
* no result and no way to know it was still going. Poll
|
|
14738
|
+
* `getObjectEmbeddingRebuildStatus` for progress.
|
|
14739
|
+
*/
|
|
14740
|
+
var RebuildObjectEmbeddingsResultSchema = object({
|
|
14741
|
+
started: boolean(),
|
|
14742
|
+
/** True when a pass was already running; the new request is ignored. */
|
|
14743
|
+
alreadyRunning: boolean()
|
|
14744
|
+
});
|
|
14745
|
+
var RebuildStatusSchema = object({
|
|
14746
|
+
running: boolean(),
|
|
14747
|
+
scanned: number(),
|
|
14748
|
+
rebuilt: number(),
|
|
14749
|
+
/** Tracks whose key frame is gone — nothing to re-embed from. */
|
|
14750
|
+
missingKeyFrame: number(),
|
|
14751
|
+
/** Tracks with no usable detection box. */
|
|
14752
|
+
missingBbox: number(),
|
|
14753
|
+
/**
|
|
14754
|
+
* Tracks the pipeline REFUSED rather than broke on: the camera is not
|
|
14755
|
+
* attached, or `clip-embedding` is not enabled in its step tree. Separate
|
|
14756
|
+
* from `failed` because the remedy is a configuration change, not an engine
|
|
14757
|
+
* investigation — and because a pass over decommissioned cameras would
|
|
14758
|
+
* otherwise read as a total engine outage.
|
|
14759
|
+
*/
|
|
14760
|
+
notRunnable: number(),
|
|
14761
|
+
failed: number(),
|
|
14762
|
+
/** Set once a pass ends: true only when EVERYTHING was covered. */
|
|
14763
|
+
complete: boolean().nullable(),
|
|
14764
|
+
startedAtMs: number().nullable(),
|
|
14765
|
+
finishedAtMs: number().nullable(),
|
|
14766
|
+
/** Present when the pass ended by throwing. */
|
|
14767
|
+
error: string().nullable()
|
|
14768
|
+
});
|
|
14705
14769
|
DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).readonly()), method(object({
|
|
14706
14770
|
deviceId: number(),
|
|
14707
14771
|
trackId: string()
|
|
@@ -14795,7 +14859,13 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
|
|
|
14795
14859
|
}), array(MediaFileSchema).readonly()), method(object({
|
|
14796
14860
|
trackId: string(),
|
|
14797
14861
|
kinds: array(MediaFileKindEnum).optional()
|
|
14798
|
-
}), array(MediaFileSchema).readonly()), method(object({ trackId: string() }), array(MediaFileInfoSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), object({
|
|
14862
|
+
}), array(MediaFileSchema).readonly()), method(object({ trackId: string() }), array(MediaFileInfoSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), method(object({}), WipeObjectEmbeddingsResultSchema, {
|
|
14863
|
+
kind: "mutation",
|
|
14864
|
+
auth: "admin"
|
|
14865
|
+
}), method(RebuildObjectEmbeddingsInput, RebuildObjectEmbeddingsResultSchema, {
|
|
14866
|
+
kind: "mutation",
|
|
14867
|
+
auth: "admin"
|
|
14868
|
+
}), method(object({}), RebuildStatusSchema), object({
|
|
14799
14869
|
deviceId: number(),
|
|
14800
14870
|
timestamp: number(),
|
|
14801
14871
|
frameWidth: number(),
|
|
@@ -15704,6 +15774,17 @@ method(RunnerCameraConfigSchema, object({ success: literal(true) }), { kind: "mu
|
|
|
15704
15774
|
}), NativeCropResultSchema.nullable()), method(object({
|
|
15705
15775
|
deviceId: number(),
|
|
15706
15776
|
frameHandle: FrameHandleSchema.optional(),
|
|
15777
|
+
/**
|
|
15778
|
+
* FULL FRAME (base64 JPEG). The runner derives the crop rectangle from
|
|
15779
|
+
* `parent.bbox` with the cluster crop convention and cuts it itself —
|
|
15780
|
+
* do NOT pre-crop for this field, that is what `cropJpeg` is.
|
|
15781
|
+
*/
|
|
15782
|
+
frameJpeg: string().optional(),
|
|
15783
|
+
/**
|
|
15784
|
+
* PRE-CUT tile (base64 JPEG), used verbatim — NO padding is applied.
|
|
15785
|
+
* The fallback when the lease/session backing the frame is gone and the
|
|
15786
|
+
* caller already holds a crop.
|
|
15787
|
+
*/
|
|
15707
15788
|
cropJpeg: string().optional(),
|
|
15708
15789
|
parent: DetailParentSchema,
|
|
15709
15790
|
steps: array(string()).optional()
|
|
@@ -17029,6 +17110,24 @@ var VectorDeleteByFilterInputSchema = object({
|
|
|
17029
17110
|
filter: VectorFilterSchema
|
|
17030
17111
|
});
|
|
17031
17112
|
var VectorDeleteResultSchema = object({ deleted: number() });
|
|
17113
|
+
var VectorGetInputSchema = object({
|
|
17114
|
+
index: string(),
|
|
17115
|
+
ids: array(string())
|
|
17116
|
+
});
|
|
17117
|
+
/**
|
|
17118
|
+
* Metadata for the requested ids, WITHOUT their vectors.
|
|
17119
|
+
*
|
|
17120
|
+
* The only caller is a best-of gate that compares a candidate's confidence
|
|
17121
|
+
* against the stored one, and shipping 512 floats back to answer "is 0.91 >
|
|
17122
|
+
* 0.87" would undo the point of the compact encoding. Ids with no row are
|
|
17123
|
+
* simply absent — a caller distinguishing "not stored" from "stored" reads the
|
|
17124
|
+
* length, and a null placeholder would invite a `?? 0` that treats a missing
|
|
17125
|
+
* row as confidence zero.
|
|
17126
|
+
*/
|
|
17127
|
+
var VectorGetResultSchema = object({ items: array(object({
|
|
17128
|
+
id: string(),
|
|
17129
|
+
metadata: VectorMetadataSchema
|
|
17130
|
+
})) });
|
|
17032
17131
|
var VectorStatsInputSchema = object({ index: string() });
|
|
17033
17132
|
var VectorStatsResultSchema = object({
|
|
17034
17133
|
/** Provider id, so an operator can tell brute force from an ANN index. */
|
|
@@ -17041,7 +17140,7 @@ var VectorStatsResultSchema = object({
|
|
|
17041
17140
|
/** False when the backend ranks approximately. */
|
|
17042
17141
|
exact: boolean()
|
|
17043
17142
|
});
|
|
17044
|
-
method(VectorDeclareIndexInputSchema, _void(), { kind: "mutation" }), method(VectorUpsertInputSchema, VectorUpsertResultSchema, { kind: "mutation" }), method(VectorQueryInputSchema, VectorQueryResultSchema), method(VectorDeleteInputSchema, VectorDeleteResultSchema, { kind: "mutation" }), method(VectorDeleteByFilterInputSchema, VectorDeleteResultSchema, { kind: "mutation" }), method(VectorStatsInputSchema, VectorStatsResultSchema);
|
|
17143
|
+
method(VectorDeclareIndexInputSchema, _void(), { kind: "mutation" }), method(VectorUpsertInputSchema, VectorUpsertResultSchema, { kind: "mutation" }), method(VectorQueryInputSchema, VectorQueryResultSchema), method(VectorGetInputSchema, VectorGetResultSchema), method(VectorDeleteInputSchema, VectorDeleteResultSchema, { kind: "mutation" }), method(VectorDeleteByFilterInputSchema, VectorDeleteResultSchema, { kind: "mutation" }), method(VectorStatsInputSchema, VectorStatsResultSchema);
|
|
17045
17144
|
/**
|
|
17046
17145
|
* `videoclips` — the unified, navigable-clip surface for a camera.
|
|
17047
17146
|
*
|
|
@@ -25304,6 +25403,12 @@ Object.freeze({
|
|
|
25304
25403
|
addonId: null,
|
|
25305
25404
|
access: "view"
|
|
25306
25405
|
},
|
|
25406
|
+
"pipelineAnalytics.getObjectEmbeddingRebuildStatus": {
|
|
25407
|
+
capName: "pipeline-analytics",
|
|
25408
|
+
capScope: "device",
|
|
25409
|
+
addonId: null,
|
|
25410
|
+
access: "view"
|
|
25411
|
+
},
|
|
25307
25412
|
"pipelineAnalytics.getObjectEvents": {
|
|
25308
25413
|
capName: "pipeline-analytics",
|
|
25309
25414
|
capScope: "device",
|
|
@@ -25382,6 +25487,12 @@ Object.freeze({
|
|
|
25382
25487
|
addonId: null,
|
|
25383
25488
|
access: "create"
|
|
25384
25489
|
},
|
|
25490
|
+
"pipelineAnalytics.rebuildObjectEmbeddings": {
|
|
25491
|
+
capName: "pipeline-analytics",
|
|
25492
|
+
capScope: "device",
|
|
25493
|
+
addonId: null,
|
|
25494
|
+
access: "create"
|
|
25495
|
+
},
|
|
25385
25496
|
"pipelineAnalytics.relocateMedia": {
|
|
25386
25497
|
capName: "pipeline-analytics",
|
|
25387
25498
|
capScope: "device",
|
|
@@ -25400,6 +25511,12 @@ Object.freeze({
|
|
|
25400
25511
|
addonId: null,
|
|
25401
25512
|
access: "delete"
|
|
25402
25513
|
},
|
|
25514
|
+
"pipelineAnalytics.wipeObjectEmbeddings": {
|
|
25515
|
+
capName: "pipeline-analytics",
|
|
25516
|
+
capScope: "device",
|
|
25517
|
+
addonId: null,
|
|
25518
|
+
access: "delete"
|
|
25519
|
+
},
|
|
25403
25520
|
"pipelineExecutor.cacheFrameInPool": {
|
|
25404
25521
|
capName: "pipeline-executor",
|
|
25405
25522
|
capScope: "system",
|
|
@@ -27320,6 +27437,12 @@ Object.freeze({
|
|
|
27320
27437
|
addonId: null,
|
|
27321
27438
|
access: "delete"
|
|
27322
27439
|
},
|
|
27440
|
+
"vectorStore.getByIds": {
|
|
27441
|
+
capName: "vector-store",
|
|
27442
|
+
capScope: "system",
|
|
27443
|
+
addonId: null,
|
|
27444
|
+
access: "view"
|
|
27445
|
+
},
|
|
27323
27446
|
"vectorStore.query": {
|
|
27324
27447
|
capName: "vector-store",
|
|
27325
27448
|
capScope: "system",
|
|
@@ -27594,6 +27717,30 @@ TimelapseRuleInputSchema.extend({
|
|
|
27594
27717
|
createdAt: number(),
|
|
27595
27718
|
updatedAt: number()
|
|
27596
27719
|
});
|
|
27720
|
+
object({
|
|
27721
|
+
/**
|
|
27722
|
+
* Fraction of the box's own size added on EACH side before cutting.
|
|
27723
|
+
*
|
|
27724
|
+
* CLIP is trained on natural images WITH surroundings; a pixel-tight crop
|
|
27725
|
+
* removes exactly the context it is strongest on (a dog cut to its outline
|
|
27726
|
+
* is a dark blob). The right value is an empirical question, which is why it
|
|
27727
|
+
* is a setting: 0 / 0.15 / 0.2 / 0.5 are the interesting points.
|
|
27728
|
+
*/
|
|
27729
|
+
paddingRatio: number().min(0).max(4),
|
|
27730
|
+
/**
|
|
27731
|
+
* Square the window (in PIXELS) before cutting.
|
|
27732
|
+
*
|
|
27733
|
+
* CLIP's input is square, so a tall bbox resized straight to NxN is squashed
|
|
27734
|
+
* — a standing person becomes a shape the model never saw. Squaring costs
|
|
27735
|
+
* extra background, which is context the model wants anyway. Off by default
|
|
27736
|
+
* because the live path has never squared and the stored index reflects that.
|
|
27737
|
+
*/
|
|
27738
|
+
square: boolean()
|
|
27739
|
+
});
|
|
27740
|
+
({
|
|
27741
|
+
paddingRatio: .15,
|
|
27742
|
+
square: false
|
|
27743
|
+
}).paddingRatio;
|
|
27597
27744
|
/**
|
|
27598
27745
|
* Deterministic SHA-256 hash of an arbitrary serialisable value. The
|
|
27599
27746
|
* canonical form sorts object keys alphabetically at every depth so two
|
|
@@ -38150,13 +38297,305 @@ var require_src = /* @__PURE__ */ __commonJSMin(((exports) => {
|
|
|
38150
38297
|
__exportStar(require_util(), exports);
|
|
38151
38298
|
}));
|
|
38152
38299
|
//#endregion
|
|
38153
|
-
//#region src/mappers/builders/
|
|
38300
|
+
//#region src/mappers/builders/rtcp-gate.ts
|
|
38154
38301
|
var import_src = require_src();
|
|
38302
|
+
/** `packet@42ms` / `timeout@1000ms` — one compact log field. */
|
|
38303
|
+
function formatRtcpGate(resolution) {
|
|
38304
|
+
if (resolution === null) return "pending";
|
|
38305
|
+
return `${resolution.reason}@${resolution.waitedMs}ms`;
|
|
38306
|
+
}
|
|
38307
|
+
/**
|
|
38308
|
+
* Resolve as soon as ANY packet arrives on `socket`, or after `timeoutMs`.
|
|
38309
|
+
* One-shot: the listener is detached on either path.
|
|
38310
|
+
*/
|
|
38311
|
+
function makeRtcpGate(socket, timeoutMs) {
|
|
38312
|
+
const armedAt = Date.now();
|
|
38313
|
+
return new Promise((resolve) => {
|
|
38314
|
+
const onMessage = () => {
|
|
38315
|
+
socket.removeListener("message", onMessage);
|
|
38316
|
+
clearTimeout(timer);
|
|
38317
|
+
resolve({
|
|
38318
|
+
reason: "packet",
|
|
38319
|
+
waitedMs: Date.now() - armedAt
|
|
38320
|
+
});
|
|
38321
|
+
};
|
|
38322
|
+
const timer = setTimeout(() => {
|
|
38323
|
+
socket.removeListener("message", onMessage);
|
|
38324
|
+
resolve({
|
|
38325
|
+
reason: "timeout",
|
|
38326
|
+
waitedMs: Date.now() - armedAt
|
|
38327
|
+
});
|
|
38328
|
+
}, timeoutMs);
|
|
38329
|
+
socket.on("message", onMessage);
|
|
38330
|
+
});
|
|
38331
|
+
}
|
|
38332
|
+
/**
|
|
38333
|
+
* The resolutions we offer, before rates are attached. Same list the delegate
|
|
38334
|
+
* advertised before R2 — only the frame rate changes, so a controller that had
|
|
38335
|
+
* already negotiated a resolution keeps finding it.
|
|
38336
|
+
*/
|
|
38337
|
+
var CANDIDATE_RESOLUTIONS = [
|
|
38338
|
+
[1920, 1080],
|
|
38339
|
+
[1280, 720],
|
|
38340
|
+
[1024, 768],
|
|
38341
|
+
[640, 480],
|
|
38342
|
+
[640, 360],
|
|
38343
|
+
[480, 360],
|
|
38344
|
+
[480, 270],
|
|
38345
|
+
[320, 240],
|
|
38346
|
+
[320, 180]
|
|
38347
|
+
];
|
|
38348
|
+
/**
|
|
38349
|
+
* Resolve the real frame rate of each profile slot.
|
|
38350
|
+
*
|
|
38351
|
+
* A slot with no measurement, no publication and no assignment simply does not
|
|
38352
|
+
* appear in the map — the caller then advertises {@link ASSUMED_FPS} and says
|
|
38353
|
+
* so. Absence is never silently rendered as a number.
|
|
38354
|
+
*/
|
|
38355
|
+
function resolveProfileFps(input) {
|
|
38356
|
+
const publishedByProfile = publishedFpsByProfile(input.slots, input.camStreams);
|
|
38357
|
+
const out = /* @__PURE__ */ new Map();
|
|
38358
|
+
for (const choice of input.choices) {
|
|
38359
|
+
if (choice.target.kind !== "profile") continue;
|
|
38360
|
+
const profile = choice.target.profile;
|
|
38361
|
+
const measured = clampFps(choice.inputFps);
|
|
38362
|
+
if (measured !== null) {
|
|
38363
|
+
out.set(profile, {
|
|
38364
|
+
profile,
|
|
38365
|
+
fps: measured,
|
|
38366
|
+
source: "measured"
|
|
38367
|
+
});
|
|
38368
|
+
continue;
|
|
38369
|
+
}
|
|
38370
|
+
const published = clampFps(publishedByProfile.get(profile) ?? null);
|
|
38371
|
+
out.set(profile, published !== null ? {
|
|
38372
|
+
profile,
|
|
38373
|
+
fps: published,
|
|
38374
|
+
source: "published"
|
|
38375
|
+
} : {
|
|
38376
|
+
profile,
|
|
38377
|
+
fps: 30,
|
|
38378
|
+
source: "assumed"
|
|
38379
|
+
});
|
|
38380
|
+
}
|
|
38381
|
+
for (const [profile, fps] of publishedByProfile) {
|
|
38382
|
+
if (out.has(profile)) continue;
|
|
38383
|
+
const published = clampFps(fps);
|
|
38384
|
+
if (published !== null) out.set(profile, {
|
|
38385
|
+
profile,
|
|
38386
|
+
fps: published,
|
|
38387
|
+
source: "published"
|
|
38388
|
+
});
|
|
38389
|
+
}
|
|
38390
|
+
return out;
|
|
38391
|
+
}
|
|
38392
|
+
/**
|
|
38393
|
+
* Attach a frame rate to each candidate resolution by asking the REAL picker
|
|
38394
|
+
* which slot the START path would dial for it. That coupling is the point: if
|
|
38395
|
+
* the picker's steering changes, the advertisement changes with it instead of
|
|
38396
|
+
* drifting into a second, silently different opinion.
|
|
38397
|
+
*/
|
|
38398
|
+
function deriveAdvertisedResolutions(input) {
|
|
38399
|
+
const candidates = input.candidates.length > 0 ? input.candidates : CANDIDATE_RESOLUTIONS;
|
|
38400
|
+
const seen = /* @__PURE__ */ new Set();
|
|
38401
|
+
const out = [];
|
|
38402
|
+
for (const [width, height] of candidates) {
|
|
38403
|
+
const picked = pickPreferredRtspEntry(input.entries, input.pref, input.deviceId, { targetResolution: {
|
|
38404
|
+
width,
|
|
38405
|
+
height
|
|
38406
|
+
} });
|
|
38407
|
+
const profile = picked === null ? null : toCamProfile(picked.profileId);
|
|
38408
|
+
const resolved = profile === null ? void 0 : input.fpsByProfile.get(profile);
|
|
38409
|
+
const advertised = {
|
|
38410
|
+
width,
|
|
38411
|
+
height,
|
|
38412
|
+
fps: resolved?.fps ?? 30,
|
|
38413
|
+
profile,
|
|
38414
|
+
source: resolved?.source ?? "assumed"
|
|
38415
|
+
};
|
|
38416
|
+
const key = `${advertised.width}x${advertised.height}@${advertised.fps}`;
|
|
38417
|
+
if (seen.has(key)) continue;
|
|
38418
|
+
seen.add(key);
|
|
38419
|
+
out.push(advertised);
|
|
38420
|
+
}
|
|
38421
|
+
return out;
|
|
38422
|
+
}
|
|
38423
|
+
/** Project onto the `[width, height, fps]` triples hap-nodejs expects. */
|
|
38424
|
+
function toHapResolutions(advertised) {
|
|
38425
|
+
return advertised.map((a) => [
|
|
38426
|
+
a.width,
|
|
38427
|
+
a.height,
|
|
38428
|
+
a.fps
|
|
38429
|
+
]);
|
|
38430
|
+
}
|
|
38431
|
+
/** Compact `1280x720@10(measured)` rendering for a single log field. */
|
|
38432
|
+
function formatAdvertisedResolutions(advertised) {
|
|
38433
|
+
return advertised.map((a) => `${a.width}x${a.height}@${a.fps}/${a.profile ?? "-"}(${a.source})`);
|
|
38434
|
+
}
|
|
38435
|
+
function publishedFpsByProfile(slots, camStreams) {
|
|
38436
|
+
const fpsByCamStream = /* @__PURE__ */ new Map();
|
|
38437
|
+
for (const stream of camStreams) if (typeof stream.fps === "number") fpsByCamStream.set(stream.camStreamId, stream.fps);
|
|
38438
|
+
const out = /* @__PURE__ */ new Map();
|
|
38439
|
+
for (const slot of slots) {
|
|
38440
|
+
if (slot.sourceCamStreamId === null) continue;
|
|
38441
|
+
const fps = fpsByCamStream.get(slot.sourceCamStreamId);
|
|
38442
|
+
if (fps !== void 0) out.set(slot.profile, fps);
|
|
38443
|
+
}
|
|
38444
|
+
return out;
|
|
38445
|
+
}
|
|
38446
|
+
/**
|
|
38447
|
+
* Coerce a probe reading into an advertisable integer rate, or null when the
|
|
38448
|
+
* reading carries no information (absent, zero because the broker is idle,
|
|
38449
|
+
* negative, NaN, or below the representable floor).
|
|
38450
|
+
*/
|
|
38451
|
+
function clampFps(value) {
|
|
38452
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return null;
|
|
38453
|
+
const floored = Math.floor(value);
|
|
38454
|
+
if (floored < 1) return null;
|
|
38455
|
+
return Math.min(floored, 30);
|
|
38456
|
+
}
|
|
38457
|
+
var CAM_PROFILES = [
|
|
38458
|
+
"high",
|
|
38459
|
+
"mid",
|
|
38460
|
+
"low"
|
|
38461
|
+
];
|
|
38462
|
+
/**
|
|
38463
|
+
* `PickedStream.profileId` is the brokerId suffix, which for a profile-keyed
|
|
38464
|
+
* entry IS the profile name. Anything else (a raw cam-stream id) is not a
|
|
38465
|
+
* profile and must not be coerced into one.
|
|
38466
|
+
*/
|
|
38467
|
+
function toCamProfile(profileId) {
|
|
38468
|
+
return CAM_PROFILES.find((p) => p === profileId) ?? null;
|
|
38469
|
+
}
|
|
38470
|
+
//#endregion
|
|
38471
|
+
//#region src/mappers/builders/stream-telemetry.ts
|
|
38472
|
+
/**
|
|
38473
|
+
* Every branch on the streaming path that discards work, and its starting
|
|
38474
|
+
* count. This object is the single source of the reason set: the union below
|
|
38475
|
+
* is its `keyof`, so adding a silent `return` without adding a reason here is
|
|
38476
|
+
* a compile error rather than an invisible hole.
|
|
38477
|
+
*/
|
|
38478
|
+
var ZERO_DROP_COUNTERS = {
|
|
38479
|
+
/** Outbound: the leg has no SRTP session (prepareStream init failed). */
|
|
38480
|
+
"no-srtp-session": 0,
|
|
38481
|
+
/** Outbound: the leg has no return-path gate — forwarding was never armed. */
|
|
38482
|
+
"no-gate": 0,
|
|
38483
|
+
/** Outbound: werift refused to encrypt the packet. */
|
|
38484
|
+
"srtp-encrypt-failed": 0,
|
|
38485
|
+
/** Outbound: the UDP send itself errored. */
|
|
38486
|
+
"srtp-send-failed": 0,
|
|
38487
|
+
/** Outbound RTCP: the leg has no SRTCP context, so no Sender Report can go out. */
|
|
38488
|
+
"rtcp-no-srtcp": 0,
|
|
38489
|
+
/** Outbound RTCP: building or encrypting the Sender Report failed. */
|
|
38490
|
+
"rtcp-encrypt-failed": 0,
|
|
38491
|
+
/** Upstream: packet shorter than an RTP header. */
|
|
38492
|
+
"upstream-short-packet": 0,
|
|
38493
|
+
/** Upstream: no inbound SRTP context (init failed at prepareStream). */
|
|
38494
|
+
"upstream-no-srtp": 0,
|
|
38495
|
+
/** Upstream: SRTP authentication/decrypt failed. */
|
|
38496
|
+
"upstream-decrypt-failed": 0,
|
|
38497
|
+
/** Upstream: decrypted bytes did not parse as RTP. */
|
|
38498
|
+
"upstream-parse-failed": 0,
|
|
38499
|
+
/** Upstream: audio arrived before `handleStreamRequest('start')` settled. */
|
|
38500
|
+
"upstream-before-start": 0,
|
|
38501
|
+
/** Upstream: payload type is not the negotiated audio PT (RTCP / keepalive). */
|
|
38502
|
+
"upstream-pt-mismatch": 0,
|
|
38503
|
+
/** Upstream: RTP carried a zero-length payload. */
|
|
38504
|
+
"upstream-empty-payload": 0,
|
|
38505
|
+
/** Upstream: no camera-side talk session, so the frame has nowhere to go. */
|
|
38506
|
+
"upstream-no-talk-session": 0,
|
|
38507
|
+
/** Upstream: the camera-side `pushTalkAudio` rejected the frame. */
|
|
38508
|
+
"upstream-push-failed": 0,
|
|
38509
|
+
/** Snapshot: the `snapshot` cap returned nothing. */
|
|
38510
|
+
"snapshot-unavailable": 0
|
|
38511
|
+
};
|
|
38512
|
+
function emptyDropCounters() {
|
|
38513
|
+
return { ...ZERO_DROP_COUNTERS };
|
|
38514
|
+
}
|
|
38515
|
+
/** Immutable increment — returns a new record, never touches the input. */
|
|
38516
|
+
function recordDrop(counters, reason) {
|
|
38517
|
+
return {
|
|
38518
|
+
...counters,
|
|
38519
|
+
[reason]: counters[reason] + 1
|
|
38520
|
+
};
|
|
38521
|
+
}
|
|
38522
|
+
/** Only the reasons that actually fired, so a clean session logs `{}`. */
|
|
38523
|
+
function nonZeroDrops(counters) {
|
|
38524
|
+
const out = {};
|
|
38525
|
+
for (const [reason, count] of Object.entries(counters)) if (count > 0) out[reason] = count;
|
|
38526
|
+
return out;
|
|
38527
|
+
}
|
|
38528
|
+
/**
|
|
38529
|
+
* Classify a packet received on one of our advertised return ports.
|
|
38530
|
+
*
|
|
38531
|
+
* RTP and RTCP arrive on the same symmetric port here. RTCP packet types are
|
|
38532
|
+
* 200..207, which sit inside the 192..223 band RFC 5761 §4 reserves precisely
|
|
38533
|
+
* so the two can be told apart on a shared socket (an RTP packet's marker bit
|
|
38534
|
+
* plus payload type can never land there for any payload type we negotiate).
|
|
38535
|
+
* The payloads are SRTP/SRTCP-encrypted, but both keep the first two bytes in
|
|
38536
|
+
* the clear.
|
|
38537
|
+
*/
|
|
38538
|
+
function classifyInboundPacket(packet) {
|
|
38539
|
+
if (packet.length < 2) return "malformed";
|
|
38540
|
+
if ((packet[0] >> 6 & 3) !== 2) return "malformed";
|
|
38541
|
+
const typeByte = packet[1];
|
|
38542
|
+
if (typeByte >= 192 && typeByte <= 223) return "rtcp";
|
|
38543
|
+
return "rtp";
|
|
38544
|
+
}
|
|
38545
|
+
/**
|
|
38546
|
+
* Build the meta for `export-hap: stream session summary` — the single line a
|
|
38547
|
+
* future session greps to answer "why did this session die".
|
|
38548
|
+
*/
|
|
38549
|
+
function summariseSession(snapshot) {
|
|
38550
|
+
const durationMs = snapshot.startedAtMs !== null && snapshot.endedAtMs !== null ? snapshot.endedAtMs - snapshot.startedAtMs : null;
|
|
38551
|
+
const negotiated = snapshot.negotiated;
|
|
38552
|
+
const slot = snapshot.selectedSlot;
|
|
38553
|
+
return {
|
|
38554
|
+
sessionId: snapshot.sessionId,
|
|
38555
|
+
durationMs,
|
|
38556
|
+
stopRequestedByController: snapshot.stopRequestedByController,
|
|
38557
|
+
ffmpegExitCode: snapshot.ffmpegExit?.code ?? null,
|
|
38558
|
+
ffmpegExitSignal: snapshot.ffmpegExit?.signal ?? null,
|
|
38559
|
+
negotiatedResolution: negotiated ? `${negotiated.width}x${negotiated.height}` : null,
|
|
38560
|
+
negotiatedFps: negotiated?.fps ?? null,
|
|
38561
|
+
negotiatedMaxBitrateKbps: negotiated?.maxBitrateKbps ?? null,
|
|
38562
|
+
selectedProfile: slot?.profile ?? null,
|
|
38563
|
+
selectedBrokerId: slot?.brokerId ?? null,
|
|
38564
|
+
advertisedFps: slot?.advertisedFps ?? null,
|
|
38565
|
+
advertisedFpsSource: slot?.advertisedFpsSource ?? null,
|
|
38566
|
+
transcode: slot?.transcode ?? null,
|
|
38567
|
+
videoPacketsForwarded: snapshot.videoPacketsForwarded,
|
|
38568
|
+
audioPacketsForwarded: snapshot.audioPacketsForwarded,
|
|
38569
|
+
videoRtcpSrSent: snapshot.videoRtcpSrSent,
|
|
38570
|
+
audioRtcpSrSent: snapshot.audioRtcpSrSent,
|
|
38571
|
+
videoRtcpReceived: snapshot.videoRtcpReceived,
|
|
38572
|
+
audioRtcpReceived: snapshot.audioRtcpReceived,
|
|
38573
|
+
videoRtpReceived: snapshot.videoRtpReceived,
|
|
38574
|
+
audioRtpReceived: snapshot.audioRtpReceived,
|
|
38575
|
+
videoGate: formatRtcpGate(snapshot.videoGate),
|
|
38576
|
+
audioGate: formatRtcpGate(snapshot.audioGate),
|
|
38577
|
+
mediaStarved: snapshot.videoPacketsForwarded === 0,
|
|
38578
|
+
drops: nonZeroDrops(snapshot.drops)
|
|
38579
|
+
};
|
|
38580
|
+
}
|
|
38581
|
+
//#endregion
|
|
38582
|
+
//#region src/mappers/builders/camera-streams.ts
|
|
38155
38583
|
var SRTP_KEY_LEN = 16;
|
|
38156
38584
|
var SRTP_SALT_LEN = 14;
|
|
38585
|
+
/**
|
|
38586
|
+
* Cadence of the per-session heartbeat log.
|
|
38587
|
+
*
|
|
38588
|
+
* This timer LOGS ONLY. It never kills ffmpeg, never closes a socket and never
|
|
38589
|
+
* ends a session. That distinction matters: the two mainstream open-source
|
|
38590
|
+
* HomeKit camera stacks arm an idle watchdog that tears the stream down at
|
|
38591
|
+
* exactly 30 000 ms, and the ~31 s teardown here was misdiagnosed as one for a
|
|
38592
|
+
* long time before it was proven that this accessory has no such timer. Do not
|
|
38593
|
+
* make this one act.
|
|
38594
|
+
*/
|
|
38595
|
+
var SESSION_HEARTBEAT_MS = 5e3;
|
|
38157
38596
|
var OPUS_BITRATE_KBPS = 24;
|
|
38158
38597
|
var OPUS_CHANNELS = 1;
|
|
38159
|
-
function buildCameraStreamingDelegate(bctx) {
|
|
38598
|
+
function buildCameraStreamingDelegate(bctx, advertised) {
|
|
38160
38599
|
const { ctx, numericDeviceId } = bctx;
|
|
38161
38600
|
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
38162
38601
|
const sessions = /* @__PURE__ */ new Map();
|
|
@@ -38175,7 +38614,7 @@ function buildCameraStreamingDelegate(bctx) {
|
|
|
38175
38614
|
});
|
|
38176
38615
|
},
|
|
38177
38616
|
handleStreamRequest(request, callback) {
|
|
38178
|
-
handleStreamRequest(request, sessions, bctx).then(() => callback()).catch((err) => {
|
|
38617
|
+
handleStreamRequest(request, sessions, bctx, advertised).then(() => callback()).catch((err) => {
|
|
38179
38618
|
log.warn("export-hap: handleStreamRequest failed", { meta: { error: errMsg$8(err) } });
|
|
38180
38619
|
callback(err instanceof Error ? err : new Error(errMsg$8(err)));
|
|
38181
38620
|
});
|
|
@@ -38196,58 +38635,7 @@ function buildCameraStreamingDelegate(bctx) {
|
|
|
38196
38635
|
H264Level.LEVEL4_0
|
|
38197
38636
|
]
|
|
38198
38637
|
},
|
|
38199
|
-
resolutions:
|
|
38200
|
-
[
|
|
38201
|
-
1920,
|
|
38202
|
-
1080,
|
|
38203
|
-
30
|
|
38204
|
-
],
|
|
38205
|
-
[
|
|
38206
|
-
1280,
|
|
38207
|
-
720,
|
|
38208
|
-
30
|
|
38209
|
-
],
|
|
38210
|
-
[
|
|
38211
|
-
1024,
|
|
38212
|
-
768,
|
|
38213
|
-
30
|
|
38214
|
-
],
|
|
38215
|
-
[
|
|
38216
|
-
640,
|
|
38217
|
-
480,
|
|
38218
|
-
30
|
|
38219
|
-
],
|
|
38220
|
-
[
|
|
38221
|
-
640,
|
|
38222
|
-
360,
|
|
38223
|
-
30
|
|
38224
|
-
],
|
|
38225
|
-
[
|
|
38226
|
-
480,
|
|
38227
|
-
360,
|
|
38228
|
-
30
|
|
38229
|
-
],
|
|
38230
|
-
[
|
|
38231
|
-
480,
|
|
38232
|
-
270,
|
|
38233
|
-
30
|
|
38234
|
-
],
|
|
38235
|
-
[
|
|
38236
|
-
320,
|
|
38237
|
-
240,
|
|
38238
|
-
30
|
|
38239
|
-
],
|
|
38240
|
-
[
|
|
38241
|
-
320,
|
|
38242
|
-
240,
|
|
38243
|
-
15
|
|
38244
|
-
],
|
|
38245
|
-
[
|
|
38246
|
-
320,
|
|
38247
|
-
180,
|
|
38248
|
-
30
|
|
38249
|
-
]
|
|
38250
|
-
]
|
|
38638
|
+
resolutions: toHapResolutions(advertised.resolutions)
|
|
38251
38639
|
},
|
|
38252
38640
|
audio: {
|
|
38253
38641
|
codecs: [{
|
|
@@ -38262,10 +38650,16 @@ function buildCameraStreamingDelegate(bctx) {
|
|
|
38262
38650
|
},
|
|
38263
38651
|
dispose: async () => {
|
|
38264
38652
|
for (const session of sessions.values()) {
|
|
38653
|
+
session.teardownTrigger = "accessory-dispose";
|
|
38654
|
+
const hadFfmpeg = session.ffmpeg !== null;
|
|
38265
38655
|
killFfmpeg(session, ctx, numericDeviceId);
|
|
38656
|
+
stopHeartbeat(session);
|
|
38657
|
+
if (!hadFfmpeg) logSessionSummary(session, log, "accessory-dispose-no-ffmpeg");
|
|
38266
38658
|
await closeIntercomTalkSession(session, bctx).catch(() => void 0);
|
|
38267
38659
|
closeSocket(session.videoUdp);
|
|
38268
38660
|
closeSocket(session.audioUdp);
|
|
38661
|
+
closeSocket(session.videoLoopUdp);
|
|
38662
|
+
closeSocket(session.audioLoopUdp);
|
|
38269
38663
|
}
|
|
38270
38664
|
sessions.clear();
|
|
38271
38665
|
}
|
|
@@ -38275,7 +38669,10 @@ async function handleSnapshot(bctx, request) {
|
|
|
38275
38669
|
const { proxy, ctx, numericDeviceId } = bctx;
|
|
38276
38670
|
const result = await proxy.snapshot?.getSnapshot({});
|
|
38277
38671
|
if (!result || typeof result.base64 !== "string") {
|
|
38278
|
-
ctx.logger.withTags({ deviceId: numericDeviceId }).
|
|
38672
|
+
ctx.logger.withTags({ deviceId: numericDeviceId }).info("export-hap: snapshot dropped", { meta: {
|
|
38673
|
+
reason: "snapshot-unavailable",
|
|
38674
|
+
capBound: proxy.snapshot !== void 0
|
|
38675
|
+
} });
|
|
38279
38676
|
throw new Error("snapshot unavailable");
|
|
38280
38677
|
}
|
|
38281
38678
|
return Buffer.from(result.base64, "base64");
|
|
@@ -38347,6 +38744,26 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
38347
38744
|
const videoSsrc = randomSsrc();
|
|
38348
38745
|
const audioSsrc = randomSsrc();
|
|
38349
38746
|
const session = {
|
|
38747
|
+
sessionId: request.sessionID,
|
|
38748
|
+
startedAtMs: null,
|
|
38749
|
+
endedAtMs: null,
|
|
38750
|
+
negotiated: null,
|
|
38751
|
+
selectedSlot: null,
|
|
38752
|
+
ffmpegExit: null,
|
|
38753
|
+
stopRequestedByController: false,
|
|
38754
|
+
teardownTrigger: null,
|
|
38755
|
+
drops: emptyDropCounters(),
|
|
38756
|
+
videoPacketsForwarded: 0,
|
|
38757
|
+
audioPacketsForwarded: 0,
|
|
38758
|
+
videoRtcpSrSent: 0,
|
|
38759
|
+
audioRtcpSrSent: 0,
|
|
38760
|
+
videoRtcpReceived: 0,
|
|
38761
|
+
audioRtcpReceived: 0,
|
|
38762
|
+
videoRtpReceived: 0,
|
|
38763
|
+
audioRtpReceived: 0,
|
|
38764
|
+
videoGate: null,
|
|
38765
|
+
audioGate: null,
|
|
38766
|
+
heartbeat: null,
|
|
38350
38767
|
hapVideoPort: request.video.port,
|
|
38351
38768
|
hapAddress: request.targetAddress,
|
|
38352
38769
|
videoSrtpKey: request.video.srtp_key,
|
|
@@ -38391,19 +38808,33 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
38391
38808
|
audioRtcpIntervalMs: 5e3
|
|
38392
38809
|
};
|
|
38393
38810
|
sessions.set(request.sessionID, session);
|
|
38811
|
+
const tagLog = bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId });
|
|
38394
38812
|
session.videoSendGate = makeRtcpGate(videoUdp, 1e3);
|
|
38395
38813
|
session.audioSendGate = makeRtcpGate(audioUdp, 1e3);
|
|
38396
|
-
|
|
38397
|
-
|
|
38398
|
-
|
|
38814
|
+
session.videoSendGate.then((resolution) => {
|
|
38815
|
+
session.videoGate = resolution;
|
|
38816
|
+
logGateResolved(session, "video", resolution, tagLog);
|
|
38817
|
+
});
|
|
38818
|
+
session.audioSendGate.then((resolution) => {
|
|
38819
|
+
session.audioGate = resolution;
|
|
38820
|
+
logGateResolved(session, "audio", resolution, tagLog);
|
|
38821
|
+
});
|
|
38822
|
+
videoUdp.on("message", (packet) => {
|
|
38823
|
+
countInbound(session, "video", packet, tagLog);
|
|
38824
|
+
});
|
|
38825
|
+
audioUdp.on("message", (packet) => {
|
|
38826
|
+
countInbound(session, "audio", packet, tagLog);
|
|
38827
|
+
});
|
|
38399
38828
|
videoLoopUdp.on("message", (rtpPacket) => {
|
|
38400
|
-
videoPacketsForwarded += 1;
|
|
38401
|
-
if (videoPacketsForwarded === 1
|
|
38829
|
+
session.videoPacketsForwarded += 1;
|
|
38830
|
+
if (session.videoPacketsForwarded === 1) tagLog.info("export-hap: first video packet from ffmpeg", { meta: {
|
|
38831
|
+
sessionId: session.sessionId,
|
|
38832
|
+
bytes: rtpPacket.length
|
|
38833
|
+
} });
|
|
38402
38834
|
forwardEncryptedRtp(session, rtpPacket, "video", tagLog);
|
|
38403
38835
|
});
|
|
38404
38836
|
audioLoopUdp.on("message", (rtpPacket) => {
|
|
38405
|
-
audioPacketsForwarded += 1;
|
|
38406
|
-
if (audioPacketsForwarded === 1 || audioPacketsForwarded % 100 === 0) tagLog.info("export-hap: audio loopback packets forwarded", { meta: { count: audioPacketsForwarded } });
|
|
38837
|
+
session.audioPacketsForwarded += 1;
|
|
38407
38838
|
forwardEncryptedRtp(session, rtpPacket, "audio", tagLog);
|
|
38408
38839
|
});
|
|
38409
38840
|
audioUdp.on("message", (packet, rinfo) => {
|
|
@@ -38411,6 +38842,19 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
38411
38842
|
bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId }).debug("export-hap: incoming-audio handler error (dropped)", { meta: { error: errMsg$8(err) } });
|
|
38412
38843
|
});
|
|
38413
38844
|
});
|
|
38845
|
+
tagLog.info("export-hap: stream prepared", { meta: {
|
|
38846
|
+
sessionId: request.sessionID,
|
|
38847
|
+
controllerAddress: request.targetAddress,
|
|
38848
|
+
addressVersion: ipVersion,
|
|
38849
|
+
localIp,
|
|
38850
|
+
advertisedVideoPort: localVideoPort,
|
|
38851
|
+
advertisedAudioPort: localAudioPort,
|
|
38852
|
+
controllerVideoPort: request.video.port,
|
|
38853
|
+
controllerAudioPort: request.audio.port,
|
|
38854
|
+
videoSsrc,
|
|
38855
|
+
audioSsrc,
|
|
38856
|
+
upstreamAudioDecrypt: upstreamAudioSrtp !== null
|
|
38857
|
+
} });
|
|
38414
38858
|
return {
|
|
38415
38859
|
video: {
|
|
38416
38860
|
port: localVideoPort,
|
|
@@ -38485,30 +38929,110 @@ function sameIpv4Subnet(a, mask, b) {
|
|
|
38485
38929
|
async function bindLoopback(ipVersion) {
|
|
38486
38930
|
return bindUdp(ipVersion, ipVersion === "ipv6" ? "::1" : "127.0.0.1");
|
|
38487
38931
|
}
|
|
38932
|
+
/** Book a named drop. Every silent `return` on the streaming path routes here. */
|
|
38933
|
+
function drop(session, reason) {
|
|
38934
|
+
session.drops = recordDrop(session.drops, reason);
|
|
38935
|
+
}
|
|
38488
38936
|
/**
|
|
38489
|
-
*
|
|
38490
|
-
*
|
|
38491
|
-
*
|
|
38492
|
-
* iOS sees a return-path handshake before our actual stream begins.
|
|
38493
|
-
*
|
|
38494
|
-
* One-shot: the listener is removed on the first message OR on
|
|
38495
|
-
* timeout — the gate then stays resolved for the rest of the session.
|
|
38937
|
+
* Report a gate resolution. `timeout` is the interesting one: it means we are
|
|
38938
|
+
* about to stream into a port the controller never touched, and it was
|
|
38939
|
+
* indistinguishable from a successful probe until now.
|
|
38496
38940
|
*/
|
|
38497
|
-
function
|
|
38498
|
-
|
|
38499
|
-
|
|
38500
|
-
|
|
38501
|
-
|
|
38502
|
-
|
|
38503
|
-
|
|
38504
|
-
|
|
38505
|
-
|
|
38506
|
-
|
|
38507
|
-
|
|
38508
|
-
|
|
38941
|
+
function logGateResolved(session, leg, resolution, log) {
|
|
38942
|
+
const meta = {
|
|
38943
|
+
sessionId: session.sessionId,
|
|
38944
|
+
leg,
|
|
38945
|
+
reason: resolution.reason,
|
|
38946
|
+
waitedMs: resolution.waitedMs
|
|
38947
|
+
};
|
|
38948
|
+
if (resolution.reason === "packet") log.info("export-hap: RTCP gate opened on a REAL controller packet", { meta });
|
|
38949
|
+
else log.warn("export-hap: RTCP gate opened on the 1s FALLBACK — controller never probed", { meta });
|
|
38950
|
+
}
|
|
38951
|
+
/** Count and classify one packet the controller sent to a return port. */
|
|
38952
|
+
function countInbound(session, leg, packet, log) {
|
|
38953
|
+
const kind = classifyInboundPacket(packet);
|
|
38954
|
+
const firstRtcp = kind === "rtcp" && (leg === "video" ? session.videoRtcpReceived : session.audioRtcpReceived) === 0;
|
|
38955
|
+
if (leg === "video") {
|
|
38956
|
+
if (kind === "rtcp") session.videoRtcpReceived += 1;
|
|
38957
|
+
else if (kind === "rtp") session.videoRtpReceived += 1;
|
|
38958
|
+
} else if (kind === "rtcp") session.audioRtcpReceived += 1;
|
|
38959
|
+
else if (kind === "rtp") session.audioRtpReceived += 1;
|
|
38960
|
+
if (firstRtcp) log.info("export-hap: first inbound RTCP from controller", { meta: {
|
|
38961
|
+
sessionId: session.sessionId,
|
|
38962
|
+
leg,
|
|
38963
|
+
bytes: packet.length
|
|
38964
|
+
} });
|
|
38965
|
+
}
|
|
38966
|
+
/** Snapshot every counter into the summary meta. */
|
|
38967
|
+
function sessionSummaryMeta(session) {
|
|
38968
|
+
return summariseSession({
|
|
38969
|
+
sessionId: session.sessionId,
|
|
38970
|
+
startedAtMs: session.startedAtMs,
|
|
38971
|
+
endedAtMs: session.endedAtMs,
|
|
38972
|
+
negotiated: session.negotiated,
|
|
38973
|
+
selectedSlot: session.selectedSlot,
|
|
38974
|
+
videoPacketsForwarded: session.videoPacketsForwarded,
|
|
38975
|
+
audioPacketsForwarded: session.audioPacketsForwarded,
|
|
38976
|
+
videoRtcpSrSent: session.videoRtcpSrSent,
|
|
38977
|
+
audioRtcpSrSent: session.audioRtcpSrSent,
|
|
38978
|
+
videoRtcpReceived: session.videoRtcpReceived,
|
|
38979
|
+
audioRtcpReceived: session.audioRtcpReceived,
|
|
38980
|
+
videoRtpReceived: session.videoRtpReceived,
|
|
38981
|
+
audioRtpReceived: session.audioRtpReceived,
|
|
38982
|
+
videoGate: session.videoGate,
|
|
38983
|
+
audioGate: session.audioGate,
|
|
38984
|
+
drops: session.drops,
|
|
38985
|
+
ffmpegExit: session.ffmpegExit,
|
|
38986
|
+
stopRequestedByController: session.stopRequestedByController
|
|
38509
38987
|
});
|
|
38510
38988
|
}
|
|
38511
38989
|
/**
|
|
38990
|
+
* Arm the per-session heartbeat. LOGS ONLY — see `SESSION_HEARTBEAT_MS`. It
|
|
38991
|
+
* exists so a session that stops producing media says so while it is still
|
|
38992
|
+
* alive, instead of being reconstructed after the fact from its final line.
|
|
38993
|
+
*/
|
|
38994
|
+
function armHeartbeat(session, log) {
|
|
38995
|
+
if (session.heartbeat) clearInterval(session.heartbeat);
|
|
38996
|
+
let lastVideo = session.videoPacketsForwarded;
|
|
38997
|
+
const timer = setInterval(() => {
|
|
38998
|
+
const forwarded = session.videoPacketsForwarded - lastVideo;
|
|
38999
|
+
lastVideo = session.videoPacketsForwarded;
|
|
39000
|
+
log.info("export-hap: stream heartbeat", { meta: {
|
|
39001
|
+
...sessionSummaryMeta(session),
|
|
39002
|
+
videoPacketsSinceLastBeat: forwarded,
|
|
39003
|
+
videoStalled: forwarded === 0
|
|
39004
|
+
} });
|
|
39005
|
+
}, SESSION_HEARTBEAT_MS);
|
|
39006
|
+
timer.unref();
|
|
39007
|
+
session.heartbeat = timer;
|
|
39008
|
+
}
|
|
39009
|
+
function stopHeartbeat(session) {
|
|
39010
|
+
if (!session.heartbeat) return;
|
|
39011
|
+
clearInterval(session.heartbeat);
|
|
39012
|
+
session.heartbeat = null;
|
|
39013
|
+
}
|
|
39014
|
+
/**
|
|
39015
|
+
* **`export-hap: stream session summary` is the line to grep** when asking why
|
|
39016
|
+
* a HomeKit session died. It carries the negotiated parameters, the slot we
|
|
39017
|
+
* dialled and the rate we had promised for it, packet counts in both
|
|
39018
|
+
* directions on both legs, each leg's gate outcome, ffmpeg's exit, who asked
|
|
39019
|
+
* for the teardown, and every named drop.
|
|
39020
|
+
*
|
|
39021
|
+
* Emitted from the ffmpeg `exit` handler — the only place the exit code is
|
|
39022
|
+
* known — and directly from the teardown paths when there is no ffmpeg to wait
|
|
39023
|
+
* for. It deliberately does NOT touch the heartbeat: iOS restarts a session
|
|
39024
|
+
* in place when it is not getting a picture (one on record was started three
|
|
39025
|
+
* times in 16 s), and stopping the heartbeat on the superseded process's exit
|
|
39026
|
+
* would silence the replacement.
|
|
39027
|
+
*/
|
|
39028
|
+
function logSessionSummary(session, log, trigger) {
|
|
39029
|
+
session.endedAtMs = Date.now();
|
|
39030
|
+
log.info("export-hap: stream session summary", { meta: {
|
|
39031
|
+
...sessionSummaryMeta(session),
|
|
39032
|
+
trigger
|
|
39033
|
+
} });
|
|
39034
|
+
}
|
|
39035
|
+
/**
|
|
38512
39036
|
* Build the NTP timestamp expected in an RTCP Sender Report.
|
|
38513
39037
|
*
|
|
38514
39038
|
* NTP timestamps are 64-bit: upper 32 bits = seconds since 1900-01-01,
|
|
@@ -38541,7 +39065,10 @@ function ntpTime() {
|
|
|
38541
39065
|
*/
|
|
38542
39066
|
function sendRtcpSr(session, kind, log) {
|
|
38543
39067
|
const srtcp = kind === "video" ? session.videoOutSrtcp : session.audioOutSrtcp;
|
|
38544
|
-
if (!srtcp)
|
|
39068
|
+
if (!srtcp) {
|
|
39069
|
+
drop(session, "rtcp-no-srtcp");
|
|
39070
|
+
return;
|
|
39071
|
+
}
|
|
38545
39072
|
const sink = kind === "video" ? session.videoUdp : session.audioUdp;
|
|
38546
39073
|
const port = kind === "video" ? session.hapVideoPort : session.hapAudioPort;
|
|
38547
39074
|
try {
|
|
@@ -38556,15 +39083,31 @@ function sendRtcpSr(session, kind, log) {
|
|
|
38556
39083
|
});
|
|
38557
39084
|
const encrypted = srtcp.encrypt(sr.serialize());
|
|
38558
39085
|
sink.send(encrypted, port, session.hapAddress, (err) => {
|
|
38559
|
-
if (err)
|
|
38560
|
-
|
|
38561
|
-
error:
|
|
38562
|
-
|
|
39086
|
+
if (err) {
|
|
39087
|
+
drop(session, "srtp-send-failed");
|
|
39088
|
+
log.warn("export-hap: RTCP send error", { meta: {
|
|
39089
|
+
sessionId: session.sessionId,
|
|
39090
|
+
kind,
|
|
39091
|
+
error: err.message
|
|
39092
|
+
} });
|
|
39093
|
+
}
|
|
38563
39094
|
});
|
|
38564
|
-
if (kind === "video")
|
|
38565
|
-
|
|
39095
|
+
if (kind === "video") {
|
|
39096
|
+
session.videoOutLastRtcpAt = Date.now();
|
|
39097
|
+
session.videoRtcpSrSent += 1;
|
|
39098
|
+
if (session.videoRtcpSrSent === 1) log.info("export-hap: first VIDEO RTCP Sender Report sent", { meta: {
|
|
39099
|
+
sessionId: session.sessionId,
|
|
39100
|
+
rtpTimestamp: session.videoOutLastRtpTimestamp,
|
|
39101
|
+
intervalMs: session.videoRtcpIntervalMs
|
|
39102
|
+
} });
|
|
39103
|
+
} else {
|
|
39104
|
+
session.audioOutLastRtcpAt = Date.now();
|
|
39105
|
+
session.audioRtcpSrSent += 1;
|
|
39106
|
+
}
|
|
38566
39107
|
} catch (err) {
|
|
38567
|
-
|
|
39108
|
+
drop(session, "rtcp-encrypt-failed");
|
|
39109
|
+
log.warn("export-hap: RTCP SR build/encrypt failed", { meta: {
|
|
39110
|
+
sessionId: session.sessionId,
|
|
38568
39111
|
kind,
|
|
38569
39112
|
error: err instanceof Error ? err.message : String(err)
|
|
38570
39113
|
} });
|
|
@@ -38586,7 +39129,14 @@ function forwardEncryptedRtp(session, rtpPacket, kind, log) {
|
|
|
38586
39129
|
const sink = kind === "video" ? session.videoUdp : session.audioUdp;
|
|
38587
39130
|
const port = kind === "video" ? session.hapVideoPort : session.hapAudioPort;
|
|
38588
39131
|
const gate = kind === "video" ? session.videoSendGate : session.audioSendGate;
|
|
38589
|
-
if (!srtp
|
|
39132
|
+
if (!srtp) {
|
|
39133
|
+
drop(session, "no-srtp-session");
|
|
39134
|
+
return;
|
|
39135
|
+
}
|
|
39136
|
+
if (!gate) {
|
|
39137
|
+
drop(session, "no-gate");
|
|
39138
|
+
return;
|
|
39139
|
+
}
|
|
38590
39140
|
gate.then(() => {
|
|
38591
39141
|
try {
|
|
38592
39142
|
const parsed = import_src.RtpPacket.deSerialize(rtpPacket);
|
|
@@ -38622,32 +39172,46 @@ function forwardEncryptedRtp(session, rtpPacket, kind, log) {
|
|
|
38622
39172
|
}
|
|
38623
39173
|
const encrypted = srtp.encrypt(parsed.payload, parsed.header);
|
|
38624
39174
|
sink.send(encrypted, port, session.hapAddress, (err) => {
|
|
38625
|
-
if (err)
|
|
38626
|
-
|
|
38627
|
-
error:
|
|
38628
|
-
|
|
39175
|
+
if (err) {
|
|
39176
|
+
drop(session, "srtp-send-failed");
|
|
39177
|
+
log.debug("export-hap: SRTP send error", { meta: {
|
|
39178
|
+
sessionId: session.sessionId,
|
|
39179
|
+
kind,
|
|
39180
|
+
error: err.message
|
|
39181
|
+
} });
|
|
39182
|
+
}
|
|
38629
39183
|
});
|
|
38630
39184
|
const now = Date.now();
|
|
38631
39185
|
if (kind === "video") {
|
|
38632
39186
|
if (firstVideo || now > session.videoOutLastRtcpAt + session.videoRtcpIntervalMs) sendRtcpSr(session, "video", log);
|
|
38633
39187
|
} else if (firstAudio || now > session.audioOutLastRtcpAt + session.audioRtcpIntervalMs) sendRtcpSr(session, "audio", log);
|
|
38634
39188
|
} catch (err) {
|
|
39189
|
+
drop(session, "srtp-encrypt-failed");
|
|
38635
39190
|
log.debug("export-hap: SRTP encrypt failed", { meta: {
|
|
39191
|
+
sessionId: session.sessionId,
|
|
38636
39192
|
kind,
|
|
38637
39193
|
error: err instanceof Error ? err.message : String(err)
|
|
38638
39194
|
} });
|
|
38639
39195
|
}
|
|
38640
39196
|
});
|
|
38641
39197
|
}
|
|
38642
|
-
async function handleStreamRequest(request, sessions, bctx) {
|
|
39198
|
+
async function handleStreamRequest(request, sessions, bctx, advertised) {
|
|
38643
39199
|
const log = bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId });
|
|
38644
39200
|
const session = sessions.get(request.sessionID);
|
|
38645
39201
|
if (!session) {
|
|
38646
|
-
log.warn("export-hap: stream request for unknown session", { meta: {
|
|
39202
|
+
log.warn("export-hap: stream request for unknown session", { meta: {
|
|
39203
|
+
sessionID: request.sessionID,
|
|
39204
|
+
type: request.type
|
|
39205
|
+
} });
|
|
38647
39206
|
return;
|
|
38648
39207
|
}
|
|
38649
39208
|
if (request.type === "stop") {
|
|
39209
|
+
session.stopRequestedByController = true;
|
|
39210
|
+
session.teardownTrigger = "controller-stop";
|
|
39211
|
+
const hadFfmpeg = session.ffmpeg !== null;
|
|
38650
39212
|
killFfmpeg(session, bctx.ctx, bctx.numericDeviceId);
|
|
39213
|
+
stopHeartbeat(session);
|
|
39214
|
+
if (!hadFfmpeg) logSessionSummary(session, log, "controller-stop-no-ffmpeg");
|
|
38651
39215
|
await closeIntercomTalkSession(session, bctx).catch(() => void 0);
|
|
38652
39216
|
closeSocket(session.videoUdp);
|
|
38653
39217
|
closeSocket(session.audioUdp);
|
|
@@ -38673,6 +39237,27 @@ async function handleStreamRequest(request, sessions, bctx) {
|
|
|
38673
39237
|
session.videoOutOctetCount = 0;
|
|
38674
39238
|
session.videoOutLastRtpTimestamp = 0;
|
|
38675
39239
|
session.videoOutLastRtcpAt = 0;
|
|
39240
|
+
session.negotiated = {
|
|
39241
|
+
width: request.video.width,
|
|
39242
|
+
height: request.video.height,
|
|
39243
|
+
fps: request.video.fps,
|
|
39244
|
+
maxBitrateKbps: request.video.max_bit_rate,
|
|
39245
|
+
mtu: request.video.mtu,
|
|
39246
|
+
videoPt: request.video.pt,
|
|
39247
|
+
videoSsrc: session.videoSsrc,
|
|
39248
|
+
videoRtcpIntervalMs: session.videoRtcpIntervalMs,
|
|
39249
|
+
audioPt: request.audio.pt,
|
|
39250
|
+
audioSsrc: session.audioOutSsrc,
|
|
39251
|
+
audioSampleRateKhz: request.audio.sample_rate,
|
|
39252
|
+
audioPacketTimeMs: packetTimeMs,
|
|
39253
|
+
audioRtcpIntervalMs: session.audioRtcpIntervalMs,
|
|
39254
|
+
audioMaxBitrateKbps: request.audio.max_bit_rate
|
|
39255
|
+
};
|
|
39256
|
+
session.startedAtMs = Date.now();
|
|
39257
|
+
log.info("export-hap: stream negotiated", { meta: {
|
|
39258
|
+
sessionId: request.sessionID,
|
|
39259
|
+
...session.negotiated
|
|
39260
|
+
} });
|
|
38676
39261
|
session.lastStartParams = {
|
|
38677
39262
|
pt: request.video.pt,
|
|
38678
39263
|
mtu: request.video.mtu,
|
|
@@ -38693,7 +39278,8 @@ async function handleStreamRequest(request, sessions, bctx) {
|
|
|
38693
39278
|
sample_rate: request.audio.sample_rate,
|
|
38694
39279
|
packet_time: packetTimeMs
|
|
38695
39280
|
};
|
|
38696
|
-
await startFfmpegForSession(bctx, session, request.sessionID, startParams);
|
|
39281
|
+
await startFfmpegForSession(bctx, session, request.sessionID, startParams, advertised);
|
|
39282
|
+
armHeartbeat(session, log);
|
|
38697
39283
|
return;
|
|
38698
39284
|
}
|
|
38699
39285
|
if (request.type === "reconfigure") {
|
|
@@ -38720,23 +39306,69 @@ async function handleStreamRequest(request, sessions, bctx) {
|
|
|
38720
39306
|
sample_rate: session.lastStartParams.audioSampleRateEnum,
|
|
38721
39307
|
packet_time: session.lastStartParams.audioPacketTimeMs
|
|
38722
39308
|
};
|
|
38723
|
-
|
|
39309
|
+
log.info("export-hap: stream reconfigured", { meta: {
|
|
39310
|
+
sessionId: request.sessionID,
|
|
39311
|
+
width: request.video.width,
|
|
39312
|
+
height: request.video.height,
|
|
39313
|
+
fps: request.video.fps,
|
|
39314
|
+
maxBitrateKbps: request.video.max_bit_rate
|
|
39315
|
+
} });
|
|
39316
|
+
await startFfmpegForSession(bctx, session, request.sessionID, startParams, advertised);
|
|
39317
|
+
armHeartbeat(session, log);
|
|
38724
39318
|
}
|
|
38725
39319
|
}
|
|
38726
|
-
async function startFfmpegForSession(bctx, session, sessionId, video) {
|
|
39320
|
+
async function startFfmpegForSession(bctx, session, sessionId, video, advertised) {
|
|
38727
39321
|
const { ctx, proxy, numericDeviceId, options } = bctx;
|
|
39322
|
+
const startLog = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
38728
39323
|
const entries = await proxy.cameraStreams?.getProfileRtspEntries({}) ?? [];
|
|
38729
|
-
if (entries.length === 0)
|
|
39324
|
+
if (entries.length === 0) {
|
|
39325
|
+
startLog.warn("export-hap: stream start DROPPED — device publishes no profile RTSP entries", { meta: {
|
|
39326
|
+
sessionId,
|
|
39327
|
+
capBound: proxy.cameraStreams !== void 0
|
|
39328
|
+
} });
|
|
39329
|
+
throw new Error(`export-hap: no profile RTSP entries for device ${numericDeviceId}`);
|
|
39330
|
+
}
|
|
38730
39331
|
const pref = options.hapDeviceSettings.streamPreference;
|
|
38731
39332
|
const picked = pickPreferredRtspEntry(entries, pref, numericDeviceId, { targetResolution: {
|
|
38732
39333
|
width: video.width,
|
|
38733
39334
|
height: video.height
|
|
38734
39335
|
} });
|
|
38735
|
-
if (!picked)
|
|
39336
|
+
if (!picked) {
|
|
39337
|
+
startLog.warn("export-hap: stream start DROPPED — no ENABLED profile RTSP entry", { meta: {
|
|
39338
|
+
sessionId,
|
|
39339
|
+
streamPreference: pref,
|
|
39340
|
+
targetResolution: `${video.width}x${video.height}`,
|
|
39341
|
+
entries: entries.map((e) => `${e.profile}:${e.enabled ? "enabled" : "disabled"}`)
|
|
39342
|
+
} });
|
|
39343
|
+
throw new Error(`export-hap: no enabled RTSP entries for device ${numericDeviceId}`);
|
|
39344
|
+
}
|
|
38736
39345
|
const rtspUrl = picked.url;
|
|
38737
39346
|
const slot = (await proxy.cameraStreams?.getBrokerStreams({}) ?? []).find((s) => s.brokerId === picked.brokerId);
|
|
38738
39347
|
const codec = (picked.codec ?? slot?.codec ?? "").toLowerCase();
|
|
38739
39348
|
const needsTranscode = codec.includes("h265") || codec.includes("hevc");
|
|
39349
|
+
const pickedProfile = toKnownProfile(picked.profileId);
|
|
39350
|
+
const resolvedFps = pickedProfile === null ? void 0 : advertised.fpsByProfile.get(pickedProfile);
|
|
39351
|
+
const advertisedFps = resolvedFps?.fps ?? video.fps;
|
|
39352
|
+
const advertisedFpsSource = resolvedFps?.source ?? "assumed";
|
|
39353
|
+
session.selectedSlot = {
|
|
39354
|
+
profile: pickedProfile,
|
|
39355
|
+
brokerId: picked.brokerId,
|
|
39356
|
+
width: picked.resolution?.width ?? null,
|
|
39357
|
+
height: picked.resolution?.height ?? null,
|
|
39358
|
+
advertisedFps,
|
|
39359
|
+
advertisedFpsSource,
|
|
39360
|
+
codec: codec.length > 0 ? codec : "unknown",
|
|
39361
|
+
transcode: needsTranscode
|
|
39362
|
+
};
|
|
39363
|
+
if (advertisedFps !== video.fps) startLog.warn("export-hap: negotiated fps does NOT match the slot we are about to dial", { meta: {
|
|
39364
|
+
sessionId,
|
|
39365
|
+
negotiatedFps: video.fps,
|
|
39366
|
+
slotFps: advertisedFps,
|
|
39367
|
+
slotFpsSource: advertisedFpsSource,
|
|
39368
|
+
profile: pickedProfile,
|
|
39369
|
+
brokerId: picked.brokerId,
|
|
39370
|
+
transcode: needsTranscode
|
|
39371
|
+
} });
|
|
38740
39372
|
const videoLoopPort = session.videoLoopUdp.address().port;
|
|
38741
39373
|
const audioLoopPort = session.audioLoopUdp.address().port;
|
|
38742
39374
|
const videoTarget = `rtp://127.0.0.1:${videoLoopPort}?pkt_size=${video.mtu}`;
|
|
@@ -38836,15 +39468,22 @@ async function startFfmpegForSession(bctx, session, sessionId, video) {
|
|
|
38836
39468
|
} });
|
|
38837
39469
|
});
|
|
38838
39470
|
proc.once("exit", (code, signal) => {
|
|
39471
|
+
session.ffmpegExit = {
|
|
39472
|
+
code,
|
|
39473
|
+
signal
|
|
39474
|
+
};
|
|
38839
39475
|
const ok = code === 0 && signal === null;
|
|
38840
39476
|
const meta = {
|
|
38841
39477
|
sessionId,
|
|
38842
39478
|
code,
|
|
38843
|
-
signal
|
|
39479
|
+
signal,
|
|
39480
|
+
stopRequestedByController: session.stopRequestedByController,
|
|
39481
|
+
videoPacketsForwarded: session.videoPacketsForwarded
|
|
38844
39482
|
};
|
|
38845
|
-
if (ok) log.info("export-hap: ffmpeg exited", { meta });
|
|
38846
|
-
else log.warn("export-hap: ffmpeg exited
|
|
39483
|
+
if (ok || session.stopRequestedByController) log.info("export-hap: ffmpeg exited", { meta });
|
|
39484
|
+
else log.warn("export-hap: ffmpeg exited without a controller stop", { meta });
|
|
38847
39485
|
if (session.ffmpeg === proc) session.ffmpeg = null;
|
|
39486
|
+
logSessionSummary(session, log, session.teardownTrigger ?? "ffmpeg-exit");
|
|
38848
39487
|
});
|
|
38849
39488
|
proc.once("error", (err) => {
|
|
38850
39489
|
log.warn("export-hap: ffmpeg spawn failed", { meta: {
|
|
@@ -38855,12 +39494,27 @@ async function startFfmpegForSession(bctx, session, sessionId, video) {
|
|
|
38855
39494
|
log.info("export-hap: stream started", { meta: {
|
|
38856
39495
|
sessionId,
|
|
38857
39496
|
transcode: needsTranscode,
|
|
38858
|
-
|
|
39497
|
+
brokerId: picked.brokerId,
|
|
39498
|
+
profile: pickedProfile,
|
|
39499
|
+
sourceResolution: picked.resolution === void 0 ? null : `${picked.resolution.width}x${picked.resolution.height}`,
|
|
39500
|
+
sourceCodec: codec.length > 0 ? codec : "unknown",
|
|
39501
|
+
negotiated: `${video.width}x${video.height}@${video.fps} ${video.max_bit_rate}kbps`,
|
|
39502
|
+
slotFps: advertisedFps,
|
|
39503
|
+
slotFpsSource: advertisedFpsSource,
|
|
38859
39504
|
audioCodec: "opus",
|
|
38860
39505
|
audioBitrateKbps: OPUS_BITRATE_KBPS
|
|
38861
39506
|
} });
|
|
38862
39507
|
}
|
|
38863
39508
|
/**
|
|
39509
|
+
* `PickedStream.profileId` is the brokerId suffix; for a profile-keyed entry
|
|
39510
|
+
* that IS the profile name. Anything else addresses a raw cam-stream and must
|
|
39511
|
+
* not be coerced into a profile.
|
|
39512
|
+
*/
|
|
39513
|
+
function toKnownProfile(profileId) {
|
|
39514
|
+
if (profileId === "high" || profileId === "mid" || profileId === "low") return profileId;
|
|
39515
|
+
return null;
|
|
39516
|
+
}
|
|
39517
|
+
/**
|
|
38864
39518
|
* Handle one incoming SRTP audio packet from iOS Home.
|
|
38865
39519
|
*
|
|
38866
39520
|
* End-to-end flow:
|
|
@@ -38881,15 +39535,22 @@ async function startFfmpegForSession(bctx, session, sessionId, video) {
|
|
|
38881
39535
|
* stays codec-agnostic.
|
|
38882
39536
|
*/
|
|
38883
39537
|
async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
|
|
38884
|
-
if (packet.length < 12)
|
|
39538
|
+
if (packet.length < 12) {
|
|
39539
|
+
drop(session, "upstream-short-packet");
|
|
39540
|
+
return;
|
|
39541
|
+
}
|
|
38885
39542
|
const log = bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId });
|
|
38886
39543
|
session.upstreamRtpPacketsReceived += 1;
|
|
38887
|
-
if (!session.upstreamAudioSrtp)
|
|
39544
|
+
if (!session.upstreamAudioSrtp) {
|
|
39545
|
+
drop(session, "upstream-no-srtp");
|
|
39546
|
+
return;
|
|
39547
|
+
}
|
|
38888
39548
|
let decryptedPacket;
|
|
38889
39549
|
try {
|
|
38890
39550
|
decryptedPacket = session.upstreamAudioSrtp.decrypt(packet);
|
|
38891
39551
|
session.upstreamRtpPacketsDecrypted += 1;
|
|
38892
39552
|
} catch (err) {
|
|
39553
|
+
drop(session, "upstream-decrypt-failed");
|
|
38893
39554
|
session.upstreamRtpDecryptFailures += 1;
|
|
38894
39555
|
if (session.upstreamRtpDecryptFailures % 100 === 1) log.debug("export-hap: SRTP decrypt failed (rate-limited)", { meta: {
|
|
38895
39556
|
failures: session.upstreamRtpDecryptFailures,
|
|
@@ -38906,18 +39567,28 @@ async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
|
|
|
38906
39567
|
rtpTimestamp = parsedRtp.header.timestamp;
|
|
38907
39568
|
rtpPayloadType = parsedRtp.header.payloadType;
|
|
38908
39569
|
} catch (err) {
|
|
39570
|
+
drop(session, "upstream-parse-failed");
|
|
38909
39571
|
log.debug("export-hap: RTP parse failed after decrypt", { meta: { error: errMsg$8(err) } });
|
|
38910
39572
|
return;
|
|
38911
39573
|
}
|
|
38912
39574
|
const negotiatedAudioPt = session.lastStartParams?.audioPt;
|
|
38913
|
-
if (negotiatedAudioPt === void 0)
|
|
38914
|
-
|
|
39575
|
+
if (negotiatedAudioPt === void 0) {
|
|
39576
|
+
drop(session, "upstream-before-start");
|
|
39577
|
+
return;
|
|
39578
|
+
}
|
|
39579
|
+
if (rtpPayloadType !== negotiatedAudioPt) {
|
|
39580
|
+
drop(session, "upstream-pt-mismatch");
|
|
39581
|
+
return;
|
|
39582
|
+
}
|
|
38915
39583
|
if (session.firstUpstreamRtpTimestamp === null) {
|
|
38916
39584
|
session.firstUpstreamRtpTimestamp = rtpTimestamp;
|
|
38917
39585
|
log.info("export-hap: first upstream RTP packet received");
|
|
38918
39586
|
}
|
|
38919
39587
|
const payload = decryptedPayload;
|
|
38920
|
-
if (payload.length === 0)
|
|
39588
|
+
if (payload.length === 0) {
|
|
39589
|
+
drop(session, "upstream-empty-payload");
|
|
39590
|
+
return;
|
|
39591
|
+
}
|
|
38921
39592
|
if (session.intercomTalkSessionId === null) {
|
|
38922
39593
|
session.intercomTalkSessionId = "";
|
|
38923
39594
|
const opened = await openIntercomTalkSession(bctx).catch((err) => {
|
|
@@ -38929,7 +39600,10 @@ async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
|
|
|
38929
39600
|
log.info("export-hap: intercom talk session opened", { meta: { sessionId: opened.sessionId } });
|
|
38930
39601
|
}
|
|
38931
39602
|
}
|
|
38932
|
-
if (!session.intercomTalkSessionId)
|
|
39603
|
+
if (!session.intercomTalkSessionId) {
|
|
39604
|
+
drop(session, "upstream-no-talk-session");
|
|
39605
|
+
return;
|
|
39606
|
+
}
|
|
38933
39607
|
const opusSampleRateHz = (session.lastStartParams?.audioSampleRateEnum ?? 16) * 1e3;
|
|
38934
39608
|
session.intercomPcmSequence += 1;
|
|
38935
39609
|
session.upstreamPcmFramesDecoded += 1;
|
|
@@ -38942,6 +39616,7 @@ async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
|
|
|
38942
39616
|
sequenceNumber: session.intercomPcmSequence
|
|
38943
39617
|
});
|
|
38944
39618
|
} catch (err) {
|
|
39619
|
+
drop(session, "upstream-push-failed");
|
|
38945
39620
|
log.debug("export-hap: intercom.pushTalkAudio failed (will re-open on next frame)", { meta: {
|
|
38946
39621
|
sequenceNumber: session.intercomPcmSequence,
|
|
38947
39622
|
error: errMsg$8(err)
|
|
@@ -39025,140 +39700,6 @@ function errMsg$7(err) {
|
|
|
39025
39700
|
return err instanceof Error ? err.message : String(err);
|
|
39026
39701
|
}
|
|
39027
39702
|
//#endregion
|
|
39028
|
-
//#region src/mappers/builders/hksv.ts
|
|
39029
|
-
/**
|
|
39030
|
-
* HomeKit Secure Video (HKSV) — stub recording delegate.
|
|
39031
|
-
*
|
|
39032
|
-
* Wires the camera as HKSV-capable so iOS Home shows the "Activity"
|
|
39033
|
-
* tab, the per-camera recording settings UI ("Stream and Allow
|
|
39034
|
-
* Recording" / "Off"), and notifications. The delegate stub:
|
|
39035
|
-
* - logs every protocol callback (active toggle, config change,
|
|
39036
|
-
* stream request, close);
|
|
39037
|
-
* - returns from `handleRecordingStreamRequest` immediately without
|
|
39038
|
-
* yielding any fragment — iOS sees "no recording available" but
|
|
39039
|
-
* the camera otherwise behaves like an HKSV camera in the UI.
|
|
39040
|
-
*
|
|
39041
|
-
* Why a stub: a real implementation needs an fMP4 segmenter that runs
|
|
39042
|
-
* a per-camera ffmpeg with a sustained pre-buffer, key/IV-derived
|
|
39043
|
-
* encrypter, motion-anchored fragment alignment, and storage for the
|
|
39044
|
-
* resulting clips. That work belongs behind the `recording` cap
|
|
39045
|
-
* (recording + playback-manifest surface) once it grows fragment export,
|
|
39046
|
-
* so the segmenter lives in the recorder addon and HKSV becomes a thin
|
|
39047
|
-
* wrapper that asks the cap for "fragments since T" and streams them to iOS.
|
|
39048
|
-
*
|
|
39049
|
-
* Until that cap lands this stub keeps HKSV characteristics visible
|
|
39050
|
-
* (a) so the operator can flip "Allow Recording" without HomeKit
|
|
39051
|
-
* complaining the camera lacks the service, and (b) so the
|
|
39052
|
-
* `CameraController` advertises a `RecordingManagement` service for
|
|
39053
|
-
* iOS-side analytics + Activity-tab UX scaffolding.
|
|
39054
|
-
*
|
|
39055
|
-
* Constructor returns a `{ options, delegate }` pair the caller
|
|
39056
|
-
* passes verbatim into `new CameraController({ ..., recording })`.
|
|
39057
|
-
*/
|
|
39058
|
-
var RECORDING_OPTIONS = {
|
|
39059
|
-
prebufferLength: 4e3,
|
|
39060
|
-
mediaContainerConfiguration: [{
|
|
39061
|
-
type: MediaContainerType.FRAGMENTED_MP4,
|
|
39062
|
-
fragmentLength: 4e3
|
|
39063
|
-
}],
|
|
39064
|
-
video: {
|
|
39065
|
-
type: VideoCodecType.H264,
|
|
39066
|
-
parameters: {
|
|
39067
|
-
profiles: [
|
|
39068
|
-
H264Profile.BASELINE,
|
|
39069
|
-
H264Profile.MAIN,
|
|
39070
|
-
H264Profile.HIGH
|
|
39071
|
-
],
|
|
39072
|
-
levels: [
|
|
39073
|
-
H264Level.LEVEL3_1,
|
|
39074
|
-
H264Level.LEVEL3_2,
|
|
39075
|
-
H264Level.LEVEL4_0
|
|
39076
|
-
]
|
|
39077
|
-
},
|
|
39078
|
-
resolutions: [
|
|
39079
|
-
[
|
|
39080
|
-
1920,
|
|
39081
|
-
1080,
|
|
39082
|
-
30
|
|
39083
|
-
],
|
|
39084
|
-
[
|
|
39085
|
-
1920,
|
|
39086
|
-
1080,
|
|
39087
|
-
24
|
|
39088
|
-
],
|
|
39089
|
-
[
|
|
39090
|
-
1920,
|
|
39091
|
-
1080,
|
|
39092
|
-
15
|
|
39093
|
-
],
|
|
39094
|
-
[
|
|
39095
|
-
1280,
|
|
39096
|
-
720,
|
|
39097
|
-
30
|
|
39098
|
-
],
|
|
39099
|
-
[
|
|
39100
|
-
1280,
|
|
39101
|
-
720,
|
|
39102
|
-
24
|
|
39103
|
-
],
|
|
39104
|
-
[
|
|
39105
|
-
1280,
|
|
39106
|
-
720,
|
|
39107
|
-
15
|
|
39108
|
-
]
|
|
39109
|
-
]
|
|
39110
|
-
},
|
|
39111
|
-
audio: { codecs: [{
|
|
39112
|
-
type: AudioRecordingCodecType.AAC_LC,
|
|
39113
|
-
bitrateMode: AudioBitrate.VARIABLE,
|
|
39114
|
-
audioChannels: 1,
|
|
39115
|
-
samplerate: [AudioRecordingSamplerate.KHZ_16, AudioRecordingSamplerate.KHZ_24]
|
|
39116
|
-
}] },
|
|
39117
|
-
overrideEventTriggerOptions: [EventTriggerOption.MOTION]
|
|
39118
|
-
};
|
|
39119
|
-
function buildHksvStub(bctx) {
|
|
39120
|
-
const { ctx, numericDeviceId } = bctx;
|
|
39121
|
-
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
39122
|
-
let activeStreamId = null;
|
|
39123
|
-
return {
|
|
39124
|
-
options: RECORDING_OPTIONS,
|
|
39125
|
-
delegate: {
|
|
39126
|
-
updateRecordingActive(active) {
|
|
39127
|
-
log.info("export-hap: HKSV recording active changed", { meta: { active } });
|
|
39128
|
-
},
|
|
39129
|
-
updateRecordingConfiguration(configuration) {
|
|
39130
|
-
if (!configuration) {
|
|
39131
|
-
log.info("export-hap: HKSV recording configuration cleared");
|
|
39132
|
-
return;
|
|
39133
|
-
}
|
|
39134
|
-
log.info("export-hap: HKSV recording configuration applied", { meta: {
|
|
39135
|
-
prebufferLength: configuration.prebufferLength,
|
|
39136
|
-
fragmentLength: configuration.mediaContainerConfiguration.fragmentLength,
|
|
39137
|
-
videoResolution: `${configuration.videoCodec.resolution[0]}x${configuration.videoCodec.resolution[1]}@${configuration.videoCodec.resolution[2]}`,
|
|
39138
|
-
videoBitrate: configuration.videoCodec.parameters.bitRate,
|
|
39139
|
-
eventTriggers: configuration.eventTriggerTypes
|
|
39140
|
-
} });
|
|
39141
|
-
},
|
|
39142
|
-
async *handleRecordingStreamRequest(streamId, _signal) {
|
|
39143
|
-
activeStreamId = streamId;
|
|
39144
|
-
log.warn("export-hap: HKSV stream requested — stub returns empty (no clip source yet)", { meta: { streamId } });
|
|
39145
|
-
},
|
|
39146
|
-
acknowledgeStream(streamId) {
|
|
39147
|
-
if (activeStreamId === streamId) activeStreamId = null;
|
|
39148
|
-
log.debug("export-hap: HKSV stream acknowledged", { meta: { streamId } });
|
|
39149
|
-
},
|
|
39150
|
-
closeRecordingStream(streamId, reason) {
|
|
39151
|
-
if (activeStreamId === streamId) activeStreamId = null;
|
|
39152
|
-
log.info("export-hap: HKSV stream closed", { meta: {
|
|
39153
|
-
streamId,
|
|
39154
|
-
reason: reason ?? null
|
|
39155
|
-
} });
|
|
39156
|
-
}
|
|
39157
|
-
},
|
|
39158
|
-
handle: { async dispose() {} }
|
|
39159
|
-
};
|
|
39160
|
-
}
|
|
39161
|
-
//#endregion
|
|
39162
39703
|
//#region src/mappers/builders/intercom.ts
|
|
39163
39704
|
async function buildIntercom(input) {
|
|
39164
39705
|
const { bctx, streamingOptions } = input;
|
|
@@ -39452,6 +39993,65 @@ function errMsg$4(err) {
|
|
|
39452
39993
|
return err instanceof Error ? err.message : String(err);
|
|
39453
39994
|
}
|
|
39454
39995
|
//#endregion
|
|
39996
|
+
//#region src/mappers/builders/stream-fps-probe.ts
|
|
39997
|
+
async function probeAdvertisedVideoProfile(bctx) {
|
|
39998
|
+
const { ctx, proxy, numericDeviceId, options } = bctx;
|
|
39999
|
+
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
40000
|
+
const entries = await probe(() => proxy.cameraStreams?.getProfileRtspEntries({}), "cameraStreams.getProfileRtspEntries", log);
|
|
40001
|
+
const slots = await probe(() => proxy.cameraStreams?.getBrokerStreams({}), "cameraStreams.getBrokerStreams", log);
|
|
40002
|
+
const camStreams = await probe(() => proxy.cameraStreams?.getCameraStreams({}), "cameraStreams.getCameraStreams", log);
|
|
40003
|
+
const choices = await probe(() => proxy.webrtcSession?.listStreams({}), "webrtcSession.listStreams", log);
|
|
40004
|
+
const fpsByProfile = resolveProfileFps({
|
|
40005
|
+
choices: choices ?? [],
|
|
40006
|
+
slots: slots ?? [],
|
|
40007
|
+
camStreams: camStreams ?? []
|
|
40008
|
+
});
|
|
40009
|
+
const resolutions = deriveAdvertisedResolutions({
|
|
40010
|
+
candidates: CANDIDATE_RESOLUTIONS,
|
|
40011
|
+
entries: entries ?? [],
|
|
40012
|
+
deviceId: numericDeviceId,
|
|
40013
|
+
pref: options.hapDeviceSettings.streamPreference,
|
|
40014
|
+
fpsByProfile
|
|
40015
|
+
});
|
|
40016
|
+
const assumed = resolutions.filter((r) => r.source === "assumed").length;
|
|
40017
|
+
log.info("export-hap: advertised video profile derived", { meta: {
|
|
40018
|
+
streamPreference: options.hapDeviceSettings.streamPreference,
|
|
40019
|
+
resolutions: formatAdvertisedResolutions(resolutions),
|
|
40020
|
+
profileFps: [...fpsByProfile.values()].map((f) => `${f.profile}=${f.fps}(${f.source})`),
|
|
40021
|
+
assumedCount: assumed,
|
|
40022
|
+
previouslyAdvertisedFps: 30
|
|
40023
|
+
} });
|
|
40024
|
+
if (assumed === resolutions.length) log.warn("export-hap: no measured or published frame rate for ANY profile — advertising the assumed rate", { meta: {
|
|
40025
|
+
entries: entries?.length ?? 0,
|
|
40026
|
+
choices: choices?.length ?? 0
|
|
40027
|
+
} });
|
|
40028
|
+
return {
|
|
40029
|
+
resolutions,
|
|
40030
|
+
fpsByProfile
|
|
40031
|
+
};
|
|
40032
|
+
}
|
|
40033
|
+
/**
|
|
40034
|
+
* Run one cap read. Returns null both when the cap is not bound and when the
|
|
40035
|
+
* call throws — and LOGS which, because "the camera has no telemetry" and "the
|
|
40036
|
+
* telemetry call failed" lead to different fixes.
|
|
40037
|
+
*/
|
|
40038
|
+
async function probe(call, label, log) {
|
|
40039
|
+
try {
|
|
40040
|
+
const pending = call();
|
|
40041
|
+
if (pending === void 0) {
|
|
40042
|
+
log.info("export-hap: fps probe skipped — cap not bound on this device", { meta: { call: label } });
|
|
40043
|
+
return null;
|
|
40044
|
+
}
|
|
40045
|
+
return await pending;
|
|
40046
|
+
} catch (err) {
|
|
40047
|
+
log.warn("export-hap: fps probe failed — falling back to a lower-authority source", { meta: {
|
|
40048
|
+
call: label,
|
|
40049
|
+
error: err instanceof Error ? err.message : String(err)
|
|
40050
|
+
} });
|
|
40051
|
+
return null;
|
|
40052
|
+
}
|
|
40053
|
+
}
|
|
40054
|
+
//#endregion
|
|
39455
40055
|
//#region src/mappers/builders/child-switch.ts
|
|
39456
40056
|
/**
|
|
39457
40057
|
* Child-switch builder — turns a camstack accessory child device (siren,
|
|
@@ -39678,24 +40278,18 @@ async function buildCameraAccessory(input) {
|
|
|
39678
40278
|
displayName,
|
|
39679
40279
|
options
|
|
39680
40280
|
};
|
|
39681
|
-
const streams = buildCameraStreamingDelegate(bctx);
|
|
40281
|
+
const streams = buildCameraStreamingDelegate(bctx, await probeAdvertisedVideoProfile(bctx));
|
|
39682
40282
|
const handles = [];
|
|
39683
40283
|
if (capNames.has("intercom")) handles.push(await buildIntercom({
|
|
39684
40284
|
bctx,
|
|
39685
40285
|
streamingOptions: streams.streamingOptions
|
|
39686
40286
|
}));
|
|
39687
|
-
const hksv = capNames.has("motion-detection") ? buildHksvStub(bctx) : null;
|
|
39688
40287
|
const controller = new (isDoorbell ? DoorbellController : CameraController)({
|
|
39689
40288
|
delegate: streams.delegate,
|
|
39690
40289
|
streamingOptions: streams.streamingOptions,
|
|
39691
|
-
cameraStreamCount: 2
|
|
39692
|
-
...hksv ? { recording: {
|
|
39693
|
-
options: hksv.options,
|
|
39694
|
-
delegate: hksv.delegate
|
|
39695
|
-
} } : {}
|
|
40290
|
+
cameraStreamCount: 2
|
|
39696
40291
|
});
|
|
39697
40292
|
accessory.configureController(controller);
|
|
39698
|
-
if (hksv) handles.push(hksv.handle);
|
|
39699
40293
|
if (capNames.has("motion-detection")) handles.push(await buildMotionSensor(bctx));
|
|
39700
40294
|
if (isDoorbell && controller instanceof DoorbellController) handles.push(await buildDoorbell({
|
|
39701
40295
|
bctx,
|