@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.
@@ -1069,7 +1069,7 @@ function partial(Class, schema, mask) {
1069
1069
  checks: []
1070
1070
  }));
1071
1071
  }
1072
- function required(Class, schema, mask) {
1072
+ function required$2(Class, schema, mask) {
1073
1073
  return clone(schema, mergeDefs(schema._zod.def, { get shape() {
1074
1074
  const oldShape = schema._zod.def.shape;
1075
1075
  const shape = { ...oldShape };
@@ -1327,7 +1327,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-
1327
1327
  /** Returns a regex for validating an RFC 9562/4122 UUID.
1328
1328
  *
1329
1329
  * @param version Optionally specify a version 1-8. If no version is specified, all versions are supported. */
1330
- var uuid$1 = (version) => {
1330
+ var uuid$3 = (version) => {
1331
1331
  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)$/;
1332
1332
  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})$`);
1333
1333
  };
@@ -1897,8 +1897,8 @@ var $ZodUUID = /*@__PURE__*/ $constructor("$ZodUUID", (inst, def) => {
1897
1897
  v8: 8
1898
1898
  }[def.version];
1899
1899
  if (v === void 0) throw new Error(`Invalid UUID version: "${def.version}"`);
1900
- def.pattern ?? (def.pattern = uuid$1(v));
1901
- } else def.pattern ?? (def.pattern = uuid$1());
1900
+ def.pattern ?? (def.pattern = uuid$3(v));
1901
+ } else def.pattern ?? (def.pattern = uuid$3());
1902
1902
  $ZodStringFormat.init(inst, def);
1903
1903
  });
1904
1904
  var $ZodEmail = /*@__PURE__*/ $constructor("$ZodEmail", (inst, def) => {
@@ -4475,8 +4475,8 @@ var parse = /* @__PURE__ */ _parse(ZodRealError);
4475
4475
  var parseAsync = /* @__PURE__ */ _parseAsync(ZodRealError);
4476
4476
  var safeParse = /* @__PURE__ */ _safeParse(ZodRealError);
4477
4477
  var safeParseAsync = /* @__PURE__ */ _safeParseAsync(ZodRealError);
4478
- var encode = /* @__PURE__ */ _encode(ZodRealError);
4479
- var decode = /* @__PURE__ */ _decode(ZodRealError);
4478
+ var encode$5 = /* @__PURE__ */ _encode(ZodRealError);
4479
+ var decode$2 = /* @__PURE__ */ _decode(ZodRealError);
4480
4480
  var encodeAsync = /* @__PURE__ */ _encodeAsync(ZodRealError);
4481
4481
  var decodeAsync = /* @__PURE__ */ _decodeAsync(ZodRealError);
4482
4482
  var safeEncode = /* @__PURE__ */ _safeEncode(ZodRealError);
@@ -4536,8 +4536,8 @@ var ZodType = /*@__PURE__*/ $constructor("ZodType", (inst, def) => {
4536
4536
  inst.parseAsync = async (data, params) => parseAsync(inst, data, params, { callee: inst.parseAsync });
4537
4537
  inst.safeParseAsync = async (data, params) => safeParseAsync(inst, data, params);
4538
4538
  inst.spa = inst.safeParseAsync;
4539
- inst.encode = (data, params) => encode(inst, data, params);
4540
- inst.decode = (data, params) => decode(inst, data, params);
4539
+ inst.encode = (data, params) => encode$5(inst, data, params);
4540
+ inst.decode = (data, params) => decode$2(inst, data, params);
4541
4541
  inst.encodeAsync = async (data, params) => encodeAsync(inst, data, params);
4542
4542
  inst.decodeAsync = async (data, params) => decodeAsync(inst, data, params);
4543
4543
  inst.safeEncode = (data, params) => safeEncode(inst, data, params);
@@ -5043,7 +5043,7 @@ var ZodObject = /*@__PURE__*/ $constructor("ZodObject", (inst, def) => {
5043
5043
  return partial(ZodOptional, this, args[0]);
5044
5044
  },
5045
5045
  required(...args) {
5046
- return required(ZodNonOptional, this, args[0]);
5046
+ return required$2(ZodNonOptional, this, args[0]);
5047
5047
  }
5048
5048
  });
5049
5049
  });
@@ -13028,7 +13028,23 @@ var PickStreamRequirementsSchema = object({
13028
13028
  * This is what lets one picker serve both the bypass and the full source
13029
13029
  * choice, instead of a consumer scoring privately when the bypass misses.
13030
13030
  */
13031
- allowTranscode: boolean().optional()
13031
+ allowTranscode: boolean().optional(),
13032
+ /**
13033
+ * Consider ONLY streams the broker has bound to a profile slot.
13034
+ *
13035
+ * A consumer that dials the broker — which is every exporter, since none
13036
+ * of them may open the camera's own URL — can do nothing with a source
13037
+ * that has no slot: there is no restream to dial. Without this the picker
13038
+ * can name such a stream, and each consumer discovers the dead end for
13039
+ * itself: Alexa's `profileForCamStream` returns `null` and it quietly
13040
+ * falls back, and HomeKit would have had to grow the same function again.
13041
+ *
13042
+ * Default `false`, and deliberately so: the BROKER itself picks through
13043
+ * here when it decides which source to BIND to a slot
13044
+ * (`computeInitialAssignment`), and it must see the unbound ones or it
13045
+ * could never bind them.
13046
+ */
13047
+ requireProfileBound: boolean().optional()
13032
13048
  }).readonly();
13033
13049
  var PickStreamPreferencesSchema = object({
13034
13050
  /**
@@ -13069,7 +13085,43 @@ var PickedCamStreamSchema = object({
13069
13085
  */
13070
13086
  transcodes: boolean(),
13071
13087
  /** One-line explanation of why this stream won — for logs / debug UI. */
13072
- reason: string()
13088
+ reason: string(),
13089
+ /**
13090
+ * The broker profile slot this source is bound to, when it is bound.
13091
+ *
13092
+ * Answered HERE because every consumer needs it and none of them can get it
13093
+ * from the `camStreamId` alone. Alexa wrote `profileForCamStream` for it —
13094
+ * a second cap read plus a `null` branch — and HomeKit was about to write
13095
+ * the same function a second time.
13096
+ *
13097
+ * Absent when the source has no slot, which a caller passing
13098
+ * `requireProfileBound` can never see.
13099
+ */
13100
+ profile: CamProfileSchema.optional(),
13101
+ /** The broker id of that slot, for logs that must name what was dialled. */
13102
+ brokerId: string().optional(),
13103
+ /**
13104
+ * The broker restream URL to dial for this stream.
13105
+ *
13106
+ * An exporter must never open the camera's own URL — the broker is the one
13107
+ * that dials the camera, and every consumer goes through its restream. So
13108
+ * the picker answers with the thing a consumer may actually use, instead of
13109
+ * naming a source and leaving each caller to look the URL up.
13110
+ */
13111
+ url: string().optional(),
13112
+ /**
13113
+ * Frames per second this stream really delivers, and where the number came
13114
+ * from.
13115
+ *
13116
+ * It is here because getting it wrong is expensive and silent: a HomeKit
13117
+ * accessory advertised 720p at 24 fps while the slot serving it ran at 9,
13118
+ * and the controller rendered nothing at all. The source is carried with the
13119
+ * value for the same reason the bitrate evidence is: `published` is what the
13120
+ * publisher declares and `measured` is what the broker observed, and they
13121
+ * are not interchangeable.
13122
+ */
13123
+ fps: number().positive().optional(),
13124
+ fpsSource: _enum(["published", "measured"]).optional()
13073
13125
  });
13074
13126
  DeviceType.Camera, method(object({ deviceId: number().int().nonnegative() }), array(CameraStreamSchema).readonly()), method(object({ deviceId: number().int().nonnegative() }), array(ProfileSlotSchema).readonly()), method(object({
13075
13127
  deviceId: number().int().nonnegative(),
@@ -14124,6 +14176,70 @@ var LocationIconAssignmentSchema = object({
14124
14176
  locationKey: string(),
14125
14177
  icon: LocationIconIdSchema
14126
14178
  });
14179
+ /**
14180
+ * One (location, description) assignment as it travels.
14181
+ *
14182
+ * Same key discipline as {@link LocationIconAssignmentSchema}, and for the same
14183
+ * reason: this lived for a while in the post-analysis addon's own settings,
14184
+ * keyed by a HAND-TYPED label matched exactly and case-sensitively. A rename in
14185
+ * the Locations page carried the icon and silently orphaned the description —
14186
+ * two authorities for one thing, joined by two different rules. Now both hang
14187
+ * off the location itself, on `locationIconKey`.
14188
+ */
14189
+ var LocationDescriptionAssignmentSchema = object({
14190
+ locationKey: string(),
14191
+ description: string()
14192
+ });
14193
+ /**
14194
+ * A SITE ZONE — a named part of the property that several locations belong to.
14195
+ *
14196
+ * "Zona notte" holds every bedroom; "Zona giorno" the living room and the
14197
+ * kitchen. A camera belongs to exactly one location, and a location to at most
14198
+ * one zone, so a camera's zone is derived and never stored on the device: the
14199
+ * whole point of this entity is that grouping rooms touches no device row.
14200
+ *
14201
+ * ## Why not `zone`
14202
+ *
14203
+ * Because `zone` is already the most loaded word in this product: 612 source
14204
+ * files and 24 Italian strings where it means a DETECTION zone drawn on a
14205
+ * camera. The two are unrelated — one is a polygon in a frame, the other is a
14206
+ * set of rooms — and a reader who confuses them will look for a camera's
14207
+ * bedrooms. So the code says `siteZone` everywhere, and the UI frees the plain
14208
+ * word by calling the drawn one what it actually is: a detection zone.
14209
+ *
14210
+ * ## The key
14211
+ *
14212
+ * `siteZoneKey(label)` — trimmed, lower-cased, the SAME derivation locations
14213
+ * use (`locationIconKey`). Membership and the icon both hang off it, so a
14214
+ * case-only rename keeps them, which is exactly the orphaning that cost us a
14215
+ * silently-dropped location description.
14216
+ *
14217
+ * The icon vocabulary is the location one, unchanged: a zone is drawn on the
14218
+ * same two surfaces by the same glyph map, so a second vocabulary would be a
14219
+ * second thing to keep drawable (`scripts/check-location-icons-drawable.ts`).
14220
+ */
14221
+ /**
14222
+ * One zone as it travels: its key, the label the operator typed, its icon and
14223
+ * the locations inside it.
14224
+ *
14225
+ * The LABEL is carried alongside the key because the key is lossy on purpose —
14226
+ * a surface must show "Zona notte", not "zona notte". Locations are carried
14227
+ * here, rather than left to a second call, because every consumer wants both
14228
+ * at once and a viewer that asked twice could render a zone whose membership
14229
+ * it has not fetched yet.
14230
+ *
14231
+ * `icon: null` means nobody chose one, which is NOT the fallback pin: only one
14232
+ * of those two is a choice, and a picker has to draw them differently.
14233
+ */
14234
+ var SiteZoneSchema = object({
14235
+ zoneKey: string(),
14236
+ label: string(),
14237
+ icon: LocationIconIdSchema.nullable(),
14238
+ /** Folded location keys, sorted. A location listed here need not be
14239
+ * REGISTERED — devices carry labels nobody registered, and those rooms are
14240
+ * the ones that most want grouping. */
14241
+ locationKeys: array(string())
14242
+ });
14127
14243
  var ProviderStatusSchema = object({
14128
14244
  connected: boolean(),
14129
14245
  deviceCount: number(),
@@ -14641,6 +14757,33 @@ method(object({
14641
14757
  }), _void(), {
14642
14758
  kind: "mutation",
14643
14759
  auth: "admin"
14760
+ }), method(_void(), array(LocationDescriptionAssignmentSchema)), method(object({
14761
+ location: string(),
14762
+ description: string().max(400).nullable()
14763
+ }), _void(), {
14764
+ kind: "mutation",
14765
+ auth: "admin"
14766
+ }), method(_void(), array(SiteZoneSchema)), method(object({
14767
+ name: string(),
14768
+ icon: LocationIconIdSchema.nullable().optional()
14769
+ }), _void(), {
14770
+ kind: "mutation",
14771
+ auth: "admin"
14772
+ }), method(object({ name: string() }), _void(), {
14773
+ kind: "mutation",
14774
+ auth: "admin"
14775
+ }), method(object({
14776
+ from: string(),
14777
+ to: string()
14778
+ }), _void(), {
14779
+ kind: "mutation",
14780
+ auth: "admin"
14781
+ }), method(object({
14782
+ location: string(),
14783
+ zone: string().nullable()
14784
+ }), _void(), {
14785
+ kind: "mutation",
14786
+ auth: "admin"
14644
14787
  }), method(object({
14645
14788
  deviceId: number(),
14646
14789
  disabled: boolean()
@@ -33525,6 +33668,12 @@ Object.freeze({
33525
33668
  addonId: null,
33526
33669
  access: "view"
33527
33670
  },
33671
+ "deviceManager.listLocationDescriptions": {
33672
+ capName: "device-manager",
33673
+ capScope: "system",
33674
+ addonId: null,
33675
+ access: "view"
33676
+ },
33528
33677
  "deviceManager.listLocationIcons": {
33529
33678
  capName: "device-manager",
33530
33679
  capScope: "system",
@@ -33543,6 +33692,12 @@ Object.freeze({
33543
33692
  addonId: null,
33544
33693
  access: "view"
33545
33694
  },
33695
+ "deviceManager.listSiteZones": {
33696
+ capName: "device-manager",
33697
+ capScope: "system",
33698
+ addonId: null,
33699
+ access: "view"
33700
+ },
33546
33701
  "deviceManager.listWrappersForCap": {
33547
33702
  capName: "device-manager",
33548
33703
  capScope: "system",
@@ -33627,12 +33782,24 @@ Object.freeze({
33627
33782
  addonId: null,
33628
33783
  access: "delete"
33629
33784
  },
33785
+ "deviceManager.removeSiteZone": {
33786
+ capName: "device-manager",
33787
+ capScope: "system",
33788
+ addonId: null,
33789
+ access: "delete"
33790
+ },
33630
33791
  "deviceManager.renameLocation": {
33631
33792
  capName: "device-manager",
33632
33793
  capScope: "system",
33633
33794
  addonId: null,
33634
33795
  access: "create"
33635
33796
  },
33797
+ "deviceManager.renameSiteZone": {
33798
+ capName: "device-manager",
33799
+ capScope: "system",
33800
+ addonId: null,
33801
+ access: "create"
33802
+ },
33636
33803
  "deviceManager.runDeviceAction": {
33637
33804
  capName: "device-manager",
33638
33805
  capScope: "system",
@@ -33675,12 +33842,24 @@ Object.freeze({
33675
33842
  addonId: null,
33676
33843
  access: "create"
33677
33844
  },
33845
+ "deviceManager.setLocationDescription": {
33846
+ capName: "device-manager",
33847
+ capScope: "system",
33848
+ addonId: null,
33849
+ access: "create"
33850
+ },
33678
33851
  "deviceManager.setLocationIcon": {
33679
33852
  capName: "device-manager",
33680
33853
  capScope: "system",
33681
33854
  addonId: null,
33682
33855
  access: "create"
33683
33856
  },
33857
+ "deviceManager.setLocationZone": {
33858
+ capName: "device-manager",
33859
+ capScope: "system",
33860
+ addonId: null,
33861
+ access: "create"
33862
+ },
33684
33863
  "deviceManager.setMetadata": {
33685
33864
  capName: "device-manager",
33686
33865
  capScope: "system",
@@ -33711,6 +33890,12 @@ Object.freeze({
33711
33890
  addonId: null,
33712
33891
  access: "create"
33713
33892
  },
33893
+ "deviceManager.setSiteZone": {
33894
+ capName: "device-manager",
33895
+ capScope: "system",
33896
+ addonId: null,
33897
+ access: "create"
33898
+ },
33714
33899
  "deviceManager.setStreamProfileMap": {
33715
33900
  capName: "device-manager",
33716
33901
  capScope: "system",
@@ -97896,6 +98081,239 @@ function slotForConnection(connection) {
97896
98081
  }
97897
98082
  }
97898
98083
  //#endregion
98084
+ //#region src/multi-tier/video-stream-tiers.ts
98085
+ /**
98086
+ * The `Supported Video Stream Tiers` TLV8 — the characteristic through which an
98087
+ * accessory offers HEVC and 4K to HomeKit.
98088
+ *
98089
+ * ## Where this comes from
98090
+ *
98091
+ * Apple's *HomeKit Secure Video Open Source Compatibility Guide* (Developer
98092
+ * Preview, 2026-06-03). It defines TWO new streaming services beside the one
98093
+ * this addon uses today:
98094
+ *
98095
+ * - `camera-multi-tier-rtp-stream-management` (0x8031) — still RTP, which is
98096
+ * the plumbing we already have, and the reason this file exists at all.
98097
+ * - `camera-webrtc-stream-management` (0x8033) — a different transport.
98098
+ *
98099
+ * Both advertise their capability with this TLV. Recording moves separately to
98100
+ * CMAF ingest (0x8000 + a client-certificate provisioning flow at 0x8080), and
98101
+ * is NOT in scope here: the certificate dance belongs to ingest, not to live
98102
+ * streaming, which is what makes the live path the tractable half.
98103
+ *
98104
+ * ## Why we write this ourselves rather than patching the library
98105
+ *
98106
+ * `@homebridge/hap-nodejs@2.1.7` models none of it — its `VideoCodecType` has
98107
+ * one member, `H264 = 0`, and the new specification numbers H.264 as **1**. It
98108
+ * is not the same enumeration, so there is nothing here to extend. But nothing
98109
+ * needs patching either: `Service` and `Characteristic` take arbitrary UUIDs,
98110
+ * `Accessory.addService` validates only for duplicates and a count, and the
98111
+ * `Controller` interface is public. So this is an EXTENSION, not a fork —
98112
+ * nothing to rebase when the library moves.
98113
+ *
98114
+ * Pure bytes in, pure bytes out. No accessory, no service, no I/O: the wire
98115
+ * format is the part a test can hold still.
98116
+ */
98117
+ /** Video codec, as the NEW specification numbers it. Deliberately not the
98118
+ * library's `VideoCodecType`, whose `H264` is `0`. */
98119
+ var TierVideoCodec = /* @__PURE__ */ function(TierVideoCodec) {
98120
+ TierVideoCodec[TierVideoCodec["H264"] = 1] = "H264";
98121
+ TierVideoCodec[TierVideoCodec["H265"] = 2] = "H265";
98122
+ return TierVideoCodec;
98123
+ }({});
98124
+ /**
98125
+ * Quality tier.
98126
+ *
98127
+ * `HIGHEST` is not "even better than HIGH": the guide reserves it for the 4K
98128
+ * stream of a camera that offers a 2K stream AT THE SAME TIME, and says a
98129
+ * camera that cannot do both should not use the value. Encoding it without that
98130
+ * capability advertises a camera we do not have.
98131
+ */
98132
+ var TierQuality = /* @__PURE__ */ function(TierQuality) {
98133
+ TierQuality[TierQuality["HIGHEST"] = 1] = "HIGHEST";
98134
+ TierQuality[TierQuality["HIGH"] = 2] = "HIGH";
98135
+ TierQuality[TierQuality["MEDIUM"] = 3] = "MEDIUM";
98136
+ TierQuality[TierQuality["LOW"] = 4] = "LOW";
98137
+ return TierQuality;
98138
+ }({});
98139
+ function uint8$2(value) {
98140
+ const b = Buffer.alloc(1);
98141
+ b.writeUInt8(value, 0);
98142
+ return b;
98143
+ }
98144
+ function uint16$2(value) {
98145
+ const b = Buffer.alloc(2);
98146
+ b.writeUInt16LE(value, 0);
98147
+ return b;
98148
+ }
98149
+ function uint32$3(value) {
98150
+ const b = Buffer.alloc(4);
98151
+ b.writeUInt32LE(value, 0);
98152
+ return b;
98153
+ }
98154
+ /** One tier, as its own TLV8 blob. */
98155
+ function encodeVideoStreamTier(tier) {
98156
+ 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));
98157
+ }
98158
+ /**
98159
+ * The whole characteristic value.
98160
+ *
98161
+ * The tier list is REPEATED — one TLV of type 3 per tier — and repetition is
98162
+ * how HAP carries a list. Emitting one blob with the tiers concatenated inside
98163
+ * would decode as a single unreadable tier.
98164
+ */
98165
+ function encodeVideoStreamConfiguration(config) {
98166
+ return (0, import_dist.encode)(1, uint8$2(config.codec), 2, uint8$2(config.payloadType), 3, config.tiers.map(encodeVideoStreamTier));
98167
+ }
98168
+ //#endregion
98169
+ //#region src/multi-tier/tiers-for-sensor.ts
98170
+ /**
98171
+ * Which tiers a camera is allowed to claim.
98172
+ *
98173
+ * Derived from the sensor, never assumed: a 1080p camera that advertises 4K is
98174
+ * telling the controller it can do something it cannot, and that surfaces as a
98175
+ * stream which never starts rather than as a refusal anyone can read.
98176
+ *
98177
+ * ## The table is the specification, not a suggestion
98178
+ *
98179
+ * § 2 *Minimum Requirements*: *"The accessory must support at least three
98180
+ * concurrent video encodings on each sensor"*, and for 16:9 it names them:
98181
+ *
98182
+ * | | 4K camera | 2K camera | 1080p camera |
98183
+ * | ---------- | --------- | --------- | ------------ |
98184
+ * | **High** | 3840x2160 | 2560x1440 | 1920x1080 |
98185
+ * | **Medium** | 1920x1080 | 1920x1080 | 1280x720 |
98186
+ * | **Low** | 640x360 | 640x360 | 640x360 |
98187
+ *
98188
+ * The first version of this file built the list by pushing 1080p and 360p onto
98189
+ * whatever big tier applied, which is right for 4K and 2K and produces only
98190
+ * TWO tiers for a 1080p camera — its medium is 720p, and nothing emitted it.
98191
+ * Writing the table out means the 1080p column cannot be an afterthought.
98192
+ *
98193
+ * A tier's MEDIUM resolution can exceed what the camera publishes natively —
98194
+ * a 4K camera whose middle stream is 720p still owes a 1080p tier. That is the
98195
+ * specification's demand, not ours, and it is a demand on the ENCODER rather
98196
+ * than on the sensor.
98197
+ *
98198
+ * `HIGHEST` is never emitted: the guide reserves it for a 4K camera that also
98199
+ * serves 2K SIMULTANEOUSLY, and we cannot prove we do.
98200
+ */
98201
+ /**
98202
+ * Target average and MAXIMUM bitrate per resolution, § 2.
98203
+ *
98204
+ * The maxima are the reason this is a table and not a multiplier. A peak
98205
+ * computed as average × 1.1 reads as "the 10% gap the guide recommends", and it
98206
+ * overshoots three of these five rows — 1080p would claim 1870 against a
98207
+ * ceiling of 1800, and *"maximum bitrates should not be exceeded"*.
98208
+ */
98209
+ var BITRATE_BY_WIDTH = new Map([
98210
+ [3840, {
98211
+ average: 4500,
98212
+ peak: 5e3
98213
+ }],
98214
+ [2560, {
98215
+ average: 2800,
98216
+ peak: 3e3
98217
+ }],
98218
+ [1920, {
98219
+ average: 1700,
98220
+ peak: 1800
98221
+ }],
98222
+ [1280, {
98223
+ average: 768,
98224
+ peak: 800
98225
+ }],
98226
+ [640, {
98227
+ average: 180,
98228
+ peak: 190
98229
+ }]
98230
+ ]);
98231
+ /** The lowest row, for a width the table does not name. Claiming a small
98232
+ * budget for an unexpected size is the safe direction: it under-promises. */
98233
+ var FALLBACK_BITRATE = {
98234
+ average: 180,
98235
+ peak: 190
98236
+ };
98237
+ function bitrateFor(width) {
98238
+ return BITRATE_BY_WIDTH.get(width) ?? FALLBACK_BITRATE;
98239
+ }
98240
+ function averageBitrateKbpsFor(width) {
98241
+ return bitrateFor(width).average;
98242
+ }
98243
+ function peakBitrateKbpsFor(width) {
98244
+ return bitrateFor(width).peak;
98245
+ }
98246
+ /** The three rows of the table, by the column the sensor falls in. */
98247
+ function rowsForSensor(width, height) {
98248
+ if (width >= 3840 && height >= 2160) return [
98249
+ {
98250
+ width: 3840,
98251
+ height: 2160,
98252
+ frameRate: 30
98253
+ },
98254
+ {
98255
+ width: 1920,
98256
+ height: 1080,
98257
+ frameRate: 30
98258
+ },
98259
+ {
98260
+ width: 640,
98261
+ height: 360,
98262
+ frameRate: 15
98263
+ }
98264
+ ];
98265
+ if (width >= 2560 && height >= 1440) return [
98266
+ {
98267
+ width: 2560,
98268
+ height: 1440,
98269
+ frameRate: 30
98270
+ },
98271
+ {
98272
+ width: 1920,
98273
+ height: 1080,
98274
+ frameRate: 30
98275
+ },
98276
+ {
98277
+ width: 640,
98278
+ height: 360,
98279
+ frameRate: 15
98280
+ }
98281
+ ];
98282
+ return [
98283
+ {
98284
+ width: 1920,
98285
+ height: 1080,
98286
+ frameRate: 30
98287
+ },
98288
+ {
98289
+ width: 1280,
98290
+ height: 720,
98291
+ frameRate: 30
98292
+ },
98293
+ {
98294
+ width: 640,
98295
+ height: 360,
98296
+ frameRate: 15
98297
+ }
98298
+ ];
98299
+ }
98300
+ function tiersForSensor(width, height) {
98301
+ const rows = rowsForSensor(width, height);
98302
+ const qualities = [
98303
+ TierQuality.HIGH,
98304
+ TierQuality.MEDIUM,
98305
+ TierQuality.LOW
98306
+ ];
98307
+ return rows.map((row, index) => ({
98308
+ identifier: index + 1,
98309
+ quality: qualities[index] ?? TierQuality.LOW,
98310
+ targetAverageBitrateKbps: averageBitrateKbpsFor(row.width),
98311
+ width: row.width,
98312
+ height: row.height,
98313
+ frameRate: row.frameRate
98314
+ }));
98315
+ }
98316
+ //#endregion
97899
98317
  //#region src/mappers/builders/stream-bitrate.ts
97900
98318
  /**
97901
98319
  * Send a stream that FITS the rate HomeKit negotiated. (R5)
@@ -98008,6 +98426,38 @@ function budgetForNegotiatedRate(negotiatedMaxBitrateKbps) {
98008
98426
  return budget > 0 ? budget : null;
98009
98427
  }
98010
98428
  /**
98429
+ * The rate a TRANSCODE may actually aim at.
98430
+ *
98431
+ * Measured on the hub, 2026-09-17: two cameras on the same link, both with a
98432
+ * negotiated ceiling of 299 kbps. The H.264 one was passed through at **8192
98433
+ * kbps** under `over-budget-tolerated`; the H.265 one, which must be
98434
+ * re-encoded, was held to **269**. Thirty times less, decided entirely by
98435
+ * which branch the camera fell into.
98436
+ *
98437
+ * That asymmetry contradicts the rule this module already adopted for copy:
98438
+ * *"the negotiated ceiling is a budget the controller copes with being
98439
+ * exceeded, not a contract that must be met"*, evidenced by a reference NVR
98440
+ * doing exactly that. The copy path stopped treating it as a contract and the
98441
+ * transcode path did not.
98442
+ *
98443
+ * So the negotiated budget becomes a FLOOR and the guide's own normative
98444
+ * average for the DELIVERED resolution becomes the target (§ 2: 1080p30 →
98445
+ * 1700, 720p30 → 768, 360p → 180). Two properties matter:
98446
+ *
98447
+ * - it never LOWERS a budget the controller was willing to grant — a
98448
+ * controller asking for more gets more;
98449
+ * - it changes nothing for a relayed session, which negotiates 360p, where
98450
+ * the guide asks less than the controller already granted. Spending an
98451
+ * Apple relay's bandwidth for no picture gain is not an improvement.
98452
+ *
98453
+ * `null` stays `null`: it means "pass no `-maxrate` at all", and inventing a
98454
+ * number here would cap a stream that was deliberately uncapped.
98455
+ */
98456
+ function encodeBudgetKbps(input) {
98457
+ if (input.negotiatedBudgetKbps === null) return null;
98458
+ return Math.max(input.negotiatedBudgetKbps, averageBitrateKbpsFor(input.width));
98459
+ }
98460
+ /**
98011
98461
  * Does this slot fit?
98012
98462
  *
98013
98463
  * Only the CONFIGURED rate can answer yes. The measured rate is a lower bound,
@@ -98038,6 +98488,7 @@ function classifyBitrateFit(evidence, budgetKbps) {
98038
98488
  function selectStreamForBudget(input) {
98039
98489
  const budgetKbps = budgetForNegotiatedRate(input.negotiatedMaxBitrateKbps);
98040
98490
  const notes = fitNotes(input.entries, input.bitrates, budgetKbps);
98491
+ const allowHevc = input.allowHevcPassthrough === true;
98041
98492
  const effectivePref = input.pref === "auto" ? slotForConnection(input.connection) : input.pref;
98042
98493
  const fallback = pickPreferredRtspEntry(input.entries, effectivePref, input.deviceId, { targetResolution: input.targetResolution });
98043
98494
  if (fallback === null) return null;
@@ -98049,18 +98500,25 @@ function selectStreamForBudget(input) {
98049
98500
  budgetKbps: null,
98050
98501
  notes
98051
98502
  };
98052
- return canPassThrough(fallback.codec) ? {
98053
- kind: "copy",
98054
- reason: "no-negotiated-budget",
98055
- ...base
98056
- } : {
98503
+ if (!canPassThrough(fallback.codec, allowHevc)) return {
98057
98504
  kind: "transcode",
98058
98505
  reason: "source-codec",
98059
98506
  ...base
98060
98507
  };
98508
+ if (!fitsRequestedResolution(fallback, input.targetResolution)) return {
98509
+ kind: "transcode",
98510
+ reason: "over-resolution",
98511
+ ...base
98512
+ };
98513
+ return {
98514
+ kind: "copy",
98515
+ reason: "no-negotiated-budget",
98516
+ ...base
98517
+ };
98061
98518
  }
98062
98519
  const affordable = (input.pref !== "auto" && fallbackProfile === input.pref ? input.entries.filter((entry) => entry.profile === fallbackProfile) : input.entries).filter((entry) => {
98063
- if (!canPassThrough(entry.codec)) return false;
98520
+ if (!canPassThrough(entry.codec, allowHevc)) return false;
98521
+ if (!fitsRequestedResolution(entry, input.targetResolution)) return false;
98064
98522
  return classifyBitrateFit(input.bitrates.get(entry.profile), budgetKbps) === "fits";
98065
98523
  });
98066
98524
  if (affordable.length > 0) {
@@ -98074,7 +98532,7 @@ function selectStreamForBudget(input) {
98074
98532
  notes
98075
98533
  };
98076
98534
  }
98077
- if (canPassThrough(fallback.codec)) return {
98535
+ if (canPassThrough(fallback.codec, allowHevc) && fitsRequestedResolution(fallback, input.targetResolution)) return {
98078
98536
  kind: "copy",
98079
98537
  reason: "over-budget-tolerated",
98080
98538
  picked: fallback,
@@ -98084,7 +98542,7 @@ function selectStreamForBudget(input) {
98084
98542
  };
98085
98543
  return {
98086
98544
  kind: "transcode",
98087
- reason: transcodeReason(fallback, fallbackProfile, input.bitrates, budgetKbps),
98545
+ reason: transcodeReason(fallback, fallbackProfile, input.bitrates, budgetKbps, allowHevc, input.targetResolution),
98088
98546
  picked: fallback,
98089
98547
  profile: fallbackProfile,
98090
98548
  budgetKbps,
@@ -98138,11 +98596,57 @@ function withSlotCodecs(entries, slots) {
98138
98596
  * two load-bearing flags below back in play, with no hardware here to prove
98139
98597
  * they still hold. Hardware DECODE is where the measured cost is.
98140
98598
  */
98599
+ /**
98600
+ * The least this encoder can be given per pixel and still produce a picture a
98601
+ * decoder will lock onto.
98602
+ *
98603
+ * Not a quality target — a DECODABILITY one. The encode is pinned to
98604
+ * `ultrafast` + `zerolatency` + `baseline`, the three settings that trade
98605
+ * compression efficiency away for latency, so it needs more bits than a
98606
+ * default encoder would, not fewer.
98607
+ *
98608
+ * `0.03` is not invented: it is what the 269 kbps / 720p fixture this module
98609
+ * was tuned against works out to AT 10 FPS, which is the rate that fixture
98610
+ * dials and the rate the earlier overshoot fix was measured at.
98611
+ */
98612
+ var MIN_BITS_PER_PIXEL = .03;
98613
+ /**
98614
+ * The highest frame rate this budget can carry at this picture size.
98615
+ *
98616
+ * ## Why the FPS gives and not the bitrate
98617
+ *
98618
+ * The obvious move is to lift the bitrate until the picture is decodable. This
98619
+ * module already refused that once, for a reason recorded in its tests: an
98620
+ * encoder overshooting its ceiling (421 kbps against 299 negotiated) produced
98621
+ * three seconds of jitter, and the one-second VBV window is the only lever
98622
+ * there is on it. Raising the ceiling would buy a decodable picture and pay
98623
+ * for it in the exact currency that incident was about.
98624
+ *
98625
+ * So the ceiling holds and the frame rate gives way. A stream at 10 fps that
98626
+ * DECODES beats one at 30 fps that does not — and the controller never asked
98627
+ * for 30 in particular; it asked for a ceiling and a maximum rate.
98628
+ *
98629
+ * ## The measurement behind it
98630
+ *
98631
+ * Operator, 2026-09-16: the one H.265 camera shows a loader in the Home app,
98632
+ * the three H.264 ones are fine. H.264 is pass-through, H.265 is transcode.
98633
+ * Same controller, same 299 kbps ceiling: pass-through delivered 714 video
98634
+ * packets/s and worked; the transcode delivered 66 and produced a loader with
98635
+ * a `decode-suspect` verdict, three sessions running. It was encoding 1280x720
98636
+ * at 30 fps into 269 kbps — about 0.01 bits per pixel, a third of what the
98637
+ * fixture this was tuned on gets.
98638
+ */
98639
+ function fpsForBudget(budgetKbps, width, height, requestedFps) {
98640
+ const pixels = Math.max(1, width) * Math.max(1, height);
98641
+ const affordableFps = Math.round(budgetKbps * 1e3 / (pixels * MIN_BITS_PER_PIXEL));
98642
+ return Math.max(1, Math.min(requestedFps, affordableFps));
98643
+ }
98141
98644
  function buildVideoPlan(input) {
98142
98645
  if (!input.transcode) return {
98143
98646
  kind: "copy",
98144
98647
  bitstreamFilter: "dump_extra"
98145
98648
  };
98649
+ const deliveredFps = input.budgetKbps === null ? input.fps : fpsForBudget(input.budgetKbps, input.width, input.height, input.fps);
98146
98650
  return {
98147
98651
  kind: "encode",
98148
98652
  encoder: "libx264",
@@ -98153,11 +98657,12 @@ function buildVideoPlan(input) {
98153
98657
  },
98154
98658
  preset: "ultrafast",
98155
98659
  tune: "zerolatency",
98156
- profile: "baseline",
98157
- level: "3.1",
98660
+ singleSlicePerFrame: true,
98661
+ profile: input.profile ?? "baseline",
98662
+ level: input.level ?? "3.1",
98158
98663
  pixelFormat: "yuv420p",
98159
- fps: input.fps,
98160
- gopFrames: Math.max(1, Math.round(input.fps * 4)),
98664
+ fps: deliveredFps,
98665
+ gopFrames: Math.max(1, Math.round(deliveredFps * 4)),
98161
98666
  ...input.budgetKbps === null ? {} : {
98162
98667
  bitrateKbps: input.budgetKbps,
98163
98668
  rateControl: RATE_CONTROL_TIGHT
@@ -98180,23 +98685,64 @@ function fitNotes(entries, bitrates, budgetKbps) {
98180
98685
  };
98181
98686
  });
98182
98687
  }
98183
- function transcodeReason(picked, profile, bitrates, budgetKbps) {
98184
- if (!canPassThrough(picked.codec)) return "source-codec";
98688
+ function transcodeReason(picked, profile, bitrates, budgetKbps, allowHevc, target) {
98689
+ if (!canPassThrough(picked.codec, allowHevc)) return "source-codec";
98690
+ if (!fitsRequestedResolution(picked, target)) return "over-resolution";
98185
98691
  if (profile === null) return "unknown-bitrate";
98186
98692
  return classifyBitrateFit(bitrates.get(profile), budgetKbps) === "over-budget" ? "over-budget" : "unknown-bitrate";
98187
98693
  }
98188
98694
  /**
98189
- * iOS Home renders only H.264 over the classic HAP SRTP path, so an H.265
98190
- * source can never be passed through. An UNKNOWN codec is treated the same
98191
- * way: guessing H.264 is how a camera that changed its encoder ends up
98192
- * shipping bytes no controller can decode.
98695
+ * Which source codecs may go on the wire untouched.
98696
+ *
98697
+ * H.264 always. H.265 only when the SESSION was negotiated on Apple's
98698
+ * multi-tier RTP service, which advertises `VideoCodecType 2 = H.265` in its
98699
+ * tiers — the classic HAP SRTP path renders H.264 only, and sending HEVC there
98700
+ * is a black picture with no error.
98701
+ *
98702
+ * The permission belongs to the session rather than to the camera because the
98703
+ * same camera serves both services: which one negotiated decides what may be
98704
+ * copied. Refusing HEVC on the new service transcodes 4K HEVC down to H.264
98705
+ * for a controller that explicitly asked for the HEVC, which is the entire cost
98706
+ * that service exists to remove.
98707
+ *
98708
+ * An UNKNOWN codec is refused on BOTH. Guessing H.264 is how a camera that
98709
+ * changed its encoder ends up shipping bytes no controller can decode, and the
98710
+ * new service's permission is for H.265 specifically — not for "whatever this
98711
+ * turns out to be".
98193
98712
  */
98194
- function canPassThrough(codec) {
98713
+ function canPassThrough(codec, allowHevc) {
98195
98714
  if (codec === void 0) return false;
98196
98715
  const lower = codec.toLowerCase();
98197
- if (lower.includes("h265") || lower.includes("hevc")) return false;
98716
+ if (lower.includes("h265") || lower.includes("hevc")) return allowHevc;
98198
98717
  return lower.includes("h264") || lower.includes("avc");
98199
98718
  }
98719
+ /**
98720
+ * May this slot be forwarded UNTOUCHED at the resolution the controller asked
98721
+ * for?
98722
+ *
98723
+ * Measured on the hub, 2026-09-17, from iOS's own Receiver Reports: two
98724
+ * cameras forwarding a 3840x2160 slot to a controller that had negotiated
98725
+ * 1280x720, and the busier of the two reported losing **52.7 %** of packets in
98726
+ * its worst window, 617 packets cumulative, jitter 54 618. The third camera,
98727
+ * transcoded to 720p because its source is HEVC, lost one packet. The operator
98728
+ * saw this as "the H.264 cameras are slower to load than the H.265 one" — and
98729
+ * they were, because a 4K key frame spans hundreds of packets and losing any
98730
+ * one of them makes the picture wait for the next key frame, up to 3.5 s away.
98731
+ *
98732
+ * `over-budget-tolerated` is unchanged and still right: a RATE overshoot is
98733
+ * something a controller copes with. A RESOLUTION overshoot is a different
98734
+ * thing — it hands the controller a picture it never asked to decode.
98735
+ *
98736
+ * Smaller than asked for is fine and is copied: we cannot add detail, and an
98737
+ * encoder run that only enlarges pixels spends CPU to make the picture no
98738
+ * better and the stream bigger.
98739
+ */
98740
+ function fitsRequestedResolution(entry, target) {
98741
+ const width = entry.resolution?.width;
98742
+ const height = entry.resolution?.height;
98743
+ if (width === void 0 || height === void 0) return true;
98744
+ return width <= target.width && height <= target.height;
98745
+ }
98200
98746
  function plausibleMeasured(value) {
98201
98747
  if (value === null || !Number.isFinite(value)) return null;
98202
98748
  return value >= 64 ? value : null;
@@ -98384,6 +98930,21 @@ async function probe$1(call, label, log) {
98384
98930
  }
98385
98931
  }
98386
98932
  //#endregion
98933
+ //#region src/mappers/builders/passthrough-request.ts
98934
+ function passthroughRequest(input) {
98935
+ const acceptCodecs = ["h264"];
98936
+ if (input.multiTier && input.codec !== TierVideoCodec.H264) acceptCodecs.push("h265", "hevc");
98937
+ return {
98938
+ requirements: {
98939
+ acceptCodecs,
98940
+ allowTranscode: false,
98941
+ requireProfileBound: true,
98942
+ maxHeight: input.negotiatedHeight
98943
+ },
98944
+ preferences: { targetHeight: input.negotiatedHeight }
98945
+ };
98946
+ }
98947
+ //#endregion
98387
98948
  //#region src/mappers/builders/stream-ffmpeg-args.ts
98388
98949
  /**
98389
98950
  * The ffmpeg PLAN for one HomeKit streaming session.
@@ -98486,6 +99047,26 @@ var HAP_INPUT_PROBE = {
98486
99047
  analyzeDurationUs: 2e5,
98487
99048
  probeSizeBytes: 64 * 1024
98488
99049
  };
99050
+ /**
99051
+ * ONE encoder thread on the encode branch, none pinned on a copy. (D515)
99052
+ *
99053
+ * The plan asks x264 for one slice per access unit (`sliced-threads=0`). On
99054
+ * its own that flag hands x264 its AUTO thread count — 30 on the 20-core hub —
99055
+ * as FRAME threads, and frame threading delays output by `threads − 1`
99056
+ * frames: measured on the live hub as the first packet leaving ~3 s later at
99057
+ * the 10 fps a 269 kbps budget dials. Four threads still cost ~1 s. One thread
99058
+ * was measured with no start-up delay, one slice, and — unlike the eleven-slice
99059
+ * encode — no `VBV underflow` from x264, and a 720p `ultrafast` encode is far
99060
+ * inside what one core carries. A session is bounded by what one controller
99061
+ * asked for, never by this host's core count.
99062
+ *
99063
+ * `-threads` is an OUTPUT option here, emitted inside the video leg by the
99064
+ * shared builder, so it never reaches the decoder or the audio leg.
99065
+ */
99066
+ var HAP_ENCODER_THREADS = 1;
99067
+ function encoderThreadCount(video) {
99068
+ return video.kind === "encode" ? HAP_ENCODER_THREADS : 0;
99069
+ }
98489
99070
  function buildSessionInvocation(input) {
98490
99071
  return {
98491
99072
  logLevel: "warning",
@@ -98499,7 +99080,7 @@ function buildSessionInvocation(input) {
98499
99080
  },
98500
99081
  video: input.video,
98501
99082
  audio: audioPlan(input),
98502
- threadCount: 0,
99083
+ threadCount: encoderThreadCount(input.video),
98503
99084
  outputArgs: [],
98504
99085
  sink: {
98505
99086
  kind: "rtp-outputs",
@@ -98657,7 +99238,7 @@ function toCamProfile(profileId) {
98657
99238
  //#endregion
98658
99239
  //#region src/mappers/builders/h264-idr.ts
98659
99240
  /**
98660
- * Does this RTP packet carry the start of an H.264 IDR?
99241
+ * Does this RTP packet carry the start of a key frame?
98661
99242
  *
98662
99243
  * A pass-through session cannot manufacture a key frame on demand — it can
98663
99244
  * only forward the one the camera decides to emit. So the number that decides
@@ -98671,6 +99252,16 @@ function toCamProfile(profileId) {
98671
99252
  * boolean per packet, no state, no allocation, and it never throws. It runs on
98672
99253
  * every forwarded video packet, and a parser that throws on a malformed packet
98673
99254
  * would take the media path down with it.
99255
+ *
99256
+ * ## Two codecs, two headers
99257
+ *
99258
+ * H.264 (RFC 6184) has a ONE-byte NAL header whose type is the low 5 bits.
99259
+ * HEVC (RFC 7798) has a TWO-byte header whose type is bits 1..6 of the first.
99260
+ * The H.264 parser applied to an HEVC packet reads a type that means nothing
99261
+ * and answers "no key frame" forever — which, since this is telemetry and not
99262
+ * a gate, would show an HEVC session streaming perfectly with `videoKeyframes`
99263
+ * stuck at zero. That is the exact shape of the bug these counters exist to
99264
+ * detect (D515), so the codec is a parameter rather than an assumption.
98674
99265
  */
98675
99266
  /** NAL unit type carrying a coded slice of an IDR picture (RFC 6184 §5.2). */
98676
99267
  var NAL_TYPE_IDR = 5;
@@ -98683,6 +99274,16 @@ var RTP_MIN_HEADER_BYTES = 12;
98683
99274
  var NAL_TYPE_MASK = 31;
98684
99275
  /** FU header start bit — set only on the FIRST fragment of a fragmented NAL. */
98685
99276
  var FU_START_BIT = 128;
99277
+ /** HEVC NAL types 16..23 are IRAP pictures — the key frames of HEVC. */
99278
+ var HEVC_IRAP_MIN = 16;
99279
+ var HEVC_IRAP_MAX = 23;
99280
+ /** RFC 7798 payload structures: 48 aggregation, 49 fragmentation unit. */
99281
+ var HEVC_TYPE_AP = 48;
99282
+ var HEVC_TYPE_FU = 49;
99283
+ var HEVC_TYPE_MASK = 63;
99284
+ function rtpPacketCarriesKeyframe(packet, codec) {
99285
+ return codec === "h265" ? rtpPacketCarriesIrap(packet) : rtpPacketCarriesIdr(packet);
99286
+ }
98686
99287
  function rtpPacketCarriesIdr(packet) {
98687
99288
  const payloadStart = rtpPayloadOffset(packet);
98688
99289
  if (payloadStart === null) return false;
@@ -98732,6 +99333,158 @@ function stapContainsIdr(packet, start) {
98732
99333
  }
98733
99334
  return false;
98734
99335
  }
99336
+ /** HEVC's half: the same shape, read through RFC 7798's two-byte header. */
99337
+ function rtpPacketCarriesIrap(packet) {
99338
+ const payloadStart = rtpPayloadOffset(packet);
99339
+ if (payloadStart === null) return false;
99340
+ const firstPayloadByte = packet[payloadStart];
99341
+ if (firstPayloadByte === void 0) return false;
99342
+ const nalType = firstPayloadByte >> 1 & HEVC_TYPE_MASK;
99343
+ if (nalType === HEVC_TYPE_FU) {
99344
+ const fuHeader = packet[payloadStart + 2];
99345
+ if (fuHeader === void 0) return false;
99346
+ if ((fuHeader & FU_START_BIT) === 0) return false;
99347
+ return isHevcIrap(fuHeader & HEVC_TYPE_MASK);
99348
+ }
99349
+ if (nalType === HEVC_TYPE_AP) return hevcAggregationContainsIrap(packet, payloadStart + 2);
99350
+ return isHevcIrap(nalType);
99351
+ }
99352
+ function isHevcIrap(nalType) {
99353
+ return nalType >= HEVC_IRAP_MIN && nalType <= HEVC_IRAP_MAX;
99354
+ }
99355
+ /** Walk an aggregation packet's `[size][nal]` pairs looking for an IRAP. */
99356
+ function hevcAggregationContainsIrap(packet, start) {
99357
+ let offset = start;
99358
+ while (offset + 2 <= packet.length) {
99359
+ const size = packet.readUInt16BE(offset);
99360
+ offset += 2;
99361
+ if (size === 0 || offset + size > packet.length) return false;
99362
+ const nalHeader = packet[offset];
99363
+ if (nalHeader === void 0) return false;
99364
+ if (isHevcIrap(nalHeader >> 1 & HEVC_TYPE_MASK)) return true;
99365
+ offset += size;
99366
+ }
99367
+ return false;
99368
+ }
99369
+ //#endregion
99370
+ //#region src/multi-tier/native-codec.ts
99371
+ /**
99372
+ * The codec the camera really publishes, for the multi-tier service's tier
99373
+ * declaration.
99374
+ *
99375
+ * A tier list names ONE codec for all its tiers, so this has to pick. It picks
99376
+ * from the LARGEST stream, because that is the one the high tier will carry and
99377
+ * the one whose codec decides whether we transcode. A camera that offers a tiny
99378
+ * HEVC substream beside a 1080p H.264 main is an H.264 camera for our purposes.
99379
+ *
99380
+ * Absence falls back to H.264: it is what every controller accepts and what the
99381
+ * classic path already serves, so an unreadable publisher costs us the feature
99382
+ * rather than the camera.
99383
+ */
99384
+ /** The spellings publishers in this fleet actually use for HEVC. */
99385
+ function isHevc(codec) {
99386
+ if (codec === void 0) return false;
99387
+ const normalised = codec.toLowerCase().replace(/[.\-_\s]/g, "");
99388
+ return normalised === "h265" || normalised === "hevc";
99389
+ }
99390
+ /** The stream whose codec and size describe this camera at its best. */
99391
+ function largestReadableStream(streams) {
99392
+ let best;
99393
+ let bestArea = -1;
99394
+ for (const stream of streams) {
99395
+ if (stream.codec === void 0) continue;
99396
+ const area = stream.resolution === void 0 ? 0 : stream.resolution.width * stream.resolution.height;
99397
+ if (area > bestArea) {
99398
+ bestArea = area;
99399
+ best = stream;
99400
+ }
99401
+ }
99402
+ return best;
99403
+ }
99404
+ /**
99405
+ * The sensor the new camera service describes: size AND codec, from ONE stream.
99406
+ *
99407
+ * They used to come from two places and the kitchen camera showed what that
99408
+ * costs. The codec was read off the published streams -- h265, correct --
99409
+ * while the size came from the CLASSIC service's advertised resolution list,
99410
+ * which is capped at 1080p because that is the ceiling the classic service
99411
+ * accepts. So the accessory advertised "H.265, 1920x1080" for a camera whose
99412
+ * main stream is 3840x2160 H.265, and the 4K the new service exists to carry
99413
+ * was never offered.
99414
+ *
99415
+ * One stream answers both questions, so the two cannot disagree again.
99416
+ */
99417
+ function sensorForStreams(streams) {
99418
+ const best = largestReadableStream(streams);
99419
+ const resolution = best?.resolution;
99420
+ return {
99421
+ width: resolution?.width ?? 1920,
99422
+ height: resolution?.height ?? 1080,
99423
+ codec: isHevc(best?.codec) ? TierVideoCodec.H265 : TierVideoCodec.H264
99424
+ };
99425
+ }
99426
+ //#endregion
99427
+ //#region src/mappers/builders/stream-fps-probe.ts
99428
+ async function probeAdvertisedVideoProfile(bctx) {
99429
+ const { ctx, proxy, numericDeviceId, options } = bctx;
99430
+ const log = ctx.logger.withTags({ deviceId: numericDeviceId });
99431
+ const entries = await probe(() => proxy.cameraStreams?.getProfileRtspEntries({}), "cameraStreams.getProfileRtspEntries", log);
99432
+ const slots = await probe(() => proxy.cameraStreams?.getBrokerStreams({}), "cameraStreams.getBrokerStreams", log);
99433
+ const camStreams = await probe(() => proxy.cameraStreams?.getCameraStreams({}), "cameraStreams.getCameraStreams", log);
99434
+ const choices = await probe(() => proxy.webrtcSession?.listStreams({}), "webrtcSession.listStreams", log);
99435
+ const fpsByProfile = resolveProfileFps({
99436
+ choices: choices ?? [],
99437
+ slots: slots ?? [],
99438
+ camStreams: camStreams ?? []
99439
+ });
99440
+ const sensor = sensorForStreams(camStreams ?? []);
99441
+ const resolutions = deriveAdvertisedResolutions({
99442
+ candidates: CANDIDATE_RESOLUTIONS,
99443
+ entries: entries ?? [],
99444
+ deviceId: numericDeviceId,
99445
+ pref: options.hapDeviceSettings.streamPreference,
99446
+ fpsByProfile
99447
+ });
99448
+ const assumed = resolutions.filter((r) => r.source === "assumed").length;
99449
+ log.info("export-hap: advertised video profile derived", { meta: {
99450
+ streamPreference: options.hapDeviceSettings.streamPreference,
99451
+ resolutions: formatAdvertisedResolutions(resolutions),
99452
+ sensor: `${sensor.width}x${sensor.height} ${sensor.codec === TierVideoCodec.H265 ? "h265" : "h264"}`,
99453
+ profileFps: [...fpsByProfile.values()].map((f) => `${f.profile}=${f.fps}(${f.source})`),
99454
+ assumedCount: assumed,
99455
+ previouslyAdvertisedFps: 30
99456
+ } });
99457
+ if (assumed === resolutions.length) log.warn("export-hap: no measured or published frame rate for ANY profile — advertising the assumed rate", { meta: {
99458
+ entries: entries?.length ?? 0,
99459
+ choices: choices?.length ?? 0
99460
+ } });
99461
+ return {
99462
+ resolutions,
99463
+ fpsByProfile,
99464
+ sensor
99465
+ };
99466
+ }
99467
+ /**
99468
+ * Run one cap read. Returns null both when the cap is not bound and when the
99469
+ * call throws — and LOGS which, because "the camera has no telemetry" and "the
99470
+ * telemetry call failed" lead to different fixes.
99471
+ */
99472
+ async function probe(call, label, log) {
99473
+ try {
99474
+ const pending = call();
99475
+ if (pending === void 0) {
99476
+ log.info("export-hap: fps probe skipped — cap not bound on this device", { meta: { call: label } });
99477
+ return null;
99478
+ }
99479
+ return await pending;
99480
+ } catch (err) {
99481
+ log.warn("export-hap: fps probe failed — falling back to a lower-authority source", { meta: {
99482
+ call: label,
99483
+ error: err instanceof Error ? err.message : String(err)
99484
+ } });
99485
+ return null;
99486
+ }
99487
+ }
98735
99488
  /**
98736
99489
  * How long after spawn an exit still counts as "hardware init failed".
98737
99490
  *
@@ -99184,6 +99937,17 @@ var SESSION_HEARTBEAT_MS = 5e3;
99184
99937
  * what the controller thinks without becoming one.
99185
99938
  */
99186
99939
  var RECEIVER_REPORT_LOG_INTERVAL_MS = 5e3;
99940
+ /** HAP's profile/level enums are numeric; ffmpeg wants their names. */
99941
+ var HAP_PROFILE_NAMES = {
99942
+ 0: "baseline",
99943
+ 1: "main",
99944
+ 2: "high"
99945
+ };
99946
+ var HAP_LEVEL_NAMES = {
99947
+ 0: "3.1",
99948
+ 1: "3.2",
99949
+ 2: "4.0"
99950
+ };
99187
99951
  function buildCameraStreamingDelegate(bctx, advertised) {
99188
99952
  const { ctx, numericDeviceId } = bctx;
99189
99953
  const log = ctx.logger.withTags({ deviceId: numericDeviceId });
@@ -99197,7 +99961,7 @@ function buildCameraStreamingDelegate(bctx, advertised) {
99197
99961
  });
99198
99962
  },
99199
99963
  prepareStream(request, callback) {
99200
- prepareStream(request, sessions, bctx).then((resp) => callback(void 0, resp)).catch((err) => {
99964
+ prepareStream(request, sessions, bctx, "classic").then((resp) => callback(void 0, resp)).catch((err) => {
99201
99965
  log.warn("export-hap: prepareStream failed", { meta: { error: errMsg$12(err) } });
99202
99966
  callback(err instanceof Error ? err : new Error(errMsg$12(err)));
99203
99967
  });
@@ -99237,6 +100001,29 @@ function buildCameraStreamingDelegate(bctx, advertised) {
99237
100001
  comfort_noise: false
99238
100002
  }
99239
100003
  },
100004
+ negotiator: {
100005
+ async prepare(request) {
100006
+ const response = await prepareStream(request, sessions, bctx, "multi-tier");
100007
+ const session = sessions.get(request.sessionID);
100008
+ if (session === void 0) throw new Error("export-hap: prepareStream returned without registering a session");
100009
+ const video = response.video;
100010
+ const audio = response.audio;
100011
+ 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");
100012
+ return {
100013
+ localAddress: session.localAddress,
100014
+ videoPort: video.port,
100015
+ audioPort: audio.port,
100016
+ videoSsrc: video.ssrc,
100017
+ audioSsrc: audio.ssrc
100018
+ };
100019
+ },
100020
+ async control(request) {
100021
+ await handleStreamRequest(request, sessions, bctx, advertised);
100022
+ },
100023
+ has(sessionId) {
100024
+ return sessions.has(sessionId);
100025
+ }
100026
+ },
99240
100027
  dispose: async () => {
99241
100028
  for (const session of sessions.values()) {
99242
100029
  session.teardownTrigger = "accessory-dispose";
@@ -99267,7 +100054,7 @@ async function handleSnapshot(bctx, request) {
99267
100054
  }
99268
100055
  return Buffer.from(result.base64, "base64");
99269
100056
  }
99270
- async function prepareStream(request, sessions, bctx) {
100057
+ async function prepareStream(request, sessions, bctx, negotiatedVia) {
99271
100058
  const ipVersion = request.addressVersion;
99272
100059
  const localIp = pickLocalInterfaceIp(request.targetAddress, ipVersion);
99273
100060
  const videoUdp = await bindUdp(ipVersion, localIp);
@@ -99341,6 +100128,9 @@ async function prepareStream(request, sessions, bctx) {
99341
100128
  const audioSsrc = randomSsrc();
99342
100129
  const session = {
99343
100130
  sessionId: request.sessionID,
100131
+ localAddress: localIp,
100132
+ negotiatedVia,
100133
+ wireCodec: "h264",
99344
100134
  startedAtMs: null,
99345
100135
  endedAtMs: null,
99346
100136
  negotiated: null,
@@ -99439,7 +100229,7 @@ async function prepareStream(request, sessions, bctx) {
99439
100229
  });
99440
100230
  videoLoopUdp.on("message", (rtpPacket) => {
99441
100231
  session.videoPacketsForwarded += 1;
99442
- if (rtpPacketCarriesIdr(rtpPacket)) recordKeyframe(session);
100232
+ if (rtpPacketCarriesKeyframe(rtpPacket, session.wireCodec)) recordKeyframe(session);
99443
100233
  if (session.videoPacketsForwarded === 1) tagLog.info("export-hap: first video packet from ffmpeg", { meta: {
99444
100234
  sessionId: session.sessionId,
99445
100235
  bytes: rtpPacket.length
@@ -100069,7 +100859,9 @@ async function handleStreamRequest(request, sessions, bctx, advertised) {
100069
100859
  audioPt: request.audio.pt,
100070
100860
  audioSsrc: session.audioOutSsrc,
100071
100861
  audioSampleRateEnum: request.audio.sample_rate,
100072
- audioPacketTimeMs: packetTimeMs
100862
+ audioPacketTimeMs: packetTimeMs,
100863
+ profile: request.video.profile,
100864
+ level: request.video.level
100073
100865
  };
100074
100866
  const startParams = {
100075
100867
  width: request.video.width,
@@ -100078,6 +100870,8 @@ async function handleStreamRequest(request, sessions, bctx, advertised) {
100078
100870
  max_bit_rate: request.video.max_bit_rate,
100079
100871
  mtu: request.video.mtu,
100080
100872
  pt: request.video.pt,
100873
+ profile: request.video.profile,
100874
+ level: request.video.level,
100081
100875
  audio_pt: request.audio.pt,
100082
100876
  audio_ssrc: session.audioOutSsrc,
100083
100877
  sample_rate: request.audio.sample_rate,
@@ -100109,7 +100903,9 @@ async function handleStreamRequest(request, sessions, bctx, advertised) {
100109
100903
  audio_pt: session.lastStartParams.audioPt,
100110
100904
  audio_ssrc: session.lastStartParams.audioSsrc,
100111
100905
  sample_rate: session.lastStartParams.audioSampleRateEnum,
100112
- packet_time: session.lastStartParams.audioPacketTimeMs
100906
+ packet_time: session.lastStartParams.audioPacketTimeMs,
100907
+ profile: session.lastStartParams.profile,
100908
+ level: session.lastStartParams.level
100113
100909
  };
100114
100910
  log.info("export-hap: stream reconfigured", { meta: {
100115
100911
  sessionId: request.sessionID,
@@ -100134,6 +100930,22 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
100134
100930
  throw new Error(`export-hap: no profile RTSP entries for device ${numericDeviceId}`);
100135
100931
  }
100136
100932
  const pref = options.hapDeviceSettings.streamPreference;
100933
+ const capPick = pref === "auto" ? await probe(() => proxy.cameraStreams?.pickStream(passthroughRequest({
100934
+ negotiatedHeight: video.height,
100935
+ multiTier: session.negotiatedVia === "multi-tier"
100936
+ })), "cameraStreams.pickStream", startLog) : null;
100937
+ if (capPick?.profile !== void 0) startLog.info("export-hap: the shared picker chose the slot", { meta: {
100938
+ sessionId,
100939
+ profile: capPick.profile,
100940
+ camStreamId: capPick.camStreamId,
100941
+ codec: capPick.codec ?? "unknown",
100942
+ fps: capPick.fps ?? null,
100943
+ fpsSource: capPick.fpsSource ?? null,
100944
+ negotiated: `${video.width}x${video.height}`,
100945
+ reason: capPick.reason
100946
+ } });
100947
+ /** The picker's slot when it found one, otherwise the operator's setting. */
100948
+ const effectivePref = capPick?.profile ?? pref;
100137
100949
  const bitrates = await probeProfileBitrates({
100138
100950
  bctx,
100139
100951
  slots: brokerStreams,
@@ -100147,14 +100959,15 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
100147
100959
  const fit = selectStreamForBudget({
100148
100960
  entries: withSlotCodecs(entries, brokerStreams),
100149
100961
  deviceId: numericDeviceId,
100150
- pref,
100962
+ pref: effectivePref,
100151
100963
  connection,
100152
100964
  targetResolution: {
100153
100965
  width: video.width,
100154
100966
  height: video.height
100155
100967
  },
100156
100968
  negotiatedMaxBitrateKbps: video.max_bit_rate,
100157
- bitrates
100969
+ bitrates,
100970
+ allowHevcPassthrough: session.negotiatedVia === "multi-tier"
100158
100971
  });
100159
100972
  if (fit === null) {
100160
100973
  startLog.warn("export-hap: stream start DROPPED — no ENABLED profile RTSP entry", { meta: {
@@ -100183,6 +100996,11 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
100183
100996
  const slot = brokerStreams.find((s) => s.profile === fit.profile);
100184
100997
  const codec = (picked.codec ?? slot?.codec ?? "").toLowerCase();
100185
100998
  const needsTranscode = fit.kind === "transcode";
100999
+ const encodeBudget = needsTranscode ? encodeBudgetKbps({
101000
+ negotiatedBudgetKbps: fit.budgetKbps,
101001
+ width: video.width
101002
+ }) : fit.budgetKbps;
101003
+ session.wireCodec = !needsTranscode && (codec.includes("h265") || codec.includes("hevc")) ? "h265" : "h264";
100186
101004
  const pickedProfile = toKnownProfile(picked.profileId);
100187
101005
  const resolvedFps = pickedProfile === null ? void 0 : advertised.fpsByProfile.get(pickedProfile);
100188
101006
  const advertisedFps = resolvedFps?.fps ?? video.fps;
@@ -100203,7 +101021,7 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
100203
101021
  fitReason: fit.reason,
100204
101022
  publishedKbps: slotEvidence?.publishedKbps ?? null,
100205
101023
  measuredKbps: slotEvidence?.measuredKbps ?? null,
100206
- budgetKbps: fit.budgetKbps,
101024
+ budgetKbps: encodeBudget,
100207
101025
  fitNotes
100208
101026
  };
100209
101027
  startLog.info("export-hap: stream bitrate fit resolved", { meta: {
@@ -100211,7 +101029,7 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
100211
101029
  decision: fit.kind,
100212
101030
  reason: fit.reason,
100213
101031
  negotiatedMaxBitrateKbps: video.max_bit_rate,
100214
- encodeBudgetKbps: fit.budgetKbps,
101032
+ encodeBudgetKbps: encodeBudget,
100215
101033
  profile: pickedProfile,
100216
101034
  brokerId: picked.brokerId,
100217
101035
  slotPublishedKbps: slotEvidence?.publishedKbps ?? null,
@@ -100232,12 +101050,23 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
100232
101050
  const audioLoopPort = session.audioLoopUdp.address().port;
100233
101051
  const videoTarget = `rtp://127.0.0.1:${videoLoopPort}?pkt_size=${video.mtu}`;
100234
101052
  const audioTarget = `rtp://127.0.0.1:${audioLoopPort}?pkt_size=${video.mtu}`;
101053
+ const selectedProfile = HAP_PROFILE_NAMES[video.profile] ?? "baseline";
101054
+ const selectedLevel = HAP_LEVEL_NAMES[video.level] ?? "3.1";
101055
+ ctx.logger.info("export-hap: encoder profile", {
101056
+ tags: { deviceId: numericDeviceId },
101057
+ meta: {
101058
+ selectedByController: `${selectedProfile}/${selectedLevel}`,
101059
+ transcode: needsTranscode
101060
+ }
101061
+ });
100235
101062
  const videoPlan = buildVideoPlan({
100236
101063
  transcode: needsTranscode,
100237
101064
  width: video.width,
100238
101065
  height: video.height,
100239
101066
  fps: deliveredFps,
100240
- budgetKbps: fit.budgetKbps
101067
+ budgetKbps: encodeBudget,
101068
+ profile: selectedProfile,
101069
+ level: selectedLevel
100241
101070
  });
100242
101071
  const hwDecode = selectHwDecode({
100243
101072
  transcode: needsTranscode,
@@ -100346,7 +101175,7 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
100346
101175
  slotFpsSource: advertisedFpsSource,
100347
101176
  deliveredFps,
100348
101177
  fitReason: fit.reason,
100349
- encodeBudgetKbps: fit.budgetKbps,
101178
+ encodeBudgetKbps: encodeBudget,
100350
101179
  audioCodec: "opus",
100351
101180
  audioBitrateKbps: OPUS_BITRATE_KBPS,
100352
101181
  videoDecode: hwDecode.kind === "hardware" ? hwDecode.backend : "software"
@@ -100953,65 +101782,6 @@ function errMsg$8(err) {
100953
101782
  return err instanceof Error ? err.message : String(err);
100954
101783
  }
100955
101784
  //#endregion
100956
- //#region src/mappers/builders/stream-fps-probe.ts
100957
- async function probeAdvertisedVideoProfile(bctx) {
100958
- const { ctx, proxy, numericDeviceId, options } = bctx;
100959
- const log = ctx.logger.withTags({ deviceId: numericDeviceId });
100960
- const entries = await probe(() => proxy.cameraStreams?.getProfileRtspEntries({}), "cameraStreams.getProfileRtspEntries", log);
100961
- const slots = await probe(() => proxy.cameraStreams?.getBrokerStreams({}), "cameraStreams.getBrokerStreams", log);
100962
- const camStreams = await probe(() => proxy.cameraStreams?.getCameraStreams({}), "cameraStreams.getCameraStreams", log);
100963
- const choices = await probe(() => proxy.webrtcSession?.listStreams({}), "webrtcSession.listStreams", log);
100964
- const fpsByProfile = resolveProfileFps({
100965
- choices: choices ?? [],
100966
- slots: slots ?? [],
100967
- camStreams: camStreams ?? []
100968
- });
100969
- const resolutions = deriveAdvertisedResolutions({
100970
- candidates: CANDIDATE_RESOLUTIONS,
100971
- entries: entries ?? [],
100972
- deviceId: numericDeviceId,
100973
- pref: options.hapDeviceSettings.streamPreference,
100974
- fpsByProfile
100975
- });
100976
- const assumed = resolutions.filter((r) => r.source === "assumed").length;
100977
- log.info("export-hap: advertised video profile derived", { meta: {
100978
- streamPreference: options.hapDeviceSettings.streamPreference,
100979
- resolutions: formatAdvertisedResolutions(resolutions),
100980
- profileFps: [...fpsByProfile.values()].map((f) => `${f.profile}=${f.fps}(${f.source})`),
100981
- assumedCount: assumed,
100982
- previouslyAdvertisedFps: 30
100983
- } });
100984
- if (assumed === resolutions.length) log.warn("export-hap: no measured or published frame rate for ANY profile — advertising the assumed rate", { meta: {
100985
- entries: entries?.length ?? 0,
100986
- choices: choices?.length ?? 0
100987
- } });
100988
- return {
100989
- resolutions,
100990
- fpsByProfile
100991
- };
100992
- }
100993
- /**
100994
- * Run one cap read. Returns null both when the cap is not bound and when the
100995
- * call throws — and LOGS which, because "the camera has no telemetry" and "the
100996
- * telemetry call failed" lead to different fixes.
100997
- */
100998
- async function probe(call, label, log) {
100999
- try {
101000
- const pending = call();
101001
- if (pending === void 0) {
101002
- log.info("export-hap: fps probe skipped — cap not bound on this device", { meta: { call: label } });
101003
- return null;
101004
- }
101005
- return await pending;
101006
- } catch (err) {
101007
- log.warn("export-hap: fps probe failed — falling back to a lower-authority source", { meta: {
101008
- call: label,
101009
- error: err instanceof Error ? err.message : String(err)
101010
- } });
101011
- return null;
101012
- }
101013
- }
101014
- //#endregion
101015
101785
  //#region src/hksv/controller-census.ts
101016
101786
  var EMPTY_CENSUS = {
101017
101787
  serverPublished: false,
@@ -102166,6 +102936,776 @@ function accessoryUuidFor(kind, deviceId) {
102166
102936
  return import_dist.uuid.generate(kind === "camera" ? `camstack:camera:${deviceId}` : `camstack:device:${deviceId}`);
102167
102937
  }
102168
102938
  //#endregion
102939
+ //#region src/multi-tier/rtp-streaming-control.ts
102940
+ /**
102941
+ * `RTP Streaming Control` (0x8045) — start and stop, on the new service.
102942
+ *
102943
+ * It replaces the classic `Selected RTP Stream Configuration`, and it is a
102944
+ * different shape, not a variation. The classic one carries the whole selected
102945
+ * configuration inside the start: codec, profile, level, resolution, bitrate.
102946
+ * This one carries a **tier identifier**. The configuration was agreed when we
102947
+ * published `Supported Video Stream Tiers`; the controller now names one of
102948
+ * those entries.
102949
+ *
102950
+ * That is why the adapter maps an identifier back to the tier we advertised
102951
+ * instead of reading parameters off the write — and why a tier identifier we
102952
+ * never published is `No Such Stream` rather than something to improvise from.
102953
+ *
102954
+ * Bytes only. The engine lives elsewhere.
102955
+ */
102956
+ var RtpStreamingCommand = /* @__PURE__ */ function(RtpStreamingCommand) {
102957
+ RtpStreamingCommand[RtpStreamingCommand["END"] = 1] = "END";
102958
+ RtpStreamingCommand[RtpStreamingCommand["START"] = 2] = "START";
102959
+ return RtpStreamingCommand;
102960
+ }({});
102961
+ var RtpStreamingStatus = /* @__PURE__ */ function(RtpStreamingStatus) {
102962
+ RtpStreamingStatus[RtpStreamingStatus["SUCCESS"] = 0] = "SUCCESS";
102963
+ RtpStreamingStatus[RtpStreamingStatus["UNKNOWN_SESSION_IDENTIFIER"] = 1] = "UNKNOWN_SESSION_IDENTIFIER";
102964
+ /** An End for a stream that is not started. */
102965
+ RtpStreamingStatus[RtpStreamingStatus["NO_SUCH_STREAM"] = 2] = "NO_SUCH_STREAM";
102966
+ RtpStreamingStatus[RtpStreamingStatus["BUSY"] = 3] = "BUSY";
102967
+ RtpStreamingStatus[RtpStreamingStatus["ERROR"] = 4] = "ERROR";
102968
+ return RtpStreamingStatus;
102969
+ }({});
102970
+ function required$1(map, type, what) {
102971
+ const value = map[String(type)];
102972
+ if (value === void 0) throw new Error(`rtp-streaming-control: ${what} is missing from the controller's write`);
102973
+ return value;
102974
+ }
102975
+ function optionalUint32(map, type) {
102976
+ const value = map[String(type)];
102977
+ return value === void 0 ? void 0 : value.readUInt32LE(0);
102978
+ }
102979
+ function decodeRtpStreamingControl(base64) {
102980
+ const map = (0, import_dist.decode)(Buffer.from(base64, "base64"));
102981
+ const command = required$1(map, 2, "the command").readUInt8(0);
102982
+ if (command !== 2 && command !== 1) throw new Error(`rtp-streaming-control: command ${command} is reserved or unknown`);
102983
+ return {
102984
+ sessionId: import_dist.uuid.unparse(required$1(map, 1, "the session identifier")),
102985
+ command,
102986
+ videoTier: optionalUint32(map, 3),
102987
+ videoSsrc: optionalUint32(map, 4),
102988
+ audioTier: optionalUint32(map, 5),
102989
+ audioSsrc: optionalUint32(map, 6)
102990
+ };
102991
+ }
102992
+ /**
102993
+ * The value the characteristic reads back with: the status of the last write.
102994
+ *
102995
+ * `sessionId` is null before any write has happened. The read still has to
102996
+ * answer — a characteristic that answers nothing is what put the kitchen camera
102997
+ * on "loading" — so it answers with a status and no session rather than with an
102998
+ * invented one.
102999
+ */
103000
+ function encodeRtpStreamingControlStatus(sessionId, status) {
103001
+ if (sessionId === null) return (0, import_dist.encode)(2, Buffer.from([status])).toString("base64");
103002
+ return (0, import_dist.encode)(1, import_dist.uuid.write(sessionId), 2, Buffer.from([status])).toString("base64");
103003
+ }
103004
+ //#endregion
103005
+ //#region src/multi-tier/setup-endpoints.ts
103006
+ /**
103007
+ * The `Setup Endpoints` exchange, for the multi-tier RTP service.
103008
+ *
103009
+ * § 3.6 lists this characteristic among the service's required ones and does
103010
+ * NOT redefine it: it is R17's, byte for byte, and hap-nodejs already speaks it
103011
+ * on the classic service. The types below are transcribed from that
103012
+ * implementation rather than guessed, and the spec file decodes what this
103013
+ * encodes with the same library — a transcription error is otherwise entirely
103014
+ * plausible on review.
103015
+ *
103016
+ * Nothing here talks to a socket. The bytes are the part a test can hold still;
103017
+ * the adapter that drives the streaming engine is a separate file for exactly
103018
+ * that reason.
103019
+ */
103020
+ var SetupEndpointsStatus = /* @__PURE__ */ function(SetupEndpointsStatus) {
103021
+ SetupEndpointsStatus[SetupEndpointsStatus["SUCCESS"] = 0] = "SUCCESS";
103022
+ SetupEndpointsStatus[SetupEndpointsStatus["BUSY"] = 1] = "BUSY";
103023
+ SetupEndpointsStatus[SetupEndpointsStatus["ERROR"] = 2] = "ERROR";
103024
+ return SetupEndpointsStatus;
103025
+ }({});
103026
+ /**
103027
+ * Read a TLV field that must be present, naming it when it is not.
103028
+ *
103029
+ * A controller's bytes really can be short, and the refusal we send back has to
103030
+ * carry a reason — "undefined is not an object", thrown three frames away in
103031
+ * the socket code, is not one.
103032
+ */
103033
+ function required(map, type, what) {
103034
+ const value = map[String(type)];
103035
+ if (value === void 0) throw new Error(`setup-endpoints: ${what} is missing from the controller's write`);
103036
+ return value;
103037
+ }
103038
+ function uint16$1(value) {
103039
+ const b = Buffer.alloc(2);
103040
+ b.writeUInt16LE(value, 0);
103041
+ return b;
103042
+ }
103043
+ function uint32$2(value) {
103044
+ const b = Buffer.alloc(4);
103045
+ b.writeUInt32LE(value, 0);
103046
+ return b;
103047
+ }
103048
+ function decodeSrtp(payload, what) {
103049
+ const map = (0, import_dist.decode)(payload);
103050
+ return {
103051
+ cryptoSuite: required(map, 1, `${what} crypto suite`).readUInt8(0),
103052
+ key: required(map, 2, `${what} master key`),
103053
+ salt: required(map, 3, `${what} master salt`)
103054
+ };
103055
+ }
103056
+ function decodeSetupEndpointsRequest(base64) {
103057
+ const map = (0, import_dist.decode)(Buffer.from(base64, "base64"));
103058
+ const address = (0, import_dist.decode)(required(map, 3, "the controller address"));
103059
+ return {
103060
+ sessionId: import_dist.uuid.unparse(required(map, 1, "the session id")),
103061
+ addressVersion: required(address, 1, "the address version").readUInt8(0) === 0 ? "ipv4" : "ipv6",
103062
+ controllerAddress: required(address, 2, "the address").toString("utf8"),
103063
+ videoPort: required(address, 3, "the video port").readUInt16LE(0),
103064
+ audioPort: required(address, 4, "the audio port").readUInt16LE(0),
103065
+ videoSrtp: decodeSrtp(required(map, 4, "the video srtp parameters"), "video"),
103066
+ audioSrtp: decodeSrtp(required(map, 5, "the audio srtp parameters"), "audio")
103067
+ };
103068
+ }
103069
+ function encodeSetupEndpointsResponse(response) {
103070
+ 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));
103071
+ const srtp = (params) => (0, import_dist.encode)(1, Buffer.from([0]), 2, params.key, 3, params.salt);
103072
+ 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");
103073
+ }
103074
+ /**
103075
+ * A refusal: STATUS and nothing else, which is exactly what the classic
103076
+ * service writes. Not even the session id — matching it byte for byte means a
103077
+ * controller parses our refusal with the code it already has.
103078
+ */
103079
+ function encodeSetupEndpointsRefusal(status) {
103080
+ return (0, import_dist.encode)(2, Buffer.from([status])).toString("base64");
103081
+ }
103082
+ //#endregion
103083
+ //#region src/multi-tier/audio-stream-tiers.ts
103084
+ /**
103085
+ * The `Supported Audio Stream Tiers` TLV8 — 0x8044, a REQUIRED characteristic
103086
+ * of `camera-multi-tier-rtp-stream-management`.
103087
+ *
103088
+ * ## Not the WebRTC one
103089
+ *
103090
+ * The WebRTC service has its own audio characteristic at 0x805A, and the two
103091
+ * are easy to confuse because they carry the same idea. This file is the RTP
103092
+ * one. A service that declares the wrong sibling is a service the controller
103093
+ * reads and abandons without saying why.
103094
+ *
103095
+ * ## What the revision fixes, and why that is encoded as a refusal
103096
+ *
103097
+ * Three of the six fields are not choices in this revision of the guide:
103098
+ * packet time is 20 ms, channels is 1, and **exactly one tier is allowed**.
103099
+ * The tier count is the one that cannot be expressed in a type, so it is
103100
+ * checked — a second tier would be silently dropped by a controller, or worse,
103101
+ * accepted and then used to select a stream we never meant to offer.
103102
+ *
103103
+ * Pure bytes in, pure bytes out, like its video sibling.
103104
+ */
103105
+ /** Audio codec. Opus is the only value this revision defines, and the guide
103106
+ * requires the accessory to support it. */
103107
+ var TierAudioCodec = /* @__PURE__ */ function(TierAudioCodec) {
103108
+ TierAudioCodec[TierAudioCodec["OPUS"] = 3] = "OPUS";
103109
+ return TierAudioCodec;
103110
+ }({});
103111
+ function uint8$1(value) {
103112
+ const b = Buffer.alloc(1);
103113
+ b.writeUInt8(value, 0);
103114
+ return b;
103115
+ }
103116
+ function uint32$1(value) {
103117
+ const b = Buffer.alloc(4);
103118
+ b.writeUInt32LE(value, 0);
103119
+ return b;
103120
+ }
103121
+ /**
103122
+ * The one Opus tier we offer, from the rate the camera actually captures at.
103123
+ *
103124
+ * The capture rate is an input only so that the caller cannot forget it exists;
103125
+ * what goes on the wire is 48 kHz either way. Returning a tier rather than
103126
+ * bytes keeps the "what do we offer" decision separate from the encoding.
103127
+ */
103128
+ function opusTierForCapture(captureSampleRateHz) {
103129
+ return {
103130
+ identifier: 1,
103131
+ targetAverageBitrateBps: captureSampleRateHz >= 24e3 ? 32e3 : 24e3,
103132
+ sampleRate: 4,
103133
+ bitDepth: 2,
103134
+ packetTimeMs: 20,
103135
+ channels: 1
103136
+ };
103137
+ }
103138
+ function encodeAudioStreamTier(tier) {
103139
+ 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));
103140
+ }
103141
+ function encodeAudioStreamConfiguration(config) {
103142
+ if (config.tiers.length !== 1) throw new Error(`supported-audio-stream-tiers allows exactly one tier in this revision, got ${config.tiers.length}`);
103143
+ return (0, import_dist.encode)(1, uint8$1(config.codec), 2, uint8$1(config.payloadType), 3, config.tiers.map(encodeAudioStreamTier));
103144
+ }
103145
+ //#endregion
103146
+ //#region src/multi-tier/camera-capabilities.ts
103147
+ /**
103148
+ * `Camera Capabilities` — the handshake that says "I speak the new camera
103149
+ * specification".
103150
+ *
103151
+ * The guide, § 4.5, is explicit: *"the presence of this service and
103152
+ * characteristic with versions as specified in this document is required to
103153
+ * signify that this camera supports the camera functionality described in this
103154
+ * specification."*
103155
+ *
103156
+ * That sentence is the diagnosis of this addon's first attempt. We declared
103157
+ * `camera-multi-tier-rtp-stream-management` and nothing else, and iOS read its
103158
+ * tiers and opened the session on the classic service two seconds later. It was
103159
+ * not refusing the tiers; it had never been told the accessory speaks the
103160
+ * specification the tiers belong to.
103161
+ *
103162
+ * ## Two different versions, and they are not interchangeable
103163
+ *
103164
+ * - The SERVICE (0x8010) carries `Version`, a **string**, whose value is the
103165
+ * specification revision: `"17.99"`.
103166
+ * - The CHARACTERISTIC (0x8011) carries `Version`, a **uint8**, which versions
103167
+ * the TLV layout below it.
103168
+ *
103169
+ * ## The sensor UUID
103170
+ *
103171
+ * `data` on the wire, and it must be stable across restarts: a controller
103172
+ * correlates a stream with it, and it appears in three places. Deriving it from
103173
+ * the device id makes it stable with nothing to persist and nothing to lose.
103174
+ */
103175
+ /** Service and characteristic UUIDs, § 3.1 and § 4.5. */
103176
+ var CAMERA_CAPABILITIES_SERVICE_UUID = "00008010-0000-1000-8000-0026BB765291";
103177
+ var CHAR_CAMERA_CAPABILITIES = "00008011-0000-1000-8000-0026BB765291";
103178
+ /**
103179
+ * The specification revision, as the SERVICE's `Version` string.
103180
+ *
103181
+ * The guide notes this "may be updated prior to release". When it is, this is
103182
+ * the one place to change — and a controller that wants a newer revision will
103183
+ * decline rather than misbehave, which is the failure we can see.
103184
+ */
103185
+ var CAMERA_CAPABILITIES_SPEC_VERSION = "17.99";
103186
+ var SensorType = /* @__PURE__ */ function(SensorType) {
103187
+ SensorType[SensorType["UNKNOWN"] = 0] = "UNKNOWN";
103188
+ SensorType[SensorType["PRIMARY"] = 1] = "PRIMARY";
103189
+ SensorType[SensorType["GENERIC"] = 255] = "GENERIC";
103190
+ return SensorType;
103191
+ }({});
103192
+ var SensorIntent = /* @__PURE__ */ function(SensorIntent) {
103193
+ SensorIntent[SensorIntent["UNKNOWN"] = 0] = "UNKNOWN";
103194
+ SensorIntent[SensorIntent["MAIN"] = 1] = "MAIN";
103195
+ SensorIntent[SensorIntent["PACKAGE"] = 2] = "PACKAGE";
103196
+ SensorIntent[SensorIntent["GENERIC"] = 255] = "GENERIC";
103197
+ return SensorIntent;
103198
+ }({});
103199
+ function uint8(value) {
103200
+ const b = Buffer.alloc(1);
103201
+ b.writeUInt8(value, 0);
103202
+ return b;
103203
+ }
103204
+ function uint16(value) {
103205
+ const b = Buffer.alloc(2);
103206
+ b.writeUInt16LE(value, 0);
103207
+ return b;
103208
+ }
103209
+ function uint32(value) {
103210
+ const b = Buffer.alloc(4);
103211
+ b.writeUInt32LE(value, 0);
103212
+ return b;
103213
+ }
103214
+ /**
103215
+ * A stable 16-byte sensor UUID for a device.
103216
+ *
103217
+ * Hashed rather than random so that it survives a restart without being
103218
+ * persisted — a value we would have to store is a value that can go missing
103219
+ * while the accessory that references it does not.
103220
+ */
103221
+ function sensorUuidForDevice(deviceId) {
103222
+ return createHash("sha256").update(`camstack:hap:sensor:${deviceId}`).digest().subarray(0, 16);
103223
+ }
103224
+ function encodeVideoStreamCapability(capability) {
103225
+ 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));
103226
+ }
103227
+ function encodeSensorConfiguration(sensor) {
103228
+ 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));
103229
+ }
103230
+ function encodeCameraCapabilities(sensors) {
103231
+ return (0, import_dist.encode)(1, uint8(1), 2, (0, import_dist.encode)(1, sensors.map(encodeSensorConfiguration)));
103232
+ }
103233
+ //#endregion
103234
+ //#region src/multi-tier/multi-tier-service.ts
103235
+ /**
103236
+ * The three services Apple's new camera specification requires, built together
103237
+ * because none of them means anything alone.
103238
+ *
103239
+ * ## What the first attempt got wrong
103240
+ *
103241
+ * It declared `camera-multi-tier-rtp-stream-management` (0x8031) with two of
103242
+ * its eight required characteristics, and nothing else. Measured on the hub,
103243
+ * 2026-09-16: at 22:53:21 iOS read the tiers, at 22:53:23 it opened the session
103244
+ * on the CLASSIC service. That was read as "it engages and declines".
103245
+ *
103246
+ * Reading the guide closes the question differently. § 4.5: *"the presence of
103247
+ * this service and characteristic with versions as specified in this document
103248
+ * is required to signify that this camera supports the camera functionality
103249
+ * described in this specification."* `Camera Capabilities` (0x8010) is the
103250
+ * handshake, and we never sent it. iOS was not declining our tiers — it had no
103251
+ * reason to believe the accessory spoke the specification they belong to.
103252
+ *
103253
+ * So this builds all three:
103254
+ *
103255
+ * - **Camera Capabilities** (0x8010) — the claim, with `Version = "17.99"`.
103256
+ * - **Camera Global Operating Mode** (0x8032) — the master switches, which
103257
+ * `Status Active` on the streaming service is defined in terms of.
103258
+ * - **Camera Multi-Tier RTP Stream Management** (0x8031) — all eight required
103259
+ * characteristics.
103260
+ *
103261
+ * ## Why it is honest to ship this before the negotiation exists
103262
+ *
103263
+ * The guide defines the state we are in. § 3.6: *"Status Active must be set to
103264
+ * false if ... Streaming Enabled on this service is false"*, and *"the
103265
+ * accessory must reject any request to start a stream if Status Active is set
103266
+ * to false."* An accessory that cannot serve a session yet is not a
103267
+ * half-written accessory — it is a conformant one with streaming off, and it
103268
+ * says so in the field designed to say it.
103269
+ *
103270
+ * What is NOT allowed is declaring a characteristic we cannot answer. An empty
103271
+ * string is not a valid TLV8, and the Home app reads a camera's characteristics
103272
+ * in ONE batch to draw it — one malformed member took the kitchen camera's
103273
+ * whole read down and left it on "loading". Every characteristic here answers
103274
+ * with a real value, and the two that would negotiate a session answer with the
103275
+ * specification's own explicit refusal.
103276
+ */
103277
+ /** Service UUIDs, § 3.2 and § 3.6. */
103278
+ var MULTI_TIER_RTP_SERVICE_UUID = "00008031-0000-1000-8000-0026BB765291";
103279
+ var GLOBAL_OPERATING_MODE_SERVICE_UUID = "00008032-0000-1000-8000-0026BB765291";
103280
+ /**
103281
+ * Characteristic UUIDs this specification adds.
103282
+ *
103283
+ * The RTP service and the WebRTC service each have their OWN tier
103284
+ * characteristics and they are not interchangeable — video is 0x8043 here and
103285
+ * 0x8059 there, audio is 0x8044 here and 0x805A there. Declaring a sibling's
103286
+ * UUID produces a service a controller reads and abandons with no error.
103287
+ */
103288
+ var CHAR_RTP_SUPPORTED_VIDEO_STREAM_TIERS = "00008043-0000-1000-8000-0026BB765291";
103289
+ var CHAR_RTP_SUPPORTED_AUDIO_STREAM_TIERS = "00008044-0000-1000-8000-0026BB765291";
103290
+ var CHAR_RTP_STREAMING_CONTROL = "00008045-0000-1000-8000-0026BB765291";
103291
+ var CHAR_STREAMING_ENABLED = "00008041-0000-1000-8000-0026BB765291";
103292
+ var CHAR_SENSOR_UUID = "0000805B-0000-1000-8000-0026BB765291";
103293
+ /** Supported RTP Configuration, § 3.6: "must be set to AES_CM_128_HMAC_SHA1_80". */
103294
+ var SRTP_CRYPTO_SUITE_TYPE = 2;
103295
+ var AES_CM_128_HMAC_SHA1_80 = 0;
103296
+ function tlvCharacteristic(uuid, name, writable) {
103297
+ return new import_dist.Characteristic(name, uuid, {
103298
+ format: import_dist.Formats.TLV8,
103299
+ perms: writable ? [
103300
+ import_dist.Perms.PAIRED_READ,
103301
+ import_dist.Perms.PAIRED_WRITE,
103302
+ import_dist.Perms.NOTIFY
103303
+ ] : [import_dist.Perms.PAIRED_READ, import_dist.Perms.NOTIFY]
103304
+ });
103305
+ }
103306
+ /** The video configurations advertised in Camera Capabilities, from the tiers
103307
+ * we would actually offer — one source, so the two cannot drift. */
103308
+ function capabilitiesFromTiers(tiers, deviceId) {
103309
+ return tiers.map((tier) => ({
103310
+ identifier: sensorUuidForDevice(deviceId * 1e3 + tier.identifier),
103311
+ quality: tier.quality,
103312
+ width: tier.width,
103313
+ height: tier.height,
103314
+ framesPerSecond: tier.frameRate,
103315
+ averageBitrateKbps: tier.targetAverageBitrateKbps,
103316
+ peakBitrateKbps: peakBitrateKbpsFor(tier.width)
103317
+ }));
103318
+ }
103319
+ function buildMultiTierServices(input) {
103320
+ const { deviceId, logger } = input;
103321
+ const sensorUuid = sensorUuidForDevice(deviceId);
103322
+ const tiers = tiersForSensor(input.width, input.height);
103323
+ const note = (what) => {
103324
+ logger.info("hap multi-tier: controller touched a characteristic", {
103325
+ tags: { deviceId },
103326
+ meta: { characteristic: what }
103327
+ });
103328
+ };
103329
+ const capabilities = new import_dist.Service("Camera Capabilities", CAMERA_CAPABILITIES_SERVICE_UUID, "camera-capabilities");
103330
+ const version = new import_dist.Characteristic.Version();
103331
+ version.onGet(() => {
103332
+ note("version");
103333
+ return CAMERA_CAPABILITIES_SPEC_VERSION;
103334
+ });
103335
+ capabilities.addCharacteristic(version);
103336
+ const capabilitiesValue = encodeCameraCapabilities([{
103337
+ sensorUuid,
103338
+ type: SensorType.PRIMARY,
103339
+ intent: SensorIntent.MAIN,
103340
+ width: input.width,
103341
+ height: input.height,
103342
+ videoStreams: capabilitiesFromTiers(tiers, deviceId)
103343
+ }]).toString("base64");
103344
+ const capabilitiesChar = tlvCharacteristic(CHAR_CAMERA_CAPABILITIES, "Camera Capabilities", false);
103345
+ capabilitiesChar.onGet(() => {
103346
+ note("camera-capabilities");
103347
+ return capabilitiesValue;
103348
+ });
103349
+ capabilities.addCharacteristic(capabilitiesChar);
103350
+ const globalOperatingMode = new import_dist.Service("Camera Operating Mode", GLOBAL_OPERATING_MODE_SERVICE_UUID, "global-operating-mode");
103351
+ let homeKitCameraActive = true;
103352
+ const cameraActive = new import_dist.Characteristic.HomeKitCameraActive();
103353
+ cameraActive.onGet(() => {
103354
+ note("homekit-camera-active");
103355
+ return homeKitCameraActive ? 1 : 0;
103356
+ });
103357
+ cameraActive.onSet((value) => {
103358
+ homeKitCameraActive = value === 1 || value === true;
103359
+ });
103360
+ globalOperatingMode.addCharacteristic(cameraActive);
103361
+ let globalStreamingEnabled = true;
103362
+ const globalStreaming = new import_dist.Characteristic("Streaming Enabled", CHAR_STREAMING_ENABLED, {
103363
+ format: import_dist.Formats.BOOL,
103364
+ perms: [
103365
+ import_dist.Perms.PAIRED_READ,
103366
+ import_dist.Perms.PAIRED_WRITE,
103367
+ import_dist.Perms.NOTIFY
103368
+ ]
103369
+ });
103370
+ globalStreaming.onGet(() => globalStreamingEnabled);
103371
+ globalStreaming.onSet((value) => {
103372
+ globalStreamingEnabled = value === true;
103373
+ });
103374
+ globalOperatingMode.addCharacteristic(globalStreaming);
103375
+ const indicator = new import_dist.Characteristic.CameraOperatingModeIndicator();
103376
+ indicator.onGet(() => 0);
103377
+ globalOperatingMode.addCharacteristic(indicator);
103378
+ const streamManagement = new import_dist.Service("CamStack multi tier", MULTI_TIER_RTP_SERVICE_UUID, "multi-tier");
103379
+ let serviceStreamingEnabled = true;
103380
+ const streamingEnabled = new import_dist.Characteristic("Streaming Enabled", CHAR_STREAMING_ENABLED, {
103381
+ format: import_dist.Formats.BOOL,
103382
+ perms: [
103383
+ import_dist.Perms.PAIRED_READ,
103384
+ import_dist.Perms.PAIRED_WRITE,
103385
+ import_dist.Perms.NOTIFY
103386
+ ]
103387
+ });
103388
+ streamingEnabled.onGet(() => {
103389
+ note("streaming-enabled");
103390
+ return serviceStreamingEnabled;
103391
+ });
103392
+ streamingEnabled.onSet((value) => {
103393
+ serviceStreamingEnabled = value === true;
103394
+ });
103395
+ streamManagement.addCharacteristic(streamingEnabled);
103396
+ /**
103397
+ * § 3.6's rule, written once and read everywhere it matters.
103398
+ *
103399
+ * It is deliberately an AND over four terms rather than a stored flag: a
103400
+ * stored one would have to be invalidated from four places, and the one that
103401
+ * got forgotten would leave the accessory claiming it can stream.
103402
+ */
103403
+ const statusActive = () => serviceStreamingEnabled && globalStreamingEnabled && homeKitCameraActive && input.negotiation !== void 0;
103404
+ const active = new import_dist.Characteristic.StatusActive();
103405
+ active.onGet(() => {
103406
+ const value = statusActive();
103407
+ logger.info("hap multi-tier: status active read", {
103408
+ tags: { deviceId },
103409
+ meta: {
103410
+ value,
103411
+ serviceStreamingEnabled,
103412
+ globalStreamingEnabled,
103413
+ homeKitCameraActive,
103414
+ hasNegotiator: input.negotiation !== void 0
103415
+ }
103416
+ });
103417
+ return value;
103418
+ });
103419
+ streamManagement.addCharacteristic(active);
103420
+ const videoTiersValue = encodeVideoStreamConfiguration({
103421
+ codec: input.codec,
103422
+ payloadType: 96,
103423
+ tiers
103424
+ }).toString("base64");
103425
+ const videoTiers = tlvCharacteristic(CHAR_RTP_SUPPORTED_VIDEO_STREAM_TIERS, "Supported Video Stream Tiers", false);
103426
+ videoTiers.onGet(() => {
103427
+ note("supported-video-stream-tiers");
103428
+ return videoTiersValue;
103429
+ });
103430
+ streamManagement.addCharacteristic(videoTiers);
103431
+ const audioTiersValue = encodeAudioStreamConfiguration({
103432
+ codec: TierAudioCodec.OPUS,
103433
+ payloadType: 110,
103434
+ tiers: [opusTierForCapture(input.audioCaptureRateHz)]
103435
+ }).toString("base64");
103436
+ const audioTiers = tlvCharacteristic(CHAR_RTP_SUPPORTED_AUDIO_STREAM_TIERS, "Supported Audio Stream Tiers", false);
103437
+ audioTiers.onGet(() => {
103438
+ note("supported-audio-stream-tiers");
103439
+ return audioTiersValue;
103440
+ });
103441
+ streamManagement.addCharacteristic(audioTiers);
103442
+ const rtpConfiguration = new import_dist.Characteristic.SupportedRTPConfiguration();
103443
+ const rtpConfigurationValue = (0, import_dist.encode)(SRTP_CRYPTO_SUITE_TYPE, Buffer.from([AES_CM_128_HMAC_SHA1_80])).toString("base64");
103444
+ rtpConfiguration.onGet(() => {
103445
+ note("supported-rtp-configuration");
103446
+ return rtpConfigurationValue;
103447
+ });
103448
+ streamManagement.addCharacteristic(rtpConfiguration);
103449
+ const setupEndpoints = new import_dist.Characteristic.SetupEndpoints();
103450
+ setupEndpoints.onGet(() => {
103451
+ note("setup-endpoints");
103452
+ return input.negotiation?.readSetupEndpoints() ?? encodeSetupEndpointsRefusal(SetupEndpointsStatus.ERROR);
103453
+ });
103454
+ setupEndpoints.onSet(async (value) => {
103455
+ if (input.negotiation === void 0 || typeof value !== "string") {
103456
+ logger.info("hap multi-tier: refused a setup-endpoints write", {
103457
+ tags: { deviceId },
103458
+ meta: { reason: input.negotiation === void 0 ? "no-negotiator" : "not-a-string" }
103459
+ });
103460
+ return;
103461
+ }
103462
+ await input.negotiation.handleSetupEndpointsWrite(value);
103463
+ });
103464
+ streamManagement.addCharacteristic(setupEndpoints);
103465
+ const streamingControl = new import_dist.Characteristic("RTP Streaming Control", CHAR_RTP_STREAMING_CONTROL, {
103466
+ format: import_dist.Formats.TLV8,
103467
+ perms: [import_dist.Perms.PAIRED_READ, import_dist.Perms.PAIRED_WRITE]
103468
+ });
103469
+ streamingControl.onGet(() => {
103470
+ note("rtp-streaming-control");
103471
+ return input.negotiation?.readStreamingControl() ?? encodeRtpStreamingControlStatus(null, RtpStreamingStatus.ERROR);
103472
+ });
103473
+ streamingControl.onSet(async (value) => {
103474
+ if (input.negotiation === void 0 || typeof value !== "string") {
103475
+ logger.info("hap multi-tier: refused an rtp streaming control write", {
103476
+ tags: { deviceId },
103477
+ meta: { reason: input.negotiation === void 0 ? "no-negotiator" : "not-a-string" }
103478
+ });
103479
+ return;
103480
+ }
103481
+ await input.negotiation.handleStreamingControlWrite(value);
103482
+ });
103483
+ streamManagement.addCharacteristic(streamingControl);
103484
+ const sensor = new import_dist.Characteristic("Sensor UUID", CHAR_SENSOR_UUID, {
103485
+ format: import_dist.Formats.DATA,
103486
+ perms: [import_dist.Perms.PAIRED_READ]
103487
+ });
103488
+ const sensorValue = sensorUuid.toString("base64");
103489
+ sensor.onGet(() => {
103490
+ note("sensor-uuid");
103491
+ return sensorValue;
103492
+ });
103493
+ streamManagement.addCharacteristic(sensor);
103494
+ return {
103495
+ capabilities,
103496
+ globalOperatingMode,
103497
+ streamManagement
103498
+ };
103499
+ }
103500
+ //#endregion
103501
+ //#region src/multi-tier/negotiation.ts
103502
+ /**
103503
+ * The adapter between Apple's new characteristics and the streaming engine this
103504
+ * addon already has.
103505
+ *
103506
+ * ## Why an adapter and not a second engine
103507
+ *
103508
+ * The multi-tier RTP service owns its OWN `Setup Endpoints` and `RTP Streaming
103509
+ * Control`, so hap-nodejs's `CameraController` never negotiates for it — and
103510
+ * the library's own handlers are all `private`, so its engine cannot be
103511
+ * borrowed either. But the sockets, the SRTP contexts, the ffmpeg pipeline and
103512
+ * the teardown are the same ones the classic service uses. A second copy would
103513
+ * be two engines to keep in agreement, and this repo has paid for that shape
103514
+ * before. So: new characteristics, one engine, through `StreamNegotiator`.
103515
+ *
103516
+ * ## What is different about the new flow
103517
+ *
103518
+ * The classic `Selected RTP Stream Configuration` carries the whole selected
103519
+ * configuration in its start — codec, profile, level, resolution, bitrate.
103520
+ * `RTP Streaming Control` carries a **tier identifier**, because the
103521
+ * configuration was agreed when we published `Supported Video Stream Tiers`.
103522
+ * So this maps an identifier back to the tier WE published, and a tier we never
103523
+ * published is `No Such Stream` — improvising a geometry there would serve a
103524
+ * stream the controller did not ask for and it would look like it worked.
103525
+ *
103526
+ * ## Every path answers
103527
+ *
103528
+ * A read of either characteristic always produces a valid TLV8. The Home app
103529
+ * reads a camera's characteristics in one batch, and one member that answers
103530
+ * nothing took the kitchen camera's whole read down and left it on "loading".
103531
+ * So an engine failure, a malformed write and "nothing has happened yet" are
103532
+ * all statuses, never silence.
103533
+ */
103534
+ /** RTP payload types. 96 is the first dynamic one (RFC 3551); 110 is what our
103535
+ * own Opus offers use. Both are echoed in the tier declaration. */
103536
+ var VIDEO_PAYLOAD_TYPE = 96;
103537
+ var AUDIO_PAYLOAD_TYPE = 110;
103538
+ /** RTCP interval in SECONDS, which is the unit the engine reads. */
103539
+ var RTCP_INTERVAL_SECONDS = 5;
103540
+ /** The MTU the classic path uses for an Ethernet-sized path. */
103541
+ var DEFAULT_MTU = 1378;
103542
+ function errorMessage(error) {
103543
+ return error instanceof Error ? error.message : String(error);
103544
+ }
103545
+ function buildMultiTierNegotiation(input) {
103546
+ const { deviceId, logger, negotiator } = input;
103547
+ const log = logger.withTags({ deviceId });
103548
+ let setupResponse = encodeSetupEndpointsRefusal(SetupEndpointsStatus.ERROR);
103549
+ let controlResponse = encodeRtpStreamingControlStatus(null, RtpStreamingStatus.ERROR);
103550
+ /** Sessions we have STARTED, so an End can tell "not started" from "unknown". */
103551
+ const started = /* @__PURE__ */ new Set();
103552
+ async function handleSetupEndpointsWrite(base64) {
103553
+ let sessionId = null;
103554
+ try {
103555
+ const request = decodeSetupEndpointsRequest(base64);
103556
+ sessionId = request.sessionId;
103557
+ const prepareRequest = {
103558
+ sessionID: request.sessionId,
103559
+ sourceAddress: request.controllerAddress,
103560
+ targetAddress: request.controllerAddress,
103561
+ addressVersion: request.addressVersion,
103562
+ video: {
103563
+ port: request.videoPort,
103564
+ srtpCryptoSuite: import_dist.SRTPCryptoSuites.AES_CM_128_HMAC_SHA1_80,
103565
+ srtp_key: request.videoSrtp.key,
103566
+ srtp_salt: request.videoSrtp.salt
103567
+ },
103568
+ audio: {
103569
+ port: request.audioPort,
103570
+ srtpCryptoSuite: import_dist.SRTPCryptoSuites.AES_CM_128_HMAC_SHA1_80,
103571
+ srtp_key: request.audioSrtp.key,
103572
+ srtp_salt: request.audioSrtp.salt
103573
+ }
103574
+ };
103575
+ const prepared = await negotiator.prepare(prepareRequest);
103576
+ setupResponse = encodeSetupEndpointsResponse({
103577
+ sessionId: request.sessionId,
103578
+ addressVersion: request.addressVersion,
103579
+ accessoryAddress: prepared.localAddress,
103580
+ videoPort: prepared.videoPort,
103581
+ audioPort: prepared.audioPort,
103582
+ videoSrtp: {
103583
+ key: request.videoSrtp.key,
103584
+ salt: request.videoSrtp.salt
103585
+ },
103586
+ audioSrtp: {
103587
+ key: request.audioSrtp.key,
103588
+ salt: request.audioSrtp.salt
103589
+ },
103590
+ videoSsrc: prepared.videoSsrc,
103591
+ audioSsrc: prepared.audioSsrc
103592
+ });
103593
+ log.info("hap multi-tier: endpoints prepared", { meta: {
103594
+ sessionId: request.sessionId,
103595
+ controller: `${request.controllerAddress}:${request.videoPort}/${request.audioPort}`,
103596
+ accessory: `${prepared.localAddress}:${prepared.videoPort}/${prepared.audioPort}`,
103597
+ videoSsrc: prepared.videoSsrc,
103598
+ audioSsrc: prepared.audioSsrc
103599
+ } });
103600
+ } catch (error) {
103601
+ setupResponse = encodeSetupEndpointsRefusal(SetupEndpointsStatus.ERROR);
103602
+ log.warn("hap multi-tier: setup endpoints refused", { meta: {
103603
+ sessionId,
103604
+ error: errorMessage(error)
103605
+ } });
103606
+ }
103607
+ }
103608
+ async function handleStreamingControlWrite(base64) {
103609
+ let sessionId = null;
103610
+ try {
103611
+ const command = decodeRtpStreamingControl(base64);
103612
+ sessionId = command.sessionId;
103613
+ if (!negotiator.has(command.sessionId)) {
103614
+ controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.UNKNOWN_SESSION_IDENTIFIER);
103615
+ log.warn("hap multi-tier: control write for a session that was never prepared", { meta: {
103616
+ sessionId: command.sessionId,
103617
+ command: command.command
103618
+ } });
103619
+ return;
103620
+ }
103621
+ if (command.command === RtpStreamingCommand.END) {
103622
+ if (!started.has(command.sessionId)) {
103623
+ controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.NO_SUCH_STREAM);
103624
+ return;
103625
+ }
103626
+ const stop = {
103627
+ sessionID: command.sessionId,
103628
+ type: import_dist.StreamRequestTypes.STOP
103629
+ };
103630
+ await negotiator.control(stop);
103631
+ started.delete(command.sessionId);
103632
+ controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.SUCCESS);
103633
+ log.info("hap multi-tier: stream ended", { meta: { sessionId: command.sessionId } });
103634
+ return;
103635
+ }
103636
+ const tier = input.tiers.find((t) => t.identifier === command.videoTier);
103637
+ if (tier === void 0) {
103638
+ controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.NO_SUCH_STREAM);
103639
+ log.warn("hap multi-tier: start named a tier we never published", { meta: {
103640
+ sessionId: command.sessionId,
103641
+ requested: command.videoTier,
103642
+ published: input.tiers.map((t) => t.identifier)
103643
+ } });
103644
+ return;
103645
+ }
103646
+ log.info("hap multi-tier: start ssrc comparison", { meta: {
103647
+ sessionId: command.sessionId,
103648
+ controllerVideoSsrc: command.videoSsrc ?? null,
103649
+ controllerAudioSsrc: command.audioSsrc ?? null,
103650
+ note: "accessory keeps the ssrcs it advertised at setup"
103651
+ } });
103652
+ const start = {
103653
+ sessionID: command.sessionId,
103654
+ type: import_dist.StreamRequestTypes.START,
103655
+ video: {
103656
+ codec: import_dist.VideoCodecType.H264,
103657
+ profile: import_dist.H264Profile.HIGH,
103658
+ level: import_dist.H264Level.LEVEL4_0,
103659
+ packetizationMode: import_dist.VideoCodecPacketizationMode.NON_INTERLEAVED,
103660
+ width: tier.width,
103661
+ height: tier.height,
103662
+ fps: tier.frameRate,
103663
+ pt: VIDEO_PAYLOAD_TYPE,
103664
+ ssrc: command.videoSsrc ?? 0,
103665
+ max_bit_rate: tier.targetAverageBitrateKbps,
103666
+ rtcp_interval: RTCP_INTERVAL_SECONDS,
103667
+ mtu: DEFAULT_MTU
103668
+ },
103669
+ audio: {
103670
+ codec: import_dist.AudioStreamingCodecType.OPUS,
103671
+ channel: 1,
103672
+ bit_rate: 24,
103673
+ sample_rate: import_dist.AudioStreamingSamplerate.KHZ_24,
103674
+ packet_time: 20,
103675
+ pt: AUDIO_PAYLOAD_TYPE,
103676
+ ssrc: command.audioSsrc ?? 0,
103677
+ max_bit_rate: 24,
103678
+ rtcp_interval: RTCP_INTERVAL_SECONDS,
103679
+ comfort_pt: 13,
103680
+ comfortNoiseEnabled: false
103681
+ }
103682
+ };
103683
+ await negotiator.control(start);
103684
+ started.add(command.sessionId);
103685
+ controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.SUCCESS);
103686
+ log.info("hap multi-tier: stream started", { meta: {
103687
+ sessionId: command.sessionId,
103688
+ tier: tier.identifier,
103689
+ geometry: `${tier.width}x${tier.height}@${tier.frameRate}`,
103690
+ targetKbps: tier.targetAverageBitrateKbps,
103691
+ codec: input.codec
103692
+ } });
103693
+ } catch (error) {
103694
+ controlResponse = encodeRtpStreamingControlStatus(sessionId, RtpStreamingStatus.ERROR);
103695
+ log.warn("hap multi-tier: control write refused", { meta: {
103696
+ sessionId,
103697
+ error: errorMessage(error)
103698
+ } });
103699
+ }
103700
+ }
103701
+ return {
103702
+ handleSetupEndpointsWrite,
103703
+ readSetupEndpoints: () => setupResponse,
103704
+ handleStreamingControlWrite,
103705
+ readStreamingControl: () => controlResponse
103706
+ };
103707
+ }
103708
+ //#endregion
102169
103709
  //#region src/mappers/camera-accessory.ts
102170
103710
  /**
102171
103711
  * Camera accessory orchestrator — given a camstack deviceId, builds one
@@ -102231,6 +103771,35 @@ async function buildCameraAccessory(input) {
102231
103771
  ...recording === null || !capNames.has("motion-detection") ? {} : { sensors: { motion: true } }
102232
103772
  });
102233
103773
  accessory.configureController(controller);
103774
+ if (options.hapDeviceSettings.multiTierService !== false) {
103775
+ const sensor = advertisedVideo.sensor;
103776
+ const services = buildMultiTierServices({
103777
+ deviceId: numericId,
103778
+ logger: ctx.logger,
103779
+ width: sensor.width,
103780
+ height: sensor.height,
103781
+ codec: sensor.codec,
103782
+ audioCaptureRateHz: 16e3,
103783
+ negotiation: buildMultiTierNegotiation({
103784
+ deviceId: numericId,
103785
+ logger: ctx.logger,
103786
+ negotiator: streams.negotiator,
103787
+ tiers: tiersForSensor(sensor.width, sensor.height),
103788
+ codec: sensor.codec
103789
+ })
103790
+ });
103791
+ accessory.addService(services.capabilities);
103792
+ accessory.addService(services.globalOperatingMode);
103793
+ accessory.addService(services.streamManagement);
103794
+ ctx.logger.info("hap multi-tier: services declared on this accessory", {
103795
+ tags: { deviceId: numericId },
103796
+ meta: {
103797
+ serviceUuid: MULTI_TIER_RTP_SERVICE_UUID,
103798
+ codec: sensor.codec === TierVideoCodec.H265 ? "h265" : "h264",
103799
+ sensor: `${sensor.width}x${sensor.height}`
103800
+ }
103801
+ });
103802
+ }
102234
103803
  if (recording !== null) {
102235
103804
  handles.push({ dispose: () => recording.dispose() });
102236
103805
  const audioCharacteristic = (controller.recordingManagement?.operatingModeService)?.getCharacteristic(import_dist.Characteristic.RecordingAudioActive);
@@ -103211,6 +104780,24 @@ var DEFAULT_DEVICE_SETTINGS = {
103211
104780
  function resolveHksvRecording(settings) {
103212
104781
  return settings?.hksvRecording !== false;
103213
104782
  }
104783
+ /**
104784
+ * Whether this camera offers Apple's NEW camera service alongside the classic
104785
+ * one — `camera-multi-tier-rtp-stream-management` (0x8031), where HEVC and 4K
104786
+ * live.
104787
+ *
104788
+ * ABSENT MEANS ON, like `resolveHksvRecording` and for the same reason: a
104789
+ * camera exposed before this field existed must not silently lose it. Only an
104790
+ * explicit `false` turns it off.
104791
+ *
104792
+ * Safe as a default because the FALLBACK is the controller's own and it was
104793
+ * measured: iOS reads the new service's characteristics and, finding it
104794
+ * unusable, opens its session on the classic one two seconds later. Until the
104795
+ * negotiation is built we say `streaming-enabled: false`, so declining is the
104796
+ * only thing a controller can do with it.
104797
+ */
104798
+ function resolveMultiTierService(settings) {
104799
+ return settings?.multiTierService === true;
104800
+ }
103214
104801
  var HAP_STREAM_PREFERENCE_OPTIONS = [
103215
104802
  {
103216
104803
  value: "auto",
@@ -103442,7 +105029,18 @@ var ExportHapAddon = class extends BaseAddon {
103442
105029
  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."
103443
105030
  };
103444
105031
  }
103445
- async exposeDevice(deviceId, capabilities) {
105032
+ /**
105033
+ * @param settings Settings to build the accessory WITH.
105034
+ *
105035
+ * Needed because the only path a per-camera settings change takes is
105036
+ * `unexposeDevice` then `exposeDevice`, and the unexpose deletes the stored
105037
+ * entry — so `carryForward` below has nothing to carry and the accessory is
105038
+ * rebuilt on the DEFAULTS. Persisting the value afterwards is not enough:
105039
+ * the mapper, and with it the advertised resolution list, is already built.
105040
+ * Measured 2026-09-17 — the stored value read `mid` while the advertisement
105041
+ * still said `streamPreference=auto`.
105042
+ */
105043
+ async exposeDevice(deviceId, capabilities, settings) {
103446
105044
  const numericId = Number.parseInt(deviceId, 10);
103447
105045
  const log = this.ctx.logger.withTags({ deviceId: numericId });
103448
105046
  if (this.exposed.has(deviceId)) {
@@ -103454,12 +105052,16 @@ var ExportHapAddon = class extends BaseAddon {
103454
105052
  if (!mapperKind) throw new Error(`export-hap: device ${numericId} has type '${summary?.type ?? "unknown"}', which HomeKit export does not support`);
103455
105053
  const displayName = summary?.name ?? `Device ${deviceId}`;
103456
105054
  const previous = this.config.exposed.find((e) => e.deviceId === deviceId);
103457
- const baseEntry = carryForward({
105055
+ const rebuilt = {
103458
105056
  deviceId,
103459
105057
  displayName,
103460
105058
  mapperKind,
103461
105059
  addedAt: previous?.addedAt ?? Date.now(),
103462
105060
  ...capabilities ? { capabilities: [...capabilities] } : {}
105061
+ };
105062
+ const baseEntry = carryForward(settings === void 0 ? rebuilt : {
105063
+ ...rebuilt,
105064
+ settings
103463
105065
  }, previous, ["settings", "capabilities"]);
103464
105066
  const attached = await this.attachMapper(baseEntry);
103465
105067
  const finalEntry = {
@@ -103503,6 +105105,7 @@ var ExportHapAddon = class extends BaseAddon {
103503
105105
  hapDeviceSettings: {
103504
105106
  streamPreference: entrySettings.streamPreference ?? "auto",
103505
105107
  hksvRecording: resolveHksvRecording(entrySettings),
105108
+ multiTierService: resolveMultiTierService(entrySettings),
103506
105109
  allowTranscode: entrySettings.allowTranscode !== false
103507
105110
  }
103508
105111
  }
@@ -103833,6 +105436,7 @@ var ExportHapAddon = class extends BaseAddon {
103833
105436
  const enabledKey = `hap:${deviceId}:enabled`;
103834
105437
  const streamPreferenceKey = `hap:${deviceId}:streamPreference`;
103835
105438
  const hksvKey = `hap:${deviceId}:hksvRecording`;
105439
+ const multiTierKey = `hap:${deviceId}:multiTierService`;
103836
105440
  const useBridgeKey = `hap:${deviceId}:useBridge`;
103837
105441
  const allowTranscodeKey = `hap:${deviceId}:allowTranscode`;
103838
105442
  const mapper = this.exposed.get(String(deviceId)) ?? null;
@@ -103892,6 +105496,19 @@ var ExportHapAddon = class extends BaseAddon {
103892
105496
  },
103893
105497
  immediate: true
103894
105498
  },
105499
+ {
105500
+ type: "boolean",
105501
+ key: multiTierKey,
105502
+ label: "Offer the new HomeKit camera service (HEVC / 4K)",
105503
+ 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.",
105504
+ style: "switch",
105505
+ value: resolveMultiTierService(settings),
105506
+ showWhen: {
105507
+ field: enabledKey,
105508
+ equals: true
105509
+ },
105510
+ immediate: true
105511
+ },
103895
105512
  {
103896
105513
  type: "boolean",
103897
105514
  key: allowTranscodeKey,
@@ -103978,18 +105595,21 @@ var ExportHapAddon = class extends BaseAddon {
103978
105595
  const hksvKey = `hap:${deviceId}:hksvRecording`;
103979
105596
  const useBridgeKey = `hap:${deviceId}:useBridge`;
103980
105597
  const allowTranscodeKey = `hap:${deviceId}:allowTranscode`;
105598
+ const multiTierKey = `hap:${deviceId}:multiTierService`;
103981
105599
  const enabledValue = enabledKey in patch ? Boolean(patch[enabledKey]) : wasEnabled;
103982
105600
  const streamPreferenceRaw = streamPreferenceKey in patch ? patch[streamPreferenceKey] : current?.settings?.streamPreference;
103983
105601
  const streamPreference = typeof streamPreferenceRaw === "string" && streamPreferenceRaw.trim().length > 0 ? streamPreferenceRaw : "auto";
103984
105602
  const hksvRecording = hksvKey in patch ? Boolean(patch[hksvKey]) : resolveHksvRecording(current?.settings);
103985
105603
  const useBridge = useBridgeKey in patch ? Boolean(patch[useBridgeKey]) : current?.settings?.useBridge !== false;
103986
105604
  const allowTranscode = allowTranscodeKey in patch ? Boolean(patch[allowTranscodeKey]) : current?.settings?.allowTranscode !== false;
105605
+ const multiTierService = multiTierKey in patch ? Boolean(patch[multiTierKey]) : resolveMultiTierService(current?.settings);
103987
105606
  const nextSettings = {
103988
105607
  ...current?.settings ?? DEFAULT_DEVICE_SETTINGS,
103989
105608
  streamPreference,
103990
105609
  hksvRecording,
103991
105610
  useBridge,
103992
- allowTranscode
105611
+ allowTranscode,
105612
+ multiTierService
103993
105613
  };
103994
105614
  if (!enabledValue) {
103995
105615
  if (wasEnabled) await this.unexposeDevice(deviceIdStr);
@@ -104029,7 +105649,7 @@ var ExportHapAddon = class extends BaseAddon {
104029
105649
  } });
104030
105650
  try {
104031
105651
  await this.unexposeDevice(deviceIdStr, { clearPairing: wasBridged !== willBridge });
104032
- await this.exposeDevice(deviceIdStr);
105652
+ await this.exposeDevice(deviceIdStr, void 0, nextSettings);
104033
105653
  } catch (err) {
104034
105654
  log.warn("export-hap: failed to refresh accessory after a settings change", { meta: { error: errMsg(err) } });
104035
105655
  }
@@ -104052,4 +105672,4 @@ function errMsg(err) {
104052
105672
  return err instanceof Error ? err.message : String(err);
104053
105673
  }
104054
105674
  //#endregion
104055
- export { ExportHapAddon, ExportHapAddon as default, unpublishAccessory as i, initHapStorage as n, publishStandalone as r, resolveHksvRecording, deriveUsername as t };
105675
+ export { ExportHapAddon, ExportHapAddon as default, unpublishAccessory as i, initHapStorage as n, publishStandalone as r, resolveHksvRecording, resolveMultiTierService, deriveUsername as t };