@camstack/system 1.2.179 → 1.2.180

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 (64) 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.d.ts +56 -10
  10. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +657 -62
  11. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +658 -63
  12. package/dist/builtins/backup-orchestrator/run-queue.d.ts +103 -0
  13. package/dist/builtins/backup-orchestrator/staging-paths.d.ts +36 -0
  14. package/dist/builtins/console-logging/index.js +1 -1
  15. package/dist/builtins/console-logging/index.mjs +1 -1
  16. package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
  17. package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
  18. package/dist/builtins/device-manager/device-manager.addon.js +146 -5
  19. package/dist/builtins/device-manager/device-manager.addon.mjs +146 -5
  20. package/dist/builtins/device-manager/device-meta-actions.d.ts +1 -1
  21. package/dist/builtins/device-manager/migrate-device.d.ts +33 -0
  22. package/dist/builtins/device-manager/migrate-hardware-state.d.ts +24 -0
  23. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  24. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  25. package/dist/builtins/hub-forwarder/index.js +1 -1
  26. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  27. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  28. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  29. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  30. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  31. package/dist/builtins/local-network/local-network.addon.js +1 -1
  32. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  33. package/dist/builtins/loki-logging/index.js +1 -1
  34. package/dist/builtins/loki-logging/index.mjs +1 -1
  35. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  36. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  37. package/dist/builtins/platform-probe/index.js +1 -1
  38. package/dist/builtins/platform-probe/index.mjs +1 -1
  39. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  40. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  41. package/dist/builtins/snapshot/index.js +1 -1
  42. package/dist/builtins/snapshot/index.mjs +1 -1
  43. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  44. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  45. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
  46. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
  47. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  48. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  49. package/dist/builtins/system-backup/system-backup.service.d.ts +55 -2
  50. package/dist/builtins/system-config/system-config.addon.js +1 -1
  51. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  52. package/dist/builtins/winston-logging/index.js +1 -1
  53. package/dist/builtins/winston-logging/index.mjs +1 -1
  54. package/dist/{dist-L_39vRmH.mjs → dist-DqNTelT4.mjs} +167 -1
  55. package/dist/{dist-C7qWwrK1.js → dist-TEwQZL3g.js} +167 -1
  56. package/dist/index.js +530 -2
  57. package/dist/index.mjs +533 -5
  58. package/dist/kernel/addon-installer.d.ts +69 -0
  59. package/dist/kernel/deps/closure-dedupe.d.ts +105 -0
  60. package/dist/{manifest-system-deps-PAIfmNun.js → manifest-system-deps-BVIr3MYJ.js} +1 -1
  61. package/dist/{manifest-system-deps-CMOGc5nC.mjs → manifest-system-deps-Ci-jhrxY.mjs} +1 -1
  62. package/dist/{retired-settings-keys-CnbKAxmX.mjs → retired-settings-keys-CeSITpIX.mjs} +1 -1
  63. package/dist/{retired-settings-keys-3soU9pfP.js → retired-settings-keys-DauPtbFR.js} +1 -1
  64. package/package.json +1 -1
@@ -0,0 +1,103 @@
1
+ export type BackupRunState = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled';
2
+ export type BackupRunPhase = 'queued' | 'building' | 'uploading' | 'done';
3
+ /** What a trigger asks for — the queue keys joining on this. */
4
+ export interface BackupRunRequest {
5
+ readonly destinations?: readonly string[];
6
+ readonly locations?: readonly string[];
7
+ readonly label?: string;
8
+ readonly retentionCount?: number;
9
+ }
10
+ /** Immutable snapshot of one run — the wire shape of `backup.listRuns`. */
11
+ export interface BackupRunSnapshot {
12
+ readonly id: string;
13
+ readonly state: BackupRunState;
14
+ readonly phase: BackupRunPhase;
15
+ readonly destinationIds: readonly string[];
16
+ readonly label?: string;
17
+ readonly requestedAt: number;
18
+ readonly startedAt?: number;
19
+ readonly finishedAt?: number;
20
+ readonly stagedBytes: number;
21
+ readonly archiveSizeBytes?: number;
22
+ readonly uploadedBytes: number;
23
+ readonly completedDestinationIds: readonly string[];
24
+ readonly failedDestinationIds: readonly string[];
25
+ readonly error?: string;
26
+ /**
27
+ * 1-based place in the execution queue — 1 = runs next. Present only
28
+ * while `state === 'queued'`, and derived from the queue's OWN
29
+ * pending order at read time (never from timestamps), so a consumer
30
+ * can only ever show the order the executor will actually honour.
31
+ */
32
+ readonly queuePosition?: number;
33
+ }
34
+ /**
35
+ * Progress sink handed to the executor. Every update replaces the
36
+ * stored snapshot (no shared mutable state leaks out of the queue).
37
+ */
38
+ export interface BackupRunProgress {
39
+ setPhase(phase: BackupRunPhase): void;
40
+ /** Resolved destination targets — known only once the run starts. */
41
+ setDestinations(ids: readonly string[]): void;
42
+ setStagedBytes(bytes: number): void;
43
+ setArchiveSize(bytes: number): void;
44
+ setUploadedBytes(bytes: number): void;
45
+ destinationCompleted(id: string): void;
46
+ destinationFailed(id: string): void;
47
+ }
48
+ export interface SubmitResult<T> {
49
+ readonly runId: string;
50
+ /** True when the run had to wait behind an in-flight run. */
51
+ readonly queued: boolean;
52
+ /** True when this submit was coalesced onto an identical queued run. */
53
+ readonly joined: boolean;
54
+ /** Settles when the run terminates. Rejects `BackupCancelledError` on cancel-before-any-result. */
55
+ readonly result: Promise<T>;
56
+ }
57
+ export type BackupRunExecutor<T> = (request: BackupRunRequest, signal: AbortSignal, progress: BackupRunProgress) => Promise<T>;
58
+ export interface BackupRunQueueOptions {
59
+ readonly maxFinished?: number;
60
+ readonly now?: () => number;
61
+ }
62
+ /**
63
+ * FIFO queue with concurrency 1. `submit` never starts a second
64
+ * executor while one is in flight; an identical request already
65
+ * WAITING (same destinations/locations/label/retention) is joined
66
+ * instead of queued twice — two schedule ticks due on the same minute
67
+ * produce one run, not two.
68
+ */
69
+ export declare class BackupRunQueue<T> {
70
+ private readonly executor;
71
+ private readonly runs;
72
+ private readonly pending;
73
+ private readonly finished;
74
+ private activeId;
75
+ private readonly maxFinished;
76
+ private readonly now;
77
+ constructor(executor: BackupRunExecutor<T>, opts?: BackupRunQueueOptions);
78
+ submit(request: BackupRunRequest): SubmitResult<T>;
79
+ /**
80
+ * Cancel a run. Queued: removed before it ever starts (its result
81
+ * rejects `BackupCancelledError`). Running: the abort signal fires —
82
+ * the executor's tar/upload streams stop and its cleanup runs.
83
+ * Unknown or already terminal: `false`.
84
+ */
85
+ cancel(runId: string): boolean;
86
+ /**
87
+ * EXECUTION order, not timestamp order: the running run first, then
88
+ * every queued run in the exact order the pump will start them (each
89
+ * stamped with `queuePosition`, 1 = next), then the bounded finished
90
+ * history newest-first. The queued section is read straight off
91
+ * `this.pending` — a consumer sorting by `requestedAt` descending
92
+ * would put the run that starts NEXT at the BOTTOM, which is
93
+ * backwards exactly when the queue matters (more than one waiter).
94
+ */
95
+ listRuns(): readonly BackupRunSnapshot[];
96
+ getRun(runId: string): BackupRunSnapshot | undefined;
97
+ private createRecord;
98
+ private pump;
99
+ private execute;
100
+ private progressFor;
101
+ private patch;
102
+ private finish;
103
+ }
@@ -0,0 +1,36 @@
1
+ import { IScopedLogger } from '@camstack/types';
2
+ /** Basename of the staging dir under the node data root. */
3
+ export declare const STAGING_DIR_NAME = "backup-staging";
4
+ /** Canonical staging dir for a node data root. */
5
+ export declare function resolveStagingDir(nodeDataDir: string): string;
6
+ export interface StagingSweepResult {
7
+ /** Top-level staging entries removed. */
8
+ readonly removedCount: number;
9
+ /** File bytes reclaimed (recursive for removed subdirectories). */
10
+ readonly freedBytes: number;
11
+ /** Entries spared because `keepPaths` named them. */
12
+ readonly keptCount: number;
13
+ }
14
+ export interface DirectoryFootprint {
15
+ readonly present: boolean;
16
+ readonly totalBytes: number;
17
+ readonly fileCount: number;
18
+ }
19
+ /**
20
+ * Delete every top-level entry of `stagingDir` except those whose
21
+ * resolved path appears in `keepPaths`. Per-entry failures are logged
22
+ * and skipped — a locked file must not abort the sweep. A missing
23
+ * staging dir is a clean no-op.
24
+ */
25
+ export declare function sweepStagingDir(input: {
26
+ stagingDir: string;
27
+ keepPaths: readonly string[];
28
+ logger: IScopedLogger;
29
+ }): Promise<StagingSweepResult>;
30
+ /**
31
+ * Recursive size + file count of a path (file or directory). Absent
32
+ * path → `present: false` with zeros. Used by the sweep to report
33
+ * reclaimed bytes and by the orchestrator to surface the legacy
34
+ * staging dir's footprint without touching it.
35
+ */
36
+ export declare function measureDirectoryFootprint(dir: string): Promise<DirectoryFootprint>;
@@ -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-C7qWwrK1.js");
6
+ const require_dist = require("../../dist-TEwQZL3g.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-L_39vRmH.mjs";
1
+ import { Ft as BaseAddon, ct as logDestinationCapability } from "../../dist-DqNTelT4.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-C7qWwrK1.js");
3
+ const require_dist = require("../../dist-TEwQZL3g.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-L_39vRmH.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-DqNTelT4.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";
@@ -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-C7qWwrK1.js");
7
- const require_retired_settings_keys = require("../../retired-settings-keys-3soU9pfP.js");
6
+ const require_dist = require("../../dist-TEwQZL3g.js");
7
+ const require_retired_settings_keys = require("../../retired-settings-keys-DauPtbFR.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
@@ -2114,7 +2114,13 @@ function resolveLinkedDeviceIds(params) {
2114
2114
  if (config.mode === "auto") {
2115
2115
  if (selfLocation !== null && selfLocation.length > 0) for (const c of candidates) {
2116
2116
  if (c.type === require_dist.DeviceType.Container) continue;
2117
- if (c.location === selfLocation) ids.add(c.id);
2117
+ if (c.location !== selfLocation) continue;
2118
+ const parent = c.parentDeviceId !== null ? byId.get(c.parentDeviceId) : void 0;
2119
+ if (parent !== void 0 && parent.type === require_dist.DeviceType.Container) {
2120
+ expand(parent.id);
2121
+ continue;
2122
+ }
2123
+ ids.add(c.id);
2118
2124
  }
2119
2125
  } else for (const id of config.manualIds) if (byId.has(id)) expand(id);
2120
2126
  ids.delete(selfId);
@@ -3327,6 +3333,16 @@ async function migrateDevice$1(deps, input) {
3327
3333
  const down = [...await applyAll(deps, sourceId, false), ...await applyAll(deps, targetId, false)];
3328
3334
  const sourceStillLive = down.filter((r) => r.deviceId === sourceId && r.outcome === "unreachable").map((r) => r.switchId);
3329
3335
  await deps.swapIds(sourceId, targetId);
3336
+ await deps.swapHardwareState(sourceId, targetId);
3337
+ for (const deviceId of [sourceId, targetId]) try {
3338
+ await deps.forgetStreamState(deviceId);
3339
+ } catch (err) {
3340
+ deps.log("migration: broker hardware state NOT forgotten — stale stream state survives", {
3341
+ deviceId,
3342
+ error: err instanceof Error ? err.message : String(err),
3343
+ remedy: "delete the device's derived streams and reassign its stream profiles, or update the stream-broker addon and re-run streamBroker.forgetDeviceHardware"
3344
+ });
3345
+ }
3330
3346
  const up = await applyAll(deps, sourceId, true);
3331
3347
  await deps.respawnRunners([sourceId, targetId]);
3332
3348
  deps.log("camera migrated", {
@@ -3344,6 +3360,102 @@ async function migrateDevice$1(deps, input) {
3344
3360
  };
3345
3361
  }
3346
3362
  //#endregion
3363
+ //#region src/builtins/device-manager/migrate-hardware-state.ts
3364
+ /**
3365
+ * The per-device state half of a migration (`deviceManager.migrateDevice`).
3366
+ *
3367
+ * ## The rule
3368
+ *
3369
+ * **Role-scoped state follows the NUMBER. Hardware-scoped state follows the
3370
+ * DEVICE.** `swapIds` moves the device ROWS — `addonId` and `stableId` follow
3371
+ * the hardware — but the per-device CONFIG blob and the runtime-state blob are
3372
+ * keyed by the number in a separate store, and until 2026-09-01 they simply
3373
+ * stayed put. Measured on the live hub after replacing a Hikvision (id 618)
3374
+ * with a Reolink: device 618 — Reolink hardware — was dialled at
3375
+ * `192.168.1.245:80` with `deviceCache.model="DS-2DE2A404IW-DE3/W"`, the
3376
+ * Hikvision's address, credentials and cached model, and never came online.
3377
+ * Device 4362 wore the Reolink's config on Hikvision hardware. The two devices
3378
+ * were wearing each other's identity.
3379
+ *
3380
+ * The proof the config belongs to the hardware, not the number: writing the
3381
+ * Hikvision blob onto the Reolink was REJECTED by the Reolink driver's own
3382
+ * zod schema (`motionSensitivity: 60` vs max 50). A config that fails the
3383
+ * other provider's schema is a config for different hardware.
3384
+ *
3385
+ * ## What swaps, what stays
3386
+ *
3387
+ * - The DRIVER config keys (host, port, credentials, `deviceCache`, every
3388
+ * schema field) SWAP — they describe the box.
3389
+ * - The device-manager-owned section keys ({@link DEVICE_STORE_SECTIONS}:
3390
+ * linked-devices mode/ids) STAY with the number — a sensor is linked to
3391
+ * "the driveway camera", not to a serial number.
3392
+ * - `_profileMap` is DROPPED from both. It is the legacy spelling of the
3393
+ * stream-profile assignment (the live authority is the stream broker's own
3394
+ * map — see `StreamsTab.tsx`, which stopped writing it), and either copy
3395
+ * names stream ids of the wrong hardware after a swap.
3396
+ * - The runtime-state blobs (`__device-state:<id>` — battery snapshot, sleep
3397
+ * flag, feature probe) SWAP wholesale: every slice mirrors the box.
3398
+ *
3399
+ * ## Crash points are loud, same as `swapIds`
3400
+ *
3401
+ * There is no transaction: each blob is cleared and rewritten. A crash between
3402
+ * clear and write leaves a device with NO config — a provider that fails to
3403
+ * construct and says so — never two devices wearing each other's, which is the
3404
+ * state that reads as plausible and costs a day.
3405
+ */
3406
+ /** Legacy stream-profile assignment key (cf. `device-queries.ts`). */
3407
+ var PROFILE_MAP_CONFIG_KEY = "_profileMap";
3408
+ /** Config keys that belong to the camera's ROLE and stay with the number. */
3409
+ var ROLE_SCOPED_CONFIG_KEYS = new Set(DEVICE_STORE_SECTIONS.flatMap((section) => [...section.keys]));
3410
+ function splitConfig(config) {
3411
+ const hardware = {};
3412
+ const role = {};
3413
+ for (const [key, value] of Object.entries(config)) {
3414
+ if (key === PROFILE_MAP_CONFIG_KEY) continue;
3415
+ if (ROLE_SCOPED_CONFIG_KEYS.has(key)) role[key] = value;
3416
+ else hardware[key] = value;
3417
+ }
3418
+ return {
3419
+ hardware,
3420
+ role
3421
+ };
3422
+ }
3423
+ /**
3424
+ * Swap the hardware-scoped halves of two devices' persisted state. Runs right
3425
+ * after `swapIds`, under the same meta write lock. THROWS on failure: a
3426
+ * migration whose rows swapped but whose configs did not is exactly the
3427
+ * incident this module exists to prevent, and it must never be reported as a
3428
+ * success.
3429
+ */
3430
+ async function swapHardwareState(settings, sourceId, targetId) {
3431
+ const sourceConfig = await settings.readDeviceStore(sourceId);
3432
+ const targetConfig = await settings.readDeviceStore(targetId);
3433
+ const source = splitConfig(sourceConfig);
3434
+ const target = splitConfig(targetConfig);
3435
+ await settings.clearDeviceStore(sourceId);
3436
+ await settings.writeDeviceStore(sourceId, {
3437
+ ...target.hardware,
3438
+ ...source.role
3439
+ });
3440
+ await settings.clearDeviceStore(targetId);
3441
+ await settings.writeDeviceStore(targetId, {
3442
+ ...source.hardware,
3443
+ ...target.role
3444
+ });
3445
+ const sourceRuntimeState = await settings.readDeviceRuntimeState(sourceId);
3446
+ const targetRuntimeState = await settings.readDeviceRuntimeState(targetId);
3447
+ await settings.writeDeviceRuntimeState(sourceId, targetRuntimeState);
3448
+ await settings.writeDeviceRuntimeState(targetId, sourceRuntimeState);
3449
+ return {
3450
+ runtimeStateNowAtSource: targetRuntimeState,
3451
+ runtimeStateNowAtTarget: sourceRuntimeState,
3452
+ hardwareKeysToSource: Object.keys(target.hardware).toSorted(),
3453
+ hardwareKeysToTarget: Object.keys(source.hardware).toSorted(),
3454
+ roleKeysKeptAtSource: Object.keys(source.role).toSorted(),
3455
+ roleKeysKeptAtTarget: Object.keys(target.role).toSorted()
3456
+ };
3457
+ }
3458
+ //#endregion
3347
3459
  //#region src/builtins/device-manager/device-meta-actions.ts
3348
3460
  /**
3349
3461
  * Device meta-mutation + persistence actions for the device-manager addon —
@@ -3423,7 +3535,7 @@ async function allocateDeviceId(pctx, input) {
3423
3535
  * The composition lives in `migrate-device.ts`, deliberately free of this
3424
3536
  * file's context so the decisions it carries — above all "never report a switch
3425
3537
  * as off when it could not be written" — are testable without a hub. This is
3426
- * the wiring: the four real dependencies, and the write lock.
3538
+ * the wiring: the real dependencies, and the write lock.
3427
3539
  *
3428
3540
  * The temp id comes from `allocateNextDeviceId`, the monotonic counter, so the
3429
3541
  * value a crash could park a row on was never used and will never be reused.
@@ -3446,13 +3558,42 @@ async function migrateDevice(pctx, input) {
3446
3558
  swapIds: async (a, b) => {
3447
3559
  await pctx.metaStore.rows.swapIds(a, b, tempId);
3448
3560
  },
3561
+ swapHardwareState: async (a, b) => {
3562
+ const swapped = await swapHardwareState(pctx.settings, a, b);
3563
+ pctx.host.seedMirror(a, pctx.host.withResetSessionProbe(swapped.runtimeStateNowAtSource));
3564
+ pctx.host.seedMirror(b, pctx.host.withResetSessionProbe(swapped.runtimeStateNowAtTarget));
3565
+ ctx.logger.info("migration: hardware-scoped device state swapped", {
3566
+ tags: { deviceId: a },
3567
+ meta: {
3568
+ sourceId: a,
3569
+ targetId: b,
3570
+ hardwareKeysToSource: swapped.hardwareKeysToSource,
3571
+ hardwareKeysToTarget: swapped.hardwareKeysToTarget,
3572
+ roleKeysKeptAtSource: swapped.roleKeysKeptAtSource,
3573
+ roleKeysKeptAtTarget: swapped.roleKeysKeptAtTarget
3574
+ }
3575
+ });
3576
+ },
3577
+ forgetStreamState: async (deviceId) => {
3578
+ await ctx.api.streamBroker.forgetDeviceHardware.mutate({ deviceId });
3579
+ },
3449
3580
  respawnRunners: async (deviceIds) => {
3450
3581
  const owners = /* @__PURE__ */ new Set();
3451
3582
  for (const deviceId of deviceIds) {
3452
3583
  const addonId = pctx.metaStore.idToAddonId.get(deviceId);
3453
3584
  if (addonId !== void 0) owners.add(addonId);
3454
3585
  }
3455
- for (const addonId of owners) await ctx.api.addons.restartAddon.mutate({ addonId });
3586
+ for (const addonId of owners) {
3587
+ const owned = [...pctx.metaStore.idToAddonId.entries()].filter(([, a]) => a === addonId).length;
3588
+ ctx.logger.info("migration: restarting a whole provider to flush the id maps", {
3589
+ tags: { deviceId: sourceId },
3590
+ meta: {
3591
+ addonId,
3592
+ devicesTakenDown: owned
3593
+ }
3594
+ });
3595
+ await ctx.api.addons.restartAddon.mutate({ addonId });
3596
+ }
3456
3597
  },
3457
3598
  log: (message, meta) => {
3458
3599
  ctx.logger.info(message, {
@@ -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-L_39vRmH.mjs";
2
- import { n as purgeRetiredSettingsRows } from "../../retired-settings-keys-CnbKAxmX.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-DqNTelT4.mjs";
2
+ import { n as purgeRetiredSettingsRows } from "../../retired-settings-keys-CeSITpIX.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
@@ -2109,7 +2109,13 @@ function resolveLinkedDeviceIds(params) {
2109
2109
  if (config.mode === "auto") {
2110
2110
  if (selfLocation !== null && selfLocation.length > 0) for (const c of candidates) {
2111
2111
  if (c.type === DeviceType.Container) continue;
2112
- if (c.location === selfLocation) ids.add(c.id);
2112
+ if (c.location !== selfLocation) continue;
2113
+ const parent = c.parentDeviceId !== null ? byId.get(c.parentDeviceId) : void 0;
2114
+ if (parent !== void 0 && parent.type === DeviceType.Container) {
2115
+ expand(parent.id);
2116
+ continue;
2117
+ }
2118
+ ids.add(c.id);
2113
2119
  }
2114
2120
  } else for (const id of config.manualIds) if (byId.has(id)) expand(id);
2115
2121
  ids.delete(selfId);
@@ -3322,6 +3328,16 @@ async function migrateDevice$1(deps, input) {
3322
3328
  const down = [...await applyAll(deps, sourceId, false), ...await applyAll(deps, targetId, false)];
3323
3329
  const sourceStillLive = down.filter((r) => r.deviceId === sourceId && r.outcome === "unreachable").map((r) => r.switchId);
3324
3330
  await deps.swapIds(sourceId, targetId);
3331
+ await deps.swapHardwareState(sourceId, targetId);
3332
+ for (const deviceId of [sourceId, targetId]) try {
3333
+ await deps.forgetStreamState(deviceId);
3334
+ } catch (err) {
3335
+ deps.log("migration: broker hardware state NOT forgotten — stale stream state survives", {
3336
+ deviceId,
3337
+ error: err instanceof Error ? err.message : String(err),
3338
+ remedy: "delete the device's derived streams and reassign its stream profiles, or update the stream-broker addon and re-run streamBroker.forgetDeviceHardware"
3339
+ });
3340
+ }
3325
3341
  const up = await applyAll(deps, sourceId, true);
3326
3342
  await deps.respawnRunners([sourceId, targetId]);
3327
3343
  deps.log("camera migrated", {
@@ -3339,6 +3355,102 @@ async function migrateDevice$1(deps, input) {
3339
3355
  };
3340
3356
  }
3341
3357
  //#endregion
3358
+ //#region src/builtins/device-manager/migrate-hardware-state.ts
3359
+ /**
3360
+ * The per-device state half of a migration (`deviceManager.migrateDevice`).
3361
+ *
3362
+ * ## The rule
3363
+ *
3364
+ * **Role-scoped state follows the NUMBER. Hardware-scoped state follows the
3365
+ * DEVICE.** `swapIds` moves the device ROWS — `addonId` and `stableId` follow
3366
+ * the hardware — but the per-device CONFIG blob and the runtime-state blob are
3367
+ * keyed by the number in a separate store, and until 2026-09-01 they simply
3368
+ * stayed put. Measured on the live hub after replacing a Hikvision (id 618)
3369
+ * with a Reolink: device 618 — Reolink hardware — was dialled at
3370
+ * `192.168.1.245:80` with `deviceCache.model="DS-2DE2A404IW-DE3/W"`, the
3371
+ * Hikvision's address, credentials and cached model, and never came online.
3372
+ * Device 4362 wore the Reolink's config on Hikvision hardware. The two devices
3373
+ * were wearing each other's identity.
3374
+ *
3375
+ * The proof the config belongs to the hardware, not the number: writing the
3376
+ * Hikvision blob onto the Reolink was REJECTED by the Reolink driver's own
3377
+ * zod schema (`motionSensitivity: 60` vs max 50). A config that fails the
3378
+ * other provider's schema is a config for different hardware.
3379
+ *
3380
+ * ## What swaps, what stays
3381
+ *
3382
+ * - The DRIVER config keys (host, port, credentials, `deviceCache`, every
3383
+ * schema field) SWAP — they describe the box.
3384
+ * - The device-manager-owned section keys ({@link DEVICE_STORE_SECTIONS}:
3385
+ * linked-devices mode/ids) STAY with the number — a sensor is linked to
3386
+ * "the driveway camera", not to a serial number.
3387
+ * - `_profileMap` is DROPPED from both. It is the legacy spelling of the
3388
+ * stream-profile assignment (the live authority is the stream broker's own
3389
+ * map — see `StreamsTab.tsx`, which stopped writing it), and either copy
3390
+ * names stream ids of the wrong hardware after a swap.
3391
+ * - The runtime-state blobs (`__device-state:<id>` — battery snapshot, sleep
3392
+ * flag, feature probe) SWAP wholesale: every slice mirrors the box.
3393
+ *
3394
+ * ## Crash points are loud, same as `swapIds`
3395
+ *
3396
+ * There is no transaction: each blob is cleared and rewritten. A crash between
3397
+ * clear and write leaves a device with NO config — a provider that fails to
3398
+ * construct and says so — never two devices wearing each other's, which is the
3399
+ * state that reads as plausible and costs a day.
3400
+ */
3401
+ /** Legacy stream-profile assignment key (cf. `device-queries.ts`). */
3402
+ var PROFILE_MAP_CONFIG_KEY = "_profileMap";
3403
+ /** Config keys that belong to the camera's ROLE and stay with the number. */
3404
+ var ROLE_SCOPED_CONFIG_KEYS = new Set(DEVICE_STORE_SECTIONS.flatMap((section) => [...section.keys]));
3405
+ function splitConfig(config) {
3406
+ const hardware = {};
3407
+ const role = {};
3408
+ for (const [key, value] of Object.entries(config)) {
3409
+ if (key === PROFILE_MAP_CONFIG_KEY) continue;
3410
+ if (ROLE_SCOPED_CONFIG_KEYS.has(key)) role[key] = value;
3411
+ else hardware[key] = value;
3412
+ }
3413
+ return {
3414
+ hardware,
3415
+ role
3416
+ };
3417
+ }
3418
+ /**
3419
+ * Swap the hardware-scoped halves of two devices' persisted state. Runs right
3420
+ * after `swapIds`, under the same meta write lock. THROWS on failure: a
3421
+ * migration whose rows swapped but whose configs did not is exactly the
3422
+ * incident this module exists to prevent, and it must never be reported as a
3423
+ * success.
3424
+ */
3425
+ async function swapHardwareState(settings, sourceId, targetId) {
3426
+ const sourceConfig = await settings.readDeviceStore(sourceId);
3427
+ const targetConfig = await settings.readDeviceStore(targetId);
3428
+ const source = splitConfig(sourceConfig);
3429
+ const target = splitConfig(targetConfig);
3430
+ await settings.clearDeviceStore(sourceId);
3431
+ await settings.writeDeviceStore(sourceId, {
3432
+ ...target.hardware,
3433
+ ...source.role
3434
+ });
3435
+ await settings.clearDeviceStore(targetId);
3436
+ await settings.writeDeviceStore(targetId, {
3437
+ ...source.hardware,
3438
+ ...target.role
3439
+ });
3440
+ const sourceRuntimeState = await settings.readDeviceRuntimeState(sourceId);
3441
+ const targetRuntimeState = await settings.readDeviceRuntimeState(targetId);
3442
+ await settings.writeDeviceRuntimeState(sourceId, targetRuntimeState);
3443
+ await settings.writeDeviceRuntimeState(targetId, sourceRuntimeState);
3444
+ return {
3445
+ runtimeStateNowAtSource: targetRuntimeState,
3446
+ runtimeStateNowAtTarget: sourceRuntimeState,
3447
+ hardwareKeysToSource: Object.keys(target.hardware).toSorted(),
3448
+ hardwareKeysToTarget: Object.keys(source.hardware).toSorted(),
3449
+ roleKeysKeptAtSource: Object.keys(source.role).toSorted(),
3450
+ roleKeysKeptAtTarget: Object.keys(target.role).toSorted()
3451
+ };
3452
+ }
3453
+ //#endregion
3342
3454
  //#region src/builtins/device-manager/device-meta-actions.ts
3343
3455
  /**
3344
3456
  * Device meta-mutation + persistence actions for the device-manager addon —
@@ -3418,7 +3530,7 @@ async function allocateDeviceId(pctx, input) {
3418
3530
  * The composition lives in `migrate-device.ts`, deliberately free of this
3419
3531
  * file's context so the decisions it carries — above all "never report a switch
3420
3532
  * as off when it could not be written" — are testable without a hub. This is
3421
- * the wiring: the four real dependencies, and the write lock.
3533
+ * the wiring: the real dependencies, and the write lock.
3422
3534
  *
3423
3535
  * The temp id comes from `allocateNextDeviceId`, the monotonic counter, so the
3424
3536
  * value a crash could park a row on was never used and will never be reused.
@@ -3441,13 +3553,42 @@ async function migrateDevice(pctx, input) {
3441
3553
  swapIds: async (a, b) => {
3442
3554
  await pctx.metaStore.rows.swapIds(a, b, tempId);
3443
3555
  },
3556
+ swapHardwareState: async (a, b) => {
3557
+ const swapped = await swapHardwareState(pctx.settings, a, b);
3558
+ pctx.host.seedMirror(a, pctx.host.withResetSessionProbe(swapped.runtimeStateNowAtSource));
3559
+ pctx.host.seedMirror(b, pctx.host.withResetSessionProbe(swapped.runtimeStateNowAtTarget));
3560
+ ctx.logger.info("migration: hardware-scoped device state swapped", {
3561
+ tags: { deviceId: a },
3562
+ meta: {
3563
+ sourceId: a,
3564
+ targetId: b,
3565
+ hardwareKeysToSource: swapped.hardwareKeysToSource,
3566
+ hardwareKeysToTarget: swapped.hardwareKeysToTarget,
3567
+ roleKeysKeptAtSource: swapped.roleKeysKeptAtSource,
3568
+ roleKeysKeptAtTarget: swapped.roleKeysKeptAtTarget
3569
+ }
3570
+ });
3571
+ },
3572
+ forgetStreamState: async (deviceId) => {
3573
+ await ctx.api.streamBroker.forgetDeviceHardware.mutate({ deviceId });
3574
+ },
3444
3575
  respawnRunners: async (deviceIds) => {
3445
3576
  const owners = /* @__PURE__ */ new Set();
3446
3577
  for (const deviceId of deviceIds) {
3447
3578
  const addonId = pctx.metaStore.idToAddonId.get(deviceId);
3448
3579
  if (addonId !== void 0) owners.add(addonId);
3449
3580
  }
3450
- for (const addonId of owners) await ctx.api.addons.restartAddon.mutate({ addonId });
3581
+ for (const addonId of owners) {
3582
+ const owned = [...pctx.metaStore.idToAddonId.entries()].filter(([, a]) => a === addonId).length;
3583
+ ctx.logger.info("migration: restarting a whole provider to flush the id maps", {
3584
+ tags: { deviceId: sourceId },
3585
+ meta: {
3586
+ addonId,
3587
+ devicesTakenDown: owned
3588
+ }
3589
+ });
3590
+ await ctx.api.addons.restartAddon.mutate({ addonId });
3591
+ }
3451
3592
  },
3452
3593
  log: (message, meta) => {
3453
3594
  ctx.logger.info(message, {
@@ -19,7 +19,7 @@ export declare function allocateDeviceId(pctx: ProviderContext, input: Parameter
19
19
  * The composition lives in `migrate-device.ts`, deliberately free of this
20
20
  * file's context so the decisions it carries — above all "never report a switch
21
21
  * as off when it could not be written" — are testable without a hub. This is
22
- * the wiring: the four real dependencies, and the write lock.
22
+ * the wiring: the real dependencies, and the write lock.
23
23
  *
24
24
  * The temp id comes from `allocateNextDeviceId`, the monotonic counter, so the
25
25
  * value a crash could park a row on was never used and will never be reused.
@@ -26,6 +26,39 @@ export interface MigrateDeviceDeps {
26
26
  readonly setSwitch: (deviceId: number, switchId: CameraSwitchId, enabled: boolean) => Promise<void>;
27
27
  /** `DeviceRowStore.swapIds`, already under the meta write lock. */
28
28
  readonly swapIds: (a: number, b: number) => Promise<void>;
29
+ /**
30
+ * Swap the HARDWARE-scoped per-device state along with the rows: the driver
31
+ * config blob (host/port/credentials/`deviceCache` — everything the
32
+ * provider's schema owns) and the runtime-state blob. Role-scoped keys stay
33
+ * with the number. See `migrate-hardware-state.ts` for the rule, and for
34
+ * the incident where the rows swapped and the configs did not — the two
35
+ * devices came out wearing each other's identity.
36
+ *
37
+ * A failure here THROWS, and the migration throws with it: rows swapped
38
+ * with configs unswapped is exactly that incident, and it must never be
39
+ * reported as a success.
40
+ */
41
+ readonly swapHardwareState: (sourceId: number, targetId: number) => Promise<void>;
42
+ /**
43
+ * Tell the stream broker the hardware behind this number is gone
44
+ * (`streamBroker.forgetDeviceHardware`): every DERIVED stream definition is
45
+ * DELETED, the profile-slot assignment entry is purged, and the probed
46
+ * snapshots and RTSP token rows of the old streams go with it.
47
+ *
48
+ * Measured on the live hub after replacing a Hikvision with a Reolink on id
49
+ * 618: the assignment still said `mid → native:slot-3`, a raw slot only the
50
+ * Hikvision had, and the `derived:Mid to low` feeder — defined against the
51
+ * old profile layout — respawned every ten seconds forever (observed at
52
+ * attempt 2732). The recorder reported `no profile attached for device
53
+ * 618`, live view answered `No broker for stream "618/adaptive"`, and no
54
+ * line named the stale assignment as the cause.
55
+ *
56
+ * Forgetting is deliberately what this does, rather than guessing a
57
+ * mapping: the catalog is the authority on which stream is which, and it
58
+ * belongs to the camera that is now there. An RPC, never an event — a
59
+ * dropped event (D8) would leave the feeder respawning forever.
60
+ */
61
+ readonly forgetStreamState: (deviceId: number) => Promise<void>;
29
62
  /** Respawn the runners that hold either number in memory. Disabling stops
30
63
  * data PRODUCTION; it does not flush a cache that maps id → addon. */
31
64
  readonly respawnRunners: (deviceIds: readonly number[]) => Promise<void>;
@@ -0,0 +1,24 @@
1
+ import { DeviceManagerSettings } from './device-meta-store.js';
2
+ /** The narrow settings slice the swap needs — the seam the spec fakes. */
3
+ export type HardwareStateStore = Pick<DeviceManagerSettings, 'readDeviceStore' | 'writeDeviceStore' | 'clearDeviceStore' | 'readDeviceRuntimeState' | 'writeDeviceRuntimeState'>;
4
+ export interface SwappedHardwareState {
5
+ /** Runtime-state blob now persisted at `sourceId` (was the target's). */
6
+ readonly runtimeStateNowAtSource: Record<string, unknown>;
7
+ /** Runtime-state blob now persisted at `targetId` (was the source's). */
8
+ readonly runtimeStateNowAtTarget: Record<string, unknown>;
9
+ /** Hardware config keys that moved target → source. */
10
+ readonly hardwareKeysToSource: readonly string[];
11
+ /** Hardware config keys that moved source → target. */
12
+ readonly hardwareKeysToTarget: readonly string[];
13
+ /** Role config keys that stayed on each number. */
14
+ readonly roleKeysKeptAtSource: readonly string[];
15
+ readonly roleKeysKeptAtTarget: readonly string[];
16
+ }
17
+ /**
18
+ * Swap the hardware-scoped halves of two devices' persisted state. Runs right
19
+ * after `swapIds`, under the same meta write lock. THROWS on failure: a
20
+ * migration whose rows swapped but whose configs did not is exactly the
21
+ * incident this module exists to prevent, and it must never be reported as a
22
+ * success.
23
+ */
24
+ export declare function swapHardwareState(settings: HardwareStateStore, sourceId: number, targetId: number): Promise<SwappedHardwareState>;
@@ -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-C7qWwrK1.js");
6
+ const require_dist = require("../../dist-TEwQZL3g.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-L_39vRmH.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-DqNTelT4.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