@camstack/addon-export-hap 1.2.118 → 1.2.120
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 +1730 -109
- package/dist/hap-export.addon.mjs +1728 -108
- package/package.json +1 -1
package/dist/hap-export.addon.js
CHANGED
|
@@ -1081,7 +1081,7 @@ function partial(Class, schema, mask) {
|
|
|
1081
1081
|
checks: []
|
|
1082
1082
|
}));
|
|
1083
1083
|
}
|
|
1084
|
-
function required(Class, schema, mask) {
|
|
1084
|
+
function required$2(Class, schema, mask) {
|
|
1085
1085
|
return clone(schema, mergeDefs(schema._zod.def, { get shape() {
|
|
1086
1086
|
const oldShape = schema._zod.def.shape;
|
|
1087
1087
|
const shape = { ...oldShape };
|
|
@@ -1339,7 +1339,7 @@ var guid = /^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-
|
|
|
1339
1339
|
/** Returns a regex for validating an RFC 9562/4122 UUID.
|
|
1340
1340
|
*
|
|
1341
1341
|
* @param version Optionally specify a version 1-8. If no version is specified, all versions are supported. */
|
|
1342
|
-
var uuid$
|
|
1342
|
+
var uuid$3 = (version) => {
|
|
1343
1343
|
if (!version) return /^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$/;
|
|
1344
1344
|
return new RegExp(`^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-${version}[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$`);
|
|
1345
1345
|
};
|
|
@@ -1909,8 +1909,8 @@ var $ZodUUID = /*@__PURE__*/ $constructor("$ZodUUID", (inst, def) => {
|
|
|
1909
1909
|
v8: 8
|
|
1910
1910
|
}[def.version];
|
|
1911
1911
|
if (v === void 0) throw new Error(`Invalid UUID version: "${def.version}"`);
|
|
1912
|
-
def.pattern ?? (def.pattern = uuid$
|
|
1913
|
-
} else def.pattern ?? (def.pattern = uuid$
|
|
1912
|
+
def.pattern ?? (def.pattern = uuid$3(v));
|
|
1913
|
+
} else def.pattern ?? (def.pattern = uuid$3());
|
|
1914
1914
|
$ZodStringFormat.init(inst, def);
|
|
1915
1915
|
});
|
|
1916
1916
|
var $ZodEmail = /*@__PURE__*/ $constructor("$ZodEmail", (inst, def) => {
|
|
@@ -4487,8 +4487,8 @@ var parse = /* @__PURE__ */ _parse(ZodRealError);
|
|
|
4487
4487
|
var parseAsync = /* @__PURE__ */ _parseAsync(ZodRealError);
|
|
4488
4488
|
var safeParse = /* @__PURE__ */ _safeParse(ZodRealError);
|
|
4489
4489
|
var safeParseAsync = /* @__PURE__ */ _safeParseAsync(ZodRealError);
|
|
4490
|
-
var encode = /* @__PURE__ */ _encode(ZodRealError);
|
|
4491
|
-
var decode = /* @__PURE__ */ _decode(ZodRealError);
|
|
4490
|
+
var encode$5 = /* @__PURE__ */ _encode(ZodRealError);
|
|
4491
|
+
var decode$2 = /* @__PURE__ */ _decode(ZodRealError);
|
|
4492
4492
|
var encodeAsync = /* @__PURE__ */ _encodeAsync(ZodRealError);
|
|
4493
4493
|
var decodeAsync = /* @__PURE__ */ _decodeAsync(ZodRealError);
|
|
4494
4494
|
var safeEncode = /* @__PURE__ */ _safeEncode(ZodRealError);
|
|
@@ -4548,8 +4548,8 @@ var ZodType = /*@__PURE__*/ $constructor("ZodType", (inst, def) => {
|
|
|
4548
4548
|
inst.parseAsync = async (data, params) => parseAsync(inst, data, params, { callee: inst.parseAsync });
|
|
4549
4549
|
inst.safeParseAsync = async (data, params) => safeParseAsync(inst, data, params);
|
|
4550
4550
|
inst.spa = inst.safeParseAsync;
|
|
4551
|
-
inst.encode = (data, params) => encode(inst, data, params);
|
|
4552
|
-
inst.decode = (data, params) => decode(inst, data, params);
|
|
4551
|
+
inst.encode = (data, params) => encode$5(inst, data, params);
|
|
4552
|
+
inst.decode = (data, params) => decode$2(inst, data, params);
|
|
4553
4553
|
inst.encodeAsync = async (data, params) => encodeAsync(inst, data, params);
|
|
4554
4554
|
inst.decodeAsync = async (data, params) => decodeAsync(inst, data, params);
|
|
4555
4555
|
inst.safeEncode = (data, params) => safeEncode(inst, data, params);
|
|
@@ -5055,7 +5055,7 @@ var ZodObject = /*@__PURE__*/ $constructor("ZodObject", (inst, def) => {
|
|
|
5055
5055
|
return partial(ZodOptional, this, args[0]);
|
|
5056
5056
|
},
|
|
5057
5057
|
required(...args) {
|
|
5058
|
-
return required(ZodNonOptional, this, args[0]);
|
|
5058
|
+
return required$2(ZodNonOptional, this, args[0]);
|
|
5059
5059
|
}
|
|
5060
5060
|
});
|
|
5061
5061
|
});
|
|
@@ -13040,7 +13040,23 @@ var PickStreamRequirementsSchema = object({
|
|
|
13040
13040
|
* This is what lets one picker serve both the bypass and the full source
|
|
13041
13041
|
* choice, instead of a consumer scoring privately when the bypass misses.
|
|
13042
13042
|
*/
|
|
13043
|
-
allowTranscode: boolean().optional()
|
|
13043
|
+
allowTranscode: boolean().optional(),
|
|
13044
|
+
/**
|
|
13045
|
+
* Consider ONLY streams the broker has bound to a profile slot.
|
|
13046
|
+
*
|
|
13047
|
+
* A consumer that dials the broker — which is every exporter, since none
|
|
13048
|
+
* of them may open the camera's own URL — can do nothing with a source
|
|
13049
|
+
* that has no slot: there is no restream to dial. Without this the picker
|
|
13050
|
+
* can name such a stream, and each consumer discovers the dead end for
|
|
13051
|
+
* itself: Alexa's `profileForCamStream` returns `null` and it quietly
|
|
13052
|
+
* falls back, and HomeKit would have had to grow the same function again.
|
|
13053
|
+
*
|
|
13054
|
+
* Default `false`, and deliberately so: the BROKER itself picks through
|
|
13055
|
+
* here when it decides which source to BIND to a slot
|
|
13056
|
+
* (`computeInitialAssignment`), and it must see the unbound ones or it
|
|
13057
|
+
* could never bind them.
|
|
13058
|
+
*/
|
|
13059
|
+
requireProfileBound: boolean().optional()
|
|
13044
13060
|
}).readonly();
|
|
13045
13061
|
var PickStreamPreferencesSchema = object({
|
|
13046
13062
|
/**
|
|
@@ -13081,7 +13097,43 @@ var PickedCamStreamSchema = object({
|
|
|
13081
13097
|
*/
|
|
13082
13098
|
transcodes: boolean(),
|
|
13083
13099
|
/** One-line explanation of why this stream won — for logs / debug UI. */
|
|
13084
|
-
reason: string()
|
|
13100
|
+
reason: string(),
|
|
13101
|
+
/**
|
|
13102
|
+
* The broker profile slot this source is bound to, when it is bound.
|
|
13103
|
+
*
|
|
13104
|
+
* Answered HERE because every consumer needs it and none of them can get it
|
|
13105
|
+
* from the `camStreamId` alone. Alexa wrote `profileForCamStream` for it —
|
|
13106
|
+
* a second cap read plus a `null` branch — and HomeKit was about to write
|
|
13107
|
+
* the same function a second time.
|
|
13108
|
+
*
|
|
13109
|
+
* Absent when the source has no slot, which a caller passing
|
|
13110
|
+
* `requireProfileBound` can never see.
|
|
13111
|
+
*/
|
|
13112
|
+
profile: CamProfileSchema.optional(),
|
|
13113
|
+
/** The broker id of that slot, for logs that must name what was dialled. */
|
|
13114
|
+
brokerId: string().optional(),
|
|
13115
|
+
/**
|
|
13116
|
+
* The broker restream URL to dial for this stream.
|
|
13117
|
+
*
|
|
13118
|
+
* An exporter must never open the camera's own URL — the broker is the one
|
|
13119
|
+
* that dials the camera, and every consumer goes through its restream. So
|
|
13120
|
+
* the picker answers with the thing a consumer may actually use, instead of
|
|
13121
|
+
* naming a source and leaving each caller to look the URL up.
|
|
13122
|
+
*/
|
|
13123
|
+
url: string().optional(),
|
|
13124
|
+
/**
|
|
13125
|
+
* Frames per second this stream really delivers, and where the number came
|
|
13126
|
+
* from.
|
|
13127
|
+
*
|
|
13128
|
+
* It is here because getting it wrong is expensive and silent: a HomeKit
|
|
13129
|
+
* accessory advertised 720p at 24 fps while the slot serving it ran at 9,
|
|
13130
|
+
* and the controller rendered nothing at all. The source is carried with the
|
|
13131
|
+
* value for the same reason the bitrate evidence is: `published` is what the
|
|
13132
|
+
* publisher declares and `measured` is what the broker observed, and they
|
|
13133
|
+
* are not interchangeable.
|
|
13134
|
+
*/
|
|
13135
|
+
fps: number().positive().optional(),
|
|
13136
|
+
fpsSource: _enum(["published", "measured"]).optional()
|
|
13085
13137
|
});
|
|
13086
13138
|
DeviceType.Camera, method(object({ deviceId: number().int().nonnegative() }), array(CameraStreamSchema).readonly()), method(object({ deviceId: number().int().nonnegative() }), array(ProfileSlotSchema).readonly()), method(object({
|
|
13087
13139
|
deviceId: number().int().nonnegative(),
|
|
@@ -14136,6 +14188,70 @@ var LocationIconAssignmentSchema = object({
|
|
|
14136
14188
|
locationKey: string(),
|
|
14137
14189
|
icon: LocationIconIdSchema
|
|
14138
14190
|
});
|
|
14191
|
+
/**
|
|
14192
|
+
* One (location, description) assignment as it travels.
|
|
14193
|
+
*
|
|
14194
|
+
* Same key discipline as {@link LocationIconAssignmentSchema}, and for the same
|
|
14195
|
+
* reason: this lived for a while in the post-analysis addon's own settings,
|
|
14196
|
+
* keyed by a HAND-TYPED label matched exactly and case-sensitively. A rename in
|
|
14197
|
+
* the Locations page carried the icon and silently orphaned the description —
|
|
14198
|
+
* two authorities for one thing, joined by two different rules. Now both hang
|
|
14199
|
+
* off the location itself, on `locationIconKey`.
|
|
14200
|
+
*/
|
|
14201
|
+
var LocationDescriptionAssignmentSchema = object({
|
|
14202
|
+
locationKey: string(),
|
|
14203
|
+
description: string()
|
|
14204
|
+
});
|
|
14205
|
+
/**
|
|
14206
|
+
* A SITE ZONE — a named part of the property that several locations belong to.
|
|
14207
|
+
*
|
|
14208
|
+
* "Zona notte" holds every bedroom; "Zona giorno" the living room and the
|
|
14209
|
+
* kitchen. A camera belongs to exactly one location, and a location to at most
|
|
14210
|
+
* one zone, so a camera's zone is derived and never stored on the device: the
|
|
14211
|
+
* whole point of this entity is that grouping rooms touches no device row.
|
|
14212
|
+
*
|
|
14213
|
+
* ## Why not `zone`
|
|
14214
|
+
*
|
|
14215
|
+
* Because `zone` is already the most loaded word in this product: 612 source
|
|
14216
|
+
* files and 24 Italian strings where it means a DETECTION zone drawn on a
|
|
14217
|
+
* camera. The two are unrelated — one is a polygon in a frame, the other is a
|
|
14218
|
+
* set of rooms — and a reader who confuses them will look for a camera's
|
|
14219
|
+
* bedrooms. So the code says `siteZone` everywhere, and the UI frees the plain
|
|
14220
|
+
* word by calling the drawn one what it actually is: a detection zone.
|
|
14221
|
+
*
|
|
14222
|
+
* ## The key
|
|
14223
|
+
*
|
|
14224
|
+
* `siteZoneKey(label)` — trimmed, lower-cased, the SAME derivation locations
|
|
14225
|
+
* use (`locationIconKey`). Membership and the icon both hang off it, so a
|
|
14226
|
+
* case-only rename keeps them, which is exactly the orphaning that cost us a
|
|
14227
|
+
* silently-dropped location description.
|
|
14228
|
+
*
|
|
14229
|
+
* The icon vocabulary is the location one, unchanged: a zone is drawn on the
|
|
14230
|
+
* same two surfaces by the same glyph map, so a second vocabulary would be a
|
|
14231
|
+
* second thing to keep drawable (`scripts/check-location-icons-drawable.ts`).
|
|
14232
|
+
*/
|
|
14233
|
+
/**
|
|
14234
|
+
* One zone as it travels: its key, the label the operator typed, its icon and
|
|
14235
|
+
* the locations inside it.
|
|
14236
|
+
*
|
|
14237
|
+
* The LABEL is carried alongside the key because the key is lossy on purpose —
|
|
14238
|
+
* a surface must show "Zona notte", not "zona notte". Locations are carried
|
|
14239
|
+
* here, rather than left to a second call, because every consumer wants both
|
|
14240
|
+
* at once and a viewer that asked twice could render a zone whose membership
|
|
14241
|
+
* it has not fetched yet.
|
|
14242
|
+
*
|
|
14243
|
+
* `icon: null` means nobody chose one, which is NOT the fallback pin: only one
|
|
14244
|
+
* of those two is a choice, and a picker has to draw them differently.
|
|
14245
|
+
*/
|
|
14246
|
+
var SiteZoneSchema = object({
|
|
14247
|
+
zoneKey: string(),
|
|
14248
|
+
label: string(),
|
|
14249
|
+
icon: LocationIconIdSchema.nullable(),
|
|
14250
|
+
/** Folded location keys, sorted. A location listed here need not be
|
|
14251
|
+
* REGISTERED — devices carry labels nobody registered, and those rooms are
|
|
14252
|
+
* the ones that most want grouping. */
|
|
14253
|
+
locationKeys: array(string())
|
|
14254
|
+
});
|
|
14139
14255
|
var ProviderStatusSchema = object({
|
|
14140
14256
|
connected: boolean(),
|
|
14141
14257
|
deviceCount: number(),
|
|
@@ -14653,6 +14769,33 @@ method(object({
|
|
|
14653
14769
|
}), _void(), {
|
|
14654
14770
|
kind: "mutation",
|
|
14655
14771
|
auth: "admin"
|
|
14772
|
+
}), method(_void(), array(LocationDescriptionAssignmentSchema)), method(object({
|
|
14773
|
+
location: string(),
|
|
14774
|
+
description: string().max(400).nullable()
|
|
14775
|
+
}), _void(), {
|
|
14776
|
+
kind: "mutation",
|
|
14777
|
+
auth: "admin"
|
|
14778
|
+
}), method(_void(), array(SiteZoneSchema)), method(object({
|
|
14779
|
+
name: string(),
|
|
14780
|
+
icon: LocationIconIdSchema.nullable().optional()
|
|
14781
|
+
}), _void(), {
|
|
14782
|
+
kind: "mutation",
|
|
14783
|
+
auth: "admin"
|
|
14784
|
+
}), method(object({ name: string() }), _void(), {
|
|
14785
|
+
kind: "mutation",
|
|
14786
|
+
auth: "admin"
|
|
14787
|
+
}), method(object({
|
|
14788
|
+
from: string(),
|
|
14789
|
+
to: string()
|
|
14790
|
+
}), _void(), {
|
|
14791
|
+
kind: "mutation",
|
|
14792
|
+
auth: "admin"
|
|
14793
|
+
}), method(object({
|
|
14794
|
+
location: string(),
|
|
14795
|
+
zone: string().nullable()
|
|
14796
|
+
}), _void(), {
|
|
14797
|
+
kind: "mutation",
|
|
14798
|
+
auth: "admin"
|
|
14656
14799
|
}), method(object({
|
|
14657
14800
|
deviceId: number(),
|
|
14658
14801
|
disabled: boolean()
|
|
@@ -33537,6 +33680,12 @@ Object.freeze({
|
|
|
33537
33680
|
addonId: null,
|
|
33538
33681
|
access: "view"
|
|
33539
33682
|
},
|
|
33683
|
+
"deviceManager.listLocationDescriptions": {
|
|
33684
|
+
capName: "device-manager",
|
|
33685
|
+
capScope: "system",
|
|
33686
|
+
addonId: null,
|
|
33687
|
+
access: "view"
|
|
33688
|
+
},
|
|
33540
33689
|
"deviceManager.listLocationIcons": {
|
|
33541
33690
|
capName: "device-manager",
|
|
33542
33691
|
capScope: "system",
|
|
@@ -33555,6 +33704,12 @@ Object.freeze({
|
|
|
33555
33704
|
addonId: null,
|
|
33556
33705
|
access: "view"
|
|
33557
33706
|
},
|
|
33707
|
+
"deviceManager.listSiteZones": {
|
|
33708
|
+
capName: "device-manager",
|
|
33709
|
+
capScope: "system",
|
|
33710
|
+
addonId: null,
|
|
33711
|
+
access: "view"
|
|
33712
|
+
},
|
|
33558
33713
|
"deviceManager.listWrappersForCap": {
|
|
33559
33714
|
capName: "device-manager",
|
|
33560
33715
|
capScope: "system",
|
|
@@ -33639,12 +33794,24 @@ Object.freeze({
|
|
|
33639
33794
|
addonId: null,
|
|
33640
33795
|
access: "delete"
|
|
33641
33796
|
},
|
|
33797
|
+
"deviceManager.removeSiteZone": {
|
|
33798
|
+
capName: "device-manager",
|
|
33799
|
+
capScope: "system",
|
|
33800
|
+
addonId: null,
|
|
33801
|
+
access: "delete"
|
|
33802
|
+
},
|
|
33642
33803
|
"deviceManager.renameLocation": {
|
|
33643
33804
|
capName: "device-manager",
|
|
33644
33805
|
capScope: "system",
|
|
33645
33806
|
addonId: null,
|
|
33646
33807
|
access: "create"
|
|
33647
33808
|
},
|
|
33809
|
+
"deviceManager.renameSiteZone": {
|
|
33810
|
+
capName: "device-manager",
|
|
33811
|
+
capScope: "system",
|
|
33812
|
+
addonId: null,
|
|
33813
|
+
access: "create"
|
|
33814
|
+
},
|
|
33648
33815
|
"deviceManager.runDeviceAction": {
|
|
33649
33816
|
capName: "device-manager",
|
|
33650
33817
|
capScope: "system",
|
|
@@ -33687,12 +33854,24 @@ Object.freeze({
|
|
|
33687
33854
|
addonId: null,
|
|
33688
33855
|
access: "create"
|
|
33689
33856
|
},
|
|
33857
|
+
"deviceManager.setLocationDescription": {
|
|
33858
|
+
capName: "device-manager",
|
|
33859
|
+
capScope: "system",
|
|
33860
|
+
addonId: null,
|
|
33861
|
+
access: "create"
|
|
33862
|
+
},
|
|
33690
33863
|
"deviceManager.setLocationIcon": {
|
|
33691
33864
|
capName: "device-manager",
|
|
33692
33865
|
capScope: "system",
|
|
33693
33866
|
addonId: null,
|
|
33694
33867
|
access: "create"
|
|
33695
33868
|
},
|
|
33869
|
+
"deviceManager.setLocationZone": {
|
|
33870
|
+
capName: "device-manager",
|
|
33871
|
+
capScope: "system",
|
|
33872
|
+
addonId: null,
|
|
33873
|
+
access: "create"
|
|
33874
|
+
},
|
|
33696
33875
|
"deviceManager.setMetadata": {
|
|
33697
33876
|
capName: "device-manager",
|
|
33698
33877
|
capScope: "system",
|
|
@@ -33723,6 +33902,12 @@ Object.freeze({
|
|
|
33723
33902
|
addonId: null,
|
|
33724
33903
|
access: "create"
|
|
33725
33904
|
},
|
|
33905
|
+
"deviceManager.setSiteZone": {
|
|
33906
|
+
capName: "device-manager",
|
|
33907
|
+
capScope: "system",
|
|
33908
|
+
addonId: null,
|
|
33909
|
+
access: "create"
|
|
33910
|
+
},
|
|
33726
33911
|
"deviceManager.setStreamProfileMap": {
|
|
33727
33912
|
capName: "device-manager",
|
|
33728
33913
|
capScope: "system",
|
|
@@ -49770,7 +49955,7 @@ var require_hkdf = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
|
49770
49955
|
* limitations under the License.
|
|
49771
49956
|
*/
|
|
49772
49957
|
var { Buffer: Buffer$2 } = require("buffer");
|
|
49773
|
-
var { createHash: createHash$
|
|
49958
|
+
var { createHash: createHash$2, createHmac } = require("crypto");
|
|
49774
49959
|
var g_digestLenCache = {};
|
|
49775
49960
|
/**
|
|
49776
49961
|
* Get expected hash length.
|
|
@@ -49799,7 +49984,7 @@ var require_hkdf = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
|
49799
49984
|
default: {
|
|
49800
49985
|
let len = g_digestLenCache[hash];
|
|
49801
49986
|
if (len === void 0) {
|
|
49802
|
-
len = createHash$
|
|
49987
|
+
len = createHash$2(hash).digest().length;
|
|
49803
49988
|
g_digestLenCache[hash] = len;
|
|
49804
49989
|
}
|
|
49805
49990
|
return len;
|
|
@@ -97908,6 +98093,239 @@ function slotForConnection(connection) {
|
|
|
97908
98093
|
}
|
|
97909
98094
|
}
|
|
97910
98095
|
//#endregion
|
|
98096
|
+
//#region src/multi-tier/video-stream-tiers.ts
|
|
98097
|
+
/**
|
|
98098
|
+
* The `Supported Video Stream Tiers` TLV8 — the characteristic through which an
|
|
98099
|
+
* accessory offers HEVC and 4K to HomeKit.
|
|
98100
|
+
*
|
|
98101
|
+
* ## Where this comes from
|
|
98102
|
+
*
|
|
98103
|
+
* Apple's *HomeKit Secure Video Open Source Compatibility Guide* (Developer
|
|
98104
|
+
* Preview, 2026-06-03). It defines TWO new streaming services beside the one
|
|
98105
|
+
* this addon uses today:
|
|
98106
|
+
*
|
|
98107
|
+
* - `camera-multi-tier-rtp-stream-management` (0x8031) — still RTP, which is
|
|
98108
|
+
* the plumbing we already have, and the reason this file exists at all.
|
|
98109
|
+
* - `camera-webrtc-stream-management` (0x8033) — a different transport.
|
|
98110
|
+
*
|
|
98111
|
+
* Both advertise their capability with this TLV. Recording moves separately to
|
|
98112
|
+
* CMAF ingest (0x8000 + a client-certificate provisioning flow at 0x8080), and
|
|
98113
|
+
* is NOT in scope here: the certificate dance belongs to ingest, not to live
|
|
98114
|
+
* streaming, which is what makes the live path the tractable half.
|
|
98115
|
+
*
|
|
98116
|
+
* ## Why we write this ourselves rather than patching the library
|
|
98117
|
+
*
|
|
98118
|
+
* `@homebridge/hap-nodejs@2.1.7` models none of it — its `VideoCodecType` has
|
|
98119
|
+
* one member, `H264 = 0`, and the new specification numbers H.264 as **1**. It
|
|
98120
|
+
* is not the same enumeration, so there is nothing here to extend. But nothing
|
|
98121
|
+
* needs patching either: `Service` and `Characteristic` take arbitrary UUIDs,
|
|
98122
|
+
* `Accessory.addService` validates only for duplicates and a count, and the
|
|
98123
|
+
* `Controller` interface is public. So this is an EXTENSION, not a fork —
|
|
98124
|
+
* nothing to rebase when the library moves.
|
|
98125
|
+
*
|
|
98126
|
+
* Pure bytes in, pure bytes out. No accessory, no service, no I/O: the wire
|
|
98127
|
+
* format is the part a test can hold still.
|
|
98128
|
+
*/
|
|
98129
|
+
/** Video codec, as the NEW specification numbers it. Deliberately not the
|
|
98130
|
+
* library's `VideoCodecType`, whose `H264` is `0`. */
|
|
98131
|
+
var TierVideoCodec = /* @__PURE__ */ function(TierVideoCodec) {
|
|
98132
|
+
TierVideoCodec[TierVideoCodec["H264"] = 1] = "H264";
|
|
98133
|
+
TierVideoCodec[TierVideoCodec["H265"] = 2] = "H265";
|
|
98134
|
+
return TierVideoCodec;
|
|
98135
|
+
}({});
|
|
98136
|
+
/**
|
|
98137
|
+
* Quality tier.
|
|
98138
|
+
*
|
|
98139
|
+
* `HIGHEST` is not "even better than HIGH": the guide reserves it for the 4K
|
|
98140
|
+
* stream of a camera that offers a 2K stream AT THE SAME TIME, and says a
|
|
98141
|
+
* camera that cannot do both should not use the value. Encoding it without that
|
|
98142
|
+
* capability advertises a camera we do not have.
|
|
98143
|
+
*/
|
|
98144
|
+
var TierQuality = /* @__PURE__ */ function(TierQuality) {
|
|
98145
|
+
TierQuality[TierQuality["HIGHEST"] = 1] = "HIGHEST";
|
|
98146
|
+
TierQuality[TierQuality["HIGH"] = 2] = "HIGH";
|
|
98147
|
+
TierQuality[TierQuality["MEDIUM"] = 3] = "MEDIUM";
|
|
98148
|
+
TierQuality[TierQuality["LOW"] = 4] = "LOW";
|
|
98149
|
+
return TierQuality;
|
|
98150
|
+
}({});
|
|
98151
|
+
function uint8$2(value) {
|
|
98152
|
+
const b = Buffer.alloc(1);
|
|
98153
|
+
b.writeUInt8(value, 0);
|
|
98154
|
+
return b;
|
|
98155
|
+
}
|
|
98156
|
+
function uint16$2(value) {
|
|
98157
|
+
const b = Buffer.alloc(2);
|
|
98158
|
+
b.writeUInt16LE(value, 0);
|
|
98159
|
+
return b;
|
|
98160
|
+
}
|
|
98161
|
+
function uint32$3(value) {
|
|
98162
|
+
const b = Buffer.alloc(4);
|
|
98163
|
+
b.writeUInt32LE(value, 0);
|
|
98164
|
+
return b;
|
|
98165
|
+
}
|
|
98166
|
+
/** One tier, as its own TLV8 blob. */
|
|
98167
|
+
function encodeVideoStreamTier(tier) {
|
|
98168
|
+
return (0, import_dist.encode)(1, uint32$3(tier.identifier), 2, uint8$2(tier.quality), 3, uint32$3(tier.targetAverageBitrateKbps), 4, uint16$2(tier.width), 5, uint16$2(tier.height), 6, uint8$2(tier.frameRate));
|
|
98169
|
+
}
|
|
98170
|
+
/**
|
|
98171
|
+
* The whole characteristic value.
|
|
98172
|
+
*
|
|
98173
|
+
* The tier list is REPEATED — one TLV of type 3 per tier — and repetition is
|
|
98174
|
+
* how HAP carries a list. Emitting one blob with the tiers concatenated inside
|
|
98175
|
+
* would decode as a single unreadable tier.
|
|
98176
|
+
*/
|
|
98177
|
+
function encodeVideoStreamConfiguration(config) {
|
|
98178
|
+
return (0, import_dist.encode)(1, uint8$2(config.codec), 2, uint8$2(config.payloadType), 3, config.tiers.map(encodeVideoStreamTier));
|
|
98179
|
+
}
|
|
98180
|
+
//#endregion
|
|
98181
|
+
//#region src/multi-tier/tiers-for-sensor.ts
|
|
98182
|
+
/**
|
|
98183
|
+
* Which tiers a camera is allowed to claim.
|
|
98184
|
+
*
|
|
98185
|
+
* Derived from the sensor, never assumed: a 1080p camera that advertises 4K is
|
|
98186
|
+
* telling the controller it can do something it cannot, and that surfaces as a
|
|
98187
|
+
* stream which never starts rather than as a refusal anyone can read.
|
|
98188
|
+
*
|
|
98189
|
+
* ## The table is the specification, not a suggestion
|
|
98190
|
+
*
|
|
98191
|
+
* § 2 *Minimum Requirements*: *"The accessory must support at least three
|
|
98192
|
+
* concurrent video encodings on each sensor"*, and for 16:9 it names them:
|
|
98193
|
+
*
|
|
98194
|
+
* | | 4K camera | 2K camera | 1080p camera |
|
|
98195
|
+
* | ---------- | --------- | --------- | ------------ |
|
|
98196
|
+
* | **High** | 3840x2160 | 2560x1440 | 1920x1080 |
|
|
98197
|
+
* | **Medium** | 1920x1080 | 1920x1080 | 1280x720 |
|
|
98198
|
+
* | **Low** | 640x360 | 640x360 | 640x360 |
|
|
98199
|
+
*
|
|
98200
|
+
* The first version of this file built the list by pushing 1080p and 360p onto
|
|
98201
|
+
* whatever big tier applied, which is right for 4K and 2K and produces only
|
|
98202
|
+
* TWO tiers for a 1080p camera — its medium is 720p, and nothing emitted it.
|
|
98203
|
+
* Writing the table out means the 1080p column cannot be an afterthought.
|
|
98204
|
+
*
|
|
98205
|
+
* A tier's MEDIUM resolution can exceed what the camera publishes natively —
|
|
98206
|
+
* a 4K camera whose middle stream is 720p still owes a 1080p tier. That is the
|
|
98207
|
+
* specification's demand, not ours, and it is a demand on the ENCODER rather
|
|
98208
|
+
* than on the sensor.
|
|
98209
|
+
*
|
|
98210
|
+
* `HIGHEST` is never emitted: the guide reserves it for a 4K camera that also
|
|
98211
|
+
* serves 2K SIMULTANEOUSLY, and we cannot prove we do.
|
|
98212
|
+
*/
|
|
98213
|
+
/**
|
|
98214
|
+
* Target average and MAXIMUM bitrate per resolution, § 2.
|
|
98215
|
+
*
|
|
98216
|
+
* The maxima are the reason this is a table and not a multiplier. A peak
|
|
98217
|
+
* computed as average × 1.1 reads as "the 10% gap the guide recommends", and it
|
|
98218
|
+
* overshoots three of these five rows — 1080p would claim 1870 against a
|
|
98219
|
+
* ceiling of 1800, and *"maximum bitrates should not be exceeded"*.
|
|
98220
|
+
*/
|
|
98221
|
+
var BITRATE_BY_WIDTH = new Map([
|
|
98222
|
+
[3840, {
|
|
98223
|
+
average: 4500,
|
|
98224
|
+
peak: 5e3
|
|
98225
|
+
}],
|
|
98226
|
+
[2560, {
|
|
98227
|
+
average: 2800,
|
|
98228
|
+
peak: 3e3
|
|
98229
|
+
}],
|
|
98230
|
+
[1920, {
|
|
98231
|
+
average: 1700,
|
|
98232
|
+
peak: 1800
|
|
98233
|
+
}],
|
|
98234
|
+
[1280, {
|
|
98235
|
+
average: 768,
|
|
98236
|
+
peak: 800
|
|
98237
|
+
}],
|
|
98238
|
+
[640, {
|
|
98239
|
+
average: 180,
|
|
98240
|
+
peak: 190
|
|
98241
|
+
}]
|
|
98242
|
+
]);
|
|
98243
|
+
/** The lowest row, for a width the table does not name. Claiming a small
|
|
98244
|
+
* budget for an unexpected size is the safe direction: it under-promises. */
|
|
98245
|
+
var FALLBACK_BITRATE = {
|
|
98246
|
+
average: 180,
|
|
98247
|
+
peak: 190
|
|
98248
|
+
};
|
|
98249
|
+
function bitrateFor(width) {
|
|
98250
|
+
return BITRATE_BY_WIDTH.get(width) ?? FALLBACK_BITRATE;
|
|
98251
|
+
}
|
|
98252
|
+
function averageBitrateKbpsFor(width) {
|
|
98253
|
+
return bitrateFor(width).average;
|
|
98254
|
+
}
|
|
98255
|
+
function peakBitrateKbpsFor(width) {
|
|
98256
|
+
return bitrateFor(width).peak;
|
|
98257
|
+
}
|
|
98258
|
+
/** The three rows of the table, by the column the sensor falls in. */
|
|
98259
|
+
function rowsForSensor(width, height) {
|
|
98260
|
+
if (width >= 3840 && height >= 2160) return [
|
|
98261
|
+
{
|
|
98262
|
+
width: 3840,
|
|
98263
|
+
height: 2160,
|
|
98264
|
+
frameRate: 30
|
|
98265
|
+
},
|
|
98266
|
+
{
|
|
98267
|
+
width: 1920,
|
|
98268
|
+
height: 1080,
|
|
98269
|
+
frameRate: 30
|
|
98270
|
+
},
|
|
98271
|
+
{
|
|
98272
|
+
width: 640,
|
|
98273
|
+
height: 360,
|
|
98274
|
+
frameRate: 15
|
|
98275
|
+
}
|
|
98276
|
+
];
|
|
98277
|
+
if (width >= 2560 && height >= 1440) return [
|
|
98278
|
+
{
|
|
98279
|
+
width: 2560,
|
|
98280
|
+
height: 1440,
|
|
98281
|
+
frameRate: 30
|
|
98282
|
+
},
|
|
98283
|
+
{
|
|
98284
|
+
width: 1920,
|
|
98285
|
+
height: 1080,
|
|
98286
|
+
frameRate: 30
|
|
98287
|
+
},
|
|
98288
|
+
{
|
|
98289
|
+
width: 640,
|
|
98290
|
+
height: 360,
|
|
98291
|
+
frameRate: 15
|
|
98292
|
+
}
|
|
98293
|
+
];
|
|
98294
|
+
return [
|
|
98295
|
+
{
|
|
98296
|
+
width: 1920,
|
|
98297
|
+
height: 1080,
|
|
98298
|
+
frameRate: 30
|
|
98299
|
+
},
|
|
98300
|
+
{
|
|
98301
|
+
width: 1280,
|
|
98302
|
+
height: 720,
|
|
98303
|
+
frameRate: 30
|
|
98304
|
+
},
|
|
98305
|
+
{
|
|
98306
|
+
width: 640,
|
|
98307
|
+
height: 360,
|
|
98308
|
+
frameRate: 15
|
|
98309
|
+
}
|
|
98310
|
+
];
|
|
98311
|
+
}
|
|
98312
|
+
function tiersForSensor(width, height) {
|
|
98313
|
+
const rows = rowsForSensor(width, height);
|
|
98314
|
+
const qualities = [
|
|
98315
|
+
TierQuality.HIGH,
|
|
98316
|
+
TierQuality.MEDIUM,
|
|
98317
|
+
TierQuality.LOW
|
|
98318
|
+
];
|
|
98319
|
+
return rows.map((row, index) => ({
|
|
98320
|
+
identifier: index + 1,
|
|
98321
|
+
quality: qualities[index] ?? TierQuality.LOW,
|
|
98322
|
+
targetAverageBitrateKbps: averageBitrateKbpsFor(row.width),
|
|
98323
|
+
width: row.width,
|
|
98324
|
+
height: row.height,
|
|
98325
|
+
frameRate: row.frameRate
|
|
98326
|
+
}));
|
|
98327
|
+
}
|
|
98328
|
+
//#endregion
|
|
97911
98329
|
//#region src/mappers/builders/stream-bitrate.ts
|
|
97912
98330
|
/**
|
|
97913
98331
|
* Send a stream that FITS the rate HomeKit negotiated. (R5)
|
|
@@ -98020,6 +98438,38 @@ function budgetForNegotiatedRate(negotiatedMaxBitrateKbps) {
|
|
|
98020
98438
|
return budget > 0 ? budget : null;
|
|
98021
98439
|
}
|
|
98022
98440
|
/**
|
|
98441
|
+
* The rate a TRANSCODE may actually aim at.
|
|
98442
|
+
*
|
|
98443
|
+
* Measured on the hub, 2026-09-17: two cameras on the same link, both with a
|
|
98444
|
+
* negotiated ceiling of 299 kbps. The H.264 one was passed through at **8192
|
|
98445
|
+
* kbps** under `over-budget-tolerated`; the H.265 one, which must be
|
|
98446
|
+
* re-encoded, was held to **269**. Thirty times less, decided entirely by
|
|
98447
|
+
* which branch the camera fell into.
|
|
98448
|
+
*
|
|
98449
|
+
* That asymmetry contradicts the rule this module already adopted for copy:
|
|
98450
|
+
* *"the negotiated ceiling is a budget the controller copes with being
|
|
98451
|
+
* exceeded, not a contract that must be met"*, evidenced by a reference NVR
|
|
98452
|
+
* doing exactly that. The copy path stopped treating it as a contract and the
|
|
98453
|
+
* transcode path did not.
|
|
98454
|
+
*
|
|
98455
|
+
* So the negotiated budget becomes a FLOOR and the guide's own normative
|
|
98456
|
+
* average for the DELIVERED resolution becomes the target (§ 2: 1080p30 →
|
|
98457
|
+
* 1700, 720p30 → 768, 360p → 180). Two properties matter:
|
|
98458
|
+
*
|
|
98459
|
+
* - it never LOWERS a budget the controller was willing to grant — a
|
|
98460
|
+
* controller asking for more gets more;
|
|
98461
|
+
* - it changes nothing for a relayed session, which negotiates 360p, where
|
|
98462
|
+
* the guide asks less than the controller already granted. Spending an
|
|
98463
|
+
* Apple relay's bandwidth for no picture gain is not an improvement.
|
|
98464
|
+
*
|
|
98465
|
+
* `null` stays `null`: it means "pass no `-maxrate` at all", and inventing a
|
|
98466
|
+
* number here would cap a stream that was deliberately uncapped.
|
|
98467
|
+
*/
|
|
98468
|
+
function encodeBudgetKbps(input) {
|
|
98469
|
+
if (input.negotiatedBudgetKbps === null) return null;
|
|
98470
|
+
return Math.max(input.negotiatedBudgetKbps, averageBitrateKbpsFor(input.width));
|
|
98471
|
+
}
|
|
98472
|
+
/**
|
|
98023
98473
|
* Does this slot fit?
|
|
98024
98474
|
*
|
|
98025
98475
|
* Only the CONFIGURED rate can answer yes. The measured rate is a lower bound,
|
|
@@ -98050,6 +98500,7 @@ function classifyBitrateFit(evidence, budgetKbps) {
|
|
|
98050
98500
|
function selectStreamForBudget(input) {
|
|
98051
98501
|
const budgetKbps = budgetForNegotiatedRate(input.negotiatedMaxBitrateKbps);
|
|
98052
98502
|
const notes = fitNotes(input.entries, input.bitrates, budgetKbps);
|
|
98503
|
+
const allowHevc = input.allowHevcPassthrough === true;
|
|
98053
98504
|
const effectivePref = input.pref === "auto" ? slotForConnection(input.connection) : input.pref;
|
|
98054
98505
|
const fallback = pickPreferredRtspEntry(input.entries, effectivePref, input.deviceId, { targetResolution: input.targetResolution });
|
|
98055
98506
|
if (fallback === null) return null;
|
|
@@ -98061,18 +98512,25 @@ function selectStreamForBudget(input) {
|
|
|
98061
98512
|
budgetKbps: null,
|
|
98062
98513
|
notes
|
|
98063
98514
|
};
|
|
98064
|
-
|
|
98065
|
-
kind: "copy",
|
|
98066
|
-
reason: "no-negotiated-budget",
|
|
98067
|
-
...base
|
|
98068
|
-
} : {
|
|
98515
|
+
if (!canPassThrough(fallback.codec, allowHevc)) return {
|
|
98069
98516
|
kind: "transcode",
|
|
98070
98517
|
reason: "source-codec",
|
|
98071
98518
|
...base
|
|
98072
98519
|
};
|
|
98520
|
+
if (!fitsRequestedResolution(fallback, input.targetResolution)) return {
|
|
98521
|
+
kind: "transcode",
|
|
98522
|
+
reason: "over-resolution",
|
|
98523
|
+
...base
|
|
98524
|
+
};
|
|
98525
|
+
return {
|
|
98526
|
+
kind: "copy",
|
|
98527
|
+
reason: "no-negotiated-budget",
|
|
98528
|
+
...base
|
|
98529
|
+
};
|
|
98073
98530
|
}
|
|
98074
98531
|
const affordable = (input.pref !== "auto" && fallbackProfile === input.pref ? input.entries.filter((entry) => entry.profile === fallbackProfile) : input.entries).filter((entry) => {
|
|
98075
|
-
if (!canPassThrough(entry.codec)) return false;
|
|
98532
|
+
if (!canPassThrough(entry.codec, allowHevc)) return false;
|
|
98533
|
+
if (!fitsRequestedResolution(entry, input.targetResolution)) return false;
|
|
98076
98534
|
return classifyBitrateFit(input.bitrates.get(entry.profile), budgetKbps) === "fits";
|
|
98077
98535
|
});
|
|
98078
98536
|
if (affordable.length > 0) {
|
|
@@ -98086,7 +98544,7 @@ function selectStreamForBudget(input) {
|
|
|
98086
98544
|
notes
|
|
98087
98545
|
};
|
|
98088
98546
|
}
|
|
98089
|
-
if (canPassThrough(fallback.codec)) return {
|
|
98547
|
+
if (canPassThrough(fallback.codec, allowHevc) && fitsRequestedResolution(fallback, input.targetResolution)) return {
|
|
98090
98548
|
kind: "copy",
|
|
98091
98549
|
reason: "over-budget-tolerated",
|
|
98092
98550
|
picked: fallback,
|
|
@@ -98096,7 +98554,7 @@ function selectStreamForBudget(input) {
|
|
|
98096
98554
|
};
|
|
98097
98555
|
return {
|
|
98098
98556
|
kind: "transcode",
|
|
98099
|
-
reason: transcodeReason(fallback, fallbackProfile, input.bitrates, budgetKbps),
|
|
98557
|
+
reason: transcodeReason(fallback, fallbackProfile, input.bitrates, budgetKbps, allowHevc, input.targetResolution),
|
|
98100
98558
|
picked: fallback,
|
|
98101
98559
|
profile: fallbackProfile,
|
|
98102
98560
|
budgetKbps,
|
|
@@ -98150,11 +98608,57 @@ function withSlotCodecs(entries, slots) {
|
|
|
98150
98608
|
* two load-bearing flags below back in play, with no hardware here to prove
|
|
98151
98609
|
* they still hold. Hardware DECODE is where the measured cost is.
|
|
98152
98610
|
*/
|
|
98611
|
+
/**
|
|
98612
|
+
* The least this encoder can be given per pixel and still produce a picture a
|
|
98613
|
+
* decoder will lock onto.
|
|
98614
|
+
*
|
|
98615
|
+
* Not a quality target — a DECODABILITY one. The encode is pinned to
|
|
98616
|
+
* `ultrafast` + `zerolatency` + `baseline`, the three settings that trade
|
|
98617
|
+
* compression efficiency away for latency, so it needs more bits than a
|
|
98618
|
+
* default encoder would, not fewer.
|
|
98619
|
+
*
|
|
98620
|
+
* `0.03` is not invented: it is what the 269 kbps / 720p fixture this module
|
|
98621
|
+
* was tuned against works out to AT 10 FPS, which is the rate that fixture
|
|
98622
|
+
* dials and the rate the earlier overshoot fix was measured at.
|
|
98623
|
+
*/
|
|
98624
|
+
var MIN_BITS_PER_PIXEL = .03;
|
|
98625
|
+
/**
|
|
98626
|
+
* The highest frame rate this budget can carry at this picture size.
|
|
98627
|
+
*
|
|
98628
|
+
* ## Why the FPS gives and not the bitrate
|
|
98629
|
+
*
|
|
98630
|
+
* The obvious move is to lift the bitrate until the picture is decodable. This
|
|
98631
|
+
* module already refused that once, for a reason recorded in its tests: an
|
|
98632
|
+
* encoder overshooting its ceiling (421 kbps against 299 negotiated) produced
|
|
98633
|
+
* three seconds of jitter, and the one-second VBV window is the only lever
|
|
98634
|
+
* there is on it. Raising the ceiling would buy a decodable picture and pay
|
|
98635
|
+
* for it in the exact currency that incident was about.
|
|
98636
|
+
*
|
|
98637
|
+
* So the ceiling holds and the frame rate gives way. A stream at 10 fps that
|
|
98638
|
+
* DECODES beats one at 30 fps that does not — and the controller never asked
|
|
98639
|
+
* for 30 in particular; it asked for a ceiling and a maximum rate.
|
|
98640
|
+
*
|
|
98641
|
+
* ## The measurement behind it
|
|
98642
|
+
*
|
|
98643
|
+
* Operator, 2026-09-16: the one H.265 camera shows a loader in the Home app,
|
|
98644
|
+
* the three H.264 ones are fine. H.264 is pass-through, H.265 is transcode.
|
|
98645
|
+
* Same controller, same 299 kbps ceiling: pass-through delivered 714 video
|
|
98646
|
+
* packets/s and worked; the transcode delivered 66 and produced a loader with
|
|
98647
|
+
* a `decode-suspect` verdict, three sessions running. It was encoding 1280x720
|
|
98648
|
+
* at 30 fps into 269 kbps — about 0.01 bits per pixel, a third of what the
|
|
98649
|
+
* fixture this was tuned on gets.
|
|
98650
|
+
*/
|
|
98651
|
+
function fpsForBudget(budgetKbps, width, height, requestedFps) {
|
|
98652
|
+
const pixels = Math.max(1, width) * Math.max(1, height);
|
|
98653
|
+
const affordableFps = Math.round(budgetKbps * 1e3 / (pixels * MIN_BITS_PER_PIXEL));
|
|
98654
|
+
return Math.max(1, Math.min(requestedFps, affordableFps));
|
|
98655
|
+
}
|
|
98153
98656
|
function buildVideoPlan(input) {
|
|
98154
98657
|
if (!input.transcode) return {
|
|
98155
98658
|
kind: "copy",
|
|
98156
98659
|
bitstreamFilter: "dump_extra"
|
|
98157
98660
|
};
|
|
98661
|
+
const deliveredFps = input.budgetKbps === null ? input.fps : fpsForBudget(input.budgetKbps, input.width, input.height, input.fps);
|
|
98158
98662
|
return {
|
|
98159
98663
|
kind: "encode",
|
|
98160
98664
|
encoder: "libx264",
|
|
@@ -98165,11 +98669,12 @@ function buildVideoPlan(input) {
|
|
|
98165
98669
|
},
|
|
98166
98670
|
preset: "ultrafast",
|
|
98167
98671
|
tune: "zerolatency",
|
|
98168
|
-
|
|
98169
|
-
|
|
98672
|
+
singleSlicePerFrame: true,
|
|
98673
|
+
profile: input.profile ?? "baseline",
|
|
98674
|
+
level: input.level ?? "3.1",
|
|
98170
98675
|
pixelFormat: "yuv420p",
|
|
98171
|
-
fps:
|
|
98172
|
-
gopFrames: Math.max(1, Math.round(
|
|
98676
|
+
fps: deliveredFps,
|
|
98677
|
+
gopFrames: Math.max(1, Math.round(deliveredFps * 4)),
|
|
98173
98678
|
...input.budgetKbps === null ? {} : {
|
|
98174
98679
|
bitrateKbps: input.budgetKbps,
|
|
98175
98680
|
rateControl: RATE_CONTROL_TIGHT
|
|
@@ -98192,23 +98697,64 @@ function fitNotes(entries, bitrates, budgetKbps) {
|
|
|
98192
98697
|
};
|
|
98193
98698
|
});
|
|
98194
98699
|
}
|
|
98195
|
-
function transcodeReason(picked, profile, bitrates, budgetKbps) {
|
|
98196
|
-
if (!canPassThrough(picked.codec)) return "source-codec";
|
|
98700
|
+
function transcodeReason(picked, profile, bitrates, budgetKbps, allowHevc, target) {
|
|
98701
|
+
if (!canPassThrough(picked.codec, allowHevc)) return "source-codec";
|
|
98702
|
+
if (!fitsRequestedResolution(picked, target)) return "over-resolution";
|
|
98197
98703
|
if (profile === null) return "unknown-bitrate";
|
|
98198
98704
|
return classifyBitrateFit(bitrates.get(profile), budgetKbps) === "over-budget" ? "over-budget" : "unknown-bitrate";
|
|
98199
98705
|
}
|
|
98200
98706
|
/**
|
|
98201
|
-
*
|
|
98202
|
-
*
|
|
98203
|
-
*
|
|
98204
|
-
*
|
|
98707
|
+
* Which source codecs may go on the wire untouched.
|
|
98708
|
+
*
|
|
98709
|
+
* H.264 always. H.265 only when the SESSION was negotiated on Apple's
|
|
98710
|
+
* multi-tier RTP service, which advertises `VideoCodecType 2 = H.265` in its
|
|
98711
|
+
* tiers — the classic HAP SRTP path renders H.264 only, and sending HEVC there
|
|
98712
|
+
* is a black picture with no error.
|
|
98713
|
+
*
|
|
98714
|
+
* The permission belongs to the session rather than to the camera because the
|
|
98715
|
+
* same camera serves both services: which one negotiated decides what may be
|
|
98716
|
+
* copied. Refusing HEVC on the new service transcodes 4K HEVC down to H.264
|
|
98717
|
+
* for a controller that explicitly asked for the HEVC, which is the entire cost
|
|
98718
|
+
* that service exists to remove.
|
|
98719
|
+
*
|
|
98720
|
+
* An UNKNOWN codec is refused on BOTH. Guessing H.264 is how a camera that
|
|
98721
|
+
* changed its encoder ends up shipping bytes no controller can decode, and the
|
|
98722
|
+
* new service's permission is for H.265 specifically — not for "whatever this
|
|
98723
|
+
* turns out to be".
|
|
98205
98724
|
*/
|
|
98206
|
-
function canPassThrough(codec) {
|
|
98725
|
+
function canPassThrough(codec, allowHevc) {
|
|
98207
98726
|
if (codec === void 0) return false;
|
|
98208
98727
|
const lower = codec.toLowerCase();
|
|
98209
|
-
if (lower.includes("h265") || lower.includes("hevc")) return
|
|
98728
|
+
if (lower.includes("h265") || lower.includes("hevc")) return allowHevc;
|
|
98210
98729
|
return lower.includes("h264") || lower.includes("avc");
|
|
98211
98730
|
}
|
|
98731
|
+
/**
|
|
98732
|
+
* May this slot be forwarded UNTOUCHED at the resolution the controller asked
|
|
98733
|
+
* for?
|
|
98734
|
+
*
|
|
98735
|
+
* Measured on the hub, 2026-09-17, from iOS's own Receiver Reports: two
|
|
98736
|
+
* cameras forwarding a 3840x2160 slot to a controller that had negotiated
|
|
98737
|
+
* 1280x720, and the busier of the two reported losing **52.7 %** of packets in
|
|
98738
|
+
* its worst window, 617 packets cumulative, jitter 54 618. The third camera,
|
|
98739
|
+
* transcoded to 720p because its source is HEVC, lost one packet. The operator
|
|
98740
|
+
* saw this as "the H.264 cameras are slower to load than the H.265 one" — and
|
|
98741
|
+
* they were, because a 4K key frame spans hundreds of packets and losing any
|
|
98742
|
+
* one of them makes the picture wait for the next key frame, up to 3.5 s away.
|
|
98743
|
+
*
|
|
98744
|
+
* `over-budget-tolerated` is unchanged and still right: a RATE overshoot is
|
|
98745
|
+
* something a controller copes with. A RESOLUTION overshoot is a different
|
|
98746
|
+
* thing — it hands the controller a picture it never asked to decode.
|
|
98747
|
+
*
|
|
98748
|
+
* Smaller than asked for is fine and is copied: we cannot add detail, and an
|
|
98749
|
+
* encoder run that only enlarges pixels spends CPU to make the picture no
|
|
98750
|
+
* better and the stream bigger.
|
|
98751
|
+
*/
|
|
98752
|
+
function fitsRequestedResolution(entry, target) {
|
|
98753
|
+
const width = entry.resolution?.width;
|
|
98754
|
+
const height = entry.resolution?.height;
|
|
98755
|
+
if (width === void 0 || height === void 0) return true;
|
|
98756
|
+
return width <= target.width && height <= target.height;
|
|
98757
|
+
}
|
|
98212
98758
|
function plausibleMeasured(value) {
|
|
98213
98759
|
if (value === null || !Number.isFinite(value)) return null;
|
|
98214
98760
|
return value >= 64 ? value : null;
|
|
@@ -98396,6 +98942,21 @@ async function probe$1(call, label, log) {
|
|
|
98396
98942
|
}
|
|
98397
98943
|
}
|
|
98398
98944
|
//#endregion
|
|
98945
|
+
//#region src/mappers/builders/passthrough-request.ts
|
|
98946
|
+
function passthroughRequest(input) {
|
|
98947
|
+
const acceptCodecs = ["h264"];
|
|
98948
|
+
if (input.multiTier && input.codec !== TierVideoCodec.H264) acceptCodecs.push("h265", "hevc");
|
|
98949
|
+
return {
|
|
98950
|
+
requirements: {
|
|
98951
|
+
acceptCodecs,
|
|
98952
|
+
allowTranscode: false,
|
|
98953
|
+
requireProfileBound: true,
|
|
98954
|
+
maxHeight: input.negotiatedHeight
|
|
98955
|
+
},
|
|
98956
|
+
preferences: { targetHeight: input.negotiatedHeight }
|
|
98957
|
+
};
|
|
98958
|
+
}
|
|
98959
|
+
//#endregion
|
|
98399
98960
|
//#region src/mappers/builders/stream-ffmpeg-args.ts
|
|
98400
98961
|
/**
|
|
98401
98962
|
* The ffmpeg PLAN for one HomeKit streaming session.
|
|
@@ -98498,6 +99059,26 @@ var HAP_INPUT_PROBE = {
|
|
|
98498
99059
|
analyzeDurationUs: 2e5,
|
|
98499
99060
|
probeSizeBytes: 64 * 1024
|
|
98500
99061
|
};
|
|
99062
|
+
/**
|
|
99063
|
+
* ONE encoder thread on the encode branch, none pinned on a copy. (D515)
|
|
99064
|
+
*
|
|
99065
|
+
* The plan asks x264 for one slice per access unit (`sliced-threads=0`). On
|
|
99066
|
+
* its own that flag hands x264 its AUTO thread count — 30 on the 20-core hub —
|
|
99067
|
+
* as FRAME threads, and frame threading delays output by `threads − 1`
|
|
99068
|
+
* frames: measured on the live hub as the first packet leaving ~3 s later at
|
|
99069
|
+
* the 10 fps a 269 kbps budget dials. Four threads still cost ~1 s. One thread
|
|
99070
|
+
* was measured with no start-up delay, one slice, and — unlike the eleven-slice
|
|
99071
|
+
* encode — no `VBV underflow` from x264, and a 720p `ultrafast` encode is far
|
|
99072
|
+
* inside what one core carries. A session is bounded by what one controller
|
|
99073
|
+
* asked for, never by this host's core count.
|
|
99074
|
+
*
|
|
99075
|
+
* `-threads` is an OUTPUT option here, emitted inside the video leg by the
|
|
99076
|
+
* shared builder, so it never reaches the decoder or the audio leg.
|
|
99077
|
+
*/
|
|
99078
|
+
var HAP_ENCODER_THREADS = 1;
|
|
99079
|
+
function encoderThreadCount(video) {
|
|
99080
|
+
return video.kind === "encode" ? HAP_ENCODER_THREADS : 0;
|
|
99081
|
+
}
|
|
98501
99082
|
function buildSessionInvocation(input) {
|
|
98502
99083
|
return {
|
|
98503
99084
|
logLevel: "warning",
|
|
@@ -98511,7 +99092,7 @@ function buildSessionInvocation(input) {
|
|
|
98511
99092
|
},
|
|
98512
99093
|
video: input.video,
|
|
98513
99094
|
audio: audioPlan(input),
|
|
98514
|
-
threadCount:
|
|
99095
|
+
threadCount: encoderThreadCount(input.video),
|
|
98515
99096
|
outputArgs: [],
|
|
98516
99097
|
sink: {
|
|
98517
99098
|
kind: "rtp-outputs",
|
|
@@ -98669,7 +99250,7 @@ function toCamProfile(profileId) {
|
|
|
98669
99250
|
//#endregion
|
|
98670
99251
|
//#region src/mappers/builders/h264-idr.ts
|
|
98671
99252
|
/**
|
|
98672
|
-
* Does this RTP packet carry the start of
|
|
99253
|
+
* Does this RTP packet carry the start of a key frame?
|
|
98673
99254
|
*
|
|
98674
99255
|
* A pass-through session cannot manufacture a key frame on demand — it can
|
|
98675
99256
|
* only forward the one the camera decides to emit. So the number that decides
|
|
@@ -98683,6 +99264,16 @@ function toCamProfile(profileId) {
|
|
|
98683
99264
|
* boolean per packet, no state, no allocation, and it never throws. It runs on
|
|
98684
99265
|
* every forwarded video packet, and a parser that throws on a malformed packet
|
|
98685
99266
|
* would take the media path down with it.
|
|
99267
|
+
*
|
|
99268
|
+
* ## Two codecs, two headers
|
|
99269
|
+
*
|
|
99270
|
+
* H.264 (RFC 6184) has a ONE-byte NAL header whose type is the low 5 bits.
|
|
99271
|
+
* HEVC (RFC 7798) has a TWO-byte header whose type is bits 1..6 of the first.
|
|
99272
|
+
* The H.264 parser applied to an HEVC packet reads a type that means nothing
|
|
99273
|
+
* and answers "no key frame" forever — which, since this is telemetry and not
|
|
99274
|
+
* a gate, would show an HEVC session streaming perfectly with `videoKeyframes`
|
|
99275
|
+
* stuck at zero. That is the exact shape of the bug these counters exist to
|
|
99276
|
+
* detect (D515), so the codec is a parameter rather than an assumption.
|
|
98686
99277
|
*/
|
|
98687
99278
|
/** NAL unit type carrying a coded slice of an IDR picture (RFC 6184 §5.2). */
|
|
98688
99279
|
var NAL_TYPE_IDR = 5;
|
|
@@ -98695,6 +99286,16 @@ var RTP_MIN_HEADER_BYTES = 12;
|
|
|
98695
99286
|
var NAL_TYPE_MASK = 31;
|
|
98696
99287
|
/** FU header start bit — set only on the FIRST fragment of a fragmented NAL. */
|
|
98697
99288
|
var FU_START_BIT = 128;
|
|
99289
|
+
/** HEVC NAL types 16..23 are IRAP pictures — the key frames of HEVC. */
|
|
99290
|
+
var HEVC_IRAP_MIN = 16;
|
|
99291
|
+
var HEVC_IRAP_MAX = 23;
|
|
99292
|
+
/** RFC 7798 payload structures: 48 aggregation, 49 fragmentation unit. */
|
|
99293
|
+
var HEVC_TYPE_AP = 48;
|
|
99294
|
+
var HEVC_TYPE_FU = 49;
|
|
99295
|
+
var HEVC_TYPE_MASK = 63;
|
|
99296
|
+
function rtpPacketCarriesKeyframe(packet, codec) {
|
|
99297
|
+
return codec === "h265" ? rtpPacketCarriesIrap(packet) : rtpPacketCarriesIdr(packet);
|
|
99298
|
+
}
|
|
98698
99299
|
function rtpPacketCarriesIdr(packet) {
|
|
98699
99300
|
const payloadStart = rtpPayloadOffset(packet);
|
|
98700
99301
|
if (payloadStart === null) return false;
|
|
@@ -98744,6 +99345,158 @@ function stapContainsIdr(packet, start) {
|
|
|
98744
99345
|
}
|
|
98745
99346
|
return false;
|
|
98746
99347
|
}
|
|
99348
|
+
/** HEVC's half: the same shape, read through RFC 7798's two-byte header. */
|
|
99349
|
+
function rtpPacketCarriesIrap(packet) {
|
|
99350
|
+
const payloadStart = rtpPayloadOffset(packet);
|
|
99351
|
+
if (payloadStart === null) return false;
|
|
99352
|
+
const firstPayloadByte = packet[payloadStart];
|
|
99353
|
+
if (firstPayloadByte === void 0) return false;
|
|
99354
|
+
const nalType = firstPayloadByte >> 1 & HEVC_TYPE_MASK;
|
|
99355
|
+
if (nalType === HEVC_TYPE_FU) {
|
|
99356
|
+
const fuHeader = packet[payloadStart + 2];
|
|
99357
|
+
if (fuHeader === void 0) return false;
|
|
99358
|
+
if ((fuHeader & FU_START_BIT) === 0) return false;
|
|
99359
|
+
return isHevcIrap(fuHeader & HEVC_TYPE_MASK);
|
|
99360
|
+
}
|
|
99361
|
+
if (nalType === HEVC_TYPE_AP) return hevcAggregationContainsIrap(packet, payloadStart + 2);
|
|
99362
|
+
return isHevcIrap(nalType);
|
|
99363
|
+
}
|
|
99364
|
+
function isHevcIrap(nalType) {
|
|
99365
|
+
return nalType >= HEVC_IRAP_MIN && nalType <= HEVC_IRAP_MAX;
|
|
99366
|
+
}
|
|
99367
|
+
/** Walk an aggregation packet's `[size][nal]` pairs looking for an IRAP. */
|
|
99368
|
+
function hevcAggregationContainsIrap(packet, start) {
|
|
99369
|
+
let offset = start;
|
|
99370
|
+
while (offset + 2 <= packet.length) {
|
|
99371
|
+
const size = packet.readUInt16BE(offset);
|
|
99372
|
+
offset += 2;
|
|
99373
|
+
if (size === 0 || offset + size > packet.length) return false;
|
|
99374
|
+
const nalHeader = packet[offset];
|
|
99375
|
+
if (nalHeader === void 0) return false;
|
|
99376
|
+
if (isHevcIrap(nalHeader >> 1 & HEVC_TYPE_MASK)) return true;
|
|
99377
|
+
offset += size;
|
|
99378
|
+
}
|
|
99379
|
+
return false;
|
|
99380
|
+
}
|
|
99381
|
+
//#endregion
|
|
99382
|
+
//#region src/multi-tier/native-codec.ts
|
|
99383
|
+
/**
|
|
99384
|
+
* The codec the camera really publishes, for the multi-tier service's tier
|
|
99385
|
+
* declaration.
|
|
99386
|
+
*
|
|
99387
|
+
* A tier list names ONE codec for all its tiers, so this has to pick. It picks
|
|
99388
|
+
* from the LARGEST stream, because that is the one the high tier will carry and
|
|
99389
|
+
* the one whose codec decides whether we transcode. A camera that offers a tiny
|
|
99390
|
+
* HEVC substream beside a 1080p H.264 main is an H.264 camera for our purposes.
|
|
99391
|
+
*
|
|
99392
|
+
* Absence falls back to H.264: it is what every controller accepts and what the
|
|
99393
|
+
* classic path already serves, so an unreadable publisher costs us the feature
|
|
99394
|
+
* rather than the camera.
|
|
99395
|
+
*/
|
|
99396
|
+
/** The spellings publishers in this fleet actually use for HEVC. */
|
|
99397
|
+
function isHevc(codec) {
|
|
99398
|
+
if (codec === void 0) return false;
|
|
99399
|
+
const normalised = codec.toLowerCase().replace(/[.\-_\s]/g, "");
|
|
99400
|
+
return normalised === "h265" || normalised === "hevc";
|
|
99401
|
+
}
|
|
99402
|
+
/** The stream whose codec and size describe this camera at its best. */
|
|
99403
|
+
function largestReadableStream(streams) {
|
|
99404
|
+
let best;
|
|
99405
|
+
let bestArea = -1;
|
|
99406
|
+
for (const stream of streams) {
|
|
99407
|
+
if (stream.codec === void 0) continue;
|
|
99408
|
+
const area = stream.resolution === void 0 ? 0 : stream.resolution.width * stream.resolution.height;
|
|
99409
|
+
if (area > bestArea) {
|
|
99410
|
+
bestArea = area;
|
|
99411
|
+
best = stream;
|
|
99412
|
+
}
|
|
99413
|
+
}
|
|
99414
|
+
return best;
|
|
99415
|
+
}
|
|
99416
|
+
/**
|
|
99417
|
+
* The sensor the new camera service describes: size AND codec, from ONE stream.
|
|
99418
|
+
*
|
|
99419
|
+
* They used to come from two places and the kitchen camera showed what that
|
|
99420
|
+
* costs. The codec was read off the published streams -- h265, correct --
|
|
99421
|
+
* while the size came from the CLASSIC service's advertised resolution list,
|
|
99422
|
+
* which is capped at 1080p because that is the ceiling the classic service
|
|
99423
|
+
* accepts. So the accessory advertised "H.265, 1920x1080" for a camera whose
|
|
99424
|
+
* main stream is 3840x2160 H.265, and the 4K the new service exists to carry
|
|
99425
|
+
* was never offered.
|
|
99426
|
+
*
|
|
99427
|
+
* One stream answers both questions, so the two cannot disagree again.
|
|
99428
|
+
*/
|
|
99429
|
+
function sensorForStreams(streams) {
|
|
99430
|
+
const best = largestReadableStream(streams);
|
|
99431
|
+
const resolution = best?.resolution;
|
|
99432
|
+
return {
|
|
99433
|
+
width: resolution?.width ?? 1920,
|
|
99434
|
+
height: resolution?.height ?? 1080,
|
|
99435
|
+
codec: isHevc(best?.codec) ? TierVideoCodec.H265 : TierVideoCodec.H264
|
|
99436
|
+
};
|
|
99437
|
+
}
|
|
99438
|
+
//#endregion
|
|
99439
|
+
//#region src/mappers/builders/stream-fps-probe.ts
|
|
99440
|
+
async function probeAdvertisedVideoProfile(bctx) {
|
|
99441
|
+
const { ctx, proxy, numericDeviceId, options } = bctx;
|
|
99442
|
+
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
99443
|
+
const entries = await probe(() => proxy.cameraStreams?.getProfileRtspEntries({}), "cameraStreams.getProfileRtspEntries", log);
|
|
99444
|
+
const slots = await probe(() => proxy.cameraStreams?.getBrokerStreams({}), "cameraStreams.getBrokerStreams", log);
|
|
99445
|
+
const camStreams = await probe(() => proxy.cameraStreams?.getCameraStreams({}), "cameraStreams.getCameraStreams", log);
|
|
99446
|
+
const choices = await probe(() => proxy.webrtcSession?.listStreams({}), "webrtcSession.listStreams", log);
|
|
99447
|
+
const fpsByProfile = resolveProfileFps({
|
|
99448
|
+
choices: choices ?? [],
|
|
99449
|
+
slots: slots ?? [],
|
|
99450
|
+
camStreams: camStreams ?? []
|
|
99451
|
+
});
|
|
99452
|
+
const sensor = sensorForStreams(camStreams ?? []);
|
|
99453
|
+
const resolutions = deriveAdvertisedResolutions({
|
|
99454
|
+
candidates: CANDIDATE_RESOLUTIONS,
|
|
99455
|
+
entries: entries ?? [],
|
|
99456
|
+
deviceId: numericDeviceId,
|
|
99457
|
+
pref: options.hapDeviceSettings.streamPreference,
|
|
99458
|
+
fpsByProfile
|
|
99459
|
+
});
|
|
99460
|
+
const assumed = resolutions.filter((r) => r.source === "assumed").length;
|
|
99461
|
+
log.info("export-hap: advertised video profile derived", { meta: {
|
|
99462
|
+
streamPreference: options.hapDeviceSettings.streamPreference,
|
|
99463
|
+
resolutions: formatAdvertisedResolutions(resolutions),
|
|
99464
|
+
sensor: `${sensor.width}x${sensor.height} ${sensor.codec === TierVideoCodec.H265 ? "h265" : "h264"}`,
|
|
99465
|
+
profileFps: [...fpsByProfile.values()].map((f) => `${f.profile}=${f.fps}(${f.source})`),
|
|
99466
|
+
assumedCount: assumed,
|
|
99467
|
+
previouslyAdvertisedFps: 30
|
|
99468
|
+
} });
|
|
99469
|
+
if (assumed === resolutions.length) log.warn("export-hap: no measured or published frame rate for ANY profile — advertising the assumed rate", { meta: {
|
|
99470
|
+
entries: entries?.length ?? 0,
|
|
99471
|
+
choices: choices?.length ?? 0
|
|
99472
|
+
} });
|
|
99473
|
+
return {
|
|
99474
|
+
resolutions,
|
|
99475
|
+
fpsByProfile,
|
|
99476
|
+
sensor
|
|
99477
|
+
};
|
|
99478
|
+
}
|
|
99479
|
+
/**
|
|
99480
|
+
* Run one cap read. Returns null both when the cap is not bound and when the
|
|
99481
|
+
* call throws — and LOGS which, because "the camera has no telemetry" and "the
|
|
99482
|
+
* telemetry call failed" lead to different fixes.
|
|
99483
|
+
*/
|
|
99484
|
+
async function probe(call, label, log) {
|
|
99485
|
+
try {
|
|
99486
|
+
const pending = call();
|
|
99487
|
+
if (pending === void 0) {
|
|
99488
|
+
log.info("export-hap: fps probe skipped — cap not bound on this device", { meta: { call: label } });
|
|
99489
|
+
return null;
|
|
99490
|
+
}
|
|
99491
|
+
return await pending;
|
|
99492
|
+
} catch (err) {
|
|
99493
|
+
log.warn("export-hap: fps probe failed — falling back to a lower-authority source", { meta: {
|
|
99494
|
+
call: label,
|
|
99495
|
+
error: err instanceof Error ? err.message : String(err)
|
|
99496
|
+
} });
|
|
99497
|
+
return null;
|
|
99498
|
+
}
|
|
99499
|
+
}
|
|
98747
99500
|
/**
|
|
98748
99501
|
* How long after spawn an exit still counts as "hardware init failed".
|
|
98749
99502
|
*
|
|
@@ -99196,6 +99949,17 @@ var SESSION_HEARTBEAT_MS = 5e3;
|
|
|
99196
99949
|
* what the controller thinks without becoming one.
|
|
99197
99950
|
*/
|
|
99198
99951
|
var RECEIVER_REPORT_LOG_INTERVAL_MS = 5e3;
|
|
99952
|
+
/** HAP's profile/level enums are numeric; ffmpeg wants their names. */
|
|
99953
|
+
var HAP_PROFILE_NAMES = {
|
|
99954
|
+
0: "baseline",
|
|
99955
|
+
1: "main",
|
|
99956
|
+
2: "high"
|
|
99957
|
+
};
|
|
99958
|
+
var HAP_LEVEL_NAMES = {
|
|
99959
|
+
0: "3.1",
|
|
99960
|
+
1: "3.2",
|
|
99961
|
+
2: "4.0"
|
|
99962
|
+
};
|
|
99199
99963
|
function buildCameraStreamingDelegate(bctx, advertised) {
|
|
99200
99964
|
const { ctx, numericDeviceId } = bctx;
|
|
99201
99965
|
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
@@ -99209,7 +99973,7 @@ function buildCameraStreamingDelegate(bctx, advertised) {
|
|
|
99209
99973
|
});
|
|
99210
99974
|
},
|
|
99211
99975
|
prepareStream(request, callback) {
|
|
99212
|
-
prepareStream(request, sessions, bctx).then((resp) => callback(void 0, resp)).catch((err) => {
|
|
99976
|
+
prepareStream(request, sessions, bctx, "classic").then((resp) => callback(void 0, resp)).catch((err) => {
|
|
99213
99977
|
log.warn("export-hap: prepareStream failed", { meta: { error: errMsg$12(err) } });
|
|
99214
99978
|
callback(err instanceof Error ? err : new Error(errMsg$12(err)));
|
|
99215
99979
|
});
|
|
@@ -99249,6 +100013,29 @@ function buildCameraStreamingDelegate(bctx, advertised) {
|
|
|
99249
100013
|
comfort_noise: false
|
|
99250
100014
|
}
|
|
99251
100015
|
},
|
|
100016
|
+
negotiator: {
|
|
100017
|
+
async prepare(request) {
|
|
100018
|
+
const response = await prepareStream(request, sessions, bctx, "multi-tier");
|
|
100019
|
+
const session = sessions.get(request.sessionID);
|
|
100020
|
+
if (session === void 0) throw new Error("export-hap: prepareStream returned without registering a session");
|
|
100021
|
+
const video = response.video;
|
|
100022
|
+
const audio = response.audio;
|
|
100023
|
+
if (!("port" in video) || audio === void 0 || !("port" in audio)) throw new Error("export-hap: the multi-tier path does not support a proxied source");
|
|
100024
|
+
return {
|
|
100025
|
+
localAddress: session.localAddress,
|
|
100026
|
+
videoPort: video.port,
|
|
100027
|
+
audioPort: audio.port,
|
|
100028
|
+
videoSsrc: video.ssrc,
|
|
100029
|
+
audioSsrc: audio.ssrc
|
|
100030
|
+
};
|
|
100031
|
+
},
|
|
100032
|
+
async control(request) {
|
|
100033
|
+
await handleStreamRequest(request, sessions, bctx, advertised);
|
|
100034
|
+
},
|
|
100035
|
+
has(sessionId) {
|
|
100036
|
+
return sessions.has(sessionId);
|
|
100037
|
+
}
|
|
100038
|
+
},
|
|
99252
100039
|
dispose: async () => {
|
|
99253
100040
|
for (const session of sessions.values()) {
|
|
99254
100041
|
session.teardownTrigger = "accessory-dispose";
|
|
@@ -99279,7 +100066,7 @@ async function handleSnapshot(bctx, request) {
|
|
|
99279
100066
|
}
|
|
99280
100067
|
return Buffer.from(result.base64, "base64");
|
|
99281
100068
|
}
|
|
99282
|
-
async function prepareStream(request, sessions, bctx) {
|
|
100069
|
+
async function prepareStream(request, sessions, bctx, negotiatedVia) {
|
|
99283
100070
|
const ipVersion = request.addressVersion;
|
|
99284
100071
|
const localIp = pickLocalInterfaceIp(request.targetAddress, ipVersion);
|
|
99285
100072
|
const videoUdp = await bindUdp(ipVersion, localIp);
|
|
@@ -99353,6 +100140,9 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
99353
100140
|
const audioSsrc = randomSsrc();
|
|
99354
100141
|
const session = {
|
|
99355
100142
|
sessionId: request.sessionID,
|
|
100143
|
+
localAddress: localIp,
|
|
100144
|
+
negotiatedVia,
|
|
100145
|
+
wireCodec: "h264",
|
|
99356
100146
|
startedAtMs: null,
|
|
99357
100147
|
endedAtMs: null,
|
|
99358
100148
|
negotiated: null,
|
|
@@ -99451,7 +100241,7 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
99451
100241
|
});
|
|
99452
100242
|
videoLoopUdp.on("message", (rtpPacket) => {
|
|
99453
100243
|
session.videoPacketsForwarded += 1;
|
|
99454
|
-
if (
|
|
100244
|
+
if (rtpPacketCarriesKeyframe(rtpPacket, session.wireCodec)) recordKeyframe(session);
|
|
99455
100245
|
if (session.videoPacketsForwarded === 1) tagLog.info("export-hap: first video packet from ffmpeg", { meta: {
|
|
99456
100246
|
sessionId: session.sessionId,
|
|
99457
100247
|
bytes: rtpPacket.length
|
|
@@ -100081,7 +100871,9 @@ async function handleStreamRequest(request, sessions, bctx, advertised) {
|
|
|
100081
100871
|
audioPt: request.audio.pt,
|
|
100082
100872
|
audioSsrc: session.audioOutSsrc,
|
|
100083
100873
|
audioSampleRateEnum: request.audio.sample_rate,
|
|
100084
|
-
audioPacketTimeMs: packetTimeMs
|
|
100874
|
+
audioPacketTimeMs: packetTimeMs,
|
|
100875
|
+
profile: request.video.profile,
|
|
100876
|
+
level: request.video.level
|
|
100085
100877
|
};
|
|
100086
100878
|
const startParams = {
|
|
100087
100879
|
width: request.video.width,
|
|
@@ -100090,6 +100882,8 @@ async function handleStreamRequest(request, sessions, bctx, advertised) {
|
|
|
100090
100882
|
max_bit_rate: request.video.max_bit_rate,
|
|
100091
100883
|
mtu: request.video.mtu,
|
|
100092
100884
|
pt: request.video.pt,
|
|
100885
|
+
profile: request.video.profile,
|
|
100886
|
+
level: request.video.level,
|
|
100093
100887
|
audio_pt: request.audio.pt,
|
|
100094
100888
|
audio_ssrc: session.audioOutSsrc,
|
|
100095
100889
|
sample_rate: request.audio.sample_rate,
|
|
@@ -100121,7 +100915,9 @@ async function handleStreamRequest(request, sessions, bctx, advertised) {
|
|
|
100121
100915
|
audio_pt: session.lastStartParams.audioPt,
|
|
100122
100916
|
audio_ssrc: session.lastStartParams.audioSsrc,
|
|
100123
100917
|
sample_rate: session.lastStartParams.audioSampleRateEnum,
|
|
100124
|
-
packet_time: session.lastStartParams.audioPacketTimeMs
|
|
100918
|
+
packet_time: session.lastStartParams.audioPacketTimeMs,
|
|
100919
|
+
profile: session.lastStartParams.profile,
|
|
100920
|
+
level: session.lastStartParams.level
|
|
100125
100921
|
};
|
|
100126
100922
|
log.info("export-hap: stream reconfigured", { meta: {
|
|
100127
100923
|
sessionId: request.sessionID,
|
|
@@ -100146,6 +100942,22 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
|
|
|
100146
100942
|
throw new Error(`export-hap: no profile RTSP entries for device ${numericDeviceId}`);
|
|
100147
100943
|
}
|
|
100148
100944
|
const pref = options.hapDeviceSettings.streamPreference;
|
|
100945
|
+
const capPick = pref === "auto" ? await probe(() => proxy.cameraStreams?.pickStream(passthroughRequest({
|
|
100946
|
+
negotiatedHeight: video.height,
|
|
100947
|
+
multiTier: session.negotiatedVia === "multi-tier"
|
|
100948
|
+
})), "cameraStreams.pickStream", startLog) : null;
|
|
100949
|
+
if (capPick?.profile !== void 0) startLog.info("export-hap: the shared picker chose the slot", { meta: {
|
|
100950
|
+
sessionId,
|
|
100951
|
+
profile: capPick.profile,
|
|
100952
|
+
camStreamId: capPick.camStreamId,
|
|
100953
|
+
codec: capPick.codec ?? "unknown",
|
|
100954
|
+
fps: capPick.fps ?? null,
|
|
100955
|
+
fpsSource: capPick.fpsSource ?? null,
|
|
100956
|
+
negotiated: `${video.width}x${video.height}`,
|
|
100957
|
+
reason: capPick.reason
|
|
100958
|
+
} });
|
|
100959
|
+
/** The picker's slot when it found one, otherwise the operator's setting. */
|
|
100960
|
+
const effectivePref = capPick?.profile ?? pref;
|
|
100149
100961
|
const bitrates = await probeProfileBitrates({
|
|
100150
100962
|
bctx,
|
|
100151
100963
|
slots: brokerStreams,
|
|
@@ -100159,14 +100971,15 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
|
|
|
100159
100971
|
const fit = selectStreamForBudget({
|
|
100160
100972
|
entries: withSlotCodecs(entries, brokerStreams),
|
|
100161
100973
|
deviceId: numericDeviceId,
|
|
100162
|
-
pref,
|
|
100974
|
+
pref: effectivePref,
|
|
100163
100975
|
connection,
|
|
100164
100976
|
targetResolution: {
|
|
100165
100977
|
width: video.width,
|
|
100166
100978
|
height: video.height
|
|
100167
100979
|
},
|
|
100168
100980
|
negotiatedMaxBitrateKbps: video.max_bit_rate,
|
|
100169
|
-
bitrates
|
|
100981
|
+
bitrates,
|
|
100982
|
+
allowHevcPassthrough: session.negotiatedVia === "multi-tier"
|
|
100170
100983
|
});
|
|
100171
100984
|
if (fit === null) {
|
|
100172
100985
|
startLog.warn("export-hap: stream start DROPPED — no ENABLED profile RTSP entry", { meta: {
|
|
@@ -100195,6 +101008,11 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
|
|
|
100195
101008
|
const slot = brokerStreams.find((s) => s.profile === fit.profile);
|
|
100196
101009
|
const codec = (picked.codec ?? slot?.codec ?? "").toLowerCase();
|
|
100197
101010
|
const needsTranscode = fit.kind === "transcode";
|
|
101011
|
+
const encodeBudget = needsTranscode ? encodeBudgetKbps({
|
|
101012
|
+
negotiatedBudgetKbps: fit.budgetKbps,
|
|
101013
|
+
width: video.width
|
|
101014
|
+
}) : fit.budgetKbps;
|
|
101015
|
+
session.wireCodec = !needsTranscode && (codec.includes("h265") || codec.includes("hevc")) ? "h265" : "h264";
|
|
100198
101016
|
const pickedProfile = toKnownProfile(picked.profileId);
|
|
100199
101017
|
const resolvedFps = pickedProfile === null ? void 0 : advertised.fpsByProfile.get(pickedProfile);
|
|
100200
101018
|
const advertisedFps = resolvedFps?.fps ?? video.fps;
|
|
@@ -100215,7 +101033,7 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
|
|
|
100215
101033
|
fitReason: fit.reason,
|
|
100216
101034
|
publishedKbps: slotEvidence?.publishedKbps ?? null,
|
|
100217
101035
|
measuredKbps: slotEvidence?.measuredKbps ?? null,
|
|
100218
|
-
budgetKbps:
|
|
101036
|
+
budgetKbps: encodeBudget,
|
|
100219
101037
|
fitNotes
|
|
100220
101038
|
};
|
|
100221
101039
|
startLog.info("export-hap: stream bitrate fit resolved", { meta: {
|
|
@@ -100223,7 +101041,7 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
|
|
|
100223
101041
|
decision: fit.kind,
|
|
100224
101042
|
reason: fit.reason,
|
|
100225
101043
|
negotiatedMaxBitrateKbps: video.max_bit_rate,
|
|
100226
|
-
encodeBudgetKbps:
|
|
101044
|
+
encodeBudgetKbps: encodeBudget,
|
|
100227
101045
|
profile: pickedProfile,
|
|
100228
101046
|
brokerId: picked.brokerId,
|
|
100229
101047
|
slotPublishedKbps: slotEvidence?.publishedKbps ?? null,
|
|
@@ -100244,12 +101062,23 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
|
|
|
100244
101062
|
const audioLoopPort = session.audioLoopUdp.address().port;
|
|
100245
101063
|
const videoTarget = `rtp://127.0.0.1:${videoLoopPort}?pkt_size=${video.mtu}`;
|
|
100246
101064
|
const audioTarget = `rtp://127.0.0.1:${audioLoopPort}?pkt_size=${video.mtu}`;
|
|
101065
|
+
const selectedProfile = HAP_PROFILE_NAMES[video.profile] ?? "baseline";
|
|
101066
|
+
const selectedLevel = HAP_LEVEL_NAMES[video.level] ?? "3.1";
|
|
101067
|
+
ctx.logger.info("export-hap: encoder profile", {
|
|
101068
|
+
tags: { deviceId: numericDeviceId },
|
|
101069
|
+
meta: {
|
|
101070
|
+
selectedByController: `${selectedProfile}/${selectedLevel}`,
|
|
101071
|
+
transcode: needsTranscode
|
|
101072
|
+
}
|
|
101073
|
+
});
|
|
100247
101074
|
const videoPlan = buildVideoPlan({
|
|
100248
101075
|
transcode: needsTranscode,
|
|
100249
101076
|
width: video.width,
|
|
100250
101077
|
height: video.height,
|
|
100251
101078
|
fps: deliveredFps,
|
|
100252
|
-
budgetKbps:
|
|
101079
|
+
budgetKbps: encodeBudget,
|
|
101080
|
+
profile: selectedProfile,
|
|
101081
|
+
level: selectedLevel
|
|
100253
101082
|
});
|
|
100254
101083
|
const hwDecode = selectHwDecode({
|
|
100255
101084
|
transcode: needsTranscode,
|
|
@@ -100358,7 +101187,7 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
|
|
|
100358
101187
|
slotFpsSource: advertisedFpsSource,
|
|
100359
101188
|
deliveredFps,
|
|
100360
101189
|
fitReason: fit.reason,
|
|
100361
|
-
encodeBudgetKbps:
|
|
101190
|
+
encodeBudgetKbps: encodeBudget,
|
|
100362
101191
|
audioCodec: "opus",
|
|
100363
101192
|
audioBitrateKbps: OPUS_BITRATE_KBPS,
|
|
100364
101193
|
videoDecode: hwDecode.kind === "hardware" ? hwDecode.backend : "software"
|
|
@@ -100965,65 +101794,6 @@ function errMsg$8(err) {
|
|
|
100965
101794
|
return err instanceof Error ? err.message : String(err);
|
|
100966
101795
|
}
|
|
100967
101796
|
//#endregion
|
|
100968
|
-
//#region src/mappers/builders/stream-fps-probe.ts
|
|
100969
|
-
async function probeAdvertisedVideoProfile(bctx) {
|
|
100970
|
-
const { ctx, proxy, numericDeviceId, options } = bctx;
|
|
100971
|
-
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
100972
|
-
const entries = await probe(() => proxy.cameraStreams?.getProfileRtspEntries({}), "cameraStreams.getProfileRtspEntries", log);
|
|
100973
|
-
const slots = await probe(() => proxy.cameraStreams?.getBrokerStreams({}), "cameraStreams.getBrokerStreams", log);
|
|
100974
|
-
const camStreams = await probe(() => proxy.cameraStreams?.getCameraStreams({}), "cameraStreams.getCameraStreams", log);
|
|
100975
|
-
const choices = await probe(() => proxy.webrtcSession?.listStreams({}), "webrtcSession.listStreams", log);
|
|
100976
|
-
const fpsByProfile = resolveProfileFps({
|
|
100977
|
-
choices: choices ?? [],
|
|
100978
|
-
slots: slots ?? [],
|
|
100979
|
-
camStreams: camStreams ?? []
|
|
100980
|
-
});
|
|
100981
|
-
const resolutions = deriveAdvertisedResolutions({
|
|
100982
|
-
candidates: CANDIDATE_RESOLUTIONS,
|
|
100983
|
-
entries: entries ?? [],
|
|
100984
|
-
deviceId: numericDeviceId,
|
|
100985
|
-
pref: options.hapDeviceSettings.streamPreference,
|
|
100986
|
-
fpsByProfile
|
|
100987
|
-
});
|
|
100988
|
-
const assumed = resolutions.filter((r) => r.source === "assumed").length;
|
|
100989
|
-
log.info("export-hap: advertised video profile derived", { meta: {
|
|
100990
|
-
streamPreference: options.hapDeviceSettings.streamPreference,
|
|
100991
|
-
resolutions: formatAdvertisedResolutions(resolutions),
|
|
100992
|
-
profileFps: [...fpsByProfile.values()].map((f) => `${f.profile}=${f.fps}(${f.source})`),
|
|
100993
|
-
assumedCount: assumed,
|
|
100994
|
-
previouslyAdvertisedFps: 30
|
|
100995
|
-
} });
|
|
100996
|
-
if (assumed === resolutions.length) log.warn("export-hap: no measured or published frame rate for ANY profile — advertising the assumed rate", { meta: {
|
|
100997
|
-
entries: entries?.length ?? 0,
|
|
100998
|
-
choices: choices?.length ?? 0
|
|
100999
|
-
} });
|
|
101000
|
-
return {
|
|
101001
|
-
resolutions,
|
|
101002
|
-
fpsByProfile
|
|
101003
|
-
};
|
|
101004
|
-
}
|
|
101005
|
-
/**
|
|
101006
|
-
* Run one cap read. Returns null both when the cap is not bound and when the
|
|
101007
|
-
* call throws — and LOGS which, because "the camera has no telemetry" and "the
|
|
101008
|
-
* telemetry call failed" lead to different fixes.
|
|
101009
|
-
*/
|
|
101010
|
-
async function probe(call, label, log) {
|
|
101011
|
-
try {
|
|
101012
|
-
const pending = call();
|
|
101013
|
-
if (pending === void 0) {
|
|
101014
|
-
log.info("export-hap: fps probe skipped — cap not bound on this device", { meta: { call: label } });
|
|
101015
|
-
return null;
|
|
101016
|
-
}
|
|
101017
|
-
return await pending;
|
|
101018
|
-
} catch (err) {
|
|
101019
|
-
log.warn("export-hap: fps probe failed — falling back to a lower-authority source", { meta: {
|
|
101020
|
-
call: label,
|
|
101021
|
-
error: err instanceof Error ? err.message : String(err)
|
|
101022
|
-
} });
|
|
101023
|
-
return null;
|
|
101024
|
-
}
|
|
101025
|
-
}
|
|
101026
|
-
//#endregion
|
|
101027
101797
|
//#region src/hksv/controller-census.ts
|
|
101028
101798
|
var EMPTY_CENSUS = {
|
|
101029
101799
|
serverPublished: false,
|
|
@@ -102178,6 +102948,776 @@ function accessoryUuidFor(kind, deviceId) {
|
|
|
102178
102948
|
return import_dist.uuid.generate(kind === "camera" ? `camstack:camera:${deviceId}` : `camstack:device:${deviceId}`);
|
|
102179
102949
|
}
|
|
102180
102950
|
//#endregion
|
|
102951
|
+
//#region src/multi-tier/rtp-streaming-control.ts
|
|
102952
|
+
/**
|
|
102953
|
+
* `RTP Streaming Control` (0x8045) — start and stop, on the new service.
|
|
102954
|
+
*
|
|
102955
|
+
* It replaces the classic `Selected RTP Stream Configuration`, and it is a
|
|
102956
|
+
* different shape, not a variation. The classic one carries the whole selected
|
|
102957
|
+
* configuration inside the start: codec, profile, level, resolution, bitrate.
|
|
102958
|
+
* This one carries a **tier identifier**. The configuration was agreed when we
|
|
102959
|
+
* published `Supported Video Stream Tiers`; the controller now names one of
|
|
102960
|
+
* those entries.
|
|
102961
|
+
*
|
|
102962
|
+
* That is why the adapter maps an identifier back to the tier we advertised
|
|
102963
|
+
* instead of reading parameters off the write — and why a tier identifier we
|
|
102964
|
+
* never published is `No Such Stream` rather than something to improvise from.
|
|
102965
|
+
*
|
|
102966
|
+
* Bytes only. The engine lives elsewhere.
|
|
102967
|
+
*/
|
|
102968
|
+
var RtpStreamingCommand = /* @__PURE__ */ function(RtpStreamingCommand) {
|
|
102969
|
+
RtpStreamingCommand[RtpStreamingCommand["END"] = 1] = "END";
|
|
102970
|
+
RtpStreamingCommand[RtpStreamingCommand["START"] = 2] = "START";
|
|
102971
|
+
return RtpStreamingCommand;
|
|
102972
|
+
}({});
|
|
102973
|
+
var RtpStreamingStatus = /* @__PURE__ */ function(RtpStreamingStatus) {
|
|
102974
|
+
RtpStreamingStatus[RtpStreamingStatus["SUCCESS"] = 0] = "SUCCESS";
|
|
102975
|
+
RtpStreamingStatus[RtpStreamingStatus["UNKNOWN_SESSION_IDENTIFIER"] = 1] = "UNKNOWN_SESSION_IDENTIFIER";
|
|
102976
|
+
/** An End for a stream that is not started. */
|
|
102977
|
+
RtpStreamingStatus[RtpStreamingStatus["NO_SUCH_STREAM"] = 2] = "NO_SUCH_STREAM";
|
|
102978
|
+
RtpStreamingStatus[RtpStreamingStatus["BUSY"] = 3] = "BUSY";
|
|
102979
|
+
RtpStreamingStatus[RtpStreamingStatus["ERROR"] = 4] = "ERROR";
|
|
102980
|
+
return RtpStreamingStatus;
|
|
102981
|
+
}({});
|
|
102982
|
+
function required$1(map, type, what) {
|
|
102983
|
+
const value = map[String(type)];
|
|
102984
|
+
if (value === void 0) throw new Error(`rtp-streaming-control: ${what} is missing from the controller's write`);
|
|
102985
|
+
return value;
|
|
102986
|
+
}
|
|
102987
|
+
function optionalUint32(map, type) {
|
|
102988
|
+
const value = map[String(type)];
|
|
102989
|
+
return value === void 0 ? void 0 : value.readUInt32LE(0);
|
|
102990
|
+
}
|
|
102991
|
+
function decodeRtpStreamingControl(base64) {
|
|
102992
|
+
const map = (0, import_dist.decode)(Buffer.from(base64, "base64"));
|
|
102993
|
+
const command = required$1(map, 2, "the command").readUInt8(0);
|
|
102994
|
+
if (command !== 2 && command !== 1) throw new Error(`rtp-streaming-control: command ${command} is reserved or unknown`);
|
|
102995
|
+
return {
|
|
102996
|
+
sessionId: import_dist.uuid.unparse(required$1(map, 1, "the session identifier")),
|
|
102997
|
+
command,
|
|
102998
|
+
videoTier: optionalUint32(map, 3),
|
|
102999
|
+
videoSsrc: optionalUint32(map, 4),
|
|
103000
|
+
audioTier: optionalUint32(map, 5),
|
|
103001
|
+
audioSsrc: optionalUint32(map, 6)
|
|
103002
|
+
};
|
|
103003
|
+
}
|
|
103004
|
+
/**
|
|
103005
|
+
* The value the characteristic reads back with: the status of the last write.
|
|
103006
|
+
*
|
|
103007
|
+
* `sessionId` is null before any write has happened. The read still has to
|
|
103008
|
+
* answer — a characteristic that answers nothing is what put the kitchen camera
|
|
103009
|
+
* on "loading" — so it answers with a status and no session rather than with an
|
|
103010
|
+
* invented one.
|
|
103011
|
+
*/
|
|
103012
|
+
function encodeRtpStreamingControlStatus(sessionId, status) {
|
|
103013
|
+
if (sessionId === null) return (0, import_dist.encode)(2, Buffer.from([status])).toString("base64");
|
|
103014
|
+
return (0, import_dist.encode)(1, import_dist.uuid.write(sessionId), 2, Buffer.from([status])).toString("base64");
|
|
103015
|
+
}
|
|
103016
|
+
//#endregion
|
|
103017
|
+
//#region src/multi-tier/setup-endpoints.ts
|
|
103018
|
+
/**
|
|
103019
|
+
* The `Setup Endpoints` exchange, for the multi-tier RTP service.
|
|
103020
|
+
*
|
|
103021
|
+
* § 3.6 lists this characteristic among the service's required ones and does
|
|
103022
|
+
* NOT redefine it: it is R17's, byte for byte, and hap-nodejs already speaks it
|
|
103023
|
+
* on the classic service. The types below are transcribed from that
|
|
103024
|
+
* implementation rather than guessed, and the spec file decodes what this
|
|
103025
|
+
* encodes with the same library — a transcription error is otherwise entirely
|
|
103026
|
+
* plausible on review.
|
|
103027
|
+
*
|
|
103028
|
+
* Nothing here talks to a socket. The bytes are the part a test can hold still;
|
|
103029
|
+
* the adapter that drives the streaming engine is a separate file for exactly
|
|
103030
|
+
* that reason.
|
|
103031
|
+
*/
|
|
103032
|
+
var SetupEndpointsStatus = /* @__PURE__ */ function(SetupEndpointsStatus) {
|
|
103033
|
+
SetupEndpointsStatus[SetupEndpointsStatus["SUCCESS"] = 0] = "SUCCESS";
|
|
103034
|
+
SetupEndpointsStatus[SetupEndpointsStatus["BUSY"] = 1] = "BUSY";
|
|
103035
|
+
SetupEndpointsStatus[SetupEndpointsStatus["ERROR"] = 2] = "ERROR";
|
|
103036
|
+
return SetupEndpointsStatus;
|
|
103037
|
+
}({});
|
|
103038
|
+
/**
|
|
103039
|
+
* Read a TLV field that must be present, naming it when it is not.
|
|
103040
|
+
*
|
|
103041
|
+
* A controller's bytes really can be short, and the refusal we send back has to
|
|
103042
|
+
* carry a reason — "undefined is not an object", thrown three frames away in
|
|
103043
|
+
* the socket code, is not one.
|
|
103044
|
+
*/
|
|
103045
|
+
function required(map, type, what) {
|
|
103046
|
+
const value = map[String(type)];
|
|
103047
|
+
if (value === void 0) throw new Error(`setup-endpoints: ${what} is missing from the controller's write`);
|
|
103048
|
+
return value;
|
|
103049
|
+
}
|
|
103050
|
+
function uint16$1(value) {
|
|
103051
|
+
const b = Buffer.alloc(2);
|
|
103052
|
+
b.writeUInt16LE(value, 0);
|
|
103053
|
+
return b;
|
|
103054
|
+
}
|
|
103055
|
+
function uint32$2(value) {
|
|
103056
|
+
const b = Buffer.alloc(4);
|
|
103057
|
+
b.writeUInt32LE(value, 0);
|
|
103058
|
+
return b;
|
|
103059
|
+
}
|
|
103060
|
+
function decodeSrtp(payload, what) {
|
|
103061
|
+
const map = (0, import_dist.decode)(payload);
|
|
103062
|
+
return {
|
|
103063
|
+
cryptoSuite: required(map, 1, `${what} crypto suite`).readUInt8(0),
|
|
103064
|
+
key: required(map, 2, `${what} master key`),
|
|
103065
|
+
salt: required(map, 3, `${what} master salt`)
|
|
103066
|
+
};
|
|
103067
|
+
}
|
|
103068
|
+
function decodeSetupEndpointsRequest(base64) {
|
|
103069
|
+
const map = (0, import_dist.decode)(Buffer.from(base64, "base64"));
|
|
103070
|
+
const address = (0, import_dist.decode)(required(map, 3, "the controller address"));
|
|
103071
|
+
return {
|
|
103072
|
+
sessionId: import_dist.uuid.unparse(required(map, 1, "the session id")),
|
|
103073
|
+
addressVersion: required(address, 1, "the address version").readUInt8(0) === 0 ? "ipv4" : "ipv6",
|
|
103074
|
+
controllerAddress: required(address, 2, "the address").toString("utf8"),
|
|
103075
|
+
videoPort: required(address, 3, "the video port").readUInt16LE(0),
|
|
103076
|
+
audioPort: required(address, 4, "the audio port").readUInt16LE(0),
|
|
103077
|
+
videoSrtp: decodeSrtp(required(map, 4, "the video srtp parameters"), "video"),
|
|
103078
|
+
audioSrtp: decodeSrtp(required(map, 5, "the audio srtp parameters"), "audio")
|
|
103079
|
+
};
|
|
103080
|
+
}
|
|
103081
|
+
function encodeSetupEndpointsResponse(response) {
|
|
103082
|
+
const address = (0, import_dist.encode)(1, Buffer.from([response.addressVersion === "ipv4" ? 0 : 1]), 2, Buffer.from(response.accessoryAddress, "utf8"), 3, uint16$1(response.videoPort), 4, uint16$1(response.audioPort));
|
|
103083
|
+
const srtp = (params) => (0, import_dist.encode)(1, Buffer.from([0]), 2, params.key, 3, params.salt);
|
|
103084
|
+
return (0, import_dist.encode)(1, import_dist.uuid.write(response.sessionId), 2, Buffer.from([0]), 3, address, 4, srtp(response.videoSrtp), 5, srtp(response.audioSrtp), 6, uint32$2(response.videoSsrc), 7, uint32$2(response.audioSsrc)).toString("base64");
|
|
103085
|
+
}
|
|
103086
|
+
/**
|
|
103087
|
+
* A refusal: STATUS and nothing else, which is exactly what the classic
|
|
103088
|
+
* service writes. Not even the session id — matching it byte for byte means a
|
|
103089
|
+
* controller parses our refusal with the code it already has.
|
|
103090
|
+
*/
|
|
103091
|
+
function encodeSetupEndpointsRefusal(status) {
|
|
103092
|
+
return (0, import_dist.encode)(2, Buffer.from([status])).toString("base64");
|
|
103093
|
+
}
|
|
103094
|
+
//#endregion
|
|
103095
|
+
//#region src/multi-tier/audio-stream-tiers.ts
|
|
103096
|
+
/**
|
|
103097
|
+
* The `Supported Audio Stream Tiers` TLV8 — 0x8044, a REQUIRED characteristic
|
|
103098
|
+
* of `camera-multi-tier-rtp-stream-management`.
|
|
103099
|
+
*
|
|
103100
|
+
* ## Not the WebRTC one
|
|
103101
|
+
*
|
|
103102
|
+
* The WebRTC service has its own audio characteristic at 0x805A, and the two
|
|
103103
|
+
* are easy to confuse because they carry the same idea. This file is the RTP
|
|
103104
|
+
* one. A service that declares the wrong sibling is a service the controller
|
|
103105
|
+
* reads and abandons without saying why.
|
|
103106
|
+
*
|
|
103107
|
+
* ## What the revision fixes, and why that is encoded as a refusal
|
|
103108
|
+
*
|
|
103109
|
+
* Three of the six fields are not choices in this revision of the guide:
|
|
103110
|
+
* packet time is 20 ms, channels is 1, and **exactly one tier is allowed**.
|
|
103111
|
+
* The tier count is the one that cannot be expressed in a type, so it is
|
|
103112
|
+
* checked — a second tier would be silently dropped by a controller, or worse,
|
|
103113
|
+
* accepted and then used to select a stream we never meant to offer.
|
|
103114
|
+
*
|
|
103115
|
+
* Pure bytes in, pure bytes out, like its video sibling.
|
|
103116
|
+
*/
|
|
103117
|
+
/** Audio codec. Opus is the only value this revision defines, and the guide
|
|
103118
|
+
* requires the accessory to support it. */
|
|
103119
|
+
var TierAudioCodec = /* @__PURE__ */ function(TierAudioCodec) {
|
|
103120
|
+
TierAudioCodec[TierAudioCodec["OPUS"] = 3] = "OPUS";
|
|
103121
|
+
return TierAudioCodec;
|
|
103122
|
+
}({});
|
|
103123
|
+
function uint8$1(value) {
|
|
103124
|
+
const b = Buffer.alloc(1);
|
|
103125
|
+
b.writeUInt8(value, 0);
|
|
103126
|
+
return b;
|
|
103127
|
+
}
|
|
103128
|
+
function uint32$1(value) {
|
|
103129
|
+
const b = Buffer.alloc(4);
|
|
103130
|
+
b.writeUInt32LE(value, 0);
|
|
103131
|
+
return b;
|
|
103132
|
+
}
|
|
103133
|
+
/**
|
|
103134
|
+
* The one Opus tier we offer, from the rate the camera actually captures at.
|
|
103135
|
+
*
|
|
103136
|
+
* The capture rate is an input only so that the caller cannot forget it exists;
|
|
103137
|
+
* what goes on the wire is 48 kHz either way. Returning a tier rather than
|
|
103138
|
+
* bytes keeps the "what do we offer" decision separate from the encoding.
|
|
103139
|
+
*/
|
|
103140
|
+
function opusTierForCapture(captureSampleRateHz) {
|
|
103141
|
+
return {
|
|
103142
|
+
identifier: 1,
|
|
103143
|
+
targetAverageBitrateBps: captureSampleRateHz >= 24e3 ? 32e3 : 24e3,
|
|
103144
|
+
sampleRate: 4,
|
|
103145
|
+
bitDepth: 2,
|
|
103146
|
+
packetTimeMs: 20,
|
|
103147
|
+
channels: 1
|
|
103148
|
+
};
|
|
103149
|
+
}
|
|
103150
|
+
function encodeAudioStreamTier(tier) {
|
|
103151
|
+
return (0, import_dist.encode)(1, uint32$1(tier.identifier), 2, uint32$1(tier.targetAverageBitrateBps), 3, uint8$1(tier.sampleRate), 4, uint8$1(tier.bitDepth), 5, uint8$1(tier.packetTimeMs), 6, uint8$1(tier.channels));
|
|
103152
|
+
}
|
|
103153
|
+
function encodeAudioStreamConfiguration(config) {
|
|
103154
|
+
if (config.tiers.length !== 1) throw new Error(`supported-audio-stream-tiers allows exactly one tier in this revision, got ${config.tiers.length}`);
|
|
103155
|
+
return (0, import_dist.encode)(1, uint8$1(config.codec), 2, uint8$1(config.payloadType), 3, config.tiers.map(encodeAudioStreamTier));
|
|
103156
|
+
}
|
|
103157
|
+
//#endregion
|
|
103158
|
+
//#region src/multi-tier/camera-capabilities.ts
|
|
103159
|
+
/**
|
|
103160
|
+
* `Camera Capabilities` — the handshake that says "I speak the new camera
|
|
103161
|
+
* specification".
|
|
103162
|
+
*
|
|
103163
|
+
* The guide, § 4.5, is explicit: *"the presence of this service and
|
|
103164
|
+
* characteristic with versions as specified in this document is required to
|
|
103165
|
+
* signify that this camera supports the camera functionality described in this
|
|
103166
|
+
* specification."*
|
|
103167
|
+
*
|
|
103168
|
+
* That sentence is the diagnosis of this addon's first attempt. We declared
|
|
103169
|
+
* `camera-multi-tier-rtp-stream-management` and nothing else, and iOS read its
|
|
103170
|
+
* tiers and opened the session on the classic service two seconds later. It was
|
|
103171
|
+
* not refusing the tiers; it had never been told the accessory speaks the
|
|
103172
|
+
* specification the tiers belong to.
|
|
103173
|
+
*
|
|
103174
|
+
* ## Two different versions, and they are not interchangeable
|
|
103175
|
+
*
|
|
103176
|
+
* - The SERVICE (0x8010) carries `Version`, a **string**, whose value is the
|
|
103177
|
+
* specification revision: `"17.99"`.
|
|
103178
|
+
* - The CHARACTERISTIC (0x8011) carries `Version`, a **uint8**, which versions
|
|
103179
|
+
* the TLV layout below it.
|
|
103180
|
+
*
|
|
103181
|
+
* ## The sensor UUID
|
|
103182
|
+
*
|
|
103183
|
+
* `data` on the wire, and it must be stable across restarts: a controller
|
|
103184
|
+
* correlates a stream with it, and it appears in three places. Deriving it from
|
|
103185
|
+
* the device id makes it stable with nothing to persist and nothing to lose.
|
|
103186
|
+
*/
|
|
103187
|
+
/** Service and characteristic UUIDs, § 3.1 and § 4.5. */
|
|
103188
|
+
var CAMERA_CAPABILITIES_SERVICE_UUID = "00008010-0000-1000-8000-0026BB765291";
|
|
103189
|
+
var CHAR_CAMERA_CAPABILITIES = "00008011-0000-1000-8000-0026BB765291";
|
|
103190
|
+
/**
|
|
103191
|
+
* The specification revision, as the SERVICE's `Version` string.
|
|
103192
|
+
*
|
|
103193
|
+
* The guide notes this "may be updated prior to release". When it is, this is
|
|
103194
|
+
* the one place to change — and a controller that wants a newer revision will
|
|
103195
|
+
* decline rather than misbehave, which is the failure we can see.
|
|
103196
|
+
*/
|
|
103197
|
+
var CAMERA_CAPABILITIES_SPEC_VERSION = "17.99";
|
|
103198
|
+
var SensorType = /* @__PURE__ */ function(SensorType) {
|
|
103199
|
+
SensorType[SensorType["UNKNOWN"] = 0] = "UNKNOWN";
|
|
103200
|
+
SensorType[SensorType["PRIMARY"] = 1] = "PRIMARY";
|
|
103201
|
+
SensorType[SensorType["GENERIC"] = 255] = "GENERIC";
|
|
103202
|
+
return SensorType;
|
|
103203
|
+
}({});
|
|
103204
|
+
var SensorIntent = /* @__PURE__ */ function(SensorIntent) {
|
|
103205
|
+
SensorIntent[SensorIntent["UNKNOWN"] = 0] = "UNKNOWN";
|
|
103206
|
+
SensorIntent[SensorIntent["MAIN"] = 1] = "MAIN";
|
|
103207
|
+
SensorIntent[SensorIntent["PACKAGE"] = 2] = "PACKAGE";
|
|
103208
|
+
SensorIntent[SensorIntent["GENERIC"] = 255] = "GENERIC";
|
|
103209
|
+
return SensorIntent;
|
|
103210
|
+
}({});
|
|
103211
|
+
function uint8(value) {
|
|
103212
|
+
const b = Buffer.alloc(1);
|
|
103213
|
+
b.writeUInt8(value, 0);
|
|
103214
|
+
return b;
|
|
103215
|
+
}
|
|
103216
|
+
function uint16(value) {
|
|
103217
|
+
const b = Buffer.alloc(2);
|
|
103218
|
+
b.writeUInt16LE(value, 0);
|
|
103219
|
+
return b;
|
|
103220
|
+
}
|
|
103221
|
+
function uint32(value) {
|
|
103222
|
+
const b = Buffer.alloc(4);
|
|
103223
|
+
b.writeUInt32LE(value, 0);
|
|
103224
|
+
return b;
|
|
103225
|
+
}
|
|
103226
|
+
/**
|
|
103227
|
+
* A stable 16-byte sensor UUID for a device.
|
|
103228
|
+
*
|
|
103229
|
+
* Hashed rather than random so that it survives a restart without being
|
|
103230
|
+
* persisted — a value we would have to store is a value that can go missing
|
|
103231
|
+
* while the accessory that references it does not.
|
|
103232
|
+
*/
|
|
103233
|
+
function sensorUuidForDevice(deviceId) {
|
|
103234
|
+
return (0, node_crypto.createHash)("sha256").update(`camstack:hap:sensor:${deviceId}`).digest().subarray(0, 16);
|
|
103235
|
+
}
|
|
103236
|
+
function encodeVideoStreamCapability(capability) {
|
|
103237
|
+
return (0, import_dist.encode)(1, capability.identifier, 2, uint8(capability.quality), 3, uint16(capability.width), 4, uint16(capability.height), 5, uint8(capability.framesPerSecond), 6, uint32(capability.averageBitrateKbps), 7, uint32(capability.peakBitrateKbps));
|
|
103238
|
+
}
|
|
103239
|
+
function encodeSensorConfiguration(sensor) {
|
|
103240
|
+
return (0, import_dist.encode)(1, (0, import_dist.encode)(1, uint16(sensor.width), 2, uint16(sensor.height)), 2, sensor.sensorUuid, 3, uint8(sensor.type), 4, uint8(sensor.intent), 5, sensor.videoStreams.map(encodeVideoStreamCapability));
|
|
103241
|
+
}
|
|
103242
|
+
function encodeCameraCapabilities(sensors) {
|
|
103243
|
+
return (0, import_dist.encode)(1, uint8(1), 2, (0, import_dist.encode)(1, sensors.map(encodeSensorConfiguration)));
|
|
103244
|
+
}
|
|
103245
|
+
//#endregion
|
|
103246
|
+
//#region src/multi-tier/multi-tier-service.ts
|
|
103247
|
+
/**
|
|
103248
|
+
* The three services Apple's new camera specification requires, built together
|
|
103249
|
+
* because none of them means anything alone.
|
|
103250
|
+
*
|
|
103251
|
+
* ## What the first attempt got wrong
|
|
103252
|
+
*
|
|
103253
|
+
* It declared `camera-multi-tier-rtp-stream-management` (0x8031) with two of
|
|
103254
|
+
* its eight required characteristics, and nothing else. Measured on the hub,
|
|
103255
|
+
* 2026-09-16: at 22:53:21 iOS read the tiers, at 22:53:23 it opened the session
|
|
103256
|
+
* on the CLASSIC service. That was read as "it engages and declines".
|
|
103257
|
+
*
|
|
103258
|
+
* Reading the guide closes the question differently. § 4.5: *"the presence of
|
|
103259
|
+
* this service and characteristic with versions as specified in this document
|
|
103260
|
+
* is required to signify that this camera supports the camera functionality
|
|
103261
|
+
* described in this specification."* `Camera Capabilities` (0x8010) is the
|
|
103262
|
+
* handshake, and we never sent it. iOS was not declining our tiers — it had no
|
|
103263
|
+
* reason to believe the accessory spoke the specification they belong to.
|
|
103264
|
+
*
|
|
103265
|
+
* So this builds all three:
|
|
103266
|
+
*
|
|
103267
|
+
* - **Camera Capabilities** (0x8010) — the claim, with `Version = "17.99"`.
|
|
103268
|
+
* - **Camera Global Operating Mode** (0x8032) — the master switches, which
|
|
103269
|
+
* `Status Active` on the streaming service is defined in terms of.
|
|
103270
|
+
* - **Camera Multi-Tier RTP Stream Management** (0x8031) — all eight required
|
|
103271
|
+
* characteristics.
|
|
103272
|
+
*
|
|
103273
|
+
* ## Why it is honest to ship this before the negotiation exists
|
|
103274
|
+
*
|
|
103275
|
+
* The guide defines the state we are in. § 3.6: *"Status Active must be set to
|
|
103276
|
+
* false if ... Streaming Enabled on this service is false"*, and *"the
|
|
103277
|
+
* accessory must reject any request to start a stream if Status Active is set
|
|
103278
|
+
* to false."* An accessory that cannot serve a session yet is not a
|
|
103279
|
+
* half-written accessory — it is a conformant one with streaming off, and it
|
|
103280
|
+
* says so in the field designed to say it.
|
|
103281
|
+
*
|
|
103282
|
+
* What is NOT allowed is declaring a characteristic we cannot answer. An empty
|
|
103283
|
+
* string is not a valid TLV8, and the Home app reads a camera's characteristics
|
|
103284
|
+
* in ONE batch to draw it — one malformed member took the kitchen camera's
|
|
103285
|
+
* whole read down and left it on "loading". Every characteristic here answers
|
|
103286
|
+
* with a real value, and the two that would negotiate a session answer with the
|
|
103287
|
+
* specification's own explicit refusal.
|
|
103288
|
+
*/
|
|
103289
|
+
/** Service UUIDs, § 3.2 and § 3.6. */
|
|
103290
|
+
var MULTI_TIER_RTP_SERVICE_UUID = "00008031-0000-1000-8000-0026BB765291";
|
|
103291
|
+
var GLOBAL_OPERATING_MODE_SERVICE_UUID = "00008032-0000-1000-8000-0026BB765291";
|
|
103292
|
+
/**
|
|
103293
|
+
* Characteristic UUIDs this specification adds.
|
|
103294
|
+
*
|
|
103295
|
+
* The RTP service and the WebRTC service each have their OWN tier
|
|
103296
|
+
* characteristics and they are not interchangeable — video is 0x8043 here and
|
|
103297
|
+
* 0x8059 there, audio is 0x8044 here and 0x805A there. Declaring a sibling's
|
|
103298
|
+
* UUID produces a service a controller reads and abandons with no error.
|
|
103299
|
+
*/
|
|
103300
|
+
var CHAR_RTP_SUPPORTED_VIDEO_STREAM_TIERS = "00008043-0000-1000-8000-0026BB765291";
|
|
103301
|
+
var CHAR_RTP_SUPPORTED_AUDIO_STREAM_TIERS = "00008044-0000-1000-8000-0026BB765291";
|
|
103302
|
+
var CHAR_RTP_STREAMING_CONTROL = "00008045-0000-1000-8000-0026BB765291";
|
|
103303
|
+
var CHAR_STREAMING_ENABLED = "00008041-0000-1000-8000-0026BB765291";
|
|
103304
|
+
var CHAR_SENSOR_UUID = "0000805B-0000-1000-8000-0026BB765291";
|
|
103305
|
+
/** Supported RTP Configuration, § 3.6: "must be set to AES_CM_128_HMAC_SHA1_80". */
|
|
103306
|
+
var SRTP_CRYPTO_SUITE_TYPE = 2;
|
|
103307
|
+
var AES_CM_128_HMAC_SHA1_80 = 0;
|
|
103308
|
+
function tlvCharacteristic(uuid, name, writable) {
|
|
103309
|
+
return new import_dist.Characteristic(name, uuid, {
|
|
103310
|
+
format: import_dist.Formats.TLV8,
|
|
103311
|
+
perms: writable ? [
|
|
103312
|
+
import_dist.Perms.PAIRED_READ,
|
|
103313
|
+
import_dist.Perms.PAIRED_WRITE,
|
|
103314
|
+
import_dist.Perms.NOTIFY
|
|
103315
|
+
] : [import_dist.Perms.PAIRED_READ, import_dist.Perms.NOTIFY]
|
|
103316
|
+
});
|
|
103317
|
+
}
|
|
103318
|
+
/** The video configurations advertised in Camera Capabilities, from the tiers
|
|
103319
|
+
* we would actually offer — one source, so the two cannot drift. */
|
|
103320
|
+
function capabilitiesFromTiers(tiers, deviceId) {
|
|
103321
|
+
return tiers.map((tier) => ({
|
|
103322
|
+
identifier: sensorUuidForDevice(deviceId * 1e3 + tier.identifier),
|
|
103323
|
+
quality: tier.quality,
|
|
103324
|
+
width: tier.width,
|
|
103325
|
+
height: tier.height,
|
|
103326
|
+
framesPerSecond: tier.frameRate,
|
|
103327
|
+
averageBitrateKbps: tier.targetAverageBitrateKbps,
|
|
103328
|
+
peakBitrateKbps: peakBitrateKbpsFor(tier.width)
|
|
103329
|
+
}));
|
|
103330
|
+
}
|
|
103331
|
+
function buildMultiTierServices(input) {
|
|
103332
|
+
const { deviceId, logger } = input;
|
|
103333
|
+
const sensorUuid = sensorUuidForDevice(deviceId);
|
|
103334
|
+
const tiers = tiersForSensor(input.width, input.height);
|
|
103335
|
+
const note = (what) => {
|
|
103336
|
+
logger.info("hap multi-tier: controller touched a characteristic", {
|
|
103337
|
+
tags: { deviceId },
|
|
103338
|
+
meta: { characteristic: what }
|
|
103339
|
+
});
|
|
103340
|
+
};
|
|
103341
|
+
const capabilities = new import_dist.Service("Camera Capabilities", CAMERA_CAPABILITIES_SERVICE_UUID, "camera-capabilities");
|
|
103342
|
+
const version = new import_dist.Characteristic.Version();
|
|
103343
|
+
version.onGet(() => {
|
|
103344
|
+
note("version");
|
|
103345
|
+
return CAMERA_CAPABILITIES_SPEC_VERSION;
|
|
103346
|
+
});
|
|
103347
|
+
capabilities.addCharacteristic(version);
|
|
103348
|
+
const capabilitiesValue = encodeCameraCapabilities([{
|
|
103349
|
+
sensorUuid,
|
|
103350
|
+
type: SensorType.PRIMARY,
|
|
103351
|
+
intent: SensorIntent.MAIN,
|
|
103352
|
+
width: input.width,
|
|
103353
|
+
height: input.height,
|
|
103354
|
+
videoStreams: capabilitiesFromTiers(tiers, deviceId)
|
|
103355
|
+
}]).toString("base64");
|
|
103356
|
+
const capabilitiesChar = tlvCharacteristic(CHAR_CAMERA_CAPABILITIES, "Camera Capabilities", false);
|
|
103357
|
+
capabilitiesChar.onGet(() => {
|
|
103358
|
+
note("camera-capabilities");
|
|
103359
|
+
return capabilitiesValue;
|
|
103360
|
+
});
|
|
103361
|
+
capabilities.addCharacteristic(capabilitiesChar);
|
|
103362
|
+
const globalOperatingMode = new import_dist.Service("Camera Operating Mode", GLOBAL_OPERATING_MODE_SERVICE_UUID, "global-operating-mode");
|
|
103363
|
+
let homeKitCameraActive = true;
|
|
103364
|
+
const cameraActive = new import_dist.Characteristic.HomeKitCameraActive();
|
|
103365
|
+
cameraActive.onGet(() => {
|
|
103366
|
+
note("homekit-camera-active");
|
|
103367
|
+
return homeKitCameraActive ? 1 : 0;
|
|
103368
|
+
});
|
|
103369
|
+
cameraActive.onSet((value) => {
|
|
103370
|
+
homeKitCameraActive = value === 1 || value === true;
|
|
103371
|
+
});
|
|
103372
|
+
globalOperatingMode.addCharacteristic(cameraActive);
|
|
103373
|
+
let globalStreamingEnabled = true;
|
|
103374
|
+
const globalStreaming = new import_dist.Characteristic("Streaming Enabled", CHAR_STREAMING_ENABLED, {
|
|
103375
|
+
format: import_dist.Formats.BOOL,
|
|
103376
|
+
perms: [
|
|
103377
|
+
import_dist.Perms.PAIRED_READ,
|
|
103378
|
+
import_dist.Perms.PAIRED_WRITE,
|
|
103379
|
+
import_dist.Perms.NOTIFY
|
|
103380
|
+
]
|
|
103381
|
+
});
|
|
103382
|
+
globalStreaming.onGet(() => globalStreamingEnabled);
|
|
103383
|
+
globalStreaming.onSet((value) => {
|
|
103384
|
+
globalStreamingEnabled = value === true;
|
|
103385
|
+
});
|
|
103386
|
+
globalOperatingMode.addCharacteristic(globalStreaming);
|
|
103387
|
+
const indicator = new import_dist.Characteristic.CameraOperatingModeIndicator();
|
|
103388
|
+
indicator.onGet(() => 0);
|
|
103389
|
+
globalOperatingMode.addCharacteristic(indicator);
|
|
103390
|
+
const streamManagement = new import_dist.Service("CamStack multi tier", MULTI_TIER_RTP_SERVICE_UUID, "multi-tier");
|
|
103391
|
+
let serviceStreamingEnabled = true;
|
|
103392
|
+
const streamingEnabled = new import_dist.Characteristic("Streaming Enabled", CHAR_STREAMING_ENABLED, {
|
|
103393
|
+
format: import_dist.Formats.BOOL,
|
|
103394
|
+
perms: [
|
|
103395
|
+
import_dist.Perms.PAIRED_READ,
|
|
103396
|
+
import_dist.Perms.PAIRED_WRITE,
|
|
103397
|
+
import_dist.Perms.NOTIFY
|
|
103398
|
+
]
|
|
103399
|
+
});
|
|
103400
|
+
streamingEnabled.onGet(() => {
|
|
103401
|
+
note("streaming-enabled");
|
|
103402
|
+
return serviceStreamingEnabled;
|
|
103403
|
+
});
|
|
103404
|
+
streamingEnabled.onSet((value) => {
|
|
103405
|
+
serviceStreamingEnabled = value === true;
|
|
103406
|
+
});
|
|
103407
|
+
streamManagement.addCharacteristic(streamingEnabled);
|
|
103408
|
+
/**
|
|
103409
|
+
* § 3.6's rule, written once and read everywhere it matters.
|
|
103410
|
+
*
|
|
103411
|
+
* It is deliberately an AND over four terms rather than a stored flag: a
|
|
103412
|
+
* stored one would have to be invalidated from four places, and the one that
|
|
103413
|
+
* got forgotten would leave the accessory claiming it can stream.
|
|
103414
|
+
*/
|
|
103415
|
+
const statusActive = () => serviceStreamingEnabled && globalStreamingEnabled && homeKitCameraActive && input.negotiation !== void 0;
|
|
103416
|
+
const active = new import_dist.Characteristic.StatusActive();
|
|
103417
|
+
active.onGet(() => {
|
|
103418
|
+
const value = statusActive();
|
|
103419
|
+
logger.info("hap multi-tier: status active read", {
|
|
103420
|
+
tags: { deviceId },
|
|
103421
|
+
meta: {
|
|
103422
|
+
value,
|
|
103423
|
+
serviceStreamingEnabled,
|
|
103424
|
+
globalStreamingEnabled,
|
|
103425
|
+
homeKitCameraActive,
|
|
103426
|
+
hasNegotiator: input.negotiation !== void 0
|
|
103427
|
+
}
|
|
103428
|
+
});
|
|
103429
|
+
return value;
|
|
103430
|
+
});
|
|
103431
|
+
streamManagement.addCharacteristic(active);
|
|
103432
|
+
const videoTiersValue = encodeVideoStreamConfiguration({
|
|
103433
|
+
codec: input.codec,
|
|
103434
|
+
payloadType: 96,
|
|
103435
|
+
tiers
|
|
103436
|
+
}).toString("base64");
|
|
103437
|
+
const videoTiers = tlvCharacteristic(CHAR_RTP_SUPPORTED_VIDEO_STREAM_TIERS, "Supported Video Stream Tiers", false);
|
|
103438
|
+
videoTiers.onGet(() => {
|
|
103439
|
+
note("supported-video-stream-tiers");
|
|
103440
|
+
return videoTiersValue;
|
|
103441
|
+
});
|
|
103442
|
+
streamManagement.addCharacteristic(videoTiers);
|
|
103443
|
+
const audioTiersValue = encodeAudioStreamConfiguration({
|
|
103444
|
+
codec: TierAudioCodec.OPUS,
|
|
103445
|
+
payloadType: 110,
|
|
103446
|
+
tiers: [opusTierForCapture(input.audioCaptureRateHz)]
|
|
103447
|
+
}).toString("base64");
|
|
103448
|
+
const audioTiers = tlvCharacteristic(CHAR_RTP_SUPPORTED_AUDIO_STREAM_TIERS, "Supported Audio Stream Tiers", false);
|
|
103449
|
+
audioTiers.onGet(() => {
|
|
103450
|
+
note("supported-audio-stream-tiers");
|
|
103451
|
+
return audioTiersValue;
|
|
103452
|
+
});
|
|
103453
|
+
streamManagement.addCharacteristic(audioTiers);
|
|
103454
|
+
const rtpConfiguration = new import_dist.Characteristic.SupportedRTPConfiguration();
|
|
103455
|
+
const rtpConfigurationValue = (0, import_dist.encode)(SRTP_CRYPTO_SUITE_TYPE, Buffer.from([AES_CM_128_HMAC_SHA1_80])).toString("base64");
|
|
103456
|
+
rtpConfiguration.onGet(() => {
|
|
103457
|
+
note("supported-rtp-configuration");
|
|
103458
|
+
return rtpConfigurationValue;
|
|
103459
|
+
});
|
|
103460
|
+
streamManagement.addCharacteristic(rtpConfiguration);
|
|
103461
|
+
const setupEndpoints = new import_dist.Characteristic.SetupEndpoints();
|
|
103462
|
+
setupEndpoints.onGet(() => {
|
|
103463
|
+
note("setup-endpoints");
|
|
103464
|
+
return input.negotiation?.readSetupEndpoints() ?? encodeSetupEndpointsRefusal(SetupEndpointsStatus.ERROR);
|
|
103465
|
+
});
|
|
103466
|
+
setupEndpoints.onSet(async (value) => {
|
|
103467
|
+
if (input.negotiation === void 0 || typeof value !== "string") {
|
|
103468
|
+
logger.info("hap multi-tier: refused a setup-endpoints write", {
|
|
103469
|
+
tags: { deviceId },
|
|
103470
|
+
meta: { reason: input.negotiation === void 0 ? "no-negotiator" : "not-a-string" }
|
|
103471
|
+
});
|
|
103472
|
+
return;
|
|
103473
|
+
}
|
|
103474
|
+
await input.negotiation.handleSetupEndpointsWrite(value);
|
|
103475
|
+
});
|
|
103476
|
+
streamManagement.addCharacteristic(setupEndpoints);
|
|
103477
|
+
const streamingControl = new import_dist.Characteristic("RTP Streaming Control", CHAR_RTP_STREAMING_CONTROL, {
|
|
103478
|
+
format: import_dist.Formats.TLV8,
|
|
103479
|
+
perms: [import_dist.Perms.PAIRED_READ, import_dist.Perms.PAIRED_WRITE]
|
|
103480
|
+
});
|
|
103481
|
+
streamingControl.onGet(() => {
|
|
103482
|
+
note("rtp-streaming-control");
|
|
103483
|
+
return input.negotiation?.readStreamingControl() ?? encodeRtpStreamingControlStatus(null, RtpStreamingStatus.ERROR);
|
|
103484
|
+
});
|
|
103485
|
+
streamingControl.onSet(async (value) => {
|
|
103486
|
+
if (input.negotiation === void 0 || typeof value !== "string") {
|
|
103487
|
+
logger.info("hap multi-tier: refused an rtp streaming control write", {
|
|
103488
|
+
tags: { deviceId },
|
|
103489
|
+
meta: { reason: input.negotiation === void 0 ? "no-negotiator" : "not-a-string" }
|
|
103490
|
+
});
|
|
103491
|
+
return;
|
|
103492
|
+
}
|
|
103493
|
+
await input.negotiation.handleStreamingControlWrite(value);
|
|
103494
|
+
});
|
|
103495
|
+
streamManagement.addCharacteristic(streamingControl);
|
|
103496
|
+
const sensor = new import_dist.Characteristic("Sensor UUID", CHAR_SENSOR_UUID, {
|
|
103497
|
+
format: import_dist.Formats.DATA,
|
|
103498
|
+
perms: [import_dist.Perms.PAIRED_READ]
|
|
103499
|
+
});
|
|
103500
|
+
const sensorValue = sensorUuid.toString("base64");
|
|
103501
|
+
sensor.onGet(() => {
|
|
103502
|
+
note("sensor-uuid");
|
|
103503
|
+
return sensorValue;
|
|
103504
|
+
});
|
|
103505
|
+
streamManagement.addCharacteristic(sensor);
|
|
103506
|
+
return {
|
|
103507
|
+
capabilities,
|
|
103508
|
+
globalOperatingMode,
|
|
103509
|
+
streamManagement
|
|
103510
|
+
};
|
|
103511
|
+
}
|
|
103512
|
+
//#endregion
|
|
103513
|
+
//#region src/multi-tier/negotiation.ts
|
|
103514
|
+
/**
|
|
103515
|
+
* The adapter between Apple's new characteristics and the streaming engine this
|
|
103516
|
+
* addon already has.
|
|
103517
|
+
*
|
|
103518
|
+
* ## Why an adapter and not a second engine
|
|
103519
|
+
*
|
|
103520
|
+
* The multi-tier RTP service owns its OWN `Setup Endpoints` and `RTP Streaming
|
|
103521
|
+
* Control`, so hap-nodejs's `CameraController` never negotiates for it — and
|
|
103522
|
+
* the library's own handlers are all `private`, so its engine cannot be
|
|
103523
|
+
* borrowed either. But the sockets, the SRTP contexts, the ffmpeg pipeline and
|
|
103524
|
+
* the teardown are the same ones the classic service uses. A second copy would
|
|
103525
|
+
* be two engines to keep in agreement, and this repo has paid for that shape
|
|
103526
|
+
* before. So: new characteristics, one engine, through `StreamNegotiator`.
|
|
103527
|
+
*
|
|
103528
|
+
* ## What is different about the new flow
|
|
103529
|
+
*
|
|
103530
|
+
* The classic `Selected RTP Stream Configuration` carries the whole selected
|
|
103531
|
+
* configuration in its start — codec, profile, level, resolution, bitrate.
|
|
103532
|
+
* `RTP Streaming Control` carries a **tier identifier**, because the
|
|
103533
|
+
* configuration was agreed when we published `Supported Video Stream Tiers`.
|
|
103534
|
+
* So this maps an identifier back to the tier WE published, and a tier we never
|
|
103535
|
+
* published is `No Such Stream` — improvising a geometry there would serve a
|
|
103536
|
+
* stream the controller did not ask for and it would look like it worked.
|
|
103537
|
+
*
|
|
103538
|
+
* ## Every path answers
|
|
103539
|
+
*
|
|
103540
|
+
* A read of either characteristic always produces a valid TLV8. The Home app
|
|
103541
|
+
* reads a camera's characteristics in one batch, and one member that answers
|
|
103542
|
+
* nothing took the kitchen camera's whole read down and left it on "loading".
|
|
103543
|
+
* So an engine failure, a malformed write and "nothing has happened yet" are
|
|
103544
|
+
* all statuses, never silence.
|
|
103545
|
+
*/
|
|
103546
|
+
/** RTP payload types. 96 is the first dynamic one (RFC 3551); 110 is what our
|
|
103547
|
+
* own Opus offers use. Both are echoed in the tier declaration. */
|
|
103548
|
+
var VIDEO_PAYLOAD_TYPE = 96;
|
|
103549
|
+
var AUDIO_PAYLOAD_TYPE = 110;
|
|
103550
|
+
/** RTCP interval in SECONDS, which is the unit the engine reads. */
|
|
103551
|
+
var RTCP_INTERVAL_SECONDS = 5;
|
|
103552
|
+
/** The MTU the classic path uses for an Ethernet-sized path. */
|
|
103553
|
+
var DEFAULT_MTU = 1378;
|
|
103554
|
+
function errorMessage(error) {
|
|
103555
|
+
return error instanceof Error ? error.message : String(error);
|
|
103556
|
+
}
|
|
103557
|
+
function buildMultiTierNegotiation(input) {
|
|
103558
|
+
const { deviceId, logger, negotiator } = input;
|
|
103559
|
+
const log = logger.withTags({ deviceId });
|
|
103560
|
+
let setupResponse = encodeSetupEndpointsRefusal(SetupEndpointsStatus.ERROR);
|
|
103561
|
+
let controlResponse = encodeRtpStreamingControlStatus(null, RtpStreamingStatus.ERROR);
|
|
103562
|
+
/** Sessions we have STARTED, so an End can tell "not started" from "unknown". */
|
|
103563
|
+
const started = /* @__PURE__ */ new Set();
|
|
103564
|
+
async function handleSetupEndpointsWrite(base64) {
|
|
103565
|
+
let sessionId = null;
|
|
103566
|
+
try {
|
|
103567
|
+
const request = decodeSetupEndpointsRequest(base64);
|
|
103568
|
+
sessionId = request.sessionId;
|
|
103569
|
+
const prepareRequest = {
|
|
103570
|
+
sessionID: request.sessionId,
|
|
103571
|
+
sourceAddress: request.controllerAddress,
|
|
103572
|
+
targetAddress: request.controllerAddress,
|
|
103573
|
+
addressVersion: request.addressVersion,
|
|
103574
|
+
video: {
|
|
103575
|
+
port: request.videoPort,
|
|
103576
|
+
srtpCryptoSuite: import_dist.SRTPCryptoSuites.AES_CM_128_HMAC_SHA1_80,
|
|
103577
|
+
srtp_key: request.videoSrtp.key,
|
|
103578
|
+
srtp_salt: request.videoSrtp.salt
|
|
103579
|
+
},
|
|
103580
|
+
audio: {
|
|
103581
|
+
port: request.audioPort,
|
|
103582
|
+
srtpCryptoSuite: import_dist.SRTPCryptoSuites.AES_CM_128_HMAC_SHA1_80,
|
|
103583
|
+
srtp_key: request.audioSrtp.key,
|
|
103584
|
+
srtp_salt: request.audioSrtp.salt
|
|
103585
|
+
}
|
|
103586
|
+
};
|
|
103587
|
+
const prepared = await negotiator.prepare(prepareRequest);
|
|
103588
|
+
setupResponse = encodeSetupEndpointsResponse({
|
|
103589
|
+
sessionId: request.sessionId,
|
|
103590
|
+
addressVersion: request.addressVersion,
|
|
103591
|
+
accessoryAddress: prepared.localAddress,
|
|
103592
|
+
videoPort: prepared.videoPort,
|
|
103593
|
+
audioPort: prepared.audioPort,
|
|
103594
|
+
videoSrtp: {
|
|
103595
|
+
key: request.videoSrtp.key,
|
|
103596
|
+
salt: request.videoSrtp.salt
|
|
103597
|
+
},
|
|
103598
|
+
audioSrtp: {
|
|
103599
|
+
key: request.audioSrtp.key,
|
|
103600
|
+
salt: request.audioSrtp.salt
|
|
103601
|
+
},
|
|
103602
|
+
videoSsrc: prepared.videoSsrc,
|
|
103603
|
+
audioSsrc: prepared.audioSsrc
|
|
103604
|
+
});
|
|
103605
|
+
log.info("hap multi-tier: endpoints prepared", { meta: {
|
|
103606
|
+
sessionId: request.sessionId,
|
|
103607
|
+
controller: `${request.controllerAddress}:${request.videoPort}/${request.audioPort}`,
|
|
103608
|
+
accessory: `${prepared.localAddress}:${prepared.videoPort}/${prepared.audioPort}`,
|
|
103609
|
+
videoSsrc: prepared.videoSsrc,
|
|
103610
|
+
audioSsrc: prepared.audioSsrc
|
|
103611
|
+
} });
|
|
103612
|
+
} catch (error) {
|
|
103613
|
+
setupResponse = encodeSetupEndpointsRefusal(SetupEndpointsStatus.ERROR);
|
|
103614
|
+
log.warn("hap multi-tier: setup endpoints refused", { meta: {
|
|
103615
|
+
sessionId,
|
|
103616
|
+
error: errorMessage(error)
|
|
103617
|
+
} });
|
|
103618
|
+
}
|
|
103619
|
+
}
|
|
103620
|
+
async function handleStreamingControlWrite(base64) {
|
|
103621
|
+
let sessionId = null;
|
|
103622
|
+
try {
|
|
103623
|
+
const command = decodeRtpStreamingControl(base64);
|
|
103624
|
+
sessionId = command.sessionId;
|
|
103625
|
+
if (!negotiator.has(command.sessionId)) {
|
|
103626
|
+
controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.UNKNOWN_SESSION_IDENTIFIER);
|
|
103627
|
+
log.warn("hap multi-tier: control write for a session that was never prepared", { meta: {
|
|
103628
|
+
sessionId: command.sessionId,
|
|
103629
|
+
command: command.command
|
|
103630
|
+
} });
|
|
103631
|
+
return;
|
|
103632
|
+
}
|
|
103633
|
+
if (command.command === RtpStreamingCommand.END) {
|
|
103634
|
+
if (!started.has(command.sessionId)) {
|
|
103635
|
+
controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.NO_SUCH_STREAM);
|
|
103636
|
+
return;
|
|
103637
|
+
}
|
|
103638
|
+
const stop = {
|
|
103639
|
+
sessionID: command.sessionId,
|
|
103640
|
+
type: import_dist.StreamRequestTypes.STOP
|
|
103641
|
+
};
|
|
103642
|
+
await negotiator.control(stop);
|
|
103643
|
+
started.delete(command.sessionId);
|
|
103644
|
+
controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.SUCCESS);
|
|
103645
|
+
log.info("hap multi-tier: stream ended", { meta: { sessionId: command.sessionId } });
|
|
103646
|
+
return;
|
|
103647
|
+
}
|
|
103648
|
+
const tier = input.tiers.find((t) => t.identifier === command.videoTier);
|
|
103649
|
+
if (tier === void 0) {
|
|
103650
|
+
controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.NO_SUCH_STREAM);
|
|
103651
|
+
log.warn("hap multi-tier: start named a tier we never published", { meta: {
|
|
103652
|
+
sessionId: command.sessionId,
|
|
103653
|
+
requested: command.videoTier,
|
|
103654
|
+
published: input.tiers.map((t) => t.identifier)
|
|
103655
|
+
} });
|
|
103656
|
+
return;
|
|
103657
|
+
}
|
|
103658
|
+
log.info("hap multi-tier: start ssrc comparison", { meta: {
|
|
103659
|
+
sessionId: command.sessionId,
|
|
103660
|
+
controllerVideoSsrc: command.videoSsrc ?? null,
|
|
103661
|
+
controllerAudioSsrc: command.audioSsrc ?? null,
|
|
103662
|
+
note: "accessory keeps the ssrcs it advertised at setup"
|
|
103663
|
+
} });
|
|
103664
|
+
const start = {
|
|
103665
|
+
sessionID: command.sessionId,
|
|
103666
|
+
type: import_dist.StreamRequestTypes.START,
|
|
103667
|
+
video: {
|
|
103668
|
+
codec: import_dist.VideoCodecType.H264,
|
|
103669
|
+
profile: import_dist.H264Profile.HIGH,
|
|
103670
|
+
level: import_dist.H264Level.LEVEL4_0,
|
|
103671
|
+
packetizationMode: import_dist.VideoCodecPacketizationMode.NON_INTERLEAVED,
|
|
103672
|
+
width: tier.width,
|
|
103673
|
+
height: tier.height,
|
|
103674
|
+
fps: tier.frameRate,
|
|
103675
|
+
pt: VIDEO_PAYLOAD_TYPE,
|
|
103676
|
+
ssrc: command.videoSsrc ?? 0,
|
|
103677
|
+
max_bit_rate: tier.targetAverageBitrateKbps,
|
|
103678
|
+
rtcp_interval: RTCP_INTERVAL_SECONDS,
|
|
103679
|
+
mtu: DEFAULT_MTU
|
|
103680
|
+
},
|
|
103681
|
+
audio: {
|
|
103682
|
+
codec: import_dist.AudioStreamingCodecType.OPUS,
|
|
103683
|
+
channel: 1,
|
|
103684
|
+
bit_rate: 24,
|
|
103685
|
+
sample_rate: import_dist.AudioStreamingSamplerate.KHZ_24,
|
|
103686
|
+
packet_time: 20,
|
|
103687
|
+
pt: AUDIO_PAYLOAD_TYPE,
|
|
103688
|
+
ssrc: command.audioSsrc ?? 0,
|
|
103689
|
+
max_bit_rate: 24,
|
|
103690
|
+
rtcp_interval: RTCP_INTERVAL_SECONDS,
|
|
103691
|
+
comfort_pt: 13,
|
|
103692
|
+
comfortNoiseEnabled: false
|
|
103693
|
+
}
|
|
103694
|
+
};
|
|
103695
|
+
await negotiator.control(start);
|
|
103696
|
+
started.add(command.sessionId);
|
|
103697
|
+
controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.SUCCESS);
|
|
103698
|
+
log.info("hap multi-tier: stream started", { meta: {
|
|
103699
|
+
sessionId: command.sessionId,
|
|
103700
|
+
tier: tier.identifier,
|
|
103701
|
+
geometry: `${tier.width}x${tier.height}@${tier.frameRate}`,
|
|
103702
|
+
targetKbps: tier.targetAverageBitrateKbps,
|
|
103703
|
+
codec: input.codec
|
|
103704
|
+
} });
|
|
103705
|
+
} catch (error) {
|
|
103706
|
+
controlResponse = encodeRtpStreamingControlStatus(sessionId, RtpStreamingStatus.ERROR);
|
|
103707
|
+
log.warn("hap multi-tier: control write refused", { meta: {
|
|
103708
|
+
sessionId,
|
|
103709
|
+
error: errorMessage(error)
|
|
103710
|
+
} });
|
|
103711
|
+
}
|
|
103712
|
+
}
|
|
103713
|
+
return {
|
|
103714
|
+
handleSetupEndpointsWrite,
|
|
103715
|
+
readSetupEndpoints: () => setupResponse,
|
|
103716
|
+
handleStreamingControlWrite,
|
|
103717
|
+
readStreamingControl: () => controlResponse
|
|
103718
|
+
};
|
|
103719
|
+
}
|
|
103720
|
+
//#endregion
|
|
102181
103721
|
//#region src/mappers/camera-accessory.ts
|
|
102182
103722
|
/**
|
|
102183
103723
|
* Camera accessory orchestrator — given a camstack deviceId, builds one
|
|
@@ -102243,6 +103783,35 @@ async function buildCameraAccessory(input) {
|
|
|
102243
103783
|
...recording === null || !capNames.has("motion-detection") ? {} : { sensors: { motion: true } }
|
|
102244
103784
|
});
|
|
102245
103785
|
accessory.configureController(controller);
|
|
103786
|
+
if (options.hapDeviceSettings.multiTierService !== false) {
|
|
103787
|
+
const sensor = advertisedVideo.sensor;
|
|
103788
|
+
const services = buildMultiTierServices({
|
|
103789
|
+
deviceId: numericId,
|
|
103790
|
+
logger: ctx.logger,
|
|
103791
|
+
width: sensor.width,
|
|
103792
|
+
height: sensor.height,
|
|
103793
|
+
codec: sensor.codec,
|
|
103794
|
+
audioCaptureRateHz: 16e3,
|
|
103795
|
+
negotiation: buildMultiTierNegotiation({
|
|
103796
|
+
deviceId: numericId,
|
|
103797
|
+
logger: ctx.logger,
|
|
103798
|
+
negotiator: streams.negotiator,
|
|
103799
|
+
tiers: tiersForSensor(sensor.width, sensor.height),
|
|
103800
|
+
codec: sensor.codec
|
|
103801
|
+
})
|
|
103802
|
+
});
|
|
103803
|
+
accessory.addService(services.capabilities);
|
|
103804
|
+
accessory.addService(services.globalOperatingMode);
|
|
103805
|
+
accessory.addService(services.streamManagement);
|
|
103806
|
+
ctx.logger.info("hap multi-tier: services declared on this accessory", {
|
|
103807
|
+
tags: { deviceId: numericId },
|
|
103808
|
+
meta: {
|
|
103809
|
+
serviceUuid: MULTI_TIER_RTP_SERVICE_UUID,
|
|
103810
|
+
codec: sensor.codec === TierVideoCodec.H265 ? "h265" : "h264",
|
|
103811
|
+
sensor: `${sensor.width}x${sensor.height}`
|
|
103812
|
+
}
|
|
103813
|
+
});
|
|
103814
|
+
}
|
|
102246
103815
|
if (recording !== null) {
|
|
102247
103816
|
handles.push({ dispose: () => recording.dispose() });
|
|
102248
103817
|
const audioCharacteristic = (controller.recordingManagement?.operatingModeService)?.getCharacteristic(import_dist.Characteristic.RecordingAudioActive);
|
|
@@ -103223,6 +104792,24 @@ var DEFAULT_DEVICE_SETTINGS = {
|
|
|
103223
104792
|
function resolveHksvRecording(settings) {
|
|
103224
104793
|
return settings?.hksvRecording !== false;
|
|
103225
104794
|
}
|
|
104795
|
+
/**
|
|
104796
|
+
* Whether this camera offers Apple's NEW camera service alongside the classic
|
|
104797
|
+
* one — `camera-multi-tier-rtp-stream-management` (0x8031), where HEVC and 4K
|
|
104798
|
+
* live.
|
|
104799
|
+
*
|
|
104800
|
+
* ABSENT MEANS ON, like `resolveHksvRecording` and for the same reason: a
|
|
104801
|
+
* camera exposed before this field existed must not silently lose it. Only an
|
|
104802
|
+
* explicit `false` turns it off.
|
|
104803
|
+
*
|
|
104804
|
+
* Safe as a default because the FALLBACK is the controller's own and it was
|
|
104805
|
+
* measured: iOS reads the new service's characteristics and, finding it
|
|
104806
|
+
* unusable, opens its session on the classic one two seconds later. Until the
|
|
104807
|
+
* negotiation is built we say `streaming-enabled: false`, so declining is the
|
|
104808
|
+
* only thing a controller can do with it.
|
|
104809
|
+
*/
|
|
104810
|
+
function resolveMultiTierService(settings) {
|
|
104811
|
+
return settings?.multiTierService === true;
|
|
104812
|
+
}
|
|
103226
104813
|
var HAP_STREAM_PREFERENCE_OPTIONS = [
|
|
103227
104814
|
{
|
|
103228
104815
|
value: "auto",
|
|
@@ -103454,7 +105041,18 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
103454
105041
|
note: qr !== void 0 ? "Scan the QR with the iOS Camera app to pair the first exposed camera. Add additional cameras from iOS Home → + → Add Accessory; the setup code above applies to all of them." : "Each exposed camera is published as its OWN HomeKit accessory (cameras cannot be bridged). In the iOS Home app, tap + → Add Accessory: each exposed camera appears separately by name; add each one and enter this same setup code."
|
|
103455
105042
|
};
|
|
103456
105043
|
}
|
|
103457
|
-
|
|
105044
|
+
/**
|
|
105045
|
+
* @param settings Settings to build the accessory WITH.
|
|
105046
|
+
*
|
|
105047
|
+
* Needed because the only path a per-camera settings change takes is
|
|
105048
|
+
* `unexposeDevice` then `exposeDevice`, and the unexpose deletes the stored
|
|
105049
|
+
* entry — so `carryForward` below has nothing to carry and the accessory is
|
|
105050
|
+
* rebuilt on the DEFAULTS. Persisting the value afterwards is not enough:
|
|
105051
|
+
* the mapper, and with it the advertised resolution list, is already built.
|
|
105052
|
+
* Measured 2026-09-17 — the stored value read `mid` while the advertisement
|
|
105053
|
+
* still said `streamPreference=auto`.
|
|
105054
|
+
*/
|
|
105055
|
+
async exposeDevice(deviceId, capabilities, settings) {
|
|
103458
105056
|
const numericId = Number.parseInt(deviceId, 10);
|
|
103459
105057
|
const log = this.ctx.logger.withTags({ deviceId: numericId });
|
|
103460
105058
|
if (this.exposed.has(deviceId)) {
|
|
@@ -103466,12 +105064,16 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
103466
105064
|
if (!mapperKind) throw new Error(`export-hap: device ${numericId} has type '${summary?.type ?? "unknown"}', which HomeKit export does not support`);
|
|
103467
105065
|
const displayName = summary?.name ?? `Device ${deviceId}`;
|
|
103468
105066
|
const previous = this.config.exposed.find((e) => e.deviceId === deviceId);
|
|
103469
|
-
const
|
|
105067
|
+
const rebuilt = {
|
|
103470
105068
|
deviceId,
|
|
103471
105069
|
displayName,
|
|
103472
105070
|
mapperKind,
|
|
103473
105071
|
addedAt: previous?.addedAt ?? Date.now(),
|
|
103474
105072
|
...capabilities ? { capabilities: [...capabilities] } : {}
|
|
105073
|
+
};
|
|
105074
|
+
const baseEntry = carryForward(settings === void 0 ? rebuilt : {
|
|
105075
|
+
...rebuilt,
|
|
105076
|
+
settings
|
|
103475
105077
|
}, previous, ["settings", "capabilities"]);
|
|
103476
105078
|
const attached = await this.attachMapper(baseEntry);
|
|
103477
105079
|
const finalEntry = {
|
|
@@ -103515,6 +105117,7 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
103515
105117
|
hapDeviceSettings: {
|
|
103516
105118
|
streamPreference: entrySettings.streamPreference ?? "auto",
|
|
103517
105119
|
hksvRecording: resolveHksvRecording(entrySettings),
|
|
105120
|
+
multiTierService: resolveMultiTierService(entrySettings),
|
|
103518
105121
|
allowTranscode: entrySettings.allowTranscode !== false
|
|
103519
105122
|
}
|
|
103520
105123
|
}
|
|
@@ -103845,6 +105448,7 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
103845
105448
|
const enabledKey = `hap:${deviceId}:enabled`;
|
|
103846
105449
|
const streamPreferenceKey = `hap:${deviceId}:streamPreference`;
|
|
103847
105450
|
const hksvKey = `hap:${deviceId}:hksvRecording`;
|
|
105451
|
+
const multiTierKey = `hap:${deviceId}:multiTierService`;
|
|
103848
105452
|
const useBridgeKey = `hap:${deviceId}:useBridge`;
|
|
103849
105453
|
const allowTranscodeKey = `hap:${deviceId}:allowTranscode`;
|
|
103850
105454
|
const mapper = this.exposed.get(String(deviceId)) ?? null;
|
|
@@ -103904,6 +105508,19 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
103904
105508
|
},
|
|
103905
105509
|
immediate: true
|
|
103906
105510
|
},
|
|
105511
|
+
{
|
|
105512
|
+
type: "boolean",
|
|
105513
|
+
key: multiTierKey,
|
|
105514
|
+
label: "Offer the new HomeKit camera service (HEVC / 4K)",
|
|
105515
|
+
description: "Declares Apple’s multi-tier camera service beside the classic one, advertising what this sensor can really produce. Today a controller reads it and still streams over the classic H.264 path — the negotiation is being built. Turn it off if this camera behaves oddly in iOS Home.",
|
|
105516
|
+
style: "switch",
|
|
105517
|
+
value: resolveMultiTierService(settings),
|
|
105518
|
+
showWhen: {
|
|
105519
|
+
field: enabledKey,
|
|
105520
|
+
equals: true
|
|
105521
|
+
},
|
|
105522
|
+
immediate: true
|
|
105523
|
+
},
|
|
103907
105524
|
{
|
|
103908
105525
|
type: "boolean",
|
|
103909
105526
|
key: allowTranscodeKey,
|
|
@@ -103990,18 +105607,21 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
103990
105607
|
const hksvKey = `hap:${deviceId}:hksvRecording`;
|
|
103991
105608
|
const useBridgeKey = `hap:${deviceId}:useBridge`;
|
|
103992
105609
|
const allowTranscodeKey = `hap:${deviceId}:allowTranscode`;
|
|
105610
|
+
const multiTierKey = `hap:${deviceId}:multiTierService`;
|
|
103993
105611
|
const enabledValue = enabledKey in patch ? Boolean(patch[enabledKey]) : wasEnabled;
|
|
103994
105612
|
const streamPreferenceRaw = streamPreferenceKey in patch ? patch[streamPreferenceKey] : current?.settings?.streamPreference;
|
|
103995
105613
|
const streamPreference = typeof streamPreferenceRaw === "string" && streamPreferenceRaw.trim().length > 0 ? streamPreferenceRaw : "auto";
|
|
103996
105614
|
const hksvRecording = hksvKey in patch ? Boolean(patch[hksvKey]) : resolveHksvRecording(current?.settings);
|
|
103997
105615
|
const useBridge = useBridgeKey in patch ? Boolean(patch[useBridgeKey]) : current?.settings?.useBridge !== false;
|
|
103998
105616
|
const allowTranscode = allowTranscodeKey in patch ? Boolean(patch[allowTranscodeKey]) : current?.settings?.allowTranscode !== false;
|
|
105617
|
+
const multiTierService = multiTierKey in patch ? Boolean(patch[multiTierKey]) : resolveMultiTierService(current?.settings);
|
|
103999
105618
|
const nextSettings = {
|
|
104000
105619
|
...current?.settings ?? DEFAULT_DEVICE_SETTINGS,
|
|
104001
105620
|
streamPreference,
|
|
104002
105621
|
hksvRecording,
|
|
104003
105622
|
useBridge,
|
|
104004
|
-
allowTranscode
|
|
105623
|
+
allowTranscode,
|
|
105624
|
+
multiTierService
|
|
104005
105625
|
};
|
|
104006
105626
|
if (!enabledValue) {
|
|
104007
105627
|
if (wasEnabled) await this.unexposeDevice(deviceIdStr);
|
|
@@ -104041,7 +105661,7 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
104041
105661
|
} });
|
|
104042
105662
|
try {
|
|
104043
105663
|
await this.unexposeDevice(deviceIdStr, { clearPairing: wasBridged !== willBridge });
|
|
104044
|
-
await this.exposeDevice(deviceIdStr);
|
|
105664
|
+
await this.exposeDevice(deviceIdStr, void 0, nextSettings);
|
|
104045
105665
|
} catch (err) {
|
|
104046
105666
|
log.warn("export-hap: failed to refresh accessory after a settings change", { meta: { error: errMsg(err) } });
|
|
104047
105667
|
}
|
|
@@ -104071,4 +105691,5 @@ exports.deriveUsername = deriveUsername;
|
|
|
104071
105691
|
exports.initHapStorage = initHapStorage;
|
|
104072
105692
|
exports.publishStandalone = publishStandalone;
|
|
104073
105693
|
exports.resolveHksvRecording = resolveHksvRecording;
|
|
105694
|
+
exports.resolveMultiTierService = resolveMultiTierService;
|
|
104074
105695
|
exports.unpublishAccessory = unpublishAccessory;
|