@camstack/addon-import-alexa 0.2.149 → 0.2.151
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 +9 -0
- package/assets/alexa-echo.LICENSE.md +13 -0
- package/assets/alexa-echo.svg +1 -0
- package/dist/{MotionZonesSettings-BD57OAIs.mjs → MotionZonesSettings-Dxo0EAzN.mjs} +2 -2
- package/dist/{PrivacyMaskSettings-ZxdPEelf.mjs → PrivacyMaskSettings-DwWxpZrj.mjs} +4 -4
- package/dist/{SceneMonitorEditor--KqtKX1B.mjs → SceneMonitorEditor-CC0dk5TR.mjs} +3 -3
- package/dist/_stub.js +250 -349
- package/dist/{_virtual_mf-localSharedImportMap___mfe_internal__addon_import_alexa_page-CdRL7bFZ.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_import_alexa_page-CcQjQJmj.mjs} +3 -3
- package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Msberbw_.mjs +26 -0
- package/dist/addon.js +1108 -598
- package/dist/addon.mjs +1110 -600
- package/dist/{hostInit-_k3o3MHN.mjs → hostInit-CRUwUtSm.mjs} +2 -2
- package/dist/{player-overlays-CJBC2I_E.mjs → player-overlays-DNpsEqAe.mjs} +1 -1
- package/dist/remoteEntry.js +1 -1
- package/dist/{responsive-N99niSJu.mjs → responsive-B82v3AbM.mjs} +1 -1
- package/dist/{scene-monitor-copy-DeuPVYmS.mjs → scene-monitor-copy-CPnjwzw6.mjs} +1 -1
- package/dist/{square-CnLcG0Zc.mjs → square-YTl_L1_3.mjs} +1 -1
- package/dist/{trash-2-BzimUQsl.mjs → trash-2-DCHugLVt.mjs} +1 -1
- package/dist/{virtual_mf-REMOTE_ENTRY_ID___mfe_internal__addon_import_alexa_page__remoteEntry_js-CsdMNhUS.mjs → virtual_mf-REMOTE_ENTRY_ID___mfe_internal__addon_import_alexa_page__remoteEntry_js-DMdgQOgz.mjs} +1 -1
- package/i18n/en.json +7 -5
- package/i18n/it.json +7 -5
- package/package.json +1 -1
- package/python/alexa_gateway.py +85 -58
- package/python/alexa_protocol.py +22 -17
- package/python/test_alexa_gateway.py +141 -0
- package/python/test_alexa_protocol.py +44 -35
- package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-I8eNxvdp.mjs +0 -26
package/dist/addon.js
CHANGED
|
@@ -41,11 +41,11 @@ node_fs = __toESM(node_fs, 1);
|
|
|
41
41
|
let node_path = require("node:path");
|
|
42
42
|
let node_path$1 = __toESM(node_path, 1);
|
|
43
43
|
node_path = __toESM(node_path);
|
|
44
|
-
let node_url = require("node:url");
|
|
45
44
|
let node_fs_promises = require("node:fs/promises");
|
|
46
45
|
node_fs_promises = __toESM(node_fs_promises, 1);
|
|
47
46
|
let node_child_process = require("node:child_process");
|
|
48
47
|
require("node:zlib");
|
|
48
|
+
let node_url = require("node:url");
|
|
49
49
|
//#region ../types/dist/event-category-CT-hz5Rb.mjs
|
|
50
50
|
var EventCategory = /* @__PURE__ */ function(EventCategory) {
|
|
51
51
|
EventCategory["SystemBoot"] = "system.boot";
|
|
@@ -5622,7 +5622,7 @@ var ZodIssueCode = {
|
|
|
5622
5622
|
var ZodFirstPartyTypeKind;
|
|
5623
5623
|
ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
|
|
5624
5624
|
//#endregion
|
|
5625
|
-
//#region ../types/dist/sleep
|
|
5625
|
+
//#region ../types/dist/sleep--jXS9hWo.mjs
|
|
5626
5626
|
/**
|
|
5627
5627
|
* The audio chunk plane's byte format, and the ONE expansion from a coded
|
|
5628
5628
|
* window to float samples (D455).
|
|
@@ -9025,6 +9025,7 @@ var CAP_NAMES = Object.freeze([
|
|
|
9025
9025
|
"filesystem-browse",
|
|
9026
9026
|
"flood",
|
|
9027
9027
|
"gas",
|
|
9028
|
+
"group-active",
|
|
9028
9029
|
"group-commands",
|
|
9029
9030
|
"group-status",
|
|
9030
9031
|
"heating-valve",
|
|
@@ -9846,6 +9847,10 @@ Object.freeze({
|
|
|
9846
9847
|
"path": "detected",
|
|
9847
9848
|
"valueType": "boolean"
|
|
9848
9849
|
}],
|
|
9850
|
+
"group-active": [{
|
|
9851
|
+
"path": "active",
|
|
9852
|
+
"valueType": "boolean"
|
|
9853
|
+
}],
|
|
9849
9854
|
"group-status": [
|
|
9850
9855
|
{
|
|
9851
9856
|
"path": "included",
|
|
@@ -11211,7 +11216,7 @@ function errMsg(err) {
|
|
|
11211
11216
|
return String(err);
|
|
11212
11217
|
}
|
|
11213
11218
|
//#endregion
|
|
11214
|
-
//#region ../types/dist/composition-
|
|
11219
|
+
//#region ../types/dist/composition-Bb8iwHEJ.mjs
|
|
11215
11220
|
/**
|
|
11216
11221
|
* The ffmpeg build every CamStack node downloads, per platform and arch.
|
|
11217
11222
|
*
|
|
@@ -11526,6 +11531,13 @@ var table = {
|
|
|
11526
11531
|
apply: () => {
|
|
11527
11532
|
throw new ExpressionEvalError("latchedAt: stateful evaluation context is required");
|
|
11528
11533
|
}
|
|
11534
|
+
},
|
|
11535
|
+
schmitt: {
|
|
11536
|
+
minArgs: 3,
|
|
11537
|
+
maxArgs: 5,
|
|
11538
|
+
apply: () => {
|
|
11539
|
+
throw new ExpressionEvalError("schmitt: stateful evaluation context is required");
|
|
11540
|
+
}
|
|
11529
11541
|
}
|
|
11530
11542
|
};
|
|
11531
11543
|
Object.freeze(Object.assign(Object.create(null), table));
|
|
@@ -12036,7 +12048,7 @@ var RESET_PERIODS = [
|
|
|
12036
12048
|
5 * MINUTE_MS;
|
|
12037
12049
|
24 * HOUR_MS, 2 * DAY_MS, 7 * DAY_MS, 30 * DAY_MS;
|
|
12038
12050
|
7 * DAY_MS, 30 * DAY_MS, 365 * DAY_MS;
|
|
12039
|
-
var
|
|
12051
|
+
var rollingWindowObject = object({
|
|
12040
12052
|
kind: literal("rolling"),
|
|
12041
12053
|
span: _enum(ROLLING_SPANS),
|
|
12042
12054
|
customMinutes: number().int().min(5).optional()
|
|
@@ -12053,7 +12065,9 @@ var StatisticWindowSchema = discriminatedUnion("kind", [object({
|
|
|
12053
12065
|
path: ["customMinutes"],
|
|
12054
12066
|
input: payload.value
|
|
12055
12067
|
});
|
|
12056
|
-
})
|
|
12068
|
+
});
|
|
12069
|
+
var RollingWindowSchema = rollingWindowObject;
|
|
12070
|
+
var StatisticWindowSchema = discriminatedUnion("kind", [rollingWindowObject, object({
|
|
12057
12071
|
kind: literal("calendar"),
|
|
12058
12072
|
period: _enum(CALENDAR_PERIODS)
|
|
12059
12073
|
})]);
|
|
@@ -12083,7 +12097,13 @@ var GROUP_TEMPLATE_IDS = [
|
|
|
12083
12097
|
"occupancy",
|
|
12084
12098
|
"binary",
|
|
12085
12099
|
"switch",
|
|
12086
|
-
"light"
|
|
12100
|
+
"light",
|
|
12101
|
+
"fan",
|
|
12102
|
+
"cover",
|
|
12103
|
+
"valve",
|
|
12104
|
+
"lock",
|
|
12105
|
+
"media-player",
|
|
12106
|
+
"climate"
|
|
12087
12107
|
];
|
|
12088
12108
|
var NUMERIC_AGGREGATES = [
|
|
12089
12109
|
"mean",
|
|
@@ -12142,6 +12162,42 @@ var HelperSpecSchema = discriminatedUnion("kind", [
|
|
|
12142
12162
|
reset: _enum(RESET_PERIODS),
|
|
12143
12163
|
output: _enum(["energy", "meter"]),
|
|
12144
12164
|
source: HelperMemberSchema
|
|
12165
|
+
}),
|
|
12166
|
+
object({
|
|
12167
|
+
kind: literal("threshold"),
|
|
12168
|
+
source: HelperMemberSchema,
|
|
12169
|
+
high: number(),
|
|
12170
|
+
low: number(),
|
|
12171
|
+
onForMs: number().int().nonnegative(),
|
|
12172
|
+
offForMs: number().int().nonnegative()
|
|
12173
|
+
}),
|
|
12174
|
+
object({
|
|
12175
|
+
kind: literal("arithmetic"),
|
|
12176
|
+
op: _enum([
|
|
12177
|
+
"sum",
|
|
12178
|
+
"difference",
|
|
12179
|
+
"ratio"
|
|
12180
|
+
]),
|
|
12181
|
+
inputs: array(HelperMemberSchema).min(2).max(64)
|
|
12182
|
+
}),
|
|
12183
|
+
object({
|
|
12184
|
+
kind: literal("expression"),
|
|
12185
|
+
expr: string().min(1),
|
|
12186
|
+
inputs: array(object({
|
|
12187
|
+
name: string().min(1),
|
|
12188
|
+
member: HelperMemberSchema
|
|
12189
|
+
})).min(1).max(64),
|
|
12190
|
+
output: _enum(["number", "boolean"])
|
|
12191
|
+
}),
|
|
12192
|
+
object({
|
|
12193
|
+
kind: literal("derivative"),
|
|
12194
|
+
window: RollingWindowSchema,
|
|
12195
|
+
per: _enum([
|
|
12196
|
+
"second",
|
|
12197
|
+
"minute",
|
|
12198
|
+
"hour"
|
|
12199
|
+
]),
|
|
12200
|
+
source: HelperMemberSchema
|
|
12145
12201
|
})
|
|
12146
12202
|
]);
|
|
12147
12203
|
/** A composed device's stableId is this prefix plus the block id. */
|
|
@@ -12204,6 +12260,12 @@ var CompositionFieldSourceSchema = discriminatedUnion("kind", [
|
|
|
12204
12260
|
"all"
|
|
12205
12261
|
]),
|
|
12206
12262
|
reads: array(CompositionFieldReadSchema).min(1).max(64),
|
|
12263
|
+
/**
|
|
12264
|
+
* Set when the reads are an enum state, not a yes/no: the listed states
|
|
12265
|
+
* count as active (a cover is `open`). A state outside the list is inactive;
|
|
12266
|
+
* a read that is not a state at all is left out. Only `any` / `all` use it.
|
|
12267
|
+
*/
|
|
12268
|
+
activeWhen: array(string().min(1)).min(1).optional(),
|
|
12207
12269
|
/** `value` is the reading; the others are the member counts. */
|
|
12208
12270
|
publish: _enum([
|
|
12209
12271
|
"value",
|
|
@@ -18938,13 +19000,17 @@ Object.freeze({
|
|
|
18938
19000
|
latchedAt: {
|
|
18939
19001
|
kind: "fixed",
|
|
18940
19002
|
result: ["number", "null"]
|
|
19003
|
+
},
|
|
19004
|
+
schmitt: {
|
|
19005
|
+
kind: "fixed",
|
|
19006
|
+
result: ["boolean", "null"]
|
|
18941
19007
|
}
|
|
18942
19008
|
});
|
|
18943
19009
|
/**
|
|
18944
19010
|
* One catalog row per base reading a group helper can build (D944 §3).
|
|
18945
19011
|
* Data, not a form default written into the block.
|
|
18946
19012
|
*/
|
|
18947
|
-
var NOW$
|
|
19013
|
+
var NOW$2 = {
|
|
18948
19014
|
fieldPath: "lastFetchedAt",
|
|
18949
19015
|
expr: "now"
|
|
18950
19016
|
};
|
|
@@ -18958,6 +19024,8 @@ function numeric(input) {
|
|
|
18958
19024
|
family: "numeric",
|
|
18959
19025
|
deviceType: DeviceType.Sensor,
|
|
18960
19026
|
memberDeviceType: DeviceType.Sensor,
|
|
19027
|
+
memberDeviceTypes: [DeviceType.Sensor],
|
|
19028
|
+
activeWhen: void 0,
|
|
18961
19029
|
commandCaps: [],
|
|
18962
19030
|
baseUnit: baseUnitOf(input.quantity),
|
|
18963
19031
|
aggregates: NUMERIC_AGGREGATES,
|
|
@@ -18973,6 +19041,8 @@ function binary(input) {
|
|
|
18973
19041
|
family: "binary",
|
|
18974
19042
|
deviceType: DeviceType.Sensor,
|
|
18975
19043
|
memberDeviceType: DeviceType.Sensor,
|
|
19044
|
+
memberDeviceTypes: [DeviceType.Sensor],
|
|
19045
|
+
activeWhen: void 0,
|
|
18976
19046
|
commandCaps: [],
|
|
18977
19047
|
quantity: "none",
|
|
18978
19048
|
baseUnit: void 0,
|
|
@@ -18990,7 +19060,7 @@ var GROUP_TEMPLATES = [
|
|
|
18990
19060
|
quantity: "temperature",
|
|
18991
19061
|
defaultAggregate: "mean",
|
|
18992
19062
|
empty: "null",
|
|
18993
|
-
companions: [NOW$
|
|
19063
|
+
companions: [NOW$2]
|
|
18994
19064
|
}),
|
|
18995
19065
|
numeric({
|
|
18996
19066
|
id: "humidity",
|
|
@@ -19001,7 +19071,7 @@ var GROUP_TEMPLATES = [
|
|
|
19001
19071
|
quantity: "humidity",
|
|
19002
19072
|
defaultAggregate: "mean",
|
|
19003
19073
|
empty: "null",
|
|
19004
|
-
companions: [NOW$
|
|
19074
|
+
companions: [NOW$2]
|
|
19005
19075
|
}),
|
|
19006
19076
|
numeric({
|
|
19007
19077
|
id: "pressure",
|
|
@@ -19012,7 +19082,7 @@ var GROUP_TEMPLATES = [
|
|
|
19012
19082
|
quantity: "pressure",
|
|
19013
19083
|
defaultAggregate: "mean",
|
|
19014
19084
|
empty: "null",
|
|
19015
|
-
companions: [NOW$
|
|
19085
|
+
companions: [NOW$2]
|
|
19016
19086
|
}),
|
|
19017
19087
|
numeric({
|
|
19018
19088
|
id: "illuminance",
|
|
@@ -19023,7 +19093,7 @@ var GROUP_TEMPLATES = [
|
|
|
19023
19093
|
quantity: "illuminance",
|
|
19024
19094
|
defaultAggregate: "mean",
|
|
19025
19095
|
empty: "null",
|
|
19026
|
-
companions: [NOW$
|
|
19096
|
+
companions: [NOW$2]
|
|
19027
19097
|
}),
|
|
19028
19098
|
numeric({
|
|
19029
19099
|
id: "power",
|
|
@@ -19034,7 +19104,7 @@ var GROUP_TEMPLATES = [
|
|
|
19034
19104
|
quantity: "power",
|
|
19035
19105
|
defaultAggregate: "sum",
|
|
19036
19106
|
empty: "omit",
|
|
19037
|
-
companions: [NOW$
|
|
19107
|
+
companions: [NOW$2]
|
|
19038
19108
|
}),
|
|
19039
19109
|
numeric({
|
|
19040
19110
|
id: "energy",
|
|
@@ -19045,7 +19115,7 @@ var GROUP_TEMPLATES = [
|
|
|
19045
19115
|
quantity: "energy",
|
|
19046
19116
|
defaultAggregate: "sum",
|
|
19047
19117
|
empty: "omit",
|
|
19048
|
-
companions: [NOW$
|
|
19118
|
+
companions: [NOW$2]
|
|
19049
19119
|
}),
|
|
19050
19120
|
numeric({
|
|
19051
19121
|
id: "battery",
|
|
@@ -19080,7 +19150,7 @@ var GROUP_TEMPLATES = [
|
|
|
19080
19150
|
quantity: "device",
|
|
19081
19151
|
defaultAggregate: "mean",
|
|
19082
19152
|
empty: "null",
|
|
19083
|
-
companions: [NOW$
|
|
19153
|
+
companions: [NOW$2]
|
|
19084
19154
|
}),
|
|
19085
19155
|
binary({
|
|
19086
19156
|
id: "motion",
|
|
@@ -19174,6 +19244,11 @@ var GROUP_TEMPLATES = [
|
|
|
19174
19244
|
summary: "On when at least one switch is on, or when all of them are. Offline and unknown members are left out of that decision, never read as off, and still receive the command.",
|
|
19175
19245
|
deviceType: DeviceType.Switch,
|
|
19176
19246
|
outputCap: "switch",
|
|
19247
|
+
outputField: "on",
|
|
19248
|
+
member: {
|
|
19249
|
+
cap: "switch",
|
|
19250
|
+
fieldPath: "on"
|
|
19251
|
+
},
|
|
19177
19252
|
commandCaps: ["switch"]
|
|
19178
19253
|
}),
|
|
19179
19254
|
actuator({
|
|
@@ -19181,29 +19256,130 @@ var GROUP_TEMPLATES = [
|
|
|
19181
19256
|
summary: "On when at least one light is on, or when all of them are. Brightness and colour are offered only when every member has them. A command is not sent to a member that lacks it.",
|
|
19182
19257
|
deviceType: DeviceType.Light,
|
|
19183
19258
|
outputCap: "switch",
|
|
19259
|
+
outputField: "on",
|
|
19260
|
+
member: {
|
|
19261
|
+
cap: "switch",
|
|
19262
|
+
fieldPath: "on"
|
|
19263
|
+
},
|
|
19184
19264
|
commandCaps: [
|
|
19185
19265
|
"switch",
|
|
19186
19266
|
"brightness",
|
|
19187
19267
|
"color"
|
|
19188
19268
|
]
|
|
19269
|
+
}),
|
|
19270
|
+
actuator({
|
|
19271
|
+
id: "fan",
|
|
19272
|
+
summary: "On when at least one fan is on, or when all of them are. Speed, preset, direction and oscillation are offered only when every fan has them. A command is not sent to a fan that lacks it.",
|
|
19273
|
+
deviceType: DeviceType.Fan,
|
|
19274
|
+
outputCap: "switch",
|
|
19275
|
+
outputField: "on",
|
|
19276
|
+
member: {
|
|
19277
|
+
cap: "switch",
|
|
19278
|
+
fieldPath: "on"
|
|
19279
|
+
},
|
|
19280
|
+
commandCaps: ["switch", "fan-control"]
|
|
19281
|
+
}),
|
|
19282
|
+
actuator({
|
|
19283
|
+
id: "cover",
|
|
19284
|
+
summary: "Open when at least one cover is open or part-open, or only when all of them are. Position and tilt are offered only when every cover has them. A cover that reports nothing is left out, never read as closed.",
|
|
19285
|
+
deviceType: DeviceType.Cover,
|
|
19286
|
+
outputCap: "group-active",
|
|
19287
|
+
outputField: "active",
|
|
19288
|
+
member: {
|
|
19289
|
+
cap: "cover",
|
|
19290
|
+
fieldPath: "state"
|
|
19291
|
+
},
|
|
19292
|
+
activeWhen: [
|
|
19293
|
+
"open",
|
|
19294
|
+
"opening",
|
|
19295
|
+
"stopped"
|
|
19296
|
+
],
|
|
19297
|
+
commandCaps: ["cover"]
|
|
19298
|
+
}),
|
|
19299
|
+
actuator({
|
|
19300
|
+
id: "valve",
|
|
19301
|
+
summary: "Open when at least one valve is open or part-open, or only when all of them are. Position is offered only when every valve has it. A valve that reports nothing is left out, never read as closed.",
|
|
19302
|
+
deviceType: DeviceType.Valve,
|
|
19303
|
+
outputCap: "group-active",
|
|
19304
|
+
outputField: "active",
|
|
19305
|
+
member: {
|
|
19306
|
+
cap: "valve",
|
|
19307
|
+
fieldPath: "state"
|
|
19308
|
+
},
|
|
19309
|
+
activeWhen: [
|
|
19310
|
+
"open",
|
|
19311
|
+
"opening",
|
|
19312
|
+
"stopped"
|
|
19313
|
+
],
|
|
19314
|
+
commandCaps: ["valve"]
|
|
19315
|
+
}),
|
|
19316
|
+
actuator({
|
|
19317
|
+
id: "lock",
|
|
19318
|
+
summary: "Locked when at least one lock is locked, or only when all of them are. A jammed or moving lock is not locked. Locking is one tap; unlocking the group needs a confirmation every time, and nothing is sent without it.",
|
|
19319
|
+
deviceType: DeviceType.Lock,
|
|
19320
|
+
outputCap: "group-active",
|
|
19321
|
+
outputField: "active",
|
|
19322
|
+
member: {
|
|
19323
|
+
cap: "lock-control",
|
|
19324
|
+
fieldPath: "state"
|
|
19325
|
+
},
|
|
19326
|
+
activeWhen: ["locked"],
|
|
19327
|
+
commandCaps: ["lock-control"]
|
|
19328
|
+
}),
|
|
19329
|
+
actuator({
|
|
19330
|
+
id: "media-player",
|
|
19331
|
+
summary: "Playing when at least one player is playing, or only when all of them are. Volume is offered only when every player has it. A player that reports nothing is left out, never read as stopped.",
|
|
19332
|
+
deviceType: DeviceType.MediaPlayer,
|
|
19333
|
+
outputCap: "group-active",
|
|
19334
|
+
outputField: "active",
|
|
19335
|
+
member: {
|
|
19336
|
+
cap: "media-player",
|
|
19337
|
+
fieldPath: "state"
|
|
19338
|
+
},
|
|
19339
|
+
activeWhen: ["playing", "buffering"],
|
|
19340
|
+
commandCaps: ["media-player"]
|
|
19341
|
+
}),
|
|
19342
|
+
actuator({
|
|
19343
|
+
id: "climate",
|
|
19344
|
+
summary: "Running when at least one thermostat or air conditioner is not off, or only when all of them are. Target temperature and fan are offered only when every device has them. A device that reports nothing is left out, never read as off.",
|
|
19345
|
+
deviceType: DeviceType.Thermostat,
|
|
19346
|
+
memberDeviceTypes: [DeviceType.Thermostat, DeviceType.Climate],
|
|
19347
|
+
outputCap: "group-active",
|
|
19348
|
+
outputField: "active",
|
|
19349
|
+
member: {
|
|
19350
|
+
cap: "climate-control",
|
|
19351
|
+
fieldPath: "mode"
|
|
19352
|
+
},
|
|
19353
|
+
activeWhen: [
|
|
19354
|
+
"heat",
|
|
19355
|
+
"cool",
|
|
19356
|
+
"auto",
|
|
19357
|
+
"heat_cool",
|
|
19358
|
+
"fan_only",
|
|
19359
|
+
"dry"
|
|
19360
|
+
],
|
|
19361
|
+
commandCaps: ["climate-control"]
|
|
19189
19362
|
})
|
|
19190
19363
|
];
|
|
19191
19364
|
function actuator(input) {
|
|
19192
19365
|
return {
|
|
19193
|
-
|
|
19366
|
+
id: input.id,
|
|
19367
|
+
summary: input.summary,
|
|
19368
|
+
deviceType: input.deviceType,
|
|
19369
|
+
outputCap: input.outputCap,
|
|
19370
|
+
outputField: input.outputField,
|
|
19371
|
+
commandCaps: input.commandCaps,
|
|
19194
19372
|
family: "actuator",
|
|
19195
19373
|
role: void 0,
|
|
19196
19374
|
memberDeviceType: input.deviceType,
|
|
19197
|
-
|
|
19375
|
+
memberDeviceTypes: input.memberDeviceTypes ?? [input.deviceType],
|
|
19376
|
+
activeWhen: input.activeWhen,
|
|
19198
19377
|
quantity: "none",
|
|
19199
19378
|
baseUnit: void 0,
|
|
19200
19379
|
aggregates: BINARY_AGGREGATES,
|
|
19201
19380
|
defaultAggregate: "any",
|
|
19202
19381
|
empty: "offline",
|
|
19203
|
-
accept: [
|
|
19204
|
-
cap: "switch",
|
|
19205
|
-
fieldPath: "on"
|
|
19206
|
-
}],
|
|
19382
|
+
accept: [input.member],
|
|
19207
19383
|
companions: [CHANGED]
|
|
19208
19384
|
};
|
|
19209
19385
|
}
|
|
@@ -47142,7 +47318,20 @@ var NcRuleInputSchema = object({
|
|
|
47142
47318
|
* save by the hub's grouping guard). `.optional()`, never `.default()`: the addon cap
|
|
47143
47319
|
* path does not run Zod defaults.
|
|
47144
47320
|
*/
|
|
47145
|
-
groupingSec: number().int().min(5).max(60).optional()
|
|
47321
|
+
groupingSec: number().int().min(5).max(60).optional(),
|
|
47322
|
+
/**
|
|
47323
|
+
* ASK ME TO CONFIRM DETECTIONS (#243): a DETECTION notification of this rule
|
|
47324
|
+
* carries "Correct" / "Wrong" buttons (and, where the channel has room, a
|
|
47325
|
+
* "Wrong, it is a <class>" choice). A tap marks the track for retraining, the
|
|
47326
|
+
* same mark the Library writes, and records whose notification it was and
|
|
47327
|
+
* when. A channel with no buttons gets the same two choices as links.
|
|
47328
|
+
*
|
|
47329
|
+
* `.optional()`, never `.default()` (a Zod default does not run on the addon
|
|
47330
|
+
* cap path): absent and `false` both mean off, which is what every rule
|
|
47331
|
+
* authored before this field meant. An editor turns it off by writing
|
|
47332
|
+
* `false`, so it needs no `clear` key.
|
|
47333
|
+
*/
|
|
47334
|
+
askVerdict: boolean().optional()
|
|
47146
47335
|
});
|
|
47147
47336
|
/**
|
|
47148
47337
|
* Partial patch for `updateRule` — any subset of the input fields, plus the
|
|
@@ -60739,7 +60928,17 @@ var groupCommandsCapability = {
|
|
|
60739
60928
|
scope: "device",
|
|
60740
60929
|
deviceNative: true,
|
|
60741
60930
|
mode: "singleton",
|
|
60742
|
-
deviceTypes: [
|
|
60931
|
+
deviceTypes: [
|
|
60932
|
+
DeviceType.Switch,
|
|
60933
|
+
DeviceType.Light,
|
|
60934
|
+
DeviceType.Fan,
|
|
60935
|
+
DeviceType.Cover,
|
|
60936
|
+
DeviceType.Valve,
|
|
60937
|
+
DeviceType.Lock,
|
|
60938
|
+
DeviceType.MediaPlayer,
|
|
60939
|
+
DeviceType.Thermostat,
|
|
60940
|
+
DeviceType.Climate
|
|
60941
|
+
],
|
|
60743
60942
|
methods: {},
|
|
60744
60943
|
status: {
|
|
60745
60944
|
schema: GroupCommandsSchema,
|
|
@@ -60757,6 +60956,49 @@ var groupCommandsCapability = {
|
|
|
60757
60956
|
history: "none"
|
|
60758
60957
|
};
|
|
60759
60958
|
/**
|
|
60959
|
+
* Whether a helper actuator group counts as active (D944): at least one
|
|
60960
|
+
* included member is, or every included member is. What "active" means is the
|
|
60961
|
+
* type's: a cover is open, a lock is locked, a media player is playing, a
|
|
60962
|
+
* climate device is not off. A group of lights, switches and fans reports
|
|
60963
|
+
* `switch.on` instead; this cap is for the types whose own state is not a
|
|
60964
|
+
* yes/no. Unknown members are left out (`group-status` counts them) and an
|
|
60965
|
+
* empty included set takes the device offline, never `active: false`.
|
|
60966
|
+
*/
|
|
60967
|
+
var GroupActiveSchema = object({
|
|
60968
|
+
active: boolean().meta({ label: "Active" }),
|
|
60969
|
+
/** Ms epoch when the slice was last updated. */
|
|
60970
|
+
lastChangedAt: number()
|
|
60971
|
+
});
|
|
60972
|
+
var groupActiveCapability = {
|
|
60973
|
+
name: "group-active",
|
|
60974
|
+
scope: "device",
|
|
60975
|
+
deviceNative: true,
|
|
60976
|
+
mode: "singleton",
|
|
60977
|
+
deviceTypes: [
|
|
60978
|
+
DeviceType.Cover,
|
|
60979
|
+
DeviceType.Valve,
|
|
60980
|
+
DeviceType.Lock,
|
|
60981
|
+
DeviceType.MediaPlayer,
|
|
60982
|
+
DeviceType.Climate,
|
|
60983
|
+
DeviceType.Thermostat
|
|
60984
|
+
],
|
|
60985
|
+
methods: {},
|
|
60986
|
+
status: {
|
|
60987
|
+
schema: GroupActiveSchema,
|
|
60988
|
+
kind: "push"
|
|
60989
|
+
},
|
|
60990
|
+
runtimeState: GroupActiveSchema,
|
|
60991
|
+
/**
|
|
60992
|
+
* Runtime-state durability: **session** — recomputed from the members on the
|
|
60993
|
+
* next source event; a restored value would outlive the members it named.
|
|
60994
|
+
*
|
|
60995
|
+
* See `RuntimeStateDurability`. Enforced by
|
|
60996
|
+
* `scripts/check-runtime-state-durability.ts`.
|
|
60997
|
+
*/
|
|
60998
|
+
durability: "session",
|
|
60999
|
+
history: "none"
|
|
61000
|
+
};
|
|
61001
|
+
/**
|
|
60760
61002
|
* How many members of a helper group answered (D944). Counts are integers,
|
|
60761
61003
|
* not measurements: zero included is a real count, never a stand-in reading.
|
|
60762
61004
|
* The reading itself stays on the output capability. History is none — the
|
|
@@ -60787,7 +61029,14 @@ var groupStatusCapability = {
|
|
|
60787
61029
|
deviceTypes: [
|
|
60788
61030
|
DeviceType.Sensor,
|
|
60789
61031
|
DeviceType.Switch,
|
|
60790
|
-
DeviceType.Light
|
|
61032
|
+
DeviceType.Light,
|
|
61033
|
+
DeviceType.Fan,
|
|
61034
|
+
DeviceType.Cover,
|
|
61035
|
+
DeviceType.Valve,
|
|
61036
|
+
DeviceType.Lock,
|
|
61037
|
+
DeviceType.MediaPlayer,
|
|
61038
|
+
DeviceType.Thermostat,
|
|
61039
|
+
DeviceType.Climate
|
|
60791
61040
|
],
|
|
60792
61041
|
methods: {},
|
|
60793
61042
|
status: {
|
|
@@ -62060,7 +62309,14 @@ var lockControlCapability = {
|
|
|
62060
62309
|
}),
|
|
62061
62310
|
unlock: method(object({
|
|
62062
62311
|
deviceId: number().int().nonnegative(),
|
|
62063
|
-
code: string().min(1).optional()
|
|
62312
|
+
code: string().min(1).optional(),
|
|
62313
|
+
/**
|
|
62314
|
+
* Honoured by a helper lock GROUP only (D944): unlocking several doors
|
|
62315
|
+
* with one call is refused unless the caller says it was confirmed. A
|
|
62316
|
+
* single lock ignores it. The refusal lives in the callee, so no
|
|
62317
|
+
* automation or API caller reaches the doors by skipping a dialog.
|
|
62318
|
+
*/
|
|
62319
|
+
confirm: boolean().optional()
|
|
62064
62320
|
}), _void(), {
|
|
62065
62321
|
kind: "mutation",
|
|
62066
62322
|
auth: "protected"
|
|
@@ -67895,7 +68151,8 @@ var switchCapability = {
|
|
|
67895
68151
|
deviceTypes: [
|
|
67896
68152
|
DeviceType.Switch,
|
|
67897
68153
|
DeviceType.Siren,
|
|
67898
|
-
DeviceType.Light
|
|
68154
|
+
DeviceType.Light,
|
|
68155
|
+
DeviceType.Fan
|
|
67899
68156
|
],
|
|
67900
68157
|
methods: { setState: method(object({
|
|
67901
68158
|
deviceId: number(),
|
|
@@ -70108,6 +70365,7 @@ method(object({}), array(TrainingBaseModelSchema).readonly(), { auth: "admin" })
|
|
|
70108
70365
|
kind: "mutation",
|
|
70109
70366
|
auth: "admin"
|
|
70110
70367
|
});
|
|
70368
|
+
DeviceFeature.FanSpeed, DeviceFeature.FanPreset, DeviceFeature.FanDirection, DeviceFeature.FanOscillating, DeviceFeature.CoverPositionable, DeviceFeature.CoverTilt, DeviceFeature.ValvePositionable, DeviceFeature.MediaPlayerVolume, DeviceFeature.ClimateFanMode;
|
|
70111
70369
|
SOURCE_CAPS.filter((cap) => cap !== "switch" && cap !== "motion");
|
|
70112
70370
|
DeviceType.Camera, DeviceType.Sensor, DeviceType.Button, DeviceType.Switch, DeviceType.PetFeeder, DeviceType.Container, DeviceType.Thermostat, DeviceType.Camera, DeviceType.Hub, DeviceType.Light, DeviceType.Siren, DeviceType.Switch, DeviceType.Sensor, DeviceType.Thermostat, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.Fan, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder, DeviceType.Dishwasher, DeviceType.Washer, DeviceType.Dryer, DeviceType.Camera, DeviceType.Camera, DeviceType.Washer, DeviceType.Dryer, DeviceType.Dishwasher, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.MediaPlayer, DeviceType.WaterHeater, DeviceType.Humidifier, DeviceType.Fan, DeviceType.Climate, DeviceType.Thermostat, DeviceType.PetFeeder;
|
|
70113
70371
|
String.raw`rose\(active(\d+), changed(\d+), (\d+(?:\.\d+)?)\)`;
|
|
@@ -70438,6 +70696,7 @@ var DEVICE_LOCAL_STATE_CAPS = {
|
|
|
70438
70696
|
featureProbe: featureProbeCapability,
|
|
70439
70697
|
flood: floodCapability,
|
|
70440
70698
|
gas: gasCapability,
|
|
70699
|
+
groupActive: groupActiveCapability,
|
|
70441
70700
|
groupCommands: groupCommandsCapability,
|
|
70442
70701
|
groupStatus: groupStatusCapability,
|
|
70443
70702
|
heatingValve: heatingValveCapability,
|
|
@@ -74932,6 +75191,13 @@ Object.freeze({
|
|
|
74932
75191
|
access: "view",
|
|
74933
75192
|
role: "viewer"
|
|
74934
75193
|
},
|
|
75194
|
+
"groupActive.getStatus": {
|
|
75195
|
+
capName: "group-active",
|
|
75196
|
+
capScope: "device",
|
|
75197
|
+
addonId: null,
|
|
75198
|
+
access: "view",
|
|
75199
|
+
role: "viewer"
|
|
75200
|
+
},
|
|
74935
75201
|
"groupCommands.getStatus": {
|
|
74936
75202
|
capName: "group-commands",
|
|
74937
75203
|
capScope: "device",
|
|
@@ -84807,6 +85073,10 @@ var SENSOR_CAP_VOCABULARY = {
|
|
|
84807
85073
|
icon: "flame"
|
|
84808
85074
|
}]
|
|
84809
85075
|
},
|
|
85076
|
+
"group-active": {
|
|
85077
|
+
scope: "device",
|
|
85078
|
+
noun: t("Group state", "Stato del gruppo")
|
|
85079
|
+
},
|
|
84810
85080
|
"group-commands": {
|
|
84811
85081
|
scope: "device",
|
|
84812
85082
|
noun: t("Group command", "Comando di gruppo")
|
|
@@ -86353,6 +86623,11 @@ Object.freeze({
|
|
|
86353
86623
|
form: "single",
|
|
86354
86624
|
optional: false
|
|
86355
86625
|
}],
|
|
86626
|
+
"groupActive.getStatus": [{
|
|
86627
|
+
name: "deviceId",
|
|
86628
|
+
form: "single",
|
|
86629
|
+
optional: false
|
|
86630
|
+
}],
|
|
86356
86631
|
"groupCommands.getStatus": [{
|
|
86357
86632
|
name: "deviceId",
|
|
86358
86633
|
form: "single",
|
|
@@ -89748,32 +90023,39 @@ var alexaProxyLoginSchema = object({
|
|
|
89748
90023
|
proxyHost: string().default("").describe("Host/IP the browser uses to reach the login proxy (blank = auto-detect)"),
|
|
89749
90024
|
proxyPort: preprocess((v) => v === "" || v === null ? void 0 : v, number().int().min(1).max(65535).default(3456)).describe("Port the local Amazon-login proxy listens on (must be reachable)")
|
|
89750
90025
|
});
|
|
89751
|
-
/**
|
|
90026
|
+
/**
|
|
90027
|
+
* Echo speech settings. Speech signs in as an import broker, so the targets
|
|
90028
|
+
* are what lives here. `email`, `password`, `region` and `session` are the OLD
|
|
90029
|
+
* standalone login: they are read once to migrate it onto the single broker
|
|
90030
|
+
* (see `speak/migration.ts`) and written back empty.
|
|
90031
|
+
*/
|
|
89752
90032
|
var alexaSpeakSettingsSchema = object({
|
|
89753
90033
|
email: string().default(""),
|
|
89754
|
-
password: string().default("").describe("Amazon password").meta({ secret: true }),
|
|
90034
|
+
password: string().default("").describe("Legacy Amazon password").meta({ secret: true }),
|
|
89755
90035
|
region: _enum([
|
|
89756
90036
|
"NA",
|
|
89757
90037
|
"EU",
|
|
89758
90038
|
"FE"
|
|
89759
90039
|
]).default("EU"),
|
|
89760
|
-
session: string().default("").describe("Alexa session blob").meta({ secret: true }),
|
|
90040
|
+
session: string().default("").describe("Legacy Alexa session blob").meta({ secret: true }),
|
|
89761
90041
|
targets: array(object({
|
|
89762
90042
|
id: string(),
|
|
89763
90043
|
name: string(),
|
|
89764
90044
|
enabled: boolean(),
|
|
90045
|
+
/** The broker (Amazon account) the Echo belongs to; '' until one is chosen. */
|
|
90046
|
+
brokerId: string().default(""),
|
|
89765
90047
|
echoId: string(),
|
|
89766
90048
|
mode: _enum(["speak", "announce"]),
|
|
89767
90049
|
volume: number(),
|
|
89768
90050
|
respectDnd: boolean()
|
|
89769
90051
|
})).default([])
|
|
89770
90052
|
});
|
|
89771
|
-
function isRecord$
|
|
90053
|
+
function isRecord$3(value) {
|
|
89772
90054
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
89773
90055
|
}
|
|
89774
90056
|
/** Resolved settings. A keychain marker (an object) is an empty secret, not a password. */
|
|
89775
90057
|
function readSpeakSettings(config) {
|
|
89776
|
-
const raw = isRecord$
|
|
90058
|
+
const raw = isRecord$3(config.speak) ? config.speak : {};
|
|
89777
90059
|
const region = raw["region"];
|
|
89778
90060
|
const targets = raw["targets"];
|
|
89779
90061
|
return alexaSpeakSettingsSchema.parse({
|
|
@@ -131889,15 +132171,15 @@ function parseCookieMaterial(raw) {
|
|
|
131889
132171
|
}
|
|
131890
132172
|
}
|
|
131891
132173
|
/** True when `value` is a non-null, non-array plain object (cast-free guard). */
|
|
131892
|
-
function isRecord$
|
|
132174
|
+
function isRecord$2(value) {
|
|
131893
132175
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
131894
132176
|
}
|
|
131895
132177
|
/** Narrow a loaded JSON file to its `cookieData` (string | object), or null. */
|
|
131896
132178
|
function readCookieData(json) {
|
|
131897
|
-
if (!isRecord$
|
|
132179
|
+
if (!isRecord$2(json)) return null;
|
|
131898
132180
|
const value = json["cookieData"];
|
|
131899
132181
|
if (typeof value === "string") return value;
|
|
131900
|
-
if (isRecord$
|
|
132182
|
+
if (isRecord$2(value)) return value;
|
|
131901
132183
|
return null;
|
|
131902
132184
|
}
|
|
131903
132185
|
//#endregion
|
|
@@ -132872,7 +133154,7 @@ function mergeAlexaMotionStatus(cached, live, nowMs) {
|
|
|
132872
133154
|
};
|
|
132873
133155
|
}
|
|
132874
133156
|
/** True when `v` is a non-null, non-array object (cast-free guard). */
|
|
132875
|
-
function isRecord(v) {
|
|
133157
|
+
function isRecord$1(v) {
|
|
132876
133158
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
132877
133159
|
}
|
|
132878
133160
|
/**
|
|
@@ -132881,7 +133163,7 @@ function isRecord(v) {
|
|
|
132881
133163
|
*/
|
|
132882
133164
|
function decodeTemperatureCelsius(value) {
|
|
132883
133165
|
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
|
132884
|
-
if (!isRecord(value)) return null;
|
|
133166
|
+
if (!isRecord$1(value)) return null;
|
|
132885
133167
|
const raw = value["value"];
|
|
132886
133168
|
const n = typeof raw === "number" ? raw : Number(raw);
|
|
132887
133169
|
if (!Number.isFinite(n)) return null;
|
|
@@ -132892,7 +133174,7 @@ function decodeTemperatureCelsius(value) {
|
|
|
132892
133174
|
function decodeHumidityPercent(value) {
|
|
132893
133175
|
const direct = decodePercentage(value);
|
|
132894
133176
|
if (direct !== null) return direct;
|
|
132895
|
-
if (!isRecord(value)) return null;
|
|
133177
|
+
if (!isRecord$1(value)) return null;
|
|
132896
133178
|
return decodePercentage(value["value"]);
|
|
132897
133179
|
}
|
|
132898
133180
|
//#endregion
|
|
@@ -133228,6 +133510,16 @@ var alexaBrokersEntryKind = defineEntryKind("brokers", {
|
|
|
133228
133510
|
}),
|
|
133229
133511
|
writeTrigger: "an operator links or removes an Alexa account broker"
|
|
133230
133512
|
});
|
|
133513
|
+
var alexaSpeakSessionsEntryKind = defineEntryKind("speak-sessions", {
|
|
133514
|
+
title: "Alexa Echo speech sessions",
|
|
133515
|
+
schema: entryBody({ session: string().default("").meta({ secret: true }) }),
|
|
133516
|
+
writeTrigger: "an Echo worker signs in as an Alexa account broker"
|
|
133517
|
+
});
|
|
133518
|
+
/** Every kind this addon declares, keyed by its name: `records.declare` refuses any other key. */
|
|
133519
|
+
var alexaRecordKinds = {
|
|
133520
|
+
brokers: alexaBrokersEntryKind,
|
|
133521
|
+
"speak-sessions": alexaSpeakSessionsEntryKind
|
|
133522
|
+
};
|
|
133231
133523
|
//#endregion
|
|
133232
133524
|
//#region src/alexa-broker-store.ts
|
|
133233
133525
|
function rowToEntry(id, data) {
|
|
@@ -133289,12 +133581,8 @@ var AlexaBrokerStore = class AlexaBrokerStore {
|
|
|
133289
133581
|
var ALEXA_SPEAK_ADDON_ID = "import-alexa";
|
|
133290
133582
|
var ALEXA_SPEAK_KIND = "alexa-echo";
|
|
133291
133583
|
var DEVICE_REFRESH_MS = 5 * 6e4;
|
|
133292
|
-
|
|
133293
|
-
var
|
|
133294
|
-
"NA",
|
|
133295
|
-
"EU",
|
|
133296
|
-
"FE"
|
|
133297
|
-
];
|
|
133584
|
+
/** Where the addon serves the Alexa mark; `listTargetKinds` stamps it as `iconUrl`. */
|
|
133585
|
+
var ALEXA_SPEAK_ICON_URL = `/addon/${ALEXA_SPEAK_ADDON_ID}/icons/${ALEXA_SPEAK_KIND}`;
|
|
133298
133586
|
//#endregion
|
|
133299
133587
|
//#region src/speak/worker-protocol.ts
|
|
133300
133588
|
/** Line-delimited JSON the long-lived Python worker speaks. One object per line. */
|
|
@@ -133305,14 +133593,11 @@ var EchoDeviceSchema = object({
|
|
|
133305
133593
|
online: boolean(),
|
|
133306
133594
|
kind: _enum(["device", "group"]),
|
|
133307
133595
|
dnd: boolean(),
|
|
133308
|
-
deviceType: string()
|
|
133596
|
+
deviceType: string(),
|
|
133597
|
+
/** The room the account files the device under, when Amazon names one. */
|
|
133598
|
+
room: string().optional()
|
|
133309
133599
|
});
|
|
133310
|
-
var LoginStatusSchema = _enum([
|
|
133311
|
-
"ok",
|
|
133312
|
-
"otp_needed",
|
|
133313
|
-
"captcha",
|
|
133314
|
-
"failed"
|
|
133315
|
-
]);
|
|
133600
|
+
var LoginStatusSchema = _enum(["ok", "failed"]);
|
|
133316
133601
|
var SessionStatusSchema = object({
|
|
133317
133602
|
loggedIn: boolean(),
|
|
133318
133603
|
ageSec: number().int().nonnegative(),
|
|
@@ -133366,40 +133651,37 @@ function parseSessionStatus(result) {
|
|
|
133366
133651
|
}
|
|
133367
133652
|
//#endregion
|
|
133368
133653
|
//#region src/speak/actions.ts
|
|
133654
|
+
var BrokerEchoStateSchema = _enum([
|
|
133655
|
+
"starting",
|
|
133656
|
+
"ready",
|
|
133657
|
+
"broker-not-signed-in",
|
|
133658
|
+
"failed"
|
|
133659
|
+
]);
|
|
133369
133660
|
var SpeakStatusSchema = object({
|
|
133370
|
-
|
|
133371
|
-
|
|
133372
|
-
|
|
133373
|
-
|
|
133374
|
-
|
|
133375
|
-
|
|
133376
|
-
|
|
133377
|
-
|
|
133378
|
-
|
|
133379
|
-
|
|
133380
|
-
|
|
133381
|
-
|
|
133382
|
-
|
|
133383
|
-
|
|
133661
|
+
brokers: array(object({
|
|
133662
|
+
id: string(),
|
|
133663
|
+
name: string(),
|
|
133664
|
+
state: BrokerEchoStateSchema,
|
|
133665
|
+
error: string().optional(),
|
|
133666
|
+
devices: array(EchoDeviceSchema),
|
|
133667
|
+
session: SessionStatusSchema
|
|
133668
|
+
})),
|
|
133669
|
+
/** Targets that name no Amazon account yet (they cannot speak until one is chosen). */
|
|
133670
|
+
unassignedTargets: number().int().nonnegative(),
|
|
133671
|
+
/** What the old standalone login left behind, named; '' when nothing is pending. */
|
|
133672
|
+
notice: _enum([
|
|
133673
|
+
"",
|
|
133674
|
+
"no-broker",
|
|
133675
|
+
"choose-broker"
|
|
133676
|
+
])
|
|
133384
133677
|
});
|
|
133385
133678
|
var alexaSpeakActions = defineCustomActions({
|
|
133386
133679
|
speakStatus: customAction(object({}), SpeakStatusSchema, {
|
|
133387
133680
|
kind: "mutation",
|
|
133388
133681
|
auth: "admin"
|
|
133389
133682
|
}),
|
|
133390
|
-
loginStart: customAction(object({
|
|
133391
|
-
email: string().min(1),
|
|
133392
|
-
password: string().min(1).meta({ secret: true }),
|
|
133393
|
-
region: _enum(ALEXA_REGIONS)
|
|
133394
|
-
}), LoginResultSchema, {
|
|
133395
|
-
kind: "mutation",
|
|
133396
|
-
auth: "admin"
|
|
133397
|
-
}),
|
|
133398
|
-
loginOtp: customAction(object({ code: string().min(1).max(12) }), LoginResultSchema, {
|
|
133399
|
-
kind: "mutation",
|
|
133400
|
-
auth: "admin"
|
|
133401
|
-
}),
|
|
133402
133683
|
sendTest: customAction(object({
|
|
133684
|
+
brokerId: string().min(1),
|
|
133403
133685
|
echoIds: array(string().min(1)).min(1),
|
|
133404
133686
|
mode: _enum(["speak", "announce"]),
|
|
133405
133687
|
volume: number().int().min(0).max(100),
|
|
@@ -133414,55 +133696,403 @@ var alexaSpeakActions = defineCustomActions({
|
|
|
133414
133696
|
})
|
|
133415
133697
|
});
|
|
133416
133698
|
//#endregion
|
|
133417
|
-
//#region src/speak/
|
|
133699
|
+
//#region src/speak/broker-seed.ts
|
|
133700
|
+
/**
|
|
133701
|
+
* What the Echo worker needs from an import broker to act as the same Amazon
|
|
133702
|
+
* account: the refresh token of the broker's own device registration. The
|
|
133703
|
+
* broker keeps that registration fresh (it persists `cookieData` after every
|
|
133704
|
+
* refresh), so the freshest copy is the persisted session file, then the
|
|
133705
|
+
* stored cookie blob, then a bare refresh token. A raw cookie string carries
|
|
133706
|
+
* no refresh token and gives no seed.
|
|
133707
|
+
*/
|
|
133708
|
+
/** The registration name alexapy sends when the broker did not record one. */
|
|
133709
|
+
var DEFAULT_DEVICE_APP_NAME = "Alexa Media Player";
|
|
133710
|
+
function isRecord(value) {
|
|
133711
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
133712
|
+
}
|
|
133713
|
+
/** The registration blob alexa-remote2 hands back, from an object or its JSON text. */
|
|
133714
|
+
function asRegistration(value) {
|
|
133715
|
+
if (isRecord(value)) return value;
|
|
133716
|
+
if (typeof value !== "string") return null;
|
|
133717
|
+
const trimmed = value.trim();
|
|
133718
|
+
if (!trimmed.startsWith("{")) return null;
|
|
133719
|
+
try {
|
|
133720
|
+
const parsed = JSON.parse(trimmed);
|
|
133721
|
+
return isRecord(parsed) ? parsed : null;
|
|
133722
|
+
} catch {
|
|
133723
|
+
return null;
|
|
133724
|
+
}
|
|
133725
|
+
}
|
|
133726
|
+
function seedFromRegistration(value) {
|
|
133727
|
+
const registration = asRegistration(value);
|
|
133728
|
+
if (registration === null) return null;
|
|
133729
|
+
const token = registration["refreshToken"];
|
|
133730
|
+
if (typeof token !== "string" || token.length === 0) return null;
|
|
133731
|
+
const name = registration["deviceAppName"];
|
|
133732
|
+
return {
|
|
133733
|
+
refreshToken: token,
|
|
133734
|
+
deviceAppName: typeof name === "string" && name.length > 0 ? name : DEFAULT_DEVICE_APP_NAME
|
|
133735
|
+
};
|
|
133736
|
+
}
|
|
133737
|
+
async function readPersistedRegistration(dataDir, brokerId) {
|
|
133738
|
+
if (dataDir === void 0) return null;
|
|
133739
|
+
try {
|
|
133740
|
+
const raw = await node_fs_promises.readFile(node_path.join(dataDir, `${brokerId}.${SESSION_FILE}`), "utf8");
|
|
133741
|
+
const json = JSON.parse(raw);
|
|
133742
|
+
return isRecord(json) ? json["cookieData"] : null;
|
|
133743
|
+
} catch {
|
|
133744
|
+
return null;
|
|
133745
|
+
}
|
|
133746
|
+
}
|
|
133747
|
+
async function readBrokerSeed(dataDir, entry) {
|
|
133748
|
+
const persisted = seedFromRegistration(await readPersistedRegistration(dataDir, entry.id));
|
|
133749
|
+
if (persisted !== null) return persisted;
|
|
133750
|
+
const stored = seedFromRegistration(entry.connection.cookie);
|
|
133751
|
+
if (stored !== null) return stored;
|
|
133752
|
+
const bare = entry.connection.refreshToken;
|
|
133753
|
+
if (bare.length > 0) return {
|
|
133754
|
+
refreshToken: bare,
|
|
133755
|
+
deviceAppName: DEFAULT_DEVICE_APP_NAME
|
|
133756
|
+
};
|
|
133757
|
+
return null;
|
|
133758
|
+
}
|
|
133759
|
+
/** Amazon domain to the region the worker uses for the spoken-sequence locale. */
|
|
133760
|
+
function regionOfDomain(domain) {
|
|
133761
|
+
const tail = domain.toLowerCase().replace(/^(www|alexa)\./, "");
|
|
133762
|
+
if (tail === "amazon.com") return "NA";
|
|
133763
|
+
if (tail === "amazon.co.jp" || tail === "amazon.com.au" || tail === "amazon.in") return "FE";
|
|
133764
|
+
return "EU";
|
|
133765
|
+
}
|
|
133766
|
+
process.platform, process.env["SystemRoot"];
|
|
133418
133767
|
/**
|
|
133419
|
-
*
|
|
133420
|
-
*
|
|
133768
|
+
* Files only their owner may read: the one place a secret-bearing file is
|
|
133769
|
+
* restricted, on every OS.
|
|
133770
|
+
*
|
|
133771
|
+
* `mode: 0o600` is a POSIX idea. On Windows Node maps it to the read-only
|
|
133772
|
+
* attribute and nothing else, and `chmod` is a no-op for permissions, so a
|
|
133773
|
+
* "0600" `agent.json` (the mesh private key) would have
|
|
133774
|
+
* been readable by every local user. Windows has its own tool for this:
|
|
133775
|
+
* `icacls`. This module removes the inherited ACL entries and grants access to
|
|
133776
|
+
* the current user and SYSTEM only — the Windows reading of "0600".
|
|
133777
|
+
*
|
|
133778
|
+
* ZERO imports beyond Node built-ins, on purpose: the Electron shell bundles
|
|
133779
|
+
* this single file (see `packages/desktop/tsup.config.ts`) instead of the
|
|
133780
|
+
* whole `@camstack/types` barrel.
|
|
133781
|
+
*
|
|
133782
|
+
* Failure is NOT swallowed. A secret file that could not be restricted is a
|
|
133783
|
+
* secret file the caller must know about, so `OwnerOnlyError` propagates; the
|
|
133784
|
+
* write order below makes sure nothing sensitive is in the file yet when that
|
|
133785
|
+
* happens (the temp file is empty until it has been restricted).
|
|
133786
|
+
*
|
|
133787
|
+
* Out of scope on purpose: files that are only tidy, not secret (the job
|
|
133788
|
+
* journal, the addon manifest) keep a plain `mode`, since one `icacls` per
|
|
133789
|
+
* write would be a process spawn on a hot path.
|
|
133421
133790
|
*/
|
|
133422
|
-
|
|
133423
|
-
|
|
133791
|
+
/** `*S-1-5-18` is SYSTEM in every locale — a name like "SYSTEM" is not. */
|
|
133792
|
+
var WINDOWS_SYSTEM_SID = "*S-1-5-18";
|
|
133793
|
+
var FILE_MODE = 384;
|
|
133794
|
+
var DIR_MODE = 448;
|
|
133795
|
+
var OwnerOnlyError = class extends Error {
|
|
133796
|
+
target;
|
|
133797
|
+
constructor(target, detail) {
|
|
133798
|
+
super(`could not restrict ${target} to its owner: ${detail}`);
|
|
133799
|
+
this.target = target;
|
|
133800
|
+
this.name = "OwnerOnlyError";
|
|
133801
|
+
}
|
|
133802
|
+
};
|
|
133803
|
+
function currentPrincipal() {
|
|
133804
|
+
const user = process.env["USERNAME"] ?? (0, node_os.userInfo)().username;
|
|
133805
|
+
const domain = process.env["USERDOMAIN"];
|
|
133806
|
+
return domain !== void 0 && domain.length > 0 ? `${domain}\\${user}` : user;
|
|
133807
|
+
}
|
|
133808
|
+
/** Resolved per call, not at import: a platform stubbed by a test must be seen. */
|
|
133809
|
+
function resolveDeps(overrides) {
|
|
133424
133810
|
return {
|
|
133425
|
-
|
|
133426
|
-
|
|
133427
|
-
|
|
133428
|
-
|
|
133429
|
-
|
|
133430
|
-
|
|
133431
|
-
|
|
133432
|
-
|
|
133433
|
-
|
|
133434
|
-
|
|
133435
|
-
status: "active",
|
|
133436
|
-
read: false,
|
|
133437
|
-
createdAt: now,
|
|
133438
|
-
updatedAt: now,
|
|
133439
|
-
source: {
|
|
133440
|
-
type: "addon",
|
|
133441
|
-
id: "import-alexa"
|
|
133442
|
-
},
|
|
133443
|
-
metadata: { episodeKey: reason }
|
|
133811
|
+
platform: process.platform,
|
|
133812
|
+
exec: (file, args) => {
|
|
133813
|
+
(0, node_child_process.execFileSync)(file, [...args], {
|
|
133814
|
+
stdio: "pipe",
|
|
133815
|
+
windowsHide: true
|
|
133816
|
+
});
|
|
133817
|
+
},
|
|
133818
|
+
principal: currentPrincipal,
|
|
133819
|
+
systemRoot: process.env["SystemRoot"],
|
|
133820
|
+
...overrides
|
|
133444
133821
|
};
|
|
133445
133822
|
}
|
|
133446
|
-
|
|
133447
|
-
|
|
133448
|
-
|
|
133449
|
-
|
|
133450
|
-
|
|
133451
|
-
|
|
133452
|
-
|
|
133453
|
-
|
|
133454
|
-
|
|
133455
|
-
|
|
133456
|
-
|
|
133457
|
-
|
|
133458
|
-
|
|
133459
|
-
|
|
133460
|
-
|
|
133461
|
-
|
|
133462
|
-
|
|
133823
|
+
/** The icacls argv for one path: no inheritance, owner + SYSTEM only. */
|
|
133824
|
+
function icaclsArgs(target, principal, isDirectory) {
|
|
133825
|
+
const inherit = isDirectory ? "(OI)(CI)" : "";
|
|
133826
|
+
return [
|
|
133827
|
+
target,
|
|
133828
|
+
"/inheritance:r",
|
|
133829
|
+
"/grant:r",
|
|
133830
|
+
`${principal}:${inherit}F`,
|
|
133831
|
+
"/grant:r",
|
|
133832
|
+
`${WINDOWS_SYSTEM_SID}:${inherit}F`
|
|
133833
|
+
];
|
|
133834
|
+
}
|
|
133835
|
+
function restrictWindows(target, isDirectory, deps) {
|
|
133836
|
+
const icacls = node_path$1.win32.join(deps.systemRoot ?? "C:\\Windows", "System32", "icacls.exe");
|
|
133837
|
+
try {
|
|
133838
|
+
deps.exec(icacls, icaclsArgs(target, deps.principal(), isDirectory));
|
|
133839
|
+
} catch (err) {
|
|
133840
|
+
throw new OwnerOnlyError(target, err instanceof Error ? err.message : String(err));
|
|
133841
|
+
}
|
|
133842
|
+
}
|
|
133843
|
+
/** Restrict an existing file to its owner (chmod 0600 / owner+SYSTEM ACL). */
|
|
133844
|
+
function restrictFileToOwnerSync(target, overrides = {}) {
|
|
133845
|
+
const deps = resolveDeps(overrides);
|
|
133846
|
+
if (deps.platform === "win32") {
|
|
133847
|
+
restrictWindows(target, false, deps);
|
|
133848
|
+
return;
|
|
133849
|
+
}
|
|
133850
|
+
node_fs.chmodSync(target, FILE_MODE);
|
|
133851
|
+
}
|
|
133852
|
+
/** Restrict an existing directory to its owner (chmod 0700 / owner+SYSTEM, inherited). */
|
|
133853
|
+
function restrictDirToOwnerSync(target, overrides = {}) {
|
|
133854
|
+
const deps = resolveDeps(overrides);
|
|
133855
|
+
if (deps.platform === "win32") {
|
|
133856
|
+
restrictWindows(target, true, deps);
|
|
133857
|
+
return;
|
|
133858
|
+
}
|
|
133859
|
+
node_fs.chmodSync(target, DIR_MODE);
|
|
133860
|
+
}
|
|
133861
|
+
/**
|
|
133862
|
+
* Atomically write a secret-bearing file that only its owner can read.
|
|
133863
|
+
*
|
|
133864
|
+
* Order matters: the temp file is created EMPTY, restricted, and only then
|
|
133865
|
+
* given its content, so there is no moment at which the secret exists under a
|
|
133866
|
+
* permissive ACL. The rename carries the (already restricted) ACL with it.
|
|
133867
|
+
*/
|
|
133868
|
+
function writeOwnerOnlyFileSync(target, data, overrides = {}) {
|
|
133869
|
+
node_fs.mkdirSync(node_path$1.dirname(target), { recursive: true });
|
|
133870
|
+
const tmp = `${target}.tmp.${process.pid}`;
|
|
133871
|
+
node_fs.writeFileSync(tmp, "", { mode: FILE_MODE });
|
|
133872
|
+
try {
|
|
133873
|
+
restrictFileToOwnerSync(tmp, overrides);
|
|
133874
|
+
node_fs.writeFileSync(tmp, data);
|
|
133875
|
+
node_fs.renameSync(tmp, target);
|
|
133876
|
+
} catch (err) {
|
|
133877
|
+
node_fs.rmSync(tmp, { force: true });
|
|
133878
|
+
throw err;
|
|
133879
|
+
}
|
|
133880
|
+
}
|
|
133881
|
+
//#endregion
|
|
133882
|
+
//#region src/speak/secret-file.ts
|
|
133883
|
+
/**
|
|
133884
|
+
* Hand a refresh token or a saved Amazon session to the Python worker as an
|
|
133885
|
+
* owner-only file. The path travels on stdin. The bytes never travel on
|
|
133886
|
+
* argv or in the environment, and they are removed when the call settles.
|
|
133887
|
+
*/
|
|
133888
|
+
var REDACTED = "‹redacted›";
|
|
133889
|
+
function decodeSession(sessionB64) {
|
|
133890
|
+
if (sessionB64.length === 0) return Buffer.alloc(0);
|
|
133891
|
+
return Buffer.from(sessionB64, "base64");
|
|
133892
|
+
}
|
|
133893
|
+
async function readSessionB64(sessionPath) {
|
|
133894
|
+
const bytes = await node_fs_promises.readFile(sessionPath);
|
|
133895
|
+
if (bytes.length === 0) return "";
|
|
133896
|
+
return bytes.toString("base64");
|
|
133897
|
+
}
|
|
133898
|
+
/**
|
|
133899
|
+
* Create a 0700 directory, write the refresh token and the session 0600, run
|
|
133900
|
+
* `body`, then delete the directory whether `body` succeeded or threw.
|
|
133901
|
+
* Returns the session file as base64 so the caller can store it in the keychain.
|
|
133902
|
+
*/
|
|
133903
|
+
async function withSpeakSecretFiles(input, body) {
|
|
133904
|
+
const dir = await node_fs_promises.mkdtemp(node_path.join(node_os.tmpdir(), "camstack-alexa-"));
|
|
133905
|
+
const paths = {
|
|
133906
|
+
dir,
|
|
133907
|
+
refreshTokenPath: node_path.join(dir, "refresh-token"),
|
|
133908
|
+
sessionPath: node_path.join(dir, "session")
|
|
133909
|
+
};
|
|
133910
|
+
try {
|
|
133911
|
+
restrictDirToOwnerSync(dir);
|
|
133912
|
+
writeOwnerOnlyFileSync(paths.refreshTokenPath, input.refreshToken);
|
|
133913
|
+
writeOwnerOnlyFileSync(paths.sessionPath, decodeSession(input.sessionB64));
|
|
133914
|
+
await body(paths);
|
|
133915
|
+
return { sessionB64: await readSessionB64(paths.sessionPath) };
|
|
133916
|
+
} finally {
|
|
133917
|
+
await node_fs_promises.rm(dir, {
|
|
133918
|
+
recursive: true,
|
|
133919
|
+
force: true
|
|
133920
|
+
});
|
|
133921
|
+
}
|
|
133922
|
+
}
|
|
133923
|
+
/** Replace every occurrence of each non-empty secret. Used before a log line is emitted. */
|
|
133924
|
+
function scrubSecrets(text, secrets) {
|
|
133925
|
+
let out = text;
|
|
133926
|
+
for (const secret of secrets) {
|
|
133927
|
+
if (secret.length === 0) continue;
|
|
133928
|
+
out = out.split(secret).join(REDACTED);
|
|
133929
|
+
}
|
|
133930
|
+
return out;
|
|
133931
|
+
}
|
|
133932
|
+
//#endregion
|
|
133933
|
+
//#region src/speak/broker-echo.ts
|
|
133934
|
+
var BrokerEcho = class {
|
|
133935
|
+
deps;
|
|
133936
|
+
currentState = "starting";
|
|
133937
|
+
currentError = null;
|
|
133938
|
+
currentDevices = [];
|
|
133939
|
+
constructor(deps) {
|
|
133940
|
+
this.deps = deps;
|
|
133941
|
+
}
|
|
133942
|
+
get id() {
|
|
133943
|
+
return this.deps.brokerId;
|
|
133944
|
+
}
|
|
133945
|
+
get name() {
|
|
133946
|
+
return this.deps.brokerName;
|
|
133947
|
+
}
|
|
133948
|
+
get state() {
|
|
133949
|
+
return this.currentState;
|
|
133950
|
+
}
|
|
133951
|
+
get error() {
|
|
133952
|
+
return this.currentError;
|
|
133953
|
+
}
|
|
133954
|
+
get devices() {
|
|
133955
|
+
return this.currentDevices;
|
|
133956
|
+
}
|
|
133957
|
+
stop() {
|
|
133958
|
+
this.deps.worker.stop();
|
|
133959
|
+
}
|
|
133960
|
+
/** Sign the worker in as the broker's account. Never throws; the outcome is `state`. */
|
|
133961
|
+
async signIn() {
|
|
133962
|
+
const { brokerId, worker, sessions, logger } = this.deps;
|
|
133963
|
+
const tags = { tags: { brokerId } };
|
|
133964
|
+
let secrets = [];
|
|
133965
|
+
try {
|
|
133966
|
+
const seed = await this.deps.readSeed();
|
|
133967
|
+
const stored = await sessions.get(brokerId);
|
|
133968
|
+
if (seed === null && stored.length === 0) {
|
|
133969
|
+
this.currentState = "broker-not-signed-in";
|
|
133970
|
+
this.currentError = null;
|
|
133971
|
+
logger.warn("alexa speak: the broker has no sign-in to share yet", tags);
|
|
133972
|
+
return;
|
|
133973
|
+
}
|
|
133974
|
+
secrets = seed === null ? [] : [seed.refreshToken];
|
|
133975
|
+
worker.noteSecrets(secrets);
|
|
133976
|
+
let outcome = parseLoginResult({
|
|
133977
|
+
status: "failed",
|
|
133978
|
+
error: "worker gave no answer"
|
|
133979
|
+
});
|
|
133980
|
+
const held = await withSpeakSecretFiles({
|
|
133981
|
+
refreshToken: seed?.refreshToken ?? "",
|
|
133982
|
+
sessionB64: stored
|
|
133983
|
+
}, async (paths) => {
|
|
133984
|
+
outcome = parseLoginResult(await worker.request({
|
|
133985
|
+
op: "login_broker",
|
|
133986
|
+
label: brokerId,
|
|
133987
|
+
refreshTokenPath: seed === null ? "" : paths.refreshTokenPath,
|
|
133988
|
+
deviceAppName: seed?.deviceAppName ?? "",
|
|
133989
|
+
domain: this.deps.domain,
|
|
133990
|
+
sessionPath: paths.sessionPath,
|
|
133991
|
+
region: regionOfDomain(this.deps.domain)
|
|
133992
|
+
}));
|
|
133993
|
+
});
|
|
133994
|
+
if (held.sessionB64.length > 0 && held.sessionB64 !== stored) await sessions.put(brokerId, held.sessionB64);
|
|
133995
|
+
this.currentError = outcome.status === "ok" ? null : outcome.error ?? "sign-in failed";
|
|
133996
|
+
this.currentState = outcome.status === "ok" ? "ready" : "failed";
|
|
133997
|
+
if (outcome.status !== "ok") {
|
|
133998
|
+
logger.warn("alexa speak: sign-in as the broker failed", {
|
|
133999
|
+
...tags,
|
|
134000
|
+
meta: { error: this.currentError }
|
|
134001
|
+
});
|
|
134002
|
+
return;
|
|
134003
|
+
}
|
|
134004
|
+
await this.refreshDevices();
|
|
134005
|
+
} catch (err) {
|
|
134006
|
+
this.currentState = "failed";
|
|
134007
|
+
this.currentError = scrubSecrets(err instanceof Error ? err.message : String(err), secrets);
|
|
134008
|
+
logger.warn("alexa speak: sign-in as the broker failed", {
|
|
134009
|
+
...tags,
|
|
134010
|
+
meta: { error: this.currentError }
|
|
134011
|
+
});
|
|
134012
|
+
}
|
|
134013
|
+
}
|
|
134014
|
+
/** A session that is not ready tries to sign in again before it gives up. */
|
|
134015
|
+
async ensureReady() {
|
|
134016
|
+
if (this.currentState !== "ready") await this.signIn();
|
|
134017
|
+
return this.currentState === "ready";
|
|
134018
|
+
}
|
|
134019
|
+
async refreshDevices() {
|
|
134020
|
+
if (this.currentState !== "ready") return;
|
|
134021
|
+
try {
|
|
134022
|
+
this.currentDevices = [...parseDevices(await this.deps.worker.request({ op: "devices" }))];
|
|
134023
|
+
} catch (err) {
|
|
134024
|
+
this.deps.logger.warn("alexa speak: device refresh failed", {
|
|
134025
|
+
tags: { brokerId: this.deps.brokerId },
|
|
134026
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
134027
|
+
});
|
|
134028
|
+
}
|
|
134029
|
+
}
|
|
134030
|
+
async utter(op, targets, text, volume) {
|
|
134031
|
+
if (!await this.ensureReady()) throw new Error(this.notReadyReason());
|
|
134032
|
+
return parseDeliveries(await this.deps.worker.request({
|
|
134033
|
+
op,
|
|
134034
|
+
targets,
|
|
134035
|
+
text,
|
|
134036
|
+
volume
|
|
134037
|
+
}));
|
|
134038
|
+
}
|
|
134039
|
+
async session() {
|
|
134040
|
+
if (this.deps.worker.failed) return {
|
|
134041
|
+
loggedIn: false,
|
|
134042
|
+
ageSec: 0,
|
|
134043
|
+
lastSuccessAt: null
|
|
134044
|
+
};
|
|
134045
|
+
try {
|
|
134046
|
+
return parseSessionStatus(await this.deps.worker.request({ op: "session_status" }));
|
|
134047
|
+
} catch {
|
|
134048
|
+
return {
|
|
134049
|
+
loggedIn: this.currentState === "ready",
|
|
134050
|
+
ageSec: 0,
|
|
134051
|
+
lastSuccessAt: null
|
|
134052
|
+
};
|
|
134053
|
+
}
|
|
134054
|
+
}
|
|
134055
|
+
notReadyReason() {
|
|
134056
|
+
if (this.currentState === "broker-not-signed-in") return `account '${this.deps.brokerName}' is not signed in on its broker`;
|
|
134057
|
+
return this.currentError ?? "alexa session is not ready";
|
|
133463
134058
|
}
|
|
133464
134059
|
};
|
|
133465
134060
|
//#endregion
|
|
134061
|
+
//#region src/speak/migration.ts
|
|
134062
|
+
function legacyLoginOf(settings) {
|
|
134063
|
+
return {
|
|
134064
|
+
email: settings.email,
|
|
134065
|
+
password: settings.password,
|
|
134066
|
+
session: settings.session
|
|
134067
|
+
};
|
|
134068
|
+
}
|
|
134069
|
+
function planLegacyMigration(legacy, brokerIds, targets) {
|
|
134070
|
+
const present = legacy.email.length > 0 || legacy.password.length > 0 || legacy.session.length > 0;
|
|
134071
|
+
const only = brokerIds.length === 1 ? brokerIds[0] ?? null : null;
|
|
134072
|
+
if (!present || only === null) return {
|
|
134073
|
+
present,
|
|
134074
|
+
attachTo: null,
|
|
134075
|
+
targets
|
|
134076
|
+
};
|
|
134077
|
+
return {
|
|
134078
|
+
present,
|
|
134079
|
+
attachTo: legacy.session.length > 0 ? only : null,
|
|
134080
|
+
targets: targets.map((row) => row.brokerId.length > 0 ? row : {
|
|
134081
|
+
...row,
|
|
134082
|
+
brokerId: only
|
|
134083
|
+
})
|
|
134084
|
+
};
|
|
134085
|
+
}
|
|
134086
|
+
/** What the page must tell the operator about targets that name no account. */
|
|
134087
|
+
function noticeFor(targets, brokerIds) {
|
|
134088
|
+
if (!targets.some((row) => row.brokerId.length === 0)) return "";
|
|
134089
|
+
if (brokerIds.length === 0) return "no-broker";
|
|
134090
|
+
return brokerIds.length > 1 ? "choose-broker" : "";
|
|
134091
|
+
}
|
|
134092
|
+
function unassignedCount(targets) {
|
|
134093
|
+
return targets.filter((row) => row.brokerId.length === 0).length;
|
|
134094
|
+
}
|
|
134095
|
+
//#endregion
|
|
133466
134096
|
//#region src/speak/rate-limit.ts
|
|
133467
134097
|
/**
|
|
133468
134098
|
* Per-Echo admission. A burst is dropped here, not queued: a queue would be
|
|
@@ -133577,19 +134207,58 @@ function readVolume(config) {
|
|
|
133577
134207
|
function respectDnd(config) {
|
|
133578
134208
|
return config["respectDnd"] !== false;
|
|
133579
134209
|
}
|
|
133580
|
-
|
|
134210
|
+
/** "Kitchen · Living room · ECHO" — the name first, then what tells two Echoes apart. */
|
|
134211
|
+
function echoOptionLabel(device) {
|
|
134212
|
+
const kind = device.kind === "group" ? "Group" : device.family;
|
|
134213
|
+
const parts = [device.name.length > 0 ? device.name : device.id];
|
|
134214
|
+
if (device.room !== void 0 && device.room.length > 0) parts.push(device.room);
|
|
134215
|
+
if (kind.length > 0) parts.push(kind);
|
|
134216
|
+
return parts.join(" · ");
|
|
134217
|
+
}
|
|
134218
|
+
function echoField(devices, showWhen) {
|
|
134219
|
+
return {
|
|
134220
|
+
type: "select",
|
|
134221
|
+
key: "echoId",
|
|
134222
|
+
label: "Echo",
|
|
134223
|
+
labelKey: "import-alexa:echoTarget.s_echo.echoId.label",
|
|
134224
|
+
description: devices.length === 0 ? "No Echo found yet. Sign the Amazon account in on Integrations, Alexa." : void 0,
|
|
134225
|
+
descriptionKey: devices.length === 0 ? "import-alexa:echoTarget.s_echo.echoId.empty" : void 0,
|
|
134226
|
+
options: devices.map((device) => ({
|
|
134227
|
+
value: device.id,
|
|
134228
|
+
label: echoOptionLabel(device)
|
|
134229
|
+
})),
|
|
134230
|
+
...showWhen === void 0 ? {} : { showWhen }
|
|
134231
|
+
};
|
|
134232
|
+
}
|
|
134233
|
+
/**
|
|
134234
|
+
* The two pickers are not `required`: a required select shows its first option
|
|
134235
|
+
* while the form still holds no value, so a save would drop the choice. Left
|
|
134236
|
+
* optional they open on "Select", and `upsertTarget` refuses an empty pick.
|
|
134237
|
+
*
|
|
134238
|
+
* The Echo picker is built from the accounts' own device lists on every call,
|
|
134239
|
+
* so a new Echo shows up without a deploy. With one account there is no account
|
|
134240
|
+
* field; with several, each account's Echoes appear when that account is chosen.
|
|
134241
|
+
*/
|
|
134242
|
+
function configSchema(brokers) {
|
|
134243
|
+
const first = brokers[0];
|
|
133581
134244
|
return { sections: [{
|
|
133582
134245
|
id: "echo",
|
|
133583
134246
|
title: "Echo",
|
|
133584
134247
|
titleKey: "import-alexa:echoTarget.s_echo.title",
|
|
133585
134248
|
fields: [
|
|
133586
|
-
{
|
|
133587
|
-
type: "
|
|
133588
|
-
key: "
|
|
133589
|
-
label: "
|
|
133590
|
-
labelKey: "import-alexa:echoTarget.s_echo.
|
|
133591
|
-
|
|
133592
|
-
|
|
134249
|
+
...brokers.length > 1 ? [{
|
|
134250
|
+
type: "select",
|
|
134251
|
+
key: "brokerId",
|
|
134252
|
+
label: "Amazon account",
|
|
134253
|
+
labelKey: "import-alexa:echoTarget.s_echo.brokerId.label",
|
|
134254
|
+
options: brokers.map((broker) => ({
|
|
134255
|
+
value: broker.id,
|
|
134256
|
+
label: broker.name
|
|
134257
|
+
}))
|
|
134258
|
+
}, ...brokers.map((broker) => echoField(broker.devices, {
|
|
134259
|
+
field: "brokerId",
|
|
134260
|
+
equals: broker.id
|
|
134261
|
+
}))] : [echoField(first?.devices ?? [])],
|
|
133593
134262
|
{
|
|
133594
134263
|
type: "select",
|
|
133595
134264
|
key: "mode",
|
|
@@ -133623,15 +134292,18 @@ function configSchema() {
|
|
|
133623
134292
|
]
|
|
133624
134293
|
}] };
|
|
133625
134294
|
}
|
|
133626
|
-
|
|
134295
|
+
/** The Alexa target kind as the Add target form sees it, for the accounts as they are now. */
|
|
134296
|
+
function alexaTargetKind(brokers) {
|
|
133627
134297
|
return {
|
|
133628
134298
|
kind: ALEXA_SPEAK_KIND,
|
|
133629
134299
|
label: "Alexa",
|
|
133630
134300
|
icon: "megaphone",
|
|
134301
|
+
iconUrl: ALEXA_SPEAK_ICON_URL,
|
|
134302
|
+
iconMediaType: "image/svg+xml",
|
|
133631
134303
|
addonId: ALEXA_SPEAK_ADDON_ID,
|
|
133632
134304
|
supportsDiscovery: true,
|
|
133633
|
-
destinationKeys: ["echoId"],
|
|
133634
|
-
configSchema: configSchema(),
|
|
134305
|
+
destinationKeys: ["brokerId", "echoId"],
|
|
134306
|
+
configSchema: configSchema(brokers),
|
|
133635
134307
|
caps: {
|
|
133636
134308
|
attachments: {
|
|
133637
134309
|
mediaTypes: [],
|
|
@@ -133653,11 +134325,12 @@ function kindDescriptor() {
|
|
|
133653
134325
|
}
|
|
133654
134326
|
};
|
|
133655
134327
|
}
|
|
133656
|
-
function discoveredFrom(device) {
|
|
134328
|
+
function discoveredFrom(broker, device) {
|
|
133657
134329
|
return {
|
|
133658
134330
|
kind: ALEXA_SPEAK_KIND,
|
|
133659
134331
|
suggestedName: device.name.length > 0 ? device.name : device.id,
|
|
133660
134332
|
config: {
|
|
134333
|
+
brokerId: broker.id,
|
|
133661
134334
|
echoId: device.id,
|
|
133662
134335
|
mode: "speak",
|
|
133663
134336
|
volume: 40,
|
|
@@ -133675,8 +134348,18 @@ function createAlexaNotificationProvider(ports) {
|
|
|
133675
134348
|
function record(row) {
|
|
133676
134349
|
deliveries = [...deliveries, row];
|
|
133677
134350
|
}
|
|
133678
|
-
|
|
133679
|
-
|
|
134351
|
+
/**
|
|
134352
|
+
* The account a target speaks through: the one it names; else the only one
|
|
134353
|
+
* there is; else the one whose Echoes include the target's Echo.
|
|
134354
|
+
*/
|
|
134355
|
+
function brokerFor(config) {
|
|
134356
|
+
const all = ports.brokers();
|
|
134357
|
+
const named = readString(config, "brokerId");
|
|
134358
|
+
if (named.length > 0) return all.find((broker) => broker.id === named);
|
|
134359
|
+
if (all.length === 1) return all[0];
|
|
134360
|
+
const echoId = readString(config, "echoId");
|
|
134361
|
+
const owners = all.filter((broker) => broker.devices.some((device) => device.id === echoId));
|
|
134362
|
+
return owners.length === 1 ? owners[0] : void 0;
|
|
133680
134363
|
}
|
|
133681
134364
|
function persist(next) {
|
|
133682
134365
|
targets = next;
|
|
@@ -133722,7 +134405,29 @@ function createAlexaNotificationProvider(ports) {
|
|
|
133722
134405
|
renderedAs: speech.renderedAs
|
|
133723
134406
|
};
|
|
133724
134407
|
}
|
|
133725
|
-
const
|
|
134408
|
+
const broker = brokerFor(target.config);
|
|
134409
|
+
if (broker === void 0) {
|
|
134410
|
+
const error = "target has no Amazon account";
|
|
134411
|
+
record({
|
|
134412
|
+
targetId: target.id,
|
|
134413
|
+
outcome: "failed",
|
|
134414
|
+
at: ports.now(),
|
|
134415
|
+
error
|
|
134416
|
+
});
|
|
134417
|
+
logger.warn("alexa speak dropped: target names no Amazon account", {
|
|
134418
|
+
...tags,
|
|
134419
|
+
meta: {
|
|
134420
|
+
targetId: target.id,
|
|
134421
|
+
echoId
|
|
134422
|
+
}
|
|
134423
|
+
});
|
|
134424
|
+
return {
|
|
134425
|
+
success: false,
|
|
134426
|
+
error,
|
|
134427
|
+
renderedAs: speech.renderedAs
|
|
134428
|
+
};
|
|
134429
|
+
}
|
|
134430
|
+
const device = broker.devices.find((row) => row.id === echoId);
|
|
133726
134431
|
if (device !== void 0 && respectDnd(target.config) && device.dnd) {
|
|
133727
134432
|
record({
|
|
133728
134433
|
targetId: target.id,
|
|
@@ -133784,6 +134489,7 @@ function createAlexaNotificationProvider(ports) {
|
|
|
133784
134489
|
const mode = readMode(target.config);
|
|
133785
134490
|
try {
|
|
133786
134491
|
const outcomes = await ports.speak({
|
|
134492
|
+
brokerId: broker.id,
|
|
133787
134493
|
mode,
|
|
133788
134494
|
targets: [echoId],
|
|
133789
134495
|
text: speech.text,
|
|
@@ -133858,7 +134564,7 @@ function createAlexaNotificationProvider(ports) {
|
|
|
133858
134564
|
return deliveries;
|
|
133859
134565
|
},
|
|
133860
134566
|
async listTargetKinds() {
|
|
133861
|
-
return [
|
|
134567
|
+
return [alexaTargetKind(ports.brokers())];
|
|
133862
134568
|
},
|
|
133863
134569
|
async listTargets() {
|
|
133864
134570
|
return [...targets];
|
|
@@ -133869,16 +134575,21 @@ function createAlexaNotificationProvider(ports) {
|
|
|
133869
134575
|
return found;
|
|
133870
134576
|
},
|
|
133871
134577
|
async discoverTargets() {
|
|
133872
|
-
|
|
134578
|
+
const taken = new Set(targets.flatMap((row) => {
|
|
134579
|
+
const owner = brokerFor(row.config);
|
|
134580
|
+
return owner === void 0 ? [] : [`${owner.id}/${readString(row.config, "echoId")}`];
|
|
134581
|
+
}));
|
|
134582
|
+
return ports.brokers().flatMap((broker) => broker.devices.filter((device) => !taken.has(`${broker.id}/${device.id}`)).map((device) => discoveredFrom(broker, device)));
|
|
133873
134583
|
},
|
|
133874
|
-
async probeTargetConfig() {
|
|
133875
|
-
|
|
134584
|
+
async probeTargetConfig(input) {
|
|
134585
|
+
const broker = brokerFor(input.config);
|
|
134586
|
+
return broker !== void 0 && broker.ready ? {
|
|
133876
134587
|
ok: true,
|
|
133877
134588
|
resolvedUrl: "alexa://session"
|
|
133878
134589
|
} : {
|
|
133879
134590
|
ok: false,
|
|
133880
134591
|
resolvedUrl: "alexa://session",
|
|
133881
|
-
error: "not signed in"
|
|
134592
|
+
error: "the Amazon account is not signed in on its broker"
|
|
133882
134593
|
};
|
|
133883
134594
|
},
|
|
133884
134595
|
async send(input) {
|
|
@@ -133911,8 +134622,16 @@ function createAlexaNotificationProvider(ports) {
|
|
|
133911
134622
|
});
|
|
133912
134623
|
},
|
|
133913
134624
|
async upsertTarget(input) {
|
|
134625
|
+
const owner = brokerFor(input.target.config);
|
|
134626
|
+
if (readString(input.target.config, "echoId").length === 0) throw new Error("Pick the Echo that should speak");
|
|
134627
|
+
if (owner === void 0) throw new Error("Pick the Amazon account this Echo belongs to");
|
|
134628
|
+
const pinned = readString(input.target.config, "brokerId").length === 0 ? {
|
|
134629
|
+
...input.target.config,
|
|
134630
|
+
brokerId: owner.id
|
|
134631
|
+
} : input.target.config;
|
|
133914
134632
|
const target = {
|
|
133915
134633
|
...input.target,
|
|
134634
|
+
config: pinned,
|
|
133916
134635
|
addonId: ALEXA_SPEAK_ADDON_ID,
|
|
133917
134636
|
kind: ALEXA_SPEAK_KIND
|
|
133918
134637
|
};
|
|
@@ -133943,186 +134662,10 @@ function resolveAlexaWorkerScript(fromModuleUrl = require("url").pathToFileURL(_
|
|
|
133943
134662
|
for (const candidate of candidates) if ((0, node_fs.existsSync)(candidate)) return candidate;
|
|
133944
134663
|
throw new Error(`import-alexa: alexa_worker.py not found. Searched:\n${candidates.join("\n")}`);
|
|
133945
134664
|
}
|
|
133946
|
-
process.platform, process.env["SystemRoot"];
|
|
133947
|
-
/**
|
|
133948
|
-
* Files only their owner may read: the one place a secret-bearing file is
|
|
133949
|
-
* restricted, on every OS.
|
|
133950
|
-
*
|
|
133951
|
-
* `mode: 0o600` is a POSIX idea. On Windows Node maps it to the read-only
|
|
133952
|
-
* attribute and nothing else, and `chmod` is a no-op for permissions, so a
|
|
133953
|
-
* "0600" `agent.json` (the mesh private key) would have
|
|
133954
|
-
* been readable by every local user. Windows has its own tool for this:
|
|
133955
|
-
* `icacls`. This module removes the inherited ACL entries and grants access to
|
|
133956
|
-
* the current user and SYSTEM only — the Windows reading of "0600".
|
|
133957
|
-
*
|
|
133958
|
-
* ZERO imports beyond Node built-ins, on purpose: the Electron shell bundles
|
|
133959
|
-
* this single file (see `packages/desktop/tsup.config.ts`) instead of the
|
|
133960
|
-
* whole `@camstack/types` barrel.
|
|
133961
|
-
*
|
|
133962
|
-
* Failure is NOT swallowed. A secret file that could not be restricted is a
|
|
133963
|
-
* secret file the caller must know about, so `OwnerOnlyError` propagates; the
|
|
133964
|
-
* write order below makes sure nothing sensitive is in the file yet when that
|
|
133965
|
-
* happens (the temp file is empty until it has been restricted).
|
|
133966
|
-
*
|
|
133967
|
-
* Out of scope on purpose: files that are only tidy, not secret (the job
|
|
133968
|
-
* journal, the addon manifest) keep a plain `mode`, since one `icacls` per
|
|
133969
|
-
* write would be a process spawn on a hot path.
|
|
133970
|
-
*/
|
|
133971
|
-
/** `*S-1-5-18` is SYSTEM in every locale — a name like "SYSTEM" is not. */
|
|
133972
|
-
var WINDOWS_SYSTEM_SID = "*S-1-5-18";
|
|
133973
|
-
var FILE_MODE = 384;
|
|
133974
|
-
var DIR_MODE = 448;
|
|
133975
|
-
var OwnerOnlyError = class extends Error {
|
|
133976
|
-
target;
|
|
133977
|
-
constructor(target, detail) {
|
|
133978
|
-
super(`could not restrict ${target} to its owner: ${detail}`);
|
|
133979
|
-
this.target = target;
|
|
133980
|
-
this.name = "OwnerOnlyError";
|
|
133981
|
-
}
|
|
133982
|
-
};
|
|
133983
|
-
function currentPrincipal() {
|
|
133984
|
-
const user = process.env["USERNAME"] ?? (0, node_os.userInfo)().username;
|
|
133985
|
-
const domain = process.env["USERDOMAIN"];
|
|
133986
|
-
return domain !== void 0 && domain.length > 0 ? `${domain}\\${user}` : user;
|
|
133987
|
-
}
|
|
133988
|
-
/** Resolved per call, not at import: a platform stubbed by a test must be seen. */
|
|
133989
|
-
function resolveDeps(overrides) {
|
|
133990
|
-
return {
|
|
133991
|
-
platform: process.platform,
|
|
133992
|
-
exec: (file, args) => {
|
|
133993
|
-
(0, node_child_process.execFileSync)(file, [...args], {
|
|
133994
|
-
stdio: "pipe",
|
|
133995
|
-
windowsHide: true
|
|
133996
|
-
});
|
|
133997
|
-
},
|
|
133998
|
-
principal: currentPrincipal,
|
|
133999
|
-
systemRoot: process.env["SystemRoot"],
|
|
134000
|
-
...overrides
|
|
134001
|
-
};
|
|
134002
|
-
}
|
|
134003
|
-
/** The icacls argv for one path: no inheritance, owner + SYSTEM only. */
|
|
134004
|
-
function icaclsArgs(target, principal, isDirectory) {
|
|
134005
|
-
const inherit = isDirectory ? "(OI)(CI)" : "";
|
|
134006
|
-
return [
|
|
134007
|
-
target,
|
|
134008
|
-
"/inheritance:r",
|
|
134009
|
-
"/grant:r",
|
|
134010
|
-
`${principal}:${inherit}F`,
|
|
134011
|
-
"/grant:r",
|
|
134012
|
-
`${WINDOWS_SYSTEM_SID}:${inherit}F`
|
|
134013
|
-
];
|
|
134014
|
-
}
|
|
134015
|
-
function restrictWindows(target, isDirectory, deps) {
|
|
134016
|
-
const icacls = node_path$1.win32.join(deps.systemRoot ?? "C:\\Windows", "System32", "icacls.exe");
|
|
134017
|
-
try {
|
|
134018
|
-
deps.exec(icacls, icaclsArgs(target, deps.principal(), isDirectory));
|
|
134019
|
-
} catch (err) {
|
|
134020
|
-
throw new OwnerOnlyError(target, err instanceof Error ? err.message : String(err));
|
|
134021
|
-
}
|
|
134022
|
-
}
|
|
134023
|
-
/** Restrict an existing file to its owner (chmod 0600 / owner+SYSTEM ACL). */
|
|
134024
|
-
function restrictFileToOwnerSync(target, overrides = {}) {
|
|
134025
|
-
const deps = resolveDeps(overrides);
|
|
134026
|
-
if (deps.platform === "win32") {
|
|
134027
|
-
restrictWindows(target, false, deps);
|
|
134028
|
-
return;
|
|
134029
|
-
}
|
|
134030
|
-
node_fs.chmodSync(target, FILE_MODE);
|
|
134031
|
-
}
|
|
134032
|
-
/** Restrict an existing directory to its owner (chmod 0700 / owner+SYSTEM, inherited). */
|
|
134033
|
-
function restrictDirToOwnerSync(target, overrides = {}) {
|
|
134034
|
-
const deps = resolveDeps(overrides);
|
|
134035
|
-
if (deps.platform === "win32") {
|
|
134036
|
-
restrictWindows(target, true, deps);
|
|
134037
|
-
return;
|
|
134038
|
-
}
|
|
134039
|
-
node_fs.chmodSync(target, DIR_MODE);
|
|
134040
|
-
}
|
|
134041
|
-
/**
|
|
134042
|
-
* Atomically write a secret-bearing file that only its owner can read.
|
|
134043
|
-
*
|
|
134044
|
-
* Order matters: the temp file is created EMPTY, restricted, and only then
|
|
134045
|
-
* given its content, so there is no moment at which the secret exists under a
|
|
134046
|
-
* permissive ACL. The rename carries the (already restricted) ACL with it.
|
|
134047
|
-
*/
|
|
134048
|
-
function writeOwnerOnlyFileSync(target, data, overrides = {}) {
|
|
134049
|
-
node_fs.mkdirSync(node_path$1.dirname(target), { recursive: true });
|
|
134050
|
-
const tmp = `${target}.tmp.${process.pid}`;
|
|
134051
|
-
node_fs.writeFileSync(tmp, "", { mode: FILE_MODE });
|
|
134052
|
-
try {
|
|
134053
|
-
restrictFileToOwnerSync(tmp, overrides);
|
|
134054
|
-
node_fs.writeFileSync(tmp, data);
|
|
134055
|
-
node_fs.renameSync(tmp, target);
|
|
134056
|
-
} catch (err) {
|
|
134057
|
-
node_fs.rmSync(tmp, { force: true });
|
|
134058
|
-
throw err;
|
|
134059
|
-
}
|
|
134060
|
-
}
|
|
134061
|
-
//#endregion
|
|
134062
|
-
//#region src/speak/secret-file.ts
|
|
134063
|
-
/**
|
|
134064
|
-
* Hand a password or a saved Amazon session to the Python worker as an
|
|
134065
|
-
* owner-only file. The path travels on stdin. The bytes never travel on
|
|
134066
|
-
* argv or in the environment, and they are removed when the call settles.
|
|
134067
|
-
*/
|
|
134068
|
-
var REDACTED = "‹redacted›";
|
|
134069
|
-
function decodeSession(sessionB64) {
|
|
134070
|
-
if (sessionB64.length === 0) return Buffer.alloc(0);
|
|
134071
|
-
return Buffer.from(sessionB64, "base64");
|
|
134072
|
-
}
|
|
134073
|
-
async function readSessionB64(sessionPath) {
|
|
134074
|
-
const bytes = await node_fs_promises.readFile(sessionPath);
|
|
134075
|
-
if (bytes.length === 0) return "";
|
|
134076
|
-
return bytes.toString("base64");
|
|
134077
|
-
}
|
|
134078
|
-
/**
|
|
134079
|
-
* Create a 0700 directory, write the password and the session 0600, run
|
|
134080
|
-
* `body`, then delete the directory whether `body` succeeded or threw.
|
|
134081
|
-
* Returns the session file as base64 so the caller can store it in the keychain.
|
|
134082
|
-
*/
|
|
134083
|
-
async function withSpeakSecretFiles(input, body) {
|
|
134084
|
-
const dir = await node_fs_promises.mkdtemp(node_path.join(node_os.tmpdir(), "camstack-alexa-"));
|
|
134085
|
-
const paths = {
|
|
134086
|
-
dir,
|
|
134087
|
-
passwordPath: node_path.join(dir, "password"),
|
|
134088
|
-
sessionPath: node_path.join(dir, "session")
|
|
134089
|
-
};
|
|
134090
|
-
try {
|
|
134091
|
-
restrictDirToOwnerSync(dir);
|
|
134092
|
-
writeOwnerOnlyFileSync(paths.passwordPath, input.password);
|
|
134093
|
-
writeOwnerOnlyFileSync(paths.sessionPath, decodeSession(input.sessionB64));
|
|
134094
|
-
await body(paths);
|
|
134095
|
-
return { sessionB64: await readSessionB64(paths.sessionPath) };
|
|
134096
|
-
} finally {
|
|
134097
|
-
await node_fs_promises.rm(dir, {
|
|
134098
|
-
recursive: true,
|
|
134099
|
-
force: true
|
|
134100
|
-
});
|
|
134101
|
-
}
|
|
134102
|
-
}
|
|
134103
|
-
/** Replace every occurrence of each non-empty secret. Used before a log line is emitted. */
|
|
134104
|
-
function scrubSecrets(text, secrets) {
|
|
134105
|
-
let out = text;
|
|
134106
|
-
for (const secret of secrets) {
|
|
134107
|
-
if (secret.length === 0) continue;
|
|
134108
|
-
out = out.split(secret).join(REDACTED);
|
|
134109
|
-
}
|
|
134110
|
-
return out;
|
|
134111
|
-
}
|
|
134112
|
-
//#endregion
|
|
134113
|
-
//#region src/speak/setup-flow.ts
|
|
134114
|
-
/** Where the next screen is. A failed password stays on sign-in. A failed code stays on the code step. */
|
|
134115
|
-
function reduceSetup(phase, event) {
|
|
134116
|
-
if (event.type === "reset") return "sign-in";
|
|
134117
|
-
if (event.status === "ok") return "devices";
|
|
134118
|
-
if (event.status === "otp_needed") return "otp";
|
|
134119
|
-
if (event.status === "captcha") return "captcha";
|
|
134120
|
-
return phase;
|
|
134121
|
-
}
|
|
134122
134665
|
//#endregion
|
|
134123
134666
|
//#region src/speak/worker-client.ts
|
|
134124
134667
|
/**
|
|
134125
|
-
* One Python process for the life of the addon. Requests are one JSON line
|
|
134668
|
+
* One Python process per broker, for the life of the addon. Requests are one JSON line
|
|
134126
134669
|
* on stdin; responses are one JSON line on stdout, matched by id.
|
|
134127
134670
|
* A crash restarts the process until the same bound CrashSupervisor uses,
|
|
134128
134671
|
* then requests fail instead of looping.
|
|
@@ -134350,10 +134893,6 @@ function startAlexaWorker(deps) {
|
|
|
134350
134893
|
}
|
|
134351
134894
|
//#endregion
|
|
134352
134895
|
//#region src/speak/controller.ts
|
|
134353
|
-
/**
|
|
134354
|
-
* Owns the one Python worker, the keychain session, the Echo cache and the
|
|
134355
|
-
* notification provider. A login that needs a code raises one alert.
|
|
134356
|
-
*/
|
|
134357
134896
|
function targetsFrom(settings) {
|
|
134358
134897
|
return settings.targets.map((row) => ({
|
|
134359
134898
|
id: row.id,
|
|
@@ -134362,6 +134901,7 @@ function targetsFrom(settings) {
|
|
|
134362
134901
|
addonId: ALEXA_SPEAK_ADDON_ID,
|
|
134363
134902
|
enabled: row.enabled,
|
|
134364
134903
|
config: {
|
|
134904
|
+
...row.brokerId.length > 0 ? { brokerId: row.brokerId } : {},
|
|
134365
134905
|
echoId: row.echoId,
|
|
134366
134906
|
mode: row.mode,
|
|
134367
134907
|
volume: row.volume,
|
|
@@ -134369,104 +134909,85 @@ function targetsFrom(settings) {
|
|
|
134369
134909
|
}
|
|
134370
134910
|
}));
|
|
134371
134911
|
}
|
|
134372
|
-
function
|
|
134373
|
-
return {
|
|
134374
|
-
|
|
134375
|
-
|
|
134376
|
-
|
|
134377
|
-
|
|
134378
|
-
|
|
134379
|
-
|
|
134380
|
-
|
|
134381
|
-
|
|
134382
|
-
|
|
134383
|
-
}))
|
|
134384
|
-
};
|
|
134385
|
-
}
|
|
134386
|
-
function usablePassword(value) {
|
|
134387
|
-
if (value.length === 0 || isSecretMarker(value)) return "";
|
|
134388
|
-
return value;
|
|
134912
|
+
function rowsFrom(targets) {
|
|
134913
|
+
return targets.map((target) => ({
|
|
134914
|
+
id: target.id,
|
|
134915
|
+
name: target.name,
|
|
134916
|
+
enabled: target.enabled,
|
|
134917
|
+
brokerId: typeof target.config["brokerId"] === "string" ? target.config["brokerId"] : "",
|
|
134918
|
+
echoId: typeof target.config["echoId"] === "string" ? target.config["echoId"] : "",
|
|
134919
|
+
mode: target.config["mode"] === "announce" ? "announce" : "speak",
|
|
134920
|
+
volume: typeof target.config["volume"] === "number" ? target.config["volume"] : 40,
|
|
134921
|
+
respectDnd: target.config["respectDnd"] !== false
|
|
134922
|
+
}));
|
|
134389
134923
|
}
|
|
134390
134924
|
var AlexaSpeakController = class {
|
|
134391
134925
|
host;
|
|
134392
134926
|
provider;
|
|
134393
134927
|
settings;
|
|
134394
|
-
|
|
134395
|
-
|
|
134396
|
-
phase = "sign-in";
|
|
134928
|
+
pythonPath = null;
|
|
134929
|
+
echoes = /* @__PURE__ */ new Map();
|
|
134397
134930
|
timer = null;
|
|
134398
|
-
|
|
134931
|
+
queue = Promise.resolve();
|
|
134399
134932
|
now;
|
|
134400
134933
|
constructor(host) {
|
|
134401
134934
|
this.host = host;
|
|
134402
134935
|
this.now = host.now ?? (() => Date.now());
|
|
134403
134936
|
this.settings = readSpeakSettings(host.readConfig());
|
|
134404
|
-
this.phase = this.settings.session.length > 0 ? "devices" : "sign-in";
|
|
134405
|
-
this.attention = new OtpAttention({
|
|
134406
|
-
now: this.now,
|
|
134407
|
-
emit: (alert) => host.emitAlert(alert),
|
|
134408
|
-
resolve: (id) => host.resolveAlert(id)
|
|
134409
|
-
});
|
|
134410
134937
|
this.provider = createAlexaNotificationProvider({
|
|
134411
|
-
|
|
134412
|
-
loggedIn: () => this.phase === "devices",
|
|
134938
|
+
brokers: () => this.brokerViews(),
|
|
134413
134939
|
now: this.now,
|
|
134414
134940
|
logger: host.logger,
|
|
134415
134941
|
loadTargets: () => targetsFrom(this.settings),
|
|
134416
134942
|
saveTargets: (targets) => {
|
|
134417
|
-
this.settings =
|
|
134943
|
+
this.settings = {
|
|
134944
|
+
...this.settings,
|
|
134945
|
+
targets: [...rowsFrom(targets)]
|
|
134946
|
+
};
|
|
134418
134947
|
this.persist();
|
|
134419
134948
|
},
|
|
134420
|
-
speak: (command) => this.utter(command.mode, command.targets, command.text, command.volume)
|
|
134949
|
+
speak: (command) => this.utter(command.brokerId, command.mode, command.targets, command.text, command.volume)
|
|
134421
134950
|
});
|
|
134422
134951
|
}
|
|
134423
134952
|
async start() {
|
|
134424
|
-
|
|
134425
|
-
if (pythonPath === null) {
|
|
134953
|
+
this.pythonPath = await this.host.ensurePython();
|
|
134954
|
+
if (this.pythonPath === null) {
|
|
134426
134955
|
this.host.logger.warn("alexa speak: embedded Python unavailable; Echo speech will not run");
|
|
134427
134956
|
return;
|
|
134428
134957
|
}
|
|
134429
|
-
this.
|
|
134430
|
-
|
|
134431
|
-
scriptPath: resolveAlexaWorkerScript(),
|
|
134432
|
-
logger: this.host.logger,
|
|
134433
|
-
now: this.now
|
|
134434
|
-
});
|
|
134435
|
-
this.noteSecrets();
|
|
134436
|
-
if (this.settings.session.length > 0) await this.loginWithFiles().catch((err) => {
|
|
134437
|
-
this.host.logger.warn("alexa speak: stored session did not restore", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
134438
|
-
});
|
|
134958
|
+
await this.migrateLegacyLogin();
|
|
134959
|
+
await this.brokersChanged();
|
|
134439
134960
|
this.timer = setInterval(() => {
|
|
134440
|
-
this.
|
|
134961
|
+
this.refreshAll();
|
|
134441
134962
|
}, DEVICE_REFRESH_MS);
|
|
134442
134963
|
}
|
|
134443
134964
|
stop() {
|
|
134444
134965
|
if (this.timer !== null) clearInterval(this.timer);
|
|
134445
134966
|
this.timer = null;
|
|
134446
|
-
this.
|
|
134447
|
-
this.
|
|
134967
|
+
for (const echo of this.echoes.values()) echo.stop();
|
|
134968
|
+
this.echoes.clear();
|
|
134969
|
+
}
|
|
134970
|
+
/** The addon calls this whenever its list of brokers may have changed. */
|
|
134971
|
+
brokersChanged() {
|
|
134972
|
+
this.queue = this.queue.then(() => this.reconcile()).catch(() => void 0);
|
|
134973
|
+
return this.queue;
|
|
134448
134974
|
}
|
|
134449
134975
|
handlers() {
|
|
134450
134976
|
return {
|
|
134451
134977
|
speakStatus: async () => this.status(),
|
|
134452
|
-
loginStart: async (input) => {
|
|
134453
|
-
this.settings = {
|
|
134454
|
-
...this.settings,
|
|
134455
|
-
email: input.email,
|
|
134456
|
-
password: input.password,
|
|
134457
|
-
region: input.region
|
|
134458
|
-
};
|
|
134459
|
-
await this.persist();
|
|
134460
|
-
this.noteSecrets();
|
|
134461
|
-
return this.loginWithFiles();
|
|
134462
|
-
},
|
|
134463
|
-
loginOtp: async (input) => this.otpWithFiles(input.code),
|
|
134464
134978
|
sendTest: async (input) => this.sendTest(input)
|
|
134465
134979
|
};
|
|
134466
134980
|
}
|
|
134467
|
-
|
|
134468
|
-
|
|
134469
|
-
|
|
134981
|
+
brokerViews() {
|
|
134982
|
+
return this.host.listBrokers().map((ref) => {
|
|
134983
|
+
const echo = this.echoes.get(ref.id);
|
|
134984
|
+
return {
|
|
134985
|
+
id: ref.id,
|
|
134986
|
+
name: ref.name,
|
|
134987
|
+
ready: echo?.state === "ready",
|
|
134988
|
+
devices: echo?.devices ?? []
|
|
134989
|
+
};
|
|
134990
|
+
});
|
|
134470
134991
|
}
|
|
134471
134992
|
async persist() {
|
|
134472
134993
|
try {
|
|
@@ -134475,142 +134996,126 @@ var AlexaSpeakController = class {
|
|
|
134475
134996
|
this.host.logger.warn("alexa speak: could not store settings", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
134476
134997
|
}
|
|
134477
134998
|
}
|
|
134478
|
-
|
|
134479
|
-
|
|
134480
|
-
|
|
134481
|
-
|
|
134482
|
-
|
|
134483
|
-
async
|
|
134484
|
-
|
|
134485
|
-
|
|
134486
|
-
|
|
134487
|
-
|
|
134488
|
-
|
|
134489
|
-
|
|
134490
|
-
|
|
134491
|
-
}, async (paths) => {
|
|
134492
|
-
outcome = await this.applyLogin(await this.loginRequest(paths));
|
|
134493
|
-
});
|
|
134494
|
-
if (held.sessionB64 !== this.settings.session) {
|
|
134495
|
-
this.settings = {
|
|
134496
|
-
...this.settings,
|
|
134497
|
-
session: held.sessionB64
|
|
134498
|
-
};
|
|
134499
|
-
await this.persist();
|
|
134500
|
-
}
|
|
134501
|
-
return outcome;
|
|
134502
|
-
}
|
|
134503
|
-
async otpWithFiles(code) {
|
|
134504
|
-
let outcome = {
|
|
134505
|
-
status: "failed",
|
|
134506
|
-
error: "alexa worker is not running"
|
|
134507
|
-
};
|
|
134508
|
-
const held = await withSpeakSecretFiles({
|
|
134999
|
+
/**
|
|
135000
|
+
* Drain the old standalone login once. With exactly one broker its jar and
|
|
135001
|
+
* its targets move onto that broker; otherwise the jar is dropped and the
|
|
135002
|
+
* targets wait for the operator to choose (the status names that state).
|
|
135003
|
+
*/
|
|
135004
|
+
async migrateLegacyLogin() {
|
|
135005
|
+
const brokers = this.host.listBrokers();
|
|
135006
|
+
const plan = planLegacyMigration(legacyLoginOf(this.settings), brokers.map((ref) => ref.id), this.settings.targets);
|
|
135007
|
+
if (!plan.present) return;
|
|
135008
|
+
if (plan.attachTo !== null) await this.host.sessions.put(plan.attachTo, this.settings.session);
|
|
135009
|
+
this.settings = {
|
|
135010
|
+
...this.settings,
|
|
135011
|
+
email: "",
|
|
134509
135012
|
password: "",
|
|
134510
|
-
|
|
134511
|
-
|
|
134512
|
-
|
|
134513
|
-
|
|
134514
|
-
|
|
134515
|
-
|
|
134516
|
-
|
|
134517
|
-
|
|
134518
|
-
|
|
134519
|
-
|
|
134520
|
-
...this.settings,
|
|
134521
|
-
session: held.sessionB64
|
|
134522
|
-
};
|
|
134523
|
-
await this.persist();
|
|
134524
|
-
}
|
|
134525
|
-
return outcome;
|
|
135013
|
+
session: "",
|
|
135014
|
+
targets: [...plan.targets]
|
|
135015
|
+
};
|
|
135016
|
+
await this.persist();
|
|
135017
|
+
await this.host.clearLegacySecrets();
|
|
135018
|
+
this.host.logger.info("alexa speak: the old standalone login was retired", { meta: {
|
|
135019
|
+
attachedTo: plan.attachTo ?? "none",
|
|
135020
|
+
brokers: brokers.length,
|
|
135021
|
+
targets: plan.targets.length
|
|
135022
|
+
} });
|
|
134526
135023
|
}
|
|
134527
|
-
|
|
134528
|
-
return this.
|
|
134529
|
-
|
|
134530
|
-
|
|
134531
|
-
|
|
134532
|
-
|
|
134533
|
-
|
|
135024
|
+
makeWorker(brokerId, pythonPath) {
|
|
135025
|
+
if (this.host.startWorker !== void 0) return this.host.startWorker(brokerId, pythonPath);
|
|
135026
|
+
return startAlexaWorker({
|
|
135027
|
+
pythonPath,
|
|
135028
|
+
scriptPath: resolveAlexaWorkerScript(),
|
|
135029
|
+
logger: this.host.logger.withTags({ brokerId }),
|
|
135030
|
+
now: this.now
|
|
134534
135031
|
});
|
|
134535
135032
|
}
|
|
134536
|
-
async
|
|
134537
|
-
const
|
|
134538
|
-
|
|
134539
|
-
|
|
134540
|
-
|
|
134541
|
-
|
|
134542
|
-
|
|
134543
|
-
|
|
134544
|
-
|
|
134545
|
-
await this.
|
|
134546
|
-
|
|
135033
|
+
async reconcile() {
|
|
135034
|
+
const pythonPath = this.pythonPath;
|
|
135035
|
+
if (pythonPath === null) return;
|
|
135036
|
+
const refs = this.host.listBrokers();
|
|
135037
|
+
const live = new Set(refs.map((ref) => ref.id));
|
|
135038
|
+
for (const [id, echo] of this.echoes) {
|
|
135039
|
+
if (live.has(id)) continue;
|
|
135040
|
+
echo.stop();
|
|
135041
|
+
this.echoes.delete(id);
|
|
135042
|
+
await this.host.sessions.delete(id).catch((err) => {
|
|
135043
|
+
this.host.logger.warn("alexa speak: could not drop the session of a removed account", {
|
|
135044
|
+
tags: { brokerId: id },
|
|
135045
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
135046
|
+
});
|
|
135047
|
+
});
|
|
134547
135048
|
}
|
|
134548
|
-
|
|
135049
|
+
const fresh = refs.filter((ref) => !this.echoes.has(ref.id));
|
|
135050
|
+
for (const ref of fresh) this.echoes.set(ref.id, new BrokerEcho({
|
|
135051
|
+
brokerId: ref.id,
|
|
135052
|
+
brokerName: ref.name,
|
|
135053
|
+
domain: ref.amazonDomain,
|
|
135054
|
+
worker: this.makeWorker(ref.id, pythonPath),
|
|
135055
|
+
readSeed: () => this.host.readSeed(ref.id),
|
|
135056
|
+
sessions: this.host.sessions,
|
|
135057
|
+
logger: this.host.logger
|
|
135058
|
+
}));
|
|
135059
|
+
await Promise.all(refs.map(async (ref) => {
|
|
135060
|
+
const echo = this.echoes.get(ref.id);
|
|
135061
|
+
if (echo !== void 0 && (fresh.includes(ref) || echo.state !== "ready")) await echo.signIn();
|
|
135062
|
+
}));
|
|
134549
135063
|
}
|
|
134550
|
-
async
|
|
134551
|
-
|
|
134552
|
-
|
|
134553
|
-
this.devices = [...parseDevices(await this.workerRequest({ op: "devices" }))];
|
|
134554
|
-
} catch (err) {
|
|
134555
|
-
this.host.logger.warn("alexa speak: device refresh failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
134556
|
-
}
|
|
135064
|
+
async refreshAll() {
|
|
135065
|
+
for (const echo of this.echoes.values()) if (echo.state === "ready") await echo.refreshDevices();
|
|
135066
|
+
else await echo.signIn();
|
|
134557
135067
|
}
|
|
134558
|
-
async utter(mode, targets, text, volume) {
|
|
134559
|
-
const
|
|
134560
|
-
|
|
134561
|
-
|
|
134562
|
-
targets,
|
|
134563
|
-
text,
|
|
134564
|
-
volume
|
|
134565
|
-
}));
|
|
135068
|
+
async utter(brokerId, mode, targets, text, volume) {
|
|
135069
|
+
const echo = this.echoes.get(brokerId);
|
|
135070
|
+
if (echo === void 0) throw new Error("no Echo session for this Amazon account");
|
|
135071
|
+
return echo.utter(mode === "announce" ? "announce" : "speak", targets, text, volume);
|
|
134566
135072
|
}
|
|
134567
|
-
async
|
|
134568
|
-
|
|
134569
|
-
|
|
134570
|
-
|
|
134571
|
-
lastSuccessAt: null
|
|
134572
|
-
};
|
|
134573
|
-
try {
|
|
134574
|
-
return parseSessionStatus(await this.workerRequest({ op: "session_status" }));
|
|
134575
|
-
} catch {
|
|
135073
|
+
async status() {
|
|
135074
|
+
const brokers = this.host.listBrokers();
|
|
135075
|
+
const rows = await Promise.all(brokers.map(async (ref) => {
|
|
135076
|
+
const echo = this.echoes.get(ref.id);
|
|
134576
135077
|
return {
|
|
134577
|
-
|
|
134578
|
-
|
|
134579
|
-
|
|
135078
|
+
id: ref.id,
|
|
135079
|
+
name: ref.name,
|
|
135080
|
+
state: echo?.state ?? "failed",
|
|
135081
|
+
...echo === void 0 ? { error: "embedded Python unavailable" } : echo.error === null ? {} : { error: echo.error },
|
|
135082
|
+
devices: [...echo?.devices ?? []],
|
|
135083
|
+
session: echo === void 0 ? {
|
|
135084
|
+
loggedIn: false,
|
|
135085
|
+
ageSec: 0,
|
|
135086
|
+
lastSuccessAt: null
|
|
135087
|
+
} : await echo.session()
|
|
134580
135088
|
};
|
|
134581
|
-
}
|
|
134582
|
-
|
|
134583
|
-
|
|
135089
|
+
}));
|
|
135090
|
+
const targets = this.settings.targets;
|
|
135091
|
+
const ids = brokers.map((ref) => ref.id);
|
|
134584
135092
|
return {
|
|
134585
|
-
|
|
134586
|
-
|
|
134587
|
-
|
|
134588
|
-
devices: [...this.devices],
|
|
134589
|
-
session: await this.session()
|
|
135093
|
+
brokers: rows,
|
|
135094
|
+
unassignedTargets: unassignedCount(targets),
|
|
135095
|
+
notice: noticeFor(targets, ids)
|
|
134590
135096
|
};
|
|
134591
135097
|
}
|
|
134592
135098
|
async sendTest(input) {
|
|
134593
|
-
const outcomes = await this.utter(input.mode, input.echoIds, input.text, input.volume);
|
|
134594
|
-
const known = new Map(this.devices.map((device) => [device.id, device]));
|
|
134595
|
-
const next = [...
|
|
135099
|
+
const outcomes = await this.utter(input.brokerId, input.mode, input.echoIds, input.text, input.volume);
|
|
135100
|
+
const known = new Map((this.echoes.get(input.brokerId)?.devices ?? []).map((device) => [device.id, device]));
|
|
135101
|
+
const next = [...this.settings.targets];
|
|
134596
135102
|
for (const echoId of input.echoIds) {
|
|
134597
|
-
if (next.some((row) => row.
|
|
134598
|
-
const device = known.get(echoId);
|
|
135103
|
+
if (next.some((row) => row.echoId === echoId && row.brokerId === input.brokerId)) continue;
|
|
134599
135104
|
next.push({
|
|
134600
|
-
id: `echo-${echoId}`,
|
|
134601
|
-
name:
|
|
134602
|
-
kind: ALEXA_SPEAK_KIND,
|
|
134603
|
-
addonId: ALEXA_SPEAK_ADDON_ID,
|
|
135105
|
+
id: `echo-${input.brokerId}-${echoId}`,
|
|
135106
|
+
name: known.get(echoId)?.name ?? echoId,
|
|
134604
135107
|
enabled: true,
|
|
134605
|
-
|
|
134606
|
-
|
|
134607
|
-
|
|
134608
|
-
|
|
134609
|
-
|
|
134610
|
-
}
|
|
135108
|
+
brokerId: input.brokerId,
|
|
135109
|
+
echoId,
|
|
135110
|
+
mode: input.mode,
|
|
135111
|
+
volume: input.volume,
|
|
135112
|
+
respectDnd: true
|
|
134611
135113
|
});
|
|
134612
135114
|
}
|
|
134613
|
-
this.settings =
|
|
135115
|
+
this.settings = {
|
|
135116
|
+
...this.settings,
|
|
135117
|
+
targets: next
|
|
135118
|
+
};
|
|
134614
135119
|
await this.persist();
|
|
134615
135120
|
return { outcomes: outcomes.map((row) => ({
|
|
134616
135121
|
echoId: row.targetId,
|
|
@@ -134620,6 +135125,55 @@ var AlexaSpeakController = class {
|
|
|
134620
135125
|
}
|
|
134621
135126
|
};
|
|
134622
135127
|
//#endregion
|
|
135128
|
+
//#region src/speak/icon-routes.ts
|
|
135129
|
+
var ALEXA_ICON_SVG = "<svg role=\"img\" viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"#00CAFF\"><title>Amazon Alexa</title><path d=\"M12 0C5.37 0 0 5.37 0 12C0 18.09 4.53 23.11 10.4 23.9V21.5A1.59 1.59 0 0 0 9.32 19.97A8.41 8.41 0 0 1 3.6 11.8A8.37 8.37 0 0 1 12.09 3.6A8.4 8.4 0 0 1 20.4 12.31L20.39 12.38A8.68 8.68 0 0 1 20.36 12.76C20.36 12.83 20.35 12.9 20.34 12.96C20.34 13.04 20.33 13.12 20.32 13.19L20.3 13.29C19.27 20.07 10.45 23.87 10.4 23.9C10.92 23.97 11.46 24 12 24C18.63 24 24 18.63 24 12S18.63 0 12 0Z\"/></svg>";
|
|
135130
|
+
/** One day; the mark is immutable per addon version. */
|
|
135131
|
+
var CACHE_CONTROL = "public, max-age=86400, immutable";
|
|
135132
|
+
function speakIconRoutes() {
|
|
135133
|
+
return [{
|
|
135134
|
+
method: "GET",
|
|
135135
|
+
path: `/icons/${ALEXA_SPEAK_KIND}`,
|
|
135136
|
+
access: "public",
|
|
135137
|
+
description: "Alexa brand mark (image/svg+xml) for the Alexa notification target.",
|
|
135138
|
+
handler: async (_request, reply) => {
|
|
135139
|
+
reply.code(200).type("image/svg+xml").header("cache-control", CACHE_CONTROL).send(ALEXA_ICON_SVG);
|
|
135140
|
+
}
|
|
135141
|
+
}];
|
|
135142
|
+
}
|
|
135143
|
+
//#endregion
|
|
135144
|
+
//#region src/speak/session-store.ts
|
|
135145
|
+
/** The Echo worker's cookie jar per broker, in the records store (secret field, keychain-backed). */
|
|
135146
|
+
var RecordsSpeakSessionStore = class RecordsSpeakSessionStore {
|
|
135147
|
+
kind;
|
|
135148
|
+
constructor(kind) {
|
|
135149
|
+
this.kind = kind;
|
|
135150
|
+
}
|
|
135151
|
+
static of(records) {
|
|
135152
|
+
return new RecordsSpeakSessionStore(records.kind(alexaSpeakSessionsEntryKind));
|
|
135153
|
+
}
|
|
135154
|
+
async get(brokerId) {
|
|
135155
|
+
try {
|
|
135156
|
+
const resolved = await this.kind.getResolved(brokerId);
|
|
135157
|
+
return resolved === null ? "" : resolved.data.session;
|
|
135158
|
+
} catch (err) {
|
|
135159
|
+
if (err instanceof RecordSecretUnavailableError) return "";
|
|
135160
|
+
throw err;
|
|
135161
|
+
}
|
|
135162
|
+
}
|
|
135163
|
+
async put(brokerId, sessionB64) {
|
|
135164
|
+
await this.kind.put({
|
|
135165
|
+
id: brokerId,
|
|
135166
|
+
data: {
|
|
135167
|
+
name: brokerId,
|
|
135168
|
+
session: sessionB64
|
|
135169
|
+
}
|
|
135170
|
+
});
|
|
135171
|
+
}
|
|
135172
|
+
async delete(brokerId) {
|
|
135173
|
+
await this.kind.delete(brokerId);
|
|
135174
|
+
}
|
|
135175
|
+
};
|
|
135176
|
+
//#endregion
|
|
134623
135177
|
//#region src/addon.ts
|
|
134624
135178
|
var SPEAK_PAGE = {
|
|
134625
135179
|
id: "speak",
|
|
@@ -134693,7 +135247,7 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
|
|
|
134693
135247
|
const regs = this.providerRegistrationsFromSuper(await super.onInitialize());
|
|
134694
135248
|
const records = this.ctx.records;
|
|
134695
135249
|
if (records === void 0) throw new Error("import-alexa: ctx.records is required (hub-only addon)");
|
|
134696
|
-
await records.declare(
|
|
135250
|
+
await records.declare(alexaRecordKinds);
|
|
134697
135251
|
this.brokerStore = AlexaBrokerStore.of(records);
|
|
134698
135252
|
this.activeBrokers = await this.loadPersistedBrokers();
|
|
134699
135253
|
this.registry = new AlexaBrokerRegistry(this.ctx.logger, {
|
|
@@ -134734,29 +135288,33 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
|
|
|
134734
135288
|
this.ctx.logger.info("Alexa: import addon initialised", { meta: { brokerCount: this.activeBrokers.length } });
|
|
134735
135289
|
await this.reconcileIntegrationsToBrokers();
|
|
134736
135290
|
this.subscribeIntegrationLifecycle();
|
|
134737
|
-
const loginRoutes = buildAlexaLoginRoutes({
|
|
135291
|
+
const loginRoutes = [...buildAlexaLoginRoutes({
|
|
134738
135292
|
logger: this.ctx.logger,
|
|
134739
135293
|
proxyLogin: this.proxyLogin,
|
|
134740
135294
|
commitCookie: (input) => this.commitCapturedCookie(input)
|
|
134741
|
-
});
|
|
135295
|
+
}), ...speakIconRoutes()];
|
|
134742
135296
|
const speak = new AlexaSpeakController({
|
|
134743
135297
|
logger: this.ctx.logger.child("speak"),
|
|
134744
135298
|
readConfig: () => this.config,
|
|
134745
135299
|
writeSpeak: async (next) => {
|
|
134746
135300
|
await this.updateGlobalSettings({ speak: next });
|
|
134747
135301
|
},
|
|
134748
|
-
|
|
134749
|
-
|
|
134750
|
-
|
|
134751
|
-
|
|
134752
|
-
|
|
134753
|
-
|
|
134754
|
-
|
|
135302
|
+
listBrokers: () => this.activeBrokers.map((entry) => ({
|
|
135303
|
+
id: entry.id,
|
|
135304
|
+
name: entry.name,
|
|
135305
|
+
amazonDomain: entry.connection.amazonDomain
|
|
135306
|
+
})),
|
|
135307
|
+
readSeed: async (brokerId) => {
|
|
135308
|
+
const entry = this.activeBrokers.find((row) => row.id === brokerId);
|
|
135309
|
+
return entry === void 0 ? null : readBrokerSeed(this.ctx.dataDir, entry);
|
|
134755
135310
|
},
|
|
134756
|
-
|
|
134757
|
-
|
|
134758
|
-
|
|
134759
|
-
|
|
135311
|
+
sessions: RecordsSpeakSessionStore.of(records),
|
|
135312
|
+
clearLegacySecrets: async () => {
|
|
135313
|
+
await this.updateGlobalSettingsAsOperator({ speak: {
|
|
135314
|
+
...this.config.speak,
|
|
135315
|
+
password: SECRET_CLEAR,
|
|
135316
|
+
session: SECRET_CLEAR
|
|
135317
|
+
} });
|
|
134760
135318
|
},
|
|
134761
135319
|
ensurePython: () => this.ctx.deps.ensurePython()
|
|
134762
135320
|
});
|
|
@@ -134807,11 +135365,9 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
|
|
|
134807
135365
|
async persistBrokerEntries(entries) {
|
|
134808
135366
|
this.activeBrokers = [...entries];
|
|
134809
135367
|
const store = this.brokerStore;
|
|
134810
|
-
if (store !== null)
|
|
134811
|
-
|
|
134812
|
-
|
|
134813
|
-
}
|
|
134814
|
-
await this.updateGlobalSettings({ brokers: [...entries] });
|
|
135368
|
+
if (store !== null) await store.replaceAll(entries);
|
|
135369
|
+
else await this.updateGlobalSettings({ brokers: [...entries] });
|
|
135370
|
+
this.speak?.brokersChanged();
|
|
134815
135371
|
}
|
|
134816
135372
|
async onConfigChanged() {
|
|
134817
135373
|
const reg = this.registry;
|
|
@@ -134857,6 +135413,7 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
|
|
|
134857
135413
|
} });
|
|
134858
135414
|
}
|
|
134859
135415
|
}
|
|
135416
|
+
this.speak?.brokersChanged();
|
|
134860
135417
|
}
|
|
134861
135418
|
async onShutdown() {
|
|
134862
135419
|
this.speak?.stop();
|
|
@@ -135095,90 +135652,43 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
|
|
|
135095
135652
|
});
|
|
135096
135653
|
}
|
|
135097
135654
|
globalSettingsSchema() {
|
|
135098
|
-
return this.schema({ sections: [
|
|
135099
|
-
|
|
135100
|
-
|
|
135101
|
-
|
|
135102
|
-
|
|
135103
|
-
|
|
135104
|
-
|
|
135105
|
-
|
|
135106
|
-
|
|
135107
|
-
|
|
135108
|
-
|
|
135109
|
-
|
|
135110
|
-
|
|
135111
|
-
|
|
135112
|
-
|
|
135113
|
-
|
|
135114
|
-
|
|
135115
|
-
|
|
135116
|
-
|
|
135117
|
-
|
|
135118
|
-
|
|
135119
|
-
|
|
135120
|
-
|
|
135121
|
-
|
|
135122
|
-
|
|
135123
|
-
|
|
135124
|
-
|
|
135125
|
-
|
|
135126
|
-
|
|
135127
|
-
|
|
135128
|
-
|
|
135129
|
-
|
|
135130
|
-
|
|
135131
|
-
|
|
135132
|
-
|
|
135133
|
-
|
|
135134
|
-
|
|
135135
|
-
}]
|
|
135136
|
-
},
|
|
135137
|
-
{
|
|
135138
|
-
id: "alexa-speak",
|
|
135139
|
-
title: "Speak on Echo",
|
|
135140
|
-
titleKey: "import-alexa:globalSettings.s_alexa-speak.title",
|
|
135141
|
-
description: "Sign in on the Speak on Echo page, pick the devices, and use them as a notification target. The text is the rule template, read aloud.",
|
|
135142
|
-
descriptionKey: "import-alexa:globalSettings.s_alexa-speak.description",
|
|
135143
|
-
columns: 1,
|
|
135144
|
-
fields: [
|
|
135145
|
-
{
|
|
135146
|
-
type: "text",
|
|
135147
|
-
key: "speak.email",
|
|
135148
|
-
label: "Amazon email",
|
|
135149
|
-
labelKey: "import-alexa:globalSettings.s_alexa-speak.speak_email.label",
|
|
135150
|
-
inputType: "email"
|
|
135151
|
-
},
|
|
135152
|
-
{
|
|
135153
|
-
type: "password",
|
|
135154
|
-
key: "speak.password",
|
|
135155
|
-
label: "Amazon password",
|
|
135156
|
-
labelKey: "import-alexa:globalSettings.s_alexa-speak.speak_password.label"
|
|
135157
|
-
},
|
|
135158
|
-
{
|
|
135159
|
-
type: "select",
|
|
135160
|
-
key: "speak.region",
|
|
135161
|
-
label: "Region",
|
|
135162
|
-
labelKey: "import-alexa:globalSettings.s_alexa-speak.speak_region.label",
|
|
135163
|
-
default: "EU",
|
|
135164
|
-
options: [
|
|
135165
|
-
{
|
|
135166
|
-
value: "NA",
|
|
135167
|
-
label: "North America"
|
|
135168
|
-
},
|
|
135169
|
-
{
|
|
135170
|
-
value: "EU",
|
|
135171
|
-
label: "Europe"
|
|
135172
|
-
},
|
|
135173
|
-
{
|
|
135174
|
-
value: "FE",
|
|
135175
|
-
label: "Far East"
|
|
135176
|
-
}
|
|
135177
|
-
]
|
|
135178
|
-
}
|
|
135179
|
-
]
|
|
135180
|
-
}
|
|
135181
|
-
] });
|
|
135655
|
+
return this.schema({ sections: [{
|
|
135656
|
+
id: "alexa-account",
|
|
135657
|
+
title: "Amazon Alexa account",
|
|
135658
|
+
titleKey: "import-alexa:globalSettings.s_alexa-account.title",
|
|
135659
|
+
description: "Alexa accounts are managed in the External systems → Brokers tab.",
|
|
135660
|
+
descriptionKey: "import-alexa:globalSettings.s_alexa-account.description",
|
|
135661
|
+
columns: 1,
|
|
135662
|
+
fields: [{
|
|
135663
|
+
type: "info",
|
|
135664
|
+
key: "accountHelp",
|
|
135665
|
+
label: "Accounts are managed separately",
|
|
135666
|
+
labelKey: "import-alexa:globalSettings.s_alexa-account.accountHelp.label",
|
|
135667
|
+
content: "This integration links to an Amazon Alexa account broker. Add, edit, or remove accounts from External systems → Brokers, then adopt the imported devices. The integration only stores a reference to its broker. NOTE: this is an unofficial integration that Amazon can change or break without notice."
|
|
135668
|
+
}]
|
|
135669
|
+
}, {
|
|
135670
|
+
id: "alexa-proxy-login",
|
|
135671
|
+
title: "Interactive login (proxy)",
|
|
135672
|
+
titleKey: "import-alexa:globalSettings.s_alexa-proxy-login.title",
|
|
135673
|
+
description: "The \"Login to Amazon\" button in onboarding opens a local proxy that captures the cookie after you complete the real Amazon login (incl. 2FA). Because Amazon URLs are rewritten to this exact host:port, it must be a directly-reachable address — leave the host blank to auto-detect the server IP, and make sure the port is reachable from the browser you log in from.",
|
|
135674
|
+
descriptionKey: "import-alexa:globalSettings.s_alexa-proxy-login.description",
|
|
135675
|
+
columns: 2,
|
|
135676
|
+
fields: [{
|
|
135677
|
+
type: "text",
|
|
135678
|
+
key: "proxyLogin.proxyHost",
|
|
135679
|
+
label: "Login proxy host/IP (blank = auto-detect)",
|
|
135680
|
+
labelKey: "import-alexa:globalSettings.s_alexa-proxy-login.proxyLogin_proxyHost.label",
|
|
135681
|
+
placeholder: "auto-detect"
|
|
135682
|
+
}, {
|
|
135683
|
+
type: "number",
|
|
135684
|
+
key: "proxyLogin.proxyPort",
|
|
135685
|
+
label: "Login proxy port",
|
|
135686
|
+
labelKey: "import-alexa:globalSettings.s_alexa-proxy-login.proxyLogin_proxyPort.label",
|
|
135687
|
+
min: 1,
|
|
135688
|
+
max: 65535,
|
|
135689
|
+
default: 3456
|
|
135690
|
+
}]
|
|
135691
|
+
}] });
|
|
135182
135692
|
}
|
|
135183
135693
|
async supportsManualCreation() {
|
|
135184
135694
|
return false;
|