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