@camstack/addon-import-alexa 0.2.180 → 0.2.181

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/CHANGELOG.md CHANGED
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.181] - 2026-11-03
11
+
12
+ ### Changed
13
+
14
+ - An idle Echo now shows its real volume and mute (read once per account from Amazon's volume list); volume up and down step the level by 10.
15
+
10
16
  ## [0.2.180] - 2026-11-03
11
17
 
12
18
  ### Changed
package/dist/addon.js CHANGED
@@ -6044,7 +6044,7 @@ function number(params) {
6044
6044
  return /* @__PURE__ */ _coercedNumber(ZodNumber, params);
6045
6045
  }
6046
6046
  //#endregion
6047
- //#region ../types/dist/sleep-BKClVk0K.mjs
6047
+ //#region ../types/dist/sleep-DV4qxEyD.mjs
6048
6048
  /**
6049
6049
  * The audio chunk plane's byte format, and the ONE expansion from a coded
6050
6050
  * window to float samples (D455).
@@ -8132,6 +8132,10 @@ var BaseAddon = class {
8132
8132
  const stored = keychain ? await this.readAddonStoreWithRetry() : {};
8133
8133
  const patch = {};
8134
8134
  for (const key of keys) {
8135
+ if (tree.get(key)?.kind === "array") {
8136
+ patch[key] = [];
8137
+ continue;
8138
+ }
8135
8139
  const keychainStored = tree.has(key) || collectSecretMarkerRefs(stored[key]).length > 0;
8136
8140
  patch[key] = keychain && keychainStored ? SECRET_CLEAR : "";
8137
8141
  }
@@ -11788,7 +11792,7 @@ object({
11788
11792
  params: record(string(), union([string(), number$1()])).optional()
11789
11793
  });
11790
11794
  //#endregion
11791
- //#region ../types/dist/composition-BodkvOV0.mjs
11795
+ //#region ../types/dist/composition-CD1GdFuv.mjs
11792
11796
  /**
11793
11797
  * The ffmpeg build every CamStack node downloads, per platform and arch.
11794
11798
  *
@@ -12977,7 +12981,7 @@ var CompositionSchema = object({
12977
12981
  kind: literal("existing"),
12978
12982
  device: CompositionSourceRefSchema
12979
12983
  })]),
12980
- features: array(CompositionFeatureSchema).min(1).max(16),
12984
+ features: array(CompositionFeatureSchema).min(0).max(16),
12981
12985
  /**
12982
12986
  * Present when the block is a helper template. Absent on a free
12983
12987
  * composition (the room example, device 4603). The editor reads this,
@@ -70379,6 +70383,14 @@ var powerMeterCapability = {
70379
70383
  deviceNative: true,
70380
70384
  mode: "singleton",
70381
70385
  deviceTypes: POWER_METER_DEVICE_TYPES,
70386
+ deviceConfig: { ui: {
70387
+ kind: "widget",
70388
+ widgetId: "host/power-meter-line",
70389
+ tab: "power",
70390
+ placement: "hero",
70391
+ label: "Power",
70392
+ order: 4
70393
+ } },
70382
70394
  methods: {},
70383
70395
  status: {
70384
70396
  schema: PowerMeterStatusSchema,
@@ -142194,6 +142206,7 @@ function isKnownCap(cap) {
142194
142206
  var EchoSessionRegistry = class {
142195
142207
  #resolve = null;
142196
142208
  #dnd = /* @__PURE__ */ new Map();
142209
+ #volumes = /* @__PURE__ */ new Map();
142197
142210
  setResolver(fn) {
142198
142211
  this.#resolve = fn;
142199
142212
  }
@@ -142216,9 +142229,26 @@ var EchoSessionRegistry = class {
142216
142229
  });
142217
142230
  return pending;
142218
142231
  }
142232
+ /** Volume and mute of every device of the account. A failed read is not kept for the next device. */
142233
+ volumes(brokerId, now) {
142234
+ const shared = this.#volumes.get(brokerId);
142235
+ if (shared !== void 0 && now - shared.at < 25e3) return shared.pending;
142236
+ const session = this.get(brokerId);
142237
+ if (session === null) return Promise.reject(/* @__PURE__ */ new Error("no Echo session for this account"));
142238
+ const pending = session.deviceVolumes();
142239
+ this.#volumes.set(brokerId, {
142240
+ at: now,
142241
+ pending
142242
+ });
142243
+ pending.catch(() => {
142244
+ if (this.#volumes.get(brokerId)?.pending === pending) this.#volumes.delete(brokerId);
142245
+ });
142246
+ return pending;
142247
+ }
142219
142248
  clear() {
142220
142249
  this.#resolve = null;
142221
142250
  this.#dnd.clear();
142251
+ this.#volumes.clear();
142222
142252
  }
142223
142253
  };
142224
142254
  /** The single in-process registry shared across the addon. */
@@ -142301,6 +142331,18 @@ function mapPlayerInfoToStatus(info, input) {
142301
142331
  repeat: repeatOf(info.transport?.repeat)
142302
142332
  };
142303
142333
  }
142334
+ /**
142335
+ * Fill the volume and mute the player read left null from the account's volume
142336
+ * list. The player read wins when it has a value; a value neither source has stays null.
142337
+ */
142338
+ function withListedVolume(status, listed) {
142339
+ if (listed === void 0) return status;
142340
+ return {
142341
+ ...status,
142342
+ volumeLevel: status.volumeLevel ?? percent(listed.volume),
142343
+ isMuted: status.isMuted ?? listed.muted
142344
+ };
142345
+ }
142304
142346
  //#endregion
142305
142347
  //#region src/speak/worker-protocol.ts
142306
142348
  /** Line-delimited JSON the long-lived Python worker speaks. One object per line. */
@@ -142418,6 +142460,19 @@ function parseDndState(result) {
142418
142460
  if (!parsed.success) throw new Error("worker returned an unexpected Do Not Disturb list");
142419
142461
  return new Map(parsed.data.devices.map((entry) => [entry.serial, entry.enabled]));
142420
142462
  }
142463
+ var DeviceVolumeEntrySchema = object({
142464
+ serial: string().min(1),
142465
+ volume: number$1().finite().optional(),
142466
+ muted: boolean().optional()
142467
+ });
142468
+ function parseDeviceVolumes(result) {
142469
+ const parsed = object({ devices: array(DeviceVolumeEntrySchema) }).safeParse(result);
142470
+ if (!parsed.success) throw new Error("worker returned an unexpected device volume list");
142471
+ return new Map(parsed.data.devices.map((entry) => [entry.serial, {
142472
+ volume: entry.volume ?? null,
142473
+ muted: entry.muted ?? null
142474
+ }]));
142475
+ }
142421
142476
  //#endregion
142422
142477
  //#region src/devices/alexa-echo-device.ts
142423
142478
  /**
@@ -142432,6 +142487,8 @@ var alexaEchoDeviceSchema = object({
142432
142487
  system: literal("alexa").optional(),
142433
142488
  integrationId: string().optional()
142434
142489
  });
142490
+ /** Percent points one volume-up / volume-down moves an Echo. */
142491
+ var VOLUME_STEP = 10;
142435
142492
  var COLD_DND = {
142436
142493
  enabled: null,
142437
142494
  lastChangedAt: 0
@@ -142452,6 +142509,7 @@ var AlexaEchoDevice = class extends BaseDevice {
142452
142509
  rereadTimer = null;
142453
142510
  polling = false;
142454
142511
  failureLogged = false;
142512
+ volumeFailureLogged = false;
142455
142513
  health = "ok";
142456
142514
  constructor(ctx) {
142457
142515
  const persisted = alexaEchoDeviceSchema.parse(ctx.persistedConfig ?? {});
@@ -142524,12 +142582,11 @@ var AlexaEchoDevice = class extends BaseDevice {
142524
142582
  }
142525
142583
  const deviceType = this.amazonDeviceType !== "" ? this.amazonDeviceType : listed?.deviceType ?? "";
142526
142584
  try {
142527
- const info = await session.playerState(this.serial, deviceType);
142528
- const now = Date.now();
142529
- this.runtimeState.setCapState("media-player", mapPlayerInfoToStatus(info, {
142585
+ const fromPlayer = mapPlayerInfoToStatus(await session.playerState(this.serial, deviceType), {
142530
142586
  online: true,
142531
- now
142532
- }));
142587
+ now: Date.now()
142588
+ });
142589
+ this.runtimeState.setCapState("media-player", await this.completeVolume(fromPlayer));
142533
142590
  this.markOnline(true);
142534
142591
  this.noteRecovered();
142535
142592
  } catch (err) {
@@ -142538,6 +142595,29 @@ var AlexaEchoDevice = class extends BaseDevice {
142538
142595
  }
142539
142596
  await this.readDoNotDisturb();
142540
142597
  }
142598
+ /**
142599
+ * An idle Echo's player read carries no volume. Ask the account's volume list
142600
+ * (shared by every Echo of the account in this cycle); a failed read leaves null.
142601
+ */
142602
+ async completeVolume(status) {
142603
+ if (status.volumeLevel !== null && status.isMuted !== null) return status;
142604
+ try {
142605
+ const all = await alexaEchoSessions.volumes(this.brokerId, Date.now());
142606
+ if (this.volumeFailureLogged) this.ctx.logger.info("alexa echo: the volume list reads again", { tags: { deviceId: this.id } });
142607
+ this.volumeFailureLogged = false;
142608
+ return withListedVolume(status, all.get(this.serial));
142609
+ } catch (err) {
142610
+ if (err instanceof RateLimitedError) return status;
142611
+ if (!this.volumeFailureLogged) {
142612
+ this.volumeFailureLogged = true;
142613
+ this.ctx.logger.warn("alexa echo: could not read the volume list, volume stays unknown", {
142614
+ tags: { deviceId: this.id },
142615
+ meta: { error: err instanceof Error ? err.message : String(err) }
142616
+ });
142617
+ }
142618
+ return status;
142619
+ }
142620
+ }
142541
142621
  async readDoNotDisturb() {
142542
142622
  try {
142543
142623
  const enabled = (await alexaEchoSessions.dnd(this.brokerId, Date.now())).get(this.serial);
@@ -142573,6 +142653,14 @@ var AlexaEchoDevice = class extends BaseDevice {
142573
142653
  await this.session().npCommand(this.serial, command);
142574
142654
  this.rereadSoon();
142575
142655
  }
142656
+ /** Amazon has no step command: move from the last known level, refuse when there is none. */
142657
+ async stepVolume(delta) {
142658
+ const level = this.runtimeState.getCapState("media-player")?.volumeLevel;
142659
+ if (level === null || level === void 0) throw new Error("the Echo volume is not known yet");
142660
+ const next = Math.max(0, Math.min(100, level + delta));
142661
+ await this.session().setVolume(this.serial, next);
142662
+ this.rereadSoon();
142663
+ }
142576
142664
  registerMediaPlayer() {
142577
142665
  this.ctx.registerNativeCap(mediaPlayerCapability, {
142578
142666
  getStatus: async () => this.runtimeState.getCapState("media-player") ?? emptyEchoStatus("off", 0),
@@ -142587,8 +142675,8 @@ var AlexaEchoDevice = class extends BaseDevice {
142587
142675
  stop: async () => unsupported("stop"),
142588
142676
  turnOn: async () => unsupported("power"),
142589
142677
  turnOff: async () => unsupported("power"),
142590
- volumeUp: async () => unsupported("volume steps"),
142591
- volumeDown: async () => unsupported("volume steps"),
142678
+ volumeUp: async () => this.stepVolume(VOLUME_STEP),
142679
+ volumeDown: async () => this.stepVolume(-10),
142592
142680
  channelUp: async () => unsupported("channel steps"),
142593
142681
  channelDown: async () => unsupported("channel steps"),
142594
142682
  seek: async () => unsupported("seek"),
@@ -143095,6 +143183,11 @@ var BrokerEcho = class {
143095
143183
  await this.requireReady();
143096
143184
  return this.limited(async () => parseDndState(await this.deps.worker.request({ op: "dnd_state" })));
143097
143185
  }
143186
+ /** Speaker volume and mute of every device of the account, by serial, in one call. */
143187
+ async deviceVolumes() {
143188
+ await this.requireReady();
143189
+ return this.limited(async () => parseDeviceVolumes(await this.deps.worker.request({ op: "device_volumes" })));
143190
+ }
143098
143191
  async setDnd(serial, enabled) {
143099
143192
  await this.requireReady();
143100
143193
  await this.limited(() => this.deps.worker.request({
package/dist/addon.mjs CHANGED
@@ -6042,7 +6042,7 @@ function number(params) {
6042
6042
  return /* @__PURE__ */ _coercedNumber(ZodNumber, params);
6043
6043
  }
6044
6044
  //#endregion
6045
- //#region ../types/dist/sleep-BKClVk0K.mjs
6045
+ //#region ../types/dist/sleep-DV4qxEyD.mjs
6046
6046
  /**
6047
6047
  * The audio chunk plane's byte format, and the ONE expansion from a coded
6048
6048
  * window to float samples (D455).
@@ -8130,6 +8130,10 @@ var BaseAddon = class {
8130
8130
  const stored = keychain ? await this.readAddonStoreWithRetry() : {};
8131
8131
  const patch = {};
8132
8132
  for (const key of keys) {
8133
+ if (tree.get(key)?.kind === "array") {
8134
+ patch[key] = [];
8135
+ continue;
8136
+ }
8133
8137
  const keychainStored = tree.has(key) || collectSecretMarkerRefs(stored[key]).length > 0;
8134
8138
  patch[key] = keychain && keychainStored ? SECRET_CLEAR : "";
8135
8139
  }
@@ -11786,7 +11790,7 @@ object({
11786
11790
  params: record(string(), union([string(), number$1()])).optional()
11787
11791
  });
11788
11792
  //#endregion
11789
- //#region ../types/dist/composition-BodkvOV0.mjs
11793
+ //#region ../types/dist/composition-CD1GdFuv.mjs
11790
11794
  /**
11791
11795
  * The ffmpeg build every CamStack node downloads, per platform and arch.
11792
11796
  *
@@ -12975,7 +12979,7 @@ var CompositionSchema = object({
12975
12979
  kind: literal("existing"),
12976
12980
  device: CompositionSourceRefSchema
12977
12981
  })]),
12978
- features: array(CompositionFeatureSchema).min(1).max(16),
12982
+ features: array(CompositionFeatureSchema).min(0).max(16),
12979
12983
  /**
12980
12984
  * Present when the block is a helper template. Absent on a free
12981
12985
  * composition (the room example, device 4603). The editor reads this,
@@ -70377,6 +70381,14 @@ var powerMeterCapability = {
70377
70381
  deviceNative: true,
70378
70382
  mode: "singleton",
70379
70383
  deviceTypes: POWER_METER_DEVICE_TYPES,
70384
+ deviceConfig: { ui: {
70385
+ kind: "widget",
70386
+ widgetId: "host/power-meter-line",
70387
+ tab: "power",
70388
+ placement: "hero",
70389
+ label: "Power",
70390
+ order: 4
70391
+ } },
70380
70392
  methods: {},
70381
70393
  status: {
70382
70394
  schema: PowerMeterStatusSchema,
@@ -142192,6 +142204,7 @@ function isKnownCap(cap) {
142192
142204
  var EchoSessionRegistry = class {
142193
142205
  #resolve = null;
142194
142206
  #dnd = /* @__PURE__ */ new Map();
142207
+ #volumes = /* @__PURE__ */ new Map();
142195
142208
  setResolver(fn) {
142196
142209
  this.#resolve = fn;
142197
142210
  }
@@ -142214,9 +142227,26 @@ var EchoSessionRegistry = class {
142214
142227
  });
142215
142228
  return pending;
142216
142229
  }
142230
+ /** Volume and mute of every device of the account. A failed read is not kept for the next device. */
142231
+ volumes(brokerId, now) {
142232
+ const shared = this.#volumes.get(brokerId);
142233
+ if (shared !== void 0 && now - shared.at < 25e3) return shared.pending;
142234
+ const session = this.get(brokerId);
142235
+ if (session === null) return Promise.reject(/* @__PURE__ */ new Error("no Echo session for this account"));
142236
+ const pending = session.deviceVolumes();
142237
+ this.#volumes.set(brokerId, {
142238
+ at: now,
142239
+ pending
142240
+ });
142241
+ pending.catch(() => {
142242
+ if (this.#volumes.get(brokerId)?.pending === pending) this.#volumes.delete(brokerId);
142243
+ });
142244
+ return pending;
142245
+ }
142217
142246
  clear() {
142218
142247
  this.#resolve = null;
142219
142248
  this.#dnd.clear();
142249
+ this.#volumes.clear();
142220
142250
  }
142221
142251
  };
142222
142252
  /** The single in-process registry shared across the addon. */
@@ -142299,6 +142329,18 @@ function mapPlayerInfoToStatus(info, input) {
142299
142329
  repeat: repeatOf(info.transport?.repeat)
142300
142330
  };
142301
142331
  }
142332
+ /**
142333
+ * Fill the volume and mute the player read left null from the account's volume
142334
+ * list. The player read wins when it has a value; a value neither source has stays null.
142335
+ */
142336
+ function withListedVolume(status, listed) {
142337
+ if (listed === void 0) return status;
142338
+ return {
142339
+ ...status,
142340
+ volumeLevel: status.volumeLevel ?? percent(listed.volume),
142341
+ isMuted: status.isMuted ?? listed.muted
142342
+ };
142343
+ }
142302
142344
  //#endregion
142303
142345
  //#region src/speak/worker-protocol.ts
142304
142346
  /** Line-delimited JSON the long-lived Python worker speaks. One object per line. */
@@ -142416,6 +142458,19 @@ function parseDndState(result) {
142416
142458
  if (!parsed.success) throw new Error("worker returned an unexpected Do Not Disturb list");
142417
142459
  return new Map(parsed.data.devices.map((entry) => [entry.serial, entry.enabled]));
142418
142460
  }
142461
+ var DeviceVolumeEntrySchema = object({
142462
+ serial: string().min(1),
142463
+ volume: number$1().finite().optional(),
142464
+ muted: boolean().optional()
142465
+ });
142466
+ function parseDeviceVolumes(result) {
142467
+ const parsed = object({ devices: array(DeviceVolumeEntrySchema) }).safeParse(result);
142468
+ if (!parsed.success) throw new Error("worker returned an unexpected device volume list");
142469
+ return new Map(parsed.data.devices.map((entry) => [entry.serial, {
142470
+ volume: entry.volume ?? null,
142471
+ muted: entry.muted ?? null
142472
+ }]));
142473
+ }
142419
142474
  //#endregion
142420
142475
  //#region src/devices/alexa-echo-device.ts
142421
142476
  /**
@@ -142430,6 +142485,8 @@ var alexaEchoDeviceSchema = object({
142430
142485
  system: literal("alexa").optional(),
142431
142486
  integrationId: string().optional()
142432
142487
  });
142488
+ /** Percent points one volume-up / volume-down moves an Echo. */
142489
+ var VOLUME_STEP = 10;
142433
142490
  var COLD_DND = {
142434
142491
  enabled: null,
142435
142492
  lastChangedAt: 0
@@ -142450,6 +142507,7 @@ var AlexaEchoDevice = class extends BaseDevice {
142450
142507
  rereadTimer = null;
142451
142508
  polling = false;
142452
142509
  failureLogged = false;
142510
+ volumeFailureLogged = false;
142453
142511
  health = "ok";
142454
142512
  constructor(ctx) {
142455
142513
  const persisted = alexaEchoDeviceSchema.parse(ctx.persistedConfig ?? {});
@@ -142522,12 +142580,11 @@ var AlexaEchoDevice = class extends BaseDevice {
142522
142580
  }
142523
142581
  const deviceType = this.amazonDeviceType !== "" ? this.amazonDeviceType : listed?.deviceType ?? "";
142524
142582
  try {
142525
- const info = await session.playerState(this.serial, deviceType);
142526
- const now = Date.now();
142527
- this.runtimeState.setCapState("media-player", mapPlayerInfoToStatus(info, {
142583
+ const fromPlayer = mapPlayerInfoToStatus(await session.playerState(this.serial, deviceType), {
142528
142584
  online: true,
142529
- now
142530
- }));
142585
+ now: Date.now()
142586
+ });
142587
+ this.runtimeState.setCapState("media-player", await this.completeVolume(fromPlayer));
142531
142588
  this.markOnline(true);
142532
142589
  this.noteRecovered();
142533
142590
  } catch (err) {
@@ -142536,6 +142593,29 @@ var AlexaEchoDevice = class extends BaseDevice {
142536
142593
  }
142537
142594
  await this.readDoNotDisturb();
142538
142595
  }
142596
+ /**
142597
+ * An idle Echo's player read carries no volume. Ask the account's volume list
142598
+ * (shared by every Echo of the account in this cycle); a failed read leaves null.
142599
+ */
142600
+ async completeVolume(status) {
142601
+ if (status.volumeLevel !== null && status.isMuted !== null) return status;
142602
+ try {
142603
+ const all = await alexaEchoSessions.volumes(this.brokerId, Date.now());
142604
+ if (this.volumeFailureLogged) this.ctx.logger.info("alexa echo: the volume list reads again", { tags: { deviceId: this.id } });
142605
+ this.volumeFailureLogged = false;
142606
+ return withListedVolume(status, all.get(this.serial));
142607
+ } catch (err) {
142608
+ if (err instanceof RateLimitedError) return status;
142609
+ if (!this.volumeFailureLogged) {
142610
+ this.volumeFailureLogged = true;
142611
+ this.ctx.logger.warn("alexa echo: could not read the volume list, volume stays unknown", {
142612
+ tags: { deviceId: this.id },
142613
+ meta: { error: err instanceof Error ? err.message : String(err) }
142614
+ });
142615
+ }
142616
+ return status;
142617
+ }
142618
+ }
142539
142619
  async readDoNotDisturb() {
142540
142620
  try {
142541
142621
  const enabled = (await alexaEchoSessions.dnd(this.brokerId, Date.now())).get(this.serial);
@@ -142571,6 +142651,14 @@ var AlexaEchoDevice = class extends BaseDevice {
142571
142651
  await this.session().npCommand(this.serial, command);
142572
142652
  this.rereadSoon();
142573
142653
  }
142654
+ /** Amazon has no step command: move from the last known level, refuse when there is none. */
142655
+ async stepVolume(delta) {
142656
+ const level = this.runtimeState.getCapState("media-player")?.volumeLevel;
142657
+ if (level === null || level === void 0) throw new Error("the Echo volume is not known yet");
142658
+ const next = Math.max(0, Math.min(100, level + delta));
142659
+ await this.session().setVolume(this.serial, next);
142660
+ this.rereadSoon();
142661
+ }
142574
142662
  registerMediaPlayer() {
142575
142663
  this.ctx.registerNativeCap(mediaPlayerCapability, {
142576
142664
  getStatus: async () => this.runtimeState.getCapState("media-player") ?? emptyEchoStatus("off", 0),
@@ -142585,8 +142673,8 @@ var AlexaEchoDevice = class extends BaseDevice {
142585
142673
  stop: async () => unsupported("stop"),
142586
142674
  turnOn: async () => unsupported("power"),
142587
142675
  turnOff: async () => unsupported("power"),
142588
- volumeUp: async () => unsupported("volume steps"),
142589
- volumeDown: async () => unsupported("volume steps"),
142676
+ volumeUp: async () => this.stepVolume(VOLUME_STEP),
142677
+ volumeDown: async () => this.stepVolume(-10),
142590
142678
  channelUp: async () => unsupported("channel steps"),
142591
142679
  channelDown: async () => unsupported("channel steps"),
142592
142680
  seek: async () => unsupported("seek"),
@@ -143093,6 +143181,11 @@ var BrokerEcho = class {
143093
143181
  await this.requireReady();
143094
143182
  return this.limited(async () => parseDndState(await this.deps.worker.request({ op: "dnd_state" })));
143095
143183
  }
143184
+ /** Speaker volume and mute of every device of the account, by serial, in one call. */
143185
+ async deviceVolumes() {
143186
+ await this.requireReady();
143187
+ return this.limited(async () => parseDeviceVolumes(await this.deps.worker.request({ op: "device_volumes" })));
143188
+ }
143096
143189
  async setDnd(serial, enabled) {
143097
143190
  await this.requireReady();
143098
143191
  await this.limited(() => this.deps.worker.request({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-import-alexa",
3
- "version": "0.2.180",
3
+ "version": "0.2.181",
4
4
  "description": "Alexa device-import provider for CamStack — imports the smart-home devices in a user's Alexa account via the unofficial alexa-remote2 cookie/token client (the inverse of the Alexa exporter)",
5
5
  "keywords": [
6
6
  "camstack",
@@ -68,7 +68,7 @@
68
68
  }
69
69
  ],
70
70
  "requires": {
71
- "server": ">=1.2.747"
71
+ "server": ">=1.2.750"
72
72
  }
73
73
  },
74
74
  "files": [
@@ -95,6 +95,9 @@ def normalize_device(raw: dict) -> dict | None:
95
95
  # The skill alexapy itself names for an AlexaAnnouncement node.
96
96
  ANNOUNCEMENT_SKILL_ID = "amzn1.ask.1p.routines.messaging"
97
97
 
98
+ VOLUMES_PATH = "/api/devices/deviceType/dsn/audio/v1/allDeviceVolumes"
99
+
100
+
98
101
  class AlexaPyGateway:
99
102
  """Holds one AlexaLogin for the life of the process (one process per broker)."""
100
103
 
@@ -309,6 +312,37 @@ class AlexaPyGateway:
309
312
  self._touch_success()
310
313
  return {"devices": devices}
311
314
 
315
+ async def device_volumes(self) -> dict:
316
+ """Speaker volume and mute of every device, in one call (idle Echos included)."""
317
+ login = self._require_login()
318
+ _AlexaLogin, AlexaAPI = _import_alexapy()
319
+ try:
320
+ body = await AlexaAPI._static_request("get", login, VOLUMES_PATH)
321
+ except Exception as err: # noqa: BLE001 — typed below
322
+ if is_rate_limited(err):
323
+ raise RateLimitedError("amazon rate limit (429) on the device volumes") from err
324
+ raise
325
+ if not isinstance(body, dict):
326
+ raise RuntimeError("amazon returned no device volume list")
327
+ rows = body.get("volumes")
328
+ devices = []
329
+ for row in rows if isinstance(rows, list) else []:
330
+ if not isinstance(row, dict):
331
+ continue
332
+ serial = row.get("dsn")
333
+ volume = row.get("speakerVolume")
334
+ muted = row.get("speakerMuted")
335
+ if not isinstance(serial, str) or serial == "":
336
+ continue
337
+ entry = {"serial": serial}
338
+ if isinstance(volume, (int, float)) and not isinstance(volume, bool):
339
+ entry["volume"] = volume
340
+ if isinstance(muted, bool):
341
+ entry["muted"] = muted
342
+ devices.append(entry)
343
+ self._touch_success()
344
+ return {"devices": devices}
345
+
312
346
  async def set_dnd(self, serial: str, enabled: bool) -> None:
313
347
  device_type = self._device_type_of(serial)
314
348
  async with self._lock(serial):
@@ -44,6 +44,8 @@ class Gateway(Protocol):
44
44
 
45
45
  async def dnd_state(self) -> dict: ...
46
46
 
47
+ async def device_volumes(self) -> dict: ...
48
+
47
49
  async def set_dnd(self, serial: str, enabled: bool) -> None: ...
48
50
 
49
51
  def target_for(self, serial: str) -> EchoTarget | None: ...
@@ -116,6 +118,8 @@ class Session:
116
118
  return {}
117
119
  if op == "dnd_state":
118
120
  return await self._gateway.dnd_state()
121
+ if op == "device_volumes":
122
+ return await self._gateway.device_volumes()
119
123
  if op == "set_dnd":
120
124
  enabled = message.get("enabled")
121
125
  if not isinstance(enabled, bool):
@@ -22,7 +22,7 @@ def run(coro):
22
22
  return asyncio.run(coro)
23
23
 
24
24
 
25
- def install_fake(state=None, dnd=None, raises=None):
25
+ def install_fake(state=None, dnd=None, raises=None, volumes=None):
26
26
  calls: dict = {"behaviors": [], "media": [], "dnd_set": [], "state_for": [], "active": 0, "max_active": 0}
27
27
 
28
28
  class FakeAPI:
@@ -54,6 +54,13 @@ def install_fake(state=None, dnd=None, raises=None):
54
54
  raise raises
55
55
  calls["behaviors"].append(node)
56
56
 
57
+ @staticmethod
58
+ async def _static_request(method, login, uri, data=None, query=None):
59
+ if raises:
60
+ raise raises
61
+ calls.setdefault("static", []).append((method, uri))
62
+ return volumes
63
+
57
64
  @staticmethod
58
65
  async def get_dnd_state(login):
59
66
  if raises:
@@ -134,6 +141,30 @@ class EchoMediaTests(unittest.TestCase):
134
141
  result = run(gateway.dnd_state())
135
142
  self.assertEqual(result["devices"], [{"serial": "s1", "enabled": True}, {"serial": "s2", "enabled": False}])
136
143
 
144
+ def test_device_volumes_is_one_call_keeping_only_typed_readings(self) -> None:
145
+ gateway, calls = install_fake(
146
+ volumes={
147
+ "volumes": [
148
+ {"dsn": "s1", "deviceType": "T1", "speakerVolume": 46, "speakerMuted": False},
149
+ {"dsn": "s2", "speakerVolume": None, "speakerMuted": True},
150
+ {"deviceType": "T1", "speakerVolume": 10},
151
+ ]
152
+ }
153
+ )
154
+ result = run(gateway.device_volumes())
155
+ self.assertEqual(
156
+ result["devices"],
157
+ [{"serial": "s1", "volume": 46, "muted": False}, {"serial": "s2", "muted": True}],
158
+ )
159
+ self.assertEqual(
160
+ calls["static"], [("get", "/api/devices/deviceType/dsn/audio/v1/allDeviceVolumes")]
161
+ )
162
+
163
+ def test_device_volumes_429_is_typed(self) -> None:
164
+ gateway, _ = install_fake(raises=AlexapyTooManyRequestsError("Too Many Requests"))
165
+ with self.assertRaises(RateLimitedError):
166
+ run(gateway.device_volumes())
167
+
137
168
  def test_set_dnd_goes_to_the_device(self) -> None:
138
169
  gateway, calls = install_fake()
139
170
  run(gateway.set_dnd("s1", True))