@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 +6 -0
- package/dist/addon.js +365 -82
- package/dist/addon.mjs +365 -82
- package/package.json +2 -2
- package/python/alexa_gateway.py +6 -1
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-
|
|
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
|
-
|
|
8953
|
-
|
|
8954
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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-
|
|
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
|
-
|
|
8951
|
-
|
|
8952
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
75
|
+
"server": ">=1.2.793"
|
|
76
76
|
}
|
|
77
77
|
},
|
|
78
78
|
"files": [
|
package/python/alexa_gateway.py
CHANGED
|
@@ -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
|