camstack 1.2.84 → 1.2.86

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.
@@ -23633,9 +23633,9 @@ var require_zod = __commonJS({
23633
23633
  }
23634
23634
  });
23635
23635
 
23636
- // ../system/dist/dist-TEwQZL3g.js
23637
- var require_dist_TEwQZL3g = __commonJS({
23638
- "../system/dist/dist-TEwQZL3g.js"(exports) {
23636
+ // ../system/dist/dist-D8VIyu_0.js
23637
+ var require_dist_D8VIyu_0 = __commonJS({
23638
+ "../system/dist/dist-D8VIyu_0.js"(exports) {
23639
23639
  "use strict";
23640
23640
  var zod = require_zod();
23641
23641
  var EventCategory = /* @__PURE__ */ (function(EventCategory2) {
@@ -31077,7 +31077,26 @@ var require_dist_TEwQZL3g = __commonJS({
31077
31077
  * identity ahead of adoption. Rendering metadata (unit, precision)
31078
31078
  * flows live through the cap STATUS SLICE after adoption.
31079
31079
  */
31080
- sourceInfo: SourceInfoSchema.optional()
31080
+ sourceInfo: SourceInfoSchema.optional(),
31081
+ /**
31082
+ * Set when this candidate is a device the provider ALREADY owns.
31083
+ *
31084
+ * A scan cannot generally produce the identity a device was onboarded under
31085
+ * (Reolink keys on `mac-<mac>`, learned at adopt time), so a stableId
31086
+ * comparison never matches and an owned device looks addable. Re-adopting one
31087
+ * overwrites its config with scan-derived values — that is how a Home Hub's
31088
+ * Baichuan port was overwritten with its ONVIF port, taking the hub and its
31089
+ * three child cameras offline for four hours.
31090
+ *
31091
+ * A provider that can recognise its own devices says so here. Absent means
31092
+ * "not recognised", which is not the same as "known to be new" — a provider
31093
+ * that cannot tell simply never sets it.
31094
+ */
31095
+ alreadyOnboarded: zod.z.boolean().optional(),
31096
+ /** Numeric id of the device this candidate was matched to. Set with `alreadyOnboarded`. */
31097
+ onboardedDeviceId: zod.z.number().optional(),
31098
+ /** Operator-facing name of the matched device, so the UI can say WHICH one it is. */
31099
+ onboardedName: zod.z.string().optional()
31081
31100
  });
31082
31101
  var DeviceSummarySchema = zod.z.object({
31083
31102
  id: zod.z.number(),
@@ -31114,6 +31133,35 @@ var require_dist_TEwQZL3g = __commonJS({
31114
31133
  name: zod.z.string(),
31115
31134
  type: zod.z.string()
31116
31135
  }))),
31136
+ /**
31137
+ * Tear down and reconstruct ONE device in place from its persisted rows —
31138
+ * touching no other device this provider owns.
31139
+ *
31140
+ * The primitive `deviceManager.migrateDevice` uses to flush the two
31141
+ * migrated numbers: after `swapIds` the runner's live instance still
31142
+ * carries the PRE-swap numeric id (baked into the object, its native-cap
31143
+ * registrations and its log tags), and a live object cannot be renumbered.
31144
+ * Before this method the only flush was restarting the whole owning addon
31145
+ * — which took every camera the provider owns down with it (28 devices
31146
+ * for one migrated camera, measured 2026-09-04, and the morning of the
31147
+ * same day ~27 devices' native caps did not come back on their own).
31148
+ *
31149
+ * Keyed by `stableId`, deliberately: the numeric id is exactly the thing
31150
+ * that changes. The reply carries the id the device answers on NOW.
31151
+ * Implemented once in `BaseDeviceProvider` — decommission the live
31152
+ * instance (if any), then re-create from the persisted row: the same
31153
+ * teardown/rehydrate pair every graceful shutdown + boot already uses.
31154
+ * An RPC, never an event: a dropped event would leave the runner writing
31155
+ * against the wrong camera (D8).
31156
+ *
31157
+ * Construction can dial hardware, and the migrated source is
31158
+ * characteristically dead — the timeout covers a full activate window
31159
+ * rather than the 60 s default.
31160
+ */
31161
+ reloadDevice: method(zod.z.object({ stableId: zod.z.string() }), zod.z.object({ deviceId: zod.z.number() }), {
31162
+ kind: "mutation",
31163
+ timeoutMs: 3 * 6e4
31164
+ }),
31117
31165
  supportsDiscovery: method(zod.z.object({}), zod.z.boolean()),
31118
31166
  /**
31119
31167
  * Run a network scan. `params` carries optional provider-specific scan
@@ -31434,13 +31482,21 @@ var require_dist_TEwQZL3g = __commonJS({
31434
31482
  * `sourceStillLive` names what stayed live; empty is the only value that
31435
31483
  * means the old hardware is quiet. The migration proceeds either way —
31436
31484
  * refusing would refuse the case this exists for.
31485
+ *
31486
+ * **Safe to retry.** A duplicate call while a migration involving either
31487
+ * device is in flight is REFUSED, never queued; a repeat of a swap that
31488
+ * already completed (matched against the `addonId`/`stableId` fingerprint
31489
+ * the swap left on the rows, within 15 min) is a no-op returning the
31490
+ * original report. A blind re-run can no longer silently undo the
31491
+ * migration it retried.
31437
31492
  */
31438
31493
  migrateDevice: method(zod.z.object({
31439
31494
  sourceId: zod.z.number(),
31440
31495
  targetId: zod.z.number()
31441
31496
  }), MigrateDeviceResultSchema, {
31442
31497
  kind: "mutation",
31443
- auth: "admin"
31498
+ auth: "admin",
31499
+ timeoutMs: 12 * 6e4
31444
31500
  }),
31445
31501
  /** Register a device in the DB + in-memory registry. Called by DeviceManagerApi.register(). */
31446
31502
  registerDevice: method(DeviceRegisterPayloadSchema, zod.z.void(), { kind: "mutation" }),
@@ -52508,6 +52564,12 @@ var require_dist_TEwQZL3g = __commonJS({
52508
52564
  addonId: null,
52509
52565
  access: "view"
52510
52566
  },
52567
+ "deviceProvider.reloadDevice": {
52568
+ capName: "device-provider",
52569
+ capScope: "system",
52570
+ addonId: null,
52571
+ access: "create"
52572
+ },
52511
52573
  "deviceProvider.start": {
52512
52574
  capName: "device-provider",
52513
52575
  capScope: "system",
@@ -59999,7 +60061,7 @@ var require_alerts_addon = __commonJS({
59999
60061
  [Symbol.toStringTag]: { value: "Module" }
60000
60062
  });
60001
60063
  require_chunk_Cek0wNdY();
60002
- var require_dist10 = require_dist_TEwQZL3g();
60064
+ var require_dist10 = require_dist_D8VIyu_0();
60003
60065
  function selectExpired(alerts, cutoffMs) {
60004
60066
  return alerts.filter((a) => a.createdAt < cutoffMs).sort((a, b) => a.createdAt - b.createdAt).map((a) => a.id);
60005
60067
  }
@@ -60818,7 +60880,7 @@ var require_console_logging = __commonJS({
60818
60880
  [Symbol.toStringTag]: { value: "Module" }
60819
60881
  });
60820
60882
  require_chunk_Cek0wNdY();
60821
- var require_dist10 = require_dist_TEwQZL3g();
60883
+ var require_dist10 = require_dist_D8VIyu_0();
60822
60884
  var require_formatter = require_formatter_DqAKDlvN();
60823
60885
  var LEVEL_RANK = {
60824
60886
  debug: 0,
@@ -60912,7 +60974,7 @@ var require_core_blocks_addon = __commonJS({
60912
60974
  "use strict";
60913
60975
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
60914
60976
  var require_chunk = require_chunk_Cek0wNdY();
60915
- var require_dist10 = require_dist_TEwQZL3g();
60977
+ var require_dist10 = require_dist_D8VIyu_0();
60916
60978
  var node_crypto = __require("crypto");
60917
60979
  var node_fs_promises = __require("fs/promises");
60918
60980
  node_fs_promises = require_chunk.__toESM(node_fs_promises);
@@ -61809,11 +61871,11 @@ var require_core_blocks = __commonJS({
61809
61871
  }
61810
61872
  });
61811
61873
 
61812
- // ../system/dist/retired-settings-keys-DauPtbFR.js
61813
- var require_retired_settings_keys_DauPtbFR = __commonJS({
61814
- "../system/dist/retired-settings-keys-DauPtbFR.js"(exports) {
61874
+ // ../system/dist/retired-settings-keys-wakiINPT.js
61875
+ var require_retired_settings_keys_wakiINPT = __commonJS({
61876
+ "../system/dist/retired-settings-keys-wakiINPT.js"(exports) {
61815
61877
  "use strict";
61816
- var require_dist10 = require_dist_TEwQZL3g();
61878
+ var require_dist10 = require_dist_D8VIyu_0();
61817
61879
  function settingsStoreIsAuthoritativeHere(env) {
61818
61880
  const raw = (env["CAMSTACK_ROLE"] ?? "").trim().toLowerCase();
61819
61881
  return raw === "" || raw === "hub";
@@ -64027,8 +64089,8 @@ var require_device_manager_addon = __commonJS({
64027
64089
  [Symbol.toStringTag]: { value: "Module" }
64028
64090
  });
64029
64091
  require_chunk_Cek0wNdY();
64030
- var require_dist10 = require_dist_TEwQZL3g();
64031
- var require_retired_settings_keys = require_retired_settings_keys_DauPtbFR();
64092
+ var require_dist10 = require_dist_D8VIyu_0();
64093
+ var require_retired_settings_keys = require_retired_settings_keys_wakiINPT();
64032
64094
  var node_crypto = __require("crypto");
64033
64095
  var _camstack_types_node = require_node();
64034
64096
  var JOB_HISTORY = 20;
@@ -65504,7 +65566,10 @@ var require_device_manager_addon = __commonJS({
65504
65566
  stableId: d.stableId,
65505
65567
  type: d.type,
65506
65568
  suggestedName: d.suggestedName,
65507
- prefilledConfig: d.prefilledConfig
65569
+ prefilledConfig: d.prefilledConfig,
65570
+ ...d.alreadyOnboarded === true ? { alreadyOnboarded: true } : {},
65571
+ ...d.onboardedDeviceId !== void 0 ? { onboardedDeviceId: d.onboardedDeviceId } : {},
65572
+ ...d.onboardedName !== void 0 ? { onboardedName: d.onboardedName } : {}
65508
65573
  }));
65509
65574
  }
65510
65575
  async function adoptDevice(pctx, input) {
@@ -66527,10 +66592,26 @@ var require_device_manager_addon = __commonJS({
66527
66592
  detail
66528
66593
  };
66529
66594
  }
66595
+ async function setSwitchBounded(deps, deviceId, switchId, enabled) {
66596
+ const budget = deps.switchTimeoutMs ?? 15e3;
66597
+ const write = deps.setSwitch(deviceId, switchId, enabled);
66598
+ write.catch(() => {
66599
+ });
66600
+ let timer;
66601
+ try {
66602
+ await Promise.race([write, new Promise((_resolve, reject) => {
66603
+ timer = setTimeout(() => {
66604
+ reject(/* @__PURE__ */ new Error(`switch write did not answer within ${budget}ms \u2014 reported unreachable; if it lands late, a duplicate write of the same value is a no-op`));
66605
+ }, budget);
66606
+ })]);
66607
+ } finally {
66608
+ if (timer !== void 0) clearTimeout(timer);
66609
+ }
66610
+ }
66530
66611
  async function applyAll(deps, deviceId, enabled) {
66531
66612
  const out = [];
66532
66613
  for (const switchId of require_dist10.CAMERA_SWITCH_ORDER) try {
66533
- await deps.setSwitch(deviceId, switchId, enabled);
66614
+ await setSwitchBounded(deps, deviceId, switchId, enabled);
66534
66615
  out.push({
66535
66616
  deviceId,
66536
66617
  switchId,
@@ -66571,7 +66652,6 @@ var require_device_manager_addon = __commonJS({
66571
66652
  });
66572
66653
  }
66573
66654
  const up = await applyAll(deps, sourceId, true);
66574
- await deps.respawnRunners([sourceId, targetId]);
66575
66655
  deps.log("camera migrated", {
66576
66656
  sourceId,
66577
66657
  targetId,
@@ -66586,6 +66666,50 @@ var require_device_manager_addon = __commonJS({
66586
66666
  swapped: true
66587
66667
  };
66588
66668
  }
66669
+ async function reloadMigratedDevices(pctx, targets) {
66670
+ const ctx = pctx.host.ctx;
66671
+ for (const target of targets) {
66672
+ pctx.metaStore.idToAddonId.set(target.deviceId, target.addonId);
66673
+ pctx.host.remoteNativeCaps.delete(target.deviceId);
66674
+ pctx.host.capabilityRegistry?.unregisterAllNativeForDevice(target.deviceId);
66675
+ }
66676
+ const fallbackAddons = /* @__PURE__ */ new Map();
66677
+ await Promise.all(targets.map(async (target) => {
66678
+ try {
66679
+ const reloaded = await (await pctx.host.requireDeviceProvider(target.addonId)).reloadDevice({ stableId: target.stableId });
66680
+ ctx.logger.info("migration: device reloaded in place \u2014 no other device disturbed", {
66681
+ tags: { deviceId: target.deviceId },
66682
+ meta: {
66683
+ addonId: target.addonId,
66684
+ stableId: target.stableId,
66685
+ reloadedAs: reloaded.deviceId
66686
+ }
66687
+ });
66688
+ } catch (err) {
66689
+ ctx.logger.error("migration: in-place reload FAILED \u2014 falling back to a provider restart", {
66690
+ tags: { deviceId: target.deviceId },
66691
+ meta: {
66692
+ addonId: target.addonId,
66693
+ stableId: target.stableId,
66694
+ error: err instanceof Error ? err.message : String(err)
66695
+ }
66696
+ });
66697
+ fallbackAddons.set(target.addonId, target.deviceId);
66698
+ }
66699
+ }));
66700
+ if (fallbackAddons.size === 0) return;
66701
+ await Promise.all([...fallbackAddons.entries()].map(async ([addonId, migratedDeviceId]) => {
66702
+ const owned = [...pctx.metaStore.idToAddonId.entries()].filter(([, a]) => a === addonId).length;
66703
+ ctx.logger.info("migration: restarting a whole provider to flush the id maps", {
66704
+ tags: { deviceId: migratedDeviceId },
66705
+ meta: {
66706
+ addonId,
66707
+ devicesTakenDown: owned
66708
+ }
66709
+ });
66710
+ await ctx.api.addons.restartAddon.mutate({ addonId });
66711
+ }));
66712
+ }
66589
66713
  var PROFILE_MAP_CONFIG_KEY = "_profileMap";
66590
66714
  var ROLE_SCOPED_CONFIG_KEYS = new Set(DEVICE_STORE_SECTIONS.flatMap((section) => [...section.keys]));
66591
66715
  function splitConfig(config) {
@@ -66674,68 +66798,110 @@ var require_device_manager_addon = __commonJS({
66674
66798
  async function migrateDevice(pctx, input) {
66675
66799
  const { sourceId, targetId } = input;
66676
66800
  const ctx = pctx.host.ctx;
66677
- return await pctx.metaStore.withMetaWriteLock(async () => {
66678
- for (const id of [sourceId, targetId]) if (await pctx.metaStore.rows.get(id) === null) throw new Error(`[device-manager] migrateDevice: device ${id} does not exist`);
66679
- const tempId = await pctx.metaStore.allocateNextDeviceId();
66680
- return await migrateDevice$1({
66681
- setSwitch: async (deviceId, switchId, enabled) => {
66682
- await ctx.api.pipelineOrchestrator.setCameraSwitch.mutate({
66683
- deviceId,
66684
- switchId,
66685
- enabled
66686
- });
66687
- },
66688
- swapIds: async (a, b) => {
66689
- await pctx.metaStore.rows.swapIds(a, b, tempId);
66690
- },
66691
- swapHardwareState: async (a, b) => {
66692
- const swapped = await swapHardwareState(pctx.settings, a, b);
66693
- pctx.host.seedMirror(a, pctx.host.withResetSessionProbe(swapped.runtimeStateNowAtSource));
66694
- pctx.host.seedMirror(b, pctx.host.withResetSessionProbe(swapped.runtimeStateNowAtTarget));
66695
- ctx.logger.info("migration: hardware-scoped device state swapped", {
66696
- tags: { deviceId: a },
66801
+ pctx.migrationGuard.begin(sourceId, targetId);
66802
+ try {
66803
+ const outcome = await pctx.metaStore.withMetaWriteLock(async () => {
66804
+ const sourceRow = await pctx.metaStore.rows.get(sourceId);
66805
+ if (sourceRow === null) throw new Error(`[device-manager] migrateDevice: device ${sourceId} does not exist`);
66806
+ const targetRow = await pctx.metaStore.rows.get(targetId);
66807
+ if (targetRow === null) throw new Error(`[device-manager] migrateDevice: device ${targetId} does not exist`);
66808
+ const replay = pctx.migrationGuard.matchReplay(sourceId, targetId, {
66809
+ addonId: sourceRow.meta.addonId,
66810
+ stableId: sourceRow.meta.stableId
66811
+ }, {
66812
+ addonId: targetRow.meta.addonId,
66813
+ stableId: targetRow.meta.stableId
66814
+ });
66815
+ if (replay !== null) {
66816
+ ctx.logger.info("migrateDevice: this swap already completed \u2014 replaying the original report, NOTHING re-run", {
66817
+ tags: { deviceId: sourceId },
66697
66818
  meta: {
66698
- sourceId: a,
66699
- targetId: b,
66700
- hardwareKeysToSource: swapped.hardwareKeysToSource,
66701
- hardwareKeysToTarget: swapped.hardwareKeysToTarget,
66702
- roleKeysKeptAtSource: swapped.roleKeysKeptAtSource,
66703
- roleKeysKeptAtTarget: swapped.roleKeysKeptAtTarget
66819
+ sourceId,
66820
+ targetId
66704
66821
  }
66705
66822
  });
66706
- },
66707
- forgetStreamState: async (deviceId) => {
66708
- await ctx.api.streamBroker.forgetDeviceHardware.mutate({ deviceId });
66709
- },
66710
- respawnRunners: async (deviceIds) => {
66711
- const owners = /* @__PURE__ */ new Set();
66712
- for (const deviceId of deviceIds) {
66713
- const addonId = pctx.metaStore.idToAddonId.get(deviceId);
66714
- if (addonId !== void 0) owners.add(addonId);
66715
- }
66716
- for (const addonId of owners) {
66717
- const owned = [...pctx.metaStore.idToAddonId.entries()].filter(([, a]) => a === addonId).length;
66718
- ctx.logger.info("migration: restarting a whole provider to flush the id maps", {
66719
- tags: { deviceId: sourceId },
66823
+ return {
66824
+ result: replay,
66825
+ reloadTargets: null
66826
+ };
66827
+ }
66828
+ const tempId = await pctx.metaStore.allocateNextDeviceId();
66829
+ const result = await migrateDevice$1({
66830
+ setSwitch: async (deviceId, switchId, enabled) => {
66831
+ if (switchId === "stream-broker") {
66832
+ const moved = await writeDisabledFlagUnderLock(pctx, deviceId, !enabled);
66833
+ emitDisabledEvents(pctx, deviceId, !enabled, moved);
66834
+ return;
66835
+ }
66836
+ await ctx.api.pipelineOrchestrator.setCameraSwitch.mutate({
66837
+ deviceId,
66838
+ switchId,
66839
+ enabled
66840
+ });
66841
+ },
66842
+ swapIds: async (a, b) => {
66843
+ await pctx.metaStore.rows.swapIds(a, b, tempId);
66844
+ },
66845
+ swapHardwareState: async (a, b) => {
66846
+ const swapped = await swapHardwareState(pctx.settings, a, b);
66847
+ pctx.host.seedMirror(a, pctx.host.withResetSessionProbe(swapped.runtimeStateNowAtSource));
66848
+ pctx.host.seedMirror(b, pctx.host.withResetSessionProbe(swapped.runtimeStateNowAtTarget));
66849
+ ctx.logger.info("migration: hardware-scoped device state swapped", {
66850
+ tags: { deviceId: a },
66720
66851
  meta: {
66721
- addonId,
66722
- devicesTakenDown: owned
66852
+ sourceId: a,
66853
+ targetId: b,
66854
+ hardwareKeysToSource: swapped.hardwareKeysToSource,
66855
+ hardwareKeysToTarget: swapped.hardwareKeysToTarget,
66856
+ roleKeysKeptAtSource: swapped.roleKeysKeptAtSource,
66857
+ roleKeysKeptAtTarget: swapped.roleKeysKeptAtTarget
66723
66858
  }
66724
66859
  });
66725
- await ctx.api.addons.restartAddon.mutate({ addonId });
66860
+ },
66861
+ forgetStreamState: async (deviceId) => {
66862
+ await ctx.api.streamBroker.forgetDeviceHardware.mutate({ deviceId });
66863
+ },
66864
+ log: (message, meta) => {
66865
+ ctx.logger.info(message, {
66866
+ tags: { deviceId: sourceId },
66867
+ meta
66868
+ });
66726
66869
  }
66727
- },
66728
- log: (message, meta) => {
66729
- ctx.logger.info(message, {
66730
- tags: { deviceId: sourceId },
66731
- meta
66732
- });
66733
- }
66734
- }, {
66735
- sourceId,
66736
- targetId
66870
+ }, {
66871
+ sourceId,
66872
+ targetId
66873
+ });
66874
+ pctx.migrationGuard.recordCompleted({
66875
+ sourceId,
66876
+ targetId,
66877
+ identityAtSource: {
66878
+ addonId: targetRow.meta.addonId,
66879
+ stableId: targetRow.meta.stableId
66880
+ },
66881
+ identityAtTarget: {
66882
+ addonId: sourceRow.meta.addonId,
66883
+ stableId: sourceRow.meta.stableId
66884
+ },
66885
+ result
66886
+ });
66887
+ return {
66888
+ result,
66889
+ reloadTargets: [{
66890
+ deviceId: sourceId,
66891
+ addonId: targetRow.meta.addonId,
66892
+ stableId: targetRow.meta.stableId
66893
+ }, {
66894
+ deviceId: targetId,
66895
+ addonId: sourceRow.meta.addonId,
66896
+ stableId: sourceRow.meta.stableId
66897
+ }]
66898
+ };
66737
66899
  });
66738
- });
66900
+ if (outcome.reloadTargets !== null) await reloadMigratedDevices(pctx, outcome.reloadTargets);
66901
+ return outcome.result;
66902
+ } finally {
66903
+ pctx.migrationGuard.end(sourceId, targetId);
66904
+ }
66739
66905
  }
66740
66906
  async function registerDevice(pctx, input) {
66741
66907
  const { addonId, stableId, id, type, name, parentDeviceId, features, config } = input;
@@ -67256,18 +67422,17 @@ var require_device_manager_addon = __commonJS({
67256
67422
  }
67257
67423
  });
67258
67424
  }
67259
- async function setDisabled(pctx, input) {
67260
- const { deviceId, disabled } = input;
67261
- const moved = await pctx.metaStore.withMetaWriteLock(async () => {
67262
- const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
67263
- if (!persisted) throw new Error(`[device-manager] setDisabled: unknown device id=${deviceId}`);
67264
- const { meta: m } = persisted;
67265
- await pctx.metaStore.rows.patch(deviceId, { disabled });
67266
- return {
67267
- changed: (m.disabled ?? false) !== disabled,
67268
- integrationId: m.integrationId ?? ""
67269
- };
67270
- });
67425
+ async function writeDisabledFlagUnderLock(pctx, deviceId, disabled) {
67426
+ const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
67427
+ if (!persisted) throw new Error(`[device-manager] setDisabled: unknown device id=${deviceId}`);
67428
+ const { meta: m } = persisted;
67429
+ await pctx.metaStore.rows.patch(deviceId, { disabled });
67430
+ return {
67431
+ changed: (m.disabled ?? false) !== disabled,
67432
+ integrationId: m.integrationId ?? ""
67433
+ };
67434
+ }
67435
+ function emitDisabledEvents(pctx, deviceId, disabled, moved) {
67271
67436
  pctx.host.ctx.eventBus.emit({
67272
67437
  id: (0, node_crypto.randomUUID)(),
67273
67438
  timestamp: /* @__PURE__ */ new Date(),
@@ -67297,6 +67462,10 @@ var require_device_manager_addon = __commonJS({
67297
67462
  }
67298
67463
  });
67299
67464
  }
67465
+ async function setDisabled(pctx, input) {
67466
+ const { deviceId, disabled } = input;
67467
+ emitDisabledEvents(pctx, deviceId, disabled, await pctx.metaStore.withMetaWriteLock(() => writeDisabledFlagUnderLock(pctx, deviceId, disabled)));
67468
+ }
67300
67469
  async function loadRuntimeState(pctx, input) {
67301
67470
  const { deviceId } = input;
67302
67471
  if (!await pctx.metaStore.resolvePersistedById(deviceId)) return {};
@@ -68677,6 +68846,42 @@ var require_device_manager_addon = __commonJS({
68677
68846
  };
68678
68847
  return resolveRemoteNativeCapFromRegistry(pctx.bindingsDeps, capName, deviceId);
68679
68848
  }
68849
+ function sameIdentity(a, b) {
68850
+ return a.addonId === b.addonId && a.stableId === b.stableId;
68851
+ }
68852
+ function createMigrationGuard(now = Date.now) {
68853
+ const inFlight = /* @__PURE__ */ new Set();
68854
+ const completed = /* @__PURE__ */ new Map();
68855
+ const pairKey = (sourceId, targetId) => `${sourceId}->${targetId}`;
68856
+ return {
68857
+ begin(sourceId, targetId) {
68858
+ for (const id of [sourceId, targetId]) if (inFlight.has(id)) throw new Error(`[device-manager] migrateDevice: a migration involving device ${id} is already in flight \u2014 this call was refused, not queued. The running migration's report stands; re-read the device rows before retrying.`);
68859
+ inFlight.add(sourceId);
68860
+ inFlight.add(targetId);
68861
+ },
68862
+ end(sourceId, targetId) {
68863
+ inFlight.delete(sourceId);
68864
+ inFlight.delete(targetId);
68865
+ },
68866
+ recordCompleted(record) {
68867
+ completed.set(pairKey(record.sourceId, record.targetId), {
68868
+ ...record,
68869
+ completedAt: now()
68870
+ });
68871
+ },
68872
+ matchReplay(sourceId, targetId, atSource, atTarget) {
68873
+ const key = pairKey(sourceId, targetId);
68874
+ const record = completed.get(key);
68875
+ if (record === void 0) return null;
68876
+ if (now() - record.completedAt > 9e5) {
68877
+ completed.delete(key);
68878
+ return null;
68879
+ }
68880
+ if (!sameIdentity(record.identityAtSource, atSource) || !sameIdentity(record.identityAtTarget, atTarget)) return null;
68881
+ return record.result;
68882
+ }
68883
+ };
68884
+ }
68680
68885
  var DeviceManagerAddon = class extends require_dist10.BaseAddon {
68681
68886
  constructor() {
68682
68887
  super({});
@@ -68915,6 +69120,7 @@ var require_device_manager_addon = __commonJS({
68915
69120
  settings,
68916
69121
  registry,
68917
69122
  bindingsDeps: this.bindingsDeps,
69123
+ migrationGuard: createMigrationGuard(),
68918
69124
  requireDeviceOps,
68919
69125
  stampIntegrationId: stampIntegrationId$1,
68920
69126
  get provider() {
@@ -69156,7 +69362,7 @@ var require_hub_forwarder = __commonJS({
69156
69362
  [Symbol.toStringTag]: { value: "Module" }
69157
69363
  });
69158
69364
  require_chunk_Cek0wNdY();
69159
- var require_dist10 = require_dist_TEwQZL3g();
69365
+ var require_dist10 = require_dist_D8VIyu_0();
69160
69366
  var require_formatter = require_formatter_DqAKDlvN();
69161
69367
  var DEFAULT_OUTBOUND_BUFFER_SIZE = 500;
69162
69368
  var HubForwarderDestination = class {
@@ -69293,7 +69499,7 @@ var require_liveness_monitor_addon = __commonJS({
69293
69499
  "use strict";
69294
69500
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
69295
69501
  require_chunk_Cek0wNdY();
69296
- var require_dist10 = require_dist_TEwQZL3g();
69502
+ var require_dist10 = require_dist_D8VIyu_0();
69297
69503
  var NO_DEVICES = "liveness:no-devices";
69298
69504
  var ALL_OFFLINE = "liveness:all-devices-offline";
69299
69505
  var NO_FOOTAGE_PREFIX = "liveness:no-footage:";
@@ -69483,7 +69689,7 @@ var require_local_auth_addon = __commonJS({
69483
69689
  [Symbol.toStringTag]: { value: "Module" }
69484
69690
  });
69485
69691
  var require_chunk = require_chunk_Cek0wNdY();
69486
- var require_dist10 = require_dist_TEwQZL3g();
69692
+ var require_dist10 = require_dist_D8VIyu_0();
69487
69693
  var node_crypto = __require("crypto");
69488
69694
  node_crypto = require_chunk.__toESM(node_crypto);
69489
69695
  var crypto$1 = __require("crypto");
@@ -77296,7 +77502,7 @@ var require_loki_logging = __commonJS({
77296
77502
  [Symbol.toStringTag]: { value: "Module" }
77297
77503
  });
77298
77504
  require_chunk_Cek0wNdY();
77299
- var require_dist10 = require_dist_TEwQZL3g();
77505
+ var require_dist10 = require_dist_D8VIyu_0();
77300
77506
  function sanitizeLabelName(raw) {
77301
77507
  const replaced = raw.replaceAll(/[^a-zA-Z0-9_]/g, "_");
77302
77508
  return /^[a-zA-Z_]/.test(replaced) ? replaced : `_${replaced}`;
@@ -77861,7 +78067,7 @@ var require_native_metrics_addon = __commonJS({
77861
78067
  [Symbol.toStringTag]: { value: "Module" }
77862
78068
  });
77863
78069
  var require_chunk = require_chunk_Cek0wNdY();
77864
- var require_dist10 = require_dist_TEwQZL3g();
78070
+ var require_dist10 = require_dist_D8VIyu_0();
77865
78071
  var node_fs_promises = __require("fs/promises");
77866
78072
  var node_child_process = __require("child_process");
77867
78073
  var node_util = __require("util");
@@ -80445,7 +80651,7 @@ var require_filesystem_storage_addon = __commonJS({
80445
80651
  [Symbol.toStringTag]: { value: "Module" }
80446
80652
  });
80447
80653
  var require_chunk = require_chunk_Cek0wNdY();
80448
- var require_dist10 = require_dist_TEwQZL3g();
80654
+ var require_dist10 = require_dist_D8VIyu_0();
80449
80655
  var node_crypto = __require("crypto");
80450
80656
  var node_fs_promises = __require("fs/promises");
80451
80657
  var node_path = __require("path");
@@ -81561,8 +81767,8 @@ var require_sqlite_settings_addon = __commonJS({
81561
81767
  [Symbol.toStringTag]: { value: "Module" }
81562
81768
  });
81563
81769
  var require_chunk = require_chunk_Cek0wNdY();
81564
- var require_dist10 = require_dist_TEwQZL3g();
81565
- var require_retired_settings_keys = require_retired_settings_keys_DauPtbFR();
81770
+ var require_dist10 = require_dist_D8VIyu_0();
81771
+ var require_retired_settings_keys = require_retired_settings_keys_wakiINPT();
81566
81772
  var node_crypto = __require("crypto");
81567
81773
  var node_fs = __require("fs");
81568
81774
  var node_module = __require("module");
@@ -84052,7 +84258,7 @@ var require_storage_orchestrator_addon = __commonJS({
84052
84258
  [Symbol.toStringTag]: { value: "Module" }
84053
84259
  });
84054
84260
  var require_chunk = require_chunk_Cek0wNdY();
84055
- var require_dist10 = require_dist_TEwQZL3g();
84261
+ var require_dist10 = require_dist_D8VIyu_0();
84056
84262
  var require_hub_cap_forward = require_hub_cap_forward_DmHNjbB0();
84057
84263
  var zod = require_zod();
84058
84264
  var node_crypto = __require("crypto");
@@ -87003,7 +87209,7 @@ var require_system_config_addon = __commonJS({
87003
87209
  [Symbol.toStringTag]: { value: "Module" }
87004
87210
  });
87005
87211
  require_chunk_Cek0wNdY();
87006
- var require_dist10 = require_dist_TEwQZL3g();
87212
+ var require_dist10 = require_dist_D8VIyu_0();
87007
87213
  var SECTION_TITLES = {
87008
87214
  server: "Server",
87009
87215
  auth: "Authentication"
@@ -105064,7 +105270,7 @@ var require_winston_logging = __commonJS({
105064
105270
  [Symbol.toStringTag]: { value: "Module" }
105065
105271
  });
105066
105272
  var require_chunk = require_chunk_Cek0wNdY();
105067
- var require_dist10 = require_dist_TEwQZL3g();
105273
+ var require_dist10 = require_dist_D8VIyu_0();
105068
105274
  var require_formatter = require_formatter_DqAKDlvN();
105069
105275
  var node_path = __require("path");
105070
105276
  node_path = require_chunk.__toESM(node_path);
@@ -107007,9 +107213,9 @@ var require_event_category_BaEgqJNv = __commonJS({
107007
107213
  }
107008
107214
  });
107009
107215
 
107010
- // ../types/dist/sleep-BDmIj1HV.js
107011
- var require_sleep_BDmIj1HV = __commonJS({
107012
- "../types/dist/sleep-BDmIj1HV.js"(exports) {
107216
+ // ../types/dist/sleep-jdpPltQH.js
107217
+ var require_sleep_jdpPltQH = __commonJS({
107218
+ "../types/dist/sleep-jdpPltQH.js"(exports) {
107013
107219
  "use strict";
107014
107220
  var require_event_category = require_event_category_BaEgqJNv();
107015
107221
  var zod = require_zod();
@@ -109933,6 +110139,7 @@ var require_sleep_BDmIj1HV = __commonJS({
109933
110139
  getDeviceLiveContribution: (input) => dispatchSystem("deviceManager", "getDeviceLiveContribution", "query", input),
109934
110140
  applyDeviceSettingsPatch: (input) => dispatchSystem("deviceManager", "applyDeviceSettingsPatch", "mutation", input)
109935
110141
  },
110142
+ deviceProvider: { reloadDevice: (input) => dispatchSystem("deviceProvider", "reloadDevice", "mutation", input) },
109936
110143
  deviceState: {
109937
110144
  getSnapshot: (input) => dispatchSystem("deviceState", "getSnapshot", "query", input),
109938
110145
  getCapSlice: (input) => dispatchSystem("deviceState", "getCapSlice", "query", input),
@@ -110682,7 +110889,7 @@ var require_addon = __commonJS({
110682
110889
  "use strict";
110683
110890
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
110684
110891
  var require_event_category = require_event_category_BaEgqJNv();
110685
- var require_sleep = require_sleep_BDmIj1HV();
110892
+ var require_sleep = require_sleep_jdpPltQH();
110686
110893
  var require_err_msg = require_err_msg_COpsHMw2();
110687
110894
  var CAP_INPUT_DEFAULTS = Object.freeze({
110688
110895
  "addons": { "getLogs": { "limit": 100 } },
@@ -117565,12 +117772,12 @@ var require_dist2 = __commonJS({
117565
117772
  }
117566
117773
  });
117567
117774
 
117568
- // ../system/dist/manifest-system-deps-BVIr3MYJ.js
117569
- var require_manifest_system_deps_BVIr3MYJ = __commonJS({
117570
- "../system/dist/manifest-system-deps-BVIr3MYJ.js"(exports) {
117775
+ // ../system/dist/manifest-system-deps-BbP419V1.js
117776
+ var require_manifest_system_deps_BbP419V1 = __commonJS({
117777
+ "../system/dist/manifest-system-deps-BbP419V1.js"(exports) {
117571
117778
  "use strict";
117572
117779
  var require_chunk = require_chunk_Cek0wNdY();
117573
- require_dist_TEwQZL3g();
117780
+ require_dist_D8VIyu_0();
117574
117781
  require_hub_cap_forward_DmHNjbB0();
117575
117782
  var node_crypto = __require("crypto");
117576
117783
  node_crypto = require_chunk.__toESM(node_crypto);
@@ -129637,7 +129844,7 @@ var require_dist3 = __commonJS({
129637
129844
  "use strict";
129638
129845
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
129639
129846
  var require_chunk = require_chunk_Cek0wNdY();
129640
- var require_dist10 = require_dist_TEwQZL3g();
129847
+ var require_dist10 = require_dist_D8VIyu_0();
129641
129848
  var require_builtins_alerts_alerts_addon = require_alerts_addon();
129642
129849
  require_alerts();
129643
129850
  var require_formatter = require_formatter_DqAKDlvN();
@@ -129664,7 +129871,7 @@ var require_dist3 = __commonJS({
129664
129871
  var require_builtins_winston_logging_index = require_winston_logging();
129665
129872
  var require_file_data_plane = require_file_data_plane_DO8KbxCe();
129666
129873
  var require_tls$1 = require_tls_BxQlomxd();
129667
- var require_manifest_system_deps = require_manifest_system_deps_BVIr3MYJ();
129874
+ var require_manifest_system_deps = require_manifest_system_deps_BbP419V1();
129668
129875
  var require_resource_monitor = require_resource_monitor_CdnzxBLP();
129669
129876
  var require_custom_action_registry = require_custom_action_registry_jY0NOZK8();
129670
129877
  var zod = require_zod();
@@ -210754,7 +210961,7 @@ var require_dist4 = __commonJS({
210754
210961
  "use strict";
210755
210962
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
210756
210963
  var require_event_category = require_event_category_BaEgqJNv();
210757
- var require_sleep = require_sleep_BDmIj1HV();
210964
+ var require_sleep = require_sleep_jdpPltQH();
210758
210965
  var require_canonical_hash = require_canonical_hash_DNV8S5ET();
210759
210966
  var require_enums2 = require_enums();
210760
210967
  var require_err_msg = require_err_msg_COpsHMw2();
@@ -218062,7 +218269,26 @@ ${recipe.triggers.map((t, i) => emitTrigger(t, i)).join("\n\n")}
218062
218269
  * identity ahead of adoption. Rendering metadata (unit, precision)
218063
218270
  * flows live through the cap STATUS SLICE after adoption.
218064
218271
  */
218065
- sourceInfo: SourceInfoSchema.optional()
218272
+ sourceInfo: SourceInfoSchema.optional(),
218273
+ /**
218274
+ * Set when this candidate is a device the provider ALREADY owns.
218275
+ *
218276
+ * A scan cannot generally produce the identity a device was onboarded under
218277
+ * (Reolink keys on `mac-<mac>`, learned at adopt time), so a stableId
218278
+ * comparison never matches and an owned device looks addable. Re-adopting one
218279
+ * overwrites its config with scan-derived values — that is how a Home Hub's
218280
+ * Baichuan port was overwritten with its ONVIF port, taking the hub and its
218281
+ * three child cameras offline for four hours.
218282
+ *
218283
+ * A provider that can recognise its own devices says so here. Absent means
218284
+ * "not recognised", which is not the same as "known to be new" — a provider
218285
+ * that cannot tell simply never sets it.
218286
+ */
218287
+ alreadyOnboarded: zod.z.boolean().optional(),
218288
+ /** Numeric id of the device this candidate was matched to. Set with `alreadyOnboarded`. */
218289
+ onboardedDeviceId: zod.z.number().optional(),
218290
+ /** Operator-facing name of the matched device, so the UI can say WHICH one it is. */
218291
+ onboardedName: zod.z.string().optional()
218066
218292
  });
218067
218293
  var DeviceSummarySchema = zod.z.object({
218068
218294
  id: zod.z.number(),
@@ -218099,6 +218325,35 @@ ${recipe.triggers.map((t, i) => emitTrigger(t, i)).join("\n\n")}
218099
218325
  name: zod.z.string(),
218100
218326
  type: zod.z.string()
218101
218327
  }))),
218328
+ /**
218329
+ * Tear down and reconstruct ONE device in place from its persisted rows —
218330
+ * touching no other device this provider owns.
218331
+ *
218332
+ * The primitive `deviceManager.migrateDevice` uses to flush the two
218333
+ * migrated numbers: after `swapIds` the runner's live instance still
218334
+ * carries the PRE-swap numeric id (baked into the object, its native-cap
218335
+ * registrations and its log tags), and a live object cannot be renumbered.
218336
+ * Before this method the only flush was restarting the whole owning addon
218337
+ * — which took every camera the provider owns down with it (28 devices
218338
+ * for one migrated camera, measured 2026-09-04, and the morning of the
218339
+ * same day ~27 devices' native caps did not come back on their own).
218340
+ *
218341
+ * Keyed by `stableId`, deliberately: the numeric id is exactly the thing
218342
+ * that changes. The reply carries the id the device answers on NOW.
218343
+ * Implemented once in `BaseDeviceProvider` — decommission the live
218344
+ * instance (if any), then re-create from the persisted row: the same
218345
+ * teardown/rehydrate pair every graceful shutdown + boot already uses.
218346
+ * An RPC, never an event: a dropped event would leave the runner writing
218347
+ * against the wrong camera (D8).
218348
+ *
218349
+ * Construction can dial hardware, and the migrated source is
218350
+ * characteristically dead — the timeout covers a full activate window
218351
+ * rather than the 60 s default.
218352
+ */
218353
+ reloadDevice: require_sleep.method(zod.z.object({ stableId: zod.z.string() }), zod.z.object({ deviceId: zod.z.number() }), {
218354
+ kind: "mutation",
218355
+ timeoutMs: 3 * 6e4
218356
+ }),
218102
218357
  supportsDiscovery: require_sleep.method(zod.z.object({}), zod.z.boolean()),
218103
218358
  /**
218104
218359
  * Run a network scan. `params` carries optional provider-specific scan
@@ -218420,13 +218675,21 @@ ${recipe.triggers.map((t, i) => emitTrigger(t, i)).join("\n\n")}
218420
218675
  * `sourceStillLive` names what stayed live; empty is the only value that
218421
218676
  * means the old hardware is quiet. The migration proceeds either way —
218422
218677
  * refusing would refuse the case this exists for.
218678
+ *
218679
+ * **Safe to retry.** A duplicate call while a migration involving either
218680
+ * device is in flight is REFUSED, never queued; a repeat of a swap that
218681
+ * already completed (matched against the `addonId`/`stableId` fingerprint
218682
+ * the swap left on the rows, within 15 min) is a no-op returning the
218683
+ * original report. A blind re-run can no longer silently undo the
218684
+ * migration it retried.
218423
218685
  */
218424
218686
  migrateDevice: require_sleep.method(zod.z.object({
218425
218687
  sourceId: zod.z.number(),
218426
218688
  targetId: zod.z.number()
218427
218689
  }), MigrateDeviceResultSchema, {
218428
218690
  kind: "mutation",
218429
- auth: "admin"
218691
+ auth: "admin",
218692
+ timeoutMs: 12 * 6e4
218430
218693
  }),
218431
218694
  /** Register a device in the DB + in-memory registry. Called by DeviceManagerApi.register(). */
218432
218695
  registerDevice: require_sleep.method(DeviceRegisterPayloadSchema, zod.z.void(), { kind: "mutation" }),
@@ -239111,6 +239374,138 @@ ${recipe.triggers.map((t, i) => emitTrigger(t, i)).join("\n\n")}
239111
239374
  return (this.runtimeState.getCapState("feature-probe")?.lastProbedAt ?? 0) > 0;
239112
239375
  }
239113
239376
  };
239377
+ var DEVICE_RESTORE_RETRY_DELAYS_MS = [
239378
+ 1e4,
239379
+ 3e4,
239380
+ 9e4
239381
+ ];
239382
+ var DEVICE_RESTORE_RETRY_CONCURRENCY = 4;
239383
+ function sleep$1(ms, signal) {
239384
+ return new Promise((resolve) => {
239385
+ if (signal.aborted) {
239386
+ resolve();
239387
+ return;
239388
+ }
239389
+ const onAbort = () => {
239390
+ clearTimeout(timer);
239391
+ resolve();
239392
+ };
239393
+ const timer = setTimeout(() => {
239394
+ signal.removeEventListener("abort", onAbort);
239395
+ resolve();
239396
+ }, ms);
239397
+ timer.unref?.();
239398
+ signal.addEventListener("abort", onAbort, { once: true });
239399
+ });
239400
+ }
239401
+ async function runWithConcurrency(items, width, fn) {
239402
+ const queue = [...items];
239403
+ const laneCount = Math.max(1, Math.min(width, queue.length));
239404
+ const lane = async () => {
239405
+ for (; ; ) {
239406
+ const item = queue.shift();
239407
+ if (item === void 0) return;
239408
+ await fn(item);
239409
+ }
239410
+ };
239411
+ await Promise.all(Array.from({ length: laneCount }, lane));
239412
+ }
239413
+ var DeviceRestoreRetryScheduler = class {
239414
+ #logger;
239415
+ #attempt;
239416
+ #onPermanentFailure;
239417
+ #delaysMs;
239418
+ #concurrency;
239419
+ #now;
239420
+ #abort = new AbortController();
239421
+ constructor(options) {
239422
+ this.#logger = options.logger;
239423
+ this.#attempt = options.attempt;
239424
+ this.#onPermanentFailure = options.onPermanentFailure;
239425
+ this.#delaysMs = options.delaysMs ?? DEVICE_RESTORE_RETRY_DELAYS_MS;
239426
+ this.#concurrency = options.concurrency ?? 4;
239427
+ this.#now = options.now ?? Date.now;
239428
+ }
239429
+ /** Stop retrying (shutdown). Pending entries are NOT marked
239430
+ * permanently failed — the next boot restores them from disk. */
239431
+ cancel() {
239432
+ this.#abort.abort();
239433
+ }
239434
+ /**
239435
+ * Run the bounded retry rounds. Resolves when every entry has either
239436
+ * restored, been marked permanently failed, or the scheduler was
239437
+ * cancelled. Never rejects.
239438
+ */
239439
+ async run(initialFailures) {
239440
+ let pending = initialFailures.map((failure) => ({
239441
+ saved: failure.saved,
239442
+ lastError: failure.error,
239443
+ attempts: 1
239444
+ }));
239445
+ for (let round = 0; round < this.#delaysMs.length; round += 1) {
239446
+ if (pending.length === 0 || this.#abort.signal.aborted) break;
239447
+ await sleep$1(this.#delaysMs[round] ?? 0, this.#abort.signal);
239448
+ if (this.#abort.signal.aborted) break;
239449
+ pending = await this.#runRound(pending, round);
239450
+ }
239451
+ if (this.#abort.signal.aborted) return [];
239452
+ const terminal = pending.map((entry) => ({
239453
+ deviceId: entry.saved.id,
239454
+ stableId: entry.saved.stableId,
239455
+ type: String(entry.saved.type),
239456
+ attempts: entry.attempts,
239457
+ lastError: entry.lastError,
239458
+ failedAt: this.#now()
239459
+ }));
239460
+ for (const failure of terminal) this.#onPermanentFailure(failure);
239461
+ return terminal;
239462
+ }
239463
+ /** One retry round: parents first (phase 0), then hub-adopted
239464
+ * children (phase 1) — a child's attempt depends on its parent
239465
+ * having landed, exactly like the initial two-pass restore. */
239466
+ async #runRound(pending, round) {
239467
+ const next = [];
239468
+ const parents = pending.filter((entry) => entry.saved.parentDeviceId === null);
239469
+ const children = pending.filter((entry) => entry.saved.parentDeviceId !== null);
239470
+ for (const phase of [parents, children]) await runWithConcurrency(phase, this.#concurrency, async (entry) => {
239471
+ if (this.#abort.signal.aborted) {
239472
+ next.push(entry);
239473
+ return;
239474
+ }
239475
+ const attemptNo = entry.attempts + 1;
239476
+ try {
239477
+ await this.#attempt(entry.saved);
239478
+ this.#logger.info("Device restored on retry", {
239479
+ tags: {
239480
+ deviceId: entry.saved.id,
239481
+ stableId: entry.saved.stableId
239482
+ },
239483
+ meta: { attempt: attemptNo }
239484
+ });
239485
+ } catch (err) {
239486
+ const lastError = err instanceof Error ? err.message : String(err);
239487
+ const remainingRetries = this.#delaysMs.length - (round + 1);
239488
+ this.#logger.warn("Device restore retry failed", {
239489
+ tags: {
239490
+ deviceId: entry.saved.id,
239491
+ stableId: entry.saved.stableId
239492
+ },
239493
+ meta: {
239494
+ attempt: attemptNo,
239495
+ remainingRetries,
239496
+ error: lastError
239497
+ }
239498
+ });
239499
+ next.push({
239500
+ saved: entry.saved,
239501
+ lastError,
239502
+ attempts: attemptNo
239503
+ });
239504
+ }
239505
+ });
239506
+ return next;
239507
+ }
239508
+ };
239114
239509
  function toDeviceSummary(device2, addonId) {
239115
239510
  const config = {};
239116
239511
  for (const entry of device2.config.entries()) config[entry.key] = entry.value;
@@ -239136,6 +239531,7 @@ ${recipe.triggers.map((t, i) => emitTrigger(t, i)).join("\n\n")}
239136
239531
  }];
239137
239532
  }
239138
239533
  async onShutdown() {
239534
+ this.cancelRestoreRetries();
239139
239535
  const devices = await this.ctx.kernel.devices?.getAll() ?? [];
239140
239536
  for (const device2 of devices) try {
239141
239537
  await this.ctx.kernel.devices?.decommission(device2.id);
@@ -239155,9 +239551,16 @@ ${recipe.triggers.map((t, i) => emitTrigger(t, i)).join("\n\n")}
239155
239551
  async stop() {
239156
239552
  }
239157
239553
  async getStatus() {
239554
+ const all = await this.ctx.kernel.devices?.getAll() ?? [];
239555
+ const summary = this.restoreFailureSummary();
239556
+ if (summary === null) return {
239557
+ connected: true,
239558
+ deviceCount: all.length
239559
+ };
239158
239560
  return {
239159
239561
  connected: true,
239160
- deviceCount: (await this.ctx.kernel.devices?.getAll() ?? []).length
239562
+ deviceCount: all.length,
239563
+ error: summary
239161
239564
  };
239162
239565
  }
239163
239566
  async getDevices() {
@@ -239247,8 +239650,137 @@ ${recipe.triggers.map((t, i) => emitTrigger(t, i)).join("\n\n")}
239247
239650
  };
239248
239651
  }
239249
239652
  async restoreDevices(savedDevices) {
239250
- await this.onRestoreDevices(savedDevices);
239251
- if (savedDevices.length > 0) this.ctx.logger.info(`Restored ${savedDevices.length} ${this.providerName} device(s)`);
239653
+ const report = await this.onRestoreDevices(savedDevices);
239654
+ if (savedDevices.length === 0) return;
239655
+ if (report && report.failedCount > 0) {
239656
+ this.ctx.logger.warn(`Restored ${report.restoredCount}/${savedDevices.length} ${this.providerName} device(s) \u2014 ${report.failedCount} failed, bounded retry scheduled`);
239657
+ return;
239658
+ }
239659
+ const restoredCount = report ? report.restoredCount : savedDevices.length;
239660
+ this.ctx.logger.info(`Restored ${restoredCount} ${this.providerName} device(s)`);
239661
+ }
239662
+ /** Retry schedule. Overridable (tests use millisecond delays). */
239663
+ restoreRetryDelaysMs = DEVICE_RESTORE_RETRY_DELAYS_MS;
239664
+ /** Retry lane width. See `device-restore-retry.ts` for why retries
239665
+ * never re-stampede full-width while the initial pass does (D167). */
239666
+ restoreRetryConcurrency = 4;
239667
+ _restoreRetryScheduler = null;
239668
+ _restoreRetryCompletion = null;
239669
+ _permanentRestoreFailures = /* @__PURE__ */ new Map();
239670
+ /** Settles when the background retry rounds finish (or `null` when
239671
+ * nothing failed). Exposed for tests and subclass diagnostics —
239672
+ * boot NEVER awaits this: the runner's post-init handshake goes out
239673
+ * with the devices that restored, and a late success is announced
239674
+ * through the `native-cap-change` → `updateCaps` path. */
239675
+ get restoreRetryCompletion() {
239676
+ return this._restoreRetryCompletion;
239677
+ }
239678
+ /** Devices that exhausted the retry bound this process lifetime. */
239679
+ get permanentRestoreFailures() {
239680
+ return [...this._permanentRestoreFailures.values()];
239681
+ }
239682
+ /** One-line operator-facing summary for `getStatus().error`, or
239683
+ * `null` when every device restored. */
239684
+ restoreFailureSummary() {
239685
+ if (this._permanentRestoreFailures.size === 0) return null;
239686
+ const ids = [...this._permanentRestoreFailures.keys()].join(", ");
239687
+ return `${this._permanentRestoreFailures.size} device(s) permanently failed restore (deviceIds: ${ids}) \u2014 restart the ${this.providerName} provider to retry`;
239688
+ }
239689
+ cancelRestoreRetries() {
239690
+ this._restoreRetryScheduler?.cancel();
239691
+ this._restoreRetryScheduler = null;
239692
+ }
239693
+ recordPermanentRestoreFailure(failure) {
239694
+ this._permanentRestoreFailures.set(failure.deviceId, failure);
239695
+ this.ctx.logger.error("Device restore permanently failed \u2014 its capabilities will not register until the provider restarts", {
239696
+ tags: {
239697
+ deviceId: failure.deviceId,
239698
+ stableId: failure.stableId
239699
+ },
239700
+ meta: {
239701
+ type: failure.type,
239702
+ attempts: failure.attempts,
239703
+ error: failure.lastError
239704
+ }
239705
+ });
239706
+ }
239707
+ scheduleRestoreRetries(failures, attempt) {
239708
+ const scheduler2 = new DeviceRestoreRetryScheduler({
239709
+ logger: this.ctx.logger,
239710
+ delaysMs: this.restoreRetryDelaysMs,
239711
+ concurrency: this.restoreRetryConcurrency,
239712
+ attempt,
239713
+ onPermanentFailure: (failure) => this.recordPermanentRestoreFailure(failure)
239714
+ });
239715
+ this._restoreRetryScheduler = scheduler2;
239716
+ this._restoreRetryCompletion = scheduler2.run(failures).then(() => void 0).catch((err) => {
239717
+ this.ctx.logger.error("Restore retry scheduler crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
239718
+ });
239719
+ }
239720
+ /**
239721
+ * Tear down and reconstruct ONE device from its persisted rows — the
239722
+ * `deviceProvider.reloadDevice` cap method. Persistence is never touched,
239723
+ * and no other device this provider owns is disturbed.
239724
+ *
239725
+ * Keyed by `stableId` because the caller's whole reason to be here is that
239726
+ * the NUMERIC id changed (`deviceManager.migrateDevice` swapped it): the
239727
+ * fresh instance resolves its id through `allocateDeviceId`, which returns
239728
+ * whatever number the row carries NOW. The teardown is `decommission` —
239729
+ * exactly what a graceful shutdown runs per device (fires `removeDevice()`,
239730
+ * unregisters native caps, drops the registry entry) — and the rebuild is
239731
+ * the boot restore's own `create()` path, including its pass 2: first-class
239732
+ * children (hub-adopted cameras under an NVR) are decommissioned with the
239733
+ * parent by the cascade and must be re-created explicitly, because only
239734
+ * accessory children come back through `getAccessoryChildren()`.
239735
+ *
239736
+ * Reloading an accessory child directly is refused (no device class) —
239737
+ * reload its parent instead.
239738
+ */
239739
+ async reloadDevice(input) {
239740
+ const { stableId } = input;
239741
+ const devices = this.ctx.kernel.devices;
239742
+ if (!devices) throw new Error(`${this.providerName}: kernel.devices unavailable \u2014 cannot reload`);
239743
+ const live = (await devices.getAll()).find((d) => d.stableId === stableId);
239744
+ if (live) await devices.decommission(live.id);
239745
+ const { id } = await this.ctx.api.deviceManager.allocateDeviceId.mutate({
239746
+ addonId: this.addonId,
239747
+ stableId
239748
+ });
239749
+ const meta = await this.ctx.api.deviceManager.loadMeta.query({ deviceId: id });
239750
+ if (meta === null) throw new Error(`${this.providerName}: no persisted meta for "${stableId}" (id ${id}) \u2014 cannot reload`);
239751
+ const deviceType = Object.values(require_sleep.DeviceType).find((t) => t === meta.type);
239752
+ const Class = deviceType !== void 0 ? this.deviceClasses[deviceType] : void 0;
239753
+ if (!Class) throw new Error(`${this.providerName}: no device class for type "${meta.type}" \u2014 "${stableId}" is an accessory child; reload its parent instead`);
239754
+ await devices.create(stableId, Class, {}, meta.parentDeviceId ?? null);
239755
+ const rows = await this.ctx.api.deviceManager.listPersistedByAddon.query({ addonId: this.addonId });
239756
+ for (const row of rows) {
239757
+ if (row.parentDeviceId !== id) continue;
239758
+ const childType = Object.values(require_sleep.DeviceType).find((t) => t === row.type);
239759
+ const ChildClass = childType !== void 0 ? this.deviceClasses[childType] : void 0;
239760
+ if (!ChildClass) continue;
239761
+ try {
239762
+ await devices.create(row.stableId, ChildClass, {}, id);
239763
+ } catch (err) {
239764
+ this.ctx.logger.warn("reloadDevice: failed to re-create first-class child", {
239765
+ tags: {
239766
+ deviceId: row.id,
239767
+ stableId: row.stableId
239768
+ },
239769
+ meta: {
239770
+ parentDeviceId: id,
239771
+ error: err instanceof Error ? err.message : String(err)
239772
+ }
239773
+ });
239774
+ }
239775
+ }
239776
+ this.ctx.logger.info("device reloaded in place from persisted rows", {
239777
+ tags: { deviceId: id },
239778
+ meta: {
239779
+ stableId,
239780
+ type: meta.type
239781
+ }
239782
+ });
239783
+ return { deviceId: id };
239252
239784
  }
239253
239785
  /**
239254
239786
  * Restore devices from persisted state. Two-pass:
@@ -239274,55 +239806,126 @@ ${recipe.triggers.map((t, i) => emitTrigger(t, i)).join("\n\n")}
239274
239806
  * accessory-spawn flow handles via the parent's
239275
239807
  * `getAccessoryChildren()`. Override only when the default doesn't
239276
239808
  * fit.
239809
+ *
239810
+ * A row that fails either pass is NOT terminal (D347): it is handed
239811
+ * to a bounded background retry (`DeviceRestoreRetryScheduler`).
239812
+ * Only after the bound is exhausted is the device marked permanently
239813
+ * failed — logged at ERROR with `tags.deviceId` and surfaced via
239814
+ * `getStatus().error`.
239815
+ */
239816
+ /**
239817
+ * Repair a row's PERSISTED config blob immediately before it is restored.
239818
+ * Default: no-op — most providers have nothing to heal.
239819
+ *
239820
+ * This exists because a restored device self-hydrates from the DB: `create()`
239821
+ * passes `{}` and `BaseDevice` parses the stored blob against the device
239822
+ * schema. A blob that lost a REQUIRED field therefore fails restore forever,
239823
+ * and no later pass revisits it — a hub-adopted Reolink camera whose blob had
239824
+ * been emptied failed all four bounded attempts against fields
239825
+ * (`host`, `password`) it inherits from its parent and never dials itself.
239826
+ *
239827
+ * Implementations get every saved row, so a child can read its parent's blob.
239828
+ * A heal that throws is treated like any other restore failure: retried under
239829
+ * the bound, then reported — never swallowed.
239277
239830
  */
239831
+ async healSavedConfig(_saved, _allSaved) {
239832
+ }
239278
239833
  async onRestoreDevices(savedDevices) {
239279
239834
  const restored = /* @__PURE__ */ new Set();
239835
+ const failures = [];
239836
+ const attemptRestore = async (saved) => {
239837
+ if (restored.has(saved.id)) return;
239838
+ const Class = this.deviceClasses[saved.type];
239839
+ if (!Class) throw new Error(`no device class registered for type "${saved.type}"`);
239840
+ if (saved.parentDeviceId !== null && !restored.has(saved.parentDeviceId)) throw new Error(`parent device ${saved.parentDeviceId} not restored`);
239841
+ await this.healSavedConfig(saved, savedDevices);
239842
+ await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
239843
+ restored.add(saved.id);
239844
+ };
239280
239845
  const topLevel = savedDevices.filter((saved) => saved.parentDeviceId === null);
239281
239846
  const restoreOne = async (saved) => {
239282
- const Class = this.deviceClasses[saved.type];
239283
- if (!Class) {
239847
+ if (!this.deviceClasses[saved.type]) {
239284
239848
  this.ctx.logger.warn("No device class registered for restored type \u2014 skipping", {
239285
- tags: { stableId: saved.stableId },
239849
+ tags: {
239850
+ deviceId: saved.id,
239851
+ stableId: saved.stableId
239852
+ },
239286
239853
  meta: { type: saved.type }
239287
239854
  });
239288
239855
  return;
239289
239856
  }
239290
239857
  try {
239291
- await this.ctx.kernel.devices.create(saved.stableId, Class, {});
239292
- restored.add(saved.id);
239858
+ await attemptRestore(saved);
239293
239859
  } catch (err) {
239294
- this.ctx.logger.warn("Failed to restore device", {
239295
- tags: { stableId: saved.stableId },
239860
+ const error = err instanceof Error ? err.message : String(err);
239861
+ this.ctx.logger.warn("Failed to restore device \u2014 bounded retry scheduled", {
239862
+ tags: {
239863
+ deviceId: saved.id,
239864
+ stableId: saved.stableId
239865
+ },
239296
239866
  meta: {
239297
239867
  type: saved.type,
239298
- error: err instanceof Error ? err.message : String(err)
239868
+ attempt: 1,
239869
+ error
239299
239870
  }
239300
239871
  });
239872
+ failures.push({
239873
+ saved,
239874
+ error
239875
+ });
239301
239876
  }
239302
239877
  };
239303
239878
  await Promise.all(topLevel.map((saved) => restoreOne(saved)));
239879
+ const failedTopLevelIds = new Set(failures.map((failure) => failure.saved.id));
239304
239880
  const childRows = savedDevices.filter((s) => s.parentDeviceId !== null);
239305
239881
  for (const saved of childRows) {
239306
- const Class = this.deviceClasses[saved.type];
239307
- if (!Class) continue;
239882
+ if (!this.deviceClasses[saved.type]) continue;
239308
239883
  if (saved.parentDeviceId === null) continue;
239309
- if (!restored.has(saved.parentDeviceId)) continue;
239310
- try {
239311
- await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
239312
- restored.add(saved.id);
239313
- } catch (err) {
239314
- this.ctx.logger.warn("Failed to restore hub-adopted child", {
239884
+ if (restored.has(saved.parentDeviceId)) {
239885
+ try {
239886
+ await attemptRestore(saved);
239887
+ } catch (err) {
239888
+ const error = err instanceof Error ? err.message : String(err);
239889
+ this.ctx.logger.warn("Failed to restore hub-adopted child \u2014 bounded retry scheduled", {
239890
+ tags: {
239891
+ deviceId: saved.id,
239892
+ stableId: saved.stableId,
239893
+ parentDeviceId: saved.parentDeviceId
239894
+ },
239895
+ meta: {
239896
+ type: saved.type,
239897
+ attempt: 1,
239898
+ error
239899
+ }
239900
+ });
239901
+ failures.push({
239902
+ saved,
239903
+ error
239904
+ });
239905
+ }
239906
+ continue;
239907
+ }
239908
+ if (failedTopLevelIds.has(saved.parentDeviceId)) {
239909
+ this.ctx.logger.warn("Hub-adopted child deferred \u2014 parent failed initial restore", {
239315
239910
  tags: {
239911
+ deviceId: saved.id,
239316
239912
  stableId: saved.stableId,
239317
239913
  parentDeviceId: saved.parentDeviceId
239318
239914
  },
239319
- meta: {
239320
- type: saved.type,
239321
- error: err instanceof Error ? err.message : String(err)
239322
- }
239915
+ meta: { type: saved.type }
239323
239916
  });
239917
+ failures.push({
239918
+ saved,
239919
+ error: `parent device ${saved.parentDeviceId} not restored`
239920
+ });
239921
+ continue;
239324
239922
  }
239325
239923
  }
239924
+ if (failures.length > 0) this.scheduleRestoreRetries(failures, attemptRestore);
239925
+ return {
239926
+ restoredCount: restored.size,
239927
+ failedCount: failures.length
239928
+ };
239326
239929
  }
239327
239930
  /** Convert an IDevice to the flat DeviceSummary for the cap router. */
239328
239931
  toSummary(device2) {
@@ -243418,6 +244021,12 @@ ${recipe.triggers.map((t, i) => emitTrigger(t, i)).join("\n\n")}
243418
244021
  addonId: null,
243419
244022
  access: "view"
243420
244023
  },
244024
+ "deviceProvider.reloadDevice": {
244025
+ capName: "device-provider",
244026
+ capScope: "system",
244027
+ addonId: null,
244028
+ access: "create"
244029
+ },
243421
244030
  "deviceProvider.start": {
243422
244031
  capName: "device-provider",
243423
244032
  capScope: "system",
@@ -253822,6 +254431,8 @@ ${recipe.triggers.map((t, i) => emitTrigger(t, i)).join("\n\n")}
253822
254431
  exports.DEVICE_CAP_NAMES = DEVICE_CAP_NAMES;
253823
254432
  exports.DEVICE_CHILDREN_BATCH_MAX = DEVICE_CHILDREN_BATCH_MAX;
253824
254433
  exports.DEVICE_PROFILES = DEVICE_PROFILES;
254434
+ exports.DEVICE_RESTORE_RETRY_CONCURRENCY = DEVICE_RESTORE_RETRY_CONCURRENCY;
254435
+ exports.DEVICE_RESTORE_RETRY_DELAYS_MS = DEVICE_RESTORE_RETRY_DELAYS_MS;
253825
254436
  exports.DEVICE_SCOPED_CAPS = require_sleep.DEVICE_SCOPED_CAPS;
253826
254437
  exports.DEVICE_SETTINGS_CONTRIBUTION_METHODS = require_sleep.DEVICE_SETTINGS_CONTRIBUTION_METHODS;
253827
254438
  exports.DEVICE_STATE_READERS = DEVICE_STATE_READERS;
@@ -253851,6 +254462,7 @@ ${recipe.triggers.map((t, i) => emitTrigger(t, i)).join("\n\n")}
253851
254462
  exports.DeviceFeature = require_sleep.DeviceFeature;
253852
254463
  exports.DeviceInfoSchema = DeviceInfoSchema;
253853
254464
  exports.DeviceNetworkStatsSchema = DeviceNetworkStatsSchema;
254465
+ exports.DeviceRestoreRetryScheduler = DeviceRestoreRetryScheduler;
253854
254466
  exports.DeviceRole = require_sleep.DeviceRole;
253855
254467
  exports.DeviceRuntimeState = DeviceRuntimeState;
253856
254468
  exports.DeviceSelectorSchema = DeviceSelectorSchema;