@camstack/system 1.2.184 → 1.2.186

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 (57) hide show
  1. package/dist/addon-runner.js +1 -1
  2. package/dist/addon-runner.mjs +1 -1
  3. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  4. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  5. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  6. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  7. package/dist/builtins/alerts/alerts.addon.js +1 -1
  8. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  9. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  10. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  11. package/dist/builtins/console-logging/index.js +1 -1
  12. package/dist/builtins/console-logging/index.mjs +1 -1
  13. package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
  14. package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
  15. package/dist/builtins/device-manager/device-linked-devices.d.ts +20 -0
  16. package/dist/builtins/device-manager/device-manager.addon.js +371 -12
  17. package/dist/builtins/device-manager/device-manager.addon.mjs +371 -12
  18. package/dist/builtins/device-manager/location-alignment.d.ts +61 -0
  19. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  20. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  21. package/dist/builtins/hub-forwarder/index.js +1 -1
  22. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  23. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  24. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  25. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  26. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  27. package/dist/builtins/local-network/local-network.addon.js +1 -1
  28. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  29. package/dist/builtins/loki-logging/index.js +1 -1
  30. package/dist/builtins/loki-logging/index.mjs +1 -1
  31. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  32. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  33. package/dist/builtins/platform-probe/index.js +1 -1
  34. package/dist/builtins/platform-probe/index.mjs +1 -1
  35. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  36. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  37. package/dist/builtins/snapshot/index.js +1 -1
  38. package/dist/builtins/snapshot/index.mjs +1 -1
  39. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  40. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  41. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
  42. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
  43. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  44. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  45. package/dist/builtins/system-config/system-config.addon.js +1 -1
  46. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  47. package/dist/builtins/winston-logging/index.js +1 -1
  48. package/dist/builtins/winston-logging/index.mjs +1 -1
  49. package/dist/{dist-DE3OH-FG.js → dist-0fjBDzUu.js} +56 -5
  50. package/dist/{dist-CCMYMmkQ.mjs → dist-BLU9Dol1.mjs} +56 -5
  51. package/dist/index.js +2 -2
  52. package/dist/index.mjs +2 -2
  53. package/dist/{manifest-system-deps-BmXFnBQZ.mjs → manifest-system-deps-BF-C5NDL.mjs} +2 -55
  54. package/dist/{manifest-system-deps-DMWd5Ygf.js → manifest-system-deps-KBuTXH-y.js} +1 -54
  55. package/dist/{retired-settings-keys-BgUS9Rr3.js → retired-settings-keys-pL2Nq6KT.js} +1 -1
  56. package/dist/{retired-settings-keys-C0uslzAd.mjs → retired-settings-keys-rIzS2f51.mjs} +1 -1
  57. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  const require_chunk = require("./chunk-Cek0wNdY.js");
2
- const require_manifest_system_deps = require("./manifest-system-deps-DMWd5Ygf.js");
2
+ const require_manifest_system_deps = require("./manifest-system-deps-KBuTXH-y.js");
3
3
  const require_custom_action_registry = require("./custom-action-registry-jY0NOZK8.js");
4
4
  let node_path = require("node:path");
5
5
  node_path = require_chunk.__toESM(node_path);
@@ -1,4 +1,4 @@
1
- import { A as createUdsLoggerWithControl, Ft as RUNNER_RSS_BUDGET_ENV, Jt as startRunnerHeapWatch, Qt as emitHeapDiagnosticReport, a as createUdsAddonContext, bt as resolveAddonClass, it as setWorkerNativeCapsChangeListener, j as LocalChildClient, n as installManifestPythonDeps, nt as getWorkerNativeCapSnapshot, ot as validateProviderRegistrations, t as installManifestSystemDeps, tt as getWorkerNativeCapProvider, yt as installManifestNativeDeps } from "./manifest-system-deps-BmXFnBQZ.mjs";
1
+ import { A as createUdsLoggerWithControl, Ft as RUNNER_RSS_BUDGET_ENV, Jt as startRunnerHeapWatch, Qt as emitHeapDiagnosticReport, a as createUdsAddonContext, bt as resolveAddonClass, it as setWorkerNativeCapsChangeListener, j as LocalChildClient, n as installManifestPythonDeps, nt as getWorkerNativeCapSnapshot, ot as validateProviderRegistrations, t as installManifestSystemDeps, tt as getWorkerNativeCapProvider, yt as installManifestNativeDeps } from "./manifest-system-deps-BF-C5NDL.mjs";
2
2
  import { t as CustomActionRegistry } from "./custom-action-registry-F__gp_VX.mjs";
3
3
  import { register } from "node:module";
4
4
  import * as path$1 from "node:path";
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-DE3OH-FG.js");
6
+ const require_dist = require("../../dist-0fjBDzUu.js");
7
7
  const require_settle_sources = require("../../settle-sources-Bhsy57y-.js");
8
8
  let node_crypto = require("node:crypto");
9
9
  let node_path = require("node:path");
@@ -1,4 +1,4 @@
1
- import { E as addonPagesCapability, Ft as BaseAddon, Pt as errMsg, sn as EventCategory } from "../../dist-CCMYMmkQ.mjs";
1
+ import { E as addonPagesCapability, Ft as BaseAddon, Pt as errMsg, sn as EventCategory } from "../../dist-BLU9Dol1.mjs";
2
2
  import { t as settleSourcesWithTimeout } from "../../settle-sources-CDtNC8ub.mjs";
3
3
  import { createHash, randomUUID } from "node:crypto";
4
4
  import * as path$1 from "node:path";
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-DE3OH-FG.js");
6
+ const require_dist = require("../../dist-0fjBDzUu.js");
7
7
  const require_settle_sources = require("../../settle-sources-Bhsy57y-.js");
8
8
  let node_crypto = require("node:crypto");
9
9
  let node_path = require("node:path");
@@ -1,4 +1,4 @@
1
- import { Ft as BaseAddon, O as addonWidgetsCapability, Pt as errMsg, sn as EventCategory } from "../../dist-CCMYMmkQ.mjs";
1
+ import { Ft as BaseAddon, O as addonWidgetsCapability, Pt as errMsg, sn as EventCategory } from "../../dist-BLU9Dol1.mjs";
2
2
  import { t as settleSourcesWithTimeout } from "../../settle-sources-CDtNC8ub.mjs";
3
3
  import { createHash, randomUUID } from "node:crypto";
4
4
  import * as path$1 from "node:path";
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-DE3OH-FG.js");
6
+ const require_dist = require("../../dist-0fjBDzUu.js");
7
7
  //#region src/builtins/alerts/prune-policy.ts
8
8
  /**
9
9
  * Ids older than `cutoffMs`, oldest first.
@@ -1,4 +1,4 @@
1
- import { Ft as BaseAddon, Jt as createEvent, Pt as errMsg, k as alertsCapability, n as AlertSchema, sn as EventCategory } from "../../dist-CCMYMmkQ.mjs";
1
+ import { Ft as BaseAddon, Jt as createEvent, Pt as errMsg, k as alertsCapability, n as AlertSchema, sn as EventCategory } from "../../dist-BLU9Dol1.mjs";
2
2
  //#region src/builtins/alerts/prune-policy.ts
3
3
  /**
4
4
  * Ids older than `cutoffMs`, oldest first.
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-DE3OH-FG.js");
6
+ const require_dist = require("../../dist-0fjBDzUu.js");
7
7
  let node_crypto = require("node:crypto");
8
8
  let buffer = require("buffer");
9
9
  let node_fs_promises = require("node:fs/promises");
@@ -1,4 +1,4 @@
1
- import { Ft as BaseAddon, j as backupCapability, sn as EventCategory } from "../../dist-CCMYMmkQ.mjs";
1
+ import { Ft as BaseAddon, j as backupCapability, sn as EventCategory } from "../../dist-BLU9Dol1.mjs";
2
2
  import { randomBytes, randomUUID } from "node:crypto";
3
3
  import { Buffer as Buffer$1 } from "buffer";
4
4
  import * as fsp from "node:fs/promises";
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-DE3OH-FG.js");
6
+ const require_dist = require("../../dist-0fjBDzUu.js");
7
7
  const require_formatter = require("../../formatter-DqAKDlvN.js");
8
8
  //#region src/builtins/console-logging/console-destination.ts
9
9
  var LEVEL_RANK = {
@@ -1,4 +1,4 @@
1
- import { Ft as BaseAddon, ct as logDestinationCapability } from "../../dist-CCMYMmkQ.mjs";
1
+ import { Ft as BaseAddon, ct as logDestinationCapability } from "../../dist-BLU9Dol1.mjs";
2
2
  import { t as formatLogLine } from "../../formatter-B7qW8bPJ.mjs";
3
3
  //#region src/builtins/console-logging/console-destination.ts
4
4
  var LEVEL_RANK = {
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_chunk = require("../../chunk-Cek0wNdY.js");
3
- const require_dist = require("../../dist-DE3OH-FG.js");
3
+ const require_dist = require("../../dist-0fjBDzUu.js");
4
4
  let node_crypto = require("node:crypto");
5
5
  let node_fs_promises = require("node:fs/promises");
6
6
  node_fs_promises = require_chunk.__toESM(node_fs_promises);
@@ -1,4 +1,4 @@
1
- import { Ft as BaseAddon, I as coreBlockAddonId, L as coreBlockIdFromAddonId, Pt as errMsg, R as coreBlocksCapability, c as CORE_BLOCK_ADDON_PREFIX, d as DeclaredDevices, l as CoreBlockSchema, s as CORE_BLOCKS_ADDON_ID } from "../../dist-CCMYMmkQ.mjs";
1
+ import { Ft as BaseAddon, I as coreBlockAddonId, L as coreBlockIdFromAddonId, Pt as errMsg, R as coreBlocksCapability, c as CORE_BLOCK_ADDON_PREFIX, d as DeclaredDevices, l as CoreBlockSchema, s as CORE_BLOCKS_ADDON_ID } from "../../dist-BLU9Dol1.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import * as fs from "node:fs/promises";
4
4
  import * as path$1 from "node:path";
@@ -64,6 +64,26 @@ export declare function resolveLinkedDeviceIds(params: {
64
64
  export declare function resolveTrackedEventDeviceIds(config: Record<string, unknown>, linkedIds: readonly number[]): readonly number[];
65
65
  /** The single resolved answer, before it is tagged with its camera. */
66
66
  export type ResolvedLinkedDevices = Awaited<ReturnType<IDeviceManagerProvider['getLinkedDevices']>>;
67
+ /**
68
+ * The broken state the 2026-08-26 design leaves possible, made DETECTABLE: a
69
+ * container whose OWN location matches the camera's while its descendants
70
+ * carry `location: null` (a location that predates the cascade, or a cascade
71
+ * write that failed) is skipped by the container rule AND matched by nobody —
72
+ * the whole appliance silently vanishes from auto-link. Measured live
73
+ * 2026-09-05: 40 containers, 373 children, zero log lines.
74
+ *
75
+ * A container qualifies when it matches the camera's location, has at least
76
+ * one descendant, contributed NONE of them to the resolved set, and at least
77
+ * one descendant is location-less. A subtree whose members all carry a
78
+ * DIFFERENT explicit location is excluded on purpose — that is an operator's
79
+ * deliberate split, not the broken state.
80
+ */
81
+ export declare function findLocationInvisibleContainers(params: {
82
+ readonly selfId: number;
83
+ readonly selfLocation: string | null;
84
+ readonly resolvedIds: ReadonlySet<number>;
85
+ readonly candidates: readonly LinkCandidate[];
86
+ }): readonly number[];
67
87
  /** Cap implementation of `getLinkedDevices`. */
68
88
  export declare function getLinkedDevices(pctx: ProviderContext, input: Parameters<IDeviceManagerProvider['getLinkedDevices']>[0]): ReturnType<IDeviceManagerProvider['getLinkedDevices']>;
69
89
  /**
@@ -3,8 +3,8 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-DE3OH-FG.js");
7
- const require_retired_settings_keys = require("../../retired-settings-keys-BgUS9Rr3.js");
6
+ const require_dist = require("../../dist-0fjBDzUu.js");
7
+ const require_retired_settings_keys = require("../../retired-settings-keys-pL2Nq6KT.js");
8
8
  let node_crypto = require("node:crypto");
9
9
  let _camstack_types_node = require("@camstack/types/node");
10
10
  //#region src/builtins/device-manager/adoption-job-engine.ts
@@ -2172,6 +2172,91 @@ function resolveTrackedEventDeviceIds(config, linkedIds) {
2172
2172
  */
2173
2173
  var LINK_CANDIDATE_PROJECTION = { projection: "slim" };
2174
2174
  /**
2175
+ * The broken state the 2026-08-26 design leaves possible, made DETECTABLE: a
2176
+ * container whose OWN location matches the camera's while its descendants
2177
+ * carry `location: null` (a location that predates the cascade, or a cascade
2178
+ * write that failed) is skipped by the container rule AND matched by nobody —
2179
+ * the whole appliance silently vanishes from auto-link. Measured live
2180
+ * 2026-09-05: 40 containers, 373 children, zero log lines.
2181
+ *
2182
+ * A container qualifies when it matches the camera's location, has at least
2183
+ * one descendant, contributed NONE of them to the resolved set, and at least
2184
+ * one descendant is location-less. A subtree whose members all carry a
2185
+ * DIFFERENT explicit location is excluded on purpose — that is an operator's
2186
+ * deliberate split, not the broken state.
2187
+ */
2188
+ function findLocationInvisibleContainers(params) {
2189
+ const { selfId, selfLocation, resolvedIds, candidates } = params;
2190
+ if (selfLocation === null || selfLocation.length === 0) return [];
2191
+ const byParent = /* @__PURE__ */ new Map();
2192
+ for (const c of candidates) {
2193
+ if (c.parentDeviceId === null) continue;
2194
+ const bucket = byParent.get(c.parentDeviceId);
2195
+ if (bucket) bucket.push(c);
2196
+ else byParent.set(c.parentDeviceId, [c]);
2197
+ }
2198
+ const out = [];
2199
+ for (const c of candidates) {
2200
+ if (c.type !== require_dist.DeviceType.Container) continue;
2201
+ if (c.id === selfId) continue;
2202
+ if (c.location !== selfLocation) continue;
2203
+ let anyResolved = false;
2204
+ let anyNullLocation = false;
2205
+ let descendantCount = 0;
2206
+ const visited = new Set([c.id]);
2207
+ let frontier = [c.id];
2208
+ while (frontier.length > 0) {
2209
+ const next = [];
2210
+ for (const parentId of frontier) for (const child of byParent.get(parentId) ?? []) {
2211
+ if (visited.has(child.id)) continue;
2212
+ visited.add(child.id);
2213
+ descendantCount += 1;
2214
+ if (resolvedIds.has(child.id)) anyResolved = true;
2215
+ if (child.location === null) anyNullLocation = true;
2216
+ next.push(child.id);
2217
+ }
2218
+ frontier = next;
2219
+ }
2220
+ if (descendantCount > 0 && !anyResolved && anyNullLocation) out.push(c.id);
2221
+ }
2222
+ return out;
2223
+ }
2224
+ /**
2225
+ * Per-process warn dedupe for {@link findLocationInvisibleContainers}. This
2226
+ * read path runs continuously (the analytics reverse index resolves linked
2227
+ * devices for every camera on a timer), so an undeduped warn would be a log
2228
+ * flood about a state that only an operator action (or the boot backfill)
2229
+ * changes. One line per (camera, container) pair per process is exactly as
2230
+ * loud as the fact is. Bounded so pathological id churn cannot grow it
2231
+ * forever; once full it stops ADDING, never stops the resolver.
2232
+ */
2233
+ var INVISIBLE_CONTAINER_WARN_CAP = 4096;
2234
+ var warnedInvisibleContainers = /* @__PURE__ */ new Set();
2235
+ function warnLocationInvisibleContainers(pctx, cameraId, byId, all, resolved) {
2236
+ if (resolved.mode !== "auto") return;
2237
+ const selfLocation = byId.get(cameraId)?.location ?? null;
2238
+ if (selfLocation === null || selfLocation.length === 0) return;
2239
+ const invisible = findLocationInvisibleContainers({
2240
+ selfId: cameraId,
2241
+ selfLocation,
2242
+ resolvedIds: new Set(resolved.devices.map((d) => d.deviceId)),
2243
+ candidates: all
2244
+ });
2245
+ for (const containerId of invisible) {
2246
+ const key = `${cameraId}:${containerId}`;
2247
+ if (warnedInvisibleContainers.has(key)) continue;
2248
+ if (warnedInvisibleContainers.size >= INVISIBLE_CONTAINER_WARN_CAP) return;
2249
+ warnedInvisibleContainers.add(key);
2250
+ pctx.host.ctx.logger.warn("getLinkedDevices: container matches the camera location but is invisible to auto-link — its children carry no location; re-save the container location to realign", {
2251
+ tags: { deviceId: containerId },
2252
+ meta: {
2253
+ cameraId,
2254
+ location: selfLocation
2255
+ }
2256
+ });
2257
+ }
2258
+ }
2259
+ /**
2175
2260
  * Resolve ONE camera's linked set against an ALREADY-FETCHED fleet.
2176
2261
  *
2177
2262
  * The fleet is a parameter, not a fetch, because it is the entire cost of this
@@ -2209,7 +2294,10 @@ function resolveFor(deviceId, all, byId, blob) {
2209
2294
  async function getLinkedDevices(pctx, input) {
2210
2295
  const { deviceId } = input;
2211
2296
  const [all, blob] = await Promise.all([listAll(pctx, LINK_CANDIDATE_PROJECTION), pctx.settings.readDeviceStore(deviceId)]);
2212
- return resolveFor(deviceId, all, new Map(all.map((d) => [d.id, d])), blob);
2297
+ const byId = new Map(all.map((d) => [d.id, d]));
2298
+ const resolved = resolveFor(deviceId, all, byId, blob);
2299
+ warnLocationInvisibleContainers(pctx, deviceId, byId, all, resolved);
2300
+ return resolved;
2213
2301
  }
2214
2302
  /**
2215
2303
  * Cap implementation of `getLinkedDevicesBatch` — ONE fleet listing for the
@@ -2235,10 +2323,14 @@ async function getLinkedDevicesBatch(pctx, input) {
2235
2323
  return {};
2236
2324
  })))]);
2237
2325
  const byId = new Map(all.map((d) => [d.id, d]));
2238
- return deviceIds.map((deviceId, index) => ({
2239
- deviceId,
2240
- ...resolveFor(deviceId, all, byId, blobs[index] ?? {})
2241
- }));
2326
+ return deviceIds.map((deviceId, index) => {
2327
+ const resolved = resolveFor(deviceId, all, byId, blobs[index] ?? {});
2328
+ warnLocationInvisibleContainers(pctx, deviceId, byId, all, resolved);
2329
+ return {
2330
+ deviceId,
2331
+ ...resolved
2332
+ };
2333
+ });
2242
2334
  }
2243
2335
  /**
2244
2336
  * "Linked devices" settings section for a CAMERA (null for every other
@@ -3525,6 +3617,215 @@ async function swapHardwareState(settings, sourceId, targetId) {
3525
3617
  };
3526
3618
  }
3527
3619
  //#endregion
3620
+ //#region src/builtins/device-manager/location-alignment.ts
3621
+ /**
3622
+ * Location alignment — closes the RETROACTIVITY hole D252/D329 left open, and
3623
+ * makes the broken state LOUD wherever it survives.
3624
+ *
3625
+ * The cascade (`setLocation` → every descendant's own `location` column,
3626
+ * device-meta-actions.ts) runs only when `setLocation` is CALLED. Both records
3627
+ * said so out loud ("Nothing retroactive") and the fleet paid for it: measured
3628
+ * on the live hub 2026-09-04, 40 containers whose location predated the
3629
+ * cascade carried 373 children at `location: null` — every one of those
3630
+ * containers invisible to auto-mode linking-by-location (the container itself
3631
+ * is deliberately never a link; its children never matched), and not one log
3632
+ * line said so. Camera 615 showed one siren where four appliances belonged.
3633
+ *
3634
+ * Two mechanisms close it, both WRITE-time — read-time inheritance stays
3635
+ * rejected (D252's context: a live-resolved location is invisible everywhere
3636
+ * except the one call site that resolves it):
3637
+ *
3638
+ * 1. **A one-shot boot backfill** (`runLocationBackfillAtBoot`). For every
3639
+ * device whose `location` is null and whose parent chain reaches a located
3640
+ * CONTAINER ancestor (container-only — see `planNullLocationFills` for
3641
+ * why a camera's subtree is deliberately left alone), the NEAREST located
3642
+ * ancestor's location is written onto the
3643
+ * row — once per install, marker-guarded. It fills NULL ONLY: an automated
3644
+ * pass carries no operator intent, so it must never overwrite an explicit
3645
+ * different location the way an operator's `setLocation` deliberately
3646
+ * does. Runs under `withMetaWriteLock` and re-verifies each row is still
3647
+ * null before patching, so it cannot fight a concurrent edit. The marker
3648
+ * is only advanced when every planned write LANDED — a partial repair
3649
+ * retries next boot (bounded: once per boot), never silently half-done.
3650
+ *
3651
+ * 2. **Registration-time inheritance** (`resolveInheritedLocation`, consumed
3652
+ * by `registerDevice` in device-meta-actions.ts). A child adopted AFTER
3653
+ * its parent's location was set is the same hole reopened — the cascade
3654
+ * only fires on `setLocation`, and nobody re-saves a container because HA
3655
+ * grew an entity. So a newly-registered (or re-parented) row whose own
3656
+ * location is null inherits the nearest located CONTAINER ancestor's
3657
+ * location the instant it registers, and pulls it down onto its own
3658
+ * null-location descendants (out-of-order adoption registers children
3659
+ * before parents).
3660
+ *
3661
+ * And one detection: every boot, read-only, the fleet is checked for the
3662
+ * broken state and each misaligned ancestor is WARNED about, device-tagged —
3663
+ * the state can re-enter (a partially-failed cascade, a hand-edited ledger)
3664
+ * and a branch that drops work must say so.
3665
+ */
3666
+ /** Addon-store key of the one-shot marker. A number: the highest backfill
3667
+ * version that COMPLETED (every planned write landed). */
3668
+ var LOCATION_BACKFILL_MARKER_KEY = "locationBackfillVersion";
3669
+ /** Walk the parent chain of `startParentId` over an in-memory fleet snapshot
3670
+ * and return the nearest ancestor carrying a non-null location. Cycle-guarded:
3671
+ * a chain that revisits a node answers null rather than spinning. */
3672
+ function nearestLocatedAncestor(byId, startParentId) {
3673
+ const visited = /* @__PURE__ */ new Set();
3674
+ let cursor = startParentId;
3675
+ while (cursor !== null && !visited.has(cursor)) {
3676
+ visited.add(cursor);
3677
+ const ancestor = byId.get(cursor);
3678
+ if (!ancestor) return null;
3679
+ if (ancestor.location !== null) return ancestor;
3680
+ cursor = ancestor.parentDeviceId;
3681
+ }
3682
+ return null;
3683
+ }
3684
+ /**
3685
+ * Pure planning core of the backfill: every row whose own `location` is null
3686
+ * and whose parent chain reaches a located CONTAINER ancestor gets that
3687
+ * ancestor's location. Nearest located ancestor wins — and DECIDES: a located
3688
+ * non-container between the row and a located container blocks the fill.
3689
+ *
3690
+ * The container-only predicate is an operator decision (2026-09-05), the
3691
+ * automated mirror of the structural-children rule: a non-container parent's
3692
+ * subtree (a camera and its siren/floodlight) is already fully reachable
3693
+ * through `resolveLinkedDeviceIds`' children-of-self rule, so a location buys
3694
+ * its own camera nothing — and COSTS cross-camera noise, verified against the
3695
+ * real fleet shape with the real resolver: a camera child that gains the room
3696
+ * matches every OTHER camera in that room individually (`ids.add`; only a
3697
+ * Container parent routes through `expand`), so camera B lists camera A's
3698
+ * siren as a companion. A container's descendants, by contrast, are reachable
3699
+ * by location ONLY through their own columns — the container itself is never
3700
+ * a link — which is the whole reason the cascade writes them (D252). Note the
3701
+ * asymmetry with the OPERATOR's cascade, which has no type predicate (D329):
3702
+ * an operator re-saving a camera accepts that trade explicitly; an automated
3703
+ * pass must not manufacture it.
3704
+ */
3705
+ function planNullLocationFills(rows) {
3706
+ const byId = new Map(rows.map((r) => [r.id, r]));
3707
+ const plan = [];
3708
+ for (const row of rows) {
3709
+ if (row.location !== null) continue;
3710
+ if (row.parentDeviceId === null) continue;
3711
+ const hit = nearestLocatedAncestor(byId, row.parentDeviceId);
3712
+ if (hit === null) continue;
3713
+ if (hit.type !== require_dist.DeviceType.Container) continue;
3714
+ if (hit.location === null) continue;
3715
+ plan.push({
3716
+ id: row.id,
3717
+ location: hit.location,
3718
+ ancestorId: hit.id
3719
+ });
3720
+ }
3721
+ return plan;
3722
+ }
3723
+ /**
3724
+ * Async ancestor walk for `registerDevice`: the nearest located CONTAINER
3725
+ * ancestor's location, read row by row (registration has no fleet snapshot in
3726
+ * hand and must not pay for one). Same container-only predicate as
3727
+ * {@link planNullLocationFills}, and for the same reason — a child adopted
3728
+ * under a located CAMERA is already linked structurally to that camera, and a
3729
+ * location on its own column would make it a companion of every other camera
3730
+ * in the room. The nearest located ancestor decides: a located non-container
3731
+ * on the way up answers null. Cycle-guarded like the pure walk; a missing row
3732
+ * — a parent registered out of order — answers null, and the parent's own
3733
+ * later registration pulls the location down instead (see `registerDevice`).
3734
+ */
3735
+ async function resolveInheritedLocation(rows, parentDeviceId) {
3736
+ const visited = /* @__PURE__ */ new Set();
3737
+ let cursor = parentDeviceId;
3738
+ while (cursor !== null && !visited.has(cursor)) {
3739
+ visited.add(cursor);
3740
+ const ancestor = await rows.get(cursor);
3741
+ if (ancestor === null) return null;
3742
+ if (ancestor.meta.location !== null) return ancestor.meta.type === require_dist.DeviceType.Container ? ancestor.meta.location : null;
3743
+ cursor = ancestor.meta.parentDeviceId;
3744
+ }
3745
+ return null;
3746
+ }
3747
+ /** `fills` grouped by the ancestor whose location they inherit — the unit the
3748
+ * logs speak in, because the ancestor is the device an operator would act on. */
3749
+ function groupByAncestor(fills) {
3750
+ const grouped = /* @__PURE__ */ new Map();
3751
+ for (const fill of fills) {
3752
+ const bucket = grouped.get(fill.ancestorId);
3753
+ if (bucket) bucket.push(fill);
3754
+ else grouped.set(fill.ancestorId, [fill]);
3755
+ }
3756
+ return grouped;
3757
+ }
3758
+ /**
3759
+ * Boot entrypoint: the one-shot repair (marker-guarded) plus the every-boot
3760
+ * detection. `bootRows` is the fleet listing the addon's warm-boot pass
3761
+ * already paid for — reused for the detection so a clean boot costs no extra
3762
+ * fleet read; ignored by the repair, which takes its own listing UNDER the
3763
+ * meta write lock (the snapshot must not predate the lock).
3764
+ */
3765
+ async function runLocationBackfillAtBoot(pctx, bootRows) {
3766
+ const logger = pctx.host.ctx.logger;
3767
+ const markerRaw = (await pctx.settings.readAddonStore())[LOCATION_BACKFILL_MARKER_KEY];
3768
+ const completedVersion = typeof markerRaw === "number" ? markerRaw : 0;
3769
+ let repaired = false;
3770
+ if (completedVersion < 1) {
3771
+ const patched = [];
3772
+ const failed = [];
3773
+ await pctx.metaStore.withMetaWriteLock(async () => {
3774
+ const plan = planNullLocationFills((await pctx.metaStore.rows.listAll()).map((r) => r.meta));
3775
+ for (const fill of plan) try {
3776
+ const current = await pctx.metaStore.rows.get(fill.id);
3777
+ if (current === null || current.meta.location !== null) continue;
3778
+ await pctx.metaStore.rows.patch(fill.id, { location: fill.location });
3779
+ patched.push(fill);
3780
+ } catch (err) {
3781
+ failed.push({
3782
+ id: fill.id,
3783
+ error: err
3784
+ });
3785
+ }
3786
+ });
3787
+ repaired = true;
3788
+ for (const [ancestorId, fills] of groupByAncestor(patched)) {
3789
+ pctx.host.ctx.eventBus.emit({
3790
+ id: (0, node_crypto.randomUUID)(),
3791
+ timestamp: /* @__PURE__ */ new Date(),
3792
+ source: {
3793
+ type: "device",
3794
+ id: ancestorId
3795
+ },
3796
+ category: require_dist.EventCategory.DeviceMetaChanged,
3797
+ data: {
3798
+ deviceId: ancestorId,
3799
+ field: "location",
3800
+ value: fills[0]?.location ?? null
3801
+ }
3802
+ });
3803
+ logger.info("location backfill: aligned location-less descendants to their ancestor", {
3804
+ tags: { deviceId: ancestorId },
3805
+ meta: {
3806
+ location: fills[0]?.location,
3807
+ updatedIds: fills.map((f) => f.id)
3808
+ }
3809
+ });
3810
+ }
3811
+ if (failed.length > 0) logger.error("location backfill partially failed — retrying on the next boot", { meta: {
3812
+ failedIds: failed.map((f) => f.id),
3813
+ errors: failed.map((f) => f.error instanceof Error ? f.error.message : String(f.error))
3814
+ } });
3815
+ else await pctx.metaStore.withAddonStoreWriteLock(async () => {
3816
+ await pctx.settings.writeAddonStore({ [LOCATION_BACKFILL_MARKER_KEY]: 1 });
3817
+ });
3818
+ }
3819
+ const residual = planNullLocationFills((!repaired && bootRows !== void 0 ? bootRows : await pctx.metaStore.rows.listAll()).map((r) => r.meta));
3820
+ for (const [ancestorId, fills] of groupByAncestor(residual)) logger.warn("location cascade misaligned — location-less descendants under a located ancestor never match location auto-link; re-save the ancestor location to realign", {
3821
+ tags: { deviceId: ancestorId },
3822
+ meta: {
3823
+ location: fills[0]?.location,
3824
+ misalignedDescendantIds: fills.map((f) => f.id)
3825
+ }
3826
+ });
3827
+ }
3828
+ //#endregion
3528
3829
  //#region src/builtins/device-manager/device-meta-actions.ts
3529
3830
  /**
3530
3831
  * Device meta-mutation + persistence actions for the device-manager addon —
@@ -3728,7 +4029,7 @@ async function migrateDevice(pctx, input) {
3728
4029
  async function registerDevice(pctx, input) {
3729
4030
  const { addonId, stableId, id, type, name, parentDeviceId, features, config } = input;
3730
4031
  const featuresArr = Array.isArray(features) ? [...features] : [];
3731
- const { isFirstRegistration, exportFingerprint, fingerprintChanged } = await pctx.metaStore.withMetaWriteLock(async () => {
4032
+ const { isFirstRegistration, exportFingerprint, fingerprintChanged, inheritedLocation, inheritedDescendantIds, inheritFailed } = await pctx.metaStore.withMetaWriteLock(async () => {
3732
4033
  const existingRow = await pctx.metaStore.rows.get(id);
3733
4034
  const existingMeta = existingRow?.meta;
3734
4035
  const wasRegistered = existingRow?.registered ?? false;
@@ -3740,6 +4041,10 @@ async function registerDevice(pctx, input) {
3740
4041
  deviceType: type,
3741
4042
  features: featuresArr
3742
4043
  });
4044
+ const priorLocation = existingMeta?.location ?? null;
4045
+ const resolvedParentId = parentDeviceId ?? null;
4046
+ const parentChanged = existingMeta !== void 0 && (existingMeta.parentDeviceId ?? null) !== resolvedParentId;
4047
+ const inherited = priorLocation === null && resolvedParentId !== null && (isFirst || parentChanged) ? await resolveInheritedLocation(pctx.metaStore.rows, resolvedParentId) : null;
3743
4048
  await pctx.metaStore.rows.upsertRegistration({
3744
4049
  deviceId: id,
3745
4050
  addonId,
@@ -3747,21 +4052,69 @@ async function registerDevice(pctx, input) {
3747
4052
  type,
3748
4053
  name: resolvedName,
3749
4054
  userNamed: wasUserNamed,
3750
- location: existingMeta?.location ?? null,
4055
+ location: priorLocation ?? inherited,
3751
4056
  disabled: existingMeta?.disabled ?? false,
3752
- parentDeviceId: parentDeviceId ?? null,
4057
+ parentDeviceId: resolvedParentId,
3753
4058
  registered: true,
3754
4059
  features: featuresArr,
3755
4060
  exportFingerprint: fingerprint
3756
4061
  });
4062
+ const descendantsFilled = [];
4063
+ const descendantsFailed = [];
4064
+ if (inherited !== null) for (const descendant of await collectDescendants(pctx.metaStore, id)) {
4065
+ if (descendant.location !== null) continue;
4066
+ try {
4067
+ await pctx.metaStore.rows.patch(descendant.id, { location: inherited });
4068
+ descendantsFilled.push(descendant.id);
4069
+ } catch (err) {
4070
+ descendantsFailed.push({
4071
+ id: descendant.id,
4072
+ error: err
4073
+ });
4074
+ }
4075
+ }
3757
4076
  return {
3758
4077
  isFirstRegistration: isFirst,
3759
4078
  exportFingerprint: fingerprint,
3760
- fingerprintChanged: existingMeta?.exportFingerprint !== fingerprint
4079
+ fingerprintChanged: existingMeta?.exportFingerprint !== fingerprint,
4080
+ inheritedLocation: inherited,
4081
+ inheritedDescendantIds: descendantsFilled,
4082
+ inheritFailed: descendantsFailed
3761
4083
  };
3762
4084
  });
3763
4085
  if (Object.keys(config).length > 0) await pctx.settings.writeDeviceStore(id, config);
3764
4086
  pctx.metaStore.idToAddonId.set(id, addonId);
4087
+ if (inheritedLocation !== null) {
4088
+ for (const changedId of [id, ...inheritedDescendantIds]) pctx.host.ctx.eventBus.emit({
4089
+ id: (0, node_crypto.randomUUID)(),
4090
+ timestamp: /* @__PURE__ */ new Date(),
4091
+ source: {
4092
+ type: "device",
4093
+ id: changedId
4094
+ },
4095
+ category: require_dist.EventCategory.DeviceMetaChanged,
4096
+ data: {
4097
+ deviceId: changedId,
4098
+ field: "location",
4099
+ value: inheritedLocation
4100
+ }
4101
+ });
4102
+ pctx.host.ctx.logger.info("registerDevice: inherited located-ancestor location", {
4103
+ tags: { deviceId: id },
4104
+ meta: {
4105
+ location: inheritedLocation,
4106
+ descendantsFilled: inheritedDescendantIds.length
4107
+ }
4108
+ });
4109
+ if (inheritFailed.length > 0) pctx.host.ctx.logger.error("registerDevice: inherited-location fill partially failed — some descendants NOT aligned", {
4110
+ tags: { deviceId: id },
4111
+ meta: {
4112
+ location: inheritedLocation,
4113
+ failedIds: inheritFailed.map((f) => f.id),
4114
+ errors: inheritFailed.map((f) => f.error instanceof Error ? f.error.message : String(f.error))
4115
+ }
4116
+ });
4117
+ }
3765
4118
  if (isFirstRegistration) pctx.host.ctx.eventBus.emit({
3766
4119
  id: (0, node_crypto.randomUUID)(),
3767
4120
  timestamp: /* @__PURE__ */ new Date(),
@@ -6353,7 +6706,8 @@ var DeviceManagerAddon = class extends require_dist.BaseAddon {
6353
6706
  const stateMirror = this.stateMirrorImpl;
6354
6707
  const resolvePersistedById = metaStore.resolvePersistedById;
6355
6708
  const idToAddonId = metaStore.idToAddonId;
6356
- for (const row of await deviceRows.listAll()) idToAddonId.set(row.meta.id, row.meta.addonId);
6709
+ const bootRows = await deviceRows.listAll();
6710
+ for (const row of bootRows) idToAddonId.set(row.meta.id, row.meta.addonId);
6357
6711
  const stampIntegrationId$1 = (deviceId, integrationId) => stampIntegrationId(metaStore, this.ctx, deviceId, integrationId);
6358
6712
  const pctx = {
6359
6713
  host: this.providerHost,
@@ -6368,6 +6722,11 @@ var DeviceManagerAddon = class extends require_dist.BaseAddon {
6368
6722
  return provider;
6369
6723
  }
6370
6724
  };
6725
+ try {
6726
+ await runLocationBackfillAtBoot(pctx, bootRows);
6727
+ } catch (err) {
6728
+ this.ctx.logger.warn("location backfill failed — fleet boots without it", { meta: { error: require_dist.errMsg(err) } });
6729
+ }
6371
6730
  const provider = {
6372
6731
  resolveDeviceOwnerSync: (deviceId) => resolveDeviceOwnerSync(pctx, deviceId),
6373
6732
  resolveNativeCapOwnerSync: (capName, deviceId) => resolveNativeCapOwnerSync(pctx, capName, deviceId),