@camstack/addon-import-alexa 0.2.190 → 0.2.192

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