@camstack/addon-import-alexa 0.2.195 → 0.2.196

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,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.196] - 2026-11-07
11
+
12
+ ### Fixed
13
+
14
+ - An Echo that is part of a playing speaker group shows the group's track and artwork.
15
+
10
16
  ## [0.2.195] - 2026-11-07
11
17
 
12
18
  ### Changed
package/dist/addon.js CHANGED
@@ -6061,7 +6061,7 @@ function number(params) {
6061
6061
  return /* @__PURE__ */ _coercedNumber(ZodNumber, params);
6062
6062
  }
6063
6063
  //#endregion
6064
- //#region ../types/dist/sleep-AEnTN3b4.mjs
6064
+ //#region ../types/dist/sleep-CQP-REJp.mjs
6065
6065
  /**
6066
6066
  * The audio chunk plane's byte format, and the ONE expansion from a coded
6067
6067
  * window to float samples (D455).
@@ -6425,6 +6425,59 @@ Object.fromEntries([
6425
6425
  function isValuelessField(field) {
6426
6426
  return field.type === "separator" || field.type === "info" || field.type === "qr-code" || field.type === "button" || field.type === "object-array" || field.type === "widget" || field.type === "addon-action-button" || field.type === "device-action-button" || field.type === "account-sign-in" || field.type === "secret-presence";
6427
6427
  }
6428
+ /**
6429
+ * Base collector: flatten a hydrated `ConfigUISchemaWithValues` into
6430
+ * `key → { value, default }`, recursing through `group` and `sub-tabs`
6431
+ * layout fields. Keeping `value` and `default` SEPARATE (rather than
6432
+ * pre-resolving) lets callers both read the effective value AND detect
6433
+ * overrides (`value` differs from `default`). Structural over `unknown`;
6434
+ * malformed input yields an empty map, never throws.
6435
+ */
6436
+ function collectHydratedFieldEntries(schema) {
6437
+ const out = /* @__PURE__ */ new Map();
6438
+ if (typeof schema !== "object" || schema === null) return out;
6439
+ const sections = schema.sections;
6440
+ if (!Array.isArray(sections)) return out;
6441
+ for (const section of sections) if (typeof section === "object" && section !== null) collectFieldEntriesInto(section.fields, out);
6442
+ return out;
6443
+ }
6444
+ /** Recursive field walker for {@link collectHydratedFieldEntries}. */
6445
+ function collectFieldEntriesInto(fields, into) {
6446
+ if (!Array.isArray(fields)) return;
6447
+ for (const field of fields) {
6448
+ if (typeof field !== "object" || field === null) continue;
6449
+ const type = field.type;
6450
+ if (type === "group") {
6451
+ collectFieldEntriesInto(field.fields, into);
6452
+ continue;
6453
+ }
6454
+ if (type === "sub-tabs") {
6455
+ const tabs = field.tabs;
6456
+ if (Array.isArray(tabs)) {
6457
+ for (const tab of tabs) if (typeof tab === "object" && tab !== null) collectFieldEntriesInto(tab.fields, into);
6458
+ }
6459
+ continue;
6460
+ }
6461
+ const key = field.key;
6462
+ if (typeof key !== "string" || key.length === 0) continue;
6463
+ if (!("value" in field)) continue;
6464
+ into.set(key, {
6465
+ value: field.value,
6466
+ default: field.default
6467
+ });
6468
+ }
6469
+ }
6470
+ /**
6471
+ * Flatten a hydrated settings payload into `key → EFFECTIVE value`
6472
+ * (`value ?? default`). The read-side inverse of `hydrateSchema` for the
6473
+ * common "just give me the resolved values" case. Built on
6474
+ * {@link collectHydratedFieldEntries}.
6475
+ */
6476
+ function collectHydratedFieldValues(schema) {
6477
+ const out = /* @__PURE__ */ new Map();
6478
+ for (const [key, entry] of collectHydratedFieldEntries(schema)) out.set(key, entry.value ?? entry.default);
6479
+ return out;
6480
+ }
6428
6481
  function hydrateSchema(schema, values, options = {}) {
6429
6482
  return {
6430
6483
  ...schema.tabs ? { tabs: [...schema.tabs] } : {},
@@ -7664,6 +7717,21 @@ var SCOPED_SETTINGS_SCOPES = [
7664
7717
  "device-defaults",
7665
7718
  "node"
7666
7719
  ];
7720
+ /** The one refusal of a key a scope did not declare. */
7721
+ function assertDeclaredKeys(keys, declared) {
7722
+ const undeclared = keys.filter((key) => !declared.has(key));
7723
+ if (undeclared.length > 0) throw new Error(`scoped settings: [${undeclared.join(", ")}] not declared for this scope (declared: [${[...declared].join(", ")}])`);
7724
+ }
7725
+ /** Build the handle over an addon's scoped read, write and unset. */
7726
+ function createScopedSettings(deps) {
7727
+ return {
7728
+ async get() {
7729
+ return deps.schema.parse(await deps.read());
7730
+ },
7731
+ patch: (patch) => deps.write(Object.fromEntries(Object.entries(patch))),
7732
+ unset: (keys) => deps.unset(keys)
7733
+ };
7734
+ }
7667
7735
  /** A write to a secret path could not be stored in the keychain, so it was refused. */
7668
7736
  var KeychainWriteRefusedError = class extends Error {
7669
7737
  fieldPath;
@@ -8239,7 +8307,16 @@ function createHandle(raw, opts) {
8239
8307
  return blob;
8240
8308
  }
8241
8309
  },
8242
- writeAddonStore: writeAddon
8310
+ writeAddonStore: writeAddon,
8311
+ removeClearedKeys: (keys) => serialize("addon", async () => {
8312
+ const stored = await readStoredAddonBlob(raw);
8313
+ index.seen("addon", stored);
8314
+ const removable = keys.filter((key) => collectSecretMarkerRefs(stored[key]).length === 0);
8315
+ if (removable.length === 0) return;
8316
+ const patch = Object.fromEntries(removable.map((key) => [key, void 0]));
8317
+ await raw.writeAddonStore(patch);
8318
+ index.wrote("addon", patch);
8319
+ })
8243
8320
  };
8244
8321
  }
8245
8322
  /** The context the addon sees, plus the private raw view and write outcome. */
@@ -8784,6 +8861,151 @@ var BaseAddon = class {
8784
8861
  }
8785
8862
  }
8786
8863
  /**
8864
+ * Hook: values layered over the stored ones when the FORM of one scope is served
8865
+ * ({@link getScopedSettings}) — a preview that derives dependent options without writing.
8866
+ * Never applied to {@link ScopedSettings.get} or to a write. `null` ⇒ no overlay.
8867
+ */
8868
+ async scopedSettingsOverlay(_scope, _cap, _nodeId) {
8869
+ return null;
8870
+ }
8871
+ /**
8872
+ * Hook: a write of one scope changed stored values (not called when the store reports no change,
8873
+ * e.g. the write-back of a stored secret). `keys` are the bare field keys of the write, node keys included.
8874
+ */
8875
+ async onScopedSettingsChanged(_scope, _cap, _keys) {}
8876
+ /** Typed handle over the CLUSTER settings `cap` declares (`clusterSettingsSchema`). */
8877
+ clusterSettings(cap, schema) {
8878
+ return this.scopedHandle("cluster", cap, schema);
8879
+ }
8880
+ /** Typed handle over the fleet defaults of the per-device settings `cap` declares. */
8881
+ deviceDefaults(cap, schema) {
8882
+ return this.scopedHandle("device-defaults", cap, schema);
8883
+ }
8884
+ /**
8885
+ * Typed handle over the settings `cap` owns PER NODE. `nodeId` omitted ⇒ the local node. A node
8886
+ * with no row yields the schema defaults — never a bare-key fallback.
8887
+ */
8888
+ nodeSettings(cap, schema, nodeId) {
8889
+ return this.scopedHandle("node", cap, schema, nodeId);
8890
+ }
8891
+ /**
8892
+ * {@link state} for a value that is kept per node: stored under `<key>@<nodeId>`, and the
8893
+ * settings store carries `nodeId` in the row data (D986 §1), so a forgotten node's rows go in one
8894
+ * central delete. `nodeId` omitted ⇒ the local node.
8895
+ */
8896
+ nodeState(key, schema, fallback, nodeId) {
8897
+ this._declaredStateKeys.add(key);
8898
+ return createDurableState({
8899
+ key: nodeScopedKey(key, nodeId ?? this._ctx?.kernel?.localNodeId ?? ""),
8900
+ schema,
8901
+ fallback,
8902
+ read: async () => this.resolveStoredSecrets(await this.readAddonStoreWithRetry()),
8903
+ write: async (patch) => {
8904
+ await this._ctx?.settings?.writeAddonStore(patch);
8905
+ },
8906
+ onParseError: (k, e) => this._ctx?.logger?.warn?.(`durable-state: stored "${k}" failed validation — using fallback`, { meta: {
8907
+ addonId: this._ctx?.id,
8908
+ key: k,
8909
+ error: String(e)
8910
+ } })
8911
+ });
8912
+ }
8913
+ /** The loaded schema `cap` declares for `scope`, or `null`. */
8914
+ async declaredScopedSchema(scope, cap) {
8915
+ await this.loadScopedSchemas(cap);
8916
+ return this.loadedScopedSchemas(cap).find((entry) => entry.scope === scope)?.schema ?? null;
8917
+ }
8918
+ async requireScopedSchema(scope, cap) {
8919
+ const schema = await this.declaredScopedSchema(scope, cap);
8920
+ if (schema === null) throw new Error(`${this.constructor.name}: cap "${cap}" declares no ${scope} settings`);
8921
+ return schema;
8922
+ }
8923
+ scopedKeys(schema) {
8924
+ return new Set(schema.sections.flatMap((section) => collectValueFieldKeys(section.fields)));
8925
+ }
8926
+ /** `store` narrowed to the keys `schema` declares. */
8927
+ narrowToScope(store, schema) {
8928
+ const keys = this.scopedKeys(schema);
8929
+ return Object.fromEntries(Object.entries(store).filter(([key]) => keys.has(key)));
8930
+ }
8931
+ scopedHandle(scope, cap, schema, nodeId) {
8932
+ return createScopedSettings({
8933
+ schema,
8934
+ read: async () => {
8935
+ const declared = await this.requireScopedSchema(scope, cap);
8936
+ const stored = this.narrowToScope(await this.resolveGlobalStore(nodeId, cap), declared);
8937
+ const effective = collectHydratedFieldValues(hydrateSchema(declared, stored));
8938
+ return {
8939
+ ...Object.fromEntries([...effective].filter(([, value]) => value !== void 0)),
8940
+ ...stored
8941
+ };
8942
+ },
8943
+ write: (patch) => this.writeScoped(scope, cap, patch, nodeId),
8944
+ unset: (keys) => this.unsetScoped(scope, cap, keys, nodeId)
8945
+ });
8946
+ }
8947
+ /** The one write path of the scoped API: declared keys only, then the global patch. */
8948
+ async writeScoped(scope, cap, patch, nodeId) {
8949
+ assertDeclaredKeys(Object.keys(patch), this.scopedKeys(await this.requireScopedSchema(scope, cap)));
8950
+ if (await this.applyGlobalPatch(patch, scope === "node" ? nodeId : void 0, cap)) await this.onScopedSettingsChanged(scope, cap, Object.keys(patch));
8951
+ }
8952
+ /**
8953
+ * Remove declared keys of one scope. A secret goes through {@link clearSecrets} — its keychain
8954
+ * value is released exactly as there — and the emptied row is then removed; every other key is
8955
+ * removed by an `undefined` patch (which a secret transform would read as "keep").
8956
+ */
8957
+ async unsetScoped(scope, cap, keys, nodeId) {
8958
+ assertDeclaredKeys(keys, this.scopedKeys(await this.requireScopedSchema(scope, cap)));
8959
+ const target = scope === "node" ? nodeId : void 0;
8960
+ const tree = this.addonSecretTree();
8961
+ const secretKeys = keys.filter((key) => tree.has(key));
8962
+ const plainKeys = keys.filter((key) => !tree.has(key));
8963
+ let changed = false;
8964
+ if (plainKeys.length > 0) {
8965
+ const patch = Object.fromEntries(plainKeys.map((key) => [key, void 0]));
8966
+ changed = await this.applyGlobalPatch(patch, target, cap) || changed;
8967
+ }
8968
+ if (secretKeys.length > 0) {
8969
+ await this.clearSecretKeys(secretKeys, target, cap);
8970
+ const node = normalizeNodeId(target ?? this._ctx?.kernel?.localNodeId);
8971
+ const rows = secretKeys.map((key) => scope === "node" ? nodeScopedKey(key, node) : key);
8972
+ if (this._secretSettings !== null) await this._secretSettings.removeClearedKeys(rows);
8973
+ else await this._ctx?.settings?.writeAddonStore(Object.fromEntries(rows.map((row) => [row, void 0])));
8974
+ changed = true;
8975
+ }
8976
+ if (changed) await this.onScopedSettingsChanged(scope, cap, [...keys]);
8977
+ }
8978
+ /**
8979
+ * The hydrated form of one scope of one capability's settings (any
8980
+ * {@link scopedSettingsOverlay} applied), or `null` when the capability declares nothing there.
8981
+ */
8982
+ async getScopedSettings(scope, cap, nodeId) {
8983
+ const schema = await this.declaredScopedSchema(scope, cap);
8984
+ if (schema === null) return null;
8985
+ const stored = this.narrowToScope(await this.readRawProjectedStore(nodeId, cap), schema);
8986
+ const overlay = await this.scopedSettingsOverlay(scope, cap, nodeId);
8987
+ return hydrateSchema(schema, overlay ? await this.overlayKeepingMarkers(stored, overlay) : stored, { inlineSecretsAsViews: true });
8988
+ }
8989
+ /** The OPERATOR's write of declared keys of one scope (D787 secret semantics). */
8990
+ async updateScopedSettings(scope, cap, patch, nodeId) {
8991
+ this._operatorWriteDepth++;
8992
+ try {
8993
+ await this.writeScoped(scope, cap, patch, nodeId);
8994
+ } finally {
8995
+ this._operatorWriteDepth--;
8996
+ }
8997
+ }
8998
+ /**
8999
+ * The effective values (`stored ?? default`) of one scope, a secret as its `set` view — never
9000
+ * its value. `null` when the capability declares nothing for the scope.
9001
+ */
9002
+ async readScopedValues(scope, cap, nodeId) {
9003
+ const schema = await this.declaredScopedSchema(scope, cap);
9004
+ if (schema === null) return null;
9005
+ const form = hydrateSchema(schema, this.narrowToScope(await this.readRawProjectedStore(nodeId, cap), schema), { inlineSecretsAsViews: true });
9006
+ return Object.fromEntries([...collectHydratedFieldValues(form)].filter(([, value]) => value !== void 0));
9007
+ }
9008
+ /**
8787
9009
  * What the create wizard and the integration edit page render for one instance: the addon's
8788
9010
  * per-instance schema when it declares one, else its global schema (what they always showed).
8789
9011
  */
@@ -8935,6 +9157,9 @@ var BaseAddon = class {
8935
9157
  * a keychain (an older kernel) it writes `''`, as such code always did.
8936
9158
  */
8937
9159
  async clearSecrets(keys, nodeId) {
9160
+ await this.clearSecretKeys(keys, nodeId);
9161
+ }
9162
+ async clearSecretKeys(keys, nodeId, cap) {
8938
9163
  const tree = this.addonSecretTree();
8939
9164
  const keychain = this._ctx?.secrets !== void 0;
8940
9165
  const stored = keychain ? await this.readAddonStoreWithRetry() : {};
@@ -8947,25 +9172,27 @@ var BaseAddon = class {
8947
9172
  const keychainStored = tree.has(key) || collectSecretMarkerRefs(stored[key]).length > 0;
8948
9173
  patch[key] = keychain && keychainStored ? SECRET_CLEAR : "";
8949
9174
  }
8950
- await this.applyGlobalPatch(patch, nodeId);
9175
+ await this.applyGlobalPatch(patch, nodeId, cap);
8951
9176
  }
8952
- async applyGlobalPatch(barePatch, nodeId) {
8953
- await this.loadScopedSchemas();
8954
- const keys = this.perNodeKeys();
9177
+ /** Resolves `true` when the write changed stored values. */
9178
+ async applyGlobalPatch(barePatch, nodeId, cap) {
9179
+ await this.loadScopedSchemas(cap);
9180
+ const keys = this.perNodeKeys(cap);
8955
9181
  const localNode = normalizeNodeId(this._ctx?.kernel?.localNodeId);
8956
9182
  const target = normalizeNodeId(nodeId ?? this._ctx?.kernel?.localNodeId);
8957
9183
  const scoped = keys.size > 0 ? scopePatch(barePatch, keys, target) : barePatch;
8958
9184
  const handle = this._secretSettings;
8959
9185
  if (handle !== null) {
8960
9186
  const { changed } = await handle.writeAddonStore(scoped);
8961
- if (!changed) return;
9187
+ if (!changed) return false;
8962
9188
  } else await this._ctx?.settings?.writeAddonStore(scoped);
8963
- if (target !== localNode) return;
9189
+ if (target !== localNode) return true;
8964
9190
  await this.resolveConfig();
8965
9191
  await this.onConfigChanged();
8966
9192
  this.emitLifecycle(EventCategory.AddonUpdated, { level: "global" });
8967
9193
  const legacy = this.globalSettingsSchema();
8968
9194
  this.maybeAutoRestart(Object.keys(barePatch), [...legacy === null ? [] : [legacy], ...this.loadedScopedSchemas().map((entry) => entry.schema)]);
9195
+ return true;
8969
9196
  }
8970
9197
  /**
8971
9198
  * The set of field keys the global settings schema declares `perNode: true`
@@ -45494,6 +45721,8 @@ var SettingsSchemaWithValuesSchema = object({
45494
45721
  });
45495
45722
  /** Patch object — keys are field names, values are the new field values. */
45496
45723
  var SettingsPatchSchema = record(string(), unknown());
45724
+ /** The scope of a scoped-settings call (D986): where the capability declared the values. */
45725
+ var ScopedSettingsScopeSchema = _enum(SCOPED_SETTINGS_SCOPES);
45497
45726
  /** Standard success response for update operations. */
45498
45727
  var SettingsUpdateResultSchema = object({ success: literal(true) });
45499
45728
  method(object({
@@ -45509,6 +45738,25 @@ method(object({
45509
45738
  kind: "mutation",
45510
45739
  auth: "admin"
45511
45740
  }), method(object({
45741
+ addonId: string(),
45742
+ cap: string(),
45743
+ scope: ScopedSettingsScopeSchema,
45744
+ nodeId: string().optional()
45745
+ }), SettingsSchemaWithValuesSchema.nullable()), method(object({
45746
+ addonId: string(),
45747
+ cap: string(),
45748
+ scope: ScopedSettingsScopeSchema,
45749
+ nodeId: string().optional(),
45750
+ patch: SettingsPatchSchema
45751
+ }), SettingsUpdateResultSchema, {
45752
+ kind: "mutation",
45753
+ auth: "admin"
45754
+ }), method(object({
45755
+ addonId: string(),
45756
+ cap: string(),
45757
+ scope: ScopedSettingsScopeSchema,
45758
+ nodeId: string().optional()
45759
+ }), record(string(), unknown()).nullable()), method(object({
45512
45760
  addonId: string(),
45513
45761
  nodeId: string().optional()
45514
45762
  }), SettingsSchemaWithValuesSchema.nullable()), method(object({
@@ -78500,7 +78748,7 @@ var TerminalInstanceInfoSchema = object({
78500
78748
  /**
78501
78749
  * False while the node the instance is bound to is not in the cluster
78502
78750
  * topology (forgotten, renamed, re-paired). The instance is kept; the
78503
- * operator re-points it with `updateInstance({ targetNodeId })`.
78751
+ * operator re-points it on the Terminal page.
78504
78752
  */
78505
78753
  nodeInCluster: boolean(),
78506
78754
  profileId: string(),
@@ -78538,41 +78786,7 @@ var TerminalOutputBatchSchema = object({
78538
78786
  snapshot: string().optional(),
78539
78787
  events: array(TerminalOutputEventSchema).readonly()
78540
78788
  });
78541
- method(_void(), array(TerminalProfileInfoSchema).readonly(), { auth: "admin" }), method(_void(), array(TerminalInstanceInfoSchema).readonly(), { auth: "admin" }), method(object({
78542
- targetNodeId: string().min(1),
78543
- profileId: string().min(1),
78544
- name: string().trim().min(1).max(160).optional(),
78545
- executable: string().max(1024).optional(),
78546
- args: array(string().max(2048)).max(64).optional(),
78547
- cwd: string().max(1024).optional(),
78548
- environment: array(string().max(4096)).max(64).optional(),
78549
- profileSettings: ProfileSettingsBagSchema.optional()
78550
- }), TerminalInstanceInfoSchema, {
78551
- kind: "mutation",
78552
- auth: "admin"
78553
- }), method(object({
78554
- instanceId: string().min(1),
78555
- /** Re-point the instance to another node; the profile must exist there. */
78556
- targetNodeId: string().min(1).max(256).optional(),
78557
- name: string().trim().min(1).max(160).optional(),
78558
- executable: string().max(1024).optional(),
78559
- args: array(string().max(2048)).max(64).optional(),
78560
- cwd: string().max(1024).optional(),
78561
- environment: array(string().max(4096)).max(64).optional(),
78562
- profileSettings: ProfileSettingsBagSchema.optional()
78563
- }), TerminalInstanceInfoSchema, {
78564
- kind: "mutation",
78565
- auth: "admin"
78566
- }), method(object({ instanceId: string().min(1) }), _void(), {
78567
- kind: "mutation",
78568
- auth: "admin"
78569
- }), method(object({
78570
- instanceId: string().min(1),
78571
- enabled: boolean()
78572
- }), TerminalInstanceInfoSchema, {
78573
- kind: "mutation",
78574
- auth: "admin"
78575
- }), method(_void(), array(TerminalLegacyCameraSchema).readonly(), { auth: "admin" }), method(object({
78789
+ method(_void(), array(TerminalProfileInfoSchema).readonly(), { auth: "admin" }), method(_void(), array(TerminalLegacyCameraSchema).readonly(), { auth: "admin" }), method(object({
78576
78790
  stableId: string().min(1),
78577
78791
  name: string().trim().min(1).max(160).optional()
78578
78792
  }), TerminalInstanceInfoSchema, {
@@ -83392,6 +83606,20 @@ Object.freeze({
83392
83606
  access: "view",
83393
83607
  role: "admin"
83394
83608
  },
83609
+ "addonSettings.getScopedSettings": {
83610
+ capName: "addon-settings",
83611
+ capScope: "system",
83612
+ addonId: null,
83613
+ access: "view",
83614
+ role: "admin"
83615
+ },
83616
+ "addonSettings.readScopedValues": {
83617
+ capName: "addon-settings",
83618
+ capScope: "system",
83619
+ addonId: null,
83620
+ access: "view",
83621
+ role: "admin"
83622
+ },
83395
83623
  "addonSettings.unsetStoredSettingsKeys": {
83396
83624
  capName: "addon-settings",
83397
83625
  capScope: "system",
@@ -83413,6 +83641,13 @@ Object.freeze({
83413
83641
  access: "create",
83414
83642
  role: "admin"
83415
83643
  },
83644
+ "addonSettings.updateScopedSettings": {
83645
+ capName: "addon-settings",
83646
+ capScope: "system",
83647
+ addonId: null,
83648
+ access: "create",
83649
+ role: "admin"
83650
+ },
83416
83651
  "addonWidgets.listWidgets": {
83417
83652
  capName: "addon-widgets",
83418
83653
  capScope: "system",
@@ -92303,27 +92538,6 @@ Object.freeze({
92303
92538
  access: "create",
92304
92539
  role: "admin"
92305
92540
  },
92306
- "terminalSession.createInstance": {
92307
- capName: "terminal-session",
92308
- capScope: "system",
92309
- addonId: null,
92310
- access: "create",
92311
- role: "admin"
92312
- },
92313
- "terminalSession.deleteInstance": {
92314
- capName: "terminal-session",
92315
- capScope: "system",
92316
- addonId: null,
92317
- access: "delete",
92318
- role: "admin"
92319
- },
92320
- "terminalSession.listInstances": {
92321
- capName: "terminal-session",
92322
- capScope: "system",
92323
- addonId: null,
92324
- access: "view",
92325
- role: "admin"
92326
- },
92327
92541
  "terminalSession.listLegacyCameras": {
92328
92542
  capName: "terminal-session",
92329
92543
  capScope: "system",
@@ -92366,20 +92580,6 @@ Object.freeze({
92366
92580
  access: "create",
92367
92581
  role: "admin"
92368
92582
  },
92369
- "terminalSession.setInstanceEnabled": {
92370
- capName: "terminal-session",
92371
- capScope: "system",
92372
- addonId: null,
92373
- access: "create",
92374
- role: "admin"
92375
- },
92376
- "terminalSession.updateInstance": {
92377
- capName: "terminal-session",
92378
- capScope: "system",
92379
- addonId: null,
92380
- access: "create",
92381
- role: "admin"
92382
- },
92383
92583
  "terminalSession.writeInput": {
92384
92584
  capName: "terminal-session",
92385
92585
  capScope: "system",
@@ -144132,6 +144332,7 @@ var EchoSessionRegistry = class {
144132
144332
  #resolve = null;
144133
144333
  #dnd = /* @__PURE__ */ new Map();
144134
144334
  #volumes = /* @__PURE__ */ new Map();
144335
+ #groupPlayers = /* @__PURE__ */ new Map();
144135
144336
  setResolver(fn) {
144136
144337
  this.#resolve = fn;
144137
144338
  }
@@ -144170,10 +144371,35 @@ var EchoSessionRegistry = class {
144170
144371
  });
144171
144372
  return pending;
144172
144373
  }
144374
+ /**
144375
+ * The player state of a speaker group, shared by its members within one poll cycle.
144376
+ * A failed read is not kept for the next member.
144377
+ */
144378
+ groupPlayer(brokerId, group, now, deviceId) {
144379
+ const key = `${brokerId}\u0000${group.id}`;
144380
+ const shared = this.#groupPlayers.get(key);
144381
+ if (shared !== void 0 && now - shared.at < 25e3) return shared.pending;
144382
+ const session = this.get(brokerId);
144383
+ if (session === null) return Promise.reject(/* @__PURE__ */ new Error("no Echo session for this account"));
144384
+ const pending = session.playerState(group.id, group.deviceType, deviceId);
144385
+ this.#groupPlayers.set(key, {
144386
+ at: now,
144387
+ pending
144388
+ });
144389
+ pending.catch(() => {
144390
+ if (this.#groupPlayers.get(key)?.pending === pending) this.#groupPlayers.delete(key);
144391
+ });
144392
+ return pending;
144393
+ }
144173
144394
  /** Forget the shared volume list: a list fetched before a set must not answer a poll after it. */
144174
144395
  invalidateVolumes(brokerId) {
144175
144396
  this.#volumes.delete(brokerId);
144176
144397
  }
144398
+ /** Forget the shared group players: a transport command or a push makes a cached group answer stale. */
144399
+ invalidateGroupPlayers(brokerId) {
144400
+ const prefix = `${brokerId}\u0000`;
144401
+ for (const key of [...this.#groupPlayers.keys()]) if (key.startsWith(prefix)) this.#groupPlayers.delete(key);
144402
+ }
144177
144403
  /** Forget the shared Do Not Disturb list, for the same reason. */
144178
144404
  invalidateDnd(brokerId) {
144179
144405
  this.#dnd.delete(brokerId);
@@ -144189,6 +144415,7 @@ var EchoSessionRegistry = class {
144189
144415
  }
144190
144416
  event(brokerId, serial) {
144191
144417
  this.invalidateVolumes(brokerId);
144418
+ this.invalidateGroupPlayers(brokerId);
144192
144419
  this.invalidateDnd(brokerId);
144193
144420
  for (const listener of [...this.#pushListeners.get(`${brokerId}\u0000${serial}`) ?? []]) listener();
144194
144421
  }
@@ -144207,6 +144434,7 @@ var EchoSessionRegistry = class {
144207
144434
  this.#resolve = null;
144208
144435
  this.#dnd.clear();
144209
144436
  this.#volumes.clear();
144437
+ this.#groupPlayers.clear();
144210
144438
  }
144211
144439
  };
144212
144440
  function subscribe(map, key, listener) {
@@ -144249,6 +144477,11 @@ function mapEchoPlaybackState(value) {
144249
144477
  default: return "idle";
144250
144478
  }
144251
144479
  }
144480
+ /** Whether a player answer carries any playback: an active state, a text or artwork. */
144481
+ function hasPlayback(info) {
144482
+ if (mapEchoPlaybackState(info.state) !== "idle") return true;
144483
+ return nonEmpty(info.infoText?.title) !== null || nonEmpty(info.mainArt?.url) !== null;
144484
+ }
144252
144485
  function percent(value) {
144253
144486
  if (typeof value !== "number" || !Number.isFinite(value)) return null;
144254
144487
  return Math.round(Math.max(0, Math.min(100, value)));
@@ -144338,7 +144571,9 @@ var EchoDeviceSchema = object({
144338
144571
  dnd: boolean(),
144339
144572
  deviceType: string(),
144340
144573
  /** The room the account files the device under, when Amazon names one. */
144341
- room: string().optional()
144574
+ room: string().optional(),
144575
+ /** Serials of the speaker groups this device belongs to (Amazon's `parentClusters`); absent when none. */
144576
+ parentClusters: array(string()).optional()
144342
144577
  });
144343
144578
  var LoginStatusSchema = _enum(["ok", "failed"]);
144344
144579
  var SessionStatusSchema = object({
@@ -144567,6 +144802,8 @@ var AlexaEchoDevice = class extends BaseDevice {
144567
144802
  unsubscribers = [];
144568
144803
  polling = false;
144569
144804
  failureLogged = false;
144805
+ /** The name of the speaker group whose playback this Echo shows; null when it plays on its own. */
144806
+ viaGroup = null;
144570
144807
  volumeFailureLogged = false;
144571
144808
  health = "ok";
144572
144809
  /** When this device last wrote its volume / Do Not Disturb optimistically; a read that began earlier must not overwrite it. */
@@ -144694,9 +144931,11 @@ var AlexaEchoDevice = class extends BaseDevice {
144694
144931
  }
144695
144932
  const deviceType = this.amazonDeviceType !== "" ? this.amazonDeviceType : listed?.deviceType ?? "";
144696
144933
  try {
144697
- const fromPlayer = mapPlayerInfoToStatus(await session.playerState(this.serial, deviceType, this.id), {
144934
+ const own = await session.playerState(this.serial, deviceType, this.id);
144935
+ const now = Date.now();
144936
+ const fromPlayer = mapPlayerInfoToStatus(await this.withGroupPlayback(own, listed, session), {
144698
144937
  online: true,
144699
- now: Date.now()
144938
+ now
144700
144939
  });
144701
144940
  const read = await this.completeVolume(fromPlayer);
144702
144941
  const kept = this.keepNewerVolume(read, readStartedAt);
@@ -144711,6 +144950,49 @@ var AlexaEchoDevice = class extends BaseDevice {
144711
144950
  await this.readDoNotDisturb(readStartedAt);
144712
144951
  }
144713
144952
  /**
144953
+ * A member of a playing speaker group is answered empty by its own player read while
144954
+ * the group plays. When the member's answer carries no playback, serve the first
144955
+ * parent group that is playing or paused (its track and artwork; the volume stays the
144956
+ * member's own). A failed group read leaves the member's own answer.
144957
+ */
144958
+ async withGroupPlayback(own, listed, session) {
144959
+ const parents = listed?.parentClusters ?? [];
144960
+ if (hasPlayback(own) || parents.length === 0) {
144961
+ this.noteViaGroup(null);
144962
+ return own;
144963
+ }
144964
+ for (const parentId of parents) {
144965
+ const group = session.devices.find((device) => device.id === parentId);
144966
+ if (group === void 0 || !group.online) continue;
144967
+ try {
144968
+ const groupInfo = await alexaEchoSessions.groupPlayer(this.brokerId, group, Date.now(), this.id);
144969
+ if (groupInfo.state === "PLAYING" || groupInfo.state === "PAUSED" || groupInfo.state === "BUFFERING") {
144970
+ this.noteViaGroup(group.name);
144971
+ return {
144972
+ ...groupInfo,
144973
+ volume: own.volume
144974
+ };
144975
+ }
144976
+ } catch (err) {
144977
+ this.ctx.logger.debug("alexa echo: the group player could not be read", {
144978
+ tags: { deviceId: this.id },
144979
+ meta: {
144980
+ group: group.name,
144981
+ error: err instanceof Error ? err.message : String(err)
144982
+ }
144983
+ });
144984
+ }
144985
+ }
144986
+ this.noteViaGroup(null);
144987
+ return own;
144988
+ }
144989
+ /** Log each transition into and out of "playing via a group" once. */
144990
+ noteViaGroup(name) {
144991
+ if (name === this.viaGroup) return;
144992
+ this.viaGroup = name;
144993
+ this.ctx.logger.info(name === null ? "alexa echo: no longer playing via a group" : `alexa echo: playing via group ${name}`, { tags: { deviceId: this.id } });
144994
+ }
144995
+ /**
144714
144996
  * An idle Echo's player read carries no volume. Ask the account's volume list
144715
144997
  * (shared by every Echo of the account in this cycle); a failed read leaves null.
144716
144998
  */
@@ -144782,6 +145064,7 @@ var AlexaEchoDevice = class extends BaseDevice {
144782
145064
  }
144783
145065
  async command(command) {
144784
145066
  await this.session().npCommand(this.serial, command, this.id);
145067
+ alexaEchoSessions.invalidateGroupPlayers(this.brokerId);
144785
145068
  this.rereadSoon();
144786
145069
  }
144787
145070
  /**
package/dist/addon.mjs CHANGED
@@ -6059,7 +6059,7 @@ function number(params) {
6059
6059
  return /* @__PURE__ */ _coercedNumber(ZodNumber, params);
6060
6060
  }
6061
6061
  //#endregion
6062
- //#region ../types/dist/sleep-AEnTN3b4.mjs
6062
+ //#region ../types/dist/sleep-CQP-REJp.mjs
6063
6063
  /**
6064
6064
  * The audio chunk plane's byte format, and the ONE expansion from a coded
6065
6065
  * window to float samples (D455).
@@ -6423,6 +6423,59 @@ Object.fromEntries([
6423
6423
  function isValuelessField(field) {
6424
6424
  return field.type === "separator" || field.type === "info" || field.type === "qr-code" || field.type === "button" || field.type === "object-array" || field.type === "widget" || field.type === "addon-action-button" || field.type === "device-action-button" || field.type === "account-sign-in" || field.type === "secret-presence";
6425
6425
  }
6426
+ /**
6427
+ * Base collector: flatten a hydrated `ConfigUISchemaWithValues` into
6428
+ * `key → { value, default }`, recursing through `group` and `sub-tabs`
6429
+ * layout fields. Keeping `value` and `default` SEPARATE (rather than
6430
+ * pre-resolving) lets callers both read the effective value AND detect
6431
+ * overrides (`value` differs from `default`). Structural over `unknown`;
6432
+ * malformed input yields an empty map, never throws.
6433
+ */
6434
+ function collectHydratedFieldEntries(schema) {
6435
+ const out = /* @__PURE__ */ new Map();
6436
+ if (typeof schema !== "object" || schema === null) return out;
6437
+ const sections = schema.sections;
6438
+ if (!Array.isArray(sections)) return out;
6439
+ for (const section of sections) if (typeof section === "object" && section !== null) collectFieldEntriesInto(section.fields, out);
6440
+ return out;
6441
+ }
6442
+ /** Recursive field walker for {@link collectHydratedFieldEntries}. */
6443
+ function collectFieldEntriesInto(fields, into) {
6444
+ if (!Array.isArray(fields)) return;
6445
+ for (const field of fields) {
6446
+ if (typeof field !== "object" || field === null) continue;
6447
+ const type = field.type;
6448
+ if (type === "group") {
6449
+ collectFieldEntriesInto(field.fields, into);
6450
+ continue;
6451
+ }
6452
+ if (type === "sub-tabs") {
6453
+ const tabs = field.tabs;
6454
+ if (Array.isArray(tabs)) {
6455
+ for (const tab of tabs) if (typeof tab === "object" && tab !== null) collectFieldEntriesInto(tab.fields, into);
6456
+ }
6457
+ continue;
6458
+ }
6459
+ const key = field.key;
6460
+ if (typeof key !== "string" || key.length === 0) continue;
6461
+ if (!("value" in field)) continue;
6462
+ into.set(key, {
6463
+ value: field.value,
6464
+ default: field.default
6465
+ });
6466
+ }
6467
+ }
6468
+ /**
6469
+ * Flatten a hydrated settings payload into `key → EFFECTIVE value`
6470
+ * (`value ?? default`). The read-side inverse of `hydrateSchema` for the
6471
+ * common "just give me the resolved values" case. Built on
6472
+ * {@link collectHydratedFieldEntries}.
6473
+ */
6474
+ function collectHydratedFieldValues(schema) {
6475
+ const out = /* @__PURE__ */ new Map();
6476
+ for (const [key, entry] of collectHydratedFieldEntries(schema)) out.set(key, entry.value ?? entry.default);
6477
+ return out;
6478
+ }
6426
6479
  function hydrateSchema(schema, values, options = {}) {
6427
6480
  return {
6428
6481
  ...schema.tabs ? { tabs: [...schema.tabs] } : {},
@@ -7662,6 +7715,21 @@ var SCOPED_SETTINGS_SCOPES = [
7662
7715
  "device-defaults",
7663
7716
  "node"
7664
7717
  ];
7718
+ /** The one refusal of a key a scope did not declare. */
7719
+ function assertDeclaredKeys(keys, declared) {
7720
+ const undeclared = keys.filter((key) => !declared.has(key));
7721
+ if (undeclared.length > 0) throw new Error(`scoped settings: [${undeclared.join(", ")}] not declared for this scope (declared: [${[...declared].join(", ")}])`);
7722
+ }
7723
+ /** Build the handle over an addon's scoped read, write and unset. */
7724
+ function createScopedSettings(deps) {
7725
+ return {
7726
+ async get() {
7727
+ return deps.schema.parse(await deps.read());
7728
+ },
7729
+ patch: (patch) => deps.write(Object.fromEntries(Object.entries(patch))),
7730
+ unset: (keys) => deps.unset(keys)
7731
+ };
7732
+ }
7665
7733
  /** A write to a secret path could not be stored in the keychain, so it was refused. */
7666
7734
  var KeychainWriteRefusedError = class extends Error {
7667
7735
  fieldPath;
@@ -8237,7 +8305,16 @@ function createHandle(raw, opts) {
8237
8305
  return blob;
8238
8306
  }
8239
8307
  },
8240
- writeAddonStore: writeAddon
8308
+ writeAddonStore: writeAddon,
8309
+ removeClearedKeys: (keys) => serialize("addon", async () => {
8310
+ const stored = await readStoredAddonBlob(raw);
8311
+ index.seen("addon", stored);
8312
+ const removable = keys.filter((key) => collectSecretMarkerRefs(stored[key]).length === 0);
8313
+ if (removable.length === 0) return;
8314
+ const patch = Object.fromEntries(removable.map((key) => [key, void 0]));
8315
+ await raw.writeAddonStore(patch);
8316
+ index.wrote("addon", patch);
8317
+ })
8241
8318
  };
8242
8319
  }
8243
8320
  /** The context the addon sees, plus the private raw view and write outcome. */
@@ -8782,6 +8859,151 @@ var BaseAddon = class {
8782
8859
  }
8783
8860
  }
8784
8861
  /**
8862
+ * Hook: values layered over the stored ones when the FORM of one scope is served
8863
+ * ({@link getScopedSettings}) — a preview that derives dependent options without writing.
8864
+ * Never applied to {@link ScopedSettings.get} or to a write. `null` ⇒ no overlay.
8865
+ */
8866
+ async scopedSettingsOverlay(_scope, _cap, _nodeId) {
8867
+ return null;
8868
+ }
8869
+ /**
8870
+ * Hook: a write of one scope changed stored values (not called when the store reports no change,
8871
+ * e.g. the write-back of a stored secret). `keys` are the bare field keys of the write, node keys included.
8872
+ */
8873
+ async onScopedSettingsChanged(_scope, _cap, _keys) {}
8874
+ /** Typed handle over the CLUSTER settings `cap` declares (`clusterSettingsSchema`). */
8875
+ clusterSettings(cap, schema) {
8876
+ return this.scopedHandle("cluster", cap, schema);
8877
+ }
8878
+ /** Typed handle over the fleet defaults of the per-device settings `cap` declares. */
8879
+ deviceDefaults(cap, schema) {
8880
+ return this.scopedHandle("device-defaults", cap, schema);
8881
+ }
8882
+ /**
8883
+ * Typed handle over the settings `cap` owns PER NODE. `nodeId` omitted ⇒ the local node. A node
8884
+ * with no row yields the schema defaults — never a bare-key fallback.
8885
+ */
8886
+ nodeSettings(cap, schema, nodeId) {
8887
+ return this.scopedHandle("node", cap, schema, nodeId);
8888
+ }
8889
+ /**
8890
+ * {@link state} for a value that is kept per node: stored under `<key>@<nodeId>`, and the
8891
+ * settings store carries `nodeId` in the row data (D986 §1), so a forgotten node's rows go in one
8892
+ * central delete. `nodeId` omitted ⇒ the local node.
8893
+ */
8894
+ nodeState(key, schema, fallback, nodeId) {
8895
+ this._declaredStateKeys.add(key);
8896
+ return createDurableState({
8897
+ key: nodeScopedKey(key, nodeId ?? this._ctx?.kernel?.localNodeId ?? ""),
8898
+ schema,
8899
+ fallback,
8900
+ read: async () => this.resolveStoredSecrets(await this.readAddonStoreWithRetry()),
8901
+ write: async (patch) => {
8902
+ await this._ctx?.settings?.writeAddonStore(patch);
8903
+ },
8904
+ onParseError: (k, e) => this._ctx?.logger?.warn?.(`durable-state: stored "${k}" failed validation — using fallback`, { meta: {
8905
+ addonId: this._ctx?.id,
8906
+ key: k,
8907
+ error: String(e)
8908
+ } })
8909
+ });
8910
+ }
8911
+ /** The loaded schema `cap` declares for `scope`, or `null`. */
8912
+ async declaredScopedSchema(scope, cap) {
8913
+ await this.loadScopedSchemas(cap);
8914
+ return this.loadedScopedSchemas(cap).find((entry) => entry.scope === scope)?.schema ?? null;
8915
+ }
8916
+ async requireScopedSchema(scope, cap) {
8917
+ const schema = await this.declaredScopedSchema(scope, cap);
8918
+ if (schema === null) throw new Error(`${this.constructor.name}: cap "${cap}" declares no ${scope} settings`);
8919
+ return schema;
8920
+ }
8921
+ scopedKeys(schema) {
8922
+ return new Set(schema.sections.flatMap((section) => collectValueFieldKeys(section.fields)));
8923
+ }
8924
+ /** `store` narrowed to the keys `schema` declares. */
8925
+ narrowToScope(store, schema) {
8926
+ const keys = this.scopedKeys(schema);
8927
+ return Object.fromEntries(Object.entries(store).filter(([key]) => keys.has(key)));
8928
+ }
8929
+ scopedHandle(scope, cap, schema, nodeId) {
8930
+ return createScopedSettings({
8931
+ schema,
8932
+ read: async () => {
8933
+ const declared = await this.requireScopedSchema(scope, cap);
8934
+ const stored = this.narrowToScope(await this.resolveGlobalStore(nodeId, cap), declared);
8935
+ const effective = collectHydratedFieldValues(hydrateSchema(declared, stored));
8936
+ return {
8937
+ ...Object.fromEntries([...effective].filter(([, value]) => value !== void 0)),
8938
+ ...stored
8939
+ };
8940
+ },
8941
+ write: (patch) => this.writeScoped(scope, cap, patch, nodeId),
8942
+ unset: (keys) => this.unsetScoped(scope, cap, keys, nodeId)
8943
+ });
8944
+ }
8945
+ /** The one write path of the scoped API: declared keys only, then the global patch. */
8946
+ async writeScoped(scope, cap, patch, nodeId) {
8947
+ assertDeclaredKeys(Object.keys(patch), this.scopedKeys(await this.requireScopedSchema(scope, cap)));
8948
+ if (await this.applyGlobalPatch(patch, scope === "node" ? nodeId : void 0, cap)) await this.onScopedSettingsChanged(scope, cap, Object.keys(patch));
8949
+ }
8950
+ /**
8951
+ * Remove declared keys of one scope. A secret goes through {@link clearSecrets} — its keychain
8952
+ * value is released exactly as there — and the emptied row is then removed; every other key is
8953
+ * removed by an `undefined` patch (which a secret transform would read as "keep").
8954
+ */
8955
+ async unsetScoped(scope, cap, keys, nodeId) {
8956
+ assertDeclaredKeys(keys, this.scopedKeys(await this.requireScopedSchema(scope, cap)));
8957
+ const target = scope === "node" ? nodeId : void 0;
8958
+ const tree = this.addonSecretTree();
8959
+ const secretKeys = keys.filter((key) => tree.has(key));
8960
+ const plainKeys = keys.filter((key) => !tree.has(key));
8961
+ let changed = false;
8962
+ if (plainKeys.length > 0) {
8963
+ const patch = Object.fromEntries(plainKeys.map((key) => [key, void 0]));
8964
+ changed = await this.applyGlobalPatch(patch, target, cap) || changed;
8965
+ }
8966
+ if (secretKeys.length > 0) {
8967
+ await this.clearSecretKeys(secretKeys, target, cap);
8968
+ const node = normalizeNodeId(target ?? this._ctx?.kernel?.localNodeId);
8969
+ const rows = secretKeys.map((key) => scope === "node" ? nodeScopedKey(key, node) : key);
8970
+ if (this._secretSettings !== null) await this._secretSettings.removeClearedKeys(rows);
8971
+ else await this._ctx?.settings?.writeAddonStore(Object.fromEntries(rows.map((row) => [row, void 0])));
8972
+ changed = true;
8973
+ }
8974
+ if (changed) await this.onScopedSettingsChanged(scope, cap, [...keys]);
8975
+ }
8976
+ /**
8977
+ * The hydrated form of one scope of one capability's settings (any
8978
+ * {@link scopedSettingsOverlay} applied), or `null` when the capability declares nothing there.
8979
+ */
8980
+ async getScopedSettings(scope, cap, nodeId) {
8981
+ const schema = await this.declaredScopedSchema(scope, cap);
8982
+ if (schema === null) return null;
8983
+ const stored = this.narrowToScope(await this.readRawProjectedStore(nodeId, cap), schema);
8984
+ const overlay = await this.scopedSettingsOverlay(scope, cap, nodeId);
8985
+ return hydrateSchema(schema, overlay ? await this.overlayKeepingMarkers(stored, overlay) : stored, { inlineSecretsAsViews: true });
8986
+ }
8987
+ /** The OPERATOR's write of declared keys of one scope (D787 secret semantics). */
8988
+ async updateScopedSettings(scope, cap, patch, nodeId) {
8989
+ this._operatorWriteDepth++;
8990
+ try {
8991
+ await this.writeScoped(scope, cap, patch, nodeId);
8992
+ } finally {
8993
+ this._operatorWriteDepth--;
8994
+ }
8995
+ }
8996
+ /**
8997
+ * The effective values (`stored ?? default`) of one scope, a secret as its `set` view — never
8998
+ * its value. `null` when the capability declares nothing for the scope.
8999
+ */
9000
+ async readScopedValues(scope, cap, nodeId) {
9001
+ const schema = await this.declaredScopedSchema(scope, cap);
9002
+ if (schema === null) return null;
9003
+ const form = hydrateSchema(schema, this.narrowToScope(await this.readRawProjectedStore(nodeId, cap), schema), { inlineSecretsAsViews: true });
9004
+ return Object.fromEntries([...collectHydratedFieldValues(form)].filter(([, value]) => value !== void 0));
9005
+ }
9006
+ /**
8785
9007
  * What the create wizard and the integration edit page render for one instance: the addon's
8786
9008
  * per-instance schema when it declares one, else its global schema (what they always showed).
8787
9009
  */
@@ -8933,6 +9155,9 @@ var BaseAddon = class {
8933
9155
  * a keychain (an older kernel) it writes `''`, as such code always did.
8934
9156
  */
8935
9157
  async clearSecrets(keys, nodeId) {
9158
+ await this.clearSecretKeys(keys, nodeId);
9159
+ }
9160
+ async clearSecretKeys(keys, nodeId, cap) {
8936
9161
  const tree = this.addonSecretTree();
8937
9162
  const keychain = this._ctx?.secrets !== void 0;
8938
9163
  const stored = keychain ? await this.readAddonStoreWithRetry() : {};
@@ -8945,25 +9170,27 @@ var BaseAddon = class {
8945
9170
  const keychainStored = tree.has(key) || collectSecretMarkerRefs(stored[key]).length > 0;
8946
9171
  patch[key] = keychain && keychainStored ? SECRET_CLEAR : "";
8947
9172
  }
8948
- await this.applyGlobalPatch(patch, nodeId);
9173
+ await this.applyGlobalPatch(patch, nodeId, cap);
8949
9174
  }
8950
- async applyGlobalPatch(barePatch, nodeId) {
8951
- await this.loadScopedSchemas();
8952
- const keys = this.perNodeKeys();
9175
+ /** Resolves `true` when the write changed stored values. */
9176
+ async applyGlobalPatch(barePatch, nodeId, cap) {
9177
+ await this.loadScopedSchemas(cap);
9178
+ const keys = this.perNodeKeys(cap);
8953
9179
  const localNode = normalizeNodeId(this._ctx?.kernel?.localNodeId);
8954
9180
  const target = normalizeNodeId(nodeId ?? this._ctx?.kernel?.localNodeId);
8955
9181
  const scoped = keys.size > 0 ? scopePatch(barePatch, keys, target) : barePatch;
8956
9182
  const handle = this._secretSettings;
8957
9183
  if (handle !== null) {
8958
9184
  const { changed } = await handle.writeAddonStore(scoped);
8959
- if (!changed) return;
9185
+ if (!changed) return false;
8960
9186
  } else await this._ctx?.settings?.writeAddonStore(scoped);
8961
- if (target !== localNode) return;
9187
+ if (target !== localNode) return true;
8962
9188
  await this.resolveConfig();
8963
9189
  await this.onConfigChanged();
8964
9190
  this.emitLifecycle(EventCategory.AddonUpdated, { level: "global" });
8965
9191
  const legacy = this.globalSettingsSchema();
8966
9192
  this.maybeAutoRestart(Object.keys(barePatch), [...legacy === null ? [] : [legacy], ...this.loadedScopedSchemas().map((entry) => entry.schema)]);
9193
+ return true;
8967
9194
  }
8968
9195
  /**
8969
9196
  * The set of field keys the global settings schema declares `perNode: true`
@@ -45492,6 +45719,8 @@ var SettingsSchemaWithValuesSchema = object({
45492
45719
  });
45493
45720
  /** Patch object — keys are field names, values are the new field values. */
45494
45721
  var SettingsPatchSchema = record(string(), unknown());
45722
+ /** The scope of a scoped-settings call (D986): where the capability declared the values. */
45723
+ var ScopedSettingsScopeSchema = _enum(SCOPED_SETTINGS_SCOPES);
45495
45724
  /** Standard success response for update operations. */
45496
45725
  var SettingsUpdateResultSchema = object({ success: literal(true) });
45497
45726
  method(object({
@@ -45507,6 +45736,25 @@ method(object({
45507
45736
  kind: "mutation",
45508
45737
  auth: "admin"
45509
45738
  }), method(object({
45739
+ addonId: string(),
45740
+ cap: string(),
45741
+ scope: ScopedSettingsScopeSchema,
45742
+ nodeId: string().optional()
45743
+ }), SettingsSchemaWithValuesSchema.nullable()), method(object({
45744
+ addonId: string(),
45745
+ cap: string(),
45746
+ scope: ScopedSettingsScopeSchema,
45747
+ nodeId: string().optional(),
45748
+ patch: SettingsPatchSchema
45749
+ }), SettingsUpdateResultSchema, {
45750
+ kind: "mutation",
45751
+ auth: "admin"
45752
+ }), method(object({
45753
+ addonId: string(),
45754
+ cap: string(),
45755
+ scope: ScopedSettingsScopeSchema,
45756
+ nodeId: string().optional()
45757
+ }), record(string(), unknown()).nullable()), method(object({
45510
45758
  addonId: string(),
45511
45759
  nodeId: string().optional()
45512
45760
  }), SettingsSchemaWithValuesSchema.nullable()), method(object({
@@ -78498,7 +78746,7 @@ var TerminalInstanceInfoSchema = object({
78498
78746
  /**
78499
78747
  * False while the node the instance is bound to is not in the cluster
78500
78748
  * topology (forgotten, renamed, re-paired). The instance is kept; the
78501
- * operator re-points it with `updateInstance({ targetNodeId })`.
78749
+ * operator re-points it on the Terminal page.
78502
78750
  */
78503
78751
  nodeInCluster: boolean(),
78504
78752
  profileId: string(),
@@ -78536,41 +78784,7 @@ var TerminalOutputBatchSchema = object({
78536
78784
  snapshot: string().optional(),
78537
78785
  events: array(TerminalOutputEventSchema).readonly()
78538
78786
  });
78539
- method(_void(), array(TerminalProfileInfoSchema).readonly(), { auth: "admin" }), method(_void(), array(TerminalInstanceInfoSchema).readonly(), { auth: "admin" }), method(object({
78540
- targetNodeId: string().min(1),
78541
- profileId: string().min(1),
78542
- name: string().trim().min(1).max(160).optional(),
78543
- executable: string().max(1024).optional(),
78544
- args: array(string().max(2048)).max(64).optional(),
78545
- cwd: string().max(1024).optional(),
78546
- environment: array(string().max(4096)).max(64).optional(),
78547
- profileSettings: ProfileSettingsBagSchema.optional()
78548
- }), TerminalInstanceInfoSchema, {
78549
- kind: "mutation",
78550
- auth: "admin"
78551
- }), method(object({
78552
- instanceId: string().min(1),
78553
- /** Re-point the instance to another node; the profile must exist there. */
78554
- targetNodeId: string().min(1).max(256).optional(),
78555
- name: string().trim().min(1).max(160).optional(),
78556
- executable: string().max(1024).optional(),
78557
- args: array(string().max(2048)).max(64).optional(),
78558
- cwd: string().max(1024).optional(),
78559
- environment: array(string().max(4096)).max(64).optional(),
78560
- profileSettings: ProfileSettingsBagSchema.optional()
78561
- }), TerminalInstanceInfoSchema, {
78562
- kind: "mutation",
78563
- auth: "admin"
78564
- }), method(object({ instanceId: string().min(1) }), _void(), {
78565
- kind: "mutation",
78566
- auth: "admin"
78567
- }), method(object({
78568
- instanceId: string().min(1),
78569
- enabled: boolean()
78570
- }), TerminalInstanceInfoSchema, {
78571
- kind: "mutation",
78572
- auth: "admin"
78573
- }), method(_void(), array(TerminalLegacyCameraSchema).readonly(), { auth: "admin" }), method(object({
78787
+ method(_void(), array(TerminalProfileInfoSchema).readonly(), { auth: "admin" }), method(_void(), array(TerminalLegacyCameraSchema).readonly(), { auth: "admin" }), method(object({
78574
78788
  stableId: string().min(1),
78575
78789
  name: string().trim().min(1).max(160).optional()
78576
78790
  }), TerminalInstanceInfoSchema, {
@@ -83390,6 +83604,20 @@ Object.freeze({
83390
83604
  access: "view",
83391
83605
  role: "admin"
83392
83606
  },
83607
+ "addonSettings.getScopedSettings": {
83608
+ capName: "addon-settings",
83609
+ capScope: "system",
83610
+ addonId: null,
83611
+ access: "view",
83612
+ role: "admin"
83613
+ },
83614
+ "addonSettings.readScopedValues": {
83615
+ capName: "addon-settings",
83616
+ capScope: "system",
83617
+ addonId: null,
83618
+ access: "view",
83619
+ role: "admin"
83620
+ },
83393
83621
  "addonSettings.unsetStoredSettingsKeys": {
83394
83622
  capName: "addon-settings",
83395
83623
  capScope: "system",
@@ -83411,6 +83639,13 @@ Object.freeze({
83411
83639
  access: "create",
83412
83640
  role: "admin"
83413
83641
  },
83642
+ "addonSettings.updateScopedSettings": {
83643
+ capName: "addon-settings",
83644
+ capScope: "system",
83645
+ addonId: null,
83646
+ access: "create",
83647
+ role: "admin"
83648
+ },
83414
83649
  "addonWidgets.listWidgets": {
83415
83650
  capName: "addon-widgets",
83416
83651
  capScope: "system",
@@ -92301,27 +92536,6 @@ Object.freeze({
92301
92536
  access: "create",
92302
92537
  role: "admin"
92303
92538
  },
92304
- "terminalSession.createInstance": {
92305
- capName: "terminal-session",
92306
- capScope: "system",
92307
- addonId: null,
92308
- access: "create",
92309
- role: "admin"
92310
- },
92311
- "terminalSession.deleteInstance": {
92312
- capName: "terminal-session",
92313
- capScope: "system",
92314
- addonId: null,
92315
- access: "delete",
92316
- role: "admin"
92317
- },
92318
- "terminalSession.listInstances": {
92319
- capName: "terminal-session",
92320
- capScope: "system",
92321
- addonId: null,
92322
- access: "view",
92323
- role: "admin"
92324
- },
92325
92539
  "terminalSession.listLegacyCameras": {
92326
92540
  capName: "terminal-session",
92327
92541
  capScope: "system",
@@ -92364,20 +92578,6 @@ Object.freeze({
92364
92578
  access: "create",
92365
92579
  role: "admin"
92366
92580
  },
92367
- "terminalSession.setInstanceEnabled": {
92368
- capName: "terminal-session",
92369
- capScope: "system",
92370
- addonId: null,
92371
- access: "create",
92372
- role: "admin"
92373
- },
92374
- "terminalSession.updateInstance": {
92375
- capName: "terminal-session",
92376
- capScope: "system",
92377
- addonId: null,
92378
- access: "create",
92379
- role: "admin"
92380
- },
92381
92581
  "terminalSession.writeInput": {
92382
92582
  capName: "terminal-session",
92383
92583
  capScope: "system",
@@ -144130,6 +144330,7 @@ var EchoSessionRegistry = class {
144130
144330
  #resolve = null;
144131
144331
  #dnd = /* @__PURE__ */ new Map();
144132
144332
  #volumes = /* @__PURE__ */ new Map();
144333
+ #groupPlayers = /* @__PURE__ */ new Map();
144133
144334
  setResolver(fn) {
144134
144335
  this.#resolve = fn;
144135
144336
  }
@@ -144168,10 +144369,35 @@ var EchoSessionRegistry = class {
144168
144369
  });
144169
144370
  return pending;
144170
144371
  }
144372
+ /**
144373
+ * The player state of a speaker group, shared by its members within one poll cycle.
144374
+ * A failed read is not kept for the next member.
144375
+ */
144376
+ groupPlayer(brokerId, group, now, deviceId) {
144377
+ const key = `${brokerId}\u0000${group.id}`;
144378
+ const shared = this.#groupPlayers.get(key);
144379
+ if (shared !== void 0 && now - shared.at < 25e3) return shared.pending;
144380
+ const session = this.get(brokerId);
144381
+ if (session === null) return Promise.reject(/* @__PURE__ */ new Error("no Echo session for this account"));
144382
+ const pending = session.playerState(group.id, group.deviceType, deviceId);
144383
+ this.#groupPlayers.set(key, {
144384
+ at: now,
144385
+ pending
144386
+ });
144387
+ pending.catch(() => {
144388
+ if (this.#groupPlayers.get(key)?.pending === pending) this.#groupPlayers.delete(key);
144389
+ });
144390
+ return pending;
144391
+ }
144171
144392
  /** Forget the shared volume list: a list fetched before a set must not answer a poll after it. */
144172
144393
  invalidateVolumes(brokerId) {
144173
144394
  this.#volumes.delete(brokerId);
144174
144395
  }
144396
+ /** Forget the shared group players: a transport command or a push makes a cached group answer stale. */
144397
+ invalidateGroupPlayers(brokerId) {
144398
+ const prefix = `${brokerId}\u0000`;
144399
+ for (const key of [...this.#groupPlayers.keys()]) if (key.startsWith(prefix)) this.#groupPlayers.delete(key);
144400
+ }
144175
144401
  /** Forget the shared Do Not Disturb list, for the same reason. */
144176
144402
  invalidateDnd(brokerId) {
144177
144403
  this.#dnd.delete(brokerId);
@@ -144187,6 +144413,7 @@ var EchoSessionRegistry = class {
144187
144413
  }
144188
144414
  event(brokerId, serial) {
144189
144415
  this.invalidateVolumes(brokerId);
144416
+ this.invalidateGroupPlayers(brokerId);
144190
144417
  this.invalidateDnd(brokerId);
144191
144418
  for (const listener of [...this.#pushListeners.get(`${brokerId}\u0000${serial}`) ?? []]) listener();
144192
144419
  }
@@ -144205,6 +144432,7 @@ var EchoSessionRegistry = class {
144205
144432
  this.#resolve = null;
144206
144433
  this.#dnd.clear();
144207
144434
  this.#volumes.clear();
144435
+ this.#groupPlayers.clear();
144208
144436
  }
144209
144437
  };
144210
144438
  function subscribe(map, key, listener) {
@@ -144247,6 +144475,11 @@ function mapEchoPlaybackState(value) {
144247
144475
  default: return "idle";
144248
144476
  }
144249
144477
  }
144478
+ /** Whether a player answer carries any playback: an active state, a text or artwork. */
144479
+ function hasPlayback(info) {
144480
+ if (mapEchoPlaybackState(info.state) !== "idle") return true;
144481
+ return nonEmpty(info.infoText?.title) !== null || nonEmpty(info.mainArt?.url) !== null;
144482
+ }
144250
144483
  function percent(value) {
144251
144484
  if (typeof value !== "number" || !Number.isFinite(value)) return null;
144252
144485
  return Math.round(Math.max(0, Math.min(100, value)));
@@ -144336,7 +144569,9 @@ var EchoDeviceSchema = object({
144336
144569
  dnd: boolean(),
144337
144570
  deviceType: string(),
144338
144571
  /** The room the account files the device under, when Amazon names one. */
144339
- room: string().optional()
144572
+ room: string().optional(),
144573
+ /** Serials of the speaker groups this device belongs to (Amazon's `parentClusters`); absent when none. */
144574
+ parentClusters: array(string()).optional()
144340
144575
  });
144341
144576
  var LoginStatusSchema = _enum(["ok", "failed"]);
144342
144577
  var SessionStatusSchema = object({
@@ -144565,6 +144800,8 @@ var AlexaEchoDevice = class extends BaseDevice {
144565
144800
  unsubscribers = [];
144566
144801
  polling = false;
144567
144802
  failureLogged = false;
144803
+ /** The name of the speaker group whose playback this Echo shows; null when it plays on its own. */
144804
+ viaGroup = null;
144568
144805
  volumeFailureLogged = false;
144569
144806
  health = "ok";
144570
144807
  /** When this device last wrote its volume / Do Not Disturb optimistically; a read that began earlier must not overwrite it. */
@@ -144692,9 +144929,11 @@ var AlexaEchoDevice = class extends BaseDevice {
144692
144929
  }
144693
144930
  const deviceType = this.amazonDeviceType !== "" ? this.amazonDeviceType : listed?.deviceType ?? "";
144694
144931
  try {
144695
- const fromPlayer = mapPlayerInfoToStatus(await session.playerState(this.serial, deviceType, this.id), {
144932
+ const own = await session.playerState(this.serial, deviceType, this.id);
144933
+ const now = Date.now();
144934
+ const fromPlayer = mapPlayerInfoToStatus(await this.withGroupPlayback(own, listed, session), {
144696
144935
  online: true,
144697
- now: Date.now()
144936
+ now
144698
144937
  });
144699
144938
  const read = await this.completeVolume(fromPlayer);
144700
144939
  const kept = this.keepNewerVolume(read, readStartedAt);
@@ -144709,6 +144948,49 @@ var AlexaEchoDevice = class extends BaseDevice {
144709
144948
  await this.readDoNotDisturb(readStartedAt);
144710
144949
  }
144711
144950
  /**
144951
+ * A member of a playing speaker group is answered empty by its own player read while
144952
+ * the group plays. When the member's answer carries no playback, serve the first
144953
+ * parent group that is playing or paused (its track and artwork; the volume stays the
144954
+ * member's own). A failed group read leaves the member's own answer.
144955
+ */
144956
+ async withGroupPlayback(own, listed, session) {
144957
+ const parents = listed?.parentClusters ?? [];
144958
+ if (hasPlayback(own) || parents.length === 0) {
144959
+ this.noteViaGroup(null);
144960
+ return own;
144961
+ }
144962
+ for (const parentId of parents) {
144963
+ const group = session.devices.find((device) => device.id === parentId);
144964
+ if (group === void 0 || !group.online) continue;
144965
+ try {
144966
+ const groupInfo = await alexaEchoSessions.groupPlayer(this.brokerId, group, Date.now(), this.id);
144967
+ if (groupInfo.state === "PLAYING" || groupInfo.state === "PAUSED" || groupInfo.state === "BUFFERING") {
144968
+ this.noteViaGroup(group.name);
144969
+ return {
144970
+ ...groupInfo,
144971
+ volume: own.volume
144972
+ };
144973
+ }
144974
+ } catch (err) {
144975
+ this.ctx.logger.debug("alexa echo: the group player could not be read", {
144976
+ tags: { deviceId: this.id },
144977
+ meta: {
144978
+ group: group.name,
144979
+ error: err instanceof Error ? err.message : String(err)
144980
+ }
144981
+ });
144982
+ }
144983
+ }
144984
+ this.noteViaGroup(null);
144985
+ return own;
144986
+ }
144987
+ /** Log each transition into and out of "playing via a group" once. */
144988
+ noteViaGroup(name) {
144989
+ if (name === this.viaGroup) return;
144990
+ this.viaGroup = name;
144991
+ this.ctx.logger.info(name === null ? "alexa echo: no longer playing via a group" : `alexa echo: playing via group ${name}`, { tags: { deviceId: this.id } });
144992
+ }
144993
+ /**
144712
144994
  * An idle Echo's player read carries no volume. Ask the account's volume list
144713
144995
  * (shared by every Echo of the account in this cycle); a failed read leaves null.
144714
144996
  */
@@ -144780,6 +145062,7 @@ var AlexaEchoDevice = class extends BaseDevice {
144780
145062
  }
144781
145063
  async command(command) {
144782
145064
  await this.session().npCommand(this.serial, command, this.id);
145065
+ alexaEchoSessions.invalidateGroupPlayers(this.brokerId);
144783
145066
  this.rereadSoon();
144784
145067
  }
144785
145068
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-import-alexa",
3
- "version": "0.2.195",
3
+ "version": "0.2.196",
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",
@@ -72,7 +72,7 @@
72
72
  }
73
73
  ],
74
74
  "requires": {
75
- "server": ">=1.2.790"
75
+ "server": ">=1.2.793"
76
76
  }
77
77
  },
78
78
  "files": [
@@ -110,6 +110,11 @@ def normalize_device(raw: dict) -> dict | None:
110
110
  }
111
111
  if room != "":
112
112
  result["room"] = room
113
+ parents = raw.get("parentClusters")
114
+ if isinstance(parents, list):
115
+ serials = [p for p in parents if isinstance(p, str) and p != ""]
116
+ if serials:
117
+ result["parentClusters"] = serials
113
118
  return result
114
119
 
115
120
 
@@ -246,7 +251,7 @@ class AlexaPyGateway:
246
251
  public = [
247
252
  {
248
253
  key: device[key]
249
- for key in ("id", "name", "family", "online", "kind", "dnd", "deviceType", "room")
254
+ for key in ("id", "name", "family", "online", "kind", "dnd", "deviceType", "room", "parentClusters")
250
255
  if key in device
251
256
  }
252
257
  for device in devices