@camstack/addon-import-alexa 0.2.191 → 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/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-BX_MqzUe.mjs
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
- * Build a {@link DurableState} over a single store key. Transport-agnostic:
6733
- * `read`/`write` are the addon-store or device-store accessors. The whole
6734
- * validated value round-trips on every read/write — the schema is the
6735
- * single source of truth for what is persisted, so no hand-listed field
6736
- * set can drop a key on save.
6737
- */
6738
- function createDurableState(deps) {
6739
- const get = async () => {
6740
- const raw = (await deps.read())[deps.key];
6741
- if (raw === void 0) return deps.fallback;
6742
- const parsed = deps.schema.safeParse(raw);
6743
- if (!parsed.success) {
6744
- deps.onParseError?.(deps.key, parsed.error);
6745
- return deps.fallback;
6746
- }
6747
- return parsed.data;
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
- get,
6758
- set,
6759
- update
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
- * Per-node scoping for the shared addon-settings blob.
6764
- *
6765
- * An addon's settings store is hub-central (the `addon-settings` cap is
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
- function readNodeValue(store, base, nodeId) {
6815
- return store[nodeScopedKey(base, nodeId)];
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
- * Re-map a UI/settings patch so every bare perNode field persists under the
6819
- * TARGET node's scoped key; all other keys pass through unchanged. Used on
6820
- * the write path so a save for one node never clobbers another node's value
6821
- * (and the bare key is never written). Returns a new object — the input
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
- * Project the raw shared store onto the bare schema keys for ONE node so a
6831
- * UI schema (whose field keys are bare) hydrates from that node's own
6832
- * values:
6833
- *
6834
- * - EVERY `@`-scoped key (any node's) is dropped from the pass-through.
6835
- * - Every bare perNode key is dropped from the pass-through (a stray bare
6836
- * legacy key must never hydrate any node — no bare fallback).
6837
- * - THIS node's effective value ({@link readNodeValue}) is then laid onto
6838
- * each bare perNode key; when the node has no scoped key the bare key is
6839
- * left ABSENT so the field's schema `default` wins.
6840
- *
6841
- * Returns a new object — the input store is not mutated.
6842
- */
6843
- function projectStore(store, perNodeKeys, nodeId) {
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 out;
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$6(value) {
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$6(value)) for (const child of Object.values(value)) collectSecretMarkerRefs(child, out);
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$6) : [];
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$6(row)) {
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$6(incoming)) {
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$6(stored) ? stored : {};
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$6(v)) {
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$5(value) {
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$5(item) && isRecord$5(twin)) return inArrayRow(pass, item, twin);
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$5(value) && isRecord$5(twin)) out[key] = inArrayRow(pass, value, twin);
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$5(incoming) || !isRecord$5(stored)) return incoming;
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$5(a) && isRecord$5(b)) {
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$5(value)) return null;
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
- async runBound(migrations, options) {
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, options);
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-pOHbTzRB.mjs
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: 3,
12877
+ maxArgs: 4,
12420
12878
  apply: () => {
12421
12879
  throw new ExpressionEvalError("dwell: stateful evaluation context is required");
12422
12880
  }
@@ -13576,254 +14034,6 @@ function buildCapturingReply(envelope) {
13576
14034
  };
13577
14035
  return wrapper;
13578
14036
  }
13579
- /**
13580
- * The link primitive's declaration half (D1013 rule 5): what a link field names and how a kind
13581
- * records it. Split from `instance-link.ts` so `RecordIntegrationInstances` can read the links of
13582
- * its kind without importing the builders that import it.
13583
- */
13584
- /** The body field of a link: the id of the instance it names; the target rides in the field meta. */
13585
- function linkField(target) {
13586
- return string().meta({ link: target });
13587
- }
13588
- function linkTargetOf(meta) {
13589
- if (typeof meta !== "object" || meta === null) return void 0;
13590
- const link = Reflect.get(meta, "link");
13591
- const parsed = LinkTargetSchema.safeParse(link);
13592
- return parsed.success ? parsed.data : void 0;
13593
- }
13594
- var LinkTargetSchema = object({
13595
- cap: string().min(1),
13596
- kind: string().min(1).optional()
13597
- });
13598
- /** The links a kind declares, read from the meta of its body fields. */
13599
- function linksOfKind(kind) {
13600
- const schema = kind.schema;
13601
- if (!(schema instanceof ZodObject)) return [];
13602
- const links = [];
13603
- for (const [field, fieldSchema] of Object.entries(schema.shape)) {
13604
- const to = linkTargetOf(globalRegistry.get(fieldSchema));
13605
- if (to !== void 0) links.push({
13606
- field,
13607
- to
13608
- });
13609
- }
13610
- return links;
13611
- }
13612
- /**
13613
- * `RecordIntegrationInstances` - the `integration-instances` provider over ONE
13614
- * entry kind, so an addon that holds a connection serves it in ~30 lines.
13615
- *
13616
- * Every instance is one record of the addon's own kind, keyed by the
13617
- * `integrations` registry id. The records door does the secret work: a field the
13618
- * kind marks `.meta({ secret: true })` is a keychain marker bound to the row, so
13619
- * deleting the row deletes the secret, and one row's value is never another's.
13620
- *
13621
- * Patch rules for a secret (`updateInstance`): omitted, `''`, the redaction
13622
- * token or the view the form was given KEEP the stored secret;
13623
- * `{ $secretClear: true }` clears it; any other string replaces it.
13624
- */
13625
- /** The most instances one addon lists (an integrations page, not a table). */
13626
- var INSTANCES_LIST_MAX = 1e3;
13627
- /** The keys of the kind's secret fields that sit at the top level of the body. */
13628
- function topLevelSecretKeys(paths) {
13629
- return new Set(paths.filter((p) => !p.includes(".") && !p.includes("[") && !p.includes("{")));
13630
- }
13631
- /** The keys of the kind's top-level MAPS whose values are secrets (`applianceSecrets{}`). */
13632
- function secretMapKeys(paths) {
13633
- return new Set(paths.filter((p) => p.endsWith("{}") && !p.slice(0, -2).match(/[.[{]/)).map((p) => p.slice(0, -2)));
13634
- }
13635
- /**
13636
- * A patch value for a map of secrets: an entry that is omitted, `''`, the redaction token or the
13637
- * view the form was given KEEPS its stored secret (it is left out of the merge); `{ $secretClear }`
13638
- * or `null` removes the entry; any other string replaces it. Null when nothing is left to merge.
13639
- */
13640
- function secretMapPatch(value) {
13641
- if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
13642
- const out = {};
13643
- for (const [key, entry] of Object.entries(value)) if (entry === null || isSecretClear(entry)) out[key] = null;
13644
- else if (typeof entry === "string" && entry !== "" && entry !== "__camstack_redacted__" && !isSecretFieldView(entry)) out[key] = entry;
13645
- return Object.keys(out).length > 0 ? out : null;
13646
- }
13647
- /** Does the kind's body schema carry an `enabled` field? */
13648
- function declaresEnabled(kind) {
13649
- const json = toJSONSchema(kind.schema, {
13650
- unrepresentable: "any",
13651
- io: "input"
13652
- });
13653
- const properties = Reflect.get(json, "properties");
13654
- return typeof properties === "object" && properties !== null && "enabled" in properties;
13655
- }
13656
- var RecordIntegrationInstances = class {
13657
- records;
13658
- kind;
13659
- hooks;
13660
- handle;
13661
- secretKeys;
13662
- secretMaps;
13663
- nameField;
13664
- /** The `integrations` registry owns the on/off switch (`role: devices`); the body has none. */
13665
- registryOwnsEnabled;
13666
- constructor(records, kind, hooks) {
13667
- this.records = records;
13668
- this.kind = kind;
13669
- this.hooks = hooks;
13670
- this.handle = records.kind(kind);
13671
- this.secretKeys = topLevelSecretKeys(kind.declaration.secretPaths);
13672
- this.secretMaps = secretMapKeys(kind.declaration.secretPaths);
13673
- this.nameField = kind.declaration.entry?.nameField ?? "name";
13674
- this.registryOwnsEnabled = hooks.description.role === "devices";
13675
- 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"`);
13676
- }
13677
- /**
13678
- * One instance read for the addon's own runtime; null when absent. When the keychain is
13679
- * locked the row is served WITHOUT its secrets and says so (`secrets: 'unavailable'`), so the
13680
- * caller can keep what it had instead of reconfiguring with an empty secret (D49).
13681
- */
13682
- async readResolved(id) {
13683
- try {
13684
- const record = await this.handle.getResolved(id);
13685
- return record === null ? null : {
13686
- id,
13687
- body: record.data,
13688
- enabled: record.data.enabled !== false,
13689
- secrets: "resolved"
13690
- };
13691
- } catch (err) {
13692
- if (!(err instanceof RecordSecretUnavailableError)) throw err;
13693
- const stored = await this.handle.get(id);
13694
- if (stored === null) return null;
13695
- return {
13696
- id,
13697
- body: Object.fromEntries(Object.entries(stored.data).filter(([key]) => !this.secretKeys.has(key))),
13698
- enabled: stored.data.enabled !== false,
13699
- secrets: "unavailable"
13700
- };
13701
- }
13702
- }
13703
- /** The body of {@link readResolved} alone; a locked keychain yields the row without secrets. */
13704
- async getResolved(id) {
13705
- return (await this.readResolved(id))?.body ?? null;
13706
- }
13707
- /** Every instance, secrets resolved where the keychain allows; an absent one is skipped. */
13708
- async listResolved() {
13709
- const page = await this.handle.query({ limit: INSTANCES_LIST_MAX });
13710
- const out = [];
13711
- for (const record of page.records) {
13712
- const read = await this.readResolved(record.id);
13713
- if (read !== null) out.push(read);
13714
- }
13715
- return out;
13716
- }
13717
- viewOf(record) {
13718
- const shown = secretViewsOf(this.records.owner, this.kind.name, record.id, this.kind.declaration, record.data);
13719
- const rawName = record.data[this.nameField];
13720
- const kind = this.hooks.kindOf?.(record.data);
13721
- const health = this.hooks.health?.(record.data, record.id);
13722
- return {
13723
- id: record.id,
13724
- name: typeof rawName === "string" ? rawName : record.id,
13725
- configured: this.hooks.configured(record.data),
13726
- ...this.registryOwnsEnabled ? {} : { enabled: record.data.enabled !== false },
13727
- formValues: shown,
13728
- ...kind !== void 0 ? { kind } : {},
13729
- ...health?.status !== void 0 ? { status: health.status } : {},
13730
- ...health?.lastCheckedAt !== void 0 ? { lastCheckedAt: health.lastCheckedAt } : {}
13731
- };
13732
- }
13733
- describe = async () => {
13734
- const declared = linksOfKind(this.kind);
13735
- const links = this.hooks.description.links !== void 0 || declared.length === 0 ? {} : { links: declared.map((link) => ({
13736
- kind: this.kind.name,
13737
- field: link.field,
13738
- to: link.to
13739
- })) };
13740
- return {
13741
- ...this.hooks.description,
13742
- ...links,
13743
- ...this.hooks.mintId === void 0 ? {} : { mintsId: true }
13744
- };
13745
- };
13746
- instanceFormSchema = async ({ id }) => this.hooks.formSchema(id);
13747
- listInstances = async () => {
13748
- return (await this.handle.query({ limit: INSTANCES_LIST_MAX })).records.map((record) => this.viewOf(record));
13749
- };
13750
- getInstance = async ({ id }) => {
13751
- const record = await this.handle.get(id);
13752
- return record === null ? null : this.viewOf(record);
13753
- };
13754
- /** Insert ONE row (`ifRev: 0`: an existing id is refused, never overwritten). */
13755
- createInstance = async ({ id, name, kind, values }) => {
13756
- const minted = id ?? this.hooks.mintId?.({
13757
- name,
13758
- ...kind !== void 0 ? { kind } : {}
13759
- });
13760
- if (minted === void 0) throw new Error("createInstance needs an id: this provider does not mint one");
13761
- await this.handle.put({
13762
- id: minted,
13763
- data: this.hooks.initialBody(name, kind)
13764
- }, { ifRev: 0 });
13765
- const patch = this.patchOf(values ?? {});
13766
- if (Object.keys(patch).length > 0) await this.handle.patch(minted, patch);
13767
- await this.hooks.onChanged?.(minted, "created");
13768
- return { id: minted };
13769
- };
13770
- updateInstance = async ({ id, name, values }) => {
13771
- const patch = this.patchOf(values);
13772
- if (name !== void 0) patch[this.nameField] = name;
13773
- await this.handle.patch(id, patch);
13774
- await this.hooks.onChanged?.(id, "updated");
13775
- return { ok: true };
13776
- };
13777
- /** The record patch a form `values` object means: secrets kept, cleared or replaced. */
13778
- patchOf(values) {
13779
- const patch = {};
13780
- for (const [key, value] of Object.entries(values)) {
13781
- if (value === void 0) continue;
13782
- if (this.secretMaps.has(key)) {
13783
- const entries = secretMapPatch(value);
13784
- if (entries !== null) patch[key] = entries;
13785
- } else if (!this.secretKeys.has(key)) patch[key] = value;
13786
- else if (isSecretClear(value)) patch[key] = null;
13787
- else if (typeof value === "string" && value !== "" && value !== "__camstack_redacted__" && !isSecretFieldView(value)) patch[key] = value;
13788
- }
13789
- return patch;
13790
- }
13791
- /** The switch lives in the record body; the addon is told so it applies it (D996). */
13792
- setInstanceEnabled = async ({ id, enabled }) => {
13793
- if (this.registryOwnsEnabled) throw new Error(`instance "${id}": the integrations registry owns the on/off switch of this addon; set it with integrations.update`);
13794
- await this.handle.patch(id, { enabled });
13795
- await this.hooks.onChanged?.(id, "enabled");
13796
- return { ok: true };
13797
- };
13798
- /** The row and its keychain refs go together (the records door). */
13799
- deleteInstance = async ({ id }) => {
13800
- await this.handle.delete(id);
13801
- await this.hooks.onChanged?.(id, "deleted");
13802
- return { ok: true };
13803
- };
13804
- testInstance = async (input) => {
13805
- if (this.hooks.test === void 0) return {
13806
- success: false,
13807
- status: "unsupported",
13808
- error: "This integration declares no connection test"
13809
- };
13810
- return this.hooks.test(input);
13811
- };
13812
- /** The flat provider object to hand to `registerProvider`. */
13813
- toProvider() {
13814
- return {
13815
- describe: this.describe,
13816
- instanceFormSchema: this.instanceFormSchema,
13817
- listInstances: this.listInstances,
13818
- getInstance: this.getInstance,
13819
- createInstance: this.createInstance,
13820
- updateInstance: this.updateInstance,
13821
- setInstanceEnabled: this.setInstanceEnabled,
13822
- deleteInstance: this.deleteInstance,
13823
- testInstance: this.testInstance
13824
- };
13825
- }
13826
- };
13827
14037
  /** No part holds the id a call named. */
13828
14038
  var UnknownInstanceError = class extends Error {
13829
14039
  instanceId;
@@ -20190,31 +20400,21 @@ var APPLIANCE_CAP_VOCABULARY = {
20190
20400
  "pet-fountain": {
20191
20401
  scope: "device",
20192
20402
  noun: t("Pet fountain", "Fontana per animali"),
20193
- actions: [
20194
- {
20195
- id: "petFountain.pumpOn",
20196
- method: "setPump",
20197
- label: t("Pump on", "Pompa accesa"),
20198
- phrase: t("Start the pump of {device}", "Avvia la pompa di {device}"),
20199
- icon: "play",
20200
- args: { on: true }
20201
- },
20202
- {
20203
- id: "petFountain.pumpOff",
20204
- method: "setPump",
20205
- label: t("Pump off", "Pompa spenta"),
20206
- phrase: t("Stop the pump of {device}", "Ferma la pompa di {device}"),
20207
- icon: "square",
20208
- args: { on: false }
20209
- },
20210
- {
20211
- id: "petFountain.resetDailyConsumption",
20212
- method: "resetDailyConsumption",
20213
- label: t("Clear daily consumption", "Azzera il consumo giornaliero"),
20214
- phrase: t("Clear the daily water consumption of {device}", "Azzera il consumo giornaliero di acqua di {device}"),
20215
- icon: "rotate-ccw"
20216
- }
20217
- ],
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
+ }],
20218
20418
  state: [{
20219
20419
  kind: "boolean",
20220
20420
  field: "empty",
@@ -44168,8 +44368,15 @@ var ActionInputSchema = object({
44168
44368
  var ActionRunOriginSchema = object({
44169
44369
  /** The automation block whose step started it. */
44170
44370
  blockId: string().min(1).optional() });
44171
- /** `started` = the caller did not ask to wait. */
44172
- var ActionRunOutcomeSchema = union([AutomationRunOutcomeSchema, literal("started")]);
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
+ ]);
44173
44380
  var ActionRunResultSchema = object({
44174
44381
  runId: string().min(1),
44175
44382
  outcome: ActionRunOutcomeSchema,
@@ -44713,6 +44920,8 @@ method(object({}), array(ActionEntrySchema), { auth: "admin" }), method(object({
44713
44920
  kind: "mutation",
44714
44921
  auth: "admin"
44715
44922
  });
44923
+ /** `timeoutMs` of `actions.run`: a waited call outlives any legal run. */
44924
+ var ACTION_RUN_TIMEOUT_MS = 363e4;
44716
44925
  /**
44717
44926
  * actions — the ONE surface for runnable actions, for the UI and for
44718
44927
  * automations. Provided by the core-blocks builtin on the hub.
@@ -44731,6 +44940,12 @@ method(object({}), array(ActionEntrySchema), { auth: "admin" }), method(object({
44731
44940
  * An admin, and an in-process caller (an automation block), may run any action.
44732
44941
  * Every refusal is typed (`[not-linked] …`) and logged.
44733
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
+ *
44734
44949
  * A second `run` of an action that is already running is REFUSED — traced and
44735
44950
  * logged — never queued.
44736
44951
  */
@@ -44757,7 +44972,8 @@ method(object({}), array(ActionEntrySchema), { auth: "admin" }), method(object({
44757
44972
  role: "viewer",
44758
44973
  auth: "protected",
44759
44974
  kind: "mutation",
44760
- caller: "required"
44975
+ caller: "required",
44976
+ timeoutMs: ACTION_RUN_TIMEOUT_MS
44761
44977
  }), method(ActionIdInput, _void(), {
44762
44978
  kind: "mutation",
44763
44979
  auth: "admin"
@@ -49848,6 +50064,9 @@ var MediaPlayerStatusSchema = object({
49848
50064
  /** Repeat mode. Null when no repeat surface.
49849
50065
  * Pair with `DeviceFeature.MediaPlayerRepeat`. */
49850
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),
49851
50070
  /** Ms epoch when the slice was last updated. */
49852
50071
  lastChangedAt: number$1()
49853
50072
  });
@@ -50483,7 +50702,9 @@ var ThresholdTransformDraftSchema = object({
50483
50702
  comparison: _enum(["gte", "lte"]),
50484
50703
  threshold: number$1(),
50485
50704
  startDwellMs: number$1().nonnegative(),
50486
- 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()
50487
50708
  });
50488
50709
  var CustomizeTransformDraftSchema = discriminatedUnion("kind", [
50489
50710
  IdentityTransformDraftSchema,
@@ -56360,6 +56581,7 @@ var IntegrationWithStateSchema = object({
56360
56581
  id: string(),
56361
56582
  addonId: string(),
56362
56583
  name: string(),
56584
+ /** A projection of the instance body's switch (`setInstanceEnabled` is the only writer). */
56363
56585
  enabled: boolean(),
56364
56586
  info: record(string(), unknown()).readonly(),
56365
56587
  /** The discovery candidate this integration was set up from (D984). */
@@ -56372,6 +56594,7 @@ var IntegrationLiteSchema = object({
56372
56594
  id: string(),
56373
56595
  addonId: string(),
56374
56596
  name: string(),
56597
+ /** A projection of the instance body's switch (`setInstanceEnabled` is the only writer). */
56375
56598
  enabled: boolean(),
56376
56599
  info: record(string(), unknown()).readonly(),
56377
56600
  /** The discovery candidate this integration was set up from (D984). */
@@ -56471,7 +56694,6 @@ var TestConnectionResultSchema = object({
56471
56694
  var CreateIntegrationInputSchema = object({
56472
56695
  addonId: string(),
56473
56696
  name: string(),
56474
- enabled: boolean().optional(),
56475
56697
  info: record(string(), unknown()).optional(),
56476
56698
  /** `stableId` of the discovery candidate the form was seeded from (D984). */
56477
56699
  originStableId: string().optional(),
@@ -56480,6 +56702,11 @@ var CreateIntegrationInputSchema = object({
56480
56702
  var UpdateIntegrationInputSchema = object({
56481
56703
  id: string(),
56482
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
+ */
56483
56710
  enabled: boolean().optional(),
56484
56711
  info: record(string(), unknown()).optional()
56485
56712
  });
@@ -56527,7 +56754,7 @@ var IntegrationInstanceIdSchema = string().min(1);
56527
56754
  var IntegrationInstanceModeSchema = _enum(["single", "multiple"]);
56528
56755
  /**
56529
56756
  * What the addon's instances ARE for the Integrations page (planning#416). `devices`: each
56530
- * instance is an integration that brings devices (the registry owns its on/off switch);
56757
+ * instance is an integration that brings devices (its record body owns the on/off switch);
56531
56758
  * `services`: a connection configured from the addon's own function page, never listed on
56532
56759
  * Integrations.
56533
56760
  * It is the same vocabulary as the manifest's `integrationRole`, which stays as an override.
@@ -56644,8 +56871,8 @@ var IntegrationInstanceSchema = object({
56644
56871
  configured: boolean(),
56645
56872
  /**
56646
56873
  * The instance's own on/off switch (D996), derived from the record body (`enabled !== false`,
56647
- * so a row written before the field existed is on). Absent when the `integrations` registry
56648
- * owns the switch (`describe().role` is `devices`): read it from the registry row.
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).
56649
56876
  */
56650
56877
  enabled: boolean().optional(),
56651
56878
  /** The kind of this instance (one of `describe().kinds[].id`), when the provider has kinds. */
@@ -56695,7 +56922,8 @@ var integrationInstancesCapability = {
56695
56922
  auth: "admin"
56696
56923
  }),
56697
56924
  /**
56698
- * Switch ONE instance on or off. A row control, not a form field: `updateInstance.values` is the
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
56699
56927
  * form patch (D62, D996). An off instance keeps its record and secrets and does no work.
56700
56928
  */
56701
56929
  setInstanceEnabled: method(object({
@@ -59758,6 +59986,8 @@ var MigrationIntegrationSchema = object({
59758
59986
  addonId: string(),
59759
59987
  name: string(),
59760
59988
  info: Rows,
59989
+ /** The registry's stored switch: read by the one-switch migration, nothing else. */
59990
+ enabled: boolean().optional(),
59761
59991
  originStableId: string().optional()
59762
59992
  });
59763
59993
  var MigrationDeviceSchema = object({
@@ -69887,14 +70117,6 @@ var PetFountainStatusSchema = object({
69887
70117
  empty: boolean().nullable(),
69888
70118
  /** True while the pump is running. Null while unknown. */
69889
70119
  pumpRunning: boolean().nullable(),
69890
- /** Water drunk in the last drink, in litres. */
69891
- lastDrink: number$1().min(0).nullable().meta(readingMeta("volume")),
69892
- /** Water drunk in the current hour, in litres. */
69893
- currentHourDrink: number$1().min(0).nullable().meta(readingMeta("volume")),
69894
- /** Water drunk over the last 24 hours (rolling), in litres. */
69895
- last24hDrink: number$1().min(0).nullable().meta(readingMeta("volume")),
69896
- /** Water drunk since local midnight, in litres. Null when the fountain has no such sensor. */
69897
- todayDrink: number$1().min(0).nullable().meta(readingMeta("volume")),
69898
70120
  /** Decoded fault message. Null means healthy; a non-null string is an active fault. */
69899
70121
  error: string().nullable(),
69900
70122
  /** Ms epoch when the slice was last refreshed. */
@@ -69907,21 +70129,15 @@ var petFountainCapability = {
69907
70129
  deviceNative: true,
69908
70130
  mode: "singleton",
69909
70131
  deviceTypes: [DeviceType.PetFountain],
69910
- methods: {
69911
- /** Start or stop the pump. */
69912
- setPump: method(object({
69913
- deviceId: DeviceIdSchema$1,
69914
- on: boolean()
69915
- }), _void(), {
69916
- kind: "mutation",
69917
- auth: "protected"
69918
- }),
69919
- /** Clear the rolling daily consumption counters on the device. */
69920
- resetDailyConsumption: method(object({ deviceId: DeviceIdSchema$1 }), _void(), {
69921
- kind: "mutation",
69922
- auth: "protected"
69923
- })
69924
- },
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
+ }) },
69925
70141
  status: {
69926
70142
  schema: PetFountainStatusSchema,
69927
70143
  kind: "push"
@@ -69945,14 +70161,6 @@ var petFountainCapability = {
69945
70161
  waterVolume: {
69946
70162
  kind: "gauge",
69947
70163
  unit: "L"
69948
- },
69949
- todayDrink: {
69950
- kind: "gauge",
69951
- unit: "L"
69952
- },
69953
- last24hDrink: {
69954
- kind: "gauge",
69955
- unit: "L"
69956
70164
  }
69957
70165
  } }
69958
70166
  };
@@ -88401,13 +88609,6 @@ Object.freeze({
88401
88609
  access: "view",
88402
88610
  role: "viewer"
88403
88611
  },
88404
- "petFountain.resetDailyConsumption": {
88405
- capName: "pet-fountain",
88406
- capScope: "device",
88407
- addonId: null,
88408
- access: "delete",
88409
- role: "viewer"
88410
- },
88411
88612
  "petFountain.setPump": {
88412
88613
  capName: "pet-fountain",
88413
88614
  capScope: "device",
@@ -94804,11 +95005,6 @@ Object.freeze({
94804
95005
  form: "single",
94805
95006
  optional: false
94806
95007
  }],
94807
- "petFountain.resetDailyConsumption": [{
94808
- name: "deviceId",
94809
- form: "single",
94810
- optional: false
94811
- }],
94812
95008
  "petFountain.setPump": [{
94813
95009
  name: "deviceId",
94814
95010
  form: "single",
@@ -98526,6 +98722,20 @@ async function assertInstanceUnused(instanceId, finders) {
98526
98722
  for (const finder of finders) users.push(...await finder.find(instanceId));
98527
98723
  if (users.length > 0) throw new InstanceInUseError(instanceId, users);
98528
98724
  }
98725
+ /**
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.
98729
+ */
98730
+ function guardInstanceDelete(base, users) {
98731
+ return {
98732
+ ...base,
98733
+ deleteInstance: async (input) => {
98734
+ await assertInstanceUnused(input.id, users());
98735
+ return base.deleteInstance(input);
98736
+ }
98737
+ };
98738
+ }
98529
98739
  /** The `instance-select` field of a link, keyed like the body field it fills. */
98530
98740
  function linkFieldDescriptor(key, target, labels) {
98531
98741
  return {
@@ -98539,108 +98749,71 @@ function linkFieldDescriptor(key, target, labels) {
98539
98749
  ...target.kind !== void 0 ? { kind: target.kind } : {}
98540
98750
  };
98541
98751
  }
98542
- /** @deprecated use {@link InstanceInUseError}; this one only adds the old `brokerId` name. */
98543
- var BrokerInUseError = class extends InstanceInUseError {
98544
- get brokerId() {
98545
- return this.instanceId;
98546
- }
98547
- };
98548
- /** @deprecated use {@link assertInstanceUnused}. */
98549
- async function assertBrokerUnused(brokerId, finders) {
98550
- try {
98551
- await assertInstanceUnused(brokerId, finders);
98552
- } catch (err) {
98553
- if (err instanceof InstanceInUseError) throw new BrokerInUseError(err.instanceId, err.users);
98554
- throw err;
98555
- }
98556
- }
98557
- /** @deprecated use `guardInstanceDelete`. */
98558
- function guardBrokerDelete(base, users) {
98559
- return {
98560
- ...base,
98561
- deleteInstance: async (input) => {
98562
- await assertBrokerUnused(input.id, users());
98563
- return base.deleteInstance(input);
98564
- }
98565
- };
98566
- }
98567
- /**
98568
- * The generic binding of a service to a broker (D1012). A service (an integration today; a
98569
- * notifier target, a calendar source or an exporter later) names the broker it runs through by id
98570
- * and keeps only its OWN link settings: never a copy of the broker's host, sign-in or secrets,
98571
- * which stay with the broker and are edited on the Brokers page.
98572
- *
98573
- * One primitive serves every consumer:
98574
- * - {@link declareBrokerLinkKind}: the record kind, body `{ name, brokerId, ...linkFields }`;
98575
- * - {@link buildBrokerLinkForm}: the form, a `broker-select` picker plus the service's link
98576
- * sections, which may be DYNAMIC (a list read from the chosen broker, like HA calendars);
98577
- * - {@link buildBrokerLinkInstances}: the `integration-instances` provider of an integration;
98578
- * - {@link resolveBrokerId} / {@link brokerLinkUserFinder}: which broker a record names and which
98579
- * records name a broker, so a delete of a broker still in use can name its users generically.
98580
- */
98581
- /** The record kind an integration is stored under. */
98582
- var BROKER_LINK_KIND_NAME = "connections";
98583
- /** What every broker-linked record carries; `brokerId` is always present, `''` is a row whose broker is not picked yet; link fields of the service come on top. */
98584
- var BrokerLinkBodySchema = entryBody({ brokerId: string() });
98585
- function declareBrokerLinkKind(spec) {
98586
- return defineEntryKind(BROKER_LINK_KIND_NAME, {
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, {
98587
98760
  title: spec.title,
98588
- schema: BrokerLinkBodySchema.extend(spec.linkFields ?? {}),
98761
+ schema: typed,
98589
98762
  writeTrigger: spec.writeTrigger
98590
98763
  });
98591
98764
  }
98592
- /** The form: a picker over the brokers of `brokerKind`, then the service's link sections. */
98593
- async function buildBrokerLinkForm(spec, brokerId) {
98594
- const dynamic = brokerId !== void 0 && spec.dynamicSections !== void 0 ? await spec.dynamicSections(brokerId) : [];
98595
- return { sections: [
98596
- {
98597
- id: "broker-link",
98598
- title: spec.brokerLabel,
98599
- description: `Each integration links to a ${spec.brokerLabel} connection (Connectivity → Brokers). The connection and its sign-in are edited there.`,
98600
- columns: 1,
98601
- fields: [{
98602
- type: "broker-select",
98603
- key: "brokerId",
98604
- label: spec.brokerLabel,
98605
- brokerKind: spec.brokerKind
98606
- }]
98607
- },
98608
- ...spec.linkSections ?? [],
98609
- ...dynamic
98610
- ] };
98611
- }
98612
- /** The broker a record names; undefined while it names none. */
98613
- function resolveBrokerId(record) {
98614
- 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;
98615
98769
  }
98616
- /** The broker an integration links to, from its record. */
98617
- async function brokerIdOf(instances, integrationId) {
98770
+ /** The instance an integration's record links to through `field`; undefined while it names none. */
98771
+ async function linkedIdOf(instances, integrationId, field) {
98618
98772
  const body = await instances.getResolved(integrationId);
98619
- return body === null ? void 0 : resolveBrokerId(body);
98773
+ return body === null ? void 0 : linkedId(body, field);
98620
98774
  }
98621
- /** Every record of the kind that names `brokerId`, as users of that broker. */
98622
- function brokerLinkUserFinder(instances, userKind = "integration") {
98623
- return { find: async (brokerId) => (await instances.listResolved()).filter((entry) => resolveBrokerId(entry.body) === brokerId).map((entry) => ({
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) => ({
98624
98778
  kind: userKind,
98625
98779
  id: entry.id,
98626
- name: entry.body.name
98780
+ name: typeof entry.body["name"] === "string" ? entry.body["name"] : entry.id
98627
98781
  })) };
98628
98782
  }
98629
- /** The `integration-instances` provider of an addon whose integrations link to a broker. */
98630
- function buildBrokerLinkInstances(records, spec) {
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;
98631
98787
  const instances = new RecordIntegrationInstances(records, spec.kind, {
98632
98788
  description: {
98633
- role: "devices",
98634
- instanceMode: "multiple",
98635
- kind: "connection",
98636
- canTest: false
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
+ ] };
98637
98810
  },
98638
- formSchema: async (id) => buildBrokerLinkForm(spec, id === null ? void 0 : await brokerIdOf(instances, id)),
98639
98811
  initialBody: (name) => spec.kind.schema.parse({
98640
98812
  name,
98641
- brokerId: ""
98813
+ ...Object.fromEntries(links.map((link) => [link.field, ""]))
98642
98814
  }),
98643
- configured: (body) => resolveBrokerId(body) !== void 0,
98815
+ configured: (body) => links.every((link) => linkedId(body, link.field) !== void 0) && (spec.alsoConfigured?.(body) ?? true),
98816
+ ...spec.mintId !== void 0 ? { mintId: spec.mintId } : {},
98644
98817
  ...spec.onChanged !== void 0 ? { onChanged: spec.onChanged } : {}
98645
98818
  });
98646
98819
  return instances;
@@ -98722,8 +98895,15 @@ var ALEXA_ACCOUNT_LINK = {
98722
98895
  var DEVICE_REFRESH_MS = 5 * 6e4;
98723
98896
  /** Where the addon serves the Alexa mark; `listTargetKinds` stamps it as `iconUrl`. */
98724
98897
  var ALEXA_SPEAK_ICON_URL = `/addon/${ALEXA_SPEAK_ADDON_ID}/icons/${ALEXA_SPEAK_KIND}`;
98725
- /** How often an adopted Echo's player state is read (a push channel is a later phase). */
98898
+ /** How often an adopted Echo's player state is read while the push channel is down. */
98726
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;
98727
98907
  /** One more read after a command, once Amazon has had time to apply it. */
98728
98908
  var ECHO_REREAD_MS = 1500;
98729
98909
  /** After a 429 the account is left alone for this long, doubling up to the cap. */
@@ -143052,7 +143232,7 @@ var AlexaBrokerRegistry = class {
143052
143232
  async restore(entries) {
143053
143233
  for (const entry of entries) this.#seedCounter(entry.id);
143054
143234
  for (const entry of entries) {
143055
- if (entry.enabled === false) continue;
143235
+ if (!isInstanceEnabled(entry)) continue;
143056
143236
  try {
143057
143237
  await this.#startManager(entry);
143058
143238
  } catch (err) {
@@ -144018,6 +144198,9 @@ function isKnownCap(cap) {
144018
144198
  return KNOWN_CAPS.has(cap);
144019
144199
  }
144020
144200
  var EchoSessionRegistry = class {
144201
+ #channels = /* @__PURE__ */ new Map();
144202
+ #pushListeners = /* @__PURE__ */ new Map();
144203
+ #channelListeners = /* @__PURE__ */ new Map();
144021
144204
  #resolve = null;
144022
144205
  #dnd = /* @__PURE__ */ new Map();
144023
144206
  #volumes = /* @__PURE__ */ new Map();
@@ -144059,12 +144242,54 @@ var EchoSessionRegistry = class {
144059
144242
  });
144060
144243
  return pending;
144061
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
+ }
144062
144275
  clear() {
144276
+ this.#channels.clear();
144277
+ this.#pushListeners.clear();
144278
+ this.#channelListeners.clear();
144063
144279
  this.#resolve = null;
144064
144280
  this.#dnd.clear();
144065
144281
  this.#volumes.clear();
144066
144282
  }
144067
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
+ }
144068
144293
  /** The single in-process registry shared across the addon. */
144069
144294
  var alexaEchoSessions = new EchoSessionRegistry();
144070
144295
  //#endregion
@@ -144084,6 +144309,7 @@ function emptyEchoStatus(state, now) {
144084
144309
  duration: null,
144085
144310
  shuffle: null,
144086
144311
  repeat: null,
144312
+ positionUpdatedAt: null,
144087
144313
  lastChangedAt: now
144088
144314
  };
144089
144315
  }
@@ -144117,12 +144343,24 @@ function repeatOf(value) {
144117
144343
  if (value === "UNSELECTED") return "off";
144118
144344
  return null;
144119
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
+ */
144120
144356
  function currentMediaOf(info) {
144121
- const title = nonEmpty(info.infoText?.title);
144122
- if (title === null) return null;
144123
- const artist = nonEmpty(info.infoText?.subText1);
144124
- const album = nonEmpty(info.infoText?.subText2);
144357
+ const first = nonEmpty(info.infoText?.title);
144358
+ const second = nonEmpty(info.infoText?.subText1);
144125
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;
144126
144364
  return {
144127
144365
  type: "unknown",
144128
144366
  title,
@@ -144134,12 +144372,15 @@ function currentMediaOf(info) {
144134
144372
  function mapPlayerInfoToStatus(info, input) {
144135
144373
  if (!input.online) return emptyEchoStatus("off", input.now);
144136
144374
  const length = secondsToMs(info.progress?.mediaLength);
144375
+ const position = secondsToMs(info.progress?.mediaProgress);
144137
144376
  return {
144138
144377
  ...emptyEchoStatus(mapEchoPlaybackState(info.state), input.now),
144139
144378
  volumeLevel: percent(info.volume?.volume),
144140
144379
  isMuted: typeof info.volume?.muted === "boolean" ? info.volume.muted : null,
144380
+ source: sourceOf(info),
144141
144381
  currentMedia: currentMediaOf(info),
144142
- position: secondsToMs(info.progress?.mediaProgress),
144382
+ position,
144383
+ positionUpdatedAt: position === null ? null : input.now,
144143
144384
  duration: length === 0 ? null : length,
144144
144385
  shuffle: shuffleOf(info.transport?.shuffle),
144145
144386
  repeat: repeatOf(info.transport?.repeat)
@@ -144193,6 +144434,35 @@ var WorkerLineSchema = union([object({
144193
144434
  /** A typed reason the worker names; `rate-limited` is Amazon's 429. */
144194
144435
  code: string().optional()
144195
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
+ ]);
144196
144466
  var RATE_LIMITED_CODE = "rate-limited";
144197
144467
  /** Amazon answered 429. Never an empty answer: the caller backs off and keeps what it had. */
144198
144468
  var RateLimitedError = class extends Error {
@@ -144235,35 +144505,75 @@ function parseSessionStatus(result) {
144235
144505
  return parsed.data;
144236
144506
  }
144237
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
+ /**
144238
144516
  * The raw `playerInfo` of `/api/np/player`. Amazon omits what it does not know,
144239
144517
  * so every field is nullable; the mapping to the media-player cap turns a missing
144240
- * 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.
144241
144520
  */
144242
144521
  var PlayerInfoSchema = object({
144243
- state: string().nullish(),
144522
+ state: text,
144244
144523
  volume: object({
144245
- volume: number$1().nullish(),
144246
- muted: boolean().nullish()
144247
- }).nullish(),
144524
+ volume: count,
144525
+ muted: flag
144526
+ }).nullish().catch(null),
144248
144527
  infoText: object({
144249
- title: string().nullish(),
144250
- subText1: string().nullish(),
144251
- subText2: string().nullish()
144252
- }).nullish(),
144253
- mainArt: object({ url: string().nullish() }).nullish(),
144528
+ title: text,
144529
+ subText1: text,
144530
+ subText2: text
144531
+ }).nullish().catch(null),
144532
+ mainArt: object({ url: text }).nullish().catch(null),
144254
144533
  progress: object({
144255
- mediaLength: number$1().nullish(),
144256
- mediaProgress: number$1().nullish()
144257
- }).nullish(),
144534
+ mediaLength: count,
144535
+ mediaProgress: count
144536
+ }).nullish().catch(null),
144537
+ provider: object({
144538
+ providerName: text,
144539
+ providerDisplayName: text
144540
+ }).nullish().catch(null),
144258
144541
  transport: object({
144259
- shuffle: string().nullish(),
144260
- repeat: string().nullish()
144261
- }).nullish()
144262
- });
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
+ */
144263
144559
  function parsePlayerState(result) {
144264
144560
  const parsed = object({ playerInfo: PlayerInfoSchema.nullish() }).safeParse(result);
144265
144561
  if (!parsed.success) throw new Error("worker returned an unexpected player state");
144266
- return parsed.data.playerInfo ?? {};
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;
144267
144577
  }
144268
144578
  var DndEntrySchema = object({
144269
144579
  serial: string().min(1),
@@ -144314,21 +144624,32 @@ var AlexaEchoDevice = class extends BaseDevice {
144314
144624
  features = [
144315
144625
  DeviceFeature.MediaPlayerVolume,
144316
144626
  DeviceFeature.MediaPlayerNext,
144317
- DeviceFeature.MediaPlayerPrevious
144627
+ DeviceFeature.MediaPlayerPrevious,
144628
+ DeviceFeature.MediaPlayerShuffle,
144629
+ DeviceFeature.MediaPlayerRepeat
144318
144630
  ];
144319
144631
  brokerId;
144320
144632
  serial;
144321
144633
  amazonDeviceType;
144322
144634
  timer = null;
144323
144635
  rereadTimer = null;
144636
+ pushReadTimer = null;
144637
+ lastPushReadAt = 0;
144638
+ repollAfter = false;
144639
+ unsubscribers = [];
144324
144640
  polling = false;
144325
144641
  failureLogged = false;
144326
144642
  volumeFailureLogged = false;
144327
144643
  health = "ok";
144328
- /** The last level this device asked Amazon for, and when; a step starts from it until a read is newer. */
144329
- lastSet = null;
144330
- /** When the read that produced the current media-player slice began. */
144331
- polledFrom = 0;
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;
144332
144653
  constructor(ctx) {
144333
144654
  const persisted = alexaEchoDeviceSchema.parse(ctx.persistedConfig ?? {});
144334
144655
  super(ctx, alexaEchoDeviceSchema, { type: DeviceType.MediaPlayer });
@@ -144347,14 +144668,54 @@ var AlexaEchoDevice = class extends BaseDevice {
144347
144668
  this.runtimeState.setCapState("media-player", emptyEchoStatus("off", 0));
144348
144669
  this.runtimeState.setCapState("do-not-disturb", COLD_DND);
144349
144670
  this.poll();
144350
- this.timer = setInterval(() => void this.poll(), ECHO_POLL_MS);
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);
144351
144681
  this.timer.unref();
144352
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
+ }
144353
144710
  async removeDevice() {
144354
144711
  if (this.timer !== null) clearInterval(this.timer);
144355
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 = [];
144356
144716
  this.timer = null;
144357
144717
  this.rereadTimer = null;
144718
+ this.pushReadTimer = null;
144358
144719
  await super.removeDevice();
144359
144720
  }
144360
144721
  session() {
@@ -144383,8 +144744,13 @@ var AlexaEchoDevice = class extends BaseDevice {
144383
144744
  } finally {
144384
144745
  this.polling = false;
144385
144746
  }
144747
+ if (this.repollAfter) {
144748
+ this.repollAfter = false;
144749
+ await this.poll();
144750
+ }
144386
144751
  }
144387
144752
  async readOnce() {
144753
+ const readStartedAt = Date.now();
144388
144754
  const session = alexaEchoSessions.get(this.brokerId);
144389
144755
  if (session === null || session.state !== "ready") return;
144390
144756
  if (session.rateLimitedUntil !== null) {
@@ -144400,21 +144766,21 @@ var AlexaEchoDevice = class extends BaseDevice {
144400
144766
  }
144401
144767
  const deviceType = this.amazonDeviceType !== "" ? this.amazonDeviceType : listed?.deviceType ?? "";
144402
144768
  try {
144403
- const readStartedAt = Date.now();
144404
- const info = await session.playerState(this.serial, deviceType, this.id);
144405
- this.polledFrom = readStartedAt;
144406
- const fromPlayer = mapPlayerInfoToStatus(info, {
144769
+ const fromPlayer = mapPlayerInfoToStatus(await session.playerState(this.serial, deviceType, this.id), {
144407
144770
  online: true,
144408
144771
  now: Date.now()
144409
144772
  });
144410
- this.runtimeState.setCapState("media-player", await this.completeVolume(fromPlayer));
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);
144411
144777
  this.markOnline(true);
144412
144778
  this.noteRecovered();
144413
144779
  } catch (err) {
144414
144780
  this.noteReadFailure(err);
144415
144781
  return;
144416
144782
  }
144417
- await this.readDoNotDisturb();
144783
+ await this.readDoNotDisturb(readStartedAt);
144418
144784
  }
144419
144785
  /**
144420
144786
  * An idle Echo's player read carries no volume. Ask the account's volume list
@@ -144439,10 +144805,26 @@ var AlexaEchoDevice = class extends BaseDevice {
144439
144805
  return status;
144440
144806
  }
144441
144807
  }
144442
- async readDoNotDisturb() {
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) {
144443
144823
  try {
144444
144824
  const enabled = (await alexaEchoSessions.dnd(this.brokerId, Date.now())).get(this.serial);
144445
144825
  if (enabled === void 0) return;
144826
+ if (this.dndSetAt >= readStartedAt) return;
144827
+ this.dndSettled = enabled;
144446
144828
  this.runtimeState.setCapState("do-not-disturb", {
144447
144829
  enabled,
144448
144830
  lastChangedAt: Date.now()
@@ -144474,23 +144856,56 @@ var AlexaEchoDevice = class extends BaseDevice {
144474
144856
  await this.session().npCommand(this.serial, command, this.id);
144475
144857
  this.rereadSoon();
144476
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
+ */
144477
144869
  async sendVolume(level) {
144478
- this.lastSet = {
144479
- level,
144480
- at: Date.now()
144481
- };
144482
- await this.session().setVolume(this.serial, level, this.id);
144483
- this.rereadSoon();
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
+ }
144484
144902
  }
144485
144903
  /**
144486
- * Amazon has no step command: move from the level we last SET when no read since
144487
- * is newer (a polled level lags the command), else the polled one; refuse when
144488
- * 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.
144489
144906
  */
144490
144907
  async stepVolume(delta) {
144491
- const polled = this.runtimeState.getCapState("media-player")?.volumeLevel;
144492
- const set = this.lastSet;
144493
- const level = set !== null && set.at >= this.polledFrom ? set.level : polled;
144908
+ const level = this.runtimeState.getCapState("media-player")?.volumeLevel;
144494
144909
  if (level === null || level === void 0) throw new Error("the Echo volume is not known yet");
144495
144910
  await this.sendVolume(Math.max(0, Math.min(100, level + delta)));
144496
144911
  }
@@ -144511,22 +144926,51 @@ var AlexaEchoDevice = class extends BaseDevice {
144511
144926
  channelDown: async () => unsupported("channel steps"),
144512
144927
  seek: async () => unsupported("seek"),
144513
144928
  setMute: async () => unsupported("mute"),
144514
- setShuffle: async () => unsupported("shuffle"),
144515
- setRepeat: async () => unsupported("repeat"),
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
+ },
144516
144934
  selectSource: async () => unsupported("source selection"),
144517
144935
  playMedia: async () => unsupported("playing media")
144518
144936
  });
144519
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
+ }
144520
144970
  registerDoNotDisturb() {
144521
144971
  this.ctx.registerNativeCap(doNotDisturbCapability, {
144522
144972
  getStatus: async () => this.runtimeState.getCapState("do-not-disturb") ?? COLD_DND,
144523
- setEnabled: async ({ enabled }) => {
144524
- await this.session().setDnd(this.serial, enabled, this.id);
144525
- this.runtimeState.setCapState("do-not-disturb", {
144526
- enabled,
144527
- lastChangedAt: Date.now()
144528
- });
144529
- }
144973
+ setEnabled: async ({ enabled }) => this.sendDnd(enabled)
144530
144974
  });
144531
144975
  }
144532
144976
  };
@@ -144547,7 +144991,9 @@ var alexaSpeakSessionsEntryKind = defineEntryKind("speak-sessions", {
144547
144991
  writeTrigger: "an Echo worker signs in as an Alexa account broker"
144548
144992
  });
144549
144993
  /** An Alexa integration: the link to the Alexa account (broker) it imports devices from. */
144550
- var alexaConnectionsEntryKind = declareBrokerLinkKind({
144994
+ var alexaConnectionsEntryKind = declareLinkKind({
144995
+ name: "connections",
144996
+ links: { brokerId: ALEXA_ACCOUNT_LINK },
144551
144997
  title: "Alexa integrations",
144552
144998
  writeTrigger: "an operator links an Alexa integration to an Alexa account"
144553
144999
  });
@@ -144649,11 +145095,15 @@ function buildAlexaAccountInstances(records, deps) {
144649
145095
  * record, edited on the Brokers page). The account's sign-in and secrets are never copied here.
144650
145096
  */
144651
145097
  function buildAlexaInstances(records) {
144652
- return buildBrokerLinkInstances(records, {
145098
+ return buildLinkedInstances(records, {
144653
145099
  kind: alexaConnectionsEntryKind,
144654
- brokerKind: ALEXA_BROKER_KIND,
144655
- brokerLabel: "Alexa account",
144656
- onChanged: () => void 0
145100
+ description: {
145101
+ role: "devices",
145102
+ instanceMode: "multiple",
145103
+ kind: "connection",
145104
+ canTest: false
145105
+ },
145106
+ labels: { brokerId: { label: "Alexa account" } }
144657
145107
  });
144658
145108
  }
144659
145109
  //#endregion
@@ -144946,8 +145396,67 @@ var BrokerEcho = class {
144946
145396
  currentDevices = [];
144947
145397
  backoffMs = 0;
144948
145398
  limitedUntil = 0;
145399
+ channelState = "down";
145400
+ channelWasUp = false;
145401
+ secrets = [];
145402
+ unknownPushLogged = /* @__PURE__ */ new Set();
144949
145403
  constructor(deps) {
144950
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
+ });
144951
145460
  }
144952
145461
  get id() {
144953
145462
  return this.deps.brokerId;
@@ -144973,6 +145482,10 @@ var BrokerEcho = class {
144973
145482
  }
144974
145483
  stop() {
144975
145484
  this.deps.worker.stop();
145485
+ if (this.channelState === "up") {
145486
+ this.channelState = "down";
145487
+ this.deps.push?.channel(this.deps.brokerId, "down");
145488
+ }
144976
145489
  }
144977
145490
  /** Sign the worker in as the broker's account. Never throws; the outcome is `state`. */
144978
145491
  async signIn() {
@@ -144999,6 +145512,7 @@ var BrokerEcho = class {
144999
145512
  }
145000
145513
  secrets = seed === null ? [] : [seed.refreshToken];
145001
145514
  worker.noteSecrets(secrets);
145515
+ this.secrets = secrets;
145002
145516
  let outcome = parseLoginResult({
145003
145517
  status: "failed",
145004
145518
  error: "worker gave no answer"
@@ -145817,6 +146331,14 @@ function startAlexaWorker(deps) {
145817
146331
  let secrets = [];
145818
146332
  let pending = null;
145819
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
+ }
145820
146342
  function scrub(text) {
145821
146343
  return scrubSecrets(text, secrets);
145822
146344
  }
@@ -145894,6 +146416,12 @@ function startAlexaWorker(deps) {
145894
146416
  logger.warn("alexa worker non-JSON line", { meta: { error: err instanceof Error ? err.message : String(err) } });
145895
146417
  return;
145896
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
+ }
145897
146425
  const checked = WorkerLineSchema.safeParse(parsed);
145898
146426
  if (!checked.success) {
145899
146427
  logger.warn("alexa worker line failed schema", { meta: { error: checked.error.message } });
@@ -145920,6 +146448,12 @@ function startAlexaWorker(deps) {
145920
146448
  }
145921
146449
  function onExit(code) {
145922
146450
  child = null;
146451
+ deliverPush({
146452
+ push: "channel",
146453
+ state: "down",
146454
+ reason: "worker exited",
146455
+ retryInMs: 0
146456
+ });
145923
146457
  if (stopped) {
145924
146458
  failPending(/* @__PURE__ */ new Error("alexa worker stopped"));
145925
146459
  return;
@@ -146026,6 +146560,9 @@ function startAlexaWorker(deps) {
146026
146560
  }
146027
146561
  return {
146028
146562
  request,
146563
+ onPush(listener) {
146564
+ pushListeners.push(listener);
146565
+ },
146029
146566
  noteSecrets(next) {
146030
146567
  secrets = [...next];
146031
146568
  },
@@ -146176,7 +146713,8 @@ var AlexaSpeakController = class {
146176
146713
  readSeed: () => this.host.readSeed(ref.id),
146177
146714
  sessions: this.host.sessions,
146178
146715
  logger: this.host.logger,
146179
- now: this.now
146716
+ now: this.now,
146717
+ ...this.host.push === void 0 ? {} : { push: this.host.push }
146180
146718
  }));
146181
146719
  await Promise.all(refs.map(async (ref) => {
146182
146720
  const echo = this.echoes.get(ref.id);
@@ -146590,7 +147128,8 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
146590
147128
  return entry === void 0 ? null : readBrokerSeed(entry);
146591
147129
  },
146592
147130
  sessions: RecordsSpeakSessionStore.of(records),
146593
- ensurePython: () => this.ctx.deps.ensurePython()
147131
+ ensurePython: () => this.ctx.deps.ensurePython(),
147132
+ push: alexaEchoSessions
146594
147133
  });
146595
147134
  this.speak = speak;
146596
147135
  alexaEchoSessions.setResolver((brokerId) => speak.echoFor(brokerId));
@@ -146654,7 +147193,7 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
146654
147193
  methods: ["device-code", "proxy"]
146655
147194
  }
146656
147195
  },
146657
- provider: guardBrokerDelete(this.accountInstances.toProvider(), () => this.brokerUsers())
147196
+ provider: guardInstanceDelete(this.accountInstances.toProvider(), () => this.brokerUsers())
146658
147197
  }, {
146659
147198
  kind: {
146660
147199
  id: "integration",
@@ -146687,7 +147226,7 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
146687
147226
  return;
146688
147227
  }
146689
147228
  const configured = entry.connection.cookie.length > 0 || entry.connection.refreshToken.length > 0;
146690
- if (entry.enabled === false || !configured) {
147229
+ if (!isInstanceEnabled(entry) || !configured) {
146691
147230
  await reg.removeEntry(id);
146692
147231
  this.speak?.brokersChanged();
146693
147232
  return;
@@ -146779,7 +147318,7 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
146779
147318
  } });
146780
147319
  }
146781
147320
  for (const entry of persisted) {
146782
- if (entry.enabled === false) continue;
147321
+ if (!isInstanceEnabled(entry)) continue;
146783
147322
  if (entry.id && liveIds.has(entry.id)) {
146784
147323
  if (sameConnection(entry.connection, reg.getConnection(entry.id))) continue;
146785
147324
  try {
@@ -146954,7 +147493,7 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
146954
147493
  }
146955
147494
  /** Who still points at an Alexa account: its integrations and the Echo targets that speak through it. */
146956
147495
  brokerUsers() {
146957
- return [brokerLinkUserFinder(this.requireInstances()), { find: async (brokerId) => readEchoTargets(this.config).filter((target) => target.brokerId === brokerId).map((target) => ({
147496
+ return [linkUserFinder(this.requireInstances(), "brokerId"), { find: async (brokerId) => readEchoTargets(this.config).filter((target) => target.brokerId === brokerId).map((target) => ({
146958
147497
  kind: "notification-target",
146959
147498
  id: target.id,
146960
147499
  name: target.name
@@ -146985,7 +147524,7 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
146985
147524
  },
146986
147525
  logger: this.ctx.logger,
146987
147526
  getBrokerIdForIntegration: async (id) => {
146988
- const brokerId = await brokerIdOf(this.requireInstances(), id);
147527
+ const brokerId = await linkedIdOf(this.requireInstances(), id, "brokerId");
146989
147528
  if (brokerId === void 0) throw new Error(`integration ${id} has no broker`);
146990
147529
  return brokerId;
146991
147530
  },