@camstack/addon-import-alexa 0.2.170 → 0.2.172

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.172] - 2026-10-31
11
+
12
+ ### Changed
13
+
14
+ - Alexa shows an error instead of 'connected' when polling Amazon keeps failing, with the HTTP status in the log.
15
+ - Alexa: quiet vs announce is now decided per notification (a silent notification is spoken quietly, any other announces); the per-target Mode setting is gone and a stored one is ignored.
16
+
17
+ ## [0.2.171] - 2026-10-30
18
+
19
+ ### Changed
20
+
21
+ - Notification targets pick a broker and list their options inside the add/edit form; the page-wide Find devices dialog is gone
22
+
10
23
  ## [0.2.170] - 2026-10-30
11
24
 
12
25
  ### Changed
package/dist/addon.js CHANGED
@@ -57379,6 +57379,12 @@ var TerminalInstanceInfoSchema = object({
57379
57379
  instanceId: string(),
57380
57380
  cameraStableId: string(),
57381
57381
  nodeId: string(),
57382
+ /**
57383
+ * False while the node the instance is bound to is not in the cluster
57384
+ * topology (forgotten, renamed, re-paired). The instance is kept; the
57385
+ * operator re-points it with `updateInstance({ targetNodeId })`.
57386
+ */
57387
+ nodeInCluster: boolean(),
57382
57388
  profileId: string(),
57383
57389
  profileLabel: string(),
57384
57390
  name: string(),
@@ -57428,6 +57434,8 @@ method(_void(), array(TerminalProfileInfoSchema).readonly(), { auth: "admin" }),
57428
57434
  auth: "admin"
57429
57435
  }), method(object({
57430
57436
  instanceId: string().min(1),
57437
+ /** Re-point the instance to another node; the profile must exist there. */
57438
+ targetNodeId: string().min(1).max(256).optional(),
57431
57439
  name: string().trim().min(1).max(160).optional(),
57432
57440
  executable: string().max(1024).optional(),
57433
57441
  args: array(string().max(2048)).max(64).optional(),
@@ -137140,7 +137148,21 @@ function isRateLimitError(message) {
137140
137148
  const m = message.toLowerCase();
137141
137149
  return m.includes("rate exceeded") || m.includes("throttl") || m.includes("too many requests") || m.includes("429");
137142
137150
  }
137151
+ /** HTTP statuses that mean Amazon rejected the session (a re-link is needed). */
137152
+ var SESSION_DEAD_STATUSES = new Set([401, 403]);
137153
+ /** The library logs `No body (code=NNN)` on a body-less non-2xx; it is the only place the status shows. */
137154
+ var NO_BODY_CODE = /No body \(code=(\d{3})\)/;
137155
+ /** A failed Amazon call whose HTTP status is known (null when the library did not say). */
137156
+ var AlexaHttpError = class extends Error {
137157
+ statusCode;
137158
+ constructor(message, statusCode) {
137159
+ super(message);
137160
+ this.name = "AlexaHttpError";
137161
+ this.statusCode = statusCode;
137162
+ }
137163
+ };
137143
137164
  var AlexaRemoteClient = class {
137165
+ #lastHttpStatus = null;
137144
137166
  #options;
137145
137167
  #logger;
137146
137168
  #dataDir;
@@ -137160,7 +137182,10 @@ var AlexaRemoteClient = class {
137160
137182
  async connect() {
137161
137183
  const remote = new import_alexa_remote.default();
137162
137184
  this.#remote = remote;
137163
- const initOptions = await this.#buildInitOptions();
137185
+ const initOptions = {
137186
+ ...await this.#buildInitOptions(),
137187
+ logger: (...args) => this.#captureLibraryLine(args)
137188
+ };
137164
137189
  remote.on("cookie", () => {
137165
137190
  this.#persistSession(remote);
137166
137191
  });
@@ -137254,11 +137279,17 @@ var AlexaRemoteClient = class {
137254
137279
  async #withBackoff(op, run) {
137255
137280
  let lastError = null;
137256
137281
  for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) try {
137282
+ this.#lastHttpStatus = null;
137257
137283
  return await run();
137258
137284
  } catch (err) {
137259
137285
  lastError = err;
137260
137286
  const message = errMsg(err);
137261
137287
  if (isAuthError(message)) throw new AlexaAuthError(message);
137288
+ if (message.toLowerCase().includes("no body")) {
137289
+ const status = this.#lastHttpStatus;
137290
+ if (status !== null && SESSION_DEAD_STATUSES.has(status)) throw new AlexaAuthError(`${message} (HTTP ${status})`);
137291
+ throw new AlexaHttpError(message, status);
137292
+ }
137262
137293
  if (!isRateLimitError(message) || attempt === MAX_ATTEMPTS - 1) throw err;
137263
137294
  const delay = this.#backoffDelay(attempt);
137264
137295
  this.#logger?.warn("AlexaRemoteClient: rate-limited — backing off", { meta: {
@@ -137271,6 +137302,10 @@ var AlexaRemoteClient = class {
137271
137302
  }
137272
137303
  throw lastError instanceof Error ? lastError : /* @__PURE__ */ new Error(`AlexaRemoteClient: ${op} failed`);
137273
137304
  }
137305
+ #captureLibraryLine(args) {
137306
+ const match = NO_BODY_CODE.exec(args.map(String).join(" "));
137307
+ if (match?.[1] !== void 0) this.#lastHttpStatus = Number(match[1]);
137308
+ }
137274
137309
  #backoffDelay(attempt) {
137275
137310
  return Math.min(BACKOFF_BASE_MS * 2 ** attempt, BACKOFF_MAX_MS) + Math.floor(Math.random() * BACKOFF_BASE_MS);
137276
137311
  }
@@ -137509,8 +137544,6 @@ var AlexaCommandSink = class {
137509
137544
  };
137510
137545
  /** The single in-process command sink shared across the addon. */
137511
137546
  var alexaCommands = new AlexaCommandSink();
137512
- //#endregion
137513
- //#region src/alexa-integration-manager.ts
137514
137547
  /**
137515
137548
  * Wraps exactly one {@link AlexaClient} (= one Amazon account). Authenticates on
137516
137549
  * `start()`, lists the appliance inventory, then runs a POLL loop (Alexa has no
@@ -137538,6 +137571,7 @@ var AlexaIntegrationManager = class {
137538
137571
  #client = null;
137539
137572
  #pollTimer = null;
137540
137573
  #status = "disconnected";
137574
+ #pollFailures = 0;
137541
137575
  #applianceCount = 0;
137542
137576
  #lastCheckedAt = null;
137543
137577
  #error = null;
@@ -137642,14 +137676,37 @@ var AlexaIntegrationManager = class {
137642
137676
  alexaAccounts.recordStates(this.#id, values);
137643
137677
  this.#lastCheckedAt = Date.now();
137644
137678
  this.#onStatesPolled(this.#id, values);
137679
+ this.#pollFailures = 0;
137680
+ this.#error = null;
137681
+ if (this.#status === "error" || this.#status === "auth-failed") this.#transition("connected");
137645
137682
  } catch (err) {
137646
137683
  this.#error = errMsg(err);
137684
+ this.#pollFailures += 1;
137685
+ const httpStatus = err instanceof AlexaHttpError ? err.statusCode : null;
137647
137686
  this.#logger.warn("AlexaIntegrationManager: poll failed", {
137648
137687
  tags: { brokerId: this.#id },
137649
- meta: { error: errMsg(err) }
137688
+ meta: {
137689
+ error: errMsg(err),
137690
+ consecutiveFailures: this.#pollFailures,
137691
+ httpStatus
137692
+ }
137650
137693
  });
137694
+ if (this.#pollFailures >= 3 && this.#status !== "disconnected") this.#transition(err instanceof AlexaAuthError ? "auth-failed" : "error");
137651
137695
  }
137652
137696
  }
137697
+ #transition(next) {
137698
+ if (this.#status === next) return;
137699
+ const previous = this.#status;
137700
+ this.#status = next;
137701
+ this.#logger.info("AlexaIntegrationManager: status changed", {
137702
+ tags: { brokerId: this.#id },
137703
+ meta: {
137704
+ from: previous,
137705
+ to: next,
137706
+ error: this.#error
137707
+ }
137708
+ });
137709
+ }
137653
137710
  /** Stop the poll loop, disconnect the client (FIRST), null it. */
137654
137711
  async stop() {
137655
137712
  if (this.#pollTimer !== null) {
@@ -137868,8 +137925,17 @@ var AlexaBrokerRegistry = class {
137868
137925
  }
137869
137926
  };
137870
137927
  //#endregion
137928
+ //#region src/speak/constants.ts
137929
+ /** Echo speech limits and the worker restart bound (same window as CrashSupervisor). */
137930
+ var ALEXA_SPEAK_ADDON_ID = "import-alexa";
137931
+ var ALEXA_SPEAK_KIND = "alexa-echo";
137932
+ /** The kind of the Amazon-account broker the Echoes are read from. */
137933
+ var ALEXA_BROKER_KIND = "alexa";
137934
+ var DEVICE_REFRESH_MS = 5 * 6e4;
137935
+ /** Where the addon serves the Alexa mark; `listTargetKinds` stamps it as `iconUrl`. */
137936
+ var ALEXA_SPEAK_ICON_URL = `/addon/${ALEXA_SPEAK_ADDON_ID}/icons/${ALEXA_SPEAK_KIND}`;
137937
+ //#endregion
137871
137938
  //#region src/alexa-broker-provider.ts
137872
- var ALEXA_KIND = "alexa";
137873
137939
  /**
137874
137940
  * Construct the `broker` cap provider for the Alexa import addon. Pure builder.
137875
137941
  * Mirrors `buildEcowittBrokerProvider`. The cookie + refresh token are secrets:
@@ -137887,7 +137953,7 @@ function buildAlexaBrokerProvider(deps) {
137887
137953
  });
137888
137954
  return {
137889
137955
  list: async ({ kind }) => {
137890
- if (kind && kind !== ALEXA_KIND) return [];
137956
+ if (kind && kind !== "alexa") return [];
137891
137957
  return registry.list().map((info) => ({
137892
137958
  ...info,
137893
137959
  addonId: ownerAddonId
@@ -137903,7 +137969,7 @@ function buildAlexaBrokerProvider(deps) {
137903
137969
  listProviders: async () => [{
137904
137970
  addonId: ownerAddonId,
137905
137971
  kinds: [{
137906
- kind: ALEXA_KIND,
137972
+ kind: ALEXA_BROKER_KIND,
137907
137973
  label: "Alexa",
137908
137974
  signIn: {
137909
137975
  basePath: "/addon/import-alexa/login",
@@ -137912,7 +137978,7 @@ function buildAlexaBrokerProvider(deps) {
137912
137978
  }]
137913
137979
  }],
137914
137980
  add: async ({ kind, name, settings }) => {
137915
- if (kind !== ALEXA_KIND) throw new Error(`import-alexa: only kind '${ALEXA_KIND}' is handled here (got '${kind}')`);
137981
+ if (kind !== "alexa") throw new Error(`import-alexa: only kind '${ALEXA_BROKER_KIND}' is handled here (got '${kind}')`);
137916
137982
  const entry = await registry.createEntry(name, settingsToAlexaConnection(settings));
137917
137983
  await persistBrokers([...getBrokers(), entry]);
137918
137984
  return { id: entry.id };
@@ -137970,11 +138036,11 @@ function buildAlexaBrokerProvider(deps) {
137970
138036
  return entry ? redact(entry) : null;
137971
138037
  },
137972
138038
  getSettingsSchema: async ({ kind }) => {
137973
- if (kind !== ALEXA_KIND) return null;
138039
+ if (kind !== "alexa") return null;
137974
138040
  return buildConnectionFormSchema();
137975
138041
  },
137976
138042
  testSettings: async ({ kind, settings }) => {
137977
- if (kind !== ALEXA_KIND) return {
138043
+ if (kind !== "alexa") return {
137978
138044
  ok: false,
137979
138045
  error: `unsupported kind: ${kind}`
137980
138046
  };
@@ -139193,14 +139259,6 @@ var BrokerEcho = class {
139193
139259
  }
139194
139260
  };
139195
139261
  //#endregion
139196
- //#region src/speak/constants.ts
139197
- /** Echo speech limits and the worker restart bound (same window as CrashSupervisor). */
139198
- var ALEXA_SPEAK_ADDON_ID = "import-alexa";
139199
- var ALEXA_SPEAK_KIND = "alexa-echo";
139200
- var DEVICE_REFRESH_MS = 5 * 6e4;
139201
- /** Where the addon serves the Alexa mark; `listTargetKinds` stamps it as `iconUrl`. */
139202
- var ALEXA_SPEAK_ICON_URL = `/addon/${ALEXA_SPEAK_ADDON_ID}/icons/${ALEXA_SPEAK_KIND}`;
139203
- //#endregion
139204
139262
  //#region src/speak/rate-limit.ts
139205
139263
  /**
139206
139264
  * Per-Echo admission. A burst is dropped here, not queued: a queue would be
@@ -139304,9 +139362,16 @@ function readString(config, key) {
139304
139362
  const value = config[key];
139305
139363
  return typeof value === "string" ? value : "";
139306
139364
  }
139307
- /** The rule's value for this send wins; the target's saved config is the fallback. */
139308
- function readMode(config, delivery) {
139309
- return (delivery?.["mode"] ?? config["mode"]) === "announce" ? "announce" : "speak";
139365
+ /** The level id an Echo notification uses for "quiet: no chime, no announcement". */
139366
+ var SILENT_LEVEL_ID = "silent";
139367
+ var SILENT_PRIORITY = 1;
139368
+ /**
139369
+ * Quiet or announce is decided PER NOTIFICATION, never per target: a silent
139370
+ * notification (the kind's silent level, or the lowest priority when no level
139371
+ * is named) is spoken quietly, hours-aware; every other one announces.
139372
+ */
139373
+ function modeFor(notification) {
139374
+ return (notification.level !== void 0 ? notification.level === SILENT_LEVEL_ID : notification.priority <= SILENT_PRIORITY) ? "speak" : "announce";
139310
139375
  }
139311
139376
  function readVolume(config, delivery) {
139312
139377
  const ruled = delivery?.["volume"];
@@ -139317,21 +139382,9 @@ function readVolume(config, delivery) {
139317
139382
  /**
139318
139383
  * The delivery parameters a rule may set for an Echo target (D938). The
139319
139384
  * target's own config holds the default; these override it per notification.
139385
+ * Quiet vs announce is not one of them: it follows the notification's level.
139320
139386
  */
139321
139387
  var DELIVERY_PARAMS = [{
139322
- key: "mode",
139323
- label: "Mode",
139324
- labelKey: "import-alexa:echoTarget.s_echo.mode.label",
139325
- type: "enum",
139326
- options: [{
139327
- value: "speak",
139328
- label: "Speak (quiet hours apply)"
139329
- }, {
139330
- value: "announce",
139331
- label: "Announce (chime, then speech)"
139332
- }],
139333
- default: "speak"
139334
- }, {
139335
139388
  key: "volume",
139336
139389
  label: "Volume",
139337
139390
  labelKey: "import-alexa:echoTarget.s_echo.volume.label",
@@ -139351,64 +139404,37 @@ function echoOptionLabel(device) {
139351
139404
  if (kind.length > 0) parts.push(kind);
139352
139405
  return parts.join(" · ");
139353
139406
  }
139354
- function echoField(devices, showWhen) {
139355
- return {
139356
- type: "select",
139357
- key: "echoId",
139358
- label: "Echo",
139359
- labelKey: "import-alexa:echoTarget.s_echo.echoId.label",
139360
- description: devices.length === 0 ? "No Echo found yet. Sign the Amazon account in on Integrations, Alexa." : void 0,
139361
- descriptionKey: devices.length === 0 ? "import-alexa:echoTarget.s_echo.echoId.empty" : void 0,
139362
- options: devices.map((device) => ({
139363
- value: device.id,
139364
- label: echoOptionLabel(device)
139365
- })),
139366
- ...showWhen === void 0 ? {} : { showWhen }
139367
- };
139368
- }
139369
139407
  /**
139370
139408
  * The two pickers are not `required`: a required select shows its first option
139371
139409
  * while the form still holds no value, so a save would drop the choice. Left
139372
139410
  * optional they open on "Select", and `upsertTarget` refuses an empty pick.
139373
139411
  *
139374
- * The Echo picker is built from the accounts' own device lists on every call,
139375
- * so a new Echo shows up without a deploy. With one account there is no account
139376
- * field; with several, each account's Echoes appear when that account is chosen.
139412
+ * The account is a broker pick (existing, or created in place); the Echo list
139413
+ * is fetched from that account, so a new Echo shows up without a deploy.
139377
139414
  */
139378
- function configSchema(brokers) {
139379
- const first = brokers[0];
139415
+ function configSchema() {
139380
139416
  return { sections: [{
139381
139417
  id: "echo",
139382
139418
  title: "Echo",
139383
139419
  titleKey: "import-alexa:echoTarget.s_echo.title",
139384
139420
  fields: [
139385
- ...brokers.length > 1 ? [{
139386
- type: "select",
139421
+ ...[{
139422
+ type: "broker-select",
139387
139423
  key: "brokerId",
139388
139424
  label: "Amazon account",
139389
139425
  labelKey: "import-alexa:echoTarget.s_echo.brokerId.label",
139390
- options: brokers.map((broker) => ({
139391
- value: broker.id,
139392
- label: broker.name
139393
- }))
139394
- }, ...brokers.map((broker) => echoField(broker.devices, {
139395
- field: "brokerId",
139396
- equals: broker.id
139397
- }))] : [echoField(first?.devices ?? [])],
139398
- {
139426
+ brokerKind: ALEXA_BROKER_KIND
139427
+ }, {
139399
139428
  type: "select",
139400
- key: "mode",
139401
- label: "Mode",
139402
- labelKey: "import-alexa:echoTarget.s_echo.mode.label",
139403
- default: "speak",
139404
- options: [{
139405
- value: "speak",
139406
- label: "Speak (quiet hours apply)"
139407
- }, {
139408
- value: "announce",
139409
- label: "Announce (chime, then speech)"
139410
- }]
139411
- },
139429
+ key: "echoId",
139430
+ label: "Echo",
139431
+ labelKey: "import-alexa:echoTarget.s_echo.echoId.label",
139432
+ options: [],
139433
+ discoverOptions: {
139434
+ kind: ALEXA_SPEAK_KIND,
139435
+ dependsOn: ["brokerId"]
139436
+ }
139437
+ }],
139412
139438
  {
139413
139439
  type: "number",
139414
139440
  key: "volume",
@@ -139428,8 +139454,8 @@ function configSchema(brokers) {
139428
139454
  ]
139429
139455
  }] };
139430
139456
  }
139431
- /** The Alexa target kind as the Add target form sees it, for the accounts as they are now. */
139432
- function alexaTargetKind(brokers) {
139457
+ /** The Alexa target kind as the Add target form sees it. */
139458
+ function alexaTargetKind() {
139433
139459
  return {
139434
139460
  kind: ALEXA_SPEAK_KIND,
139435
139461
  label: "Alexa",
@@ -139439,7 +139465,7 @@ function alexaTargetKind(brokers) {
139439
139465
  addonId: ALEXA_SPEAK_ADDON_ID,
139440
139466
  supportsDiscovery: true,
139441
139467
  destinationKeys: ["brokerId", "echoId"],
139442
- configSchema: configSchema(brokers),
139468
+ configSchema: configSchema(),
139443
139469
  deliveryParams: DELIVERY_PARAMS,
139444
139470
  caps: {
139445
139471
  attachments: {
@@ -139450,9 +139476,16 @@ function alexaTargetKind(brokers) {
139450
139476
  actions: 0,
139451
139477
  actionIcons: false,
139452
139478
  levels: [{
139479
+ id: SILENT_LEVEL_ID,
139480
+ label: "Silent",
139481
+ ordinal: SILENT_PRIORITY,
139482
+ flags: { silent: true },
139483
+ description: "Spoken quietly: no chime, quiet hours apply."
139484
+ }, {
139453
139485
  id: "default",
139454
- label: "Default",
139455
- ordinal: 3
139486
+ label: "Announce",
139487
+ ordinal: 3,
139488
+ description: "Announced: chime, then speech."
139456
139489
  }],
139457
139490
  format: ["text"],
139458
139491
  clickUrl: false,
@@ -139469,7 +139502,6 @@ function discoveredFrom(broker, device) {
139469
139502
  config: {
139470
139503
  brokerId: broker.id,
139471
139504
  echoId: device.id,
139472
- mode: "speak",
139473
139505
  volume: 40,
139474
139506
  respectDnd: true,
139475
139507
  family: device.family,
@@ -139482,6 +139514,7 @@ function createAlexaNotificationProvider(ports) {
139482
139514
  let targets = ports.loadTargets();
139483
139515
  let ledger = emptySpeechLedger();
139484
139516
  let deliveries = [];
139517
+ const warnedLegacyMode = /* @__PURE__ */ new Set();
139485
139518
  function record(row) {
139486
139519
  deliveries = [...deliveries, row];
139487
139520
  }
@@ -139623,7 +139656,14 @@ function createAlexaNotificationProvider(ports) {
139623
139656
  renderedAs: speech.renderedAs
139624
139657
  };
139625
139658
  }
139626
- const mode = readMode(target.config, notification.delivery);
139659
+ const mode = modeFor(notification);
139660
+ if (target.config["mode"] !== void 0 && !warnedLegacyMode.has(target.id)) {
139661
+ warnedLegacyMode.add(target.id);
139662
+ logger.warn("alexa target mode is ignored: quiet vs announce follows each notification", {
139663
+ ...tags,
139664
+ meta: { targetId: target.id }
139665
+ });
139666
+ }
139627
139667
  try {
139628
139668
  const outcomes = await ports.speak({
139629
139669
  brokerId: broker.id,
@@ -139701,7 +139741,7 @@ function createAlexaNotificationProvider(ports) {
139701
139741
  return deliveries;
139702
139742
  },
139703
139743
  async listTargetKinds() {
139704
- return [alexaTargetKind(ports.brokers())];
139744
+ return [alexaTargetKind()];
139705
139745
  },
139706
139746
  async listTargets() {
139707
139747
  return [...targets];
@@ -139711,7 +139751,20 @@ function createAlexaNotificationProvider(ports) {
139711
139751
  if (found === void 0) throw new Error(`target not found: ${input.targetId}`);
139712
139752
  return found;
139713
139753
  },
139714
- async discoverTargets() {
139754
+ async discoverTargets(input) {
139755
+ const named = readString(input.config ?? {}, "brokerId");
139756
+ if (named.length > 0) {
139757
+ const owner = ports.brokers().find((broker) => broker.id === named);
139758
+ return (owner?.devices ?? []).map((device) => ({
139759
+ ...discoveredFrom(owner ?? {
139760
+ id: named,
139761
+ name: named,
139762
+ ready: false,
139763
+ devices: []
139764
+ }, device),
139765
+ suggestedName: echoOptionLabel(device)
139766
+ }));
139767
+ }
139715
139768
  const taken = new Set(targets.flatMap((row) => {
139716
139769
  const owner = brokerFor(row.config);
139717
139770
  return owner === void 0 ? [] : [`${owner.id}/${readString(row.config, "echoId")}`];
@@ -140040,7 +140093,6 @@ function targetsFrom(rows) {
140040
140093
  config: {
140041
140094
  ...row.brokerId.length > 0 ? { brokerId: row.brokerId } : {},
140042
140095
  echoId: row.echoId,
140043
- mode: row.mode,
140044
140096
  volume: row.volume,
140045
140097
  respectDnd: row.respectDnd
140046
140098
  }
@@ -140053,7 +140105,7 @@ function rowsFrom(targets) {
140053
140105
  enabled: target.enabled,
140054
140106
  brokerId: typeof target.config["brokerId"] === "string" ? target.config["brokerId"] : "",
140055
140107
  echoId: typeof target.config["echoId"] === "string" ? target.config["echoId"] : "",
140056
- mode: target.config["mode"] === "announce" ? "announce" : "speak",
140108
+ mode: "speak",
140057
140109
  volume: typeof target.config["volume"] === "number" ? target.config["volume"] : 40,
140058
140110
  respectDnd: target.config["respectDnd"] !== false
140059
140111
  }));
package/dist/addon.mjs CHANGED
@@ -57377,6 +57377,12 @@ var TerminalInstanceInfoSchema = object({
57377
57377
  instanceId: string(),
57378
57378
  cameraStableId: string(),
57379
57379
  nodeId: string(),
57380
+ /**
57381
+ * False while the node the instance is bound to is not in the cluster
57382
+ * topology (forgotten, renamed, re-paired). The instance is kept; the
57383
+ * operator re-points it with `updateInstance({ targetNodeId })`.
57384
+ */
57385
+ nodeInCluster: boolean(),
57380
57386
  profileId: string(),
57381
57387
  profileLabel: string(),
57382
57388
  name: string(),
@@ -57426,6 +57432,8 @@ method(_void(), array(TerminalProfileInfoSchema).readonly(), { auth: "admin" }),
57426
57432
  auth: "admin"
57427
57433
  }), method(object({
57428
57434
  instanceId: string().min(1),
57435
+ /** Re-point the instance to another node; the profile must exist there. */
57436
+ targetNodeId: string().min(1).max(256).optional(),
57429
57437
  name: string().trim().min(1).max(160).optional(),
57430
57438
  executable: string().max(1024).optional(),
57431
57439
  args: array(string().max(2048)).max(64).optional(),
@@ -137138,7 +137146,21 @@ function isRateLimitError(message) {
137138
137146
  const m = message.toLowerCase();
137139
137147
  return m.includes("rate exceeded") || m.includes("throttl") || m.includes("too many requests") || m.includes("429");
137140
137148
  }
137149
+ /** HTTP statuses that mean Amazon rejected the session (a re-link is needed). */
137150
+ var SESSION_DEAD_STATUSES = new Set([401, 403]);
137151
+ /** The library logs `No body (code=NNN)` on a body-less non-2xx; it is the only place the status shows. */
137152
+ var NO_BODY_CODE = /No body \(code=(\d{3})\)/;
137153
+ /** A failed Amazon call whose HTTP status is known (null when the library did not say). */
137154
+ var AlexaHttpError = class extends Error {
137155
+ statusCode;
137156
+ constructor(message, statusCode) {
137157
+ super(message);
137158
+ this.name = "AlexaHttpError";
137159
+ this.statusCode = statusCode;
137160
+ }
137161
+ };
137141
137162
  var AlexaRemoteClient = class {
137163
+ #lastHttpStatus = null;
137142
137164
  #options;
137143
137165
  #logger;
137144
137166
  #dataDir;
@@ -137158,7 +137180,10 @@ var AlexaRemoteClient = class {
137158
137180
  async connect() {
137159
137181
  const remote = new import_alexa_remote.default();
137160
137182
  this.#remote = remote;
137161
- const initOptions = await this.#buildInitOptions();
137183
+ const initOptions = {
137184
+ ...await this.#buildInitOptions(),
137185
+ logger: (...args) => this.#captureLibraryLine(args)
137186
+ };
137162
137187
  remote.on("cookie", () => {
137163
137188
  this.#persistSession(remote);
137164
137189
  });
@@ -137252,11 +137277,17 @@ var AlexaRemoteClient = class {
137252
137277
  async #withBackoff(op, run) {
137253
137278
  let lastError = null;
137254
137279
  for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) try {
137280
+ this.#lastHttpStatus = null;
137255
137281
  return await run();
137256
137282
  } catch (err) {
137257
137283
  lastError = err;
137258
137284
  const message = errMsg(err);
137259
137285
  if (isAuthError(message)) throw new AlexaAuthError(message);
137286
+ if (message.toLowerCase().includes("no body")) {
137287
+ const status = this.#lastHttpStatus;
137288
+ if (status !== null && SESSION_DEAD_STATUSES.has(status)) throw new AlexaAuthError(`${message} (HTTP ${status})`);
137289
+ throw new AlexaHttpError(message, status);
137290
+ }
137260
137291
  if (!isRateLimitError(message) || attempt === MAX_ATTEMPTS - 1) throw err;
137261
137292
  const delay = this.#backoffDelay(attempt);
137262
137293
  this.#logger?.warn("AlexaRemoteClient: rate-limited — backing off", { meta: {
@@ -137269,6 +137300,10 @@ var AlexaRemoteClient = class {
137269
137300
  }
137270
137301
  throw lastError instanceof Error ? lastError : /* @__PURE__ */ new Error(`AlexaRemoteClient: ${op} failed`);
137271
137302
  }
137303
+ #captureLibraryLine(args) {
137304
+ const match = NO_BODY_CODE.exec(args.map(String).join(" "));
137305
+ if (match?.[1] !== void 0) this.#lastHttpStatus = Number(match[1]);
137306
+ }
137272
137307
  #backoffDelay(attempt) {
137273
137308
  return Math.min(BACKOFF_BASE_MS * 2 ** attempt, BACKOFF_MAX_MS) + Math.floor(Math.random() * BACKOFF_BASE_MS);
137274
137309
  }
@@ -137507,8 +137542,6 @@ var AlexaCommandSink = class {
137507
137542
  };
137508
137543
  /** The single in-process command sink shared across the addon. */
137509
137544
  var alexaCommands = new AlexaCommandSink();
137510
- //#endregion
137511
- //#region src/alexa-integration-manager.ts
137512
137545
  /**
137513
137546
  * Wraps exactly one {@link AlexaClient} (= one Amazon account). Authenticates on
137514
137547
  * `start()`, lists the appliance inventory, then runs a POLL loop (Alexa has no
@@ -137536,6 +137569,7 @@ var AlexaIntegrationManager = class {
137536
137569
  #client = null;
137537
137570
  #pollTimer = null;
137538
137571
  #status = "disconnected";
137572
+ #pollFailures = 0;
137539
137573
  #applianceCount = 0;
137540
137574
  #lastCheckedAt = null;
137541
137575
  #error = null;
@@ -137640,14 +137674,37 @@ var AlexaIntegrationManager = class {
137640
137674
  alexaAccounts.recordStates(this.#id, values);
137641
137675
  this.#lastCheckedAt = Date.now();
137642
137676
  this.#onStatesPolled(this.#id, values);
137677
+ this.#pollFailures = 0;
137678
+ this.#error = null;
137679
+ if (this.#status === "error" || this.#status === "auth-failed") this.#transition("connected");
137643
137680
  } catch (err) {
137644
137681
  this.#error = errMsg(err);
137682
+ this.#pollFailures += 1;
137683
+ const httpStatus = err instanceof AlexaHttpError ? err.statusCode : null;
137645
137684
  this.#logger.warn("AlexaIntegrationManager: poll failed", {
137646
137685
  tags: { brokerId: this.#id },
137647
- meta: { error: errMsg(err) }
137686
+ meta: {
137687
+ error: errMsg(err),
137688
+ consecutiveFailures: this.#pollFailures,
137689
+ httpStatus
137690
+ }
137648
137691
  });
137692
+ if (this.#pollFailures >= 3 && this.#status !== "disconnected") this.#transition(err instanceof AlexaAuthError ? "auth-failed" : "error");
137649
137693
  }
137650
137694
  }
137695
+ #transition(next) {
137696
+ if (this.#status === next) return;
137697
+ const previous = this.#status;
137698
+ this.#status = next;
137699
+ this.#logger.info("AlexaIntegrationManager: status changed", {
137700
+ tags: { brokerId: this.#id },
137701
+ meta: {
137702
+ from: previous,
137703
+ to: next,
137704
+ error: this.#error
137705
+ }
137706
+ });
137707
+ }
137651
137708
  /** Stop the poll loop, disconnect the client (FIRST), null it. */
137652
137709
  async stop() {
137653
137710
  if (this.#pollTimer !== null) {
@@ -137866,8 +137923,17 @@ var AlexaBrokerRegistry = class {
137866
137923
  }
137867
137924
  };
137868
137925
  //#endregion
137926
+ //#region src/speak/constants.ts
137927
+ /** Echo speech limits and the worker restart bound (same window as CrashSupervisor). */
137928
+ var ALEXA_SPEAK_ADDON_ID = "import-alexa";
137929
+ var ALEXA_SPEAK_KIND = "alexa-echo";
137930
+ /** The kind of the Amazon-account broker the Echoes are read from. */
137931
+ var ALEXA_BROKER_KIND = "alexa";
137932
+ var DEVICE_REFRESH_MS = 5 * 6e4;
137933
+ /** Where the addon serves the Alexa mark; `listTargetKinds` stamps it as `iconUrl`. */
137934
+ var ALEXA_SPEAK_ICON_URL = `/addon/${ALEXA_SPEAK_ADDON_ID}/icons/${ALEXA_SPEAK_KIND}`;
137935
+ //#endregion
137869
137936
  //#region src/alexa-broker-provider.ts
137870
- var ALEXA_KIND = "alexa";
137871
137937
  /**
137872
137938
  * Construct the `broker` cap provider for the Alexa import addon. Pure builder.
137873
137939
  * Mirrors `buildEcowittBrokerProvider`. The cookie + refresh token are secrets:
@@ -137885,7 +137951,7 @@ function buildAlexaBrokerProvider(deps) {
137885
137951
  });
137886
137952
  return {
137887
137953
  list: async ({ kind }) => {
137888
- if (kind && kind !== ALEXA_KIND) return [];
137954
+ if (kind && kind !== "alexa") return [];
137889
137955
  return registry.list().map((info) => ({
137890
137956
  ...info,
137891
137957
  addonId: ownerAddonId
@@ -137901,7 +137967,7 @@ function buildAlexaBrokerProvider(deps) {
137901
137967
  listProviders: async () => [{
137902
137968
  addonId: ownerAddonId,
137903
137969
  kinds: [{
137904
- kind: ALEXA_KIND,
137970
+ kind: ALEXA_BROKER_KIND,
137905
137971
  label: "Alexa",
137906
137972
  signIn: {
137907
137973
  basePath: "/addon/import-alexa/login",
@@ -137910,7 +137976,7 @@ function buildAlexaBrokerProvider(deps) {
137910
137976
  }]
137911
137977
  }],
137912
137978
  add: async ({ kind, name, settings }) => {
137913
- if (kind !== ALEXA_KIND) throw new Error(`import-alexa: only kind '${ALEXA_KIND}' is handled here (got '${kind}')`);
137979
+ if (kind !== "alexa") throw new Error(`import-alexa: only kind '${ALEXA_BROKER_KIND}' is handled here (got '${kind}')`);
137914
137980
  const entry = await registry.createEntry(name, settingsToAlexaConnection(settings));
137915
137981
  await persistBrokers([...getBrokers(), entry]);
137916
137982
  return { id: entry.id };
@@ -137968,11 +138034,11 @@ function buildAlexaBrokerProvider(deps) {
137968
138034
  return entry ? redact(entry) : null;
137969
138035
  },
137970
138036
  getSettingsSchema: async ({ kind }) => {
137971
- if (kind !== ALEXA_KIND) return null;
138037
+ if (kind !== "alexa") return null;
137972
138038
  return buildConnectionFormSchema();
137973
138039
  },
137974
138040
  testSettings: async ({ kind, settings }) => {
137975
- if (kind !== ALEXA_KIND) return {
138041
+ if (kind !== "alexa") return {
137976
138042
  ok: false,
137977
138043
  error: `unsupported kind: ${kind}`
137978
138044
  };
@@ -139191,14 +139257,6 @@ var BrokerEcho = class {
139191
139257
  }
139192
139258
  };
139193
139259
  //#endregion
139194
- //#region src/speak/constants.ts
139195
- /** Echo speech limits and the worker restart bound (same window as CrashSupervisor). */
139196
- var ALEXA_SPEAK_ADDON_ID = "import-alexa";
139197
- var ALEXA_SPEAK_KIND = "alexa-echo";
139198
- var DEVICE_REFRESH_MS = 5 * 6e4;
139199
- /** Where the addon serves the Alexa mark; `listTargetKinds` stamps it as `iconUrl`. */
139200
- var ALEXA_SPEAK_ICON_URL = `/addon/${ALEXA_SPEAK_ADDON_ID}/icons/${ALEXA_SPEAK_KIND}`;
139201
- //#endregion
139202
139260
  //#region src/speak/rate-limit.ts
139203
139261
  /**
139204
139262
  * Per-Echo admission. A burst is dropped here, not queued: a queue would be
@@ -139302,9 +139360,16 @@ function readString(config, key) {
139302
139360
  const value = config[key];
139303
139361
  return typeof value === "string" ? value : "";
139304
139362
  }
139305
- /** The rule's value for this send wins; the target's saved config is the fallback. */
139306
- function readMode(config, delivery) {
139307
- return (delivery?.["mode"] ?? config["mode"]) === "announce" ? "announce" : "speak";
139363
+ /** The level id an Echo notification uses for "quiet: no chime, no announcement". */
139364
+ var SILENT_LEVEL_ID = "silent";
139365
+ var SILENT_PRIORITY = 1;
139366
+ /**
139367
+ * Quiet or announce is decided PER NOTIFICATION, never per target: a silent
139368
+ * notification (the kind's silent level, or the lowest priority when no level
139369
+ * is named) is spoken quietly, hours-aware; every other one announces.
139370
+ */
139371
+ function modeFor(notification) {
139372
+ return (notification.level !== void 0 ? notification.level === SILENT_LEVEL_ID : notification.priority <= SILENT_PRIORITY) ? "speak" : "announce";
139308
139373
  }
139309
139374
  function readVolume(config, delivery) {
139310
139375
  const ruled = delivery?.["volume"];
@@ -139315,21 +139380,9 @@ function readVolume(config, delivery) {
139315
139380
  /**
139316
139381
  * The delivery parameters a rule may set for an Echo target (D938). The
139317
139382
  * target's own config holds the default; these override it per notification.
139383
+ * Quiet vs announce is not one of them: it follows the notification's level.
139318
139384
  */
139319
139385
  var DELIVERY_PARAMS = [{
139320
- key: "mode",
139321
- label: "Mode",
139322
- labelKey: "import-alexa:echoTarget.s_echo.mode.label",
139323
- type: "enum",
139324
- options: [{
139325
- value: "speak",
139326
- label: "Speak (quiet hours apply)"
139327
- }, {
139328
- value: "announce",
139329
- label: "Announce (chime, then speech)"
139330
- }],
139331
- default: "speak"
139332
- }, {
139333
139386
  key: "volume",
139334
139387
  label: "Volume",
139335
139388
  labelKey: "import-alexa:echoTarget.s_echo.volume.label",
@@ -139349,64 +139402,37 @@ function echoOptionLabel(device) {
139349
139402
  if (kind.length > 0) parts.push(kind);
139350
139403
  return parts.join(" · ");
139351
139404
  }
139352
- function echoField(devices, showWhen) {
139353
- return {
139354
- type: "select",
139355
- key: "echoId",
139356
- label: "Echo",
139357
- labelKey: "import-alexa:echoTarget.s_echo.echoId.label",
139358
- description: devices.length === 0 ? "No Echo found yet. Sign the Amazon account in on Integrations, Alexa." : void 0,
139359
- descriptionKey: devices.length === 0 ? "import-alexa:echoTarget.s_echo.echoId.empty" : void 0,
139360
- options: devices.map((device) => ({
139361
- value: device.id,
139362
- label: echoOptionLabel(device)
139363
- })),
139364
- ...showWhen === void 0 ? {} : { showWhen }
139365
- };
139366
- }
139367
139405
  /**
139368
139406
  * The two pickers are not `required`: a required select shows its first option
139369
139407
  * while the form still holds no value, so a save would drop the choice. Left
139370
139408
  * optional they open on "Select", and `upsertTarget` refuses an empty pick.
139371
139409
  *
139372
- * The Echo picker is built from the accounts' own device lists on every call,
139373
- * so a new Echo shows up without a deploy. With one account there is no account
139374
- * field; with several, each account's Echoes appear when that account is chosen.
139410
+ * The account is a broker pick (existing, or created in place); the Echo list
139411
+ * is fetched from that account, so a new Echo shows up without a deploy.
139375
139412
  */
139376
- function configSchema(brokers) {
139377
- const first = brokers[0];
139413
+ function configSchema() {
139378
139414
  return { sections: [{
139379
139415
  id: "echo",
139380
139416
  title: "Echo",
139381
139417
  titleKey: "import-alexa:echoTarget.s_echo.title",
139382
139418
  fields: [
139383
- ...brokers.length > 1 ? [{
139384
- type: "select",
139419
+ ...[{
139420
+ type: "broker-select",
139385
139421
  key: "brokerId",
139386
139422
  label: "Amazon account",
139387
139423
  labelKey: "import-alexa:echoTarget.s_echo.brokerId.label",
139388
- options: brokers.map((broker) => ({
139389
- value: broker.id,
139390
- label: broker.name
139391
- }))
139392
- }, ...brokers.map((broker) => echoField(broker.devices, {
139393
- field: "brokerId",
139394
- equals: broker.id
139395
- }))] : [echoField(first?.devices ?? [])],
139396
- {
139424
+ brokerKind: ALEXA_BROKER_KIND
139425
+ }, {
139397
139426
  type: "select",
139398
- key: "mode",
139399
- label: "Mode",
139400
- labelKey: "import-alexa:echoTarget.s_echo.mode.label",
139401
- default: "speak",
139402
- options: [{
139403
- value: "speak",
139404
- label: "Speak (quiet hours apply)"
139405
- }, {
139406
- value: "announce",
139407
- label: "Announce (chime, then speech)"
139408
- }]
139409
- },
139427
+ key: "echoId",
139428
+ label: "Echo",
139429
+ labelKey: "import-alexa:echoTarget.s_echo.echoId.label",
139430
+ options: [],
139431
+ discoverOptions: {
139432
+ kind: ALEXA_SPEAK_KIND,
139433
+ dependsOn: ["brokerId"]
139434
+ }
139435
+ }],
139410
139436
  {
139411
139437
  type: "number",
139412
139438
  key: "volume",
@@ -139426,8 +139452,8 @@ function configSchema(brokers) {
139426
139452
  ]
139427
139453
  }] };
139428
139454
  }
139429
- /** The Alexa target kind as the Add target form sees it, for the accounts as they are now. */
139430
- function alexaTargetKind(brokers) {
139455
+ /** The Alexa target kind as the Add target form sees it. */
139456
+ function alexaTargetKind() {
139431
139457
  return {
139432
139458
  kind: ALEXA_SPEAK_KIND,
139433
139459
  label: "Alexa",
@@ -139437,7 +139463,7 @@ function alexaTargetKind(brokers) {
139437
139463
  addonId: ALEXA_SPEAK_ADDON_ID,
139438
139464
  supportsDiscovery: true,
139439
139465
  destinationKeys: ["brokerId", "echoId"],
139440
- configSchema: configSchema(brokers),
139466
+ configSchema: configSchema(),
139441
139467
  deliveryParams: DELIVERY_PARAMS,
139442
139468
  caps: {
139443
139469
  attachments: {
@@ -139448,9 +139474,16 @@ function alexaTargetKind(brokers) {
139448
139474
  actions: 0,
139449
139475
  actionIcons: false,
139450
139476
  levels: [{
139477
+ id: SILENT_LEVEL_ID,
139478
+ label: "Silent",
139479
+ ordinal: SILENT_PRIORITY,
139480
+ flags: { silent: true },
139481
+ description: "Spoken quietly: no chime, quiet hours apply."
139482
+ }, {
139451
139483
  id: "default",
139452
- label: "Default",
139453
- ordinal: 3
139484
+ label: "Announce",
139485
+ ordinal: 3,
139486
+ description: "Announced: chime, then speech."
139454
139487
  }],
139455
139488
  format: ["text"],
139456
139489
  clickUrl: false,
@@ -139467,7 +139500,6 @@ function discoveredFrom(broker, device) {
139467
139500
  config: {
139468
139501
  brokerId: broker.id,
139469
139502
  echoId: device.id,
139470
- mode: "speak",
139471
139503
  volume: 40,
139472
139504
  respectDnd: true,
139473
139505
  family: device.family,
@@ -139480,6 +139512,7 @@ function createAlexaNotificationProvider(ports) {
139480
139512
  let targets = ports.loadTargets();
139481
139513
  let ledger = emptySpeechLedger();
139482
139514
  let deliveries = [];
139515
+ const warnedLegacyMode = /* @__PURE__ */ new Set();
139483
139516
  function record(row) {
139484
139517
  deliveries = [...deliveries, row];
139485
139518
  }
@@ -139621,7 +139654,14 @@ function createAlexaNotificationProvider(ports) {
139621
139654
  renderedAs: speech.renderedAs
139622
139655
  };
139623
139656
  }
139624
- const mode = readMode(target.config, notification.delivery);
139657
+ const mode = modeFor(notification);
139658
+ if (target.config["mode"] !== void 0 && !warnedLegacyMode.has(target.id)) {
139659
+ warnedLegacyMode.add(target.id);
139660
+ logger.warn("alexa target mode is ignored: quiet vs announce follows each notification", {
139661
+ ...tags,
139662
+ meta: { targetId: target.id }
139663
+ });
139664
+ }
139625
139665
  try {
139626
139666
  const outcomes = await ports.speak({
139627
139667
  brokerId: broker.id,
@@ -139699,7 +139739,7 @@ function createAlexaNotificationProvider(ports) {
139699
139739
  return deliveries;
139700
139740
  },
139701
139741
  async listTargetKinds() {
139702
- return [alexaTargetKind(ports.brokers())];
139742
+ return [alexaTargetKind()];
139703
139743
  },
139704
139744
  async listTargets() {
139705
139745
  return [...targets];
@@ -139709,7 +139749,20 @@ function createAlexaNotificationProvider(ports) {
139709
139749
  if (found === void 0) throw new Error(`target not found: ${input.targetId}`);
139710
139750
  return found;
139711
139751
  },
139712
- async discoverTargets() {
139752
+ async discoverTargets(input) {
139753
+ const named = readString(input.config ?? {}, "brokerId");
139754
+ if (named.length > 0) {
139755
+ const owner = ports.brokers().find((broker) => broker.id === named);
139756
+ return (owner?.devices ?? []).map((device) => ({
139757
+ ...discoveredFrom(owner ?? {
139758
+ id: named,
139759
+ name: named,
139760
+ ready: false,
139761
+ devices: []
139762
+ }, device),
139763
+ suggestedName: echoOptionLabel(device)
139764
+ }));
139765
+ }
139713
139766
  const taken = new Set(targets.flatMap((row) => {
139714
139767
  const owner = brokerFor(row.config);
139715
139768
  return owner === void 0 ? [] : [`${owner.id}/${readString(row.config, "echoId")}`];
@@ -140038,7 +140091,6 @@ function targetsFrom(rows) {
140038
140091
  config: {
140039
140092
  ...row.brokerId.length > 0 ? { brokerId: row.brokerId } : {},
140040
140093
  echoId: row.echoId,
140041
- mode: row.mode,
140042
140094
  volume: row.volume,
140043
140095
  respectDnd: row.respectDnd
140044
140096
  }
@@ -140051,7 +140103,7 @@ function rowsFrom(targets) {
140051
140103
  enabled: target.enabled,
140052
140104
  brokerId: typeof target.config["brokerId"] === "string" ? target.config["brokerId"] : "",
140053
140105
  echoId: typeof target.config["echoId"] === "string" ? target.config["echoId"] : "",
140054
- mode: target.config["mode"] === "announce" ? "announce" : "speak",
140106
+ mode: "speak",
140055
140107
  volume: typeof target.config["volume"] === "number" ? target.config["volume"] : 40,
140056
140108
  respectDnd: target.config["respectDnd"] !== false
140057
140109
  }));
package/i18n/en.json CHANGED
@@ -14,8 +14,6 @@
14
14
  "echoTarget.s_echo.title": "Echo",
15
15
  "echoTarget.s_echo.brokerId.label": "Amazon account",
16
16
  "echoTarget.s_echo.echoId.label": "Echo",
17
- "echoTarget.s_echo.echoId.empty": "No Echo found yet. Sign the Amazon account in on Integrations, Alexa.",
18
- "echoTarget.s_echo.mode.label": "Mode",
19
17
  "echoTarget.s_echo.volume.label": "Volume",
20
18
  "echoTarget.s_echo.respectDnd.label": "Respect Do Not Disturb",
21
19
  "globalSettings": {
package/i18n/it.json CHANGED
@@ -14,8 +14,6 @@
14
14
  "echoTarget.s_echo.title": "Echo",
15
15
  "echoTarget.s_echo.brokerId.label": "Account Amazon",
16
16
  "echoTarget.s_echo.echoId.label": "Echo",
17
- "echoTarget.s_echo.echoId.empty": "Nessun Echo trovato. Accedi con l'account Amazon da Integrazioni, Alexa.",
18
- "echoTarget.s_echo.mode.label": "Modalità",
19
17
  "echoTarget.s_echo.volume.label": "Volume",
20
18
  "echoTarget.s_echo.respectDnd.label": "Rispetta Non disturbare",
21
19
  "globalSettings": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-import-alexa",
3
- "version": "0.2.170",
3
+ "version": "0.2.172",
4
4
  "description": "Alexa device-import provider for CamStack — imports the smart-home devices in a user's Alexa account via the unofficial alexa-remote2 cookie/token client (the inverse of the Alexa exporter)",
5
5
  "keywords": [
6
6
  "camstack",