@apocaliss92/scrypted-reolink-native 0.5.15 → 0.5.21
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/.claude/agent-memory/baichuan-reolink-engineer/MEMORY.md +4 -0
- package/.claude/agent-memory/baichuan-reolink-engineer/baichuan_talk_sequence.md +62 -0
- package/.claude/agent-memory/baichuan-reolink-engineer/camstack_probe_gated_cap_registration.md +38 -0
- package/dist/main.nodejs.js +1 -1
- package/dist/main.nodejs.js.LICENSE.txt +4 -0
- package/dist/plugin.zip +0 -0
- package/package.json +4 -4
- package/src/baichuan-base.ts +47 -1
- package/src/camera.ts +249 -7
- package/src/email-push-server-device.ts +583 -0
- package/src/main.ts +228 -188
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email Push Server — Scrypted singleton device exposing the manager-
|
|
3
|
+
* side SMTP intake from `@apocaliss92/nodelink-js`.
|
|
4
|
+
*
|
|
5
|
+
* - Owns the lib's `createEmailPushServer` instance for this plugin
|
|
6
|
+
* process.
|
|
7
|
+
* - Resolves incoming RCPT TO (`cam-<nativeId>@<domain>`) to a real
|
|
8
|
+
* Scrypted device (`ReolinkCamera`) by walking the plugin's
|
|
9
|
+
* `camerasMap`.
|
|
10
|
+
* - Stores config + per-camera credentials in Scrypted's storage:
|
|
11
|
+
* - port, bindHost, domain, requireAuth, authUsername, authPassword,
|
|
12
|
+
* tls, tlsDir, maxMessageBytes, enabled
|
|
13
|
+
* - Random credentials are generated on first boot.
|
|
14
|
+
* - Provides `Auto-configure all cameras` button: iterates every
|
|
15
|
+
* ReolinkCamera under the provider and pushes the manager-side SMTP
|
|
16
|
+
* settings via `api.setupEmailPushToManager` (lib auto-wraps the
|
|
17
|
+
* bare username in `<user@domain>` for the camera-side MAIL FROM).
|
|
18
|
+
*
|
|
19
|
+
* Per-camera event wiring lives in `camera.ts` — each ReolinkCamera
|
|
20
|
+
* calls `api.subscribeEmailPushEvents({ cameraId: this.nativeId })`
|
|
21
|
+
* once its api is ready, so the existing `api.onSimpleEvent` listener
|
|
22
|
+
* that flips `motionDetected` true automatically handles both native
|
|
23
|
+
* and SMTP-delivered events.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import os from "node:os";
|
|
27
|
+
import { randomBytes } from "node:crypto";
|
|
28
|
+
import {
|
|
29
|
+
ScryptedDeviceBase,
|
|
30
|
+
ScryptedInterface,
|
|
31
|
+
Setting,
|
|
32
|
+
Settings,
|
|
33
|
+
SettingValue,
|
|
34
|
+
} from "@scrypted/sdk";
|
|
35
|
+
// Type-only imports: the plugin builds against CommonJS while
|
|
36
|
+
// nodelink-js ships ESM. Values are loaded via dynamic import below.
|
|
37
|
+
import type {
|
|
38
|
+
EmailPushServerConfig,
|
|
39
|
+
EmailPushServerInstance,
|
|
40
|
+
} from "@apocaliss92/nodelink-js" with {
|
|
41
|
+
"resolution-mode": "import",
|
|
42
|
+
};
|
|
43
|
+
import type ReolinkNativePlugin from "./main";
|
|
44
|
+
|
|
45
|
+
export const EMAIL_PUSH_SERVER_NATIVE_ID = "email-push-server";
|
|
46
|
+
|
|
47
|
+
const DEFAULT_PORT = 2525;
|
|
48
|
+
const DEFAULT_DOMAIN = "nodelink.local";
|
|
49
|
+
const DEFAULT_MAX_BYTES = 25 * 1024 * 1024;
|
|
50
|
+
|
|
51
|
+
interface RecentMailRow {
|
|
52
|
+
cameraId: string;
|
|
53
|
+
recipient: string;
|
|
54
|
+
inferredType: string;
|
|
55
|
+
receivedAtMs: number;
|
|
56
|
+
subject: string;
|
|
57
|
+
from: string;
|
|
58
|
+
bodyExcerpt: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class EmailPushServerDevice
|
|
62
|
+
extends ScryptedDeviceBase
|
|
63
|
+
implements Settings
|
|
64
|
+
{
|
|
65
|
+
plugin!: ReolinkNativePlugin;
|
|
66
|
+
private instance: EmailPushServerInstance | undefined;
|
|
67
|
+
|
|
68
|
+
constructor(nativeId: string) {
|
|
69
|
+
super(nativeId);
|
|
70
|
+
// Bootstrap missing credentials on first construction so the user
|
|
71
|
+
// never sees blank/insecure defaults in Settings.
|
|
72
|
+
if (!this.storage.getItem("authUsername")) {
|
|
73
|
+
this.storage.setItem(
|
|
74
|
+
"authUsername",
|
|
75
|
+
`nodelink-${randomBytes(4).toString("hex")}`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
if (!this.storage.getItem("authPassword")) {
|
|
79
|
+
this.storage.setItem(
|
|
80
|
+
"authPassword",
|
|
81
|
+
randomBytes(18).toString("base64url"),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Called by the plugin on boot. Starts the SMTP intake if enabled. */
|
|
87
|
+
async start(): Promise<void> {
|
|
88
|
+
this.online = true;
|
|
89
|
+
if (this.getEnabled()) {
|
|
90
|
+
try {
|
|
91
|
+
const inst = await this.ensureInstance();
|
|
92
|
+
await inst.start();
|
|
93
|
+
} catch (e) {
|
|
94
|
+
this.console.error(
|
|
95
|
+
`Email push server failed to start: ${e instanceof Error ? e.message : e}`,
|
|
96
|
+
);
|
|
97
|
+
this.online = false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Called by the plugin on disposal. */
|
|
103
|
+
async stop(): Promise<void> {
|
|
104
|
+
if (this.instance) {
|
|
105
|
+
await this.instance.stop().catch(() => {});
|
|
106
|
+
}
|
|
107
|
+
this.online = false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private async ensureInstance(): Promise<EmailPushServerInstance> {
|
|
111
|
+
if (this.instance) {
|
|
112
|
+
this.instance.updateConfig(this.buildConfig());
|
|
113
|
+
return this.instance;
|
|
114
|
+
}
|
|
115
|
+
const { createEmailPushServer } = await import("@apocaliss92/nodelink-js");
|
|
116
|
+
this.instance = createEmailPushServer({
|
|
117
|
+
config: this.buildConfig(),
|
|
118
|
+
cameraResolver: (local) => this.resolveCameraId(local),
|
|
119
|
+
logger: {
|
|
120
|
+
debug: (m) => this.console.log(`[debug] ${m}`),
|
|
121
|
+
info: (m) => this.console.log(m),
|
|
122
|
+
warn: (m) => this.console.warn(m),
|
|
123
|
+
error: (m) => this.console.error(m),
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
return this.instance;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private buildConfig(): EmailPushServerConfig {
|
|
130
|
+
return {
|
|
131
|
+
port: Number(this.storage.getItem("port") || DEFAULT_PORT),
|
|
132
|
+
bindHost: this.storage.getItem("bindHost") || "0.0.0.0",
|
|
133
|
+
domain: this.storage.getItem("domain") || DEFAULT_DOMAIN,
|
|
134
|
+
requireAuth: this.storage.getItem("requireAuth") !== "false",
|
|
135
|
+
authUsername: this.storage.getItem("authUsername") || "",
|
|
136
|
+
authPassword: this.storage.getItem("authPassword") || "",
|
|
137
|
+
tls: this.storage.getItem("tls") === "true",
|
|
138
|
+
tlsDir: this.storage.getItem("tlsDir") || "./email-push-tls",
|
|
139
|
+
maxMessageBytes: Number(
|
|
140
|
+
this.storage.getItem("maxMessageBytes") || DEFAULT_MAX_BYTES,
|
|
141
|
+
),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private getEnabled(): boolean {
|
|
146
|
+
return this.storage.getItem("enabled") !== "false";
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* `camerasMap` is keyed by Scrypted's internal device UUID
|
|
151
|
+
* (`cam.id`), NOT by `nativeId`. The e-mail pipeline however
|
|
152
|
+
* identifies cameras by `nativeId` (recipient local-part = the
|
|
153
|
+
* `cam-<nativeId>` we feed to `setupEmailPushToManager`), so any
|
|
154
|
+
* lookup driven by an SMTP recipient or by a bus event needs to
|
|
155
|
+
* walk the map's values and match on `cam.nativeId`.
|
|
156
|
+
*/
|
|
157
|
+
private findCameraByNativeId(
|
|
158
|
+
nativeId: string,
|
|
159
|
+
): ReolinkNativePlugin["camerasMap"] extends Map<string, infer C>
|
|
160
|
+
? C | undefined
|
|
161
|
+
: never {
|
|
162
|
+
for (const cam of this.plugin.camerasMap.values()) {
|
|
163
|
+
if (cam.nativeId === nativeId) return cam as never;
|
|
164
|
+
}
|
|
165
|
+
return undefined as never;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Match RCPT localpart (`cam-<nativeId>`) against the plugin's known
|
|
170
|
+
* camera nativeIds. Returning the nativeId verbatim is what the lib
|
|
171
|
+
* uses as the cameraId in the event payload; the per-camera
|
|
172
|
+
* `subscribeEmailPushEvents({cameraId: this.nativeId})` filter in
|
|
173
|
+
* camera.ts matches on equality.
|
|
174
|
+
*
|
|
175
|
+
* NVR-attached cameras are skipped — they don't have an independent
|
|
176
|
+
* SMTP path (the NVR handles e-mail centrally), so accepting one
|
|
177
|
+
* here would silently mis-route alerts. The Settings UI hides them
|
|
178
|
+
* from the per-camera Auto-configure select for the same reason.
|
|
179
|
+
*/
|
|
180
|
+
private resolveCameraId(local: string): string | undefined {
|
|
181
|
+
const cam = this.findCameraByNativeId(local);
|
|
182
|
+
if (!cam || cam.isOnNvr) return undefined;
|
|
183
|
+
return local;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Public accessor used by `ReolinkCamera` when the camera-side
|
|
188
|
+
* Settings panel calls `Auto-configure from Email Push Server`.
|
|
189
|
+
* Returns `null` when the server isn't yet configured (no LAN
|
|
190
|
+
* address resolvable, missing storage) — the caller should surface
|
|
191
|
+
* a user-friendly error instead of throwing.
|
|
192
|
+
*/
|
|
193
|
+
public getManagerSetupParamsForCamera(props: {
|
|
194
|
+
nativeId: string;
|
|
195
|
+
id: string;
|
|
196
|
+
}): {
|
|
197
|
+
managerHost: string;
|
|
198
|
+
managerPort: number;
|
|
199
|
+
domain: string;
|
|
200
|
+
recipientLocalPart: string;
|
|
201
|
+
sendNickname: string;
|
|
202
|
+
authUsername?: string;
|
|
203
|
+
authPassword?: string;
|
|
204
|
+
} | null {
|
|
205
|
+
const { nativeId, id } = props;
|
|
206
|
+
// Lookup by Scrypted device id — that's how `camerasMap` is keyed
|
|
207
|
+
// (see `camera.ts:837 — camerasMap.set(this.id, this)`).
|
|
208
|
+
const cam = this.plugin.camerasMap.get(id);
|
|
209
|
+
if (!cam || cam.isOnNvr) return null;
|
|
210
|
+
const cfg = this.buildConfig();
|
|
211
|
+
const managerHost = this.listLanHosts()[0];
|
|
212
|
+
if (!managerHost) return null;
|
|
213
|
+
return {
|
|
214
|
+
managerHost,
|
|
215
|
+
managerPort: cfg.port,
|
|
216
|
+
domain: cfg.domain,
|
|
217
|
+
recipientLocalPart: nativeId,
|
|
218
|
+
sendNickname: cam.name ?? nativeId,
|
|
219
|
+
...(cfg.requireAuth
|
|
220
|
+
? {
|
|
221
|
+
authUsername: cfg.authUsername,
|
|
222
|
+
authPassword: cfg.authPassword,
|
|
223
|
+
}
|
|
224
|
+
: {}),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ─── Settings ──────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
async getSettings(): Promise<Setting[]> {
|
|
231
|
+
const cfg = this.buildConfig();
|
|
232
|
+
const status = this.instance?.getStatus();
|
|
233
|
+
const candidates = this.listLanHosts();
|
|
234
|
+
const recommendedHost = candidates[0] ?? "127.0.0.1";
|
|
235
|
+
// Pull the latest from the in-memory ring before rendering.
|
|
236
|
+
await this.refreshRecentEventsCache();
|
|
237
|
+
|
|
238
|
+
return [
|
|
239
|
+
{
|
|
240
|
+
group: "Server",
|
|
241
|
+
key: "enabled",
|
|
242
|
+
title: "Enabled",
|
|
243
|
+
description:
|
|
244
|
+
"Start the SMTP intake on plugin load. When off the server is stopped and no e-mails are received.",
|
|
245
|
+
type: "boolean",
|
|
246
|
+
value: this.getEnabled(),
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
group: "Server",
|
|
250
|
+
key: "status",
|
|
251
|
+
title: "Status",
|
|
252
|
+
type: "string",
|
|
253
|
+
readonly: true,
|
|
254
|
+
value: status
|
|
255
|
+
? `${status.running ? "Running" : "Stopped"} on ${status.bindHost}:${status.port} (auth=${status.requireAuth}, tls=${status.tls}) — accepted=${status.messagesAccepted} rejected=${status.messagesRejected}`
|
|
256
|
+
: "Not initialised",
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
group: "Server",
|
|
260
|
+
key: "managerHost",
|
|
261
|
+
title: "Recommended camera-facing host",
|
|
262
|
+
description:
|
|
263
|
+
"Auto-detected LAN address of this Scrypted host. Use this value as the SMTP server on the camera side (or hit Auto-configure below).",
|
|
264
|
+
type: "string",
|
|
265
|
+
readonly: true,
|
|
266
|
+
value: recommendedHost,
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
group: "Server",
|
|
270
|
+
key: "port",
|
|
271
|
+
title: "Port",
|
|
272
|
+
description: "Default 2525. Avoid privileged 25.",
|
|
273
|
+
type: "number",
|
|
274
|
+
value: cfg.port,
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
group: "Server",
|
|
278
|
+
key: "bindHost",
|
|
279
|
+
title: "Bind host",
|
|
280
|
+
description: "0.0.0.0 to listen on every interface.",
|
|
281
|
+
type: "string",
|
|
282
|
+
value: cfg.bindHost,
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
group: "Server",
|
|
286
|
+
key: "domain",
|
|
287
|
+
title: "Virtual domain",
|
|
288
|
+
description:
|
|
289
|
+
"Used to extract the camera id from the recipient (cam-<id>@<domain>).",
|
|
290
|
+
type: "string",
|
|
291
|
+
value: cfg.domain,
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
group: "Server",
|
|
295
|
+
key: "maxMessageBytes",
|
|
296
|
+
title: "Max message bytes",
|
|
297
|
+
type: "number",
|
|
298
|
+
value: cfg.maxMessageBytes,
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
group: "Auth",
|
|
302
|
+
key: "requireAuth",
|
|
303
|
+
title: "Require AUTH",
|
|
304
|
+
description:
|
|
305
|
+
"Reolink firmwares are noticeably more reliable when AUTH PLAIN is negotiated. Recommended on.",
|
|
306
|
+
type: "boolean",
|
|
307
|
+
value: cfg.requireAuth,
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
group: "Auth",
|
|
311
|
+
key: "authUsername",
|
|
312
|
+
title: "Auth username",
|
|
313
|
+
description:
|
|
314
|
+
"Bare value. The lib auto-wraps with @<domain> when configuring the camera so MAIL FROM stays RFC-compliant.",
|
|
315
|
+
type: "string",
|
|
316
|
+
value: cfg.authUsername,
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
group: "Auth",
|
|
320
|
+
key: "authPassword",
|
|
321
|
+
title: "Auth password",
|
|
322
|
+
type: "password",
|
|
323
|
+
value: cfg.authPassword,
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
group: "Auth",
|
|
327
|
+
key: "regenerateAuth",
|
|
328
|
+
title: "Regenerate random credentials",
|
|
329
|
+
description:
|
|
330
|
+
"Replaces the username + password with fresh random values. You'll need to re-run Auto-configure on the cameras after this.",
|
|
331
|
+
type: "button",
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
group: "TLS",
|
|
335
|
+
key: "tls",
|
|
336
|
+
title: "Enable STARTTLS",
|
|
337
|
+
description:
|
|
338
|
+
"Loads cert.pem + key.pem from the TLS directory. Disable if you don't have valid cert material.",
|
|
339
|
+
type: "boolean",
|
|
340
|
+
value: cfg.tls,
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
group: "TLS",
|
|
344
|
+
key: "tlsDir",
|
|
345
|
+
title: "TLS directory",
|
|
346
|
+
description: "Where the server looks for cert.pem and key.pem.",
|
|
347
|
+
type: "string",
|
|
348
|
+
value: cfg.tlsDir ?? "./email-push-tls",
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
group: "Actions",
|
|
352
|
+
key: "restart",
|
|
353
|
+
title: "Restart server",
|
|
354
|
+
description:
|
|
355
|
+
"Stops and re-starts the SMTP intake with the latest settings.",
|
|
356
|
+
type: "button",
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
group: "Actions",
|
|
360
|
+
key: "autoConfigureCamera",
|
|
361
|
+
title: "Auto-configure a camera",
|
|
362
|
+
description:
|
|
363
|
+
"Pick a camera to push these server settings to (host, port, AUTH, recipient, 24/7 trigger schedule). Applies one camera at a time — pick again to configure the next. The selector resets after each apply so it never silently re-runs.",
|
|
364
|
+
type: "string",
|
|
365
|
+
// Force the empty value on every render so the select always
|
|
366
|
+
// resets after an apply — Scrypted re-fetches settings on save.
|
|
367
|
+
value: "",
|
|
368
|
+
immediate: true,
|
|
369
|
+
choices: ["", ...this.listCameraChoices()],
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
group: "Recent e-mails (last 20, in-memory)",
|
|
373
|
+
key: "recentEmails",
|
|
374
|
+
title: "Last received",
|
|
375
|
+
readonly: true,
|
|
376
|
+
type: "string",
|
|
377
|
+
value: this.recentEventsCache,
|
|
378
|
+
},
|
|
379
|
+
];
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async putSetting(key: string, value: SettingValue): Promise<void> {
|
|
383
|
+
switch (key) {
|
|
384
|
+
case "restart":
|
|
385
|
+
try {
|
|
386
|
+
const inst = await this.ensureInstance();
|
|
387
|
+
await inst.restart();
|
|
388
|
+
} catch (e) {
|
|
389
|
+
this.console.error(
|
|
390
|
+
`Restart failed: ${e instanceof Error ? e.message : e}`,
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
break;
|
|
394
|
+
case "autoConfigureCamera": {
|
|
395
|
+
const targetNativeId = String(value ?? "").trim();
|
|
396
|
+
if (!targetNativeId) break;
|
|
397
|
+
await this.autoConfigureCamera(targetNativeId);
|
|
398
|
+
// The select is rendered with `value: ""` on every getSettings,
|
|
399
|
+
// so the next Settings refresh shows the empty placeholder again.
|
|
400
|
+
break;
|
|
401
|
+
}
|
|
402
|
+
case "regenerateAuth":
|
|
403
|
+
this.storage.setItem(
|
|
404
|
+
"authUsername",
|
|
405
|
+
`nodelink-${randomBytes(4).toString("hex")}`,
|
|
406
|
+
);
|
|
407
|
+
this.storage.setItem(
|
|
408
|
+
"authPassword",
|
|
409
|
+
randomBytes(18).toString("base64url"),
|
|
410
|
+
);
|
|
411
|
+
if (this.instance) await this.instance.restart().catch(() => {});
|
|
412
|
+
break;
|
|
413
|
+
case "enabled": {
|
|
414
|
+
const enabled = value === true || value === "true";
|
|
415
|
+
this.storage.setItem("enabled", String(enabled));
|
|
416
|
+
if (enabled) {
|
|
417
|
+
try {
|
|
418
|
+
const inst = await this.ensureInstance();
|
|
419
|
+
await inst.start();
|
|
420
|
+
} catch (e) {
|
|
421
|
+
this.console.error(
|
|
422
|
+
`Start failed: ${e instanceof Error ? e.message : e}`,
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
} else if (this.instance) {
|
|
426
|
+
await this.instance.stop().catch(() => {});
|
|
427
|
+
}
|
|
428
|
+
break;
|
|
429
|
+
}
|
|
430
|
+
case "port":
|
|
431
|
+
case "maxMessageBytes":
|
|
432
|
+
this.storage.setItem(key, String(Number(value) || 0));
|
|
433
|
+
if (this.instance) await this.instance.restart().catch(() => {});
|
|
434
|
+
break;
|
|
435
|
+
case "requireAuth":
|
|
436
|
+
case "tls":
|
|
437
|
+
this.storage.setItem(
|
|
438
|
+
key,
|
|
439
|
+
value === true || value === "true" ? "true" : "false",
|
|
440
|
+
);
|
|
441
|
+
if (this.instance) await this.instance.restart().catch(() => {});
|
|
442
|
+
break;
|
|
443
|
+
case "bindHost":
|
|
444
|
+
case "domain":
|
|
445
|
+
case "authUsername":
|
|
446
|
+
case "authPassword":
|
|
447
|
+
case "tlsDir":
|
|
448
|
+
this.storage.setItem(key, String(value ?? ""));
|
|
449
|
+
if (this.instance) await this.instance.restart().catch(() => {});
|
|
450
|
+
break;
|
|
451
|
+
}
|
|
452
|
+
this.onDeviceEvent(ScryptedInterface.Settings, undefined);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Cached snapshot of the bus's recent events, refreshed on each Settings GET. */
|
|
456
|
+
private recentEventsCache: string = "(loading…)";
|
|
457
|
+
|
|
458
|
+
private async refreshRecentEventsCache(): Promise<void> {
|
|
459
|
+
try {
|
|
460
|
+
const { getRecentEmailPushEvents } =
|
|
461
|
+
await import("@apocaliss92/nodelink-js");
|
|
462
|
+
const events = getRecentEmailPushEvents(20);
|
|
463
|
+
if (events.length === 0) {
|
|
464
|
+
this.recentEventsCache = "No e-mails received yet.";
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
this.recentEventsCache = events
|
|
468
|
+
.map((e: RecentMailRow) => {
|
|
469
|
+
const ts = new Date(e.receivedAtMs).toISOString();
|
|
470
|
+
// `e.cameraId` is the nativeId from the recipient local-part,
|
|
471
|
+
// so we have to walk by nativeId to recover a friendly name.
|
|
472
|
+
const name =
|
|
473
|
+
this.findCameraByNativeId(e.cameraId)?.name ?? e.cameraId;
|
|
474
|
+
return `${ts} ${e.inferredType.padEnd(8)} ${name} — ${e.subject || "(no subject)"}`;
|
|
475
|
+
})
|
|
476
|
+
.join("\n");
|
|
477
|
+
} catch (e) {
|
|
478
|
+
this.recentEventsCache = `(error reading recent events: ${e instanceof Error ? e.message : e})`;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Build the dropdown options for the per-camera Auto-configure select.
|
|
484
|
+
* Format: `"<nativeId> — <displayName>"` — `nativeId` is the
|
|
485
|
+
* camera's plugin-side identifier (NOT the Scrypted UUID) so it
|
|
486
|
+
* matches what the SMTP pipeline expects as the recipient local-part
|
|
487
|
+
* and what `setupEmailPushToManager` writes back to the camera.
|
|
488
|
+
*/
|
|
489
|
+
private listCameraChoices(): string[] {
|
|
490
|
+
const out: string[] = [];
|
|
491
|
+
for (const cam of this.plugin.camerasMap.values()) {
|
|
492
|
+
// NVR-attached cameras don't have an independent SMTP path —
|
|
493
|
+
// exclude them so users can't pick something that will silently
|
|
494
|
+
// fail at delivery time.
|
|
495
|
+
if (cam.isOnNvr) continue;
|
|
496
|
+
if (!cam.nativeId) continue;
|
|
497
|
+
const label = cam.name
|
|
498
|
+
? `${cam.nativeId} — ${cam.name}`
|
|
499
|
+
: cam.nativeId;
|
|
500
|
+
out.push(label);
|
|
501
|
+
}
|
|
502
|
+
return out.sort();
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Push the manager-side SMTP config to a single camera. The selector
|
|
507
|
+
* value comes back as `"<nativeId> — <name>"`; we strip the label
|
|
508
|
+
* suffix to recover the `nativeId`, then walk `camerasMap.values()`
|
|
509
|
+
* to find the matching camera (the map is keyed by Scrypted UUID,
|
|
510
|
+
* not nativeId — see `findCameraByNativeId`). The lib auto-wraps
|
|
511
|
+
* `authUsername` in `<user@domain>` so the camera-side MAIL FROM is
|
|
512
|
+
* RFC-compliant.
|
|
513
|
+
*/
|
|
514
|
+
private async autoConfigureCamera(selection: string): Promise<void> {
|
|
515
|
+
const nativeId = selection.split(/\s+—\s+/, 1)[0]?.trim();
|
|
516
|
+
if (!nativeId) {
|
|
517
|
+
this.console.warn(`Auto-configure: empty selection.`);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
const cam = this.findCameraByNativeId(nativeId);
|
|
521
|
+
if (!cam) {
|
|
522
|
+
this.console.warn(
|
|
523
|
+
`Auto-configure: camera not found for nativeId="${nativeId}".`,
|
|
524
|
+
);
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
const cfg = this.buildConfig();
|
|
528
|
+
const managerHost = this.listLanHosts()[0] ?? "127.0.0.1";
|
|
529
|
+
this.console.log(
|
|
530
|
+
`Auto-configuring ${cam.name ?? nativeId} against ${managerHost}:${cfg.port}`,
|
|
531
|
+
);
|
|
532
|
+
try {
|
|
533
|
+
const api = await cam.ensureClient();
|
|
534
|
+
await api.setupEmailPushToManager(
|
|
535
|
+
{
|
|
536
|
+
managerHost,
|
|
537
|
+
managerPort: cfg.port,
|
|
538
|
+
domain: cfg.domain,
|
|
539
|
+
recipientLocalPart: nativeId,
|
|
540
|
+
sendNickname: cam.name ?? nativeId,
|
|
541
|
+
attachmentType: "picture",
|
|
542
|
+
triggerTypes: ["MD", "people", "vehicle"],
|
|
543
|
+
...(cfg.requireAuth
|
|
544
|
+
? {
|
|
545
|
+
authUsername: cfg.authUsername,
|
|
546
|
+
authPassword: cfg.authPassword,
|
|
547
|
+
}
|
|
548
|
+
: {}),
|
|
549
|
+
runTest: false,
|
|
550
|
+
},
|
|
551
|
+
cam.storageSettings?.values?.rtspChannel ?? 0,
|
|
552
|
+
);
|
|
553
|
+
this.console.log(` ✓ ${cam.name ?? nativeId}`);
|
|
554
|
+
} catch (e) {
|
|
555
|
+
this.console.warn(
|
|
556
|
+
` ✗ ${cam.name ?? nativeId}: ${e instanceof Error ? e.message : e}`,
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
private listLanHosts(): string[] {
|
|
562
|
+
const ifaces = os.networkInterfaces();
|
|
563
|
+
const candidates: { addr: string; rank: number }[] = [];
|
|
564
|
+
for (const list of Object.values(ifaces)) {
|
|
565
|
+
if (!list) continue;
|
|
566
|
+
for (const addr of list) {
|
|
567
|
+
if (addr.family !== "IPv4") continue;
|
|
568
|
+
if (addr.internal) continue;
|
|
569
|
+
if (addr.address.startsWith("169.254.")) continue;
|
|
570
|
+
const rank = addr.address.startsWith("192.168.")
|
|
571
|
+
? 0
|
|
572
|
+
: addr.address.startsWith("10.")
|
|
573
|
+
? 1
|
|
574
|
+
: /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(addr.address)
|
|
575
|
+
? 2
|
|
576
|
+
: 3;
|
|
577
|
+
candidates.push({ addr: addr.address, rank });
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
candidates.sort((a, b) => a.rank - b.rank);
|
|
581
|
+
return candidates.map((c) => c.addr);
|
|
582
|
+
}
|
|
583
|
+
}
|