@camstack/addon-import-alexa 0.2.149 → 0.2.151

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