@camstack/addon-google-services 0.2.43 → 0.2.44

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.
@@ -806,7 +806,7 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
806
806
  return EventCategory;
807
807
  }({});
808
808
  //#endregion
809
- //#region ../types/dist/device-type-D1fH5kaC.mjs
809
+ //#region ../types/dist/device-type-DxCT0krO.mjs
810
810
  /**
811
811
  import { errMsg } from '@camstack/types'
812
812
  * Extract a human-readable message from an unknown error value.
@@ -968,6 +968,13 @@ var DeviceFeature = /* @__PURE__ */ function(DeviceFeature) {
968
968
  * filters that want "all devices with on-motion behaviour".
969
969
  */
970
970
  DeviceFeature["MotionTrigger"] = "motion-trigger";
971
+ /**
972
+ * Device can be run for a chosen time and then stopped. Mirrors the
973
+ * `timed-run` wrapper cap — the cap is bound by the core-blocks addon, not by
974
+ * a driver, so the flag is derived from the device's bindings
975
+ * (`featuresOfBoundCaps`). Gates the quick-run control in the viewer.
976
+ */
977
+ DeviceFeature["TimedRun"] = "timed-run";
971
978
  /** Light supports rgb-triplet color via `color` cap. */
972
979
  DeviceFeature["LightColorRgb"] = "light-color-rgb";
973
980
  /** Light supports HSV color via `color` cap. */
@@ -6002,7 +6009,7 @@ var ZodIssueCode = {
6002
6009
  var ZodFirstPartyTypeKind;
6003
6010
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
6004
6011
  //#endregion
6005
- //#region ../types/dist/sleep-AEnTN3b4.mjs
6012
+ //#region ../types/dist/sleep-CGPZyzB5.mjs
6006
6013
  /**
6007
6014
  * The audio chunk plane's byte format, and the ONE expansion from a coded
6008
6015
  * window to float samples (D455).
@@ -6366,6 +6373,59 @@ Object.fromEntries([
6366
6373
  function isValuelessField(field) {
6367
6374
  return field.type === "separator" || field.type === "info" || field.type === "qr-code" || field.type === "button" || field.type === "object-array" || field.type === "widget" || field.type === "addon-action-button" || field.type === "device-action-button" || field.type === "account-sign-in" || field.type === "secret-presence";
6368
6375
  }
6376
+ /**
6377
+ * Base collector: flatten a hydrated `ConfigUISchemaWithValues` into
6378
+ * `key → { value, default }`, recursing through `group` and `sub-tabs`
6379
+ * layout fields. Keeping `value` and `default` SEPARATE (rather than
6380
+ * pre-resolving) lets callers both read the effective value AND detect
6381
+ * overrides (`value` differs from `default`). Structural over `unknown`;
6382
+ * malformed input yields an empty map, never throws.
6383
+ */
6384
+ function collectHydratedFieldEntries(schema) {
6385
+ const out = /* @__PURE__ */ new Map();
6386
+ if (typeof schema !== "object" || schema === null) return out;
6387
+ const sections = schema.sections;
6388
+ if (!Array.isArray(sections)) return out;
6389
+ for (const section of sections) if (typeof section === "object" && section !== null) collectFieldEntriesInto(section.fields, out);
6390
+ return out;
6391
+ }
6392
+ /** Recursive field walker for {@link collectHydratedFieldEntries}. */
6393
+ function collectFieldEntriesInto(fields, into) {
6394
+ if (!Array.isArray(fields)) return;
6395
+ for (const field of fields) {
6396
+ if (typeof field !== "object" || field === null) continue;
6397
+ const type = field.type;
6398
+ if (type === "group") {
6399
+ collectFieldEntriesInto(field.fields, into);
6400
+ continue;
6401
+ }
6402
+ if (type === "sub-tabs") {
6403
+ const tabs = field.tabs;
6404
+ if (Array.isArray(tabs)) {
6405
+ for (const tab of tabs) if (typeof tab === "object" && tab !== null) collectFieldEntriesInto(tab.fields, into);
6406
+ }
6407
+ continue;
6408
+ }
6409
+ const key = field.key;
6410
+ if (typeof key !== "string" || key.length === 0) continue;
6411
+ if (!("value" in field)) continue;
6412
+ into.set(key, {
6413
+ value: field.value,
6414
+ default: field.default
6415
+ });
6416
+ }
6417
+ }
6418
+ /**
6419
+ * Flatten a hydrated settings payload into `key → EFFECTIVE value`
6420
+ * (`value ?? default`). The read-side inverse of `hydrateSchema` for the
6421
+ * common "just give me the resolved values" case. Built on
6422
+ * {@link collectHydratedFieldEntries}.
6423
+ */
6424
+ function collectHydratedFieldValues(schema) {
6425
+ const out = /* @__PURE__ */ new Map();
6426
+ for (const [key, entry] of collectHydratedFieldEntries(schema)) out.set(key, entry.value ?? entry.default);
6427
+ return out;
6428
+ }
6369
6429
  function hydrateSchema(schema, values, options = {}) {
6370
6430
  return {
6371
6431
  ...schema.tabs ? { tabs: [...schema.tabs] } : {},
@@ -7166,6 +7226,7 @@ var RecordIntegrationInstances = class {
7166
7226
  this.secretKeys = topLevelSecretKeys(kind.declaration.secretPaths);
7167
7227
  this.secretMaps = secretMapKeys(kind.declaration.secretPaths);
7168
7228
  this.nameField = kind.declaration.entry?.nameField ?? "name";
7229
+ if (hooks.kindOf === void 0 && hooks.description.kinds.length !== 1) throw new Error(`record kind '${kind.name}': describe().kinds has ${hooks.description.kinds.length} entries, so kindOf is required to say which one an instance is`);
7169
7230
  }
7170
7231
  /**
7171
7232
  * One instance read for the addon's own runtime; null when absent. When the keychain is
@@ -7207,10 +7268,21 @@ var RecordIntegrationInstances = class {
7207
7268
  }
7208
7269
  return out;
7209
7270
  }
7271
+ /** The kind an instance body is of: the provider's `kindOf`, else its one declared kind. */
7272
+ kindOfBody(body) {
7273
+ if (this.hooks.kindOf !== void 0) {
7274
+ const id = this.hooks.kindOf(body);
7275
+ if (!this.hooks.description.kinds.some((declared) => declared.id === id)) throw new Error(`record kind '${this.kind.name}': kindOf returned '${id}', which describe().kinds does not declare (D1013)`);
7276
+ return id;
7277
+ }
7278
+ const only = this.hooks.description.kinds[0];
7279
+ if (only === void 0) throw new Error(`record kind '${this.kind.name}' declares no kinds`);
7280
+ return only.id;
7281
+ }
7210
7282
  viewOf(record) {
7211
7283
  const shown = secretViewsOf(this.records.owner, this.kind.name, record.id, this.kind.declaration, record.data);
7212
7284
  const rawName = record.data[this.nameField];
7213
- const kind = this.hooks.kindOf?.(record.data);
7285
+ const kind = this.kindOfBody(record.data);
7214
7286
  const health = this.hooks.health?.(record.data, record.id);
7215
7287
  return {
7216
7288
  id: record.id,
@@ -7218,7 +7290,7 @@ var RecordIntegrationInstances = class {
7218
7290
  configured: this.hooks.configured(record.data),
7219
7291
  enabled: isInstanceEnabled(record.data),
7220
7292
  formValues: shown,
7221
- ...kind !== void 0 ? { kind } : {},
7293
+ kind,
7222
7294
  ...health?.status !== void 0 ? { status: health.status } : {},
7223
7295
  ...health?.lastCheckedAt !== void 0 ? { lastCheckedAt: health.lastCheckedAt } : {}
7224
7296
  };
@@ -7590,6 +7662,21 @@ var SCOPED_SETTINGS_SCOPES = [
7590
7662
  "device-defaults",
7591
7663
  "node"
7592
7664
  ];
7665
+ /** The one refusal of a key a scope did not declare. */
7666
+ function assertDeclaredKeys(keys, declared) {
7667
+ const undeclared = keys.filter((key) => !declared.has(key));
7668
+ if (undeclared.length > 0) throw new Error(`scoped settings: [${undeclared.join(", ")}] not declared for this scope (declared: [${[...declared].join(", ")}])`);
7669
+ }
7670
+ /** Build the handle over an addon's scoped read, write and unset. */
7671
+ function createScopedSettings(deps) {
7672
+ return {
7673
+ async get() {
7674
+ return deps.schema.parse(await deps.read());
7675
+ },
7676
+ patch: (patch) => deps.write(Object.fromEntries(Object.entries(patch))),
7677
+ unset: (keys) => deps.unset(keys)
7678
+ };
7679
+ }
7593
7680
  /** A write to a secret path could not be stored in the keychain, so it was refused. */
7594
7681
  var KeychainWriteRefusedError = class extends Error {
7595
7682
  fieldPath;
@@ -8165,7 +8252,16 @@ function createHandle(raw, opts) {
8165
8252
  return blob;
8166
8253
  }
8167
8254
  },
8168
- writeAddonStore: writeAddon
8255
+ writeAddonStore: writeAddon,
8256
+ removeClearedKeys: (keys) => serialize("addon", async () => {
8257
+ const stored = await readStoredAddonBlob(raw);
8258
+ index.seen("addon", stored);
8259
+ const removable = keys.filter((key) => collectSecretMarkerRefs(stored[key]).length === 0);
8260
+ if (removable.length === 0) return;
8261
+ const patch = Object.fromEntries(removable.map((key) => [key, void 0]));
8262
+ await raw.writeAddonStore(patch);
8263
+ index.wrote("addon", patch);
8264
+ })
8169
8265
  };
8170
8266
  }
8171
8267
  /** The context the addon sees, plus the private raw view and write outcome. */
@@ -8295,7 +8391,7 @@ function boundOf(list) {
8295
8391
  * Base class for CamStack addons. Eliminates settings boilerplate:
8296
8392
  *
8297
8393
  * - Typed `config` property with automatic resolution from store + defaults
8298
- * - `getGlobalSettings()` / `updateGlobalSettings()` auto-implemented
8394
+ * - the scoped settings API (`clusterSettings` / `nodeSettings` / `deviceDefaults`) auto-implemented
8299
8395
  * - `getAddonSettings()` / `updateAddonSettings()` auto-implemented
8300
8396
  * - `getDeviceSettings()` / `updateDeviceSettings()` auto-implemented
8301
8397
  * - `ctx` accessor for the AddonContext (no manual `this.ctxRef` storage)
@@ -8305,7 +8401,7 @@ function boundOf(list) {
8305
8401
  * Subclasses override:
8306
8402
  * - `onInitialize()` — addon-specific init logic, return ProviderRegistration[]
8307
8403
  * - `onShutdown()` — cleanup
8308
- * - `globalSettingsSchema()` / `deviceSettingsSchema()` — UI schemas
8404
+ * - `clusterSettingsSchema()` / `nodeSettingsSchema()` / `deviceSettingsSchema()` — UI schemas
8309
8405
  *
8310
8406
  * @example
8311
8407
  * ```ts
@@ -8319,7 +8415,7 @@ function boundOf(list) {
8319
8415
  * super({ maxRetries: 3, endpoint: 'http://localhost' })
8320
8416
  * }
8321
8417
  *
8322
- * protected globalSettingsSchema() {
8418
+ * protected clusterSettingsSchema() {
8323
8419
  * return this.schema({
8324
8420
  * sections: [{
8325
8421
  * id: 'main', title: 'Settings',
@@ -8362,7 +8458,7 @@ var BaseAddon = class {
8362
8458
  defaults;
8363
8459
  /**
8364
8460
  * Hidden-state paths that hold secrets (D787) — values the addon writes
8365
- * itself (`updateGlobalSettings`, `state()`) that no form field declares.
8461
+ * itself (`state()`, the scoped writers) that no form field declares.
8366
8462
  * Stored in the keychain exactly like a `password` field. Syntax:
8367
8463
  * `'snapshotSigningKey'`, `'identity.pincode'`, `'tokens[userId].accessToken'`
8368
8464
  * (`key[<id field>]` addresses array items by id, never by index).
@@ -8372,7 +8468,7 @@ var BaseAddon = class {
8372
8468
  /**
8373
8469
  * D986 M4: the scoped schemas this addon declared, resolved once per capability
8374
8470
  * (`loadScopedSchemas`) so the sync readers (`perNodeKeys`, the secret tree, the restart check)
8375
- * see them. Empty until loaded: the readers then fall back to {@link globalSettingsSchema}.
8471
+ * see them. Empty until loaded.
8376
8472
  */
8377
8473
  _scopedByCap = /* @__PURE__ */ new Map();
8378
8474
  _nodeKeysByCap = /* @__PURE__ */ new Map();
@@ -8558,7 +8654,7 @@ var BaseAddon = class {
8558
8654
  /** Addon-specific cleanup. Override if needed. */
8559
8655
  async onShutdown() {}
8560
8656
  /**
8561
- * Called after config is resolved during updateGlobalSettings/updateAddonSettings.
8657
+ * Called after config is resolved during a settings write.
8562
8658
  * Override to react to config changes (e.g. restart a sampler, reconnect a service).
8563
8659
  * Not called during initialize() — use onInitialize() for initial setup.
8564
8660
  */
@@ -8587,82 +8683,12 @@ var BaseAddon = class {
8587
8683
  scopedSettingsCapabilities() {
8588
8684
  return [];
8589
8685
  }
8590
- /** Override to provide global-level settings UI schema. */
8591
- globalSettingsSchema(_cap) {
8592
- return null;
8593
- }
8594
8686
  /** Override to provide device-level settings UI schema. */
8595
8687
  deviceSettingsSchema() {
8596
8688
  return null;
8597
8689
  }
8598
8690
  /**
8599
- * INTEGRATION-LEVEL SETTINGS — declare which of this addon's global sections
8600
- * ARE the configuration of its integration.
8601
- *
8602
- * Return the `ConfigSection.id`s, from {@link globalSettingsSchema}, that an
8603
- * operator should find on the addon's integration page (System →
8604
- * Integrations → <name>) rather than only in the cluster-wide list of every
8605
- * addon. Empty (the default) means the addon has no integration-level
8606
- * settings and no such surface is offered — this is opt-in, because whether
8607
- * an addon's configuration IS its integration's configuration depends on the
8608
- * nature of the integration.
8609
- *
8610
- * WHAT THIS IS NOT. It is not a scope. The selected sections keep living in
8611
- * the ONE global schema, in the ONE addon store, written by the ONE
8612
- * `updateGlobalSettings` path. There is deliberately no
8613
- * `updateIntegrationSettings`: a second write path is how a surface acquires
8614
- * a second store key, and this repo has shipped that twice (`btmPath@hub`,
8615
- * D266). Selecting sections cannot introduce a key that selecting cannot.
8616
- *
8617
- * WHY IT IS A LIST OF SECTION IDS AND NOT A MARKER ON THE SECTION.
8618
- * `ConfigFieldBase` used to carry `scope?: 'device' | 'global'` and it was
8619
- * removed with the reason recorded at
8620
- * `packages/types/src/interfaces/config-ui.ts:249` — *"a field's scope is
8621
- * determined by WHICH schema it lives in, not by a field-level marker."* A
8622
- * marker sprinkled across sections also has to borrow a field that already
8623
- * means something else; borrowing `section.tab` put the literal word
8624
- * "integration" into an operator-facing tab bar, because `tab` means "how to
8625
- * GROUP this visually" and cannot also mean "where this lives" (D269
8626
- * supersedes D268). One declaration, in one place, next to the schema whose
8627
- * ids it names.
8628
- */
8629
- integrationSettingSections() {
8630
- return [];
8631
- }
8632
- async getGlobalSettings(overlay, cap, nodeId) {
8633
- await this.loadScopedSchemas(cap);
8634
- const schema = this.globalSettingsSchema(cap) ?? this.scopedFormSchema(cap);
8635
- if (!schema) return { sections: [] };
8636
- const projected = await this.readRawProjectedStore(nodeId, cap);
8637
- return hydrateSchema(schema, overlay ? await this.overlayKeepingMarkers(projected, overlay) : projected, { inlineSecretsAsViews: true });
8638
- }
8639
- /**
8640
- * The integration-level view of this addon's settings: exactly the sections
8641
- * named by {@link integrationSettingSections}, hydrated from the SAME store
8642
- * `getGlobalSettings` reads, and narrowed to cluster-scoped fields.
8643
- *
8644
- * Returns `null` when the addon declared nothing — an addon that opts out has
8645
- * no integration settings surface at all, rather than an empty one that reads
8646
- * as a failed load.
8647
- *
8648
- * Three properties hold BY CONSTRUCTION, which is why they are here in core
8649
- * and not in whichever UI happens to render this:
8650
- *
8651
- * 1. **One key.** The payload is a SUBSET of the global schema, so a field
8652
- * shown here is the same field, with the same bare key, that the addon's
8653
- * own page shows. There is no integration-specific writer — callers save
8654
- * through `updateGlobalSettings` — so a second store key is unreachable,
8655
- * not merely discouraged.
8656
- * 2. **No node scope.** `perNode: true` fields are DROPPED. Their store key
8657
- * is `<key>@<nodeId>` and an integration is not a node; whichever node
8658
- * such a field silently picked would be a wrong answer for the operator
8659
- * who opened the page (D266).
8660
- * 3. **No silent typo.** A declared id that names no section throws. The
8661
- * alternative — skip it — turns a rename into a surface that quietly
8662
- * empties, which looks exactly like an addon with nothing to configure.
8663
- */
8664
- /**
8665
- * The form schema of ONE integration instance, or `null` to use {@link globalSettingsSchema}.
8691
+ * The form schema of ONE integration instance, or `null` to use the scoped form.
8666
8692
  *
8667
8693
  * Override it only when an `instanceMode: multiple` addon's instances differ: an NVR integration
8668
8694
  * needs its connection fields, the default integration the addon's own sections. It is the
@@ -8710,37 +8736,178 @@ var BaseAddon = class {
8710
8736
  }
8711
8737
  }
8712
8738
  /**
8739
+ * Hook: values layered over the stored ones when the FORM of one scope is served
8740
+ * ({@link getScopedSettings}) — a preview that derives dependent options without writing.
8741
+ * Never applied to {@link ScopedSettings.get} or to a write. `null` ⇒ no overlay.
8742
+ */
8743
+ async scopedSettingsOverlay(_scope, _cap, _nodeId) {
8744
+ return null;
8745
+ }
8746
+ /**
8747
+ * Hook: a write of one scope changed stored values (not called when the store reports no change,
8748
+ * e.g. the write-back of a stored secret). `keys` are the bare field keys of the write, node keys included.
8749
+ */
8750
+ async onScopedSettingsChanged(_scope, _cap, _keys) {}
8751
+ /** Typed handle over the CLUSTER settings `cap` declares (`clusterSettingsSchema`). */
8752
+ clusterSettings(cap, schema) {
8753
+ return this.scopedHandle("cluster", cap, schema);
8754
+ }
8755
+ /** Typed handle over the fleet defaults of the per-device settings `cap` declares. */
8756
+ deviceDefaults(cap, schema) {
8757
+ return this.scopedHandle("device-defaults", cap, schema);
8758
+ }
8759
+ /**
8760
+ * Typed handle over the settings `cap` owns PER NODE. `nodeId` omitted ⇒ the local node. A node
8761
+ * with no row yields the schema defaults — never a bare-key fallback.
8762
+ */
8763
+ nodeSettings(cap, schema, nodeId) {
8764
+ return this.scopedHandle("node", cap, schema, nodeId);
8765
+ }
8766
+ /**
8767
+ * {@link state} for a value that is kept per node: stored under `<key>@<nodeId>`, and the
8768
+ * settings store carries `nodeId` in the row data (D986 §1), so a forgotten node's rows go in one
8769
+ * central delete. `nodeId` omitted ⇒ the local node.
8770
+ */
8771
+ nodeState(key, schema, fallback, nodeId) {
8772
+ this._declaredStateKeys.add(key);
8773
+ return createDurableState({
8774
+ key: nodeScopedKey(key, nodeId ?? this._ctx?.kernel?.localNodeId ?? ""),
8775
+ schema,
8776
+ fallback,
8777
+ read: async () => this.resolveStoredSecrets(await this.readAddonStoreWithRetry()),
8778
+ write: async (patch) => {
8779
+ await this._ctx?.settings?.writeAddonStore(patch);
8780
+ },
8781
+ onParseError: (k, e) => this._ctx?.logger?.warn?.(`durable-state: stored "${k}" failed validation — using fallback`, { meta: {
8782
+ addonId: this._ctx?.id,
8783
+ key: k,
8784
+ error: String(e)
8785
+ } })
8786
+ });
8787
+ }
8788
+ /** The loaded schema `cap` declares for `scope`, or `null`. */
8789
+ async declaredScopedSchema(scope, cap) {
8790
+ await this.loadScopedSchemas(cap);
8791
+ return this.loadedScopedSchemas(cap).find((entry) => entry.scope === scope)?.schema ?? null;
8792
+ }
8793
+ async requireScopedSchema(scope, cap) {
8794
+ const schema = await this.declaredScopedSchema(scope, cap);
8795
+ if (schema === null) throw new Error(`${this.constructor.name}: cap "${cap}" declares no ${scope} settings`);
8796
+ return schema;
8797
+ }
8798
+ scopedKeys(schema) {
8799
+ return new Set(schema.sections.flatMap((section) => collectValueFieldKeys(section.fields)));
8800
+ }
8801
+ /** `store` narrowed to the keys `schema` declares. */
8802
+ narrowToScope(store, schema) {
8803
+ const keys = this.scopedKeys(schema);
8804
+ return Object.fromEntries(Object.entries(store).filter(([key]) => keys.has(key)));
8805
+ }
8806
+ scopedHandle(scope, cap, schema, nodeId) {
8807
+ return createScopedSettings({
8808
+ schema,
8809
+ read: async () => {
8810
+ const declared = await this.requireScopedSchema(scope, cap);
8811
+ const stored = this.narrowToScope(await this.resolveGlobalStore(nodeId, cap), declared);
8812
+ const effective = collectHydratedFieldValues(hydrateSchema(declared, stored));
8813
+ return {
8814
+ ...Object.fromEntries([...effective].filter(([, value]) => value !== void 0)),
8815
+ ...stored
8816
+ };
8817
+ },
8818
+ write: (patch) => this.writeScoped(scope, cap, patch, nodeId),
8819
+ unset: (keys) => this.unsetScoped(scope, cap, keys, nodeId)
8820
+ });
8821
+ }
8822
+ /** The one write path of the scoped API: declared keys only, then the global patch. */
8823
+ async writeScoped(scope, cap, patch, nodeId) {
8824
+ assertDeclaredKeys(Object.keys(patch), this.scopedKeys(await this.requireScopedSchema(scope, cap)));
8825
+ if (await this.applyGlobalPatch(patch, scope === "node" ? nodeId : void 0, cap)) await this.onScopedSettingsChanged(scope, cap, Object.keys(patch));
8826
+ }
8827
+ /**
8828
+ * Remove declared keys of one scope. A secret goes through {@link clearSecrets} — its keychain
8829
+ * value is released exactly as there — and the emptied row is then removed; every other key is
8830
+ * removed by an `undefined` patch (which a secret transform would read as "keep").
8831
+ */
8832
+ async unsetScoped(scope, cap, keys, nodeId) {
8833
+ assertDeclaredKeys(keys, this.scopedKeys(await this.requireScopedSchema(scope, cap)));
8834
+ const target = scope === "node" ? nodeId : void 0;
8835
+ const tree = this.addonSecretTree();
8836
+ const secretKeys = keys.filter((key) => tree.has(key));
8837
+ const plainKeys = keys.filter((key) => !tree.has(key));
8838
+ let changed = false;
8839
+ if (plainKeys.length > 0) {
8840
+ const patch = Object.fromEntries(plainKeys.map((key) => [key, void 0]));
8841
+ changed = await this.applyGlobalPatch(patch, target, cap) || changed;
8842
+ }
8843
+ if (secretKeys.length > 0) {
8844
+ await this.clearSecretKeys(secretKeys, target, cap);
8845
+ const node = normalizeNodeId(target ?? this._ctx?.kernel?.localNodeId);
8846
+ const rows = secretKeys.map((key) => scope === "node" ? nodeScopedKey(key, node) : key);
8847
+ if (this._secretSettings !== null) await this._secretSettings.removeClearedKeys(rows);
8848
+ else await this._ctx?.settings?.writeAddonStore(Object.fromEntries(rows.map((row) => [row, void 0])));
8849
+ changed = true;
8850
+ }
8851
+ if (changed) await this.onScopedSettingsChanged(scope, cap, [...keys]);
8852
+ }
8853
+ /**
8854
+ * The hydrated form of one scope of one capability's settings (any
8855
+ * {@link scopedSettingsOverlay} applied), or `null` when the capability declares nothing there.
8856
+ */
8857
+ async getScopedSettings(scope, cap, nodeId) {
8858
+ const schema = await this.declaredScopedSchema(scope, cap);
8859
+ if (schema === null) return null;
8860
+ const stored = this.narrowToScope(await this.readRawProjectedStore(nodeId, cap), schema);
8861
+ const overlay = await this.scopedSettingsOverlay(scope, cap, nodeId);
8862
+ return hydrateSchema(schema, overlay ? await this.overlayKeepingMarkers(stored, overlay) : stored, { inlineSecretsAsViews: true });
8863
+ }
8864
+ /**
8865
+ * The scoped schemas this addon serves: one entry per loaded (capability, scope), capabilities
8866
+ * in declaration order, scopes in {@link SCOPED_SETTINGS_SCOPES} order. `[]` when it declares
8867
+ * nothing.
8868
+ */
8869
+ async listScopedSettings() {
8870
+ await this.loadScopedSchemas();
8871
+ return [...this._scopedByCap].flatMap(([cap, entries]) => entries.map((entry) => ({
8872
+ cap,
8873
+ scope: entry.scope,
8874
+ sectionIds: entry.schema.sections.map((section) => section.id)
8875
+ })));
8876
+ }
8877
+ /** The OPERATOR's write of declared keys of one scope (D787 secret semantics). */
8878
+ async updateScopedSettings(scope, cap, patch, nodeId) {
8879
+ this._operatorWriteDepth++;
8880
+ try {
8881
+ await this.writeScoped(scope, cap, patch, nodeId);
8882
+ } finally {
8883
+ this._operatorWriteDepth--;
8884
+ }
8885
+ }
8886
+ /**
8887
+ * The effective values (`stored ?? default`) of one scope, a secret as its `set` view — never
8888
+ * its value. `null` when the capability declares nothing for the scope.
8889
+ */
8890
+ async readScopedValues(scope, cap, nodeId) {
8891
+ const schema = await this.declaredScopedSchema(scope, cap);
8892
+ if (schema === null) return null;
8893
+ const form = hydrateSchema(schema, this.narrowToScope(await this.readRawProjectedStore(nodeId, cap), schema), { inlineSecretsAsViews: true });
8894
+ return Object.fromEntries([...collectHydratedFieldValues(form)].filter(([, value]) => value !== void 0));
8895
+ }
8896
+ /**
8713
8897
  * What the create wizard and the integration edit page render for one instance: the addon's
8714
8898
  * per-instance schema when it declares one, else its global schema (what they always showed).
8715
8899
  */
8716
8900
  async getIntegrationFormSchema(instance, nodeId) {
8717
8901
  const scoped = await this.integrationFormSchema(instance);
8718
- if (scoped === null) return this.getGlobalSettings(void 0, void 0, nodeId);
8902
+ if (scoped === null) return this.scopedForm(nodeId);
8719
8903
  return hydrateSchema(scoped, {}, { inlineSecretsAsViews: true });
8720
8904
  }
8721
- async getIntegrationSettings(nodeId) {
8722
- const declared = this.integrationSettingSections();
8723
- if (declared.length === 0) return null;
8724
- const schema = this.globalSettingsSchema();
8725
- if (!schema) throw new Error(`${this.constructor.name}: integrationSettingSections() names [${declared.join(", ")}] but globalSettingsSchema() returns null.`);
8726
- const byId = new Map(schema.sections.map((section) => [section.id, section]));
8727
- const sections = [];
8728
- for (const id of declared) {
8729
- const section = byId.get(id);
8730
- if (!section) throw new Error(`${this.constructor.name}: integrationSettingSections() names unknown section "${id}". Known sections: [${[...byId.keys()].join(", ")}].`);
8731
- const fields = dropPerNodeFields(section.fields);
8732
- if (fields.length === 0) continue;
8733
- sections.push({
8734
- ...section,
8735
- fields
8736
- });
8737
- }
8738
- if (sections.length === 0) return null;
8739
- const projected = await this.readRawProjectedStore(nodeId);
8740
- return hydrateSchema({
8741
- ...schema,
8742
- sections
8743
- }, projected, { inlineSecretsAsViews: true });
8905
+ /** The form of the scoped schemas this addon declared, hydrated from its store. */
8906
+ async scopedForm(nodeId) {
8907
+ await this.loadScopedSchemas();
8908
+ const schema = this.scopedFormSchema();
8909
+ if (!schema) return { sections: [] };
8910
+ return hydrateSchema(schema, await this.readRawProjectedStore(nodeId), { inlineSecretsAsViews: true });
8744
8911
  }
8745
8912
  /**
8746
8913
  * The raw addon store PROJECTED onto the target node's bare per-node keys:
@@ -8749,8 +8916,8 @@ var BaseAddon = class {
8749
8916
  * bare fallback), all `@`-scoped and stray bare per-node keys are dropped.
8750
8917
  * A no-op passthrough when the schema declares no `perNode` field.
8751
8918
  *
8752
- * This is the sanctioned way for a `getGlobalSettings` OVERRIDE that needs
8753
- * the store for custom option logic (option narrowing, value snapping) to
8919
+ * This is the sanctioned way for an addon that needs
8920
+ * the store for custom logic (option narrowing, value snapping) to
8754
8921
  * read it per-node — never `ctx.settings.readAddonStore()` directly.
8755
8922
  * `nodeId` omitted ⇒ the local node (node-blind injection from ctx).
8756
8923
  *
@@ -8802,7 +8969,7 @@ var BaseAddon = class {
8802
8969
  }
8803
8970
  /**
8804
8971
  * Whether ONE global key of this addon's store was written, read three-way
8805
- * (D651). For a question a hydrated view cannot answer: `getGlobalSettings`
8972
+ * (D651). For a question a hydrated view cannot answer: a hydrated form
8806
8973
  * reports `value ?? default`, so "the operator chose the default" and "nobody
8807
8974
  * chose" look the same, and a failed read looks like both. A `perNode` key is
8808
8975
  * not supported here: its stored name is scoped.
@@ -8836,7 +9003,7 @@ var BaseAddon = class {
8836
9003
  }
8837
9004
  /**
8838
9005
  * The OPERATOR's write — a settings form or an admin API call, dispatched by
8839
- * the kernel. Identical to {@link updateGlobalSettings}, except that a secret
9006
+ * the kernel. The one in-process write of the addon blob that is not a scoped write; a secret
8840
9007
  * the operator sends is stored as sent even when it equals a pre-keychain
8841
9008
  * inline value: the operator re-entering a password is a change, an addon
8842
9009
  * writing back what it read is not (D787).
@@ -8849,9 +9016,6 @@ var BaseAddon = class {
8849
9016
  this._operatorWriteDepth--;
8850
9017
  }
8851
9018
  }
8852
- async updateGlobalSettings(patch, nodeId) {
8853
- await this.applyGlobalPatch(patch, nodeId);
8854
- }
8855
9019
  /**
8856
9020
  * REMOVE stored secrets — their keychain values and their markers (D787).
8857
9021
  *
@@ -8861,6 +9025,9 @@ var BaseAddon = class {
8861
9025
  * a keychain (an older kernel) it writes `''`, as such code always did.
8862
9026
  */
8863
9027
  async clearSecrets(keys, nodeId) {
9028
+ await this.clearSecretKeys(keys, nodeId);
9029
+ }
9030
+ async clearSecretKeys(keys, nodeId, cap) {
8864
9031
  const tree = this.addonSecretTree();
8865
9032
  const keychain = this._ctx?.secrets !== void 0;
8866
9033
  const stored = keychain ? await this.readAddonStoreWithRetry() : {};
@@ -8873,25 +9040,26 @@ var BaseAddon = class {
8873
9040
  const keychainStored = tree.has(key) || collectSecretMarkerRefs(stored[key]).length > 0;
8874
9041
  patch[key] = keychain && keychainStored ? SECRET_CLEAR : "";
8875
9042
  }
8876
- await this.applyGlobalPatch(patch, nodeId);
9043
+ await this.applyGlobalPatch(patch, nodeId, cap);
8877
9044
  }
8878
- async applyGlobalPatch(barePatch, nodeId) {
8879
- await this.loadScopedSchemas();
8880
- const keys = this.perNodeKeys();
9045
+ /** Resolves `true` when the write changed stored values. */
9046
+ async applyGlobalPatch(barePatch, nodeId, cap) {
9047
+ await this.loadScopedSchemas(cap);
9048
+ const keys = this.perNodeKeys(cap);
8881
9049
  const localNode = normalizeNodeId(this._ctx?.kernel?.localNodeId);
8882
9050
  const target = normalizeNodeId(nodeId ?? this._ctx?.kernel?.localNodeId);
8883
9051
  const scoped = keys.size > 0 ? scopePatch(barePatch, keys, target) : barePatch;
8884
9052
  const handle = this._secretSettings;
8885
9053
  if (handle !== null) {
8886
9054
  const { changed } = await handle.writeAddonStore(scoped);
8887
- if (!changed) return;
9055
+ if (!changed) return false;
8888
9056
  } else await this._ctx?.settings?.writeAddonStore(scoped);
8889
- if (target !== localNode) return;
9057
+ if (target !== localNode) return true;
8890
9058
  await this.resolveConfig();
8891
9059
  await this.onConfigChanged();
8892
9060
  this.emitLifecycle(EventCategory.AddonUpdated, { level: "global" });
8893
- const legacy = this.globalSettingsSchema();
8894
- this.maybeAutoRestart(Object.keys(barePatch), [...legacy === null ? [] : [legacy], ...this.loadedScopedSchemas().map((entry) => entry.schema)]);
9061
+ this.maybeAutoRestart(Object.keys(barePatch), this.loadedScopedSchemas().map((entry) => entry.schema));
9062
+ return true;
8895
9063
  }
8896
9064
  /**
8897
9065
  * The set of field keys the global settings schema declares `perNode: true`
@@ -8928,10 +9096,7 @@ var BaseAddon = class {
8928
9096
  this._nodeKeysByCap = /* @__PURE__ */ new Map();
8929
9097
  this._addonSecretTree = null;
8930
9098
  }
8931
- /**
8932
- * The form the settings API serves while an addon declares no {@link globalSettingsSchema}: the
8933
- * sections of its scoped schemas, so dropping the old hook does not blank the form.
8934
- */
9099
+ /** The form of an addon's scoped schemas: their sections, each shown once. */
8935
9100
  scopedFormSchema(cap) {
8936
9101
  const byId = /* @__PURE__ */ new Map();
8937
9102
  for (const entry of this.loadedScopedSchemas(cap)) for (const section of entry.schema.sections) if (!byId.has(section.id)) byId.set(section.id, section);
@@ -8943,8 +9108,7 @@ var BaseAddon = class {
8943
9108
  return [...this._scopedByCap.values()].flat();
8944
9109
  }
8945
9110
  /**
8946
- * The field keys that are stored per node: every field of a {@link nodeSettingsSchema}, plus
8947
- * the fields the legacy {@link globalSettingsSchema} still marks `perNode: true`. Derived once
9111
+ * The field keys that are stored per node: every field of a {@link nodeSettingsSchema}. Derived once
8948
9112
  * per `cap` argument and memoized (schemas are static declarations). Empty set ⇒ every
8949
9113
  * per-node code path is bypassed and the settings API behaves like the node-agnostic one.
8950
9114
  * Call {@link loadScopedSchemas} first (the async seams do).
@@ -8953,8 +9117,7 @@ var BaseAddon = class {
8953
9117
  const cacheKey = cap ?? "";
8954
9118
  const cached = this._nodeKeysByCap.get(cacheKey);
8955
9119
  if (cached) return cached;
8956
- const legacy = this.globalSettingsSchema(cap);
8957
- const keys = new Set(legacy ? legacy.sections.flatMap((section) => collectPerNodeFieldKeys(section.fields)) : []);
9120
+ const keys = /* @__PURE__ */ new Set();
8958
9121
  for (const { scope, schema } of this.loadedScopedSchemas(cap)) {
8959
9122
  if (scope !== "node") continue;
8960
9123
  for (const section of schema.sections) for (const key of collectValueFieldKeys(section.fields)) keys.add(key);
@@ -9172,8 +9335,6 @@ var BaseAddon = class {
9172
9335
  }
9173
9336
  declaredKeySet() {
9174
9337
  const keys = new Set(Object.keys(this.defaults));
9175
- const schema = this.globalSettingsSchema();
9176
- if (schema) for (const section of schema.sections) for (const key of collectFieldKeys(section.fields)) keys.add(key);
9177
9338
  for (const { schema: scoped } of this.loadedScopedSchemas()) for (const section of scoped.sections) for (const key of collectFieldKeys(section.fields)) keys.add(key);
9178
9339
  for (const key of this._declaredStateKeys) keys.add(key);
9179
9340
  for (const path of this.secretStatePaths) {
@@ -9271,7 +9432,7 @@ var BaseAddon = class {
9271
9432
  /** The addon store's secret tree: schema fields by TYPE, plus `secretStatePaths`. */
9272
9433
  addonSecretTree() {
9273
9434
  if (this._addonSecretTree === null) {
9274
- const tree = new Map(secretTreeFromSchema(this.globalSettingsSchema()));
9435
+ const tree = /* @__PURE__ */ new Map();
9275
9436
  for (const { schema } of this.loadedScopedSchemas()) for (const [key, node] of secretTreeFromSchema(schema)) tree.set(key, node);
9276
9437
  this._addonSecretTree = mergeSecretStatePaths(tree, this.secretStatePaths);
9277
9438
  }
@@ -9483,47 +9644,6 @@ function classifyStoreError(err) {
9483
9644
  if (TRANSPORT_PHRASES.some((f) => message.includes(f))) return "transport";
9484
9645
  return "permanent";
9485
9646
  }
9486
- /**
9487
- * Collect the keys of every field marked `perNode: true`, recursing into
9488
- * layout containers (`group` fields and `sub-tabs` tabs) the same way
9489
- * `hydrateSchema` does. Valueless structural fields (separator/info/…)
9490
- * don't declare `perNode` and are excluded by the `in` narrowing.
9491
- */
9492
- /**
9493
- * The same fields with every `perNode: true` one removed, recursing into layout
9494
- * containers exactly as {@link collectPerNodeFieldKeys} does. A container left
9495
- * with no child is dropped rather than rendered empty.
9496
- *
9497
- * Used by `getIntegrationSettings`: an integration is not a node, so a field
9498
- * whose store key is `<key>@<nodeId>` has no node to belong to there.
9499
- */
9500
- function dropPerNodeFields(fields) {
9501
- const kept = [];
9502
- for (const field of fields) {
9503
- if (field.type === "group") {
9504
- const inner = dropPerNodeFields(field.fields);
9505
- if (inner.length > 0) kept.push({
9506
- ...field,
9507
- fields: inner
9508
- });
9509
- continue;
9510
- }
9511
- if (field.type === "sub-tabs") {
9512
- const tabs = field.tabs.map((tab) => ({
9513
- ...tab,
9514
- fields: dropPerNodeFields(tab.fields)
9515
- })).filter((tab) => tab.fields.length > 0);
9516
- if (tabs.length > 0) kept.push({
9517
- ...field,
9518
- tabs
9519
- });
9520
- continue;
9521
- }
9522
- if ("perNode" in field && field.perNode === true) continue;
9523
- kept.push(field);
9524
- }
9525
- return kept;
9526
- }
9527
9647
  /** The keys of the value-holding fields, recursing into layout containers. */
9528
9648
  function collectValueFieldKeys(fields) {
9529
9649
  const collected = [];
@@ -9556,21 +9676,6 @@ function collectFieldKeys(fields) {
9556
9676
  }
9557
9677
  return collected;
9558
9678
  }
9559
- function collectPerNodeFieldKeys(fields) {
9560
- const collected = [];
9561
- for (const field of fields) {
9562
- if (field.type === "group") {
9563
- collected.push(...collectPerNodeFieldKeys(field.fields));
9564
- continue;
9565
- }
9566
- if (field.type === "sub-tabs") {
9567
- for (const tab of field.tabs) collected.push(...collectPerNodeFieldKeys(tab.fields));
9568
- continue;
9569
- }
9570
- if ("perNode" in field && field.perNode === true) collected.push(field.key);
9571
- }
9572
- return collected;
9573
- }
9574
9679
  /**
9575
9680
  * Normalize an `ICamstackAddon.initialize()` return value into the
9576
9681
  * `AddonInitResult` envelope. Arrays are wrapped into `{ providers }`;
@@ -12327,13 +12432,25 @@ Object.freeze({
12327
12432
  "path": "temperature",
12328
12433
  "valueType": "number"
12329
12434
  }],
12330
- "timed-run": [{
12331
- "path": "activeUntil",
12332
- "valueType": "number"
12333
- }, {
12334
- "path": "requestedBy",
12335
- "valueType": "string"
12336
- }],
12435
+ "timed-run": [
12436
+ {
12437
+ "path": "activeUntil",
12438
+ "valueType": "number"
12439
+ },
12440
+ {
12441
+ "path": "requestedBy",
12442
+ "valueType": "string"
12443
+ },
12444
+ {
12445
+ "path": "quickRun.duration",
12446
+ "valueType": "number"
12447
+ },
12448
+ {
12449
+ "path": "quickRun.unit",
12450
+ "valueType": "enum",
12451
+ "enumValues": ["s", "min"]
12452
+ }
12453
+ ],
12337
12454
  "update": [
12338
12455
  {
12339
12456
  "path": "currentVersion",
@@ -12571,7 +12688,7 @@ function userError(code, params, englishFallback) {
12571
12688
  return new CamstackError(code, params, englishFallback);
12572
12689
  }
12573
12690
  //#endregion
12574
- //#region ../types/dist/composition-BmBHuDTQ.mjs
12691
+ //#region ../types/dist/composition-Bx0JvKUU.mjs
12575
12692
  /**
12576
12693
  * The ffmpeg build every CamStack node downloads, per platform and arch.
12577
12694
  *
@@ -13733,502 +13850,883 @@ var NodeColorIdSchema = _enum([
13733
13850
  "node-h"
13734
13851
  ]);
13735
13852
  //#endregion
13736
- //#region ../types/dist/index.mjs
13737
- var SMTP_INBOUND_DEFAULT_PORT = 2525;
13738
- var SMTP_INBOUND_DEFAULT_DOMAIN = "camstack.local";
13739
- var SMTP_INBOUND_DEFAULT_MAX_MESSAGE_BYTES = 25 * 1024 * 1024;
13740
- /** The text excerpt a message carries on the bus. */
13741
- var SMTP_INBOUND_TEXT_MAX_CHARS = 4096;
13742
- var PortSchema = number().int().min(1).max(65535);
13743
- /** A recipient local-part: what stands before the `@`. */
13744
- var SmtpRecipientSchema = string().min(1).max(64).regex(/^[A-Za-z0-9._+-]+$/);
13745
- object({
13746
- port: PortSchema.default(SMTP_INBOUND_DEFAULT_PORT),
13747
- bindHost: string().min(1).default("0.0.0.0"),
13748
- domain: string().min(1).default(SMTP_INBOUND_DEFAULT_DOMAIN),
13749
- requireAuth: boolean().default(true),
13750
- username: string().default(""),
13751
- password: string().default("").meta({ secret: true }),
13752
- starttls: boolean().default(false),
13753
- maxMessageBytes: number().int().positive().default(SMTP_INBOUND_DEFAULT_MAX_MESSAGE_BYTES)
13754
- });
13755
- object({
13756
- host: string().min(1).nullable(),
13757
- hostReason: string().nullable(),
13758
- port: PortSchema,
13759
- domain: string().min(1),
13760
- requireAuth: boolean(),
13761
- username: string(),
13762
- password: string(),
13763
- starttls: boolean()
13764
- });
13765
- object({
13766
- recipients: array(SmtpRecipientSchema).min(1),
13767
- /**
13768
- * Who is subscribing. A second `subscribe` with the same id REPLACES that subscriber's
13769
- * recipients (the way a consumer that restarted, or lost a device, declares its set again);
13770
- * a recipient held by another subscriber is refused with `smtp-inbound/recipient-claimed`.
13771
- * Absent, the subscriber is anonymous and unique.
13772
- */
13773
- subscriberId: string().min(1).max(64).optional()
13774
- });
13775
- var SmtpInboundAttachmentSchema = object({
13776
- filename: string(),
13777
- contentType: string(),
13778
- bytes: number().int().nonnegative()
13779
- });
13780
- /** The payload of one `broker.message` (the key is the recipient local-part). */
13781
- var SmtpInboundMessageSchema = object({
13782
- /** Monotonic per mailbox; a gap tells a consumer it missed an event. */
13783
- seq: number().int().positive(),
13784
- messageId: string(),
13785
- receivedAt: number().int().nonnegative(),
13786
- from: string(),
13787
- to: string(),
13788
- subject: string(),
13789
- text: string().max(SMTP_INBOUND_TEXT_MAX_CHARS),
13790
- attachments: array(SmtpInboundAttachmentSchema)
13791
- });
13792
- object({
13793
- /** The newest seq issued so far (0 before any mail). */
13794
- lastSeq: number().int().nonnegative(),
13795
- /** The oldest seq still retained; messages below it are gone. */
13796
- oldestSeq: number().int().nonnegative(),
13797
- messages: array(object({
13798
- key: string(),
13799
- message: SmtpInboundMessageSchema
13800
- }))
13801
- });
13802
- new Set([
13803
- "track",
13804
- "face",
13805
- "identity",
13806
- "plate",
13807
- "vehicle",
13808
- "scene",
13809
- "motion",
13810
- "object",
13811
- "audio",
13812
- "mosaic"
13813
- ]);
13814
- /**
13815
- * The owner types that are event tables, in the order the event-media resolver
13816
- * should consider them. Exported so a consumer asking "is this key an event?"
13817
- * does not re-spell the list and drift from it.
13818
- */
13819
- var EVENT_OWNER_TYPES = [
13820
- "motion",
13821
- "object",
13822
- "audio"
13823
- ];
13853
+ //#region ../types/dist/convert-BAzH15HU.mjs
13824
13854
  /**
13825
- * The same list as a Zod enum, for the cap inputs that must NAME the table
13826
- * (D482: `getEventMedia` / `listEventMedia` take an owner, and an owner is
13827
- * `(table, id)`).
13828
- *
13829
- * Built FROM {@link EVENT_OWNER_TYPES} rather than re-spelled: a fourth event
13830
- * table would otherwise be accepted by the codec and refused at the door, with
13831
- * nothing failing until a caller asked.
13832
- */
13833
- var EventOwnerTypeSchema = _enum(EVENT_OWNER_TYPES);
13834
- /** The same list as a Zod enum, for the cap input that carries it. */
13835
- var MediaPresenceOwnerKindSchema = _enum([...EVENT_OWNER_TYPES, "track"]);
13836
- new Set(EVENT_OWNER_TYPES);
13837
- /**
13838
- * What a published package needs from the rest of the install (D989).
13839
- *
13840
- * One published framework package (`@camstack/server`) carries the kernel, the
13841
- * builtins and the capability contracts, so "which addon runs on which
13842
- * framework" is a question of version, and the answer is DECLARED by the
13843
- * package that knows it:
13844
- *
13845
- * - an addon says the oldest server it runs on (`requires.server: ">=X"`);
13846
- * - the server says the oldest addon it works with
13847
- * (`requires.addons: { "@camstack/addon-x": ">=Y" }`) and the oldest shell
13848
- * around it (`requires.wrapper: { desktop, image }`);
13849
- * - a wrapper (the desktop app, the container starter) says which server
13850
- * range it can host, and reports its own kind and version.
13851
- *
13852
- * The publish workflow STAMPS these after the version bump
13853
- * (`scripts/ci/stamp-requires.mjs`); nobody types a number by hand.
13854
- *
13855
- * A requirement that cannot be checked is NOT met: a shell that reports no
13856
- * version counts as below the minimum, and an unreadable running version is
13857
- * named in the result (a failed read is never a pass).
13858
- */
13859
- /** `>=MAJOR.MINOR.PATCH[-prerelease]` — the only range shape a requirement uses. */
13860
- var MinVersionSchema = string().regex(/^>=\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/, "a minimum is written \">=X.Y.Z\"");
13861
- object({
13862
- /** The oldest `@camstack/server` this package runs on. */
13863
- server: MinVersionSchema.optional(),
13864
- /** The oldest installed version of each named addon package. */
13865
- addons: record(string(), MinVersionSchema).optional(),
13866
- /** The oldest shell around the server, per shell kind. */
13867
- wrapper: object({
13868
- desktop: MinVersionSchema.optional(),
13869
- image: MinVersionSchema.optional()
13870
- }).optional()
13871
- });
13872
- /** One unmet requirement; each names the versions on both sides. */
13873
- var UnmetRequirementSchema = discriminatedUnion("kind", [
13874
- object({
13875
- kind: literal("server-too-old"),
13876
- required: string(),
13877
- /** `null` — the running server version could not be read. */
13878
- running: string().nullable()
13879
- }),
13880
- object({
13881
- kind: literal("addon-too-old"),
13882
- packageName: string(),
13883
- required: string(),
13884
- /** `null` — the addon is not installed (or its version is unreadable). */
13885
- running: string().nullable()
13886
- }),
13887
- object({
13888
- kind: literal("wrapper-too-old"),
13889
- wrapper: _enum(["desktop", "image"]),
13890
- required: string(),
13891
- /** `null` — the wrapper reported no version. */
13892
- running: string().nullable()
13893
- })
13894
- ]);
13895
- var RequiredAddonUpdateSchema = object({
13896
- packageName: string(),
13897
- /** The minimum the target server declares. */
13898
- required: string(),
13899
- /** What this node has; `null` when the addon is not installed. */
13900
- installed: string().nullable()
13901
- });
13902
- /**
13903
- * Which integration instance a settings form is for. An addon that serves several instances
13904
- * (`instanceMode: multiple`) can show a different form per instance — an NVR integration
13905
- * needs its connection, the default integration needs the addon's own sections.
13906
- *
13907
- * - `integrationId` is `null` in the create wizard: nothing exists yet, the operator is
13908
- * setting up a NEW instance.
13909
- * - `mode` is the wizard mode of the integration type (`getAvailableTypes`).
13910
- * - `candidateStableId` is the discovery candidate a wizard was seeded from, if any.
13911
- */
13912
- var IntegrationInstanceRefSchema = object({
13913
- integrationId: string().nullable(),
13914
- mode: _enum([
13915
- "standalone",
13916
- "account",
13917
- "broker"
13918
- ]),
13919
- candidateStableId: string().optional()
13920
- });
13921
- /**
13922
- * Build an `IAddonRouteProvider` from a list of routes. Implements
13923
- * both the operator-facing `getRoutes` (returning route descriptors
13924
- * minus the handlers, which can't cross JSON) and the framework-
13925
- * private `invoke` method that the hub calls when this provider lives
13926
- * in a forked worker.
13927
- *
13928
- * Co-located addons use the returned `getRoutes` directly because
13929
- * their handlers don't need to cross any wire. The `invoke` method
13930
- * is present anyway so the bridge code on the hub is uniform — it
13931
- * doesn't need to switch on "local vs remote provider" at the call
13932
- * site.
13933
- *
13934
- * Example:
13935
- * const routes: IAddonHttpRoute[] = [
13936
- * { method: 'GET', path: '/start', access: 'public', handler: this.handleStart },
13937
- * ]
13938
- * return [
13939
- * {
13940
- * capability: addonRoutesCapability,
13941
- * provider: buildAddonRouteProvider('auth-oidc', routes),
13942
- * },
13943
- * ]
13944
- */
13945
- function buildAddonRouteProvider(id, routes) {
13946
- return {
13947
- id,
13948
- getRoutes: () => routes,
13949
- invoke: async (input) => {
13950
- const match = matchRoute(routes, input.method, input.path);
13951
- if (!match) return {
13952
- status: 404,
13953
- headers: {},
13954
- redirectUrl: null,
13955
- body: { error: `No route matches ${input.method} ${input.path}` }
13956
- };
13957
- const envelope = {
13958
- status: 200,
13959
- headers: {},
13960
- redirectUrl: null
13961
- };
13962
- const reply = buildCapturingReply(envelope);
13963
- const request = {
13964
- params: {
13965
- ...match.params,
13966
- ...input.params
13967
- },
13968
- query: input.query,
13969
- body: input.body,
13970
- headers: input.headers,
13971
- ...input.user ? { user: input.user } : {},
13972
- ...input.scopedToken !== void 0 ? { scopedToken: input.scopedToken } : {}
13973
- };
13974
- await match.route.handler(request, reply);
13975
- return envelope;
13976
- }
13977
- };
13978
- }
13979
- /**
13980
- * Pattern matcher: same semantics as `AddonRouteRegistry.matchRoute`
13981
- * but operating on a flat list and bypassing the `/addon/<id>/` prefix
13982
- * — the bridge sends the post-prefix path directly so we don't need
13983
- * to round-trip it through normalization.
13855
+ * Frozen unit → spec table. `•` in the doc marks each dimension's canonical
13856
+ * unit (`factor: 1, offset: 0`). °F derives from `°C = (°F − 32) × 5/9` ⇒
13857
+ * `factor = 5/9`, `offset = -160/9` (kept as exact expressions, not rounded
13858
+ * decimals). K derives from `°C = K − 273.15`.
13984
13859
  */
13985
- function matchRoute(routes, method, path) {
13986
- const normalizedMethod = method.toUpperCase();
13987
- for (const route of routes) {
13988
- if (route.method !== normalizedMethod) continue;
13989
- const params = matchPath(route.path, path);
13990
- if (params !== null) return {
13991
- route,
13992
- params
13993
- };
13994
- }
13995
- return null;
13996
- }
13997
- function matchPath(pattern, p) {
13998
- const patternParts = pattern.split("/").filter(Boolean);
13999
- const pathParts = p.split("/").filter(Boolean);
14000
- if (patternParts.length !== pathParts.length) return null;
14001
- const params = {};
14002
- for (let i = 0; i < patternParts.length; i++) {
14003
- const a = patternParts[i];
14004
- const b = pathParts[i];
14005
- if (a.startsWith(":")) params[a.slice(1)] = b;
14006
- else if (a !== b) return null;
14007
- }
14008
- return params;
14009
- }
14010
- function buildCapturingReply(envelope) {
14011
- const wrapper = {
14012
- status(code) {
14013
- envelope.status = code;
14014
- return wrapper;
14015
- },
14016
- code(code) {
14017
- envelope.status = code;
14018
- return wrapper;
14019
- },
14020
- send(data) {
14021
- envelope.body = data;
14022
- },
14023
- redirect(url) {
14024
- envelope.redirectUrl = url;
14025
- if (envelope.status === 200) envelope.status = 302;
14026
- },
14027
- header(name, value) {
14028
- envelope.headers[name.toLowerCase()] = value;
14029
- return wrapper;
14030
- },
14031
- type(mime) {
14032
- envelope.contentType = mime;
14033
- return wrapper;
14034
- }
14035
- };
14036
- return wrapper;
14037
- }
14038
- /** No part holds the id a call named. */
14039
- var UnknownInstanceError = class extends Error {
14040
- instanceId;
14041
- constructor(instanceId) {
14042
- super(`Unknown instance '${instanceId}'`);
14043
- this.instanceId = instanceId;
14044
- this.name = "UnknownInstanceError";
14045
- }
14046
- };
14047
- /** A create or a form named no kind, or a kind no part serves. */
14048
- var UnknownInstanceKindError = class extends Error {
14049
- kindId;
14050
- constructor(kindId) {
14051
- super(kindId === void 0 ? "This provider has several kinds: name one" : `Unknown instance kind '${kindId}'`);
14052
- this.kindId = kindId;
14053
- this.name = "UnknownInstanceKindError";
14054
- }
14055
- };
14056
- function composeInstanceKinds(parts, base) {
14057
- const byKind = (kindId) => {
14058
- if (kindId === void 0) {
14059
- const only = parts.length === 1 ? parts[0] : void 0;
14060
- if (only === void 0) throw new UnknownInstanceKindError(void 0);
14061
- return only;
14062
- }
14063
- const part = parts.find((candidate) => candidate.kind.id === kindId);
14064
- if (part === void 0) throw new UnknownInstanceKindError(kindId);
14065
- return part;
14066
- };
14067
- /**
14068
- * The part a DRAFT test runs on. A named kind is that kind; an unnamed one is the provider's only
14069
- * testable kind. Several testable kinds and none named is a typed refusal that lists them, never a throw.
14070
- */
14071
- const draftPart = (kindId) => {
14072
- if (kindId !== void 0) return parts.find((candidate) => candidate.kind.id === kindId) ?? { error: `Unknown instance kind '${kindId}'` };
14073
- const testable = parts.filter((part) => part.kind.canTest !== false);
14074
- const only = testable.length === 1 ? testable[0] : parts.length === 1 ? parts[0] : void 0;
14075
- if (only !== void 0) return only;
14076
- return { error: `This provider has several kinds: name one of ${(testable.length > 0 ? testable : parts).map((part) => part.kind.id).join(", ")}` };
14077
- };
14078
- const holderOf = async (id) => {
14079
- for (const part of parts) if (await part.provider.getInstance({ id }) !== null) return part;
14080
- throw new UnknownInstanceError(id);
14081
- };
14082
- const tagged = (part, instance) => ({
14083
- ...instance,
14084
- kind: part.kind.id
14085
- });
14086
- return {
14087
- describe: async () => {
14088
- const links = (await Promise.all(parts.map(async (part) => (await part.provider.describe()).links ?? []))).flat();
14089
- return {
14090
- ...base,
14091
- kinds: parts.map((part) => part.kind),
14092
- ...links.length > 0 ? { links } : {}
14093
- };
14094
- },
14095
- instanceFormSchema: async ({ id, kind }) => {
14096
- return (id === null ? byKind(kind) : await holderOf(id)).provider.instanceFormSchema({ id });
14097
- },
14098
- listInstances: async () => {
14099
- return (await Promise.all(parts.map(async (part) => (await part.provider.listInstances()).map((instance) => tagged(part, instance))))).flat();
14100
- },
14101
- getInstance: async ({ id }) => {
14102
- for (const part of parts) {
14103
- const found = await part.provider.getInstance({ id });
14104
- if (found !== null) return tagged(part, found);
14105
- }
14106
- return null;
14107
- },
14108
- createInstance: async (input) => byKind(input.kind).provider.createInstance(input),
14109
- updateInstance: async (input) => (await holderOf(input.id)).provider.updateInstance(input),
14110
- setInstanceEnabled: async (input) => (await holderOf(input.id)).provider.setInstanceEnabled(input),
14111
- deleteInstance: async (input) => (await holderOf(input.id)).provider.deleteInstance(input),
14112
- testInstance: async (input) => {
14113
- const resolved = input.id === null ? draftPart(input.kind) : await holderOf(input.id);
14114
- if ("error" in resolved) return {
14115
- success: false,
14116
- status: "inconclusive",
14117
- error: resolved.error
14118
- };
14119
- const test = resolved.provider.testInstance;
14120
- if (test === void 0) return {
14121
- success: false,
14122
- status: "inconclusive",
14123
- error: "This kind cannot be tested"
14124
- };
14125
- return test(input);
14126
- }
14127
- };
13860
+ var UNIT_TABLE = {
13861
+ "°C": {
13862
+ dimension: "temperature",
13863
+ factor: 1,
13864
+ offset: 0
13865
+ },
13866
+ "°F": {
13867
+ dimension: "temperature",
13868
+ factor: 5 / 9,
13869
+ offset: -160 / 9
13870
+ },
13871
+ K: {
13872
+ dimension: "temperature",
13873
+ factor: 1,
13874
+ offset: -273.15
13875
+ },
13876
+ hPa: {
13877
+ dimension: "pressure",
13878
+ factor: 1,
13879
+ offset: 0
13880
+ },
13881
+ kPa: {
13882
+ dimension: "pressure",
13883
+ factor: 10,
13884
+ offset: 0
13885
+ },
13886
+ Pa: {
13887
+ dimension: "pressure",
13888
+ factor: .01,
13889
+ offset: 0
13890
+ },
13891
+ mbar: {
13892
+ dimension: "pressure",
13893
+ factor: 1,
13894
+ offset: 0
13895
+ },
13896
+ bar: {
13897
+ dimension: "pressure",
13898
+ factor: 1e3,
13899
+ offset: 0
13900
+ },
13901
+ inHg: {
13902
+ dimension: "pressure",
13903
+ factor: 33.8639,
13904
+ offset: 0
13905
+ },
13906
+ mmHg: {
13907
+ dimension: "pressure",
13908
+ factor: 1.33322,
13909
+ offset: 0
13910
+ },
13911
+ psi: {
13912
+ dimension: "pressure",
13913
+ factor: 68.9476,
13914
+ offset: 0
13915
+ },
13916
+ "m/s": {
13917
+ dimension: "speed",
13918
+ factor: 1,
13919
+ offset: 0
13920
+ },
13921
+ "km/h": {
13922
+ dimension: "speed",
13923
+ factor: 1 / 3.6,
13924
+ offset: 0
13925
+ },
13926
+ mph: {
13927
+ dimension: "speed",
13928
+ factor: .44704,
13929
+ offset: 0
13930
+ },
13931
+ kn: {
13932
+ dimension: "speed",
13933
+ factor: .514444,
13934
+ offset: 0
13935
+ },
13936
+ "mm/h": {
13937
+ dimension: "precipitation-rate",
13938
+ factor: 1,
13939
+ offset: 0
13940
+ },
13941
+ "in/h": {
13942
+ dimension: "precipitation-rate",
13943
+ factor: 25.4,
13944
+ offset: 0
13945
+ },
13946
+ m: {
13947
+ dimension: "length",
13948
+ factor: 1,
13949
+ offset: 0
13950
+ },
13951
+ mm: {
13952
+ dimension: "length",
13953
+ factor: .001,
13954
+ offset: 0
13955
+ },
13956
+ cm: {
13957
+ dimension: "length",
13958
+ factor: .01,
13959
+ offset: 0
13960
+ },
13961
+ km: {
13962
+ dimension: "length",
13963
+ factor: 1e3,
13964
+ offset: 0
13965
+ },
13966
+ in: {
13967
+ dimension: "length",
13968
+ factor: .0254,
13969
+ offset: 0
13970
+ },
13971
+ ft: {
13972
+ dimension: "length",
13973
+ factor: .3048,
13974
+ offset: 0
13975
+ },
13976
+ mi: {
13977
+ dimension: "length",
13978
+ factor: 1609.344,
13979
+ offset: 0
13980
+ },
13981
+ "m²": {
13982
+ dimension: "area",
13983
+ factor: 1,
13984
+ offset: 0
13985
+ },
13986
+ lx: {
13987
+ dimension: "illuminance",
13988
+ factor: 1,
13989
+ offset: 0
13990
+ },
13991
+ "W/m²": {
13992
+ dimension: "irradiance",
13993
+ factor: 1,
13994
+ offset: 0
13995
+ },
13996
+ W: {
13997
+ dimension: "power",
13998
+ factor: 1,
13999
+ offset: 0
14000
+ },
14001
+ kW: {
14002
+ dimension: "power",
14003
+ factor: 1e3,
14004
+ offset: 0
14005
+ },
14006
+ VA: {
14007
+ dimension: "apparent-power",
14008
+ factor: 1,
14009
+ offset: 0
14010
+ },
14011
+ Wh: {
14012
+ dimension: "energy",
14013
+ factor: 1,
14014
+ offset: 0
14015
+ },
14016
+ kWh: {
14017
+ dimension: "energy",
14018
+ factor: 1e3,
14019
+ offset: 0
14020
+ },
14021
+ MWh: {
14022
+ dimension: "energy",
14023
+ factor: 1e6,
14024
+ offset: 0
14025
+ },
14026
+ V: {
14027
+ dimension: "voltage",
14028
+ factor: 1,
14029
+ offset: 0
14030
+ },
14031
+ mV: {
14032
+ dimension: "voltage",
14033
+ factor: .001,
14034
+ offset: 0
14035
+ },
14036
+ A: {
14037
+ dimension: "current",
14038
+ factor: 1,
14039
+ offset: 0
14040
+ },
14041
+ mA: {
14042
+ dimension: "current",
14043
+ factor: .001,
14044
+ offset: 0
14045
+ },
14046
+ ppm: {
14047
+ dimension: "concentration-volume",
14048
+ factor: 1,
14049
+ offset: 0
14050
+ },
14051
+ ppb: {
14052
+ dimension: "concentration-volume",
14053
+ factor: .001,
14054
+ offset: 0
14055
+ },
14056
+ "µg/m³": {
14057
+ dimension: "concentration-mass",
14058
+ factor: 1,
14059
+ offset: 0
14060
+ },
14061
+ "mg/m³": {
14062
+ dimension: "concentration-mass",
14063
+ factor: 1e3,
14064
+ offset: 0
14065
+ },
14066
+ kg: {
14067
+ dimension: "mass",
14068
+ factor: 1,
14069
+ offset: 0
14070
+ },
14071
+ g: {
14072
+ dimension: "mass",
14073
+ factor: .001,
14074
+ offset: 0
14075
+ },
14076
+ lb: {
14077
+ dimension: "mass",
14078
+ factor: .453592,
14079
+ offset: 0
14080
+ },
14081
+ oz: {
14082
+ dimension: "mass",
14083
+ factor: .0283495,
14084
+ offset: 0
14085
+ },
14086
+ "%": {
14087
+ dimension: "percentage",
14088
+ factor: 1,
14089
+ offset: 0
14090
+ },
14091
+ L: {
14092
+ dimension: "volume",
14093
+ factor: 1,
14094
+ offset: 0
14095
+ },
14096
+ mL: {
14097
+ dimension: "volume",
14098
+ factor: .001,
14099
+ offset: 0
14100
+ },
14101
+ gal: {
14102
+ dimension: "volume",
14103
+ factor: 3.785411784,
14104
+ offset: 0
14105
+ },
14106
+ s: {
14107
+ dimension: "duration",
14108
+ factor: 1,
14109
+ offset: 0
14110
+ },
14111
+ ms: {
14112
+ dimension: "duration",
14113
+ factor: .001,
14114
+ offset: 0
14115
+ },
14116
+ min: {
14117
+ dimension: "duration",
14118
+ factor: 60,
14119
+ offset: 0
14120
+ },
14121
+ h: {
14122
+ dimension: "duration",
14123
+ factor: 3600,
14124
+ offset: 0
14125
+ },
14126
+ d: {
14127
+ dimension: "duration",
14128
+ factor: 86400,
14129
+ offset: 0
14130
+ },
14131
+ B: {
14132
+ dimension: "information",
14133
+ factor: 1,
14134
+ offset: 0
14135
+ },
14136
+ KiB: {
14137
+ dimension: "information",
14138
+ factor: 1024,
14139
+ offset: 0
14140
+ },
14141
+ MiB: {
14142
+ dimension: "information",
14143
+ factor: 1024 ** 2,
14144
+ offset: 0
14145
+ },
14146
+ GiB: {
14147
+ dimension: "information",
14148
+ factor: 1024 ** 3,
14149
+ offset: 0
14150
+ },
14151
+ TiB: {
14152
+ dimension: "information",
14153
+ factor: 1024 ** 4,
14154
+ offset: 0
14155
+ },
14156
+ kB: {
14157
+ dimension: "information",
14158
+ factor: 1e3,
14159
+ offset: 0
14160
+ },
14161
+ MB: {
14162
+ dimension: "information",
14163
+ factor: 1e6,
14164
+ offset: 0
14165
+ },
14166
+ GB: {
14167
+ dimension: "information",
14168
+ factor: 1e9,
14169
+ offset: 0
14170
+ },
14171
+ "°": {
14172
+ dimension: "angle",
14173
+ factor: 1,
14174
+ offset: 0
14175
+ },
14176
+ dBFS: {
14177
+ dimension: "sound-level",
14178
+ factor: 1,
14179
+ offset: 0
14180
+ },
14181
+ dBm: {
14182
+ dimension: "signal-level",
14183
+ factor: 1,
14184
+ offset: 0
14185
+ },
14186
+ mired: {
14187
+ dimension: "colour-temperature",
14188
+ factor: 1,
14189
+ offset: 0
14190
+ },
14191
+ kbps: {
14192
+ dimension: "bitrate",
14193
+ factor: 1,
14194
+ offset: 0
14195
+ },
14196
+ Mbps: {
14197
+ dimension: "bitrate",
14198
+ factor: 1e3,
14199
+ offset: 0
14200
+ },
14201
+ fps: {
14202
+ dimension: "rate",
14203
+ factor: 1,
14204
+ offset: 0
14205
+ }
14206
+ };
14207
+ /** Resolve a unit's `UnitSpec`, or `undefined` when the spelling is unknown. */
14208
+ function specFor(unit) {
14209
+ return Object.hasOwn(UNIT_TABLE, unit) ? UNIT_TABLE[unit] : void 0;
14210
+ }
14211
+ /** Value in `spec`'s unit → its dimension's canonical unit. */
14212
+ function toCanonical(value, spec) {
14213
+ return value * spec.factor + spec.offset;
14214
+ }
14215
+ /** Canonical-unit value → the unit described by `spec`. */
14216
+ function fromCanonical(canonical, spec) {
14217
+ return (canonical - spec.offset) / spec.factor;
14128
14218
  }
14129
14219
  /**
14130
- * notification-output — canonical, capability-gated notification delivery.
14131
- *
14132
- * Apprise-derived model (see
14133
- * `docs/superpowers/specs/2026-07-03-notification-output-notifier-matrix.md`):
14134
- * callers emit ONE canonical `Notification`; each provider declares a
14135
- * per-kind capability descriptor (`TargetKind`), and the pure degrade
14136
- * engine (`@camstack/types` `prepareNotification`) transcodes / degrades the
14137
- * message to what the kind supports — callers never special-case a service.
14138
- *
14139
- * DESIGN DECISIONS (locked):
14140
- * - A target is an INSTANCE of this function cap (epic #440, step 6): the
14141
- * providers serve their targets as `integration-instances` records, one
14142
- * record kind per target kind, and the admin lists, adds, edits, switches
14143
- * and deletes them through that cap. This cap keeps what an instance cannot
14144
- * say: the kind catalogue (`listTargetKinds`: capabilities, delivery
14145
- * parameters, brand icon), dispatch (`send`, `testTarget`) and the form
14146
- * helpers (`discoverTargets`, `probeTargetConfig`).
14147
- * - `listTargetKinds` / `discoverTargets` return arrays → the generated
14148
- * cap-mount auto-`concatCollection`-fans them across every registered
14149
- * provider so one catalog is routable. `send` / `testTarget` /
14150
- * `probeTargetConfig` route to ONE provider by the `addonId` the generated
14151
- * collection router extracts from the call input.
14152
- * - `Attachment.bytes` is `Uint8Array`. Transport-safe: superjson (the tRPC
14153
- * transformer) + UDS MsgPack both round-trip typed arrays — already used by
14154
- * `storage` / `storage-provider` / `recording` caps over the same path. No
14155
- * base64 fallback needed.
14156
- *
14157
- * TODO (deferred, closed-set change — separate decision): add
14158
- * `providerKind: 'notify'` so notification providers surface on the unified
14159
- * admin "Integrations" page.
14160
- */
14161
- /**
14162
- * Zentik-derived typed-media enum — the superset across every kind. Each
14163
- * adapter picks what it supports and the degrade engine filters the rest.
14164
- */
14165
- var AttachmentMediaTypeSchema = _enum([
14166
- "image",
14167
- "video",
14168
- "gif",
14169
- "audio",
14170
- "icon"
14171
- ]);
14172
- /**
14173
- * A single attachment. Exactly one of `url` (remote source, most adapters
14174
- * prefer this) or `bytes` (inline source; required for Pushover-style
14175
- * bytes-only kinds) MUST be present — the degrade engine expresses a
14176
- * url→bytes fetch as a `needsFetch` directive the adapter executes.
14177
- */
14178
- var AttachmentSchema = object({
14179
- mediaType: AttachmentMediaTypeSchema,
14180
- url: string().optional(),
14181
- bytes: _instanceof(Uint8Array).optional(),
14182
- mime: string().optional(),
14183
- name: string().optional()
14184
- }).refine((a) => a.url !== void 0 || a.bytes !== void 0, { message: "Attachment requires either `url` or `bytes`" });
14185
- var NotificationFormatSchema = _enum([
14186
- "text",
14187
- "markdown",
14188
- "html"
14189
- ]);
14190
- /**
14191
- * The CLOSED icon vocabulary an action button may use.
14192
- *
14193
- * A closed set, not a free string, and that is the whole point: an arbitrary
14194
- * icon name is one that ntfy renders, zentik silently drops, and nobody
14195
- * notices — the same class of gap as a zone vocabulary nothing produced
14196
- * ([D35](../../../docs/decisions/adr-0035.md)). Every adapter maps this set or
14197
- * declares `actionIcons: false` and the degrade engine strips the field.
14198
- *
14199
- * Named by INTENT, never by glyph. "check" would tie the vocabulary to one
14200
- * renderer's icon set; "acknowledge" survives an adapter that draws it
14201
- * differently.
14202
- *
14203
- * ── A TOKEN IS NOT A WIRE VALUE ─────────────────────────────────────
14204
- *
14205
- * These names are for US. **No adapter may forward one verbatim.** Each maps
14206
- * the whole set onto its own renderer's vocabulary through a
14207
- * `Record<NotificationActionIcon, string>` — a Record, never a lookup with a
14208
- * fallback, so adding a member here fails every adapter's build until someone
14209
- * decides its glyph, which is the only place that decision can be made
14210
- * honestly.
14211
- *
14212
- * This paragraph is the bug. Zentik declared `actionIcons: true` and passed
14213
- * `disarm` straight through; iOS feeds that string to
14214
- * `UNNotificationActionIcon(systemImageName:)`, `disarm` is not an SF Symbol,
14215
- * and every snooze and alarm button arrived BLANK. A pass-through is not a
14216
- * mapping, and "the field is documented" is not "the value renders".
14217
- *
14218
- * Adding a member is TRAIN-BOUND. The enum lives in the published
14219
- * `@camstack/server` closure and the cap seam validates against the HUB's copy,
14220
- * so an addon that emits a token the running hub does not know does not lose an
14221
- * icon — its whole `send` fails Zod validation and the notification never
14222
- * arrives. Never emit a new token from an addon before the train carrying it.
14220
+ * Graceful variant for the render path: returns the converted value, or
14221
+ * `undefined` when the conversion is refused (unknown unit / cross-dimension).
14222
+ * Never throws — a render caller falls back to the raw value + source unit.
14223
14223
  */
14224
- var NotificationActionIconSchema = _enum([
14225
- "acknowledge",
14226
- "dismiss",
14227
- "silence",
14228
- "snooze",
14229
- "view",
14230
- "play",
14231
- "open",
14224
+ function tryConvertUnit(value, from, to) {
14225
+ if (from === to) return value;
14226
+ const fromSpec = specFor(from);
14227
+ if (!fromSpec) return void 0;
14228
+ const toSpec = specFor(to);
14229
+ if (!toSpec) return void 0;
14230
+ if (fromSpec.dimension !== toSpec.dimension) return void 0;
14231
+ return fromCanonical(toCanonical(value, fromSpec), toSpec);
14232
+ }
14233
+ //#endregion
14234
+ //#region ../types/dist/index.mjs
14235
+ var SMTP_INBOUND_DEFAULT_PORT = 2525;
14236
+ var SMTP_INBOUND_DEFAULT_DOMAIN = "camstack.local";
14237
+ var SMTP_INBOUND_DEFAULT_MAX_MESSAGE_BYTES = 25 * 1024 * 1024;
14238
+ /** The text excerpt a message carries on the bus. */
14239
+ var SMTP_INBOUND_TEXT_MAX_CHARS = 4096;
14240
+ var PortSchema = number().int().min(1).max(65535);
14241
+ /** A recipient local-part: what stands before the `@`. */
14242
+ var SmtpRecipientSchema = string().min(1).max(64).regex(/^[A-Za-z0-9._+-]+$/);
14243
+ object({
14244
+ port: PortSchema.default(SMTP_INBOUND_DEFAULT_PORT),
14245
+ bindHost: string().min(1).default("0.0.0.0"),
14246
+ domain: string().min(1).default(SMTP_INBOUND_DEFAULT_DOMAIN),
14247
+ requireAuth: boolean().default(true),
14248
+ username: string().default(""),
14249
+ password: string().default("").meta({ secret: true }),
14250
+ starttls: boolean().default(false),
14251
+ maxMessageBytes: number().int().positive().default(SMTP_INBOUND_DEFAULT_MAX_MESSAGE_BYTES)
14252
+ });
14253
+ object({
14254
+ host: string().min(1).nullable(),
14255
+ hostReason: string().nullable(),
14256
+ port: PortSchema,
14257
+ domain: string().min(1),
14258
+ requireAuth: boolean(),
14259
+ username: string(),
14260
+ password: string(),
14261
+ starttls: boolean()
14262
+ });
14263
+ object({
14264
+ recipients: array(SmtpRecipientSchema).min(1),
14265
+ /**
14266
+ * Who is subscribing. A second `subscribe` with the same id REPLACES that subscriber's
14267
+ * recipients (the way a consumer that restarted, or lost a device, declares its set again);
14268
+ * a recipient held by another subscriber is refused with `smtp-inbound/recipient-claimed`.
14269
+ * Absent, the subscriber is anonymous and unique.
14270
+ */
14271
+ subscriberId: string().min(1).max(64).optional()
14272
+ });
14273
+ var SmtpInboundAttachmentSchema = object({
14274
+ filename: string(),
14275
+ contentType: string(),
14276
+ bytes: number().int().nonnegative()
14277
+ });
14278
+ /** The payload of one `broker.message` (the key is the recipient local-part). */
14279
+ var SmtpInboundMessageSchema = object({
14280
+ /** Monotonic per mailbox; a gap tells a consumer it missed an event. */
14281
+ seq: number().int().positive(),
14282
+ messageId: string(),
14283
+ receivedAt: number().int().nonnegative(),
14284
+ from: string(),
14285
+ to: string(),
14286
+ subject: string(),
14287
+ text: string().max(SMTP_INBOUND_TEXT_MAX_CHARS),
14288
+ attachments: array(SmtpInboundAttachmentSchema)
14289
+ });
14290
+ object({
14291
+ /** The newest seq issued so far (0 before any mail). */
14292
+ lastSeq: number().int().nonnegative(),
14293
+ /** The oldest seq still retained; messages below it are gone. */
14294
+ oldestSeq: number().int().nonnegative(),
14295
+ messages: array(object({
14296
+ key: string(),
14297
+ message: SmtpInboundMessageSchema
14298
+ }))
14299
+ });
14300
+ new Set([
14301
+ "track",
14302
+ "face",
14303
+ "identity",
14304
+ "plate",
14305
+ "vehicle",
14306
+ "scene",
14307
+ "motion",
14308
+ "object",
14309
+ "audio",
14310
+ "mosaic"
14311
+ ]);
14312
+ /**
14313
+ * The owner types that are event tables, in the order the event-media resolver
14314
+ * should consider them. Exported so a consumer asking "is this key an event?"
14315
+ * does not re-spell the list and drift from it.
14316
+ */
14317
+ var EVENT_OWNER_TYPES = [
14318
+ "motion",
14319
+ "object",
14320
+ "audio"
14321
+ ];
14322
+ /**
14323
+ * The same list as a Zod enum, for the cap inputs that must NAME the table
14324
+ * (D482: `getEventMedia` / `listEventMedia` take an owner, and an owner is
14325
+ * `(table, id)`).
14326
+ *
14327
+ * Built FROM {@link EVENT_OWNER_TYPES} rather than re-spelled: a fourth event
14328
+ * table would otherwise be accepted by the codec and refused at the door, with
14329
+ * nothing failing until a caller asked.
14330
+ */
14331
+ var EventOwnerTypeSchema = _enum(EVENT_OWNER_TYPES);
14332
+ /** The same list as a Zod enum, for the cap input that carries it. */
14333
+ var MediaPresenceOwnerKindSchema = _enum([...EVENT_OWNER_TYPES, "track"]);
14334
+ new Set(EVENT_OWNER_TYPES);
14335
+ /**
14336
+ * What a published package needs from the rest of the install (D989).
14337
+ *
14338
+ * One published framework package (`@camstack/server`) carries the kernel, the
14339
+ * builtins and the capability contracts, so "which addon runs on which
14340
+ * framework" is a question of version, and the answer is DECLARED by the
14341
+ * package that knows it:
14342
+ *
14343
+ * - an addon says the oldest server it runs on (`requires.server: ">=X"`);
14344
+ * - the server says the oldest addon it works with
14345
+ * (`requires.addons: { "@camstack/addon-x": ">=Y" }`) and the oldest shell
14346
+ * around it (`requires.wrapper: { desktop, image }`);
14347
+ * - a wrapper (the desktop app, the container starter) says which server
14348
+ * range it can host, and reports its own kind and version.
14349
+ *
14350
+ * The publish workflow STAMPS these after the version bump
14351
+ * (`scripts/ci/stamp-requires.mjs`); nobody types a number by hand.
14352
+ *
14353
+ * A requirement that cannot be checked is NOT met: a shell that reports no
14354
+ * version counts as below the minimum, and an unreadable running version is
14355
+ * named in the result (a failed read is never a pass).
14356
+ */
14357
+ /** `>=MAJOR.MINOR.PATCH[-prerelease]` — the only range shape a requirement uses. */
14358
+ var MinVersionSchema = string().regex(/^>=\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/, "a minimum is written \">=X.Y.Z\"");
14359
+ object({
14360
+ /** The oldest `@camstack/server` this package runs on. */
14361
+ server: MinVersionSchema.optional(),
14362
+ /** The oldest installed version of each named addon package. */
14363
+ addons: record(string(), MinVersionSchema).optional(),
14364
+ /** The oldest shell around the server, per shell kind. */
14365
+ wrapper: object({
14366
+ desktop: MinVersionSchema.optional(),
14367
+ image: MinVersionSchema.optional()
14368
+ }).optional()
14369
+ });
14370
+ /** One unmet requirement; each names the versions on both sides. */
14371
+ var UnmetRequirementSchema = discriminatedUnion("kind", [
14372
+ object({
14373
+ kind: literal("server-too-old"),
14374
+ required: string(),
14375
+ /** `null` — the running server version could not be read. */
14376
+ running: string().nullable()
14377
+ }),
14378
+ object({
14379
+ kind: literal("addon-too-old"),
14380
+ packageName: string(),
14381
+ required: string(),
14382
+ /** `null` — the addon is not installed (or its version is unreadable). */
14383
+ running: string().nullable()
14384
+ }),
14385
+ object({
14386
+ kind: literal("wrapper-too-old"),
14387
+ wrapper: _enum(["desktop", "image"]),
14388
+ required: string(),
14389
+ /** `null` — the wrapper reported no version. */
14390
+ running: string().nullable()
14391
+ })
14392
+ ]);
14393
+ var RequiredAddonUpdateSchema = object({
14394
+ packageName: string(),
14395
+ /** The minimum the target server declares. */
14396
+ required: string(),
14397
+ /** What this node has; `null` when the addon is not installed. */
14398
+ installed: string().nullable()
14399
+ });
14400
+ /**
14401
+ * Which integration instance a settings form is for. An addon that serves several instances
14402
+ * (`instanceMode: multiple`) can show a different form per instance — an NVR integration
14403
+ * needs its connection, the default integration needs the addon's own sections.
14404
+ *
14405
+ * - `integrationId` is `null` in the create wizard: nothing exists yet, the operator is
14406
+ * setting up a NEW instance.
14407
+ * - `mode` is the wizard mode of the integration type (`getAvailableTypes`).
14408
+ * - `candidateStableId` is the discovery candidate a wizard was seeded from, if any.
14409
+ */
14410
+ var IntegrationInstanceRefSchema = object({
14411
+ integrationId: string().nullable(),
14412
+ mode: _enum([
14413
+ "standalone",
14414
+ "account",
14415
+ "broker"
14416
+ ]),
14417
+ candidateStableId: string().optional()
14418
+ });
14419
+ /**
14420
+ * Build an `IAddonRouteProvider` from a list of routes. Implements
14421
+ * both the operator-facing `getRoutes` (returning route descriptors
14422
+ * minus the handlers, which can't cross JSON) and the framework-
14423
+ * private `invoke` method that the hub calls when this provider lives
14424
+ * in a forked worker.
14425
+ *
14426
+ * Co-located addons use the returned `getRoutes` directly because
14427
+ * their handlers don't need to cross any wire. The `invoke` method
14428
+ * is present anyway so the bridge code on the hub is uniform — it
14429
+ * doesn't need to switch on "local vs remote provider" at the call
14430
+ * site.
14431
+ *
14432
+ * Example:
14433
+ * const routes: IAddonHttpRoute[] = [
14434
+ * { method: 'GET', path: '/start', access: 'public', handler: this.handleStart },
14435
+ * ]
14436
+ * return [
14437
+ * {
14438
+ * capability: addonRoutesCapability,
14439
+ * provider: buildAddonRouteProvider('auth-oidc', routes),
14440
+ * },
14441
+ * ]
14442
+ */
14443
+ function buildAddonRouteProvider(id, routes) {
14444
+ return {
14445
+ id,
14446
+ getRoutes: () => routes,
14447
+ invoke: async (input) => {
14448
+ const match = matchRoute(routes, input.method, input.path);
14449
+ if (!match) return {
14450
+ status: 404,
14451
+ headers: {},
14452
+ redirectUrl: null,
14453
+ body: { error: `No route matches ${input.method} ${input.path}` }
14454
+ };
14455
+ const envelope = {
14456
+ status: 200,
14457
+ headers: {},
14458
+ redirectUrl: null
14459
+ };
14460
+ const reply = buildCapturingReply(envelope);
14461
+ const request = {
14462
+ params: {
14463
+ ...match.params,
14464
+ ...input.params
14465
+ },
14466
+ query: input.query,
14467
+ body: input.body,
14468
+ headers: input.headers,
14469
+ ...input.user ? { user: input.user } : {},
14470
+ ...input.scopedToken !== void 0 ? { scopedToken: input.scopedToken } : {}
14471
+ };
14472
+ await match.route.handler(request, reply);
14473
+ return envelope;
14474
+ }
14475
+ };
14476
+ }
14477
+ /**
14478
+ * Pattern matcher: same semantics as `AddonRouteRegistry.matchRoute`
14479
+ * but operating on a flat list and bypassing the `/addon/<id>/` prefix
14480
+ * — the bridge sends the post-prefix path directly so we don't need
14481
+ * to round-trip it through normalization.
14482
+ */
14483
+ function matchRoute(routes, method, path) {
14484
+ const normalizedMethod = method.toUpperCase();
14485
+ for (const route of routes) {
14486
+ if (route.method !== normalizedMethod) continue;
14487
+ const params = matchPath(route.path, path);
14488
+ if (params !== null) return {
14489
+ route,
14490
+ params
14491
+ };
14492
+ }
14493
+ return null;
14494
+ }
14495
+ function matchPath(pattern, p) {
14496
+ const patternParts = pattern.split("/").filter(Boolean);
14497
+ const pathParts = p.split("/").filter(Boolean);
14498
+ if (patternParts.length !== pathParts.length) return null;
14499
+ const params = {};
14500
+ for (let i = 0; i < patternParts.length; i++) {
14501
+ const a = patternParts[i];
14502
+ const b = pathParts[i];
14503
+ if (a.startsWith(":")) params[a.slice(1)] = b;
14504
+ else if (a !== b) return null;
14505
+ }
14506
+ return params;
14507
+ }
14508
+ function buildCapturingReply(envelope) {
14509
+ const wrapper = {
14510
+ status(code) {
14511
+ envelope.status = code;
14512
+ return wrapper;
14513
+ },
14514
+ code(code) {
14515
+ envelope.status = code;
14516
+ return wrapper;
14517
+ },
14518
+ send(data) {
14519
+ envelope.body = data;
14520
+ },
14521
+ redirect(url) {
14522
+ envelope.redirectUrl = url;
14523
+ if (envelope.status === 200) envelope.status = 302;
14524
+ },
14525
+ header(name, value) {
14526
+ envelope.headers[name.toLowerCase()] = value;
14527
+ return wrapper;
14528
+ },
14529
+ type(mime) {
14530
+ envelope.contentType = mime;
14531
+ return wrapper;
14532
+ }
14533
+ };
14534
+ return wrapper;
14535
+ }
14536
+ /** No part holds the id a call named. */
14537
+ var UnknownInstanceError = class extends Error {
14538
+ instanceId;
14539
+ constructor(instanceId) {
14540
+ super(`Unknown instance '${instanceId}'`);
14541
+ this.instanceId = instanceId;
14542
+ this.name = "UnknownInstanceError";
14543
+ }
14544
+ };
14545
+ /** A create or a form named no kind, or a kind no part serves. */
14546
+ var UnknownInstanceKindError = class extends Error {
14547
+ kindId;
14548
+ constructor(kindId) {
14549
+ super(kindId === void 0 ? "This provider has several kinds: name one" : `Unknown instance kind '${kindId}'`);
14550
+ this.kindId = kindId;
14551
+ this.name = "UnknownInstanceKindError";
14552
+ }
14553
+ };
14554
+ function composeInstanceKinds(parts, base) {
14555
+ const byKind = (kindId) => {
14556
+ if (kindId === void 0) {
14557
+ const only = parts.length === 1 ? parts[0] : void 0;
14558
+ if (only === void 0) throw new UnknownInstanceKindError(void 0);
14559
+ return only;
14560
+ }
14561
+ const part = parts.find((candidate) => candidate.kind.id === kindId);
14562
+ if (part === void 0) throw new UnknownInstanceKindError(kindId);
14563
+ return part;
14564
+ };
14565
+ /**
14566
+ * The part a DRAFT test runs on. A named kind is that kind; an unnamed one is the provider's only
14567
+ * testable kind. Several testable kinds and none named is a typed refusal that lists them, never a throw.
14568
+ */
14569
+ const draftPart = (kindId) => {
14570
+ if (kindId !== void 0) return parts.find((candidate) => candidate.kind.id === kindId) ?? { error: `Unknown instance kind '${kindId}'` };
14571
+ const testable = parts.filter((part) => part.kind.canTest !== false);
14572
+ const only = testable.length === 1 ? testable[0] : parts.length === 1 ? parts[0] : void 0;
14573
+ if (only !== void 0) return only;
14574
+ return { error: `This provider has several kinds: name one of ${(testable.length > 0 ? testable : parts).map((part) => part.kind.id).join(", ")}` };
14575
+ };
14576
+ const holderOf = async (id) => {
14577
+ for (const part of parts) if (await part.provider.getInstance({ id }) !== null) return part;
14578
+ throw new UnknownInstanceError(id);
14579
+ };
14580
+ const tagged = (part, instance) => ({
14581
+ ...instance,
14582
+ kind: part.kind.id
14583
+ });
14584
+ return {
14585
+ describe: async () => {
14586
+ const links = (await Promise.all(parts.map(async (part) => (await part.provider.describe()).links ?? []))).flat();
14587
+ return {
14588
+ ...base,
14589
+ kinds: parts.map((part) => part.kind),
14590
+ ...links.length > 0 ? { links } : {}
14591
+ };
14592
+ },
14593
+ instanceFormSchema: async ({ id, kind }) => {
14594
+ return (id === null ? byKind(kind) : await holderOf(id)).provider.instanceFormSchema({ id });
14595
+ },
14596
+ listInstances: async () => {
14597
+ return (await Promise.all(parts.map(async (part) => (await part.provider.listInstances()).map((instance) => tagged(part, instance))))).flat();
14598
+ },
14599
+ getInstance: async ({ id }) => {
14600
+ for (const part of parts) {
14601
+ const found = await part.provider.getInstance({ id });
14602
+ if (found !== null) return tagged(part, found);
14603
+ }
14604
+ return null;
14605
+ },
14606
+ createInstance: async (input) => byKind(input.kind).provider.createInstance(input),
14607
+ updateInstance: async (input) => (await holderOf(input.id)).provider.updateInstance(input),
14608
+ setInstanceEnabled: async (input) => (await holderOf(input.id)).provider.setInstanceEnabled(input),
14609
+ deleteInstance: async (input) => (await holderOf(input.id)).provider.deleteInstance(input),
14610
+ testInstance: async (input) => {
14611
+ const resolved = input.id === null ? draftPart(input.kind) : await holderOf(input.id);
14612
+ if ("error" in resolved) return {
14613
+ success: false,
14614
+ status: "inconclusive",
14615
+ error: resolved.error
14616
+ };
14617
+ const test = resolved.provider.testInstance;
14618
+ if (test === void 0) return {
14619
+ success: false,
14620
+ status: "inconclusive",
14621
+ error: "This kind cannot be tested"
14622
+ };
14623
+ return test(input);
14624
+ }
14625
+ };
14626
+ }
14627
+ /**
14628
+ * notification-output — canonical, capability-gated notification delivery.
14629
+ *
14630
+ * Apprise-derived model (see
14631
+ * `docs/superpowers/specs/2026-07-03-notification-output-notifier-matrix.md`):
14632
+ * callers emit ONE canonical `Notification`; each provider declares a
14633
+ * per-kind capability descriptor (`TargetKind`), and the pure degrade
14634
+ * engine (`@camstack/types` `prepareNotification`) transcodes / degrades the
14635
+ * message to what the kind supports — callers never special-case a service.
14636
+ *
14637
+ * DESIGN DECISIONS (locked):
14638
+ * - A target is an INSTANCE of this function cap (epic #440, step 6): the
14639
+ * providers serve their targets as `integration-instances` records, one
14640
+ * record kind per target kind, and the admin lists, adds, edits, switches
14641
+ * and deletes them through that cap. This cap keeps what an instance cannot
14642
+ * say: the kind catalogue (`listTargetKinds`: capabilities, delivery
14643
+ * parameters, brand icon), dispatch (`send`, `testTarget`) and the form
14644
+ * helpers (`discoverTargets`, `probeTargetConfig`).
14645
+ * - `listTargetKinds` / `discoverTargets` return arrays → the generated
14646
+ * cap-mount auto-`concatCollection`-fans them across every registered
14647
+ * provider so one catalog is routable. `send` / `testTarget` /
14648
+ * `probeTargetConfig` route to ONE provider by the `addonId` the generated
14649
+ * collection router extracts from the call input.
14650
+ * - `Attachment.bytes` is `Uint8Array`. Transport-safe: superjson (the tRPC
14651
+ * transformer) + UDS MsgPack both round-trip typed arrays — already used by
14652
+ * `storage` / `storage-provider` / `recording` caps over the same path. No
14653
+ * base64 fallback needed.
14654
+ *
14655
+ * TODO (deferred, closed-set change — separate decision): add
14656
+ * `providerKind: 'notify'` so notification providers surface on the unified
14657
+ * admin "Integrations" page.
14658
+ */
14659
+ /**
14660
+ * Zentik-derived typed-media enum — the superset across every kind. Each
14661
+ * adapter picks what it supports and the degrade engine filters the rest.
14662
+ */
14663
+ var AttachmentMediaTypeSchema = _enum([
14664
+ "image",
14665
+ "video",
14666
+ "gif",
14667
+ "audio",
14668
+ "icon"
14669
+ ]);
14670
+ /**
14671
+ * A single attachment. Exactly one of `url` (remote source, most adapters
14672
+ * prefer this) or `bytes` (inline source; required for Pushover-style
14673
+ * bytes-only kinds) MUST be present — the degrade engine expresses a
14674
+ * url→bytes fetch as a `needsFetch` directive the adapter executes.
14675
+ */
14676
+ var AttachmentSchema = object({
14677
+ mediaType: AttachmentMediaTypeSchema,
14678
+ url: string().optional(),
14679
+ bytes: _instanceof(Uint8Array).optional(),
14680
+ mime: string().optional(),
14681
+ name: string().optional()
14682
+ }).refine((a) => a.url !== void 0 || a.bytes !== void 0, { message: "Attachment requires either `url` or `bytes`" });
14683
+ var NotificationFormatSchema = _enum([
14684
+ "text",
14685
+ "markdown",
14686
+ "html"
14687
+ ]);
14688
+ /**
14689
+ * The CLOSED icon vocabulary an action button may use.
14690
+ *
14691
+ * A closed set, not a free string, and that is the whole point: an arbitrary
14692
+ * icon name is one that ntfy renders, zentik silently drops, and nobody
14693
+ * notices — the same class of gap as a zone vocabulary nothing produced
14694
+ * ([D35](../../../docs/decisions/adr-0035.md)). Every adapter maps this set or
14695
+ * declares `actionIcons: false` and the degrade engine strips the field.
14696
+ *
14697
+ * Named by INTENT, never by glyph. "check" would tie the vocabulary to one
14698
+ * renderer's icon set; "acknowledge" survives an adapter that draws it
14699
+ * differently.
14700
+ *
14701
+ * ── A TOKEN IS NOT A WIRE VALUE ─────────────────────────────────────
14702
+ *
14703
+ * These names are for US. **No adapter may forward one verbatim.** Each maps
14704
+ * the whole set onto its own renderer's vocabulary through a
14705
+ * `Record<NotificationActionIcon, string>` — a Record, never a lookup with a
14706
+ * fallback, so adding a member here fails every adapter's build until someone
14707
+ * decides its glyph, which is the only place that decision can be made
14708
+ * honestly.
14709
+ *
14710
+ * This paragraph is the bug. Zentik declared `actionIcons: true` and passed
14711
+ * `disarm` straight through; iOS feeds that string to
14712
+ * `UNNotificationActionIcon(systemImageName:)`, `disarm` is not an SF Symbol,
14713
+ * and every snooze and alarm button arrived BLANK. A pass-through is not a
14714
+ * mapping, and "the field is documented" is not "the value renders".
14715
+ *
14716
+ * Adding a member is TRAIN-BOUND. The enum lives in the published
14717
+ * `@camstack/server` closure and the cap seam validates against the HUB's copy,
14718
+ * so an addon that emits a token the running hub does not know does not lose an
14719
+ * icon — its whole `send` fails Zod validation and the notification never
14720
+ * arrives. Never emit a new token from an addon before the train carrying it.
14721
+ */
14722
+ var NotificationActionIconSchema = _enum([
14723
+ "acknowledge",
14724
+ "dismiss",
14725
+ "silence",
14726
+ "snooze",
14727
+ "view",
14728
+ "play",
14729
+ "open",
14232
14730
  "close",
14233
14731
  "lock",
14234
14732
  "unlock",
@@ -42528,738 +43026,360 @@ var ICON_SYNONYMS = {
42528
43026
  ],
42529
43027
  "toggle-right": [
42530
43028
  "interruttore",
42531
- "switch",
42532
- "toggle",
42533
- "acceso"
42534
- ],
42535
- tractor: [
42536
- "trattore",
42537
- "tosaerba",
42538
- "giardino"
42539
- ],
42540
- "train-front": ["treno"],
42541
- "tree-pine": [
42542
- "albero",
42543
- "pino",
42544
- "giardino"
42545
- ],
42546
- trees: [
42547
- "giardino",
42548
- "alberi",
42549
- "esterno",
42550
- "parco",
42551
- "garden"
42552
- ],
42553
- truck: [
42554
- "camion",
42555
- "furgone",
42556
- "consegna"
42557
- ],
42558
- tv: ["televisore"],
42559
- umbrella: [
42560
- "tenda",
42561
- "markise",
42562
- "ombrellone",
42563
- "pergola",
42564
- "veranda",
42565
- "terrace"
42566
- ],
42567
- user: ["persona", "utente"],
42568
- users: [
42569
- "persone",
42570
- "gruppo",
42571
- "famiglia"
42572
- ],
42573
- utensils: [
42574
- "cucina",
42575
- "posate",
42576
- "sala",
42577
- "pranzo",
42578
- "kitchen"
42579
- ],
42580
- vibrate: [
42581
- "vibrazione",
42582
- "urto",
42583
- "tamper"
42584
- ],
42585
- video: [],
42586
- "video-off": ["video", "spento"],
42587
- "volume-2": [
42588
- "volume",
42589
- "audio",
42590
- "altoparlante",
42591
- "suono"
42592
- ],
42593
- "volume-x": [
42594
- "muto",
42595
- "silenziato",
42596
- "volume",
42597
- "spento"
42598
- ],
42599
- warehouse: [
42600
- "magazzino",
42601
- "capanno",
42602
- "deposito",
42603
- "cantina",
42604
- "warehouse"
42605
- ],
42606
- "washing-machine": [
42607
- "lavatrice",
42608
- "lavastoviglie",
42609
- "asciugatrice"
42610
- ],
42611
- waves: [
42612
- "onde",
42613
- "acqua",
42614
- "vibrazione"
42615
- ],
42616
- "waves-ladder": [
42617
- "piscina",
42618
- "vasca",
42619
- "pool"
42620
- ],
42621
- wifi: ["rete", "segnale"],
42622
- "wifi-off": [
42623
- "wifi",
42624
- "assente",
42625
- "offline",
42626
- "disconnesso"
42627
- ],
42628
- wind: [
42629
- "vento",
42630
- "aria",
42631
- "gas",
42632
- "monossido",
42633
- "co"
42634
- ],
42635
- workflow: [
42636
- "automazione",
42637
- "flusso",
42638
- "scenario"
42639
- ],
42640
- wrench: [
42641
- "officina",
42642
- "manutenzione",
42643
- "attrezzi",
42644
- "utenze"
42645
- ],
42646
- zap: [
42647
- "energia",
42648
- "corrente",
42649
- "consumo",
42650
- "watt",
42651
- "elettricita"
42652
- ]
42653
- };
42654
- /**
42655
- * Spellings an operator may already have PERSISTED that no lucide package
42656
- * accepts any more, and the catalog key they draw. They resolve
42657
- * (`canonicalIconKey`) but are never offered. `gauge2` was this repo's own
42658
- * pre-catalog spelling of `gauge`.
42659
- */
42660
- var LEGACY_ICON_ALIASES = { gauge2: "gauge" };
42661
- /**
42662
- * icon-catalog — THE icon vocabulary (D854).
42663
- *
42664
- * Every place that picks or stores an icon — a device's display override, a
42665
- * location, a site zone, a viewer accessory, an `icon` settings field — names
42666
- * it by a key of this catalog and nothing else. There is no curated subset and
42667
- * no second list: the catalog is every lucide icon that both surfaces can draw
42668
- * (the admin UI's `lucide-react` and the viewer's `lucide-react-native`), the
42669
- * intersection computed by `scripts/generate-icon-catalog.ts` into the
42670
- * committed `icon-catalog.generated.ts`.
42671
- *
42672
- * Like the vocabularies it replaces, this module holds STRINGS only — no React,
42673
- * no lucide import, no glyph. Each surface owns its glyph table (the web one
42674
- * lives in `ui-library`, loaded lazily), and a test on each side pins that it
42675
- * covers {@link ICON_KEYS}.
42676
- *
42677
- * ## What is persisted
42678
- *
42679
- * The canonical kebab-case lucide id ({@link IconKey}). Spellings that a lucide
42680
- * package still accepts (`home` for `house`) and this repo's own legacy ones
42681
- * (`gauge2`) RESOLVE ({@link canonicalIconKey}) so a stored value never goes
42682
- * stale, but they are never OFFERED by search.
42683
- *
42684
- * ## Search
42685
- *
42686
- * Over the key, the Italian/English overlay in `icon-synonyms.ts`, any words
42687
- * the caller adds, and lucide's English tags — in that order of weight.
42688
- */
42689
- var KEY_SET$2 = new Set(ICON_KEYS);
42690
- new Map([...Object.entries(ICON_ALIASES), ...Object.entries(LEGACY_ICON_ALIASES)]);
42691
- /** Is this a canonical key of the catalog? (An alias is not — see {@link canonicalIconKey}.) */
42692
- function isIconKey(value) {
42693
- return typeof value === "string" && KEY_SET$2.has(value);
42694
- }
42695
- /**
42696
- * Wire/storage schema for one icon key.
42697
- *
42698
- * Deliberately NOT a `z.enum` of the catalog: ~1,700 literals would be inlined
42699
- * into every cap router, type and API-reference row that carries an icon. The
42700
- * check is membership in the catalog and the type is {@link IconKey}.
42701
- */
42702
- var IconKeySchema = custom(isIconKey, "Not an icon of the catalog");
42703
- var ENTRIES = ICON_KEYS.map((key, index) => {
42704
- const [categoryIndexes, tags] = ICON_META[index] ?? [[], []];
42705
- const categories = categoryIndexes.flatMap((i) => {
42706
- const id = ICON_CATEGORY_IDS[i];
42707
- return id === void 0 ? [] : [id];
42708
- });
42709
- return {
42710
- key,
42711
- categories,
42712
- primaryCategory: categories[0] ?? ICON_CATEGORY_IDS[0],
42713
- tags,
42714
- synonyms: ICON_SYNONYMS[key] ?? []
42715
- };
42716
- });
42717
- new Map(ENTRIES.map((e) => [e.key, e]));
42718
- /** Categories a picker offers first: the ones this product's icons mostly live in. */
42719
- var LEADING_CATEGORIES = [
42720
- "devices",
42721
- "home",
42722
- "security",
42723
- "buildings",
42724
- "connectivity",
42725
- "multimedia",
42726
- "nature",
42727
- "weather",
42728
- "transportation",
42729
- "animals"
42730
- ];
42731
- var PRIMARY_CATEGORIES = new Set(ENTRIES.map((e) => e.primaryCategory));
42732
- [...LEADING_CATEGORIES, ...ICON_CATEGORY_IDS.filter((c) => !LEADING_CATEGORIES.includes(c))].filter((c) => PRIMARY_CATEGORIES.has(c));
42733
- /**
42734
- * One (location, icon) assignment as it travels.
42735
- *
42736
- * `locationKey` is ALWAYS {@link locationIconKey} of the label — never the
42737
- * operator's casing. The registry matches labels trimmed + case-insensitively
42738
- * everywhere (`addLocation`, `removeLocation`, `renameLocation`), and a
42739
- * case-only rename (`kitchen` → `Kitchen`) is a real edit this repo supports; a
42740
- * map keyed on the displayed casing would orphan its entry on exactly that
42741
- * edit. Consumers join by keying their own label through `locationIconKey`.
42742
- */
42743
- var LocationIconAssignmentSchema = object({
42744
- locationKey: string(),
42745
- icon: IconKeySchema
42746
- });
42747
- /**
42748
- * One (location, description) assignment as it travels.
42749
- *
42750
- * Same key discipline as {@link LocationIconAssignmentSchema}, and for the same
42751
- * reason: this lived for a while in the post-analysis addon's own settings,
42752
- * keyed by a HAND-TYPED label matched exactly and case-sensitively. A rename in
42753
- * the Locations page carried the icon and silently orphaned the description —
42754
- * two authorities for one thing, joined by two different rules. Now both hang
42755
- * off the location itself, on `locationIconKey`.
42756
- */
42757
- var LocationDescriptionAssignmentSchema = object({
42758
- locationKey: string(),
42759
- description: string()
42760
- });
42761
- /**
42762
- * WHO an action step acts on (D983) — one target shape for every place that
42763
- * performs an action (automation steps, Action entries, notification
42764
- * sequences, scheduler points):
42765
- *
42766
- * - `devices` — device ids the operator picked, frozen at save time;
42767
- * - `domain` — every device that offers the step's VOCABULARY action,
42768
- * optionally narrowed to one device type and one room, resolved WHEN THE
42769
- * STEP RUNS (a device added later is included). The action always comes
42770
- * from the vocabulary (`cover.open`), never from a raw cap name, so a
42771
- * `domain` target needs an `actionId` (enforced on the step schema);
42772
- * - `var` — a `$var` resolved from the run's scope.
42773
- *
42774
- * {@link ActionTargetResolver} turns a target into device ids. It is pure
42775
- * (a {@link DeviceDirectory} is handed in) so the hub's core-blocks service and
42776
- * a generated automation block share it.
42777
- *
42778
- * A room is the device's `location` label (`DeviceInfo.location`); two labels
42779
- * name the same room when `locationIconKey` agrees (trimmed, lower-cased).
42780
- */
42781
- var ActionTargetSchema = discriminatedUnion("kind", [
42782
- object({
42783
- kind: literal("devices"),
42784
- ids: array(number().int()).min(1)
42785
- }),
42786
- object({
42787
- kind: literal("domain"),
42788
- /** Narrow to one kind of device. */
42789
- deviceType: _enum(DeviceType).optional(),
42790
- /** Narrow to one room: the `location` label devices carry. */
42791
- roomId: string().min(1).optional()
42792
- }),
42793
- object({
42794
- kind: literal("var"),
42795
- name: string().min(1)
42796
- })
42797
- ]);
42798
- EventCategory.DeviceRegistered, EventCategory.DeviceUnregistered, EventCategory.DeviceUpdated, EventCategory.DeviceMetaChanged, EventCategory.DeviceBindingsChanged;
42799
- var ExpressionBindingSourceSchema = union([
42800
- object({
42801
- kind: literal("field").optional(),
42802
- sourceKey: string(),
42803
- cap: string(),
42804
- fieldPath: string()
42805
- }),
42806
- object({
42807
- kind: literal("literal"),
42808
- value: union([
42809
- string(),
42810
- number(),
42811
- boolean(),
42812
- _null()
42813
- ])
42814
- }),
42815
- object({
42816
- kind: literal("global"),
42817
- sourceStableId: string(),
42818
- cap: string(),
42819
- fieldPath: string()
42820
- })
42821
- ]);
42822
- object({
42823
- expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
42824
- bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
42825
- }).superRefine((src, ctx) => {
42826
- const err = validateExpressionSource(src);
42827
- if (err !== null) ctx.addIssue({
42828
- code: "custom",
42829
- message: err,
42830
- path: ["expr"]
42831
- });
42832
- });
42833
- /**
42834
- * Identity envelope for a device's upstream-system metadata.
42835
- *
42836
- * Two jobs:
42837
- *
42838
- * 1. **Dispatch key.** `id` is the natural identifier the SOURCE system
42839
- * uses to address this device (HA `entity_id`, Reolink MAC, ONVIF
42840
- * WS-Discovery endpoint UUID, …). Provider code keeps a
42841
- * `Map<sourceInfo.id, IDevice>` to route inbound push events in O(1)
42842
- * without round-tripping through `stableId` / `id` mappings.
42843
- *
42844
- * 2. **Stability bridge.** `uniqueId` is the *immutable* upstream id when
42845
- * one exists (HA `entity.unique_id`, Reolink MAC again — same as `id`
42846
- * for vendors where MAC is the natural key). For systems where the
42847
- * live dispatch key can be renamed (HA `entity_id` can be edited),
42848
- * providers detect renames by matching `uniqueId` and patch `id`
42849
- * in-place so dispatch keeps working.
42850
- *
42851
- * **Display unit and precision are NOT stored here.** They are a read-time
42852
- * preference (D944). A polymorphic reading stores its `quantity` here once,
42853
- * at adopt, and a date/time value stores `dateTimeShape` the same way —
42854
- * neither is repeated on each sample. The `device_class` tag is an
42855
- * HA-specific input consumed at the discovery boundary (`ha-domain-mapping`)
42856
- * to derive the CamStack `DeviceRole`; it is never stored after that point.
42857
- *
42858
- * The `raw` escape hatch carries provider-specific attributes that don't
42859
- * fit the structured slots (HA `attributes` whitelist, Reolink `deviceCache`
42860
- * details, …). Providers control what lands here.
42861
- */
42862
- var SourceInfoSchema = object({
42863
- /** Live dispatch key — mutable when the source system allows rename. */
42864
- id: string(),
42865
- /** Source system tag — e.g. 'homeassistant' | 'reolink' | 'onvif'. */
42866
- system: string(),
42867
- /** Immutable upstream identifier when available (HA `unique_id`, … ).
42868
- * Used to detect rename when `id` changes. */
42869
- uniqueId: string().optional(),
42870
- /** Quantity of a polymorphic reading, written once at adopt (D944). */
42871
- quantity: ReadingQuantitySchema.optional(),
42872
- /** Date / time / datetime shape of a string reading, written once at adopt. */
42873
- dateTimeShape: _enum([
42874
- "date",
42875
- "time",
42876
- "datetime"
42877
- ]).optional(),
42878
- /** Provider-specific extras that don't fit the structured slots.
42879
- * Whitelist what goes here per provider — do NOT dump entire HA
42880
- * attribute blobs (would explode the meta JSON column). */
42881
- raw: record(string(), unknown()).optional()
42882
- });
42883
- /**
42884
- * Frozen unit → spec table. `•` in the doc marks each dimension's canonical
42885
- * unit (`factor: 1, offset: 0`). °F derives from `°C = (°F − 32) × 5/9` ⇒
42886
- * `factor = 5/9`, `offset = -160/9` (kept as exact expressions, not rounded
42887
- * decimals). K derives from `°C = K − 273.15`.
42888
- */
42889
- var UNIT_TABLE = {
42890
- "°C": {
42891
- dimension: "temperature",
42892
- factor: 1,
42893
- offset: 0
42894
- },
42895
- "°F": {
42896
- dimension: "temperature",
42897
- factor: 5 / 9,
42898
- offset: -160 / 9
42899
- },
42900
- K: {
42901
- dimension: "temperature",
42902
- factor: 1,
42903
- offset: -273.15
42904
- },
42905
- hPa: {
42906
- dimension: "pressure",
42907
- factor: 1,
42908
- offset: 0
42909
- },
42910
- kPa: {
42911
- dimension: "pressure",
42912
- factor: 10,
42913
- offset: 0
42914
- },
42915
- Pa: {
42916
- dimension: "pressure",
42917
- factor: .01,
42918
- offset: 0
42919
- },
42920
- mbar: {
42921
- dimension: "pressure",
42922
- factor: 1,
42923
- offset: 0
42924
- },
42925
- bar: {
42926
- dimension: "pressure",
42927
- factor: 1e3,
42928
- offset: 0
42929
- },
42930
- inHg: {
42931
- dimension: "pressure",
42932
- factor: 33.8639,
42933
- offset: 0
42934
- },
42935
- mmHg: {
42936
- dimension: "pressure",
42937
- factor: 1.33322,
42938
- offset: 0
42939
- },
42940
- psi: {
42941
- dimension: "pressure",
42942
- factor: 68.9476,
42943
- offset: 0
42944
- },
42945
- "m/s": {
42946
- dimension: "speed",
42947
- factor: 1,
42948
- offset: 0
42949
- },
42950
- "km/h": {
42951
- dimension: "speed",
42952
- factor: 1 / 3.6,
42953
- offset: 0
42954
- },
42955
- mph: {
42956
- dimension: "speed",
42957
- factor: .44704,
42958
- offset: 0
42959
- },
42960
- kn: {
42961
- dimension: "speed",
42962
- factor: .514444,
42963
- offset: 0
42964
- },
42965
- "mm/h": {
42966
- dimension: "precipitation-rate",
42967
- factor: 1,
42968
- offset: 0
42969
- },
42970
- "in/h": {
42971
- dimension: "precipitation-rate",
42972
- factor: 25.4,
42973
- offset: 0
42974
- },
42975
- m: {
42976
- dimension: "length",
42977
- factor: 1,
42978
- offset: 0
42979
- },
42980
- mm: {
42981
- dimension: "length",
42982
- factor: .001,
42983
- offset: 0
42984
- },
42985
- cm: {
42986
- dimension: "length",
42987
- factor: .01,
42988
- offset: 0
42989
- },
42990
- km: {
42991
- dimension: "length",
42992
- factor: 1e3,
42993
- offset: 0
42994
- },
42995
- in: {
42996
- dimension: "length",
42997
- factor: .0254,
42998
- offset: 0
42999
- },
43000
- ft: {
43001
- dimension: "length",
43002
- factor: .3048,
43003
- offset: 0
43004
- },
43005
- mi: {
43006
- dimension: "length",
43007
- factor: 1609.344,
43008
- offset: 0
43009
- },
43010
- "m²": {
43011
- dimension: "area",
43012
- factor: 1,
43013
- offset: 0
43014
- },
43015
- lx: {
43016
- dimension: "illuminance",
43017
- factor: 1,
43018
- offset: 0
43019
- },
43020
- "W/m²": {
43021
- dimension: "irradiance",
43022
- factor: 1,
43023
- offset: 0
43024
- },
43025
- W: {
43026
- dimension: "power",
43027
- factor: 1,
43028
- offset: 0
43029
- },
43030
- kW: {
43031
- dimension: "power",
43032
- factor: 1e3,
43033
- offset: 0
43034
- },
43035
- VA: {
43036
- dimension: "apparent-power",
43037
- factor: 1,
43038
- offset: 0
43039
- },
43040
- Wh: {
43041
- dimension: "energy",
43042
- factor: 1,
43043
- offset: 0
43044
- },
43045
- kWh: {
43046
- dimension: "energy",
43047
- factor: 1e3,
43048
- offset: 0
43049
- },
43050
- MWh: {
43051
- dimension: "energy",
43052
- factor: 1e6,
43053
- offset: 0
43054
- },
43055
- V: {
43056
- dimension: "voltage",
43057
- factor: 1,
43058
- offset: 0
43059
- },
43060
- mV: {
43061
- dimension: "voltage",
43062
- factor: .001,
43063
- offset: 0
43064
- },
43065
- A: {
43066
- dimension: "current",
43067
- factor: 1,
43068
- offset: 0
43069
- },
43070
- mA: {
43071
- dimension: "current",
43072
- factor: .001,
43073
- offset: 0
43074
- },
43075
- ppm: {
43076
- dimension: "concentration-volume",
43077
- factor: 1,
43078
- offset: 0
43079
- },
43080
- ppb: {
43081
- dimension: "concentration-volume",
43082
- factor: .001,
43083
- offset: 0
43084
- },
43085
- "µg/m³": {
43086
- dimension: "concentration-mass",
43087
- factor: 1,
43088
- offset: 0
43089
- },
43090
- "mg/m³": {
43091
- dimension: "concentration-mass",
43092
- factor: 1e3,
43093
- offset: 0
43094
- },
43095
- kg: {
43096
- dimension: "mass",
43097
- factor: 1,
43098
- offset: 0
43099
- },
43100
- g: {
43101
- dimension: "mass",
43102
- factor: .001,
43103
- offset: 0
43104
- },
43105
- lb: {
43106
- dimension: "mass",
43107
- factor: .453592,
43108
- offset: 0
43109
- },
43110
- oz: {
43111
- dimension: "mass",
43112
- factor: .0283495,
43113
- offset: 0
43114
- },
43115
- "%": {
43116
- dimension: "percentage",
43117
- factor: 1,
43118
- offset: 0
43119
- },
43120
- L: {
43121
- dimension: "volume",
43122
- factor: 1,
43123
- offset: 0
43124
- },
43125
- mL: {
43126
- dimension: "volume",
43127
- factor: .001,
43128
- offset: 0
43129
- },
43130
- gal: {
43131
- dimension: "volume",
43132
- factor: 3.785411784,
43133
- offset: 0
43134
- },
43135
- s: {
43136
- dimension: "duration",
43137
- factor: 1,
43138
- offset: 0
43139
- },
43140
- ms: {
43141
- dimension: "duration",
43142
- factor: .001,
43143
- offset: 0
43144
- },
43145
- min: {
43146
- dimension: "duration",
43147
- factor: 60,
43148
- offset: 0
43149
- },
43150
- h: {
43151
- dimension: "duration",
43152
- factor: 3600,
43153
- offset: 0
43154
- },
43155
- d: {
43156
- dimension: "duration",
43157
- factor: 86400,
43158
- offset: 0
43159
- },
43160
- B: {
43161
- dimension: "information",
43162
- factor: 1,
43163
- offset: 0
43164
- },
43165
- KiB: {
43166
- dimension: "information",
43167
- factor: 1024,
43168
- offset: 0
43169
- },
43170
- MiB: {
43171
- dimension: "information",
43172
- factor: 1024 ** 2,
43173
- offset: 0
43174
- },
43175
- GiB: {
43176
- dimension: "information",
43177
- factor: 1024 ** 3,
43178
- offset: 0
43179
- },
43180
- TiB: {
43181
- dimension: "information",
43182
- factor: 1024 ** 4,
43183
- offset: 0
43184
- },
43185
- kB: {
43186
- dimension: "information",
43187
- factor: 1e3,
43188
- offset: 0
43189
- },
43190
- MB: {
43191
- dimension: "information",
43192
- factor: 1e6,
43193
- offset: 0
43194
- },
43195
- GB: {
43196
- dimension: "information",
43197
- factor: 1e9,
43198
- offset: 0
43199
- },
43200
- "°": {
43201
- dimension: "angle",
43202
- factor: 1,
43203
- offset: 0
43204
- },
43205
- dBFS: {
43206
- dimension: "sound-level",
43207
- factor: 1,
43208
- offset: 0
43209
- },
43210
- dBm: {
43211
- dimension: "signal-level",
43212
- factor: 1,
43213
- offset: 0
43214
- },
43215
- mired: {
43216
- dimension: "colour-temperature",
43217
- factor: 1,
43218
- offset: 0
43219
- },
43220
- kbps: {
43221
- dimension: "bitrate",
43222
- factor: 1,
43223
- offset: 0
43224
- },
43225
- Mbps: {
43226
- dimension: "bitrate",
43227
- factor: 1e3,
43228
- offset: 0
43229
- },
43230
- fps: {
43231
- dimension: "rate",
43232
- factor: 1,
43233
- offset: 0
43234
- }
43029
+ "switch",
43030
+ "toggle",
43031
+ "acceso"
43032
+ ],
43033
+ tractor: [
43034
+ "trattore",
43035
+ "tosaerba",
43036
+ "giardino"
43037
+ ],
43038
+ "train-front": ["treno"],
43039
+ "tree-pine": [
43040
+ "albero",
43041
+ "pino",
43042
+ "giardino"
43043
+ ],
43044
+ trees: [
43045
+ "giardino",
43046
+ "alberi",
43047
+ "esterno",
43048
+ "parco",
43049
+ "garden"
43050
+ ],
43051
+ truck: [
43052
+ "camion",
43053
+ "furgone",
43054
+ "consegna"
43055
+ ],
43056
+ tv: ["televisore"],
43057
+ umbrella: [
43058
+ "tenda",
43059
+ "markise",
43060
+ "ombrellone",
43061
+ "pergola",
43062
+ "veranda",
43063
+ "terrace"
43064
+ ],
43065
+ user: ["persona", "utente"],
43066
+ users: [
43067
+ "persone",
43068
+ "gruppo",
43069
+ "famiglia"
43070
+ ],
43071
+ utensils: [
43072
+ "cucina",
43073
+ "posate",
43074
+ "sala",
43075
+ "pranzo",
43076
+ "kitchen"
43077
+ ],
43078
+ vibrate: [
43079
+ "vibrazione",
43080
+ "urto",
43081
+ "tamper"
43082
+ ],
43083
+ video: [],
43084
+ "video-off": ["video", "spento"],
43085
+ "volume-2": [
43086
+ "volume",
43087
+ "audio",
43088
+ "altoparlante",
43089
+ "suono"
43090
+ ],
43091
+ "volume-x": [
43092
+ "muto",
43093
+ "silenziato",
43094
+ "volume",
43095
+ "spento"
43096
+ ],
43097
+ warehouse: [
43098
+ "magazzino",
43099
+ "capanno",
43100
+ "deposito",
43101
+ "cantina",
43102
+ "warehouse"
43103
+ ],
43104
+ "washing-machine": [
43105
+ "lavatrice",
43106
+ "lavastoviglie",
43107
+ "asciugatrice"
43108
+ ],
43109
+ waves: [
43110
+ "onde",
43111
+ "acqua",
43112
+ "vibrazione"
43113
+ ],
43114
+ "waves-ladder": [
43115
+ "piscina",
43116
+ "vasca",
43117
+ "pool"
43118
+ ],
43119
+ wifi: ["rete", "segnale"],
43120
+ "wifi-off": [
43121
+ "wifi",
43122
+ "assente",
43123
+ "offline",
43124
+ "disconnesso"
43125
+ ],
43126
+ wind: [
43127
+ "vento",
43128
+ "aria",
43129
+ "gas",
43130
+ "monossido",
43131
+ "co"
43132
+ ],
43133
+ workflow: [
43134
+ "automazione",
43135
+ "flusso",
43136
+ "scenario"
43137
+ ],
43138
+ wrench: [
43139
+ "officina",
43140
+ "manutenzione",
43141
+ "attrezzi",
43142
+ "utenze"
43143
+ ],
43144
+ zap: [
43145
+ "energia",
43146
+ "corrente",
43147
+ "consumo",
43148
+ "watt",
43149
+ "elettricita"
43150
+ ]
43235
43151
  };
43236
- /** Resolve a unit's `UnitSpec`, or `undefined` when the spelling is unknown. */
43237
- function specFor(unit) {
43238
- return Object.hasOwn(UNIT_TABLE, unit) ? UNIT_TABLE[unit] : void 0;
43239
- }
43240
- /** Value in `spec`'s unit → its dimension's canonical unit. */
43241
- function toCanonical(value, spec) {
43242
- return value * spec.factor + spec.offset;
43243
- }
43244
- /** Canonical-unit value → the unit described by `spec`. */
43245
- function fromCanonical(canonical, spec) {
43246
- return (canonical - spec.offset) / spec.factor;
43247
- }
43248
43152
  /**
43249
- * Graceful variant for the render path: returns the converted value, or
43250
- * `undefined` when the conversion is refused (unknown unit / cross-dimension).
43251
- * Never throws — a render caller falls back to the raw value + source unit.
43153
+ * Spellings an operator may already have PERSISTED that no lucide package
43154
+ * accepts any more, and the catalog key they draw. They resolve
43155
+ * (`canonicalIconKey`) but are never offered. `gauge2` was this repo's own
43156
+ * pre-catalog spelling of `gauge`.
43252
43157
  */
43253
- function tryConvertUnit(value, from, to) {
43254
- if (from === to) return value;
43255
- const fromSpec = specFor(from);
43256
- if (!fromSpec) return void 0;
43257
- const toSpec = specFor(to);
43258
- if (!toSpec) return void 0;
43259
- if (fromSpec.dimension !== toSpec.dimension) return void 0;
43260
- return fromCanonical(toCanonical(value, fromSpec), toSpec);
43158
+ var LEGACY_ICON_ALIASES = { gauge2: "gauge" };
43159
+ /**
43160
+ * icon-catalog — THE icon vocabulary (D854).
43161
+ *
43162
+ * Every place that picks or stores an icon — a device's display override, a
43163
+ * location, a site zone, a viewer accessory, an `icon` settings field — names
43164
+ * it by a key of this catalog and nothing else. There is no curated subset and
43165
+ * no second list: the catalog is every lucide icon that both surfaces can draw
43166
+ * (the admin UI's `lucide-react` and the viewer's `lucide-react-native`), the
43167
+ * intersection computed by `scripts/generate-icon-catalog.ts` into the
43168
+ * committed `icon-catalog.generated.ts`.
43169
+ *
43170
+ * Like the vocabularies it replaces, this module holds STRINGS only — no React,
43171
+ * no lucide import, no glyph. Each surface owns its glyph table (the web one
43172
+ * lives in `ui-library`, loaded lazily), and a test on each side pins that it
43173
+ * covers {@link ICON_KEYS}.
43174
+ *
43175
+ * ## What is persisted
43176
+ *
43177
+ * The canonical kebab-case lucide id ({@link IconKey}). Spellings that a lucide
43178
+ * package still accepts (`home` for `house`) and this repo's own legacy ones
43179
+ * (`gauge2`) RESOLVE ({@link canonicalIconKey}) so a stored value never goes
43180
+ * stale, but they are never OFFERED by search.
43181
+ *
43182
+ * ## Search
43183
+ *
43184
+ * Over the key, the Italian/English overlay in `icon-synonyms.ts`, any words
43185
+ * the caller adds, and lucide's English tags — in that order of weight.
43186
+ */
43187
+ var KEY_SET$2 = new Set(ICON_KEYS);
43188
+ new Map([...Object.entries(ICON_ALIASES), ...Object.entries(LEGACY_ICON_ALIASES)]);
43189
+ /** Is this a canonical key of the catalog? (An alias is not — see {@link canonicalIconKey}.) */
43190
+ function isIconKey(value) {
43191
+ return typeof value === "string" && KEY_SET$2.has(value);
43261
43192
  }
43262
43193
  /**
43194
+ * Wire/storage schema for one icon key.
43195
+ *
43196
+ * Deliberately NOT a `z.enum` of the catalog: ~1,700 literals would be inlined
43197
+ * into every cap router, type and API-reference row that carries an icon. The
43198
+ * check is membership in the catalog and the type is {@link IconKey}.
43199
+ */
43200
+ var IconKeySchema = custom(isIconKey, "Not an icon of the catalog");
43201
+ var ENTRIES = ICON_KEYS.map((key, index) => {
43202
+ const [categoryIndexes, tags] = ICON_META[index] ?? [[], []];
43203
+ const categories = categoryIndexes.flatMap((i) => {
43204
+ const id = ICON_CATEGORY_IDS[i];
43205
+ return id === void 0 ? [] : [id];
43206
+ });
43207
+ return {
43208
+ key,
43209
+ categories,
43210
+ primaryCategory: categories[0] ?? ICON_CATEGORY_IDS[0],
43211
+ tags,
43212
+ synonyms: ICON_SYNONYMS[key] ?? []
43213
+ };
43214
+ });
43215
+ new Map(ENTRIES.map((e) => [e.key, e]));
43216
+ /** Categories a picker offers first: the ones this product's icons mostly live in. */
43217
+ var LEADING_CATEGORIES = [
43218
+ "devices",
43219
+ "home",
43220
+ "security",
43221
+ "buildings",
43222
+ "connectivity",
43223
+ "multimedia",
43224
+ "nature",
43225
+ "weather",
43226
+ "transportation",
43227
+ "animals"
43228
+ ];
43229
+ var PRIMARY_CATEGORIES = new Set(ENTRIES.map((e) => e.primaryCategory));
43230
+ [...LEADING_CATEGORIES, ...ICON_CATEGORY_IDS.filter((c) => !LEADING_CATEGORIES.includes(c))].filter((c) => PRIMARY_CATEGORIES.has(c));
43231
+ /**
43232
+ * One (location, icon) assignment as it travels.
43233
+ *
43234
+ * `locationKey` is ALWAYS {@link locationIconKey} of the label — never the
43235
+ * operator's casing. The registry matches labels trimmed + case-insensitively
43236
+ * everywhere (`addLocation`, `removeLocation`, `renameLocation`), and a
43237
+ * case-only rename (`kitchen` → `Kitchen`) is a real edit this repo supports; a
43238
+ * map keyed on the displayed casing would orphan its entry on exactly that
43239
+ * edit. Consumers join by keying their own label through `locationIconKey`.
43240
+ */
43241
+ var LocationIconAssignmentSchema = object({
43242
+ locationKey: string(),
43243
+ icon: IconKeySchema
43244
+ });
43245
+ /**
43246
+ * One (location, description) assignment as it travels.
43247
+ *
43248
+ * Same key discipline as {@link LocationIconAssignmentSchema}, and for the same
43249
+ * reason: this lived for a while in the post-analysis addon's own settings,
43250
+ * keyed by a HAND-TYPED label matched exactly and case-sensitively. A rename in
43251
+ * the Locations page carried the icon and silently orphaned the description —
43252
+ * two authorities for one thing, joined by two different rules. Now both hang
43253
+ * off the location itself, on `locationIconKey`.
43254
+ */
43255
+ var LocationDescriptionAssignmentSchema = object({
43256
+ locationKey: string(),
43257
+ description: string()
43258
+ });
43259
+ /**
43260
+ * WHO an action step acts on (D983) — one target shape for every place that
43261
+ * performs an action (automation steps, Action entries, notification
43262
+ * sequences, scheduler points):
43263
+ *
43264
+ * - `devices` — device ids the operator picked, frozen at save time;
43265
+ * - `domain` — every device that offers the step's VOCABULARY action,
43266
+ * optionally narrowed to one device type and one room, resolved WHEN THE
43267
+ * STEP RUNS (a device added later is included). The action always comes
43268
+ * from the vocabulary (`cover.open`), never from a raw cap name, so a
43269
+ * `domain` target needs an `actionId` (enforced on the step schema);
43270
+ * - `var` — a `$var` resolved from the run's scope.
43271
+ *
43272
+ * {@link ActionTargetResolver} turns a target into device ids. It is pure
43273
+ * (a {@link DeviceDirectory} is handed in) so the hub's core-blocks service and
43274
+ * a generated automation block share it.
43275
+ *
43276
+ * A room is the device's `location` label (`DeviceInfo.location`); two labels
43277
+ * name the same room when `locationIconKey` agrees (trimmed, lower-cased).
43278
+ */
43279
+ var ActionTargetSchema = discriminatedUnion("kind", [
43280
+ object({
43281
+ kind: literal("devices"),
43282
+ ids: array(number().int()).min(1)
43283
+ }),
43284
+ object({
43285
+ kind: literal("domain"),
43286
+ /** Narrow to one kind of device. */
43287
+ deviceType: _enum(DeviceType).optional(),
43288
+ /** Narrow to one room: the `location` label devices carry. */
43289
+ roomId: string().min(1).optional()
43290
+ }),
43291
+ object({
43292
+ kind: literal("var"),
43293
+ name: string().min(1)
43294
+ })
43295
+ ]);
43296
+ EventCategory.DeviceRegistered, EventCategory.DeviceUnregistered, EventCategory.DeviceUpdated, EventCategory.DeviceMetaChanged, EventCategory.DeviceBindingsChanged;
43297
+ var ExpressionBindingSourceSchema = union([
43298
+ object({
43299
+ kind: literal("field").optional(),
43300
+ sourceKey: string(),
43301
+ cap: string(),
43302
+ fieldPath: string()
43303
+ }),
43304
+ object({
43305
+ kind: literal("literal"),
43306
+ value: union([
43307
+ string(),
43308
+ number(),
43309
+ boolean(),
43310
+ _null()
43311
+ ])
43312
+ }),
43313
+ object({
43314
+ kind: literal("global"),
43315
+ sourceStableId: string(),
43316
+ addonId: string().optional(),
43317
+ cap: string(),
43318
+ fieldPath: string()
43319
+ })
43320
+ ]);
43321
+ object({
43322
+ expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
43323
+ bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
43324
+ }).superRefine((src, ctx) => {
43325
+ const err = validateExpressionSource(src);
43326
+ if (err !== null) ctx.addIssue({
43327
+ code: "custom",
43328
+ message: err,
43329
+ path: ["expr"]
43330
+ });
43331
+ });
43332
+ /**
43333
+ * Identity envelope for a device's upstream-system metadata.
43334
+ *
43335
+ * Two jobs:
43336
+ *
43337
+ * 1. **Dispatch key.** `id` is the natural identifier the SOURCE system
43338
+ * uses to address this device (HA `entity_id`, Reolink MAC, ONVIF
43339
+ * WS-Discovery endpoint UUID, …). Provider code keeps a
43340
+ * `Map<sourceInfo.id, IDevice>` to route inbound push events in O(1)
43341
+ * without round-tripping through `stableId` / `id` mappings.
43342
+ *
43343
+ * 2. **Stability bridge.** `uniqueId` is the *immutable* upstream id when
43344
+ * one exists (HA `entity.unique_id`, Reolink MAC again — same as `id`
43345
+ * for vendors where MAC is the natural key). For systems where the
43346
+ * live dispatch key can be renamed (HA `entity_id` can be edited),
43347
+ * providers detect renames by matching `uniqueId` and patch `id`
43348
+ * in-place so dispatch keeps working.
43349
+ *
43350
+ * **Display unit and precision are NOT stored here.** They are a read-time
43351
+ * preference (D944). A polymorphic reading stores its `quantity` here once,
43352
+ * at adopt, and a date/time value stores `dateTimeShape` the same way —
43353
+ * neither is repeated on each sample. The `device_class` tag is an
43354
+ * HA-specific input consumed at the discovery boundary (`ha-domain-mapping`)
43355
+ * to derive the CamStack `DeviceRole`; it is never stored after that point.
43356
+ *
43357
+ * The `raw` escape hatch carries provider-specific attributes that don't
43358
+ * fit the structured slots (HA `attributes` whitelist, Reolink `deviceCache`
43359
+ * details, …). Providers control what lands here.
43360
+ */
43361
+ var SourceInfoSchema = object({
43362
+ /** Live dispatch key — mutable when the source system allows rename. */
43363
+ id: string(),
43364
+ /** Source system tag — e.g. 'homeassistant' | 'reolink' | 'onvif'. */
43365
+ system: string(),
43366
+ /** Immutable upstream identifier when available (HA `unique_id`, … ).
43367
+ * Used to detect rename when `id` changes. */
43368
+ uniqueId: string().optional(),
43369
+ /** Quantity of a polymorphic reading, written once at adopt (D944). */
43370
+ quantity: ReadingQuantitySchema.optional(),
43371
+ /** Date / time / datetime shape of a string reading, written once at adopt. */
43372
+ dateTimeShape: _enum([
43373
+ "date",
43374
+ "time",
43375
+ "datetime"
43376
+ ]).optional(),
43377
+ /** Provider-specific extras that don't fit the structured slots.
43378
+ * Whitelist what goes here per provider — do NOT dump entire HA
43379
+ * attribute blobs (would explode the meta JSON column). */
43380
+ raw: record(string(), unknown()).optional()
43381
+ });
43382
+ /**
43263
43383
  * A number a person types for a physical quantity — the input side of the
43264
43384
  * value + unit contract (D972).
43265
43385
  *
@@ -44955,15 +45075,28 @@ var SettingsSchemaWithValuesSchema = object({
44955
45075
  });
44956
45076
  /** Patch object — keys are field names, values are the new field values. */
44957
45077
  var SettingsPatchSchema = record(string(), unknown());
45078
+ /** The scope of a scoped-settings call (D986): where the capability declared the values. */
45079
+ var ScopedSettingsScopeSchema = _enum(SCOPED_SETTINGS_SCOPES);
45080
+ /** One scoped schema an addon serves: its capability, scope and section ids. */
45081
+ var ScopedSettingsDeclarationSchema = object({
45082
+ cap: string(),
45083
+ scope: ScopedSettingsScopeSchema,
45084
+ sectionIds: array(string())
45085
+ });
44958
45086
  /** Standard success response for update operations. */
44959
45087
  var SettingsUpdateResultSchema = object({ success: literal(true) });
44960
45088
  method(object({
44961
45089
  addonId: string(),
44962
- nodeId: string().optional(),
44963
- overlay: record(string(), unknown()).optional(),
44964
- cap: string().optional()
45090
+ cap: string(),
45091
+ scope: ScopedSettingsScopeSchema,
45092
+ nodeId: string().optional()
44965
45093
  }), SettingsSchemaWithValuesSchema.nullable()), method(object({
44966
45094
  addonId: string(),
45095
+ nodeId: string().optional()
45096
+ }), array(ScopedSettingsDeclarationSchema).nullable()), method(object({
45097
+ addonId: string(),
45098
+ cap: string(),
45099
+ scope: ScopedSettingsScopeSchema,
44967
45100
  nodeId: string().optional(),
44968
45101
  patch: SettingsPatchSchema
44969
45102
  }), SettingsUpdateResultSchema, {
@@ -44971,8 +45104,10 @@ method(object({
44971
45104
  auth: "admin"
44972
45105
  }), method(object({
44973
45106
  addonId: string(),
45107
+ cap: string(),
45108
+ scope: ScopedSettingsScopeSchema,
44974
45109
  nodeId: string().optional()
44975
- }), SettingsSchemaWithValuesSchema.nullable()), method(object({
45110
+ }), record(string(), unknown()).nullable()), method(object({
44976
45111
  addonId: string(),
44977
45112
  nodeId: string().optional(),
44978
45113
  instance: IntegrationInstanceRefSchema
@@ -46312,7 +46447,7 @@ var BackupDestinationInfoSchema = object({
46312
46447
  id: string(),
46313
46448
  /**
46314
46449
  * Manifest id of the owning addon. The Settings button on the
46315
- * destination card opens that addon's `globalSettingsSchema` panel.
46450
+ * destination card opens that addon's scoped settings panel.
46316
46451
  */
46317
46452
  addonId: string(),
46318
46453
  /** Operator-toggled enable flag from the destination-policy table. */
@@ -48921,7 +49056,7 @@ DeviceType.MediaPlayer, method(deviceOnly, _void(), {
48921
49056
  kind: "mutation",
48922
49057
  auth: "protected"
48923
49058
  });
48924
- DeviceType.Camera, DeviceType.Sensor, DeviceType.Button, DeviceType.Switch, DeviceType.PetFeeder, DeviceType.Container, DeviceType.Thermostat, DeviceType.EventEmitter, DeviceType.Camera, DeviceType.Hub, DeviceType.Light, DeviceType.Siren, DeviceType.Switch, DeviceType.Sensor, DeviceType.Thermostat, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.Fan, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder, DeviceType.PetFountain, DeviceType.Dishwasher, DeviceType.Washer, DeviceType.Heater, DeviceType.Dryer, DeviceType.Gateway, DeviceType.Camera, DeviceType.Fan, DeviceType.Lock, DeviceType.Camera, DeviceType.Switch, DeviceType.Light, DeviceType.Fan, DeviceType.Climate, DeviceType.Thermostat, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.MediaPlayer, DeviceType.Washer, DeviceType.Dryer, DeviceType.Dishwasher, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.PetFeeder, DeviceType.Heater, DeviceType.Switch, DeviceType.Light, DeviceType.Fan, DeviceType.Climate, DeviceType.Thermostat, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.MediaPlayer, DeviceType.Washer, DeviceType.Dryer, DeviceType.Dishwasher, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.PetFeeder, DeviceType.Heater, DeviceType.Switch, DeviceType.Light, DeviceType.Fan, DeviceType.Climate, DeviceType.Thermostat, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.MediaPlayer, DeviceType.Washer, DeviceType.Dryer, DeviceType.Dishwasher, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.PetFeeder, DeviceType.Heater, DeviceType.Switch, DeviceType.Light, DeviceType.Fan, DeviceType.Climate, DeviceType.Thermostat, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.MediaPlayer, DeviceType.Washer, DeviceType.Dryer, DeviceType.Dishwasher, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.PetFeeder, DeviceType.Heater, DeviceType.Gateway, DeviceType.Hub, DeviceType.Camera, DeviceType.Sensor, DeviceType.MediaPlayer, DeviceType.Vacuum, DeviceType.Generic, DeviceType.Washer, DeviceType.Dryer, DeviceType.Dishwasher, DeviceType.PetFountain, DeviceType.Fan, DeviceType.Humidifier, DeviceType.Climate, DeviceType.WaterHeater, DeviceType.MediaPlayer, DeviceType.Washer, DeviceType.Dryer, DeviceType.Dishwasher, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.MediaPlayer, DeviceType.WaterHeater, DeviceType.Humidifier, DeviceType.Fan, DeviceType.Climate, DeviceType.Thermostat, DeviceType.PetFeeder, DeviceType.Heater;
49059
+ DeviceType.Camera, DeviceType.Sensor, DeviceType.Button, DeviceType.Switch, DeviceType.PetFeeder, DeviceType.Container, DeviceType.Thermostat, DeviceType.EventEmitter, DeviceType.Camera, DeviceType.Hub, DeviceType.Light, DeviceType.Siren, DeviceType.Switch, DeviceType.Sensor, DeviceType.Thermostat, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.Fan, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder, DeviceType.PetFountain, DeviceType.Dishwasher, DeviceType.Washer, DeviceType.Heater, DeviceType.Dryer, DeviceType.Gateway, DeviceType.Camera, DeviceType.Fan, DeviceType.Lock, DeviceType.Camera, DeviceType.Switch, DeviceType.Light, DeviceType.Fan, DeviceType.Climate, DeviceType.Thermostat, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.MediaPlayer, DeviceType.Washer, DeviceType.Dryer, DeviceType.Dishwasher, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.PetFeeder, DeviceType.Heater, DeviceType.Switch, DeviceType.Light, DeviceType.Fan, DeviceType.Climate, DeviceType.Thermostat, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.MediaPlayer, DeviceType.Washer, DeviceType.Dryer, DeviceType.Dishwasher, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.PetFeeder, DeviceType.Heater, DeviceType.Switch, DeviceType.Light, DeviceType.Fan, DeviceType.Climate, DeviceType.Thermostat, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.MediaPlayer, DeviceType.Washer, DeviceType.Dryer, DeviceType.Dishwasher, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.PetFeeder, DeviceType.Heater, DeviceType.Switch, DeviceType.Light, DeviceType.Fan, DeviceType.Climate, DeviceType.Thermostat, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.MediaPlayer, DeviceType.Washer, DeviceType.Dryer, DeviceType.Dishwasher, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.PetFeeder, DeviceType.Heater, DeviceType.Gateway, DeviceType.Hub, DeviceType.Camera, DeviceType.Sensor, DeviceType.MediaPlayer, DeviceType.Vacuum, DeviceType.Generic, DeviceType.Washer, DeviceType.Dryer, DeviceType.Dishwasher, DeviceType.PetFountain, DeviceType.Fan, DeviceType.Humidifier, DeviceType.Climate, DeviceType.WaterHeater, DeviceType.MediaPlayer, DeviceType.Washer, DeviceType.Dryer, DeviceType.Dishwasher, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.MediaPlayer, DeviceType.WaterHeater, DeviceType.Humidifier, DeviceType.Fan, DeviceType.Climate, DeviceType.Thermostat, DeviceType.PetFeeder, DeviceType.Heater, DeviceType.MediaPlayer;
48925
49060
  var NUMBER = {
48926
49061
  kind: "fixed",
48927
49062
  result: ["number"]
@@ -50792,6 +50927,7 @@ method(InstancePinSchema, DeviceExportStatusSchema), method(_void(), array(Expor
50792
50927
  kind: "mutation",
50793
50928
  auth: "admin"
50794
50929
  });
50930
+ var DeviceAliasSchema = string().max(64).regex(/^[a-z][a-z0-9_]*$/);
50795
50931
  /**
50796
50932
  * A SITE ZONE — a named part of the property that several locations belong to.
50797
50933
  *
@@ -51185,6 +51321,12 @@ var DeviceInfoSchema = object({
51185
51321
  addonId: string(),
51186
51322
  type: _enum(DeviceType),
51187
51323
  name: string(),
51324
+ /** The name a value template uses for this device (`{{ stufa_veranda.temperature }}`),
51325
+ * entity_id style. Generated once from the name at first registration, unique
51326
+ * cluster-wide, editable through `setAlias`, NOT changed by a rename (D1021).
51327
+ * Optional: a record produced by an older build omits it until the boot
51328
+ * backfill has run. */
51329
+ alias: DeviceAliasSchema.optional(),
51188
51330
  /** Operator-organisational location label. `null` when unset. */
51189
51331
  location: string().nullable(),
51190
51332
  /** Soft-disabled flag. */
@@ -51504,6 +51646,25 @@ method(object({
51504
51646
  }), _void(), {
51505
51647
  kind: "mutation",
51506
51648
  auth: "admin"
51649
+ }), method(object({
51650
+ deviceId: number(),
51651
+ alias: string()
51652
+ }), discriminatedUnion("ok", [object({
51653
+ ok: literal(true),
51654
+ alias: DeviceAliasSchema
51655
+ }), object({
51656
+ ok: literal(false),
51657
+ reason: _enum([
51658
+ "empty",
51659
+ "too-long",
51660
+ "grammar",
51661
+ "reserved",
51662
+ "taken"
51663
+ ]),
51664
+ takenBy: number().optional()
51665
+ })]), {
51666
+ kind: "mutation",
51667
+ auth: "admin"
51507
51668
  }), method(object({
51508
51669
  deviceId: number(),
51509
51670
  location: string().nullable()
@@ -54625,10 +54786,11 @@ var IntegrationInstanceDescriptionSchema = object({
54625
54786
  /** True when `testInstance` really probes the remote. */
54626
54787
  canTest: boolean(),
54627
54788
  /**
54628
- * The kinds an instance can be of. Absent or one entry: nothing to choose; more than one: the
54629
- * Add flow asks which first and sends it as `createInstance.kind`.
54789
+ * The kinds an instance can be of (D1013 rule 1), each naming its `functionCap`. One entry:
54790
+ * nothing to choose; more than one: the Add flow asks which first and sends it as
54791
+ * `createInstance.kind`. Never empty: every instance belongs to exactly one kind.
54630
54792
  */
54631
- kinds: array(IntegrationInstanceKindSchema).optional(),
54793
+ kinds: array(IntegrationInstanceKindSchema).min(1),
54632
54794
  /**
54633
54795
  * The provider accepts `createInstance.id` for an id that once existed (an adopted id, because
54634
54796
  * devices carry it). The UI then offers "restore under its old id" on Add. Absent means no.
@@ -54664,8 +54826,8 @@ var IntegrationInstanceSchema = object({
54664
54826
  * the body is the one store, `setInstanceEnabled` the one writer (epic #440, step 3).
54665
54827
  */
54666
54828
  enabled: boolean().optional(),
54667
- /** The kind of this instance (one of `describe().kinds[].id`), when the provider has kinds. */
54668
- kind: string().optional(),
54829
+ /** The kind of this instance: exactly one of `describe().kinds[].id` (D1013 rule 2). */
54830
+ kind: string().min(1),
54669
54831
  /** Values for the instance form; a secret is a `SecretFieldView`, never text. */
54670
54832
  formValues: record(string(), unknown()),
54671
54833
  status: IntegrationInstanceStatusSchema.optional(),
@@ -64235,6 +64397,11 @@ var NcScheduleSchema = object({
64235
64397
  * as a STATE SUMMARY at the end of each window of `schedule`.
64236
64398
  */
64237
64399
  var NcSystemEventDeliveryModeSchema = _enum(["instant", "summary"]);
64400
+ _enum([
64401
+ "activation",
64402
+ "deactivation",
64403
+ "schedule"
64404
+ ]);
64238
64405
  /**
64239
64406
  * How a summary window is SENT (D698): `rule` (absent) = ONE notification with
64240
64407
  * every section; `section` = one notification per section with something to
@@ -64251,11 +64418,26 @@ var NcStateSummarySplitSchema = _enum(["rule", "section"]);
64251
64418
  * midnight crossing); the send time is each window's END. Refused on the
64252
64419
  * `alarm` family, which stays instant (`systemEventDeliveryProblems`).
64253
64420
  */
64254
- var NcSystemEventDeliverySchema = discriminatedUnion("mode", [object({ mode: literal("instant") }), object({
64255
- mode: literal("summary"),
64256
- schedule: NcScheduleSchema,
64257
- split: NcStateSummarySplitSchema.optional()
64258
- })]);
64421
+ var NcSystemEventDeliverySchema = discriminatedUnion("mode", [
64422
+ object({ mode: literal("instant") }),
64423
+ object({
64424
+ mode: literal("summary"),
64425
+ schedule: NcScheduleSchema,
64426
+ split: NcStateSummarySplitSchema.optional()
64427
+ }),
64428
+ object({
64429
+ mode: literal("modes"),
64430
+ /** The bad edge, and every kind that has a single edge. */
64431
+ onActivation: boolean(),
64432
+ /** The recovery edges of the classes of the selected kinds (derived, never stored per kind). */
64433
+ onDeactivation: boolean(),
64434
+ /** A state summary at the end of each window; absent = no summary. */
64435
+ onSchedule: object({
64436
+ schedule: NcScheduleSchema,
64437
+ grouping: NcStateSummarySplitSchema
64438
+ }).optional()
64439
+ })
64440
+ ]);
64259
64441
  /** Fuzzy plate matcher — OCR noise makes exact match useless (spec row 12/13). */
64260
64442
  var NcPlateMatcherSchema = object({
64261
64443
  /**
@@ -71582,6 +71764,7 @@ var RemoteLayoutComponentSchema = object({
71582
71764
  });
71583
71765
  });
71584
71766
  method(object({}), array(RemoteLayoutComponentSchema), { auth: "protected" });
71767
+ DeviceType.MediaPlayer;
71585
71768
  var RunningStateSourceSchema = _enum(["native", "assigned"]);
71586
71769
  object({
71587
71770
  /** null = unknown — never coerced to false (D393). */
@@ -74009,7 +74192,7 @@ var TerminalInstanceInfoSchema = object({
74009
74192
  /**
74010
74193
  * False while the node the instance is bound to is not in the cluster
74011
74194
  * topology (forgotten, renamed, re-paired). The instance is kept; the
74012
- * operator re-points it with `updateInstance({ targetNodeId })`.
74195
+ * operator re-points it on the Terminal page.
74013
74196
  */
74014
74197
  nodeInCluster: boolean(),
74015
74198
  profileId: string(),
@@ -74047,41 +74230,7 @@ var TerminalOutputBatchSchema = object({
74047
74230
  snapshot: string().optional(),
74048
74231
  events: array(TerminalOutputEventSchema).readonly()
74049
74232
  });
74050
- method(_void(), array(TerminalProfileInfoSchema).readonly(), { auth: "admin" }), method(_void(), array(TerminalInstanceInfoSchema).readonly(), { auth: "admin" }), method(object({
74051
- targetNodeId: string().min(1),
74052
- profileId: string().min(1),
74053
- name: string().trim().min(1).max(160).optional(),
74054
- executable: string().max(1024).optional(),
74055
- args: array(string().max(2048)).max(64).optional(),
74056
- cwd: string().max(1024).optional(),
74057
- environment: array(string().max(4096)).max(64).optional(),
74058
- profileSettings: ProfileSettingsBagSchema.optional()
74059
- }), TerminalInstanceInfoSchema, {
74060
- kind: "mutation",
74061
- auth: "admin"
74062
- }), method(object({
74063
- instanceId: string().min(1),
74064
- /** Re-point the instance to another node; the profile must exist there. */
74065
- targetNodeId: string().min(1).max(256).optional(),
74066
- name: string().trim().min(1).max(160).optional(),
74067
- executable: string().max(1024).optional(),
74068
- args: array(string().max(2048)).max(64).optional(),
74069
- cwd: string().max(1024).optional(),
74070
- environment: array(string().max(4096)).max(64).optional(),
74071
- profileSettings: ProfileSettingsBagSchema.optional()
74072
- }), TerminalInstanceInfoSchema, {
74073
- kind: "mutation",
74074
- auth: "admin"
74075
- }), method(object({ instanceId: string().min(1) }), _void(), {
74076
- kind: "mutation",
74077
- auth: "admin"
74078
- }), method(object({
74079
- instanceId: string().min(1),
74080
- enabled: boolean()
74081
- }), TerminalInstanceInfoSchema, {
74082
- kind: "mutation",
74083
- auth: "admin"
74084
- }), method(_void(), array(TerminalLegacyCameraSchema).readonly(), { auth: "admin" }), method(object({
74233
+ method(_void(), array(TerminalProfileInfoSchema).readonly(), { auth: "admin" }), method(_void(), array(TerminalLegacyCameraSchema).readonly(), { auth: "admin" }), method(object({
74085
74234
  stableId: string().min(1),
74086
74235
  name: string().trim().min(1).max(160).optional()
74087
74236
  }), TerminalInstanceInfoSchema, {
@@ -74150,6 +74299,11 @@ var TimedRunUnitSchema = _enum(["s", "min"]);
74150
74299
  _enum(["switch", "pet-fountain"]);
74151
74300
  DeviceType.Heater, DeviceType.PetFountain;
74152
74301
  DeviceType.Heater, DeviceType.PetFountain;
74302
+ /** One duration, as the one-tap run asks for it. */
74303
+ var TimedRunQuickRunSchema = object({
74304
+ duration: number().positive().finite(),
74305
+ unit: TimedRunUnitSchema
74306
+ });
74153
74307
  /** Why a request was not carried out. Typed, so a caller can say it in words. */
74154
74308
  var TimedRunRefusalSchema = _enum([
74155
74309
  "unsupported-device",
@@ -74170,9 +74324,30 @@ object({
74170
74324
  activeUntil: number().nullable(),
74171
74325
  /** Who asked for the run (a user id or an addon id); `null` when nothing is armed or it did not say. */
74172
74326
  requestedBy: string().nullable(),
74327
+ /**
74328
+ * The one-tap run of THIS device: what a "Run" button on any surface starts. `null` when
74329
+ * the device is gone or its type has no timed run; a status from before this field
74330
+ * existed has none, and a surface falls back to the type's default.
74331
+ */
74332
+ quickRun: TimedRunQuickRunSchema.nullable(),
74173
74333
  lastFetchedAt: number()
74174
74334
  });
74175
- method(object({
74335
+ /** What the settings form needs: the unit, the bounds and the type's default. */
74336
+ var TimedRunOptionsSchema = object({
74337
+ unit: TimedRunUnitSchema,
74338
+ /** The longest run, in `unit`. */
74339
+ maxDuration: number(),
74340
+ defaultDuration: number()
74341
+ });
74342
+ /** The one-tap run duration, in the type's unit; `null` returns to the type's default. */
74343
+ var TimedRunSettingsPatchSchema = object({ quickRunDuration: number().positive().finite().nullable().optional() });
74344
+ method(object({ deviceId: number().int() }), TimedRunOptionsSchema), method(object({
74345
+ deviceId: number().int(),
74346
+ settings: TimedRunSettingsPatchSchema
74347
+ }), _void(), {
74348
+ kind: "mutation",
74349
+ auth: "admin"
74350
+ }), method(object({
74176
74351
  deviceId: number().int(),
74177
74352
  duration: number().positive().finite(),
74178
74353
  unit: TimedRunUnitSchema
@@ -76878,21 +77053,28 @@ Object.freeze({
76878
77053
  access: "view",
76879
77054
  role: "admin"
76880
77055
  },
76881
- "addonSettings.getGlobalSettings": {
77056
+ "addonSettings.getIntegrationFormSchema": {
76882
77057
  capName: "addon-settings",
76883
77058
  capScope: "system",
76884
77059
  addonId: null,
76885
77060
  access: "view",
76886
77061
  role: "admin"
76887
77062
  },
76888
- "addonSettings.getIntegrationFormSchema": {
77063
+ "addonSettings.getScopedSettings": {
77064
+ capName: "addon-settings",
77065
+ capScope: "system",
77066
+ addonId: null,
77067
+ access: "view",
77068
+ role: "admin"
77069
+ },
77070
+ "addonSettings.listScopedSettings": {
76889
77071
  capName: "addon-settings",
76890
77072
  capScope: "system",
76891
77073
  addonId: null,
76892
77074
  access: "view",
76893
77075
  role: "admin"
76894
77076
  },
76895
- "addonSettings.getIntegrationSettings": {
77077
+ "addonSettings.readScopedValues": {
76896
77078
  capName: "addon-settings",
76897
77079
  capScope: "system",
76898
77080
  addonId: null,
@@ -76913,7 +77095,7 @@ Object.freeze({
76913
77095
  access: "create",
76914
77096
  role: "admin"
76915
77097
  },
76916
- "addonSettings.updateGlobalSettings": {
77098
+ "addonSettings.updateScopedSettings": {
76917
77099
  capName: "addon-settings",
76918
77100
  capScope: "system",
76919
77101
  addonId: null,
@@ -79083,6 +79265,13 @@ Object.freeze({
79083
79265
  access: "create",
79084
79266
  role: "admin"
79085
79267
  },
79268
+ "deviceManager.setAlias": {
79269
+ capName: "device-manager",
79270
+ capScope: "system",
79271
+ addonId: null,
79272
+ access: "create",
79273
+ role: "admin"
79274
+ },
79086
79275
  "deviceManager.setChildLayout": {
79087
79276
  capName: "device-manager",
79088
79277
  capScope: "system",
@@ -85810,27 +85999,6 @@ Object.freeze({
85810
85999
  access: "create",
85811
86000
  role: "admin"
85812
86001
  },
85813
- "terminalSession.createInstance": {
85814
- capName: "terminal-session",
85815
- capScope: "system",
85816
- addonId: null,
85817
- access: "create",
85818
- role: "admin"
85819
- },
85820
- "terminalSession.deleteInstance": {
85821
- capName: "terminal-session",
85822
- capScope: "system",
85823
- addonId: null,
85824
- access: "delete",
85825
- role: "admin"
85826
- },
85827
- "terminalSession.listInstances": {
85828
- capName: "terminal-session",
85829
- capScope: "system",
85830
- addonId: null,
85831
- access: "view",
85832
- role: "admin"
85833
- },
85834
86002
  "terminalSession.listLegacyCameras": {
85835
86003
  capName: "terminal-session",
85836
86004
  capScope: "system",
@@ -85873,20 +86041,6 @@ Object.freeze({
85873
86041
  access: "create",
85874
86042
  role: "admin"
85875
86043
  },
85876
- "terminalSession.setInstanceEnabled": {
85877
- capName: "terminal-session",
85878
- capScope: "system",
85879
- addonId: null,
85880
- access: "create",
85881
- role: "admin"
85882
- },
85883
- "terminalSession.updateInstance": {
85884
- capName: "terminal-session",
85885
- capScope: "system",
85886
- addonId: null,
85887
- access: "create",
85888
- role: "admin"
85889
- },
85890
86044
  "terminalSession.writeInput": {
85891
86045
  capName: "terminal-session",
85892
86046
  capScope: "system",
@@ -85901,6 +86055,13 @@ Object.freeze({
85901
86055
  access: "create",
85902
86056
  role: "viewer"
85903
86057
  },
86058
+ "timedRun.getOptions": {
86059
+ capName: "timed-run",
86060
+ capScope: "device",
86061
+ addonId: null,
86062
+ access: "view",
86063
+ role: "viewer"
86064
+ },
85904
86065
  "timedRun.getStatus": {
85905
86066
  capName: "timed-run",
85906
86067
  capScope: "device",
@@ -85915,6 +86076,13 @@ Object.freeze({
85915
86076
  access: "create",
85916
86077
  role: "viewer"
85917
86078
  },
86079
+ "timedRun.setSettings": {
86080
+ capName: "timed-run",
86081
+ capScope: "device",
86082
+ addonId: null,
86083
+ access: "create",
86084
+ role: "admin"
86085
+ },
85918
86086
  "toast.onToast": {
85919
86087
  capName: "toast",
85920
86088
  capScope: "system",
@@ -86845,6 +87013,7 @@ function offered(received) {
86845
87013
  };
86846
87014
  }
86847
87015
  offered(HA_AS), withheld(HOMEKIT_REASON), withheld(ALEXA_REASON), withheld(GOOGLE_REASON), withheld(MATTER_REASON);
87016
+ string().min(1).max(4096);
86848
87017
  Object.freeze({
86849
87018
  "accessories.getStatus": [{
86850
87019
  name: "deviceId",
@@ -87528,6 +87697,11 @@ Object.freeze({
87528
87697
  form: "single",
87529
87698
  optional: false
87530
87699
  }],
87700
+ "deviceManager.setAlias": [{
87701
+ name: "deviceId",
87702
+ form: "single",
87703
+ optional: false
87704
+ }],
87531
87705
  "deviceManager.setChildLayout": [{
87532
87706
  name: "deviceId",
87533
87707
  form: "single",
@@ -89592,6 +89766,11 @@ Object.freeze({
89592
89766
  form: "single",
89593
89767
  optional: false
89594
89768
  }],
89769
+ "timedRun.getOptions": [{
89770
+ name: "deviceId",
89771
+ form: "single",
89772
+ optional: false
89773
+ }],
89595
89774
  "timedRun.getStatus": [{
89596
89775
  name: "deviceId",
89597
89776
  form: "single",
@@ -89602,6 +89781,11 @@ Object.freeze({
89602
89781
  form: "single",
89603
89782
  optional: false
89604
89783
  }],
89784
+ "timedRun.setSettings": [{
89785
+ name: "deviceId",
89786
+ form: "single",
89787
+ optional: false
89788
+ }],
89605
89789
  "update.getStatus": [{
89606
89790
  name: "deviceId",
89607
89791
  form: "single",
@@ -90464,6 +90648,7 @@ var STATE_SUMMARY_VARS = [
90464
90648
  [...RULE_TEXT],
90465
90649
  [...RULE_TEXT],
90466
90650
  [...RULE_TEXT],
90651
+ [...RULE_TEXT],
90467
90652
  [...RULE_BODY_ONLY],
90468
90653
  [...RULE_TEXT],
90469
90654
  ([...RULE_TEXT], [...DEVICE_EVENTS]),
@@ -92081,7 +92266,13 @@ function buildGoogleCalendarInstances(records, deps) {
92081
92266
  role: "services",
92082
92267
  instanceMode: "multiple",
92083
92268
  kind: GOOGLE_CALENDAR_INSTANCE_KIND,
92084
- canTest: false
92269
+ canTest: false,
92270
+ kinds: [{
92271
+ id: GOOGLE_CALENDAR_INSTANCE_KIND,
92272
+ label: "Google calendar",
92273
+ functionCap: "calendar-source",
92274
+ role: "services"
92275
+ }]
92085
92276
  },
92086
92277
  labels: { accountId: {
92087
92278
  label: "Google account",
@@ -92314,7 +92505,8 @@ function buildGoogleInstances(records, deps) {
92314
92505
  instanceMode: "multiple",
92315
92506
  kind: "account",
92316
92507
  canTest: true,
92317
- allowRestoreId: true
92508
+ allowRestoreId: true,
92509
+ kinds: [googleAccountKind(deps)]
92318
92510
  },
92319
92511
  formSchema: () => buildGoogleBrokerSettingsSchema(deps.publicOrigin()),
92320
92512
  initialBody: (name) => ({