@camstack/addon-provider-reolink 1.2.83 → 1.2.85

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.
package/dist/addon.js CHANGED
@@ -13361,7 +13361,26 @@ var DiscoveryCandidateSchema = object({
13361
13361
  * identity ahead of adoption. Rendering metadata (unit, precision)
13362
13362
  * flows live through the cap STATUS SLICE after adoption.
13363
13363
  */
13364
- sourceInfo: SourceInfoSchema.optional()
13364
+ sourceInfo: SourceInfoSchema.optional(),
13365
+ /**
13366
+ * Set when this candidate is a device the provider ALREADY owns.
13367
+ *
13368
+ * A scan cannot generally produce the identity a device was onboarded under
13369
+ * (Reolink keys on `mac-<mac>`, learned at adopt time), so a stableId
13370
+ * comparison never matches and an owned device looks addable. Re-adopting one
13371
+ * overwrites its config with scan-derived values — that is how a Home Hub's
13372
+ * Baichuan port was overwritten with its ONVIF port, taking the hub and its
13373
+ * three child cameras offline for four hours.
13374
+ *
13375
+ * A provider that can recognise its own devices says so here. Absent means
13376
+ * "not recognised", which is not the same as "known to be new" — a provider
13377
+ * that cannot tell simply never sets it.
13378
+ */
13379
+ alreadyOnboarded: boolean().optional(),
13380
+ /** Numeric id of the device this candidate was matched to. Set with `alreadyOnboarded`. */
13381
+ onboardedDeviceId: number().optional(),
13382
+ /** Operator-facing name of the matched device, so the UI can say WHICH one it is. */
13383
+ onboardedName: string().optional()
13365
13384
  });
13366
13385
  /**
13367
13386
  * Flat device summary returned by `createDevice` / `adoptDiscoveredDevice`.
@@ -34043,6 +34062,22 @@ var BaseDeviceProvider = class extends BaseAddon {
34043
34062
  * failed — logged at ERROR with `tags.deviceId` and surfaced via
34044
34063
  * `getStatus().error`.
34045
34064
  */
34065
+ /**
34066
+ * Repair a row's PERSISTED config blob immediately before it is restored.
34067
+ * Default: no-op — most providers have nothing to heal.
34068
+ *
34069
+ * This exists because a restored device self-hydrates from the DB: `create()`
34070
+ * passes `{}` and `BaseDevice` parses the stored blob against the device
34071
+ * schema. A blob that lost a REQUIRED field therefore fails restore forever,
34072
+ * and no later pass revisits it — a hub-adopted Reolink camera whose blob had
34073
+ * been emptied failed all four bounded attempts against fields
34074
+ * (`host`, `password`) it inherits from its parent and never dials itself.
34075
+ *
34076
+ * Implementations get every saved row, so a child can read its parent's blob.
34077
+ * A heal that throws is treated like any other restore failure: retried under
34078
+ * the bound, then reported — never swallowed.
34079
+ */
34080
+ async healSavedConfig(_saved, _allSaved) {}
34046
34081
  async onRestoreDevices(savedDevices) {
34047
34082
  const restored = /* @__PURE__ */ new Set();
34048
34083
  const failures = [];
@@ -34051,6 +34086,7 @@ var BaseDeviceProvider = class extends BaseAddon {
34051
34086
  const Class = this.deviceClasses[saved.type];
34052
34087
  if (!Class) throw new Error(`no device class registered for type "${saved.type}"`);
34053
34088
  if (saved.parentDeviceId !== null && !restored.has(saved.parentDeviceId)) throw new Error(`parent device ${saved.parentDeviceId} not restored`);
34089
+ await this.healSavedConfig(saved, savedDevices);
34054
34090
  await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
34055
34091
  restored.add(saved.id);
34056
34092
  };
@@ -242593,6 +242629,43 @@ function computeHealthCheckBackoffMs(consecutive) {
242593
242629
  return 15 * 6e4;
242594
242630
  }
242595
242631
  //#endregion
242632
+ //#region src/hub-channel-reconcile.ts
242633
+ /**
242634
+ * Match adopted children to the hub's own channel list by IDENTITY, then report the
242635
+ * ones whose persisted `channel` is missing or wrong.
242636
+ *
242637
+ * A child's stableId is `${hubStableId}-${childNativeId}`, and `childNativeId` is built
242638
+ * from the camera's UID — so identity survives a channel move, while `channel` does not.
242639
+ * The hub is the authority on which slot a UID currently occupies.
242640
+ *
242641
+ * Why this exists: `loadAdoptedChildrenByChannel` keys the adopted set on the persisted
242642
+ * `channel` alone. A child that lost it is absent from that map, so the discovery panel
242643
+ * renders it `alreadyAdopted: false` — one click away from being adopted a SECOND time —
242644
+ * and `routeSimpleEvent` silently drops every push for its slot. Observed live on
242645
+ * 2026-09-04 with an Argus MagiCam that was actually on channel 3.
242646
+ *
242647
+ * Pure: no I/O. A discovered entry with no channel, or with no adopted child, is skipped —
242648
+ * this repairs what exists, it never adopts.
242649
+ */
242650
+ function planChannelReconciliation(hubStableId, discovered, children) {
242651
+ const byStableId = /* @__PURE__ */ new Map();
242652
+ for (const child of children) byStableId.set(child.stableId, child);
242653
+ const repairs = [];
242654
+ for (const entry of discovered) {
242655
+ if (typeof entry.rtspChannel !== "number") continue;
242656
+ const child = byStableId.get(`${hubStableId}-${entry.childNativeId}`);
242657
+ if (child === void 0) continue;
242658
+ if (child.channel === entry.rtspChannel) continue;
242659
+ repairs.push({
242660
+ deviceId: child.deviceId,
242661
+ stableId: child.stableId,
242662
+ from: child.channel ?? null,
242663
+ to: entry.rtspChannel
242664
+ });
242665
+ }
242666
+ return repairs;
242667
+ }
242668
+ //#endregion
242596
242669
  //#region src/simple-event-dispatch-trace.ts
242597
242670
  /**
242598
242671
  * Gate for hub-side simpleEvent traces.
@@ -243089,6 +243162,7 @@ var ReolinkHub = class ReolinkHub extends BaseDevice {
243089
243162
  lastFetchedAt: Date.now()
243090
243163
  };
243091
243164
  this.runtimeState.setCapState(deviceDiscoveryCapability.name, slice);
243165
+ if (lastError === null) await this.reconcileChildChannels(discovered);
243092
243166
  if (lastError === null) {
243093
243167
  this.knownUnadoptedChannels.clear();
243094
243168
  for (const d of discovered) {
@@ -243261,6 +243335,64 @@ var ReolinkHub = class ReolinkHub extends BaseDevice {
243261
243335
  * the channel index. Returns a `channel → kernel deviceId` map.
243262
243336
  * Used to gate the `alreadyAdopted` flag in the discovery list.
243263
243337
  */
243338
+ /**
243339
+ * Re-derive each adopted child's `channel` from the hub's own channel list, matching by
243340
+ * IDENTITY (stableId carries the camera UID) rather than by the channel itself.
243341
+ *
243342
+ * `loadAdoptedChildrenByChannel` keys the adopted set on the persisted `channel` alone,
243343
+ * so a child that lost it is invisible: the discovery panel renders it available (one
243344
+ * click from a second adoption of the same camera) and `routeSimpleEvent` drops every
243345
+ * push for its slot. A channel move produces the same damage under a wrong number.
243346
+ *
243347
+ * Writes through the live device so its in-memory config and the DB stay in step.
243348
+ * A child that is not live is skipped — it is repaired on its next successful restore.
243349
+ */
243350
+ async reconcileChildChannels(discovered) {
243351
+ const children = await this.ctx.devices.getChildren(this.id);
243352
+ const adopted = children.map((child) => {
243353
+ const channel = child instanceof ReolinkCamera ? child.config.values.channel : void 0;
243354
+ return {
243355
+ deviceId: child.id,
243356
+ stableId: child.stableId,
243357
+ ...typeof channel === "number" ? { channel } : {}
243358
+ };
243359
+ });
243360
+ const repairs = planChannelReconciliation(this.stableId, discovered.map((d) => ({
243361
+ childNativeId: d.childNativeId,
243362
+ ...typeof d.metadata.rtspChannel === "number" ? { rtspChannel: d.metadata.rtspChannel } : {}
243363
+ })), adopted);
243364
+ if (repairs.length === 0) return;
243365
+ for (const repair of repairs) {
243366
+ const child = children.find((c) => c.id === repair.deviceId);
243367
+ if (!(child instanceof ReolinkCamera)) continue;
243368
+ try {
243369
+ await child.config.setAll({ channel: repair.to });
243370
+ this.channelToDeviceId.set(repair.to, repair.deviceId);
243371
+ this.ctx.logger.warn("Reolink Hub: repaired a child channel from the hub channel list", {
243372
+ tags: {
243373
+ deviceId: repair.deviceId,
243374
+ stableId: repair.stableId
243375
+ },
243376
+ meta: {
243377
+ from: repair.from,
243378
+ to: repair.to
243379
+ }
243380
+ });
243381
+ } catch (err) {
243382
+ this.ctx.logger.warn("Reolink Hub: child channel repair failed", {
243383
+ tags: {
243384
+ deviceId: repair.deviceId,
243385
+ stableId: repair.stableId
243386
+ },
243387
+ meta: {
243388
+ from: repair.from,
243389
+ to: repair.to,
243390
+ error: err instanceof Error ? err.message : String(err)
243391
+ }
243392
+ });
243393
+ }
243394
+ }
243395
+ }
243264
243396
  async loadAdoptedChildrenByChannel() {
243265
243397
  const result = /* @__PURE__ */ new Map();
243266
243398
  const children = await this.ctx.devices.getChildren(this.id);
@@ -243725,6 +243857,53 @@ function buildCreationFormSchema() {
243725
243857
  ] };
243726
243858
  }
243727
243859
  //#endregion
243860
+ //#region src/child-config-heal.ts
243861
+ /** Connection fields a hub-adopted child inherits from its parent at adoption time. */
243862
+ var INHERITED_CONNECTION_KEYS = [
243863
+ "host",
243864
+ "port",
243865
+ "username",
243866
+ "password",
243867
+ "transport"
243868
+ ];
243869
+ /** A stored value counts as present only when it is a usable string/number. */
243870
+ function isPresent(value) {
243871
+ if (typeof value === "string") return value !== "";
243872
+ return typeof value === "number";
243873
+ }
243874
+ /**
243875
+ * Work out what a hub-adopted child's persisted blob is missing from the connection
243876
+ * fields it inherited from its parent when it was adopted.
243877
+ *
243878
+ * `ReolinkCamera` with a parent uses `parent.api` and never dials its own login — but
243879
+ * `reolinkCameraSchema` still requires `host` and `password` as strings, so a blob that
243880
+ * lost them fails to parse and the camera cannot be restored at all. Observed live on
243881
+ * 2026-09-04: an Argus MagiCam under a Home Hub carried `{}` and burned all four bounded
243882
+ * restore attempts on `host`/`password` — fields it would never have used.
243883
+ *
243884
+ * Only ABSENT fields are filled. A child that deliberately differs from its parent (a
243885
+ * per-child credential, a non-default port) keeps what it has.
243886
+ */
243887
+ function planChildConfigHeal(childConfig, parentConfig) {
243888
+ const patch = {};
243889
+ const missingKeys = [];
243890
+ for (const key of INHERITED_CONNECTION_KEYS) {
243891
+ if (isPresent(childConfig[key])) continue;
243892
+ if (!isPresent(parentConfig[key])) continue;
243893
+ patch[key] = parentConfig[key];
243894
+ missingKeys.push(key);
243895
+ }
243896
+ return {
243897
+ patch,
243898
+ missingKeys
243899
+ };
243900
+ }
243901
+ /** Find the saved row a child names as its parent. */
243902
+ function findParentRow(child, allSaved) {
243903
+ if (child.parentDeviceId === null) return void 0;
243904
+ return allSaved.find((row) => row.id === child.parentDeviceId);
243905
+ }
243906
+ //#endregion
243728
243907
  //#region src/reolink-discovery-map.ts
243729
243908
  /**
243730
243909
  * Flatten a host (IP / hostname) into a flat row-key slug — shared by `generateStableId` and the
@@ -243734,21 +243913,44 @@ function slugifyReolinkHost(host) {
243734
243913
  return host.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
243735
243914
  }
243736
243915
  /**
243916
+ * Decide whether a discovered host is a device this provider already owns.
243917
+ *
243918
+ * Matching is by UID first (stable across a DHCP lease change) and by host second. A UID match
243919
+ * wins even when the host differs, because the address is the thing that moves.
243920
+ */
243921
+ function findOnboardedMatch(discovered, onboarded) {
243922
+ if (discovered.uid !== void 0 && discovered.uid !== "") {
243923
+ const byUid = onboarded.find((o) => o.uid === discovered.uid);
243924
+ if (byUid !== void 0) return byUid;
243925
+ }
243926
+ return onboarded.find((o) => o.host !== void 0 && o.host === discovered.host);
243927
+ }
243928
+ /**
243737
243929
  * Map discovered Reolink hosts to adoption {@link DiscoveryCandidate}s, de-duplicated by host (the
243738
243930
  * same camera can answer on more than one discovery method — UDP broadcast, ONVIF, HTTP scan). The
243739
243931
  * first responder for a host wins. Pure: no I/O.
243740
243932
  *
243933
+ * **The scan never supplies `port`.** `port` in the Reolink config schema is the BAICHUAN port
243934
+ * (default 9000). Discovery reports `httpPort`, the HTTP/ONVIF listener — on a Reolink Home Hub
243935
+ * that is 8000, while Baichuan lives on `mediaPort` 9000. Writing the discovered value into `port`
243936
+ * pointed every Baichuan login at the ONVIF listener, which accepts the TCP connection and then
243937
+ * closes it: `Baichuan socket closed`, 316 times in 12 hours, taking the hub and all three of its
243938
+ * child cameras offline. The default is correct; a non-default Baichuan port is an operator edit,
243939
+ * never a scan result.
243940
+ *
243741
243941
  * The authoritative stableId is `mac-<mac>` (learned during autodetect at adopt time), which discovery
243742
- * can't produce — so a re-scan of a MAC-keyed camera may still show as addable. The `host-` key still
243743
- * lets host-added cameras be detected as onboarded on re-scan.
243942
+ * can't produce — so candidates carry `alreadyOnboarded` rather than relying on a stableId match,
243943
+ * which never fires for a MAC-keyed device. Re-adding an owned device is how the port got clobbered
243944
+ * in the first place, so a scan says so instead of offering it again.
243744
243945
  */
243745
- function mapReolinkDiscoveryToCandidates(devices, credentials) {
243946
+ function mapReolinkDiscoveryToCandidates(devices, credentials, onboarded = []) {
243746
243947
  const username = credentials.username?.trim() ?? "";
243747
243948
  const password = credentials.password ?? "";
243748
243949
  const byHost = /* @__PURE__ */ new Map();
243749
243950
  for (const d of devices) if (!byHost.has(d.host)) byHost.set(d.host, d);
243750
243951
  return [...byHost.values()].map((d) => {
243751
243952
  const displayName = d.name ?? d.model ?? d.host;
243953
+ const match = findOnboardedMatch(d, onboarded);
243752
243954
  return {
243753
243955
  stableId: `host-${slugifyReolinkHost(d.host)}`,
243754
243956
  type: DeviceType.Camera,
@@ -243757,11 +243959,15 @@ function mapReolinkDiscoveryToCandidates(devices, credentials) {
243757
243959
  name: displayName,
243758
243960
  host: d.host,
243759
243961
  transport: "auto",
243760
- ...d.httpPort !== void 0 ? { port: d.httpPort } : {},
243761
243962
  ...d.uid ? { uid: d.uid } : {},
243762
243963
  ...username ? { username } : {},
243763
243964
  ...password ? { password } : {}
243764
- }
243965
+ },
243966
+ ...match !== void 0 ? {
243967
+ alreadyOnboarded: true,
243968
+ onboardedDeviceId: match.deviceId,
243969
+ onboardedName: match.name
243970
+ } : {}
243765
243971
  };
243766
243972
  });
243767
243973
  }
@@ -244410,6 +244616,61 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
244410
244616
  super({});
244411
244617
  }
244412
244618
  /**
244619
+ * Reduce this addon's live devices to the identities a network scan can produce, so
244620
+ * {@link mapReolinkDiscoveryToCandidates} can recognise a device it already owns.
244621
+ *
244622
+ * Hubs are included: the incident this exists for was a Home Hub re-offered by a scan and
244623
+ * re-added, which overwrote its Baichuan port with the ONVIF port the scan reported.
244624
+ */
244625
+ /**
244626
+ * Re-inherit the parent's connection fields into a hub-adopted child whose persisted
244627
+ * blob lost them, so the child can be restored at all.
244628
+ *
244629
+ * `adoptDiscoveredChild` copies `host`/`port`/`username`/`password`/`transport` down
244630
+ * from the Hub at adoption. Nothing re-applied them afterwards, so a child whose blob
244631
+ * was emptied failed `reolinkCameraSchema` on every restore — on fields a child never
244632
+ * dials, because a child with a parent uses `parent.api`. Only ABSENT fields are
244633
+ * filled; a child that deliberately differs keeps what it has.
244634
+ */
244635
+ async healSavedConfig(saved, allSaved) {
244636
+ const parent = findParentRow(saved, allSaved);
244637
+ if (parent === void 0) return;
244638
+ const { patch, missingKeys } = planChildConfigHeal(saved.config, parent.config);
244639
+ if (missingKeys.length === 0) return;
244640
+ await this.ctx.kernel.devices?.persistInitialConfig(saved.stableId, {
244641
+ ...saved.config,
244642
+ ...patch
244643
+ });
244644
+ this.ctx.logger.warn("Reolink child config healed from its parent before restore", {
244645
+ tags: {
244646
+ deviceId: saved.id,
244647
+ stableId: saved.stableId
244648
+ },
244649
+ meta: {
244650
+ parentDeviceId: parent.id,
244651
+ restoredKeys: [...missingKeys]
244652
+ }
244653
+ });
244654
+ }
244655
+ listOnboardedForDiscovery() {
244656
+ const all = this.ctx.kernel.deviceRegistry?.getAll() ?? [];
244657
+ const onboarded = [];
244658
+ for (const d of all) {
244659
+ if (!(d instanceof ReolinkCamera) && !(d instanceof ReolinkHub)) continue;
244660
+ const values = d.config.values;
244661
+ const host = typeof values.host === "string" && values.host !== "" ? values.host : void 0;
244662
+ const uid = typeof values.uid === "string" && values.uid !== "" ? values.uid : void 0;
244663
+ if (host === void 0 && uid === void 0) continue;
244664
+ onboarded.push({
244665
+ deviceId: d.id,
244666
+ name: d.name,
244667
+ ...host !== void 0 ? { host } : {},
244668
+ ...uid !== void 0 ? { uid } : {}
244669
+ });
244670
+ }
244671
+ return onboarded;
244672
+ }
244673
+ /**
244413
244674
  * Enumerate this addon's live `ReolinkCamera` instances from the
244414
244675
  * global device registry. Used by the email-push server to resolve
244415
244676
  * recipients + route inbound motion. Returns `[]` when the registry
@@ -244692,10 +244953,17 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
244692
244953
  networkCidr: networkCidr || "local",
244693
244954
  enableOnvif
244694
244955
  } });
244695
- return mapReolinkDiscoveryToCandidates(devices, {
244956
+ const onboarded = this.listOnboardedForDiscovery();
244957
+ const candidates = mapReolinkDiscoveryToCandidates(devices, {
244696
244958
  username,
244697
244959
  password
244698
- });
244960
+ }, onboarded);
244961
+ const owned = candidates.filter((c) => c.alreadyOnboarded === true).length;
244962
+ if (owned > 0) this.ctx.logger.info("Reolink discovery: candidates already owned by this provider", { meta: {
244963
+ owned,
244964
+ total: candidates.length
244965
+ } });
244966
+ return candidates;
244699
244967
  }
244700
244968
  async adoptDiscoveredDevice(input) {
244701
244969
  return this.createDevice({
package/dist/addon.mjs CHANGED
@@ -13356,7 +13356,26 @@ var DiscoveryCandidateSchema = object({
13356
13356
  * identity ahead of adoption. Rendering metadata (unit, precision)
13357
13357
  * flows live through the cap STATUS SLICE after adoption.
13358
13358
  */
13359
- sourceInfo: SourceInfoSchema.optional()
13359
+ sourceInfo: SourceInfoSchema.optional(),
13360
+ /**
13361
+ * Set when this candidate is a device the provider ALREADY owns.
13362
+ *
13363
+ * A scan cannot generally produce the identity a device was onboarded under
13364
+ * (Reolink keys on `mac-<mac>`, learned at adopt time), so a stableId
13365
+ * comparison never matches and an owned device looks addable. Re-adopting one
13366
+ * overwrites its config with scan-derived values — that is how a Home Hub's
13367
+ * Baichuan port was overwritten with its ONVIF port, taking the hub and its
13368
+ * three child cameras offline for four hours.
13369
+ *
13370
+ * A provider that can recognise its own devices says so here. Absent means
13371
+ * "not recognised", which is not the same as "known to be new" — a provider
13372
+ * that cannot tell simply never sets it.
13373
+ */
13374
+ alreadyOnboarded: boolean().optional(),
13375
+ /** Numeric id of the device this candidate was matched to. Set with `alreadyOnboarded`. */
13376
+ onboardedDeviceId: number().optional(),
13377
+ /** Operator-facing name of the matched device, so the UI can say WHICH one it is. */
13378
+ onboardedName: string().optional()
13360
13379
  });
13361
13380
  /**
13362
13381
  * Flat device summary returned by `createDevice` / `adoptDiscoveredDevice`.
@@ -34038,6 +34057,22 @@ var BaseDeviceProvider = class extends BaseAddon {
34038
34057
  * failed — logged at ERROR with `tags.deviceId` and surfaced via
34039
34058
  * `getStatus().error`.
34040
34059
  */
34060
+ /**
34061
+ * Repair a row's PERSISTED config blob immediately before it is restored.
34062
+ * Default: no-op — most providers have nothing to heal.
34063
+ *
34064
+ * This exists because a restored device self-hydrates from the DB: `create()`
34065
+ * passes `{}` and `BaseDevice` parses the stored blob against the device
34066
+ * schema. A blob that lost a REQUIRED field therefore fails restore forever,
34067
+ * and no later pass revisits it — a hub-adopted Reolink camera whose blob had
34068
+ * been emptied failed all four bounded attempts against fields
34069
+ * (`host`, `password`) it inherits from its parent and never dials itself.
34070
+ *
34071
+ * Implementations get every saved row, so a child can read its parent's blob.
34072
+ * A heal that throws is treated like any other restore failure: retried under
34073
+ * the bound, then reported — never swallowed.
34074
+ */
34075
+ async healSavedConfig(_saved, _allSaved) {}
34041
34076
  async onRestoreDevices(savedDevices) {
34042
34077
  const restored = /* @__PURE__ */ new Set();
34043
34078
  const failures = [];
@@ -34046,6 +34081,7 @@ var BaseDeviceProvider = class extends BaseAddon {
34046
34081
  const Class = this.deviceClasses[saved.type];
34047
34082
  if (!Class) throw new Error(`no device class registered for type "${saved.type}"`);
34048
34083
  if (saved.parentDeviceId !== null && !restored.has(saved.parentDeviceId)) throw new Error(`parent device ${saved.parentDeviceId} not restored`);
34084
+ await this.healSavedConfig(saved, savedDevices);
34049
34085
  await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
34050
34086
  restored.add(saved.id);
34051
34087
  };
@@ -242573,6 +242609,43 @@ function computeHealthCheckBackoffMs(consecutive) {
242573
242609
  return 15 * 6e4;
242574
242610
  }
242575
242611
  //#endregion
242612
+ //#region src/hub-channel-reconcile.ts
242613
+ /**
242614
+ * Match adopted children to the hub's own channel list by IDENTITY, then report the
242615
+ * ones whose persisted `channel` is missing or wrong.
242616
+ *
242617
+ * A child's stableId is `${hubStableId}-${childNativeId}`, and `childNativeId` is built
242618
+ * from the camera's UID — so identity survives a channel move, while `channel` does not.
242619
+ * The hub is the authority on which slot a UID currently occupies.
242620
+ *
242621
+ * Why this exists: `loadAdoptedChildrenByChannel` keys the adopted set on the persisted
242622
+ * `channel` alone. A child that lost it is absent from that map, so the discovery panel
242623
+ * renders it `alreadyAdopted: false` — one click away from being adopted a SECOND time —
242624
+ * and `routeSimpleEvent` silently drops every push for its slot. Observed live on
242625
+ * 2026-09-04 with an Argus MagiCam that was actually on channel 3.
242626
+ *
242627
+ * Pure: no I/O. A discovered entry with no channel, or with no adopted child, is skipped —
242628
+ * this repairs what exists, it never adopts.
242629
+ */
242630
+ function planChannelReconciliation(hubStableId, discovered, children) {
242631
+ const byStableId = /* @__PURE__ */ new Map();
242632
+ for (const child of children) byStableId.set(child.stableId, child);
242633
+ const repairs = [];
242634
+ for (const entry of discovered) {
242635
+ if (typeof entry.rtspChannel !== "number") continue;
242636
+ const child = byStableId.get(`${hubStableId}-${entry.childNativeId}`);
242637
+ if (child === void 0) continue;
242638
+ if (child.channel === entry.rtspChannel) continue;
242639
+ repairs.push({
242640
+ deviceId: child.deviceId,
242641
+ stableId: child.stableId,
242642
+ from: child.channel ?? null,
242643
+ to: entry.rtspChannel
242644
+ });
242645
+ }
242646
+ return repairs;
242647
+ }
242648
+ //#endregion
242576
242649
  //#region src/simple-event-dispatch-trace.ts
242577
242650
  /**
242578
242651
  * Gate for hub-side simpleEvent traces.
@@ -243069,6 +243142,7 @@ var ReolinkHub = class ReolinkHub extends BaseDevice {
243069
243142
  lastFetchedAt: Date.now()
243070
243143
  };
243071
243144
  this.runtimeState.setCapState(deviceDiscoveryCapability.name, slice);
243145
+ if (lastError === null) await this.reconcileChildChannels(discovered);
243072
243146
  if (lastError === null) {
243073
243147
  this.knownUnadoptedChannels.clear();
243074
243148
  for (const d of discovered) {
@@ -243241,6 +243315,64 @@ var ReolinkHub = class ReolinkHub extends BaseDevice {
243241
243315
  * the channel index. Returns a `channel → kernel deviceId` map.
243242
243316
  * Used to gate the `alreadyAdopted` flag in the discovery list.
243243
243317
  */
243318
+ /**
243319
+ * Re-derive each adopted child's `channel` from the hub's own channel list, matching by
243320
+ * IDENTITY (stableId carries the camera UID) rather than by the channel itself.
243321
+ *
243322
+ * `loadAdoptedChildrenByChannel` keys the adopted set on the persisted `channel` alone,
243323
+ * so a child that lost it is invisible: the discovery panel renders it available (one
243324
+ * click from a second adoption of the same camera) and `routeSimpleEvent` drops every
243325
+ * push for its slot. A channel move produces the same damage under a wrong number.
243326
+ *
243327
+ * Writes through the live device so its in-memory config and the DB stay in step.
243328
+ * A child that is not live is skipped — it is repaired on its next successful restore.
243329
+ */
243330
+ async reconcileChildChannels(discovered) {
243331
+ const children = await this.ctx.devices.getChildren(this.id);
243332
+ const adopted = children.map((child) => {
243333
+ const channel = child instanceof ReolinkCamera ? child.config.values.channel : void 0;
243334
+ return {
243335
+ deviceId: child.id,
243336
+ stableId: child.stableId,
243337
+ ...typeof channel === "number" ? { channel } : {}
243338
+ };
243339
+ });
243340
+ const repairs = planChannelReconciliation(this.stableId, discovered.map((d) => ({
243341
+ childNativeId: d.childNativeId,
243342
+ ...typeof d.metadata.rtspChannel === "number" ? { rtspChannel: d.metadata.rtspChannel } : {}
243343
+ })), adopted);
243344
+ if (repairs.length === 0) return;
243345
+ for (const repair of repairs) {
243346
+ const child = children.find((c) => c.id === repair.deviceId);
243347
+ if (!(child instanceof ReolinkCamera)) continue;
243348
+ try {
243349
+ await child.config.setAll({ channel: repair.to });
243350
+ this.channelToDeviceId.set(repair.to, repair.deviceId);
243351
+ this.ctx.logger.warn("Reolink Hub: repaired a child channel from the hub channel list", {
243352
+ tags: {
243353
+ deviceId: repair.deviceId,
243354
+ stableId: repair.stableId
243355
+ },
243356
+ meta: {
243357
+ from: repair.from,
243358
+ to: repair.to
243359
+ }
243360
+ });
243361
+ } catch (err) {
243362
+ this.ctx.logger.warn("Reolink Hub: child channel repair failed", {
243363
+ tags: {
243364
+ deviceId: repair.deviceId,
243365
+ stableId: repair.stableId
243366
+ },
243367
+ meta: {
243368
+ from: repair.from,
243369
+ to: repair.to,
243370
+ error: err instanceof Error ? err.message : String(err)
243371
+ }
243372
+ });
243373
+ }
243374
+ }
243375
+ }
243244
243376
  async loadAdoptedChildrenByChannel() {
243245
243377
  const result = /* @__PURE__ */ new Map();
243246
243378
  const children = await this.ctx.devices.getChildren(this.id);
@@ -243705,6 +243837,53 @@ function buildCreationFormSchema() {
243705
243837
  ] };
243706
243838
  }
243707
243839
  //#endregion
243840
+ //#region src/child-config-heal.ts
243841
+ /** Connection fields a hub-adopted child inherits from its parent at adoption time. */
243842
+ var INHERITED_CONNECTION_KEYS = [
243843
+ "host",
243844
+ "port",
243845
+ "username",
243846
+ "password",
243847
+ "transport"
243848
+ ];
243849
+ /** A stored value counts as present only when it is a usable string/number. */
243850
+ function isPresent(value) {
243851
+ if (typeof value === "string") return value !== "";
243852
+ return typeof value === "number";
243853
+ }
243854
+ /**
243855
+ * Work out what a hub-adopted child's persisted blob is missing from the connection
243856
+ * fields it inherited from its parent when it was adopted.
243857
+ *
243858
+ * `ReolinkCamera` with a parent uses `parent.api` and never dials its own login — but
243859
+ * `reolinkCameraSchema` still requires `host` and `password` as strings, so a blob that
243860
+ * lost them fails to parse and the camera cannot be restored at all. Observed live on
243861
+ * 2026-09-04: an Argus MagiCam under a Home Hub carried `{}` and burned all four bounded
243862
+ * restore attempts on `host`/`password` — fields it would never have used.
243863
+ *
243864
+ * Only ABSENT fields are filled. A child that deliberately differs from its parent (a
243865
+ * per-child credential, a non-default port) keeps what it has.
243866
+ */
243867
+ function planChildConfigHeal(childConfig, parentConfig) {
243868
+ const patch = {};
243869
+ const missingKeys = [];
243870
+ for (const key of INHERITED_CONNECTION_KEYS) {
243871
+ if (isPresent(childConfig[key])) continue;
243872
+ if (!isPresent(parentConfig[key])) continue;
243873
+ patch[key] = parentConfig[key];
243874
+ missingKeys.push(key);
243875
+ }
243876
+ return {
243877
+ patch,
243878
+ missingKeys
243879
+ };
243880
+ }
243881
+ /** Find the saved row a child names as its parent. */
243882
+ function findParentRow(child, allSaved) {
243883
+ if (child.parentDeviceId === null) return void 0;
243884
+ return allSaved.find((row) => row.id === child.parentDeviceId);
243885
+ }
243886
+ //#endregion
243708
243887
  //#region src/reolink-discovery-map.ts
243709
243888
  /**
243710
243889
  * Flatten a host (IP / hostname) into a flat row-key slug — shared by `generateStableId` and the
@@ -243714,21 +243893,44 @@ function slugifyReolinkHost(host) {
243714
243893
  return host.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
243715
243894
  }
243716
243895
  /**
243896
+ * Decide whether a discovered host is a device this provider already owns.
243897
+ *
243898
+ * Matching is by UID first (stable across a DHCP lease change) and by host second. A UID match
243899
+ * wins even when the host differs, because the address is the thing that moves.
243900
+ */
243901
+ function findOnboardedMatch(discovered, onboarded) {
243902
+ if (discovered.uid !== void 0 && discovered.uid !== "") {
243903
+ const byUid = onboarded.find((o) => o.uid === discovered.uid);
243904
+ if (byUid !== void 0) return byUid;
243905
+ }
243906
+ return onboarded.find((o) => o.host !== void 0 && o.host === discovered.host);
243907
+ }
243908
+ /**
243717
243909
  * Map discovered Reolink hosts to adoption {@link DiscoveryCandidate}s, de-duplicated by host (the
243718
243910
  * same camera can answer on more than one discovery method — UDP broadcast, ONVIF, HTTP scan). The
243719
243911
  * first responder for a host wins. Pure: no I/O.
243720
243912
  *
243913
+ * **The scan never supplies `port`.** `port` in the Reolink config schema is the BAICHUAN port
243914
+ * (default 9000). Discovery reports `httpPort`, the HTTP/ONVIF listener — on a Reolink Home Hub
243915
+ * that is 8000, while Baichuan lives on `mediaPort` 9000. Writing the discovered value into `port`
243916
+ * pointed every Baichuan login at the ONVIF listener, which accepts the TCP connection and then
243917
+ * closes it: `Baichuan socket closed`, 316 times in 12 hours, taking the hub and all three of its
243918
+ * child cameras offline. The default is correct; a non-default Baichuan port is an operator edit,
243919
+ * never a scan result.
243920
+ *
243721
243921
  * The authoritative stableId is `mac-<mac>` (learned during autodetect at adopt time), which discovery
243722
- * can't produce — so a re-scan of a MAC-keyed camera may still show as addable. The `host-` key still
243723
- * lets host-added cameras be detected as onboarded on re-scan.
243922
+ * can't produce — so candidates carry `alreadyOnboarded` rather than relying on a stableId match,
243923
+ * which never fires for a MAC-keyed device. Re-adding an owned device is how the port got clobbered
243924
+ * in the first place, so a scan says so instead of offering it again.
243724
243925
  */
243725
- function mapReolinkDiscoveryToCandidates(devices, credentials) {
243926
+ function mapReolinkDiscoveryToCandidates(devices, credentials, onboarded = []) {
243726
243927
  const username = credentials.username?.trim() ?? "";
243727
243928
  const password = credentials.password ?? "";
243728
243929
  const byHost = /* @__PURE__ */ new Map();
243729
243930
  for (const d of devices) if (!byHost.has(d.host)) byHost.set(d.host, d);
243730
243931
  return [...byHost.values()].map((d) => {
243731
243932
  const displayName = d.name ?? d.model ?? d.host;
243933
+ const match = findOnboardedMatch(d, onboarded);
243732
243934
  return {
243733
243935
  stableId: `host-${slugifyReolinkHost(d.host)}`,
243734
243936
  type: DeviceType.Camera,
@@ -243737,11 +243939,15 @@ function mapReolinkDiscoveryToCandidates(devices, credentials) {
243737
243939
  name: displayName,
243738
243940
  host: d.host,
243739
243941
  transport: "auto",
243740
- ...d.httpPort !== void 0 ? { port: d.httpPort } : {},
243741
243942
  ...d.uid ? { uid: d.uid } : {},
243742
243943
  ...username ? { username } : {},
243743
243944
  ...password ? { password } : {}
243744
- }
243945
+ },
243946
+ ...match !== void 0 ? {
243947
+ alreadyOnboarded: true,
243948
+ onboardedDeviceId: match.deviceId,
243949
+ onboardedName: match.name
243950
+ } : {}
243745
243951
  };
243746
243952
  });
243747
243953
  }
@@ -244390,6 +244596,61 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
244390
244596
  super({});
244391
244597
  }
244392
244598
  /**
244599
+ * Reduce this addon's live devices to the identities a network scan can produce, so
244600
+ * {@link mapReolinkDiscoveryToCandidates} can recognise a device it already owns.
244601
+ *
244602
+ * Hubs are included: the incident this exists for was a Home Hub re-offered by a scan and
244603
+ * re-added, which overwrote its Baichuan port with the ONVIF port the scan reported.
244604
+ */
244605
+ /**
244606
+ * Re-inherit the parent's connection fields into a hub-adopted child whose persisted
244607
+ * blob lost them, so the child can be restored at all.
244608
+ *
244609
+ * `adoptDiscoveredChild` copies `host`/`port`/`username`/`password`/`transport` down
244610
+ * from the Hub at adoption. Nothing re-applied them afterwards, so a child whose blob
244611
+ * was emptied failed `reolinkCameraSchema` on every restore — on fields a child never
244612
+ * dials, because a child with a parent uses `parent.api`. Only ABSENT fields are
244613
+ * filled; a child that deliberately differs keeps what it has.
244614
+ */
244615
+ async healSavedConfig(saved, allSaved) {
244616
+ const parent = findParentRow(saved, allSaved);
244617
+ if (parent === void 0) return;
244618
+ const { patch, missingKeys } = planChildConfigHeal(saved.config, parent.config);
244619
+ if (missingKeys.length === 0) return;
244620
+ await this.ctx.kernel.devices?.persistInitialConfig(saved.stableId, {
244621
+ ...saved.config,
244622
+ ...patch
244623
+ });
244624
+ this.ctx.logger.warn("Reolink child config healed from its parent before restore", {
244625
+ tags: {
244626
+ deviceId: saved.id,
244627
+ stableId: saved.stableId
244628
+ },
244629
+ meta: {
244630
+ parentDeviceId: parent.id,
244631
+ restoredKeys: [...missingKeys]
244632
+ }
244633
+ });
244634
+ }
244635
+ listOnboardedForDiscovery() {
244636
+ const all = this.ctx.kernel.deviceRegistry?.getAll() ?? [];
244637
+ const onboarded = [];
244638
+ for (const d of all) {
244639
+ if (!(d instanceof ReolinkCamera) && !(d instanceof ReolinkHub)) continue;
244640
+ const values = d.config.values;
244641
+ const host = typeof values.host === "string" && values.host !== "" ? values.host : void 0;
244642
+ const uid = typeof values.uid === "string" && values.uid !== "" ? values.uid : void 0;
244643
+ if (host === void 0 && uid === void 0) continue;
244644
+ onboarded.push({
244645
+ deviceId: d.id,
244646
+ name: d.name,
244647
+ ...host !== void 0 ? { host } : {},
244648
+ ...uid !== void 0 ? { uid } : {}
244649
+ });
244650
+ }
244651
+ return onboarded;
244652
+ }
244653
+ /**
244393
244654
  * Enumerate this addon's live `ReolinkCamera` instances from the
244394
244655
  * global device registry. Used by the email-push server to resolve
244395
244656
  * recipients + route inbound motion. Returns `[]` when the registry
@@ -244672,10 +244933,17 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
244672
244933
  networkCidr: networkCidr || "local",
244673
244934
  enableOnvif
244674
244935
  } });
244675
- return mapReolinkDiscoveryToCandidates(devices, {
244936
+ const onboarded = this.listOnboardedForDiscovery();
244937
+ const candidates = mapReolinkDiscoveryToCandidates(devices, {
244676
244938
  username,
244677
244939
  password
244678
- });
244940
+ }, onboarded);
244941
+ const owned = candidates.filter((c) => c.alreadyOnboarded === true).length;
244942
+ if (owned > 0) this.ctx.logger.info("Reolink discovery: candidates already owned by this provider", { meta: {
244943
+ owned,
244944
+ total: candidates.length
244945
+ } });
244946
+ return candidates;
244679
244947
  }
244680
244948
  async adoptDiscoveredDevice(input) {
244681
244949
  return this.createDevice({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-reolink",
3
- "version": "1.2.83",
3
+ "version": "1.2.85",
4
4
  "description": "Reolink camera device provider addon for CamStack — native Baichuan protocol",
5
5
  "keywords": [
6
6
  "camstack",