@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.js CHANGED
@@ -29171,7 +29171,7 @@ var require_lz4 = /* @__PURE__ */ require_chunk.__commonJSMin(((exports) => {
29171
29171
  };
29172
29172
  }));
29173
29173
  //#endregion
29174
- //#region node_modules/@apocaliss92/nodelink-js/dist/chunk-OYFAZ255.js
29174
+ //#region node_modules/@apocaliss92/nodelink-js/dist/chunk-DBCXE3EJ.js
29175
29175
  var import_fxp = require_fxp();
29176
29176
  var import_lz4 = /* @__PURE__ */ require_chunk.__toESM(require_lz4(), 1);
29177
29177
  function bcHeaderLen(messageClass, buf, bodyLen) {
@@ -31248,6 +31248,13 @@ var BaichuanClient = class _BaichuanClient extends events.EventEmitter {
31248
31248
  * others wait on the same promise.
31249
31249
  */
31250
31250
  tcpConnectPromise;
31251
+ /**
31252
+ * Bound for a single TCP handshake. Without this (and without rejecting on
31253
+ * `close`), a socket destroyed mid-connect left `tcpConnectPromise` pending
31254
+ * forever — every later `sendXml`/`connect` that joined the same promise
31255
+ * hung past its request timeout (which only started AFTER connect resolved).
31256
+ */
31257
+ static TCP_CONNECT_TIMEOUT_MS = 1e4;
31251
31258
  msgNum = 0;
31252
31259
  loggedIn = false;
31253
31260
  subscribed = false;
@@ -31843,6 +31850,26 @@ var BaichuanClient = class _BaichuanClient extends events.EventEmitter {
31843
31850
  }
31844
31851
  }
31845
31852
  }
31853
+ /**
31854
+ * Connect, but fail if the handshake does not settle within `timeoutMs`.
31855
+ * Returns remaining ms so callers can apply the rest of their budget to the
31856
+ * request itself (request timers used to start only AFTER connect resolved,
31857
+ * so a wedged `tcpConnectPromise` made every send hang forever).
31858
+ */
31859
+ async connectWithDeadline(timeoutMs, label) {
31860
+ const started = Date.now();
31861
+ let timer;
31862
+ try {
31863
+ await Promise.race([this.connect(), new Promise((_, reject) => {
31864
+ timer = setTimeout(() => {
31865
+ reject(/* @__PURE__ */ new Error(`Baichuan connect timeout after ${timeoutMs}ms (${label})`));
31866
+ }, Math.max(1, timeoutMs));
31867
+ })]);
31868
+ } finally {
31869
+ if (timer) clearTimeout(timer);
31870
+ }
31871
+ return Math.max(1, timeoutMs - (Date.now() - started));
31872
+ }
31846
31873
  async connect() {
31847
31874
  const desired = this.opts.transport ?? "tcp";
31848
31875
  if (desired === "tcp") {
@@ -31976,8 +32003,30 @@ var BaichuanClient = class _BaichuanClient extends events.EventEmitter {
31976
32003
  this.emit("error", err);
31977
32004
  });
31978
32005
  await new Promise((resolve, reject) => {
31979
- sock.once("connect", () => resolve());
31980
- sock.once("error", (e) => reject(e));
32006
+ let settled = false;
32007
+ const settle = (fn) => {
32008
+ if (settled) return;
32009
+ settled = true;
32010
+ clearTimeout(timer);
32011
+ sock.off("connect", onConnect);
32012
+ sock.off("error", onError);
32013
+ sock.off("close", onClose);
32014
+ fn();
32015
+ };
32016
+ const onConnect = () => settle(() => resolve());
32017
+ const onError = (e) => settle(() => reject(e));
32018
+ const onClose = () => settle(() => reject(/* @__PURE__ */ new Error("Baichuan TCP connect aborted: socket closed")));
32019
+ const timer = setTimeout(() => {
32020
+ settle(() => {
32021
+ try {
32022
+ if (!sock.destroyed) sock.destroy();
32023
+ } catch {}
32024
+ reject(/* @__PURE__ */ new Error(`TCP connection timeout after ${_BaichuanClient.TCP_CONNECT_TIMEOUT_MS}ms`));
32025
+ });
32026
+ }, _BaichuanClient.TCP_CONNECT_TIMEOUT_MS);
32027
+ sock.once("connect", onConnect);
32028
+ sock.once("error", onError);
32029
+ sock.once("close", onClose);
31981
32030
  });
31982
32031
  const sid = this.socketSessionId;
31983
32032
  const localAddress = sock.localAddress;
@@ -32888,7 +32937,8 @@ var BaichuanClient = class _BaichuanClient extends events.EventEmitter {
32888
32937
  async sendXml(params) {
32889
32938
  const internal = params.internal === true;
32890
32939
  if (!internal) this.touchUserActivity(`sendXml cmdId=${params.cmdId}`);
32891
- await this.connect();
32940
+ const timeoutMs = params.timeoutMs ?? 1e4;
32941
+ const remainingMs = await this.connectWithDeadline(timeoutMs, `sendXml cmdId=${params.cmdId}`);
32892
32942
  if (!internal) this.kickIdleDisconnectTimer();
32893
32943
  const channel = params.channel ?? this.opts.channel ?? 0;
32894
32944
  const channelId = params.channelIdOverride ?? (params.channel == null ? this.hostChannelId : channel + 1);
@@ -32913,12 +32963,11 @@ var BaichuanClient = class _BaichuanClient extends events.EventEmitter {
32913
32963
  const enc = params.encryption ?? this.enc;
32914
32964
  const bodyBytes = this.encodeBodyXml(extXml, payloadXml, channelId, enc);
32915
32965
  const wire = Buffer.concat([header, bodyBytes]);
32916
- const timeoutMs = params.timeoutMs ?? 1e4;
32917
32966
  const framePromise = new Promise((resolve, reject) => {
32918
32967
  const t = setTimeout(() => {
32919
32968
  this.pending.delete(pendingKey);
32920
32969
  reject(/* @__PURE__ */ new Error(`Baichuan timeout cmdId=${cmdId} msgNum=${msgNum}`));
32921
- }, timeoutMs);
32970
+ }, remainingMs);
32922
32971
  this.pending.set(pendingKey, {
32923
32972
  resolve: (f) => {
32924
32973
  clearTimeout(t);
@@ -32975,7 +33024,8 @@ var BaichuanClient = class _BaichuanClient extends events.EventEmitter {
32975
33024
  async sendFrame(params) {
32976
33025
  const internal = params.internal === true;
32977
33026
  if (!internal) this.touchUserActivity(`sendFrame cmdId=${params.cmdId}`);
32978
- await this.connect();
33027
+ const timeoutMs = params.timeoutMs ?? 1e4;
33028
+ const remainingMs = await this.connectWithDeadline(timeoutMs, `sendFrame cmdId=${params.cmdId}`);
32979
33029
  if (!internal) this.kickIdleDisconnectTimer();
32980
33030
  const channel = params.channel ?? this.opts.channel ?? 0;
32981
33031
  const channelId = params.channelIdOverride ?? (params.channel == null ? this.hostChannelId : channel + 1);
@@ -33000,12 +33050,11 @@ var BaichuanClient = class _BaichuanClient extends events.EventEmitter {
33000
33050
  const enc = params.encryption ?? this.enc;
33001
33051
  const bodyBytes = this.encodeBodyXml(extXml, payloadXml, channelId, enc);
33002
33052
  const wire = Buffer.concat([header, bodyBytes]);
33003
- const timeoutMs = params.timeoutMs ?? 1e4;
33004
33053
  const framePromise = new Promise((resolve, reject) => {
33005
33054
  const t = setTimeout(() => {
33006
33055
  this.pending.delete(pendingKey);
33007
33056
  reject(/* @__PURE__ */ new Error(`Baichuan timeout cmdId=${cmdId} msgNum=${msgNum}`));
33008
- }, timeoutMs);
33057
+ }, remainingMs);
33009
33058
  this.pending.set(pendingKey, {
33010
33059
  resolve: (f) => {
33011
33060
  clearTimeout(t);
@@ -37615,10 +37664,18 @@ var mapToSimpleEvent = (event) => {
37615
37664
  }
37616
37665
  return null;
37617
37666
  };
37618
- var shouldAcceptSleepPushSource = (source, preference, webhookSupported) => {
37667
+ var shouldAcceptSleepPushSource = (source, preference, options) => {
37619
37668
  if (source !== "email" && source !== "baichuanWebhook") return true;
37669
+ const opts = typeof options === "boolean" ? {
37670
+ webhookSupported: options,
37671
+ webhookDeliverySeen: options
37672
+ } : options;
37620
37673
  const pref = preference === "webhook" || preference === "email" ? preference : "auto";
37621
- return pref === "webhook" || pref === "auto" && webhookSupported ? source === "baichuanWebhook" : source === "email";
37674
+ if (pref === "email") return source === "email";
37675
+ if (pref === "webhook") return source === "baichuanWebhook";
37676
+ if (!opts.webhookSupported) return source === "email";
37677
+ if (opts.webhookDeliverySeen) return source === "baichuanWebhook";
37678
+ return true;
37622
37679
  };
37623
37680
  var DEFAULT_SHELTER_CANVAS = {
37624
37681
  width: 540,
@@ -38966,19 +39023,10 @@ function parsePtzGuardXml(xml) {
38966
39023
  };
38967
39024
  }
38968
39025
  var emitter = new events.EventEmitter();
38969
- var lastEventByCamera = /* @__PURE__ */ new Map();
38970
- var MAX_GLOBAL_EVENTS = 300;
38971
- var globalRecentEvents = [];
38972
39026
  function onEmailPushEvent(handler) {
38973
39027
  emitter.on("event", handler);
38974
39028
  return () => emitter.off("event", handler);
38975
39029
  }
38976
- function emitEmailPushEvent(event) {
38977
- lastEventByCamera.set(event.cameraId, event);
38978
- globalRecentEvents.unshift(event);
38979
- if (globalRecentEvents.length > MAX_GLOBAL_EVENTS) globalRecentEvents.length = MAX_GLOBAL_EVENTS;
38980
- emitter.emit("event", event);
38981
- }
38982
39030
  function mapEmailPushInferredType(inferred) {
38983
39031
  switch (inferred) {
38984
39032
  case "motion":
@@ -38991,6 +39039,52 @@ function mapEmailPushInferredType(inferred) {
38991
39039
  default: return "motion";
38992
39040
  }
38993
39041
  }
39042
+ var AI_OBJECT_MAP = {
39043
+ people: "people",
39044
+ person: "people",
39045
+ human: "people",
39046
+ vehicle: "vehicle",
39047
+ car: "vehicle",
39048
+ dog_cat: "animal",
39049
+ dog: "animal",
39050
+ cat: "animal",
39051
+ pet: "animal",
39052
+ face: "face",
39053
+ package: "package"
39054
+ };
39055
+ function xmlTag(xml, tag2) {
39056
+ return (new RegExp(`<${tag2}>([^<]*)</${tag2}>`, "i").exec(xml)?.[1] ?? "").trim().toLowerCase();
39057
+ }
39058
+ function splitCsv(value) {
39059
+ return value.split(",").map((s) => s.trim().toLowerCase()).filter((s) => s.length > 0);
39060
+ }
39061
+ function mapBaichuanWebhookAlarmXmlToSimpleEvents(xml) {
39062
+ if (!xml || !/<AlarmEvent\b/i.test(xml)) return [];
39063
+ const out = /* @__PURE__ */ new Set();
39064
+ for (const match of xml.matchAll(/<AlarmEvent\b[^>]*>([\s\S]*?)<\/AlarmEvent>/gi)) {
39065
+ const block = match[1] ?? "";
39066
+ const status = splitCsv(xmlTag(block, "status"));
39067
+ const ai = splitCsv(xmlTag(block, "AItype") || xmlTag(block, "aiType") || xmlTag(block, "aitype"));
39068
+ if (status.includes("visitor")) {
39069
+ out.add("doorbell");
39070
+ continue;
39071
+ }
39072
+ const objects = [];
39073
+ for (const token of ai) {
39074
+ if (token === "none" || token === "other") continue;
39075
+ const mapped = AI_OBJECT_MAP[token];
39076
+ if (mapped) objects.push(mapped);
39077
+ }
39078
+ if (objects.length > 0) {
39079
+ for (const t of objects) out.add(t);
39080
+ continue;
39081
+ }
39082
+ const statusIndicatesMotion = status.some((s) => s !== "none" && s !== "visitor" && s !== "dn" && s !== "daynight");
39083
+ const aiIndicatesMotion = ai.some((a) => a !== "none");
39084
+ if (statusIndicatesMotion || aiIndicatesMotion) out.add("motion");
39085
+ }
39086
+ return [...out];
39087
+ }
38994
39088
  var emitter2 = new events.EventEmitter();
38995
39089
  var lastEventByCamera2 = /* @__PURE__ */ new Map();
38996
39090
  var MAX_GLOBAL_EVENTS2 = 300;
@@ -39015,6 +39109,11 @@ function mapBaichuanWebhookToSimpleEvents(event) {
39015
39109
  if (reason === "pir") return ["motion", "awake"];
39016
39110
  return ["awake"];
39017
39111
  }
39112
+ function mapBaichuanWebhookParsedToSimpleEvents(parsed) {
39113
+ if (parsed.kind === "event") return mapBaichuanWebhookToSimpleEvents(parsed);
39114
+ if (parsed.cmdId === 33) return mapBaichuanWebhookAlarmXmlToSimpleEvents(parsed.xml);
39115
+ return [];
39116
+ }
39018
39117
  var parser = new import_fxp.XMLParser({
39019
39118
  ignoreAttributes: false,
39020
39119
  attributeNamePrefix: "@_",
@@ -42592,13 +42691,19 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
42592
42691
  const matches = "match" in params ? params.match : (event) => event.cameraId === params.cameraId;
42593
42692
  return onBaichuanWebhookEvent((event) => {
42594
42693
  if (!matches(event)) return;
42595
- const types = mapBaichuanWebhookToSimpleEvents(event);
42694
+ const types = mapBaichuanWebhookParsedToSimpleEvents(event.raw);
42596
42695
  for (const type of types) this.dispatchSimpleEvent({
42597
42696
  type,
42598
42697
  channel,
42599
42698
  timestamp: event.receivedAtMs,
42600
42699
  source: "baichuanWebhook"
42601
42700
  });
42701
+ if (types.some((t) => t !== "motion" && t !== "doorbell" && t !== "awake" && t !== "sleeping" && t !== "other") && !types.includes("motion")) this.dispatchSimpleEvent({
42702
+ type: "motion",
42703
+ channel,
42704
+ timestamp: event.receivedAtMs,
42705
+ source: "baichuanWebhook"
42706
+ });
42602
42707
  if (onEvent) try {
42603
42708
  onEvent(event);
42604
42709
  } catch (err) {
@@ -44677,7 +44782,7 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
44677
44782
  const uid = await this.ensureUidForRecordings(channel, void 0);
44678
44783
  const payloadXml = buildFileInfoListReplayByNameXml({
44679
44784
  channel,
44680
- xmlChannelId: 0,
44785
+ xmlChannelId: channel,
44681
44786
  name: params.fileName,
44682
44787
  uid,
44683
44788
  streamType
@@ -44733,6 +44838,7 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
44733
44838
  });
44734
44839
  await dedicatedClient.sendXml({
44735
44840
  cmdId: 7,
44841
+ extensionXml: "",
44736
44842
  channel,
44737
44843
  payloadXml: stopXml,
44738
44844
  messageClass: require_chunk_LXENEW4N.BC_CLASS_MODERN_24,
@@ -44785,7 +44891,7 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
44785
44891
  const headerChannelIdOverride = this.resolveHeaderChannelIdForLogicalChannel(channel);
44786
44892
  const payloadXml = buildFileInfoListReplayByIdXml({
44787
44893
  channel,
44788
- xmlChannelId: 0,
44894
+ xmlChannelId: channel,
44789
44895
  id: params.fileName,
44790
44896
  ...uid ? { uid } : {},
44791
44897
  streamType
@@ -44842,6 +44948,7 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
44842
44948
  });
44843
44949
  await dedicatedClient.sendXml({
44844
44950
  cmdId: 7,
44951
+ extensionXml: "",
44845
44952
  channel,
44846
44953
  payloadXml: stopXml,
44847
44954
  messageClass: require_chunk_LXENEW4N.BC_CLASS_MODERN_24,
@@ -45772,13 +45879,13 @@ ${stderr}`));
45772
45879
  if (isNvr) uid = await this.ensureUidForRecordings(channel, params.uid);
45773
45880
  const payloadXml = ident.includes("/") ? buildFileInfoListReplayByIdXml({
45774
45881
  channel,
45775
- xmlChannelId: 0,
45882
+ xmlChannelId: channel,
45776
45883
  id: ident,
45777
45884
  ...uid ? { uid } : {},
45778
45885
  streamType
45779
45886
  }) : buildFileInfoListReplayByNameXml({
45780
45887
  channel,
45781
- xmlChannelId: 0,
45888
+ xmlChannelId: channel,
45782
45889
  name: ident,
45783
45890
  ...uid ? { uid } : {},
45784
45891
  streamType
@@ -45945,6 +46052,7 @@ ${stderr}`));
45945
46052
  try {
45946
46053
  await this.client.sendXml({
45947
46054
  cmdId: 7,
46055
+ extensionXml: "",
45948
46056
  channel: params.channel,
45949
46057
  payloadXml: buildFileInfoListStopXml({
45950
46058
  channel: params.channel,
@@ -98122,10 +98230,8 @@ var require_mailparser = /* @__PURE__ */ require_chunk.__commonJSMin(((exports,
98122
98230
  simpleParser: require_simple_parser()
98123
98231
  };
98124
98232
  }));
98125
- //#endregion
98126
- //#region node_modules/@apocaliss92/nodelink-js/dist/index.js
98127
- var import_smtp_server = require_smtp_server();
98128
- var import_mailparser = require_mailparser();
98233
+ require_smtp_server();
98234
+ require_mailparser();
98129
98235
  function buildRfc4571Sdp(video, audio) {
98130
98236
  let out = "v=0\r\n";
98131
98237
  out += "o=- 0 0 IN IP4 0.0.0.0\r\n";
@@ -100533,271 +100639,18 @@ function resolveAlawmulaw() {
100533
100639
  throw new Error("alawmulaw: unable to resolve mulaw/alaw codecs from module exports");
100534
100640
  }
100535
100641
  var { mulaw, alaw } = resolveAlawmulaw();
100536
- async function loadEmailPushTls(params) {
100537
- const certPath = path.default.join(params.dir, "cert.pem");
100538
- const keyPath = path.default.join(params.dir, "key.pem");
100539
- const warn = params.warn ?? ((m) => console.warn(`[email-push-tls] ${m}`));
100540
- if (!fs.default.existsSync(certPath) || !fs.default.existsSync(keyPath)) {
100541
- 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.`);
100542
- return;
100543
- }
100544
- return {
100545
- cert: fs.default.readFileSync(certPath),
100546
- key: fs.default.readFileSync(keyPath)
100547
- };
100548
- }
100549
- async function defaultLoadTls(dir, warn) {
100550
- return loadEmailPushTls({
100551
- dir,
100552
- warn
100553
- });
100554
- }
100555
- function classifyMessage(parsed) {
100556
- const haystack = `${parsed.subject ?? ""} ${parsed.text ?? ""}`.toLowerCase();
100642
+ function classifyEmailPushMessage(input) {
100643
+ const haystack = `${(input.subject ?? "").toLowerCase()} ${(input.text ?? "").toLowerCase()}`;
100557
100644
  if (/person|people|human/.test(haystack)) return "people";
100558
100645
  if (/vehicle|car|truck/.test(haystack)) return "vehicle";
100559
100646
  if (/dog[_\s-]?cat|pet|animal/.test(haystack)) return "animal";
100560
100647
  if (/face/.test(haystack)) return "face";
100561
100648
  if (/package|parcel/.test(haystack)) return "package";
100562
- if (/doorbell|ring(?:ing)?\s+button/.test(haystack)) return "doorbell";
100649
+ if (/ring(?:ing)?\s+button|doorbell\s+(?:press|ring|button)|visitor\s+at|someone\s+rang/.test(haystack)) return "doorbell";
100563
100650
  if (/motion|alarm|alert|detect/.test(haystack)) return "motion";
100651
+ if (/\bdoorbell\b/.test(haystack) && !/\bfrom\s+[^\n]*doorbell/.test(haystack)) return "doorbell";
100564
100652
  return "other";
100565
100653
  }
100566
- function createEmailPushServer(params) {
100567
- const log = {
100568
- debug: params.logger?.debug ?? (() => {}),
100569
- info: params.logger?.info ?? (() => {}),
100570
- warn: params.logger?.warn ?? (() => {}),
100571
- error: params.logger?.error ?? (() => {})
100572
- };
100573
- let config = params.config;
100574
- let server;
100575
- let status = buildInitialStatus(config);
100576
- function parseRecipient(rcpt) {
100577
- const at = rcpt.lastIndexOf("@");
100578
- if (at <= 0 || at === rcpt.length - 1) return void 0;
100579
- return {
100580
- local: rcpt.slice(0, at),
100581
- domain: rcpt.slice(at + 1).toLowerCase()
100582
- };
100583
- }
100584
- function resolveCameraIdFromRecipient(rcpt) {
100585
- const parsed = parseRecipient(rcpt);
100586
- if (!parsed) return void 0;
100587
- if (parsed.domain !== config.domain.toLowerCase()) return void 0;
100588
- const match = parsed.local.match(/^cam-(.+)$/);
100589
- if (!match || !match[1]) return void 0;
100590
- return params.cameraResolver(match[1]);
100591
- }
100592
- async function handleIncomingMessage(cameraId, recipient, raw) {
100593
- let parsed;
100594
- try {
100595
- parsed = await (0, import_mailparser.simpleParser)(raw);
100596
- } catch (err) {
100597
- log.warn(`Failed to parse mail for ${cameraId}: ${err instanceof Error ? err.message : err}`);
100598
- status.messagesRejected++;
100599
- return;
100600
- }
100601
- const receivedAtMs = Date.now();
100602
- const event = {
100603
- cameraId,
100604
- recipient,
100605
- inferredType: classifyMessage(parsed),
100606
- receivedAtMs,
100607
- subject: parsed.subject ?? "",
100608
- from: typeof parsed.from === "object" && parsed.from !== null && "text" in parsed.from ? String(parsed.from.text) : "",
100609
- bodyExcerpt: (parsed.text ?? "").slice(0, 500)
100610
- };
100611
- status.messagesAccepted++;
100612
- log.info(`Email push for camera=${cameraId} type=${event.inferredType} subject="${event.subject.slice(0, 80)}"`);
100613
- emitEmailPushEvent(event);
100614
- }
100615
- async function start() {
100616
- if (server) {
100617
- log.debug("startEmailPushServer called but server already running");
100618
- return;
100619
- }
100620
- const tlsOptions = config.tls ? await (params.loadTls ?? defaultLoadTls)(config.tlsDir ?? "./email-push-tls", (m) => log.warn(m)) : void 0;
100621
- status = buildInitialStatus(config);
100622
- const next = new import_smtp_server.SMTPServer({
100623
- authOptional: !config.requireAuth,
100624
- disabledCommands: config.requireAuth ? [] : ["AUTH"],
100625
- allowInsecureAuth: !config.tls,
100626
- size: config.maxMessageBytes,
100627
- maxClients: 50,
100628
- socketTimeout: 6e4,
100629
- closeTimeout: 3e4,
100630
- logger: {
100631
- info: (...args) => log.info(formatSmtpLogArgs(args)),
100632
- debug: (...args) => log.debug(formatSmtpLogArgs(args)),
100633
- error: (...args) => log.warn(formatSmtpLogArgs(args))
100634
- },
100635
- ...tlsOptions ? {
100636
- secure: false,
100637
- ...tlsOptions
100638
- } : {},
100639
- onConnect(session, callback) {
100640
- log.info(`SMTP connect from ${session.remoteAddress} (sessionId=${session.id})`);
100641
- callback();
100642
- },
100643
- onClose(session) {
100644
- log.debug(`SMTP close ${session.remoteAddress} (sessionId=${session.id})`);
100645
- },
100646
- onMailFrom(address, session, callback) {
100647
- log.info(`SMTP MAIL FROM ${address.address} (sessionId=${session.id})`);
100648
- callback();
100649
- },
100650
- onAuth(auth, session, callback) {
100651
- const expectedUser = config.authUsername;
100652
- const expectedPass = config.authPassword;
100653
- if (!expectedUser || !expectedPass) {
100654
- log.warn(`SMTP AUTH rejected from ${session.remoteAddress} (sessionId=${session.id}): server has no credentials configured`);
100655
- return callback(/* @__PURE__ */ new Error("Email push auth not configured"));
100656
- }
100657
- const stripDomain = (u) => {
100658
- const at = u.lastIndexOf("@");
100659
- if (at < 0) return u;
100660
- const local = u.slice(0, at);
100661
- return u.slice(at + 1).toLowerCase() === config.domain.toLowerCase() ? local : u;
100662
- };
100663
- if (stripDomain(auth.username ?? "") === stripDomain(expectedUser) && auth.password === expectedPass) {
100664
- log.info(`SMTP AUTH ok method=${auth.method} user=${auth.username} (sessionId=${session.id})`);
100665
- return callback(null, { user: auth.username });
100666
- }
100667
- log.warn(`SMTP AUTH FAIL method=${auth.method} from=${session.remoteAddress} triedUser="${auth.username}" expectedUser="${expectedUser}" triedPasswordLen=${auth.password?.length ?? 0} (sessionId=${session.id})`);
100668
- return callback(/* @__PURE__ */ new Error("Invalid username or password"));
100669
- },
100670
- onRcptTo(address, session, callback) {
100671
- const cameraId = resolveCameraIdFromRecipient(address.address);
100672
- if (!cameraId) {
100673
- status.messagesRejected++;
100674
- log.warn(`SMTP RCPT TO rejected ${address.address} \u2014 unknown recipient (sessionId=${session.id})`);
100675
- return callback(/* @__PURE__ */ new Error(`Unknown recipient ${address.address} (not registered)`));
100676
- }
100677
- log.info(`SMTP RCPT TO ${address.address} \u2192 camera=${cameraId} (sessionId=${session.id})`);
100678
- callback();
100679
- },
100680
- onData(stream$5, session, callback) {
100681
- const startedAt = Date.now();
100682
- log.debug(`SMTP DATA start (sessionId=${session.id} from=${session.envelope.mailFrom ? session.envelope.mailFrom.address : "?"})`);
100683
- const chunks = [];
100684
- stream$5.on("data", (chunk) => chunks.push(chunk));
100685
- stream$5.on("end", () => {
100686
- const recipients = session.envelope.rcptTo?.map((r) => r.address) ?? [];
100687
- const buffer = Buffer.concat(chunks);
100688
- log.info(`SMTP DATA received ${buffer.length}B in ${Date.now() - startedAt}ms for ${recipients.length} recipient(s) (sessionId=${session.id})`);
100689
- const matched = recipients.map((r) => ({
100690
- recipient: r,
100691
- cameraId: resolveCameraIdFromRecipient(r)
100692
- })).filter((m) => Boolean(m.cameraId));
100693
- if (matched.length === 0) {
100694
- status.messagesRejected++;
100695
- log.warn(`SMTP DATA dropped \u2014 no recognised recipients in [${recipients.join(", ")}] (sessionId=${session.id})`);
100696
- return callback(/* @__PURE__ */ new Error("No recognised recipients"));
100697
- }
100698
- Promise.all(matched.map((m) => handleIncomingMessage(m.cameraId, m.recipient, buffer))).then(() => callback()).catch((err) => {
100699
- const msg = err instanceof Error ? err.message : String(err);
100700
- log.error(`Email push pipeline error (sessionId=${session.id}): ${msg}`);
100701
- status.lastErrorMessage = msg;
100702
- callback(new Error(msg));
100703
- });
100704
- });
100705
- stream$5.on("error", (err) => {
100706
- log.warn(`SMTP stream error (sessionId=${session.id}): ${err.message}`);
100707
- callback(err);
100708
- });
100709
- }
100710
- });
100711
- next.on("error", (err) => {
100712
- status.lastErrorMessage = err.message;
100713
- log.error(`Email push server error: ${err.code ?? ""} ${err.message}`);
100714
- const netServer = next.server;
100715
- if (server === next && netServer && netServer.listening === false) {
100716
- status.running = false;
100717
- setTimeout(() => {
100718
- if (server !== next) return;
100719
- try {
100720
- next.listen(config.port, config.bindHost, () => {
100721
- status.running = true;
100722
- log.warn(`Email push SMTP re-listened on ${config.bindHost}:${config.port} after error`);
100723
- });
100724
- } catch (e) {
100725
- log.error(`Email push re-listen failed: ${e.message}`);
100726
- }
100727
- }, 5e3).unref?.();
100728
- }
100729
- });
100730
- await new Promise((resolve, reject) => {
100731
- next.listen(config.port, config.bindHost, () => {
100732
- status.running = true;
100733
- status.startedAtMs = Date.now();
100734
- log.info(`Email push SMTP listening on ${config.bindHost}:${config.port} (domain=${config.domain}, auth=${config.requireAuth}, tls=${config.tls})`);
100735
- resolve();
100736
- });
100737
- next.once("error", reject);
100738
- });
100739
- server = next;
100740
- }
100741
- async function stop() {
100742
- if (!server) return;
100743
- const active = server;
100744
- server = void 0;
100745
- await new Promise((resolve) => {
100746
- active.close(() => resolve());
100747
- });
100748
- status.running = false;
100749
- log.info("Email push SMTP server stopped");
100750
- }
100751
- async function restart() {
100752
- await stop();
100753
- await start();
100754
- }
100755
- return {
100756
- start,
100757
- stop,
100758
- restart,
100759
- getStatus() {
100760
- return { ...status };
100761
- },
100762
- updateConfig(next) {
100763
- config = next;
100764
- }
100765
- };
100766
- }
100767
- function formatSmtpLogArgs(args) {
100768
- if (args.length === 0) return "[smtp]";
100769
- const [first, ...rest] = args;
100770
- let tag = "";
100771
- let formatArgs = args;
100772
- if (first && typeof first === "object" && !Array.isArray(first)) {
100773
- const meta = first;
100774
- const tnx = typeof meta.tnx === "string" ? meta.tnx : void 0;
100775
- const cid = typeof meta.cid === "string" ? meta.cid : void 0;
100776
- const sid = typeof meta.sid === "string" ? meta.sid : void 0;
100777
- const bits = [];
100778
- if (tnx) bits.push(tnx);
100779
- if (cid) bits.push(`cid=${cid}`);
100780
- if (sid) bits.push(`sid=${sid}`);
100781
- if (bits.length > 0) tag = ` [${bits.join(" ")}]`;
100782
- formatArgs = rest;
100783
- }
100784
- return `[smtp]${tag} ${(0, util.format)(...formatArgs)}`;
100785
- }
100786
- function buildInitialStatus(config) {
100787
- return {
100788
- enabled: true,
100789
- running: false,
100790
- port: config.port,
100791
- bindHost: config.bindHost,
100792
- domain: config.domain,
100793
- requireAuth: config.requireAuth,
100794
- tls: config.tls,
100795
- messagesAccepted: 0,
100796
- messagesRejected: 0,
100797
- startedAtMs: void 0,
100798
- lastErrorMessage: void 0
100799
- };
100800
- }
100801
100654
  function asRecord(value) {
100802
100655
  if (value && typeof value === "object" && !Array.isArray(value)) return value;
100803
100656
  }
@@ -101814,13 +101667,13 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
101814
101667
  return EventCategory;
101815
101668
  }({});
101816
101669
  //#endregion
101817
- //#region ../types/dist/device-type-BOWLtoOt.mjs
101670
+ //#region ../types/dist/device-type-CfHw_quR.mjs
101818
101671
  /**
101819
101672
  import { errMsg } from '@camstack/types'
101820
101673
  * Extract a human-readable message from an unknown error value.
101821
101674
  * Replaces the ubiquitous `errMsg(err)` pattern.
101822
101675
  */
101823
- function errMsg$2(err) {
101676
+ function errMsg$3(err) {
101824
101677
  if (err instanceof Error) return err.message;
101825
101678
  if (typeof err === "string") return err;
101826
101679
  return String(err);
@@ -102003,6 +101856,9 @@ var DeviceFeature = /* @__PURE__ */ function(DeviceFeature) {
102003
101856
  DeviceFeature["CoverPositionable"] = "cover-positionable";
102004
101857
  /** Cover exposes slat-tilt control. Gates the tilt slider UI. */
102005
101858
  DeviceFeature["CoverTilt"] = "cover-tilt";
101859
+ /** Cover has a real stop: `stop` halts the travel in place. Gates the stop
101860
+ * control in exporters; a provider whose `stop` is a no-op never declares it. */
101861
+ DeviceFeature["CoverStoppable"] = "cover-stoppable";
102006
101862
  /** Valve exposes intermediate position control (0..100). Gates the
102007
101863
  * position slider / drag surface UI. */
102008
101864
  DeviceFeature["ValvePositionable"] = "valve-positionable";
@@ -106868,7 +106724,7 @@ var ZodIssueCode = {
106868
106724
  var ZodFirstPartyTypeKind;
106869
106725
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
106870
106726
  //#endregion
106871
- //#region ../types/dist/sleep-B-vp7x15.mjs
106727
+ //#region ../types/dist/sleep-QsoHs66J.mjs
106872
106728
  /**
106873
106729
  * The audio chunk plane's byte format, and the ONE expansion from a coded
106874
106730
  * window to float samples (D455).
@@ -106985,7 +106841,7 @@ function isKeychainStoredField(field) {
106985
106841
  var SECRET_CLEAR = { $secretClear: true };
106986
106842
  /** The `'***'` token D634 redaction writes for non-admins. */
106987
106843
  var LEGACY_DEVICE_REDACTION = "***";
106988
- function isRecord$6(value) {
106844
+ function isRecord$7(value) {
106989
106845
  return typeof value === "object" && value !== null && !Array.isArray(value);
106990
106846
  }
106991
106847
  function makeSecretMarker(ref) {
@@ -106996,16 +106852,16 @@ function makeSecretMarker(ref) {
106996
106852
  }
106997
106853
  /** A stored marker: an object whose `$secret` is a non-empty string. */
106998
106854
  function isSecretMarker(value) {
106999
- if (!isRecord$6(value)) return false;
106855
+ if (!isRecord$7(value)) return false;
107000
106856
  const ref = value["$secret"];
107001
106857
  return typeof ref === "string" && ref.length > 0;
107002
106858
  }
107003
106859
  /** Anything carrying a `$secret` key — a stored marker or a UI view of one. */
107004
106860
  function isSecretMarkerLike(value) {
107005
- return isRecord$6(value) && Object.prototype.hasOwnProperty.call(value, "$secret");
106861
+ return isRecord$7(value) && Object.prototype.hasOwnProperty.call(value, "$secret");
107006
106862
  }
107007
106863
  function isSecretClear(value) {
107008
- return isRecord$6(value) && value["$secretClear"] === true;
106864
+ return isRecord$7(value) && value["$secretClear"] === true;
107009
106865
  }
107010
106866
  /**
107011
106867
  * The UI view of a stored secret field value: a marker becomes "set", inline
@@ -107388,6 +107244,53 @@ function emitReadiness(bus, params) {
107388
107244
  ts
107389
107245
  }));
107390
107246
  }
107247
+ /** Why a catalogue entry is not a runnable body of its declared kind. */
107248
+ var AddonMigrationDefinitionError = class extends Error {
107249
+ id;
107250
+ constructor(id, message) {
107251
+ super(`${id}: ${message}`);
107252
+ this.id = id;
107253
+ this.name = "AddonMigrationDefinitionError";
107254
+ }
107255
+ };
107256
+ /**
107257
+ * Bind a catalogue to the addon's ports. Throws {@link AddonMigrationDefinitionError} when
107258
+ * an entry's `bodyKind` does not match the body it carries, or is `sql` (a hub-only
107259
+ * body). A throwing body never escapes `execute`: it is reported as a failed result.
107260
+ */
107261
+ function defineAddonMigrations(catalogue, ports) {
107262
+ return catalogue.map((entry) => {
107263
+ const { meta } = entry;
107264
+ if ("plan" in entry) {
107265
+ if (meta.bodyKind !== "plan") throw new AddonMigrationDefinitionError(meta.id, `carries a plan body but declares "${meta.bodyKind}"`);
107266
+ return {
107267
+ meta,
107268
+ execute: () => guarded(async () => ({
107269
+ kind: "plan",
107270
+ plan: await entry.plan(ports)
107271
+ }))
107272
+ };
107273
+ }
107274
+ if (meta.bodyKind !== "procedure") throw new AddonMigrationDefinitionError(meta.id, `carries a procedure body but declares "${meta.bodyKind}"`);
107275
+ return {
107276
+ meta,
107277
+ execute: () => guarded(async () => ({
107278
+ kind: "procedure",
107279
+ result: await entry.procedure(ports)
107280
+ }))
107281
+ };
107282
+ });
107283
+ }
107284
+ async function guarded(work) {
107285
+ try {
107286
+ return await work();
107287
+ } catch (err) {
107288
+ return {
107289
+ kind: "failed",
107290
+ error: errMsg$3(err)
107291
+ };
107292
+ }
107293
+ }
107391
107294
  var NOT_RUN = {
107392
107295
  declared: false,
107393
107296
  snapshot: null,
@@ -107415,7 +107318,7 @@ async function runAddonMigrations(host, migrations, logger) {
107415
107318
  metas: migrations.map((m) => m.meta)
107416
107319
  });
107417
107320
  } catch (err) {
107418
- log.error("the migration engine refused or could not take this addon catalogue — none ran", { meta: { error: errMsg$2(err) } });
107321
+ log.error("the migration engine refused or could not take this addon catalogue — none ran", { meta: { error: errMsg$3(err) } });
107419
107322
  return {
107420
107323
  ...NOT_RUN,
107421
107324
  blockOwner: migrations.filter((m) => m.meta.onFailure === "block-owner").map((m) => m.meta.id)
@@ -107468,7 +107371,7 @@ async function runAddonMigrations(host, migrations, logger) {
107468
107371
  } catch (err) {
107469
107372
  log.error("migration could not be completed with the engine", {
107470
107373
  tags,
107471
- meta: { error: errMsg$2(err) }
107374
+ meta: { error: errMsg$3(err) }
107472
107375
  });
107473
107376
  failed.push(step.id);
107474
107377
  }
@@ -107711,7 +107614,7 @@ var KeychainWriteRefusedError = class extends Error {
107711
107614
  }
107712
107615
  };
107713
107616
  var KEEP = { kind: "keep" };
107714
- function isRecord$5(value) {
107617
+ function isRecord$6(value) {
107715
107618
  return typeof value === "object" && value !== null && !Array.isArray(value);
107716
107619
  }
107717
107620
  function defaultMintId() {
@@ -107724,7 +107627,7 @@ function collectSecretMarkerRefs(value, out = []) {
107724
107627
  return out;
107725
107628
  }
107726
107629
  if (Array.isArray(value)) for (const item of value) collectSecretMarkerRefs(item, out);
107727
- else if (isRecord$5(value)) for (const child of Object.values(value)) collectSecretMarkerRefs(child, out);
107630
+ else if (isRecord$6(value)) for (const child of Object.values(value)) collectSecretMarkerRefs(child, out);
107728
107631
  return out;
107729
107632
  }
107730
107633
  async function storedValueEquals(secrets, marker, candidate) {
@@ -107779,7 +107682,7 @@ function rowId(row, idKey) {
107779
107682
  return null;
107780
107683
  }
107781
107684
  async function writeArray(ctx, node, incoming, stored, fieldPath) {
107782
- const storedRows = Array.isArray(stored) ? stored.filter(isRecord$5) : [];
107685
+ const storedRows = Array.isArray(stored) ? stored.filter(isRecord$6) : [];
107783
107686
  if (!Array.isArray(incoming)) {
107784
107687
  ctx.deletes.push(...collectSecretMarkerRefs(storedRows));
107785
107688
  return {
@@ -107795,7 +107698,7 @@ async function writeArray(ctx, node, incoming, stored, fieldPath) {
107795
107698
  const kept = /* @__PURE__ */ new Set();
107796
107699
  const rows = [];
107797
107700
  for (const row of incoming) {
107798
- if (!isRecord$5(row)) {
107701
+ if (!isRecord$6(row)) {
107799
107702
  rows.push(row);
107800
107703
  continue;
107801
107704
  }
@@ -107820,7 +107723,7 @@ async function writeNode(ctx, node, incoming, stored, fieldPath) {
107820
107723
  case "leaf": return writeLeaf(ctx, incoming, stored, fieldPath);
107821
107724
  case "array": return writeArray(ctx, node, incoming, stored, fieldPath);
107822
107725
  case "object": {
107823
- if (!isRecord$5(incoming)) {
107726
+ if (!isRecord$6(incoming)) {
107824
107727
  if (incoming === void 0) return KEEP;
107825
107728
  ctx.deletes.push(...collectSecretMarkerRefs(stored));
107826
107729
  return {
@@ -107828,7 +107731,7 @@ async function writeNode(ctx, node, incoming, stored, fieldPath) {
107828
107731
  value: incoming
107829
107732
  };
107830
107733
  }
107831
- const storedObj = isRecord$5(stored) ? stored : {};
107734
+ const storedObj = isRecord$6(stored) ? stored : {};
107832
107735
  return {
107833
107736
  kind: "value",
107834
107737
  value: await writeMembers(ctx, node.children, incoming, storedObj, (m) => `${fieldPath}.${m}`)
@@ -107951,7 +107854,7 @@ async function resolveSecretMarkers(value, secrets, options = {}) {
107951
107854
  value: items
107952
107855
  };
107953
107856
  }
107954
- if (isRecord$5(v)) {
107857
+ if (isRecord$6(v)) {
107955
107858
  const out = {};
107956
107859
  for (const [k, child] of Object.entries(v)) {
107957
107860
  const s = substitute(child);
@@ -107983,7 +107886,7 @@ var SecretOverwriteRefusedError = class extends Error {
107983
107886
  this.name = "SecretOverwriteRefusedError";
107984
107887
  }
107985
107888
  };
107986
- function isRecord$4(value) {
107889
+ function isRecord$5(value) {
107987
107890
  return typeof value === "object" && value !== null && !Array.isArray(value);
107988
107891
  }
107989
107892
  function resolvedValue(pass, marker) {
@@ -108012,7 +107915,7 @@ function inArray(pass, incoming, stored) {
108012
107915
  return incoming.map((item, index) => {
108013
107916
  const twin = stored[index];
108014
107917
  if (isSecretMarker(twin)) return typeof item === "string" && item === resolvedValue(pass, twin) ? twin : item;
108015
- if (isRecord$4(item) && isRecord$4(twin)) return inArrayRow(pass, item, twin);
107918
+ if (isRecord$5(item) && isRecord$5(twin)) return inArrayRow(pass, item, twin);
108016
107919
  if (Array.isArray(item) && Array.isArray(twin)) return inArray(pass, item, twin);
108017
107920
  return item;
108018
107921
  });
@@ -108023,7 +107926,7 @@ function inArrayRow(pass, incoming, stored) {
108023
107926
  const twin = stored[key];
108024
107927
  if (isSecretMarker(twin)) {
108025
107928
  if (typeof value === "string" && value === resolvedValue(pass, twin)) out[key] = twin;
108026
- } else if (isRecord$4(value) && isRecord$4(twin)) out[key] = inArrayRow(pass, value, twin);
107929
+ } else if (isRecord$5(value) && isRecord$5(twin)) out[key] = inArrayRow(pass, value, twin);
108027
107930
  else if (Array.isArray(value) && Array.isArray(twin)) out[key] = inArray(pass, value, twin);
108028
107931
  }
108029
107932
  return out;
@@ -108031,7 +107934,7 @@ function inArrayRow(pass, incoming, stored) {
108031
107934
  async function walk(pass, incoming, stored) {
108032
107935
  if (isSecretMarker(stored)) return atMarker(pass, incoming, stored);
108033
107936
  if (Array.isArray(incoming) && Array.isArray(stored)) return inArray(pass, incoming, stored);
108034
- if (!isRecord$4(incoming) || !isRecord$4(stored)) return incoming;
107937
+ if (!isRecord$5(incoming) || !isRecord$5(stored)) return incoming;
108035
107938
  const out = { ...incoming };
108036
107939
  for (const [key, value] of Object.entries(incoming)) out[key] = await walk(pass, value, stored[key]);
108037
107940
  return out;
@@ -108062,7 +107965,7 @@ async function restoreUnchangedSecrets(input) {
108062
107965
  function isJsonEqual(a, b) {
108063
107966
  if (Object.is(a, b)) return true;
108064
107967
  if (Array.isArray(a)) return Array.isArray(b) && a.length === b.length && a.every((v, i) => isJsonEqual(v, b[i]));
108065
- if (isRecord$4(a) && isRecord$4(b)) {
107968
+ if (isRecord$5(a) && isRecord$5(b)) {
108066
107969
  const keys = Object.keys(a);
108067
107970
  if (keys.length !== Object.keys(b).length) return false;
108068
107971
  return keys.every((k) => Object.prototype.hasOwnProperty.call(b, k) && isJsonEqual(a[k], b[k]));
@@ -108086,7 +107989,7 @@ function markerKeysOf(blob, owner) {
108086
107989
  }
108087
107990
  function nodeFromStored(value, owner) {
108088
107991
  if (isSecretMarker(value)) return isSecretRefOwnedBy(value.$secret, owner) ? { kind: "leaf" } : null;
108089
- if (!isRecord$4(value)) return null;
107992
+ if (!isRecord$5(value)) return null;
108090
107993
  const children = /* @__PURE__ */ new Map();
108091
107994
  for (const [key, child] of Object.entries(value)) {
108092
107995
  const node = nodeFromStored(child, owner);
@@ -108705,6 +108608,44 @@ var BaseAddon = class {
108705
108608
  return null;
108706
108609
  }
108707
108610
  /**
108611
+ * D986 scoped settings: the form shape of the CLUSTER-wide settings one capability this addon
108612
+ * provides owns, or `null` when it has none. `cap` is a capability name the addon's manifest
108613
+ * declares. Stored as `addon-settings` rows `<addonId>.<field>`. The keys of this schema, of
108614
+ * {@link nodeSettingsSchema} and of {@link deviceDefaultsSchema} are disjoint, and a key never
108615
+ * contains `@` (guard `check-scoped-settings-declared`). SHAPE only: values are read by scope.
108616
+ */
108617
+ async clusterSettingsSchema(_cap) {
108618
+ return null;
108619
+ }
108620
+ /**
108621
+ * D986: the form shape of the settings one capability owns PER NODE, or `null`. Stored as
108622
+ * `addon-settings` rows `<addonId>.<field>@<nodeId>` carrying `nodeId` in the row data, so a
108623
+ * forgotten node's rows go in one central delete.
108624
+ */
108625
+ async nodeSettingsSchema(_cap) {
108626
+ return null;
108627
+ }
108628
+ /**
108629
+ * D986: the form shape of the FLEET DEFAULTS of the per-device settings one capability owns,
108630
+ * or `null`. Stored as `addon-settings` rows `<addonId>.<field>`; a device resolves its value
108631
+ * as device row, then this default, then the schema default (`resolveDeviceSettings`).
108632
+ */
108633
+ async deviceDefaultsSchema(_cap) {
108634
+ return null;
108635
+ }
108636
+ /**
108637
+ * The host's read of the scoped declarations: the schema one capability declares for `scope`,
108638
+ * or `null`. Beside {@link getIntegrationFormSchema}, the same shape: the hook is protected, the
108639
+ * host asks this method.
108640
+ */
108641
+ async getScopedSettingsSchema(scope, cap) {
108642
+ switch (scope) {
108643
+ case "cluster": return this.clusterSettingsSchema(cap);
108644
+ case "node": return this.nodeSettingsSchema(cap);
108645
+ case "device-defaults": return this.deviceDefaultsSchema(cap);
108646
+ }
108647
+ }
108648
+ /**
108708
108649
  * What the create wizard and the integration edit page render for one instance: the addon's
108709
108650
  * per-instance schema when it declares one, else its global schema (what they always showed).
108710
108651
  */
@@ -109498,6 +109439,21 @@ function normalizeAddonInitResult(result) {
109498
109439
  if (Array.isArray(result)) return { providers: result };
109499
109440
  return result;
109500
109441
  }
109442
+ /** Limits and names of `ctx.records` (D881). One authority; the engine and the facade read it. */
109443
+ /** The files a kind may live in. `history` is device-history's (D850), never records. */
109444
+ var RECORD_DATABASES = ["settings", "events"];
109445
+ /** The classes of a kind: the `@durable` vocabulary, one authority (replaces the annotation). */
109446
+ var RECORD_CLASSES = [
109447
+ "config",
109448
+ "ledger",
109449
+ "audit",
109450
+ "mirror",
109451
+ "ephemeral"
109452
+ ];
109453
+ var RECORD_DEFAULT_MAX_DATA_BYTES = 64 * 1024;
109454
+ var RECORD_MAX_DATA_BYTES = 1024 * 1024;
109455
+ var RECORD_BATCH_MAX = 1e3;
109456
+ var RECORD_QUERY_MAX = 1e3;
109501
109457
  /**
109502
109458
  * The share-view scope kinds a share-link token can carry. The hub mints
109503
109459
  * and validates them (`ShareViewGrantSchema` builds its
@@ -109522,21 +109478,6 @@ var PeerBytesTicketSchema = object({
109522
109478
  */
109523
109479
  declaredBytes: number().int().nonnegative().nullable()
109524
109480
  });
109525
- /** Limits and names of `ctx.records` (D881). One authority; the engine and the facade read it. */
109526
- /** The files a kind may live in. `history` is device-history's (D850), never records. */
109527
- var RECORD_DATABASES = ["settings", "events"];
109528
- /** The classes of a kind: the `@durable` vocabulary, one authority (replaces the annotation). */
109529
- var RECORD_CLASSES = [
109530
- "config",
109531
- "ledger",
109532
- "audit",
109533
- "mirror",
109534
- "ephemeral"
109535
- ];
109536
- var RECORD_DEFAULT_MAX_DATA_BYTES = 64 * 1024;
109537
- var RECORD_MAX_DATA_BYTES = 1024 * 1024;
109538
- var RECORD_BATCH_MAX = 1e3;
109539
- var RECORD_QUERY_MAX = 1e3;
109540
109481
  function defOf(schema) {
109541
109482
  const zodBag = Reflect.get(schema, "_zod");
109542
109483
  if (typeof zodBag !== "object" || zodBag === null) return {};
@@ -110148,6 +110089,81 @@ var ProfileRtspEntrySchema = object({
110148
110089
  codec: string().optional(),
110149
110090
  resolution: CamStreamResolutionSchema.optional()
110150
110091
  });
110092
+ /** Every debug channel's name starts with this; the ring denies it by default. */
110093
+ var DEBUG_CHANNEL_PREFIX = "debug.";
110094
+ function isDebugChannelName(name) {
110095
+ return name.startsWith(DEBUG_CHANNEL_PREFIX);
110096
+ }
110097
+ /**
110098
+ * Who is watching what, in THIS process. In-memory, moved off the log path.
110099
+ *
110100
+ * `active` is a plain boolean FIELD, read first: with no watch anywhere, a
110101
+ * `ctx.debug` call is one load and one branch.
110102
+ */
110103
+ var DebugInterest = class {
110104
+ active = false;
110105
+ windows = /* @__PURE__ */ new Map();
110106
+ /**
110107
+ * Replace the interest with the FULL set of windows. Non-debug windows are
110108
+ * ignored and an absent one is dropped, so a lost disarm cannot leave a
110109
+ * channel running.
110110
+ */
110111
+ apply(windows, nowMs) {
110112
+ this.windows.clear();
110113
+ for (const window of windows) {
110114
+ if (!isDebugChannelName(window.channel) || window.armedUntilMs <= nowMs) continue;
110115
+ const ids = window.deviceIds;
110116
+ this.windows.set(window.channel, {
110117
+ untilMs: window.armedUntilMs,
110118
+ devices: ids === null || ids.length === 0 ? null : new Set(ids)
110119
+ });
110120
+ }
110121
+ this.active = this.windows.size > 0;
110122
+ }
110123
+ /** Drop what has lapsed. Called on a timer, never from a log path. */
110124
+ tick(nowMs) {
110125
+ const closed = [];
110126
+ for (const [name, window] of this.windows) if (window.untilMs <= nowMs) {
110127
+ this.windows.delete(name);
110128
+ closed.push(name);
110129
+ }
110130
+ this.active = this.windows.size > 0;
110131
+ return closed;
110132
+ }
110133
+ /** `channel` is the full name, `debug.<channel>`. */
110134
+ wants(channel, deviceId) {
110135
+ if (!this.active) return false;
110136
+ const window = this.windows.get(channel);
110137
+ if (window === void 0) return false;
110138
+ return window.devices === null || window.devices.has(deviceId);
110139
+ }
110140
+ };
110141
+ var interestInstance = null;
110142
+ function getDebugInterest() {
110143
+ interestInstance ??= new DebugInterest();
110144
+ return interestInstance;
110145
+ }
110146
+ /**
110147
+ * Build `ctx.debug`. The thunk runs ONLY when somebody watches this
110148
+ * (channel, device) pair, and the line carries `tags: { deviceId }` plus the
110149
+ * `debug.<channel>` name as `logChannel`.
110150
+ */
110151
+ function createDebugEmitter(logger, interest = getDebugInterest()) {
110152
+ return (channel, deviceId, thunk) => {
110153
+ if (!interest.active) return;
110154
+ const name = `${DEBUG_CHANNEL_PREFIX}${channel}`;
110155
+ if (!interest.wants(name, deviceId)) return;
110156
+ const line = thunk();
110157
+ const tags = {
110158
+ deviceId,
110159
+ logChannel: name
110160
+ };
110161
+ logger.info(line.message, line.meta === void 0 ? { tags } : {
110162
+ tags,
110163
+ meta: line.meta
110164
+ });
110165
+ };
110166
+ }
110151
110167
  /**
110152
110168
  * The model weight formats a catalog entry can ship. Schema-free on purpose:
110153
110169
  * `types/models.ts` builds Zod schemas from it, but a runner-closure consumer
@@ -110423,6 +110439,7 @@ var CAP_NAMES = Object.freeze([
110423
110439
  "humidity-sensor",
110424
110440
  "image",
110425
110441
  "image-settings",
110442
+ "integration-instances",
110426
110443
  "integrations",
110427
110444
  "intercom",
110428
110445
  "ir-transmitter",
@@ -112394,7 +112411,7 @@ function sleep(ms) {
112394
112411
  return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
112395
112412
  }
112396
112413
  //#endregion
112397
- //#region ../types/dist/composition-kKKOdFIG.mjs
112414
+ //#region ../types/dist/composition-Dh3RfMx5.mjs
112398
112415
  /**
112399
112416
  * The ffmpeg build every CamStack node downloads, per platform and arch.
112400
112417
  *
@@ -113608,6 +113625,94 @@ var NodeColorIdSchema = _enum([
113608
113625
  "node-g",
113609
113626
  "node-h"
113610
113627
  ]);
113628
+ //#endregion
113629
+ //#region ../types/dist/index.mjs
113630
+ /**
113631
+ * The `smtp-inbound` broker kind (D999): a mailbox a device can send mail to.
113632
+ *
113633
+ * The `broker` cap carries these as `Record<string, unknown>` on the wire; the
113634
+ * provider and its consumers parse them with the schemas below at BOTH ends, so
113635
+ * the shape is declared once.
113636
+ */
113637
+ /** The broker kind tag. */
113638
+ var SMTP_INBOUND_KIND = "smtp-inbound";
113639
+ /** The id the first mailbox is minted under (adopt-id), so a consumer can find it again. */
113640
+ var SMTP_INBOUND_DEFAULT_ID = "smtp-inbound-default";
113641
+ var SMTP_INBOUND_DEFAULT_PORT = 2525;
113642
+ var SMTP_INBOUND_DEFAULT_DOMAIN = "camstack.local";
113643
+ var SMTP_INBOUND_DEFAULT_MAX_MESSAGE_BYTES = 25 * 1024 * 1024;
113644
+ /** The text excerpt a message carries on the bus. */
113645
+ var SMTP_INBOUND_TEXT_MAX_CHARS = 4096;
113646
+ var PortSchema = number().int().min(1).max(65535);
113647
+ /** A recipient local-part: what stands before the `@`. */
113648
+ var SmtpRecipientSchema = string().min(1).max(64).regex(/^[A-Za-z0-9._+-]+$/);
113649
+ object({
113650
+ port: PortSchema.default(SMTP_INBOUND_DEFAULT_PORT),
113651
+ bindHost: string().min(1).default("0.0.0.0"),
113652
+ domain: string().min(1).default(SMTP_INBOUND_DEFAULT_DOMAIN),
113653
+ requireAuth: boolean().default(true),
113654
+ username: string().default(""),
113655
+ password: string().default("").meta({ secret: true }),
113656
+ starttls: boolean().default(false),
113657
+ maxMessageBytes: number().int().positive().default(SMTP_INBOUND_DEFAULT_MAX_MESSAGE_BYTES)
113658
+ });
113659
+ /**
113660
+ * What `getBrokerConfig` answers: what a device needs to send mail here.
113661
+ * `host` is null when the `internal` role of the network palette is not set;
113662
+ * `hostReason` then says where to set it.
113663
+ */
113664
+ var SmtpInboundConfigSchema = object({
113665
+ host: string().min(1).nullable(),
113666
+ hostReason: string().nullable(),
113667
+ port: PortSchema,
113668
+ domain: string().min(1),
113669
+ requireAuth: boolean(),
113670
+ username: string(),
113671
+ password: string(),
113672
+ starttls: boolean()
113673
+ });
113674
+ object({
113675
+ recipients: array(SmtpRecipientSchema).min(1),
113676
+ /**
113677
+ * Who is subscribing. A second `subscribe` with the same id REPLACES that subscriber's
113678
+ * recipients (the way a consumer that restarted, or lost a device, declares its set again);
113679
+ * a recipient held by another subscriber is refused with `smtp-inbound/recipient-claimed`.
113680
+ * Absent, the subscriber is anonymous and unique.
113681
+ */
113682
+ subscriberId: string().min(1).max(64).optional()
113683
+ });
113684
+ var SmtpInboundAttachmentSchema = object({
113685
+ filename: string(),
113686
+ contentType: string(),
113687
+ bytes: number().int().nonnegative()
113688
+ });
113689
+ /** The payload of one `broker.message` (the key is the recipient local-part). */
113690
+ var SmtpInboundMessageSchema = object({
113691
+ /** Monotonic per mailbox; a gap tells a consumer it missed an event. */
113692
+ seq: number().int().positive(),
113693
+ messageId: string(),
113694
+ receivedAt: number().int().nonnegative(),
113695
+ from: string(),
113696
+ to: string(),
113697
+ subject: string(),
113698
+ text: string().max(SMTP_INBOUND_TEXT_MAX_CHARS),
113699
+ attachments: array(SmtpInboundAttachmentSchema)
113700
+ });
113701
+ /** What `getState('since:<seq>')` answers: every retained message after `seq`. */
113702
+ var SmtpInboundSinceSchema = object({
113703
+ /** The newest seq issued so far (0 before any mail). */
113704
+ lastSeq: number().int().nonnegative(),
113705
+ /** The oldest seq still retained; messages below it are gone. */
113706
+ oldestSeq: number().int().nonnegative(),
113707
+ messages: array(object({
113708
+ key: string(),
113709
+ message: SmtpInboundMessageSchema
113710
+ }))
113711
+ });
113712
+ /** The key a consumer passes to `getState` to replay the ring, and its parser. */
113713
+ function smtpSinceKey(seq) {
113714
+ return `since:${seq}`;
113715
+ }
113611
113716
  new Set([
113612
113717
  "track",
113613
113718
  "face",
@@ -116773,81 +116878,6 @@ var TIMEZONES = [
116773
116878
  function findTimezone(id) {
116774
116879
  return TIMEZONES.find((tz) => tz.id === id);
116775
116880
  }
116776
- /** Every debug channel's name starts with this; the ring denies it by default. */
116777
- var DEBUG_CHANNEL_PREFIX = "debug.";
116778
- function isDebugChannelName(name) {
116779
- return name.startsWith(DEBUG_CHANNEL_PREFIX);
116780
- }
116781
- /**
116782
- * Who is watching what, in THIS process. In-memory, moved off the log path.
116783
- *
116784
- * `active` is a plain boolean FIELD, read first: with no watch anywhere, a
116785
- * `ctx.debug` call is one load and one branch.
116786
- */
116787
- var DebugInterest = class {
116788
- active = false;
116789
- windows = /* @__PURE__ */ new Map();
116790
- /**
116791
- * Replace the interest with the FULL set of windows. Non-debug windows are
116792
- * ignored and an absent one is dropped, so a lost disarm cannot leave a
116793
- * channel running.
116794
- */
116795
- apply(windows, nowMs) {
116796
- this.windows.clear();
116797
- for (const window of windows) {
116798
- if (!isDebugChannelName(window.channel) || window.armedUntilMs <= nowMs) continue;
116799
- const ids = window.deviceIds;
116800
- this.windows.set(window.channel, {
116801
- untilMs: window.armedUntilMs,
116802
- devices: ids === null || ids.length === 0 ? null : new Set(ids)
116803
- });
116804
- }
116805
- this.active = this.windows.size > 0;
116806
- }
116807
- /** Drop what has lapsed. Called on a timer, never from a log path. */
116808
- tick(nowMs) {
116809
- const closed = [];
116810
- for (const [name, window] of this.windows) if (window.untilMs <= nowMs) {
116811
- this.windows.delete(name);
116812
- closed.push(name);
116813
- }
116814
- this.active = this.windows.size > 0;
116815
- return closed;
116816
- }
116817
- /** `channel` is the full name, `debug.<channel>`. */
116818
- wants(channel, deviceId) {
116819
- if (!this.active) return false;
116820
- const window = this.windows.get(channel);
116821
- if (window === void 0) return false;
116822
- return window.devices === null || window.devices.has(deviceId);
116823
- }
116824
- };
116825
- var interestInstance = null;
116826
- function getDebugInterest() {
116827
- interestInstance ??= new DebugInterest();
116828
- return interestInstance;
116829
- }
116830
- /**
116831
- * Build `ctx.debug`. The thunk runs ONLY when somebody watches this
116832
- * (channel, device) pair, and the line carries `tags: { deviceId }` plus the
116833
- * `debug.<channel>` name as `logChannel`.
116834
- */
116835
- function createDebugEmitter(logger, interest = getDebugInterest()) {
116836
- return (channel, deviceId, thunk) => {
116837
- if (!interest.active) return;
116838
- const name = `${DEBUG_CHANNEL_PREFIX}${channel}`;
116839
- if (!interest.wants(name, deviceId)) return;
116840
- const line = thunk();
116841
- const tags = {
116842
- deviceId,
116843
- logChannel: name
116844
- };
116845
- logger.info(line.message, line.meta === void 0 ? { tags } : {
116846
- tags,
116847
- meta: line.meta
116848
- });
116849
- };
116850
- }
116851
116881
  /**
116852
116882
  * Per-component log CHANNELS — the gate a hot path consults, and the registry
116853
116883
  * an addon declares its channels in.
@@ -122524,6 +122554,7 @@ var SYSTEM_CAP_VOCABULARY = {
122524
122554
  "load-contribution": system(INTERNAL),
122525
122555
  "local-network": system(NETWORK),
122526
122556
  "log-channels": system(INTERNAL),
122557
+ "integration-instances": system(INTERNAL),
122527
122558
  "log-destination": system(INTERNAL),
122528
122559
  "login-method": system(IDENTITY),
122529
122560
  "mesh-network": system(NETWORK),
@@ -144730,7 +144761,7 @@ var TargetKindCapsSchema = object({
144730
144761
  * the union is large and not meant for runtime validation here; the exported
144731
144762
  * `TargetKind` type re-tightens `configSchema` to `ConfigUISchema`.
144732
144763
  */
144733
- var ConfigSchemaPassthrough$3 = unknown();
144764
+ var ConfigSchemaPassthrough$4 = unknown();
144734
144765
  var TargetKindSchema = object({
144735
144766
  kind: string(),
144736
144767
  label: string(),
@@ -144767,7 +144798,7 @@ var TargetKindSchema = object({
144767
144798
  * its raster path, which is the safe default for an unknown image.
144768
144799
  */
144769
144800
  iconMediaType: string().optional(),
144770
- configSchema: ConfigSchemaPassthrough$3,
144801
+ configSchema: ConfigSchemaPassthrough$4,
144771
144802
  /**
144772
144803
  * The config keys that decide WHERE this kind's secrets are sent (server,
144773
144804
  * base URL, instance, chat, recipient). A save that changes any of them
@@ -153663,10 +153694,23 @@ var DeviceDataEntrySchema = object({
153663
153694
  /** Rows (or files) held for the device in the store. */
153664
153695
  count: number().int().nonnegative()
153665
153696
  });
153666
- method(DeviceRemovedInputSchema, DeviceRemovedResultSchema, {
153667
- kind: "mutation",
153668
- auth: "admin"
153669
- }), method(_void(), array(DeviceDataEntrySchema).readonly(), { auth: "admin" });
153697
+ var deviceRemovalHookCapability = {
153698
+ name: "device-removal-hook",
153699
+ scope: "system",
153700
+ mode: "collection",
153701
+ internal: true,
153702
+ methods: {
153703
+ /** Delete everything this addon keeps for the deleted device. Idempotent. */
153704
+ onDeviceRemoved: method(DeviceRemovedInputSchema, DeviceRemovedResultSchema, {
153705
+ kind: "mutation",
153706
+ auth: "admin"
153707
+ }),
153708
+ /** Which devices this addon holds data for, per store. Reads only. */
153709
+ listDeviceData: method(_void(), array(DeviceDataEntrySchema).readonly(), { auth: "admin" })
153710
+ },
153711
+ /** In-process only — enumerated through `addons.listCapabilityProviders`. */
153712
+ mount: { kind: "skip" }
153713
+ };
153670
153714
  /**
153671
153715
  * device-runs - the durable record of ONE run of a device: a washing cycle, a
153672
153716
  * dishwasher cycle, later a mow or a clean (D992).
@@ -156529,6 +156573,89 @@ method(_void(), array(IntegrationWithStateSchema)), method(object({ id: string()
156529
156573
  auth: "admin"
156530
156574
  });
156531
156575
  /**
156576
+ * `integration-instances` - the connection of an addon that holds ONE or SEVERAL
156577
+ * (an account, a relay, a destination) as typed records (D985, D986). The addon's
156578
+ * own function page reads and writes them (Logging for Loki); the Integrations
156579
+ * page lists only the integrations that bring devices (D996).
156580
+ *
156581
+ * A collection of providers: each addon that holds connections registers one and a
156582
+ * caller reaches a provider with the pinned `addonId`.
156583
+ *
156584
+ * Identity: the instance id is immutable and opaque to the caller. A record the
156585
+ * Loki migration made keeps the `integrations` registry id it was created under
156586
+ * (`int_NNNN`); a single instance made by its function page takes the addon id.
156587
+ * `name` is the editable label.
156588
+ *
156589
+ * Secrets: every field the instance kind marks `.meta({ secret: true })` reaches
156590
+ * the form as a `SecretFieldView`, never text (D789). A patch value omitted, `''`
156591
+ * or the redaction token KEEPS the stored secret; `{ $secretClear: true }`
156592
+ * clears it.
156593
+ */
156594
+ var ConfigSchemaPassthrough$3 = custom();
156595
+ var IntegrationInstanceIdSchema = string().min(1);
156596
+ var IntegrationInstanceDescriptionSchema = object({
156597
+ /** `single`: at most one instance can exist (Loki). `multiple`: an account each. */
156598
+ instanceMode: _enum(["single", "multiple"]),
156599
+ /** What the instance is, for the UI (`destination`, `relay`, `account`). */
156600
+ kind: string(),
156601
+ /** True when `testInstance` really probes the remote. */
156602
+ canTest: boolean()
156603
+ });
156604
+ var IntegrationInstanceStatusSchema = object({
156605
+ state: _enum([
156606
+ "ok",
156607
+ "off",
156608
+ "error",
156609
+ "unknown"
156610
+ ]),
156611
+ detail: string().optional()
156612
+ });
156613
+ var IntegrationInstanceSchema = object({
156614
+ id: IntegrationInstanceIdSchema,
156615
+ name: string(),
156616
+ /** The addon's own rule (an URL is present, a client id is present...). */
156617
+ configured: boolean(),
156618
+ /**
156619
+ * The instance's own on/off switch (D996): always present, derived from the record body
156620
+ * (`enabled !== false`, so a row written before the field existed is on).
156621
+ */
156622
+ enabled: boolean(),
156623
+ /** Values for the instance form; a secret is a `SecretFieldView`, never text. */
156624
+ formValues: record(string(), unknown()),
156625
+ status: IntegrationInstanceStatusSchema.optional()
156626
+ });
156627
+ var IntegrationInstancePatchSchema = object({
156628
+ id: IntegrationInstanceIdSchema,
156629
+ name: string().min(1).optional(),
156630
+ /** Omitted / '' / redaction token keeps a secret; `{ $secretClear: true }` clears it. */
156631
+ values: record(string(), unknown())
156632
+ });
156633
+ 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({
156634
+ id: IntegrationInstanceIdSchema,
156635
+ name: string().min(1)
156636
+ }), object({ id: string() }), {
156637
+ kind: "mutation",
156638
+ auth: "admin"
156639
+ }), method(IntegrationInstancePatchSchema, object({ ok: literal(true) }), {
156640
+ kind: "mutation",
156641
+ auth: "admin"
156642
+ }), method(object({
156643
+ id: IntegrationInstanceIdSchema,
156644
+ enabled: boolean()
156645
+ }), object({ ok: literal(true) }), {
156646
+ kind: "mutation",
156647
+ auth: "admin"
156648
+ }), method(object({ id: IntegrationInstanceIdSchema }), object({ ok: literal(true) }), {
156649
+ kind: "mutation",
156650
+ auth: "admin"
156651
+ }), optionalMethod(object({
156652
+ id: IntegrationInstanceIdSchema.nullable(),
156653
+ values: record(string(), unknown())
156654
+ }), TestConnectionResultSchema$1, {
156655
+ kind: "mutation",
156656
+ auth: "admin"
156657
+ });
156658
+ /**
156532
156659
  * Two-way audio / talk-back cap. Clients open a WebRTC audio-only
156533
156660
  * session (server-initiated offer pattern, identical to the main
156534
156661
  * video WebRTC flow) and the browser captures the mic; the server
@@ -167337,7 +167464,14 @@ var NcMediaPolicySchema = object({
167337
167464
  */
167338
167465
  profile: CamProfileSchema.optional(),
167339
167466
  /** See {@link NcLinkedCameraModeSchema}. */
167340
- linkedCameras: NcLinkedCameraModeSchema.optional()
167467
+ linkedCameras: NcLinkedCameraModeSchema.optional(),
167468
+ /**
167469
+ * Sensor rules only: may this rule wake a SLEEPING battery camera to take
167470
+ * the linked still it attaches (D998)? Absent means yes, an explicit `false`
167471
+ * opts out. Deliberately not a Zod default: a rule that never chose keeps
167472
+ * following the hub's. One bounded wake per event; nothing is attached later.
167473
+ */
167474
+ wakeSleepingCamera: boolean().optional()
167341
167475
  });
167342
167476
  /**
167343
167477
  * Cooldown GRANULARITY over the subject's class — how much a fired
@@ -176500,7 +176634,16 @@ var smokeCapability = {
176500
176634
  * future fields without breaking the existing call sites.
176501
176635
  */
176502
176636
  var EmailAddressSchema = email();
176637
+ /**
176638
+ * Which relay of a multi-relay provider answers (D996). Absent = the provider's first / default
176639
+ * relay, so a caller written before relays existed keeps working unchanged. A provider with one
176640
+ * fixed relay ignores it. This is the integration instance id (`int_NNNN`), NOT the addon id: the
176641
+ * collection pin `addonId` still picks the provider, `instanceId` picks the relay inside it.
176642
+ */
176643
+ var InstanceIdSchema = string().min(1).optional();
176644
+ var RelayPinSchema = object({ instanceId: InstanceIdSchema });
176503
176645
  var SendEmailInputSchema = object({
176646
+ instanceId: InstanceIdSchema,
176504
176647
  to: union([EmailAddressSchema, array(EmailAddressSchema).min(1)]),
176505
176648
  cc: array(EmailAddressSchema).optional(),
176506
176649
  bcc: array(EmailAddressSchema).optional(),
@@ -176536,11 +176679,11 @@ method(SendEmailInputSchema, SendEmailResultSchema, {
176536
176679
  kind: "mutation",
176537
176680
  auth: "admin",
176538
176681
  access: "create"
176539
- }), method(_void(), SmtpStatusSchema, {
176682
+ }), method(RelayPinSchema, SmtpStatusSchema, {
176540
176683
  kind: "mutation",
176541
176684
  auth: "admin",
176542
176685
  access: "view"
176543
- }), method(_void(), SmtpStatusSchema, { auth: "admin" });
176686
+ }), method(RelayPinSchema, SmtpStatusSchema, { auth: "admin" });
176544
176687
  /**
176545
176688
  * A single device snapshot returned as base64 JPEG/PNG.
176546
176689
  *
@@ -176608,10 +176751,12 @@ var snapshotCapability = {
176608
176751
  * even when the cache is well within the device's
176609
176752
  * `snapshotMaxAgeS` window.
176610
176753
  *
176611
- * **`force` is an OPERATOR signal, not a freshness preference.** On a
176754
+ * **`force` is a demand with a named principal, not a freshness
176755
+ * preference** — an operator button, the scheduled refresh (D847), or a
176756
+ * notification rule that left the camera's wake allowed (D998). On a
176612
176757
  * battery camera it is the one thing that walks past the wrapper's
176613
- * sleep gate and wakes the camera, so a background caller — a poller,
176614
- * an event handler, a thumbnail — must NEVER set it. Every such caller
176758
+ * sleep gate and wakes the camera, so a poller, a thumbnail or a
176759
+ * prestream must NEVER set it. Every such caller
176615
176760
  * gets the cached frame, which on a sleeping battery camera is the
176616
176761
  * correct answer: stale but honest beats woken.
176617
176762
  */
@@ -181954,7 +182099,7 @@ function withClipEventLabels(provider, deps) {
181954
182099
  listClips
181955
182100
  };
181956
182101
  }
181957
- DeviceFeature.FanSpeed, DeviceFeature.FanPreset, DeviceFeature.FanDirection, DeviceFeature.FanOscillating, DeviceFeature.CoverPositionable, DeviceFeature.CoverTilt, DeviceFeature.ValvePositionable, DeviceFeature.MediaPlayerVolume, DeviceFeature.ClimateFanMode;
182102
+ DeviceFeature.FanSpeed, DeviceFeature.FanPreset, DeviceFeature.FanDirection, DeviceFeature.FanOscillating, DeviceFeature.CoverStoppable, DeviceFeature.CoverPositionable, DeviceFeature.CoverTilt, DeviceFeature.ValvePositionable, DeviceFeature.MediaPlayerVolume, DeviceFeature.ClimateFanMode;
181958
182103
  SOURCE_CAPS.filter((cap) => cap !== "switch" && cap !== "motion");
181959
182104
  String.raw`rose\(active(\d+), changed(\d+), (\d+(?:\.\d+)?)\)`;
181960
182105
  DeviceRole.BinarySensor, DeviceRole.MotionSensor;
@@ -187152,6 +187297,69 @@ Object.freeze({
187152
187297
  access: "create",
187153
187298
  role: "admin"
187154
187299
  },
187300
+ "integrationInstances.createInstance": {
187301
+ capName: "integration-instances",
187302
+ capScope: "system",
187303
+ addonId: null,
187304
+ access: "create",
187305
+ role: "admin"
187306
+ },
187307
+ "integrationInstances.deleteInstance": {
187308
+ capName: "integration-instances",
187309
+ capScope: "system",
187310
+ addonId: null,
187311
+ access: "delete",
187312
+ role: "admin"
187313
+ },
187314
+ "integrationInstances.describe": {
187315
+ capName: "integration-instances",
187316
+ capScope: "system",
187317
+ addonId: null,
187318
+ access: "view",
187319
+ role: "admin"
187320
+ },
187321
+ "integrationInstances.getInstance": {
187322
+ capName: "integration-instances",
187323
+ capScope: "system",
187324
+ addonId: null,
187325
+ access: "view",
187326
+ role: "admin"
187327
+ },
187328
+ "integrationInstances.instanceFormSchema": {
187329
+ capName: "integration-instances",
187330
+ capScope: "system",
187331
+ addonId: null,
187332
+ access: "view",
187333
+ role: "admin"
187334
+ },
187335
+ "integrationInstances.listInstances": {
187336
+ capName: "integration-instances",
187337
+ capScope: "system",
187338
+ addonId: null,
187339
+ access: "view",
187340
+ role: "admin"
187341
+ },
187342
+ "integrationInstances.setInstanceEnabled": {
187343
+ capName: "integration-instances",
187344
+ capScope: "system",
187345
+ addonId: null,
187346
+ access: "create",
187347
+ role: "admin"
187348
+ },
187349
+ "integrationInstances.testInstance": {
187350
+ capName: "integration-instances",
187351
+ capScope: "system",
187352
+ addonId: null,
187353
+ access: "create",
187354
+ role: "admin"
187355
+ },
187356
+ "integrationInstances.updateInstance": {
187357
+ capName: "integration-instances",
187358
+ capScope: "system",
187359
+ addonId: null,
187360
+ access: "create",
187361
+ role: "admin"
187362
+ },
187155
187363
  "integrations.create": {
187156
187364
  capName: "integrations",
187157
187365
  capScope: "system",
@@ -205787,7 +205995,7 @@ var ReolinkHubSession = class {
205787
205995
  this.getApi().catch((err) => {
205788
205996
  this.#logger.warn("Reolink hub initial connect failed — will retry", {
205789
205997
  tags: { integrationId: this.integrationId },
205790
- meta: { error: errMsg$2(err) }
205998
+ meta: { error: errMsg$3(err) }
205791
205999
  });
205792
206000
  });
205793
206001
  }
@@ -205806,7 +206014,7 @@ var ReolinkHubSession = class {
205806
206014
  this.getApi().catch((err) => {
205807
206015
  this.#logger.warn("Reolink hub re-dial after settings change failed", {
205808
206016
  tags: { integrationId: this.integrationId },
205809
- meta: { error: errMsg$2(err) }
206017
+ meta: { error: errMsg$3(err) }
205810
206018
  });
205811
206019
  });
205812
206020
  }
@@ -205867,7 +206075,7 @@ var ReolinkHubSession = class {
205867
206075
  api.client.on("error", (err) => {
205868
206076
  this.#logger.warn("Reolink hub socket error — scheduling reconnect", {
205869
206077
  tags: { integrationId: this.integrationId },
205870
- meta: { error: errMsg$2(err) }
206078
+ meta: { error: errMsg$3(err) }
205871
206079
  });
205872
206080
  this.#scheduleReconnect("socket-error");
205873
206081
  });
@@ -205936,7 +206144,7 @@ var ReolinkHubSession = class {
205936
206144
  isMultifocal: d.isMultifocal === true
205937
206145
  }));
205938
206146
  } catch (err) {
205939
- lastError = errMsg$2(err);
206147
+ lastError = errMsg$3(err);
205940
206148
  this.#logger.warn("Reolink hub channel refresh failed", {
205941
206149
  tags: { integrationId: this.integrationId },
205942
206150
  meta: { error: lastError }
@@ -206010,7 +206218,7 @@ var ReolinkHubSession = class {
206010
206218
  meta: {
206011
206219
  from: repair.from,
206012
206220
  to: repair.to,
206013
- error: errMsg$2(err)
206221
+ error: errMsg$3(err)
206014
206222
  }
206015
206223
  });
206016
206224
  }
@@ -206123,7 +206331,7 @@ var ReolinkHubSession = class {
206123
206331
  } catch (err) {
206124
206332
  this.#logger.debug("Reolink hub: per-channel battery refresh failed", {
206125
206333
  tags: { integrationId: this.integrationId },
206126
- meta: { error: errMsg$2(err) }
206334
+ meta: { error: errMsg$3(err) }
206127
206335
  });
206128
206336
  }
206129
206337
  }
@@ -206171,7 +206379,7 @@ var ReolinkHubSession = class {
206171
206379
  tags: { deviceId: child.id },
206172
206380
  meta: {
206173
206381
  channel,
206174
- error: errMsg$2(err)
206382
+ error: errMsg$3(err)
206175
206383
  }
206176
206384
  });
206177
206385
  }
@@ -209197,7 +209405,7 @@ var IntercomOrchestrator = class {
209197
209405
  if (this.opts.wakeBeforeStart) try {
209198
209406
  await this.opts.wakeBeforeStart();
209199
209407
  } catch (err) {
209200
- this.opts.logger.warn("intercom: pre-wake failed", { meta: { error: errMsg$1(err) } });
209408
+ this.opts.logger.warn("intercom: pre-wake failed", { meta: { error: errMsg$2(err) } });
209201
209409
  throw err;
209202
209410
  }
209203
209411
  const talkSession = new ReolinkIntercomSession({
@@ -209212,7 +209420,7 @@ var IntercomOrchestrator = class {
209212
209420
  try {
209213
209421
  await talkSession.start();
209214
209422
  } catch (err) {
209215
- this.opts.logger.warn("intercom: talk session open failed", { meta: { error: errMsg$1(err) } });
209423
+ this.opts.logger.warn("intercom: talk session open failed", { meta: { error: errMsg$2(err) } });
209216
209424
  throw err;
209217
209425
  }
209218
209426
  const realCameraRate = talkSession.sampleRate;
@@ -209231,7 +209439,7 @@ var IntercomOrchestrator = class {
209231
209439
  } catch (err) {
209232
209440
  await talkSession.stop().catch(() => {});
209233
209441
  this.opts.logger.warn("intercom: audio-codec createDecodeSession failed", { meta: {
209234
- error: errMsg$1(err),
209442
+ error: errMsg$2(err),
209235
209443
  opusRate,
209236
209444
  opusChannels,
209237
209445
  targetRate
@@ -209253,7 +209461,7 @@ var IntercomOrchestrator = class {
209253
209461
  this.session = active;
209254
209462
  peer.onOpusFrame((frame, pts) => {
209255
209463
  this.handleOpusFrame(active, frame, pts).catch((err) => {
209256
- this.opts.logger.debug("intercom: opus frame pump error (dropped)", { meta: { error: errMsg$1(err) } });
209464
+ this.opts.logger.debug("intercom: opus frame pump error (dropped)", { meta: { error: errMsg$2(err) } });
209257
209465
  });
209258
209466
  });
209259
209467
  let offer;
@@ -209261,7 +209469,7 @@ var IntercomOrchestrator = class {
209261
209469
  offer = await peer.createOffer();
209262
209470
  } catch (err) {
209263
209471
  await this.stop(sessionId, "start-failed-cleanup").catch(() => {});
209264
- this.opts.logger.warn("intercom: webrtc createOffer failed", { meta: { error: errMsg$1(err) } });
209472
+ this.opts.logger.warn("intercom: webrtc createOffer failed", { meta: { error: errMsg$2(err) } });
209265
209473
  throw err;
209266
209474
  }
209267
209475
  this.opts.logger.info("intercom session opened", { meta: {
@@ -209310,7 +209518,7 @@ var IntercomOrchestrator = class {
209310
209518
  } catch (err) {
209311
209519
  this.opts.logger.debug("intercom: peer.close error (continuing)", { meta: {
209312
209520
  sessionId,
209313
- error: errMsg$1(err)
209521
+ error: errMsg$2(err)
209314
209522
  } });
209315
209523
  }
209316
209524
  try {
@@ -209321,7 +209529,7 @@ var IntercomOrchestrator = class {
209321
209529
  } catch (err) {
209322
209530
  this.opts.logger.debug("intercom: audio-codec.closeSession error (continuing)", { meta: {
209323
209531
  sessionId,
209324
- error: errMsg$1(err)
209532
+ error: errMsg$2(err)
209325
209533
  } });
209326
209534
  }
209327
209535
  try {
@@ -209329,7 +209537,7 @@ var IntercomOrchestrator = class {
209329
209537
  } catch (err) {
209330
209538
  this.opts.logger.debug("intercom: talk-session.stop error (continuing)", { meta: {
209331
209539
  sessionId,
209332
- error: errMsg$1(err)
209540
+ error: errMsg$2(err)
209333
209541
  } });
209334
209542
  }
209335
209543
  this.opts.logger.info("intercom session closed", { meta: {
@@ -209385,7 +209593,7 @@ var IntercomOrchestrator = class {
209385
209593
  function generateSessionId() {
209386
209594
  return `intercom-${Date.now().toString(36)}-${Math.floor(Math.random() * 16777215).toString(36)}`;
209387
209595
  }
209388
- function errMsg$1(err) {
209596
+ function errMsg$2(err) {
209389
209597
  return err instanceof Error ? err.message : String(err);
209390
209598
  }
209391
209599
  //#endregion
@@ -209444,7 +209652,7 @@ var WeriftIntercomPeer = class {
209444
209652
  for (const cb of this.opusCallbacks) try {
209445
209653
  cb(payload, ptsMs);
209446
209654
  } catch (err) {
209447
- this.opts.logger.debug("intercom-peer: opus callback threw", { meta: { error: errMsg(err) } });
209655
+ this.opts.logger.debug("intercom-peer: opus callback threw", { meta: { error: errMsg$1(err) } });
209448
209656
  }
209449
209657
  });
209450
209658
  if (rtpSub && typeof rtpSub.unsubscribe === "function") this.rtpUnsubscribe = () => rtpSub.unsubscribe?.();
@@ -209492,7 +209700,7 @@ var WeriftIntercomPeer = class {
209492
209700
  }
209493
209701
  }
209494
209702
  };
209495
- function errMsg(err) {
209703
+ function errMsg$1(err) {
209496
209704
  return err instanceof Error ? err.message : String(err);
209497
209705
  }
209498
209706
  //#endregion
@@ -210968,7 +211176,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
210968
211176
  * `true` iff this camera can run its own email/SMTP push path. Hub
210969
211177
  * children are excluded: they share the parent's TCP socket and have
210970
211178
  * no independent email config, so the NVR/Hub handles email centrally.
210971
- * Consumed by the provider-level SMTP intake (`email-push-server.ts`)
211179
+ * Consumed by the camera mailbox bridge (`email-push-bridge.ts`)
210972
211180
  * to decide which cameras accept inbound mail + can be auto-configured.
210973
211181
  */
210974
211182
  get isEmailPushEligible() {
@@ -211674,12 +211882,12 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
211674
211882
  }
211675
211883
  if (!this.emailPushParamsResolver) {
211676
211884
  this.ctx.logger.warn("email-push: resolver not wired — restart the Reolink addon", { tags: { deviceId: this.id } });
211677
- throw userError("reolink.email-push-not-wired", void 0, "Email Push Server is not wired — restart the Reolink addon");
211885
+ throw userError("reolink.email-push-not-wired", void 0, "The camera mailbox is not wired — restart the Reolink addon");
211678
211886
  }
211679
211887
  const params = await this.emailPushParamsResolver();
211680
211888
  if (!params) {
211681
- this.ctx.logger.warn("email-push: no manager params — enable the server + reachable LAN host", { tags: { deviceId: this.id } });
211682
- throw new Error("Enable the Email Push Server in the Reolink addon settings, and ensure this host has a reachable LAN address.");
211889
+ this.ctx.logger.warn("email-push: no manager params — this camera cannot use the camera mailbox", { tags: { deviceId: this.id } });
211890
+ throw new Error("This camera cannot use the camera mailbox.");
211683
211891
  }
211684
211892
  const triggers = this.config.get("emailPushTriggers") ?? [
211685
211893
  "MD",
@@ -218663,15 +218871,6 @@ function mapInferredTypeToSimpleEvents(inferred) {
218663
218871
  default: return ["motion"];
218664
218872
  }
218665
218873
  }
218666
- /** Default SMTP listen port. Avoid privileged 25; Reolink firmwares are
218667
- * happy on any port the email config points at. */
218668
- var DEFAULT_EMAIL_PUSH_PORT = 2525;
218669
- /** Virtual mail domain — only used to extract the camera id from the
218670
- * recipient (`cam-<id>@<domain>`). Never resolved via DNS. */
218671
- var DEFAULT_EMAIL_PUSH_DOMAIN = "camstack.local";
218672
- /** Reolink snapshots fit comfortably; 25 MB covers video attachments
218673
- * too without letting a runaway upload exhaust memory. */
218674
- var DEFAULT_EMAIL_PUSH_MAX_MESSAGE_BYTES = 25 * 1024 * 1024;
218675
218874
  /**
218676
218875
  * Enumerate candidate camera-facing LAN addresses, best first (private
218677
218876
  * RFC-1918 ranges ranked ahead of public). Link-local and internal
@@ -218698,15 +218897,15 @@ function listLanHosts() {
218698
218897
  }
218699
218898
  //#endregion
218700
218899
  //#region src/baichuan-webhook-server.ts
218701
- function readString$1(raw, key, fallback) {
218900
+ function readString(raw, key, fallback) {
218702
218901
  const v = raw[key];
218703
218902
  return typeof v === "string" && v.length > 0 ? v : fallback;
218704
218903
  }
218705
- function readNumber$1(raw, key, fallback) {
218904
+ function readNumber(raw, key, fallback) {
218706
218905
  const v = raw[key];
218707
218906
  return typeof v === "number" && Number.isFinite(v) ? v : fallback;
218708
218907
  }
218709
- function readBoolean$1(raw, key, fallback) {
218908
+ function readBoolean(raw, key, fallback) {
218710
218909
  const v = raw[key];
218711
218910
  return typeof v === "boolean" ? v : fallback;
218712
218911
  }
@@ -218722,9 +218921,9 @@ var ReolinkBaichuanWebhookServer = class {
218722
218921
  if (this.settings) return this.settings;
218723
218922
  const raw = await this.deps.readStore();
218724
218923
  this.settings = {
218725
- enabled: readBoolean$1(raw, "baichuanWebhookEnabled", true),
218726
- port: readNumber$1(raw, "baichuanWebhookPort", DEFAULT_BAICHUAN_WEBHOOK_PORT),
218727
- bindHost: readString$1(raw, "baichuanWebhookBindHost", "0.0.0.0")
218924
+ enabled: readBoolean(raw, "baichuanWebhookEnabled", true),
218925
+ port: readNumber(raw, "baichuanWebhookPort", DEFAULT_BAICHUAN_WEBHOOK_PORT),
218926
+ bindHost: readString(raw, "baichuanWebhookBindHost", "0.0.0.0")
218728
218927
  };
218729
218928
  return this.settings;
218730
218929
  }
@@ -219031,338 +219230,420 @@ function buildCreationFormSchema() {
219031
219230
  ] };
219032
219231
  }
219033
219232
  //#endregion
219034
- //#region src/email-push-schema.ts
219035
- /**
219036
- * The ids of the sections above, in render order — the value the addon hands
219037
- * to `BaseAddon.integrationSettingSections()`. Exported so the declaration and
219038
- * the schema cannot drift: core throws if an id here names no section.
219039
- */
219040
- var EMAIL_PUSH_SECTION_IDS = ["email-push", "email-push-auth"];
219041
- /**
219042
- * Addon-level settings UI for the Reolink email/SMTP push intake.
219043
- *
219044
- * Rendered under the provider's addon settings (Cluster → addon config).
219045
- * Edits land in the addon store via `updateGlobalSettings`; the provider
219046
- * restarts the SMTP server on change. The recommended camera-facing host
219047
- * is rendered read-only so the operator knows what to point cameras at if
219048
- * they configure email manually instead of using Auto-configure.
219049
- *
219050
- * `requiresRestart` is intentionally NOT set — the provider handles the
219051
- * restart itself in `onConfigChanged` (a targeted SMTP rebind, not a full
219052
- * addon teardown).
219053
- *
219054
- * ── These sections ARE the Reolink integration's configuration ──────────
219055
- *
219056
- * Both are declared in `ReolinkAddon.integrationSettingSections()`, so core
219057
- * serves them on System → Integrations → Reolink. The operator looks for "the
219058
- * Reolink SMTP port" under Reolink; this puts it there.
219059
- *
219060
- * They EARN that placement, and the reason is the test any future declaration
219061
- * must pass: they configure ONE SMTP server in ONE provider process, serving
219062
- * every Reolink camera at once. There is no per-camera copy to confuse them
219063
- * with, which is exactly why a per-camera home would be nonsense and a
219064
- * per-integration home is correct. A setting that differs per camera belongs
219065
- * in `deviceSettingsSchema`, never here.
219066
- *
219067
- * The declaration does not move the values: they remain the same bare keys in
219068
- * the same addon store, still reachable from System → Settings, still written
219069
- * by `updateGlobalSettings` — there is no integration-specific writer at all.
219070
- * See ADR-0269.
219071
- *
219072
- * `tab` is deliberately NOT used to express this. It means "how to group this
219073
- * visually", and an earlier design that overloaded it put the literal word
219074
- * "integration" into an operator-facing tab bar (D268 → D269).
219075
- */
219076
- function buildEmailPushSettingsSchema(recommendedHost) {
219077
- return { sections: [{
219078
- id: "email-push",
219079
- title: "Battery-cam Motion (Email/SMTP Push)",
219080
- titleKey: "provider-reolink:emailPushSettings.s_email-push.title",
219081
- description: "Lets battery cameras report motion by email without being woken.",
219082
- descriptionKey: "provider-reolink:emailPushSettings.s_email-push.description",
219083
- columns: 2,
219084
- fields: [
219085
- {
219086
- type: "boolean",
219087
- key: "emailPushEnabled",
219088
- label: "Enable Email Push Server",
219089
- labelKey: "provider-reolink:emailPushSettings.s_email-push.emailPushEnabled.label",
219090
- default: true
219091
- },
219092
- {
219093
- type: "info",
219094
- key: "emailPushRecommendedHost",
219095
- label: "Camera-facing host",
219096
- labelKey: "provider-reolink:emailPushSettings.s_email-push.emailPushRecommendedHost.label",
219097
- content: `Point cameras at this host: ${recommendedHost}`
219098
- },
219099
- {
219100
- type: "number",
219101
- key: "emailPushPort",
219102
- label: "SMTP Port",
219103
- labelKey: "provider-reolink:emailPushSettings.s_email-push.emailPushPort.label",
219104
- default: DEFAULT_EMAIL_PUSH_PORT,
219105
- min: 1,
219106
- max: 65535
219107
- },
219108
- {
219109
- type: "text",
219110
- key: "emailPushDomain",
219111
- label: "Virtual Domain",
219112
- labelKey: "provider-reolink:emailPushSettings.s_email-push.emailPushDomain.label",
219113
- default: DEFAULT_EMAIL_PUSH_DOMAIN,
219114
- placeholder: DEFAULT_EMAIL_PUSH_DOMAIN
219115
- }
219116
- ]
219117
- }, {
219118
- id: "email-push-auth",
219119
- title: "Email Push Authentication",
219120
- titleKey: "provider-reolink:emailPushSettings.s_email-push-auth.title",
219121
- description: "Random values are generated on first boot; re-run Auto-configure on cameras after changing them.",
219122
- descriptionKey: "provider-reolink:emailPushSettings.s_email-push-auth.description",
219123
- columns: 2,
219124
- fields: [
219125
- {
219126
- type: "boolean",
219127
- key: "emailPushRequireAuth",
219128
- label: "Require AUTH",
219129
- labelKey: "provider-reolink:emailPushSettings.s_email-push-auth.emailPushRequireAuth.label",
219130
- default: true
219131
- },
219132
- {
219133
- type: "text",
219134
- key: "emailPushAuthUsername",
219135
- label: "Auth Username",
219136
- labelKey: "provider-reolink:emailPushSettings.s_email-push-auth.emailPushAuthUsername.label"
219233
+ //#region src/email-push-bridge.ts
219234
+ /**
219235
+ * The Reolink side of the `smtp-inbound` broker kind (D999).
219236
+ *
219237
+ * Battery cameras report motion by mail. The mailbox they mail is a `broker` of kind
219238
+ * `smtp-inbound` owned by `smtp-nodemailer`; this bridge
219239
+ * - creates it on first need (`ensureMailbox`: one in-process promise, adopt-id, never at a bare boot),
219240
+ * - declares the `cam-<stableId>` recipients of the standalone cameras (`sync`),
219241
+ * - turns each `broker.message` into the camera's `handleSimpleEvent({ source: 'email' })`,
219242
+ * - replays what the bus lost from the broker's ring (seq gap, a broker that came back, subscribe).
219243
+ * It never deletes the mailbox: the operator owns it, and the bridge recreates it at the next need.
219244
+ * Events are telemetry (D8); the ring is the RPC reconcile.
219245
+ */
219246
+ /** The addon that owns `smtp-inbound` mailboxes: routes the id-less calls (`add`). */
219247
+ var SMTP_BROKER_ADDON_ID = "smtp-nodemailer";
219248
+ /** Who this addon is, among the subscribers of a mailbox. */
219249
+ var REOLINK_SUBSCRIBER_ID = "provider-reolink";
219250
+ var MAILBOX_NAME = "Camera mailbox";
219251
+ var RECIPIENT_PREFIX = "cam-";
219252
+ /** The real port: `ctx.api.broker`, every call routed to the addon that owns the kind. */
219253
+ function createBrokerPort(api) {
219254
+ const route = { addonId: SMTP_BROKER_ADDON_ID };
219255
+ return {
219256
+ list: async () => api.broker.list.query({ kind: SMTP_INBOUND_KIND }),
219257
+ add: async ({ id, name, settings }) => api.broker.add.mutate({
219258
+ kind: SMTP_INBOUND_KIND,
219259
+ name,
219260
+ id,
219261
+ settings: { ...settings },
219262
+ ...route
219263
+ }),
219264
+ getBrokerConfig: async (id) => api.broker.getBrokerConfig.query({
219265
+ id,
219266
+ ...route
219267
+ }),
219268
+ subscribe: async (brokerId, recipients) => api.broker.subscribe.mutate({
219269
+ brokerId,
219270
+ filter: {
219271
+ recipients: [...recipients],
219272
+ subscriberId: REOLINK_SUBSCRIBER_ID
219137
219273
  },
219138
- {
219139
- type: "password",
219140
- key: "emailPushAuthPassword",
219141
- label: "Auth Password",
219142
- labelKey: "provider-reolink:emailPushSettings.s_email-push-auth.emailPushAuthPassword.label",
219143
- showToggle: true
219144
- }
219145
- ]
219146
- }] };
219147
- }
219148
- //#endregion
219149
- //#region src/email-push-server.ts
219150
- /** Reads a typed value from the raw addon store with a fallback. */
219151
- function readString(raw, key, fallback) {
219152
- const v = raw[key];
219153
- return typeof v === "string" && v.length > 0 ? v : fallback;
219274
+ ...route
219275
+ }),
219276
+ unsubscribe: async (brokerId, subscriptionId) => api.broker.unsubscribe.mutate({
219277
+ brokerId,
219278
+ subscriptionId,
219279
+ ...route
219280
+ }),
219281
+ getState: async (brokerId, key) => api.broker.getState.query({
219282
+ brokerId,
219283
+ key,
219284
+ ...route
219285
+ })
219286
+ };
219154
219287
  }
219155
- function readNumber(raw, key, fallback) {
219156
- const v = raw[key];
219157
- return typeof v === "number" && Number.isFinite(v) ? v : fallback;
219288
+ var BrokerMessageEventSchema = object({
219289
+ brokerId: string(),
219290
+ subscriptionId: string(),
219291
+ key: string(),
219292
+ payload: unknown()
219293
+ });
219294
+ var ReadyStateEventSchema = object({
219295
+ capName: string(),
219296
+ state: string()
219297
+ });
219298
+ var BrokerStatusEventSchema = object({
219299
+ brokerId: string(),
219300
+ status: string(),
219301
+ error: string().optional()
219302
+ });
219303
+ function errMsg(err) {
219304
+ return err instanceof Error ? err.message : String(err);
219158
219305
  }
219159
- function readBoolean(raw, key, fallback) {
219160
- const v = raw[key];
219161
- return typeof v === "boolean" ? v : fallback;
219306
+ function isInUse(err) {
219307
+ return /already in use/i.test(errMsg(err));
219162
219308
  }
219163
- /**
219164
- * Owns the lib's SMTP intake for the Reolink addon. Lifecycle:
219165
- * - `start()` from the provider's `onInitialize` (no-op when disabled).
219166
- * - `restart()` from `onConfigChanged` so port/domain/auth edits apply.
219167
- * - `stop()` from the provider's `onShutdown`.
219168
- */
219169
- var ReolinkEmailPushServer = class {
219309
+ var EmailPushBridge = class {
219170
219310
  deps;
219171
- instance;
219172
- busOff;
219173
- settings;
219311
+ mailbox = null;
219312
+ brokerId = null;
219313
+ subscriptionId = null;
219314
+ /** The recipient set the broker holds for this addon, as a key. */
219315
+ declared = null;
219316
+ lastSeq = 0;
219317
+ /** The seq baseline of the current mailbox was taken (before the first subscribe). */
219318
+ seeded = false;
219319
+ /** The last failure that was reported, so a mailbox provider that stays away is said once. */
219320
+ lastFailure = null;
219321
+ chain = Promise.resolve();
219174
219322
  constructor(deps) {
219175
219323
  this.deps = deps;
219176
219324
  }
219177
- /**
219178
- * Hydrate the persisted settings, bootstrapping random AUTH
219179
- * credentials on first use so the server is never an open relay.
219180
- * Cached after the first resolution; `reloadSettings` forces a re-read.
219181
- */
219182
- async resolveSettings() {
219183
- if (this.settings) return this.settings;
219184
- const raw = await this.deps.readStore();
219185
- let authUsername = readString(raw, "emailPushAuthUsername", "");
219186
- let authPassword = readString(raw, "emailPushAuthPassword", "");
219187
- const passwordUnresolved = isSecretMarkerLike(raw["emailPushAuthPassword"]);
219188
- const bootstrapped = {};
219189
- if (!authUsername) {
219190
- authUsername = `camstack-${(0, node_crypto.randomBytes)(4).toString("hex")}`;
219191
- bootstrapped.emailPushAuthUsername = authUsername;
219192
- }
219193
- if (!authPassword) {
219194
- authPassword = (0, node_crypto.randomBytes)(18).toString("base64url");
219195
- 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");
219196
- else bootstrapped.emailPushAuthPassword = authPassword;
219197
- }
219198
- if (Object.keys(bootstrapped).length > 0) try {
219199
- await this.deps.writeStore(bootstrapped);
219325
+ /** The id of the mailbox, creating it when none exists. One in-flight promise. */
219326
+ ensureMailbox() {
219327
+ this.mailbox ??= this.findOrCreate().catch((err) => {
219328
+ this.mailbox = null;
219329
+ throw err;
219330
+ });
219331
+ return this.mailbox;
219332
+ }
219333
+ async findOrCreate() {
219334
+ const found = pickMailbox(await this.deps.port.list());
219335
+ if (found !== null) {
219336
+ await this.deps.pending?.clear();
219337
+ return found;
219338
+ }
219339
+ const pending = await this.deps.pending?.read() ?? null;
219340
+ try {
219341
+ const created = await this.deps.port.add({
219342
+ id: SMTP_INBOUND_DEFAULT_ID,
219343
+ name: MAILBOX_NAME,
219344
+ ...pending === null ? {} : { settings: pending }
219345
+ });
219346
+ await this.deps.pending?.clear();
219347
+ this.deps.logger.info("email-push: created the camera mailbox", { meta: {
219348
+ brokerId: created.id,
219349
+ carriedLegacySettings: pending !== null
219350
+ } });
219351
+ return created.id;
219200
219352
  } catch (err) {
219201
- this.deps.logger.warn("email-push: failed to persist bootstrapped credentials", { meta: { error: err instanceof Error ? err.message : String(err) } });
219353
+ if (!isInUse(err)) throw err;
219354
+ const again = pickMailbox(await this.deps.port.list());
219355
+ if (again === null) throw err;
219356
+ await this.deps.pending?.clear();
219357
+ return again;
219202
219358
  }
219203
- this.settings = {
219204
- enabled: readBoolean(raw, "emailPushEnabled", true),
219205
- port: readNumber(raw, "emailPushPort", DEFAULT_EMAIL_PUSH_PORT),
219206
- bindHost: readString(raw, "emailPushBindHost", "0.0.0.0"),
219207
- domain: readString(raw, "emailPushDomain", DEFAULT_EMAIL_PUSH_DOMAIN),
219208
- requireAuth: readBoolean(raw, "emailPushRequireAuth", true),
219209
- authUsername,
219210
- authPassword,
219211
- maxMessageBytes: readNumber(raw, "emailPushMaxMessageBytes", DEFAULT_EMAIL_PUSH_MAX_MESSAGE_BYTES)
219212
- };
219213
- return this.settings;
219214
219359
  }
219215
- /** Drop the cached settings so the next start/restart re-reads the
219216
- * store (used by `restart`). */
219217
- reloadSettings() {
219218
- this.settings = void 0;
219219
- }
219220
- buildConfig(settings) {
219221
- return {
219222
- port: settings.port,
219223
- bindHost: settings.bindHost,
219224
- domain: settings.domain,
219225
- requireAuth: settings.requireAuth,
219226
- authUsername: settings.authUsername,
219227
- authPassword: settings.authPassword,
219228
- tls: false,
219229
- maxMessageBytes: settings.maxMessageBytes
219230
- };
219231
- }
219232
- /**
219233
- * Resolve a recipient local-part (`<id>`, already stripped of the
219234
- * `cam-` prefix by the lib) to a camera id. Returns the matching
219235
- * camera's `stableId` (which the lib echoes back as `event.cameraId`)
219236
- * or `undefined` to reject the recipient at RCPT TO.
219237
- *
219238
- * Hub-attached children are skipped: they share the parent's TCP
219239
- * socket and have no independent email path, so accepting one would
219240
- * silently mis-route. Match is on `stableId` (durable across
219241
- * re-adoption) — the same value `buildCameraSetupParams` writes into
219242
- * the camera's email config.
219243
- */
219244
- resolveCameraId(localPart) {
219245
- const cam = this.findCameraByStableId(localPart);
219246
- if (!cam || !cam.isEmailPushEligible) return void 0;
219247
- return localPart;
219360
+ recipients() {
219361
+ return this.deps.listCameras().filter((cam) => cam.isEmailPushEligible).map((cam) => `${RECIPIENT_PREFIX}${cam.stableId}`).sort();
219248
219362
  }
219249
- findCameraByStableId(stableId) {
219250
- return this.deps.listCameras().find((c) => c.stableId === stableId);
219363
+ enqueue(job) {
219364
+ const run = this.chain.then(job);
219365
+ this.chain = run.then(() => void 0, () => void 0);
219366
+ return run;
219251
219367
  }
219252
- /** Route an accepted email-push event into the matched camera's unified
219253
- * simple-event handler. */
219254
- dispatchEvent(event) {
219255
- const cam = this.findCameraByStableId(event.cameraId);
219256
- if (!cam) {
219257
- this.deps.logger.warn("email-push: event for unknown camera id — dropping", { meta: {
219258
- cameraId: event.cameraId,
219259
- inferredType: event.inferredType
219260
- } });
219261
- return;
219262
- }
219263
- this.deps.logger.info("email-push: motion received from battery camera", {
219264
- tags: { deviceId: cam.id },
219265
- meta: {
219266
- inferredType: event.inferredType,
219267
- subject: event.subject.slice(0, 80)
219368
+ /**
219369
+ * Declare the recipients of the eligible cameras (again). Cheap when nothing changed.
219370
+ * A failure is logged, never thrown: a camera that cannot be declared does not stop the addon.
219371
+ */
219372
+ sync(options = {}) {
219373
+ return this.enqueue(async () => {
219374
+ try {
219375
+ await this.doSync(options.force === true);
219376
+ this.lastFailure = null;
219377
+ } catch (err) {
219378
+ this.declared = null;
219379
+ this.subscriptionId = null;
219380
+ const message = errMsg(err);
219381
+ if (message !== this.lastFailure) {
219382
+ this.lastFailure = message;
219383
+ this.deps.logger.warn("email-push: the camera mailbox is not available; battery cameras cannot mail motion until it is", { meta: { error: message } });
219384
+ }
219268
219385
  }
219269
219386
  });
219270
- for (const type of mapInferredTypeToSimpleEvents(event.inferredType)) cam.handleSimpleEvent({
219271
- type,
219272
- channel: cam.emailPushChannel,
219273
- timestamp: event.receivedAtMs,
219274
- source: "email"
219275
- });
219276
219387
  }
219277
- /** Start the SMTP intake when enabled. Idempotent: a second call while
219278
- * running re-applies config without rebinding. */
219279
- async start() {
219280
- const settings = await this.resolveSettings();
219281
- if (!settings.enabled) {
219282
- this.deps.logger.debug("email-push: disabled — SMTP intake not started");
219388
+ async doSync(force) {
219389
+ const recipients = this.recipients();
219390
+ if (recipients.length === 0) {
219391
+ if (this.brokerId !== null && this.subscriptionId !== null) await this.deps.port.unsubscribe(this.brokerId, this.subscriptionId);
219392
+ this.subscriptionId = null;
219393
+ this.declared = null;
219283
219394
  return;
219284
219395
  }
219285
- if (this.instance) {
219286
- this.instance.updateConfig(this.buildConfig(settings));
219396
+ const key = recipients.join(",");
219397
+ if (!force && this.declared === key && this.subscriptionId !== null) return;
219398
+ const brokerId = await this.ensureMailbox();
219399
+ await this.seedBaseline(brokerId);
219400
+ const { subscriptionId } = await this.deps.port.subscribe(brokerId, recipients);
219401
+ this.brokerId = brokerId;
219402
+ this.subscriptionId = subscriptionId;
219403
+ this.declared = key;
219404
+ await this.reconcile();
219405
+ }
219406
+ /**
219407
+ * What the mailbox retains from BEFORE this subscription is old news (a restart of this addon
219408
+ * must not re-fire a quarter hour of alarms). Nothing for our recipients is accepted before the
219409
+ * subscribe, so the mailbox's seq read now is an exact baseline: only what arrives after it is
219410
+ * processed, and a seq that went back means the mailbox restarted.
219411
+ */
219412
+ async seedBaseline(brokerId) {
219413
+ const since = await this.readSince(brokerId, 0);
219414
+ if (since === null) {
219415
+ if (!this.seeded || this.brokerId !== brokerId) throw new Error("the camera mailbox state could not be read");
219287
219416
  return;
219288
219417
  }
219289
- this.instance = createEmailPushServer({
219290
- config: this.buildConfig(settings),
219291
- cameraResolver: (local) => this.resolveCameraId(local),
219292
- logger: {
219293
- debug: (m) => this.deps.logger.debug(`[smtp] ${m}`),
219294
- info: (m) => this.deps.logger.info(`[smtp] ${m}`),
219295
- warn: (m) => this.deps.logger.warn(`[smtp] ${m}`),
219296
- error: (m) => this.deps.logger.error(`[smtp] ${m}`)
219418
+ if (!this.seeded || this.brokerId !== brokerId || since.lastSeq < this.lastSeq) {
219419
+ this.lastSeq = since.lastSeq;
219420
+ this.seeded = true;
219421
+ }
219422
+ }
219423
+ /** A `broker.message` event off the bus. */
219424
+ onMessage(raw) {
219425
+ return this.enqueue(async () => {
219426
+ const event = BrokerMessageEventSchema.safeParse(raw);
219427
+ if (!event.success) return;
219428
+ if (this.brokerId === null) {
219429
+ this.deps.logger.warn("email-push: a mailbox message arrived before the mailbox was known — dropped", { meta: {
219430
+ brokerId: event.data.brokerId,
219431
+ key: event.data.key
219432
+ } });
219433
+ return;
219434
+ }
219435
+ if (event.data.brokerId !== this.brokerId) return;
219436
+ const message = SmtpInboundMessageSchema.safeParse(event.data.payload);
219437
+ if (!message.success) {
219438
+ this.deps.logger.warn("email-push: a mailbox message did not parse — dropped", { meta: { key: event.data.key } });
219439
+ return;
219440
+ }
219441
+ if (message.data.seq <= this.lastSeq) return;
219442
+ if (message.data.seq > this.lastSeq + 1) {
219443
+ await this.reconcile();
219444
+ return;
219297
219445
  }
219446
+ this.process(event.data.key, message.data);
219298
219447
  });
219299
- this.busOff = onEmailPushEvent((event) => this.dispatchEvent(event));
219300
- try {
219301
- await this.instance.start();
219302
- this.deps.logger.info("email-push: SMTP intake listening", { meta: {
219303
- host: settings.bindHost,
219304
- port: settings.port,
219305
- domain: settings.domain,
219306
- requireAuth: settings.requireAuth
219448
+ }
219449
+ /** A `broker.status-changed` event: a mailbox that came back has forgotten its subscriptions. */
219450
+ onStatusChanged(raw) {
219451
+ const event = BrokerStatusEventSchema.safeParse(raw);
219452
+ if (!event.success || event.data.brokerId !== this.brokerId) return Promise.resolve();
219453
+ if (event.data.status !== "connected") {
219454
+ this.deps.logger.warn("email-push: the camera mailbox is not connected", { meta: {
219455
+ status: event.data.status,
219456
+ error: event.data.error
219307
219457
  } });
219308
- } catch (err) {
219309
- this.deps.logger.error("email-push: SMTP intake failed to start", { meta: { error: err instanceof Error ? err.message : String(err) } });
219310
- this.busOff?.();
219311
- this.busOff = void 0;
219312
- this.instance = void 0;
219458
+ return Promise.resolve();
219313
219459
  }
219460
+ return this.sync({ force: true });
219314
219461
  }
219315
- /** Re-read settings and rebind. Used after a settings edit. */
219316
- async restart() {
219317
- await this.stop();
219318
- this.reloadSettings();
219319
- await this.start();
219462
+ /**
219463
+ * The mailbox provider (any `broker` provider) became ready: the subscription it may have lost
219464
+ * is declared again, and a mailbox that never existed is created if a camera needs one.
219465
+ */
219466
+ onProviderReady(raw) {
219467
+ const event = ReadyStateEventSchema.safeParse(raw);
219468
+ if (!event.success || event.data.capName !== "broker" || event.data.state !== "ready") return Promise.resolve();
219469
+ return this.sync({ force: true });
219320
219470
  }
219321
- /** Stop the SMTP intake + detach the bus subscription. Safe to call
219322
- * when never started. */
219323
- async stop() {
219324
- this.busOff?.();
219325
- this.busOff = void 0;
219326
- if (this.instance) {
219327
- await this.instance.stop().catch(() => {});
219328
- this.instance = void 0;
219471
+ /** Replay what the ring holds after the last seq we processed. */
219472
+ async reconcile() {
219473
+ const id = this.brokerId;
219474
+ if (id === null) return;
219475
+ let since = await this.readSince(id, this.lastSeq);
219476
+ if (since !== null && since.lastSeq < this.lastSeq) {
219477
+ this.lastSeq = 0;
219478
+ since = await this.readSince(id, 0);
219479
+ }
219480
+ if (since === null) return;
219481
+ for (const entry of since.messages) this.process(entry.key, entry.message);
219482
+ }
219483
+ async readSince(id, seq) {
219484
+ try {
219485
+ const raw = await this.deps.port.getState(id, smtpSinceKey(seq));
219486
+ const parsed = SmtpInboundSinceSchema.safeParse(raw);
219487
+ if (parsed.success) return parsed.data;
219488
+ this.deps.logger.warn("email-push: the mailbox replay did not parse", { meta: { brokerId: id } });
219489
+ } catch (err) {
219490
+ this.deps.logger.warn("email-push: the mailbox replay could not be read", { meta: {
219491
+ brokerId: id,
219492
+ error: errMsg(err)
219493
+ } });
219329
219494
  }
219495
+ return null;
219496
+ }
219497
+ process(key, message) {
219498
+ if (message.seq <= this.lastSeq) return;
219499
+ this.lastSeq = message.seq;
219500
+ const cam = this.deps.listCameras().find((c) => `${RECIPIENT_PREFIX}${c.stableId}` === key);
219501
+ if (cam === void 0) {
219502
+ this.deps.logger.warn("email-push: mail for an unknown camera — dropping", { meta: {
219503
+ key,
219504
+ seq: message.seq
219505
+ } });
219506
+ return;
219507
+ }
219508
+ const inferred = classifyEmailPushMessage({
219509
+ subject: message.subject,
219510
+ text: message.text
219511
+ });
219512
+ this.deps.logger.info("email-push: motion received from battery camera", {
219513
+ tags: { deviceId: cam.id },
219514
+ meta: {
219515
+ inferredType: inferred,
219516
+ subject: message.subject.slice(0, 80),
219517
+ seq: message.seq
219518
+ }
219519
+ });
219520
+ for (const type of mapInferredTypeToSimpleEvents(inferred)) cam.handleSimpleEvent({
219521
+ type,
219522
+ channel: cam.emailPushChannel,
219523
+ timestamp: message.receivedAt,
219524
+ source: "email"
219525
+ });
219330
219526
  }
219331
219527
  /**
219332
- * Compute the manager-side parameters a given camera needs to send
219333
- * email-push to this server. Returns `null` when the server has no
219334
- * resolvable LAN address (no camera-reachable host), or the camera is
219335
- * hub-attached / not eligible. The caller passes these straight into
219336
- * `api.setupEmailPushToManager`.
219528
+ * What a camera needs to mail the mailbox. `null` for a camera that cannot do email push.
219529
+ * Throws the reason when the hub has no internal address: there is no fallback host.
219337
219530
  */
219338
219531
  async buildCameraSetupParams(cam) {
219339
219532
  if (!cam.isEmailPushEligible) return null;
219340
- const settings = await this.resolveSettings();
219341
- const managerHost = listLanHosts()[0];
219342
- if (!managerHost) return null;
219533
+ const brokerId = await this.ensureMailbox();
219534
+ const parsed = SmtpInboundConfigSchema.safeParse(await this.deps.port.getBrokerConfig(brokerId));
219535
+ if (!parsed.success) throw new Error("The camera mailbox answered a configuration this build cannot read");
219536
+ const config = parsed.data;
219537
+ if (config.host === null) throw new Error(config.hostReason ?? "The hub has no internal address (System → Network)");
219538
+ await this.sync();
219343
219539
  return {
219344
- managerHost,
219345
- managerPort: settings.port,
219346
- domain: settings.domain,
219347
- recipientLocalPart: `cam-${cam.stableId}`,
219348
- ...settings.requireAuth ? {
219349
- authUsername: settings.authUsername,
219350
- authPassword: settings.authPassword
219540
+ managerHost: config.host,
219541
+ managerPort: config.port,
219542
+ domain: config.domain,
219543
+ recipientLocalPart: `${RECIPIENT_PREFIX}${cam.stableId}`,
219544
+ ...config.requireAuth ? {
219545
+ authUsername: config.username,
219546
+ authPassword: config.password
219351
219547
  } : {}
219352
219548
  };
219353
219549
  }
219354
219550
  };
219551
+ function pickMailbox(list) {
219552
+ return list.find((b) => b.id === "smtp-inbound-default")?.id ?? list[0]?.id ?? null;
219553
+ }
219355
219554
  //#endregion
219356
- //#region src/email-push-store.ts
219357
- async function readEmailPushStore(deps) {
219358
- const raw = await deps.readRaw();
219359
- const password = await deps.readPassword();
219360
- return password.length > 0 ? {
219361
- ...raw,
219362
- emailPushAuthPassword: password
219363
- } : raw;
219555
+ //#region src/email-push-removal-hook.ts
219556
+ /**
219557
+ * A removed camera leaves its `cam-<stableId>` recipient in the mailbox (D865). The addon holds
219558
+ * no per-device DATA for the intake (the mailbox is owned by the mail addon), so removal only
219559
+ * redeclares the recipient set without the camera. Driven by the tombstone, never by absence.
219560
+ */
219561
+ function createEmailPushRemovalHook(deps) {
219562
+ return {
219563
+ onDeviceRemoved: async () => {
219564
+ await deps.resync();
219565
+ return { counts: { "mailbox-recipients": 0 } };
219566
+ },
219567
+ listDeviceData: async () => []
219568
+ };
219364
219569
  }
219365
219570
  //#endregion
219571
+ //#region src/migrations/email-push-to-mailbox.ts
219572
+ /** The pending mailbox as kept in the addon store: everything but the password (a keychain secret). */
219573
+ var PendingMailboxSchema = object({
219574
+ port: number().int(),
219575
+ bindHost: string().optional(),
219576
+ domain: string(),
219577
+ requireAuth: boolean(),
219578
+ username: string()
219579
+ });
219580
+ /** The keys an install that ever used the intake has; any one of them means "carry it over". */
219581
+ var LEGACY_KEYS = [
219582
+ "emailPushPort",
219583
+ "emailPushDomain",
219584
+ "emailPushRequireAuth",
219585
+ "emailPushAuthUsername",
219586
+ "emailPushAuthPassword",
219587
+ "emailPushBindHost"
219588
+ ];
219589
+ var DEFAULT_PORT = 2525;
219590
+ var DEFAULT_DOMAIN = "camstack.local";
219591
+ function text(stored, key) {
219592
+ const value = stored[key];
219593
+ return typeof value === "string" && value.length > 0 ? value : null;
219594
+ }
219595
+ function portOf(stored, ports) {
219596
+ const value = stored["emailPushPort"];
219597
+ const numeric = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
219598
+ if (Number.isInteger(numeric) && numeric >= 1 && numeric <= 65535) return numeric;
219599
+ if (value !== void 0) ports.warn(`email-push migration: the legacy port is not usable; using ${DEFAULT_PORT}`);
219600
+ return DEFAULT_PORT;
219601
+ }
219602
+ async function moveEmailPushToMailbox(ports) {
219603
+ const stored = await ports.readStored();
219604
+ if (!LEGACY_KEYS.some((key) => stored[key] !== void 0)) return {
219605
+ outcome: "already-done",
219606
+ summary: { moved: false }
219607
+ };
219608
+ if (stored["emailPushEnabled"] === false) return {
219609
+ outcome: "already-done",
219610
+ summary: {
219611
+ moved: false,
219612
+ disabled: true
219613
+ }
219614
+ };
219615
+ const bindHost = text(stored, "emailPushBindHost");
219616
+ await ports.stashPending({
219617
+ port: portOf(stored, ports),
219618
+ ...bindHost === null ? {} : { bindHost },
219619
+ domain: text(stored, "emailPushDomain") ?? DEFAULT_DOMAIN,
219620
+ requireAuth: stored["emailPushRequireAuth"] !== false,
219621
+ username: text(stored, "emailPushAuthUsername") ?? "",
219622
+ password: text(stored, "emailPushAuthPassword") ?? ""
219623
+ });
219624
+ return {
219625
+ outcome: "done",
219626
+ summary: { moved: true }
219627
+ };
219628
+ }
219629
+ /** The `provider-reolink` catalogue (D987): offered to the engine when the addon initialises. */
219630
+ var REOLINK_MIGRATIONS = [{
219631
+ meta: {
219632
+ id: "provider-reolink/2026-11-02-email-push-to-mailbox",
219633
+ owner: "provider-reolink",
219634
+ level: "addon-state",
219635
+ seq: 1,
219636
+ dependsOn: [],
219637
+ store: "settings",
219638
+ scope: "cluster",
219639
+ onFailure: "tolerate",
219640
+ bodyKind: "procedure",
219641
+ 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",
219642
+ fixtureMark: "a setting key the migration does not know"
219643
+ },
219644
+ procedure: moveEmailPushToMailbox
219645
+ }];
219646
+ //#endregion
219366
219647
  //#region src/reolink-discovery-map.ts
219367
219648
  /**
219368
219649
  * Flatten a host (IP / hostname) into a flat row-key slug — shared by `generateStableId` and the
@@ -219582,7 +219863,7 @@ function buildReolinkAdoptionProvider(deps) {
219582
219863
  lastDiscoveryAt: null,
219583
219864
  candidateCount: 0,
219584
219865
  adoptedCount: 0,
219585
- lastError: errMsg$2(err)
219866
+ lastError: errMsg$3(err)
219586
219867
  };
219587
219868
  }
219588
219869
  },
@@ -219643,7 +219924,7 @@ function buildReolinkAdoptionProvider(deps) {
219643
219924
  logger.warn("reolink adopt: failed to adopt channel", { meta: {
219644
219925
  childNativeId,
219645
219926
  integrationId,
219646
- error: errMsg$2(err)
219927
+ error: errMsg$3(err)
219647
219928
  } });
219648
219929
  failures++;
219649
219930
  }
@@ -219823,7 +220104,7 @@ async function planHubMigration(saved, ports) {
219823
220104
  try {
219824
220105
  integrations = await ports.listIntegrations();
219825
220106
  } catch (err) {
219826
- ports.logger.warn("Reolink hub migration: integrations unreadable — hubs left in place", { meta: { error: errMsg$2(err) } });
220107
+ ports.logger.warn("Reolink hub migration: integrations unreadable — hubs left in place", { meta: { error: errMsg$3(err) } });
219827
220108
  return {
219828
220109
  rows: saved.filter((row) => row.type !== DeviceType.Hub),
219829
220110
  retirements: []
@@ -219850,7 +220131,7 @@ async function planHubMigration(saved, ports) {
219850
220131
  ports.logger.warn("Reolink hub migration failed — the hub device is left in place", {
219851
220132
  tags: { deviceId: hub.id },
219852
220133
  meta: {
219853
- error: errMsg$2(err),
220134
+ error: errMsg$3(err),
219854
220135
  name: hub.name
219855
220136
  }
219856
220137
  });
@@ -219888,7 +220169,7 @@ async function retireMigratedHubs(retirements, ports) {
219888
220169
  } catch (err) {
219889
220170
  ports.logger.warn("Reolink hub retirement failed — retried next boot", {
219890
220171
  tags: { deviceId: r.hubDeviceId },
219891
- meta: { error: errMsg$2(err) }
220172
+ meta: { error: errMsg$3(err) }
219892
220173
  });
219893
220174
  }
219894
220175
  return retired;
@@ -220134,7 +220415,7 @@ async function enrichHostIdentifiersFromLiveApi(detection, logger) {
220134
220415
  }
220135
220416
  var ReolinkProviderAddon = class extends BaseDeviceProvider {
220136
220417
  /** The email-push SMTP password is hidden state — kept in the keychain (D787). */
220137
- secretStatePaths = ["emailPushAuthPassword"];
220418
+ secretStatePaths = ["emailPushAuthPassword", "mailboxPendingPassword"];
220138
220419
  addonId = REOLINK_ADDON_ID;
220139
220420
  providerName = "Reolink";
220140
220421
  deviceClasses = {
@@ -220148,14 +220429,13 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
220148
220429
  * so a Test click and the subsequent Save share one Baichuan login.
220149
220430
  */
220150
220431
  autodetectCache = new AutodetectCache();
220432
+ hubManager = null;
220151
220433
  /**
220152
- * Provider-level singleton SMTP intake for battery-cam motion push
220153
- * (`email-push-server.ts`). Lazily constructed in `onInitialize` once
220154
- * the addon context is available; started/stopped with the provider
220155
- * lifecycle and restarted on settings change.
220434
+ * The camera mailbox bridge (`email-push-bridge.ts`, D999): battery cameras mail motion to a
220435
+ * mailbox of the `smtp-inbound` broker, the bridge turns each message into the camera's
220436
+ * simple event. Built in `onInitialize`, once the addon context exists.
220156
220437
  */
220157
- hubManager = null;
220158
- emailPushServer;
220438
+ emailBridge;
220159
220439
  baichuanWebhookServer;
220160
220440
  /** The `log-channels` provider for this process. Stopped on shutdown so a
220161
220441
  * diagnostic timer is never the reason a runner refuses to exit. */
@@ -220187,54 +220467,94 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
220187
220467
  }
220188
220468
  /**
220189
220469
  * Enumerate this addon's live `ReolinkCamera` instances from the
220190
- * global device registry. Used by the email-push server to resolve
220470
+ * global device registry. Used by the camera mailbox bridge to resolve
220191
220471
  * recipients + route inbound motion. Returns `[]` when the registry
220192
220472
  * isn't available (worker context — providers are hub-only here).
220193
220473
  */
220194
220474
  listReolinkCameras() {
220195
220475
  const all = this.ctx.kernel.deviceRegistry?.getAll() ?? [];
220196
- const emailServer = this.emailPushServer;
220476
+ const emailBridge = this.emailBridge;
220197
220477
  const webhookServer = this.baichuanWebhookServer;
220198
220478
  const cameras = [];
220199
220479
  for (const d of all) if (d instanceof ReolinkCamera) {
220200
- if (emailServer) d.setEmailPushParamsResolver(() => emailServer.buildCameraSetupParams(d));
220480
+ if (emailBridge) d.setEmailPushParamsResolver(() => emailBridge.buildCameraSetupParams(d));
220201
220481
  if (webhookServer) d.setBaichuanWebhookParamsResolver(() => webhookServer.buildCameraSetupParams(d));
220202
220482
  cameras.push(d);
220203
220483
  }
220204
220484
  return cameras;
220205
220485
  }
220206
220486
  /**
220207
- * Bootstrapped SMTP AUTH credentials — Zod-validated durable handles
220208
- * over the SAME two store keys the raw writer used. Built lazily
220209
- * (needs `this.ctx`) and memoised.
220487
+ * The legacy SMTP AUTH password: hidden state, kept in the keychain (D787). Only the
220488
+ * `email-push-to-mailbox` migration reads it, to carry it into the mailbox.
220210
220489
  */
220211
- _emailPushAuthUsernameState = null;
220212
220490
  _emailPushAuthPasswordState = null;
220213
- get emailPushAuthUsernameState() {
220214
- if (!this._emailPushAuthUsernameState) this._emailPushAuthUsernameState = this.state("emailPushAuthUsername", string(), "");
220215
- return this._emailPushAuthUsernameState;
220216
- }
220217
220491
  get emailPushAuthPasswordState() {
220218
220492
  if (!this._emailPushAuthPasswordState) this._emailPushAuthPasswordState = this.state("emailPushAuthPassword", string(), "");
220219
220493
  return this._emailPushAuthPasswordState;
220220
220494
  }
220221
- /** Lazily build the email-push server, wiring its provider deps. */
220222
- ensureEmailPushServer() {
220223
- if (!this.emailPushServer) this.emailPushServer = new ReolinkEmailPushServer({
220224
- logger: this.ctx.logger.child("email-push"),
220495
+ /**
220496
+ * The mailbox the old intake described, waiting for a provider to create it: the migration
220497
+ * writes it (no network), the bridge reads it when it creates the mailbox and clears it after.
220498
+ * The password is a keychain secret of its own (`mailboxPendingPassword`).
220499
+ */
220500
+ _mailboxPendingState = null;
220501
+ _mailboxPendingPasswordState = null;
220502
+ get mailboxPendingState() {
220503
+ this._mailboxPendingState ??= this.state("mailboxPending", PendingMailboxSchema.nullable(), null);
220504
+ return this._mailboxPendingState;
220505
+ }
220506
+ get mailboxPendingPasswordState() {
220507
+ this._mailboxPendingPasswordState ??= this.state("mailboxPendingPassword", string(), "");
220508
+ return this._mailboxPendingPasswordState;
220509
+ }
220510
+ async stashPendingMailbox(settings) {
220511
+ const { password, ...rest } = settings;
220512
+ await this.mailboxPendingPasswordState.set(password);
220513
+ await this.mailboxPendingState.set(rest);
220514
+ }
220515
+ /** The pending settings, or null; throws when the stored password cannot be resolved. */
220516
+ async readPendingMailbox() {
220517
+ const pending = await this.mailboxPendingState.get();
220518
+ if (pending === null) return null;
220519
+ const password = await this.mailboxPendingPasswordState.get();
220520
+ const raw = (await this.resolveGlobalStore())["mailboxPendingPassword"];
220521
+ if (password.length === 0 && raw !== void 0 && raw !== "" && isSecretMarker(raw)) throw new Error("the pending camera mailbox password could not be resolved");
220522
+ return {
220523
+ ...pending,
220524
+ password
220525
+ };
220526
+ }
220527
+ async clearPendingMailbox() {
220528
+ if (await this.mailboxPendingState.get() === null) return;
220529
+ await this.mailboxPendingState.set(null);
220530
+ await this.mailboxPendingPasswordState.set("");
220531
+ }
220532
+ /** The camera mailbox bridge, built once. */
220533
+ ensureEmailBridge() {
220534
+ this.emailBridge ??= new EmailPushBridge({
220535
+ port: createBrokerPort(this.ctx.api),
220225
220536
  listCameras: () => this.listReolinkCameras(),
220226
- readStore: () => readEmailPushStore({
220227
- readRaw: () => this.resolveGlobalStore(),
220228
- readPassword: () => this.emailPushAuthPasswordState.get()
220229
- }),
220230
- writeStore: async (patch) => {
220231
- const username = patch["emailPushAuthUsername"];
220232
- if (typeof username === "string") await this.emailPushAuthUsernameState.set(username);
220233
- const password = patch["emailPushAuthPassword"];
220234
- if (typeof password === "string") await this.emailPushAuthPasswordState.set(password);
220537
+ logger: this.ctx.logger.child("email-push"),
220538
+ pending: {
220539
+ read: () => this.readPendingMailbox(),
220540
+ clear: () => this.clearPendingMailbox()
220235
220541
  }
220236
220542
  });
220237
- return this.emailPushServer;
220543
+ return this.emailBridge;
220544
+ }
220545
+ /**
220546
+ * The stored intake settings the migration carries into the mailbox. A password that is
220547
+ * stored but cannot be resolved (a locked keychain) aborts: it is never read as empty.
220548
+ */
220549
+ async readLegacyEmailPush() {
220550
+ const raw = await this.resolveGlobalStore();
220551
+ const password = await this.emailPushAuthPasswordState.get();
220552
+ const stored = raw["emailPushAuthPassword"];
220553
+ if (password.length === 0 && stored !== void 0 && stored !== "" && isSecretMarker(stored)) throw new Error("the legacy email-push password could not be resolved");
220554
+ return password.length > 0 ? {
220555
+ ...raw,
220556
+ emailPushAuthPassword: password
220557
+ } : raw;
220238
220558
  }
220239
220559
  /** Lazily build the HaCfg webhook intake, wiring its provider deps. */
220240
220560
  ensureBaichuanWebhookServer() {
@@ -220311,10 +220631,9 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
220311
220631
  }
220312
220632
  this.logChannels?.stop();
220313
220633
  await this.clipService?.disposeClipStreamDial().catch(() => {});
220314
- await this.emailPushServer?.stop().catch(() => {});
220315
220634
  await this.baichuanWebhookServer?.stop().catch(() => {});
220316
220635
  await this.hubManager?.shutdown().catch((err) => {
220317
- this.ctx.logger.warn("Reolink NVR sessions did not close cleanly", { meta: { error: errMsg$2(err) } });
220636
+ this.ctx.logger.warn("Reolink NVR sessions did not close cleanly", { meta: { error: errMsg$3(err) } });
220318
220637
  });
220319
220638
  this.hubManager = null;
220320
220639
  reolinkHubSessions.clear();
@@ -220342,7 +220661,7 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
220342
220661
  }
220343
220662
  await manager.reconcile(withSettings);
220344
220663
  } catch (err) {
220345
- this.ctx.logger.warn("Reolink integration reconcile failed — sessions left as they were", { meta: { error: errMsg$2(err) } });
220664
+ this.ctx.logger.warn("Reolink integration reconcile failed — sessions left as they were", { meta: { error: errMsg$3(err) } });
220346
220665
  }
220347
220666
  }
220348
220667
  subscribeIntegrationLifecycle() {
@@ -220507,9 +220826,6 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
220507
220826
  */
220508
220827
  async onConfigChanged() {
220509
220828
  await this.refreshClipRefreshGlobalStore();
220510
- await this.emailPushServer?.restart().catch((err) => {
220511
- this.ctx.logger.warn("email-push: restart after settings change failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
220512
- });
220513
220829
  await this.baichuanWebhookServer?.restart().catch((err) => {
220514
220830
  this.ctx.logger.warn("baichuan-webhook: restart after settings change failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
220515
220831
  });
@@ -220529,12 +220845,7 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
220529
220845
  * for core to select from.
220530
220846
  */
220531
220847
  globalSettingsSchema() {
220532
- const recommendedHost = listLanHosts()[0] ?? "127.0.0.1";
220533
- return { sections: [
220534
- ...buildEmailPushSettingsSchema(recommendedHost).sections,
220535
- ...buildBaichuanWebhookSettingsSchema(recommendedHost).sections,
220536
- ...buildClipRefreshSettingsSchema().sections
220537
- ] };
220848
+ return { sections: [...buildBaichuanWebhookSettingsSchema(listLanHosts()[0] ?? "127.0.0.1").sections, ...buildClipRefreshSettingsSchema().sections] };
220538
220849
  }
220539
220850
  /**
220540
220851
  * Per-instance form (D985): an NVR / Home Hub integration shows its connection only; the
@@ -220553,18 +220864,17 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
220553
220864
  } catch (err) {
220554
220865
  this.ctx.logger.warn("Reolink integration form: instance lookup failed — full schema shown", { meta: {
220555
220866
  integrationId: instance.integrationId,
220556
- error: errMsg$2(err)
220867
+ error: errMsg$3(err)
220557
220868
  } });
220558
220869
  return null;
220559
220870
  }
220560
220871
  }
220561
220872
  /**
220562
- * Email-push + HaCfg webhook sections ARE this integration's configuration —
220563
- * one intake of each kind serving every Reolink camera. See
220564
- * `email-push-schema.ts` / `baichuan-webhook-schema.ts`.
220873
+ * The HaCfg webhook section IS this integration's configuration — one intake serving
220874
+ * every Reolink camera. See `baichuan-webhook-schema.ts`. (The camera mailbox is a broker.)
220565
220875
  */
220566
220876
  integrationSettingSections() {
220567
- return [...EMAIL_PUSH_SECTION_IDS, ...BAICHUAN_WEBHOOK_SECTION_IDS];
220877
+ return [...BAICHUAN_WEBHOOK_SECTION_IDS];
220568
220878
  }
220569
220879
  async onInitialize() {
220570
220880
  const regs = this.providerRegistrationsFromSuper(await super.onInitialize());
@@ -220609,9 +220919,8 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
220609
220919
  this.startClipCatalogRefresh(this.clipService);
220610
220920
  this.wireClipCatalogRefreshers();
220611
220921
  await this.serveClipPlanes(this.clipService);
220612
- this.ensureEmailPushServer().start().catch((err) => {
220613
- this.ctx.logger.warn("email-push: SMTP intake failed to start at init", { meta: { error: err instanceof Error ? err.message : String(err) } });
220614
- });
220922
+ await this.runMigrations(defineAddonMigrations(REOLINK_MIGRATIONS, this.migrationPorts()));
220923
+ this.wireEmailBridge();
220615
220924
  this.ensureBaichuanWebhookServer().start().catch((err) => {
220616
220925
  this.ctx.logger.warn("baichuan-webhook: HTTP intake failed to start at init", { meta: { error: err instanceof Error ? err.message : String(err) } });
220617
220926
  });
@@ -220621,8 +220930,9 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
220621
220930
  if (typeof data.deviceId !== "number") return;
220622
220931
  const dev = this.ctx.kernel.deviceRegistry?.getById(data.deviceId);
220623
220932
  if (dev instanceof ReolinkCamera) {
220624
- const emailServer = this.ensureEmailPushServer();
220625
- dev.setEmailPushParamsResolver(() => emailServer.buildCameraSetupParams(dev));
220933
+ const emailBridge = this.ensureEmailBridge();
220934
+ dev.setEmailPushParamsResolver(() => emailBridge.buildCameraSetupParams(dev));
220935
+ emailBridge.sync();
220626
220936
  const webhookServer = this.ensureBaichuanWebhookServer();
220627
220937
  dev.setBaichuanWebhookParamsResolver(() => webhookServer.buildCameraSetupParams(dev));
220628
220938
  this.wireClipCatalogRefreshers();
@@ -220648,8 +220958,36 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
220648
220958
  capability: deviceAdoptionCapability,
220649
220959
  provider: this.buildAdoptionProvider()
220650
220960
  });
220961
+ regs.push({
220962
+ capability: deviceRemovalHookCapability,
220963
+ provider: createEmailPushRemovalHook({ resync: () => this.ensureEmailBridge().sync() })
220964
+ });
220651
220965
  return regs;
220652
220966
  }
220967
+ /** What the catalogue's migration reads and writes (D987). */
220968
+ migrationPorts() {
220969
+ return {
220970
+ readStored: () => this.readLegacyEmailPush(),
220971
+ stashPending: (settings) => this.stashPendingMailbox(settings),
220972
+ warn: (message) => {
220973
+ this.ctx.logger.warn(message);
220974
+ }
220975
+ };
220976
+ }
220977
+ /** Listen to the mailbox's messages and keep the declared recipients current. */
220978
+ wireEmailBridge() {
220979
+ const bridge = this.ensureEmailBridge();
220980
+ this.subscribe({ category: EventCategory.BrokerMessage }, (e) => {
220981
+ bridge.onMessage(e.data);
220982
+ });
220983
+ this.subscribe({ category: EventCategory.BrokerStatusChanged }, (e) => {
220984
+ bridge.onStatusChanged(e.data);
220985
+ });
220986
+ this.subscribe({ category: EventCategory.SystemReadyState }, (e) => {
220987
+ bridge.onProviderReady(e.data);
220988
+ });
220989
+ bridge.sync();
220990
+ }
220653
220991
  /** The clip catalog, its thumbnail cache and its two routes (D549). */
220654
220992
  clipService = null;
220655
220993
  clipRefreshTimer = null;
@@ -221013,8 +221351,8 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
221013
221351
  onAfterCreate: async (device) => {
221014
221352
  if (device instanceof ReolinkCamera) {
221015
221353
  device.adoptApi(detectedApi);
221016
- const emailServer = this.ensureEmailPushServer();
221017
- device.setEmailPushParamsResolver(() => emailServer.buildCameraSetupParams(device));
221354
+ const emailBridge = this.ensureEmailBridge();
221355
+ device.setEmailPushParamsResolver(() => emailBridge.buildCameraSetupParams(device));
221018
221356
  const webhookServer = this.ensureBaichuanWebhookServer();
221019
221357
  device.setBaichuanWebhookParamsResolver(() => webhookServer.buildCameraSetupParams(device));
221020
221358
  }