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