@camstack/addon-export-hap 1.2.134 → 1.2.139
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/hap-export.addon.js +302 -160
- package/dist/hap-export.addon.mjs +303 -160
- package/package.json +1 -1
package/dist/hap-export.addon.js
CHANGED
|
@@ -90670,6 +90670,153 @@ function createClipThumbnailMinter(input) {
|
|
|
90670
90670
|
};
|
|
90671
90671
|
}
|
|
90672
90672
|
//#endregion
|
|
90673
|
+
//#region src/hksv/recording-active.ts
|
|
90674
|
+
/**
|
|
90675
|
+
* `true`/`false` as HomeKit holds it, or `null` when this accessory does not
|
|
90676
|
+
* offer recording at all.
|
|
90677
|
+
*/
|
|
90678
|
+
function readHomekitRecordingActive(accessories) {
|
|
90679
|
+
for (const accessory of accessories) {
|
|
90680
|
+
const service = accessory.getService(import_dist.Service.CameraRecordingManagement);
|
|
90681
|
+
if (service === void 0) continue;
|
|
90682
|
+
const value = service.getCharacteristic(import_dist.Characteristic.Active).value;
|
|
90683
|
+
if (typeof value === "number") return value === 1;
|
|
90684
|
+
if (typeof value === "boolean") return value;
|
|
90685
|
+
return null;
|
|
90686
|
+
}
|
|
90687
|
+
return null;
|
|
90688
|
+
}
|
|
90689
|
+
/**
|
|
90690
|
+
* One sentence for the settings page: what HomeKit says, or why it was never
|
|
90691
|
+
* asked.
|
|
90692
|
+
*
|
|
90693
|
+
* The cost stays in the text even though the switch is gone. It is still the
|
|
90694
|
+
* operator's to understand — a camera recording here runs a permanent ffmpeg —
|
|
90695
|
+
* and a status line that reported only the state would have quietly dropped
|
|
90696
|
+
* the one number that decides whether to turn it on.
|
|
90697
|
+
*/
|
|
90698
|
+
function describeHomekitRecording(active, lastBuild) {
|
|
90699
|
+
if (active === true) return "HomeKit is recording this camera. Turn it off in the Home app (Camera settings → “Stream”), not here — iOS owns this switch and keeps it across reboots. While it is on, this camera runs a permanent prebuffer (~0.7% of one CPU core on the copy branch).";
|
|
90700
|
+
if (active === false) return "This camera offers recording and HomeKit has it off. Turn it on in the Home app (Camera settings → “Stream and Allow Recording”); it needs iCloud+ and a home hub. Nothing runs here until you do.";
|
|
90701
|
+
if (lastBuild !== null && !lastBuild.advertised) return `Recording is not offered for this camera: ${lastBuild.detail ?? "the accessory could not advertise it"}.`;
|
|
90702
|
+
return "Not known yet — this camera’s accessory has not published its recording service in this process. It appears once the accessory is built.";
|
|
90703
|
+
}
|
|
90704
|
+
//#endregion
|
|
90705
|
+
//#region src/hksv/recording-source.ts
|
|
90706
|
+
/** The tallest frame the recording path will hold in its prebuffer. */
|
|
90707
|
+
var MAX_RECORDING_HEIGHT = 1080;
|
|
90708
|
+
/** HKSV takes H.264 only — AAC audio and H.264 video, no negotiation. */
|
|
90709
|
+
var RECORDABLE_CODEC = "h264";
|
|
90710
|
+
/**
|
|
90711
|
+
* Pick the slot the recording child pulls.
|
|
90712
|
+
*
|
|
90713
|
+
* Deliberately NOT `pickPreferredRtspEntry`: that picker resolves the operator's
|
|
90714
|
+
* LIVE preference and, on `auto`, steers by the resolution iOS negotiated for a
|
|
90715
|
+
* live session — neither is a fact about recording, and on 615 it selects `mid`,
|
|
90716
|
+
* a 10 fps slot. Recording has one criterion, applied here and nowhere else:
|
|
90717
|
+
* the largest copyable frame that does not exceed {@link MAX_RECORDING_HEIGHT}.
|
|
90718
|
+
*/
|
|
90719
|
+
function pickRecordingSource(entries, options = {}) {
|
|
90720
|
+
const enabled = entries.filter((e) => e.enabled);
|
|
90721
|
+
if (enabled.length === 0) return {
|
|
90722
|
+
ok: false,
|
|
90723
|
+
refusal: "no-enabled-stream"
|
|
90724
|
+
};
|
|
90725
|
+
const h264 = enabled.filter((e) => normaliseCodec(e.codec) === RECORDABLE_CODEC);
|
|
90726
|
+
const pool = h264.length > 0 ? h264 : options.allowTranscode === true ? enabled : [];
|
|
90727
|
+
if (pool.length === 0) return {
|
|
90728
|
+
ok: false,
|
|
90729
|
+
refusal: "no-h264-stream"
|
|
90730
|
+
};
|
|
90731
|
+
const transcode = h264.length === 0;
|
|
90732
|
+
const sized = pool.filter(hasUsableResolution);
|
|
90733
|
+
if (sized.length === 0) return {
|
|
90734
|
+
ok: false,
|
|
90735
|
+
refusal: "no-resolution"
|
|
90736
|
+
};
|
|
90737
|
+
const withinCeiling = sized.filter((e) => height(e) <= MAX_RECORDING_HEIGHT);
|
|
90738
|
+
const within = withinCeiling.length > 0;
|
|
90739
|
+
const best = [...within ? withinCeiling : sized].sort((a, b) => within ? height(b) - height(a) : height(a) - height(b))[0];
|
|
90740
|
+
if (best === void 0 || best.resolution === void 0) return {
|
|
90741
|
+
ok: false,
|
|
90742
|
+
refusal: "no-resolution"
|
|
90743
|
+
};
|
|
90744
|
+
return {
|
|
90745
|
+
ok: true,
|
|
90746
|
+
source: {
|
|
90747
|
+
brokerId: best.brokerId,
|
|
90748
|
+
profile: best.profile ?? best.brokerId,
|
|
90749
|
+
transcode,
|
|
90750
|
+
url: best.url,
|
|
90751
|
+
mutedUrl: best.mutedUrl,
|
|
90752
|
+
width: best.resolution.width,
|
|
90753
|
+
height: best.resolution.height
|
|
90754
|
+
}
|
|
90755
|
+
};
|
|
90756
|
+
}
|
|
90757
|
+
/** A one-line reason for the log — silence about a withdrawn service reads as a bug. */
|
|
90758
|
+
function refusalReason(refusal) {
|
|
90759
|
+
switch (refusal) {
|
|
90760
|
+
case "no-enabled-stream": return "the camera has no enabled RTSP profile";
|
|
90761
|
+
case "no-h264-stream": return "every enabled profile is H.265 — HKSV takes H.264 only, and a permanent transcode costs 128x a copy";
|
|
90762
|
+
case "no-resolution": return "no enabled profile declares a resolution, so nothing honest could be advertised";
|
|
90763
|
+
}
|
|
90764
|
+
}
|
|
90765
|
+
function height(entry) {
|
|
90766
|
+
return entry.resolution?.height ?? 0;
|
|
90767
|
+
}
|
|
90768
|
+
function hasUsableResolution(entry) {
|
|
90769
|
+
const r = entry.resolution;
|
|
90770
|
+
return r !== void 0 && r.width > 0 && r.height > 0;
|
|
90771
|
+
}
|
|
90772
|
+
/** Publishers spell H.265 four ways; the same normalisation the broker uses. */
|
|
90773
|
+
function normaliseCodec(codec) {
|
|
90774
|
+
return (codec ?? "").toLowerCase().replace(/[.\s-]/g, "");
|
|
90775
|
+
}
|
|
90776
|
+
//#endregion
|
|
90777
|
+
//#region src/hksv/recordability-token.ts
|
|
90778
|
+
/** No slot is recordable. A real answer, and a fingerprint-moving one. */
|
|
90779
|
+
var RECORDABILITY_NONE = "rec:none";
|
|
90780
|
+
/**
|
|
90781
|
+
* The slot HKSV would record from, as a fingerprint token.
|
|
90782
|
+
*
|
|
90783
|
+
* `null` when `entries` is null — the profiles could not be read, which is not
|
|
90784
|
+
* a claim about the camera.
|
|
90785
|
+
*/
|
|
90786
|
+
function recordabilityToken(entries, options = {}) {
|
|
90787
|
+
if (entries === null) return null;
|
|
90788
|
+
const choice = pickRecordingSource(entries, options);
|
|
90789
|
+
return choice.ok ? `rec:${choice.source.profile}${choice.source.transcode ? "+x" : ""}` : RECORDABILITY_NONE;
|
|
90790
|
+
}
|
|
90791
|
+
var TOKEN_SUFFIX = /\|(rec:[^|]*)$/;
|
|
90792
|
+
/**
|
|
90793
|
+
* Attach the recording token to a device-shape fingerprint.
|
|
90794
|
+
*
|
|
90795
|
+
* On an unreadable read the persisted token is carried forward — but only onto
|
|
90796
|
+
* the base it was persisted against. If the device's SHAPE also changed, that
|
|
90797
|
+
* change is a rebuild in its own right and the token has nothing to say about
|
|
90798
|
+
* it; inventing one there would advertise a recording slot nobody read.
|
|
90799
|
+
*/
|
|
90800
|
+
function withRecordabilityToken(input) {
|
|
90801
|
+
if (input.token !== null) return `${input.base}|${input.token}`;
|
|
90802
|
+
const carried = input.persisted?.match(TOKEN_SUFFIX);
|
|
90803
|
+
if (carried === null || carried === void 0) return input.base;
|
|
90804
|
+
return carried.input?.slice(0, carried.index) === input.base ? `${input.base}|${carried[1]}` : input.base;
|
|
90805
|
+
}
|
|
90806
|
+
/**
|
|
90807
|
+
* What this camera could record, as one fingerprint token.
|
|
90808
|
+
*
|
|
90809
|
+
* There used to be an `rec:off` here, for a per-camera switch of ours. That
|
|
90810
|
+
* switch is gone (D589): whether HomeKit RECORDS is HomeKit's own persisted
|
|
90811
|
+
* characteristic, it does not change the accessory's shape, and a rebuild is
|
|
90812
|
+
* about shape. What still belongs here is the CAPABILITY — which slot, and
|
|
90813
|
+
* whether serving it means running an encoder — because those are two
|
|
90814
|
+
* different accessories to build.
|
|
90815
|
+
*/
|
|
90816
|
+
function recordabilityTokenFor(input) {
|
|
90817
|
+
return recordabilityToken(input.entries, { allowTranscode: input.allowTranscode !== false });
|
|
90818
|
+
}
|
|
90819
|
+
//#endregion
|
|
90673
90820
|
//#region src/hksv/videoclips-plane.ts
|
|
90674
90821
|
/**
|
|
90675
90822
|
* The two data-plane routes behind the HomeKit clip source.
|
|
@@ -90932,8 +91079,9 @@ function createHksvClipPlanes(deps) {
|
|
|
90932
91079
|
*
|
|
90933
91080
|
* ## The source EXISTS because of HomeKit, and is not a switch
|
|
90934
91081
|
*
|
|
90935
|
-
* A camera has this source because it is exported to HomeKit
|
|
90936
|
-
*
|
|
91082
|
+
* A camera has this source because it is exported to HomeKit and HomeKit has
|
|
91083
|
+
* not said no to recording it; un-export it, or switch recording off in the
|
|
91084
|
+
* Home app, and the row is gone (D550, D569 § 5, D589).
|
|
90937
91085
|
* There is deliberately no enable control of its own — a knob over somebody
|
|
90938
91086
|
* else's decision is D62's defect. `keepClips` is NOT that gate either: it
|
|
90939
91087
|
* governs the TEE, and the clips already kept remain the truth about this
|
|
@@ -90980,7 +91128,7 @@ function createHksvVideoclipsProvider(deps) {
|
|
|
90980
91128
|
*/
|
|
90981
91129
|
async function measure(deviceId) {
|
|
90982
91130
|
const facts = deps.describeCamera(deviceId);
|
|
90983
|
-
if (!facts.exported ||
|
|
91131
|
+
if (!facts.exported || facts.homekitRecording === false) return null;
|
|
90984
91132
|
const catalog = await deps.readCatalog(deviceId);
|
|
90985
91133
|
return {
|
|
90986
91134
|
availability: availabilityFor(facts, catalog),
|
|
@@ -106335,6 +106483,72 @@ function buildRecordingOptions(input) {
|
|
|
106335
106483
|
};
|
|
106336
106484
|
}
|
|
106337
106485
|
//#endregion
|
|
106486
|
+
//#region src/hksv/recording-invocation.ts
|
|
106487
|
+
/**
|
|
106488
|
+
* The resolution the accessory may advertise, which is the one it can deliver.
|
|
106489
|
+
*
|
|
106490
|
+
* A copy is advertised at its source size whatever that is — there is no
|
|
106491
|
+
* encoder to resize with, and advertising a size we cannot produce is the
|
|
106492
|
+
* "stream that does not match its own advertisement" defect in miniature.
|
|
106493
|
+
* A transcode above the ceiling is fitted down, because with an encoder in the
|
|
106494
|
+
* pipeline the 6.35 MB 4K fragment is a choice rather than a fact, and iOS
|
|
106495
|
+
* renders HKSV clips at 1080p regardless.
|
|
106496
|
+
*/
|
|
106497
|
+
function recordingOutput(source) {
|
|
106498
|
+
if (!source.transcode || source.height <= 1080) return {
|
|
106499
|
+
width: source.width,
|
|
106500
|
+
height: source.height,
|
|
106501
|
+
scaled: false
|
|
106502
|
+
};
|
|
106503
|
+
return {
|
|
106504
|
+
width: Math.max(2, Math.round(source.width * MAX_RECORDING_HEIGHT / source.height / 2) * 2),
|
|
106505
|
+
height: MAX_RECORDING_HEIGHT,
|
|
106506
|
+
scaled: true
|
|
106507
|
+
};
|
|
106508
|
+
}
|
|
106509
|
+
/** The invocation, minus the sink and sidecar the child owns. */
|
|
106510
|
+
function buildRecordingInvocation(input) {
|
|
106511
|
+
const { source } = input;
|
|
106512
|
+
const audio = input.audioActive ? {
|
|
106513
|
+
kind: "encode",
|
|
106514
|
+
codec: "aac",
|
|
106515
|
+
bitrateKbps: 32,
|
|
106516
|
+
sampleRateHz: HKSV_AUDIO_SAMPLE_RATE_HZ,
|
|
106517
|
+
channels: 1
|
|
106518
|
+
} : { kind: "none" };
|
|
106519
|
+
const out = recordingOutput(source);
|
|
106520
|
+
const video = source.transcode ? {
|
|
106521
|
+
kind: "encode",
|
|
106522
|
+
encoder: "libx264",
|
|
106523
|
+
preset: "veryfast",
|
|
106524
|
+
profile: "main",
|
|
106525
|
+
level: "4.0",
|
|
106526
|
+
pixelFormat: "yuv420p",
|
|
106527
|
+
forceKeyFramesSeconds: Math.max(1, Math.round(input.fragmentMs / 1e3)),
|
|
106528
|
+
bf: 0,
|
|
106529
|
+
...out.scaled ? { scale: {
|
|
106530
|
+
mode: "fit",
|
|
106531
|
+
width: out.width,
|
|
106532
|
+
height: out.height
|
|
106533
|
+
} } : {}
|
|
106534
|
+
} : { kind: "copy" };
|
|
106535
|
+
return {
|
|
106536
|
+
logLevel: "error",
|
|
106537
|
+
decodeHwAccel: source.transcode ? input.decodeHwAccel : null,
|
|
106538
|
+
input: {
|
|
106539
|
+
url: input.audioActive ? source.url : source.mutedUrl,
|
|
106540
|
+
rtspTransport: "tcp",
|
|
106541
|
+
...source.transcode && input.decodeInputArgs !== void 0 && input.decodeInputArgs.length > 0 ? { extraArgs: input.decodeInputArgs } : {},
|
|
106542
|
+
analyzeDurationUs: 1e6,
|
|
106543
|
+
probeSizeBytes: 1e6
|
|
106544
|
+
},
|
|
106545
|
+
video,
|
|
106546
|
+
audio,
|
|
106547
|
+
threadCount: 0,
|
|
106548
|
+
outputArgs: []
|
|
106549
|
+
};
|
|
106550
|
+
}
|
|
106551
|
+
//#endregion
|
|
106338
106552
|
//#region src/hksv/fragment-source.ts
|
|
106339
106553
|
/**
|
|
106340
106554
|
* How far back the prebuffer ring reaches.
|
|
@@ -106502,6 +106716,7 @@ var HksvFragmentSource = class {
|
|
|
106502
106716
|
meta: {
|
|
106503
106717
|
brokerId: this.input.source.brokerId,
|
|
106504
106718
|
resolution: `${this.input.source.width}x${this.input.source.height}`,
|
|
106719
|
+
branch: this.input.source.transcode ? "transcode" : "copy",
|
|
106505
106720
|
fragmentMs: this.input.fragmentMs,
|
|
106506
106721
|
audioActive: this.audioActive,
|
|
106507
106722
|
windowMs: PREBUFFER_WINDOW_MS
|
|
@@ -106566,27 +106781,13 @@ var HksvFragmentSource = class {
|
|
|
106566
106781
|
* takes AAC only — which the same measurement priced at 0.4 % of a core.
|
|
106567
106782
|
*/
|
|
106568
106783
|
buildInvocation() {
|
|
106569
|
-
|
|
106570
|
-
|
|
106571
|
-
|
|
106572
|
-
|
|
106573
|
-
|
|
106574
|
-
|
|
106575
|
-
}
|
|
106576
|
-
return {
|
|
106577
|
-
logLevel: "error",
|
|
106578
|
-
decodeHwAccel: null,
|
|
106579
|
-
input: {
|
|
106580
|
-
url: this.audioActive ? this.input.source.url : this.input.source.mutedUrl,
|
|
106581
|
-
rtspTransport: "tcp",
|
|
106582
|
-
analyzeDurationUs: 1e6,
|
|
106583
|
-
probeSizeBytes: 1e6
|
|
106584
|
-
},
|
|
106585
|
-
video: { kind: "copy" },
|
|
106586
|
-
audio,
|
|
106587
|
-
threadCount: 0,
|
|
106588
|
-
outputArgs: []
|
|
106589
|
-
};
|
|
106784
|
+
return buildRecordingInvocation({
|
|
106785
|
+
source: this.input.source,
|
|
106786
|
+
audioActive: this.audioActive,
|
|
106787
|
+
fragmentMs: this.input.fragmentMs,
|
|
106788
|
+
decodeHwAccel: this.input.decodeHwAccel ?? null,
|
|
106789
|
+
...this.input.decodeInputArgs === void 0 ? {} : { decodeInputArgs: this.input.decodeInputArgs }
|
|
106790
|
+
});
|
|
106590
106791
|
}
|
|
106591
106792
|
};
|
|
106592
106793
|
//#endregion
|
|
@@ -106946,74 +107147,6 @@ var HksvRecordingDelegate = class {
|
|
|
106946
107147
|
}
|
|
106947
107148
|
};
|
|
106948
107149
|
//#endregion
|
|
106949
|
-
//#region src/hksv/recording-source.ts
|
|
106950
|
-
/** The tallest frame the recording path will hold in its prebuffer. */
|
|
106951
|
-
var MAX_RECORDING_HEIGHT = 1080;
|
|
106952
|
-
/** HKSV takes H.264 only — AAC audio and H.264 video, no negotiation. */
|
|
106953
|
-
var RECORDABLE_CODEC = "h264";
|
|
106954
|
-
/**
|
|
106955
|
-
* Pick the slot the recording child pulls.
|
|
106956
|
-
*
|
|
106957
|
-
* Deliberately NOT `pickPreferredRtspEntry`: that picker resolves the operator's
|
|
106958
|
-
* LIVE preference and, on `auto`, steers by the resolution iOS negotiated for a
|
|
106959
|
-
* live session — neither is a fact about recording, and on 615 it selects `mid`,
|
|
106960
|
-
* a 10 fps slot. Recording has one criterion, applied here and nowhere else:
|
|
106961
|
-
* the largest copyable frame that does not exceed {@link MAX_RECORDING_HEIGHT}.
|
|
106962
|
-
*/
|
|
106963
|
-
function pickRecordingSource(entries) {
|
|
106964
|
-
const enabled = entries.filter((e) => e.enabled);
|
|
106965
|
-
if (enabled.length === 0) return {
|
|
106966
|
-
ok: false,
|
|
106967
|
-
refusal: "no-enabled-stream"
|
|
106968
|
-
};
|
|
106969
|
-
const h264 = enabled.filter((e) => normaliseCodec(e.codec) === RECORDABLE_CODEC);
|
|
106970
|
-
if (h264.length === 0) return {
|
|
106971
|
-
ok: false,
|
|
106972
|
-
refusal: "no-h264-stream"
|
|
106973
|
-
};
|
|
106974
|
-
const sized = h264.filter(hasUsableResolution);
|
|
106975
|
-
if (sized.length === 0) return {
|
|
106976
|
-
ok: false,
|
|
106977
|
-
refusal: "no-resolution"
|
|
106978
|
-
};
|
|
106979
|
-
const withinCeiling = sized.filter((e) => height(e) <= MAX_RECORDING_HEIGHT);
|
|
106980
|
-
const best = [...withinCeiling.length > 0 ? withinCeiling : sized].sort((a, b) => withinCeiling.length > 0 ? height(b) - height(a) : height(a) - height(b))[0];
|
|
106981
|
-
if (best === void 0 || best.resolution === void 0) return {
|
|
106982
|
-
ok: false,
|
|
106983
|
-
refusal: "no-resolution"
|
|
106984
|
-
};
|
|
106985
|
-
return {
|
|
106986
|
-
ok: true,
|
|
106987
|
-
source: {
|
|
106988
|
-
brokerId: best.brokerId,
|
|
106989
|
-
profile: best.profile ?? best.brokerId,
|
|
106990
|
-
url: best.url,
|
|
106991
|
-
mutedUrl: best.mutedUrl,
|
|
106992
|
-
width: best.resolution.width,
|
|
106993
|
-
height: best.resolution.height
|
|
106994
|
-
}
|
|
106995
|
-
};
|
|
106996
|
-
}
|
|
106997
|
-
/** A one-line reason for the log — silence about a withdrawn service reads as a bug. */
|
|
106998
|
-
function refusalReason(refusal) {
|
|
106999
|
-
switch (refusal) {
|
|
107000
|
-
case "no-enabled-stream": return "the camera has no enabled RTSP profile";
|
|
107001
|
-
case "no-h264-stream": return "every enabled profile is H.265 — HKSV takes H.264 only, and a permanent transcode costs 128x a copy";
|
|
107002
|
-
case "no-resolution": return "no enabled profile declares a resolution, so nothing honest could be advertised";
|
|
107003
|
-
}
|
|
107004
|
-
}
|
|
107005
|
-
function height(entry) {
|
|
107006
|
-
return entry.resolution?.height ?? 0;
|
|
107007
|
-
}
|
|
107008
|
-
function hasUsableResolution(entry) {
|
|
107009
|
-
const r = entry.resolution;
|
|
107010
|
-
return r !== void 0 && r.width > 0 && r.height > 0;
|
|
107011
|
-
}
|
|
107012
|
-
/** Publishers spell H.265 four ways; the same normalisation the broker uses. */
|
|
107013
|
-
function normaliseCodec(codec) {
|
|
107014
|
-
return (codec ?? "").toLowerCase().replace(/[.\s-]/g, "");
|
|
107015
|
-
}
|
|
107016
|
-
//#endregion
|
|
107017
107150
|
//#region src/hksv/build-recording.ts
|
|
107018
107151
|
/**
|
|
107019
107152
|
* Assemble HomeKit Secure Video for one camera — the ADVERTISEMENT and the
|
|
@@ -107065,7 +107198,7 @@ async function buildHksvRecording(input) {
|
|
|
107065
107198
|
note("profiles-unreadable", "cameraStreams.getProfileRtspEntries could not be read");
|
|
107066
107199
|
return null;
|
|
107067
107200
|
}
|
|
107068
|
-
const choice = pickRecordingSource(entries);
|
|
107201
|
+
const choice = pickRecordingSource(entries, { allowTranscode: bctx.options.hapDeviceSettings?.allowTranscode !== false });
|
|
107069
107202
|
if (!choice.ok) {
|
|
107070
107203
|
log.info("export-hap: HKSV withheld — no recordable stream", { meta: {
|
|
107071
107204
|
refusal: choice.refusal,
|
|
@@ -107075,9 +107208,10 @@ async function buildHksvRecording(input) {
|
|
|
107075
107208
|
return null;
|
|
107076
107209
|
}
|
|
107077
107210
|
const source = choice.source;
|
|
107211
|
+
const decodeHwAccel = source.transcode ? await resolveDecodeHwAccel(bctx, log) : null;
|
|
107078
107212
|
const gopMs = await readSourceGopMs(bctx, source.width, source.height);
|
|
107079
|
-
const fragmentLengthMs = deriveFragmentLengthMs(gopMs);
|
|
107080
|
-
if (fragmentLengthMs === null) {
|
|
107213
|
+
const fragmentLengthMs = source.transcode ? HKSV_FRAGMENT_LENGTHS_MS[0] : deriveFragmentLengthMs(gopMs);
|
|
107214
|
+
if (fragmentLengthMs === void 0 || fragmentLengthMs === null) {
|
|
107081
107215
|
log.warn("export-hap: HKSV withheld — the camera key-frame interval is longer than any fragment length we advertise", { meta: {
|
|
107082
107216
|
gopMs,
|
|
107083
107217
|
brokerId: source.brokerId
|
|
@@ -107088,9 +107222,10 @@ async function buildHksvRecording(input) {
|
|
|
107088
107222
|
const parsedProfile = CamProfileSchema.safeParse(source.profile);
|
|
107089
107223
|
const recordedProfile = parsedProfile.success ? parsedProfile.data : null;
|
|
107090
107224
|
const fps = resolveFps(input.fpsByProfile, source.profile);
|
|
107225
|
+
const out = recordingOutput(source);
|
|
107091
107226
|
const options = buildRecordingOptions({
|
|
107092
|
-
width:
|
|
107093
|
-
height:
|
|
107227
|
+
width: out.width,
|
|
107228
|
+
height: out.height,
|
|
107094
107229
|
fps,
|
|
107095
107230
|
fragmentLengthMs
|
|
107096
107231
|
});
|
|
@@ -107100,8 +107235,8 @@ async function buildHksvRecording(input) {
|
|
|
107100
107235
|
logger: log,
|
|
107101
107236
|
deviceId: numericDeviceId,
|
|
107102
107237
|
store: clipStore,
|
|
107103
|
-
width:
|
|
107104
|
-
height:
|
|
107238
|
+
width: out.width,
|
|
107239
|
+
height: out.height,
|
|
107105
107240
|
fragmentMs: fragmentLengthMs,
|
|
107106
107241
|
...recordedProfile === null ? {} : { profile: recordedProfile }
|
|
107107
107242
|
});
|
|
@@ -107120,14 +107255,21 @@ async function buildHksvRecording(input) {
|
|
|
107120
107255
|
spawnFn: node_child_process.spawn,
|
|
107121
107256
|
source,
|
|
107122
107257
|
fragmentMs,
|
|
107123
|
-
audioActive
|
|
107258
|
+
audioActive,
|
|
107259
|
+
decodeHwAccel: decodeHwAccel?.hwaccel ?? null,
|
|
107260
|
+
...decodeHwAccel === null || decodeHwAccel.extraInputArgs.length === 0 ? {} : { decodeInputArgs: decodeHwAccel.extraInputArgs }
|
|
107124
107261
|
})
|
|
107125
107262
|
});
|
|
107126
107263
|
const advertisedResolution = options.video.resolutions[0];
|
|
107127
107264
|
log.info("export-hap: HKSV ADVERTISED — recording is offered for this camera", { meta: {
|
|
107128
107265
|
brokerId: source.brokerId,
|
|
107129
107266
|
profile: source.profile,
|
|
107130
|
-
resolution: `${
|
|
107267
|
+
resolution: `${out.width}x${out.height}`,
|
|
107268
|
+
branch: source.transcode ? "transcode" : "copy",
|
|
107269
|
+
...source.transcode ? {
|
|
107270
|
+
sourceResolution: `${source.width}x${source.height}`,
|
|
107271
|
+
decodeHwAccel: decodeHwAccel?.hwaccel ?? "software"
|
|
107272
|
+
} : {},
|
|
107131
107273
|
measuredFps: fps,
|
|
107132
107274
|
advertisedFps: advertisedResolution?.[2] ?? null,
|
|
107133
107275
|
fragmentLengthMs,
|
|
@@ -107142,6 +107284,27 @@ async function buildHksvRecording(input) {
|
|
|
107142
107284
|
dispose: () => delegate.dispose()
|
|
107143
107285
|
};
|
|
107144
107286
|
}
|
|
107287
|
+
/**
|
|
107288
|
+
* The `-hwaccel` value for a transcoding prebuffer, and the flags that travel
|
|
107289
|
+
* with it.
|
|
107290
|
+
*
|
|
107291
|
+
* Software is a legitimate answer and is always NAMED: a camera that quietly
|
|
107292
|
+
* stopped using the GPU and one that never had it look identical in a CPU
|
|
107293
|
+
* graph, and on this branch the difference is most of a core, permanently.
|
|
107294
|
+
*/
|
|
107295
|
+
async function resolveDecodeHwAccel(bctx, log) {
|
|
107296
|
+
const decision = selectHwDecode({
|
|
107297
|
+
transcode: true,
|
|
107298
|
+
reading: await probeDecoderHwaccel({
|
|
107299
|
+
source: decoderInfoSourceFromContext(bctx.ctx),
|
|
107300
|
+
log,
|
|
107301
|
+
memo: bctx.options.decodeMemos.reading
|
|
107302
|
+
}),
|
|
107303
|
+
platform: process.platform
|
|
107304
|
+
});
|
|
107305
|
+
if (decision.kind === "software") log.warn("export-hap: HKSV will transcode in SOFTWARE — this costs most of a core, permanently", { meta: { reason: decision.reason } });
|
|
107306
|
+
return decision;
|
|
107307
|
+
}
|
|
107145
107308
|
/** `cameraStreams.getProfileRtspEntries`, or `null` when the cap is unreachable. */
|
|
107146
107309
|
async function readProfileEntries(bctx) {
|
|
107147
107310
|
try {
|
|
@@ -108137,13 +108300,12 @@ async function buildCameraAccessory(input) {
|
|
|
108137
108300
|
bctx,
|
|
108138
108301
|
streamingOptions: streams.streamingOptions
|
|
108139
108302
|
}));
|
|
108140
|
-
const recordingEnabled = options.hapDeviceSettings.hksvRecording === true;
|
|
108141
108303
|
let recordingAudioActive = true;
|
|
108142
|
-
const recording =
|
|
108304
|
+
const recording = await buildHksvRecording({
|
|
108143
108305
|
bctx,
|
|
108144
108306
|
fpsByProfile: advertisedVideo.fpsByProfile,
|
|
108145
108307
|
isAudioActive: () => recordingAudioActive
|
|
108146
|
-
})
|
|
108308
|
+
});
|
|
108147
108309
|
const controller = new (isDoorbell ? import_dist.DoorbellController : import_dist.CameraController)({
|
|
108148
108310
|
delegate: streams.delegate,
|
|
108149
108311
|
streamingOptions: streams.streamingOptions,
|
|
@@ -108392,38 +108554,7 @@ function syncStateToJson(map) {
|
|
|
108392
108554
|
* that matters about it: it must resolve to NOT recording, or "off by default"
|
|
108393
108555
|
* is true only for cameras exposed before the flip.
|
|
108394
108556
|
*/
|
|
108395
|
-
var DEFAULT_DEVICE_SETTINGS = {
|
|
108396
|
-
streamPreference: "auto",
|
|
108397
|
-
hksvRecording: false
|
|
108398
|
-
};
|
|
108399
|
-
/**
|
|
108400
|
-
* OFF unless explicitly switched on — operator decision 2026-09-21, reversing
|
|
108401
|
-
* the 2026-08-08 flip:
|
|
108402
|
-
*
|
|
108403
|
-
* > *"il recording deve essere off di default, non deve essere attivo quando
|
|
108404
|
-
* > si esporta una camera su HKSV"*
|
|
108405
|
-
*
|
|
108406
|
-
* What changed is not the cost the earlier record argued about — recording on
|
|
108407
|
-
* the copy branch really is 0.7 % of a core and ~30 MB (D84), and cheap it
|
|
108408
|
-
* remains. What changed is that on-by-default made the switch **say something
|
|
108409
|
-
* the system was not doing**. Measured on 2026-09-21: 592, 587, 618 and 640
|
|
108410
|
-
* all reported HomeKit recording ON while the advertisement was WITHHELD
|
|
108411
|
-
* (`no-enabled-stream` — no enabled RTSP profile), so HomeKit never recorded
|
|
108412
|
-
* them and the tee had nothing to keep. The operator was being told four
|
|
108413
|
-
* cameras were recording because nobody had turned them off.
|
|
108414
|
-
*
|
|
108415
|
-
* Exposing a camera to HomeKit is a decision about EXPOSURE. Recording is a
|
|
108416
|
-
* second decision and now has to be made.
|
|
108417
|
-
*
|
|
108418
|
-
* ABSENT resolves OFF — the load-bearing case, for the same reason it was
|
|
108419
|
-
* before and with the opposite answer: a camera whose settings predate the
|
|
108420
|
-
* field, or that was exposed before today, was never asked about. Every read
|
|
108421
|
-
* goes through this one resolver (`=== true`), never a scattered `!== false`,
|
|
108422
|
-
* because those two disagree about exactly that case.
|
|
108423
|
-
*/
|
|
108424
|
-
function resolveHksvRecording(settings) {
|
|
108425
|
-
return settings?.hksvRecording === true;
|
|
108426
|
-
}
|
|
108557
|
+
var DEFAULT_DEVICE_SETTINGS = { streamPreference: "auto" };
|
|
108427
108558
|
/**
|
|
108428
108559
|
* Whether this camera offers Apple's NEW camera service alongside the classic
|
|
108429
108560
|
* one — `camera-multi-tier-rtp-stream-management` (0x8031), where HEVC and 4K
|
|
@@ -108466,11 +108597,11 @@ var EXPORT_HAP_ADDON_ID = "export-hap";
|
|
|
108466
108597
|
* stays and says the copies are off, which is D62's other half: an off switch
|
|
108467
108598
|
* is REPORTED off, never made to look like a broken camera.
|
|
108468
108599
|
*/
|
|
108469
|
-
function describeHksvCamera(entry, lastBuild) {
|
|
108600
|
+
function describeHksvCamera(entry, lastBuild, homekitRecording) {
|
|
108470
108601
|
const isCamera = entry !== null && entry.mapperKind === "camera";
|
|
108471
108602
|
return {
|
|
108472
108603
|
exported: isCamera,
|
|
108473
|
-
|
|
108604
|
+
homekitRecording: isCamera ? homekitRecording : null,
|
|
108474
108605
|
keepingClips: isCamera && resolveKeepHomekitClips(entry.settings),
|
|
108475
108606
|
lastBuild
|
|
108476
108607
|
};
|
|
@@ -108714,7 +108845,7 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
108714
108845
|
const clipsProvider = createHksvVideoclipsProvider({
|
|
108715
108846
|
logger: this.ctx.logger.child("hksv-clips"),
|
|
108716
108847
|
addonId: EXPORT_HAP_ADDON_ID,
|
|
108717
|
-
describeCamera: (deviceId) => describeHksvCamera(this.findEntry(deviceId), this.hksvBuilds.lastFor(deviceId)),
|
|
108848
|
+
describeCamera: (deviceId) => describeHksvCamera(this.findEntry(deviceId), this.hksvBuilds.lastFor(deviceId), readHomekitRecordingActive(this.exposed.get(String(deviceId))?.accessories ?? [])),
|
|
108718
108849
|
readCatalog: async (deviceId) => await this.hksvClipStore?.listCatalog(deviceId) ?? { kind: "no-root" },
|
|
108719
108850
|
locationRefusal: () => this.hksvClipLocation?.lastRefusal ?? null,
|
|
108720
108851
|
mediaUrl: (deviceId, stem) => this.hksvClipUrl("media", deviceId, stem),
|
|
@@ -108870,7 +109001,6 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
108870
109001
|
},
|
|
108871
109002
|
hapDeviceSettings: {
|
|
108872
109003
|
streamPreference: entrySettings.streamPreference ?? "auto",
|
|
108873
|
-
hksvRecording: resolveHksvRecording(entrySettings),
|
|
108874
109004
|
keepHomekitClips: resolveKeepHomekitClips(entrySettings),
|
|
108875
109005
|
multiTierService: resolveMultiTierService(entrySettings),
|
|
108876
109006
|
allowTranscode: entrySettings.allowTranscode !== false
|
|
@@ -108951,15 +109081,40 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
108951
109081
|
features: device.features
|
|
108952
109082
|
});
|
|
108953
109083
|
const persisted = syncStateFromJson(this.config.exportSyncState).get(deviceId)?.fingerprint;
|
|
109084
|
+
const withToken = withRecordabilityToken({
|
|
109085
|
+
base: fresh,
|
|
109086
|
+
token: await this.readRecordabilityToken(deviceId, device.type),
|
|
109087
|
+
persisted
|
|
109088
|
+
});
|
|
108954
109089
|
return resolveExportFingerprint({
|
|
108955
109090
|
ready: device.probed ?? false,
|
|
108956
|
-
fresh,
|
|
109091
|
+
fresh: withToken,
|
|
108957
109092
|
persisted
|
|
108958
109093
|
});
|
|
108959
109094
|
} catch {
|
|
108960
109095
|
return null;
|
|
108961
109096
|
}
|
|
108962
109097
|
}
|
|
109098
|
+
/**
|
|
109099
|
+
* The slot HKSV would record this camera from, for the fingerprint.
|
|
109100
|
+
*
|
|
109101
|
+
* `null` — do not move the fingerprint — for anything that is not a camera
|
|
109102
|
+
* (a switch has no recording decision, and stamping a constant on it would
|
|
109103
|
+
* republish every non-camera accessory once for nothing) and for a camera
|
|
109104
|
+
* whose profiles could not be read right now.
|
|
109105
|
+
*/
|
|
109106
|
+
async readRecordabilityToken(deviceId, type) {
|
|
109107
|
+
if (pickMapperKind(type) !== "camera") return null;
|
|
109108
|
+
try {
|
|
109109
|
+
const entries = await (await this.ctx.fetchDevice(deviceId)).cameraStreams?.getProfileRtspEntries({});
|
|
109110
|
+
return recordabilityTokenFor({
|
|
109111
|
+
allowTranscode: this.findEntry(deviceId)?.settings?.allowTranscode !== false,
|
|
109112
|
+
entries: entries ?? null
|
|
109113
|
+
});
|
|
109114
|
+
} catch {
|
|
109115
|
+
return null;
|
|
109116
|
+
}
|
|
109117
|
+
}
|
|
108963
109118
|
/** Persist a device's current export fingerprint into `exportSyncState`
|
|
108964
109119
|
* (best-effort; called after a successful attach). */
|
|
108965
109120
|
async persistFingerprint(deviceId, fingerprint) {
|
|
@@ -109225,7 +109380,6 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
109225
109380
|
const enabled = entry !== null;
|
|
109226
109381
|
const enabledKey = `hap:${deviceId}:enabled`;
|
|
109227
109382
|
const streamPreferenceKey = `hap:${deviceId}:streamPreference`;
|
|
109228
|
-
const hksvKey = `hap:${deviceId}:hksvRecording`;
|
|
109229
109383
|
const multiTierKey = `hap:${deviceId}:multiTierService`;
|
|
109230
109384
|
const useBridgeKey = `hap:${deviceId}:useBridge`;
|
|
109231
109385
|
const allowTranscodeKey = `hap:${deviceId}:allowTranscode`;
|
|
@@ -109276,17 +109430,14 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
109276
109430
|
immediate: true
|
|
109277
109431
|
},
|
|
109278
109432
|
{
|
|
109279
|
-
type: "
|
|
109280
|
-
key:
|
|
109433
|
+
type: "info",
|
|
109434
|
+
key: `hap:${deviceId}:hksvRecordingStatus`,
|
|
109281
109435
|
label: "HomeKit recording (Secure Video)",
|
|
109282
|
-
|
|
109283
|
-
style: "switch",
|
|
109284
|
-
value: resolveHksvRecording(settings),
|
|
109436
|
+
content: describeHomekitRecording(readHomekitRecordingActive(this.exposed.get(String(deviceId))?.accessories ?? []), this.hksvBuilds.lastFor(deviceId)),
|
|
109285
109437
|
showWhen: {
|
|
109286
109438
|
field: enabledKey,
|
|
109287
109439
|
equals: true
|
|
109288
|
-
}
|
|
109289
|
-
immediate: true
|
|
109440
|
+
}
|
|
109290
109441
|
},
|
|
109291
109442
|
{
|
|
109292
109443
|
type: "boolean",
|
|
@@ -109309,7 +109460,7 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
109309
109460
|
style: "switch",
|
|
109310
109461
|
value: resolveKeepHomekitClips(settings),
|
|
109311
109462
|
showWhen: {
|
|
109312
|
-
field:
|
|
109463
|
+
field: enabledKey,
|
|
109313
109464
|
equals: true
|
|
109314
109465
|
},
|
|
109315
109466
|
immediate: true
|
|
@@ -109409,7 +109560,6 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
109409
109560
|
const wasEnabled = this.exposed.has(deviceIdStr);
|
|
109410
109561
|
const enabledKey = `hap:${deviceId}:enabled`;
|
|
109411
109562
|
const streamPreferenceKey = `hap:${deviceId}:streamPreference`;
|
|
109412
|
-
const hksvKey = `hap:${deviceId}:hksvRecording`;
|
|
109413
109563
|
const useBridgeKey = `hap:${deviceId}:useBridge`;
|
|
109414
109564
|
const allowTranscodeKey = `hap:${deviceId}:allowTranscode`;
|
|
109415
109565
|
const multiTierKey = `hap:${deviceId}:multiTierService`;
|
|
@@ -109418,7 +109568,6 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
109418
109568
|
const enabledValue = enabledKey in patch ? Boolean(patch[enabledKey]) : wasEnabled;
|
|
109419
109569
|
const streamPreferenceRaw = streamPreferenceKey in patch ? patch[streamPreferenceKey] : current?.settings?.streamPreference;
|
|
109420
109570
|
const streamPreference = typeof streamPreferenceRaw === "string" && streamPreferenceRaw.trim().length > 0 ? streamPreferenceRaw : "auto";
|
|
109421
|
-
const hksvRecording = hksvKey in patch ? Boolean(patch[hksvKey]) : resolveHksvRecording(current?.settings);
|
|
109422
109571
|
const useBridge = useBridgeKey in patch ? Boolean(patch[useBridgeKey]) : current?.settings?.useBridge !== false;
|
|
109423
109572
|
const allowTranscode = allowTranscodeKey in patch ? Boolean(patch[allowTranscodeKey]) : current?.settings?.allowTranscode !== false;
|
|
109424
109573
|
const multiTierService = multiTierKey in patch ? Boolean(patch[multiTierKey]) : resolveMultiTierService(current?.settings);
|
|
@@ -109430,7 +109579,6 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
109430
109579
|
const nextSettings = {
|
|
109431
109580
|
...current?.settings ?? DEFAULT_DEVICE_SETTINGS,
|
|
109432
109581
|
streamPreference,
|
|
109433
|
-
hksvRecording,
|
|
109434
109582
|
useBridge,
|
|
109435
109583
|
allowTranscode,
|
|
109436
109584
|
multiTierService,
|
|
@@ -109447,7 +109595,6 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
109447
109595
|
return { success: true };
|
|
109448
109596
|
}
|
|
109449
109597
|
const currentPref = current?.settings?.streamPreference ?? "auto";
|
|
109450
|
-
const currentHksv = resolveHksvRecording(current?.settings);
|
|
109451
109598
|
const currentKeepClips = resolveKeepHomekitClips(current?.settings);
|
|
109452
109599
|
const mapperKind = current?.mapperKind ?? "generic";
|
|
109453
109600
|
const wasBridged = shouldBridge({
|
|
@@ -109459,16 +109606,12 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
109459
109606
|
useBridge
|
|
109460
109607
|
});
|
|
109461
109608
|
await this.updateEntrySettings(deviceIdStr, nextSettings);
|
|
109462
|
-
if (currentPref !== streamPreference ||
|
|
109609
|
+
if (currentPref !== streamPreference || currentKeepClips !== keepClips || wasBridged !== willBridge) {
|
|
109463
109610
|
log.info("export-hap: per-camera export settings changed — refreshing accessory", { meta: {
|
|
109464
109611
|
streamPreference: {
|
|
109465
109612
|
from: currentPref,
|
|
109466
109613
|
to: streamPreference
|
|
109467
109614
|
},
|
|
109468
|
-
hksvRecording: {
|
|
109469
|
-
from: currentHksv,
|
|
109470
|
-
to: hksvRecording
|
|
109471
|
-
},
|
|
109472
109615
|
keepClips: {
|
|
109473
109616
|
from: currentKeepClips,
|
|
109474
109617
|
to: keepClips
|
|
@@ -109565,7 +109708,6 @@ exports.describeHksvCamera = describeHksvCamera;
|
|
|
109565
109708
|
exports.initHapStorage = initHapStorage;
|
|
109566
109709
|
exports.publishStandalone = publishStandalone;
|
|
109567
109710
|
exports.resolveClipRetentionDays = resolveClipRetentionDays;
|
|
109568
|
-
exports.resolveHksvRecording = resolveHksvRecording;
|
|
109569
109711
|
exports.resolveKeepHomekitClips = resolveKeepHomekitClips;
|
|
109570
109712
|
exports.resolveMultiTierService = resolveMultiTierService;
|
|
109571
109713
|
exports.unpublishAccessory = unpublishAccessory;
|
|
@@ -90660,6 +90660,153 @@ function createClipThumbnailMinter(input) {
|
|
|
90660
90660
|
};
|
|
90661
90661
|
}
|
|
90662
90662
|
//#endregion
|
|
90663
|
+
//#region src/hksv/recording-active.ts
|
|
90664
|
+
/**
|
|
90665
|
+
* `true`/`false` as HomeKit holds it, or `null` when this accessory does not
|
|
90666
|
+
* offer recording at all.
|
|
90667
|
+
*/
|
|
90668
|
+
function readHomekitRecordingActive(accessories) {
|
|
90669
|
+
for (const accessory of accessories) {
|
|
90670
|
+
const service = accessory.getService(import_dist.Service.CameraRecordingManagement);
|
|
90671
|
+
if (service === void 0) continue;
|
|
90672
|
+
const value = service.getCharacteristic(import_dist.Characteristic.Active).value;
|
|
90673
|
+
if (typeof value === "number") return value === 1;
|
|
90674
|
+
if (typeof value === "boolean") return value;
|
|
90675
|
+
return null;
|
|
90676
|
+
}
|
|
90677
|
+
return null;
|
|
90678
|
+
}
|
|
90679
|
+
/**
|
|
90680
|
+
* One sentence for the settings page: what HomeKit says, or why it was never
|
|
90681
|
+
* asked.
|
|
90682
|
+
*
|
|
90683
|
+
* The cost stays in the text even though the switch is gone. It is still the
|
|
90684
|
+
* operator's to understand — a camera recording here runs a permanent ffmpeg —
|
|
90685
|
+
* and a status line that reported only the state would have quietly dropped
|
|
90686
|
+
* the one number that decides whether to turn it on.
|
|
90687
|
+
*/
|
|
90688
|
+
function describeHomekitRecording(active, lastBuild) {
|
|
90689
|
+
if (active === true) return "HomeKit is recording this camera. Turn it off in the Home app (Camera settings → “Stream”), not here — iOS owns this switch and keeps it across reboots. While it is on, this camera runs a permanent prebuffer (~0.7% of one CPU core on the copy branch).";
|
|
90690
|
+
if (active === false) return "This camera offers recording and HomeKit has it off. Turn it on in the Home app (Camera settings → “Stream and Allow Recording”); it needs iCloud+ and a home hub. Nothing runs here until you do.";
|
|
90691
|
+
if (lastBuild !== null && !lastBuild.advertised) return `Recording is not offered for this camera: ${lastBuild.detail ?? "the accessory could not advertise it"}.`;
|
|
90692
|
+
return "Not known yet — this camera’s accessory has not published its recording service in this process. It appears once the accessory is built.";
|
|
90693
|
+
}
|
|
90694
|
+
//#endregion
|
|
90695
|
+
//#region src/hksv/recording-source.ts
|
|
90696
|
+
/** The tallest frame the recording path will hold in its prebuffer. */
|
|
90697
|
+
var MAX_RECORDING_HEIGHT = 1080;
|
|
90698
|
+
/** HKSV takes H.264 only — AAC audio and H.264 video, no negotiation. */
|
|
90699
|
+
var RECORDABLE_CODEC = "h264";
|
|
90700
|
+
/**
|
|
90701
|
+
* Pick the slot the recording child pulls.
|
|
90702
|
+
*
|
|
90703
|
+
* Deliberately NOT `pickPreferredRtspEntry`: that picker resolves the operator's
|
|
90704
|
+
* LIVE preference and, on `auto`, steers by the resolution iOS negotiated for a
|
|
90705
|
+
* live session — neither is a fact about recording, and on 615 it selects `mid`,
|
|
90706
|
+
* a 10 fps slot. Recording has one criterion, applied here and nowhere else:
|
|
90707
|
+
* the largest copyable frame that does not exceed {@link MAX_RECORDING_HEIGHT}.
|
|
90708
|
+
*/
|
|
90709
|
+
function pickRecordingSource(entries, options = {}) {
|
|
90710
|
+
const enabled = entries.filter((e) => e.enabled);
|
|
90711
|
+
if (enabled.length === 0) return {
|
|
90712
|
+
ok: false,
|
|
90713
|
+
refusal: "no-enabled-stream"
|
|
90714
|
+
};
|
|
90715
|
+
const h264 = enabled.filter((e) => normaliseCodec(e.codec) === RECORDABLE_CODEC);
|
|
90716
|
+
const pool = h264.length > 0 ? h264 : options.allowTranscode === true ? enabled : [];
|
|
90717
|
+
if (pool.length === 0) return {
|
|
90718
|
+
ok: false,
|
|
90719
|
+
refusal: "no-h264-stream"
|
|
90720
|
+
};
|
|
90721
|
+
const transcode = h264.length === 0;
|
|
90722
|
+
const sized = pool.filter(hasUsableResolution);
|
|
90723
|
+
if (sized.length === 0) return {
|
|
90724
|
+
ok: false,
|
|
90725
|
+
refusal: "no-resolution"
|
|
90726
|
+
};
|
|
90727
|
+
const withinCeiling = sized.filter((e) => height(e) <= MAX_RECORDING_HEIGHT);
|
|
90728
|
+
const within = withinCeiling.length > 0;
|
|
90729
|
+
const best = [...within ? withinCeiling : sized].sort((a, b) => within ? height(b) - height(a) : height(a) - height(b))[0];
|
|
90730
|
+
if (best === void 0 || best.resolution === void 0) return {
|
|
90731
|
+
ok: false,
|
|
90732
|
+
refusal: "no-resolution"
|
|
90733
|
+
};
|
|
90734
|
+
return {
|
|
90735
|
+
ok: true,
|
|
90736
|
+
source: {
|
|
90737
|
+
brokerId: best.brokerId,
|
|
90738
|
+
profile: best.profile ?? best.brokerId,
|
|
90739
|
+
transcode,
|
|
90740
|
+
url: best.url,
|
|
90741
|
+
mutedUrl: best.mutedUrl,
|
|
90742
|
+
width: best.resolution.width,
|
|
90743
|
+
height: best.resolution.height
|
|
90744
|
+
}
|
|
90745
|
+
};
|
|
90746
|
+
}
|
|
90747
|
+
/** A one-line reason for the log — silence about a withdrawn service reads as a bug. */
|
|
90748
|
+
function refusalReason(refusal) {
|
|
90749
|
+
switch (refusal) {
|
|
90750
|
+
case "no-enabled-stream": return "the camera has no enabled RTSP profile";
|
|
90751
|
+
case "no-h264-stream": return "every enabled profile is H.265 — HKSV takes H.264 only, and a permanent transcode costs 128x a copy";
|
|
90752
|
+
case "no-resolution": return "no enabled profile declares a resolution, so nothing honest could be advertised";
|
|
90753
|
+
}
|
|
90754
|
+
}
|
|
90755
|
+
function height(entry) {
|
|
90756
|
+
return entry.resolution?.height ?? 0;
|
|
90757
|
+
}
|
|
90758
|
+
function hasUsableResolution(entry) {
|
|
90759
|
+
const r = entry.resolution;
|
|
90760
|
+
return r !== void 0 && r.width > 0 && r.height > 0;
|
|
90761
|
+
}
|
|
90762
|
+
/** Publishers spell H.265 four ways; the same normalisation the broker uses. */
|
|
90763
|
+
function normaliseCodec(codec) {
|
|
90764
|
+
return (codec ?? "").toLowerCase().replace(/[.\s-]/g, "");
|
|
90765
|
+
}
|
|
90766
|
+
//#endregion
|
|
90767
|
+
//#region src/hksv/recordability-token.ts
|
|
90768
|
+
/** No slot is recordable. A real answer, and a fingerprint-moving one. */
|
|
90769
|
+
var RECORDABILITY_NONE = "rec:none";
|
|
90770
|
+
/**
|
|
90771
|
+
* The slot HKSV would record from, as a fingerprint token.
|
|
90772
|
+
*
|
|
90773
|
+
* `null` when `entries` is null — the profiles could not be read, which is not
|
|
90774
|
+
* a claim about the camera.
|
|
90775
|
+
*/
|
|
90776
|
+
function recordabilityToken(entries, options = {}) {
|
|
90777
|
+
if (entries === null) return null;
|
|
90778
|
+
const choice = pickRecordingSource(entries, options);
|
|
90779
|
+
return choice.ok ? `rec:${choice.source.profile}${choice.source.transcode ? "+x" : ""}` : RECORDABILITY_NONE;
|
|
90780
|
+
}
|
|
90781
|
+
var TOKEN_SUFFIX = /\|(rec:[^|]*)$/;
|
|
90782
|
+
/**
|
|
90783
|
+
* Attach the recording token to a device-shape fingerprint.
|
|
90784
|
+
*
|
|
90785
|
+
* On an unreadable read the persisted token is carried forward — but only onto
|
|
90786
|
+
* the base it was persisted against. If the device's SHAPE also changed, that
|
|
90787
|
+
* change is a rebuild in its own right and the token has nothing to say about
|
|
90788
|
+
* it; inventing one there would advertise a recording slot nobody read.
|
|
90789
|
+
*/
|
|
90790
|
+
function withRecordabilityToken(input) {
|
|
90791
|
+
if (input.token !== null) return `${input.base}|${input.token}`;
|
|
90792
|
+
const carried = input.persisted?.match(TOKEN_SUFFIX);
|
|
90793
|
+
if (carried === null || carried === void 0) return input.base;
|
|
90794
|
+
return carried.input?.slice(0, carried.index) === input.base ? `${input.base}|${carried[1]}` : input.base;
|
|
90795
|
+
}
|
|
90796
|
+
/**
|
|
90797
|
+
* What this camera could record, as one fingerprint token.
|
|
90798
|
+
*
|
|
90799
|
+
* There used to be an `rec:off` here, for a per-camera switch of ours. That
|
|
90800
|
+
* switch is gone (D589): whether HomeKit RECORDS is HomeKit's own persisted
|
|
90801
|
+
* characteristic, it does not change the accessory's shape, and a rebuild is
|
|
90802
|
+
* about shape. What still belongs here is the CAPABILITY — which slot, and
|
|
90803
|
+
* whether serving it means running an encoder — because those are two
|
|
90804
|
+
* different accessories to build.
|
|
90805
|
+
*/
|
|
90806
|
+
function recordabilityTokenFor(input) {
|
|
90807
|
+
return recordabilityToken(input.entries, { allowTranscode: input.allowTranscode !== false });
|
|
90808
|
+
}
|
|
90809
|
+
//#endregion
|
|
90663
90810
|
//#region src/hksv/videoclips-plane.ts
|
|
90664
90811
|
/**
|
|
90665
90812
|
* The two data-plane routes behind the HomeKit clip source.
|
|
@@ -90922,8 +91069,9 @@ function createHksvClipPlanes(deps) {
|
|
|
90922
91069
|
*
|
|
90923
91070
|
* ## The source EXISTS because of HomeKit, and is not a switch
|
|
90924
91071
|
*
|
|
90925
|
-
* A camera has this source because it is exported to HomeKit
|
|
90926
|
-
*
|
|
91072
|
+
* A camera has this source because it is exported to HomeKit and HomeKit has
|
|
91073
|
+
* not said no to recording it; un-export it, or switch recording off in the
|
|
91074
|
+
* Home app, and the row is gone (D550, D569 § 5, D589).
|
|
90927
91075
|
* There is deliberately no enable control of its own — a knob over somebody
|
|
90928
91076
|
* else's decision is D62's defect. `keepClips` is NOT that gate either: it
|
|
90929
91077
|
* governs the TEE, and the clips already kept remain the truth about this
|
|
@@ -90970,7 +91118,7 @@ function createHksvVideoclipsProvider(deps) {
|
|
|
90970
91118
|
*/
|
|
90971
91119
|
async function measure(deviceId) {
|
|
90972
91120
|
const facts = deps.describeCamera(deviceId);
|
|
90973
|
-
if (!facts.exported ||
|
|
91121
|
+
if (!facts.exported || facts.homekitRecording === false) return null;
|
|
90974
91122
|
const catalog = await deps.readCatalog(deviceId);
|
|
90975
91123
|
return {
|
|
90976
91124
|
availability: availabilityFor(facts, catalog),
|
|
@@ -106325,6 +106473,72 @@ function buildRecordingOptions(input) {
|
|
|
106325
106473
|
};
|
|
106326
106474
|
}
|
|
106327
106475
|
//#endregion
|
|
106476
|
+
//#region src/hksv/recording-invocation.ts
|
|
106477
|
+
/**
|
|
106478
|
+
* The resolution the accessory may advertise, which is the one it can deliver.
|
|
106479
|
+
*
|
|
106480
|
+
* A copy is advertised at its source size whatever that is — there is no
|
|
106481
|
+
* encoder to resize with, and advertising a size we cannot produce is the
|
|
106482
|
+
* "stream that does not match its own advertisement" defect in miniature.
|
|
106483
|
+
* A transcode above the ceiling is fitted down, because with an encoder in the
|
|
106484
|
+
* pipeline the 6.35 MB 4K fragment is a choice rather than a fact, and iOS
|
|
106485
|
+
* renders HKSV clips at 1080p regardless.
|
|
106486
|
+
*/
|
|
106487
|
+
function recordingOutput(source) {
|
|
106488
|
+
if (!source.transcode || source.height <= 1080) return {
|
|
106489
|
+
width: source.width,
|
|
106490
|
+
height: source.height,
|
|
106491
|
+
scaled: false
|
|
106492
|
+
};
|
|
106493
|
+
return {
|
|
106494
|
+
width: Math.max(2, Math.round(source.width * MAX_RECORDING_HEIGHT / source.height / 2) * 2),
|
|
106495
|
+
height: MAX_RECORDING_HEIGHT,
|
|
106496
|
+
scaled: true
|
|
106497
|
+
};
|
|
106498
|
+
}
|
|
106499
|
+
/** The invocation, minus the sink and sidecar the child owns. */
|
|
106500
|
+
function buildRecordingInvocation(input) {
|
|
106501
|
+
const { source } = input;
|
|
106502
|
+
const audio = input.audioActive ? {
|
|
106503
|
+
kind: "encode",
|
|
106504
|
+
codec: "aac",
|
|
106505
|
+
bitrateKbps: 32,
|
|
106506
|
+
sampleRateHz: HKSV_AUDIO_SAMPLE_RATE_HZ,
|
|
106507
|
+
channels: 1
|
|
106508
|
+
} : { kind: "none" };
|
|
106509
|
+
const out = recordingOutput(source);
|
|
106510
|
+
const video = source.transcode ? {
|
|
106511
|
+
kind: "encode",
|
|
106512
|
+
encoder: "libx264",
|
|
106513
|
+
preset: "veryfast",
|
|
106514
|
+
profile: "main",
|
|
106515
|
+
level: "4.0",
|
|
106516
|
+
pixelFormat: "yuv420p",
|
|
106517
|
+
forceKeyFramesSeconds: Math.max(1, Math.round(input.fragmentMs / 1e3)),
|
|
106518
|
+
bf: 0,
|
|
106519
|
+
...out.scaled ? { scale: {
|
|
106520
|
+
mode: "fit",
|
|
106521
|
+
width: out.width,
|
|
106522
|
+
height: out.height
|
|
106523
|
+
} } : {}
|
|
106524
|
+
} : { kind: "copy" };
|
|
106525
|
+
return {
|
|
106526
|
+
logLevel: "error",
|
|
106527
|
+
decodeHwAccel: source.transcode ? input.decodeHwAccel : null,
|
|
106528
|
+
input: {
|
|
106529
|
+
url: input.audioActive ? source.url : source.mutedUrl,
|
|
106530
|
+
rtspTransport: "tcp",
|
|
106531
|
+
...source.transcode && input.decodeInputArgs !== void 0 && input.decodeInputArgs.length > 0 ? { extraArgs: input.decodeInputArgs } : {},
|
|
106532
|
+
analyzeDurationUs: 1e6,
|
|
106533
|
+
probeSizeBytes: 1e6
|
|
106534
|
+
},
|
|
106535
|
+
video,
|
|
106536
|
+
audio,
|
|
106537
|
+
threadCount: 0,
|
|
106538
|
+
outputArgs: []
|
|
106539
|
+
};
|
|
106540
|
+
}
|
|
106541
|
+
//#endregion
|
|
106328
106542
|
//#region src/hksv/fragment-source.ts
|
|
106329
106543
|
/**
|
|
106330
106544
|
* How far back the prebuffer ring reaches.
|
|
@@ -106492,6 +106706,7 @@ var HksvFragmentSource = class {
|
|
|
106492
106706
|
meta: {
|
|
106493
106707
|
brokerId: this.input.source.brokerId,
|
|
106494
106708
|
resolution: `${this.input.source.width}x${this.input.source.height}`,
|
|
106709
|
+
branch: this.input.source.transcode ? "transcode" : "copy",
|
|
106495
106710
|
fragmentMs: this.input.fragmentMs,
|
|
106496
106711
|
audioActive: this.audioActive,
|
|
106497
106712
|
windowMs: PREBUFFER_WINDOW_MS
|
|
@@ -106556,27 +106771,13 @@ var HksvFragmentSource = class {
|
|
|
106556
106771
|
* takes AAC only — which the same measurement priced at 0.4 % of a core.
|
|
106557
106772
|
*/
|
|
106558
106773
|
buildInvocation() {
|
|
106559
|
-
|
|
106560
|
-
|
|
106561
|
-
|
|
106562
|
-
|
|
106563
|
-
|
|
106564
|
-
|
|
106565
|
-
}
|
|
106566
|
-
return {
|
|
106567
|
-
logLevel: "error",
|
|
106568
|
-
decodeHwAccel: null,
|
|
106569
|
-
input: {
|
|
106570
|
-
url: this.audioActive ? this.input.source.url : this.input.source.mutedUrl,
|
|
106571
|
-
rtspTransport: "tcp",
|
|
106572
|
-
analyzeDurationUs: 1e6,
|
|
106573
|
-
probeSizeBytes: 1e6
|
|
106574
|
-
},
|
|
106575
|
-
video: { kind: "copy" },
|
|
106576
|
-
audio,
|
|
106577
|
-
threadCount: 0,
|
|
106578
|
-
outputArgs: []
|
|
106579
|
-
};
|
|
106774
|
+
return buildRecordingInvocation({
|
|
106775
|
+
source: this.input.source,
|
|
106776
|
+
audioActive: this.audioActive,
|
|
106777
|
+
fragmentMs: this.input.fragmentMs,
|
|
106778
|
+
decodeHwAccel: this.input.decodeHwAccel ?? null,
|
|
106779
|
+
...this.input.decodeInputArgs === void 0 ? {} : { decodeInputArgs: this.input.decodeInputArgs }
|
|
106780
|
+
});
|
|
106580
106781
|
}
|
|
106581
106782
|
};
|
|
106582
106783
|
//#endregion
|
|
@@ -106936,74 +107137,6 @@ var HksvRecordingDelegate = class {
|
|
|
106936
107137
|
}
|
|
106937
107138
|
};
|
|
106938
107139
|
//#endregion
|
|
106939
|
-
//#region src/hksv/recording-source.ts
|
|
106940
|
-
/** The tallest frame the recording path will hold in its prebuffer. */
|
|
106941
|
-
var MAX_RECORDING_HEIGHT = 1080;
|
|
106942
|
-
/** HKSV takes H.264 only — AAC audio and H.264 video, no negotiation. */
|
|
106943
|
-
var RECORDABLE_CODEC = "h264";
|
|
106944
|
-
/**
|
|
106945
|
-
* Pick the slot the recording child pulls.
|
|
106946
|
-
*
|
|
106947
|
-
* Deliberately NOT `pickPreferredRtspEntry`: that picker resolves the operator's
|
|
106948
|
-
* LIVE preference and, on `auto`, steers by the resolution iOS negotiated for a
|
|
106949
|
-
* live session — neither is a fact about recording, and on 615 it selects `mid`,
|
|
106950
|
-
* a 10 fps slot. Recording has one criterion, applied here and nowhere else:
|
|
106951
|
-
* the largest copyable frame that does not exceed {@link MAX_RECORDING_HEIGHT}.
|
|
106952
|
-
*/
|
|
106953
|
-
function pickRecordingSource(entries) {
|
|
106954
|
-
const enabled = entries.filter((e) => e.enabled);
|
|
106955
|
-
if (enabled.length === 0) return {
|
|
106956
|
-
ok: false,
|
|
106957
|
-
refusal: "no-enabled-stream"
|
|
106958
|
-
};
|
|
106959
|
-
const h264 = enabled.filter((e) => normaliseCodec(e.codec) === RECORDABLE_CODEC);
|
|
106960
|
-
if (h264.length === 0) return {
|
|
106961
|
-
ok: false,
|
|
106962
|
-
refusal: "no-h264-stream"
|
|
106963
|
-
};
|
|
106964
|
-
const sized = h264.filter(hasUsableResolution);
|
|
106965
|
-
if (sized.length === 0) return {
|
|
106966
|
-
ok: false,
|
|
106967
|
-
refusal: "no-resolution"
|
|
106968
|
-
};
|
|
106969
|
-
const withinCeiling = sized.filter((e) => height(e) <= MAX_RECORDING_HEIGHT);
|
|
106970
|
-
const best = [...withinCeiling.length > 0 ? withinCeiling : sized].sort((a, b) => withinCeiling.length > 0 ? height(b) - height(a) : height(a) - height(b))[0];
|
|
106971
|
-
if (best === void 0 || best.resolution === void 0) return {
|
|
106972
|
-
ok: false,
|
|
106973
|
-
refusal: "no-resolution"
|
|
106974
|
-
};
|
|
106975
|
-
return {
|
|
106976
|
-
ok: true,
|
|
106977
|
-
source: {
|
|
106978
|
-
brokerId: best.brokerId,
|
|
106979
|
-
profile: best.profile ?? best.brokerId,
|
|
106980
|
-
url: best.url,
|
|
106981
|
-
mutedUrl: best.mutedUrl,
|
|
106982
|
-
width: best.resolution.width,
|
|
106983
|
-
height: best.resolution.height
|
|
106984
|
-
}
|
|
106985
|
-
};
|
|
106986
|
-
}
|
|
106987
|
-
/** A one-line reason for the log — silence about a withdrawn service reads as a bug. */
|
|
106988
|
-
function refusalReason(refusal) {
|
|
106989
|
-
switch (refusal) {
|
|
106990
|
-
case "no-enabled-stream": return "the camera has no enabled RTSP profile";
|
|
106991
|
-
case "no-h264-stream": return "every enabled profile is H.265 — HKSV takes H.264 only, and a permanent transcode costs 128x a copy";
|
|
106992
|
-
case "no-resolution": return "no enabled profile declares a resolution, so nothing honest could be advertised";
|
|
106993
|
-
}
|
|
106994
|
-
}
|
|
106995
|
-
function height(entry) {
|
|
106996
|
-
return entry.resolution?.height ?? 0;
|
|
106997
|
-
}
|
|
106998
|
-
function hasUsableResolution(entry) {
|
|
106999
|
-
const r = entry.resolution;
|
|
107000
|
-
return r !== void 0 && r.width > 0 && r.height > 0;
|
|
107001
|
-
}
|
|
107002
|
-
/** Publishers spell H.265 four ways; the same normalisation the broker uses. */
|
|
107003
|
-
function normaliseCodec(codec) {
|
|
107004
|
-
return (codec ?? "").toLowerCase().replace(/[.\s-]/g, "");
|
|
107005
|
-
}
|
|
107006
|
-
//#endregion
|
|
107007
107140
|
//#region src/hksv/build-recording.ts
|
|
107008
107141
|
/**
|
|
107009
107142
|
* Assemble HomeKit Secure Video for one camera — the ADVERTISEMENT and the
|
|
@@ -107055,7 +107188,7 @@ async function buildHksvRecording(input) {
|
|
|
107055
107188
|
note("profiles-unreadable", "cameraStreams.getProfileRtspEntries could not be read");
|
|
107056
107189
|
return null;
|
|
107057
107190
|
}
|
|
107058
|
-
const choice = pickRecordingSource(entries);
|
|
107191
|
+
const choice = pickRecordingSource(entries, { allowTranscode: bctx.options.hapDeviceSettings?.allowTranscode !== false });
|
|
107059
107192
|
if (!choice.ok) {
|
|
107060
107193
|
log.info("export-hap: HKSV withheld — no recordable stream", { meta: {
|
|
107061
107194
|
refusal: choice.refusal,
|
|
@@ -107065,9 +107198,10 @@ async function buildHksvRecording(input) {
|
|
|
107065
107198
|
return null;
|
|
107066
107199
|
}
|
|
107067
107200
|
const source = choice.source;
|
|
107201
|
+
const decodeHwAccel = source.transcode ? await resolveDecodeHwAccel(bctx, log) : null;
|
|
107068
107202
|
const gopMs = await readSourceGopMs(bctx, source.width, source.height);
|
|
107069
|
-
const fragmentLengthMs = deriveFragmentLengthMs(gopMs);
|
|
107070
|
-
if (fragmentLengthMs === null) {
|
|
107203
|
+
const fragmentLengthMs = source.transcode ? HKSV_FRAGMENT_LENGTHS_MS[0] : deriveFragmentLengthMs(gopMs);
|
|
107204
|
+
if (fragmentLengthMs === void 0 || fragmentLengthMs === null) {
|
|
107071
107205
|
log.warn("export-hap: HKSV withheld — the camera key-frame interval is longer than any fragment length we advertise", { meta: {
|
|
107072
107206
|
gopMs,
|
|
107073
107207
|
brokerId: source.brokerId
|
|
@@ -107078,9 +107212,10 @@ async function buildHksvRecording(input) {
|
|
|
107078
107212
|
const parsedProfile = CamProfileSchema.safeParse(source.profile);
|
|
107079
107213
|
const recordedProfile = parsedProfile.success ? parsedProfile.data : null;
|
|
107080
107214
|
const fps = resolveFps(input.fpsByProfile, source.profile);
|
|
107215
|
+
const out = recordingOutput(source);
|
|
107081
107216
|
const options = buildRecordingOptions({
|
|
107082
|
-
width:
|
|
107083
|
-
height:
|
|
107217
|
+
width: out.width,
|
|
107218
|
+
height: out.height,
|
|
107084
107219
|
fps,
|
|
107085
107220
|
fragmentLengthMs
|
|
107086
107221
|
});
|
|
@@ -107090,8 +107225,8 @@ async function buildHksvRecording(input) {
|
|
|
107090
107225
|
logger: log,
|
|
107091
107226
|
deviceId: numericDeviceId,
|
|
107092
107227
|
store: clipStore,
|
|
107093
|
-
width:
|
|
107094
|
-
height:
|
|
107228
|
+
width: out.width,
|
|
107229
|
+
height: out.height,
|
|
107095
107230
|
fragmentMs: fragmentLengthMs,
|
|
107096
107231
|
...recordedProfile === null ? {} : { profile: recordedProfile }
|
|
107097
107232
|
});
|
|
@@ -107110,14 +107245,21 @@ async function buildHksvRecording(input) {
|
|
|
107110
107245
|
spawnFn: spawn,
|
|
107111
107246
|
source,
|
|
107112
107247
|
fragmentMs,
|
|
107113
|
-
audioActive
|
|
107248
|
+
audioActive,
|
|
107249
|
+
decodeHwAccel: decodeHwAccel?.hwaccel ?? null,
|
|
107250
|
+
...decodeHwAccel === null || decodeHwAccel.extraInputArgs.length === 0 ? {} : { decodeInputArgs: decodeHwAccel.extraInputArgs }
|
|
107114
107251
|
})
|
|
107115
107252
|
});
|
|
107116
107253
|
const advertisedResolution = options.video.resolutions[0];
|
|
107117
107254
|
log.info("export-hap: HKSV ADVERTISED — recording is offered for this camera", { meta: {
|
|
107118
107255
|
brokerId: source.brokerId,
|
|
107119
107256
|
profile: source.profile,
|
|
107120
|
-
resolution: `${
|
|
107257
|
+
resolution: `${out.width}x${out.height}`,
|
|
107258
|
+
branch: source.transcode ? "transcode" : "copy",
|
|
107259
|
+
...source.transcode ? {
|
|
107260
|
+
sourceResolution: `${source.width}x${source.height}`,
|
|
107261
|
+
decodeHwAccel: decodeHwAccel?.hwaccel ?? "software"
|
|
107262
|
+
} : {},
|
|
107121
107263
|
measuredFps: fps,
|
|
107122
107264
|
advertisedFps: advertisedResolution?.[2] ?? null,
|
|
107123
107265
|
fragmentLengthMs,
|
|
@@ -107132,6 +107274,27 @@ async function buildHksvRecording(input) {
|
|
|
107132
107274
|
dispose: () => delegate.dispose()
|
|
107133
107275
|
};
|
|
107134
107276
|
}
|
|
107277
|
+
/**
|
|
107278
|
+
* The `-hwaccel` value for a transcoding prebuffer, and the flags that travel
|
|
107279
|
+
* with it.
|
|
107280
|
+
*
|
|
107281
|
+
* Software is a legitimate answer and is always NAMED: a camera that quietly
|
|
107282
|
+
* stopped using the GPU and one that never had it look identical in a CPU
|
|
107283
|
+
* graph, and on this branch the difference is most of a core, permanently.
|
|
107284
|
+
*/
|
|
107285
|
+
async function resolveDecodeHwAccel(bctx, log) {
|
|
107286
|
+
const decision = selectHwDecode({
|
|
107287
|
+
transcode: true,
|
|
107288
|
+
reading: await probeDecoderHwaccel({
|
|
107289
|
+
source: decoderInfoSourceFromContext(bctx.ctx),
|
|
107290
|
+
log,
|
|
107291
|
+
memo: bctx.options.decodeMemos.reading
|
|
107292
|
+
}),
|
|
107293
|
+
platform: process.platform
|
|
107294
|
+
});
|
|
107295
|
+
if (decision.kind === "software") log.warn("export-hap: HKSV will transcode in SOFTWARE — this costs most of a core, permanently", { meta: { reason: decision.reason } });
|
|
107296
|
+
return decision;
|
|
107297
|
+
}
|
|
107135
107298
|
/** `cameraStreams.getProfileRtspEntries`, or `null` when the cap is unreachable. */
|
|
107136
107299
|
async function readProfileEntries(bctx) {
|
|
107137
107300
|
try {
|
|
@@ -108127,13 +108290,12 @@ async function buildCameraAccessory(input) {
|
|
|
108127
108290
|
bctx,
|
|
108128
108291
|
streamingOptions: streams.streamingOptions
|
|
108129
108292
|
}));
|
|
108130
|
-
const recordingEnabled = options.hapDeviceSettings.hksvRecording === true;
|
|
108131
108293
|
let recordingAudioActive = true;
|
|
108132
|
-
const recording =
|
|
108294
|
+
const recording = await buildHksvRecording({
|
|
108133
108295
|
bctx,
|
|
108134
108296
|
fpsByProfile: advertisedVideo.fpsByProfile,
|
|
108135
108297
|
isAudioActive: () => recordingAudioActive
|
|
108136
|
-
})
|
|
108298
|
+
});
|
|
108137
108299
|
const controller = new (isDoorbell ? import_dist.DoorbellController : import_dist.CameraController)({
|
|
108138
108300
|
delegate: streams.delegate,
|
|
108139
108301
|
streamingOptions: streams.streamingOptions,
|
|
@@ -108382,38 +108544,7 @@ function syncStateToJson(map) {
|
|
|
108382
108544
|
* that matters about it: it must resolve to NOT recording, or "off by default"
|
|
108383
108545
|
* is true only for cameras exposed before the flip.
|
|
108384
108546
|
*/
|
|
108385
|
-
var DEFAULT_DEVICE_SETTINGS = {
|
|
108386
|
-
streamPreference: "auto",
|
|
108387
|
-
hksvRecording: false
|
|
108388
|
-
};
|
|
108389
|
-
/**
|
|
108390
|
-
* OFF unless explicitly switched on — operator decision 2026-09-21, reversing
|
|
108391
|
-
* the 2026-08-08 flip:
|
|
108392
|
-
*
|
|
108393
|
-
* > *"il recording deve essere off di default, non deve essere attivo quando
|
|
108394
|
-
* > si esporta una camera su HKSV"*
|
|
108395
|
-
*
|
|
108396
|
-
* What changed is not the cost the earlier record argued about — recording on
|
|
108397
|
-
* the copy branch really is 0.7 % of a core and ~30 MB (D84), and cheap it
|
|
108398
|
-
* remains. What changed is that on-by-default made the switch **say something
|
|
108399
|
-
* the system was not doing**. Measured on 2026-09-21: 592, 587, 618 and 640
|
|
108400
|
-
* all reported HomeKit recording ON while the advertisement was WITHHELD
|
|
108401
|
-
* (`no-enabled-stream` — no enabled RTSP profile), so HomeKit never recorded
|
|
108402
|
-
* them and the tee had nothing to keep. The operator was being told four
|
|
108403
|
-
* cameras were recording because nobody had turned them off.
|
|
108404
|
-
*
|
|
108405
|
-
* Exposing a camera to HomeKit is a decision about EXPOSURE. Recording is a
|
|
108406
|
-
* second decision and now has to be made.
|
|
108407
|
-
*
|
|
108408
|
-
* ABSENT resolves OFF — the load-bearing case, for the same reason it was
|
|
108409
|
-
* before and with the opposite answer: a camera whose settings predate the
|
|
108410
|
-
* field, or that was exposed before today, was never asked about. Every read
|
|
108411
|
-
* goes through this one resolver (`=== true`), never a scattered `!== false`,
|
|
108412
|
-
* because those two disagree about exactly that case.
|
|
108413
|
-
*/
|
|
108414
|
-
function resolveHksvRecording(settings) {
|
|
108415
|
-
return settings?.hksvRecording === true;
|
|
108416
|
-
}
|
|
108547
|
+
var DEFAULT_DEVICE_SETTINGS = { streamPreference: "auto" };
|
|
108417
108548
|
/**
|
|
108418
108549
|
* Whether this camera offers Apple's NEW camera service alongside the classic
|
|
108419
108550
|
* one — `camera-multi-tier-rtp-stream-management` (0x8031), where HEVC and 4K
|
|
@@ -108456,11 +108587,11 @@ var EXPORT_HAP_ADDON_ID = "export-hap";
|
|
|
108456
108587
|
* stays and says the copies are off, which is D62's other half: an off switch
|
|
108457
108588
|
* is REPORTED off, never made to look like a broken camera.
|
|
108458
108589
|
*/
|
|
108459
|
-
function describeHksvCamera(entry, lastBuild) {
|
|
108590
|
+
function describeHksvCamera(entry, lastBuild, homekitRecording) {
|
|
108460
108591
|
const isCamera = entry !== null && entry.mapperKind === "camera";
|
|
108461
108592
|
return {
|
|
108462
108593
|
exported: isCamera,
|
|
108463
|
-
|
|
108594
|
+
homekitRecording: isCamera ? homekitRecording : null,
|
|
108464
108595
|
keepingClips: isCamera && resolveKeepHomekitClips(entry.settings),
|
|
108465
108596
|
lastBuild
|
|
108466
108597
|
};
|
|
@@ -108704,7 +108835,7 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
108704
108835
|
const clipsProvider = createHksvVideoclipsProvider({
|
|
108705
108836
|
logger: this.ctx.logger.child("hksv-clips"),
|
|
108706
108837
|
addonId: EXPORT_HAP_ADDON_ID,
|
|
108707
|
-
describeCamera: (deviceId) => describeHksvCamera(this.findEntry(deviceId), this.hksvBuilds.lastFor(deviceId)),
|
|
108838
|
+
describeCamera: (deviceId) => describeHksvCamera(this.findEntry(deviceId), this.hksvBuilds.lastFor(deviceId), readHomekitRecordingActive(this.exposed.get(String(deviceId))?.accessories ?? [])),
|
|
108708
108839
|
readCatalog: async (deviceId) => await this.hksvClipStore?.listCatalog(deviceId) ?? { kind: "no-root" },
|
|
108709
108840
|
locationRefusal: () => this.hksvClipLocation?.lastRefusal ?? null,
|
|
108710
108841
|
mediaUrl: (deviceId, stem) => this.hksvClipUrl("media", deviceId, stem),
|
|
@@ -108860,7 +108991,6 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
108860
108991
|
},
|
|
108861
108992
|
hapDeviceSettings: {
|
|
108862
108993
|
streamPreference: entrySettings.streamPreference ?? "auto",
|
|
108863
|
-
hksvRecording: resolveHksvRecording(entrySettings),
|
|
108864
108994
|
keepHomekitClips: resolveKeepHomekitClips(entrySettings),
|
|
108865
108995
|
multiTierService: resolveMultiTierService(entrySettings),
|
|
108866
108996
|
allowTranscode: entrySettings.allowTranscode !== false
|
|
@@ -108941,15 +109071,40 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
108941
109071
|
features: device.features
|
|
108942
109072
|
});
|
|
108943
109073
|
const persisted = syncStateFromJson(this.config.exportSyncState).get(deviceId)?.fingerprint;
|
|
109074
|
+
const withToken = withRecordabilityToken({
|
|
109075
|
+
base: fresh,
|
|
109076
|
+
token: await this.readRecordabilityToken(deviceId, device.type),
|
|
109077
|
+
persisted
|
|
109078
|
+
});
|
|
108944
109079
|
return resolveExportFingerprint({
|
|
108945
109080
|
ready: device.probed ?? false,
|
|
108946
|
-
fresh,
|
|
109081
|
+
fresh: withToken,
|
|
108947
109082
|
persisted
|
|
108948
109083
|
});
|
|
108949
109084
|
} catch {
|
|
108950
109085
|
return null;
|
|
108951
109086
|
}
|
|
108952
109087
|
}
|
|
109088
|
+
/**
|
|
109089
|
+
* The slot HKSV would record this camera from, for the fingerprint.
|
|
109090
|
+
*
|
|
109091
|
+
* `null` — do not move the fingerprint — for anything that is not a camera
|
|
109092
|
+
* (a switch has no recording decision, and stamping a constant on it would
|
|
109093
|
+
* republish every non-camera accessory once for nothing) and for a camera
|
|
109094
|
+
* whose profiles could not be read right now.
|
|
109095
|
+
*/
|
|
109096
|
+
async readRecordabilityToken(deviceId, type) {
|
|
109097
|
+
if (pickMapperKind(type) !== "camera") return null;
|
|
109098
|
+
try {
|
|
109099
|
+
const entries = await (await this.ctx.fetchDevice(deviceId)).cameraStreams?.getProfileRtspEntries({});
|
|
109100
|
+
return recordabilityTokenFor({
|
|
109101
|
+
allowTranscode: this.findEntry(deviceId)?.settings?.allowTranscode !== false,
|
|
109102
|
+
entries: entries ?? null
|
|
109103
|
+
});
|
|
109104
|
+
} catch {
|
|
109105
|
+
return null;
|
|
109106
|
+
}
|
|
109107
|
+
}
|
|
108953
109108
|
/** Persist a device's current export fingerprint into `exportSyncState`
|
|
108954
109109
|
* (best-effort; called after a successful attach). */
|
|
108955
109110
|
async persistFingerprint(deviceId, fingerprint) {
|
|
@@ -109215,7 +109370,6 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
109215
109370
|
const enabled = entry !== null;
|
|
109216
109371
|
const enabledKey = `hap:${deviceId}:enabled`;
|
|
109217
109372
|
const streamPreferenceKey = `hap:${deviceId}:streamPreference`;
|
|
109218
|
-
const hksvKey = `hap:${deviceId}:hksvRecording`;
|
|
109219
109373
|
const multiTierKey = `hap:${deviceId}:multiTierService`;
|
|
109220
109374
|
const useBridgeKey = `hap:${deviceId}:useBridge`;
|
|
109221
109375
|
const allowTranscodeKey = `hap:${deviceId}:allowTranscode`;
|
|
@@ -109266,17 +109420,14 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
109266
109420
|
immediate: true
|
|
109267
109421
|
},
|
|
109268
109422
|
{
|
|
109269
|
-
type: "
|
|
109270
|
-
key:
|
|
109423
|
+
type: "info",
|
|
109424
|
+
key: `hap:${deviceId}:hksvRecordingStatus`,
|
|
109271
109425
|
label: "HomeKit recording (Secure Video)",
|
|
109272
|
-
|
|
109273
|
-
style: "switch",
|
|
109274
|
-
value: resolveHksvRecording(settings),
|
|
109426
|
+
content: describeHomekitRecording(readHomekitRecordingActive(this.exposed.get(String(deviceId))?.accessories ?? []), this.hksvBuilds.lastFor(deviceId)),
|
|
109275
109427
|
showWhen: {
|
|
109276
109428
|
field: enabledKey,
|
|
109277
109429
|
equals: true
|
|
109278
|
-
}
|
|
109279
|
-
immediate: true
|
|
109430
|
+
}
|
|
109280
109431
|
},
|
|
109281
109432
|
{
|
|
109282
109433
|
type: "boolean",
|
|
@@ -109299,7 +109450,7 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
109299
109450
|
style: "switch",
|
|
109300
109451
|
value: resolveKeepHomekitClips(settings),
|
|
109301
109452
|
showWhen: {
|
|
109302
|
-
field:
|
|
109453
|
+
field: enabledKey,
|
|
109303
109454
|
equals: true
|
|
109304
109455
|
},
|
|
109305
109456
|
immediate: true
|
|
@@ -109399,7 +109550,6 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
109399
109550
|
const wasEnabled = this.exposed.has(deviceIdStr);
|
|
109400
109551
|
const enabledKey = `hap:${deviceId}:enabled`;
|
|
109401
109552
|
const streamPreferenceKey = `hap:${deviceId}:streamPreference`;
|
|
109402
|
-
const hksvKey = `hap:${deviceId}:hksvRecording`;
|
|
109403
109553
|
const useBridgeKey = `hap:${deviceId}:useBridge`;
|
|
109404
109554
|
const allowTranscodeKey = `hap:${deviceId}:allowTranscode`;
|
|
109405
109555
|
const multiTierKey = `hap:${deviceId}:multiTierService`;
|
|
@@ -109408,7 +109558,6 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
109408
109558
|
const enabledValue = enabledKey in patch ? Boolean(patch[enabledKey]) : wasEnabled;
|
|
109409
109559
|
const streamPreferenceRaw = streamPreferenceKey in patch ? patch[streamPreferenceKey] : current?.settings?.streamPreference;
|
|
109410
109560
|
const streamPreference = typeof streamPreferenceRaw === "string" && streamPreferenceRaw.trim().length > 0 ? streamPreferenceRaw : "auto";
|
|
109411
|
-
const hksvRecording = hksvKey in patch ? Boolean(patch[hksvKey]) : resolveHksvRecording(current?.settings);
|
|
109412
109561
|
const useBridge = useBridgeKey in patch ? Boolean(patch[useBridgeKey]) : current?.settings?.useBridge !== false;
|
|
109413
109562
|
const allowTranscode = allowTranscodeKey in patch ? Boolean(patch[allowTranscodeKey]) : current?.settings?.allowTranscode !== false;
|
|
109414
109563
|
const multiTierService = multiTierKey in patch ? Boolean(patch[multiTierKey]) : resolveMultiTierService(current?.settings);
|
|
@@ -109420,7 +109569,6 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
109420
109569
|
const nextSettings = {
|
|
109421
109570
|
...current?.settings ?? DEFAULT_DEVICE_SETTINGS,
|
|
109422
109571
|
streamPreference,
|
|
109423
|
-
hksvRecording,
|
|
109424
109572
|
useBridge,
|
|
109425
109573
|
allowTranscode,
|
|
109426
109574
|
multiTierService,
|
|
@@ -109437,7 +109585,6 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
109437
109585
|
return { success: true };
|
|
109438
109586
|
}
|
|
109439
109587
|
const currentPref = current?.settings?.streamPreference ?? "auto";
|
|
109440
|
-
const currentHksv = resolveHksvRecording(current?.settings);
|
|
109441
109588
|
const currentKeepClips = resolveKeepHomekitClips(current?.settings);
|
|
109442
109589
|
const mapperKind = current?.mapperKind ?? "generic";
|
|
109443
109590
|
const wasBridged = shouldBridge({
|
|
@@ -109449,16 +109596,12 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
109449
109596
|
useBridge
|
|
109450
109597
|
});
|
|
109451
109598
|
await this.updateEntrySettings(deviceIdStr, nextSettings);
|
|
109452
|
-
if (currentPref !== streamPreference ||
|
|
109599
|
+
if (currentPref !== streamPreference || currentKeepClips !== keepClips || wasBridged !== willBridge) {
|
|
109453
109600
|
log.info("export-hap: per-camera export settings changed — refreshing accessory", { meta: {
|
|
109454
109601
|
streamPreference: {
|
|
109455
109602
|
from: currentPref,
|
|
109456
109603
|
to: streamPreference
|
|
109457
109604
|
},
|
|
109458
|
-
hksvRecording: {
|
|
109459
|
-
from: currentHksv,
|
|
109460
|
-
to: hksvRecording
|
|
109461
|
-
},
|
|
109462
109605
|
keepClips: {
|
|
109463
109606
|
from: currentKeepClips,
|
|
109464
109607
|
to: keepClips
|
|
@@ -109544,4 +109687,4 @@ function errMsg(err) {
|
|
|
109544
109687
|
return err instanceof Error ? err.message : String(err);
|
|
109545
109688
|
}
|
|
109546
109689
|
//#endregion
|
|
109547
|
-
export { DEFAULT_CLIP_RETENTION_DAYS, DEFAULT_DEVICE_SETTINGS, EXPORT_HAP_ADDON_ID, ExportHapAddon, ExportHapAddon as default, describeHksvCamera, unpublishAccessory as i, initHapStorage as n, publishStandalone as r, resolveClipRetentionDays,
|
|
109690
|
+
export { DEFAULT_CLIP_RETENTION_DAYS, DEFAULT_DEVICE_SETTINGS, EXPORT_HAP_ADDON_ID, ExportHapAddon, ExportHapAddon as default, describeHksvCamera, unpublishAccessory as i, initHapStorage as n, publishStandalone as r, resolveClipRetentionDays, resolveKeepHomekitClips, resolveMultiTierService, deriveUsername as t };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/addon-export-hap",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.139",
|
|
4
4
|
"description": "HomeKit (HAP) exporter for CamStack devices. Publishes each exposed device as its own HomeKit accessory: cameras and doorbells with SRTP streaming, HomeKit Secure Video, motion, two-way audio, PTZ and battery; switches, lights, locks and sensors through a capability→service table.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"camstack",
|