@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.mjs CHANGED
@@ -22,10 +22,8 @@ import { setInterval as setInterval$1 } from "timers";
22
22
  import * as net2 from "net";
23
23
  import netImpl from "net";
24
24
  import { mkdir as mkdir$1 } from "fs/promises";
25
- import http from "http";
26
- import { URL as URL$1 } from "url";
27
25
  import { spawn as spawn$1 } from "node:child_process";
28
- import os, { tmpdir } from "node:os";
26
+ import { tmpdir } from "node:os";
29
27
  import { createReadStream } from "node:fs";
30
28
  //#region ../../node_modules/undici/lib/core/symbols.js
31
29
  var require_symbols = /* @__PURE__ */ __commonJSMin(((exports, module) => {
@@ -7769,7 +7767,7 @@ var require_client_h2 = /* @__PURE__ */ __commonJSMin(((exports, module) => {
7769
7767
  var require_client = /* @__PURE__ */ __commonJSMin(((exports, module) => {
7770
7768
  var assert$21 = __require("node:assert");
7771
7769
  var net$5 = __require("node:net");
7772
- var http$2 = __require("node:http");
7770
+ var http$1 = __require("node:http");
7773
7771
  var util = require_util$9();
7774
7772
  var { ClientStats } = require_stats();
7775
7773
  var { channels } = require_diagnostics();
@@ -7781,7 +7779,7 @@ var require_client = /* @__PURE__ */ __commonJSMin(((exports, module) => {
7781
7779
  var connectH1 = require_client_h1();
7782
7780
  var connectH2 = require_client_h2();
7783
7781
  var kClosedResolve = Symbol("kClosedResolve");
7784
- var getDefaultNodeMaxHeaderSize = http$2 && http$2.maxHeaderSize && Number.isInteger(http$2.maxHeaderSize) && http$2.maxHeaderSize > 0 ? () => http$2.maxHeaderSize : () => {
7782
+ var getDefaultNodeMaxHeaderSize = http$1 && http$1.maxHeaderSize && Number.isInteger(http$1.maxHeaderSize) && http$1.maxHeaderSize > 0 ? () => http$1.maxHeaderSize : () => {
7785
7783
  throw new InvalidArgumentError("http module not available or http.maxHeaderSize invalid");
7786
7784
  };
7787
7785
  var noop = () => {};
@@ -9180,7 +9178,7 @@ var require_socks5_client = /* @__PURE__ */ __commonJSMin(((exports, module) =>
9180
9178
  //#endregion
9181
9179
  //#region ../../node_modules/undici/lib/dispatcher/socks5-proxy-agent.js
9182
9180
  var require_socks5_proxy_agent = /* @__PURE__ */ __commonJSMin(((exports, module) => {
9183
- var { URL: URL$2 } = __require("node:url");
9181
+ var { URL: URL$1 } = __require("node:url");
9184
9182
  var tls;
9185
9183
  var DispatcherBase = require_dispatcher_base();
9186
9184
  var { InvalidArgumentError } = require_errors$1();
@@ -9209,7 +9207,7 @@ var require_socks5_proxy_agent = /* @__PURE__ */ __commonJSMin(((exports, module
9209
9207
  experimentalWarningEmitted = true;
9210
9208
  }
9211
9209
  if (!proxyUrl) throw new InvalidArgumentError("Proxy URL is mandatory");
9212
- const url = typeof proxyUrl === "string" ? new URL$2(proxyUrl) : proxyUrl;
9210
+ const url = typeof proxyUrl === "string" ? new URL$1(proxyUrl) : proxyUrl;
9213
9211
  if (url.protocol !== "socks5:" && url.protocol !== "socks:") throw new InvalidArgumentError("Proxy URL must use socks5:// or socks:// protocol");
9214
9212
  this[kProxyUrl] = url;
9215
9213
  this[kProxyHeaders] = options.headers || {};
@@ -9307,7 +9305,7 @@ var require_socks5_proxy_agent = /* @__PURE__ */ __commonJSMin(((exports, module
9307
9305
  connections: opts.connections,
9308
9306
  connect: async (connectOpts, callback) => {
9309
9307
  try {
9310
- const url = new URL$2(origin);
9308
+ const url = new URL$1(origin);
9311
9309
  const targetHost = url.hostname;
9312
9310
  const targetPort = parseInt(url.port) || (url.protocol === "https:" ? 443 : 80);
9313
9311
  debug("establishing SOCKS5 connection to", targetHost, targetPort);
@@ -39077,19 +39075,10 @@ function mapBaichuanWebhookAlarmXmlToSimpleEvents(xml) {
39077
39075
  return [...out];
39078
39076
  }
39079
39077
  var emitter2 = new EventEmitter();
39080
- var lastEventByCamera2 = /* @__PURE__ */ new Map();
39081
- var MAX_GLOBAL_EVENTS2 = 300;
39082
- var globalRecentEvents2 = [];
39083
39078
  function onBaichuanWebhookEvent(handler) {
39084
39079
  emitter2.on("event", handler);
39085
39080
  return () => emitter2.off("event", handler);
39086
39081
  }
39087
- function emitBaichuanWebhookEvent(event) {
39088
- lastEventByCamera2.set(event.cameraId, event);
39089
- globalRecentEvents2.unshift(event);
39090
- if (globalRecentEvents2.length > MAX_GLOBAL_EVENTS2) globalRecentEvents2.length = MAX_GLOBAL_EVENTS2;
39091
- emitter2.emit("event", event);
39092
- }
39093
39082
  function mapBaichuanWebhookToSimpleEvents(event) {
39094
39083
  const name = (event.event ?? "").toLowerCase();
39095
39084
  if (name === "sleep") return ["sleeping"];
@@ -54964,7 +54953,7 @@ var require_smtp_connection = /* @__PURE__ */ __commonJSMin(((exports, module) =
54964
54953
  var ipv6normalize = require_ipv6_normalize();
54965
54954
  var sasl = require_sasl();
54966
54955
  var crypto$5 = __require("crypto");
54967
- var os$2 = __require("os");
54956
+ var os$1 = __require("os");
54968
54957
  var punycode = (init_punycode_es6(), __toCommonJS(punycode_es6_exports));
54969
54958
  var EventEmitter$2 = __require("events");
54970
54959
  var SOCKET_TIMEOUT = 60 * 1e3;
@@ -55063,7 +55052,7 @@ var require_smtp_connection = /* @__PURE__ */ __commonJSMin(((exports, module) =
55063
55052
  this._unauthenticatedCommands = 0;
55064
55053
  this._maxAllowedUnauthenticatedCommands = this._server.options.maxAllowedUnauthenticatedCommands || 10;
55065
55054
  this._unrecognizedCommands = 0;
55066
- this.name = this._server.options.name || os$2.hostname();
55055
+ this.name = this._server.options.name || os$1.hostname();
55067
55056
  this.clientHostname = false;
55068
55057
  this.openingCommand = false;
55069
55058
  this.hostNameAppearsAs = false;
@@ -56884,7 +56873,7 @@ var require_errors = /* @__PURE__ */ __commonJSMin(((exports, module) => {
56884
56873
  //#endregion
56885
56874
  //#region ../../node_modules/nodemailer/lib/fetch/index.js
56886
56875
  var require_fetch = /* @__PURE__ */ __commonJSMin(((exports, module) => {
56887
- var http$1 = __require("http");
56876
+ var http = __require("http");
56888
56877
  var https = __require("https");
56889
56878
  var urllib = require_url();
56890
56879
  var zlib = __require("zlib");
@@ -56916,7 +56905,7 @@ var require_fetch = /* @__PURE__ */ __commonJSMin(((exports, module) => {
56916
56905
  let finished = false;
56917
56906
  let cookies;
56918
56907
  let body;
56919
- const handler = parsed.protocol === "https:" ? https : http$1;
56908
+ const handler = parsed.protocol === "https:" ? https : http;
56920
56909
  const headers = {
56921
56910
  "accept-encoding": "gzip,deflate",
56922
56911
  "user-agent": "nodemailer/" + packageData.version
@@ -57103,7 +57092,7 @@ var require_shared = /* @__PURE__ */ __commonJSMin(((exports, module) => {
57103
57092
  var errors = require_errors();
57104
57093
  var dns$1 = __require("dns");
57105
57094
  var net$1 = __require("net");
57106
- var os$1 = __require("os");
57095
+ var os = __require("os");
57107
57096
  var DNS_TTL = 300 * 1e3;
57108
57097
  var CACHE_CLEANUP_INTERVAL = 30 * 1e3;
57109
57098
  var MAX_CACHE_SIZE = 1e3;
@@ -57114,7 +57103,7 @@ var require_shared = /* @__PURE__ */ __commonJSMin(((exports, module) => {
57114
57103
  };
57115
57104
  var networkInterfaces;
57116
57105
  try {
57117
- networkInterfaces = os$1.networkInterfaces();
57106
+ networkInterfaces = os.networkInterfaces();
57118
57107
  } catch (_err) {}
57119
57108
  module.exports.networkInterfaces = networkInterfaces;
57120
57109
  var isFamilySupported = (family, allowInternal) => {
@@ -100697,180 +100686,6 @@ function parseBaichuanWebhookObject(mess) {
100697
100686
  if (typeof dataRaw.reason === "string") parsed.reason = dataRaw.reason;
100698
100687
  return parsed;
100699
100688
  }
100700
- function normalizePrefix(prefix) {
100701
- const raw = (prefix ?? "/webhook").trim() || "/webhook";
100702
- return (raw.startsWith("/") ? raw : `/${raw}`).replace(/\/+$/, "") || "/webhook";
100703
- }
100704
- function getCameraWebhookPath(cameraId, pathPrefix = "/webhook") {
100705
- return `${normalizePrefix(pathPrefix)}/${encodeURIComponent(cameraId)}`;
100706
- }
100707
- function createBaichuanWebhookServer(params) {
100708
- const log = {
100709
- debug: params.logger?.debug ?? (() => {}),
100710
- info: params.logger?.info ?? (() => {}),
100711
- warn: params.logger?.warn ?? (() => {}),
100712
- error: params.logger?.error ?? (() => {})
100713
- };
100714
- let config = params.config;
100715
- let enabled = params.enabled !== false;
100716
- let server;
100717
- let status = buildInitialStatus2(config, enabled);
100718
- const resolve = params.cameraResolver ?? ((id) => id ? id : void 0);
100719
- function pathPrefix() {
100720
- return normalizePrefix(config.pathPrefix);
100721
- }
100722
- function maxBody() {
100723
- return config.maxBodyBytes && config.maxBodyBytes > 0 ? config.maxBodyBytes : 64 * 1024;
100724
- }
100725
- async function handle(req, res) {
100726
- try {
100727
- if (req.method !== "POST" && req.method !== "PUT") {
100728
- res.writeHead(405, { "Content-Type": "text/plain" });
100729
- res.end("Method Not Allowed");
100730
- status.messagesRejected += 1;
100731
- return;
100732
- }
100733
- const host = req.headers.host ?? "localhost";
100734
- const url = new URL$1(req.url ?? "/", `http://${host}`);
100735
- const prefix = pathPrefix();
100736
- if (!url.pathname.startsWith(prefix + "/") && url.pathname !== prefix) {
100737
- res.writeHead(404, { "Content-Type": "text/plain" });
100738
- res.end("Not Found");
100739
- status.messagesRejected += 1;
100740
- return;
100741
- }
100742
- const rest = url.pathname.slice(prefix.length).replace(/^\/+/, "");
100743
- const cameraId = resolve(decodeURIComponent(rest.split("/")[0] ?? ""));
100744
- if (!cameraId) {
100745
- res.writeHead(404, { "Content-Type": "text/plain" });
100746
- res.end("Unknown camera");
100747
- status.messagesRejected += 1;
100748
- return;
100749
- }
100750
- const chunks = [];
100751
- let total = 0;
100752
- const limit = maxBody();
100753
- for await (const chunk of req) {
100754
- const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
100755
- total += buf.length;
100756
- if (total > limit) {
100757
- res.writeHead(413, { "Content-Type": "text/plain" });
100758
- res.end("Payload Too Large");
100759
- status.messagesRejected += 1;
100760
- return;
100761
- }
100762
- chunks.push(buf);
100763
- }
100764
- const body = Buffer.concat(chunks);
100765
- const parsed = parseBaichuanWebhookBody(body);
100766
- if (!parsed) {
100767
- log.warn(`Baichuan webhook: unparseable body for camera=${cameraId} (${body.length}B)`);
100768
- res.writeHead(400, { "Content-Type": "text/plain" });
100769
- res.end("Bad Request");
100770
- status.messagesRejected += 1;
100771
- return;
100772
- }
100773
- const event = {
100774
- cameraId,
100775
- event: parsed.kind === "event" ? parsed.event : `cmd_${parsed.cmdId}`,
100776
- ...parsed.kind === "event" && parsed.reason ? { reason: parsed.reason } : {},
100777
- receivedAtMs: Date.now(),
100778
- raw: parsed
100779
- };
100780
- emitBaichuanWebhookEvent(event);
100781
- status.messagesAccepted += 1;
100782
- log.debug(`Baichuan webhook camera=${cameraId} event=${event.event}` + (event.reason ? ` reason=${event.reason}` : ""));
100783
- res.writeHead(200, { "Content-Type": "text/plain" });
100784
- res.end("OK");
100785
- } catch (err) {
100786
- const msg = err instanceof Error ? err.message : String(err);
100787
- status.lastErrorMessage = msg;
100788
- status.messagesRejected += 1;
100789
- log.error(`Baichuan webhook handler error: ${msg}`);
100790
- if (!res.headersSent) {
100791
- res.writeHead(500, { "Content-Type": "text/plain" });
100792
- res.end("Error");
100793
- }
100794
- }
100795
- }
100796
- async function start() {
100797
- if (server) {
100798
- log.debug("startBaichuanWebhookServer called but server already running");
100799
- return;
100800
- }
100801
- enabled = true;
100802
- server = http.createServer((req, res) => {
100803
- handle(req, res);
100804
- });
100805
- await new Promise((resolveListen, reject) => {
100806
- server.once("error", (err) => {
100807
- status.lastErrorMessage = err.message;
100808
- status.running = false;
100809
- reject(err);
100810
- });
100811
- server.listen(config.port, config.bindHost, () => {
100812
- const addr = server.address();
100813
- if (addr && typeof addr === "object") status.port = addr.port;
100814
- status.running = true;
100815
- status.enabled = true;
100816
- status.startedAtMs = Date.now();
100817
- status.lastErrorMessage = void 0;
100818
- log.info(`Baichuan webhook listening on ${config.bindHost}:${status.port} (prefix=${pathPrefix()})`);
100819
- resolveListen();
100820
- });
100821
- });
100822
- }
100823
- async function stop() {
100824
- const srv = server;
100825
- server = void 0;
100826
- if (!srv) {
100827
- status.running = false;
100828
- return;
100829
- }
100830
- await new Promise((resolveStop) => {
100831
- srv.close(() => resolveStop());
100832
- });
100833
- status.running = false;
100834
- log.info("Baichuan webhook server stopped");
100835
- }
100836
- return {
100837
- start,
100838
- stop,
100839
- async restart() {
100840
- await stop();
100841
- await start();
100842
- },
100843
- getStatus() {
100844
- return {
100845
- ...status,
100846
- enabled,
100847
- pathPrefix: pathPrefix()
100848
- };
100849
- },
100850
- updateConfig(next) {
100851
- config = next;
100852
- status.port = next.port;
100853
- status.bindHost = next.bindHost;
100854
- status.pathPrefix = normalizePrefix(next.pathPrefix);
100855
- },
100856
- getCameraWebhookPath(cameraId) {
100857
- return getCameraWebhookPath(cameraId, pathPrefix());
100858
- }
100859
- };
100860
- }
100861
- function buildInitialStatus2(config, enabled) {
100862
- return {
100863
- enabled,
100864
- running: false,
100865
- port: config.port,
100866
- bindHost: config.bindHost,
100867
- pathPrefix: normalizePrefix(config.pathPrefix),
100868
- messagesAccepted: 0,
100869
- messagesRejected: 0,
100870
- startedAtMs: void 0,
100871
- lastErrorMessage: void 0
100872
- };
100873
- }
100874
100689
  //#endregion
100875
100690
  //#region ../types/dist/event-category-BmPm98n0.mjs
100876
100691
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
@@ -106715,7 +106530,7 @@ var ZodIssueCode = {
106715
106530
  var ZodFirstPartyTypeKind;
106716
106531
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
106717
106532
  //#endregion
106718
- //#region ../types/dist/sleep-QsoHs66J.mjs
106533
+ //#region ../types/dist/sleep-hmSP-eJt.mjs
106719
106534
  /**
106720
106535
  * The audio chunk plane's byte format, and the ONE expansion from a coded
106721
106536
  * window to float samples (D455).
@@ -110367,6 +110182,7 @@ var CAP_NAMES = Object.freeze([
110367
110182
  "backup",
110368
110183
  "battery",
110369
110184
  "binary",
110185
+ "board-key-source",
110370
110186
  "brightness",
110371
110187
  "broker",
110372
110188
  "button",
@@ -110488,6 +110304,7 @@ var CAP_NAMES = Object.freeze([
110488
110304
  "privacy-mask",
110489
110305
  "ptz",
110490
110306
  "ptz-autotrack",
110307
+ "pump-cycle",
110491
110308
  "reboot",
110492
110309
  "record-entries",
110493
110310
  "recording",
@@ -111780,18 +111597,6 @@ Object.freeze({
111780
111597
  "path": "pumpRunning",
111781
111598
  "valueType": "boolean"
111782
111599
  },
111783
- {
111784
- "path": "pumpIntervalEnabled",
111785
- "valueType": "boolean"
111786
- },
111787
- {
111788
- "path": "pumpOnTime",
111789
- "valueType": "number"
111790
- },
111791
- {
111792
- "path": "pumpOffTime",
111793
- "valueType": "number"
111794
- },
111795
111600
  {
111796
111601
  "path": "lastDrink",
111797
111602
  "valueType": "number"
@@ -111915,6 +111720,20 @@ Object.freeze({
111915
111720
  "valueType": "number"
111916
111721
  }
111917
111722
  ],
111723
+ "pump-cycle": [
111724
+ {
111725
+ "path": "enabled",
111726
+ "valueType": "boolean"
111727
+ },
111728
+ {
111729
+ "path": "onTime",
111730
+ "valueType": "number"
111731
+ },
111732
+ {
111733
+ "path": "offTime",
111734
+ "valueType": "number"
111735
+ }
111736
+ ],
111918
111737
  "recording-archive": [
111919
111738
  {
111920
111739
  "path": "deviceId",
@@ -114818,6 +114637,16 @@ var MigrationPlanSchema = object({ edits: array(discriminatedUnion("op", [
114818
114637
  precondition: PreconditionSchema
114819
114638
  })
114820
114639
  ])) });
114640
+ /** `JSON.stringify` with object keys sorted, so equal values spell the same. */
114641
+ function canonicalJson(value) {
114642
+ if (Array.isArray(value)) return `[${value.map((item) => canonicalJson(item)).join(",")}]`;
114643
+ 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(",")}}`;
114644
+ return JSON.stringify(value) ?? "null";
114645
+ }
114646
+ /** The precondition string for a value a plan read (`null` when it read nothing). */
114647
+ function preconditionOf(value) {
114648
+ return canonicalJson(value ?? null);
114649
+ }
114821
114650
  /**
114822
114651
  * What a `procedure` body reports. `already-done` means the rerun observed the
114823
114652
  * work finished (a crash between the work and the ledger row, or a manual fix)
@@ -120548,24 +120377,52 @@ var APPLIANCE_CAP_VOCABULARY = {
120548
120377
  args: { on: false }
120549
120378
  },
120550
120379
  {
120551
- id: "petFountain.intervalOn",
120552
- method: "setPumpInterval",
120380
+ id: "petFountain.resetDailyConsumption",
120381
+ method: "resetDailyConsumption",
120382
+ label: t("Clear daily consumption", "Azzera il consumo giornaliero"),
120383
+ phrase: t("Clear the daily water consumption of {device}", "Azzera il consumo giornaliero di acqua di {device}"),
120384
+ icon: "rotate-ccw"
120385
+ }
120386
+ ],
120387
+ state: [{
120388
+ kind: "boolean",
120389
+ field: "empty",
120390
+ label: t("Tank empty", "Serbatoio vuoto"),
120391
+ whenTrue: t("When the tank of {device} is empty", "Quando il serbatoio di {device} è vuoto"),
120392
+ whenFalse: t("When the tank of {device} is refilled", "Quando il serbatoio di {device} viene riempito"),
120393
+ icon: "droplet"
120394
+ }, {
120395
+ kind: "numeric",
120396
+ field: "waterLevel",
120397
+ quantity: t("the water level", "il livello dell’acqua"),
120398
+ label: t("Water level", "Livello dell’acqua"),
120399
+ unit: "%",
120400
+ icon: "droplets"
120401
+ }]
120402
+ },
120403
+ "pump-cycle": {
120404
+ scope: "device",
120405
+ noun: t("Pump cycle", "Ciclo della pompa"),
120406
+ actions: [
120407
+ {
120408
+ id: "pumpCycle.on",
120409
+ method: "setEnabled",
120553
120410
  label: t("Pump cycle on", "Ciclo pompa attivo"),
120554
120411
  phrase: t("Run the pump of {device} on its on/off cycle", "Fai lavorare la pompa di {device} a ciclo acceso/spento"),
120555
120412
  icon: "timer",
120556
120413
  args: { on: true }
120557
120414
  },
120558
120415
  {
120559
- id: "petFountain.intervalOff",
120560
- method: "setPumpInterval",
120416
+ id: "pumpCycle.off",
120417
+ method: "setEnabled",
120561
120418
  label: t("Pump cycle off", "Ciclo pompa spento"),
120562
120419
  phrase: t("Run the pump of {device} continuously", "Fai lavorare la pompa di {device} in continuo"),
120563
120420
  icon: "timer-off",
120564
120421
  args: { on: false }
120565
120422
  },
120566
120423
  {
120567
- id: "petFountain.pumpOnMinutes",
120568
- method: "setPumpOnMinutes",
120424
+ id: "pumpCycle.onMinutes",
120425
+ method: "setOnMinutes",
120569
120426
  label: t("Set pump run time", "Imposta il tempo di lavoro della pompa"),
120570
120427
  phrase: t("Set the pump of {device} to run {minutes} minutes per cycle", "Imposta la pompa di {device} a lavorare {minutes} minuti per ciclo"),
120571
120428
  icon: "timer",
@@ -120581,8 +120438,8 @@ var APPLIANCE_CAP_VOCABULARY = {
120581
120438
  }]
120582
120439
  },
120583
120440
  {
120584
- id: "petFountain.pumpOffMinutes",
120585
- method: "setPumpOffMinutes",
120441
+ id: "pumpCycle.offMinutes",
120442
+ method: "setOffMinutes",
120586
120443
  label: t("Set pump rest time", "Imposta il tempo di riposo della pompa"),
120587
120444
  phrase: t("Set the pump of {device} to rest {minutes} minutes per cycle", "Imposta la pompa di {device} a riposare {minutes} minuti per ciclo"),
120588
120445
  icon: "timer-off",
@@ -120596,29 +120453,15 @@ var APPLIANCE_CAP_VOCABULARY = {
120596
120453
  unit: "min"
120597
120454
  }
120598
120455
  }]
120599
- },
120600
- {
120601
- id: "petFountain.resetDailyConsumption",
120602
- method: "resetDailyConsumption",
120603
- label: t("Clear daily consumption", "Azzera il consumo giornaliero"),
120604
- phrase: t("Clear the daily water consumption of {device}", "Azzera il consumo giornaliero di acqua di {device}"),
120605
- icon: "rotate-ccw"
120606
120456
  }
120607
120457
  ],
120608
120458
  state: [{
120609
120459
  kind: "boolean",
120610
- field: "empty",
120611
- label: t("Tank empty", "Serbatoio vuoto"),
120612
- whenTrue: t("When the tank of {device} is empty", "Quando il serbatoio di {device} è vuoto"),
120613
- whenFalse: t("When the tank of {device} is refilled", "Quando il serbatoio di {device} viene riempito"),
120614
- icon: "droplet"
120615
- }, {
120616
- kind: "numeric",
120617
- field: "waterLevel",
120618
- quantity: t("the water level", "il livello dell’acqua"),
120619
- label: t("Water level", "Livello dell’acqua"),
120620
- unit: "%",
120621
- icon: "droplets"
120460
+ field: "enabled",
120461
+ label: t("Pump cycle", "Ciclo pompa"),
120462
+ whenTrue: t("When {device} starts running its pump on a cycle", "Quando {device} inizia a far lavorare la pompa a ciclo"),
120463
+ whenFalse: t("When {device} runs its pump continuously", "Quando {device} fa lavorare la pompa in continuo"),
120464
+ icon: "timer"
120622
120465
  }]
120623
120466
  },
120624
120467
  "media-player": {
@@ -122511,6 +122354,7 @@ var SYSTEM_CAP_VOCABULARY = {
122511
122354
  "audio-analyzer": system(PIPELINE),
122512
122355
  "audio-codec": system(INTERNAL),
122513
122356
  "auth-provider": system(IDENTITY),
122357
+ "board-key-source": system(INTERNAL),
122514
122358
  backup: system("system backups — run and restored from the Backups page"),
122515
122359
  broker: system("message-broker connections — configured, not actuated"),
122516
122360
  "calendar-source": system(INTERNAL),
@@ -147420,6 +147264,49 @@ var binaryCapability = {
147420
147264
  volatileStateFields: ["lastChangedAt"]
147421
147265
  };
147422
147266
  /**
147267
+ * Why a board key could not be read. Typed so a caller reports the cause and
147268
+ * never treats "could not read" as "has no key" (D49).
147269
+ */
147270
+ var BoardKeyFailureReasonSchema = _enum([
147271
+ "unknown-source",
147272
+ "not-admin",
147273
+ "no-builder",
147274
+ "no-ingress-url",
147275
+ "unreachable",
147276
+ "unknown-board",
147277
+ "no-encryption-key",
147278
+ "unreadable-config"
147279
+ ]);
147280
+ var BoardKeySourceSchema = object({
147281
+ sourceId: string(),
147282
+ name: string()
147283
+ });
147284
+ var BoardKeyBoardSchema = object({
147285
+ /** The builder's file name, e.g. `kitchen.yaml`: the handle `getBoardKey` takes. */
147286
+ configuration: string(),
147287
+ /** The node name the builder gives the board. */
147288
+ name: string(),
147289
+ /** The address the builder knows the board by, when it has one. */
147290
+ address: string().nullable()
147291
+ });
147292
+ var BoardKeyFailureSchema = object({
147293
+ ok: literal(false),
147294
+ reason: BoardKeyFailureReasonSchema,
147295
+ detail: string().optional()
147296
+ });
147297
+ var ListBoardsResultSchema = discriminatedUnion("ok", [object({
147298
+ ok: literal(true),
147299
+ boards: array(BoardKeyBoardSchema).readonly()
147300
+ }), BoardKeyFailureSchema]);
147301
+ var GetBoardKeyResultSchema = discriminatedUnion("ok", [object({
147302
+ ok: literal(true),
147303
+ key: string()
147304
+ }), BoardKeyFailureSchema]);
147305
+ method(_void(), array(BoardKeySourceSchema).readonly(), { auth: "admin" }), method(object({ sourceId: string().min(1) }), ListBoardsResultSchema, { auth: "admin" }), method(object({
147306
+ sourceId: string().min(1),
147307
+ configuration: string().min(1)
147308
+ }), GetBoardKeyResultSchema, { auth: "admin" });
147309
+ /**
147423
147310
  * Dimmable-light brightness control. Co-exists with `switch` on the
147424
147311
  * same device — the switch toggles on/off, this cap sets the level
147425
147312
  * applied when the light is on. Drivers map their per-vendor dim
@@ -154057,7 +153944,7 @@ var DishwasherStatusSchema = object({
154057
153944
  /** Ms epoch of the last value received from the appliance; 0 = never. */
154058
153945
  lastFetchedAt: number()
154059
153946
  });
154060
- var DeviceIdSchema$1 = number().int().nonnegative();
153947
+ var DeviceIdSchema$2 = number().int().nonnegative();
154061
153948
  /** A value a program option or setting can take. */
154062
153949
  var DishwasherValueSchema = union([
154063
153950
  boolean(),
@@ -154132,77 +154019,77 @@ var dishwasherCapability = {
154132
154019
  * option feature keys; each is checked against the program's constraints.
154133
154020
  */
154134
154021
  start: method(object({
154135
- deviceId: DeviceIdSchema$1,
154022
+ deviceId: DeviceIdSchema$2,
154136
154023
  program: string().min(1).optional(),
154137
154024
  options: record(string(), DishwasherValueSchema).optional()
154138
154025
  }), _void(), {
154139
154026
  kind: "mutation",
154140
154027
  auth: "protected"
154141
154028
  }),
154142
- pause: method(object({ deviceId: DeviceIdSchema$1 }), _void(), {
154029
+ pause: method(object({ deviceId: DeviceIdSchema$2 }), _void(), {
154143
154030
  kind: "mutation",
154144
154031
  auth: "protected"
154145
154032
  }),
154146
- resume: method(object({ deviceId: DeviceIdSchema$1 }), _void(), {
154033
+ resume: method(object({ deviceId: DeviceIdSchema$2 }), _void(), {
154147
154034
  kind: "mutation",
154148
154035
  auth: "protected"
154149
154036
  }),
154150
- stop: method(object({ deviceId: DeviceIdSchema$1 }), _void(), {
154037
+ stop: method(object({ deviceId: DeviceIdSchema$2 }), _void(), {
154151
154038
  kind: "mutation",
154152
154039
  auth: "protected"
154153
154040
  }),
154154
154041
  /** Select (not start) a program; refused when the appliance does not offer it. */
154155
154042
  setProgram: method(object({
154156
- deviceId: DeviceIdSchema$1,
154043
+ deviceId: DeviceIdSchema$2,
154157
154044
  program: string().min(1)
154158
154045
  }), _void(), {
154159
154046
  kind: "mutation",
154160
154047
  auth: "protected"
154161
154048
  }),
154162
154049
  setPower: method(object({
154163
- deviceId: DeviceIdSchema$1,
154050
+ deviceId: DeviceIdSchema$2,
154164
154051
  on: boolean()
154165
154052
  }), _void(), {
154166
154053
  kind: "mutation",
154167
154054
  auth: "protected"
154168
154055
  }),
154169
154056
  setHalfLoad: method(object({
154170
- deviceId: DeviceIdSchema$1,
154057
+ deviceId: DeviceIdSchema$2,
154171
154058
  on: boolean()
154172
154059
  }), _void(), {
154173
154060
  kind: "mutation",
154174
154061
  auth: "protected"
154175
154062
  }),
154176
154063
  setHygienePlus: method(object({
154177
- deviceId: DeviceIdSchema$1,
154064
+ deviceId: DeviceIdSchema$2,
154178
154065
  on: boolean()
154179
154066
  }), _void(), {
154180
154067
  kind: "mutation",
154181
154068
  auth: "protected"
154182
154069
  }),
154183
154070
  setExtraDry: method(object({
154184
- deviceId: DeviceIdSchema$1,
154071
+ deviceId: DeviceIdSchema$2,
154185
154072
  on: boolean()
154186
154073
  }), _void(), {
154187
154074
  kind: "mutation",
154188
154075
  auth: "protected"
154189
154076
  }),
154190
154077
  setIntensiveZone: method(object({
154191
- deviceId: DeviceIdSchema$1,
154078
+ deviceId: DeviceIdSchema$2,
154192
154079
  on: boolean()
154193
154080
  }), _void(), {
154194
154081
  kind: "mutation",
154195
154082
  auth: "protected"
154196
154083
  }),
154197
154084
  setVarioSpeedPlus: method(object({
154198
- deviceId: DeviceIdSchema$1,
154085
+ deviceId: DeviceIdSchema$2,
154199
154086
  on: boolean()
154200
154087
  }), _void(), {
154201
154088
  kind: "mutation",
154202
154089
  auth: "protected"
154203
154090
  }),
154204
154091
  setChildLock: method(object({
154205
- deviceId: DeviceIdSchema$1,
154092
+ deviceId: DeviceIdSchema$2,
154206
154093
  on: boolean()
154207
154094
  }), _void(), {
154208
154095
  kind: "mutation",
@@ -154214,7 +154101,7 @@ var dishwasherCapability = {
154214
154101
  * its declared range / members.
154215
154102
  */
154216
154103
  setOption: method(object({
154217
- deviceId: DeviceIdSchema$1,
154104
+ deviceId: DeviceIdSchema$2,
154218
154105
  key: string().min(1),
154219
154106
  value: DishwasherValueSchema
154220
154107
  }), _void(), {
@@ -154222,7 +154109,7 @@ var dishwasherCapability = {
154222
154109
  auth: "protected"
154223
154110
  }),
154224
154111
  /** What this appliance can do, from its own description — the UI is derived from it. */
154225
- getOptions: method(object({ deviceId: DeviceIdSchema$1 }), DishwasherOptionsSchema, { auth: "protected" })
154112
+ getOptions: method(object({ deviceId: DeviceIdSchema$2 }), DishwasherOptionsSchema, { auth: "protected" })
154226
154113
  },
154227
154114
  status: {
154228
154115
  schema: DishwasherStatusSchema,
@@ -169651,7 +169538,7 @@ var petFeederCapability = {
169651
169538
  /**
169652
169539
  * Pet water-fountain cap. Models the telemetry and the control surface of a
169653
169540
  * circulating drinking fountain: how much water is in the tank, how much the
169654
- * pet drank, whether the pump runs and on what duty cycle.
169541
+ * pet drank, whether the pump runs.
169655
169542
  *
169656
169543
  * It has no native vendor provider of its own: a fountain reaches CamStack as
169657
169544
  * a container of loose entities (a pump switch, two number boxes, a "filter
@@ -169667,8 +169554,10 @@ var petFeederCapability = {
169667
169554
  * never here. Every reading may be `null`: a source that is offline or does
169668
169555
  * not report is unknown, never zero.
169669
169556
  *
169670
- * The filter is not a field of this cap: it is an item of the `consumables`
169671
- * cap on the same device (kind `wear`), reset by the same forward mechanism.
169557
+ * The pump's on/off duty cycle is model-specific and lives in the optional
169558
+ * `pump-cycle` cap. The filter, the cleaning and the water change are not
169559
+ * fields of this cap: they are items of the `consumables` cap on the same
169560
+ * device (kind `wear`), reset by the same forward mechanism.
169672
169561
  */
169673
169562
  var PetFountainStatusSchema = object({
169674
169563
  /** Water in the tank, 0..100 (%). Null while the source has not reported. */
@@ -169679,27 +169568,20 @@ var PetFountainStatusSchema = object({
169679
169568
  empty: boolean().nullable(),
169680
169569
  /** True while the pump is running. Null while unknown. */
169681
169570
  pumpRunning: boolean().nullable(),
169682
- /** True when the pump works on an on/off duty cycle rather than continuously. */
169683
- pumpIntervalEnabled: boolean().nullable(),
169684
- /** How long the pump runs per cycle, in seconds. */
169685
- pumpOnTime: number().min(0).nullable().meta(readingMeta("duration")),
169686
- /** How long the pump rests per cycle, in seconds. */
169687
- pumpOffTime: number().min(0).nullable().meta(readingMeta("duration")),
169688
169571
  /** Water drunk in the last drink, in litres. */
169689
169572
  lastDrink: number().min(0).nullable().meta(readingMeta("volume")),
169690
169573
  /** Water drunk in the current hour, in litres. */
169691
169574
  currentHourDrink: number().min(0).nullable().meta(readingMeta("volume")),
169692
169575
  /** Water drunk over the last 24 hours (rolling), in litres. */
169693
169576
  last24hDrink: number().min(0).nullable().meta(readingMeta("volume")),
169694
- /** Water drunk since local midnight, in litres. */
169577
+ /** Water drunk since local midnight, in litres. Null when the fountain has no such sensor. */
169695
169578
  todayDrink: number().min(0).nullable().meta(readingMeta("volume")),
169696
169579
  /** Decoded fault message. Null means healthy; a non-null string is an active fault. */
169697
169580
  error: string().nullable(),
169698
169581
  /** Ms epoch when the slice was last refreshed. */
169699
169582
  lastFetchedAt: number()
169700
169583
  });
169701
- var DeviceIdSchema = number().int().nonnegative();
169702
- var MinutesSchema = number().int().min(0);
169584
+ var DeviceIdSchema$1 = number().int().nonnegative();
169703
169585
  var petFountainCapability = {
169704
169586
  name: "pet-fountain",
169705
169587
  scope: "device",
@@ -169709,38 +169591,14 @@ var petFountainCapability = {
169709
169591
  methods: {
169710
169592
  /** Start or stop the pump. */
169711
169593
  setPump: method(object({
169712
- deviceId: DeviceIdSchema,
169713
- on: boolean()
169714
- }), _void(), {
169715
- kind: "mutation",
169716
- auth: "protected"
169717
- }),
169718
- /** Switch the on/off duty cycle on or off (off = the pump runs continuously). */
169719
- setPumpInterval: method(object({
169720
- deviceId: DeviceIdSchema,
169594
+ deviceId: DeviceIdSchema$1,
169721
169595
  on: boolean()
169722
169596
  }), _void(), {
169723
169597
  kind: "mutation",
169724
169598
  auth: "protected"
169725
169599
  }),
169726
- /** Set how many minutes the pump runs per cycle. */
169727
- setPumpOnMinutes: method(object({
169728
- deviceId: DeviceIdSchema,
169729
- minutes: MinutesSchema
169730
- }), _void(), {
169731
- kind: "mutation",
169732
- auth: "protected"
169733
- }),
169734
- /** Set how many minutes the pump rests per cycle. */
169735
- setPumpOffMinutes: method(object({
169736
- deviceId: DeviceIdSchema,
169737
- minutes: MinutesSchema
169738
- }), _void(), {
169739
- kind: "mutation",
169740
- auth: "protected"
169741
- }),
169742
169600
  /** Clear the rolling daily consumption counters on the device. */
169743
- resetDailyConsumption: method(object({ deviceId: DeviceIdSchema }), _void(), {
169601
+ resetDailyConsumption: method(object({ deviceId: DeviceIdSchema$1 }), _void(), {
169744
169602
  kind: "mutation",
169745
169603
  auth: "protected"
169746
169604
  })
@@ -172186,6 +172044,79 @@ var ptzAutotrackCapability = {
172186
172044
  history: "none"
172187
172045
  };
172188
172046
  /**
172047
+ * The on/off duty cycle of a pet fountain's pump. Some models run the pump
172048
+ * continuously and have nothing to configure; others can alternate a run time
172049
+ * and a rest time. That is model-specific, so it is its own optional cap on the
172050
+ * fountain device: a fountain without cycle entities simply never composes it
172051
+ * (planning#366), and the `pet-fountain` cap stays the model-independent core.
172052
+ *
172053
+ * Like `pet-fountain` it has no native vendor provider: an operator BUILDS the
172054
+ * cycle from loose entities (a cycle switch and two number boxes) and every
172055
+ * method is a forward to one source entity (D659, D967). Durations are stored
172056
+ * in SECONDS (D944); a number box that cannot be read back leaves them `null`
172057
+ * — unknown, never zero (D393).
172058
+ */
172059
+ var PumpCycleStatusSchema = object({
172060
+ /** True when the pump works on the on/off cycle, false when it runs continuously. Null while unknown. */
172061
+ enabled: boolean().nullable(),
172062
+ /** How long the pump runs per cycle, in seconds. Null while unknown. */
172063
+ onTime: number().min(0).nullable().meta(readingMeta("duration")),
172064
+ /** How long the pump rests per cycle, in seconds. Null while unknown. */
172065
+ offTime: number().min(0).nullable().meta(readingMeta("duration")),
172066
+ /** Ms epoch when the slice was last refreshed. */
172067
+ lastFetchedAt: number()
172068
+ });
172069
+ var DeviceIdSchema = number().int().nonnegative();
172070
+ var MinutesSchema = number().int().min(0);
172071
+ var pumpCycleCapability = {
172072
+ name: "pump-cycle",
172073
+ scope: "device",
172074
+ deviceNative: true,
172075
+ mode: "singleton",
172076
+ deviceTypes: [DeviceType.PetFountain],
172077
+ methods: {
172078
+ /** Switch the on/off duty cycle on or off (off = the pump runs continuously). */
172079
+ setEnabled: method(object({
172080
+ deviceId: DeviceIdSchema,
172081
+ on: boolean()
172082
+ }), _void(), {
172083
+ kind: "mutation",
172084
+ auth: "protected"
172085
+ }),
172086
+ /** Set how many minutes the pump runs per cycle. */
172087
+ setOnMinutes: method(object({
172088
+ deviceId: DeviceIdSchema,
172089
+ minutes: MinutesSchema
172090
+ }), _void(), {
172091
+ kind: "mutation",
172092
+ auth: "protected"
172093
+ }),
172094
+ /** Set how many minutes the pump rests per cycle. */
172095
+ setOffMinutes: method(object({
172096
+ deviceId: DeviceIdSchema,
172097
+ minutes: MinutesSchema
172098
+ }), _void(), {
172099
+ kind: "mutation",
172100
+ auth: "protected"
172101
+ })
172102
+ },
172103
+ status: {
172104
+ schema: PumpCycleStatusSchema,
172105
+ kind: "push"
172106
+ },
172107
+ /** Runtime-state slice — mirrored by the kernel; the hero card reads it whole. */
172108
+ runtimeState: PumpCycleStatusSchema,
172109
+ /**
172110
+ * Runtime-state durability: **session** — live appliance state re-published on connect.
172111
+ *
172112
+ * See `RuntimeStateDurability`. Enforced by
172113
+ * `scripts/check-runtime-state-durability.ts`.
172114
+ */
172115
+ durability: "session",
172116
+ /** History (D850): a duty-cycle setting is not a series worth recording. */
172117
+ history: "none"
172118
+ };
172119
+ /**
172189
172120
  * reboot — device-scoped capability for "soft" device reboots (firmware
172190
172121
  * reboot via vendor protocol; cameras, NVRs, doorbells). Surfaces a
172191
172122
  * single mutation so the UI can offer a confirm-and-reboot button for
@@ -182465,6 +182396,7 @@ var DEVICE_LOCAL_STATE_CAPS = {
182465
182396
  pressureSensor: pressureSensorCapability,
182466
182397
  privacyMask: privacyMaskCapability,
182467
182398
  ptzAutotrack: ptzAutotrackCapability,
182399
+ pumpCycle: pumpCycleCapability,
182468
182400
  recordingOnboard: recordingOnboardCapability,
182469
182401
  recordingSignal: recordingSignalCapability,
182470
182402
  runningState: runningStateCapability,
@@ -184649,6 +184581,27 @@ Object.freeze({
184649
184581
  access: "view",
184650
184582
  role: "viewer"
184651
184583
  },
184584
+ "boardKeySource.getBoardKey": {
184585
+ capName: "board-key-source",
184586
+ capScope: "system",
184587
+ addonId: null,
184588
+ access: "view",
184589
+ role: "admin"
184590
+ },
184591
+ "boardKeySource.listBoards": {
184592
+ capName: "board-key-source",
184593
+ capScope: "system",
184594
+ addonId: null,
184595
+ access: "view",
184596
+ role: "admin"
184597
+ },
184598
+ "boardKeySource.listSources": {
184599
+ capName: "board-key-source",
184600
+ capScope: "system",
184601
+ addonId: null,
184602
+ access: "view",
184603
+ role: "admin"
184604
+ },
184652
184605
  "brightness.getStatus": {
184653
184606
  capName: "brightness",
184654
184607
  capScope: "device",
@@ -189472,27 +189425,6 @@ Object.freeze({
189472
189425
  access: "create",
189473
189426
  role: "viewer"
189474
189427
  },
189475
- "petFountain.setPumpInterval": {
189476
- capName: "pet-fountain",
189477
- capScope: "device",
189478
- addonId: null,
189479
- access: "create",
189480
- role: "viewer"
189481
- },
189482
- "petFountain.setPumpOffMinutes": {
189483
- capName: "pet-fountain",
189484
- capScope: "device",
189485
- addonId: null,
189486
- access: "create",
189487
- role: "viewer"
189488
- },
189489
- "petFountain.setPumpOnMinutes": {
189490
- capName: "pet-fountain",
189491
- capScope: "device",
189492
- addonId: null,
189493
- access: "create",
189494
- role: "viewer"
189495
- },
189496
189428
  "pipelineAnalytics.addRetrainFrameFromRecording": {
189497
189429
  capName: "pipeline-analytics",
189498
189430
  capScope: "device",
@@ -191271,6 +191203,34 @@ Object.freeze({
191271
191203
  access: "create",
191272
191204
  role: "admin"
191273
191205
  },
191206
+ "pumpCycle.getStatus": {
191207
+ capName: "pump-cycle",
191208
+ capScope: "device",
191209
+ addonId: null,
191210
+ access: "view",
191211
+ role: "viewer"
191212
+ },
191213
+ "pumpCycle.setEnabled": {
191214
+ capName: "pump-cycle",
191215
+ capScope: "device",
191216
+ addonId: null,
191217
+ access: "create",
191218
+ role: "viewer"
191219
+ },
191220
+ "pumpCycle.setOffMinutes": {
191221
+ capName: "pump-cycle",
191222
+ capScope: "device",
191223
+ addonId: null,
191224
+ access: "create",
191225
+ role: "viewer"
191226
+ },
191227
+ "pumpCycle.setOnMinutes": {
191228
+ capName: "pump-cycle",
191229
+ capScope: "device",
191230
+ addonId: null,
191231
+ access: "create",
191232
+ role: "viewer"
191233
+ },
191274
191234
  "reboot.reboot": {
191275
191235
  capName: "reboot",
191276
191236
  capScope: "device",
@@ -195967,21 +195927,6 @@ Object.freeze({
195967
195927
  form: "single",
195968
195928
  optional: false
195969
195929
  }],
195970
- "petFountain.setPumpInterval": [{
195971
- name: "deviceId",
195972
- form: "single",
195973
- optional: false
195974
- }],
195975
- "petFountain.setPumpOffMinutes": [{
195976
- name: "deviceId",
195977
- form: "single",
195978
- optional: false
195979
- }],
195980
- "petFountain.setPumpOnMinutes": [{
195981
- name: "deviceId",
195982
- form: "single",
195983
- optional: false
195984
- }],
195985
195930
  "pipelineAnalytics.addRetrainFrameFromRecording": [{
195986
195931
  name: "deviceId",
195987
195932
  form: "single",
@@ -196617,6 +196562,26 @@ Object.freeze({
196617
196562
  form: "single",
196618
196563
  optional: false
196619
196564
  }],
196565
+ "pumpCycle.getStatus": [{
196566
+ name: "deviceId",
196567
+ form: "single",
196568
+ optional: false
196569
+ }],
196570
+ "pumpCycle.setEnabled": [{
196571
+ name: "deviceId",
196572
+ form: "single",
196573
+ optional: false
196574
+ }],
196575
+ "pumpCycle.setOffMinutes": [{
196576
+ name: "deviceId",
196577
+ form: "single",
196578
+ optional: false
196579
+ }],
196580
+ "pumpCycle.setOnMinutes": [{
196581
+ name: "deviceId",
196582
+ form: "single",
196583
+ optional: false
196584
+ }],
196620
196585
  "reboot.reboot": [{
196621
196586
  name: "deviceId",
196622
196587
  form: "single",
@@ -199228,7 +199193,7 @@ async function resolvePinnedFfmpegSpawner(seam, resolveFfmpeg, options) {
199228
199193
  * provider's `addonId` field manually — both must match for the device-
199229
199194
  * manager + alert center to correlate events back to the provider.
199230
199195
  */
199231
- var REOLINK_ADDON_ID = "provider-reolink";
199196
+ var REOLINK_ADDON_ID$1 = "provider-reolink";
199232
199197
  /**
199233
199198
  * Map nodelink ReolinkSimpleEvent.type values to the loose
199234
199199
  * CameraNativeDetection class strings the rest of camstack consumes.
@@ -199729,6 +199694,12 @@ var reolinkCameraSchema = object({
199729
199694
  "email"
199730
199695
  ]).optional(),
199731
199696
  /**
199697
+ * The URL generation this battery camera's HaCfg webhook points at (absent = the retired private
199698
+ * :9081 listener). Written once the camera has been moved to the addon's data-plane URL, so the
199699
+ * move happens on the first Baichuan contact after the upgrade and never again.
199700
+ */
199701
+ webhookUrlGeneration: number().int().optional(),
199702
+ /**
199732
199703
  * On-camera motion detection master switch + sensitivity.
199733
199704
  * Pushed via `setMotionAlarm` (cmdId=47). The Reolink mobile
199734
199705
  * app + camera web UI use 1..50 (1 = least sensitive,
@@ -203371,7 +203342,7 @@ function createReolinkVideoclipsProvider(deps) {
203371
203342
  if (device === null) return [];
203372
203343
  return [{
203373
203344
  source: CLIP_SOURCE_ONBOARD,
203374
- addonId: REOLINK_ADDON_ID,
203345
+ addonId: REOLINK_ADDON_ID$1,
203375
203346
  label: "Camera storage",
203376
203347
  availability: await resolveAvailability(device)
203377
203348
  }];
@@ -204538,10 +204509,10 @@ function createClipService(deps) {
204538
204509
  cache,
204539
204510
  resolveDevice: deps.resolveDevice,
204540
204511
  hasVouchedThumbnail: (deviceId, clipKey) => thumbs.has(deviceId, clipKey),
204541
- thumbnailUrl: (deviceId, clipKey) => `/addon/${REOLINK_ADDON_ID}/${CLIP_THUMB_PREFIX}/${deviceId}/${clipKey}`,
204542
- thumbnailMintUrl: (deviceId, clipKey) => `/addon/${REOLINK_ADDON_ID}/${CLIP_THUMB_PREFIX}/${deviceId}/${clipKey}`,
204512
+ thumbnailUrl: (deviceId, clipKey) => `/addon/${REOLINK_ADDON_ID$1}/${CLIP_THUMB_PREFIX}/${deviceId}/${clipKey}`,
204513
+ thumbnailMintUrl: (deviceId, clipKey) => `/addon/${REOLINK_ADDON_ID$1}/${CLIP_THUMB_PREFIX}/${deviceId}/${clipKey}`,
204543
204514
  preparePlayback: async ({ device, clipKey, profile }) => ({
204544
- playbackUrl: `/addon/${REOLINK_ADDON_ID}/${CLIP_MEDIA_PREFIX}/${device.deviceId}/${clipKey}.mp4`,
204515
+ playbackUrl: `/addon/${REOLINK_ADDON_ID$1}/${CLIP_MEDIA_PREFIX}/${device.deviceId}/${clipKey}.mp4`,
204545
204516
  served: profile === "high" ? "high" : "low"
204546
204517
  }),
204547
204518
  prepareBytes,
@@ -204587,7 +204558,7 @@ function createClipService(deps) {
204587
204558
  deviceId,
204588
204559
  since: until - CLIP_CATALOG_REFRESH_WINDOW_MS,
204589
204560
  until,
204590
- provider: REOLINK_ADDON_ID
204561
+ provider: REOLINK_ADDON_ID$1
204591
204562
  });
204592
204563
  },
204593
204564
  hostBusy: (hostKey) => queue.busy(hostKey),
@@ -205334,6 +205305,52 @@ var IntercomFailureReport = class {
205334
205305
  /** The process-wide instance every camera in this addon notes into. */
205335
205306
  var intercomFailureReport = new IntercomFailureReport();
205336
205307
  //#endregion
205308
+ //#region src/baichuan-webhook-shared.ts
205309
+ /** The data-plane prefix this addon serves the camera push on: `/addon/<addonId>/webhook/<stableId>`. */
205310
+ var BAICHUAN_WEBHOOK_PREFIX = "webhook";
205311
+ /** The addon id the hub's reverse proxy mounts the plane under. */
205312
+ var BAICHUAN_WEBHOOK_ADDON_ID = "provider-reolink";
205313
+ /** The hub's plain-HTTP LAN listener, when the address book does not say (a hub older than its `listenerOrigins`). */
205314
+ var DEFAULT_LAN_HTTP_PORT = 4480;
205315
+ /**
205316
+ * Absolute URL a camera should POST HaCfg events to: the addon's data plane on the hub's plain-HTTP
205317
+ * LAN listener. HTTP, not HTTPS: the firmware refuses the hub's self-signed certificate.
205318
+ */
205319
+ function buildBaichuanWebhookUrl(params) {
205320
+ const id = encodeURIComponent(params.stableId);
205321
+ return `http://${params.host}:${String(params.port)}/addon/${BAICHUAN_WEBHOOK_ADDON_ID}/${BAICHUAN_WEBHOOK_PREFIX}/${id}`;
205322
+ }
205323
+ function hostOf(url) {
205324
+ if (url === null || url.trim().length === 0) return null;
205325
+ try {
205326
+ const host = new URL(url).hostname;
205327
+ return host.length > 0 ? host : null;
205328
+ } catch {
205329
+ return null;
205330
+ }
205331
+ }
205332
+ /**
205333
+ * The host is the `internal` role of the palette (D729) and the port is the plain-HTTP origin the
205334
+ * hub lists for that host. Throws the reason, in the operator's words, when there is none: there is
205335
+ * no fallback host and never an HTTPS one.
205336
+ */
205337
+ function resolveWebhookEndpoint(book) {
205338
+ const host = hostOf(book.roles.internal.resolved);
205339
+ if (host === null) throw new Error("The hub has no internal address (System → Network)");
205340
+ if (book.listenerOrigins === void 0) return {
205341
+ host,
205342
+ port: DEFAULT_LAN_HTTP_PORT
205343
+ };
205344
+ for (const origin of book.listenerOrigins) try {
205345
+ const url = new URL(origin);
205346
+ if (url.protocol === "http:" && url.hostname === host) return {
205347
+ host,
205348
+ port: url.port.length > 0 ? Number(url.port) : 80
205349
+ };
205350
+ } catch {}
205351
+ throw new Error("The hub has no plain-HTTP LAN listener: the camera webhook cannot use HTTPS. Turn on LAN HTTP in System → Network");
205352
+ }
205353
+ //#endregion
205337
205354
  //#region src/battery-charging.ts
205338
205355
  /**
205339
205356
  * From `adapterStatus` + `chargeStatus` to the power source.
@@ -205587,912 +205604,6 @@ var BatteryHourlyRefresh = class {
205587
205604
  }
205588
205605
  };
205589
205606
  //#endregion
205590
- //#region src/hub-connection.ts
205591
- /** Baichuan control port a Reolink NVR / Home Hub listens on. */
205592
- var REOLINK_HUB_DEFAULT_PORT = 9e3;
205593
- /** Settings key that names the section holding the hub connection form. */
205594
- var HUB_CONNECTION_SECTION_ID = "connection";
205595
- var HUB_DEBUG_SECTION_ID = "hub-debug";
205596
- /** A blank form field is "not provided", never an invalid port. */
205597
- function blankToUndefined(value) {
205598
- return value === "" || value === null ? void 0 : value;
205599
- }
205600
- /**
205601
- * The connection of ONE Reolink NVR / Home Hub: the settings of an INTEGRATION (D985). The
205602
- * password is a schema `password` field kept in the keychain — never in env, argv or logs.
205603
- */
205604
- var reolinkHubConnectionSchema = object({
205605
- host: string().trim().min(1).describe("NVR / Home Hub IP or hostname"),
205606
- port: preprocess(blankToUndefined, number().int().min(1).max(65535).default(REOLINK_HUB_DEFAULT_PORT)),
205607
- /** The RTSP relay port the cameras dial for their streams (not the Baichuan `port`). */
205608
- rtspPort: preprocess(blankToUndefined, number().int().min(1).max(65535).default(554)),
205609
- username: preprocess(blankToUndefined, string().trim().min(1).default("admin")),
205610
- password: string().min(1).describe("NVR / Home Hub password").meta({ secret: true }),
205611
- /** Hubs are TCP-only on supported firmwares — kept for symmetry with a camera. */
205612
- transport: _enum(["tcp", "udp"]).default("tcp"),
205613
- /** UID of a hub that was paired by it; unused for a plain TCP login. */
205614
- uid: string().optional(),
205615
- debugGeneral: boolean().default(false),
205616
- debugSocketLogs: array(ReolinkSocketDebugFlagSchema).default([])
205617
- });
205618
- function stringValue(value) {
205619
- return typeof value === "string" ? value.trim() : "";
205620
- }
205621
- function readHubConnection(settings) {
205622
- if (stringValue(settings["host"]).length === 0) return { kind: "no-host" };
205623
- const password = settings["password"];
205624
- if (isSecretMarkerLike(password)) return { kind: "password-locked" };
205625
- if (typeof password !== "string" || password.length === 0) return { kind: "no-password" };
205626
- const parsed = reolinkHubConnectionSchema.safeParse(settings);
205627
- if (!parsed.success) return { kind: "no-host" };
205628
- return {
205629
- kind: "ok",
205630
- connection: parsed.data
205631
- };
205632
- }
205633
- /** Two reads that would dial the same way. */
205634
- function sameHubConnection(a, b) {
205635
- 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;
205636
- }
205637
- /**
205638
- * The connection + debug sections of the integration form. Fields are NOT `required` here:
205639
- * the same schema is the addon's own settings page, where a required host would block saving
205640
- * an unrelated email-push change on the default integration. A hub integration missing its
205641
- * host or password is named by the session at reconcile time instead.
205642
- */
205643
- function buildHubConnectionSections() {
205644
- return { sections: [{
205645
- id: HUB_CONNECTION_SECTION_ID,
205646
- title: "NVR / Home Hub connection",
205647
- titleKey: "provider-reolink:connection.s_connection.title",
205648
- description: "Only for an integration that is one NVR or Home Hub. Its channels are adopted as cameras that share this connection.",
205649
- descriptionKey: "provider-reolink:connection.s_connection.description",
205650
- columns: 2,
205651
- fields: [
205652
- {
205653
- type: "text",
205654
- key: "host",
205655
- label: "Hub IP",
205656
- labelKey: "provider-reolink:connection.s_connection.host.label",
205657
- placeholder: "192.168.10.100"
205658
- },
205659
- {
205660
- type: "number",
205661
- key: "port",
205662
- label: "Port",
205663
- labelKey: "provider-reolink:connection.s_connection.port.label",
205664
- default: REOLINK_HUB_DEFAULT_PORT,
205665
- min: 1,
205666
- max: 65535,
205667
- step: 1
205668
- },
205669
- {
205670
- type: "number",
205671
- key: "rtspPort",
205672
- label: "RTSP port",
205673
- labelKey: "provider-reolink:connection.s_connection.rtspPort.label",
205674
- default: 554,
205675
- min: 1,
205676
- max: 65535,
205677
- step: 1
205678
- },
205679
- {
205680
- type: "text",
205681
- key: "username",
205682
- label: "Username",
205683
- labelKey: "provider-reolink:connection.s_connection.username.label",
205684
- default: "admin"
205685
- },
205686
- {
205687
- type: "password",
205688
- key: "password",
205689
- label: "Password",
205690
- labelKey: "provider-reolink:connection.s_connection.password.label",
205691
- showToggle: true
205692
- }
205693
- ]
205694
- }, {
205695
- id: HUB_DEBUG_SECTION_ID,
205696
- title: "NVR / Home Hub debug",
205697
- titleKey: "provider-reolink:connection.s_hub-debug.title",
205698
- description: "Debug logging adds log volume; use it temporarily.",
205699
- descriptionKey: "provider-reolink:connection.s_hub-debug.description",
205700
- columns: 1,
205701
- fields: [{
205702
- type: "boolean",
205703
- key: "debugGeneral",
205704
- label: "General debug logs",
205705
- labelKey: "provider-reolink:connection.s_hub-debug.debugGeneral.label",
205706
- default: false,
205707
- style: "switch"
205708
- }, {
205709
- type: "multiselect",
205710
- key: "debugSocketLogs",
205711
- label: "Socket trace categories",
205712
- labelKey: "provider-reolink:connection.s_hub-debug.debugSocketLogs.label",
205713
- description: "Each area adds log volume.",
205714
- descriptionKey: "provider-reolink:connection.s_hub-debug.debugSocketLogs.description",
205715
- default: [],
205716
- options: [{
205717
- value: "traceEvents",
205718
- label: "Events (cmd 33 — AlarmEventList push)",
205719
- labelKey: "provider-reolink:connection.s_hub-debug.debugSocketLogs.o_traceEvents.label"
205720
- }]
205721
- }]
205722
- }] };
205723
- }
205724
- /**
205725
- * `true` for an integration that IS one NVR / Home Hub: it was set up from a hub candidate, was
205726
- * migrated from a Hub device, or carries a host. The default integration (standalone cameras,
205727
- * email push, webhook, clips) has none of the three.
205728
- */
205729
- function isHubIntegration(evidence) {
205730
- if (evidence.originStableId?.startsWith("reolink-hub-") === true) return true;
205731
- if (evidence.info["migratedFromHubDeviceId"] !== void 0) return true;
205732
- return stringValue(evidence.settings["host"]).length > 0;
205733
- }
205734
- /** The form of a hub integration: its connection and debug sections, nothing of the addon's own. */
205735
- function buildHubInstanceSchema() {
205736
- return buildHubConnectionSections();
205737
- }
205738
- //#endregion
205739
- //#region src/hub-battery-fields.ts
205740
- /**
205741
- * The hub boundary for the per-channel battery power fields.
205742
- *
205743
- * The Reolink hub/NVR reports `chargeStatus` and `adapterStatus` as NUMBERS
205744
- * (0/1/2); the standalone camera path reports the same facts as strings, and
205745
- * `deriveChargingSource` (`battery-charging.ts`) speaks only the string
205746
- * vocabulary. This module is the translation, and — the part that was
205747
- * missing — it NAMES a code it cannot translate instead of dropping the field
205748
- * silently. A dropped field reads as "the push said nothing about power", so
205749
- * a new firmware value would have kept the previous belief forever, with not
205750
- * one log line. The ledger lets the hub warn once per (device, field, code).
205751
- */
205752
- var CHARGE_STATUS_BY_CODE = {
205753
- 0: "none",
205754
- 1: "charging",
205755
- 2: "chargeComplete"
205756
- };
205757
- var ADAPTER_STATUS_BY_CODE = {
205758
- 0: "none",
205759
- 1: "dc",
205760
- 2: "solarPanel"
205761
- };
205762
- function mapOne(field, value, byCode) {
205763
- if (value === void 0 || value === null) return {};
205764
- if (typeof value === "string") return { mapped: value };
205765
- if (typeof value === "number") {
205766
- const mapped = byCode[value];
205767
- if (mapped !== void 0) return { mapped };
205768
- }
205769
- return { unmapped: {
205770
- field,
205771
- value
205772
- } };
205773
- }
205774
- /** Translate one channel's raw CGI power fields; never throws. */
205775
- function mapHubBatteryPower(cgi) {
205776
- const charge = mapOne("chargeStatus", cgi?.["chargeStatus"], CHARGE_STATUS_BY_CODE);
205777
- const adapter = mapOne("adapterStatus", cgi?.["adapterStatus"], ADAPTER_STATUS_BY_CODE);
205778
- return {
205779
- fields: {
205780
- ...charge.mapped !== void 0 ? { chargeStatus: charge.mapped } : {},
205781
- ...adapter.mapped !== void 0 ? { adapterStatus: adapter.mapped } : {}
205782
- },
205783
- unmapped: [...charge.unmapped !== void 0 ? [charge.unmapped] : [], ...adapter.unmapped !== void 0 ? [adapter.unmapped] : []]
205784
- };
205785
- }
205786
- /**
205787
- * Remembers which (device, field, code) triples were already reported, so a
205788
- * polled read warns once per finding and not once per tick. Unbounded only in
205789
- * theory: the key space is a few cameras times two fields times the handful of
205790
- * codes a firmware can invent.
205791
- */
205792
- var UnmappedCodeLedger = class {
205793
- seen = /* @__PURE__ */ new Set();
205794
- firstSeen(deviceId, field, value) {
205795
- const key = `${deviceId}:${field}:${String(value)}`;
205796
- if (this.seen.has(key)) return false;
205797
- this.seen.add(key);
205798
- return true;
205799
- }
205800
- };
205801
- //#endregion
205802
- //#region src/hub-channel-reconcile.ts
205803
- /**
205804
- * Match adopted children to the hub's own channel list by IDENTITY, then report the
205805
- * ones whose persisted `channel` is missing or wrong.
205806
- *
205807
- * A child's stableId ENDS with `-${childNativeId}` (the prefix is the integration it was
205808
- * adopted into, or the retired Hub's stableId for a migrated child), and `childNativeId` is built
205809
- * from the camera's UID — so identity survives a channel move, while `channel` does not.
205810
- * The hub is the authority on which slot a UID currently occupies.
205811
- *
205812
- * Why this exists: `loadAdoptedChildrenByChannel` keys the adopted set on the persisted
205813
- * `channel` alone. A child that lost it is absent from that map, so the discovery panel
205814
- * renders it `alreadyAdopted: false` — one click away from being adopted a SECOND time —
205815
- * and `routeSimpleEvent` silently drops every push for its slot. Observed live on
205816
- * 2026-09-04 with an Argus MagiCam that was actually on channel 3.
205817
- *
205818
- * Pure: no I/O. A discovered entry with no channel, or with no adopted child, is skipped —
205819
- * this repairs what exists, it never adopts.
205820
- */
205821
- function planChannelReconciliation(discovered, children) {
205822
- const repairs = [];
205823
- for (const entry of discovered) {
205824
- if (typeof entry.rtspChannel !== "number") continue;
205825
- const child = children.find((c) => c.stableId.endsWith(`-${entry.childNativeId}`));
205826
- if (child === void 0) continue;
205827
- if (child.channel === entry.rtspChannel) continue;
205828
- repairs.push({
205829
- deviceId: child.deviceId,
205830
- stableId: child.stableId,
205831
- from: child.channel ?? null,
205832
- to: entry.rtspChannel
205833
- });
205834
- }
205835
- return repairs;
205836
- }
205837
- function createMotionSensorTracker(now = Date.now) {
205838
- const latest = /* @__PURE__ */ new Map();
205839
- return {
205840
- observe(event) {
205841
- if (event.type !== "motion") return;
205842
- const sensor = event.motion.source;
205843
- if (sensor === void 0) return;
205844
- latest.set(event.channel, {
205845
- sensor,
205846
- at: now()
205847
- });
205848
- },
205849
- take(channel) {
205850
- const entry = latest.get(channel);
205851
- if (!entry) return null;
205852
- latest.delete(channel);
205853
- return now() - entry.at <= 2e3 ? entry.sensor : null;
205854
- }
205855
- };
205856
- }
205857
- var isMotionSensor = (value) => value === "md" || value === "pir" || value === "unknown";
205858
- /**
205859
- * The sensor a simple event states itself, for a library version that forwards
205860
- * `motion.source` on the simple event; `null` for the versions that do not.
205861
- */
205862
- function motionSensorOnSimpleEvent(event) {
205863
- if (!("motion" in event)) return null;
205864
- const motion = event.motion;
205865
- if (typeof motion !== "object" || motion === null || !("source" in motion)) return null;
205866
- return isMotionSensor(motion.source) ? motion.source : null;
205867
- }
205868
- //#endregion
205869
- //#region src/simple-event-dispatch-trace.ts
205870
- /**
205871
- * Gate for hub-side simpleEvent traces.
205872
- *
205873
- * Hub-child events arrive on the parent's Baichuan socket. Logging them on
205874
- * the child logger only is invisible when the operator filters Logs to the
205875
- * Hub; logging every channel unconditionally floods the Hub (battery ticks).
205876
- * Trace when the target camera — or the hub itself — is under debug.
205877
- */
205878
- function shouldTraceSimpleEventDispatch(input) {
205879
- return wantsEventTrace(input.hubDebugGeneral, input.hubSocketFlags) || wantsEventTrace(input.childDebugGeneral, input.childSocketFlags);
205880
- }
205881
- function wantsEventTrace(debugGeneral, socketFlags) {
205882
- return debugGeneral || socketFlags.includes("traceEvents");
205883
- }
205884
- //#endregion
205885
- //#region src/reolink-hub-session.ts
205886
- var HUB_DISCOVERY_REFRESH_TIMEOUT_MS = 8e3;
205887
- /**
205888
- * How often the session re-reads its own channels summary to reconcile the adopted children's
205889
- * sleep slices. 60 s: a naturally-waking camera (PIR, schedule) is caught within a minute, and
205890
- * the play path has its own faster confirm + post-dial watch, so this only ever carries
205891
- * background drift. Never wakes a child — the summary is answered by the hub itself.
205892
- */
205893
- var HUB_CHILD_SLEEP_RECONCILE_MS = 6e4;
205894
- var HUB_RECONNECT_BASE_MS = 2e3;
205895
- var HUB_RECONNECT_MAX_MS = 3e4;
205896
- var UNROUTED_LOG_THROTTLE_MS = 6e4;
205897
- /**
205898
- * ONE Reolink NVR / Home Hub: a single Baichuan control + event socket fronting N channels,
205899
- * owned per integration (D985). It was the `ReolinkHub` device; the device is gone, the
205900
- * mechanism is unchanged:
205901
- *
205902
- * - one persistent socket (no per-channel login), reconnected with a bounded backoff;
205903
- * - every `simpleEvent` from any channel is dispatched into the matching child;
205904
- * - every 60 s the hub's own channels summary reconciles the children's sleep, online and
205905
- * battery slices — read from the hub's mains-powered socket, never from a battery camera.
205906
- */
205907
- var ReolinkHubSession = class {
205908
- integrationId;
205909
- #deps;
205910
- #logger;
205911
- #now;
205912
- #connection;
205913
- #api = null;
205914
- #connectInFlight = null;
205915
- #reconnectAttempts = 0;
205916
- #reconnectTimer = null;
205917
- #reconcileTimer = null;
205918
- #shutdown = false;
205919
- #online = false;
205920
- #snapshot = {
205921
- channels: [],
205922
- lastRefreshAt: null,
205923
- lastError: null
205924
- };
205925
- #refreshInFlight = null;
205926
- /** Wall-clock ms of the last refresh that listed each child (by device id). */
205927
- #childLastSeenAt = /* @__PURE__ */ new Map();
205928
- /** Channels the last good refresh listed but nobody adopted: their pushes are expected drops. */
205929
- #knownUnadoptedChannels = /* @__PURE__ */ new Set();
205930
- #unroutedLoggedAt = /* @__PURE__ */ new Map();
205931
- #batteryShapeLogged = false;
205932
- #unmappedBatteryCodes = new UnmappedCodeLedger();
205933
- #motionSensors = createMotionSensorTracker();
205934
- #simpleEventBound = (event) => {
205935
- this.routeSimpleEvent(event);
205936
- };
205937
- #rawEventBound = (event) => {
205938
- this.#motionSensors.observe(event);
205939
- };
205940
- constructor(deps) {
205941
- this.integrationId = deps.integrationId;
205942
- this.#deps = deps;
205943
- this.#logger = deps.logger;
205944
- this.#connection = deps.connection;
205945
- this.#now = deps.now ?? Date.now;
205946
- }
205947
- get connection() {
205948
- return this.#connection;
205949
- }
205950
- get isOnline() {
205951
- return this.#online;
205952
- }
205953
- /** The integration's display name. */
205954
- get name() {
205955
- return this.#deps.name;
205956
- }
205957
- /** Restart the hub itself (not a camera behind it). */
205958
- async reboot() {
205959
- await (await this.getApi()).reboot();
205960
- }
205961
- /** The last channel list, for the adoption surface. */
205962
- channelSnapshot() {
205963
- return this.#snapshot;
205964
- }
205965
- /**
205966
- * What a child dials for the hub's RTSP relay. The port is the connection's RTSP port
205967
- * (default 554) — never the Baichuan port.
205968
- */
205969
- relayEndpoint() {
205970
- const c = this.#connection;
205971
- return {
205972
- host: c.host,
205973
- username: c.username,
205974
- password: c.password,
205975
- port: c.rtspPort
205976
- };
205977
- }
205978
- /** Arm the periodic reconcile and make the first dial. Idempotent. */
205979
- start() {
205980
- if (this.#reconcileTimer === null) {
205981
- this.#reconcileTimer = setInterval(() => {
205982
- this.refresh().catch(() => {});
205983
- }, HUB_CHILD_SLEEP_RECONCILE_MS);
205984
- if (typeof this.#reconcileTimer.unref === "function") this.#reconcileTimer.unref();
205985
- }
205986
- this.getApi().catch((err) => {
205987
- this.#logger.warn("Reolink hub initial connect failed — will retry", {
205988
- tags: { integrationId: this.integrationId },
205989
- meta: { error: errMsg$3(err) }
205990
- });
205991
- });
205992
- }
205993
- /**
205994
- * Apply a new connection. A session already dialled the same way keeps its socket; any real
205995
- * change tears the socket down and re-dials with the new credentials.
205996
- */
205997
- async reconfigure(connection) {
205998
- if (sameHubConnection(this.#connection, connection)) {
205999
- this.#connection = connection;
206000
- return;
206001
- }
206002
- this.#connection = connection;
206003
- await this.dropApi("settings-changed");
206004
- this.#setOnline(false);
206005
- this.getApi().catch((err) => {
206006
- this.#logger.warn("Reolink hub re-dial after settings change failed", {
206007
- tags: { integrationId: this.integrationId },
206008
- meta: { error: errMsg$3(err) }
206009
- });
206010
- });
206011
- }
206012
- async close() {
206013
- this.#shutdown = true;
206014
- if (this.#reconnectTimer !== null) {
206015
- clearTimeout(this.#reconnectTimer);
206016
- this.#reconnectTimer = null;
206017
- }
206018
- if (this.#reconcileTimer !== null) {
206019
- clearInterval(this.#reconcileTimer);
206020
- this.#reconcileTimer = null;
206021
- }
206022
- await this.dropApi("hub-shutdown");
206023
- }
206024
- /**
206025
- * The logged-in api (single round-trip on first call). Mid-disconnect, callers wait for the
206026
- * reconnect that is already in flight.
206027
- */
206028
- async getApi() {
206029
- const live = this.#api;
206030
- if (live !== null && live.client.isSocketConnected() && live.client.loggedIn) return live;
206031
- if (this.#connectInFlight !== null) return this.#connectInFlight;
206032
- this.#connectInFlight = this.#connect().finally(() => {
206033
- this.#connectInFlight = null;
206034
- });
206035
- return this.#connectInFlight;
206036
- }
206037
- async dropApi(reason) {
206038
- const api = this.#api;
206039
- this.#api = null;
206040
- if (api === null) return;
206041
- try {
206042
- await api.close({ reason });
206043
- } catch {}
206044
- }
206045
- async #connect() {
206046
- await this.dropApi("hub-reconnect");
206047
- const connection = this.#connection;
206048
- if (connection.host.length === 0) throw userError("reolink.hub-host-missing", void 0, "Reolink Hub: host not configured");
206049
- if (connection.password.length === 0) throw userError("reolink.hub-password-missing", void 0, "Reolink Hub: password not configured");
206050
- this.#logger.info("Reolink hub connecting", {
206051
- tags: { integrationId: this.integrationId },
206052
- meta: {
206053
- host: connection.host,
206054
- port: connection.port,
206055
- transport: connection.transport
206056
- }
206057
- });
206058
- const api = await this.#deps.connect(connection);
206059
- try {
206060
- await api.offSimpleEvent(this.#simpleEventBound);
206061
- } catch {}
206062
- api.client.off("event", this.#rawEventBound);
206063
- api.client.prependListener("event", this.#rawEventBound);
206064
- await api.onSimpleEvent(this.#simpleEventBound);
206065
- api.client.on("close", () => this.#scheduleReconnect("socket-close"));
206066
- api.client.on("error", (err) => {
206067
- this.#logger.warn("Reolink hub socket error — scheduling reconnect", {
206068
- tags: { integrationId: this.integrationId },
206069
- meta: { error: errMsg$3(err) }
206070
- });
206071
- this.#scheduleReconnect("socket-error");
206072
- });
206073
- this.#api = api;
206074
- this.#reconnectAttempts = 0;
206075
- this.#setOnline(true);
206076
- this.refresh().catch(() => {});
206077
- return api;
206078
- }
206079
- #setOnline(online) {
206080
- if (this.#online === online) return;
206081
- this.#online = online;
206082
- this.#deps.onConnectionChange?.(online);
206083
- }
206084
- #scheduleReconnect(reason) {
206085
- if (this.#shutdown) return;
206086
- this.#api = null;
206087
- this.#setOnline(false);
206088
- if (this.#reconnectTimer !== null) return;
206089
- const backoff = Math.min(HUB_RECONNECT_BASE_MS * 2 ** this.#reconnectAttempts, HUB_RECONNECT_MAX_MS);
206090
- this.#reconnectAttempts += 1;
206091
- this.#logger.info("Reolink hub reconnect scheduled", {
206092
- tags: { integrationId: this.integrationId },
206093
- meta: {
206094
- reason,
206095
- attempt: this.#reconnectAttempts,
206096
- backoffMs: backoff
206097
- }
206098
- });
206099
- this.#reconnectTimer = setTimeout(() => {
206100
- this.#reconnectTimer = null;
206101
- this.getApi().catch(() => {});
206102
- }, backoff);
206103
- }
206104
- /**
206105
- * Pull the channel list from the hub via `getNvrChannelsSummary({ source: 'cgi' })` — CGI,
206106
- * because `GetChannelstatus` answers synchronously (no race against the cmd_id 145 push
206107
- * cache that is empty right after login). Single-flight.
206108
- */
206109
- refresh() {
206110
- if (this.#refreshInFlight !== null) return this.#refreshInFlight;
206111
- const run = this.#refresh().finally(() => {
206112
- this.#refreshInFlight = null;
206113
- });
206114
- this.#refreshInFlight = run;
206115
- return run;
206116
- }
206117
- async #refresh() {
206118
- const startedAt = this.#now();
206119
- let lastError = null;
206120
- let channels = [];
206121
- try {
206122
- channels = (await (await this.getApi()).getNvrChannelsSummary({
206123
- source: "cgi",
206124
- timeoutMs: HUB_DISCOVERY_REFRESH_TIMEOUT_MS
206125
- })).devices.map((d) => ({
206126
- childNativeId: computeChildNativeId(d.channel, d.uid),
206127
- channel: d.channel,
206128
- name: d.name ?? `Channel ${d.channel}`,
206129
- status: discoveredStatusFor(d),
206130
- ...d.uid ? { uid: d.uid } : {},
206131
- ...d.model ? { model: d.model } : {},
206132
- ...d.serialNumber ? { serialNumber: d.serialNumber } : {},
206133
- isBattery: d.isBattery === true,
206134
- isDoorbell: d.isDoorbell === true,
206135
- isMultifocal: d.isMultifocal === true
206136
- }));
206137
- } catch (err) {
206138
- lastError = errMsg$3(err);
206139
- this.#logger.warn("Reolink hub channel refresh failed", {
206140
- tags: { integrationId: this.integrationId },
206141
- meta: { error: lastError }
206142
- });
206143
- }
206144
- this.#snapshot = lastError === null ? {
206145
- channels,
206146
- lastRefreshAt: this.#now(),
206147
- lastError: null
206148
- } : {
206149
- ...this.#snapshot,
206150
- lastError
206151
- };
206152
- if (lastError !== null) return;
206153
- await this.reconcileChildChannels(channels);
206154
- const adoptedChannels = /* @__PURE__ */ new Set();
206155
- for (const child of this.#deps.children()) if (child.channel !== void 0) adoptedChannels.add(child.channel);
206156
- this.#knownUnadoptedChannels.clear();
206157
- for (const c of channels) if (!adoptedChannels.has(c.channel)) this.#knownUnadoptedChannels.add(c.channel);
206158
- this.#logger.info("Reolink hub: channel refresh complete", {
206159
- tags: { integrationId: this.integrationId },
206160
- meta: {
206161
- durationMs: this.#now() - startedAt,
206162
- total: channels.length,
206163
- adopted: channels.filter((c) => adoptedChannels.has(c.channel)).length,
206164
- online: channels.filter((c) => c.status === "online").length,
206165
- sleeping: channels.filter((c) => c.status === "sleeping").length
206166
- }
206167
- });
206168
- this.reconcileChildOnline(channels);
206169
- this.reconcileChildSleepState(channels);
206170
- await this.reconcileChildBattery();
206171
- }
206172
- /**
206173
- * Re-derive each adopted child's `channel` from the hub's own list, matching by IDENTITY
206174
- * (the stableId carries the camera UID) rather than by the channel itself — a child that lost
206175
- * its channel is invisible to the routing, and a channel move produces the same damage under
206176
- * a wrong number. A child that is not live is skipped: it is repaired on its next restore.
206177
- */
206178
- async reconcileChildChannels(channels) {
206179
- const children = this.#deps.children();
206180
- const repairs = planChannelReconciliation(channels.map((c) => ({
206181
- childNativeId: c.childNativeId,
206182
- rtspChannel: c.channel
206183
- })), children.map((child) => ({
206184
- deviceId: child.id,
206185
- stableId: child.stableId,
206186
- ...child.channel !== void 0 ? { channel: child.channel } : {}
206187
- })));
206188
- for (const repair of repairs) {
206189
- const child = children.find((c) => c.id === repair.deviceId);
206190
- if (child === void 0) continue;
206191
- try {
206192
- await child.repairChannel(repair.to);
206193
- this.#logger.warn("Reolink hub: repaired a child channel from the hub channel list", {
206194
- tags: {
206195
- deviceId: repair.deviceId,
206196
- stableId: repair.stableId
206197
- },
206198
- meta: {
206199
- from: repair.from,
206200
- to: repair.to
206201
- }
206202
- });
206203
- } catch (err) {
206204
- this.#logger.warn("Reolink hub: child channel repair failed", {
206205
- tags: {
206206
- deviceId: repair.deviceId,
206207
- stableId: repair.stableId
206208
- },
206209
- meta: {
206210
- from: repair.from,
206211
- to: repair.to,
206212
- error: errMsg$3(err)
206213
- }
206214
- });
206215
- }
206216
- }
206217
- }
206218
- /**
206219
- * Mark every child still listed `online`, and any child missing for more than
206220
- * `HUB_MISSING_OFFLINE_MS` `offline` — "if the hub firmware doesn't list it, the device isn't
206221
- * reachable through the hub". Battery cams that briefly drop out of the summary during deep
206222
- * sleep are not false-flagged: the threshold gives 3+ cycles of grace.
206223
- */
206224
- reconcileChildOnline(channels) {
206225
- const now = this.#now();
206226
- const listed = new Set(channels.map((c) => c.channel));
206227
- for (const child of this.#deps.children()) {
206228
- const ch = child.channel;
206229
- if (ch === void 0) continue;
206230
- if (child.type !== DeviceType.Camera) continue;
206231
- if (listed.has(ch)) {
206232
- this.#childLastSeenAt.set(child.id, now);
206233
- if (!child.online) {
206234
- child.markOnline(true);
206235
- this.#logger.info("Reolink hub: adopted child back online", {
206236
- tags: {
206237
- deviceId: child.id,
206238
- deviceName: child.name
206239
- },
206240
- meta: { channel: ch }
206241
- });
206242
- }
206243
- continue;
206244
- }
206245
- const lastSeen = this.#childLastSeenAt.get(child.id) ?? 0;
206246
- const missingMs = lastSeen > 0 ? now - lastSeen : Number.POSITIVE_INFINITY;
206247
- if (missingMs >= 6e5 && child.online) {
206248
- child.markOnline(false);
206249
- this.#logger.warn("Reolink hub: adopted child marked offline (missing from the hub list)", {
206250
- tags: {
206251
- deviceId: child.id,
206252
- deviceName: child.name
206253
- },
206254
- meta: {
206255
- channel: ch,
206256
- missingMs
206257
- }
206258
- });
206259
- }
206260
- }
206261
- }
206262
- /**
206263
- * Push the hub's per-channel sleep state into each adopted battery child's `battery.sleeping`
206264
- * slice. The summary carries a `sleeping` flag straight from the firmware over the hub's own
206265
- * mains-powered socket: it costs the battery channel nothing, and it is real firmware state
206266
- * rather than socket-I/O inference. Only `online` / `sleeping` are acted on: `offline` and
206267
- * `unknown` carry no sleep information, so the last known value stands.
206268
- */
206269
- reconcileChildSleepState(channels) {
206270
- const byChannel = new Map(channels.map((c) => [c.channel, c]));
206271
- for (const child of this.#deps.children()) {
206272
- if (child.channel === void 0) continue;
206273
- const entry = byChannel.get(child.channel);
206274
- if (entry === void 0) continue;
206275
- if (entry.status !== "sleeping" && entry.status !== "online") continue;
206276
- child.applyHubSleepState(entry.status === "sleeping");
206277
- }
206278
- }
206279
- /**
206280
- * Push the hub's per-channel battery read into each adopted battery child.
206281
- * `getAllChannelsBatteryInfo` is one CGI on the hub's own socket — it never touches a battery
206282
- * camera — and it is the ONLY way a sleeping child's percentage/charging ever moves.
206283
- * Independent of the refresh's success path: a battery-read failure must not fail the refresh.
206284
- */
206285
- async reconcileChildBattery() {
206286
- const children = this.#deps.children();
206287
- if (children.length === 0) return;
206288
- try {
206289
- const data = await (await this.getApi()).getAllChannelsBatteryInfo();
206290
- const entries = Object.entries(data.batteryInfoData);
206291
- if (entries.length === 0) return;
206292
- if (!this.#batteryShapeLogged) {
206293
- this.#batteryShapeLogged = true;
206294
- this.#logger.info("Reolink hub: per-channel battery payload (first read)", {
206295
- tags: { integrationId: this.integrationId },
206296
- meta: { raw: entries.map(([ch, v]) => ({
206297
- channel: Number(ch),
206298
- ...v
206299
- })) }
206300
- });
206301
- }
206302
- for (const [chKey, info] of entries) {
206303
- const child = children.find((c) => c.channel === Number(chKey));
206304
- if (child === void 0) continue;
206305
- const power = mapHubBatteryPower(info.entries[0]);
206306
- for (const { field, value } of power.unmapped) {
206307
- if (!this.#unmappedBatteryCodes.firstSeen(child.id, field, value)) continue;
206308
- this.#logger.warn("Reolink hub: unmapped battery power code — field dropped", {
206309
- tags: { deviceId: child.id },
206310
- meta: {
206311
- channel: Number(chKey),
206312
- field,
206313
- value
206314
- }
206315
- });
206316
- }
206317
- child.applyHubBatteryInfo({
206318
- batteryPercent: info.batteryLevel,
206319
- ...power.fields
206320
- });
206321
- }
206322
- } catch (err) {
206323
- this.#logger.debug("Reolink hub: per-channel battery refresh failed", {
206324
- tags: { integrationId: this.integrationId },
206325
- meta: { error: errMsg$3(err) }
206326
- });
206327
- }
206328
- }
206329
- /** Dispatch a hub-received simpleEvent to the matching child's `handleSimpleEvent`. */
206330
- routeSimpleEvent(event) {
206331
- const channel = event?.channel;
206332
- if (typeof channel !== "number") return;
206333
- const motionSensor = event.type === "motion" ? this.#motionSensors.take(channel) : null;
206334
- const child = this.#deps.children().find((c) => c.channel === channel);
206335
- if (child === void 0) {
206336
- const last = this.#unroutedLoggedAt.get(channel) ?? 0;
206337
- const now = this.#now();
206338
- if (now - last > UNROUTED_LOG_THROTTLE_MS) {
206339
- this.#unroutedLoggedAt.set(channel, now);
206340
- const knownUnadopted = this.#knownUnadoptedChannels.has(channel);
206341
- this.#logger[knownUnadopted ? "debug" : "warn"]("Reolink hub: simpleEvent for an unmapped channel dropped", {
206342
- tags: { integrationId: this.integrationId },
206343
- meta: {
206344
- channel,
206345
- type: event?.type ?? "unknown",
206346
- knownUnadopted
206347
- }
206348
- });
206349
- }
206350
- return;
206351
- }
206352
- try {
206353
- if (shouldTraceSimpleEventDispatch({
206354
- hubDebugGeneral: this.#connection.debugGeneral,
206355
- hubSocketFlags: this.#connection.debugSocketLogs,
206356
- childDebugGeneral: child.debugGeneral,
206357
- childSocketFlags: child.debugSocketFlags
206358
- })) this.#logger.info("Reolink hub: simpleEvent dispatch", {
206359
- tags: { deviceId: child.id },
206360
- meta: {
206361
- type: event?.type ?? "unknown",
206362
- channel,
206363
- timestamp: event?.timestamp,
206364
- name: child.name
206365
- }
206366
- });
206367
- child.handleSimpleEvent(event, motionSensor);
206368
- } catch (err) {
206369
- this.#logger.debug("Reolink hub simpleEvent dispatch failed", {
206370
- tags: { deviceId: child.id },
206371
- meta: {
206372
- channel,
206373
- error: errMsg$3(err)
206374
- }
206375
- });
206376
- }
206377
- }
206378
- };
206379
- /**
206380
- * A stable per-child identifier. UID is preferred when available (it identifies a paired camera
206381
- * across re-pairings); otherwise `channel-N`, which is stable for a fixed channel assignment.
206382
- */
206383
- function computeChildNativeId(channel, uid) {
206384
- if (uid && uid.length > 0) return `cam-${uid}`;
206385
- return `channel-${channel}`;
206386
- }
206387
- /**
206388
- * Map the lib's per-channel summary booleans to our `DiscoveredChildStatus`. Precedence:
206389
- * `online === false` → offline; `sleeping === true` → sleeping; `online === true` → online;
206390
- * any non-empty state → online; unknown otherwise (the cmd_id 145 push has not landed yet).
206391
- */
206392
- function discoveredStatusFor(d) {
206393
- if (d.online === false) return "offline";
206394
- if (d.sleeping === true) return "sleeping";
206395
- if (d.online === true) return "online";
206396
- if (typeof d.state === "string" && d.state.length > 0) return "online";
206397
- return "unknown";
206398
- }
206399
- /** The wire-shaped channel the adoption surface lists. */
206400
- function toDiscoveredChild(channel, adoptedDeviceId) {
206401
- return {
206402
- childNativeId: channel.childNativeId,
206403
- name: channel.name,
206404
- type: DeviceType.Camera,
206405
- status: channel.status,
206406
- metadata: {
206407
- ...channel.model ? { model: channel.model } : {},
206408
- ...channel.serialNumber ? { serialNumber: channel.serialNumber } : {},
206409
- ...channel.uid ? { uid: channel.uid } : {},
206410
- rtspChannel: channel.channel,
206411
- isBattery: channel.isBattery,
206412
- isDoorbell: channel.isDoorbell,
206413
- isMultifocal: channel.isMultifocal
206414
- },
206415
- alreadyAdopted: adoptedDeviceId !== null,
206416
- adoptedDeviceId
206417
- };
206418
- }
206419
- var GATEWAY_SUFFIX = ":gateway";
206420
- /** The `childNativeId` of the hub box itself, one per integration. */
206421
- function hubGatewayNativeId(integrationId) {
206422
- return `${integrationId}${GATEWAY_SUFFIX}`;
206423
- }
206424
- /**
206425
- * The hub (NVR / Home Hub) as an adoption candidate: a {@link DeviceType.Gateway} that is a
206426
- * sibling of its channel cameras, never their parent. Only what the library really exposes:
206427
- * `reboot` (Baichuan `Reboot`) and the unit's web page.
206428
- */
206429
- function toDiscoveredGateway(input) {
206430
- return {
206431
- childNativeId: hubGatewayNativeId(input.integrationId),
206432
- name: input.name,
206433
- type: DeviceType.Gateway,
206434
- status: input.online ? "online" : "offline",
206435
- metadata: { manufacturer: "Reolink" },
206436
- alreadyAdopted: input.adoptedDeviceId !== null,
206437
- adoptedDeviceId: input.adoptedDeviceId,
206438
- capabilities: [rebootCapability.name, deviceAdminLinkCapability.name]
206439
- };
206440
- }
206441
- /**
206442
- * The per-integration registry the cameras reach their session through (the shape of
206443
- * `veluxGateways` / `enpalConnections`). It also holds the children, so a child that activates
206444
- * before its session exists (boot order) is already routable the moment the session starts.
206445
- */
206446
- var ReolinkHubSessions = class {
206447
- #sessions = /* @__PURE__ */ new Map();
206448
- #children = /* @__PURE__ */ new Map();
206449
- set(integrationId, session) {
206450
- if (session === null) {
206451
- this.#sessions.delete(integrationId);
206452
- return;
206453
- }
206454
- this.#sessions.set(integrationId, session);
206455
- }
206456
- get(integrationId) {
206457
- return this.#sessions.get(integrationId) ?? null;
206458
- }
206459
- attach(integrationId, child) {
206460
- let set = this.#children.get(integrationId);
206461
- if (set === void 0) {
206462
- set = /* @__PURE__ */ new Map();
206463
- this.#children.set(integrationId, set);
206464
- }
206465
- set.set(child.id, child);
206466
- }
206467
- /** Detach only if `child` is still the attached handle — a rebuilt device keeps its slot. */
206468
- detach(integrationId, child) {
206469
- const set = this.#children.get(integrationId);
206470
- if (set?.get(child.id) === child) set.delete(child.id);
206471
- }
206472
- childrenOf(integrationId) {
206473
- return [...this.#children.get(integrationId)?.values() ?? []];
206474
- }
206475
- clear() {
206476
- this.#sessions.clear();
206477
- this.#children.clear();
206478
- }
206479
- };
206480
- /** The module-level registry (one per addon process). */
206481
- var reolinkHubSessions = new ReolinkHubSessions();
206482
- /** Dial a real hub. The default `connect` dependency of a session. */
206483
- async function connectReolinkHub(connection) {
206484
- const api = new ReolinkBaichuanApi({
206485
- host: connection.host,
206486
- port: connection.port,
206487
- username: connection.username,
206488
- password: connection.password,
206489
- transport: connection.transport,
206490
- ...connection.uid ? { uid: connection.uid } : {}
206491
- });
206492
- await api.login();
206493
- return api;
206494
- }
206495
- //#endregion
206496
205607
  //#region src/battery-refresh-settings.ts
206497
205608
  var BATTERY_REFRESH_SECTION_ID = "battery-refresh";
206498
205609
  var BATTERY_REFRESH_MINUTES_DESCRIPTION = "A camera that slept the whole window is woken once to refresh. 0 turns the scheduled refresh off.";
@@ -207419,6 +206530,912 @@ function buildOnboardWrites(patch, schedule) {
207419
206530
  };
207420
206531
  }
207421
206532
  //#endregion
206533
+ //#region src/hub-connection.ts
206534
+ /** Baichuan control port a Reolink NVR / Home Hub listens on. */
206535
+ var REOLINK_HUB_DEFAULT_PORT = 9e3;
206536
+ /** Settings key that names the section holding the hub connection form. */
206537
+ var HUB_CONNECTION_SECTION_ID = "connection";
206538
+ var HUB_DEBUG_SECTION_ID = "hub-debug";
206539
+ /** A blank form field is "not provided", never an invalid port. */
206540
+ function blankToUndefined(value) {
206541
+ return value === "" || value === null ? void 0 : value;
206542
+ }
206543
+ /**
206544
+ * The connection of ONE Reolink NVR / Home Hub: the settings of an INTEGRATION (D985). The
206545
+ * password is a schema `password` field kept in the keychain — never in env, argv or logs.
206546
+ */
206547
+ var reolinkHubConnectionSchema = object({
206548
+ host: string().trim().min(1).describe("NVR / Home Hub IP or hostname"),
206549
+ port: preprocess(blankToUndefined, number().int().min(1).max(65535).default(REOLINK_HUB_DEFAULT_PORT)),
206550
+ /** The RTSP relay port the cameras dial for their streams (not the Baichuan `port`). */
206551
+ rtspPort: preprocess(blankToUndefined, number().int().min(1).max(65535).default(554)),
206552
+ username: preprocess(blankToUndefined, string().trim().min(1).default("admin")),
206553
+ password: string().min(1).describe("NVR / Home Hub password").meta({ secret: true }),
206554
+ /** Hubs are TCP-only on supported firmwares — kept for symmetry with a camera. */
206555
+ transport: _enum(["tcp", "udp"]).default("tcp"),
206556
+ /** UID of a hub that was paired by it; unused for a plain TCP login. */
206557
+ uid: string().optional(),
206558
+ debugGeneral: boolean().default(false),
206559
+ debugSocketLogs: array(ReolinkSocketDebugFlagSchema).default([])
206560
+ });
206561
+ function stringValue(value) {
206562
+ return typeof value === "string" ? value.trim() : "";
206563
+ }
206564
+ function readHubConnection(settings) {
206565
+ if (stringValue(settings["host"]).length === 0) return { kind: "no-host" };
206566
+ const password = settings["password"];
206567
+ if (isSecretMarkerLike(password)) return { kind: "password-locked" };
206568
+ if (typeof password !== "string" || password.length === 0) return { kind: "no-password" };
206569
+ const parsed = reolinkHubConnectionSchema.safeParse(settings);
206570
+ if (!parsed.success) return { kind: "no-host" };
206571
+ return {
206572
+ kind: "ok",
206573
+ connection: parsed.data
206574
+ };
206575
+ }
206576
+ /** Two reads that would dial the same way. */
206577
+ function sameHubConnection(a, b) {
206578
+ 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;
206579
+ }
206580
+ /**
206581
+ * The connection + debug sections of the integration form. Fields are NOT `required` here:
206582
+ * the same schema is the addon's own settings page, where a required host would block saving
206583
+ * an unrelated email-push change on the default integration. A hub integration missing its
206584
+ * host or password is named by the session at reconcile time instead.
206585
+ */
206586
+ function buildHubConnectionSections() {
206587
+ return { sections: [{
206588
+ id: HUB_CONNECTION_SECTION_ID,
206589
+ title: "NVR / Home Hub connection",
206590
+ titleKey: "provider-reolink:connection.s_connection.title",
206591
+ description: "Only for an integration that is one NVR or Home Hub. Its channels are adopted as cameras that share this connection.",
206592
+ descriptionKey: "provider-reolink:connection.s_connection.description",
206593
+ columns: 2,
206594
+ fields: [
206595
+ {
206596
+ type: "text",
206597
+ key: "host",
206598
+ label: "Hub IP",
206599
+ labelKey: "provider-reolink:connection.s_connection.host.label",
206600
+ placeholder: "192.168.10.100"
206601
+ },
206602
+ {
206603
+ type: "number",
206604
+ key: "port",
206605
+ label: "Port",
206606
+ labelKey: "provider-reolink:connection.s_connection.port.label",
206607
+ default: REOLINK_HUB_DEFAULT_PORT,
206608
+ min: 1,
206609
+ max: 65535,
206610
+ step: 1
206611
+ },
206612
+ {
206613
+ type: "number",
206614
+ key: "rtspPort",
206615
+ label: "RTSP port",
206616
+ labelKey: "provider-reolink:connection.s_connection.rtspPort.label",
206617
+ default: 554,
206618
+ min: 1,
206619
+ max: 65535,
206620
+ step: 1
206621
+ },
206622
+ {
206623
+ type: "text",
206624
+ key: "username",
206625
+ label: "Username",
206626
+ labelKey: "provider-reolink:connection.s_connection.username.label",
206627
+ default: "admin"
206628
+ },
206629
+ {
206630
+ type: "password",
206631
+ key: "password",
206632
+ label: "Password",
206633
+ labelKey: "provider-reolink:connection.s_connection.password.label",
206634
+ showToggle: true
206635
+ }
206636
+ ]
206637
+ }, {
206638
+ id: HUB_DEBUG_SECTION_ID,
206639
+ title: "NVR / Home Hub debug",
206640
+ titleKey: "provider-reolink:connection.s_hub-debug.title",
206641
+ description: "Debug logging adds log volume; use it temporarily.",
206642
+ descriptionKey: "provider-reolink:connection.s_hub-debug.description",
206643
+ columns: 1,
206644
+ fields: [{
206645
+ type: "boolean",
206646
+ key: "debugGeneral",
206647
+ label: "General debug logs",
206648
+ labelKey: "provider-reolink:connection.s_hub-debug.debugGeneral.label",
206649
+ default: false,
206650
+ style: "switch"
206651
+ }, {
206652
+ type: "multiselect",
206653
+ key: "debugSocketLogs",
206654
+ label: "Socket trace categories",
206655
+ labelKey: "provider-reolink:connection.s_hub-debug.debugSocketLogs.label",
206656
+ description: "Each area adds log volume.",
206657
+ descriptionKey: "provider-reolink:connection.s_hub-debug.debugSocketLogs.description",
206658
+ default: [],
206659
+ options: [{
206660
+ value: "traceEvents",
206661
+ label: "Events (cmd 33 — AlarmEventList push)",
206662
+ labelKey: "provider-reolink:connection.s_hub-debug.debugSocketLogs.o_traceEvents.label"
206663
+ }]
206664
+ }]
206665
+ }] };
206666
+ }
206667
+ /**
206668
+ * `true` for an integration that IS one NVR / Home Hub: it was set up from a hub candidate, was
206669
+ * migrated from a Hub device, or carries a host. The default integration (standalone cameras,
206670
+ * email push, webhook, clips) has none of the three.
206671
+ */
206672
+ function isHubIntegration(evidence) {
206673
+ if (evidence.originStableId?.startsWith("reolink-hub-") === true) return true;
206674
+ if (evidence.info["migratedFromHubDeviceId"] !== void 0) return true;
206675
+ return stringValue(evidence.settings["host"]).length > 0;
206676
+ }
206677
+ /** The form of a hub integration: its connection and debug sections, nothing of the addon's own. */
206678
+ function buildHubInstanceSchema() {
206679
+ return buildHubConnectionSections();
206680
+ }
206681
+ //#endregion
206682
+ //#region src/hub-battery-fields.ts
206683
+ /**
206684
+ * The hub boundary for the per-channel battery power fields.
206685
+ *
206686
+ * The Reolink hub/NVR reports `chargeStatus` and `adapterStatus` as NUMBERS
206687
+ * (0/1/2); the standalone camera path reports the same facts as strings, and
206688
+ * `deriveChargingSource` (`battery-charging.ts`) speaks only the string
206689
+ * vocabulary. This module is the translation, and — the part that was
206690
+ * missing — it NAMES a code it cannot translate instead of dropping the field
206691
+ * silently. A dropped field reads as "the push said nothing about power", so
206692
+ * a new firmware value would have kept the previous belief forever, with not
206693
+ * one log line. The ledger lets the hub warn once per (device, field, code).
206694
+ */
206695
+ var CHARGE_STATUS_BY_CODE = {
206696
+ 0: "none",
206697
+ 1: "charging",
206698
+ 2: "chargeComplete"
206699
+ };
206700
+ var ADAPTER_STATUS_BY_CODE = {
206701
+ 0: "none",
206702
+ 1: "dc",
206703
+ 2: "solarPanel"
206704
+ };
206705
+ function mapOne(field, value, byCode) {
206706
+ if (value === void 0 || value === null) return {};
206707
+ if (typeof value === "string") return { mapped: value };
206708
+ if (typeof value === "number") {
206709
+ const mapped = byCode[value];
206710
+ if (mapped !== void 0) return { mapped };
206711
+ }
206712
+ return { unmapped: {
206713
+ field,
206714
+ value
206715
+ } };
206716
+ }
206717
+ /** Translate one channel's raw CGI power fields; never throws. */
206718
+ function mapHubBatteryPower(cgi) {
206719
+ const charge = mapOne("chargeStatus", cgi?.["chargeStatus"], CHARGE_STATUS_BY_CODE);
206720
+ const adapter = mapOne("adapterStatus", cgi?.["adapterStatus"], ADAPTER_STATUS_BY_CODE);
206721
+ return {
206722
+ fields: {
206723
+ ...charge.mapped !== void 0 ? { chargeStatus: charge.mapped } : {},
206724
+ ...adapter.mapped !== void 0 ? { adapterStatus: adapter.mapped } : {}
206725
+ },
206726
+ unmapped: [...charge.unmapped !== void 0 ? [charge.unmapped] : [], ...adapter.unmapped !== void 0 ? [adapter.unmapped] : []]
206727
+ };
206728
+ }
206729
+ /**
206730
+ * Remembers which (device, field, code) triples were already reported, so a
206731
+ * polled read warns once per finding and not once per tick. Unbounded only in
206732
+ * theory: the key space is a few cameras times two fields times the handful of
206733
+ * codes a firmware can invent.
206734
+ */
206735
+ var UnmappedCodeLedger = class {
206736
+ seen = /* @__PURE__ */ new Set();
206737
+ firstSeen(deviceId, field, value) {
206738
+ const key = `${deviceId}:${field}:${String(value)}`;
206739
+ if (this.seen.has(key)) return false;
206740
+ this.seen.add(key);
206741
+ return true;
206742
+ }
206743
+ };
206744
+ //#endregion
206745
+ //#region src/hub-channel-reconcile.ts
206746
+ /**
206747
+ * Match adopted children to the hub's own channel list by IDENTITY, then report the
206748
+ * ones whose persisted `channel` is missing or wrong.
206749
+ *
206750
+ * A child's stableId ENDS with `-${childNativeId}` (the prefix is the integration it was
206751
+ * adopted into, or the retired Hub's stableId for a migrated child), and `childNativeId` is built
206752
+ * from the camera's UID — so identity survives a channel move, while `channel` does not.
206753
+ * The hub is the authority on which slot a UID currently occupies.
206754
+ *
206755
+ * Why this exists: `loadAdoptedChildrenByChannel` keys the adopted set on the persisted
206756
+ * `channel` alone. A child that lost it is absent from that map, so the discovery panel
206757
+ * renders it `alreadyAdopted: false` — one click away from being adopted a SECOND time —
206758
+ * and `routeSimpleEvent` silently drops every push for its slot. Observed live on
206759
+ * 2026-09-04 with an Argus MagiCam that was actually on channel 3.
206760
+ *
206761
+ * Pure: no I/O. A discovered entry with no channel, or with no adopted child, is skipped —
206762
+ * this repairs what exists, it never adopts.
206763
+ */
206764
+ function planChannelReconciliation(discovered, children) {
206765
+ const repairs = [];
206766
+ for (const entry of discovered) {
206767
+ if (typeof entry.rtspChannel !== "number") continue;
206768
+ const child = children.find((c) => c.stableId.endsWith(`-${entry.childNativeId}`));
206769
+ if (child === void 0) continue;
206770
+ if (child.channel === entry.rtspChannel) continue;
206771
+ repairs.push({
206772
+ deviceId: child.deviceId,
206773
+ stableId: child.stableId,
206774
+ from: child.channel ?? null,
206775
+ to: entry.rtspChannel
206776
+ });
206777
+ }
206778
+ return repairs;
206779
+ }
206780
+ function createMotionSensorTracker(now = Date.now) {
206781
+ const latest = /* @__PURE__ */ new Map();
206782
+ return {
206783
+ observe(event) {
206784
+ if (event.type !== "motion") return;
206785
+ const sensor = event.motion.source;
206786
+ if (sensor === void 0) return;
206787
+ latest.set(event.channel, {
206788
+ sensor,
206789
+ at: now()
206790
+ });
206791
+ },
206792
+ take(channel) {
206793
+ const entry = latest.get(channel);
206794
+ if (!entry) return null;
206795
+ latest.delete(channel);
206796
+ return now() - entry.at <= 2e3 ? entry.sensor : null;
206797
+ }
206798
+ };
206799
+ }
206800
+ var isMotionSensor = (value) => value === "md" || value === "pir" || value === "unknown";
206801
+ /**
206802
+ * The sensor a simple event states itself, for a library version that forwards
206803
+ * `motion.source` on the simple event; `null` for the versions that do not.
206804
+ */
206805
+ function motionSensorOnSimpleEvent(event) {
206806
+ if (!("motion" in event)) return null;
206807
+ const motion = event.motion;
206808
+ if (typeof motion !== "object" || motion === null || !("source" in motion)) return null;
206809
+ return isMotionSensor(motion.source) ? motion.source : null;
206810
+ }
206811
+ //#endregion
206812
+ //#region src/simple-event-dispatch-trace.ts
206813
+ /**
206814
+ * Gate for hub-side simpleEvent traces.
206815
+ *
206816
+ * Hub-child events arrive on the parent's Baichuan socket. Logging them on
206817
+ * the child logger only is invisible when the operator filters Logs to the
206818
+ * Hub; logging every channel unconditionally floods the Hub (battery ticks).
206819
+ * Trace when the target camera — or the hub itself — is under debug.
206820
+ */
206821
+ function shouldTraceSimpleEventDispatch(input) {
206822
+ return wantsEventTrace(input.hubDebugGeneral, input.hubSocketFlags) || wantsEventTrace(input.childDebugGeneral, input.childSocketFlags);
206823
+ }
206824
+ function wantsEventTrace(debugGeneral, socketFlags) {
206825
+ return debugGeneral || socketFlags.includes("traceEvents");
206826
+ }
206827
+ //#endregion
206828
+ //#region src/reolink-hub-session.ts
206829
+ var HUB_DISCOVERY_REFRESH_TIMEOUT_MS = 8e3;
206830
+ /**
206831
+ * How often the session re-reads its own channels summary to reconcile the adopted children's
206832
+ * sleep slices. 60 s: a naturally-waking camera (PIR, schedule) is caught within a minute, and
206833
+ * the play path has its own faster confirm + post-dial watch, so this only ever carries
206834
+ * background drift. Never wakes a child — the summary is answered by the hub itself.
206835
+ */
206836
+ var HUB_CHILD_SLEEP_RECONCILE_MS = 6e4;
206837
+ var HUB_RECONNECT_BASE_MS = 2e3;
206838
+ var HUB_RECONNECT_MAX_MS = 3e4;
206839
+ var UNROUTED_LOG_THROTTLE_MS = 6e4;
206840
+ /**
206841
+ * ONE Reolink NVR / Home Hub: a single Baichuan control + event socket fronting N channels,
206842
+ * owned per integration (D985). It was the `ReolinkHub` device; the device is gone, the
206843
+ * mechanism is unchanged:
206844
+ *
206845
+ * - one persistent socket (no per-channel login), reconnected with a bounded backoff;
206846
+ * - every `simpleEvent` from any channel is dispatched into the matching child;
206847
+ * - every 60 s the hub's own channels summary reconciles the children's sleep, online and
206848
+ * battery slices — read from the hub's mains-powered socket, never from a battery camera.
206849
+ */
206850
+ var ReolinkHubSession = class {
206851
+ integrationId;
206852
+ #deps;
206853
+ #logger;
206854
+ #now;
206855
+ #connection;
206856
+ #api = null;
206857
+ #connectInFlight = null;
206858
+ #reconnectAttempts = 0;
206859
+ #reconnectTimer = null;
206860
+ #reconcileTimer = null;
206861
+ #shutdown = false;
206862
+ #online = false;
206863
+ #snapshot = {
206864
+ channels: [],
206865
+ lastRefreshAt: null,
206866
+ lastError: null
206867
+ };
206868
+ #refreshInFlight = null;
206869
+ /** Wall-clock ms of the last refresh that listed each child (by device id). */
206870
+ #childLastSeenAt = /* @__PURE__ */ new Map();
206871
+ /** Channels the last good refresh listed but nobody adopted: their pushes are expected drops. */
206872
+ #knownUnadoptedChannels = /* @__PURE__ */ new Set();
206873
+ #unroutedLoggedAt = /* @__PURE__ */ new Map();
206874
+ #batteryShapeLogged = false;
206875
+ #unmappedBatteryCodes = new UnmappedCodeLedger();
206876
+ #motionSensors = createMotionSensorTracker();
206877
+ #simpleEventBound = (event) => {
206878
+ this.routeSimpleEvent(event);
206879
+ };
206880
+ #rawEventBound = (event) => {
206881
+ this.#motionSensors.observe(event);
206882
+ };
206883
+ constructor(deps) {
206884
+ this.integrationId = deps.integrationId;
206885
+ this.#deps = deps;
206886
+ this.#logger = deps.logger;
206887
+ this.#connection = deps.connection;
206888
+ this.#now = deps.now ?? Date.now;
206889
+ }
206890
+ get connection() {
206891
+ return this.#connection;
206892
+ }
206893
+ get isOnline() {
206894
+ return this.#online;
206895
+ }
206896
+ /** The integration's display name. */
206897
+ get name() {
206898
+ return this.#deps.name;
206899
+ }
206900
+ /** Restart the hub itself (not a camera behind it). */
206901
+ async reboot() {
206902
+ await (await this.getApi()).reboot();
206903
+ }
206904
+ /** The last channel list, for the adoption surface. */
206905
+ channelSnapshot() {
206906
+ return this.#snapshot;
206907
+ }
206908
+ /**
206909
+ * What a child dials for the hub's RTSP relay. The port is the connection's RTSP port
206910
+ * (default 554) — never the Baichuan port.
206911
+ */
206912
+ relayEndpoint() {
206913
+ const c = this.#connection;
206914
+ return {
206915
+ host: c.host,
206916
+ username: c.username,
206917
+ password: c.password,
206918
+ port: c.rtspPort
206919
+ };
206920
+ }
206921
+ /** Arm the periodic reconcile and make the first dial. Idempotent. */
206922
+ start() {
206923
+ if (this.#reconcileTimer === null) {
206924
+ this.#reconcileTimer = setInterval(() => {
206925
+ this.refresh().catch(() => {});
206926
+ }, HUB_CHILD_SLEEP_RECONCILE_MS);
206927
+ if (typeof this.#reconcileTimer.unref === "function") this.#reconcileTimer.unref();
206928
+ }
206929
+ this.getApi().catch((err) => {
206930
+ this.#logger.warn("Reolink hub initial connect failed — will retry", {
206931
+ tags: { integrationId: this.integrationId },
206932
+ meta: { error: errMsg$3(err) }
206933
+ });
206934
+ });
206935
+ }
206936
+ /**
206937
+ * Apply a new connection. A session already dialled the same way keeps its socket; any real
206938
+ * change tears the socket down and re-dials with the new credentials.
206939
+ */
206940
+ async reconfigure(connection) {
206941
+ if (sameHubConnection(this.#connection, connection)) {
206942
+ this.#connection = connection;
206943
+ return;
206944
+ }
206945
+ this.#connection = connection;
206946
+ await this.dropApi("settings-changed");
206947
+ this.#setOnline(false);
206948
+ this.getApi().catch((err) => {
206949
+ this.#logger.warn("Reolink hub re-dial after settings change failed", {
206950
+ tags: { integrationId: this.integrationId },
206951
+ meta: { error: errMsg$3(err) }
206952
+ });
206953
+ });
206954
+ }
206955
+ async close() {
206956
+ this.#shutdown = true;
206957
+ if (this.#reconnectTimer !== null) {
206958
+ clearTimeout(this.#reconnectTimer);
206959
+ this.#reconnectTimer = null;
206960
+ }
206961
+ if (this.#reconcileTimer !== null) {
206962
+ clearInterval(this.#reconcileTimer);
206963
+ this.#reconcileTimer = null;
206964
+ }
206965
+ await this.dropApi("hub-shutdown");
206966
+ }
206967
+ /**
206968
+ * The logged-in api (single round-trip on first call). Mid-disconnect, callers wait for the
206969
+ * reconnect that is already in flight.
206970
+ */
206971
+ async getApi() {
206972
+ const live = this.#api;
206973
+ if (live !== null && live.client.isSocketConnected() && live.client.loggedIn) return live;
206974
+ if (this.#connectInFlight !== null) return this.#connectInFlight;
206975
+ this.#connectInFlight = this.#connect().finally(() => {
206976
+ this.#connectInFlight = null;
206977
+ });
206978
+ return this.#connectInFlight;
206979
+ }
206980
+ async dropApi(reason) {
206981
+ const api = this.#api;
206982
+ this.#api = null;
206983
+ if (api === null) return;
206984
+ try {
206985
+ await api.close({ reason });
206986
+ } catch {}
206987
+ }
206988
+ async #connect() {
206989
+ await this.dropApi("hub-reconnect");
206990
+ const connection = this.#connection;
206991
+ if (connection.host.length === 0) throw userError("reolink.hub-host-missing", void 0, "Reolink Hub: host not configured");
206992
+ if (connection.password.length === 0) throw userError("reolink.hub-password-missing", void 0, "Reolink Hub: password not configured");
206993
+ this.#logger.info("Reolink hub connecting", {
206994
+ tags: { integrationId: this.integrationId },
206995
+ meta: {
206996
+ host: connection.host,
206997
+ port: connection.port,
206998
+ transport: connection.transport
206999
+ }
207000
+ });
207001
+ const api = await this.#deps.connect(connection);
207002
+ try {
207003
+ await api.offSimpleEvent(this.#simpleEventBound);
207004
+ } catch {}
207005
+ api.client.off("event", this.#rawEventBound);
207006
+ api.client.prependListener("event", this.#rawEventBound);
207007
+ await api.onSimpleEvent(this.#simpleEventBound);
207008
+ api.client.on("close", () => this.#scheduleReconnect("socket-close"));
207009
+ api.client.on("error", (err) => {
207010
+ this.#logger.warn("Reolink hub socket error — scheduling reconnect", {
207011
+ tags: { integrationId: this.integrationId },
207012
+ meta: { error: errMsg$3(err) }
207013
+ });
207014
+ this.#scheduleReconnect("socket-error");
207015
+ });
207016
+ this.#api = api;
207017
+ this.#reconnectAttempts = 0;
207018
+ this.#setOnline(true);
207019
+ this.refresh().catch(() => {});
207020
+ return api;
207021
+ }
207022
+ #setOnline(online) {
207023
+ if (this.#online === online) return;
207024
+ this.#online = online;
207025
+ this.#deps.onConnectionChange?.(online);
207026
+ }
207027
+ #scheduleReconnect(reason) {
207028
+ if (this.#shutdown) return;
207029
+ this.#api = null;
207030
+ this.#setOnline(false);
207031
+ if (this.#reconnectTimer !== null) return;
207032
+ const backoff = Math.min(HUB_RECONNECT_BASE_MS * 2 ** this.#reconnectAttempts, HUB_RECONNECT_MAX_MS);
207033
+ this.#reconnectAttempts += 1;
207034
+ this.#logger.info("Reolink hub reconnect scheduled", {
207035
+ tags: { integrationId: this.integrationId },
207036
+ meta: {
207037
+ reason,
207038
+ attempt: this.#reconnectAttempts,
207039
+ backoffMs: backoff
207040
+ }
207041
+ });
207042
+ this.#reconnectTimer = setTimeout(() => {
207043
+ this.#reconnectTimer = null;
207044
+ this.getApi().catch(() => {});
207045
+ }, backoff);
207046
+ }
207047
+ /**
207048
+ * Pull the channel list from the hub via `getNvrChannelsSummary({ source: 'cgi' })` — CGI,
207049
+ * because `GetChannelstatus` answers synchronously (no race against the cmd_id 145 push
207050
+ * cache that is empty right after login). Single-flight.
207051
+ */
207052
+ refresh() {
207053
+ if (this.#refreshInFlight !== null) return this.#refreshInFlight;
207054
+ const run = this.#refresh().finally(() => {
207055
+ this.#refreshInFlight = null;
207056
+ });
207057
+ this.#refreshInFlight = run;
207058
+ return run;
207059
+ }
207060
+ async #refresh() {
207061
+ const startedAt = this.#now();
207062
+ let lastError = null;
207063
+ let channels = [];
207064
+ try {
207065
+ channels = (await (await this.getApi()).getNvrChannelsSummary({
207066
+ source: "cgi",
207067
+ timeoutMs: HUB_DISCOVERY_REFRESH_TIMEOUT_MS
207068
+ })).devices.map((d) => ({
207069
+ childNativeId: computeChildNativeId(d.channel, d.uid),
207070
+ channel: d.channel,
207071
+ name: d.name ?? `Channel ${d.channel}`,
207072
+ status: discoveredStatusFor(d),
207073
+ ...d.uid ? { uid: d.uid } : {},
207074
+ ...d.model ? { model: d.model } : {},
207075
+ ...d.serialNumber ? { serialNumber: d.serialNumber } : {},
207076
+ isBattery: d.isBattery === true,
207077
+ isDoorbell: d.isDoorbell === true,
207078
+ isMultifocal: d.isMultifocal === true
207079
+ }));
207080
+ } catch (err) {
207081
+ lastError = errMsg$3(err);
207082
+ this.#logger.warn("Reolink hub channel refresh failed", {
207083
+ tags: { integrationId: this.integrationId },
207084
+ meta: { error: lastError }
207085
+ });
207086
+ }
207087
+ this.#snapshot = lastError === null ? {
207088
+ channels,
207089
+ lastRefreshAt: this.#now(),
207090
+ lastError: null
207091
+ } : {
207092
+ ...this.#snapshot,
207093
+ lastError
207094
+ };
207095
+ if (lastError !== null) return;
207096
+ await this.reconcileChildChannels(channels);
207097
+ const adoptedChannels = /* @__PURE__ */ new Set();
207098
+ for (const child of this.#deps.children()) if (child.channel !== void 0) adoptedChannels.add(child.channel);
207099
+ this.#knownUnadoptedChannels.clear();
207100
+ for (const c of channels) if (!adoptedChannels.has(c.channel)) this.#knownUnadoptedChannels.add(c.channel);
207101
+ this.#logger.info("Reolink hub: channel refresh complete", {
207102
+ tags: { integrationId: this.integrationId },
207103
+ meta: {
207104
+ durationMs: this.#now() - startedAt,
207105
+ total: channels.length,
207106
+ adopted: channels.filter((c) => adoptedChannels.has(c.channel)).length,
207107
+ online: channels.filter((c) => c.status === "online").length,
207108
+ sleeping: channels.filter((c) => c.status === "sleeping").length
207109
+ }
207110
+ });
207111
+ this.reconcileChildOnline(channels);
207112
+ this.reconcileChildSleepState(channels);
207113
+ await this.reconcileChildBattery();
207114
+ }
207115
+ /**
207116
+ * Re-derive each adopted child's `channel` from the hub's own list, matching by IDENTITY
207117
+ * (the stableId carries the camera UID) rather than by the channel itself — a child that lost
207118
+ * its channel is invisible to the routing, and a channel move produces the same damage under
207119
+ * a wrong number. A child that is not live is skipped: it is repaired on its next restore.
207120
+ */
207121
+ async reconcileChildChannels(channels) {
207122
+ const children = this.#deps.children();
207123
+ const repairs = planChannelReconciliation(channels.map((c) => ({
207124
+ childNativeId: c.childNativeId,
207125
+ rtspChannel: c.channel
207126
+ })), children.map((child) => ({
207127
+ deviceId: child.id,
207128
+ stableId: child.stableId,
207129
+ ...child.channel !== void 0 ? { channel: child.channel } : {}
207130
+ })));
207131
+ for (const repair of repairs) {
207132
+ const child = children.find((c) => c.id === repair.deviceId);
207133
+ if (child === void 0) continue;
207134
+ try {
207135
+ await child.repairChannel(repair.to);
207136
+ this.#logger.warn("Reolink hub: repaired a child channel from the hub channel list", {
207137
+ tags: {
207138
+ deviceId: repair.deviceId,
207139
+ stableId: repair.stableId
207140
+ },
207141
+ meta: {
207142
+ from: repair.from,
207143
+ to: repair.to
207144
+ }
207145
+ });
207146
+ } catch (err) {
207147
+ this.#logger.warn("Reolink hub: child channel repair failed", {
207148
+ tags: {
207149
+ deviceId: repair.deviceId,
207150
+ stableId: repair.stableId
207151
+ },
207152
+ meta: {
207153
+ from: repair.from,
207154
+ to: repair.to,
207155
+ error: errMsg$3(err)
207156
+ }
207157
+ });
207158
+ }
207159
+ }
207160
+ }
207161
+ /**
207162
+ * Mark every child still listed `online`, and any child missing for more than
207163
+ * `HUB_MISSING_OFFLINE_MS` `offline` — "if the hub firmware doesn't list it, the device isn't
207164
+ * reachable through the hub". Battery cams that briefly drop out of the summary during deep
207165
+ * sleep are not false-flagged: the threshold gives 3+ cycles of grace.
207166
+ */
207167
+ reconcileChildOnline(channels) {
207168
+ const now = this.#now();
207169
+ const listed = new Set(channels.map((c) => c.channel));
207170
+ for (const child of this.#deps.children()) {
207171
+ const ch = child.channel;
207172
+ if (ch === void 0) continue;
207173
+ if (child.type !== DeviceType.Camera) continue;
207174
+ if (listed.has(ch)) {
207175
+ this.#childLastSeenAt.set(child.id, now);
207176
+ if (!child.online) {
207177
+ child.markOnline(true);
207178
+ this.#logger.info("Reolink hub: adopted child back online", {
207179
+ tags: {
207180
+ deviceId: child.id,
207181
+ deviceName: child.name
207182
+ },
207183
+ meta: { channel: ch }
207184
+ });
207185
+ }
207186
+ continue;
207187
+ }
207188
+ const lastSeen = this.#childLastSeenAt.get(child.id) ?? 0;
207189
+ const missingMs = lastSeen > 0 ? now - lastSeen : Number.POSITIVE_INFINITY;
207190
+ if (missingMs >= 6e5 && child.online) {
207191
+ child.markOnline(false);
207192
+ this.#logger.warn("Reolink hub: adopted child marked offline (missing from the hub list)", {
207193
+ tags: {
207194
+ deviceId: child.id,
207195
+ deviceName: child.name
207196
+ },
207197
+ meta: {
207198
+ channel: ch,
207199
+ missingMs
207200
+ }
207201
+ });
207202
+ }
207203
+ }
207204
+ }
207205
+ /**
207206
+ * Push the hub's per-channel sleep state into each adopted battery child's `battery.sleeping`
207207
+ * slice. The summary carries a `sleeping` flag straight from the firmware over the hub's own
207208
+ * mains-powered socket: it costs the battery channel nothing, and it is real firmware state
207209
+ * rather than socket-I/O inference. Only `online` / `sleeping` are acted on: `offline` and
207210
+ * `unknown` carry no sleep information, so the last known value stands.
207211
+ */
207212
+ reconcileChildSleepState(channels) {
207213
+ const byChannel = new Map(channels.map((c) => [c.channel, c]));
207214
+ for (const child of this.#deps.children()) {
207215
+ if (child.channel === void 0) continue;
207216
+ const entry = byChannel.get(child.channel);
207217
+ if (entry === void 0) continue;
207218
+ if (entry.status !== "sleeping" && entry.status !== "online") continue;
207219
+ child.applyHubSleepState(entry.status === "sleeping");
207220
+ }
207221
+ }
207222
+ /**
207223
+ * Push the hub's per-channel battery read into each adopted battery child.
207224
+ * `getAllChannelsBatteryInfo` is one CGI on the hub's own socket — it never touches a battery
207225
+ * camera — and it is the ONLY way a sleeping child's percentage/charging ever moves.
207226
+ * Independent of the refresh's success path: a battery-read failure must not fail the refresh.
207227
+ */
207228
+ async reconcileChildBattery() {
207229
+ const children = this.#deps.children();
207230
+ if (children.length === 0) return;
207231
+ try {
207232
+ const data = await (await this.getApi()).getAllChannelsBatteryInfo();
207233
+ const entries = Object.entries(data.batteryInfoData);
207234
+ if (entries.length === 0) return;
207235
+ if (!this.#batteryShapeLogged) {
207236
+ this.#batteryShapeLogged = true;
207237
+ this.#logger.info("Reolink hub: per-channel battery payload (first read)", {
207238
+ tags: { integrationId: this.integrationId },
207239
+ meta: { raw: entries.map(([ch, v]) => ({
207240
+ channel: Number(ch),
207241
+ ...v
207242
+ })) }
207243
+ });
207244
+ }
207245
+ for (const [chKey, info] of entries) {
207246
+ const child = children.find((c) => c.channel === Number(chKey));
207247
+ if (child === void 0) continue;
207248
+ const power = mapHubBatteryPower(info.entries[0]);
207249
+ for (const { field, value } of power.unmapped) {
207250
+ if (!this.#unmappedBatteryCodes.firstSeen(child.id, field, value)) continue;
207251
+ this.#logger.warn("Reolink hub: unmapped battery power code — field dropped", {
207252
+ tags: { deviceId: child.id },
207253
+ meta: {
207254
+ channel: Number(chKey),
207255
+ field,
207256
+ value
207257
+ }
207258
+ });
207259
+ }
207260
+ child.applyHubBatteryInfo({
207261
+ batteryPercent: info.batteryLevel,
207262
+ ...power.fields
207263
+ });
207264
+ }
207265
+ } catch (err) {
207266
+ this.#logger.debug("Reolink hub: per-channel battery refresh failed", {
207267
+ tags: { integrationId: this.integrationId },
207268
+ meta: { error: errMsg$3(err) }
207269
+ });
207270
+ }
207271
+ }
207272
+ /** Dispatch a hub-received simpleEvent to the matching child's `handleSimpleEvent`. */
207273
+ routeSimpleEvent(event) {
207274
+ const channel = event?.channel;
207275
+ if (typeof channel !== "number") return;
207276
+ const motionSensor = event.type === "motion" ? this.#motionSensors.take(channel) : null;
207277
+ const child = this.#deps.children().find((c) => c.channel === channel);
207278
+ if (child === void 0) {
207279
+ const last = this.#unroutedLoggedAt.get(channel) ?? 0;
207280
+ const now = this.#now();
207281
+ if (now - last > UNROUTED_LOG_THROTTLE_MS) {
207282
+ this.#unroutedLoggedAt.set(channel, now);
207283
+ const knownUnadopted = this.#knownUnadoptedChannels.has(channel);
207284
+ this.#logger[knownUnadopted ? "debug" : "warn"]("Reolink hub: simpleEvent for an unmapped channel dropped", {
207285
+ tags: { integrationId: this.integrationId },
207286
+ meta: {
207287
+ channel,
207288
+ type: event?.type ?? "unknown",
207289
+ knownUnadopted
207290
+ }
207291
+ });
207292
+ }
207293
+ return;
207294
+ }
207295
+ try {
207296
+ if (shouldTraceSimpleEventDispatch({
207297
+ hubDebugGeneral: this.#connection.debugGeneral,
207298
+ hubSocketFlags: this.#connection.debugSocketLogs,
207299
+ childDebugGeneral: child.debugGeneral,
207300
+ childSocketFlags: child.debugSocketFlags
207301
+ })) this.#logger.info("Reolink hub: simpleEvent dispatch", {
207302
+ tags: { deviceId: child.id },
207303
+ meta: {
207304
+ type: event?.type ?? "unknown",
207305
+ channel,
207306
+ timestamp: event?.timestamp,
207307
+ name: child.name
207308
+ }
207309
+ });
207310
+ child.handleSimpleEvent(event, motionSensor);
207311
+ } catch (err) {
207312
+ this.#logger.debug("Reolink hub simpleEvent dispatch failed", {
207313
+ tags: { deviceId: child.id },
207314
+ meta: {
207315
+ channel,
207316
+ error: errMsg$3(err)
207317
+ }
207318
+ });
207319
+ }
207320
+ }
207321
+ };
207322
+ /**
207323
+ * A stable per-child identifier. UID is preferred when available (it identifies a paired camera
207324
+ * across re-pairings); otherwise `channel-N`, which is stable for a fixed channel assignment.
207325
+ */
207326
+ function computeChildNativeId(channel, uid) {
207327
+ if (uid && uid.length > 0) return `cam-${uid}`;
207328
+ return `channel-${channel}`;
207329
+ }
207330
+ /**
207331
+ * Map the lib's per-channel summary booleans to our `DiscoveredChildStatus`. Precedence:
207332
+ * `online === false` → offline; `sleeping === true` → sleeping; `online === true` → online;
207333
+ * any non-empty state → online; unknown otherwise (the cmd_id 145 push has not landed yet).
207334
+ */
207335
+ function discoveredStatusFor(d) {
207336
+ if (d.online === false) return "offline";
207337
+ if (d.sleeping === true) return "sleeping";
207338
+ if (d.online === true) return "online";
207339
+ if (typeof d.state === "string" && d.state.length > 0) return "online";
207340
+ return "unknown";
207341
+ }
207342
+ /** The wire-shaped channel the adoption surface lists. */
207343
+ function toDiscoveredChild(channel, adoptedDeviceId) {
207344
+ return {
207345
+ childNativeId: channel.childNativeId,
207346
+ name: channel.name,
207347
+ type: DeviceType.Camera,
207348
+ status: channel.status,
207349
+ metadata: {
207350
+ ...channel.model ? { model: channel.model } : {},
207351
+ ...channel.serialNumber ? { serialNumber: channel.serialNumber } : {},
207352
+ ...channel.uid ? { uid: channel.uid } : {},
207353
+ rtspChannel: channel.channel,
207354
+ isBattery: channel.isBattery,
207355
+ isDoorbell: channel.isDoorbell,
207356
+ isMultifocal: channel.isMultifocal
207357
+ },
207358
+ alreadyAdopted: adoptedDeviceId !== null,
207359
+ adoptedDeviceId
207360
+ };
207361
+ }
207362
+ var GATEWAY_SUFFIX = ":gateway";
207363
+ /** The `childNativeId` of the hub box itself, one per integration. */
207364
+ function hubGatewayNativeId(integrationId) {
207365
+ return `${integrationId}${GATEWAY_SUFFIX}`;
207366
+ }
207367
+ /**
207368
+ * The hub (NVR / Home Hub) as an adoption candidate: a {@link DeviceType.Gateway} that is a
207369
+ * sibling of its channel cameras, never their parent. Only what the library really exposes:
207370
+ * `reboot` (Baichuan `Reboot`) and the unit's web page.
207371
+ */
207372
+ function toDiscoveredGateway(input) {
207373
+ return {
207374
+ childNativeId: hubGatewayNativeId(input.integrationId),
207375
+ name: input.name,
207376
+ type: DeviceType.Gateway,
207377
+ status: input.online ? "online" : "offline",
207378
+ metadata: { manufacturer: "Reolink" },
207379
+ alreadyAdopted: input.adoptedDeviceId !== null,
207380
+ adoptedDeviceId: input.adoptedDeviceId,
207381
+ capabilities: [rebootCapability.name, deviceAdminLinkCapability.name]
207382
+ };
207383
+ }
207384
+ /**
207385
+ * The per-integration registry the cameras reach their session through (the shape of
207386
+ * `veluxGateways` / `enpalConnections`). It also holds the children, so a child that activates
207387
+ * before its session exists (boot order) is already routable the moment the session starts.
207388
+ */
207389
+ var ReolinkHubSessions = class {
207390
+ #sessions = /* @__PURE__ */ new Map();
207391
+ #children = /* @__PURE__ */ new Map();
207392
+ set(integrationId, session) {
207393
+ if (session === null) {
207394
+ this.#sessions.delete(integrationId);
207395
+ return;
207396
+ }
207397
+ this.#sessions.set(integrationId, session);
207398
+ }
207399
+ get(integrationId) {
207400
+ return this.#sessions.get(integrationId) ?? null;
207401
+ }
207402
+ attach(integrationId, child) {
207403
+ let set = this.#children.get(integrationId);
207404
+ if (set === void 0) {
207405
+ set = /* @__PURE__ */ new Map();
207406
+ this.#children.set(integrationId, set);
207407
+ }
207408
+ set.set(child.id, child);
207409
+ }
207410
+ /** Detach only if `child` is still the attached handle — a rebuilt device keeps its slot. */
207411
+ detach(integrationId, child) {
207412
+ const set = this.#children.get(integrationId);
207413
+ if (set?.get(child.id) === child) set.delete(child.id);
207414
+ }
207415
+ childrenOf(integrationId) {
207416
+ return [...this.#children.get(integrationId)?.values() ?? []];
207417
+ }
207418
+ clear() {
207419
+ this.#sessions.clear();
207420
+ this.#children.clear();
207421
+ }
207422
+ };
207423
+ /** The module-level registry (one per addon process). */
207424
+ var reolinkHubSessions = new ReolinkHubSessions();
207425
+ /** Dial a real hub. The default `connect` dependency of a session. */
207426
+ async function connectReolinkHub(connection) {
207427
+ const api = new ReolinkBaichuanApi({
207428
+ host: connection.host,
207429
+ port: connection.port,
207430
+ username: connection.username,
207431
+ password: connection.password,
207432
+ transport: connection.transport,
207433
+ ...connection.uid ? { uid: connection.uid } : {}
207434
+ });
207435
+ await api.login();
207436
+ return api;
207437
+ }
207438
+ //#endregion
207422
207439
  //#region src/sleep-state-decision.ts
207423
207440
  /**
207424
207441
  * Hysteresis is ASYMMETRIC, because the two directions have very different
@@ -207518,6 +207535,34 @@ function decideSnapshotCameraAccess(input) {
207518
207535
  };
207519
207536
  }
207520
207537
  //#endregion
207538
+ //#region src/webhook-generation.ts
207539
+ /**
207540
+ * Point a battery camera's webhook at the data-plane URL ONCE per URL generation.
207541
+ *
207542
+ * Runs on a camera's first Baichuan contact after the upgrade (the one moment it is awake without
207543
+ * us waking it). Only a camera that is actually armed is moved: reading its HaCfg decides, so a
207544
+ * camera on e-mail push is never switched to the webhook by an upgrade. The generation is written
207545
+ * only when the camera was moved or found not to need moving; a failure (no internal address, the
207546
+ * camera refusing the write) leaves it unwritten, so the next contact tries again.
207547
+ */
207548
+ async function repointWebhookOnce(ports) {
207549
+ const stored = ports.readGeneration();
207550
+ if (stored !== void 0 && stored >= 1) return "current";
207551
+ const armed = await ports.readCameraHaCfg();
207552
+ if (armed === void 0 || !armed.enable || armed.url.length === 0) {
207553
+ await ports.writeGeneration(1);
207554
+ return "not-armed";
207555
+ }
207556
+ const url = await ports.targetUrl();
207557
+ if (armed.url === url) {
207558
+ await ports.writeGeneration(1);
207559
+ return "already-there";
207560
+ }
207561
+ await ports.arm(url);
207562
+ await ports.writeGeneration(1);
207563
+ return "repointed";
207564
+ }
207565
+ //#endregion
207521
207566
  //#region src/accessories/base.ts
207522
207567
  /**
207523
207568
  * Reolink accessory base layer — kind enum, parent-host contract,
@@ -211934,7 +211979,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
211934
211979
  }
211935
211980
  if (!this.baichuanWebhookParamsResolver) throw userError("reolink.webhook-not-wired", void 0, "HaCfg Webhook Server is not wired — restart the Reolink addon");
211936
211981
  const params = await this.baichuanWebhookParamsResolver();
211937
- if (!params) throw new Error("Enable the HaCfg Webhook Server in the Reolink addon settings, and ensure this host has a reachable LAN address.");
211982
+ if (!params) throw new Error("The HaCfg webhook cannot be armed for this camera (it is not a standalone battery camera).");
211938
211983
  await this.wakeIfSleeping();
211939
211984
  const api = await this.ensureApi();
211940
211985
  const supported = await api.probeBaichuanWebhookSupport();
@@ -211951,12 +211996,47 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
211951
211996
  try {
211952
211997
  await api.subscribeEvents();
211953
211998
  } catch {}
211999
+ await this.config.setAll({ webhookUrlGeneration: 1 });
211954
212000
  this.ctx.logger.info("baichuan-webhook: camera auto-configure applied", {
211955
212001
  tags: { deviceId: this.id },
211956
212002
  meta: { url: params.url }
211957
212003
  });
211958
212004
  }
211959
212005
  /**
212006
+ * Move this battery camera's HaCfg webhook to the addon's data-plane URL, once, on the first
212007
+ * Baichuan contact after the upgrade (the camera is awake: this costs no wake). Only a camera
212008
+ * that is armed is touched; a failure is logged and retried at the next contact.
212009
+ */
212010
+ async repointWebhookOnFirstContact(api) {
212011
+ const resolver = this.baichuanWebhookParamsResolver;
212012
+ if (!this.isBattery || !this.isEmailPushEligible || resolver === void 0) return;
212013
+ try {
212014
+ if (await repointWebhookOnce({
212015
+ readGeneration: () => this.config.get("webhookUrlGeneration"),
212016
+ writeGeneration: async (generation) => {
212017
+ await this.config.setAll({ webhookUrlGeneration: generation });
212018
+ },
212019
+ readCameraHaCfg: async () => await api.getHaCfg(),
212020
+ targetUrl: async () => {
212021
+ const params = await resolver();
212022
+ if (params === null) throw new Error("this camera has no webhook URL");
212023
+ return params.url;
212024
+ },
212025
+ arm: async (url) => {
212026
+ await api.setupBaichuanWebhookToManager({
212027
+ url,
212028
+ verifyCert: false
212029
+ });
212030
+ }
212031
+ }) === "repointed") this.ctx.logger.info("baichuan-webhook: camera moved to the data-plane URL", { tags: { deviceId: this.id } });
212032
+ } catch (err) {
212033
+ this.ctx.logger.warn("baichuan-webhook: re-point failed — retried at the next contact", {
212034
+ tags: { deviceId: this.id },
212035
+ meta: { error: err instanceof Error ? err.message : String(err) }
212036
+ });
212037
+ }
212038
+ }
212039
+ /**
211960
212040
  * Read the camera's OWN email/SMTP config (`getEmail` cmd 42 + `getEmailTask`
211961
212041
  * cmd 216) and persist a snapshot into `deviceCache.emailConfigSnapshot` so
211962
212042
  * the Email Push settings section can show the camera's CURRENT mail config
@@ -218157,6 +218237,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
218157
218237
  this.registerBatteryIfSupported();
218158
218238
  this.refreshBatteryFromApi("demand");
218159
218239
  }
218240
+ if (this.isBattery) this.repointWebhookOnFirstContact(api);
218160
218241
  this.startWatchdogs();
218161
218242
  this.probeAndPersistFeatures(api).catch((err) => {
218162
218243
  this.ctx.logger.debug("Reolink feature probe failed (non-fatal)", {
@@ -218211,7 +218292,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
218211
218292
  return {
218212
218293
  type: "device",
218213
218294
  id: this.id,
218214
- addonId: REOLINK_ADDON_ID,
218295
+ addonId: REOLINK_ADDON_ID$1,
218215
218296
  deviceId: this.id
218216
218297
  };
218217
218298
  }
@@ -218304,7 +218385,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
218304
218385
  this.ctx.logger.info("Reolink camera woke up", { tags: { deviceId: this.id } });
218305
218386
  this.ctx.eventBus.emit(createEvent(EventCategory.DeviceAwake, eventSource, {
218306
218387
  deviceId: this.id,
218307
- providerId: REOLINK_ADDON_ID,
218388
+ providerId: REOLINK_ADDON_ID$1,
218308
218389
  reason: "awake"
218309
218390
  }));
218310
218391
  this.onWakeTransition("simple-event").catch(() => {});
@@ -218318,12 +218399,12 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
218318
218399
  this.markOnline(next);
218319
218400
  this.ctx.eventBus.emit(createEvent(next ? EventCategory.DeviceOnline : EventCategory.DeviceOffline, eventSource, next ? {
218320
218401
  deviceId: this.id,
218321
- providerId: REOLINK_ADDON_ID,
218402
+ providerId: REOLINK_ADDON_ID$1,
218322
218403
  profileCount: 0,
218323
218404
  reason: "firmware-push"
218324
218405
  } : {
218325
218406
  deviceId: this.id,
218326
- providerId: REOLINK_ADDON_ID,
218407
+ providerId: REOLINK_ADDON_ID$1,
218327
218408
  reason: "firmware-push"
218328
218409
  }));
218329
218410
  }
@@ -218334,7 +218415,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
218334
218415
  if (!this.commitSleepState(true, "simple-event")) return;
218335
218416
  if (!wasSleeping) this.ctx.eventBus.emit(createEvent(EventCategory.DeviceSleeping, eventSource, {
218336
218417
  deviceId: this.id,
218337
- providerId: REOLINK_ADDON_ID,
218418
+ providerId: REOLINK_ADDON_ID$1,
218338
218419
  reason: "sleeping"
218339
218420
  }));
218340
218421
  if (this.active.size > 0) {
@@ -218566,7 +218647,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
218566
218647
  this.markOnline(false);
218567
218648
  this.ctx.eventBus.emit(createEvent(EventCategory.DeviceOffline, this.eventSource(), {
218568
218649
  deviceId: this.id,
218569
- providerId: REOLINK_ADDON_ID,
218650
+ providerId: REOLINK_ADDON_ID$1,
218570
218651
  reason: "unreachable"
218571
218652
  }));
218572
218653
  }
@@ -218580,7 +218661,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
218580
218661
  this.markOnline(true);
218581
218662
  this.ctx.eventBus.emit(createEvent(EventCategory.DeviceOnline, this.eventSource(), {
218582
218663
  deviceId: this.id,
218583
- providerId: REOLINK_ADDON_ID,
218664
+ providerId: REOLINK_ADDON_ID$1,
218584
218665
  profileCount: 0,
218585
218666
  reason: "reachable-again"
218586
218667
  }));
@@ -218763,281 +218844,6 @@ var AutodetectCache = class {
218763
218844
  }
218764
218845
  };
218765
218846
  //#endregion
218766
- //#region src/baichuan-webhook-shared.ts
218767
- /** Default HTTP listen port for HaCfg POSTs (matches nodelink docs / HA). */
218768
- var DEFAULT_BAICHUAN_WEBHOOK_PORT = 9081;
218769
- /** Path prefix before the camera id. Final route: `${prefix}/:cameraId`. */
218770
- var DEFAULT_BAICHUAN_WEBHOOK_PATH_PREFIX = "/webhook";
218771
- /**
218772
- * Absolute URL a camera should POST HaCfg events to.
218773
- * HTTP (not HTTPS): Reolink HaCfg often fails TLS verify against the hub's
218774
- * self-signed cert; the intake binds a dedicated LAN port like SMTP.
218775
- */
218776
- function buildBaichuanWebhookUrl(params) {
218777
- const prefix = (params.pathPrefix ?? "/webhook").replace(/\/$/, "");
218778
- const id = encodeURIComponent(params.cameraId);
218779
- return `http://${params.host}:${String(params.port)}${prefix}/${id}`;
218780
- }
218781
- //#endregion
218782
- //#region src/baichuan-webhook-schema.ts
218783
- /**
218784
- * Addon-level settings for the Baichuan HaCfg HTTP intake.
218785
- *
218786
- * Sibling of the Email/SMTP Push sections — same integration home
218787
- * (System → Integrations → Reolink). Declared via
218788
- * `integrationSettingSections()`.
218789
- */
218790
- var BAICHUAN_WEBHOOK_SECTION_IDS = ["baichuan-webhook"];
218791
- function buildBaichuanWebhookSettingsSchema(recommendedHost) {
218792
- return { sections: [{
218793
- id: "baichuan-webhook",
218794
- title: "Battery-cam Motion (HaCfg Webhook)",
218795
- titleKey: "provider-reolink:baichuanWebhookSettings.s_baichuan-webhook.title",
218796
- description: "Preferred over email push when the camera firmware supports it.",
218797
- descriptionKey: "provider-reolink:baichuanWebhookSettings.s_baichuan-webhook.description",
218798
- columns: 2,
218799
- fields: [
218800
- {
218801
- type: "boolean",
218802
- key: "baichuanWebhookEnabled",
218803
- label: "Enable HaCfg Webhook Server",
218804
- labelKey: "provider-reolink:baichuanWebhookSettings.s_baichuan-webhook.baichuanWebhookEnabled.label",
218805
- default: true
218806
- },
218807
- {
218808
- type: "info",
218809
- key: "baichuanWebhookRecommendedHost",
218810
- label: "Camera-facing host",
218811
- labelKey: "provider-reolink:baichuanWebhookSettings.s_baichuan-webhook.baichuanWebhookRecommendedHost.label",
218812
- content: `Point cameras at this host: ${recommendedHost}`
218813
- },
218814
- {
218815
- type: "number",
218816
- key: "baichuanWebhookPort",
218817
- label: "HTTP Port",
218818
- labelKey: "provider-reolink:baichuanWebhookSettings.s_baichuan-webhook.baichuanWebhookPort.label",
218819
- default: DEFAULT_BAICHUAN_WEBHOOK_PORT,
218820
- min: 1,
218821
- max: 65535
218822
- },
218823
- {
218824
- type: "text",
218825
- key: "baichuanWebhookBindHost",
218826
- label: "Bind host",
218827
- labelKey: "provider-reolink:baichuanWebhookSettings.s_baichuan-webhook.baichuanWebhookBindHost.label",
218828
- default: "0.0.0.0",
218829
- description: "0.0.0.0 accepts on every interface (cameras on LAN).",
218830
- descriptionKey: "provider-reolink:baichuanWebhookSettings.s_baichuan-webhook.baichuanWebhookBindHost.description"
218831
- }
218832
- ]
218833
- }] };
218834
- }
218835
- //#endregion
218836
- //#region src/email-push-shared.ts
218837
- /**
218838
- * Map the lib's email-push classifier output onto the `ReolinkSimpleEvent`
218839
- * types the camera should be fed. AI subtypes + motion pass through;
218840
- * anything unrecognised collapses to plain `motion` so a wake is never
218841
- * silently dropped.
218842
- *
218843
- * Returns a LIST rather than a single type because of `doorbell`. The
218844
- * camera's `handleSimpleEvent` emits `ProviderMotion` for `motion`
218845
- * and for every AI class, but the `doorbell` branch emits ONLY
218846
- * `DoorbellOnPressed` and returns. An email is the sole signal a sleeping
218847
- * battery camera can send, so a doorbell-classified email mapped to
218848
- * `doorbell` alone rang the bell and left motion, recording and
218849
- * notification rules blind — the exact "silently dropped wake" this mapping
218850
- * exists to prevent. Pairing it with `motion` keeps the doorbell semantic
218851
- * AND the wake.
218852
- */
218853
- function mapInferredTypeToSimpleEvents(inferred) {
218854
- switch (inferred) {
218855
- case "people":
218856
- case "vehicle":
218857
- case "animal":
218858
- case "face":
218859
- case "package":
218860
- case "motion": return [inferred];
218861
- case "doorbell": return ["doorbell", "motion"];
218862
- default: return ["motion"];
218863
- }
218864
- }
218865
- /**
218866
- * Enumerate candidate camera-facing LAN addresses, best first (private
218867
- * RFC-1918 ranges ranked ahead of public). Link-local and internal
218868
- * interfaces are excluded — a camera can't reach 127.0.0.1.
218869
- */
218870
- function listLanHosts() {
218871
- const ifaces = os.networkInterfaces();
218872
- const candidates = [];
218873
- for (const list of Object.values(ifaces)) {
218874
- if (!list) continue;
218875
- for (const addr of list) {
218876
- if (addr.family !== "IPv4") continue;
218877
- if (addr.internal) continue;
218878
- if (addr.address.startsWith("169.254.")) continue;
218879
- 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;
218880
- candidates.push({
218881
- addr: addr.address,
218882
- rank
218883
- });
218884
- }
218885
- }
218886
- candidates.sort((a, b) => a.rank - b.rank);
218887
- return candidates.map((c) => c.addr);
218888
- }
218889
- //#endregion
218890
- //#region src/baichuan-webhook-server.ts
218891
- function readString(raw, key, fallback) {
218892
- const v = raw[key];
218893
- return typeof v === "string" && v.length > 0 ? v : fallback;
218894
- }
218895
- function readNumber(raw, key, fallback) {
218896
- const v = raw[key];
218897
- return typeof v === "number" && Number.isFinite(v) ? v : fallback;
218898
- }
218899
- function readBoolean(raw, key, fallback) {
218900
- const v = raw[key];
218901
- return typeof v === "boolean" ? v : fallback;
218902
- }
218903
- var ReolinkBaichuanWebhookServer = class {
218904
- deps;
218905
- instance;
218906
- busOff;
218907
- settings;
218908
- constructor(deps) {
218909
- this.deps = deps;
218910
- }
218911
- async resolveSettings() {
218912
- if (this.settings) return this.settings;
218913
- const raw = await this.deps.readStore();
218914
- this.settings = {
218915
- enabled: readBoolean(raw, "baichuanWebhookEnabled", true),
218916
- port: readNumber(raw, "baichuanWebhookPort", DEFAULT_BAICHUAN_WEBHOOK_PORT),
218917
- bindHost: readString(raw, "baichuanWebhookBindHost", "0.0.0.0")
218918
- };
218919
- return this.settings;
218920
- }
218921
- /** Drop the cached settings so the next start re-reads the store. */
218922
- reloadSettings() {
218923
- this.settings = void 0;
218924
- }
218925
- findCameraByStableId(stableId) {
218926
- return this.deps.listCameras().find((c) => c.stableId === stableId);
218927
- }
218928
- resolveCameraId(pathId) {
218929
- const cam = this.findCameraByStableId(pathId);
218930
- if (!cam || !cam.isEmailPushEligible) return void 0;
218931
- return pathId;
218932
- }
218933
- dispatchEvent(event) {
218934
- const cam = this.findCameraByStableId(event.cameraId);
218935
- if (!cam) {
218936
- this.deps.logger.warn("baichuan-webhook: event for unknown camera id — dropping", { meta: {
218937
- cameraId: event.cameraId,
218938
- event: event.event
218939
- } });
218940
- return;
218941
- }
218942
- const types = mapBaichuanWebhookToSimpleEvents({
218943
- event: event.event,
218944
- reason: event.reason
218945
- });
218946
- this.deps.logger.info("baichuan-webhook: event received from battery camera", {
218947
- tags: { deviceId: cam.id },
218948
- meta: {
218949
- event: event.event,
218950
- reason: event.reason,
218951
- types
218952
- }
218953
- });
218954
- const timestamp = Date.now();
218955
- const channel = cam.emailPushChannel;
218956
- for (const type of types) cam.handleSimpleEvent({
218957
- type,
218958
- channel,
218959
- timestamp,
218960
- source: "baichuanWebhook"
218961
- });
218962
- }
218963
- async start() {
218964
- const settings = await this.resolveSettings();
218965
- if (!settings.enabled) {
218966
- this.deps.logger.debug("baichuan-webhook: disabled — HTTP intake not started");
218967
- return;
218968
- }
218969
- if (this.instance) {
218970
- this.instance.updateConfig({
218971
- port: settings.port,
218972
- bindHost: settings.bindHost,
218973
- pathPrefix: DEFAULT_BAICHUAN_WEBHOOK_PATH_PREFIX
218974
- });
218975
- return;
218976
- }
218977
- this.instance = createBaichuanWebhookServer({
218978
- config: {
218979
- port: settings.port,
218980
- bindHost: settings.bindHost,
218981
- pathPrefix: DEFAULT_BAICHUAN_WEBHOOK_PATH_PREFIX
218982
- },
218983
- cameraResolver: (pathId) => this.resolveCameraId(pathId),
218984
- enabled: true,
218985
- logger: {
218986
- debug: (m) => this.deps.logger.debug(`[hacfg] ${m}`),
218987
- info: (m) => this.deps.logger.info(`[hacfg] ${m}`),
218988
- warn: (m) => this.deps.logger.warn(`[hacfg] ${m}`),
218989
- error: (m) => this.deps.logger.error(`[hacfg] ${m}`)
218990
- }
218991
- });
218992
- this.busOff = onBaichuanWebhookEvent((event) => this.dispatchEvent(event));
218993
- try {
218994
- await this.instance.start();
218995
- this.deps.logger.info("baichuan-webhook: HTTP intake listening", { meta: {
218996
- host: settings.bindHost,
218997
- port: settings.port,
218998
- pathPrefix: DEFAULT_BAICHUAN_WEBHOOK_PATH_PREFIX
218999
- } });
219000
- } catch (err) {
219001
- this.deps.logger.error("baichuan-webhook: HTTP intake failed to start", { meta: { error: err instanceof Error ? err.message : String(err) } });
219002
- this.busOff?.();
219003
- this.busOff = void 0;
219004
- this.instance = void 0;
219005
- }
219006
- }
219007
- async restart() {
219008
- await this.stop();
219009
- this.reloadSettings();
219010
- await this.start();
219011
- }
219012
- async stop() {
219013
- this.busOff?.();
219014
- this.busOff = void 0;
219015
- if (this.instance) {
219016
- await this.instance.stop().catch(() => {});
219017
- this.instance = void 0;
219018
- }
219019
- }
219020
- /**
219021
- * Absolute HaCfg URL for one camera, or null when the intake is off /
219022
- * has no LAN host / the camera is ineligible.
219023
- */
219024
- async buildCameraSetupParams(cam) {
219025
- if (!cam.isEmailPushEligible) return null;
219026
- const settings = await this.resolveSettings();
219027
- if (!settings.enabled) return null;
219028
- const managerHost = listLanHosts()[0];
219029
- if (!managerHost) return null;
219030
- return {
219031
- url: buildBaichuanWebhookUrl({
219032
- host: managerHost,
219033
- port: settings.port,
219034
- cameraId: cam.stableId
219035
- }),
219036
- verifyCert: false
219037
- };
219038
- }
219039
- };
219040
- //#endregion
219041
218847
  //#region src/creation-schema.ts
219042
218848
  /**
219043
218849
  * Creation form for Reolink devices.
@@ -219221,6 +219027,36 @@ function buildCreationFormSchema() {
219221
219027
  ] };
219222
219028
  }
219223
219029
  //#endregion
219030
+ //#region src/email-push-shared.ts
219031
+ /**
219032
+ * Map the lib's email-push classifier output onto the `ReolinkSimpleEvent`
219033
+ * types the camera should be fed. AI subtypes + motion pass through;
219034
+ * anything unrecognised collapses to plain `motion` so a wake is never
219035
+ * silently dropped.
219036
+ *
219037
+ * Returns a LIST rather than a single type because of `doorbell`. The
219038
+ * camera's `handleSimpleEvent` emits `ProviderMotion` for `motion`
219039
+ * and for every AI class, but the `doorbell` branch emits ONLY
219040
+ * `DoorbellOnPressed` and returns. An email is the sole signal a sleeping
219041
+ * battery camera can send, so a doorbell-classified email mapped to
219042
+ * `doorbell` alone rang the bell and left motion, recording and
219043
+ * notification rules blind — the exact "silently dropped wake" this mapping
219044
+ * exists to prevent. Pairing it with `motion` keeps the doorbell semantic
219045
+ * AND the wake.
219046
+ */
219047
+ function mapInferredTypeToSimpleEvents(inferred) {
219048
+ switch (inferred) {
219049
+ case "people":
219050
+ case "vehicle":
219051
+ case "animal":
219052
+ case "face":
219053
+ case "package":
219054
+ case "motion": return [inferred];
219055
+ case "doorbell": return ["doorbell", "motion"];
219056
+ default: return ["motion"];
219057
+ }
219058
+ }
219059
+ //#endregion
219224
219060
  //#region src/email-push-bridge.ts
219225
219061
  /**
219226
219062
  * The Reolink side of the `smtp-inbound` broker kind (D999).
@@ -219558,6 +219394,78 @@ function createEmailPushRemovalHook(deps) {
219558
219394
  listDeviceData: async () => []
219559
219395
  };
219560
219396
  }
219397
+ var LEGACY_PREFIX = "/webhook/";
219398
+ async function startLegacyWebhookIntake(deps) {
219399
+ const warned = /* @__PURE__ */ new Set();
219400
+ const server = createServer((req, res) => {
219401
+ const url = req.url ?? "";
219402
+ if (!url.startsWith(LEGACY_PREFIX)) {
219403
+ res.writeHead(404).end();
219404
+ return;
219405
+ }
219406
+ const rest = url.slice(8);
219407
+ req.url = rest;
219408
+ const id = decodeURIComponent(rest.replace(/^\//, "").split("?")[0] ?? "");
219409
+ const camera = deps.listCameras().find((c) => c.stableId === id);
219410
+ if (camera !== void 0 && !warned.has(id)) {
219411
+ warned.add(id);
219412
+ deps.logger.warn("legacy webhook port used — camera will be re-pointed", {
219413
+ tags: { deviceId: camera.id },
219414
+ meta: { port: deps.port ?? 9081 }
219415
+ });
219416
+ }
219417
+ Promise.resolve(deps.handler(req, res)).catch((err) => {
219418
+ deps.logger.error("legacy webhook intake failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
219419
+ if (!res.headersSent) res.writeHead(500);
219420
+ res.end();
219421
+ });
219422
+ });
219423
+ await new Promise((resolve, reject) => {
219424
+ server.once("error", reject);
219425
+ server.listen(deps.port ?? 9081, "0.0.0.0", () => {
219426
+ server.off("error", reject);
219427
+ resolve();
219428
+ });
219429
+ });
219430
+ return {
219431
+ server,
219432
+ close: () => new Promise((resolve) => server.close(() => resolve()))
219433
+ };
219434
+ }
219435
+ //#endregion
219436
+ //#region src/migrations/drop-webhook-intake-settings.ts
219437
+ /**
219438
+ * The camera webhook is a plane of the addon's HTTP data plane, not a private listener
219439
+ * (D999): its enable switch, port and bind host are gone. The three keys older versions
219440
+ * persisted are deleted; every other key is left exactly as stored.
219441
+ *
219442
+ * Idempotent: a store without the keys yields an empty plan, which writes nothing.
219443
+ */
219444
+ /** The addon id, the prefix of its rows in the canonical settings table. */
219445
+ var REOLINK_ADDON_ID = "provider-reolink";
219446
+ /** The canonical `addon-settings` table: one row per key, id `<addonId>.<key>`, data `{ addonId, key, value }`. */
219447
+ var ADDON_SETTINGS_TABLE = "addon-settings";
219448
+ var RETIRED_WEBHOOK_INTAKE_KEYS = [
219449
+ "baichuanWebhookEnabled",
219450
+ "baichuanWebhookPort",
219451
+ "baichuanWebhookBindHost"
219452
+ ];
219453
+ async function planDropWebhookIntakeSettings(ports) {
219454
+ const stored = await ports.readStoredRaw();
219455
+ const present = RETIRED_WEBHOOK_INTAKE_KEYS.filter((key) => key in stored);
219456
+ if (present.length === 0) return { edits: [] };
219457
+ const rowId = (key) => `${REOLINK_ADDON_ID}.${key}`;
219458
+ return { edits: [{
219459
+ op: "deleteKeys",
219460
+ scope: ADDON_SETTINGS_TABLE,
219461
+ keys: present.map(rowId),
219462
+ precondition: preconditionOf(Object.fromEntries(present.map((key) => [rowId(key), {
219463
+ addonId: REOLINK_ADDON_ID,
219464
+ key,
219465
+ value: stored[key]
219466
+ }])))
219467
+ }] };
219468
+ }
219561
219469
  //#endregion
219562
219470
  //#region src/migrations/email-push-to-mailbox.ts
219563
219471
  /** The pending mailbox as kept in the addon store: everything but the password (a keychain secret). */
@@ -219633,146 +219541,23 @@ var REOLINK_MIGRATIONS = [{
219633
219541
  fixtureMark: "a setting key the migration does not know"
219634
219542
  },
219635
219543
  procedure: moveEmailPushToMailbox
219544
+ }, {
219545
+ meta: {
219546
+ id: "provider-reolink/2026-11-02-drop-webhook-intake-settings",
219547
+ owner: "provider-reolink",
219548
+ level: "addon-state",
219549
+ seq: 2,
219550
+ dependsOn: [],
219551
+ store: "settings",
219552
+ scope: "cluster",
219553
+ onFailure: "tolerate",
219554
+ bodyKind: "plan",
219555
+ description: "Delete the retired baichuanWebhookEnabled, baichuanWebhookPort and baichuanWebhookBindHost settings; the webhook is served on the addon data plane",
219556
+ fixtureMark: "a setting key the migration does not know"
219557
+ },
219558
+ plan: planDropWebhookIntakeSettings
219636
219559
  }];
219637
219560
  //#endregion
219638
- //#region src/reolink-discovery-map.ts
219639
- /**
219640
- * Flatten a host (IP / hostname) into a flat row-key slug — shared by `generateStableId` and the
219641
- * discovery candidate mapping so a host-keyed camera is detected as already onboarded on re-scan.
219642
- */
219643
- function slugifyReolinkHost(host) {
219644
- return host.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
219645
- }
219646
- /**
219647
- * Decide whether a discovered host is a device this provider already owns.
219648
- *
219649
- * Matching is by UID first (stable across a DHCP lease change) and by host second. A UID match
219650
- * wins even when the host differs, because the address is the thing that moves.
219651
- */
219652
- function findOnboardedMatch(discovered, onboarded) {
219653
- if (discovered.uid !== void 0 && discovered.uid !== "") {
219654
- const byUid = onboarded.find((o) => o.uid === discovered.uid);
219655
- if (byUid !== void 0) return byUid;
219656
- }
219657
- return onboarded.find((o) => o.host !== void 0 && o.host === discovered.host);
219658
- }
219659
- /** Baichuan control port of an NVR / Home Hub (the scan only ever reports the HTTP/ONVIF one). */
219660
- var HUB_BAICHUAN_PORT = 9e3;
219661
- /**
219662
- * `true` for a model string that names an NVR or a Home Hub: a device that fronts channels
219663
- * rather than being one camera. Such a device is offered as an INTEGRATION to set up (D985), never
219664
- * as a camera to add.
219665
- */
219666
- function looksLikeReolinkHub(model) {
219667
- if (model === void 0) return false;
219668
- return /\b(nvr|rln\d*|home\s*hub|hub)\b/i.test(model);
219669
- }
219670
- /**
219671
- * The stableId of an NVR / Home Hub candidate. UID first (it survives a DHCP lease change), host
219672
- * second. The boot migration derives the SAME id for an old Hub device, so the integration it
219673
- * creates hides this candidate through `originStableId`.
219674
- */
219675
- function reolinkHubStableId(identity) {
219676
- const uid = identity.uid?.trim() ?? "";
219677
- if (uid !== "") return `reolink-hub-uid-${slugifyReolinkHost(uid)}`;
219678
- return `reolink-hub-host-${slugifyReolinkHost(identity.host)}`;
219679
- }
219680
- function isKnownHub(d, known) {
219681
- return known.some((k) => k.uid !== void 0 && k.uid !== "" && k.uid === d.uid || k.host !== void 0 && k.host !== "" && k.host === d.host);
219682
- }
219683
- /**
219684
- * Map discovered Reolink hosts to adoption {@link DiscoveryCandidate}s, de-duplicated by host (the
219685
- * same camera can answer on more than one discovery method — UDP broadcast, ONVIF, HTTP scan). The
219686
- * first responder for a host wins. Pure: no I/O.
219687
- *
219688
- * **The scan never supplies `port`.** `port` in the Reolink config schema is the BAICHUAN port
219689
- * (default 9000). Discovery reports `httpPort`, the HTTP/ONVIF listener — on a Reolink Home Hub
219690
- * that is 8000, while Baichuan lives on `mediaPort` 9000. Writing the discovered value into `port`
219691
- * pointed every Baichuan login at the ONVIF listener, which accepts the TCP connection and then
219692
- * closes it: `Baichuan socket closed`, 316 times in 12 hours, taking the hub and all three of its
219693
- * child cameras offline. The default is correct; a non-default Baichuan port is an operator edit,
219694
- * never a scan result.
219695
- *
219696
- * The authoritative stableId is `mac-<mac>` (learned during autodetect at adopt time), which discovery
219697
- * can't produce — so candidates carry `alreadyOnboarded` rather than relying on a stableId match,
219698
- * which never fires for a MAC-keyed device. Re-adding an owned device is how the port got clobbered
219699
- * in the first place, so a scan says so instead of offering it again.
219700
- */
219701
- function mapReolinkDiscoveryToCandidates(devices, credentials, onboarded = [], hubIntegrations = []) {
219702
- const username = credentials.username?.trim() ?? "";
219703
- const password = credentials.password ?? "";
219704
- const byHost = /* @__PURE__ */ new Map();
219705
- for (const d of devices) if (!byHost.has(d.host)) byHost.set(d.host, d);
219706
- return [...byHost.values()].flatMap((d) => {
219707
- const displayName = d.name ?? d.model ?? d.host;
219708
- if (looksLikeReolinkHub(d.model)) {
219709
- if (isKnownHub(d, hubIntegrations)) return [];
219710
- return [{
219711
- stableId: reolinkHubStableId(d),
219712
- type: DeviceType.Gateway,
219713
- suggestedName: displayName,
219714
- prefilledConfig: {
219715
- host: d.host,
219716
- port: HUB_BAICHUAN_PORT,
219717
- ...d.uid ? { uid: d.uid } : {},
219718
- ...username ? { username } : {}
219719
- },
219720
- setupIntegration: true
219721
- }];
219722
- }
219723
- const match = findOnboardedMatch(d, onboarded);
219724
- return [{
219725
- stableId: `host-${slugifyReolinkHost(d.host)}`,
219726
- type: DeviceType.Camera,
219727
- suggestedName: displayName,
219728
- prefilledConfig: {
219729
- name: displayName,
219730
- host: d.host,
219731
- transport: "auto",
219732
- ...d.uid ? { uid: d.uid } : {},
219733
- ...username ? { username } : {},
219734
- ...password ? { password } : {}
219735
- },
219736
- ...match !== void 0 ? {
219737
- alreadyOnboarded: true,
219738
- onboardedDeviceId: match.deviceId,
219739
- onboardedName: match.name
219740
- } : {}
219741
- }];
219742
- });
219743
- }
219744
- //#endregion
219745
- //#region src/reolink-subnet-scan.ts
219746
- /**
219747
- * Reolink cameras answer a vendor UDP broadcast (Baichuan) and ONVIF through the nodelink
219748
- * library, which owns both protocols' framing. The engine cannot send either, so the addon
219749
- * keeps `discoverDevices` and runs the library once per subnet the engine covers.
219750
- */
219751
- var REOLINK_DISCOVERY_MODES = [{
219752
- kind: "custom",
219753
- reason: "Reolink answers a Baichuan vendor UDP broadcast and ONVIF that only the nodelink library speaks; this addon runs it once per covered subnet"
219754
- }];
219755
- /** The subnets handed in by the discovery orchestrator (`params.subnets`), or [] when absent. */
219756
- function subnetsFromParams(params) {
219757
- const raw = params?.["subnets"];
219758
- if (!Array.isArray(raw)) return [];
219759
- return raw.filter((s) => typeof s === "string" && s.length > 0);
219760
- }
219761
- /**
219762
- * Run `scan` once per covered subnet (once with no CIDR when the engine named none) and merge
219763
- * the answers by host. A subnet whose scan fails is skipped and reported to `onError`.
219764
- */
219765
- async function scanReolinkSubnets(input) {
219766
- const targets = input.subnets.length > 0 ? input.subnets : [void 0];
219767
- const byHost = /* @__PURE__ */ new Map();
219768
- for (const cidr of targets) try {
219769
- for (const device of await input.scan(cidr)) if (!byHost.has(device.host)) byHost.set(device.host, device);
219770
- } catch (error) {
219771
- input.onError?.(cidr, error);
219772
- }
219773
- return [...byHost.values()];
219774
- }
219775
- //#endregion
219776
219561
  //#region src/reolink-adoption-provider.ts
219777
219562
  var DEVICES_FILTER = {
219778
219563
  id: "devices",
@@ -219937,233 +219722,111 @@ function buildReolinkAdoptionProvider(deps) {
219937
219722
  };
219938
219723
  }
219939
219724
  //#endregion
219940
- //#region src/reolink-hub-manager.ts
219725
+ //#region src/reolink-discovery-map.ts
219941
219726
  /**
219942
- * One {@link ReolinkHubSession} per NVR / Home Hub INTEGRATION (D985). `reconcile` is the one
219943
- * entry point: it is handed the live list of this addon's enabled integrations at boot and on
219944
- * every integration lifecycle event, and makes the sessions match it — upsert, re-dial on a
219945
- * changed connection, close what vanished. The default integration (no host) stays a plain
219946
- * container of standalone cameras and gets no session.
219727
+ * Flatten a host (IP / hostname) into a flat row-key slug — shared by `generateStableId` and the
219728
+ * discovery candidate mapping so a host-keyed camera is detected as already onboarded on re-scan.
219947
219729
  */
219948
- var ReolinkHubManager = class {
219949
- #sessions = /* @__PURE__ */ new Map();
219950
- #deps;
219951
- constructor(deps) {
219952
- this.#deps = deps;
219953
- }
219954
- list() {
219955
- return [...this.#sessions.keys()];
219956
- }
219957
- get(integrationId) {
219958
- return this.#sessions.get(integrationId) ?? null;
219959
- }
219960
- async reconcile(integrations) {
219961
- const { logger } = this.#deps;
219962
- const wanted = /* @__PURE__ */ new Set();
219963
- for (const integration of integrations) {
219964
- const read = readHubConnection(integration.settings);
219965
- if (read.kind === "no-host") continue;
219966
- wanted.add(integration.id);
219967
- if (read.kind !== "ok") {
219968
- logger.warn("Reolink NVR integration has no usable password — no session started", { meta: {
219969
- integrationId: integration.id,
219970
- reason: read.kind
219971
- } });
219972
- continue;
219973
- }
219974
- const existing = this.#sessions.get(integration.id);
219975
- if (existing !== void 0) {
219976
- if (!sameHubConnection(existing.connection, read.connection)) await existing.reconfigure(read.connection);
219977
- continue;
219978
- }
219979
- const session = new ReolinkHubSession({
219980
- integrationId: integration.id,
219981
- name: integration.name,
219982
- logger: logger.child(`hub-${integration.id}`),
219983
- connection: read.connection,
219984
- connect: this.#deps.connect,
219985
- children: () => this.#deps.childrenOf(integration.id),
219986
- onConnectionChange: (online) => this.#deps.onConnectionChange(integration.id, online)
219987
- });
219988
- this.#sessions.set(integration.id, session);
219989
- this.#deps.publish(integration.id, session);
219990
- session.start();
219991
- }
219992
- for (const [id, session] of [...this.#sessions]) {
219993
- if (wanted.has(id)) continue;
219994
- this.#sessions.delete(id);
219995
- this.#deps.publish(id, null);
219996
- await session.close();
219997
- }
219998
- }
219999
- async shutdown() {
220000
- for (const [id, session] of [...this.#sessions]) {
220001
- this.#sessions.delete(id);
220002
- this.#deps.publish(id, null);
220003
- await session.close();
220004
- }
220005
- }
220006
- };
220007
- /** The manager of a real addon process: dials real hubs. */
220008
- function createReolinkHubManager(deps, registry) {
220009
- return new ReolinkHubManager({
220010
- ...deps,
220011
- childrenOf: (id) => registry.childrenOf(id),
220012
- publish: (id, session) => registry.set(id, session),
220013
- connect: connectReolinkHub
220014
- });
220015
- }
220016
- function positivePort(value) {
220017
- return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : void 0;
220018
- }
220019
- var CONNECTION_FIELDS = [
220020
- "host",
220021
- "port",
220022
- "username",
220023
- "transport"
220024
- ];
220025
- function str(value) {
220026
- return typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
219730
+ function slugifyReolinkHost(host) {
219731
+ return host.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
220027
219732
  }
220028
- function migratedChildConfig(child, hub, integrationId) {
220029
- const filled = {};
220030
- for (const key of CONNECTION_FIELDS) {
220031
- const own = child[key];
220032
- const inherited = hub[key];
220033
- if ((own === void 0 || own === null || own === "") && inherited !== void 0) filled[key] = inherited;
219733
+ /**
219734
+ * Decide whether a discovered host is a device this provider already owns.
219735
+ *
219736
+ * Matching is by UID first (stable across a DHCP lease change) and by host second. A UID match
219737
+ * wins even when the host differs, because the address is the thing that moves.
219738
+ */
219739
+ function findOnboardedMatch(discovered, onboarded) {
219740
+ if (discovered.uid !== void 0 && discovered.uid !== "") {
219741
+ const byUid = onboarded.find((o) => o.uid === discovered.uid);
219742
+ if (byUid !== void 0) return byUid;
220034
219743
  }
220035
- return {
220036
- ...child,
220037
- ...filled,
220038
- integrationId
220039
- };
219744
+ return onboarded.find((o) => o.host !== void 0 && o.host === discovered.host);
220040
219745
  }
220041
- async function migrateOneHub(hub, children, integrations, ports) {
220042
- const read = readHubConnection(hub.config);
220043
- if (read.kind !== "ok") throw new Error(`hub connection unusable (${read.kind}) — left in place`);
220044
- const { connection } = read;
220045
- const uid = str(hub.config["uid"]);
220046
- const integrationId = integrations.find((i) => i.addonId === ports.addonId && i.info["migratedFromHubDeviceId"] === hub.id)?.id ?? (await ports.createIntegration({
220047
- addonId: ports.addonId,
220048
- name: hub.name,
220049
- info: { ["migratedFromHubDeviceId"]: hub.id },
220050
- originStableId: reolinkHubStableId({
220051
- ...uid !== void 0 ? { uid } : {},
220052
- host: connection.host
220053
- }),
220054
- settings: {
220055
- host: connection.host,
220056
- port: connection.port,
220057
- rtspPort: positivePort(hub.config["rtspPort"]) ?? connection.rtspPort,
220058
- username: connection.username,
220059
- password: connection.password,
220060
- transport: connection.transport,
220061
- ...uid !== void 0 ? { uid } : {},
220062
- debugGeneral: connection.debugGeneral,
220063
- debugSocketLogs: connection.debugSocketLogs
220064
- },
220065
- secretKeys: ["password"]
220066
- })).id;
220067
- const rewritten = [];
220068
- for (const child of children) {
220069
- const config = migratedChildConfig(child.config, hub.config, integrationId);
220070
- await ports.persistConfig(child.stableId, config);
220071
- await ports.setIntegrationId(child.id, integrationId);
220072
- rewritten.push({
220073
- ...child,
220074
- config,
220075
- parentDeviceId: null
220076
- });
220077
- }
220078
- return {
220079
- rows: rewritten,
220080
- retirement: {
220081
- hubDeviceId: hub.id,
220082
- hubName: hub.name,
220083
- integrationId
220084
- }
220085
- };
219746
+ /** Baichuan control port of an NVR / Home Hub (the scan only ever reports the HTTP/ONVIF one). */
219747
+ var HUB_BAICHUAN_PORT = 9e3;
219748
+ /**
219749
+ * `true` for a model string that names an NVR or a Home Hub: a device that fronts channels
219750
+ * rather than being one camera. Such a device is offered as an INTEGRATION to set up (D985), never
219751
+ * as a camera to add.
219752
+ */
219753
+ function looksLikeReolinkHub(model) {
219754
+ if (model === void 0) return false;
219755
+ return /\b(nvr|rln\d*|home\s*hub|hub)\b/i.test(model);
220086
219756
  }
220087
- /** Plan and persist the migration of every Hub row in `saved`. Never throws. */
220088
- async function planHubMigration(saved, ports) {
220089
- const hubs = saved.filter((row) => row.type === DeviceType.Hub);
220090
- if (hubs.length === 0) return {
220091
- rows: saved,
220092
- retirements: []
220093
- };
220094
- let integrations;
220095
- try {
220096
- integrations = await ports.listIntegrations();
220097
- } catch (err) {
220098
- ports.logger.warn("Reolink hub migration: integrations unreadable — hubs left in place", { meta: { error: errMsg$3(err) } });
220099
- return {
220100
- rows: saved.filter((row) => row.type !== DeviceType.Hub),
220101
- retirements: []
220102
- };
220103
- }
220104
- const hubIds = new Set(hubs.map((h) => h.id));
220105
- const rewrittenById = /* @__PURE__ */ new Map();
220106
- const retirements = [];
220107
- for (const hub of hubs) {
220108
- const children = saved.filter((row) => row.parentDeviceId === hub.id);
220109
- try {
220110
- const done = await migrateOneHub(hub, children, integrations, ports);
220111
- for (const row of done.rows) rewrittenById.set(row.id, row);
220112
- retirements.push(done.retirement);
220113
- ports.logger.info("Reolink hub migrated to an integration", {
220114
- tags: { deviceId: hub.id },
220115
- meta: {
220116
- integrationId: done.retirement.integrationId,
220117
- children: children.length,
220118
- name: hub.name
220119
- }
220120
- });
220121
- } catch (err) {
220122
- ports.logger.warn("Reolink hub migration failed — the hub device is left in place", {
220123
- tags: { deviceId: hub.id },
220124
- meta: {
220125
- error: errMsg$3(err),
220126
- name: hub.name
220127
- }
220128
- });
220129
- }
220130
- }
220131
- return {
220132
- rows: saved.filter((row) => !hubIds.has(row.id)).map((row) => rewrittenById.get(row.id) ?? row),
220133
- retirements
220134
- };
219757
+ /**
219758
+ * The stableId of an NVR / Home Hub candidate. UID first (it survives a DHCP lease change), host
219759
+ * second. The boot migration derives the SAME id for an old Hub device, so the integration it
219760
+ * creates hides this candidate through `originStableId`.
219761
+ */
219762
+ function reolinkHubStableId(identity) {
219763
+ const uid = identity.uid?.trim() ?? "";
219764
+ if (uid !== "") return `reolink-hub-uid-${slugifyReolinkHost(uid)}`;
219765
+ return `reolink-hub-host-${slugifyReolinkHost(identity.host)}`;
219766
+ }
219767
+ function isKnownHub(d, known) {
219768
+ return known.some((k) => k.uid !== void 0 && k.uid !== "" && k.uid === d.uid || k.host !== void 0 && k.host !== "" && k.host === d.host);
220135
219769
  }
220136
219770
  /**
220137
- * Retire the hubs whose children all restored. A hub that still has a child in the store is left
220138
- * and named: removing it would cascade the camera away with it.
219771
+ * Map discovered Reolink hosts to adoption {@link DiscoveryCandidate}s, de-duplicated by host (the
219772
+ * same camera can answer on more than one discovery method — UDP broadcast, ONVIF, HTTP scan). The
219773
+ * first responder for a host wins. Pure: no I/O.
219774
+ *
219775
+ * **The scan never supplies `port`.** `port` in the Reolink config schema is the BAICHUAN port
219776
+ * (default 9000). Discovery reports `httpPort`, the HTTP/ONVIF listener — on a Reolink Home Hub
219777
+ * that is 8000, while Baichuan lives on `mediaPort` 9000. Writing the discovered value into `port`
219778
+ * pointed every Baichuan login at the ONVIF listener, which accepts the TCP connection and then
219779
+ * closes it: `Baichuan socket closed`, 316 times in 12 hours, taking the hub and all three of its
219780
+ * child cameras offline. The default is correct; a non-default Baichuan port is an operator edit,
219781
+ * never a scan result.
219782
+ *
219783
+ * The authoritative stableId is `mac-<mac>` (learned during autodetect at adopt time), which discovery
219784
+ * can't produce — so candidates carry `alreadyOnboarded` rather than relying on a stableId match,
219785
+ * which never fires for a MAC-keyed device. Re-adding an owned device is how the port got clobbered
219786
+ * in the first place, so a scan says so instead of offering it again.
220139
219787
  */
220140
- async function retireMigratedHubs(retirements, ports) {
220141
- let retired = 0;
220142
- for (const r of retirements) try {
220143
- const remaining = await ports.childIdsOf(r.hubDeviceId);
220144
- if (remaining.length > 0) {
220145
- ports.logger.warn("Reolink hub kept — a child did not restore, retiring it would remove it", {
220146
- tags: { deviceId: r.hubDeviceId },
220147
- meta: {
220148
- remaining: [...remaining],
220149
- integrationId: r.integrationId
220150
- }
220151
- });
220152
- continue;
219788
+ function mapReolinkDiscoveryToCandidates(devices, credentials, onboarded = [], hubIntegrations = []) {
219789
+ const username = credentials.username?.trim() ?? "";
219790
+ const password = credentials.password ?? "";
219791
+ const byHost = /* @__PURE__ */ new Map();
219792
+ for (const d of devices) if (!byHost.has(d.host)) byHost.set(d.host, d);
219793
+ return [...byHost.values()].flatMap((d) => {
219794
+ const displayName = d.name ?? d.model ?? d.host;
219795
+ if (looksLikeReolinkHub(d.model)) {
219796
+ if (isKnownHub(d, hubIntegrations)) return [];
219797
+ return [{
219798
+ stableId: reolinkHubStableId(d),
219799
+ type: DeviceType.Gateway,
219800
+ suggestedName: displayName,
219801
+ prefilledConfig: {
219802
+ host: d.host,
219803
+ port: HUB_BAICHUAN_PORT,
219804
+ ...d.uid ? { uid: d.uid } : {},
219805
+ ...username ? { username } : {}
219806
+ },
219807
+ setupIntegration: true
219808
+ }];
220153
219809
  }
220154
- await ports.removeDevice(r.hubDeviceId);
220155
- retired++;
220156
- ports.logger.info("Reolink hub device retired", {
220157
- tags: { deviceId: r.hubDeviceId },
220158
- meta: { integrationId: r.integrationId }
220159
- });
220160
- } catch (err) {
220161
- ports.logger.warn("Reolink hub retirement failed — retried next boot", {
220162
- tags: { deviceId: r.hubDeviceId },
220163
- meta: { error: errMsg$3(err) }
220164
- });
220165
- }
220166
- return retired;
219810
+ const match = findOnboardedMatch(d, onboarded);
219811
+ return [{
219812
+ stableId: `host-${slugifyReolinkHost(d.host)}`,
219813
+ type: DeviceType.Camera,
219814
+ suggestedName: displayName,
219815
+ prefilledConfig: {
219816
+ name: displayName,
219817
+ host: d.host,
219818
+ transport: "auto",
219819
+ ...d.uid ? { uid: d.uid } : {},
219820
+ ...username ? { username } : {},
219821
+ ...password ? { password } : {}
219822
+ },
219823
+ ...match !== void 0 ? {
219824
+ alreadyOnboarded: true,
219825
+ onboardedDeviceId: match.deviceId,
219826
+ onboardedName: match.name
219827
+ } : {}
219828
+ }];
219829
+ });
220167
219830
  }
220168
219831
  //#endregion
220169
219832
  //#region src/reolink-gateway-device.ts
@@ -220246,6 +219909,375 @@ var ReolinkGatewayDevice = class extends BaseDevice {
220246
219909
  }
220247
219910
  };
220248
219911
  //#endregion
219912
+ //#region src/reolink-hub-manager.ts
219913
+ /**
219914
+ * One {@link ReolinkHubSession} per NVR / Home Hub INTEGRATION (D985). `reconcile` is the one
219915
+ * entry point: it is handed the live list of this addon's enabled integrations at boot and on
219916
+ * every integration lifecycle event, and makes the sessions match it — upsert, re-dial on a
219917
+ * changed connection, close what vanished. The default integration (no host) stays a plain
219918
+ * container of standalone cameras and gets no session.
219919
+ */
219920
+ var ReolinkHubManager = class {
219921
+ #sessions = /* @__PURE__ */ new Map();
219922
+ #deps;
219923
+ constructor(deps) {
219924
+ this.#deps = deps;
219925
+ }
219926
+ list() {
219927
+ return [...this.#sessions.keys()];
219928
+ }
219929
+ get(integrationId) {
219930
+ return this.#sessions.get(integrationId) ?? null;
219931
+ }
219932
+ async reconcile(integrations) {
219933
+ const { logger } = this.#deps;
219934
+ const wanted = /* @__PURE__ */ new Set();
219935
+ for (const integration of integrations) {
219936
+ const read = readHubConnection(integration.settings);
219937
+ if (read.kind === "no-host") continue;
219938
+ wanted.add(integration.id);
219939
+ if (read.kind !== "ok") {
219940
+ logger.warn("Reolink NVR integration has no usable password — no session started", { meta: {
219941
+ integrationId: integration.id,
219942
+ reason: read.kind
219943
+ } });
219944
+ continue;
219945
+ }
219946
+ const existing = this.#sessions.get(integration.id);
219947
+ if (existing !== void 0) {
219948
+ if (!sameHubConnection(existing.connection, read.connection)) await existing.reconfigure(read.connection);
219949
+ continue;
219950
+ }
219951
+ const session = new ReolinkHubSession({
219952
+ integrationId: integration.id,
219953
+ name: integration.name,
219954
+ logger: logger.child(`hub-${integration.id}`),
219955
+ connection: read.connection,
219956
+ connect: this.#deps.connect,
219957
+ children: () => this.#deps.childrenOf(integration.id),
219958
+ onConnectionChange: (online) => this.#deps.onConnectionChange(integration.id, online)
219959
+ });
219960
+ this.#sessions.set(integration.id, session);
219961
+ this.#deps.publish(integration.id, session);
219962
+ session.start();
219963
+ }
219964
+ for (const [id, session] of [...this.#sessions]) {
219965
+ if (wanted.has(id)) continue;
219966
+ this.#sessions.delete(id);
219967
+ this.#deps.publish(id, null);
219968
+ await session.close();
219969
+ }
219970
+ }
219971
+ async shutdown() {
219972
+ for (const [id, session] of [...this.#sessions]) {
219973
+ this.#sessions.delete(id);
219974
+ this.#deps.publish(id, null);
219975
+ await session.close();
219976
+ }
219977
+ }
219978
+ };
219979
+ /** The manager of a real addon process: dials real hubs. */
219980
+ function createReolinkHubManager(deps, registry) {
219981
+ return new ReolinkHubManager({
219982
+ ...deps,
219983
+ childrenOf: (id) => registry.childrenOf(id),
219984
+ publish: (id, session) => registry.set(id, session),
219985
+ connect: connectReolinkHub
219986
+ });
219987
+ }
219988
+ function positivePort(value) {
219989
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : void 0;
219990
+ }
219991
+ var CONNECTION_FIELDS = [
219992
+ "host",
219993
+ "port",
219994
+ "username",
219995
+ "transport"
219996
+ ];
219997
+ function str(value) {
219998
+ return typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
219999
+ }
220000
+ function migratedChildConfig(child, hub, integrationId) {
220001
+ const filled = {};
220002
+ for (const key of CONNECTION_FIELDS) {
220003
+ const own = child[key];
220004
+ const inherited = hub[key];
220005
+ if ((own === void 0 || own === null || own === "") && inherited !== void 0) filled[key] = inherited;
220006
+ }
220007
+ return {
220008
+ ...child,
220009
+ ...filled,
220010
+ integrationId
220011
+ };
220012
+ }
220013
+ async function migrateOneHub(hub, children, integrations, ports) {
220014
+ const read = readHubConnection(hub.config);
220015
+ if (read.kind !== "ok") throw new Error(`hub connection unusable (${read.kind}) — left in place`);
220016
+ const { connection } = read;
220017
+ const uid = str(hub.config["uid"]);
220018
+ const integrationId = integrations.find((i) => i.addonId === ports.addonId && i.info["migratedFromHubDeviceId"] === hub.id)?.id ?? (await ports.createIntegration({
220019
+ addonId: ports.addonId,
220020
+ name: hub.name,
220021
+ info: { ["migratedFromHubDeviceId"]: hub.id },
220022
+ originStableId: reolinkHubStableId({
220023
+ ...uid !== void 0 ? { uid } : {},
220024
+ host: connection.host
220025
+ }),
220026
+ settings: {
220027
+ host: connection.host,
220028
+ port: connection.port,
220029
+ rtspPort: positivePort(hub.config["rtspPort"]) ?? connection.rtspPort,
220030
+ username: connection.username,
220031
+ password: connection.password,
220032
+ transport: connection.transport,
220033
+ ...uid !== void 0 ? { uid } : {},
220034
+ debugGeneral: connection.debugGeneral,
220035
+ debugSocketLogs: connection.debugSocketLogs
220036
+ },
220037
+ secretKeys: ["password"]
220038
+ })).id;
220039
+ const rewritten = [];
220040
+ for (const child of children) {
220041
+ const config = migratedChildConfig(child.config, hub.config, integrationId);
220042
+ await ports.persistConfig(child.stableId, config);
220043
+ await ports.setIntegrationId(child.id, integrationId);
220044
+ rewritten.push({
220045
+ ...child,
220046
+ config,
220047
+ parentDeviceId: null
220048
+ });
220049
+ }
220050
+ return {
220051
+ rows: rewritten,
220052
+ retirement: {
220053
+ hubDeviceId: hub.id,
220054
+ hubName: hub.name,
220055
+ integrationId
220056
+ }
220057
+ };
220058
+ }
220059
+ /** Plan and persist the migration of every Hub row in `saved`. Never throws. */
220060
+ async function planHubMigration(saved, ports) {
220061
+ const hubs = saved.filter((row) => row.type === DeviceType.Hub);
220062
+ if (hubs.length === 0) return {
220063
+ rows: saved,
220064
+ retirements: []
220065
+ };
220066
+ let integrations;
220067
+ try {
220068
+ integrations = await ports.listIntegrations();
220069
+ } catch (err) {
220070
+ ports.logger.warn("Reolink hub migration: integrations unreadable — hubs left in place", { meta: { error: errMsg$3(err) } });
220071
+ return {
220072
+ rows: saved.filter((row) => row.type !== DeviceType.Hub),
220073
+ retirements: []
220074
+ };
220075
+ }
220076
+ const hubIds = new Set(hubs.map((h) => h.id));
220077
+ const rewrittenById = /* @__PURE__ */ new Map();
220078
+ const retirements = [];
220079
+ for (const hub of hubs) {
220080
+ const children = saved.filter((row) => row.parentDeviceId === hub.id);
220081
+ try {
220082
+ const done = await migrateOneHub(hub, children, integrations, ports);
220083
+ for (const row of done.rows) rewrittenById.set(row.id, row);
220084
+ retirements.push(done.retirement);
220085
+ ports.logger.info("Reolink hub migrated to an integration", {
220086
+ tags: { deviceId: hub.id },
220087
+ meta: {
220088
+ integrationId: done.retirement.integrationId,
220089
+ children: children.length,
220090
+ name: hub.name
220091
+ }
220092
+ });
220093
+ } catch (err) {
220094
+ ports.logger.warn("Reolink hub migration failed — the hub device is left in place", {
220095
+ tags: { deviceId: hub.id },
220096
+ meta: {
220097
+ error: errMsg$3(err),
220098
+ name: hub.name
220099
+ }
220100
+ });
220101
+ }
220102
+ }
220103
+ return {
220104
+ rows: saved.filter((row) => !hubIds.has(row.id)).map((row) => rewrittenById.get(row.id) ?? row),
220105
+ retirements
220106
+ };
220107
+ }
220108
+ /**
220109
+ * Retire the hubs whose children all restored. A hub that still has a child in the store is left
220110
+ * and named: removing it would cascade the camera away with it.
220111
+ */
220112
+ async function retireMigratedHubs(retirements, ports) {
220113
+ let retired = 0;
220114
+ for (const r of retirements) try {
220115
+ const remaining = await ports.childIdsOf(r.hubDeviceId);
220116
+ if (remaining.length > 0) {
220117
+ ports.logger.warn("Reolink hub kept — a child did not restore, retiring it would remove it", {
220118
+ tags: { deviceId: r.hubDeviceId },
220119
+ meta: {
220120
+ remaining: [...remaining],
220121
+ integrationId: r.integrationId
220122
+ }
220123
+ });
220124
+ continue;
220125
+ }
220126
+ await ports.removeDevice(r.hubDeviceId);
220127
+ retired++;
220128
+ ports.logger.info("Reolink hub device retired", {
220129
+ tags: { deviceId: r.hubDeviceId },
220130
+ meta: { integrationId: r.integrationId }
220131
+ });
220132
+ } catch (err) {
220133
+ ports.logger.warn("Reolink hub retirement failed — retried next boot", {
220134
+ tags: { deviceId: r.hubDeviceId },
220135
+ meta: { error: errMsg$3(err) }
220136
+ });
220137
+ }
220138
+ return retired;
220139
+ }
220140
+ //#endregion
220141
+ //#region src/reolink-subnet-scan.ts
220142
+ /**
220143
+ * Reolink cameras answer a vendor UDP broadcast (Baichuan) and ONVIF through the nodelink
220144
+ * library, which owns both protocols' framing. The engine cannot send either, so the addon
220145
+ * keeps `discoverDevices` and runs the library once per subnet the engine covers.
220146
+ */
220147
+ var REOLINK_DISCOVERY_MODES = [{
220148
+ kind: "custom",
220149
+ reason: "Reolink answers a Baichuan vendor UDP broadcast and ONVIF that only the nodelink library speaks; this addon runs it once per covered subnet"
220150
+ }];
220151
+ /** The subnets handed in by the discovery orchestrator (`params.subnets`), or [] when absent. */
220152
+ function subnetsFromParams(params) {
220153
+ const raw = params?.["subnets"];
220154
+ if (!Array.isArray(raw)) return [];
220155
+ return raw.filter((s) => typeof s === "string" && s.length > 0);
220156
+ }
220157
+ /**
220158
+ * Run `scan` once per covered subnet (once with no CIDR when the engine named none) and merge
220159
+ * the answers by host. A subnet whose scan fails is skipped and reported to `onError`.
220160
+ */
220161
+ async function scanReolinkSubnets(input) {
220162
+ const targets = input.subnets.length > 0 ? input.subnets : [void 0];
220163
+ const byHost = /* @__PURE__ */ new Map();
220164
+ for (const cidr of targets) try {
220165
+ for (const device of await input.scan(cidr)) if (!byHost.has(device.host)) byHost.set(device.host, device);
220166
+ } catch (error) {
220167
+ input.onError?.(cidr, error);
220168
+ }
220169
+ return [...byHost.values()];
220170
+ }
220171
+ //#endregion
220172
+ //#region src/webhook-plane.ts
220173
+ /**
220174
+ * The Baichuan HaCfg webhook (battery sleep push, cmd 807) as a plane of this addon's HTTP data
220175
+ * plane: a camera POSTs `{"event":"wake","reason":"pir"}` to
220176
+ * `http://<internal-host>:<lan-http-port>/addon/provider-reolink/webhook/<stableId>`. The hub
220177
+ * proxies it without auth (`access: 'public'`: the firmware cannot sign in), so this handler is
220178
+ * the whole gate: a known battery camera, a small JSON body, nothing else.
220179
+ */
220180
+ /** The firmware's body is a few dozen bytes; this bounds what an unauthenticated peer can make us hold. */
220181
+ var WEBHOOK_MAX_BODY_BYTES = 64 * 1024;
220182
+ var BodyTooLarge = class extends Error {};
220183
+ function reply(res, status, allow) {
220184
+ res.writeHead(status, {
220185
+ "content-type": "text/plain",
220186
+ ...allow === void 0 ? {} : { allow }
220187
+ });
220188
+ res.end();
220189
+ }
220190
+ async function readBody(req) {
220191
+ const chunks = [];
220192
+ let size = 0;
220193
+ for await (const chunk of req) {
220194
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
220195
+ size += buffer.length;
220196
+ if (size > 65536) throw new BodyTooLarge();
220197
+ chunks.push(buffer);
220198
+ }
220199
+ return Buffer.concat(chunks);
220200
+ }
220201
+ /** The stable id in `req.url` (`/<id>`, already stripped of the plane's prefix), or null. */
220202
+ function stableIdOf(url) {
220203
+ const segment = ((url ?? "").split("?")[0] ?? "").replace(/^\/+/, "");
220204
+ if (segment.length === 0 || segment.includes("/")) return null;
220205
+ try {
220206
+ return decodeURIComponent(segment);
220207
+ } catch {
220208
+ return null;
220209
+ }
220210
+ }
220211
+ function createWebhookHandler(deps) {
220212
+ const { logger } = deps;
220213
+ return async (req, res) => {
220214
+ if (req.method !== "POST" && req.method !== "PUT") {
220215
+ reply(res, 405, "POST, PUT");
220216
+ return;
220217
+ }
220218
+ const stableId = stableIdOf(req.url);
220219
+ const camera = stableId === null ? void 0 : deps.listCameras().find((c) => c.stableId === stableId);
220220
+ if (camera === void 0 || !camera.isEmailPushEligible) {
220221
+ logger.warn("baichuan-webhook: push for an unknown or ineligible camera — refused", {
220222
+ ...camera === void 0 ? {} : { tags: { deviceId: camera.id } },
220223
+ meta: {
220224
+ stableId: stableId ?? "(none)",
220225
+ known: camera !== void 0
220226
+ }
220227
+ });
220228
+ reply(res, 404);
220229
+ return;
220230
+ }
220231
+ let body;
220232
+ try {
220233
+ body = await readBody(req);
220234
+ } catch (err) {
220235
+ const tooLarge = err instanceof BodyTooLarge;
220236
+ logger.warn(tooLarge ? "baichuan-webhook: push body over the bound — refused" : "baichuan-webhook: push body unreadable — refused", {
220237
+ tags: { deviceId: camera.id },
220238
+ meta: { maxBytes: WEBHOOK_MAX_BODY_BYTES }
220239
+ });
220240
+ reply(res, tooLarge ? 413 : 400);
220241
+ if (tooLarge) req.destroy();
220242
+ return;
220243
+ }
220244
+ const parsed = parseBaichuanWebhookBody(body);
220245
+ if (parsed === void 0) {
220246
+ logger.warn("baichuan-webhook: push body is not a webhook payload — refused", { tags: { deviceId: camera.id } });
220247
+ reply(res, 400);
220248
+ return;
220249
+ }
220250
+ if (parsed.kind !== "event") {
220251
+ logger.debug("baichuan-webhook: command-shaped push ignored", {
220252
+ tags: { deviceId: camera.id },
220253
+ meta: { cmdId: parsed.cmdId }
220254
+ });
220255
+ reply(res, 202);
220256
+ return;
220257
+ }
220258
+ const types = mapBaichuanWebhookToSimpleEvents({
220259
+ event: parsed.event,
220260
+ reason: parsed.reason
220261
+ });
220262
+ logger.info("baichuan-webhook: event received from battery camera", {
220263
+ tags: { deviceId: camera.id },
220264
+ meta: {
220265
+ event: parsed.event,
220266
+ reason: parsed.reason,
220267
+ types
220268
+ }
220269
+ });
220270
+ const timestamp = Date.now();
220271
+ for (const type of types) camera.handleSimpleEvent({
220272
+ type,
220273
+ channel: camera.emailPushChannel,
220274
+ timestamp,
220275
+ source: "baichuanWebhook"
220276
+ });
220277
+ reply(res, 200);
220278
+ };
220279
+ }
220280
+ //#endregion
220249
220281
  //#region src/addon.ts
220250
220282
  /**
220251
220283
  * Adapt a camstack scoped logger to the lib's `Logger` shape (just
@@ -220407,7 +220439,7 @@ async function enrichHostIdentifiersFromLiveApi(detection, logger) {
220407
220439
  var ReolinkProviderAddon = class extends BaseDeviceProvider {
220408
220440
  /** The email-push SMTP password is hidden state — kept in the keychain (D787). */
220409
220441
  secretStatePaths = ["emailPushAuthPassword", "mailboxPendingPassword"];
220410
- addonId = REOLINK_ADDON_ID;
220442
+ addonId = REOLINK_ADDON_ID$1;
220411
220443
  providerName = "Reolink";
220412
220444
  deviceClasses = {
220413
220445
  [DeviceType.Camera]: ReolinkCamera,
@@ -220427,7 +220459,6 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
220427
220459
  * simple event. Built in `onInitialize`, once the addon context exists.
220428
220460
  */
220429
220461
  emailBridge;
220430
- baichuanWebhookServer;
220431
220462
  /** The `log-channels` provider for this process. Stopped on shutdown so a
220432
220463
  * diagnostic timer is never the reason a runner refuses to exit. */
220433
220464
  logChannels;
@@ -220465,11 +220496,10 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
220465
220496
  listReolinkCameras() {
220466
220497
  const all = this.ctx.kernel.deviceRegistry?.getAll() ?? [];
220467
220498
  const emailBridge = this.emailBridge;
220468
- const webhookServer = this.baichuanWebhookServer;
220469
220499
  const cameras = [];
220470
220500
  for (const d of all) if (d instanceof ReolinkCamera) {
220471
220501
  if (emailBridge) d.setEmailPushParamsResolver(() => emailBridge.buildCameraSetupParams(d));
220472
- if (webhookServer) d.setBaichuanWebhookParamsResolver(() => webhookServer.buildCameraSetupParams(d));
220502
+ d.setBaichuanWebhookParamsResolver(() => this.buildWebhookParams(d));
220473
220503
  cameras.push(d);
220474
220504
  }
220475
220505
  return cameras;
@@ -220547,14 +220577,50 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
220547
220577
  emailPushAuthPassword: password
220548
220578
  } : raw;
220549
220579
  }
220550
- /** Lazily build the HaCfg webhook intake, wiring its provider deps. */
220551
- ensureBaichuanWebhookServer() {
220552
- if (!this.baichuanWebhookServer) this.baichuanWebhookServer = new ReolinkBaichuanWebhookServer({
220553
- logger: this.ctx.logger.child("baichuan-webhook"),
220554
- listCameras: () => this.listReolinkCameras(),
220555
- readStore: async () => await this.resolveGlobalStore()
220556
- });
220557
- return this.baichuanWebhookServer;
220580
+ /**
220581
+ * What a battery camera needs to arm its HaCfg webhook: the data-plane URL on the hub's
220582
+ * plain-HTTP LAN listener. The host is the `internal` role of the palette, read on every use
220583
+ * (D729). `null` for a camera that cannot do it; throws the reason when the hub has no address.
220584
+ */
220585
+ async buildWebhookParams(cam) {
220586
+ if (!cam.isEmailPushEligible) return null;
220587
+ return {
220588
+ url: buildBaichuanWebhookUrl({
220589
+ ...resolveWebhookEndpoint(await this.ctx.api.localNetwork.getAddresses.query()),
220590
+ stableId: cam.stableId
220591
+ }),
220592
+ verifyCert: false
220593
+ };
220594
+ }
220595
+ /**
220596
+ * Serve the camera's HaCfg push on the addon's HTTP data plane. Public: the firmware cannot sign
220597
+ * in, so the handler is the gate. Like the clip planes it is served from `onInitialize` (the hub
220598
+ * pulls planes during registration) and a host without the facility only loses this intake.
220599
+ */
220600
+ legacyWebhook;
220601
+ async serveWebhookPlane() {
220602
+ try {
220603
+ const handler = createWebhookHandler({
220604
+ logger: this.ctx.logger.child("baichuan-webhook"),
220605
+ listCameras: () => this.listReolinkCameras()
220606
+ });
220607
+ await this.ctx.dataPlane?.serve({
220608
+ prefix: BAICHUAN_WEBHOOK_PREFIX,
220609
+ access: "public",
220610
+ handler
220611
+ });
220612
+ try {
220613
+ this.legacyWebhook = await startLegacyWebhookIntake({
220614
+ logger: this.ctx.logger.child("baichuan-webhook"),
220615
+ listCameras: () => this.listReolinkCameras(),
220616
+ handler
220617
+ });
220618
+ } catch (err) {
220619
+ this.ctx.logger.warn("baichuan-webhook: legacy :9081 intake not bound (port in use?)", { meta: { error: err instanceof Error ? err.message : String(err) } });
220620
+ }
220621
+ } catch (err) {
220622
+ 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) } });
220623
+ }
220558
220624
  }
220559
220625
  /**
220560
220626
  * Reolink stableId derivation — uniform `mac → host(IP)` chain for
@@ -220622,7 +220688,7 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
220622
220688
  }
220623
220689
  this.logChannels?.stop();
220624
220690
  await this.clipService?.disposeClipStreamDial().catch(() => {});
220625
- await this.baichuanWebhookServer?.stop().catch(() => {});
220691
+ await this.legacyWebhook?.close().catch(() => void 0);
220626
220692
  await this.hubManager?.shutdown().catch((err) => {
220627
220693
  this.ctx.logger.warn("Reolink NVR sessions did not close cleanly", { meta: { error: errMsg$3(err) } });
220628
220694
  });
@@ -220811,32 +220877,17 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
220811
220877
  return report;
220812
220878
  }
220813
220879
  /**
220814
- * Settings changed → rebind the SMTP / HaCfg intakes so port/domain/auth
220815
- * edits apply without a full addon restart. Best-effort: a bind failure is
220816
- * logged inside the server and leaves it stopped until the next edit.
220880
+ * Settings changed → refresh the mirror the clip refresh timer reads.
220817
220881
  */
220818
220882
  async onConfigChanged() {
220819
220883
  await this.refreshClipRefreshGlobalStore();
220820
- await this.baichuanWebhookServer?.restart().catch((err) => {
220821
- this.ctx.logger.warn("baichuan-webhook: restart after settings change failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
220822
- });
220823
220884
  }
220824
220885
  /**
220825
- * The addon's global settings schema.
220826
- *
220827
- * This used to override `getGlobalSettings()` wholesale, on the belief that a
220828
- * dynamic `recommendedHost` could not come from a static schema declaration.
220829
- * It can: `listLanHosts()` is synchronous, so the schema builder runs here
220830
- * and `BaseAddon.getGlobalSettings` does the identical
220831
- * `resolveGlobalStore` + `hydrateSchema` the override did by hand.
220832
- *
220833
- * Declaring the SCHEMA rather than overriding the GETTER is what lets core
220834
- * serve `getIntegrationSettings()` — it selects the declared sections out of
220835
- * this schema, so an addon that hides its schema behind a getter has nothing
220836
- * for core to select from.
220886
+ * The addon's global settings schema: the clip-catalog refresh default. (The battery push has
220887
+ * no addon-level setting: the mailbox is a broker and the webhook is a plane of the data plane.)
220837
220888
  */
220838
220889
  globalSettingsSchema() {
220839
- return { sections: [...buildBaichuanWebhookSettingsSchema(listLanHosts()[0] ?? "127.0.0.1").sections, ...buildClipRefreshSettingsSchema().sections] };
220890
+ return { sections: [...buildClipRefreshSettingsSchema().sections] };
220840
220891
  }
220841
220892
  /**
220842
220893
  * Per-instance form (D985): an NVR / Home Hub integration shows its connection only; the
@@ -220860,13 +220911,6 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
220860
220911
  return null;
220861
220912
  }
220862
220913
  }
220863
- /**
220864
- * The HaCfg webhook section IS this integration's configuration — one intake serving
220865
- * every Reolink camera. See `baichuan-webhook-schema.ts`. (The camera mailbox is a broker.)
220866
- */
220867
- integrationSettingSections() {
220868
- return [...BAICHUAN_WEBHOOK_SECTION_IDS];
220869
- }
220870
220914
  async onInitialize() {
220871
220915
  const regs = this.providerRegistrationsFromSuper(await super.onInitialize());
220872
220916
  this.logChannels = createLogChannelsProvider(this.ctx.logger.child("log-channels"));
@@ -220910,11 +220954,9 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
220910
220954
  this.startClipCatalogRefresh(this.clipService);
220911
220955
  this.wireClipCatalogRefreshers();
220912
220956
  await this.serveClipPlanes(this.clipService);
220957
+ await this.serveWebhookPlane();
220913
220958
  await this.runMigrations(defineAddonMigrations(REOLINK_MIGRATIONS, this.migrationPorts()));
220914
220959
  this.wireEmailBridge();
220915
- this.ensureBaichuanWebhookServer().start().catch((err) => {
220916
- this.ctx.logger.warn("baichuan-webhook: HTTP intake failed to start at init", { meta: { error: err instanceof Error ? err.message : String(err) } });
220917
- });
220918
220960
  const wireSleepPushResolvers = (event, via) => {
220919
220961
  const data = event.data;
220920
220962
  if (data.providerId !== this.addonId) return;
@@ -220924,8 +220966,7 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
220924
220966
  const emailBridge = this.ensureEmailBridge();
220925
220967
  dev.setEmailPushParamsResolver(() => emailBridge.buildCameraSetupParams(dev));
220926
220968
  emailBridge.sync();
220927
- const webhookServer = this.ensureBaichuanWebhookServer();
220928
- dev.setBaichuanWebhookParamsResolver(() => webhookServer.buildCameraSetupParams(dev));
220969
+ dev.setBaichuanWebhookParamsResolver(() => this.buildWebhookParams(dev));
220929
220970
  this.wireClipCatalogRefreshers();
220930
220971
  this.ctx.logger.debug("sleep-push: resolvers wired", {
220931
220972
  tags: { deviceId: data.deviceId },
@@ -220959,6 +221000,7 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
220959
221000
  migrationPorts() {
220960
221001
  return {
220961
221002
  readStored: () => this.readLegacyEmailPush(),
221003
+ readStoredRaw: () => this.resolveGlobalStore(),
220962
221004
  stashPending: (settings) => this.stashPendingMailbox(settings),
220963
221005
  warn: (message) => {
220964
221006
  this.ctx.logger.warn(message);
@@ -221102,9 +221144,9 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
221102
221144
  handler: service.streamHandler
221103
221145
  });
221104
221146
  this.ctx.logger.info("Reolink clip data planes served", { meta: {
221105
- thumbPath: `/addon/${REOLINK_ADDON_ID}/${service.thumbPrefix}`,
221106
- mediaPath: `/addon/${REOLINK_ADDON_ID}/${service.mediaPrefix}`,
221107
- streamPath: `/addon/${REOLINK_ADDON_ID}/${service.streamPrefix}`,
221147
+ thumbPath: `/addon/${REOLINK_ADDON_ID$1}/${service.thumbPrefix}`,
221148
+ mediaPath: `/addon/${REOLINK_ADDON_ID$1}/${service.mediaPrefix}`,
221149
+ streamPath: `/addon/${REOLINK_ADDON_ID$1}/${service.streamPrefix}`,
221108
221150
  thumbServed: thumb !== void 0,
221109
221151
  mediaServed: media !== void 0,
221110
221152
  streamServed: stream !== void 0
@@ -221344,8 +221386,7 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
221344
221386
  device.adoptApi(detectedApi);
221345
221387
  const emailBridge = this.ensureEmailBridge();
221346
221388
  device.setEmailPushParamsResolver(() => emailBridge.buildCameraSetupParams(device));
221347
- const webhookServer = this.ensureBaichuanWebhookServer();
221348
- device.setBaichuanWebhookParamsResolver(() => webhookServer.buildCameraSetupParams(device));
221389
+ device.setBaichuanWebhookParamsResolver(() => this.buildWebhookParams(device));
221349
221390
  }
221350
221391
  ctx.api?.pipelineOrchestrator?.applyDeviceSettingsPatch?.mutate({
221351
221392
  deviceId: device.id,