@camstack/addon-export-alexa 1.2.107 → 1.2.109

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-DEuj7E3j.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).
@@ -7488,7 +7502,23 @@ var EncodeProfileSchema = object({
7488
7502
  "zerolatency",
7489
7503
  "film",
7490
7504
  "animation"
7491
- ]).optional()
7505
+ ]).optional(),
7506
+ /**
7507
+ * ONE slice per access unit.
7508
+ *
7509
+ * `-tune zerolatency` turns on x264's sliced threads, and a frame then leaves
7510
+ * the encoder as five NAL slices that share one RTP timestamp and carry one
7511
+ * marker bit. A libwebrtc depacketiser sees five frame-starts and one
7512
+ * frame-end per frame: the first pictures render and the video then freezes
7513
+ * for good while the audio, on its own plane, plays on. Measured on this hub
7514
+ * 2026-09-13 against the Echo, and reduced to one slice by this flag alone.
7515
+ *
7516
+ * A NAMED field and not a raw flag, because {@link EgressEncodeSchema} omits
7517
+ * `outputArgs` on purpose: an opaque array is part of the sharing key, so two
7518
+ * consumers meaning the same thing spelled differently would stop sharing one
7519
+ * child. Absent means "whatever the encoder does" — today's behaviour.
7520
+ */
7521
+ singleSlicePerFrame: boolean().optional()
7492
7522
  }),
7493
7523
  audio: union([literal("passthrough"), object({
7494
7524
  codec: _enum([
@@ -7517,6 +7547,28 @@ var EncodeProfileSchema = object({
7517
7547
  outputArgs: array(string()).optional()
7518
7548
  });
7519
7549
  /**
7550
+ * THE target rule: which of two sizes serves a consumer better.
7551
+ *
7552
+ * "Prefer at or above the target so the consumer downscales instead of
7553
+ * upscaling; among those the SMALLEST, because the extra pixels are thrown
7554
+ * away and the decode is not; among the rest the LARGEST, because it is the
7555
+ * closest we can get." One sentence, and until now it was written twice — once
7556
+ * here in lines, once in `stream-selection.ts` in pixels, for the same two
7557
+ * exporters. The two agreed, which is luck, not a property.
7558
+ *
7559
+ * The `size` is whatever measure the caller ranks by: the cap ranks by LINES
7560
+ * (a consumer's requirement is stated in p), the RTSP picker by PIXELS (it
7561
+ * compares entries of possibly different aspect). The rule is the same either
7562
+ * way and the measure is the caller's business.
7563
+ *
7564
+ * Returns a sort delta: negative when `a` should come first.
7565
+ */
7566
+ function compareToTarget(a, b, target) {
7567
+ const aReaches = a >= target;
7568
+ if (aReaches !== b >= target) return aReaches ? -1 : 1;
7569
+ return aReaches ? a - b : b - a;
7570
+ }
7571
+ /**
7520
7572
  * The shape every live egress starts from: H.264 Baseline 3.1 at 720p25.
7521
7573
  * Baseline because it is the one profile every consumer in this repo decodes
7522
7574
  * (Echo, iOS, an old browser); 3.1 because that is what the SDPs advertise.
@@ -7564,31 +7616,10 @@ var BASE_LIVE_EGRESS_PROFILE = {
7564
7616
  var ALEXA_EGRESS_PROFILE = {
7565
7617
  ...BASE_LIVE_EGRESS_PROFILE,
7566
7618
  audio: { codec: "opus" },
7567
- /**
7568
- * ONE slice per access unit.
7569
- *
7570
- * `-tune zerolatency` turns on x264's sliced threads, and every access unit
7571
- * came out of this encode as FIVE slices — measured on the hub 2026-09-13 on
7572
- * the exact argv this path spawns, and reduced to one by this single flag.
7573
- *
7574
- * Five slices share one RTP timestamp and carry one marker bit, so a
7575
- * libwebrtc depacketiser sees five frame-starts and one frame-end per frame.
7576
- * The Echo renders the first pictures and then freezes for good, while the
7577
- * audio — a separate plane — plays on. It is the only structural property
7578
- * that survived the elimination: the same freeze reproduced at 2304×1296
7579
- * HEVC with 217 KB keyframes AND at 640×360 H.264 with 67 KB keyframes and a
7580
- * fifth of the bitrate, so source, resolution, bitrate, keyframe burst,
7581
- * hardware decode, parameter sets, drift and bitstream conformance are all
7582
- * out — each ruled out by a measurement, not by elimination.
7583
- *
7584
- * The tune STAYS: it also drops the lookahead and the B-frames, which a live
7585
- * path wants. Only the slicing is unwanted, and this is the flag that says so.
7586
- *
7587
- * Scoped to Alexa deliberately. HomeKit and the browser transcode inherit
7588
- * {@link BASE_LIVE_EGRESS_PROFILE}; whether they want the same shape is a
7589
- * question for their own evidence, and the browser has not been measured.
7590
- */
7591
- outputArgs: ["-x264-params", "sliced-threads=0"]
7619
+ video: {
7620
+ ...BASE_LIVE_EGRESS_PROFILE.video,
7621
+ singleSlicePerFrame: true
7622
+ }
7592
7623
  };
7593
7624
  /**
7594
7625
  * Deep wiring healthcheck — snapshot of active reachability probes across
@@ -9452,6 +9483,47 @@ var LogChannelWindowSchema = object({
9452
9483
  /** `null` = every camera. A non-empty list narrows to those numeric ids. */
9453
9484
  deviceIds: array(number().int()).readonly().nullable()
9454
9485
  });
9486
+ /**
9487
+ * Shared stream-selection picker for export addons.
9488
+ *
9489
+ * Both `addon-export-alexa` and `addon-export-hap` resolve the
9490
+ * operator's `streamPreference` setting against the broker's RTSP
9491
+ * entries before dialling a camera. Each addon used to roll its own:
9492
+ * Alexa had `pickPreferredRtspEntry` under `handlers/`, HAP had an
9493
+ * inline `"prefer /mid, then /high, then first"` chain inside its
9494
+ * camera-streams mapper. The chains drifted (Alexa honoured
9495
+ * `streamPreference`, HAP didn't; only Alexa got the smart
9496
+ * resolution-targeted auto-pick) and the cross-addon import rule
9497
+ * blocks deduping in either direction.
9498
+ *
9499
+ * Lifting the picker into `@camstack/types` lets every exporter call
9500
+ * the same code path with the consumer's target resolution baked into
9501
+ * the call site — Alexa passes 1280×720, HomeKit passes 1920×1080,
9502
+ * the picker handles the rest.
9503
+ *
9504
+ * Selection algorithm:
9505
+ * - pref === '<profile>' → profile-keyed entries (from
9506
+ * `getProfileRtspEntries`) match on `e.profile`;
9507
+ * legacy raw entries match on the brokerId
9508
+ * suffix (`brokerId` ENDS WITH `/${pref}`, shape
9509
+ * `${deviceId}/${camStreamId}`). Addons store the
9510
+ * bare profile/camStream id in their
9511
+ * `ExportDeviceSettings.streamPreference`. Falls
9512
+ * back to 'auto' when no enabled entry matches
9513
+ * either way.
9514
+ * - pref === 'auto' → when `targetResolution` is given, pick the
9515
+ * entry whose source resolution is closest to
9516
+ * it (prefer ≥ target so the consumer
9517
+ * downscales instead of upscaling). Without a
9518
+ * target, fall back to first-enabled. If no
9519
+ * entry advertises a resolution at all
9520
+ * (broker hasn't probed yet), also fall back
9521
+ * to first-enabled so cold start still serves.
9522
+ *
9523
+ * Returns `null` when nothing is publishable — callers fold that into
9524
+ * the consumer's "no stream" error (Alexa: STREAM_TYPE_UNSUPPORTED;
9525
+ * HAP: a thrown error inside `startFfmpegForSession`).
9526
+ */
9455
9527
  function pickPreferredRtspEntry(entries, pref, deviceId, options = {}) {
9456
9528
  if (entries.length === 0) return null;
9457
9529
  const prefix = `${deviceId}/`;
@@ -9481,30 +9553,23 @@ function pickPreferredRtspEntry(entries, pref, deviceId, options = {}) {
9481
9553
  return toPicked(eligible[0]);
9482
9554
  }
9483
9555
  /**
9484
- * Pick the entry whose `resolution` is closest to `target`. Prefer
9485
- * entries ≥ target (downscale is cheap at the consumer; upscale is
9486
- * lossy). Among the rest pick the LARGEST still ≤ target so the
9487
- * consumer gets the best feasible quality. "Closeness" is the
9488
- * `width × height` pixel delta.
9556
+ * Pick the entry whose `resolution` is closest to `target`, by the ONE target
9557
+ * rule ({@link compareToTarget}): prefer at or above the target so the consumer
9558
+ * downscales instead of upscaling; among those the smallest; among the rest the
9559
+ * largest.
9560
+ *
9561
+ * This function used to implement that rule itself, in pixels, while
9562
+ * `rankStreams` implemented it again in lines — the same sentence written twice
9563
+ * for the same two exporters, agreeing by luck rather than by construction.
9564
+ * Only the MEASURE is this picker's own: it compares whole pixel counts because
9565
+ * its entries can differ in aspect, where the cap's requirement is stated in p.
9489
9566
  */
9490
9567
  function pickClosestResolution(entries, target) {
9491
9568
  const targetPixels = target.width * target.height;
9492
- let bestAbove;
9493
- let bestBelow;
9494
- for (const e of entries) {
9495
- if (e.resolution === void 0) continue;
9496
- const pixels = e.resolution.width * e.resolution.height;
9497
- if (pixels >= targetPixels) {
9498
- if (!bestAbove || pixels < bestAbove.pixels) bestAbove = {
9499
- entry: e,
9500
- pixels
9501
- };
9502
- } else if (!bestBelow || pixels > bestBelow.pixels) bestBelow = {
9503
- entry: e,
9504
- pixels
9505
- };
9506
- }
9507
- return bestAbove?.entry ?? bestBelow?.entry;
9569
+ return [...entries.flatMap((entry) => entry.resolution === void 0 ? [] : [{
9570
+ entry,
9571
+ pixels: entry.resolution.width * entry.resolution.height
9572
+ }])].sort((a, b) => compareToTarget(a.pixels, b.pixels, targetPixels))[0]?.entry;
9508
9573
  }
9509
9574
  var MODEL_FORMATS = [
9510
9575
  "onnx",
@@ -11718,6 +11783,58 @@ method(ListInputSchema, array(BrokerInfoSchema$1)), method(GetInputSchema, Broke
11718
11783
  auth: "admin"
11719
11784
  }), method(GetStateInputSchema, unknown().nullable()), method(_void(), RegistryStatusSchema);
11720
11785
  DeviceType.Camera;
11786
+ /**
11787
+ * The signals a device can emit to WAKE its own stream.
11788
+ *
11789
+ * A camera whose stream is built on demand sleeps until something asks for it,
11790
+ * and "something" cannot be a consumer that is merely attached — a Frigate-style
11791
+ * puller holds a session open for ever, and treating that as demand would keep
11792
+ * a battery camera awake for ever, which is the whole thing the battery is for
11793
+ * (D173). So the wake has to come from the CAMERA: an event it noticed by
11794
+ * itself, with no stream running.
11795
+ *
11796
+ * ## The vocabulary is the PROVIDER'S, not ours
11797
+ *
11798
+ * Like `consumables`, this cap declares no vocabulary of its own. A provider
11799
+ * names each signal with a `code` it chooses and a `label` an operator reads.
11800
+ * Reolink offers motion and camera-native detection; another provider may offer
11801
+ * a tamper, a doorbell press, a PIR, or something no camera in this fleet has
11802
+ * yet. A fixed enum here would mean every new signal is a framework release.
11803
+ *
11804
+ * It is deliberately NOT derived from the caps a device already binds. Whether
11805
+ * a camera CAN push firmware motion is expressed by `motionSources` containing
11806
+ * `'onboard'`, and whether it does AI on-camera by the `native-object-detection`
11807
+ * binding — but both answer "what drives the detection pipeline", which is a
11808
+ * different question from "what may wake a sleeping stream". A camera can do
11809
+ * the first and not be trusted with the second, and the operator picks per
11810
+ * camera. Two questions, two authorities.
11811
+ *
11812
+ * ## Availability is not permission
11813
+ *
11814
+ * `listSignals` says what the device CAN emit. Whether a given signal actually
11815
+ * wakes the stream is the operator's per-camera choice, held by the broker
11816
+ * alongside the cooldown — see the stream-broker cap's wake settings. A
11817
+ * provider declaring a signal is not a provider enabling it.
11818
+ */
11819
+ /** One signal a device can emit. */
11820
+ var StreamSignalSchema = object({
11821
+ /** Stable id chosen by the provider, e.g. `'motion'`, `'person'`, `'tamper'`. */
11822
+ code: string().min(1),
11823
+ /** What an operator reads in the picker. The provider's own wording. */
11824
+ label: string().min(1),
11825
+ /**
11826
+ * Whether the provider recommends this signal ON when a camera is first set
11827
+ * up. A provider knows which of its signals are cheap and reliable; an
11828
+ * operator should not have to discover that by trial. Reolink recommends
11829
+ * both of its own.
11830
+ */
11831
+ recommended: boolean()
11832
+ });
11833
+ object({
11834
+ signals: array(StreamSignalSchema),
11835
+ lastFetchedAt: number()
11836
+ });
11837
+ Object.values(DeviceType), method(_void(), array(StreamSignalSchema).readonly());
11721
11838
  /** Stream delivery format. (Relocated from the retired `streaming-engine` cap.) */
11722
11839
  var StreamFormatSchema = _enum([
11723
11840
  "webrtc",
@@ -12114,6 +12231,22 @@ var EgressTranscodeSchema = object({
12114
12231
  camStreamId: string().nullable()
12115
12232
  });
12116
12233
  method(object({
12234
+ deviceId: number().int().nonnegative(),
12235
+ /** The provider's signal code. */
12236
+ code: string().min(1),
12237
+ /** Ms epoch. Absent ⇒ now. */
12238
+ at: number().optional()
12239
+ }), object({
12240
+ /** Whether the broker acted on it, and if not, why. */
12241
+ accepted: boolean(),
12242
+ reason: _enum([
12243
+ "woke",
12244
+ "hold-extended",
12245
+ "not-enabled",
12246
+ "no-consumer",
12247
+ "unknown-code"
12248
+ ])
12249
+ }), { kind: "mutation" }), method(object({
12117
12250
  deviceId: number().int().nonnegative(),
12118
12251
  camStreamId: string().min(1),
12119
12252
  kind: CamStreamKindSchema,
@@ -12368,6 +12501,16 @@ var PickStreamRequirementsSchema = object({
12368
12501
  acceptCodecs: array(StreamCodecSchema).readonly().optional(),
12369
12502
  /** Minimum vertical resolution. Streams shorter than this are dropped. */
12370
12503
  minHeight: number().int().positive().optional(),
12504
+ /**
12505
+ * Maximum vertical resolution. Streams TALLER than this are dropped.
12506
+ *
12507
+ * A consumer can have a ceiling as real as its floor: Alexa documents
12508
+ * 480p to 1080p, and a 4K stream is as unusable to an Echo as a 360p one.
12509
+ * Without this the ceiling had to be re-implemented by every caller — and
12510
+ * one caller implementing it privately is how a second scoring authority
12511
+ * gets born.
12512
+ */
12513
+ maxHeight: number().int().positive().optional(),
12371
12514
  /** Minimum horizontal resolution. */
12372
12515
  minWidth: number().int().positive().optional(),
12373
12516
  /**
@@ -12384,7 +12527,22 @@ var PickStreamRequirementsSchema = object({
12384
12527
  * transcoded" guard: if the device is already serving the consumer's
12385
12528
  * codec end-to-end, there's nothing to optimise.
12386
12529
  */
12387
- requireSiblingCodec: array(StreamCodecSchema).readonly().optional()
12530
+ requireSiblingCodec: array(StreamCodecSchema).readonly().optional(),
12531
+ /**
12532
+ * Whether a stream the consumer CANNOT decode may still be picked, on the
12533
+ * understanding that it will be transcoded.
12534
+ *
12535
+ * Default `false` — today's behaviour, and the right one for a bypass
12536
+ * question ("is there a stream I can forward untouched?"). Set `true` to
12537
+ * ask the larger question: "what is the best stream for me, transcoding if
12538
+ * I must?" A stream that satisfies `acceptCodecs` always outranks one that
12539
+ * does not, so a passthrough is never lost to a transcode; the answer says
12540
+ * which it is in {@link PickedCamStreamSchema.transcodes}.
12541
+ *
12542
+ * This is what lets one picker serve both the bypass and the full source
12543
+ * choice, instead of a consumer scoring privately when the bypass misses.
12544
+ */
12545
+ allowTranscode: boolean().optional()
12388
12546
  }).readonly();
12389
12547
  var PickStreamPreferencesSchema = object({
12390
12548
  /**
@@ -12398,12 +12556,32 @@ var PickStreamPreferencesSchema = object({
12398
12556
  * picks the tallest stream; `'lowest'` picks the shortest (used by
12399
12557
  * memory-constrained consumers / Apple Home guest sessions).
12400
12558
  */
12401
- resolutionPreference: _enum(["highest", "lowest"]).optional()
12559
+ resolutionPreference: _enum(["highest", "lowest"]).optional(),
12560
+ /**
12561
+ * The height the consumer actually wants to DELIVER.
12562
+ *
12563
+ * Not a constraint — an ordering. With it set, the SMALLEST stream at or
12564
+ * above the target wins, and only if nothing reaches it does the tallest
12565
+ * take over. Pulling 1296 lines to draw 720 on a 1280x800 Echo panel costs
12566
+ * a decode and buys nothing, and `resolutionPreference: 'highest'` cannot
12567
+ * express that: it says "as big as possible", which is a different wish.
12568
+ *
12569
+ * Ignored when absent, so every existing caller keeps its ordering.
12570
+ */
12571
+ targetHeight: number().int().positive().optional()
12402
12572
  }).readonly();
12403
12573
  var PickedCamStreamSchema = object({
12404
12574
  camStreamId: string(),
12405
12575
  codec: string().optional(),
12406
12576
  resolution: CamStreamResolutionSchema.optional(),
12577
+ /**
12578
+ * Whether serving this stream requires a decode + re-encode.
12579
+ *
12580
+ * `false` is a stream the consumer can take as it stands. Only ever `true`
12581
+ * when the caller asked for it with `allowTranscode`, so a caller that did
12582
+ * not ask cannot be handed a cost it never agreed to pay.
12583
+ */
12584
+ transcodes: boolean(),
12407
12585
  /** One-line explanation of why this stream won — for logs / debug UI. */
12408
12586
  reason: string()
12409
12587
  });
@@ -16476,7 +16654,7 @@ var AlarmArmModeSchema = _enum([
16476
16654
  "vacation",
16477
16655
  "custom_bypass"
16478
16656
  ]);
16479
- object({
16657
+ var AlarmPanelStatusSchema = object({
16480
16658
  /** Current lifecycle state. */
16481
16659
  state: AlarmStateSchema,
16482
16660
  /** Subset of arm modes the panel accepts. UI renders one button per
@@ -16639,6 +16817,8 @@ var NcSystemEventKindSchema = _enum([
16639
16817
  "device-enabled",
16640
16818
  "device-battery-low",
16641
16819
  "device-battery-normal",
16820
+ "device-consumable-low",
16821
+ "device-consumable-normal",
16642
16822
  "stream-online",
16643
16823
  "stream-offline",
16644
16824
  "node-online",
@@ -16657,6 +16837,7 @@ var NcSystemEventKindSchema = _enum([
16657
16837
  "addon-updated",
16658
16838
  "server-updated",
16659
16839
  "export-completed",
16840
+ "backup-completed",
16660
16841
  "camera-online",
16661
16842
  "camera-offline",
16662
16843
  "camera-disabled",
@@ -23914,10 +24095,28 @@ DeviceType.Camera, method(object({ deviceId: number().int().nonnegative() }), ar
23914
24095
  }), boolean()), method(object({
23915
24096
  deviceId: number().int().nonnegative(),
23916
24097
  sessionId: string()
23917
- }), object({ pendingRenegotiation: object({
23918
- target: WebrtcStreamTargetSchema,
23919
- epoch: number()
23920
- }).nullable() }));
24098
+ }), object({
24099
+ pendingRenegotiation: object({
24100
+ target: WebrtcStreamTargetSchema,
24101
+ epoch: number()
24102
+ }).nullable(),
24103
+ /**
24104
+ * Whether the session still EXISTS.
24105
+ *
24106
+ * A consumer that holds a resource for the life of a session needs to
24107
+ * be able to ask, because a session does not always end the way it
24108
+ * began. Measured on this hub 2026-09-13: an Echo that got stuck never
24109
+ * sent `SessionDisconnected`, so the Alexa exporter's
24110
+ * `releaseEgressTranscode` never ran, and a 2304x1296 HEVC to 720p
24111
+ * H.264 transcode kept running for NOBODY for more than twenty
24112
+ * minutes. The WebRTC session had logged `WebRTC session closed`
24113
+ * minutes earlier — the broker knew; the holder had no way to ask.
24114
+ *
24115
+ * `false` for a session id the provider has never heard of, which is
24116
+ * the same answer as "it ended": either way nothing is holding it up.
24117
+ */
24118
+ alive: boolean()
24119
+ }));
23921
24120
  object({
23922
24121
  /** All accessory children of the parent. */
23923
24122
  childDeviceIds: array(number()).readonly(),
@@ -36421,6 +36620,12 @@ Object.freeze({
36421
36620
  addonId: null,
36422
36621
  access: "create"
36423
36622
  },
36623
+ "streamBroker.reportStreamSignal": {
36624
+ capName: "stream-broker",
36625
+ capScope: "system",
36626
+ addonId: null,
36627
+ access: "create"
36628
+ },
36424
36629
  "streamBroker.restartProfile": {
36425
36630
  capName: "stream-broker",
36426
36631
  capScope: "system",
@@ -36505,6 +36710,12 @@ Object.freeze({
36505
36710
  addonId: null,
36506
36711
  access: "create"
36507
36712
  },
36713
+ "streamSignals.listSignals": {
36714
+ capName: "stream-signals",
36715
+ capScope: "device",
36716
+ addonId: null,
36717
+ access: "view"
36718
+ },
36508
36719
  "switch.setState": {
36509
36720
  capName: "switch",
36510
36721
  capScope: "device",
@@ -38877,6 +39088,11 @@ Object.freeze({
38877
39088
  form: "single",
38878
39089
  optional: false
38879
39090
  }],
39091
+ "streamBroker.reportStreamSignal": [{
39092
+ name: "deviceId",
39093
+ form: "single",
39094
+ optional: false
39095
+ }],
38880
39096
  "streamBroker.restartProfile": [{
38881
39097
  name: "deviceId",
38882
39098
  form: "single",
@@ -39707,7 +39923,7 @@ async function acquireAlexaEgress(input) {
39707
39923
  profile: sourceProfile
39708
39924
  },
39709
39925
  encode: ALEXA_EGRESS_PROFILE,
39710
- rateControl: "relaxed",
39926
+ rateControl: "tight",
39711
39927
  publishLocally: true,
39712
39928
  tag: `alexa/${sessionId}`
39713
39929
  });
@@ -39731,6 +39947,7 @@ async function acquireAlexaEgress(input) {
39731
39947
  meta: {
39732
39948
  pipelineKey: transcode.pipelineKey,
39733
39949
  camStreamId,
39950
+ sourceProfile: input.sourceProfile,
39734
39951
  encoder: transcode.encoder,
39735
39952
  decodeHwAccel: transcode.decodeHwAccel ?? "software"
39736
39953
  }
@@ -39764,6 +39981,148 @@ async function releaseAlexaEgress(input) {
39764
39981
  }
39765
39982
  }
39766
39983
  //#endregion
39984
+ //#region src/egress/egress-handle-reaper.ts
39985
+ /**
39986
+ * Which handles should be released now.
39987
+ *
39988
+ * A session the broker says is GONE is reclaimed. A session it could not be
39989
+ * asked about is KEPT — a read that failed is not evidence of death, and
39990
+ * releasing a live session's transcode would black out a working stream to
39991
+ * save a process (D393, read in the direction that destroys work).
39992
+ */
39993
+ async function reapDeadEgressHandles(input) {
39994
+ const dead = [];
39995
+ for (const handle of input.held) if (await input.isAlive(handle) === false) dead.push(handle);
39996
+ return dead;
39997
+ }
39998
+ /** How often the sweep runs. Well under the minute Amazon requires a session
39999
+ * to survive, so a genuinely dead one is not transcoding for long. */
40000
+ var EGRESS_REAP_INTERVAL_MS = 3e4;
40001
+ function logReaped(logger, handle) {
40002
+ logger?.warn("alexa egress: session is GONE — reclaiming the transcode it still held", {
40003
+ tags: { deviceId: handle.deviceId },
40004
+ meta: {
40005
+ sessionId: handle.sessionId,
40006
+ pipelineKey: handle.pipelineKey,
40007
+ reason: "no SessionDisconnected directive"
40008
+ }
40009
+ });
40010
+ }
40011
+ //#endregion
40012
+ //#region src/egress/alexa-source-pick.ts
40013
+ /** Amazon's documented ceiling. */
40014
+ var ALEXA_MAX_LINES = 1080;
40015
+ /**
40016
+ * Which profile slot serves `camStreamId`, or `null` when none does — a read
40017
+ * that failed is `null` too, and the caller keeps its previous behaviour
40018
+ * rather than guessing a slot from an error (D393).
40019
+ */
40020
+ async function profileForCamStream(input) {
40021
+ try {
40022
+ const slots = await input.api.streamBroker.listAllProfileSlots.query();
40023
+ for (const slot of slots) {
40024
+ if (slot.deviceId !== input.deviceId) continue;
40025
+ if (slot.sourceCamStreamId !== input.camStreamId) continue;
40026
+ if (slot.profile === "high" || slot.profile === "mid" || slot.profile === "low") return slot.profile;
40027
+ }
40028
+ return null;
40029
+ } catch (err) {
40030
+ input.logger?.warn("alexa rtc-session: could not read the profile slots", {
40031
+ tags: { deviceId: input.deviceId },
40032
+ meta: { error: errMsg(err) }
40033
+ });
40034
+ return null;
40035
+ }
40036
+ }
40037
+ //#endregion
40038
+ //#region src/handlers/endpoint-id.ts
40039
+ var SEPARATOR = "__";
40040
+ function parseCameraEndpointId(raw) {
40041
+ if (!raw.includes(SEPARATOR)) {
40042
+ const id = parseNonNegativeInt(raw);
40043
+ if (id === null) return {
40044
+ kind: "invalid",
40045
+ reason: `not a numeric deviceId: ${raw}`
40046
+ };
40047
+ return {
40048
+ kind: "camera",
40049
+ deviceId: id
40050
+ };
40051
+ }
40052
+ const [head, tail, ...rest] = raw.split(SEPARATOR);
40053
+ if (head === void 0 || tail === void 0 || rest.length > 0) return {
40054
+ kind: "invalid",
40055
+ reason: `unexpected segments in endpointId: ${raw}`
40056
+ };
40057
+ const parent = parseNonNegativeInt(head);
40058
+ if (parent === null) return {
40059
+ kind: "invalid",
40060
+ reason: `parent segment not numeric: ${raw}`
40061
+ };
40062
+ if (tail === "autotrack") return {
40063
+ kind: "autotrack",
40064
+ parentDeviceId: parent
40065
+ };
40066
+ if (tail === "privacy") return {
40067
+ kind: "privacy",
40068
+ parentDeviceId: parent
40069
+ };
40070
+ if (tail === "alarm") return {
40071
+ kind: "alarm-panel",
40072
+ deviceId: parent
40073
+ };
40074
+ const child = parseNonNegativeInt(tail);
40075
+ if (child === null) return {
40076
+ kind: "invalid",
40077
+ reason: `child segment not numeric and not 'autotrack'/'privacy'/'alarm': ${raw}`
40078
+ };
40079
+ return {
40080
+ kind: "child",
40081
+ parentDeviceId: parent,
40082
+ childDeviceId: child
40083
+ };
40084
+ }
40085
+ function parseNonNegativeInt(s) {
40086
+ if (!/^\d+$/.test(s)) return null;
40087
+ const n = Number.parseInt(s, 10);
40088
+ if (!Number.isFinite(n) || n < 0) return null;
40089
+ return n;
40090
+ }
40091
+ //#endregion
40092
+ //#region src/handlers/error-envelope.ts
40093
+ /**
40094
+ * Shared Alexa `Alexa/ErrorResponse` envelope builder.
40095
+ *
40096
+ * Lives in its own module so every handler (and the directive router)
40097
+ * can emit its own namespace-appropriate error without circular imports.
40098
+ */
40099
+ object({ event: object({
40100
+ header: object({
40101
+ namespace: literal("Alexa"),
40102
+ name: literal("ErrorResponse"),
40103
+ messageId: string(),
40104
+ payloadVersion: literal("3")
40105
+ }),
40106
+ payload: object({
40107
+ type: string(),
40108
+ message: string()
40109
+ })
40110
+ }) });
40111
+ function errorEnvelope(header, type, message) {
40112
+ return { event: {
40113
+ header: {
40114
+ namespace: "Alexa",
40115
+ name: "ErrorResponse",
40116
+ messageId: header.messageId,
40117
+ payloadVersion: "3"
40118
+ },
40119
+ payload: {
40120
+ type,
40121
+ message
40122
+ }
40123
+ } };
40124
+ }
40125
+ //#endregion
39767
40126
  //#region src/handlers/types.ts
39768
40127
  /**
39769
40128
  * Zod schemas for the subset of the Alexa Smart Home directive surface
@@ -39825,6 +40184,369 @@ var InitiateSessionWithOfferPayloadSchema = object({
39825
40184
  var SessionDisconnectedPayloadSchema = object({ sessionId: string() });
39826
40185
  var SetModePayloadSchema = object({ mode: string() });
39827
40186
  //#endregion
40187
+ //#region src/handlers/rtc-session.ts
40188
+ var InitiateSessionWithOfferDirectiveSchema = object({
40189
+ endpoint: EndpointRefSchema,
40190
+ payload: InitiateSessionWithOfferPayloadSchema
40191
+ });
40192
+ var SessionDisconnectedDirectiveSchema = object({
40193
+ endpoint: EndpointRefSchema,
40194
+ payload: SessionDisconnectedPayloadSchema
40195
+ });
40196
+ /**
40197
+ * Live egress handles, keyed by `<deviceId>/<sessionId>`.
40198
+ *
40199
+ * The primitive's refcount has no lease TTL: a holder that vanishes without
40200
+ * releasing leaks a child until the broker restarts (D67). This handler is a
40201
+ * holder, so it keeps the one piece of state that lets it not be that holder —
40202
+ * the `pipelineKey` its `SessionDisconnected` has to release.
40203
+ *
40204
+ * A module-level map is right here because the directive handlers are free
40205
+ * functions called per directive; the entry is written on a successful acquire
40206
+ * and deleted on the release path, both of which are the same tick as the
40207
+ * directive that owns it.
40208
+ */
40209
+ var EGRESS_HANDLES = /* @__PURE__ */ new Map();
40210
+ function egressHandleKey(deviceId, sessionId) {
40211
+ return `${deviceId}/${sessionId}`;
40212
+ }
40213
+ /** Every handle currently held, for the sweep. */
40214
+ function heldEgressHandles() {
40215
+ const held = [];
40216
+ for (const [key, pipelineKey] of EGRESS_HANDLES) {
40217
+ const slash = key.indexOf("/");
40218
+ if (slash <= 0) continue;
40219
+ const deviceId = Number.parseInt(key.slice(0, slash), 10);
40220
+ if (!Number.isFinite(deviceId)) continue;
40221
+ held.push({
40222
+ deviceId,
40223
+ sessionId: key.slice(slash + 1),
40224
+ pipelineKey
40225
+ });
40226
+ }
40227
+ return held;
40228
+ }
40229
+ /** Drop a handle the sweep reclaimed, so it is not reaped twice. */
40230
+ function forgetEgressHandle(handle) {
40231
+ EGRESS_HANDLES.delete(egressHandleKey(handle.deviceId, handle.sessionId));
40232
+ }
40233
+ var WEBRTC_PROFILE_NAMES = [
40234
+ "high",
40235
+ "mid",
40236
+ "low"
40237
+ ];
40238
+ function resolveWebrtcTarget(pref) {
40239
+ if (pref === "auto") return void 0;
40240
+ if (WEBRTC_PROFILE_NAMES.includes(pref)) return {
40241
+ kind: "profile",
40242
+ profile: pref
40243
+ };
40244
+ return {
40245
+ kind: "cam-stream",
40246
+ camStreamId: pref
40247
+ };
40248
+ }
40249
+ async function handleInitiateSessionWithOffer(header, endpointRaw, payloadRaw, deps, _callerIdentity) {
40250
+ const parsed = InitiateSessionWithOfferDirectiveSchema.safeParse({
40251
+ endpoint: endpointRaw,
40252
+ payload: payloadRaw ?? {}
40253
+ });
40254
+ if (!parsed.success) return errorEnvelope(header, "INVALID_DIRECTIVE", `payload validation failed: ${parsed.error.message}`);
40255
+ const endpoint = parsed.data.endpoint;
40256
+ const payload = parsed.data.payload;
40257
+ const parsedId = parseCameraEndpointId(endpoint.endpointId);
40258
+ if (parsedId.kind !== "camera") return errorEnvelope(header, "INVALID_DIRECTIVE", `endpointId ${endpoint.endpointId} is not a camera`);
40259
+ const deviceId = parsedId.deviceId;
40260
+ const sessionId = payload.sessionId;
40261
+ const log = deps.logger.withTags({
40262
+ deviceId,
40263
+ sessionId
40264
+ });
40265
+ const sdpOffer = payload.offer.value;
40266
+ if (sdpOffer.length === 0) return errorEnvelope(header, "INVALID_DIRECTIVE", "offer.value is empty");
40267
+ const api = deps.ctx.api;
40268
+ const exposedEntry = deps.listExposed().find((e) => e.deviceId === deviceId);
40269
+ const pinnedProfile = (() => {
40270
+ const pref = exposedEntry?.settings?.streamPreference;
40271
+ if (pref === "low" || pref === "mid" || pref === "high") return pref;
40272
+ })();
40273
+ const allowTranscode = exposedEntry?.settings?.allowTranscode !== false;
40274
+ const scored = pinnedProfile === void 0 ? await api.cameraStreams.pickStream.query({
40275
+ deviceId,
40276
+ requirements: {
40277
+ acceptCodecs: ["h264"],
40278
+ minHeight: 480,
40279
+ maxHeight: ALEXA_MAX_LINES,
40280
+ excludeDerived: true,
40281
+ allowTranscode
40282
+ },
40283
+ preferences: { targetHeight: 720 }
40284
+ }).catch((err) => {
40285
+ log.warn("alexa rtc-session: pickStream threw — falling back to the high slot", {
40286
+ tags: { deviceId },
40287
+ meta: { error: err instanceof Error ? err.message : String(err) }
40288
+ });
40289
+ return null;
40290
+ }) : null;
40291
+ const scoredProfile = scored === null ? null : await profileForCamStream({
40292
+ api,
40293
+ deviceId,
40294
+ camStreamId: scored.camStreamId,
40295
+ logger: log
40296
+ });
40297
+ const sourceProfile = pinnedProfile ?? scoredProfile ?? "high";
40298
+ if (pinnedProfile === void 0) log.info("alexa rtc-session: source scored against the Echo requirements", {
40299
+ tags: { deviceId },
40300
+ meta: {
40301
+ chosen: sourceProfile,
40302
+ scored: scored === null ? "none-eligible" : scored.reason,
40303
+ camStreamId: scored?.camStreamId ?? null,
40304
+ slot: scoredProfile ?? (scored === null ? null : "no-slot"),
40305
+ height: scored?.resolution?.height ?? null,
40306
+ transcodes: scored?.transcodes ?? null,
40307
+ allowTranscode
40308
+ }
40309
+ });
40310
+ let derivedTarget;
40311
+ /**
40312
+ * The primitive's release handle, when this session acquired one. Recorded so
40313
+ * `SessionDisconnected` can release it: a refcount whose holder disappears is
40314
+ * the one gap D67 leaves open, and the least this handler can do is not BE
40315
+ * that holder.
40316
+ */
40317
+ let egressPipelineKey = null;
40318
+ const picked = await api.cameraStreams.pickStream.query({
40319
+ deviceId,
40320
+ requirements: {
40321
+ acceptCodecs: ["h264"],
40322
+ minHeight: 480,
40323
+ excludeDerived: true,
40324
+ requireSiblingCodec: ["h265", "hevc"]
40325
+ }
40326
+ }).catch((err) => {
40327
+ log.warn("alexa rtc-session: cameraStreams.pickStream threw — falling back to transcode", { meta: { error: err instanceof Error ? err.message : String(err) } });
40328
+ return null;
40329
+ });
40330
+ if (picked) {
40331
+ log.info("alexa rtc-session: bypassing transcode via cameraStreams.pickStream", { meta: {
40332
+ sourceProfile,
40333
+ selectedCamStreamId: picked.camStreamId,
40334
+ reason: picked.reason
40335
+ } });
40336
+ derivedTarget = {
40337
+ kind: "cam-stream",
40338
+ camStreamId: picked.camStreamId
40339
+ };
40340
+ } else try {
40341
+ const acquired = await acquireAlexaEgress({
40342
+ api,
40343
+ deviceId,
40344
+ sourceProfile,
40345
+ sessionId,
40346
+ logger: log
40347
+ });
40348
+ egressPipelineKey = acquired.pipelineKey;
40349
+ EGRESS_HANDLES.set(egressHandleKey(deviceId, sessionId), acquired.pipelineKey);
40350
+ derivedTarget = {
40351
+ kind: "cam-stream",
40352
+ camStreamId: acquired.camStreamId
40353
+ };
40354
+ } catch (err) {
40355
+ log.warn("alexa rtc-session: egress acquire failed, falling back to streamPreference target", { meta: { error: err instanceof Error ? err.message : String(err) } });
40356
+ derivedTarget = resolveWebrtcTarget(exposedEntry?.settings?.streamPreference ?? "auto");
40357
+ }
40358
+ try {
40359
+ const result = await api.webrtcSession.handleOffer.mutate({
40360
+ deviceId,
40361
+ sdpOffer,
40362
+ sessionId,
40363
+ target: derivedTarget,
40364
+ relayOnly: false,
40365
+ nonTrickle: true,
40366
+ disableTurn: true,
40367
+ disableIpv6: true,
40368
+ consumerAttribution: {
40369
+ kind: "alexa",
40370
+ sessionId
40371
+ }
40372
+ });
40373
+ log.info("alexa rtc-session: AnswerGenerated", { meta: {
40374
+ returnedSessionId: result.sessionId,
40375
+ sdpAnswerLen: result.sdpAnswer.length
40376
+ } });
40377
+ return { event: {
40378
+ header: {
40379
+ namespace: "Alexa.RTCSessionController",
40380
+ name: "AnswerGeneratedForSession",
40381
+ messageId: header.messageId,
40382
+ correlationToken: header.correlationToken,
40383
+ payloadVersion: "3"
40384
+ },
40385
+ endpoint: { endpointId: endpoint.endpointId },
40386
+ payload: { answer: {
40387
+ format: payload.offer.format ?? "SDP",
40388
+ value: result.sdpAnswer
40389
+ } }
40390
+ } };
40391
+ } catch (err) {
40392
+ const message = err instanceof Error ? err.message : String(err);
40393
+ const mSectionSummary = (() => {
40394
+ const lines = sdpOffer.split(/\r?\n/);
40395
+ const sections = [];
40396
+ let current = null;
40397
+ for (const raw of lines) {
40398
+ const line = raw.trim();
40399
+ if (line.startsWith("m=")) {
40400
+ if (current) sections.push(current);
40401
+ current = {
40402
+ mid: null,
40403
+ kind: line.split(" ")[0]?.slice(2) ?? "unknown",
40404
+ direction: null,
40405
+ codecs: []
40406
+ };
40407
+ } else if (current) {
40408
+ if (line.startsWith("a=mid:")) current.mid = line.slice(6);
40409
+ else if (/^a=(sendonly|recvonly|sendrecv|inactive)$/.test(line)) current.direction = line.slice(2);
40410
+ else if (line.startsWith("a=rtpmap:")) current.codecs.push(line.slice(9));
40411
+ }
40412
+ }
40413
+ if (current) sections.push(current);
40414
+ return sections;
40415
+ })();
40416
+ log.warn("alexa rtc-session: handleOffer failed", { meta: {
40417
+ error: message,
40418
+ offerLen: sdpOffer.length,
40419
+ mSections: mSectionSummary,
40420
+ offerSdp: sdpOffer
40421
+ } });
40422
+ if (egressPipelineKey !== null) {
40423
+ EGRESS_HANDLES.delete(egressHandleKey(deviceId, sessionId));
40424
+ await releaseAlexaEgress({
40425
+ api,
40426
+ deviceId,
40427
+ pipelineKey: egressPipelineKey,
40428
+ logger: log
40429
+ });
40430
+ }
40431
+ return errorEnvelope(header, "INTERNAL_ERROR", `webrtc-session.handleOffer failed: ${message}`);
40432
+ }
40433
+ }
40434
+ async function handleSessionDisconnected(header, endpointRaw, payloadRaw, deps, _callerIdentity) {
40435
+ const parsed = SessionDisconnectedDirectiveSchema.safeParse({
40436
+ endpoint: endpointRaw,
40437
+ payload: payloadRaw ?? {}
40438
+ });
40439
+ if (!parsed.success) return errorEnvelope(header, "INVALID_DIRECTIVE", `payload validation failed: ${parsed.error.message}`);
40440
+ const endpoint = parsed.data.endpoint;
40441
+ const parsedId = parseCameraEndpointId(endpoint.endpointId);
40442
+ if (parsedId.kind === "camera") {
40443
+ const handleKey = egressHandleKey(parsedId.deviceId, parsed.data.payload.sessionId);
40444
+ const pipelineKey = EGRESS_HANDLES.get(handleKey);
40445
+ if (pipelineKey !== void 0) {
40446
+ EGRESS_HANDLES.delete(handleKey);
40447
+ await releaseAlexaEgress({
40448
+ api: deps.ctx.api,
40449
+ deviceId: parsedId.deviceId,
40450
+ pipelineKey,
40451
+ logger: deps.logger
40452
+ });
40453
+ }
40454
+ try {
40455
+ await deps.ctx.api.webrtcSession.closeSession.mutate({
40456
+ deviceId: parsedId.deviceId,
40457
+ sessionId: parsed.data.payload.sessionId
40458
+ });
40459
+ } catch (err) {
40460
+ deps.logger.warn("alexa rtc-session: closeSession threw on disconnect (continuing to ack)", {
40461
+ tags: {
40462
+ deviceId: parsedId.deviceId,
40463
+ sessionId: parsed.data.payload.sessionId
40464
+ },
40465
+ meta: { error: err instanceof Error ? err.message : String(err) }
40466
+ });
40467
+ }
40468
+ }
40469
+ const disconnectedDeviceId = parsedId.kind === "camera" ? parsedId.deviceId : void 0;
40470
+ deps.logger.info("alexa rtc-session: SessionDisconnected", {
40471
+ tags: {
40472
+ sessionId: parsed.data.payload.sessionId,
40473
+ ...disconnectedDeviceId !== void 0 ? { deviceId: disconnectedDeviceId } : {}
40474
+ },
40475
+ meta: { endpointId: endpoint.endpointId }
40476
+ });
40477
+ return { event: {
40478
+ header: {
40479
+ namespace: "Alexa",
40480
+ name: "Response",
40481
+ messageId: header.messageId,
40482
+ correlationToken: header.correlationToken,
40483
+ payloadVersion: "3"
40484
+ },
40485
+ endpoint: { endpointId: endpoint.endpointId },
40486
+ payload: {}
40487
+ } };
40488
+ }
40489
+ //#endregion
40490
+ //#region src/handlers/security-panel-arm-state.ts
40491
+ /**
40492
+ * The panel modes behind each Alexa arm state, PRIMARY first — the mirror of
40493
+ * `MODES_BY_TARGET` in the HomeKit exporter, and for the same reason: one
40494
+ * Alexa state stands for two panel modes, so a panel offering only the
40495
+ * secondary would otherwise be sent a mode it refuses.
40496
+ */
40497
+ var MODES_BY_ARM_STATE = {
40498
+ ARMED_STAY: ["home", "custom_bypass"],
40499
+ ARMED_AWAY: ["away", "vacation"],
40500
+ ARMED_NIGHT: ["night"]
40501
+ };
40502
+ var ARM_STATES = [
40503
+ "ARMED_STAY",
40504
+ "ARMED_AWAY",
40505
+ "ARMED_NIGHT"
40506
+ ];
40507
+ /** The panel's state as Alexa's `armState` property. */
40508
+ function alexaArmStateFor(state) {
40509
+ switch (state) {
40510
+ case "armed_home":
40511
+ case "armed_custom_bypass": return "ARMED_STAY";
40512
+ case "armed_night": return "ARMED_NIGHT";
40513
+ case "armed_away":
40514
+ case "armed_vacation":
40515
+ case "pending":
40516
+ case "triggered": return "ARMED_AWAY";
40517
+ default: return "DISARMED";
40518
+ }
40519
+ }
40520
+ /** `burglaryAlarm`, the only alarm property this cap can honestly fill. */
40521
+ function burglaryAlarmFor(state) {
40522
+ return { value: state === "triggered" ? "ALARM" : "OK" };
40523
+ }
40524
+ /**
40525
+ * The `supportedArmStates` for discovery.
40526
+ *
40527
+ * `DISARMED` is always present: disarming is not one of the panel's arm modes.
40528
+ * An EMPTY `availableModes` is unknown, not "none" — a provider that never
40529
+ * populated the list looks identical to one that accepts nothing, and
40530
+ * publishing a panel that can only be disarmed on that guess takes the
40531
+ * controls off a working alarm. The caller logs it.
40532
+ */
40533
+ function supportedArmStatesFor(availableModes) {
40534
+ return [...availableModes.length === 0 ? ARM_STATES : ARM_STATES.filter((armState) => MODES_BY_ARM_STATE[armState].some((mode) => availableModes.includes(mode))), "DISARMED"];
40535
+ }
40536
+ /**
40537
+ * The arm mode to send for an Alexa arm state, or `null` when there is none.
40538
+ *
40539
+ * `null` is not a shrug: `DISARMED` is the Disarm DIRECTIVE, a different call,
40540
+ * and an arm state the panel accepts no mode for is a directive the handler
40541
+ * refuses with `NOT_READY_FOR_ARM` rather than translating into a guess.
40542
+ */
40543
+ function alarmArmModeForAlexa(armState, availableModes) {
40544
+ if (armState === "DISARMED") return null;
40545
+ const candidates = MODES_BY_ARM_STATE[armState];
40546
+ if (availableModes.length === 0) return candidates[0] ?? null;
40547
+ return candidates.find((mode) => availableModes.includes(mode)) ?? null;
40548
+ }
40549
+ //#endregion
39828
40550
  //#region src/endpoints/builder.ts
39829
40551
  var DEFAULT_RESOLUTIONS = [{
39830
40552
  width: 1920,
@@ -40147,55 +40869,55 @@ function buildPrivacyEndpoint(input) {
40147
40869
  }
40148
40870
  };
40149
40871
  }
40150
- //#endregion
40151
- //#region src/handlers/error-envelope.ts
40152
40872
  /**
40153
- * Shared Alexa `Alexa/ErrorResponse` envelope builder.
40154
- *
40155
- * Lives in its own module so every handler (and the directive router)
40156
- * can emit its own namespace-appropriate error without circular imports.
40157
- */
40158
- object({ event: object({
40159
- header: object({
40160
- namespace: literal("Alexa"),
40161
- name: literal("ErrorResponse"),
40162
- messageId: string(),
40163
- payloadVersion: literal("3")
40164
- }),
40165
- payload: object({
40166
- type: string(),
40167
- message: string()
40168
- })
40169
- }) });
40170
- function errorEnvelope(header, type, message) {
40171
- return { event: {
40172
- header: {
40173
- namespace: "Alexa",
40174
- name: "ErrorResponse",
40175
- messageId: header.messageId,
40176
- payloadVersion: "3"
40873
+ * An `alarm-panel` device as an `Alexa.SecurityPanelController` endpoint.
40874
+ *
40875
+ * It is its OWN endpoint, not a child of a camera: a panel is a standalone
40876
+ * device and has no stream, no doorbell and no PTZ. The `__alarm` suffix on
40877
+ * the endpoint id is a routing tag (see `handlers/endpoint-id.ts`).
40878
+ *
40879
+ * ## What is declared, and what is deliberately not
40880
+ *
40881
+ * - `armState` and `burglaryAlarm` are the two properties this cap can fill
40882
+ * honestly. `fireAlarm` / `carbonMonoxideAlarm` / `waterAlarm` are NOT
40883
+ * declared: `alarm-panel` has no field saying WHICH alarm is sounding, and
40884
+ * declaring a property we would always answer `OK` on is a claim with no
40885
+ * basis behind it. The reasoning is in `security-panel-arm-state.ts`.
40886
+ * - `proactivelyReported: false`. This addon's Event Gateway pushes
40887
+ * ChangeReports for camera motion only; a panel state change is not wired
40888
+ * to it, and promising a report nothing sends is how an endpoint ends up
40889
+ * looking broken. `retrievable: true` — `Alexa/ReportState` answers.
40890
+ * - `supportedAuthorizationTypes` appears only for a panel that requires a
40891
+ * code. Alexa's Disarm directive can carry a PIN; its **Arm directive
40892
+ * cannot**, which is why a PIN-gated arm is refused by the handler with
40893
+ * `AUTHORIZATION_REQUIRED` rather than attempted.
40894
+ */
40895
+ function buildAlarmPanelEndpoint(input) {
40896
+ const configuration = { supportedArmStates: supportedArmStatesFor(input.availableModes).map((value) => ({ value })) };
40897
+ if (input.requiresCode) configuration.supportedAuthorizationTypes = [{ type: "FOUR_DIGIT_PIN" }];
40898
+ const securityPanelController = {
40899
+ type: "AlexaInterface",
40900
+ interface: "Alexa.SecurityPanelController",
40901
+ version: "3",
40902
+ properties: {
40903
+ supported: [{ name: "armState" }, { name: "burglaryAlarm" }],
40904
+ proactivelyReported: false,
40905
+ retrievable: true
40177
40906
  },
40178
- payload: {
40179
- type,
40180
- message
40181
- }
40182
- } };
40907
+ configuration
40908
+ };
40909
+ return {
40910
+ endpointId: `${input.deviceId}__alarm`,
40911
+ manufacturerName: "camstack",
40912
+ description: "CamStack alarm panel",
40913
+ friendlyName: input.friendlyName.trim().length > 0 ? input.friendlyName.trim() : `Alarm ${input.deviceId}`,
40914
+ displayCategories: ["SECURITY_PANEL"],
40915
+ capabilities: [ALEXA_BASE_CAPABILITY, securityPanelController],
40916
+ cookie: { stableId: input.stableId }
40917
+ };
40183
40918
  }
40184
40919
  //#endregion
40185
40920
  //#region src/handlers/discovery.ts
40186
- /**
40187
- * `Alexa.Discovery/Discover` handler — fires when the Alexa app pulls
40188
- * the user's device list. We respond with the operator-curated
40189
- * `exposed-devices.json` list, resolved against the live device
40190
- * registry (so we don't return zombie endpoints for deleted devices).
40191
- *
40192
- * Feature detection is PASSIVE: per exposed device we inspect the
40193
- * `DeviceInfo.features` + bound caps (from `device-manager.getBindings`)
40194
- * and emit only the Alexa capabilities backed by something on our side.
40195
- * Child accessories (siren/floodlight/PIR/…) become SEPARATE endpoints
40196
- * with `endpointId = ${parent}__${child}` so directive handlers can
40197
- * route to the right cap on the right device.
40198
- */
40199
40921
  var discoveryPayloadSchema = DiscoveryDirectiveSchema.shape.payload;
40200
40922
  /** Devices built at once in `handleDiscovery` — latency-bound tRPC reads, so
40201
40923
  * six lanes hide the round-trips without stampeding a booting hub. */
@@ -40209,6 +40931,7 @@ var CAP_SWITCH = "switch";
40209
40931
  var CAP_BRIGHTNESS = "brightness";
40210
40932
  var CAP_BATTERY = "battery";
40211
40933
  var CAP_PRIVACY_MASK = "privacy-mask";
40934
+ var CAP_ALARM_PANEL = "alarm-panel";
40212
40935
  async function handleDiscovery(header, payloadRaw, deps, callerIdentity) {
40213
40936
  if (!discoveryPayloadSchema.safeParse(payloadRaw).success) return errorEnvelope(header, "INVALID_DIRECTIVE", "Discovery payload shape unexpected");
40214
40937
  const exposed = await deps.listExposed();
@@ -40248,6 +40971,7 @@ async function buildEndpointsForExposedDevice(deviceId, deps) {
40248
40971
  return [];
40249
40972
  }
40250
40973
  const parentInfo = await loadBoundDeviceInfo(device, api);
40974
+ if (parentInfo.capNames.includes(CAP_ALARM_PANEL)) return [await buildAlarmPanelEndpointFor(parentInfo, api, deps)];
40251
40975
  const children = await api.deviceManager.getChildren.query({ parentDeviceId: deviceId }).catch((err) => {
40252
40976
  deps.logger.warn("alexa discovery: getChildren failed", {
40253
40977
  tags: { deviceId },
@@ -40327,6 +41051,47 @@ async function buildEndpointsForExposedDevice(deviceId, deps) {
40327
41051
  }
40328
41052
  return out;
40329
41053
  }
41054
+ /**
41055
+ * The panel endpoint, with what the panel says it accepts.
41056
+ *
41057
+ * A `getStatus` that fails leaves `availableModes` empty and `requiresCode`
41058
+ * false, and those are the UNKNOWN readings, not assertions: an empty mode
41059
+ * list makes discovery declare all three arm states (`supportedArmStatesFor`)
41060
+ * rather than publishing a panel that can only be disarmed, and the directive
41061
+ * handler re-reads the panel before it commands anything, so a PIN discovered
41062
+ * later is still honoured. The blind read is logged against the device id.
41063
+ */
41064
+ async function buildAlarmPanelEndpointFor(info, api, deps) {
41065
+ const status = await api.alarmPanel.getStatus.query({ deviceId: info.id }).catch((err) => {
41066
+ deps.logger.warn("alexa discovery: alarmPanel.getStatus failed — declaring every arm state", {
41067
+ tags: { deviceId: info.id },
41068
+ meta: { error: err instanceof Error ? err.message : String(err) }
41069
+ });
41070
+ return null;
41071
+ });
41072
+ const parsed = AlarmPanelStatusSchema.pick({
41073
+ availableModes: true,
41074
+ requiresCode: true
41075
+ }).safeParse(status);
41076
+ if (!parsed.success && status !== null) deps.logger.warn("alexa discovery: panel status did not match the cap schema", { tags: { deviceId: info.id } });
41077
+ const availableModes = parsed.success ? parsed.data.availableModes : [];
41078
+ const requiresCode = parsed.success ? parsed.data.requiresCode : false;
41079
+ deps.logger.info("alexa discovery: built alarm panel endpoint", {
41080
+ tags: { deviceId: info.id },
41081
+ meta: {
41082
+ name: info.name,
41083
+ availableModes: [...availableModes],
41084
+ requiresCode
41085
+ }
41086
+ });
41087
+ return buildAlarmPanelEndpoint({
41088
+ deviceId: info.id,
41089
+ stableId: info.stableId,
41090
+ friendlyName: info.name,
41091
+ availableModes,
41092
+ requiresCode
41093
+ });
41094
+ }
40330
41095
  async function loadBoundDeviceInfo(device, api) {
40331
41096
  const capNames = (await api.deviceManager.getBindings.query({ deviceId: device.id })).entries.map((e) => e.capName);
40332
41097
  return {
@@ -40360,55 +41125,67 @@ function discoveryResponse(header, endpoints) {
40360
41125
  } };
40361
41126
  }
40362
41127
  //#endregion
40363
- //#region src/handlers/endpoint-id.ts
40364
- var SEPARATOR = "__";
40365
- function parseCameraEndpointId(raw) {
40366
- if (!raw.includes(SEPARATOR)) {
40367
- const id = parseNonNegativeInt(raw);
40368
- if (id === null) return {
40369
- kind: "invalid",
40370
- reason: `not a numeric deviceId: ${raw}`
40371
- };
40372
- return {
40373
- kind: "camera",
40374
- deviceId: id
40375
- };
41128
+ //#region src/handlers/brightness-controller.ts
41129
+ var SetBrightnessDirectiveSchema = object({
41130
+ header: object({
41131
+ namespace: literal("Alexa.BrightnessController"),
41132
+ name: literal("SetBrightness")
41133
+ }),
41134
+ endpoint: EndpointRefSchema,
41135
+ payload: object({ brightness: number().min(0).max(100) })
41136
+ });
41137
+ async function handleSetBrightness(header, endpointRaw, payloadRaw, deps, _callerIdentity) {
41138
+ const parsed = SetBrightnessDirectiveSchema.safeParse({
41139
+ header: {
41140
+ namespace: header.namespace,
41141
+ name: header.name
41142
+ },
41143
+ endpoint: endpointRaw,
41144
+ payload: payloadRaw ?? {}
41145
+ });
41146
+ if (!parsed.success) return errorEnvelope(header, "INVALID_DIRECTIVE", `payload validation failed: ${parsed.error.message}`);
41147
+ const endpointId = parsed.data.endpoint.endpointId;
41148
+ const parsedId = parseCameraEndpointId(endpointId);
41149
+ if (parsedId.kind === "autotrack" || parsedId.kind === "privacy" || parsedId.kind === "alarm-panel") return errorEnvelope(header, "INVALID_DIRECTIVE", `BrightnessController is not exposed on the ${parsedId.kind} endpoint`);
41150
+ if (parsedId.kind === "camera") return errorEnvelope(header, "INVALID_DIRECTIVE", "BrightnessController is not exposed on camera endpoints — direct the directive at the dimmable child accessory");
41151
+ if (parsedId.kind === "invalid") return errorEnvelope(header, "INVALID_DIRECTIVE", parsedId.reason);
41152
+ const percentage = parsed.data.payload.brightness;
41153
+ const api = deps.ctx.api;
41154
+ try {
41155
+ await api.brightness.setBrightness.mutate({
41156
+ deviceId: parsedId.childDeviceId,
41157
+ percentage
41158
+ });
41159
+ } catch (err) {
41160
+ const message = err instanceof Error ? err.message : String(err);
41161
+ deps.logger.warn("alexa brightness-controller: setBrightness failed", { meta: {
41162
+ deviceId: parsedId.childDeviceId,
41163
+ percentage,
41164
+ error: message
41165
+ } });
41166
+ return errorEnvelope(header, "INTERNAL_ERROR", `brightness.setBrightness failed: ${message}`);
40376
41167
  }
40377
- const [head, tail, ...rest] = raw.split(SEPARATOR);
40378
- if (head === void 0 || tail === void 0 || rest.length > 0) return {
40379
- kind: "invalid",
40380
- reason: `unexpected segments in endpointId: ${raw}`
40381
- };
40382
- const parent = parseNonNegativeInt(head);
40383
- if (parent === null) return {
40384
- kind: "invalid",
40385
- reason: `parent segment not numeric: ${raw}`
40386
- };
40387
- if (tail === "autotrack") return {
40388
- kind: "autotrack",
40389
- parentDeviceId: parent
40390
- };
40391
- if (tail === "privacy") return {
40392
- kind: "privacy",
40393
- parentDeviceId: parent
40394
- };
40395
- const child = parseNonNegativeInt(tail);
40396
- if (child === null) return {
40397
- kind: "invalid",
40398
- reason: `child segment not numeric and not 'autotrack'/'privacy': ${raw}`
40399
- };
40400
41168
  return {
40401
- kind: "child",
40402
- parentDeviceId: parent,
40403
- childDeviceId: child
41169
+ event: {
41170
+ header: {
41171
+ namespace: "Alexa",
41172
+ name: "Response",
41173
+ messageId: header.messageId,
41174
+ correlationToken: header.correlationToken,
41175
+ payloadVersion: "3"
41176
+ },
41177
+ endpoint: { endpointId },
41178
+ payload: {}
41179
+ },
41180
+ context: { properties: [{
41181
+ namespace: "Alexa.BrightnessController",
41182
+ name: "brightness",
41183
+ value: percentage,
41184
+ timeOfSample: (/* @__PURE__ */ new Date()).toISOString(),
41185
+ uncertaintyInMilliseconds: 500
41186
+ }] }
40404
41187
  };
40405
41188
  }
40406
- function parseNonNegativeInt(s) {
40407
- if (!/^\d+$/.test(s)) return null;
40408
- const n = Number.parseInt(s, 10);
40409
- if (!Number.isFinite(n) || n < 0) return null;
40410
- return n;
40411
- }
40412
41189
  //#endregion
40413
41190
  //#region src/handlers/camera-stream.ts
40414
41191
  /**
@@ -40492,347 +41269,42 @@ async function handleInitializeCameraStreams(header, endpointRaw, payloadRaw, de
40492
41269
  } };
40493
41270
  }
40494
41271
  //#endregion
40495
- //#region src/handlers/rtc-session.ts
40496
- var InitiateSessionWithOfferDirectiveSchema = object({
40497
- endpoint: EndpointRefSchema,
40498
- payload: InitiateSessionWithOfferPayloadSchema
40499
- });
40500
- var SessionDisconnectedDirectiveSchema = object({
40501
- endpoint: EndpointRefSchema,
40502
- payload: SessionDisconnectedPayloadSchema
40503
- });
40504
- /**
40505
- * Live egress handles, keyed by `<deviceId>/<sessionId>`.
40506
- *
40507
- * The primitive's refcount has no lease TTL: a holder that vanishes without
40508
- * releasing leaks a child until the broker restarts (D67). This handler is a
40509
- * holder, so it keeps the one piece of state that lets it not be that holder —
40510
- * the `pipelineKey` its `SessionDisconnected` has to release.
40511
- *
40512
- * A module-level map is right here because the directive handlers are free
40513
- * functions called per directive; the entry is written on a successful acquire
40514
- * and deleted on the release path, both of which are the same tick as the
40515
- * directive that owns it.
40516
- */
40517
- var EGRESS_HANDLES = /* @__PURE__ */ new Map();
40518
- function egressHandleKey(deviceId, sessionId) {
40519
- return `${deviceId}/${sessionId}`;
40520
- }
40521
- var WEBRTC_PROFILE_NAMES = [
40522
- "high",
40523
- "mid",
40524
- "low"
40525
- ];
40526
- function resolveWebrtcTarget(pref) {
40527
- if (pref === "auto") return void 0;
40528
- if (WEBRTC_PROFILE_NAMES.includes(pref)) return {
40529
- kind: "profile",
40530
- profile: pref
40531
- };
40532
- return {
40533
- kind: "cam-stream",
40534
- camStreamId: pref
40535
- };
40536
- }
40537
- async function handleInitiateSessionWithOffer(header, endpointRaw, payloadRaw, deps, _callerIdentity) {
40538
- const parsed = InitiateSessionWithOfferDirectiveSchema.safeParse({
40539
- endpoint: endpointRaw,
40540
- payload: payloadRaw ?? {}
40541
- });
40542
- if (!parsed.success) return errorEnvelope(header, "INVALID_DIRECTIVE", `payload validation failed: ${parsed.error.message}`);
40543
- const endpoint = parsed.data.endpoint;
40544
- const payload = parsed.data.payload;
40545
- const parsedId = parseCameraEndpointId(endpoint.endpointId);
40546
- if (parsedId.kind !== "camera") return errorEnvelope(header, "INVALID_DIRECTIVE", `endpointId ${endpoint.endpointId} is not a camera`);
40547
- const deviceId = parsedId.deviceId;
40548
- const sessionId = payload.sessionId;
40549
- const log = deps.logger.withTags({
40550
- deviceId,
40551
- sessionId
40552
- });
40553
- const sdpOffer = payload.offer.value;
40554
- if (sdpOffer.length === 0) return errorEnvelope(header, "INVALID_DIRECTIVE", "offer.value is empty");
40555
- const api = deps.ctx.api;
40556
- const exposedEntry = deps.listExposed().find((e) => e.deviceId === deviceId);
40557
- const explicitSourceProfile = exposedEntry?.settings?.sourceProfile;
40558
- const mappedFromPreference = (() => {
40559
- const pref = exposedEntry?.settings?.streamPreference;
40560
- if (pref === "low" || pref === "mid" || pref === "high") return pref;
40561
- })();
40562
- const sourceProfile = explicitSourceProfile ?? mappedFromPreference ?? "high";
40563
- let derivedTarget;
40564
- /**
40565
- * The primitive's release handle, when this session acquired one. Recorded so
40566
- * `SessionDisconnected` can release it: a refcount whose holder disappears is
40567
- * the one gap D67 leaves open, and the least this handler can do is not BE
40568
- * that holder.
40569
- */
40570
- let egressPipelineKey = null;
40571
- const picked = await api.cameraStreams.pickStream.query({
40572
- deviceId,
40573
- requirements: {
40574
- acceptCodecs: ["h264"],
40575
- minHeight: 480,
40576
- excludeDerived: true,
40577
- requireSiblingCodec: ["h265", "hevc"]
40578
- }
40579
- }).catch((err) => {
40580
- log.warn("alexa rtc-session: cameraStreams.pickStream threw — falling back to transcode", { meta: { error: err instanceof Error ? err.message : String(err) } });
40581
- return null;
40582
- });
40583
- if (picked) {
40584
- log.info("alexa rtc-session: bypassing transcode via cameraStreams.pickStream", { meta: {
40585
- sourceProfile,
40586
- selectedCamStreamId: picked.camStreamId,
40587
- reason: picked.reason
40588
- } });
40589
- derivedTarget = {
40590
- kind: "cam-stream",
40591
- camStreamId: picked.camStreamId
40592
- };
40593
- } else try {
40594
- const acquired = await acquireAlexaEgress({
40595
- api,
40596
- deviceId,
40597
- sourceProfile,
40598
- sessionId,
40599
- logger: log
40600
- });
40601
- egressPipelineKey = acquired.pipelineKey;
40602
- EGRESS_HANDLES.set(egressHandleKey(deviceId, sessionId), acquired.pipelineKey);
40603
- derivedTarget = {
40604
- kind: "cam-stream",
40605
- camStreamId: acquired.camStreamId
40606
- };
40607
- } catch (err) {
40608
- log.warn("alexa rtc-session: egress acquire failed, falling back to streamPreference target", { meta: { error: err instanceof Error ? err.message : String(err) } });
40609
- derivedTarget = resolveWebrtcTarget(exposedEntry?.settings?.streamPreference ?? "auto");
40610
- }
40611
- try {
40612
- const result = await api.webrtcSession.handleOffer.mutate({
40613
- deviceId,
40614
- sdpOffer,
40615
- sessionId,
40616
- target: derivedTarget,
40617
- relayOnly: false,
40618
- nonTrickle: true,
40619
- disableTurn: true,
40620
- disableIpv6: true,
40621
- consumerAttribution: {
40622
- kind: "alexa",
40623
- sessionId
40624
- }
40625
- });
40626
- log.info("alexa rtc-session: AnswerGenerated", { meta: {
40627
- returnedSessionId: result.sessionId,
40628
- sdpAnswerLen: result.sdpAnswer.length
40629
- } });
40630
- return { event: {
40631
- header: {
40632
- namespace: "Alexa.RTCSessionController",
40633
- name: "AnswerGeneratedForSession",
40634
- messageId: header.messageId,
40635
- correlationToken: header.correlationToken,
40636
- payloadVersion: "3"
40637
- },
40638
- endpoint: { endpointId: endpoint.endpointId },
40639
- payload: { answer: {
40640
- format: payload.offer.format ?? "SDP",
40641
- value: result.sdpAnswer
40642
- } }
40643
- } };
40644
- } catch (err) {
40645
- const message = err instanceof Error ? err.message : String(err);
40646
- const mSectionSummary = (() => {
40647
- const lines = sdpOffer.split(/\r?\n/);
40648
- const sections = [];
40649
- let current = null;
40650
- for (const raw of lines) {
40651
- const line = raw.trim();
40652
- if (line.startsWith("m=")) {
40653
- if (current) sections.push(current);
40654
- current = {
40655
- mid: null,
40656
- kind: line.split(" ")[0]?.slice(2) ?? "unknown",
40657
- direction: null,
40658
- codecs: []
40659
- };
40660
- } else if (current) {
40661
- if (line.startsWith("a=mid:")) current.mid = line.slice(6);
40662
- else if (/^a=(sendonly|recvonly|sendrecv|inactive)$/.test(line)) current.direction = line.slice(2);
40663
- else if (line.startsWith("a=rtpmap:")) current.codecs.push(line.slice(9));
40664
- }
40665
- }
40666
- if (current) sections.push(current);
40667
- return sections;
40668
- })();
40669
- log.warn("alexa rtc-session: handleOffer failed", { meta: {
40670
- error: message,
40671
- offerLen: sdpOffer.length,
40672
- mSections: mSectionSummary,
40673
- offerSdp: sdpOffer
40674
- } });
40675
- if (egressPipelineKey !== null) {
40676
- EGRESS_HANDLES.delete(egressHandleKey(deviceId, sessionId));
40677
- await releaseAlexaEgress({
40678
- api,
40679
- deviceId,
40680
- pipelineKey: egressPipelineKey,
40681
- logger: log
40682
- });
40683
- }
40684
- return errorEnvelope(header, "INTERNAL_ERROR", `webrtc-session.handleOffer failed: ${message}`);
40685
- }
40686
- }
40687
- async function handleSessionDisconnected(header, endpointRaw, payloadRaw, deps, _callerIdentity) {
40688
- const parsed = SessionDisconnectedDirectiveSchema.safeParse({
40689
- endpoint: endpointRaw,
40690
- payload: payloadRaw ?? {}
40691
- });
40692
- if (!parsed.success) return errorEnvelope(header, "INVALID_DIRECTIVE", `payload validation failed: ${parsed.error.message}`);
40693
- const endpoint = parsed.data.endpoint;
40694
- const parsedId = parseCameraEndpointId(endpoint.endpointId);
40695
- if (parsedId.kind === "camera") {
40696
- const handleKey = egressHandleKey(parsedId.deviceId, parsed.data.payload.sessionId);
40697
- const pipelineKey = EGRESS_HANDLES.get(handleKey);
40698
- if (pipelineKey !== void 0) {
40699
- EGRESS_HANDLES.delete(handleKey);
40700
- await releaseAlexaEgress({
40701
- api: deps.ctx.api,
40702
- deviceId: parsedId.deviceId,
40703
- pipelineKey,
40704
- logger: deps.logger
40705
- });
40706
- }
40707
- try {
40708
- await deps.ctx.api.webrtcSession.closeSession.mutate({
40709
- deviceId: parsedId.deviceId,
40710
- sessionId: parsed.data.payload.sessionId
40711
- });
40712
- } catch (err) {
40713
- deps.logger.warn("alexa rtc-session: closeSession threw on disconnect (continuing to ack)", {
40714
- tags: {
40715
- deviceId: parsedId.deviceId,
40716
- sessionId: parsed.data.payload.sessionId
40717
- },
40718
- meta: { error: err instanceof Error ? err.message : String(err) }
40719
- });
40720
- }
40721
- }
40722
- const disconnectedDeviceId = parsedId.kind === "camera" ? parsedId.deviceId : void 0;
40723
- deps.logger.info("alexa rtc-session: SessionDisconnected", {
40724
- tags: {
40725
- sessionId: parsed.data.payload.sessionId,
40726
- ...disconnectedDeviceId !== void 0 ? { deviceId: disconnectedDeviceId } : {}
40727
- },
40728
- meta: { endpointId: endpoint.endpointId }
40729
- });
40730
- return { event: {
40731
- header: {
40732
- namespace: "Alexa",
40733
- name: "Response",
40734
- messageId: header.messageId,
40735
- correlationToken: header.correlationToken,
40736
- payloadVersion: "3"
40737
- },
40738
- endpoint: { endpointId: endpoint.endpointId },
40739
- payload: {}
40740
- } };
40741
- }
40742
- //#endregion
40743
- //#region src/handlers/ptz-preset.ts
40744
- /**
40745
- * `Alexa.ModeController/SetMode` handler — dispatches three PTZ instances:
40746
- *
40747
- * `ptz.preset` — preset-recall. Decodes the preset name from the mode
40748
- * value and calls `ptz.goToPreset({deviceId, presetId})`.
40749
- *
40750
- * `ptz.pan` — momentary pan. `ptz.pan.left|right|center` issues a
40751
- * continuousMove pulse stopped on a timer; the dropdown reverts to
40752
- * `center` server-side so subsequent picks re-trigger the move.
40753
- *
40754
- * `ptz.tilt` — momentary tilt. Mirrors ptz.pan with up|down|center.
40755
- */
40756
- var SetModeDirectiveSchema = object({
41272
+ //#region src/handlers/power-controller.ts
41273
+ var PowerDirectiveSchema = object({
40757
41274
  header: object({
40758
- namespace: literal("Alexa.ModeController"),
40759
- name: literal("SetMode"),
40760
- instance: string().optional()
41275
+ namespace: literal("Alexa.PowerController"),
41276
+ name: _enum(["TurnOn", "TurnOff"])
40761
41277
  }),
40762
- endpoint: EndpointRefSchema,
40763
- payload: SetModePayloadSchema
41278
+ endpoint: EndpointRefSchema
40764
41279
  });
40765
- var PTZ_PRESET_MODE_PREFIX = "ptz.preset.";
40766
- var PTZ_DIRECTION_PULSE_MS = 1e3;
40767
- var PTZ_PAN_MOVES = {
40768
- "ptz.pan.left": { pan: -1 },
40769
- "ptz.pan.right": { pan: 1 },
40770
- "ptz.pan.center": {}
40771
- };
40772
- var PTZ_TILT_MOVES = {
40773
- "ptz.tilt.up": { tilt: 1 },
40774
- "ptz.tilt.down": { tilt: -1 },
40775
- "ptz.tilt.center": {}
40776
- };
40777
- async function handleSetMode(header, endpointRaw, payloadRaw, deps, _callerIdentity) {
40778
- const parsed = SetModeDirectiveSchema.safeParse({
41280
+ async function handlePower(header, endpointRaw, deps, _callerIdentity) {
41281
+ const parsed = PowerDirectiveSchema.safeParse({
40779
41282
  header: {
40780
41283
  namespace: header.namespace,
40781
- name: header.name,
40782
- instance: header.instance
41284
+ name: header.name
40783
41285
  },
40784
- endpoint: endpointRaw,
40785
- payload: payloadRaw ?? {}
41286
+ endpoint: endpointRaw
40786
41287
  });
40787
41288
  if (!parsed.success) return errorEnvelope(header, "INVALID_DIRECTIVE", `payload validation failed: ${parsed.error.message}`);
40788
- const instance = parsed.data.header.instance;
40789
- if (instance !== "ptz.preset" && instance !== "ptz.pan" && instance !== "ptz.tilt") return errorEnvelope(header, "INVALID_DIRECTIVE", `ModeController instance not supported: ${instance ?? "(missing)"}`);
40790
41289
  const endpointId = parsed.data.endpoint.endpointId;
40791
41290
  const parsedId = parseCameraEndpointId(endpointId);
40792
- if (parsedId.kind !== "camera") return errorEnvelope(header, "INVALID_DIRECTIVE", `ModeController only supported on camera endpoints — got ${endpointId}`);
40793
- const mode = parsed.data.payload.mode;
41291
+ const on = parsed.data.header.name === "TurnOn";
40794
41292
  const api = deps.ctx.api;
40795
- if (instance === "ptz.pan" || instance === "ptz.tilt") {
40796
- const move = (instance === "ptz.pan" ? PTZ_PAN_MOVES : PTZ_TILT_MOVES)[mode];
40797
- if (!move) return errorEnvelope(header, "INVALID_DIRECTIVE", `mode '${mode}' is not a valid ${instance} mode`);
40798
- if (Object.keys(move).length === 0) await api.ptz.stop.mutate({ deviceId: parsedId.deviceId }).catch((err) => {
40799
- deps.logger.debug("alexa ptz-mode: stop (center) failed (non-fatal)", { meta: {
40800
- deviceId: parsedId.deviceId,
40801
- instance,
40802
- error: err instanceof Error ? err.message : String(err)
40803
- } });
40804
- });
40805
- else try {
40806
- await api.ptz.continuousMove.mutate({
40807
- deviceId: parsedId.deviceId,
40808
- ...move
40809
- });
40810
- setTimeout(() => {
40811
- api.ptz.stop.mutate({ deviceId: parsedId.deviceId }).catch((err) => {
40812
- deps.logger.debug("alexa ptz-mode: stop after pulse failed (non-fatal)", { meta: {
40813
- deviceId: parsedId.deviceId,
40814
- instance,
40815
- error: err instanceof Error ? err.message : String(err)
40816
- } });
40817
- });
40818
- }, PTZ_DIRECTION_PULSE_MS);
40819
- } catch (err) {
40820
- return errorEnvelope(header, "INTERNAL_ERROR", `ptz.continuousMove failed: ${err instanceof Error ? err.message : String(err)}`);
40821
- }
40822
- return modeSuccessResponse(header, endpointId, instance, instance === "ptz.pan" ? "ptz.pan.center" : "ptz.tilt.center");
40823
- }
40824
- if (!mode.startsWith(PTZ_PRESET_MODE_PREFIX)) return errorEnvelope(header, "INVALID_DIRECTIVE", `mode '${mode}' is not a ptz.preset mode`);
40825
- const presetName = mode.slice(11);
40826
- if (presetName.length === 0) return errorEnvelope(header, "INVALID_DIRECTIVE", "preset name missing after prefix");
40827
- const preset = (await api.ptz.getPresets.query({ deviceId: parsedId.deviceId })).find((p) => p.name === presetName);
40828
- if (!preset) return errorEnvelope(header, "INVALID_DIRECTIVE", `preset '${presetName}' not found on device ${parsedId.deviceId}`);
40829
- await api.ptz.goToPreset.mutate({
40830
- deviceId: parsedId.deviceId,
40831
- presetId: preset.id
41293
+ if (parsedId.kind === "autotrack") await api.ptzAutotrack.setEnabled.mutate({
41294
+ deviceId: parsedId.parentDeviceId,
41295
+ enabled: on
40832
41296
  });
40833
- return modeSuccessResponse(header, endpointId, "ptz.preset", mode);
40834
- }
40835
- function modeSuccessResponse(header, endpointId, instance, mode) {
41297
+ else if (parsedId.kind === "privacy") await api.privacyMask.setMask.mutate({
41298
+ deviceId: parsedId.parentDeviceId,
41299
+ patch: { enabled: on }
41300
+ });
41301
+ else if (parsedId.kind === "child") await api.switch.setState.mutate({
41302
+ deviceId: parsedId.childDeviceId,
41303
+ on
41304
+ });
41305
+ else if (parsedId.kind === "camera") return errorEnvelope(header, "INVALID_DIRECTIVE", "PowerController is not exposed on camera endpoints — direct the directive at the child accessory");
41306
+ else if (parsedId.kind === "alarm-panel") return errorEnvelope(header, "INVALID_DIRECTIVE", "PowerController is not exposed on alarm-panel endpoints — use Alexa.SecurityPanelController");
41307
+ else return errorEnvelope(header, "INVALID_DIRECTIVE", parsedId.reason);
40836
41308
  return {
40837
41309
  event: {
40838
41310
  header: {
@@ -40846,10 +41318,9 @@ function modeSuccessResponse(header, endpointId, instance, mode) {
40846
41318
  payload: {}
40847
41319
  },
40848
41320
  context: { properties: [{
40849
- namespace: "Alexa.ModeController",
40850
- instance,
40851
- name: "mode",
40852
- value: mode,
41321
+ namespace: "Alexa.PowerController",
41322
+ name: "powerState",
41323
+ value: on ? "ON" : "OFF",
40853
41324
  timeOfSample: (/* @__PURE__ */ new Date()).toISOString(),
40854
41325
  uncertaintyInMilliseconds: 500
40855
41326
  }] }
@@ -40949,133 +41420,99 @@ function successResponse(header, endpointId, toggleState, instance) {
40949
41420
  };
40950
41421
  }
40951
41422
  //#endregion
40952
- //#region src/handlers/power-controller.ts
40953
- /**
40954
- * `Alexa.PowerController/{TurnOn,TurnOff}` handler.
40955
- *
40956
- * Routing decodes the endpoint id:
40957
- * - `${parent}__${child}` → call `switch.setState({deviceId: child, on})`
40958
- * - `${parent}__autotrack` → call `ptzAutotrack.setEnabled({deviceId: parent, enabled})`
40959
- * - `${parent}__privacy` → call `privacyMask.setMask({deviceId: parent, patch: { enabled } })`
40960
- * - `${deviceId}` (bare parent camera) → reject with INVALID_DIRECTIVE
40961
- * (we never expose PowerController on the camera itself).
40962
- *
40963
- * Auth: verify the directive's scope token (same as every other
40964
- * controller).
40965
- */
40966
- var PowerDirectiveSchema = object({
40967
- header: object({
40968
- namespace: literal("Alexa.PowerController"),
40969
- name: _enum(["TurnOn", "TurnOff"])
40970
- }),
40971
- endpoint: EndpointRefSchema
40972
- });
40973
- async function handlePower(header, endpointRaw, deps, _callerIdentity) {
40974
- const parsed = PowerDirectiveSchema.safeParse({
40975
- header: {
40976
- namespace: header.namespace,
40977
- name: header.name
40978
- },
40979
- endpoint: endpointRaw
40980
- });
40981
- if (!parsed.success) return errorEnvelope(header, "INVALID_DIRECTIVE", `payload validation failed: ${parsed.error.message}`);
40982
- const endpointId = parsed.data.endpoint.endpointId;
40983
- const parsedId = parseCameraEndpointId(endpointId);
40984
- const on = parsed.data.header.name === "TurnOn";
40985
- const api = deps.ctx.api;
40986
- if (parsedId.kind === "autotrack") await api.ptzAutotrack.setEnabled.mutate({
40987
- deviceId: parsedId.parentDeviceId,
40988
- enabled: on
40989
- });
40990
- else if (parsedId.kind === "privacy") await api.privacyMask.setMask.mutate({
40991
- deviceId: parsedId.parentDeviceId,
40992
- patch: { enabled: on }
40993
- });
40994
- else if (parsedId.kind === "child") await api.switch.setState.mutate({
40995
- deviceId: parsedId.childDeviceId,
40996
- on
40997
- });
40998
- else if (parsedId.kind === "camera") return errorEnvelope(header, "INVALID_DIRECTIVE", "PowerController is not exposed on camera endpoints — direct the directive at the child accessory");
40999
- else return errorEnvelope(header, "INVALID_DIRECTIVE", parsedId.reason);
41000
- return {
41001
- event: {
41002
- header: {
41003
- namespace: "Alexa",
41004
- name: "Response",
41005
- messageId: header.messageId,
41006
- correlationToken: header.correlationToken,
41007
- payloadVersion: "3"
41008
- },
41009
- endpoint: { endpointId },
41010
- payload: {}
41011
- },
41012
- context: { properties: [{
41013
- namespace: "Alexa.PowerController",
41014
- name: "powerState",
41015
- value: on ? "ON" : "OFF",
41016
- timeOfSample: (/* @__PURE__ */ new Date()).toISOString(),
41017
- uncertaintyInMilliseconds: 500
41018
- }] }
41019
- };
41020
- }
41021
- //#endregion
41022
- //#region src/handlers/brightness-controller.ts
41423
+ //#region src/handlers/ptz-preset.ts
41023
41424
  /**
41024
- * `Alexa.BrightnessController/SetBrightness` handler.
41425
+ * `Alexa.ModeController/SetMode` handler — dispatches three PTZ instances:
41025
41426
  *
41026
- * The Discovery handler declares `Alexa.BrightnessController` only on
41027
- * child accessory endpoints that bind the `brightness` cap (today:
41028
- * Reolink floodlights, Hikvision supplemental lights). Brightness is
41029
- * NEVER exposed on the parent camera endpoint or on the synthetic
41030
- * autotrack endpoint — both reject INVALID_DIRECTIVE here.
41427
+ * `ptz.preset` — preset-recall. Decodes the preset name from the mode
41428
+ * value and calls `ptz.goToPreset({deviceId, presetId})`.
41031
41429
  *
41032
- * Routing:
41033
- * - `${parent}__${child}` → `brightness.setBrightness({deviceId: child, percentage})`
41430
+ * `ptz.pan` — momentary pan. `ptz.pan.left|right|center` issues a
41431
+ * continuousMove pulse stopped on a timer; the dropdown reverts to
41432
+ * `center` server-side so subsequent picks re-trigger the move.
41034
41433
  *
41035
- * Auth: same scope-token verification path as the other controllers.
41036
- * The response carries the `Alexa.BrightnessController.brightness`
41037
- * property echoing the new level so the Echo updates its UI without an
41038
- * extra ReportState round-trip.
41434
+ * `ptz.tilt` — momentary tilt. Mirrors ptz.pan with up|down|center.
41039
41435
  */
41040
- var SetBrightnessDirectiveSchema = object({
41436
+ var SetModeDirectiveSchema = object({
41041
41437
  header: object({
41042
- namespace: literal("Alexa.BrightnessController"),
41043
- name: literal("SetBrightness")
41438
+ namespace: literal("Alexa.ModeController"),
41439
+ name: literal("SetMode"),
41440
+ instance: string().optional()
41044
41441
  }),
41045
41442
  endpoint: EndpointRefSchema,
41046
- payload: object({ brightness: number().min(0).max(100) })
41443
+ payload: SetModePayloadSchema
41047
41444
  });
41048
- async function handleSetBrightness(header, endpointRaw, payloadRaw, deps, _callerIdentity) {
41049
- const parsed = SetBrightnessDirectiveSchema.safeParse({
41445
+ var PTZ_PRESET_MODE_PREFIX = "ptz.preset.";
41446
+ var PTZ_DIRECTION_PULSE_MS = 1e3;
41447
+ var PTZ_PAN_MOVES = {
41448
+ "ptz.pan.left": { pan: -1 },
41449
+ "ptz.pan.right": { pan: 1 },
41450
+ "ptz.pan.center": {}
41451
+ };
41452
+ var PTZ_TILT_MOVES = {
41453
+ "ptz.tilt.up": { tilt: 1 },
41454
+ "ptz.tilt.down": { tilt: -1 },
41455
+ "ptz.tilt.center": {}
41456
+ };
41457
+ async function handleSetMode(header, endpointRaw, payloadRaw, deps, _callerIdentity) {
41458
+ const parsed = SetModeDirectiveSchema.safeParse({
41050
41459
  header: {
41051
41460
  namespace: header.namespace,
41052
- name: header.name
41461
+ name: header.name,
41462
+ instance: header.instance
41053
41463
  },
41054
41464
  endpoint: endpointRaw,
41055
41465
  payload: payloadRaw ?? {}
41056
41466
  });
41057
41467
  if (!parsed.success) return errorEnvelope(header, "INVALID_DIRECTIVE", `payload validation failed: ${parsed.error.message}`);
41468
+ const instance = parsed.data.header.instance;
41469
+ if (instance !== "ptz.preset" && instance !== "ptz.pan" && instance !== "ptz.tilt") return errorEnvelope(header, "INVALID_DIRECTIVE", `ModeController instance not supported: ${instance ?? "(missing)"}`);
41058
41470
  const endpointId = parsed.data.endpoint.endpointId;
41059
41471
  const parsedId = parseCameraEndpointId(endpointId);
41060
- if (parsedId.kind === "autotrack" || parsedId.kind === "privacy") return errorEnvelope(header, "INVALID_DIRECTIVE", `BrightnessController is not exposed on the ${parsedId.kind} endpoint`);
41061
- if (parsedId.kind === "camera") return errorEnvelope(header, "INVALID_DIRECTIVE", "BrightnessController is not exposed on camera endpoints — direct the directive at the dimmable child accessory");
41062
- if (parsedId.kind === "invalid") return errorEnvelope(header, "INVALID_DIRECTIVE", parsedId.reason);
41063
- const percentage = parsed.data.payload.brightness;
41472
+ if (parsedId.kind !== "camera") return errorEnvelope(header, "INVALID_DIRECTIVE", `ModeController only supported on camera endpoints — got ${endpointId}`);
41473
+ const mode = parsed.data.payload.mode;
41064
41474
  const api = deps.ctx.api;
41065
- try {
41066
- await api.brightness.setBrightness.mutate({
41067
- deviceId: parsedId.childDeviceId,
41068
- percentage
41475
+ if (instance === "ptz.pan" || instance === "ptz.tilt") {
41476
+ const move = (instance === "ptz.pan" ? PTZ_PAN_MOVES : PTZ_TILT_MOVES)[mode];
41477
+ if (!move) return errorEnvelope(header, "INVALID_DIRECTIVE", `mode '${mode}' is not a valid ${instance} mode`);
41478
+ if (Object.keys(move).length === 0) await api.ptz.stop.mutate({ deviceId: parsedId.deviceId }).catch((err) => {
41479
+ deps.logger.debug("alexa ptz-mode: stop (center) failed (non-fatal)", { meta: {
41480
+ deviceId: parsedId.deviceId,
41481
+ instance,
41482
+ error: err instanceof Error ? err.message : String(err)
41483
+ } });
41069
41484
  });
41070
- } catch (err) {
41071
- const message = err instanceof Error ? err.message : String(err);
41072
- deps.logger.warn("alexa brightness-controller: setBrightness failed", { meta: {
41073
- deviceId: parsedId.childDeviceId,
41074
- percentage,
41075
- error: message
41076
- } });
41077
- return errorEnvelope(header, "INTERNAL_ERROR", `brightness.setBrightness failed: ${message}`);
41485
+ else try {
41486
+ await api.ptz.continuousMove.mutate({
41487
+ deviceId: parsedId.deviceId,
41488
+ ...move
41489
+ });
41490
+ setTimeout(() => {
41491
+ api.ptz.stop.mutate({ deviceId: parsedId.deviceId }).catch((err) => {
41492
+ deps.logger.debug("alexa ptz-mode: stop after pulse failed (non-fatal)", { meta: {
41493
+ deviceId: parsedId.deviceId,
41494
+ instance,
41495
+ error: err instanceof Error ? err.message : String(err)
41496
+ } });
41497
+ });
41498
+ }, PTZ_DIRECTION_PULSE_MS);
41499
+ } catch (err) {
41500
+ return errorEnvelope(header, "INTERNAL_ERROR", `ptz.continuousMove failed: ${err instanceof Error ? err.message : String(err)}`);
41501
+ }
41502
+ return modeSuccessResponse(header, endpointId, instance, instance === "ptz.pan" ? "ptz.pan.center" : "ptz.tilt.center");
41078
41503
  }
41504
+ if (!mode.startsWith(PTZ_PRESET_MODE_PREFIX)) return errorEnvelope(header, "INVALID_DIRECTIVE", `mode '${mode}' is not a ptz.preset mode`);
41505
+ const presetName = mode.slice(11);
41506
+ if (presetName.length === 0) return errorEnvelope(header, "INVALID_DIRECTIVE", "preset name missing after prefix");
41507
+ const preset = (await api.ptz.getPresets.query({ deviceId: parsedId.deviceId })).find((p) => p.name === presetName);
41508
+ if (!preset) return errorEnvelope(header, "INVALID_DIRECTIVE", `preset '${presetName}' not found on device ${parsedId.deviceId}`);
41509
+ await api.ptz.goToPreset.mutate({
41510
+ deviceId: parsedId.deviceId,
41511
+ presetId: preset.id
41512
+ });
41513
+ return modeSuccessResponse(header, endpointId, "ptz.preset", mode);
41514
+ }
41515
+ function modeSuccessResponse(header, endpointId, instance, mode) {
41079
41516
  return {
41080
41517
  event: {
41081
41518
  header: {
@@ -41089,9 +41526,10 @@ async function handleSetBrightness(header, endpointRaw, payloadRaw, deps, _calle
41089
41526
  payload: {}
41090
41527
  },
41091
41528
  context: { properties: [{
41092
- namespace: "Alexa.BrightnessController",
41093
- name: "brightness",
41094
- value: percentage,
41529
+ namespace: "Alexa.ModeController",
41530
+ instance,
41531
+ name: "mode",
41532
+ value: mode,
41095
41533
  timeOfSample: (/* @__PURE__ */ new Date()).toISOString(),
41096
41534
  uncertaintyInMilliseconds: 500
41097
41535
  }] }
@@ -41112,6 +41550,7 @@ async function handleReportState(header, endpointRaw, deps, _callerIdentity) {
41112
41550
  else if (parsed.kind === "privacy") await collectPrivacyProperties(api, parsed.parentDeviceId, properties, nowIso, deps.logger);
41113
41551
  else if (parsed.kind === "autotrack") await collectAutotrackProperties(api, parsed.parentDeviceId, properties, nowIso, deps.logger);
41114
41552
  else if (parsed.kind === "child") await collectChildProperties(api, parsed.childDeviceId, properties, nowIso, deps.logger);
41553
+ else if (parsed.kind === "alarm-panel") await collectAlarmPanelProperties(api, parsed.deviceId, properties, nowIso, deps.logger);
41115
41554
  } catch (err) {
41116
41555
  deps.logger.warn("alexa report-state: collection failed", { meta: {
41117
41556
  endpointId,
@@ -41173,6 +41612,42 @@ async function collectCameraProperties(api, deviceId, properties, timeOfSample,
41173
41612
  uncertaintyInMilliseconds: 0
41174
41613
  });
41175
41614
  }
41615
+ /**
41616
+ * The panel's two declared properties.
41617
+ *
41618
+ * Both are omitted when the panel cannot be read — an `armState` we guessed
41619
+ * would be a claim about whether a house is locked, and Alexa rendering the
41620
+ * endpoint as unavailable is the truthful outcome of a panel nobody reached.
41621
+ * The failure is logged with the device id, never swallowed.
41622
+ */
41623
+ async function collectAlarmPanelProperties(api, deviceId, properties, timeOfSample, logger) {
41624
+ const status = await api.alarmPanel.getStatus.query({ deviceId }).catch((err) => {
41625
+ logger.warn("alexa report-state: alarmPanel.getStatus FAILED — Alexa will show the panel as unavailable", {
41626
+ tags: { deviceId },
41627
+ meta: { error: err instanceof Error ? err.message : String(err) }
41628
+ });
41629
+ return null;
41630
+ });
41631
+ const parsed = AlarmPanelStatusSchema.pick({ state: true }).safeParse(status);
41632
+ if (!parsed.success) {
41633
+ if (status !== null) logger.warn("alexa report-state: panel status did not match the cap schema — omitting", { tags: { deviceId } });
41634
+ return;
41635
+ }
41636
+ properties.push({
41637
+ namespace: "Alexa.SecurityPanelController",
41638
+ name: "armState",
41639
+ value: alexaArmStateFor(parsed.data.state),
41640
+ timeOfSample,
41641
+ uncertaintyInMilliseconds: 0
41642
+ });
41643
+ properties.push({
41644
+ namespace: "Alexa.SecurityPanelController",
41645
+ name: "burglaryAlarm",
41646
+ value: burglaryAlarmFor(parsed.data.state),
41647
+ timeOfSample,
41648
+ uncertaintyInMilliseconds: 0
41649
+ });
41650
+ }
41176
41651
  async function collectPrivacyProperties(api, parentDeviceId, properties, timeOfSample, _logger) {
41177
41652
  const status = await api.privacyMask.getStatus.query({ deviceId: parentDeviceId }).catch(() => null);
41178
41653
  const enabled = Boolean(status?.enabled);
@@ -41226,6 +41701,230 @@ async function collectChildProperties(api, childDeviceId, properties, timeOfSamp
41226
41701
  });
41227
41702
  }
41228
41703
  //#endregion
41704
+ //#region src/handlers/security-panel.ts
41705
+ var ArmPayloadSchema = object({ armState: _enum([
41706
+ "ARMED_STAY",
41707
+ "ARMED_AWAY",
41708
+ "ARMED_NIGHT"
41709
+ ]) });
41710
+ var DisarmPayloadSchema = object({ authorization: object({
41711
+ type: literal("FOUR_DIGIT_PIN"),
41712
+ value: string().min(1)
41713
+ }).optional() });
41714
+ /** The subset of the panel status the directives read. */
41715
+ var PanelReadSchema = AlarmPanelStatusSchema.pick({
41716
+ state: true,
41717
+ availableModes: true,
41718
+ requiresCode: true
41719
+ });
41720
+ /**
41721
+ * `Alexa.SecurityPanelController.ErrorResponse`.
41722
+ *
41723
+ * A namespace of its own, because the four types that matter here
41724
+ * (`AUTHORIZATION_REQUIRED`, `UNAUTHORIZED`, `NOT_READY_FOR_ARM`,
41725
+ * `BYPASS_NEEDED`) are only understood under it — the generic `Alexa`
41726
+ * `ErrorResponse` from `error-envelope.ts` carries none of them, and a PIN
41727
+ * problem reported there is a message the Alexa app cannot act on.
41728
+ */
41729
+ function securityPanelError(header, type, message) {
41730
+ return { event: {
41731
+ header: {
41732
+ namespace: "Alexa.SecurityPanelController",
41733
+ name: "ErrorResponse",
41734
+ messageId: header.messageId,
41735
+ correlationToken: header.correlationToken,
41736
+ payloadVersion: "3"
41737
+ },
41738
+ payload: {
41739
+ type,
41740
+ message
41741
+ }
41742
+ } };
41743
+ }
41744
+ /** Resolve the alarm panel this directive targets, or the refusal to send. */
41745
+ function resolveDeviceId(endpointRaw) {
41746
+ const parsedEndpoint = EndpointRefSchema.safeParse(endpointRaw);
41747
+ if (!parsedEndpoint.success) return { reason: "missing or malformed endpoint" };
41748
+ const parsedId = parseCameraEndpointId(parsedEndpoint.data.endpointId);
41749
+ if (parsedId.kind === "alarm-panel") return { deviceId: parsedId.deviceId };
41750
+ return { reason: `SecurityPanelController is only exposed on alarm-panel endpoints — got '${parsedEndpoint.data.endpointId}'` };
41751
+ }
41752
+ /**
41753
+ * What the panel currently accepts, or `null` when it could not be read.
41754
+ *
41755
+ * `null` is the honest answer and the callers treat it as unknown; nothing
41756
+ * here substitutes a default that would read as "accepts nothing, needs no
41757
+ * PIN".
41758
+ */
41759
+ async function readPanel(api, deviceId, logger, phase) {
41760
+ const raw = await api.alarmPanel.getStatus.query({ deviceId }).catch((err) => {
41761
+ logger.warn("alexa security-panel: alarmPanel.getStatus failed — proceeding without it", {
41762
+ tags: { deviceId },
41763
+ meta: {
41764
+ phase,
41765
+ error: err instanceof Error ? err.message : String(err)
41766
+ }
41767
+ });
41768
+ return null;
41769
+ });
41770
+ const parsed = PanelReadSchema.safeParse(raw);
41771
+ if (!parsed.success) {
41772
+ if (raw !== null) logger.warn("alexa security-panel: panel status did not match the cap schema", {
41773
+ tags: { deviceId },
41774
+ meta: { phase }
41775
+ });
41776
+ return null;
41777
+ }
41778
+ return parsed.data;
41779
+ }
41780
+ /** The `context.properties` block for a panel whose state we re-read. */
41781
+ function panelProperties(facts, fallback) {
41782
+ const timeOfSample = (/* @__PURE__ */ new Date()).toISOString();
41783
+ const parsedState = facts === null ? null : AlarmPanelStatusSchema.shape.state.parse(facts.state);
41784
+ const armState = parsedState === null ? fallback : alexaArmStateFor(parsedState);
41785
+ const burglary = parsedState === null ? { value: "OK" } : burglaryAlarmFor(parsedState);
41786
+ return [{
41787
+ namespace: "Alexa.SecurityPanelController",
41788
+ name: "armState",
41789
+ value: armState,
41790
+ timeOfSample,
41791
+ uncertaintyInMilliseconds: 0
41792
+ }, {
41793
+ namespace: "Alexa.SecurityPanelController",
41794
+ name: "burglaryAlarm",
41795
+ value: burglary,
41796
+ timeOfSample,
41797
+ uncertaintyInMilliseconds: 0
41798
+ }];
41799
+ }
41800
+ async function handleArm(header, endpointRaw, payloadRaw, deps, _callerIdentity) {
41801
+ const target = resolveDeviceId(endpointRaw);
41802
+ if (!("deviceId" in target)) return errorEnvelope(header, "INVALID_DIRECTIVE", target.reason);
41803
+ const { deviceId } = target;
41804
+ const payload = ArmPayloadSchema.safeParse(payloadRaw);
41805
+ if (!payload.success) {
41806
+ deps.logger.warn("alexa security-panel: Arm payload is not an arm state Alexa defines", {
41807
+ tags: { deviceId },
41808
+ meta: { error: payload.error.message }
41809
+ });
41810
+ return errorEnvelope(header, "INVALID_DIRECTIVE", `Arm payload unexpected: ${payload.error.message}`);
41811
+ }
41812
+ const requested = payload.data.armState;
41813
+ const api = deps.ctx.api;
41814
+ const facts = await readPanel(api, deviceId, deps.logger, "arm");
41815
+ if (facts?.requiresCode === true) {
41816
+ deps.logger.warn("alexa security-panel: panel requires a PIN and the Arm directive carries none — REFUSED", {
41817
+ tags: { deviceId },
41818
+ meta: { requested }
41819
+ });
41820
+ return securityPanelError(header, "AUTHORIZATION_REQUIRED", "this panel requires a PIN to arm, and Alexa cannot supply one on an Arm directive");
41821
+ }
41822
+ const mode = alarmArmModeForAlexa(requested, facts?.availableModes ?? []);
41823
+ if (mode === null) {
41824
+ deps.logger.warn("alexa security-panel: panel accepts no mode for this arm state — REFUSED", {
41825
+ tags: { deviceId },
41826
+ meta: {
41827
+ requested,
41828
+ availableModes: facts?.availableModes ?? null
41829
+ }
41830
+ });
41831
+ return securityPanelError(header, "NOT_READY_FOR_ARM", `panel does not accept ${requested}`);
41832
+ }
41833
+ try {
41834
+ await api.alarmPanel.arm.mutate({
41835
+ deviceId,
41836
+ mode
41837
+ });
41838
+ } catch (err) {
41839
+ deps.logger.warn("alexa security-panel: arm command failed", {
41840
+ tags: { deviceId },
41841
+ meta: {
41842
+ requested,
41843
+ mode,
41844
+ error: err instanceof Error ? err.message : String(err)
41845
+ }
41846
+ });
41847
+ return securityPanelError(header, "NOT_READY_FOR_ARM", err instanceof Error ? err.message : String(err));
41848
+ }
41849
+ deps.logger.info("alexa security-panel: armed", {
41850
+ tags: { deviceId },
41851
+ meta: {
41852
+ requested,
41853
+ mode
41854
+ }
41855
+ });
41856
+ const after = await readPanel(api, deviceId, deps.logger, "arm-confirm");
41857
+ if (after?.state === "arming") deps.logger.info("alexa security-panel: panel is in its exit delay — reporting DISARMED until it is set", {
41858
+ tags: { deviceId },
41859
+ meta: {
41860
+ requested,
41861
+ mode
41862
+ }
41863
+ });
41864
+ return {
41865
+ event: {
41866
+ header: {
41867
+ namespace: "Alexa.SecurityPanelController",
41868
+ name: "Arm.Response",
41869
+ messageId: header.messageId,
41870
+ correlationToken: header.correlationToken,
41871
+ payloadVersion: "3"
41872
+ },
41873
+ endpoint: { endpointId: `${deviceId}__alarm` },
41874
+ payload: {}
41875
+ },
41876
+ context: { properties: panelProperties(after, requested) }
41877
+ };
41878
+ }
41879
+ async function handleDisarm(header, endpointRaw, payloadRaw, deps, _callerIdentity) {
41880
+ const target = resolveDeviceId(endpointRaw);
41881
+ if (!("deviceId" in target)) return errorEnvelope(header, "INVALID_DIRECTIVE", target.reason);
41882
+ const { deviceId } = target;
41883
+ const payload = DisarmPayloadSchema.safeParse(payloadRaw ?? {});
41884
+ if (!payload.success) return errorEnvelope(header, "INVALID_DIRECTIVE", `Disarm payload unexpected: ${payload.error.message}`);
41885
+ const code = payload.data.authorization?.value;
41886
+ const api = deps.ctx.api;
41887
+ if ((await readPanel(api, deviceId, deps.logger, "disarm"))?.requiresCode === true && code === void 0) {
41888
+ deps.logger.warn("alexa security-panel: panel requires a PIN and the Disarm directive carried none — REFUSED", { tags: { deviceId } });
41889
+ return securityPanelError(header, "AUTHORIZATION_REQUIRED", "this panel requires a PIN to disarm");
41890
+ }
41891
+ try {
41892
+ await api.alarmPanel.disarm.mutate(code === void 0 ? { deviceId } : {
41893
+ deviceId,
41894
+ code
41895
+ });
41896
+ } catch (err) {
41897
+ const message = err instanceof Error ? err.message : String(err);
41898
+ deps.logger.warn("alexa security-panel: disarm command failed", {
41899
+ tags: { deviceId },
41900
+ meta: {
41901
+ codeSupplied: code !== void 0,
41902
+ error: message
41903
+ }
41904
+ });
41905
+ return code === void 0 ? errorEnvelope(header, "INTERNAL_ERROR", message) : securityPanelError(header, "UNAUTHORIZED", message);
41906
+ }
41907
+ deps.logger.info("alexa security-panel: disarmed", {
41908
+ tags: { deviceId },
41909
+ meta: { codeSupplied: code !== void 0 }
41910
+ });
41911
+ const after = await readPanel(api, deviceId, deps.logger, "disarm-confirm");
41912
+ return {
41913
+ event: {
41914
+ header: {
41915
+ namespace: "Alexa",
41916
+ name: "Response",
41917
+ messageId: header.messageId,
41918
+ correlationToken: header.correlationToken,
41919
+ payloadVersion: "3"
41920
+ },
41921
+ endpoint: { endpointId: `${deviceId}__alarm` },
41922
+ payload: {}
41923
+ },
41924
+ context: { properties: panelProperties(after, "DISARMED") }
41925
+ };
41926
+ }
41927
+ //#endregion
41229
41928
  //#region src/handlers/stubs.ts
41230
41929
  /**
41231
41930
  * Stub responder for namespaces not yet implemented (CameraStream /
@@ -41305,7 +42004,9 @@ async function dispatchDirective(rawBody, deps, callerIdentity) {
41305
42004
  "Alexa.ModeController/SetMode": "create",
41306
42005
  "Alexa.ToggleController/TurnOn": "create",
41307
42006
  "Alexa.ToggleController/TurnOff": "create",
41308
- "Alexa/ReportState": "view"
42007
+ "Alexa/ReportState": "view",
42008
+ "Alexa.SecurityPanelController/Arm": "create",
42009
+ "Alexa.SecurityPanelController/Disarm": "create"
41309
42010
  };
41310
42011
  try {
41311
42012
  const key = `${header.namespace}/${header.name}`;
@@ -41350,6 +42051,14 @@ async function dispatchDirective(rawBody, deps, callerIdentity) {
41350
42051
  status: 200,
41351
42052
  body: await handlePtzDirection(header, endpoint, deps.ptzDirection, callerIdentity)
41352
42053
  };
42054
+ if (key === "Alexa.SecurityPanelController/Arm") return {
42055
+ status: 200,
42056
+ body: await handleArm(header, endpoint, payload, deps.securityPanel, callerIdentity)
42057
+ };
42058
+ if (key === "Alexa.SecurityPanelController/Disarm") return {
42059
+ status: 200,
42060
+ body: await handleDisarm(header, endpoint, payload, deps.securityPanel, callerIdentity)
42061
+ };
41353
42062
  if (key === "Alexa/ReportState") return {
41354
42063
  status: 200,
41355
42064
  body: await handleReportState(header, endpoint, deps.reportState, callerIdentity)
@@ -42906,7 +43615,13 @@ var DEFAULT_CONFIG = {
42906
43615
  enforceIngressToken: false,
42907
43616
  staleEndpointPurgeInput: ""
42908
43617
  };
42909
- var SUPPORTED_KINDS = ["camera", "doorbell"];
43618
+ /** UI filter for the Export picker. `alarm-panel` publishes an
43619
+ * `Alexa.SecurityPanelController` endpoint (`endpoints/builder.ts`). */
43620
+ var SUPPORTED_KINDS = [
43621
+ "camera",
43622
+ "doorbell",
43623
+ DeviceType.AlarmPanel
43624
+ ];
42910
43625
  var ExportAlexaAddon = class extends BaseAddon {
42911
43626
  lwaGateway = null;
42912
43627
  syncEngine = null;
@@ -42975,6 +43690,10 @@ var ExportAlexaAddon = class extends BaseAddon {
42975
43690
  reportState: {
42976
43691
  ctx: this.ctx,
42977
43692
  logger: this.ctx.logger
43693
+ },
43694
+ securityPanel: {
43695
+ ctx: this.ctx,
43696
+ logger: this.ctx.logger
42978
43697
  }
42979
43698
  },
42980
43699
  lwaTokensCallbackDeps: {
@@ -43029,6 +43748,11 @@ var ExportAlexaAddon = class extends BaseAddon {
43029
43748
  });
43030
43749
  });
43031
43750
  this.ctx.addDisposer(async () => offTunnelStarted());
43751
+ const reaper = setInterval(() => {
43752
+ this.reapDeadEgressHandles();
43753
+ }, EGRESS_REAP_INTERVAL_MS);
43754
+ reaper.unref?.();
43755
+ this.ctx.addDisposer(async () => clearInterval(reaper));
43032
43756
  this.ctx.logger.info("export-alexa: initialized", { meta: {
43033
43757
  exposedCount: this.config.exposed.length,
43034
43758
  skillMode: this.config.skillMode,
@@ -43683,6 +44407,37 @@ var ExportAlexaAddon = class extends BaseAddon {
43683
44407
  ]
43684
44408
  }] });
43685
44409
  }
44410
+ /**
44411
+ * One sweep: ask the broker which held sessions are still alive, and release
44412
+ * the transcodes the dead ones are still holding.
44413
+ */
44414
+ async reapDeadEgressHandles() {
44415
+ const held = heldEgressHandles();
44416
+ if (held.length === 0) return;
44417
+ const dead = await reapDeadEgressHandles({
44418
+ held,
44419
+ isAlive: async (handle) => {
44420
+ try {
44421
+ return (await this.ctx.api.webrtcSession.getSessionState.query({
44422
+ deviceId: handle.deviceId,
44423
+ sessionId: handle.sessionId
44424
+ })).alive;
44425
+ } catch {
44426
+ return null;
44427
+ }
44428
+ }
44429
+ });
44430
+ for (const handle of dead) {
44431
+ logReaped(this.ctx.logger, handle);
44432
+ forgetEgressHandle(handle);
44433
+ await releaseAlexaEgress({
44434
+ api: this.ctx.api,
44435
+ deviceId: handle.deviceId,
44436
+ pipelineKey: handle.pipelineKey,
44437
+ logger: this.ctx.logger
44438
+ }).catch(() => {});
44439
+ }
44440
+ }
43686
44441
  async buildDeviceSettingsContribution(deviceId) {
43687
44442
  if (!await this.isCameraDevice(deviceId)) return null;
43688
44443
  const entry = this.findEntry(deviceId);
@@ -43690,6 +44445,7 @@ var ExportAlexaAddon = class extends BaseAddon {
43690
44445
  const enabled = entry !== null;
43691
44446
  const enabledKey = `alexa:${deviceId}:enabled`;
43692
44447
  const streamPreferenceKey = `alexa:${deviceId}:streamPreference`;
44448
+ const allowTranscodeKey = `alexa:${deviceId}:allowTranscode`;
43693
44449
  const linkedCount = this.config.linkedAccounts.length;
43694
44450
  return {
43695
44451
  tabs: [{
@@ -43735,6 +44491,19 @@ var ExportAlexaAddon = class extends BaseAddon {
43735
44491
  equals: true
43736
44492
  },
43737
44493
  immediate: true
44494
+ },
44495
+ {
44496
+ type: "boolean",
44497
+ key: allowTranscodeKey,
44498
+ label: "Allow transcoding for Alexa",
44499
+ 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.",
44500
+ style: "switch",
44501
+ value: settings.allowTranscode !== false,
44502
+ showWhen: {
44503
+ field: enabledKey,
44504
+ equals: true
44505
+ },
44506
+ immediate: true
43738
44507
  }
43739
44508
  ]
43740
44509
  }]
@@ -43754,26 +44523,17 @@ var ExportAlexaAddon = class extends BaseAddon {
43754
44523
  const idStr = String(deviceId);
43755
44524
  const current = this.findEntry(deviceId);
43756
44525
  const wasEnabled = current !== null;
43757
- const sourceProfileKey = `alexa:${deviceId}:sourceProfile`;
43758
- if (sourceProfileKey in patch) {
43759
- if (!wasEnabled) return { success: true };
43760
- const currentSettings = current?.settings ?? DEFAULT_DEVICE_SETTINGS;
43761
- const sourceProfileRaw = patch[sourceProfileKey];
43762
- const sourceProfile = sourceProfileRaw === "mid" ? "mid" : sourceProfileRaw === "low" ? "low" : "high";
43763
- await this.patchEntrySettings(deviceId, {
43764
- ...currentSettings,
43765
- sourceProfile
43766
- });
43767
- return { success: true };
43768
- }
43769
44526
  const enabledKey = `alexa:${deviceId}:enabled`;
43770
44527
  const streamPreferenceKey = `alexa:${deviceId}:streamPreference`;
44528
+ const allowTranscodeKey = `alexa:${deviceId}:allowTranscode`;
43771
44529
  const enabledValue = enabledKey in patch ? Boolean(patch[enabledKey]) : wasEnabled;
43772
44530
  const streamPreferenceRaw = streamPreferenceKey in patch ? patch[streamPreferenceKey] : current?.settings?.streamPreference;
43773
44531
  const streamPreference = typeof streamPreferenceRaw === "string" && streamPreferenceRaw.trim().length > 0 ? streamPreferenceRaw : "auto";
44532
+ const allowTranscode = allowTranscodeKey in patch ? Boolean(patch[allowTranscodeKey]) : current?.settings?.allowTranscode !== false;
43774
44533
  const nextSettings = {
43775
44534
  ...current?.settings ?? DEFAULT_DEVICE_SETTINGS,
43776
- streamPreference
44535
+ streamPreference,
44536
+ allowTranscode
43777
44537
  };
43778
44538
  if (!enabledValue) {
43779
44539
  if (wasEnabled) await this.unexposeDevice(idStr);
@@ -43785,13 +44545,22 @@ var ExportAlexaAddon = class extends BaseAddon {
43785
44545
  return { success: true };
43786
44546
  }
43787
44547
  const currentPref = current?.settings?.streamPreference ?? "auto";
44548
+ const currentAllow = current?.settings?.allowTranscode !== false;
43788
44549
  await this.patchEntrySettings(deviceId, nextSettings);
43789
- if (currentPref !== streamPreference) {
43790
- this.ctx.logger.info("export-alexa: streamPreference changed — refreshing exposure", { meta: {
43791
- deviceId,
43792
- from: currentPref,
43793
- to: streamPreference
43794
- } });
44550
+ if (currentPref !== streamPreference || currentAllow !== allowTranscode) {
44551
+ this.ctx.logger.info("export-alexa: source settings changed — refreshing exposure", {
44552
+ tags: { deviceId },
44553
+ meta: {
44554
+ streamPreference: {
44555
+ from: currentPref,
44556
+ to: streamPreference
44557
+ },
44558
+ allowTranscode: {
44559
+ from: currentAllow,
44560
+ to: allowTranscode
44561
+ }
44562
+ }
44563
+ });
43795
44564
  try {
43796
44565
  await this.unexposeDevice(idStr);
43797
44566
  await this.exposeDevice(idStr);