@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
- import { $t as isDeviceConfigCap, Bt as DeviceRole, Ft as BaseAddon, H as deviceManagerCapability, J as enumerateSchemaFields, P as buildStreamParamsConfigSchema, Pt as errMsg, U as deviceStateCapability, Vt as DeviceType, W as deviceStatusCapability, Wt as WELL_KNOWN_TAB_MAP, a as CAMERA_SWITCH_ORDER, b as STREAM_PROFILE_META, f as DeviceStatusSchema, ft as normalizeUnit, mt as parseStreamParamsFormPatch, o as CAP_NAMES_WITH_STATUS, on as sleep, q as enumerateItemArrayFields, sn as EventCategory, t as ALL_CAPABILITY_DEFINITIONS, xt as runtimeStatePolicyFor, yt as resolveContainerPrimaryChild, zt as DeviceFeature } from "../../dist-CCMYMmkQ.mjs";
2
- import { n as purgeRetiredSettingsRows } from "../../retired-settings-keys-C0uslzAd.mjs";
1
+ import { $t as isDeviceConfigCap, Bt as DeviceRole, Ft as BaseAddon, H as deviceManagerCapability, J as enumerateSchemaFields, P as buildStreamParamsConfigSchema, Pt as errMsg, U as deviceStateCapability, Vt as DeviceType, W as deviceStatusCapability, Wt as WELL_KNOWN_TAB_MAP, a as CAMERA_SWITCH_ORDER, b as STREAM_PROFILE_META, f as DeviceStatusSchema, ft as normalizeUnit, mt as parseStreamParamsFormPatch, o as CAP_NAMES_WITH_STATUS, on as sleep, q as enumerateItemArrayFields, sn as EventCategory, t as ALL_CAPABILITY_DEFINITIONS, xt as runtimeStatePolicyFor, yt as resolveContainerPrimaryChild, zt as DeviceFeature } from "../../dist-BLU9Dol1.mjs";
2
+ import { n as purgeRetiredSettingsRows } from "../../retired-settings-keys-rIzS2f51.mjs";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { canonicalDeviceFingerprint } from "@camstack/types/node";
5
5
  //#region src/builtins/device-manager/adoption-job-engine.ts
@@ -2167,6 +2167,91 @@ function resolveTrackedEventDeviceIds(config, linkedIds) {
2167
2167
  */
2168
2168
  var LINK_CANDIDATE_PROJECTION = { projection: "slim" };
2169
2169
  /**
2170
+ * The broken state the 2026-08-26 design leaves possible, made DETECTABLE: a
2171
+ * container whose OWN location matches the camera's while its descendants
2172
+ * carry `location: null` (a location that predates the cascade, or a cascade
2173
+ * write that failed) is skipped by the container rule AND matched by nobody —
2174
+ * the whole appliance silently vanishes from auto-link. Measured live
2175
+ * 2026-09-05: 40 containers, 373 children, zero log lines.
2176
+ *
2177
+ * A container qualifies when it matches the camera's location, has at least
2178
+ * one descendant, contributed NONE of them to the resolved set, and at least
2179
+ * one descendant is location-less. A subtree whose members all carry a
2180
+ * DIFFERENT explicit location is excluded on purpose — that is an operator's
2181
+ * deliberate split, not the broken state.
2182
+ */
2183
+ function findLocationInvisibleContainers(params) {
2184
+ const { selfId, selfLocation, resolvedIds, candidates } = params;
2185
+ if (selfLocation === null || selfLocation.length === 0) return [];
2186
+ const byParent = /* @__PURE__ */ new Map();
2187
+ for (const c of candidates) {
2188
+ if (c.parentDeviceId === null) continue;
2189
+ const bucket = byParent.get(c.parentDeviceId);
2190
+ if (bucket) bucket.push(c);
2191
+ else byParent.set(c.parentDeviceId, [c]);
2192
+ }
2193
+ const out = [];
2194
+ for (const c of candidates) {
2195
+ if (c.type !== DeviceType.Container) continue;
2196
+ if (c.id === selfId) continue;
2197
+ if (c.location !== selfLocation) continue;
2198
+ let anyResolved = false;
2199
+ let anyNullLocation = false;
2200
+ let descendantCount = 0;
2201
+ const visited = new Set([c.id]);
2202
+ let frontier = [c.id];
2203
+ while (frontier.length > 0) {
2204
+ const next = [];
2205
+ for (const parentId of frontier) for (const child of byParent.get(parentId) ?? []) {
2206
+ if (visited.has(child.id)) continue;
2207
+ visited.add(child.id);
2208
+ descendantCount += 1;
2209
+ if (resolvedIds.has(child.id)) anyResolved = true;
2210
+ if (child.location === null) anyNullLocation = true;
2211
+ next.push(child.id);
2212
+ }
2213
+ frontier = next;
2214
+ }
2215
+ if (descendantCount > 0 && !anyResolved && anyNullLocation) out.push(c.id);
2216
+ }
2217
+ return out;
2218
+ }
2219
+ /**
2220
+ * Per-process warn dedupe for {@link findLocationInvisibleContainers}. This
2221
+ * read path runs continuously (the analytics reverse index resolves linked
2222
+ * devices for every camera on a timer), so an undeduped warn would be a log
2223
+ * flood about a state that only an operator action (or the boot backfill)
2224
+ * changes. One line per (camera, container) pair per process is exactly as
2225
+ * loud as the fact is. Bounded so pathological id churn cannot grow it
2226
+ * forever; once full it stops ADDING, never stops the resolver.
2227
+ */
2228
+ var INVISIBLE_CONTAINER_WARN_CAP = 4096;
2229
+ var warnedInvisibleContainers = /* @__PURE__ */ new Set();
2230
+ function warnLocationInvisibleContainers(pctx, cameraId, byId, all, resolved) {
2231
+ if (resolved.mode !== "auto") return;
2232
+ const selfLocation = byId.get(cameraId)?.location ?? null;
2233
+ if (selfLocation === null || selfLocation.length === 0) return;
2234
+ const invisible = findLocationInvisibleContainers({
2235
+ selfId: cameraId,
2236
+ selfLocation,
2237
+ resolvedIds: new Set(resolved.devices.map((d) => d.deviceId)),
2238
+ candidates: all
2239
+ });
2240
+ for (const containerId of invisible) {
2241
+ const key = `${cameraId}:${containerId}`;
2242
+ if (warnedInvisibleContainers.has(key)) continue;
2243
+ if (warnedInvisibleContainers.size >= INVISIBLE_CONTAINER_WARN_CAP) return;
2244
+ warnedInvisibleContainers.add(key);
2245
+ 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", {
2246
+ tags: { deviceId: containerId },
2247
+ meta: {
2248
+ cameraId,
2249
+ location: selfLocation
2250
+ }
2251
+ });
2252
+ }
2253
+ }
2254
+ /**
2170
2255
  * Resolve ONE camera's linked set against an ALREADY-FETCHED fleet.
2171
2256
  *
2172
2257
  * The fleet is a parameter, not a fetch, because it is the entire cost of this
@@ -2204,7 +2289,10 @@ function resolveFor(deviceId, all, byId, blob) {
2204
2289
  async function getLinkedDevices(pctx, input) {
2205
2290
  const { deviceId } = input;
2206
2291
  const [all, blob] = await Promise.all([listAll(pctx, LINK_CANDIDATE_PROJECTION), pctx.settings.readDeviceStore(deviceId)]);
2207
- return resolveFor(deviceId, all, new Map(all.map((d) => [d.id, d])), blob);
2292
+ const byId = new Map(all.map((d) => [d.id, d]));
2293
+ const resolved = resolveFor(deviceId, all, byId, blob);
2294
+ warnLocationInvisibleContainers(pctx, deviceId, byId, all, resolved);
2295
+ return resolved;
2208
2296
  }
2209
2297
  /**
2210
2298
  * Cap implementation of `getLinkedDevicesBatch` — ONE fleet listing for the
@@ -2230,10 +2318,14 @@ async function getLinkedDevicesBatch(pctx, input) {
2230
2318
  return {};
2231
2319
  })))]);
2232
2320
  const byId = new Map(all.map((d) => [d.id, d]));
2233
- return deviceIds.map((deviceId, index) => ({
2234
- deviceId,
2235
- ...resolveFor(deviceId, all, byId, blobs[index] ?? {})
2236
- }));
2321
+ return deviceIds.map((deviceId, index) => {
2322
+ const resolved = resolveFor(deviceId, all, byId, blobs[index] ?? {});
2323
+ warnLocationInvisibleContainers(pctx, deviceId, byId, all, resolved);
2324
+ return {
2325
+ deviceId,
2326
+ ...resolved
2327
+ };
2328
+ });
2237
2329
  }
2238
2330
  /**
2239
2331
  * "Linked devices" settings section for a CAMERA (null for every other
@@ -3520,6 +3612,215 @@ async function swapHardwareState(settings, sourceId, targetId) {
3520
3612
  };
3521
3613
  }
3522
3614
  //#endregion
3615
+ //#region src/builtins/device-manager/location-alignment.ts
3616
+ /**
3617
+ * Location alignment — closes the RETROACTIVITY hole D252/D329 left open, and
3618
+ * makes the broken state LOUD wherever it survives.
3619
+ *
3620
+ * The cascade (`setLocation` → every descendant's own `location` column,
3621
+ * device-meta-actions.ts) runs only when `setLocation` is CALLED. Both records
3622
+ * said so out loud ("Nothing retroactive") and the fleet paid for it: measured
3623
+ * on the live hub 2026-09-04, 40 containers whose location predated the
3624
+ * cascade carried 373 children at `location: null` — every one of those
3625
+ * containers invisible to auto-mode linking-by-location (the container itself
3626
+ * is deliberately never a link; its children never matched), and not one log
3627
+ * line said so. Camera 615 showed one siren where four appliances belonged.
3628
+ *
3629
+ * Two mechanisms close it, both WRITE-time — read-time inheritance stays
3630
+ * rejected (D252's context: a live-resolved location is invisible everywhere
3631
+ * except the one call site that resolves it):
3632
+ *
3633
+ * 1. **A one-shot boot backfill** (`runLocationBackfillAtBoot`). For every
3634
+ * device whose `location` is null and whose parent chain reaches a located
3635
+ * CONTAINER ancestor (container-only — see `planNullLocationFills` for
3636
+ * why a camera's subtree is deliberately left alone), the NEAREST located
3637
+ * ancestor's location is written onto the
3638
+ * row — once per install, marker-guarded. It fills NULL ONLY: an automated
3639
+ * pass carries no operator intent, so it must never overwrite an explicit
3640
+ * different location the way an operator's `setLocation` deliberately
3641
+ * does. Runs under `withMetaWriteLock` and re-verifies each row is still
3642
+ * null before patching, so it cannot fight a concurrent edit. The marker
3643
+ * is only advanced when every planned write LANDED — a partial repair
3644
+ * retries next boot (bounded: once per boot), never silently half-done.
3645
+ *
3646
+ * 2. **Registration-time inheritance** (`resolveInheritedLocation`, consumed
3647
+ * by `registerDevice` in device-meta-actions.ts). A child adopted AFTER
3648
+ * its parent's location was set is the same hole reopened — the cascade
3649
+ * only fires on `setLocation`, and nobody re-saves a container because HA
3650
+ * grew an entity. So a newly-registered (or re-parented) row whose own
3651
+ * location is null inherits the nearest located CONTAINER ancestor's
3652
+ * location the instant it registers, and pulls it down onto its own
3653
+ * null-location descendants (out-of-order adoption registers children
3654
+ * before parents).
3655
+ *
3656
+ * And one detection: every boot, read-only, the fleet is checked for the
3657
+ * broken state and each misaligned ancestor is WARNED about, device-tagged —
3658
+ * the state can re-enter (a partially-failed cascade, a hand-edited ledger)
3659
+ * and a branch that drops work must say so.
3660
+ */
3661
+ /** Addon-store key of the one-shot marker. A number: the highest backfill
3662
+ * version that COMPLETED (every planned write landed). */
3663
+ var LOCATION_BACKFILL_MARKER_KEY = "locationBackfillVersion";
3664
+ /** Walk the parent chain of `startParentId` over an in-memory fleet snapshot
3665
+ * and return the nearest ancestor carrying a non-null location. Cycle-guarded:
3666
+ * a chain that revisits a node answers null rather than spinning. */
3667
+ function nearestLocatedAncestor(byId, startParentId) {
3668
+ const visited = /* @__PURE__ */ new Set();
3669
+ let cursor = startParentId;
3670
+ while (cursor !== null && !visited.has(cursor)) {
3671
+ visited.add(cursor);
3672
+ const ancestor = byId.get(cursor);
3673
+ if (!ancestor) return null;
3674
+ if (ancestor.location !== null) return ancestor;
3675
+ cursor = ancestor.parentDeviceId;
3676
+ }
3677
+ return null;
3678
+ }
3679
+ /**
3680
+ * Pure planning core of the backfill: every row whose own `location` is null
3681
+ * and whose parent chain reaches a located CONTAINER ancestor gets that
3682
+ * ancestor's location. Nearest located ancestor wins — and DECIDES: a located
3683
+ * non-container between the row and a located container blocks the fill.
3684
+ *
3685
+ * The container-only predicate is an operator decision (2026-09-05), the
3686
+ * automated mirror of the structural-children rule: a non-container parent's
3687
+ * subtree (a camera and its siren/floodlight) is already fully reachable
3688
+ * through `resolveLinkedDeviceIds`' children-of-self rule, so a location buys
3689
+ * its own camera nothing — and COSTS cross-camera noise, verified against the
3690
+ * real fleet shape with the real resolver: a camera child that gains the room
3691
+ * matches every OTHER camera in that room individually (`ids.add`; only a
3692
+ * Container parent routes through `expand`), so camera B lists camera A's
3693
+ * siren as a companion. A container's descendants, by contrast, are reachable
3694
+ * by location ONLY through their own columns — the container itself is never
3695
+ * a link — which is the whole reason the cascade writes them (D252). Note the
3696
+ * asymmetry with the OPERATOR's cascade, which has no type predicate (D329):
3697
+ * an operator re-saving a camera accepts that trade explicitly; an automated
3698
+ * pass must not manufacture it.
3699
+ */
3700
+ function planNullLocationFills(rows) {
3701
+ const byId = new Map(rows.map((r) => [r.id, r]));
3702
+ const plan = [];
3703
+ for (const row of rows) {
3704
+ if (row.location !== null) continue;
3705
+ if (row.parentDeviceId === null) continue;
3706
+ const hit = nearestLocatedAncestor(byId, row.parentDeviceId);
3707
+ if (hit === null) continue;
3708
+ if (hit.type !== DeviceType.Container) continue;
3709
+ if (hit.location === null) continue;
3710
+ plan.push({
3711
+ id: row.id,
3712
+ location: hit.location,
3713
+ ancestorId: hit.id
3714
+ });
3715
+ }
3716
+ return plan;
3717
+ }
3718
+ /**
3719
+ * Async ancestor walk for `registerDevice`: the nearest located CONTAINER
3720
+ * ancestor's location, read row by row (registration has no fleet snapshot in
3721
+ * hand and must not pay for one). Same container-only predicate as
3722
+ * {@link planNullLocationFills}, and for the same reason — a child adopted
3723
+ * under a located CAMERA is already linked structurally to that camera, and a
3724
+ * location on its own column would make it a companion of every other camera
3725
+ * in the room. The nearest located ancestor decides: a located non-container
3726
+ * on the way up answers null. Cycle-guarded like the pure walk; a missing row
3727
+ * — a parent registered out of order — answers null, and the parent's own
3728
+ * later registration pulls the location down instead (see `registerDevice`).
3729
+ */
3730
+ async function resolveInheritedLocation(rows, parentDeviceId) {
3731
+ const visited = /* @__PURE__ */ new Set();
3732
+ let cursor = parentDeviceId;
3733
+ while (cursor !== null && !visited.has(cursor)) {
3734
+ visited.add(cursor);
3735
+ const ancestor = await rows.get(cursor);
3736
+ if (ancestor === null) return null;
3737
+ if (ancestor.meta.location !== null) return ancestor.meta.type === DeviceType.Container ? ancestor.meta.location : null;
3738
+ cursor = ancestor.meta.parentDeviceId;
3739
+ }
3740
+ return null;
3741
+ }
3742
+ /** `fills` grouped by the ancestor whose location they inherit — the unit the
3743
+ * logs speak in, because the ancestor is the device an operator would act on. */
3744
+ function groupByAncestor(fills) {
3745
+ const grouped = /* @__PURE__ */ new Map();
3746
+ for (const fill of fills) {
3747
+ const bucket = grouped.get(fill.ancestorId);
3748
+ if (bucket) bucket.push(fill);
3749
+ else grouped.set(fill.ancestorId, [fill]);
3750
+ }
3751
+ return grouped;
3752
+ }
3753
+ /**
3754
+ * Boot entrypoint: the one-shot repair (marker-guarded) plus the every-boot
3755
+ * detection. `bootRows` is the fleet listing the addon's warm-boot pass
3756
+ * already paid for — reused for the detection so a clean boot costs no extra
3757
+ * fleet read; ignored by the repair, which takes its own listing UNDER the
3758
+ * meta write lock (the snapshot must not predate the lock).
3759
+ */
3760
+ async function runLocationBackfillAtBoot(pctx, bootRows) {
3761
+ const logger = pctx.host.ctx.logger;
3762
+ const markerRaw = (await pctx.settings.readAddonStore())[LOCATION_BACKFILL_MARKER_KEY];
3763
+ const completedVersion = typeof markerRaw === "number" ? markerRaw : 0;
3764
+ let repaired = false;
3765
+ if (completedVersion < 1) {
3766
+ const patched = [];
3767
+ const failed = [];
3768
+ await pctx.metaStore.withMetaWriteLock(async () => {
3769
+ const plan = planNullLocationFills((await pctx.metaStore.rows.listAll()).map((r) => r.meta));
3770
+ for (const fill of plan) try {
3771
+ const current = await pctx.metaStore.rows.get(fill.id);
3772
+ if (current === null || current.meta.location !== null) continue;
3773
+ await pctx.metaStore.rows.patch(fill.id, { location: fill.location });
3774
+ patched.push(fill);
3775
+ } catch (err) {
3776
+ failed.push({
3777
+ id: fill.id,
3778
+ error: err
3779
+ });
3780
+ }
3781
+ });
3782
+ repaired = true;
3783
+ for (const [ancestorId, fills] of groupByAncestor(patched)) {
3784
+ pctx.host.ctx.eventBus.emit({
3785
+ id: randomUUID(),
3786
+ timestamp: /* @__PURE__ */ new Date(),
3787
+ source: {
3788
+ type: "device",
3789
+ id: ancestorId
3790
+ },
3791
+ category: EventCategory.DeviceMetaChanged,
3792
+ data: {
3793
+ deviceId: ancestorId,
3794
+ field: "location",
3795
+ value: fills[0]?.location ?? null
3796
+ }
3797
+ });
3798
+ logger.info("location backfill: aligned location-less descendants to their ancestor", {
3799
+ tags: { deviceId: ancestorId },
3800
+ meta: {
3801
+ location: fills[0]?.location,
3802
+ updatedIds: fills.map((f) => f.id)
3803
+ }
3804
+ });
3805
+ }
3806
+ if (failed.length > 0) logger.error("location backfill partially failed — retrying on the next boot", { meta: {
3807
+ failedIds: failed.map((f) => f.id),
3808
+ errors: failed.map((f) => f.error instanceof Error ? f.error.message : String(f.error))
3809
+ } });
3810
+ else await pctx.metaStore.withAddonStoreWriteLock(async () => {
3811
+ await pctx.settings.writeAddonStore({ [LOCATION_BACKFILL_MARKER_KEY]: 1 });
3812
+ });
3813
+ }
3814
+ const residual = planNullLocationFills((!repaired && bootRows !== void 0 ? bootRows : await pctx.metaStore.rows.listAll()).map((r) => r.meta));
3815
+ 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", {
3816
+ tags: { deviceId: ancestorId },
3817
+ meta: {
3818
+ location: fills[0]?.location,
3819
+ misalignedDescendantIds: fills.map((f) => f.id)
3820
+ }
3821
+ });
3822
+ }
3823
+ //#endregion
3523
3824
  //#region src/builtins/device-manager/device-meta-actions.ts
3524
3825
  /**
3525
3826
  * Device meta-mutation + persistence actions for the device-manager addon —
@@ -3723,7 +4024,7 @@ async function migrateDevice(pctx, input) {
3723
4024
  async function registerDevice(pctx, input) {
3724
4025
  const { addonId, stableId, id, type, name, parentDeviceId, features, config } = input;
3725
4026
  const featuresArr = Array.isArray(features) ? [...features] : [];
3726
- const { isFirstRegistration, exportFingerprint, fingerprintChanged } = await pctx.metaStore.withMetaWriteLock(async () => {
4027
+ const { isFirstRegistration, exportFingerprint, fingerprintChanged, inheritedLocation, inheritedDescendantIds, inheritFailed } = await pctx.metaStore.withMetaWriteLock(async () => {
3727
4028
  const existingRow = await pctx.metaStore.rows.get(id);
3728
4029
  const existingMeta = existingRow?.meta;
3729
4030
  const wasRegistered = existingRow?.registered ?? false;
@@ -3735,6 +4036,10 @@ async function registerDevice(pctx, input) {
3735
4036
  deviceType: type,
3736
4037
  features: featuresArr
3737
4038
  });
4039
+ const priorLocation = existingMeta?.location ?? null;
4040
+ const resolvedParentId = parentDeviceId ?? null;
4041
+ const parentChanged = existingMeta !== void 0 && (existingMeta.parentDeviceId ?? null) !== resolvedParentId;
4042
+ const inherited = priorLocation === null && resolvedParentId !== null && (isFirst || parentChanged) ? await resolveInheritedLocation(pctx.metaStore.rows, resolvedParentId) : null;
3738
4043
  await pctx.metaStore.rows.upsertRegistration({
3739
4044
  deviceId: id,
3740
4045
  addonId,
@@ -3742,21 +4047,69 @@ async function registerDevice(pctx, input) {
3742
4047
  type,
3743
4048
  name: resolvedName,
3744
4049
  userNamed: wasUserNamed,
3745
- location: existingMeta?.location ?? null,
4050
+ location: priorLocation ?? inherited,
3746
4051
  disabled: existingMeta?.disabled ?? false,
3747
- parentDeviceId: parentDeviceId ?? null,
4052
+ parentDeviceId: resolvedParentId,
3748
4053
  registered: true,
3749
4054
  features: featuresArr,
3750
4055
  exportFingerprint: fingerprint
3751
4056
  });
4057
+ const descendantsFilled = [];
4058
+ const descendantsFailed = [];
4059
+ if (inherited !== null) for (const descendant of await collectDescendants(pctx.metaStore, id)) {
4060
+ if (descendant.location !== null) continue;
4061
+ try {
4062
+ await pctx.metaStore.rows.patch(descendant.id, { location: inherited });
4063
+ descendantsFilled.push(descendant.id);
4064
+ } catch (err) {
4065
+ descendantsFailed.push({
4066
+ id: descendant.id,
4067
+ error: err
4068
+ });
4069
+ }
4070
+ }
3752
4071
  return {
3753
4072
  isFirstRegistration: isFirst,
3754
4073
  exportFingerprint: fingerprint,
3755
- fingerprintChanged: existingMeta?.exportFingerprint !== fingerprint
4074
+ fingerprintChanged: existingMeta?.exportFingerprint !== fingerprint,
4075
+ inheritedLocation: inherited,
4076
+ inheritedDescendantIds: descendantsFilled,
4077
+ inheritFailed: descendantsFailed
3756
4078
  };
3757
4079
  });
3758
4080
  if (Object.keys(config).length > 0) await pctx.settings.writeDeviceStore(id, config);
3759
4081
  pctx.metaStore.idToAddonId.set(id, addonId);
4082
+ if (inheritedLocation !== null) {
4083
+ for (const changedId of [id, ...inheritedDescendantIds]) pctx.host.ctx.eventBus.emit({
4084
+ id: randomUUID(),
4085
+ timestamp: /* @__PURE__ */ new Date(),
4086
+ source: {
4087
+ type: "device",
4088
+ id: changedId
4089
+ },
4090
+ category: EventCategory.DeviceMetaChanged,
4091
+ data: {
4092
+ deviceId: changedId,
4093
+ field: "location",
4094
+ value: inheritedLocation
4095
+ }
4096
+ });
4097
+ pctx.host.ctx.logger.info("registerDevice: inherited located-ancestor location", {
4098
+ tags: { deviceId: id },
4099
+ meta: {
4100
+ location: inheritedLocation,
4101
+ descendantsFilled: inheritedDescendantIds.length
4102
+ }
4103
+ });
4104
+ if (inheritFailed.length > 0) pctx.host.ctx.logger.error("registerDevice: inherited-location fill partially failed — some descendants NOT aligned", {
4105
+ tags: { deviceId: id },
4106
+ meta: {
4107
+ location: inheritedLocation,
4108
+ failedIds: inheritFailed.map((f) => f.id),
4109
+ errors: inheritFailed.map((f) => f.error instanceof Error ? f.error.message : String(f.error))
4110
+ }
4111
+ });
4112
+ }
3760
4113
  if (isFirstRegistration) pctx.host.ctx.eventBus.emit({
3761
4114
  id: randomUUID(),
3762
4115
  timestamp: /* @__PURE__ */ new Date(),
@@ -6348,7 +6701,8 @@ var DeviceManagerAddon = class extends BaseAddon {
6348
6701
  const stateMirror = this.stateMirrorImpl;
6349
6702
  const resolvePersistedById = metaStore.resolvePersistedById;
6350
6703
  const idToAddonId = metaStore.idToAddonId;
6351
- for (const row of await deviceRows.listAll()) idToAddonId.set(row.meta.id, row.meta.addonId);
6704
+ const bootRows = await deviceRows.listAll();
6705
+ for (const row of bootRows) idToAddonId.set(row.meta.id, row.meta.addonId);
6352
6706
  const stampIntegrationId$1 = (deviceId, integrationId) => stampIntegrationId(metaStore, this.ctx, deviceId, integrationId);
6353
6707
  const pctx = {
6354
6708
  host: this.providerHost,
@@ -6363,6 +6717,11 @@ var DeviceManagerAddon = class extends BaseAddon {
6363
6717
  return provider;
6364
6718
  }
6365
6719
  };
6720
+ try {
6721
+ await runLocationBackfillAtBoot(pctx, bootRows);
6722
+ } catch (err) {
6723
+ this.ctx.logger.warn("location backfill failed — fleet boots without it", { meta: { error: errMsg(err) } });
6724
+ }
6366
6725
  const provider = {
6367
6726
  resolveDeviceOwnerSync: (deviceId) => resolveDeviceOwnerSync(pctx, deviceId),
6368
6727
  resolveNativeCapOwnerSync: (capName, deviceId) => resolveNativeCapOwnerSync(pctx, capName, deviceId),
@@ -0,0 +1,61 @@
1
+ import { PersistedDeviceMeta } from './device-meta-types.js';
2
+ import { ProviderContext } from './device-provider-context.js';
3
+ import { DeviceRow } from './device-row-store.js';
4
+ /** Addon-store key of the one-shot marker. A number: the highest backfill
5
+ * version that COMPLETED (every planned write landed). */
6
+ export declare const LOCATION_BACKFILL_MARKER_KEY = "locationBackfillVersion";
7
+ export declare const LOCATION_BACKFILL_VERSION = 1;
8
+ /** One planned repair: write `location` onto row `id`, inherited from the
9
+ * nearest located ancestor `ancestorId`. */
10
+ export interface NullLocationFill {
11
+ readonly id: number;
12
+ readonly location: string;
13
+ readonly ancestorId: number;
14
+ }
15
+ /**
16
+ * Pure planning core of the backfill: every row whose own `location` is null
17
+ * and whose parent chain reaches a located CONTAINER ancestor gets that
18
+ * ancestor's location. Nearest located ancestor wins — and DECIDES: a located
19
+ * non-container between the row and a located container blocks the fill.
20
+ *
21
+ * The container-only predicate is an operator decision (2026-09-05), the
22
+ * automated mirror of the structural-children rule: a non-container parent's
23
+ * subtree (a camera and its siren/floodlight) is already fully reachable
24
+ * through `resolveLinkedDeviceIds`' children-of-self rule, so a location buys
25
+ * its own camera nothing — and COSTS cross-camera noise, verified against the
26
+ * real fleet shape with the real resolver: a camera child that gains the room
27
+ * matches every OTHER camera in that room individually (`ids.add`; only a
28
+ * Container parent routes through `expand`), so camera B lists camera A's
29
+ * siren as a companion. A container's descendants, by contrast, are reachable
30
+ * by location ONLY through their own columns — the container itself is never
31
+ * a link — which is the whole reason the cascade writes them (D252). Note the
32
+ * asymmetry with the OPERATOR's cascade, which has no type predicate (D329):
33
+ * an operator re-saving a camera accepts that trade explicitly; an automated
34
+ * pass must not manufacture it.
35
+ */
36
+ export declare function planNullLocationFills(rows: readonly PersistedDeviceMeta[]): readonly NullLocationFill[];
37
+ /** The one row-store method the async ancestor walk needs. */
38
+ export interface RowReader {
39
+ get(deviceId: number): Promise<DeviceRow | null>;
40
+ }
41
+ /**
42
+ * Async ancestor walk for `registerDevice`: the nearest located CONTAINER
43
+ * ancestor's location, read row by row (registration has no fleet snapshot in
44
+ * hand and must not pay for one). Same container-only predicate as
45
+ * {@link planNullLocationFills}, and for the same reason — a child adopted
46
+ * under a located CAMERA is already linked structurally to that camera, and a
47
+ * location on its own column would make it a companion of every other camera
48
+ * in the room. The nearest located ancestor decides: a located non-container
49
+ * on the way up answers null. Cycle-guarded like the pure walk; a missing row
50
+ * — a parent registered out of order — answers null, and the parent's own
51
+ * later registration pulls the location down instead (see `registerDevice`).
52
+ */
53
+ export declare function resolveInheritedLocation(rows: RowReader, parentDeviceId: number | null): Promise<string | null>;
54
+ /**
55
+ * Boot entrypoint: the one-shot repair (marker-guarded) plus the every-boot
56
+ * detection. `bootRows` is the fleet listing the addon's warm-boot pass
57
+ * already paid for — reused for the detection so a clean boot costs no extra
58
+ * fleet read; ignored by the repair, which takes its own listing UNDER the
59
+ * meta write lock (the snapshot must not predate the lock).
60
+ */
61
+ export declare function runLocationBackfillAtBoot(pctx: ProviderContext, bootRows?: readonly DeviceRow[]): Promise<void>;
@@ -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/doorbell/trigger-engine.ts
8
8
  /**
9
9
  * Pure trigger logic for the virtual-doorbell builtin — edge detection over
@@ -1,4 +1,4 @@
1
- import { Ft as BaseAddon, G as doorbellCapability, Jt as createEvent, M as bareAddonId, Pt as errMsg, Vt as DeviceType, Y as evaluateSensorEdge, _ as SOURCE_CAPS, nt as isSameAddonId, rt as isSourceCap, sn as EventCategory, v as SOURCE_DEVICE_TYPES } from "../../dist-CCMYMmkQ.mjs";
1
+ import { Ft as BaseAddon, G as doorbellCapability, Jt as createEvent, M as bareAddonId, Pt as errMsg, Vt as DeviceType, Y as evaluateSensorEdge, _ as SOURCE_CAPS, nt as isSameAddonId, rt as isSourceCap, sn as EventCategory, v as SOURCE_DEVICE_TYPES } from "../../dist-BLU9Dol1.mjs";
2
2
  //#region src/builtins/doorbell/trigger-engine.ts
3
3
  /**
4
4
  * Pure trigger logic for the virtual-doorbell builtin — edge detection over
@@ -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/hub-forwarder/hub-forwarder-destination.ts
9
9
  var DEFAULT_OUTBOUND_BUFFER_SIZE = 500;
@@ -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/hub-forwarder/hub-forwarder-destination.ts
4
4
  var DEFAULT_OUTBOUND_BUFFER_SIZE = 500;
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  require("../../chunk-Cek0wNdY.js");
3
- const require_dist = require("../../dist-DE3OH-FG.js");
3
+ const require_dist = require("../../dist-0fjBDzUu.js");
4
4
  //#region src/builtins/liveness-monitor/liveness-checks.ts
5
5
  var NO_DEVICES = "liveness:no-devices";
6
6
  var ALL_OFFLINE = "liveness:all-devices-offline";
@@ -1,4 +1,4 @@
1
- import { Ft as BaseAddon, Jt as createEvent, Pt as errMsg, sn as EventCategory } from "../../dist-CCMYMmkQ.mjs";
1
+ import { Ft as BaseAddon, Jt as createEvent, Pt as errMsg, sn as EventCategory } from "../../dist-BLU9Dol1.mjs";
2
2
  //#region src/builtins/liveness-monitor/liveness-checks.ts
3
3
  var NO_DEVICES = "liveness:no-devices";
4
4
  var ALL_OFFLINE = "liveness:all-devices-offline";
@@ -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
  node_crypto = require_chunk.__toESM(node_crypto);
9
9
  let crypto$1 = require("crypto");
@@ -1,5 +1,5 @@
1
1
  import { i as __require, o as __toESM, t as __commonJSMin } from "../../chunk-CNf5ZN-e.mjs";
2
- import { A as authProviderCapability, Ft as BaseAddon, T as UserRecordSchema, jt as userManagementCapability, r as ApiKeyRecordSchema, x as ScopedTokenSchema } from "../../dist-CCMYMmkQ.mjs";
2
+ import { A as authProviderCapability, Ft as BaseAddon, T as UserRecordSchema, jt as userManagementCapability, r as ApiKeyRecordSchema, x as ScopedTokenSchema } from "../../dist-BLU9Dol1.mjs";
3
3
  import * as crypto$2 from "node:crypto";
4
4
  import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
5
5
  import nodeCrypto from "crypto";
@@ -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
  const require_tls = require("../../tls-BxQlomxd.js");
5
5
  let node_fs_promises = require("node:fs/promises");
6
6
  let node_path = require("node:path");
@@ -1,4 +1,4 @@
1
- import { Ft as BaseAddon, sn as EventCategory, st as localNetworkCapability } from "../../dist-CCMYMmkQ.mjs";
1
+ import { Ft as BaseAddon, sn as EventCategory, st as localNetworkCapability } from "../../dist-BLU9Dol1.mjs";
2
2
  import { S as readLanHttpState, _ as DEFAULT_LAN_HTTP_PORT, a as writeTlsMode, i as writeExtraSans, l as reissueTlsLeaf, n as readTlsAccessStatus, o as validateUploadedTls, r as readTlsMode, s as ensureTlsCert, t as readExtraSans, y as applyLanHttp } from "../../tls-DOTmtLCW.mjs";
3
3
  import { mkdir, writeFile } from "node:fs/promises";
4
4
  import { join } 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/loki-logging/loki-payload.ts
8
8
  /**
9
9
  * Loki rejects a label name that is not a valid Prometheus label
@@ -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
  //#region src/builtins/loki-logging/loki-payload.ts
3
3
  /**
4
4
  * Loki rejects a label name that is not a valid Prometheus label
@@ -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_fs_promises = require("node:fs/promises");
8
8
  let node_child_process = require("node:child_process");
9
9
  let node_util = require("node:util");
@@ -1,4 +1,4 @@
1
- import { Ft as BaseAddon, Jt as createEvent, Pt as errMsg, Q as foldSnapshotByFunction, _t as reducePoints, dt as metricsProviderCapability, sn as EventCategory, vt as resolveBucketMs } from "../../dist-CCMYMmkQ.mjs";
1
+ import { Ft as BaseAddon, Jt as createEvent, Pt as errMsg, Q as foldSnapshotByFunction, _t as reducePoints, dt as metricsProviderCapability, sn as EventCategory, vt as resolveBucketMs } from "../../dist-BLU9Dol1.mjs";
2
2
  import { readFile, readdir } from "node:fs/promises";
3
3
  import { execFile, execFileSync } from "node:child_process";
4
4
  import { promisify } from "node:util";
@@ -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_child_process = require("node:child_process");
8
8
  let node_util = require("node:util");
9
9
  let node_fs = require("node:fs");