@camstack/addon-export-alexa 1.2.135 → 1.2.137

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.
@@ -26,6 +26,8 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
26
26
  //#endregion
27
27
  let node_crypto = require("node:crypto");
28
28
  node_crypto = __toESM(node_crypto, 1);
29
+ let sharp = require("sharp");
30
+ sharp = __toESM(sharp);
29
31
  //#region ../types/dist/event-category-B_0XEcLF.mjs
30
32
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
31
33
  EventCategory["SystemBoot"] = "system.boot";
@@ -3789,7 +3791,7 @@ function initializeContext(params) {
3789
3791
  external: params?.external ?? void 0
3790
3792
  };
3791
3793
  }
3792
- function process(schema, ctx, _params = {
3794
+ function process$1(schema, ctx, _params = {
3793
3795
  path: [],
3794
3796
  schemaPath: []
3795
3797
  }) {
@@ -3826,7 +3828,7 @@ function process(schema, ctx, _params = {
3826
3828
  const parent = schema._zod.parent;
3827
3829
  if (parent) {
3828
3830
  if (!result.ref) result.ref = parent;
3829
- process(parent, ctx, params);
3831
+ process$1(parent, ctx, params);
3830
3832
  ctx.seen.get(parent).isParent = true;
3831
3833
  }
3832
3834
  }
@@ -4046,7 +4048,7 @@ var createToJSONSchemaMethod = (schema, processors = {}) => (params) => {
4046
4048
  ...params,
4047
4049
  processors
4048
4050
  });
4049
- process(schema, ctx);
4051
+ process$1(schema, ctx);
4050
4052
  extractDefs(ctx, schema);
4051
4053
  return finalize(ctx, schema);
4052
4054
  };
@@ -4058,7 +4060,7 @@ var createStandardJSONSchemaMethod = (schema, io, processors = {}) => (params) =
4058
4060
  io,
4059
4061
  processors
4060
4062
  });
4061
- process(schema, ctx);
4063
+ process$1(schema, ctx);
4062
4064
  extractDefs(ctx, schema);
4063
4065
  return finalize(ctx, schema);
4064
4066
  };
@@ -4172,7 +4174,7 @@ var arrayProcessor = (schema, ctx, _json, params) => {
4172
4174
  if (typeof minimum === "number") json.minItems = minimum;
4173
4175
  if (typeof maximum === "number") json.maxItems = maximum;
4174
4176
  json.type = "array";
4175
- json.items = process(def.element, ctx, {
4177
+ json.items = process$1(def.element, ctx, {
4176
4178
  ...params,
4177
4179
  path: [...params.path, "items"]
4178
4180
  });
@@ -4183,7 +4185,7 @@ var objectProcessor = (schema, ctx, _json, params) => {
4183
4185
  json.type = "object";
4184
4186
  json.properties = {};
4185
4187
  const shape = def.shape;
4186
- for (const key in shape) json.properties[key] = process(shape[key], ctx, {
4188
+ for (const key in shape) json.properties[key] = process$1(shape[key], ctx, {
4187
4189
  ...params,
4188
4190
  path: [
4189
4191
  ...params.path,
@@ -4201,7 +4203,7 @@ var objectProcessor = (schema, ctx, _json, params) => {
4201
4203
  if (def.catchall?._zod.def.type === "never") json.additionalProperties = false;
4202
4204
  else if (!def.catchall) {
4203
4205
  if (ctx.io === "output") json.additionalProperties = false;
4204
- } else if (def.catchall) json.additionalProperties = process(def.catchall, ctx, {
4206
+ } else if (def.catchall) json.additionalProperties = process$1(def.catchall, ctx, {
4205
4207
  ...params,
4206
4208
  path: [...params.path, "additionalProperties"]
4207
4209
  });
@@ -4209,7 +4211,7 @@ var objectProcessor = (schema, ctx, _json, params) => {
4209
4211
  var unionProcessor = (schema, ctx, json, params) => {
4210
4212
  const def = schema._zod.def;
4211
4213
  const isExclusive = def.inclusive === false;
4212
- const options = def.options.map((x, i) => process(x, ctx, {
4214
+ const options = def.options.map((x, i) => process$1(x, ctx, {
4213
4215
  ...params,
4214
4216
  path: [
4215
4217
  ...params.path,
@@ -4222,7 +4224,7 @@ var unionProcessor = (schema, ctx, json, params) => {
4222
4224
  };
4223
4225
  var intersectionProcessor = (schema, ctx, json, params) => {
4224
4226
  const def = schema._zod.def;
4225
- const a = process(def.left, ctx, {
4227
+ const a = process$1(def.left, ctx, {
4226
4228
  ...params,
4227
4229
  path: [
4228
4230
  ...params.path,
@@ -4230,7 +4232,7 @@ var intersectionProcessor = (schema, ctx, json, params) => {
4230
4232
  0
4231
4233
  ]
4232
4234
  });
4233
- const b = process(def.right, ctx, {
4235
+ const b = process$1(def.right, ctx, {
4234
4236
  ...params,
4235
4237
  path: [
4236
4238
  ...params.path,
@@ -4247,7 +4249,7 @@ var tupleProcessor = (schema, ctx, _json, params) => {
4247
4249
  json.type = "array";
4248
4250
  const prefixPath = ctx.target === "draft-2020-12" ? "prefixItems" : "items";
4249
4251
  const restPath = ctx.target === "draft-2020-12" ? "items" : ctx.target === "openapi-3.0" ? "items" : "additionalItems";
4250
- const prefixItems = def.items.map((x, i) => process(x, ctx, {
4252
+ const prefixItems = def.items.map((x, i) => process$1(x, ctx, {
4251
4253
  ...params,
4252
4254
  path: [
4253
4255
  ...params.path,
@@ -4255,7 +4257,7 @@ var tupleProcessor = (schema, ctx, _json, params) => {
4255
4257
  i
4256
4258
  ]
4257
4259
  }));
4258
- const rest = def.rest ? process(def.rest, ctx, {
4260
+ const rest = def.rest ? process$1(def.rest, ctx, {
4259
4261
  ...params,
4260
4262
  path: [
4261
4263
  ...params.path,
@@ -4286,7 +4288,7 @@ var recordProcessor = (schema, ctx, _json, params) => {
4286
4288
  const keyType = def.keyType;
4287
4289
  const patterns = keyType._zod.bag?.patterns;
4288
4290
  if (def.mode === "loose" && patterns && patterns.size > 0) {
4289
- const valueSchema = process(def.valueType, ctx, {
4291
+ const valueSchema = process$1(def.valueType, ctx, {
4290
4292
  ...params,
4291
4293
  path: [
4292
4294
  ...params.path,
@@ -4297,11 +4299,11 @@ var recordProcessor = (schema, ctx, _json, params) => {
4297
4299
  json.patternProperties = {};
4298
4300
  for (const pattern of patterns) json.patternProperties[pattern.source] = valueSchema;
4299
4301
  } else {
4300
- if (ctx.target === "draft-07" || ctx.target === "draft-2020-12") json.propertyNames = process(def.keyType, ctx, {
4302
+ if (ctx.target === "draft-07" || ctx.target === "draft-2020-12") json.propertyNames = process$1(def.keyType, ctx, {
4301
4303
  ...params,
4302
4304
  path: [...params.path, "propertyNames"]
4303
4305
  });
4304
- json.additionalProperties = process(def.valueType, ctx, {
4306
+ json.additionalProperties = process$1(def.valueType, ctx, {
4305
4307
  ...params,
4306
4308
  path: [...params.path, "additionalProperties"]
4307
4309
  });
@@ -4314,7 +4316,7 @@ var recordProcessor = (schema, ctx, _json, params) => {
4314
4316
  };
4315
4317
  var nullableProcessor = (schema, ctx, json, params) => {
4316
4318
  const def = schema._zod.def;
4317
- const inner = process(def.innerType, ctx, params);
4319
+ const inner = process$1(def.innerType, ctx, params);
4318
4320
  const seen = ctx.seen.get(schema);
4319
4321
  if (ctx.target === "openapi-3.0") {
4320
4322
  seen.ref = def.innerType;
@@ -4323,27 +4325,27 @@ var nullableProcessor = (schema, ctx, json, params) => {
4323
4325
  };
4324
4326
  var nonoptionalProcessor = (schema, ctx, _json, params) => {
4325
4327
  const def = schema._zod.def;
4326
- process(def.innerType, ctx, params);
4328
+ process$1(def.innerType, ctx, params);
4327
4329
  const seen = ctx.seen.get(schema);
4328
4330
  seen.ref = def.innerType;
4329
4331
  };
4330
4332
  var defaultProcessor = (schema, ctx, json, params) => {
4331
4333
  const def = schema._zod.def;
4332
- process(def.innerType, ctx, params);
4334
+ process$1(def.innerType, ctx, params);
4333
4335
  const seen = ctx.seen.get(schema);
4334
4336
  seen.ref = def.innerType;
4335
4337
  json.default = JSON.parse(JSON.stringify(def.defaultValue));
4336
4338
  };
4337
4339
  var prefaultProcessor = (schema, ctx, json, params) => {
4338
4340
  const def = schema._zod.def;
4339
- process(def.innerType, ctx, params);
4341
+ process$1(def.innerType, ctx, params);
4340
4342
  const seen = ctx.seen.get(schema);
4341
4343
  seen.ref = def.innerType;
4342
4344
  if (ctx.io === "input") json._prefault = JSON.parse(JSON.stringify(def.defaultValue));
4343
4345
  };
4344
4346
  var catchProcessor = (schema, ctx, json, params) => {
4345
4347
  const def = schema._zod.def;
4346
- process(def.innerType, ctx, params);
4348
+ process$1(def.innerType, ctx, params);
4347
4349
  const seen = ctx.seen.get(schema);
4348
4350
  seen.ref = def.innerType;
4349
4351
  let catchValue;
@@ -4358,26 +4360,26 @@ var pipeProcessor = (schema, ctx, _json, params) => {
4358
4360
  const def = schema._zod.def;
4359
4361
  const inIsTransform = def.in._zod.traits.has("$ZodTransform");
4360
4362
  const innerType = ctx.io === "input" ? inIsTransform ? def.out : def.in : def.out;
4361
- process(innerType, ctx, params);
4363
+ process$1(innerType, ctx, params);
4362
4364
  const seen = ctx.seen.get(schema);
4363
4365
  seen.ref = innerType;
4364
4366
  };
4365
4367
  var readonlyProcessor = (schema, ctx, json, params) => {
4366
4368
  const def = schema._zod.def;
4367
- process(def.innerType, ctx, params);
4369
+ process$1(def.innerType, ctx, params);
4368
4370
  const seen = ctx.seen.get(schema);
4369
4371
  seen.ref = def.innerType;
4370
4372
  json.readOnly = true;
4371
4373
  };
4372
4374
  var optionalProcessor = (schema, ctx, _json, params) => {
4373
4375
  const def = schema._zod.def;
4374
- process(def.innerType, ctx, params);
4376
+ process$1(def.innerType, ctx, params);
4375
4377
  const seen = ctx.seen.get(schema);
4376
4378
  seen.ref = def.innerType;
4377
4379
  };
4378
4380
  var lazyProcessor = (schema, ctx, _json, params) => {
4379
4381
  const innerType = schema._zod.innerType;
4380
- process(innerType, ctx, params);
4382
+ process$1(innerType, ctx, params);
4381
4383
  const seen = ctx.seen.get(schema);
4382
4384
  seen.ref = innerType;
4383
4385
  };
@@ -7468,9 +7470,9 @@ function errMsg(err) {
7468
7470
  */
7469
7471
  /** The pinned jellyfin-ffmpeg release. Changing this is the version change. */
7470
7472
  var FFMPEG_RELEASE = "8.1.2-5";
7471
- var BASE = `https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v${FFMPEG_RELEASE}`;
7473
+ var BASE$1 = `https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v${FFMPEG_RELEASE}`;
7472
7474
  function portable(target) {
7473
- return `${BASE}/jellyfin-ffmpeg_${FFMPEG_RELEASE}_portable_${target}-gpl.tar.xz`;
7475
+ return `${BASE$1}/jellyfin-ffmpeg_${FFMPEG_RELEASE}_portable_${target}-gpl.tar.xz`;
7474
7476
  }
7475
7477
  portable("linux64"), portable("linuxarm64"), portable("macarm64"), portable("mac64");
7476
7478
  /**
@@ -26352,7 +26354,7 @@ method(SendEmailInputSchema, SendEmailResultSchema, {
26352
26354
  * the device-native provider (onboard capture) or from the stream-broker
26353
26355
  * prebuffer fallback.
26354
26356
  */
26355
- var SnapshotImageSchema = object({
26357
+ var SnapshotImageSchema$1 = object({
26356
26358
  base64: string(),
26357
26359
  contentType: string()
26358
26360
  });
@@ -26384,7 +26386,7 @@ DeviceType.Camera, method(object({
26384
26386
  * correct answer: stale but honest beats woken.
26385
26387
  */
26386
26388
  force: boolean().optional()
26387
- }), SnapshotImageSchema.nullable()), method(object({ deviceId: number() }), _void(), {
26389
+ }), SnapshotImageSchema$1.nullable()), method(object({ deviceId: number() }), _void(), {
26388
26390
  kind: "mutation",
26389
26391
  auth: "admin"
26390
26392
  }), systemMethod(object({ deviceIds: array(number()).min(1).max(200) }), array(object({
@@ -28432,7 +28434,22 @@ DeviceType.Camera, method(object({ deviceId: number().int().nonnegative() }), ar
28432
28434
  * field their hints were silently stripped by the schema and the
28433
28435
  * adaptive ladder started blind on the client's display size.
28434
28436
  */
28435
- hints: webrtcClientHintsSchema.optional()
28437
+ hints: webrtcClientHintsSchema.optional(),
28438
+ /**
28439
+ * Talk-back (D771): relay the PEER's audio to the device's speaker
28440
+ * through the `intercom` cap's push-talk triple. When `true` and the
28441
+ * offer lets the peer send audio, the answer's audio is `sendrecv`
28442
+ * and every inbound frame goes to `intercom.pushTalkAudio` for this
28443
+ * `deviceId`; absent/false answers `sendonly`, as for every viewer.
28444
+ *
28445
+ * TRUSTED-CALLER ONLY. Set by the Alexa export for an Echo on a camera
28446
+ * with `intercom`, when the linked user may control that camera. The
28447
+ * hub STRIPS it from every request-bound call (a browser), exactly as
28448
+ * it overwrites `relayOnly` — a viewer's talk permission is the
28449
+ * intercom cap's own surface (share tokens gate it there), and this
28450
+ * flag must not become a second door to the camera's speaker.
28451
+ */
28452
+ talkback: boolean().optional()
28436
28453
  }), object({
28437
28454
  sessionId: string(),
28438
28455
  sdpAnswer: string()
@@ -28734,6 +28751,59 @@ var AddonAutoUpdateSchema = ChannelWithInheritSchema;
28734
28751
  var RestartAddonResultSchema = unknown();
28735
28752
  var InstallPackageResultSchema = unknown();
28736
28753
  var ReloadPackagesResultSchema = unknown();
28754
+ /**
28755
+ * What an uninstall did on ONE node.
28756
+ * - `uninstalled` — removed: runner(s) stopped, caps gone, code deleted.
28757
+ * - `not-installed` — the node did not have it (nothing to do).
28758
+ * - `refused` — protected / bootstrap package; nothing touched.
28759
+ * - `failed` — attempted and did not complete; `error` says why.
28760
+ * - `pending-reconnect` — the node is OFFLINE: its record was pruned so the
28761
+ * back-fill never returns the package, and the stale-addon sweep removes the
28762
+ * copy when the node registers again (only once the hub no longer has it).
28763
+ */
28764
+ var UninstallNodeOutcomeSchema = _enum([
28765
+ "uninstalled",
28766
+ "not-installed",
28767
+ "refused",
28768
+ "failed",
28769
+ "pending-reconnect"
28770
+ ]);
28771
+ var UninstallNodeResultSchema = object({
28772
+ nodeId: string(),
28773
+ outcome: UninstallNodeOutcomeSchema,
28774
+ error: string().optional(),
28775
+ addonIds: array(string()).readonly(),
28776
+ runnersStopped: array(string()).readonly(),
28777
+ warnings: array(string()).readonly()
28778
+ });
28779
+ var UninstallPackageResultSchema = object({
28780
+ /** Every targeted node ended `uninstalled`, `not-installed` or `pending-reconnect`. */
28781
+ success: boolean(),
28782
+ packageName: string(),
28783
+ results: array(UninstallNodeResultSchema).readonly()
28784
+ });
28785
+ /**
28786
+ * `unreachable` lists online agents whose status read FAILED — absence from
28787
+ * `installations` is only "not installed" for a node that answered (D315).
28788
+ */
28789
+ var PackageInstallationsSchema = object({
28790
+ installations: array(object({
28791
+ packageName: string(),
28792
+ nodeId: string(),
28793
+ nodeName: string(),
28794
+ isHub: boolean(),
28795
+ isOnline: boolean(),
28796
+ version: string().nullable(),
28797
+ addonIds: array(string()).readonly(),
28798
+ source: _enum(["live", "recorded"]),
28799
+ /** The hub installs this package too. `false` on an agent row = an orphan. */
28800
+ onHub: boolean()
28801
+ })).readonly(),
28802
+ unreachable: array(object({
28803
+ nodeId: string(),
28804
+ error: string()
28805
+ })).readonly()
28806
+ });
28737
28807
  var BulkUpdateItemStatusSchema = _enum([
28738
28808
  "queued",
28739
28809
  "updating",
@@ -28816,10 +28886,14 @@ method(_void(), array(AddonListItemSchema).readonly()), method(object({
28816
28886
  }), method(object({ packageName: string() }), InstallPackageResultSchema, {
28817
28887
  kind: "mutation",
28818
28888
  auth: "admin"
28819
- }), method(_void(), boolean()), method(_void(), array(unknown()).readonly()), method(object({ packageName: string() }), unknown(), {
28889
+ }), method(_void(), boolean()), method(_void(), array(unknown()).readonly()), method(object({
28890
+ packageName: string().min(1),
28891
+ nodeId: string().optional(),
28892
+ everywhere: boolean().optional()
28893
+ }), UninstallPackageResultSchema, {
28820
28894
  kind: "mutation",
28821
28895
  auth: "admin"
28822
- }), method(_void(), ReloadPackagesResultSchema, {
28896
+ }), method(object({ packageName: string().optional() }), PackageInstallationsSchema, { auth: "admin" }), method(_void(), ReloadPackagesResultSchema, {
28823
28897
  kind: "mutation",
28824
28898
  auth: "admin"
28825
28899
  }), method(object({ query: string().optional() }), array(SearchResultSchema)), method(object({ nodeId: string().optional() }), array(PackageUpdateSchema).readonly(), { auth: "admin" }), method(object({
@@ -29162,7 +29236,19 @@ object({
29162
29236
  lastChangedAt: number()
29163
29237
  });
29164
29238
  DeviceType.Sensor;
29165
- object({
29239
+ /**
29240
+ * Dimmable-light brightness control. Co-exists with `switch` on the
29241
+ * same device — the switch toggles on/off, this cap sets the level
29242
+ * applied when the light is on. Drivers map their per-vendor dim
29243
+ * controls to this single-method surface.
29244
+ *
29245
+ * The cap is intentionally minimal: a single `setBrightness({deviceId,
29246
+ * percentage})` mutation plus the auto-injected `getStatus`. Drivers
29247
+ * that expose richer controls (color temperature, scenes, schedules)
29248
+ * should surface those via the device's `getSettingsUISchema()`
29249
+ * instead of bloating this cap.
29250
+ */
29251
+ var BrightnessStatusSchema = object({
29166
29252
  /** Current level as 0..100 inclusive. Firmware-reported. */
29167
29253
  percentage: number().min(0).max(100),
29168
29254
  /** Ms epoch of the last operator-driven change. Useful for UI freshness. */
@@ -29787,7 +29873,19 @@ Object.values(DeviceType), method(object({
29787
29873
  kind: "mutation",
29788
29874
  auth: "admin"
29789
29875
  }), ConsumablesStatusSchema.extend({ lastFetchedAt: number() });
29790
- object({
29876
+ /**
29877
+ * Door / window / opening / garage / valve contact sensor. Boolean
29878
+ * "is the entry currently open" with the timestamp of the last
29879
+ * transition. Drives Home Assistant `binary_sensor` entries whose
29880
+ * `device_class` is `door`, `window`, `opening`, `garage`, or
29881
+ * `garage_door` — and any future native integration that needs
29882
+ * the same semantics.
29883
+ *
29884
+ * Push-driven: providers update the slice on transition events from
29885
+ * the upstream source (HA WebSocket `state_changed`, ZWave
29886
+ * `notification` …). Consumers read the slice; no polling.
29887
+ */
29888
+ var ContactStatusSchema = object({
29791
29889
  /** True when the entry is open; false when closed. */
29792
29890
  entryOpen: boolean(),
29793
29891
  /** Ms epoch of the last open↔closed transition. 0 if never observed. */
@@ -30609,7 +30707,15 @@ DeviceType.Humidifier, method(object({
30609
30707
  kind: "mutation",
30610
30708
  auth: "protected"
30611
30709
  });
30612
- object({
30710
+ /**
30711
+ * Single-metric humidity reading. Drives Home Assistant `sensor`
30712
+ * entries with `device_class: humidity`.
30713
+ *
30714
+ * Unit normalisation: percent. The canonical display unit (`%`) is a
30715
+ * descriptor constant in the UI (ROLE_DESCRIPTOR), not stored in
30716
+ * `sourceInfo`.
30717
+ */
30718
+ var HumiditySensorStatusSchema = object({
30613
30719
  /** Current relative humidity, 0..100. */
30614
30720
  percent: number().min(0).max(100),
30615
30721
  /** Ms epoch when the slice was last updated. */
@@ -31416,7 +31522,7 @@ method(_void(), ListResultSchema), method(_void(), PreferredSchema), method(obje
31416
31522
  kind: "mutation",
31417
31523
  auth: "admin"
31418
31524
  });
31419
- object({
31525
+ var LockControlStatusSchema = object({
31420
31526
  /** Lifecycle state of the lock. `jammed` means the motor reported
31421
31527
  * failure to reach the target — operator intervention required. */
31422
31528
  state: _enum([
@@ -31754,7 +31860,7 @@ authKey: string().optional() }), object({
31754
31860
  /** Human-readable error when `ok: false`. */
31755
31861
  error: string().optional()
31756
31862
  }), { kind: "mutation" });
31757
- object({
31863
+ var MotionStatusSchema = object({
31758
31864
  detected: boolean(),
31759
31865
  /** Ms epoch of the last detected-true observation. Null if never detected. */
31760
31866
  lastDetectedAt: number().nullable(),
@@ -33299,7 +33405,7 @@ var GpsLocationSchema = object({
33299
33405
  /** Reported accuracy in meters (lower = better). */
33300
33406
  accuracyMeters: number().nonnegative()
33301
33407
  });
33302
- object({
33408
+ var PresenceStatusSchema = object({
33303
33409
  /** `home` / `not_home` / any user-defined zone name. */
33304
33410
  state: string(),
33305
33411
  /** Optional textual location label (zone name, city, address). Null
@@ -35721,7 +35827,14 @@ DeviceType.Camera, method(object({ deviceId: number() }), StreamParamsOptionsSch
35721
35827
  kind: "mutation",
35722
35828
  auth: "admin"
35723
35829
  }), method(object({ deviceId: number() }), unknown().nullable());
35724
- object({
35830
+ /**
35831
+ * Generic on/off switch cap for accessory children (siren, floodlight,
35832
+ * spotlight, PIR toggle, chime silencer, autotrack enable, …). The cap
35833
+ * owns ONLY the boolean state — driver-specific settings (brightness,
35834
+ * sensitivity, schedule, duration) live on the device's own
35835
+ * `getSettingsUISchema()` surface.
35836
+ */
35837
+ var SwitchStatusSchema = object({
35725
35838
  on: boolean(),
35726
35839
  /** Ms epoch of the last state change. Useful for UI "X minutes ago". */
35727
35840
  lastChangedAt: number()
@@ -36262,7 +36375,23 @@ object({
36262
36375
  lastChangedAt: number()
36263
36376
  });
36264
36377
  DeviceType.Sensor;
36265
- object({
36378
+ /**
36379
+ * Single-metric temperature reading. Drives Home Assistant `sensor`
36380
+ * entries with `device_class: temperature` and any future native
36381
+ * thermometer.
36382
+ *
36383
+ * Unit normalisation: providers convert to Celsius before storing.
36384
+ * The slice value is always Celsius so cross-cap aggregators
36385
+ * (climate-control's `currentTemp`, energy analytics) can compose
36386
+ * without per-source unit fixups. The canonical display unit (`°C`) is
36387
+ * a descriptor constant in the UI (ROLE_DESCRIPTOR), not stored in
36388
+ * `sourceInfo`.
36389
+ *
36390
+ * Status `lastFetchedAt` lets staleness-aware consumers detect a
36391
+ * frozen feed (provider hung) distinct from a "temperature hasn't
36392
+ * changed" steady state.
36393
+ */
36394
+ var TemperatureSensorStatusSchema = object({
36266
36395
  /** Current temperature in Celsius. */
36267
36396
  celsius: number(),
36268
36397
  /** Ms epoch when the slice was last updated (push or poll). */
@@ -37108,6 +37237,12 @@ Object.freeze({
37108
37237
  addonId: null,
37109
37238
  access: "view"
37110
37239
  },
37240
+ "addons.listInstallations": {
37241
+ capName: "addons",
37242
+ capScope: "system",
37243
+ addonId: null,
37244
+ access: "view"
37245
+ },
37111
37246
  "addons.listJobs": {
37112
37247
  capName: "addons",
37113
37248
  capScope: "system",
@@ -49197,6 +49332,76 @@ DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
49197
49332
  DEFAULT_NATIVE_LEASE_SETTINGS.tileBudgetMb;
49198
49333
  DEFAULT_NATIVE_LEASE_SETTINGS.sceneBudgetMb;
49199
49334
  DEFAULT_NATIVE_LEASE_SETTINGS.admission;
49335
+ /** Ancestor-walk bound — defence-in-depth against a corrupt parentage cycle. */
49336
+ var MAX_ANCESTOR_HOPS$1 = 8;
49337
+ /**
49338
+ * Does `selector` cover `device` DIRECTLY (no inheritance)? Inheritance is
49339
+ * layered on by the resolver/matcher, which tests this against a device's
49340
+ * ancestors too.
49341
+ */
49342
+ function deviceSelectorMatches(selector, device) {
49343
+ switch (selector.kind) {
49344
+ case "all": return true;
49345
+ case "ids": return selector.ids.includes(device.id);
49346
+ case "types": return selector.types.some((t) => String(t) === device.type);
49347
+ case "locations": return device.location !== null && selector.locations.includes(device.location);
49348
+ }
49349
+ }
49350
+ /** Whether a `category:device` grant covers `access` (the broad lever). */
49351
+ function hasBroadDeviceGrant(scopes, access) {
49352
+ return scopes.some((s) => s.type === "category" && s.target === "device" && s.access.includes(access));
49353
+ }
49354
+ /** The `device`-typed scopes granting `access`. */
49355
+ function deviceScopesFor(scopes, access) {
49356
+ return scopes.filter((s) => s.type === "device" && s.access.includes(access));
49357
+ }
49358
+ /** Whether inheritance applies for a grant at `access` (view inherits, others
49359
+ * don't, unless the grant overrides via `includeLinked`). */
49360
+ function scopeInherits(scope, access) {
49361
+ return scope.includeLinked ?? access === "view";
49362
+ }
49363
+ /** Ancestors (parent, grandparent, …) of `deviceId` within a fleet map. */
49364
+ function ancestorsOf(deviceId, byId) {
49365
+ const out = [];
49366
+ let current = deviceId;
49367
+ for (let hop = 0; hop < MAX_ANCESTOR_HOPS$1; hop++) {
49368
+ const parent = current === null ? null : byId.get(current)?.parentDeviceId ?? null;
49369
+ if (parent === null || parent === current) break;
49370
+ out.push(parent);
49371
+ current = parent;
49372
+ }
49373
+ return out;
49374
+ }
49375
+ /**
49376
+ * Resolve the set of deviceIds the caller may reach at `access`, expanded
49377
+ * against the live `fleet`. A `category:device` grant returns the whole
49378
+ * fleet. Device selectors are matched per device — directly, or (when the
49379
+ * grant inherits) against an ancestor, so a parent grant covers its
49380
+ * accessories.
49381
+ */
49382
+ function resolveViewableDeviceIds(scopes, fleet, access) {
49383
+ if (hasBroadDeviceGrant(scopes, access)) return new Set(fleet.map((d) => d.id));
49384
+ const byId = /* @__PURE__ */ new Map();
49385
+ for (const d of fleet) byId.set(d.id, d);
49386
+ const grants = deviceScopesFor(scopes, access);
49387
+ const out = /* @__PURE__ */ new Set();
49388
+ for (const d of fleet) for (const grant of grants) {
49389
+ if (deviceSelectorMatches(grant.selector, d)) {
49390
+ out.add(d.id);
49391
+ break;
49392
+ }
49393
+ if (scopeInherits(grant, access)) {
49394
+ if (ancestorsOf(d.id, byId).some((ancestorId) => {
49395
+ const ancestor = byId.get(ancestorId);
49396
+ return ancestor !== void 0 && deviceSelectorMatches(grant.selector, ancestor);
49397
+ })) {
49398
+ out.add(d.id);
49399
+ break;
49400
+ }
49401
+ }
49402
+ }
49403
+ return out;
49404
+ }
49200
49405
  var MB = 1024 * 1024;
49201
49406
  1024 * MB, 3072 * MB;
49202
49407
  //#endregion
@@ -49217,17 +49422,29 @@ var MB = 1024 * 1024;
49217
49422
  * `/addon/export-alexa/admin/redetect-hub-url` route.
49218
49423
  */
49219
49424
  var exportAlexaActions = defineCustomActions({
49220
- redetectHubUrl: customAction(object({}).optional(), object({ publicHubUrl: string() }), { kind: "mutation" }),
49221
- regenerateIngressToken: customAction(object({}).optional(), object({ ingressToken: string() }), { kind: "mutation" }),
49425
+ redetectHubUrl: customAction(object({}).optional(), object({ publicHubUrl: string() }), {
49426
+ kind: "mutation",
49427
+ auth: "admin"
49428
+ }),
49429
+ regenerateIngressToken: customAction(object({}).optional(), object({ ingressToken: string() }), {
49430
+ kind: "mutation",
49431
+ auth: "admin"
49432
+ }),
49222
49433
  resyncDiscovery: customAction(object({}).optional(), object({
49223
49434
  endpointCount: number(),
49224
49435
  accountCount: number()
49225
- }), { kind: "mutation" }),
49436
+ }), {
49437
+ kind: "mutation",
49438
+ auth: "admin"
49439
+ }),
49226
49440
  purgeStaleEndpoints: customAction(object({}).optional(), object({
49227
49441
  requested: number(),
49228
49442
  accountCount: number(),
49229
49443
  attemptedAccounts: number()
49230
- }), { kind: "mutation" }),
49444
+ }), {
49445
+ kind: "mutation",
49446
+ auth: "admin"
49447
+ }),
49231
49448
  /**
49232
49449
  * Sever the Amazon account link from this side — the only severance path
49233
49450
  * that exists, because the descriptor sets `refreshTokenTtlSec: 'never'`
@@ -49244,16 +49461,31 @@ var exportAlexaActions = defineCustomActions({
49244
49461
  unlinkAlexaAccounts: customAction(object({}).optional(), object({
49245
49462
  revokedSessions: number(),
49246
49463
  removedAccounts: number()
49247
- }), { kind: "mutation" }),
49464
+ }), {
49465
+ kind: "mutation",
49466
+ auth: "admin"
49467
+ }),
49248
49468
  /**
49249
49469
  * Swap the per-link credential this hub presents to the skill Lambda's
49250
49470
  * token broker (D753) for a fresh one; the old one dies at once. Rotation
49251
49471
  * also happens implicitly on every re-link.
49252
49472
  */
49473
+ /**
49474
+ * The ingress token, in clear, for an administrator who asked for it. The
49475
+ * one read surface that returns it: `deviceExport.getStatus` and the
49476
+ * settings form carry only a mask.
49477
+ */
49478
+ revealIngressToken: customAction(object({}).optional(), object({ ingressToken: string() }), {
49479
+ kind: "mutation",
49480
+ auth: "admin"
49481
+ }),
49253
49482
  rotateBrokerCredential: customAction(object({}).optional(), object({
49254
49483
  rotated: number(),
49255
49484
  failed: number()
49256
- }), { kind: "mutation" })
49485
+ }), {
49486
+ kind: "mutation",
49487
+ auth: "admin"
49488
+ })
49257
49489
  });
49258
49490
  //#endregion
49259
49491
  //#region src/egress/alexa-rtc-egress.ts
@@ -49393,6 +49625,21 @@ async function profileForCamStream(input) {
49393
49625
  }
49394
49626
  //#endregion
49395
49627
  //#region src/handlers/endpoint-id.ts
49628
+ /** The classes a camera publishes a detection sensor for (R1). */
49629
+ var DETECTION_SENSOR_CLASSES = [
49630
+ "person",
49631
+ "vehicle",
49632
+ "animal",
49633
+ "package"
49634
+ ];
49635
+ var DETECTION_PREFIX = "det_";
49636
+ function isDetectionSensorClass(value) {
49637
+ return DETECTION_SENSOR_CLASSES.includes(value);
49638
+ }
49639
+ /** The endpoint id of a camera's detection sensor for one class. */
49640
+ function detectionSensorEndpointId(deviceId, cls) {
49641
+ return `${deviceId}${SEPARATOR}${DETECTION_PREFIX}${cls}`;
49642
+ }
49396
49643
  var SEPARATOR = "__";
49397
49644
  function parseCameraEndpointId(raw) {
49398
49645
  if (!raw.includes(SEPARATOR)) {
@@ -49428,6 +49675,22 @@ function parseCameraEndpointId(raw) {
49428
49675
  kind: "alarm-panel",
49429
49676
  deviceId: parent
49430
49677
  };
49678
+ if (tail === "dev") return {
49679
+ kind: "generic",
49680
+ deviceId: parent
49681
+ };
49682
+ if (tail.startsWith(DETECTION_PREFIX)) {
49683
+ const cls = tail.slice(4);
49684
+ if (isDetectionSensorClass(cls)) return {
49685
+ kind: "detection",
49686
+ deviceId: parent,
49687
+ detectionClass: cls
49688
+ };
49689
+ return {
49690
+ kind: "invalid",
49691
+ reason: `unknown detection class in endpointId: ${raw}`
49692
+ };
49693
+ }
49431
49694
  const child = parseNonNegativeInt(tail);
49432
49695
  if (child === null) return {
49433
49696
  kind: "invalid",
@@ -49609,7 +49872,12 @@ function resolveWebrtcTarget(pref) {
49609
49872
  camStreamId: pref
49610
49873
  };
49611
49874
  }
49612
- async function handleInitiateSessionWithOffer(header, endpointRaw, payloadRaw, deps, _callerIdentity) {
49875
+ /** The cap whose push-talk triple receives the Echo's microphone. */
49876
+ var CAP_INTERCOM$1 = "intercom";
49877
+ async function deviceHasIntercom(api, deviceId) {
49878
+ return (await api.deviceManager.getBindings.query({ deviceId })).entries.some((e) => e.capName === CAP_INTERCOM$1);
49879
+ }
49880
+ async function handleInitiateSessionWithOffer(header, endpointRaw, payloadRaw, deps, _callerIdentity, grant = { mayTalk: false }) {
49613
49881
  const parsed = InitiateSessionWithOfferDirectiveSchema.safeParse({
49614
49882
  endpoint: endpointRaw,
49615
49883
  payload: payloadRaw ?? {}
@@ -49718,12 +49986,21 @@ async function handleInitiateSessionWithOffer(header, endpointRaw, payloadRaw, d
49718
49986
  log.warn("alexa rtc-session: egress acquire failed, falling back to streamPreference target", { meta: { error: err instanceof Error ? err.message : String(err) } });
49719
49987
  derivedTarget = resolveWebrtcTarget(exposedEntry?.settings?.streamPreference ?? "auto");
49720
49988
  }
49989
+ const talkback = grant.mayTalk && await deviceHasIntercom(api, deviceId).catch((err) => {
49990
+ log.warn("alexa rtc-session: intercom lookup failed — this session is one-way audio", { meta: { error: err instanceof Error ? err.message : String(err) } });
49991
+ return false;
49992
+ });
49993
+ log.info("alexa rtc-session: talk-back decision", { meta: {
49994
+ talkback,
49995
+ mayTalk: grant.mayTalk
49996
+ } });
49721
49997
  try {
49722
49998
  const result = await api.webrtcSession.handleOffer.mutate({
49723
49999
  deviceId,
49724
50000
  sdpOffer,
49725
50001
  sessionId,
49726
50002
  target: derivedTarget,
50003
+ talkback,
49727
50004
  relayOnly: false,
49728
50005
  nonTrickle: true,
49729
50006
  disableTurn: true,
@@ -49978,6 +50255,111 @@ async function rotateBrokerCredential(fetchImpl, brokerUrl, credential) {
49978
50255
  async function revokeBrokerCredential(fetchImpl, brokerUrl, credential) {
49979
50256
  await call(fetchImpl, brokerUrl, "/broker/revoke", { bearer: credential }, unknown());
49980
50257
  }
50258
+ /** Device types this export may publish as a generic endpoint. */
50259
+ var GENERIC_DEVICE_TYPES = [
50260
+ "light",
50261
+ "switch",
50262
+ "siren",
50263
+ "sensor",
50264
+ "lock",
50265
+ "presence",
50266
+ "thermostat",
50267
+ "generic"
50268
+ ];
50269
+ function iface(name, property) {
50270
+ return {
50271
+ type: "AlexaInterface",
50272
+ interface: name,
50273
+ version: "3",
50274
+ properties: {
50275
+ supported: [{ name: property }],
50276
+ proactivelyReported: true,
50277
+ retrievable: true
50278
+ }
50279
+ };
50280
+ }
50281
+ var BASE = {
50282
+ type: "AlexaInterface",
50283
+ interface: "Alexa",
50284
+ version: "3"
50285
+ };
50286
+ var HEALTH = {
50287
+ type: "AlexaInterface",
50288
+ interface: "Alexa.EndpointHealth",
50289
+ version: "3.1",
50290
+ properties: {
50291
+ supported: [{ name: "connectivity" }],
50292
+ proactivelyReported: true,
50293
+ retrievable: true
50294
+ }
50295
+ };
50296
+ var ROWS = [
50297
+ {
50298
+ cap: "lock-control",
50299
+ capability: iface("Alexa.LockController", "lockState"),
50300
+ category: "SMARTLOCK"
50301
+ },
50302
+ {
50303
+ cap: "contact",
50304
+ capability: iface("Alexa.ContactSensor", "detectionState"),
50305
+ category: "CONTACT_SENSOR"
50306
+ },
50307
+ {
50308
+ cap: "motion",
50309
+ capability: iface("Alexa.MotionSensor", "detectionState"),
50310
+ category: "MOTION_SENSOR"
50311
+ },
50312
+ {
50313
+ cap: "presence",
50314
+ capability: iface("Alexa.MotionSensor", "detectionState"),
50315
+ category: "MOTION_SENSOR"
50316
+ },
50317
+ {
50318
+ cap: "temperature-sensor",
50319
+ capability: iface("Alexa.TemperatureSensor", "temperature"),
50320
+ category: "TEMPERATURE_SENSOR"
50321
+ },
50322
+ {
50323
+ cap: "humidity-sensor",
50324
+ capability: iface("Alexa.HumiditySensor", "relativeHumidity"),
50325
+ category: "TEMPERATURE_SENSOR"
50326
+ },
50327
+ {
50328
+ cap: "switch",
50329
+ capability: iface("Alexa.PowerController", "powerState"),
50330
+ category: "SWITCH"
50331
+ }
50332
+ ];
50333
+ var BRIGHTNESS = iface("Alexa.BrightnessController", "brightness");
50334
+ /** The endpoint for a non-camera device, or null when none of its caps has an
50335
+ * Alexa interface. */
50336
+ function buildGenericDeviceEndpoint(input) {
50337
+ const capabilities = [BASE];
50338
+ const seen = /* @__PURE__ */ new Set();
50339
+ let category = null;
50340
+ for (const row of ROWS) {
50341
+ if (!input.capNames.includes(row.cap)) continue;
50342
+ if (seen.has(row.capability.interface)) continue;
50343
+ seen.add(row.capability.interface);
50344
+ capabilities.push(row.capability);
50345
+ category ??= row.category;
50346
+ }
50347
+ if (category === null) return null;
50348
+ const dimmable = input.capNames.includes("switch") && input.capNames.includes("brightness");
50349
+ if (dimmable) capabilities.push(BRIGHTNESS);
50350
+ if (category === "SWITCH" && (dimmable || input.type === "light")) category = "LIGHT";
50351
+ capabilities.push(HEALTH);
50352
+ const name = input.name.trim();
50353
+ return {
50354
+ endpointId: `${input.deviceId}__dev`,
50355
+ manufacturerName: "camstack",
50356
+ description: `CamStack ${input.type || "device"}`,
50357
+ friendlyName: name.length > 0 ? name : `Device ${input.deviceId}`,
50358
+ displayCategories: [category],
50359
+ capabilities,
50360
+ cookie: { stableId: input.stableId }
50361
+ };
50362
+ }
49981
50363
  //#endregion
49982
50364
  //#region src/handlers/security-panel-arm-state.ts
49983
50365
  /**
@@ -50079,6 +50461,34 @@ var CAMERA_STREAM_CONTROLLER = {
50079
50461
  audioCodecs: ["AAC"]
50080
50462
  }]
50081
50463
  };
50464
+ /**
50465
+ * `Alexa.EndpointHealth` v3.1 — `connectivity`, retrievable and proactive.
50466
+ *
50467
+ * Required by Amazon on every sensor endpoint (a camera with MotionSensor is
50468
+ * one) and for live view it is load-bearing: the first time it was declared,
50469
+ * the hub only ever reported a TRANSITION and ReportState never answered it,
50470
+ * so Alexa held no value — and the phone app refused live view on an endpoint
50471
+ * of unknown health while an Echo Show kept streaming. It is declared ONLY
50472
+ * together with what keeps a value in Alexa's hands (D770):
50473
+ *
50474
+ * - `handlers/report-state.ts` answers `connectivity` for every endpoint
50475
+ * that declares it (`OK` when the status read itself fails);
50476
+ * - `LwaEventGateway.scheduleInitialConnectivity` reports it after every
50477
+ * AddOrUpdateReport and every Discover;
50478
+ * - the gateway's online/offline ChangeReports keep it current.
50479
+ *
50480
+ * `endpoint-health.spec.ts` pins all three.
50481
+ */
50482
+ var ENDPOINT_HEALTH = {
50483
+ type: "AlexaInterface",
50484
+ interface: "Alexa.EndpointHealth",
50485
+ version: "3.1",
50486
+ properties: {
50487
+ supported: [{ name: "connectivity" }],
50488
+ proactivelyReported: true,
50489
+ retrievable: true
50490
+ }
50491
+ };
50082
50492
  var DOORBELL_EVENT_SOURCE = {
50083
50493
  type: "AlexaInterface",
50084
50494
  interface: "Alexa.DoorbellEventSource",
@@ -50095,6 +50505,26 @@ var MOTION_SENSOR = {
50095
50505
  retrievable: true
50096
50506
  }
50097
50507
  };
50508
+ /**
50509
+ * `Alexa.SmartVision.SnapshotProvider` v1.1 — the camera's still in the Alexa
50510
+ * app (R2, D773). Available in it-IT and en-GB, unlike object detection.
50511
+ * Declared on every camera while the integration's "Snapshots" switch is on;
50512
+ * `GetSnapshot` answers with a signed, ten-minute, account-token-checked image
50513
+ * on the hub's public address (`handlers/snapshot-provider.ts`).
50514
+ *
50515
+ * `minRefreshIntervalInSeconds` bounds how often Alexa may ask per camera:
50516
+ * every ask is a snapshot read and a re-encode on the hub, and a picture of the
50517
+ * home handed to Amazon, so the tile refreshes at most every few minutes.
50518
+ */
50519
+ var SMART_VISION_SNAPSHOT_PROVIDER = {
50520
+ type: "AlexaInterface",
50521
+ interface: "Alexa.SmartVision.SnapshotProvider",
50522
+ version: "1.1",
50523
+ configuration: {
50524
+ isAvailable: true,
50525
+ minRefreshIntervalInSeconds: 180
50526
+ }
50527
+ };
50098
50528
  function rtcSessionController(fullDuplexAudio) {
50099
50529
  return {
50100
50530
  type: "AlexaInterface",
@@ -50103,13 +50533,15 @@ function rtcSessionController(fullDuplexAudio) {
50103
50533
  configuration: { isFullDuplexAudioSupported: fullDuplexAudio }
50104
50534
  };
50105
50535
  }
50536
+ /** Proactively reported (R3, D776): `StateChangeReporter` pushes every
50537
+ * `switch` / `privacy-mask` / `ptz-autotrack` / `brightness` change. */
50106
50538
  var POWER_CONTROLLER = {
50107
50539
  type: "AlexaInterface",
50108
50540
  interface: "Alexa.PowerController",
50109
50541
  version: "3",
50110
50542
  properties: {
50111
50543
  supported: [{ name: "powerState" }],
50112
- proactivelyReported: false,
50544
+ proactivelyReported: true,
50113
50545
  retrievable: true
50114
50546
  }
50115
50547
  };
@@ -50119,16 +50551,6 @@ var BRIGHTNESS_CONTROLLER = {
50119
50551
  version: "3",
50120
50552
  properties: {
50121
50553
  supported: [{ name: "brightness" }],
50122
- proactivelyReported: false,
50123
- retrievable: true
50124
- }
50125
- };
50126
- var BATTERY_LEVEL_SENSOR = {
50127
- type: "AlexaInterface",
50128
- interface: "Alexa.BatteryLevelSensor",
50129
- version: "3",
50130
- properties: {
50131
- supported: [{ name: "batteryLevel" }],
50132
50554
  proactivelyReported: true,
50133
50555
  retrievable: true
50134
50556
  }
@@ -50301,11 +50723,12 @@ function buildParentCameraEndpoint(input) {
50301
50723
  const capabilities = [
50302
50724
  ALEXA_BASE_CAPABILITY,
50303
50725
  CAMERA_STREAM_CONTROLLER,
50304
- rtcSessionController(input.hasIntercom)
50726
+ rtcSessionController(input.hasIntercom),
50727
+ ENDPOINT_HEALTH
50305
50728
  ];
50306
50729
  if (input.isDoorbell) capabilities.push(DOORBELL_EVENT_SOURCE);
50307
50730
  if (input.hasMotion) capabilities.push(MOTION_SENSOR);
50308
- if (input.hasBattery) capabilities.push(BATTERY_LEVEL_SENSOR);
50731
+ if (input.snapshotProvider === true) capabilities.push(SMART_VISION_SNAPSHOT_PROVIDER);
50309
50732
  const presetNames = distinctPresetNames(input.ptzPresetNames);
50310
50733
  if (presetNames.length >= MIN_MODE_CONTROLLER_MODES) capabilities.push(ptzPresetModeController(presetNames));
50311
50734
  if (input.hasPtz) {
@@ -50412,10 +50835,10 @@ function buildPrivacyEndpoint(input) {
50412
50835
  * declared: `alarm-panel` has no field saying WHICH alarm is sounding, and
50413
50836
  * declaring a property we would always answer `OK` on is a claim with no
50414
50837
  * basis behind it. The reasoning is in `security-panel-arm-state.ts`.
50415
- * - `proactivelyReported: false`. This addon's Event Gateway pushes
50416
- * ChangeReports for camera motion only; a panel state change is not wired
50417
- * to it, and promising a report nothing sends is how an endpoint ends up
50418
- * looking broken. `retrievable: true` — `Alexa/ReportState` answers.
50838
+ * - `proactivelyReported: true` (R3, D776). Amazon asks for alarms to be
50839
+ * reported proactively; `StateChangeReporter` pushes every `alarm-panel`
50840
+ * slice change (arm state and the burglary alarm). `retrievable: true` —
50841
+ * `Alexa/ReportState` answers too.
50419
50842
  * - `supportedAuthorizationTypes` appears only for a panel that requires a
50420
50843
  * code. Alexa's Disarm directive can carry a PIN; its **Arm directive
50421
50844
  * cannot**, which is why a PIN-gated arm is refused by the handler with
@@ -50430,7 +50853,7 @@ function buildAlarmPanelEndpoint(input) {
50430
50853
  version: "3",
50431
50854
  properties: {
50432
50855
  supported: [{ name: "armState" }, { name: "burglaryAlarm" }],
50433
- proactivelyReported: false,
50856
+ proactivelyReported: true,
50434
50857
  retrievable: true
50435
50858
  },
50436
50859
  configuration
@@ -50445,6 +50868,42 @@ function buildAlarmPanelEndpoint(input) {
50445
50868
  cookie: { stableId: input.stableId }
50446
50869
  };
50447
50870
  }
50871
+ /** How a detection sensor is named after its camera. English, like every
50872
+ * other name this skill publishes; the operator can rename it in the app. */
50873
+ var DETECTION_SENSOR_LABELS = {
50874
+ person: "Person",
50875
+ vehicle: "Vehicle",
50876
+ animal: "Animal",
50877
+ package: "Package"
50878
+ };
50879
+ /**
50880
+ * One class of a camera's detections as an `Alexa.MotionSensor` endpoint —
50881
+ * "Front door Person" (R1, D772). It is what an it-IT / en-GB account can use
50882
+ * as a routine trigger ("when Front door Person detects motion → announce…"):
50883
+ * Amazon's object-detection interface is en-US only, MotionSensor is not.
50884
+ * Driven by the pipeline's tracks, not by the camera's own motion. Sensors
50885
+ * need `Alexa.EndpointHealth` (Amazon), reported like the camera's (D770).
50886
+ */
50887
+ function buildDetectionSensorEndpoint(input) {
50888
+ const label = DETECTION_SENSOR_LABELS[input.detectionClass];
50889
+ const camera = input.cameraName.trim().length > 0 ? input.cameraName.trim() : `Camera ${input.deviceId}`;
50890
+ return {
50891
+ endpointId: detectionSensorEndpointId(input.deviceId, input.detectionClass),
50892
+ manufacturerName: "camstack",
50893
+ description: `CamStack ${input.detectionClass} detection`,
50894
+ friendlyName: `${camera} ${label}`,
50895
+ displayCategories: ["MOTION_SENSOR"],
50896
+ capabilities: [
50897
+ ALEXA_BASE_CAPABILITY,
50898
+ MOTION_SENSOR,
50899
+ ENDPOINT_HEALTH
50900
+ ],
50901
+ cookie: {
50902
+ stableId: input.stableId,
50903
+ detectionClass: input.detectionClass
50904
+ }
50905
+ };
50906
+ }
50448
50907
  //#endregion
50449
50908
  //#region src/handlers/ptz-preset-recallable.ts
50450
50909
  var RECALLABLE_FUNCTIONS = new Set(["home"]);
@@ -50464,12 +50923,13 @@ var CAP_PTZ = "ptz";
50464
50923
  var CAP_PTZ_AUTOTRACK = "ptz-autotrack";
50465
50924
  var CAP_SWITCH = "switch";
50466
50925
  var CAP_BRIGHTNESS = "brightness";
50467
- var CAP_BATTERY = "battery";
50468
50926
  var CAP_PRIVACY_MASK = "privacy-mask";
50469
50927
  var CAP_ALARM_PANEL = "alarm-panel";
50470
- async function handleDiscovery(header, payloadRaw, deps, callerIdentity) {
50928
+ async function handleDiscovery(header, payloadRaw, deps, callerIdentity, visibility) {
50471
50929
  if (!discoveryPayloadSchema.safeParse(payloadRaw).success) return errorEnvelope(header, "INVALID_DIRECTIVE", "Discovery payload shape unexpected");
50472
- const exposed = await deps.listExposed();
50930
+ const allExposed = await deps.listExposed();
50931
+ const exposed = [];
50932
+ for (const entry of allExposed) if (await visibility.deviceVisible(entry.deviceId)) exposed.push(entry);
50473
50933
  const built = new Array(exposed.length).fill(null);
50474
50934
  const queue = exposed.map((entry, index) => ({
50475
50935
  entry,
@@ -50480,7 +50940,7 @@ async function handleDiscovery(header, payloadRaw, deps, callerIdentity) {
50480
50940
  const item = queue.shift();
50481
50941
  if (item === void 0) return;
50482
50942
  try {
50483
- built[item.index] = await buildEndpointsForExposedDevice(item.entry.deviceId, deps);
50943
+ built[item.index] = await deps.buildEndpoints(item.entry.deviceId);
50484
50944
  } catch (err) {
50485
50945
  deps.logger.warn("alexa discovery (on-demand): skipping device — build failed", {
50486
50946
  tags: { deviceId: item.entry.deviceId },
@@ -50491,10 +50951,12 @@ async function handleDiscovery(header, payloadRaw, deps, callerIdentity) {
50491
50951
  };
50492
50952
  const lanes = Math.min(DISCOVERY_BUILD_CONCURRENCY, Math.max(1, queue.length));
50493
50953
  await Promise.all(Array.from({ length: lanes }, lane));
50494
- const endpoints = built.flatMap((b) => b ?? []);
50954
+ const endpoints = await visibility.filterEndpoints(built.flatMap((b) => b ?? []));
50495
50955
  deps.logger.info("alexa discovery returned endpoints", { meta: {
50496
50956
  count: endpoints.length,
50497
- userId: callerIdentity.userId
50957
+ userId: callerIdentity.userId,
50958
+ exposedDevices: allExposed.length,
50959
+ visibleDevices: exposed.length
50498
50960
  } });
50499
50961
  return discoveryResponse(header, endpoints);
50500
50962
  }
@@ -50506,6 +50968,26 @@ async function buildEndpointsForExposedDevice(deviceId, deps) {
50506
50968
  return [];
50507
50969
  }
50508
50970
  const parentInfo = await loadBoundDeviceInfo(device, api);
50971
+ if (GENERIC_DEVICE_TYPES.includes(String(device.type)) && !parentInfo.capNames.includes(CAP_ALARM_PANEL)) {
50972
+ const generic = buildGenericDeviceEndpoint({
50973
+ deviceId: parentInfo.id,
50974
+ stableId: parentInfo.stableId,
50975
+ name: parentInfo.name,
50976
+ type: String(device.type),
50977
+ capNames: parentInfo.capNames
50978
+ });
50979
+ if (generic === null) {
50980
+ deps.logger.warn("alexa discovery: device has no cap Alexa has an interface for — not published", {
50981
+ tags: { deviceId },
50982
+ meta: {
50983
+ type: device.type,
50984
+ capNames: parentInfo.capNames
50985
+ }
50986
+ });
50987
+ return [];
50988
+ }
50989
+ return [generic];
50990
+ }
50509
50991
  if (parentInfo.capNames.includes(CAP_ALARM_PANEL)) return [await buildAlarmPanelEndpointFor(parentInfo, api, deps)];
50510
50992
  const children = await api.deviceManager.getChildren.query({ parentDeviceId: deviceId }).catch((err) => {
50511
50993
  deps.logger.warn("alexa discovery: getChildren failed", {
@@ -50521,7 +51003,6 @@ async function buildEndpointsForExposedDevice(deviceId, deps) {
50521
51003
  const hasMotion = parentInfo.capNames.includes(CAP_MOTION);
50522
51004
  const hasAutotrack = parentInfo.capNames.includes(CAP_PTZ_AUTOTRACK);
50523
51005
  const hasPtz = parentInfo.capNames.includes(CAP_PTZ);
50524
- const hasBattery = parentInfo.capNames.includes(CAP_BATTERY);
50525
51006
  const hasPrivacy = parentInfo.capNames.includes(CAP_PRIVACY_MASK);
50526
51007
  deps.logger.info("alexa discovery: built feature flags", {
50527
51008
  tags: { deviceId: parentInfo.id },
@@ -50529,7 +51010,6 @@ async function buildEndpointsForExposedDevice(deviceId, deps) {
50529
51010
  name: parentInfo.name,
50530
51011
  capNames: parentInfo.capNames,
50531
51012
  hasMotion,
50532
- hasBattery,
50533
51013
  hasPtz,
50534
51014
  hasAutotrack,
50535
51015
  hasPrivacy,
@@ -50554,9 +51034,18 @@ async function buildEndpointsForExposedDevice(deviceId, deps) {
50554
51034
  hasIntercom,
50555
51035
  hasMotion,
50556
51036
  ptzPresetNames,
50557
- hasBattery,
50558
- hasPtz
51037
+ hasPtz,
51038
+ snapshotProvider: deps.snapshots === true
50559
51039
  }));
51040
+ if (deps.detectionClasses !== void 0) {
51041
+ const classes = await deps.detectionClasses(parentInfo.id);
51042
+ for (const detectionClass of classes) out.push(buildDetectionSensorEndpoint({
51043
+ deviceId: parentInfo.id,
51044
+ stableId: parentInfo.stableId,
51045
+ cameraName: parentInfo.name,
51046
+ detectionClass
51047
+ }));
51048
+ }
50560
51049
  if (hasAutotrack) out.push(buildAutotrackEndpoint({
50561
51050
  parentDeviceId: parentInfo.id,
50562
51051
  parentStableId: parentInfo.stableId,
@@ -50659,6 +51148,476 @@ function discoveryResponse(header, endpoints) {
50659
51148
  payload: { endpoints }
50660
51149
  } };
50661
51150
  }
51151
+ /** Ancestor-walk bound — defence against a corrupt parentage cycle. */
51152
+ var MAX_ANCESTOR_HOPS = 8;
51153
+ function principalFromGrant(grant) {
51154
+ return grant.isAdmin ? {
51155
+ kind: "admin",
51156
+ userId: grant.userId
51157
+ } : {
51158
+ kind: "scoped",
51159
+ userId: grant.userId,
51160
+ scopes: grant.scopes
51161
+ };
51162
+ }
51163
+ /**
51164
+ * Does `principal` reach `chain[0]` at `access`? `chain` is the device followed
51165
+ * by its ancestors (parent first), each with the metadata a selector needs.
51166
+ * Pure — the one derivation shared by directives, discovery and the event mirror.
51167
+ */
51168
+ function principalAllowsDevice(principal, chain, access) {
51169
+ if (principal.kind === "admin") return true;
51170
+ const target = chain[0];
51171
+ if (target === void 0) return false;
51172
+ return resolveViewableDeviceIds(principal.scopes, chain, access).has(target.id);
51173
+ }
51174
+ function endpointAccessTarget(parsed) {
51175
+ switch (parsed.kind) {
51176
+ case "camera": return {
51177
+ exposedDeviceId: parsed.deviceId,
51178
+ deviceId: parsed.deviceId,
51179
+ requiresParent: null,
51180
+ ownAccess: "view"
51181
+ };
51182
+ case "privacy":
51183
+ case "autotrack": return {
51184
+ exposedDeviceId: parsed.parentDeviceId,
51185
+ deviceId: parsed.parentDeviceId,
51186
+ requiresParent: null,
51187
+ ownAccess: "create"
51188
+ };
51189
+ case "child": return {
51190
+ exposedDeviceId: parsed.parentDeviceId,
51191
+ deviceId: parsed.childDeviceId,
51192
+ requiresParent: parsed.parentDeviceId,
51193
+ ownAccess: "create"
51194
+ };
51195
+ case "alarm-panel":
51196
+ case "detection":
51197
+ case "generic": return {
51198
+ exposedDeviceId: parsed.deviceId,
51199
+ deviceId: parsed.deviceId,
51200
+ requiresParent: null,
51201
+ ownAccess: "view"
51202
+ };
51203
+ case "invalid": return null;
51204
+ }
51205
+ }
51206
+ var AlexaAccessGate = class {
51207
+ deps;
51208
+ grants = null;
51209
+ grantsFlight = null;
51210
+ meta = /* @__PURE__ */ new Map();
51211
+ warnedStale = false;
51212
+ constructor(deps) {
51213
+ this.deps = deps;
51214
+ }
51215
+ now() {
51216
+ return this.deps.now?.() ?? Date.now();
51217
+ }
51218
+ /** Resolve the principal a link with this `userId` acts for. */
51219
+ async principal(userId) {
51220
+ const snapshot = await this.readGrants();
51221
+ if (snapshot === null) return {
51222
+ kind: "unavailable",
51223
+ error: "the hub user store could not be read"
51224
+ };
51225
+ const grant = snapshot.byUserId.get(userId);
51226
+ if (grant === void 0) return { kind: "unknown-user" };
51227
+ return {
51228
+ kind: "found",
51229
+ principal: principalFromGrant(grant)
51230
+ };
51231
+ }
51232
+ /** Does `principal` reach `deviceId` at `access`? */
51233
+ async allows(principal, deviceId, access) {
51234
+ if (principal.kind === "admin") return "allowed";
51235
+ const chain = await this.chainOf(deviceId);
51236
+ if (chain === null) return "unknown";
51237
+ return principalAllowsDevice(principal, chain, access) ? "allowed" : "denied";
51238
+ }
51239
+ /** The device's parent per the registry; `undefined` when unknown. */
51240
+ async parentOf(deviceId) {
51241
+ const meta = await this.metaOf(deviceId);
51242
+ return meta === null ? void 0 : meta.parentDeviceId;
51243
+ }
51244
+ /** Forget everything cached — for tests and an operator-driven re-sync. */
51245
+ invalidate() {
51246
+ this.grants = null;
51247
+ this.meta.clear();
51248
+ }
51249
+ async readGrants() {
51250
+ const cached = this.grants;
51251
+ if (cached !== null && this.now() - cached.at < 3e4) return cached;
51252
+ if (this.grantsFlight === null) this.grantsFlight = this.deps.listUserGrants().then((list) => {
51253
+ const byUserId = /* @__PURE__ */ new Map();
51254
+ for (const g of list) byUserId.set(g.userId, g);
51255
+ const snapshot = {
51256
+ at: this.now(),
51257
+ byUserId
51258
+ };
51259
+ this.grants = snapshot;
51260
+ this.warnedStale = false;
51261
+ return snapshot;
51262
+ }).finally(() => {
51263
+ this.grantsFlight = null;
51264
+ });
51265
+ try {
51266
+ return await this.grantsFlight;
51267
+ } catch (err) {
51268
+ if (cached !== null) {
51269
+ if (!this.warnedStale) {
51270
+ this.warnedStale = true;
51271
+ this.deps.logger.warn("alexa access: the hub user store could not be re-read — keeping the last good grants", { meta: {
51272
+ error: errMsg(err),
51273
+ ageMs: this.now() - cached.at
51274
+ } });
51275
+ }
51276
+ return cached;
51277
+ }
51278
+ this.deps.logger.warn("alexa access: the hub user store could not be read — refusing Alexa access until it can", { meta: { error: errMsg(err) } });
51279
+ return null;
51280
+ }
51281
+ }
51282
+ async metaOf(deviceId) {
51283
+ const cached = this.meta.get(deviceId);
51284
+ if (cached !== void 0 && this.now() - cached.at < 6e4) return cached.meta;
51285
+ try {
51286
+ const meta = await this.deps.getDeviceMeta(deviceId);
51287
+ this.meta.set(deviceId, {
51288
+ at: this.now(),
51289
+ meta
51290
+ });
51291
+ return meta;
51292
+ } catch (err) {
51293
+ this.deps.logger.debug("alexa access: device metadata read failed", {
51294
+ tags: { deviceId },
51295
+ meta: { error: errMsg(err) }
51296
+ });
51297
+ return cached?.meta ?? null;
51298
+ }
51299
+ }
51300
+ /** The device followed by its ancestors, or `null` if the device is unknown.
51301
+ * An unknown ANCESTOR ends the walk — inheritance through it is simply not
51302
+ * granted (fail closed). */
51303
+ async chainOf(deviceId) {
51304
+ const self = await this.metaOf(deviceId);
51305
+ if (self === null) return null;
51306
+ const chain = [self];
51307
+ let parent = self.parentDeviceId;
51308
+ for (let hop = 0; hop < MAX_ANCESTOR_HOPS && parent !== null; hop++) {
51309
+ if (chain.some((d) => d.id === parent)) break;
51310
+ const meta = await this.metaOf(parent);
51311
+ if (meta === null) break;
51312
+ chain.push(meta);
51313
+ parent = meta.parentDeviceId;
51314
+ }
51315
+ return chain;
51316
+ }
51317
+ };
51318
+ //#endregion
51319
+ //#region src/connectivity.ts
51320
+ /** The one field read off the `device-status` slice (its wire output is
51321
+ * untyped here). */
51322
+ var OnlineSchema$1 = object({ online: boolean() });
51323
+ async function readConnectivity(api, deviceId, logger) {
51324
+ try {
51325
+ const parsed = OnlineSchema$1.safeParse(await api.deviceStatus.getStatus.query({ deviceId }));
51326
+ return parsed.success && !parsed.data.online ? "UNREACHABLE" : "OK";
51327
+ } catch (err) {
51328
+ logger.debug("alexa: device-status unreadable — reporting connectivity OK", {
51329
+ tags: { deviceId },
51330
+ meta: { error: errMsg(err) }
51331
+ });
51332
+ return "OK";
51333
+ }
51334
+ }
51335
+ //#endregion
51336
+ //#region src/endpoints/validate-endpoint.ts
51337
+ /**
51338
+ * Interfaces this skill may declare, with the version Amazon documents. An
51339
+ * interface missing here is either not in Amazon's list (`BatteryLevelSensor`
51340
+ * never was) or deprecated (`MediaMetadata`, `EventDetectionSensor`).
51341
+ */
51342
+ var ALLOWED_INTERFACE_VERSIONS = {
51343
+ Alexa: "3",
51344
+ "Alexa.CameraStreamController": "3",
51345
+ "Alexa.RTCSessionController": "3",
51346
+ "Alexa.DoorbellEventSource": "3",
51347
+ "Alexa.MotionSensor": "3",
51348
+ "Alexa.ContactSensor": "3",
51349
+ "Alexa.TemperatureSensor": "3",
51350
+ "Alexa.HumiditySensor": "3",
51351
+ "Alexa.EndpointHealth": "3.1",
51352
+ "Alexa.PowerController": "3",
51353
+ "Alexa.BrightnessController": "3",
51354
+ "Alexa.ModeController": "3",
51355
+ "Alexa.ToggleController": "3",
51356
+ "Alexa.RangeController": "3",
51357
+ "Alexa.SecurityPanelController": "3",
51358
+ "Alexa.LockController": "3",
51359
+ "Alexa.SmartVision.SnapshotProvider": "1.1",
51360
+ "Alexa.SmartVision.ObjectDetectionSensor": "1.0"
51361
+ };
51362
+ /** Interfaces that need an `instance`. */
51363
+ var INSTANCED = new Set([
51364
+ "Alexa.ModeController",
51365
+ "Alexa.ToggleController",
51366
+ "Alexa.RangeController"
51367
+ ]);
51368
+ /** Sensor interfaces Amazon requires to be paired with `Alexa.EndpointHealth`. */
51369
+ var NEEDS_ENDPOINT_HEALTH = new Set([
51370
+ "Alexa.MotionSensor",
51371
+ "Alexa.ContactSensor",
51372
+ "Alexa.TemperatureSensor",
51373
+ "Alexa.HumiditySensor",
51374
+ "Alexa.LockController",
51375
+ "Alexa.SmartVision.ObjectDetectionSensor"
51376
+ ]);
51377
+ /** `displayCategories` values from alexa-discovery (the subset a camstack
51378
+ * device can be). */
51379
+ var DISPLAY_CATEGORIES = new Set([
51380
+ "CAMERA",
51381
+ "DOORBELL",
51382
+ "LIGHT",
51383
+ "SWITCH",
51384
+ "SMARTPLUG",
51385
+ "SECURITY_PANEL",
51386
+ "SECURITY_SYSTEM",
51387
+ "MOTION_SENSOR",
51388
+ "CONTACT_SENSOR",
51389
+ "TEMPERATURE_SENSOR",
51390
+ "SMARTLOCK",
51391
+ "DOOR",
51392
+ "GARAGE_DOOR",
51393
+ "OTHER"
51394
+ ]);
51395
+ var ENDPOINT_ID_PATTERN = /^[A-Za-z0-9 _\-=#;:?@&]{1,256}$/;
51396
+ var MAX_FRIENDLY_NAME = 256;
51397
+ var MAX_DESCRIPTION = 128;
51398
+ var MAX_MANUFACTURER = 128;
51399
+ var MAX_COOKIE_BYTES = 5e3;
51400
+ var MAX_CAPABILITIES = 100;
51401
+ var MIN_MODES = 2;
51402
+ function capabilityKey$1(c) {
51403
+ return c.instance === void 0 ? c.interface : `${c.interface}#${c.instance}`;
51404
+ }
51405
+ function friendlyTexts(c) {
51406
+ return (c.capabilityResources?.friendlyNames ?? []).map((n) => n.value.text);
51407
+ }
51408
+ function validateCapability(endpointId, c, out) {
51409
+ const push = (rule) => {
51410
+ out.push({
51411
+ endpointId,
51412
+ rule: `${capabilityKey$1(c)}: ${rule}`
51413
+ });
51414
+ };
51415
+ if (c.type !== "AlexaInterface") push("type must be AlexaInterface");
51416
+ const version = ALLOWED_INTERFACE_VERSIONS[c.interface];
51417
+ if (version === void 0) {
51418
+ push("interface is not in Amazon's interface list (or is deprecated)");
51419
+ return;
51420
+ }
51421
+ if (c.version !== version) push(`version must be ${version}`);
51422
+ if (INSTANCED.has(c.interface)) {
51423
+ if (c.instance === void 0 || c.instance.length === 0) push("instance is required");
51424
+ if (friendlyTexts(c).length === 0) push("capabilityResources.friendlyNames is required");
51425
+ }
51426
+ if (c.properties !== void 0) {
51427
+ if (c.properties.supported.length === 0) push("properties.supported must not be empty");
51428
+ }
51429
+ if (c.interface === "Alexa.ModeController") {
51430
+ const modes = c.configuration?.["supportedModes"];
51431
+ if (!Array.isArray(modes) || modes.length < MIN_MODES) push(`at least ${MIN_MODES} supportedModes`);
51432
+ else {
51433
+ const values = modes.map((m) => typeof m === "object" && m !== null ? String(Reflect.get(m, "value")) : "");
51434
+ if (new Set(values).size !== values.length) push("supportedModes values must be unique");
51435
+ }
51436
+ }
51437
+ if (c.interface === "Alexa.SmartVision.SnapshotProvider") {
51438
+ if (typeof c.configuration?.["isAvailable"] !== "boolean") push("configuration.isAvailable is required");
51439
+ }
51440
+ }
51441
+ /** Every rule an endpoint breaks. Empty = Amazon should accept it. */
51442
+ function validateAlexaEndpoint(endpoint) {
51443
+ const out = [];
51444
+ const id = endpoint.endpointId;
51445
+ const push = (rule) => {
51446
+ out.push({
51447
+ endpointId: id,
51448
+ rule
51449
+ });
51450
+ };
51451
+ if (!ENDPOINT_ID_PATTERN.test(id)) push("endpointId: 1-256 of [A-Za-z0-9 _-=#;:?@&]");
51452
+ if (endpoint.friendlyName.trim().length === 0) push("friendlyName must not be empty");
51453
+ if (endpoint.friendlyName.length > MAX_FRIENDLY_NAME) push("friendlyName longer than 256");
51454
+ if (endpoint.description.length === 0 || endpoint.description.length > MAX_DESCRIPTION) push("description must be 1-128 characters");
51455
+ if (endpoint.manufacturerName.length === 0 || endpoint.manufacturerName.length > MAX_MANUFACTURER) push("manufacturerName must be 1-128 characters");
51456
+ if (endpoint.displayCategories.length === 0) push("displayCategories must not be empty");
51457
+ for (const cat of endpoint.displayCategories) if (!DISPLAY_CATEGORIES.has(cat)) push(`displayCategory ${cat} is not an Amazon value`);
51458
+ if (endpoint.cookie !== void 0) {
51459
+ if (new TextEncoder().encode(JSON.stringify(endpoint.cookie)).length > MAX_COOKIE_BYTES) push("cookie larger than 5000 bytes");
51460
+ }
51461
+ const caps = endpoint.capabilities;
51462
+ if (caps.length > MAX_CAPABILITIES) push("more than 100 capabilities");
51463
+ if (!caps.some((c) => c.interface === "Alexa" && c.version === "3")) push("the base Alexa interface (version 3) is required");
51464
+ const keys = caps.map(capabilityKey$1);
51465
+ if (new Set(keys).size !== keys.length) push("a capability (interface + instance) is declared twice");
51466
+ const interfaces = new Set(caps.map((c) => c.interface));
51467
+ for (const needs of NEEDS_ENDPOINT_HEALTH) if (interfaces.has(needs) && !interfaces.has("Alexa.EndpointHealth")) push(`${needs} requires Alexa.EndpointHealth`);
51468
+ if (interfaces.has("Alexa.RTCSessionController") && endpoint.displayCategories[0] !== "CAMERA") push("an RTCSessionController endpoint must list CAMERA first");
51469
+ for (const c of caps) validateCapability(id, c, out);
51470
+ return out;
51471
+ }
51472
+ /** Split endpoints into the valid ones and the violations of the rest. */
51473
+ function partitionValidEndpoints(endpoints) {
51474
+ const valid = [];
51475
+ const violations = [];
51476
+ for (const e of endpoints) {
51477
+ const found = validateAlexaEndpoint(e);
51478
+ if (found.length === 0) valid.push(e);
51479
+ else violations.push(...found);
51480
+ }
51481
+ return {
51482
+ valid,
51483
+ violations
51484
+ };
51485
+ }
51486
+ /**
51487
+ * The runtime gate: keep the valid endpoints, and say out loud which ones were
51488
+ * withheld and why. Sending an invalid one risks Alexa rejecting the report —
51489
+ * and with it every valid endpoint beside it.
51490
+ */
51491
+ function withholdInvalidEndpoints(endpoints, warn) {
51492
+ const { valid, violations } = partitionValidEndpoints(endpoints);
51493
+ if (violations.length > 0) warn("alexa: endpoints WITHHELD — they break an Amazon discovery rule and could make Alexa reject the whole report", violations);
51494
+ return valid;
51495
+ }
51496
+ //#endregion
51497
+ //#region src/access/endpoint-access.ts
51498
+ /**
51499
+ * May `principal` reach `endpointId` at `access`? `access` defaults to the
51500
+ * endpoint's own discovery access (`view` for a camera, `create` for an
51501
+ * actuator-only endpoint).
51502
+ */
51503
+ async function authorizeEndpoint(endpointId, principal, deps, access) {
51504
+ const target = endpointAccessTarget(parseCameraEndpointId(endpointId));
51505
+ if (target === null) return {
51506
+ allowed: false,
51507
+ refusal: { kind: "invalid-endpoint" }
51508
+ };
51509
+ if (!deps.isExposed(target.exposedDeviceId)) return {
51510
+ allowed: false,
51511
+ refusal: {
51512
+ kind: "not-exposed",
51513
+ deviceId: target.exposedDeviceId
51514
+ }
51515
+ };
51516
+ if (target.requiresParent !== null) {
51517
+ const parent = await deps.gate.parentOf(target.deviceId);
51518
+ if (parent === void 0) return {
51519
+ allowed: false,
51520
+ refusal: {
51521
+ kind: "device-unknown",
51522
+ deviceId: target.deviceId
51523
+ }
51524
+ };
51525
+ if (parent !== target.requiresParent) return {
51526
+ allowed: false,
51527
+ refusal: {
51528
+ kind: "not-a-child",
51529
+ deviceId: target.deviceId,
51530
+ parentDeviceId: target.requiresParent
51531
+ }
51532
+ };
51533
+ }
51534
+ const needed = access ?? target.ownAccess;
51535
+ const verdict = await deps.gate.allows(principal, target.deviceId, needed);
51536
+ if (verdict === "allowed") return { allowed: true };
51537
+ if (verdict === "unknown") return {
51538
+ allowed: false,
51539
+ refusal: {
51540
+ kind: "device-unknown",
51541
+ deviceId: target.deviceId
51542
+ }
51543
+ };
51544
+ return {
51545
+ allowed: false,
51546
+ refusal: {
51547
+ kind: "not-granted",
51548
+ deviceId: target.deviceId,
51549
+ access: needed
51550
+ }
51551
+ };
51552
+ }
51553
+ /**
51554
+ * Interfaces on a VIEWABLE endpoint that only actuate. A user who may see a
51555
+ * camera but not control it is shown the camera without them, so the Alexa app
51556
+ * never offers a PTZ dropdown whose every use would be refused.
51557
+ */
51558
+ var CONTROL_ONLY_INTERFACES = new Set(["Alexa.ModeController", "Alexa.ToggleController"]);
51559
+ /** Strip what a view-only principal may not drive. Two-way audio is driving
51560
+ * the camera's speaker, so it goes too. Returns a NEW endpoint. */
51561
+ function viewOnlyShape(endpoint) {
51562
+ return {
51563
+ ...endpoint,
51564
+ capabilities: endpoint.capabilities.filter((c) => !CONTROL_ONLY_INTERFACES.has(c.interface)).map((c) => c.interface === "Alexa.RTCSessionController" ? {
51565
+ ...c,
51566
+ configuration: {
51567
+ ...c.configuration,
51568
+ isFullDuplexAudioSupported: false
51569
+ }
51570
+ } : c)
51571
+ };
51572
+ }
51573
+ /** Keep the endpoints `principal` may be shown, shaped to what it may drive.
51574
+ * Order is preserved. */
51575
+ async function filterEndpointsForPrincipal(endpoints, principal, deps) {
51576
+ const kept = [];
51577
+ const refused = [];
51578
+ for (const endpoint of endpoints) {
51579
+ const verdict = await authorizeEndpoint(endpoint.endpointId, principal, deps);
51580
+ if (!verdict.allowed) {
51581
+ refused.push({
51582
+ endpointId: endpoint.endpointId,
51583
+ refusal: verdict.refusal
51584
+ });
51585
+ continue;
51586
+ }
51587
+ const target = endpointAccessTarget(parseCameraEndpointId(endpoint.endpointId));
51588
+ if (principal.kind === "scoped" && target !== null && target.ownAccess === "view") {
51589
+ const control = await deps.gate.allows(principal, target.deviceId, "create");
51590
+ kept.push(control === "allowed" ? endpoint : viewOnlyShape(endpoint));
51591
+ continue;
51592
+ }
51593
+ kept.push(endpoint);
51594
+ }
51595
+ return {
51596
+ kept,
51597
+ refused
51598
+ };
51599
+ }
51600
+ /** The Alexa error type a refusal maps to. A device the user may not see does
51601
+ * not exist for that user; one it may see but not control is a permission
51602
+ * failure; a device the registry cannot read right now is unreachable. */
51603
+ function alexaErrorTypeFor(refusal) {
51604
+ switch (refusal.kind) {
51605
+ case "device-unknown": return "ENDPOINT_UNREACHABLE";
51606
+ case "not-granted": return refusal.access === "view" ? "NO_SUCH_ENDPOINT" : "INVALID_DIRECTIVE";
51607
+ case "invalid-endpoint":
51608
+ case "not-exposed":
51609
+ case "not-a-child": return "NO_SUCH_ENDPOINT";
51610
+ }
51611
+ }
51612
+ function describeRefusal(refusal) {
51613
+ switch (refusal.kind) {
51614
+ case "invalid-endpoint": return "the endpoint id is not one this skill publishes";
51615
+ case "not-exposed": return `device ${refusal.deviceId} is not exposed to Alexa`;
51616
+ case "not-a-child": return `device ${refusal.deviceId} is not an accessory of ${refusal.parentDeviceId}`;
51617
+ case "not-granted": return `the linked hub user has no ${refusal.access} grant on device ${refusal.deviceId}`;
51618
+ case "device-unknown": return `device ${refusal.deviceId} could not be resolved in the registry`;
51619
+ }
51620
+ }
50662
51621
  //#endregion
50663
51622
  //#region src/handlers/brightness-controller.ts
50664
51623
  var SetBrightnessDirectiveSchema = object({
@@ -50681,20 +51640,21 @@ async function handleSetBrightness(header, endpointRaw, payloadRaw, deps, _calle
50681
51640
  if (!parsed.success) return errorEnvelope(header, "INVALID_DIRECTIVE", `payload validation failed: ${parsed.error.message}`);
50682
51641
  const endpointId = parsed.data.endpoint.endpointId;
50683
51642
  const parsedId = parseCameraEndpointId(endpointId);
50684
- 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`);
51643
+ if (parsedId.kind === "autotrack" || parsedId.kind === "privacy" || parsedId.kind === "alarm-panel" || parsedId.kind === "detection") return errorEnvelope(header, "INVALID_DIRECTIVE", `BrightnessController is not exposed on the ${parsedId.kind} endpoint`);
50685
51644
  if (parsedId.kind === "camera") return errorEnvelope(header, "INVALID_DIRECTIVE", "BrightnessController is not exposed on camera endpoints — direct the directive at the dimmable child accessory");
50686
51645
  if (parsedId.kind === "invalid") return errorEnvelope(header, "INVALID_DIRECTIVE", parsedId.reason);
50687
51646
  const percentage = parsed.data.payload.brightness;
50688
51647
  const api = deps.ctx.api;
51648
+ const targetDeviceId = parsedId.kind === "child" ? parsedId.childDeviceId : parsedId.deviceId;
50689
51649
  try {
50690
51650
  await api.brightness.setBrightness.mutate({
50691
- deviceId: parsedId.childDeviceId,
51651
+ deviceId: targetDeviceId,
50692
51652
  percentage
50693
51653
  });
50694
51654
  } catch (err) {
50695
51655
  const message = err instanceof Error ? err.message : String(err);
50696
51656
  deps.logger.warn("alexa brightness-controller: setBrightness failed", { meta: {
50697
- deviceId: parsedId.childDeviceId,
51657
+ deviceId: targetDeviceId,
50698
51658
  percentage,
50699
51659
  error: message
50700
51660
  } });
@@ -50804,6 +51764,66 @@ async function handleInitializeCameraStreams(header, endpointRaw, payloadRaw, de
50804
51764
  } };
50805
51765
  }
50806
51766
  //#endregion
51767
+ //#region src/handlers/lock-controller.ts
51768
+ /** The `lock-control` state as Alexa's `lockState`. */
51769
+ function alexaLockStateFor(state) {
51770
+ switch (state) {
51771
+ case "locked":
51772
+ case "locking": return "LOCKED";
51773
+ case "unlocked":
51774
+ case "unlocking": return "UNLOCKED";
51775
+ case "jammed": return "JAMMED";
51776
+ }
51777
+ }
51778
+ async function readLockState(api, deviceId) {
51779
+ const raw = await api.lockControl.getStatus.query({ deviceId });
51780
+ const parsed = LockControlStatusSchema.pick({ state: true }).safeParse(raw);
51781
+ return parsed.success ? alexaLockStateFor(parsed.data.state) : null;
51782
+ }
51783
+ async function handleLock(header, endpoint, deps) {
51784
+ if (endpoint === void 0) return errorEnvelope(header, "INVALID_DIRECTIVE", "missing endpoint");
51785
+ const parsed = parseCameraEndpointId(endpoint.endpointId);
51786
+ if (parsed.kind !== "generic") return errorEnvelope(header, "INVALID_DIRECTIVE", "LockController is only on a lock endpoint");
51787
+ const lock = header.name === "Lock";
51788
+ const api = deps.ctx.api;
51789
+ try {
51790
+ if (lock) await api.lockControl.lock.mutate({ deviceId: parsed.deviceId });
51791
+ else await api.lockControl.unlock.mutate({ deviceId: parsed.deviceId });
51792
+ } catch (err) {
51793
+ const message = err instanceof Error ? err.message : String(err);
51794
+ deps.logger.warn(`alexa lock-controller: ${header.name} failed`, {
51795
+ tags: { deviceId: parsed.deviceId },
51796
+ meta: { error: message }
51797
+ });
51798
+ return errorEnvelope(header, "ENDPOINT_UNREACHABLE", `${header.name} failed: ${message}`);
51799
+ }
51800
+ const state = await readLockState(api, parsed.deviceId).catch(() => null) ?? (lock ? "LOCKED" : "UNLOCKED");
51801
+ deps.logger.info(`alexa lock-controller: ${header.name}`, {
51802
+ tags: { deviceId: parsed.deviceId },
51803
+ meta: { lockState: state }
51804
+ });
51805
+ return {
51806
+ event: {
51807
+ header: {
51808
+ namespace: "Alexa",
51809
+ name: "Response",
51810
+ messageId: header.messageId,
51811
+ ...header.correlationToken !== void 0 ? { correlationToken: header.correlationToken } : {},
51812
+ payloadVersion: "3"
51813
+ },
51814
+ endpoint: { endpointId: endpoint.endpointId },
51815
+ payload: {}
51816
+ },
51817
+ context: { properties: [{
51818
+ namespace: "Alexa.LockController",
51819
+ name: "lockState",
51820
+ value: state,
51821
+ timeOfSample: (/* @__PURE__ */ new Date()).toISOString(),
51822
+ uncertaintyInMilliseconds: 500
51823
+ }] }
51824
+ };
51825
+ }
51826
+ //#endregion
50807
51827
  //#region src/handlers/power-controller.ts
50808
51828
  var PowerDirectiveSchema = object({
50809
51829
  header: object({
@@ -50839,6 +51859,11 @@ async function handlePower(header, endpointRaw, deps, _callerIdentity) {
50839
51859
  });
50840
51860
  else if (parsedId.kind === "camera") return errorEnvelope(header, "INVALID_DIRECTIVE", "PowerController is not exposed on camera endpoints — direct the directive at the child accessory");
50841
51861
  else if (parsedId.kind === "alarm-panel") return errorEnvelope(header, "INVALID_DIRECTIVE", "PowerController is not exposed on alarm-panel endpoints — use Alexa.SecurityPanelController");
51862
+ else if (parsedId.kind === "generic") await api.switch.setState.mutate({
51863
+ deviceId: parsedId.deviceId,
51864
+ on
51865
+ });
51866
+ else if (parsedId.kind === "detection") return errorEnvelope(header, "INVALID_DIRECTIVE", "PowerController is not exposed on a detection sensor — it only reports");
50842
51867
  else return errorEnvelope(header, "INVALID_DIRECTIVE", parsedId.reason);
50843
51868
  return {
50844
51869
  event: {
@@ -51070,6 +52095,236 @@ function modeSuccessResponse(header, endpointId, instance, mode) {
51070
52095
  }] }
51071
52096
  };
51072
52097
  }
52098
+ var JPEG_QUALITY = 72;
52099
+ var RESIZE_TIMEOUT_MS = 4e3;
52100
+ /** Re-encode a camera frame for Alexa. Rejects (never falls back to the
52101
+ * original) on failure or timeout. */
52102
+ async function toAlexaJpeg(source) {
52103
+ const work = (async () => {
52104
+ const meta = await (0, sharp.default)(source).metadata();
52105
+ const width = meta.width;
52106
+ const height = meta.height;
52107
+ if (width === void 0 || height === void 0 || width === 0 || height === 0) throw new Error("snapshot has no readable dimensions");
52108
+ const short = Math.min(width, height);
52109
+ const targetShort = short < 360 ? 360 : Math.min(short, 480);
52110
+ const resize = width <= height ? { width: targetShort } : { height: targetShort };
52111
+ const { data, info } = await (0, sharp.default)(source).rotate().resize(resize).jpeg({
52112
+ quality: JPEG_QUALITY,
52113
+ mozjpeg: false
52114
+ }).toBuffer({ resolveWithObject: true });
52115
+ return {
52116
+ bytes: data,
52117
+ width: info.width,
52118
+ height: info.height
52119
+ };
52120
+ })();
52121
+ let timer;
52122
+ const timeout = new Promise((_, reject) => {
52123
+ timer = setTimeout(() => reject(/* @__PURE__ */ new Error("snapshot re-encode timed out")), RESIZE_TIMEOUT_MS);
52124
+ });
52125
+ try {
52126
+ return await Promise.race([work, timeout]);
52127
+ } finally {
52128
+ if (timer !== void 0) clearTimeout(timer);
52129
+ }
52130
+ }
52131
+ //#endregion
52132
+ //#region src/handlers/snapshot-provider.ts
52133
+ /** Leaves the rest of Amazon's 8 s for the re-encode and the Lambda hop. */
52134
+ var SNAPSHOT_READ_TIMEOUT_MS = 5e3;
52135
+ var SnapshotImageSchema = object({
52136
+ base64: string().min(1),
52137
+ contentType: string()
52138
+ });
52139
+ function snapshotError(header, type, message) {
52140
+ return { event: {
52141
+ header: {
52142
+ namespace: "Alexa.SmartVision.SnapshotProvider",
52143
+ name: "ErrorResponse",
52144
+ messageId: header.messageId,
52145
+ ...header.correlationToken !== void 0 ? { correlationToken: header.correlationToken } : {},
52146
+ payloadVersion: "1.1"
52147
+ },
52148
+ payload: {
52149
+ type,
52150
+ message
52151
+ }
52152
+ } };
52153
+ }
52154
+ async function readFrame(ctx, deviceId) {
52155
+ let timer;
52156
+ const timeout = new Promise((resolve) => {
52157
+ timer = setTimeout(() => resolve(null), SNAPSHOT_READ_TIMEOUT_MS);
52158
+ });
52159
+ try {
52160
+ const raw = await Promise.race([ctx.api.snapshot.getSnapshot.query({ deviceId }), timeout]);
52161
+ const parsed = SnapshotImageSchema.safeParse(raw);
52162
+ return parsed.success ? Buffer.from(parsed.data.base64, "base64") : null;
52163
+ } finally {
52164
+ if (timer !== void 0) clearTimeout(timer);
52165
+ }
52166
+ }
52167
+ async function handleGetSnapshot(header, endpoint, deps, caller) {
52168
+ if (endpoint === void 0) return errorEnvelope(header, "INVALID_DIRECTIVE", "missing endpoint");
52169
+ if (!deps.enabled()) return snapshotError(header, "DISABLED_BY_USER", "snapshots are switched off on this hub");
52170
+ const parsed = parseCameraEndpointId(endpoint.endpointId);
52171
+ if (parsed.kind !== "camera") return errorEnvelope(header, "INVALID_DIRECTIVE", "snapshots are served for camera endpoints");
52172
+ const deviceId = parsed.deviceId;
52173
+ const base = deps.publicHubUrl().replace(/\/+$/, "");
52174
+ if (!base.startsWith("https://")) {
52175
+ deps.logger.warn("alexa snapshot: no public HTTPS address — Alexa cannot fetch an image", { tags: { deviceId } });
52176
+ return errorEnvelope(header, "INTERNAL_ERROR", "the hub has no public HTTPS address");
52177
+ }
52178
+ const started = deps.now?.() ?? Date.now();
52179
+ const frame = await readFrame(deps.ctx, deviceId).catch((err) => {
52180
+ deps.logger.warn("alexa snapshot: snapshot read failed", {
52181
+ tags: { deviceId },
52182
+ meta: { error: err instanceof Error ? err.message : String(err) }
52183
+ });
52184
+ return null;
52185
+ });
52186
+ if (frame === null) return errorEnvelope(header, "ENDPOINT_UNREACHABLE", `no snapshot from device ${deviceId}`);
52187
+ let jpeg;
52188
+ try {
52189
+ jpeg = await (deps.encode ?? toAlexaJpeg)(frame);
52190
+ } catch (err) {
52191
+ deps.logger.warn("alexa snapshot: re-encode failed — not sending the original frame", {
52192
+ tags: { deviceId },
52193
+ meta: { error: err instanceof Error ? err.message : String(err) }
52194
+ });
52195
+ return errorEnvelope(header, "INTERNAL_ERROR", "snapshot could not be prepared");
52196
+ }
52197
+ const now = deps.now?.() ?? Date.now();
52198
+ const minted = deps.store.mint({
52199
+ deviceId,
52200
+ userId: caller.userId,
52201
+ bytes: jpeg.bytes,
52202
+ capturedAt: now
52203
+ });
52204
+ deps.logger.info("alexa snapshot: served", {
52205
+ tags: { deviceId },
52206
+ meta: {
52207
+ userId: caller.userId,
52208
+ bytes: jpeg.bytes.length,
52209
+ width: jpeg.width,
52210
+ height: jpeg.height,
52211
+ tookMs: now - started
52212
+ }
52213
+ });
52214
+ return { event: {
52215
+ header: {
52216
+ namespace: "Alexa.SmartVision.SnapshotProvider",
52217
+ name: "Snapshot",
52218
+ messageId: header.messageId,
52219
+ ...header.correlationToken !== void 0 ? { correlationToken: header.correlationToken } : {},
52220
+ payloadVersion: "1.1"
52221
+ },
52222
+ endpoint: { endpointId: endpoint.endpointId },
52223
+ payload: {
52224
+ value: {
52225
+ uri: `${base}${minted.path}`,
52226
+ uriExpirationTime: new Date(minted.expiresAt).toISOString(),
52227
+ authenticationType: "ACCESS_TOKEN"
52228
+ },
52229
+ timeOfSample: new Date(now).toISOString(),
52230
+ uncertaintyInMilliseconds: 1e4
52231
+ }
52232
+ } };
52233
+ }
52234
+ //#endregion
52235
+ //#region src/handlers/generic-state.ts
52236
+ /**
52237
+ * A generic device's Alexa properties from its cap slices (R5, D775) — ONE
52238
+ * mapping shared by `Alexa/ReportState` (reading each cap's status) and the
52239
+ * proactive reports (`DeviceStateChanged` slices), so the two can never answer
52240
+ * differently for the same state.
52241
+ */
52242
+ /** The caps a generic endpoint reads, in the order ReportState asks them. */
52243
+ var GENERIC_STATE_CAPS = [
52244
+ "lock-control",
52245
+ "contact",
52246
+ "motion",
52247
+ "presence",
52248
+ "temperature-sensor",
52249
+ "humidity-sensor",
52250
+ "switch",
52251
+ "brightness"
52252
+ ];
52253
+ var detection = (detected) => ({
52254
+ namespace: "",
52255
+ name: "detectionState",
52256
+ value: detected ? "DETECTED" : "NOT_DETECTED"
52257
+ });
52258
+ /** The Alexa property one cap slice sets, or null (unknown cap, bad slice). */
52259
+ function genericPropertyFor(capName, slice) {
52260
+ switch (capName) {
52261
+ case "lock-control": {
52262
+ const p = LockControlStatusSchema.pick({ state: true }).safeParse(slice);
52263
+ return p.success ? {
52264
+ namespace: "Alexa.LockController",
52265
+ name: "lockState",
52266
+ value: alexaLockStateFor(p.data.state)
52267
+ } : null;
52268
+ }
52269
+ case "contact": {
52270
+ const p = ContactStatusSchema.pick({ entryOpen: true }).safeParse(slice);
52271
+ return p.success ? {
52272
+ ...detection(p.data.entryOpen),
52273
+ namespace: "Alexa.ContactSensor"
52274
+ } : null;
52275
+ }
52276
+ case "motion": {
52277
+ const p = MotionStatusSchema.pick({ detected: true }).safeParse(slice);
52278
+ return p.success ? {
52279
+ ...detection(p.data.detected),
52280
+ namespace: "Alexa.MotionSensor"
52281
+ } : null;
52282
+ }
52283
+ case "presence": {
52284
+ const p = PresenceStatusSchema.pick({ state: true }).safeParse(slice);
52285
+ return p.success ? {
52286
+ ...detection(p.data.state === "home"),
52287
+ namespace: "Alexa.MotionSensor"
52288
+ } : null;
52289
+ }
52290
+ case "temperature-sensor": {
52291
+ const p = TemperatureSensorStatusSchema.pick({ celsius: true }).safeParse(slice);
52292
+ return p.success ? {
52293
+ namespace: "Alexa.TemperatureSensor",
52294
+ name: "temperature",
52295
+ value: {
52296
+ value: Math.round(p.data.celsius * 10) / 10,
52297
+ scale: "CELSIUS"
52298
+ }
52299
+ } : null;
52300
+ }
52301
+ case "humidity-sensor": {
52302
+ const p = HumiditySensorStatusSchema.pick({ percent: true }).safeParse(slice);
52303
+ return p.success ? {
52304
+ namespace: "Alexa.HumiditySensor",
52305
+ name: "relativeHumidity",
52306
+ value: { value: Math.max(0, Math.min(100, p.data.percent)) }
52307
+ } : null;
52308
+ }
52309
+ case "switch": {
52310
+ const p = SwitchStatusSchema.pick({ on: true }).safeParse(slice);
52311
+ return p.success ? {
52312
+ namespace: "Alexa.PowerController",
52313
+ name: "powerState",
52314
+ value: p.data.on ? "ON" : "OFF"
52315
+ } : null;
52316
+ }
52317
+ case "brightness": {
52318
+ const p = BrightnessStatusSchema.pick({ percentage: true }).safeParse(slice);
52319
+ return p.success ? {
52320
+ namespace: "Alexa.BrightnessController",
52321
+ name: "brightness",
52322
+ value: Math.max(0, Math.min(100, Math.round(p.data.percentage)))
52323
+ } : null;
52324
+ }
52325
+ default: return null;
52326
+ }
52327
+ }
51073
52328
  //#endregion
51074
52329
  //#region src/handlers/report-state.ts
51075
52330
  async function handleReportState(header, endpointRaw, deps, _callerIdentity) {
@@ -51086,6 +52341,23 @@ async function handleReportState(header, endpointRaw, deps, _callerIdentity) {
51086
52341
  else if (parsed.kind === "autotrack") await collectAutotrackProperties(api, parsed.parentDeviceId, properties, nowIso, deps.logger);
51087
52342
  else if (parsed.kind === "child") await collectChildProperties(api, parsed.childDeviceId, properties, nowIso, deps.logger);
51088
52343
  else if (parsed.kind === "alarm-panel") await collectAlarmPanelProperties(api, parsed.deviceId, properties, nowIso, deps.logger);
52344
+ else if (parsed.kind === "generic") await collectGenericProperties(api, parsed.deviceId, properties, nowIso, deps.logger);
52345
+ else if (parsed.kind === "detection") {
52346
+ properties.push({
52347
+ namespace: "Alexa.MotionSensor",
52348
+ name: "detectionState",
52349
+ value: deps.detectionPresent?.(parsed.deviceId, parsed.detectionClass) ? "DETECTED" : "NOT_DETECTED",
52350
+ timeOfSample: nowIso,
52351
+ uncertaintyInMilliseconds: 0
52352
+ });
52353
+ properties.push({
52354
+ namespace: "Alexa.EndpointHealth",
52355
+ name: "connectivity",
52356
+ value: { value: await readConnectivity(api, parsed.deviceId, deps.logger) },
52357
+ timeOfSample: nowIso,
52358
+ uncertaintyInMilliseconds: 0
52359
+ });
52360
+ }
51089
52361
  } catch (err) {
51090
52362
  deps.logger.warn("alexa report-state: collection failed", { meta: {
51091
52363
  endpointId,
@@ -51107,6 +52379,39 @@ async function handleReportState(header, endpointRaw, deps, _callerIdentity) {
51107
52379
  context: { properties }
51108
52380
  };
51109
52381
  }
52382
+ /**
52383
+ * A generic device (R5): every cap it has, read through its status, mapped by
52384
+ * the same function the proactive reports use. A cap the device does not have
52385
+ * fails its read and contributes nothing; connectivity is always answered.
52386
+ */
52387
+ async function collectGenericProperties(api, deviceId, properties, timeOfSample, logger) {
52388
+ const reads = await Promise.all(GENERIC_STATE_CAPS.map(async (capName) => {
52389
+ const status = await api.deviceState.getCapSlice.query({
52390
+ deviceId,
52391
+ capName
52392
+ }).catch(() => null);
52393
+ return status === null ? null : genericPropertyFor(capName, status);
52394
+ }));
52395
+ const seen = /* @__PURE__ */ new Set();
52396
+ for (const p of reads) {
52397
+ if (p === null) continue;
52398
+ const key = `${p.namespace}.${p.name}`;
52399
+ if (seen.has(key)) continue;
52400
+ seen.add(key);
52401
+ properties.push({
52402
+ ...p,
52403
+ timeOfSample,
52404
+ uncertaintyInMilliseconds: 0
52405
+ });
52406
+ }
52407
+ properties.push({
52408
+ namespace: "Alexa.EndpointHealth",
52409
+ name: "connectivity",
52410
+ value: { value: await readConnectivity(api, deviceId, logger) },
52411
+ timeOfSample,
52412
+ uncertaintyInMilliseconds: 0
52413
+ });
52414
+ }
51110
52415
  function numberField(value, key) {
51111
52416
  if (typeof value !== "object" || value === null) return null;
51112
52417
  const raw = value[key];
@@ -51118,17 +52423,10 @@ function boolField(value, key) {
51118
52423
  return typeof raw === "boolean" ? raw : null;
51119
52424
  }
51120
52425
  async function collectCameraProperties(api, deviceId, properties, timeOfSample, logger) {
51121
- const percentage = numberField(await api.battery.getStatus.query({ deviceId }).catch((err) => {
51122
- logger.info("alexa report-state: battery.getStatus skipped", { meta: {
51123
- deviceId,
51124
- error: err instanceof Error ? err.message : String(err)
51125
- } });
51126
- return null;
51127
- }), "percentage");
51128
- if (percentage !== null) properties.push({
51129
- namespace: "Alexa.BatteryLevelSensor",
51130
- name: "batteryLevel",
51131
- value: Math.max(0, Math.min(100, Math.round(percentage))),
52426
+ properties.push({
52427
+ namespace: "Alexa.EndpointHealth",
52428
+ name: "connectivity",
52429
+ value: { value: await readConnectivity(api, deviceId, logger) },
51132
52430
  timeOfSample,
51133
52431
  uncertaintyInMilliseconds: 0
51134
52432
  });
@@ -51515,6 +52813,35 @@ function requireDeviceScope(scopes, access) {
51515
52813
  if (!scopesAllowDeviceCap(scopes, access)) throw new Error(`directive token not authorized for device ${access}`);
51516
52814
  }
51517
52815
  /**
52816
+ * Directives that are never refused by the access gate. `SessionDisconnected`
52817
+ * only tears down a session that already exists (and releases its transcode);
52818
+ * refusing it because the user lost access in the meantime would leak the
52819
+ * egress the reaper then has to find.
52820
+ */
52821
+ var ACCESS_EXEMPT = new Set(["Alexa.RTCSessionController/SessionDisconnected"]);
52822
+ /**
52823
+ * Directives that need the device itself to answer. On a device the registry
52824
+ * reports OFFLINE they are refused with `ENDPOINT_UNREACHABLE` at once, rather
52825
+ * than spending Alexa's few-second budget on a transcode or a command that
52826
+ * cannot land (runbook C.2-b). `Alexa/ReportState` is NOT here: an offline
52827
+ * endpoint still answers, with `connectivity: UNREACHABLE` and the last known
52828
+ * values (alexa-endpointhealth). A status read that fails does not refuse.
52829
+ */
52830
+ var NEEDS_REACHABLE_DEVICE = new Set([
52831
+ "Alexa.CameraStreamController/InitializeCameraStreams",
52832
+ "Alexa.RTCSessionController/InitiateSessionWithOffer",
52833
+ "Alexa.PowerController/TurnOn",
52834
+ "Alexa.PowerController/TurnOff",
52835
+ "Alexa.BrightnessController/SetBrightness",
52836
+ "Alexa.ModeController/SetMode",
52837
+ "Alexa.ToggleController/TurnOn",
52838
+ "Alexa.ToggleController/TurnOff",
52839
+ "Alexa.SecurityPanelController/Arm",
52840
+ "Alexa.SecurityPanelController/Disarm",
52841
+ "Alexa.LockController/Lock",
52842
+ "Alexa.LockController/Unlock"
52843
+ ]);
52844
+ /**
51518
52845
  * Give an `Alexa.ErrorResponse` the directive's endpoint when it lacks one.
51519
52846
  * Amazon requires `endpoint` on every ErrorResponse to an endpoint directive
51520
52847
  * (alexa-errorresponse); the handlers build their errors from the header
@@ -51536,6 +52863,18 @@ function withErrorEndpoint(body, endpoint) {
51536
52863
  }
51537
52864
  };
51538
52865
  }
52866
+ /** The device whose online state decides whether an endpoint is reachable:
52867
+ * the device the directive actuates (an accessory for a child endpoint). */
52868
+ function reachabilityDeviceOf(endpointId) {
52869
+ const target = endpointAccessTarget(parseCameraEndpointId(endpointId));
52870
+ return target === null ? null : target.deviceId;
52871
+ }
52872
+ /** Whether the principal may CONTROL the device an endpoint addresses — the
52873
+ * talk-back gate (D771). */
52874
+ async function mayControlEndpoint(endpointId, principal, access) {
52875
+ if (endpointId === void 0 || principal === null) return false;
52876
+ return (await authorizeEndpoint(endpointId, principal, access, "create")).allowed;
52877
+ }
51539
52878
  async function dispatchDirective(rawBody, deps, callerIdentity) {
51540
52879
  const result = await dispatchParsedDirective(rawBody, deps, callerIdentity);
51541
52880
  const endpoint = rawBody?.directive?.endpoint;
@@ -51576,7 +52915,10 @@ async function dispatchParsedDirective(rawBody, deps, callerIdentity) {
51576
52915
  "Alexa.ToggleController/TurnOff": "create",
51577
52916
  "Alexa/ReportState": "view",
51578
52917
  "Alexa.SecurityPanelController/Arm": "create",
51579
- "Alexa.SecurityPanelController/Disarm": "create"
52918
+ "Alexa.SecurityPanelController/Disarm": "create",
52919
+ "Alexa.SmartVision.SnapshotProvider/GetSnapshot": "view",
52920
+ "Alexa.LockController/Lock": "create",
52921
+ "Alexa.LockController/Unlock": "create"
51580
52922
  };
51581
52923
  try {
51582
52924
  const key = `${header.namespace}/${header.name}`;
@@ -51589,17 +52931,95 @@ async function dispatchParsedDirective(rawBody, deps, callerIdentity) {
51589
52931
  body: errorEnvelope(header, "INVALID_AUTHORIZATION_CREDENTIAL", scopeErr instanceof Error ? scopeErr.message : String(scopeErr))
51590
52932
  };
51591
52933
  }
51592
- if (key === "Alexa.Discovery/Discover") return {
51593
- status: 200,
51594
- body: await handleDiscovery(header, payload, deps.discovery, callerIdentity)
51595
- };
52934
+ let principal = null;
52935
+ if (!ACCESS_EXEMPT.has(key)) {
52936
+ const lookup = await deps.access.gate.principal(callerIdentity.userId);
52937
+ if (lookup.kind === "unknown-user") {
52938
+ deps.discovery.logger.warn("alexa directive: refused — the hub user who linked this Alexa account no longer exists", { meta: {
52939
+ userId: callerIdentity.userId,
52940
+ directive: key
52941
+ } });
52942
+ return {
52943
+ status: 200,
52944
+ body: errorEnvelope(header, "INVALID_AUTHORIZATION_CREDENTIAL", "the hub user behind this account link no longer exists")
52945
+ };
52946
+ }
52947
+ if (lookup.kind === "unavailable") return {
52948
+ status: 200,
52949
+ body: errorEnvelope(header, "INTERNAL_ERROR", lookup.error)
52950
+ };
52951
+ principal = lookup.principal;
52952
+ if (key !== "Alexa.Discovery/Discover") {
52953
+ if (endpoint === void 0) return {
52954
+ status: 200,
52955
+ body: errorEnvelope(header, "INVALID_DIRECTIVE", "missing directive.endpoint")
52956
+ };
52957
+ const verdict = await authorizeEndpoint(endpoint.endpointId, principal, deps.access, requiredAccess);
52958
+ if (!verdict.allowed) {
52959
+ deps.discovery.logger.info("alexa directive: refused by the user access gate", { meta: {
52960
+ userId: callerIdentity.userId,
52961
+ directive: key,
52962
+ endpointId: endpoint.endpointId,
52963
+ reason: describeRefusal(verdict.refusal)
52964
+ } });
52965
+ return {
52966
+ status: 200,
52967
+ body: errorEnvelope(header, alexaErrorTypeFor(verdict.refusal), describeRefusal(verdict.refusal))
52968
+ };
52969
+ }
52970
+ if (NEEDS_REACHABLE_DEVICE.has(key)) {
52971
+ const deviceId = reachabilityDeviceOf(endpoint.endpointId);
52972
+ if (deviceId !== null && await readConnectivity(deps.reportState.ctx.api, deviceId, deps.reportState.logger) === "UNREACHABLE") {
52973
+ deps.reportState.logger.info("alexa directive: refused — the device is offline", {
52974
+ tags: { deviceId },
52975
+ meta: {
52976
+ directive: key,
52977
+ endpointId: endpoint.endpointId
52978
+ }
52979
+ });
52980
+ return {
52981
+ status: 200,
52982
+ body: errorEnvelope(header, "ENDPOINT_UNREACHABLE", `device ${deviceId} is offline`)
52983
+ };
52984
+ }
52985
+ }
52986
+ }
52987
+ }
52988
+ if (key === "Alexa.Discovery/Discover") {
52989
+ if (principal === null) throw new Error("discovery reached without a principal");
52990
+ const who = principal;
52991
+ return {
52992
+ status: 200,
52993
+ body: await handleDiscovery(header, payload, deps.discovery, callerIdentity, {
52994
+ deviceVisible: async (deviceId) => deps.access.isExposed(deviceId) && await deps.access.gate.allows(who, deviceId, "view") === "allowed",
52995
+ filterEndpoints: async (endpoints) => {
52996
+ const filtered = await filterEndpointsForPrincipal(endpoints, who, deps.access);
52997
+ if (filtered.refused.length > 0) deps.discovery.logger.info("alexa discovery: endpoints withheld from this link", { meta: {
52998
+ userId: callerIdentity.userId,
52999
+ withheld: filtered.refused.map((r) => ({
53000
+ endpointId: r.endpointId,
53001
+ reason: describeRefusal(r.refusal)
53002
+ }))
53003
+ } });
53004
+ const valid = withholdInvalidEndpoints(filtered.kept, (message, violations) => {
53005
+ deps.discovery.logger.warn(message, { meta: {
53006
+ userId: callerIdentity.userId,
53007
+ violations
53008
+ } });
53009
+ });
53010
+ deps.afterDiscovery(callerIdentity.userId, valid);
53011
+ return valid;
53012
+ }
53013
+ })
53014
+ };
53015
+ }
51596
53016
  if (key === "Alexa.CameraStreamController/InitializeCameraStreams") return {
51597
53017
  status: 200,
51598
53018
  body: await handleInitializeCameraStreams(header, endpoint, payload, deps.cameraStream, callerIdentity)
51599
53019
  };
51600
53020
  if (key === "Alexa.RTCSessionController/InitiateSessionWithOffer") return {
51601
53021
  status: 200,
51602
- body: await handleInitiateSessionWithOffer(header, endpoint, payload, deps.rtcSession, callerIdentity)
53022
+ body: await handleInitiateSessionWithOffer(header, endpoint, payload, deps.rtcSession, callerIdentity, { mayTalk: await mayControlEndpoint(endpoint?.endpointId, principal, deps.access) })
51603
53023
  };
51604
53024
  if (key === "Alexa.RTCSessionController/SessionConnected") return {
51605
53025
  status: 200,
@@ -51633,6 +53053,14 @@ async function dispatchParsedDirective(rawBody, deps, callerIdentity) {
51633
53053
  status: 200,
51634
53054
  body: await handleDisarm(header, endpoint, payload, deps.securityPanel, callerIdentity)
51635
53055
  };
53056
+ if (key === "Alexa.LockController/Lock" || key === "Alexa.LockController/Unlock") return {
53057
+ status: 200,
53058
+ body: await handleLock(header, endpoint, deps.lockController)
53059
+ };
53060
+ if (key === "Alexa.SmartVision.SnapshotProvider/GetSnapshot") return {
53061
+ status: 200,
53062
+ body: await handleGetSnapshot(header, endpoint, deps.snapshotProvider, callerIdentity)
53063
+ };
51636
53064
  if (key === "Alexa/ReportState") return {
51637
53065
  status: 200,
51638
53066
  body: await handleReportState(header, endpoint, deps.reportState, callerIdentity)
@@ -51866,6 +53294,81 @@ function verifyIngressToken(expected, provided) {
51866
53294
  function readIngressHeader(headers) {
51867
53295
  for (const [key, value] of Object.entries(headers)) if (key.toLowerCase() === "x-camstack-ingress") return value;
51868
53296
  }
53297
+ /** Characters of a secret a mask may keep, so an operator can tell two apart. */
53298
+ var MASK_TAIL = 4;
53299
+ /**
53300
+ * A secret as a READ surface may show it: a fixed run of dots and, for a value
53301
+ * long enough that four characters do not meaningfully shorten a guess, its
53302
+ * last four. Every read of this addon's status goes through this (S2) — the
53303
+ * value itself is handed out only by the admin-only `revealIngressToken`.
53304
+ */
53305
+ function maskSecretTail(secret) {
53306
+ if (secret.length === 0) return "";
53307
+ if (secret.length <= MASK_TAIL * 4) return "••••";
53308
+ return `••••••••${secret.slice(-4)}`;
53309
+ }
53310
+ //#endregion
53311
+ //#region src/snapshots/snapshot-route.ts
53312
+ var IMAGE_FILE = /^([0-9a-f-]{36})\.jpg$/;
53313
+ function bearerOf(headers) {
53314
+ for (const [key, value] of Object.entries(headers)) {
53315
+ if (key.toLowerCase() !== "authorization") continue;
53316
+ return /^Bearer\s+(.+)$/i.exec(value.trim())?.[1] ?? null;
53317
+ }
53318
+ return null;
53319
+ }
53320
+ function refuse(reply, status, error) {
53321
+ reply.code(status).type("application/json").header("cache-control", "no-store").header("content-encoding", "identity");
53322
+ reply.send({ error });
53323
+ }
53324
+ async function handleSnapshotFetch(req, reply, deps) {
53325
+ if (!deps.enabled()) {
53326
+ refuse(reply, 410, "snapshots are switched off");
53327
+ return;
53328
+ }
53329
+ const imageId = IMAGE_FILE.exec(req.params["file"] ?? "")?.[1];
53330
+ if (imageId === void 0) {
53331
+ refuse(reply, 410, "not a snapshot address");
53332
+ return;
53333
+ }
53334
+ const found = deps.store.lookup(imageId, req.query["exp"], req.query["sig"]);
53335
+ if (found.kind === "gone") {
53336
+ deps.logger.debug("alexa snapshot: fetch of a gone image", { meta: {
53337
+ imageId,
53338
+ reason: found.reason
53339
+ } });
53340
+ refuse(reply, 410, found.reason);
53341
+ return;
53342
+ }
53343
+ const image = found.image;
53344
+ const token = bearerOf(req.headers);
53345
+ const verified = token === null ? null : await deps.verifyToken(token).catch(() => null);
53346
+ if (verified === null) {
53347
+ deps.logger.warn("alexa snapshot: refused a fetch without a valid account-link token", {
53348
+ tags: { deviceId: image.deviceId },
53349
+ meta: {
53350
+ imageId,
53351
+ hadToken: token !== null
53352
+ }
53353
+ });
53354
+ refuse(reply, 401, "a valid Alexa account-link token is required");
53355
+ return;
53356
+ }
53357
+ if (verified.userId !== image.userId || !await deps.mayView(verified.userId, image.deviceId)) {
53358
+ deps.logger.warn("alexa snapshot: refused — the token does not belong to the user this image was made for, or that user lost the camera", {
53359
+ tags: { deviceId: image.deviceId },
53360
+ meta: {
53361
+ imageId,
53362
+ tokenUser: verified.userId,
53363
+ imageUser: image.userId
53364
+ }
53365
+ });
53366
+ refuse(reply, 403, "not allowed");
53367
+ return;
53368
+ }
53369
+ reply.code(200).type(image.contentType).header("cache-control", "private, no-store").header("content-encoding", "identity").header("content-length", String(image.bytes.byteLength));
53370
+ reply.send(image.bytes);
53371
+ }
51869
53372
  //#endregion
51870
53373
  //#region src/http-server.ts
51871
53374
  /**
@@ -51918,6 +53421,13 @@ function buildRoutes(deps) {
51918
53421
  await handleLwaTokensCallback(req, reply, deps.lwaTokensCallbackDeps);
51919
53422
  }
51920
53423
  },
53424
+ {
53425
+ method: "GET",
53426
+ path: "/snapshot/:file",
53427
+ access: "public",
53428
+ description: "Alexa SnapshotProvider image (signed, 10 min, account-link token required)",
53429
+ handler: (req, reply) => handleSnapshotFetch(req, reply, deps.snapshotRoute)
53430
+ },
51921
53431
  {
51922
53432
  method: "POST",
51923
53433
  path: "/admin/redetect-hub-url",
@@ -52100,6 +53610,46 @@ function describeGrant(acc, input) {
52100
53610
  return `Amazon push grant: held via the skill's token broker, valid for another ${minutes(token.expiresAt - input.now)} min (last renewed ${renewedAgo} min ago).`;
52101
53611
  }
52102
53612
  //#endregion
53613
+ //#region src/endpoint-signature.ts
53614
+ /**
53615
+ * One capability's identity as Alexa distinguishes it. A capability whose
53616
+ * properties are proactively reported carries a `!p` mark: Alexa's copy must
53617
+ * know the hub reports them, or the ChangeReports are worth nothing (R3) —
53618
+ * so flipping the flag is a shape change the reconcile re-pushes.
53619
+ */
53620
+ function capabilityKey(capability) {
53621
+ const instance = typeof capability.instance === "string" ? capability.instance : "";
53622
+ const base = instance.length > 0 ? `${capability.interface}#${instance}` : capability.interface;
53623
+ return capability.properties?.proactivelyReported === true ? `${base}!p` : base;
53624
+ }
53625
+ /** Read back the endpoints (and their interfaces) a signature records — what
53626
+ * Alexa was last told about a device. */
53627
+ function signatureEntries(signature) {
53628
+ if (signature.length === 0) return [];
53629
+ return signature.split(";").map((entry) => {
53630
+ const [endpointId = "", , interfaces = ""] = entry.split("|");
53631
+ return {
53632
+ endpointId,
53633
+ interfaces: interfaces.split(",").filter((i) => i.length > 0).map((i) => i.replace(/!p$/, "").replace(/#.*$/, ""))
53634
+ };
53635
+ });
53636
+ }
53637
+ /** Stable, order-independent signature of ONE endpoint's advertised shape. */
53638
+ function endpointKey(endpoint) {
53639
+ const interfaces = endpoint.capabilities.map(capabilityKey).sort().join(",");
53640
+ const categories = [...endpoint.displayCategories].sort().join(",");
53641
+ return `${endpoint.endpointId}|${categories}|${interfaces}`;
53642
+ }
53643
+ /**
53644
+ * Stable signature of the endpoint set built for ONE device. Two builds that
53645
+ * would land identically in Alexa's registry produce the same string; any
53646
+ * added, removed or renamed interface — or a changed display category —
53647
+ * produces a different one.
53648
+ */
53649
+ function endpointsSignature(endpoints) {
53650
+ return endpoints.map(endpointKey).sort().join(";");
53651
+ }
53652
+ //#endregion
52103
53653
  //#region src/motion-clear-hold.ts
52104
53654
  /**
52105
53655
  * Default hold. Matches `RESET_DEBOUNCE_MS` in
@@ -52234,6 +53784,24 @@ var MIGRATION_RETRY_MS = 300 * 1e3;
52234
53784
  /** The interface a camera endpoint must declare for a connectivity
52235
53785
  * ChangeReport to mean anything to the Alexa cloud. */
52236
53786
  var ENDPOINT_HEALTH_INTERFACE = "Alexa.EndpointHealth";
53787
+ /** Whether an exported signature (`endpoint-signature.ts`) names this
53788
+ * endpoint id. */
53789
+ function signatureNamesEndpoint(signature, endpointId) {
53790
+ return signature.split(";").some((entry) => entry.startsWith(`${endpointId}|`));
53791
+ }
53792
+ /** How long after a push the initial connectivity reports go out — the
53793
+ * AddOrUpdateReport is accepted asynchronously, and a ChangeReport for an
53794
+ * endpoint the cloud has not created yet is dropped. */
53795
+ var INITIAL_CONNECTIVITY_DELAY_MS = 5e3;
53796
+ /**
53797
+ * The device whose online state an endpoint's connectivity reports: the
53798
+ * exposed device the endpoint is published under (a camera's own id, a
53799
+ * detection sensor's camera, a generic device's id).
53800
+ */
53801
+ function connectivityDeviceOf(endpointId) {
53802
+ const target = endpointAccessTarget(parseCameraEndpointId(endpointId));
53803
+ return target === null ? null : target.exposedDeviceId;
53804
+ }
52237
53805
  /** The interface an endpoint must advertise for a `DoorbellPress` to survive
52238
53806
  * the Alexa cloud. */
52239
53807
  var DOORBELL_INTERFACE = "Alexa.DoorbellEventSource";
@@ -52264,11 +53832,12 @@ var LwaEventGateway = class {
52264
53832
  deps;
52265
53833
  unsubscribe = null;
52266
53834
  unsubscribeMotion = null;
52267
- unsubscribeBattery = null;
52268
53835
  unsubscribeConnectivity = [];
52269
53836
  /** Last connectivity reported per `userId|endpointId` — a camera that
52270
53837
  * re-announces the same state must not become a ChangeReport stream. */
52271
53838
  lastConnectivity = /* @__PURE__ */ new Map();
53839
+ /** Initial-connectivity reports waiting for the cloud to create endpoints. */
53840
+ pendingInitialReports = /* @__PURE__ */ new Set();
52272
53841
  refreshTimer = null;
52273
53842
  lastSentAt = /* @__PURE__ */ new Map();
52274
53843
  /**
@@ -52282,11 +53851,6 @@ var LwaEventGateway = class {
52282
53851
  /** Per-endpoint count of `ObjectDetection` events dropped because the
52283
53852
  * endpoint does not declare {@link SMART_VISION_INTERFACE}. */
52284
53853
  smartVisionDropped = /* @__PURE__ */ new Map();
52285
- /** Last reported integer battery percentage per `userId|endpointId` —
52286
- * battery providers may push the same level every poll cycle and
52287
- * Alexa Smart Home rate-limits ChangeReports per property; dedupe
52288
- * so only level *changes* (or step-grain ones) hit the gateway. */
52289
- lastBatteryLevel = /* @__PURE__ */ new Map();
52290
53854
  /** Latches the "no grant at all" warning so it's logged ONCE, not on every
52291
53855
  * doorbell/motion event while no account has a push grant. */
52292
53856
  warnedNoGrant = false;
@@ -52312,11 +53876,6 @@ var LwaEventGateway = class {
52312
53876
  this.deps.logger.warn("alexa lwa-gateway: motion handler crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
52313
53877
  });
52314
53878
  });
52315
- this.unsubscribeBattery = this.deps.ctx.eventBus.subscribe({ category: EventCategory.BatteryOnStatusChanged }, (event) => {
52316
- this.onBatteryStatusChanged(event.data).catch((err) => {
52317
- this.deps.logger.warn("alexa lwa-gateway: battery handler crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
52318
- });
52319
- });
52320
53879
  this.unsubscribeConnectivity = [[EventCategory.DeviceOnline, "OK"], [EventCategory.DeviceOffline, "UNREACHABLE"]].map(([category, connectivity]) => this.deps.ctx.eventBus.subscribe({ category }, (event) => {
52321
53880
  this.onConnectivityChanged(event.data, connectivity).catch((err) => {
52322
53881
  this.deps.logger.warn("alexa lwa-gateway: connectivity handler crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
@@ -52334,17 +53893,16 @@ var LwaEventGateway = class {
52334
53893
  this.unsubscribe = null;
52335
53894
  if (this.unsubscribeMotion) this.unsubscribeMotion();
52336
53895
  this.unsubscribeMotion = null;
52337
- if (this.unsubscribeBattery) this.unsubscribeBattery();
52338
- this.unsubscribeBattery = null;
52339
53896
  for (const off of this.unsubscribeConnectivity) off();
52340
53897
  this.unsubscribeConnectivity = [];
52341
53898
  this.lastConnectivity.clear();
53899
+ for (const timer of this.pendingInitialReports) clearTimeout(timer);
53900
+ this.pendingInitialReports.clear();
52342
53901
  if (this.refreshTimer) clearInterval(this.refreshTimer);
52343
53902
  this.refreshTimer = null;
52344
53903
  this.lastSentAt.clear();
52345
53904
  this.motionHold.clear();
52346
53905
  this.smartVisionDropped.clear();
52347
- this.lastBatteryLevel.clear();
52348
53906
  this.warnedNoToken.clear();
52349
53907
  this.warnedLegacyWaiting.clear();
52350
53908
  this.migrationRetryAt.clear();
@@ -52376,6 +53934,7 @@ var LwaEventGateway = class {
52376
53934
  }
52377
53935
  if (!this.hasProactiveCredentials()) return;
52378
53936
  for (const account of linked) {
53937
+ if (!this.mayPush(account.userId, parentDeviceId, "doorbell press")) continue;
52379
53938
  if (!this.findToken(account.userId)) {
52380
53939
  this.deps.logger.info("alexa lwa-gateway: no LWA token for user — skipped", {
52381
53940
  tags: { deviceId: parentDeviceId },
@@ -52400,6 +53959,19 @@ var LwaEventGateway = class {
52400
53959
  });
52401
53960
  }
52402
53961
  }
53962
+ /**
53963
+ * S1: the account's hub user may view `deviceId`. A refusal is logged at
53964
+ * debug — for a scoped user it is the EXPECTED outcome for every camera
53965
+ * outside the grant, many times a minute.
53966
+ */
53967
+ mayPush(userId, deviceId, label) {
53968
+ if (this.deps.accountMaySee(userId, deviceId)) return true;
53969
+ this.deps.logger.debug(`alexa lwa-gateway: ${label} not sent to this account — its hub user may not view the device`, {
53970
+ tags: { deviceId },
53971
+ meta: { userId }
53972
+ });
53973
+ return false;
53974
+ }
52403
53975
  isThrottled(key) {
52404
53976
  const composite = `${key.userId}|${key.endpointId}`;
52405
53977
  const last = this.lastSentAt.get(composite) ?? 0;
@@ -52422,13 +53994,57 @@ var LwaEventGateway = class {
52422
53994
  async onMotionChanged(data) {
52423
53995
  if (!this.deps.listExposed().some((e) => e.deviceId === data.deviceId)) return;
52424
53996
  const endpointId = String(data.deviceId);
53997
+ await this.pushMotionState({
53998
+ deviceId: data.deviceId,
53999
+ endpointId,
54000
+ detected: data.detected,
54001
+ timestamp: data.timestamp,
54002
+ label: "motion",
54003
+ onRisingPosted: (userId) => this.postObjectDetection({
54004
+ userId,
54005
+ deviceId: data.deviceId,
54006
+ endpointId,
54007
+ timestamp: data.timestamp
54008
+ })
54009
+ });
54010
+ }
54011
+ /**
54012
+ * A camera's detection class appeared or cleared (R1, D772): report it on
54013
+ * that class's detection-sensor endpoint, with the same hold as camera
54014
+ * motion (instant rising edge; NOT_DETECTED only after the source went
54015
+ * quiet and at least 30 s after DETECTED, Amazon's MotionSensor rule).
54016
+ *
54017
+ * Posted only to an endpoint Alexa KNOWS: a ChangeReport for an endpoint the
54018
+ * last AddOrUpdateReport did not contain is a 202 the cloud discards (D76),
54019
+ * so the exported signature must name it.
54020
+ */
54021
+ async pushDetectionSensor(opts) {
54022
+ if (!this.deps.listExposed().some((e) => e.deviceId === opts.deviceId)) return;
54023
+ const signature = this.deps.getExportedSignature(opts.deviceId);
54024
+ if (signature === null || !signatureNamesEndpoint(signature, opts.endpointId)) {
54025
+ this.deps.logger.debug("alexa lwa-gateway: detection change not sent — Alexa has not been told about this sensor endpoint", {
54026
+ tags: { deviceId: opts.deviceId },
54027
+ meta: { endpointId: opts.endpointId }
54028
+ });
54029
+ return;
54030
+ }
54031
+ await this.pushMotionState({
54032
+ ...opts,
54033
+ label: "detection"
54034
+ });
54035
+ }
54036
+ /** The per-account MotionSensor ChangeReport loop shared by camera motion
54037
+ * and the detection sensors. The hold is keyed per account AND endpoint. */
54038
+ async pushMotionState(opts) {
54039
+ const { deviceId, endpointId } = opts;
52425
54040
  const linked = this.deps.listLinkedAccounts();
52426
54041
  if (linked.length === 0) return;
52427
54042
  if (!this.hasProactiveCredentials()) return;
52428
54043
  for (const account of linked) {
54044
+ if (!this.mayPush(account.userId, deviceId, opts.label)) continue;
52429
54045
  if (!this.findToken(account.userId)) continue;
52430
54046
  const key = `${account.userId}|${endpointId}`;
52431
- if (!data.detected) {
54047
+ if (!opts.detected) {
52432
54048
  this.motionHold.onCleared(key, () => {
52433
54049
  this.postMotionChange({
52434
54050
  userId: account.userId,
@@ -52436,8 +54052,8 @@ var LwaEventGateway = class {
52436
54052
  detected: false,
52437
54053
  timestamp: Date.now()
52438
54054
  }).catch((err) => {
52439
- this.deps.logger.warn("alexa lwa-gateway: held motion clear failed to post", {
52440
- tags: { deviceId: data.deviceId },
54055
+ this.deps.logger.warn(`alexa lwa-gateway: held ${opts.label} clear failed to post`, {
54056
+ tags: { deviceId },
52441
54057
  meta: {
52442
54058
  endpointId,
52443
54059
  error: err instanceof Error ? err.message : String(err)
@@ -52452,14 +54068,9 @@ var LwaEventGateway = class {
52452
54068
  userId: account.userId,
52453
54069
  endpointId,
52454
54070
  detected: true,
52455
- timestamp: data.timestamp
52456
- });
52457
- await this.postObjectDetection({
52458
- userId: account.userId,
52459
- deviceId: data.deviceId,
52460
- endpointId,
52461
- timestamp: data.timestamp
54071
+ timestamp: opts.timestamp
52462
54072
  });
54073
+ await opts.onRisingPosted?.(account.userId);
52463
54074
  }
52464
54075
  }
52465
54076
  async postMotionChange(opts) {
@@ -52514,59 +54125,113 @@ var LwaEventGateway = class {
52514
54125
  }), `smartvision person`);
52515
54126
  }
52516
54127
  /**
52517
- * Battery slice changed. Push an `Alexa.BatteryLevelSensor`
52518
- * ChangeReport with the integer percentage. Dedupe so a provider that
52519
- * re-emits the same level on every poll doesn't flood the gateway —
52520
- * only round-percentage TRANSITIONS make it out (a typical battery
52521
- * cam reports every few minutes, and Alexa Smart Home rate-limits
52522
- * ChangeReports anyway).
54128
+ * A camera went offline or came back. Amazon requires the `connectivity`
54129
+ * ChangeReport within 3 s of the change (alexa-endpointhealth). Deduped per
54130
+ * account and endpoint, and skipped for an endpoint Alexa is known to hold
54131
+ * WITHOUT `Alexa.EndpointHealth` — the cloud would drop it (D76).
52523
54132
  */
52524
- async onBatteryStatusChanged(data) {
52525
- if (!this.deps.listExposed().some((e) => e.deviceId === data.deviceId)) return;
52526
- const endpointId = String(data.deviceId);
54133
+ async onConnectivityChanged(data, connectivity) {
54134
+ if (typeof data?.deviceId !== "number") return;
54135
+ await this.reportConnectivity(data.deviceId, connectivity);
54136
+ }
54137
+ /**
54138
+ * A device went offline or came back: report `connectivity` on EVERY
54139
+ * endpoint of it that Alexa knows with `Alexa.EndpointHealth` — the camera
54140
+ * and its detection sensors (D770). Fed by the generic `device-status`
54141
+ * slice (R3) and by the providers that also emit DeviceOnline/Offline;
54142
+ * deduplicated per account and endpoint, so the two sources never double-post.
54143
+ */
54144
+ async reportConnectivity(deviceId, connectivity) {
54145
+ if (!this.deps.listExposed().some((e) => e.deviceId === deviceId)) return;
54146
+ const signature = this.deps.getExportedSignature(deviceId);
54147
+ const endpointIds = signature === null ? [String(deviceId)] : signatureEntries(signature).filter((e) => e.interfaces.includes(ENDPOINT_HEALTH_INTERFACE)).map((e) => e.endpointId);
54148
+ if (endpointIds.length === 0) return;
52527
54149
  const linked = this.deps.listLinkedAccounts();
52528
54150
  if (linked.length === 0) return;
52529
54151
  if (!this.hasProactiveCredentials()) return;
52530
- const level = Math.max(0, Math.min(100, Math.round(data.status.percentage)));
52531
54152
  for (const account of linked) {
52532
- if (!this.findToken(account.userId)) continue;
52533
- const key = `${account.userId}|${endpointId}`;
52534
- if (this.lastBatteryLevel.get(key) === level) continue;
52535
- this.lastBatteryLevel.set(key, level);
52536
- await this.postEventBody(account.userId, endpointId, (accessToken) => buildBatteryChangeReportPayload({
52537
- userId: account.userId,
52538
- endpointId,
52539
- batteryLevel: level,
52540
- timestamp: Date.now(),
52541
- accessToken
52542
- }), `battery ${level}%`);
54153
+ if (!this.mayPush(account.userId, deviceId, "connectivity")) continue;
54154
+ for (const endpointId of endpointIds) {
54155
+ const key = `${account.userId}|${endpointId}`;
54156
+ if (this.lastConnectivity.get(key) === connectivity) continue;
54157
+ this.lastConnectivity.set(key, connectivity);
54158
+ await this.postEventBody(account.userId, endpointId, (accessToken) => buildConnectivityChangeReportPayload({
54159
+ endpointId,
54160
+ connectivity,
54161
+ timestamp: Date.now(),
54162
+ accessToken
54163
+ }), `connectivity ${connectivity}`);
54164
+ }
52543
54165
  }
52544
54166
  }
52545
54167
  /**
52546
- * A camera went offline or came back. Amazon requires the `connectivity`
52547
- * ChangeReport within 3 s of the change (alexa-endpointhealth). Deduped per
52548
- * account and endpoint, and skipped for an endpoint Alexa is known to hold
52549
- * WITHOUT `Alexa.EndpointHealth` — the cloud would drop it (D76).
54168
+ * A device state change Alexa should hear about (R3, D776): the alarm
54169
+ * panel, a privacy/autotrack switch, an accessory. Posted only to an
54170
+ * endpoint Alexa knows WITH the interface the properties belong to (D76).
52550
54171
  */
52551
- async onConnectivityChanged(data, connectivity) {
52552
- if (typeof data?.deviceId !== "number") return;
52553
- if (!this.deps.listExposed().some((e) => e.deviceId === data.deviceId)) return;
52554
- const signature = this.deps.getExportedSignature(data.deviceId);
52555
- if (signature !== null && !signature.includes(ENDPOINT_HEALTH_INTERFACE)) return;
54172
+ async postStateChange(report) {
54173
+ if (!this.deps.listExposed().some((e) => e.deviceId === report.exposedDeviceId)) return;
54174
+ const signature = this.deps.getExportedSignature(report.exposedDeviceId);
54175
+ const entry = signature === null ? void 0 : signatureEntries(signature).find((e) => e.endpointId === report.endpointId);
54176
+ if (entry === void 0 || !entry.interfaces.includes(report.interfaceName)) {
54177
+ this.deps.logger.debug("alexa lwa-gateway: state change not sent — Alexa has not been told about this endpoint/interface", {
54178
+ tags: { deviceId: report.exposedDeviceId },
54179
+ meta: {
54180
+ endpointId: report.endpointId,
54181
+ interfaceName: report.interfaceName
54182
+ }
54183
+ });
54184
+ return;
54185
+ }
52556
54186
  const linked = this.deps.listLinkedAccounts();
52557
54187
  if (linked.length === 0) return;
52558
54188
  if (!this.hasProactiveCredentials()) return;
52559
- const endpointId = String(data.deviceId);
52560
54189
  for (const account of linked) {
52561
- const key = `${account.userId}|${endpointId}`;
52562
- if (this.lastConnectivity.get(key) === connectivity) continue;
52563
- this.lastConnectivity.set(key, connectivity);
52564
- await this.postEventBody(account.userId, endpointId, (accessToken) => buildConnectivityChangeReportPayload({
52565
- endpointId,
54190
+ if (!this.mayPush(account.userId, report.exposedDeviceId, "state change")) continue;
54191
+ if (!this.findToken(account.userId)) continue;
54192
+ await this.postEventBody(account.userId, report.endpointId, (accessToken) => buildStateChangeReportPayload({
54193
+ endpointId: report.endpointId,
54194
+ properties: report.properties,
54195
+ timestamp: Date.now(),
54196
+ accessToken
54197
+ }), `state ${report.interfaceName}`);
54198
+ }
54199
+ }
54200
+ /**
54201
+ * After endpoints reach an account (AddOrUpdateReport or a Discover answer),
54202
+ * tell Alexa each one's connectivity, so it never holds an endpoint of
54203
+ * unknown health — the state in which the phone app refused live view
54204
+ * (D770). Delayed so the cloud has created the endpoints the report names.
54205
+ * Only endpoints that DECLARE `Alexa.EndpointHealth` are reported.
54206
+ */
54207
+ scheduleInitialConnectivity(userId, endpoints) {
54208
+ const targets = endpoints.filter((e) => e.capabilities.some((c) => c.interface === ENDPOINT_HEALTH_INTERFACE));
54209
+ if (targets.length === 0) return;
54210
+ const timer = setTimeout(() => {
54211
+ this.reportInitialConnectivity(userId, targets).catch((err) => {
54212
+ this.deps.logger.warn("alexa lwa-gateway: initial connectivity report failed", { meta: {
54213
+ userId,
54214
+ error: err instanceof Error ? err.message : String(err)
54215
+ } });
54216
+ });
54217
+ }, INITIAL_CONNECTIVITY_DELAY_MS);
54218
+ timer.unref?.();
54219
+ this.pendingInitialReports.add(timer);
54220
+ }
54221
+ async reportInitialConnectivity(userId, endpoints) {
54222
+ if (!this.hasProactiveCredentials()) return;
54223
+ for (const endpoint of endpoints) {
54224
+ const deviceId = connectivityDeviceOf(endpoint.endpointId);
54225
+ if (deviceId === null) continue;
54226
+ if (!this.mayPush(userId, deviceId, "initial connectivity")) continue;
54227
+ const connectivity = await readConnectivity(this.deps.ctx.api, deviceId, this.deps.logger);
54228
+ this.lastConnectivity.set(`${userId}|${endpoint.endpointId}`, connectivity);
54229
+ await this.postEventBody(userId, endpoint.endpointId, (accessToken) => buildConnectivityChangeReportPayload({
54230
+ endpointId: endpoint.endpointId,
52566
54231
  connectivity,
52567
54232
  timestamp: Date.now(),
52568
54233
  accessToken
52569
- }), `connectivity ${connectivity}`);
54234
+ }), `initial connectivity ${connectivity}`);
52570
54235
  }
52571
54236
  }
52572
54237
  /**
@@ -52625,14 +54290,25 @@ var LwaEventGateway = class {
52625
54290
  * with a fresh header would ship a token Amazon has already rejected. */
52626
54291
  async postEventBody(userId, endpointId, buildBody, label) {
52627
54292
  const url = EVENT_GATEWAY_URLS[this.deps.getRegion()];
52628
- const send = async (accessToken) => fetch(url, {
52629
- method: "POST",
52630
- headers: {
52631
- "Content-Type": "application/json",
52632
- Authorization: `Bearer ${accessToken}`
52633
- },
52634
- body: JSON.stringify(buildBody(accessToken))
52635
- });
54293
+ const send = async (accessToken) => {
54294
+ const body = JSON.stringify(buildBody(accessToken));
54295
+ return sendWithGatewayRetry(() => fetch(url, {
54296
+ method: "POST",
54297
+ headers: {
54298
+ "Content-Type": "application/json",
54299
+ Authorization: `Bearer ${accessToken}`
54300
+ },
54301
+ body
54302
+ }), (attempt, reason, delayMs) => {
54303
+ this.deps.logger.info(`alexa lwa-gateway: ${label} retrying`, { meta: {
54304
+ userId,
54305
+ endpointId,
54306
+ attempt,
54307
+ reason,
54308
+ delayMs
54309
+ } });
54310
+ });
54311
+ };
52636
54312
  const initial = this.findToken(userId);
52637
54313
  if (!initial) {
52638
54314
  if (!this.warnedNoToken.has(userId)) {
@@ -52807,12 +54483,65 @@ var LwaEventGateway = class {
52807
54483
  return false;
52808
54484
  }
52809
54485
  };
54486
+ /**
54487
+ * Statuses Amazon tells a sender to retry (smarthome/send-events: 429
54488
+ * THROTTLING, 500 INTERNAL_SERVICE, 503 SERVICE_UNAVAILABLE — "resend the
54489
+ * message up to three times, with at least a one-second interval").
54490
+ */
54491
+ var RETRYABLE_GATEWAY_STATUSES = new Set([
54492
+ 429,
54493
+ 500,
54494
+ 503
54495
+ ]);
54496
+ /** Delays before retry 1, 2, 3 — bounded, at least one second each. */
54497
+ var GATEWAY_RETRY_DELAYS_MS = [
54498
+ 1e3,
54499
+ 2e3,
54500
+ 4e3
54501
+ ];
54502
+ /** A `Retry-After` the gateway sends is honoured up to this much. */
54503
+ var MAX_RETRY_AFTER_MS = 1e4;
54504
+ function retryAfterMs(response) {
54505
+ const raw = response.headers?.get?.("retry-after");
54506
+ if (raw === null || raw === void 0) return 0;
54507
+ const seconds = Number(raw);
54508
+ return Number.isFinite(seconds) && seconds > 0 ? Math.min(seconds * 1e3, MAX_RETRY_AFTER_MS) : 0;
54509
+ }
54510
+ /**
54511
+ * POST with Amazon's retry rule: a retryable status or a network failure is
54512
+ * re-sent up to three times, waiting 1 s / 2 s / 4 s (or the gateway's
54513
+ * `Retry-After`, capped). Anything else — 2xx, 401 (renewal is the caller's),
54514
+ * 403, 400 — is returned at once. The last answer (or the last network error)
54515
+ * is what the caller sees.
54516
+ */
54517
+ async function sendWithGatewayRetry(attemptOnce, onRetry) {
54518
+ for (let attempt = 0;; attempt++) {
54519
+ let response = null;
54520
+ let failure = null;
54521
+ try {
54522
+ response = await attemptOnce();
54523
+ } catch (err) {
54524
+ failure = err;
54525
+ }
54526
+ const retryable = response === null || RETRYABLE_GATEWAY_STATUSES.has(response.status);
54527
+ const delay = GATEWAY_RETRY_DELAYS_MS[attempt];
54528
+ if (!retryable || delay === void 0) {
54529
+ if (response !== null) return response;
54530
+ throw failure;
54531
+ }
54532
+ const wait = response === null ? delay : Math.max(delay, retryAfterMs(response));
54533
+ onRetry(attempt + 1, response === null ? `network: ${failure instanceof Error ? failure.message : String(failure)}` : `status ${response.status}`, wait);
54534
+ await new Promise((resolve) => {
54535
+ setTimeout(resolve, wait).unref?.();
54536
+ });
54537
+ }
54538
+ }
52810
54539
  function buildDoorbellPressPayload(opts) {
52811
54540
  return { event: {
52812
54541
  header: {
52813
54542
  namespace: "Alexa.DoorbellEventSource",
52814
54543
  name: "DoorbellPress",
52815
- messageId: `doorbell-${opts.userId}-${opts.timestamp}`,
54544
+ messageId: (0, node_crypto.randomUUID)(),
52816
54545
  payloadVersion: "3"
52817
54546
  },
52818
54547
  endpoint: {
@@ -52835,7 +54564,7 @@ function buildMotionChangeReportPayload(opts) {
52835
54564
  header: {
52836
54565
  namespace: "Alexa",
52837
54566
  name: "ChangeReport",
52838
- messageId: `motion-${opts.userId}-${opts.timestamp}`,
54567
+ messageId: (0, node_crypto.randomUUID)(),
52839
54568
  payloadVersion: "3"
52840
54569
  },
52841
54570
  endpoint: {
@@ -52859,14 +54588,14 @@ function buildMotionChangeReportPayload(opts) {
52859
54588
  context: { properties: [] }
52860
54589
  };
52861
54590
  }
52862
- function buildBatteryChangeReportPayload(opts) {
54591
+ function buildStateChangeReportPayload(opts) {
52863
54592
  const timeOfSample = new Date(opts.timestamp).toISOString();
52864
54593
  return {
52865
54594
  event: {
52866
54595
  header: {
52867
54596
  namespace: "Alexa",
52868
54597
  name: "ChangeReport",
52869
- messageId: `battery-${opts.userId}-${opts.timestamp}`,
54598
+ messageId: (0, node_crypto.randomUUID)(),
52870
54599
  payloadVersion: "3"
52871
54600
  },
52872
54601
  endpoint: {
@@ -52877,14 +54606,14 @@ function buildBatteryChangeReportPayload(opts) {
52877
54606
  endpointId: opts.endpointId
52878
54607
  },
52879
54608
  payload: { change: {
52880
- cause: { type: "PERIODIC_POLL" },
52881
- properties: [{
52882
- namespace: "Alexa.BatteryLevelSensor",
52883
- name: "batteryLevel",
52884
- value: opts.batteryLevel,
54609
+ cause: { type: "PHYSICAL_INTERACTION" },
54610
+ properties: opts.properties.map((p) => ({
54611
+ namespace: p.namespace,
54612
+ name: p.name,
54613
+ value: p.value,
52885
54614
  timeOfSample,
52886
54615
  uncertaintyInMilliseconds: 0
52887
- }]
54616
+ }))
52888
54617
  } }
52889
54618
  },
52890
54619
  context: { properties: [] }
@@ -52926,7 +54655,7 @@ function buildSmartVisionObjectDetectionEvent(opts) {
52926
54655
  header: {
52927
54656
  namespace: "Alexa.SmartVision.ObjectDetectionSensor",
52928
54657
  name: "ObjectDetection",
52929
- messageId: `smartvision-${opts.userId}-${opts.timestamp}`,
54658
+ messageId: (0, node_crypto.randomUUID)(),
52930
54659
  payloadVersion: "1.0"
52931
54660
  },
52932
54661
  endpoint: {
@@ -52947,12 +54676,12 @@ function buildSmartVisionObjectDetectionEvent(opts) {
52947
54676
  })) }
52948
54677
  } };
52949
54678
  }
52950
- function buildAddOrUpdateReportPayload(userId, endpoints, accessToken) {
54679
+ function buildAddOrUpdateReportPayload(_userId, endpoints, accessToken) {
52951
54680
  return { event: {
52952
54681
  header: {
52953
54682
  namespace: "Alexa.Discovery",
52954
54683
  name: "AddOrUpdateReport",
52955
- messageId: `discovery-addupdate-${userId}-${Date.now()}`,
54684
+ messageId: (0, node_crypto.randomUUID)(),
52956
54685
  payloadVersion: "3"
52957
54686
  },
52958
54687
  payload: {
@@ -52964,12 +54693,12 @@ function buildAddOrUpdateReportPayload(userId, endpoints, accessToken) {
52964
54693
  }
52965
54694
  } };
52966
54695
  }
52967
- function buildDeleteReportPayload(userId, endpointIds, accessToken) {
54696
+ function buildDeleteReportPayload(_userId, endpointIds, accessToken) {
52968
54697
  return { event: {
52969
54698
  header: {
52970
54699
  namespace: "Alexa.Discovery",
52971
54700
  name: "DeleteReport",
52972
- messageId: `discovery-delete-${userId}-${Date.now()}`,
54701
+ messageId: (0, node_crypto.randomUUID)(),
52973
54702
  payloadVersion: "3"
52974
54703
  },
52975
54704
  payload: {
@@ -52982,6 +54711,425 @@ function buildDeleteReportPayload(userId, endpointIds, accessToken) {
52982
54711
  } };
52983
54712
  }
52984
54713
  //#endregion
54714
+ //#region src/access/account-visibility.ts
54715
+ /** Event-path gate. Unknown account ⇒ refused (fail closed). */
54716
+ function visibilityAllows(entry, deviceId) {
54717
+ if (entry === void 0) return false;
54718
+ if (entry.all === true) return true;
54719
+ return entry.view.includes(deviceId);
54720
+ }
54721
+ function visibleSet(entry, exposed) {
54722
+ if (entry === void 0 || entry.all === true) return new Set(exposed);
54723
+ return new Set(entry.view);
54724
+ }
54725
+ var AccountVisibilityMirror = class {
54726
+ deps;
54727
+ flight = null;
54728
+ constructor(deps) {
54729
+ this.deps = deps;
54730
+ }
54731
+ /** Synchronous event-path read. */
54732
+ allows(userId, deviceId) {
54733
+ return visibilityAllows(this.deps.getStored()[userId], deviceId);
54734
+ }
54735
+ /** Recompute every linked account. One pass at a time; a caller joins the
54736
+ * pass in flight. Returns the changes the caller must apply to Alexa. */
54737
+ async refresh() {
54738
+ if (this.flight === null) this.flight = this.refreshNow().finally(() => {
54739
+ this.flight = null;
54740
+ });
54741
+ return this.flight;
54742
+ }
54743
+ async refreshNow() {
54744
+ const exposed = this.deps.listExposedDeviceIds();
54745
+ const stored = this.deps.getStored();
54746
+ const next = {};
54747
+ const changes = [];
54748
+ for (const userId of this.deps.listLinkedUserIds()) {
54749
+ const prev = stored[userId];
54750
+ const computed = await this.compute(userId, prev, exposed);
54751
+ if (computed === null) {
54752
+ if (prev !== void 0) next[userId] = prev;
54753
+ continue;
54754
+ }
54755
+ next[userId] = computed.entry;
54756
+ if (computed.change.added.length > 0 || computed.change.removed.length > 0) changes.push(computed.change);
54757
+ }
54758
+ await this.deps.setStored(next);
54759
+ return changes;
54760
+ }
54761
+ async compute(userId, prev, exposed) {
54762
+ const lookup = await this.deps.gate.principal(userId);
54763
+ if (lookup.kind === "unavailable") return null;
54764
+ const before = visibleSet(prev, exposed);
54765
+ const prevPending = new Set(prev !== void 0 && prev.all !== true ? prev.pendingRemoval ?? [] : []);
54766
+ if (lookup.kind === "found" && lookup.principal.kind === "admin") return {
54767
+ entry: { all: true },
54768
+ change: {
54769
+ userId,
54770
+ added: prev === void 0 ? [] : exposed.filter((id) => !before.has(id)),
54771
+ removed: []
54772
+ }
54773
+ };
54774
+ const view = [];
54775
+ for (const deviceId of exposed) {
54776
+ if (lookup.kind === "unknown-user") continue;
54777
+ const verdict = await this.deps.gate.allows(lookup.principal, deviceId, "view");
54778
+ if (verdict === "allowed" || verdict === "unknown" && before.has(deviceId)) view.push(deviceId);
54779
+ }
54780
+ const viewSet = new Set(view);
54781
+ const added = prev === void 0 ? [] : view.filter((id) => !before.has(id));
54782
+ const lostNow = exposed.filter((id) => before.has(id) && !viewSet.has(id));
54783
+ const removed = [...prevPending].filter((id) => !viewSet.has(id));
54784
+ const pendingRemoval = lostNow.filter((id) => !prevPending.has(id));
54785
+ if (lookup.kind === "unknown-user" && prev === void 0) this.deps.logger.warn("alexa access: a linked Alexa account belongs to a hub user that no longer exists — it will be shown nothing", { meta: { userId } });
54786
+ return {
54787
+ entry: pendingRemoval.length > 0 ? {
54788
+ view,
54789
+ pendingRemoval
54790
+ } : { view },
54791
+ change: {
54792
+ userId,
54793
+ added,
54794
+ removed
54795
+ }
54796
+ };
54797
+ }
54798
+ };
54799
+ //#endregion
54800
+ //#region src/access/hub-access-source.ts
54801
+ function createHubAccessGate(ctx) {
54802
+ return new AlexaAccessGate({
54803
+ logger: ctx.logger,
54804
+ listUserGrants: async () => {
54805
+ return (await ctx.api.userManagement.listUsers.query()).map((u) => ({
54806
+ userId: u.id,
54807
+ isAdmin: u.isAdmin,
54808
+ scopes: u.scopes
54809
+ }));
54810
+ },
54811
+ getDeviceMeta: async (deviceId) => {
54812
+ const device = await ctx.api.deviceManager.getDevice.query({ deviceId });
54813
+ if (device === null) return null;
54814
+ return {
54815
+ id: device.id,
54816
+ type: String(device.type),
54817
+ location: device.location,
54818
+ parentDeviceId: device.parentDeviceId
54819
+ };
54820
+ }
54821
+ });
54822
+ }
54823
+ //#endregion
54824
+ //#region src/access/visibility-sync.ts
54825
+ async function applyVisibilityChanges(changes, target, logger) {
54826
+ for (const change of changes) {
54827
+ for (const deviceId of change.added) {
54828
+ const ok = await target.pushDeviceToAccount(change.userId, deviceId).catch((err) => {
54829
+ logger.warn("alexa access: pushing a newly granted device failed", {
54830
+ tags: { deviceId },
54831
+ meta: {
54832
+ userId: change.userId,
54833
+ error: errMsg(err)
54834
+ }
54835
+ });
54836
+ return false;
54837
+ });
54838
+ logger.info("alexa access: the linked user gained this device — pushed to their Alexa account", {
54839
+ tags: { deviceId },
54840
+ meta: {
54841
+ userId: change.userId,
54842
+ delivered: ok
54843
+ }
54844
+ });
54845
+ }
54846
+ for (const deviceId of change.removed) {
54847
+ const ok = await target.removeDeviceFromAccount(change.userId, deviceId).catch((err) => {
54848
+ logger.warn("alexa access: deleting a revoked device failed", {
54849
+ tags: { deviceId },
54850
+ meta: {
54851
+ userId: change.userId,
54852
+ error: errMsg(err)
54853
+ }
54854
+ });
54855
+ return false;
54856
+ });
54857
+ logger.warn("alexa access: the linked user lost this device (confirmed twice) — deleted from their Alexa account", {
54858
+ tags: { deviceId },
54859
+ meta: {
54860
+ userId: change.userId,
54861
+ delivered: ok
54862
+ }
54863
+ });
54864
+ }
54865
+ }
54866
+ }
54867
+ //#endregion
54868
+ //#region src/detection-sensors/detection-tracker.ts
54869
+ /**
54870
+ * Which detection classes are currently present on each camera (R1, D772).
54871
+ *
54872
+ * Fed by the pipeline's track lifecycle (`PipelineAnalyticsTrackLifecycle`,
54873
+ * `data.deviceId` — these events carry no device on their `source`) and by
54874
+ * package deliveries. A class is present while at least one of ITS tracks is
54875
+ * open: the set is per `(camera, class)` and per `trackId`, so the end of one
54876
+ * person's track does not clear "Person" while another person is still in
54877
+ * view (the HA export's per-event flip has exactly that bug).
54878
+ *
54879
+ * Events are telemetry and may be dropped (D8), and the pipeline does not
54880
+ * promise an `end` for every `start` (a zero-media false birth or a discarded
54881
+ * stale track ends silently). So {@link reconcile} replaces a camera's sets
54882
+ * with an authoritative read of its ACTIVE tracks; a sensor can therefore never
54883
+ * stay "detected" past one reconcile after its last track closed. A read that
54884
+ * fails is not passed in — the sets then stay as they were.
54885
+ *
54886
+ * Emits a transition per `(camera, class)` only when presence changes; the
54887
+ * Alexa-facing hold (≥ 30 s DETECTED before NOT_DETECTED) is the gateway's.
54888
+ */
54889
+ /** A package delivery is an instant, not a track: it is present this long. */
54890
+ var PACKAGE_PULSE_MS = 1e3;
54891
+ /** The detection class a pipeline class name belongs to, or null. Pipeline
54892
+ * classes are already macros; a raw COCO label is mapped the same way the
54893
+ * executor maps it. */
54894
+ function detectionClassOf(className) {
54895
+ if (className === void 0) return null;
54896
+ const lower = className.toLowerCase();
54897
+ const isClass = (v) => DETECTION_SENSOR_CLASSES.includes(v);
54898
+ if (isClass(lower)) return lower;
54899
+ const mapped = COCO_TO_MACRO.mapping[lower];
54900
+ return mapped !== void 0 && isClass(mapped) ? mapped : null;
54901
+ }
54902
+ var DetectionTracker = class {
54903
+ onTransition;
54904
+ /** `${deviceId}|${class}` → open track ids. */
54905
+ open = /* @__PURE__ */ new Map();
54906
+ pulseTimers = /* @__PURE__ */ new Set();
54907
+ constructor(onTransition) {
54908
+ this.onTransition = onTransition;
54909
+ }
54910
+ onTrackLifecycle(event) {
54911
+ const cls = detectionClassOf(event.bestClassName ?? event.classes?.[0]);
54912
+ if (cls === null) return;
54913
+ if (event.phase === "end") {
54914
+ this.remove(event.deviceId, cls, event.trackId);
54915
+ for (const other of DETECTION_SENSOR_CLASSES) if (other !== cls) this.remove(event.deviceId, other, event.trackId);
54916
+ return;
54917
+ }
54918
+ this.add(event.deviceId, cls, event.trackId);
54919
+ }
54920
+ /** A package was delivered: "Package" is detected, as a pulse. */
54921
+ onPackageDelivered(deviceId, entryId) {
54922
+ const id = `package:${entryId}`;
54923
+ this.add(deviceId, "package", id);
54924
+ const timer = setTimeout(() => {
54925
+ this.pulseTimers.delete(timer);
54926
+ this.remove(deviceId, "package", id);
54927
+ }, PACKAGE_PULSE_MS);
54928
+ timer.unref?.();
54929
+ this.pulseTimers.add(timer);
54930
+ }
54931
+ /** Replace a camera's open tracks with what the pipeline says is ACTIVE. */
54932
+ reconcile(deviceId, active) {
54933
+ const truth = /* @__PURE__ */ new Map();
54934
+ for (const track of active) {
54935
+ const cls = detectionClassOf(track.className);
54936
+ if (cls === null) continue;
54937
+ const set = truth.get(cls) ?? /* @__PURE__ */ new Set();
54938
+ set.add(track.trackId);
54939
+ truth.set(cls, set);
54940
+ }
54941
+ for (const cls of DETECTION_SENSOR_CLASSES) {
54942
+ const key = `${deviceId}|${cls}`;
54943
+ const before = this.open.get(key) ?? /* @__PURE__ */ new Set();
54944
+ const after = truth.get(cls) ?? /* @__PURE__ */ new Set();
54945
+ for (const id of before) if (id.startsWith("package:")) after.add(id);
54946
+ this.replace(deviceId, cls, before, after);
54947
+ }
54948
+ }
54949
+ /** Cameras with at least one class present — the ones worth reconciling. */
54950
+ camerasWithPresence() {
54951
+ const out = /* @__PURE__ */ new Set();
54952
+ for (const [key, set] of this.open) if (set.size > 0) out.add(Number(key.split("|")[0]));
54953
+ return [...out];
54954
+ }
54955
+ isPresent(deviceId, cls) {
54956
+ return (this.open.get(`${deviceId}|${cls}`)?.size ?? 0) > 0;
54957
+ }
54958
+ clear() {
54959
+ this.open.clear();
54960
+ for (const t of this.pulseTimers) clearTimeout(t);
54961
+ this.pulseTimers.clear();
54962
+ }
54963
+ add(deviceId, cls, id) {
54964
+ const key = `${deviceId}|${cls}`;
54965
+ const set = this.open.get(key) ?? /* @__PURE__ */ new Set();
54966
+ const was = set.size > 0;
54967
+ set.add(id);
54968
+ this.open.set(key, set);
54969
+ if (!was) this.onTransition(deviceId, cls, true);
54970
+ }
54971
+ remove(deviceId, cls, id) {
54972
+ const set = this.open.get(`${deviceId}|${cls}`);
54973
+ if (set === void 0 || !set.delete(id)) return;
54974
+ if (set.size === 0) this.onTransition(deviceId, cls, false);
54975
+ }
54976
+ replace(deviceId, cls, before, after) {
54977
+ this.open.set(`${deviceId}|${cls}`, after);
54978
+ if (before.size > 0 && after.size === 0) this.onTransition(deviceId, cls, false);
54979
+ else if (before.size === 0 && after.size > 0) this.onTransition(deviceId, cls, true);
54980
+ }
54981
+ };
54982
+ /** The detection classes an event-kind answer offers. */
54983
+ function detectionClassesOfKinds(kinds) {
54984
+ const out = /* @__PURE__ */ new Set();
54985
+ for (const k of kinds) {
54986
+ if (k.level !== "macro") continue;
54987
+ if (k.category !== "detection" && k.category !== "package") continue;
54988
+ const cls = detectionClassOf(k.kind);
54989
+ if (cls !== null) out.add(cls);
54990
+ }
54991
+ return DETECTION_SENSOR_CLASSES.filter((c) => out.has(c));
54992
+ }
54993
+ function sameClasses(a, b) {
54994
+ return JSON.stringify(a ?? null) === JSON.stringify(b);
54995
+ }
54996
+ var DetectionClassSource = class {
54997
+ deps;
54998
+ /** Serializes the persisted map's read-modify-write: builds run four
54999
+ * cameras at once, and two writes computed off one snapshot would erase
55000
+ * each other's camera. */
55001
+ writeChain = Promise.resolve();
55002
+ constructor(deps) {
55003
+ this.deps = deps;
55004
+ }
55005
+ persist(deviceId, next) {
55006
+ const write = this.writeChain.then(() => this.deps.setStored({
55007
+ ...this.deps.getStored(),
55008
+ [deviceId]: next
55009
+ }));
55010
+ this.writeChain = write.catch(() => void 0);
55011
+ return write;
55012
+ }
55013
+ async classesFor(deviceId) {
55014
+ const prev = this.deps.getStored()[deviceId];
55015
+ let read;
55016
+ try {
55017
+ read = detectionClassesOfKinds(await this.deps.readEventKinds(deviceId));
55018
+ } catch (err) {
55019
+ this.deps.logger.debug("alexa detection sensors: class read failed — keeping the last answer", {
55020
+ tags: { deviceId },
55021
+ meta: { error: errMsg(err) }
55022
+ });
55023
+ return prev?.classes ?? [];
55024
+ }
55025
+ const now = this.deps.now?.() ?? Date.now();
55026
+ const missingSince = {};
55027
+ const keep = new Set(read);
55028
+ const removed = [];
55029
+ for (const cls of prev?.classes ?? []) {
55030
+ if (keep.has(cls)) continue;
55031
+ const since = prev?.missingSince?.[cls] ?? now;
55032
+ if (now - since >= 9e5) {
55033
+ removed.push(cls);
55034
+ continue;
55035
+ }
55036
+ keep.add(cls);
55037
+ missingSince[cls] = since;
55038
+ }
55039
+ const classes = DETECTION_SENSOR_CLASSES.filter((c) => keep.has(c));
55040
+ const next = Object.keys(missingSince).length > 0 ? {
55041
+ classes,
55042
+ missingSince
55043
+ } : { classes };
55044
+ if (!sameClasses(prev, next)) {
55045
+ await this.persist(deviceId, next);
55046
+ if (removed.length > 0 || classes.some((c) => !(prev?.classes ?? []).includes(c))) this.deps.logger.info("alexa detection sensors: the camera's detection classes changed", {
55047
+ tags: { deviceId },
55048
+ meta: {
55049
+ classes,
55050
+ removed
55051
+ }
55052
+ });
55053
+ }
55054
+ return classes;
55055
+ }
55056
+ };
55057
+ //#endregion
55058
+ //#region src/detection-sensors/detection-feed.ts
55059
+ var DETECTION_RECONCILE_INTERVAL_MS = 6e4;
55060
+ var TrackLifecycleSchema = object({
55061
+ deviceId: number(),
55062
+ trackId: string(),
55063
+ phase: _enum([
55064
+ "start",
55065
+ "update",
55066
+ "end"
55067
+ ]),
55068
+ bestClassName: string().optional(),
55069
+ classes: array(string()).optional()
55070
+ });
55071
+ var PackageDeliveredSchema = object({
55072
+ deviceId: number(),
55073
+ entryId: string()
55074
+ });
55075
+ var DetectionSensorFeed = class {
55076
+ deps;
55077
+ tracker;
55078
+ offs = [];
55079
+ timer = null;
55080
+ constructor(deps) {
55081
+ this.deps = deps;
55082
+ this.tracker = new DetectionTracker((deviceId, cls, present) => {
55083
+ this.onTransition(deviceId, cls, present);
55084
+ });
55085
+ }
55086
+ start() {
55087
+ this.offs.push(this.deps.eventBus.subscribe({ category: EventCategory.PipelineAnalyticsTrackLifecycle }, (event) => {
55088
+ const parsed = TrackLifecycleSchema.safeParse(event.data);
55089
+ if (parsed.success) this.tracker.onTrackLifecycle(parsed.data);
55090
+ }), this.deps.eventBus.subscribe({ category: EventCategory.PipelineAnalyticsPackageDelivered }, (event) => {
55091
+ const parsed = PackageDeliveredSchema.safeParse(event.data);
55092
+ if (parsed.success) this.tracker.onPackageDelivered(parsed.data.deviceId, parsed.data.entryId);
55093
+ }));
55094
+ this.timer = setInterval(() => void this.reconcile(), DETECTION_RECONCILE_INTERVAL_MS);
55095
+ this.timer.unref?.();
55096
+ }
55097
+ stop() {
55098
+ for (const off of this.offs.splice(0)) off();
55099
+ if (this.timer !== null) clearInterval(this.timer);
55100
+ this.timer = null;
55101
+ this.tracker.clear();
55102
+ }
55103
+ isPresent(deviceId, cls) {
55104
+ return this.deps.enabled() && this.tracker.isPresent(deviceId, cls);
55105
+ }
55106
+ /** One reconcile pass over every camera that shows a class present. A read
55107
+ * that fails changes nothing for that camera. */
55108
+ async reconcile() {
55109
+ for (const deviceId of this.tracker.camerasWithPresence()) try {
55110
+ this.tracker.reconcile(deviceId, await this.deps.readActiveTracks(deviceId));
55111
+ } catch (err) {
55112
+ this.deps.logger.debug("alexa detection sensors: active-track read failed — kept as is", {
55113
+ tags: { deviceId },
55114
+ meta: { error: errMsg(err) }
55115
+ });
55116
+ }
55117
+ }
55118
+ onTransition(deviceId, cls, present) {
55119
+ if (!this.deps.enabled() || !this.deps.isExposed(deviceId)) return;
55120
+ this.deps.push(deviceId, cls, present, Date.now()).catch((err) => {
55121
+ this.deps.logger.warn("alexa detection sensors: change report failed", {
55122
+ tags: { deviceId },
55123
+ meta: {
55124
+ detectionClass: cls,
55125
+ present,
55126
+ error: errMsg(err)
55127
+ }
55128
+ });
55129
+ });
55130
+ }
55131
+ };
55132
+ //#endregion
52985
55133
  //#region src/oauth-integration.ts
52986
55134
  /**
52987
55135
  * The `export-alexa` OAuth client — the descriptor behind
@@ -53079,6 +55227,418 @@ function buildAlexaOauthIntegration(publicHubUrl) {
53079
55227
  };
53080
55228
  }
53081
55229
  //#endregion
55230
+ //#region src/setup-block.ts
55231
+ function buildAlexaSetupBlock(input) {
55232
+ const base = input.publicHubUrl.replace(/\/+$/, "");
55233
+ return {
55234
+ fields: [
55235
+ ...base ? [{
55236
+ label: "Directive endpoint",
55237
+ value: `${base}/addon/export-alexa/directive`
55238
+ }] : [],
55239
+ ...input.ingressToken ? [{
55240
+ label: "Ingress token (Lambda HUB_INGRESS_TOKEN) — use \"Show ingress token\" in settings",
55241
+ value: maskSecretTail(input.ingressToken)
55242
+ }] : [],
55243
+ {
55244
+ label: "Linked Alexa accounts",
55245
+ value: String(input.linkedAccountCount)
55246
+ }
55247
+ ],
55248
+ note: base ? "Account linking is handled by the core OAuth server. Use the hub's /api/oauth2/authorize endpoint with integration=export-alexa." : "Set the public hub URL on the settings form first — the directive endpoint URL is derived from it."
55249
+ };
55250
+ }
55251
+ /**
55252
+ * The ONE derivation of "a signed, expiring URL".
55253
+ *
55254
+ * This repo mints unguessable, self-expiring links in three places now — the
55255
+ * notification artifact plane, the Home Assistant media plane, and the snapshot
55256
+ * link plane. The first two grew independently and are byte-identical logic
55257
+ * (`hmac(secret, "<id>:<exp>")`, expiry checked before a constant-time compare),
55258
+ * each restating the crypto locally because **addons never import each other**.
55259
+ *
55260
+ * That reason is real, and the conclusion drawn from it was wrong. Two copies of
55261
+ * a signing scheme is how one of them quietly ends up with a different TTL, a
55262
+ * different compare, or a missing expiry check, and nothing fails until a link
55263
+ * that should have died keeps working. The fix is the same one D52 applies to
55264
+ * crop geometry: one derivation, in a place every addon may depend on. A
55265
+ * framework package is exactly that place — `@camstack/types/node`, off the root
55266
+ * entry because `node:crypto` must never be traversed by a browser bundler.
55267
+ *
55268
+ * What this module deliberately does NOT decide: the TTL, the base URL, the
55269
+ * shape of `id`, and the access level of the route. Those are per-plane policy
55270
+ * and each caller states them where a reader can see them.
55271
+ */
55272
+ /**
55273
+ * The signature over `(id, expMs)`.
55274
+ *
55275
+ * `id` is whatever the plane uses to name the thing being served — an artifact
55276
+ * id, a track id, a `<deviceId>:<width>` pair. It is joined with `:` so a caller
55277
+ * must not put a `:` inside a field whose boundary matters; where a plane has
55278
+ * more than one field, it composes them itself and owns that ambiguity.
55279
+ */
55280
+ function signExpiringUrl(secret, id, expMs) {
55281
+ return (0, node_crypto.createHmac)("sha256", secret).update(`${id}:${String(expMs)}`).digest("hex");
55282
+ }
55283
+ /**
55284
+ * Verify a request's `(id, exp, sig)`.
55285
+ *
55286
+ * **Expiry is checked BEFORE the compare**, so an expired link is refused
55287
+ * whether or not its signature is valid — a leaked URL stops working on its own
55288
+ * and cannot be kept alive by holding a correct signature. The compare itself is
55289
+ * constant-time so a public route cannot be probed for the signature byte by
55290
+ * byte.
55291
+ */
55292
+ function verifyExpiringUrl(input) {
55293
+ const { secret, id, exp, sig, nowMs } = input;
55294
+ if (exp === void 0 || sig === void 0) return false;
55295
+ const expMs = typeof exp === "number" ? exp : Number(exp);
55296
+ if (!Number.isFinite(expMs)) return false;
55297
+ if (expMs <= nowMs) return false;
55298
+ const expected = signExpiringUrl(secret, id, expMs);
55299
+ const a = Buffer.from(expected, "utf8");
55300
+ const b = Buffer.from(sig, "utf8");
55301
+ if (a.length !== b.length) return false;
55302
+ return (0, node_crypto.timingSafeEqual)(a, b);
55303
+ }
55304
+ //#endregion
55305
+ //#region src/snapshots/snapshot-store.ts
55306
+ /**
55307
+ * The images this hub has handed to Alexa (R2, D773), each behind a signed,
55308
+ * short-lived URL.
55309
+ *
55310
+ * Amazon's rules for a SnapshotProvider image URI (device-apis/
55311
+ * alexa-smartvision-snapshotprovider): HTTPS, NOT public — Alexa presents the
55312
+ * user's access token — valid about ten minutes, HTTP 410 once expired, no
55313
+ * redirects. So every image is minted for ONE linked user and ONE device, kept
55314
+ * in memory until it expires, and addressed by a random id whose URL carries an
55315
+ * HMAC over `(imageId, exp)`. A hub restart forgets them all; Alexa then gets a
55316
+ * 410 and asks for a new snapshot, which is the documented recovery.
55317
+ *
55318
+ * Bounded by count and by bytes: Alexa refreshes a tile at most every
55319
+ * `minRefreshIntervalInSeconds` per camera, so a few hundred kilobytes per
55320
+ * camera is the steady state; the bound is what keeps a misbehaving client from
55321
+ * turning this into a memory leak.
55322
+ */
55323
+ /** How long a minted image URL is valid — Amazon recommends ten minutes. */
55324
+ var SNAPSHOT_URL_TTL_MS = 10 * 6e4;
55325
+ var MAX_IMAGES = 128;
55326
+ var MAX_TOTAL_BYTES = 24 * 1024 * 1024;
55327
+ var SnapshotStore = class {
55328
+ secret;
55329
+ now;
55330
+ images = /* @__PURE__ */ new Map();
55331
+ totalBytes = 0;
55332
+ constructor(secret, now = Date.now) {
55333
+ this.secret = secret;
55334
+ this.now = now;
55335
+ }
55336
+ /** Keep `bytes` for `userId`/`deviceId` and return the signed path to it. */
55337
+ mint(input) {
55338
+ this.prune();
55339
+ const imageId = (0, node_crypto.randomUUID)();
55340
+ const expiresAt = this.now() + SNAPSHOT_URL_TTL_MS;
55341
+ const image = {
55342
+ imageId,
55343
+ deviceId: input.deviceId,
55344
+ userId: input.userId,
55345
+ bytes: input.bytes,
55346
+ contentType: "image/jpeg",
55347
+ capturedAt: input.capturedAt,
55348
+ expiresAt
55349
+ };
55350
+ this.images.set(imageId, image);
55351
+ this.totalBytes += image.bytes.length;
55352
+ this.evictOverBounds();
55353
+ return {
55354
+ imageId,
55355
+ expiresAt,
55356
+ path: `/addon/export-alexa/snapshot/${imageId}.jpg?exp=${expiresAt}&sig=${signExpiringUrl(this.secret(), imageId, expiresAt)}`
55357
+ };
55358
+ }
55359
+ /** Resolve a fetch of a minted URL. */
55360
+ lookup(imageId, exp, sig) {
55361
+ const now = this.now();
55362
+ const expMs = Number(exp);
55363
+ if (Number.isFinite(expMs) && expMs <= now) return {
55364
+ kind: "gone",
55365
+ reason: "expired"
55366
+ };
55367
+ if (!verifyExpiringUrl({
55368
+ secret: this.secret(),
55369
+ id: imageId,
55370
+ exp,
55371
+ sig,
55372
+ nowMs: now
55373
+ })) return {
55374
+ kind: "gone",
55375
+ reason: "bad-signature"
55376
+ };
55377
+ const image = this.images.get(imageId);
55378
+ if (image === void 0 || image.expiresAt <= now) return {
55379
+ kind: "gone",
55380
+ reason: "unknown-image"
55381
+ };
55382
+ return {
55383
+ kind: "found",
55384
+ image
55385
+ };
55386
+ }
55387
+ /** Drop everything — the integration switch was turned off. */
55388
+ clear() {
55389
+ this.images.clear();
55390
+ this.totalBytes = 0;
55391
+ }
55392
+ get size() {
55393
+ return this.images.size;
55394
+ }
55395
+ prune() {
55396
+ const now = this.now();
55397
+ for (const [id, image] of this.images) if (image.expiresAt <= now) this.drop(id, image);
55398
+ }
55399
+ evictOverBounds() {
55400
+ for (const [id, image] of this.images) {
55401
+ if (this.images.size <= MAX_IMAGES && this.totalBytes <= MAX_TOTAL_BYTES) return;
55402
+ this.drop(id, image);
55403
+ }
55404
+ }
55405
+ drop(id, image) {
55406
+ this.images.delete(id);
55407
+ this.totalBytes -= image.bytes.length;
55408
+ }
55409
+ };
55410
+ //#endregion
55411
+ //#region src/state-reports/state-change-map.ts
55412
+ /**
55413
+ * What a device's state change means for Alexa (R3, D776).
55414
+ *
55415
+ * Every cap state change on the hub arrives as ONE event —
55416
+ * `DeviceStateChanged {deviceId, capName, slice}` (the device-state mirror;
55417
+ * the per-cap `on…Changed` events the cap files declare are never emitted).
55418
+ * This maps one such change to the Alexa properties it changes, on the
55419
+ * endpoints this addon last published (`exportedEndpoints`): the alarm panel's
55420
+ * `armState` / `burglaryAlarm`, a privacy or autotrack switch's `powerState`,
55421
+ * an accessory's `powerState` / `brightness`, and — for `device-status` — the
55422
+ * device's connectivity.
55423
+ *
55424
+ * Pure: no I/O, so the mapping is pinned by a spec without an event bus.
55425
+ */
55426
+ /** Every published endpoint, keyed by the device whose state drives it. */
55427
+ function indexByDrivingDevice(exported) {
55428
+ const out = /* @__PURE__ */ new Map();
55429
+ const add = (deviceId, entry) => {
55430
+ const list = out.get(deviceId) ?? [];
55431
+ list.push(entry);
55432
+ out.set(deviceId, list);
55433
+ };
55434
+ for (const [key, ids] of Object.entries(exported)) {
55435
+ const exposedDeviceId = Number(key);
55436
+ for (const endpointId of ids) {
55437
+ const parsed = parseCameraEndpointId(endpointId);
55438
+ switch (parsed.kind) {
55439
+ case "camera":
55440
+ add(parsed.deviceId, {
55441
+ exposedDeviceId,
55442
+ endpointId,
55443
+ kind: "camera"
55444
+ });
55445
+ break;
55446
+ case "privacy":
55447
+ case "autotrack":
55448
+ add(parsed.parentDeviceId, {
55449
+ exposedDeviceId,
55450
+ endpointId,
55451
+ kind: parsed.kind
55452
+ });
55453
+ break;
55454
+ case "child":
55455
+ add(parsed.childDeviceId, {
55456
+ exposedDeviceId,
55457
+ endpointId,
55458
+ kind: "child"
55459
+ });
55460
+ break;
55461
+ case "alarm-panel":
55462
+ add(parsed.deviceId, {
55463
+ exposedDeviceId,
55464
+ endpointId,
55465
+ kind: "alarm-panel"
55466
+ });
55467
+ break;
55468
+ case "detection":
55469
+ add(parsed.deviceId, {
55470
+ exposedDeviceId,
55471
+ endpointId,
55472
+ kind: "detection"
55473
+ });
55474
+ break;
55475
+ case "generic":
55476
+ add(parsed.deviceId, {
55477
+ exposedDeviceId,
55478
+ endpointId,
55479
+ kind: "generic"
55480
+ });
55481
+ break;
55482
+ case "invalid": break;
55483
+ }
55484
+ }
55485
+ }
55486
+ return out;
55487
+ }
55488
+ var EnabledSchema = object({ enabled: boolean() });
55489
+ var OnSchema = object({ on: boolean() });
55490
+ var PercentageSchema = object({ percentage: number() });
55491
+ var OnlineSchema = object({ online: boolean() });
55492
+ function power(on) {
55493
+ return {
55494
+ namespace: "Alexa.PowerController",
55495
+ name: "powerState",
55496
+ value: on ? "ON" : "OFF"
55497
+ };
55498
+ }
55499
+ function stateReportsFor(change, exported) {
55500
+ if (change.capName === "device-status") {
55501
+ const parsed = OnlineSchema.safeParse(change.slice);
55502
+ return parsed.success ? {
55503
+ kind: "connectivity",
55504
+ deviceId: change.deviceId,
55505
+ online: parsed.data.online
55506
+ } : null;
55507
+ }
55508
+ const endpoints = indexByDrivingDevice(exported).get(change.deviceId) ?? [];
55509
+ const reports = [];
55510
+ const of = (kind) => endpoints.filter((e) => e.kind === kind).map((e) => ({
55511
+ exposedDeviceId: e.exposedDeviceId,
55512
+ endpointId: e.endpointId
55513
+ }));
55514
+ const generic = genericPropertyFor(change.capName, change.slice);
55515
+ if (generic !== null) for (const e of of("generic")) reports.push({
55516
+ ...e,
55517
+ interfaceName: generic.namespace,
55518
+ properties: [generic]
55519
+ });
55520
+ switch (change.capName) {
55521
+ case "alarm-panel": {
55522
+ const parsed = AlarmPanelStatusSchema.pick({
55523
+ state: true,
55524
+ targetMode: true
55525
+ }).safeParse(change.slice);
55526
+ if (!parsed.success) return null;
55527
+ for (const e of of("alarm-panel")) reports.push({
55528
+ ...e,
55529
+ interfaceName: "Alexa.SecurityPanelController",
55530
+ properties: [{
55531
+ namespace: "Alexa.SecurityPanelController",
55532
+ name: "armState",
55533
+ value: alexaArmStateFor(parsed.data.state, parsed.data.targetMode ?? null)
55534
+ }, {
55535
+ namespace: "Alexa.SecurityPanelController",
55536
+ name: "burglaryAlarm",
55537
+ value: burglaryAlarmFor(parsed.data.state)
55538
+ }]
55539
+ });
55540
+ break;
55541
+ }
55542
+ case "privacy-mask":
55543
+ case "ptz-autotrack": {
55544
+ const parsed = EnabledSchema.safeParse(change.slice);
55545
+ if (!parsed.success) return null;
55546
+ const kind = change.capName === "privacy-mask" ? "privacy" : "autotrack";
55547
+ for (const e of of(kind)) reports.push({
55548
+ ...e,
55549
+ interfaceName: "Alexa.PowerController",
55550
+ properties: [power(parsed.data.enabled)]
55551
+ });
55552
+ break;
55553
+ }
55554
+ case "switch": {
55555
+ const parsed = OnSchema.safeParse(change.slice);
55556
+ if (!parsed.success) break;
55557
+ for (const e of of("child")) reports.push({
55558
+ ...e,
55559
+ interfaceName: "Alexa.PowerController",
55560
+ properties: [power(parsed.data.on)]
55561
+ });
55562
+ break;
55563
+ }
55564
+ case "brightness": {
55565
+ const parsed = PercentageSchema.safeParse(change.slice);
55566
+ if (!parsed.success) break;
55567
+ const value = Math.max(0, Math.min(100, Math.round(parsed.data.percentage)));
55568
+ for (const e of of("child")) reports.push({
55569
+ ...e,
55570
+ interfaceName: "Alexa.BrightnessController",
55571
+ properties: [{
55572
+ namespace: "Alexa.BrightnessController",
55573
+ name: "brightness",
55574
+ value
55575
+ }]
55576
+ });
55577
+ break;
55578
+ }
55579
+ default: break;
55580
+ }
55581
+ return reports.length > 0 ? {
55582
+ kind: "reports",
55583
+ reports
55584
+ } : null;
55585
+ }
55586
+ //#endregion
55587
+ //#region src/state-reports/state-change-reporter.ts
55588
+ var DeviceStateChangedSchema = object({
55589
+ deviceId: number(),
55590
+ capName: string(),
55591
+ slice: record(string(), unknown())
55592
+ });
55593
+ var StateChangeReporter = class {
55594
+ deps;
55595
+ off = null;
55596
+ lastReported = /* @__PURE__ */ new Map();
55597
+ constructor(deps) {
55598
+ this.deps = deps;
55599
+ }
55600
+ start() {
55601
+ this.off = this.deps.eventBus.subscribe({ category: EventCategory.DeviceStateChanged }, (event) => {
55602
+ const parsed = DeviceStateChangedSchema.safeParse(event.data);
55603
+ if (parsed.success) this.onChange(parsed.data);
55604
+ });
55605
+ }
55606
+ stop() {
55607
+ this.off?.();
55608
+ this.off = null;
55609
+ this.lastReported.clear();
55610
+ }
55611
+ async onChange(change) {
55612
+ const outcome = stateReportsFor(change, this.deps.getExportedEndpoints());
55613
+ if (outcome === null) return;
55614
+ try {
55615
+ if (outcome.kind === "connectivity") {
55616
+ await this.deps.reportConnectivity(outcome.deviceId, outcome.online ? "OK" : "UNREACHABLE");
55617
+ return;
55618
+ }
55619
+ for (const report of outcome.reports) {
55620
+ const key = `${report.endpointId}|${report.interfaceName}`;
55621
+ const value = JSON.stringify(report.properties);
55622
+ if (this.lastReported.get(key) === value) continue;
55623
+ this.lastReported.set(key, value);
55624
+ if (report.interfaceName === "Alexa.MotionSensor") {
55625
+ await this.deps.postHeldMotion(report, report.properties[0]?.value === "DETECTED");
55626
+ continue;
55627
+ }
55628
+ await this.deps.postStateChange(report);
55629
+ }
55630
+ } catch (err) {
55631
+ this.deps.logger.warn("alexa state reports: posting a state change failed", {
55632
+ tags: { deviceId: change.deviceId },
55633
+ meta: {
55634
+ capName: change.capName,
55635
+ error: errMsg(err)
55636
+ }
55637
+ });
55638
+ }
55639
+ }
55640
+ };
55641
+ //#endregion
53082
55642
  //#region src/public-skill.ts
53083
55643
  /**
53084
55644
  * The built-in "public" CamStack Alexa skill.
@@ -53097,28 +55657,6 @@ function buildAlexaOauthIntegration(publicHubUrl) {
53097
55657
  */
53098
55658
  var PUBLIC_SKILL_ID = "amzn1.ask.skill.aa85d6df-1600-4f13-bda9-babcfdc849e6";
53099
55659
  //#endregion
53100
- //#region src/endpoint-signature.ts
53101
- /** One capability's identity as Alexa distinguishes it. */
53102
- function capabilityKey(capability) {
53103
- const instance = typeof capability.instance === "string" ? capability.instance : "";
53104
- return instance.length > 0 ? `${capability.interface}#${instance}` : capability.interface;
53105
- }
53106
- /** Stable, order-independent signature of ONE endpoint's advertised shape. */
53107
- function endpointKey(endpoint) {
53108
- const interfaces = endpoint.capabilities.map(capabilityKey).sort().join(",");
53109
- const categories = [...endpoint.displayCategories].sort().join(",");
53110
- return `${endpoint.endpointId}|${categories}|${interfaces}`;
53111
- }
53112
- /**
53113
- * Stable signature of the endpoint set built for ONE device. Two builds that
53114
- * would land identically in Alexa's registry produce the same string; any
53115
- * added, removed or renamed interface — or a changed display category —
53116
- * produces a different one.
53117
- */
53118
- function endpointsSignature(endpoints) {
53119
- return endpoints.map(endpointKey).sort().join(";");
53120
- }
53121
- //#endregion
53122
55660
  //#region src/sync-engine.ts
53123
55661
  /**
53124
55662
  * Devices reconciled at once in {@link SyncEngine.reconcileAllExposed}. Each
@@ -53166,8 +55704,18 @@ var SyncEngine = class {
53166
55704
  };
53167
55705
  }
53168
55706
  const endpointIds = endpoints.map((e) => e.endpointId);
55707
+ const retired = (this.deps.getExportedEndpoints()[deviceId] ?? []).filter((id) => !endpointIds.includes(id) && this.deps.mayRetire(id));
53169
55708
  const failures = [];
53170
- for (const account of accounts) if (!await this.deps.postAddOrUpdate(account.userId, endpoints)) failures.push(`${account.userId}:${deviceId}`);
55709
+ for (const account of accounts) {
55710
+ if (!await this.postFiltered(account.userId, deviceId, endpoints)) failures.push(`${account.userId}:${deviceId}`);
55711
+ if (retired.length > 0) {
55712
+ if (!await this.deps.postDelete(account.userId, retired)) failures.push(`${account.userId}:${deviceId}:delete`);
55713
+ }
55714
+ }
55715
+ if (retired.length > 0) this.deps.logger.info("alexa sync: retired endpoints the device no longer builds", {
55716
+ tags: { deviceId },
55717
+ meta: { retired }
55718
+ });
53171
55719
  await this.recordEndpoints(deviceId, endpointIds, endpointsSignature(endpoints));
53172
55720
  return {
53173
55721
  deviceCount: 1,
@@ -53213,7 +55761,7 @@ var SyncEngine = class {
53213
55761
  for (const { deviceId } of this.deps.listExposed()) {
53214
55762
  const endpoints = await this.deps.buildEndpoints(deviceId);
53215
55763
  if (endpoints.length === 0) continue;
53216
- if (!await this.deps.postAddOrUpdate(userId, endpoints)) failures.push(`${userId}:${deviceId}`);
55764
+ if (!await this.postFiltered(userId, deviceId, endpoints)) failures.push(`${userId}:${deviceId}`);
53217
55765
  await this.recordEndpoints(deviceId, endpoints.map((e) => e.endpointId), endpointsSignature(endpoints));
53218
55766
  deviceCount += 1;
53219
55767
  endpointCount += endpoints.length;
@@ -53225,6 +55773,49 @@ var SyncEngine = class {
53225
55773
  failures
53226
55774
  };
53227
55775
  }
55776
+ /**
55777
+ * Post `endpoints` to one account, cut to what that account's user may see.
55778
+ * Nothing visible is a success with nothing to send: an empty
55779
+ * AddOrUpdateReport is an Alexa protocol error.
55780
+ */
55781
+ async postFiltered(userId, deviceId, endpoints) {
55782
+ const filtered = await this.deps.filterForAccount(userId, endpoints);
55783
+ if (filtered === null) {
55784
+ this.deps.logger.warn("alexa sync: the linked user's grants could not be read — this account was NOT sent the device", {
55785
+ tags: { deviceId },
55786
+ meta: { userId }
55787
+ });
55788
+ return false;
55789
+ }
55790
+ const visible = withholdInvalidEndpoints(filtered, (message, violations) => {
55791
+ this.deps.logger.warn(message, {
55792
+ tags: { deviceId },
55793
+ meta: {
55794
+ userId,
55795
+ violations
55796
+ }
55797
+ });
55798
+ });
55799
+ if (visible.length === 0) return true;
55800
+ const ok = await this.deps.postAddOrUpdate(userId, visible);
55801
+ if (ok) this.deps.afterPush(userId, visible);
55802
+ return ok;
55803
+ }
55804
+ /** A device the account's user just gained (visibility mirror). */
55805
+ async pushDeviceToAccount(userId, deviceId) {
55806
+ if (!this.deps.hasProactiveCredentials()) return false;
55807
+ if (!this.deps.listExposed().some((e) => e.deviceId === deviceId)) return true;
55808
+ const endpoints = await this.deps.buildEndpoints(deviceId);
55809
+ if (endpoints.length === 0) return false;
55810
+ return this.postFiltered(userId, deviceId, endpoints);
55811
+ }
55812
+ /** A device the account's user lost (confirmed by two reads): delete every
55813
+ * endpoint we ever published for it from THAT account only. */
55814
+ async removeDeviceFromAccount(userId, deviceId) {
55815
+ if (!this.deps.hasProactiveCredentials()) return false;
55816
+ const ids = this.deps.getExportedEndpoints()[deviceId] ?? [String(deviceId)];
55817
+ return this.deps.postDelete(userId, ids);
55818
+ }
53228
55819
  async removeDevice(deviceId) {
53229
55820
  const ids = this.deps.getExportedEndpoints()[deviceId] ?? [];
53230
55821
  if (ids.length === 0) return {
@@ -53383,6 +55974,17 @@ var SyncEngine = class {
53383
55974
  * provider respawn emits one `DeviceBindingsChanged` per cap; reconciling in
53384
55975
  * the middle of that burst would read a half-registered binding set. */
53385
55976
  var BINDINGS_RECONCILE_DEBOUNCE_MS = 5e3;
55977
+ /** How often each linked account's visibility is recomputed from the live
55978
+ * user grants (S1). Also the bound on how long an event keeps reaching a user
55979
+ * whose grant was just revoked. */
55980
+ var VISIBILITY_REFRESH_INTERVAL_MS = 6e4;
55981
+ /** First visibility pass after boot — soon, because until an account has an
55982
+ * entry its events are refused (fail closed). */
55983
+ var BOOT_VISIBILITY_DELAY_MS = 5e3;
55984
+ /** How often every exposed camera's endpoint shape is re-checked while the
55985
+ * detection sensors are on — a camera whose pipeline gained or (confirmed)
55986
+ * lost a class gets its sensors added or removed. */
55987
+ var DETECTION_CLASS_REFRESH_MS = 15 * 6e4;
53386
55988
  /** Delay before the boot-time reconcile pass. Long enough for device providers
53387
55989
  * to have registered their caps — a device whose bindings have not landed
53388
55990
  * builds a shape we must never push to Alexa. */
@@ -53421,18 +56023,47 @@ var DEFAULT_CONFIG = {
53421
56023
  availableEndpoints: [],
53422
56024
  ingressToken: "",
53423
56025
  enforceIngressToken: false,
53424
- staleEndpointPurgeInput: ""
56026
+ staleEndpointPurgeInput: "",
56027
+ accountVisibility: {},
56028
+ detectionSensors: true,
56029
+ detectionClasses: {},
56030
+ snapshots: true,
56031
+ snapshotSigningKey: ""
53425
56032
  };
56033
+ /** The one field of `oauthVerifyAccessToken`'s answer the snapshot route
56034
+ * reads. */
56035
+ var VerifiedTokenSchema = object({ userId: string() }).nullable();
53426
56036
  /** UI filter for the Export picker. `alarm-panel` publishes an
53427
56037
  * `Alexa.SecurityPanelController` endpoint (`endpoints/builder.ts`). */
53428
56038
  var SUPPORTED_KINDS = [
53429
56039
  "camera",
53430
56040
  "doorbell",
53431
- DeviceType.AlarmPanel
56041
+ DeviceType.AlarmPanel,
56042
+ DeviceType.Light,
56043
+ DeviceType.Switch,
56044
+ DeviceType.Siren,
56045
+ DeviceType.Sensor,
56046
+ DeviceType.Lock,
56047
+ DeviceType.Presence,
56048
+ DeviceType.Thermostat,
56049
+ DeviceType.Generic
53432
56050
  ];
53433
56051
  var ExportAlexaAddon = class extends BaseAddon {
53434
56052
  lwaGateway = null;
53435
56053
  syncEngine = null;
56054
+ /** S1 (D767): the linking user's live grants. Built in `onInitialize`, the
56055
+ * first point `ctx` exists. */
56056
+ accessGateRef = null;
56057
+ visibilityRef = null;
56058
+ detectionClassSource = null;
56059
+ /** The images handed to Alexa, behind signed URLs (R2). In memory only: a
56060
+ * restart answers 410 and Alexa asks again. */
56061
+ snapshotStore = new SnapshotStore(() => this.config.snapshotSigningKey);
56062
+ detectionFeed = null;
56063
+ stateReporter = null;
56064
+ /** The switches whose change re-shapes every exposed endpoint, as last
56065
+ * applied — `onConfigChanged` runs on EVERY settings write. */
56066
+ appliedShapeSwitches = null;
53436
56067
  /** Per-device debounce timers for the bindings-change discovery reconcile. */
53437
56068
  pendingReconcileTimers = /* @__PURE__ */ new Map();
53438
56069
  constructor() {
@@ -53440,6 +56071,7 @@ var ExportAlexaAddon = class extends BaseAddon {
53440
56071
  }
53441
56072
  async onInitialize() {
53442
56073
  if (!this.config.ingressToken) await this.updateGlobalSettings({ ingressToken: generateIngressToken() });
56074
+ if (!this.config.snapshotSigningKey) await this.updateGlobalSettings({ snapshotSigningKey: generateIngressToken() });
53443
56075
  if (this.config.lwaClientSecret) {
53444
56076
  await this.updateGlobalSettings({ lwaClientSecret: "" });
53445
56077
  this.ctx.logger.warn("export-alexa: removed a stored LWA client secret — the hub no longer uses one; your skill Lambda holds it (D753)");
@@ -53448,6 +56080,21 @@ var ExportAlexaAddon = class extends BaseAddon {
53448
56080
  this.autoDetectPublicHubUrl().catch((err) => {
53449
56081
  this.ctx.logger.warn("export-alexa: public hub URL auto-detect failed", { meta: { error: errMsg(err) } });
53450
56082
  });
56083
+ this.accessGateRef = createHubAccessGate(this.ctx);
56084
+ this.detectionClassSource = new DetectionClassSource({
56085
+ readEventKinds: (deviceId) => this.ctx.api.pipelineAnalytics.listEventKinds.query({ deviceId }),
56086
+ getStored: () => this.config.detectionClasses,
56087
+ setStored: (next) => this.updateGlobalSettings({ detectionClasses: next }),
56088
+ logger: this.ctx.logger
56089
+ });
56090
+ this.visibilityRef = new AccountVisibilityMirror({
56091
+ gate: this.accessGateRef,
56092
+ listLinkedUserIds: () => this.config.linkedAccounts.map((a) => a.userId),
56093
+ listExposedDeviceIds: () => this.config.exposed.map((e) => e.deviceId),
56094
+ getStored: () => this.config.accountVisibility,
56095
+ setStored: (next) => this.updateGlobalSettings({ accountVisibility: next }),
56096
+ logger: this.ctx.logger
56097
+ });
53451
56098
  const deviceExportProvider = {
53452
56099
  getStatus: async () => ({
53453
56100
  linkState: this.config.linkedAccounts.length > 0 ? "linked" : "unlinked",
@@ -53470,7 +56117,8 @@ var ExportAlexaAddon = class extends BaseAddon {
53470
56117
  discovery: {
53471
56118
  ctx: this.ctx,
53472
56119
  logger: this.ctx.logger,
53473
- listExposed: async () => this.config.exposed
56120
+ listExposed: async () => this.config.exposed,
56121
+ buildEndpoints: (deviceId) => this.buildEndpointsForOneDevice(deviceId)
53474
56122
  },
53475
56123
  cameraStream: {
53476
56124
  ctx: this.ctx,
@@ -53501,12 +56149,26 @@ var ExportAlexaAddon = class extends BaseAddon {
53501
56149
  },
53502
56150
  reportState: {
53503
56151
  ctx: this.ctx,
53504
- logger: this.ctx.logger
56152
+ logger: this.ctx.logger,
56153
+ detectionPresent: (deviceId, cls) => this.detectionFeed?.isPresent(deviceId, cls) ?? false
53505
56154
  },
53506
56155
  securityPanel: {
53507
56156
  ctx: this.ctx,
53508
56157
  logger: this.ctx.logger
53509
- }
56158
+ },
56159
+ lockController: {
56160
+ ctx: this.ctx,
56161
+ logger: this.ctx.logger
56162
+ },
56163
+ snapshotProvider: {
56164
+ ctx: this.ctx,
56165
+ logger: this.ctx.logger,
56166
+ store: this.snapshotStore,
56167
+ enabled: () => this.config.snapshots,
56168
+ publicHubUrl: () => this.config.publicHubUrl
56169
+ },
56170
+ access: this.endpointAccess,
56171
+ afterDiscovery: (userId, endpoints) => this.requireGateway().scheduleInitialConnectivity(userId, endpoints)
53510
56172
  },
53511
56173
  lwaTokensCallbackDeps: {
53512
56174
  ctx: this.ctx,
@@ -53515,7 +56177,18 @@ var ExportAlexaAddon = class extends BaseAddon {
53515
56177
  recordLinkedAccount: (input) => this.recordLinkedAccount(input)
53516
56178
  },
53517
56179
  redetectPublicHubUrl: () => this.redetectPublicHubUrl(),
53518
- onBrokerAdvertised: (raw) => this.rememberAdvertisedBroker(raw)
56180
+ onBrokerAdvertised: (raw) => this.rememberAdvertisedBroker(raw),
56181
+ snapshotRoute: {
56182
+ store: this.snapshotStore,
56183
+ enabled: () => this.config.snapshots,
56184
+ verifyToken: (token) => this.verifyAccountLinkToken(token),
56185
+ mayView: async (userId, deviceId) => {
56186
+ const lookup = await this.accessGate.principal(userId);
56187
+ if (lookup.kind !== "found") return false;
56188
+ return await this.accessGate.allows(lookup.principal, deviceId, "view") === "allowed";
56189
+ },
56190
+ logger: this.ctx.logger
56191
+ }
53519
56192
  }));
53520
56193
  const oauthIntegrationProvider = { getDescriptor: async () => buildAlexaOauthIntegration(this.config.publicHubUrl) };
53521
56194
  this.lwaGateway = new LwaEventGateway({
@@ -53529,15 +56202,54 @@ var ExportAlexaAddon = class extends BaseAddon {
53529
56202
  getAdvertisedBrokerUrl: () => this.config.advertisedBrokerUrl || null,
53530
56203
  getPublicHubUrl: () => this.config.publicHubUrl,
53531
56204
  getRegion: () => this.config.region,
53532
- getExportedSignature: (deviceId) => this.config.exportedSignatures[deviceId] ?? null
56205
+ getExportedSignature: (deviceId) => this.config.exportedSignatures[deviceId] ?? null,
56206
+ accountMaySee: (userId, deviceId) => this.visibility.allows(userId, deviceId)
53533
56207
  });
53534
56208
  this.lwaGateway.start();
56209
+ const gatewayForFeed = this.lwaGateway;
56210
+ this.detectionFeed = new DetectionSensorFeed({
56211
+ eventBus: this.ctx.eventBus,
56212
+ enabled: () => this.config.detectionSensors,
56213
+ isExposed: (deviceId) => this.config.exposed.some((e) => e.deviceId === deviceId),
56214
+ readActiveTracks: async (deviceId) => {
56215
+ return (await this.ctx.api.pipelineAnalytics.getActiveTracks.query({ deviceId })).map((t) => ({
56216
+ trackId: t.trackId,
56217
+ className: t.className
56218
+ }));
56219
+ },
56220
+ push: (deviceId, cls, detected, timestamp) => gatewayForFeed.pushDetectionSensor({
56221
+ deviceId,
56222
+ endpointId: detectionSensorEndpointId(deviceId, cls),
56223
+ detected,
56224
+ timestamp
56225
+ }),
56226
+ logger: this.ctx.logger
56227
+ });
56228
+ this.detectionFeed.start();
56229
+ this.appliedShapeSwitches = this.shapeSwitches();
56230
+ this.stateReporter = new StateChangeReporter({
56231
+ eventBus: this.ctx.eventBus,
56232
+ getExportedEndpoints: () => this.config.exportedEndpoints,
56233
+ reportConnectivity: (deviceId, connectivity) => gatewayForFeed.reportConnectivity(deviceId, connectivity),
56234
+ postStateChange: (report) => gatewayForFeed.postStateChange(report),
56235
+ postHeldMotion: (report, detected) => gatewayForFeed.pushDetectionSensor({
56236
+ deviceId: report.exposedDeviceId,
56237
+ endpointId: report.endpointId,
56238
+ detected,
56239
+ timestamp: Date.now()
56240
+ }),
56241
+ logger: this.ctx.logger
56242
+ });
56243
+ this.stateReporter.start();
53535
56244
  this.syncEngine = new SyncEngine({
53536
56245
  logger: this.ctx.logger,
53537
56246
  listExposed: () => this.config.exposed,
53538
56247
  listLinkedAccounts: () => this.config.linkedAccounts,
53539
56248
  hasProactiveCredentials: () => this.config.tokens.length > 0,
53540
56249
  buildEndpoints: (deviceId) => this.buildEndpointsForOneDevice(deviceId),
56250
+ filterForAccount: (userId, endpoints) => this.filterForAccount(userId, endpoints),
56251
+ afterPush: (userId, endpoints) => this.requireGateway().scheduleInitialConnectivity(userId, endpoints),
56252
+ mayRetire: (endpointId) => parseCameraEndpointId(endpointId).kind === "detection",
53541
56253
  postAddOrUpdate: (userId, endpoints) => this.requireGateway().postReport(userId, (accessToken) => buildAddOrUpdateReportPayload(userId, endpoints, accessToken), `add/update (${endpoints.length})`),
53542
56254
  postDelete: (userId, ids) => this.requireGateway().postReport(userId, (accessToken) => buildDeleteReportPayload(userId, ids, accessToken), `delete (${ids.length})`),
53543
56255
  getExportedEndpoints: () => this.config.exportedEndpoints,
@@ -53547,6 +56259,8 @@ var ExportAlexaAddon = class extends BaseAddon {
53547
56259
  });
53548
56260
  this.subscribeBindingsChangedForReconcile();
53549
56261
  this.scheduleBootDiscoveryReconcile();
56262
+ this.scheduleVisibilityRefresh();
56263
+ this.scheduleDetectionClassRefresh();
53550
56264
  const offRemoved = this.ctx.eventBus.subscribe({ category: EventCategory.DeviceUnregistered }, (event) => {
53551
56265
  const id = event.data?.deviceId;
53552
56266
  if (typeof id !== "number") return;
@@ -53593,6 +56307,10 @@ var ExportAlexaAddon = class extends BaseAddon {
53593
56307
  actionHandlers: {
53594
56308
  redetectHubUrl: async () => this.redetectPublicHubUrl(),
53595
56309
  regenerateIngressToken: async () => this.regenerateIngressToken(),
56310
+ revealIngressToken: async () => {
56311
+ this.ctx.logger.info("export-alexa: an administrator revealed the ingress token");
56312
+ return { ingressToken: this.config.ingressToken };
56313
+ },
53596
56314
  resyncDiscovery: async () => this.resyncDiscovery(),
53597
56315
  purgeStaleEndpoints: async () => this.purgeStaleEndpoints(),
53598
56316
  unlinkAlexaAccounts: async () => this.unlinkAlexaAccounts(),
@@ -53601,6 +56319,10 @@ var ExportAlexaAddon = class extends BaseAddon {
53601
56319
  };
53602
56320
  }
53603
56321
  async onShutdown() {
56322
+ this.detectionFeed?.stop();
56323
+ this.detectionFeed = null;
56324
+ this.stateReporter?.stop();
56325
+ this.stateReporter = null;
53604
56326
  if (this.lwaGateway) {
53605
56327
  this.lwaGateway.stop();
53606
56328
  this.lwaGateway = null;
@@ -53621,7 +56343,10 @@ var ExportAlexaAddon = class extends BaseAddon {
53621
56343
  });
53622
56344
  await this.updateGlobalSettings({ exposed: next });
53623
56345
  this.ctx.logger.info("export-alexa: exposed device", { meta: { deviceId } });
53624
- this.requireSyncEngine().pushDevice(deviceId).then((r) => this.ctx.logger.info("export-alexa: synced exposed device", { meta: { ...r } })).catch((err) => {
56346
+ this.requireSyncEngine().pushDevice(deviceId).then((r) => {
56347
+ this.ctx.logger.info("export-alexa: synced exposed device", { meta: { ...r } });
56348
+ return this.refreshVisibility();
56349
+ }).catch((err) => {
53625
56350
  this.ctx.logger.warn("export-alexa: expose sync failed", { meta: {
53626
56351
  deviceId,
53627
56352
  error: errMsg(err)
@@ -53648,12 +56373,96 @@ var ExportAlexaAddon = class extends BaseAddon {
53648
56373
  * `Discover` handler uses.
53649
56374
  */
53650
56375
  async buildEndpointsForOneDevice(deviceId) {
56376
+ const classSource = this.detectionClassSource;
53651
56377
  return buildEndpointsForExposedDevice(deviceId, {
53652
56378
  ctx: this.ctx,
53653
56379
  logger: this.ctx.logger,
53654
- listExposed: async () => this.config.exposed
56380
+ snapshots: this.config.snapshots,
56381
+ ...this.config.detectionSensors && classSource !== null ? { detectionClasses: (id) => classSource.classesFor(id) } : {}
56382
+ });
56383
+ }
56384
+ /** The settings that change every exposed endpoint's shape, as one key. */
56385
+ shapeSwitches() {
56386
+ return JSON.stringify({
56387
+ detectionSensors: this.config.detectionSensors,
56388
+ snapshots: this.config.snapshots
56389
+ });
56390
+ }
56391
+ /**
56392
+ * A shape switch was flipped: re-push every exposed device. The signature
56393
+ * reconcile pushes only what changed, and the push retires the endpoints a
56394
+ * device no longer builds (the detection sensors, when switched off).
56395
+ */
56396
+ async onConfigChanged() {
56397
+ const now = this.shapeSwitches();
56398
+ if (this.appliedShapeSwitches === null || now === this.appliedShapeSwitches) return;
56399
+ this.appliedShapeSwitches = now;
56400
+ if (!this.config.snapshots) this.snapshotStore.clear();
56401
+ this.ctx.logger.info("export-alexa: an endpoint-shape switch changed — re-syncing every device", { meta: { switches: now } });
56402
+ const engine = this.syncEngine;
56403
+ if (engine === null) return;
56404
+ engine.reconcileAllExposed().catch((err) => {
56405
+ this.ctx.logger.warn("export-alexa: re-sync after a switch change failed", { meta: { error: errMsg(err) } });
56406
+ });
56407
+ }
56408
+ scheduleDetectionClassRefresh() {
56409
+ const timer = setInterval(() => {
56410
+ if (!this.config.detectionSensors) return;
56411
+ this.requireSyncEngine().reconcileAllExposed().catch((err) => {
56412
+ this.ctx.logger.warn("export-alexa: detection-class refresh failed", { meta: { error: errMsg(err) } });
56413
+ });
56414
+ }, DETECTION_CLASS_REFRESH_MS);
56415
+ timer.unref?.();
56416
+ this.ctx.addDisposer(async () => clearInterval(timer));
56417
+ }
56418
+ get visibility() {
56419
+ if (!this.visibilityRef) throw new Error("export-alexa: visibility mirror not initialized");
56420
+ return this.visibilityRef;
56421
+ }
56422
+ /** The endpoints one linked account may be sent (S1). `null` = its user's
56423
+ * grants are unreadable right now; a deleted user is sent nothing. */
56424
+ async filterForAccount(userId, endpoints) {
56425
+ const lookup = await this.accessGate.principal(userId);
56426
+ if (lookup.kind === "unavailable") return null;
56427
+ if (lookup.kind === "unknown-user") return [];
56428
+ return (await filterEndpointsForPrincipal(endpoints, lookup.principal, this.endpointAccess)).kept;
56429
+ }
56430
+ /** One visibility pass, applied to Alexa. Never throws. */
56431
+ async refreshVisibility() {
56432
+ try {
56433
+ await applyVisibilityChanges(await this.visibility.refresh(), this.requireSyncEngine(), this.ctx.logger);
56434
+ } catch (err) {
56435
+ this.ctx.logger.warn("export-alexa: visibility refresh failed", { meta: { error: errMsg(err) } });
56436
+ }
56437
+ }
56438
+ scheduleVisibilityRefresh() {
56439
+ const first = setTimeout(() => void this.refreshVisibility(), BOOT_VISIBILITY_DELAY_MS);
56440
+ first.unref?.();
56441
+ const every = setInterval(() => void this.refreshVisibility(), VISIBILITY_REFRESH_INTERVAL_MS);
56442
+ every.unref?.();
56443
+ this.ctx.addDisposer(async () => {
56444
+ clearTimeout(first);
56445
+ clearInterval(every);
53655
56446
  });
53656
56447
  }
56448
+ /** An account-link token's hub user, or null — the snapshot route's
56449
+ * credential check (the same verification `/directive` runs). */
56450
+ async verifyAccountLinkToken(token) {
56451
+ const raw = await this.ctx.api.userManagement.oauthVerifyAccessToken.query({ token });
56452
+ const parsed = VerifiedTokenSchema.safeParse(raw);
56453
+ return parsed.success && parsed.data !== null ? { userId: parsed.data.userId } : null;
56454
+ }
56455
+ get accessGate() {
56456
+ if (!this.accessGateRef) throw new Error("export-alexa: access gate not initialized");
56457
+ return this.accessGateRef;
56458
+ }
56459
+ /** The exposed set + the user gate, as every enforcement point reads them. */
56460
+ get endpointAccess() {
56461
+ return {
56462
+ gate: this.accessGate,
56463
+ isExposed: (deviceId) => this.config.exposed.some((e) => e.deviceId === deviceId)
56464
+ };
56465
+ }
53657
56466
  /** Narrow the nullable `lwaGateway` for the SyncEngine post closures. It is
53658
56467
  * constructed + started in `onInitialize` before the SyncEngine, so its
53659
56468
  * `postReport` is always reachable when the engine pushes. */
@@ -53824,7 +56633,8 @@ var ExportAlexaAddon = class extends BaseAddon {
53824
56633
  linkedAccounts: [],
53825
56634
  tokens: [],
53826
56635
  exportedEndpoints: {},
53827
- exportedSignatures: {}
56636
+ exportedSignatures: {},
56637
+ accountVisibility: {}
53828
56638
  });
53829
56639
  this.ctx.logger.warn("export-alexa: operator unlinked the Amazon account(s)", { meta: {
53830
56640
  revokedSessions,
@@ -53867,7 +56677,10 @@ var ExportAlexaAddon = class extends BaseAddon {
53867
56677
  await this.updateGlobalSettings({ linkedAccounts: next });
53868
56678
  const engine = this.syncEngine;
53869
56679
  if (!engine) return;
53870
- engine.pushAllToAccount(input.userId).then((r) => this.ctx.logger.info("export-alexa: pushed to newly linked account", { meta: { ...r } })).catch((err) => {
56680
+ engine.pushAllToAccount(input.userId).then((r) => {
56681
+ this.ctx.logger.info("export-alexa: pushed to newly linked account", { meta: { ...r } });
56682
+ return this.refreshVisibility();
56683
+ }).catch((err) => {
53871
56684
  this.ctx.logger.warn("export-alexa: new-account push failed", { meta: {
53872
56685
  userId: input.userId,
53873
56686
  error: errMsg(err)
@@ -54068,26 +56881,11 @@ var ExportAlexaAddon = class extends BaseAddon {
54068
56881
  * gets a copy button in the panel; the secret rows are masked.
54069
56882
  */
54070
56883
  buildSetupBlock() {
54071
- const base = this.config.publicHubUrl.replace(/\/+$/, "");
54072
- const linkedCount = this.config.linkedAccounts.length;
54073
- return {
54074
- fields: [
54075
- ...base ? [{
54076
- label: "Directive endpoint",
54077
- value: `${base}/addon/export-alexa/directive`
54078
- }] : [],
54079
- ...this.config.ingressToken ? [{
54080
- label: "Ingress token (Lambda HUB_INGRESS_TOKEN)",
54081
- value: this.config.ingressToken,
54082
- secret: true
54083
- }] : [],
54084
- {
54085
- label: "Linked Alexa accounts",
54086
- value: String(linkedCount)
54087
- }
54088
- ],
54089
- note: base ? "Account linking is handled by the core OAuth server. Use the hub's /api/oauth2/authorize endpoint with integration=export-alexa." : "Set the public hub URL on the settings form first — the directive endpoint URL is derived from it."
54090
- };
56884
+ return buildAlexaSetupBlock({
56885
+ publicHubUrl: this.config.publicHubUrl,
56886
+ ingressToken: this.config.ingressToken,
56887
+ linkedAccountCount: this.config.linkedAccounts.length
56888
+ });
54091
56889
  }
54092
56890
  /** Options for the "External address" picker: the cached HTTPS endpoints
54093
56891
  * plus the current selection (so a stale/custom value is never dropped). */
@@ -54158,7 +56956,7 @@ var ExportAlexaAddon = class extends BaseAddon {
54158
56956
  type: "select",
54159
56957
  key: "region",
54160
56958
  label: "Skill region",
54161
- description: "The Alexa Event Gateway this hub posts to: doorbell presses, motion, battery level, and the device-list updates sent when you expose or unexpose a camera. Pick the region your Amazon account is in — events sent to another region are rejected.",
56959
+ description: "The Alexa Event Gateway this hub posts to: doorbell presses, motion, camera online/offline and state changes, and the device-list updates sent when you expose or unexpose a camera. Pick the region your Amazon account is in — events sent to another region are rejected.",
54162
56960
  options: [
54163
56961
  {
54164
56962
  value: "NA",
@@ -54175,6 +56973,20 @@ var ExportAlexaAddon = class extends BaseAddon {
54175
56973
  ],
54176
56974
  default: DEFAULT_CONFIG.region
54177
56975
  }),
56976
+ this.field({
56977
+ type: "boolean",
56978
+ key: "detectionSensors",
56979
+ label: "Detection sensors",
56980
+ description: "Give every exposed camera one Alexa motion sensor per class its detection pipeline finds: \"<camera> Person\", \"Vehicle\", \"Animal\", \"Package\". Use them as routine triggers in the Alexa app (for example: when Front door Person detects motion, announce on the Echo devices). They report only \"detected\" — no images. Off removes them from Alexa.",
56981
+ default: DEFAULT_CONFIG.detectionSensors
56982
+ }),
56983
+ this.field({
56984
+ type: "boolean",
56985
+ key: "snapshots",
56986
+ label: "Snapshots in the Alexa app",
56987
+ description: "Show each exposed camera's latest still on its tile in the Alexa app. While this is on, Amazon fetches still images of your cameras (re-encoded to about 480 px, each link valid ten minutes and only with your account's token). Off removes the feature from Alexa and invalidates every link already handed out. The hub's public HTTPS address must be set.",
56988
+ default: DEFAULT_CONFIG.snapshots
56989
+ }),
54178
56990
  ...this.config.skillMode === "custom" ? [this.field({
54179
56991
  type: "text",
54180
56992
  key: "expectedAudience",
@@ -54186,7 +56998,7 @@ var ExportAlexaAddon = class extends BaseAddon {
54186
56998
  key: "__custom-skill-info",
54187
56999
  label: "Your skill Lambda",
54188
57000
  variant: "info",
54189
- content: "Proactive events (doorbell, motion, battery, device-list updates) are authorised by YOUR skill Lambda: it keeps the \"Send Alexa Events\" LWA client id/secret in SSM Parameter Store and hands this hub short-lived tokens. Nothing to paste here — see the custom-skill setup guide."
57001
+ content: "Proactive events (doorbell, motion, online/offline, device-list updates) are authorised by YOUR skill Lambda: it keeps the \"Send Alexa Events\" LWA client id/secret in SSM Parameter Store and hands this hub short-lived tokens. Nothing to paste here — see the custom-skill setup guide."
54190
57002
  }] : [{
54191
57003
  type: "info",
54192
57004
  key: "__public-skill-info",
@@ -54256,16 +57068,30 @@ var ExportAlexaAddon = class extends BaseAddon {
54256
57068
  key: "__ingress-token-info",
54257
57069
  label: "Ingress token",
54258
57070
  variant: "info",
54259
- content: "Defense-in-depth shared secret. Copy it from the \"Ingress token\" row in the Exported devices panel (reveal + copy) into your Alexa Lambda's HUB_INGRESS_TOKEN env, then enable \"Require ingress token\" below. Use \"Regenerate token\" to rotate (update the Lambda afterwards)."
57071
+ content: "Defense-in-depth shared secret. Press \"Show ingress token\" to read it (it is never part of a status or settings read), paste it into your Alexa Lambda's HUB_INGRESS_TOKEN env, then enable \"Require ingress token\" below. Use \"Regenerate token\" to rotate (update the Lambda afterwards)."
54260
57072
  },
54261
57073
  {
54262
- type: "button",
57074
+ type: "addon-action-button",
57075
+ addonId: "export-alexa",
57076
+ key: "__reveal-ingress-token",
57077
+ label: "Show ingress token",
57078
+ description: "Administrators only. Shows the current token once, here.",
57079
+ buttonLabel: "Show ingress token",
57080
+ action: "revealIngressToken",
57081
+ variant: "default",
57082
+ successMessage: "Ingress token: {ingressToken}"
57083
+ },
57084
+ {
57085
+ type: "addon-action-button",
57086
+ addonId: "export-alexa",
54263
57087
  key: "__regenerate-ingress-token",
54264
57088
  label: "Rotate ingress token",
54265
57089
  description: "Generate a new ingress token, invalidating the old one. Update your Lambda's HUB_INGRESS_TOKEN afterwards.",
54266
57090
  buttonLabel: "Regenerate token",
54267
57091
  action: "regenerateIngressToken",
54268
- variant: "default"
57092
+ variant: "default",
57093
+ successMessage: "New ingress token: {ingressToken} — paste it into HUB_INGRESS_TOKEN now.",
57094
+ confirmMessage: "Rotate the ingress token? With \"Require ingress token\" on, the Lambda is refused until you update HUB_INGRESS_TOKEN."
54269
57095
  },
54270
57096
  this.field({
54271
57097
  type: "boolean",
@@ -54309,14 +57135,14 @@ var ExportAlexaAddon = class extends BaseAddon {
54309
57135
  }
54310
57136
  }
54311
57137
  async buildDeviceSettingsContribution(deviceId) {
54312
- if (!await this.isCameraDevice(deviceId)) return null;
57138
+ const kind = await this.exportKindOf(deviceId);
57139
+ if (kind === null) return null;
54313
57140
  const entry = this.findEntry(deviceId);
54314
57141
  const settings = entry?.settings ?? DEFAULT_DEVICE_SETTINGS;
54315
57142
  const enabled = entry !== null;
54316
57143
  const enabledKey = `alexa:${deviceId}:enabled`;
54317
- const streamPreferenceKey = `alexa:${deviceId}:streamPreference`;
54318
- const allowTranscodeKey = `alexa:${deviceId}:allowTranscode`;
54319
57144
  const linkedCount = this.config.linkedAccounts.length;
57145
+ const statusLabel = enabled ? `Exposed${linkedCount > 0 ? `, ${linkedCount} linked Alexa account(s)` : " (no linked Alexa account yet)"}` : linkedCount > 0 ? `${linkedCount} linked Alexa account(s) — device not exposed` : "Not exposed";
54320
57146
  return {
54321
57147
  tabs: [{
54322
57148
  id: "export",
@@ -54327,7 +57153,7 @@ var ExportAlexaAddon = class extends BaseAddon {
54327
57153
  sections: [{
54328
57154
  id: "export-alexa",
54329
57155
  title: "Alexa Export",
54330
- description: "Surface this camera to the linked Alexa Smart Home skill.",
57156
+ description: kind === "camera" ? "Surface this camera to the linked Alexa Smart Home skill." : "Surface this device to the linked Alexa Smart Home skill (locks, sensors, switches and lights each become one Alexa device).",
54331
57157
  tab: "export",
54332
57158
  columns: 1,
54333
57159
  order: 20,
@@ -54336,49 +57162,54 @@ var ExportAlexaAddon = class extends BaseAddon {
54336
57162
  type: "info",
54337
57163
  key: "__alexa-status",
54338
57164
  label: "Status",
54339
- content: enabled ? `Exposed${linkedCount > 0 ? `, ${linkedCount} linked Alexa account(s)` : " (no linked Alexa account yet)"}` : linkedCount > 0 ? `${linkedCount} linked Alexa account(s) — device not exposed` : "Not exposed",
57165
+ content: statusLabel,
54340
57166
  variant: "info"
54341
57167
  },
54342
57168
  {
54343
57169
  type: "boolean",
54344
57170
  key: enabledKey,
54345
57171
  label: "Expose to Alexa",
54346
- description: "Toggle to mirror this camera onto the Alexa Smart Home skill.",
57172
+ description: "Toggle to mirror this device onto the Alexa Smart Home skill.",
54347
57173
  style: "switch",
54348
57174
  value: enabled,
54349
57175
  immediate: true
54350
57176
  },
54351
- {
54352
- type: "select",
54353
- key: streamPreferenceKey,
54354
- label: "Source stream (Alexa / Echo)",
54355
- description: "Which camstack profile slot the Echo pulls. Auto = the broker picks the slot closest to 720p at session start (the Echo doesn’t adapt mid-session, so pin a specific profile if Auto picks badly on your hardware).",
54356
- options: STREAM_PREFERENCE_OPTIONS,
54357
- required: true,
54358
- value: settings.streamPreference,
54359
- showWhen: {
54360
- field: enabledKey,
54361
- equals: true
54362
- },
54363
- immediate: true
54364
- },
54365
- {
54366
- type: "boolean",
54367
- key: allowTranscodeKey,
54368
- label: "Allow transcoding for Alexa",
54369
- 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.",
54370
- style: "switch",
54371
- value: settings.allowTranscode !== false,
54372
- showWhen: {
54373
- field: enabledKey,
54374
- equals: true
54375
- },
54376
- immediate: true
54377
- }
57177
+ ...kind === "camera" ? this.cameraOnlyExportFields(deviceId, settings, enabledKey) : []
54378
57178
  ]
54379
57179
  }]
54380
57180
  };
54381
57181
  }
57182
+ /** The stream fields of the Export section — a camera's only. */
57183
+ cameraOnlyExportFields(deviceId, settings, enabledKey) {
57184
+ const streamPreferenceKey = `alexa:${deviceId}:streamPreference`;
57185
+ const allowTranscodeKey = `alexa:${deviceId}:allowTranscode`;
57186
+ return [{
57187
+ type: "select",
57188
+ key: streamPreferenceKey,
57189
+ label: "Source stream (Alexa / Echo)",
57190
+ description: "Which camstack profile slot the Echo pulls. Auto = the broker picks the slot closest to 720p at session start (the Echo doesn’t adapt mid-session, so pin a specific profile if Auto picks badly on your hardware).",
57191
+ options: STREAM_PREFERENCE_OPTIONS,
57192
+ required: true,
57193
+ value: settings.streamPreference,
57194
+ showWhen: {
57195
+ field: enabledKey,
57196
+ equals: true
57197
+ },
57198
+ immediate: true
57199
+ }, {
57200
+ type: "boolean",
57201
+ key: allowTranscodeKey,
57202
+ label: "Allow transcoding for Alexa",
57203
+ 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.",
57204
+ style: "switch",
57205
+ value: settings.allowTranscode !== false,
57206
+ showWhen: {
57207
+ field: enabledKey,
57208
+ equals: true
57209
+ },
57210
+ immediate: true
57211
+ }];
57212
+ }
54382
57213
  /**
54383
57214
  * Apply a patch produced by the device-details "Export" panel. Flips
54384
57215
  * the expose/unexpose lifecycle on `enabled` transitions and persists
@@ -54444,13 +57275,20 @@ var ExportAlexaAddon = class extends BaseAddon {
54444
57275
  }
54445
57276
  return { success: true };
54446
57277
  }
54447
- async isCameraDevice(deviceId) {
57278
+ /**
57279
+ * Which Export section a device gets: the camera one (with stream
57280
+ * settings), the generic one (on/off only — R5, D775), or none. An
57281
+ * unreadable device reads as a camera, as before.
57282
+ */
57283
+ async exportKindOf(deviceId) {
54448
57284
  try {
54449
57285
  const dev = await this.ctx.api.deviceManager.getDevice.query({ deviceId });
54450
- if (!dev) return true;
54451
- return dev.type === DeviceType.Camera;
57286
+ if (!dev) return "camera";
57287
+ const type = String(dev.type);
57288
+ if (type === DeviceType.Camera) return "camera";
57289
+ return GENERIC_DEVICE_TYPES.includes(type) ? "generic" : null;
54452
57290
  } catch {
54453
- return true;
57291
+ return "camera";
54454
57292
  }
54455
57293
  }
54456
57294
  findEntry(deviceId) {