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