@camstack/addon-export-alexa 1.2.108 → 1.2.110

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.
@@ -1,5 +1,5 @@
1
1
  import * as crypto$1 from "node:crypto";
2
- //#region ../types/dist/event-category-ZyX6jcse.mjs
2
+ //#region ../types/dist/event-category-BVDXG4tB.mjs
3
3
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
4
4
  EventCategory["SystemBoot"] = "system.boot";
5
5
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -616,6 +616,20 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
616
616
  * pull-reconcile from the provider's `getStatus` on reconnect. */
617
617
  EventCategory["MeshNetworkChanged"] = "network.mesh.changed";
618
618
  EventCategory["BackupCompleted"] = "backup.completed";
619
+ /**
620
+ * A whole backup RUN finished — every destination attempted, win or lose.
621
+ *
622
+ * `backup.completed` fires once per DESTINATION, which is the right grain for
623
+ * a progress UI and the wrong one for a notification: an operator with three
624
+ * destinations would be told three times. And a run where two of three
625
+ * destinations succeeded is not a clean success — a single "backup
626
+ * completed" that hid the failed one would be a lie, so the count of each is
627
+ * carried here and the message says both.
628
+ *
629
+ * Emitted by the backup orchestrator only after the destination loop, so a
630
+ * run that dies during the BUILD phase produces no completion at all.
631
+ */
632
+ EventCategory["BackupRunCompleted"] = "backup.run-completed";
619
633
  EventCategory["BackupRestored"] = "backup.restored";
620
634
  EventCategory["NotificationDispatched"] = "notification.dispatched";
621
635
  EventCategory["NotificationFailed"] = "notification.failed";
@@ -5346,7 +5360,7 @@ var ZodIssueCode = {
5346
5360
  var ZodFirstPartyTypeKind;
5347
5361
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5348
5362
  //#endregion
5349
- //#region ../types/dist/sleep-DBKu2-U5.mjs
5363
+ //#region ../types/dist/sleep-BDR76Ykr.mjs
5350
5364
  /**
5351
5365
  * The audio chunk plane's byte format, and the ONE expansion from a coded
5352
5366
  * window to float samples (D455).
@@ -7227,6 +7241,28 @@ var DeviceRole = /* @__PURE__ */ function(DeviceRole) {
7227
7241
  DeviceRole["GenericNotifier"] = "generic-notifier";
7228
7242
  return DeviceRole;
7229
7243
  }({});
7244
+ /**
7245
+ * Identity — preserves literal types for downstream inference.
7246
+ *
7247
+ * The constraint is `Record<string, unknown>` (not `CustomActionsSpec`) so
7248
+ * TypeScript does not widen each entry's literal `kind`/`auth` fields to
7249
+ * the broader unions declared on `CustomActionSpec`'s default generics.
7250
+ * Shape validity is enforced separately by the `customAction(...)` helper
7251
+ * whose return type is already a `CustomActionSpec<...>`.
7252
+ */
7253
+ function defineCustomActions(spec) {
7254
+ return spec;
7255
+ }
7256
+ function customAction(input, output, options) {
7257
+ return {
7258
+ input,
7259
+ output,
7260
+ kind: options?.kind ?? "query",
7261
+ auth: options?.auth ?? "protected",
7262
+ scope: options?.scope ?? { kind: "system" },
7263
+ ...options?.caller ? { caller: "required" } : {}
7264
+ };
7265
+ }
7230
7266
  var StaticDirOutputSchema = object({ staticDir: string() });
7231
7267
  var VersionOutputSchema = object({ version: string() });
7232
7268
  method(_void(), StaticDirOutputSchema, { auth: "admin" }), method(_void(), VersionOutputSchema, { auth: "admin" });
@@ -7533,6 +7569,28 @@ var EncodeProfileSchema = object({
7533
7569
  outputArgs: array(string()).optional()
7534
7570
  });
7535
7571
  /**
7572
+ * THE target rule: which of two sizes serves a consumer better.
7573
+ *
7574
+ * "Prefer at or above the target so the consumer downscales instead of
7575
+ * upscaling; among those the SMALLEST, because the extra pixels are thrown
7576
+ * away and the decode is not; among the rest the LARGEST, because it is the
7577
+ * closest we can get." One sentence, and until now it was written twice — once
7578
+ * here in lines, once in `stream-selection.ts` in pixels, for the same two
7579
+ * exporters. The two agreed, which is luck, not a property.
7580
+ *
7581
+ * The `size` is whatever measure the caller ranks by: the cap ranks by LINES
7582
+ * (a consumer's requirement is stated in p), the RTSP picker by PIXELS (it
7583
+ * compares entries of possibly different aspect). The rule is the same either
7584
+ * way and the measure is the caller's business.
7585
+ *
7586
+ * Returns a sort delta: negative when `a` should come first.
7587
+ */
7588
+ function compareToTarget(a, b, target) {
7589
+ const aReaches = a >= target;
7590
+ if (aReaches !== b >= target) return aReaches ? -1 : 1;
7591
+ return aReaches ? a - b : b - a;
7592
+ }
7593
+ /**
7536
7594
  * The shape every live egress starts from: H.264 Baseline 3.1 at 720p25.
7537
7595
  * Baseline because it is the one profile every consumer in this repo decodes
7538
7596
  * (Echo, iOS, an old browser); 3.1 because that is what the SDPs advertise.
@@ -9447,6 +9505,47 @@ var LogChannelWindowSchema = object({
9447
9505
  /** `null` = every camera. A non-empty list narrows to those numeric ids. */
9448
9506
  deviceIds: array(number().int()).readonly().nullable()
9449
9507
  });
9508
+ /**
9509
+ * Shared stream-selection picker for export addons.
9510
+ *
9511
+ * Both `addon-export-alexa` and `addon-export-hap` resolve the
9512
+ * operator's `streamPreference` setting against the broker's RTSP
9513
+ * entries before dialling a camera. Each addon used to roll its own:
9514
+ * Alexa had `pickPreferredRtspEntry` under `handlers/`, HAP had an
9515
+ * inline `"prefer /mid, then /high, then first"` chain inside its
9516
+ * camera-streams mapper. The chains drifted (Alexa honoured
9517
+ * `streamPreference`, HAP didn't; only Alexa got the smart
9518
+ * resolution-targeted auto-pick) and the cross-addon import rule
9519
+ * blocks deduping in either direction.
9520
+ *
9521
+ * Lifting the picker into `@camstack/types` lets every exporter call
9522
+ * the same code path with the consumer's target resolution baked into
9523
+ * the call site — Alexa passes 1280×720, HomeKit passes 1920×1080,
9524
+ * the picker handles the rest.
9525
+ *
9526
+ * Selection algorithm:
9527
+ * - pref === '<profile>' → profile-keyed entries (from
9528
+ * `getProfileRtspEntries`) match on `e.profile`;
9529
+ * legacy raw entries match on the brokerId
9530
+ * suffix (`brokerId` ENDS WITH `/${pref}`, shape
9531
+ * `${deviceId}/${camStreamId}`). Addons store the
9532
+ * bare profile/camStream id in their
9533
+ * `ExportDeviceSettings.streamPreference`. Falls
9534
+ * back to 'auto' when no enabled entry matches
9535
+ * either way.
9536
+ * - pref === 'auto' → when `targetResolution` is given, pick the
9537
+ * entry whose source resolution is closest to
9538
+ * it (prefer ≥ target so the consumer
9539
+ * downscales instead of upscaling). Without a
9540
+ * target, fall back to first-enabled. If no
9541
+ * entry advertises a resolution at all
9542
+ * (broker hasn't probed yet), also fall back
9543
+ * to first-enabled so cold start still serves.
9544
+ *
9545
+ * Returns `null` when nothing is publishable — callers fold that into
9546
+ * the consumer's "no stream" error (Alexa: STREAM_TYPE_UNSUPPORTED;
9547
+ * HAP: a thrown error inside `startFfmpegForSession`).
9548
+ */
9450
9549
  function pickPreferredRtspEntry(entries, pref, deviceId, options = {}) {
9451
9550
  if (entries.length === 0) return null;
9452
9551
  const prefix = `${deviceId}/`;
@@ -9476,30 +9575,23 @@ function pickPreferredRtspEntry(entries, pref, deviceId, options = {}) {
9476
9575
  return toPicked(eligible[0]);
9477
9576
  }
9478
9577
  /**
9479
- * Pick the entry whose `resolution` is closest to `target`. Prefer
9480
- * entries ≥ target (downscale is cheap at the consumer; upscale is
9481
- * lossy). Among the rest pick the LARGEST still ≤ target so the
9482
- * consumer gets the best feasible quality. "Closeness" is the
9483
- * `width × height` pixel delta.
9578
+ * Pick the entry whose `resolution` is closest to `target`, by the ONE target
9579
+ * rule ({@link compareToTarget}): prefer at or above the target so the consumer
9580
+ * downscales instead of upscaling; among those the smallest; among the rest the
9581
+ * largest.
9582
+ *
9583
+ * This function used to implement that rule itself, in pixels, while
9584
+ * `rankStreams` implemented it again in lines — the same sentence written twice
9585
+ * for the same two exporters, agreeing by luck rather than by construction.
9586
+ * Only the MEASURE is this picker's own: it compares whole pixel counts because
9587
+ * its entries can differ in aspect, where the cap's requirement is stated in p.
9484
9588
  */
9485
9589
  function pickClosestResolution(entries, target) {
9486
9590
  const targetPixels = target.width * target.height;
9487
- let bestAbove;
9488
- let bestBelow;
9489
- for (const e of entries) {
9490
- if (e.resolution === void 0) continue;
9491
- const pixels = e.resolution.width * e.resolution.height;
9492
- if (pixels >= targetPixels) {
9493
- if (!bestAbove || pixels < bestAbove.pixels) bestAbove = {
9494
- entry: e,
9495
- pixels
9496
- };
9497
- } else if (!bestBelow || pixels > bestBelow.pixels) bestBelow = {
9498
- entry: e,
9499
- pixels
9500
- };
9501
- }
9502
- return bestAbove?.entry ?? bestBelow?.entry;
9591
+ return [...entries.flatMap((entry) => entry.resolution === void 0 ? [] : [{
9592
+ entry,
9593
+ pixels: entry.resolution.width * entry.resolution.height
9594
+ }])].sort((a, b) => compareToTarget(a.pixels, b.pixels, targetPixels))[0]?.entry;
9503
9595
  }
9504
9596
  var MODEL_FORMATS = [
9505
9597
  "onnx",
@@ -11713,6 +11805,58 @@ method(ListInputSchema, array(BrokerInfoSchema$1)), method(GetInputSchema, Broke
11713
11805
  auth: "admin"
11714
11806
  }), method(GetStateInputSchema, unknown().nullable()), method(_void(), RegistryStatusSchema);
11715
11807
  DeviceType.Camera;
11808
+ /**
11809
+ * The signals a device can emit to WAKE its own stream.
11810
+ *
11811
+ * A camera whose stream is built on demand sleeps until something asks for it,
11812
+ * and "something" cannot be a consumer that is merely attached — a Frigate-style
11813
+ * puller holds a session open for ever, and treating that as demand would keep
11814
+ * a battery camera awake for ever, which is the whole thing the battery is for
11815
+ * (D173). So the wake has to come from the CAMERA: an event it noticed by
11816
+ * itself, with no stream running.
11817
+ *
11818
+ * ## The vocabulary is the PROVIDER'S, not ours
11819
+ *
11820
+ * Like `consumables`, this cap declares no vocabulary of its own. A provider
11821
+ * names each signal with a `code` it chooses and a `label` an operator reads.
11822
+ * Reolink offers motion and camera-native detection; another provider may offer
11823
+ * a tamper, a doorbell press, a PIR, or something no camera in this fleet has
11824
+ * yet. A fixed enum here would mean every new signal is a framework release.
11825
+ *
11826
+ * It is deliberately NOT derived from the caps a device already binds. Whether
11827
+ * a camera CAN push firmware motion is expressed by `motionSources` containing
11828
+ * `'onboard'`, and whether it does AI on-camera by the `native-object-detection`
11829
+ * binding — but both answer "what drives the detection pipeline", which is a
11830
+ * different question from "what may wake a sleeping stream". A camera can do
11831
+ * the first and not be trusted with the second, and the operator picks per
11832
+ * camera. Two questions, two authorities.
11833
+ *
11834
+ * ## Availability is not permission
11835
+ *
11836
+ * `listSignals` says what the device CAN emit. Whether a given signal actually
11837
+ * wakes the stream is the operator's per-camera choice, held by the broker
11838
+ * alongside the cooldown — see the stream-broker cap's wake settings. A
11839
+ * provider declaring a signal is not a provider enabling it.
11840
+ */
11841
+ /** One signal a device can emit. */
11842
+ var StreamSignalSchema = object({
11843
+ /** Stable id chosen by the provider, e.g. `'motion'`, `'person'`, `'tamper'`. */
11844
+ code: string().min(1),
11845
+ /** What an operator reads in the picker. The provider's own wording. */
11846
+ label: string().min(1),
11847
+ /**
11848
+ * Whether the provider recommends this signal ON when a camera is first set
11849
+ * up. A provider knows which of its signals are cheap and reliable; an
11850
+ * operator should not have to discover that by trial. Reolink recommends
11851
+ * both of its own.
11852
+ */
11853
+ recommended: boolean()
11854
+ });
11855
+ object({
11856
+ signals: array(StreamSignalSchema),
11857
+ lastFetchedAt: number()
11858
+ });
11859
+ Object.values(DeviceType), method(_void(), array(StreamSignalSchema).readonly());
11716
11860
  /** Stream delivery format. (Relocated from the retired `streaming-engine` cap.) */
11717
11861
  var StreamFormatSchema = _enum([
11718
11862
  "webrtc",
@@ -12109,6 +12253,22 @@ var EgressTranscodeSchema = object({
12109
12253
  camStreamId: string().nullable()
12110
12254
  });
12111
12255
  method(object({
12256
+ deviceId: number().int().nonnegative(),
12257
+ /** The provider's signal code. */
12258
+ code: string().min(1),
12259
+ /** Ms epoch. Absent ⇒ now. */
12260
+ at: number().optional()
12261
+ }), object({
12262
+ /** Whether the broker acted on it, and if not, why. */
12263
+ accepted: boolean(),
12264
+ reason: _enum([
12265
+ "woke",
12266
+ "hold-extended",
12267
+ "not-enabled",
12268
+ "no-consumer",
12269
+ "unknown-code"
12270
+ ])
12271
+ }), { kind: "mutation" }), method(object({
12112
12272
  deviceId: number().int().nonnegative(),
12113
12273
  camStreamId: string().min(1),
12114
12274
  kind: CamStreamKindSchema,
@@ -12363,6 +12523,16 @@ var PickStreamRequirementsSchema = object({
12363
12523
  acceptCodecs: array(StreamCodecSchema).readonly().optional(),
12364
12524
  /** Minimum vertical resolution. Streams shorter than this are dropped. */
12365
12525
  minHeight: number().int().positive().optional(),
12526
+ /**
12527
+ * Maximum vertical resolution. Streams TALLER than this are dropped.
12528
+ *
12529
+ * A consumer can have a ceiling as real as its floor: Alexa documents
12530
+ * 480p to 1080p, and a 4K stream is as unusable to an Echo as a 360p one.
12531
+ * Without this the ceiling had to be re-implemented by every caller — and
12532
+ * one caller implementing it privately is how a second scoring authority
12533
+ * gets born.
12534
+ */
12535
+ maxHeight: number().int().positive().optional(),
12366
12536
  /** Minimum horizontal resolution. */
12367
12537
  minWidth: number().int().positive().optional(),
12368
12538
  /**
@@ -12379,7 +12549,22 @@ var PickStreamRequirementsSchema = object({
12379
12549
  * transcoded" guard: if the device is already serving the consumer's
12380
12550
  * codec end-to-end, there's nothing to optimise.
12381
12551
  */
12382
- requireSiblingCodec: array(StreamCodecSchema).readonly().optional()
12552
+ requireSiblingCodec: array(StreamCodecSchema).readonly().optional(),
12553
+ /**
12554
+ * Whether a stream the consumer CANNOT decode may still be picked, on the
12555
+ * understanding that it will be transcoded.
12556
+ *
12557
+ * Default `false` — today's behaviour, and the right one for a bypass
12558
+ * question ("is there a stream I can forward untouched?"). Set `true` to
12559
+ * ask the larger question: "what is the best stream for me, transcoding if
12560
+ * I must?" A stream that satisfies `acceptCodecs` always outranks one that
12561
+ * does not, so a passthrough is never lost to a transcode; the answer says
12562
+ * which it is in {@link PickedCamStreamSchema.transcodes}.
12563
+ *
12564
+ * This is what lets one picker serve both the bypass and the full source
12565
+ * choice, instead of a consumer scoring privately when the bypass misses.
12566
+ */
12567
+ allowTranscode: boolean().optional()
12383
12568
  }).readonly();
12384
12569
  var PickStreamPreferencesSchema = object({
12385
12570
  /**
@@ -12393,12 +12578,32 @@ var PickStreamPreferencesSchema = object({
12393
12578
  * picks the tallest stream; `'lowest'` picks the shortest (used by
12394
12579
  * memory-constrained consumers / Apple Home guest sessions).
12395
12580
  */
12396
- resolutionPreference: _enum(["highest", "lowest"]).optional()
12581
+ resolutionPreference: _enum(["highest", "lowest"]).optional(),
12582
+ /**
12583
+ * The height the consumer actually wants to DELIVER.
12584
+ *
12585
+ * Not a constraint — an ordering. With it set, the SMALLEST stream at or
12586
+ * above the target wins, and only if nothing reaches it does the tallest
12587
+ * take over. Pulling 1296 lines to draw 720 on a 1280x800 Echo panel costs
12588
+ * a decode and buys nothing, and `resolutionPreference: 'highest'` cannot
12589
+ * express that: it says "as big as possible", which is a different wish.
12590
+ *
12591
+ * Ignored when absent, so every existing caller keeps its ordering.
12592
+ */
12593
+ targetHeight: number().int().positive().optional()
12397
12594
  }).readonly();
12398
12595
  var PickedCamStreamSchema = object({
12399
12596
  camStreamId: string(),
12400
12597
  codec: string().optional(),
12401
12598
  resolution: CamStreamResolutionSchema.optional(),
12599
+ /**
12600
+ * Whether serving this stream requires a decode + re-encode.
12601
+ *
12602
+ * `false` is a stream the consumer can take as it stands. Only ever `true`
12603
+ * when the caller asked for it with `allowTranscode`, so a caller that did
12604
+ * not ask cannot be handed a cost it never agreed to pay.
12605
+ */
12606
+ transcodes: boolean(),
12402
12607
  /** One-line explanation of why this stream won — for logs / debug UI. */
12403
12608
  reason: string()
12404
12609
  });
@@ -12516,28 +12721,6 @@ method(object({}), object({ blocks: array(CoreBlockSchema) }), { auth: "admin" }
12516
12721
  content: string()
12517
12722
  })) }), { auth: "admin" });
12518
12723
  /**
12519
- * Identity — preserves literal types for downstream inference.
12520
- *
12521
- * The constraint is `Record<string, unknown>` (not `CustomActionsSpec`) so
12522
- * TypeScript does not widen each entry's literal `kind`/`auth` fields to
12523
- * the broader unions declared on `CustomActionSpec`'s default generics.
12524
- * Shape validity is enforced separately by the `customAction(...)` helper
12525
- * whose return type is already a `CustomActionSpec<...>`.
12526
- */
12527
- function defineCustomActions(spec) {
12528
- return spec;
12529
- }
12530
- function customAction(input, output, options) {
12531
- return {
12532
- input,
12533
- output,
12534
- kind: options?.kind ?? "query",
12535
- auth: options?.auth ?? "protected",
12536
- scope: options?.scope ?? { kind: "system" },
12537
- ...options?.caller ? { caller: "required" } : {}
12538
- };
12539
- }
12540
- /**
12541
12724
  * `custom-model-registry` — collection cap exposing operator-registered
12542
12725
  * custom detection models. Each provider (today: `addon-model-studio`)
12543
12726
  * contributes a list of `CustomModelDescriptor`s; the hub auto-concatenates
@@ -16634,6 +16817,8 @@ var NcSystemEventKindSchema = _enum([
16634
16817
  "device-enabled",
16635
16818
  "device-battery-low",
16636
16819
  "device-battery-normal",
16820
+ "device-consumable-low",
16821
+ "device-consumable-normal",
16637
16822
  "stream-online",
16638
16823
  "stream-offline",
16639
16824
  "node-online",
@@ -16652,6 +16837,7 @@ var NcSystemEventKindSchema = _enum([
16652
16837
  "addon-updated",
16653
16838
  "server-updated",
16654
16839
  "export-completed",
16840
+ "backup-completed",
16655
16841
  "camera-online",
16656
16842
  "camera-offline",
16657
16843
  "camera-disabled",
@@ -17974,6 +18160,48 @@ var NcAlarmConfigSchema = object({
17974
18160
  settings: NcAlarmSettingsSchema,
17975
18161
  coverage: array(NcAlarmModeCoverageSchema)
17976
18162
  });
18163
+ /**
18164
+ * ONE rule's demand on ONE camera's clip ring.
18165
+ *
18166
+ * A rule, not a camera: the broker takes the MAX over the asks that reach a
18167
+ * camera, so the answer stays a faithful description of the rules and the
18168
+ * bounding (the byte budget, the broker's own ceiling) stays where the cost
18169
+ * lives. A camera that appears in no ask is being told **nothing** — which is
18170
+ * a different thing from never having been told, and only the envelope
18171
+ * (`rulesLoaded`) can tell those apart.
18172
+ */
18173
+ var NcClipRetentionAskSchema = object({
18174
+ /**
18175
+ * The camera this ask is about. **Absent = every camera**, which is what a
18176
+ * rule with no `conditions.devices` means: it can fire anywhere, so it is
18177
+ * asking everywhere.
18178
+ */
18179
+ deviceId: number().int().nonnegative().optional(),
18180
+ /** Seconds of history the rule needs held for it. Never zero — a rule that
18181
+ * needs nothing produces no ask at all. */
18182
+ seconds: number().min(0).max(60),
18183
+ /** The profile the rule cuts from, when it named one. Absent = the cheapest
18184
+ * assigned, which is what the cut defaults to. */
18185
+ profile: CamProfileSchema.optional(),
18186
+ /** Who is asking — so a retention the operator did not expect names a rule. */
18187
+ ruleId: string(),
18188
+ ruleName: string(),
18189
+ /** Why this many seconds: the rule's own window, or the occupancy ceiling. */
18190
+ reason: _enum(["window", "occupancy"])
18191
+ });
18192
+ /**
18193
+ * The whole answer, with the one bit that keeps absence from reading as zero.
18194
+ *
18195
+ * `rulesLoaded` is false until the rule ledger has completed a load at least
18196
+ * once. A centre that is up but has not read its rules yet answers with an
18197
+ * empty ask list that means nothing at all — applying it would drop every
18198
+ * camera's ring to zero for the first events after a restart, which is the
18199
+ * footage this mechanism exists to keep.
18200
+ */
18201
+ var NcClipRetentionPlanSchema = object({
18202
+ rulesLoaded: boolean(),
18203
+ asks: array(NcClipRetentionAskSchema).readonly()
18204
+ });
17977
18205
  method(object({}), object({ rules: array(NcRuleSchema) }), { auth: "admin" }), method(object({ ruleId: string() }), object({ rule: NcRuleSchema.nullable() }), { auth: "admin" }), method(object({ rule: NcRuleInputSchema }), object({ rule: NcRuleSchema }), {
17978
18206
  kind: "mutation",
17979
18207
  auth: "admin",
@@ -17994,7 +18222,7 @@ method(object({}), object({ rules: array(NcRuleSchema) }), { auth: "admin" }), m
17994
18222
  }), object({ success: literal(true) }), {
17995
18223
  kind: "mutation",
17996
18224
  auth: "admin"
17997
- }), method(object({}), object({ mutedDeviceIds: array(number().int()).readonly() }), { auth: "admin" }), method(object({
18225
+ }), method(object({}), NcClipRetentionPlanSchema, { auth: "admin" }), method(object({}), object({ mutedDeviceIds: array(number().int()).readonly() }), { auth: "admin" }), method(object({
17998
18226
  deviceId: number().int(),
17999
18227
  muted: boolean()
18000
18228
  }), object({ success: literal(true) }), {
@@ -20544,6 +20772,33 @@ var RunReplayFrameProcessorResultSchema = object({ tracks: array(object({
20544
20772
  * solid is this track", cheaper than re-deriving it from a trajectory. */
20545
20773
  framesMatched: number().int()
20546
20774
  })).readonly() });
20775
+ /**
20776
+ * The long-term series a camera produces. A closed union so a typo cannot
20777
+ * invent one, and the ONE declaration of it — the addon's `AnalyticsLtsSeries`
20778
+ * is an alias of this, not a second list.
20779
+ */
20780
+ var AnalyticsLtsSeriesSchema = _enum([
20781
+ "motion",
20782
+ "audio-dbfs",
20783
+ "battery",
20784
+ "occupancy"
20785
+ ]);
20786
+ /**
20787
+ * One closed 5-minute bucket of a long-term series.
20788
+ *
20789
+ * `samples` is how many readings the bucket folded — it is the ACTIVITY for a
20790
+ * series like `motion`, and the confidence for one like `occupancy`. A bucket
20791
+ * that exists at all was measured; a bucket that is absent was not, which is a
20792
+ * different claim from zero and the reason this is a sparse series.
20793
+ */
20794
+ var LtsBucketSchema = object({
20795
+ scope: string(),
20796
+ bucketStart: number().int(),
20797
+ samples: number().int().nonnegative(),
20798
+ sum: number(),
20799
+ min: number(),
20800
+ max: number()
20801
+ });
20547
20802
  DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).readonly()), method(object({
20548
20803
  deviceId: number(),
20549
20804
  trackId: string()
@@ -20590,6 +20845,14 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
20590
20845
  kinds: array(string()).optional(),
20591
20846
  limit: number().int().min(1).max(MAX_EVENT_QUERY_LIMIT).default(DEFAULT_EVENT_QUERY_LIMIT)
20592
20847
  }), array(SensorEventSchema).readonly()), method(KeyEventQueryInput, array(KeyEventSchema).readonly()), method(KeyEventBatchQueryInput, array(KeyEventsForDeviceSchema).readonly()), method(object({
20848
+ deviceId: number(),
20849
+ series: AnalyticsLtsSeriesSchema,
20850
+ /** Omit for EVERY scope of this series in the range. */
20851
+ scope: string().optional(),
20852
+ from: number(),
20853
+ to: number(),
20854
+ limit: number().int().positive().optional()
20855
+ }), array(LtsBucketSchema).readonly()), method(object({
20593
20856
  deviceId: number(),
20594
20857
  since: number(),
20595
20858
  until: number(),
@@ -21208,6 +21471,14 @@ var MotionSourceEnum = _enum([
21208
21471
  */
21209
21472
  var MotionSourcesSchema = array(MotionSourceEnum);
21210
21473
  /**
21474
+ * Which root detectors a camera runs. Deliberately the SAME vocabulary
21475
+ * post-analysis already tags every detection with (`DetectionSource`) rather
21476
+ * than a second spelling of the same two ideas — the value an operator picks
21477
+ * here is the value that comes back on the track, the overlay and the debug
21478
+ * row.
21479
+ */
21480
+ var DetectionSourcesSchema = array(DetectionSourceSchema);
21481
+ /**
21211
21482
  * Input shape for `pipeline-runner.reportMotion` cap method. Exported
21212
21483
  * so cap-side consumers (the orchestrator forward, the runner addon's
21213
21484
  * cap implementation, tests) can reuse the type instead of redeclaring
@@ -21358,6 +21629,35 @@ var RunnerCameraConfigSchema = object({
21358
21629
  * events; the orchestrator forwards to `reportMotion`.
21359
21630
  */
21360
21631
  motionSources: MotionSourcesSchema.default(["analyzer"]),
21632
+ /**
21633
+ * WHICH root detector runs for this camera. The detection counterpart of
21634
+ * {@link motionSources}, same shape and same discipline: a per-camera list
21635
+ * of sources the system understands, vendor-agnostic, owned here.
21636
+ *
21637
+ * - `pipeline` — this runner's own root step on decoded pixels (`steps`).
21638
+ * - `onboard` — the CAMERA's own detector, admitted only when it is named
21639
+ * here. Its boxes reach post-analysis on the same
21640
+ * `PipelineInferenceResult` payload as the pipeline's, so an onboard-born
21641
+ * track gets the same tracker, the same overlay and the same detail
21642
+ * subtree (face / plate / embedding).
21643
+ *
21644
+ * `['onboard']` alone is the REPLACEMENT: this runner's root step does not
21645
+ * run, and that is where a camera with a usable onboard detector buys back
21646
+ * its share of the accelerator. It saves the root inference, not the decode
21647
+ * — the detail subtree still needs pixels to cut crops from.
21648
+ *
21649
+ * A camera whose onboard detections carry no geometry cannot serve as a root
21650
+ * step at all (there is nothing to track), and the cap that knows this says
21651
+ * so on `native-object-detection.getOptions().geometry`. On Reolink that is
21652
+ * every battery camera: the boxes ride a sub-stream a battery camera never
21653
+ * attaches, so such a camera stays on `['pipeline']` and its onboard AI is
21654
+ * worth exactly what it already is — a wake signal with a class.
21655
+ *
21656
+ * Defaults to `['pipeline']`, which is byte-for-byte today's behaviour: a
21657
+ * vendor never turns a detector on for the operator, and neither does a
21658
+ * default.
21659
+ */
21660
+ detectionSources: DetectionSourcesSchema.default(["pipeline"]),
21361
21661
  pipelineEnabled: boolean().default(true),
21362
21662
  /** Ordered tree of video steps. Absent → runner skips video detection. */
21363
21663
  steps: array(PipelineStepInputSchema).readonly().optional(),
@@ -21539,7 +21839,47 @@ var RunnerLocalMetricsSchema = object({
21539
21839
  queueDepth: number(),
21540
21840
  frameLazy: FrameLazyMetricsSchema.optional()
21541
21841
  });
21842
+ /**
21843
+ * Pipeline Runner capability — runtime detection workhorse.
21844
+ *
21845
+ * One instance per node. Receives camera assignments from
21846
+ * `addon-pipeline-orchestrator`, subscribes to the local stream-broker for
21847
+ * decoded frames, drains motion + detection queues, calls the local
21848
+ * `motion-detection` and `pipeline-executor` capabilities, and emits typed
21849
+ * `pipeline.inference-result` and `detection.motion-analysis` events on
21850
+ * the bus.
21851
+ *
21852
+ * Distinct from `pipeline-orchestrator` (the hub-side load balancer) — the
21853
+ * runner has zero knowledge of other agents, no global state, and never
21854
+ * makes assignment decisions itself.
21855
+ */
21856
+ /**
21857
+ * Why a runner declined to look at a camera out of band. Named rather than a
21858
+ * bare `false`: a camera asleep on battery (D173), one attached elsewhere, one
21859
+ * already mid-session and one already bursting are four different facts, and a
21860
+ * caller told only "no" cannot tell a protection from a bug.
21861
+ */
21862
+ var OccupancyBurstRefusalSchema = _enum([
21863
+ "not-attached",
21864
+ "battery-asleep",
21865
+ "not-watching",
21866
+ "already-bursting"
21867
+ ]);
21542
21868
  method(RunnerCameraConfigSchema, object({ success: literal(true) }), { kind: "mutation" }), method(object({ deviceId: number() }), object({ success: literal(true) }), { kind: "mutation" }), method(ReportMotionInputSchema, object({ success: literal(true) }), { kind: "mutation" }), method(_void(), RunnerLocalLoadSchema), method(_void(), RunnerLocalMetricsSchema), method(object({ deviceId: number() }), CameraMetricsSchema.nullable()), method(_void(), array(CameraMetricsWithDeviceIdSchema).readonly()), method(_void(), array(number()).readonly()), method(object({
21869
+ deviceId: number(),
21870
+ /** How many frames to collect. The caller sizes this against what its
21871
+ * own confirmation needs; the runner clamps it to the field's range. */
21872
+ frames: number().int().positive().optional(),
21873
+ /** Why, for the log. A burst nobody can attribute is a cost nobody can
21874
+ * defend — the periodic one at least has a timer to point at. */
21875
+ reason: string()
21876
+ }), object({
21877
+ started: boolean(),
21878
+ refusedBecause: OccupancyBurstRefusalSchema.optional()
21879
+ }), {
21880
+ auth: "admin",
21881
+ kind: "mutation"
21882
+ }), method(object({
21543
21883
  handle: FrameHandleSchema,
21544
21884
  bbox: NativeCropBboxSchema,
21545
21885
  maxWidth: number().int().positive().optional(),
@@ -23909,10 +24249,28 @@ DeviceType.Camera, method(object({ deviceId: number().int().nonnegative() }), ar
23909
24249
  }), boolean()), method(object({
23910
24250
  deviceId: number().int().nonnegative(),
23911
24251
  sessionId: string()
23912
- }), object({ pendingRenegotiation: object({
23913
- target: WebrtcStreamTargetSchema,
23914
- epoch: number()
23915
- }).nullable() }));
24252
+ }), object({
24253
+ pendingRenegotiation: object({
24254
+ target: WebrtcStreamTargetSchema,
24255
+ epoch: number()
24256
+ }).nullable(),
24257
+ /**
24258
+ * Whether the session still EXISTS.
24259
+ *
24260
+ * A consumer that holds a resource for the life of a session needs to
24261
+ * be able to ask, because a session does not always end the way it
24262
+ * began. Measured on this hub 2026-09-13: an Echo that got stuck never
24263
+ * sent `SessionDisconnected`, so the Alexa exporter's
24264
+ * `releaseEgressTranscode` never ran, and a 2304x1296 HEVC to 720p
24265
+ * H.264 transcode kept running for NOBODY for more than twenty
24266
+ * minutes. The WebRTC session had logged `WebRTC session closed`
24267
+ * minutes earlier — the broker knew; the holder had no way to ask.
24268
+ *
24269
+ * `false` for a session id the provider has never heard of, which is
24270
+ * the same answer as "it ended": either way nothing is holding it up.
24271
+ */
24272
+ alive: boolean()
24273
+ }));
23916
24274
  object({
23917
24275
  /** All accessory children of the parent. */
23918
24276
  childDeviceIds: array(number()).readonly(),
@@ -26990,7 +27348,7 @@ var NativeDetectionSchema = object({
26990
27348
  /** Firmware-provided confidence [0..1]. Reolink pushes don't carry it → undefined. */
26991
27349
  confidence: number().min(0).max(1).optional()
26992
27350
  });
26993
- object({
27351
+ var NativeObjectDetectionStatusSchema = object({
26994
27352
  /**
26995
27353
  * Last observed instance per class. Missing entries mean the class
26996
27354
  * is supported but nothing has been seen since the provider started.
@@ -27007,14 +27365,56 @@ object({
27007
27365
  supportedClasses: array(NativeObjectClassEnum).readonly(),
27008
27366
  /**
27009
27367
  * Whether forwarding of onboard AI detections is enabled for this device.
27010
- * Default FALSE (opt-in, cold-start) — onboard AI pushes are noisy/sparse and
27011
- * churn the tracker, so forwarding stays off until the operator enables it.
27368
+ *
27369
+ * Cold-start OFF on every camera. A vendor does not decide which detector a
27370
+ * camera runs — that is the operator's choice, and it is made where every
27371
+ * other detection choice is made. `geometry` on
27372
+ * {@link NativeObjectDetectionOptionsSchema} is how the form says what the
27373
+ * choice would buy this particular camera.
27012
27374
  */
27013
27375
  enabled: boolean()
27014
- }).extend({
27376
+ });
27377
+ /**
27378
+ * WHEN a camera's onboard detections carry geometry.
27379
+ *
27380
+ * - `boxed` — always. The provider holds a standing channel for the boxes, so
27381
+ * a detection is a trackable subject whenever the camera sees one.
27382
+ * - `while-streaming` — only while some stream of this camera is already open.
27383
+ * The boxes ride the video's own side-channel rather than a feed of their
27384
+ * own, so they cost nothing and they exist only when something is pulling.
27385
+ * On Reolink this is every BATTERY camera: a persistent second feed is real
27386
+ * radio drain, but a camera that is awake is awake precisely because
27387
+ * something is in front of it, and its stream is already being pulled.
27388
+ * - `flag-only` — never. The firmware ships the class and nothing else; the
27389
+ * pipeline needs geometry to make a subject, so such a push reaches the
27390
+ * tracker as motion and no further.
27391
+ *
27392
+ * Reported and not inferred, because the operator's question in front of the
27393
+ * picker is "what do I get", and these are three different answers (D14: the
27394
+ * derived form is only as honest as `getOptions`).
27395
+ */
27396
+ var NativeObjectGeometryEnum = _enum([
27397
+ "boxed",
27398
+ "while-streaming",
27399
+ "flag-only"
27400
+ ]);
27401
+ var NativeObjectDetectionOptionsSchema = object({
27402
+ /** Classes this firmware can detect — the same list the status reports. */
27403
+ supportedClasses: array(NativeObjectClassEnum).readonly(),
27404
+ /** What a detection from this camera carries. */
27405
+ geometry: NativeObjectGeometryEnum
27406
+ });
27407
+ var NativeObjectDetectionSettingsPatchSchema = object({ enabled: boolean().optional() });
27408
+ NativeObjectDetectionStatusSchema.extend({
27015
27409
  /** Required by createRuntimeStateBridge — epoch ms of last refresh. */
27016
27410
  lastFetchedAt: number() });
27017
- DeviceType.Camera, method(object({
27411
+ DeviceType.Camera, method(object({ deviceId: number() }), NativeObjectDetectionOptionsSchema), method(object({
27412
+ deviceId: number(),
27413
+ settings: NativeObjectDetectionSettingsPatchSchema
27414
+ }), _void(), {
27415
+ kind: "mutation",
27416
+ auth: "admin"
27417
+ }), method(object({
27018
27418
  deviceId: number(),
27019
27419
  enabled: boolean()
27020
27420
  }), _void(), {
@@ -30813,7 +31213,28 @@ var PerScopeBreakdownSchema = object({
30813
31213
  /** Total tracked objects in this scope (frame / zone / unzoned). */
30814
31214
  totalObjects: number().int().nonnegative(),
30815
31215
  /** Per-class count. Keys are macro class names (e.g. `person`, `car`). */
30816
- byClass: record(string(), number().int().nonnegative())
31216
+ byClass: record(string(), number().int().nonnegative()),
31217
+ /**
31218
+ * Of `totalObjects`, how many are STANDING — objects the census holds,
31219
+ * present but not going anywhere (a parked car, a bin, a statue).
31220
+ *
31221
+ * The operator's question about a scene has two halves and they answer
31222
+ * different things: "what is parked here" is stable for hours and must not
31223
+ * flap, while "who is walking through" is the thing an alert is about. One
31224
+ * number could only serve one of them, and `totalObjects` served the first
31225
+ * badly — a passer-by moved it and every occupancy rule saw a change.
31226
+ *
31227
+ * `standing + transient === totalObjects`, always. The total keeps its old
31228
+ * meaning exactly, so no rule written against it changes behaviour.
31229
+ *
31230
+ * ABSENT means UNKNOWN, never zero (D393): a snapshot from a node older than
31231
+ * this field cannot say, and a reader that folds absence in as 0 reports "no
31232
+ * parked objects here" about a camera whose lot is full.
31233
+ */
31234
+ standingObjects: number().int().nonnegative().optional(),
31235
+ /** Of `totalObjects`, how many are passing through. See `standingObjects` —
31236
+ * same absence rule. */
31237
+ transientObjects: number().int().nonnegative().optional()
30817
31238
  });
30818
31239
  var ZoneScopeBreakdownSchema = PerScopeBreakdownSchema.extend({
30819
31240
  zoneId: string(),
@@ -33752,12 +34173,24 @@ Object.freeze({
33752
34173
  addonId: null,
33753
34174
  access: "create"
33754
34175
  },
34176
+ "nativeObjectDetection.getOptions": {
34177
+ capName: "native-object-detection",
34178
+ capScope: "device",
34179
+ addonId: null,
34180
+ access: "view"
34181
+ },
33755
34182
  "nativeObjectDetection.setEnabled": {
33756
34183
  capName: "native-object-detection",
33757
34184
  capScope: "device",
33758
34185
  addonId: null,
33759
34186
  access: "create"
33760
34187
  },
34188
+ "nativeObjectDetection.setSettings": {
34189
+ capName: "native-object-detection",
34190
+ capScope: "device",
34191
+ addonId: null,
34192
+ access: "create"
34193
+ },
33761
34194
  "navigation.getFeatures": {
33762
34195
  capName: "navigation",
33763
34196
  capScope: "device",
@@ -34022,6 +34455,12 @@ Object.freeze({
34022
34455
  addonId: null,
34023
34456
  access: "view"
34024
34457
  },
34458
+ "notificationRules.getClipRetentionAsks": {
34459
+ capName: "notification-rules",
34460
+ capScope: "system",
34461
+ addonId: null,
34462
+ access: "view"
34463
+ },
34025
34464
  "notificationRules.getConditionCatalog": {
34026
34465
  capName: "notification-rules",
34027
34466
  capScope: "system",
@@ -34532,6 +34971,12 @@ Object.freeze({
34532
34971
  addonId: null,
34533
34972
  access: "create"
34534
34973
  },
34974
+ "pipelineAnalytics.readLongTermSeries": {
34975
+ capName: "pipeline-analytics",
34976
+ capScope: "device",
34977
+ addonId: null,
34978
+ access: "view"
34979
+ },
34535
34980
  "pipelineAnalytics.rebuildObjectEmbeddings": {
34536
34981
  capName: "pipeline-analytics",
34537
34982
  capScope: "device",
@@ -35204,6 +35649,12 @@ Object.freeze({
35204
35649
  addonId: null,
35205
35650
  access: "create"
35206
35651
  },
35652
+ "pipelineRunner.requestOccupancyBurst": {
35653
+ capName: "pipeline-runner",
35654
+ capScope: "system",
35655
+ addonId: null,
35656
+ access: "create"
35657
+ },
35207
35658
  "pipelineRunner.runDetailSubtree": {
35208
35659
  capName: "pipeline-runner",
35209
35660
  capScope: "system",
@@ -36416,6 +36867,12 @@ Object.freeze({
36416
36867
  addonId: null,
36417
36868
  access: "create"
36418
36869
  },
36870
+ "streamBroker.reportStreamSignal": {
36871
+ capName: "stream-broker",
36872
+ capScope: "system",
36873
+ addonId: null,
36874
+ access: "create"
36875
+ },
36419
36876
  "streamBroker.restartProfile": {
36420
36877
  capName: "stream-broker",
36421
36878
  capScope: "system",
@@ -36500,6 +36957,12 @@ Object.freeze({
36500
36957
  addonId: null,
36501
36958
  access: "create"
36502
36959
  },
36960
+ "streamSignals.listSignals": {
36961
+ capName: "stream-signals",
36962
+ capScope: "device",
36963
+ addonId: null,
36964
+ access: "view"
36965
+ },
36503
36966
  "switch.setState": {
36504
36967
  capName: "switch",
36505
36968
  capScope: "device",
@@ -37972,11 +38435,21 @@ Object.freeze({
37972
38435
  form: "single",
37973
38436
  optional: false
37974
38437
  }],
38438
+ "nativeObjectDetection.getOptions": [{
38439
+ name: "deviceId",
38440
+ form: "single",
38441
+ optional: false
38442
+ }],
37975
38443
  "nativeObjectDetection.setEnabled": [{
37976
38444
  name: "deviceId",
37977
38445
  form: "single",
37978
38446
  optional: false
37979
38447
  }],
38448
+ "nativeObjectDetection.setSettings": [{
38449
+ name: "deviceId",
38450
+ form: "single",
38451
+ optional: false
38452
+ }],
37980
38453
  "navigation.getFeatures": [{
37981
38454
  name: "deviceId",
37982
38455
  form: "single",
@@ -38326,6 +38799,11 @@ Object.freeze({
38326
38799
  form: "single",
38327
38800
  optional: false
38328
38801
  }],
38802
+ "pipelineAnalytics.readLongTermSeries": [{
38803
+ name: "deviceId",
38804
+ form: "single",
38805
+ optional: false
38806
+ }],
38329
38807
  "pipelineAnalytics.rebuildObjectEmbeddings": [{
38330
38808
  name: "deviceId",
38331
38809
  form: "single",
@@ -38516,6 +38994,11 @@ Object.freeze({
38516
38994
  form: "single",
38517
38995
  optional: false
38518
38996
  }],
38997
+ "pipelineRunner.requestOccupancyBurst": [{
38998
+ name: "deviceId",
38999
+ form: "single",
39000
+ optional: false
39001
+ }],
38519
39002
  "pipelineRunner.runDetailSubtree": [{
38520
39003
  name: "deviceId",
38521
39004
  form: "single",
@@ -38872,6 +39355,11 @@ Object.freeze({
38872
39355
  form: "single",
38873
39356
  optional: false
38874
39357
  }],
39358
+ "streamBroker.reportStreamSignal": [{
39359
+ name: "deviceId",
39360
+ form: "single",
39361
+ optional: false
39362
+ }],
38875
39363
  "streamBroker.restartProfile": [{
38876
39364
  name: "deviceId",
38877
39365
  form: "single",
@@ -39702,7 +40190,7 @@ async function acquireAlexaEgress(input) {
39702
40190
  profile: sourceProfile
39703
40191
  },
39704
40192
  encode: ALEXA_EGRESS_PROFILE,
39705
- rateControl: "relaxed",
40193
+ rateControl: "tight",
39706
40194
  publishLocally: true,
39707
40195
  tag: `alexa/${sessionId}`
39708
40196
  });
@@ -39726,6 +40214,7 @@ async function acquireAlexaEgress(input) {
39726
40214
  meta: {
39727
40215
  pipelineKey: transcode.pipelineKey,
39728
40216
  camStreamId,
40217
+ sourceProfile: input.sourceProfile,
39729
40218
  encoder: transcode.encoder,
39730
40219
  decodeHwAccel: transcode.decodeHwAccel ?? "software"
39731
40220
  }
@@ -39759,6 +40248,512 @@ async function releaseAlexaEgress(input) {
39759
40248
  }
39760
40249
  }
39761
40250
  //#endregion
40251
+ //#region src/egress/egress-handle-reaper.ts
40252
+ /**
40253
+ * Which handles should be released now.
40254
+ *
40255
+ * A session the broker says is GONE is reclaimed. A session it could not be
40256
+ * asked about is KEPT — a read that failed is not evidence of death, and
40257
+ * releasing a live session's transcode would black out a working stream to
40258
+ * save a process (D393, read in the direction that destroys work).
40259
+ */
40260
+ async function reapDeadEgressHandles(input) {
40261
+ const dead = [];
40262
+ for (const handle of input.held) if (await input.isAlive(handle) === false) dead.push(handle);
40263
+ return dead;
40264
+ }
40265
+ /** How often the sweep runs. Well under the minute Amazon requires a session
40266
+ * to survive, so a genuinely dead one is not transcoding for long. */
40267
+ var EGRESS_REAP_INTERVAL_MS = 3e4;
40268
+ function logReaped(logger, handle) {
40269
+ logger?.warn("alexa egress: session is GONE — reclaiming the transcode it still held", {
40270
+ tags: { deviceId: handle.deviceId },
40271
+ meta: {
40272
+ sessionId: handle.sessionId,
40273
+ pipelineKey: handle.pipelineKey,
40274
+ reason: "no SessionDisconnected directive"
40275
+ }
40276
+ });
40277
+ }
40278
+ //#endregion
40279
+ //#region src/egress/alexa-source-pick.ts
40280
+ /** Amazon's documented ceiling. */
40281
+ var ALEXA_MAX_LINES = 1080;
40282
+ /**
40283
+ * Which profile slot serves `camStreamId`, or `null` when none does — a read
40284
+ * that failed is `null` too, and the caller keeps its previous behaviour
40285
+ * rather than guessing a slot from an error (D393).
40286
+ */
40287
+ async function profileForCamStream(input) {
40288
+ try {
40289
+ const slots = await input.api.streamBroker.listAllProfileSlots.query();
40290
+ for (const slot of slots) {
40291
+ if (slot.deviceId !== input.deviceId) continue;
40292
+ if (slot.sourceCamStreamId !== input.camStreamId) continue;
40293
+ if (slot.profile === "high" || slot.profile === "mid" || slot.profile === "low") return slot.profile;
40294
+ }
40295
+ return null;
40296
+ } catch (err) {
40297
+ input.logger?.warn("alexa rtc-session: could not read the profile slots", {
40298
+ tags: { deviceId: input.deviceId },
40299
+ meta: { error: errMsg(err) }
40300
+ });
40301
+ return null;
40302
+ }
40303
+ }
40304
+ //#endregion
40305
+ //#region src/handlers/endpoint-id.ts
40306
+ var SEPARATOR = "__";
40307
+ function parseCameraEndpointId(raw) {
40308
+ if (!raw.includes(SEPARATOR)) {
40309
+ const id = parseNonNegativeInt(raw);
40310
+ if (id === null) return {
40311
+ kind: "invalid",
40312
+ reason: `not a numeric deviceId: ${raw}`
40313
+ };
40314
+ return {
40315
+ kind: "camera",
40316
+ deviceId: id
40317
+ };
40318
+ }
40319
+ const [head, tail, ...rest] = raw.split(SEPARATOR);
40320
+ if (head === void 0 || tail === void 0 || rest.length > 0) return {
40321
+ kind: "invalid",
40322
+ reason: `unexpected segments in endpointId: ${raw}`
40323
+ };
40324
+ const parent = parseNonNegativeInt(head);
40325
+ if (parent === null) return {
40326
+ kind: "invalid",
40327
+ reason: `parent segment not numeric: ${raw}`
40328
+ };
40329
+ if (tail === "autotrack") return {
40330
+ kind: "autotrack",
40331
+ parentDeviceId: parent
40332
+ };
40333
+ if (tail === "privacy") return {
40334
+ kind: "privacy",
40335
+ parentDeviceId: parent
40336
+ };
40337
+ if (tail === "alarm") return {
40338
+ kind: "alarm-panel",
40339
+ deviceId: parent
40340
+ };
40341
+ const child = parseNonNegativeInt(tail);
40342
+ if (child === null) return {
40343
+ kind: "invalid",
40344
+ reason: `child segment not numeric and not 'autotrack'/'privacy'/'alarm': ${raw}`
40345
+ };
40346
+ return {
40347
+ kind: "child",
40348
+ parentDeviceId: parent,
40349
+ childDeviceId: child
40350
+ };
40351
+ }
40352
+ function parseNonNegativeInt(s) {
40353
+ if (!/^\d+$/.test(s)) return null;
40354
+ const n = Number.parseInt(s, 10);
40355
+ if (!Number.isFinite(n) || n < 0) return null;
40356
+ return n;
40357
+ }
40358
+ //#endregion
40359
+ //#region src/handlers/error-envelope.ts
40360
+ /**
40361
+ * Shared Alexa `Alexa/ErrorResponse` envelope builder.
40362
+ *
40363
+ * Lives in its own module so every handler (and the directive router)
40364
+ * can emit its own namespace-appropriate error without circular imports.
40365
+ */
40366
+ object({ event: object({
40367
+ header: object({
40368
+ namespace: literal("Alexa"),
40369
+ name: literal("ErrorResponse"),
40370
+ messageId: string(),
40371
+ payloadVersion: literal("3")
40372
+ }),
40373
+ payload: object({
40374
+ type: string(),
40375
+ message: string()
40376
+ })
40377
+ }) });
40378
+ function errorEnvelope(header, type, message) {
40379
+ return { event: {
40380
+ header: {
40381
+ namespace: "Alexa",
40382
+ name: "ErrorResponse",
40383
+ messageId: header.messageId,
40384
+ payloadVersion: "3"
40385
+ },
40386
+ payload: {
40387
+ type,
40388
+ message
40389
+ }
40390
+ } };
40391
+ }
40392
+ //#endregion
40393
+ //#region src/handlers/types.ts
40394
+ /**
40395
+ * Zod schemas for the subset of the Alexa Smart Home directive surface
40396
+ * that we actually parse hub-side. The Smart Home Skill API ships
40397
+ * thousands of optional fields per namespace — we only validate what
40398
+ * we read; everything else passes through as `unknown`.
40399
+ *
40400
+ * Reference: https://developer.amazon.com/en-US/docs/alexa/smarthome/smart-home-skill-api-message-reference.html
40401
+ */
40402
+ /** A directive header carries namespace + name + token + correlation id. */
40403
+ var DirectiveHeaderSchema = object({
40404
+ namespace: string(),
40405
+ name: string(),
40406
+ payloadVersion: string(),
40407
+ messageId: string(),
40408
+ correlationToken: string().optional()
40409
+ });
40410
+ /** Scope carries the bearer token Alexa minted via our OAuth provider. */
40411
+ var BearerScopeSchema = object({
40412
+ type: literal("BearerToken"),
40413
+ token: string()
40414
+ });
40415
+ /** Endpoint reference inside a directive payload. */
40416
+ var EndpointRefSchema = object({
40417
+ endpointId: string(),
40418
+ scope: BearerScopeSchema.optional(),
40419
+ cookie: record(string(), string()).optional()
40420
+ });
40421
+ var DiscoveryDirectiveSchema = object({
40422
+ header: DirectiveHeaderSchema.extend({
40423
+ namespace: literal("Alexa.Discovery"),
40424
+ name: literal("Discover")
40425
+ }),
40426
+ payload: object({ scope: BearerScopeSchema })
40427
+ });
40428
+ /** Envelope wrapping a directive on the Alexa→skill wire. */
40429
+ var DirectiveRequestSchema = object({ directive: object({
40430
+ header: DirectiveHeaderSchema,
40431
+ endpoint: EndpointRefSchema.optional(),
40432
+ payload: unknown().optional()
40433
+ }) });
40434
+ var InitializeCameraStreamsPayloadSchema = object({ cameraStreams: array(object({
40435
+ protocol: string(),
40436
+ resolution: object({
40437
+ width: number(),
40438
+ height: number()
40439
+ }).optional(),
40440
+ authorizationType: string().optional(),
40441
+ videoCodec: string().optional(),
40442
+ audioCodec: string().optional()
40443
+ })).optional() });
40444
+ var InitiateSessionWithOfferPayloadSchema = object({
40445
+ sessionId: string(),
40446
+ offer: object({
40447
+ format: string().optional(),
40448
+ value: string()
40449
+ })
40450
+ });
40451
+ var SessionDisconnectedPayloadSchema = object({ sessionId: string() });
40452
+ var SetModePayloadSchema = object({ mode: string() });
40453
+ //#endregion
40454
+ //#region src/handlers/rtc-session.ts
40455
+ var InitiateSessionWithOfferDirectiveSchema = object({
40456
+ endpoint: EndpointRefSchema,
40457
+ payload: InitiateSessionWithOfferPayloadSchema
40458
+ });
40459
+ var SessionDisconnectedDirectiveSchema = object({
40460
+ endpoint: EndpointRefSchema,
40461
+ payload: SessionDisconnectedPayloadSchema
40462
+ });
40463
+ /**
40464
+ * Live egress handles, keyed by `<deviceId>/<sessionId>`.
40465
+ *
40466
+ * The primitive's refcount has no lease TTL: a holder that vanishes without
40467
+ * releasing leaks a child until the broker restarts (D67). This handler is a
40468
+ * holder, so it keeps the one piece of state that lets it not be that holder —
40469
+ * the `pipelineKey` its `SessionDisconnected` has to release.
40470
+ *
40471
+ * A module-level map is right here because the directive handlers are free
40472
+ * functions called per directive; the entry is written on a successful acquire
40473
+ * and deleted on the release path, both of which are the same tick as the
40474
+ * directive that owns it.
40475
+ */
40476
+ var EGRESS_HANDLES = /* @__PURE__ */ new Map();
40477
+ function egressHandleKey(deviceId, sessionId) {
40478
+ return `${deviceId}/${sessionId}`;
40479
+ }
40480
+ /** Every handle currently held, for the sweep. */
40481
+ function heldEgressHandles() {
40482
+ const held = [];
40483
+ for (const [key, pipelineKey] of EGRESS_HANDLES) {
40484
+ const slash = key.indexOf("/");
40485
+ if (slash <= 0) continue;
40486
+ const deviceId = Number.parseInt(key.slice(0, slash), 10);
40487
+ if (!Number.isFinite(deviceId)) continue;
40488
+ held.push({
40489
+ deviceId,
40490
+ sessionId: key.slice(slash + 1),
40491
+ pipelineKey
40492
+ });
40493
+ }
40494
+ return held;
40495
+ }
40496
+ /** Drop a handle the sweep reclaimed, so it is not reaped twice. */
40497
+ function forgetEgressHandle(handle) {
40498
+ EGRESS_HANDLES.delete(egressHandleKey(handle.deviceId, handle.sessionId));
40499
+ }
40500
+ var WEBRTC_PROFILE_NAMES = [
40501
+ "high",
40502
+ "mid",
40503
+ "low"
40504
+ ];
40505
+ function resolveWebrtcTarget(pref) {
40506
+ if (pref === "auto") return void 0;
40507
+ if (WEBRTC_PROFILE_NAMES.includes(pref)) return {
40508
+ kind: "profile",
40509
+ profile: pref
40510
+ };
40511
+ return {
40512
+ kind: "cam-stream",
40513
+ camStreamId: pref
40514
+ };
40515
+ }
40516
+ async function handleInitiateSessionWithOffer(header, endpointRaw, payloadRaw, deps, _callerIdentity) {
40517
+ const parsed = InitiateSessionWithOfferDirectiveSchema.safeParse({
40518
+ endpoint: endpointRaw,
40519
+ payload: payloadRaw ?? {}
40520
+ });
40521
+ if (!parsed.success) return errorEnvelope(header, "INVALID_DIRECTIVE", `payload validation failed: ${parsed.error.message}`);
40522
+ const endpoint = parsed.data.endpoint;
40523
+ const payload = parsed.data.payload;
40524
+ const parsedId = parseCameraEndpointId(endpoint.endpointId);
40525
+ if (parsedId.kind !== "camera") return errorEnvelope(header, "INVALID_DIRECTIVE", `endpointId ${endpoint.endpointId} is not a camera`);
40526
+ const deviceId = parsedId.deviceId;
40527
+ const sessionId = payload.sessionId;
40528
+ const log = deps.logger.withTags({
40529
+ deviceId,
40530
+ sessionId
40531
+ });
40532
+ const sdpOffer = payload.offer.value;
40533
+ if (sdpOffer.length === 0) return errorEnvelope(header, "INVALID_DIRECTIVE", "offer.value is empty");
40534
+ const api = deps.ctx.api;
40535
+ const exposedEntry = deps.listExposed().find((e) => e.deviceId === deviceId);
40536
+ const pinnedProfile = (() => {
40537
+ const pref = exposedEntry?.settings?.streamPreference;
40538
+ if (pref === "low" || pref === "mid" || pref === "high") return pref;
40539
+ })();
40540
+ const allowTranscode = exposedEntry?.settings?.allowTranscode !== false;
40541
+ const scored = pinnedProfile === void 0 ? await api.cameraStreams.pickStream.query({
40542
+ deviceId,
40543
+ requirements: {
40544
+ acceptCodecs: ["h264"],
40545
+ minHeight: 480,
40546
+ maxHeight: ALEXA_MAX_LINES,
40547
+ excludeDerived: true,
40548
+ allowTranscode
40549
+ },
40550
+ preferences: { targetHeight: 720 }
40551
+ }).catch((err) => {
40552
+ log.warn("alexa rtc-session: pickStream threw — falling back to the high slot", {
40553
+ tags: { deviceId },
40554
+ meta: { error: err instanceof Error ? err.message : String(err) }
40555
+ });
40556
+ return null;
40557
+ }) : null;
40558
+ const scoredProfile = scored === null ? null : await profileForCamStream({
40559
+ api,
40560
+ deviceId,
40561
+ camStreamId: scored.camStreamId,
40562
+ logger: log
40563
+ });
40564
+ const sourceProfile = pinnedProfile ?? scoredProfile ?? "high";
40565
+ if (pinnedProfile === void 0) log.info("alexa rtc-session: source scored against the Echo requirements", {
40566
+ tags: { deviceId },
40567
+ meta: {
40568
+ chosen: sourceProfile,
40569
+ scored: scored === null ? "none-eligible" : scored.reason,
40570
+ camStreamId: scored?.camStreamId ?? null,
40571
+ slot: scoredProfile ?? (scored === null ? null : "no-slot"),
40572
+ height: scored?.resolution?.height ?? null,
40573
+ transcodes: scored?.transcodes ?? null,
40574
+ allowTranscode
40575
+ }
40576
+ });
40577
+ let derivedTarget;
40578
+ /**
40579
+ * The primitive's release handle, when this session acquired one. Recorded so
40580
+ * `SessionDisconnected` can release it: a refcount whose holder disappears is
40581
+ * the one gap D67 leaves open, and the least this handler can do is not BE
40582
+ * that holder.
40583
+ */
40584
+ let egressPipelineKey = null;
40585
+ const picked = await api.cameraStreams.pickStream.query({
40586
+ deviceId,
40587
+ requirements: {
40588
+ acceptCodecs: ["h264"],
40589
+ minHeight: 480,
40590
+ excludeDerived: true,
40591
+ requireSiblingCodec: ["h265", "hevc"]
40592
+ }
40593
+ }).catch((err) => {
40594
+ log.warn("alexa rtc-session: cameraStreams.pickStream threw — falling back to transcode", { meta: { error: err instanceof Error ? err.message : String(err) } });
40595
+ return null;
40596
+ });
40597
+ if (picked) {
40598
+ log.info("alexa rtc-session: bypassing transcode via cameraStreams.pickStream", { meta: {
40599
+ sourceProfile,
40600
+ selectedCamStreamId: picked.camStreamId,
40601
+ reason: picked.reason
40602
+ } });
40603
+ derivedTarget = {
40604
+ kind: "cam-stream",
40605
+ camStreamId: picked.camStreamId
40606
+ };
40607
+ } else try {
40608
+ const acquired = await acquireAlexaEgress({
40609
+ api,
40610
+ deviceId,
40611
+ sourceProfile,
40612
+ sessionId,
40613
+ logger: log
40614
+ });
40615
+ egressPipelineKey = acquired.pipelineKey;
40616
+ EGRESS_HANDLES.set(egressHandleKey(deviceId, sessionId), acquired.pipelineKey);
40617
+ derivedTarget = {
40618
+ kind: "cam-stream",
40619
+ camStreamId: acquired.camStreamId
40620
+ };
40621
+ } catch (err) {
40622
+ log.warn("alexa rtc-session: egress acquire failed, falling back to streamPreference target", { meta: { error: err instanceof Error ? err.message : String(err) } });
40623
+ derivedTarget = resolveWebrtcTarget(exposedEntry?.settings?.streamPreference ?? "auto");
40624
+ }
40625
+ try {
40626
+ const result = await api.webrtcSession.handleOffer.mutate({
40627
+ deviceId,
40628
+ sdpOffer,
40629
+ sessionId,
40630
+ target: derivedTarget,
40631
+ relayOnly: false,
40632
+ nonTrickle: true,
40633
+ disableTurn: true,
40634
+ disableIpv6: true,
40635
+ consumerAttribution: {
40636
+ kind: "alexa",
40637
+ sessionId
40638
+ }
40639
+ });
40640
+ log.info("alexa rtc-session: AnswerGenerated", { meta: {
40641
+ returnedSessionId: result.sessionId,
40642
+ sdpAnswerLen: result.sdpAnswer.length
40643
+ } });
40644
+ return { event: {
40645
+ header: {
40646
+ namespace: "Alexa.RTCSessionController",
40647
+ name: "AnswerGeneratedForSession",
40648
+ messageId: header.messageId,
40649
+ correlationToken: header.correlationToken,
40650
+ payloadVersion: "3"
40651
+ },
40652
+ endpoint: { endpointId: endpoint.endpointId },
40653
+ payload: { answer: {
40654
+ format: payload.offer.format ?? "SDP",
40655
+ value: result.sdpAnswer
40656
+ } }
40657
+ } };
40658
+ } catch (err) {
40659
+ const message = err instanceof Error ? err.message : String(err);
40660
+ const mSectionSummary = (() => {
40661
+ const lines = sdpOffer.split(/\r?\n/);
40662
+ const sections = [];
40663
+ let current = null;
40664
+ for (const raw of lines) {
40665
+ const line = raw.trim();
40666
+ if (line.startsWith("m=")) {
40667
+ if (current) sections.push(current);
40668
+ current = {
40669
+ mid: null,
40670
+ kind: line.split(" ")[0]?.slice(2) ?? "unknown",
40671
+ direction: null,
40672
+ codecs: []
40673
+ };
40674
+ } else if (current) {
40675
+ if (line.startsWith("a=mid:")) current.mid = line.slice(6);
40676
+ else if (/^a=(sendonly|recvonly|sendrecv|inactive)$/.test(line)) current.direction = line.slice(2);
40677
+ else if (line.startsWith("a=rtpmap:")) current.codecs.push(line.slice(9));
40678
+ }
40679
+ }
40680
+ if (current) sections.push(current);
40681
+ return sections;
40682
+ })();
40683
+ log.warn("alexa rtc-session: handleOffer failed", { meta: {
40684
+ error: message,
40685
+ offerLen: sdpOffer.length,
40686
+ mSections: mSectionSummary,
40687
+ offerSdp: sdpOffer
40688
+ } });
40689
+ if (egressPipelineKey !== null) {
40690
+ EGRESS_HANDLES.delete(egressHandleKey(deviceId, sessionId));
40691
+ await releaseAlexaEgress({
40692
+ api,
40693
+ deviceId,
40694
+ pipelineKey: egressPipelineKey,
40695
+ logger: log
40696
+ });
40697
+ }
40698
+ return errorEnvelope(header, "INTERNAL_ERROR", `webrtc-session.handleOffer failed: ${message}`);
40699
+ }
40700
+ }
40701
+ async function handleSessionDisconnected(header, endpointRaw, payloadRaw, deps, _callerIdentity) {
40702
+ const parsed = SessionDisconnectedDirectiveSchema.safeParse({
40703
+ endpoint: endpointRaw,
40704
+ payload: payloadRaw ?? {}
40705
+ });
40706
+ if (!parsed.success) return errorEnvelope(header, "INVALID_DIRECTIVE", `payload validation failed: ${parsed.error.message}`);
40707
+ const endpoint = parsed.data.endpoint;
40708
+ const parsedId = parseCameraEndpointId(endpoint.endpointId);
40709
+ if (parsedId.kind === "camera") {
40710
+ const handleKey = egressHandleKey(parsedId.deviceId, parsed.data.payload.sessionId);
40711
+ const pipelineKey = EGRESS_HANDLES.get(handleKey);
40712
+ if (pipelineKey !== void 0) {
40713
+ EGRESS_HANDLES.delete(handleKey);
40714
+ await releaseAlexaEgress({
40715
+ api: deps.ctx.api,
40716
+ deviceId: parsedId.deviceId,
40717
+ pipelineKey,
40718
+ logger: deps.logger
40719
+ });
40720
+ }
40721
+ try {
40722
+ await deps.ctx.api.webrtcSession.closeSession.mutate({
40723
+ deviceId: parsedId.deviceId,
40724
+ sessionId: parsed.data.payload.sessionId
40725
+ });
40726
+ } catch (err) {
40727
+ deps.logger.warn("alexa rtc-session: closeSession threw on disconnect (continuing to ack)", {
40728
+ tags: {
40729
+ deviceId: parsedId.deviceId,
40730
+ sessionId: parsed.data.payload.sessionId
40731
+ },
40732
+ meta: { error: err instanceof Error ? err.message : String(err) }
40733
+ });
40734
+ }
40735
+ }
40736
+ const disconnectedDeviceId = parsedId.kind === "camera" ? parsedId.deviceId : void 0;
40737
+ deps.logger.info("alexa rtc-session: SessionDisconnected", {
40738
+ tags: {
40739
+ sessionId: parsed.data.payload.sessionId,
40740
+ ...disconnectedDeviceId !== void 0 ? { deviceId: disconnectedDeviceId } : {}
40741
+ },
40742
+ meta: { endpointId: endpoint.endpointId }
40743
+ });
40744
+ return { event: {
40745
+ header: {
40746
+ namespace: "Alexa",
40747
+ name: "Response",
40748
+ messageId: header.messageId,
40749
+ correlationToken: header.correlationToken,
40750
+ payloadVersion: "3"
40751
+ },
40752
+ endpoint: { endpointId: endpoint.endpointId },
40753
+ payload: {}
40754
+ } };
40755
+ }
40756
+ //#endregion
39762
40757
  //#region src/handlers/security-panel-arm-state.ts
39763
40758
  /**
39764
40759
  * The panel modes behind each Alexa arm state, PRIMARY first — the mirror of
@@ -40189,101 +41184,6 @@ function buildAlarmPanelEndpoint(input) {
40189
41184
  };
40190
41185
  }
40191
41186
  //#endregion
40192
- //#region src/handlers/error-envelope.ts
40193
- /**
40194
- * Shared Alexa `Alexa/ErrorResponse` envelope builder.
40195
- *
40196
- * Lives in its own module so every handler (and the directive router)
40197
- * can emit its own namespace-appropriate error without circular imports.
40198
- */
40199
- object({ event: object({
40200
- header: object({
40201
- namespace: literal("Alexa"),
40202
- name: literal("ErrorResponse"),
40203
- messageId: string(),
40204
- payloadVersion: literal("3")
40205
- }),
40206
- payload: object({
40207
- type: string(),
40208
- message: string()
40209
- })
40210
- }) });
40211
- function errorEnvelope(header, type, message) {
40212
- return { event: {
40213
- header: {
40214
- namespace: "Alexa",
40215
- name: "ErrorResponse",
40216
- messageId: header.messageId,
40217
- payloadVersion: "3"
40218
- },
40219
- payload: {
40220
- type,
40221
- message
40222
- }
40223
- } };
40224
- }
40225
- //#endregion
40226
- //#region src/handlers/types.ts
40227
- /**
40228
- * Zod schemas for the subset of the Alexa Smart Home directive surface
40229
- * that we actually parse hub-side. The Smart Home Skill API ships
40230
- * thousands of optional fields per namespace — we only validate what
40231
- * we read; everything else passes through as `unknown`.
40232
- *
40233
- * Reference: https://developer.amazon.com/en-US/docs/alexa/smarthome/smart-home-skill-api-message-reference.html
40234
- */
40235
- /** A directive header carries namespace + name + token + correlation id. */
40236
- var DirectiveHeaderSchema = object({
40237
- namespace: string(),
40238
- name: string(),
40239
- payloadVersion: string(),
40240
- messageId: string(),
40241
- correlationToken: string().optional()
40242
- });
40243
- /** Scope carries the bearer token Alexa minted via our OAuth provider. */
40244
- var BearerScopeSchema = object({
40245
- type: literal("BearerToken"),
40246
- token: string()
40247
- });
40248
- /** Endpoint reference inside a directive payload. */
40249
- var EndpointRefSchema = object({
40250
- endpointId: string(),
40251
- scope: BearerScopeSchema.optional(),
40252
- cookie: record(string(), string()).optional()
40253
- });
40254
- var DiscoveryDirectiveSchema = object({
40255
- header: DirectiveHeaderSchema.extend({
40256
- namespace: literal("Alexa.Discovery"),
40257
- name: literal("Discover")
40258
- }),
40259
- payload: object({ scope: BearerScopeSchema })
40260
- });
40261
- /** Envelope wrapping a directive on the Alexa→skill wire. */
40262
- var DirectiveRequestSchema = object({ directive: object({
40263
- header: DirectiveHeaderSchema,
40264
- endpoint: EndpointRefSchema.optional(),
40265
- payload: unknown().optional()
40266
- }) });
40267
- var InitializeCameraStreamsPayloadSchema = object({ cameraStreams: array(object({
40268
- protocol: string(),
40269
- resolution: object({
40270
- width: number(),
40271
- height: number()
40272
- }).optional(),
40273
- authorizationType: string().optional(),
40274
- videoCodec: string().optional(),
40275
- audioCodec: string().optional()
40276
- })).optional() });
40277
- var InitiateSessionWithOfferPayloadSchema = object({
40278
- sessionId: string(),
40279
- offer: object({
40280
- format: string().optional(),
40281
- value: string()
40282
- })
40283
- });
40284
- var SessionDisconnectedPayloadSchema = object({ sessionId: string() });
40285
- var SetModePayloadSchema = object({ mode: string() });
40286
- //#endregion
40287
41187
  //#region src/handlers/discovery.ts
40288
41188
  var discoveryPayloadSchema = DiscoveryDirectiveSchema.shape.payload;
40289
41189
  /** Devices built at once in `handleDiscovery` — latency-bound tRPC reads, so
@@ -40492,60 +41392,6 @@ function discoveryResponse(header, endpoints) {
40492
41392
  } };
40493
41393
  }
40494
41394
  //#endregion
40495
- //#region src/handlers/endpoint-id.ts
40496
- var SEPARATOR = "__";
40497
- function parseCameraEndpointId(raw) {
40498
- if (!raw.includes(SEPARATOR)) {
40499
- const id = parseNonNegativeInt(raw);
40500
- if (id === null) return {
40501
- kind: "invalid",
40502
- reason: `not a numeric deviceId: ${raw}`
40503
- };
40504
- return {
40505
- kind: "camera",
40506
- deviceId: id
40507
- };
40508
- }
40509
- const [head, tail, ...rest] = raw.split(SEPARATOR);
40510
- if (head === void 0 || tail === void 0 || rest.length > 0) return {
40511
- kind: "invalid",
40512
- reason: `unexpected segments in endpointId: ${raw}`
40513
- };
40514
- const parent = parseNonNegativeInt(head);
40515
- if (parent === null) return {
40516
- kind: "invalid",
40517
- reason: `parent segment not numeric: ${raw}`
40518
- };
40519
- if (tail === "autotrack") return {
40520
- kind: "autotrack",
40521
- parentDeviceId: parent
40522
- };
40523
- if (tail === "privacy") return {
40524
- kind: "privacy",
40525
- parentDeviceId: parent
40526
- };
40527
- if (tail === "alarm") return {
40528
- kind: "alarm-panel",
40529
- deviceId: parent
40530
- };
40531
- const child = parseNonNegativeInt(tail);
40532
- if (child === null) return {
40533
- kind: "invalid",
40534
- reason: `child segment not numeric and not 'autotrack'/'privacy'/'alarm': ${raw}`
40535
- };
40536
- return {
40537
- kind: "child",
40538
- parentDeviceId: parent,
40539
- childDeviceId: child
40540
- };
40541
- }
40542
- function parseNonNegativeInt(s) {
40543
- if (!/^\d+$/.test(s)) return null;
40544
- const n = Number.parseInt(s, 10);
40545
- if (!Number.isFinite(n) || n < 0) return null;
40546
- return n;
40547
- }
40548
- //#endregion
40549
41395
  //#region src/handlers/brightness-controller.ts
40550
41396
  var SetBrightnessDirectiveSchema = object({
40551
41397
  header: object({
@@ -41122,254 +41968,6 @@ async function collectChildProperties(api, childDeviceId, properties, timeOfSamp
41122
41968
  });
41123
41969
  }
41124
41970
  //#endregion
41125
- //#region src/handlers/rtc-session.ts
41126
- var InitiateSessionWithOfferDirectiveSchema = object({
41127
- endpoint: EndpointRefSchema,
41128
- payload: InitiateSessionWithOfferPayloadSchema
41129
- });
41130
- var SessionDisconnectedDirectiveSchema = object({
41131
- endpoint: EndpointRefSchema,
41132
- payload: SessionDisconnectedPayloadSchema
41133
- });
41134
- /**
41135
- * Live egress handles, keyed by `<deviceId>/<sessionId>`.
41136
- *
41137
- * The primitive's refcount has no lease TTL: a holder that vanishes without
41138
- * releasing leaks a child until the broker restarts (D67). This handler is a
41139
- * holder, so it keeps the one piece of state that lets it not be that holder —
41140
- * the `pipelineKey` its `SessionDisconnected` has to release.
41141
- *
41142
- * A module-level map is right here because the directive handlers are free
41143
- * functions called per directive; the entry is written on a successful acquire
41144
- * and deleted on the release path, both of which are the same tick as the
41145
- * directive that owns it.
41146
- */
41147
- var EGRESS_HANDLES = /* @__PURE__ */ new Map();
41148
- function egressHandleKey(deviceId, sessionId) {
41149
- return `${deviceId}/${sessionId}`;
41150
- }
41151
- var WEBRTC_PROFILE_NAMES = [
41152
- "high",
41153
- "mid",
41154
- "low"
41155
- ];
41156
- function resolveWebrtcTarget(pref) {
41157
- if (pref === "auto") return void 0;
41158
- if (WEBRTC_PROFILE_NAMES.includes(pref)) return {
41159
- kind: "profile",
41160
- profile: pref
41161
- };
41162
- return {
41163
- kind: "cam-stream",
41164
- camStreamId: pref
41165
- };
41166
- }
41167
- async function handleInitiateSessionWithOffer(header, endpointRaw, payloadRaw, deps, _callerIdentity) {
41168
- const parsed = InitiateSessionWithOfferDirectiveSchema.safeParse({
41169
- endpoint: endpointRaw,
41170
- payload: payloadRaw ?? {}
41171
- });
41172
- if (!parsed.success) return errorEnvelope(header, "INVALID_DIRECTIVE", `payload validation failed: ${parsed.error.message}`);
41173
- const endpoint = parsed.data.endpoint;
41174
- const payload = parsed.data.payload;
41175
- const parsedId = parseCameraEndpointId(endpoint.endpointId);
41176
- if (parsedId.kind !== "camera") return errorEnvelope(header, "INVALID_DIRECTIVE", `endpointId ${endpoint.endpointId} is not a camera`);
41177
- const deviceId = parsedId.deviceId;
41178
- const sessionId = payload.sessionId;
41179
- const log = deps.logger.withTags({
41180
- deviceId,
41181
- sessionId
41182
- });
41183
- const sdpOffer = payload.offer.value;
41184
- if (sdpOffer.length === 0) return errorEnvelope(header, "INVALID_DIRECTIVE", "offer.value is empty");
41185
- const api = deps.ctx.api;
41186
- const exposedEntry = deps.listExposed().find((e) => e.deviceId === deviceId);
41187
- const explicitSourceProfile = exposedEntry?.settings?.sourceProfile;
41188
- const mappedFromPreference = (() => {
41189
- const pref = exposedEntry?.settings?.streamPreference;
41190
- if (pref === "low" || pref === "mid" || pref === "high") return pref;
41191
- })();
41192
- const sourceProfile = explicitSourceProfile ?? mappedFromPreference ?? "high";
41193
- let derivedTarget;
41194
- /**
41195
- * The primitive's release handle, when this session acquired one. Recorded so
41196
- * `SessionDisconnected` can release it: a refcount whose holder disappears is
41197
- * the one gap D67 leaves open, and the least this handler can do is not BE
41198
- * that holder.
41199
- */
41200
- let egressPipelineKey = null;
41201
- const picked = await api.cameraStreams.pickStream.query({
41202
- deviceId,
41203
- requirements: {
41204
- acceptCodecs: ["h264"],
41205
- minHeight: 480,
41206
- excludeDerived: true,
41207
- requireSiblingCodec: ["h265", "hevc"]
41208
- }
41209
- }).catch((err) => {
41210
- log.warn("alexa rtc-session: cameraStreams.pickStream threw — falling back to transcode", { meta: { error: err instanceof Error ? err.message : String(err) } });
41211
- return null;
41212
- });
41213
- if (picked) {
41214
- log.info("alexa rtc-session: bypassing transcode via cameraStreams.pickStream", { meta: {
41215
- sourceProfile,
41216
- selectedCamStreamId: picked.camStreamId,
41217
- reason: picked.reason
41218
- } });
41219
- derivedTarget = {
41220
- kind: "cam-stream",
41221
- camStreamId: picked.camStreamId
41222
- };
41223
- } else try {
41224
- const acquired = await acquireAlexaEgress({
41225
- api,
41226
- deviceId,
41227
- sourceProfile,
41228
- sessionId,
41229
- logger: log
41230
- });
41231
- egressPipelineKey = acquired.pipelineKey;
41232
- EGRESS_HANDLES.set(egressHandleKey(deviceId, sessionId), acquired.pipelineKey);
41233
- derivedTarget = {
41234
- kind: "cam-stream",
41235
- camStreamId: acquired.camStreamId
41236
- };
41237
- } catch (err) {
41238
- log.warn("alexa rtc-session: egress acquire failed, falling back to streamPreference target", { meta: { error: err instanceof Error ? err.message : String(err) } });
41239
- derivedTarget = resolveWebrtcTarget(exposedEntry?.settings?.streamPreference ?? "auto");
41240
- }
41241
- try {
41242
- const result = await api.webrtcSession.handleOffer.mutate({
41243
- deviceId,
41244
- sdpOffer,
41245
- sessionId,
41246
- target: derivedTarget,
41247
- relayOnly: false,
41248
- nonTrickle: true,
41249
- disableTurn: true,
41250
- disableIpv6: true,
41251
- consumerAttribution: {
41252
- kind: "alexa",
41253
- sessionId
41254
- }
41255
- });
41256
- log.info("alexa rtc-session: AnswerGenerated", { meta: {
41257
- returnedSessionId: result.sessionId,
41258
- sdpAnswerLen: result.sdpAnswer.length
41259
- } });
41260
- return { event: {
41261
- header: {
41262
- namespace: "Alexa.RTCSessionController",
41263
- name: "AnswerGeneratedForSession",
41264
- messageId: header.messageId,
41265
- correlationToken: header.correlationToken,
41266
- payloadVersion: "3"
41267
- },
41268
- endpoint: { endpointId: endpoint.endpointId },
41269
- payload: { answer: {
41270
- format: payload.offer.format ?? "SDP",
41271
- value: result.sdpAnswer
41272
- } }
41273
- } };
41274
- } catch (err) {
41275
- const message = err instanceof Error ? err.message : String(err);
41276
- const mSectionSummary = (() => {
41277
- const lines = sdpOffer.split(/\r?\n/);
41278
- const sections = [];
41279
- let current = null;
41280
- for (const raw of lines) {
41281
- const line = raw.trim();
41282
- if (line.startsWith("m=")) {
41283
- if (current) sections.push(current);
41284
- current = {
41285
- mid: null,
41286
- kind: line.split(" ")[0]?.slice(2) ?? "unknown",
41287
- direction: null,
41288
- codecs: []
41289
- };
41290
- } else if (current) {
41291
- if (line.startsWith("a=mid:")) current.mid = line.slice(6);
41292
- else if (/^a=(sendonly|recvonly|sendrecv|inactive)$/.test(line)) current.direction = line.slice(2);
41293
- else if (line.startsWith("a=rtpmap:")) current.codecs.push(line.slice(9));
41294
- }
41295
- }
41296
- if (current) sections.push(current);
41297
- return sections;
41298
- })();
41299
- log.warn("alexa rtc-session: handleOffer failed", { meta: {
41300
- error: message,
41301
- offerLen: sdpOffer.length,
41302
- mSections: mSectionSummary,
41303
- offerSdp: sdpOffer
41304
- } });
41305
- if (egressPipelineKey !== null) {
41306
- EGRESS_HANDLES.delete(egressHandleKey(deviceId, sessionId));
41307
- await releaseAlexaEgress({
41308
- api,
41309
- deviceId,
41310
- pipelineKey: egressPipelineKey,
41311
- logger: log
41312
- });
41313
- }
41314
- return errorEnvelope(header, "INTERNAL_ERROR", `webrtc-session.handleOffer failed: ${message}`);
41315
- }
41316
- }
41317
- async function handleSessionDisconnected(header, endpointRaw, payloadRaw, deps, _callerIdentity) {
41318
- const parsed = SessionDisconnectedDirectiveSchema.safeParse({
41319
- endpoint: endpointRaw,
41320
- payload: payloadRaw ?? {}
41321
- });
41322
- if (!parsed.success) return errorEnvelope(header, "INVALID_DIRECTIVE", `payload validation failed: ${parsed.error.message}`);
41323
- const endpoint = parsed.data.endpoint;
41324
- const parsedId = parseCameraEndpointId(endpoint.endpointId);
41325
- if (parsedId.kind === "camera") {
41326
- const handleKey = egressHandleKey(parsedId.deviceId, parsed.data.payload.sessionId);
41327
- const pipelineKey = EGRESS_HANDLES.get(handleKey);
41328
- if (pipelineKey !== void 0) {
41329
- EGRESS_HANDLES.delete(handleKey);
41330
- await releaseAlexaEgress({
41331
- api: deps.ctx.api,
41332
- deviceId: parsedId.deviceId,
41333
- pipelineKey,
41334
- logger: deps.logger
41335
- });
41336
- }
41337
- try {
41338
- await deps.ctx.api.webrtcSession.closeSession.mutate({
41339
- deviceId: parsedId.deviceId,
41340
- sessionId: parsed.data.payload.sessionId
41341
- });
41342
- } catch (err) {
41343
- deps.logger.warn("alexa rtc-session: closeSession threw on disconnect (continuing to ack)", {
41344
- tags: {
41345
- deviceId: parsedId.deviceId,
41346
- sessionId: parsed.data.payload.sessionId
41347
- },
41348
- meta: { error: err instanceof Error ? err.message : String(err) }
41349
- });
41350
- }
41351
- }
41352
- const disconnectedDeviceId = parsedId.kind === "camera" ? parsedId.deviceId : void 0;
41353
- deps.logger.info("alexa rtc-session: SessionDisconnected", {
41354
- tags: {
41355
- sessionId: parsed.data.payload.sessionId,
41356
- ...disconnectedDeviceId !== void 0 ? { deviceId: disconnectedDeviceId } : {}
41357
- },
41358
- meta: { endpointId: endpoint.endpointId }
41359
- });
41360
- return { event: {
41361
- header: {
41362
- namespace: "Alexa",
41363
- name: "Response",
41364
- messageId: header.messageId,
41365
- correlationToken: header.correlationToken,
41366
- payloadVersion: "3"
41367
- },
41368
- endpoint: { endpointId: endpoint.endpointId },
41369
- payload: {}
41370
- } };
41371
- }
41372
- //#endregion
41373
41971
  //#region src/handlers/security-panel.ts
41374
41972
  var ArmPayloadSchema = object({ armState: _enum([
41375
41973
  "ARMED_STAY",
@@ -43417,6 +44015,11 @@ var ExportAlexaAddon = class extends BaseAddon {
43417
44015
  });
43418
44016
  });
43419
44017
  this.ctx.addDisposer(async () => offTunnelStarted());
44018
+ const reaper = setInterval(() => {
44019
+ this.reapDeadEgressHandles();
44020
+ }, EGRESS_REAP_INTERVAL_MS);
44021
+ reaper.unref?.();
44022
+ this.ctx.addDisposer(async () => clearInterval(reaper));
43420
44023
  this.ctx.logger.info("export-alexa: initialized", { meta: {
43421
44024
  exposedCount: this.config.exposed.length,
43422
44025
  skillMode: this.config.skillMode,
@@ -44071,6 +44674,37 @@ var ExportAlexaAddon = class extends BaseAddon {
44071
44674
  ]
44072
44675
  }] });
44073
44676
  }
44677
+ /**
44678
+ * One sweep: ask the broker which held sessions are still alive, and release
44679
+ * the transcodes the dead ones are still holding.
44680
+ */
44681
+ async reapDeadEgressHandles() {
44682
+ const held = heldEgressHandles();
44683
+ if (held.length === 0) return;
44684
+ const dead = await reapDeadEgressHandles({
44685
+ held,
44686
+ isAlive: async (handle) => {
44687
+ try {
44688
+ return (await this.ctx.api.webrtcSession.getSessionState.query({
44689
+ deviceId: handle.deviceId,
44690
+ sessionId: handle.sessionId
44691
+ })).alive;
44692
+ } catch {
44693
+ return null;
44694
+ }
44695
+ }
44696
+ });
44697
+ for (const handle of dead) {
44698
+ logReaped(this.ctx.logger, handle);
44699
+ forgetEgressHandle(handle);
44700
+ await releaseAlexaEgress({
44701
+ api: this.ctx.api,
44702
+ deviceId: handle.deviceId,
44703
+ pipelineKey: handle.pipelineKey,
44704
+ logger: this.ctx.logger
44705
+ }).catch(() => {});
44706
+ }
44707
+ }
44074
44708
  async buildDeviceSettingsContribution(deviceId) {
44075
44709
  if (!await this.isCameraDevice(deviceId)) return null;
44076
44710
  const entry = this.findEntry(deviceId);
@@ -44078,6 +44712,7 @@ var ExportAlexaAddon = class extends BaseAddon {
44078
44712
  const enabled = entry !== null;
44079
44713
  const enabledKey = `alexa:${deviceId}:enabled`;
44080
44714
  const streamPreferenceKey = `alexa:${deviceId}:streamPreference`;
44715
+ const allowTranscodeKey = `alexa:${deviceId}:allowTranscode`;
44081
44716
  const linkedCount = this.config.linkedAccounts.length;
44082
44717
  return {
44083
44718
  tabs: [{
@@ -44123,6 +44758,19 @@ var ExportAlexaAddon = class extends BaseAddon {
44123
44758
  equals: true
44124
44759
  },
44125
44760
  immediate: true
44761
+ },
44762
+ {
44763
+ type: "boolean",
44764
+ key: allowTranscodeKey,
44765
+ label: "Allow transcoding for Alexa",
44766
+ description: "Let the hub re-encode this camera for the Echo when no stream is directly usable. Off means only a stream Alexa can take as-is (H.264, 480p-1080p) is offered — cheaper, but a camera that publishes only H.265, or only a small sub-stream, will not be streamable to Alexa at all.",
44767
+ style: "switch",
44768
+ value: settings.allowTranscode !== false,
44769
+ showWhen: {
44770
+ field: enabledKey,
44771
+ equals: true
44772
+ },
44773
+ immediate: true
44126
44774
  }
44127
44775
  ]
44128
44776
  }]
@@ -44142,26 +44790,17 @@ var ExportAlexaAddon = class extends BaseAddon {
44142
44790
  const idStr = String(deviceId);
44143
44791
  const current = this.findEntry(deviceId);
44144
44792
  const wasEnabled = current !== null;
44145
- const sourceProfileKey = `alexa:${deviceId}:sourceProfile`;
44146
- if (sourceProfileKey in patch) {
44147
- if (!wasEnabled) return { success: true };
44148
- const currentSettings = current?.settings ?? DEFAULT_DEVICE_SETTINGS;
44149
- const sourceProfileRaw = patch[sourceProfileKey];
44150
- const sourceProfile = sourceProfileRaw === "mid" ? "mid" : sourceProfileRaw === "low" ? "low" : "high";
44151
- await this.patchEntrySettings(deviceId, {
44152
- ...currentSettings,
44153
- sourceProfile
44154
- });
44155
- return { success: true };
44156
- }
44157
44793
  const enabledKey = `alexa:${deviceId}:enabled`;
44158
44794
  const streamPreferenceKey = `alexa:${deviceId}:streamPreference`;
44795
+ const allowTranscodeKey = `alexa:${deviceId}:allowTranscode`;
44159
44796
  const enabledValue = enabledKey in patch ? Boolean(patch[enabledKey]) : wasEnabled;
44160
44797
  const streamPreferenceRaw = streamPreferenceKey in patch ? patch[streamPreferenceKey] : current?.settings?.streamPreference;
44161
44798
  const streamPreference = typeof streamPreferenceRaw === "string" && streamPreferenceRaw.trim().length > 0 ? streamPreferenceRaw : "auto";
44799
+ const allowTranscode = allowTranscodeKey in patch ? Boolean(patch[allowTranscodeKey]) : current?.settings?.allowTranscode !== false;
44162
44800
  const nextSettings = {
44163
44801
  ...current?.settings ?? DEFAULT_DEVICE_SETTINGS,
44164
- streamPreference
44802
+ streamPreference,
44803
+ allowTranscode
44165
44804
  };
44166
44805
  if (!enabledValue) {
44167
44806
  if (wasEnabled) await this.unexposeDevice(idStr);
@@ -44173,13 +44812,22 @@ var ExportAlexaAddon = class extends BaseAddon {
44173
44812
  return { success: true };
44174
44813
  }
44175
44814
  const currentPref = current?.settings?.streamPreference ?? "auto";
44815
+ const currentAllow = current?.settings?.allowTranscode !== false;
44176
44816
  await this.patchEntrySettings(deviceId, nextSettings);
44177
- if (currentPref !== streamPreference) {
44178
- this.ctx.logger.info("export-alexa: streamPreference changed — refreshing exposure", { meta: {
44179
- deviceId,
44180
- from: currentPref,
44181
- to: streamPreference
44182
- } });
44817
+ if (currentPref !== streamPreference || currentAllow !== allowTranscode) {
44818
+ this.ctx.logger.info("export-alexa: source settings changed — refreshing exposure", {
44819
+ tags: { deviceId },
44820
+ meta: {
44821
+ streamPreference: {
44822
+ from: currentPref,
44823
+ to: streamPreference
44824
+ },
44825
+ allowTranscode: {
44826
+ from: currentAllow,
44827
+ to: allowTranscode
44828
+ }
44829
+ }
44830
+ });
44183
44831
  try {
44184
44832
  await this.unexposeDevice(idStr);
44185
44833
  await this.exposeDevice(idStr);