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