@camstack/addon-import-alexa 0.2.177 → 0.2.178

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.
package/dist/addon.mjs CHANGED
@@ -6033,7 +6033,7 @@ function number(params) {
6033
6033
  return /* @__PURE__ */ _coercedNumber(ZodNumber, params);
6034
6034
  }
6035
6035
  //#endregion
6036
- //#region ../types/dist/sleep-_4vKNWxb.mjs
6036
+ //#region ../types/dist/sleep-B-vp7x15.mjs
6037
6037
  /**
6038
6038
  * The audio chunk plane's byte format, and the ONE expansion from a coded
6039
6039
  * window to float samples (D455).
@@ -9764,6 +9764,7 @@ var CAP_NAMES = Object.freeze([
9764
9764
  "device-status",
9765
9765
  "dishwasher",
9766
9766
  "display-palette",
9767
+ "do-not-disturb",
9767
9768
  "doorbell",
9768
9769
  "embedding-encoder",
9769
9770
  "enum-sensor",
@@ -10523,6 +10524,10 @@ Object.freeze({
10523
10524
  "valueType": "string"
10524
10525
  }
10525
10526
  ],
10527
+ "do-not-disturb": [{
10528
+ "path": "enabled",
10529
+ "valueType": "boolean"
10530
+ }],
10526
10531
  "doorbell": [{
10527
10532
  "path": "lastPressedAt",
10528
10533
  "valueType": "number"
@@ -20062,6 +20067,33 @@ var APPLIANCE_CAP_VOCABULARY = {
20062
20067
  }
20063
20068
  ]
20064
20069
  },
20070
+ "do-not-disturb": {
20071
+ scope: "device",
20072
+ noun: t("Do not disturb", "Non disturbare"),
20073
+ actions: [{
20074
+ id: "dnd.on",
20075
+ method: "setEnabled",
20076
+ label: t("Do not disturb on", "Attiva Non disturbare"),
20077
+ phrase: t("Turn on Do not disturb on {device}", "Attiva Non disturbare su {device}"),
20078
+ icon: "bell-off",
20079
+ args: { enabled: true }
20080
+ }, {
20081
+ id: "dnd.off",
20082
+ method: "setEnabled",
20083
+ label: t("Do not disturb off", "Disattiva Non disturbare"),
20084
+ phrase: t("Turn off Do not disturb on {device}", "Disattiva Non disturbare su {device}"),
20085
+ icon: "bell",
20086
+ args: { enabled: false }
20087
+ }],
20088
+ state: [{
20089
+ kind: "boolean",
20090
+ field: "enabled",
20091
+ label: t("Do not disturb", "Non disturbare"),
20092
+ whenTrue: t("When Do not disturb turns on on {device}", "Quando {device} attiva Non disturbare"),
20093
+ whenFalse: t("When Do not disturb turns off on {device}", "Quando {device} disattiva Non disturbare"),
20094
+ icon: "bell-off"
20095
+ }]
20096
+ },
20065
20097
  update: {
20066
20098
  scope: "device",
20067
20099
  noun: t("Firmware update", "Aggiornamento firmware"),
@@ -42178,14 +42210,16 @@ var DeviceIsLeafSchema = object({
42178
42210
  });
42179
42211
  /**
42180
42212
  * True when the camera currently has a PARKED object matching `subjects` in at
42181
- * least one of `zones`. Reads the zone-analytics snapshot through the same
42213
+ * least one of `zones` (`minCount` of them, default one). Reads the zone-analytics snapshot through the same
42182
42214
  * mirror as every other state leaf, so a dropped event never decides it (D49).
42183
42215
  */
42184
42216
  var StationaryPresentLeafSchema = object({
42185
42217
  kind: literal("stationary-present"),
42186
42218
  deviceId: number$1().int().nonnegative(),
42187
42219
  subjects: array(StationarySubjectSchema).min(1).optional(),
42188
- zones: array(string().min(1)).min(1).optional()
42220
+ zones: array(string().min(1)).min(1).optional(),
42221
+ /** How many matching parked objects must be there; absent means one. */
42222
+ minCount: number$1().int().positive().optional()
42189
42223
  });
42190
42224
  /**
42191
42225
  * The escape hatch: the bounded expression engine over named device bindings.
@@ -42697,6 +42731,28 @@ function convertLegacyRecipe(legacy) {
42697
42731
  * it. The output type is always the new one.
42698
42732
  */
42699
42733
  var AutomationRecipeSchema = union([CurrentAutomationRecipeSchema, LegacyAutomationRecipeSchema.transform(convertLegacyRecipe)]);
42734
+ var DETECTION_DESCRIPTIONS = {
42735
+ deviceId: "the camera that saw it",
42736
+ className: "what it saw",
42737
+ trackId: "the track",
42738
+ score: "how sure the detector was",
42739
+ label: "the label it matched (a name, a plate), empty when none"
42740
+ };
42741
+ var STATIONARY_DESCRIPTIONS = {
42742
+ deviceId: "the camera that saw it",
42743
+ className: "what it saw",
42744
+ zones: "the zones it is in",
42745
+ phase: "appeared or departed",
42746
+ entryId: "the stopped object"
42747
+ };
42748
+ function describe$1(descriptions) {
42749
+ return Object.entries(descriptions).map(([name, description]) => ({
42750
+ name,
42751
+ description
42752
+ }));
42753
+ }
42754
+ describe$1(DETECTION_DESCRIPTIONS);
42755
+ describe$1(STATIONARY_DESCRIPTIONS);
42700
42756
  new Set(Object.values(COCO_TO_MACRO.mapping));
42701
42757
  /**
42702
42758
  * Schedulers (D983) — an entity that performs actions over the day.
@@ -53377,6 +53433,49 @@ method(_void(), paletteResultSchema, {
53377
53433
  kind: "mutation",
53378
53434
  auth: "admin"
53379
53435
  });
53436
+ /**
53437
+ * do-not-disturb - whether a speaker or display holds back its own sounds
53438
+ * (announcements, notifications, alarms) until the operator lets it speak again.
53439
+ *
53440
+ * It is NOT part of `media-player`: a player is about what it plays and how
53441
+ * loud, while Do Not Disturb is a household switch the vendor keeps per device.
53442
+ * `enabled` is null when the vendor has not said (never a guess of `false`).
53443
+ */
53444
+ var DoNotDisturbStatusSchema = object({
53445
+ /** True while Do Not Disturb is on; null when unknown. */
53446
+ enabled: boolean().nullable().meta({ label: "Do not disturb" }),
53447
+ /** Ms epoch of the last read or write that set `enabled`. */
53448
+ lastChangedAt: number$1()
53449
+ });
53450
+ var doNotDisturbCapability = {
53451
+ name: "do-not-disturb",
53452
+ scope: "device",
53453
+ deviceNative: true,
53454
+ mode: "singleton",
53455
+ deviceTypes: [DeviceType.MediaPlayer],
53456
+ methods: { setEnabled: method(object({
53457
+ deviceId: number$1().int(),
53458
+ enabled: boolean()
53459
+ }), _void(), {
53460
+ kind: "mutation",
53461
+ auth: "protected"
53462
+ }) },
53463
+ status: {
53464
+ schema: DoNotDisturbStatusSchema,
53465
+ kind: "push"
53466
+ },
53467
+ runtimeState: DoNotDisturbStatusSchema,
53468
+ /**
53469
+ * Runtime-state durability: **session** - the vendor owns the switch and the
53470
+ * device re-reads it on the next poll, so a restored value would only be stale.
53471
+ *
53472
+ * See `RuntimeStateDurability`. Enforced by
53473
+ * `scripts/check-runtime-state-durability.ts`.
53474
+ */
53475
+ durability: "session",
53476
+ /** History (D850): when Do Not Disturb was switched on and off. */
53477
+ history: { fields: { enabled: { kind: "boolean" } } }
53478
+ };
53380
53479
  /** Pulse override: a rising edge advances `lastPressedAt` and the press count. */
53381
53480
  var doorbellCustomize = {
53382
53481
  version: 1,
@@ -55968,6 +56067,14 @@ var LlmGenerateBaseInputSchema = object({
55968
56067
  topP: number$1().min(0).max(1).optional(),
55969
56068
  /** Per-call override of the profile default (top-k sampling). */
55970
56069
  topK: number$1().int().positive().optional(),
56070
+ /** Per-call override of `profile.reasoningEffort`. Sent only to a kind whose
56071
+ * descriptor offers the field; a kind that does not never sends it. */
56072
+ reasoningEffort: _enum([
56073
+ "off",
56074
+ "low",
56075
+ "medium",
56076
+ "high"
56077
+ ]).optional(),
55971
56078
  /** Per-call override of `profile.timeoutMs` — the total generation bound. */
55972
56079
  timeoutMs: number$1().int().positive().optional(),
55973
56080
  /** Per-call override; beats both the consumer table and the profile. */
@@ -56259,7 +56366,10 @@ var LlmProfileKindSchema = string().regex(/^[a-z0-9][a-z0-9-]{0,63}$/, "a kind i
56259
56366
  var LlmModelFeaturesSchema = object({
56260
56367
  vision: boolean(),
56261
56368
  tools: boolean(),
56262
- streaming: boolean()
56369
+ streaming: boolean(),
56370
+ /** Whether the model thinks before it answers. `null` = the endpoint does not
56371
+ * say; an editor then still offers the switch (unknown is not "no"). */
56372
+ reasoning: boolean().nullable().optional()
56263
56373
  });
56264
56374
  /** One model a provider offers. `features` overrides the kind's defaults, per field. */
56265
56375
  var LlmModelInfoSchema = object({
@@ -74445,9 +74555,17 @@ var ServerUpdateActionResultSchema = object({
74445
74555
  */
74446
74556
  requiredAddonUpdates: array(RequiredAddonUpdateSchema).optional()
74447
74557
  });
74448
- var LaunchOverridesSchema = object({
74449
- /** The persisted heap ceiling in MB, or `null` when none is set. */
74450
- heapMb: number$1().int().nullable() });
74558
+ var LaunchOverridesSchema = object({
74559
+ /** The persisted heap ceiling in MB, or `null` when none is set. */
74560
+ heapMb: number$1().int().nullable(),
74561
+ /** The persisted hub-main RSS budget in MB, or `null` when none is set. */
74562
+ rssBudgetMb: number$1().int().nullable()
74563
+ });
74564
+ var SetLaunchOverridesInputSchema = object({
74565
+ heapMb: number$1().int().min(512).max(65536).nullable(),
74566
+ /** Omitted keeps the stored budget; `null` clears it. */
74567
+ rssBudgetMb: number$1().int().min(512).max(65536).nullable().optional()
74568
+ });
74451
74569
  var SetLaunchOverridesResultSchema = LaunchOverridesSchema.extend({
74452
74570
  /** Always true: the override is read at launch, not by the running process. */
74453
74571
  restartRequired: boolean() });
@@ -74476,7 +74594,7 @@ method(_void(), ServerPackageStatusSchema, { auth: "admin" }), method(_void(), S
74476
74594
  }), method(_void(), ServerUpdateActionResultSchema, {
74477
74595
  kind: "mutation",
74478
74596
  auth: "admin"
74479
- }), method(_void(), LaunchOverridesSchema, { auth: "admin" }), method(object({ heapMb: number$1().int().min(512).max(65536).nullable() }), SetLaunchOverridesResultSchema, {
74597
+ }), method(_void(), LaunchOverridesSchema, { auth: "admin" }), method(SetLaunchOverridesInputSchema, SetLaunchOverridesResultSchema, {
74480
74598
  kind: "mutation",
74481
74599
  auth: "admin"
74482
74600
  });
@@ -79582,6 +79700,7 @@ var DEVICE_LOCAL_STATE_CAPS = {
79582
79700
  deviceDiscovery: deviceDiscoveryCapability,
79583
79701
  deviceStatus: deviceStatusCapability,
79584
79702
  dishwasher: dishwasherCapability,
79703
+ doNotDisturb: doNotDisturbCapability,
79585
79704
  doorbell: doorbellCapability,
79586
79705
  enumSensor: enumSensorCapability,
79587
79706
  eventEmitter: eventEmitterCapability,
@@ -84035,6 +84154,20 @@ Object.freeze({
84035
84154
  access: "create",
84036
84155
  role: "admin"
84037
84156
  },
84157
+ "doNotDisturb.getStatus": {
84158
+ capName: "do-not-disturb",
84159
+ capScope: "device",
84160
+ addonId: null,
84161
+ access: "view",
84162
+ role: "viewer"
84163
+ },
84164
+ "doNotDisturb.setEnabled": {
84165
+ capName: "do-not-disturb",
84166
+ capScope: "device",
84167
+ addonId: null,
84168
+ access: "create",
84169
+ role: "viewer"
84170
+ },
84038
84171
  "doorbell.getStatus": {
84039
84172
  capName: "doorbell",
84040
84173
  capScope: "device",
@@ -92223,6 +92356,16 @@ Object.freeze({
92223
92356
  form: "single",
92224
92357
  optional: false
92225
92358
  }],
92359
+ "doNotDisturb.getStatus": [{
92360
+ name: "deviceId",
92361
+ form: "single",
92362
+ optional: false
92363
+ }],
92364
+ "doNotDisturb.setEnabled": [{
92365
+ name: "deviceId",
92366
+ form: "single",
92367
+ optional: false
92368
+ }],
92226
92369
  "doorbell.getStatus": [{
92227
92370
  name: "deviceId",
92228
92371
  form: "single",
@@ -140378,6 +140521,13 @@ var ALEXA_BROKER_KIND = "alexa";
140378
140521
  var DEVICE_REFRESH_MS = 5 * 6e4;
140379
140522
  /** Where the addon serves the Alexa mark; `listTargetKinds` stamps it as `iconUrl`. */
140380
140523
  var ALEXA_SPEAK_ICON_URL = `/addon/${ALEXA_SPEAK_ADDON_ID}/icons/${ALEXA_SPEAK_KIND}`;
140524
+ /** How often an adopted Echo's player state is read (a push channel is a later phase). */
140525
+ var ECHO_POLL_MS = 3e4;
140526
+ /** One more read after a command, once Amazon has had time to apply it. */
140527
+ var ECHO_REREAD_MS = 1500;
140528
+ /** After a 429 the account is left alone for this long, doubling up to the cap. */
140529
+ var ECHO_RATE_BACKOFF_BASE_MS = 3e4;
140530
+ var ECHO_RATE_BACKOFF_MAX_MS = 5 * 6e4;
140381
140531
  //#endregion
140382
140532
  //#region src/alexa-broker-provider.ts
140383
140533
  /**
@@ -140607,6 +140757,37 @@ function buildAlexaApplianceCandidate(input) {
140607
140757
  function mappableAppliance(appliance) {
140608
140758
  return mapAlexaCapabilitiesToCamStack(appliance.interfaces).capabilities.length > 0;
140609
140759
  }
140760
+ /** `echo:<serial>`: how an Echo is named among an account's adoption candidates. */
140761
+ var ECHO_NATIVE_ID_PREFIX = "echo:";
140762
+ function echoNativeId(serial) {
140763
+ return `${ECHO_NATIVE_ID_PREFIX}${serial}`;
140764
+ }
140765
+ /** The serial an Echo candidate id names, or null when the id is not an Echo's. */
140766
+ function echoSerialOf(childNativeId) {
140767
+ if (!childNativeId.startsWith("echo:")) return null;
140768
+ const serial = childNativeId.slice(5);
140769
+ return serial === "" ? null : serial;
140770
+ }
140771
+ /** An Echo the adoption list offers: a media player, a flat sibling under the Alexa integration. */
140772
+ function isAdoptableEcho(device) {
140773
+ return device.kind === "device";
140774
+ }
140775
+ function buildAlexaEchoCandidate(input) {
140776
+ const { device, adoptedDeviceId } = input;
140777
+ return {
140778
+ childNativeId: echoNativeId(device.id),
140779
+ name: device.name.length > 0 ? device.name : device.id,
140780
+ type: DeviceType.MediaPlayer,
140781
+ status: device.online ? "online" : "offline",
140782
+ metadata: {
140783
+ manufacturer: "Amazon",
140784
+ ...device.room !== void 0 ? { externalLocation: device.room } : {}
140785
+ },
140786
+ alreadyAdopted: adoptedDeviceId !== null,
140787
+ adoptedDeviceId,
140788
+ capabilities: ["media-player", "do-not-disturb"]
140789
+ };
140790
+ }
140610
140791
  //#endregion
140611
140792
  //#region src/alexa-adoption-provider.ts
140612
140793
  /** Single granularity: each Alexa appliance adopts as one device. */
@@ -140620,6 +140801,11 @@ async function adoptedApplianceId(brokerId, applianceId, listAdoptedAlexa) {
140620
140801
  for (const d of await listAdoptedAlexa()) if (d.config["system"] === "alexa" && d.config["brokerId"] === brokerId && d.config["applianceId"] === applianceId) return d.id;
140621
140802
  return null;
140622
140803
  }
140804
+ /** Resolve the adopted CamStack device id for a (broker, Echo serial), or null. */
140805
+ async function adoptedEchoId(brokerId, serial, listAdoptedAlexa) {
140806
+ for (const d of await listAdoptedAlexa()) if (d.config["system"] === "alexa" && d.config["brokerId"] === brokerId && d.config["serial"] === serial) return d.id;
140807
+ return null;
140808
+ }
140623
140809
  /**
140624
140810
  * Construct the `device-adoption` cap provider for the Alexa import addon. Pure
140625
140811
  * builder. One broker = one Amazon account; each mappable appliance is a flat
@@ -140627,7 +140813,7 @@ async function adoptedApplianceId(brokerId, applianceId, listAdoptedAlexa) {
140627
140813
  * Container fan-out — Alexa appliances are standalone devices).
140628
140814
  */
140629
140815
  function buildAlexaAdoptionProvider(deps) {
140630
- const { registry, getBrokerIdForIntegration, listAdoptedAlexa, adoptAppliance, removeDevice, findDeviceConfig, logger } = deps;
140816
+ const { registry, getBrokerIdForIntegration, listAdoptedAlexa, adoptAppliance, adoptEcho, removeDevice, findDeviceConfig, logger } = deps;
140631
140817
  async function candidatesForBroker(brokerId) {
140632
140818
  const appliances = registry.getAppliancesForBroker(brokerId).filter(mappableAppliance);
140633
140819
  const out = [];
@@ -140638,6 +140824,13 @@ function buildAlexaAdoptionProvider(deps) {
140638
140824
  adoptedDeviceId
140639
140825
  }));
140640
140826
  }
140827
+ for (const device of registry.getEchoesForBroker(brokerId).filter(isAdoptableEcho)) {
140828
+ const adoptedDeviceId = await adoptedEchoId(brokerId, device.id, listAdoptedAlexa);
140829
+ out.push(buildAlexaEchoCandidate({
140830
+ device,
140831
+ adoptedDeviceId
140832
+ }));
140833
+ }
140641
140834
  return out;
140642
140835
  }
140643
140836
  return {
@@ -140692,7 +140885,41 @@ function buildAlexaAdoptionProvider(deps) {
140692
140885
  const appliances = registry.getAppliancesForBroker(brokerId);
140693
140886
  const adopted = [];
140694
140887
  let failures = 0;
140888
+ const echoes = registry.getEchoesForBroker(brokerId);
140695
140889
  for (const nativeId of childNativeIds) {
140890
+ const echoSerial = echoSerialOf(nativeId);
140891
+ if (echoSerial !== null) {
140892
+ const echo = echoes.find((device) => device.id === echoSerial && isAdoptableEcho(device));
140893
+ if (echo === void 0) {
140894
+ logger.warn("alexa adopt: Echo not found in the account — skipping", { meta: {
140895
+ nativeId,
140896
+ brokerId
140897
+ } });
140898
+ failures++;
140899
+ continue;
140900
+ }
140901
+ try {
140902
+ const { deviceId } = await adoptEcho({
140903
+ brokerId,
140904
+ echo,
140905
+ integrationId,
140906
+ name: perCandidate?.[nativeId]?.name ?? echo.name
140907
+ });
140908
+ adopted.push({
140909
+ childNativeId: nativeId,
140910
+ parentDeviceId: deviceId,
140911
+ accessoryDeviceIds: []
140912
+ });
140913
+ } catch (err) {
140914
+ logger.warn("alexa adopt: failed to adopt Echo", { meta: {
140915
+ brokerId,
140916
+ serial: echoSerial,
140917
+ error: errMsg(err)
140918
+ } });
140919
+ failures++;
140920
+ }
140921
+ continue;
140922
+ }
140696
140923
  const appliance = appliances.find((a) => a.applianceId === nativeId);
140697
140924
  if (appliance === void 0) {
140698
140925
  logger.warn("alexa adopt: appliance not found in inventory — skipping", { meta: {
@@ -140785,6 +141012,7 @@ function setBrokerDevicesOnline(devices, brokerId, online) {
140785
141012
  for (const dev of devices) {
140786
141013
  if (dev.config.get("system") !== "alexa") continue;
140787
141014
  if (dev.config.get("brokerId") !== brokerId) continue;
141015
+ if (typeof dev.config.get("serial") === "string") continue;
140788
141016
  if (dev.online === online) continue;
140789
141017
  dev.markOnline(online);
140790
141018
  count += 1;
@@ -141192,6 +141420,421 @@ var AlexaDevice = class extends BaseDevice {
141192
141420
  function isKnownCap(cap) {
141193
141421
  return KNOWN_CAPS.has(cap);
141194
141422
  }
141423
+ var EchoSessionRegistry = class {
141424
+ #resolve = null;
141425
+ #dnd = /* @__PURE__ */ new Map();
141426
+ setResolver(fn) {
141427
+ this.#resolve = fn;
141428
+ }
141429
+ get(brokerId) {
141430
+ return this.#resolve === null ? null : this.#resolve(brokerId);
141431
+ }
141432
+ /** Do Not Disturb of every device of the account. A failed read is not kept for the next device. */
141433
+ dnd(brokerId, now) {
141434
+ const shared = this.#dnd.get(brokerId);
141435
+ if (shared !== void 0 && now - shared.at < 25e3) return shared.pending;
141436
+ const session = this.get(brokerId);
141437
+ if (session === null) return Promise.reject(/* @__PURE__ */ new Error("no Echo session for this account"));
141438
+ const pending = session.dndState();
141439
+ this.#dnd.set(brokerId, {
141440
+ at: now,
141441
+ pending
141442
+ });
141443
+ pending.catch(() => {
141444
+ if (this.#dnd.get(brokerId)?.pending === pending) this.#dnd.delete(brokerId);
141445
+ });
141446
+ return pending;
141447
+ }
141448
+ clear() {
141449
+ this.#resolve = null;
141450
+ this.#dnd.clear();
141451
+ }
141452
+ };
141453
+ /** The single in-process registry shared across the addon. */
141454
+ var alexaEchoSessions = new EchoSessionRegistry();
141455
+ //#endregion
141456
+ //#region src/alexa-echo-state-to-cap.ts
141457
+ /** The slice of an Echo that cannot be read: every reading null, the state as given. */
141458
+ function emptyEchoStatus(state, now) {
141459
+ return {
141460
+ state,
141461
+ stateSource: "reported",
141462
+ assumedFields: [],
141463
+ volumeLevel: null,
141464
+ isMuted: null,
141465
+ source: "",
141466
+ availableSources: [],
141467
+ currentMedia: null,
141468
+ position: null,
141469
+ duration: null,
141470
+ shuffle: null,
141471
+ repeat: null,
141472
+ lastChangedAt: now
141473
+ };
141474
+ }
141475
+ function mapEchoPlaybackState(value) {
141476
+ switch (value) {
141477
+ case "PLAYING": return "playing";
141478
+ case "PAUSED": return "paused";
141479
+ case "BUFFERING": return "buffering";
141480
+ default: return "idle";
141481
+ }
141482
+ }
141483
+ function percent(value) {
141484
+ if (typeof value !== "number" || !Number.isFinite(value)) return null;
141485
+ return Math.round(Math.max(0, Math.min(100, value)));
141486
+ }
141487
+ function secondsToMs(value) {
141488
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return null;
141489
+ return Math.round(value * 1e3);
141490
+ }
141491
+ function nonEmpty(value) {
141492
+ return typeof value === "string" && value !== "" ? value : null;
141493
+ }
141494
+ /** `SELECTED` is on, `UNSELECTED` is off; `HIDDEN` and `DISABLED` mean the surface does not exist. */
141495
+ function shuffleOf(value) {
141496
+ if (value === "SELECTED") return true;
141497
+ if (value === "UNSELECTED") return false;
141498
+ return null;
141499
+ }
141500
+ function repeatOf(value) {
141501
+ if (value === "SELECTED") return "all";
141502
+ if (value === "UNSELECTED") return "off";
141503
+ return null;
141504
+ }
141505
+ function currentMediaOf(info) {
141506
+ const title = nonEmpty(info.infoText?.title);
141507
+ if (title === null) return null;
141508
+ const artist = nonEmpty(info.infoText?.subText1);
141509
+ const album = nonEmpty(info.infoText?.subText2);
141510
+ const imageUrl = nonEmpty(info.mainArt?.url);
141511
+ return {
141512
+ type: "unknown",
141513
+ title,
141514
+ ...artist !== null ? { artist } : {},
141515
+ ...album !== null ? { album } : {},
141516
+ ...imageUrl !== null ? { imageUrl } : {}
141517
+ };
141518
+ }
141519
+ function mapPlayerInfoToStatus(info, input) {
141520
+ if (!input.online) return emptyEchoStatus("off", input.now);
141521
+ const length = secondsToMs(info.progress?.mediaLength);
141522
+ return {
141523
+ ...emptyEchoStatus(mapEchoPlaybackState(info.state), input.now),
141524
+ volumeLevel: percent(info.volume?.volume),
141525
+ isMuted: typeof info.volume?.muted === "boolean" ? info.volume.muted : null,
141526
+ currentMedia: currentMediaOf(info),
141527
+ position: secondsToMs(info.progress?.mediaProgress),
141528
+ duration: length === 0 ? null : length,
141529
+ shuffle: shuffleOf(info.transport?.shuffle),
141530
+ repeat: repeatOf(info.transport?.repeat)
141531
+ };
141532
+ }
141533
+ //#endregion
141534
+ //#region src/speak/worker-protocol.ts
141535
+ /** Line-delimited JSON the long-lived Python worker speaks. One object per line. */
141536
+ var EchoDeviceSchema = object({
141537
+ id: string().min(1),
141538
+ name: string(),
141539
+ family: string(),
141540
+ online: boolean(),
141541
+ kind: _enum(["device", "group"]),
141542
+ dnd: boolean(),
141543
+ deviceType: string(),
141544
+ /** The room the account files the device under, when Amazon names one. */
141545
+ room: string().optional()
141546
+ });
141547
+ var LoginStatusSchema = _enum(["ok", "failed"]);
141548
+ var SessionStatusSchema = object({
141549
+ loggedIn: boolean(),
141550
+ ageSec: number$1().int().nonnegative(),
141551
+ lastSuccessAt: number$1().int().nullable()
141552
+ });
141553
+ var TargetDeliverySchema = object({
141554
+ targetId: string(),
141555
+ ok: boolean(),
141556
+ error: string().optional()
141557
+ });
141558
+ var WorkerLineSchema = union([object({
141559
+ id: string(),
141560
+ ok: literal(true),
141561
+ result: unknown().optional()
141562
+ }), object({
141563
+ id: string(),
141564
+ ok: literal(false),
141565
+ error: string(),
141566
+ /** A typed reason the worker names; `rate-limited` is Amazon's 429. */
141567
+ code: string().optional()
141568
+ })]);
141569
+ var RATE_LIMITED_CODE = "rate-limited";
141570
+ /** Amazon answered 429. Never an empty answer: the caller backs off and keeps what it had. */
141571
+ var RateLimitedError = class extends Error {
141572
+ code = RATE_LIMITED_CODE;
141573
+ constructor(message) {
141574
+ super(message);
141575
+ this.name = "RateLimitedError";
141576
+ }
141577
+ };
141578
+ function requestLine(id, request) {
141579
+ return `${JSON.stringify({
141580
+ id,
141581
+ ...request
141582
+ })}\n`;
141583
+ }
141584
+ function parseLoginResult(result) {
141585
+ const parsed = object({
141586
+ status: LoginStatusSchema,
141587
+ error: string().optional()
141588
+ }).safeParse(result);
141589
+ if (!parsed.success) return {
141590
+ status: "failed",
141591
+ error: "worker returned an unexpected login result"
141592
+ };
141593
+ return parsed.data;
141594
+ }
141595
+ function parseDevices(result) {
141596
+ const parsed = object({ devices: array(EchoDeviceSchema) }).safeParse(result);
141597
+ if (!parsed.success) throw new Error("worker returned an unexpected device list");
141598
+ return parsed.data.devices;
141599
+ }
141600
+ function parseDeliveries(result) {
141601
+ const parsed = object({ outcomes: array(TargetDeliverySchema) }).safeParse(result);
141602
+ if (!parsed.success) throw new Error("worker returned an unexpected delivery list");
141603
+ return parsed.data.outcomes;
141604
+ }
141605
+ function parseSessionStatus(result) {
141606
+ const parsed = SessionStatusSchema.safeParse(result);
141607
+ if (!parsed.success) throw new Error("worker returned an unexpected session status");
141608
+ return parsed.data;
141609
+ }
141610
+ /**
141611
+ * The raw `playerInfo` of `/api/np/player`. Amazon omits what it does not know,
141612
+ * so every field is nullable; the mapping to the media-player cap turns a missing
141613
+ * value into null, never into 0 (D393).
141614
+ */
141615
+ var PlayerInfoSchema = object({
141616
+ state: string().nullish(),
141617
+ volume: object({
141618
+ volume: number$1().nullish(),
141619
+ muted: boolean().nullish()
141620
+ }).nullish(),
141621
+ infoText: object({
141622
+ title: string().nullish(),
141623
+ subText1: string().nullish(),
141624
+ subText2: string().nullish()
141625
+ }).nullish(),
141626
+ mainArt: object({ url: string().nullish() }).nullish(),
141627
+ progress: object({
141628
+ mediaLength: number$1().nullish(),
141629
+ mediaProgress: number$1().nullish()
141630
+ }).nullish(),
141631
+ transport: object({
141632
+ shuffle: string().nullish(),
141633
+ repeat: string().nullish()
141634
+ }).nullish()
141635
+ });
141636
+ function parsePlayerState(result) {
141637
+ const parsed = object({ playerInfo: PlayerInfoSchema.nullish() }).safeParse(result);
141638
+ if (!parsed.success) throw new Error("worker returned an unexpected player state");
141639
+ return parsed.data.playerInfo ?? {};
141640
+ }
141641
+ var DndEntrySchema = object({
141642
+ serial: string().min(1),
141643
+ enabled: boolean()
141644
+ });
141645
+ function parseDndState(result) {
141646
+ const parsed = object({ devices: array(DndEntrySchema) }).safeParse(result);
141647
+ if (!parsed.success) throw new Error("worker returned an unexpected Do Not Disturb list");
141648
+ return new Map(parsed.data.devices.map((entry) => [entry.serial, entry.enabled]));
141649
+ }
141650
+ //#endregion
141651
+ //#region src/devices/alexa-echo-device.ts
141652
+ /**
141653
+ * An Echo is a media-player device. Identity only: the account (`brokerId`) and
141654
+ * the Echo's serial. The Echo's notification target is a separate record linked
141655
+ * by the same (brokerId, serial); nothing here sets or reads a notification.
141656
+ */
141657
+ var alexaEchoDeviceSchema = object({
141658
+ brokerId: string().min(1).describe("Registry broker id"),
141659
+ serial: string().min(1).describe("The Echo serial number"),
141660
+ deviceType: string().default("").describe("Amazon device type id"),
141661
+ system: literal("alexa").optional(),
141662
+ integrationId: string().optional()
141663
+ });
141664
+ var COLD_DND = {
141665
+ enabled: null,
141666
+ lastChangedAt: 0
141667
+ };
141668
+ function unsupported(what) {
141669
+ throw new Error(`${what} is not available on an Echo`);
141670
+ }
141671
+ var AlexaEchoDevice = class extends BaseDevice {
141672
+ features = [
141673
+ DeviceFeature.MediaPlayerVolume,
141674
+ DeviceFeature.MediaPlayerNext,
141675
+ DeviceFeature.MediaPlayerPrevious
141676
+ ];
141677
+ brokerId;
141678
+ serial;
141679
+ amazonDeviceType;
141680
+ timer = null;
141681
+ rereadTimer = null;
141682
+ polling = false;
141683
+ failureLogged = false;
141684
+ health = "ok";
141685
+ constructor(ctx) {
141686
+ const persisted = alexaEchoDeviceSchema.parse(ctx.persistedConfig ?? {});
141687
+ super(ctx, alexaEchoDeviceSchema, { type: DeviceType.MediaPlayer });
141688
+ this.brokerId = persisted.brokerId;
141689
+ this.serial = persisted.serial;
141690
+ this.amazonDeviceType = persisted.deviceType;
141691
+ }
141692
+ /** The outcome of the last read, for the broker status and the specs. */
141693
+ get pollHealth() {
141694
+ return this.health;
141695
+ }
141696
+ async onActivate() {
141697
+ await super.onActivate();
141698
+ this.registerMediaPlayer();
141699
+ this.registerDoNotDisturb();
141700
+ this.runtimeState.setCapState("media-player", emptyEchoStatus("off", 0));
141701
+ this.runtimeState.setCapState("do-not-disturb", COLD_DND);
141702
+ this.poll();
141703
+ this.timer = setInterval(() => void this.poll(), ECHO_POLL_MS);
141704
+ this.timer.unref();
141705
+ }
141706
+ async removeDevice() {
141707
+ if (this.timer !== null) clearInterval(this.timer);
141708
+ if (this.rereadTimer !== null) clearTimeout(this.rereadTimer);
141709
+ this.timer = null;
141710
+ this.rereadTimer = null;
141711
+ await super.removeDevice();
141712
+ }
141713
+ session() {
141714
+ const session = alexaEchoSessions.get(this.brokerId);
141715
+ if (session === null) throw new Error("the Amazon account of this Echo is not connected");
141716
+ return session;
141717
+ }
141718
+ /** One re-read shortly after a command, once Amazon has applied it. */
141719
+ rereadSoon() {
141720
+ if (this.rereadTimer !== null) clearTimeout(this.rereadTimer);
141721
+ this.rereadTimer = setTimeout(() => {
141722
+ this.rereadTimer = null;
141723
+ this.poll();
141724
+ }, ECHO_REREAD_MS);
141725
+ this.rereadTimer.unref();
141726
+ }
141727
+ /**
141728
+ * Read the player and Do Not Disturb. A failed read leaves both slices as they
141729
+ * were and marks the device degraded; a 429 is a pause, not a fault. Never throws.
141730
+ */
141731
+ async poll() {
141732
+ if (this.polling) return;
141733
+ this.polling = true;
141734
+ try {
141735
+ await this.readOnce();
141736
+ } finally {
141737
+ this.polling = false;
141738
+ }
141739
+ }
141740
+ async readOnce() {
141741
+ const session = alexaEchoSessions.get(this.brokerId);
141742
+ if (session === null || session.state !== "ready") return;
141743
+ if (session.rateLimitedUntil !== null) {
141744
+ this.health = "rate-limited";
141745
+ return;
141746
+ }
141747
+ const listed = session.devices.find((device) => device.id === this.serial);
141748
+ if (listed !== void 0 && !listed.online) {
141749
+ this.runtimeState.setCapState("media-player", emptyEchoStatus("off", Date.now()));
141750
+ this.markOnline(false);
141751
+ this.health = "ok";
141752
+ return;
141753
+ }
141754
+ const deviceType = this.amazonDeviceType !== "" ? this.amazonDeviceType : listed?.deviceType ?? "";
141755
+ try {
141756
+ const info = await session.playerState(this.serial, deviceType);
141757
+ const now = Date.now();
141758
+ this.runtimeState.setCapState("media-player", mapPlayerInfoToStatus(info, {
141759
+ online: true,
141760
+ now
141761
+ }));
141762
+ this.markOnline(true);
141763
+ this.noteRecovered();
141764
+ } catch (err) {
141765
+ this.noteReadFailure(err);
141766
+ return;
141767
+ }
141768
+ await this.readDoNotDisturb();
141769
+ }
141770
+ async readDoNotDisturb() {
141771
+ try {
141772
+ const enabled = (await alexaEchoSessions.dnd(this.brokerId, Date.now())).get(this.serial);
141773
+ if (enabled === void 0) return;
141774
+ this.runtimeState.setCapState("do-not-disturb", {
141775
+ enabled,
141776
+ lastChangedAt: Date.now()
141777
+ });
141778
+ } catch (err) {
141779
+ this.noteReadFailure(err);
141780
+ }
141781
+ }
141782
+ noteRecovered() {
141783
+ if (this.failureLogged) this.ctx.logger.info("alexa echo: reads are working again", { tags: { deviceId: this.id } });
141784
+ this.failureLogged = false;
141785
+ this.health = "ok";
141786
+ }
141787
+ noteReadFailure(err) {
141788
+ if (err instanceof RateLimitedError) {
141789
+ this.health = "rate-limited";
141790
+ this.ctx.logger.info("alexa echo: amazon asked for a pause, keeping the last state", { tags: { deviceId: this.id } });
141791
+ return;
141792
+ }
141793
+ this.health = "degraded";
141794
+ if (this.failureLogged) return;
141795
+ this.failureLogged = true;
141796
+ this.ctx.logger.warn("alexa echo: could not read the player, keeping the last state", {
141797
+ tags: { deviceId: this.id },
141798
+ meta: { error: err instanceof Error ? err.message : String(err) }
141799
+ });
141800
+ }
141801
+ async command(command) {
141802
+ await this.session().npCommand(this.serial, command);
141803
+ this.rereadSoon();
141804
+ }
141805
+ registerMediaPlayer() {
141806
+ this.ctx.registerNativeCap(mediaPlayerCapability, {
141807
+ getStatus: async () => this.runtimeState.getCapState("media-player") ?? emptyEchoStatus("off", 0),
141808
+ play: () => this.command("play"),
141809
+ pause: () => this.command("pause"),
141810
+ next: () => this.command("next"),
141811
+ previous: () => this.command("previous"),
141812
+ setVolume: async ({ volumeLevel }) => {
141813
+ await this.session().setVolume(this.serial, Math.round(volumeLevel));
141814
+ this.rereadSoon();
141815
+ },
141816
+ stop: async () => unsupported("stop"),
141817
+ seek: async () => unsupported("seek"),
141818
+ setMute: async () => unsupported("mute"),
141819
+ setShuffle: async () => unsupported("shuffle"),
141820
+ setRepeat: async () => unsupported("repeat"),
141821
+ selectSource: async () => unsupported("source selection"),
141822
+ playMedia: async () => unsupported("playing media")
141823
+ });
141824
+ }
141825
+ registerDoNotDisturb() {
141826
+ this.ctx.registerNativeCap(doNotDisturbCapability, {
141827
+ getStatus: async () => this.runtimeState.getCapState("do-not-disturb") ?? COLD_DND,
141828
+ setEnabled: async ({ enabled }) => {
141829
+ await this.session().setDnd(this.serial, enabled);
141830
+ this.runtimeState.setCapState("do-not-disturb", {
141831
+ enabled,
141832
+ lastChangedAt: Date.now()
141833
+ });
141834
+ }
141835
+ });
141836
+ }
141837
+ };
141195
141838
  var alexaBrokersEntryKind = defineEntryKind("brokers", {
141196
141839
  title: "Alexa import brokers",
141197
141840
  schema: entryBody({
@@ -141500,78 +142143,14 @@ function scrubSecrets(text, secrets) {
141500
142143
  return out;
141501
142144
  }
141502
142145
  //#endregion
141503
- //#region src/speak/worker-protocol.ts
141504
- /** Line-delimited JSON the long-lived Python worker speaks. One object per line. */
141505
- var EchoDeviceSchema = object({
141506
- id: string().min(1),
141507
- name: string(),
141508
- family: string(),
141509
- online: boolean(),
141510
- kind: _enum(["device", "group"]),
141511
- dnd: boolean(),
141512
- deviceType: string(),
141513
- /** The room the account files the device under, when Amazon names one. */
141514
- room: string().optional()
141515
- });
141516
- var LoginStatusSchema = _enum(["ok", "failed"]);
141517
- var SessionStatusSchema = object({
141518
- loggedIn: boolean(),
141519
- ageSec: number$1().int().nonnegative(),
141520
- lastSuccessAt: number$1().int().nullable()
141521
- });
141522
- var TargetDeliverySchema = object({
141523
- targetId: string(),
141524
- ok: boolean(),
141525
- error: string().optional()
141526
- });
141527
- var WorkerLineSchema = union([object({
141528
- id: string(),
141529
- ok: literal(true),
141530
- result: unknown().optional()
141531
- }), object({
141532
- id: string(),
141533
- ok: literal(false),
141534
- error: string()
141535
- })]);
141536
- function requestLine(id, request) {
141537
- return `${JSON.stringify({
141538
- id,
141539
- ...request
141540
- })}\n`;
141541
- }
141542
- function parseLoginResult(result) {
141543
- const parsed = object({
141544
- status: LoginStatusSchema,
141545
- error: string().optional()
141546
- }).safeParse(result);
141547
- if (!parsed.success) return {
141548
- status: "failed",
141549
- error: "worker returned an unexpected login result"
141550
- };
141551
- return parsed.data;
141552
- }
141553
- function parseDevices(result) {
141554
- const parsed = object({ devices: array(EchoDeviceSchema) }).safeParse(result);
141555
- if (!parsed.success) throw new Error("worker returned an unexpected device list");
141556
- return parsed.data.devices;
141557
- }
141558
- function parseDeliveries(result) {
141559
- const parsed = object({ outcomes: array(TargetDeliverySchema) }).safeParse(result);
141560
- if (!parsed.success) throw new Error("worker returned an unexpected delivery list");
141561
- return parsed.data.outcomes;
141562
- }
141563
- function parseSessionStatus(result) {
141564
- const parsed = SessionStatusSchema.safeParse(result);
141565
- if (!parsed.success) throw new Error("worker returned an unexpected session status");
141566
- return parsed.data;
141567
- }
141568
- //#endregion
141569
142146
  //#region src/speak/broker-echo.ts
141570
142147
  var BrokerEcho = class {
141571
142148
  deps;
141572
142149
  currentState = "starting";
141573
142150
  currentError = null;
141574
142151
  currentDevices = [];
142152
+ backoffMs = 0;
142153
+ limitedUntil = 0;
141575
142154
  constructor(deps) {
141576
142155
  this.deps = deps;
141577
142156
  }
@@ -141590,6 +142169,13 @@ var BrokerEcho = class {
141590
142169
  get devices() {
141591
142170
  return this.currentDevices;
141592
142171
  }
142172
+ /** Ms epoch until which Amazon asked us to wait (429), or null when the account is free. */
142173
+ get rateLimitedUntil() {
142174
+ return this.limitedUntil > this.nowMs() ? this.limitedUntil : null;
142175
+ }
142176
+ nowMs() {
142177
+ return (this.deps.now ?? Date.now)();
142178
+ }
141593
142179
  stop() {
141594
142180
  this.deps.worker.stop();
141595
142181
  }
@@ -141680,6 +142266,69 @@ var BrokerEcho = class {
141680
142266
  text
141681
142267
  }));
141682
142268
  }
142269
+ /**
142270
+ * Run one worker call. A 429 starts (or doubles) the account's back-off and is
142271
+ * rethrown as is; any answer ends it. Rate limiting is a status, not a failure.
142272
+ */
142273
+ async limited(call) {
142274
+ try {
142275
+ const result = await call();
142276
+ this.backoffMs = 0;
142277
+ this.limitedUntil = 0;
142278
+ return result;
142279
+ } catch (err) {
142280
+ if (err instanceof RateLimitedError) {
142281
+ this.backoffMs = this.backoffMs === 0 ? ECHO_RATE_BACKOFF_BASE_MS : Math.min(this.backoffMs * 2, ECHO_RATE_BACKOFF_MAX_MS);
142282
+ this.limitedUntil = this.nowMs() + this.backoffMs;
142283
+ this.deps.logger.info("alexa echo: amazon asked for a pause (429)", {
142284
+ tags: { brokerId: this.deps.brokerId },
142285
+ meta: { backoffMs: this.backoffMs }
142286
+ });
142287
+ }
142288
+ throw err;
142289
+ }
142290
+ }
142291
+ /** The raw player state of one Echo. Throws on any failure, a 429 included: never an empty answer. */
142292
+ async playerState(serial, deviceType) {
142293
+ await this.requireReady();
142294
+ return this.limited(async () => parsePlayerState(await this.deps.worker.request({
142295
+ op: "player_state",
142296
+ serial,
142297
+ deviceType
142298
+ })));
142299
+ }
142300
+ async npCommand(serial, command) {
142301
+ await this.requireReady();
142302
+ await this.limited(() => this.deps.worker.request({
142303
+ op: "np_command",
142304
+ serial,
142305
+ command
142306
+ }));
142307
+ }
142308
+ async setVolume(serial, level) {
142309
+ await this.requireReady();
142310
+ await this.limited(() => this.deps.worker.request({
142311
+ op: "set_volume",
142312
+ serial,
142313
+ level
142314
+ }));
142315
+ }
142316
+ /** Do Not Disturb of every device of the account, by serial, in one call. */
142317
+ async dndState() {
142318
+ await this.requireReady();
142319
+ return this.limited(async () => parseDndState(await this.deps.worker.request({ op: "dnd_state" })));
142320
+ }
142321
+ async setDnd(serial, enabled) {
142322
+ await this.requireReady();
142323
+ await this.limited(() => this.deps.worker.request({
142324
+ op: "set_dnd",
142325
+ serial,
142326
+ enabled
142327
+ }));
142328
+ }
142329
+ async requireReady() {
142330
+ if (!await this.ensureReady()) throw new Error(this.notReadyReason());
142331
+ }
141683
142332
  async session() {
141684
142333
  if (this.deps.worker.failed) return {
141685
142334
  loggedIn: false,
@@ -142402,6 +143051,7 @@ function startAlexaWorker(deps) {
142402
143051
  const current = pending;
142403
143052
  pending = null;
142404
143053
  if (message.ok) current.resolve(message.result);
143054
+ else if (message.code === "rate-limited") current.reject(new RateLimitedError(scrub(message.error)));
142405
143055
  else current.reject(new Error(scrub(message.error)));
142406
143056
  pump();
142407
143057
  }
@@ -142555,6 +143205,10 @@ var AlexaSpeakController = class {
142555
143205
  this.queue = this.queue.then(() => this.reconcile()).catch(() => void 0);
142556
143206
  return this.queue;
142557
143207
  }
143208
+ /** The live session of one account, for the Echo devices that poll it. */
143209
+ echoFor(brokerId) {
143210
+ return this.echoes.get(brokerId) ?? null;
143211
+ }
142558
143212
  brokerViews() {
142559
143213
  return this.host.listBrokers().map((ref) => {
142560
143214
  const echo = this.echoes.get(ref.id);
@@ -142562,7 +143216,8 @@ var AlexaSpeakController = class {
142562
143216
  id: ref.id,
142563
143217
  name: ref.name,
142564
143218
  ready: echo?.state === "ready",
142565
- devices: echo?.devices ?? []
143219
+ devices: echo?.devices ?? [],
143220
+ rateLimitedUntil: echo?.rateLimitedUntil ?? null
142566
143221
  };
142567
143222
  });
142568
143223
  }
@@ -142606,7 +143261,8 @@ var AlexaSpeakController = class {
142606
143261
  worker: this.makeWorker(ref.id, pythonPath),
142607
143262
  readSeed: () => this.host.readSeed(ref.id),
142608
143263
  sessions: this.host.sessions,
142609
- logger: this.host.logger
143264
+ logger: this.host.logger,
143265
+ now: this.now
142610
143266
  }));
142611
143267
  await Promise.all(refs.map(async (ref) => {
142612
143268
  const echo = this.echoes.get(ref.id);
@@ -142765,7 +143421,10 @@ var ALEXA_DEVICE_TYPES = [
142765
143421
  var AlexaImportAddon = class extends BaseDeviceProvider {
142766
143422
  addonId = "import-alexa";
142767
143423
  providerName = "Alexa";
142768
- deviceClasses = Object.fromEntries(ALEXA_DEVICE_TYPES.map((t) => [t, AlexaDevice]));
143424
+ deviceClasses = {
143425
+ ...Object.fromEntries(ALEXA_DEVICE_TYPES.map((t) => [t, AlexaDevice])),
143426
+ [DeviceType.MediaPlayer]: AlexaEchoDevice
143427
+ };
142769
143428
  registry = null;
142770
143429
  proxyLogin = null;
142771
143430
  deviceCodeLogin = null;
@@ -142852,6 +143511,7 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
142852
143511
  ensurePython: () => this.ctx.deps.ensurePython()
142853
143512
  });
142854
143513
  this.speak = speak;
143514
+ alexaEchoSessions.setResolver((brokerId) => speak.echoFor(brokerId));
142855
143515
  speak.start().catch((err) => {
142856
143516
  this.ctx.logger.warn("alexa speak: worker did not start", { meta: { error: err instanceof Error ? err.message : String(err) } });
142857
143517
  });
@@ -142956,6 +143616,7 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
142956
143616
  async onShutdown() {
142957
143617
  this.speak?.stop();
142958
143618
  this.speak = null;
143619
+ alexaEchoSessions.clear();
142959
143620
  alexaCommands.clear();
142960
143621
  try {
142961
143622
  await this.proxyLogin?.dispose();
@@ -142979,7 +143640,10 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
142979
143640
  }
142980
143641
  /** One broker = one account; the appliance id distinguishes devices within it. */
142981
143642
  generateStableId(_type, config) {
142982
- return `alexa:${String(config?.["brokerId"] ?? "unknown")}:${String(config?.["applianceId"] ?? "unknown")}`;
143643
+ const brokerId = String(config?.["brokerId"] ?? "unknown");
143644
+ const serial = config?.["serial"];
143645
+ if (typeof serial === "string") return `alexa:${brokerId}:echo:${serial}`;
143646
+ return `alexa:${brokerId}:${String(config?.["applianceId"] ?? "unknown")}`;
142983
143647
  }
142984
143648
  /**
142985
143649
  * Re-derive every adopted Alexa device's `capabilities[]` from the live
@@ -143142,7 +143806,8 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
143142
143806
  id: b.id,
143143
143807
  name: b.name
143144
143808
  })),
143145
- has: (id) => this.requireRegistry().get(id) !== null
143809
+ has: (id) => this.requireRegistry().get(id) !== null,
143810
+ getEchoesForBroker: (id) => this.speak?.echoFor(id)?.devices ?? []
143146
143811
  },
143147
143812
  logger: this.ctx.logger,
143148
143813
  getBrokerIdForIntegration: async (id) => {
@@ -143184,6 +143849,23 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
143184
143849
  integrationId
143185
143850
  })).id };
143186
143851
  },
143852
+ adoptEcho: async ({ brokerId, echo, integrationId, name }) => {
143853
+ const devices = this.ctx.kernel.devices;
143854
+ if (!devices) throw new Error("alexa adopt: kernel.devices unavailable");
143855
+ const config = {
143856
+ brokerId,
143857
+ serial: echo.id,
143858
+ deviceType: echo.deviceType,
143859
+ system: "alexa",
143860
+ integrationId,
143861
+ name
143862
+ };
143863
+ return { deviceId: (await devices.create(this.generateStableId(DeviceType.MediaPlayer, config), AlexaEchoDevice, config, null, {
143864
+ type: DeviceType.MediaPlayer,
143865
+ name,
143866
+ integrationId
143867
+ })).id };
143868
+ },
143187
143869
  removeDevice: async (id) => {
143188
143870
  await this.ctx.kernel.devices?.remove(id);
143189
143871
  },