@camstack/addon-import-alexa 0.2.188 → 0.2.189

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/dist/addon.mjs CHANGED
@@ -6042,7 +6042,7 @@ function number(params) {
6042
6042
  return /* @__PURE__ */ _coercedNumber(ZodNumber, params);
6043
6043
  }
6044
6044
  //#endregion
6045
- //#region ../types/dist/sleep-9TIHFj1w.mjs
6045
+ //#region ../types/dist/sleep-DEZaLbiu.mjs
6046
6046
  /**
6047
6047
  * The audio chunk plane's byte format, and the ONE expansion from a coded
6048
6048
  * window to float samples (D455).
@@ -6851,6 +6851,12 @@ function projectStore(store, perNodeKeys, nodeId) {
6851
6851
  }
6852
6852
  return out;
6853
6853
  }
6854
+ /** Every scope, in the order the guard and the docs list them. */
6855
+ var SCOPED_SETTINGS_SCOPES = [
6856
+ "cluster",
6857
+ "device-defaults",
6858
+ "node"
6859
+ ];
6854
6860
  /**
6855
6861
  * The address of one keychain value: `secret://<owner>/<scope>/<fieldPath>`.
6856
6862
  *
@@ -7742,6 +7748,13 @@ var BaseAddon = class {
7742
7748
  */
7743
7749
  secretStatePaths = [];
7744
7750
  _addonSecretTree = null;
7751
+ /**
7752
+ * D986 M4: the scoped schemas this addon declared, resolved once per capability
7753
+ * (`loadScopedSchemas`) so the sync readers (`perNodeKeys`, the secret tree, the restart check)
7754
+ * see them. Empty until loaded: the readers then fall back to {@link globalSettingsSchema}.
7755
+ */
7756
+ _scopedByCap = /* @__PURE__ */ new Map();
7757
+ _nodeKeysByCap = /* @__PURE__ */ new Map();
7745
7758
  _deviceSecretTree = null;
7746
7759
  /** Refs the last resolved config pointed at — a change to one re-resolves. */
7747
7760
  _configSecretRefs = /* @__PURE__ */ new Set();
@@ -7811,6 +7824,7 @@ var BaseAddon = class {
7811
7824
  this._ctx = context;
7812
7825
  this._secretSettings = null;
7813
7826
  }
7827
+ await this.loadScopedSchemas();
7814
7828
  await this.resolveConfig();
7815
7829
  if (secrets !== void 0) this._secretChangeUnsub = secrets.onChange((ref) => {
7816
7830
  if (this._configSecretRefs.has(ref)) this.scheduleSecretReresolve(SECRET_CHANGE_DEBOUNCE_MS);
@@ -7820,6 +7834,7 @@ var BaseAddon = class {
7820
7834
  const normalized = normalizeAddonInitResult(result);
7821
7835
  const providers = normalized && "providers" in normalized && normalized.providers ? normalized.providers : [];
7822
7836
  this._registeredCapNames = providers.map((p) => p.capability.name);
7837
+ await this.loadScopedSchemas();
7823
7838
  return normalized;
7824
7839
  }
7825
7840
  /**
@@ -7908,6 +7923,16 @@ var BaseAddon = class {
7908
7923
  schema(schema) {
7909
7924
  return schema;
7910
7925
  }
7926
+ /**
7927
+ * The capability names whose scoped settings schemas ({@link clusterSettingsSchema},
7928
+ * {@link nodeSettingsSchema}, {@link deviceDefaultsSchema}) this addon declares. Read BEFORE
7929
+ * `onInitialize`, when the providers it registers are not known yet: the first config resolve
7930
+ * must already know which keys are per node. Names the providers register at init are added
7931
+ * once known.
7932
+ */
7933
+ scopedSettingsCapabilities() {
7934
+ return [];
7935
+ }
7911
7936
  /** Override to provide global-level settings UI schema. */
7912
7937
  globalSettingsSchema(_cap) {
7913
7938
  return null;
@@ -7951,7 +7976,8 @@ var BaseAddon = class {
7951
7976
  return [];
7952
7977
  }
7953
7978
  async getGlobalSettings(overlay, cap, nodeId) {
7954
- const schema = this.globalSettingsSchema(cap);
7979
+ await this.loadScopedSchemas(cap);
7980
+ const schema = this.globalSettingsSchema(cap) ?? this.scopedFormSchema(cap);
7955
7981
  if (!schema) return { sections: [] };
7956
7982
  const projected = await this.readRawProjectedStore(nodeId, cap);
7957
7983
  return hydrateSchema(schema, overlay ? await this.overlayKeepingMarkers(projected, overlay) : projected, { inlineSecretsAsViews: true });
@@ -8082,6 +8108,7 @@ var BaseAddon = class {
8082
8108
  * resolved blob back is a no-op for every unchanged secret.
8083
8109
  */
8084
8110
  async resolveGlobalStore(nodeId, cap) {
8111
+ await this.loadScopedSchemas(cap);
8085
8112
  const raw = await this._ctx?.settings?.readAddonStore() ?? {};
8086
8113
  return this.projectForNode(raw, nodeId, cap);
8087
8114
  }
@@ -8092,6 +8119,7 @@ var BaseAddon = class {
8092
8119
  }
8093
8120
  /** The PROJECTED store with its markers — the form hydrate only (D798). */
8094
8121
  async readRawProjectedStore(nodeId, cap) {
8122
+ await this.loadScopedSchemas(cap);
8095
8123
  const raw = await this.rawSettings()?.readAddonStore() ?? {};
8096
8124
  return this.projectForNode(raw, nodeId, cap);
8097
8125
  }
@@ -8194,6 +8222,7 @@ var BaseAddon = class {
8194
8222
  await this.applyGlobalPatch(patch, nodeId);
8195
8223
  }
8196
8224
  async applyGlobalPatch(barePatch, nodeId) {
8225
+ await this.loadScopedSchemas();
8197
8226
  const keys = this.perNodeKeys();
8198
8227
  const localNode = normalizeNodeId(this._ctx?.kernel?.localNodeId);
8199
8228
  const target = normalizeNodeId(nodeId ?? this._ctx?.kernel?.localNodeId);
@@ -8207,7 +8236,8 @@ var BaseAddon = class {
8207
8236
  await this.resolveConfig();
8208
8237
  await this.onConfigChanged();
8209
8238
  this.emitLifecycle(EventCategory.AddonUpdated, { level: "global" });
8210
- this.maybeAutoRestart(Object.keys(barePatch), this.globalSettingsSchema());
8239
+ const legacy = this.globalSettingsSchema();
8240
+ this.maybeAutoRestart(Object.keys(barePatch), [...legacy === null ? [] : [legacy], ...this.loadedScopedSchemas().map((entry) => entry.schema)]);
8211
8241
  }
8212
8242
  /**
8213
8243
  * The set of field keys the global settings schema declares `perNode: true`
@@ -8215,14 +8245,67 @@ var BaseAddon = class {
8215
8245
  * declarations). Empty set ⇒ every per-node code path is bypassed and the
8216
8246
  * settings API behaves exactly like the legacy node-agnostic one.
8217
8247
  */
8218
- _perNodeKeysCache = /* @__PURE__ */ new Map();
8248
+ /**
8249
+ * Load the scoped schemas of every capability this addon declares (the override and the
8250
+ * providers registered at init, plus `extraCap`) that is not loaded yet, then drop what was
8251
+ * derived from the previous set. Idempotent.
8252
+ */
8253
+ async loadScopedSchemas(extraCap) {
8254
+ const caps = new Set([
8255
+ ...this.scopedSettingsCapabilities(),
8256
+ ...this._registeredCapNames,
8257
+ ...extraCap === void 0 ? [] : [extraCap]
8258
+ ]);
8259
+ let added = false;
8260
+ for (const cap of caps) {
8261
+ if (this._scopedByCap.has(cap)) continue;
8262
+ const loaded = [];
8263
+ for (const scope of SCOPED_SETTINGS_SCOPES) {
8264
+ const schema = await this.getScopedSettingsSchema(scope, cap);
8265
+ if (schema !== null) loaded.push({
8266
+ scope,
8267
+ schema
8268
+ });
8269
+ }
8270
+ this._scopedByCap.set(cap, loaded);
8271
+ added = true;
8272
+ }
8273
+ if (!added) return;
8274
+ this._nodeKeysByCap = /* @__PURE__ */ new Map();
8275
+ this._addonSecretTree = null;
8276
+ }
8277
+ /**
8278
+ * The form the settings API serves while an addon declares no {@link globalSettingsSchema}: the
8279
+ * sections of its scoped schemas, so dropping the old hook does not blank the form.
8280
+ */
8281
+ scopedFormSchema(cap) {
8282
+ const byId = /* @__PURE__ */ new Map();
8283
+ for (const entry of this.loadedScopedSchemas(cap)) for (const section of entry.schema.sections) if (!byId.has(section.id)) byId.set(section.id, section);
8284
+ return byId.size === 0 ? null : { sections: [...byId.values()] };
8285
+ }
8286
+ /** Every loaded scoped schema of `cap`, or of every loaded capability. */
8287
+ loadedScopedSchemas(cap) {
8288
+ if (cap !== void 0) return this._scopedByCap.get(cap) ?? [];
8289
+ return [...this._scopedByCap.values()].flat();
8290
+ }
8291
+ /**
8292
+ * The field keys that are stored per node: every field of a {@link nodeSettingsSchema}, plus
8293
+ * the fields the legacy {@link globalSettingsSchema} still marks `perNode: true`. Derived once
8294
+ * per `cap` argument and memoized (schemas are static declarations). Empty set ⇒ every
8295
+ * per-node code path is bypassed and the settings API behaves like the node-agnostic one.
8296
+ * Call {@link loadScopedSchemas} first (the async seams do).
8297
+ */
8219
8298
  perNodeKeys(cap) {
8220
8299
  const cacheKey = cap ?? "";
8221
- const cached = this._perNodeKeysCache.get(cacheKey);
8300
+ const cached = this._nodeKeysByCap.get(cacheKey);
8222
8301
  if (cached) return cached;
8223
- const schema = this.globalSettingsSchema(cap);
8224
- const keys = new Set(schema ? schema.sections.flatMap((section) => collectPerNodeFieldKeys(section.fields)) : []);
8225
- this._perNodeKeysCache.set(cacheKey, keys);
8302
+ const legacy = this.globalSettingsSchema(cap);
8303
+ const keys = new Set(legacy ? legacy.sections.flatMap((section) => collectPerNodeFieldKeys(section.fields)) : []);
8304
+ for (const { scope, schema } of this.loadedScopedSchemas(cap)) {
8305
+ if (scope !== "node") continue;
8306
+ for (const section of schema.sections) for (const key of collectValueFieldKeys(section.fields)) keys.add(key);
8307
+ }
8308
+ this._nodeKeysByCap.set(cacheKey, keys);
8226
8309
  return keys;
8227
8310
  }
8228
8311
  /**
@@ -8232,10 +8315,9 @@ var BaseAddon = class {
8232
8315
  * time to return its response before the addon is torn down and
8233
8316
  * re-initialised by `AddonRegistryService.restartAddon`.
8234
8317
  */
8235
- maybeAutoRestart(patchKeys, schema) {
8236
- if (!schema) return;
8318
+ maybeAutoRestart(patchKeys, schemas) {
8237
8319
  const restartKeys = /* @__PURE__ */ new Set();
8238
- for (const section of schema.sections) for (const field of section.fields) {
8320
+ for (const schema of schemas) for (const section of schema.sections) for (const field of section.fields) {
8239
8321
  if (field.type === "separator" || field.type === "info") continue;
8240
8322
  if (field.requiresRestart) restartKeys.add(field.key);
8241
8323
  }
@@ -8431,12 +8513,14 @@ var BaseAddon = class {
8431
8513
  * here, in the addon's own process, so the hub keeps no copy of it.
8432
8514
  */
8433
8515
  async getDeclaredSettingsKeys() {
8516
+ await this.loadScopedSchemas();
8434
8517
  return { keys: [...this.declaredKeySet()].toSorted() };
8435
8518
  }
8436
8519
  declaredKeySet() {
8437
8520
  const keys = new Set(Object.keys(this.defaults));
8438
8521
  const schema = this.globalSettingsSchema();
8439
8522
  if (schema) for (const section of schema.sections) for (const key of collectFieldKeys(section.fields)) keys.add(key);
8523
+ for (const { schema: scoped } of this.loadedScopedSchemas()) for (const section of scoped.sections) for (const key of collectFieldKeys(section.fields)) keys.add(key);
8440
8524
  for (const key of this._declaredStateKeys) keys.add(key);
8441
8525
  for (const path of this.secretStatePaths) {
8442
8526
  const root = path.split(/[.[]/, 1)[0];
@@ -8532,7 +8616,11 @@ var BaseAddon = class {
8532
8616
  }
8533
8617
  /** The addon store's secret tree: schema fields by TYPE, plus `secretStatePaths`. */
8534
8618
  addonSecretTree() {
8535
- this._addonSecretTree ??= mergeSecretStatePaths(secretTreeFromSchema(this.globalSettingsSchema()), this.secretStatePaths);
8619
+ if (this._addonSecretTree === null) {
8620
+ const tree = new Map(secretTreeFromSchema(this.globalSettingsSchema()));
8621
+ for (const { schema } of this.loadedScopedSchemas()) for (const [key, node] of secretTreeFromSchema(schema)) tree.set(key, node);
8622
+ this._addonSecretTree = mergeSecretStatePaths(tree, this.secretStatePaths);
8623
+ }
8536
8624
  return this._addonSecretTree;
8537
8625
  }
8538
8626
  deviceSecretTree() {
@@ -8782,6 +8870,23 @@ function dropPerNodeFields(fields) {
8782
8870
  }
8783
8871
  return kept;
8784
8872
  }
8873
+ /** The keys of the value-holding fields, recursing into layout containers. */
8874
+ function collectValueFieldKeys(fields) {
8875
+ const collected = [];
8876
+ for (const field of fields) {
8877
+ if (field.type === "group") {
8878
+ collected.push(...collectValueFieldKeys(field.fields));
8879
+ continue;
8880
+ }
8881
+ if (field.type === "sub-tabs") {
8882
+ for (const tab of field.tabs) collected.push(...collectValueFieldKeys(tab.fields));
8883
+ continue;
8884
+ }
8885
+ if (field.type === "separator" || field.type === "info") continue;
8886
+ if ("key" in field && typeof field.key === "string") collected.push(field.key);
8887
+ }
8888
+ return collected;
8889
+ }
8785
8890
  function collectFieldKeys(fields) {
8786
8891
  const collected = [];
8787
8892
  for (const field of fields) {
@@ -13574,12 +13679,22 @@ var RecordIntegrationInstances = class {
13574
13679
  ...this.hooks.description,
13575
13680
  mintsId: true
13576
13681
  };
13577
- instanceFormSchema = async ({ id }) => this.hooks.formSchema(id);
13682
+ /**
13683
+ * The record an id names. A caller holding the `integrations` registry id (the Integrations
13684
+ * page) reaches a kind whose body links to it through `integrationId`; an id that is itself a
13685
+ * record id answers as it is. An id that names nothing comes back unchanged.
13686
+ */
13687
+ async resolveRecordId(id) {
13688
+ if (await this.handle.get(id) !== null) return id;
13689
+ const linked = (await this.handle.query({ limit: INSTANCES_LIST_MAX })).records.find((record) => record.data["integrationId"] === id);
13690
+ return linked === void 0 ? id : linked.id;
13691
+ }
13692
+ instanceFormSchema = async ({ id }) => this.hooks.formSchema(id === null ? null : await this.resolveRecordId(id));
13578
13693
  listInstances = async () => {
13579
13694
  return (await this.handle.query({ limit: INSTANCES_LIST_MAX })).records.map((record) => this.viewOf(record));
13580
13695
  };
13581
13696
  getInstance = async ({ id }) => {
13582
- const record = await this.handle.get(id);
13697
+ const record = await this.handle.get(await this.resolveRecordId(id));
13583
13698
  return record === null ? null : this.viewOf(record);
13584
13699
  };
13585
13700
  /** Insert ONE row (`ifRev: 0`: an existing id is refused, never overwritten). */
@@ -13596,7 +13711,8 @@ var RecordIntegrationInstances = class {
13596
13711
  await this.hooks.onChanged?.(minted, "created");
13597
13712
  return { id: minted };
13598
13713
  };
13599
- updateInstance = async ({ id, name, values }) => {
13714
+ updateInstance = async ({ id: askedId, name, values }) => {
13715
+ const id = await this.resolveRecordId(askedId);
13600
13716
  const patch = {};
13601
13717
  for (const [key, value] of Object.entries(values)) {
13602
13718
  if (value === void 0) continue;
@@ -13631,7 +13747,10 @@ var RecordIntegrationInstances = class {
13631
13747
  status: "unsupported",
13632
13748
  error: "This integration declares no connection test"
13633
13749
  };
13634
- return this.hooks.test(input);
13750
+ return this.hooks.test(input.id === null ? input : {
13751
+ ...input,
13752
+ id: await this.resolveRecordId(input.id)
13753
+ });
13635
13754
  };
13636
13755
  /** The flat provider object to hand to `registerProvider`. */
13637
13756
  toProvider() {
@@ -98405,7 +98524,12 @@ var AMAZON_DOMAIN_BY_COUNTRY = {
98405
98524
  BR: "amazon.com.br",
98406
98525
  MX: "amazon.com.mx"
98407
98526
  };
98408
- /** Hand-written connection form for the broker/integration creation UI. */
98527
+ /**
98528
+ * The connection form: what the operator chooses (region, poll cadence). The cookie blob and the
98529
+ * refresh token are login STATE minted by the in-house sign-in; they live on the record as
98530
+ * keychain secrets, are written by that flow and are never part of this form, so a form save
98531
+ * cannot overwrite or clear them.
98532
+ */
98409
98533
  function buildConnectionFormSchema() {
98410
98534
  return { sections: [{
98411
98535
  id: "alexa-account",
@@ -98414,45 +98538,26 @@ function buildConnectionFormSchema() {
98414
98538
  description: "Sign in with your Amazon account to link it. This is an unofficial integration: Amazon can change or break it without notice, and it may violate Amazon ToS. State is polled, not pushed.",
98415
98539
  descriptionKey: "import-alexa:connection.s_alexa-account.description",
98416
98540
  columns: 1,
98417
- fields: [
98418
- {
98419
- type: "select",
98420
- key: "amazonDomain",
98421
- label: "Amazon domain (region)",
98422
- labelKey: "import-alexa:connection.s_alexa-account.amazonDomain.label",
98423
- options: AMAZON_DOMAINS.map((value) => ({
98424
- value,
98425
- label: value
98426
- })),
98427
- default: "amazon.com",
98428
- defaultByCountry: AMAZON_DOMAIN_BY_COUNTRY
98429
- },
98430
- {
98431
- type: "password",
98432
- key: "cookie",
98433
- label: "Cookie blob (filled by the sign-in)",
98434
- labelKey: "import-alexa:connection.s_alexa-account.cookie.label",
98435
- showToggle: true,
98436
- filledBySignIn: true
98437
- },
98438
- {
98439
- type: "password",
98440
- key: "refreshToken",
98441
- label: "Refresh token (alternative to the sign-in)",
98442
- labelKey: "import-alexa:connection.s_alexa-account.refreshToken.label",
98443
- showToggle: true,
98444
- filledBySignIn: true
98445
- },
98446
- {
98447
- type: "number",
98448
- key: "pollIntervalMs",
98449
- label: "State poll interval (ms)",
98450
- labelKey: "import-alexa:connection.s_alexa-account.pollIntervalMs.label",
98451
- min: 15e3,
98452
- max: 36e5,
98453
- default: 6e4
98454
- }
98455
- ]
98541
+ fields: [{
98542
+ type: "select",
98543
+ key: "amazonDomain",
98544
+ label: "Amazon domain (region)",
98545
+ labelKey: "import-alexa:connection.s_alexa-account.amazonDomain.label",
98546
+ options: AMAZON_DOMAINS.map((value) => ({
98547
+ value,
98548
+ label: value
98549
+ })),
98550
+ default: "amazon.com",
98551
+ defaultByCountry: AMAZON_DOMAIN_BY_COUNTRY
98552
+ }, {
98553
+ type: "number",
98554
+ key: "pollIntervalMs",
98555
+ label: "State poll interval (ms)",
98556
+ labelKey: "import-alexa:connection.s_alexa-account.pollIntervalMs.label",
98557
+ min: 15e3,
98558
+ max: 36e5,
98559
+ default: 6e4
98560
+ }]
98456
98561
  }] };
98457
98562
  }
98458
98563
  //#endregion
@@ -142817,6 +142922,13 @@ var ECHO_REREAD_MS = 1500;
142817
142922
  /** After a 429 the account is left alone for this long, doubling up to the cap. */
142818
142923
  var ECHO_RATE_BACKOFF_BASE_MS = 3e4;
142819
142924
  var ECHO_RATE_BACKOFF_MAX_MS = 5 * 6e4;
142925
+ /**
142926
+ * A command (volume, play, Do Not Disturb) that has not started within this, or not
142927
+ * finished, is answered with an error instead of running minutes late.
142928
+ */
142929
+ var ECHO_COMMAND_DEADLINE_MS = 12e3;
142930
+ /** A poll read is stale after this; it is dropped from the queue rather than sent late. */
142931
+ var ECHO_READ_DEADLINE_MS = 25e3;
142820
142932
  //#endregion
142821
142933
  //#region src/alexa-broker-provider.ts
142822
142934
  /**
@@ -144017,6 +144129,10 @@ var AlexaEchoDevice = class extends BaseDevice {
144017
144129
  failureLogged = false;
144018
144130
  volumeFailureLogged = false;
144019
144131
  health = "ok";
144132
+ /** The last level this device asked Amazon for, and when; a step starts from it until a read is newer. */
144133
+ lastSet = null;
144134
+ /** When the read that produced the current media-player slice began. */
144135
+ polledFrom = 0;
144020
144136
  constructor(ctx) {
144021
144137
  const persisted = alexaEchoDeviceSchema.parse(ctx.persistedConfig ?? {});
144022
144138
  super(ctx, alexaEchoDeviceSchema, { type: DeviceType.MediaPlayer });
@@ -144088,7 +144204,10 @@ var AlexaEchoDevice = class extends BaseDevice {
144088
144204
  }
144089
144205
  const deviceType = this.amazonDeviceType !== "" ? this.amazonDeviceType : listed?.deviceType ?? "";
144090
144206
  try {
144091
- const fromPlayer = mapPlayerInfoToStatus(await session.playerState(this.serial, deviceType), {
144207
+ const readStartedAt = Date.now();
144208
+ const info = await session.playerState(this.serial, deviceType, this.id);
144209
+ this.polledFrom = readStartedAt;
144210
+ const fromPlayer = mapPlayerInfoToStatus(info, {
144092
144211
  online: true,
144093
144212
  now: Date.now()
144094
144213
  });
@@ -144156,16 +144275,28 @@ var AlexaEchoDevice = class extends BaseDevice {
144156
144275
  });
144157
144276
  }
144158
144277
  async command(command) {
144159
- await this.session().npCommand(this.serial, command);
144278
+ await this.session().npCommand(this.serial, command, this.id);
144279
+ this.rereadSoon();
144280
+ }
144281
+ async sendVolume(level) {
144282
+ this.lastSet = {
144283
+ level,
144284
+ at: Date.now()
144285
+ };
144286
+ await this.session().setVolume(this.serial, level, this.id);
144160
144287
  this.rereadSoon();
144161
144288
  }
144162
- /** Amazon has no step command: move from the last known level, refuse when there is none. */
144289
+ /**
144290
+ * Amazon has no step command: move from the level we last SET when no read since
144291
+ * is newer (a polled level lags the command), else the polled one; refuse when
144292
+ * there is none.
144293
+ */
144163
144294
  async stepVolume(delta) {
144164
- const level = this.runtimeState.getCapState("media-player")?.volumeLevel;
144295
+ const polled = this.runtimeState.getCapState("media-player")?.volumeLevel;
144296
+ const set = this.lastSet;
144297
+ const level = set !== null && set.at >= this.polledFrom ? set.level : polled;
144165
144298
  if (level === null || level === void 0) throw new Error("the Echo volume is not known yet");
144166
- const next = Math.max(0, Math.min(100, level + delta));
144167
- await this.session().setVolume(this.serial, next);
144168
- this.rereadSoon();
144299
+ await this.sendVolume(Math.max(0, Math.min(100, level + delta)));
144169
144300
  }
144170
144301
  registerMediaPlayer() {
144171
144302
  this.ctx.registerNativeCap(mediaPlayerCapability, {
@@ -144174,10 +144305,7 @@ var AlexaEchoDevice = class extends BaseDevice {
144174
144305
  pause: () => this.command("pause"),
144175
144306
  next: () => this.command("next"),
144176
144307
  previous: () => this.command("previous"),
144177
- setVolume: async ({ volumeLevel }) => {
144178
- await this.session().setVolume(this.serial, Math.round(volumeLevel));
144179
- this.rereadSoon();
144180
- },
144308
+ setVolume: async ({ volumeLevel }) => this.sendVolume(Math.round(volumeLevel)),
144181
144309
  stop: async () => unsupported("stop"),
144182
144310
  turnOn: async () => unsupported("power"),
144183
144311
  turnOff: async () => unsupported("power"),
@@ -144197,7 +144325,7 @@ var AlexaEchoDevice = class extends BaseDevice {
144197
144325
  this.ctx.registerNativeCap(doNotDisturbCapability, {
144198
144326
  getStatus: async () => this.runtimeState.getCapState("do-not-disturb") ?? COLD_DND,
144199
144327
  setEnabled: async ({ enabled }) => {
144200
- await this.session().setDnd(this.serial, enabled);
144328
+ await this.session().setDnd(this.serial, enabled, this.id);
144201
144329
  this.runtimeState.setCapState("do-not-disturb", {
144202
144330
  enabled,
144203
144331
  lastChangedAt: Date.now()
@@ -144299,15 +144427,7 @@ function buildAlexaInstances(records, deps) {
144299
144427
  },
144300
144428
  configured: isAlexaBodyConfigured,
144301
144429
  onChanged: deps.onChanged,
144302
- test: async ({ id, values }) => {
144303
- if (["cookie", "refreshToken"].some((key) => {
144304
- const v = values[key];
144305
- return typeof v === "string" && v.length > 0 && v !== "__camstack_redacted__";
144306
- })) return {
144307
- success: true,
144308
- status: "inconclusive",
144309
- error: "Save the account to check the sign-in"
144310
- };
144430
+ test: async ({ id }) => {
144311
144431
  const info = id === null ? null : deps.infoOf(id);
144312
144432
  if (info === null) return {
144313
144433
  success: false,
@@ -144607,6 +144727,9 @@ function scrubSecrets(text, secrets) {
144607
144727
  }
144608
144728
  //#endregion
144609
144729
  //#region src/speak/broker-echo.ts
144730
+ function withDevice(deviceId) {
144731
+ return deviceId === void 0 ? {} : { deviceId };
144732
+ }
144610
144733
  var BrokerEcho = class {
144611
144734
  deps;
144612
144735
  currentState = "starting";
@@ -144752,29 +144875,29 @@ var BrokerEcho = class {
144752
144875
  }
144753
144876
  }
144754
144877
  /** The raw player state of one Echo. Throws on any failure, a 429 included: never an empty answer. */
144755
- async playerState(serial, deviceType) {
144878
+ async playerState(serial, deviceType, deviceId) {
144756
144879
  await this.requireReady();
144757
144880
  return this.limited(async () => parsePlayerState(await this.deps.worker.request({
144758
144881
  op: "player_state",
144759
144882
  serial,
144760
144883
  deviceType
144761
- })));
144884
+ }, withDevice(deviceId))));
144762
144885
  }
144763
- async npCommand(serial, command) {
144886
+ async npCommand(serial, command, deviceId) {
144764
144887
  await this.requireReady();
144765
144888
  await this.limited(() => this.deps.worker.request({
144766
144889
  op: "np_command",
144767
144890
  serial,
144768
144891
  command
144769
- }));
144892
+ }, withDevice(deviceId)));
144770
144893
  }
144771
- async setVolume(serial, level) {
144894
+ async setVolume(serial, level, deviceId) {
144772
144895
  await this.requireReady();
144773
144896
  await this.limited(() => this.deps.worker.request({
144774
144897
  op: "set_volume",
144775
144898
  serial,
144776
144899
  level
144777
- }));
144900
+ }, withDevice(deviceId)));
144778
144901
  }
144779
144902
  /** Do Not Disturb of every device of the account, by serial, in one call. */
144780
144903
  async dndState() {
@@ -144786,13 +144909,13 @@ var BrokerEcho = class {
144786
144909
  await this.requireReady();
144787
144910
  return this.limited(async () => parseDeviceVolumes(await this.deps.worker.request({ op: "device_volumes" })));
144788
144911
  }
144789
- async setDnd(serial, enabled) {
144912
+ async setDnd(serial, enabled, deviceId) {
144790
144913
  await this.requireReady();
144791
144914
  await this.limited(() => this.deps.worker.request({
144792
144915
  op: "set_dnd",
144793
144916
  serial,
144794
144917
  enabled
144795
- }));
144918
+ }, withDevice(deviceId)));
144796
144919
  }
144797
144920
  async requireReady() {
144798
144921
  if (!await this.ensureReady()) throw new Error(this.notReadyReason());
@@ -145378,6 +145501,28 @@ function resolveAlexaWorkerScript(fromModuleUrl = import.meta.url) {
145378
145501
  * A crash restarts the process until the same bound CrashSupervisor uses,
145379
145502
  * then requests fail instead of looping.
145380
145503
  */
145504
+ var READ_OPS = new Set([
145505
+ "player_state",
145506
+ "device_volumes",
145507
+ "dnd_state",
145508
+ "devices",
145509
+ "session_status"
145510
+ ]);
145511
+ function coalesceKey(request) {
145512
+ switch (request.op) {
145513
+ case "set_volume":
145514
+ case "player_state": return `${request.op}:${request.serial}`;
145515
+ case "device_volumes":
145516
+ case "dnd_state":
145517
+ case "devices":
145518
+ case "session_status": return request.op;
145519
+ default: return null;
145520
+ }
145521
+ }
145522
+ function defaultDeadlineMs(request) {
145523
+ if (request.op === "set_volume" || request.op === "np_command" || request.op === "set_dnd") return ECHO_COMMAND_DEADLINE_MS;
145524
+ if (READ_OPS.has(request.op) && request.op !== "devices" && request.op !== "session_status") return ECHO_READ_DEADLINE_MS;
145525
+ }
145381
145526
  /** Pure: a new timestamp list, never a mutated one. */
145382
145527
  function recordWorkerCrash(times, now, windowMs, maxCrashes) {
145383
145528
  const cutoff = now - windowMs;
@@ -145462,10 +145607,40 @@ function startAlexaWorker(deps) {
145462
145607
  function scrub(text) {
145463
145608
  return scrubSecrets(text, secrets);
145464
145609
  }
145610
+ function clearTimer(entry) {
145611
+ if (entry.timer !== null) clearTimeout(entry.timer);
145612
+ entry.timer = null;
145613
+ }
145614
+ function timing(entry, outcome) {
145615
+ const end = now();
145616
+ const queuedMs = (entry.sentAt ?? end) - entry.enqueuedAt;
145617
+ const workerMs = entry.sentAt === null ? 0 : end - entry.sentAt;
145618
+ const totalMs = end - entry.enqueuedAt;
145619
+ const extras = {
145620
+ ...entry.deviceId === void 0 ? {} : { tags: { deviceId: entry.deviceId } },
145621
+ meta: {
145622
+ op: entry.request.op,
145623
+ outcome,
145624
+ queuedMs,
145625
+ workerMs,
145626
+ totalMs
145627
+ }
145628
+ };
145629
+ if (totalMs > 5e3) logger.warn("alexa worker request was slow", extras);
145630
+ else logger.debug("alexa worker request", extras);
145631
+ }
145632
+ function settle(entry, outcome, error, result) {
145633
+ clearTimer(entry);
145634
+ timing(entry, outcome);
145635
+ const waiters = entry.waiters;
145636
+ entry.waiters = [];
145637
+ for (const waiter of waiters) if (error === null) waiter.resolve(result);
145638
+ else waiter.reject(error);
145639
+ }
145465
145640
  function failPending(err) {
145466
145641
  const current = pending;
145467
145642
  pending = null;
145468
- current?.reject(err);
145643
+ if (current !== null && !current.abandoned) settle(current, "worker-gone", err);
145469
145644
  }
145470
145645
  function attach(spawned) {
145471
145646
  child = spawned;
@@ -145518,9 +145693,16 @@ function startAlexaWorker(deps) {
145518
145693
  }
145519
145694
  const current = pending;
145520
145695
  pending = null;
145521
- if (message.ok) current.resolve(message.result);
145522
- else if (message.code === "rate-limited") current.reject(new RateLimitedError(scrub(message.error)));
145523
- else current.reject(new Error(scrub(message.error)));
145696
+ if (current.abandoned) logger.debug("alexa worker late reply dropped", {
145697
+ ...current.deviceId === void 0 ? {} : { tags: { deviceId: current.deviceId } },
145698
+ meta: {
145699
+ op: current.request.op,
145700
+ lateMs: now() - current.enqueuedAt
145701
+ }
145702
+ });
145703
+ else if (message.ok) settle(current, "ok", null, message.result);
145704
+ else if (message.code === "rate-limited") settle(current, "rate-limited", new RateLimitedError(scrub(message.error)));
145705
+ else settle(current, "error", new Error(scrub(message.error)));
145524
145706
  pump();
145525
145707
  }
145526
145708
  function onExit(code) {
@@ -145536,7 +145718,7 @@ function startAlexaWorker(deps) {
145536
145718
  failed = true;
145537
145719
  logger.error("alexa worker restart bound reached", { meta: { crashes: crashTimes.length } });
145538
145720
  const waiting = queue.splice(0, queue.length);
145539
- for (const item of waiting) item.pending.reject(/* @__PURE__ */ new Error("alexa worker restart bound reached"));
145721
+ for (const item of waiting) settle(item, "worker-gone", /* @__PURE__ */ new Error("alexa worker restart bound reached"));
145540
145722
  return;
145541
145723
  }
145542
145724
  logger.warn("alexa worker exited; restarting", { meta: {
@@ -145557,23 +145739,74 @@ function startAlexaWorker(deps) {
145557
145739
  if (pending !== null || child === null || failed || stopped) return;
145558
145740
  const next = queue.shift();
145559
145741
  if (next === void 0) return;
145560
- pending = next.pending;
145561
- child.stdin.write(requestLine(next.pending.id, next.request));
145742
+ pending = next;
145743
+ next.sentAt = now();
145744
+ child.stdin.write(requestLine(next.id, next.request));
145745
+ }
145746
+ function expire(entry, deadlineMs) {
145747
+ if (pending === entry) {
145748
+ entry.abandoned = true;
145749
+ settle(entry, "timeout-in-flight", /* @__PURE__ */ new Error(`alexa worker call timed out after ${deadlineMs} ms`));
145750
+ return;
145751
+ }
145752
+ const at = queue.indexOf(entry);
145753
+ if (at < 0) return;
145754
+ queue.splice(at, 1);
145755
+ settle(entry, "expired-queued", /* @__PURE__ */ new Error(`alexa worker call not started within ${deadlineMs} ms`));
145562
145756
  }
145563
- function request(body) {
145757
+ function enqueue(entry) {
145758
+ if (entry.isRead) {
145759
+ queue.push(entry);
145760
+ return;
145761
+ }
145762
+ const firstRead = queue.findIndex((queued) => queued.isRead);
145763
+ if (firstRead < 0) queue.push(entry);
145764
+ else queue.splice(firstRead, 0, entry);
145765
+ }
145766
+ function request(body, options) {
145564
145767
  if (failed) return Promise.reject(/* @__PURE__ */ new Error("alexa worker restart bound reached"));
145565
145768
  if (stopped) return Promise.reject(/* @__PURE__ */ new Error("alexa worker stopped"));
145566
- const id = String(nextId);
145567
- nextId += 1;
145769
+ const key = coalesceKey(body);
145770
+ const isRead = READ_OPS.has(body.op);
145568
145771
  return new Promise((resolve, reject) => {
145569
- queue.push({
145772
+ const waiter = {
145773
+ resolve,
145774
+ reject
145775
+ };
145776
+ const twin = key === null ? void 0 : queue.find((queued) => queued.key === key);
145777
+ if (twin !== void 0 && isRead) {
145778
+ twin.waiters.push(waiter);
145779
+ return;
145780
+ }
145781
+ const id = String(nextId);
145782
+ nextId += 1;
145783
+ const entry = {
145784
+ id,
145570
145785
  request: body,
145571
- pending: {
145572
- id,
145573
- resolve,
145574
- reject
145575
- }
145576
- });
145786
+ key,
145787
+ isRead,
145788
+ deviceId: options?.deviceId,
145789
+ enqueuedAt: now(),
145790
+ sentAt: null,
145791
+ waiters: [waiter],
145792
+ abandoned: false,
145793
+ timer: null
145794
+ };
145795
+ if (twin !== void 0) {
145796
+ queue.splice(queue.indexOf(twin), 1);
145797
+ clearTimer(twin);
145798
+ entry.waiters = [...twin.waiters, waiter];
145799
+ logger.debug("alexa worker queued call replaced by a newer one", {
145800
+ ...twin.deviceId === void 0 ? {} : { tags: { deviceId: twin.deviceId } },
145801
+ meta: { op: body.op }
145802
+ });
145803
+ }
145804
+ const deadlineMs = options?.deadlineMs ?? defaultDeadlineMs(body);
145805
+ if (deadlineMs !== void 0) {
145806
+ entry.timer = setTimeout(() => expire(entry, deadlineMs), deadlineMs);
145807
+ entry.timer.unref();
145808
+ }
145809
+ enqueue(entry);
145577
145810
  if (child === null) launch();
145578
145811
  pump();
145579
145812
  });
@@ -145590,7 +145823,7 @@ function startAlexaWorker(deps) {
145590
145823
  current?.kill("SIGTERM");
145591
145824
  failPending(/* @__PURE__ */ new Error("alexa worker stopped"));
145592
145825
  const waiting = queue.splice(0, queue.length);
145593
- for (const item of waiting) item.pending.reject(/* @__PURE__ */ new Error("alexa worker stopped"));
145826
+ for (const item of waiting) settle(item, "worker-gone", /* @__PURE__ */ new Error("alexa worker stopped"));
145594
145827
  },
145595
145828
  get failed() {
145596
145829
  return failed;
@@ -146569,22 +146802,9 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
146569
146802
  }
146570
146803
  });
146571
146804
  }
146572
- globalSettingsSchema() {
146573
- return this.schema({ sections: [{
146574
- id: "alexa-account",
146575
- title: "Amazon Alexa account",
146576
- titleKey: "import-alexa:globalSettings.s_alexa-account.title",
146577
- description: "Alexa accounts are managed in the External systems → Brokers tab.",
146578
- descriptionKey: "import-alexa:globalSettings.s_alexa-account.description",
146579
- columns: 1,
146580
- fields: [{
146581
- type: "info",
146582
- key: "accountHelp",
146583
- label: "Accounts are managed separately",
146584
- labelKey: "import-alexa:globalSettings.s_alexa-account.accountHelp.label",
146585
- 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."
146586
- }]
146587
- }] });
146805
+ /** The connection form a record carries; the values live on the record, never in `addon-settings`. */
146806
+ async integrationFormSchema(_instance) {
146807
+ return buildConnectionFormSchema();
146588
146808
  }
146589
146809
  async supportsManualCreation() {
146590
146810
  return false;