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