@camstack/types 1.1.21 → 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.
@@ -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),
@@ -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),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/types",
3
- "version": "1.1.21",
3
+ "version": "1.1.22",
4
4
  "description": "Shared types, interfaces, and model catalogs for the CamStack detection ecosystem",
5
5
  "keywords": [
6
6
  "camstack",