@camstack/addon-import-alexa 0.2.180 → 0.2.182
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 +12 -0
- package/dist/addon.js +103 -10
- package/dist/addon.mjs +103 -10
- package/package.json +2 -2
- package/python/alexa_gateway.py +46 -0
- package/python/alexa_protocol.py +4 -0
- package/python/test_alexa_gateway_echo.py +60 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.182] - 2026-11-03
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- An idle Echo shows its volume and mute again (the volume list is read correctly)
|
|
15
|
+
|
|
16
|
+
## [0.2.181] - 2026-11-03
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- 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.
|
|
21
|
+
|
|
10
22
|
## [0.2.180] - 2026-11-03
|
|
11
23
|
|
|
12
24
|
### 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-
|
|
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-
|
|
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(
|
|
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
|
|
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 () =>
|
|
142591
|
-
volumeDown: async () =>
|
|
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-
|
|
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-
|
|
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(
|
|
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
|
|
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 () =>
|
|
142589
|
-
volumeDown: async () =>
|
|
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.
|
|
3
|
+
"version": "0.2.182",
|
|
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.
|
|
71
|
+
"server": ">=1.2.750"
|
|
72
72
|
}
|
|
73
73
|
},
|
|
74
74
|
"files": [
|
package/python/alexa_gateway.py
CHANGED
|
@@ -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,49 @@ 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
|
+
# `_static_request` hands back the raw response (None on status >= 400): parse it here.
|
|
326
|
+
if body is None:
|
|
327
|
+
raise RuntimeError("amazon returned no response for the device volume list (HTTP >= 400)")
|
|
328
|
+
status = getattr(body, "status", None)
|
|
329
|
+
try:
|
|
330
|
+
body = await body.json(content_type=None)
|
|
331
|
+
except Exception as err: # noqa: BLE001 — typed below
|
|
332
|
+
raise RuntimeError(
|
|
333
|
+
f"amazon returned an unexpected body for the device volume list (HTTP {status}, unparseable)"
|
|
334
|
+
) from err
|
|
335
|
+
if not isinstance(body, dict):
|
|
336
|
+
raise RuntimeError(
|
|
337
|
+
f"amazon returned an unexpected body for the device volume list (HTTP {status}, not an object)"
|
|
338
|
+
)
|
|
339
|
+
rows = body.get("volumes")
|
|
340
|
+
devices = []
|
|
341
|
+
for row in rows if isinstance(rows, list) else []:
|
|
342
|
+
if not isinstance(row, dict):
|
|
343
|
+
continue
|
|
344
|
+
serial = row.get("dsn")
|
|
345
|
+
volume = row.get("speakerVolume")
|
|
346
|
+
muted = row.get("speakerMuted")
|
|
347
|
+
if not isinstance(serial, str) or serial == "":
|
|
348
|
+
continue
|
|
349
|
+
entry = {"serial": serial}
|
|
350
|
+
if isinstance(volume, (int, float)) and not isinstance(volume, bool):
|
|
351
|
+
entry["volume"] = volume
|
|
352
|
+
if isinstance(muted, bool):
|
|
353
|
+
entry["muted"] = muted
|
|
354
|
+
devices.append(entry)
|
|
355
|
+
self._touch_success()
|
|
356
|
+
return {"devices": devices}
|
|
357
|
+
|
|
312
358
|
async def set_dnd(self, serial: str, enabled: bool) -> None:
|
|
313
359
|
device_type = self._device_type_of(serial)
|
|
314
360
|
async with self._lock(serial):
|
package/python/alexa_protocol.py
CHANGED
|
@@ -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):
|
|
@@ -18,11 +18,25 @@ class AlexapyTooManyRequestsError(Exception):
|
|
|
18
18
|
pass
|
|
19
19
|
|
|
20
20
|
|
|
21
|
+
class FakeResponse:
|
|
22
|
+
"""What alexapy's `_static_request` really returns: an aiohttp-like response, parsed by the caller."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, status, payload=None, bad_json=False):
|
|
25
|
+
self.status = status
|
|
26
|
+
self._payload = payload
|
|
27
|
+
self._bad_json = bad_json
|
|
28
|
+
|
|
29
|
+
async def json(self, content_type=None):
|
|
30
|
+
if self._bad_json:
|
|
31
|
+
raise ValueError("not json")
|
|
32
|
+
return self._payload
|
|
33
|
+
|
|
34
|
+
|
|
21
35
|
def run(coro):
|
|
22
36
|
return asyncio.run(coro)
|
|
23
37
|
|
|
24
38
|
|
|
25
|
-
def install_fake(state=None, dnd=None, raises=None):
|
|
39
|
+
def install_fake(state=None, dnd=None, raises=None, volumes=None):
|
|
26
40
|
calls: dict = {"behaviors": [], "media": [], "dnd_set": [], "state_for": [], "active": 0, "max_active": 0}
|
|
27
41
|
|
|
28
42
|
class FakeAPI:
|
|
@@ -54,6 +68,13 @@ def install_fake(state=None, dnd=None, raises=None):
|
|
|
54
68
|
raise raises
|
|
55
69
|
calls["behaviors"].append(node)
|
|
56
70
|
|
|
71
|
+
@staticmethod
|
|
72
|
+
async def _static_request(method, login, uri, data=None, query=None):
|
|
73
|
+
if raises:
|
|
74
|
+
raise raises
|
|
75
|
+
calls.setdefault("static", []).append((method, uri))
|
|
76
|
+
return volumes
|
|
77
|
+
|
|
57
78
|
@staticmethod
|
|
58
79
|
async def get_dnd_state(login):
|
|
59
80
|
if raises:
|
|
@@ -134,6 +155,44 @@ class EchoMediaTests(unittest.TestCase):
|
|
|
134
155
|
result = run(gateway.dnd_state())
|
|
135
156
|
self.assertEqual(result["devices"], [{"serial": "s1", "enabled": True}, {"serial": "s2", "enabled": False}])
|
|
136
157
|
|
|
158
|
+
def test_device_volumes_is_one_call_keeping_only_typed_readings(self) -> None:
|
|
159
|
+
gateway, calls = install_fake(
|
|
160
|
+
volumes=FakeResponse(
|
|
161
|
+
200,
|
|
162
|
+
{
|
|
163
|
+
"volumes": [
|
|
164
|
+
{"dsn": "s1", "deviceType": "T1", "speakerVolume": 46, "speakerMuted": False},
|
|
165
|
+
{"dsn": "s2", "speakerVolume": None, "speakerMuted": True},
|
|
166
|
+
{"deviceType": "T1", "speakerVolume": 10},
|
|
167
|
+
]
|
|
168
|
+
},
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
result = run(gateway.device_volumes())
|
|
172
|
+
self.assertEqual(
|
|
173
|
+
result["devices"],
|
|
174
|
+
[{"serial": "s1", "volume": 46, "muted": False}, {"serial": "s2", "muted": True}],
|
|
175
|
+
)
|
|
176
|
+
self.assertEqual(
|
|
177
|
+
calls["static"], [("get", "/api/devices/deviceType/dsn/audio/v1/allDeviceVolumes")]
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
def test_device_volumes_http_failure_is_told_apart_from_a_bad_body(self) -> None:
|
|
181
|
+
gateway, _ = install_fake(volumes=None)
|
|
182
|
+
with self.assertRaisesRegex(RuntimeError, "no response"):
|
|
183
|
+
run(gateway.device_volumes())
|
|
184
|
+
gateway, _ = install_fake(volumes=FakeResponse(200, ["not", "a", "dict"]))
|
|
185
|
+
with self.assertRaisesRegex(RuntimeError, "unexpected body"):
|
|
186
|
+
run(gateway.device_volumes())
|
|
187
|
+
gateway, _ = install_fake(volumes=FakeResponse(200, bad_json=True))
|
|
188
|
+
with self.assertRaisesRegex(RuntimeError, "unexpected body"):
|
|
189
|
+
run(gateway.device_volumes())
|
|
190
|
+
|
|
191
|
+
def test_device_volumes_429_is_typed(self) -> None:
|
|
192
|
+
gateway, _ = install_fake(raises=AlexapyTooManyRequestsError("Too Many Requests"))
|
|
193
|
+
with self.assertRaises(RateLimitedError):
|
|
194
|
+
run(gateway.device_volumes())
|
|
195
|
+
|
|
137
196
|
def test_set_dnd_goes_to_the_device(self) -> None:
|
|
138
197
|
gateway, calls = install_fake()
|
|
139
198
|
run(gateway.set_dnd("s1", True))
|