@camstack/addon-provider-reolink 1.2.219 → 1.2.220

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
@@ -28,12 +28,9 @@ let net = require("net");
28
28
  net = require_chunk.__toESM(net, 1);
29
29
  let fs_promises = require("fs/promises");
30
30
  fs_promises = require_chunk.__toESM(fs_promises, 1);
31
- let http = require("http");
32
- http = require_chunk.__toESM(http, 1);
33
- let url = require("url");
31
+ require("url");
34
32
  let node_child_process = require("node:child_process");
35
33
  let node_os = require("node:os");
36
- node_os = require_chunk.__toESM(node_os, 1);
37
34
  let node_fs = require("node:fs");
38
35
  node_fs = require_chunk.__toESM(node_fs, 1);
39
36
  //#region ../../node_modules/undici/lib/core/symbols.js
@@ -29494,8 +29491,8 @@ function buildC2dS(params) {
29494
29491
  return buildP2pXml(`<C2D_S><to><port>${params.clientPort}</port></to></C2D_S>`);
29495
29492
  }
29496
29493
  function buildC2mQ(params) {
29497
- const os$5 = params.os ?? "MAC";
29498
- return buildP2pXml(`<C2M_Q><uid>${xmlEscape(params.uid)}</uid><p>${xmlEscape(os$5)}</p></C2M_Q>`);
29494
+ const os$4 = params.os ?? "MAC";
29495
+ return buildP2pXml(`<C2M_Q><uid>${xmlEscape(params.uid)}</uid><p>${xmlEscape(os$4)}</p></C2M_Q>`);
29499
29496
  }
29500
29497
  function parseIpPortBlock(tag2, body) {
29501
29498
  const m = new RegExp(`<${tag2}>([\\s\\S]*?)</${tag2}>`, "i").exec(body);
@@ -29528,10 +29525,10 @@ function parseM2cQr(xml) {
29528
29525
  };
29529
29526
  }
29530
29527
  function buildC2rC(params) {
29531
- const os$6 = params.os ?? "MAC";
29528
+ const os$5 = params.os ?? "MAC";
29532
29529
  const debug = params.debug ?? false;
29533
29530
  const rev = params.revision != null ? `<r>${params.revision}</r>` : "";
29534
- return buildP2pXml(`<C2R_C><uid>${xmlEscape(params.uid)}</uid><cli><ip>${xmlEscape(params.cli.ip)}</ip><port>${params.cli.port}</port></cli><relay><ip>${xmlEscape(params.relay.ip)}</ip><port>${params.relay.port}</port></relay><cid>${params.cid}</cid><debug>${debug ? "true" : "false"}</debug><family>${params.family}</family><p>${xmlEscape(os$6)}</p>` + rev + `</C2R_C>`);
29531
+ return buildP2pXml(`<C2R_C><uid>${xmlEscape(params.uid)}</uid><cli><ip>${xmlEscape(params.cli.ip)}</ip><port>${params.cli.port}</port></cli><relay><ip>${xmlEscape(params.relay.ip)}</ip><port>${params.relay.port}</port></relay><cid>${params.cid}</cid><debug>${debug ? "true" : "false"}</debug><family>${params.family}</family><p>${xmlEscape(os$5)}</p>` + rev + `</C2R_C>`);
29535
29532
  }
29536
29533
  function parseR2cCr(xml) {
29537
29534
  const m = /<R2C_C_R>([\s\S]*?)<\/R2C_C_R>/i.exec(xml);
@@ -29575,8 +29572,8 @@ function buildC2rCfm(params) {
29575
29572
  return buildP2pXml(`<C2R_CFM><sid>${params.sid}</sid><conn>${xmlEscape(params.conn)}</conn><rsp>${rsp}</rsp><cid>${params.cid}</cid><did>${params.did}</did></C2R_CFM>`);
29576
29573
  }
29577
29574
  function buildC2dC(params) {
29578
- const os$7 = params.os ?? "MAC";
29579
- return buildP2pXml(`<C2D_C><uid>${xmlEscape(params.uid)}</uid><cli><port>${params.clientPort}</port></cli><cid>${params.cid}</cid><mtu>${params.mtu}</mtu><debug>false</debug><p>${xmlEscape(os$7)}</p></C2D_C>`);
29575
+ const os$6 = params.os ?? "MAC";
29576
+ return buildP2pXml(`<C2D_C><uid>${xmlEscape(params.uid)}</uid><cli><port>${params.clientPort}</port></cli><cid>${params.cid}</cid><mtu>${params.mtu}</mtu><debug>false</debug><p>${xmlEscape(os$6)}</p></C2D_C>`);
29580
29577
  }
29581
29578
  function buildC2dHb(params) {
29582
29579
  return buildP2pXml(`<C2D_HB><cid>${params.cid}</cid><did>${params.did}</did></C2D_HB>`);
@@ -39086,19 +39083,10 @@ function mapBaichuanWebhookAlarmXmlToSimpleEvents(xml) {
39086
39083
  return [...out];
39087
39084
  }
39088
39085
  var emitter2 = new events.EventEmitter();
39089
- var lastEventByCamera2 = /* @__PURE__ */ new Map();
39090
- var MAX_GLOBAL_EVENTS2 = 300;
39091
- var globalRecentEvents2 = [];
39092
39086
  function onBaichuanWebhookEvent(handler) {
39093
39087
  emitter2.on("event", handler);
39094
39088
  return () => emitter2.off("event", handler);
39095
39089
  }
39096
- function emitBaichuanWebhookEvent(event) {
39097
- lastEventByCamera2.set(event.cameraId, event);
39098
- globalRecentEvents2.unshift(event);
39099
- if (globalRecentEvents2.length > MAX_GLOBAL_EVENTS2) globalRecentEvents2.length = MAX_GLOBAL_EVENTS2;
39100
- emitter2.emit("event", event);
39101
- }
39102
39090
  function mapBaichuanWebhookToSimpleEvents(event) {
39103
39091
  const name = (event.event ?? "").toLowerCase();
39104
39092
  if (name === "sleep") return ["sleeping"];
@@ -46540,10 +46528,10 @@ ${stderr}`));
46540
46528
  const { spawn: spawn4 } = await import("child_process");
46541
46529
  const { randomUUID: randomUUID3 } = await import("crypto");
46542
46530
  const fs = await import("fs/promises");
46543
- const os$8 = await import("os");
46531
+ const os$7 = await import("os");
46544
46532
  const path$3 = await import("path");
46545
46533
  const ffmpeg = params.ffmpegPath ?? "ffmpeg";
46546
- const tmpDir = os$8.tmpdir();
46534
+ const tmpDir = os$7.tmpdir();
46547
46535
  const id = randomUUID3();
46548
46536
  const videoFormat = params.videoCodec === "H265" ? "hevc" : "h264";
46549
46537
  const videoPath = path$3.join(tmpDir, `reolink-${id}.${videoFormat}`);
@@ -51643,11 +51631,11 @@ ${scheduleItems}
51643
51631
  async createRecordingReplayHlsSession(params) {
51644
51632
  const logger = params.logger ?? this.logger;
51645
51633
  const hlsSegmentDuration = params.hlsSegmentDuration ?? 4;
51646
- const os$9 = await import("os");
51634
+ const os$8 = await import("os");
51647
51635
  const path$4 = await import("path");
51648
51636
  const fs = await import("fs/promises");
51649
51637
  const crypto2 = await import("crypto");
51650
- const tempDir = path$4.join(os$9.tmpdir(), `reolink-hls-${crypto2.randomBytes(8).toString("hex")}`);
51638
+ const tempDir = path$4.join(os$8.tmpdir(), `reolink-hls-${crypto2.randomBytes(8).toString("hex")}`);
51651
51639
  await fs.mkdir(tempDir, { recursive: true });
51652
51640
  const playlistPath = path$4.join(tempDir, "playlist.m3u8");
51653
51641
  const segmentPattern = path$4.join(tempDir, "segment_%03d.ts");
@@ -54973,7 +54961,7 @@ var require_smtp_connection = /* @__PURE__ */ require_chunk.__commonJSMin(((expo
54973
54961
  var ipv6normalize = require_ipv6_normalize();
54974
54962
  var sasl = require_sasl();
54975
54963
  var crypto$6 = require("crypto");
54976
- var os$4 = require("os");
54964
+ var os$3 = require("os");
54977
54965
  var punycode = (init_punycode_es6(), require_chunk.__toCommonJS(punycode_es6_exports));
54978
54966
  var EventEmitter$2 = require("events");
54979
54967
  var SOCKET_TIMEOUT = 60 * 1e3;
@@ -55072,7 +55060,7 @@ var require_smtp_connection = /* @__PURE__ */ require_chunk.__commonJSMin(((expo
55072
55060
  this._unauthenticatedCommands = 0;
55073
55061
  this._maxAllowedUnauthenticatedCommands = this._server.options.maxAllowedUnauthenticatedCommands || 10;
55074
55062
  this._unrecognizedCommands = 0;
55075
- this.name = this._server.options.name || os$4.hostname();
55063
+ this.name = this._server.options.name || os$3.hostname();
55076
55064
  this.clientHostname = false;
55077
55065
  this.openingCommand = false;
55078
55066
  this.hostNameAppearsAs = false;
@@ -57112,7 +57100,7 @@ var require_shared = /* @__PURE__ */ require_chunk.__commonJSMin(((exports, modu
57112
57100
  var errors = require_errors();
57113
57101
  var dns = require("dns");
57114
57102
  var net$2 = require("net");
57115
- var os$3 = require("os");
57103
+ var os$2 = require("os");
57116
57104
  var DNS_TTL = 300 * 1e3;
57117
57105
  var CACHE_CLEANUP_INTERVAL = 30 * 1e3;
57118
57106
  var MAX_CACHE_SIZE = 1e3;
@@ -57123,7 +57111,7 @@ var require_shared = /* @__PURE__ */ require_chunk.__commonJSMin(((exports, modu
57123
57111
  };
57124
57112
  var networkInterfaces;
57125
57113
  try {
57126
- networkInterfaces = os$3.networkInterfaces();
57114
+ networkInterfaces = os$2.networkInterfaces();
57127
57115
  } catch (_err) {}
57128
57116
  module.exports.networkInterfaces = networkInterfaces;
57129
57117
  var isFamilySupported = (family, allowInternal) => {
@@ -100706,180 +100694,6 @@ function parseBaichuanWebhookObject(mess) {
100706
100694
  if (typeof dataRaw.reason === "string") parsed.reason = dataRaw.reason;
100707
100695
  return parsed;
100708
100696
  }
100709
- function normalizePrefix(prefix) {
100710
- const raw = (prefix ?? "/webhook").trim() || "/webhook";
100711
- return (raw.startsWith("/") ? raw : `/${raw}`).replace(/\/+$/, "") || "/webhook";
100712
- }
100713
- function getCameraWebhookPath(cameraId, pathPrefix = "/webhook") {
100714
- return `${normalizePrefix(pathPrefix)}/${encodeURIComponent(cameraId)}`;
100715
- }
100716
- function createBaichuanWebhookServer(params) {
100717
- const log = {
100718
- debug: params.logger?.debug ?? (() => {}),
100719
- info: params.logger?.info ?? (() => {}),
100720
- warn: params.logger?.warn ?? (() => {}),
100721
- error: params.logger?.error ?? (() => {})
100722
- };
100723
- let config = params.config;
100724
- let enabled = params.enabled !== false;
100725
- let server;
100726
- let status = buildInitialStatus2(config, enabled);
100727
- const resolve = params.cameraResolver ?? ((id) => id ? id : void 0);
100728
- function pathPrefix() {
100729
- return normalizePrefix(config.pathPrefix);
100730
- }
100731
- function maxBody() {
100732
- return config.maxBodyBytes && config.maxBodyBytes > 0 ? config.maxBodyBytes : 64 * 1024;
100733
- }
100734
- async function handle(req, res) {
100735
- try {
100736
- if (req.method !== "POST" && req.method !== "PUT") {
100737
- res.writeHead(405, { "Content-Type": "text/plain" });
100738
- res.end("Method Not Allowed");
100739
- status.messagesRejected += 1;
100740
- return;
100741
- }
100742
- const host = req.headers.host ?? "localhost";
100743
- const url$8 = new url.URL(req.url ?? "/", `http://${host}`);
100744
- const prefix = pathPrefix();
100745
- if (!url$8.pathname.startsWith(prefix + "/") && url$8.pathname !== prefix) {
100746
- res.writeHead(404, { "Content-Type": "text/plain" });
100747
- res.end("Not Found");
100748
- status.messagesRejected += 1;
100749
- return;
100750
- }
100751
- const rest = url$8.pathname.slice(prefix.length).replace(/^\/+/, "");
100752
- const cameraId = resolve(decodeURIComponent(rest.split("/")[0] ?? ""));
100753
- if (!cameraId) {
100754
- res.writeHead(404, { "Content-Type": "text/plain" });
100755
- res.end("Unknown camera");
100756
- status.messagesRejected += 1;
100757
- return;
100758
- }
100759
- const chunks = [];
100760
- let total = 0;
100761
- const limit = maxBody();
100762
- for await (const chunk of req) {
100763
- const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
100764
- total += buf.length;
100765
- if (total > limit) {
100766
- res.writeHead(413, { "Content-Type": "text/plain" });
100767
- res.end("Payload Too Large");
100768
- status.messagesRejected += 1;
100769
- return;
100770
- }
100771
- chunks.push(buf);
100772
- }
100773
- const body = Buffer.concat(chunks);
100774
- const parsed = parseBaichuanWebhookBody(body);
100775
- if (!parsed) {
100776
- log.warn(`Baichuan webhook: unparseable body for camera=${cameraId} (${body.length}B)`);
100777
- res.writeHead(400, { "Content-Type": "text/plain" });
100778
- res.end("Bad Request");
100779
- status.messagesRejected += 1;
100780
- return;
100781
- }
100782
- const event = {
100783
- cameraId,
100784
- event: parsed.kind === "event" ? parsed.event : `cmd_${parsed.cmdId}`,
100785
- ...parsed.kind === "event" && parsed.reason ? { reason: parsed.reason } : {},
100786
- receivedAtMs: Date.now(),
100787
- raw: parsed
100788
- };
100789
- emitBaichuanWebhookEvent(event);
100790
- status.messagesAccepted += 1;
100791
- log.debug(`Baichuan webhook camera=${cameraId} event=${event.event}` + (event.reason ? ` reason=${event.reason}` : ""));
100792
- res.writeHead(200, { "Content-Type": "text/plain" });
100793
- res.end("OK");
100794
- } catch (err) {
100795
- const msg = err instanceof Error ? err.message : String(err);
100796
- status.lastErrorMessage = msg;
100797
- status.messagesRejected += 1;
100798
- log.error(`Baichuan webhook handler error: ${msg}`);
100799
- if (!res.headersSent) {
100800
- res.writeHead(500, { "Content-Type": "text/plain" });
100801
- res.end("Error");
100802
- }
100803
- }
100804
- }
100805
- async function start() {
100806
- if (server) {
100807
- log.debug("startBaichuanWebhookServer called but server already running");
100808
- return;
100809
- }
100810
- enabled = true;
100811
- server = http.default.createServer((req, res) => {
100812
- handle(req, res);
100813
- });
100814
- await new Promise((resolveListen, reject) => {
100815
- server.once("error", (err) => {
100816
- status.lastErrorMessage = err.message;
100817
- status.running = false;
100818
- reject(err);
100819
- });
100820
- server.listen(config.port, config.bindHost, () => {
100821
- const addr = server.address();
100822
- if (addr && typeof addr === "object") status.port = addr.port;
100823
- status.running = true;
100824
- status.enabled = true;
100825
- status.startedAtMs = Date.now();
100826
- status.lastErrorMessage = void 0;
100827
- log.info(`Baichuan webhook listening on ${config.bindHost}:${status.port} (prefix=${pathPrefix()})`);
100828
- resolveListen();
100829
- });
100830
- });
100831
- }
100832
- async function stop() {
100833
- const srv = server;
100834
- server = void 0;
100835
- if (!srv) {
100836
- status.running = false;
100837
- return;
100838
- }
100839
- await new Promise((resolveStop) => {
100840
- srv.close(() => resolveStop());
100841
- });
100842
- status.running = false;
100843
- log.info("Baichuan webhook server stopped");
100844
- }
100845
- return {
100846
- start,
100847
- stop,
100848
- async restart() {
100849
- await stop();
100850
- await start();
100851
- },
100852
- getStatus() {
100853
- return {
100854
- ...status,
100855
- enabled,
100856
- pathPrefix: pathPrefix()
100857
- };
100858
- },
100859
- updateConfig(next) {
100860
- config = next;
100861
- status.port = next.port;
100862
- status.bindHost = next.bindHost;
100863
- status.pathPrefix = normalizePrefix(next.pathPrefix);
100864
- },
100865
- getCameraWebhookPath(cameraId) {
100866
- return getCameraWebhookPath(cameraId, pathPrefix());
100867
- }
100868
- };
100869
- }
100870
- function buildInitialStatus2(config, enabled) {
100871
- return {
100872
- enabled,
100873
- running: false,
100874
- port: config.port,
100875
- bindHost: config.bindHost,
100876
- pathPrefix: normalizePrefix(config.pathPrefix),
100877
- messagesAccepted: 0,
100878
- messagesRejected: 0,
100879
- startedAtMs: void 0,
100880
- lastErrorMessage: void 0
100881
- };
100882
- }
100883
100697
  //#endregion
100884
100698
  //#region ../types/dist/event-category-BmPm98n0.mjs
100885
100699
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
@@ -106724,7 +106538,7 @@ var ZodIssueCode = {
106724
106538
  var ZodFirstPartyTypeKind;
106725
106539
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
106726
106540
  //#endregion
106727
- //#region ../types/dist/sleep-QsoHs66J.mjs
106541
+ //#region ../types/dist/sleep-hmSP-eJt.mjs
106728
106542
  /**
106729
106543
  * The audio chunk plane's byte format, and the ONE expansion from a coded
106730
106544
  * window to float samples (D455).
@@ -110376,6 +110190,7 @@ var CAP_NAMES = Object.freeze([
110376
110190
  "backup",
110377
110191
  "battery",
110378
110192
  "binary",
110193
+ "board-key-source",
110379
110194
  "brightness",
110380
110195
  "broker",
110381
110196
  "button",
@@ -110497,6 +110312,7 @@ var CAP_NAMES = Object.freeze([
110497
110312
  "privacy-mask",
110498
110313
  "ptz",
110499
110314
  "ptz-autotrack",
110315
+ "pump-cycle",
110500
110316
  "reboot",
110501
110317
  "record-entries",
110502
110318
  "recording",
@@ -111789,18 +111605,6 @@ Object.freeze({
111789
111605
  "path": "pumpRunning",
111790
111606
  "valueType": "boolean"
111791
111607
  },
111792
- {
111793
- "path": "pumpIntervalEnabled",
111794
- "valueType": "boolean"
111795
- },
111796
- {
111797
- "path": "pumpOnTime",
111798
- "valueType": "number"
111799
- },
111800
- {
111801
- "path": "pumpOffTime",
111802
- "valueType": "number"
111803
- },
111804
111608
  {
111805
111609
  "path": "lastDrink",
111806
111610
  "valueType": "number"
@@ -111924,6 +111728,20 @@ Object.freeze({
111924
111728
  "valueType": "number"
111925
111729
  }
111926
111730
  ],
111731
+ "pump-cycle": [
111732
+ {
111733
+ "path": "enabled",
111734
+ "valueType": "boolean"
111735
+ },
111736
+ {
111737
+ "path": "onTime",
111738
+ "valueType": "number"
111739
+ },
111740
+ {
111741
+ "path": "offTime",
111742
+ "valueType": "number"
111743
+ }
111744
+ ],
111927
111745
  "recording-archive": [
111928
111746
  {
111929
111747
  "path": "deviceId",
@@ -114827,6 +114645,16 @@ var MigrationPlanSchema = object({ edits: array(discriminatedUnion("op", [
114827
114645
  precondition: PreconditionSchema
114828
114646
  })
114829
114647
  ])) });
114648
+ /** `JSON.stringify` with object keys sorted, so equal values spell the same. */
114649
+ function canonicalJson(value) {
114650
+ if (Array.isArray(value)) return `[${value.map((item) => canonicalJson(item)).join(",")}]`;
114651
+ if (typeof value === "object" && value !== null) return `{${Object.entries(value).filter(([, item]) => item !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(",")}}`;
114652
+ return JSON.stringify(value) ?? "null";
114653
+ }
114654
+ /** The precondition string for a value a plan read (`null` when it read nothing). */
114655
+ function preconditionOf(value) {
114656
+ return canonicalJson(value ?? null);
114657
+ }
114830
114658
  /**
114831
114659
  * What a `procedure` body reports. `already-done` means the rerun observed the
114832
114660
  * work finished (a crash between the work and the ledger row, or a manual fix)
@@ -120557,24 +120385,52 @@ var APPLIANCE_CAP_VOCABULARY = {
120557
120385
  args: { on: false }
120558
120386
  },
120559
120387
  {
120560
- id: "petFountain.intervalOn",
120561
- method: "setPumpInterval",
120388
+ id: "petFountain.resetDailyConsumption",
120389
+ method: "resetDailyConsumption",
120390
+ label: t("Clear daily consumption", "Azzera il consumo giornaliero"),
120391
+ phrase: t("Clear the daily water consumption of {device}", "Azzera il consumo giornaliero di acqua di {device}"),
120392
+ icon: "rotate-ccw"
120393
+ }
120394
+ ],
120395
+ state: [{
120396
+ kind: "boolean",
120397
+ field: "empty",
120398
+ label: t("Tank empty", "Serbatoio vuoto"),
120399
+ whenTrue: t("When the tank of {device} is empty", "Quando il serbatoio di {device} è vuoto"),
120400
+ whenFalse: t("When the tank of {device} is refilled", "Quando il serbatoio di {device} viene riempito"),
120401
+ icon: "droplet"
120402
+ }, {
120403
+ kind: "numeric",
120404
+ field: "waterLevel",
120405
+ quantity: t("the water level", "il livello dell’acqua"),
120406
+ label: t("Water level", "Livello dell’acqua"),
120407
+ unit: "%",
120408
+ icon: "droplets"
120409
+ }]
120410
+ },
120411
+ "pump-cycle": {
120412
+ scope: "device",
120413
+ noun: t("Pump cycle", "Ciclo della pompa"),
120414
+ actions: [
120415
+ {
120416
+ id: "pumpCycle.on",
120417
+ method: "setEnabled",
120562
120418
  label: t("Pump cycle on", "Ciclo pompa attivo"),
120563
120419
  phrase: t("Run the pump of {device} on its on/off cycle", "Fai lavorare la pompa di {device} a ciclo acceso/spento"),
120564
120420
  icon: "timer",
120565
120421
  args: { on: true }
120566
120422
  },
120567
120423
  {
120568
- id: "petFountain.intervalOff",
120569
- method: "setPumpInterval",
120424
+ id: "pumpCycle.off",
120425
+ method: "setEnabled",
120570
120426
  label: t("Pump cycle off", "Ciclo pompa spento"),
120571
120427
  phrase: t("Run the pump of {device} continuously", "Fai lavorare la pompa di {device} in continuo"),
120572
120428
  icon: "timer-off",
120573
120429
  args: { on: false }
120574
120430
  },
120575
120431
  {
120576
- id: "petFountain.pumpOnMinutes",
120577
- method: "setPumpOnMinutes",
120432
+ id: "pumpCycle.onMinutes",
120433
+ method: "setOnMinutes",
120578
120434
  label: t("Set pump run time", "Imposta il tempo di lavoro della pompa"),
120579
120435
  phrase: t("Set the pump of {device} to run {minutes} minutes per cycle", "Imposta la pompa di {device} a lavorare {minutes} minuti per ciclo"),
120580
120436
  icon: "timer",
@@ -120590,8 +120446,8 @@ var APPLIANCE_CAP_VOCABULARY = {
120590
120446
  }]
120591
120447
  },
120592
120448
  {
120593
- id: "petFountain.pumpOffMinutes",
120594
- method: "setPumpOffMinutes",
120449
+ id: "pumpCycle.offMinutes",
120450
+ method: "setOffMinutes",
120595
120451
  label: t("Set pump rest time", "Imposta il tempo di riposo della pompa"),
120596
120452
  phrase: t("Set the pump of {device} to rest {minutes} minutes per cycle", "Imposta la pompa di {device} a riposare {minutes} minuti per ciclo"),
120597
120453
  icon: "timer-off",
@@ -120605,29 +120461,15 @@ var APPLIANCE_CAP_VOCABULARY = {
120605
120461
  unit: "min"
120606
120462
  }
120607
120463
  }]
120608
- },
120609
- {
120610
- id: "petFountain.resetDailyConsumption",
120611
- method: "resetDailyConsumption",
120612
- label: t("Clear daily consumption", "Azzera il consumo giornaliero"),
120613
- phrase: t("Clear the daily water consumption of {device}", "Azzera il consumo giornaliero di acqua di {device}"),
120614
- icon: "rotate-ccw"
120615
120464
  }
120616
120465
  ],
120617
120466
  state: [{
120618
120467
  kind: "boolean",
120619
- field: "empty",
120620
- label: t("Tank empty", "Serbatoio vuoto"),
120621
- whenTrue: t("When the tank of {device} is empty", "Quando il serbatoio di {device} è vuoto"),
120622
- whenFalse: t("When the tank of {device} is refilled", "Quando il serbatoio di {device} viene riempito"),
120623
- icon: "droplet"
120624
- }, {
120625
- kind: "numeric",
120626
- field: "waterLevel",
120627
- quantity: t("the water level", "il livello dell’acqua"),
120628
- label: t("Water level", "Livello dell’acqua"),
120629
- unit: "%",
120630
- icon: "droplets"
120468
+ field: "enabled",
120469
+ label: t("Pump cycle", "Ciclo pompa"),
120470
+ whenTrue: t("When {device} starts running its pump on a cycle", "Quando {device} inizia a far lavorare la pompa a ciclo"),
120471
+ whenFalse: t("When {device} runs its pump continuously", "Quando {device} fa lavorare la pompa in continuo"),
120472
+ icon: "timer"
120631
120473
  }]
120632
120474
  },
120633
120475
  "media-player": {
@@ -122520,6 +122362,7 @@ var SYSTEM_CAP_VOCABULARY = {
122520
122362
  "audio-analyzer": system(PIPELINE),
122521
122363
  "audio-codec": system(INTERNAL),
122522
122364
  "auth-provider": system(IDENTITY),
122365
+ "board-key-source": system(INTERNAL),
122523
122366
  backup: system("system backups — run and restored from the Backups page"),
122524
122367
  broker: system("message-broker connections — configured, not actuated"),
122525
122368
  "calendar-source": system(INTERNAL),
@@ -147429,6 +147272,49 @@ var binaryCapability = {
147429
147272
  volatileStateFields: ["lastChangedAt"]
147430
147273
  };
147431
147274
  /**
147275
+ * Why a board key could not be read. Typed so a caller reports the cause and
147276
+ * never treats "could not read" as "has no key" (D49).
147277
+ */
147278
+ var BoardKeyFailureReasonSchema = _enum([
147279
+ "unknown-source",
147280
+ "not-admin",
147281
+ "no-builder",
147282
+ "no-ingress-url",
147283
+ "unreachable",
147284
+ "unknown-board",
147285
+ "no-encryption-key",
147286
+ "unreadable-config"
147287
+ ]);
147288
+ var BoardKeySourceSchema = object({
147289
+ sourceId: string(),
147290
+ name: string()
147291
+ });
147292
+ var BoardKeyBoardSchema = object({
147293
+ /** The builder's file name, e.g. `kitchen.yaml`: the handle `getBoardKey` takes. */
147294
+ configuration: string(),
147295
+ /** The node name the builder gives the board. */
147296
+ name: string(),
147297
+ /** The address the builder knows the board by, when it has one. */
147298
+ address: string().nullable()
147299
+ });
147300
+ var BoardKeyFailureSchema = object({
147301
+ ok: literal(false),
147302
+ reason: BoardKeyFailureReasonSchema,
147303
+ detail: string().optional()
147304
+ });
147305
+ var ListBoardsResultSchema = discriminatedUnion("ok", [object({
147306
+ ok: literal(true),
147307
+ boards: array(BoardKeyBoardSchema).readonly()
147308
+ }), BoardKeyFailureSchema]);
147309
+ var GetBoardKeyResultSchema = discriminatedUnion("ok", [object({
147310
+ ok: literal(true),
147311
+ key: string()
147312
+ }), BoardKeyFailureSchema]);
147313
+ method(_void(), array(BoardKeySourceSchema).readonly(), { auth: "admin" }), method(object({ sourceId: string().min(1) }), ListBoardsResultSchema, { auth: "admin" }), method(object({
147314
+ sourceId: string().min(1),
147315
+ configuration: string().min(1)
147316
+ }), GetBoardKeyResultSchema, { auth: "admin" });
147317
+ /**
147432
147318
  * Dimmable-light brightness control. Co-exists with `switch` on the
147433
147319
  * same device — the switch toggles on/off, this cap sets the level
147434
147320
  * applied when the light is on. Drivers map their per-vendor dim
@@ -154066,7 +153952,7 @@ var DishwasherStatusSchema = object({
154066
153952
  /** Ms epoch of the last value received from the appliance; 0 = never. */
154067
153953
  lastFetchedAt: number()
154068
153954
  });
154069
- var DeviceIdSchema$1 = number().int().nonnegative();
153955
+ var DeviceIdSchema$2 = number().int().nonnegative();
154070
153956
  /** A value a program option or setting can take. */
154071
153957
  var DishwasherValueSchema = union([
154072
153958
  boolean(),
@@ -154141,77 +154027,77 @@ var dishwasherCapability = {
154141
154027
  * option feature keys; each is checked against the program's constraints.
154142
154028
  */
154143
154029
  start: method(object({
154144
- deviceId: DeviceIdSchema$1,
154030
+ deviceId: DeviceIdSchema$2,
154145
154031
  program: string().min(1).optional(),
154146
154032
  options: record(string(), DishwasherValueSchema).optional()
154147
154033
  }), _void(), {
154148
154034
  kind: "mutation",
154149
154035
  auth: "protected"
154150
154036
  }),
154151
- pause: method(object({ deviceId: DeviceIdSchema$1 }), _void(), {
154037
+ pause: method(object({ deviceId: DeviceIdSchema$2 }), _void(), {
154152
154038
  kind: "mutation",
154153
154039
  auth: "protected"
154154
154040
  }),
154155
- resume: method(object({ deviceId: DeviceIdSchema$1 }), _void(), {
154041
+ resume: method(object({ deviceId: DeviceIdSchema$2 }), _void(), {
154156
154042
  kind: "mutation",
154157
154043
  auth: "protected"
154158
154044
  }),
154159
- stop: method(object({ deviceId: DeviceIdSchema$1 }), _void(), {
154045
+ stop: method(object({ deviceId: DeviceIdSchema$2 }), _void(), {
154160
154046
  kind: "mutation",
154161
154047
  auth: "protected"
154162
154048
  }),
154163
154049
  /** Select (not start) a program; refused when the appliance does not offer it. */
154164
154050
  setProgram: method(object({
154165
- deviceId: DeviceIdSchema$1,
154051
+ deviceId: DeviceIdSchema$2,
154166
154052
  program: string().min(1)
154167
154053
  }), _void(), {
154168
154054
  kind: "mutation",
154169
154055
  auth: "protected"
154170
154056
  }),
154171
154057
  setPower: method(object({
154172
- deviceId: DeviceIdSchema$1,
154058
+ deviceId: DeviceIdSchema$2,
154173
154059
  on: boolean()
154174
154060
  }), _void(), {
154175
154061
  kind: "mutation",
154176
154062
  auth: "protected"
154177
154063
  }),
154178
154064
  setHalfLoad: method(object({
154179
- deviceId: DeviceIdSchema$1,
154065
+ deviceId: DeviceIdSchema$2,
154180
154066
  on: boolean()
154181
154067
  }), _void(), {
154182
154068
  kind: "mutation",
154183
154069
  auth: "protected"
154184
154070
  }),
154185
154071
  setHygienePlus: method(object({
154186
- deviceId: DeviceIdSchema$1,
154072
+ deviceId: DeviceIdSchema$2,
154187
154073
  on: boolean()
154188
154074
  }), _void(), {
154189
154075
  kind: "mutation",
154190
154076
  auth: "protected"
154191
154077
  }),
154192
154078
  setExtraDry: method(object({
154193
- deviceId: DeviceIdSchema$1,
154079
+ deviceId: DeviceIdSchema$2,
154194
154080
  on: boolean()
154195
154081
  }), _void(), {
154196
154082
  kind: "mutation",
154197
154083
  auth: "protected"
154198
154084
  }),
154199
154085
  setIntensiveZone: method(object({
154200
- deviceId: DeviceIdSchema$1,
154086
+ deviceId: DeviceIdSchema$2,
154201
154087
  on: boolean()
154202
154088
  }), _void(), {
154203
154089
  kind: "mutation",
154204
154090
  auth: "protected"
154205
154091
  }),
154206
154092
  setVarioSpeedPlus: method(object({
154207
- deviceId: DeviceIdSchema$1,
154093
+ deviceId: DeviceIdSchema$2,
154208
154094
  on: boolean()
154209
154095
  }), _void(), {
154210
154096
  kind: "mutation",
154211
154097
  auth: "protected"
154212
154098
  }),
154213
154099
  setChildLock: method(object({
154214
- deviceId: DeviceIdSchema$1,
154100
+ deviceId: DeviceIdSchema$2,
154215
154101
  on: boolean()
154216
154102
  }), _void(), {
154217
154103
  kind: "mutation",
@@ -154223,7 +154109,7 @@ var dishwasherCapability = {
154223
154109
  * its declared range / members.
154224
154110
  */
154225
154111
  setOption: method(object({
154226
- deviceId: DeviceIdSchema$1,
154112
+ deviceId: DeviceIdSchema$2,
154227
154113
  key: string().min(1),
154228
154114
  value: DishwasherValueSchema
154229
154115
  }), _void(), {
@@ -154231,7 +154117,7 @@ var dishwasherCapability = {
154231
154117
  auth: "protected"
154232
154118
  }),
154233
154119
  /** What this appliance can do, from its own description — the UI is derived from it. */
154234
- getOptions: method(object({ deviceId: DeviceIdSchema$1 }), DishwasherOptionsSchema, { auth: "protected" })
154120
+ getOptions: method(object({ deviceId: DeviceIdSchema$2 }), DishwasherOptionsSchema, { auth: "protected" })
154235
154121
  },
154236
154122
  status: {
154237
154123
  schema: DishwasherStatusSchema,
@@ -169660,7 +169546,7 @@ var petFeederCapability = {
169660
169546
  /**
169661
169547
  * Pet water-fountain cap. Models the telemetry and the control surface of a
169662
169548
  * circulating drinking fountain: how much water is in the tank, how much the
169663
- * pet drank, whether the pump runs and on what duty cycle.
169549
+ * pet drank, whether the pump runs.
169664
169550
  *
169665
169551
  * It has no native vendor provider of its own: a fountain reaches CamStack as
169666
169552
  * a container of loose entities (a pump switch, two number boxes, a "filter
@@ -169676,8 +169562,10 @@ var petFeederCapability = {
169676
169562
  * never here. Every reading may be `null`: a source that is offline or does
169677
169563
  * not report is unknown, never zero.
169678
169564
  *
169679
- * The filter is not a field of this cap: it is an item of the `consumables`
169680
- * cap on the same device (kind `wear`), reset by the same forward mechanism.
169565
+ * The pump's on/off duty cycle is model-specific and lives in the optional
169566
+ * `pump-cycle` cap. The filter, the cleaning and the water change are not
169567
+ * fields of this cap: they are items of the `consumables` cap on the same
169568
+ * device (kind `wear`), reset by the same forward mechanism.
169681
169569
  */
169682
169570
  var PetFountainStatusSchema = object({
169683
169571
  /** Water in the tank, 0..100 (%). Null while the source has not reported. */
@@ -169688,27 +169576,20 @@ var PetFountainStatusSchema = object({
169688
169576
  empty: boolean().nullable(),
169689
169577
  /** True while the pump is running. Null while unknown. */
169690
169578
  pumpRunning: boolean().nullable(),
169691
- /** True when the pump works on an on/off duty cycle rather than continuously. */
169692
- pumpIntervalEnabled: boolean().nullable(),
169693
- /** How long the pump runs per cycle, in seconds. */
169694
- pumpOnTime: number().min(0).nullable().meta(readingMeta("duration")),
169695
- /** How long the pump rests per cycle, in seconds. */
169696
- pumpOffTime: number().min(0).nullable().meta(readingMeta("duration")),
169697
169579
  /** Water drunk in the last drink, in litres. */
169698
169580
  lastDrink: number().min(0).nullable().meta(readingMeta("volume")),
169699
169581
  /** Water drunk in the current hour, in litres. */
169700
169582
  currentHourDrink: number().min(0).nullable().meta(readingMeta("volume")),
169701
169583
  /** Water drunk over the last 24 hours (rolling), in litres. */
169702
169584
  last24hDrink: number().min(0).nullable().meta(readingMeta("volume")),
169703
- /** Water drunk since local midnight, in litres. */
169585
+ /** Water drunk since local midnight, in litres. Null when the fountain has no such sensor. */
169704
169586
  todayDrink: number().min(0).nullable().meta(readingMeta("volume")),
169705
169587
  /** Decoded fault message. Null means healthy; a non-null string is an active fault. */
169706
169588
  error: string().nullable(),
169707
169589
  /** Ms epoch when the slice was last refreshed. */
169708
169590
  lastFetchedAt: number()
169709
169591
  });
169710
- var DeviceIdSchema = number().int().nonnegative();
169711
- var MinutesSchema = number().int().min(0);
169592
+ var DeviceIdSchema$1 = number().int().nonnegative();
169712
169593
  var petFountainCapability = {
169713
169594
  name: "pet-fountain",
169714
169595
  scope: "device",
@@ -169718,38 +169599,14 @@ var petFountainCapability = {
169718
169599
  methods: {
169719
169600
  /** Start or stop the pump. */
169720
169601
  setPump: method(object({
169721
- deviceId: DeviceIdSchema,
169722
- on: boolean()
169723
- }), _void(), {
169724
- kind: "mutation",
169725
- auth: "protected"
169726
- }),
169727
- /** Switch the on/off duty cycle on or off (off = the pump runs continuously). */
169728
- setPumpInterval: method(object({
169729
- deviceId: DeviceIdSchema,
169602
+ deviceId: DeviceIdSchema$1,
169730
169603
  on: boolean()
169731
169604
  }), _void(), {
169732
169605
  kind: "mutation",
169733
169606
  auth: "protected"
169734
169607
  }),
169735
- /** Set how many minutes the pump runs per cycle. */
169736
- setPumpOnMinutes: method(object({
169737
- deviceId: DeviceIdSchema,
169738
- minutes: MinutesSchema
169739
- }), _void(), {
169740
- kind: "mutation",
169741
- auth: "protected"
169742
- }),
169743
- /** Set how many minutes the pump rests per cycle. */
169744
- setPumpOffMinutes: method(object({
169745
- deviceId: DeviceIdSchema,
169746
- minutes: MinutesSchema
169747
- }), _void(), {
169748
- kind: "mutation",
169749
- auth: "protected"
169750
- }),
169751
169608
  /** Clear the rolling daily consumption counters on the device. */
169752
- resetDailyConsumption: method(object({ deviceId: DeviceIdSchema }), _void(), {
169609
+ resetDailyConsumption: method(object({ deviceId: DeviceIdSchema$1 }), _void(), {
169753
169610
  kind: "mutation",
169754
169611
  auth: "protected"
169755
169612
  })
@@ -172195,6 +172052,79 @@ var ptzAutotrackCapability = {
172195
172052
  history: "none"
172196
172053
  };
172197
172054
  /**
172055
+ * The on/off duty cycle of a pet fountain's pump. Some models run the pump
172056
+ * continuously and have nothing to configure; others can alternate a run time
172057
+ * and a rest time. That is model-specific, so it is its own optional cap on the
172058
+ * fountain device: a fountain without cycle entities simply never composes it
172059
+ * (planning#366), and the `pet-fountain` cap stays the model-independent core.
172060
+ *
172061
+ * Like `pet-fountain` it has no native vendor provider: an operator BUILDS the
172062
+ * cycle from loose entities (a cycle switch and two number boxes) and every
172063
+ * method is a forward to one source entity (D659, D967). Durations are stored
172064
+ * in SECONDS (D944); a number box that cannot be read back leaves them `null`
172065
+ * — unknown, never zero (D393).
172066
+ */
172067
+ var PumpCycleStatusSchema = object({
172068
+ /** True when the pump works on the on/off cycle, false when it runs continuously. Null while unknown. */
172069
+ enabled: boolean().nullable(),
172070
+ /** How long the pump runs per cycle, in seconds. Null while unknown. */
172071
+ onTime: number().min(0).nullable().meta(readingMeta("duration")),
172072
+ /** How long the pump rests per cycle, in seconds. Null while unknown. */
172073
+ offTime: number().min(0).nullable().meta(readingMeta("duration")),
172074
+ /** Ms epoch when the slice was last refreshed. */
172075
+ lastFetchedAt: number()
172076
+ });
172077
+ var DeviceIdSchema = number().int().nonnegative();
172078
+ var MinutesSchema = number().int().min(0);
172079
+ var pumpCycleCapability = {
172080
+ name: "pump-cycle",
172081
+ scope: "device",
172082
+ deviceNative: true,
172083
+ mode: "singleton",
172084
+ deviceTypes: [DeviceType.PetFountain],
172085
+ methods: {
172086
+ /** Switch the on/off duty cycle on or off (off = the pump runs continuously). */
172087
+ setEnabled: method(object({
172088
+ deviceId: DeviceIdSchema,
172089
+ on: boolean()
172090
+ }), _void(), {
172091
+ kind: "mutation",
172092
+ auth: "protected"
172093
+ }),
172094
+ /** Set how many minutes the pump runs per cycle. */
172095
+ setOnMinutes: method(object({
172096
+ deviceId: DeviceIdSchema,
172097
+ minutes: MinutesSchema
172098
+ }), _void(), {
172099
+ kind: "mutation",
172100
+ auth: "protected"
172101
+ }),
172102
+ /** Set how many minutes the pump rests per cycle. */
172103
+ setOffMinutes: method(object({
172104
+ deviceId: DeviceIdSchema,
172105
+ minutes: MinutesSchema
172106
+ }), _void(), {
172107
+ kind: "mutation",
172108
+ auth: "protected"
172109
+ })
172110
+ },
172111
+ status: {
172112
+ schema: PumpCycleStatusSchema,
172113
+ kind: "push"
172114
+ },
172115
+ /** Runtime-state slice — mirrored by the kernel; the hero card reads it whole. */
172116
+ runtimeState: PumpCycleStatusSchema,
172117
+ /**
172118
+ * Runtime-state durability: **session** — live appliance state re-published on connect.
172119
+ *
172120
+ * See `RuntimeStateDurability`. Enforced by
172121
+ * `scripts/check-runtime-state-durability.ts`.
172122
+ */
172123
+ durability: "session",
172124
+ /** History (D850): a duty-cycle setting is not a series worth recording. */
172125
+ history: "none"
172126
+ };
172127
+ /**
172198
172128
  * reboot — device-scoped capability for "soft" device reboots (firmware
172199
172129
  * reboot via vendor protocol; cameras, NVRs, doorbells). Surfaces a
172200
172130
  * single mutation so the UI can offer a confirm-and-reboot button for
@@ -182474,6 +182404,7 @@ var DEVICE_LOCAL_STATE_CAPS = {
182474
182404
  pressureSensor: pressureSensorCapability,
182475
182405
  privacyMask: privacyMaskCapability,
182476
182406
  ptzAutotrack: ptzAutotrackCapability,
182407
+ pumpCycle: pumpCycleCapability,
182477
182408
  recordingOnboard: recordingOnboardCapability,
182478
182409
  recordingSignal: recordingSignalCapability,
182479
182410
  runningState: runningStateCapability,
@@ -184658,6 +184589,27 @@ Object.freeze({
184658
184589
  access: "view",
184659
184590
  role: "viewer"
184660
184591
  },
184592
+ "boardKeySource.getBoardKey": {
184593
+ capName: "board-key-source",
184594
+ capScope: "system",
184595
+ addonId: null,
184596
+ access: "view",
184597
+ role: "admin"
184598
+ },
184599
+ "boardKeySource.listBoards": {
184600
+ capName: "board-key-source",
184601
+ capScope: "system",
184602
+ addonId: null,
184603
+ access: "view",
184604
+ role: "admin"
184605
+ },
184606
+ "boardKeySource.listSources": {
184607
+ capName: "board-key-source",
184608
+ capScope: "system",
184609
+ addonId: null,
184610
+ access: "view",
184611
+ role: "admin"
184612
+ },
184661
184613
  "brightness.getStatus": {
184662
184614
  capName: "brightness",
184663
184615
  capScope: "device",
@@ -189481,27 +189433,6 @@ Object.freeze({
189481
189433
  access: "create",
189482
189434
  role: "viewer"
189483
189435
  },
189484
- "petFountain.setPumpInterval": {
189485
- capName: "pet-fountain",
189486
- capScope: "device",
189487
- addonId: null,
189488
- access: "create",
189489
- role: "viewer"
189490
- },
189491
- "petFountain.setPumpOffMinutes": {
189492
- capName: "pet-fountain",
189493
- capScope: "device",
189494
- addonId: null,
189495
- access: "create",
189496
- role: "viewer"
189497
- },
189498
- "petFountain.setPumpOnMinutes": {
189499
- capName: "pet-fountain",
189500
- capScope: "device",
189501
- addonId: null,
189502
- access: "create",
189503
- role: "viewer"
189504
- },
189505
189436
  "pipelineAnalytics.addRetrainFrameFromRecording": {
189506
189437
  capName: "pipeline-analytics",
189507
189438
  capScope: "device",
@@ -191280,6 +191211,34 @@ Object.freeze({
191280
191211
  access: "create",
191281
191212
  role: "admin"
191282
191213
  },
191214
+ "pumpCycle.getStatus": {
191215
+ capName: "pump-cycle",
191216
+ capScope: "device",
191217
+ addonId: null,
191218
+ access: "view",
191219
+ role: "viewer"
191220
+ },
191221
+ "pumpCycle.setEnabled": {
191222
+ capName: "pump-cycle",
191223
+ capScope: "device",
191224
+ addonId: null,
191225
+ access: "create",
191226
+ role: "viewer"
191227
+ },
191228
+ "pumpCycle.setOffMinutes": {
191229
+ capName: "pump-cycle",
191230
+ capScope: "device",
191231
+ addonId: null,
191232
+ access: "create",
191233
+ role: "viewer"
191234
+ },
191235
+ "pumpCycle.setOnMinutes": {
191236
+ capName: "pump-cycle",
191237
+ capScope: "device",
191238
+ addonId: null,
191239
+ access: "create",
191240
+ role: "viewer"
191241
+ },
191283
191242
  "reboot.reboot": {
191284
191243
  capName: "reboot",
191285
191244
  capScope: "device",
@@ -195976,21 +195935,6 @@ Object.freeze({
195976
195935
  form: "single",
195977
195936
  optional: false
195978
195937
  }],
195979
- "petFountain.setPumpInterval": [{
195980
- name: "deviceId",
195981
- form: "single",
195982
- optional: false
195983
- }],
195984
- "petFountain.setPumpOffMinutes": [{
195985
- name: "deviceId",
195986
- form: "single",
195987
- optional: false
195988
- }],
195989
- "petFountain.setPumpOnMinutes": [{
195990
- name: "deviceId",
195991
- form: "single",
195992
- optional: false
195993
- }],
195994
195938
  "pipelineAnalytics.addRetrainFrameFromRecording": [{
195995
195939
  name: "deviceId",
195996
195940
  form: "single",
@@ -196626,6 +196570,26 @@ Object.freeze({
196626
196570
  form: "single",
196627
196571
  optional: false
196628
196572
  }],
196573
+ "pumpCycle.getStatus": [{
196574
+ name: "deviceId",
196575
+ form: "single",
196576
+ optional: false
196577
+ }],
196578
+ "pumpCycle.setEnabled": [{
196579
+ name: "deviceId",
196580
+ form: "single",
196581
+ optional: false
196582
+ }],
196583
+ "pumpCycle.setOffMinutes": [{
196584
+ name: "deviceId",
196585
+ form: "single",
196586
+ optional: false
196587
+ }],
196588
+ "pumpCycle.setOnMinutes": [{
196589
+ name: "deviceId",
196590
+ form: "single",
196591
+ optional: false
196592
+ }],
196629
196593
  "reboot.reboot": [{
196630
196594
  name: "deviceId",
196631
196595
  form: "single",
@@ -199237,7 +199201,7 @@ async function resolvePinnedFfmpegSpawner(seam, resolveFfmpeg, options) {
199237
199201
  * provider's `addonId` field manually — both must match for the device-
199238
199202
  * manager + alert center to correlate events back to the provider.
199239
199203
  */
199240
- var REOLINK_ADDON_ID = "provider-reolink";
199204
+ var REOLINK_ADDON_ID$1 = "provider-reolink";
199241
199205
  /**
199242
199206
  * Map nodelink ReolinkSimpleEvent.type values to the loose
199243
199207
  * CameraNativeDetection class strings the rest of camstack consumes.
@@ -199738,6 +199702,12 @@ var reolinkCameraSchema = object({
199738
199702
  "email"
199739
199703
  ]).optional(),
199740
199704
  /**
199705
+ * The URL generation this battery camera's HaCfg webhook points at (absent = the retired private
199706
+ * :9081 listener). Written once the camera has been moved to the addon's data-plane URL, so the
199707
+ * move happens on the first Baichuan contact after the upgrade and never again.
199708
+ */
199709
+ webhookUrlGeneration: number().int().optional(),
199710
+ /**
199741
199711
  * On-camera motion detection master switch + sensitivity.
199742
199712
  * Pushed via `setMotionAlarm` (cmdId=47). The Reolink mobile
199743
199713
  * app + camera web UI use 1..50 (1 = least sensitive,
@@ -203380,7 +203350,7 @@ function createReolinkVideoclipsProvider(deps) {
203380
203350
  if (device === null) return [];
203381
203351
  return [{
203382
203352
  source: CLIP_SOURCE_ONBOARD,
203383
- addonId: REOLINK_ADDON_ID,
203353
+ addonId: REOLINK_ADDON_ID$1,
203384
203354
  label: "Camera storage",
203385
203355
  availability: await resolveAvailability(device)
203386
203356
  }];
@@ -204547,10 +204517,10 @@ function createClipService(deps) {
204547
204517
  cache,
204548
204518
  resolveDevice: deps.resolveDevice,
204549
204519
  hasVouchedThumbnail: (deviceId, clipKey) => thumbs.has(deviceId, clipKey),
204550
- thumbnailUrl: (deviceId, clipKey) => `/addon/${REOLINK_ADDON_ID}/${CLIP_THUMB_PREFIX}/${deviceId}/${clipKey}`,
204551
- thumbnailMintUrl: (deviceId, clipKey) => `/addon/${REOLINK_ADDON_ID}/${CLIP_THUMB_PREFIX}/${deviceId}/${clipKey}`,
204520
+ thumbnailUrl: (deviceId, clipKey) => `/addon/${REOLINK_ADDON_ID$1}/${CLIP_THUMB_PREFIX}/${deviceId}/${clipKey}`,
204521
+ thumbnailMintUrl: (deviceId, clipKey) => `/addon/${REOLINK_ADDON_ID$1}/${CLIP_THUMB_PREFIX}/${deviceId}/${clipKey}`,
204552
204522
  preparePlayback: async ({ device, clipKey, profile }) => ({
204553
- playbackUrl: `/addon/${REOLINK_ADDON_ID}/${CLIP_MEDIA_PREFIX}/${device.deviceId}/${clipKey}.mp4`,
204523
+ playbackUrl: `/addon/${REOLINK_ADDON_ID$1}/${CLIP_MEDIA_PREFIX}/${device.deviceId}/${clipKey}.mp4`,
204554
204524
  served: profile === "high" ? "high" : "low"
204555
204525
  }),
204556
204526
  prepareBytes,
@@ -204596,7 +204566,7 @@ function createClipService(deps) {
204596
204566
  deviceId,
204597
204567
  since: until - CLIP_CATALOG_REFRESH_WINDOW_MS,
204598
204568
  until,
204599
- provider: REOLINK_ADDON_ID
204569
+ provider: REOLINK_ADDON_ID$1
204600
204570
  });
204601
204571
  },
204602
204572
  hostBusy: (hostKey) => queue.busy(hostKey),
@@ -205343,6 +205313,52 @@ var IntercomFailureReport = class {
205343
205313
  /** The process-wide instance every camera in this addon notes into. */
205344
205314
  var intercomFailureReport = new IntercomFailureReport();
205345
205315
  //#endregion
205316
+ //#region src/baichuan-webhook-shared.ts
205317
+ /** The data-plane prefix this addon serves the camera push on: `/addon/<addonId>/webhook/<stableId>`. */
205318
+ var BAICHUAN_WEBHOOK_PREFIX = "webhook";
205319
+ /** The addon id the hub's reverse proxy mounts the plane under. */
205320
+ var BAICHUAN_WEBHOOK_ADDON_ID = "provider-reolink";
205321
+ /** The hub's plain-HTTP LAN listener, when the address book does not say (a hub older than its `listenerOrigins`). */
205322
+ var DEFAULT_LAN_HTTP_PORT = 4480;
205323
+ /**
205324
+ * Absolute URL a camera should POST HaCfg events to: the addon's data plane on the hub's plain-HTTP
205325
+ * LAN listener. HTTP, not HTTPS: the firmware refuses the hub's self-signed certificate.
205326
+ */
205327
+ function buildBaichuanWebhookUrl(params) {
205328
+ const id = encodeURIComponent(params.stableId);
205329
+ return `http://${params.host}:${String(params.port)}/addon/${BAICHUAN_WEBHOOK_ADDON_ID}/${BAICHUAN_WEBHOOK_PREFIX}/${id}`;
205330
+ }
205331
+ function hostOf(url) {
205332
+ if (url === null || url.trim().length === 0) return null;
205333
+ try {
205334
+ const host = new URL(url).hostname;
205335
+ return host.length > 0 ? host : null;
205336
+ } catch {
205337
+ return null;
205338
+ }
205339
+ }
205340
+ /**
205341
+ * The host is the `internal` role of the palette (D729) and the port is the plain-HTTP origin the
205342
+ * hub lists for that host. Throws the reason, in the operator's words, when there is none: there is
205343
+ * no fallback host and never an HTTPS one.
205344
+ */
205345
+ function resolveWebhookEndpoint(book) {
205346
+ const host = hostOf(book.roles.internal.resolved);
205347
+ if (host === null) throw new Error("The hub has no internal address (System → Network)");
205348
+ if (book.listenerOrigins === void 0) return {
205349
+ host,
205350
+ port: DEFAULT_LAN_HTTP_PORT
205351
+ };
205352
+ for (const origin of book.listenerOrigins) try {
205353
+ const url = new URL(origin);
205354
+ if (url.protocol === "http:" && url.hostname === host) return {
205355
+ host,
205356
+ port: url.port.length > 0 ? Number(url.port) : 80
205357
+ };
205358
+ } catch {}
205359
+ throw new Error("The hub has no plain-HTTP LAN listener: the camera webhook cannot use HTTPS. Turn on LAN HTTP in System → Network");
205360
+ }
205361
+ //#endregion
205346
205362
  //#region src/battery-charging.ts
205347
205363
  /**
205348
205364
  * From `adapterStatus` + `chargeStatus` to the power source.
@@ -205596,912 +205612,6 @@ var BatteryHourlyRefresh = class {
205596
205612
  }
205597
205613
  };
205598
205614
  //#endregion
205599
- //#region src/hub-connection.ts
205600
- /** Baichuan control port a Reolink NVR / Home Hub listens on. */
205601
- var REOLINK_HUB_DEFAULT_PORT = 9e3;
205602
- /** Settings key that names the section holding the hub connection form. */
205603
- var HUB_CONNECTION_SECTION_ID = "connection";
205604
- var HUB_DEBUG_SECTION_ID = "hub-debug";
205605
- /** A blank form field is "not provided", never an invalid port. */
205606
- function blankToUndefined(value) {
205607
- return value === "" || value === null ? void 0 : value;
205608
- }
205609
- /**
205610
- * The connection of ONE Reolink NVR / Home Hub: the settings of an INTEGRATION (D985). The
205611
- * password is a schema `password` field kept in the keychain — never in env, argv or logs.
205612
- */
205613
- var reolinkHubConnectionSchema = object({
205614
- host: string().trim().min(1).describe("NVR / Home Hub IP or hostname"),
205615
- port: preprocess(blankToUndefined, number().int().min(1).max(65535).default(REOLINK_HUB_DEFAULT_PORT)),
205616
- /** The RTSP relay port the cameras dial for their streams (not the Baichuan `port`). */
205617
- rtspPort: preprocess(blankToUndefined, number().int().min(1).max(65535).default(554)),
205618
- username: preprocess(blankToUndefined, string().trim().min(1).default("admin")),
205619
- password: string().min(1).describe("NVR / Home Hub password").meta({ secret: true }),
205620
- /** Hubs are TCP-only on supported firmwares — kept for symmetry with a camera. */
205621
- transport: _enum(["tcp", "udp"]).default("tcp"),
205622
- /** UID of a hub that was paired by it; unused for a plain TCP login. */
205623
- uid: string().optional(),
205624
- debugGeneral: boolean().default(false),
205625
- debugSocketLogs: array(ReolinkSocketDebugFlagSchema).default([])
205626
- });
205627
- function stringValue(value) {
205628
- return typeof value === "string" ? value.trim() : "";
205629
- }
205630
- function readHubConnection(settings) {
205631
- if (stringValue(settings["host"]).length === 0) return { kind: "no-host" };
205632
- const password = settings["password"];
205633
- if (isSecretMarkerLike(password)) return { kind: "password-locked" };
205634
- if (typeof password !== "string" || password.length === 0) return { kind: "no-password" };
205635
- const parsed = reolinkHubConnectionSchema.safeParse(settings);
205636
- if (!parsed.success) return { kind: "no-host" };
205637
- return {
205638
- kind: "ok",
205639
- connection: parsed.data
205640
- };
205641
- }
205642
- /** Two reads that would dial the same way. */
205643
- function sameHubConnection(a, b) {
205644
- return a.host === b.host && a.port === b.port && a.rtspPort === b.rtspPort && a.username === b.username && a.password === b.password && a.transport === b.transport && a.uid === b.uid;
205645
- }
205646
- /**
205647
- * The connection + debug sections of the integration form. Fields are NOT `required` here:
205648
- * the same schema is the addon's own settings page, where a required host would block saving
205649
- * an unrelated email-push change on the default integration. A hub integration missing its
205650
- * host or password is named by the session at reconcile time instead.
205651
- */
205652
- function buildHubConnectionSections() {
205653
- return { sections: [{
205654
- id: HUB_CONNECTION_SECTION_ID,
205655
- title: "NVR / Home Hub connection",
205656
- titleKey: "provider-reolink:connection.s_connection.title",
205657
- description: "Only for an integration that is one NVR or Home Hub. Its channels are adopted as cameras that share this connection.",
205658
- descriptionKey: "provider-reolink:connection.s_connection.description",
205659
- columns: 2,
205660
- fields: [
205661
- {
205662
- type: "text",
205663
- key: "host",
205664
- label: "Hub IP",
205665
- labelKey: "provider-reolink:connection.s_connection.host.label",
205666
- placeholder: "192.168.10.100"
205667
- },
205668
- {
205669
- type: "number",
205670
- key: "port",
205671
- label: "Port",
205672
- labelKey: "provider-reolink:connection.s_connection.port.label",
205673
- default: REOLINK_HUB_DEFAULT_PORT,
205674
- min: 1,
205675
- max: 65535,
205676
- step: 1
205677
- },
205678
- {
205679
- type: "number",
205680
- key: "rtspPort",
205681
- label: "RTSP port",
205682
- labelKey: "provider-reolink:connection.s_connection.rtspPort.label",
205683
- default: 554,
205684
- min: 1,
205685
- max: 65535,
205686
- step: 1
205687
- },
205688
- {
205689
- type: "text",
205690
- key: "username",
205691
- label: "Username",
205692
- labelKey: "provider-reolink:connection.s_connection.username.label",
205693
- default: "admin"
205694
- },
205695
- {
205696
- type: "password",
205697
- key: "password",
205698
- label: "Password",
205699
- labelKey: "provider-reolink:connection.s_connection.password.label",
205700
- showToggle: true
205701
- }
205702
- ]
205703
- }, {
205704
- id: HUB_DEBUG_SECTION_ID,
205705
- title: "NVR / Home Hub debug",
205706
- titleKey: "provider-reolink:connection.s_hub-debug.title",
205707
- description: "Debug logging adds log volume; use it temporarily.",
205708
- descriptionKey: "provider-reolink:connection.s_hub-debug.description",
205709
- columns: 1,
205710
- fields: [{
205711
- type: "boolean",
205712
- key: "debugGeneral",
205713
- label: "General debug logs",
205714
- labelKey: "provider-reolink:connection.s_hub-debug.debugGeneral.label",
205715
- default: false,
205716
- style: "switch"
205717
- }, {
205718
- type: "multiselect",
205719
- key: "debugSocketLogs",
205720
- label: "Socket trace categories",
205721
- labelKey: "provider-reolink:connection.s_hub-debug.debugSocketLogs.label",
205722
- description: "Each area adds log volume.",
205723
- descriptionKey: "provider-reolink:connection.s_hub-debug.debugSocketLogs.description",
205724
- default: [],
205725
- options: [{
205726
- value: "traceEvents",
205727
- label: "Events (cmd 33 — AlarmEventList push)",
205728
- labelKey: "provider-reolink:connection.s_hub-debug.debugSocketLogs.o_traceEvents.label"
205729
- }]
205730
- }]
205731
- }] };
205732
- }
205733
- /**
205734
- * `true` for an integration that IS one NVR / Home Hub: it was set up from a hub candidate, was
205735
- * migrated from a Hub device, or carries a host. The default integration (standalone cameras,
205736
- * email push, webhook, clips) has none of the three.
205737
- */
205738
- function isHubIntegration(evidence) {
205739
- if (evidence.originStableId?.startsWith("reolink-hub-") === true) return true;
205740
- if (evidence.info["migratedFromHubDeviceId"] !== void 0) return true;
205741
- return stringValue(evidence.settings["host"]).length > 0;
205742
- }
205743
- /** The form of a hub integration: its connection and debug sections, nothing of the addon's own. */
205744
- function buildHubInstanceSchema() {
205745
- return buildHubConnectionSections();
205746
- }
205747
- //#endregion
205748
- //#region src/hub-battery-fields.ts
205749
- /**
205750
- * The hub boundary for the per-channel battery power fields.
205751
- *
205752
- * The Reolink hub/NVR reports `chargeStatus` and `adapterStatus` as NUMBERS
205753
- * (0/1/2); the standalone camera path reports the same facts as strings, and
205754
- * `deriveChargingSource` (`battery-charging.ts`) speaks only the string
205755
- * vocabulary. This module is the translation, and — the part that was
205756
- * missing — it NAMES a code it cannot translate instead of dropping the field
205757
- * silently. A dropped field reads as "the push said nothing about power", so
205758
- * a new firmware value would have kept the previous belief forever, with not
205759
- * one log line. The ledger lets the hub warn once per (device, field, code).
205760
- */
205761
- var CHARGE_STATUS_BY_CODE = {
205762
- 0: "none",
205763
- 1: "charging",
205764
- 2: "chargeComplete"
205765
- };
205766
- var ADAPTER_STATUS_BY_CODE = {
205767
- 0: "none",
205768
- 1: "dc",
205769
- 2: "solarPanel"
205770
- };
205771
- function mapOne(field, value, byCode) {
205772
- if (value === void 0 || value === null) return {};
205773
- if (typeof value === "string") return { mapped: value };
205774
- if (typeof value === "number") {
205775
- const mapped = byCode[value];
205776
- if (mapped !== void 0) return { mapped };
205777
- }
205778
- return { unmapped: {
205779
- field,
205780
- value
205781
- } };
205782
- }
205783
- /** Translate one channel's raw CGI power fields; never throws. */
205784
- function mapHubBatteryPower(cgi) {
205785
- const charge = mapOne("chargeStatus", cgi?.["chargeStatus"], CHARGE_STATUS_BY_CODE);
205786
- const adapter = mapOne("adapterStatus", cgi?.["adapterStatus"], ADAPTER_STATUS_BY_CODE);
205787
- return {
205788
- fields: {
205789
- ...charge.mapped !== void 0 ? { chargeStatus: charge.mapped } : {},
205790
- ...adapter.mapped !== void 0 ? { adapterStatus: adapter.mapped } : {}
205791
- },
205792
- unmapped: [...charge.unmapped !== void 0 ? [charge.unmapped] : [], ...adapter.unmapped !== void 0 ? [adapter.unmapped] : []]
205793
- };
205794
- }
205795
- /**
205796
- * Remembers which (device, field, code) triples were already reported, so a
205797
- * polled read warns once per finding and not once per tick. Unbounded only in
205798
- * theory: the key space is a few cameras times two fields times the handful of
205799
- * codes a firmware can invent.
205800
- */
205801
- var UnmappedCodeLedger = class {
205802
- seen = /* @__PURE__ */ new Set();
205803
- firstSeen(deviceId, field, value) {
205804
- const key = `${deviceId}:${field}:${String(value)}`;
205805
- if (this.seen.has(key)) return false;
205806
- this.seen.add(key);
205807
- return true;
205808
- }
205809
- };
205810
- //#endregion
205811
- //#region src/hub-channel-reconcile.ts
205812
- /**
205813
- * Match adopted children to the hub's own channel list by IDENTITY, then report the
205814
- * ones whose persisted `channel` is missing or wrong.
205815
- *
205816
- * A child's stableId ENDS with `-${childNativeId}` (the prefix is the integration it was
205817
- * adopted into, or the retired Hub's stableId for a migrated child), and `childNativeId` is built
205818
- * from the camera's UID — so identity survives a channel move, while `channel` does not.
205819
- * The hub is the authority on which slot a UID currently occupies.
205820
- *
205821
- * Why this exists: `loadAdoptedChildrenByChannel` keys the adopted set on the persisted
205822
- * `channel` alone. A child that lost it is absent from that map, so the discovery panel
205823
- * renders it `alreadyAdopted: false` — one click away from being adopted a SECOND time —
205824
- * and `routeSimpleEvent` silently drops every push for its slot. Observed live on
205825
- * 2026-09-04 with an Argus MagiCam that was actually on channel 3.
205826
- *
205827
- * Pure: no I/O. A discovered entry with no channel, or with no adopted child, is skipped —
205828
- * this repairs what exists, it never adopts.
205829
- */
205830
- function planChannelReconciliation(discovered, children) {
205831
- const repairs = [];
205832
- for (const entry of discovered) {
205833
- if (typeof entry.rtspChannel !== "number") continue;
205834
- const child = children.find((c) => c.stableId.endsWith(`-${entry.childNativeId}`));
205835
- if (child === void 0) continue;
205836
- if (child.channel === entry.rtspChannel) continue;
205837
- repairs.push({
205838
- deviceId: child.deviceId,
205839
- stableId: child.stableId,
205840
- from: child.channel ?? null,
205841
- to: entry.rtspChannel
205842
- });
205843
- }
205844
- return repairs;
205845
- }
205846
- function createMotionSensorTracker(now = Date.now) {
205847
- const latest = /* @__PURE__ */ new Map();
205848
- return {
205849
- observe(event) {
205850
- if (event.type !== "motion") return;
205851
- const sensor = event.motion.source;
205852
- if (sensor === void 0) return;
205853
- latest.set(event.channel, {
205854
- sensor,
205855
- at: now()
205856
- });
205857
- },
205858
- take(channel) {
205859
- const entry = latest.get(channel);
205860
- if (!entry) return null;
205861
- latest.delete(channel);
205862
- return now() - entry.at <= 2e3 ? entry.sensor : null;
205863
- }
205864
- };
205865
- }
205866
- var isMotionSensor = (value) => value === "md" || value === "pir" || value === "unknown";
205867
- /**
205868
- * The sensor a simple event states itself, for a library version that forwards
205869
- * `motion.source` on the simple event; `null` for the versions that do not.
205870
- */
205871
- function motionSensorOnSimpleEvent(event) {
205872
- if (!("motion" in event)) return null;
205873
- const motion = event.motion;
205874
- if (typeof motion !== "object" || motion === null || !("source" in motion)) return null;
205875
- return isMotionSensor(motion.source) ? motion.source : null;
205876
- }
205877
- //#endregion
205878
- //#region src/simple-event-dispatch-trace.ts
205879
- /**
205880
- * Gate for hub-side simpleEvent traces.
205881
- *
205882
- * Hub-child events arrive on the parent's Baichuan socket. Logging them on
205883
- * the child logger only is invisible when the operator filters Logs to the
205884
- * Hub; logging every channel unconditionally floods the Hub (battery ticks).
205885
- * Trace when the target camera — or the hub itself — is under debug.
205886
- */
205887
- function shouldTraceSimpleEventDispatch(input) {
205888
- return wantsEventTrace(input.hubDebugGeneral, input.hubSocketFlags) || wantsEventTrace(input.childDebugGeneral, input.childSocketFlags);
205889
- }
205890
- function wantsEventTrace(debugGeneral, socketFlags) {
205891
- return debugGeneral || socketFlags.includes("traceEvents");
205892
- }
205893
- //#endregion
205894
- //#region src/reolink-hub-session.ts
205895
- var HUB_DISCOVERY_REFRESH_TIMEOUT_MS = 8e3;
205896
- /**
205897
- * How often the session re-reads its own channels summary to reconcile the adopted children's
205898
- * sleep slices. 60 s: a naturally-waking camera (PIR, schedule) is caught within a minute, and
205899
- * the play path has its own faster confirm + post-dial watch, so this only ever carries
205900
- * background drift. Never wakes a child — the summary is answered by the hub itself.
205901
- */
205902
- var HUB_CHILD_SLEEP_RECONCILE_MS = 6e4;
205903
- var HUB_RECONNECT_BASE_MS = 2e3;
205904
- var HUB_RECONNECT_MAX_MS = 3e4;
205905
- var UNROUTED_LOG_THROTTLE_MS = 6e4;
205906
- /**
205907
- * ONE Reolink NVR / Home Hub: a single Baichuan control + event socket fronting N channels,
205908
- * owned per integration (D985). It was the `ReolinkHub` device; the device is gone, the
205909
- * mechanism is unchanged:
205910
- *
205911
- * - one persistent socket (no per-channel login), reconnected with a bounded backoff;
205912
- * - every `simpleEvent` from any channel is dispatched into the matching child;
205913
- * - every 60 s the hub's own channels summary reconciles the children's sleep, online and
205914
- * battery slices — read from the hub's mains-powered socket, never from a battery camera.
205915
- */
205916
- var ReolinkHubSession = class {
205917
- integrationId;
205918
- #deps;
205919
- #logger;
205920
- #now;
205921
- #connection;
205922
- #api = null;
205923
- #connectInFlight = null;
205924
- #reconnectAttempts = 0;
205925
- #reconnectTimer = null;
205926
- #reconcileTimer = null;
205927
- #shutdown = false;
205928
- #online = false;
205929
- #snapshot = {
205930
- channels: [],
205931
- lastRefreshAt: null,
205932
- lastError: null
205933
- };
205934
- #refreshInFlight = null;
205935
- /** Wall-clock ms of the last refresh that listed each child (by device id). */
205936
- #childLastSeenAt = /* @__PURE__ */ new Map();
205937
- /** Channels the last good refresh listed but nobody adopted: their pushes are expected drops. */
205938
- #knownUnadoptedChannels = /* @__PURE__ */ new Set();
205939
- #unroutedLoggedAt = /* @__PURE__ */ new Map();
205940
- #batteryShapeLogged = false;
205941
- #unmappedBatteryCodes = new UnmappedCodeLedger();
205942
- #motionSensors = createMotionSensorTracker();
205943
- #simpleEventBound = (event) => {
205944
- this.routeSimpleEvent(event);
205945
- };
205946
- #rawEventBound = (event) => {
205947
- this.#motionSensors.observe(event);
205948
- };
205949
- constructor(deps) {
205950
- this.integrationId = deps.integrationId;
205951
- this.#deps = deps;
205952
- this.#logger = deps.logger;
205953
- this.#connection = deps.connection;
205954
- this.#now = deps.now ?? Date.now;
205955
- }
205956
- get connection() {
205957
- return this.#connection;
205958
- }
205959
- get isOnline() {
205960
- return this.#online;
205961
- }
205962
- /** The integration's display name. */
205963
- get name() {
205964
- return this.#deps.name;
205965
- }
205966
- /** Restart the hub itself (not a camera behind it). */
205967
- async reboot() {
205968
- await (await this.getApi()).reboot();
205969
- }
205970
- /** The last channel list, for the adoption surface. */
205971
- channelSnapshot() {
205972
- return this.#snapshot;
205973
- }
205974
- /**
205975
- * What a child dials for the hub's RTSP relay. The port is the connection's RTSP port
205976
- * (default 554) — never the Baichuan port.
205977
- */
205978
- relayEndpoint() {
205979
- const c = this.#connection;
205980
- return {
205981
- host: c.host,
205982
- username: c.username,
205983
- password: c.password,
205984
- port: c.rtspPort
205985
- };
205986
- }
205987
- /** Arm the periodic reconcile and make the first dial. Idempotent. */
205988
- start() {
205989
- if (this.#reconcileTimer === null) {
205990
- this.#reconcileTimer = setInterval(() => {
205991
- this.refresh().catch(() => {});
205992
- }, HUB_CHILD_SLEEP_RECONCILE_MS);
205993
- if (typeof this.#reconcileTimer.unref === "function") this.#reconcileTimer.unref();
205994
- }
205995
- this.getApi().catch((err) => {
205996
- this.#logger.warn("Reolink hub initial connect failed — will retry", {
205997
- tags: { integrationId: this.integrationId },
205998
- meta: { error: errMsg$3(err) }
205999
- });
206000
- });
206001
- }
206002
- /**
206003
- * Apply a new connection. A session already dialled the same way keeps its socket; any real
206004
- * change tears the socket down and re-dials with the new credentials.
206005
- */
206006
- async reconfigure(connection) {
206007
- if (sameHubConnection(this.#connection, connection)) {
206008
- this.#connection = connection;
206009
- return;
206010
- }
206011
- this.#connection = connection;
206012
- await this.dropApi("settings-changed");
206013
- this.#setOnline(false);
206014
- this.getApi().catch((err) => {
206015
- this.#logger.warn("Reolink hub re-dial after settings change failed", {
206016
- tags: { integrationId: this.integrationId },
206017
- meta: { error: errMsg$3(err) }
206018
- });
206019
- });
206020
- }
206021
- async close() {
206022
- this.#shutdown = true;
206023
- if (this.#reconnectTimer !== null) {
206024
- clearTimeout(this.#reconnectTimer);
206025
- this.#reconnectTimer = null;
206026
- }
206027
- if (this.#reconcileTimer !== null) {
206028
- clearInterval(this.#reconcileTimer);
206029
- this.#reconcileTimer = null;
206030
- }
206031
- await this.dropApi("hub-shutdown");
206032
- }
206033
- /**
206034
- * The logged-in api (single round-trip on first call). Mid-disconnect, callers wait for the
206035
- * reconnect that is already in flight.
206036
- */
206037
- async getApi() {
206038
- const live = this.#api;
206039
- if (live !== null && live.client.isSocketConnected() && live.client.loggedIn) return live;
206040
- if (this.#connectInFlight !== null) return this.#connectInFlight;
206041
- this.#connectInFlight = this.#connect().finally(() => {
206042
- this.#connectInFlight = null;
206043
- });
206044
- return this.#connectInFlight;
206045
- }
206046
- async dropApi(reason) {
206047
- const api = this.#api;
206048
- this.#api = null;
206049
- if (api === null) return;
206050
- try {
206051
- await api.close({ reason });
206052
- } catch {}
206053
- }
206054
- async #connect() {
206055
- await this.dropApi("hub-reconnect");
206056
- const connection = this.#connection;
206057
- if (connection.host.length === 0) throw userError("reolink.hub-host-missing", void 0, "Reolink Hub: host not configured");
206058
- if (connection.password.length === 0) throw userError("reolink.hub-password-missing", void 0, "Reolink Hub: password not configured");
206059
- this.#logger.info("Reolink hub connecting", {
206060
- tags: { integrationId: this.integrationId },
206061
- meta: {
206062
- host: connection.host,
206063
- port: connection.port,
206064
- transport: connection.transport
206065
- }
206066
- });
206067
- const api = await this.#deps.connect(connection);
206068
- try {
206069
- await api.offSimpleEvent(this.#simpleEventBound);
206070
- } catch {}
206071
- api.client.off("event", this.#rawEventBound);
206072
- api.client.prependListener("event", this.#rawEventBound);
206073
- await api.onSimpleEvent(this.#simpleEventBound);
206074
- api.client.on("close", () => this.#scheduleReconnect("socket-close"));
206075
- api.client.on("error", (err) => {
206076
- this.#logger.warn("Reolink hub socket error — scheduling reconnect", {
206077
- tags: { integrationId: this.integrationId },
206078
- meta: { error: errMsg$3(err) }
206079
- });
206080
- this.#scheduleReconnect("socket-error");
206081
- });
206082
- this.#api = api;
206083
- this.#reconnectAttempts = 0;
206084
- this.#setOnline(true);
206085
- this.refresh().catch(() => {});
206086
- return api;
206087
- }
206088
- #setOnline(online) {
206089
- if (this.#online === online) return;
206090
- this.#online = online;
206091
- this.#deps.onConnectionChange?.(online);
206092
- }
206093
- #scheduleReconnect(reason) {
206094
- if (this.#shutdown) return;
206095
- this.#api = null;
206096
- this.#setOnline(false);
206097
- if (this.#reconnectTimer !== null) return;
206098
- const backoff = Math.min(HUB_RECONNECT_BASE_MS * 2 ** this.#reconnectAttempts, HUB_RECONNECT_MAX_MS);
206099
- this.#reconnectAttempts += 1;
206100
- this.#logger.info("Reolink hub reconnect scheduled", {
206101
- tags: { integrationId: this.integrationId },
206102
- meta: {
206103
- reason,
206104
- attempt: this.#reconnectAttempts,
206105
- backoffMs: backoff
206106
- }
206107
- });
206108
- this.#reconnectTimer = setTimeout(() => {
206109
- this.#reconnectTimer = null;
206110
- this.getApi().catch(() => {});
206111
- }, backoff);
206112
- }
206113
- /**
206114
- * Pull the channel list from the hub via `getNvrChannelsSummary({ source: 'cgi' })` — CGI,
206115
- * because `GetChannelstatus` answers synchronously (no race against the cmd_id 145 push
206116
- * cache that is empty right after login). Single-flight.
206117
- */
206118
- refresh() {
206119
- if (this.#refreshInFlight !== null) return this.#refreshInFlight;
206120
- const run = this.#refresh().finally(() => {
206121
- this.#refreshInFlight = null;
206122
- });
206123
- this.#refreshInFlight = run;
206124
- return run;
206125
- }
206126
- async #refresh() {
206127
- const startedAt = this.#now();
206128
- let lastError = null;
206129
- let channels = [];
206130
- try {
206131
- channels = (await (await this.getApi()).getNvrChannelsSummary({
206132
- source: "cgi",
206133
- timeoutMs: HUB_DISCOVERY_REFRESH_TIMEOUT_MS
206134
- })).devices.map((d) => ({
206135
- childNativeId: computeChildNativeId(d.channel, d.uid),
206136
- channel: d.channel,
206137
- name: d.name ?? `Channel ${d.channel}`,
206138
- status: discoveredStatusFor(d),
206139
- ...d.uid ? { uid: d.uid } : {},
206140
- ...d.model ? { model: d.model } : {},
206141
- ...d.serialNumber ? { serialNumber: d.serialNumber } : {},
206142
- isBattery: d.isBattery === true,
206143
- isDoorbell: d.isDoorbell === true,
206144
- isMultifocal: d.isMultifocal === true
206145
- }));
206146
- } catch (err) {
206147
- lastError = errMsg$3(err);
206148
- this.#logger.warn("Reolink hub channel refresh failed", {
206149
- tags: { integrationId: this.integrationId },
206150
- meta: { error: lastError }
206151
- });
206152
- }
206153
- this.#snapshot = lastError === null ? {
206154
- channels,
206155
- lastRefreshAt: this.#now(),
206156
- lastError: null
206157
- } : {
206158
- ...this.#snapshot,
206159
- lastError
206160
- };
206161
- if (lastError !== null) return;
206162
- await this.reconcileChildChannels(channels);
206163
- const adoptedChannels = /* @__PURE__ */ new Set();
206164
- for (const child of this.#deps.children()) if (child.channel !== void 0) adoptedChannels.add(child.channel);
206165
- this.#knownUnadoptedChannels.clear();
206166
- for (const c of channels) if (!adoptedChannels.has(c.channel)) this.#knownUnadoptedChannels.add(c.channel);
206167
- this.#logger.info("Reolink hub: channel refresh complete", {
206168
- tags: { integrationId: this.integrationId },
206169
- meta: {
206170
- durationMs: this.#now() - startedAt,
206171
- total: channels.length,
206172
- adopted: channels.filter((c) => adoptedChannels.has(c.channel)).length,
206173
- online: channels.filter((c) => c.status === "online").length,
206174
- sleeping: channels.filter((c) => c.status === "sleeping").length
206175
- }
206176
- });
206177
- this.reconcileChildOnline(channels);
206178
- this.reconcileChildSleepState(channels);
206179
- await this.reconcileChildBattery();
206180
- }
206181
- /**
206182
- * Re-derive each adopted child's `channel` from the hub's own list, matching by IDENTITY
206183
- * (the stableId carries the camera UID) rather than by the channel itself — a child that lost
206184
- * its channel is invisible to the routing, and a channel move produces the same damage under
206185
- * a wrong number. A child that is not live is skipped: it is repaired on its next restore.
206186
- */
206187
- async reconcileChildChannels(channels) {
206188
- const children = this.#deps.children();
206189
- const repairs = planChannelReconciliation(channels.map((c) => ({
206190
- childNativeId: c.childNativeId,
206191
- rtspChannel: c.channel
206192
- })), children.map((child) => ({
206193
- deviceId: child.id,
206194
- stableId: child.stableId,
206195
- ...child.channel !== void 0 ? { channel: child.channel } : {}
206196
- })));
206197
- for (const repair of repairs) {
206198
- const child = children.find((c) => c.id === repair.deviceId);
206199
- if (child === void 0) continue;
206200
- try {
206201
- await child.repairChannel(repair.to);
206202
- this.#logger.warn("Reolink hub: repaired a child channel from the hub channel list", {
206203
- tags: {
206204
- deviceId: repair.deviceId,
206205
- stableId: repair.stableId
206206
- },
206207
- meta: {
206208
- from: repair.from,
206209
- to: repair.to
206210
- }
206211
- });
206212
- } catch (err) {
206213
- this.#logger.warn("Reolink hub: child channel repair failed", {
206214
- tags: {
206215
- deviceId: repair.deviceId,
206216
- stableId: repair.stableId
206217
- },
206218
- meta: {
206219
- from: repair.from,
206220
- to: repair.to,
206221
- error: errMsg$3(err)
206222
- }
206223
- });
206224
- }
206225
- }
206226
- }
206227
- /**
206228
- * Mark every child still listed `online`, and any child missing for more than
206229
- * `HUB_MISSING_OFFLINE_MS` `offline` — "if the hub firmware doesn't list it, the device isn't
206230
- * reachable through the hub". Battery cams that briefly drop out of the summary during deep
206231
- * sleep are not false-flagged: the threshold gives 3+ cycles of grace.
206232
- */
206233
- reconcileChildOnline(channels) {
206234
- const now = this.#now();
206235
- const listed = new Set(channels.map((c) => c.channel));
206236
- for (const child of this.#deps.children()) {
206237
- const ch = child.channel;
206238
- if (ch === void 0) continue;
206239
- if (child.type !== DeviceType.Camera) continue;
206240
- if (listed.has(ch)) {
206241
- this.#childLastSeenAt.set(child.id, now);
206242
- if (!child.online) {
206243
- child.markOnline(true);
206244
- this.#logger.info("Reolink hub: adopted child back online", {
206245
- tags: {
206246
- deviceId: child.id,
206247
- deviceName: child.name
206248
- },
206249
- meta: { channel: ch }
206250
- });
206251
- }
206252
- continue;
206253
- }
206254
- const lastSeen = this.#childLastSeenAt.get(child.id) ?? 0;
206255
- const missingMs = lastSeen > 0 ? now - lastSeen : Number.POSITIVE_INFINITY;
206256
- if (missingMs >= 6e5 && child.online) {
206257
- child.markOnline(false);
206258
- this.#logger.warn("Reolink hub: adopted child marked offline (missing from the hub list)", {
206259
- tags: {
206260
- deviceId: child.id,
206261
- deviceName: child.name
206262
- },
206263
- meta: {
206264
- channel: ch,
206265
- missingMs
206266
- }
206267
- });
206268
- }
206269
- }
206270
- }
206271
- /**
206272
- * Push the hub's per-channel sleep state into each adopted battery child's `battery.sleeping`
206273
- * slice. The summary carries a `sleeping` flag straight from the firmware over the hub's own
206274
- * mains-powered socket: it costs the battery channel nothing, and it is real firmware state
206275
- * rather than socket-I/O inference. Only `online` / `sleeping` are acted on: `offline` and
206276
- * `unknown` carry no sleep information, so the last known value stands.
206277
- */
206278
- reconcileChildSleepState(channels) {
206279
- const byChannel = new Map(channels.map((c) => [c.channel, c]));
206280
- for (const child of this.#deps.children()) {
206281
- if (child.channel === void 0) continue;
206282
- const entry = byChannel.get(child.channel);
206283
- if (entry === void 0) continue;
206284
- if (entry.status !== "sleeping" && entry.status !== "online") continue;
206285
- child.applyHubSleepState(entry.status === "sleeping");
206286
- }
206287
- }
206288
- /**
206289
- * Push the hub's per-channel battery read into each adopted battery child.
206290
- * `getAllChannelsBatteryInfo` is one CGI on the hub's own socket — it never touches a battery
206291
- * camera — and it is the ONLY way a sleeping child's percentage/charging ever moves.
206292
- * Independent of the refresh's success path: a battery-read failure must not fail the refresh.
206293
- */
206294
- async reconcileChildBattery() {
206295
- const children = this.#deps.children();
206296
- if (children.length === 0) return;
206297
- try {
206298
- const data = await (await this.getApi()).getAllChannelsBatteryInfo();
206299
- const entries = Object.entries(data.batteryInfoData);
206300
- if (entries.length === 0) return;
206301
- if (!this.#batteryShapeLogged) {
206302
- this.#batteryShapeLogged = true;
206303
- this.#logger.info("Reolink hub: per-channel battery payload (first read)", {
206304
- tags: { integrationId: this.integrationId },
206305
- meta: { raw: entries.map(([ch, v]) => ({
206306
- channel: Number(ch),
206307
- ...v
206308
- })) }
206309
- });
206310
- }
206311
- for (const [chKey, info] of entries) {
206312
- const child = children.find((c) => c.channel === Number(chKey));
206313
- if (child === void 0) continue;
206314
- const power = mapHubBatteryPower(info.entries[0]);
206315
- for (const { field, value } of power.unmapped) {
206316
- if (!this.#unmappedBatteryCodes.firstSeen(child.id, field, value)) continue;
206317
- this.#logger.warn("Reolink hub: unmapped battery power code — field dropped", {
206318
- tags: { deviceId: child.id },
206319
- meta: {
206320
- channel: Number(chKey),
206321
- field,
206322
- value
206323
- }
206324
- });
206325
- }
206326
- child.applyHubBatteryInfo({
206327
- batteryPercent: info.batteryLevel,
206328
- ...power.fields
206329
- });
206330
- }
206331
- } catch (err) {
206332
- this.#logger.debug("Reolink hub: per-channel battery refresh failed", {
206333
- tags: { integrationId: this.integrationId },
206334
- meta: { error: errMsg$3(err) }
206335
- });
206336
- }
206337
- }
206338
- /** Dispatch a hub-received simpleEvent to the matching child's `handleSimpleEvent`. */
206339
- routeSimpleEvent(event) {
206340
- const channel = event?.channel;
206341
- if (typeof channel !== "number") return;
206342
- const motionSensor = event.type === "motion" ? this.#motionSensors.take(channel) : null;
206343
- const child = this.#deps.children().find((c) => c.channel === channel);
206344
- if (child === void 0) {
206345
- const last = this.#unroutedLoggedAt.get(channel) ?? 0;
206346
- const now = this.#now();
206347
- if (now - last > UNROUTED_LOG_THROTTLE_MS) {
206348
- this.#unroutedLoggedAt.set(channel, now);
206349
- const knownUnadopted = this.#knownUnadoptedChannels.has(channel);
206350
- this.#logger[knownUnadopted ? "debug" : "warn"]("Reolink hub: simpleEvent for an unmapped channel dropped", {
206351
- tags: { integrationId: this.integrationId },
206352
- meta: {
206353
- channel,
206354
- type: event?.type ?? "unknown",
206355
- knownUnadopted
206356
- }
206357
- });
206358
- }
206359
- return;
206360
- }
206361
- try {
206362
- if (shouldTraceSimpleEventDispatch({
206363
- hubDebugGeneral: this.#connection.debugGeneral,
206364
- hubSocketFlags: this.#connection.debugSocketLogs,
206365
- childDebugGeneral: child.debugGeneral,
206366
- childSocketFlags: child.debugSocketFlags
206367
- })) this.#logger.info("Reolink hub: simpleEvent dispatch", {
206368
- tags: { deviceId: child.id },
206369
- meta: {
206370
- type: event?.type ?? "unknown",
206371
- channel,
206372
- timestamp: event?.timestamp,
206373
- name: child.name
206374
- }
206375
- });
206376
- child.handleSimpleEvent(event, motionSensor);
206377
- } catch (err) {
206378
- this.#logger.debug("Reolink hub simpleEvent dispatch failed", {
206379
- tags: { deviceId: child.id },
206380
- meta: {
206381
- channel,
206382
- error: errMsg$3(err)
206383
- }
206384
- });
206385
- }
206386
- }
206387
- };
206388
- /**
206389
- * A stable per-child identifier. UID is preferred when available (it identifies a paired camera
206390
- * across re-pairings); otherwise `channel-N`, which is stable for a fixed channel assignment.
206391
- */
206392
- function computeChildNativeId(channel, uid) {
206393
- if (uid && uid.length > 0) return `cam-${uid}`;
206394
- return `channel-${channel}`;
206395
- }
206396
- /**
206397
- * Map the lib's per-channel summary booleans to our `DiscoveredChildStatus`. Precedence:
206398
- * `online === false` → offline; `sleeping === true` → sleeping; `online === true` → online;
206399
- * any non-empty state → online; unknown otherwise (the cmd_id 145 push has not landed yet).
206400
- */
206401
- function discoveredStatusFor(d) {
206402
- if (d.online === false) return "offline";
206403
- if (d.sleeping === true) return "sleeping";
206404
- if (d.online === true) return "online";
206405
- if (typeof d.state === "string" && d.state.length > 0) return "online";
206406
- return "unknown";
206407
- }
206408
- /** The wire-shaped channel the adoption surface lists. */
206409
- function toDiscoveredChild(channel, adoptedDeviceId) {
206410
- return {
206411
- childNativeId: channel.childNativeId,
206412
- name: channel.name,
206413
- type: DeviceType.Camera,
206414
- status: channel.status,
206415
- metadata: {
206416
- ...channel.model ? { model: channel.model } : {},
206417
- ...channel.serialNumber ? { serialNumber: channel.serialNumber } : {},
206418
- ...channel.uid ? { uid: channel.uid } : {},
206419
- rtspChannel: channel.channel,
206420
- isBattery: channel.isBattery,
206421
- isDoorbell: channel.isDoorbell,
206422
- isMultifocal: channel.isMultifocal
206423
- },
206424
- alreadyAdopted: adoptedDeviceId !== null,
206425
- adoptedDeviceId
206426
- };
206427
- }
206428
- var GATEWAY_SUFFIX = ":gateway";
206429
- /** The `childNativeId` of the hub box itself, one per integration. */
206430
- function hubGatewayNativeId(integrationId) {
206431
- return `${integrationId}${GATEWAY_SUFFIX}`;
206432
- }
206433
- /**
206434
- * The hub (NVR / Home Hub) as an adoption candidate: a {@link DeviceType.Gateway} that is a
206435
- * sibling of its channel cameras, never their parent. Only what the library really exposes:
206436
- * `reboot` (Baichuan `Reboot`) and the unit's web page.
206437
- */
206438
- function toDiscoveredGateway(input) {
206439
- return {
206440
- childNativeId: hubGatewayNativeId(input.integrationId),
206441
- name: input.name,
206442
- type: DeviceType.Gateway,
206443
- status: input.online ? "online" : "offline",
206444
- metadata: { manufacturer: "Reolink" },
206445
- alreadyAdopted: input.adoptedDeviceId !== null,
206446
- adoptedDeviceId: input.adoptedDeviceId,
206447
- capabilities: [rebootCapability.name, deviceAdminLinkCapability.name]
206448
- };
206449
- }
206450
- /**
206451
- * The per-integration registry the cameras reach their session through (the shape of
206452
- * `veluxGateways` / `enpalConnections`). It also holds the children, so a child that activates
206453
- * before its session exists (boot order) is already routable the moment the session starts.
206454
- */
206455
- var ReolinkHubSessions = class {
206456
- #sessions = /* @__PURE__ */ new Map();
206457
- #children = /* @__PURE__ */ new Map();
206458
- set(integrationId, session) {
206459
- if (session === null) {
206460
- this.#sessions.delete(integrationId);
206461
- return;
206462
- }
206463
- this.#sessions.set(integrationId, session);
206464
- }
206465
- get(integrationId) {
206466
- return this.#sessions.get(integrationId) ?? null;
206467
- }
206468
- attach(integrationId, child) {
206469
- let set = this.#children.get(integrationId);
206470
- if (set === void 0) {
206471
- set = /* @__PURE__ */ new Map();
206472
- this.#children.set(integrationId, set);
206473
- }
206474
- set.set(child.id, child);
206475
- }
206476
- /** Detach only if `child` is still the attached handle — a rebuilt device keeps its slot. */
206477
- detach(integrationId, child) {
206478
- const set = this.#children.get(integrationId);
206479
- if (set?.get(child.id) === child) set.delete(child.id);
206480
- }
206481
- childrenOf(integrationId) {
206482
- return [...this.#children.get(integrationId)?.values() ?? []];
206483
- }
206484
- clear() {
206485
- this.#sessions.clear();
206486
- this.#children.clear();
206487
- }
206488
- };
206489
- /** The module-level registry (one per addon process). */
206490
- var reolinkHubSessions = new ReolinkHubSessions();
206491
- /** Dial a real hub. The default `connect` dependency of a session. */
206492
- async function connectReolinkHub(connection) {
206493
- const api = new ReolinkBaichuanApi({
206494
- host: connection.host,
206495
- port: connection.port,
206496
- username: connection.username,
206497
- password: connection.password,
206498
- transport: connection.transport,
206499
- ...connection.uid ? { uid: connection.uid } : {}
206500
- });
206501
- await api.login();
206502
- return api;
206503
- }
206504
- //#endregion
206505
205615
  //#region src/battery-refresh-settings.ts
206506
205616
  var BATTERY_REFRESH_SECTION_ID = "battery-refresh";
206507
205617
  var BATTERY_REFRESH_MINUTES_DESCRIPTION = "A camera that slept the whole window is woken once to refresh. 0 turns the scheduled refresh off.";
@@ -207428,6 +206538,912 @@ function buildOnboardWrites(patch, schedule) {
207428
206538
  };
207429
206539
  }
207430
206540
  //#endregion
206541
+ //#region src/hub-connection.ts
206542
+ /** Baichuan control port a Reolink NVR / Home Hub listens on. */
206543
+ var REOLINK_HUB_DEFAULT_PORT = 9e3;
206544
+ /** Settings key that names the section holding the hub connection form. */
206545
+ var HUB_CONNECTION_SECTION_ID = "connection";
206546
+ var HUB_DEBUG_SECTION_ID = "hub-debug";
206547
+ /** A blank form field is "not provided", never an invalid port. */
206548
+ function blankToUndefined(value) {
206549
+ return value === "" || value === null ? void 0 : value;
206550
+ }
206551
+ /**
206552
+ * The connection of ONE Reolink NVR / Home Hub: the settings of an INTEGRATION (D985). The
206553
+ * password is a schema `password` field kept in the keychain — never in env, argv or logs.
206554
+ */
206555
+ var reolinkHubConnectionSchema = object({
206556
+ host: string().trim().min(1).describe("NVR / Home Hub IP or hostname"),
206557
+ port: preprocess(blankToUndefined, number().int().min(1).max(65535).default(REOLINK_HUB_DEFAULT_PORT)),
206558
+ /** The RTSP relay port the cameras dial for their streams (not the Baichuan `port`). */
206559
+ rtspPort: preprocess(blankToUndefined, number().int().min(1).max(65535).default(554)),
206560
+ username: preprocess(blankToUndefined, string().trim().min(1).default("admin")),
206561
+ password: string().min(1).describe("NVR / Home Hub password").meta({ secret: true }),
206562
+ /** Hubs are TCP-only on supported firmwares — kept for symmetry with a camera. */
206563
+ transport: _enum(["tcp", "udp"]).default("tcp"),
206564
+ /** UID of a hub that was paired by it; unused for a plain TCP login. */
206565
+ uid: string().optional(),
206566
+ debugGeneral: boolean().default(false),
206567
+ debugSocketLogs: array(ReolinkSocketDebugFlagSchema).default([])
206568
+ });
206569
+ function stringValue(value) {
206570
+ return typeof value === "string" ? value.trim() : "";
206571
+ }
206572
+ function readHubConnection(settings) {
206573
+ if (stringValue(settings["host"]).length === 0) return { kind: "no-host" };
206574
+ const password = settings["password"];
206575
+ if (isSecretMarkerLike(password)) return { kind: "password-locked" };
206576
+ if (typeof password !== "string" || password.length === 0) return { kind: "no-password" };
206577
+ const parsed = reolinkHubConnectionSchema.safeParse(settings);
206578
+ if (!parsed.success) return { kind: "no-host" };
206579
+ return {
206580
+ kind: "ok",
206581
+ connection: parsed.data
206582
+ };
206583
+ }
206584
+ /** Two reads that would dial the same way. */
206585
+ function sameHubConnection(a, b) {
206586
+ return a.host === b.host && a.port === b.port && a.rtspPort === b.rtspPort && a.username === b.username && a.password === b.password && a.transport === b.transport && a.uid === b.uid;
206587
+ }
206588
+ /**
206589
+ * The connection + debug sections of the integration form. Fields are NOT `required` here:
206590
+ * the same schema is the addon's own settings page, where a required host would block saving
206591
+ * an unrelated email-push change on the default integration. A hub integration missing its
206592
+ * host or password is named by the session at reconcile time instead.
206593
+ */
206594
+ function buildHubConnectionSections() {
206595
+ return { sections: [{
206596
+ id: HUB_CONNECTION_SECTION_ID,
206597
+ title: "NVR / Home Hub connection",
206598
+ titleKey: "provider-reolink:connection.s_connection.title",
206599
+ description: "Only for an integration that is one NVR or Home Hub. Its channels are adopted as cameras that share this connection.",
206600
+ descriptionKey: "provider-reolink:connection.s_connection.description",
206601
+ columns: 2,
206602
+ fields: [
206603
+ {
206604
+ type: "text",
206605
+ key: "host",
206606
+ label: "Hub IP",
206607
+ labelKey: "provider-reolink:connection.s_connection.host.label",
206608
+ placeholder: "192.168.10.100"
206609
+ },
206610
+ {
206611
+ type: "number",
206612
+ key: "port",
206613
+ label: "Port",
206614
+ labelKey: "provider-reolink:connection.s_connection.port.label",
206615
+ default: REOLINK_HUB_DEFAULT_PORT,
206616
+ min: 1,
206617
+ max: 65535,
206618
+ step: 1
206619
+ },
206620
+ {
206621
+ type: "number",
206622
+ key: "rtspPort",
206623
+ label: "RTSP port",
206624
+ labelKey: "provider-reolink:connection.s_connection.rtspPort.label",
206625
+ default: 554,
206626
+ min: 1,
206627
+ max: 65535,
206628
+ step: 1
206629
+ },
206630
+ {
206631
+ type: "text",
206632
+ key: "username",
206633
+ label: "Username",
206634
+ labelKey: "provider-reolink:connection.s_connection.username.label",
206635
+ default: "admin"
206636
+ },
206637
+ {
206638
+ type: "password",
206639
+ key: "password",
206640
+ label: "Password",
206641
+ labelKey: "provider-reolink:connection.s_connection.password.label",
206642
+ showToggle: true
206643
+ }
206644
+ ]
206645
+ }, {
206646
+ id: HUB_DEBUG_SECTION_ID,
206647
+ title: "NVR / Home Hub debug",
206648
+ titleKey: "provider-reolink:connection.s_hub-debug.title",
206649
+ description: "Debug logging adds log volume; use it temporarily.",
206650
+ descriptionKey: "provider-reolink:connection.s_hub-debug.description",
206651
+ columns: 1,
206652
+ fields: [{
206653
+ type: "boolean",
206654
+ key: "debugGeneral",
206655
+ label: "General debug logs",
206656
+ labelKey: "provider-reolink:connection.s_hub-debug.debugGeneral.label",
206657
+ default: false,
206658
+ style: "switch"
206659
+ }, {
206660
+ type: "multiselect",
206661
+ key: "debugSocketLogs",
206662
+ label: "Socket trace categories",
206663
+ labelKey: "provider-reolink:connection.s_hub-debug.debugSocketLogs.label",
206664
+ description: "Each area adds log volume.",
206665
+ descriptionKey: "provider-reolink:connection.s_hub-debug.debugSocketLogs.description",
206666
+ default: [],
206667
+ options: [{
206668
+ value: "traceEvents",
206669
+ label: "Events (cmd 33 — AlarmEventList push)",
206670
+ labelKey: "provider-reolink:connection.s_hub-debug.debugSocketLogs.o_traceEvents.label"
206671
+ }]
206672
+ }]
206673
+ }] };
206674
+ }
206675
+ /**
206676
+ * `true` for an integration that IS one NVR / Home Hub: it was set up from a hub candidate, was
206677
+ * migrated from a Hub device, or carries a host. The default integration (standalone cameras,
206678
+ * email push, webhook, clips) has none of the three.
206679
+ */
206680
+ function isHubIntegration(evidence) {
206681
+ if (evidence.originStableId?.startsWith("reolink-hub-") === true) return true;
206682
+ if (evidence.info["migratedFromHubDeviceId"] !== void 0) return true;
206683
+ return stringValue(evidence.settings["host"]).length > 0;
206684
+ }
206685
+ /** The form of a hub integration: its connection and debug sections, nothing of the addon's own. */
206686
+ function buildHubInstanceSchema() {
206687
+ return buildHubConnectionSections();
206688
+ }
206689
+ //#endregion
206690
+ //#region src/hub-battery-fields.ts
206691
+ /**
206692
+ * The hub boundary for the per-channel battery power fields.
206693
+ *
206694
+ * The Reolink hub/NVR reports `chargeStatus` and `adapterStatus` as NUMBERS
206695
+ * (0/1/2); the standalone camera path reports the same facts as strings, and
206696
+ * `deriveChargingSource` (`battery-charging.ts`) speaks only the string
206697
+ * vocabulary. This module is the translation, and — the part that was
206698
+ * missing — it NAMES a code it cannot translate instead of dropping the field
206699
+ * silently. A dropped field reads as "the push said nothing about power", so
206700
+ * a new firmware value would have kept the previous belief forever, with not
206701
+ * one log line. The ledger lets the hub warn once per (device, field, code).
206702
+ */
206703
+ var CHARGE_STATUS_BY_CODE = {
206704
+ 0: "none",
206705
+ 1: "charging",
206706
+ 2: "chargeComplete"
206707
+ };
206708
+ var ADAPTER_STATUS_BY_CODE = {
206709
+ 0: "none",
206710
+ 1: "dc",
206711
+ 2: "solarPanel"
206712
+ };
206713
+ function mapOne(field, value, byCode) {
206714
+ if (value === void 0 || value === null) return {};
206715
+ if (typeof value === "string") return { mapped: value };
206716
+ if (typeof value === "number") {
206717
+ const mapped = byCode[value];
206718
+ if (mapped !== void 0) return { mapped };
206719
+ }
206720
+ return { unmapped: {
206721
+ field,
206722
+ value
206723
+ } };
206724
+ }
206725
+ /** Translate one channel's raw CGI power fields; never throws. */
206726
+ function mapHubBatteryPower(cgi) {
206727
+ const charge = mapOne("chargeStatus", cgi?.["chargeStatus"], CHARGE_STATUS_BY_CODE);
206728
+ const adapter = mapOne("adapterStatus", cgi?.["adapterStatus"], ADAPTER_STATUS_BY_CODE);
206729
+ return {
206730
+ fields: {
206731
+ ...charge.mapped !== void 0 ? { chargeStatus: charge.mapped } : {},
206732
+ ...adapter.mapped !== void 0 ? { adapterStatus: adapter.mapped } : {}
206733
+ },
206734
+ unmapped: [...charge.unmapped !== void 0 ? [charge.unmapped] : [], ...adapter.unmapped !== void 0 ? [adapter.unmapped] : []]
206735
+ };
206736
+ }
206737
+ /**
206738
+ * Remembers which (device, field, code) triples were already reported, so a
206739
+ * polled read warns once per finding and not once per tick. Unbounded only in
206740
+ * theory: the key space is a few cameras times two fields times the handful of
206741
+ * codes a firmware can invent.
206742
+ */
206743
+ var UnmappedCodeLedger = class {
206744
+ seen = /* @__PURE__ */ new Set();
206745
+ firstSeen(deviceId, field, value) {
206746
+ const key = `${deviceId}:${field}:${String(value)}`;
206747
+ if (this.seen.has(key)) return false;
206748
+ this.seen.add(key);
206749
+ return true;
206750
+ }
206751
+ };
206752
+ //#endregion
206753
+ //#region src/hub-channel-reconcile.ts
206754
+ /**
206755
+ * Match adopted children to the hub's own channel list by IDENTITY, then report the
206756
+ * ones whose persisted `channel` is missing or wrong.
206757
+ *
206758
+ * A child's stableId ENDS with `-${childNativeId}` (the prefix is the integration it was
206759
+ * adopted into, or the retired Hub's stableId for a migrated child), and `childNativeId` is built
206760
+ * from the camera's UID — so identity survives a channel move, while `channel` does not.
206761
+ * The hub is the authority on which slot a UID currently occupies.
206762
+ *
206763
+ * Why this exists: `loadAdoptedChildrenByChannel` keys the adopted set on the persisted
206764
+ * `channel` alone. A child that lost it is absent from that map, so the discovery panel
206765
+ * renders it `alreadyAdopted: false` — one click away from being adopted a SECOND time —
206766
+ * and `routeSimpleEvent` silently drops every push for its slot. Observed live on
206767
+ * 2026-09-04 with an Argus MagiCam that was actually on channel 3.
206768
+ *
206769
+ * Pure: no I/O. A discovered entry with no channel, or with no adopted child, is skipped —
206770
+ * this repairs what exists, it never adopts.
206771
+ */
206772
+ function planChannelReconciliation(discovered, children) {
206773
+ const repairs = [];
206774
+ for (const entry of discovered) {
206775
+ if (typeof entry.rtspChannel !== "number") continue;
206776
+ const child = children.find((c) => c.stableId.endsWith(`-${entry.childNativeId}`));
206777
+ if (child === void 0) continue;
206778
+ if (child.channel === entry.rtspChannel) continue;
206779
+ repairs.push({
206780
+ deviceId: child.deviceId,
206781
+ stableId: child.stableId,
206782
+ from: child.channel ?? null,
206783
+ to: entry.rtspChannel
206784
+ });
206785
+ }
206786
+ return repairs;
206787
+ }
206788
+ function createMotionSensorTracker(now = Date.now) {
206789
+ const latest = /* @__PURE__ */ new Map();
206790
+ return {
206791
+ observe(event) {
206792
+ if (event.type !== "motion") return;
206793
+ const sensor = event.motion.source;
206794
+ if (sensor === void 0) return;
206795
+ latest.set(event.channel, {
206796
+ sensor,
206797
+ at: now()
206798
+ });
206799
+ },
206800
+ take(channel) {
206801
+ const entry = latest.get(channel);
206802
+ if (!entry) return null;
206803
+ latest.delete(channel);
206804
+ return now() - entry.at <= 2e3 ? entry.sensor : null;
206805
+ }
206806
+ };
206807
+ }
206808
+ var isMotionSensor = (value) => value === "md" || value === "pir" || value === "unknown";
206809
+ /**
206810
+ * The sensor a simple event states itself, for a library version that forwards
206811
+ * `motion.source` on the simple event; `null` for the versions that do not.
206812
+ */
206813
+ function motionSensorOnSimpleEvent(event) {
206814
+ if (!("motion" in event)) return null;
206815
+ const motion = event.motion;
206816
+ if (typeof motion !== "object" || motion === null || !("source" in motion)) return null;
206817
+ return isMotionSensor(motion.source) ? motion.source : null;
206818
+ }
206819
+ //#endregion
206820
+ //#region src/simple-event-dispatch-trace.ts
206821
+ /**
206822
+ * Gate for hub-side simpleEvent traces.
206823
+ *
206824
+ * Hub-child events arrive on the parent's Baichuan socket. Logging them on
206825
+ * the child logger only is invisible when the operator filters Logs to the
206826
+ * Hub; logging every channel unconditionally floods the Hub (battery ticks).
206827
+ * Trace when the target camera — or the hub itself — is under debug.
206828
+ */
206829
+ function shouldTraceSimpleEventDispatch(input) {
206830
+ return wantsEventTrace(input.hubDebugGeneral, input.hubSocketFlags) || wantsEventTrace(input.childDebugGeneral, input.childSocketFlags);
206831
+ }
206832
+ function wantsEventTrace(debugGeneral, socketFlags) {
206833
+ return debugGeneral || socketFlags.includes("traceEvents");
206834
+ }
206835
+ //#endregion
206836
+ //#region src/reolink-hub-session.ts
206837
+ var HUB_DISCOVERY_REFRESH_TIMEOUT_MS = 8e3;
206838
+ /**
206839
+ * How often the session re-reads its own channels summary to reconcile the adopted children's
206840
+ * sleep slices. 60 s: a naturally-waking camera (PIR, schedule) is caught within a minute, and
206841
+ * the play path has its own faster confirm + post-dial watch, so this only ever carries
206842
+ * background drift. Never wakes a child — the summary is answered by the hub itself.
206843
+ */
206844
+ var HUB_CHILD_SLEEP_RECONCILE_MS = 6e4;
206845
+ var HUB_RECONNECT_BASE_MS = 2e3;
206846
+ var HUB_RECONNECT_MAX_MS = 3e4;
206847
+ var UNROUTED_LOG_THROTTLE_MS = 6e4;
206848
+ /**
206849
+ * ONE Reolink NVR / Home Hub: a single Baichuan control + event socket fronting N channels,
206850
+ * owned per integration (D985). It was the `ReolinkHub` device; the device is gone, the
206851
+ * mechanism is unchanged:
206852
+ *
206853
+ * - one persistent socket (no per-channel login), reconnected with a bounded backoff;
206854
+ * - every `simpleEvent` from any channel is dispatched into the matching child;
206855
+ * - every 60 s the hub's own channels summary reconciles the children's sleep, online and
206856
+ * battery slices — read from the hub's mains-powered socket, never from a battery camera.
206857
+ */
206858
+ var ReolinkHubSession = class {
206859
+ integrationId;
206860
+ #deps;
206861
+ #logger;
206862
+ #now;
206863
+ #connection;
206864
+ #api = null;
206865
+ #connectInFlight = null;
206866
+ #reconnectAttempts = 0;
206867
+ #reconnectTimer = null;
206868
+ #reconcileTimer = null;
206869
+ #shutdown = false;
206870
+ #online = false;
206871
+ #snapshot = {
206872
+ channels: [],
206873
+ lastRefreshAt: null,
206874
+ lastError: null
206875
+ };
206876
+ #refreshInFlight = null;
206877
+ /** Wall-clock ms of the last refresh that listed each child (by device id). */
206878
+ #childLastSeenAt = /* @__PURE__ */ new Map();
206879
+ /** Channels the last good refresh listed but nobody adopted: their pushes are expected drops. */
206880
+ #knownUnadoptedChannels = /* @__PURE__ */ new Set();
206881
+ #unroutedLoggedAt = /* @__PURE__ */ new Map();
206882
+ #batteryShapeLogged = false;
206883
+ #unmappedBatteryCodes = new UnmappedCodeLedger();
206884
+ #motionSensors = createMotionSensorTracker();
206885
+ #simpleEventBound = (event) => {
206886
+ this.routeSimpleEvent(event);
206887
+ };
206888
+ #rawEventBound = (event) => {
206889
+ this.#motionSensors.observe(event);
206890
+ };
206891
+ constructor(deps) {
206892
+ this.integrationId = deps.integrationId;
206893
+ this.#deps = deps;
206894
+ this.#logger = deps.logger;
206895
+ this.#connection = deps.connection;
206896
+ this.#now = deps.now ?? Date.now;
206897
+ }
206898
+ get connection() {
206899
+ return this.#connection;
206900
+ }
206901
+ get isOnline() {
206902
+ return this.#online;
206903
+ }
206904
+ /** The integration's display name. */
206905
+ get name() {
206906
+ return this.#deps.name;
206907
+ }
206908
+ /** Restart the hub itself (not a camera behind it). */
206909
+ async reboot() {
206910
+ await (await this.getApi()).reboot();
206911
+ }
206912
+ /** The last channel list, for the adoption surface. */
206913
+ channelSnapshot() {
206914
+ return this.#snapshot;
206915
+ }
206916
+ /**
206917
+ * What a child dials for the hub's RTSP relay. The port is the connection's RTSP port
206918
+ * (default 554) — never the Baichuan port.
206919
+ */
206920
+ relayEndpoint() {
206921
+ const c = this.#connection;
206922
+ return {
206923
+ host: c.host,
206924
+ username: c.username,
206925
+ password: c.password,
206926
+ port: c.rtspPort
206927
+ };
206928
+ }
206929
+ /** Arm the periodic reconcile and make the first dial. Idempotent. */
206930
+ start() {
206931
+ if (this.#reconcileTimer === null) {
206932
+ this.#reconcileTimer = setInterval(() => {
206933
+ this.refresh().catch(() => {});
206934
+ }, HUB_CHILD_SLEEP_RECONCILE_MS);
206935
+ if (typeof this.#reconcileTimer.unref === "function") this.#reconcileTimer.unref();
206936
+ }
206937
+ this.getApi().catch((err) => {
206938
+ this.#logger.warn("Reolink hub initial connect failed — will retry", {
206939
+ tags: { integrationId: this.integrationId },
206940
+ meta: { error: errMsg$3(err) }
206941
+ });
206942
+ });
206943
+ }
206944
+ /**
206945
+ * Apply a new connection. A session already dialled the same way keeps its socket; any real
206946
+ * change tears the socket down and re-dials with the new credentials.
206947
+ */
206948
+ async reconfigure(connection) {
206949
+ if (sameHubConnection(this.#connection, connection)) {
206950
+ this.#connection = connection;
206951
+ return;
206952
+ }
206953
+ this.#connection = connection;
206954
+ await this.dropApi("settings-changed");
206955
+ this.#setOnline(false);
206956
+ this.getApi().catch((err) => {
206957
+ this.#logger.warn("Reolink hub re-dial after settings change failed", {
206958
+ tags: { integrationId: this.integrationId },
206959
+ meta: { error: errMsg$3(err) }
206960
+ });
206961
+ });
206962
+ }
206963
+ async close() {
206964
+ this.#shutdown = true;
206965
+ if (this.#reconnectTimer !== null) {
206966
+ clearTimeout(this.#reconnectTimer);
206967
+ this.#reconnectTimer = null;
206968
+ }
206969
+ if (this.#reconcileTimer !== null) {
206970
+ clearInterval(this.#reconcileTimer);
206971
+ this.#reconcileTimer = null;
206972
+ }
206973
+ await this.dropApi("hub-shutdown");
206974
+ }
206975
+ /**
206976
+ * The logged-in api (single round-trip on first call). Mid-disconnect, callers wait for the
206977
+ * reconnect that is already in flight.
206978
+ */
206979
+ async getApi() {
206980
+ const live = this.#api;
206981
+ if (live !== null && live.client.isSocketConnected() && live.client.loggedIn) return live;
206982
+ if (this.#connectInFlight !== null) return this.#connectInFlight;
206983
+ this.#connectInFlight = this.#connect().finally(() => {
206984
+ this.#connectInFlight = null;
206985
+ });
206986
+ return this.#connectInFlight;
206987
+ }
206988
+ async dropApi(reason) {
206989
+ const api = this.#api;
206990
+ this.#api = null;
206991
+ if (api === null) return;
206992
+ try {
206993
+ await api.close({ reason });
206994
+ } catch {}
206995
+ }
206996
+ async #connect() {
206997
+ await this.dropApi("hub-reconnect");
206998
+ const connection = this.#connection;
206999
+ if (connection.host.length === 0) throw userError("reolink.hub-host-missing", void 0, "Reolink Hub: host not configured");
207000
+ if (connection.password.length === 0) throw userError("reolink.hub-password-missing", void 0, "Reolink Hub: password not configured");
207001
+ this.#logger.info("Reolink hub connecting", {
207002
+ tags: { integrationId: this.integrationId },
207003
+ meta: {
207004
+ host: connection.host,
207005
+ port: connection.port,
207006
+ transport: connection.transport
207007
+ }
207008
+ });
207009
+ const api = await this.#deps.connect(connection);
207010
+ try {
207011
+ await api.offSimpleEvent(this.#simpleEventBound);
207012
+ } catch {}
207013
+ api.client.off("event", this.#rawEventBound);
207014
+ api.client.prependListener("event", this.#rawEventBound);
207015
+ await api.onSimpleEvent(this.#simpleEventBound);
207016
+ api.client.on("close", () => this.#scheduleReconnect("socket-close"));
207017
+ api.client.on("error", (err) => {
207018
+ this.#logger.warn("Reolink hub socket error — scheduling reconnect", {
207019
+ tags: { integrationId: this.integrationId },
207020
+ meta: { error: errMsg$3(err) }
207021
+ });
207022
+ this.#scheduleReconnect("socket-error");
207023
+ });
207024
+ this.#api = api;
207025
+ this.#reconnectAttempts = 0;
207026
+ this.#setOnline(true);
207027
+ this.refresh().catch(() => {});
207028
+ return api;
207029
+ }
207030
+ #setOnline(online) {
207031
+ if (this.#online === online) return;
207032
+ this.#online = online;
207033
+ this.#deps.onConnectionChange?.(online);
207034
+ }
207035
+ #scheduleReconnect(reason) {
207036
+ if (this.#shutdown) return;
207037
+ this.#api = null;
207038
+ this.#setOnline(false);
207039
+ if (this.#reconnectTimer !== null) return;
207040
+ const backoff = Math.min(HUB_RECONNECT_BASE_MS * 2 ** this.#reconnectAttempts, HUB_RECONNECT_MAX_MS);
207041
+ this.#reconnectAttempts += 1;
207042
+ this.#logger.info("Reolink hub reconnect scheduled", {
207043
+ tags: { integrationId: this.integrationId },
207044
+ meta: {
207045
+ reason,
207046
+ attempt: this.#reconnectAttempts,
207047
+ backoffMs: backoff
207048
+ }
207049
+ });
207050
+ this.#reconnectTimer = setTimeout(() => {
207051
+ this.#reconnectTimer = null;
207052
+ this.getApi().catch(() => {});
207053
+ }, backoff);
207054
+ }
207055
+ /**
207056
+ * Pull the channel list from the hub via `getNvrChannelsSummary({ source: 'cgi' })` — CGI,
207057
+ * because `GetChannelstatus` answers synchronously (no race against the cmd_id 145 push
207058
+ * cache that is empty right after login). Single-flight.
207059
+ */
207060
+ refresh() {
207061
+ if (this.#refreshInFlight !== null) return this.#refreshInFlight;
207062
+ const run = this.#refresh().finally(() => {
207063
+ this.#refreshInFlight = null;
207064
+ });
207065
+ this.#refreshInFlight = run;
207066
+ return run;
207067
+ }
207068
+ async #refresh() {
207069
+ const startedAt = this.#now();
207070
+ let lastError = null;
207071
+ let channels = [];
207072
+ try {
207073
+ channels = (await (await this.getApi()).getNvrChannelsSummary({
207074
+ source: "cgi",
207075
+ timeoutMs: HUB_DISCOVERY_REFRESH_TIMEOUT_MS
207076
+ })).devices.map((d) => ({
207077
+ childNativeId: computeChildNativeId(d.channel, d.uid),
207078
+ channel: d.channel,
207079
+ name: d.name ?? `Channel ${d.channel}`,
207080
+ status: discoveredStatusFor(d),
207081
+ ...d.uid ? { uid: d.uid } : {},
207082
+ ...d.model ? { model: d.model } : {},
207083
+ ...d.serialNumber ? { serialNumber: d.serialNumber } : {},
207084
+ isBattery: d.isBattery === true,
207085
+ isDoorbell: d.isDoorbell === true,
207086
+ isMultifocal: d.isMultifocal === true
207087
+ }));
207088
+ } catch (err) {
207089
+ lastError = errMsg$3(err);
207090
+ this.#logger.warn("Reolink hub channel refresh failed", {
207091
+ tags: { integrationId: this.integrationId },
207092
+ meta: { error: lastError }
207093
+ });
207094
+ }
207095
+ this.#snapshot = lastError === null ? {
207096
+ channels,
207097
+ lastRefreshAt: this.#now(),
207098
+ lastError: null
207099
+ } : {
207100
+ ...this.#snapshot,
207101
+ lastError
207102
+ };
207103
+ if (lastError !== null) return;
207104
+ await this.reconcileChildChannels(channels);
207105
+ const adoptedChannels = /* @__PURE__ */ new Set();
207106
+ for (const child of this.#deps.children()) if (child.channel !== void 0) adoptedChannels.add(child.channel);
207107
+ this.#knownUnadoptedChannels.clear();
207108
+ for (const c of channels) if (!adoptedChannels.has(c.channel)) this.#knownUnadoptedChannels.add(c.channel);
207109
+ this.#logger.info("Reolink hub: channel refresh complete", {
207110
+ tags: { integrationId: this.integrationId },
207111
+ meta: {
207112
+ durationMs: this.#now() - startedAt,
207113
+ total: channels.length,
207114
+ adopted: channels.filter((c) => adoptedChannels.has(c.channel)).length,
207115
+ online: channels.filter((c) => c.status === "online").length,
207116
+ sleeping: channels.filter((c) => c.status === "sleeping").length
207117
+ }
207118
+ });
207119
+ this.reconcileChildOnline(channels);
207120
+ this.reconcileChildSleepState(channels);
207121
+ await this.reconcileChildBattery();
207122
+ }
207123
+ /**
207124
+ * Re-derive each adopted child's `channel` from the hub's own list, matching by IDENTITY
207125
+ * (the stableId carries the camera UID) rather than by the channel itself — a child that lost
207126
+ * its channel is invisible to the routing, and a channel move produces the same damage under
207127
+ * a wrong number. A child that is not live is skipped: it is repaired on its next restore.
207128
+ */
207129
+ async reconcileChildChannels(channels) {
207130
+ const children = this.#deps.children();
207131
+ const repairs = planChannelReconciliation(channels.map((c) => ({
207132
+ childNativeId: c.childNativeId,
207133
+ rtspChannel: c.channel
207134
+ })), children.map((child) => ({
207135
+ deviceId: child.id,
207136
+ stableId: child.stableId,
207137
+ ...child.channel !== void 0 ? { channel: child.channel } : {}
207138
+ })));
207139
+ for (const repair of repairs) {
207140
+ const child = children.find((c) => c.id === repair.deviceId);
207141
+ if (child === void 0) continue;
207142
+ try {
207143
+ await child.repairChannel(repair.to);
207144
+ this.#logger.warn("Reolink hub: repaired a child channel from the hub channel list", {
207145
+ tags: {
207146
+ deviceId: repair.deviceId,
207147
+ stableId: repair.stableId
207148
+ },
207149
+ meta: {
207150
+ from: repair.from,
207151
+ to: repair.to
207152
+ }
207153
+ });
207154
+ } catch (err) {
207155
+ this.#logger.warn("Reolink hub: child channel repair failed", {
207156
+ tags: {
207157
+ deviceId: repair.deviceId,
207158
+ stableId: repair.stableId
207159
+ },
207160
+ meta: {
207161
+ from: repair.from,
207162
+ to: repair.to,
207163
+ error: errMsg$3(err)
207164
+ }
207165
+ });
207166
+ }
207167
+ }
207168
+ }
207169
+ /**
207170
+ * Mark every child still listed `online`, and any child missing for more than
207171
+ * `HUB_MISSING_OFFLINE_MS` `offline` — "if the hub firmware doesn't list it, the device isn't
207172
+ * reachable through the hub". Battery cams that briefly drop out of the summary during deep
207173
+ * sleep are not false-flagged: the threshold gives 3+ cycles of grace.
207174
+ */
207175
+ reconcileChildOnline(channels) {
207176
+ const now = this.#now();
207177
+ const listed = new Set(channels.map((c) => c.channel));
207178
+ for (const child of this.#deps.children()) {
207179
+ const ch = child.channel;
207180
+ if (ch === void 0) continue;
207181
+ if (child.type !== DeviceType.Camera) continue;
207182
+ if (listed.has(ch)) {
207183
+ this.#childLastSeenAt.set(child.id, now);
207184
+ if (!child.online) {
207185
+ child.markOnline(true);
207186
+ this.#logger.info("Reolink hub: adopted child back online", {
207187
+ tags: {
207188
+ deviceId: child.id,
207189
+ deviceName: child.name
207190
+ },
207191
+ meta: { channel: ch }
207192
+ });
207193
+ }
207194
+ continue;
207195
+ }
207196
+ const lastSeen = this.#childLastSeenAt.get(child.id) ?? 0;
207197
+ const missingMs = lastSeen > 0 ? now - lastSeen : Number.POSITIVE_INFINITY;
207198
+ if (missingMs >= 6e5 && child.online) {
207199
+ child.markOnline(false);
207200
+ this.#logger.warn("Reolink hub: adopted child marked offline (missing from the hub list)", {
207201
+ tags: {
207202
+ deviceId: child.id,
207203
+ deviceName: child.name
207204
+ },
207205
+ meta: {
207206
+ channel: ch,
207207
+ missingMs
207208
+ }
207209
+ });
207210
+ }
207211
+ }
207212
+ }
207213
+ /**
207214
+ * Push the hub's per-channel sleep state into each adopted battery child's `battery.sleeping`
207215
+ * slice. The summary carries a `sleeping` flag straight from the firmware over the hub's own
207216
+ * mains-powered socket: it costs the battery channel nothing, and it is real firmware state
207217
+ * rather than socket-I/O inference. Only `online` / `sleeping` are acted on: `offline` and
207218
+ * `unknown` carry no sleep information, so the last known value stands.
207219
+ */
207220
+ reconcileChildSleepState(channels) {
207221
+ const byChannel = new Map(channels.map((c) => [c.channel, c]));
207222
+ for (const child of this.#deps.children()) {
207223
+ if (child.channel === void 0) continue;
207224
+ const entry = byChannel.get(child.channel);
207225
+ if (entry === void 0) continue;
207226
+ if (entry.status !== "sleeping" && entry.status !== "online") continue;
207227
+ child.applyHubSleepState(entry.status === "sleeping");
207228
+ }
207229
+ }
207230
+ /**
207231
+ * Push the hub's per-channel battery read into each adopted battery child.
207232
+ * `getAllChannelsBatteryInfo` is one CGI on the hub's own socket — it never touches a battery
207233
+ * camera — and it is the ONLY way a sleeping child's percentage/charging ever moves.
207234
+ * Independent of the refresh's success path: a battery-read failure must not fail the refresh.
207235
+ */
207236
+ async reconcileChildBattery() {
207237
+ const children = this.#deps.children();
207238
+ if (children.length === 0) return;
207239
+ try {
207240
+ const data = await (await this.getApi()).getAllChannelsBatteryInfo();
207241
+ const entries = Object.entries(data.batteryInfoData);
207242
+ if (entries.length === 0) return;
207243
+ if (!this.#batteryShapeLogged) {
207244
+ this.#batteryShapeLogged = true;
207245
+ this.#logger.info("Reolink hub: per-channel battery payload (first read)", {
207246
+ tags: { integrationId: this.integrationId },
207247
+ meta: { raw: entries.map(([ch, v]) => ({
207248
+ channel: Number(ch),
207249
+ ...v
207250
+ })) }
207251
+ });
207252
+ }
207253
+ for (const [chKey, info] of entries) {
207254
+ const child = children.find((c) => c.channel === Number(chKey));
207255
+ if (child === void 0) continue;
207256
+ const power = mapHubBatteryPower(info.entries[0]);
207257
+ for (const { field, value } of power.unmapped) {
207258
+ if (!this.#unmappedBatteryCodes.firstSeen(child.id, field, value)) continue;
207259
+ this.#logger.warn("Reolink hub: unmapped battery power code — field dropped", {
207260
+ tags: { deviceId: child.id },
207261
+ meta: {
207262
+ channel: Number(chKey),
207263
+ field,
207264
+ value
207265
+ }
207266
+ });
207267
+ }
207268
+ child.applyHubBatteryInfo({
207269
+ batteryPercent: info.batteryLevel,
207270
+ ...power.fields
207271
+ });
207272
+ }
207273
+ } catch (err) {
207274
+ this.#logger.debug("Reolink hub: per-channel battery refresh failed", {
207275
+ tags: { integrationId: this.integrationId },
207276
+ meta: { error: errMsg$3(err) }
207277
+ });
207278
+ }
207279
+ }
207280
+ /** Dispatch a hub-received simpleEvent to the matching child's `handleSimpleEvent`. */
207281
+ routeSimpleEvent(event) {
207282
+ const channel = event?.channel;
207283
+ if (typeof channel !== "number") return;
207284
+ const motionSensor = event.type === "motion" ? this.#motionSensors.take(channel) : null;
207285
+ const child = this.#deps.children().find((c) => c.channel === channel);
207286
+ if (child === void 0) {
207287
+ const last = this.#unroutedLoggedAt.get(channel) ?? 0;
207288
+ const now = this.#now();
207289
+ if (now - last > UNROUTED_LOG_THROTTLE_MS) {
207290
+ this.#unroutedLoggedAt.set(channel, now);
207291
+ const knownUnadopted = this.#knownUnadoptedChannels.has(channel);
207292
+ this.#logger[knownUnadopted ? "debug" : "warn"]("Reolink hub: simpleEvent for an unmapped channel dropped", {
207293
+ tags: { integrationId: this.integrationId },
207294
+ meta: {
207295
+ channel,
207296
+ type: event?.type ?? "unknown",
207297
+ knownUnadopted
207298
+ }
207299
+ });
207300
+ }
207301
+ return;
207302
+ }
207303
+ try {
207304
+ if (shouldTraceSimpleEventDispatch({
207305
+ hubDebugGeneral: this.#connection.debugGeneral,
207306
+ hubSocketFlags: this.#connection.debugSocketLogs,
207307
+ childDebugGeneral: child.debugGeneral,
207308
+ childSocketFlags: child.debugSocketFlags
207309
+ })) this.#logger.info("Reolink hub: simpleEvent dispatch", {
207310
+ tags: { deviceId: child.id },
207311
+ meta: {
207312
+ type: event?.type ?? "unknown",
207313
+ channel,
207314
+ timestamp: event?.timestamp,
207315
+ name: child.name
207316
+ }
207317
+ });
207318
+ child.handleSimpleEvent(event, motionSensor);
207319
+ } catch (err) {
207320
+ this.#logger.debug("Reolink hub simpleEvent dispatch failed", {
207321
+ tags: { deviceId: child.id },
207322
+ meta: {
207323
+ channel,
207324
+ error: errMsg$3(err)
207325
+ }
207326
+ });
207327
+ }
207328
+ }
207329
+ };
207330
+ /**
207331
+ * A stable per-child identifier. UID is preferred when available (it identifies a paired camera
207332
+ * across re-pairings); otherwise `channel-N`, which is stable for a fixed channel assignment.
207333
+ */
207334
+ function computeChildNativeId(channel, uid) {
207335
+ if (uid && uid.length > 0) return `cam-${uid}`;
207336
+ return `channel-${channel}`;
207337
+ }
207338
+ /**
207339
+ * Map the lib's per-channel summary booleans to our `DiscoveredChildStatus`. Precedence:
207340
+ * `online === false` → offline; `sleeping === true` → sleeping; `online === true` → online;
207341
+ * any non-empty state → online; unknown otherwise (the cmd_id 145 push has not landed yet).
207342
+ */
207343
+ function discoveredStatusFor(d) {
207344
+ if (d.online === false) return "offline";
207345
+ if (d.sleeping === true) return "sleeping";
207346
+ if (d.online === true) return "online";
207347
+ if (typeof d.state === "string" && d.state.length > 0) return "online";
207348
+ return "unknown";
207349
+ }
207350
+ /** The wire-shaped channel the adoption surface lists. */
207351
+ function toDiscoveredChild(channel, adoptedDeviceId) {
207352
+ return {
207353
+ childNativeId: channel.childNativeId,
207354
+ name: channel.name,
207355
+ type: DeviceType.Camera,
207356
+ status: channel.status,
207357
+ metadata: {
207358
+ ...channel.model ? { model: channel.model } : {},
207359
+ ...channel.serialNumber ? { serialNumber: channel.serialNumber } : {},
207360
+ ...channel.uid ? { uid: channel.uid } : {},
207361
+ rtspChannel: channel.channel,
207362
+ isBattery: channel.isBattery,
207363
+ isDoorbell: channel.isDoorbell,
207364
+ isMultifocal: channel.isMultifocal
207365
+ },
207366
+ alreadyAdopted: adoptedDeviceId !== null,
207367
+ adoptedDeviceId
207368
+ };
207369
+ }
207370
+ var GATEWAY_SUFFIX = ":gateway";
207371
+ /** The `childNativeId` of the hub box itself, one per integration. */
207372
+ function hubGatewayNativeId(integrationId) {
207373
+ return `${integrationId}${GATEWAY_SUFFIX}`;
207374
+ }
207375
+ /**
207376
+ * The hub (NVR / Home Hub) as an adoption candidate: a {@link DeviceType.Gateway} that is a
207377
+ * sibling of its channel cameras, never their parent. Only what the library really exposes:
207378
+ * `reboot` (Baichuan `Reboot`) and the unit's web page.
207379
+ */
207380
+ function toDiscoveredGateway(input) {
207381
+ return {
207382
+ childNativeId: hubGatewayNativeId(input.integrationId),
207383
+ name: input.name,
207384
+ type: DeviceType.Gateway,
207385
+ status: input.online ? "online" : "offline",
207386
+ metadata: { manufacturer: "Reolink" },
207387
+ alreadyAdopted: input.adoptedDeviceId !== null,
207388
+ adoptedDeviceId: input.adoptedDeviceId,
207389
+ capabilities: [rebootCapability.name, deviceAdminLinkCapability.name]
207390
+ };
207391
+ }
207392
+ /**
207393
+ * The per-integration registry the cameras reach their session through (the shape of
207394
+ * `veluxGateways` / `enpalConnections`). It also holds the children, so a child that activates
207395
+ * before its session exists (boot order) is already routable the moment the session starts.
207396
+ */
207397
+ var ReolinkHubSessions = class {
207398
+ #sessions = /* @__PURE__ */ new Map();
207399
+ #children = /* @__PURE__ */ new Map();
207400
+ set(integrationId, session) {
207401
+ if (session === null) {
207402
+ this.#sessions.delete(integrationId);
207403
+ return;
207404
+ }
207405
+ this.#sessions.set(integrationId, session);
207406
+ }
207407
+ get(integrationId) {
207408
+ return this.#sessions.get(integrationId) ?? null;
207409
+ }
207410
+ attach(integrationId, child) {
207411
+ let set = this.#children.get(integrationId);
207412
+ if (set === void 0) {
207413
+ set = /* @__PURE__ */ new Map();
207414
+ this.#children.set(integrationId, set);
207415
+ }
207416
+ set.set(child.id, child);
207417
+ }
207418
+ /** Detach only if `child` is still the attached handle — a rebuilt device keeps its slot. */
207419
+ detach(integrationId, child) {
207420
+ const set = this.#children.get(integrationId);
207421
+ if (set?.get(child.id) === child) set.delete(child.id);
207422
+ }
207423
+ childrenOf(integrationId) {
207424
+ return [...this.#children.get(integrationId)?.values() ?? []];
207425
+ }
207426
+ clear() {
207427
+ this.#sessions.clear();
207428
+ this.#children.clear();
207429
+ }
207430
+ };
207431
+ /** The module-level registry (one per addon process). */
207432
+ var reolinkHubSessions = new ReolinkHubSessions();
207433
+ /** Dial a real hub. The default `connect` dependency of a session. */
207434
+ async function connectReolinkHub(connection) {
207435
+ const api = new ReolinkBaichuanApi({
207436
+ host: connection.host,
207437
+ port: connection.port,
207438
+ username: connection.username,
207439
+ password: connection.password,
207440
+ transport: connection.transport,
207441
+ ...connection.uid ? { uid: connection.uid } : {}
207442
+ });
207443
+ await api.login();
207444
+ return api;
207445
+ }
207446
+ //#endregion
207431
207447
  //#region src/sleep-state-decision.ts
207432
207448
  /**
207433
207449
  * Hysteresis is ASYMMETRIC, because the two directions have very different
@@ -207527,6 +207543,34 @@ function decideSnapshotCameraAccess(input) {
207527
207543
  };
207528
207544
  }
207529
207545
  //#endregion
207546
+ //#region src/webhook-generation.ts
207547
+ /**
207548
+ * Point a battery camera's webhook at the data-plane URL ONCE per URL generation.
207549
+ *
207550
+ * Runs on a camera's first Baichuan contact after the upgrade (the one moment it is awake without
207551
+ * us waking it). Only a camera that is actually armed is moved: reading its HaCfg decides, so a
207552
+ * camera on e-mail push is never switched to the webhook by an upgrade. The generation is written
207553
+ * only when the camera was moved or found not to need moving; a failure (no internal address, the
207554
+ * camera refusing the write) leaves it unwritten, so the next contact tries again.
207555
+ */
207556
+ async function repointWebhookOnce(ports) {
207557
+ const stored = ports.readGeneration();
207558
+ if (stored !== void 0 && stored >= 1) return "current";
207559
+ const armed = await ports.readCameraHaCfg();
207560
+ if (armed === void 0 || !armed.enable || armed.url.length === 0) {
207561
+ await ports.writeGeneration(1);
207562
+ return "not-armed";
207563
+ }
207564
+ const url = await ports.targetUrl();
207565
+ if (armed.url === url) {
207566
+ await ports.writeGeneration(1);
207567
+ return "already-there";
207568
+ }
207569
+ await ports.arm(url);
207570
+ await ports.writeGeneration(1);
207571
+ return "repointed";
207572
+ }
207573
+ //#endregion
207530
207574
  //#region src/accessories/base.ts
207531
207575
  /**
207532
207576
  * Reolink accessory base layer — kind enum, parent-host contract,
@@ -211943,7 +211987,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
211943
211987
  }
211944
211988
  if (!this.baichuanWebhookParamsResolver) throw userError("reolink.webhook-not-wired", void 0, "HaCfg Webhook Server is not wired — restart the Reolink addon");
211945
211989
  const params = await this.baichuanWebhookParamsResolver();
211946
- if (!params) throw new Error("Enable the HaCfg Webhook Server in the Reolink addon settings, and ensure this host has a reachable LAN address.");
211990
+ if (!params) throw new Error("The HaCfg webhook cannot be armed for this camera (it is not a standalone battery camera).");
211947
211991
  await this.wakeIfSleeping();
211948
211992
  const api = await this.ensureApi();
211949
211993
  const supported = await api.probeBaichuanWebhookSupport();
@@ -211960,12 +212004,47 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
211960
212004
  try {
211961
212005
  await api.subscribeEvents();
211962
212006
  } catch {}
212007
+ await this.config.setAll({ webhookUrlGeneration: 1 });
211963
212008
  this.ctx.logger.info("baichuan-webhook: camera auto-configure applied", {
211964
212009
  tags: { deviceId: this.id },
211965
212010
  meta: { url: params.url }
211966
212011
  });
211967
212012
  }
211968
212013
  /**
212014
+ * Move this battery camera's HaCfg webhook to the addon's data-plane URL, once, on the first
212015
+ * Baichuan contact after the upgrade (the camera is awake: this costs no wake). Only a camera
212016
+ * that is armed is touched; a failure is logged and retried at the next contact.
212017
+ */
212018
+ async repointWebhookOnFirstContact(api) {
212019
+ const resolver = this.baichuanWebhookParamsResolver;
212020
+ if (!this.isBattery || !this.isEmailPushEligible || resolver === void 0) return;
212021
+ try {
212022
+ if (await repointWebhookOnce({
212023
+ readGeneration: () => this.config.get("webhookUrlGeneration"),
212024
+ writeGeneration: async (generation) => {
212025
+ await this.config.setAll({ webhookUrlGeneration: generation });
212026
+ },
212027
+ readCameraHaCfg: async () => await api.getHaCfg(),
212028
+ targetUrl: async () => {
212029
+ const params = await resolver();
212030
+ if (params === null) throw new Error("this camera has no webhook URL");
212031
+ return params.url;
212032
+ },
212033
+ arm: async (url) => {
212034
+ await api.setupBaichuanWebhookToManager({
212035
+ url,
212036
+ verifyCert: false
212037
+ });
212038
+ }
212039
+ }) === "repointed") this.ctx.logger.info("baichuan-webhook: camera moved to the data-plane URL", { tags: { deviceId: this.id } });
212040
+ } catch (err) {
212041
+ this.ctx.logger.warn("baichuan-webhook: re-point failed — retried at the next contact", {
212042
+ tags: { deviceId: this.id },
212043
+ meta: { error: err instanceof Error ? err.message : String(err) }
212044
+ });
212045
+ }
212046
+ }
212047
+ /**
211969
212048
  * Read the camera's OWN email/SMTP config (`getEmail` cmd 42 + `getEmailTask`
211970
212049
  * cmd 216) and persist a snapshot into `deviceCache.emailConfigSnapshot` so
211971
212050
  * the Email Push settings section can show the camera's CURRENT mail config
@@ -218166,6 +218245,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
218166
218245
  this.registerBatteryIfSupported();
218167
218246
  this.refreshBatteryFromApi("demand");
218168
218247
  }
218248
+ if (this.isBattery) this.repointWebhookOnFirstContact(api);
218169
218249
  this.startWatchdogs();
218170
218250
  this.probeAndPersistFeatures(api).catch((err) => {
218171
218251
  this.ctx.logger.debug("Reolink feature probe failed (non-fatal)", {
@@ -218220,7 +218300,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
218220
218300
  return {
218221
218301
  type: "device",
218222
218302
  id: this.id,
218223
- addonId: REOLINK_ADDON_ID,
218303
+ addonId: REOLINK_ADDON_ID$1,
218224
218304
  deviceId: this.id
218225
218305
  };
218226
218306
  }
@@ -218313,7 +218393,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
218313
218393
  this.ctx.logger.info("Reolink camera woke up", { tags: { deviceId: this.id } });
218314
218394
  this.ctx.eventBus.emit(createEvent(EventCategory.DeviceAwake, eventSource, {
218315
218395
  deviceId: this.id,
218316
- providerId: REOLINK_ADDON_ID,
218396
+ providerId: REOLINK_ADDON_ID$1,
218317
218397
  reason: "awake"
218318
218398
  }));
218319
218399
  this.onWakeTransition("simple-event").catch(() => {});
@@ -218327,12 +218407,12 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
218327
218407
  this.markOnline(next);
218328
218408
  this.ctx.eventBus.emit(createEvent(next ? EventCategory.DeviceOnline : EventCategory.DeviceOffline, eventSource, next ? {
218329
218409
  deviceId: this.id,
218330
- providerId: REOLINK_ADDON_ID,
218410
+ providerId: REOLINK_ADDON_ID$1,
218331
218411
  profileCount: 0,
218332
218412
  reason: "firmware-push"
218333
218413
  } : {
218334
218414
  deviceId: this.id,
218335
- providerId: REOLINK_ADDON_ID,
218415
+ providerId: REOLINK_ADDON_ID$1,
218336
218416
  reason: "firmware-push"
218337
218417
  }));
218338
218418
  }
@@ -218343,7 +218423,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
218343
218423
  if (!this.commitSleepState(true, "simple-event")) return;
218344
218424
  if (!wasSleeping) this.ctx.eventBus.emit(createEvent(EventCategory.DeviceSleeping, eventSource, {
218345
218425
  deviceId: this.id,
218346
- providerId: REOLINK_ADDON_ID,
218426
+ providerId: REOLINK_ADDON_ID$1,
218347
218427
  reason: "sleeping"
218348
218428
  }));
218349
218429
  if (this.active.size > 0) {
@@ -218575,7 +218655,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
218575
218655
  this.markOnline(false);
218576
218656
  this.ctx.eventBus.emit(createEvent(EventCategory.DeviceOffline, this.eventSource(), {
218577
218657
  deviceId: this.id,
218578
- providerId: REOLINK_ADDON_ID,
218658
+ providerId: REOLINK_ADDON_ID$1,
218579
218659
  reason: "unreachable"
218580
218660
  }));
218581
218661
  }
@@ -218589,7 +218669,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
218589
218669
  this.markOnline(true);
218590
218670
  this.ctx.eventBus.emit(createEvent(EventCategory.DeviceOnline, this.eventSource(), {
218591
218671
  deviceId: this.id,
218592
- providerId: REOLINK_ADDON_ID,
218672
+ providerId: REOLINK_ADDON_ID$1,
218593
218673
  profileCount: 0,
218594
218674
  reason: "reachable-again"
218595
218675
  }));
@@ -218772,281 +218852,6 @@ var AutodetectCache = class {
218772
218852
  }
218773
218853
  };
218774
218854
  //#endregion
218775
- //#region src/baichuan-webhook-shared.ts
218776
- /** Default HTTP listen port for HaCfg POSTs (matches nodelink docs / HA). */
218777
- var DEFAULT_BAICHUAN_WEBHOOK_PORT = 9081;
218778
- /** Path prefix before the camera id. Final route: `${prefix}/:cameraId`. */
218779
- var DEFAULT_BAICHUAN_WEBHOOK_PATH_PREFIX = "/webhook";
218780
- /**
218781
- * Absolute URL a camera should POST HaCfg events to.
218782
- * HTTP (not HTTPS): Reolink HaCfg often fails TLS verify against the hub's
218783
- * self-signed cert; the intake binds a dedicated LAN port like SMTP.
218784
- */
218785
- function buildBaichuanWebhookUrl(params) {
218786
- const prefix = (params.pathPrefix ?? "/webhook").replace(/\/$/, "");
218787
- const id = encodeURIComponent(params.cameraId);
218788
- return `http://${params.host}:${String(params.port)}${prefix}/${id}`;
218789
- }
218790
- //#endregion
218791
- //#region src/baichuan-webhook-schema.ts
218792
- /**
218793
- * Addon-level settings for the Baichuan HaCfg HTTP intake.
218794
- *
218795
- * Sibling of the Email/SMTP Push sections — same integration home
218796
- * (System → Integrations → Reolink). Declared via
218797
- * `integrationSettingSections()`.
218798
- */
218799
- var BAICHUAN_WEBHOOK_SECTION_IDS = ["baichuan-webhook"];
218800
- function buildBaichuanWebhookSettingsSchema(recommendedHost) {
218801
- return { sections: [{
218802
- id: "baichuan-webhook",
218803
- title: "Battery-cam Motion (HaCfg Webhook)",
218804
- titleKey: "provider-reolink:baichuanWebhookSettings.s_baichuan-webhook.title",
218805
- description: "Preferred over email push when the camera firmware supports it.",
218806
- descriptionKey: "provider-reolink:baichuanWebhookSettings.s_baichuan-webhook.description",
218807
- columns: 2,
218808
- fields: [
218809
- {
218810
- type: "boolean",
218811
- key: "baichuanWebhookEnabled",
218812
- label: "Enable HaCfg Webhook Server",
218813
- labelKey: "provider-reolink:baichuanWebhookSettings.s_baichuan-webhook.baichuanWebhookEnabled.label",
218814
- default: true
218815
- },
218816
- {
218817
- type: "info",
218818
- key: "baichuanWebhookRecommendedHost",
218819
- label: "Camera-facing host",
218820
- labelKey: "provider-reolink:baichuanWebhookSettings.s_baichuan-webhook.baichuanWebhookRecommendedHost.label",
218821
- content: `Point cameras at this host: ${recommendedHost}`
218822
- },
218823
- {
218824
- type: "number",
218825
- key: "baichuanWebhookPort",
218826
- label: "HTTP Port",
218827
- labelKey: "provider-reolink:baichuanWebhookSettings.s_baichuan-webhook.baichuanWebhookPort.label",
218828
- default: DEFAULT_BAICHUAN_WEBHOOK_PORT,
218829
- min: 1,
218830
- max: 65535
218831
- },
218832
- {
218833
- type: "text",
218834
- key: "baichuanWebhookBindHost",
218835
- label: "Bind host",
218836
- labelKey: "provider-reolink:baichuanWebhookSettings.s_baichuan-webhook.baichuanWebhookBindHost.label",
218837
- default: "0.0.0.0",
218838
- description: "0.0.0.0 accepts on every interface (cameras on LAN).",
218839
- descriptionKey: "provider-reolink:baichuanWebhookSettings.s_baichuan-webhook.baichuanWebhookBindHost.description"
218840
- }
218841
- ]
218842
- }] };
218843
- }
218844
- //#endregion
218845
- //#region src/email-push-shared.ts
218846
- /**
218847
- * Map the lib's email-push classifier output onto the `ReolinkSimpleEvent`
218848
- * types the camera should be fed. AI subtypes + motion pass through;
218849
- * anything unrecognised collapses to plain `motion` so a wake is never
218850
- * silently dropped.
218851
- *
218852
- * Returns a LIST rather than a single type because of `doorbell`. The
218853
- * camera's `handleSimpleEvent` emits `ProviderMotion` for `motion`
218854
- * and for every AI class, but the `doorbell` branch emits ONLY
218855
- * `DoorbellOnPressed` and returns. An email is the sole signal a sleeping
218856
- * battery camera can send, so a doorbell-classified email mapped to
218857
- * `doorbell` alone rang the bell and left motion, recording and
218858
- * notification rules blind — the exact "silently dropped wake" this mapping
218859
- * exists to prevent. Pairing it with `motion` keeps the doorbell semantic
218860
- * AND the wake.
218861
- */
218862
- function mapInferredTypeToSimpleEvents(inferred) {
218863
- switch (inferred) {
218864
- case "people":
218865
- case "vehicle":
218866
- case "animal":
218867
- case "face":
218868
- case "package":
218869
- case "motion": return [inferred];
218870
- case "doorbell": return ["doorbell", "motion"];
218871
- default: return ["motion"];
218872
- }
218873
- }
218874
- /**
218875
- * Enumerate candidate camera-facing LAN addresses, best first (private
218876
- * RFC-1918 ranges ranked ahead of public). Link-local and internal
218877
- * interfaces are excluded — a camera can't reach 127.0.0.1.
218878
- */
218879
- function listLanHosts() {
218880
- const ifaces = node_os.default.networkInterfaces();
218881
- const candidates = [];
218882
- for (const list of Object.values(ifaces)) {
218883
- if (!list) continue;
218884
- for (const addr of list) {
218885
- if (addr.family !== "IPv4") continue;
218886
- if (addr.internal) continue;
218887
- if (addr.address.startsWith("169.254.")) continue;
218888
- const rank = addr.address.startsWith("192.168.") ? 0 : addr.address.startsWith("10.") ? 1 : /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(addr.address) ? 2 : 3;
218889
- candidates.push({
218890
- addr: addr.address,
218891
- rank
218892
- });
218893
- }
218894
- }
218895
- candidates.sort((a, b) => a.rank - b.rank);
218896
- return candidates.map((c) => c.addr);
218897
- }
218898
- //#endregion
218899
- //#region src/baichuan-webhook-server.ts
218900
- function readString(raw, key, fallback) {
218901
- const v = raw[key];
218902
- return typeof v === "string" && v.length > 0 ? v : fallback;
218903
- }
218904
- function readNumber(raw, key, fallback) {
218905
- const v = raw[key];
218906
- return typeof v === "number" && Number.isFinite(v) ? v : fallback;
218907
- }
218908
- function readBoolean(raw, key, fallback) {
218909
- const v = raw[key];
218910
- return typeof v === "boolean" ? v : fallback;
218911
- }
218912
- var ReolinkBaichuanWebhookServer = class {
218913
- deps;
218914
- instance;
218915
- busOff;
218916
- settings;
218917
- constructor(deps) {
218918
- this.deps = deps;
218919
- }
218920
- async resolveSettings() {
218921
- if (this.settings) return this.settings;
218922
- const raw = await this.deps.readStore();
218923
- this.settings = {
218924
- enabled: readBoolean(raw, "baichuanWebhookEnabled", true),
218925
- port: readNumber(raw, "baichuanWebhookPort", DEFAULT_BAICHUAN_WEBHOOK_PORT),
218926
- bindHost: readString(raw, "baichuanWebhookBindHost", "0.0.0.0")
218927
- };
218928
- return this.settings;
218929
- }
218930
- /** Drop the cached settings so the next start re-reads the store. */
218931
- reloadSettings() {
218932
- this.settings = void 0;
218933
- }
218934
- findCameraByStableId(stableId) {
218935
- return this.deps.listCameras().find((c) => c.stableId === stableId);
218936
- }
218937
- resolveCameraId(pathId) {
218938
- const cam = this.findCameraByStableId(pathId);
218939
- if (!cam || !cam.isEmailPushEligible) return void 0;
218940
- return pathId;
218941
- }
218942
- dispatchEvent(event) {
218943
- const cam = this.findCameraByStableId(event.cameraId);
218944
- if (!cam) {
218945
- this.deps.logger.warn("baichuan-webhook: event for unknown camera id — dropping", { meta: {
218946
- cameraId: event.cameraId,
218947
- event: event.event
218948
- } });
218949
- return;
218950
- }
218951
- const types = mapBaichuanWebhookToSimpleEvents({
218952
- event: event.event,
218953
- reason: event.reason
218954
- });
218955
- this.deps.logger.info("baichuan-webhook: event received from battery camera", {
218956
- tags: { deviceId: cam.id },
218957
- meta: {
218958
- event: event.event,
218959
- reason: event.reason,
218960
- types
218961
- }
218962
- });
218963
- const timestamp = Date.now();
218964
- const channel = cam.emailPushChannel;
218965
- for (const type of types) cam.handleSimpleEvent({
218966
- type,
218967
- channel,
218968
- timestamp,
218969
- source: "baichuanWebhook"
218970
- });
218971
- }
218972
- async start() {
218973
- const settings = await this.resolveSettings();
218974
- if (!settings.enabled) {
218975
- this.deps.logger.debug("baichuan-webhook: disabled — HTTP intake not started");
218976
- return;
218977
- }
218978
- if (this.instance) {
218979
- this.instance.updateConfig({
218980
- port: settings.port,
218981
- bindHost: settings.bindHost,
218982
- pathPrefix: DEFAULT_BAICHUAN_WEBHOOK_PATH_PREFIX
218983
- });
218984
- return;
218985
- }
218986
- this.instance = createBaichuanWebhookServer({
218987
- config: {
218988
- port: settings.port,
218989
- bindHost: settings.bindHost,
218990
- pathPrefix: DEFAULT_BAICHUAN_WEBHOOK_PATH_PREFIX
218991
- },
218992
- cameraResolver: (pathId) => this.resolveCameraId(pathId),
218993
- enabled: true,
218994
- logger: {
218995
- debug: (m) => this.deps.logger.debug(`[hacfg] ${m}`),
218996
- info: (m) => this.deps.logger.info(`[hacfg] ${m}`),
218997
- warn: (m) => this.deps.logger.warn(`[hacfg] ${m}`),
218998
- error: (m) => this.deps.logger.error(`[hacfg] ${m}`)
218999
- }
219000
- });
219001
- this.busOff = onBaichuanWebhookEvent((event) => this.dispatchEvent(event));
219002
- try {
219003
- await this.instance.start();
219004
- this.deps.logger.info("baichuan-webhook: HTTP intake listening", { meta: {
219005
- host: settings.bindHost,
219006
- port: settings.port,
219007
- pathPrefix: DEFAULT_BAICHUAN_WEBHOOK_PATH_PREFIX
219008
- } });
219009
- } catch (err) {
219010
- this.deps.logger.error("baichuan-webhook: HTTP intake failed to start", { meta: { error: err instanceof Error ? err.message : String(err) } });
219011
- this.busOff?.();
219012
- this.busOff = void 0;
219013
- this.instance = void 0;
219014
- }
219015
- }
219016
- async restart() {
219017
- await this.stop();
219018
- this.reloadSettings();
219019
- await this.start();
219020
- }
219021
- async stop() {
219022
- this.busOff?.();
219023
- this.busOff = void 0;
219024
- if (this.instance) {
219025
- await this.instance.stop().catch(() => {});
219026
- this.instance = void 0;
219027
- }
219028
- }
219029
- /**
219030
- * Absolute HaCfg URL for one camera, or null when the intake is off /
219031
- * has no LAN host / the camera is ineligible.
219032
- */
219033
- async buildCameraSetupParams(cam) {
219034
- if (!cam.isEmailPushEligible) return null;
219035
- const settings = await this.resolveSettings();
219036
- if (!settings.enabled) return null;
219037
- const managerHost = listLanHosts()[0];
219038
- if (!managerHost) return null;
219039
- return {
219040
- url: buildBaichuanWebhookUrl({
219041
- host: managerHost,
219042
- port: settings.port,
219043
- cameraId: cam.stableId
219044
- }),
219045
- verifyCert: false
219046
- };
219047
- }
219048
- };
219049
- //#endregion
219050
218855
  //#region src/creation-schema.ts
219051
218856
  /**
219052
218857
  * Creation form for Reolink devices.
@@ -219230,6 +219035,36 @@ function buildCreationFormSchema() {
219230
219035
  ] };
219231
219036
  }
219232
219037
  //#endregion
219038
+ //#region src/email-push-shared.ts
219039
+ /**
219040
+ * Map the lib's email-push classifier output onto the `ReolinkSimpleEvent`
219041
+ * types the camera should be fed. AI subtypes + motion pass through;
219042
+ * anything unrecognised collapses to plain `motion` so a wake is never
219043
+ * silently dropped.
219044
+ *
219045
+ * Returns a LIST rather than a single type because of `doorbell`. The
219046
+ * camera's `handleSimpleEvent` emits `ProviderMotion` for `motion`
219047
+ * and for every AI class, but the `doorbell` branch emits ONLY
219048
+ * `DoorbellOnPressed` and returns. An email is the sole signal a sleeping
219049
+ * battery camera can send, so a doorbell-classified email mapped to
219050
+ * `doorbell` alone rang the bell and left motion, recording and
219051
+ * notification rules blind — the exact "silently dropped wake" this mapping
219052
+ * exists to prevent. Pairing it with `motion` keeps the doorbell semantic
219053
+ * AND the wake.
219054
+ */
219055
+ function mapInferredTypeToSimpleEvents(inferred) {
219056
+ switch (inferred) {
219057
+ case "people":
219058
+ case "vehicle":
219059
+ case "animal":
219060
+ case "face":
219061
+ case "package":
219062
+ case "motion": return [inferred];
219063
+ case "doorbell": return ["doorbell", "motion"];
219064
+ default: return ["motion"];
219065
+ }
219066
+ }
219067
+ //#endregion
219233
219068
  //#region src/email-push-bridge.ts
219234
219069
  /**
219235
219070
  * The Reolink side of the `smtp-inbound` broker kind (D999).
@@ -219567,6 +219402,78 @@ function createEmailPushRemovalHook(deps) {
219567
219402
  listDeviceData: async () => []
219568
219403
  };
219569
219404
  }
219405
+ var LEGACY_PREFIX = "/webhook/";
219406
+ async function startLegacyWebhookIntake(deps) {
219407
+ const warned = /* @__PURE__ */ new Set();
219408
+ const server = (0, node_http.createServer)((req, res) => {
219409
+ const url = req.url ?? "";
219410
+ if (!url.startsWith(LEGACY_PREFIX)) {
219411
+ res.writeHead(404).end();
219412
+ return;
219413
+ }
219414
+ const rest = url.slice(8);
219415
+ req.url = rest;
219416
+ const id = decodeURIComponent(rest.replace(/^\//, "").split("?")[0] ?? "");
219417
+ const camera = deps.listCameras().find((c) => c.stableId === id);
219418
+ if (camera !== void 0 && !warned.has(id)) {
219419
+ warned.add(id);
219420
+ deps.logger.warn("legacy webhook port used — camera will be re-pointed", {
219421
+ tags: { deviceId: camera.id },
219422
+ meta: { port: deps.port ?? 9081 }
219423
+ });
219424
+ }
219425
+ Promise.resolve(deps.handler(req, res)).catch((err) => {
219426
+ deps.logger.error("legacy webhook intake failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
219427
+ if (!res.headersSent) res.writeHead(500);
219428
+ res.end();
219429
+ });
219430
+ });
219431
+ await new Promise((resolve, reject) => {
219432
+ server.once("error", reject);
219433
+ server.listen(deps.port ?? 9081, "0.0.0.0", () => {
219434
+ server.off("error", reject);
219435
+ resolve();
219436
+ });
219437
+ });
219438
+ return {
219439
+ server,
219440
+ close: () => new Promise((resolve) => server.close(() => resolve()))
219441
+ };
219442
+ }
219443
+ //#endregion
219444
+ //#region src/migrations/drop-webhook-intake-settings.ts
219445
+ /**
219446
+ * The camera webhook is a plane of the addon's HTTP data plane, not a private listener
219447
+ * (D999): its enable switch, port and bind host are gone. The three keys older versions
219448
+ * persisted are deleted; every other key is left exactly as stored.
219449
+ *
219450
+ * Idempotent: a store without the keys yields an empty plan, which writes nothing.
219451
+ */
219452
+ /** The addon id, the prefix of its rows in the canonical settings table. */
219453
+ var REOLINK_ADDON_ID = "provider-reolink";
219454
+ /** The canonical `addon-settings` table: one row per key, id `<addonId>.<key>`, data `{ addonId, key, value }`. */
219455
+ var ADDON_SETTINGS_TABLE = "addon-settings";
219456
+ var RETIRED_WEBHOOK_INTAKE_KEYS = [
219457
+ "baichuanWebhookEnabled",
219458
+ "baichuanWebhookPort",
219459
+ "baichuanWebhookBindHost"
219460
+ ];
219461
+ async function planDropWebhookIntakeSettings(ports) {
219462
+ const stored = await ports.readStoredRaw();
219463
+ const present = RETIRED_WEBHOOK_INTAKE_KEYS.filter((key) => key in stored);
219464
+ if (present.length === 0) return { edits: [] };
219465
+ const rowId = (key) => `${REOLINK_ADDON_ID}.${key}`;
219466
+ return { edits: [{
219467
+ op: "deleteKeys",
219468
+ scope: ADDON_SETTINGS_TABLE,
219469
+ keys: present.map(rowId),
219470
+ precondition: preconditionOf(Object.fromEntries(present.map((key) => [rowId(key), {
219471
+ addonId: REOLINK_ADDON_ID,
219472
+ key,
219473
+ value: stored[key]
219474
+ }])))
219475
+ }] };
219476
+ }
219570
219477
  //#endregion
219571
219478
  //#region src/migrations/email-push-to-mailbox.ts
219572
219479
  /** The pending mailbox as kept in the addon store: everything but the password (a keychain secret). */
@@ -219642,146 +219549,23 @@ var REOLINK_MIGRATIONS = [{
219642
219549
  fixtureMark: "a setting key the migration does not know"
219643
219550
  },
219644
219551
  procedure: moveEmailPushToMailbox
219552
+ }, {
219553
+ meta: {
219554
+ id: "provider-reolink/2026-11-02-drop-webhook-intake-settings",
219555
+ owner: "provider-reolink",
219556
+ level: "addon-state",
219557
+ seq: 2,
219558
+ dependsOn: [],
219559
+ store: "settings",
219560
+ scope: "cluster",
219561
+ onFailure: "tolerate",
219562
+ bodyKind: "plan",
219563
+ description: "Delete the retired baichuanWebhookEnabled, baichuanWebhookPort and baichuanWebhookBindHost settings; the webhook is served on the addon data plane",
219564
+ fixtureMark: "a setting key the migration does not know"
219565
+ },
219566
+ plan: planDropWebhookIntakeSettings
219645
219567
  }];
219646
219568
  //#endregion
219647
- //#region src/reolink-discovery-map.ts
219648
- /**
219649
- * Flatten a host (IP / hostname) into a flat row-key slug — shared by `generateStableId` and the
219650
- * discovery candidate mapping so a host-keyed camera is detected as already onboarded on re-scan.
219651
- */
219652
- function slugifyReolinkHost(host) {
219653
- return host.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
219654
- }
219655
- /**
219656
- * Decide whether a discovered host is a device this provider already owns.
219657
- *
219658
- * Matching is by UID first (stable across a DHCP lease change) and by host second. A UID match
219659
- * wins even when the host differs, because the address is the thing that moves.
219660
- */
219661
- function findOnboardedMatch(discovered, onboarded) {
219662
- if (discovered.uid !== void 0 && discovered.uid !== "") {
219663
- const byUid = onboarded.find((o) => o.uid === discovered.uid);
219664
- if (byUid !== void 0) return byUid;
219665
- }
219666
- return onboarded.find((o) => o.host !== void 0 && o.host === discovered.host);
219667
- }
219668
- /** Baichuan control port of an NVR / Home Hub (the scan only ever reports the HTTP/ONVIF one). */
219669
- var HUB_BAICHUAN_PORT = 9e3;
219670
- /**
219671
- * `true` for a model string that names an NVR or a Home Hub: a device that fronts channels
219672
- * rather than being one camera. Such a device is offered as an INTEGRATION to set up (D985), never
219673
- * as a camera to add.
219674
- */
219675
- function looksLikeReolinkHub(model) {
219676
- if (model === void 0) return false;
219677
- return /\b(nvr|rln\d*|home\s*hub|hub)\b/i.test(model);
219678
- }
219679
- /**
219680
- * The stableId of an NVR / Home Hub candidate. UID first (it survives a DHCP lease change), host
219681
- * second. The boot migration derives the SAME id for an old Hub device, so the integration it
219682
- * creates hides this candidate through `originStableId`.
219683
- */
219684
- function reolinkHubStableId(identity) {
219685
- const uid = identity.uid?.trim() ?? "";
219686
- if (uid !== "") return `reolink-hub-uid-${slugifyReolinkHost(uid)}`;
219687
- return `reolink-hub-host-${slugifyReolinkHost(identity.host)}`;
219688
- }
219689
- function isKnownHub(d, known) {
219690
- return known.some((k) => k.uid !== void 0 && k.uid !== "" && k.uid === d.uid || k.host !== void 0 && k.host !== "" && k.host === d.host);
219691
- }
219692
- /**
219693
- * Map discovered Reolink hosts to adoption {@link DiscoveryCandidate}s, de-duplicated by host (the
219694
- * same camera can answer on more than one discovery method — UDP broadcast, ONVIF, HTTP scan). The
219695
- * first responder for a host wins. Pure: no I/O.
219696
- *
219697
- * **The scan never supplies `port`.** `port` in the Reolink config schema is the BAICHUAN port
219698
- * (default 9000). Discovery reports `httpPort`, the HTTP/ONVIF listener — on a Reolink Home Hub
219699
- * that is 8000, while Baichuan lives on `mediaPort` 9000. Writing the discovered value into `port`
219700
- * pointed every Baichuan login at the ONVIF listener, which accepts the TCP connection and then
219701
- * closes it: `Baichuan socket closed`, 316 times in 12 hours, taking the hub and all three of its
219702
- * child cameras offline. The default is correct; a non-default Baichuan port is an operator edit,
219703
- * never a scan result.
219704
- *
219705
- * The authoritative stableId is `mac-<mac>` (learned during autodetect at adopt time), which discovery
219706
- * can't produce — so candidates carry `alreadyOnboarded` rather than relying on a stableId match,
219707
- * which never fires for a MAC-keyed device. Re-adding an owned device is how the port got clobbered
219708
- * in the first place, so a scan says so instead of offering it again.
219709
- */
219710
- function mapReolinkDiscoveryToCandidates(devices, credentials, onboarded = [], hubIntegrations = []) {
219711
- const username = credentials.username?.trim() ?? "";
219712
- const password = credentials.password ?? "";
219713
- const byHost = /* @__PURE__ */ new Map();
219714
- for (const d of devices) if (!byHost.has(d.host)) byHost.set(d.host, d);
219715
- return [...byHost.values()].flatMap((d) => {
219716
- const displayName = d.name ?? d.model ?? d.host;
219717
- if (looksLikeReolinkHub(d.model)) {
219718
- if (isKnownHub(d, hubIntegrations)) return [];
219719
- return [{
219720
- stableId: reolinkHubStableId(d),
219721
- type: DeviceType.Gateway,
219722
- suggestedName: displayName,
219723
- prefilledConfig: {
219724
- host: d.host,
219725
- port: HUB_BAICHUAN_PORT,
219726
- ...d.uid ? { uid: d.uid } : {},
219727
- ...username ? { username } : {}
219728
- },
219729
- setupIntegration: true
219730
- }];
219731
- }
219732
- const match = findOnboardedMatch(d, onboarded);
219733
- return [{
219734
- stableId: `host-${slugifyReolinkHost(d.host)}`,
219735
- type: DeviceType.Camera,
219736
- suggestedName: displayName,
219737
- prefilledConfig: {
219738
- name: displayName,
219739
- host: d.host,
219740
- transport: "auto",
219741
- ...d.uid ? { uid: d.uid } : {},
219742
- ...username ? { username } : {},
219743
- ...password ? { password } : {}
219744
- },
219745
- ...match !== void 0 ? {
219746
- alreadyOnboarded: true,
219747
- onboardedDeviceId: match.deviceId,
219748
- onboardedName: match.name
219749
- } : {}
219750
- }];
219751
- });
219752
- }
219753
- //#endregion
219754
- //#region src/reolink-subnet-scan.ts
219755
- /**
219756
- * Reolink cameras answer a vendor UDP broadcast (Baichuan) and ONVIF through the nodelink
219757
- * library, which owns both protocols' framing. The engine cannot send either, so the addon
219758
- * keeps `discoverDevices` and runs the library once per subnet the engine covers.
219759
- */
219760
- var REOLINK_DISCOVERY_MODES = [{
219761
- kind: "custom",
219762
- reason: "Reolink answers a Baichuan vendor UDP broadcast and ONVIF that only the nodelink library speaks; this addon runs it once per covered subnet"
219763
- }];
219764
- /** The subnets handed in by the discovery orchestrator (`params.subnets`), or [] when absent. */
219765
- function subnetsFromParams(params) {
219766
- const raw = params?.["subnets"];
219767
- if (!Array.isArray(raw)) return [];
219768
- return raw.filter((s) => typeof s === "string" && s.length > 0);
219769
- }
219770
- /**
219771
- * Run `scan` once per covered subnet (once with no CIDR when the engine named none) and merge
219772
- * the answers by host. A subnet whose scan fails is skipped and reported to `onError`.
219773
- */
219774
- async function scanReolinkSubnets(input) {
219775
- const targets = input.subnets.length > 0 ? input.subnets : [void 0];
219776
- const byHost = /* @__PURE__ */ new Map();
219777
- for (const cidr of targets) try {
219778
- for (const device of await input.scan(cidr)) if (!byHost.has(device.host)) byHost.set(device.host, device);
219779
- } catch (error) {
219780
- input.onError?.(cidr, error);
219781
- }
219782
- return [...byHost.values()];
219783
- }
219784
- //#endregion
219785
219569
  //#region src/reolink-adoption-provider.ts
219786
219570
  var DEVICES_FILTER = {
219787
219571
  id: "devices",
@@ -219946,233 +219730,111 @@ function buildReolinkAdoptionProvider(deps) {
219946
219730
  };
219947
219731
  }
219948
219732
  //#endregion
219949
- //#region src/reolink-hub-manager.ts
219733
+ //#region src/reolink-discovery-map.ts
219950
219734
  /**
219951
- * One {@link ReolinkHubSession} per NVR / Home Hub INTEGRATION (D985). `reconcile` is the one
219952
- * entry point: it is handed the live list of this addon's enabled integrations at boot and on
219953
- * every integration lifecycle event, and makes the sessions match it — upsert, re-dial on a
219954
- * changed connection, close what vanished. The default integration (no host) stays a plain
219955
- * container of standalone cameras and gets no session.
219735
+ * Flatten a host (IP / hostname) into a flat row-key slug — shared by `generateStableId` and the
219736
+ * discovery candidate mapping so a host-keyed camera is detected as already onboarded on re-scan.
219956
219737
  */
219957
- var ReolinkHubManager = class {
219958
- #sessions = /* @__PURE__ */ new Map();
219959
- #deps;
219960
- constructor(deps) {
219961
- this.#deps = deps;
219962
- }
219963
- list() {
219964
- return [...this.#sessions.keys()];
219965
- }
219966
- get(integrationId) {
219967
- return this.#sessions.get(integrationId) ?? null;
219968
- }
219969
- async reconcile(integrations) {
219970
- const { logger } = this.#deps;
219971
- const wanted = /* @__PURE__ */ new Set();
219972
- for (const integration of integrations) {
219973
- const read = readHubConnection(integration.settings);
219974
- if (read.kind === "no-host") continue;
219975
- wanted.add(integration.id);
219976
- if (read.kind !== "ok") {
219977
- logger.warn("Reolink NVR integration has no usable password — no session started", { meta: {
219978
- integrationId: integration.id,
219979
- reason: read.kind
219980
- } });
219981
- continue;
219982
- }
219983
- const existing = this.#sessions.get(integration.id);
219984
- if (existing !== void 0) {
219985
- if (!sameHubConnection(existing.connection, read.connection)) await existing.reconfigure(read.connection);
219986
- continue;
219987
- }
219988
- const session = new ReolinkHubSession({
219989
- integrationId: integration.id,
219990
- name: integration.name,
219991
- logger: logger.child(`hub-${integration.id}`),
219992
- connection: read.connection,
219993
- connect: this.#deps.connect,
219994
- children: () => this.#deps.childrenOf(integration.id),
219995
- onConnectionChange: (online) => this.#deps.onConnectionChange(integration.id, online)
219996
- });
219997
- this.#sessions.set(integration.id, session);
219998
- this.#deps.publish(integration.id, session);
219999
- session.start();
220000
- }
220001
- for (const [id, session] of [...this.#sessions]) {
220002
- if (wanted.has(id)) continue;
220003
- this.#sessions.delete(id);
220004
- this.#deps.publish(id, null);
220005
- await session.close();
220006
- }
220007
- }
220008
- async shutdown() {
220009
- for (const [id, session] of [...this.#sessions]) {
220010
- this.#sessions.delete(id);
220011
- this.#deps.publish(id, null);
220012
- await session.close();
220013
- }
220014
- }
220015
- };
220016
- /** The manager of a real addon process: dials real hubs. */
220017
- function createReolinkHubManager(deps, registry) {
220018
- return new ReolinkHubManager({
220019
- ...deps,
220020
- childrenOf: (id) => registry.childrenOf(id),
220021
- publish: (id, session) => registry.set(id, session),
220022
- connect: connectReolinkHub
220023
- });
220024
- }
220025
- function positivePort(value) {
220026
- return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : void 0;
220027
- }
220028
- var CONNECTION_FIELDS = [
220029
- "host",
220030
- "port",
220031
- "username",
220032
- "transport"
220033
- ];
220034
- function str(value) {
220035
- return typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
219738
+ function slugifyReolinkHost(host) {
219739
+ return host.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
220036
219740
  }
220037
- function migratedChildConfig(child, hub, integrationId) {
220038
- const filled = {};
220039
- for (const key of CONNECTION_FIELDS) {
220040
- const own = child[key];
220041
- const inherited = hub[key];
220042
- if ((own === void 0 || own === null || own === "") && inherited !== void 0) filled[key] = inherited;
219741
+ /**
219742
+ * Decide whether a discovered host is a device this provider already owns.
219743
+ *
219744
+ * Matching is by UID first (stable across a DHCP lease change) and by host second. A UID match
219745
+ * wins even when the host differs, because the address is the thing that moves.
219746
+ */
219747
+ function findOnboardedMatch(discovered, onboarded) {
219748
+ if (discovered.uid !== void 0 && discovered.uid !== "") {
219749
+ const byUid = onboarded.find((o) => o.uid === discovered.uid);
219750
+ if (byUid !== void 0) return byUid;
220043
219751
  }
220044
- return {
220045
- ...child,
220046
- ...filled,
220047
- integrationId
220048
- };
219752
+ return onboarded.find((o) => o.host !== void 0 && o.host === discovered.host);
220049
219753
  }
220050
- async function migrateOneHub(hub, children, integrations, ports) {
220051
- const read = readHubConnection(hub.config);
220052
- if (read.kind !== "ok") throw new Error(`hub connection unusable (${read.kind}) — left in place`);
220053
- const { connection } = read;
220054
- const uid = str(hub.config["uid"]);
220055
- const integrationId = integrations.find((i) => i.addonId === ports.addonId && i.info["migratedFromHubDeviceId"] === hub.id)?.id ?? (await ports.createIntegration({
220056
- addonId: ports.addonId,
220057
- name: hub.name,
220058
- info: { ["migratedFromHubDeviceId"]: hub.id },
220059
- originStableId: reolinkHubStableId({
220060
- ...uid !== void 0 ? { uid } : {},
220061
- host: connection.host
220062
- }),
220063
- settings: {
220064
- host: connection.host,
220065
- port: connection.port,
220066
- rtspPort: positivePort(hub.config["rtspPort"]) ?? connection.rtspPort,
220067
- username: connection.username,
220068
- password: connection.password,
220069
- transport: connection.transport,
220070
- ...uid !== void 0 ? { uid } : {},
220071
- debugGeneral: connection.debugGeneral,
220072
- debugSocketLogs: connection.debugSocketLogs
220073
- },
220074
- secretKeys: ["password"]
220075
- })).id;
220076
- const rewritten = [];
220077
- for (const child of children) {
220078
- const config = migratedChildConfig(child.config, hub.config, integrationId);
220079
- await ports.persistConfig(child.stableId, config);
220080
- await ports.setIntegrationId(child.id, integrationId);
220081
- rewritten.push({
220082
- ...child,
220083
- config,
220084
- parentDeviceId: null
220085
- });
220086
- }
220087
- return {
220088
- rows: rewritten,
220089
- retirement: {
220090
- hubDeviceId: hub.id,
220091
- hubName: hub.name,
220092
- integrationId
220093
- }
220094
- };
219754
+ /** Baichuan control port of an NVR / Home Hub (the scan only ever reports the HTTP/ONVIF one). */
219755
+ var HUB_BAICHUAN_PORT = 9e3;
219756
+ /**
219757
+ * `true` for a model string that names an NVR or a Home Hub: a device that fronts channels
219758
+ * rather than being one camera. Such a device is offered as an INTEGRATION to set up (D985), never
219759
+ * as a camera to add.
219760
+ */
219761
+ function looksLikeReolinkHub(model) {
219762
+ if (model === void 0) return false;
219763
+ return /\b(nvr|rln\d*|home\s*hub|hub)\b/i.test(model);
220095
219764
  }
220096
- /** Plan and persist the migration of every Hub row in `saved`. Never throws. */
220097
- async function planHubMigration(saved, ports) {
220098
- const hubs = saved.filter((row) => row.type === DeviceType.Hub);
220099
- if (hubs.length === 0) return {
220100
- rows: saved,
220101
- retirements: []
220102
- };
220103
- let integrations;
220104
- try {
220105
- integrations = await ports.listIntegrations();
220106
- } catch (err) {
220107
- ports.logger.warn("Reolink hub migration: integrations unreadable — hubs left in place", { meta: { error: errMsg$3(err) } });
220108
- return {
220109
- rows: saved.filter((row) => row.type !== DeviceType.Hub),
220110
- retirements: []
220111
- };
220112
- }
220113
- const hubIds = new Set(hubs.map((h) => h.id));
220114
- const rewrittenById = /* @__PURE__ */ new Map();
220115
- const retirements = [];
220116
- for (const hub of hubs) {
220117
- const children = saved.filter((row) => row.parentDeviceId === hub.id);
220118
- try {
220119
- const done = await migrateOneHub(hub, children, integrations, ports);
220120
- for (const row of done.rows) rewrittenById.set(row.id, row);
220121
- retirements.push(done.retirement);
220122
- ports.logger.info("Reolink hub migrated to an integration", {
220123
- tags: { deviceId: hub.id },
220124
- meta: {
220125
- integrationId: done.retirement.integrationId,
220126
- children: children.length,
220127
- name: hub.name
220128
- }
220129
- });
220130
- } catch (err) {
220131
- ports.logger.warn("Reolink hub migration failed — the hub device is left in place", {
220132
- tags: { deviceId: hub.id },
220133
- meta: {
220134
- error: errMsg$3(err),
220135
- name: hub.name
220136
- }
220137
- });
220138
- }
220139
- }
220140
- return {
220141
- rows: saved.filter((row) => !hubIds.has(row.id)).map((row) => rewrittenById.get(row.id) ?? row),
220142
- retirements
220143
- };
219765
+ /**
219766
+ * The stableId of an NVR / Home Hub candidate. UID first (it survives a DHCP lease change), host
219767
+ * second. The boot migration derives the SAME id for an old Hub device, so the integration it
219768
+ * creates hides this candidate through `originStableId`.
219769
+ */
219770
+ function reolinkHubStableId(identity) {
219771
+ const uid = identity.uid?.trim() ?? "";
219772
+ if (uid !== "") return `reolink-hub-uid-${slugifyReolinkHost(uid)}`;
219773
+ return `reolink-hub-host-${slugifyReolinkHost(identity.host)}`;
219774
+ }
219775
+ function isKnownHub(d, known) {
219776
+ return known.some((k) => k.uid !== void 0 && k.uid !== "" && k.uid === d.uid || k.host !== void 0 && k.host !== "" && k.host === d.host);
220144
219777
  }
220145
219778
  /**
220146
- * Retire the hubs whose children all restored. A hub that still has a child in the store is left
220147
- * and named: removing it would cascade the camera away with it.
219779
+ * Map discovered Reolink hosts to adoption {@link DiscoveryCandidate}s, de-duplicated by host (the
219780
+ * same camera can answer on more than one discovery method — UDP broadcast, ONVIF, HTTP scan). The
219781
+ * first responder for a host wins. Pure: no I/O.
219782
+ *
219783
+ * **The scan never supplies `port`.** `port` in the Reolink config schema is the BAICHUAN port
219784
+ * (default 9000). Discovery reports `httpPort`, the HTTP/ONVIF listener — on a Reolink Home Hub
219785
+ * that is 8000, while Baichuan lives on `mediaPort` 9000. Writing the discovered value into `port`
219786
+ * pointed every Baichuan login at the ONVIF listener, which accepts the TCP connection and then
219787
+ * closes it: `Baichuan socket closed`, 316 times in 12 hours, taking the hub and all three of its
219788
+ * child cameras offline. The default is correct; a non-default Baichuan port is an operator edit,
219789
+ * never a scan result.
219790
+ *
219791
+ * The authoritative stableId is `mac-<mac>` (learned during autodetect at adopt time), which discovery
219792
+ * can't produce — so candidates carry `alreadyOnboarded` rather than relying on a stableId match,
219793
+ * which never fires for a MAC-keyed device. Re-adding an owned device is how the port got clobbered
219794
+ * in the first place, so a scan says so instead of offering it again.
220148
219795
  */
220149
- async function retireMigratedHubs(retirements, ports) {
220150
- let retired = 0;
220151
- for (const r of retirements) try {
220152
- const remaining = await ports.childIdsOf(r.hubDeviceId);
220153
- if (remaining.length > 0) {
220154
- ports.logger.warn("Reolink hub kept — a child did not restore, retiring it would remove it", {
220155
- tags: { deviceId: r.hubDeviceId },
220156
- meta: {
220157
- remaining: [...remaining],
220158
- integrationId: r.integrationId
220159
- }
220160
- });
220161
- continue;
219796
+ function mapReolinkDiscoveryToCandidates(devices, credentials, onboarded = [], hubIntegrations = []) {
219797
+ const username = credentials.username?.trim() ?? "";
219798
+ const password = credentials.password ?? "";
219799
+ const byHost = /* @__PURE__ */ new Map();
219800
+ for (const d of devices) if (!byHost.has(d.host)) byHost.set(d.host, d);
219801
+ return [...byHost.values()].flatMap((d) => {
219802
+ const displayName = d.name ?? d.model ?? d.host;
219803
+ if (looksLikeReolinkHub(d.model)) {
219804
+ if (isKnownHub(d, hubIntegrations)) return [];
219805
+ return [{
219806
+ stableId: reolinkHubStableId(d),
219807
+ type: DeviceType.Gateway,
219808
+ suggestedName: displayName,
219809
+ prefilledConfig: {
219810
+ host: d.host,
219811
+ port: HUB_BAICHUAN_PORT,
219812
+ ...d.uid ? { uid: d.uid } : {},
219813
+ ...username ? { username } : {}
219814
+ },
219815
+ setupIntegration: true
219816
+ }];
220162
219817
  }
220163
- await ports.removeDevice(r.hubDeviceId);
220164
- retired++;
220165
- ports.logger.info("Reolink hub device retired", {
220166
- tags: { deviceId: r.hubDeviceId },
220167
- meta: { integrationId: r.integrationId }
220168
- });
220169
- } catch (err) {
220170
- ports.logger.warn("Reolink hub retirement failed — retried next boot", {
220171
- tags: { deviceId: r.hubDeviceId },
220172
- meta: { error: errMsg$3(err) }
220173
- });
220174
- }
220175
- return retired;
219818
+ const match = findOnboardedMatch(d, onboarded);
219819
+ return [{
219820
+ stableId: `host-${slugifyReolinkHost(d.host)}`,
219821
+ type: DeviceType.Camera,
219822
+ suggestedName: displayName,
219823
+ prefilledConfig: {
219824
+ name: displayName,
219825
+ host: d.host,
219826
+ transport: "auto",
219827
+ ...d.uid ? { uid: d.uid } : {},
219828
+ ...username ? { username } : {},
219829
+ ...password ? { password } : {}
219830
+ },
219831
+ ...match !== void 0 ? {
219832
+ alreadyOnboarded: true,
219833
+ onboardedDeviceId: match.deviceId,
219834
+ onboardedName: match.name
219835
+ } : {}
219836
+ }];
219837
+ });
220176
219838
  }
220177
219839
  //#endregion
220178
219840
  //#region src/reolink-gateway-device.ts
@@ -220255,6 +219917,375 @@ var ReolinkGatewayDevice = class extends BaseDevice {
220255
219917
  }
220256
219918
  };
220257
219919
  //#endregion
219920
+ //#region src/reolink-hub-manager.ts
219921
+ /**
219922
+ * One {@link ReolinkHubSession} per NVR / Home Hub INTEGRATION (D985). `reconcile` is the one
219923
+ * entry point: it is handed the live list of this addon's enabled integrations at boot and on
219924
+ * every integration lifecycle event, and makes the sessions match it — upsert, re-dial on a
219925
+ * changed connection, close what vanished. The default integration (no host) stays a plain
219926
+ * container of standalone cameras and gets no session.
219927
+ */
219928
+ var ReolinkHubManager = class {
219929
+ #sessions = /* @__PURE__ */ new Map();
219930
+ #deps;
219931
+ constructor(deps) {
219932
+ this.#deps = deps;
219933
+ }
219934
+ list() {
219935
+ return [...this.#sessions.keys()];
219936
+ }
219937
+ get(integrationId) {
219938
+ return this.#sessions.get(integrationId) ?? null;
219939
+ }
219940
+ async reconcile(integrations) {
219941
+ const { logger } = this.#deps;
219942
+ const wanted = /* @__PURE__ */ new Set();
219943
+ for (const integration of integrations) {
219944
+ const read = readHubConnection(integration.settings);
219945
+ if (read.kind === "no-host") continue;
219946
+ wanted.add(integration.id);
219947
+ if (read.kind !== "ok") {
219948
+ logger.warn("Reolink NVR integration has no usable password — no session started", { meta: {
219949
+ integrationId: integration.id,
219950
+ reason: read.kind
219951
+ } });
219952
+ continue;
219953
+ }
219954
+ const existing = this.#sessions.get(integration.id);
219955
+ if (existing !== void 0) {
219956
+ if (!sameHubConnection(existing.connection, read.connection)) await existing.reconfigure(read.connection);
219957
+ continue;
219958
+ }
219959
+ const session = new ReolinkHubSession({
219960
+ integrationId: integration.id,
219961
+ name: integration.name,
219962
+ logger: logger.child(`hub-${integration.id}`),
219963
+ connection: read.connection,
219964
+ connect: this.#deps.connect,
219965
+ children: () => this.#deps.childrenOf(integration.id),
219966
+ onConnectionChange: (online) => this.#deps.onConnectionChange(integration.id, online)
219967
+ });
219968
+ this.#sessions.set(integration.id, session);
219969
+ this.#deps.publish(integration.id, session);
219970
+ session.start();
219971
+ }
219972
+ for (const [id, session] of [...this.#sessions]) {
219973
+ if (wanted.has(id)) continue;
219974
+ this.#sessions.delete(id);
219975
+ this.#deps.publish(id, null);
219976
+ await session.close();
219977
+ }
219978
+ }
219979
+ async shutdown() {
219980
+ for (const [id, session] of [...this.#sessions]) {
219981
+ this.#sessions.delete(id);
219982
+ this.#deps.publish(id, null);
219983
+ await session.close();
219984
+ }
219985
+ }
219986
+ };
219987
+ /** The manager of a real addon process: dials real hubs. */
219988
+ function createReolinkHubManager(deps, registry) {
219989
+ return new ReolinkHubManager({
219990
+ ...deps,
219991
+ childrenOf: (id) => registry.childrenOf(id),
219992
+ publish: (id, session) => registry.set(id, session),
219993
+ connect: connectReolinkHub
219994
+ });
219995
+ }
219996
+ function positivePort(value) {
219997
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : void 0;
219998
+ }
219999
+ var CONNECTION_FIELDS = [
220000
+ "host",
220001
+ "port",
220002
+ "username",
220003
+ "transport"
220004
+ ];
220005
+ function str(value) {
220006
+ return typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
220007
+ }
220008
+ function migratedChildConfig(child, hub, integrationId) {
220009
+ const filled = {};
220010
+ for (const key of CONNECTION_FIELDS) {
220011
+ const own = child[key];
220012
+ const inherited = hub[key];
220013
+ if ((own === void 0 || own === null || own === "") && inherited !== void 0) filled[key] = inherited;
220014
+ }
220015
+ return {
220016
+ ...child,
220017
+ ...filled,
220018
+ integrationId
220019
+ };
220020
+ }
220021
+ async function migrateOneHub(hub, children, integrations, ports) {
220022
+ const read = readHubConnection(hub.config);
220023
+ if (read.kind !== "ok") throw new Error(`hub connection unusable (${read.kind}) — left in place`);
220024
+ const { connection } = read;
220025
+ const uid = str(hub.config["uid"]);
220026
+ const integrationId = integrations.find((i) => i.addonId === ports.addonId && i.info["migratedFromHubDeviceId"] === hub.id)?.id ?? (await ports.createIntegration({
220027
+ addonId: ports.addonId,
220028
+ name: hub.name,
220029
+ info: { ["migratedFromHubDeviceId"]: hub.id },
220030
+ originStableId: reolinkHubStableId({
220031
+ ...uid !== void 0 ? { uid } : {},
220032
+ host: connection.host
220033
+ }),
220034
+ settings: {
220035
+ host: connection.host,
220036
+ port: connection.port,
220037
+ rtspPort: positivePort(hub.config["rtspPort"]) ?? connection.rtspPort,
220038
+ username: connection.username,
220039
+ password: connection.password,
220040
+ transport: connection.transport,
220041
+ ...uid !== void 0 ? { uid } : {},
220042
+ debugGeneral: connection.debugGeneral,
220043
+ debugSocketLogs: connection.debugSocketLogs
220044
+ },
220045
+ secretKeys: ["password"]
220046
+ })).id;
220047
+ const rewritten = [];
220048
+ for (const child of children) {
220049
+ const config = migratedChildConfig(child.config, hub.config, integrationId);
220050
+ await ports.persistConfig(child.stableId, config);
220051
+ await ports.setIntegrationId(child.id, integrationId);
220052
+ rewritten.push({
220053
+ ...child,
220054
+ config,
220055
+ parentDeviceId: null
220056
+ });
220057
+ }
220058
+ return {
220059
+ rows: rewritten,
220060
+ retirement: {
220061
+ hubDeviceId: hub.id,
220062
+ hubName: hub.name,
220063
+ integrationId
220064
+ }
220065
+ };
220066
+ }
220067
+ /** Plan and persist the migration of every Hub row in `saved`. Never throws. */
220068
+ async function planHubMigration(saved, ports) {
220069
+ const hubs = saved.filter((row) => row.type === DeviceType.Hub);
220070
+ if (hubs.length === 0) return {
220071
+ rows: saved,
220072
+ retirements: []
220073
+ };
220074
+ let integrations;
220075
+ try {
220076
+ integrations = await ports.listIntegrations();
220077
+ } catch (err) {
220078
+ ports.logger.warn("Reolink hub migration: integrations unreadable — hubs left in place", { meta: { error: errMsg$3(err) } });
220079
+ return {
220080
+ rows: saved.filter((row) => row.type !== DeviceType.Hub),
220081
+ retirements: []
220082
+ };
220083
+ }
220084
+ const hubIds = new Set(hubs.map((h) => h.id));
220085
+ const rewrittenById = /* @__PURE__ */ new Map();
220086
+ const retirements = [];
220087
+ for (const hub of hubs) {
220088
+ const children = saved.filter((row) => row.parentDeviceId === hub.id);
220089
+ try {
220090
+ const done = await migrateOneHub(hub, children, integrations, ports);
220091
+ for (const row of done.rows) rewrittenById.set(row.id, row);
220092
+ retirements.push(done.retirement);
220093
+ ports.logger.info("Reolink hub migrated to an integration", {
220094
+ tags: { deviceId: hub.id },
220095
+ meta: {
220096
+ integrationId: done.retirement.integrationId,
220097
+ children: children.length,
220098
+ name: hub.name
220099
+ }
220100
+ });
220101
+ } catch (err) {
220102
+ ports.logger.warn("Reolink hub migration failed — the hub device is left in place", {
220103
+ tags: { deviceId: hub.id },
220104
+ meta: {
220105
+ error: errMsg$3(err),
220106
+ name: hub.name
220107
+ }
220108
+ });
220109
+ }
220110
+ }
220111
+ return {
220112
+ rows: saved.filter((row) => !hubIds.has(row.id)).map((row) => rewrittenById.get(row.id) ?? row),
220113
+ retirements
220114
+ };
220115
+ }
220116
+ /**
220117
+ * Retire the hubs whose children all restored. A hub that still has a child in the store is left
220118
+ * and named: removing it would cascade the camera away with it.
220119
+ */
220120
+ async function retireMigratedHubs(retirements, ports) {
220121
+ let retired = 0;
220122
+ for (const r of retirements) try {
220123
+ const remaining = await ports.childIdsOf(r.hubDeviceId);
220124
+ if (remaining.length > 0) {
220125
+ ports.logger.warn("Reolink hub kept — a child did not restore, retiring it would remove it", {
220126
+ tags: { deviceId: r.hubDeviceId },
220127
+ meta: {
220128
+ remaining: [...remaining],
220129
+ integrationId: r.integrationId
220130
+ }
220131
+ });
220132
+ continue;
220133
+ }
220134
+ await ports.removeDevice(r.hubDeviceId);
220135
+ retired++;
220136
+ ports.logger.info("Reolink hub device retired", {
220137
+ tags: { deviceId: r.hubDeviceId },
220138
+ meta: { integrationId: r.integrationId }
220139
+ });
220140
+ } catch (err) {
220141
+ ports.logger.warn("Reolink hub retirement failed — retried next boot", {
220142
+ tags: { deviceId: r.hubDeviceId },
220143
+ meta: { error: errMsg$3(err) }
220144
+ });
220145
+ }
220146
+ return retired;
220147
+ }
220148
+ //#endregion
220149
+ //#region src/reolink-subnet-scan.ts
220150
+ /**
220151
+ * Reolink cameras answer a vendor UDP broadcast (Baichuan) and ONVIF through the nodelink
220152
+ * library, which owns both protocols' framing. The engine cannot send either, so the addon
220153
+ * keeps `discoverDevices` and runs the library once per subnet the engine covers.
220154
+ */
220155
+ var REOLINK_DISCOVERY_MODES = [{
220156
+ kind: "custom",
220157
+ reason: "Reolink answers a Baichuan vendor UDP broadcast and ONVIF that only the nodelink library speaks; this addon runs it once per covered subnet"
220158
+ }];
220159
+ /** The subnets handed in by the discovery orchestrator (`params.subnets`), or [] when absent. */
220160
+ function subnetsFromParams(params) {
220161
+ const raw = params?.["subnets"];
220162
+ if (!Array.isArray(raw)) return [];
220163
+ return raw.filter((s) => typeof s === "string" && s.length > 0);
220164
+ }
220165
+ /**
220166
+ * Run `scan` once per covered subnet (once with no CIDR when the engine named none) and merge
220167
+ * the answers by host. A subnet whose scan fails is skipped and reported to `onError`.
220168
+ */
220169
+ async function scanReolinkSubnets(input) {
220170
+ const targets = input.subnets.length > 0 ? input.subnets : [void 0];
220171
+ const byHost = /* @__PURE__ */ new Map();
220172
+ for (const cidr of targets) try {
220173
+ for (const device of await input.scan(cidr)) if (!byHost.has(device.host)) byHost.set(device.host, device);
220174
+ } catch (error) {
220175
+ input.onError?.(cidr, error);
220176
+ }
220177
+ return [...byHost.values()];
220178
+ }
220179
+ //#endregion
220180
+ //#region src/webhook-plane.ts
220181
+ /**
220182
+ * The Baichuan HaCfg webhook (battery sleep push, cmd 807) as a plane of this addon's HTTP data
220183
+ * plane: a camera POSTs `{"event":"wake","reason":"pir"}` to
220184
+ * `http://<internal-host>:<lan-http-port>/addon/provider-reolink/webhook/<stableId>`. The hub
220185
+ * proxies it without auth (`access: 'public'`: the firmware cannot sign in), so this handler is
220186
+ * the whole gate: a known battery camera, a small JSON body, nothing else.
220187
+ */
220188
+ /** The firmware's body is a few dozen bytes; this bounds what an unauthenticated peer can make us hold. */
220189
+ var WEBHOOK_MAX_BODY_BYTES = 64 * 1024;
220190
+ var BodyTooLarge = class extends Error {};
220191
+ function reply(res, status, allow) {
220192
+ res.writeHead(status, {
220193
+ "content-type": "text/plain",
220194
+ ...allow === void 0 ? {} : { allow }
220195
+ });
220196
+ res.end();
220197
+ }
220198
+ async function readBody(req) {
220199
+ const chunks = [];
220200
+ let size = 0;
220201
+ for await (const chunk of req) {
220202
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
220203
+ size += buffer.length;
220204
+ if (size > 65536) throw new BodyTooLarge();
220205
+ chunks.push(buffer);
220206
+ }
220207
+ return Buffer.concat(chunks);
220208
+ }
220209
+ /** The stable id in `req.url` (`/<id>`, already stripped of the plane's prefix), or null. */
220210
+ function stableIdOf(url) {
220211
+ const segment = ((url ?? "").split("?")[0] ?? "").replace(/^\/+/, "");
220212
+ if (segment.length === 0 || segment.includes("/")) return null;
220213
+ try {
220214
+ return decodeURIComponent(segment);
220215
+ } catch {
220216
+ return null;
220217
+ }
220218
+ }
220219
+ function createWebhookHandler(deps) {
220220
+ const { logger } = deps;
220221
+ return async (req, res) => {
220222
+ if (req.method !== "POST" && req.method !== "PUT") {
220223
+ reply(res, 405, "POST, PUT");
220224
+ return;
220225
+ }
220226
+ const stableId = stableIdOf(req.url);
220227
+ const camera = stableId === null ? void 0 : deps.listCameras().find((c) => c.stableId === stableId);
220228
+ if (camera === void 0 || !camera.isEmailPushEligible) {
220229
+ logger.warn("baichuan-webhook: push for an unknown or ineligible camera — refused", {
220230
+ ...camera === void 0 ? {} : { tags: { deviceId: camera.id } },
220231
+ meta: {
220232
+ stableId: stableId ?? "(none)",
220233
+ known: camera !== void 0
220234
+ }
220235
+ });
220236
+ reply(res, 404);
220237
+ return;
220238
+ }
220239
+ let body;
220240
+ try {
220241
+ body = await readBody(req);
220242
+ } catch (err) {
220243
+ const tooLarge = err instanceof BodyTooLarge;
220244
+ logger.warn(tooLarge ? "baichuan-webhook: push body over the bound — refused" : "baichuan-webhook: push body unreadable — refused", {
220245
+ tags: { deviceId: camera.id },
220246
+ meta: { maxBytes: WEBHOOK_MAX_BODY_BYTES }
220247
+ });
220248
+ reply(res, tooLarge ? 413 : 400);
220249
+ if (tooLarge) req.destroy();
220250
+ return;
220251
+ }
220252
+ const parsed = parseBaichuanWebhookBody(body);
220253
+ if (parsed === void 0) {
220254
+ logger.warn("baichuan-webhook: push body is not a webhook payload — refused", { tags: { deviceId: camera.id } });
220255
+ reply(res, 400);
220256
+ return;
220257
+ }
220258
+ if (parsed.kind !== "event") {
220259
+ logger.debug("baichuan-webhook: command-shaped push ignored", {
220260
+ tags: { deviceId: camera.id },
220261
+ meta: { cmdId: parsed.cmdId }
220262
+ });
220263
+ reply(res, 202);
220264
+ return;
220265
+ }
220266
+ const types = mapBaichuanWebhookToSimpleEvents({
220267
+ event: parsed.event,
220268
+ reason: parsed.reason
220269
+ });
220270
+ logger.info("baichuan-webhook: event received from battery camera", {
220271
+ tags: { deviceId: camera.id },
220272
+ meta: {
220273
+ event: parsed.event,
220274
+ reason: parsed.reason,
220275
+ types
220276
+ }
220277
+ });
220278
+ const timestamp = Date.now();
220279
+ for (const type of types) camera.handleSimpleEvent({
220280
+ type,
220281
+ channel: camera.emailPushChannel,
220282
+ timestamp,
220283
+ source: "baichuanWebhook"
220284
+ });
220285
+ reply(res, 200);
220286
+ };
220287
+ }
220288
+ //#endregion
220258
220289
  //#region src/addon.ts
220259
220290
  /**
220260
220291
  * Adapt a camstack scoped logger to the lib's `Logger` shape (just
@@ -220416,7 +220447,7 @@ async function enrichHostIdentifiersFromLiveApi(detection, logger) {
220416
220447
  var ReolinkProviderAddon = class extends BaseDeviceProvider {
220417
220448
  /** The email-push SMTP password is hidden state — kept in the keychain (D787). */
220418
220449
  secretStatePaths = ["emailPushAuthPassword", "mailboxPendingPassword"];
220419
- addonId = REOLINK_ADDON_ID;
220450
+ addonId = REOLINK_ADDON_ID$1;
220420
220451
  providerName = "Reolink";
220421
220452
  deviceClasses = {
220422
220453
  [DeviceType.Camera]: ReolinkCamera,
@@ -220436,7 +220467,6 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
220436
220467
  * simple event. Built in `onInitialize`, once the addon context exists.
220437
220468
  */
220438
220469
  emailBridge;
220439
- baichuanWebhookServer;
220440
220470
  /** The `log-channels` provider for this process. Stopped on shutdown so a
220441
220471
  * diagnostic timer is never the reason a runner refuses to exit. */
220442
220472
  logChannels;
@@ -220474,11 +220504,10 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
220474
220504
  listReolinkCameras() {
220475
220505
  const all = this.ctx.kernel.deviceRegistry?.getAll() ?? [];
220476
220506
  const emailBridge = this.emailBridge;
220477
- const webhookServer = this.baichuanWebhookServer;
220478
220507
  const cameras = [];
220479
220508
  for (const d of all) if (d instanceof ReolinkCamera) {
220480
220509
  if (emailBridge) d.setEmailPushParamsResolver(() => emailBridge.buildCameraSetupParams(d));
220481
- if (webhookServer) d.setBaichuanWebhookParamsResolver(() => webhookServer.buildCameraSetupParams(d));
220510
+ d.setBaichuanWebhookParamsResolver(() => this.buildWebhookParams(d));
220482
220511
  cameras.push(d);
220483
220512
  }
220484
220513
  return cameras;
@@ -220556,14 +220585,50 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
220556
220585
  emailPushAuthPassword: password
220557
220586
  } : raw;
220558
220587
  }
220559
- /** Lazily build the HaCfg webhook intake, wiring its provider deps. */
220560
- ensureBaichuanWebhookServer() {
220561
- if (!this.baichuanWebhookServer) this.baichuanWebhookServer = new ReolinkBaichuanWebhookServer({
220562
- logger: this.ctx.logger.child("baichuan-webhook"),
220563
- listCameras: () => this.listReolinkCameras(),
220564
- readStore: async () => await this.resolveGlobalStore()
220565
- });
220566
- return this.baichuanWebhookServer;
220588
+ /**
220589
+ * What a battery camera needs to arm its HaCfg webhook: the data-plane URL on the hub's
220590
+ * plain-HTTP LAN listener. The host is the `internal` role of the palette, read on every use
220591
+ * (D729). `null` for a camera that cannot do it; throws the reason when the hub has no address.
220592
+ */
220593
+ async buildWebhookParams(cam) {
220594
+ if (!cam.isEmailPushEligible) return null;
220595
+ return {
220596
+ url: buildBaichuanWebhookUrl({
220597
+ ...resolveWebhookEndpoint(await this.ctx.api.localNetwork.getAddresses.query()),
220598
+ stableId: cam.stableId
220599
+ }),
220600
+ verifyCert: false
220601
+ };
220602
+ }
220603
+ /**
220604
+ * Serve the camera's HaCfg push on the addon's HTTP data plane. Public: the firmware cannot sign
220605
+ * in, so the handler is the gate. Like the clip planes it is served from `onInitialize` (the hub
220606
+ * pulls planes during registration) and a host without the facility only loses this intake.
220607
+ */
220608
+ legacyWebhook;
220609
+ async serveWebhookPlane() {
220610
+ try {
220611
+ const handler = createWebhookHandler({
220612
+ logger: this.ctx.logger.child("baichuan-webhook"),
220613
+ listCameras: () => this.listReolinkCameras()
220614
+ });
220615
+ await this.ctx.dataPlane?.serve({
220616
+ prefix: BAICHUAN_WEBHOOK_PREFIX,
220617
+ access: "public",
220618
+ handler
220619
+ });
220620
+ try {
220621
+ this.legacyWebhook = await startLegacyWebhookIntake({
220622
+ logger: this.ctx.logger.child("baichuan-webhook"),
220623
+ listCameras: () => this.listReolinkCameras(),
220624
+ handler
220625
+ });
220626
+ } catch (err) {
220627
+ this.ctx.logger.warn("baichuan-webhook: legacy :9081 intake not bound (port in use?)", { meta: { error: err instanceof Error ? err.message : String(err) } });
220628
+ }
220629
+ } catch (err) {
220630
+ this.ctx.logger.warn("baichuan-webhook: data plane failed to serve — battery push will not arrive", { meta: { error: err instanceof Error ? err.message : String(err) } });
220631
+ }
220567
220632
  }
220568
220633
  /**
220569
220634
  * Reolink stableId derivation — uniform `mac → host(IP)` chain for
@@ -220631,7 +220696,7 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
220631
220696
  }
220632
220697
  this.logChannels?.stop();
220633
220698
  await this.clipService?.disposeClipStreamDial().catch(() => {});
220634
- await this.baichuanWebhookServer?.stop().catch(() => {});
220699
+ await this.legacyWebhook?.close().catch(() => void 0);
220635
220700
  await this.hubManager?.shutdown().catch((err) => {
220636
220701
  this.ctx.logger.warn("Reolink NVR sessions did not close cleanly", { meta: { error: errMsg$3(err) } });
220637
220702
  });
@@ -220820,32 +220885,17 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
220820
220885
  return report;
220821
220886
  }
220822
220887
  /**
220823
- * Settings changed → rebind the SMTP / HaCfg intakes so port/domain/auth
220824
- * edits apply without a full addon restart. Best-effort: a bind failure is
220825
- * logged inside the server and leaves it stopped until the next edit.
220888
+ * Settings changed → refresh the mirror the clip refresh timer reads.
220826
220889
  */
220827
220890
  async onConfigChanged() {
220828
220891
  await this.refreshClipRefreshGlobalStore();
220829
- await this.baichuanWebhookServer?.restart().catch((err) => {
220830
- this.ctx.logger.warn("baichuan-webhook: restart after settings change failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
220831
- });
220832
220892
  }
220833
220893
  /**
220834
- * The addon's global settings schema.
220835
- *
220836
- * This used to override `getGlobalSettings()` wholesale, on the belief that a
220837
- * dynamic `recommendedHost` could not come from a static schema declaration.
220838
- * It can: `listLanHosts()` is synchronous, so the schema builder runs here
220839
- * and `BaseAddon.getGlobalSettings` does the identical
220840
- * `resolveGlobalStore` + `hydrateSchema` the override did by hand.
220841
- *
220842
- * Declaring the SCHEMA rather than overriding the GETTER is what lets core
220843
- * serve `getIntegrationSettings()` — it selects the declared sections out of
220844
- * this schema, so an addon that hides its schema behind a getter has nothing
220845
- * for core to select from.
220894
+ * The addon's global settings schema: the clip-catalog refresh default. (The battery push has
220895
+ * no addon-level setting: the mailbox is a broker and the webhook is a plane of the data plane.)
220846
220896
  */
220847
220897
  globalSettingsSchema() {
220848
- return { sections: [...buildBaichuanWebhookSettingsSchema(listLanHosts()[0] ?? "127.0.0.1").sections, ...buildClipRefreshSettingsSchema().sections] };
220898
+ return { sections: [...buildClipRefreshSettingsSchema().sections] };
220849
220899
  }
220850
220900
  /**
220851
220901
  * Per-instance form (D985): an NVR / Home Hub integration shows its connection only; the
@@ -220869,13 +220919,6 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
220869
220919
  return null;
220870
220920
  }
220871
220921
  }
220872
- /**
220873
- * The HaCfg webhook section IS this integration's configuration — one intake serving
220874
- * every Reolink camera. See `baichuan-webhook-schema.ts`. (The camera mailbox is a broker.)
220875
- */
220876
- integrationSettingSections() {
220877
- return [...BAICHUAN_WEBHOOK_SECTION_IDS];
220878
- }
220879
220922
  async onInitialize() {
220880
220923
  const regs = this.providerRegistrationsFromSuper(await super.onInitialize());
220881
220924
  this.logChannels = createLogChannelsProvider(this.ctx.logger.child("log-channels"));
@@ -220919,11 +220962,9 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
220919
220962
  this.startClipCatalogRefresh(this.clipService);
220920
220963
  this.wireClipCatalogRefreshers();
220921
220964
  await this.serveClipPlanes(this.clipService);
220965
+ await this.serveWebhookPlane();
220922
220966
  await this.runMigrations(defineAddonMigrations(REOLINK_MIGRATIONS, this.migrationPorts()));
220923
220967
  this.wireEmailBridge();
220924
- this.ensureBaichuanWebhookServer().start().catch((err) => {
220925
- this.ctx.logger.warn("baichuan-webhook: HTTP intake failed to start at init", { meta: { error: err instanceof Error ? err.message : String(err) } });
220926
- });
220927
220968
  const wireSleepPushResolvers = (event, via) => {
220928
220969
  const data = event.data;
220929
220970
  if (data.providerId !== this.addonId) return;
@@ -220933,8 +220974,7 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
220933
220974
  const emailBridge = this.ensureEmailBridge();
220934
220975
  dev.setEmailPushParamsResolver(() => emailBridge.buildCameraSetupParams(dev));
220935
220976
  emailBridge.sync();
220936
- const webhookServer = this.ensureBaichuanWebhookServer();
220937
- dev.setBaichuanWebhookParamsResolver(() => webhookServer.buildCameraSetupParams(dev));
220977
+ dev.setBaichuanWebhookParamsResolver(() => this.buildWebhookParams(dev));
220938
220978
  this.wireClipCatalogRefreshers();
220939
220979
  this.ctx.logger.debug("sleep-push: resolvers wired", {
220940
220980
  tags: { deviceId: data.deviceId },
@@ -220968,6 +221008,7 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
220968
221008
  migrationPorts() {
220969
221009
  return {
220970
221010
  readStored: () => this.readLegacyEmailPush(),
221011
+ readStoredRaw: () => this.resolveGlobalStore(),
220971
221012
  stashPending: (settings) => this.stashPendingMailbox(settings),
220972
221013
  warn: (message) => {
220973
221014
  this.ctx.logger.warn(message);
@@ -221111,9 +221152,9 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
221111
221152
  handler: service.streamHandler
221112
221153
  });
221113
221154
  this.ctx.logger.info("Reolink clip data planes served", { meta: {
221114
- thumbPath: `/addon/${REOLINK_ADDON_ID}/${service.thumbPrefix}`,
221115
- mediaPath: `/addon/${REOLINK_ADDON_ID}/${service.mediaPrefix}`,
221116
- streamPath: `/addon/${REOLINK_ADDON_ID}/${service.streamPrefix}`,
221155
+ thumbPath: `/addon/${REOLINK_ADDON_ID$1}/${service.thumbPrefix}`,
221156
+ mediaPath: `/addon/${REOLINK_ADDON_ID$1}/${service.mediaPrefix}`,
221157
+ streamPath: `/addon/${REOLINK_ADDON_ID$1}/${service.streamPrefix}`,
221117
221158
  thumbServed: thumb !== void 0,
221118
221159
  mediaServed: media !== void 0,
221119
221160
  streamServed: stream !== void 0
@@ -221353,8 +221394,7 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
221353
221394
  device.adoptApi(detectedApi);
221354
221395
  const emailBridge = this.ensureEmailBridge();
221355
221396
  device.setEmailPushParamsResolver(() => emailBridge.buildCameraSetupParams(device));
221356
- const webhookServer = this.ensureBaichuanWebhookServer();
221357
- device.setBaichuanWebhookParamsResolver(() => webhookServer.buildCameraSetupParams(device));
221397
+ device.setBaichuanWebhookParamsResolver(() => this.buildWebhookParams(device));
221358
221398
  }
221359
221399
  ctx.api?.pipelineOrchestrator?.applyDeviceSettingsPatch?.mutate({
221360
221400
  deviceId: device.id,