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