@camstack/addon-export-alexa 1.2.136 → 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.
@@ -1,5 +1,6 @@
1
1
  import * as crypto$1 from "node:crypto";
2
- import { randomUUID } from "node:crypto";
2
+ import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
3
+ import sharp from "sharp";
3
4
  //#region ../types/dist/event-category-B_0XEcLF.mjs
4
5
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
5
6
  EventCategory["SystemBoot"] = "system.boot";
@@ -3763,7 +3764,7 @@ function initializeContext(params) {
3763
3764
  external: params?.external ?? void 0
3764
3765
  };
3765
3766
  }
3766
- function process(schema, ctx, _params = {
3767
+ function process$1(schema, ctx, _params = {
3767
3768
  path: [],
3768
3769
  schemaPath: []
3769
3770
  }) {
@@ -3800,7 +3801,7 @@ function process(schema, ctx, _params = {
3800
3801
  const parent = schema._zod.parent;
3801
3802
  if (parent) {
3802
3803
  if (!result.ref) result.ref = parent;
3803
- process(parent, ctx, params);
3804
+ process$1(parent, ctx, params);
3804
3805
  ctx.seen.get(parent).isParent = true;
3805
3806
  }
3806
3807
  }
@@ -4020,7 +4021,7 @@ var createToJSONSchemaMethod = (schema, processors = {}) => (params) => {
4020
4021
  ...params,
4021
4022
  processors
4022
4023
  });
4023
- process(schema, ctx);
4024
+ process$1(schema, ctx);
4024
4025
  extractDefs(ctx, schema);
4025
4026
  return finalize(ctx, schema);
4026
4027
  };
@@ -4032,7 +4033,7 @@ var createStandardJSONSchemaMethod = (schema, io, processors = {}) => (params) =
4032
4033
  io,
4033
4034
  processors
4034
4035
  });
4035
- process(schema, ctx);
4036
+ process$1(schema, ctx);
4036
4037
  extractDefs(ctx, schema);
4037
4038
  return finalize(ctx, schema);
4038
4039
  };
@@ -4146,7 +4147,7 @@ var arrayProcessor = (schema, ctx, _json, params) => {
4146
4147
  if (typeof minimum === "number") json.minItems = minimum;
4147
4148
  if (typeof maximum === "number") json.maxItems = maximum;
4148
4149
  json.type = "array";
4149
- json.items = process(def.element, ctx, {
4150
+ json.items = process$1(def.element, ctx, {
4150
4151
  ...params,
4151
4152
  path: [...params.path, "items"]
4152
4153
  });
@@ -4157,7 +4158,7 @@ var objectProcessor = (schema, ctx, _json, params) => {
4157
4158
  json.type = "object";
4158
4159
  json.properties = {};
4159
4160
  const shape = def.shape;
4160
- for (const key in shape) json.properties[key] = process(shape[key], ctx, {
4161
+ for (const key in shape) json.properties[key] = process$1(shape[key], ctx, {
4161
4162
  ...params,
4162
4163
  path: [
4163
4164
  ...params.path,
@@ -4175,7 +4176,7 @@ var objectProcessor = (schema, ctx, _json, params) => {
4175
4176
  if (def.catchall?._zod.def.type === "never") json.additionalProperties = false;
4176
4177
  else if (!def.catchall) {
4177
4178
  if (ctx.io === "output") json.additionalProperties = false;
4178
- } else if (def.catchall) json.additionalProperties = process(def.catchall, ctx, {
4179
+ } else if (def.catchall) json.additionalProperties = process$1(def.catchall, ctx, {
4179
4180
  ...params,
4180
4181
  path: [...params.path, "additionalProperties"]
4181
4182
  });
@@ -4183,7 +4184,7 @@ var objectProcessor = (schema, ctx, _json, params) => {
4183
4184
  var unionProcessor = (schema, ctx, json, params) => {
4184
4185
  const def = schema._zod.def;
4185
4186
  const isExclusive = def.inclusive === false;
4186
- const options = def.options.map((x, i) => process(x, ctx, {
4187
+ const options = def.options.map((x, i) => process$1(x, ctx, {
4187
4188
  ...params,
4188
4189
  path: [
4189
4190
  ...params.path,
@@ -4196,7 +4197,7 @@ var unionProcessor = (schema, ctx, json, params) => {
4196
4197
  };
4197
4198
  var intersectionProcessor = (schema, ctx, json, params) => {
4198
4199
  const def = schema._zod.def;
4199
- const a = process(def.left, ctx, {
4200
+ const a = process$1(def.left, ctx, {
4200
4201
  ...params,
4201
4202
  path: [
4202
4203
  ...params.path,
@@ -4204,7 +4205,7 @@ var intersectionProcessor = (schema, ctx, json, params) => {
4204
4205
  0
4205
4206
  ]
4206
4207
  });
4207
- const b = process(def.right, ctx, {
4208
+ const b = process$1(def.right, ctx, {
4208
4209
  ...params,
4209
4210
  path: [
4210
4211
  ...params.path,
@@ -4221,7 +4222,7 @@ var tupleProcessor = (schema, ctx, _json, params) => {
4221
4222
  json.type = "array";
4222
4223
  const prefixPath = ctx.target === "draft-2020-12" ? "prefixItems" : "items";
4223
4224
  const restPath = ctx.target === "draft-2020-12" ? "items" : ctx.target === "openapi-3.0" ? "items" : "additionalItems";
4224
- const prefixItems = def.items.map((x, i) => process(x, ctx, {
4225
+ const prefixItems = def.items.map((x, i) => process$1(x, ctx, {
4225
4226
  ...params,
4226
4227
  path: [
4227
4228
  ...params.path,
@@ -4229,7 +4230,7 @@ var tupleProcessor = (schema, ctx, _json, params) => {
4229
4230
  i
4230
4231
  ]
4231
4232
  }));
4232
- const rest = def.rest ? process(def.rest, ctx, {
4233
+ const rest = def.rest ? process$1(def.rest, ctx, {
4233
4234
  ...params,
4234
4235
  path: [
4235
4236
  ...params.path,
@@ -4260,7 +4261,7 @@ var recordProcessor = (schema, ctx, _json, params) => {
4260
4261
  const keyType = def.keyType;
4261
4262
  const patterns = keyType._zod.bag?.patterns;
4262
4263
  if (def.mode === "loose" && patterns && patterns.size > 0) {
4263
- const valueSchema = process(def.valueType, ctx, {
4264
+ const valueSchema = process$1(def.valueType, ctx, {
4264
4265
  ...params,
4265
4266
  path: [
4266
4267
  ...params.path,
@@ -4271,11 +4272,11 @@ var recordProcessor = (schema, ctx, _json, params) => {
4271
4272
  json.patternProperties = {};
4272
4273
  for (const pattern of patterns) json.patternProperties[pattern.source] = valueSchema;
4273
4274
  } else {
4274
- if (ctx.target === "draft-07" || ctx.target === "draft-2020-12") json.propertyNames = process(def.keyType, ctx, {
4275
+ if (ctx.target === "draft-07" || ctx.target === "draft-2020-12") json.propertyNames = process$1(def.keyType, ctx, {
4275
4276
  ...params,
4276
4277
  path: [...params.path, "propertyNames"]
4277
4278
  });
4278
- json.additionalProperties = process(def.valueType, ctx, {
4279
+ json.additionalProperties = process$1(def.valueType, ctx, {
4279
4280
  ...params,
4280
4281
  path: [...params.path, "additionalProperties"]
4281
4282
  });
@@ -4288,7 +4289,7 @@ var recordProcessor = (schema, ctx, _json, params) => {
4288
4289
  };
4289
4290
  var nullableProcessor = (schema, ctx, json, params) => {
4290
4291
  const def = schema._zod.def;
4291
- const inner = process(def.innerType, ctx, params);
4292
+ const inner = process$1(def.innerType, ctx, params);
4292
4293
  const seen = ctx.seen.get(schema);
4293
4294
  if (ctx.target === "openapi-3.0") {
4294
4295
  seen.ref = def.innerType;
@@ -4297,27 +4298,27 @@ var nullableProcessor = (schema, ctx, json, params) => {
4297
4298
  };
4298
4299
  var nonoptionalProcessor = (schema, ctx, _json, params) => {
4299
4300
  const def = schema._zod.def;
4300
- process(def.innerType, ctx, params);
4301
+ process$1(def.innerType, ctx, params);
4301
4302
  const seen = ctx.seen.get(schema);
4302
4303
  seen.ref = def.innerType;
4303
4304
  };
4304
4305
  var defaultProcessor = (schema, ctx, json, params) => {
4305
4306
  const def = schema._zod.def;
4306
- process(def.innerType, ctx, params);
4307
+ process$1(def.innerType, ctx, params);
4307
4308
  const seen = ctx.seen.get(schema);
4308
4309
  seen.ref = def.innerType;
4309
4310
  json.default = JSON.parse(JSON.stringify(def.defaultValue));
4310
4311
  };
4311
4312
  var prefaultProcessor = (schema, ctx, json, params) => {
4312
4313
  const def = schema._zod.def;
4313
- process(def.innerType, ctx, params);
4314
+ process$1(def.innerType, ctx, params);
4314
4315
  const seen = ctx.seen.get(schema);
4315
4316
  seen.ref = def.innerType;
4316
4317
  if (ctx.io === "input") json._prefault = JSON.parse(JSON.stringify(def.defaultValue));
4317
4318
  };
4318
4319
  var catchProcessor = (schema, ctx, json, params) => {
4319
4320
  const def = schema._zod.def;
4320
- process(def.innerType, ctx, params);
4321
+ process$1(def.innerType, ctx, params);
4321
4322
  const seen = ctx.seen.get(schema);
4322
4323
  seen.ref = def.innerType;
4323
4324
  let catchValue;
@@ -4332,26 +4333,26 @@ var pipeProcessor = (schema, ctx, _json, params) => {
4332
4333
  const def = schema._zod.def;
4333
4334
  const inIsTransform = def.in._zod.traits.has("$ZodTransform");
4334
4335
  const innerType = ctx.io === "input" ? inIsTransform ? def.out : def.in : def.out;
4335
- process(innerType, ctx, params);
4336
+ process$1(innerType, ctx, params);
4336
4337
  const seen = ctx.seen.get(schema);
4337
4338
  seen.ref = innerType;
4338
4339
  };
4339
4340
  var readonlyProcessor = (schema, ctx, json, params) => {
4340
4341
  const def = schema._zod.def;
4341
- process(def.innerType, ctx, params);
4342
+ process$1(def.innerType, ctx, params);
4342
4343
  const seen = ctx.seen.get(schema);
4343
4344
  seen.ref = def.innerType;
4344
4345
  json.readOnly = true;
4345
4346
  };
4346
4347
  var optionalProcessor = (schema, ctx, _json, params) => {
4347
4348
  const def = schema._zod.def;
4348
- process(def.innerType, ctx, params);
4349
+ process$1(def.innerType, ctx, params);
4349
4350
  const seen = ctx.seen.get(schema);
4350
4351
  seen.ref = def.innerType;
4351
4352
  };
4352
4353
  var lazyProcessor = (schema, ctx, _json, params) => {
4353
4354
  const innerType = schema._zod.innerType;
4354
- process(innerType, ctx, params);
4355
+ process$1(innerType, ctx, params);
4355
4356
  const seen = ctx.seen.get(schema);
4356
4357
  seen.ref = innerType;
4357
4358
  };
@@ -7442,9 +7443,9 @@ function errMsg(err) {
7442
7443
  */
7443
7444
  /** The pinned jellyfin-ffmpeg release. Changing this is the version change. */
7444
7445
  var FFMPEG_RELEASE = "8.1.2-5";
7445
- var BASE = `https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v${FFMPEG_RELEASE}`;
7446
+ var BASE$1 = `https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v${FFMPEG_RELEASE}`;
7446
7447
  function portable(target) {
7447
- return `${BASE}/jellyfin-ffmpeg_${FFMPEG_RELEASE}_portable_${target}-gpl.tar.xz`;
7448
+ return `${BASE$1}/jellyfin-ffmpeg_${FFMPEG_RELEASE}_portable_${target}-gpl.tar.xz`;
7448
7449
  }
7449
7450
  portable("linux64"), portable("linuxarm64"), portable("macarm64"), portable("mac64");
7450
7451
  /**
@@ -26326,7 +26327,7 @@ method(SendEmailInputSchema, SendEmailResultSchema, {
26326
26327
  * the device-native provider (onboard capture) or from the stream-broker
26327
26328
  * prebuffer fallback.
26328
26329
  */
26329
- var SnapshotImageSchema = object({
26330
+ var SnapshotImageSchema$1 = object({
26330
26331
  base64: string(),
26331
26332
  contentType: string()
26332
26333
  });
@@ -26358,7 +26359,7 @@ DeviceType.Camera, method(object({
26358
26359
  * correct answer: stale but honest beats woken.
26359
26360
  */
26360
26361
  force: boolean().optional()
26361
- }), SnapshotImageSchema.nullable()), method(object({ deviceId: number() }), _void(), {
26362
+ }), SnapshotImageSchema$1.nullable()), method(object({ deviceId: number() }), _void(), {
26362
26363
  kind: "mutation",
26363
26364
  auth: "admin"
26364
26365
  }), systemMethod(object({ deviceIds: array(number()).min(1).max(200) }), array(object({
@@ -28406,7 +28407,22 @@ DeviceType.Camera, method(object({ deviceId: number().int().nonnegative() }), ar
28406
28407
  * field their hints were silently stripped by the schema and the
28407
28408
  * adaptive ladder started blind on the client's display size.
28408
28409
  */
28409
- hints: webrtcClientHintsSchema.optional()
28410
+ hints: webrtcClientHintsSchema.optional(),
28411
+ /**
28412
+ * Talk-back (D771): relay the PEER's audio to the device's speaker
28413
+ * through the `intercom` cap's push-talk triple. When `true` and the
28414
+ * offer lets the peer send audio, the answer's audio is `sendrecv`
28415
+ * and every inbound frame goes to `intercom.pushTalkAudio` for this
28416
+ * `deviceId`; absent/false answers `sendonly`, as for every viewer.
28417
+ *
28418
+ * TRUSTED-CALLER ONLY. Set by the Alexa export for an Echo on a camera
28419
+ * with `intercom`, when the linked user may control that camera. The
28420
+ * hub STRIPS it from every request-bound call (a browser), exactly as
28421
+ * it overwrites `relayOnly` — a viewer's talk permission is the
28422
+ * intercom cap's own surface (share tokens gate it there), and this
28423
+ * flag must not become a second door to the camera's speaker.
28424
+ */
28425
+ talkback: boolean().optional()
28410
28426
  }), object({
28411
28427
  sessionId: string(),
28412
28428
  sdpAnswer: string()
@@ -29193,7 +29209,19 @@ object({
29193
29209
  lastChangedAt: number()
29194
29210
  });
29195
29211
  DeviceType.Sensor;
29196
- object({
29212
+ /**
29213
+ * Dimmable-light brightness control. Co-exists with `switch` on the
29214
+ * same device — the switch toggles on/off, this cap sets the level
29215
+ * applied when the light is on. Drivers map their per-vendor dim
29216
+ * controls to this single-method surface.
29217
+ *
29218
+ * The cap is intentionally minimal: a single `setBrightness({deviceId,
29219
+ * percentage})` mutation plus the auto-injected `getStatus`. Drivers
29220
+ * that expose richer controls (color temperature, scenes, schedules)
29221
+ * should surface those via the device's `getSettingsUISchema()`
29222
+ * instead of bloating this cap.
29223
+ */
29224
+ var BrightnessStatusSchema = object({
29197
29225
  /** Current level as 0..100 inclusive. Firmware-reported. */
29198
29226
  percentage: number().min(0).max(100),
29199
29227
  /** Ms epoch of the last operator-driven change. Useful for UI freshness. */
@@ -29818,7 +29846,19 @@ Object.values(DeviceType), method(object({
29818
29846
  kind: "mutation",
29819
29847
  auth: "admin"
29820
29848
  }), ConsumablesStatusSchema.extend({ lastFetchedAt: number() });
29821
- object({
29849
+ /**
29850
+ * Door / window / opening / garage / valve contact sensor. Boolean
29851
+ * "is the entry currently open" with the timestamp of the last
29852
+ * transition. Drives Home Assistant `binary_sensor` entries whose
29853
+ * `device_class` is `door`, `window`, `opening`, `garage`, or
29854
+ * `garage_door` — and any future native integration that needs
29855
+ * the same semantics.
29856
+ *
29857
+ * Push-driven: providers update the slice on transition events from
29858
+ * the upstream source (HA WebSocket `state_changed`, ZWave
29859
+ * `notification` …). Consumers read the slice; no polling.
29860
+ */
29861
+ var ContactStatusSchema = object({
29822
29862
  /** True when the entry is open; false when closed. */
29823
29863
  entryOpen: boolean(),
29824
29864
  /** Ms epoch of the last open↔closed transition. 0 if never observed. */
@@ -30640,7 +30680,15 @@ DeviceType.Humidifier, method(object({
30640
30680
  kind: "mutation",
30641
30681
  auth: "protected"
30642
30682
  });
30643
- object({
30683
+ /**
30684
+ * Single-metric humidity reading. Drives Home Assistant `sensor`
30685
+ * entries with `device_class: humidity`.
30686
+ *
30687
+ * Unit normalisation: percent. The canonical display unit (`%`) is a
30688
+ * descriptor constant in the UI (ROLE_DESCRIPTOR), not stored in
30689
+ * `sourceInfo`.
30690
+ */
30691
+ var HumiditySensorStatusSchema = object({
30644
30692
  /** Current relative humidity, 0..100. */
30645
30693
  percent: number().min(0).max(100),
30646
30694
  /** Ms epoch when the slice was last updated. */
@@ -31447,7 +31495,7 @@ method(_void(), ListResultSchema), method(_void(), PreferredSchema), method(obje
31447
31495
  kind: "mutation",
31448
31496
  auth: "admin"
31449
31497
  });
31450
- object({
31498
+ var LockControlStatusSchema = object({
31451
31499
  /** Lifecycle state of the lock. `jammed` means the motor reported
31452
31500
  * failure to reach the target — operator intervention required. */
31453
31501
  state: _enum([
@@ -31785,7 +31833,7 @@ authKey: string().optional() }), object({
31785
31833
  /** Human-readable error when `ok: false`. */
31786
31834
  error: string().optional()
31787
31835
  }), { kind: "mutation" });
31788
- object({
31836
+ var MotionStatusSchema = object({
31789
31837
  detected: boolean(),
31790
31838
  /** Ms epoch of the last detected-true observation. Null if never detected. */
31791
31839
  lastDetectedAt: number().nullable(),
@@ -33330,7 +33378,7 @@ var GpsLocationSchema = object({
33330
33378
  /** Reported accuracy in meters (lower = better). */
33331
33379
  accuracyMeters: number().nonnegative()
33332
33380
  });
33333
- object({
33381
+ var PresenceStatusSchema = object({
33334
33382
  /** `home` / `not_home` / any user-defined zone name. */
33335
33383
  state: string(),
33336
33384
  /** Optional textual location label (zone name, city, address). Null
@@ -35752,7 +35800,14 @@ DeviceType.Camera, method(object({ deviceId: number() }), StreamParamsOptionsSch
35752
35800
  kind: "mutation",
35753
35801
  auth: "admin"
35754
35802
  }), method(object({ deviceId: number() }), unknown().nullable());
35755
- object({
35803
+ /**
35804
+ * Generic on/off switch cap for accessory children (siren, floodlight,
35805
+ * spotlight, PIR toggle, chime silencer, autotrack enable, …). The cap
35806
+ * owns ONLY the boolean state — driver-specific settings (brightness,
35807
+ * sensitivity, schedule, duration) live on the device's own
35808
+ * `getSettingsUISchema()` surface.
35809
+ */
35810
+ var SwitchStatusSchema = object({
35756
35811
  on: boolean(),
35757
35812
  /** Ms epoch of the last state change. Useful for UI "X minutes ago". */
35758
35813
  lastChangedAt: number()
@@ -36293,7 +36348,23 @@ object({
36293
36348
  lastChangedAt: number()
36294
36349
  });
36295
36350
  DeviceType.Sensor;
36296
- object({
36351
+ /**
36352
+ * Single-metric temperature reading. Drives Home Assistant `sensor`
36353
+ * entries with `device_class: temperature` and any future native
36354
+ * thermometer.
36355
+ *
36356
+ * Unit normalisation: providers convert to Celsius before storing.
36357
+ * The slice value is always Celsius so cross-cap aggregators
36358
+ * (climate-control's `currentTemp`, energy analytics) can compose
36359
+ * without per-source unit fixups. The canonical display unit (`°C`) is
36360
+ * a descriptor constant in the UI (ROLE_DESCRIPTOR), not stored in
36361
+ * `sourceInfo`.
36362
+ *
36363
+ * Status `lastFetchedAt` lets staleness-aware consumers detect a
36364
+ * frozen feed (provider hung) distinct from a "temperature hasn't
36365
+ * changed" steady state.
36366
+ */
36367
+ var TemperatureSensorStatusSchema = object({
36297
36368
  /** Current temperature in Celsius. */
36298
36369
  celsius: number(),
36299
36370
  /** Ms epoch when the slice was last updated (push or poll). */
@@ -49234,6 +49305,76 @@ DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
49234
49305
  DEFAULT_NATIVE_LEASE_SETTINGS.tileBudgetMb;
49235
49306
  DEFAULT_NATIVE_LEASE_SETTINGS.sceneBudgetMb;
49236
49307
  DEFAULT_NATIVE_LEASE_SETTINGS.admission;
49308
+ /** Ancestor-walk bound — defence-in-depth against a corrupt parentage cycle. */
49309
+ var MAX_ANCESTOR_HOPS$1 = 8;
49310
+ /**
49311
+ * Does `selector` cover `device` DIRECTLY (no inheritance)? Inheritance is
49312
+ * layered on by the resolver/matcher, which tests this against a device's
49313
+ * ancestors too.
49314
+ */
49315
+ function deviceSelectorMatches(selector, device) {
49316
+ switch (selector.kind) {
49317
+ case "all": return true;
49318
+ case "ids": return selector.ids.includes(device.id);
49319
+ case "types": return selector.types.some((t) => String(t) === device.type);
49320
+ case "locations": return device.location !== null && selector.locations.includes(device.location);
49321
+ }
49322
+ }
49323
+ /** Whether a `category:device` grant covers `access` (the broad lever). */
49324
+ function hasBroadDeviceGrant(scopes, access) {
49325
+ return scopes.some((s) => s.type === "category" && s.target === "device" && s.access.includes(access));
49326
+ }
49327
+ /** The `device`-typed scopes granting `access`. */
49328
+ function deviceScopesFor(scopes, access) {
49329
+ return scopes.filter((s) => s.type === "device" && s.access.includes(access));
49330
+ }
49331
+ /** Whether inheritance applies for a grant at `access` (view inherits, others
49332
+ * don't, unless the grant overrides via `includeLinked`). */
49333
+ function scopeInherits(scope, access) {
49334
+ return scope.includeLinked ?? access === "view";
49335
+ }
49336
+ /** Ancestors (parent, grandparent, …) of `deviceId` within a fleet map. */
49337
+ function ancestorsOf(deviceId, byId) {
49338
+ const out = [];
49339
+ let current = deviceId;
49340
+ for (let hop = 0; hop < MAX_ANCESTOR_HOPS$1; hop++) {
49341
+ const parent = current === null ? null : byId.get(current)?.parentDeviceId ?? null;
49342
+ if (parent === null || parent === current) break;
49343
+ out.push(parent);
49344
+ current = parent;
49345
+ }
49346
+ return out;
49347
+ }
49348
+ /**
49349
+ * Resolve the set of deviceIds the caller may reach at `access`, expanded
49350
+ * against the live `fleet`. A `category:device` grant returns the whole
49351
+ * fleet. Device selectors are matched per device — directly, or (when the
49352
+ * grant inherits) against an ancestor, so a parent grant covers its
49353
+ * accessories.
49354
+ */
49355
+ function resolveViewableDeviceIds(scopes, fleet, access) {
49356
+ if (hasBroadDeviceGrant(scopes, access)) return new Set(fleet.map((d) => d.id));
49357
+ const byId = /* @__PURE__ */ new Map();
49358
+ for (const d of fleet) byId.set(d.id, d);
49359
+ const grants = deviceScopesFor(scopes, access);
49360
+ const out = /* @__PURE__ */ new Set();
49361
+ for (const d of fleet) for (const grant of grants) {
49362
+ if (deviceSelectorMatches(grant.selector, d)) {
49363
+ out.add(d.id);
49364
+ break;
49365
+ }
49366
+ if (scopeInherits(grant, access)) {
49367
+ if (ancestorsOf(d.id, byId).some((ancestorId) => {
49368
+ const ancestor = byId.get(ancestorId);
49369
+ return ancestor !== void 0 && deviceSelectorMatches(grant.selector, ancestor);
49370
+ })) {
49371
+ out.add(d.id);
49372
+ break;
49373
+ }
49374
+ }
49375
+ }
49376
+ return out;
49377
+ }
49237
49378
  var MB = 1024 * 1024;
49238
49379
  1024 * MB, 3072 * MB;
49239
49380
  //#endregion
@@ -49254,17 +49395,29 @@ var MB = 1024 * 1024;
49254
49395
  * `/addon/export-alexa/admin/redetect-hub-url` route.
49255
49396
  */
49256
49397
  var exportAlexaActions = defineCustomActions({
49257
- redetectHubUrl: customAction(object({}).optional(), object({ publicHubUrl: string() }), { kind: "mutation" }),
49258
- regenerateIngressToken: customAction(object({}).optional(), object({ ingressToken: string() }), { kind: "mutation" }),
49398
+ redetectHubUrl: customAction(object({}).optional(), object({ publicHubUrl: string() }), {
49399
+ kind: "mutation",
49400
+ auth: "admin"
49401
+ }),
49402
+ regenerateIngressToken: customAction(object({}).optional(), object({ ingressToken: string() }), {
49403
+ kind: "mutation",
49404
+ auth: "admin"
49405
+ }),
49259
49406
  resyncDiscovery: customAction(object({}).optional(), object({
49260
49407
  endpointCount: number(),
49261
49408
  accountCount: number()
49262
- }), { kind: "mutation" }),
49409
+ }), {
49410
+ kind: "mutation",
49411
+ auth: "admin"
49412
+ }),
49263
49413
  purgeStaleEndpoints: customAction(object({}).optional(), object({
49264
49414
  requested: number(),
49265
49415
  accountCount: number(),
49266
49416
  attemptedAccounts: number()
49267
- }), { kind: "mutation" }),
49417
+ }), {
49418
+ kind: "mutation",
49419
+ auth: "admin"
49420
+ }),
49268
49421
  /**
49269
49422
  * Sever the Amazon account link from this side — the only severance path
49270
49423
  * that exists, because the descriptor sets `refreshTokenTtlSec: 'never'`
@@ -49281,16 +49434,31 @@ var exportAlexaActions = defineCustomActions({
49281
49434
  unlinkAlexaAccounts: customAction(object({}).optional(), object({
49282
49435
  revokedSessions: number(),
49283
49436
  removedAccounts: number()
49284
- }), { kind: "mutation" }),
49437
+ }), {
49438
+ kind: "mutation",
49439
+ auth: "admin"
49440
+ }),
49285
49441
  /**
49286
49442
  * Swap the per-link credential this hub presents to the skill Lambda's
49287
49443
  * token broker (D753) for a fresh one; the old one dies at once. Rotation
49288
49444
  * also happens implicitly on every re-link.
49289
49445
  */
49446
+ /**
49447
+ * The ingress token, in clear, for an administrator who asked for it. The
49448
+ * one read surface that returns it: `deviceExport.getStatus` and the
49449
+ * settings form carry only a mask.
49450
+ */
49451
+ revealIngressToken: customAction(object({}).optional(), object({ ingressToken: string() }), {
49452
+ kind: "mutation",
49453
+ auth: "admin"
49454
+ }),
49290
49455
  rotateBrokerCredential: customAction(object({}).optional(), object({
49291
49456
  rotated: number(),
49292
49457
  failed: number()
49293
- }), { kind: "mutation" })
49458
+ }), {
49459
+ kind: "mutation",
49460
+ auth: "admin"
49461
+ })
49294
49462
  });
49295
49463
  //#endregion
49296
49464
  //#region src/egress/alexa-rtc-egress.ts
@@ -49430,6 +49598,21 @@ async function profileForCamStream(input) {
49430
49598
  }
49431
49599
  //#endregion
49432
49600
  //#region src/handlers/endpoint-id.ts
49601
+ /** The classes a camera publishes a detection sensor for (R1). */
49602
+ var DETECTION_SENSOR_CLASSES = [
49603
+ "person",
49604
+ "vehicle",
49605
+ "animal",
49606
+ "package"
49607
+ ];
49608
+ var DETECTION_PREFIX = "det_";
49609
+ function isDetectionSensorClass(value) {
49610
+ return DETECTION_SENSOR_CLASSES.includes(value);
49611
+ }
49612
+ /** The endpoint id of a camera's detection sensor for one class. */
49613
+ function detectionSensorEndpointId(deviceId, cls) {
49614
+ return `${deviceId}${SEPARATOR}${DETECTION_PREFIX}${cls}`;
49615
+ }
49433
49616
  var SEPARATOR = "__";
49434
49617
  function parseCameraEndpointId(raw) {
49435
49618
  if (!raw.includes(SEPARATOR)) {
@@ -49465,6 +49648,22 @@ function parseCameraEndpointId(raw) {
49465
49648
  kind: "alarm-panel",
49466
49649
  deviceId: parent
49467
49650
  };
49651
+ if (tail === "dev") return {
49652
+ kind: "generic",
49653
+ deviceId: parent
49654
+ };
49655
+ if (tail.startsWith(DETECTION_PREFIX)) {
49656
+ const cls = tail.slice(4);
49657
+ if (isDetectionSensorClass(cls)) return {
49658
+ kind: "detection",
49659
+ deviceId: parent,
49660
+ detectionClass: cls
49661
+ };
49662
+ return {
49663
+ kind: "invalid",
49664
+ reason: `unknown detection class in endpointId: ${raw}`
49665
+ };
49666
+ }
49468
49667
  const child = parseNonNegativeInt(tail);
49469
49668
  if (child === null) return {
49470
49669
  kind: "invalid",
@@ -49646,7 +49845,12 @@ function resolveWebrtcTarget(pref) {
49646
49845
  camStreamId: pref
49647
49846
  };
49648
49847
  }
49649
- async function handleInitiateSessionWithOffer(header, endpointRaw, payloadRaw, deps, _callerIdentity) {
49848
+ /** The cap whose push-talk triple receives the Echo's microphone. */
49849
+ var CAP_INTERCOM$1 = "intercom";
49850
+ async function deviceHasIntercom(api, deviceId) {
49851
+ return (await api.deviceManager.getBindings.query({ deviceId })).entries.some((e) => e.capName === CAP_INTERCOM$1);
49852
+ }
49853
+ async function handleInitiateSessionWithOffer(header, endpointRaw, payloadRaw, deps, _callerIdentity, grant = { mayTalk: false }) {
49650
49854
  const parsed = InitiateSessionWithOfferDirectiveSchema.safeParse({
49651
49855
  endpoint: endpointRaw,
49652
49856
  payload: payloadRaw ?? {}
@@ -49755,12 +49959,21 @@ async function handleInitiateSessionWithOffer(header, endpointRaw, payloadRaw, d
49755
49959
  log.warn("alexa rtc-session: egress acquire failed, falling back to streamPreference target", { meta: { error: err instanceof Error ? err.message : String(err) } });
49756
49960
  derivedTarget = resolveWebrtcTarget(exposedEntry?.settings?.streamPreference ?? "auto");
49757
49961
  }
49962
+ const talkback = grant.mayTalk && await deviceHasIntercom(api, deviceId).catch((err) => {
49963
+ log.warn("alexa rtc-session: intercom lookup failed — this session is one-way audio", { meta: { error: err instanceof Error ? err.message : String(err) } });
49964
+ return false;
49965
+ });
49966
+ log.info("alexa rtc-session: talk-back decision", { meta: {
49967
+ talkback,
49968
+ mayTalk: grant.mayTalk
49969
+ } });
49758
49970
  try {
49759
49971
  const result = await api.webrtcSession.handleOffer.mutate({
49760
49972
  deviceId,
49761
49973
  sdpOffer,
49762
49974
  sessionId,
49763
49975
  target: derivedTarget,
49976
+ talkback,
49764
49977
  relayOnly: false,
49765
49978
  nonTrickle: true,
49766
49979
  disableTurn: true,
@@ -50015,6 +50228,111 @@ async function rotateBrokerCredential(fetchImpl, brokerUrl, credential) {
50015
50228
  async function revokeBrokerCredential(fetchImpl, brokerUrl, credential) {
50016
50229
  await call(fetchImpl, brokerUrl, "/broker/revoke", { bearer: credential }, unknown());
50017
50230
  }
50231
+ /** Device types this export may publish as a generic endpoint. */
50232
+ var GENERIC_DEVICE_TYPES = [
50233
+ "light",
50234
+ "switch",
50235
+ "siren",
50236
+ "sensor",
50237
+ "lock",
50238
+ "presence",
50239
+ "thermostat",
50240
+ "generic"
50241
+ ];
50242
+ function iface(name, property) {
50243
+ return {
50244
+ type: "AlexaInterface",
50245
+ interface: name,
50246
+ version: "3",
50247
+ properties: {
50248
+ supported: [{ name: property }],
50249
+ proactivelyReported: true,
50250
+ retrievable: true
50251
+ }
50252
+ };
50253
+ }
50254
+ var BASE = {
50255
+ type: "AlexaInterface",
50256
+ interface: "Alexa",
50257
+ version: "3"
50258
+ };
50259
+ var HEALTH = {
50260
+ type: "AlexaInterface",
50261
+ interface: "Alexa.EndpointHealth",
50262
+ version: "3.1",
50263
+ properties: {
50264
+ supported: [{ name: "connectivity" }],
50265
+ proactivelyReported: true,
50266
+ retrievable: true
50267
+ }
50268
+ };
50269
+ var ROWS = [
50270
+ {
50271
+ cap: "lock-control",
50272
+ capability: iface("Alexa.LockController", "lockState"),
50273
+ category: "SMARTLOCK"
50274
+ },
50275
+ {
50276
+ cap: "contact",
50277
+ capability: iface("Alexa.ContactSensor", "detectionState"),
50278
+ category: "CONTACT_SENSOR"
50279
+ },
50280
+ {
50281
+ cap: "motion",
50282
+ capability: iface("Alexa.MotionSensor", "detectionState"),
50283
+ category: "MOTION_SENSOR"
50284
+ },
50285
+ {
50286
+ cap: "presence",
50287
+ capability: iface("Alexa.MotionSensor", "detectionState"),
50288
+ category: "MOTION_SENSOR"
50289
+ },
50290
+ {
50291
+ cap: "temperature-sensor",
50292
+ capability: iface("Alexa.TemperatureSensor", "temperature"),
50293
+ category: "TEMPERATURE_SENSOR"
50294
+ },
50295
+ {
50296
+ cap: "humidity-sensor",
50297
+ capability: iface("Alexa.HumiditySensor", "relativeHumidity"),
50298
+ category: "TEMPERATURE_SENSOR"
50299
+ },
50300
+ {
50301
+ cap: "switch",
50302
+ capability: iface("Alexa.PowerController", "powerState"),
50303
+ category: "SWITCH"
50304
+ }
50305
+ ];
50306
+ var BRIGHTNESS = iface("Alexa.BrightnessController", "brightness");
50307
+ /** The endpoint for a non-camera device, or null when none of its caps has an
50308
+ * Alexa interface. */
50309
+ function buildGenericDeviceEndpoint(input) {
50310
+ const capabilities = [BASE];
50311
+ const seen = /* @__PURE__ */ new Set();
50312
+ let category = null;
50313
+ for (const row of ROWS) {
50314
+ if (!input.capNames.includes(row.cap)) continue;
50315
+ if (seen.has(row.capability.interface)) continue;
50316
+ seen.add(row.capability.interface);
50317
+ capabilities.push(row.capability);
50318
+ category ??= row.category;
50319
+ }
50320
+ if (category === null) return null;
50321
+ const dimmable = input.capNames.includes("switch") && input.capNames.includes("brightness");
50322
+ if (dimmable) capabilities.push(BRIGHTNESS);
50323
+ if (category === "SWITCH" && (dimmable || input.type === "light")) category = "LIGHT";
50324
+ capabilities.push(HEALTH);
50325
+ const name = input.name.trim();
50326
+ return {
50327
+ endpointId: `${input.deviceId}__dev`,
50328
+ manufacturerName: "camstack",
50329
+ description: `CamStack ${input.type || "device"}`,
50330
+ friendlyName: name.length > 0 ? name : `Device ${input.deviceId}`,
50331
+ displayCategories: [category],
50332
+ capabilities,
50333
+ cookie: { stableId: input.stableId }
50334
+ };
50335
+ }
50018
50336
  //#endregion
50019
50337
  //#region src/handlers/security-panel-arm-state.ts
50020
50338
  /**
@@ -50116,6 +50434,34 @@ var CAMERA_STREAM_CONTROLLER = {
50116
50434
  audioCodecs: ["AAC"]
50117
50435
  }]
50118
50436
  };
50437
+ /**
50438
+ * `Alexa.EndpointHealth` v3.1 — `connectivity`, retrievable and proactive.
50439
+ *
50440
+ * Required by Amazon on every sensor endpoint (a camera with MotionSensor is
50441
+ * one) and for live view it is load-bearing: the first time it was declared,
50442
+ * the hub only ever reported a TRANSITION and ReportState never answered it,
50443
+ * so Alexa held no value — and the phone app refused live view on an endpoint
50444
+ * of unknown health while an Echo Show kept streaming. It is declared ONLY
50445
+ * together with what keeps a value in Alexa's hands (D770):
50446
+ *
50447
+ * - `handlers/report-state.ts` answers `connectivity` for every endpoint
50448
+ * that declares it (`OK` when the status read itself fails);
50449
+ * - `LwaEventGateway.scheduleInitialConnectivity` reports it after every
50450
+ * AddOrUpdateReport and every Discover;
50451
+ * - the gateway's online/offline ChangeReports keep it current.
50452
+ *
50453
+ * `endpoint-health.spec.ts` pins all three.
50454
+ */
50455
+ var ENDPOINT_HEALTH = {
50456
+ type: "AlexaInterface",
50457
+ interface: "Alexa.EndpointHealth",
50458
+ version: "3.1",
50459
+ properties: {
50460
+ supported: [{ name: "connectivity" }],
50461
+ proactivelyReported: true,
50462
+ retrievable: true
50463
+ }
50464
+ };
50119
50465
  var DOORBELL_EVENT_SOURCE = {
50120
50466
  type: "AlexaInterface",
50121
50467
  interface: "Alexa.DoorbellEventSource",
@@ -50132,6 +50478,26 @@ var MOTION_SENSOR = {
50132
50478
  retrievable: true
50133
50479
  }
50134
50480
  };
50481
+ /**
50482
+ * `Alexa.SmartVision.SnapshotProvider` v1.1 — the camera's still in the Alexa
50483
+ * app (R2, D773). Available in it-IT and en-GB, unlike object detection.
50484
+ * Declared on every camera while the integration's "Snapshots" switch is on;
50485
+ * `GetSnapshot` answers with a signed, ten-minute, account-token-checked image
50486
+ * on the hub's public address (`handlers/snapshot-provider.ts`).
50487
+ *
50488
+ * `minRefreshIntervalInSeconds` bounds how often Alexa may ask per camera:
50489
+ * every ask is a snapshot read and a re-encode on the hub, and a picture of the
50490
+ * home handed to Amazon, so the tile refreshes at most every few minutes.
50491
+ */
50492
+ var SMART_VISION_SNAPSHOT_PROVIDER = {
50493
+ type: "AlexaInterface",
50494
+ interface: "Alexa.SmartVision.SnapshotProvider",
50495
+ version: "1.1",
50496
+ configuration: {
50497
+ isAvailable: true,
50498
+ minRefreshIntervalInSeconds: 180
50499
+ }
50500
+ };
50135
50501
  function rtcSessionController(fullDuplexAudio) {
50136
50502
  return {
50137
50503
  type: "AlexaInterface",
@@ -50140,13 +50506,15 @@ function rtcSessionController(fullDuplexAudio) {
50140
50506
  configuration: { isFullDuplexAudioSupported: fullDuplexAudio }
50141
50507
  };
50142
50508
  }
50509
+ /** Proactively reported (R3, D776): `StateChangeReporter` pushes every
50510
+ * `switch` / `privacy-mask` / `ptz-autotrack` / `brightness` change. */
50143
50511
  var POWER_CONTROLLER = {
50144
50512
  type: "AlexaInterface",
50145
50513
  interface: "Alexa.PowerController",
50146
50514
  version: "3",
50147
50515
  properties: {
50148
50516
  supported: [{ name: "powerState" }],
50149
- proactivelyReported: false,
50517
+ proactivelyReported: true,
50150
50518
  retrievable: true
50151
50519
  }
50152
50520
  };
@@ -50156,16 +50524,6 @@ var BRIGHTNESS_CONTROLLER = {
50156
50524
  version: "3",
50157
50525
  properties: {
50158
50526
  supported: [{ name: "brightness" }],
50159
- proactivelyReported: false,
50160
- retrievable: true
50161
- }
50162
- };
50163
- var BATTERY_LEVEL_SENSOR = {
50164
- type: "AlexaInterface",
50165
- interface: "Alexa.BatteryLevelSensor",
50166
- version: "3",
50167
- properties: {
50168
- supported: [{ name: "batteryLevel" }],
50169
50527
  proactivelyReported: true,
50170
50528
  retrievable: true
50171
50529
  }
@@ -50338,11 +50696,12 @@ function buildParentCameraEndpoint(input) {
50338
50696
  const capabilities = [
50339
50697
  ALEXA_BASE_CAPABILITY,
50340
50698
  CAMERA_STREAM_CONTROLLER,
50341
- rtcSessionController(input.hasIntercom)
50699
+ rtcSessionController(input.hasIntercom),
50700
+ ENDPOINT_HEALTH
50342
50701
  ];
50343
50702
  if (input.isDoorbell) capabilities.push(DOORBELL_EVENT_SOURCE);
50344
50703
  if (input.hasMotion) capabilities.push(MOTION_SENSOR);
50345
- if (input.hasBattery) capabilities.push(BATTERY_LEVEL_SENSOR);
50704
+ if (input.snapshotProvider === true) capabilities.push(SMART_VISION_SNAPSHOT_PROVIDER);
50346
50705
  const presetNames = distinctPresetNames(input.ptzPresetNames);
50347
50706
  if (presetNames.length >= MIN_MODE_CONTROLLER_MODES) capabilities.push(ptzPresetModeController(presetNames));
50348
50707
  if (input.hasPtz) {
@@ -50449,10 +50808,10 @@ function buildPrivacyEndpoint(input) {
50449
50808
  * declared: `alarm-panel` has no field saying WHICH alarm is sounding, and
50450
50809
  * declaring a property we would always answer `OK` on is a claim with no
50451
50810
  * basis behind it. The reasoning is in `security-panel-arm-state.ts`.
50452
- * - `proactivelyReported: false`. This addon's Event Gateway pushes
50453
- * ChangeReports for camera motion only; a panel state change is not wired
50454
- * to it, and promising a report nothing sends is how an endpoint ends up
50455
- * looking broken. `retrievable: true` — `Alexa/ReportState` answers.
50811
+ * - `proactivelyReported: true` (R3, D776). Amazon asks for alarms to be
50812
+ * reported proactively; `StateChangeReporter` pushes every `alarm-panel`
50813
+ * slice change (arm state and the burglary alarm). `retrievable: true` —
50814
+ * `Alexa/ReportState` answers too.
50456
50815
  * - `supportedAuthorizationTypes` appears only for a panel that requires a
50457
50816
  * code. Alexa's Disarm directive can carry a PIN; its **Arm directive
50458
50817
  * cannot**, which is why a PIN-gated arm is refused by the handler with
@@ -50467,7 +50826,7 @@ function buildAlarmPanelEndpoint(input) {
50467
50826
  version: "3",
50468
50827
  properties: {
50469
50828
  supported: [{ name: "armState" }, { name: "burglaryAlarm" }],
50470
- proactivelyReported: false,
50829
+ proactivelyReported: true,
50471
50830
  retrievable: true
50472
50831
  },
50473
50832
  configuration
@@ -50482,6 +50841,42 @@ function buildAlarmPanelEndpoint(input) {
50482
50841
  cookie: { stableId: input.stableId }
50483
50842
  };
50484
50843
  }
50844
+ /** How a detection sensor is named after its camera. English, like every
50845
+ * other name this skill publishes; the operator can rename it in the app. */
50846
+ var DETECTION_SENSOR_LABELS = {
50847
+ person: "Person",
50848
+ vehicle: "Vehicle",
50849
+ animal: "Animal",
50850
+ package: "Package"
50851
+ };
50852
+ /**
50853
+ * One class of a camera's detections as an `Alexa.MotionSensor` endpoint —
50854
+ * "Front door Person" (R1, D772). It is what an it-IT / en-GB account can use
50855
+ * as a routine trigger ("when Front door Person detects motion → announce…"):
50856
+ * Amazon's object-detection interface is en-US only, MotionSensor is not.
50857
+ * Driven by the pipeline's tracks, not by the camera's own motion. Sensors
50858
+ * need `Alexa.EndpointHealth` (Amazon), reported like the camera's (D770).
50859
+ */
50860
+ function buildDetectionSensorEndpoint(input) {
50861
+ const label = DETECTION_SENSOR_LABELS[input.detectionClass];
50862
+ const camera = input.cameraName.trim().length > 0 ? input.cameraName.trim() : `Camera ${input.deviceId}`;
50863
+ return {
50864
+ endpointId: detectionSensorEndpointId(input.deviceId, input.detectionClass),
50865
+ manufacturerName: "camstack",
50866
+ description: `CamStack ${input.detectionClass} detection`,
50867
+ friendlyName: `${camera} ${label}`,
50868
+ displayCategories: ["MOTION_SENSOR"],
50869
+ capabilities: [
50870
+ ALEXA_BASE_CAPABILITY,
50871
+ MOTION_SENSOR,
50872
+ ENDPOINT_HEALTH
50873
+ ],
50874
+ cookie: {
50875
+ stableId: input.stableId,
50876
+ detectionClass: input.detectionClass
50877
+ }
50878
+ };
50879
+ }
50485
50880
  //#endregion
50486
50881
  //#region src/handlers/ptz-preset-recallable.ts
50487
50882
  var RECALLABLE_FUNCTIONS = new Set(["home"]);
@@ -50501,12 +50896,13 @@ var CAP_PTZ = "ptz";
50501
50896
  var CAP_PTZ_AUTOTRACK = "ptz-autotrack";
50502
50897
  var CAP_SWITCH = "switch";
50503
50898
  var CAP_BRIGHTNESS = "brightness";
50504
- var CAP_BATTERY = "battery";
50505
50899
  var CAP_PRIVACY_MASK = "privacy-mask";
50506
50900
  var CAP_ALARM_PANEL = "alarm-panel";
50507
- async function handleDiscovery(header, payloadRaw, deps, callerIdentity) {
50901
+ async function handleDiscovery(header, payloadRaw, deps, callerIdentity, visibility) {
50508
50902
  if (!discoveryPayloadSchema.safeParse(payloadRaw).success) return errorEnvelope(header, "INVALID_DIRECTIVE", "Discovery payload shape unexpected");
50509
- const exposed = await deps.listExposed();
50903
+ const allExposed = await deps.listExposed();
50904
+ const exposed = [];
50905
+ for (const entry of allExposed) if (await visibility.deviceVisible(entry.deviceId)) exposed.push(entry);
50510
50906
  const built = new Array(exposed.length).fill(null);
50511
50907
  const queue = exposed.map((entry, index) => ({
50512
50908
  entry,
@@ -50517,7 +50913,7 @@ async function handleDiscovery(header, payloadRaw, deps, callerIdentity) {
50517
50913
  const item = queue.shift();
50518
50914
  if (item === void 0) return;
50519
50915
  try {
50520
- built[item.index] = await buildEndpointsForExposedDevice(item.entry.deviceId, deps);
50916
+ built[item.index] = await deps.buildEndpoints(item.entry.deviceId);
50521
50917
  } catch (err) {
50522
50918
  deps.logger.warn("alexa discovery (on-demand): skipping device — build failed", {
50523
50919
  tags: { deviceId: item.entry.deviceId },
@@ -50528,10 +50924,12 @@ async function handleDiscovery(header, payloadRaw, deps, callerIdentity) {
50528
50924
  };
50529
50925
  const lanes = Math.min(DISCOVERY_BUILD_CONCURRENCY, Math.max(1, queue.length));
50530
50926
  await Promise.all(Array.from({ length: lanes }, lane));
50531
- const endpoints = built.flatMap((b) => b ?? []);
50927
+ const endpoints = await visibility.filterEndpoints(built.flatMap((b) => b ?? []));
50532
50928
  deps.logger.info("alexa discovery returned endpoints", { meta: {
50533
50929
  count: endpoints.length,
50534
- userId: callerIdentity.userId
50930
+ userId: callerIdentity.userId,
50931
+ exposedDevices: allExposed.length,
50932
+ visibleDevices: exposed.length
50535
50933
  } });
50536
50934
  return discoveryResponse(header, endpoints);
50537
50935
  }
@@ -50543,6 +50941,26 @@ async function buildEndpointsForExposedDevice(deviceId, deps) {
50543
50941
  return [];
50544
50942
  }
50545
50943
  const parentInfo = await loadBoundDeviceInfo(device, api);
50944
+ if (GENERIC_DEVICE_TYPES.includes(String(device.type)) && !parentInfo.capNames.includes(CAP_ALARM_PANEL)) {
50945
+ const generic = buildGenericDeviceEndpoint({
50946
+ deviceId: parentInfo.id,
50947
+ stableId: parentInfo.stableId,
50948
+ name: parentInfo.name,
50949
+ type: String(device.type),
50950
+ capNames: parentInfo.capNames
50951
+ });
50952
+ if (generic === null) {
50953
+ deps.logger.warn("alexa discovery: device has no cap Alexa has an interface for — not published", {
50954
+ tags: { deviceId },
50955
+ meta: {
50956
+ type: device.type,
50957
+ capNames: parentInfo.capNames
50958
+ }
50959
+ });
50960
+ return [];
50961
+ }
50962
+ return [generic];
50963
+ }
50546
50964
  if (parentInfo.capNames.includes(CAP_ALARM_PANEL)) return [await buildAlarmPanelEndpointFor(parentInfo, api, deps)];
50547
50965
  const children = await api.deviceManager.getChildren.query({ parentDeviceId: deviceId }).catch((err) => {
50548
50966
  deps.logger.warn("alexa discovery: getChildren failed", {
@@ -50558,7 +50976,6 @@ async function buildEndpointsForExposedDevice(deviceId, deps) {
50558
50976
  const hasMotion = parentInfo.capNames.includes(CAP_MOTION);
50559
50977
  const hasAutotrack = parentInfo.capNames.includes(CAP_PTZ_AUTOTRACK);
50560
50978
  const hasPtz = parentInfo.capNames.includes(CAP_PTZ);
50561
- const hasBattery = parentInfo.capNames.includes(CAP_BATTERY);
50562
50979
  const hasPrivacy = parentInfo.capNames.includes(CAP_PRIVACY_MASK);
50563
50980
  deps.logger.info("alexa discovery: built feature flags", {
50564
50981
  tags: { deviceId: parentInfo.id },
@@ -50566,7 +50983,6 @@ async function buildEndpointsForExposedDevice(deviceId, deps) {
50566
50983
  name: parentInfo.name,
50567
50984
  capNames: parentInfo.capNames,
50568
50985
  hasMotion,
50569
- hasBattery,
50570
50986
  hasPtz,
50571
50987
  hasAutotrack,
50572
50988
  hasPrivacy,
@@ -50591,9 +51007,18 @@ async function buildEndpointsForExposedDevice(deviceId, deps) {
50591
51007
  hasIntercom,
50592
51008
  hasMotion,
50593
51009
  ptzPresetNames,
50594
- hasBattery,
50595
- hasPtz
51010
+ hasPtz,
51011
+ snapshotProvider: deps.snapshots === true
50596
51012
  }));
51013
+ if (deps.detectionClasses !== void 0) {
51014
+ const classes = await deps.detectionClasses(parentInfo.id);
51015
+ for (const detectionClass of classes) out.push(buildDetectionSensorEndpoint({
51016
+ deviceId: parentInfo.id,
51017
+ stableId: parentInfo.stableId,
51018
+ cameraName: parentInfo.name,
51019
+ detectionClass
51020
+ }));
51021
+ }
50597
51022
  if (hasAutotrack) out.push(buildAutotrackEndpoint({
50598
51023
  parentDeviceId: parentInfo.id,
50599
51024
  parentStableId: parentInfo.stableId,
@@ -50696,6 +51121,476 @@ function discoveryResponse(header, endpoints) {
50696
51121
  payload: { endpoints }
50697
51122
  } };
50698
51123
  }
51124
+ /** Ancestor-walk bound — defence against a corrupt parentage cycle. */
51125
+ var MAX_ANCESTOR_HOPS = 8;
51126
+ function principalFromGrant(grant) {
51127
+ return grant.isAdmin ? {
51128
+ kind: "admin",
51129
+ userId: grant.userId
51130
+ } : {
51131
+ kind: "scoped",
51132
+ userId: grant.userId,
51133
+ scopes: grant.scopes
51134
+ };
51135
+ }
51136
+ /**
51137
+ * Does `principal` reach `chain[0]` at `access`? `chain` is the device followed
51138
+ * by its ancestors (parent first), each with the metadata a selector needs.
51139
+ * Pure — the one derivation shared by directives, discovery and the event mirror.
51140
+ */
51141
+ function principalAllowsDevice(principal, chain, access) {
51142
+ if (principal.kind === "admin") return true;
51143
+ const target = chain[0];
51144
+ if (target === void 0) return false;
51145
+ return resolveViewableDeviceIds(principal.scopes, chain, access).has(target.id);
51146
+ }
51147
+ function endpointAccessTarget(parsed) {
51148
+ switch (parsed.kind) {
51149
+ case "camera": return {
51150
+ exposedDeviceId: parsed.deviceId,
51151
+ deviceId: parsed.deviceId,
51152
+ requiresParent: null,
51153
+ ownAccess: "view"
51154
+ };
51155
+ case "privacy":
51156
+ case "autotrack": return {
51157
+ exposedDeviceId: parsed.parentDeviceId,
51158
+ deviceId: parsed.parentDeviceId,
51159
+ requiresParent: null,
51160
+ ownAccess: "create"
51161
+ };
51162
+ case "child": return {
51163
+ exposedDeviceId: parsed.parentDeviceId,
51164
+ deviceId: parsed.childDeviceId,
51165
+ requiresParent: parsed.parentDeviceId,
51166
+ ownAccess: "create"
51167
+ };
51168
+ case "alarm-panel":
51169
+ case "detection":
51170
+ case "generic": return {
51171
+ exposedDeviceId: parsed.deviceId,
51172
+ deviceId: parsed.deviceId,
51173
+ requiresParent: null,
51174
+ ownAccess: "view"
51175
+ };
51176
+ case "invalid": return null;
51177
+ }
51178
+ }
51179
+ var AlexaAccessGate = class {
51180
+ deps;
51181
+ grants = null;
51182
+ grantsFlight = null;
51183
+ meta = /* @__PURE__ */ new Map();
51184
+ warnedStale = false;
51185
+ constructor(deps) {
51186
+ this.deps = deps;
51187
+ }
51188
+ now() {
51189
+ return this.deps.now?.() ?? Date.now();
51190
+ }
51191
+ /** Resolve the principal a link with this `userId` acts for. */
51192
+ async principal(userId) {
51193
+ const snapshot = await this.readGrants();
51194
+ if (snapshot === null) return {
51195
+ kind: "unavailable",
51196
+ error: "the hub user store could not be read"
51197
+ };
51198
+ const grant = snapshot.byUserId.get(userId);
51199
+ if (grant === void 0) return { kind: "unknown-user" };
51200
+ return {
51201
+ kind: "found",
51202
+ principal: principalFromGrant(grant)
51203
+ };
51204
+ }
51205
+ /** Does `principal` reach `deviceId` at `access`? */
51206
+ async allows(principal, deviceId, access) {
51207
+ if (principal.kind === "admin") return "allowed";
51208
+ const chain = await this.chainOf(deviceId);
51209
+ if (chain === null) return "unknown";
51210
+ return principalAllowsDevice(principal, chain, access) ? "allowed" : "denied";
51211
+ }
51212
+ /** The device's parent per the registry; `undefined` when unknown. */
51213
+ async parentOf(deviceId) {
51214
+ const meta = await this.metaOf(deviceId);
51215
+ return meta === null ? void 0 : meta.parentDeviceId;
51216
+ }
51217
+ /** Forget everything cached — for tests and an operator-driven re-sync. */
51218
+ invalidate() {
51219
+ this.grants = null;
51220
+ this.meta.clear();
51221
+ }
51222
+ async readGrants() {
51223
+ const cached = this.grants;
51224
+ if (cached !== null && this.now() - cached.at < 3e4) return cached;
51225
+ if (this.grantsFlight === null) this.grantsFlight = this.deps.listUserGrants().then((list) => {
51226
+ const byUserId = /* @__PURE__ */ new Map();
51227
+ for (const g of list) byUserId.set(g.userId, g);
51228
+ const snapshot = {
51229
+ at: this.now(),
51230
+ byUserId
51231
+ };
51232
+ this.grants = snapshot;
51233
+ this.warnedStale = false;
51234
+ return snapshot;
51235
+ }).finally(() => {
51236
+ this.grantsFlight = null;
51237
+ });
51238
+ try {
51239
+ return await this.grantsFlight;
51240
+ } catch (err) {
51241
+ if (cached !== null) {
51242
+ if (!this.warnedStale) {
51243
+ this.warnedStale = true;
51244
+ this.deps.logger.warn("alexa access: the hub user store could not be re-read — keeping the last good grants", { meta: {
51245
+ error: errMsg(err),
51246
+ ageMs: this.now() - cached.at
51247
+ } });
51248
+ }
51249
+ return cached;
51250
+ }
51251
+ this.deps.logger.warn("alexa access: the hub user store could not be read — refusing Alexa access until it can", { meta: { error: errMsg(err) } });
51252
+ return null;
51253
+ }
51254
+ }
51255
+ async metaOf(deviceId) {
51256
+ const cached = this.meta.get(deviceId);
51257
+ if (cached !== void 0 && this.now() - cached.at < 6e4) return cached.meta;
51258
+ try {
51259
+ const meta = await this.deps.getDeviceMeta(deviceId);
51260
+ this.meta.set(deviceId, {
51261
+ at: this.now(),
51262
+ meta
51263
+ });
51264
+ return meta;
51265
+ } catch (err) {
51266
+ this.deps.logger.debug("alexa access: device metadata read failed", {
51267
+ tags: { deviceId },
51268
+ meta: { error: errMsg(err) }
51269
+ });
51270
+ return cached?.meta ?? null;
51271
+ }
51272
+ }
51273
+ /** The device followed by its ancestors, or `null` if the device is unknown.
51274
+ * An unknown ANCESTOR ends the walk — inheritance through it is simply not
51275
+ * granted (fail closed). */
51276
+ async chainOf(deviceId) {
51277
+ const self = await this.metaOf(deviceId);
51278
+ if (self === null) return null;
51279
+ const chain = [self];
51280
+ let parent = self.parentDeviceId;
51281
+ for (let hop = 0; hop < MAX_ANCESTOR_HOPS && parent !== null; hop++) {
51282
+ if (chain.some((d) => d.id === parent)) break;
51283
+ const meta = await this.metaOf(parent);
51284
+ if (meta === null) break;
51285
+ chain.push(meta);
51286
+ parent = meta.parentDeviceId;
51287
+ }
51288
+ return chain;
51289
+ }
51290
+ };
51291
+ //#endregion
51292
+ //#region src/connectivity.ts
51293
+ /** The one field read off the `device-status` slice (its wire output is
51294
+ * untyped here). */
51295
+ var OnlineSchema$1 = object({ online: boolean() });
51296
+ async function readConnectivity(api, deviceId, logger) {
51297
+ try {
51298
+ const parsed = OnlineSchema$1.safeParse(await api.deviceStatus.getStatus.query({ deviceId }));
51299
+ return parsed.success && !parsed.data.online ? "UNREACHABLE" : "OK";
51300
+ } catch (err) {
51301
+ logger.debug("alexa: device-status unreadable — reporting connectivity OK", {
51302
+ tags: { deviceId },
51303
+ meta: { error: errMsg(err) }
51304
+ });
51305
+ return "OK";
51306
+ }
51307
+ }
51308
+ //#endregion
51309
+ //#region src/endpoints/validate-endpoint.ts
51310
+ /**
51311
+ * Interfaces this skill may declare, with the version Amazon documents. An
51312
+ * interface missing here is either not in Amazon's list (`BatteryLevelSensor`
51313
+ * never was) or deprecated (`MediaMetadata`, `EventDetectionSensor`).
51314
+ */
51315
+ var ALLOWED_INTERFACE_VERSIONS = {
51316
+ Alexa: "3",
51317
+ "Alexa.CameraStreamController": "3",
51318
+ "Alexa.RTCSessionController": "3",
51319
+ "Alexa.DoorbellEventSource": "3",
51320
+ "Alexa.MotionSensor": "3",
51321
+ "Alexa.ContactSensor": "3",
51322
+ "Alexa.TemperatureSensor": "3",
51323
+ "Alexa.HumiditySensor": "3",
51324
+ "Alexa.EndpointHealth": "3.1",
51325
+ "Alexa.PowerController": "3",
51326
+ "Alexa.BrightnessController": "3",
51327
+ "Alexa.ModeController": "3",
51328
+ "Alexa.ToggleController": "3",
51329
+ "Alexa.RangeController": "3",
51330
+ "Alexa.SecurityPanelController": "3",
51331
+ "Alexa.LockController": "3",
51332
+ "Alexa.SmartVision.SnapshotProvider": "1.1",
51333
+ "Alexa.SmartVision.ObjectDetectionSensor": "1.0"
51334
+ };
51335
+ /** Interfaces that need an `instance`. */
51336
+ var INSTANCED = new Set([
51337
+ "Alexa.ModeController",
51338
+ "Alexa.ToggleController",
51339
+ "Alexa.RangeController"
51340
+ ]);
51341
+ /** Sensor interfaces Amazon requires to be paired with `Alexa.EndpointHealth`. */
51342
+ var NEEDS_ENDPOINT_HEALTH = new Set([
51343
+ "Alexa.MotionSensor",
51344
+ "Alexa.ContactSensor",
51345
+ "Alexa.TemperatureSensor",
51346
+ "Alexa.HumiditySensor",
51347
+ "Alexa.LockController",
51348
+ "Alexa.SmartVision.ObjectDetectionSensor"
51349
+ ]);
51350
+ /** `displayCategories` values from alexa-discovery (the subset a camstack
51351
+ * device can be). */
51352
+ var DISPLAY_CATEGORIES = new Set([
51353
+ "CAMERA",
51354
+ "DOORBELL",
51355
+ "LIGHT",
51356
+ "SWITCH",
51357
+ "SMARTPLUG",
51358
+ "SECURITY_PANEL",
51359
+ "SECURITY_SYSTEM",
51360
+ "MOTION_SENSOR",
51361
+ "CONTACT_SENSOR",
51362
+ "TEMPERATURE_SENSOR",
51363
+ "SMARTLOCK",
51364
+ "DOOR",
51365
+ "GARAGE_DOOR",
51366
+ "OTHER"
51367
+ ]);
51368
+ var ENDPOINT_ID_PATTERN = /^[A-Za-z0-9 _\-=#;:?@&]{1,256}$/;
51369
+ var MAX_FRIENDLY_NAME = 256;
51370
+ var MAX_DESCRIPTION = 128;
51371
+ var MAX_MANUFACTURER = 128;
51372
+ var MAX_COOKIE_BYTES = 5e3;
51373
+ var MAX_CAPABILITIES = 100;
51374
+ var MIN_MODES = 2;
51375
+ function capabilityKey$1(c) {
51376
+ return c.instance === void 0 ? c.interface : `${c.interface}#${c.instance}`;
51377
+ }
51378
+ function friendlyTexts(c) {
51379
+ return (c.capabilityResources?.friendlyNames ?? []).map((n) => n.value.text);
51380
+ }
51381
+ function validateCapability(endpointId, c, out) {
51382
+ const push = (rule) => {
51383
+ out.push({
51384
+ endpointId,
51385
+ rule: `${capabilityKey$1(c)}: ${rule}`
51386
+ });
51387
+ };
51388
+ if (c.type !== "AlexaInterface") push("type must be AlexaInterface");
51389
+ const version = ALLOWED_INTERFACE_VERSIONS[c.interface];
51390
+ if (version === void 0) {
51391
+ push("interface is not in Amazon's interface list (or is deprecated)");
51392
+ return;
51393
+ }
51394
+ if (c.version !== version) push(`version must be ${version}`);
51395
+ if (INSTANCED.has(c.interface)) {
51396
+ if (c.instance === void 0 || c.instance.length === 0) push("instance is required");
51397
+ if (friendlyTexts(c).length === 0) push("capabilityResources.friendlyNames is required");
51398
+ }
51399
+ if (c.properties !== void 0) {
51400
+ if (c.properties.supported.length === 0) push("properties.supported must not be empty");
51401
+ }
51402
+ if (c.interface === "Alexa.ModeController") {
51403
+ const modes = c.configuration?.["supportedModes"];
51404
+ if (!Array.isArray(modes) || modes.length < MIN_MODES) push(`at least ${MIN_MODES} supportedModes`);
51405
+ else {
51406
+ const values = modes.map((m) => typeof m === "object" && m !== null ? String(Reflect.get(m, "value")) : "");
51407
+ if (new Set(values).size !== values.length) push("supportedModes values must be unique");
51408
+ }
51409
+ }
51410
+ if (c.interface === "Alexa.SmartVision.SnapshotProvider") {
51411
+ if (typeof c.configuration?.["isAvailable"] !== "boolean") push("configuration.isAvailable is required");
51412
+ }
51413
+ }
51414
+ /** Every rule an endpoint breaks. Empty = Amazon should accept it. */
51415
+ function validateAlexaEndpoint(endpoint) {
51416
+ const out = [];
51417
+ const id = endpoint.endpointId;
51418
+ const push = (rule) => {
51419
+ out.push({
51420
+ endpointId: id,
51421
+ rule
51422
+ });
51423
+ };
51424
+ if (!ENDPOINT_ID_PATTERN.test(id)) push("endpointId: 1-256 of [A-Za-z0-9 _-=#;:?@&]");
51425
+ if (endpoint.friendlyName.trim().length === 0) push("friendlyName must not be empty");
51426
+ if (endpoint.friendlyName.length > MAX_FRIENDLY_NAME) push("friendlyName longer than 256");
51427
+ if (endpoint.description.length === 0 || endpoint.description.length > MAX_DESCRIPTION) push("description must be 1-128 characters");
51428
+ if (endpoint.manufacturerName.length === 0 || endpoint.manufacturerName.length > MAX_MANUFACTURER) push("manufacturerName must be 1-128 characters");
51429
+ if (endpoint.displayCategories.length === 0) push("displayCategories must not be empty");
51430
+ for (const cat of endpoint.displayCategories) if (!DISPLAY_CATEGORIES.has(cat)) push(`displayCategory ${cat} is not an Amazon value`);
51431
+ if (endpoint.cookie !== void 0) {
51432
+ if (new TextEncoder().encode(JSON.stringify(endpoint.cookie)).length > MAX_COOKIE_BYTES) push("cookie larger than 5000 bytes");
51433
+ }
51434
+ const caps = endpoint.capabilities;
51435
+ if (caps.length > MAX_CAPABILITIES) push("more than 100 capabilities");
51436
+ if (!caps.some((c) => c.interface === "Alexa" && c.version === "3")) push("the base Alexa interface (version 3) is required");
51437
+ const keys = caps.map(capabilityKey$1);
51438
+ if (new Set(keys).size !== keys.length) push("a capability (interface + instance) is declared twice");
51439
+ const interfaces = new Set(caps.map((c) => c.interface));
51440
+ for (const needs of NEEDS_ENDPOINT_HEALTH) if (interfaces.has(needs) && !interfaces.has("Alexa.EndpointHealth")) push(`${needs} requires Alexa.EndpointHealth`);
51441
+ if (interfaces.has("Alexa.RTCSessionController") && endpoint.displayCategories[0] !== "CAMERA") push("an RTCSessionController endpoint must list CAMERA first");
51442
+ for (const c of caps) validateCapability(id, c, out);
51443
+ return out;
51444
+ }
51445
+ /** Split endpoints into the valid ones and the violations of the rest. */
51446
+ function partitionValidEndpoints(endpoints) {
51447
+ const valid = [];
51448
+ const violations = [];
51449
+ for (const e of endpoints) {
51450
+ const found = validateAlexaEndpoint(e);
51451
+ if (found.length === 0) valid.push(e);
51452
+ else violations.push(...found);
51453
+ }
51454
+ return {
51455
+ valid,
51456
+ violations
51457
+ };
51458
+ }
51459
+ /**
51460
+ * The runtime gate: keep the valid endpoints, and say out loud which ones were
51461
+ * withheld and why. Sending an invalid one risks Alexa rejecting the report —
51462
+ * and with it every valid endpoint beside it.
51463
+ */
51464
+ function withholdInvalidEndpoints(endpoints, warn) {
51465
+ const { valid, violations } = partitionValidEndpoints(endpoints);
51466
+ if (violations.length > 0) warn("alexa: endpoints WITHHELD — they break an Amazon discovery rule and could make Alexa reject the whole report", violations);
51467
+ return valid;
51468
+ }
51469
+ //#endregion
51470
+ //#region src/access/endpoint-access.ts
51471
+ /**
51472
+ * May `principal` reach `endpointId` at `access`? `access` defaults to the
51473
+ * endpoint's own discovery access (`view` for a camera, `create` for an
51474
+ * actuator-only endpoint).
51475
+ */
51476
+ async function authorizeEndpoint(endpointId, principal, deps, access) {
51477
+ const target = endpointAccessTarget(parseCameraEndpointId(endpointId));
51478
+ if (target === null) return {
51479
+ allowed: false,
51480
+ refusal: { kind: "invalid-endpoint" }
51481
+ };
51482
+ if (!deps.isExposed(target.exposedDeviceId)) return {
51483
+ allowed: false,
51484
+ refusal: {
51485
+ kind: "not-exposed",
51486
+ deviceId: target.exposedDeviceId
51487
+ }
51488
+ };
51489
+ if (target.requiresParent !== null) {
51490
+ const parent = await deps.gate.parentOf(target.deviceId);
51491
+ if (parent === void 0) return {
51492
+ allowed: false,
51493
+ refusal: {
51494
+ kind: "device-unknown",
51495
+ deviceId: target.deviceId
51496
+ }
51497
+ };
51498
+ if (parent !== target.requiresParent) return {
51499
+ allowed: false,
51500
+ refusal: {
51501
+ kind: "not-a-child",
51502
+ deviceId: target.deviceId,
51503
+ parentDeviceId: target.requiresParent
51504
+ }
51505
+ };
51506
+ }
51507
+ const needed = access ?? target.ownAccess;
51508
+ const verdict = await deps.gate.allows(principal, target.deviceId, needed);
51509
+ if (verdict === "allowed") return { allowed: true };
51510
+ if (verdict === "unknown") return {
51511
+ allowed: false,
51512
+ refusal: {
51513
+ kind: "device-unknown",
51514
+ deviceId: target.deviceId
51515
+ }
51516
+ };
51517
+ return {
51518
+ allowed: false,
51519
+ refusal: {
51520
+ kind: "not-granted",
51521
+ deviceId: target.deviceId,
51522
+ access: needed
51523
+ }
51524
+ };
51525
+ }
51526
+ /**
51527
+ * Interfaces on a VIEWABLE endpoint that only actuate. A user who may see a
51528
+ * camera but not control it is shown the camera without them, so the Alexa app
51529
+ * never offers a PTZ dropdown whose every use would be refused.
51530
+ */
51531
+ var CONTROL_ONLY_INTERFACES = new Set(["Alexa.ModeController", "Alexa.ToggleController"]);
51532
+ /** Strip what a view-only principal may not drive. Two-way audio is driving
51533
+ * the camera's speaker, so it goes too. Returns a NEW endpoint. */
51534
+ function viewOnlyShape(endpoint) {
51535
+ return {
51536
+ ...endpoint,
51537
+ capabilities: endpoint.capabilities.filter((c) => !CONTROL_ONLY_INTERFACES.has(c.interface)).map((c) => c.interface === "Alexa.RTCSessionController" ? {
51538
+ ...c,
51539
+ configuration: {
51540
+ ...c.configuration,
51541
+ isFullDuplexAudioSupported: false
51542
+ }
51543
+ } : c)
51544
+ };
51545
+ }
51546
+ /** Keep the endpoints `principal` may be shown, shaped to what it may drive.
51547
+ * Order is preserved. */
51548
+ async function filterEndpointsForPrincipal(endpoints, principal, deps) {
51549
+ const kept = [];
51550
+ const refused = [];
51551
+ for (const endpoint of endpoints) {
51552
+ const verdict = await authorizeEndpoint(endpoint.endpointId, principal, deps);
51553
+ if (!verdict.allowed) {
51554
+ refused.push({
51555
+ endpointId: endpoint.endpointId,
51556
+ refusal: verdict.refusal
51557
+ });
51558
+ continue;
51559
+ }
51560
+ const target = endpointAccessTarget(parseCameraEndpointId(endpoint.endpointId));
51561
+ if (principal.kind === "scoped" && target !== null && target.ownAccess === "view") {
51562
+ const control = await deps.gate.allows(principal, target.deviceId, "create");
51563
+ kept.push(control === "allowed" ? endpoint : viewOnlyShape(endpoint));
51564
+ continue;
51565
+ }
51566
+ kept.push(endpoint);
51567
+ }
51568
+ return {
51569
+ kept,
51570
+ refused
51571
+ };
51572
+ }
51573
+ /** The Alexa error type a refusal maps to. A device the user may not see does
51574
+ * not exist for that user; one it may see but not control is a permission
51575
+ * failure; a device the registry cannot read right now is unreachable. */
51576
+ function alexaErrorTypeFor(refusal) {
51577
+ switch (refusal.kind) {
51578
+ case "device-unknown": return "ENDPOINT_UNREACHABLE";
51579
+ case "not-granted": return refusal.access === "view" ? "NO_SUCH_ENDPOINT" : "INVALID_DIRECTIVE";
51580
+ case "invalid-endpoint":
51581
+ case "not-exposed":
51582
+ case "not-a-child": return "NO_SUCH_ENDPOINT";
51583
+ }
51584
+ }
51585
+ function describeRefusal(refusal) {
51586
+ switch (refusal.kind) {
51587
+ case "invalid-endpoint": return "the endpoint id is not one this skill publishes";
51588
+ case "not-exposed": return `device ${refusal.deviceId} is not exposed to Alexa`;
51589
+ case "not-a-child": return `device ${refusal.deviceId} is not an accessory of ${refusal.parentDeviceId}`;
51590
+ case "not-granted": return `the linked hub user has no ${refusal.access} grant on device ${refusal.deviceId}`;
51591
+ case "device-unknown": return `device ${refusal.deviceId} could not be resolved in the registry`;
51592
+ }
51593
+ }
50699
51594
  //#endregion
50700
51595
  //#region src/handlers/brightness-controller.ts
50701
51596
  var SetBrightnessDirectiveSchema = object({
@@ -50718,20 +51613,21 @@ async function handleSetBrightness(header, endpointRaw, payloadRaw, deps, _calle
50718
51613
  if (!parsed.success) return errorEnvelope(header, "INVALID_DIRECTIVE", `payload validation failed: ${parsed.error.message}`);
50719
51614
  const endpointId = parsed.data.endpoint.endpointId;
50720
51615
  const parsedId = parseCameraEndpointId(endpointId);
50721
- 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`);
51616
+ 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`);
50722
51617
  if (parsedId.kind === "camera") return errorEnvelope(header, "INVALID_DIRECTIVE", "BrightnessController is not exposed on camera endpoints — direct the directive at the dimmable child accessory");
50723
51618
  if (parsedId.kind === "invalid") return errorEnvelope(header, "INVALID_DIRECTIVE", parsedId.reason);
50724
51619
  const percentage = parsed.data.payload.brightness;
50725
51620
  const api = deps.ctx.api;
51621
+ const targetDeviceId = parsedId.kind === "child" ? parsedId.childDeviceId : parsedId.deviceId;
50726
51622
  try {
50727
51623
  await api.brightness.setBrightness.mutate({
50728
- deviceId: parsedId.childDeviceId,
51624
+ deviceId: targetDeviceId,
50729
51625
  percentage
50730
51626
  });
50731
51627
  } catch (err) {
50732
51628
  const message = err instanceof Error ? err.message : String(err);
50733
51629
  deps.logger.warn("alexa brightness-controller: setBrightness failed", { meta: {
50734
- deviceId: parsedId.childDeviceId,
51630
+ deviceId: targetDeviceId,
50735
51631
  percentage,
50736
51632
  error: message
50737
51633
  } });
@@ -50841,6 +51737,66 @@ async function handleInitializeCameraStreams(header, endpointRaw, payloadRaw, de
50841
51737
  } };
50842
51738
  }
50843
51739
  //#endregion
51740
+ //#region src/handlers/lock-controller.ts
51741
+ /** The `lock-control` state as Alexa's `lockState`. */
51742
+ function alexaLockStateFor(state) {
51743
+ switch (state) {
51744
+ case "locked":
51745
+ case "locking": return "LOCKED";
51746
+ case "unlocked":
51747
+ case "unlocking": return "UNLOCKED";
51748
+ case "jammed": return "JAMMED";
51749
+ }
51750
+ }
51751
+ async function readLockState(api, deviceId) {
51752
+ const raw = await api.lockControl.getStatus.query({ deviceId });
51753
+ const parsed = LockControlStatusSchema.pick({ state: true }).safeParse(raw);
51754
+ return parsed.success ? alexaLockStateFor(parsed.data.state) : null;
51755
+ }
51756
+ async function handleLock(header, endpoint, deps) {
51757
+ if (endpoint === void 0) return errorEnvelope(header, "INVALID_DIRECTIVE", "missing endpoint");
51758
+ const parsed = parseCameraEndpointId(endpoint.endpointId);
51759
+ if (parsed.kind !== "generic") return errorEnvelope(header, "INVALID_DIRECTIVE", "LockController is only on a lock endpoint");
51760
+ const lock = header.name === "Lock";
51761
+ const api = deps.ctx.api;
51762
+ try {
51763
+ if (lock) await api.lockControl.lock.mutate({ deviceId: parsed.deviceId });
51764
+ else await api.lockControl.unlock.mutate({ deviceId: parsed.deviceId });
51765
+ } catch (err) {
51766
+ const message = err instanceof Error ? err.message : String(err);
51767
+ deps.logger.warn(`alexa lock-controller: ${header.name} failed`, {
51768
+ tags: { deviceId: parsed.deviceId },
51769
+ meta: { error: message }
51770
+ });
51771
+ return errorEnvelope(header, "ENDPOINT_UNREACHABLE", `${header.name} failed: ${message}`);
51772
+ }
51773
+ const state = await readLockState(api, parsed.deviceId).catch(() => null) ?? (lock ? "LOCKED" : "UNLOCKED");
51774
+ deps.logger.info(`alexa lock-controller: ${header.name}`, {
51775
+ tags: { deviceId: parsed.deviceId },
51776
+ meta: { lockState: state }
51777
+ });
51778
+ return {
51779
+ event: {
51780
+ header: {
51781
+ namespace: "Alexa",
51782
+ name: "Response",
51783
+ messageId: header.messageId,
51784
+ ...header.correlationToken !== void 0 ? { correlationToken: header.correlationToken } : {},
51785
+ payloadVersion: "3"
51786
+ },
51787
+ endpoint: { endpointId: endpoint.endpointId },
51788
+ payload: {}
51789
+ },
51790
+ context: { properties: [{
51791
+ namespace: "Alexa.LockController",
51792
+ name: "lockState",
51793
+ value: state,
51794
+ timeOfSample: (/* @__PURE__ */ new Date()).toISOString(),
51795
+ uncertaintyInMilliseconds: 500
51796
+ }] }
51797
+ };
51798
+ }
51799
+ //#endregion
50844
51800
  //#region src/handlers/power-controller.ts
50845
51801
  var PowerDirectiveSchema = object({
50846
51802
  header: object({
@@ -50876,6 +51832,11 @@ async function handlePower(header, endpointRaw, deps, _callerIdentity) {
50876
51832
  });
50877
51833
  else if (parsedId.kind === "camera") return errorEnvelope(header, "INVALID_DIRECTIVE", "PowerController is not exposed on camera endpoints — direct the directive at the child accessory");
50878
51834
  else if (parsedId.kind === "alarm-panel") return errorEnvelope(header, "INVALID_DIRECTIVE", "PowerController is not exposed on alarm-panel endpoints — use Alexa.SecurityPanelController");
51835
+ else if (parsedId.kind === "generic") await api.switch.setState.mutate({
51836
+ deviceId: parsedId.deviceId,
51837
+ on
51838
+ });
51839
+ else if (parsedId.kind === "detection") return errorEnvelope(header, "INVALID_DIRECTIVE", "PowerController is not exposed on a detection sensor — it only reports");
50879
51840
  else return errorEnvelope(header, "INVALID_DIRECTIVE", parsedId.reason);
50880
51841
  return {
50881
51842
  event: {
@@ -51107,6 +52068,236 @@ function modeSuccessResponse(header, endpointId, instance, mode) {
51107
52068
  }] }
51108
52069
  };
51109
52070
  }
52071
+ var JPEG_QUALITY = 72;
52072
+ var RESIZE_TIMEOUT_MS = 4e3;
52073
+ /** Re-encode a camera frame for Alexa. Rejects (never falls back to the
52074
+ * original) on failure or timeout. */
52075
+ async function toAlexaJpeg(source) {
52076
+ const work = (async () => {
52077
+ const meta = await sharp(source).metadata();
52078
+ const width = meta.width;
52079
+ const height = meta.height;
52080
+ if (width === void 0 || height === void 0 || width === 0 || height === 0) throw new Error("snapshot has no readable dimensions");
52081
+ const short = Math.min(width, height);
52082
+ const targetShort = short < 360 ? 360 : Math.min(short, 480);
52083
+ const resize = width <= height ? { width: targetShort } : { height: targetShort };
52084
+ const { data, info } = await sharp(source).rotate().resize(resize).jpeg({
52085
+ quality: JPEG_QUALITY,
52086
+ mozjpeg: false
52087
+ }).toBuffer({ resolveWithObject: true });
52088
+ return {
52089
+ bytes: data,
52090
+ width: info.width,
52091
+ height: info.height
52092
+ };
52093
+ })();
52094
+ let timer;
52095
+ const timeout = new Promise((_, reject) => {
52096
+ timer = setTimeout(() => reject(/* @__PURE__ */ new Error("snapshot re-encode timed out")), RESIZE_TIMEOUT_MS);
52097
+ });
52098
+ try {
52099
+ return await Promise.race([work, timeout]);
52100
+ } finally {
52101
+ if (timer !== void 0) clearTimeout(timer);
52102
+ }
52103
+ }
52104
+ //#endregion
52105
+ //#region src/handlers/snapshot-provider.ts
52106
+ /** Leaves the rest of Amazon's 8 s for the re-encode and the Lambda hop. */
52107
+ var SNAPSHOT_READ_TIMEOUT_MS = 5e3;
52108
+ var SnapshotImageSchema = object({
52109
+ base64: string().min(1),
52110
+ contentType: string()
52111
+ });
52112
+ function snapshotError(header, type, message) {
52113
+ return { event: {
52114
+ header: {
52115
+ namespace: "Alexa.SmartVision.SnapshotProvider",
52116
+ name: "ErrorResponse",
52117
+ messageId: header.messageId,
52118
+ ...header.correlationToken !== void 0 ? { correlationToken: header.correlationToken } : {},
52119
+ payloadVersion: "1.1"
52120
+ },
52121
+ payload: {
52122
+ type,
52123
+ message
52124
+ }
52125
+ } };
52126
+ }
52127
+ async function readFrame(ctx, deviceId) {
52128
+ let timer;
52129
+ const timeout = new Promise((resolve) => {
52130
+ timer = setTimeout(() => resolve(null), SNAPSHOT_READ_TIMEOUT_MS);
52131
+ });
52132
+ try {
52133
+ const raw = await Promise.race([ctx.api.snapshot.getSnapshot.query({ deviceId }), timeout]);
52134
+ const parsed = SnapshotImageSchema.safeParse(raw);
52135
+ return parsed.success ? Buffer.from(parsed.data.base64, "base64") : null;
52136
+ } finally {
52137
+ if (timer !== void 0) clearTimeout(timer);
52138
+ }
52139
+ }
52140
+ async function handleGetSnapshot(header, endpoint, deps, caller) {
52141
+ if (endpoint === void 0) return errorEnvelope(header, "INVALID_DIRECTIVE", "missing endpoint");
52142
+ if (!deps.enabled()) return snapshotError(header, "DISABLED_BY_USER", "snapshots are switched off on this hub");
52143
+ const parsed = parseCameraEndpointId(endpoint.endpointId);
52144
+ if (parsed.kind !== "camera") return errorEnvelope(header, "INVALID_DIRECTIVE", "snapshots are served for camera endpoints");
52145
+ const deviceId = parsed.deviceId;
52146
+ const base = deps.publicHubUrl().replace(/\/+$/, "");
52147
+ if (!base.startsWith("https://")) {
52148
+ deps.logger.warn("alexa snapshot: no public HTTPS address — Alexa cannot fetch an image", { tags: { deviceId } });
52149
+ return errorEnvelope(header, "INTERNAL_ERROR", "the hub has no public HTTPS address");
52150
+ }
52151
+ const started = deps.now?.() ?? Date.now();
52152
+ const frame = await readFrame(deps.ctx, deviceId).catch((err) => {
52153
+ deps.logger.warn("alexa snapshot: snapshot read failed", {
52154
+ tags: { deviceId },
52155
+ meta: { error: err instanceof Error ? err.message : String(err) }
52156
+ });
52157
+ return null;
52158
+ });
52159
+ if (frame === null) return errorEnvelope(header, "ENDPOINT_UNREACHABLE", `no snapshot from device ${deviceId}`);
52160
+ let jpeg;
52161
+ try {
52162
+ jpeg = await (deps.encode ?? toAlexaJpeg)(frame);
52163
+ } catch (err) {
52164
+ deps.logger.warn("alexa snapshot: re-encode failed — not sending the original frame", {
52165
+ tags: { deviceId },
52166
+ meta: { error: err instanceof Error ? err.message : String(err) }
52167
+ });
52168
+ return errorEnvelope(header, "INTERNAL_ERROR", "snapshot could not be prepared");
52169
+ }
52170
+ const now = deps.now?.() ?? Date.now();
52171
+ const minted = deps.store.mint({
52172
+ deviceId,
52173
+ userId: caller.userId,
52174
+ bytes: jpeg.bytes,
52175
+ capturedAt: now
52176
+ });
52177
+ deps.logger.info("alexa snapshot: served", {
52178
+ tags: { deviceId },
52179
+ meta: {
52180
+ userId: caller.userId,
52181
+ bytes: jpeg.bytes.length,
52182
+ width: jpeg.width,
52183
+ height: jpeg.height,
52184
+ tookMs: now - started
52185
+ }
52186
+ });
52187
+ return { event: {
52188
+ header: {
52189
+ namespace: "Alexa.SmartVision.SnapshotProvider",
52190
+ name: "Snapshot",
52191
+ messageId: header.messageId,
52192
+ ...header.correlationToken !== void 0 ? { correlationToken: header.correlationToken } : {},
52193
+ payloadVersion: "1.1"
52194
+ },
52195
+ endpoint: { endpointId: endpoint.endpointId },
52196
+ payload: {
52197
+ value: {
52198
+ uri: `${base}${minted.path}`,
52199
+ uriExpirationTime: new Date(minted.expiresAt).toISOString(),
52200
+ authenticationType: "ACCESS_TOKEN"
52201
+ },
52202
+ timeOfSample: new Date(now).toISOString(),
52203
+ uncertaintyInMilliseconds: 1e4
52204
+ }
52205
+ } };
52206
+ }
52207
+ //#endregion
52208
+ //#region src/handlers/generic-state.ts
52209
+ /**
52210
+ * A generic device's Alexa properties from its cap slices (R5, D775) — ONE
52211
+ * mapping shared by `Alexa/ReportState` (reading each cap's status) and the
52212
+ * proactive reports (`DeviceStateChanged` slices), so the two can never answer
52213
+ * differently for the same state.
52214
+ */
52215
+ /** The caps a generic endpoint reads, in the order ReportState asks them. */
52216
+ var GENERIC_STATE_CAPS = [
52217
+ "lock-control",
52218
+ "contact",
52219
+ "motion",
52220
+ "presence",
52221
+ "temperature-sensor",
52222
+ "humidity-sensor",
52223
+ "switch",
52224
+ "brightness"
52225
+ ];
52226
+ var detection = (detected) => ({
52227
+ namespace: "",
52228
+ name: "detectionState",
52229
+ value: detected ? "DETECTED" : "NOT_DETECTED"
52230
+ });
52231
+ /** The Alexa property one cap slice sets, or null (unknown cap, bad slice). */
52232
+ function genericPropertyFor(capName, slice) {
52233
+ switch (capName) {
52234
+ case "lock-control": {
52235
+ const p = LockControlStatusSchema.pick({ state: true }).safeParse(slice);
52236
+ return p.success ? {
52237
+ namespace: "Alexa.LockController",
52238
+ name: "lockState",
52239
+ value: alexaLockStateFor(p.data.state)
52240
+ } : null;
52241
+ }
52242
+ case "contact": {
52243
+ const p = ContactStatusSchema.pick({ entryOpen: true }).safeParse(slice);
52244
+ return p.success ? {
52245
+ ...detection(p.data.entryOpen),
52246
+ namespace: "Alexa.ContactSensor"
52247
+ } : null;
52248
+ }
52249
+ case "motion": {
52250
+ const p = MotionStatusSchema.pick({ detected: true }).safeParse(slice);
52251
+ return p.success ? {
52252
+ ...detection(p.data.detected),
52253
+ namespace: "Alexa.MotionSensor"
52254
+ } : null;
52255
+ }
52256
+ case "presence": {
52257
+ const p = PresenceStatusSchema.pick({ state: true }).safeParse(slice);
52258
+ return p.success ? {
52259
+ ...detection(p.data.state === "home"),
52260
+ namespace: "Alexa.MotionSensor"
52261
+ } : null;
52262
+ }
52263
+ case "temperature-sensor": {
52264
+ const p = TemperatureSensorStatusSchema.pick({ celsius: true }).safeParse(slice);
52265
+ return p.success ? {
52266
+ namespace: "Alexa.TemperatureSensor",
52267
+ name: "temperature",
52268
+ value: {
52269
+ value: Math.round(p.data.celsius * 10) / 10,
52270
+ scale: "CELSIUS"
52271
+ }
52272
+ } : null;
52273
+ }
52274
+ case "humidity-sensor": {
52275
+ const p = HumiditySensorStatusSchema.pick({ percent: true }).safeParse(slice);
52276
+ return p.success ? {
52277
+ namespace: "Alexa.HumiditySensor",
52278
+ name: "relativeHumidity",
52279
+ value: { value: Math.max(0, Math.min(100, p.data.percent)) }
52280
+ } : null;
52281
+ }
52282
+ case "switch": {
52283
+ const p = SwitchStatusSchema.pick({ on: true }).safeParse(slice);
52284
+ return p.success ? {
52285
+ namespace: "Alexa.PowerController",
52286
+ name: "powerState",
52287
+ value: p.data.on ? "ON" : "OFF"
52288
+ } : null;
52289
+ }
52290
+ case "brightness": {
52291
+ const p = BrightnessStatusSchema.pick({ percentage: true }).safeParse(slice);
52292
+ return p.success ? {
52293
+ namespace: "Alexa.BrightnessController",
52294
+ name: "brightness",
52295
+ value: Math.max(0, Math.min(100, Math.round(p.data.percentage)))
52296
+ } : null;
52297
+ }
52298
+ default: return null;
52299
+ }
52300
+ }
51110
52301
  //#endregion
51111
52302
  //#region src/handlers/report-state.ts
51112
52303
  async function handleReportState(header, endpointRaw, deps, _callerIdentity) {
@@ -51123,6 +52314,23 @@ async function handleReportState(header, endpointRaw, deps, _callerIdentity) {
51123
52314
  else if (parsed.kind === "autotrack") await collectAutotrackProperties(api, parsed.parentDeviceId, properties, nowIso, deps.logger);
51124
52315
  else if (parsed.kind === "child") await collectChildProperties(api, parsed.childDeviceId, properties, nowIso, deps.logger);
51125
52316
  else if (parsed.kind === "alarm-panel") await collectAlarmPanelProperties(api, parsed.deviceId, properties, nowIso, deps.logger);
52317
+ else if (parsed.kind === "generic") await collectGenericProperties(api, parsed.deviceId, properties, nowIso, deps.logger);
52318
+ else if (parsed.kind === "detection") {
52319
+ properties.push({
52320
+ namespace: "Alexa.MotionSensor",
52321
+ name: "detectionState",
52322
+ value: deps.detectionPresent?.(parsed.deviceId, parsed.detectionClass) ? "DETECTED" : "NOT_DETECTED",
52323
+ timeOfSample: nowIso,
52324
+ uncertaintyInMilliseconds: 0
52325
+ });
52326
+ properties.push({
52327
+ namespace: "Alexa.EndpointHealth",
52328
+ name: "connectivity",
52329
+ value: { value: await readConnectivity(api, parsed.deviceId, deps.logger) },
52330
+ timeOfSample: nowIso,
52331
+ uncertaintyInMilliseconds: 0
52332
+ });
52333
+ }
51126
52334
  } catch (err) {
51127
52335
  deps.logger.warn("alexa report-state: collection failed", { meta: {
51128
52336
  endpointId,
@@ -51144,6 +52352,39 @@ async function handleReportState(header, endpointRaw, deps, _callerIdentity) {
51144
52352
  context: { properties }
51145
52353
  };
51146
52354
  }
52355
+ /**
52356
+ * A generic device (R5): every cap it has, read through its status, mapped by
52357
+ * the same function the proactive reports use. A cap the device does not have
52358
+ * fails its read and contributes nothing; connectivity is always answered.
52359
+ */
52360
+ async function collectGenericProperties(api, deviceId, properties, timeOfSample, logger) {
52361
+ const reads = await Promise.all(GENERIC_STATE_CAPS.map(async (capName) => {
52362
+ const status = await api.deviceState.getCapSlice.query({
52363
+ deviceId,
52364
+ capName
52365
+ }).catch(() => null);
52366
+ return status === null ? null : genericPropertyFor(capName, status);
52367
+ }));
52368
+ const seen = /* @__PURE__ */ new Set();
52369
+ for (const p of reads) {
52370
+ if (p === null) continue;
52371
+ const key = `${p.namespace}.${p.name}`;
52372
+ if (seen.has(key)) continue;
52373
+ seen.add(key);
52374
+ properties.push({
52375
+ ...p,
52376
+ timeOfSample,
52377
+ uncertaintyInMilliseconds: 0
52378
+ });
52379
+ }
52380
+ properties.push({
52381
+ namespace: "Alexa.EndpointHealth",
52382
+ name: "connectivity",
52383
+ value: { value: await readConnectivity(api, deviceId, logger) },
52384
+ timeOfSample,
52385
+ uncertaintyInMilliseconds: 0
52386
+ });
52387
+ }
51147
52388
  function numberField(value, key) {
51148
52389
  if (typeof value !== "object" || value === null) return null;
51149
52390
  const raw = value[key];
@@ -51155,17 +52396,10 @@ function boolField(value, key) {
51155
52396
  return typeof raw === "boolean" ? raw : null;
51156
52397
  }
51157
52398
  async function collectCameraProperties(api, deviceId, properties, timeOfSample, logger) {
51158
- const percentage = numberField(await api.battery.getStatus.query({ deviceId }).catch((err) => {
51159
- logger.info("alexa report-state: battery.getStatus skipped", { meta: {
51160
- deviceId,
51161
- error: err instanceof Error ? err.message : String(err)
51162
- } });
51163
- return null;
51164
- }), "percentage");
51165
- if (percentage !== null) properties.push({
51166
- namespace: "Alexa.BatteryLevelSensor",
51167
- name: "batteryLevel",
51168
- value: Math.max(0, Math.min(100, Math.round(percentage))),
52399
+ properties.push({
52400
+ namespace: "Alexa.EndpointHealth",
52401
+ name: "connectivity",
52402
+ value: { value: await readConnectivity(api, deviceId, logger) },
51169
52403
  timeOfSample,
51170
52404
  uncertaintyInMilliseconds: 0
51171
52405
  });
@@ -51552,6 +52786,35 @@ function requireDeviceScope(scopes, access) {
51552
52786
  if (!scopesAllowDeviceCap(scopes, access)) throw new Error(`directive token not authorized for device ${access}`);
51553
52787
  }
51554
52788
  /**
52789
+ * Directives that are never refused by the access gate. `SessionDisconnected`
52790
+ * only tears down a session that already exists (and releases its transcode);
52791
+ * refusing it because the user lost access in the meantime would leak the
52792
+ * egress the reaper then has to find.
52793
+ */
52794
+ var ACCESS_EXEMPT = new Set(["Alexa.RTCSessionController/SessionDisconnected"]);
52795
+ /**
52796
+ * Directives that need the device itself to answer. On a device the registry
52797
+ * reports OFFLINE they are refused with `ENDPOINT_UNREACHABLE` at once, rather
52798
+ * than spending Alexa's few-second budget on a transcode or a command that
52799
+ * cannot land (runbook C.2-b). `Alexa/ReportState` is NOT here: an offline
52800
+ * endpoint still answers, with `connectivity: UNREACHABLE` and the last known
52801
+ * values (alexa-endpointhealth). A status read that fails does not refuse.
52802
+ */
52803
+ var NEEDS_REACHABLE_DEVICE = new Set([
52804
+ "Alexa.CameraStreamController/InitializeCameraStreams",
52805
+ "Alexa.RTCSessionController/InitiateSessionWithOffer",
52806
+ "Alexa.PowerController/TurnOn",
52807
+ "Alexa.PowerController/TurnOff",
52808
+ "Alexa.BrightnessController/SetBrightness",
52809
+ "Alexa.ModeController/SetMode",
52810
+ "Alexa.ToggleController/TurnOn",
52811
+ "Alexa.ToggleController/TurnOff",
52812
+ "Alexa.SecurityPanelController/Arm",
52813
+ "Alexa.SecurityPanelController/Disarm",
52814
+ "Alexa.LockController/Lock",
52815
+ "Alexa.LockController/Unlock"
52816
+ ]);
52817
+ /**
51555
52818
  * Give an `Alexa.ErrorResponse` the directive's endpoint when it lacks one.
51556
52819
  * Amazon requires `endpoint` on every ErrorResponse to an endpoint directive
51557
52820
  * (alexa-errorresponse); the handlers build their errors from the header
@@ -51573,6 +52836,18 @@ function withErrorEndpoint(body, endpoint) {
51573
52836
  }
51574
52837
  };
51575
52838
  }
52839
+ /** The device whose online state decides whether an endpoint is reachable:
52840
+ * the device the directive actuates (an accessory for a child endpoint). */
52841
+ function reachabilityDeviceOf(endpointId) {
52842
+ const target = endpointAccessTarget(parseCameraEndpointId(endpointId));
52843
+ return target === null ? null : target.deviceId;
52844
+ }
52845
+ /** Whether the principal may CONTROL the device an endpoint addresses — the
52846
+ * talk-back gate (D771). */
52847
+ async function mayControlEndpoint(endpointId, principal, access) {
52848
+ if (endpointId === void 0 || principal === null) return false;
52849
+ return (await authorizeEndpoint(endpointId, principal, access, "create")).allowed;
52850
+ }
51576
52851
  async function dispatchDirective(rawBody, deps, callerIdentity) {
51577
52852
  const result = await dispatchParsedDirective(rawBody, deps, callerIdentity);
51578
52853
  const endpoint = rawBody?.directive?.endpoint;
@@ -51613,7 +52888,10 @@ async function dispatchParsedDirective(rawBody, deps, callerIdentity) {
51613
52888
  "Alexa.ToggleController/TurnOff": "create",
51614
52889
  "Alexa/ReportState": "view",
51615
52890
  "Alexa.SecurityPanelController/Arm": "create",
51616
- "Alexa.SecurityPanelController/Disarm": "create"
52891
+ "Alexa.SecurityPanelController/Disarm": "create",
52892
+ "Alexa.SmartVision.SnapshotProvider/GetSnapshot": "view",
52893
+ "Alexa.LockController/Lock": "create",
52894
+ "Alexa.LockController/Unlock": "create"
51617
52895
  };
51618
52896
  try {
51619
52897
  const key = `${header.namespace}/${header.name}`;
@@ -51626,17 +52904,95 @@ async function dispatchParsedDirective(rawBody, deps, callerIdentity) {
51626
52904
  body: errorEnvelope(header, "INVALID_AUTHORIZATION_CREDENTIAL", scopeErr instanceof Error ? scopeErr.message : String(scopeErr))
51627
52905
  };
51628
52906
  }
51629
- if (key === "Alexa.Discovery/Discover") return {
51630
- status: 200,
51631
- body: await handleDiscovery(header, payload, deps.discovery, callerIdentity)
51632
- };
52907
+ let principal = null;
52908
+ if (!ACCESS_EXEMPT.has(key)) {
52909
+ const lookup = await deps.access.gate.principal(callerIdentity.userId);
52910
+ if (lookup.kind === "unknown-user") {
52911
+ deps.discovery.logger.warn("alexa directive: refused — the hub user who linked this Alexa account no longer exists", { meta: {
52912
+ userId: callerIdentity.userId,
52913
+ directive: key
52914
+ } });
52915
+ return {
52916
+ status: 200,
52917
+ body: errorEnvelope(header, "INVALID_AUTHORIZATION_CREDENTIAL", "the hub user behind this account link no longer exists")
52918
+ };
52919
+ }
52920
+ if (lookup.kind === "unavailable") return {
52921
+ status: 200,
52922
+ body: errorEnvelope(header, "INTERNAL_ERROR", lookup.error)
52923
+ };
52924
+ principal = lookup.principal;
52925
+ if (key !== "Alexa.Discovery/Discover") {
52926
+ if (endpoint === void 0) return {
52927
+ status: 200,
52928
+ body: errorEnvelope(header, "INVALID_DIRECTIVE", "missing directive.endpoint")
52929
+ };
52930
+ const verdict = await authorizeEndpoint(endpoint.endpointId, principal, deps.access, requiredAccess);
52931
+ if (!verdict.allowed) {
52932
+ deps.discovery.logger.info("alexa directive: refused by the user access gate", { meta: {
52933
+ userId: callerIdentity.userId,
52934
+ directive: key,
52935
+ endpointId: endpoint.endpointId,
52936
+ reason: describeRefusal(verdict.refusal)
52937
+ } });
52938
+ return {
52939
+ status: 200,
52940
+ body: errorEnvelope(header, alexaErrorTypeFor(verdict.refusal), describeRefusal(verdict.refusal))
52941
+ };
52942
+ }
52943
+ if (NEEDS_REACHABLE_DEVICE.has(key)) {
52944
+ const deviceId = reachabilityDeviceOf(endpoint.endpointId);
52945
+ if (deviceId !== null && await readConnectivity(deps.reportState.ctx.api, deviceId, deps.reportState.logger) === "UNREACHABLE") {
52946
+ deps.reportState.logger.info("alexa directive: refused — the device is offline", {
52947
+ tags: { deviceId },
52948
+ meta: {
52949
+ directive: key,
52950
+ endpointId: endpoint.endpointId
52951
+ }
52952
+ });
52953
+ return {
52954
+ status: 200,
52955
+ body: errorEnvelope(header, "ENDPOINT_UNREACHABLE", `device ${deviceId} is offline`)
52956
+ };
52957
+ }
52958
+ }
52959
+ }
52960
+ }
52961
+ if (key === "Alexa.Discovery/Discover") {
52962
+ if (principal === null) throw new Error("discovery reached without a principal");
52963
+ const who = principal;
52964
+ return {
52965
+ status: 200,
52966
+ body: await handleDiscovery(header, payload, deps.discovery, callerIdentity, {
52967
+ deviceVisible: async (deviceId) => deps.access.isExposed(deviceId) && await deps.access.gate.allows(who, deviceId, "view") === "allowed",
52968
+ filterEndpoints: async (endpoints) => {
52969
+ const filtered = await filterEndpointsForPrincipal(endpoints, who, deps.access);
52970
+ if (filtered.refused.length > 0) deps.discovery.logger.info("alexa discovery: endpoints withheld from this link", { meta: {
52971
+ userId: callerIdentity.userId,
52972
+ withheld: filtered.refused.map((r) => ({
52973
+ endpointId: r.endpointId,
52974
+ reason: describeRefusal(r.refusal)
52975
+ }))
52976
+ } });
52977
+ const valid = withholdInvalidEndpoints(filtered.kept, (message, violations) => {
52978
+ deps.discovery.logger.warn(message, { meta: {
52979
+ userId: callerIdentity.userId,
52980
+ violations
52981
+ } });
52982
+ });
52983
+ deps.afterDiscovery(callerIdentity.userId, valid);
52984
+ return valid;
52985
+ }
52986
+ })
52987
+ };
52988
+ }
51633
52989
  if (key === "Alexa.CameraStreamController/InitializeCameraStreams") return {
51634
52990
  status: 200,
51635
52991
  body: await handleInitializeCameraStreams(header, endpoint, payload, deps.cameraStream, callerIdentity)
51636
52992
  };
51637
52993
  if (key === "Alexa.RTCSessionController/InitiateSessionWithOffer") return {
51638
52994
  status: 200,
51639
- body: await handleInitiateSessionWithOffer(header, endpoint, payload, deps.rtcSession, callerIdentity)
52995
+ body: await handleInitiateSessionWithOffer(header, endpoint, payload, deps.rtcSession, callerIdentity, { mayTalk: await mayControlEndpoint(endpoint?.endpointId, principal, deps.access) })
51640
52996
  };
51641
52997
  if (key === "Alexa.RTCSessionController/SessionConnected") return {
51642
52998
  status: 200,
@@ -51670,6 +53026,14 @@ async function dispatchParsedDirective(rawBody, deps, callerIdentity) {
51670
53026
  status: 200,
51671
53027
  body: await handleDisarm(header, endpoint, payload, deps.securityPanel, callerIdentity)
51672
53028
  };
53029
+ if (key === "Alexa.LockController/Lock" || key === "Alexa.LockController/Unlock") return {
53030
+ status: 200,
53031
+ body: await handleLock(header, endpoint, deps.lockController)
53032
+ };
53033
+ if (key === "Alexa.SmartVision.SnapshotProvider/GetSnapshot") return {
53034
+ status: 200,
53035
+ body: await handleGetSnapshot(header, endpoint, deps.snapshotProvider, callerIdentity)
53036
+ };
51673
53037
  if (key === "Alexa/ReportState") return {
51674
53038
  status: 200,
51675
53039
  body: await handleReportState(header, endpoint, deps.reportState, callerIdentity)
@@ -51903,6 +53267,81 @@ function verifyIngressToken(expected, provided) {
51903
53267
  function readIngressHeader(headers) {
51904
53268
  for (const [key, value] of Object.entries(headers)) if (key.toLowerCase() === "x-camstack-ingress") return value;
51905
53269
  }
53270
+ /** Characters of a secret a mask may keep, so an operator can tell two apart. */
53271
+ var MASK_TAIL = 4;
53272
+ /**
53273
+ * A secret as a READ surface may show it: a fixed run of dots and, for a value
53274
+ * long enough that four characters do not meaningfully shorten a guess, its
53275
+ * last four. Every read of this addon's status goes through this (S2) — the
53276
+ * value itself is handed out only by the admin-only `revealIngressToken`.
53277
+ */
53278
+ function maskSecretTail(secret) {
53279
+ if (secret.length === 0) return "";
53280
+ if (secret.length <= MASK_TAIL * 4) return "••••";
53281
+ return `••••••••${secret.slice(-4)}`;
53282
+ }
53283
+ //#endregion
53284
+ //#region src/snapshots/snapshot-route.ts
53285
+ var IMAGE_FILE = /^([0-9a-f-]{36})\.jpg$/;
53286
+ function bearerOf(headers) {
53287
+ for (const [key, value] of Object.entries(headers)) {
53288
+ if (key.toLowerCase() !== "authorization") continue;
53289
+ return /^Bearer\s+(.+)$/i.exec(value.trim())?.[1] ?? null;
53290
+ }
53291
+ return null;
53292
+ }
53293
+ function refuse(reply, status, error) {
53294
+ reply.code(status).type("application/json").header("cache-control", "no-store").header("content-encoding", "identity");
53295
+ reply.send({ error });
53296
+ }
53297
+ async function handleSnapshotFetch(req, reply, deps) {
53298
+ if (!deps.enabled()) {
53299
+ refuse(reply, 410, "snapshots are switched off");
53300
+ return;
53301
+ }
53302
+ const imageId = IMAGE_FILE.exec(req.params["file"] ?? "")?.[1];
53303
+ if (imageId === void 0) {
53304
+ refuse(reply, 410, "not a snapshot address");
53305
+ return;
53306
+ }
53307
+ const found = deps.store.lookup(imageId, req.query["exp"], req.query["sig"]);
53308
+ if (found.kind === "gone") {
53309
+ deps.logger.debug("alexa snapshot: fetch of a gone image", { meta: {
53310
+ imageId,
53311
+ reason: found.reason
53312
+ } });
53313
+ refuse(reply, 410, found.reason);
53314
+ return;
53315
+ }
53316
+ const image = found.image;
53317
+ const token = bearerOf(req.headers);
53318
+ const verified = token === null ? null : await deps.verifyToken(token).catch(() => null);
53319
+ if (verified === null) {
53320
+ deps.logger.warn("alexa snapshot: refused a fetch without a valid account-link token", {
53321
+ tags: { deviceId: image.deviceId },
53322
+ meta: {
53323
+ imageId,
53324
+ hadToken: token !== null
53325
+ }
53326
+ });
53327
+ refuse(reply, 401, "a valid Alexa account-link token is required");
53328
+ return;
53329
+ }
53330
+ if (verified.userId !== image.userId || !await deps.mayView(verified.userId, image.deviceId)) {
53331
+ 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", {
53332
+ tags: { deviceId: image.deviceId },
53333
+ meta: {
53334
+ imageId,
53335
+ tokenUser: verified.userId,
53336
+ imageUser: image.userId
53337
+ }
53338
+ });
53339
+ refuse(reply, 403, "not allowed");
53340
+ return;
53341
+ }
53342
+ reply.code(200).type(image.contentType).header("cache-control", "private, no-store").header("content-encoding", "identity").header("content-length", String(image.bytes.byteLength));
53343
+ reply.send(image.bytes);
53344
+ }
51906
53345
  //#endregion
51907
53346
  //#region src/http-server.ts
51908
53347
  /**
@@ -51955,6 +53394,13 @@ function buildRoutes(deps) {
51955
53394
  await handleLwaTokensCallback(req, reply, deps.lwaTokensCallbackDeps);
51956
53395
  }
51957
53396
  },
53397
+ {
53398
+ method: "GET",
53399
+ path: "/snapshot/:file",
53400
+ access: "public",
53401
+ description: "Alexa SnapshotProvider image (signed, 10 min, account-link token required)",
53402
+ handler: (req, reply) => handleSnapshotFetch(req, reply, deps.snapshotRoute)
53403
+ },
51958
53404
  {
51959
53405
  method: "POST",
51960
53406
  path: "/admin/redetect-hub-url",
@@ -52137,6 +53583,46 @@ function describeGrant(acc, input) {
52137
53583
  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).`;
52138
53584
  }
52139
53585
  //#endregion
53586
+ //#region src/endpoint-signature.ts
53587
+ /**
53588
+ * One capability's identity as Alexa distinguishes it. A capability whose
53589
+ * properties are proactively reported carries a `!p` mark: Alexa's copy must
53590
+ * know the hub reports them, or the ChangeReports are worth nothing (R3) —
53591
+ * so flipping the flag is a shape change the reconcile re-pushes.
53592
+ */
53593
+ function capabilityKey(capability) {
53594
+ const instance = typeof capability.instance === "string" ? capability.instance : "";
53595
+ const base = instance.length > 0 ? `${capability.interface}#${instance}` : capability.interface;
53596
+ return capability.properties?.proactivelyReported === true ? `${base}!p` : base;
53597
+ }
53598
+ /** Read back the endpoints (and their interfaces) a signature records — what
53599
+ * Alexa was last told about a device. */
53600
+ function signatureEntries(signature) {
53601
+ if (signature.length === 0) return [];
53602
+ return signature.split(";").map((entry) => {
53603
+ const [endpointId = "", , interfaces = ""] = entry.split("|");
53604
+ return {
53605
+ endpointId,
53606
+ interfaces: interfaces.split(",").filter((i) => i.length > 0).map((i) => i.replace(/!p$/, "").replace(/#.*$/, ""))
53607
+ };
53608
+ });
53609
+ }
53610
+ /** Stable, order-independent signature of ONE endpoint's advertised shape. */
53611
+ function endpointKey(endpoint) {
53612
+ const interfaces = endpoint.capabilities.map(capabilityKey).sort().join(",");
53613
+ const categories = [...endpoint.displayCategories].sort().join(",");
53614
+ return `${endpoint.endpointId}|${categories}|${interfaces}`;
53615
+ }
53616
+ /**
53617
+ * Stable signature of the endpoint set built for ONE device. Two builds that
53618
+ * would land identically in Alexa's registry produce the same string; any
53619
+ * added, removed or renamed interface — or a changed display category —
53620
+ * produces a different one.
53621
+ */
53622
+ function endpointsSignature(endpoints) {
53623
+ return endpoints.map(endpointKey).sort().join(";");
53624
+ }
53625
+ //#endregion
52140
53626
  //#region src/motion-clear-hold.ts
52141
53627
  /**
52142
53628
  * Default hold. Matches `RESET_DEBOUNCE_MS` in
@@ -52271,6 +53757,24 @@ var MIGRATION_RETRY_MS = 300 * 1e3;
52271
53757
  /** The interface a camera endpoint must declare for a connectivity
52272
53758
  * ChangeReport to mean anything to the Alexa cloud. */
52273
53759
  var ENDPOINT_HEALTH_INTERFACE = "Alexa.EndpointHealth";
53760
+ /** Whether an exported signature (`endpoint-signature.ts`) names this
53761
+ * endpoint id. */
53762
+ function signatureNamesEndpoint(signature, endpointId) {
53763
+ return signature.split(";").some((entry) => entry.startsWith(`${endpointId}|`));
53764
+ }
53765
+ /** How long after a push the initial connectivity reports go out — the
53766
+ * AddOrUpdateReport is accepted asynchronously, and a ChangeReport for an
53767
+ * endpoint the cloud has not created yet is dropped. */
53768
+ var INITIAL_CONNECTIVITY_DELAY_MS = 5e3;
53769
+ /**
53770
+ * The device whose online state an endpoint's connectivity reports: the
53771
+ * exposed device the endpoint is published under (a camera's own id, a
53772
+ * detection sensor's camera, a generic device's id).
53773
+ */
53774
+ function connectivityDeviceOf(endpointId) {
53775
+ const target = endpointAccessTarget(parseCameraEndpointId(endpointId));
53776
+ return target === null ? null : target.exposedDeviceId;
53777
+ }
52274
53778
  /** The interface an endpoint must advertise for a `DoorbellPress` to survive
52275
53779
  * the Alexa cloud. */
52276
53780
  var DOORBELL_INTERFACE = "Alexa.DoorbellEventSource";
@@ -52301,11 +53805,12 @@ var LwaEventGateway = class {
52301
53805
  deps;
52302
53806
  unsubscribe = null;
52303
53807
  unsubscribeMotion = null;
52304
- unsubscribeBattery = null;
52305
53808
  unsubscribeConnectivity = [];
52306
53809
  /** Last connectivity reported per `userId|endpointId` — a camera that
52307
53810
  * re-announces the same state must not become a ChangeReport stream. */
52308
53811
  lastConnectivity = /* @__PURE__ */ new Map();
53812
+ /** Initial-connectivity reports waiting for the cloud to create endpoints. */
53813
+ pendingInitialReports = /* @__PURE__ */ new Set();
52309
53814
  refreshTimer = null;
52310
53815
  lastSentAt = /* @__PURE__ */ new Map();
52311
53816
  /**
@@ -52319,11 +53824,6 @@ var LwaEventGateway = class {
52319
53824
  /** Per-endpoint count of `ObjectDetection` events dropped because the
52320
53825
  * endpoint does not declare {@link SMART_VISION_INTERFACE}. */
52321
53826
  smartVisionDropped = /* @__PURE__ */ new Map();
52322
- /** Last reported integer battery percentage per `userId|endpointId` —
52323
- * battery providers may push the same level every poll cycle and
52324
- * Alexa Smart Home rate-limits ChangeReports per property; dedupe
52325
- * so only level *changes* (or step-grain ones) hit the gateway. */
52326
- lastBatteryLevel = /* @__PURE__ */ new Map();
52327
53827
  /** Latches the "no grant at all" warning so it's logged ONCE, not on every
52328
53828
  * doorbell/motion event while no account has a push grant. */
52329
53829
  warnedNoGrant = false;
@@ -52349,11 +53849,6 @@ var LwaEventGateway = class {
52349
53849
  this.deps.logger.warn("alexa lwa-gateway: motion handler crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
52350
53850
  });
52351
53851
  });
52352
- this.unsubscribeBattery = this.deps.ctx.eventBus.subscribe({ category: EventCategory.BatteryOnStatusChanged }, (event) => {
52353
- this.onBatteryStatusChanged(event.data).catch((err) => {
52354
- this.deps.logger.warn("alexa lwa-gateway: battery handler crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
52355
- });
52356
- });
52357
53852
  this.unsubscribeConnectivity = [[EventCategory.DeviceOnline, "OK"], [EventCategory.DeviceOffline, "UNREACHABLE"]].map(([category, connectivity]) => this.deps.ctx.eventBus.subscribe({ category }, (event) => {
52358
53853
  this.onConnectivityChanged(event.data, connectivity).catch((err) => {
52359
53854
  this.deps.logger.warn("alexa lwa-gateway: connectivity handler crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
@@ -52371,17 +53866,16 @@ var LwaEventGateway = class {
52371
53866
  this.unsubscribe = null;
52372
53867
  if (this.unsubscribeMotion) this.unsubscribeMotion();
52373
53868
  this.unsubscribeMotion = null;
52374
- if (this.unsubscribeBattery) this.unsubscribeBattery();
52375
- this.unsubscribeBattery = null;
52376
53869
  for (const off of this.unsubscribeConnectivity) off();
52377
53870
  this.unsubscribeConnectivity = [];
52378
53871
  this.lastConnectivity.clear();
53872
+ for (const timer of this.pendingInitialReports) clearTimeout(timer);
53873
+ this.pendingInitialReports.clear();
52379
53874
  if (this.refreshTimer) clearInterval(this.refreshTimer);
52380
53875
  this.refreshTimer = null;
52381
53876
  this.lastSentAt.clear();
52382
53877
  this.motionHold.clear();
52383
53878
  this.smartVisionDropped.clear();
52384
- this.lastBatteryLevel.clear();
52385
53879
  this.warnedNoToken.clear();
52386
53880
  this.warnedLegacyWaiting.clear();
52387
53881
  this.migrationRetryAt.clear();
@@ -52413,6 +53907,7 @@ var LwaEventGateway = class {
52413
53907
  }
52414
53908
  if (!this.hasProactiveCredentials()) return;
52415
53909
  for (const account of linked) {
53910
+ if (!this.mayPush(account.userId, parentDeviceId, "doorbell press")) continue;
52416
53911
  if (!this.findToken(account.userId)) {
52417
53912
  this.deps.logger.info("alexa lwa-gateway: no LWA token for user — skipped", {
52418
53913
  tags: { deviceId: parentDeviceId },
@@ -52437,6 +53932,19 @@ var LwaEventGateway = class {
52437
53932
  });
52438
53933
  }
52439
53934
  }
53935
+ /**
53936
+ * S1: the account's hub user may view `deviceId`. A refusal is logged at
53937
+ * debug — for a scoped user it is the EXPECTED outcome for every camera
53938
+ * outside the grant, many times a minute.
53939
+ */
53940
+ mayPush(userId, deviceId, label) {
53941
+ if (this.deps.accountMaySee(userId, deviceId)) return true;
53942
+ this.deps.logger.debug(`alexa lwa-gateway: ${label} not sent to this account — its hub user may not view the device`, {
53943
+ tags: { deviceId },
53944
+ meta: { userId }
53945
+ });
53946
+ return false;
53947
+ }
52440
53948
  isThrottled(key) {
52441
53949
  const composite = `${key.userId}|${key.endpointId}`;
52442
53950
  const last = this.lastSentAt.get(composite) ?? 0;
@@ -52459,13 +53967,57 @@ var LwaEventGateway = class {
52459
53967
  async onMotionChanged(data) {
52460
53968
  if (!this.deps.listExposed().some((e) => e.deviceId === data.deviceId)) return;
52461
53969
  const endpointId = String(data.deviceId);
53970
+ await this.pushMotionState({
53971
+ deviceId: data.deviceId,
53972
+ endpointId,
53973
+ detected: data.detected,
53974
+ timestamp: data.timestamp,
53975
+ label: "motion",
53976
+ onRisingPosted: (userId) => this.postObjectDetection({
53977
+ userId,
53978
+ deviceId: data.deviceId,
53979
+ endpointId,
53980
+ timestamp: data.timestamp
53981
+ })
53982
+ });
53983
+ }
53984
+ /**
53985
+ * A camera's detection class appeared or cleared (R1, D772): report it on
53986
+ * that class's detection-sensor endpoint, with the same hold as camera
53987
+ * motion (instant rising edge; NOT_DETECTED only after the source went
53988
+ * quiet and at least 30 s after DETECTED, Amazon's MotionSensor rule).
53989
+ *
53990
+ * Posted only to an endpoint Alexa KNOWS: a ChangeReport for an endpoint the
53991
+ * last AddOrUpdateReport did not contain is a 202 the cloud discards (D76),
53992
+ * so the exported signature must name it.
53993
+ */
53994
+ async pushDetectionSensor(opts) {
53995
+ if (!this.deps.listExposed().some((e) => e.deviceId === opts.deviceId)) return;
53996
+ const signature = this.deps.getExportedSignature(opts.deviceId);
53997
+ if (signature === null || !signatureNamesEndpoint(signature, opts.endpointId)) {
53998
+ this.deps.logger.debug("alexa lwa-gateway: detection change not sent — Alexa has not been told about this sensor endpoint", {
53999
+ tags: { deviceId: opts.deviceId },
54000
+ meta: { endpointId: opts.endpointId }
54001
+ });
54002
+ return;
54003
+ }
54004
+ await this.pushMotionState({
54005
+ ...opts,
54006
+ label: "detection"
54007
+ });
54008
+ }
54009
+ /** The per-account MotionSensor ChangeReport loop shared by camera motion
54010
+ * and the detection sensors. The hold is keyed per account AND endpoint. */
54011
+ async pushMotionState(opts) {
54012
+ const { deviceId, endpointId } = opts;
52462
54013
  const linked = this.deps.listLinkedAccounts();
52463
54014
  if (linked.length === 0) return;
52464
54015
  if (!this.hasProactiveCredentials()) return;
52465
54016
  for (const account of linked) {
54017
+ if (!this.mayPush(account.userId, deviceId, opts.label)) continue;
52466
54018
  if (!this.findToken(account.userId)) continue;
52467
54019
  const key = `${account.userId}|${endpointId}`;
52468
- if (!data.detected) {
54020
+ if (!opts.detected) {
52469
54021
  this.motionHold.onCleared(key, () => {
52470
54022
  this.postMotionChange({
52471
54023
  userId: account.userId,
@@ -52473,8 +54025,8 @@ var LwaEventGateway = class {
52473
54025
  detected: false,
52474
54026
  timestamp: Date.now()
52475
54027
  }).catch((err) => {
52476
- this.deps.logger.warn("alexa lwa-gateway: held motion clear failed to post", {
52477
- tags: { deviceId: data.deviceId },
54028
+ this.deps.logger.warn(`alexa lwa-gateway: held ${opts.label} clear failed to post`, {
54029
+ tags: { deviceId },
52478
54030
  meta: {
52479
54031
  endpointId,
52480
54032
  error: err instanceof Error ? err.message : String(err)
@@ -52489,14 +54041,9 @@ var LwaEventGateway = class {
52489
54041
  userId: account.userId,
52490
54042
  endpointId,
52491
54043
  detected: true,
52492
- timestamp: data.timestamp
52493
- });
52494
- await this.postObjectDetection({
52495
- userId: account.userId,
52496
- deviceId: data.deviceId,
52497
- endpointId,
52498
- timestamp: data.timestamp
54044
+ timestamp: opts.timestamp
52499
54045
  });
54046
+ await opts.onRisingPosted?.(account.userId);
52500
54047
  }
52501
54048
  }
52502
54049
  async postMotionChange(opts) {
@@ -52551,59 +54098,113 @@ var LwaEventGateway = class {
52551
54098
  }), `smartvision person`);
52552
54099
  }
52553
54100
  /**
52554
- * Battery slice changed. Push an `Alexa.BatteryLevelSensor`
52555
- * ChangeReport with the integer percentage. Dedupe so a provider that
52556
- * re-emits the same level on every poll doesn't flood the gateway —
52557
- * only round-percentage TRANSITIONS make it out (a typical battery
52558
- * cam reports every few minutes, and Alexa Smart Home rate-limits
52559
- * ChangeReports anyway).
54101
+ * A camera went offline or came back. Amazon requires the `connectivity`
54102
+ * ChangeReport within 3 s of the change (alexa-endpointhealth). Deduped per
54103
+ * account and endpoint, and skipped for an endpoint Alexa is known to hold
54104
+ * WITHOUT `Alexa.EndpointHealth` — the cloud would drop it (D76).
52560
54105
  */
52561
- async onBatteryStatusChanged(data) {
52562
- if (!this.deps.listExposed().some((e) => e.deviceId === data.deviceId)) return;
52563
- const endpointId = String(data.deviceId);
54106
+ async onConnectivityChanged(data, connectivity) {
54107
+ if (typeof data?.deviceId !== "number") return;
54108
+ await this.reportConnectivity(data.deviceId, connectivity);
54109
+ }
54110
+ /**
54111
+ * A device went offline or came back: report `connectivity` on EVERY
54112
+ * endpoint of it that Alexa knows with `Alexa.EndpointHealth` — the camera
54113
+ * and its detection sensors (D770). Fed by the generic `device-status`
54114
+ * slice (R3) and by the providers that also emit DeviceOnline/Offline;
54115
+ * deduplicated per account and endpoint, so the two sources never double-post.
54116
+ */
54117
+ async reportConnectivity(deviceId, connectivity) {
54118
+ if (!this.deps.listExposed().some((e) => e.deviceId === deviceId)) return;
54119
+ const signature = this.deps.getExportedSignature(deviceId);
54120
+ const endpointIds = signature === null ? [String(deviceId)] : signatureEntries(signature).filter((e) => e.interfaces.includes(ENDPOINT_HEALTH_INTERFACE)).map((e) => e.endpointId);
54121
+ if (endpointIds.length === 0) return;
52564
54122
  const linked = this.deps.listLinkedAccounts();
52565
54123
  if (linked.length === 0) return;
52566
54124
  if (!this.hasProactiveCredentials()) return;
52567
- const level = Math.max(0, Math.min(100, Math.round(data.status.percentage)));
52568
54125
  for (const account of linked) {
52569
- if (!this.findToken(account.userId)) continue;
52570
- const key = `${account.userId}|${endpointId}`;
52571
- if (this.lastBatteryLevel.get(key) === level) continue;
52572
- this.lastBatteryLevel.set(key, level);
52573
- await this.postEventBody(account.userId, endpointId, (accessToken) => buildBatteryChangeReportPayload({
52574
- userId: account.userId,
52575
- endpointId,
52576
- batteryLevel: level,
52577
- timestamp: Date.now(),
52578
- accessToken
52579
- }), `battery ${level}%`);
54126
+ if (!this.mayPush(account.userId, deviceId, "connectivity")) continue;
54127
+ for (const endpointId of endpointIds) {
54128
+ const key = `${account.userId}|${endpointId}`;
54129
+ if (this.lastConnectivity.get(key) === connectivity) continue;
54130
+ this.lastConnectivity.set(key, connectivity);
54131
+ await this.postEventBody(account.userId, endpointId, (accessToken) => buildConnectivityChangeReportPayload({
54132
+ endpointId,
54133
+ connectivity,
54134
+ timestamp: Date.now(),
54135
+ accessToken
54136
+ }), `connectivity ${connectivity}`);
54137
+ }
52580
54138
  }
52581
54139
  }
52582
54140
  /**
52583
- * A camera went offline or came back. Amazon requires the `connectivity`
52584
- * ChangeReport within 3 s of the change (alexa-endpointhealth). Deduped per
52585
- * account and endpoint, and skipped for an endpoint Alexa is known to hold
52586
- * WITHOUT `Alexa.EndpointHealth` — the cloud would drop it (D76).
54141
+ * A device state change Alexa should hear about (R3, D776): the alarm
54142
+ * panel, a privacy/autotrack switch, an accessory. Posted only to an
54143
+ * endpoint Alexa knows WITH the interface the properties belong to (D76).
52587
54144
  */
52588
- async onConnectivityChanged(data, connectivity) {
52589
- if (typeof data?.deviceId !== "number") return;
52590
- if (!this.deps.listExposed().some((e) => e.deviceId === data.deviceId)) return;
52591
- const signature = this.deps.getExportedSignature(data.deviceId);
52592
- if (signature !== null && !signature.includes(ENDPOINT_HEALTH_INTERFACE)) return;
54145
+ async postStateChange(report) {
54146
+ if (!this.deps.listExposed().some((e) => e.deviceId === report.exposedDeviceId)) return;
54147
+ const signature = this.deps.getExportedSignature(report.exposedDeviceId);
54148
+ const entry = signature === null ? void 0 : signatureEntries(signature).find((e) => e.endpointId === report.endpointId);
54149
+ if (entry === void 0 || !entry.interfaces.includes(report.interfaceName)) {
54150
+ this.deps.logger.debug("alexa lwa-gateway: state change not sent — Alexa has not been told about this endpoint/interface", {
54151
+ tags: { deviceId: report.exposedDeviceId },
54152
+ meta: {
54153
+ endpointId: report.endpointId,
54154
+ interfaceName: report.interfaceName
54155
+ }
54156
+ });
54157
+ return;
54158
+ }
52593
54159
  const linked = this.deps.listLinkedAccounts();
52594
54160
  if (linked.length === 0) return;
52595
54161
  if (!this.hasProactiveCredentials()) return;
52596
- const endpointId = String(data.deviceId);
52597
54162
  for (const account of linked) {
52598
- const key = `${account.userId}|${endpointId}`;
52599
- if (this.lastConnectivity.get(key) === connectivity) continue;
52600
- this.lastConnectivity.set(key, connectivity);
52601
- await this.postEventBody(account.userId, endpointId, (accessToken) => buildConnectivityChangeReportPayload({
52602
- endpointId,
54163
+ if (!this.mayPush(account.userId, report.exposedDeviceId, "state change")) continue;
54164
+ if (!this.findToken(account.userId)) continue;
54165
+ await this.postEventBody(account.userId, report.endpointId, (accessToken) => buildStateChangeReportPayload({
54166
+ endpointId: report.endpointId,
54167
+ properties: report.properties,
54168
+ timestamp: Date.now(),
54169
+ accessToken
54170
+ }), `state ${report.interfaceName}`);
54171
+ }
54172
+ }
54173
+ /**
54174
+ * After endpoints reach an account (AddOrUpdateReport or a Discover answer),
54175
+ * tell Alexa each one's connectivity, so it never holds an endpoint of
54176
+ * unknown health — the state in which the phone app refused live view
54177
+ * (D770). Delayed so the cloud has created the endpoints the report names.
54178
+ * Only endpoints that DECLARE `Alexa.EndpointHealth` are reported.
54179
+ */
54180
+ scheduleInitialConnectivity(userId, endpoints) {
54181
+ const targets = endpoints.filter((e) => e.capabilities.some((c) => c.interface === ENDPOINT_HEALTH_INTERFACE));
54182
+ if (targets.length === 0) return;
54183
+ const timer = setTimeout(() => {
54184
+ this.reportInitialConnectivity(userId, targets).catch((err) => {
54185
+ this.deps.logger.warn("alexa lwa-gateway: initial connectivity report failed", { meta: {
54186
+ userId,
54187
+ error: err instanceof Error ? err.message : String(err)
54188
+ } });
54189
+ });
54190
+ }, INITIAL_CONNECTIVITY_DELAY_MS);
54191
+ timer.unref?.();
54192
+ this.pendingInitialReports.add(timer);
54193
+ }
54194
+ async reportInitialConnectivity(userId, endpoints) {
54195
+ if (!this.hasProactiveCredentials()) return;
54196
+ for (const endpoint of endpoints) {
54197
+ const deviceId = connectivityDeviceOf(endpoint.endpointId);
54198
+ if (deviceId === null) continue;
54199
+ if (!this.mayPush(userId, deviceId, "initial connectivity")) continue;
54200
+ const connectivity = await readConnectivity(this.deps.ctx.api, deviceId, this.deps.logger);
54201
+ this.lastConnectivity.set(`${userId}|${endpoint.endpointId}`, connectivity);
54202
+ await this.postEventBody(userId, endpoint.endpointId, (accessToken) => buildConnectivityChangeReportPayload({
54203
+ endpointId: endpoint.endpointId,
52603
54204
  connectivity,
52604
54205
  timestamp: Date.now(),
52605
54206
  accessToken
52606
- }), `connectivity ${connectivity}`);
54207
+ }), `initial connectivity ${connectivity}`);
52607
54208
  }
52608
54209
  }
52609
54210
  /**
@@ -52662,14 +54263,25 @@ var LwaEventGateway = class {
52662
54263
  * with a fresh header would ship a token Amazon has already rejected. */
52663
54264
  async postEventBody(userId, endpointId, buildBody, label) {
52664
54265
  const url = EVENT_GATEWAY_URLS[this.deps.getRegion()];
52665
- const send = async (accessToken) => fetch(url, {
52666
- method: "POST",
52667
- headers: {
52668
- "Content-Type": "application/json",
52669
- Authorization: `Bearer ${accessToken}`
52670
- },
52671
- body: JSON.stringify(buildBody(accessToken))
52672
- });
54266
+ const send = async (accessToken) => {
54267
+ const body = JSON.stringify(buildBody(accessToken));
54268
+ return sendWithGatewayRetry(() => fetch(url, {
54269
+ method: "POST",
54270
+ headers: {
54271
+ "Content-Type": "application/json",
54272
+ Authorization: `Bearer ${accessToken}`
54273
+ },
54274
+ body
54275
+ }), (attempt, reason, delayMs) => {
54276
+ this.deps.logger.info(`alexa lwa-gateway: ${label} retrying`, { meta: {
54277
+ userId,
54278
+ endpointId,
54279
+ attempt,
54280
+ reason,
54281
+ delayMs
54282
+ } });
54283
+ });
54284
+ };
52673
54285
  const initial = this.findToken(userId);
52674
54286
  if (!initial) {
52675
54287
  if (!this.warnedNoToken.has(userId)) {
@@ -52844,12 +54456,65 @@ var LwaEventGateway = class {
52844
54456
  return false;
52845
54457
  }
52846
54458
  };
54459
+ /**
54460
+ * Statuses Amazon tells a sender to retry (smarthome/send-events: 429
54461
+ * THROTTLING, 500 INTERNAL_SERVICE, 503 SERVICE_UNAVAILABLE — "resend the
54462
+ * message up to three times, with at least a one-second interval").
54463
+ */
54464
+ var RETRYABLE_GATEWAY_STATUSES = new Set([
54465
+ 429,
54466
+ 500,
54467
+ 503
54468
+ ]);
54469
+ /** Delays before retry 1, 2, 3 — bounded, at least one second each. */
54470
+ var GATEWAY_RETRY_DELAYS_MS = [
54471
+ 1e3,
54472
+ 2e3,
54473
+ 4e3
54474
+ ];
54475
+ /** A `Retry-After` the gateway sends is honoured up to this much. */
54476
+ var MAX_RETRY_AFTER_MS = 1e4;
54477
+ function retryAfterMs(response) {
54478
+ const raw = response.headers?.get?.("retry-after");
54479
+ if (raw === null || raw === void 0) return 0;
54480
+ const seconds = Number(raw);
54481
+ return Number.isFinite(seconds) && seconds > 0 ? Math.min(seconds * 1e3, MAX_RETRY_AFTER_MS) : 0;
54482
+ }
54483
+ /**
54484
+ * POST with Amazon's retry rule: a retryable status or a network failure is
54485
+ * re-sent up to three times, waiting 1 s / 2 s / 4 s (or the gateway's
54486
+ * `Retry-After`, capped). Anything else — 2xx, 401 (renewal is the caller's),
54487
+ * 403, 400 — is returned at once. The last answer (or the last network error)
54488
+ * is what the caller sees.
54489
+ */
54490
+ async function sendWithGatewayRetry(attemptOnce, onRetry) {
54491
+ for (let attempt = 0;; attempt++) {
54492
+ let response = null;
54493
+ let failure = null;
54494
+ try {
54495
+ response = await attemptOnce();
54496
+ } catch (err) {
54497
+ failure = err;
54498
+ }
54499
+ const retryable = response === null || RETRYABLE_GATEWAY_STATUSES.has(response.status);
54500
+ const delay = GATEWAY_RETRY_DELAYS_MS[attempt];
54501
+ if (!retryable || delay === void 0) {
54502
+ if (response !== null) return response;
54503
+ throw failure;
54504
+ }
54505
+ const wait = response === null ? delay : Math.max(delay, retryAfterMs(response));
54506
+ onRetry(attempt + 1, response === null ? `network: ${failure instanceof Error ? failure.message : String(failure)}` : `status ${response.status}`, wait);
54507
+ await new Promise((resolve) => {
54508
+ setTimeout(resolve, wait).unref?.();
54509
+ });
54510
+ }
54511
+ }
52847
54512
  function buildDoorbellPressPayload(opts) {
52848
54513
  return { event: {
52849
54514
  header: {
52850
54515
  namespace: "Alexa.DoorbellEventSource",
52851
54516
  name: "DoorbellPress",
52852
- messageId: `doorbell-${opts.userId}-${opts.timestamp}`,
54517
+ messageId: randomUUID(),
52853
54518
  payloadVersion: "3"
52854
54519
  },
52855
54520
  endpoint: {
@@ -52872,7 +54537,7 @@ function buildMotionChangeReportPayload(opts) {
52872
54537
  header: {
52873
54538
  namespace: "Alexa",
52874
54539
  name: "ChangeReport",
52875
- messageId: `motion-${opts.userId}-${opts.timestamp}`,
54540
+ messageId: randomUUID(),
52876
54541
  payloadVersion: "3"
52877
54542
  },
52878
54543
  endpoint: {
@@ -52896,14 +54561,14 @@ function buildMotionChangeReportPayload(opts) {
52896
54561
  context: { properties: [] }
52897
54562
  };
52898
54563
  }
52899
- function buildBatteryChangeReportPayload(opts) {
54564
+ function buildStateChangeReportPayload(opts) {
52900
54565
  const timeOfSample = new Date(opts.timestamp).toISOString();
52901
54566
  return {
52902
54567
  event: {
52903
54568
  header: {
52904
54569
  namespace: "Alexa",
52905
54570
  name: "ChangeReport",
52906
- messageId: `battery-${opts.userId}-${opts.timestamp}`,
54571
+ messageId: randomUUID(),
52907
54572
  payloadVersion: "3"
52908
54573
  },
52909
54574
  endpoint: {
@@ -52914,14 +54579,14 @@ function buildBatteryChangeReportPayload(opts) {
52914
54579
  endpointId: opts.endpointId
52915
54580
  },
52916
54581
  payload: { change: {
52917
- cause: { type: "PERIODIC_POLL" },
52918
- properties: [{
52919
- namespace: "Alexa.BatteryLevelSensor",
52920
- name: "batteryLevel",
52921
- value: opts.batteryLevel,
54582
+ cause: { type: "PHYSICAL_INTERACTION" },
54583
+ properties: opts.properties.map((p) => ({
54584
+ namespace: p.namespace,
54585
+ name: p.name,
54586
+ value: p.value,
52922
54587
  timeOfSample,
52923
54588
  uncertaintyInMilliseconds: 0
52924
- }]
54589
+ }))
52925
54590
  } }
52926
54591
  },
52927
54592
  context: { properties: [] }
@@ -52963,7 +54628,7 @@ function buildSmartVisionObjectDetectionEvent(opts) {
52963
54628
  header: {
52964
54629
  namespace: "Alexa.SmartVision.ObjectDetectionSensor",
52965
54630
  name: "ObjectDetection",
52966
- messageId: `smartvision-${opts.userId}-${opts.timestamp}`,
54631
+ messageId: randomUUID(),
52967
54632
  payloadVersion: "1.0"
52968
54633
  },
52969
54634
  endpoint: {
@@ -52984,12 +54649,12 @@ function buildSmartVisionObjectDetectionEvent(opts) {
52984
54649
  })) }
52985
54650
  } };
52986
54651
  }
52987
- function buildAddOrUpdateReportPayload(userId, endpoints, accessToken) {
54652
+ function buildAddOrUpdateReportPayload(_userId, endpoints, accessToken) {
52988
54653
  return { event: {
52989
54654
  header: {
52990
54655
  namespace: "Alexa.Discovery",
52991
54656
  name: "AddOrUpdateReport",
52992
- messageId: `discovery-addupdate-${userId}-${Date.now()}`,
54657
+ messageId: randomUUID(),
52993
54658
  payloadVersion: "3"
52994
54659
  },
52995
54660
  payload: {
@@ -53001,12 +54666,12 @@ function buildAddOrUpdateReportPayload(userId, endpoints, accessToken) {
53001
54666
  }
53002
54667
  } };
53003
54668
  }
53004
- function buildDeleteReportPayload(userId, endpointIds, accessToken) {
54669
+ function buildDeleteReportPayload(_userId, endpointIds, accessToken) {
53005
54670
  return { event: {
53006
54671
  header: {
53007
54672
  namespace: "Alexa.Discovery",
53008
54673
  name: "DeleteReport",
53009
- messageId: `discovery-delete-${userId}-${Date.now()}`,
54674
+ messageId: randomUUID(),
53010
54675
  payloadVersion: "3"
53011
54676
  },
53012
54677
  payload: {
@@ -53019,6 +54684,425 @@ function buildDeleteReportPayload(userId, endpointIds, accessToken) {
53019
54684
  } };
53020
54685
  }
53021
54686
  //#endregion
54687
+ //#region src/access/account-visibility.ts
54688
+ /** Event-path gate. Unknown account ⇒ refused (fail closed). */
54689
+ function visibilityAllows(entry, deviceId) {
54690
+ if (entry === void 0) return false;
54691
+ if (entry.all === true) return true;
54692
+ return entry.view.includes(deviceId);
54693
+ }
54694
+ function visibleSet(entry, exposed) {
54695
+ if (entry === void 0 || entry.all === true) return new Set(exposed);
54696
+ return new Set(entry.view);
54697
+ }
54698
+ var AccountVisibilityMirror = class {
54699
+ deps;
54700
+ flight = null;
54701
+ constructor(deps) {
54702
+ this.deps = deps;
54703
+ }
54704
+ /** Synchronous event-path read. */
54705
+ allows(userId, deviceId) {
54706
+ return visibilityAllows(this.deps.getStored()[userId], deviceId);
54707
+ }
54708
+ /** Recompute every linked account. One pass at a time; a caller joins the
54709
+ * pass in flight. Returns the changes the caller must apply to Alexa. */
54710
+ async refresh() {
54711
+ if (this.flight === null) this.flight = this.refreshNow().finally(() => {
54712
+ this.flight = null;
54713
+ });
54714
+ return this.flight;
54715
+ }
54716
+ async refreshNow() {
54717
+ const exposed = this.deps.listExposedDeviceIds();
54718
+ const stored = this.deps.getStored();
54719
+ const next = {};
54720
+ const changes = [];
54721
+ for (const userId of this.deps.listLinkedUserIds()) {
54722
+ const prev = stored[userId];
54723
+ const computed = await this.compute(userId, prev, exposed);
54724
+ if (computed === null) {
54725
+ if (prev !== void 0) next[userId] = prev;
54726
+ continue;
54727
+ }
54728
+ next[userId] = computed.entry;
54729
+ if (computed.change.added.length > 0 || computed.change.removed.length > 0) changes.push(computed.change);
54730
+ }
54731
+ await this.deps.setStored(next);
54732
+ return changes;
54733
+ }
54734
+ async compute(userId, prev, exposed) {
54735
+ const lookup = await this.deps.gate.principal(userId);
54736
+ if (lookup.kind === "unavailable") return null;
54737
+ const before = visibleSet(prev, exposed);
54738
+ const prevPending = new Set(prev !== void 0 && prev.all !== true ? prev.pendingRemoval ?? [] : []);
54739
+ if (lookup.kind === "found" && lookup.principal.kind === "admin") return {
54740
+ entry: { all: true },
54741
+ change: {
54742
+ userId,
54743
+ added: prev === void 0 ? [] : exposed.filter((id) => !before.has(id)),
54744
+ removed: []
54745
+ }
54746
+ };
54747
+ const view = [];
54748
+ for (const deviceId of exposed) {
54749
+ if (lookup.kind === "unknown-user") continue;
54750
+ const verdict = await this.deps.gate.allows(lookup.principal, deviceId, "view");
54751
+ if (verdict === "allowed" || verdict === "unknown" && before.has(deviceId)) view.push(deviceId);
54752
+ }
54753
+ const viewSet = new Set(view);
54754
+ const added = prev === void 0 ? [] : view.filter((id) => !before.has(id));
54755
+ const lostNow = exposed.filter((id) => before.has(id) && !viewSet.has(id));
54756
+ const removed = [...prevPending].filter((id) => !viewSet.has(id));
54757
+ const pendingRemoval = lostNow.filter((id) => !prevPending.has(id));
54758
+ 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 } });
54759
+ return {
54760
+ entry: pendingRemoval.length > 0 ? {
54761
+ view,
54762
+ pendingRemoval
54763
+ } : { view },
54764
+ change: {
54765
+ userId,
54766
+ added,
54767
+ removed
54768
+ }
54769
+ };
54770
+ }
54771
+ };
54772
+ //#endregion
54773
+ //#region src/access/hub-access-source.ts
54774
+ function createHubAccessGate(ctx) {
54775
+ return new AlexaAccessGate({
54776
+ logger: ctx.logger,
54777
+ listUserGrants: async () => {
54778
+ return (await ctx.api.userManagement.listUsers.query()).map((u) => ({
54779
+ userId: u.id,
54780
+ isAdmin: u.isAdmin,
54781
+ scopes: u.scopes
54782
+ }));
54783
+ },
54784
+ getDeviceMeta: async (deviceId) => {
54785
+ const device = await ctx.api.deviceManager.getDevice.query({ deviceId });
54786
+ if (device === null) return null;
54787
+ return {
54788
+ id: device.id,
54789
+ type: String(device.type),
54790
+ location: device.location,
54791
+ parentDeviceId: device.parentDeviceId
54792
+ };
54793
+ }
54794
+ });
54795
+ }
54796
+ //#endregion
54797
+ //#region src/access/visibility-sync.ts
54798
+ async function applyVisibilityChanges(changes, target, logger) {
54799
+ for (const change of changes) {
54800
+ for (const deviceId of change.added) {
54801
+ const ok = await target.pushDeviceToAccount(change.userId, deviceId).catch((err) => {
54802
+ logger.warn("alexa access: pushing a newly granted device failed", {
54803
+ tags: { deviceId },
54804
+ meta: {
54805
+ userId: change.userId,
54806
+ error: errMsg(err)
54807
+ }
54808
+ });
54809
+ return false;
54810
+ });
54811
+ logger.info("alexa access: the linked user gained this device — pushed to their Alexa account", {
54812
+ tags: { deviceId },
54813
+ meta: {
54814
+ userId: change.userId,
54815
+ delivered: ok
54816
+ }
54817
+ });
54818
+ }
54819
+ for (const deviceId of change.removed) {
54820
+ const ok = await target.removeDeviceFromAccount(change.userId, deviceId).catch((err) => {
54821
+ logger.warn("alexa access: deleting a revoked device failed", {
54822
+ tags: { deviceId },
54823
+ meta: {
54824
+ userId: change.userId,
54825
+ error: errMsg(err)
54826
+ }
54827
+ });
54828
+ return false;
54829
+ });
54830
+ logger.warn("alexa access: the linked user lost this device (confirmed twice) — deleted from their Alexa account", {
54831
+ tags: { deviceId },
54832
+ meta: {
54833
+ userId: change.userId,
54834
+ delivered: ok
54835
+ }
54836
+ });
54837
+ }
54838
+ }
54839
+ }
54840
+ //#endregion
54841
+ //#region src/detection-sensors/detection-tracker.ts
54842
+ /**
54843
+ * Which detection classes are currently present on each camera (R1, D772).
54844
+ *
54845
+ * Fed by the pipeline's track lifecycle (`PipelineAnalyticsTrackLifecycle`,
54846
+ * `data.deviceId` — these events carry no device on their `source`) and by
54847
+ * package deliveries. A class is present while at least one of ITS tracks is
54848
+ * open: the set is per `(camera, class)` and per `trackId`, so the end of one
54849
+ * person's track does not clear "Person" while another person is still in
54850
+ * view (the HA export's per-event flip has exactly that bug).
54851
+ *
54852
+ * Events are telemetry and may be dropped (D8), and the pipeline does not
54853
+ * promise an `end` for every `start` (a zero-media false birth or a discarded
54854
+ * stale track ends silently). So {@link reconcile} replaces a camera's sets
54855
+ * with an authoritative read of its ACTIVE tracks; a sensor can therefore never
54856
+ * stay "detected" past one reconcile after its last track closed. A read that
54857
+ * fails is not passed in — the sets then stay as they were.
54858
+ *
54859
+ * Emits a transition per `(camera, class)` only when presence changes; the
54860
+ * Alexa-facing hold (≥ 30 s DETECTED before NOT_DETECTED) is the gateway's.
54861
+ */
54862
+ /** A package delivery is an instant, not a track: it is present this long. */
54863
+ var PACKAGE_PULSE_MS = 1e3;
54864
+ /** The detection class a pipeline class name belongs to, or null. Pipeline
54865
+ * classes are already macros; a raw COCO label is mapped the same way the
54866
+ * executor maps it. */
54867
+ function detectionClassOf(className) {
54868
+ if (className === void 0) return null;
54869
+ const lower = className.toLowerCase();
54870
+ const isClass = (v) => DETECTION_SENSOR_CLASSES.includes(v);
54871
+ if (isClass(lower)) return lower;
54872
+ const mapped = COCO_TO_MACRO.mapping[lower];
54873
+ return mapped !== void 0 && isClass(mapped) ? mapped : null;
54874
+ }
54875
+ var DetectionTracker = class {
54876
+ onTransition;
54877
+ /** `${deviceId}|${class}` → open track ids. */
54878
+ open = /* @__PURE__ */ new Map();
54879
+ pulseTimers = /* @__PURE__ */ new Set();
54880
+ constructor(onTransition) {
54881
+ this.onTransition = onTransition;
54882
+ }
54883
+ onTrackLifecycle(event) {
54884
+ const cls = detectionClassOf(event.bestClassName ?? event.classes?.[0]);
54885
+ if (cls === null) return;
54886
+ if (event.phase === "end") {
54887
+ this.remove(event.deviceId, cls, event.trackId);
54888
+ for (const other of DETECTION_SENSOR_CLASSES) if (other !== cls) this.remove(event.deviceId, other, event.trackId);
54889
+ return;
54890
+ }
54891
+ this.add(event.deviceId, cls, event.trackId);
54892
+ }
54893
+ /** A package was delivered: "Package" is detected, as a pulse. */
54894
+ onPackageDelivered(deviceId, entryId) {
54895
+ const id = `package:${entryId}`;
54896
+ this.add(deviceId, "package", id);
54897
+ const timer = setTimeout(() => {
54898
+ this.pulseTimers.delete(timer);
54899
+ this.remove(deviceId, "package", id);
54900
+ }, PACKAGE_PULSE_MS);
54901
+ timer.unref?.();
54902
+ this.pulseTimers.add(timer);
54903
+ }
54904
+ /** Replace a camera's open tracks with what the pipeline says is ACTIVE. */
54905
+ reconcile(deviceId, active) {
54906
+ const truth = /* @__PURE__ */ new Map();
54907
+ for (const track of active) {
54908
+ const cls = detectionClassOf(track.className);
54909
+ if (cls === null) continue;
54910
+ const set = truth.get(cls) ?? /* @__PURE__ */ new Set();
54911
+ set.add(track.trackId);
54912
+ truth.set(cls, set);
54913
+ }
54914
+ for (const cls of DETECTION_SENSOR_CLASSES) {
54915
+ const key = `${deviceId}|${cls}`;
54916
+ const before = this.open.get(key) ?? /* @__PURE__ */ new Set();
54917
+ const after = truth.get(cls) ?? /* @__PURE__ */ new Set();
54918
+ for (const id of before) if (id.startsWith("package:")) after.add(id);
54919
+ this.replace(deviceId, cls, before, after);
54920
+ }
54921
+ }
54922
+ /** Cameras with at least one class present — the ones worth reconciling. */
54923
+ camerasWithPresence() {
54924
+ const out = /* @__PURE__ */ new Set();
54925
+ for (const [key, set] of this.open) if (set.size > 0) out.add(Number(key.split("|")[0]));
54926
+ return [...out];
54927
+ }
54928
+ isPresent(deviceId, cls) {
54929
+ return (this.open.get(`${deviceId}|${cls}`)?.size ?? 0) > 0;
54930
+ }
54931
+ clear() {
54932
+ this.open.clear();
54933
+ for (const t of this.pulseTimers) clearTimeout(t);
54934
+ this.pulseTimers.clear();
54935
+ }
54936
+ add(deviceId, cls, id) {
54937
+ const key = `${deviceId}|${cls}`;
54938
+ const set = this.open.get(key) ?? /* @__PURE__ */ new Set();
54939
+ const was = set.size > 0;
54940
+ set.add(id);
54941
+ this.open.set(key, set);
54942
+ if (!was) this.onTransition(deviceId, cls, true);
54943
+ }
54944
+ remove(deviceId, cls, id) {
54945
+ const set = this.open.get(`${deviceId}|${cls}`);
54946
+ if (set === void 0 || !set.delete(id)) return;
54947
+ if (set.size === 0) this.onTransition(deviceId, cls, false);
54948
+ }
54949
+ replace(deviceId, cls, before, after) {
54950
+ this.open.set(`${deviceId}|${cls}`, after);
54951
+ if (before.size > 0 && after.size === 0) this.onTransition(deviceId, cls, false);
54952
+ else if (before.size === 0 && after.size > 0) this.onTransition(deviceId, cls, true);
54953
+ }
54954
+ };
54955
+ /** The detection classes an event-kind answer offers. */
54956
+ function detectionClassesOfKinds(kinds) {
54957
+ const out = /* @__PURE__ */ new Set();
54958
+ for (const k of kinds) {
54959
+ if (k.level !== "macro") continue;
54960
+ if (k.category !== "detection" && k.category !== "package") continue;
54961
+ const cls = detectionClassOf(k.kind);
54962
+ if (cls !== null) out.add(cls);
54963
+ }
54964
+ return DETECTION_SENSOR_CLASSES.filter((c) => out.has(c));
54965
+ }
54966
+ function sameClasses(a, b) {
54967
+ return JSON.stringify(a ?? null) === JSON.stringify(b);
54968
+ }
54969
+ var DetectionClassSource = class {
54970
+ deps;
54971
+ /** Serializes the persisted map's read-modify-write: builds run four
54972
+ * cameras at once, and two writes computed off one snapshot would erase
54973
+ * each other's camera. */
54974
+ writeChain = Promise.resolve();
54975
+ constructor(deps) {
54976
+ this.deps = deps;
54977
+ }
54978
+ persist(deviceId, next) {
54979
+ const write = this.writeChain.then(() => this.deps.setStored({
54980
+ ...this.deps.getStored(),
54981
+ [deviceId]: next
54982
+ }));
54983
+ this.writeChain = write.catch(() => void 0);
54984
+ return write;
54985
+ }
54986
+ async classesFor(deviceId) {
54987
+ const prev = this.deps.getStored()[deviceId];
54988
+ let read;
54989
+ try {
54990
+ read = detectionClassesOfKinds(await this.deps.readEventKinds(deviceId));
54991
+ } catch (err) {
54992
+ this.deps.logger.debug("alexa detection sensors: class read failed — keeping the last answer", {
54993
+ tags: { deviceId },
54994
+ meta: { error: errMsg(err) }
54995
+ });
54996
+ return prev?.classes ?? [];
54997
+ }
54998
+ const now = this.deps.now?.() ?? Date.now();
54999
+ const missingSince = {};
55000
+ const keep = new Set(read);
55001
+ const removed = [];
55002
+ for (const cls of prev?.classes ?? []) {
55003
+ if (keep.has(cls)) continue;
55004
+ const since = prev?.missingSince?.[cls] ?? now;
55005
+ if (now - since >= 9e5) {
55006
+ removed.push(cls);
55007
+ continue;
55008
+ }
55009
+ keep.add(cls);
55010
+ missingSince[cls] = since;
55011
+ }
55012
+ const classes = DETECTION_SENSOR_CLASSES.filter((c) => keep.has(c));
55013
+ const next = Object.keys(missingSince).length > 0 ? {
55014
+ classes,
55015
+ missingSince
55016
+ } : { classes };
55017
+ if (!sameClasses(prev, next)) {
55018
+ await this.persist(deviceId, next);
55019
+ if (removed.length > 0 || classes.some((c) => !(prev?.classes ?? []).includes(c))) this.deps.logger.info("alexa detection sensors: the camera's detection classes changed", {
55020
+ tags: { deviceId },
55021
+ meta: {
55022
+ classes,
55023
+ removed
55024
+ }
55025
+ });
55026
+ }
55027
+ return classes;
55028
+ }
55029
+ };
55030
+ //#endregion
55031
+ //#region src/detection-sensors/detection-feed.ts
55032
+ var DETECTION_RECONCILE_INTERVAL_MS = 6e4;
55033
+ var TrackLifecycleSchema = object({
55034
+ deviceId: number(),
55035
+ trackId: string(),
55036
+ phase: _enum([
55037
+ "start",
55038
+ "update",
55039
+ "end"
55040
+ ]),
55041
+ bestClassName: string().optional(),
55042
+ classes: array(string()).optional()
55043
+ });
55044
+ var PackageDeliveredSchema = object({
55045
+ deviceId: number(),
55046
+ entryId: string()
55047
+ });
55048
+ var DetectionSensorFeed = class {
55049
+ deps;
55050
+ tracker;
55051
+ offs = [];
55052
+ timer = null;
55053
+ constructor(deps) {
55054
+ this.deps = deps;
55055
+ this.tracker = new DetectionTracker((deviceId, cls, present) => {
55056
+ this.onTransition(deviceId, cls, present);
55057
+ });
55058
+ }
55059
+ start() {
55060
+ this.offs.push(this.deps.eventBus.subscribe({ category: EventCategory.PipelineAnalyticsTrackLifecycle }, (event) => {
55061
+ const parsed = TrackLifecycleSchema.safeParse(event.data);
55062
+ if (parsed.success) this.tracker.onTrackLifecycle(parsed.data);
55063
+ }), this.deps.eventBus.subscribe({ category: EventCategory.PipelineAnalyticsPackageDelivered }, (event) => {
55064
+ const parsed = PackageDeliveredSchema.safeParse(event.data);
55065
+ if (parsed.success) this.tracker.onPackageDelivered(parsed.data.deviceId, parsed.data.entryId);
55066
+ }));
55067
+ this.timer = setInterval(() => void this.reconcile(), DETECTION_RECONCILE_INTERVAL_MS);
55068
+ this.timer.unref?.();
55069
+ }
55070
+ stop() {
55071
+ for (const off of this.offs.splice(0)) off();
55072
+ if (this.timer !== null) clearInterval(this.timer);
55073
+ this.timer = null;
55074
+ this.tracker.clear();
55075
+ }
55076
+ isPresent(deviceId, cls) {
55077
+ return this.deps.enabled() && this.tracker.isPresent(deviceId, cls);
55078
+ }
55079
+ /** One reconcile pass over every camera that shows a class present. A read
55080
+ * that fails changes nothing for that camera. */
55081
+ async reconcile() {
55082
+ for (const deviceId of this.tracker.camerasWithPresence()) try {
55083
+ this.tracker.reconcile(deviceId, await this.deps.readActiveTracks(deviceId));
55084
+ } catch (err) {
55085
+ this.deps.logger.debug("alexa detection sensors: active-track read failed — kept as is", {
55086
+ tags: { deviceId },
55087
+ meta: { error: errMsg(err) }
55088
+ });
55089
+ }
55090
+ }
55091
+ onTransition(deviceId, cls, present) {
55092
+ if (!this.deps.enabled() || !this.deps.isExposed(deviceId)) return;
55093
+ this.deps.push(deviceId, cls, present, Date.now()).catch((err) => {
55094
+ this.deps.logger.warn("alexa detection sensors: change report failed", {
55095
+ tags: { deviceId },
55096
+ meta: {
55097
+ detectionClass: cls,
55098
+ present,
55099
+ error: errMsg(err)
55100
+ }
55101
+ });
55102
+ });
55103
+ }
55104
+ };
55105
+ //#endregion
53022
55106
  //#region src/oauth-integration.ts
53023
55107
  /**
53024
55108
  * The `export-alexa` OAuth client — the descriptor behind
@@ -53116,6 +55200,418 @@ function buildAlexaOauthIntegration(publicHubUrl) {
53116
55200
  };
53117
55201
  }
53118
55202
  //#endregion
55203
+ //#region src/setup-block.ts
55204
+ function buildAlexaSetupBlock(input) {
55205
+ const base = input.publicHubUrl.replace(/\/+$/, "");
55206
+ return {
55207
+ fields: [
55208
+ ...base ? [{
55209
+ label: "Directive endpoint",
55210
+ value: `${base}/addon/export-alexa/directive`
55211
+ }] : [],
55212
+ ...input.ingressToken ? [{
55213
+ label: "Ingress token (Lambda HUB_INGRESS_TOKEN) — use \"Show ingress token\" in settings",
55214
+ value: maskSecretTail(input.ingressToken)
55215
+ }] : [],
55216
+ {
55217
+ label: "Linked Alexa accounts",
55218
+ value: String(input.linkedAccountCount)
55219
+ }
55220
+ ],
55221
+ 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."
55222
+ };
55223
+ }
55224
+ /**
55225
+ * The ONE derivation of "a signed, expiring URL".
55226
+ *
55227
+ * This repo mints unguessable, self-expiring links in three places now — the
55228
+ * notification artifact plane, the Home Assistant media plane, and the snapshot
55229
+ * link plane. The first two grew independently and are byte-identical logic
55230
+ * (`hmac(secret, "<id>:<exp>")`, expiry checked before a constant-time compare),
55231
+ * each restating the crypto locally because **addons never import each other**.
55232
+ *
55233
+ * That reason is real, and the conclusion drawn from it was wrong. Two copies of
55234
+ * a signing scheme is how one of them quietly ends up with a different TTL, a
55235
+ * different compare, or a missing expiry check, and nothing fails until a link
55236
+ * that should have died keeps working. The fix is the same one D52 applies to
55237
+ * crop geometry: one derivation, in a place every addon may depend on. A
55238
+ * framework package is exactly that place — `@camstack/types/node`, off the root
55239
+ * entry because `node:crypto` must never be traversed by a browser bundler.
55240
+ *
55241
+ * What this module deliberately does NOT decide: the TTL, the base URL, the
55242
+ * shape of `id`, and the access level of the route. Those are per-plane policy
55243
+ * and each caller states them where a reader can see them.
55244
+ */
55245
+ /**
55246
+ * The signature over `(id, expMs)`.
55247
+ *
55248
+ * `id` is whatever the plane uses to name the thing being served — an artifact
55249
+ * id, a track id, a `<deviceId>:<width>` pair. It is joined with `:` so a caller
55250
+ * must not put a `:` inside a field whose boundary matters; where a plane has
55251
+ * more than one field, it composes them itself and owns that ambiguity.
55252
+ */
55253
+ function signExpiringUrl(secret, id, expMs) {
55254
+ return createHmac("sha256", secret).update(`${id}:${String(expMs)}`).digest("hex");
55255
+ }
55256
+ /**
55257
+ * Verify a request's `(id, exp, sig)`.
55258
+ *
55259
+ * **Expiry is checked BEFORE the compare**, so an expired link is refused
55260
+ * whether or not its signature is valid — a leaked URL stops working on its own
55261
+ * and cannot be kept alive by holding a correct signature. The compare itself is
55262
+ * constant-time so a public route cannot be probed for the signature byte by
55263
+ * byte.
55264
+ */
55265
+ function verifyExpiringUrl(input) {
55266
+ const { secret, id, exp, sig, nowMs } = input;
55267
+ if (exp === void 0 || sig === void 0) return false;
55268
+ const expMs = typeof exp === "number" ? exp : Number(exp);
55269
+ if (!Number.isFinite(expMs)) return false;
55270
+ if (expMs <= nowMs) return false;
55271
+ const expected = signExpiringUrl(secret, id, expMs);
55272
+ const a = Buffer.from(expected, "utf8");
55273
+ const b = Buffer.from(sig, "utf8");
55274
+ if (a.length !== b.length) return false;
55275
+ return timingSafeEqual(a, b);
55276
+ }
55277
+ //#endregion
55278
+ //#region src/snapshots/snapshot-store.ts
55279
+ /**
55280
+ * The images this hub has handed to Alexa (R2, D773), each behind a signed,
55281
+ * short-lived URL.
55282
+ *
55283
+ * Amazon's rules for a SnapshotProvider image URI (device-apis/
55284
+ * alexa-smartvision-snapshotprovider): HTTPS, NOT public — Alexa presents the
55285
+ * user's access token — valid about ten minutes, HTTP 410 once expired, no
55286
+ * redirects. So every image is minted for ONE linked user and ONE device, kept
55287
+ * in memory until it expires, and addressed by a random id whose URL carries an
55288
+ * HMAC over `(imageId, exp)`. A hub restart forgets them all; Alexa then gets a
55289
+ * 410 and asks for a new snapshot, which is the documented recovery.
55290
+ *
55291
+ * Bounded by count and by bytes: Alexa refreshes a tile at most every
55292
+ * `minRefreshIntervalInSeconds` per camera, so a few hundred kilobytes per
55293
+ * camera is the steady state; the bound is what keeps a misbehaving client from
55294
+ * turning this into a memory leak.
55295
+ */
55296
+ /** How long a minted image URL is valid — Amazon recommends ten minutes. */
55297
+ var SNAPSHOT_URL_TTL_MS = 10 * 6e4;
55298
+ var MAX_IMAGES = 128;
55299
+ var MAX_TOTAL_BYTES = 24 * 1024 * 1024;
55300
+ var SnapshotStore = class {
55301
+ secret;
55302
+ now;
55303
+ images = /* @__PURE__ */ new Map();
55304
+ totalBytes = 0;
55305
+ constructor(secret, now = Date.now) {
55306
+ this.secret = secret;
55307
+ this.now = now;
55308
+ }
55309
+ /** Keep `bytes` for `userId`/`deviceId` and return the signed path to it. */
55310
+ mint(input) {
55311
+ this.prune();
55312
+ const imageId = randomUUID();
55313
+ const expiresAt = this.now() + SNAPSHOT_URL_TTL_MS;
55314
+ const image = {
55315
+ imageId,
55316
+ deviceId: input.deviceId,
55317
+ userId: input.userId,
55318
+ bytes: input.bytes,
55319
+ contentType: "image/jpeg",
55320
+ capturedAt: input.capturedAt,
55321
+ expiresAt
55322
+ };
55323
+ this.images.set(imageId, image);
55324
+ this.totalBytes += image.bytes.length;
55325
+ this.evictOverBounds();
55326
+ return {
55327
+ imageId,
55328
+ expiresAt,
55329
+ path: `/addon/export-alexa/snapshot/${imageId}.jpg?exp=${expiresAt}&sig=${signExpiringUrl(this.secret(), imageId, expiresAt)}`
55330
+ };
55331
+ }
55332
+ /** Resolve a fetch of a minted URL. */
55333
+ lookup(imageId, exp, sig) {
55334
+ const now = this.now();
55335
+ const expMs = Number(exp);
55336
+ if (Number.isFinite(expMs) && expMs <= now) return {
55337
+ kind: "gone",
55338
+ reason: "expired"
55339
+ };
55340
+ if (!verifyExpiringUrl({
55341
+ secret: this.secret(),
55342
+ id: imageId,
55343
+ exp,
55344
+ sig,
55345
+ nowMs: now
55346
+ })) return {
55347
+ kind: "gone",
55348
+ reason: "bad-signature"
55349
+ };
55350
+ const image = this.images.get(imageId);
55351
+ if (image === void 0 || image.expiresAt <= now) return {
55352
+ kind: "gone",
55353
+ reason: "unknown-image"
55354
+ };
55355
+ return {
55356
+ kind: "found",
55357
+ image
55358
+ };
55359
+ }
55360
+ /** Drop everything — the integration switch was turned off. */
55361
+ clear() {
55362
+ this.images.clear();
55363
+ this.totalBytes = 0;
55364
+ }
55365
+ get size() {
55366
+ return this.images.size;
55367
+ }
55368
+ prune() {
55369
+ const now = this.now();
55370
+ for (const [id, image] of this.images) if (image.expiresAt <= now) this.drop(id, image);
55371
+ }
55372
+ evictOverBounds() {
55373
+ for (const [id, image] of this.images) {
55374
+ if (this.images.size <= MAX_IMAGES && this.totalBytes <= MAX_TOTAL_BYTES) return;
55375
+ this.drop(id, image);
55376
+ }
55377
+ }
55378
+ drop(id, image) {
55379
+ this.images.delete(id);
55380
+ this.totalBytes -= image.bytes.length;
55381
+ }
55382
+ };
55383
+ //#endregion
55384
+ //#region src/state-reports/state-change-map.ts
55385
+ /**
55386
+ * What a device's state change means for Alexa (R3, D776).
55387
+ *
55388
+ * Every cap state change on the hub arrives as ONE event —
55389
+ * `DeviceStateChanged {deviceId, capName, slice}` (the device-state mirror;
55390
+ * the per-cap `on…Changed` events the cap files declare are never emitted).
55391
+ * This maps one such change to the Alexa properties it changes, on the
55392
+ * endpoints this addon last published (`exportedEndpoints`): the alarm panel's
55393
+ * `armState` / `burglaryAlarm`, a privacy or autotrack switch's `powerState`,
55394
+ * an accessory's `powerState` / `brightness`, and — for `device-status` — the
55395
+ * device's connectivity.
55396
+ *
55397
+ * Pure: no I/O, so the mapping is pinned by a spec without an event bus.
55398
+ */
55399
+ /** Every published endpoint, keyed by the device whose state drives it. */
55400
+ function indexByDrivingDevice(exported) {
55401
+ const out = /* @__PURE__ */ new Map();
55402
+ const add = (deviceId, entry) => {
55403
+ const list = out.get(deviceId) ?? [];
55404
+ list.push(entry);
55405
+ out.set(deviceId, list);
55406
+ };
55407
+ for (const [key, ids] of Object.entries(exported)) {
55408
+ const exposedDeviceId = Number(key);
55409
+ for (const endpointId of ids) {
55410
+ const parsed = parseCameraEndpointId(endpointId);
55411
+ switch (parsed.kind) {
55412
+ case "camera":
55413
+ add(parsed.deviceId, {
55414
+ exposedDeviceId,
55415
+ endpointId,
55416
+ kind: "camera"
55417
+ });
55418
+ break;
55419
+ case "privacy":
55420
+ case "autotrack":
55421
+ add(parsed.parentDeviceId, {
55422
+ exposedDeviceId,
55423
+ endpointId,
55424
+ kind: parsed.kind
55425
+ });
55426
+ break;
55427
+ case "child":
55428
+ add(parsed.childDeviceId, {
55429
+ exposedDeviceId,
55430
+ endpointId,
55431
+ kind: "child"
55432
+ });
55433
+ break;
55434
+ case "alarm-panel":
55435
+ add(parsed.deviceId, {
55436
+ exposedDeviceId,
55437
+ endpointId,
55438
+ kind: "alarm-panel"
55439
+ });
55440
+ break;
55441
+ case "detection":
55442
+ add(parsed.deviceId, {
55443
+ exposedDeviceId,
55444
+ endpointId,
55445
+ kind: "detection"
55446
+ });
55447
+ break;
55448
+ case "generic":
55449
+ add(parsed.deviceId, {
55450
+ exposedDeviceId,
55451
+ endpointId,
55452
+ kind: "generic"
55453
+ });
55454
+ break;
55455
+ case "invalid": break;
55456
+ }
55457
+ }
55458
+ }
55459
+ return out;
55460
+ }
55461
+ var EnabledSchema = object({ enabled: boolean() });
55462
+ var OnSchema = object({ on: boolean() });
55463
+ var PercentageSchema = object({ percentage: number() });
55464
+ var OnlineSchema = object({ online: boolean() });
55465
+ function power(on) {
55466
+ return {
55467
+ namespace: "Alexa.PowerController",
55468
+ name: "powerState",
55469
+ value: on ? "ON" : "OFF"
55470
+ };
55471
+ }
55472
+ function stateReportsFor(change, exported) {
55473
+ if (change.capName === "device-status") {
55474
+ const parsed = OnlineSchema.safeParse(change.slice);
55475
+ return parsed.success ? {
55476
+ kind: "connectivity",
55477
+ deviceId: change.deviceId,
55478
+ online: parsed.data.online
55479
+ } : null;
55480
+ }
55481
+ const endpoints = indexByDrivingDevice(exported).get(change.deviceId) ?? [];
55482
+ const reports = [];
55483
+ const of = (kind) => endpoints.filter((e) => e.kind === kind).map((e) => ({
55484
+ exposedDeviceId: e.exposedDeviceId,
55485
+ endpointId: e.endpointId
55486
+ }));
55487
+ const generic = genericPropertyFor(change.capName, change.slice);
55488
+ if (generic !== null) for (const e of of("generic")) reports.push({
55489
+ ...e,
55490
+ interfaceName: generic.namespace,
55491
+ properties: [generic]
55492
+ });
55493
+ switch (change.capName) {
55494
+ case "alarm-panel": {
55495
+ const parsed = AlarmPanelStatusSchema.pick({
55496
+ state: true,
55497
+ targetMode: true
55498
+ }).safeParse(change.slice);
55499
+ if (!parsed.success) return null;
55500
+ for (const e of of("alarm-panel")) reports.push({
55501
+ ...e,
55502
+ interfaceName: "Alexa.SecurityPanelController",
55503
+ properties: [{
55504
+ namespace: "Alexa.SecurityPanelController",
55505
+ name: "armState",
55506
+ value: alexaArmStateFor(parsed.data.state, parsed.data.targetMode ?? null)
55507
+ }, {
55508
+ namespace: "Alexa.SecurityPanelController",
55509
+ name: "burglaryAlarm",
55510
+ value: burglaryAlarmFor(parsed.data.state)
55511
+ }]
55512
+ });
55513
+ break;
55514
+ }
55515
+ case "privacy-mask":
55516
+ case "ptz-autotrack": {
55517
+ const parsed = EnabledSchema.safeParse(change.slice);
55518
+ if (!parsed.success) return null;
55519
+ const kind = change.capName === "privacy-mask" ? "privacy" : "autotrack";
55520
+ for (const e of of(kind)) reports.push({
55521
+ ...e,
55522
+ interfaceName: "Alexa.PowerController",
55523
+ properties: [power(parsed.data.enabled)]
55524
+ });
55525
+ break;
55526
+ }
55527
+ case "switch": {
55528
+ const parsed = OnSchema.safeParse(change.slice);
55529
+ if (!parsed.success) break;
55530
+ for (const e of of("child")) reports.push({
55531
+ ...e,
55532
+ interfaceName: "Alexa.PowerController",
55533
+ properties: [power(parsed.data.on)]
55534
+ });
55535
+ break;
55536
+ }
55537
+ case "brightness": {
55538
+ const parsed = PercentageSchema.safeParse(change.slice);
55539
+ if (!parsed.success) break;
55540
+ const value = Math.max(0, Math.min(100, Math.round(parsed.data.percentage)));
55541
+ for (const e of of("child")) reports.push({
55542
+ ...e,
55543
+ interfaceName: "Alexa.BrightnessController",
55544
+ properties: [{
55545
+ namespace: "Alexa.BrightnessController",
55546
+ name: "brightness",
55547
+ value
55548
+ }]
55549
+ });
55550
+ break;
55551
+ }
55552
+ default: break;
55553
+ }
55554
+ return reports.length > 0 ? {
55555
+ kind: "reports",
55556
+ reports
55557
+ } : null;
55558
+ }
55559
+ //#endregion
55560
+ //#region src/state-reports/state-change-reporter.ts
55561
+ var DeviceStateChangedSchema = object({
55562
+ deviceId: number(),
55563
+ capName: string(),
55564
+ slice: record(string(), unknown())
55565
+ });
55566
+ var StateChangeReporter = class {
55567
+ deps;
55568
+ off = null;
55569
+ lastReported = /* @__PURE__ */ new Map();
55570
+ constructor(deps) {
55571
+ this.deps = deps;
55572
+ }
55573
+ start() {
55574
+ this.off = this.deps.eventBus.subscribe({ category: EventCategory.DeviceStateChanged }, (event) => {
55575
+ const parsed = DeviceStateChangedSchema.safeParse(event.data);
55576
+ if (parsed.success) this.onChange(parsed.data);
55577
+ });
55578
+ }
55579
+ stop() {
55580
+ this.off?.();
55581
+ this.off = null;
55582
+ this.lastReported.clear();
55583
+ }
55584
+ async onChange(change) {
55585
+ const outcome = stateReportsFor(change, this.deps.getExportedEndpoints());
55586
+ if (outcome === null) return;
55587
+ try {
55588
+ if (outcome.kind === "connectivity") {
55589
+ await this.deps.reportConnectivity(outcome.deviceId, outcome.online ? "OK" : "UNREACHABLE");
55590
+ return;
55591
+ }
55592
+ for (const report of outcome.reports) {
55593
+ const key = `${report.endpointId}|${report.interfaceName}`;
55594
+ const value = JSON.stringify(report.properties);
55595
+ if (this.lastReported.get(key) === value) continue;
55596
+ this.lastReported.set(key, value);
55597
+ if (report.interfaceName === "Alexa.MotionSensor") {
55598
+ await this.deps.postHeldMotion(report, report.properties[0]?.value === "DETECTED");
55599
+ continue;
55600
+ }
55601
+ await this.deps.postStateChange(report);
55602
+ }
55603
+ } catch (err) {
55604
+ this.deps.logger.warn("alexa state reports: posting a state change failed", {
55605
+ tags: { deviceId: change.deviceId },
55606
+ meta: {
55607
+ capName: change.capName,
55608
+ error: errMsg(err)
55609
+ }
55610
+ });
55611
+ }
55612
+ }
55613
+ };
55614
+ //#endregion
53119
55615
  //#region src/public-skill.ts
53120
55616
  /**
53121
55617
  * The built-in "public" CamStack Alexa skill.
@@ -53134,28 +55630,6 @@ function buildAlexaOauthIntegration(publicHubUrl) {
53134
55630
  */
53135
55631
  var PUBLIC_SKILL_ID = "amzn1.ask.skill.aa85d6df-1600-4f13-bda9-babcfdc849e6";
53136
55632
  //#endregion
53137
- //#region src/endpoint-signature.ts
53138
- /** One capability's identity as Alexa distinguishes it. */
53139
- function capabilityKey(capability) {
53140
- const instance = typeof capability.instance === "string" ? capability.instance : "";
53141
- return instance.length > 0 ? `${capability.interface}#${instance}` : capability.interface;
53142
- }
53143
- /** Stable, order-independent signature of ONE endpoint's advertised shape. */
53144
- function endpointKey(endpoint) {
53145
- const interfaces = endpoint.capabilities.map(capabilityKey).sort().join(",");
53146
- const categories = [...endpoint.displayCategories].sort().join(",");
53147
- return `${endpoint.endpointId}|${categories}|${interfaces}`;
53148
- }
53149
- /**
53150
- * Stable signature of the endpoint set built for ONE device. Two builds that
53151
- * would land identically in Alexa's registry produce the same string; any
53152
- * added, removed or renamed interface — or a changed display category —
53153
- * produces a different one.
53154
- */
53155
- function endpointsSignature(endpoints) {
53156
- return endpoints.map(endpointKey).sort().join(";");
53157
- }
53158
- //#endregion
53159
55633
  //#region src/sync-engine.ts
53160
55634
  /**
53161
55635
  * Devices reconciled at once in {@link SyncEngine.reconcileAllExposed}. Each
@@ -53203,8 +55677,18 @@ var SyncEngine = class {
53203
55677
  };
53204
55678
  }
53205
55679
  const endpointIds = endpoints.map((e) => e.endpointId);
55680
+ const retired = (this.deps.getExportedEndpoints()[deviceId] ?? []).filter((id) => !endpointIds.includes(id) && this.deps.mayRetire(id));
53206
55681
  const failures = [];
53207
- for (const account of accounts) if (!await this.deps.postAddOrUpdate(account.userId, endpoints)) failures.push(`${account.userId}:${deviceId}`);
55682
+ for (const account of accounts) {
55683
+ if (!await this.postFiltered(account.userId, deviceId, endpoints)) failures.push(`${account.userId}:${deviceId}`);
55684
+ if (retired.length > 0) {
55685
+ if (!await this.deps.postDelete(account.userId, retired)) failures.push(`${account.userId}:${deviceId}:delete`);
55686
+ }
55687
+ }
55688
+ if (retired.length > 0) this.deps.logger.info("alexa sync: retired endpoints the device no longer builds", {
55689
+ tags: { deviceId },
55690
+ meta: { retired }
55691
+ });
53208
55692
  await this.recordEndpoints(deviceId, endpointIds, endpointsSignature(endpoints));
53209
55693
  return {
53210
55694
  deviceCount: 1,
@@ -53250,7 +55734,7 @@ var SyncEngine = class {
53250
55734
  for (const { deviceId } of this.deps.listExposed()) {
53251
55735
  const endpoints = await this.deps.buildEndpoints(deviceId);
53252
55736
  if (endpoints.length === 0) continue;
53253
- if (!await this.deps.postAddOrUpdate(userId, endpoints)) failures.push(`${userId}:${deviceId}`);
55737
+ if (!await this.postFiltered(userId, deviceId, endpoints)) failures.push(`${userId}:${deviceId}`);
53254
55738
  await this.recordEndpoints(deviceId, endpoints.map((e) => e.endpointId), endpointsSignature(endpoints));
53255
55739
  deviceCount += 1;
53256
55740
  endpointCount += endpoints.length;
@@ -53262,6 +55746,49 @@ var SyncEngine = class {
53262
55746
  failures
53263
55747
  };
53264
55748
  }
55749
+ /**
55750
+ * Post `endpoints` to one account, cut to what that account's user may see.
55751
+ * Nothing visible is a success with nothing to send: an empty
55752
+ * AddOrUpdateReport is an Alexa protocol error.
55753
+ */
55754
+ async postFiltered(userId, deviceId, endpoints) {
55755
+ const filtered = await this.deps.filterForAccount(userId, endpoints);
55756
+ if (filtered === null) {
55757
+ this.deps.logger.warn("alexa sync: the linked user's grants could not be read — this account was NOT sent the device", {
55758
+ tags: { deviceId },
55759
+ meta: { userId }
55760
+ });
55761
+ return false;
55762
+ }
55763
+ const visible = withholdInvalidEndpoints(filtered, (message, violations) => {
55764
+ this.deps.logger.warn(message, {
55765
+ tags: { deviceId },
55766
+ meta: {
55767
+ userId,
55768
+ violations
55769
+ }
55770
+ });
55771
+ });
55772
+ if (visible.length === 0) return true;
55773
+ const ok = await this.deps.postAddOrUpdate(userId, visible);
55774
+ if (ok) this.deps.afterPush(userId, visible);
55775
+ return ok;
55776
+ }
55777
+ /** A device the account's user just gained (visibility mirror). */
55778
+ async pushDeviceToAccount(userId, deviceId) {
55779
+ if (!this.deps.hasProactiveCredentials()) return false;
55780
+ if (!this.deps.listExposed().some((e) => e.deviceId === deviceId)) return true;
55781
+ const endpoints = await this.deps.buildEndpoints(deviceId);
55782
+ if (endpoints.length === 0) return false;
55783
+ return this.postFiltered(userId, deviceId, endpoints);
55784
+ }
55785
+ /** A device the account's user lost (confirmed by two reads): delete every
55786
+ * endpoint we ever published for it from THAT account only. */
55787
+ async removeDeviceFromAccount(userId, deviceId) {
55788
+ if (!this.deps.hasProactiveCredentials()) return false;
55789
+ const ids = this.deps.getExportedEndpoints()[deviceId] ?? [String(deviceId)];
55790
+ return this.deps.postDelete(userId, ids);
55791
+ }
53265
55792
  async removeDevice(deviceId) {
53266
55793
  const ids = this.deps.getExportedEndpoints()[deviceId] ?? [];
53267
55794
  if (ids.length === 0) return {
@@ -53420,6 +55947,17 @@ var SyncEngine = class {
53420
55947
  * provider respawn emits one `DeviceBindingsChanged` per cap; reconciling in
53421
55948
  * the middle of that burst would read a half-registered binding set. */
53422
55949
  var BINDINGS_RECONCILE_DEBOUNCE_MS = 5e3;
55950
+ /** How often each linked account's visibility is recomputed from the live
55951
+ * user grants (S1). Also the bound on how long an event keeps reaching a user
55952
+ * whose grant was just revoked. */
55953
+ var VISIBILITY_REFRESH_INTERVAL_MS = 6e4;
55954
+ /** First visibility pass after boot — soon, because until an account has an
55955
+ * entry its events are refused (fail closed). */
55956
+ var BOOT_VISIBILITY_DELAY_MS = 5e3;
55957
+ /** How often every exposed camera's endpoint shape is re-checked while the
55958
+ * detection sensors are on — a camera whose pipeline gained or (confirmed)
55959
+ * lost a class gets its sensors added or removed. */
55960
+ var DETECTION_CLASS_REFRESH_MS = 15 * 6e4;
53423
55961
  /** Delay before the boot-time reconcile pass. Long enough for device providers
53424
55962
  * to have registered their caps — a device whose bindings have not landed
53425
55963
  * builds a shape we must never push to Alexa. */
@@ -53458,18 +55996,47 @@ var DEFAULT_CONFIG = {
53458
55996
  availableEndpoints: [],
53459
55997
  ingressToken: "",
53460
55998
  enforceIngressToken: false,
53461
- staleEndpointPurgeInput: ""
55999
+ staleEndpointPurgeInput: "",
56000
+ accountVisibility: {},
56001
+ detectionSensors: true,
56002
+ detectionClasses: {},
56003
+ snapshots: true,
56004
+ snapshotSigningKey: ""
53462
56005
  };
56006
+ /** The one field of `oauthVerifyAccessToken`'s answer the snapshot route
56007
+ * reads. */
56008
+ var VerifiedTokenSchema = object({ userId: string() }).nullable();
53463
56009
  /** UI filter for the Export picker. `alarm-panel` publishes an
53464
56010
  * `Alexa.SecurityPanelController` endpoint (`endpoints/builder.ts`). */
53465
56011
  var SUPPORTED_KINDS = [
53466
56012
  "camera",
53467
56013
  "doorbell",
53468
- DeviceType.AlarmPanel
56014
+ DeviceType.AlarmPanel,
56015
+ DeviceType.Light,
56016
+ DeviceType.Switch,
56017
+ DeviceType.Siren,
56018
+ DeviceType.Sensor,
56019
+ DeviceType.Lock,
56020
+ DeviceType.Presence,
56021
+ DeviceType.Thermostat,
56022
+ DeviceType.Generic
53469
56023
  ];
53470
56024
  var ExportAlexaAddon = class extends BaseAddon {
53471
56025
  lwaGateway = null;
53472
56026
  syncEngine = null;
56027
+ /** S1 (D767): the linking user's live grants. Built in `onInitialize`, the
56028
+ * first point `ctx` exists. */
56029
+ accessGateRef = null;
56030
+ visibilityRef = null;
56031
+ detectionClassSource = null;
56032
+ /** The images handed to Alexa, behind signed URLs (R2). In memory only: a
56033
+ * restart answers 410 and Alexa asks again. */
56034
+ snapshotStore = new SnapshotStore(() => this.config.snapshotSigningKey);
56035
+ detectionFeed = null;
56036
+ stateReporter = null;
56037
+ /** The switches whose change re-shapes every exposed endpoint, as last
56038
+ * applied — `onConfigChanged` runs on EVERY settings write. */
56039
+ appliedShapeSwitches = null;
53473
56040
  /** Per-device debounce timers for the bindings-change discovery reconcile. */
53474
56041
  pendingReconcileTimers = /* @__PURE__ */ new Map();
53475
56042
  constructor() {
@@ -53477,6 +56044,7 @@ var ExportAlexaAddon = class extends BaseAddon {
53477
56044
  }
53478
56045
  async onInitialize() {
53479
56046
  if (!this.config.ingressToken) await this.updateGlobalSettings({ ingressToken: generateIngressToken() });
56047
+ if (!this.config.snapshotSigningKey) await this.updateGlobalSettings({ snapshotSigningKey: generateIngressToken() });
53480
56048
  if (this.config.lwaClientSecret) {
53481
56049
  await this.updateGlobalSettings({ lwaClientSecret: "" });
53482
56050
  this.ctx.logger.warn("export-alexa: removed a stored LWA client secret — the hub no longer uses one; your skill Lambda holds it (D753)");
@@ -53485,6 +56053,21 @@ var ExportAlexaAddon = class extends BaseAddon {
53485
56053
  this.autoDetectPublicHubUrl().catch((err) => {
53486
56054
  this.ctx.logger.warn("export-alexa: public hub URL auto-detect failed", { meta: { error: errMsg(err) } });
53487
56055
  });
56056
+ this.accessGateRef = createHubAccessGate(this.ctx);
56057
+ this.detectionClassSource = new DetectionClassSource({
56058
+ readEventKinds: (deviceId) => this.ctx.api.pipelineAnalytics.listEventKinds.query({ deviceId }),
56059
+ getStored: () => this.config.detectionClasses,
56060
+ setStored: (next) => this.updateGlobalSettings({ detectionClasses: next }),
56061
+ logger: this.ctx.logger
56062
+ });
56063
+ this.visibilityRef = new AccountVisibilityMirror({
56064
+ gate: this.accessGateRef,
56065
+ listLinkedUserIds: () => this.config.linkedAccounts.map((a) => a.userId),
56066
+ listExposedDeviceIds: () => this.config.exposed.map((e) => e.deviceId),
56067
+ getStored: () => this.config.accountVisibility,
56068
+ setStored: (next) => this.updateGlobalSettings({ accountVisibility: next }),
56069
+ logger: this.ctx.logger
56070
+ });
53488
56071
  const deviceExportProvider = {
53489
56072
  getStatus: async () => ({
53490
56073
  linkState: this.config.linkedAccounts.length > 0 ? "linked" : "unlinked",
@@ -53507,7 +56090,8 @@ var ExportAlexaAddon = class extends BaseAddon {
53507
56090
  discovery: {
53508
56091
  ctx: this.ctx,
53509
56092
  logger: this.ctx.logger,
53510
- listExposed: async () => this.config.exposed
56093
+ listExposed: async () => this.config.exposed,
56094
+ buildEndpoints: (deviceId) => this.buildEndpointsForOneDevice(deviceId)
53511
56095
  },
53512
56096
  cameraStream: {
53513
56097
  ctx: this.ctx,
@@ -53538,12 +56122,26 @@ var ExportAlexaAddon = class extends BaseAddon {
53538
56122
  },
53539
56123
  reportState: {
53540
56124
  ctx: this.ctx,
53541
- logger: this.ctx.logger
56125
+ logger: this.ctx.logger,
56126
+ detectionPresent: (deviceId, cls) => this.detectionFeed?.isPresent(deviceId, cls) ?? false
53542
56127
  },
53543
56128
  securityPanel: {
53544
56129
  ctx: this.ctx,
53545
56130
  logger: this.ctx.logger
53546
- }
56131
+ },
56132
+ lockController: {
56133
+ ctx: this.ctx,
56134
+ logger: this.ctx.logger
56135
+ },
56136
+ snapshotProvider: {
56137
+ ctx: this.ctx,
56138
+ logger: this.ctx.logger,
56139
+ store: this.snapshotStore,
56140
+ enabled: () => this.config.snapshots,
56141
+ publicHubUrl: () => this.config.publicHubUrl
56142
+ },
56143
+ access: this.endpointAccess,
56144
+ afterDiscovery: (userId, endpoints) => this.requireGateway().scheduleInitialConnectivity(userId, endpoints)
53547
56145
  },
53548
56146
  lwaTokensCallbackDeps: {
53549
56147
  ctx: this.ctx,
@@ -53552,7 +56150,18 @@ var ExportAlexaAddon = class extends BaseAddon {
53552
56150
  recordLinkedAccount: (input) => this.recordLinkedAccount(input)
53553
56151
  },
53554
56152
  redetectPublicHubUrl: () => this.redetectPublicHubUrl(),
53555
- onBrokerAdvertised: (raw) => this.rememberAdvertisedBroker(raw)
56153
+ onBrokerAdvertised: (raw) => this.rememberAdvertisedBroker(raw),
56154
+ snapshotRoute: {
56155
+ store: this.snapshotStore,
56156
+ enabled: () => this.config.snapshots,
56157
+ verifyToken: (token) => this.verifyAccountLinkToken(token),
56158
+ mayView: async (userId, deviceId) => {
56159
+ const lookup = await this.accessGate.principal(userId);
56160
+ if (lookup.kind !== "found") return false;
56161
+ return await this.accessGate.allows(lookup.principal, deviceId, "view") === "allowed";
56162
+ },
56163
+ logger: this.ctx.logger
56164
+ }
53556
56165
  }));
53557
56166
  const oauthIntegrationProvider = { getDescriptor: async () => buildAlexaOauthIntegration(this.config.publicHubUrl) };
53558
56167
  this.lwaGateway = new LwaEventGateway({
@@ -53566,15 +56175,54 @@ var ExportAlexaAddon = class extends BaseAddon {
53566
56175
  getAdvertisedBrokerUrl: () => this.config.advertisedBrokerUrl || null,
53567
56176
  getPublicHubUrl: () => this.config.publicHubUrl,
53568
56177
  getRegion: () => this.config.region,
53569
- getExportedSignature: (deviceId) => this.config.exportedSignatures[deviceId] ?? null
56178
+ getExportedSignature: (deviceId) => this.config.exportedSignatures[deviceId] ?? null,
56179
+ accountMaySee: (userId, deviceId) => this.visibility.allows(userId, deviceId)
53570
56180
  });
53571
56181
  this.lwaGateway.start();
56182
+ const gatewayForFeed = this.lwaGateway;
56183
+ this.detectionFeed = new DetectionSensorFeed({
56184
+ eventBus: this.ctx.eventBus,
56185
+ enabled: () => this.config.detectionSensors,
56186
+ isExposed: (deviceId) => this.config.exposed.some((e) => e.deviceId === deviceId),
56187
+ readActiveTracks: async (deviceId) => {
56188
+ return (await this.ctx.api.pipelineAnalytics.getActiveTracks.query({ deviceId })).map((t) => ({
56189
+ trackId: t.trackId,
56190
+ className: t.className
56191
+ }));
56192
+ },
56193
+ push: (deviceId, cls, detected, timestamp) => gatewayForFeed.pushDetectionSensor({
56194
+ deviceId,
56195
+ endpointId: detectionSensorEndpointId(deviceId, cls),
56196
+ detected,
56197
+ timestamp
56198
+ }),
56199
+ logger: this.ctx.logger
56200
+ });
56201
+ this.detectionFeed.start();
56202
+ this.appliedShapeSwitches = this.shapeSwitches();
56203
+ this.stateReporter = new StateChangeReporter({
56204
+ eventBus: this.ctx.eventBus,
56205
+ getExportedEndpoints: () => this.config.exportedEndpoints,
56206
+ reportConnectivity: (deviceId, connectivity) => gatewayForFeed.reportConnectivity(deviceId, connectivity),
56207
+ postStateChange: (report) => gatewayForFeed.postStateChange(report),
56208
+ postHeldMotion: (report, detected) => gatewayForFeed.pushDetectionSensor({
56209
+ deviceId: report.exposedDeviceId,
56210
+ endpointId: report.endpointId,
56211
+ detected,
56212
+ timestamp: Date.now()
56213
+ }),
56214
+ logger: this.ctx.logger
56215
+ });
56216
+ this.stateReporter.start();
53572
56217
  this.syncEngine = new SyncEngine({
53573
56218
  logger: this.ctx.logger,
53574
56219
  listExposed: () => this.config.exposed,
53575
56220
  listLinkedAccounts: () => this.config.linkedAccounts,
53576
56221
  hasProactiveCredentials: () => this.config.tokens.length > 0,
53577
56222
  buildEndpoints: (deviceId) => this.buildEndpointsForOneDevice(deviceId),
56223
+ filterForAccount: (userId, endpoints) => this.filterForAccount(userId, endpoints),
56224
+ afterPush: (userId, endpoints) => this.requireGateway().scheduleInitialConnectivity(userId, endpoints),
56225
+ mayRetire: (endpointId) => parseCameraEndpointId(endpointId).kind === "detection",
53578
56226
  postAddOrUpdate: (userId, endpoints) => this.requireGateway().postReport(userId, (accessToken) => buildAddOrUpdateReportPayload(userId, endpoints, accessToken), `add/update (${endpoints.length})`),
53579
56227
  postDelete: (userId, ids) => this.requireGateway().postReport(userId, (accessToken) => buildDeleteReportPayload(userId, ids, accessToken), `delete (${ids.length})`),
53580
56228
  getExportedEndpoints: () => this.config.exportedEndpoints,
@@ -53584,6 +56232,8 @@ var ExportAlexaAddon = class extends BaseAddon {
53584
56232
  });
53585
56233
  this.subscribeBindingsChangedForReconcile();
53586
56234
  this.scheduleBootDiscoveryReconcile();
56235
+ this.scheduleVisibilityRefresh();
56236
+ this.scheduleDetectionClassRefresh();
53587
56237
  const offRemoved = this.ctx.eventBus.subscribe({ category: EventCategory.DeviceUnregistered }, (event) => {
53588
56238
  const id = event.data?.deviceId;
53589
56239
  if (typeof id !== "number") return;
@@ -53630,6 +56280,10 @@ var ExportAlexaAddon = class extends BaseAddon {
53630
56280
  actionHandlers: {
53631
56281
  redetectHubUrl: async () => this.redetectPublicHubUrl(),
53632
56282
  regenerateIngressToken: async () => this.regenerateIngressToken(),
56283
+ revealIngressToken: async () => {
56284
+ this.ctx.logger.info("export-alexa: an administrator revealed the ingress token");
56285
+ return { ingressToken: this.config.ingressToken };
56286
+ },
53633
56287
  resyncDiscovery: async () => this.resyncDiscovery(),
53634
56288
  purgeStaleEndpoints: async () => this.purgeStaleEndpoints(),
53635
56289
  unlinkAlexaAccounts: async () => this.unlinkAlexaAccounts(),
@@ -53638,6 +56292,10 @@ var ExportAlexaAddon = class extends BaseAddon {
53638
56292
  };
53639
56293
  }
53640
56294
  async onShutdown() {
56295
+ this.detectionFeed?.stop();
56296
+ this.detectionFeed = null;
56297
+ this.stateReporter?.stop();
56298
+ this.stateReporter = null;
53641
56299
  if (this.lwaGateway) {
53642
56300
  this.lwaGateway.stop();
53643
56301
  this.lwaGateway = null;
@@ -53658,7 +56316,10 @@ var ExportAlexaAddon = class extends BaseAddon {
53658
56316
  });
53659
56317
  await this.updateGlobalSettings({ exposed: next });
53660
56318
  this.ctx.logger.info("export-alexa: exposed device", { meta: { deviceId } });
53661
- this.requireSyncEngine().pushDevice(deviceId).then((r) => this.ctx.logger.info("export-alexa: synced exposed device", { meta: { ...r } })).catch((err) => {
56319
+ this.requireSyncEngine().pushDevice(deviceId).then((r) => {
56320
+ this.ctx.logger.info("export-alexa: synced exposed device", { meta: { ...r } });
56321
+ return this.refreshVisibility();
56322
+ }).catch((err) => {
53662
56323
  this.ctx.logger.warn("export-alexa: expose sync failed", { meta: {
53663
56324
  deviceId,
53664
56325
  error: errMsg(err)
@@ -53685,12 +56346,96 @@ var ExportAlexaAddon = class extends BaseAddon {
53685
56346
  * `Discover` handler uses.
53686
56347
  */
53687
56348
  async buildEndpointsForOneDevice(deviceId) {
56349
+ const classSource = this.detectionClassSource;
53688
56350
  return buildEndpointsForExposedDevice(deviceId, {
53689
56351
  ctx: this.ctx,
53690
56352
  logger: this.ctx.logger,
53691
- listExposed: async () => this.config.exposed
56353
+ snapshots: this.config.snapshots,
56354
+ ...this.config.detectionSensors && classSource !== null ? { detectionClasses: (id) => classSource.classesFor(id) } : {}
56355
+ });
56356
+ }
56357
+ /** The settings that change every exposed endpoint's shape, as one key. */
56358
+ shapeSwitches() {
56359
+ return JSON.stringify({
56360
+ detectionSensors: this.config.detectionSensors,
56361
+ snapshots: this.config.snapshots
56362
+ });
56363
+ }
56364
+ /**
56365
+ * A shape switch was flipped: re-push every exposed device. The signature
56366
+ * reconcile pushes only what changed, and the push retires the endpoints a
56367
+ * device no longer builds (the detection sensors, when switched off).
56368
+ */
56369
+ async onConfigChanged() {
56370
+ const now = this.shapeSwitches();
56371
+ if (this.appliedShapeSwitches === null || now === this.appliedShapeSwitches) return;
56372
+ this.appliedShapeSwitches = now;
56373
+ if (!this.config.snapshots) this.snapshotStore.clear();
56374
+ this.ctx.logger.info("export-alexa: an endpoint-shape switch changed — re-syncing every device", { meta: { switches: now } });
56375
+ const engine = this.syncEngine;
56376
+ if (engine === null) return;
56377
+ engine.reconcileAllExposed().catch((err) => {
56378
+ this.ctx.logger.warn("export-alexa: re-sync after a switch change failed", { meta: { error: errMsg(err) } });
56379
+ });
56380
+ }
56381
+ scheduleDetectionClassRefresh() {
56382
+ const timer = setInterval(() => {
56383
+ if (!this.config.detectionSensors) return;
56384
+ this.requireSyncEngine().reconcileAllExposed().catch((err) => {
56385
+ this.ctx.logger.warn("export-alexa: detection-class refresh failed", { meta: { error: errMsg(err) } });
56386
+ });
56387
+ }, DETECTION_CLASS_REFRESH_MS);
56388
+ timer.unref?.();
56389
+ this.ctx.addDisposer(async () => clearInterval(timer));
56390
+ }
56391
+ get visibility() {
56392
+ if (!this.visibilityRef) throw new Error("export-alexa: visibility mirror not initialized");
56393
+ return this.visibilityRef;
56394
+ }
56395
+ /** The endpoints one linked account may be sent (S1). `null` = its user's
56396
+ * grants are unreadable right now; a deleted user is sent nothing. */
56397
+ async filterForAccount(userId, endpoints) {
56398
+ const lookup = await this.accessGate.principal(userId);
56399
+ if (lookup.kind === "unavailable") return null;
56400
+ if (lookup.kind === "unknown-user") return [];
56401
+ return (await filterEndpointsForPrincipal(endpoints, lookup.principal, this.endpointAccess)).kept;
56402
+ }
56403
+ /** One visibility pass, applied to Alexa. Never throws. */
56404
+ async refreshVisibility() {
56405
+ try {
56406
+ await applyVisibilityChanges(await this.visibility.refresh(), this.requireSyncEngine(), this.ctx.logger);
56407
+ } catch (err) {
56408
+ this.ctx.logger.warn("export-alexa: visibility refresh failed", { meta: { error: errMsg(err) } });
56409
+ }
56410
+ }
56411
+ scheduleVisibilityRefresh() {
56412
+ const first = setTimeout(() => void this.refreshVisibility(), BOOT_VISIBILITY_DELAY_MS);
56413
+ first.unref?.();
56414
+ const every = setInterval(() => void this.refreshVisibility(), VISIBILITY_REFRESH_INTERVAL_MS);
56415
+ every.unref?.();
56416
+ this.ctx.addDisposer(async () => {
56417
+ clearTimeout(first);
56418
+ clearInterval(every);
53692
56419
  });
53693
56420
  }
56421
+ /** An account-link token's hub user, or null — the snapshot route's
56422
+ * credential check (the same verification `/directive` runs). */
56423
+ async verifyAccountLinkToken(token) {
56424
+ const raw = await this.ctx.api.userManagement.oauthVerifyAccessToken.query({ token });
56425
+ const parsed = VerifiedTokenSchema.safeParse(raw);
56426
+ return parsed.success && parsed.data !== null ? { userId: parsed.data.userId } : null;
56427
+ }
56428
+ get accessGate() {
56429
+ if (!this.accessGateRef) throw new Error("export-alexa: access gate not initialized");
56430
+ return this.accessGateRef;
56431
+ }
56432
+ /** The exposed set + the user gate, as every enforcement point reads them. */
56433
+ get endpointAccess() {
56434
+ return {
56435
+ gate: this.accessGate,
56436
+ isExposed: (deviceId) => this.config.exposed.some((e) => e.deviceId === deviceId)
56437
+ };
56438
+ }
53694
56439
  /** Narrow the nullable `lwaGateway` for the SyncEngine post closures. It is
53695
56440
  * constructed + started in `onInitialize` before the SyncEngine, so its
53696
56441
  * `postReport` is always reachable when the engine pushes. */
@@ -53861,7 +56606,8 @@ var ExportAlexaAddon = class extends BaseAddon {
53861
56606
  linkedAccounts: [],
53862
56607
  tokens: [],
53863
56608
  exportedEndpoints: {},
53864
- exportedSignatures: {}
56609
+ exportedSignatures: {},
56610
+ accountVisibility: {}
53865
56611
  });
53866
56612
  this.ctx.logger.warn("export-alexa: operator unlinked the Amazon account(s)", { meta: {
53867
56613
  revokedSessions,
@@ -53904,7 +56650,10 @@ var ExportAlexaAddon = class extends BaseAddon {
53904
56650
  await this.updateGlobalSettings({ linkedAccounts: next });
53905
56651
  const engine = this.syncEngine;
53906
56652
  if (!engine) return;
53907
- engine.pushAllToAccount(input.userId).then((r) => this.ctx.logger.info("export-alexa: pushed to newly linked account", { meta: { ...r } })).catch((err) => {
56653
+ engine.pushAllToAccount(input.userId).then((r) => {
56654
+ this.ctx.logger.info("export-alexa: pushed to newly linked account", { meta: { ...r } });
56655
+ return this.refreshVisibility();
56656
+ }).catch((err) => {
53908
56657
  this.ctx.logger.warn("export-alexa: new-account push failed", { meta: {
53909
56658
  userId: input.userId,
53910
56659
  error: errMsg(err)
@@ -54105,26 +56854,11 @@ var ExportAlexaAddon = class extends BaseAddon {
54105
56854
  * gets a copy button in the panel; the secret rows are masked.
54106
56855
  */
54107
56856
  buildSetupBlock() {
54108
- const base = this.config.publicHubUrl.replace(/\/+$/, "");
54109
- const linkedCount = this.config.linkedAccounts.length;
54110
- return {
54111
- fields: [
54112
- ...base ? [{
54113
- label: "Directive endpoint",
54114
- value: `${base}/addon/export-alexa/directive`
54115
- }] : [],
54116
- ...this.config.ingressToken ? [{
54117
- label: "Ingress token (Lambda HUB_INGRESS_TOKEN)",
54118
- value: this.config.ingressToken,
54119
- secret: true
54120
- }] : [],
54121
- {
54122
- label: "Linked Alexa accounts",
54123
- value: String(linkedCount)
54124
- }
54125
- ],
54126
- 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."
54127
- };
56857
+ return buildAlexaSetupBlock({
56858
+ publicHubUrl: this.config.publicHubUrl,
56859
+ ingressToken: this.config.ingressToken,
56860
+ linkedAccountCount: this.config.linkedAccounts.length
56861
+ });
54128
56862
  }
54129
56863
  /** Options for the "External address" picker: the cached HTTPS endpoints
54130
56864
  * plus the current selection (so a stale/custom value is never dropped). */
@@ -54195,7 +56929,7 @@ var ExportAlexaAddon = class extends BaseAddon {
54195
56929
  type: "select",
54196
56930
  key: "region",
54197
56931
  label: "Skill region",
54198
- 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.",
56932
+ 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.",
54199
56933
  options: [
54200
56934
  {
54201
56935
  value: "NA",
@@ -54212,6 +56946,20 @@ var ExportAlexaAddon = class extends BaseAddon {
54212
56946
  ],
54213
56947
  default: DEFAULT_CONFIG.region
54214
56948
  }),
56949
+ this.field({
56950
+ type: "boolean",
56951
+ key: "detectionSensors",
56952
+ label: "Detection sensors",
56953
+ 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.",
56954
+ default: DEFAULT_CONFIG.detectionSensors
56955
+ }),
56956
+ this.field({
56957
+ type: "boolean",
56958
+ key: "snapshots",
56959
+ label: "Snapshots in the Alexa app",
56960
+ 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.",
56961
+ default: DEFAULT_CONFIG.snapshots
56962
+ }),
54215
56963
  ...this.config.skillMode === "custom" ? [this.field({
54216
56964
  type: "text",
54217
56965
  key: "expectedAudience",
@@ -54223,7 +56971,7 @@ var ExportAlexaAddon = class extends BaseAddon {
54223
56971
  key: "__custom-skill-info",
54224
56972
  label: "Your skill Lambda",
54225
56973
  variant: "info",
54226
- 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."
56974
+ 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."
54227
56975
  }] : [{
54228
56976
  type: "info",
54229
56977
  key: "__public-skill-info",
@@ -54293,16 +57041,30 @@ var ExportAlexaAddon = class extends BaseAddon {
54293
57041
  key: "__ingress-token-info",
54294
57042
  label: "Ingress token",
54295
57043
  variant: "info",
54296
- 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)."
57044
+ 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)."
54297
57045
  },
54298
57046
  {
54299
- type: "button",
57047
+ type: "addon-action-button",
57048
+ addonId: "export-alexa",
57049
+ key: "__reveal-ingress-token",
57050
+ label: "Show ingress token",
57051
+ description: "Administrators only. Shows the current token once, here.",
57052
+ buttonLabel: "Show ingress token",
57053
+ action: "revealIngressToken",
57054
+ variant: "default",
57055
+ successMessage: "Ingress token: {ingressToken}"
57056
+ },
57057
+ {
57058
+ type: "addon-action-button",
57059
+ addonId: "export-alexa",
54300
57060
  key: "__regenerate-ingress-token",
54301
57061
  label: "Rotate ingress token",
54302
57062
  description: "Generate a new ingress token, invalidating the old one. Update your Lambda's HUB_INGRESS_TOKEN afterwards.",
54303
57063
  buttonLabel: "Regenerate token",
54304
57064
  action: "regenerateIngressToken",
54305
- variant: "default"
57065
+ variant: "default",
57066
+ successMessage: "New ingress token: {ingressToken} — paste it into HUB_INGRESS_TOKEN now.",
57067
+ confirmMessage: "Rotate the ingress token? With \"Require ingress token\" on, the Lambda is refused until you update HUB_INGRESS_TOKEN."
54306
57068
  },
54307
57069
  this.field({
54308
57070
  type: "boolean",
@@ -54346,14 +57108,14 @@ var ExportAlexaAddon = class extends BaseAddon {
54346
57108
  }
54347
57109
  }
54348
57110
  async buildDeviceSettingsContribution(deviceId) {
54349
- if (!await this.isCameraDevice(deviceId)) return null;
57111
+ const kind = await this.exportKindOf(deviceId);
57112
+ if (kind === null) return null;
54350
57113
  const entry = this.findEntry(deviceId);
54351
57114
  const settings = entry?.settings ?? DEFAULT_DEVICE_SETTINGS;
54352
57115
  const enabled = entry !== null;
54353
57116
  const enabledKey = `alexa:${deviceId}:enabled`;
54354
- const streamPreferenceKey = `alexa:${deviceId}:streamPreference`;
54355
- const allowTranscodeKey = `alexa:${deviceId}:allowTranscode`;
54356
57117
  const linkedCount = this.config.linkedAccounts.length;
57118
+ 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";
54357
57119
  return {
54358
57120
  tabs: [{
54359
57121
  id: "export",
@@ -54364,7 +57126,7 @@ var ExportAlexaAddon = class extends BaseAddon {
54364
57126
  sections: [{
54365
57127
  id: "export-alexa",
54366
57128
  title: "Alexa Export",
54367
- description: "Surface this camera to the linked Alexa Smart Home skill.",
57129
+ 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).",
54368
57130
  tab: "export",
54369
57131
  columns: 1,
54370
57132
  order: 20,
@@ -54373,49 +57135,54 @@ var ExportAlexaAddon = class extends BaseAddon {
54373
57135
  type: "info",
54374
57136
  key: "__alexa-status",
54375
57137
  label: "Status",
54376
- 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",
57138
+ content: statusLabel,
54377
57139
  variant: "info"
54378
57140
  },
54379
57141
  {
54380
57142
  type: "boolean",
54381
57143
  key: enabledKey,
54382
57144
  label: "Expose to Alexa",
54383
- description: "Toggle to mirror this camera onto the Alexa Smart Home skill.",
57145
+ description: "Toggle to mirror this device onto the Alexa Smart Home skill.",
54384
57146
  style: "switch",
54385
57147
  value: enabled,
54386
57148
  immediate: true
54387
57149
  },
54388
- {
54389
- type: "select",
54390
- key: streamPreferenceKey,
54391
- label: "Source stream (Alexa / Echo)",
54392
- 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).",
54393
- options: STREAM_PREFERENCE_OPTIONS,
54394
- required: true,
54395
- value: settings.streamPreference,
54396
- showWhen: {
54397
- field: enabledKey,
54398
- equals: true
54399
- },
54400
- immediate: true
54401
- },
54402
- {
54403
- type: "boolean",
54404
- key: allowTranscodeKey,
54405
- label: "Allow transcoding for Alexa",
54406
- 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.",
54407
- style: "switch",
54408
- value: settings.allowTranscode !== false,
54409
- showWhen: {
54410
- field: enabledKey,
54411
- equals: true
54412
- },
54413
- immediate: true
54414
- }
57150
+ ...kind === "camera" ? this.cameraOnlyExportFields(deviceId, settings, enabledKey) : []
54415
57151
  ]
54416
57152
  }]
54417
57153
  };
54418
57154
  }
57155
+ /** The stream fields of the Export section — a camera's only. */
57156
+ cameraOnlyExportFields(deviceId, settings, enabledKey) {
57157
+ const streamPreferenceKey = `alexa:${deviceId}:streamPreference`;
57158
+ const allowTranscodeKey = `alexa:${deviceId}:allowTranscode`;
57159
+ return [{
57160
+ type: "select",
57161
+ key: streamPreferenceKey,
57162
+ label: "Source stream (Alexa / Echo)",
57163
+ 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).",
57164
+ options: STREAM_PREFERENCE_OPTIONS,
57165
+ required: true,
57166
+ value: settings.streamPreference,
57167
+ showWhen: {
57168
+ field: enabledKey,
57169
+ equals: true
57170
+ },
57171
+ immediate: true
57172
+ }, {
57173
+ type: "boolean",
57174
+ key: allowTranscodeKey,
57175
+ label: "Allow transcoding for Alexa",
57176
+ 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.",
57177
+ style: "switch",
57178
+ value: settings.allowTranscode !== false,
57179
+ showWhen: {
57180
+ field: enabledKey,
57181
+ equals: true
57182
+ },
57183
+ immediate: true
57184
+ }];
57185
+ }
54419
57186
  /**
54420
57187
  * Apply a patch produced by the device-details "Export" panel. Flips
54421
57188
  * the expose/unexpose lifecycle on `enabled` transitions and persists
@@ -54481,13 +57248,20 @@ var ExportAlexaAddon = class extends BaseAddon {
54481
57248
  }
54482
57249
  return { success: true };
54483
57250
  }
54484
- async isCameraDevice(deviceId) {
57251
+ /**
57252
+ * Which Export section a device gets: the camera one (with stream
57253
+ * settings), the generic one (on/off only — R5, D775), or none. An
57254
+ * unreadable device reads as a camera, as before.
57255
+ */
57256
+ async exportKindOf(deviceId) {
54485
57257
  try {
54486
57258
  const dev = await this.ctx.api.deviceManager.getDevice.query({ deviceId });
54487
- if (!dev) return true;
54488
- return dev.type === DeviceType.Camera;
57259
+ if (!dev) return "camera";
57260
+ const type = String(dev.type);
57261
+ if (type === DeviceType.Camera) return "camera";
57262
+ return GENERIC_DEVICE_TYPES.includes(type) ? "generic" : null;
54489
57263
  } catch {
54490
- return true;
57264
+ return "camera";
54491
57265
  }
54492
57266
  }
54493
57267
  findEntry(deviceId) {