@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/CHANGELOG.md +6 -0
- package/dist/addon.js +1969 -1929
- package/dist/addon.mjs +1966 -1925
- package/i18n/en.json +0 -7
- package/i18n/it.json +0 -7
- package/package.json +2 -2
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
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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-
|
|
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.
|
|
120552
|
-
method: "
|
|
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: "
|
|
120560
|
-
method: "
|
|
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: "
|
|
120568
|
-
method: "
|
|
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: "
|
|
120585
|
-
method: "
|
|
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: "
|
|
120611
|
-
label: t("
|
|
120612
|
-
whenTrue: t("When
|
|
120613
|
-
whenFalse: t("When
|
|
120614
|
-
icon: "
|
|
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$
|
|
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$
|
|
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$
|
|
154029
|
+
pause: method(object({ deviceId: DeviceIdSchema$2 }), _void(), {
|
|
154143
154030
|
kind: "mutation",
|
|
154144
154031
|
auth: "protected"
|
|
154145
154032
|
}),
|
|
154146
|
-
resume: method(object({ deviceId: DeviceIdSchema$
|
|
154033
|
+
resume: method(object({ deviceId: DeviceIdSchema$2 }), _void(), {
|
|
154147
154034
|
kind: "mutation",
|
|
154148
154035
|
auth: "protected"
|
|
154149
154036
|
}),
|
|
154150
|
-
stop: method(object({ deviceId: DeviceIdSchema$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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
|
|
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
|
|
169671
|
-
* cap
|
|
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("
|
|
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-
|
|
219725
|
+
//#region src/reolink-discovery-map.ts
|
|
219941
219726
|
/**
|
|
219942
|
-
*
|
|
219943
|
-
*
|
|
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
|
-
|
|
219949
|
-
|
|
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
|
-
|
|
220029
|
-
|
|
220030
|
-
|
|
220031
|
-
|
|
220032
|
-
|
|
220033
|
-
|
|
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
|
-
|
|
220042
|
-
|
|
220043
|
-
|
|
220044
|
-
|
|
220045
|
-
|
|
220046
|
-
|
|
220047
|
-
|
|
220048
|
-
|
|
220049
|
-
|
|
220050
|
-
|
|
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
|
-
/**
|
|
220088
|
-
|
|
220089
|
-
|
|
220090
|
-
|
|
220091
|
-
|
|
220092
|
-
|
|
220093
|
-
|
|
220094
|
-
|
|
220095
|
-
|
|
220096
|
-
|
|
220097
|
-
|
|
220098
|
-
|
|
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
|
-
*
|
|
220138
|
-
*
|
|
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
|
-
|
|
220141
|
-
|
|
220142
|
-
|
|
220143
|
-
|
|
220144
|
-
|
|
220145
|
-
|
|
220146
|
-
|
|
220147
|
-
|
|
220148
|
-
|
|
220149
|
-
|
|
220150
|
-
|
|
220151
|
-
|
|
220152
|
-
|
|
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
|
-
|
|
220155
|
-
|
|
220156
|
-
|
|
220157
|
-
|
|
220158
|
-
|
|
220159
|
-
|
|
220160
|
-
|
|
220161
|
-
|
|
220162
|
-
|
|
220163
|
-
|
|
220164
|
-
|
|
220165
|
-
|
|
220166
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
220551
|
-
|
|
220552
|
-
|
|
220553
|
-
|
|
220554
|
-
|
|
220555
|
-
|
|
220556
|
-
|
|
220557
|
-
return
|
|
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.
|
|
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 →
|
|
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: [...
|
|
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
|
-
|
|
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
|
-
|
|
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,
|