@camstack/types 1.1.20 → 1.1.22

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.
Files changed (49) hide show
  1. package/dist/addon/base-addon.d.ts +30 -4
  2. package/dist/addon/per-node-store.d.ts +68 -0
  3. package/dist/addon.js +1 -1
  4. package/dist/addon.mjs +1 -1
  5. package/dist/capabilities/camera-streams.cap.d.ts +5 -5
  6. package/dist/capabilities/decoder.cap.d.ts +2 -0
  7. package/dist/capabilities/device-manager.cap.d.ts +269 -6
  8. package/dist/capabilities/index.d.ts +3 -1
  9. package/dist/capabilities/metrics-provider.cap.d.ts +2 -2
  10. package/dist/capabilities/motion-detection.cap.d.ts +20 -2
  11. package/dist/capabilities/pet-feeder.cap.d.ts +185 -0
  12. package/dist/capabilities/pipeline-executor.cap.d.ts +29 -2
  13. package/dist/capabilities/pipeline-orchestrator.cap.d.ts +7 -3
  14. package/dist/capabilities/platform-probe.cap.d.ts +1 -1
  15. package/dist/capabilities/schemas/streaming-shared.d.ts +2 -2
  16. package/dist/capabilities/stream-broker.cap.d.ts +1 -1
  17. package/dist/device/device-management.d.ts +61 -2
  18. package/dist/device/device-type.d.ts +8 -1
  19. package/dist/device/index.d.ts +1 -1
  20. package/dist/expression/ast.d.ts +56 -0
  21. package/dist/expression/builtins.d.ts +19 -0
  22. package/dist/expression/compile.d.ts +17 -0
  23. package/dist/expression/errors.d.ts +16 -0
  24. package/dist/expression/evaluator.d.ts +14 -0
  25. package/dist/expression/index.d.ts +25 -0
  26. package/dist/expression/limits.d.ts +30 -0
  27. package/dist/expression/link-expression.d.ts +44 -0
  28. package/dist/expression/parser.d.ts +12 -0
  29. package/dist/expression/tokenizer.d.ts +38 -0
  30. package/dist/generated/addon-api.d.ts +616 -12
  31. package/dist/generated/cap-status-types.d.ts +3 -1
  32. package/dist/generated/capability-router-map.d.ts +5 -2
  33. package/dist/generated/device-local-state.d.ts +3 -0
  34. package/dist/generated/device-proxy.d.ts +4 -1
  35. package/dist/generated/method-access-map.d.ts +1 -1
  36. package/dist/generated/system-proxy.d.ts +1 -1
  37. package/dist/index.d.ts +4 -1
  38. package/dist/index.js +1555 -37
  39. package/dist/index.mjs +1523 -38
  40. package/dist/interfaces/addon.d.ts +15 -2
  41. package/dist/interfaces/agent.d.ts +14 -0
  42. package/dist/interfaces/config-ui.d.ts +19 -1
  43. package/dist/interfaces/pipeline-executor-capability.d.ts +10 -0
  44. package/dist/{sleep-BabrCASa.js → sleep-B8cp-HUn.js} +193 -7
  45. package/dist/{sleep-MHm--th-.mjs → sleep-BiDFW0E7.mjs} +193 -7
  46. package/dist/units/convert.d.ts +49 -0
  47. package/dist/units/index.d.ts +8 -0
  48. package/dist/units/unit-table.d.ts +270 -0
  49. package/package.json +1 -1
@@ -262,8 +262,8 @@ export interface IKernelServices {
262
262
  * own probe because hwaccel is inherently hardware-bound: decoder
263
263
  * addons on an NVIDIA agent get `['cuda', 'nvdec']`, the same addon
264
264
  * on a Mac hub gets `['videotoolbox']`. Cross-node queries for the
265
- * admin UI go via the `$hwaccel.resolve` Moleculer service
266
- * registered on every node.
265
+ * admin UI go via the node-pinned `platform-probe` capability
266
+ * (`resolveHwAccel`), not a bespoke Moleculer service.
267
267
  */
268
268
  readonly hwaccel?: IKernelHwAccel;
269
269
  /**
@@ -437,6 +437,15 @@ export interface AddonContext<TConfig = Record<string, unknown>> {
437
437
  * and `global`-level values; they're disjoint by schema invariant so
438
438
  * they can share one physical store. No defaults merged in. Returns
439
439
  * an empty object if the addon has never written anything.
440
+ *
441
+ * @internal Low-level primitive — addons MUST NOT call this directly.
442
+ * Use the high-level, schema-driven, per-node-aware API instead:
443
+ * `this.getGlobalSettings()` / `this.updateGlobalSettings()`,
444
+ * `this.resolveGlobalStore()` (raw view for a `getGlobalSettings`
445
+ * override), or `this.state<T>()` (durable JSON blob). Enforced by the
446
+ * `no-raw-addon-store` biome guard over the addon source tree; the
447
+ * sanctioned `BaseAddon` wrappers (in `@camstack/types`) and the
448
+ * allowlisted engine-cascade holdouts are the only callers.
440
449
  */
441
450
  readAddonStore(): Promise<Record<string, unknown>>;
442
451
  /**
@@ -444,6 +453,10 @@ export interface AddonContext<TConfig = Record<string, unknown>> {
444
453
  * preserved. Keys explicitly set to `null` are preserved as-is (they
445
454
  * do NOT fall back to schema default on subsequent reads — that
446
455
  * layering happens only at `hydrateSchema` time).
456
+ *
457
+ * @internal Low-level primitive — addons MUST NOT call this directly.
458
+ * Use `this.updateGlobalSettings()` or `this.state<T>().set()`. Enforced
459
+ * by the `no-raw-addon-store` biome guard (see `readAddonStore`).
447
460
  */
448
461
  writeAddonStore(patch: Record<string, unknown>): Promise<void>;
449
462
  /**
@@ -152,3 +152,17 @@ export interface AgentListItem {
152
152
  /** Local IP addresses visible on the network */
153
153
  readonly localIps?: readonly string[];
154
154
  }
155
+ /**
156
+ * Fan-out-free node liveness row for the `/health` surface, read from the
157
+ * hub's in-memory Moleculer node registry (`broker.registry.getNodeList`)
158
+ * — never a per-node RPC. `isOnline` is Moleculer's own availability
159
+ * verdict (`node.available`); a node that disconnected cleanly (or missed
160
+ * heartbeats) stays listed as offline until Moleculer purges it
161
+ * (~cleanOfflineNodesTimeout), which is what lets `/health` NAME the
162
+ * degraded node in `agents.offlineIds`.
163
+ */
164
+ export interface NodeLiveness {
165
+ readonly id: string;
166
+ readonly isHub: boolean;
167
+ readonly isOnline: boolean;
168
+ }
@@ -1,4 +1,4 @@
1
- import type { ModelCatalogEntry, ModelFormat, ModelOutputFormat, CustomModelMetadata } from '../types/models.js';
1
+ import type { CustomModelMetadata, ModelCatalogEntry, ModelFormat, ModelOutputFormat } from '../types/models.js';
2
2
  /**
3
3
  * Configuration UI Schema -- declarative form definition for any element.
4
4
  *
@@ -111,6 +111,24 @@ export interface ConfigFieldBase {
111
111
  * `updateAddonSettings`.
112
112
  */
113
113
  readonly requiresRestart?: boolean;
114
+ /**
115
+ * Per-node store scoping. `perNode: true` ⇒ the field's persisted store
116
+ * key is physically `<key>@<nodeId>` — one value per cluster node — while
117
+ * the SCHEMA field key stays bare (the UI form never sees the scoped key).
118
+ * `BaseAddon.getGlobalSettings` / `updateGlobalSettings` / `resolveConfig`
119
+ * scope reads and writes through `addon/per-node-store.ts`; a node with no
120
+ * scoped key resolves to the field's schema `default` — NEVER another
121
+ * node's value (deliberately NO bare-key fallback).
122
+ *
123
+ * Absent/false (default) ⇒ node-agnostic shared blob — a single value for
124
+ * the whole cluster, today's behavior for the vast majority of fields.
125
+ *
126
+ * This is SCHEMA metadata (like `immediate` / `requiresRestart`), NOT
127
+ * capability metadata: it lives on the field only — never in a `*.cap.ts`
128
+ * definition or an addon manifest — so it is invisible to
129
+ * `check-capability-metadata-drift.ts`.
130
+ */
131
+ readonly perNode?: boolean;
114
132
  /**
115
133
  * Override the default placement of this field.
116
134
  * Moves it to a different tab and/or section than its parent section.
@@ -2,6 +2,7 @@ import type { PipelineEngineChoice, PipelineConfig } from '../types/pipeline.js'
2
2
  import type { PipelineSchema, PipelineDefaultStep, PipelineTemplateStep, PipelineTemplate } from '../types/pipeline-schema.js';
3
3
  import type { ModelFormat } from '../types/models.js';
4
4
  import type { FrameInput } from '../types/io.js';
5
+ import type { FrameHandle } from './frame-handle.js';
5
6
  import type { FrameResult, DetectorOutput } from '../types/detection.js';
6
7
  import type { ConfigUISchema } from './config-ui.js';
7
8
  import type { InferenceCapabilities } from './inference-capabilities.js';
@@ -24,6 +25,9 @@ export interface PipelineStepInput {
24
25
  /**
25
26
  * Input for pipeline execution. Exactly one image source must be provided:
26
27
  * - `frame`: runtime dispatch (pipeline-runner — decoded broker frame)
28
+ * - `frameHandle` (CB5): a zero-pixel shm `FrameHandle` for the same
29
+ * decoded frame — the hub-local executor maps the segment and reads the
30
+ * pixels back zero-copy instead of paying the ~1.2MB UDS re-serialise.
27
31
  * - `imageBase64`: one-shot benchmark (ImageTab "Run")
28
32
  * - `referenceImage`: named file from the reference-image store
29
33
  *
@@ -36,6 +40,12 @@ export interface PipelineRunInput {
36
40
  readonly steps: readonly PipelineStepInput[];
37
41
  readonly engine?: PipelineEngineChoice;
38
42
  readonly frame?: FrameInput;
43
+ /**
44
+ * CB5 shm passthrough — names the ring slot the decoded pixels live in.
45
+ * A null read (recycled slot / foreign handle) degrades to an empty
46
+ * result; the runner ships `frame` as the fallback (queue-depth gated).
47
+ */
48
+ readonly frameHandle?: FrameHandle;
39
49
  readonly referenceImage?: string;
40
50
  readonly imageBase64?: string;
41
51
  /**
@@ -907,6 +907,102 @@ function createDurableState(deps) {
907
907
  };
908
908
  }
909
909
  //#endregion
910
+ //#region src/addon/per-node-store.ts
911
+ /**
912
+ * Per-node scoping for the shared addon-settings blob.
913
+ *
914
+ * An addon's settings store is hub-central (the `addon-settings` cap is
915
+ * hub-routed — the hub instance answers for every node), so fields whose
916
+ * value is a NODE fact (decoder backend, engine pick, probed hardware
917
+ * capabilities) persist under the node-scoped key `<key>@<nodeId>` inside
918
+ * the single shared blob. The SCHEMA field key stays bare: the write path
919
+ * ({@link scopePatch}) maps the bare field onto the target node's scoped
920
+ * key; the read/hydrate path ({@link projectStore}) maps THIS node's value
921
+ * back, so UI forms and `resolveConfig` only ever see the bare key.
922
+ *
923
+ * Fields opt in via the `perNode: true` schema marker on `ConfigFieldBase`
924
+ * (`interfaces/config-ui.ts`); `BaseAddon` derives the key set from the
925
+ * schema and routes reads/writes through these helpers.
926
+ *
927
+ * ## No bare-key fallback — deliberate
928
+ *
929
+ * {@link readNodeValue} reads the node-scoped key ONLY. A node with no
930
+ * scoped key resolves to `undefined` so the field's schema `default` wins —
931
+ * NEVER `store[base]` and never another node's value. A bare legacy key in
932
+ * the store is invisible to every node, hub included, so one node's
933
+ * selection can never leak onto another. (This generalizes the
934
+ * `decoder-backend-keys.ts` semantics — the no-fallback variant — over an
935
+ * arbitrary set of per-node field keys.)
936
+ *
937
+ * Pure functions only — no I/O, no imports beyond the language. This is a
938
+ * LEAF module: import it via its deep path, never from the root barrel.
939
+ */
940
+ /**
941
+ * Normalize a raw kernel node id to the bare node id used for scoping.
942
+ * `localNodeId` can carry a `<node>/<addon>` suffix on forked child
943
+ * processes; per-node settings are per-NODE, so strip the addon segment.
944
+ * `undefined` / `null` / empty falls back to `'hub'`.
945
+ */
946
+ function normalizeNodeId(raw) {
947
+ if (raw === void 0 || raw === null || raw === "") return "hub";
948
+ const slashIdx = raw.indexOf("/");
949
+ if (slashIdx < 0) return raw;
950
+ const bare = raw.slice(0, slashIdx);
951
+ return bare === "" ? "hub" : bare;
952
+ }
953
+ /** The node-scoped store key for a per-node field: `<base>@<nodeId>`. */
954
+ function nodeScopedKey(base, nodeId) {
955
+ return `${base}@${normalizeNodeId(nodeId)}`;
956
+ }
957
+ /**
958
+ * Read a node's value for a per-node field from the raw shared store:
959
+ * the node-scoped key when present, otherwise `undefined`.
960
+ * Deliberately NO bare-key fallback (see module doc) — the caller lets the
961
+ * schema `default` win on `undefined`.
962
+ */
963
+ function readNodeValue(store, base, nodeId) {
964
+ return store[nodeScopedKey(base, nodeId)];
965
+ }
966
+ /**
967
+ * Re-map a UI/settings patch so every bare perNode field persists under the
968
+ * TARGET node's scoped key; all other keys pass through unchanged. Used on
969
+ * the write path so a save for one node never clobbers another node's value
970
+ * (and the bare key is never written). Returns a new object — the input
971
+ * patch is not mutated.
972
+ */
973
+ function scopePatch(patch, perNodeKeys, nodeId) {
974
+ const out = {};
975
+ for (const [key, value] of Object.entries(patch)) out[perNodeKeys.has(key) ? nodeScopedKey(key, nodeId) : key] = value;
976
+ return out;
977
+ }
978
+ /**
979
+ * Project the raw shared store onto the bare schema keys for ONE node so a
980
+ * UI schema (whose field keys are bare) hydrates from that node's own
981
+ * values:
982
+ *
983
+ * - EVERY `@`-scoped key (any node's) is dropped from the pass-through.
984
+ * - Every bare perNode key is dropped from the pass-through (a stray bare
985
+ * legacy key must never hydrate any node — no bare fallback).
986
+ * - THIS node's effective value ({@link readNodeValue}) is then laid onto
987
+ * each bare perNode key; when the node has no scoped key the bare key is
988
+ * left ABSENT so the field's schema `default` wins.
989
+ *
990
+ * Returns a new object — the input store is not mutated.
991
+ */
992
+ function projectStore(store, perNodeKeys, nodeId) {
993
+ const out = {};
994
+ for (const [key, value] of Object.entries(store)) {
995
+ if (key.includes("@")) continue;
996
+ if (perNodeKeys.has(key)) continue;
997
+ out[key] = value;
998
+ }
999
+ for (const base of perNodeKeys) {
1000
+ const value = readNodeValue(store, base, nodeId);
1001
+ if (value !== void 0) out[base] = value;
1002
+ }
1003
+ return out;
1004
+ }
1005
+ //#endregion
910
1006
  //#region src/addon/base-addon.ts
911
1007
  /**
912
1008
  * Base class for CamStack addons. Eliminates settings boilerplate:
@@ -1076,23 +1172,63 @@ var BaseAddon = class {
1076
1172
  deviceSettingsSchema() {
1077
1173
  return null;
1078
1174
  }
1079
- async getGlobalSettings(overlay, cap, _nodeId) {
1175
+ async getGlobalSettings(overlay, cap, nodeId) {
1080
1176
  const schema = this.globalSettingsSchema(cap);
1081
1177
  if (!schema) return { sections: [] };
1082
- const raw = await this._ctx?.settings?.readAddonStore() ?? {};
1178
+ const projected = await this.resolveGlobalStore(nodeId, cap);
1083
1179
  return hydrateSchema(schema, overlay ? {
1084
- ...raw,
1180
+ ...projected,
1085
1181
  ...overlay
1086
- } : raw);
1182
+ } : projected);
1087
1183
  }
1088
- async updateGlobalSettings(patch, _nodeId) {
1089
- await this._ctx?.settings?.writeAddonStore(patch);
1184
+ /**
1185
+ * The raw addon store PROJECTED onto the target node's bare per-node keys:
1186
+ * every `perNode: true` field carries THAT node's scoped value on its bare
1187
+ * key (absent scoped key ⇒ key absent, so the schema `default` wins — no
1188
+ * bare fallback), all `@`-scoped and stray bare per-node keys are dropped.
1189
+ * A no-op passthrough when the schema declares no `perNode` field.
1190
+ *
1191
+ * This is the sanctioned way for a `getGlobalSettings` OVERRIDE that needs
1192
+ * the store for custom option logic (option narrowing, value snapping) to
1193
+ * read it per-node — never `ctx.settings.readAddonStore()` directly.
1194
+ * `nodeId` omitted ⇒ the local node (node-blind injection from ctx).
1195
+ */
1196
+ async resolveGlobalStore(nodeId, cap) {
1197
+ const raw = await this._ctx?.settings?.readAddonStore() ?? {};
1198
+ const keys = this.perNodeKeys(cap);
1199
+ const node = normalizeNodeId(nodeId ?? this._ctx?.kernel?.localNodeId);
1200
+ return keys.size > 0 ? projectStore(raw, keys, node) : raw;
1201
+ }
1202
+ async updateGlobalSettings(patch, nodeId) {
1203
+ const keys = this.perNodeKeys();
1204
+ const localNode = normalizeNodeId(this._ctx?.kernel?.localNodeId);
1205
+ const target = normalizeNodeId(nodeId ?? this._ctx?.kernel?.localNodeId);
1206
+ const barePatch = patch;
1207
+ const scoped = keys.size > 0 ? scopePatch(barePatch, keys, target) : barePatch;
1208
+ await this._ctx?.settings?.writeAddonStore(scoped);
1209
+ if (target !== localNode) return;
1090
1210
  await this.resolveConfig();
1091
1211
  await this.onConfigChanged();
1092
1212
  this.emitLifecycle(EventCategory.AddonUpdated, { level: "global" });
1093
1213
  this.maybeAutoRestart(patch, this.globalSettingsSchema());
1094
1214
  }
1095
1215
  /**
1216
+ * The set of field keys the global settings schema declares `perNode: true`
1217
+ * — derived once per `cap` argument and memoized (schemas are static
1218
+ * declarations). Empty set ⇒ every per-node code path is bypassed and the
1219
+ * settings API behaves exactly like the legacy node-agnostic one.
1220
+ */
1221
+ _perNodeKeysCache = /* @__PURE__ */ new Map();
1222
+ perNodeKeys(cap) {
1223
+ const cacheKey = cap ?? "";
1224
+ const cached = this._perNodeKeysCache.get(cacheKey);
1225
+ if (cached) return cached;
1226
+ const schema = this.globalSettingsSchema(cap);
1227
+ const keys = new Set(schema ? schema.sections.flatMap((section) => collectPerNodeFieldKeys(section.fields)) : []);
1228
+ this._perNodeKeysCache.set(cacheKey, keys);
1229
+ return keys;
1230
+ }
1231
+ /**
1096
1232
  * If any field in `patch` is marked `requiresRestart` in `schema`,
1097
1233
  * schedule an addon restart for the next tick. Deferred via
1098
1234
  * `setImmediate` so the tRPC mutation that triggered the write has
@@ -1245,12 +1381,19 @@ var BaseAddon = class {
1245
1381
  * The merge is shallow: each key in `defaults` is checked against the store.
1246
1382
  * Only keys present in defaults are read — the store can contain extra keys
1247
1383
  * (e.g. from older versions) without polluting the typed config.
1384
+ *
1385
+ * Keys the global settings schema declares `perNode: true` resolve from
1386
+ * THIS node's scoped key (`<key>@<localNode>`) via `readNodeValue` — never
1387
+ * from the bare key — so a per-node field resolves to this node's own
1388
+ * selection at boot (absent scoped key ⇒ the constructor default wins).
1248
1389
  */
1249
1390
  async resolveConfig() {
1250
1391
  const stored = await this.readAddonStoreWithRetry();
1392
+ const perNode = this.perNodeKeys();
1393
+ const localNode = normalizeNodeId(this._ctx?.kernel?.localNodeId);
1251
1394
  const resolved = { ...this.defaults };
1252
1395
  for (const key of Object.keys(this.defaults)) {
1253
- const storedValue = stored[key];
1396
+ const storedValue = perNode.has(key) ? readNodeValue(stored, key, localNode) : stored[key];
1254
1397
  if (storedValue !== void 0 && storedValue !== null) {
1255
1398
  const defaultType = typeof this.defaults[key];
1256
1399
  if (typeof storedValue === defaultType) resolved[key] = storedValue;
@@ -1334,6 +1477,27 @@ var BaseAddon = class {
1334
1477
  }
1335
1478
  };
1336
1479
  /**
1480
+ * Collect the keys of every field marked `perNode: true`, recursing into
1481
+ * layout containers (`group` fields and `sub-tabs` tabs) the same way
1482
+ * `hydrateSchema` does. Valueless structural fields (separator/info/…)
1483
+ * don't declare `perNode` and are excluded by the `in` narrowing.
1484
+ */
1485
+ function collectPerNodeFieldKeys(fields) {
1486
+ const collected = [];
1487
+ for (const field of fields) {
1488
+ if (field.type === "group") {
1489
+ collected.push(...collectPerNodeFieldKeys(field.fields));
1490
+ continue;
1491
+ }
1492
+ if (field.type === "sub-tabs") {
1493
+ for (const tab of field.tabs) collected.push(...collectPerNodeFieldKeys(tab.fields));
1494
+ continue;
1495
+ }
1496
+ if ("perNode" in field && field.perNode === true) collected.push(field.key);
1497
+ }
1498
+ return collected;
1499
+ }
1500
+ /**
1337
1501
  * Normalize an `ICamstackAddon.initialize()` return value into the
1338
1502
  * `AddonInitResult` envelope. Arrays are wrapped into `{ providers }`;
1339
1503
  * envelopes pass through; void stays void.
@@ -2239,6 +2403,13 @@ var DeviceType = /* @__PURE__ */ function(DeviceType) {
2239
2403
  /** Single still-image entity (HA `image.*`). Read-only display of an
2240
2404
  * `entity_picture` signed URL the browser loads directly. `image` cap. */
2241
2405
  DeviceType["Image"] = "image";
2406
+ /** Smart pet feeder — cloud-connected food dispenser with a bowl food
2407
+ * level, battery, desiccant life, feeding state and manual-feed /
2408
+ * call-pet / maintenance actions. Installed with the `pet-feeder` cap;
2409
+ * dual-hopper models (D4S/D4SH) expose per-hopper portions. Sources:
2410
+ * native PetKit (`nodepetkit` `FeederDevice`), reusable by other feeder
2411
+ * integrations sharing the same food/desiccant/hopper surface. */
2412
+ DeviceType["PetFeeder"] = "pet-feeder";
2242
2413
  return DeviceType;
2243
2414
  }({});
2244
2415
  var DeviceFeature = /* @__PURE__ */ function(DeviceFeature) {
@@ -2898,6 +3069,7 @@ function createDeviceProxy(api, binding, opts) {
2898
3069
  nativeObjectDetection: createSliceHandle(stateSource, binding.deviceId, "native-object-detection"),
2899
3070
  notifier: createSliceHandle(stateSource, binding.deviceId, "notifier"),
2900
3071
  numericSensor: createSliceHandle(stateSource, binding.deviceId, "numeric-sensor"),
3072
+ petFeeder: createSliceHandle(stateSource, binding.deviceId, "pet-feeder"),
2901
3073
  powerMeter: createSliceHandle(stateSource, binding.deviceId, "power-meter"),
2902
3074
  presence: createSliceHandle(stateSource, binding.deviceId, "presence"),
2903
3075
  pressureSensor: createSliceHandle(stateSource, binding.deviceId, "pressure-sensor"),
@@ -3121,6 +3293,19 @@ function createDeviceProxy(api, binding, opts) {
3121
3293
  setOverlay: (input) => dispatch("osd", "osd", "setOverlay", "mutation", input),
3122
3294
  getStatus: (input) => dispatch("osd", "osd", "getStatus", "query", input)
3123
3295
  },
3296
+ petFeeder: {
3297
+ feed: (input) => dispatch("pet-feeder", "petFeeder", "feed", "mutation", input),
3298
+ cancelFeed: (input) => dispatch("pet-feeder", "petFeeder", "cancelFeed", "mutation", input),
3299
+ resetDesiccant: (input) => dispatch("pet-feeder", "petFeeder", "resetDesiccant", "mutation", input),
3300
+ markFoodReplenished: (input) => dispatch("pet-feeder", "petFeeder", "markFoodReplenished", "mutation", input),
3301
+ callPet: (input) => dispatch("pet-feeder", "petFeeder", "callPet", "mutation", input),
3302
+ playSound: (input) => dispatch("pet-feeder", "petFeeder", "playSound", "mutation", input),
3303
+ setChildLock: (input) => dispatch("pet-feeder", "petFeeder", "setChildLock", "mutation", input),
3304
+ setIndicatorLight: (input) => dispatch("pet-feeder", "petFeeder", "setIndicatorLight", "mutation", input),
3305
+ setFeedSound: (input) => dispatch("pet-feeder", "petFeeder", "setFeedSound", "mutation", input),
3306
+ setVolume: (input) => dispatch("pet-feeder", "petFeeder", "setVolume", "mutation", input),
3307
+ getStatus: (input) => dispatch("pet-feeder", "petFeeder", "getStatus", "query", input)
3308
+ },
3124
3309
  pipelineAnalytics: {
3125
3310
  getActiveTracks: (input) => dispatch("pipeline-analytics", "pipelineAnalytics", "getActiveTracks", "query", input),
3126
3311
  getTrack: (input) => dispatch("pipeline-analytics", "pipelineAnalytics", "getTrack", "query", input),
@@ -3280,6 +3465,7 @@ function createDeviceProxy(api, binding, opts) {
3280
3465
  setPrimaryChildEntityId: (input) => dispatchSystem("deviceManager", "setPrimaryChildEntityId", "mutation", input),
3281
3466
  setChildLayout: (input) => dispatchSystem("deviceManager", "setChildLayout", "mutation", input),
3282
3467
  setDeviceLinks: (input) => dispatchSystem("deviceManager", "setDeviceLinks", "mutation", input),
3468
+ setDisplay: (input) => dispatchSystem("deviceManager", "setDisplay", "mutation", input),
3283
3469
  getWireableFields: (input) => dispatchSystem("deviceManager", "getWireableFields", "query", input),
3284
3470
  setRole: (input) => dispatchSystem("deviceManager", "setRole", "mutation", input),
3285
3471
  applyInitialMeta: (input) => dispatchSystem("deviceManager", "applyInitialMeta", "mutation", input),
@@ -907,6 +907,102 @@ function createDurableState(deps) {
907
907
  };
908
908
  }
909
909
  //#endregion
910
+ //#region src/addon/per-node-store.ts
911
+ /**
912
+ * Per-node scoping for the shared addon-settings blob.
913
+ *
914
+ * An addon's settings store is hub-central (the `addon-settings` cap is
915
+ * hub-routed — the hub instance answers for every node), so fields whose
916
+ * value is a NODE fact (decoder backend, engine pick, probed hardware
917
+ * capabilities) persist under the node-scoped key `<key>@<nodeId>` inside
918
+ * the single shared blob. The SCHEMA field key stays bare: the write path
919
+ * ({@link scopePatch}) maps the bare field onto the target node's scoped
920
+ * key; the read/hydrate path ({@link projectStore}) maps THIS node's value
921
+ * back, so UI forms and `resolveConfig` only ever see the bare key.
922
+ *
923
+ * Fields opt in via the `perNode: true` schema marker on `ConfigFieldBase`
924
+ * (`interfaces/config-ui.ts`); `BaseAddon` derives the key set from the
925
+ * schema and routes reads/writes through these helpers.
926
+ *
927
+ * ## No bare-key fallback — deliberate
928
+ *
929
+ * {@link readNodeValue} reads the node-scoped key ONLY. A node with no
930
+ * scoped key resolves to `undefined` so the field's schema `default` wins —
931
+ * NEVER `store[base]` and never another node's value. A bare legacy key in
932
+ * the store is invisible to every node, hub included, so one node's
933
+ * selection can never leak onto another. (This generalizes the
934
+ * `decoder-backend-keys.ts` semantics — the no-fallback variant — over an
935
+ * arbitrary set of per-node field keys.)
936
+ *
937
+ * Pure functions only — no I/O, no imports beyond the language. This is a
938
+ * LEAF module: import it via its deep path, never from the root barrel.
939
+ */
940
+ /**
941
+ * Normalize a raw kernel node id to the bare node id used for scoping.
942
+ * `localNodeId` can carry a `<node>/<addon>` suffix on forked child
943
+ * processes; per-node settings are per-NODE, so strip the addon segment.
944
+ * `undefined` / `null` / empty falls back to `'hub'`.
945
+ */
946
+ function normalizeNodeId(raw) {
947
+ if (raw === void 0 || raw === null || raw === "") return "hub";
948
+ const slashIdx = raw.indexOf("/");
949
+ if (slashIdx < 0) return raw;
950
+ const bare = raw.slice(0, slashIdx);
951
+ return bare === "" ? "hub" : bare;
952
+ }
953
+ /** The node-scoped store key for a per-node field: `<base>@<nodeId>`. */
954
+ function nodeScopedKey(base, nodeId) {
955
+ return `${base}@${normalizeNodeId(nodeId)}`;
956
+ }
957
+ /**
958
+ * Read a node's value for a per-node field from the raw shared store:
959
+ * the node-scoped key when present, otherwise `undefined`.
960
+ * Deliberately NO bare-key fallback (see module doc) — the caller lets the
961
+ * schema `default` win on `undefined`.
962
+ */
963
+ function readNodeValue(store, base, nodeId) {
964
+ return store[nodeScopedKey(base, nodeId)];
965
+ }
966
+ /**
967
+ * Re-map a UI/settings patch so every bare perNode field persists under the
968
+ * TARGET node's scoped key; all other keys pass through unchanged. Used on
969
+ * the write path so a save for one node never clobbers another node's value
970
+ * (and the bare key is never written). Returns a new object — the input
971
+ * patch is not mutated.
972
+ */
973
+ function scopePatch(patch, perNodeKeys, nodeId) {
974
+ const out = {};
975
+ for (const [key, value] of Object.entries(patch)) out[perNodeKeys.has(key) ? nodeScopedKey(key, nodeId) : key] = value;
976
+ return out;
977
+ }
978
+ /**
979
+ * Project the raw shared store onto the bare schema keys for ONE node so a
980
+ * UI schema (whose field keys are bare) hydrates from that node's own
981
+ * values:
982
+ *
983
+ * - EVERY `@`-scoped key (any node's) is dropped from the pass-through.
984
+ * - Every bare perNode key is dropped from the pass-through (a stray bare
985
+ * legacy key must never hydrate any node — no bare fallback).
986
+ * - THIS node's effective value ({@link readNodeValue}) is then laid onto
987
+ * each bare perNode key; when the node has no scoped key the bare key is
988
+ * left ABSENT so the field's schema `default` wins.
989
+ *
990
+ * Returns a new object — the input store is not mutated.
991
+ */
992
+ function projectStore(store, perNodeKeys, nodeId) {
993
+ const out = {};
994
+ for (const [key, value] of Object.entries(store)) {
995
+ if (key.includes("@")) continue;
996
+ if (perNodeKeys.has(key)) continue;
997
+ out[key] = value;
998
+ }
999
+ for (const base of perNodeKeys) {
1000
+ const value = readNodeValue(store, base, nodeId);
1001
+ if (value !== void 0) out[base] = value;
1002
+ }
1003
+ return out;
1004
+ }
1005
+ //#endregion
910
1006
  //#region src/addon/base-addon.ts
911
1007
  /**
912
1008
  * Base class for CamStack addons. Eliminates settings boilerplate:
@@ -1076,23 +1172,63 @@ var BaseAddon = class {
1076
1172
  deviceSettingsSchema() {
1077
1173
  return null;
1078
1174
  }
1079
- async getGlobalSettings(overlay, cap, _nodeId) {
1175
+ async getGlobalSettings(overlay, cap, nodeId) {
1080
1176
  const schema = this.globalSettingsSchema(cap);
1081
1177
  if (!schema) return { sections: [] };
1082
- const raw = await this._ctx?.settings?.readAddonStore() ?? {};
1178
+ const projected = await this.resolveGlobalStore(nodeId, cap);
1083
1179
  return hydrateSchema(schema, overlay ? {
1084
- ...raw,
1180
+ ...projected,
1085
1181
  ...overlay
1086
- } : raw);
1182
+ } : projected);
1087
1183
  }
1088
- async updateGlobalSettings(patch, _nodeId) {
1089
- await this._ctx?.settings?.writeAddonStore(patch);
1184
+ /**
1185
+ * The raw addon store PROJECTED onto the target node's bare per-node keys:
1186
+ * every `perNode: true` field carries THAT node's scoped value on its bare
1187
+ * key (absent scoped key ⇒ key absent, so the schema `default` wins — no
1188
+ * bare fallback), all `@`-scoped and stray bare per-node keys are dropped.
1189
+ * A no-op passthrough when the schema declares no `perNode` field.
1190
+ *
1191
+ * This is the sanctioned way for a `getGlobalSettings` OVERRIDE that needs
1192
+ * the store for custom option logic (option narrowing, value snapping) to
1193
+ * read it per-node — never `ctx.settings.readAddonStore()` directly.
1194
+ * `nodeId` omitted ⇒ the local node (node-blind injection from ctx).
1195
+ */
1196
+ async resolveGlobalStore(nodeId, cap) {
1197
+ const raw = await this._ctx?.settings?.readAddonStore() ?? {};
1198
+ const keys = this.perNodeKeys(cap);
1199
+ const node = normalizeNodeId(nodeId ?? this._ctx?.kernel?.localNodeId);
1200
+ return keys.size > 0 ? projectStore(raw, keys, node) : raw;
1201
+ }
1202
+ async updateGlobalSettings(patch, nodeId) {
1203
+ const keys = this.perNodeKeys();
1204
+ const localNode = normalizeNodeId(this._ctx?.kernel?.localNodeId);
1205
+ const target = normalizeNodeId(nodeId ?? this._ctx?.kernel?.localNodeId);
1206
+ const barePatch = patch;
1207
+ const scoped = keys.size > 0 ? scopePatch(barePatch, keys, target) : barePatch;
1208
+ await this._ctx?.settings?.writeAddonStore(scoped);
1209
+ if (target !== localNode) return;
1090
1210
  await this.resolveConfig();
1091
1211
  await this.onConfigChanged();
1092
1212
  this.emitLifecycle(EventCategory.AddonUpdated, { level: "global" });
1093
1213
  this.maybeAutoRestart(patch, this.globalSettingsSchema());
1094
1214
  }
1095
1215
  /**
1216
+ * The set of field keys the global settings schema declares `perNode: true`
1217
+ * — derived once per `cap` argument and memoized (schemas are static
1218
+ * declarations). Empty set ⇒ every per-node code path is bypassed and the
1219
+ * settings API behaves exactly like the legacy node-agnostic one.
1220
+ */
1221
+ _perNodeKeysCache = /* @__PURE__ */ new Map();
1222
+ perNodeKeys(cap) {
1223
+ const cacheKey = cap ?? "";
1224
+ const cached = this._perNodeKeysCache.get(cacheKey);
1225
+ if (cached) return cached;
1226
+ const schema = this.globalSettingsSchema(cap);
1227
+ const keys = new Set(schema ? schema.sections.flatMap((section) => collectPerNodeFieldKeys(section.fields)) : []);
1228
+ this._perNodeKeysCache.set(cacheKey, keys);
1229
+ return keys;
1230
+ }
1231
+ /**
1096
1232
  * If any field in `patch` is marked `requiresRestart` in `schema`,
1097
1233
  * schedule an addon restart for the next tick. Deferred via
1098
1234
  * `setImmediate` so the tRPC mutation that triggered the write has
@@ -1245,12 +1381,19 @@ var BaseAddon = class {
1245
1381
  * The merge is shallow: each key in `defaults` is checked against the store.
1246
1382
  * Only keys present in defaults are read — the store can contain extra keys
1247
1383
  * (e.g. from older versions) without polluting the typed config.
1384
+ *
1385
+ * Keys the global settings schema declares `perNode: true` resolve from
1386
+ * THIS node's scoped key (`<key>@<localNode>`) via `readNodeValue` — never
1387
+ * from the bare key — so a per-node field resolves to this node's own
1388
+ * selection at boot (absent scoped key ⇒ the constructor default wins).
1248
1389
  */
1249
1390
  async resolveConfig() {
1250
1391
  const stored = await this.readAddonStoreWithRetry();
1392
+ const perNode = this.perNodeKeys();
1393
+ const localNode = normalizeNodeId(this._ctx?.kernel?.localNodeId);
1251
1394
  const resolved = { ...this.defaults };
1252
1395
  for (const key of Object.keys(this.defaults)) {
1253
- const storedValue = stored[key];
1396
+ const storedValue = perNode.has(key) ? readNodeValue(stored, key, localNode) : stored[key];
1254
1397
  if (storedValue !== void 0 && storedValue !== null) {
1255
1398
  const defaultType = typeof this.defaults[key];
1256
1399
  if (typeof storedValue === defaultType) resolved[key] = storedValue;
@@ -1334,6 +1477,27 @@ var BaseAddon = class {
1334
1477
  }
1335
1478
  };
1336
1479
  /**
1480
+ * Collect the keys of every field marked `perNode: true`, recursing into
1481
+ * layout containers (`group` fields and `sub-tabs` tabs) the same way
1482
+ * `hydrateSchema` does. Valueless structural fields (separator/info/…)
1483
+ * don't declare `perNode` and are excluded by the `in` narrowing.
1484
+ */
1485
+ function collectPerNodeFieldKeys(fields) {
1486
+ const collected = [];
1487
+ for (const field of fields) {
1488
+ if (field.type === "group") {
1489
+ collected.push(...collectPerNodeFieldKeys(field.fields));
1490
+ continue;
1491
+ }
1492
+ if (field.type === "sub-tabs") {
1493
+ for (const tab of field.tabs) collected.push(...collectPerNodeFieldKeys(tab.fields));
1494
+ continue;
1495
+ }
1496
+ if ("perNode" in field && field.perNode === true) collected.push(field.key);
1497
+ }
1498
+ return collected;
1499
+ }
1500
+ /**
1337
1501
  * Normalize an `ICamstackAddon.initialize()` return value into the
1338
1502
  * `AddonInitResult` envelope. Arrays are wrapped into `{ providers }`;
1339
1503
  * envelopes pass through; void stays void.
@@ -2239,6 +2403,13 @@ var DeviceType = /* @__PURE__ */ function(DeviceType) {
2239
2403
  /** Single still-image entity (HA `image.*`). Read-only display of an
2240
2404
  * `entity_picture` signed URL the browser loads directly. `image` cap. */
2241
2405
  DeviceType["Image"] = "image";
2406
+ /** Smart pet feeder — cloud-connected food dispenser with a bowl food
2407
+ * level, battery, desiccant life, feeding state and manual-feed /
2408
+ * call-pet / maintenance actions. Installed with the `pet-feeder` cap;
2409
+ * dual-hopper models (D4S/D4SH) expose per-hopper portions. Sources:
2410
+ * native PetKit (`nodepetkit` `FeederDevice`), reusable by other feeder
2411
+ * integrations sharing the same food/desiccant/hopper surface. */
2412
+ DeviceType["PetFeeder"] = "pet-feeder";
2242
2413
  return DeviceType;
2243
2414
  }({});
2244
2415
  var DeviceFeature = /* @__PURE__ */ function(DeviceFeature) {
@@ -2898,6 +3069,7 @@ function createDeviceProxy(api, binding, opts) {
2898
3069
  nativeObjectDetection: createSliceHandle(stateSource, binding.deviceId, "native-object-detection"),
2899
3070
  notifier: createSliceHandle(stateSource, binding.deviceId, "notifier"),
2900
3071
  numericSensor: createSliceHandle(stateSource, binding.deviceId, "numeric-sensor"),
3072
+ petFeeder: createSliceHandle(stateSource, binding.deviceId, "pet-feeder"),
2901
3073
  powerMeter: createSliceHandle(stateSource, binding.deviceId, "power-meter"),
2902
3074
  presence: createSliceHandle(stateSource, binding.deviceId, "presence"),
2903
3075
  pressureSensor: createSliceHandle(stateSource, binding.deviceId, "pressure-sensor"),
@@ -3121,6 +3293,19 @@ function createDeviceProxy(api, binding, opts) {
3121
3293
  setOverlay: (input) => dispatch("osd", "osd", "setOverlay", "mutation", input),
3122
3294
  getStatus: (input) => dispatch("osd", "osd", "getStatus", "query", input)
3123
3295
  },
3296
+ petFeeder: {
3297
+ feed: (input) => dispatch("pet-feeder", "petFeeder", "feed", "mutation", input),
3298
+ cancelFeed: (input) => dispatch("pet-feeder", "petFeeder", "cancelFeed", "mutation", input),
3299
+ resetDesiccant: (input) => dispatch("pet-feeder", "petFeeder", "resetDesiccant", "mutation", input),
3300
+ markFoodReplenished: (input) => dispatch("pet-feeder", "petFeeder", "markFoodReplenished", "mutation", input),
3301
+ callPet: (input) => dispatch("pet-feeder", "petFeeder", "callPet", "mutation", input),
3302
+ playSound: (input) => dispatch("pet-feeder", "petFeeder", "playSound", "mutation", input),
3303
+ setChildLock: (input) => dispatch("pet-feeder", "petFeeder", "setChildLock", "mutation", input),
3304
+ setIndicatorLight: (input) => dispatch("pet-feeder", "petFeeder", "setIndicatorLight", "mutation", input),
3305
+ setFeedSound: (input) => dispatch("pet-feeder", "petFeeder", "setFeedSound", "mutation", input),
3306
+ setVolume: (input) => dispatch("pet-feeder", "petFeeder", "setVolume", "mutation", input),
3307
+ getStatus: (input) => dispatch("pet-feeder", "petFeeder", "getStatus", "query", input)
3308
+ },
3124
3309
  pipelineAnalytics: {
3125
3310
  getActiveTracks: (input) => dispatch("pipeline-analytics", "pipelineAnalytics", "getActiveTracks", "query", input),
3126
3311
  getTrack: (input) => dispatch("pipeline-analytics", "pipelineAnalytics", "getTrack", "query", input),
@@ -3280,6 +3465,7 @@ function createDeviceProxy(api, binding, opts) {
3280
3465
  setPrimaryChildEntityId: (input) => dispatchSystem("deviceManager", "setPrimaryChildEntityId", "mutation", input),
3281
3466
  setChildLayout: (input) => dispatchSystem("deviceManager", "setChildLayout", "mutation", input),
3282
3467
  setDeviceLinks: (input) => dispatchSystem("deviceManager", "setDeviceLinks", "mutation", input),
3468
+ setDisplay: (input) => dispatchSystem("deviceManager", "setDisplay", "mutation", input),
3283
3469
  getWireableFields: (input) => dispatchSystem("deviceManager", "getWireableFields", "query", input),
3284
3470
  setRole: (input) => dispatchSystem("deviceManager", "setRole", "mutation", input),
3285
3471
  applyInitialMeta: (input) => dispatchSystem("deviceManager", "applyInitialMeta", "mutation", input),