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