@camstack/addon-import-alexa 0.2.149 → 0.2.150
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 +1106 -598
- package/dist/addon.mjs +1108 -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,11 @@ 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
|
+
});
|
|
133230
133517
|
//#endregion
|
|
133231
133518
|
//#region src/alexa-broker-store.ts
|
|
133232
133519
|
function rowToEntry(id, data) {
|
|
@@ -133288,12 +133575,8 @@ var AlexaBrokerStore = class AlexaBrokerStore {
|
|
|
133288
133575
|
var ALEXA_SPEAK_ADDON_ID = "import-alexa";
|
|
133289
133576
|
var ALEXA_SPEAK_KIND = "alexa-echo";
|
|
133290
133577
|
var DEVICE_REFRESH_MS = 5 * 6e4;
|
|
133291
|
-
|
|
133292
|
-
var
|
|
133293
|
-
"NA",
|
|
133294
|
-
"EU",
|
|
133295
|
-
"FE"
|
|
133296
|
-
];
|
|
133578
|
+
/** Where the addon serves the Alexa mark; `listTargetKinds` stamps it as `iconUrl`. */
|
|
133579
|
+
var ALEXA_SPEAK_ICON_URL = `/addon/${ALEXA_SPEAK_ADDON_ID}/icons/${ALEXA_SPEAK_KIND}`;
|
|
133297
133580
|
//#endregion
|
|
133298
133581
|
//#region src/speak/worker-protocol.ts
|
|
133299
133582
|
/** Line-delimited JSON the long-lived Python worker speaks. One object per line. */
|
|
@@ -133304,14 +133587,11 @@ var EchoDeviceSchema = object({
|
|
|
133304
133587
|
online: boolean(),
|
|
133305
133588
|
kind: _enum(["device", "group"]),
|
|
133306
133589
|
dnd: boolean(),
|
|
133307
|
-
deviceType: string()
|
|
133590
|
+
deviceType: string(),
|
|
133591
|
+
/** The room the account files the device under, when Amazon names one. */
|
|
133592
|
+
room: string().optional()
|
|
133308
133593
|
});
|
|
133309
|
-
var LoginStatusSchema = _enum([
|
|
133310
|
-
"ok",
|
|
133311
|
-
"otp_needed",
|
|
133312
|
-
"captcha",
|
|
133313
|
-
"failed"
|
|
133314
|
-
]);
|
|
133594
|
+
var LoginStatusSchema = _enum(["ok", "failed"]);
|
|
133315
133595
|
var SessionStatusSchema = object({
|
|
133316
133596
|
loggedIn: boolean(),
|
|
133317
133597
|
ageSec: number().int().nonnegative(),
|
|
@@ -133365,40 +133645,37 @@ function parseSessionStatus(result) {
|
|
|
133365
133645
|
}
|
|
133366
133646
|
//#endregion
|
|
133367
133647
|
//#region src/speak/actions.ts
|
|
133648
|
+
var BrokerEchoStateSchema = _enum([
|
|
133649
|
+
"starting",
|
|
133650
|
+
"ready",
|
|
133651
|
+
"broker-not-signed-in",
|
|
133652
|
+
"failed"
|
|
133653
|
+
]);
|
|
133368
133654
|
var SpeakStatusSchema = object({
|
|
133369
|
-
|
|
133370
|
-
|
|
133371
|
-
|
|
133372
|
-
|
|
133373
|
-
|
|
133374
|
-
|
|
133375
|
-
|
|
133376
|
-
|
|
133377
|
-
|
|
133378
|
-
|
|
133379
|
-
|
|
133380
|
-
|
|
133381
|
-
|
|
133382
|
-
|
|
133655
|
+
brokers: array(object({
|
|
133656
|
+
id: string(),
|
|
133657
|
+
name: string(),
|
|
133658
|
+
state: BrokerEchoStateSchema,
|
|
133659
|
+
error: string().optional(),
|
|
133660
|
+
devices: array(EchoDeviceSchema),
|
|
133661
|
+
session: SessionStatusSchema
|
|
133662
|
+
})),
|
|
133663
|
+
/** Targets that name no Amazon account yet (they cannot speak until one is chosen). */
|
|
133664
|
+
unassignedTargets: number().int().nonnegative(),
|
|
133665
|
+
/** What the old standalone login left behind, named; '' when nothing is pending. */
|
|
133666
|
+
notice: _enum([
|
|
133667
|
+
"",
|
|
133668
|
+
"no-broker",
|
|
133669
|
+
"choose-broker"
|
|
133670
|
+
])
|
|
133383
133671
|
});
|
|
133384
133672
|
var alexaSpeakActions = defineCustomActions({
|
|
133385
133673
|
speakStatus: customAction(object({}), SpeakStatusSchema, {
|
|
133386
133674
|
kind: "mutation",
|
|
133387
133675
|
auth: "admin"
|
|
133388
133676
|
}),
|
|
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
133677
|
sendTest: customAction(object({
|
|
133678
|
+
brokerId: string().min(1),
|
|
133402
133679
|
echoIds: array(string().min(1)).min(1),
|
|
133403
133680
|
mode: _enum(["speak", "announce"]),
|
|
133404
133681
|
volume: number().int().min(0).max(100),
|
|
@@ -133413,55 +133690,403 @@ var alexaSpeakActions = defineCustomActions({
|
|
|
133413
133690
|
})
|
|
133414
133691
|
});
|
|
133415
133692
|
//#endregion
|
|
133416
|
-
//#region src/speak/
|
|
133693
|
+
//#region src/speak/broker-seed.ts
|
|
133694
|
+
/**
|
|
133695
|
+
* What the Echo worker needs from an import broker to act as the same Amazon
|
|
133696
|
+
* account: the refresh token of the broker's own device registration. The
|
|
133697
|
+
* broker keeps that registration fresh (it persists `cookieData` after every
|
|
133698
|
+
* refresh), so the freshest copy is the persisted session file, then the
|
|
133699
|
+
* stored cookie blob, then a bare refresh token. A raw cookie string carries
|
|
133700
|
+
* no refresh token and gives no seed.
|
|
133701
|
+
*/
|
|
133702
|
+
/** The registration name alexapy sends when the broker did not record one. */
|
|
133703
|
+
var DEFAULT_DEVICE_APP_NAME = "Alexa Media Player";
|
|
133704
|
+
function isRecord(value) {
|
|
133705
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
133706
|
+
}
|
|
133707
|
+
/** The registration blob alexa-remote2 hands back, from an object or its JSON text. */
|
|
133708
|
+
function asRegistration(value) {
|
|
133709
|
+
if (isRecord(value)) return value;
|
|
133710
|
+
if (typeof value !== "string") return null;
|
|
133711
|
+
const trimmed = value.trim();
|
|
133712
|
+
if (!trimmed.startsWith("{")) return null;
|
|
133713
|
+
try {
|
|
133714
|
+
const parsed = JSON.parse(trimmed);
|
|
133715
|
+
return isRecord(parsed) ? parsed : null;
|
|
133716
|
+
} catch {
|
|
133717
|
+
return null;
|
|
133718
|
+
}
|
|
133719
|
+
}
|
|
133720
|
+
function seedFromRegistration(value) {
|
|
133721
|
+
const registration = asRegistration(value);
|
|
133722
|
+
if (registration === null) return null;
|
|
133723
|
+
const token = registration["refreshToken"];
|
|
133724
|
+
if (typeof token !== "string" || token.length === 0) return null;
|
|
133725
|
+
const name = registration["deviceAppName"];
|
|
133726
|
+
return {
|
|
133727
|
+
refreshToken: token,
|
|
133728
|
+
deviceAppName: typeof name === "string" && name.length > 0 ? name : DEFAULT_DEVICE_APP_NAME
|
|
133729
|
+
};
|
|
133730
|
+
}
|
|
133731
|
+
async function readPersistedRegistration(dataDir, brokerId) {
|
|
133732
|
+
if (dataDir === void 0) return null;
|
|
133733
|
+
try {
|
|
133734
|
+
const raw = await fs$1.readFile(path$1.join(dataDir, `${brokerId}.${SESSION_FILE}`), "utf8");
|
|
133735
|
+
const json = JSON.parse(raw);
|
|
133736
|
+
return isRecord(json) ? json["cookieData"] : null;
|
|
133737
|
+
} catch {
|
|
133738
|
+
return null;
|
|
133739
|
+
}
|
|
133740
|
+
}
|
|
133741
|
+
async function readBrokerSeed(dataDir, entry) {
|
|
133742
|
+
const persisted = seedFromRegistration(await readPersistedRegistration(dataDir, entry.id));
|
|
133743
|
+
if (persisted !== null) return persisted;
|
|
133744
|
+
const stored = seedFromRegistration(entry.connection.cookie);
|
|
133745
|
+
if (stored !== null) return stored;
|
|
133746
|
+
const bare = entry.connection.refreshToken;
|
|
133747
|
+
if (bare.length > 0) return {
|
|
133748
|
+
refreshToken: bare,
|
|
133749
|
+
deviceAppName: DEFAULT_DEVICE_APP_NAME
|
|
133750
|
+
};
|
|
133751
|
+
return null;
|
|
133752
|
+
}
|
|
133753
|
+
/** Amazon domain to the region the worker uses for the spoken-sequence locale. */
|
|
133754
|
+
function regionOfDomain(domain) {
|
|
133755
|
+
const tail = domain.toLowerCase().replace(/^(www|alexa)\./, "");
|
|
133756
|
+
if (tail === "amazon.com") return "NA";
|
|
133757
|
+
if (tail === "amazon.co.jp" || tail === "amazon.com.au" || tail === "amazon.in") return "FE";
|
|
133758
|
+
return "EU";
|
|
133759
|
+
}
|
|
133760
|
+
process.platform, process.env["SystemRoot"];
|
|
133417
133761
|
/**
|
|
133418
|
-
*
|
|
133419
|
-
*
|
|
133762
|
+
* Files only their owner may read: the one place a secret-bearing file is
|
|
133763
|
+
* restricted, on every OS.
|
|
133764
|
+
*
|
|
133765
|
+
* `mode: 0o600` is a POSIX idea. On Windows Node maps it to the read-only
|
|
133766
|
+
* attribute and nothing else, and `chmod` is a no-op for permissions, so a
|
|
133767
|
+
* "0600" `agent.json` (the mesh private key) would have
|
|
133768
|
+
* been readable by every local user. Windows has its own tool for this:
|
|
133769
|
+
* `icacls`. This module removes the inherited ACL entries and grants access to
|
|
133770
|
+
* the current user and SYSTEM only — the Windows reading of "0600".
|
|
133771
|
+
*
|
|
133772
|
+
* ZERO imports beyond Node built-ins, on purpose: the Electron shell bundles
|
|
133773
|
+
* this single file (see `packages/desktop/tsup.config.ts`) instead of the
|
|
133774
|
+
* whole `@camstack/types` barrel.
|
|
133775
|
+
*
|
|
133776
|
+
* Failure is NOT swallowed. A secret file that could not be restricted is a
|
|
133777
|
+
* secret file the caller must know about, so `OwnerOnlyError` propagates; the
|
|
133778
|
+
* write order below makes sure nothing sensitive is in the file yet when that
|
|
133779
|
+
* happens (the temp file is empty until it has been restricted).
|
|
133780
|
+
*
|
|
133781
|
+
* Out of scope on purpose: files that are only tidy, not secret (the job
|
|
133782
|
+
* journal, the addon manifest) keep a plain `mode`, since one `icacls` per
|
|
133783
|
+
* write would be a process spawn on a hot path.
|
|
133420
133784
|
*/
|
|
133421
|
-
|
|
133422
|
-
|
|
133785
|
+
/** `*S-1-5-18` is SYSTEM in every locale — a name like "SYSTEM" is not. */
|
|
133786
|
+
var WINDOWS_SYSTEM_SID = "*S-1-5-18";
|
|
133787
|
+
var FILE_MODE = 384;
|
|
133788
|
+
var DIR_MODE = 448;
|
|
133789
|
+
var OwnerOnlyError = class extends Error {
|
|
133790
|
+
target;
|
|
133791
|
+
constructor(target, detail) {
|
|
133792
|
+
super(`could not restrict ${target} to its owner: ${detail}`);
|
|
133793
|
+
this.target = target;
|
|
133794
|
+
this.name = "OwnerOnlyError";
|
|
133795
|
+
}
|
|
133796
|
+
};
|
|
133797
|
+
function currentPrincipal() {
|
|
133798
|
+
const user = process.env["USERNAME"] ?? userInfo().username;
|
|
133799
|
+
const domain = process.env["USERDOMAIN"];
|
|
133800
|
+
return domain !== void 0 && domain.length > 0 ? `${domain}\\${user}` : user;
|
|
133801
|
+
}
|
|
133802
|
+
/** Resolved per call, not at import: a platform stubbed by a test must be seen. */
|
|
133803
|
+
function resolveDeps(overrides) {
|
|
133423
133804
|
return {
|
|
133424
|
-
|
|
133425
|
-
|
|
133426
|
-
|
|
133427
|
-
|
|
133428
|
-
|
|
133429
|
-
|
|
133430
|
-
|
|
133431
|
-
|
|
133432
|
-
|
|
133433
|
-
|
|
133434
|
-
|
|
133435
|
-
|
|
133436
|
-
|
|
133437
|
-
|
|
133438
|
-
|
|
133439
|
-
|
|
133440
|
-
|
|
133441
|
-
|
|
133442
|
-
|
|
133805
|
+
platform: process.platform,
|
|
133806
|
+
exec: (file, args) => {
|
|
133807
|
+
execFileSync(file, [...args], {
|
|
133808
|
+
stdio: "pipe",
|
|
133809
|
+
windowsHide: true
|
|
133810
|
+
});
|
|
133811
|
+
},
|
|
133812
|
+
principal: currentPrincipal,
|
|
133813
|
+
systemRoot: process.env["SystemRoot"],
|
|
133814
|
+
...overrides
|
|
133815
|
+
};
|
|
133816
|
+
}
|
|
133817
|
+
/** The icacls argv for one path: no inheritance, owner + SYSTEM only. */
|
|
133818
|
+
function icaclsArgs(target, principal, isDirectory) {
|
|
133819
|
+
const inherit = isDirectory ? "(OI)(CI)" : "";
|
|
133820
|
+
return [
|
|
133821
|
+
target,
|
|
133822
|
+
"/inheritance:r",
|
|
133823
|
+
"/grant:r",
|
|
133824
|
+
`${principal}:${inherit}F`,
|
|
133825
|
+
"/grant:r",
|
|
133826
|
+
`${WINDOWS_SYSTEM_SID}:${inherit}F`
|
|
133827
|
+
];
|
|
133828
|
+
}
|
|
133829
|
+
function restrictWindows(target, isDirectory, deps) {
|
|
133830
|
+
const icacls = path$1.win32.join(deps.systemRoot ?? "C:\\Windows", "System32", "icacls.exe");
|
|
133831
|
+
try {
|
|
133832
|
+
deps.exec(icacls, icaclsArgs(target, deps.principal(), isDirectory));
|
|
133833
|
+
} catch (err) {
|
|
133834
|
+
throw new OwnerOnlyError(target, err instanceof Error ? err.message : String(err));
|
|
133835
|
+
}
|
|
133836
|
+
}
|
|
133837
|
+
/** Restrict an existing file to its owner (chmod 0600 / owner+SYSTEM ACL). */
|
|
133838
|
+
function restrictFileToOwnerSync(target, overrides = {}) {
|
|
133839
|
+
const deps = resolveDeps(overrides);
|
|
133840
|
+
if (deps.platform === "win32") {
|
|
133841
|
+
restrictWindows(target, false, deps);
|
|
133842
|
+
return;
|
|
133843
|
+
}
|
|
133844
|
+
fs.chmodSync(target, FILE_MODE);
|
|
133845
|
+
}
|
|
133846
|
+
/** Restrict an existing directory to its owner (chmod 0700 / owner+SYSTEM, inherited). */
|
|
133847
|
+
function restrictDirToOwnerSync(target, overrides = {}) {
|
|
133848
|
+
const deps = resolveDeps(overrides);
|
|
133849
|
+
if (deps.platform === "win32") {
|
|
133850
|
+
restrictWindows(target, true, deps);
|
|
133851
|
+
return;
|
|
133852
|
+
}
|
|
133853
|
+
fs.chmodSync(target, DIR_MODE);
|
|
133854
|
+
}
|
|
133855
|
+
/**
|
|
133856
|
+
* Atomically write a secret-bearing file that only its owner can read.
|
|
133857
|
+
*
|
|
133858
|
+
* Order matters: the temp file is created EMPTY, restricted, and only then
|
|
133859
|
+
* given its content, so there is no moment at which the secret exists under a
|
|
133860
|
+
* permissive ACL. The rename carries the (already restricted) ACL with it.
|
|
133861
|
+
*/
|
|
133862
|
+
function writeOwnerOnlyFileSync(target, data, overrides = {}) {
|
|
133863
|
+
fs.mkdirSync(path$1.dirname(target), { recursive: true });
|
|
133864
|
+
const tmp = `${target}.tmp.${process.pid}`;
|
|
133865
|
+
fs.writeFileSync(tmp, "", { mode: FILE_MODE });
|
|
133866
|
+
try {
|
|
133867
|
+
restrictFileToOwnerSync(tmp, overrides);
|
|
133868
|
+
fs.writeFileSync(tmp, data);
|
|
133869
|
+
fs.renameSync(tmp, target);
|
|
133870
|
+
} catch (err) {
|
|
133871
|
+
fs.rmSync(tmp, { force: true });
|
|
133872
|
+
throw err;
|
|
133873
|
+
}
|
|
133874
|
+
}
|
|
133875
|
+
//#endregion
|
|
133876
|
+
//#region src/speak/secret-file.ts
|
|
133877
|
+
/**
|
|
133878
|
+
* Hand a refresh token or a saved Amazon session to the Python worker as an
|
|
133879
|
+
* owner-only file. The path travels on stdin. The bytes never travel on
|
|
133880
|
+
* argv or in the environment, and they are removed when the call settles.
|
|
133881
|
+
*/
|
|
133882
|
+
var REDACTED = "‹redacted›";
|
|
133883
|
+
function decodeSession(sessionB64) {
|
|
133884
|
+
if (sessionB64.length === 0) return Buffer.alloc(0);
|
|
133885
|
+
return Buffer.from(sessionB64, "base64");
|
|
133886
|
+
}
|
|
133887
|
+
async function readSessionB64(sessionPath) {
|
|
133888
|
+
const bytes = await fs$1.readFile(sessionPath);
|
|
133889
|
+
if (bytes.length === 0) return "";
|
|
133890
|
+
return bytes.toString("base64");
|
|
133891
|
+
}
|
|
133892
|
+
/**
|
|
133893
|
+
* Create a 0700 directory, write the refresh token and the session 0600, run
|
|
133894
|
+
* `body`, then delete the directory whether `body` succeeded or threw.
|
|
133895
|
+
* Returns the session file as base64 so the caller can store it in the keychain.
|
|
133896
|
+
*/
|
|
133897
|
+
async function withSpeakSecretFiles(input, body) {
|
|
133898
|
+
const dir = await fs$1.mkdtemp(path$1.join(os.tmpdir(), "camstack-alexa-"));
|
|
133899
|
+
const paths = {
|
|
133900
|
+
dir,
|
|
133901
|
+
refreshTokenPath: path$1.join(dir, "refresh-token"),
|
|
133902
|
+
sessionPath: path$1.join(dir, "session")
|
|
133443
133903
|
};
|
|
133904
|
+
try {
|
|
133905
|
+
restrictDirToOwnerSync(dir);
|
|
133906
|
+
writeOwnerOnlyFileSync(paths.refreshTokenPath, input.refreshToken);
|
|
133907
|
+
writeOwnerOnlyFileSync(paths.sessionPath, decodeSession(input.sessionB64));
|
|
133908
|
+
await body(paths);
|
|
133909
|
+
return { sessionB64: await readSessionB64(paths.sessionPath) };
|
|
133910
|
+
} finally {
|
|
133911
|
+
await fs$1.rm(dir, {
|
|
133912
|
+
recursive: true,
|
|
133913
|
+
force: true
|
|
133914
|
+
});
|
|
133915
|
+
}
|
|
133916
|
+
}
|
|
133917
|
+
/** Replace every occurrence of each non-empty secret. Used before a log line is emitted. */
|
|
133918
|
+
function scrubSecrets(text, secrets) {
|
|
133919
|
+
let out = text;
|
|
133920
|
+
for (const secret of secrets) {
|
|
133921
|
+
if (secret.length === 0) continue;
|
|
133922
|
+
out = out.split(secret).join(REDACTED);
|
|
133923
|
+
}
|
|
133924
|
+
return out;
|
|
133444
133925
|
}
|
|
133445
|
-
|
|
133446
|
-
|
|
133447
|
-
|
|
133448
|
-
|
|
133449
|
-
|
|
133450
|
-
|
|
133451
|
-
|
|
133452
|
-
|
|
133453
|
-
|
|
133454
|
-
|
|
133455
|
-
|
|
133456
|
-
return
|
|
133457
|
-
}
|
|
133458
|
-
|
|
133459
|
-
|
|
133460
|
-
|
|
133461
|
-
|
|
133926
|
+
//#endregion
|
|
133927
|
+
//#region src/speak/broker-echo.ts
|
|
133928
|
+
var BrokerEcho = class {
|
|
133929
|
+
deps;
|
|
133930
|
+
currentState = "starting";
|
|
133931
|
+
currentError = null;
|
|
133932
|
+
currentDevices = [];
|
|
133933
|
+
constructor(deps) {
|
|
133934
|
+
this.deps = deps;
|
|
133935
|
+
}
|
|
133936
|
+
get id() {
|
|
133937
|
+
return this.deps.brokerId;
|
|
133938
|
+
}
|
|
133939
|
+
get name() {
|
|
133940
|
+
return this.deps.brokerName;
|
|
133941
|
+
}
|
|
133942
|
+
get state() {
|
|
133943
|
+
return this.currentState;
|
|
133944
|
+
}
|
|
133945
|
+
get error() {
|
|
133946
|
+
return this.currentError;
|
|
133947
|
+
}
|
|
133948
|
+
get devices() {
|
|
133949
|
+
return this.currentDevices;
|
|
133950
|
+
}
|
|
133951
|
+
stop() {
|
|
133952
|
+
this.deps.worker.stop();
|
|
133953
|
+
}
|
|
133954
|
+
/** Sign the worker in as the broker's account. Never throws; the outcome is `state`. */
|
|
133955
|
+
async signIn() {
|
|
133956
|
+
const { brokerId, worker, sessions, logger } = this.deps;
|
|
133957
|
+
const tags = { tags: { brokerId } };
|
|
133958
|
+
let secrets = [];
|
|
133959
|
+
try {
|
|
133960
|
+
const seed = await this.deps.readSeed();
|
|
133961
|
+
const stored = await sessions.get(brokerId);
|
|
133962
|
+
if (seed === null && stored.length === 0) {
|
|
133963
|
+
this.currentState = "broker-not-signed-in";
|
|
133964
|
+
this.currentError = null;
|
|
133965
|
+
logger.warn("alexa speak: the broker has no sign-in to share yet", tags);
|
|
133966
|
+
return;
|
|
133967
|
+
}
|
|
133968
|
+
secrets = seed === null ? [] : [seed.refreshToken];
|
|
133969
|
+
worker.noteSecrets(secrets);
|
|
133970
|
+
let outcome = parseLoginResult({
|
|
133971
|
+
status: "failed",
|
|
133972
|
+
error: "worker gave no answer"
|
|
133973
|
+
});
|
|
133974
|
+
const held = await withSpeakSecretFiles({
|
|
133975
|
+
refreshToken: seed?.refreshToken ?? "",
|
|
133976
|
+
sessionB64: stored
|
|
133977
|
+
}, async (paths) => {
|
|
133978
|
+
outcome = parseLoginResult(await worker.request({
|
|
133979
|
+
op: "login_broker",
|
|
133980
|
+
label: brokerId,
|
|
133981
|
+
refreshTokenPath: seed === null ? "" : paths.refreshTokenPath,
|
|
133982
|
+
deviceAppName: seed?.deviceAppName ?? "",
|
|
133983
|
+
domain: this.deps.domain,
|
|
133984
|
+
sessionPath: paths.sessionPath,
|
|
133985
|
+
region: regionOfDomain(this.deps.domain)
|
|
133986
|
+
}));
|
|
133987
|
+
});
|
|
133988
|
+
if (held.sessionB64.length > 0 && held.sessionB64 !== stored) await sessions.put(brokerId, held.sessionB64);
|
|
133989
|
+
this.currentError = outcome.status === "ok" ? null : outcome.error ?? "sign-in failed";
|
|
133990
|
+
this.currentState = outcome.status === "ok" ? "ready" : "failed";
|
|
133991
|
+
if (outcome.status !== "ok") {
|
|
133992
|
+
logger.warn("alexa speak: sign-in as the broker failed", {
|
|
133993
|
+
...tags,
|
|
133994
|
+
meta: { error: this.currentError }
|
|
133995
|
+
});
|
|
133996
|
+
return;
|
|
133997
|
+
}
|
|
133998
|
+
await this.refreshDevices();
|
|
133999
|
+
} catch (err) {
|
|
134000
|
+
this.currentState = "failed";
|
|
134001
|
+
this.currentError = scrubSecrets(err instanceof Error ? err.message : String(err), secrets);
|
|
134002
|
+
logger.warn("alexa speak: sign-in as the broker failed", {
|
|
134003
|
+
...tags,
|
|
134004
|
+
meta: { error: this.currentError }
|
|
134005
|
+
});
|
|
134006
|
+
}
|
|
134007
|
+
}
|
|
134008
|
+
/** A session that is not ready tries to sign in again before it gives up. */
|
|
134009
|
+
async ensureReady() {
|
|
134010
|
+
if (this.currentState !== "ready") await this.signIn();
|
|
134011
|
+
return this.currentState === "ready";
|
|
134012
|
+
}
|
|
134013
|
+
async refreshDevices() {
|
|
134014
|
+
if (this.currentState !== "ready") return;
|
|
134015
|
+
try {
|
|
134016
|
+
this.currentDevices = [...parseDevices(await this.deps.worker.request({ op: "devices" }))];
|
|
134017
|
+
} catch (err) {
|
|
134018
|
+
this.deps.logger.warn("alexa speak: device refresh failed", {
|
|
134019
|
+
tags: { brokerId: this.deps.brokerId },
|
|
134020
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
134021
|
+
});
|
|
134022
|
+
}
|
|
134023
|
+
}
|
|
134024
|
+
async utter(op, targets, text, volume) {
|
|
134025
|
+
if (!await this.ensureReady()) throw new Error(this.notReadyReason());
|
|
134026
|
+
return parseDeliveries(await this.deps.worker.request({
|
|
134027
|
+
op,
|
|
134028
|
+
targets,
|
|
134029
|
+
text,
|
|
134030
|
+
volume
|
|
134031
|
+
}));
|
|
134032
|
+
}
|
|
134033
|
+
async session() {
|
|
134034
|
+
if (this.deps.worker.failed) return {
|
|
134035
|
+
loggedIn: false,
|
|
134036
|
+
ageSec: 0,
|
|
134037
|
+
lastSuccessAt: null
|
|
134038
|
+
};
|
|
134039
|
+
try {
|
|
134040
|
+
return parseSessionStatus(await this.deps.worker.request({ op: "session_status" }));
|
|
134041
|
+
} catch {
|
|
134042
|
+
return {
|
|
134043
|
+
loggedIn: this.currentState === "ready",
|
|
134044
|
+
ageSec: 0,
|
|
134045
|
+
lastSuccessAt: null
|
|
134046
|
+
};
|
|
134047
|
+
}
|
|
134048
|
+
}
|
|
134049
|
+
notReadyReason() {
|
|
134050
|
+
if (this.currentState === "broker-not-signed-in") return `account '${this.deps.brokerName}' is not signed in on its broker`;
|
|
134051
|
+
return this.currentError ?? "alexa session is not ready";
|
|
133462
134052
|
}
|
|
133463
134053
|
};
|
|
133464
134054
|
//#endregion
|
|
134055
|
+
//#region src/speak/migration.ts
|
|
134056
|
+
function legacyLoginOf(settings) {
|
|
134057
|
+
return {
|
|
134058
|
+
email: settings.email,
|
|
134059
|
+
password: settings.password,
|
|
134060
|
+
session: settings.session
|
|
134061
|
+
};
|
|
134062
|
+
}
|
|
134063
|
+
function planLegacyMigration(legacy, brokerIds, targets) {
|
|
134064
|
+
const present = legacy.email.length > 0 || legacy.password.length > 0 || legacy.session.length > 0;
|
|
134065
|
+
const only = brokerIds.length === 1 ? brokerIds[0] ?? null : null;
|
|
134066
|
+
if (!present || only === null) return {
|
|
134067
|
+
present,
|
|
134068
|
+
attachTo: null,
|
|
134069
|
+
targets
|
|
134070
|
+
};
|
|
134071
|
+
return {
|
|
134072
|
+
present,
|
|
134073
|
+
attachTo: legacy.session.length > 0 ? only : null,
|
|
134074
|
+
targets: targets.map((row) => row.brokerId.length > 0 ? row : {
|
|
134075
|
+
...row,
|
|
134076
|
+
brokerId: only
|
|
134077
|
+
})
|
|
134078
|
+
};
|
|
134079
|
+
}
|
|
134080
|
+
/** What the page must tell the operator about targets that name no account. */
|
|
134081
|
+
function noticeFor(targets, brokerIds) {
|
|
134082
|
+
if (!targets.some((row) => row.brokerId.length === 0)) return "";
|
|
134083
|
+
if (brokerIds.length === 0) return "no-broker";
|
|
134084
|
+
return brokerIds.length > 1 ? "choose-broker" : "";
|
|
134085
|
+
}
|
|
134086
|
+
function unassignedCount(targets) {
|
|
134087
|
+
return targets.filter((row) => row.brokerId.length === 0).length;
|
|
134088
|
+
}
|
|
134089
|
+
//#endregion
|
|
133465
134090
|
//#region src/speak/rate-limit.ts
|
|
133466
134091
|
/**
|
|
133467
134092
|
* Per-Echo admission. A burst is dropped here, not queued: a queue would be
|
|
@@ -133576,19 +134201,58 @@ function readVolume(config) {
|
|
|
133576
134201
|
function respectDnd(config) {
|
|
133577
134202
|
return config["respectDnd"] !== false;
|
|
133578
134203
|
}
|
|
133579
|
-
|
|
134204
|
+
/** "Kitchen · Living room · ECHO" — the name first, then what tells two Echoes apart. */
|
|
134205
|
+
function echoOptionLabel(device) {
|
|
134206
|
+
const kind = device.kind === "group" ? "Group" : device.family;
|
|
134207
|
+
const parts = [device.name.length > 0 ? device.name : device.id];
|
|
134208
|
+
if (device.room !== void 0 && device.room.length > 0) parts.push(device.room);
|
|
134209
|
+
if (kind.length > 0) parts.push(kind);
|
|
134210
|
+
return parts.join(" · ");
|
|
134211
|
+
}
|
|
134212
|
+
function echoField(devices, showWhen) {
|
|
134213
|
+
return {
|
|
134214
|
+
type: "select",
|
|
134215
|
+
key: "echoId",
|
|
134216
|
+
label: "Echo",
|
|
134217
|
+
labelKey: "import-alexa:echoTarget.s_echo.echoId.label",
|
|
134218
|
+
description: devices.length === 0 ? "No Echo found yet. Sign the Amazon account in on Integrations, Alexa." : void 0,
|
|
134219
|
+
descriptionKey: devices.length === 0 ? "import-alexa:echoTarget.s_echo.echoId.empty" : void 0,
|
|
134220
|
+
options: devices.map((device) => ({
|
|
134221
|
+
value: device.id,
|
|
134222
|
+
label: echoOptionLabel(device)
|
|
134223
|
+
})),
|
|
134224
|
+
...showWhen === void 0 ? {} : { showWhen }
|
|
134225
|
+
};
|
|
134226
|
+
}
|
|
134227
|
+
/**
|
|
134228
|
+
* The two pickers are not `required`: a required select shows its first option
|
|
134229
|
+
* while the form still holds no value, so a save would drop the choice. Left
|
|
134230
|
+
* optional they open on "Select", and `upsertTarget` refuses an empty pick.
|
|
134231
|
+
*
|
|
134232
|
+
* The Echo picker is built from the accounts' own device lists on every call,
|
|
134233
|
+
* so a new Echo shows up without a deploy. With one account there is no account
|
|
134234
|
+
* field; with several, each account's Echoes appear when that account is chosen.
|
|
134235
|
+
*/
|
|
134236
|
+
function configSchema(brokers) {
|
|
134237
|
+
const first = brokers[0];
|
|
133580
134238
|
return { sections: [{
|
|
133581
134239
|
id: "echo",
|
|
133582
134240
|
title: "Echo",
|
|
133583
134241
|
titleKey: "import-alexa:echoTarget.s_echo.title",
|
|
133584
134242
|
fields: [
|
|
133585
|
-
{
|
|
133586
|
-
type: "
|
|
133587
|
-
key: "
|
|
133588
|
-
label: "
|
|
133589
|
-
labelKey: "import-alexa:echoTarget.s_echo.
|
|
133590
|
-
|
|
133591
|
-
|
|
134243
|
+
...brokers.length > 1 ? [{
|
|
134244
|
+
type: "select",
|
|
134245
|
+
key: "brokerId",
|
|
134246
|
+
label: "Amazon account",
|
|
134247
|
+
labelKey: "import-alexa:echoTarget.s_echo.brokerId.label",
|
|
134248
|
+
options: brokers.map((broker) => ({
|
|
134249
|
+
value: broker.id,
|
|
134250
|
+
label: broker.name
|
|
134251
|
+
}))
|
|
134252
|
+
}, ...brokers.map((broker) => echoField(broker.devices, {
|
|
134253
|
+
field: "brokerId",
|
|
134254
|
+
equals: broker.id
|
|
134255
|
+
}))] : [echoField(first?.devices ?? [])],
|
|
133592
134256
|
{
|
|
133593
134257
|
type: "select",
|
|
133594
134258
|
key: "mode",
|
|
@@ -133622,15 +134286,18 @@ function configSchema() {
|
|
|
133622
134286
|
]
|
|
133623
134287
|
}] };
|
|
133624
134288
|
}
|
|
133625
|
-
|
|
134289
|
+
/** The Alexa target kind as the Add target form sees it, for the accounts as they are now. */
|
|
134290
|
+
function alexaTargetKind(brokers) {
|
|
133626
134291
|
return {
|
|
133627
134292
|
kind: ALEXA_SPEAK_KIND,
|
|
133628
134293
|
label: "Alexa",
|
|
133629
134294
|
icon: "megaphone",
|
|
134295
|
+
iconUrl: ALEXA_SPEAK_ICON_URL,
|
|
134296
|
+
iconMediaType: "image/svg+xml",
|
|
133630
134297
|
addonId: ALEXA_SPEAK_ADDON_ID,
|
|
133631
134298
|
supportsDiscovery: true,
|
|
133632
|
-
destinationKeys: ["echoId"],
|
|
133633
|
-
configSchema: configSchema(),
|
|
134299
|
+
destinationKeys: ["brokerId", "echoId"],
|
|
134300
|
+
configSchema: configSchema(brokers),
|
|
133634
134301
|
caps: {
|
|
133635
134302
|
attachments: {
|
|
133636
134303
|
mediaTypes: [],
|
|
@@ -133652,11 +134319,12 @@ function kindDescriptor() {
|
|
|
133652
134319
|
}
|
|
133653
134320
|
};
|
|
133654
134321
|
}
|
|
133655
|
-
function discoveredFrom(device) {
|
|
134322
|
+
function discoveredFrom(broker, device) {
|
|
133656
134323
|
return {
|
|
133657
134324
|
kind: ALEXA_SPEAK_KIND,
|
|
133658
134325
|
suggestedName: device.name.length > 0 ? device.name : device.id,
|
|
133659
134326
|
config: {
|
|
134327
|
+
brokerId: broker.id,
|
|
133660
134328
|
echoId: device.id,
|
|
133661
134329
|
mode: "speak",
|
|
133662
134330
|
volume: 40,
|
|
@@ -133674,8 +134342,18 @@ function createAlexaNotificationProvider(ports) {
|
|
|
133674
134342
|
function record(row) {
|
|
133675
134343
|
deliveries = [...deliveries, row];
|
|
133676
134344
|
}
|
|
133677
|
-
|
|
133678
|
-
|
|
134345
|
+
/**
|
|
134346
|
+
* The account a target speaks through: the one it names; else the only one
|
|
134347
|
+
* there is; else the one whose Echoes include the target's Echo.
|
|
134348
|
+
*/
|
|
134349
|
+
function brokerFor(config) {
|
|
134350
|
+
const all = ports.brokers();
|
|
134351
|
+
const named = readString(config, "brokerId");
|
|
134352
|
+
if (named.length > 0) return all.find((broker) => broker.id === named);
|
|
134353
|
+
if (all.length === 1) return all[0];
|
|
134354
|
+
const echoId = readString(config, "echoId");
|
|
134355
|
+
const owners = all.filter((broker) => broker.devices.some((device) => device.id === echoId));
|
|
134356
|
+
return owners.length === 1 ? owners[0] : void 0;
|
|
133679
134357
|
}
|
|
133680
134358
|
function persist(next) {
|
|
133681
134359
|
targets = next;
|
|
@@ -133721,7 +134399,29 @@ function createAlexaNotificationProvider(ports) {
|
|
|
133721
134399
|
renderedAs: speech.renderedAs
|
|
133722
134400
|
};
|
|
133723
134401
|
}
|
|
133724
|
-
const
|
|
134402
|
+
const broker = brokerFor(target.config);
|
|
134403
|
+
if (broker === void 0) {
|
|
134404
|
+
const error = "target has no Amazon account";
|
|
134405
|
+
record({
|
|
134406
|
+
targetId: target.id,
|
|
134407
|
+
outcome: "failed",
|
|
134408
|
+
at: ports.now(),
|
|
134409
|
+
error
|
|
134410
|
+
});
|
|
134411
|
+
logger.warn("alexa speak dropped: target names no Amazon account", {
|
|
134412
|
+
...tags,
|
|
134413
|
+
meta: {
|
|
134414
|
+
targetId: target.id,
|
|
134415
|
+
echoId
|
|
134416
|
+
}
|
|
134417
|
+
});
|
|
134418
|
+
return {
|
|
134419
|
+
success: false,
|
|
134420
|
+
error,
|
|
134421
|
+
renderedAs: speech.renderedAs
|
|
134422
|
+
};
|
|
134423
|
+
}
|
|
134424
|
+
const device = broker.devices.find((row) => row.id === echoId);
|
|
133725
134425
|
if (device !== void 0 && respectDnd(target.config) && device.dnd) {
|
|
133726
134426
|
record({
|
|
133727
134427
|
targetId: target.id,
|
|
@@ -133783,6 +134483,7 @@ function createAlexaNotificationProvider(ports) {
|
|
|
133783
134483
|
const mode = readMode(target.config);
|
|
133784
134484
|
try {
|
|
133785
134485
|
const outcomes = await ports.speak({
|
|
134486
|
+
brokerId: broker.id,
|
|
133786
134487
|
mode,
|
|
133787
134488
|
targets: [echoId],
|
|
133788
134489
|
text: speech.text,
|
|
@@ -133857,7 +134558,7 @@ function createAlexaNotificationProvider(ports) {
|
|
|
133857
134558
|
return deliveries;
|
|
133858
134559
|
},
|
|
133859
134560
|
async listTargetKinds() {
|
|
133860
|
-
return [
|
|
134561
|
+
return [alexaTargetKind(ports.brokers())];
|
|
133861
134562
|
},
|
|
133862
134563
|
async listTargets() {
|
|
133863
134564
|
return [...targets];
|
|
@@ -133868,16 +134569,21 @@ function createAlexaNotificationProvider(ports) {
|
|
|
133868
134569
|
return found;
|
|
133869
134570
|
},
|
|
133870
134571
|
async discoverTargets() {
|
|
133871
|
-
|
|
134572
|
+
const taken = new Set(targets.flatMap((row) => {
|
|
134573
|
+
const owner = brokerFor(row.config);
|
|
134574
|
+
return owner === void 0 ? [] : [`${owner.id}/${readString(row.config, "echoId")}`];
|
|
134575
|
+
}));
|
|
134576
|
+
return ports.brokers().flatMap((broker) => broker.devices.filter((device) => !taken.has(`${broker.id}/${device.id}`)).map((device) => discoveredFrom(broker, device)));
|
|
133872
134577
|
},
|
|
133873
|
-
async probeTargetConfig() {
|
|
133874
|
-
|
|
134578
|
+
async probeTargetConfig(input) {
|
|
134579
|
+
const broker = brokerFor(input.config);
|
|
134580
|
+
return broker !== void 0 && broker.ready ? {
|
|
133875
134581
|
ok: true,
|
|
133876
134582
|
resolvedUrl: "alexa://session"
|
|
133877
134583
|
} : {
|
|
133878
134584
|
ok: false,
|
|
133879
134585
|
resolvedUrl: "alexa://session",
|
|
133880
|
-
error: "not signed in"
|
|
134586
|
+
error: "the Amazon account is not signed in on its broker"
|
|
133881
134587
|
};
|
|
133882
134588
|
},
|
|
133883
134589
|
async send(input) {
|
|
@@ -133910,8 +134616,16 @@ function createAlexaNotificationProvider(ports) {
|
|
|
133910
134616
|
});
|
|
133911
134617
|
},
|
|
133912
134618
|
async upsertTarget(input) {
|
|
134619
|
+
const owner = brokerFor(input.target.config);
|
|
134620
|
+
if (readString(input.target.config, "echoId").length === 0) throw new Error("Pick the Echo that should speak");
|
|
134621
|
+
if (owner === void 0) throw new Error("Pick the Amazon account this Echo belongs to");
|
|
134622
|
+
const pinned = readString(input.target.config, "brokerId").length === 0 ? {
|
|
134623
|
+
...input.target.config,
|
|
134624
|
+
brokerId: owner.id
|
|
134625
|
+
} : input.target.config;
|
|
133913
134626
|
const target = {
|
|
133914
134627
|
...input.target,
|
|
134628
|
+
config: pinned,
|
|
133915
134629
|
addonId: ALEXA_SPEAK_ADDON_ID,
|
|
133916
134630
|
kind: ALEXA_SPEAK_KIND
|
|
133917
134631
|
};
|
|
@@ -133942,186 +134656,10 @@ function resolveAlexaWorkerScript(fromModuleUrl = import.meta.url) {
|
|
|
133942
134656
|
for (const candidate of candidates) if (existsSync(candidate)) return candidate;
|
|
133943
134657
|
throw new Error(`import-alexa: alexa_worker.py not found. Searched:\n${candidates.join("\n")}`);
|
|
133944
134658
|
}
|
|
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
134659
|
//#endregion
|
|
134122
134660
|
//#region src/speak/worker-client.ts
|
|
134123
134661
|
/**
|
|
134124
|
-
* One Python process for the life of the addon. Requests are one JSON line
|
|
134662
|
+
* One Python process per broker, for the life of the addon. Requests are one JSON line
|
|
134125
134663
|
* on stdin; responses are one JSON line on stdout, matched by id.
|
|
134126
134664
|
* A crash restarts the process until the same bound CrashSupervisor uses,
|
|
134127
134665
|
* then requests fail instead of looping.
|
|
@@ -134349,10 +134887,6 @@ function startAlexaWorker(deps) {
|
|
|
134349
134887
|
}
|
|
134350
134888
|
//#endregion
|
|
134351
134889
|
//#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
134890
|
function targetsFrom(settings) {
|
|
134357
134891
|
return settings.targets.map((row) => ({
|
|
134358
134892
|
id: row.id,
|
|
@@ -134361,6 +134895,7 @@ function targetsFrom(settings) {
|
|
|
134361
134895
|
addonId: ALEXA_SPEAK_ADDON_ID,
|
|
134362
134896
|
enabled: row.enabled,
|
|
134363
134897
|
config: {
|
|
134898
|
+
...row.brokerId.length > 0 ? { brokerId: row.brokerId } : {},
|
|
134364
134899
|
echoId: row.echoId,
|
|
134365
134900
|
mode: row.mode,
|
|
134366
134901
|
volume: row.volume,
|
|
@@ -134368,104 +134903,85 @@ function targetsFrom(settings) {
|
|
|
134368
134903
|
}
|
|
134369
134904
|
}));
|
|
134370
134905
|
}
|
|
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;
|
|
134906
|
+
function rowsFrom(targets) {
|
|
134907
|
+
return targets.map((target) => ({
|
|
134908
|
+
id: target.id,
|
|
134909
|
+
name: target.name,
|
|
134910
|
+
enabled: target.enabled,
|
|
134911
|
+
brokerId: typeof target.config["brokerId"] === "string" ? target.config["brokerId"] : "",
|
|
134912
|
+
echoId: typeof target.config["echoId"] === "string" ? target.config["echoId"] : "",
|
|
134913
|
+
mode: target.config["mode"] === "announce" ? "announce" : "speak",
|
|
134914
|
+
volume: typeof target.config["volume"] === "number" ? target.config["volume"] : 40,
|
|
134915
|
+
respectDnd: target.config["respectDnd"] !== false
|
|
134916
|
+
}));
|
|
134388
134917
|
}
|
|
134389
134918
|
var AlexaSpeakController = class {
|
|
134390
134919
|
host;
|
|
134391
134920
|
provider;
|
|
134392
134921
|
settings;
|
|
134393
|
-
|
|
134394
|
-
|
|
134395
|
-
phase = "sign-in";
|
|
134922
|
+
pythonPath = null;
|
|
134923
|
+
echoes = /* @__PURE__ */ new Map();
|
|
134396
134924
|
timer = null;
|
|
134397
|
-
|
|
134925
|
+
queue = Promise.resolve();
|
|
134398
134926
|
now;
|
|
134399
134927
|
constructor(host) {
|
|
134400
134928
|
this.host = host;
|
|
134401
134929
|
this.now = host.now ?? (() => Date.now());
|
|
134402
134930
|
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
134931
|
this.provider = createAlexaNotificationProvider({
|
|
134410
|
-
|
|
134411
|
-
loggedIn: () => this.phase === "devices",
|
|
134932
|
+
brokers: () => this.brokerViews(),
|
|
134412
134933
|
now: this.now,
|
|
134413
134934
|
logger: host.logger,
|
|
134414
134935
|
loadTargets: () => targetsFrom(this.settings),
|
|
134415
134936
|
saveTargets: (targets) => {
|
|
134416
|
-
this.settings =
|
|
134937
|
+
this.settings = {
|
|
134938
|
+
...this.settings,
|
|
134939
|
+
targets: [...rowsFrom(targets)]
|
|
134940
|
+
};
|
|
134417
134941
|
this.persist();
|
|
134418
134942
|
},
|
|
134419
|
-
speak: (command) => this.utter(command.mode, command.targets, command.text, command.volume)
|
|
134943
|
+
speak: (command) => this.utter(command.brokerId, command.mode, command.targets, command.text, command.volume)
|
|
134420
134944
|
});
|
|
134421
134945
|
}
|
|
134422
134946
|
async start() {
|
|
134423
|
-
|
|
134424
|
-
if (pythonPath === null) {
|
|
134947
|
+
this.pythonPath = await this.host.ensurePython();
|
|
134948
|
+
if (this.pythonPath === null) {
|
|
134425
134949
|
this.host.logger.warn("alexa speak: embedded Python unavailable; Echo speech will not run");
|
|
134426
134950
|
return;
|
|
134427
134951
|
}
|
|
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
|
-
});
|
|
134952
|
+
await this.migrateLegacyLogin();
|
|
134953
|
+
await this.brokersChanged();
|
|
134438
134954
|
this.timer = setInterval(() => {
|
|
134439
|
-
this.
|
|
134955
|
+
this.refreshAll();
|
|
134440
134956
|
}, DEVICE_REFRESH_MS);
|
|
134441
134957
|
}
|
|
134442
134958
|
stop() {
|
|
134443
134959
|
if (this.timer !== null) clearInterval(this.timer);
|
|
134444
134960
|
this.timer = null;
|
|
134445
|
-
this.
|
|
134446
|
-
this.
|
|
134961
|
+
for (const echo of this.echoes.values()) echo.stop();
|
|
134962
|
+
this.echoes.clear();
|
|
134963
|
+
}
|
|
134964
|
+
/** The addon calls this whenever its list of brokers may have changed. */
|
|
134965
|
+
brokersChanged() {
|
|
134966
|
+
this.queue = this.queue.then(() => this.reconcile()).catch(() => void 0);
|
|
134967
|
+
return this.queue;
|
|
134447
134968
|
}
|
|
134448
134969
|
handlers() {
|
|
134449
134970
|
return {
|
|
134450
134971
|
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
134972
|
sendTest: async (input) => this.sendTest(input)
|
|
134464
134973
|
};
|
|
134465
134974
|
}
|
|
134466
|
-
|
|
134467
|
-
|
|
134468
|
-
|
|
134975
|
+
brokerViews() {
|
|
134976
|
+
return this.host.listBrokers().map((ref) => {
|
|
134977
|
+
const echo = this.echoes.get(ref.id);
|
|
134978
|
+
return {
|
|
134979
|
+
id: ref.id,
|
|
134980
|
+
name: ref.name,
|
|
134981
|
+
ready: echo?.state === "ready",
|
|
134982
|
+
devices: echo?.devices ?? []
|
|
134983
|
+
};
|
|
134984
|
+
});
|
|
134469
134985
|
}
|
|
134470
134986
|
async persist() {
|
|
134471
134987
|
try {
|
|
@@ -134474,142 +134990,126 @@ var AlexaSpeakController = class {
|
|
|
134474
134990
|
this.host.logger.warn("alexa speak: could not store settings", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
134475
134991
|
}
|
|
134476
134992
|
}
|
|
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({
|
|
134993
|
+
/**
|
|
134994
|
+
* Drain the old standalone login once. With exactly one broker its jar and
|
|
134995
|
+
* its targets move onto that broker; otherwise the jar is dropped and the
|
|
134996
|
+
* targets wait for the operator to choose (the status names that state).
|
|
134997
|
+
*/
|
|
134998
|
+
async migrateLegacyLogin() {
|
|
134999
|
+
const brokers = this.host.listBrokers();
|
|
135000
|
+
const plan = planLegacyMigration(legacyLoginOf(this.settings), brokers.map((ref) => ref.id), this.settings.targets);
|
|
135001
|
+
if (!plan.present) return;
|
|
135002
|
+
if (plan.attachTo !== null) await this.host.sessions.put(plan.attachTo, this.settings.session);
|
|
135003
|
+
this.settings = {
|
|
135004
|
+
...this.settings,
|
|
135005
|
+
email: "",
|
|
134508
135006
|
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;
|
|
135007
|
+
session: "",
|
|
135008
|
+
targets: [...plan.targets]
|
|
135009
|
+
};
|
|
135010
|
+
await this.persist();
|
|
135011
|
+
await this.host.clearLegacySecrets();
|
|
135012
|
+
this.host.logger.info("alexa speak: the old standalone login was retired", { meta: {
|
|
135013
|
+
attachedTo: plan.attachTo ?? "none",
|
|
135014
|
+
brokers: brokers.length,
|
|
135015
|
+
targets: plan.targets.length
|
|
135016
|
+
} });
|
|
134525
135017
|
}
|
|
134526
|
-
|
|
134527
|
-
return this.
|
|
134528
|
-
|
|
134529
|
-
|
|
134530
|
-
|
|
134531
|
-
|
|
134532
|
-
|
|
135018
|
+
makeWorker(brokerId, pythonPath) {
|
|
135019
|
+
if (this.host.startWorker !== void 0) return this.host.startWorker(brokerId, pythonPath);
|
|
135020
|
+
return startAlexaWorker({
|
|
135021
|
+
pythonPath,
|
|
135022
|
+
scriptPath: resolveAlexaWorkerScript(),
|
|
135023
|
+
logger: this.host.logger.withTags({ brokerId }),
|
|
135024
|
+
now: this.now
|
|
134533
135025
|
});
|
|
134534
135026
|
}
|
|
134535
|
-
async
|
|
134536
|
-
const
|
|
134537
|
-
|
|
134538
|
-
|
|
134539
|
-
|
|
134540
|
-
|
|
134541
|
-
|
|
134542
|
-
|
|
134543
|
-
|
|
134544
|
-
await this.
|
|
134545
|
-
|
|
135027
|
+
async reconcile() {
|
|
135028
|
+
const pythonPath = this.pythonPath;
|
|
135029
|
+
if (pythonPath === null) return;
|
|
135030
|
+
const refs = this.host.listBrokers();
|
|
135031
|
+
const live = new Set(refs.map((ref) => ref.id));
|
|
135032
|
+
for (const [id, echo] of this.echoes) {
|
|
135033
|
+
if (live.has(id)) continue;
|
|
135034
|
+
echo.stop();
|
|
135035
|
+
this.echoes.delete(id);
|
|
135036
|
+
await this.host.sessions.delete(id).catch((err) => {
|
|
135037
|
+
this.host.logger.warn("alexa speak: could not drop the session of a removed account", {
|
|
135038
|
+
tags: { brokerId: id },
|
|
135039
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
135040
|
+
});
|
|
135041
|
+
});
|
|
134546
135042
|
}
|
|
134547
|
-
|
|
135043
|
+
const fresh = refs.filter((ref) => !this.echoes.has(ref.id));
|
|
135044
|
+
for (const ref of fresh) this.echoes.set(ref.id, new BrokerEcho({
|
|
135045
|
+
brokerId: ref.id,
|
|
135046
|
+
brokerName: ref.name,
|
|
135047
|
+
domain: ref.amazonDomain,
|
|
135048
|
+
worker: this.makeWorker(ref.id, pythonPath),
|
|
135049
|
+
readSeed: () => this.host.readSeed(ref.id),
|
|
135050
|
+
sessions: this.host.sessions,
|
|
135051
|
+
logger: this.host.logger
|
|
135052
|
+
}));
|
|
135053
|
+
await Promise.all(refs.map(async (ref) => {
|
|
135054
|
+
const echo = this.echoes.get(ref.id);
|
|
135055
|
+
if (echo !== void 0 && (fresh.includes(ref) || echo.state !== "ready")) await echo.signIn();
|
|
135056
|
+
}));
|
|
134548
135057
|
}
|
|
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
|
-
}
|
|
135058
|
+
async refreshAll() {
|
|
135059
|
+
for (const echo of this.echoes.values()) if (echo.state === "ready") await echo.refreshDevices();
|
|
135060
|
+
else await echo.signIn();
|
|
134556
135061
|
}
|
|
134557
|
-
async utter(mode, targets, text, volume) {
|
|
134558
|
-
const
|
|
134559
|
-
|
|
134560
|
-
|
|
134561
|
-
targets,
|
|
134562
|
-
text,
|
|
134563
|
-
volume
|
|
134564
|
-
}));
|
|
135062
|
+
async utter(brokerId, mode, targets, text, volume) {
|
|
135063
|
+
const echo = this.echoes.get(brokerId);
|
|
135064
|
+
if (echo === void 0) throw new Error("no Echo session for this Amazon account");
|
|
135065
|
+
return echo.utter(mode === "announce" ? "announce" : "speak", targets, text, volume);
|
|
134565
135066
|
}
|
|
134566
|
-
async
|
|
134567
|
-
|
|
134568
|
-
|
|
134569
|
-
|
|
134570
|
-
lastSuccessAt: null
|
|
134571
|
-
};
|
|
134572
|
-
try {
|
|
134573
|
-
return parseSessionStatus(await this.workerRequest({ op: "session_status" }));
|
|
134574
|
-
} catch {
|
|
135067
|
+
async status() {
|
|
135068
|
+
const brokers = this.host.listBrokers();
|
|
135069
|
+
const rows = await Promise.all(brokers.map(async (ref) => {
|
|
135070
|
+
const echo = this.echoes.get(ref.id);
|
|
134575
135071
|
return {
|
|
134576
|
-
|
|
134577
|
-
|
|
134578
|
-
|
|
135072
|
+
id: ref.id,
|
|
135073
|
+
name: ref.name,
|
|
135074
|
+
state: echo?.state ?? "failed",
|
|
135075
|
+
...echo === void 0 ? { error: "embedded Python unavailable" } : echo.error === null ? {} : { error: echo.error },
|
|
135076
|
+
devices: [...echo?.devices ?? []],
|
|
135077
|
+
session: echo === void 0 ? {
|
|
135078
|
+
loggedIn: false,
|
|
135079
|
+
ageSec: 0,
|
|
135080
|
+
lastSuccessAt: null
|
|
135081
|
+
} : await echo.session()
|
|
134579
135082
|
};
|
|
134580
|
-
}
|
|
134581
|
-
|
|
134582
|
-
|
|
135083
|
+
}));
|
|
135084
|
+
const targets = this.settings.targets;
|
|
135085
|
+
const ids = brokers.map((ref) => ref.id);
|
|
134583
135086
|
return {
|
|
134584
|
-
|
|
134585
|
-
|
|
134586
|
-
|
|
134587
|
-
devices: [...this.devices],
|
|
134588
|
-
session: await this.session()
|
|
135087
|
+
brokers: rows,
|
|
135088
|
+
unassignedTargets: unassignedCount(targets),
|
|
135089
|
+
notice: noticeFor(targets, ids)
|
|
134589
135090
|
};
|
|
134590
135091
|
}
|
|
134591
135092
|
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 = [...
|
|
135093
|
+
const outcomes = await this.utter(input.brokerId, input.mode, input.echoIds, input.text, input.volume);
|
|
135094
|
+
const known = new Map((this.echoes.get(input.brokerId)?.devices ?? []).map((device) => [device.id, device]));
|
|
135095
|
+
const next = [...this.settings.targets];
|
|
134595
135096
|
for (const echoId of input.echoIds) {
|
|
134596
|
-
if (next.some((row) => row.
|
|
134597
|
-
const device = known.get(echoId);
|
|
135097
|
+
if (next.some((row) => row.echoId === echoId && row.brokerId === input.brokerId)) continue;
|
|
134598
135098
|
next.push({
|
|
134599
|
-
id: `echo-${echoId}`,
|
|
134600
|
-
name:
|
|
134601
|
-
kind: ALEXA_SPEAK_KIND,
|
|
134602
|
-
addonId: ALEXA_SPEAK_ADDON_ID,
|
|
135099
|
+
id: `echo-${input.brokerId}-${echoId}`,
|
|
135100
|
+
name: known.get(echoId)?.name ?? echoId,
|
|
134603
135101
|
enabled: true,
|
|
134604
|
-
|
|
134605
|
-
|
|
134606
|
-
|
|
134607
|
-
|
|
134608
|
-
|
|
134609
|
-
}
|
|
135102
|
+
brokerId: input.brokerId,
|
|
135103
|
+
echoId,
|
|
135104
|
+
mode: input.mode,
|
|
135105
|
+
volume: input.volume,
|
|
135106
|
+
respectDnd: true
|
|
134610
135107
|
});
|
|
134611
135108
|
}
|
|
134612
|
-
this.settings =
|
|
135109
|
+
this.settings = {
|
|
135110
|
+
...this.settings,
|
|
135111
|
+
targets: next
|
|
135112
|
+
};
|
|
134613
135113
|
await this.persist();
|
|
134614
135114
|
return { outcomes: outcomes.map((row) => ({
|
|
134615
135115
|
echoId: row.targetId,
|
|
@@ -134619,6 +135119,55 @@ var AlexaSpeakController = class {
|
|
|
134619
135119
|
}
|
|
134620
135120
|
};
|
|
134621
135121
|
//#endregion
|
|
135122
|
+
//#region src/speak/icon-routes.ts
|
|
135123
|
+
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>";
|
|
135124
|
+
/** One day; the mark is immutable per addon version. */
|
|
135125
|
+
var CACHE_CONTROL = "public, max-age=86400, immutable";
|
|
135126
|
+
function speakIconRoutes() {
|
|
135127
|
+
return [{
|
|
135128
|
+
method: "GET",
|
|
135129
|
+
path: `/icons/${ALEXA_SPEAK_KIND}`,
|
|
135130
|
+
access: "public",
|
|
135131
|
+
description: "Alexa brand mark (image/svg+xml) for the Alexa notification target.",
|
|
135132
|
+
handler: async (_request, reply) => {
|
|
135133
|
+
reply.code(200).type("image/svg+xml").header("cache-control", CACHE_CONTROL).send(ALEXA_ICON_SVG);
|
|
135134
|
+
}
|
|
135135
|
+
}];
|
|
135136
|
+
}
|
|
135137
|
+
//#endregion
|
|
135138
|
+
//#region src/speak/session-store.ts
|
|
135139
|
+
/** The Echo worker's cookie jar per broker, in the records store (secret field, keychain-backed). */
|
|
135140
|
+
var RecordsSpeakSessionStore = class RecordsSpeakSessionStore {
|
|
135141
|
+
kind;
|
|
135142
|
+
constructor(kind) {
|
|
135143
|
+
this.kind = kind;
|
|
135144
|
+
}
|
|
135145
|
+
static of(records) {
|
|
135146
|
+
return new RecordsSpeakSessionStore(records.kind(alexaSpeakSessionsEntryKind));
|
|
135147
|
+
}
|
|
135148
|
+
async get(brokerId) {
|
|
135149
|
+
try {
|
|
135150
|
+
const resolved = await this.kind.getResolved(brokerId);
|
|
135151
|
+
return resolved === null ? "" : resolved.data.session;
|
|
135152
|
+
} catch (err) {
|
|
135153
|
+
if (err instanceof RecordSecretUnavailableError) return "";
|
|
135154
|
+
throw err;
|
|
135155
|
+
}
|
|
135156
|
+
}
|
|
135157
|
+
async put(brokerId, sessionB64) {
|
|
135158
|
+
await this.kind.put({
|
|
135159
|
+
id: brokerId,
|
|
135160
|
+
data: {
|
|
135161
|
+
name: brokerId,
|
|
135162
|
+
session: sessionB64
|
|
135163
|
+
}
|
|
135164
|
+
});
|
|
135165
|
+
}
|
|
135166
|
+
async delete(brokerId) {
|
|
135167
|
+
await this.kind.delete(brokerId);
|
|
135168
|
+
}
|
|
135169
|
+
};
|
|
135170
|
+
//#endregion
|
|
134622
135171
|
//#region src/addon.ts
|
|
134623
135172
|
var SPEAK_PAGE = {
|
|
134624
135173
|
id: "speak",
|
|
@@ -134692,7 +135241,10 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
|
|
|
134692
135241
|
const regs = this.providerRegistrationsFromSuper(await super.onInitialize());
|
|
134693
135242
|
const records = this.ctx.records;
|
|
134694
135243
|
if (records === void 0) throw new Error("import-alexa: ctx.records is required (hub-only addon)");
|
|
134695
|
-
await records.declare({
|
|
135244
|
+
await records.declare({
|
|
135245
|
+
brokers: alexaBrokersEntryKind,
|
|
135246
|
+
speakSessions: alexaSpeakSessionsEntryKind
|
|
135247
|
+
});
|
|
134696
135248
|
this.brokerStore = AlexaBrokerStore.of(records);
|
|
134697
135249
|
this.activeBrokers = await this.loadPersistedBrokers();
|
|
134698
135250
|
this.registry = new AlexaBrokerRegistry(this.ctx.logger, {
|
|
@@ -134733,29 +135285,33 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
|
|
|
134733
135285
|
this.ctx.logger.info("Alexa: import addon initialised", { meta: { brokerCount: this.activeBrokers.length } });
|
|
134734
135286
|
await this.reconcileIntegrationsToBrokers();
|
|
134735
135287
|
this.subscribeIntegrationLifecycle();
|
|
134736
|
-
const loginRoutes = buildAlexaLoginRoutes({
|
|
135288
|
+
const loginRoutes = [...buildAlexaLoginRoutes({
|
|
134737
135289
|
logger: this.ctx.logger,
|
|
134738
135290
|
proxyLogin: this.proxyLogin,
|
|
134739
135291
|
commitCookie: (input) => this.commitCapturedCookie(input)
|
|
134740
|
-
});
|
|
135292
|
+
}), ...speakIconRoutes()];
|
|
134741
135293
|
const speak = new AlexaSpeakController({
|
|
134742
135294
|
logger: this.ctx.logger.child("speak"),
|
|
134743
135295
|
readConfig: () => this.config,
|
|
134744
135296
|
writeSpeak: async (next) => {
|
|
134745
135297
|
await this.updateGlobalSettings({ speak: next });
|
|
134746
135298
|
},
|
|
134747
|
-
|
|
134748
|
-
|
|
134749
|
-
|
|
134750
|
-
|
|
134751
|
-
|
|
134752
|
-
|
|
134753
|
-
|
|
135299
|
+
listBrokers: () => this.activeBrokers.map((entry) => ({
|
|
135300
|
+
id: entry.id,
|
|
135301
|
+
name: entry.name,
|
|
135302
|
+
amazonDomain: entry.connection.amazonDomain
|
|
135303
|
+
})),
|
|
135304
|
+
readSeed: async (brokerId) => {
|
|
135305
|
+
const entry = this.activeBrokers.find((row) => row.id === brokerId);
|
|
135306
|
+
return entry === void 0 ? null : readBrokerSeed(this.ctx.dataDir, entry);
|
|
134754
135307
|
},
|
|
134755
|
-
|
|
134756
|
-
|
|
134757
|
-
|
|
134758
|
-
|
|
135308
|
+
sessions: RecordsSpeakSessionStore.of(records),
|
|
135309
|
+
clearLegacySecrets: async () => {
|
|
135310
|
+
await this.updateGlobalSettingsAsOperator({ speak: {
|
|
135311
|
+
...this.config.speak,
|
|
135312
|
+
password: SECRET_CLEAR,
|
|
135313
|
+
session: SECRET_CLEAR
|
|
135314
|
+
} });
|
|
134759
135315
|
},
|
|
134760
135316
|
ensurePython: () => this.ctx.deps.ensurePython()
|
|
134761
135317
|
});
|
|
@@ -134806,11 +135362,9 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
|
|
|
134806
135362
|
async persistBrokerEntries(entries) {
|
|
134807
135363
|
this.activeBrokers = [...entries];
|
|
134808
135364
|
const store = this.brokerStore;
|
|
134809
|
-
if (store !== null)
|
|
134810
|
-
|
|
134811
|
-
|
|
134812
|
-
}
|
|
134813
|
-
await this.updateGlobalSettings({ brokers: [...entries] });
|
|
135365
|
+
if (store !== null) await store.replaceAll(entries);
|
|
135366
|
+
else await this.updateGlobalSettings({ brokers: [...entries] });
|
|
135367
|
+
this.speak?.brokersChanged();
|
|
134814
135368
|
}
|
|
134815
135369
|
async onConfigChanged() {
|
|
134816
135370
|
const reg = this.registry;
|
|
@@ -134856,6 +135410,7 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
|
|
|
134856
135410
|
} });
|
|
134857
135411
|
}
|
|
134858
135412
|
}
|
|
135413
|
+
this.speak?.brokersChanged();
|
|
134859
135414
|
}
|
|
134860
135415
|
async onShutdown() {
|
|
134861
135416
|
this.speak?.stop();
|
|
@@ -135094,90 +135649,43 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
|
|
|
135094
135649
|
});
|
|
135095
135650
|
}
|
|
135096
135651
|
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
|
-
] });
|
|
135652
|
+
return this.schema({ sections: [{
|
|
135653
|
+
id: "alexa-account",
|
|
135654
|
+
title: "Amazon Alexa account",
|
|
135655
|
+
titleKey: "import-alexa:globalSettings.s_alexa-account.title",
|
|
135656
|
+
description: "Alexa accounts are managed in the External systems → Brokers tab.",
|
|
135657
|
+
descriptionKey: "import-alexa:globalSettings.s_alexa-account.description",
|
|
135658
|
+
columns: 1,
|
|
135659
|
+
fields: [{
|
|
135660
|
+
type: "info",
|
|
135661
|
+
key: "accountHelp",
|
|
135662
|
+
label: "Accounts are managed separately",
|
|
135663
|
+
labelKey: "import-alexa:globalSettings.s_alexa-account.accountHelp.label",
|
|
135664
|
+
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."
|
|
135665
|
+
}]
|
|
135666
|
+
}, {
|
|
135667
|
+
id: "alexa-proxy-login",
|
|
135668
|
+
title: "Interactive login (proxy)",
|
|
135669
|
+
titleKey: "import-alexa:globalSettings.s_alexa-proxy-login.title",
|
|
135670
|
+
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.",
|
|
135671
|
+
descriptionKey: "import-alexa:globalSettings.s_alexa-proxy-login.description",
|
|
135672
|
+
columns: 2,
|
|
135673
|
+
fields: [{
|
|
135674
|
+
type: "text",
|
|
135675
|
+
key: "proxyLogin.proxyHost",
|
|
135676
|
+
label: "Login proxy host/IP (blank = auto-detect)",
|
|
135677
|
+
labelKey: "import-alexa:globalSettings.s_alexa-proxy-login.proxyLogin_proxyHost.label",
|
|
135678
|
+
placeholder: "auto-detect"
|
|
135679
|
+
}, {
|
|
135680
|
+
type: "number",
|
|
135681
|
+
key: "proxyLogin.proxyPort",
|
|
135682
|
+
label: "Login proxy port",
|
|
135683
|
+
labelKey: "import-alexa:globalSettings.s_alexa-proxy-login.proxyLogin_proxyPort.label",
|
|
135684
|
+
min: 1,
|
|
135685
|
+
max: 65535,
|
|
135686
|
+
default: 3456
|
|
135687
|
+
}]
|
|
135688
|
+
}] });
|
|
135181
135689
|
}
|
|
135182
135690
|
async supportsManualCreation() {
|
|
135183
135691
|
return false;
|