@camstack/addon-import-alexa 0.2.190 → 0.2.192
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 +16 -0
- package/dist/addon.js +1489 -849
- package/dist/addon.mjs +1489 -849
- package/package.json +2 -2
- package/python/alexa_gateway.py +28 -8
- package/python/alexa_push.py +178 -0
- package/python/alexa_worker.py +7 -1
- package/python/test_alexa_gateway_echo.py +14 -0
- package/python/test_alexa_push.py +227 -0
package/dist/addon.js
CHANGED
|
@@ -6044,7 +6044,7 @@ function number(params) {
|
|
|
6044
6044
|
return /* @__PURE__ */ _coercedNumber(ZodNumber, params);
|
|
6045
6045
|
}
|
|
6046
6046
|
//#endregion
|
|
6047
|
-
//#region ../types/dist/sleep-
|
|
6047
|
+
//#region ../types/dist/sleep-CmjWurK5.mjs
|
|
6048
6048
|
/**
|
|
6049
6049
|
* The audio chunk plane's byte format, and the ONE expansion from a coded
|
|
6050
6050
|
* window to float samples (D455).
|
|
@@ -6728,137 +6728,102 @@ async function runAddonMigrations(host, migrations, logger, options = {}) {
|
|
|
6728
6728
|
blockOwner
|
|
6729
6729
|
};
|
|
6730
6730
|
}
|
|
6731
|
-
/**
|
|
6732
|
-
|
|
6733
|
-
|
|
6734
|
-
|
|
6735
|
-
|
|
6736
|
-
|
|
6737
|
-
|
|
6738
|
-
|
|
6739
|
-
|
|
6740
|
-
|
|
6741
|
-
|
|
6742
|
-
|
|
6743
|
-
|
|
6744
|
-
|
|
6745
|
-
|
|
6746
|
-
|
|
6747
|
-
|
|
6748
|
-
|
|
6749
|
-
const set = async (next) => {
|
|
6750
|
-
const validated = deps.schema.parse(next);
|
|
6751
|
-
await deps.write({ [deps.key]: validated });
|
|
6752
|
-
};
|
|
6753
|
-
const update = async (fn) => {
|
|
6754
|
-
await set(fn(await get()));
|
|
6755
|
-
};
|
|
6731
|
+
/** The record calls the one-switch migration makes, over the migration API. */
|
|
6732
|
+
function switchRecordsOverMigrationApi(api) {
|
|
6733
|
+
return { kind: (definition) => {
|
|
6734
|
+
const handle = api.records.kind(definition);
|
|
6735
|
+
return {
|
|
6736
|
+
get: (id) => handle.get({ id }),
|
|
6737
|
+
put: (input, opts) => handle.put({
|
|
6738
|
+
...input,
|
|
6739
|
+
...opts?.ifRev === void 0 ? {} : { ifRev: opts.ifRev }
|
|
6740
|
+
}),
|
|
6741
|
+
patch: (id, partial) => handle.patch({
|
|
6742
|
+
id,
|
|
6743
|
+
partial
|
|
6744
|
+
})
|
|
6745
|
+
};
|
|
6746
|
+
} };
|
|
6747
|
+
}
|
|
6748
|
+
function integrationRowsApiOver(api) {
|
|
6756
6749
|
return {
|
|
6757
|
-
|
|
6758
|
-
|
|
6759
|
-
|
|
6750
|
+
list: async () => (await api.integrations.list({})).integrations,
|
|
6751
|
+
readLegacyIntegrationRows: (input) => api.legacyRows.read(input),
|
|
6752
|
+
clearLegacyIntegrationRows: (input) => api.legacyRows.clear(input)
|
|
6760
6753
|
};
|
|
6761
6754
|
}
|
|
6755
|
+
var PREFIX = "records/";
|
|
6756
|
+
/** The base of every records failure. `detail` is the human part of the message. */
|
|
6757
|
+
var RecordsError = class extends Error {
|
|
6758
|
+
code;
|
|
6759
|
+
detail;
|
|
6760
|
+
constructor(code, detail) {
|
|
6761
|
+
super(`${PREFIX}${code}: ${detail}`);
|
|
6762
|
+
this.name = "RecordsError";
|
|
6763
|
+
this.code = code;
|
|
6764
|
+
this.detail = detail;
|
|
6765
|
+
}
|
|
6766
|
+
};
|
|
6767
|
+
/** A write guarded by `ifRev` found another revision (or an existing row for `ifRev: 0`). */
|
|
6768
|
+
var RevConflictError = class extends RecordsError {
|
|
6769
|
+
constructor(detail) {
|
|
6770
|
+
super("rev-conflict", detail);
|
|
6771
|
+
this.name = "RevConflictError";
|
|
6772
|
+
}
|
|
6773
|
+
};
|
|
6774
|
+
/** The keychain could not store or serve a value: explicit, never an empty string. */
|
|
6775
|
+
var RecordSecretUnavailableError = class extends RecordsError {
|
|
6776
|
+
constructor(detail) {
|
|
6777
|
+
super("secret-unavailable", detail);
|
|
6778
|
+
this.name = "RecordSecretUnavailableError";
|
|
6779
|
+
}
|
|
6780
|
+
};
|
|
6781
|
+
var InvalidRecordError = class extends RecordsError {
|
|
6782
|
+
constructor(detail) {
|
|
6783
|
+
super("invalid-record", detail);
|
|
6784
|
+
this.name = "InvalidRecordError";
|
|
6785
|
+
}
|
|
6786
|
+
};
|
|
6787
|
+
/** A kind declaration the engine refuses (non-additive change, bad shape, wrong file). */
|
|
6788
|
+
var DeclarationRefusedError = class extends RecordsError {
|
|
6789
|
+
constructor(detail) {
|
|
6790
|
+
super("declaration-refused", detail);
|
|
6791
|
+
this.name = "DeclarationRefusedError";
|
|
6792
|
+
}
|
|
6793
|
+
};
|
|
6762
6794
|
/**
|
|
6763
|
-
*
|
|
6764
|
-
*
|
|
6765
|
-
*
|
|
6766
|
-
* hub-routed — the hub instance answers for every node), so fields whose
|
|
6767
|
-
* value is a NODE fact (decoder backend, engine pick, probed hardware
|
|
6768
|
-
* capabilities) persist under the node-scoped key `<key>@<nodeId>` inside
|
|
6769
|
-
* the single shared blob. The SCHEMA field key stays bare: the write path
|
|
6770
|
-
* ({@link scopePatch}) maps the bare field onto the target node's scoped
|
|
6771
|
-
* key; the read/hydrate path ({@link projectStore}) maps THIS node's value
|
|
6772
|
-
* back, so UI forms and `resolveConfig` only ever see the bare key.
|
|
6773
|
-
*
|
|
6774
|
-
* Fields opt in via the `perNode: true` schema marker on `ConfigFieldBase`
|
|
6775
|
-
* (`interfaces/config-ui.ts`); `BaseAddon` derives the key set from the
|
|
6776
|
-
* schema and routes reads/writes through these helpers.
|
|
6777
|
-
*
|
|
6778
|
-
* ## No bare-key fallback — deliberate
|
|
6779
|
-
*
|
|
6780
|
-
* {@link readNodeValue} reads the node-scoped key ONLY. A node with no
|
|
6781
|
-
* scoped key resolves to `undefined` so the field's schema `default` wins —
|
|
6782
|
-
* NEVER `store[base]` and never another node's value. A bare legacy key in
|
|
6783
|
-
* the store is invisible to every node, hub included, so one node's
|
|
6784
|
-
* selection can never leak onto another. (This generalizes the
|
|
6785
|
-
* `decoder-backend-keys.ts` semantics — the no-fallback variant — over an
|
|
6786
|
-
* arbitrary set of per-node field keys.)
|
|
6787
|
-
*
|
|
6788
|
-
* Pure functions only — no I/O, no imports beyond the language. This is a
|
|
6789
|
-
* LEAF module: import it via its deep path, never from the root barrel.
|
|
6790
|
-
*/
|
|
6791
|
-
/**
|
|
6792
|
-
* Normalize a raw kernel node id to the bare node id used for scoping.
|
|
6793
|
-
* `localNodeId` can carry a `<node>/<addon>` suffix on forked child
|
|
6794
|
-
* processes; per-node settings are per-NODE, so strip the addon segment.
|
|
6795
|
-
* `undefined` / `null` / empty falls back to `'hub'`.
|
|
6796
|
-
*/
|
|
6797
|
-
function normalizeNodeId(raw) {
|
|
6798
|
-
if (raw === void 0 || raw === null || raw === "") return "hub";
|
|
6799
|
-
const slashIdx = raw.indexOf("/");
|
|
6800
|
-
if (slashIdx < 0) return raw;
|
|
6801
|
-
const bare = raw.slice(0, slashIdx);
|
|
6802
|
-
return bare === "" ? "hub" : bare;
|
|
6803
|
-
}
|
|
6804
|
-
/** The node-scoped store key for a per-node field: `<base>@<nodeId>`. */
|
|
6805
|
-
function nodeScopedKey(base, nodeId) {
|
|
6806
|
-
return `${base}@${normalizeNodeId(nodeId)}`;
|
|
6807
|
-
}
|
|
6808
|
-
/**
|
|
6809
|
-
* Read a node's value for a per-node field from the raw shared store:
|
|
6810
|
-
* the node-scoped key when present, otherwise `undefined`.
|
|
6811
|
-
* Deliberately NO bare-key fallback (see module doc) — the caller lets the
|
|
6812
|
-
* schema `default` win on `undefined`.
|
|
6795
|
+
* The link primitive's declaration half (D1013 rule 5): what a link field names and how a kind
|
|
6796
|
+
* records it. Split from `instance-link.ts` so `RecordIntegrationInstances` can read the links of
|
|
6797
|
+
* its kind without importing the builders that import it.
|
|
6813
6798
|
*/
|
|
6814
|
-
|
|
6815
|
-
|
|
6799
|
+
/** The body field of a link: the id of the instance it names; the target rides in the field meta. */
|
|
6800
|
+
function linkField(target) {
|
|
6801
|
+
return string().meta({ link: target });
|
|
6816
6802
|
}
|
|
6817
|
-
|
|
6818
|
-
|
|
6819
|
-
|
|
6820
|
-
|
|
6821
|
-
|
|
6822
|
-
* patch is not mutated.
|
|
6823
|
-
*/
|
|
6824
|
-
function scopePatch(patch, perNodeKeys, nodeId) {
|
|
6825
|
-
const out = {};
|
|
6826
|
-
for (const [key, value] of Object.entries(patch)) out[perNodeKeys.has(key) ? nodeScopedKey(key, nodeId) : key] = value;
|
|
6827
|
-
return out;
|
|
6803
|
+
function linkTargetOf(meta) {
|
|
6804
|
+
if (typeof meta !== "object" || meta === null) return void 0;
|
|
6805
|
+
const link = Reflect.get(meta, "link");
|
|
6806
|
+
const parsed = LinkTargetSchema.safeParse(link);
|
|
6807
|
+
return parsed.success ? parsed.data : void 0;
|
|
6828
6808
|
}
|
|
6829
|
-
|
|
6830
|
-
|
|
6831
|
-
|
|
6832
|
-
|
|
6833
|
-
|
|
6834
|
-
|
|
6835
|
-
|
|
6836
|
-
|
|
6837
|
-
|
|
6838
|
-
|
|
6839
|
-
|
|
6840
|
-
|
|
6841
|
-
|
|
6842
|
-
|
|
6843
|
-
|
|
6844
|
-
const out = {};
|
|
6845
|
-
for (const [key, value] of Object.entries(store)) {
|
|
6846
|
-
if (key.includes("@")) continue;
|
|
6847
|
-
if (perNodeKeys.has(key)) continue;
|
|
6848
|
-
out[key] = value;
|
|
6849
|
-
}
|
|
6850
|
-
for (const base of perNodeKeys) {
|
|
6851
|
-
const value = readNodeValue(store, base, nodeId);
|
|
6852
|
-
if (value !== void 0) out[base] = value;
|
|
6809
|
+
var LinkTargetSchema = object({
|
|
6810
|
+
cap: string().min(1),
|
|
6811
|
+
kind: string().min(1).optional()
|
|
6812
|
+
});
|
|
6813
|
+
/** The links a kind declares, read from the meta of its body fields. */
|
|
6814
|
+
function linksOfKind(kind) {
|
|
6815
|
+
const schema = kind.schema;
|
|
6816
|
+
if (!(schema instanceof ZodObject)) return [];
|
|
6817
|
+
const links = [];
|
|
6818
|
+
for (const [field, fieldSchema] of Object.entries(schema.shape)) {
|
|
6819
|
+
const to = linkTargetOf(globalRegistry.get(fieldSchema));
|
|
6820
|
+
if (to !== void 0) links.push({
|
|
6821
|
+
field,
|
|
6822
|
+
to
|
|
6823
|
+
});
|
|
6853
6824
|
}
|
|
6854
|
-
return
|
|
6825
|
+
return links;
|
|
6855
6826
|
}
|
|
6856
|
-
/** Every scope, in the order the guard and the docs list them. */
|
|
6857
|
-
var SCOPED_SETTINGS_SCOPES = [
|
|
6858
|
-
"cluster",
|
|
6859
|
-
"device-defaults",
|
|
6860
|
-
"node"
|
|
6861
|
-
];
|
|
6862
6827
|
/**
|
|
6863
6828
|
* The address of one keychain value: `secret://<owner>/<scope>/<fieldPath>`.
|
|
6864
6829
|
*
|
|
@@ -6993,6 +6958,643 @@ function parseSecretRef(ref) {
|
|
|
6993
6958
|
fieldPath
|
|
6994
6959
|
};
|
|
6995
6960
|
}
|
|
6961
|
+
/** Limits and names of `ctx.records` (D881). One authority; the engine and the facade read it. */
|
|
6962
|
+
/** The files a kind may live in. `history` is device-history's (D850), never records. */
|
|
6963
|
+
var RECORD_DATABASES = ["settings", "events"];
|
|
6964
|
+
/** The classes of a kind: the `@durable` vocabulary, one authority (replaces the annotation). */
|
|
6965
|
+
var RECORD_CLASSES = [
|
|
6966
|
+
"config",
|
|
6967
|
+
"ledger",
|
|
6968
|
+
"audit",
|
|
6969
|
+
"mirror",
|
|
6970
|
+
"ephemeral"
|
|
6971
|
+
];
|
|
6972
|
+
var RECORD_DEFAULT_MAX_DATA_BYTES = 64 * 1024;
|
|
6973
|
+
var RECORD_MAX_DATA_BYTES = 1024 * 1024;
|
|
6974
|
+
var RECORD_BATCH_MAX = 1e3;
|
|
6975
|
+
var RECORD_QUERY_MAX = 1e3;
|
|
6976
|
+
/** A kind in the `settings` file may not bound itself above this (D447: high volume is `events`). */
|
|
6977
|
+
var RECORD_SETTINGS_MAX_ROWS = 5e4;
|
|
6978
|
+
/** The `Column`-free proof prefix: a ref collection for a kind's secret fields. */
|
|
6979
|
+
var RECORD_SECRET_COLLECTION_PREFIX = "records:";
|
|
6980
|
+
/**
|
|
6981
|
+
* Secret fields of a record, addressed the way a Zod schema declares them
|
|
6982
|
+
* (`collectZodSecretPaths`): dotted keys, `[]` for "each element of this array"
|
|
6983
|
+
* (`auth.accessToken`, `headers[].value`).
|
|
6984
|
+
*
|
|
6985
|
+
* `{}` after a key means "each value of this map" (`applianceSecrets{}`): the map's own key
|
|
6986
|
+
* names the value (`applianceSecrets{fridge}`), so no id key is declared and a body that drops a
|
|
6987
|
+
* key releases exactly that key's secret.
|
|
6988
|
+
*
|
|
6989
|
+
* Array elements are matched by a DECLARED id key (`secretArrayKeys`:
|
|
6990
|
+
* `{ headers: 'name' }`), never by index, so reordering a list keeps every
|
|
6991
|
+
* keychain ref (the same rule as `arrayItemFieldPath`). The concrete path of a
|
|
6992
|
+
* leaf names its element: `headers[X-Api].value`.
|
|
6993
|
+
*
|
|
6994
|
+
* Pure and browser-safe. Nothing here mutates: every transform returns a new value.
|
|
6995
|
+
*/
|
|
6996
|
+
/** The array prefixes (declared form, e.g. `headers` or `a.headers`) of a path. */
|
|
6997
|
+
function parseSecretPath(path) {
|
|
6998
|
+
if (path.length === 0) throw new InvalidRecordError("an empty secret path");
|
|
6999
|
+
return path.split(".").map((raw) => {
|
|
7000
|
+
const each = raw.endsWith("[]");
|
|
7001
|
+
const map = raw.endsWith("{}");
|
|
7002
|
+
const key = each || map ? raw.slice(0, -2) : raw;
|
|
7003
|
+
if (key.length === 0) throw new InvalidRecordError(`secret path "${path}" has an empty segment`);
|
|
7004
|
+
return {
|
|
7005
|
+
key,
|
|
7006
|
+
each,
|
|
7007
|
+
map
|
|
7008
|
+
};
|
|
7009
|
+
});
|
|
7010
|
+
}
|
|
7011
|
+
/** The declared array prefixes of `path` that need an id key (`headers`, `a.rows`). */
|
|
7012
|
+
function secretArrayPrefixes(path) {
|
|
7013
|
+
const segments = parseSecretPath(path);
|
|
7014
|
+
const prefixes = [];
|
|
7015
|
+
const walked = [];
|
|
7016
|
+
for (const segment of segments) {
|
|
7017
|
+
walked.push(segment.key);
|
|
7018
|
+
if (segment.each) prefixes.push(walked.join("."));
|
|
7019
|
+
}
|
|
7020
|
+
return prefixes;
|
|
7021
|
+
}
|
|
7022
|
+
function isRecord$6(value) {
|
|
7023
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
7024
|
+
}
|
|
7025
|
+
function elementId(element, idKey, declared, prefix) {
|
|
7026
|
+
const id = isRecord$6(element) ? element[idKey] : void 0;
|
|
7027
|
+
if (typeof id !== "string" || id.length === 0) throw new InvalidRecordError(`"${declared}": every element of "${prefix}" needs a non-empty string "${idKey}" to carry a secret`);
|
|
7028
|
+
return id;
|
|
7029
|
+
}
|
|
7030
|
+
/**
|
|
7031
|
+
* Visit every secret leaf PRESENT in `data` (an absent branch has nothing to
|
|
7032
|
+
* visit). `replace` returns the new leaf value, or {@link REMOVE_LEAF}; the
|
|
7033
|
+
* result is a new body, `data` is untouched.
|
|
7034
|
+
*/
|
|
7035
|
+
var REMOVE_LEAF = Symbol("remove-leaf");
|
|
7036
|
+
function mapAt(node, segments, index, walkedKeys, concretePrefix, declared, arrayKeys, visit) {
|
|
7037
|
+
if (!isRecord$6(node)) return node;
|
|
7038
|
+
const segment = segments[index];
|
|
7039
|
+
if (segment === void 0) return node;
|
|
7040
|
+
if (!Object.prototype.hasOwnProperty.call(node, segment.key)) return node;
|
|
7041
|
+
const child = node[segment.key];
|
|
7042
|
+
const keys = [...walkedKeys, segment.key];
|
|
7043
|
+
const last = index === segments.length - 1;
|
|
7044
|
+
if (segment.each) {
|
|
7045
|
+
if (!Array.isArray(child)) return node;
|
|
7046
|
+
const prefix = keys.join(".");
|
|
7047
|
+
const idKey = arrayKeys[prefix];
|
|
7048
|
+
if (idKey === void 0) throw new InvalidRecordError(`"${declared}": no id key declared for the array "${prefix}"`);
|
|
7049
|
+
const mapped = child.map((element) => {
|
|
7050
|
+
const id = elementId(element, idKey, declared, prefix);
|
|
7051
|
+
const concrete = `${concretePrefix}${concretePrefix.length > 0 ? "." : ""}${segment.key}[${encodeURIComponent(id)}]`;
|
|
7052
|
+
return last ? element : mapAt(element, segments, index + 1, keys, concrete, declared, arrayKeys, visit);
|
|
7053
|
+
});
|
|
7054
|
+
return {
|
|
7055
|
+
...node,
|
|
7056
|
+
[segment.key]: mapped
|
|
7057
|
+
};
|
|
7058
|
+
}
|
|
7059
|
+
if (segment.map) {
|
|
7060
|
+
if (!isRecord$6(child)) return node;
|
|
7061
|
+
const kept = Object.entries(child).map(([mapKey, value]) => {
|
|
7062
|
+
const concrete = `${concretePrefix}${concretePrefix.length > 0 ? "." : ""}${segment.key}{${encodeURIComponent(mapKey)}}`;
|
|
7063
|
+
if (!last) return [mapKey, mapAt(value, segments, index + 1, keys, concrete, declared, arrayKeys, visit)];
|
|
7064
|
+
return [mapKey, visit({
|
|
7065
|
+
declared,
|
|
7066
|
+
concrete,
|
|
7067
|
+
value
|
|
7068
|
+
})];
|
|
7069
|
+
}).filter(([, value]) => value !== REMOVE_LEAF);
|
|
7070
|
+
return {
|
|
7071
|
+
...node,
|
|
7072
|
+
[segment.key]: Object.fromEntries(kept)
|
|
7073
|
+
};
|
|
7074
|
+
}
|
|
7075
|
+
const concrete = `${concretePrefix}${concretePrefix.length > 0 ? "." : ""}${segment.key}`;
|
|
7076
|
+
if (last) {
|
|
7077
|
+
const replacement = visit({
|
|
7078
|
+
declared,
|
|
7079
|
+
concrete,
|
|
7080
|
+
value: child
|
|
7081
|
+
});
|
|
7082
|
+
if (replacement === REMOVE_LEAF) return Object.fromEntries(Object.entries(node).filter(([key]) => key !== segment.key));
|
|
7083
|
+
return {
|
|
7084
|
+
...node,
|
|
7085
|
+
[segment.key]: replacement
|
|
7086
|
+
};
|
|
7087
|
+
}
|
|
7088
|
+
return {
|
|
7089
|
+
...node,
|
|
7090
|
+
[segment.key]: mapAt(child, segments, index + 1, keys, concrete, declared, arrayKeys, visit)
|
|
7091
|
+
};
|
|
7092
|
+
}
|
|
7093
|
+
/** Replace/remove every present secret leaf; returns a NEW body. */
|
|
7094
|
+
function mapSecretLeaves(data, paths, arrayKeys, visit) {
|
|
7095
|
+
let current = data;
|
|
7096
|
+
for (const declared of paths) current = mapAt(current, parseSecretPath(declared), 0, [], "", declared, arrayKeys, visit);
|
|
7097
|
+
return current;
|
|
7098
|
+
}
|
|
7099
|
+
/**
|
|
7100
|
+
* The keychain collection name a kind's secrets live under. Prefixed so a
|
|
7101
|
+
* record's refs never collide with the refs a legacy collection of the same name
|
|
7102
|
+
* already holds during an adoption.
|
|
7103
|
+
*/
|
|
7104
|
+
function recordSecretCollection(kind) {
|
|
7105
|
+
return `${RECORD_SECRET_COLLECTION_PREFIX}${kind}`;
|
|
7106
|
+
}
|
|
7107
|
+
/** The ONE ref a row's secret field may carry: the engine and the facade both build it here. */
|
|
7108
|
+
function recordSecretRef(owner, kind, rowId, concretePath) {
|
|
7109
|
+
return buildSecretRef({
|
|
7110
|
+
owner,
|
|
7111
|
+
scope: {
|
|
7112
|
+
kind: "row",
|
|
7113
|
+
collection: recordSecretCollection(kind),
|
|
7114
|
+
rowId
|
|
7115
|
+
},
|
|
7116
|
+
fieldPath: concretePath
|
|
7117
|
+
});
|
|
7118
|
+
}
|
|
7119
|
+
/**
|
|
7120
|
+
* A stored secret, as an admin read shows it: a VIEW naming the row's own
|
|
7121
|
+
* keychain ref (`set: true`), never the value and no longer the bare redaction
|
|
7122
|
+
* token. The form hands it to the secret field, which reveals the value by ref
|
|
7123
|
+
* through the audited `keychain-admin.reveal`; sent back untouched it keeps the
|
|
7124
|
+
* stored secret (the records door reads a view as KEEP).
|
|
7125
|
+
*
|
|
7126
|
+
* ONE implementation for the entries service and for every addon that serves
|
|
7127
|
+
* its own instances (`RecordIntegrationInstances`).
|
|
7128
|
+
*/
|
|
7129
|
+
function isRecord$5(value) {
|
|
7130
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
7131
|
+
}
|
|
7132
|
+
function secretViewsOf(owner, kind, id, decl, data) {
|
|
7133
|
+
if (decl.secretPaths.length === 0) return data;
|
|
7134
|
+
const shown = mapSecretLeaves(data, decl.secretPaths, decl.secretArrayKeys, (leaf) => {
|
|
7135
|
+
if (leaf.value !== "__camstack_redacted__") return leaf.value;
|
|
7136
|
+
return {
|
|
7137
|
+
$secret: recordSecretRef(owner, kind, id, leaf.concrete),
|
|
7138
|
+
v: 1,
|
|
7139
|
+
set: true
|
|
7140
|
+
};
|
|
7141
|
+
});
|
|
7142
|
+
return isRecord$5(shown) ? shown : data;
|
|
7143
|
+
}
|
|
7144
|
+
/** The most instances one addon lists (an integrations page, not a table). */
|
|
7145
|
+
var INSTANCES_LIST_MAX = 1e3;
|
|
7146
|
+
/**
|
|
7147
|
+
* The ONE reader of an instance's switch (D996): an absent field means on, so a row written
|
|
7148
|
+
* before the field existed needs no migration. Every consumer of an instance body asks this.
|
|
7149
|
+
*/
|
|
7150
|
+
function isInstanceEnabled(body) {
|
|
7151
|
+
return body.enabled !== false;
|
|
7152
|
+
}
|
|
7153
|
+
/** The body key of the switch; only {@link RecordIntegrationInstances.setInstanceEnabled} writes it. */
|
|
7154
|
+
var SWITCH_KEY = "enabled";
|
|
7155
|
+
/** The keys of the kind's secret fields that sit at the top level of the body. */
|
|
7156
|
+
function topLevelSecretKeys(paths) {
|
|
7157
|
+
return new Set(paths.filter((p) => !p.includes(".") && !p.includes("[") && !p.includes("{")));
|
|
7158
|
+
}
|
|
7159
|
+
/** The keys of the kind's top-level MAPS whose values are secrets (`applianceSecrets{}`). */
|
|
7160
|
+
function secretMapKeys(paths) {
|
|
7161
|
+
return new Set(paths.filter((p) => p.endsWith("{}") && !p.slice(0, -2).match(/[.[{]/)).map((p) => p.slice(0, -2)));
|
|
7162
|
+
}
|
|
7163
|
+
/**
|
|
7164
|
+
* A patch value for a map of secrets: an entry that is omitted, `''`, the redaction token or the
|
|
7165
|
+
* view the form was given KEEPS its stored secret (it is left out of the merge); `{ $secretClear }`
|
|
7166
|
+
* or `null` removes the entry; any other string replaces it. Null when nothing is left to merge.
|
|
7167
|
+
*/
|
|
7168
|
+
function secretMapPatch(value) {
|
|
7169
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
7170
|
+
const out = {};
|
|
7171
|
+
for (const [key, entry] of Object.entries(value)) if (entry === null || isSecretClear(entry)) out[key] = null;
|
|
7172
|
+
else if (typeof entry === "string" && entry !== "" && entry !== "__camstack_redacted__" && !isSecretFieldView(entry)) out[key] = entry;
|
|
7173
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
7174
|
+
}
|
|
7175
|
+
var RecordIntegrationInstances = class {
|
|
7176
|
+
records;
|
|
7177
|
+
kind;
|
|
7178
|
+
hooks;
|
|
7179
|
+
handle;
|
|
7180
|
+
secretKeys;
|
|
7181
|
+
secretMaps;
|
|
7182
|
+
nameField;
|
|
7183
|
+
switchListeners = /* @__PURE__ */ new Set();
|
|
7184
|
+
constructor(records, kind, hooks) {
|
|
7185
|
+
this.records = records;
|
|
7186
|
+
this.kind = kind;
|
|
7187
|
+
this.hooks = hooks;
|
|
7188
|
+
this.handle = records.kind(kind);
|
|
7189
|
+
this.secretKeys = topLevelSecretKeys(kind.declaration.secretPaths);
|
|
7190
|
+
this.secretMaps = secretMapKeys(kind.declaration.secretPaths);
|
|
7191
|
+
this.nameField = kind.declaration.entry?.nameField ?? "name";
|
|
7192
|
+
}
|
|
7193
|
+
/**
|
|
7194
|
+
* One instance read for the addon's own runtime; null when absent. When the keychain is
|
|
7195
|
+
* locked the row is served WITHOUT its secrets and says so (`secrets: 'unavailable'`), so the
|
|
7196
|
+
* caller can keep what it had instead of reconfiguring with an empty secret (D49).
|
|
7197
|
+
*/
|
|
7198
|
+
async readResolved(id) {
|
|
7199
|
+
try {
|
|
7200
|
+
const record = await this.handle.getResolved(id);
|
|
7201
|
+
return record === null ? null : {
|
|
7202
|
+
id,
|
|
7203
|
+
body: record.data,
|
|
7204
|
+
enabled: isInstanceEnabled(record.data),
|
|
7205
|
+
secrets: "resolved"
|
|
7206
|
+
};
|
|
7207
|
+
} catch (err) {
|
|
7208
|
+
if (!(err instanceof RecordSecretUnavailableError)) throw err;
|
|
7209
|
+
const stored = await this.handle.get(id);
|
|
7210
|
+
if (stored === null) return null;
|
|
7211
|
+
return {
|
|
7212
|
+
id,
|
|
7213
|
+
body: Object.fromEntries(Object.entries(stored.data).filter(([key]) => !this.secretKeys.has(key))),
|
|
7214
|
+
enabled: isInstanceEnabled(stored.data),
|
|
7215
|
+
secrets: "unavailable"
|
|
7216
|
+
};
|
|
7217
|
+
}
|
|
7218
|
+
}
|
|
7219
|
+
/** The body of {@link readResolved} alone; a locked keychain yields the row without secrets. */
|
|
7220
|
+
async getResolved(id) {
|
|
7221
|
+
return (await this.readResolved(id))?.body ?? null;
|
|
7222
|
+
}
|
|
7223
|
+
/** Every instance, secrets resolved where the keychain allows; an absent one is skipped. */
|
|
7224
|
+
async listResolved() {
|
|
7225
|
+
const page = await this.handle.query({ limit: INSTANCES_LIST_MAX });
|
|
7226
|
+
const out = [];
|
|
7227
|
+
for (const record of page.records) {
|
|
7228
|
+
const read = await this.readResolved(record.id);
|
|
7229
|
+
if (read !== null) out.push(read);
|
|
7230
|
+
}
|
|
7231
|
+
return out;
|
|
7232
|
+
}
|
|
7233
|
+
viewOf(record) {
|
|
7234
|
+
const shown = secretViewsOf(this.records.owner, this.kind.name, record.id, this.kind.declaration, record.data);
|
|
7235
|
+
const rawName = record.data[this.nameField];
|
|
7236
|
+
const kind = this.hooks.kindOf?.(record.data);
|
|
7237
|
+
const health = this.hooks.health?.(record.data, record.id);
|
|
7238
|
+
return {
|
|
7239
|
+
id: record.id,
|
|
7240
|
+
name: typeof rawName === "string" ? rawName : record.id,
|
|
7241
|
+
configured: this.hooks.configured(record.data),
|
|
7242
|
+
enabled: isInstanceEnabled(record.data),
|
|
7243
|
+
formValues: shown,
|
|
7244
|
+
...kind !== void 0 ? { kind } : {},
|
|
7245
|
+
...health?.status !== void 0 ? { status: health.status } : {},
|
|
7246
|
+
...health?.lastCheckedAt !== void 0 ? { lastCheckedAt: health.lastCheckedAt } : {}
|
|
7247
|
+
};
|
|
7248
|
+
}
|
|
7249
|
+
describe = async () => {
|
|
7250
|
+
const declared = linksOfKind(this.kind);
|
|
7251
|
+
const links = this.hooks.description.links !== void 0 || declared.length === 0 ? {} : { links: declared.map((link) => ({
|
|
7252
|
+
kind: this.kind.name,
|
|
7253
|
+
field: link.field,
|
|
7254
|
+
to: link.to
|
|
7255
|
+
})) };
|
|
7256
|
+
return {
|
|
7257
|
+
...this.hooks.description,
|
|
7258
|
+
...links,
|
|
7259
|
+
...this.hooks.mintId === void 0 ? {} : { mintsId: true }
|
|
7260
|
+
};
|
|
7261
|
+
};
|
|
7262
|
+
instanceFormSchema = async ({ id }) => this.hooks.formSchema(id);
|
|
7263
|
+
listInstances = async () => {
|
|
7264
|
+
return (await this.handle.query({ limit: INSTANCES_LIST_MAX })).records.map((record) => this.viewOf(record));
|
|
7265
|
+
};
|
|
7266
|
+
getInstance = async ({ id }) => {
|
|
7267
|
+
const record = await this.handle.get(id);
|
|
7268
|
+
return record === null ? null : this.viewOf(record);
|
|
7269
|
+
};
|
|
7270
|
+
/** Insert ONE row (`ifRev: 0`: an existing id is refused, never overwritten). */
|
|
7271
|
+
createInstance = async ({ id, name, kind, values }) => {
|
|
7272
|
+
const minted = id ?? this.hooks.mintId?.({
|
|
7273
|
+
name,
|
|
7274
|
+
...kind !== void 0 ? { kind } : {}
|
|
7275
|
+
});
|
|
7276
|
+
if (minted === void 0) throw new Error("createInstance needs an id: this provider does not mint one");
|
|
7277
|
+
await this.handle.put({
|
|
7278
|
+
id: minted,
|
|
7279
|
+
data: this.hooks.initialBody(name, kind)
|
|
7280
|
+
}, { ifRev: 0 });
|
|
7281
|
+
const patch = this.patchOf(values ?? {});
|
|
7282
|
+
if (Object.keys(patch).length > 0) await this.handle.patch(minted, patch);
|
|
7283
|
+
await this.hooks.onChanged?.(minted, "created");
|
|
7284
|
+
return { id: minted };
|
|
7285
|
+
};
|
|
7286
|
+
updateInstance = async ({ id, name, values }) => {
|
|
7287
|
+
const patch = this.patchOf(values);
|
|
7288
|
+
if (name !== void 0) patch[this.nameField] = name;
|
|
7289
|
+
await this.handle.patch(id, patch);
|
|
7290
|
+
await this.hooks.onChanged?.(id, "updated");
|
|
7291
|
+
return { ok: true };
|
|
7292
|
+
};
|
|
7293
|
+
/** The record patch a form `values` object means: secrets kept, cleared or replaced. */
|
|
7294
|
+
patchOf(values) {
|
|
7295
|
+
const patch = {};
|
|
7296
|
+
for (const [key, value] of Object.entries(values)) {
|
|
7297
|
+
if (value === void 0) continue;
|
|
7298
|
+
if (key === "enabled") continue;
|
|
7299
|
+
if (this.secretMaps.has(key)) {
|
|
7300
|
+
const entries = secretMapPatch(value);
|
|
7301
|
+
if (entries !== null) patch[key] = entries;
|
|
7302
|
+
} else if (!this.secretKeys.has(key)) patch[key] = value;
|
|
7303
|
+
else if (isSecretClear(value)) patch[key] = null;
|
|
7304
|
+
else if (typeof value === "string" && value !== "" && value !== "__camstack_redacted__" && !isSecretFieldView(value)) patch[key] = value;
|
|
7305
|
+
}
|
|
7306
|
+
return patch;
|
|
7307
|
+
}
|
|
7308
|
+
/**
|
|
7309
|
+
* Listen to the switch of ANY instance of this helper (in the process that serves it). The
|
|
7310
|
+
* durable replacement of the `integration.enabled` / `integration.disabled` events (D8): the
|
|
7311
|
+
* addon reconciles from the record, whichever way it flipped.
|
|
7312
|
+
*/
|
|
7313
|
+
onSwitched(listener) {
|
|
7314
|
+
this.switchListeners.add(listener);
|
|
7315
|
+
}
|
|
7316
|
+
/**
|
|
7317
|
+
* The ONE writer of the switch (D996, D1014): the record body's `enabled`, for every role. A
|
|
7318
|
+
* configured thing is an instance, so an id with no record yet (a default integration that was
|
|
7319
|
+
* never configured) gets its record from `initialBody` before the switch is written.
|
|
7320
|
+
*/
|
|
7321
|
+
setInstanceEnabled = async ({ id, enabled }) => {
|
|
7322
|
+
if (await this.handle.get(id) === null) await this.handle.put({
|
|
7323
|
+
id,
|
|
7324
|
+
data: this.hooks.initialBody(id)
|
|
7325
|
+
}, { ifRev: 0 });
|
|
7326
|
+
await this.handle.patch(id, { [SWITCH_KEY]: enabled });
|
|
7327
|
+
await this.hooks.onChanged?.(id, "enabled");
|
|
7328
|
+
for (const listener of this.switchListeners) await listener(id, enabled);
|
|
7329
|
+
return { ok: true };
|
|
7330
|
+
};
|
|
7331
|
+
/** The row and its keychain refs go together (the records door). */
|
|
7332
|
+
deleteInstance = async ({ id }) => {
|
|
7333
|
+
await this.handle.delete(id);
|
|
7334
|
+
await this.hooks.onChanged?.(id, "deleted");
|
|
7335
|
+
return { ok: true };
|
|
7336
|
+
};
|
|
7337
|
+
testInstance = async (input) => {
|
|
7338
|
+
if (this.hooks.test === void 0) return {
|
|
7339
|
+
success: false,
|
|
7340
|
+
status: "unsupported",
|
|
7341
|
+
error: "This integration declares no connection test"
|
|
7342
|
+
};
|
|
7343
|
+
return this.hooks.test(input);
|
|
7344
|
+
};
|
|
7345
|
+
/** The flat provider object to hand to `registerProvider`. */
|
|
7346
|
+
toProvider() {
|
|
7347
|
+
return {
|
|
7348
|
+
describe: this.describe,
|
|
7349
|
+
instanceFormSchema: this.instanceFormSchema,
|
|
7350
|
+
listInstances: this.listInstances,
|
|
7351
|
+
getInstance: this.getInstance,
|
|
7352
|
+
createInstance: this.createInstance,
|
|
7353
|
+
updateInstance: this.updateInstance,
|
|
7354
|
+
setInstanceEnabled: this.setInstanceEnabled,
|
|
7355
|
+
deleteInstance: this.deleteInstance,
|
|
7356
|
+
testInstance: this.testInstance
|
|
7357
|
+
};
|
|
7358
|
+
}
|
|
7359
|
+
};
|
|
7360
|
+
/**
|
|
7361
|
+
* The one-switch migration (epic #440, step 3). An integration's on/off state used to live in the
|
|
7362
|
+
* registry's `enabled` column for a device addon; it now lives only in the instance record's
|
|
7363
|
+
* body (`enabled`, D1007 superseded). This moves a registry `enabled = false` into the body, once.
|
|
7364
|
+
*
|
|
7365
|
+
* For every registry row of the addon that is OFF:
|
|
7366
|
+
* - no record under its id: the record is created from `initialBody(name)` with `enabled: false`
|
|
7367
|
+
* (a default integration that never had settings, Reolink's among them);
|
|
7368
|
+
* - a record whose body states no `enabled`: `enabled: false` is written into it;
|
|
7369
|
+
* - a record that already states `enabled` (either way) is the newer truth and is left alone.
|
|
7370
|
+
* A row that is ON writes nothing: an absent field reads as on. A rerun finds every off row with
|
|
7371
|
+
* a stated switch and does nothing, so nothing switched off comes back on and nothing switched on
|
|
7372
|
+
* goes off. The registry column itself is neither read afterwards nor written; its drop is step 5.
|
|
7373
|
+
*
|
|
7374
|
+
* It runs in the addon process, BEFORE the addon registers its `integration-instances` provider,
|
|
7375
|
+
* which is also why `integrations.list` still answers the stored column at that point.
|
|
7376
|
+
*/
|
|
7377
|
+
function registrySwitchToBodyId(addonId, date) {
|
|
7378
|
+
return `${addonId}/${date}-registry-switch-to-body`;
|
|
7379
|
+
}
|
|
7380
|
+
async function moveRegistrySwitchToBody(ports, spec) {
|
|
7381
|
+
const rows = await ports.listIntegrations(spec.addonId);
|
|
7382
|
+
let created = 0;
|
|
7383
|
+
let switched = 0;
|
|
7384
|
+
let kept = 0;
|
|
7385
|
+
for (const row of rows) {
|
|
7386
|
+
if (row.enabled) continue;
|
|
7387
|
+
const body = await ports.readBody(row.id);
|
|
7388
|
+
if (body === null) {
|
|
7389
|
+
if (await ports.createRecord(row.id, {
|
|
7390
|
+
...spec.initialBody(row.name),
|
|
7391
|
+
["enabled"]: false
|
|
7392
|
+
}) === "created") created += 1;
|
|
7393
|
+
else kept += 1;
|
|
7394
|
+
continue;
|
|
7395
|
+
}
|
|
7396
|
+
if ("enabled" in body) {
|
|
7397
|
+
kept += 1;
|
|
7398
|
+
continue;
|
|
7399
|
+
}
|
|
7400
|
+
await ports.switchOff(row.id);
|
|
7401
|
+
switched += 1;
|
|
7402
|
+
}
|
|
7403
|
+
const summary = {
|
|
7404
|
+
integrations: rows.length,
|
|
7405
|
+
off: rows.filter((r) => !r.enabled).length,
|
|
7406
|
+
created,
|
|
7407
|
+
switched,
|
|
7408
|
+
kept
|
|
7409
|
+
};
|
|
7410
|
+
return created === 0 && switched === 0 ? {
|
|
7411
|
+
outcome: "already-done",
|
|
7412
|
+
summary
|
|
7413
|
+
} : {
|
|
7414
|
+
outcome: "done",
|
|
7415
|
+
summary
|
|
7416
|
+
};
|
|
7417
|
+
}
|
|
7418
|
+
/** A migration entry: an addon states its kind and how a new body starts, nothing else. */
|
|
7419
|
+
function registrySwitchToBodyMigration(spec) {
|
|
7420
|
+
return {
|
|
7421
|
+
meta: {
|
|
7422
|
+
id: registrySwitchToBodyId(spec.addonId, spec.date),
|
|
7423
|
+
owner: spec.addonId,
|
|
7424
|
+
level: "addon-state",
|
|
7425
|
+
seq: spec.seq,
|
|
7426
|
+
dependsOn: [],
|
|
7427
|
+
store: "settings",
|
|
7428
|
+
scope: "cluster",
|
|
7429
|
+
onFailure: "block-owner",
|
|
7430
|
+
bodyKind: "procedure",
|
|
7431
|
+
description: "Move the registry enabled flag of every switched-off integration into its instance record body, creating the record when there is none",
|
|
7432
|
+
fixtureMark: "enabled"
|
|
7433
|
+
},
|
|
7434
|
+
procedure: (ports) => moveRegistrySwitchToBody(ports, {
|
|
7435
|
+
addonId: spec.addonId,
|
|
7436
|
+
initialBody: spec.initialBody
|
|
7437
|
+
})
|
|
7438
|
+
};
|
|
7439
|
+
}
|
|
7440
|
+
/** The hub and records sides of {@link registrySwitchToBodyMigration}. */
|
|
7441
|
+
function registrySwitchPortsOver(deps) {
|
|
7442
|
+
const handle = deps.records.kind({
|
|
7443
|
+
name: deps.kind.name,
|
|
7444
|
+
declaration: deps.kind.declaration,
|
|
7445
|
+
schema: record(string(), unknown())
|
|
7446
|
+
});
|
|
7447
|
+
return {
|
|
7448
|
+
listIntegrations: async (addonId) => (await deps.api.list()).filter((i) => i.addonId === addonId).map((i) => ({
|
|
7449
|
+
id: i.id,
|
|
7450
|
+
name: i.name,
|
|
7451
|
+
enabled: i.enabled !== false
|
|
7452
|
+
})),
|
|
7453
|
+
readBody: async (id) => (await handle.get(id))?.data ?? null,
|
|
7454
|
+
createRecord: async (id, body) => {
|
|
7455
|
+
if (await handle.get(id) !== null) return "exists";
|
|
7456
|
+
await handle.put({
|
|
7457
|
+
id,
|
|
7458
|
+
data: body
|
|
7459
|
+
}, { ifRev: 0 });
|
|
7460
|
+
return "created";
|
|
7461
|
+
},
|
|
7462
|
+
switchOff: async (id) => {
|
|
7463
|
+
await handle.patch(id, { [SWITCH_KEY]: false });
|
|
7464
|
+
}
|
|
7465
|
+
};
|
|
7466
|
+
}
|
|
7467
|
+
/**
|
|
7468
|
+
* Build a {@link DurableState} over a single store key. Transport-agnostic:
|
|
7469
|
+
* `read`/`write` are the addon-store or device-store accessors. The whole
|
|
7470
|
+
* validated value round-trips on every read/write — the schema is the
|
|
7471
|
+
* single source of truth for what is persisted, so no hand-listed field
|
|
7472
|
+
* set can drop a key on save.
|
|
7473
|
+
*/
|
|
7474
|
+
function createDurableState(deps) {
|
|
7475
|
+
const get = async () => {
|
|
7476
|
+
const raw = (await deps.read())[deps.key];
|
|
7477
|
+
if (raw === void 0) return deps.fallback;
|
|
7478
|
+
const parsed = deps.schema.safeParse(raw);
|
|
7479
|
+
if (!parsed.success) {
|
|
7480
|
+
deps.onParseError?.(deps.key, parsed.error);
|
|
7481
|
+
return deps.fallback;
|
|
7482
|
+
}
|
|
7483
|
+
return parsed.data;
|
|
7484
|
+
};
|
|
7485
|
+
const set = async (next) => {
|
|
7486
|
+
const validated = deps.schema.parse(next);
|
|
7487
|
+
await deps.write({ [deps.key]: validated });
|
|
7488
|
+
};
|
|
7489
|
+
const update = async (fn) => {
|
|
7490
|
+
await set(fn(await get()));
|
|
7491
|
+
};
|
|
7492
|
+
return {
|
|
7493
|
+
get,
|
|
7494
|
+
set,
|
|
7495
|
+
update
|
|
7496
|
+
};
|
|
7497
|
+
}
|
|
7498
|
+
/**
|
|
7499
|
+
* Per-node scoping for the shared addon-settings blob.
|
|
7500
|
+
*
|
|
7501
|
+
* An addon's settings store is hub-central (the `addon-settings` cap is
|
|
7502
|
+
* hub-routed — the hub instance answers for every node), so fields whose
|
|
7503
|
+
* value is a NODE fact (decoder backend, engine pick, probed hardware
|
|
7504
|
+
* capabilities) persist under the node-scoped key `<key>@<nodeId>` inside
|
|
7505
|
+
* the single shared blob. The SCHEMA field key stays bare: the write path
|
|
7506
|
+
* ({@link scopePatch}) maps the bare field onto the target node's scoped
|
|
7507
|
+
* key; the read/hydrate path ({@link projectStore}) maps THIS node's value
|
|
7508
|
+
* back, so UI forms and `resolveConfig` only ever see the bare key.
|
|
7509
|
+
*
|
|
7510
|
+
* Fields opt in via the `perNode: true` schema marker on `ConfigFieldBase`
|
|
7511
|
+
* (`interfaces/config-ui.ts`); `BaseAddon` derives the key set from the
|
|
7512
|
+
* schema and routes reads/writes through these helpers.
|
|
7513
|
+
*
|
|
7514
|
+
* ## No bare-key fallback — deliberate
|
|
7515
|
+
*
|
|
7516
|
+
* {@link readNodeValue} reads the node-scoped key ONLY. A node with no
|
|
7517
|
+
* scoped key resolves to `undefined` so the field's schema `default` wins —
|
|
7518
|
+
* NEVER `store[base]` and never another node's value. A bare legacy key in
|
|
7519
|
+
* the store is invisible to every node, hub included, so one node's
|
|
7520
|
+
* selection can never leak onto another. (This generalizes the
|
|
7521
|
+
* `decoder-backend-keys.ts` semantics — the no-fallback variant — over an
|
|
7522
|
+
* arbitrary set of per-node field keys.)
|
|
7523
|
+
*
|
|
7524
|
+
* Pure functions only — no I/O, no imports beyond the language. This is a
|
|
7525
|
+
* LEAF module: import it via its deep path, never from the root barrel.
|
|
7526
|
+
*/
|
|
7527
|
+
/**
|
|
7528
|
+
* Normalize a raw kernel node id to the bare node id used for scoping.
|
|
7529
|
+
* `localNodeId` can carry a `<node>/<addon>` suffix on forked child
|
|
7530
|
+
* processes; per-node settings are per-NODE, so strip the addon segment.
|
|
7531
|
+
* `undefined` / `null` / empty falls back to `'hub'`.
|
|
7532
|
+
*/
|
|
7533
|
+
function normalizeNodeId(raw) {
|
|
7534
|
+
if (raw === void 0 || raw === null || raw === "") return "hub";
|
|
7535
|
+
const slashIdx = raw.indexOf("/");
|
|
7536
|
+
if (slashIdx < 0) return raw;
|
|
7537
|
+
const bare = raw.slice(0, slashIdx);
|
|
7538
|
+
return bare === "" ? "hub" : bare;
|
|
7539
|
+
}
|
|
7540
|
+
/** The node-scoped store key for a per-node field: `<base>@<nodeId>`. */
|
|
7541
|
+
function nodeScopedKey(base, nodeId) {
|
|
7542
|
+
return `${base}@${normalizeNodeId(nodeId)}`;
|
|
7543
|
+
}
|
|
7544
|
+
/**
|
|
7545
|
+
* Read a node's value for a per-node field from the raw shared store:
|
|
7546
|
+
* the node-scoped key when present, otherwise `undefined`.
|
|
7547
|
+
* Deliberately NO bare-key fallback (see module doc) — the caller lets the
|
|
7548
|
+
* schema `default` win on `undefined`.
|
|
7549
|
+
*/
|
|
7550
|
+
function readNodeValue(store, base, nodeId) {
|
|
7551
|
+
return store[nodeScopedKey(base, nodeId)];
|
|
7552
|
+
}
|
|
7553
|
+
/**
|
|
7554
|
+
* Re-map a UI/settings patch so every bare perNode field persists under the
|
|
7555
|
+
* TARGET node's scoped key; all other keys pass through unchanged. Used on
|
|
7556
|
+
* the write path so a save for one node never clobbers another node's value
|
|
7557
|
+
* (and the bare key is never written). Returns a new object — the input
|
|
7558
|
+
* patch is not mutated.
|
|
7559
|
+
*/
|
|
7560
|
+
function scopePatch(patch, perNodeKeys, nodeId) {
|
|
7561
|
+
const out = {};
|
|
7562
|
+
for (const [key, value] of Object.entries(patch)) out[perNodeKeys.has(key) ? nodeScopedKey(key, nodeId) : key] = value;
|
|
7563
|
+
return out;
|
|
7564
|
+
}
|
|
7565
|
+
/**
|
|
7566
|
+
* Project the raw shared store onto the bare schema keys for ONE node so a
|
|
7567
|
+
* UI schema (whose field keys are bare) hydrates from that node's own
|
|
7568
|
+
* values:
|
|
7569
|
+
*
|
|
7570
|
+
* - EVERY `@`-scoped key (any node's) is dropped from the pass-through.
|
|
7571
|
+
* - Every bare perNode key is dropped from the pass-through (a stray bare
|
|
7572
|
+
* legacy key must never hydrate any node — no bare fallback).
|
|
7573
|
+
* - THIS node's effective value ({@link readNodeValue}) is then laid onto
|
|
7574
|
+
* each bare perNode key; when the node has no scoped key the bare key is
|
|
7575
|
+
* left ABSENT so the field's schema `default` wins.
|
|
7576
|
+
*
|
|
7577
|
+
* Returns a new object — the input store is not mutated.
|
|
7578
|
+
*/
|
|
7579
|
+
function projectStore(store, perNodeKeys, nodeId) {
|
|
7580
|
+
const out = {};
|
|
7581
|
+
for (const [key, value] of Object.entries(store)) {
|
|
7582
|
+
if (key.includes("@")) continue;
|
|
7583
|
+
if (perNodeKeys.has(key)) continue;
|
|
7584
|
+
out[key] = value;
|
|
7585
|
+
}
|
|
7586
|
+
for (const base of perNodeKeys) {
|
|
7587
|
+
const value = readNodeValue(store, base, nodeId);
|
|
7588
|
+
if (value !== void 0) out[base] = value;
|
|
7589
|
+
}
|
|
7590
|
+
return out;
|
|
7591
|
+
}
|
|
7592
|
+
/** Every scope, in the order the guard and the docs list them. */
|
|
7593
|
+
var SCOPED_SETTINGS_SCOPES = [
|
|
7594
|
+
"cluster",
|
|
7595
|
+
"device-defaults",
|
|
7596
|
+
"node"
|
|
7597
|
+
];
|
|
6996
7598
|
/** A write to a secret path could not be stored in the keychain, so it was refused. */
|
|
6997
7599
|
var KeychainWriteRefusedError = class extends Error {
|
|
6998
7600
|
fieldPath;
|
|
@@ -7003,7 +7605,7 @@ var KeychainWriteRefusedError = class extends Error {
|
|
|
7003
7605
|
}
|
|
7004
7606
|
};
|
|
7005
7607
|
var KEEP = { kind: "keep" };
|
|
7006
|
-
function isRecord$
|
|
7608
|
+
function isRecord$4$1(value) {
|
|
7007
7609
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
7008
7610
|
}
|
|
7009
7611
|
function defaultMintId() {
|
|
@@ -7016,7 +7618,7 @@ function collectSecretMarkerRefs(value, out = []) {
|
|
|
7016
7618
|
return out;
|
|
7017
7619
|
}
|
|
7018
7620
|
if (Array.isArray(value)) for (const item of value) collectSecretMarkerRefs(item, out);
|
|
7019
|
-
else if (isRecord$
|
|
7621
|
+
else if (isRecord$4$1(value)) for (const child of Object.values(value)) collectSecretMarkerRefs(child, out);
|
|
7020
7622
|
return out;
|
|
7021
7623
|
}
|
|
7022
7624
|
async function storedValueEquals(secrets, marker, candidate) {
|
|
@@ -7071,7 +7673,7 @@ function rowId(row, idKey) {
|
|
|
7071
7673
|
return null;
|
|
7072
7674
|
}
|
|
7073
7675
|
async function writeArray(ctx, node, incoming, stored, fieldPath) {
|
|
7074
|
-
const storedRows = Array.isArray(stored) ? stored.filter(isRecord$
|
|
7676
|
+
const storedRows = Array.isArray(stored) ? stored.filter(isRecord$4$1) : [];
|
|
7075
7677
|
if (!Array.isArray(incoming)) {
|
|
7076
7678
|
ctx.deletes.push(...collectSecretMarkerRefs(storedRows));
|
|
7077
7679
|
return {
|
|
@@ -7087,7 +7689,7 @@ async function writeArray(ctx, node, incoming, stored, fieldPath) {
|
|
|
7087
7689
|
const kept = /* @__PURE__ */ new Set();
|
|
7088
7690
|
const rows = [];
|
|
7089
7691
|
for (const row of incoming) {
|
|
7090
|
-
if (!isRecord$
|
|
7692
|
+
if (!isRecord$4$1(row)) {
|
|
7091
7693
|
rows.push(row);
|
|
7092
7694
|
continue;
|
|
7093
7695
|
}
|
|
@@ -7112,7 +7714,7 @@ async function writeNode(ctx, node, incoming, stored, fieldPath) {
|
|
|
7112
7714
|
case "leaf": return writeLeaf(ctx, incoming, stored, fieldPath);
|
|
7113
7715
|
case "array": return writeArray(ctx, node, incoming, stored, fieldPath);
|
|
7114
7716
|
case "object": {
|
|
7115
|
-
if (!isRecord$
|
|
7717
|
+
if (!isRecord$4$1(incoming)) {
|
|
7116
7718
|
if (incoming === void 0) return KEEP;
|
|
7117
7719
|
ctx.deletes.push(...collectSecretMarkerRefs(stored));
|
|
7118
7720
|
return {
|
|
@@ -7120,7 +7722,7 @@ async function writeNode(ctx, node, incoming, stored, fieldPath) {
|
|
|
7120
7722
|
value: incoming
|
|
7121
7723
|
};
|
|
7122
7724
|
}
|
|
7123
|
-
const storedObj = isRecord$
|
|
7725
|
+
const storedObj = isRecord$4$1(stored) ? stored : {};
|
|
7124
7726
|
return {
|
|
7125
7727
|
kind: "value",
|
|
7126
7728
|
value: await writeMembers(ctx, node.children, incoming, storedObj, (m) => `${fieldPath}.${m}`)
|
|
@@ -7243,7 +7845,7 @@ async function resolveSecretMarkers(value, secrets, options = {}) {
|
|
|
7243
7845
|
value: items
|
|
7244
7846
|
};
|
|
7245
7847
|
}
|
|
7246
|
-
if (isRecord$
|
|
7848
|
+
if (isRecord$4$1(v)) {
|
|
7247
7849
|
const out = {};
|
|
7248
7850
|
for (const [k, child] of Object.entries(v)) {
|
|
7249
7851
|
const s = substitute(child);
|
|
@@ -7275,7 +7877,7 @@ var SecretOverwriteRefusedError = class extends Error {
|
|
|
7275
7877
|
this.name = "SecretOverwriteRefusedError";
|
|
7276
7878
|
}
|
|
7277
7879
|
};
|
|
7278
|
-
function isRecord$
|
|
7880
|
+
function isRecord$3$1(value) {
|
|
7279
7881
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
7280
7882
|
}
|
|
7281
7883
|
function resolvedValue(pass, marker) {
|
|
@@ -7304,7 +7906,7 @@ function inArray(pass, incoming, stored) {
|
|
|
7304
7906
|
return incoming.map((item, index) => {
|
|
7305
7907
|
const twin = stored[index];
|
|
7306
7908
|
if (isSecretMarker(twin)) return typeof item === "string" && item === resolvedValue(pass, twin) ? twin : item;
|
|
7307
|
-
if (isRecord$
|
|
7909
|
+
if (isRecord$3$1(item) && isRecord$3$1(twin)) return inArrayRow(pass, item, twin);
|
|
7308
7910
|
if (Array.isArray(item) && Array.isArray(twin)) return inArray(pass, item, twin);
|
|
7309
7911
|
return item;
|
|
7310
7912
|
});
|
|
@@ -7315,7 +7917,7 @@ function inArrayRow(pass, incoming, stored) {
|
|
|
7315
7917
|
const twin = stored[key];
|
|
7316
7918
|
if (isSecretMarker(twin)) {
|
|
7317
7919
|
if (typeof value === "string" && value === resolvedValue(pass, twin)) out[key] = twin;
|
|
7318
|
-
} else if (isRecord$
|
|
7920
|
+
} else if (isRecord$3$1(value) && isRecord$3$1(twin)) out[key] = inArrayRow(pass, value, twin);
|
|
7319
7921
|
else if (Array.isArray(value) && Array.isArray(twin)) out[key] = inArray(pass, value, twin);
|
|
7320
7922
|
}
|
|
7321
7923
|
return out;
|
|
@@ -7323,7 +7925,7 @@ function inArrayRow(pass, incoming, stored) {
|
|
|
7323
7925
|
async function walk(pass, incoming, stored) {
|
|
7324
7926
|
if (isSecretMarker(stored)) return atMarker(pass, incoming, stored);
|
|
7325
7927
|
if (Array.isArray(incoming) && Array.isArray(stored)) return inArray(pass, incoming, stored);
|
|
7326
|
-
if (!isRecord$
|
|
7928
|
+
if (!isRecord$3$1(incoming) || !isRecord$3$1(stored)) return incoming;
|
|
7327
7929
|
const out = { ...incoming };
|
|
7328
7930
|
for (const [key, value] of Object.entries(incoming)) out[key] = await walk(pass, value, stored[key]);
|
|
7329
7931
|
return out;
|
|
@@ -7354,7 +7956,7 @@ async function restoreUnchangedSecrets(input) {
|
|
|
7354
7956
|
function isJsonEqual(a, b) {
|
|
7355
7957
|
if (Object.is(a, b)) return true;
|
|
7356
7958
|
if (Array.isArray(a)) return Array.isArray(b) && a.length === b.length && a.every((v, i) => isJsonEqual(v, b[i]));
|
|
7357
|
-
if (isRecord$
|
|
7959
|
+
if (isRecord$3$1(a) && isRecord$3$1(b)) {
|
|
7358
7960
|
const keys = Object.keys(a);
|
|
7359
7961
|
if (keys.length !== Object.keys(b).length) return false;
|
|
7360
7962
|
return keys.every((k) => Object.prototype.hasOwnProperty.call(b, k) && isJsonEqual(a[k], b[k]));
|
|
@@ -7378,7 +7980,7 @@ function markerKeysOf(blob, owner) {
|
|
|
7378
7980
|
}
|
|
7379
7981
|
function nodeFromStored(value, owner) {
|
|
7380
7982
|
if (isSecretMarker(value)) return isSecretRefOwnedBy(value.$secret, owner) ? { kind: "leaf" } : null;
|
|
7381
|
-
if (!isRecord$
|
|
7983
|
+
if (!isRecord$3$1(value)) return null;
|
|
7382
7984
|
const children = /* @__PURE__ */ new Map();
|
|
7383
7985
|
for (const [key, child] of Object.entries(value)) {
|
|
7384
7986
|
const node = nodeFromStored(child, owner);
|
|
@@ -7875,13 +8477,48 @@ var BaseAddon = class {
|
|
|
7875
8477
|
if (api === void 0) throw new Error(`${this.ctx.id}: the migration API is not available on this context`);
|
|
7876
8478
|
return this.runBound(sets.flatMap((set) => set.bind(api)), options);
|
|
7877
8479
|
}
|
|
7878
|
-
|
|
8480
|
+
/**
|
|
8481
|
+
* A device addon states its instance kind once, before its migrations run, and the one-switch
|
|
8482
|
+
* migration (registry `enabled` to the instance body, epic #440 step 3) joins whatever
|
|
8483
|
+
* catalogue the addon runs, in the SAME declaration (the engine replaces an owner's catalogue
|
|
8484
|
+
* on every declare).
|
|
8485
|
+
*/
|
|
8486
|
+
declareRegistrySwitch(spec) {
|
|
8487
|
+
this.registrySwitchSpec = spec;
|
|
8488
|
+
}
|
|
8489
|
+
registrySwitchSpec = null;
|
|
8490
|
+
registrySwitchBound() {
|
|
8491
|
+
const spec = this.registrySwitchSpec;
|
|
8492
|
+
if (spec === null) return [];
|
|
8493
|
+
const api = this.ctx.migrationApi;
|
|
8494
|
+
if (api === void 0) {
|
|
8495
|
+
this.ctx.logger.warn("the registry switch migration is skipped: the migration API is not available on this context");
|
|
8496
|
+
return [];
|
|
8497
|
+
}
|
|
8498
|
+
return migrationSet([registrySwitchToBodyMigration({
|
|
8499
|
+
addonId: this.ctx.id,
|
|
8500
|
+
date: "2026-11-06",
|
|
8501
|
+
seq: 1e3,
|
|
8502
|
+
initialBody: spec.initialBody
|
|
8503
|
+
})], (a) => registrySwitchPortsOver({
|
|
8504
|
+
api: integrationRowsApiOver(a),
|
|
8505
|
+
records: switchRecordsOverMigrationApi(a),
|
|
8506
|
+
kind: spec.kind
|
|
8507
|
+
})).bind(api);
|
|
8508
|
+
}
|
|
8509
|
+
async runBound(declared, options) {
|
|
8510
|
+
const added = this.registrySwitchBound();
|
|
8511
|
+
const migrations = [...declared, ...added];
|
|
8512
|
+
const phased = options?.only === void 0 || added.length === 0 ? options : {
|
|
8513
|
+
...options,
|
|
8514
|
+
only: [...options.only, ...added.map((m) => m.meta.id)]
|
|
8515
|
+
};
|
|
7879
8516
|
const api = this.ctx.api.migrations;
|
|
7880
8517
|
const report = await runAddonMigrations({
|
|
7881
8518
|
declareOwner: (input) => api.declareOwner.mutate(input),
|
|
7882
8519
|
startMigration: (input) => api.startMigration.mutate(input),
|
|
7883
8520
|
finishMigration: (input) => api.finishMigration.mutate(input)
|
|
7884
|
-
}, migrations, this.ctx.logger,
|
|
8521
|
+
}, migrations, this.ctx.logger, phased);
|
|
7885
8522
|
if (report.blockOwner.length > 0) throw new AddonMigrationsBlockedError(report.blockOwner);
|
|
7886
8523
|
return report;
|
|
7887
8524
|
}
|
|
@@ -8954,228 +9591,6 @@ function normalizeAddonInitResult(result) {
|
|
|
8954
9591
|
if (Array.isArray(result)) return { providers: result };
|
|
8955
9592
|
return result;
|
|
8956
9593
|
}
|
|
8957
|
-
var PREFIX = "records/";
|
|
8958
|
-
/** The base of every records failure. `detail` is the human part of the message. */
|
|
8959
|
-
var RecordsError = class extends Error {
|
|
8960
|
-
code;
|
|
8961
|
-
detail;
|
|
8962
|
-
constructor(code, detail) {
|
|
8963
|
-
super(`${PREFIX}${code}: ${detail}`);
|
|
8964
|
-
this.name = "RecordsError";
|
|
8965
|
-
this.code = code;
|
|
8966
|
-
this.detail = detail;
|
|
8967
|
-
}
|
|
8968
|
-
};
|
|
8969
|
-
/** A write guarded by `ifRev` found another revision (or an existing row for `ifRev: 0`). */
|
|
8970
|
-
var RevConflictError = class extends RecordsError {
|
|
8971
|
-
constructor(detail) {
|
|
8972
|
-
super("rev-conflict", detail);
|
|
8973
|
-
this.name = "RevConflictError";
|
|
8974
|
-
}
|
|
8975
|
-
};
|
|
8976
|
-
/** The keychain could not store or serve a value: explicit, never an empty string. */
|
|
8977
|
-
var RecordSecretUnavailableError = class extends RecordsError {
|
|
8978
|
-
constructor(detail) {
|
|
8979
|
-
super("secret-unavailable", detail);
|
|
8980
|
-
this.name = "RecordSecretUnavailableError";
|
|
8981
|
-
}
|
|
8982
|
-
};
|
|
8983
|
-
var InvalidRecordError = class extends RecordsError {
|
|
8984
|
-
constructor(detail) {
|
|
8985
|
-
super("invalid-record", detail);
|
|
8986
|
-
this.name = "InvalidRecordError";
|
|
8987
|
-
}
|
|
8988
|
-
};
|
|
8989
|
-
/** A kind declaration the engine refuses (non-additive change, bad shape, wrong file). */
|
|
8990
|
-
var DeclarationRefusedError = class extends RecordsError {
|
|
8991
|
-
constructor(detail) {
|
|
8992
|
-
super("declaration-refused", detail);
|
|
8993
|
-
this.name = "DeclarationRefusedError";
|
|
8994
|
-
}
|
|
8995
|
-
};
|
|
8996
|
-
/** Limits and names of `ctx.records` (D881). One authority; the engine and the facade read it. */
|
|
8997
|
-
/** The files a kind may live in. `history` is device-history's (D850), never records. */
|
|
8998
|
-
var RECORD_DATABASES = ["settings", "events"];
|
|
8999
|
-
/** The classes of a kind: the `@durable` vocabulary, one authority (replaces the annotation). */
|
|
9000
|
-
var RECORD_CLASSES = [
|
|
9001
|
-
"config",
|
|
9002
|
-
"ledger",
|
|
9003
|
-
"audit",
|
|
9004
|
-
"mirror",
|
|
9005
|
-
"ephemeral"
|
|
9006
|
-
];
|
|
9007
|
-
var RECORD_DEFAULT_MAX_DATA_BYTES = 64 * 1024;
|
|
9008
|
-
var RECORD_MAX_DATA_BYTES = 1024 * 1024;
|
|
9009
|
-
var RECORD_BATCH_MAX = 1e3;
|
|
9010
|
-
var RECORD_QUERY_MAX = 1e3;
|
|
9011
|
-
/** A kind in the `settings` file may not bound itself above this (D447: high volume is `events`). */
|
|
9012
|
-
var RECORD_SETTINGS_MAX_ROWS = 5e4;
|
|
9013
|
-
/** The `Column`-free proof prefix: a ref collection for a kind's secret fields. */
|
|
9014
|
-
var RECORD_SECRET_COLLECTION_PREFIX = "records:";
|
|
9015
|
-
/**
|
|
9016
|
-
* Secret fields of a record, addressed the way a Zod schema declares them
|
|
9017
|
-
* (`collectZodSecretPaths`): dotted keys, `[]` for "each element of this array"
|
|
9018
|
-
* (`auth.accessToken`, `headers[].value`).
|
|
9019
|
-
*
|
|
9020
|
-
* `{}` after a key means "each value of this map" (`applianceSecrets{}`): the map's own key
|
|
9021
|
-
* names the value (`applianceSecrets{fridge}`), so no id key is declared and a body that drops a
|
|
9022
|
-
* key releases exactly that key's secret.
|
|
9023
|
-
*
|
|
9024
|
-
* Array elements are matched by a DECLARED id key (`secretArrayKeys`:
|
|
9025
|
-
* `{ headers: 'name' }`), never by index, so reordering a list keeps every
|
|
9026
|
-
* keychain ref (the same rule as `arrayItemFieldPath`). The concrete path of a
|
|
9027
|
-
* leaf names its element: `headers[X-Api].value`.
|
|
9028
|
-
*
|
|
9029
|
-
* Pure and browser-safe. Nothing here mutates: every transform returns a new value.
|
|
9030
|
-
*/
|
|
9031
|
-
/** The array prefixes (declared form, e.g. `headers` or `a.headers`) of a path. */
|
|
9032
|
-
function parseSecretPath(path) {
|
|
9033
|
-
if (path.length === 0) throw new InvalidRecordError("an empty secret path");
|
|
9034
|
-
return path.split(".").map((raw) => {
|
|
9035
|
-
const each = raw.endsWith("[]");
|
|
9036
|
-
const map = raw.endsWith("{}");
|
|
9037
|
-
const key = each || map ? raw.slice(0, -2) : raw;
|
|
9038
|
-
if (key.length === 0) throw new InvalidRecordError(`secret path "${path}" has an empty segment`);
|
|
9039
|
-
return {
|
|
9040
|
-
key,
|
|
9041
|
-
each,
|
|
9042
|
-
map
|
|
9043
|
-
};
|
|
9044
|
-
});
|
|
9045
|
-
}
|
|
9046
|
-
/** The declared array prefixes of `path` that need an id key (`headers`, `a.rows`). */
|
|
9047
|
-
function secretArrayPrefixes(path) {
|
|
9048
|
-
const segments = parseSecretPath(path);
|
|
9049
|
-
const prefixes = [];
|
|
9050
|
-
const walked = [];
|
|
9051
|
-
for (const segment of segments) {
|
|
9052
|
-
walked.push(segment.key);
|
|
9053
|
-
if (segment.each) prefixes.push(walked.join("."));
|
|
9054
|
-
}
|
|
9055
|
-
return prefixes;
|
|
9056
|
-
}
|
|
9057
|
-
function isRecord$4$1(value) {
|
|
9058
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9059
|
-
}
|
|
9060
|
-
function elementId(element, idKey, declared, prefix) {
|
|
9061
|
-
const id = isRecord$4$1(element) ? element[idKey] : void 0;
|
|
9062
|
-
if (typeof id !== "string" || id.length === 0) throw new InvalidRecordError(`"${declared}": every element of "${prefix}" needs a non-empty string "${idKey}" to carry a secret`);
|
|
9063
|
-
return id;
|
|
9064
|
-
}
|
|
9065
|
-
/**
|
|
9066
|
-
* Visit every secret leaf PRESENT in `data` (an absent branch has nothing to
|
|
9067
|
-
* visit). `replace` returns the new leaf value, or {@link REMOVE_LEAF}; the
|
|
9068
|
-
* result is a new body, `data` is untouched.
|
|
9069
|
-
*/
|
|
9070
|
-
var REMOVE_LEAF = Symbol("remove-leaf");
|
|
9071
|
-
function mapAt(node, segments, index, walkedKeys, concretePrefix, declared, arrayKeys, visit) {
|
|
9072
|
-
if (!isRecord$4$1(node)) return node;
|
|
9073
|
-
const segment = segments[index];
|
|
9074
|
-
if (segment === void 0) return node;
|
|
9075
|
-
if (!Object.prototype.hasOwnProperty.call(node, segment.key)) return node;
|
|
9076
|
-
const child = node[segment.key];
|
|
9077
|
-
const keys = [...walkedKeys, segment.key];
|
|
9078
|
-
const last = index === segments.length - 1;
|
|
9079
|
-
if (segment.each) {
|
|
9080
|
-
if (!Array.isArray(child)) return node;
|
|
9081
|
-
const prefix = keys.join(".");
|
|
9082
|
-
const idKey = arrayKeys[prefix];
|
|
9083
|
-
if (idKey === void 0) throw new InvalidRecordError(`"${declared}": no id key declared for the array "${prefix}"`);
|
|
9084
|
-
const mapped = child.map((element) => {
|
|
9085
|
-
const id = elementId(element, idKey, declared, prefix);
|
|
9086
|
-
const concrete = `${concretePrefix}${concretePrefix.length > 0 ? "." : ""}${segment.key}[${encodeURIComponent(id)}]`;
|
|
9087
|
-
return last ? element : mapAt(element, segments, index + 1, keys, concrete, declared, arrayKeys, visit);
|
|
9088
|
-
});
|
|
9089
|
-
return {
|
|
9090
|
-
...node,
|
|
9091
|
-
[segment.key]: mapped
|
|
9092
|
-
};
|
|
9093
|
-
}
|
|
9094
|
-
if (segment.map) {
|
|
9095
|
-
if (!isRecord$4$1(child)) return node;
|
|
9096
|
-
const kept = Object.entries(child).map(([mapKey, value]) => {
|
|
9097
|
-
const concrete = `${concretePrefix}${concretePrefix.length > 0 ? "." : ""}${segment.key}{${encodeURIComponent(mapKey)}}`;
|
|
9098
|
-
if (!last) return [mapKey, mapAt(value, segments, index + 1, keys, concrete, declared, arrayKeys, visit)];
|
|
9099
|
-
return [mapKey, visit({
|
|
9100
|
-
declared,
|
|
9101
|
-
concrete,
|
|
9102
|
-
value
|
|
9103
|
-
})];
|
|
9104
|
-
}).filter(([, value]) => value !== REMOVE_LEAF);
|
|
9105
|
-
return {
|
|
9106
|
-
...node,
|
|
9107
|
-
[segment.key]: Object.fromEntries(kept)
|
|
9108
|
-
};
|
|
9109
|
-
}
|
|
9110
|
-
const concrete = `${concretePrefix}${concretePrefix.length > 0 ? "." : ""}${segment.key}`;
|
|
9111
|
-
if (last) {
|
|
9112
|
-
const replacement = visit({
|
|
9113
|
-
declared,
|
|
9114
|
-
concrete,
|
|
9115
|
-
value: child
|
|
9116
|
-
});
|
|
9117
|
-
if (replacement === REMOVE_LEAF) return Object.fromEntries(Object.entries(node).filter(([key]) => key !== segment.key));
|
|
9118
|
-
return {
|
|
9119
|
-
...node,
|
|
9120
|
-
[segment.key]: replacement
|
|
9121
|
-
};
|
|
9122
|
-
}
|
|
9123
|
-
return {
|
|
9124
|
-
...node,
|
|
9125
|
-
[segment.key]: mapAt(child, segments, index + 1, keys, concrete, declared, arrayKeys, visit)
|
|
9126
|
-
};
|
|
9127
|
-
}
|
|
9128
|
-
/** Replace/remove every present secret leaf; returns a NEW body. */
|
|
9129
|
-
function mapSecretLeaves(data, paths, arrayKeys, visit) {
|
|
9130
|
-
let current = data;
|
|
9131
|
-
for (const declared of paths) current = mapAt(current, parseSecretPath(declared), 0, [], "", declared, arrayKeys, visit);
|
|
9132
|
-
return current;
|
|
9133
|
-
}
|
|
9134
|
-
/**
|
|
9135
|
-
* The keychain collection name a kind's secrets live under. Prefixed so a
|
|
9136
|
-
* record's refs never collide with the refs a legacy collection of the same name
|
|
9137
|
-
* already holds during an adoption.
|
|
9138
|
-
*/
|
|
9139
|
-
function recordSecretCollection(kind) {
|
|
9140
|
-
return `${RECORD_SECRET_COLLECTION_PREFIX}${kind}`;
|
|
9141
|
-
}
|
|
9142
|
-
/** The ONE ref a row's secret field may carry: the engine and the facade both build it here. */
|
|
9143
|
-
function recordSecretRef(owner, kind, rowId, concretePath) {
|
|
9144
|
-
return buildSecretRef({
|
|
9145
|
-
owner,
|
|
9146
|
-
scope: {
|
|
9147
|
-
kind: "row",
|
|
9148
|
-
collection: recordSecretCollection(kind),
|
|
9149
|
-
rowId
|
|
9150
|
-
},
|
|
9151
|
-
fieldPath: concretePath
|
|
9152
|
-
});
|
|
9153
|
-
}
|
|
9154
|
-
/**
|
|
9155
|
-
* A stored secret, as an admin read shows it: a VIEW naming the row's own
|
|
9156
|
-
* keychain ref (`set: true`), never the value and no longer the bare redaction
|
|
9157
|
-
* token. The form hands it to the secret field, which reveals the value by ref
|
|
9158
|
-
* through the audited `keychain-admin.reveal`; sent back untouched it keeps the
|
|
9159
|
-
* stored secret (the records door reads a view as KEEP).
|
|
9160
|
-
*
|
|
9161
|
-
* ONE implementation for the entries service and for every addon that serves
|
|
9162
|
-
* its own instances (`RecordIntegrationInstances`).
|
|
9163
|
-
*/
|
|
9164
|
-
function isRecord$3$1(value) {
|
|
9165
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9166
|
-
}
|
|
9167
|
-
function secretViewsOf(owner, kind, id, decl, data) {
|
|
9168
|
-
if (decl.secretPaths.length === 0) return data;
|
|
9169
|
-
const shown = mapSecretLeaves(data, decl.secretPaths, decl.secretArrayKeys, (leaf) => {
|
|
9170
|
-
if (leaf.value !== "__camstack_redacted__") return leaf.value;
|
|
9171
|
-
return {
|
|
9172
|
-
$secret: recordSecretRef(owner, kind, id, leaf.concrete),
|
|
9173
|
-
v: 1,
|
|
9174
|
-
set: true
|
|
9175
|
-
};
|
|
9176
|
-
});
|
|
9177
|
-
return isRecord$3$1(shown) ? shown : data;
|
|
9178
|
-
}
|
|
9179
9594
|
/**
|
|
9180
9595
|
* The share-view scope kinds a share-link token can carry. The hub mints
|
|
9181
9596
|
* and validates them (`ShareViewGrantSchema` builds its
|
|
@@ -9429,6 +9844,7 @@ function defineRecordKinds(specs) {
|
|
|
9429
9844
|
function entryBody(shape) {
|
|
9430
9845
|
return object({
|
|
9431
9846
|
name: string().min(1),
|
|
9847
|
+
enabled: boolean().optional(),
|
|
9432
9848
|
...shape
|
|
9433
9849
|
});
|
|
9434
9850
|
}
|
|
@@ -11272,6 +11688,10 @@ Object.freeze({
|
|
|
11272
11688
|
"all",
|
|
11273
11689
|
"one"
|
|
11274
11690
|
]
|
|
11691
|
+
},
|
|
11692
|
+
{
|
|
11693
|
+
"path": "positionUpdatedAt",
|
|
11694
|
+
"valueType": "number"
|
|
11275
11695
|
}
|
|
11276
11696
|
],
|
|
11277
11697
|
"meter": [{
|
|
@@ -11526,22 +11946,6 @@ Object.freeze({
|
|
|
11526
11946
|
"path": "pumpRunning",
|
|
11527
11947
|
"valueType": "boolean"
|
|
11528
11948
|
},
|
|
11529
|
-
{
|
|
11530
|
-
"path": "lastDrink",
|
|
11531
|
-
"valueType": "number"
|
|
11532
|
-
},
|
|
11533
|
-
{
|
|
11534
|
-
"path": "currentHourDrink",
|
|
11535
|
-
"valueType": "number"
|
|
11536
|
-
},
|
|
11537
|
-
{
|
|
11538
|
-
"path": "last24hDrink",
|
|
11539
|
-
"valueType": "number"
|
|
11540
|
-
},
|
|
11541
|
-
{
|
|
11542
|
-
"path": "todayDrink",
|
|
11543
|
-
"valueType": "number"
|
|
11544
|
-
},
|
|
11545
11949
|
{
|
|
11546
11950
|
"path": "error",
|
|
11547
11951
|
"valueType": "string"
|
|
@@ -12107,12 +12511,66 @@ Object.freeze({
|
|
|
12107
12511
|
}
|
|
12108
12512
|
]
|
|
12109
12513
|
});
|
|
12514
|
+
Object.freeze({
|
|
12515
|
+
"actions": { "run": 363e4 },
|
|
12516
|
+
"audio-analyzer": { "classify": 3e4 },
|
|
12517
|
+
"custom-model-registry": { "importModelBundle": 18e5 },
|
|
12518
|
+
"database-snapshot": { "writeSnapshot": 18e5 },
|
|
12519
|
+
"device-manager": {
|
|
12520
|
+
"migrateDevice": 72e4,
|
|
12521
|
+
"runDiscoverySweep": 12e4
|
|
12522
|
+
},
|
|
12523
|
+
"device-provider": { "reloadDevice": 18e4 },
|
|
12524
|
+
"llm": {
|
|
12525
|
+
"generate": 6e5,
|
|
12526
|
+
"generateVision": 6e5,
|
|
12527
|
+
"testProfile": 6e5
|
|
12528
|
+
},
|
|
12529
|
+
"llm-runtime": {
|
|
12530
|
+
"complete": 6e5,
|
|
12531
|
+
"completeApple": 6e5,
|
|
12532
|
+
"ensureStarted": 6e5,
|
|
12533
|
+
"installModel": 36e5
|
|
12534
|
+
},
|
|
12535
|
+
"local-network": { "scanNetwork": 9e4 },
|
|
12536
|
+
"model-convert": { "convert": 6e5 },
|
|
12537
|
+
"pipeline-analytics": {
|
|
12538
|
+
"addRetrainFrameFromRecording": 6e4,
|
|
12539
|
+
"offerRetrainArchive": 12e4,
|
|
12540
|
+
"proposeRetrainAnnotationsWithLlm": 18e4,
|
|
12541
|
+
"resolveRetrainTrainingCorpus": 6e4,
|
|
12542
|
+
"writeRetrainArchive": 36e5
|
|
12543
|
+
},
|
|
12544
|
+
"server-management": { "applyServerUpdate": 96e4 },
|
|
12545
|
+
"terminal-session": { "pullOutput": 15e3 },
|
|
12546
|
+
"training": {
|
|
12547
|
+
"canaryResult": 12e4,
|
|
12548
|
+
"cancelJob": 12e4,
|
|
12549
|
+
"planJob": 6e4,
|
|
12550
|
+
"registerResult": 18e5,
|
|
12551
|
+
"rollbackCanary": 12e4,
|
|
12552
|
+
"startJob": 12e4,
|
|
12553
|
+
"startJobBatch": 12e4
|
|
12554
|
+
},
|
|
12555
|
+
"training-provider": {
|
|
12556
|
+
"cancelJob": 6e4,
|
|
12557
|
+
"cleanupJob": 3e5,
|
|
12558
|
+
"getJob": 6e4,
|
|
12559
|
+
"listJobEvents": 6e4,
|
|
12560
|
+
"listJobs": 12e4,
|
|
12561
|
+
"offerArtifact": 3e5,
|
|
12562
|
+
"readResult": 3e5,
|
|
12563
|
+
"submitJob": 18e5,
|
|
12564
|
+
"uploadDataset": 72e5,
|
|
12565
|
+
"validateCredentials": 3e4
|
|
12566
|
+
}
|
|
12567
|
+
});
|
|
12110
12568
|
object({
|
|
12111
12569
|
code: string().regex(/^[a-z][a-z0-9-]*(\.[a-z0-9-]+)+$/),
|
|
12112
12570
|
params: record(string(), union([string(), number$1()])).optional()
|
|
12113
12571
|
});
|
|
12114
12572
|
//#endregion
|
|
12115
|
-
//#region ../types/dist/composition-
|
|
12573
|
+
//#region ../types/dist/composition-BmBHuDTQ.mjs
|
|
12116
12574
|
/**
|
|
12117
12575
|
* The ffmpeg build every CamStack node downloads, per platform and arch.
|
|
12118
12576
|
*
|
|
@@ -12416,7 +12874,7 @@ var table = {
|
|
|
12416
12874
|
},
|
|
12417
12875
|
dwell: {
|
|
12418
12876
|
minArgs: 3,
|
|
12419
|
-
maxArgs:
|
|
12877
|
+
maxArgs: 4,
|
|
12420
12878
|
apply: () => {
|
|
12421
12879
|
throw new ExpressionEvalError("dwell: stateful evaluation context is required");
|
|
12422
12880
|
}
|
|
@@ -13576,212 +14034,6 @@ function buildCapturingReply(envelope) {
|
|
|
13576
14034
|
};
|
|
13577
14035
|
return wrapper;
|
|
13578
14036
|
}
|
|
13579
|
-
/**
|
|
13580
|
-
* `RecordIntegrationInstances` - the `integration-instances` provider over ONE
|
|
13581
|
-
* entry kind, so an addon that holds a connection serves it in ~30 lines.
|
|
13582
|
-
*
|
|
13583
|
-
* Every instance is one record of the addon's own kind, keyed by the
|
|
13584
|
-
* `integrations` registry id. The records door does the secret work: a field the
|
|
13585
|
-
* kind marks `.meta({ secret: true })` is a keychain marker bound to the row, so
|
|
13586
|
-
* deleting the row deletes the secret, and one row's value is never another's.
|
|
13587
|
-
*
|
|
13588
|
-
* Patch rules for a secret (`updateInstance`): omitted, `''`, the redaction
|
|
13589
|
-
* token or the view the form was given KEEP the stored secret;
|
|
13590
|
-
* `{ $secretClear: true }` clears it; any other string replaces it.
|
|
13591
|
-
*/
|
|
13592
|
-
/** The most instances one addon lists (an integrations page, not a table). */
|
|
13593
|
-
var INSTANCES_LIST_MAX = 1e3;
|
|
13594
|
-
/** The keys of the kind's secret fields that sit at the top level of the body. */
|
|
13595
|
-
function topLevelSecretKeys(paths) {
|
|
13596
|
-
return new Set(paths.filter((p) => !p.includes(".") && !p.includes("[") && !p.includes("{")));
|
|
13597
|
-
}
|
|
13598
|
-
/** The keys of the kind's top-level MAPS whose values are secrets (`applianceSecrets{}`). */
|
|
13599
|
-
function secretMapKeys(paths) {
|
|
13600
|
-
return new Set(paths.filter((p) => p.endsWith("{}") && !p.slice(0, -2).match(/[.[{]/)).map((p) => p.slice(0, -2)));
|
|
13601
|
-
}
|
|
13602
|
-
/**
|
|
13603
|
-
* A patch value for a map of secrets: an entry that is omitted, `''`, the redaction token or the
|
|
13604
|
-
* view the form was given KEEPS its stored secret (it is left out of the merge); `{ $secretClear }`
|
|
13605
|
-
* or `null` removes the entry; any other string replaces it. Null when nothing is left to merge.
|
|
13606
|
-
*/
|
|
13607
|
-
function secretMapPatch(value) {
|
|
13608
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
13609
|
-
const out = {};
|
|
13610
|
-
for (const [key, entry] of Object.entries(value)) if (entry === null || isSecretClear(entry)) out[key] = null;
|
|
13611
|
-
else if (typeof entry === "string" && entry !== "" && entry !== "__camstack_redacted__" && !isSecretFieldView(entry)) out[key] = entry;
|
|
13612
|
-
return Object.keys(out).length > 0 ? out : null;
|
|
13613
|
-
}
|
|
13614
|
-
/** Does the kind's body schema carry an `enabled` field? */
|
|
13615
|
-
function declaresEnabled(kind) {
|
|
13616
|
-
const json = toJSONSchema(kind.schema, {
|
|
13617
|
-
unrepresentable: "any",
|
|
13618
|
-
io: "input"
|
|
13619
|
-
});
|
|
13620
|
-
const properties = Reflect.get(json, "properties");
|
|
13621
|
-
return typeof properties === "object" && properties !== null && "enabled" in properties;
|
|
13622
|
-
}
|
|
13623
|
-
var RecordIntegrationInstances = class {
|
|
13624
|
-
records;
|
|
13625
|
-
kind;
|
|
13626
|
-
hooks;
|
|
13627
|
-
handle;
|
|
13628
|
-
secretKeys;
|
|
13629
|
-
secretMaps;
|
|
13630
|
-
nameField;
|
|
13631
|
-
/** The `integrations` registry owns the on/off switch (`role: devices`); the body has none. */
|
|
13632
|
-
registryOwnsEnabled;
|
|
13633
|
-
constructor(records, kind, hooks) {
|
|
13634
|
-
this.records = records;
|
|
13635
|
-
this.kind = kind;
|
|
13636
|
-
this.hooks = hooks;
|
|
13637
|
-
this.handle = records.kind(kind);
|
|
13638
|
-
this.secretKeys = topLevelSecretKeys(kind.declaration.secretPaths);
|
|
13639
|
-
this.secretMaps = secretMapKeys(kind.declaration.secretPaths);
|
|
13640
|
-
this.nameField = kind.declaration.entry?.nameField ?? "name";
|
|
13641
|
-
this.registryOwnsEnabled = hooks.description.role === "devices";
|
|
13642
|
-
if (this.registryOwnsEnabled && declaresEnabled(kind)) throw new Error(`kind "${kind.name}": the integrations registry owns the on/off switch of a role "devices" addon, so the body must not declare "enabled"`);
|
|
13643
|
-
}
|
|
13644
|
-
/**
|
|
13645
|
-
* One instance read for the addon's own runtime; null when absent. When the keychain is
|
|
13646
|
-
* locked the row is served WITHOUT its secrets and says so (`secrets: 'unavailable'`), so the
|
|
13647
|
-
* caller can keep what it had instead of reconfiguring with an empty secret (D49).
|
|
13648
|
-
*/
|
|
13649
|
-
async readResolved(id) {
|
|
13650
|
-
try {
|
|
13651
|
-
const record = await this.handle.getResolved(id);
|
|
13652
|
-
return record === null ? null : {
|
|
13653
|
-
id,
|
|
13654
|
-
body: record.data,
|
|
13655
|
-
enabled: record.data.enabled !== false,
|
|
13656
|
-
secrets: "resolved"
|
|
13657
|
-
};
|
|
13658
|
-
} catch (err) {
|
|
13659
|
-
if (!(err instanceof RecordSecretUnavailableError)) throw err;
|
|
13660
|
-
const stored = await this.handle.get(id);
|
|
13661
|
-
if (stored === null) return null;
|
|
13662
|
-
return {
|
|
13663
|
-
id,
|
|
13664
|
-
body: Object.fromEntries(Object.entries(stored.data).filter(([key]) => !this.secretKeys.has(key))),
|
|
13665
|
-
enabled: stored.data.enabled !== false,
|
|
13666
|
-
secrets: "unavailable"
|
|
13667
|
-
};
|
|
13668
|
-
}
|
|
13669
|
-
}
|
|
13670
|
-
/** The body of {@link readResolved} alone; a locked keychain yields the row without secrets. */
|
|
13671
|
-
async getResolved(id) {
|
|
13672
|
-
return (await this.readResolved(id))?.body ?? null;
|
|
13673
|
-
}
|
|
13674
|
-
/** Every instance, secrets resolved where the keychain allows; an absent one is skipped. */
|
|
13675
|
-
async listResolved() {
|
|
13676
|
-
const page = await this.handle.query({ limit: INSTANCES_LIST_MAX });
|
|
13677
|
-
const out = [];
|
|
13678
|
-
for (const record of page.records) {
|
|
13679
|
-
const read = await this.readResolved(record.id);
|
|
13680
|
-
if (read !== null) out.push(read);
|
|
13681
|
-
}
|
|
13682
|
-
return out;
|
|
13683
|
-
}
|
|
13684
|
-
viewOf(record) {
|
|
13685
|
-
const shown = secretViewsOf(this.records.owner, this.kind.name, record.id, this.kind.declaration, record.data);
|
|
13686
|
-
const rawName = record.data[this.nameField];
|
|
13687
|
-
const kind = this.hooks.kindOf?.(record.data);
|
|
13688
|
-
const health = this.hooks.health?.(record.data, record.id);
|
|
13689
|
-
return {
|
|
13690
|
-
id: record.id,
|
|
13691
|
-
name: typeof rawName === "string" ? rawName : record.id,
|
|
13692
|
-
configured: this.hooks.configured(record.data),
|
|
13693
|
-
...this.registryOwnsEnabled ? {} : { enabled: record.data.enabled !== false },
|
|
13694
|
-
formValues: shown,
|
|
13695
|
-
...kind !== void 0 ? { kind } : {},
|
|
13696
|
-
...health?.status !== void 0 ? { status: health.status } : {},
|
|
13697
|
-
...health?.lastCheckedAt !== void 0 ? { lastCheckedAt: health.lastCheckedAt } : {}
|
|
13698
|
-
};
|
|
13699
|
-
}
|
|
13700
|
-
describe = async () => this.hooks.mintId === void 0 ? this.hooks.description : {
|
|
13701
|
-
...this.hooks.description,
|
|
13702
|
-
mintsId: true
|
|
13703
|
-
};
|
|
13704
|
-
instanceFormSchema = async ({ id }) => this.hooks.formSchema(id);
|
|
13705
|
-
listInstances = async () => {
|
|
13706
|
-
return (await this.handle.query({ limit: INSTANCES_LIST_MAX })).records.map((record) => this.viewOf(record));
|
|
13707
|
-
};
|
|
13708
|
-
getInstance = async ({ id }) => {
|
|
13709
|
-
const record = await this.handle.get(id);
|
|
13710
|
-
return record === null ? null : this.viewOf(record);
|
|
13711
|
-
};
|
|
13712
|
-
/** Insert ONE row (`ifRev: 0`: an existing id is refused, never overwritten). */
|
|
13713
|
-
createInstance = async ({ id, name, kind, values }) => {
|
|
13714
|
-
const minted = id ?? this.hooks.mintId?.({
|
|
13715
|
-
name,
|
|
13716
|
-
...kind !== void 0 ? { kind } : {}
|
|
13717
|
-
});
|
|
13718
|
-
if (minted === void 0) throw new Error("createInstance needs an id: this provider does not mint one");
|
|
13719
|
-
await this.handle.put({
|
|
13720
|
-
id: minted,
|
|
13721
|
-
data: this.hooks.initialBody(name, kind)
|
|
13722
|
-
}, { ifRev: 0 });
|
|
13723
|
-
const patch = this.patchOf(values ?? {});
|
|
13724
|
-
if (Object.keys(patch).length > 0) await this.handle.patch(minted, patch);
|
|
13725
|
-
await this.hooks.onChanged?.(minted, "created");
|
|
13726
|
-
return { id: minted };
|
|
13727
|
-
};
|
|
13728
|
-
updateInstance = async ({ id, name, values }) => {
|
|
13729
|
-
const patch = this.patchOf(values);
|
|
13730
|
-
if (name !== void 0) patch[this.nameField] = name;
|
|
13731
|
-
await this.handle.patch(id, patch);
|
|
13732
|
-
await this.hooks.onChanged?.(id, "updated");
|
|
13733
|
-
return { ok: true };
|
|
13734
|
-
};
|
|
13735
|
-
/** The record patch a form `values` object means: secrets kept, cleared or replaced. */
|
|
13736
|
-
patchOf(values) {
|
|
13737
|
-
const patch = {};
|
|
13738
|
-
for (const [key, value] of Object.entries(values)) {
|
|
13739
|
-
if (value === void 0) continue;
|
|
13740
|
-
if (this.secretMaps.has(key)) {
|
|
13741
|
-
const entries = secretMapPatch(value);
|
|
13742
|
-
if (entries !== null) patch[key] = entries;
|
|
13743
|
-
} else if (!this.secretKeys.has(key)) patch[key] = value;
|
|
13744
|
-
else if (isSecretClear(value)) patch[key] = null;
|
|
13745
|
-
else if (typeof value === "string" && value !== "" && value !== "__camstack_redacted__" && !isSecretFieldView(value)) patch[key] = value;
|
|
13746
|
-
}
|
|
13747
|
-
return patch;
|
|
13748
|
-
}
|
|
13749
|
-
/** The switch lives in the record body; the addon is told so it applies it (D996). */
|
|
13750
|
-
setInstanceEnabled = async ({ id, enabled }) => {
|
|
13751
|
-
if (this.registryOwnsEnabled) throw new Error(`instance "${id}": the integrations registry owns the on/off switch of this addon; set it with integrations.update`);
|
|
13752
|
-
await this.handle.patch(id, { enabled });
|
|
13753
|
-
await this.hooks.onChanged?.(id, "enabled");
|
|
13754
|
-
return { ok: true };
|
|
13755
|
-
};
|
|
13756
|
-
/** The row and its keychain refs go together (the records door). */
|
|
13757
|
-
deleteInstance = async ({ id }) => {
|
|
13758
|
-
await this.handle.delete(id);
|
|
13759
|
-
await this.hooks.onChanged?.(id, "deleted");
|
|
13760
|
-
return { ok: true };
|
|
13761
|
-
};
|
|
13762
|
-
testInstance = async (input) => {
|
|
13763
|
-
if (this.hooks.test === void 0) return {
|
|
13764
|
-
success: false,
|
|
13765
|
-
status: "unsupported",
|
|
13766
|
-
error: "This integration declares no connection test"
|
|
13767
|
-
};
|
|
13768
|
-
return this.hooks.test(input);
|
|
13769
|
-
};
|
|
13770
|
-
/** The flat provider object to hand to `registerProvider`. */
|
|
13771
|
-
toProvider() {
|
|
13772
|
-
return {
|
|
13773
|
-
describe: this.describe,
|
|
13774
|
-
instanceFormSchema: this.instanceFormSchema,
|
|
13775
|
-
listInstances: this.listInstances,
|
|
13776
|
-
getInstance: this.getInstance,
|
|
13777
|
-
createInstance: this.createInstance,
|
|
13778
|
-
updateInstance: this.updateInstance,
|
|
13779
|
-
setInstanceEnabled: this.setInstanceEnabled,
|
|
13780
|
-
deleteInstance: this.deleteInstance,
|
|
13781
|
-
testInstance: this.testInstance
|
|
13782
|
-
};
|
|
13783
|
-
}
|
|
13784
|
-
};
|
|
13785
14037
|
/** No part holds the id a call named. */
|
|
13786
14038
|
var UnknownInstanceError = class extends Error {
|
|
13787
14039
|
instanceId;
|
|
@@ -13820,10 +14072,14 @@ function composeInstanceKinds(parts, base) {
|
|
|
13820
14072
|
kind: part.kind.id
|
|
13821
14073
|
});
|
|
13822
14074
|
return {
|
|
13823
|
-
describe: async () =>
|
|
13824
|
-
|
|
13825
|
-
|
|
13826
|
-
|
|
14075
|
+
describe: async () => {
|
|
14076
|
+
const links = (await Promise.all(parts.map(async (part) => (await part.provider.describe()).links ?? []))).flat();
|
|
14077
|
+
return {
|
|
14078
|
+
...base,
|
|
14079
|
+
kinds: parts.map((part) => part.kind),
|
|
14080
|
+
...links.length > 0 ? { links } : {}
|
|
14081
|
+
};
|
|
14082
|
+
},
|
|
13827
14083
|
instanceFormSchema: async ({ id, kind }) => {
|
|
13828
14084
|
return (id === null ? byKind(kind) : await holderOf(id)).provider.instanceFormSchema({ id });
|
|
13829
14085
|
},
|
|
@@ -20144,31 +20400,21 @@ var APPLIANCE_CAP_VOCABULARY = {
|
|
|
20144
20400
|
"pet-fountain": {
|
|
20145
20401
|
scope: "device",
|
|
20146
20402
|
noun: t("Pet fountain", "Fontana per animali"),
|
|
20147
|
-
actions: [
|
|
20148
|
-
|
|
20149
|
-
|
|
20150
|
-
|
|
20151
|
-
|
|
20152
|
-
|
|
20153
|
-
|
|
20154
|
-
|
|
20155
|
-
|
|
20156
|
-
|
|
20157
|
-
|
|
20158
|
-
|
|
20159
|
-
|
|
20160
|
-
|
|
20161
|
-
|
|
20162
|
-
args: { on: false }
|
|
20163
|
-
},
|
|
20164
|
-
{
|
|
20165
|
-
id: "petFountain.resetDailyConsumption",
|
|
20166
|
-
method: "resetDailyConsumption",
|
|
20167
|
-
label: t("Clear daily consumption", "Azzera il consumo giornaliero"),
|
|
20168
|
-
phrase: t("Clear the daily water consumption of {device}", "Azzera il consumo giornaliero di acqua di {device}"),
|
|
20169
|
-
icon: "rotate-ccw"
|
|
20170
|
-
}
|
|
20171
|
-
],
|
|
20403
|
+
actions: [{
|
|
20404
|
+
id: "petFountain.pumpOn",
|
|
20405
|
+
method: "setPump",
|
|
20406
|
+
label: t("Pump on", "Pompa accesa"),
|
|
20407
|
+
phrase: t("Start the pump of {device}", "Avvia la pompa di {device}"),
|
|
20408
|
+
icon: "play",
|
|
20409
|
+
args: { on: true }
|
|
20410
|
+
}, {
|
|
20411
|
+
id: "petFountain.pumpOff",
|
|
20412
|
+
method: "setPump",
|
|
20413
|
+
label: t("Pump off", "Pompa spenta"),
|
|
20414
|
+
phrase: t("Stop the pump of {device}", "Ferma la pompa di {device}"),
|
|
20415
|
+
icon: "square",
|
|
20416
|
+
args: { on: false }
|
|
20417
|
+
}],
|
|
20172
20418
|
state: [{
|
|
20173
20419
|
kind: "boolean",
|
|
20174
20420
|
field: "empty",
|
|
@@ -44122,8 +44368,15 @@ var ActionInputSchema = object({
|
|
|
44122
44368
|
var ActionRunOriginSchema = object({
|
|
44123
44369
|
/** The automation block whose step started it. */
|
|
44124
44370
|
blockId: string().min(1).optional() });
|
|
44125
|
-
/**
|
|
44126
|
-
|
|
44371
|
+
/**
|
|
44372
|
+
* `started` = the caller did not ask to wait. `timed-out` = the run reached the
|
|
44373
|
+
* ceiling (`ACTION_RUN_MAX_MS`) and was stopped; it is never a `ran`.
|
|
44374
|
+
*/
|
|
44375
|
+
var ActionRunOutcomeSchema = union([
|
|
44376
|
+
AutomationRunOutcomeSchema,
|
|
44377
|
+
literal("started"),
|
|
44378
|
+
literal("timed-out")
|
|
44379
|
+
]);
|
|
44127
44380
|
var ActionRunResultSchema = object({
|
|
44128
44381
|
runId: string().min(1),
|
|
44129
44382
|
outcome: ActionRunOutcomeSchema,
|
|
@@ -44667,6 +44920,8 @@ method(object({}), array(ActionEntrySchema), { auth: "admin" }), method(object({
|
|
|
44667
44920
|
kind: "mutation",
|
|
44668
44921
|
auth: "admin"
|
|
44669
44922
|
});
|
|
44923
|
+
/** `timeoutMs` of `actions.run`: a waited call outlives any legal run. */
|
|
44924
|
+
var ACTION_RUN_TIMEOUT_MS = 363e4;
|
|
44670
44925
|
/**
|
|
44671
44926
|
* actions — the ONE surface for runnable actions, for the UI and for
|
|
44672
44927
|
* automations. Provided by the core-blocks builtin on the hub.
|
|
@@ -44685,6 +44940,12 @@ method(object({}), array(ActionEntrySchema), { auth: "admin" }), method(object({
|
|
|
44685
44940
|
* An admin, and an in-process caller (an automation block), may run any action.
|
|
44686
44941
|
* Every refusal is typed (`[not-linked] …`) and logged.
|
|
44687
44942
|
*
|
|
44943
|
+
* **A run lasts at most `ACTION_RUN_MAX_MS` (60 min).** The provider stops a
|
|
44944
|
+
* run that reaches it and answers `timed-out` (never `ran`); an action whose
|
|
44945
|
+
* `wait` steps alone add up to more is refused at create/update
|
|
44946
|
+
* (`[invalid-steps]`). `run` declares a `timeoutMs` above that ceiling, so a
|
|
44947
|
+
* waited call (an automation step) is never cut by the 60 s transport default.
|
|
44948
|
+
*
|
|
44688
44949
|
* A second `run` of an action that is already running is REFUSED — traced and
|
|
44689
44950
|
* logged — never queued.
|
|
44690
44951
|
*/
|
|
@@ -44711,7 +44972,8 @@ method(object({}), array(ActionEntrySchema), { auth: "admin" }), method(object({
|
|
|
44711
44972
|
role: "viewer",
|
|
44712
44973
|
auth: "protected",
|
|
44713
44974
|
kind: "mutation",
|
|
44714
|
-
caller: "required"
|
|
44975
|
+
caller: "required",
|
|
44976
|
+
timeoutMs: ACTION_RUN_TIMEOUT_MS
|
|
44715
44977
|
}), method(ActionIdInput, _void(), {
|
|
44716
44978
|
kind: "mutation",
|
|
44717
44979
|
auth: "admin"
|
|
@@ -49802,6 +50064,9 @@ var MediaPlayerStatusSchema = object({
|
|
|
49802
50064
|
/** Repeat mode. Null when no repeat surface.
|
|
49803
50065
|
* Pair with `DeviceFeature.MediaPlayerRepeat`. */
|
|
49804
50066
|
repeat: MediaPlayerRepeatSchema.nullable(),
|
|
50067
|
+
/** Ms epoch when `position` was read from the device. Null when the driver
|
|
50068
|
+
* does not say; a UI that advances the position while playing counts from it. */
|
|
50069
|
+
positionUpdatedAt: number$1().nullable().default(null),
|
|
49805
50070
|
/** Ms epoch when the slice was last updated. */
|
|
49806
50071
|
lastChangedAt: number$1()
|
|
49807
50072
|
});
|
|
@@ -50437,7 +50702,9 @@ var ThresholdTransformDraftSchema = object({
|
|
|
50437
50702
|
comparison: _enum(["gte", "lte"]),
|
|
50438
50703
|
threshold: number$1(),
|
|
50439
50704
|
startDwellMs: number$1().nonnegative(),
|
|
50440
|
-
stopDwellMs: number$1().nonnegative()
|
|
50705
|
+
stopDwellMs: number$1().nonnegative(),
|
|
50706
|
+
/** Samples a start needs; absent = the compiler's default for the source (2 for running power, else 1). */
|
|
50707
|
+
minSamples: number$1().int().min(1).optional()
|
|
50441
50708
|
});
|
|
50442
50709
|
var CustomizeTransformDraftSchema = discriminatedUnion("kind", [
|
|
50443
50710
|
IdentityTransformDraftSchema,
|
|
@@ -56314,6 +56581,7 @@ var IntegrationWithStateSchema = object({
|
|
|
56314
56581
|
id: string(),
|
|
56315
56582
|
addonId: string(),
|
|
56316
56583
|
name: string(),
|
|
56584
|
+
/** A projection of the instance body's switch (`setInstanceEnabled` is the only writer). */
|
|
56317
56585
|
enabled: boolean(),
|
|
56318
56586
|
info: record(string(), unknown()).readonly(),
|
|
56319
56587
|
/** The discovery candidate this integration was set up from (D984). */
|
|
@@ -56326,6 +56594,7 @@ var IntegrationLiteSchema = object({
|
|
|
56326
56594
|
id: string(),
|
|
56327
56595
|
addonId: string(),
|
|
56328
56596
|
name: string(),
|
|
56597
|
+
/** A projection of the instance body's switch (`setInstanceEnabled` is the only writer). */
|
|
56329
56598
|
enabled: boolean(),
|
|
56330
56599
|
info: record(string(), unknown()).readonly(),
|
|
56331
56600
|
/** The discovery candidate this integration was set up from (D984). */
|
|
@@ -56425,7 +56694,6 @@ var TestConnectionResultSchema = object({
|
|
|
56425
56694
|
var CreateIntegrationInputSchema = object({
|
|
56426
56695
|
addonId: string(),
|
|
56427
56696
|
name: string(),
|
|
56428
|
-
enabled: boolean().optional(),
|
|
56429
56697
|
info: record(string(), unknown()).optional(),
|
|
56430
56698
|
/** `stableId` of the discovery candidate the form was seeded from (D984). */
|
|
56431
56699
|
originStableId: string().optional(),
|
|
@@ -56434,6 +56702,11 @@ var CreateIntegrationInputSchema = object({
|
|
|
56434
56702
|
var UpdateIntegrationInputSchema = object({
|
|
56435
56703
|
id: string(),
|
|
56436
56704
|
name: string().optional(),
|
|
56705
|
+
/**
|
|
56706
|
+
* Accepted for a FIXED builtin only (`info.fixed`: alarm panel, camera grid, core blocks,
|
|
56707
|
+
* terminal), which keeps no instance record: the registry flag is its one store. Any other
|
|
56708
|
+
* integration is switched through `integration-instances.setInstanceEnabled`.
|
|
56709
|
+
*/
|
|
56437
56710
|
enabled: boolean().optional(),
|
|
56438
56711
|
info: record(string(), unknown()).optional()
|
|
56439
56712
|
});
|
|
@@ -56481,7 +56754,7 @@ var IntegrationInstanceIdSchema = string().min(1);
|
|
|
56481
56754
|
var IntegrationInstanceModeSchema = _enum(["single", "multiple"]);
|
|
56482
56755
|
/**
|
|
56483
56756
|
* What the addon's instances ARE for the Integrations page (planning#416). `devices`: each
|
|
56484
|
-
* instance is an integration that brings devices (
|
|
56757
|
+
* instance is an integration that brings devices (its record body owns the on/off switch);
|
|
56485
56758
|
* `services`: a connection configured from the addon's own function page, never listed on
|
|
56486
56759
|
* Integrations.
|
|
56487
56760
|
* It is the same vocabulary as the manifest's `integrationRole`, which stays as an override.
|
|
@@ -56524,18 +56797,38 @@ var IntegrationInstanceKindSchema = object({
|
|
|
56524
56797
|
/** Relative hub path to disconnect that account. */
|
|
56525
56798
|
disconnectPath: string().min(1).optional()
|
|
56526
56799
|
});
|
|
56800
|
+
/**
|
|
56801
|
+
* How devices of an instance are found. `none`: the operator adds them by hand; `lan`: the addon
|
|
56802
|
+
* scans the network before any instance exists (the wizard offers the candidates); `account`: the
|
|
56803
|
+
* devices appear after the instance signs in. `scopedToInstance`: a discovery run belongs to ONE
|
|
56804
|
+
* instance and its candidates are claimed by it.
|
|
56805
|
+
*/
|
|
56806
|
+
var IntegrationInstanceDiscoverySchema = object({
|
|
56807
|
+
mode: _enum([
|
|
56808
|
+
"none",
|
|
56809
|
+
"lan",
|
|
56810
|
+
"account"
|
|
56811
|
+
]),
|
|
56812
|
+
scopedToInstance: boolean()
|
|
56813
|
+
});
|
|
56814
|
+
/**
|
|
56815
|
+
* One link a kind of this provider declares (D1013 rule 5): the body `field` of an instance of
|
|
56816
|
+
* `kind` holds the id of an instance of the function cap `to.cap` (of kind `to.kind`, when set).
|
|
56817
|
+
* The hub indexes these so the target's delete is refused while a link names it.
|
|
56818
|
+
*/
|
|
56819
|
+
var IntegrationInstanceLinkSchema = object({
|
|
56820
|
+
kind: string().min(1),
|
|
56821
|
+
field: string().min(1),
|
|
56822
|
+
to: object({
|
|
56823
|
+
cap: string().min(1),
|
|
56824
|
+
kind: string().min(1).optional()
|
|
56825
|
+
})
|
|
56826
|
+
});
|
|
56527
56827
|
var IntegrationInstanceDescriptionSchema = object({
|
|
56528
56828
|
/** What the instances are for the Integrations page; see {@link IntegrationInstanceRoleSchema}. */
|
|
56529
56829
|
role: IntegrationInstanceRoleSchema,
|
|
56530
56830
|
/** How the devices of an instance are found; absent means `{ mode: 'none' }`. */
|
|
56531
|
-
discovery:
|
|
56532
|
-
mode: _enum([
|
|
56533
|
-
"none",
|
|
56534
|
-
"lan",
|
|
56535
|
-
"account"
|
|
56536
|
-
]),
|
|
56537
|
-
scopedToInstance: boolean()
|
|
56538
|
-
}).optional(),
|
|
56831
|
+
discovery: IntegrationInstanceDiscoverySchema.optional(),
|
|
56539
56832
|
/** `single`: at most one instance can exist (Loki). `multiple`: an account each. */
|
|
56540
56833
|
instanceMode: IntegrationInstanceModeSchema,
|
|
56541
56834
|
/** What the instance is, for the UI (`destination`, `relay`, `account`). */
|
|
@@ -56556,7 +56849,9 @@ var IntegrationInstanceDescriptionSchema = object({
|
|
|
56556
56849
|
* `createInstance` without an `id` works: the provider mints it. Absent means the caller must
|
|
56557
56850
|
* send an id (the UI then mints an opaque one).
|
|
56558
56851
|
*/
|
|
56559
|
-
mintsId: boolean().optional()
|
|
56852
|
+
mintsId: boolean().optional(),
|
|
56853
|
+
/** The links to other instances the provider's kinds declare; absent means none. */
|
|
56854
|
+
links: array(IntegrationInstanceLinkSchema).optional()
|
|
56560
56855
|
});
|
|
56561
56856
|
var IntegrationInstanceStatusSchema = object({
|
|
56562
56857
|
state: _enum([
|
|
@@ -56576,8 +56871,8 @@ var IntegrationInstanceSchema = object({
|
|
|
56576
56871
|
configured: boolean(),
|
|
56577
56872
|
/**
|
|
56578
56873
|
* The instance's own on/off switch (D996), derived from the record body (`enabled !== false`,
|
|
56579
|
-
* so a row written before the field existed is on).
|
|
56580
|
-
*
|
|
56874
|
+
* so a row written before the field existed is on). The same for every role, `devices` included:
|
|
56875
|
+
* the body is the one store, `setInstanceEnabled` the one writer (epic #440, step 3).
|
|
56581
56876
|
*/
|
|
56582
56877
|
enabled: boolean().optional(),
|
|
56583
56878
|
/** The kind of this instance (one of `describe().kinds[].id`), when the provider has kinds. */
|
|
@@ -56627,7 +56922,8 @@ var integrationInstancesCapability = {
|
|
|
56627
56922
|
auth: "admin"
|
|
56628
56923
|
}),
|
|
56629
56924
|
/**
|
|
56630
|
-
* Switch ONE instance on or off
|
|
56925
|
+
* Switch ONE instance on or off, for every role; a missing record is created from the addon's
|
|
56926
|
+
* initial body. A row control, not a form field: `updateInstance.values` is the
|
|
56631
56927
|
* form patch (D62, D996). An off instance keeps its record and secrets and does no work.
|
|
56632
56928
|
*/
|
|
56633
56929
|
setInstanceEnabled: method(object({
|
|
@@ -59690,6 +59986,8 @@ var MigrationIntegrationSchema = object({
|
|
|
59690
59986
|
addonId: string(),
|
|
59691
59987
|
name: string(),
|
|
59692
59988
|
info: Rows,
|
|
59989
|
+
/** The registry's stored switch: read by the one-switch migration, nothing else. */
|
|
59990
|
+
enabled: boolean().optional(),
|
|
59693
59991
|
originStableId: string().optional()
|
|
59694
59992
|
});
|
|
59695
59993
|
var MigrationDeviceSchema = object({
|
|
@@ -69819,14 +70117,6 @@ var PetFountainStatusSchema = object({
|
|
|
69819
70117
|
empty: boolean().nullable(),
|
|
69820
70118
|
/** True while the pump is running. Null while unknown. */
|
|
69821
70119
|
pumpRunning: boolean().nullable(),
|
|
69822
|
-
/** Water drunk in the last drink, in litres. */
|
|
69823
|
-
lastDrink: number$1().min(0).nullable().meta(readingMeta("volume")),
|
|
69824
|
-
/** Water drunk in the current hour, in litres. */
|
|
69825
|
-
currentHourDrink: number$1().min(0).nullable().meta(readingMeta("volume")),
|
|
69826
|
-
/** Water drunk over the last 24 hours (rolling), in litres. */
|
|
69827
|
-
last24hDrink: number$1().min(0).nullable().meta(readingMeta("volume")),
|
|
69828
|
-
/** Water drunk since local midnight, in litres. Null when the fountain has no such sensor. */
|
|
69829
|
-
todayDrink: number$1().min(0).nullable().meta(readingMeta("volume")),
|
|
69830
70120
|
/** Decoded fault message. Null means healthy; a non-null string is an active fault. */
|
|
69831
70121
|
error: string().nullable(),
|
|
69832
70122
|
/** Ms epoch when the slice was last refreshed. */
|
|
@@ -69839,21 +70129,15 @@ var petFountainCapability = {
|
|
|
69839
70129
|
deviceNative: true,
|
|
69840
70130
|
mode: "singleton",
|
|
69841
70131
|
deviceTypes: [DeviceType.PetFountain],
|
|
69842
|
-
methods: {
|
|
69843
|
-
|
|
69844
|
-
|
|
69845
|
-
|
|
69846
|
-
|
|
69847
|
-
|
|
69848
|
-
|
|
69849
|
-
|
|
69850
|
-
|
|
69851
|
-
/** Clear the rolling daily consumption counters on the device. */
|
|
69852
|
-
resetDailyConsumption: method(object({ deviceId: DeviceIdSchema$1 }), _void(), {
|
|
69853
|
-
kind: "mutation",
|
|
69854
|
-
auth: "protected"
|
|
69855
|
-
})
|
|
69856
|
-
},
|
|
70132
|
+
methods: {
|
|
70133
|
+
/** Start or stop the pump. */
|
|
70134
|
+
setPump: method(object({
|
|
70135
|
+
deviceId: DeviceIdSchema$1,
|
|
70136
|
+
on: boolean()
|
|
70137
|
+
}), _void(), {
|
|
70138
|
+
kind: "mutation",
|
|
70139
|
+
auth: "protected"
|
|
70140
|
+
}) },
|
|
69857
70141
|
status: {
|
|
69858
70142
|
schema: PetFountainStatusSchema,
|
|
69859
70143
|
kind: "push"
|
|
@@ -69877,14 +70161,6 @@ var petFountainCapability = {
|
|
|
69877
70161
|
waterVolume: {
|
|
69878
70162
|
kind: "gauge",
|
|
69879
70163
|
unit: "L"
|
|
69880
|
-
},
|
|
69881
|
-
todayDrink: {
|
|
69882
|
-
kind: "gauge",
|
|
69883
|
-
unit: "L"
|
|
69884
|
-
},
|
|
69885
|
-
last24hDrink: {
|
|
69886
|
-
kind: "gauge",
|
|
69887
|
-
unit: "L"
|
|
69888
70164
|
}
|
|
69889
70165
|
} }
|
|
69890
70166
|
};
|
|
@@ -88333,13 +88609,6 @@ Object.freeze({
|
|
|
88333
88609
|
access: "view",
|
|
88334
88610
|
role: "viewer"
|
|
88335
88611
|
},
|
|
88336
|
-
"petFountain.resetDailyConsumption": {
|
|
88337
|
-
capName: "pet-fountain",
|
|
88338
|
-
capScope: "device",
|
|
88339
|
-
addonId: null,
|
|
88340
|
-
access: "delete",
|
|
88341
|
-
role: "viewer"
|
|
88342
|
-
},
|
|
88343
88612
|
"petFountain.setPump": {
|
|
88344
88613
|
capName: "pet-fountain",
|
|
88345
88614
|
capScope: "device",
|
|
@@ -94736,11 +95005,6 @@ Object.freeze({
|
|
|
94736
95005
|
form: "single",
|
|
94737
95006
|
optional: false
|
|
94738
95007
|
}],
|
|
94739
|
-
"petFountain.resetDailyConsumption": [{
|
|
94740
|
-
name: "deviceId",
|
|
94741
|
-
form: "single",
|
|
94742
|
-
optional: false
|
|
94743
|
-
}],
|
|
94744
95008
|
"petFountain.setPump": [{
|
|
94745
95009
|
name: "deviceId",
|
|
94746
95010
|
form: "single",
|
|
@@ -98442,113 +98706,114 @@ object({
|
|
|
98442
98706
|
delay: number$1().nonnegative().optional(),
|
|
98443
98707
|
commands: record(string(), unknown())
|
|
98444
98708
|
});
|
|
98445
|
-
var
|
|
98446
|
-
|
|
98709
|
+
var InstanceInUseError = class extends Error {
|
|
98710
|
+
instanceId;
|
|
98447
98711
|
users;
|
|
98448
|
-
name = "
|
|
98449
|
-
constructor(
|
|
98450
|
-
super(`
|
|
98451
|
-
this.
|
|
98712
|
+
name = "InstanceInUseError";
|
|
98713
|
+
constructor(instanceId, users) {
|
|
98714
|
+
super(`instance '${instanceId}' is still in use by ${users.map((u) => `${u.kind} '${u.name ?? u.id}' (${u.id})`).join(", ")}; unlink them first`);
|
|
98715
|
+
this.instanceId = instanceId;
|
|
98452
98716
|
this.users = users;
|
|
98453
98717
|
}
|
|
98454
98718
|
};
|
|
98455
|
-
/** Throw {@link
|
|
98456
|
-
async function
|
|
98719
|
+
/** Throw {@link InstanceInUseError} when any finder names a user of `instanceId`. */
|
|
98720
|
+
async function assertInstanceUnused(instanceId, finders) {
|
|
98457
98721
|
const users = [];
|
|
98458
|
-
for (const finder of finders) users.push(...await finder.find(
|
|
98459
|
-
if (users.length > 0) throw new
|
|
98722
|
+
for (const finder of finders) users.push(...await finder.find(instanceId));
|
|
98723
|
+
if (users.length > 0) throw new InstanceInUseError(instanceId, users);
|
|
98460
98724
|
}
|
|
98461
98725
|
/**
|
|
98462
|
-
*
|
|
98463
|
-
*
|
|
98464
|
-
*
|
|
98726
|
+
* A provider whose `deleteInstance` is refused while anything still uses the instance. The
|
|
98727
|
+
* account is an `integration-instances` kind (D1013), so the refusal lives on the delete the
|
|
98728
|
+
* instances page calls.
|
|
98465
98729
|
*/
|
|
98466
|
-
function
|
|
98730
|
+
function guardInstanceDelete(base, users) {
|
|
98467
98731
|
return {
|
|
98468
98732
|
...base,
|
|
98469
98733
|
deleteInstance: async (input) => {
|
|
98470
|
-
await
|
|
98734
|
+
await assertInstanceUnused(input.id, users());
|
|
98471
98735
|
return base.deleteInstance(input);
|
|
98472
98736
|
}
|
|
98473
98737
|
};
|
|
98474
98738
|
}
|
|
98475
|
-
/**
|
|
98476
|
-
|
|
98477
|
-
|
|
98478
|
-
|
|
98479
|
-
|
|
98480
|
-
|
|
98481
|
-
|
|
98482
|
-
|
|
98483
|
-
|
|
98484
|
-
|
|
98485
|
-
|
|
98486
|
-
|
|
98487
|
-
|
|
98488
|
-
|
|
98489
|
-
|
|
98490
|
-
|
|
98491
|
-
|
|
98492
|
-
|
|
98493
|
-
|
|
98494
|
-
|
|
98739
|
+
/** The `instance-select` field of a link, keyed like the body field it fills. */
|
|
98740
|
+
function linkFieldDescriptor(key, target, labels) {
|
|
98741
|
+
return {
|
|
98742
|
+
type: "instance-select",
|
|
98743
|
+
key,
|
|
98744
|
+
label: labels.label,
|
|
98745
|
+
...labels.description !== void 0 ? { description: labels.description } : {},
|
|
98746
|
+
required: labels.required ?? true,
|
|
98747
|
+
...labels.noneLabel !== void 0 ? { noneLabel: labels.noneLabel } : {},
|
|
98748
|
+
functionCap: target.cap,
|
|
98749
|
+
...target.kind !== void 0 ? { kind: target.kind } : {}
|
|
98750
|
+
};
|
|
98751
|
+
}
|
|
98752
|
+
function declareLinkKind(spec) {
|
|
98753
|
+
const linkShape = {};
|
|
98754
|
+
for (const [field, target] of Object.entries(spec.links)) linkShape[field] = linkField(target);
|
|
98755
|
+
const typed = entryBody({
|
|
98756
|
+
...linkShape,
|
|
98757
|
+
...spec.fields
|
|
98758
|
+
});
|
|
98759
|
+
return defineEntryKind(spec.name, {
|
|
98495
98760
|
title: spec.title,
|
|
98496
|
-
schema:
|
|
98761
|
+
schema: typed,
|
|
98497
98762
|
writeTrigger: spec.writeTrigger
|
|
98498
98763
|
});
|
|
98499
98764
|
}
|
|
98500
|
-
/** The
|
|
98501
|
-
|
|
98502
|
-
const
|
|
98503
|
-
return
|
|
98504
|
-
{
|
|
98505
|
-
id: "broker-link",
|
|
98506
|
-
title: spec.brokerLabel,
|
|
98507
|
-
description: `Each integration links to a ${spec.brokerLabel} connection (Connectivity → Brokers). The connection and its sign-in are edited there.`,
|
|
98508
|
-
columns: 1,
|
|
98509
|
-
fields: [{
|
|
98510
|
-
type: "broker-select",
|
|
98511
|
-
key: "brokerId",
|
|
98512
|
-
label: spec.brokerLabel,
|
|
98513
|
-
brokerKind: spec.brokerKind
|
|
98514
|
-
}]
|
|
98515
|
-
},
|
|
98516
|
-
...spec.linkSections ?? [],
|
|
98517
|
-
...dynamic
|
|
98518
|
-
] };
|
|
98519
|
-
}
|
|
98520
|
-
/** The broker a record names; undefined while it names none. */
|
|
98521
|
-
function resolveBrokerId(record) {
|
|
98522
|
-
return record.brokerId === void 0 || record.brokerId === "" ? void 0 : record.brokerId;
|
|
98765
|
+
/** The id a record holds in a link field; undefined while it names none. */
|
|
98766
|
+
function linkedId(body, field) {
|
|
98767
|
+
const value = body[field];
|
|
98768
|
+
return typeof value === "string" && value !== "" ? value : void 0;
|
|
98523
98769
|
}
|
|
98524
|
-
/** The
|
|
98525
|
-
async function
|
|
98770
|
+
/** The instance an integration's record links to through `field`; undefined while it names none. */
|
|
98771
|
+
async function linkedIdOf(instances, integrationId, field) {
|
|
98526
98772
|
const body = await instances.getResolved(integrationId);
|
|
98527
|
-
return body === null ? void 0 :
|
|
98773
|
+
return body === null ? void 0 : linkedId(body, field);
|
|
98528
98774
|
}
|
|
98529
|
-
/** Every record of the kind
|
|
98530
|
-
function
|
|
98531
|
-
return { find: async (
|
|
98775
|
+
/** Every record of the kind whose `field` names `instanceId`, as users of that instance. */
|
|
98776
|
+
function linkUserFinder(instances, field, userKind = "integration") {
|
|
98777
|
+
return { find: async (instanceId) => (await instances.listResolved()).filter((entry) => linkedId(entry.body, field) === instanceId).map((entry) => ({
|
|
98532
98778
|
kind: userKind,
|
|
98533
98779
|
id: entry.id,
|
|
98534
|
-
name: entry.body.name
|
|
98780
|
+
name: typeof entry.body["name"] === "string" ? entry.body["name"] : entry.id
|
|
98535
98781
|
})) };
|
|
98536
98782
|
}
|
|
98537
|
-
/** The `integration-instances` provider of
|
|
98538
|
-
function
|
|
98783
|
+
/** The `integration-instances` provider of a service whose records link to other instances. */
|
|
98784
|
+
function buildLinkedInstances(records, spec) {
|
|
98785
|
+
const links = linksOfKind(spec.kind);
|
|
98786
|
+
const kindId = spec.instanceKind ?? spec.kind.name;
|
|
98539
98787
|
const instances = new RecordIntegrationInstances(records, spec.kind, {
|
|
98540
98788
|
description: {
|
|
98541
|
-
|
|
98542
|
-
|
|
98543
|
-
|
|
98544
|
-
|
|
98789
|
+
...spec.description,
|
|
98790
|
+
links: links.map((link) => ({
|
|
98791
|
+
kind: kindId,
|
|
98792
|
+
field: link.field,
|
|
98793
|
+
to: link.to
|
|
98794
|
+
}))
|
|
98795
|
+
},
|
|
98796
|
+
formSchema: async (id) => {
|
|
98797
|
+
const first = links[0];
|
|
98798
|
+
const chosen = id === null || first === void 0 || spec.dynamicSections === void 0 ? void 0 : linkedId(await instances.getResolved(id) ?? {}, first.field);
|
|
98799
|
+
const dynamic = chosen !== void 0 && spec.dynamicSections !== void 0 ? await spec.dynamicSections(chosen) : [];
|
|
98800
|
+
return { sections: [
|
|
98801
|
+
{
|
|
98802
|
+
id: "links",
|
|
98803
|
+
title: "Linked instances",
|
|
98804
|
+
columns: 1,
|
|
98805
|
+
fields: links.map((link) => linkFieldDescriptor(link.field, link.to, spec.labels?.[link.field] ?? { label: link.field }))
|
|
98806
|
+
},
|
|
98807
|
+
...spec.linkSections ?? [],
|
|
98808
|
+
...dynamic
|
|
98809
|
+
] };
|
|
98545
98810
|
},
|
|
98546
|
-
formSchema: async (id) => buildBrokerLinkForm(spec, id === null ? void 0 : await brokerIdOf(instances, id)),
|
|
98547
98811
|
initialBody: (name) => spec.kind.schema.parse({
|
|
98548
98812
|
name,
|
|
98549
|
-
|
|
98813
|
+
...Object.fromEntries(links.map((link) => [link.field, ""]))
|
|
98550
98814
|
}),
|
|
98551
|
-
configured: (body) =>
|
|
98815
|
+
configured: (body) => links.every((link) => linkedId(body, link.field) !== void 0) && (spec.alsoConfigured?.(body) ?? true),
|
|
98816
|
+
...spec.mintId !== void 0 ? { mintId: spec.mintId } : {},
|
|
98552
98817
|
...spec.onChanged !== void 0 ? { onChanged: spec.onChanged } : {}
|
|
98553
98818
|
});
|
|
98554
98819
|
return instances;
|
|
@@ -98616,6 +98881,42 @@ function brokerLinkPortsOver(deps) {
|
|
|
98616
98881
|
};
|
|
98617
98882
|
}
|
|
98618
98883
|
//#endregion
|
|
98884
|
+
//#region src/speak/constants.ts
|
|
98885
|
+
/** Echo speech limits and the worker restart bound (same window as CrashSupervisor). */
|
|
98886
|
+
var ALEXA_SPEAK_ADDON_ID = "import-alexa";
|
|
98887
|
+
var ALEXA_SPEAK_KIND = "alexa-echo";
|
|
98888
|
+
/** The kind of the Amazon-account broker the Echoes are read from. */
|
|
98889
|
+
var ALEXA_BROKER_KIND = "alexa";
|
|
98890
|
+
/** The Amazon account an Echo target speaks through. */
|
|
98891
|
+
var ALEXA_ACCOUNT_LINK = {
|
|
98892
|
+
cap: "broker",
|
|
98893
|
+
kind: ALEXA_BROKER_KIND
|
|
98894
|
+
};
|
|
98895
|
+
var DEVICE_REFRESH_MS = 5 * 6e4;
|
|
98896
|
+
/** Where the addon serves the Alexa mark; `listTargetKinds` stamps it as `iconUrl`. */
|
|
98897
|
+
var ALEXA_SPEAK_ICON_URL = `/addon/${ALEXA_SPEAK_ADDON_ID}/icons/${ALEXA_SPEAK_KIND}`;
|
|
98898
|
+
/** How often an adopted Echo's player state is read while the push channel is down. */
|
|
98899
|
+
var ECHO_POLL_MS = 3e4;
|
|
98900
|
+
/**
|
|
98901
|
+
* The same read while the push channel is up: a safety net for a missed event, not the
|
|
98902
|
+
* way changes arrive. Back to `ECHO_POLL_MS` the moment the channel goes down.
|
|
98903
|
+
*/
|
|
98904
|
+
var ECHO_POLL_PUSH_HEALTHY_MS = 5 * 6e4;
|
|
98905
|
+
/** Push events for one Echo inside this window are one read, not one per event. */
|
|
98906
|
+
var ECHO_PUSH_READ_GAP_MS = 2e3;
|
|
98907
|
+
/** One more read after a command, once Amazon has had time to apply it. */
|
|
98908
|
+
var ECHO_REREAD_MS = 1500;
|
|
98909
|
+
/** After a 429 the account is left alone for this long, doubling up to the cap. */
|
|
98910
|
+
var ECHO_RATE_BACKOFF_BASE_MS = 3e4;
|
|
98911
|
+
var ECHO_RATE_BACKOFF_MAX_MS = 5 * 6e4;
|
|
98912
|
+
/**
|
|
98913
|
+
* A command (volume, play, Do Not Disturb) that has not started within this, or not
|
|
98914
|
+
* finished, is answered with an error instead of running minutes late.
|
|
98915
|
+
*/
|
|
98916
|
+
var ECHO_COMMAND_DEADLINE_MS = 12e3;
|
|
98917
|
+
/** A poll read is stale after this; it is dropped from the queue rather than sent late. */
|
|
98918
|
+
var ECHO_READ_DEADLINE_MS = 25e3;
|
|
98919
|
+
//#endregion
|
|
98619
98920
|
//#region src/config.ts
|
|
98620
98921
|
/**
|
|
98621
98922
|
* Alexa account connection settings for ONE broker (= one Amazon account).
|
|
@@ -98672,7 +98973,7 @@ var alexaEchoTargetSchema = object({
|
|
|
98672
98973
|
name: string(),
|
|
98673
98974
|
enabled: boolean(),
|
|
98674
98975
|
/** The broker (Amazon account) the Echo belongs to; '' until one is chosen. */
|
|
98675
|
-
brokerId:
|
|
98976
|
+
brokerId: linkField(ALEXA_ACCOUNT_LINK).default(""),
|
|
98676
98977
|
echoId: string(),
|
|
98677
98978
|
respectDnd: boolean()
|
|
98678
98979
|
});
|
|
@@ -142931,7 +143232,7 @@ var AlexaBrokerRegistry = class {
|
|
|
142931
143232
|
async restore(entries) {
|
|
142932
143233
|
for (const entry of entries) this.#seedCounter(entry.id);
|
|
142933
143234
|
for (const entry of entries) {
|
|
142934
|
-
if (entry
|
|
143235
|
+
if (!isInstanceEnabled(entry)) continue;
|
|
142935
143236
|
try {
|
|
142936
143237
|
await this.#startManager(entry);
|
|
142937
143238
|
} catch (err) {
|
|
@@ -143897,6 +144198,9 @@ function isKnownCap(cap) {
|
|
|
143897
144198
|
return KNOWN_CAPS.has(cap);
|
|
143898
144199
|
}
|
|
143899
144200
|
var EchoSessionRegistry = class {
|
|
144201
|
+
#channels = /* @__PURE__ */ new Map();
|
|
144202
|
+
#pushListeners = /* @__PURE__ */ new Map();
|
|
144203
|
+
#channelListeners = /* @__PURE__ */ new Map();
|
|
143900
144204
|
#resolve = null;
|
|
143901
144205
|
#dnd = /* @__PURE__ */ new Map();
|
|
143902
144206
|
#volumes = /* @__PURE__ */ new Map();
|
|
@@ -143938,12 +144242,54 @@ var EchoSessionRegistry = class {
|
|
|
143938
144242
|
});
|
|
143939
144243
|
return pending;
|
|
143940
144244
|
}
|
|
144245
|
+
/** Forget the shared volume list: a list fetched before a set must not answer a poll after it. */
|
|
144246
|
+
invalidateVolumes(brokerId) {
|
|
144247
|
+
this.#volumes.delete(brokerId);
|
|
144248
|
+
}
|
|
144249
|
+
/** Forget the shared Do Not Disturb list, for the same reason. */
|
|
144250
|
+
invalidateDnd(brokerId) {
|
|
144251
|
+
this.#dnd.delete(brokerId);
|
|
144252
|
+
}
|
|
144253
|
+
/** Whether the account's push channel is open; `down` when nothing was ever reported. */
|
|
144254
|
+
pushChannel(brokerId) {
|
|
144255
|
+
return this.#channels.get(brokerId) ?? "down";
|
|
144256
|
+
}
|
|
144257
|
+
channel(brokerId, state) {
|
|
144258
|
+
if (this.pushChannel(brokerId) === state) return;
|
|
144259
|
+
this.#channels.set(brokerId, state);
|
|
144260
|
+
for (const listener of [...this.#channelListeners.get(brokerId) ?? []]) listener(state);
|
|
144261
|
+
}
|
|
144262
|
+
event(brokerId, serial) {
|
|
144263
|
+
this.invalidateVolumes(brokerId);
|
|
144264
|
+
this.invalidateDnd(brokerId);
|
|
144265
|
+
for (const listener of [...this.#pushListeners.get(`${brokerId}\u0000${serial}`) ?? []]) listener();
|
|
144266
|
+
}
|
|
144267
|
+
/** Called when Amazon pushes something about this Echo. Returns the unsubscribe. */
|
|
144268
|
+
onEcho(brokerId, serial, listener) {
|
|
144269
|
+
return subscribe(this.#pushListeners, `${brokerId}\u0000${serial}`, listener);
|
|
144270
|
+
}
|
|
144271
|
+
/** Called when the account's channel goes up or down. Returns the unsubscribe. */
|
|
144272
|
+
onChannel(brokerId, listener) {
|
|
144273
|
+
return subscribe(this.#channelListeners, brokerId, listener);
|
|
144274
|
+
}
|
|
143941
144275
|
clear() {
|
|
144276
|
+
this.#channels.clear();
|
|
144277
|
+
this.#pushListeners.clear();
|
|
144278
|
+
this.#channelListeners.clear();
|
|
143942
144279
|
this.#resolve = null;
|
|
143943
144280
|
this.#dnd.clear();
|
|
143944
144281
|
this.#volumes.clear();
|
|
143945
144282
|
}
|
|
143946
144283
|
};
|
|
144284
|
+
function subscribe(map, key, listener) {
|
|
144285
|
+
const set = map.get(key) ?? /* @__PURE__ */ new Set();
|
|
144286
|
+
set.add(listener);
|
|
144287
|
+
map.set(key, set);
|
|
144288
|
+
return () => {
|
|
144289
|
+
set.delete(listener);
|
|
144290
|
+
if (set.size === 0 && map.get(key) === set) map.delete(key);
|
|
144291
|
+
};
|
|
144292
|
+
}
|
|
143947
144293
|
/** The single in-process registry shared across the addon. */
|
|
143948
144294
|
var alexaEchoSessions = new EchoSessionRegistry();
|
|
143949
144295
|
//#endregion
|
|
@@ -143963,6 +144309,7 @@ function emptyEchoStatus(state, now) {
|
|
|
143963
144309
|
duration: null,
|
|
143964
144310
|
shuffle: null,
|
|
143965
144311
|
repeat: null,
|
|
144312
|
+
positionUpdatedAt: null,
|
|
143966
144313
|
lastChangedAt: now
|
|
143967
144314
|
};
|
|
143968
144315
|
}
|
|
@@ -143996,12 +144343,24 @@ function repeatOf(value) {
|
|
|
143996
144343
|
if (value === "UNSELECTED") return "off";
|
|
143997
144344
|
return null;
|
|
143998
144345
|
}
|
|
144346
|
+
/** The streaming service playing: its display name, else its raw name; empty when Amazon names none. */
|
|
144347
|
+
function sourceOf(info) {
|
|
144348
|
+
return nonEmpty(info.provider?.providerDisplayName) ?? nonEmpty(info.provider?.providerName) ?? "";
|
|
144349
|
+
}
|
|
144350
|
+
/**
|
|
144351
|
+
* What is playing. Amazon puts the title in `infoText.title`, but a radio station
|
|
144352
|
+
* often names itself only in the first sub text, and some players send artwork and
|
|
144353
|
+
* a provider with no text at all: each still counts as now playing. Nothing to
|
|
144354
|
+
* title it by (no text, and artwork with no provider name) is null.
|
|
144355
|
+
*/
|
|
143999
144356
|
function currentMediaOf(info) {
|
|
144000
|
-
const
|
|
144001
|
-
|
|
144002
|
-
const artist = nonEmpty(info.infoText?.subText1);
|
|
144003
|
-
const album = nonEmpty(info.infoText?.subText2);
|
|
144357
|
+
const first = nonEmpty(info.infoText?.title);
|
|
144358
|
+
const second = nonEmpty(info.infoText?.subText1);
|
|
144004
144359
|
const imageUrl = nonEmpty(info.mainArt?.url);
|
|
144360
|
+
const album = nonEmpty(info.infoText?.subText2);
|
|
144361
|
+
const title = first ?? second ?? (imageUrl !== null ? nonEmpty(sourceOf(info)) : null);
|
|
144362
|
+
if (title === null) return null;
|
|
144363
|
+
const artist = first !== null ? second : null;
|
|
144005
144364
|
return {
|
|
144006
144365
|
type: "unknown",
|
|
144007
144366
|
title,
|
|
@@ -144013,12 +144372,15 @@ function currentMediaOf(info) {
|
|
|
144013
144372
|
function mapPlayerInfoToStatus(info, input) {
|
|
144014
144373
|
if (!input.online) return emptyEchoStatus("off", input.now);
|
|
144015
144374
|
const length = secondsToMs(info.progress?.mediaLength);
|
|
144375
|
+
const position = secondsToMs(info.progress?.mediaProgress);
|
|
144016
144376
|
return {
|
|
144017
144377
|
...emptyEchoStatus(mapEchoPlaybackState(info.state), input.now),
|
|
144018
144378
|
volumeLevel: percent(info.volume?.volume),
|
|
144019
144379
|
isMuted: typeof info.volume?.muted === "boolean" ? info.volume.muted : null,
|
|
144380
|
+
source: sourceOf(info),
|
|
144020
144381
|
currentMedia: currentMediaOf(info),
|
|
144021
|
-
position
|
|
144382
|
+
position,
|
|
144383
|
+
positionUpdatedAt: position === null ? null : input.now,
|
|
144022
144384
|
duration: length === 0 ? null : length,
|
|
144023
144385
|
shuffle: shuffleOf(info.transport?.shuffle),
|
|
144024
144386
|
repeat: repeatOf(info.transport?.repeat)
|
|
@@ -144037,30 +144399,6 @@ function withListedVolume(status, listed) {
|
|
|
144037
144399
|
};
|
|
144038
144400
|
}
|
|
144039
144401
|
//#endregion
|
|
144040
|
-
//#region src/speak/constants.ts
|
|
144041
|
-
/** Echo speech limits and the worker restart bound (same window as CrashSupervisor). */
|
|
144042
|
-
var ALEXA_SPEAK_ADDON_ID = "import-alexa";
|
|
144043
|
-
var ALEXA_SPEAK_KIND = "alexa-echo";
|
|
144044
|
-
/** The kind of the Amazon-account broker the Echoes are read from. */
|
|
144045
|
-
var ALEXA_BROKER_KIND = "alexa";
|
|
144046
|
-
var DEVICE_REFRESH_MS = 5 * 6e4;
|
|
144047
|
-
/** Where the addon serves the Alexa mark; `listTargetKinds` stamps it as `iconUrl`. */
|
|
144048
|
-
var ALEXA_SPEAK_ICON_URL = `/addon/${ALEXA_SPEAK_ADDON_ID}/icons/${ALEXA_SPEAK_KIND}`;
|
|
144049
|
-
/** How often an adopted Echo's player state is read (a push channel is a later phase). */
|
|
144050
|
-
var ECHO_POLL_MS = 3e4;
|
|
144051
|
-
/** One more read after a command, once Amazon has had time to apply it. */
|
|
144052
|
-
var ECHO_REREAD_MS = 1500;
|
|
144053
|
-
/** After a 429 the account is left alone for this long, doubling up to the cap. */
|
|
144054
|
-
var ECHO_RATE_BACKOFF_BASE_MS = 3e4;
|
|
144055
|
-
var ECHO_RATE_BACKOFF_MAX_MS = 5 * 6e4;
|
|
144056
|
-
/**
|
|
144057
|
-
* A command (volume, play, Do Not Disturb) that has not started within this, or not
|
|
144058
|
-
* finished, is answered with an error instead of running minutes late.
|
|
144059
|
-
*/
|
|
144060
|
-
var ECHO_COMMAND_DEADLINE_MS = 12e3;
|
|
144061
|
-
/** A poll read is stale after this; it is dropped from the queue rather than sent late. */
|
|
144062
|
-
var ECHO_READ_DEADLINE_MS = 25e3;
|
|
144063
|
-
//#endregion
|
|
144064
144402
|
//#region src/speak/worker-protocol.ts
|
|
144065
144403
|
/** Line-delimited JSON the long-lived Python worker speaks. One object per line. */
|
|
144066
144404
|
var EchoDeviceSchema = object({
|
|
@@ -144096,6 +144434,35 @@ var WorkerLineSchema = union([object({
|
|
|
144096
144434
|
/** A typed reason the worker names; `rate-limited` is Amazon's 429. */
|
|
144097
144435
|
code: string().optional()
|
|
144098
144436
|
})]);
|
|
144437
|
+
var WorkerPushSchema = discriminatedUnion("push", [object({
|
|
144438
|
+
push: literal("channel"),
|
|
144439
|
+
state: _enum(["up", "down"]),
|
|
144440
|
+
reason: string().default(""),
|
|
144441
|
+
/** How long until the worker opens the channel again; 0 when it is up. */
|
|
144442
|
+
retryInMs: number$1().int().nonnegative().default(0)
|
|
144443
|
+
}), object({
|
|
144444
|
+
push: literal("event"),
|
|
144445
|
+
command: string().min(1),
|
|
144446
|
+
/** The Echo the event is about; null when Amazon named none. */
|
|
144447
|
+
serial: string().min(1).nullable()
|
|
144448
|
+
})]);
|
|
144449
|
+
/** Push events that change what an Echo's slice shows: the addon reads that Echo once. */
|
|
144450
|
+
var PUSH_READ_COMMANDS = new Set([
|
|
144451
|
+
"PUSH_AUDIO_PLAYER_STATE",
|
|
144452
|
+
"PUSH_MEDIA_CHANGE",
|
|
144453
|
+
"PUSH_VOLUME_CHANGE",
|
|
144454
|
+
"PUSH_DOPPLER_CONNECTION_CHANGE"
|
|
144455
|
+
]);
|
|
144456
|
+
/**
|
|
144457
|
+
* Push events Amazon sends that the slice does not need: progress ticks every few
|
|
144458
|
+
* seconds while playing (the position is derived from `positionUpdatedAt`), and the
|
|
144459
|
+
* notification / equalizer changes touch nothing the media-player shows.
|
|
144460
|
+
*/
|
|
144461
|
+
var PUSH_IGNORED_COMMANDS = new Set([
|
|
144462
|
+
"PUSH_MEDIA_PROGRESS_CHANGE",
|
|
144463
|
+
"PUSH_NOTIFICATION_CHANGE",
|
|
144464
|
+
"PUSH_EQUALIZER_STATE_CHANGE"
|
|
144465
|
+
]);
|
|
144099
144466
|
var RATE_LIMITED_CODE = "rate-limited";
|
|
144100
144467
|
/** Amazon answered 429. Never an empty answer: the caller backs off and keeps what it had. */
|
|
144101
144468
|
var RateLimitedError = class extends Error {
|
|
@@ -144138,35 +144505,75 @@ function parseSessionStatus(result) {
|
|
|
144138
144505
|
return parsed.data;
|
|
144139
144506
|
}
|
|
144140
144507
|
/**
|
|
144508
|
+
* A reading Amazon sent in a shape we did not expect is a reading we do not have
|
|
144509
|
+
* (null), never a reason to discard the rest of the player: one odd field used to
|
|
144510
|
+
* fail the whole read and the Echo kept showing nothing.
|
|
144511
|
+
*/
|
|
144512
|
+
var text = string().nullish().catch(null);
|
|
144513
|
+
var flag = boolean().nullish().catch(null);
|
|
144514
|
+
var count = number$1().nullish().catch(null);
|
|
144515
|
+
/**
|
|
144141
144516
|
* The raw `playerInfo` of `/api/np/player`. Amazon omits what it does not know,
|
|
144142
144517
|
* so every field is nullable; the mapping to the media-player cap turns a missing
|
|
144143
|
-
* value into null, never into 0 (D393).
|
|
144518
|
+
* value into null, never into 0 (D393). Every block and every leaf is read on its
|
|
144519
|
+
* own: a block of the wrong type is null, the others still count.
|
|
144144
144520
|
*/
|
|
144145
144521
|
var PlayerInfoSchema = object({
|
|
144146
|
-
state:
|
|
144522
|
+
state: text,
|
|
144147
144523
|
volume: object({
|
|
144148
|
-
volume:
|
|
144149
|
-
muted:
|
|
144150
|
-
}).nullish(),
|
|
144524
|
+
volume: count,
|
|
144525
|
+
muted: flag
|
|
144526
|
+
}).nullish().catch(null),
|
|
144151
144527
|
infoText: object({
|
|
144152
|
-
title:
|
|
144153
|
-
subText1:
|
|
144154
|
-
subText2:
|
|
144155
|
-
}).nullish(),
|
|
144156
|
-
mainArt: object({ url:
|
|
144528
|
+
title: text,
|
|
144529
|
+
subText1: text,
|
|
144530
|
+
subText2: text
|
|
144531
|
+
}).nullish().catch(null),
|
|
144532
|
+
mainArt: object({ url: text }).nullish().catch(null),
|
|
144157
144533
|
progress: object({
|
|
144158
|
-
mediaLength:
|
|
144159
|
-
mediaProgress:
|
|
144160
|
-
}).nullish(),
|
|
144534
|
+
mediaLength: count,
|
|
144535
|
+
mediaProgress: count
|
|
144536
|
+
}).nullish().catch(null),
|
|
144537
|
+
provider: object({
|
|
144538
|
+
providerName: text,
|
|
144539
|
+
providerDisplayName: text
|
|
144540
|
+
}).nullish().catch(null),
|
|
144161
144541
|
transport: object({
|
|
144162
|
-
shuffle:
|
|
144163
|
-
repeat:
|
|
144164
|
-
}).nullish()
|
|
144165
|
-
});
|
|
144542
|
+
shuffle: text,
|
|
144543
|
+
repeat: text
|
|
144544
|
+
}).nullish().catch(null)
|
|
144545
|
+
});
|
|
144546
|
+
/** The playback states Amazon is known to send. */
|
|
144547
|
+
var KNOWN_PLAYER_STATES = new Set([
|
|
144548
|
+
"PLAYING",
|
|
144549
|
+
"PAUSED",
|
|
144550
|
+
"BUFFERING",
|
|
144551
|
+
"IDLE"
|
|
144552
|
+
]);
|
|
144553
|
+
/**
|
|
144554
|
+
* A lenient read must not turn garbage into a healthy idle player: when Amazon sent a
|
|
144555
|
+
* state we do not recognise (or of the wrong type) and not one block survived, the read
|
|
144556
|
+
* is a failure, exactly as before the leniency. A body that says nothing (`{}`, null
|
|
144557
|
+
* state) stays an idle player.
|
|
144558
|
+
*/
|
|
144166
144559
|
function parsePlayerState(result) {
|
|
144167
144560
|
const parsed = object({ playerInfo: PlayerInfoSchema.nullish() }).safeParse(result);
|
|
144168
144561
|
if (!parsed.success) throw new Error("worker returned an unexpected player state");
|
|
144169
|
-
|
|
144562
|
+
const info = parsed.data.playerInfo ?? {};
|
|
144563
|
+
const raw = object({ playerInfo: object({ state: unknown() }).nullish() }).safeParse(result);
|
|
144564
|
+
const rawState = raw.success ? raw.data.playerInfo?.state : void 0;
|
|
144565
|
+
const saidSomething = rawState !== void 0 && rawState !== null;
|
|
144566
|
+
const knownState = typeof info.state === "string" && KNOWN_PLAYER_STATES.has(info.state);
|
|
144567
|
+
const noBlock = [
|
|
144568
|
+
info.volume,
|
|
144569
|
+
info.infoText,
|
|
144570
|
+
info.mainArt,
|
|
144571
|
+
info.progress,
|
|
144572
|
+
info.provider,
|
|
144573
|
+
info.transport
|
|
144574
|
+
].every((block) => block === null || block === void 0);
|
|
144575
|
+
if (saidSomething && !knownState && noBlock) throw new Error("worker returned an unreadable player state");
|
|
144576
|
+
return info;
|
|
144170
144577
|
}
|
|
144171
144578
|
var DndEntrySchema = object({
|
|
144172
144579
|
serial: string().min(1),
|
|
@@ -144217,21 +144624,32 @@ var AlexaEchoDevice = class extends BaseDevice {
|
|
|
144217
144624
|
features = [
|
|
144218
144625
|
DeviceFeature.MediaPlayerVolume,
|
|
144219
144626
|
DeviceFeature.MediaPlayerNext,
|
|
144220
|
-
DeviceFeature.MediaPlayerPrevious
|
|
144627
|
+
DeviceFeature.MediaPlayerPrevious,
|
|
144628
|
+
DeviceFeature.MediaPlayerShuffle,
|
|
144629
|
+
DeviceFeature.MediaPlayerRepeat
|
|
144221
144630
|
];
|
|
144222
144631
|
brokerId;
|
|
144223
144632
|
serial;
|
|
144224
144633
|
amazonDeviceType;
|
|
144225
144634
|
timer = null;
|
|
144226
144635
|
rereadTimer = null;
|
|
144636
|
+
pushReadTimer = null;
|
|
144637
|
+
lastPushReadAt = 0;
|
|
144638
|
+
repollAfter = false;
|
|
144639
|
+
unsubscribers = [];
|
|
144227
144640
|
polling = false;
|
|
144228
144641
|
failureLogged = false;
|
|
144229
144642
|
volumeFailureLogged = false;
|
|
144230
144643
|
health = "ok";
|
|
144231
|
-
/**
|
|
144232
|
-
|
|
144233
|
-
|
|
144234
|
-
|
|
144644
|
+
/** When this device last wrote its volume / Do Not Disturb optimistically; a read that began earlier must not overwrite it. */
|
|
144645
|
+
volumeSetAt = 0;
|
|
144646
|
+
dndSetAt = 0;
|
|
144647
|
+
/** Counts this device's sets; a refused set reverts only while it is still the latest. */
|
|
144648
|
+
volumeSeq = 0;
|
|
144649
|
+
dndSeq = 0;
|
|
144650
|
+
/** The level Amazon last took (a good set, or a read newer than every set); undefined until one is known. */
|
|
144651
|
+
volumeSettled = void 0;
|
|
144652
|
+
dndSettled = void 0;
|
|
144235
144653
|
constructor(ctx) {
|
|
144236
144654
|
const persisted = alexaEchoDeviceSchema.parse(ctx.persistedConfig ?? {});
|
|
144237
144655
|
super(ctx, alexaEchoDeviceSchema, { type: DeviceType.MediaPlayer });
|
|
@@ -144250,14 +144668,54 @@ var AlexaEchoDevice = class extends BaseDevice {
|
|
|
144250
144668
|
this.runtimeState.setCapState("media-player", emptyEchoStatus("off", 0));
|
|
144251
144669
|
this.runtimeState.setCapState("do-not-disturb", COLD_DND);
|
|
144252
144670
|
this.poll();
|
|
144253
|
-
this.
|
|
144671
|
+
this.armPollTimer();
|
|
144672
|
+
this.unsubscribers = [alexaEchoSessions.onEcho(this.brokerId, this.serial, () => this.onPushEvent()), alexaEchoSessions.onChannel(this.brokerId, (state) => this.onChannel(state))];
|
|
144673
|
+
}
|
|
144674
|
+
/** The poll pace now: slow while the push channel is up, normal when it is down. */
|
|
144675
|
+
get pollIntervalMs() {
|
|
144676
|
+
return alexaEchoSessions.pushChannel(this.brokerId) === "up" ? ECHO_POLL_PUSH_HEALTHY_MS : ECHO_POLL_MS;
|
|
144677
|
+
}
|
|
144678
|
+
armPollTimer() {
|
|
144679
|
+
if (this.timer !== null) clearInterval(this.timer);
|
|
144680
|
+
this.timer = setInterval(() => void this.poll(), this.pollIntervalMs);
|
|
144254
144681
|
this.timer.unref();
|
|
144255
144682
|
}
|
|
144683
|
+
onChannel(state) {
|
|
144684
|
+
this.armPollTimer();
|
|
144685
|
+
this.ctx.logger.debug("alexa echo: push channel changed, poll pace follows", {
|
|
144686
|
+
tags: { deviceId: this.id },
|
|
144687
|
+
meta: {
|
|
144688
|
+
channel: state,
|
|
144689
|
+
pollMs: this.pollIntervalMs
|
|
144690
|
+
}
|
|
144691
|
+
});
|
|
144692
|
+
this.poll();
|
|
144693
|
+
}
|
|
144694
|
+
/**
|
|
144695
|
+
* Amazon pushed a change for this Echo: read it once, no sooner than the gap after the
|
|
144696
|
+
* last push read. The read is the ordinary one, so the optimistic-set guard applies as
|
|
144697
|
+
* it does to a poll: a read that began before a set never undoes it.
|
|
144698
|
+
*/
|
|
144699
|
+
onPushEvent() {
|
|
144700
|
+
if (this.pushReadTimer !== null) return;
|
|
144701
|
+
const wait = Math.max(0, this.lastPushReadAt + ECHO_PUSH_READ_GAP_MS - Date.now());
|
|
144702
|
+
this.pushReadTimer = setTimeout(() => {
|
|
144703
|
+
this.pushReadTimer = null;
|
|
144704
|
+
this.lastPushReadAt = Date.now();
|
|
144705
|
+
if (this.polling) this.repollAfter = true;
|
|
144706
|
+
else this.poll();
|
|
144707
|
+
}, wait);
|
|
144708
|
+
this.pushReadTimer.unref();
|
|
144709
|
+
}
|
|
144256
144710
|
async removeDevice() {
|
|
144257
144711
|
if (this.timer !== null) clearInterval(this.timer);
|
|
144258
144712
|
if (this.rereadTimer !== null) clearTimeout(this.rereadTimer);
|
|
144713
|
+
if (this.pushReadTimer !== null) clearTimeout(this.pushReadTimer);
|
|
144714
|
+
for (const off of this.unsubscribers) off();
|
|
144715
|
+
this.unsubscribers = [];
|
|
144259
144716
|
this.timer = null;
|
|
144260
144717
|
this.rereadTimer = null;
|
|
144718
|
+
this.pushReadTimer = null;
|
|
144261
144719
|
await super.removeDevice();
|
|
144262
144720
|
}
|
|
144263
144721
|
session() {
|
|
@@ -144286,8 +144744,13 @@ var AlexaEchoDevice = class extends BaseDevice {
|
|
|
144286
144744
|
} finally {
|
|
144287
144745
|
this.polling = false;
|
|
144288
144746
|
}
|
|
144747
|
+
if (this.repollAfter) {
|
|
144748
|
+
this.repollAfter = false;
|
|
144749
|
+
await this.poll();
|
|
144750
|
+
}
|
|
144289
144751
|
}
|
|
144290
144752
|
async readOnce() {
|
|
144753
|
+
const readStartedAt = Date.now();
|
|
144291
144754
|
const session = alexaEchoSessions.get(this.brokerId);
|
|
144292
144755
|
if (session === null || session.state !== "ready") return;
|
|
144293
144756
|
if (session.rateLimitedUntil !== null) {
|
|
@@ -144303,21 +144766,21 @@ var AlexaEchoDevice = class extends BaseDevice {
|
|
|
144303
144766
|
}
|
|
144304
144767
|
const deviceType = this.amazonDeviceType !== "" ? this.amazonDeviceType : listed?.deviceType ?? "";
|
|
144305
144768
|
try {
|
|
144306
|
-
const
|
|
144307
|
-
const info = await session.playerState(this.serial, deviceType, this.id);
|
|
144308
|
-
this.polledFrom = readStartedAt;
|
|
144309
|
-
const fromPlayer = mapPlayerInfoToStatus(info, {
|
|
144769
|
+
const fromPlayer = mapPlayerInfoToStatus(await session.playerState(this.serial, deviceType, this.id), {
|
|
144310
144770
|
online: true,
|
|
144311
144771
|
now: Date.now()
|
|
144312
144772
|
});
|
|
144313
|
-
|
|
144773
|
+
const read = await this.completeVolume(fromPlayer);
|
|
144774
|
+
const kept = this.keepNewerVolume(read, readStartedAt);
|
|
144775
|
+
if (kept === read) this.volumeSettled = read.volumeLevel;
|
|
144776
|
+
this.runtimeState.setCapState("media-player", kept);
|
|
144314
144777
|
this.markOnline(true);
|
|
144315
144778
|
this.noteRecovered();
|
|
144316
144779
|
} catch (err) {
|
|
144317
144780
|
this.noteReadFailure(err);
|
|
144318
144781
|
return;
|
|
144319
144782
|
}
|
|
144320
|
-
await this.readDoNotDisturb();
|
|
144783
|
+
await this.readDoNotDisturb(readStartedAt);
|
|
144321
144784
|
}
|
|
144322
144785
|
/**
|
|
144323
144786
|
* An idle Echo's player read carries no volume. Ask the account's volume list
|
|
@@ -144342,10 +144805,26 @@ var AlexaEchoDevice = class extends BaseDevice {
|
|
|
144342
144805
|
return status;
|
|
144343
144806
|
}
|
|
144344
144807
|
}
|
|
144345
|
-
|
|
144808
|
+
/**
|
|
144809
|
+
* A read that began before this device last set its volume carries the level from
|
|
144810
|
+
* before the set: keep the level the operator chose (and its mute), take the rest.
|
|
144811
|
+
*/
|
|
144812
|
+
keepNewerVolume(read, readStartedAt) {
|
|
144813
|
+
if (this.volumeSetAt < readStartedAt) return read;
|
|
144814
|
+
const current = this.runtimeState.getCapState("media-player");
|
|
144815
|
+
if (current === void 0) return read;
|
|
144816
|
+
return {
|
|
144817
|
+
...read,
|
|
144818
|
+
volumeLevel: current.volumeLevel,
|
|
144819
|
+
isMuted: current.isMuted
|
|
144820
|
+
};
|
|
144821
|
+
}
|
|
144822
|
+
async readDoNotDisturb(readStartedAt) {
|
|
144346
144823
|
try {
|
|
144347
144824
|
const enabled = (await alexaEchoSessions.dnd(this.brokerId, Date.now())).get(this.serial);
|
|
144348
144825
|
if (enabled === void 0) return;
|
|
144826
|
+
if (this.dndSetAt >= readStartedAt) return;
|
|
144827
|
+
this.dndSettled = enabled;
|
|
144349
144828
|
this.runtimeState.setCapState("do-not-disturb", {
|
|
144350
144829
|
enabled,
|
|
144351
144830
|
lastChangedAt: Date.now()
|
|
@@ -144377,23 +144856,56 @@ var AlexaEchoDevice = class extends BaseDevice {
|
|
|
144377
144856
|
await this.session().npCommand(this.serial, command, this.id);
|
|
144378
144857
|
this.rereadSoon();
|
|
144379
144858
|
}
|
|
144859
|
+
/**
|
|
144860
|
+
* The slice shows the new level at once; Amazon is told next. On a refusal the
|
|
144861
|
+
* slice goes back to the level Amazon last took (a good set, else the last read,
|
|
144862
|
+
* else what the slice held), with a new stamp, and the error is rethrown
|
|
144863
|
+
* (RateLimitedError included) after a warning. Only the LATEST set reverts: when
|
|
144864
|
+
* a newer set has superseded this one, that set owns the slice, so an earlier
|
|
144865
|
+
* refusal never writes over it, and a later refusal falls back to what Amazon
|
|
144866
|
+
* actually took, never to a level it did not. A cold slice (no read yet) gets no
|
|
144867
|
+
* optimistic write, so no state is invented.
|
|
144868
|
+
*/
|
|
144380
144869
|
async sendVolume(level) {
|
|
144381
|
-
|
|
144382
|
-
|
|
144383
|
-
|
|
144384
|
-
|
|
144385
|
-
|
|
144386
|
-
|
|
144870
|
+
const previous = this.runtimeState.getCapState("media-player");
|
|
144871
|
+
const setAt = Date.now();
|
|
144872
|
+
const seq = ++this.volumeSeq;
|
|
144873
|
+
this.volumeSetAt = setAt;
|
|
144874
|
+
if (previous !== void 0) this.runtimeState.setCapState("media-player", {
|
|
144875
|
+
...previous,
|
|
144876
|
+
volumeLevel: level,
|
|
144877
|
+
lastChangedAt: setAt
|
|
144878
|
+
});
|
|
144879
|
+
alexaEchoSessions.invalidateVolumes(this.brokerId);
|
|
144880
|
+
try {
|
|
144881
|
+
await this.session().setVolume(this.serial, level, this.id);
|
|
144882
|
+
this.volumeSettled = level;
|
|
144883
|
+
} catch (err) {
|
|
144884
|
+
const back = this.volumeSettled !== void 0 ? this.volumeSettled : previous?.volumeLevel ?? null;
|
|
144885
|
+
const current = this.runtimeState.getCapState("media-player");
|
|
144886
|
+
if (seq === this.volumeSeq && current !== void 0) this.runtimeState.setCapState("media-player", {
|
|
144887
|
+
...current,
|
|
144888
|
+
volumeLevel: back,
|
|
144889
|
+
lastChangedAt: Date.now()
|
|
144890
|
+
});
|
|
144891
|
+
this.ctx.logger.warn("alexa echo: Amazon refused the volume, back to the previous level", {
|
|
144892
|
+
tags: { deviceId: this.id },
|
|
144893
|
+
meta: {
|
|
144894
|
+
from: back,
|
|
144895
|
+
to: level,
|
|
144896
|
+
superseded: seq !== this.volumeSeq,
|
|
144897
|
+
error: err instanceof Error ? err.message : String(err)
|
|
144898
|
+
}
|
|
144899
|
+
});
|
|
144900
|
+
throw err;
|
|
144901
|
+
}
|
|
144387
144902
|
}
|
|
144388
144903
|
/**
|
|
144389
|
-
* Amazon has no step command: move from the level
|
|
144390
|
-
*
|
|
144391
|
-
* there is none.
|
|
144904
|
+
* Amazon has no step command: move from the level in the slice (it already holds
|
|
144905
|
+
* the last level we set); refuse when there is none.
|
|
144392
144906
|
*/
|
|
144393
144907
|
async stepVolume(delta) {
|
|
144394
|
-
const
|
|
144395
|
-
const set = this.lastSet;
|
|
144396
|
-
const level = set !== null && set.at >= this.polledFrom ? set.level : polled;
|
|
144908
|
+
const level = this.runtimeState.getCapState("media-player")?.volumeLevel;
|
|
144397
144909
|
if (level === null || level === void 0) throw new Error("the Echo volume is not known yet");
|
|
144398
144910
|
await this.sendVolume(Math.max(0, Math.min(100, level + delta)));
|
|
144399
144911
|
}
|
|
@@ -144414,22 +144926,51 @@ var AlexaEchoDevice = class extends BaseDevice {
|
|
|
144414
144926
|
channelDown: async () => unsupported("channel steps"),
|
|
144415
144927
|
seek: async () => unsupported("seek"),
|
|
144416
144928
|
setMute: async () => unsupported("mute"),
|
|
144417
|
-
setShuffle:
|
|
144418
|
-
setRepeat: async () =>
|
|
144929
|
+
setShuffle: ({ shuffle }) => this.command(shuffle ? "shuffle-on" : "shuffle-off"),
|
|
144930
|
+
setRepeat: async ({ repeat }) => {
|
|
144931
|
+
if (repeat === "one") unsupported("repeat one");
|
|
144932
|
+
await this.command(repeat === "all" ? "repeat-on" : "repeat-off");
|
|
144933
|
+
},
|
|
144419
144934
|
selectSource: async () => unsupported("source selection"),
|
|
144420
144935
|
playMedia: async () => unsupported("playing media")
|
|
144421
144936
|
});
|
|
144422
144937
|
}
|
|
144938
|
+
/** Optimistic like the volume, with the same rules: only the latest refused set reverts, to what Amazon last took. */
|
|
144939
|
+
async sendDnd(enabled) {
|
|
144940
|
+
const previous = this.runtimeState.getCapState("do-not-disturb") ?? COLD_DND;
|
|
144941
|
+
const setAt = Date.now();
|
|
144942
|
+
const seq = ++this.dndSeq;
|
|
144943
|
+
this.dndSetAt = setAt;
|
|
144944
|
+
this.runtimeState.setCapState("do-not-disturb", {
|
|
144945
|
+
enabled,
|
|
144946
|
+
lastChangedAt: setAt
|
|
144947
|
+
});
|
|
144948
|
+
alexaEchoSessions.invalidateDnd(this.brokerId);
|
|
144949
|
+
try {
|
|
144950
|
+
await this.session().setDnd(this.serial, enabled, this.id);
|
|
144951
|
+
this.dndSettled = enabled;
|
|
144952
|
+
} catch (err) {
|
|
144953
|
+
const back = this.dndSettled !== void 0 ? this.dndSettled : previous.enabled;
|
|
144954
|
+
if (seq === this.dndSeq) this.runtimeState.setCapState("do-not-disturb", {
|
|
144955
|
+
enabled: back,
|
|
144956
|
+
lastChangedAt: Date.now()
|
|
144957
|
+
});
|
|
144958
|
+
this.ctx.logger.warn("alexa echo: Amazon refused Do Not Disturb, back to the previous state", {
|
|
144959
|
+
tags: { deviceId: this.id },
|
|
144960
|
+
meta: {
|
|
144961
|
+
from: back,
|
|
144962
|
+
to: enabled,
|
|
144963
|
+
superseded: seq !== this.dndSeq,
|
|
144964
|
+
error: err instanceof Error ? err.message : String(err)
|
|
144965
|
+
}
|
|
144966
|
+
});
|
|
144967
|
+
throw err;
|
|
144968
|
+
}
|
|
144969
|
+
}
|
|
144423
144970
|
registerDoNotDisturb() {
|
|
144424
144971
|
this.ctx.registerNativeCap(doNotDisturbCapability, {
|
|
144425
144972
|
getStatus: async () => this.runtimeState.getCapState("do-not-disturb") ?? COLD_DND,
|
|
144426
|
-
setEnabled: async ({ enabled }) =>
|
|
144427
|
-
await this.session().setDnd(this.serial, enabled, this.id);
|
|
144428
|
-
this.runtimeState.setCapState("do-not-disturb", {
|
|
144429
|
-
enabled,
|
|
144430
|
-
lastChangedAt: Date.now()
|
|
144431
|
-
});
|
|
144432
|
-
}
|
|
144973
|
+
setEnabled: async ({ enabled }) => this.sendDnd(enabled)
|
|
144433
144974
|
});
|
|
144434
144975
|
}
|
|
144435
144976
|
};
|
|
@@ -144450,7 +144991,9 @@ var alexaSpeakSessionsEntryKind = defineEntryKind("speak-sessions", {
|
|
|
144450
144991
|
writeTrigger: "an Echo worker signs in as an Alexa account broker"
|
|
144451
144992
|
});
|
|
144452
144993
|
/** An Alexa integration: the link to the Alexa account (broker) it imports devices from. */
|
|
144453
|
-
var alexaConnectionsEntryKind =
|
|
144994
|
+
var alexaConnectionsEntryKind = declareLinkKind({
|
|
144995
|
+
name: "connections",
|
|
144996
|
+
links: { brokerId: ALEXA_ACCOUNT_LINK },
|
|
144454
144997
|
title: "Alexa integrations",
|
|
144455
144998
|
writeTrigger: "an operator links an Alexa integration to an Alexa account"
|
|
144456
144999
|
});
|
|
@@ -144552,11 +145095,15 @@ function buildAlexaAccountInstances(records, deps) {
|
|
|
144552
145095
|
* record, edited on the Brokers page). The account's sign-in and secrets are never copied here.
|
|
144553
145096
|
*/
|
|
144554
145097
|
function buildAlexaInstances(records) {
|
|
144555
|
-
return
|
|
145098
|
+
return buildLinkedInstances(records, {
|
|
144556
145099
|
kind: alexaConnectionsEntryKind,
|
|
144557
|
-
|
|
144558
|
-
|
|
144559
|
-
|
|
145100
|
+
description: {
|
|
145101
|
+
role: "devices",
|
|
145102
|
+
instanceMode: "multiple",
|
|
145103
|
+
kind: "connection",
|
|
145104
|
+
canTest: false
|
|
145105
|
+
},
|
|
145106
|
+
labels: { brokerId: { label: "Alexa account" } }
|
|
144560
145107
|
});
|
|
144561
145108
|
}
|
|
144562
145109
|
//#endregion
|
|
@@ -144849,8 +145396,67 @@ var BrokerEcho = class {
|
|
|
144849
145396
|
currentDevices = [];
|
|
144850
145397
|
backoffMs = 0;
|
|
144851
145398
|
limitedUntil = 0;
|
|
145399
|
+
channelState = "down";
|
|
145400
|
+
channelWasUp = false;
|
|
145401
|
+
secrets = [];
|
|
145402
|
+
unknownPushLogged = /* @__PURE__ */ new Set();
|
|
144852
145403
|
constructor(deps) {
|
|
144853
145404
|
this.deps = deps;
|
|
145405
|
+
deps.worker.onPush((push) => this.onPush(push));
|
|
145406
|
+
}
|
|
145407
|
+
/** Whether Amazon's push channel is open for this account right now. */
|
|
145408
|
+
get pushChannel() {
|
|
145409
|
+
return this.channelState;
|
|
145410
|
+
}
|
|
145411
|
+
/** A reason is text from the library: capped, then scrubbed of the secrets in play. */
|
|
145412
|
+
safeReason(reason) {
|
|
145413
|
+
return scrubSecrets(reason.slice(0, 200), this.secrets);
|
|
145414
|
+
}
|
|
145415
|
+
onPush(push) {
|
|
145416
|
+
const tags = { tags: { brokerId: this.deps.brokerId } };
|
|
145417
|
+
if (push.push === "channel") {
|
|
145418
|
+
if (push.state === this.channelState) {
|
|
145419
|
+
if (push.state === "down") this.deps.logger.debug("alexa echo: push channel still down", {
|
|
145420
|
+
...tags,
|
|
145421
|
+
meta: {
|
|
145422
|
+
reason: this.safeReason(push.reason),
|
|
145423
|
+
retryInMs: push.retryInMs
|
|
145424
|
+
}
|
|
145425
|
+
});
|
|
145426
|
+
return;
|
|
145427
|
+
}
|
|
145428
|
+
this.channelState = push.state;
|
|
145429
|
+
if (push.state === "up") {
|
|
145430
|
+
this.deps.logger.info(this.channelWasUp ? "alexa echo: push channel reconnected, polling slows down" : "alexa echo: push channel connected, polling slows down", tags);
|
|
145431
|
+
this.channelWasUp = true;
|
|
145432
|
+
} else this.deps.logger.warn("alexa echo: push channel down, polling at the normal pace", {
|
|
145433
|
+
...tags,
|
|
145434
|
+
meta: {
|
|
145435
|
+
reason: this.safeReason(push.reason),
|
|
145436
|
+
retryInMs: push.retryInMs
|
|
145437
|
+
}
|
|
145438
|
+
});
|
|
145439
|
+
this.deps.push?.channel(this.deps.brokerId, push.state);
|
|
145440
|
+
return;
|
|
145441
|
+
}
|
|
145442
|
+
if (PUSH_READ_COMMANDS.has(push.command)) {
|
|
145443
|
+
if (push.serial === null) {
|
|
145444
|
+
this.deps.logger.debug("alexa echo: push event without an Echo, ignored", {
|
|
145445
|
+
...tags,
|
|
145446
|
+
meta: { command: push.command }
|
|
145447
|
+
});
|
|
145448
|
+
return;
|
|
145449
|
+
}
|
|
145450
|
+
this.deps.push?.event(this.deps.brokerId, push.serial);
|
|
145451
|
+
return;
|
|
145452
|
+
}
|
|
145453
|
+
if (PUSH_IGNORED_COMMANDS.has(push.command)) return;
|
|
145454
|
+
if (this.unknownPushLogged.has(push.command)) return;
|
|
145455
|
+
this.unknownPushLogged.add(push.command);
|
|
145456
|
+
this.deps.logger.info("alexa echo: unknown push event, ignored (logged once)", {
|
|
145457
|
+
...tags,
|
|
145458
|
+
meta: { command: push.command }
|
|
145459
|
+
});
|
|
144854
145460
|
}
|
|
144855
145461
|
get id() {
|
|
144856
145462
|
return this.deps.brokerId;
|
|
@@ -144876,6 +145482,10 @@ var BrokerEcho = class {
|
|
|
144876
145482
|
}
|
|
144877
145483
|
stop() {
|
|
144878
145484
|
this.deps.worker.stop();
|
|
145485
|
+
if (this.channelState === "up") {
|
|
145486
|
+
this.channelState = "down";
|
|
145487
|
+
this.deps.push?.channel(this.deps.brokerId, "down");
|
|
145488
|
+
}
|
|
144879
145489
|
}
|
|
144880
145490
|
/** Sign the worker in as the broker's account. Never throws; the outcome is `state`. */
|
|
144881
145491
|
async signIn() {
|
|
@@ -144902,6 +145512,7 @@ var BrokerEcho = class {
|
|
|
144902
145512
|
}
|
|
144903
145513
|
secrets = seed === null ? [] : [seed.refreshToken];
|
|
144904
145514
|
worker.noteSecrets(secrets);
|
|
145515
|
+
this.secrets = secrets;
|
|
144905
145516
|
let outcome = parseLoginResult({
|
|
144906
145517
|
status: "failed",
|
|
144907
145518
|
error: "worker gave no answer"
|
|
@@ -145138,6 +145749,13 @@ function speechFromNotification(notification) {
|
|
|
145138
145749
|
}
|
|
145139
145750
|
//#endregion
|
|
145140
145751
|
//#region src/speak/provider.ts
|
|
145752
|
+
/**
|
|
145753
|
+
* `notification-output` provider for Echo devices. Text is the rule template
|
|
145754
|
+
* as plain speech. A burst collapses or waits; it is not queued. A transport
|
|
145755
|
+
* failure is `success: false` so the notification outbox retries. A collapse,
|
|
145756
|
+
* a rate limit, or Do Not Disturb is a recorded skip (`success: true`) so the
|
|
145757
|
+
* outbox does not speak the same sentence again later.
|
|
145758
|
+
*/
|
|
145141
145759
|
function quietLogger$1() {
|
|
145142
145760
|
const logger = {
|
|
145143
145761
|
debug() {},
|
|
@@ -145193,11 +145811,8 @@ function configSchema() {
|
|
|
145193
145811
|
title: "Echo",
|
|
145194
145812
|
titleKey: "import-alexa:echoTarget.s_echo.title",
|
|
145195
145813
|
fields: [...[{
|
|
145196
|
-
|
|
145197
|
-
|
|
145198
|
-
label: "Amazon account",
|
|
145199
|
-
labelKey: "import-alexa:echoTarget.s_echo.brokerId.label",
|
|
145200
|
-
brokerKind: ALEXA_BROKER_KIND
|
|
145814
|
+
...linkFieldDescriptor("brokerId", ALEXA_ACCOUNT_LINK, { label: "Amazon account" }),
|
|
145815
|
+
labelKey: "import-alexa:echoTarget.s_echo.brokerId.label"
|
|
145201
145816
|
}, {
|
|
145202
145817
|
type: "select",
|
|
145203
145818
|
key: "echoId",
|
|
@@ -145716,6 +146331,14 @@ function startAlexaWorker(deps) {
|
|
|
145716
146331
|
let secrets = [];
|
|
145717
146332
|
let pending = null;
|
|
145718
146333
|
const queue = [];
|
|
146334
|
+
const pushListeners = [];
|
|
146335
|
+
function deliverPush(push) {
|
|
146336
|
+
for (const listener of pushListeners) try {
|
|
146337
|
+
listener(push);
|
|
146338
|
+
} catch (err) {
|
|
146339
|
+
logger.warn("alexa worker push listener failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
146340
|
+
}
|
|
146341
|
+
}
|
|
145719
146342
|
function scrub(text) {
|
|
145720
146343
|
return scrubSecrets(text, secrets);
|
|
145721
146344
|
}
|
|
@@ -145793,6 +146416,12 @@ function startAlexaWorker(deps) {
|
|
|
145793
146416
|
logger.warn("alexa worker non-JSON line", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
145794
146417
|
return;
|
|
145795
146418
|
}
|
|
146419
|
+
if (typeof parsed === "object" && parsed !== null && "push" in parsed) {
|
|
146420
|
+
const push = WorkerPushSchema.safeParse(parsed);
|
|
146421
|
+
if (push.success) deliverPush(push.data);
|
|
146422
|
+
else logger.warn("alexa worker push line failed schema", { meta: { error: push.error.message } });
|
|
146423
|
+
return;
|
|
146424
|
+
}
|
|
145796
146425
|
const checked = WorkerLineSchema.safeParse(parsed);
|
|
145797
146426
|
if (!checked.success) {
|
|
145798
146427
|
logger.warn("alexa worker line failed schema", { meta: { error: checked.error.message } });
|
|
@@ -145819,6 +146448,12 @@ function startAlexaWorker(deps) {
|
|
|
145819
146448
|
}
|
|
145820
146449
|
function onExit(code) {
|
|
145821
146450
|
child = null;
|
|
146451
|
+
deliverPush({
|
|
146452
|
+
push: "channel",
|
|
146453
|
+
state: "down",
|
|
146454
|
+
reason: "worker exited",
|
|
146455
|
+
retryInMs: 0
|
|
146456
|
+
});
|
|
145822
146457
|
if (stopped) {
|
|
145823
146458
|
failPending(/* @__PURE__ */ new Error("alexa worker stopped"));
|
|
145824
146459
|
return;
|
|
@@ -145925,6 +146560,9 @@ function startAlexaWorker(deps) {
|
|
|
145925
146560
|
}
|
|
145926
146561
|
return {
|
|
145927
146562
|
request,
|
|
146563
|
+
onPush(listener) {
|
|
146564
|
+
pushListeners.push(listener);
|
|
146565
|
+
},
|
|
145928
146566
|
noteSecrets(next) {
|
|
145929
146567
|
secrets = [...next];
|
|
145930
146568
|
},
|
|
@@ -146075,7 +146713,8 @@ var AlexaSpeakController = class {
|
|
|
146075
146713
|
readSeed: () => this.host.readSeed(ref.id),
|
|
146076
146714
|
sessions: this.host.sessions,
|
|
146077
146715
|
logger: this.host.logger,
|
|
146078
|
-
now: this.now
|
|
146716
|
+
now: this.now,
|
|
146717
|
+
...this.host.push === void 0 ? {} : { push: this.host.push }
|
|
146079
146718
|
}));
|
|
146080
146719
|
await Promise.all(refs.map(async (ref) => {
|
|
146081
146720
|
const echo = this.echoes.get(ref.id);
|
|
@@ -146489,7 +147128,8 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
|
|
|
146489
147128
|
return entry === void 0 ? null : readBrokerSeed(entry);
|
|
146490
147129
|
},
|
|
146491
147130
|
sessions: RecordsSpeakSessionStore.of(records),
|
|
146492
|
-
ensurePython: () => this.ctx.deps.ensurePython()
|
|
147131
|
+
ensurePython: () => this.ctx.deps.ensurePython(),
|
|
147132
|
+
push: alexaEchoSessions
|
|
146493
147133
|
});
|
|
146494
147134
|
this.speak = speak;
|
|
146495
147135
|
alexaEchoSessions.setResolver((brokerId) => speak.echoFor(brokerId));
|
|
@@ -146553,7 +147193,7 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
|
|
|
146553
147193
|
methods: ["device-code", "proxy"]
|
|
146554
147194
|
}
|
|
146555
147195
|
},
|
|
146556
|
-
provider:
|
|
147196
|
+
provider: guardInstanceDelete(this.accountInstances.toProvider(), () => this.brokerUsers())
|
|
146557
147197
|
}, {
|
|
146558
147198
|
kind: {
|
|
146559
147199
|
id: "integration",
|
|
@@ -146586,7 +147226,7 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
|
|
|
146586
147226
|
return;
|
|
146587
147227
|
}
|
|
146588
147228
|
const configured = entry.connection.cookie.length > 0 || entry.connection.refreshToken.length > 0;
|
|
146589
|
-
if (entry
|
|
147229
|
+
if (!isInstanceEnabled(entry) || !configured) {
|
|
146590
147230
|
await reg.removeEntry(id);
|
|
146591
147231
|
this.speak?.brokersChanged();
|
|
146592
147232
|
return;
|
|
@@ -146678,7 +147318,7 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
|
|
|
146678
147318
|
} });
|
|
146679
147319
|
}
|
|
146680
147320
|
for (const entry of persisted) {
|
|
146681
|
-
if (entry
|
|
147321
|
+
if (!isInstanceEnabled(entry)) continue;
|
|
146682
147322
|
if (entry.id && liveIds.has(entry.id)) {
|
|
146683
147323
|
if (sameConnection(entry.connection, reg.getConnection(entry.id))) continue;
|
|
146684
147324
|
try {
|
|
@@ -146853,7 +147493,7 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
|
|
|
146853
147493
|
}
|
|
146854
147494
|
/** Who still points at an Alexa account: its integrations and the Echo targets that speak through it. */
|
|
146855
147495
|
brokerUsers() {
|
|
146856
|
-
return [
|
|
147496
|
+
return [linkUserFinder(this.requireInstances(), "brokerId"), { find: async (brokerId) => readEchoTargets(this.config).filter((target) => target.brokerId === brokerId).map((target) => ({
|
|
146857
147497
|
kind: "notification-target",
|
|
146858
147498
|
id: target.id,
|
|
146859
147499
|
name: target.name
|
|
@@ -146884,7 +147524,7 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
|
|
|
146884
147524
|
},
|
|
146885
147525
|
logger: this.ctx.logger,
|
|
146886
147526
|
getBrokerIdForIntegration: async (id) => {
|
|
146887
|
-
const brokerId = await
|
|
147527
|
+
const brokerId = await linkedIdOf(this.requireInstances(), id, "brokerId");
|
|
146888
147528
|
if (brokerId === void 0) throw new Error(`integration ${id} has no broker`);
|
|
146889
147529
|
return brokerId;
|
|
146890
147530
|
},
|