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