@camstack/addon-provider-reolink 1.2.218 → 1.2.219

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/addon.mjs CHANGED
@@ -2,9 +2,8 @@ import { a as __toCommonJS, i as __require, n as __esmMin, o as __toESM, r as __
2
2
  import { A as normalizeDebugOptions, C as eventTraceLog, D as getH265NalType, E as extractVpsFromAnnexB, F as traceLog, M as splitAnnexBToNalPayloads, N as splitAnnexBToNalPayloads2, O as isH265Irap, P as talkTraceLog, S as deriveAesKey, T as extractSpsFromAnnexB, _ as bcHeaderHasPayloadOffset, a as BC_MAGIC, b as convertToAnnexB2, c as BaichuanVideoStream, f as __require$1, g as bcEncrypt, h as bcDecrypt, i as BC_CLASS_MODERN_24, j as recordingsTraceLog, k as md5StrModern, l as BcMediaAnnexBDecoder, m as aesEncrypt, n as BC_CLASS_LEGACY, o as BC_MAGIC_REV, p as aesDecrypt, r as BC_CLASS_MODERN_20, t as BC_CLASS_FILE_DOWNLOAD, v as bcHeaderIsKnown20, w as extractPpsFromAnnexB, x as debugLog, y as convertToAnnexB } from "./chunk-LXENEW4N-QHQAg67o.mjs";
3
3
  import { EventEmitter } from "events";
4
4
  import * as fs2 from "fs";
5
- import fs from "fs";
6
5
  import * as path2 from "path";
7
- import path, { dirname, join } from "path";
6
+ import { dirname, join } from "path";
8
7
  import * as crypto$7 from "crypto";
9
8
  import crypto$1, { createHash, randomUUID } from "crypto";
10
9
  import { execFile, spawn } from "child_process";
@@ -14,7 +13,7 @@ import { createHash as createHash$1, randomBytes } from "node:crypto";
14
13
  import { mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
15
14
  import { join as join$1 } from "node:path";
16
15
  import { PassThrough } from "stream";
17
- import { format, promisify } from "util";
16
+ import { promisify } from "util";
18
17
  import * as dgram2 from "dgram";
19
18
  import dgram from "dgram";
20
19
  import dns from "dns/promises";
@@ -22101,7 +22100,7 @@ var require_dist$1 = /* @__PURE__ */ __commonJSMin(((exports, module) => {
22101
22100
  //#endregion
22102
22101
  //#region ../../node_modules/yazl/index.js
22103
22102
  var require_yazl = /* @__PURE__ */ __commonJSMin(((exports) => {
22104
- var fs$2 = __require("fs");
22103
+ var fs$1 = __require("fs");
22105
22104
  var Transform$10 = __require("stream").Transform;
22106
22105
  var PassThrough$3 = __require("stream").PassThrough;
22107
22106
  var zlib$1 = __require("zlib");
@@ -22130,14 +22129,14 @@ var require_yazl = /* @__PURE__ */ __commonJSMin(((exports) => {
22130
22129
  if (shouldIgnoreAdding(self)) return;
22131
22130
  var entry = new Entry(metadataPath, false, options);
22132
22131
  self.entries.push(entry);
22133
- fs$2.stat(realPath, function(err, stats) {
22132
+ fs$1.stat(realPath, function(err, stats) {
22134
22133
  if (err) return self.emit("error", err);
22135
22134
  if (!stats.isFile()) return self.emit("error", /* @__PURE__ */ new Error("not a file: " + realPath));
22136
22135
  entry.uncompressedSize = stats.size;
22137
22136
  if (options.mtime == null) entry.setLastModDate(stats.mtime);
22138
22137
  if (options.mode == null) entry.setFileAttributesMode(stats.mode);
22139
22138
  entry.setFileDataPumpFunction(function() {
22140
- var readStream = fs$2.createReadStream(realPath);
22139
+ var readStream = fs$1.createReadStream(realPath);
22141
22140
  entry.state = Entry.FILE_DATA_IN_PROGRESS;
22142
22141
  readStream.on("error", function(err) {
22143
22142
  self.emit("error", err);
@@ -29163,7 +29162,7 @@ var require_lz4 = /* @__PURE__ */ __commonJSMin(((exports) => {
29163
29162
  };
29164
29163
  }));
29165
29164
  //#endregion
29166
- //#region node_modules/@apocaliss92/nodelink-js/dist/chunk-OYFAZ255.js
29165
+ //#region node_modules/@apocaliss92/nodelink-js/dist/chunk-DBCXE3EJ.js
29167
29166
  var import_fxp = require_fxp();
29168
29167
  var import_lz4 = /* @__PURE__ */ __toESM(require_lz4(), 1);
29169
29168
  function bcHeaderLen(messageClass, buf, bodyLen) {
@@ -31240,6 +31239,13 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter {
31240
31239
  * others wait on the same promise.
31241
31240
  */
31242
31241
  tcpConnectPromise;
31242
+ /**
31243
+ * Bound for a single TCP handshake. Without this (and without rejecting on
31244
+ * `close`), a socket destroyed mid-connect left `tcpConnectPromise` pending
31245
+ * forever — every later `sendXml`/`connect` that joined the same promise
31246
+ * hung past its request timeout (which only started AFTER connect resolved).
31247
+ */
31248
+ static TCP_CONNECT_TIMEOUT_MS = 1e4;
31243
31249
  msgNum = 0;
31244
31250
  loggedIn = false;
31245
31251
  subscribed = false;
@@ -31835,6 +31841,26 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter {
31835
31841
  }
31836
31842
  }
31837
31843
  }
31844
+ /**
31845
+ * Connect, but fail if the handshake does not settle within `timeoutMs`.
31846
+ * Returns remaining ms so callers can apply the rest of their budget to the
31847
+ * request itself (request timers used to start only AFTER connect resolved,
31848
+ * so a wedged `tcpConnectPromise` made every send hang forever).
31849
+ */
31850
+ async connectWithDeadline(timeoutMs, label) {
31851
+ const started = Date.now();
31852
+ let timer;
31853
+ try {
31854
+ await Promise.race([this.connect(), new Promise((_, reject) => {
31855
+ timer = setTimeout(() => {
31856
+ reject(/* @__PURE__ */ new Error(`Baichuan connect timeout after ${timeoutMs}ms (${label})`));
31857
+ }, Math.max(1, timeoutMs));
31858
+ })]);
31859
+ } finally {
31860
+ if (timer) clearTimeout(timer);
31861
+ }
31862
+ return Math.max(1, timeoutMs - (Date.now() - started));
31863
+ }
31838
31864
  async connect() {
31839
31865
  const desired = this.opts.transport ?? "tcp";
31840
31866
  if (desired === "tcp") {
@@ -31968,8 +31994,30 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter {
31968
31994
  this.emit("error", err);
31969
31995
  });
31970
31996
  await new Promise((resolve, reject) => {
31971
- sock.once("connect", () => resolve());
31972
- sock.once("error", (e) => reject(e));
31997
+ let settled = false;
31998
+ const settle = (fn) => {
31999
+ if (settled) return;
32000
+ settled = true;
32001
+ clearTimeout(timer);
32002
+ sock.off("connect", onConnect);
32003
+ sock.off("error", onError);
32004
+ sock.off("close", onClose);
32005
+ fn();
32006
+ };
32007
+ const onConnect = () => settle(() => resolve());
32008
+ const onError = (e) => settle(() => reject(e));
32009
+ const onClose = () => settle(() => reject(/* @__PURE__ */ new Error("Baichuan TCP connect aborted: socket closed")));
32010
+ const timer = setTimeout(() => {
32011
+ settle(() => {
32012
+ try {
32013
+ if (!sock.destroyed) sock.destroy();
32014
+ } catch {}
32015
+ reject(/* @__PURE__ */ new Error(`TCP connection timeout after ${_BaichuanClient.TCP_CONNECT_TIMEOUT_MS}ms`));
32016
+ });
32017
+ }, _BaichuanClient.TCP_CONNECT_TIMEOUT_MS);
32018
+ sock.once("connect", onConnect);
32019
+ sock.once("error", onError);
32020
+ sock.once("close", onClose);
31973
32021
  });
31974
32022
  const sid = this.socketSessionId;
31975
32023
  const localAddress = sock.localAddress;
@@ -32880,7 +32928,8 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter {
32880
32928
  async sendXml(params) {
32881
32929
  const internal = params.internal === true;
32882
32930
  if (!internal) this.touchUserActivity(`sendXml cmdId=${params.cmdId}`);
32883
- await this.connect();
32931
+ const timeoutMs = params.timeoutMs ?? 1e4;
32932
+ const remainingMs = await this.connectWithDeadline(timeoutMs, `sendXml cmdId=${params.cmdId}`);
32884
32933
  if (!internal) this.kickIdleDisconnectTimer();
32885
32934
  const channel = params.channel ?? this.opts.channel ?? 0;
32886
32935
  const channelId = params.channelIdOverride ?? (params.channel == null ? this.hostChannelId : channel + 1);
@@ -32905,12 +32954,11 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter {
32905
32954
  const enc = params.encryption ?? this.enc;
32906
32955
  const bodyBytes = this.encodeBodyXml(extXml, payloadXml, channelId, enc);
32907
32956
  const wire = Buffer.concat([header, bodyBytes]);
32908
- const timeoutMs = params.timeoutMs ?? 1e4;
32909
32957
  const framePromise = new Promise((resolve, reject) => {
32910
32958
  const t = setTimeout(() => {
32911
32959
  this.pending.delete(pendingKey);
32912
32960
  reject(/* @__PURE__ */ new Error(`Baichuan timeout cmdId=${cmdId} msgNum=${msgNum}`));
32913
- }, timeoutMs);
32961
+ }, remainingMs);
32914
32962
  this.pending.set(pendingKey, {
32915
32963
  resolve: (f) => {
32916
32964
  clearTimeout(t);
@@ -32967,7 +33015,8 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter {
32967
33015
  async sendFrame(params) {
32968
33016
  const internal = params.internal === true;
32969
33017
  if (!internal) this.touchUserActivity(`sendFrame cmdId=${params.cmdId}`);
32970
- await this.connect();
33018
+ const timeoutMs = params.timeoutMs ?? 1e4;
33019
+ const remainingMs = await this.connectWithDeadline(timeoutMs, `sendFrame cmdId=${params.cmdId}`);
32971
33020
  if (!internal) this.kickIdleDisconnectTimer();
32972
33021
  const channel = params.channel ?? this.opts.channel ?? 0;
32973
33022
  const channelId = params.channelIdOverride ?? (params.channel == null ? this.hostChannelId : channel + 1);
@@ -32992,12 +33041,11 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter {
32992
33041
  const enc = params.encryption ?? this.enc;
32993
33042
  const bodyBytes = this.encodeBodyXml(extXml, payloadXml, channelId, enc);
32994
33043
  const wire = Buffer.concat([header, bodyBytes]);
32995
- const timeoutMs = params.timeoutMs ?? 1e4;
32996
33044
  const framePromise = new Promise((resolve, reject) => {
32997
33045
  const t = setTimeout(() => {
32998
33046
  this.pending.delete(pendingKey);
32999
33047
  reject(/* @__PURE__ */ new Error(`Baichuan timeout cmdId=${cmdId} msgNum=${msgNum}`));
33000
- }, timeoutMs);
33048
+ }, remainingMs);
33001
33049
  this.pending.set(pendingKey, {
33002
33050
  resolve: (f) => {
33003
33051
  clearTimeout(t);
@@ -34191,7 +34239,7 @@ var ALWAYS_ON_DEFAULTS = {
34191
34239
  };
34192
34240
  var jimpPromise;
34193
34241
  function loadJimp() {
34194
- jimpPromise ??= Promise.all([import("./esm-DsdWoSZo.mjs"), import("./fonts-CJf_QFtH.mjs")]).then(([jimp, fonts]) => ({
34242
+ jimpPromise ??= Promise.all([import("./esm-CO4o0BWI.mjs"), import("./fonts-CJf_QFtH.mjs")]).then(([jimp, fonts]) => ({
34195
34243
  jimp,
34196
34244
  fonts
34197
34245
  }));
@@ -37607,10 +37655,18 @@ var mapToSimpleEvent = (event) => {
37607
37655
  }
37608
37656
  return null;
37609
37657
  };
37610
- var shouldAcceptSleepPushSource = (source, preference, webhookSupported) => {
37658
+ var shouldAcceptSleepPushSource = (source, preference, options) => {
37611
37659
  if (source !== "email" && source !== "baichuanWebhook") return true;
37660
+ const opts = typeof options === "boolean" ? {
37661
+ webhookSupported: options,
37662
+ webhookDeliverySeen: options
37663
+ } : options;
37612
37664
  const pref = preference === "webhook" || preference === "email" ? preference : "auto";
37613
- return pref === "webhook" || pref === "auto" && webhookSupported ? source === "baichuanWebhook" : source === "email";
37665
+ if (pref === "email") return source === "email";
37666
+ if (pref === "webhook") return source === "baichuanWebhook";
37667
+ if (!opts.webhookSupported) return source === "email";
37668
+ if (opts.webhookDeliverySeen) return source === "baichuanWebhook";
37669
+ return true;
37614
37670
  };
37615
37671
  var DEFAULT_SHELTER_CANVAS = {
37616
37672
  width: 540,
@@ -38958,19 +39014,10 @@ function parsePtzGuardXml(xml) {
38958
39014
  };
38959
39015
  }
38960
39016
  var emitter = new EventEmitter();
38961
- var lastEventByCamera = /* @__PURE__ */ new Map();
38962
- var MAX_GLOBAL_EVENTS = 300;
38963
- var globalRecentEvents = [];
38964
39017
  function onEmailPushEvent(handler) {
38965
39018
  emitter.on("event", handler);
38966
39019
  return () => emitter.off("event", handler);
38967
39020
  }
38968
- function emitEmailPushEvent(event) {
38969
- lastEventByCamera.set(event.cameraId, event);
38970
- globalRecentEvents.unshift(event);
38971
- if (globalRecentEvents.length > MAX_GLOBAL_EVENTS) globalRecentEvents.length = MAX_GLOBAL_EVENTS;
38972
- emitter.emit("event", event);
38973
- }
38974
39021
  function mapEmailPushInferredType(inferred) {
38975
39022
  switch (inferred) {
38976
39023
  case "motion":
@@ -38983,6 +39030,52 @@ function mapEmailPushInferredType(inferred) {
38983
39030
  default: return "motion";
38984
39031
  }
38985
39032
  }
39033
+ var AI_OBJECT_MAP = {
39034
+ people: "people",
39035
+ person: "people",
39036
+ human: "people",
39037
+ vehicle: "vehicle",
39038
+ car: "vehicle",
39039
+ dog_cat: "animal",
39040
+ dog: "animal",
39041
+ cat: "animal",
39042
+ pet: "animal",
39043
+ face: "face",
39044
+ package: "package"
39045
+ };
39046
+ function xmlTag(xml, tag2) {
39047
+ return (new RegExp(`<${tag2}>([^<]*)</${tag2}>`, "i").exec(xml)?.[1] ?? "").trim().toLowerCase();
39048
+ }
39049
+ function splitCsv(value) {
39050
+ return value.split(",").map((s) => s.trim().toLowerCase()).filter((s) => s.length > 0);
39051
+ }
39052
+ function mapBaichuanWebhookAlarmXmlToSimpleEvents(xml) {
39053
+ if (!xml || !/<AlarmEvent\b/i.test(xml)) return [];
39054
+ const out = /* @__PURE__ */ new Set();
39055
+ for (const match of xml.matchAll(/<AlarmEvent\b[^>]*>([\s\S]*?)<\/AlarmEvent>/gi)) {
39056
+ const block = match[1] ?? "";
39057
+ const status = splitCsv(xmlTag(block, "status"));
39058
+ const ai = splitCsv(xmlTag(block, "AItype") || xmlTag(block, "aiType") || xmlTag(block, "aitype"));
39059
+ if (status.includes("visitor")) {
39060
+ out.add("doorbell");
39061
+ continue;
39062
+ }
39063
+ const objects = [];
39064
+ for (const token of ai) {
39065
+ if (token === "none" || token === "other") continue;
39066
+ const mapped = AI_OBJECT_MAP[token];
39067
+ if (mapped) objects.push(mapped);
39068
+ }
39069
+ if (objects.length > 0) {
39070
+ for (const t of objects) out.add(t);
39071
+ continue;
39072
+ }
39073
+ const statusIndicatesMotion = status.some((s) => s !== "none" && s !== "visitor" && s !== "dn" && s !== "daynight");
39074
+ const aiIndicatesMotion = ai.some((a) => a !== "none");
39075
+ if (statusIndicatesMotion || aiIndicatesMotion) out.add("motion");
39076
+ }
39077
+ return [...out];
39078
+ }
38986
39079
  var emitter2 = new EventEmitter();
38987
39080
  var lastEventByCamera2 = /* @__PURE__ */ new Map();
38988
39081
  var MAX_GLOBAL_EVENTS2 = 300;
@@ -39007,6 +39100,11 @@ function mapBaichuanWebhookToSimpleEvents(event) {
39007
39100
  if (reason === "pir") return ["motion", "awake"];
39008
39101
  return ["awake"];
39009
39102
  }
39103
+ function mapBaichuanWebhookParsedToSimpleEvents(parsed) {
39104
+ if (parsed.kind === "event") return mapBaichuanWebhookToSimpleEvents(parsed);
39105
+ if (parsed.cmdId === 33) return mapBaichuanWebhookAlarmXmlToSimpleEvents(parsed.xml);
39106
+ return [];
39107
+ }
39010
39108
  var parser = new import_fxp.XMLParser({
39011
39109
  ignoreAttributes: false,
39012
39110
  attributeNamePrefix: "@_",
@@ -40905,7 +41003,7 @@ var parseSirenStatusListPushXml = (xml) => {
40905
41003
  };
40906
41004
  var jimpPromise2;
40907
41005
  function loadJimp2() {
40908
- jimpPromise2 ??= import("./esm-DsdWoSZo.mjs");
41006
+ jimpPromise2 ??= import("./esm-CO4o0BWI.mjs");
40909
41007
  return jimpPromise2;
40910
41008
  }
40911
41009
  var DUAL_LENS_DUAL_MOTION_MODELS = /* @__PURE__ */ new Set([
@@ -42584,13 +42682,19 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
42584
42682
  const matches = "match" in params ? params.match : (event) => event.cameraId === params.cameraId;
42585
42683
  return onBaichuanWebhookEvent((event) => {
42586
42684
  if (!matches(event)) return;
42587
- const types = mapBaichuanWebhookToSimpleEvents(event);
42685
+ const types = mapBaichuanWebhookParsedToSimpleEvents(event.raw);
42588
42686
  for (const type of types) this.dispatchSimpleEvent({
42589
42687
  type,
42590
42688
  channel,
42591
42689
  timestamp: event.receivedAtMs,
42592
42690
  source: "baichuanWebhook"
42593
42691
  });
42692
+ if (types.some((t) => t !== "motion" && t !== "doorbell" && t !== "awake" && t !== "sleeping" && t !== "other") && !types.includes("motion")) this.dispatchSimpleEvent({
42693
+ type: "motion",
42694
+ channel,
42695
+ timestamp: event.receivedAtMs,
42696
+ source: "baichuanWebhook"
42697
+ });
42594
42698
  if (onEvent) try {
42595
42699
  onEvent(event);
42596
42700
  } catch (err) {
@@ -44669,7 +44773,7 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
44669
44773
  const uid = await this.ensureUidForRecordings(channel, void 0);
44670
44774
  const payloadXml = buildFileInfoListReplayByNameXml({
44671
44775
  channel,
44672
- xmlChannelId: 0,
44776
+ xmlChannelId: channel,
44673
44777
  name: params.fileName,
44674
44778
  uid,
44675
44779
  streamType
@@ -44725,6 +44829,7 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
44725
44829
  });
44726
44830
  await dedicatedClient.sendXml({
44727
44831
  cmdId: 7,
44832
+ extensionXml: "",
44728
44833
  channel,
44729
44834
  payloadXml: stopXml,
44730
44835
  messageClass: BC_CLASS_MODERN_24,
@@ -44777,7 +44882,7 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
44777
44882
  const headerChannelIdOverride = this.resolveHeaderChannelIdForLogicalChannel(channel);
44778
44883
  const payloadXml = buildFileInfoListReplayByIdXml({
44779
44884
  channel,
44780
- xmlChannelId: 0,
44885
+ xmlChannelId: channel,
44781
44886
  id: params.fileName,
44782
44887
  ...uid ? { uid } : {},
44783
44888
  streamType
@@ -44834,6 +44939,7 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
44834
44939
  });
44835
44940
  await dedicatedClient.sendXml({
44836
44941
  cmdId: 7,
44942
+ extensionXml: "",
44837
44943
  channel,
44838
44944
  payloadXml: stopXml,
44839
44945
  messageClass: BC_CLASS_MODERN_24,
@@ -45764,13 +45870,13 @@ ${stderr}`));
45764
45870
  if (isNvr) uid = await this.ensureUidForRecordings(channel, params.uid);
45765
45871
  const payloadXml = ident.includes("/") ? buildFileInfoListReplayByIdXml({
45766
45872
  channel,
45767
- xmlChannelId: 0,
45873
+ xmlChannelId: channel,
45768
45874
  id: ident,
45769
45875
  ...uid ? { uid } : {},
45770
45876
  streamType
45771
45877
  }) : buildFileInfoListReplayByNameXml({
45772
45878
  channel,
45773
- xmlChannelId: 0,
45879
+ xmlChannelId: channel,
45774
45880
  name: ident,
45775
45881
  ...uid ? { uid } : {},
45776
45882
  streamType
@@ -45937,6 +46043,7 @@ ${stderr}`));
45937
46043
  try {
45938
46044
  await this.client.sendXml({
45939
46045
  cmdId: 7,
46046
+ extensionXml: "",
45940
46047
  channel: params.channel,
45941
46048
  payloadXml: buildFileInfoListStopXml({
45942
46049
  channel: params.channel,
@@ -56991,7 +57098,7 @@ var require_fetch = /* @__PURE__ */ __commonJSMin(((exports, module) => {
56991
57098
  var require_shared = /* @__PURE__ */ __commonJSMin(((exports, module) => {
56992
57099
  var urllib = require_url();
56993
57100
  var util = __require("util");
56994
- var fs$1 = __require("fs");
57101
+ var fs = __require("fs");
56995
57102
  var nmfetch = require_fetch();
56996
57103
  var errors = require_errors();
56997
57104
  var dns$1 = __require("dns");
@@ -57361,7 +57468,7 @@ var require_shared = /* @__PURE__ */ __commonJSMin(((exports, module) => {
57361
57468
  err.code = errors.EFILEACCESS;
57362
57469
  callback(err);
57363
57470
  });
57364
- return resolveStream(fs$1.createReadStream(content.path), callback);
57471
+ return resolveStream(fs.createReadStream(content.path), callback);
57365
57472
  }
57366
57473
  }
57367
57474
  if (typeof data[key].content === "string" && ![
@@ -98114,10 +98221,8 @@ var require_mailparser = /* @__PURE__ */ __commonJSMin(((exports, module) => {
98114
98221
  simpleParser: require_simple_parser()
98115
98222
  };
98116
98223
  }));
98117
- //#endregion
98118
- //#region node_modules/@apocaliss92/nodelink-js/dist/index.js
98119
- var import_smtp_server = require_smtp_server();
98120
- var import_mailparser = require_mailparser();
98224
+ require_smtp_server();
98225
+ require_mailparser();
98121
98226
  function buildRfc4571Sdp(video, audio) {
98122
98227
  let out = "v=0\r\n";
98123
98228
  out += "o=- 0 0 IN IP4 0.0.0.0\r\n";
@@ -100525,271 +100630,18 @@ function resolveAlawmulaw() {
100525
100630
  throw new Error("alawmulaw: unable to resolve mulaw/alaw codecs from module exports");
100526
100631
  }
100527
100632
  var { mulaw, alaw } = resolveAlawmulaw();
100528
- async function loadEmailPushTls(params) {
100529
- const certPath = path.join(params.dir, "cert.pem");
100530
- const keyPath = path.join(params.dir, "key.pem");
100531
- const warn = params.warn ?? ((m) => console.warn(`[email-push-tls] ${m}`));
100532
- if (!fs.existsSync(certPath) || !fs.existsSync(keyPath)) {
100533
- warn(`Email-push TLS requested but ${certPath} or ${keyPath} is missing. Place a PEM cert+key under that directory to enable STARTTLS, or disable TLS in the consumer config to silence this warning.`);
100534
- return;
100535
- }
100536
- return {
100537
- cert: fs.readFileSync(certPath),
100538
- key: fs.readFileSync(keyPath)
100539
- };
100540
- }
100541
- async function defaultLoadTls(dir, warn) {
100542
- return loadEmailPushTls({
100543
- dir,
100544
- warn
100545
- });
100546
- }
100547
- function classifyMessage(parsed) {
100548
- const haystack = `${parsed.subject ?? ""} ${parsed.text ?? ""}`.toLowerCase();
100633
+ function classifyEmailPushMessage(input) {
100634
+ const haystack = `${(input.subject ?? "").toLowerCase()} ${(input.text ?? "").toLowerCase()}`;
100549
100635
  if (/person|people|human/.test(haystack)) return "people";
100550
100636
  if (/vehicle|car|truck/.test(haystack)) return "vehicle";
100551
100637
  if (/dog[_\s-]?cat|pet|animal/.test(haystack)) return "animal";
100552
100638
  if (/face/.test(haystack)) return "face";
100553
100639
  if (/package|parcel/.test(haystack)) return "package";
100554
- if (/doorbell|ring(?:ing)?\s+button/.test(haystack)) return "doorbell";
100640
+ if (/ring(?:ing)?\s+button|doorbell\s+(?:press|ring|button)|visitor\s+at|someone\s+rang/.test(haystack)) return "doorbell";
100555
100641
  if (/motion|alarm|alert|detect/.test(haystack)) return "motion";
100642
+ if (/\bdoorbell\b/.test(haystack) && !/\bfrom\s+[^\n]*doorbell/.test(haystack)) return "doorbell";
100556
100643
  return "other";
100557
100644
  }
100558
- function createEmailPushServer(params) {
100559
- const log = {
100560
- debug: params.logger?.debug ?? (() => {}),
100561
- info: params.logger?.info ?? (() => {}),
100562
- warn: params.logger?.warn ?? (() => {}),
100563
- error: params.logger?.error ?? (() => {})
100564
- };
100565
- let config = params.config;
100566
- let server;
100567
- let status = buildInitialStatus(config);
100568
- function parseRecipient(rcpt) {
100569
- const at = rcpt.lastIndexOf("@");
100570
- if (at <= 0 || at === rcpt.length - 1) return void 0;
100571
- return {
100572
- local: rcpt.slice(0, at),
100573
- domain: rcpt.slice(at + 1).toLowerCase()
100574
- };
100575
- }
100576
- function resolveCameraIdFromRecipient(rcpt) {
100577
- const parsed = parseRecipient(rcpt);
100578
- if (!parsed) return void 0;
100579
- if (parsed.domain !== config.domain.toLowerCase()) return void 0;
100580
- const match = parsed.local.match(/^cam-(.+)$/);
100581
- if (!match || !match[1]) return void 0;
100582
- return params.cameraResolver(match[1]);
100583
- }
100584
- async function handleIncomingMessage(cameraId, recipient, raw) {
100585
- let parsed;
100586
- try {
100587
- parsed = await (0, import_mailparser.simpleParser)(raw);
100588
- } catch (err) {
100589
- log.warn(`Failed to parse mail for ${cameraId}: ${err instanceof Error ? err.message : err}`);
100590
- status.messagesRejected++;
100591
- return;
100592
- }
100593
- const receivedAtMs = Date.now();
100594
- const event = {
100595
- cameraId,
100596
- recipient,
100597
- inferredType: classifyMessage(parsed),
100598
- receivedAtMs,
100599
- subject: parsed.subject ?? "",
100600
- from: typeof parsed.from === "object" && parsed.from !== null && "text" in parsed.from ? String(parsed.from.text) : "",
100601
- bodyExcerpt: (parsed.text ?? "").slice(0, 500)
100602
- };
100603
- status.messagesAccepted++;
100604
- log.info(`Email push for camera=${cameraId} type=${event.inferredType} subject="${event.subject.slice(0, 80)}"`);
100605
- emitEmailPushEvent(event);
100606
- }
100607
- async function start() {
100608
- if (server) {
100609
- log.debug("startEmailPushServer called but server already running");
100610
- return;
100611
- }
100612
- const tlsOptions = config.tls ? await (params.loadTls ?? defaultLoadTls)(config.tlsDir ?? "./email-push-tls", (m) => log.warn(m)) : void 0;
100613
- status = buildInitialStatus(config);
100614
- const next = new import_smtp_server.SMTPServer({
100615
- authOptional: !config.requireAuth,
100616
- disabledCommands: config.requireAuth ? [] : ["AUTH"],
100617
- allowInsecureAuth: !config.tls,
100618
- size: config.maxMessageBytes,
100619
- maxClients: 50,
100620
- socketTimeout: 6e4,
100621
- closeTimeout: 3e4,
100622
- logger: {
100623
- info: (...args) => log.info(formatSmtpLogArgs(args)),
100624
- debug: (...args) => log.debug(formatSmtpLogArgs(args)),
100625
- error: (...args) => log.warn(formatSmtpLogArgs(args))
100626
- },
100627
- ...tlsOptions ? {
100628
- secure: false,
100629
- ...tlsOptions
100630
- } : {},
100631
- onConnect(session, callback) {
100632
- log.info(`SMTP connect from ${session.remoteAddress} (sessionId=${session.id})`);
100633
- callback();
100634
- },
100635
- onClose(session) {
100636
- log.debug(`SMTP close ${session.remoteAddress} (sessionId=${session.id})`);
100637
- },
100638
- onMailFrom(address, session, callback) {
100639
- log.info(`SMTP MAIL FROM ${address.address} (sessionId=${session.id})`);
100640
- callback();
100641
- },
100642
- onAuth(auth, session, callback) {
100643
- const expectedUser = config.authUsername;
100644
- const expectedPass = config.authPassword;
100645
- if (!expectedUser || !expectedPass) {
100646
- log.warn(`SMTP AUTH rejected from ${session.remoteAddress} (sessionId=${session.id}): server has no credentials configured`);
100647
- return callback(/* @__PURE__ */ new Error("Email push auth not configured"));
100648
- }
100649
- const stripDomain = (u) => {
100650
- const at = u.lastIndexOf("@");
100651
- if (at < 0) return u;
100652
- const local = u.slice(0, at);
100653
- return u.slice(at + 1).toLowerCase() === config.domain.toLowerCase() ? local : u;
100654
- };
100655
- if (stripDomain(auth.username ?? "") === stripDomain(expectedUser) && auth.password === expectedPass) {
100656
- log.info(`SMTP AUTH ok method=${auth.method} user=${auth.username} (sessionId=${session.id})`);
100657
- return callback(null, { user: auth.username });
100658
- }
100659
- log.warn(`SMTP AUTH FAIL method=${auth.method} from=${session.remoteAddress} triedUser="${auth.username}" expectedUser="${expectedUser}" triedPasswordLen=${auth.password?.length ?? 0} (sessionId=${session.id})`);
100660
- return callback(/* @__PURE__ */ new Error("Invalid username or password"));
100661
- },
100662
- onRcptTo(address, session, callback) {
100663
- const cameraId = resolveCameraIdFromRecipient(address.address);
100664
- if (!cameraId) {
100665
- status.messagesRejected++;
100666
- log.warn(`SMTP RCPT TO rejected ${address.address} \u2014 unknown recipient (sessionId=${session.id})`);
100667
- return callback(/* @__PURE__ */ new Error(`Unknown recipient ${address.address} (not registered)`));
100668
- }
100669
- log.info(`SMTP RCPT TO ${address.address} \u2192 camera=${cameraId} (sessionId=${session.id})`);
100670
- callback();
100671
- },
100672
- onData(stream, session, callback) {
100673
- const startedAt = Date.now();
100674
- log.debug(`SMTP DATA start (sessionId=${session.id} from=${session.envelope.mailFrom ? session.envelope.mailFrom.address : "?"})`);
100675
- const chunks = [];
100676
- stream.on("data", (chunk) => chunks.push(chunk));
100677
- stream.on("end", () => {
100678
- const recipients = session.envelope.rcptTo?.map((r) => r.address) ?? [];
100679
- const buffer = Buffer.concat(chunks);
100680
- log.info(`SMTP DATA received ${buffer.length}B in ${Date.now() - startedAt}ms for ${recipients.length} recipient(s) (sessionId=${session.id})`);
100681
- const matched = recipients.map((r) => ({
100682
- recipient: r,
100683
- cameraId: resolveCameraIdFromRecipient(r)
100684
- })).filter((m) => Boolean(m.cameraId));
100685
- if (matched.length === 0) {
100686
- status.messagesRejected++;
100687
- log.warn(`SMTP DATA dropped \u2014 no recognised recipients in [${recipients.join(", ")}] (sessionId=${session.id})`);
100688
- return callback(/* @__PURE__ */ new Error("No recognised recipients"));
100689
- }
100690
- Promise.all(matched.map((m) => handleIncomingMessage(m.cameraId, m.recipient, buffer))).then(() => callback()).catch((err) => {
100691
- const msg = err instanceof Error ? err.message : String(err);
100692
- log.error(`Email push pipeline error (sessionId=${session.id}): ${msg}`);
100693
- status.lastErrorMessage = msg;
100694
- callback(new Error(msg));
100695
- });
100696
- });
100697
- stream.on("error", (err) => {
100698
- log.warn(`SMTP stream error (sessionId=${session.id}): ${err.message}`);
100699
- callback(err);
100700
- });
100701
- }
100702
- });
100703
- next.on("error", (err) => {
100704
- status.lastErrorMessage = err.message;
100705
- log.error(`Email push server error: ${err.code ?? ""} ${err.message}`);
100706
- const netServer = next.server;
100707
- if (server === next && netServer && netServer.listening === false) {
100708
- status.running = false;
100709
- setTimeout(() => {
100710
- if (server !== next) return;
100711
- try {
100712
- next.listen(config.port, config.bindHost, () => {
100713
- status.running = true;
100714
- log.warn(`Email push SMTP re-listened on ${config.bindHost}:${config.port} after error`);
100715
- });
100716
- } catch (e) {
100717
- log.error(`Email push re-listen failed: ${e.message}`);
100718
- }
100719
- }, 5e3).unref?.();
100720
- }
100721
- });
100722
- await new Promise((resolve, reject) => {
100723
- next.listen(config.port, config.bindHost, () => {
100724
- status.running = true;
100725
- status.startedAtMs = Date.now();
100726
- log.info(`Email push SMTP listening on ${config.bindHost}:${config.port} (domain=${config.domain}, auth=${config.requireAuth}, tls=${config.tls})`);
100727
- resolve();
100728
- });
100729
- next.once("error", reject);
100730
- });
100731
- server = next;
100732
- }
100733
- async function stop() {
100734
- if (!server) return;
100735
- const active = server;
100736
- server = void 0;
100737
- await new Promise((resolve) => {
100738
- active.close(() => resolve());
100739
- });
100740
- status.running = false;
100741
- log.info("Email push SMTP server stopped");
100742
- }
100743
- async function restart() {
100744
- await stop();
100745
- await start();
100746
- }
100747
- return {
100748
- start,
100749
- stop,
100750
- restart,
100751
- getStatus() {
100752
- return { ...status };
100753
- },
100754
- updateConfig(next) {
100755
- config = next;
100756
- }
100757
- };
100758
- }
100759
- function formatSmtpLogArgs(args) {
100760
- if (args.length === 0) return "[smtp]";
100761
- const [first, ...rest] = args;
100762
- let tag = "";
100763
- let formatArgs = args;
100764
- if (first && typeof first === "object" && !Array.isArray(first)) {
100765
- const meta = first;
100766
- const tnx = typeof meta.tnx === "string" ? meta.tnx : void 0;
100767
- const cid = typeof meta.cid === "string" ? meta.cid : void 0;
100768
- const sid = typeof meta.sid === "string" ? meta.sid : void 0;
100769
- const bits = [];
100770
- if (tnx) bits.push(tnx);
100771
- if (cid) bits.push(`cid=${cid}`);
100772
- if (sid) bits.push(`sid=${sid}`);
100773
- if (bits.length > 0) tag = ` [${bits.join(" ")}]`;
100774
- formatArgs = rest;
100775
- }
100776
- return `[smtp]${tag} ${format(...formatArgs)}`;
100777
- }
100778
- function buildInitialStatus(config) {
100779
- return {
100780
- enabled: true,
100781
- running: false,
100782
- port: config.port,
100783
- bindHost: config.bindHost,
100784
- domain: config.domain,
100785
- requireAuth: config.requireAuth,
100786
- tls: config.tls,
100787
- messagesAccepted: 0,
100788
- messagesRejected: 0,
100789
- startedAtMs: void 0,
100790
- lastErrorMessage: void 0
100791
- };
100792
- }
100793
100645
  function asRecord(value) {
100794
100646
  if (value && typeof value === "object" && !Array.isArray(value)) return value;
100795
100647
  }
@@ -101806,13 +101658,13 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
101806
101658
  return EventCategory;
101807
101659
  }({});
101808
101660
  //#endregion
101809
- //#region ../types/dist/device-type-BOWLtoOt.mjs
101661
+ //#region ../types/dist/device-type-CfHw_quR.mjs
101810
101662
  /**
101811
101663
  import { errMsg } from '@camstack/types'
101812
101664
  * Extract a human-readable message from an unknown error value.
101813
101665
  * Replaces the ubiquitous `errMsg(err)` pattern.
101814
101666
  */
101815
- function errMsg$2(err) {
101667
+ function errMsg$3(err) {
101816
101668
  if (err instanceof Error) return err.message;
101817
101669
  if (typeof err === "string") return err;
101818
101670
  return String(err);
@@ -101995,6 +101847,9 @@ var DeviceFeature = /* @__PURE__ */ function(DeviceFeature) {
101995
101847
  DeviceFeature["CoverPositionable"] = "cover-positionable";
101996
101848
  /** Cover exposes slat-tilt control. Gates the tilt slider UI. */
101997
101849
  DeviceFeature["CoverTilt"] = "cover-tilt";
101850
+ /** Cover has a real stop: `stop` halts the travel in place. Gates the stop
101851
+ * control in exporters; a provider whose `stop` is a no-op never declares it. */
101852
+ DeviceFeature["CoverStoppable"] = "cover-stoppable";
101998
101853
  /** Valve exposes intermediate position control (0..100). Gates the
101999
101854
  * position slider / drag surface UI. */
102000
101855
  DeviceFeature["ValvePositionable"] = "valve-positionable";
@@ -106860,7 +106715,7 @@ var ZodIssueCode = {
106860
106715
  var ZodFirstPartyTypeKind;
106861
106716
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
106862
106717
  //#endregion
106863
- //#region ../types/dist/sleep-B-vp7x15.mjs
106718
+ //#region ../types/dist/sleep-QsoHs66J.mjs
106864
106719
  /**
106865
106720
  * The audio chunk plane's byte format, and the ONE expansion from a coded
106866
106721
  * window to float samples (D455).
@@ -106977,7 +106832,7 @@ function isKeychainStoredField(field) {
106977
106832
  var SECRET_CLEAR = { $secretClear: true };
106978
106833
  /** The `'***'` token D634 redaction writes for non-admins. */
106979
106834
  var LEGACY_DEVICE_REDACTION = "***";
106980
- function isRecord$6(value) {
106835
+ function isRecord$7(value) {
106981
106836
  return typeof value === "object" && value !== null && !Array.isArray(value);
106982
106837
  }
106983
106838
  function makeSecretMarker(ref) {
@@ -106988,16 +106843,16 @@ function makeSecretMarker(ref) {
106988
106843
  }
106989
106844
  /** A stored marker: an object whose `$secret` is a non-empty string. */
106990
106845
  function isSecretMarker(value) {
106991
- if (!isRecord$6(value)) return false;
106846
+ if (!isRecord$7(value)) return false;
106992
106847
  const ref = value["$secret"];
106993
106848
  return typeof ref === "string" && ref.length > 0;
106994
106849
  }
106995
106850
  /** Anything carrying a `$secret` key — a stored marker or a UI view of one. */
106996
106851
  function isSecretMarkerLike(value) {
106997
- return isRecord$6(value) && Object.prototype.hasOwnProperty.call(value, "$secret");
106852
+ return isRecord$7(value) && Object.prototype.hasOwnProperty.call(value, "$secret");
106998
106853
  }
106999
106854
  function isSecretClear(value) {
107000
- return isRecord$6(value) && value["$secretClear"] === true;
106855
+ return isRecord$7(value) && value["$secretClear"] === true;
107001
106856
  }
107002
106857
  /**
107003
106858
  * The UI view of a stored secret field value: a marker becomes "set", inline
@@ -107380,6 +107235,53 @@ function emitReadiness(bus, params) {
107380
107235
  ts
107381
107236
  }));
107382
107237
  }
107238
+ /** Why a catalogue entry is not a runnable body of its declared kind. */
107239
+ var AddonMigrationDefinitionError = class extends Error {
107240
+ id;
107241
+ constructor(id, message) {
107242
+ super(`${id}: ${message}`);
107243
+ this.id = id;
107244
+ this.name = "AddonMigrationDefinitionError";
107245
+ }
107246
+ };
107247
+ /**
107248
+ * Bind a catalogue to the addon's ports. Throws {@link AddonMigrationDefinitionError} when
107249
+ * an entry's `bodyKind` does not match the body it carries, or is `sql` (a hub-only
107250
+ * body). A throwing body never escapes `execute`: it is reported as a failed result.
107251
+ */
107252
+ function defineAddonMigrations(catalogue, ports) {
107253
+ return catalogue.map((entry) => {
107254
+ const { meta } = entry;
107255
+ if ("plan" in entry) {
107256
+ if (meta.bodyKind !== "plan") throw new AddonMigrationDefinitionError(meta.id, `carries a plan body but declares "${meta.bodyKind}"`);
107257
+ return {
107258
+ meta,
107259
+ execute: () => guarded(async () => ({
107260
+ kind: "plan",
107261
+ plan: await entry.plan(ports)
107262
+ }))
107263
+ };
107264
+ }
107265
+ if (meta.bodyKind !== "procedure") throw new AddonMigrationDefinitionError(meta.id, `carries a procedure body but declares "${meta.bodyKind}"`);
107266
+ return {
107267
+ meta,
107268
+ execute: () => guarded(async () => ({
107269
+ kind: "procedure",
107270
+ result: await entry.procedure(ports)
107271
+ }))
107272
+ };
107273
+ });
107274
+ }
107275
+ async function guarded(work) {
107276
+ try {
107277
+ return await work();
107278
+ } catch (err) {
107279
+ return {
107280
+ kind: "failed",
107281
+ error: errMsg$3(err)
107282
+ };
107283
+ }
107284
+ }
107383
107285
  var NOT_RUN = {
107384
107286
  declared: false,
107385
107287
  snapshot: null,
@@ -107407,7 +107309,7 @@ async function runAddonMigrations(host, migrations, logger) {
107407
107309
  metas: migrations.map((m) => m.meta)
107408
107310
  });
107409
107311
  } catch (err) {
107410
- log.error("the migration engine refused or could not take this addon catalogue — none ran", { meta: { error: errMsg$2(err) } });
107312
+ log.error("the migration engine refused or could not take this addon catalogue — none ran", { meta: { error: errMsg$3(err) } });
107411
107313
  return {
107412
107314
  ...NOT_RUN,
107413
107315
  blockOwner: migrations.filter((m) => m.meta.onFailure === "block-owner").map((m) => m.meta.id)
@@ -107460,7 +107362,7 @@ async function runAddonMigrations(host, migrations, logger) {
107460
107362
  } catch (err) {
107461
107363
  log.error("migration could not be completed with the engine", {
107462
107364
  tags,
107463
- meta: { error: errMsg$2(err) }
107365
+ meta: { error: errMsg$3(err) }
107464
107366
  });
107465
107367
  failed.push(step.id);
107466
107368
  }
@@ -107703,7 +107605,7 @@ var KeychainWriteRefusedError = class extends Error {
107703
107605
  }
107704
107606
  };
107705
107607
  var KEEP = { kind: "keep" };
107706
- function isRecord$5(value) {
107608
+ function isRecord$6(value) {
107707
107609
  return typeof value === "object" && value !== null && !Array.isArray(value);
107708
107610
  }
107709
107611
  function defaultMintId() {
@@ -107716,7 +107618,7 @@ function collectSecretMarkerRefs(value, out = []) {
107716
107618
  return out;
107717
107619
  }
107718
107620
  if (Array.isArray(value)) for (const item of value) collectSecretMarkerRefs(item, out);
107719
- else if (isRecord$5(value)) for (const child of Object.values(value)) collectSecretMarkerRefs(child, out);
107621
+ else if (isRecord$6(value)) for (const child of Object.values(value)) collectSecretMarkerRefs(child, out);
107720
107622
  return out;
107721
107623
  }
107722
107624
  async function storedValueEquals(secrets, marker, candidate) {
@@ -107771,7 +107673,7 @@ function rowId(row, idKey) {
107771
107673
  return null;
107772
107674
  }
107773
107675
  async function writeArray(ctx, node, incoming, stored, fieldPath) {
107774
- const storedRows = Array.isArray(stored) ? stored.filter(isRecord$5) : [];
107676
+ const storedRows = Array.isArray(stored) ? stored.filter(isRecord$6) : [];
107775
107677
  if (!Array.isArray(incoming)) {
107776
107678
  ctx.deletes.push(...collectSecretMarkerRefs(storedRows));
107777
107679
  return {
@@ -107787,7 +107689,7 @@ async function writeArray(ctx, node, incoming, stored, fieldPath) {
107787
107689
  const kept = /* @__PURE__ */ new Set();
107788
107690
  const rows = [];
107789
107691
  for (const row of incoming) {
107790
- if (!isRecord$5(row)) {
107692
+ if (!isRecord$6(row)) {
107791
107693
  rows.push(row);
107792
107694
  continue;
107793
107695
  }
@@ -107812,7 +107714,7 @@ async function writeNode(ctx, node, incoming, stored, fieldPath) {
107812
107714
  case "leaf": return writeLeaf(ctx, incoming, stored, fieldPath);
107813
107715
  case "array": return writeArray(ctx, node, incoming, stored, fieldPath);
107814
107716
  case "object": {
107815
- if (!isRecord$5(incoming)) {
107717
+ if (!isRecord$6(incoming)) {
107816
107718
  if (incoming === void 0) return KEEP;
107817
107719
  ctx.deletes.push(...collectSecretMarkerRefs(stored));
107818
107720
  return {
@@ -107820,7 +107722,7 @@ async function writeNode(ctx, node, incoming, stored, fieldPath) {
107820
107722
  value: incoming
107821
107723
  };
107822
107724
  }
107823
- const storedObj = isRecord$5(stored) ? stored : {};
107725
+ const storedObj = isRecord$6(stored) ? stored : {};
107824
107726
  return {
107825
107727
  kind: "value",
107826
107728
  value: await writeMembers(ctx, node.children, incoming, storedObj, (m) => `${fieldPath}.${m}`)
@@ -107943,7 +107845,7 @@ async function resolveSecretMarkers(value, secrets, options = {}) {
107943
107845
  value: items
107944
107846
  };
107945
107847
  }
107946
- if (isRecord$5(v)) {
107848
+ if (isRecord$6(v)) {
107947
107849
  const out = {};
107948
107850
  for (const [k, child] of Object.entries(v)) {
107949
107851
  const s = substitute(child);
@@ -107975,7 +107877,7 @@ var SecretOverwriteRefusedError = class extends Error {
107975
107877
  this.name = "SecretOverwriteRefusedError";
107976
107878
  }
107977
107879
  };
107978
- function isRecord$4(value) {
107880
+ function isRecord$5(value) {
107979
107881
  return typeof value === "object" && value !== null && !Array.isArray(value);
107980
107882
  }
107981
107883
  function resolvedValue(pass, marker) {
@@ -108004,7 +107906,7 @@ function inArray(pass, incoming, stored) {
108004
107906
  return incoming.map((item, index) => {
108005
107907
  const twin = stored[index];
108006
107908
  if (isSecretMarker(twin)) return typeof item === "string" && item === resolvedValue(pass, twin) ? twin : item;
108007
- if (isRecord$4(item) && isRecord$4(twin)) return inArrayRow(pass, item, twin);
107909
+ if (isRecord$5(item) && isRecord$5(twin)) return inArrayRow(pass, item, twin);
108008
107910
  if (Array.isArray(item) && Array.isArray(twin)) return inArray(pass, item, twin);
108009
107911
  return item;
108010
107912
  });
@@ -108015,7 +107917,7 @@ function inArrayRow(pass, incoming, stored) {
108015
107917
  const twin = stored[key];
108016
107918
  if (isSecretMarker(twin)) {
108017
107919
  if (typeof value === "string" && value === resolvedValue(pass, twin)) out[key] = twin;
108018
- } else if (isRecord$4(value) && isRecord$4(twin)) out[key] = inArrayRow(pass, value, twin);
107920
+ } else if (isRecord$5(value) && isRecord$5(twin)) out[key] = inArrayRow(pass, value, twin);
108019
107921
  else if (Array.isArray(value) && Array.isArray(twin)) out[key] = inArray(pass, value, twin);
108020
107922
  }
108021
107923
  return out;
@@ -108023,7 +107925,7 @@ function inArrayRow(pass, incoming, stored) {
108023
107925
  async function walk(pass, incoming, stored) {
108024
107926
  if (isSecretMarker(stored)) return atMarker(pass, incoming, stored);
108025
107927
  if (Array.isArray(incoming) && Array.isArray(stored)) return inArray(pass, incoming, stored);
108026
- if (!isRecord$4(incoming) || !isRecord$4(stored)) return incoming;
107928
+ if (!isRecord$5(incoming) || !isRecord$5(stored)) return incoming;
108027
107929
  const out = { ...incoming };
108028
107930
  for (const [key, value] of Object.entries(incoming)) out[key] = await walk(pass, value, stored[key]);
108029
107931
  return out;
@@ -108054,7 +107956,7 @@ async function restoreUnchangedSecrets(input) {
108054
107956
  function isJsonEqual(a, b) {
108055
107957
  if (Object.is(a, b)) return true;
108056
107958
  if (Array.isArray(a)) return Array.isArray(b) && a.length === b.length && a.every((v, i) => isJsonEqual(v, b[i]));
108057
- if (isRecord$4(a) && isRecord$4(b)) {
107959
+ if (isRecord$5(a) && isRecord$5(b)) {
108058
107960
  const keys = Object.keys(a);
108059
107961
  if (keys.length !== Object.keys(b).length) return false;
108060
107962
  return keys.every((k) => Object.prototype.hasOwnProperty.call(b, k) && isJsonEqual(a[k], b[k]));
@@ -108078,7 +107980,7 @@ function markerKeysOf(blob, owner) {
108078
107980
  }
108079
107981
  function nodeFromStored(value, owner) {
108080
107982
  if (isSecretMarker(value)) return isSecretRefOwnedBy(value.$secret, owner) ? { kind: "leaf" } : null;
108081
- if (!isRecord$4(value)) return null;
107983
+ if (!isRecord$5(value)) return null;
108082
107984
  const children = /* @__PURE__ */ new Map();
108083
107985
  for (const [key, child] of Object.entries(value)) {
108084
107986
  const node = nodeFromStored(child, owner);
@@ -108697,6 +108599,44 @@ var BaseAddon = class {
108697
108599
  return null;
108698
108600
  }
108699
108601
  /**
108602
+ * D986 scoped settings: the form shape of the CLUSTER-wide settings one capability this addon
108603
+ * provides owns, or `null` when it has none. `cap` is a capability name the addon's manifest
108604
+ * declares. Stored as `addon-settings` rows `<addonId>.<field>`. The keys of this schema, of
108605
+ * {@link nodeSettingsSchema} and of {@link deviceDefaultsSchema} are disjoint, and a key never
108606
+ * contains `@` (guard `check-scoped-settings-declared`). SHAPE only: values are read by scope.
108607
+ */
108608
+ async clusterSettingsSchema(_cap) {
108609
+ return null;
108610
+ }
108611
+ /**
108612
+ * D986: the form shape of the settings one capability owns PER NODE, or `null`. Stored as
108613
+ * `addon-settings` rows `<addonId>.<field>@<nodeId>` carrying `nodeId` in the row data, so a
108614
+ * forgotten node's rows go in one central delete.
108615
+ */
108616
+ async nodeSettingsSchema(_cap) {
108617
+ return null;
108618
+ }
108619
+ /**
108620
+ * D986: the form shape of the FLEET DEFAULTS of the per-device settings one capability owns,
108621
+ * or `null`. Stored as `addon-settings` rows `<addonId>.<field>`; a device resolves its value
108622
+ * as device row, then this default, then the schema default (`resolveDeviceSettings`).
108623
+ */
108624
+ async deviceDefaultsSchema(_cap) {
108625
+ return null;
108626
+ }
108627
+ /**
108628
+ * The host's read of the scoped declarations: the schema one capability declares for `scope`,
108629
+ * or `null`. Beside {@link getIntegrationFormSchema}, the same shape: the hook is protected, the
108630
+ * host asks this method.
108631
+ */
108632
+ async getScopedSettingsSchema(scope, cap) {
108633
+ switch (scope) {
108634
+ case "cluster": return this.clusterSettingsSchema(cap);
108635
+ case "node": return this.nodeSettingsSchema(cap);
108636
+ case "device-defaults": return this.deviceDefaultsSchema(cap);
108637
+ }
108638
+ }
108639
+ /**
108700
108640
  * What the create wizard and the integration edit page render for one instance: the addon's
108701
108641
  * per-instance schema when it declares one, else its global schema (what they always showed).
108702
108642
  */
@@ -109490,6 +109430,21 @@ function normalizeAddonInitResult(result) {
109490
109430
  if (Array.isArray(result)) return { providers: result };
109491
109431
  return result;
109492
109432
  }
109433
+ /** Limits and names of `ctx.records` (D881). One authority; the engine and the facade read it. */
109434
+ /** The files a kind may live in. `history` is device-history's (D850), never records. */
109435
+ var RECORD_DATABASES = ["settings", "events"];
109436
+ /** The classes of a kind: the `@durable` vocabulary, one authority (replaces the annotation). */
109437
+ var RECORD_CLASSES = [
109438
+ "config",
109439
+ "ledger",
109440
+ "audit",
109441
+ "mirror",
109442
+ "ephemeral"
109443
+ ];
109444
+ var RECORD_DEFAULT_MAX_DATA_BYTES = 64 * 1024;
109445
+ var RECORD_MAX_DATA_BYTES = 1024 * 1024;
109446
+ var RECORD_BATCH_MAX = 1e3;
109447
+ var RECORD_QUERY_MAX = 1e3;
109493
109448
  /**
109494
109449
  * The share-view scope kinds a share-link token can carry. The hub mints
109495
109450
  * and validates them (`ShareViewGrantSchema` builds its
@@ -109514,21 +109469,6 @@ var PeerBytesTicketSchema = object({
109514
109469
  */
109515
109470
  declaredBytes: number().int().nonnegative().nullable()
109516
109471
  });
109517
- /** Limits and names of `ctx.records` (D881). One authority; the engine and the facade read it. */
109518
- /** The files a kind may live in. `history` is device-history's (D850), never records. */
109519
- var RECORD_DATABASES = ["settings", "events"];
109520
- /** The classes of a kind: the `@durable` vocabulary, one authority (replaces the annotation). */
109521
- var RECORD_CLASSES = [
109522
- "config",
109523
- "ledger",
109524
- "audit",
109525
- "mirror",
109526
- "ephemeral"
109527
- ];
109528
- var RECORD_DEFAULT_MAX_DATA_BYTES = 64 * 1024;
109529
- var RECORD_MAX_DATA_BYTES = 1024 * 1024;
109530
- var RECORD_BATCH_MAX = 1e3;
109531
- var RECORD_QUERY_MAX = 1e3;
109532
109472
  function defOf(schema) {
109533
109473
  const zodBag = Reflect.get(schema, "_zod");
109534
109474
  if (typeof zodBag !== "object" || zodBag === null) return {};
@@ -110140,6 +110080,81 @@ var ProfileRtspEntrySchema = object({
110140
110080
  codec: string().optional(),
110141
110081
  resolution: CamStreamResolutionSchema.optional()
110142
110082
  });
110083
+ /** Every debug channel's name starts with this; the ring denies it by default. */
110084
+ var DEBUG_CHANNEL_PREFIX = "debug.";
110085
+ function isDebugChannelName(name) {
110086
+ return name.startsWith(DEBUG_CHANNEL_PREFIX);
110087
+ }
110088
+ /**
110089
+ * Who is watching what, in THIS process. In-memory, moved off the log path.
110090
+ *
110091
+ * `active` is a plain boolean FIELD, read first: with no watch anywhere, a
110092
+ * `ctx.debug` call is one load and one branch.
110093
+ */
110094
+ var DebugInterest = class {
110095
+ active = false;
110096
+ windows = /* @__PURE__ */ new Map();
110097
+ /**
110098
+ * Replace the interest with the FULL set of windows. Non-debug windows are
110099
+ * ignored and an absent one is dropped, so a lost disarm cannot leave a
110100
+ * channel running.
110101
+ */
110102
+ apply(windows, nowMs) {
110103
+ this.windows.clear();
110104
+ for (const window of windows) {
110105
+ if (!isDebugChannelName(window.channel) || window.armedUntilMs <= nowMs) continue;
110106
+ const ids = window.deviceIds;
110107
+ this.windows.set(window.channel, {
110108
+ untilMs: window.armedUntilMs,
110109
+ devices: ids === null || ids.length === 0 ? null : new Set(ids)
110110
+ });
110111
+ }
110112
+ this.active = this.windows.size > 0;
110113
+ }
110114
+ /** Drop what has lapsed. Called on a timer, never from a log path. */
110115
+ tick(nowMs) {
110116
+ const closed = [];
110117
+ for (const [name, window] of this.windows) if (window.untilMs <= nowMs) {
110118
+ this.windows.delete(name);
110119
+ closed.push(name);
110120
+ }
110121
+ this.active = this.windows.size > 0;
110122
+ return closed;
110123
+ }
110124
+ /** `channel` is the full name, `debug.<channel>`. */
110125
+ wants(channel, deviceId) {
110126
+ if (!this.active) return false;
110127
+ const window = this.windows.get(channel);
110128
+ if (window === void 0) return false;
110129
+ return window.devices === null || window.devices.has(deviceId);
110130
+ }
110131
+ };
110132
+ var interestInstance = null;
110133
+ function getDebugInterest() {
110134
+ interestInstance ??= new DebugInterest();
110135
+ return interestInstance;
110136
+ }
110137
+ /**
110138
+ * Build `ctx.debug`. The thunk runs ONLY when somebody watches this
110139
+ * (channel, device) pair, and the line carries `tags: { deviceId }` plus the
110140
+ * `debug.<channel>` name as `logChannel`.
110141
+ */
110142
+ function createDebugEmitter(logger, interest = getDebugInterest()) {
110143
+ return (channel, deviceId, thunk) => {
110144
+ if (!interest.active) return;
110145
+ const name = `${DEBUG_CHANNEL_PREFIX}${channel}`;
110146
+ if (!interest.wants(name, deviceId)) return;
110147
+ const line = thunk();
110148
+ const tags = {
110149
+ deviceId,
110150
+ logChannel: name
110151
+ };
110152
+ logger.info(line.message, line.meta === void 0 ? { tags } : {
110153
+ tags,
110154
+ meta: line.meta
110155
+ });
110156
+ };
110157
+ }
110143
110158
  /**
110144
110159
  * The model weight formats a catalog entry can ship. Schema-free on purpose:
110145
110160
  * `types/models.ts` builds Zod schemas from it, but a runner-closure consumer
@@ -110415,6 +110430,7 @@ var CAP_NAMES = Object.freeze([
110415
110430
  "humidity-sensor",
110416
110431
  "image",
110417
110432
  "image-settings",
110433
+ "integration-instances",
110418
110434
  "integrations",
110419
110435
  "intercom",
110420
110436
  "ir-transmitter",
@@ -112386,7 +112402,7 @@ function sleep(ms) {
112386
112402
  return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
112387
112403
  }
112388
112404
  //#endregion
112389
- //#region ../types/dist/composition-kKKOdFIG.mjs
112405
+ //#region ../types/dist/composition-Dh3RfMx5.mjs
112390
112406
  /**
112391
112407
  * The ffmpeg build every CamStack node downloads, per platform and arch.
112392
112408
  *
@@ -113600,6 +113616,94 @@ var NodeColorIdSchema = _enum([
113600
113616
  "node-g",
113601
113617
  "node-h"
113602
113618
  ]);
113619
+ //#endregion
113620
+ //#region ../types/dist/index.mjs
113621
+ /**
113622
+ * The `smtp-inbound` broker kind (D999): a mailbox a device can send mail to.
113623
+ *
113624
+ * The `broker` cap carries these as `Record<string, unknown>` on the wire; the
113625
+ * provider and its consumers parse them with the schemas below at BOTH ends, so
113626
+ * the shape is declared once.
113627
+ */
113628
+ /** The broker kind tag. */
113629
+ var SMTP_INBOUND_KIND = "smtp-inbound";
113630
+ /** The id the first mailbox is minted under (adopt-id), so a consumer can find it again. */
113631
+ var SMTP_INBOUND_DEFAULT_ID = "smtp-inbound-default";
113632
+ var SMTP_INBOUND_DEFAULT_PORT = 2525;
113633
+ var SMTP_INBOUND_DEFAULT_DOMAIN = "camstack.local";
113634
+ var SMTP_INBOUND_DEFAULT_MAX_MESSAGE_BYTES = 25 * 1024 * 1024;
113635
+ /** The text excerpt a message carries on the bus. */
113636
+ var SMTP_INBOUND_TEXT_MAX_CHARS = 4096;
113637
+ var PortSchema = number().int().min(1).max(65535);
113638
+ /** A recipient local-part: what stands before the `@`. */
113639
+ var SmtpRecipientSchema = string().min(1).max(64).regex(/^[A-Za-z0-9._+-]+$/);
113640
+ object({
113641
+ port: PortSchema.default(SMTP_INBOUND_DEFAULT_PORT),
113642
+ bindHost: string().min(1).default("0.0.0.0"),
113643
+ domain: string().min(1).default(SMTP_INBOUND_DEFAULT_DOMAIN),
113644
+ requireAuth: boolean().default(true),
113645
+ username: string().default(""),
113646
+ password: string().default("").meta({ secret: true }),
113647
+ starttls: boolean().default(false),
113648
+ maxMessageBytes: number().int().positive().default(SMTP_INBOUND_DEFAULT_MAX_MESSAGE_BYTES)
113649
+ });
113650
+ /**
113651
+ * What `getBrokerConfig` answers: what a device needs to send mail here.
113652
+ * `host` is null when the `internal` role of the network palette is not set;
113653
+ * `hostReason` then says where to set it.
113654
+ */
113655
+ var SmtpInboundConfigSchema = object({
113656
+ host: string().min(1).nullable(),
113657
+ hostReason: string().nullable(),
113658
+ port: PortSchema,
113659
+ domain: string().min(1),
113660
+ requireAuth: boolean(),
113661
+ username: string(),
113662
+ password: string(),
113663
+ starttls: boolean()
113664
+ });
113665
+ object({
113666
+ recipients: array(SmtpRecipientSchema).min(1),
113667
+ /**
113668
+ * Who is subscribing. A second `subscribe` with the same id REPLACES that subscriber's
113669
+ * recipients (the way a consumer that restarted, or lost a device, declares its set again);
113670
+ * a recipient held by another subscriber is refused with `smtp-inbound/recipient-claimed`.
113671
+ * Absent, the subscriber is anonymous and unique.
113672
+ */
113673
+ subscriberId: string().min(1).max(64).optional()
113674
+ });
113675
+ var SmtpInboundAttachmentSchema = object({
113676
+ filename: string(),
113677
+ contentType: string(),
113678
+ bytes: number().int().nonnegative()
113679
+ });
113680
+ /** The payload of one `broker.message` (the key is the recipient local-part). */
113681
+ var SmtpInboundMessageSchema = object({
113682
+ /** Monotonic per mailbox; a gap tells a consumer it missed an event. */
113683
+ seq: number().int().positive(),
113684
+ messageId: string(),
113685
+ receivedAt: number().int().nonnegative(),
113686
+ from: string(),
113687
+ to: string(),
113688
+ subject: string(),
113689
+ text: string().max(SMTP_INBOUND_TEXT_MAX_CHARS),
113690
+ attachments: array(SmtpInboundAttachmentSchema)
113691
+ });
113692
+ /** What `getState('since:<seq>')` answers: every retained message after `seq`. */
113693
+ var SmtpInboundSinceSchema = object({
113694
+ /** The newest seq issued so far (0 before any mail). */
113695
+ lastSeq: number().int().nonnegative(),
113696
+ /** The oldest seq still retained; messages below it are gone. */
113697
+ oldestSeq: number().int().nonnegative(),
113698
+ messages: array(object({
113699
+ key: string(),
113700
+ message: SmtpInboundMessageSchema
113701
+ }))
113702
+ });
113703
+ /** The key a consumer passes to `getState` to replay the ring, and its parser. */
113704
+ function smtpSinceKey(seq) {
113705
+ return `since:${seq}`;
113706
+ }
113603
113707
  new Set([
113604
113708
  "track",
113605
113709
  "face",
@@ -116765,81 +116869,6 @@ var TIMEZONES = [
116765
116869
  function findTimezone(id) {
116766
116870
  return TIMEZONES.find((tz) => tz.id === id);
116767
116871
  }
116768
- /** Every debug channel's name starts with this; the ring denies it by default. */
116769
- var DEBUG_CHANNEL_PREFIX = "debug.";
116770
- function isDebugChannelName(name) {
116771
- return name.startsWith(DEBUG_CHANNEL_PREFIX);
116772
- }
116773
- /**
116774
- * Who is watching what, in THIS process. In-memory, moved off the log path.
116775
- *
116776
- * `active` is a plain boolean FIELD, read first: with no watch anywhere, a
116777
- * `ctx.debug` call is one load and one branch.
116778
- */
116779
- var DebugInterest = class {
116780
- active = false;
116781
- windows = /* @__PURE__ */ new Map();
116782
- /**
116783
- * Replace the interest with the FULL set of windows. Non-debug windows are
116784
- * ignored and an absent one is dropped, so a lost disarm cannot leave a
116785
- * channel running.
116786
- */
116787
- apply(windows, nowMs) {
116788
- this.windows.clear();
116789
- for (const window of windows) {
116790
- if (!isDebugChannelName(window.channel) || window.armedUntilMs <= nowMs) continue;
116791
- const ids = window.deviceIds;
116792
- this.windows.set(window.channel, {
116793
- untilMs: window.armedUntilMs,
116794
- devices: ids === null || ids.length === 0 ? null : new Set(ids)
116795
- });
116796
- }
116797
- this.active = this.windows.size > 0;
116798
- }
116799
- /** Drop what has lapsed. Called on a timer, never from a log path. */
116800
- tick(nowMs) {
116801
- const closed = [];
116802
- for (const [name, window] of this.windows) if (window.untilMs <= nowMs) {
116803
- this.windows.delete(name);
116804
- closed.push(name);
116805
- }
116806
- this.active = this.windows.size > 0;
116807
- return closed;
116808
- }
116809
- /** `channel` is the full name, `debug.<channel>`. */
116810
- wants(channel, deviceId) {
116811
- if (!this.active) return false;
116812
- const window = this.windows.get(channel);
116813
- if (window === void 0) return false;
116814
- return window.devices === null || window.devices.has(deviceId);
116815
- }
116816
- };
116817
- var interestInstance = null;
116818
- function getDebugInterest() {
116819
- interestInstance ??= new DebugInterest();
116820
- return interestInstance;
116821
- }
116822
- /**
116823
- * Build `ctx.debug`. The thunk runs ONLY when somebody watches this
116824
- * (channel, device) pair, and the line carries `tags: { deviceId }` plus the
116825
- * `debug.<channel>` name as `logChannel`.
116826
- */
116827
- function createDebugEmitter(logger, interest = getDebugInterest()) {
116828
- return (channel, deviceId, thunk) => {
116829
- if (!interest.active) return;
116830
- const name = `${DEBUG_CHANNEL_PREFIX}${channel}`;
116831
- if (!interest.wants(name, deviceId)) return;
116832
- const line = thunk();
116833
- const tags = {
116834
- deviceId,
116835
- logChannel: name
116836
- };
116837
- logger.info(line.message, line.meta === void 0 ? { tags } : {
116838
- tags,
116839
- meta: line.meta
116840
- });
116841
- };
116842
- }
116843
116872
  /**
116844
116873
  * Per-component log CHANNELS — the gate a hot path consults, and the registry
116845
116874
  * an addon declares its channels in.
@@ -122516,6 +122545,7 @@ var SYSTEM_CAP_VOCABULARY = {
122516
122545
  "load-contribution": system(INTERNAL),
122517
122546
  "local-network": system(NETWORK),
122518
122547
  "log-channels": system(INTERNAL),
122548
+ "integration-instances": system(INTERNAL),
122519
122549
  "log-destination": system(INTERNAL),
122520
122550
  "login-method": system(IDENTITY),
122521
122551
  "mesh-network": system(NETWORK),
@@ -144722,7 +144752,7 @@ var TargetKindCapsSchema = object({
144722
144752
  * the union is large and not meant for runtime validation here; the exported
144723
144753
  * `TargetKind` type re-tightens `configSchema` to `ConfigUISchema`.
144724
144754
  */
144725
- var ConfigSchemaPassthrough$3 = unknown();
144755
+ var ConfigSchemaPassthrough$4 = unknown();
144726
144756
  var TargetKindSchema = object({
144727
144757
  kind: string(),
144728
144758
  label: string(),
@@ -144759,7 +144789,7 @@ var TargetKindSchema = object({
144759
144789
  * its raster path, which is the safe default for an unknown image.
144760
144790
  */
144761
144791
  iconMediaType: string().optional(),
144762
- configSchema: ConfigSchemaPassthrough$3,
144792
+ configSchema: ConfigSchemaPassthrough$4,
144763
144793
  /**
144764
144794
  * The config keys that decide WHERE this kind's secrets are sent (server,
144765
144795
  * base URL, instance, chat, recipient). A save that changes any of them
@@ -153655,10 +153685,23 @@ var DeviceDataEntrySchema = object({
153655
153685
  /** Rows (or files) held for the device in the store. */
153656
153686
  count: number().int().nonnegative()
153657
153687
  });
153658
- method(DeviceRemovedInputSchema, DeviceRemovedResultSchema, {
153659
- kind: "mutation",
153660
- auth: "admin"
153661
- }), method(_void(), array(DeviceDataEntrySchema).readonly(), { auth: "admin" });
153688
+ var deviceRemovalHookCapability = {
153689
+ name: "device-removal-hook",
153690
+ scope: "system",
153691
+ mode: "collection",
153692
+ internal: true,
153693
+ methods: {
153694
+ /** Delete everything this addon keeps for the deleted device. Idempotent. */
153695
+ onDeviceRemoved: method(DeviceRemovedInputSchema, DeviceRemovedResultSchema, {
153696
+ kind: "mutation",
153697
+ auth: "admin"
153698
+ }),
153699
+ /** Which devices this addon holds data for, per store. Reads only. */
153700
+ listDeviceData: method(_void(), array(DeviceDataEntrySchema).readonly(), { auth: "admin" })
153701
+ },
153702
+ /** In-process only — enumerated through `addons.listCapabilityProviders`. */
153703
+ mount: { kind: "skip" }
153704
+ };
153662
153705
  /**
153663
153706
  * device-runs - the durable record of ONE run of a device: a washing cycle, a
153664
153707
  * dishwasher cycle, later a mow or a clean (D992).
@@ -156521,6 +156564,89 @@ method(_void(), array(IntegrationWithStateSchema)), method(object({ id: string()
156521
156564
  auth: "admin"
156522
156565
  });
156523
156566
  /**
156567
+ * `integration-instances` - the connection of an addon that holds ONE or SEVERAL
156568
+ * (an account, a relay, a destination) as typed records (D985, D986). The addon's
156569
+ * own function page reads and writes them (Logging for Loki); the Integrations
156570
+ * page lists only the integrations that bring devices (D996).
156571
+ *
156572
+ * A collection of providers: each addon that holds connections registers one and a
156573
+ * caller reaches a provider with the pinned `addonId`.
156574
+ *
156575
+ * Identity: the instance id is immutable and opaque to the caller. A record the
156576
+ * Loki migration made keeps the `integrations` registry id it was created under
156577
+ * (`int_NNNN`); a single instance made by its function page takes the addon id.
156578
+ * `name` is the editable label.
156579
+ *
156580
+ * Secrets: every field the instance kind marks `.meta({ secret: true })` reaches
156581
+ * the form as a `SecretFieldView`, never text (D789). A patch value omitted, `''`
156582
+ * or the redaction token KEEPS the stored secret; `{ $secretClear: true }`
156583
+ * clears it.
156584
+ */
156585
+ var ConfigSchemaPassthrough$3 = custom();
156586
+ var IntegrationInstanceIdSchema = string().min(1);
156587
+ var IntegrationInstanceDescriptionSchema = object({
156588
+ /** `single`: at most one instance can exist (Loki). `multiple`: an account each. */
156589
+ instanceMode: _enum(["single", "multiple"]),
156590
+ /** What the instance is, for the UI (`destination`, `relay`, `account`). */
156591
+ kind: string(),
156592
+ /** True when `testInstance` really probes the remote. */
156593
+ canTest: boolean()
156594
+ });
156595
+ var IntegrationInstanceStatusSchema = object({
156596
+ state: _enum([
156597
+ "ok",
156598
+ "off",
156599
+ "error",
156600
+ "unknown"
156601
+ ]),
156602
+ detail: string().optional()
156603
+ });
156604
+ var IntegrationInstanceSchema = object({
156605
+ id: IntegrationInstanceIdSchema,
156606
+ name: string(),
156607
+ /** The addon's own rule (an URL is present, a client id is present...). */
156608
+ configured: boolean(),
156609
+ /**
156610
+ * The instance's own on/off switch (D996): always present, derived from the record body
156611
+ * (`enabled !== false`, so a row written before the field existed is on).
156612
+ */
156613
+ enabled: boolean(),
156614
+ /** Values for the instance form; a secret is a `SecretFieldView`, never text. */
156615
+ formValues: record(string(), unknown()),
156616
+ status: IntegrationInstanceStatusSchema.optional()
156617
+ });
156618
+ var IntegrationInstancePatchSchema = object({
156619
+ id: IntegrationInstanceIdSchema,
156620
+ name: string().min(1).optional(),
156621
+ /** Omitted / '' / redaction token keeps a secret; `{ $secretClear: true }` clears it. */
156622
+ values: record(string(), unknown())
156623
+ });
156624
+ method(_void(), IntegrationInstanceDescriptionSchema, { auth: "admin" }), method(object({ id: IntegrationInstanceIdSchema.nullable() }), ConfigSchemaPassthrough$3, { auth: "admin" }), method(_void(), array(IntegrationInstanceSchema).readonly(), { auth: "admin" }), method(object({ id: IntegrationInstanceIdSchema }), IntegrationInstanceSchema.nullable(), { auth: "admin" }), method(object({
156625
+ id: IntegrationInstanceIdSchema,
156626
+ name: string().min(1)
156627
+ }), object({ id: string() }), {
156628
+ kind: "mutation",
156629
+ auth: "admin"
156630
+ }), method(IntegrationInstancePatchSchema, object({ ok: literal(true) }), {
156631
+ kind: "mutation",
156632
+ auth: "admin"
156633
+ }), method(object({
156634
+ id: IntegrationInstanceIdSchema,
156635
+ enabled: boolean()
156636
+ }), object({ ok: literal(true) }), {
156637
+ kind: "mutation",
156638
+ auth: "admin"
156639
+ }), method(object({ id: IntegrationInstanceIdSchema }), object({ ok: literal(true) }), {
156640
+ kind: "mutation",
156641
+ auth: "admin"
156642
+ }), optionalMethod(object({
156643
+ id: IntegrationInstanceIdSchema.nullable(),
156644
+ values: record(string(), unknown())
156645
+ }), TestConnectionResultSchema$1, {
156646
+ kind: "mutation",
156647
+ auth: "admin"
156648
+ });
156649
+ /**
156524
156650
  * Two-way audio / talk-back cap. Clients open a WebRTC audio-only
156525
156651
  * session (server-initiated offer pattern, identical to the main
156526
156652
  * video WebRTC flow) and the browser captures the mic; the server
@@ -167329,7 +167455,14 @@ var NcMediaPolicySchema = object({
167329
167455
  */
167330
167456
  profile: CamProfileSchema.optional(),
167331
167457
  /** See {@link NcLinkedCameraModeSchema}. */
167332
- linkedCameras: NcLinkedCameraModeSchema.optional()
167458
+ linkedCameras: NcLinkedCameraModeSchema.optional(),
167459
+ /**
167460
+ * Sensor rules only: may this rule wake a SLEEPING battery camera to take
167461
+ * the linked still it attaches (D998)? Absent means yes, an explicit `false`
167462
+ * opts out. Deliberately not a Zod default: a rule that never chose keeps
167463
+ * following the hub's. One bounded wake per event; nothing is attached later.
167464
+ */
167465
+ wakeSleepingCamera: boolean().optional()
167333
167466
  });
167334
167467
  /**
167335
167468
  * Cooldown GRANULARITY over the subject's class — how much a fired
@@ -176492,7 +176625,16 @@ var smokeCapability = {
176492
176625
  * future fields without breaking the existing call sites.
176493
176626
  */
176494
176627
  var EmailAddressSchema = email();
176628
+ /**
176629
+ * Which relay of a multi-relay provider answers (D996). Absent = the provider's first / default
176630
+ * relay, so a caller written before relays existed keeps working unchanged. A provider with one
176631
+ * fixed relay ignores it. This is the integration instance id (`int_NNNN`), NOT the addon id: the
176632
+ * collection pin `addonId` still picks the provider, `instanceId` picks the relay inside it.
176633
+ */
176634
+ var InstanceIdSchema = string().min(1).optional();
176635
+ var RelayPinSchema = object({ instanceId: InstanceIdSchema });
176495
176636
  var SendEmailInputSchema = object({
176637
+ instanceId: InstanceIdSchema,
176496
176638
  to: union([EmailAddressSchema, array(EmailAddressSchema).min(1)]),
176497
176639
  cc: array(EmailAddressSchema).optional(),
176498
176640
  bcc: array(EmailAddressSchema).optional(),
@@ -176528,11 +176670,11 @@ method(SendEmailInputSchema, SendEmailResultSchema, {
176528
176670
  kind: "mutation",
176529
176671
  auth: "admin",
176530
176672
  access: "create"
176531
- }), method(_void(), SmtpStatusSchema, {
176673
+ }), method(RelayPinSchema, SmtpStatusSchema, {
176532
176674
  kind: "mutation",
176533
176675
  auth: "admin",
176534
176676
  access: "view"
176535
- }), method(_void(), SmtpStatusSchema, { auth: "admin" });
176677
+ }), method(RelayPinSchema, SmtpStatusSchema, { auth: "admin" });
176536
176678
  /**
176537
176679
  * A single device snapshot returned as base64 JPEG/PNG.
176538
176680
  *
@@ -176600,10 +176742,12 @@ var snapshotCapability = {
176600
176742
  * even when the cache is well within the device's
176601
176743
  * `snapshotMaxAgeS` window.
176602
176744
  *
176603
- * **`force` is an OPERATOR signal, not a freshness preference.** On a
176745
+ * **`force` is a demand with a named principal, not a freshness
176746
+ * preference** — an operator button, the scheduled refresh (D847), or a
176747
+ * notification rule that left the camera's wake allowed (D998). On a
176604
176748
  * battery camera it is the one thing that walks past the wrapper's
176605
- * sleep gate and wakes the camera, so a background caller — a poller,
176606
- * an event handler, a thumbnail — must NEVER set it. Every such caller
176749
+ * sleep gate and wakes the camera, so a poller, a thumbnail or a
176750
+ * prestream must NEVER set it. Every such caller
176607
176751
  * gets the cached frame, which on a sleeping battery camera is the
176608
176752
  * correct answer: stale but honest beats woken.
176609
176753
  */
@@ -181946,7 +182090,7 @@ function withClipEventLabels(provider, deps) {
181946
182090
  listClips
181947
182091
  };
181948
182092
  }
181949
- DeviceFeature.FanSpeed, DeviceFeature.FanPreset, DeviceFeature.FanDirection, DeviceFeature.FanOscillating, DeviceFeature.CoverPositionable, DeviceFeature.CoverTilt, DeviceFeature.ValvePositionable, DeviceFeature.MediaPlayerVolume, DeviceFeature.ClimateFanMode;
182093
+ DeviceFeature.FanSpeed, DeviceFeature.FanPreset, DeviceFeature.FanDirection, DeviceFeature.FanOscillating, DeviceFeature.CoverStoppable, DeviceFeature.CoverPositionable, DeviceFeature.CoverTilt, DeviceFeature.ValvePositionable, DeviceFeature.MediaPlayerVolume, DeviceFeature.ClimateFanMode;
181950
182094
  SOURCE_CAPS.filter((cap) => cap !== "switch" && cap !== "motion");
181951
182095
  String.raw`rose\(active(\d+), changed(\d+), (\d+(?:\.\d+)?)\)`;
181952
182096
  DeviceRole.BinarySensor, DeviceRole.MotionSensor;
@@ -187144,6 +187288,69 @@ Object.freeze({
187144
187288
  access: "create",
187145
187289
  role: "admin"
187146
187290
  },
187291
+ "integrationInstances.createInstance": {
187292
+ capName: "integration-instances",
187293
+ capScope: "system",
187294
+ addonId: null,
187295
+ access: "create",
187296
+ role: "admin"
187297
+ },
187298
+ "integrationInstances.deleteInstance": {
187299
+ capName: "integration-instances",
187300
+ capScope: "system",
187301
+ addonId: null,
187302
+ access: "delete",
187303
+ role: "admin"
187304
+ },
187305
+ "integrationInstances.describe": {
187306
+ capName: "integration-instances",
187307
+ capScope: "system",
187308
+ addonId: null,
187309
+ access: "view",
187310
+ role: "admin"
187311
+ },
187312
+ "integrationInstances.getInstance": {
187313
+ capName: "integration-instances",
187314
+ capScope: "system",
187315
+ addonId: null,
187316
+ access: "view",
187317
+ role: "admin"
187318
+ },
187319
+ "integrationInstances.instanceFormSchema": {
187320
+ capName: "integration-instances",
187321
+ capScope: "system",
187322
+ addonId: null,
187323
+ access: "view",
187324
+ role: "admin"
187325
+ },
187326
+ "integrationInstances.listInstances": {
187327
+ capName: "integration-instances",
187328
+ capScope: "system",
187329
+ addonId: null,
187330
+ access: "view",
187331
+ role: "admin"
187332
+ },
187333
+ "integrationInstances.setInstanceEnabled": {
187334
+ capName: "integration-instances",
187335
+ capScope: "system",
187336
+ addonId: null,
187337
+ access: "create",
187338
+ role: "admin"
187339
+ },
187340
+ "integrationInstances.testInstance": {
187341
+ capName: "integration-instances",
187342
+ capScope: "system",
187343
+ addonId: null,
187344
+ access: "create",
187345
+ role: "admin"
187346
+ },
187347
+ "integrationInstances.updateInstance": {
187348
+ capName: "integration-instances",
187349
+ capScope: "system",
187350
+ addonId: null,
187351
+ access: "create",
187352
+ role: "admin"
187353
+ },
187147
187354
  "integrations.create": {
187148
187355
  capName: "integrations",
187149
187356
  capScope: "system",
@@ -205779,7 +205986,7 @@ var ReolinkHubSession = class {
205779
205986
  this.getApi().catch((err) => {
205780
205987
  this.#logger.warn("Reolink hub initial connect failed — will retry", {
205781
205988
  tags: { integrationId: this.integrationId },
205782
- meta: { error: errMsg$2(err) }
205989
+ meta: { error: errMsg$3(err) }
205783
205990
  });
205784
205991
  });
205785
205992
  }
@@ -205798,7 +206005,7 @@ var ReolinkHubSession = class {
205798
206005
  this.getApi().catch((err) => {
205799
206006
  this.#logger.warn("Reolink hub re-dial after settings change failed", {
205800
206007
  tags: { integrationId: this.integrationId },
205801
- meta: { error: errMsg$2(err) }
206008
+ meta: { error: errMsg$3(err) }
205802
206009
  });
205803
206010
  });
205804
206011
  }
@@ -205859,7 +206066,7 @@ var ReolinkHubSession = class {
205859
206066
  api.client.on("error", (err) => {
205860
206067
  this.#logger.warn("Reolink hub socket error — scheduling reconnect", {
205861
206068
  tags: { integrationId: this.integrationId },
205862
- meta: { error: errMsg$2(err) }
206069
+ meta: { error: errMsg$3(err) }
205863
206070
  });
205864
206071
  this.#scheduleReconnect("socket-error");
205865
206072
  });
@@ -205928,7 +206135,7 @@ var ReolinkHubSession = class {
205928
206135
  isMultifocal: d.isMultifocal === true
205929
206136
  }));
205930
206137
  } catch (err) {
205931
- lastError = errMsg$2(err);
206138
+ lastError = errMsg$3(err);
205932
206139
  this.#logger.warn("Reolink hub channel refresh failed", {
205933
206140
  tags: { integrationId: this.integrationId },
205934
206141
  meta: { error: lastError }
@@ -206002,7 +206209,7 @@ var ReolinkHubSession = class {
206002
206209
  meta: {
206003
206210
  from: repair.from,
206004
206211
  to: repair.to,
206005
- error: errMsg$2(err)
206212
+ error: errMsg$3(err)
206006
206213
  }
206007
206214
  });
206008
206215
  }
@@ -206115,7 +206322,7 @@ var ReolinkHubSession = class {
206115
206322
  } catch (err) {
206116
206323
  this.#logger.debug("Reolink hub: per-channel battery refresh failed", {
206117
206324
  tags: { integrationId: this.integrationId },
206118
- meta: { error: errMsg$2(err) }
206325
+ meta: { error: errMsg$3(err) }
206119
206326
  });
206120
206327
  }
206121
206328
  }
@@ -206163,7 +206370,7 @@ var ReolinkHubSession = class {
206163
206370
  tags: { deviceId: child.id },
206164
206371
  meta: {
206165
206372
  channel,
206166
- error: errMsg$2(err)
206373
+ error: errMsg$3(err)
206167
206374
  }
206168
206375
  });
206169
206376
  }
@@ -209189,7 +209396,7 @@ var IntercomOrchestrator = class {
209189
209396
  if (this.opts.wakeBeforeStart) try {
209190
209397
  await this.opts.wakeBeforeStart();
209191
209398
  } catch (err) {
209192
- this.opts.logger.warn("intercom: pre-wake failed", { meta: { error: errMsg$1(err) } });
209399
+ this.opts.logger.warn("intercom: pre-wake failed", { meta: { error: errMsg$2(err) } });
209193
209400
  throw err;
209194
209401
  }
209195
209402
  const talkSession = new ReolinkIntercomSession({
@@ -209204,7 +209411,7 @@ var IntercomOrchestrator = class {
209204
209411
  try {
209205
209412
  await talkSession.start();
209206
209413
  } catch (err) {
209207
- this.opts.logger.warn("intercom: talk session open failed", { meta: { error: errMsg$1(err) } });
209414
+ this.opts.logger.warn("intercom: talk session open failed", { meta: { error: errMsg$2(err) } });
209208
209415
  throw err;
209209
209416
  }
209210
209417
  const realCameraRate = talkSession.sampleRate;
@@ -209223,7 +209430,7 @@ var IntercomOrchestrator = class {
209223
209430
  } catch (err) {
209224
209431
  await talkSession.stop().catch(() => {});
209225
209432
  this.opts.logger.warn("intercom: audio-codec createDecodeSession failed", { meta: {
209226
- error: errMsg$1(err),
209433
+ error: errMsg$2(err),
209227
209434
  opusRate,
209228
209435
  opusChannels,
209229
209436
  targetRate
@@ -209245,7 +209452,7 @@ var IntercomOrchestrator = class {
209245
209452
  this.session = active;
209246
209453
  peer.onOpusFrame((frame, pts) => {
209247
209454
  this.handleOpusFrame(active, frame, pts).catch((err) => {
209248
- this.opts.logger.debug("intercom: opus frame pump error (dropped)", { meta: { error: errMsg$1(err) } });
209455
+ this.opts.logger.debug("intercom: opus frame pump error (dropped)", { meta: { error: errMsg$2(err) } });
209249
209456
  });
209250
209457
  });
209251
209458
  let offer;
@@ -209253,7 +209460,7 @@ var IntercomOrchestrator = class {
209253
209460
  offer = await peer.createOffer();
209254
209461
  } catch (err) {
209255
209462
  await this.stop(sessionId, "start-failed-cleanup").catch(() => {});
209256
- this.opts.logger.warn("intercom: webrtc createOffer failed", { meta: { error: errMsg$1(err) } });
209463
+ this.opts.logger.warn("intercom: webrtc createOffer failed", { meta: { error: errMsg$2(err) } });
209257
209464
  throw err;
209258
209465
  }
209259
209466
  this.opts.logger.info("intercom session opened", { meta: {
@@ -209302,7 +209509,7 @@ var IntercomOrchestrator = class {
209302
209509
  } catch (err) {
209303
209510
  this.opts.logger.debug("intercom: peer.close error (continuing)", { meta: {
209304
209511
  sessionId,
209305
- error: errMsg$1(err)
209512
+ error: errMsg$2(err)
209306
209513
  } });
209307
209514
  }
209308
209515
  try {
@@ -209313,7 +209520,7 @@ var IntercomOrchestrator = class {
209313
209520
  } catch (err) {
209314
209521
  this.opts.logger.debug("intercom: audio-codec.closeSession error (continuing)", { meta: {
209315
209522
  sessionId,
209316
- error: errMsg$1(err)
209523
+ error: errMsg$2(err)
209317
209524
  } });
209318
209525
  }
209319
209526
  try {
@@ -209321,7 +209528,7 @@ var IntercomOrchestrator = class {
209321
209528
  } catch (err) {
209322
209529
  this.opts.logger.debug("intercom: talk-session.stop error (continuing)", { meta: {
209323
209530
  sessionId,
209324
- error: errMsg$1(err)
209531
+ error: errMsg$2(err)
209325
209532
  } });
209326
209533
  }
209327
209534
  this.opts.logger.info("intercom session closed", { meta: {
@@ -209377,7 +209584,7 @@ var IntercomOrchestrator = class {
209377
209584
  function generateSessionId() {
209378
209585
  return `intercom-${Date.now().toString(36)}-${Math.floor(Math.random() * 16777215).toString(36)}`;
209379
209586
  }
209380
- function errMsg$1(err) {
209587
+ function errMsg$2(err) {
209381
209588
  return err instanceof Error ? err.message : String(err);
209382
209589
  }
209383
209590
  //#endregion
@@ -209436,7 +209643,7 @@ var WeriftIntercomPeer = class {
209436
209643
  for (const cb of this.opusCallbacks) try {
209437
209644
  cb(payload, ptsMs);
209438
209645
  } catch (err) {
209439
- this.opts.logger.debug("intercom-peer: opus callback threw", { meta: { error: errMsg(err) } });
209646
+ this.opts.logger.debug("intercom-peer: opus callback threw", { meta: { error: errMsg$1(err) } });
209440
209647
  }
209441
209648
  });
209442
209649
  if (rtpSub && typeof rtpSub.unsubscribe === "function") this.rtpUnsubscribe = () => rtpSub.unsubscribe?.();
@@ -209484,7 +209691,7 @@ var WeriftIntercomPeer = class {
209484
209691
  }
209485
209692
  }
209486
209693
  };
209487
- function errMsg(err) {
209694
+ function errMsg$1(err) {
209488
209695
  return err instanceof Error ? err.message : String(err);
209489
209696
  }
209490
209697
  //#endregion
@@ -210960,7 +211167,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
210960
211167
  * `true` iff this camera can run its own email/SMTP push path. Hub
210961
211168
  * children are excluded: they share the parent's TCP socket and have
210962
211169
  * no independent email config, so the NVR/Hub handles email centrally.
210963
- * Consumed by the provider-level SMTP intake (`email-push-server.ts`)
211170
+ * Consumed by the camera mailbox bridge (`email-push-bridge.ts`)
210964
211171
  * to decide which cameras accept inbound mail + can be auto-configured.
210965
211172
  */
210966
211173
  get isEmailPushEligible() {
@@ -211666,12 +211873,12 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
211666
211873
  }
211667
211874
  if (!this.emailPushParamsResolver) {
211668
211875
  this.ctx.logger.warn("email-push: resolver not wired — restart the Reolink addon", { tags: { deviceId: this.id } });
211669
- throw userError("reolink.email-push-not-wired", void 0, "Email Push Server is not wired — restart the Reolink addon");
211876
+ throw userError("reolink.email-push-not-wired", void 0, "The camera mailbox is not wired — restart the Reolink addon");
211670
211877
  }
211671
211878
  const params = await this.emailPushParamsResolver();
211672
211879
  if (!params) {
211673
- this.ctx.logger.warn("email-push: no manager params — enable the server + reachable LAN host", { tags: { deviceId: this.id } });
211674
- throw new Error("Enable the Email Push Server in the Reolink addon settings, and ensure this host has a reachable LAN address.");
211880
+ this.ctx.logger.warn("email-push: no manager params — this camera cannot use the camera mailbox", { tags: { deviceId: this.id } });
211881
+ throw new Error("This camera cannot use the camera mailbox.");
211675
211882
  }
211676
211883
  const triggers = this.config.get("emailPushTriggers") ?? [
211677
211884
  "MD",
@@ -218655,15 +218862,6 @@ function mapInferredTypeToSimpleEvents(inferred) {
218655
218862
  default: return ["motion"];
218656
218863
  }
218657
218864
  }
218658
- /** Default SMTP listen port. Avoid privileged 25; Reolink firmwares are
218659
- * happy on any port the email config points at. */
218660
- var DEFAULT_EMAIL_PUSH_PORT = 2525;
218661
- /** Virtual mail domain — only used to extract the camera id from the
218662
- * recipient (`cam-<id>@<domain>`). Never resolved via DNS. */
218663
- var DEFAULT_EMAIL_PUSH_DOMAIN = "camstack.local";
218664
- /** Reolink snapshots fit comfortably; 25 MB covers video attachments
218665
- * too without letting a runaway upload exhaust memory. */
218666
- var DEFAULT_EMAIL_PUSH_MAX_MESSAGE_BYTES = 25 * 1024 * 1024;
218667
218865
  /**
218668
218866
  * Enumerate candidate camera-facing LAN addresses, best first (private
218669
218867
  * RFC-1918 ranges ranked ahead of public). Link-local and internal
@@ -218690,15 +218888,15 @@ function listLanHosts() {
218690
218888
  }
218691
218889
  //#endregion
218692
218890
  //#region src/baichuan-webhook-server.ts
218693
- function readString$1(raw, key, fallback) {
218891
+ function readString(raw, key, fallback) {
218694
218892
  const v = raw[key];
218695
218893
  return typeof v === "string" && v.length > 0 ? v : fallback;
218696
218894
  }
218697
- function readNumber$1(raw, key, fallback) {
218895
+ function readNumber(raw, key, fallback) {
218698
218896
  const v = raw[key];
218699
218897
  return typeof v === "number" && Number.isFinite(v) ? v : fallback;
218700
218898
  }
218701
- function readBoolean$1(raw, key, fallback) {
218899
+ function readBoolean(raw, key, fallback) {
218702
218900
  const v = raw[key];
218703
218901
  return typeof v === "boolean" ? v : fallback;
218704
218902
  }
@@ -218714,9 +218912,9 @@ var ReolinkBaichuanWebhookServer = class {
218714
218912
  if (this.settings) return this.settings;
218715
218913
  const raw = await this.deps.readStore();
218716
218914
  this.settings = {
218717
- enabled: readBoolean$1(raw, "baichuanWebhookEnabled", true),
218718
- port: readNumber$1(raw, "baichuanWebhookPort", DEFAULT_BAICHUAN_WEBHOOK_PORT),
218719
- bindHost: readString$1(raw, "baichuanWebhookBindHost", "0.0.0.0")
218915
+ enabled: readBoolean(raw, "baichuanWebhookEnabled", true),
218916
+ port: readNumber(raw, "baichuanWebhookPort", DEFAULT_BAICHUAN_WEBHOOK_PORT),
218917
+ bindHost: readString(raw, "baichuanWebhookBindHost", "0.0.0.0")
218720
218918
  };
218721
218919
  return this.settings;
218722
218920
  }
@@ -219023,338 +219221,420 @@ function buildCreationFormSchema() {
219023
219221
  ] };
219024
219222
  }
219025
219223
  //#endregion
219026
- //#region src/email-push-schema.ts
219027
- /**
219028
- * The ids of the sections above, in render order — the value the addon hands
219029
- * to `BaseAddon.integrationSettingSections()`. Exported so the declaration and
219030
- * the schema cannot drift: core throws if an id here names no section.
219031
- */
219032
- var EMAIL_PUSH_SECTION_IDS = ["email-push", "email-push-auth"];
219033
- /**
219034
- * Addon-level settings UI for the Reolink email/SMTP push intake.
219035
- *
219036
- * Rendered under the provider's addon settings (Cluster → addon config).
219037
- * Edits land in the addon store via `updateGlobalSettings`; the provider
219038
- * restarts the SMTP server on change. The recommended camera-facing host
219039
- * is rendered read-only so the operator knows what to point cameras at if
219040
- * they configure email manually instead of using Auto-configure.
219041
- *
219042
- * `requiresRestart` is intentionally NOT set — the provider handles the
219043
- * restart itself in `onConfigChanged` (a targeted SMTP rebind, not a full
219044
- * addon teardown).
219045
- *
219046
- * ── These sections ARE the Reolink integration's configuration ──────────
219047
- *
219048
- * Both are declared in `ReolinkAddon.integrationSettingSections()`, so core
219049
- * serves them on System → Integrations → Reolink. The operator looks for "the
219050
- * Reolink SMTP port" under Reolink; this puts it there.
219051
- *
219052
- * They EARN that placement, and the reason is the test any future declaration
219053
- * must pass: they configure ONE SMTP server in ONE provider process, serving
219054
- * every Reolink camera at once. There is no per-camera copy to confuse them
219055
- * with, which is exactly why a per-camera home would be nonsense and a
219056
- * per-integration home is correct. A setting that differs per camera belongs
219057
- * in `deviceSettingsSchema`, never here.
219058
- *
219059
- * The declaration does not move the values: they remain the same bare keys in
219060
- * the same addon store, still reachable from System → Settings, still written
219061
- * by `updateGlobalSettings` — there is no integration-specific writer at all.
219062
- * See ADR-0269.
219063
- *
219064
- * `tab` is deliberately NOT used to express this. It means "how to group this
219065
- * visually", and an earlier design that overloaded it put the literal word
219066
- * "integration" into an operator-facing tab bar (D268 → D269).
219067
- */
219068
- function buildEmailPushSettingsSchema(recommendedHost) {
219069
- return { sections: [{
219070
- id: "email-push",
219071
- title: "Battery-cam Motion (Email/SMTP Push)",
219072
- titleKey: "provider-reolink:emailPushSettings.s_email-push.title",
219073
- description: "Lets battery cameras report motion by email without being woken.",
219074
- descriptionKey: "provider-reolink:emailPushSettings.s_email-push.description",
219075
- columns: 2,
219076
- fields: [
219077
- {
219078
- type: "boolean",
219079
- key: "emailPushEnabled",
219080
- label: "Enable Email Push Server",
219081
- labelKey: "provider-reolink:emailPushSettings.s_email-push.emailPushEnabled.label",
219082
- default: true
219083
- },
219084
- {
219085
- type: "info",
219086
- key: "emailPushRecommendedHost",
219087
- label: "Camera-facing host",
219088
- labelKey: "provider-reolink:emailPushSettings.s_email-push.emailPushRecommendedHost.label",
219089
- content: `Point cameras at this host: ${recommendedHost}`
219090
- },
219091
- {
219092
- type: "number",
219093
- key: "emailPushPort",
219094
- label: "SMTP Port",
219095
- labelKey: "provider-reolink:emailPushSettings.s_email-push.emailPushPort.label",
219096
- default: DEFAULT_EMAIL_PUSH_PORT,
219097
- min: 1,
219098
- max: 65535
219099
- },
219100
- {
219101
- type: "text",
219102
- key: "emailPushDomain",
219103
- label: "Virtual Domain",
219104
- labelKey: "provider-reolink:emailPushSettings.s_email-push.emailPushDomain.label",
219105
- default: DEFAULT_EMAIL_PUSH_DOMAIN,
219106
- placeholder: DEFAULT_EMAIL_PUSH_DOMAIN
219107
- }
219108
- ]
219109
- }, {
219110
- id: "email-push-auth",
219111
- title: "Email Push Authentication",
219112
- titleKey: "provider-reolink:emailPushSettings.s_email-push-auth.title",
219113
- description: "Random values are generated on first boot; re-run Auto-configure on cameras after changing them.",
219114
- descriptionKey: "provider-reolink:emailPushSettings.s_email-push-auth.description",
219115
- columns: 2,
219116
- fields: [
219117
- {
219118
- type: "boolean",
219119
- key: "emailPushRequireAuth",
219120
- label: "Require AUTH",
219121
- labelKey: "provider-reolink:emailPushSettings.s_email-push-auth.emailPushRequireAuth.label",
219122
- default: true
219123
- },
219124
- {
219125
- type: "text",
219126
- key: "emailPushAuthUsername",
219127
- label: "Auth Username",
219128
- labelKey: "provider-reolink:emailPushSettings.s_email-push-auth.emailPushAuthUsername.label"
219224
+ //#region src/email-push-bridge.ts
219225
+ /**
219226
+ * The Reolink side of the `smtp-inbound` broker kind (D999).
219227
+ *
219228
+ * Battery cameras report motion by mail. The mailbox they mail is a `broker` of kind
219229
+ * `smtp-inbound` owned by `smtp-nodemailer`; this bridge
219230
+ * - creates it on first need (`ensureMailbox`: one in-process promise, adopt-id, never at a bare boot),
219231
+ * - declares the `cam-<stableId>` recipients of the standalone cameras (`sync`),
219232
+ * - turns each `broker.message` into the camera's `handleSimpleEvent({ source: 'email' })`,
219233
+ * - replays what the bus lost from the broker's ring (seq gap, a broker that came back, subscribe).
219234
+ * It never deletes the mailbox: the operator owns it, and the bridge recreates it at the next need.
219235
+ * Events are telemetry (D8); the ring is the RPC reconcile.
219236
+ */
219237
+ /** The addon that owns `smtp-inbound` mailboxes: routes the id-less calls (`add`). */
219238
+ var SMTP_BROKER_ADDON_ID = "smtp-nodemailer";
219239
+ /** Who this addon is, among the subscribers of a mailbox. */
219240
+ var REOLINK_SUBSCRIBER_ID = "provider-reolink";
219241
+ var MAILBOX_NAME = "Camera mailbox";
219242
+ var RECIPIENT_PREFIX = "cam-";
219243
+ /** The real port: `ctx.api.broker`, every call routed to the addon that owns the kind. */
219244
+ function createBrokerPort(api) {
219245
+ const route = { addonId: SMTP_BROKER_ADDON_ID };
219246
+ return {
219247
+ list: async () => api.broker.list.query({ kind: SMTP_INBOUND_KIND }),
219248
+ add: async ({ id, name, settings }) => api.broker.add.mutate({
219249
+ kind: SMTP_INBOUND_KIND,
219250
+ name,
219251
+ id,
219252
+ settings: { ...settings },
219253
+ ...route
219254
+ }),
219255
+ getBrokerConfig: async (id) => api.broker.getBrokerConfig.query({
219256
+ id,
219257
+ ...route
219258
+ }),
219259
+ subscribe: async (brokerId, recipients) => api.broker.subscribe.mutate({
219260
+ brokerId,
219261
+ filter: {
219262
+ recipients: [...recipients],
219263
+ subscriberId: REOLINK_SUBSCRIBER_ID
219129
219264
  },
219130
- {
219131
- type: "password",
219132
- key: "emailPushAuthPassword",
219133
- label: "Auth Password",
219134
- labelKey: "provider-reolink:emailPushSettings.s_email-push-auth.emailPushAuthPassword.label",
219135
- showToggle: true
219136
- }
219137
- ]
219138
- }] };
219139
- }
219140
- //#endregion
219141
- //#region src/email-push-server.ts
219142
- /** Reads a typed value from the raw addon store with a fallback. */
219143
- function readString(raw, key, fallback) {
219144
- const v = raw[key];
219145
- return typeof v === "string" && v.length > 0 ? v : fallback;
219265
+ ...route
219266
+ }),
219267
+ unsubscribe: async (brokerId, subscriptionId) => api.broker.unsubscribe.mutate({
219268
+ brokerId,
219269
+ subscriptionId,
219270
+ ...route
219271
+ }),
219272
+ getState: async (brokerId, key) => api.broker.getState.query({
219273
+ brokerId,
219274
+ key,
219275
+ ...route
219276
+ })
219277
+ };
219146
219278
  }
219147
- function readNumber(raw, key, fallback) {
219148
- const v = raw[key];
219149
- return typeof v === "number" && Number.isFinite(v) ? v : fallback;
219279
+ var BrokerMessageEventSchema = object({
219280
+ brokerId: string(),
219281
+ subscriptionId: string(),
219282
+ key: string(),
219283
+ payload: unknown()
219284
+ });
219285
+ var ReadyStateEventSchema = object({
219286
+ capName: string(),
219287
+ state: string()
219288
+ });
219289
+ var BrokerStatusEventSchema = object({
219290
+ brokerId: string(),
219291
+ status: string(),
219292
+ error: string().optional()
219293
+ });
219294
+ function errMsg(err) {
219295
+ return err instanceof Error ? err.message : String(err);
219150
219296
  }
219151
- function readBoolean(raw, key, fallback) {
219152
- const v = raw[key];
219153
- return typeof v === "boolean" ? v : fallback;
219297
+ function isInUse(err) {
219298
+ return /already in use/i.test(errMsg(err));
219154
219299
  }
219155
- /**
219156
- * Owns the lib's SMTP intake for the Reolink addon. Lifecycle:
219157
- * - `start()` from the provider's `onInitialize` (no-op when disabled).
219158
- * - `restart()` from `onConfigChanged` so port/domain/auth edits apply.
219159
- * - `stop()` from the provider's `onShutdown`.
219160
- */
219161
- var ReolinkEmailPushServer = class {
219300
+ var EmailPushBridge = class {
219162
219301
  deps;
219163
- instance;
219164
- busOff;
219165
- settings;
219302
+ mailbox = null;
219303
+ brokerId = null;
219304
+ subscriptionId = null;
219305
+ /** The recipient set the broker holds for this addon, as a key. */
219306
+ declared = null;
219307
+ lastSeq = 0;
219308
+ /** The seq baseline of the current mailbox was taken (before the first subscribe). */
219309
+ seeded = false;
219310
+ /** The last failure that was reported, so a mailbox provider that stays away is said once. */
219311
+ lastFailure = null;
219312
+ chain = Promise.resolve();
219166
219313
  constructor(deps) {
219167
219314
  this.deps = deps;
219168
219315
  }
219169
- /**
219170
- * Hydrate the persisted settings, bootstrapping random AUTH
219171
- * credentials on first use so the server is never an open relay.
219172
- * Cached after the first resolution; `reloadSettings` forces a re-read.
219173
- */
219174
- async resolveSettings() {
219175
- if (this.settings) return this.settings;
219176
- const raw = await this.deps.readStore();
219177
- let authUsername = readString(raw, "emailPushAuthUsername", "");
219178
- let authPassword = readString(raw, "emailPushAuthPassword", "");
219179
- const passwordUnresolved = isSecretMarkerLike(raw["emailPushAuthPassword"]);
219180
- const bootstrapped = {};
219181
- if (!authUsername) {
219182
- authUsername = `camstack-${randomBytes(4).toString("hex")}`;
219183
- bootstrapped.emailPushAuthUsername = authUsername;
219184
- }
219185
- if (!authPassword) {
219186
- authPassword = randomBytes(18).toString("base64url");
219187
- if (passwordUnresolved) this.deps.logger.error("email-push: the SMTP password is in the keychain but could not be read — running on a temporary password that is NOT saved; camera mail will fail AUTH until it resolves");
219188
- else bootstrapped.emailPushAuthPassword = authPassword;
219189
- }
219190
- if (Object.keys(bootstrapped).length > 0) try {
219191
- await this.deps.writeStore(bootstrapped);
219316
+ /** The id of the mailbox, creating it when none exists. One in-flight promise. */
219317
+ ensureMailbox() {
219318
+ this.mailbox ??= this.findOrCreate().catch((err) => {
219319
+ this.mailbox = null;
219320
+ throw err;
219321
+ });
219322
+ return this.mailbox;
219323
+ }
219324
+ async findOrCreate() {
219325
+ const found = pickMailbox(await this.deps.port.list());
219326
+ if (found !== null) {
219327
+ await this.deps.pending?.clear();
219328
+ return found;
219329
+ }
219330
+ const pending = await this.deps.pending?.read() ?? null;
219331
+ try {
219332
+ const created = await this.deps.port.add({
219333
+ id: SMTP_INBOUND_DEFAULT_ID,
219334
+ name: MAILBOX_NAME,
219335
+ ...pending === null ? {} : { settings: pending }
219336
+ });
219337
+ await this.deps.pending?.clear();
219338
+ this.deps.logger.info("email-push: created the camera mailbox", { meta: {
219339
+ brokerId: created.id,
219340
+ carriedLegacySettings: pending !== null
219341
+ } });
219342
+ return created.id;
219192
219343
  } catch (err) {
219193
- this.deps.logger.warn("email-push: failed to persist bootstrapped credentials", { meta: { error: err instanceof Error ? err.message : String(err) } });
219344
+ if (!isInUse(err)) throw err;
219345
+ const again = pickMailbox(await this.deps.port.list());
219346
+ if (again === null) throw err;
219347
+ await this.deps.pending?.clear();
219348
+ return again;
219194
219349
  }
219195
- this.settings = {
219196
- enabled: readBoolean(raw, "emailPushEnabled", true),
219197
- port: readNumber(raw, "emailPushPort", DEFAULT_EMAIL_PUSH_PORT),
219198
- bindHost: readString(raw, "emailPushBindHost", "0.0.0.0"),
219199
- domain: readString(raw, "emailPushDomain", DEFAULT_EMAIL_PUSH_DOMAIN),
219200
- requireAuth: readBoolean(raw, "emailPushRequireAuth", true),
219201
- authUsername,
219202
- authPassword,
219203
- maxMessageBytes: readNumber(raw, "emailPushMaxMessageBytes", DEFAULT_EMAIL_PUSH_MAX_MESSAGE_BYTES)
219204
- };
219205
- return this.settings;
219206
219350
  }
219207
- /** Drop the cached settings so the next start/restart re-reads the
219208
- * store (used by `restart`). */
219209
- reloadSettings() {
219210
- this.settings = void 0;
219211
- }
219212
- buildConfig(settings) {
219213
- return {
219214
- port: settings.port,
219215
- bindHost: settings.bindHost,
219216
- domain: settings.domain,
219217
- requireAuth: settings.requireAuth,
219218
- authUsername: settings.authUsername,
219219
- authPassword: settings.authPassword,
219220
- tls: false,
219221
- maxMessageBytes: settings.maxMessageBytes
219222
- };
219223
- }
219224
- /**
219225
- * Resolve a recipient local-part (`<id>`, already stripped of the
219226
- * `cam-` prefix by the lib) to a camera id. Returns the matching
219227
- * camera's `stableId` (which the lib echoes back as `event.cameraId`)
219228
- * or `undefined` to reject the recipient at RCPT TO.
219229
- *
219230
- * Hub-attached children are skipped: they share the parent's TCP
219231
- * socket and have no independent email path, so accepting one would
219232
- * silently mis-route. Match is on `stableId` (durable across
219233
- * re-adoption) — the same value `buildCameraSetupParams` writes into
219234
- * the camera's email config.
219235
- */
219236
- resolveCameraId(localPart) {
219237
- const cam = this.findCameraByStableId(localPart);
219238
- if (!cam || !cam.isEmailPushEligible) return void 0;
219239
- return localPart;
219351
+ recipients() {
219352
+ return this.deps.listCameras().filter((cam) => cam.isEmailPushEligible).map((cam) => `${RECIPIENT_PREFIX}${cam.stableId}`).sort();
219240
219353
  }
219241
- findCameraByStableId(stableId) {
219242
- return this.deps.listCameras().find((c) => c.stableId === stableId);
219354
+ enqueue(job) {
219355
+ const run = this.chain.then(job);
219356
+ this.chain = run.then(() => void 0, () => void 0);
219357
+ return run;
219243
219358
  }
219244
- /** Route an accepted email-push event into the matched camera's unified
219245
- * simple-event handler. */
219246
- dispatchEvent(event) {
219247
- const cam = this.findCameraByStableId(event.cameraId);
219248
- if (!cam) {
219249
- this.deps.logger.warn("email-push: event for unknown camera id — dropping", { meta: {
219250
- cameraId: event.cameraId,
219251
- inferredType: event.inferredType
219252
- } });
219253
- return;
219254
- }
219255
- this.deps.logger.info("email-push: motion received from battery camera", {
219256
- tags: { deviceId: cam.id },
219257
- meta: {
219258
- inferredType: event.inferredType,
219259
- subject: event.subject.slice(0, 80)
219359
+ /**
219360
+ * Declare the recipients of the eligible cameras (again). Cheap when nothing changed.
219361
+ * A failure is logged, never thrown: a camera that cannot be declared does not stop the addon.
219362
+ */
219363
+ sync(options = {}) {
219364
+ return this.enqueue(async () => {
219365
+ try {
219366
+ await this.doSync(options.force === true);
219367
+ this.lastFailure = null;
219368
+ } catch (err) {
219369
+ this.declared = null;
219370
+ this.subscriptionId = null;
219371
+ const message = errMsg(err);
219372
+ if (message !== this.lastFailure) {
219373
+ this.lastFailure = message;
219374
+ this.deps.logger.warn("email-push: the camera mailbox is not available; battery cameras cannot mail motion until it is", { meta: { error: message } });
219375
+ }
219260
219376
  }
219261
219377
  });
219262
- for (const type of mapInferredTypeToSimpleEvents(event.inferredType)) cam.handleSimpleEvent({
219263
- type,
219264
- channel: cam.emailPushChannel,
219265
- timestamp: event.receivedAtMs,
219266
- source: "email"
219267
- });
219268
219378
  }
219269
- /** Start the SMTP intake when enabled. Idempotent: a second call while
219270
- * running re-applies config without rebinding. */
219271
- async start() {
219272
- const settings = await this.resolveSettings();
219273
- if (!settings.enabled) {
219274
- this.deps.logger.debug("email-push: disabled — SMTP intake not started");
219379
+ async doSync(force) {
219380
+ const recipients = this.recipients();
219381
+ if (recipients.length === 0) {
219382
+ if (this.brokerId !== null && this.subscriptionId !== null) await this.deps.port.unsubscribe(this.brokerId, this.subscriptionId);
219383
+ this.subscriptionId = null;
219384
+ this.declared = null;
219275
219385
  return;
219276
219386
  }
219277
- if (this.instance) {
219278
- this.instance.updateConfig(this.buildConfig(settings));
219387
+ const key = recipients.join(",");
219388
+ if (!force && this.declared === key && this.subscriptionId !== null) return;
219389
+ const brokerId = await this.ensureMailbox();
219390
+ await this.seedBaseline(brokerId);
219391
+ const { subscriptionId } = await this.deps.port.subscribe(brokerId, recipients);
219392
+ this.brokerId = brokerId;
219393
+ this.subscriptionId = subscriptionId;
219394
+ this.declared = key;
219395
+ await this.reconcile();
219396
+ }
219397
+ /**
219398
+ * What the mailbox retains from BEFORE this subscription is old news (a restart of this addon
219399
+ * must not re-fire a quarter hour of alarms). Nothing for our recipients is accepted before the
219400
+ * subscribe, so the mailbox's seq read now is an exact baseline: only what arrives after it is
219401
+ * processed, and a seq that went back means the mailbox restarted.
219402
+ */
219403
+ async seedBaseline(brokerId) {
219404
+ const since = await this.readSince(brokerId, 0);
219405
+ if (since === null) {
219406
+ if (!this.seeded || this.brokerId !== brokerId) throw new Error("the camera mailbox state could not be read");
219279
219407
  return;
219280
219408
  }
219281
- this.instance = createEmailPushServer({
219282
- config: this.buildConfig(settings),
219283
- cameraResolver: (local) => this.resolveCameraId(local),
219284
- logger: {
219285
- debug: (m) => this.deps.logger.debug(`[smtp] ${m}`),
219286
- info: (m) => this.deps.logger.info(`[smtp] ${m}`),
219287
- warn: (m) => this.deps.logger.warn(`[smtp] ${m}`),
219288
- error: (m) => this.deps.logger.error(`[smtp] ${m}`)
219409
+ if (!this.seeded || this.brokerId !== brokerId || since.lastSeq < this.lastSeq) {
219410
+ this.lastSeq = since.lastSeq;
219411
+ this.seeded = true;
219412
+ }
219413
+ }
219414
+ /** A `broker.message` event off the bus. */
219415
+ onMessage(raw) {
219416
+ return this.enqueue(async () => {
219417
+ const event = BrokerMessageEventSchema.safeParse(raw);
219418
+ if (!event.success) return;
219419
+ if (this.brokerId === null) {
219420
+ this.deps.logger.warn("email-push: a mailbox message arrived before the mailbox was known — dropped", { meta: {
219421
+ brokerId: event.data.brokerId,
219422
+ key: event.data.key
219423
+ } });
219424
+ return;
219425
+ }
219426
+ if (event.data.brokerId !== this.brokerId) return;
219427
+ const message = SmtpInboundMessageSchema.safeParse(event.data.payload);
219428
+ if (!message.success) {
219429
+ this.deps.logger.warn("email-push: a mailbox message did not parse — dropped", { meta: { key: event.data.key } });
219430
+ return;
219431
+ }
219432
+ if (message.data.seq <= this.lastSeq) return;
219433
+ if (message.data.seq > this.lastSeq + 1) {
219434
+ await this.reconcile();
219435
+ return;
219289
219436
  }
219437
+ this.process(event.data.key, message.data);
219290
219438
  });
219291
- this.busOff = onEmailPushEvent((event) => this.dispatchEvent(event));
219292
- try {
219293
- await this.instance.start();
219294
- this.deps.logger.info("email-push: SMTP intake listening", { meta: {
219295
- host: settings.bindHost,
219296
- port: settings.port,
219297
- domain: settings.domain,
219298
- requireAuth: settings.requireAuth
219439
+ }
219440
+ /** A `broker.status-changed` event: a mailbox that came back has forgotten its subscriptions. */
219441
+ onStatusChanged(raw) {
219442
+ const event = BrokerStatusEventSchema.safeParse(raw);
219443
+ if (!event.success || event.data.brokerId !== this.brokerId) return Promise.resolve();
219444
+ if (event.data.status !== "connected") {
219445
+ this.deps.logger.warn("email-push: the camera mailbox is not connected", { meta: {
219446
+ status: event.data.status,
219447
+ error: event.data.error
219299
219448
  } });
219300
- } catch (err) {
219301
- this.deps.logger.error("email-push: SMTP intake failed to start", { meta: { error: err instanceof Error ? err.message : String(err) } });
219302
- this.busOff?.();
219303
- this.busOff = void 0;
219304
- this.instance = void 0;
219449
+ return Promise.resolve();
219305
219450
  }
219451
+ return this.sync({ force: true });
219306
219452
  }
219307
- /** Re-read settings and rebind. Used after a settings edit. */
219308
- async restart() {
219309
- await this.stop();
219310
- this.reloadSettings();
219311
- await this.start();
219453
+ /**
219454
+ * The mailbox provider (any `broker` provider) became ready: the subscription it may have lost
219455
+ * is declared again, and a mailbox that never existed is created if a camera needs one.
219456
+ */
219457
+ onProviderReady(raw) {
219458
+ const event = ReadyStateEventSchema.safeParse(raw);
219459
+ if (!event.success || event.data.capName !== "broker" || event.data.state !== "ready") return Promise.resolve();
219460
+ return this.sync({ force: true });
219312
219461
  }
219313
- /** Stop the SMTP intake + detach the bus subscription. Safe to call
219314
- * when never started. */
219315
- async stop() {
219316
- this.busOff?.();
219317
- this.busOff = void 0;
219318
- if (this.instance) {
219319
- await this.instance.stop().catch(() => {});
219320
- this.instance = void 0;
219462
+ /** Replay what the ring holds after the last seq we processed. */
219463
+ async reconcile() {
219464
+ const id = this.brokerId;
219465
+ if (id === null) return;
219466
+ let since = await this.readSince(id, this.lastSeq);
219467
+ if (since !== null && since.lastSeq < this.lastSeq) {
219468
+ this.lastSeq = 0;
219469
+ since = await this.readSince(id, 0);
219470
+ }
219471
+ if (since === null) return;
219472
+ for (const entry of since.messages) this.process(entry.key, entry.message);
219473
+ }
219474
+ async readSince(id, seq) {
219475
+ try {
219476
+ const raw = await this.deps.port.getState(id, smtpSinceKey(seq));
219477
+ const parsed = SmtpInboundSinceSchema.safeParse(raw);
219478
+ if (parsed.success) return parsed.data;
219479
+ this.deps.logger.warn("email-push: the mailbox replay did not parse", { meta: { brokerId: id } });
219480
+ } catch (err) {
219481
+ this.deps.logger.warn("email-push: the mailbox replay could not be read", { meta: {
219482
+ brokerId: id,
219483
+ error: errMsg(err)
219484
+ } });
219321
219485
  }
219486
+ return null;
219487
+ }
219488
+ process(key, message) {
219489
+ if (message.seq <= this.lastSeq) return;
219490
+ this.lastSeq = message.seq;
219491
+ const cam = this.deps.listCameras().find((c) => `${RECIPIENT_PREFIX}${c.stableId}` === key);
219492
+ if (cam === void 0) {
219493
+ this.deps.logger.warn("email-push: mail for an unknown camera — dropping", { meta: {
219494
+ key,
219495
+ seq: message.seq
219496
+ } });
219497
+ return;
219498
+ }
219499
+ const inferred = classifyEmailPushMessage({
219500
+ subject: message.subject,
219501
+ text: message.text
219502
+ });
219503
+ this.deps.logger.info("email-push: motion received from battery camera", {
219504
+ tags: { deviceId: cam.id },
219505
+ meta: {
219506
+ inferredType: inferred,
219507
+ subject: message.subject.slice(0, 80),
219508
+ seq: message.seq
219509
+ }
219510
+ });
219511
+ for (const type of mapInferredTypeToSimpleEvents(inferred)) cam.handleSimpleEvent({
219512
+ type,
219513
+ channel: cam.emailPushChannel,
219514
+ timestamp: message.receivedAt,
219515
+ source: "email"
219516
+ });
219322
219517
  }
219323
219518
  /**
219324
- * Compute the manager-side parameters a given camera needs to send
219325
- * email-push to this server. Returns `null` when the server has no
219326
- * resolvable LAN address (no camera-reachable host), or the camera is
219327
- * hub-attached / not eligible. The caller passes these straight into
219328
- * `api.setupEmailPushToManager`.
219519
+ * What a camera needs to mail the mailbox. `null` for a camera that cannot do email push.
219520
+ * Throws the reason when the hub has no internal address: there is no fallback host.
219329
219521
  */
219330
219522
  async buildCameraSetupParams(cam) {
219331
219523
  if (!cam.isEmailPushEligible) return null;
219332
- const settings = await this.resolveSettings();
219333
- const managerHost = listLanHosts()[0];
219334
- if (!managerHost) return null;
219524
+ const brokerId = await this.ensureMailbox();
219525
+ const parsed = SmtpInboundConfigSchema.safeParse(await this.deps.port.getBrokerConfig(brokerId));
219526
+ if (!parsed.success) throw new Error("The camera mailbox answered a configuration this build cannot read");
219527
+ const config = parsed.data;
219528
+ if (config.host === null) throw new Error(config.hostReason ?? "The hub has no internal address (System → Network)");
219529
+ await this.sync();
219335
219530
  return {
219336
- managerHost,
219337
- managerPort: settings.port,
219338
- domain: settings.domain,
219339
- recipientLocalPart: `cam-${cam.stableId}`,
219340
- ...settings.requireAuth ? {
219341
- authUsername: settings.authUsername,
219342
- authPassword: settings.authPassword
219531
+ managerHost: config.host,
219532
+ managerPort: config.port,
219533
+ domain: config.domain,
219534
+ recipientLocalPart: `${RECIPIENT_PREFIX}${cam.stableId}`,
219535
+ ...config.requireAuth ? {
219536
+ authUsername: config.username,
219537
+ authPassword: config.password
219343
219538
  } : {}
219344
219539
  };
219345
219540
  }
219346
219541
  };
219542
+ function pickMailbox(list) {
219543
+ return list.find((b) => b.id === "smtp-inbound-default")?.id ?? list[0]?.id ?? null;
219544
+ }
219347
219545
  //#endregion
219348
- //#region src/email-push-store.ts
219349
- async function readEmailPushStore(deps) {
219350
- const raw = await deps.readRaw();
219351
- const password = await deps.readPassword();
219352
- return password.length > 0 ? {
219353
- ...raw,
219354
- emailPushAuthPassword: password
219355
- } : raw;
219546
+ //#region src/email-push-removal-hook.ts
219547
+ /**
219548
+ * A removed camera leaves its `cam-<stableId>` recipient in the mailbox (D865). The addon holds
219549
+ * no per-device DATA for the intake (the mailbox is owned by the mail addon), so removal only
219550
+ * redeclares the recipient set without the camera. Driven by the tombstone, never by absence.
219551
+ */
219552
+ function createEmailPushRemovalHook(deps) {
219553
+ return {
219554
+ onDeviceRemoved: async () => {
219555
+ await deps.resync();
219556
+ return { counts: { "mailbox-recipients": 0 } };
219557
+ },
219558
+ listDeviceData: async () => []
219559
+ };
219356
219560
  }
219357
219561
  //#endregion
219562
+ //#region src/migrations/email-push-to-mailbox.ts
219563
+ /** The pending mailbox as kept in the addon store: everything but the password (a keychain secret). */
219564
+ var PendingMailboxSchema = object({
219565
+ port: number().int(),
219566
+ bindHost: string().optional(),
219567
+ domain: string(),
219568
+ requireAuth: boolean(),
219569
+ username: string()
219570
+ });
219571
+ /** The keys an install that ever used the intake has; any one of them means "carry it over". */
219572
+ var LEGACY_KEYS = [
219573
+ "emailPushPort",
219574
+ "emailPushDomain",
219575
+ "emailPushRequireAuth",
219576
+ "emailPushAuthUsername",
219577
+ "emailPushAuthPassword",
219578
+ "emailPushBindHost"
219579
+ ];
219580
+ var DEFAULT_PORT = 2525;
219581
+ var DEFAULT_DOMAIN = "camstack.local";
219582
+ function text(stored, key) {
219583
+ const value = stored[key];
219584
+ return typeof value === "string" && value.length > 0 ? value : null;
219585
+ }
219586
+ function portOf(stored, ports) {
219587
+ const value = stored["emailPushPort"];
219588
+ const numeric = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
219589
+ if (Number.isInteger(numeric) && numeric >= 1 && numeric <= 65535) return numeric;
219590
+ if (value !== void 0) ports.warn(`email-push migration: the legacy port is not usable; using ${DEFAULT_PORT}`);
219591
+ return DEFAULT_PORT;
219592
+ }
219593
+ async function moveEmailPushToMailbox(ports) {
219594
+ const stored = await ports.readStored();
219595
+ if (!LEGACY_KEYS.some((key) => stored[key] !== void 0)) return {
219596
+ outcome: "already-done",
219597
+ summary: { moved: false }
219598
+ };
219599
+ if (stored["emailPushEnabled"] === false) return {
219600
+ outcome: "already-done",
219601
+ summary: {
219602
+ moved: false,
219603
+ disabled: true
219604
+ }
219605
+ };
219606
+ const bindHost = text(stored, "emailPushBindHost");
219607
+ await ports.stashPending({
219608
+ port: portOf(stored, ports),
219609
+ ...bindHost === null ? {} : { bindHost },
219610
+ domain: text(stored, "emailPushDomain") ?? DEFAULT_DOMAIN,
219611
+ requireAuth: stored["emailPushRequireAuth"] !== false,
219612
+ username: text(stored, "emailPushAuthUsername") ?? "",
219613
+ password: text(stored, "emailPushAuthPassword") ?? ""
219614
+ });
219615
+ return {
219616
+ outcome: "done",
219617
+ summary: { moved: true }
219618
+ };
219619
+ }
219620
+ /** The `provider-reolink` catalogue (D987): offered to the engine when the addon initialises. */
219621
+ var REOLINK_MIGRATIONS = [{
219622
+ meta: {
219623
+ id: "provider-reolink/2026-11-02-email-push-to-mailbox",
219624
+ owner: "provider-reolink",
219625
+ level: "addon-state",
219626
+ seq: 1,
219627
+ dependsOn: [],
219628
+ store: "settings",
219629
+ scope: "cluster",
219630
+ onFailure: "tolerate",
219631
+ bodyKind: "procedure",
219632
+ description: "Keep the SMTP intake settings (port, domain, credentials) as the pending camera mailbox, created by the bridge when the smtp-inbound broker is reachable, so no camera is reconfigured",
219633
+ fixtureMark: "a setting key the migration does not know"
219634
+ },
219635
+ procedure: moveEmailPushToMailbox
219636
+ }];
219637
+ //#endregion
219358
219638
  //#region src/reolink-discovery-map.ts
219359
219639
  /**
219360
219640
  * Flatten a host (IP / hostname) into a flat row-key slug — shared by `generateStableId` and the
@@ -219574,7 +219854,7 @@ function buildReolinkAdoptionProvider(deps) {
219574
219854
  lastDiscoveryAt: null,
219575
219855
  candidateCount: 0,
219576
219856
  adoptedCount: 0,
219577
- lastError: errMsg$2(err)
219857
+ lastError: errMsg$3(err)
219578
219858
  };
219579
219859
  }
219580
219860
  },
@@ -219635,7 +219915,7 @@ function buildReolinkAdoptionProvider(deps) {
219635
219915
  logger.warn("reolink adopt: failed to adopt channel", { meta: {
219636
219916
  childNativeId,
219637
219917
  integrationId,
219638
- error: errMsg$2(err)
219918
+ error: errMsg$3(err)
219639
219919
  } });
219640
219920
  failures++;
219641
219921
  }
@@ -219815,7 +220095,7 @@ async function planHubMigration(saved, ports) {
219815
220095
  try {
219816
220096
  integrations = await ports.listIntegrations();
219817
220097
  } catch (err) {
219818
- ports.logger.warn("Reolink hub migration: integrations unreadable — hubs left in place", { meta: { error: errMsg$2(err) } });
220098
+ ports.logger.warn("Reolink hub migration: integrations unreadable — hubs left in place", { meta: { error: errMsg$3(err) } });
219819
220099
  return {
219820
220100
  rows: saved.filter((row) => row.type !== DeviceType.Hub),
219821
220101
  retirements: []
@@ -219842,7 +220122,7 @@ async function planHubMigration(saved, ports) {
219842
220122
  ports.logger.warn("Reolink hub migration failed — the hub device is left in place", {
219843
220123
  tags: { deviceId: hub.id },
219844
220124
  meta: {
219845
- error: errMsg$2(err),
220125
+ error: errMsg$3(err),
219846
220126
  name: hub.name
219847
220127
  }
219848
220128
  });
@@ -219880,7 +220160,7 @@ async function retireMigratedHubs(retirements, ports) {
219880
220160
  } catch (err) {
219881
220161
  ports.logger.warn("Reolink hub retirement failed — retried next boot", {
219882
220162
  tags: { deviceId: r.hubDeviceId },
219883
- meta: { error: errMsg$2(err) }
220163
+ meta: { error: errMsg$3(err) }
219884
220164
  });
219885
220165
  }
219886
220166
  return retired;
@@ -220126,7 +220406,7 @@ async function enrichHostIdentifiersFromLiveApi(detection, logger) {
220126
220406
  }
220127
220407
  var ReolinkProviderAddon = class extends BaseDeviceProvider {
220128
220408
  /** The email-push SMTP password is hidden state — kept in the keychain (D787). */
220129
- secretStatePaths = ["emailPushAuthPassword"];
220409
+ secretStatePaths = ["emailPushAuthPassword", "mailboxPendingPassword"];
220130
220410
  addonId = REOLINK_ADDON_ID;
220131
220411
  providerName = "Reolink";
220132
220412
  deviceClasses = {
@@ -220140,14 +220420,13 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
220140
220420
  * so a Test click and the subsequent Save share one Baichuan login.
220141
220421
  */
220142
220422
  autodetectCache = new AutodetectCache();
220423
+ hubManager = null;
220143
220424
  /**
220144
- * Provider-level singleton SMTP intake for battery-cam motion push
220145
- * (`email-push-server.ts`). Lazily constructed in `onInitialize` once
220146
- * the addon context is available; started/stopped with the provider
220147
- * lifecycle and restarted on settings change.
220425
+ * The camera mailbox bridge (`email-push-bridge.ts`, D999): battery cameras mail motion to a
220426
+ * mailbox of the `smtp-inbound` broker, the bridge turns each message into the camera's
220427
+ * simple event. Built in `onInitialize`, once the addon context exists.
220148
220428
  */
220149
- hubManager = null;
220150
- emailPushServer;
220429
+ emailBridge;
220151
220430
  baichuanWebhookServer;
220152
220431
  /** The `log-channels` provider for this process. Stopped on shutdown so a
220153
220432
  * diagnostic timer is never the reason a runner refuses to exit. */
@@ -220179,54 +220458,94 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
220179
220458
  }
220180
220459
  /**
220181
220460
  * Enumerate this addon's live `ReolinkCamera` instances from the
220182
- * global device registry. Used by the email-push server to resolve
220461
+ * global device registry. Used by the camera mailbox bridge to resolve
220183
220462
  * recipients + route inbound motion. Returns `[]` when the registry
220184
220463
  * isn't available (worker context — providers are hub-only here).
220185
220464
  */
220186
220465
  listReolinkCameras() {
220187
220466
  const all = this.ctx.kernel.deviceRegistry?.getAll() ?? [];
220188
- const emailServer = this.emailPushServer;
220467
+ const emailBridge = this.emailBridge;
220189
220468
  const webhookServer = this.baichuanWebhookServer;
220190
220469
  const cameras = [];
220191
220470
  for (const d of all) if (d instanceof ReolinkCamera) {
220192
- if (emailServer) d.setEmailPushParamsResolver(() => emailServer.buildCameraSetupParams(d));
220471
+ if (emailBridge) d.setEmailPushParamsResolver(() => emailBridge.buildCameraSetupParams(d));
220193
220472
  if (webhookServer) d.setBaichuanWebhookParamsResolver(() => webhookServer.buildCameraSetupParams(d));
220194
220473
  cameras.push(d);
220195
220474
  }
220196
220475
  return cameras;
220197
220476
  }
220198
220477
  /**
220199
- * Bootstrapped SMTP AUTH credentials — Zod-validated durable handles
220200
- * over the SAME two store keys the raw writer used. Built lazily
220201
- * (needs `this.ctx`) and memoised.
220478
+ * The legacy SMTP AUTH password: hidden state, kept in the keychain (D787). Only the
220479
+ * `email-push-to-mailbox` migration reads it, to carry it into the mailbox.
220202
220480
  */
220203
- _emailPushAuthUsernameState = null;
220204
220481
  _emailPushAuthPasswordState = null;
220205
- get emailPushAuthUsernameState() {
220206
- if (!this._emailPushAuthUsernameState) this._emailPushAuthUsernameState = this.state("emailPushAuthUsername", string(), "");
220207
- return this._emailPushAuthUsernameState;
220208
- }
220209
220482
  get emailPushAuthPasswordState() {
220210
220483
  if (!this._emailPushAuthPasswordState) this._emailPushAuthPasswordState = this.state("emailPushAuthPassword", string(), "");
220211
220484
  return this._emailPushAuthPasswordState;
220212
220485
  }
220213
- /** Lazily build the email-push server, wiring its provider deps. */
220214
- ensureEmailPushServer() {
220215
- if (!this.emailPushServer) this.emailPushServer = new ReolinkEmailPushServer({
220216
- logger: this.ctx.logger.child("email-push"),
220486
+ /**
220487
+ * The mailbox the old intake described, waiting for a provider to create it: the migration
220488
+ * writes it (no network), the bridge reads it when it creates the mailbox and clears it after.
220489
+ * The password is a keychain secret of its own (`mailboxPendingPassword`).
220490
+ */
220491
+ _mailboxPendingState = null;
220492
+ _mailboxPendingPasswordState = null;
220493
+ get mailboxPendingState() {
220494
+ this._mailboxPendingState ??= this.state("mailboxPending", PendingMailboxSchema.nullable(), null);
220495
+ return this._mailboxPendingState;
220496
+ }
220497
+ get mailboxPendingPasswordState() {
220498
+ this._mailboxPendingPasswordState ??= this.state("mailboxPendingPassword", string(), "");
220499
+ return this._mailboxPendingPasswordState;
220500
+ }
220501
+ async stashPendingMailbox(settings) {
220502
+ const { password, ...rest } = settings;
220503
+ await this.mailboxPendingPasswordState.set(password);
220504
+ await this.mailboxPendingState.set(rest);
220505
+ }
220506
+ /** The pending settings, or null; throws when the stored password cannot be resolved. */
220507
+ async readPendingMailbox() {
220508
+ const pending = await this.mailboxPendingState.get();
220509
+ if (pending === null) return null;
220510
+ const password = await this.mailboxPendingPasswordState.get();
220511
+ const raw = (await this.resolveGlobalStore())["mailboxPendingPassword"];
220512
+ if (password.length === 0 && raw !== void 0 && raw !== "" && isSecretMarker(raw)) throw new Error("the pending camera mailbox password could not be resolved");
220513
+ return {
220514
+ ...pending,
220515
+ password
220516
+ };
220517
+ }
220518
+ async clearPendingMailbox() {
220519
+ if (await this.mailboxPendingState.get() === null) return;
220520
+ await this.mailboxPendingState.set(null);
220521
+ await this.mailboxPendingPasswordState.set("");
220522
+ }
220523
+ /** The camera mailbox bridge, built once. */
220524
+ ensureEmailBridge() {
220525
+ this.emailBridge ??= new EmailPushBridge({
220526
+ port: createBrokerPort(this.ctx.api),
220217
220527
  listCameras: () => this.listReolinkCameras(),
220218
- readStore: () => readEmailPushStore({
220219
- readRaw: () => this.resolveGlobalStore(),
220220
- readPassword: () => this.emailPushAuthPasswordState.get()
220221
- }),
220222
- writeStore: async (patch) => {
220223
- const username = patch["emailPushAuthUsername"];
220224
- if (typeof username === "string") await this.emailPushAuthUsernameState.set(username);
220225
- const password = patch["emailPushAuthPassword"];
220226
- if (typeof password === "string") await this.emailPushAuthPasswordState.set(password);
220528
+ logger: this.ctx.logger.child("email-push"),
220529
+ pending: {
220530
+ read: () => this.readPendingMailbox(),
220531
+ clear: () => this.clearPendingMailbox()
220227
220532
  }
220228
220533
  });
220229
- return this.emailPushServer;
220534
+ return this.emailBridge;
220535
+ }
220536
+ /**
220537
+ * The stored intake settings the migration carries into the mailbox. A password that is
220538
+ * stored but cannot be resolved (a locked keychain) aborts: it is never read as empty.
220539
+ */
220540
+ async readLegacyEmailPush() {
220541
+ const raw = await this.resolveGlobalStore();
220542
+ const password = await this.emailPushAuthPasswordState.get();
220543
+ const stored = raw["emailPushAuthPassword"];
220544
+ if (password.length === 0 && stored !== void 0 && stored !== "" && isSecretMarker(stored)) throw new Error("the legacy email-push password could not be resolved");
220545
+ return password.length > 0 ? {
220546
+ ...raw,
220547
+ emailPushAuthPassword: password
220548
+ } : raw;
220230
220549
  }
220231
220550
  /** Lazily build the HaCfg webhook intake, wiring its provider deps. */
220232
220551
  ensureBaichuanWebhookServer() {
@@ -220303,10 +220622,9 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
220303
220622
  }
220304
220623
  this.logChannels?.stop();
220305
220624
  await this.clipService?.disposeClipStreamDial().catch(() => {});
220306
- await this.emailPushServer?.stop().catch(() => {});
220307
220625
  await this.baichuanWebhookServer?.stop().catch(() => {});
220308
220626
  await this.hubManager?.shutdown().catch((err) => {
220309
- this.ctx.logger.warn("Reolink NVR sessions did not close cleanly", { meta: { error: errMsg$2(err) } });
220627
+ this.ctx.logger.warn("Reolink NVR sessions did not close cleanly", { meta: { error: errMsg$3(err) } });
220310
220628
  });
220311
220629
  this.hubManager = null;
220312
220630
  reolinkHubSessions.clear();
@@ -220334,7 +220652,7 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
220334
220652
  }
220335
220653
  await manager.reconcile(withSettings);
220336
220654
  } catch (err) {
220337
- this.ctx.logger.warn("Reolink integration reconcile failed — sessions left as they were", { meta: { error: errMsg$2(err) } });
220655
+ this.ctx.logger.warn("Reolink integration reconcile failed — sessions left as they were", { meta: { error: errMsg$3(err) } });
220338
220656
  }
220339
220657
  }
220340
220658
  subscribeIntegrationLifecycle() {
@@ -220499,9 +220817,6 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
220499
220817
  */
220500
220818
  async onConfigChanged() {
220501
220819
  await this.refreshClipRefreshGlobalStore();
220502
- await this.emailPushServer?.restart().catch((err) => {
220503
- this.ctx.logger.warn("email-push: restart after settings change failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
220504
- });
220505
220820
  await this.baichuanWebhookServer?.restart().catch((err) => {
220506
220821
  this.ctx.logger.warn("baichuan-webhook: restart after settings change failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
220507
220822
  });
@@ -220521,12 +220836,7 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
220521
220836
  * for core to select from.
220522
220837
  */
220523
220838
  globalSettingsSchema() {
220524
- const recommendedHost = listLanHosts()[0] ?? "127.0.0.1";
220525
- return { sections: [
220526
- ...buildEmailPushSettingsSchema(recommendedHost).sections,
220527
- ...buildBaichuanWebhookSettingsSchema(recommendedHost).sections,
220528
- ...buildClipRefreshSettingsSchema().sections
220529
- ] };
220839
+ return { sections: [...buildBaichuanWebhookSettingsSchema(listLanHosts()[0] ?? "127.0.0.1").sections, ...buildClipRefreshSettingsSchema().sections] };
220530
220840
  }
220531
220841
  /**
220532
220842
  * Per-instance form (D985): an NVR / Home Hub integration shows its connection only; the
@@ -220545,18 +220855,17 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
220545
220855
  } catch (err) {
220546
220856
  this.ctx.logger.warn("Reolink integration form: instance lookup failed — full schema shown", { meta: {
220547
220857
  integrationId: instance.integrationId,
220548
- error: errMsg$2(err)
220858
+ error: errMsg$3(err)
220549
220859
  } });
220550
220860
  return null;
220551
220861
  }
220552
220862
  }
220553
220863
  /**
220554
- * Email-push + HaCfg webhook sections ARE this integration's configuration —
220555
- * one intake of each kind serving every Reolink camera. See
220556
- * `email-push-schema.ts` / `baichuan-webhook-schema.ts`.
220864
+ * The HaCfg webhook section IS this integration's configuration — one intake serving
220865
+ * every Reolink camera. See `baichuan-webhook-schema.ts`. (The camera mailbox is a broker.)
220557
220866
  */
220558
220867
  integrationSettingSections() {
220559
- return [...EMAIL_PUSH_SECTION_IDS, ...BAICHUAN_WEBHOOK_SECTION_IDS];
220868
+ return [...BAICHUAN_WEBHOOK_SECTION_IDS];
220560
220869
  }
220561
220870
  async onInitialize() {
220562
220871
  const regs = this.providerRegistrationsFromSuper(await super.onInitialize());
@@ -220601,9 +220910,8 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
220601
220910
  this.startClipCatalogRefresh(this.clipService);
220602
220911
  this.wireClipCatalogRefreshers();
220603
220912
  await this.serveClipPlanes(this.clipService);
220604
- this.ensureEmailPushServer().start().catch((err) => {
220605
- this.ctx.logger.warn("email-push: SMTP intake failed to start at init", { meta: { error: err instanceof Error ? err.message : String(err) } });
220606
- });
220913
+ await this.runMigrations(defineAddonMigrations(REOLINK_MIGRATIONS, this.migrationPorts()));
220914
+ this.wireEmailBridge();
220607
220915
  this.ensureBaichuanWebhookServer().start().catch((err) => {
220608
220916
  this.ctx.logger.warn("baichuan-webhook: HTTP intake failed to start at init", { meta: { error: err instanceof Error ? err.message : String(err) } });
220609
220917
  });
@@ -220613,8 +220921,9 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
220613
220921
  if (typeof data.deviceId !== "number") return;
220614
220922
  const dev = this.ctx.kernel.deviceRegistry?.getById(data.deviceId);
220615
220923
  if (dev instanceof ReolinkCamera) {
220616
- const emailServer = this.ensureEmailPushServer();
220617
- dev.setEmailPushParamsResolver(() => emailServer.buildCameraSetupParams(dev));
220924
+ const emailBridge = this.ensureEmailBridge();
220925
+ dev.setEmailPushParamsResolver(() => emailBridge.buildCameraSetupParams(dev));
220926
+ emailBridge.sync();
220618
220927
  const webhookServer = this.ensureBaichuanWebhookServer();
220619
220928
  dev.setBaichuanWebhookParamsResolver(() => webhookServer.buildCameraSetupParams(dev));
220620
220929
  this.wireClipCatalogRefreshers();
@@ -220640,8 +220949,36 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
220640
220949
  capability: deviceAdoptionCapability,
220641
220950
  provider: this.buildAdoptionProvider()
220642
220951
  });
220952
+ regs.push({
220953
+ capability: deviceRemovalHookCapability,
220954
+ provider: createEmailPushRemovalHook({ resync: () => this.ensureEmailBridge().sync() })
220955
+ });
220643
220956
  return regs;
220644
220957
  }
220958
+ /** What the catalogue's migration reads and writes (D987). */
220959
+ migrationPorts() {
220960
+ return {
220961
+ readStored: () => this.readLegacyEmailPush(),
220962
+ stashPending: (settings) => this.stashPendingMailbox(settings),
220963
+ warn: (message) => {
220964
+ this.ctx.logger.warn(message);
220965
+ }
220966
+ };
220967
+ }
220968
+ /** Listen to the mailbox's messages and keep the declared recipients current. */
220969
+ wireEmailBridge() {
220970
+ const bridge = this.ensureEmailBridge();
220971
+ this.subscribe({ category: EventCategory.BrokerMessage }, (e) => {
220972
+ bridge.onMessage(e.data);
220973
+ });
220974
+ this.subscribe({ category: EventCategory.BrokerStatusChanged }, (e) => {
220975
+ bridge.onStatusChanged(e.data);
220976
+ });
220977
+ this.subscribe({ category: EventCategory.SystemReadyState }, (e) => {
220978
+ bridge.onProviderReady(e.data);
220979
+ });
220980
+ bridge.sync();
220981
+ }
220645
220982
  /** The clip catalog, its thumbnail cache and its two routes (D549). */
220646
220983
  clipService = null;
220647
220984
  clipRefreshTimer = null;
@@ -221005,8 +221342,8 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
221005
221342
  onAfterCreate: async (device) => {
221006
221343
  if (device instanceof ReolinkCamera) {
221007
221344
  device.adoptApi(detectedApi);
221008
- const emailServer = this.ensureEmailPushServer();
221009
- device.setEmailPushParamsResolver(() => emailServer.buildCameraSetupParams(device));
221345
+ const emailBridge = this.ensureEmailBridge();
221346
+ device.setEmailPushParamsResolver(() => emailBridge.buildCameraSetupParams(device));
221010
221347
  const webhookServer = this.ensureBaichuanWebhookServer();
221011
221348
  device.setBaichuanWebhookParamsResolver(() => webhookServer.buildCameraSetupParams(device));
221012
221349
  }