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