alexa-mcp 0.1.0

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/client.js ADDED
@@ -0,0 +1,660 @@
1
+ import { getConfig } from "./config.js";
2
+ import { authenticate } from "./auth.js";
3
+ import { fetch } from "undici";
4
+ export class AlexaClient {
5
+ creds = null;
6
+ refreshToken;
7
+ domain;
8
+ constructor(options) {
9
+ this.refreshToken = options.refreshToken;
10
+ this.domain = options.domain ?? "amazon.co.uk";
11
+ }
12
+ async ensureAuth() {
13
+ if (this.creds)
14
+ return this.creds;
15
+ this.creds = await authenticate({
16
+ refreshToken: this.refreshToken,
17
+ domain: this.domain,
18
+ });
19
+ return this.creds;
20
+ }
21
+ /** Low-level app API request (eu-api / na-api). */
22
+ async request(opts) {
23
+ const config = getConfig(this.domain);
24
+ const creds = await this.ensureAuth();
25
+ const fullUrl = `${config.appApiBase.replace(/\/$/, "")}${opts.url.startsWith("/") ? opts.url : "/" + opts.url}`;
26
+ const headers = {
27
+ Cookie: creds.cookies,
28
+ csrf: creds.csrf,
29
+ "Content-Type": "application/json",
30
+ Accept: "application/json",
31
+ ...opts.extraHeaders,
32
+ };
33
+ const init = {
34
+ method: opts.method,
35
+ headers,
36
+ };
37
+ if (opts.body !== undefined && opts.method !== "GET") {
38
+ init.body = JSON.stringify(opts.body);
39
+ }
40
+ const res = await fetch(fullUrl, init);
41
+ const text = await res.text();
42
+ if (!res.ok) {
43
+ if (process.env.ALEXA_DEBUG) {
44
+ console.error(`[alexa-mcp] ${opts.method} ${fullUrl} → ${res.status}: ${text.slice(0, 200)}`);
45
+ }
46
+ if (opts.throwOnError) {
47
+ const prefix = opts.errorPrefix ?? "API error ";
48
+ throw new Error(`${prefix}${res.status}: ${text.slice(0, 200)}`);
49
+ }
50
+ return {};
51
+ }
52
+ if (!text.trim())
53
+ return {};
54
+ try {
55
+ return JSON.parse(text);
56
+ }
57
+ catch {
58
+ return {};
59
+ }
60
+ }
61
+ /** GET from app API. Returns {} on failure (non-throwing). */
62
+ async getFromAppApi(url) {
63
+ return this.request({ method: "GET", url, throwOnError: false });
64
+ }
65
+ /** GET app endpoint. Throws on failure. */
66
+ async getApp(url) {
67
+ return this.request({ method: "GET", url, throwOnError: true });
68
+ }
69
+ /** POST app endpoint. Throws on failure. */
70
+ async postApp(url, body) {
71
+ return this.request({ method: "POST", url, body, throwOnError: true });
72
+ }
73
+ /** PUT app endpoint. Throws on failure. */
74
+ async putApp(url, body) {
75
+ return this.request({ method: "PUT", url, body, throwOnError: true });
76
+ }
77
+ /** POST to app API (e.g. control-media-session). Throws on failure. */
78
+ async postFromAppApi(url, body) {
79
+ const data = await this.request({
80
+ method: "POST",
81
+ url,
82
+ body,
83
+ throwOnError: true,
84
+ errorPrefix: "Media API error ",
85
+ });
86
+ return { ok: true, data };
87
+ }
88
+ /**
89
+ * GET /api/smarthome/v1/presentation/devices/control — layout IDs to capabilities.
90
+ * Returns layout keys (endpoint IDs like amzn1.alexa.endpoint.*) when available.
91
+ */
92
+ async fetchLayouts() {
93
+ try {
94
+ const data = (await this.getFromAppApi("/api/smarthome/v1/presentation/devices/control"));
95
+ const layouts = data?.layouts;
96
+ if (layouts && typeof layouts === "object") {
97
+ return Object.keys(layouts);
98
+ }
99
+ }
100
+ catch {
101
+ // ignore
102
+ }
103
+ return [];
104
+ }
105
+ /**
106
+ * POST /nexus/v1/graphql (eu-api) — power/brightness control. Uses endpointId (amzn1.alexa.endpoint.*).
107
+ * Uses setEndpointFeatures mutation (matches Alexa mobile app); updatePowerFeatureForEndpoints can fail silently.
108
+ */
109
+ async graphqlControl(endpointId, action, brightness) {
110
+ if (action === "setBrightness") {
111
+ if (brightness === undefined)
112
+ throw new Error("brightness required for setBrightness");
113
+ await this.postGraphql({
114
+ operationName: "setBrightness",
115
+ variables: {
116
+ featureControlRequests: [
117
+ {
118
+ endpointId,
119
+ featureName: "brightness",
120
+ featureOperationName: "setBrightness",
121
+ payload: { brightness },
122
+ },
123
+ ],
124
+ },
125
+ query: "mutation setBrightness($featureControlRequests: [FeatureControlRequestInput!]!) { setBrightness(featureControlRequests: $featureControlRequests) }",
126
+ });
127
+ return;
128
+ }
129
+ const featureOp = action === "turnOn" ? "turnOn" : "turnOff";
130
+ await this.postGraphql({
131
+ operationName: "setPower",
132
+ variables: {
133
+ endpointId,
134
+ featureOperationName: featureOp,
135
+ },
136
+ query: "mutation setPower($endpointId: String, $featureOperationName: FeatureOperationName!) { setEndpointFeatures(setEndpointFeaturesInput: {featureControlRequests: [{endpointId: $endpointId, featureName: power, featureOperationName: $featureOperationName}]}) { featureControlResponses { code endpointId featureOperationName __typename } errors { code message featureOperationName __typename } __typename } }",
137
+ });
138
+ }
139
+ /** POST to nexus/v1/graphql with app-like headers (matches Alexa mobile app). */
140
+ async postGraphql(body) {
141
+ return this.request({
142
+ method: "POST",
143
+ url: "/nexus/v1/graphql",
144
+ body,
145
+ throwOnError: true,
146
+ errorPrefix: "GraphQL ",
147
+ extraHeaders: this.graphqlHeaders(),
148
+ });
149
+ }
150
+ /** Batch GraphQL requests (array of operations); used for fetching friendly names. */
151
+ async postGraphqlBatch(bodies) {
152
+ if (bodies.length === 0)
153
+ return [];
154
+ const result = await this.request({
155
+ method: "POST",
156
+ url: "/nexus/v1/graphql",
157
+ body: bodies,
158
+ throwOnError: true,
159
+ errorPrefix: "GraphQL batch ",
160
+ extraHeaders: this.graphqlHeaders(),
161
+ });
162
+ return Array.isArray(result) ? result : [];
163
+ }
164
+ graphqlHeaders() {
165
+ return {
166
+ "x-amzn-client": "AlexaApp",
167
+ "x-amzn-build-version": "2.2.706594",
168
+ "x-amzn-os-name": "ios",
169
+ "x-amzn-devicetype": "phone",
170
+ "x-amzn-devicetype-id": "A2IVLV5VM2W81",
171
+ "x-amzn-marketplace-id": "A1F83G8C2ARO7P",
172
+ "User-Agent": "Alexa/2.2.706594 CFNetwork/3860.500.111 Darwin/25.4.0",
173
+ Accept: "*/*",
174
+ };
175
+ }
176
+ /** GraphQL ControlPageBanner query returns friendlyNameObject.value.text. */
177
+ static FRIENDLY_NAME_QUERY = `query ControlPageBanner($endpointId: String!) {
178
+ endpoint(id: $endpointId) {
179
+ id
180
+ friendlyNameObject { value { text __typename } __typename }
181
+ __typename
182
+ }
183
+ }`;
184
+ /** Batch-fetch friendly names for endpoint IDs. Returns map endpointId -> friendlyName. */
185
+ async fetchFriendlyNames(endpointIds) {
186
+ const map = new Map();
187
+ const amzn = endpointIds.filter((id) => id.startsWith("amzn1."));
188
+ if (amzn.length === 0)
189
+ return map;
190
+ const bodies = amzn.map((endpointId) => ({
191
+ operationName: "ControlPageBanner",
192
+ variables: { endpointId },
193
+ query: AlexaClient.FRIENDLY_NAME_QUERY,
194
+ }));
195
+ const results = await this.postGraphqlBatch(bodies);
196
+ for (let i = 0; i < amzn.length; i++) {
197
+ const r = results[i];
198
+ const text = r?.data?.endpoint?.friendlyNameObject?.value?.text;
199
+ if (text)
200
+ map.set(amzn[i], text);
201
+ }
202
+ return map;
203
+ }
204
+ /**
205
+ * POST /api/smarthome/v2/endpoints — used by the Alexa app for device list.
206
+ * Returns endpoints array; names may be encrypted (we use serialNumber as display when missing).
207
+ */
208
+ async fetchSmarthomeV2Endpoints() {
209
+ const config = getConfig(this.domain);
210
+ const creds = await this.ensureAuth();
211
+ const url = `${config.appApiBase.replace(/\/$/, "")}/api/smarthome/v2/endpoints`;
212
+ const res = await fetch(url, {
213
+ method: "POST",
214
+ headers: {
215
+ Cookie: creds.cookies,
216
+ csrf: creds.csrf,
217
+ "Content-Type": "application/json; charset=utf-8",
218
+ Accept: "application/json; charset=utf-8",
219
+ "Accept-Language": config.locale + "," + config.locale + ";q=1.0",
220
+ "User-Agent": "AppleWebKit PitanguiBridge/2.2.706594.0 (iPhone; iOS)",
221
+ },
222
+ body: JSON.stringify({ endpointContexts: ["GROUP"] }),
223
+ });
224
+ const text = await res.text();
225
+ if (process.env.ALEXA_DEBUG) {
226
+ console.error(`[alexa-mcp] POST ${url} → ${res.status} (body length ${text.length})`);
227
+ }
228
+ if (!res.ok) {
229
+ return { data: {}, status: res.status };
230
+ }
231
+ if (!text.trim()) {
232
+ return { data: {}, status: res.status };
233
+ }
234
+ try {
235
+ return { data: JSON.parse(text), status: res.status };
236
+ }
237
+ catch {
238
+ return { data: {}, status: res.status };
239
+ }
240
+ }
241
+ async getDevices() {
242
+ const data = (await this.getFromAppApi("/api/devices-v2/device?cached=true"));
243
+ return data?.devices ?? [];
244
+ }
245
+ async resolveDevice(deviceQuery) {
246
+ const devices = await this.getDevices();
247
+ const q = deviceQuery.toLowerCase().trim();
248
+ const bySerial = devices.find((d) => d.serialNumber === deviceQuery);
249
+ if (bySerial)
250
+ return bySerial;
251
+ const byName = devices.find((d) => d.accountName.toLowerCase().includes(q));
252
+ if (byName)
253
+ return byName;
254
+ return null;
255
+ }
256
+ async speak(deviceSerial, deviceType, customerId, text) {
257
+ const config = getConfig(this.domain);
258
+ const sequence = {
259
+ "@type": "com.amazon.alexa.behaviors.model.Sequence",
260
+ startNode: {
261
+ "@type": "com.amazon.alexa.behaviors.model.OpaquePayloadOperationNode",
262
+ type: "Alexa.Speak",
263
+ operationPayload: {
264
+ deviceType,
265
+ deviceSerialNumber: deviceSerial,
266
+ customerId,
267
+ locale: config.locale,
268
+ textToSpeak: text,
269
+ },
270
+ },
271
+ };
272
+ await this.postApp("/api/behaviors/preview", {
273
+ behaviorId: "PREVIEW",
274
+ sequenceJson: JSON.stringify(sequence),
275
+ status: "ENABLED",
276
+ });
277
+ }
278
+ async announce(customerId, text) {
279
+ const config = getConfig(this.domain);
280
+ const sequence = {
281
+ "@type": "com.amazon.alexa.behaviors.model.Sequence",
282
+ startNode: {
283
+ "@type": "com.amazon.alexa.behaviors.model.OpaquePayloadOperationNode",
284
+ type: "AlexaAnnouncement",
285
+ operationPayload: {
286
+ expireAfter: "PT5S",
287
+ content: [
288
+ {
289
+ locale: config.locale,
290
+ display: { title: "Announcement", body: text },
291
+ speak: { type: "text", value: text },
292
+ },
293
+ ],
294
+ target: { customerId },
295
+ },
296
+ },
297
+ };
298
+ await this.postApp("/api/behaviors/preview", {
299
+ behaviorId: "PREVIEW",
300
+ sequenceJson: JSON.stringify(sequence),
301
+ status: "ENABLED",
302
+ });
303
+ }
304
+ async command(deviceSerial, deviceType, customerId, text) {
305
+ const config = getConfig(this.domain);
306
+ const sequence = {
307
+ "@type": "com.amazon.alexa.behaviors.model.Sequence",
308
+ startNode: {
309
+ "@type": "com.amazon.alexa.behaviors.model.OpaquePayloadOperationNode",
310
+ type: "Alexa.TextCommand",
311
+ skillId: "amzn1.ask.1p.tellalexa",
312
+ operationPayload: {
313
+ deviceType,
314
+ deviceSerialNumber: deviceSerial,
315
+ customerId,
316
+ locale: config.locale,
317
+ text,
318
+ },
319
+ },
320
+ };
321
+ await this.postApp("/api/behaviors/preview", {
322
+ behaviorId: "PREVIEW",
323
+ sequenceJson: JSON.stringify(sequence),
324
+ status: "ENABLED",
325
+ });
326
+ }
327
+ async listAppliances() {
328
+ const r = await this.fetchSmarthomeV2Endpoints();
329
+ const rawLayoutKeys = await this.fetchLayouts();
330
+ const layoutIds = rawLayoutKeys
331
+ .filter((id) => id.startsWith("amzn1.") || /^[0-9a-f-]{36}$/i.test(id))
332
+ .map((id) => (id.startsWith("amzn1.") ? id : `amzn1.alexa.endpoint.${id}`));
333
+ const parseSmarthomeV2Response = (data, endpointIds, friendlyNames) => {
334
+ const d = data;
335
+ const endpoints = d.endpoints ?? [];
336
+ return endpoints.map((ep, i) => {
337
+ const serial = ep.serialNumber ?? ep.identifier?.deviceSerialNumber ?? "";
338
+ const deviceType = ep.deviceType ?? ep.identifier?.deviceType ?? "";
339
+ const endpointId = endpointIds && i < endpointIds.length ? endpointIds[i] : undefined;
340
+ const friendlyName = (endpointId && friendlyNames?.get(endpointId)) ?? serial;
341
+ return {
342
+ entityId: endpointId ?? serial,
343
+ endpointId,
344
+ applianceId: ep.deviceAccountId ?? serial,
345
+ friendlyName,
346
+ applianceTypes: deviceType ? [deviceType] : [],
347
+ isReachable: true,
348
+ deviceOwnerCustomerId: ep.deviceOwnerCustomerId,
349
+ };
350
+ });
351
+ };
352
+ const endpoints = r.data?.endpoints ?? [];
353
+ const useLayoutIds = layoutIds.length === endpoints.length && layoutIds.length > 0;
354
+ if (r.status !== 200) {
355
+ if (layoutIds.length === 0)
356
+ return [];
357
+ const friendlyNames = await this.fetchFriendlyNames(layoutIds);
358
+ return layoutIds.map((endpointId) => ({
359
+ entityId: endpointId,
360
+ endpointId,
361
+ applianceId: endpointId,
362
+ friendlyName: friendlyNames.get(endpointId) ?? endpointId,
363
+ applianceTypes: [],
364
+ isReachable: true,
365
+ }));
366
+ }
367
+ let appliances;
368
+ if (useLayoutIds) {
369
+ const friendlyNames = await this.fetchFriendlyNames(layoutIds);
370
+ appliances = parseSmarthomeV2Response(r.data, layoutIds, friendlyNames);
371
+ }
372
+ else if (layoutIds.length > 0) {
373
+ const friendlyNames = await this.fetchFriendlyNames(layoutIds);
374
+ appliances = layoutIds.map((endpointId) => ({
375
+ entityId: endpointId,
376
+ endpointId,
377
+ applianceId: endpointId,
378
+ friendlyName: friendlyNames.get(endpointId) ?? endpointId,
379
+ applianceTypes: [],
380
+ isReachable: true,
381
+ }));
382
+ }
383
+ else {
384
+ appliances = parseSmarthomeV2Response(r.data);
385
+ }
386
+ return appliances;
387
+ }
388
+ /** Resolve smart home device by friendly name (case-insensitive partial match). Prefer direct GraphQL control. */
389
+ async resolveApplianceByName(name) {
390
+ const appliances = await this.listAppliances();
391
+ const q = name.toLowerCase().trim();
392
+ const match = appliances.find((a) => {
393
+ const fn = a.friendlyName?.toLowerCase() ?? "";
394
+ return fn.includes(q) || q.includes(fn);
395
+ });
396
+ return match ?? null;
397
+ }
398
+ /**
399
+ * Resolve smart home devices by pattern (e.g. "kitchen lights").
400
+ * Matches appliances whose friendlyName contains all space-separated words (case-insensitive).
401
+ * "lights" also matches "light" and vice versa. Returns all matches for room/group control.
402
+ */
403
+ async resolveAppliancesByPattern(pattern) {
404
+ const appliances = await this.listAppliances();
405
+ const words = pattern.toLowerCase().trim().split(/\s+/).filter(Boolean);
406
+ if (words.length === 0)
407
+ return [];
408
+ const matchWord = (fn, w) => {
409
+ if (fn.includes(w))
410
+ return true;
411
+ if (w.endsWith("s") && fn.includes(w.slice(0, -1)))
412
+ return true; // "lights" → "light"
413
+ if (!w.endsWith("s") && fn.includes(w + "s"))
414
+ return true; // "light" → "lights"
415
+ return false;
416
+ };
417
+ return appliances.filter((a) => {
418
+ const fn = a.friendlyName?.toLowerCase() ?? "";
419
+ return words.every((w) => matchWord(fn, w));
420
+ });
421
+ }
422
+ /**
423
+ * Control all appliances matching a pattern (e.g. "kitchen lights").
424
+ * Uses direct GraphQL/phoenix control—avoids profile/account issues from voice commands.
425
+ * Returns names of controlled devices and any errors.
426
+ */
427
+ async controlAppliancesByPattern(pattern, action) {
428
+ const appliances = await this.resolveAppliancesByPattern(pattern);
429
+ const id = (a) => a.endpointId ?? a.entityId;
430
+ const targets = appliances
431
+ .map((a) => ({ eid: id(a), name: a.friendlyName ?? a.entityId }))
432
+ .filter((t) => !!t.eid);
433
+ const errors = appliances
434
+ .filter((a) => !id(a))
435
+ .map((a) => `${a.friendlyName ?? "?"}: no endpointId/entityId`);
436
+ const results = await Promise.allSettled(targets.map((t) => this.controlAppliance(t.eid, action)));
437
+ const controlled = [];
438
+ results.forEach((r, i) => {
439
+ if (r.status === "fulfilled")
440
+ controlled.push(targets[i].name);
441
+ else
442
+ errors.push(`${targets[i].name}: ${String(r.reason)}`);
443
+ });
444
+ return { controlled, errors };
445
+ }
446
+ async controlAppliance(entityId, action, brightness) {
447
+ const useGraphql = entityId.startsWith("amzn1.alexa.endpoint.");
448
+ if (useGraphql) {
449
+ await this.graphqlControl(entityId, action, brightness);
450
+ return;
451
+ }
452
+ const params = { action };
453
+ if (action === "setBrightness") {
454
+ if (brightness === undefined)
455
+ throw new Error("brightness required for setBrightness");
456
+ params.brightness = brightness;
457
+ }
458
+ await this.putApp("/api/phoenix/state", {
459
+ controlRequests: [
460
+ {
461
+ entityId,
462
+ entityType: "APPLIANCE",
463
+ parameters: params,
464
+ },
465
+ ],
466
+ });
467
+ }
468
+ async setBrightness(entityId, brightness) {
469
+ const useGraphql = entityId.startsWith("amzn1.alexa.endpoint.");
470
+ if (useGraphql) {
471
+ await this.graphqlControl(entityId, "setBrightness", brightness);
472
+ return;
473
+ }
474
+ await this.putApp("/api/phoenix/state", {
475
+ controlRequests: [
476
+ {
477
+ entityId,
478
+ entityType: "APPLIANCE",
479
+ parameters: { action: "setBrightness", brightness },
480
+ },
481
+ ],
482
+ });
483
+ }
484
+ /** Get full automation (includes sequence) from app API. Used for run. */
485
+ async getAutomation(automationId) {
486
+ try {
487
+ const data = (await this.getApp(`/api/behaviors/automations/${encodeURIComponent(automationId)}`));
488
+ if (!data?.automationId)
489
+ return null;
490
+ return {
491
+ automationId: data.automationId,
492
+ name: data.name,
493
+ sequence: data.sequence,
494
+ sequenceJson: typeof data.sequenceJson === "string"
495
+ ? data.sequenceJson
496
+ : data.sequence != null
497
+ ? JSON.stringify(data.sequence)
498
+ : undefined,
499
+ };
500
+ }
501
+ catch {
502
+ return null;
503
+ }
504
+ }
505
+ /** GET /api/phoenix/group — room/space groups (Living room, Kitchen, etc.) with appliance membership. */
506
+ async listDeviceGroups() {
507
+ const groups = await this.listDeviceGroupsWithAppliances();
508
+ return groups.map(({ chrEntityIds, ...g }) => ({ ...g, applianceCount: chrEntityIds.length }));
509
+ }
510
+ /** Like listDeviceGroups but includes chrEntityIds for each group (from chrEndpoints). */
511
+ async listDeviceGroupsWithAppliances() {
512
+ const data = (await this.getApp("/api/phoenix/group"));
513
+ const groups = data?.applianceGroups ?? [];
514
+ return groups.map((g) => {
515
+ const chrEntityIds = (g.chrEndpoints ?? [])
516
+ .map((e) => e.entityId)
517
+ .filter((id) => !!id);
518
+ return {
519
+ name: g.name ?? "",
520
+ groupId: g.groupId ?? "",
521
+ type: g.type ?? "SPACE",
522
+ applianceCount: chrEntityIds.length,
523
+ chrEntityIds,
524
+ };
525
+ });
526
+ }
527
+ /**
528
+ * Control appliances in a room/space group by name (e.g. "Kitchen").
529
+ * Uses chrEntityIds from phoenix group → amzn1.alexa.endpoint.{id} for GraphQL.
530
+ * lightsOnly (default true) filters to devices with light/lamp/bulb in friendlyName when available.
531
+ */
532
+ async controlAppliancesByGroup(groupName, action, options) {
533
+ const groups = await this.listDeviceGroupsWithAppliances();
534
+ const q = groupName.toLowerCase().trim();
535
+ const group = groups.find((g) => g.name.toLowerCase() === q || g.name.toLowerCase().includes(q));
536
+ if (!group) {
537
+ throw new Error(`Group not found: "${groupName}". Use list_device_groups to see groups.`);
538
+ }
539
+ const appliances = await this.listAppliances();
540
+ const uuidToAppliance = new Map();
541
+ for (const a of appliances) {
542
+ const eid = a.endpointId ?? a.entityId;
543
+ if (eid) {
544
+ const uuid = eid.startsWith("amzn1.alexa.endpoint.") ? eid.replace("amzn1.alexa.endpoint.", "") : eid;
545
+ uuidToAppliance.set(uuid.toLowerCase(), a);
546
+ }
547
+ }
548
+ const lightsOnly = options?.lightsOnly ?? true;
549
+ const lightRe = /light|lamp|bulb/i;
550
+ const targets = [];
551
+ for (const chrId of group.chrEntityIds) {
552
+ const app = uuidToAppliance.get(chrId.toLowerCase());
553
+ const name = app?.friendlyName ?? chrId;
554
+ if (lightsOnly && app && !lightRe.test(name))
555
+ continue; // skip non-lights when we have friendlyName
556
+ const endpointId = chrId.includes(".") ? chrId : `amzn1.alexa.endpoint.${chrId}`;
557
+ targets.push({ endpointId, name });
558
+ }
559
+ const results = await Promise.allSettled(targets.map((t) => this.controlAppliance(t.endpointId, action)));
560
+ const controlled = [];
561
+ const errors = [];
562
+ results.forEach((r, i) => {
563
+ if (r.status === "fulfilled")
564
+ controlled.push(targets[i].name);
565
+ else
566
+ errors.push(`${targets[i].name}: ${String(r.reason)}`);
567
+ });
568
+ return { controlled, errors };
569
+ }
570
+ /** GET /api/wholeHomeAudio/v1/groups — multi-room audio speaker groups (Downstairs, Everywhere, etc.). */
571
+ async listAudioGroups() {
572
+ const data = (await this.getApp("/api/wholeHomeAudio/v1/groups"));
573
+ const groups = data?.groups ?? [];
574
+ return groups.map((g) => ({
575
+ id: g.id ?? "",
576
+ name: g.name ?? "",
577
+ members: (g.members ?? []).map((m) => ({
578
+ deviceType: m.deviceType ?? "",
579
+ dsn: m.dsn ?? "",
580
+ speakerChannel: m.speakerChannel ?? "all",
581
+ })),
582
+ }));
583
+ }
584
+ async listRoutines() {
585
+ const data = (await this.getApp("/api/routines/routinesandgroups"));
586
+ const routines = data?.routines ?? [];
587
+ return routines.map((r) => ({
588
+ automationId: r.automationId ?? "",
589
+ name: r.primary ?? r.secondary ?? "",
590
+ sequence: undefined,
591
+ status: r.status,
592
+ type: r.type,
593
+ }));
594
+ }
595
+ async runRoutine(automationId, sequenceJson) {
596
+ let payload = sequenceJson;
597
+ if (!payload) {
598
+ const automation = await this.getAutomation(automationId);
599
+ payload = automation?.sequenceJson ?? automation?.sequence != null ? JSON.stringify(automation.sequence) : undefined;
600
+ if (!payload) {
601
+ throw new Error(`Could not get sequence for routine ${automationId}. Fetch automation failed or list did not include sequence.`);
602
+ }
603
+ }
604
+ await this.postApp("/api/behaviors/preview", {
605
+ behaviorId: automationId,
606
+ sequenceJson: payload,
607
+ status: "ENABLED",
608
+ });
609
+ }
610
+ /** Media: now-playing state for a device. Returns taskSessionId when something is playing. */
611
+ async getNowPlaying(deviceSerialNumber, deviceType) {
612
+ const q = new URLSearchParams({
613
+ deviceSerialNumber,
614
+ deviceType,
615
+ screenWidth: "375",
616
+ });
617
+ const data = (await this.getFromAppApi(`/api/np/player?${q.toString()}`));
618
+ // taskSessionId may be at root or nested inside playerInfo
619
+ let taskSessionId = data?.taskSessionId ?? data?.playerInfo?.taskSessionId;
620
+ // Fallback: list-media-sessions returns active sessions with taskSessionId
621
+ if (!taskSessionId) {
622
+ const sq = new URLSearchParams({ deviceSerialNumber, deviceType });
623
+ const sessions = (await this.getFromAppApi(`/api/np/list-media-sessions?${sq.toString()}`));
624
+ taskSessionId = sessions?.mediaSessionList?.[0]?.taskSessionId;
625
+ }
626
+ return { ...(data ?? {}), ...(taskSessionId ? { taskSessionId } : {}) };
627
+ }
628
+ /** Media: list active media sessions. */
629
+ async listMediaSessions() {
630
+ return this.getFromAppApi("/api/np/list-media-sessions");
631
+ }
632
+ /** Media: transport control (play, pause, resume, stop, next, previous). */
633
+ async controlMediaSession(device, taskSessionId, command) {
634
+ const commandTypes = {
635
+ play: "NPPlayCommand",
636
+ pause: "NPPauseCommand",
637
+ resume: "NPResumeCommand",
638
+ stop: "NPStopCommand",
639
+ next: "NPNextCommand",
640
+ previous: "NPPreviousCommand",
641
+ };
642
+ const typeName = commandTypes[command];
643
+ if (!typeName)
644
+ throw new Error(`Unknown media command: ${command}`);
645
+ const controllerEndpoint = {
646
+ __type: "NPSingletonEndpoint:http://internal.amazon.com/coral/com.amazon.dee.web.coral.model/",
647
+ id: {
648
+ __type: "NPEndpointIdentifier:http://internal.amazon.com/coral/com.amazon.dee.web.coral.model/",
649
+ deviceSerialNumber: device.serialNumber,
650
+ deviceType: device.deviceType,
651
+ },
652
+ };
653
+ await this.postFromAppApi("/api/np/control-media-session", {
654
+ taskSessionId,
655
+ command: { type: typeName },
656
+ controllerEndpoint,
657
+ });
658
+ }
659
+ }
660
+ //# sourceMappingURL=client.js.map