@mgcrea/mcp-unifi-protect 0.0.0
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/LICENSE +21 -0
- package/README.md +300 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +51 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +385 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/server-iu_3JECB.js +1743 -0
- package/dist/server-iu_3JECB.js.map +1 -0
- package/package.json +71 -0
|
@@ -0,0 +1,1743 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { chmod, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
7
|
+
import { Agent, fetch as fetch$1 } from "undici";
|
|
8
|
+
//#region src/build-info.ts
|
|
9
|
+
const readPackageJson = () => {
|
|
10
|
+
try {
|
|
11
|
+
const pkgUrl = new URL("../package.json", import.meta.url);
|
|
12
|
+
return JSON.parse(readFileSync(pkgUrl, "utf8"));
|
|
13
|
+
} catch {
|
|
14
|
+
return {
|
|
15
|
+
name: "@mgcrea/mcp-unifi-protect",
|
|
16
|
+
version: "0.0.0"
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
const pkg = readPackageJson();
|
|
21
|
+
const BUILD_INFO = {
|
|
22
|
+
name: pkg.name,
|
|
23
|
+
version: pkg.version,
|
|
24
|
+
gitCommit: "b7fe70a",
|
|
25
|
+
gitCommitDate: "2026-08-30T10:17:29+02:00"
|
|
26
|
+
};
|
|
27
|
+
//#endregion
|
|
28
|
+
//#region src/config.ts
|
|
29
|
+
/**
|
|
30
|
+
* The private (undocumented) Protect API, mounted under UniFi OS's proxy. This
|
|
31
|
+
* server deliberately wraps this rather than the official Integration API at
|
|
32
|
+
* `/proxy/protect/integration/v1`: the official one has NO historical query
|
|
33
|
+
* capability at all — its only query parameters in the entire published OpenAPI
|
|
34
|
+
* spec are `channel`, `highQuality` and `qualities` — so "what happened at the
|
|
35
|
+
* front door last night" is unanswerable through it.
|
|
36
|
+
*
|
|
37
|
+
* The trade is real and accepted: nothing below is contractual, and Ubiquiti
|
|
38
|
+
* moves these endpoints between Protect releases. `unifi_protect_get_system_info`
|
|
39
|
+
* reports the running version so a mismatch is visible, and
|
|
40
|
+
* `unifi_protect_request` reaches anything that moved without a code change.
|
|
41
|
+
*/
|
|
42
|
+
const PRIVATE_API_PATH = "/proxy/protect/api";
|
|
43
|
+
/** UniFi OS's own auth surface, which is NOT under the Protect proxy path. */
|
|
44
|
+
const LOGIN_PATH = "/api/auth/login";
|
|
45
|
+
/** The realtime channel. Not used yet — see the WebSocket note in the README. */
|
|
46
|
+
const UPDATES_WS_PATH = "/proxy/protect/ws/updates";
|
|
47
|
+
const ConfigSchema = z.object({
|
|
48
|
+
/** Console origin, e.g. `https://192.168.1.1` or `https://10.0.0.1:8443`. */
|
|
49
|
+
baseUrl: z.string().min(1).optional(),
|
|
50
|
+
username: z.string().min(1).optional(),
|
|
51
|
+
password: z.string().min(1).optional(),
|
|
52
|
+
/**
|
|
53
|
+
* A 2FA code is single-use and expires in ~30s, so it is only ever useful
|
|
54
|
+
* for a one-shot `unifi_protect_auth_login`, never for an unattended start.
|
|
55
|
+
*/
|
|
56
|
+
totp: z.string().min(1).optional(),
|
|
57
|
+
/**
|
|
58
|
+
* On by default. Turning it off is scoped to this server's own requests via
|
|
59
|
+
* an undici dispatcher, not the whole process.
|
|
60
|
+
*
|
|
61
|
+
* Verifying takes two things together, and one alone does nothing: the
|
|
62
|
+
* console's certificate is self-signed (so NODE_EXTRA_CA_CERTS must point at
|
|
63
|
+
* it) AND it carries no IP SAN — `CN=unifi.local`, SANs for `unifi.local`,
|
|
64
|
+
* `localhost` and `127.0.0.1` — so UNIFI_PROTECT_HOST must be a host name
|
|
65
|
+
* that resolves to the console, never its IP address.
|
|
66
|
+
*/
|
|
67
|
+
verifyTls: z.boolean().default(true),
|
|
68
|
+
allowWrites: z.boolean().default(false),
|
|
69
|
+
sessionFile: z.string().min(1),
|
|
70
|
+
snapshotDir: z.string().min(1),
|
|
71
|
+
maxRetries: z.number().int().nonnegative().max(10).default(3),
|
|
72
|
+
/** Guards against an unexpectedly huge video export blowing up the process. */
|
|
73
|
+
maxDownloadBytes: z.number().int().positive().default(2e8),
|
|
74
|
+
/** How long a cached camera id→name index stays fresh, in seconds. */
|
|
75
|
+
deviceCacheTtlSeconds: z.number().int().nonnegative().max(3600).default(60)
|
|
76
|
+
}).strict().superRefine((cfg, ctx) => {
|
|
77
|
+
if (cfg.baseUrl && !cfg.username) ctx.addIssue({
|
|
78
|
+
code: "custom",
|
|
79
|
+
path: ["username"],
|
|
80
|
+
message: "UNIFI_PROTECT_HOST is set but UNIFI_PROTECT_USERNAME is not. Both, plus UNIFI_PROTECT_PASSWORD, are needed to reach the console."
|
|
81
|
+
});
|
|
82
|
+
if (cfg.username && !cfg.password) ctx.addIssue({
|
|
83
|
+
code: "custom",
|
|
84
|
+
path: ["password"],
|
|
85
|
+
message: "UNIFI_PROTECT_USERNAME is set but UNIFI_PROTECT_PASSWORD is not."
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
/**
|
|
89
|
+
* The on-disk config document. Keys are camelCase to mirror `Config` rather
|
|
90
|
+
* than the env var names: this is a typed JSON file, not a shell.
|
|
91
|
+
*
|
|
92
|
+
* `.strict()` on purpose — a typo'd `userName` must be an error. Silently
|
|
93
|
+
* ignoring an unknown key looks exactly like "that setting had no effect",
|
|
94
|
+
* which is the worst way to learn your credentials came from somewhere else.
|
|
95
|
+
*/
|
|
96
|
+
const FileConfigSchema = z.object({
|
|
97
|
+
host: z.string().min(1).optional(),
|
|
98
|
+
username: z.string().min(1).optional(),
|
|
99
|
+
password: z.string().min(1).optional(),
|
|
100
|
+
verifyTls: z.boolean().optional(),
|
|
101
|
+
allowWrites: z.boolean().optional(),
|
|
102
|
+
sessionFile: z.string().min(1).optional(),
|
|
103
|
+
snapshotDir: z.string().min(1).optional(),
|
|
104
|
+
maxRetries: z.number().int().nonnegative().max(10).optional(),
|
|
105
|
+
maxDownloadBytes: z.number().int().positive().optional(),
|
|
106
|
+
deviceCacheTtlSeconds: z.number().int().nonnegative().max(3600).optional()
|
|
107
|
+
}).strict();
|
|
108
|
+
/**
|
|
109
|
+
* Normalize whatever someone pasted into a console origin, preserving the port.
|
|
110
|
+
*
|
|
111
|
+
* "192.168.1.1" -> "https://192.168.1.1"
|
|
112
|
+
* "10.0.0.1:8443" -> "https://10.0.0.1:8443"
|
|
113
|
+
* "https://udm.lan/protect/" -> "https://udm.lan"
|
|
114
|
+
*
|
|
115
|
+
* A port must survive: consoles are commonly reached on a non-443 port, and the
|
|
116
|
+
* normalizers in mcp-keycloak and mcp-shopify both drop it. Everything after
|
|
117
|
+
* the origin is discarded — the API paths are this server's business, and a
|
|
118
|
+
* pasted `/protect/dashboard` URL would otherwise be prefixed onto every call.
|
|
119
|
+
*
|
|
120
|
+
* `https` is forced: UniFi OS redirects plain HTTP, and following that redirect
|
|
121
|
+
* would send the session cookie over cleartext on the first hop.
|
|
122
|
+
*/
|
|
123
|
+
const normalizeBaseUrl = (raw) => {
|
|
124
|
+
const trimmedRaw = raw.trim().replace(/\/+$/, "");
|
|
125
|
+
const withScheme = /^https?:\/\//i.test(trimmedRaw) ? trimmedRaw : `https://${trimmedRaw}`;
|
|
126
|
+
return `https://${new URL(withScheme).host}`;
|
|
127
|
+
};
|
|
128
|
+
const parseBool = (value) => {
|
|
129
|
+
const t = trimmed(value);
|
|
130
|
+
if (t === void 0) return void 0;
|
|
131
|
+
return [
|
|
132
|
+
"1",
|
|
133
|
+
"true",
|
|
134
|
+
"yes",
|
|
135
|
+
"on"
|
|
136
|
+
].includes(t.toLowerCase());
|
|
137
|
+
};
|
|
138
|
+
const parseIntOpt = (value) => {
|
|
139
|
+
if (value === void 0 || value.trim() === "") return void 0;
|
|
140
|
+
const n = Number(value);
|
|
141
|
+
return Number.isInteger(n) ? n : void 0;
|
|
142
|
+
};
|
|
143
|
+
/** Maps "" to undefined, so an empty env var means "unset" rather than "empty". */
|
|
144
|
+
const trimmed = (value) => {
|
|
145
|
+
const t = value?.trim();
|
|
146
|
+
return t ? t : void 0;
|
|
147
|
+
};
|
|
148
|
+
const message = (err) => err instanceof Error ? err.message : String(err);
|
|
149
|
+
/** `readFileSync` does not expand `~`, but it is the natural thing to write in a config file. */
|
|
150
|
+
const expandTilde = (path) => path === "~" || path.startsWith("~/") ? join(homedir(), path.slice(1)) : path;
|
|
151
|
+
/**
|
|
152
|
+
* Where the config file lives, most specific first: an explicit override, then
|
|
153
|
+
* the XDG location, then the conventional `~/.config`.
|
|
154
|
+
*/
|
|
155
|
+
const resolveConfigPath = (env = process.env) => {
|
|
156
|
+
const explicit = trimmed(env.UNIFI_PROTECT_CONFIG);
|
|
157
|
+
if (explicit) return expandTilde(explicit);
|
|
158
|
+
const base = trimmed(env.XDG_CONFIG_HOME) ?? join(homedir(), ".config");
|
|
159
|
+
return join(expandTilde(base), "unifi-protect", "config.json");
|
|
160
|
+
};
|
|
161
|
+
/** The session file sits beside the config file unless told otherwise. */
|
|
162
|
+
const resolveSessionPath = (env = process.env) => join(dirname(resolveConfigPath(env)), "session.json");
|
|
163
|
+
/**
|
|
164
|
+
* The session file holds a live console cookie, so being readable by other
|
|
165
|
+
* users is worth saying out loud. A warning and not an error: refusing to start
|
|
166
|
+
* would be a worse trade for someone on a single-user machine.
|
|
167
|
+
*/
|
|
168
|
+
const warnIfGroupReadable = (path) => {
|
|
169
|
+
if (process.platform === "win32") return;
|
|
170
|
+
try {
|
|
171
|
+
if (statSync(path).mode & 63) process.stderr.write(`[unifi-protect] ${path} is readable by other users. Run: chmod 600 ${path}\n`);
|
|
172
|
+
} catch {}
|
|
173
|
+
};
|
|
174
|
+
/**
|
|
175
|
+
* Read the config file, treating "absent" as "contributes nothing". Every other
|
|
176
|
+
* failure throws and names the path, so a malformed file is never mistaken for
|
|
177
|
+
* a missing one — that confusion sends you hunting for credentials that were
|
|
178
|
+
* sitting right there.
|
|
179
|
+
*/
|
|
180
|
+
const readConfigFile = (path) => {
|
|
181
|
+
let raw;
|
|
182
|
+
try {
|
|
183
|
+
raw = readFileSync(path, "utf8");
|
|
184
|
+
} catch (err) {
|
|
185
|
+
if (err.code === "ENOENT") return {};
|
|
186
|
+
throw new Error(`Could not read the config file (${path}): ${message(err)}`, { cause: err });
|
|
187
|
+
}
|
|
188
|
+
warnIfGroupReadable(path);
|
|
189
|
+
let parsed;
|
|
190
|
+
try {
|
|
191
|
+
parsed = JSON.parse(raw);
|
|
192
|
+
} catch (err) {
|
|
193
|
+
throw new Error(`The config file (${path}) is not valid JSON: ${message(err)}`, { cause: err });
|
|
194
|
+
}
|
|
195
|
+
const result = FileConfigSchema.safeParse(parsed);
|
|
196
|
+
if (!result.success) {
|
|
197
|
+
const issues = result.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
|
|
198
|
+
throw new Error(`The config file (${path}) is not valid: ${issues}`);
|
|
199
|
+
}
|
|
200
|
+
return result.data;
|
|
201
|
+
};
|
|
202
|
+
/**
|
|
203
|
+
* Environment first, config file second, **per field** — not whole-source.
|
|
204
|
+
* Docker and CI inject the environment and must keep working untouched, while a
|
|
205
|
+
* one-off `UNIFI_PROTECT_ALLOW_WRITES=0` still has to override a file that says
|
|
206
|
+
* `true`. Merging field by field is the only rule that gives both.
|
|
207
|
+
*/
|
|
208
|
+
const loadConfig = (env = process.env, configPath = resolveConfigPath(env)) => {
|
|
209
|
+
const file = readConfigFile(configPath);
|
|
210
|
+
const host = trimmed(env.UNIFI_PROTECT_HOST) ?? file.host;
|
|
211
|
+
const sessionFile = trimmed(env.UNIFI_PROTECT_SESSION_FILE) ?? file.sessionFile ?? resolveSessionPath(env);
|
|
212
|
+
const snapshotDir = trimmed(env.UNIFI_PROTECT_SNAPSHOT_DIR) ?? file.snapshotDir ?? join(homedir(), ".cache", "unifi-protect");
|
|
213
|
+
return ConfigSchema.parse({
|
|
214
|
+
baseUrl: host ? normalizeBaseUrl(host) : void 0,
|
|
215
|
+
username: trimmed(env.UNIFI_PROTECT_USERNAME) ?? file.username,
|
|
216
|
+
password: trimmed(env.UNIFI_PROTECT_PASSWORD) ?? file.password,
|
|
217
|
+
totp: trimmed(env.UNIFI_PROTECT_TOTP),
|
|
218
|
+
verifyTls: parseBool(env.UNIFI_PROTECT_VERIFY_TLS) ?? file.verifyTls,
|
|
219
|
+
allowWrites: parseBool(env.UNIFI_PROTECT_ALLOW_WRITES) ?? file.allowWrites,
|
|
220
|
+
sessionFile: expandTilde(sessionFile),
|
|
221
|
+
snapshotDir: expandTilde(snapshotDir),
|
|
222
|
+
maxRetries: parseIntOpt(env.UNIFI_PROTECT_MAX_RETRIES) ?? file.maxRetries,
|
|
223
|
+
maxDownloadBytes: parseIntOpt(env.UNIFI_PROTECT_MAX_DOWNLOAD_BYTES) ?? file.maxDownloadBytes,
|
|
224
|
+
deviceCacheTtlSeconds: parseIntOpt(env.UNIFI_PROTECT_DEVICE_CACHE_TTL) ?? file.deviceCacheTtlSeconds
|
|
225
|
+
});
|
|
226
|
+
};
|
|
227
|
+
/** True once the server has everything it needs to reach a console. */
|
|
228
|
+
const isConfigured = (config) => Boolean(config.baseUrl && config.username && config.password);
|
|
229
|
+
/**
|
|
230
|
+
* Returned by unifi_protect_auth_status and printed to stderr at startup. Prose
|
|
231
|
+
* rather than a code, because this is the text someone acts on when nothing
|
|
232
|
+
* works — and the server can no longer signal it by refusing to start.
|
|
233
|
+
*/
|
|
234
|
+
const setupInstructions = (config) => {
|
|
235
|
+
if (isConfigured(config)) return [];
|
|
236
|
+
return [
|
|
237
|
+
"No UniFi Protect console is configured, so only unifi_protect_auth_status is registered.",
|
|
238
|
+
"Set UNIFI_PROTECT_HOST to your console's address — the IP or hostname of the UDM Pro, UNVR or Cloud Key, e.g. 192.168.1.1 (https:// is assumed, and a :port is preserved).",
|
|
239
|
+
"Set UNIFI_PROTECT_USERNAME and UNIFI_PROTECT_PASSWORD to a console login.",
|
|
240
|
+
"Create a dedicated LOCAL user for this rather than reusing your own: UniFi OS → Settings → Admins & Users → Add User → Local Access Only, and give it Protect permissions only. A Ubiquiti cloud (SSO) account may fail local login entirely, and a local account keeps the blast radius to Protect.",
|
|
241
|
+
"Give that user View-Only rights unless you intend to set UNIFI_PROTECT_ALLOW_WRITES=1 — with writes off the mutating tools are not registered at all, so they cannot be called.",
|
|
242
|
+
"If the account has 2FA, pass a current code once via unifi_protect_auth_login; the session is then cached and reused. A code in UNIFI_PROTECT_TOTP expires in about 30 seconds, so it is no use for an unattended start.",
|
|
243
|
+
"Certificate verification is ON by default. A console reached by IP can never pass it: the certificate is self-signed AND has no IP SAN. Either set UNIFI_PROTECT_HOST to a host name that resolves to the console and point NODE_EXTRA_CA_CERTS at its certificate, or set UNIFI_PROTECT_VERIFY_TLS=false, which is scoped to this server's requests only. Set it to true only if you installed a certificate your machine already trusts."
|
|
244
|
+
];
|
|
245
|
+
};
|
|
246
|
+
//#endregion
|
|
247
|
+
//#region src/client/errors.ts
|
|
248
|
+
/** A non-2xx answer from the console. */
|
|
249
|
+
var ProtectApiError = class extends Error {
|
|
250
|
+
name = "ProtectApiError";
|
|
251
|
+
status;
|
|
252
|
+
/** The request path, so an error names what failed without a stack trace. */
|
|
253
|
+
path;
|
|
254
|
+
errors;
|
|
255
|
+
constructor(message, opts) {
|
|
256
|
+
super(message);
|
|
257
|
+
this.status = opts.status;
|
|
258
|
+
this.path = opts.path;
|
|
259
|
+
this.errors = opts.errors;
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
/** The console rejected the credentials, or 2FA is required and was not supplied. */
|
|
263
|
+
var ProtectAuthError = class extends Error {
|
|
264
|
+
name = "ProtectAuthError";
|
|
265
|
+
/** True when the console asked for a 2FA code rather than refusing the password. */
|
|
266
|
+
needsTwoFactor;
|
|
267
|
+
constructor(message, opts = {}) {
|
|
268
|
+
super(message);
|
|
269
|
+
this.needsTwoFactor = opts.needsTwoFactor ?? false;
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
/** Thrown when a write path is reached while UNIFI_PROTECT_ALLOW_WRITES is off. */
|
|
273
|
+
var WritesDisabledError = class extends Error {
|
|
274
|
+
name = "WritesDisabledError";
|
|
275
|
+
constructor(what) {
|
|
276
|
+
super(`${what} is a write operation, but writes are disabled. Set UNIFI_PROTECT_ALLOW_WRITES=1 to register the mutating tools.`);
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
/** Thrown when the server is asked to reach a console it has no credentials for. */
|
|
280
|
+
var NotConfiguredError = class extends Error {
|
|
281
|
+
name = "NotConfiguredError";
|
|
282
|
+
constructor() {
|
|
283
|
+
super("No UniFi Protect console is configured. Call unifi_protect_auth_status for the setup steps.");
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
//#endregion
|
|
287
|
+
//#region src/client/session-store.ts
|
|
288
|
+
/** Load a persisted session, or undefined if none / unreadable / not JSON. */
|
|
289
|
+
const loadSession = async (path) => {
|
|
290
|
+
try {
|
|
291
|
+
const raw = await readFile(path, "utf8");
|
|
292
|
+
const parsed = JSON.parse(raw);
|
|
293
|
+
if (!parsed.cookie || !parsed.csrfToken || !parsed.baseUrl || !parsed.username) return;
|
|
294
|
+
return parsed;
|
|
295
|
+
} catch {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
/** Persist a session with owner-only permissions. */
|
|
300
|
+
const saveSession = async (path, session) => {
|
|
301
|
+
await mkdir(dirname(path), { recursive: true });
|
|
302
|
+
await writeFile(path, JSON.stringify(session, null, 2), { mode: 384 });
|
|
303
|
+
await chmod(path, 384);
|
|
304
|
+
};
|
|
305
|
+
/** Remove a persisted session. Absent is success — logout is idempotent. */
|
|
306
|
+
const clearSession = async (path) => {
|
|
307
|
+
try {
|
|
308
|
+
await unlink(path);
|
|
309
|
+
} catch (err) {
|
|
310
|
+
if (err.code !== "ENOENT") throw err;
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
//#endregion
|
|
314
|
+
//#region src/client/auth.ts
|
|
315
|
+
/**
|
|
316
|
+
* Read one header, collapsing undici's `string | string[] | undefined` shape.
|
|
317
|
+
* Multi-value headers (the Set-Cookie shape) collapse to their first entry,
|
|
318
|
+
* which is all the handshake needs — the session is a single cookie.
|
|
319
|
+
*/
|
|
320
|
+
const header = (res, name) => res.headers.get(name) ?? void 0;
|
|
321
|
+
/**
|
|
322
|
+
* Keep only the `TOKEN=value` pair, dropping the cookie's attributes (Path,
|
|
323
|
+
* Expires, HttpOnly, …). Sending those attributes back on a request header is
|
|
324
|
+
* malformed and the console rejects the session.
|
|
325
|
+
*/
|
|
326
|
+
const bareCookie = (setCookie) => {
|
|
327
|
+
const semicolon = setCookie.indexOf(";");
|
|
328
|
+
return semicolon === -1 ? setCookie : setCookie.slice(0, semicolon);
|
|
329
|
+
};
|
|
330
|
+
/**
|
|
331
|
+
* Whether a login response is asking for a second factor rather than refusing
|
|
332
|
+
* the password. Protect answers 499 for this; some firmwares use a 401 with a
|
|
333
|
+
* body naming the requirement, so both are probed.
|
|
334
|
+
*/
|
|
335
|
+
const isTwoFactorChallenge = (status, body) => status === 499 || /2fa|two[- ]factor|mfa|otp/i.test(body);
|
|
336
|
+
const createSessionProvider = (opts) => {
|
|
337
|
+
const { config } = opts;
|
|
338
|
+
const fetchImpl = opts.fetch ?? fetch;
|
|
339
|
+
const logger = opts.logger;
|
|
340
|
+
let session;
|
|
341
|
+
let source = "none";
|
|
342
|
+
let inflight;
|
|
343
|
+
let restored = false;
|
|
344
|
+
const requireCredentials = () => {
|
|
345
|
+
if (!config.baseUrl || !config.username || !config.password) throw new ProtectAuthError("No console credentials are configured. Call unifi_protect_auth_status for the setup steps.");
|
|
346
|
+
return {
|
|
347
|
+
baseUrl: config.baseUrl,
|
|
348
|
+
username: config.username,
|
|
349
|
+
password: config.password
|
|
350
|
+
};
|
|
351
|
+
};
|
|
352
|
+
/**
|
|
353
|
+
* Fetch a CSRF token from the console root. UniFi OS gates login behind CSRF
|
|
354
|
+
* protection, so a first login on a cold start has nothing to present until
|
|
355
|
+
* this runs.
|
|
356
|
+
*/
|
|
357
|
+
const fetchCsrfToken = async (baseUrl) => {
|
|
358
|
+
try {
|
|
359
|
+
const res = await fetchImpl(baseUrl, {
|
|
360
|
+
method: "GET",
|
|
361
|
+
redirect: "manual"
|
|
362
|
+
});
|
|
363
|
+
return header(res, "x-csrf-token");
|
|
364
|
+
} catch (err) {
|
|
365
|
+
logger?.debug?.(`could not prefetch a CSRF token: ${String(err)}`);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
/** Run the UniFi OS handshake and return a complete session. */
|
|
370
|
+
const handshake = async (totp) => {
|
|
371
|
+
const { baseUrl, username, password } = requireCredentials();
|
|
372
|
+
const url = `${baseUrl}${LOGIN_PATH}`;
|
|
373
|
+
const code = totp ?? config.totp;
|
|
374
|
+
const attempt = async (csrf) => fetchImpl(url, {
|
|
375
|
+
method: "POST",
|
|
376
|
+
headers: {
|
|
377
|
+
"Content-Type": "application/json",
|
|
378
|
+
Accept: "application/json",
|
|
379
|
+
...csrf ? { "x-csrf-token": csrf } : {}
|
|
380
|
+
},
|
|
381
|
+
body: JSON.stringify({
|
|
382
|
+
username,
|
|
383
|
+
password,
|
|
384
|
+
...code ? { token: code } : {},
|
|
385
|
+
rememberMe: true
|
|
386
|
+
}),
|
|
387
|
+
redirect: "manual"
|
|
388
|
+
});
|
|
389
|
+
logger?.debug?.(`logging in to ${baseUrl} as ${username}`);
|
|
390
|
+
let res = await attempt(session?.csrfToken);
|
|
391
|
+
if (!res.ok && !session?.csrfToken) {
|
|
392
|
+
const csrf = await fetchCsrfToken(baseUrl);
|
|
393
|
+
if (csrf) {
|
|
394
|
+
logger?.debug?.("retrying login with a freshly fetched CSRF token");
|
|
395
|
+
res = await attempt(csrf);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (!res.ok) {
|
|
399
|
+
const body = await res.text().catch(() => "");
|
|
400
|
+
if (isTwoFactorChallenge(res.status, body)) throw new ProtectAuthError("The console requires a two-factor code. Call unifi_protect_auth_login with a current code from your authenticator app — the session is cached afterwards, so this is a one-off.", { needsTwoFactor: true });
|
|
401
|
+
throw new ProtectAuthError(`Login to ${baseUrl} as ${username} failed: HTTP ${res.status} ${res.statusText}. Check the username and password, and note that a Ubiquiti cloud (SSO) account often cannot log in locally — create a Local Access Only user instead.`);
|
|
402
|
+
}
|
|
403
|
+
const csrfToken = header(res, "x-updated-csrf-token") ?? header(res, "x-csrf-token");
|
|
404
|
+
const setCookie = res.headers.getSetCookie()[0] ?? header(res, "set-cookie");
|
|
405
|
+
if (!csrfToken || !setCookie) throw new ProtectAuthError(`Login to ${baseUrl} succeeded (HTTP ${res.status}) but returned no ${!setCookie ? "session cookie" : "CSRF token"}. This usually means the host is not a UniFi OS console — check UNIFI_PROTECT_HOST points at the console itself, not at a reverse proxy in front of it.`);
|
|
406
|
+
const next = {
|
|
407
|
+
cookie: bareCookie(setCookie),
|
|
408
|
+
csrfToken,
|
|
409
|
+
baseUrl,
|
|
410
|
+
username,
|
|
411
|
+
savedAt: new Date(opts.now?.() ?? Date.now()).toISOString()
|
|
412
|
+
};
|
|
413
|
+
await saveSession(config.sessionFile, next).catch((err) => {
|
|
414
|
+
logger?.warn?.(`could not persist the session to ${config.sessionFile}: ${String(err)}`);
|
|
415
|
+
});
|
|
416
|
+
return next;
|
|
417
|
+
};
|
|
418
|
+
/**
|
|
419
|
+
* Reuse a session from disk when it belongs to the console and account we are
|
|
420
|
+
* configured for. A session for a different host or user is not merely
|
|
421
|
+
* useless, it would authenticate as the wrong identity.
|
|
422
|
+
*/
|
|
423
|
+
const restore = async () => {
|
|
424
|
+
if (restored) return void 0;
|
|
425
|
+
restored = true;
|
|
426
|
+
const persisted = await loadSession(config.sessionFile);
|
|
427
|
+
if (!persisted) return void 0;
|
|
428
|
+
if (persisted.baseUrl !== config.baseUrl || persisted.username !== config.username) {
|
|
429
|
+
logger?.debug?.("ignoring a persisted session issued for a different console or user");
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
logger?.debug?.(`restored a session from ${config.sessionFile}`);
|
|
433
|
+
return persisted;
|
|
434
|
+
};
|
|
435
|
+
const ensure = async (totp) => {
|
|
436
|
+
if (session) return session;
|
|
437
|
+
const fromDisk = await restore();
|
|
438
|
+
if (fromDisk) {
|
|
439
|
+
session = fromDisk;
|
|
440
|
+
source = "restored";
|
|
441
|
+
return session;
|
|
442
|
+
}
|
|
443
|
+
if (!inflight) inflight = handshake(totp).finally(() => {
|
|
444
|
+
inflight = void 0;
|
|
445
|
+
});
|
|
446
|
+
session = await inflight;
|
|
447
|
+
source = "login";
|
|
448
|
+
return session;
|
|
449
|
+
};
|
|
450
|
+
return {
|
|
451
|
+
async headers() {
|
|
452
|
+
const live = await ensure();
|
|
453
|
+
return {
|
|
454
|
+
cookie: live.cookie,
|
|
455
|
+
"x-csrf-token": live.csrfToken
|
|
456
|
+
};
|
|
457
|
+
},
|
|
458
|
+
invalidate() {
|
|
459
|
+
session = void 0;
|
|
460
|
+
restored = true;
|
|
461
|
+
},
|
|
462
|
+
async login(totp) {
|
|
463
|
+
session = void 0;
|
|
464
|
+
restored = true;
|
|
465
|
+
session = await handshake(totp);
|
|
466
|
+
source = "login";
|
|
467
|
+
return this.describe();
|
|
468
|
+
},
|
|
469
|
+
async logout() {
|
|
470
|
+
session = void 0;
|
|
471
|
+
restored = true;
|
|
472
|
+
source = "none";
|
|
473
|
+
await clearSession(config.sessionFile);
|
|
474
|
+
},
|
|
475
|
+
describe() {
|
|
476
|
+
return {
|
|
477
|
+
authenticated: session !== void 0,
|
|
478
|
+
source,
|
|
479
|
+
username: session?.username ?? config.username,
|
|
480
|
+
savedAt: session?.savedAt
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
};
|
|
485
|
+
/** For tests: fixed headers, no network, no disk. */
|
|
486
|
+
const staticSessionProvider = (headers = {
|
|
487
|
+
cookie: "TOKEN=test",
|
|
488
|
+
"x-csrf-token": "csrf-test"
|
|
489
|
+
}) => ({
|
|
490
|
+
headers: async () => headers,
|
|
491
|
+
invalidate: () => {},
|
|
492
|
+
login: async () => ({
|
|
493
|
+
authenticated: true,
|
|
494
|
+
source: "login",
|
|
495
|
+
username: "test",
|
|
496
|
+
savedAt: void 0
|
|
497
|
+
}),
|
|
498
|
+
logout: async () => {},
|
|
499
|
+
describe: () => ({
|
|
500
|
+
authenticated: true,
|
|
501
|
+
source: "login",
|
|
502
|
+
username: "test",
|
|
503
|
+
savedAt: void 0
|
|
504
|
+
})
|
|
505
|
+
});
|
|
506
|
+
//#endregion
|
|
507
|
+
//#region src/client/shape.ts
|
|
508
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
509
|
+
const str = (value) => typeof value === "string" && value.length > 0 ? value : void 0;
|
|
510
|
+
/** Apply a summarizer across an array, passing non-arrays through untouched. */
|
|
511
|
+
const summarizeEach = (value, fn) => Array.isArray(value) ? value.filter(isRecord).map(fn) : value;
|
|
512
|
+
/**
|
|
513
|
+
* Protect timestamps are milliseconds since the Unix epoch. Rendering them as
|
|
514
|
+
* ISO 8601 costs a few characters and saves the model from having to reason
|
|
515
|
+
* about a bare 13-digit integer — which it does get wrong, usually by reading
|
|
516
|
+
* it as seconds and landing in 1970.
|
|
517
|
+
*/
|
|
518
|
+
const isoTime = (value) => {
|
|
519
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return void 0;
|
|
520
|
+
return new Date(value).toISOString();
|
|
521
|
+
};
|
|
522
|
+
const buildNameIndex = (devices) => {
|
|
523
|
+
const index = /* @__PURE__ */ new Map();
|
|
524
|
+
if (!Array.isArray(devices)) return index;
|
|
525
|
+
for (const device of devices) {
|
|
526
|
+
if (!isRecord(device)) continue;
|
|
527
|
+
const id = str(device.id);
|
|
528
|
+
const name = str(device.name);
|
|
529
|
+
if (id && name) index.set(id, name);
|
|
530
|
+
}
|
|
531
|
+
return index;
|
|
532
|
+
};
|
|
533
|
+
const summarizeCamera = (camera) => {
|
|
534
|
+
const flags = isRecord(camera.featureFlags) ? camera.featureFlags : {};
|
|
535
|
+
const recording = isRecord(camera.recordingSettings) ? camera.recordingSettings : {};
|
|
536
|
+
const smart = isRecord(camera.smartDetectSettings) ? camera.smartDetectSettings : {};
|
|
537
|
+
return {
|
|
538
|
+
id: camera.id,
|
|
539
|
+
name: camera.name,
|
|
540
|
+
type: camera.type,
|
|
541
|
+
mac: camera.mac,
|
|
542
|
+
host: camera.host,
|
|
543
|
+
isConnected: camera.isConnected,
|
|
544
|
+
state: camera.state,
|
|
545
|
+
isRecording: camera.isRecording,
|
|
546
|
+
recordingMode: recording.mode,
|
|
547
|
+
hasPtz: flags.canOpticalZoom ?? flags.hasPtz ?? false,
|
|
548
|
+
hasPackageCamera: flags.hasPackageCamera ?? false,
|
|
549
|
+
hasSmartDetect: flags.hasSmartDetect ?? false,
|
|
550
|
+
smartDetectTypes: smart.objectTypes ?? [],
|
|
551
|
+
firmwareVersion: camera.firmwareVersion,
|
|
552
|
+
isUpdating: camera.isUpdating,
|
|
553
|
+
lastSeen: isoTime(camera.lastSeen)
|
|
554
|
+
};
|
|
555
|
+
};
|
|
556
|
+
/**
|
|
557
|
+
* Events reference their camera by id. Resolving that to a name server-side is
|
|
558
|
+
* the single most useful thing this layer does: it removes a join the model
|
|
559
|
+
* would otherwise have to perform against a separate camera list, and get
|
|
560
|
+
* silently wrong. The id is kept too, since the write and snapshot tools need it.
|
|
561
|
+
*/
|
|
562
|
+
const summarizeEvent = (event, cameras) => {
|
|
563
|
+
const cameraId = str(event.camera);
|
|
564
|
+
const metadata = isRecord(event.metadata) ? event.metadata : void 0;
|
|
565
|
+
const plate = metadata && isRecord(metadata.licensePlate) ? str(metadata.licensePlate.name) : void 0;
|
|
566
|
+
return {
|
|
567
|
+
id: event.id,
|
|
568
|
+
type: event.type,
|
|
569
|
+
start: isoTime(event.start),
|
|
570
|
+
end: isoTime(event.end),
|
|
571
|
+
...cameraId ? { cameraId } : {},
|
|
572
|
+
...cameraId && cameras?.get(cameraId) ? { camera: cameras.get(cameraId) } : {},
|
|
573
|
+
...Array.isArray(event.smartDetectTypes) && event.smartDetectTypes.length > 0 ? { smartDetectTypes: event.smartDetectTypes } : {},
|
|
574
|
+
...plate ? { licensePlate: plate } : {},
|
|
575
|
+
...typeof event.score === "number" ? { score: event.score } : {},
|
|
576
|
+
...str(event.thumbnail) ? { hasThumbnail: true } : {}
|
|
577
|
+
};
|
|
578
|
+
};
|
|
579
|
+
const summarizeLight = (light) => {
|
|
580
|
+
const settings = isRecord(light.lightDeviceSettings) ? light.lightDeviceSettings : {};
|
|
581
|
+
return {
|
|
582
|
+
id: light.id,
|
|
583
|
+
name: light.name,
|
|
584
|
+
isConnected: light.isConnected,
|
|
585
|
+
isLightOn: light.isLightOn,
|
|
586
|
+
isPirMotionDetected: light.isPirMotionDetected,
|
|
587
|
+
ledLevel: settings.ledLevel,
|
|
588
|
+
isIndicatorEnabled: settings.isIndicatorEnabled,
|
|
589
|
+
firmwareVersion: light.firmwareVersion,
|
|
590
|
+
lastSeen: isoTime(light.lastSeen)
|
|
591
|
+
};
|
|
592
|
+
};
|
|
593
|
+
const summarizeSensor = (sensor) => {
|
|
594
|
+
const stats = isRecord(sensor.stats) ? sensor.stats : {};
|
|
595
|
+
const reading = (key) => {
|
|
596
|
+
const block = stats[key];
|
|
597
|
+
return isRecord(block) ? block.value : void 0;
|
|
598
|
+
};
|
|
599
|
+
return {
|
|
600
|
+
id: sensor.id,
|
|
601
|
+
name: sensor.name,
|
|
602
|
+
type: sensor.type,
|
|
603
|
+
isConnected: sensor.isConnected,
|
|
604
|
+
mountType: sensor.mountType,
|
|
605
|
+
batteryStatus: isRecord(sensor.batteryStatus) ? sensor.batteryStatus.percentage : void 0,
|
|
606
|
+
isOpened: sensor.isOpened,
|
|
607
|
+
isMotionDetected: sensor.isMotionDetected,
|
|
608
|
+
temperature: reading("temperature"),
|
|
609
|
+
humidity: reading("humidity"),
|
|
610
|
+
light: reading("light"),
|
|
611
|
+
firmwareVersion: sensor.firmwareVersion,
|
|
612
|
+
lastSeen: isoTime(sensor.lastSeen)
|
|
613
|
+
};
|
|
614
|
+
};
|
|
615
|
+
const summarizeViewer = (viewer) => ({
|
|
616
|
+
id: viewer.id,
|
|
617
|
+
name: viewer.name,
|
|
618
|
+
isConnected: viewer.isConnected,
|
|
619
|
+
liveview: viewer.liveview,
|
|
620
|
+
streamLimit: viewer.streamLimit,
|
|
621
|
+
firmwareVersion: viewer.firmwareVersion,
|
|
622
|
+
lastSeen: isoTime(viewer.lastSeen)
|
|
623
|
+
});
|
|
624
|
+
const summarizeChime = (chime) => ({
|
|
625
|
+
id: chime.id,
|
|
626
|
+
name: chime.name,
|
|
627
|
+
isConnected: chime.isConnected,
|
|
628
|
+
volume: chime.volume,
|
|
629
|
+
cameraIds: chime.cameraIds,
|
|
630
|
+
firmwareVersion: chime.firmwareVersion,
|
|
631
|
+
lastSeen: isoTime(chime.lastSeen)
|
|
632
|
+
});
|
|
633
|
+
const summarizeLiveview = (liveview) => ({
|
|
634
|
+
id: liveview.id,
|
|
635
|
+
name: liveview.name,
|
|
636
|
+
isDefault: liveview.isDefault,
|
|
637
|
+
owner: liveview.owner,
|
|
638
|
+
slotCount: Array.isArray(liveview.slots) ? liveview.slots.length : void 0
|
|
639
|
+
});
|
|
640
|
+
const summarizeUser = (user) => ({
|
|
641
|
+
id: user.id,
|
|
642
|
+
name: user.name,
|
|
643
|
+
firstName: user.firstName,
|
|
644
|
+
lastName: user.lastName,
|
|
645
|
+
email: user.email,
|
|
646
|
+
role: user.role,
|
|
647
|
+
isOwner: user.isOwner,
|
|
648
|
+
lastLoginTime: isoTime(user.lastLoginTime)
|
|
649
|
+
});
|
|
650
|
+
const num = (value) => typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
651
|
+
const rec = (value) => isRecord(value) ? value : void 0;
|
|
652
|
+
/**
|
|
653
|
+
* Storage moved between Protect releases, so both layouts are read.
|
|
654
|
+
*
|
|
655
|
+
* Protect 6.x exposed `nvr.storageInfo` with `totalSize` / `totalSpaceUsed` and a
|
|
656
|
+
* `devices[]` array carrying full SMART tables. By 7.2 that key is gone entirely:
|
|
657
|
+
* the same numbers live under `nvr.systemInfo.storage` as `size` / `used` /
|
|
658
|
+
* `available`, the per-disk detail moved to `systemInfo.ustorage.disks`, and a
|
|
659
|
+
* separate `nvr.storageStats` carries utilization. Reading only one shape gives
|
|
660
|
+
* an empty `storage: {}` on the other, which is how this was found.
|
|
661
|
+
*/
|
|
662
|
+
const summarizeStorage = (nvr) => {
|
|
663
|
+
const system = rec(nvr.systemInfo);
|
|
664
|
+
const modern = rec(system?.storage);
|
|
665
|
+
const legacy = rec(nvr.storageInfo);
|
|
666
|
+
const stats = rec(nvr.storageStats);
|
|
667
|
+
const ustorage = rec(system?.ustorage);
|
|
668
|
+
const disks = Array.isArray(ustorage?.disks) ? ustorage.disks.filter(isRecord) : void 0;
|
|
669
|
+
const total = num(modern?.size) ?? num(legacy?.totalSize);
|
|
670
|
+
const used = num(modern?.used) ?? num(legacy?.totalSpaceUsed);
|
|
671
|
+
const available = num(modern?.available) ?? (total !== void 0 && used !== void 0 ? total - used : void 0);
|
|
672
|
+
return {
|
|
673
|
+
...total !== void 0 ? { totalBytes: total } : {},
|
|
674
|
+
...used !== void 0 ? { usedBytes: used } : {},
|
|
675
|
+
...available !== void 0 ? { availableBytes: available } : {},
|
|
676
|
+
...num(stats?.utilization) !== void 0 ? { utilizationPercent: Math.round(stats.utilization * 10) / 10 } : {},
|
|
677
|
+
...str(modern?.type) ? { type: modern.type } : {},
|
|
678
|
+
...str(ustorage?.raid) ? { raid: ustorage.raid } : {},
|
|
679
|
+
...str(modern?.capability) ?? str(nvr.hardDriveState) ? { health: modern?.capability ?? nvr.hardDriveState } : {},
|
|
680
|
+
...disks ? {
|
|
681
|
+
disks: disks.length,
|
|
682
|
+
disksUnhealthy: disks.filter((d) => d.healthy !== void 0 && d.healthy !== "good").length
|
|
683
|
+
} : {},
|
|
684
|
+
...nvr.isRecycling === true ? {
|
|
685
|
+
isRecycling: true,
|
|
686
|
+
note: "Near-full with recycling on is normal: the console overwrites the oldest footage continuously rather than stopping."
|
|
687
|
+
} : {}
|
|
688
|
+
};
|
|
689
|
+
};
|
|
690
|
+
const summarizeNvr = (nvr) => {
|
|
691
|
+
const system = rec(nvr.systemInfo);
|
|
692
|
+
const memory = rec(system?.memory);
|
|
693
|
+
const cpu = rec(system?.cpu);
|
|
694
|
+
const upSince = num(nvr.upSince);
|
|
695
|
+
return {
|
|
696
|
+
id: nvr.id,
|
|
697
|
+
name: nvr.name,
|
|
698
|
+
host: nvr.host,
|
|
699
|
+
type: nvr.type,
|
|
700
|
+
version: nvr.version,
|
|
701
|
+
firmwareVersion: nvr.firmwareVersion,
|
|
702
|
+
...str(nvr.marketName) ? { model: nvr.marketName } : {},
|
|
703
|
+
timezone: nvr.timezone,
|
|
704
|
+
isRecordingDisabled: nvr.isRecordingDisabled,
|
|
705
|
+
storage: summarizeStorage(nvr),
|
|
706
|
+
...memory ? { memory: {
|
|
707
|
+
available: memory.available,
|
|
708
|
+
total: memory.total
|
|
709
|
+
} } : {},
|
|
710
|
+
...cpu ? { cpu: {
|
|
711
|
+
load: cpu.averageLoad,
|
|
712
|
+
temperature: cpu.temperature
|
|
713
|
+
} } : {},
|
|
714
|
+
...upSince !== void 0 ? {
|
|
715
|
+
upSince: new Date(upSince).toISOString(),
|
|
716
|
+
uptimeSeconds: Math.round((Date.now() - upSince) / 1e3)
|
|
717
|
+
} : num(nvr.uptime) !== void 0 ? { uptimeSeconds: Math.round(nvr.uptime / 1e3) } : {},
|
|
718
|
+
lastSeen: isoTime(nvr.lastSeen)
|
|
719
|
+
};
|
|
720
|
+
};
|
|
721
|
+
/**
|
|
722
|
+
* The bootstrap document is the entire console state and must never be returned
|
|
723
|
+
* raw — it is the single largest context bomb this API offers. This reduces it
|
|
724
|
+
* to the NVR summary plus per-type counts, which is what "tell me about my
|
|
725
|
+
* system" actually wants.
|
|
726
|
+
*/
|
|
727
|
+
const summarizeBootstrap = (bootstrap) => {
|
|
728
|
+
const count = (key) => Array.isArray(bootstrap[key]) ? bootstrap[key].length : 0;
|
|
729
|
+
return {
|
|
730
|
+
nvr: isRecord(bootstrap.nvr) ? summarizeNvr(bootstrap.nvr) : void 0,
|
|
731
|
+
devices: {
|
|
732
|
+
cameras: count("cameras"),
|
|
733
|
+
lights: count("lights"),
|
|
734
|
+
sensors: count("sensors"),
|
|
735
|
+
viewers: count("viewers"),
|
|
736
|
+
chimes: count("chimes"),
|
|
737
|
+
bridges: count("bridges"),
|
|
738
|
+
doorlocks: count("doorlocks")
|
|
739
|
+
},
|
|
740
|
+
liveviews: count("liveviews"),
|
|
741
|
+
users: count("users"),
|
|
742
|
+
lastUpdateId: bootstrap.lastUpdateId
|
|
743
|
+
};
|
|
744
|
+
};
|
|
745
|
+
//#endregion
|
|
746
|
+
//#region src/client/device-cache.ts
|
|
747
|
+
const createDeviceCache = (opts) => {
|
|
748
|
+
const now = opts.now ?? Date.now;
|
|
749
|
+
const ttlMs = opts.ttlSeconds * 1e3;
|
|
750
|
+
let cached;
|
|
751
|
+
let inflight;
|
|
752
|
+
const fetchIndex = async () => {
|
|
753
|
+
const cameras = await opts.client.get("cameras");
|
|
754
|
+
const index = buildNameIndex(cameras);
|
|
755
|
+
cached = {
|
|
756
|
+
index,
|
|
757
|
+
expiresAt: now() + ttlMs
|
|
758
|
+
};
|
|
759
|
+
return index;
|
|
760
|
+
};
|
|
761
|
+
return {
|
|
762
|
+
async cameras() {
|
|
763
|
+
if (cached && now() < cached.expiresAt) return cached.index;
|
|
764
|
+
if (!inflight) inflight = fetchIndex().finally(() => {
|
|
765
|
+
inflight = void 0;
|
|
766
|
+
});
|
|
767
|
+
try {
|
|
768
|
+
return await inflight;
|
|
769
|
+
} catch {
|
|
770
|
+
return /* @__PURE__ */ new Map();
|
|
771
|
+
}
|
|
772
|
+
},
|
|
773
|
+
invalidate() {
|
|
774
|
+
cached = void 0;
|
|
775
|
+
}
|
|
776
|
+
};
|
|
777
|
+
};
|
|
778
|
+
//#endregion
|
|
779
|
+
//#region src/client/protect.ts
|
|
780
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
781
|
+
const backoffMs = (attempt) => Math.min(1e3 * 2 ** attempt, 8e3);
|
|
782
|
+
const retryAfterMs = (res) => {
|
|
783
|
+
const value = res.headers.get("Retry-After");
|
|
784
|
+
if (value === null) return void 0;
|
|
785
|
+
const seconds = Number(value);
|
|
786
|
+
return Number.isFinite(seconds) ? Math.max(seconds, 0) * 1e3 : void 0;
|
|
787
|
+
};
|
|
788
|
+
const safeJsonParse = (text) => {
|
|
789
|
+
try {
|
|
790
|
+
return JSON.parse(text);
|
|
791
|
+
} catch {
|
|
792
|
+
return text;
|
|
793
|
+
}
|
|
794
|
+
};
|
|
795
|
+
const buildQuery = (query) => {
|
|
796
|
+
if (!query) return "";
|
|
797
|
+
const params = new URLSearchParams();
|
|
798
|
+
for (const [key, value] of Object.entries(query)) {
|
|
799
|
+
if (value === void 0) continue;
|
|
800
|
+
if (Array.isArray(value)) for (const item of value) params.append(key, item);
|
|
801
|
+
else params.append(key, String(value));
|
|
802
|
+
}
|
|
803
|
+
const qs = params.toString();
|
|
804
|
+
return qs ? `?${qs}` : "";
|
|
805
|
+
};
|
|
806
|
+
/**
|
|
807
|
+
* The private Protect API client. Every path passed in is relative to
|
|
808
|
+
* `/proxy/protect/api` — callers write `cameras/abc123`, not the full path.
|
|
809
|
+
*/
|
|
810
|
+
var ProtectClient = class {
|
|
811
|
+
baseUrl;
|
|
812
|
+
session;
|
|
813
|
+
maxRetries;
|
|
814
|
+
userAgent;
|
|
815
|
+
maxDownloadBytes;
|
|
816
|
+
fetchImpl;
|
|
817
|
+
logger;
|
|
818
|
+
constructor(opts) {
|
|
819
|
+
this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
820
|
+
this.session = opts.session;
|
|
821
|
+
this.maxRetries = opts.maxRetries;
|
|
822
|
+
this.userAgent = opts.userAgent;
|
|
823
|
+
this.maxDownloadBytes = opts.maxDownloadBytes;
|
|
824
|
+
this.fetchImpl = opts.fetch ?? fetch;
|
|
825
|
+
this.logger = opts.logger;
|
|
826
|
+
}
|
|
827
|
+
/** Absolute URL for a path relative to the private API root. */
|
|
828
|
+
url(path, query) {
|
|
829
|
+
const clean = path.replace(/^\/+/, "");
|
|
830
|
+
return `${this.baseUrl}${PRIVATE_API_PATH}/${clean}${buildQuery(query)}`;
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* Perform one authenticated request, retrying on 401 (re-login), 429 and 5xx.
|
|
834
|
+
* Returns the raw Response so callers can decide between JSON and bytes.
|
|
835
|
+
*/
|
|
836
|
+
async send(method, url, opts = {}) {
|
|
837
|
+
const hasBody = opts.body !== void 0;
|
|
838
|
+
const bodyText = hasBody ? JSON.stringify(opts.body) : void 0;
|
|
839
|
+
let attempt = 0;
|
|
840
|
+
for (;;) {
|
|
841
|
+
const auth = await this.session.headers();
|
|
842
|
+
this.logger?.debug?.(`${method} ${url} (attempt ${attempt + 1})`);
|
|
843
|
+
const res = await this.fetchImpl(url, {
|
|
844
|
+
method,
|
|
845
|
+
headers: {
|
|
846
|
+
...auth,
|
|
847
|
+
Accept: opts.accept ?? "application/json",
|
|
848
|
+
"User-Agent": this.userAgent,
|
|
849
|
+
...hasBody ? { "Content-Type": "application/json" } : {}
|
|
850
|
+
},
|
|
851
|
+
...bodyText !== void 0 ? { body: bodyText } : {},
|
|
852
|
+
redirect: "manual"
|
|
853
|
+
});
|
|
854
|
+
if (res.status === 401 && attempt < this.maxRetries) {
|
|
855
|
+
this.logger?.warn?.("HTTP 401 — re-authenticating and retrying");
|
|
856
|
+
this.session.invalidate();
|
|
857
|
+
attempt += 1;
|
|
858
|
+
continue;
|
|
859
|
+
}
|
|
860
|
+
if ((res.status === 429 || res.status >= 500) && attempt < this.maxRetries) {
|
|
861
|
+
const delay = retryAfterMs(res) ?? backoffMs(attempt);
|
|
862
|
+
this.logger?.warn?.(`HTTP ${res.status} — retrying in ${delay}ms`);
|
|
863
|
+
await sleep(delay);
|
|
864
|
+
attempt += 1;
|
|
865
|
+
continue;
|
|
866
|
+
}
|
|
867
|
+
return res;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
/** A JSON request against the private API. */
|
|
871
|
+
async request(method, path, opts = {}) {
|
|
872
|
+
const url = this.url(path, opts.query);
|
|
873
|
+
const res = await this.send(method, url, opts.body !== void 0 ? { body: opts.body } : {});
|
|
874
|
+
const text = await res.text();
|
|
875
|
+
if (!res.ok) throw new ProtectApiError(this.errorMessage(res, method, path, text), {
|
|
876
|
+
status: res.status,
|
|
877
|
+
path,
|
|
878
|
+
errors: safeJsonParse(text)
|
|
879
|
+
});
|
|
880
|
+
if (res.status === 204 || text.trim() === "") return null;
|
|
881
|
+
return safeJsonParse(text);
|
|
882
|
+
}
|
|
883
|
+
get(path, query) {
|
|
884
|
+
return this.request("GET", path, query ? { query } : {});
|
|
885
|
+
}
|
|
886
|
+
post(path, body, query) {
|
|
887
|
+
return this.request("POST", path, {
|
|
888
|
+
...body !== void 0 ? { body } : {},
|
|
889
|
+
...query ? { query } : {}
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
patch(path, body, query) {
|
|
893
|
+
return this.request("PATCH", path, {
|
|
894
|
+
...body !== void 0 ? { body } : {},
|
|
895
|
+
...query ? { query } : {}
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
del(path, query) {
|
|
899
|
+
return this.request("DELETE", path, query ? { query } : {});
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
902
|
+
* Fetch a binary asset — a snapshot JPEG, an event thumbnail, an exported
|
|
903
|
+
* MP4. Size is checked against `maxDownloadBytes` from Content-Length where
|
|
904
|
+
* the console supplies one, and again after reading where it does not, so an
|
|
905
|
+
* unexpectedly huge export fails with a clear message rather than by
|
|
906
|
+
* exhausting the heap.
|
|
907
|
+
*/
|
|
908
|
+
async requestBytes(path, opts = {}) {
|
|
909
|
+
const url = this.url(path, opts.query);
|
|
910
|
+
const res = await this.send("GET", url, { accept: opts.accept ?? "application/octet-stream" });
|
|
911
|
+
if (!res.ok) {
|
|
912
|
+
const text = await res.text().catch(() => "");
|
|
913
|
+
throw new ProtectApiError(this.errorMessage(res, "GET", path, text), {
|
|
914
|
+
status: res.status,
|
|
915
|
+
path,
|
|
916
|
+
errors: safeJsonParse(text)
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
const declared = Number(res.headers.get("Content-Length"));
|
|
920
|
+
if (Number.isFinite(declared) && declared > this.maxDownloadBytes) throw new ProtectApiError(`${path} would return ${declared} bytes, over the ${this.maxDownloadBytes}-byte limit. Narrow the time range, or raise UNIFI_PROTECT_MAX_DOWNLOAD_BYTES.`, {
|
|
921
|
+
status: res.status,
|
|
922
|
+
path
|
|
923
|
+
});
|
|
924
|
+
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
925
|
+
if (bytes.byteLength > this.maxDownloadBytes) throw new ProtectApiError(`${path} returned ${bytes.byteLength} bytes, over the ${this.maxDownloadBytes}-byte limit. Narrow the time range, or raise UNIFI_PROTECT_MAX_DOWNLOAD_BYTES.`, {
|
|
926
|
+
status: res.status,
|
|
927
|
+
path
|
|
928
|
+
});
|
|
929
|
+
return {
|
|
930
|
+
bytes,
|
|
931
|
+
contentType: res.headers.get("Content-Type") ?? "application/octet-stream"
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
/** Status-aware prose, because this is the text someone acts on. */
|
|
935
|
+
errorMessage(res, method, path, body) {
|
|
936
|
+
const detail = safeJsonParse(body);
|
|
937
|
+
const upstream = typeof detail === "object" && detail !== null && "error" in detail ? String(detail.error) : typeof detail === "string" && detail.length > 0 && detail.length < 200 ? detail : void 0;
|
|
938
|
+
const base = `Protect ${method} ${path} failed: HTTP ${res.status} ${res.statusText}`.trim() + (upstream ? ` — ${upstream}` : "");
|
|
939
|
+
if (res.status === 401 || res.status === 403) return `${base}. The account may lack Protect permissions for this device, or may be view-only while this call needs write access.`;
|
|
940
|
+
if (res.status === 404) return `${base}. Check the id came from a list tool on THIS console. Note also that the private Protect API is undocumented and Ubiquiti moves endpoints between releases — unifi_protect_get_system_info reports the version actually running.`;
|
|
941
|
+
if (res.status >= 300 && res.status < 400) return `${base}. The console redirected the request, which usually means the session was rejected.`;
|
|
942
|
+
return base;
|
|
943
|
+
}
|
|
944
|
+
};
|
|
945
|
+
//#endregion
|
|
946
|
+
//#region src/client/tls.ts
|
|
947
|
+
/**
|
|
948
|
+
* The one place TLS is decided.
|
|
949
|
+
*
|
|
950
|
+
* UniFi consoles ship a self-signed certificate, and Node's native `fetch`
|
|
951
|
+
* ignores a `node:https` Agent — the only scoped way to relax verification is an
|
|
952
|
+
* undici dispatcher. That is why this server carries a third runtime dependency:
|
|
953
|
+
* the alternative, NODE_TLS_REJECT_UNAUTHORIZED=0, is process-global and
|
|
954
|
+
* disables verification for every other request the process ever makes.
|
|
955
|
+
*
|
|
956
|
+
* This replaced exactly that process-wide switch, which had been adopted on the
|
|
957
|
+
* belief that undici could not be imported and a dispatcher therefore could not
|
|
958
|
+
* be scoped. It can — see below.
|
|
959
|
+
*
|
|
960
|
+
* **The `fetch` here must be undici's own, not the global one.** Node's built-in
|
|
961
|
+
* fetch is a *bundled copy* of undici, and it rejects a dispatcher constructed
|
|
962
|
+
* from the separately-installed package with a bare `UND_ERR_INVALID_ARG`,
|
|
963
|
+
* surfacing as an unexplained "fetch failed". Passing undici's own fetch keeps
|
|
964
|
+
* both sides on one class. Verification is on by default, so the platform fetch
|
|
965
|
+
* remains the common path.
|
|
966
|
+
*
|
|
967
|
+
* Note what a pinned certificate can and cannot fix. These consoles present
|
|
968
|
+
* `CN=unifi.local` with SANs for `unifi.local`, `localhost` and `127.0.0.1` —
|
|
969
|
+
* and **no IP SAN**. Reached by IP, verification fails on the host name however
|
|
970
|
+
* the certificate is trusted. Measured with `curl --cacert`: by host name
|
|
971
|
+
* `ssl_verify=0`, by IP `ssl_verify=1`. So verifying requires BOTH
|
|
972
|
+
* NODE_EXTRA_CA_CERTS and a host name that resolves to the console.
|
|
973
|
+
*/
|
|
974
|
+
const createHttpFetch = (opts) => {
|
|
975
|
+
if (!opts.insecureTls) return fetch;
|
|
976
|
+
opts.logger?.warn?.("TLS certificate verification is DISABLED for this server's requests (UNIFI_PROTECT_VERIFY_TLS=false). To verify instead, address the console by a host name that resolves to it — its certificate has no IP SAN — and point NODE_EXTRA_CA_CERTS at the console's certificate.");
|
|
977
|
+
const dispatcher = new Agent({ connect: { rejectUnauthorized: false } });
|
|
978
|
+
return ((input, init) => fetch$1(input, {
|
|
979
|
+
...init,
|
|
980
|
+
dispatcher
|
|
981
|
+
}));
|
|
982
|
+
};
|
|
983
|
+
//#endregion
|
|
984
|
+
//#region src/tools/util.ts
|
|
985
|
+
const ok = (data) => ({ content: [{
|
|
986
|
+
type: "text",
|
|
987
|
+
text: JSON.stringify(data ?? { ok: true }, null, 2)
|
|
988
|
+
}] });
|
|
989
|
+
const fail = (message, extra) => ({
|
|
990
|
+
content: [{
|
|
991
|
+
type: "text",
|
|
992
|
+
text: JSON.stringify({
|
|
993
|
+
error: message,
|
|
994
|
+
...extra ? { details: extra } : {}
|
|
995
|
+
}, null, 2)
|
|
996
|
+
}],
|
|
997
|
+
isError: true
|
|
998
|
+
});
|
|
999
|
+
/** Render a thrown value as a tool error, preserving upstream detail. */
|
|
1000
|
+
const toFailure = (err) => {
|
|
1001
|
+
if (err instanceof ProtectApiError) return fail(err.message, {
|
|
1002
|
+
status: err.status,
|
|
1003
|
+
...err.path ? { path: err.path } : {},
|
|
1004
|
+
errors: err.errors
|
|
1005
|
+
});
|
|
1006
|
+
if (err instanceof ProtectAuthError) return fail(err.message, err.needsTwoFactor ? { needsTwoFactor: true } : void 0);
|
|
1007
|
+
if (err instanceof WritesDisabledError || err instanceof NotConfiguredError) return fail(err.message);
|
|
1008
|
+
if (err instanceof Error) return fail(err.message);
|
|
1009
|
+
return fail("Unknown error", err);
|
|
1010
|
+
};
|
|
1011
|
+
/** Run a tool body, JSON-formatting the result and turning errors into a tool error. */
|
|
1012
|
+
const wrap = async (fn) => {
|
|
1013
|
+
try {
|
|
1014
|
+
return ok(await fn());
|
|
1015
|
+
} catch (err) {
|
|
1016
|
+
return toFailure(err);
|
|
1017
|
+
}
|
|
1018
|
+
};
|
|
1019
|
+
/** Like `wrap`, but the body chooses its own result shape (e.g. an image block). */
|
|
1020
|
+
const wrapResult = async (fn) => {
|
|
1021
|
+
try {
|
|
1022
|
+
return await fn();
|
|
1023
|
+
} catch (err) {
|
|
1024
|
+
return toFailure(err);
|
|
1025
|
+
}
|
|
1026
|
+
};
|
|
1027
|
+
/** Drop undefined values so we never send `{"name": undefined}` to the console. */
|
|
1028
|
+
const compact = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== void 0));
|
|
1029
|
+
/** `{}` is not a no-op PATCH body — send undefined instead. */
|
|
1030
|
+
const compactOrUndefined = (obj) => {
|
|
1031
|
+
const out = compact(obj);
|
|
1032
|
+
return Object.keys(out).length > 0 ? out : void 0;
|
|
1033
|
+
};
|
|
1034
|
+
const RELATIVE = /^(\d+(?:\.\d+)?)\s*(s|sec|secs|seconds?|m|min|mins|minutes?|h|hr|hrs|hours?|d|days?|w|weeks?)(?:\s+ago)?$/i;
|
|
1035
|
+
const UNIT_MS = {
|
|
1036
|
+
s: 1e3,
|
|
1037
|
+
m: 6e4,
|
|
1038
|
+
h: 36e5,
|
|
1039
|
+
d: 864e5,
|
|
1040
|
+
w: 6048e5
|
|
1041
|
+
};
|
|
1042
|
+
/**
|
|
1043
|
+
* Convert a time expression to milliseconds since the Unix epoch.
|
|
1044
|
+
*
|
|
1045
|
+
* This exists because the console's `/events` endpoint takes JavaScript
|
|
1046
|
+
* millisecond timestamps — not ISO 8601, and NOT Unix seconds. A seconds value
|
|
1047
|
+
* is not rejected: it is interpreted as a moment in January 1970, so the query
|
|
1048
|
+
* succeeds and returns an empty list. That reads as "nothing happened last
|
|
1049
|
+
* night", which is the most expensive possible failure for this server.
|
|
1050
|
+
*
|
|
1051
|
+
* Accepts ISO 8601, a relative expression like "2h ago" or "30m", the literal
|
|
1052
|
+
* "now", or a raw millisecond number.
|
|
1053
|
+
*/
|
|
1054
|
+
const toEpochMs = (value, now = Date.now()) => {
|
|
1055
|
+
if (typeof value === "number") return Math.round(value);
|
|
1056
|
+
const text = value.trim();
|
|
1057
|
+
if (text === "" || text.toLowerCase() === "now") return now;
|
|
1058
|
+
const relative = RELATIVE.exec(text);
|
|
1059
|
+
if (relative) {
|
|
1060
|
+
const amount = Number(relative[1]);
|
|
1061
|
+
const unit = relative[2].toLowerCase()[0];
|
|
1062
|
+
const scale = UNIT_MS[unit];
|
|
1063
|
+
if (scale === void 0) throw new Error(`Unrecognised time unit in "${value}".`);
|
|
1064
|
+
return Math.round(now - amount * scale);
|
|
1065
|
+
}
|
|
1066
|
+
if (/^\d+$/.test(text)) {
|
|
1067
|
+
const n = Number(text);
|
|
1068
|
+
if (text.length <= 10) throw new Error(`"${value}" looks like Unix SECONDS. This API takes milliseconds — pass ${n * 1e3}, or better, an ISO 8601 timestamp or a relative expression like "2h ago".`);
|
|
1069
|
+
return n;
|
|
1070
|
+
}
|
|
1071
|
+
const parsed = Date.parse(text);
|
|
1072
|
+
if (Number.isNaN(parsed)) throw new Error(`Could not read "${value}" as a time. Use ISO 8601 (2026-08-29T22:00:00Z), a relative expression ("2h ago", "30m", "7d"), or "now".`);
|
|
1073
|
+
return parsed;
|
|
1074
|
+
};
|
|
1075
|
+
const confirmArg = z.literal(true).describe("Must be true. Explicit acknowledgement that this changes the console's state.");
|
|
1076
|
+
const cameraIdArg = z.string().min(1).describe("Camera id — the `id` from unifi_protect_list_cameras, a 24-character hex string. Not the camera's name and not its MAC address.");
|
|
1077
|
+
/** The shared prose for every time argument, so the trap is stated everywhere it applies. */
|
|
1078
|
+
const timeHelp = (role) => `${role} Accepts ISO 8601 ("2026-08-29T22:00:00Z"), a relative expression ("2h ago", "30m", "7d"), or "now". Converted to the millisecond epoch the console requires — do not pass Unix seconds, which would silently query 1970 and return nothing.`;
|
|
1079
|
+
const timeArg = (role) => z.string().optional().describe(timeHelp(role));
|
|
1080
|
+
const requiredTimeArg = (role) => z.string().min(1).describe(timeHelp(role));
|
|
1081
|
+
const limitArg = z.number().int().min(1).max(500).default(50).describe("Maximum number of items to return (1-500). Defaults to 50. A busy system logs thousands of motion events a day, so raise this deliberately.");
|
|
1082
|
+
//#endregion
|
|
1083
|
+
//#region src/tools/cameras.ts
|
|
1084
|
+
const RECORDING_MODES = [
|
|
1085
|
+
"always",
|
|
1086
|
+
"never",
|
|
1087
|
+
"detections",
|
|
1088
|
+
"schedule"
|
|
1089
|
+
];
|
|
1090
|
+
/** Filesystem-safe stem for a snapshot file, derived from the camera name. */
|
|
1091
|
+
const slug = (value) => value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "camera";
|
|
1092
|
+
const registerCameraTools = (server, client, ctx) => {
|
|
1093
|
+
server.registerTool("unifi_protect_list_cameras", {
|
|
1094
|
+
description: "List every camera on the console with its id, name, connection state, recording mode, firmware and what it can do (PTZ, package camera, smart detection, and which object types it detects). Returns a summary rather than the console's full camera record, which runs to thousands of fields across encoder profiles, zones and feature flags — use unifi_protect_get_camera when you need all of it for one camera.",
|
|
1095
|
+
inputSchema: {},
|
|
1096
|
+
annotations: { readOnlyHint: true }
|
|
1097
|
+
}, async () => wrap(async () => summarizeEach(await client.get("cameras"), summarizeCamera)));
|
|
1098
|
+
server.registerTool("unifi_protect_get_camera", {
|
|
1099
|
+
description: "Get one camera's complete record — every setting the console holds, including encoder channels, motion and smart-detection zones, privacy masks, OSD and LED settings, ISP tuning and live statistics. This is large (roughly 8-15 KB of JSON). Prefer unifi_protect_list_cameras unless you specifically need a field it does not carry.",
|
|
1100
|
+
inputSchema: { cameraId: cameraIdArg },
|
|
1101
|
+
annotations: { readOnlyHint: true }
|
|
1102
|
+
}, async ({ cameraId }) => wrap(() => client.get(`cameras/${encodeURIComponent(cameraId)}`)));
|
|
1103
|
+
server.registerTool("unifi_protect_get_camera_snapshot", {
|
|
1104
|
+
description: "Capture a still frame from a camera as it looks right now. Writes the JPEG to disk and returns its path, size and content type by default. Set output=\"image\" to get the frame inline instead so a vision model can actually look at it — that costs roughly 300,000 to 700,000 characters of context per call, so choose it deliberately rather than by default. A fresh capture is forced; without that the console can hand back a cached frame that is minutes old.",
|
|
1105
|
+
inputSchema: {
|
|
1106
|
+
cameraId: cameraIdArg,
|
|
1107
|
+
output: z.enum(["file", "image"]).default("file").describe("Where the frame goes. \"file\" writes it to disk and returns the path — cheap, and you can read the file later if it turns out to matter. \"image\" returns it inline for a model to look at, at a large cost in context."),
|
|
1108
|
+
highQuality: z.boolean().default(false).describe("Request the camera's full resolution rather than a scaled frame. Larger and slower; with output=\"image\" it multiplies an already expensive call."),
|
|
1109
|
+
savePath: z.string().optional().describe("Absolute path to write the JPEG to. Defaults to a timestamped file under UNIFI_PROTECT_SNAPSHOT_DIR. Parent directories are created.")
|
|
1110
|
+
},
|
|
1111
|
+
annotations: { readOnlyHint: true }
|
|
1112
|
+
}, async ({ cameraId, output, highQuality, savePath }) => wrapResult(async () => {
|
|
1113
|
+
const { bytes, contentType } = await client.requestBytes(`cameras/${encodeURIComponent(cameraId)}/snapshot`, {
|
|
1114
|
+
query: {
|
|
1115
|
+
force: "true",
|
|
1116
|
+
...highQuality ? { highQuality: "true" } : {},
|
|
1117
|
+
ts: Date.now()
|
|
1118
|
+
},
|
|
1119
|
+
accept: "image/jpeg"
|
|
1120
|
+
});
|
|
1121
|
+
if (output === "image") return { content: [{
|
|
1122
|
+
type: "image",
|
|
1123
|
+
data: Buffer.from(bytes).toString("base64"),
|
|
1124
|
+
mimeType: contentType.startsWith("image/") ? contentType : "image/jpeg"
|
|
1125
|
+
}] };
|
|
1126
|
+
const name = (await ctx.devices.cameras()).get(cameraId) ?? cameraId;
|
|
1127
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
1128
|
+
const path = savePath ?? join(ctx.config.snapshotDir, `${slug(name)}-${stamp}.jpg`);
|
|
1129
|
+
await mkdir(join(path, ".."), { recursive: true });
|
|
1130
|
+
await writeFile(path, bytes);
|
|
1131
|
+
return ok({
|
|
1132
|
+
path,
|
|
1133
|
+
bytes: bytes.byteLength,
|
|
1134
|
+
contentType,
|
|
1135
|
+
camera: name,
|
|
1136
|
+
capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1137
|
+
note: "Call again with output=\"image\" if a model needs to see the frame itself."
|
|
1138
|
+
});
|
|
1139
|
+
}));
|
|
1140
|
+
server.registerTool("unifi_protect_list_ptz_presets", {
|
|
1141
|
+
description: "List a PTZ camera's saved preset positions, with the slot number each one lives at. Only meaningful for cameras reporting hasPtz: true in unifi_protect_list_cameras. There is no tool to MOVE a PTZ camera or run a patrol: those commands exist only on Ubiquiti's official Integration API (a separate X-API-KEY auth this server does not use), not on the private API this server wraps — presets are created and driven from the Protect app itself.",
|
|
1142
|
+
inputSchema: { cameraId: cameraIdArg },
|
|
1143
|
+
annotations: { readOnlyHint: true }
|
|
1144
|
+
}, async ({ cameraId }) => wrap(() => client.get(`cameras/${encodeURIComponent(cameraId)}/ptz/preset`)));
|
|
1145
|
+
server.registerTool("unifi_protect_list_ptz_patrols", {
|
|
1146
|
+
description: "List a PTZ camera's saved patrol routes. See unifi_protect_list_ptz_presets for why there is no tool to start or stop one.",
|
|
1147
|
+
inputSchema: { cameraId: cameraIdArg },
|
|
1148
|
+
annotations: { readOnlyHint: true }
|
|
1149
|
+
}, async ({ cameraId }) => wrap(() => client.get(`cameras/${encodeURIComponent(cameraId)}/ptz/patrol`)));
|
|
1150
|
+
if (!ctx.allowWrites) return;
|
|
1151
|
+
server.registerTool("unifi_protect_update_camera", {
|
|
1152
|
+
description: "Change a camera's settings in place. Only the fields you pass are sent, and the console merges them — sibling settings inside the same block are preserved, so setting osdDate alone does not clear osdName. Use unifi_protect_set_camera_recording_mode for recording mode alone — it is the setting people mean most often and it is easy to send wrongly here.",
|
|
1153
|
+
inputSchema: {
|
|
1154
|
+
cameraId: cameraIdArg,
|
|
1155
|
+
name: z.string().min(1).optional().describe("Display name shown throughout Protect."),
|
|
1156
|
+
micVolume: z.number().int().min(0).max(100).optional().describe("Microphone sensitivity, 0-100. 0 mutes the microphone."),
|
|
1157
|
+
isMicEnabled: z.boolean().optional().describe("Whether the microphone records at all."),
|
|
1158
|
+
statusLedEnabled: z.boolean().optional().describe("Whether the camera's status LED lights up at all."),
|
|
1159
|
+
osdName: z.boolean().optional().describe("Overlay the camera name on the video."),
|
|
1160
|
+
osdDate: z.boolean().optional().describe("Overlay the date and time on the video.")
|
|
1161
|
+
},
|
|
1162
|
+
annotations: {
|
|
1163
|
+
readOnlyHint: false,
|
|
1164
|
+
destructiveHint: false,
|
|
1165
|
+
idempotentHint: true
|
|
1166
|
+
}
|
|
1167
|
+
}, async ({ cameraId, name, micVolume, isMicEnabled, statusLedEnabled, osdName, osdDate }) => wrap(async () => {
|
|
1168
|
+
const ledSettings = compactOrUndefined({ isEnabled: statusLedEnabled });
|
|
1169
|
+
const osdSettings = compactOrUndefined({
|
|
1170
|
+
isNameEnabled: osdName,
|
|
1171
|
+
isDateEnabled: osdDate
|
|
1172
|
+
});
|
|
1173
|
+
const body = compactOrUndefined({
|
|
1174
|
+
name,
|
|
1175
|
+
micVolume,
|
|
1176
|
+
isMicEnabled,
|
|
1177
|
+
ledSettings,
|
|
1178
|
+
osdSettings
|
|
1179
|
+
});
|
|
1180
|
+
if (!body) throw new Error("Nothing to update — pass at least one of name, micVolume, isMicEnabled, statusLedEnabled, osdName, osdDate.");
|
|
1181
|
+
return summarizeCamera(await client.patch(`cameras/${encodeURIComponent(cameraId)}`, body));
|
|
1182
|
+
}));
|
|
1183
|
+
server.registerTool("unifi_protect_set_camera_recording_mode", {
|
|
1184
|
+
description: "Set what a camera records. `never` stops recording entirely — the camera stays online and streams live, but nothing is written, so there will be no footage to search later. `detections` records only motion and smart detections; `always` records continuously; `schedule` follows the schedule configured in Protect.",
|
|
1185
|
+
inputSchema: {
|
|
1186
|
+
cameraId: cameraIdArg,
|
|
1187
|
+
mode: z.enum(RECORDING_MODES).describe("Recording mode. `never` means no footage is kept from now on — this is the one worth pausing over.")
|
|
1188
|
+
},
|
|
1189
|
+
annotations: {
|
|
1190
|
+
readOnlyHint: false,
|
|
1191
|
+
destructiveHint: false,
|
|
1192
|
+
idempotentHint: true
|
|
1193
|
+
}
|
|
1194
|
+
}, async ({ cameraId, mode }) => wrap(async () => summarizeCamera(await client.patch(`cameras/${encodeURIComponent(cameraId)}`, { recordingSettings: { mode } }))));
|
|
1195
|
+
server.registerTool("unifi_protect_reboot_camera", {
|
|
1196
|
+
description: "Reboot a camera. It stops recording and goes offline for roughly a minute, and any footage during that window is lost. Useful for a camera that has stopped responding.",
|
|
1197
|
+
inputSchema: {
|
|
1198
|
+
cameraId: cameraIdArg,
|
|
1199
|
+
confirm: confirmArg
|
|
1200
|
+
},
|
|
1201
|
+
annotations: {
|
|
1202
|
+
readOnlyHint: false,
|
|
1203
|
+
destructiveHint: true,
|
|
1204
|
+
idempotentHint: false
|
|
1205
|
+
}
|
|
1206
|
+
}, async ({ cameraId }) => wrap(() => client.post(`cameras/${encodeURIComponent(cameraId)}/reboot`)));
|
|
1207
|
+
};
|
|
1208
|
+
//#endregion
|
|
1209
|
+
//#region src/tools/devices.ts
|
|
1210
|
+
const idArg = (kind, listTool) => z.string().min(1).describe(`${kind} id — the \`id\` from ${listTool}.`);
|
|
1211
|
+
const registerDeviceTools = (server, client, ctx) => {
|
|
1212
|
+
server.registerTool("unifi_protect_list_lights", {
|
|
1213
|
+
description: "List UniFi Protect floodlights with their connection state, whether the light is currently on, whether PIR motion is being detected, and brightness.",
|
|
1214
|
+
inputSchema: {},
|
|
1215
|
+
annotations: { readOnlyHint: true }
|
|
1216
|
+
}, async () => wrap(async () => summarizeEach(await client.get("lights"), summarizeLight)));
|
|
1217
|
+
server.registerTool("unifi_protect_list_sensors", {
|
|
1218
|
+
description: "List UniFi Protect sensors with their current readings — temperature, humidity, light level — plus open/closed state, motion, and battery percentage. The readings are lifted out of the console's per-metric history arrays, which are far larger than the values themselves.",
|
|
1219
|
+
inputSchema: {},
|
|
1220
|
+
annotations: { readOnlyHint: true }
|
|
1221
|
+
}, async () => wrap(async () => summarizeEach(await client.get("sensors"), summarizeSensor)));
|
|
1222
|
+
server.registerTool("unifi_protect_list_viewers", {
|
|
1223
|
+
description: "List UniFi Protect Viewport devices and which live view each is currently displaying.",
|
|
1224
|
+
inputSchema: {},
|
|
1225
|
+
annotations: { readOnlyHint: true }
|
|
1226
|
+
}, async () => wrap(async () => summarizeEach(await client.get("viewers"), summarizeViewer)));
|
|
1227
|
+
server.registerTool("unifi_protect_list_chimes", {
|
|
1228
|
+
description: "List UniFi Protect chimes, their volume, and which doorbell cameras each is paired to.",
|
|
1229
|
+
inputSchema: {},
|
|
1230
|
+
annotations: { readOnlyHint: true }
|
|
1231
|
+
}, async () => wrap(async () => summarizeEach(await client.get("chimes"), summarizeChime)));
|
|
1232
|
+
if (!ctx.allowWrites) return;
|
|
1233
|
+
server.registerTool("unifi_protect_update_light", {
|
|
1234
|
+
description: "Change a floodlight's settings — brightness, whether the light is on, and the PIR sensitivity that decides when it triggers. Only the fields you pass are sent, and the console merges them, so the PIR duration and lux sensitivity you do not pass survive.",
|
|
1235
|
+
inputSchema: {
|
|
1236
|
+
lightId: idArg("Light", "unifi_protect_list_lights"),
|
|
1237
|
+
isLightOn: z.boolean().optional().describe("Turn the light on or off right now."),
|
|
1238
|
+
ledLevel: z.number().int().min(1).max(6).optional().describe("Brightness, 1 (dimmest) to 6 (brightest)."),
|
|
1239
|
+
pirSensitivity: z.number().int().min(0).max(100).optional().describe("Motion sensitivity, 0-100. Higher triggers on smaller movement.")
|
|
1240
|
+
},
|
|
1241
|
+
annotations: {
|
|
1242
|
+
readOnlyHint: false,
|
|
1243
|
+
destructiveHint: false,
|
|
1244
|
+
idempotentHint: true
|
|
1245
|
+
}
|
|
1246
|
+
}, async ({ lightId, isLightOn, ledLevel, pirSensitivity }) => wrap(async () => {
|
|
1247
|
+
const lightOnSettings = compactOrUndefined({ isLedForceOn: isLightOn });
|
|
1248
|
+
const lightDeviceSettings = compactOrUndefined({
|
|
1249
|
+
ledLevel,
|
|
1250
|
+
pirSensitivity
|
|
1251
|
+
});
|
|
1252
|
+
const body = compactOrUndefined({
|
|
1253
|
+
lightOnSettings,
|
|
1254
|
+
lightDeviceSettings
|
|
1255
|
+
});
|
|
1256
|
+
if (!body) throw new Error("Nothing to update — pass at least one of isLightOn, ledLevel, pirSensitivity.");
|
|
1257
|
+
return summarizeLight(await client.patch(`lights/${encodeURIComponent(lightId)}`, body));
|
|
1258
|
+
}));
|
|
1259
|
+
server.registerTool("unifi_protect_update_sensor", {
|
|
1260
|
+
description: "Rename a sensor or change which of its capabilities are enabled. Only the fields you pass are sent.",
|
|
1261
|
+
inputSchema: {
|
|
1262
|
+
sensorId: idArg("Sensor", "unifi_protect_list_sensors"),
|
|
1263
|
+
name: z.string().min(1).optional().describe("Display name for the sensor."),
|
|
1264
|
+
motionEnabled: z.boolean().optional().describe("Whether motion detection reports events."),
|
|
1265
|
+
temperatureEnabled: z.boolean().optional().describe("Whether temperature is reported."),
|
|
1266
|
+
humidityEnabled: z.boolean().optional().describe("Whether humidity is reported."),
|
|
1267
|
+
lightEnabled: z.boolean().optional().describe("Whether the light level is reported.")
|
|
1268
|
+
},
|
|
1269
|
+
annotations: {
|
|
1270
|
+
readOnlyHint: false,
|
|
1271
|
+
destructiveHint: false,
|
|
1272
|
+
idempotentHint: true
|
|
1273
|
+
}
|
|
1274
|
+
}, async ({ sensorId, name, motionEnabled, temperatureEnabled, humidityEnabled, lightEnabled }) => wrap(async () => {
|
|
1275
|
+
const body = compactOrUndefined({
|
|
1276
|
+
name,
|
|
1277
|
+
motionSettings: compactOrUndefined({ isEnabled: motionEnabled }),
|
|
1278
|
+
temperatureSettings: compactOrUndefined({ isEnabled: temperatureEnabled }),
|
|
1279
|
+
humiditySettings: compactOrUndefined({ isEnabled: humidityEnabled }),
|
|
1280
|
+
lightSettings: compactOrUndefined({ isEnabled: lightEnabled })
|
|
1281
|
+
});
|
|
1282
|
+
if (!body) throw new Error("Nothing to update — pass at least one of name, motionEnabled, temperatureEnabled, humidityEnabled, lightEnabled.");
|
|
1283
|
+
return summarizeSensor(await client.patch(`sensors/${encodeURIComponent(sensorId)}`, body));
|
|
1284
|
+
}));
|
|
1285
|
+
server.registerTool("unifi_protect_update_viewer", {
|
|
1286
|
+
description: "Put a saved live view on a Viewport screen, or rename the viewer. The liveview id comes from unifi_protect_list_liveviews — this changes what is displayed on a physical screen, so someone watching will see it switch.",
|
|
1287
|
+
inputSchema: {
|
|
1288
|
+
viewerId: idArg("Viewer", "unifi_protect_list_viewers"),
|
|
1289
|
+
liveview: z.string().min(1).optional().describe("Live view id from unifi_protect_list_liveviews — the layout to display."),
|
|
1290
|
+
name: z.string().min(1).optional().describe("Display name for the viewer.")
|
|
1291
|
+
},
|
|
1292
|
+
annotations: {
|
|
1293
|
+
readOnlyHint: false,
|
|
1294
|
+
destructiveHint: false,
|
|
1295
|
+
idempotentHint: true
|
|
1296
|
+
}
|
|
1297
|
+
}, async ({ viewerId, liveview, name }) => wrap(async () => {
|
|
1298
|
+
const body = compactOrUndefined({
|
|
1299
|
+
liveview,
|
|
1300
|
+
name
|
|
1301
|
+
});
|
|
1302
|
+
if (!body) throw new Error("Nothing to update — pass liveview or name.");
|
|
1303
|
+
return summarizeViewer(await client.patch(`viewers/${encodeURIComponent(viewerId)}`, body));
|
|
1304
|
+
}));
|
|
1305
|
+
server.registerTool("unifi_protect_update_chime", {
|
|
1306
|
+
description: "Change a chime's volume or rename it. Volume 0 silences it, so a doorbell press will make no sound.",
|
|
1307
|
+
inputSchema: {
|
|
1308
|
+
chimeId: idArg("Chime", "unifi_protect_list_chimes"),
|
|
1309
|
+
volume: z.number().int().min(0).max(100).optional().describe("Volume, 0-100. 0 means the chime stays silent when the doorbell is pressed."),
|
|
1310
|
+
name: z.string().min(1).optional().describe("Display name for the chime.")
|
|
1311
|
+
},
|
|
1312
|
+
annotations: {
|
|
1313
|
+
readOnlyHint: false,
|
|
1314
|
+
destructiveHint: false,
|
|
1315
|
+
idempotentHint: true
|
|
1316
|
+
}
|
|
1317
|
+
}, async ({ chimeId, volume, name }) => wrap(async () => {
|
|
1318
|
+
const body = compactOrUndefined({
|
|
1319
|
+
volume,
|
|
1320
|
+
name
|
|
1321
|
+
});
|
|
1322
|
+
if (!body) throw new Error("Nothing to update — pass volume or name.");
|
|
1323
|
+
return summarizeChime(await client.patch(`chimes/${encodeURIComponent(chimeId)}`, body));
|
|
1324
|
+
}));
|
|
1325
|
+
};
|
|
1326
|
+
//#endregion
|
|
1327
|
+
//#region src/tools/events.ts
|
|
1328
|
+
/**
|
|
1329
|
+
* The event types worth searching. Protect emits far more — disk health,
|
|
1330
|
+
* firmware updates, adoption, connection churn — but they are noise in an
|
|
1331
|
+
* event search and swamp the interesting ones.
|
|
1332
|
+
*/
|
|
1333
|
+
const EVENT_TYPES = [
|
|
1334
|
+
"motion",
|
|
1335
|
+
"smartDetectZone",
|
|
1336
|
+
"smartDetectLine",
|
|
1337
|
+
"smartAudioDetect",
|
|
1338
|
+
"ring",
|
|
1339
|
+
"doorAccess",
|
|
1340
|
+
"nfcCardScanned",
|
|
1341
|
+
"fingerprintIdentified",
|
|
1342
|
+
"disconnect",
|
|
1343
|
+
"cameraConnected",
|
|
1344
|
+
"cameraDisconnected",
|
|
1345
|
+
"recordingDeleted"
|
|
1346
|
+
];
|
|
1347
|
+
/**
|
|
1348
|
+
* The default set: what a person means by "what happened". Motion, anything the
|
|
1349
|
+
* camera classified, and doorbell rings — with the device-health chatter left out.
|
|
1350
|
+
*/
|
|
1351
|
+
const DEFAULT_EVENT_TYPES = [
|
|
1352
|
+
"motion",
|
|
1353
|
+
"smartDetectZone",
|
|
1354
|
+
"smartDetectLine",
|
|
1355
|
+
"smartAudioDetect",
|
|
1356
|
+
"ring"
|
|
1357
|
+
];
|
|
1358
|
+
const SMART_DETECT_TYPES = [
|
|
1359
|
+
"person",
|
|
1360
|
+
"vehicle",
|
|
1361
|
+
"animal",
|
|
1362
|
+
"package",
|
|
1363
|
+
"licensePlate",
|
|
1364
|
+
"face",
|
|
1365
|
+
"alrmSmoke",
|
|
1366
|
+
"alrmCmonx",
|
|
1367
|
+
"alrmSiren",
|
|
1368
|
+
"alrmBabyCry",
|
|
1369
|
+
"alrmSpeak",
|
|
1370
|
+
"alrmBark",
|
|
1371
|
+
"alrmBurglar",
|
|
1372
|
+
"alrmCarHorn",
|
|
1373
|
+
"alrmGlassBreak"
|
|
1374
|
+
];
|
|
1375
|
+
const registerEventTools = (server, client, ctx) => {
|
|
1376
|
+
server.registerTool("unifi_protect_list_events", {
|
|
1377
|
+
description: "Search recorded events over any time range — motion, smart detections (person, vehicle, animal, package, licence plate), doorbell rings, and camera connection changes. This is the tool for questions like \"what happened at the front door last night\". Each result carries its camera's NAME as well as its id, so no second lookup is needed. Narrow with `types`, `smartDetectTypes` and `cameraId` wherever you can: a busy system logs thousands of motion events a day, and an unfiltered query returns the newest slice of that rather than the interesting part.",
|
|
1378
|
+
inputSchema: {
|
|
1379
|
+
start: timeArg("Beginning of the search window."),
|
|
1380
|
+
end: timeArg("End of the search window."),
|
|
1381
|
+
types: z.array(z.enum(EVENT_TYPES)).optional().describe("Event types to include. Defaults to motion, smart detections and rings. `smartDetectZone` is the object-detection type — pair it with smartDetectTypes to ask for people or vehicles specifically."),
|
|
1382
|
+
smartDetectTypes: z.array(z.enum(SMART_DETECT_TYPES)).optional().describe("What the camera classified, e.g. [\"person\"] or [\"vehicle\",\"licensePlate\"]. Only meaningful for smartDetectZone / smartDetectLine events; the alrm* values are audio detections. Cameras without smart detection never produce these."),
|
|
1383
|
+
cameraId: cameraIdArg.optional().describe("Restrict to one camera — the `id` from unifi_protect_list_cameras. Omit for all cameras."),
|
|
1384
|
+
limit: limitArg,
|
|
1385
|
+
order: z.enum(["newest", "oldest"]).default("newest").describe("Which end of the window to return first.")
|
|
1386
|
+
},
|
|
1387
|
+
annotations: { readOnlyHint: true }
|
|
1388
|
+
}, async ({ start, end, types, smartDetectTypes, cameraId, limit, order }) => wrap(async () => {
|
|
1389
|
+
const endMs = end === void 0 ? Date.now() : toEpochMs(end);
|
|
1390
|
+
const startMs = start === void 0 ? endMs - 864e5 : toEpochMs(start);
|
|
1391
|
+
if (startMs >= endMs) throw new Error(`The window is empty: start (${new Date(startMs).toISOString()}) is not before end (${new Date(endMs).toISOString()}).`);
|
|
1392
|
+
const requestedTypes = types && types.length > 0 ? types : [...DEFAULT_EVENT_TYPES];
|
|
1393
|
+
const raw = await client.get("events", {
|
|
1394
|
+
start: startMs,
|
|
1395
|
+
end: endMs,
|
|
1396
|
+
limit,
|
|
1397
|
+
orderDirection: order === "newest" ? "DESC" : "ASC",
|
|
1398
|
+
types: [...requestedTypes],
|
|
1399
|
+
...smartDetectTypes && smartDetectTypes.length > 0 ? { smartDetectTypes: [...smartDetectTypes] } : {},
|
|
1400
|
+
withoutDescriptions: "true"
|
|
1401
|
+
});
|
|
1402
|
+
const cameras = await ctx.devices.cameras();
|
|
1403
|
+
const events = Array.isArray(raw) ? raw : [];
|
|
1404
|
+
const shaped = events.filter((e) => typeof e === "object" && e !== null).filter((e) => cameraId === void 0 || e.camera === cameraId).map((e) => summarizeEvent(e, cameras));
|
|
1405
|
+
return {
|
|
1406
|
+
window: {
|
|
1407
|
+
start: new Date(startMs).toISOString(),
|
|
1408
|
+
end: new Date(endMs).toISOString()
|
|
1409
|
+
},
|
|
1410
|
+
types: requestedTypes,
|
|
1411
|
+
count: shaped.length,
|
|
1412
|
+
...events.length >= limit ? {
|
|
1413
|
+
truncated: true,
|
|
1414
|
+
note: `Returned the ${order === "newest" ? "newest" : "oldest"} ${limit} events; more exist in this window. Narrow the range or raise limit.`
|
|
1415
|
+
} : {},
|
|
1416
|
+
events: shaped
|
|
1417
|
+
};
|
|
1418
|
+
}));
|
|
1419
|
+
server.registerTool("unifi_protect_get_event", {
|
|
1420
|
+
description: "Get one event's full record, including detection metadata the search results leave out — per-object tracking, detected zones, licence plate text and vehicle attributes where the camera captured them. Use the `id` from unifi_protect_list_events.",
|
|
1421
|
+
inputSchema: { eventId: z.string().min(1).describe("Event id — the `id` from unifi_protect_list_events.") },
|
|
1422
|
+
annotations: { readOnlyHint: true }
|
|
1423
|
+
}, async ({ eventId }) => wrap(() => client.get(`events/${encodeURIComponent(eventId)}`)));
|
|
1424
|
+
server.registerTool("unifi_protect_get_event_thumbnail", {
|
|
1425
|
+
description: "Fetch the still image Protect captured for an event — the frame that triggered the detection. Writes it to disk and returns the path by default; set output=\"image\" to return it inline for a vision model to look at, which costs a large amount of context. Pass the event's `id` from unifi_protect_list_events; results showing `hasThumbnail: true` have one.",
|
|
1426
|
+
inputSchema: {
|
|
1427
|
+
eventId: z.string().min(1).describe("Event id — the `id` from unifi_protect_list_events. Results with `hasThumbnail: true` have an image; others return 404. A raw `e-…` value from the console's own payload is also accepted."),
|
|
1428
|
+
output: z.enum(["file", "image"]).default("file").describe("\"file\" writes it to disk and returns the path; \"image\" returns it inline."),
|
|
1429
|
+
savePath: z.string().optional().describe("Absolute path to write the JPEG to. Defaults to a file under UNIFI_PROTECT_SNAPSHOT_DIR. Parent directories are created.")
|
|
1430
|
+
},
|
|
1431
|
+
annotations: { readOnlyHint: true }
|
|
1432
|
+
}, async ({ eventId, output, savePath }) => wrapResult(async () => {
|
|
1433
|
+
const id = eventId.startsWith("e-") ? eventId.slice(2) : eventId;
|
|
1434
|
+
const { bytes, contentType } = await client.requestBytes(`events/${encodeURIComponent(id)}/thumbnail`, { accept: "image/jpeg" });
|
|
1435
|
+
if (output === "image") return { content: [{
|
|
1436
|
+
type: "image",
|
|
1437
|
+
data: Buffer.from(bytes).toString("base64"),
|
|
1438
|
+
mimeType: contentType.startsWith("image/") ? contentType : "image/jpeg"
|
|
1439
|
+
}] };
|
|
1440
|
+
const path = savePath ?? join(ctx.config.snapshotDir, `event-${slugId(id)}.jpg`);
|
|
1441
|
+
await mkdir(join(path, ".."), { recursive: true });
|
|
1442
|
+
await writeFile(path, bytes);
|
|
1443
|
+
return ok({
|
|
1444
|
+
path,
|
|
1445
|
+
bytes: bytes.byteLength,
|
|
1446
|
+
contentType
|
|
1447
|
+
});
|
|
1448
|
+
}));
|
|
1449
|
+
server.registerTool("unifi_protect_export_video", {
|
|
1450
|
+
description: "Export recorded footage from one camera over a time range as an MP4 file on disk. Always writes to a file and returns the path — video is never returned inline. Size grows quickly with the window: expect tens of megabytes per minute at full quality, and the call fails rather than exhausting memory if the export exceeds UNIFI_PROTECT_MAX_DOWNLOAD_BYTES. Footage only exists if the camera was recording at the time, so check the recording mode before concluding that nothing happened.",
|
|
1451
|
+
inputSchema: {
|
|
1452
|
+
cameraId: cameraIdArg,
|
|
1453
|
+
start: requiredTimeArg("Beginning of the footage to export."),
|
|
1454
|
+
end: requiredTimeArg("End of the footage to export."),
|
|
1455
|
+
savePath: z.string().optional().describe("Absolute path to write the MP4 to. Defaults to a timestamped file under UNIFI_PROTECT_SNAPSHOT_DIR. Parent directories are created."),
|
|
1456
|
+
channel: z.number().int().min(0).max(3).default(0).describe("Encoder channel: 0 is the high-quality stream, higher numbers are progressively lower bitrate. Use a higher channel to keep a long export manageable.")
|
|
1457
|
+
},
|
|
1458
|
+
annotations: { readOnlyHint: true }
|
|
1459
|
+
}, async ({ cameraId, start, end, savePath, channel }) => wrap(async () => {
|
|
1460
|
+
const startMs = toEpochMs(start);
|
|
1461
|
+
const endMs = toEpochMs(end);
|
|
1462
|
+
if (startMs >= endMs) throw new Error(`The window is empty: start (${new Date(startMs).toISOString()}) is not before end (${new Date(endMs).toISOString()}).`);
|
|
1463
|
+
const { bytes, contentType } = await client.requestBytes("video/export", {
|
|
1464
|
+
query: {
|
|
1465
|
+
camera: cameraId,
|
|
1466
|
+
start: startMs,
|
|
1467
|
+
end: endMs,
|
|
1468
|
+
channel
|
|
1469
|
+
},
|
|
1470
|
+
accept: "video/mp4"
|
|
1471
|
+
});
|
|
1472
|
+
const name = (await ctx.devices.cameras()).get(cameraId) ?? cameraId;
|
|
1473
|
+
const stamp = new Date(startMs).toISOString().replace(/[:.]/g, "-");
|
|
1474
|
+
const path = savePath ?? join(ctx.config.snapshotDir, `${slugId(name)}-${stamp}.mp4`);
|
|
1475
|
+
await mkdir(join(path, ".."), { recursive: true });
|
|
1476
|
+
await writeFile(path, bytes);
|
|
1477
|
+
return {
|
|
1478
|
+
path,
|
|
1479
|
+
bytes: bytes.byteLength,
|
|
1480
|
+
contentType,
|
|
1481
|
+
camera: name,
|
|
1482
|
+
window: {
|
|
1483
|
+
start: new Date(startMs).toISOString(),
|
|
1484
|
+
end: new Date(endMs).toISOString()
|
|
1485
|
+
}
|
|
1486
|
+
};
|
|
1487
|
+
}));
|
|
1488
|
+
};
|
|
1489
|
+
const slugId = (value) => value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "export";
|
|
1490
|
+
//#endregion
|
|
1491
|
+
//#region src/tools/request.ts
|
|
1492
|
+
/**
|
|
1493
|
+
* Reject anything that is not a plain relative path. The server decides the
|
|
1494
|
+
* host and the `/proxy/protect/api` prefix; letting a caller supply either
|
|
1495
|
+
* would send the console session somewhere it does not belong.
|
|
1496
|
+
*/
|
|
1497
|
+
const assertSafePath = (path) => {
|
|
1498
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(path)) throw new Error("`path` must be a path, not an absolute URL — the server sets the host.");
|
|
1499
|
+
if (path.split("/").includes("..")) throw new Error("`path` must not contain `..` segments.");
|
|
1500
|
+
};
|
|
1501
|
+
const registerRequestTool = (server, client, allowWrites) => {
|
|
1502
|
+
const methods = allowWrites ? [
|
|
1503
|
+
"GET",
|
|
1504
|
+
"POST",
|
|
1505
|
+
"PATCH",
|
|
1506
|
+
"DELETE"
|
|
1507
|
+
] : ["GET"];
|
|
1508
|
+
server.registerTool("unifi_protect_request", {
|
|
1509
|
+
description: "Escape hatch: call any private Protect API endpoint directly, relative to /proxy/protect/api. This exists because the private API is undocumented and Ubiquiti moves endpoints between Protect releases — when a wrapped tool starts returning 404, this reaches the replacement without waiting for a new version of this server. Responses are returned RAW and unshaped, so a broad endpoint like `bootstrap` can return hundreds of kilobytes; prefer the wrapped tools, which summarize. " + (allowWrites ? "Writes are ENABLED, so POST, PATCH and DELETE are permitted — there is no confirmation step here, so check the path before you call it." : "Writes are DISABLED: only GET is permitted. Set UNIFI_PROTECT_ALLOW_WRITES=1 to allow mutations."),
|
|
1510
|
+
inputSchema: {
|
|
1511
|
+
path: z.string().min(1).describe("Path relative to /proxy/protect/api, without a leading slash, e.g. \"cameras\", \"nvr\", \"events/abc123\". Not an absolute URL."),
|
|
1512
|
+
method: z.enum(methods).default("GET").describe("HTTP method."),
|
|
1513
|
+
query: z.record(z.string(), z.string()).optional().describe("Query parameters as a flat string map, e.g. {\"start\":\"1756500000000\"}. Remember that Protect times are milliseconds since the epoch."),
|
|
1514
|
+
body: z.record(z.string(), z.unknown()).optional().describe("JSON request body, for POST and PATCH.")
|
|
1515
|
+
},
|
|
1516
|
+
annotations: {
|
|
1517
|
+
readOnlyHint: !allowWrites,
|
|
1518
|
+
destructiveHint: allowWrites
|
|
1519
|
+
}
|
|
1520
|
+
}, async ({ path, method, query, body }) => wrap(async () => {
|
|
1521
|
+
if (!allowWrites && method !== "GET") throw new WritesDisabledError(`unifi_protect_request with method ${method}`);
|
|
1522
|
+
assertSafePath(path);
|
|
1523
|
+
return client.request(method, path, {
|
|
1524
|
+
...query ? { query } : {},
|
|
1525
|
+
...body ? { body } : {}
|
|
1526
|
+
});
|
|
1527
|
+
}));
|
|
1528
|
+
};
|
|
1529
|
+
//#endregion
|
|
1530
|
+
//#region src/tools/status.ts
|
|
1531
|
+
const fileMode = (path) => {
|
|
1532
|
+
try {
|
|
1533
|
+
return statSync(path).mode & 511;
|
|
1534
|
+
} catch {
|
|
1535
|
+
return;
|
|
1536
|
+
}
|
|
1537
|
+
};
|
|
1538
|
+
const registerStatusTools = (server, client, ctx) => {
|
|
1539
|
+
server.registerTool("unifi_protect_auth_status", {
|
|
1540
|
+
description: "Check whether this server can actually reach your UniFi Protect console. By default it logs in and makes a real call, so the answer reflects the console rather than cached state — this is the tool to run when something is not working. Reports the host, the account, the Protect version, whether TLS is verified, and whether writes are enabled; when nothing is configured it returns the exact setup steps instead. Call this first when a tool you expected is not listed: an absent tool means missing configuration or writes being off, not a bug.",
|
|
1541
|
+
inputSchema: { probe: z.boolean().default(true).describe("Actually contact the console (logging in if needed) rather than only reporting what is already cached. Set false for a fast, purely local answer.") },
|
|
1542
|
+
annotations: { readOnlyHint: true }
|
|
1543
|
+
}, async ({ probe }) => wrap(async () => {
|
|
1544
|
+
if (!isConfigured(ctx.config)) return {
|
|
1545
|
+
configured: false,
|
|
1546
|
+
host: ctx.config.baseUrl ?? null,
|
|
1547
|
+
available_without_credentials: ["unifi_protect_auth_status"],
|
|
1548
|
+
setup: setupInstructions(ctx.config)
|
|
1549
|
+
};
|
|
1550
|
+
let reachable;
|
|
1551
|
+
let version;
|
|
1552
|
+
let failure;
|
|
1553
|
+
if (probe) try {
|
|
1554
|
+
const nvr = (await client.get("bootstrap"))?.nvr;
|
|
1555
|
+
version = typeof nvr === "object" && nvr !== null ? nvr.version : void 0;
|
|
1556
|
+
reachable = true;
|
|
1557
|
+
} catch (err) {
|
|
1558
|
+
reachable = false;
|
|
1559
|
+
failure = err instanceof Error ? err.message : String(err);
|
|
1560
|
+
}
|
|
1561
|
+
const status = ctx.session.describe();
|
|
1562
|
+
const mode = fileMode(ctx.config.sessionFile);
|
|
1563
|
+
return {
|
|
1564
|
+
configured: true,
|
|
1565
|
+
host: ctx.config.baseUrl,
|
|
1566
|
+
username: status.username ?? null,
|
|
1567
|
+
...probe ? {
|
|
1568
|
+
reachable,
|
|
1569
|
+
...version !== void 0 ? { protectVersion: version } : {},
|
|
1570
|
+
...failure ? { failure } : {}
|
|
1571
|
+
} : {},
|
|
1572
|
+
session: {
|
|
1573
|
+
authenticated: status.authenticated,
|
|
1574
|
+
source: status.source,
|
|
1575
|
+
established_at: status.savedAt ?? null,
|
|
1576
|
+
file: ctx.config.sessionFile,
|
|
1577
|
+
file_mode: mode === void 0 ? "absent" : `0${mode.toString(8)}`,
|
|
1578
|
+
...mode !== void 0 && (mode & 63) !== 0 ? { warning: `Readable by other users. Run: chmod 600 ${ctx.config.sessionFile}` } : {}
|
|
1579
|
+
},
|
|
1580
|
+
tls: ctx.config.verifyTls ? "verified" : "UNVERIFIED — certificate checks are off for this server's requests only. Verifying needs both NODE_EXTRA_CA_CERTS pointing at the console certificate and UNIFI_PROTECT_HOST set to a host name: the certificate carries no IP SAN.",
|
|
1581
|
+
writes: ctx.allowWrites ? "enabled" : "disabled",
|
|
1582
|
+
api: "private (undocumented). Ubiquiti moves these endpoints between Protect releases, so the Protect version reported above is the first thing to check if a tool that used to work starts returning 404."
|
|
1583
|
+
};
|
|
1584
|
+
}));
|
|
1585
|
+
if (!isConfigured(ctx.config)) return;
|
|
1586
|
+
server.registerTool("unifi_protect_auth_login", {
|
|
1587
|
+
description: "Force a fresh login to the console, replacing any cached session. Normally unnecessary — the server logs in on demand and re-authenticates automatically on a 401. Use it to supply a two-factor code, which cannot be done unattended: the code is single-use and expires in about 30 seconds, so it is passed here once and the resulting session is then cached and reused.",
|
|
1588
|
+
inputSchema: { totp: z.string().regex(/^\d{6,8}$/, "A two-factor code is 6 to 8 digits.").optional().describe("Current code from your authenticator app. Omit if the account has no 2FA.") },
|
|
1589
|
+
annotations: {
|
|
1590
|
+
readOnlyHint: false,
|
|
1591
|
+
destructiveHint: false,
|
|
1592
|
+
idempotentHint: false
|
|
1593
|
+
}
|
|
1594
|
+
}, async ({ totp }) => wrap(async () => {
|
|
1595
|
+
const status = await ctx.session.login(totp);
|
|
1596
|
+
return {
|
|
1597
|
+
authenticated: status.authenticated,
|
|
1598
|
+
username: status.username,
|
|
1599
|
+
established_at: status.savedAt,
|
|
1600
|
+
session_file: ctx.config.sessionFile,
|
|
1601
|
+
note: "The session is stored with mode 600 and reused until the console rejects it."
|
|
1602
|
+
};
|
|
1603
|
+
}));
|
|
1604
|
+
server.registerTool("unifi_protect_auth_logout", {
|
|
1605
|
+
description: "Drop the cached session and delete the session file. The next call logs in again from the configured username and password, so this does not lock anything out — use it to clear a session after changing accounts, or to remove the cookie from disk.",
|
|
1606
|
+
inputSchema: { confirm: confirmArg },
|
|
1607
|
+
annotations: {
|
|
1608
|
+
readOnlyHint: false,
|
|
1609
|
+
destructiveHint: true,
|
|
1610
|
+
idempotentHint: true
|
|
1611
|
+
}
|
|
1612
|
+
}, async () => wrap(async () => {
|
|
1613
|
+
await ctx.session.logout();
|
|
1614
|
+
return {
|
|
1615
|
+
logged_out: true,
|
|
1616
|
+
note: "Credentials are unchanged; the next tool call will log in again."
|
|
1617
|
+
};
|
|
1618
|
+
}));
|
|
1619
|
+
};
|
|
1620
|
+
//#endregion
|
|
1621
|
+
//#region src/tools/system.ts
|
|
1622
|
+
const registerSystemTools = (server, client, ctx) => {
|
|
1623
|
+
server.registerTool("unifi_protect_get_system_info", {
|
|
1624
|
+
description: "Overview of the console: model, Protect version, firmware, timezone, uptime, storage use and how many devices of each type are adopted. Worth calling first on an unfamiliar system. The reported Protect version matters: this server talks to Protect's private API, which Ubiquiti changes between releases, so a version that differs from the one in the README is the first thing to check if a tool starts returning 404.",
|
|
1625
|
+
inputSchema: {},
|
|
1626
|
+
annotations: { readOnlyHint: true }
|
|
1627
|
+
}, async () => wrap(async () => summarizeBootstrap(await client.get("bootstrap"))));
|
|
1628
|
+
server.registerTool("unifi_protect_list_users", {
|
|
1629
|
+
description: "List the accounts that can sign in to Protect, with their role and last login. Useful for auditing who has access to the cameras.",
|
|
1630
|
+
inputSchema: {},
|
|
1631
|
+
annotations: { readOnlyHint: true }
|
|
1632
|
+
}, async () => wrap(async () => summarizeEach(await client.get("users"), summarizeUser)));
|
|
1633
|
+
server.registerTool("unifi_protect_list_liveviews", {
|
|
1634
|
+
description: "List the saved live views — the named camera grid layouts shown on viewers and in the Protect app. The returned id is what unifi_protect_update_viewer needs to put a layout on a screen.",
|
|
1635
|
+
inputSchema: {},
|
|
1636
|
+
annotations: { readOnlyHint: true }
|
|
1637
|
+
}, async () => wrap(async () => summarizeEach(await client.get("liveviews"), summarizeLiveview)));
|
|
1638
|
+
if (!ctx.allowWrites) return;
|
|
1639
|
+
server.registerTool("unifi_protect_update_nvr_settings", {
|
|
1640
|
+
description: "Change console-wide settings. `isRecordingDisabled` is the significant one: turning it on stops recording on EVERY camera at once, so nothing is written until it is turned back off. Only the fields you pass are sent.",
|
|
1641
|
+
inputSchema: {
|
|
1642
|
+
name: z.string().min(1).optional().describe("Display name for the console."),
|
|
1643
|
+
timezone: z.string().min(1).optional().describe("IANA timezone, e.g. \"Europe/Paris\". Affects event timestamps and schedules."),
|
|
1644
|
+
isRecordingDisabled: z.boolean().optional().describe("Disable recording across every camera. True means no footage is kept, system-wide.")
|
|
1645
|
+
},
|
|
1646
|
+
annotations: {
|
|
1647
|
+
readOnlyHint: false,
|
|
1648
|
+
destructiveHint: false,
|
|
1649
|
+
idempotentHint: true
|
|
1650
|
+
}
|
|
1651
|
+
}, async ({ name, timezone, isRecordingDisabled }) => wrap(async () => {
|
|
1652
|
+
const body = compactOrUndefined({
|
|
1653
|
+
name,
|
|
1654
|
+
timezone,
|
|
1655
|
+
isRecordingDisabled
|
|
1656
|
+
});
|
|
1657
|
+
if (!body) throw new Error("Nothing to update — pass at least one of name, timezone, isRecordingDisabled.");
|
|
1658
|
+
return client.patch("nvr", body);
|
|
1659
|
+
}));
|
|
1660
|
+
server.registerTool("unifi_protect_reboot_nvr", {
|
|
1661
|
+
description: "REBOOT THE CONSOLE. Every camera stops recording for the two to five minutes it takes to come back, and footage from that window is lost permanently. This also drops the network if the console is your router (a UDM or UDM Pro), taking down everything behind it. Reboot a single unresponsive camera with unifi_protect_reboot_camera instead wherever that would do.",
|
|
1662
|
+
inputSchema: { confirm: confirmArg },
|
|
1663
|
+
annotations: {
|
|
1664
|
+
readOnlyHint: false,
|
|
1665
|
+
destructiveHint: true,
|
|
1666
|
+
idempotentHint: false
|
|
1667
|
+
}
|
|
1668
|
+
}, async () => wrap(() => client.post("nvr/reboot")));
|
|
1669
|
+
};
|
|
1670
|
+
//#endregion
|
|
1671
|
+
//#region src/tools/index.ts
|
|
1672
|
+
/**
|
|
1673
|
+
* Register the UniFi Protect tools.
|
|
1674
|
+
*
|
|
1675
|
+
* unifi_protect_auth_status comes first and unconditionally, so a server with no
|
|
1676
|
+
* console configured is still a useful one — it can say what to set — rather
|
|
1677
|
+
* than a connection that closes with its own error message swallowed.
|
|
1678
|
+
*
|
|
1679
|
+
* Read tools are then always registered; the write tools only when
|
|
1680
|
+
* `allowWrites` is set, so with the flag off they are not merely refused — they
|
|
1681
|
+
* are absent from tools/list and cannot be called at all. A refusal still lets a
|
|
1682
|
+
* model try, retry, and reason about how to get around it; a tool that does not
|
|
1683
|
+
* exist ends the conversation.
|
|
1684
|
+
*/
|
|
1685
|
+
const registerTools = (server, client, ctx) => {
|
|
1686
|
+
registerStatusTools(server, client, ctx);
|
|
1687
|
+
if (!isConfigured(ctx.config)) return;
|
|
1688
|
+
registerSystemTools(server, client, ctx);
|
|
1689
|
+
registerCameraTools(server, client, ctx);
|
|
1690
|
+
registerEventTools(server, client, ctx);
|
|
1691
|
+
registerDeviceTools(server, client, ctx);
|
|
1692
|
+
registerRequestTool(server, client, ctx.allowWrites);
|
|
1693
|
+
};
|
|
1694
|
+
//#endregion
|
|
1695
|
+
//#region src/server.ts
|
|
1696
|
+
const SERVER_NAME = BUILD_INFO.name;
|
|
1697
|
+
const SERVER_VERSION = BUILD_INFO.version;
|
|
1698
|
+
const USER_AGENT = `mcp-unifi-protect-js/${BUILD_INFO.version}`;
|
|
1699
|
+
const createServer = (opts) => {
|
|
1700
|
+
const { config } = opts;
|
|
1701
|
+
const server = new McpServer({
|
|
1702
|
+
name: SERVER_NAME,
|
|
1703
|
+
version: SERVER_VERSION
|
|
1704
|
+
});
|
|
1705
|
+
const fetchImpl = opts.fetch ?? createHttpFetch({
|
|
1706
|
+
insecureTls: !config.verifyTls,
|
|
1707
|
+
...opts.logger ? { logger: opts.logger } : {}
|
|
1708
|
+
});
|
|
1709
|
+
const session = opts.session ?? createSessionProvider({
|
|
1710
|
+
config,
|
|
1711
|
+
fetch: fetchImpl,
|
|
1712
|
+
...opts.logger ? { logger: opts.logger } : {}
|
|
1713
|
+
});
|
|
1714
|
+
const client = new ProtectClient({
|
|
1715
|
+
baseUrl: config.baseUrl ?? "https://unconfigured.invalid",
|
|
1716
|
+
session,
|
|
1717
|
+
maxRetries: config.maxRetries,
|
|
1718
|
+
userAgent: USER_AGENT,
|
|
1719
|
+
maxDownloadBytes: config.maxDownloadBytes,
|
|
1720
|
+
fetch: fetchImpl,
|
|
1721
|
+
...opts.logger ? { logger: opts.logger } : {}
|
|
1722
|
+
});
|
|
1723
|
+
const devices = createDeviceCache({
|
|
1724
|
+
client,
|
|
1725
|
+
ttlSeconds: config.deviceCacheTtlSeconds
|
|
1726
|
+
});
|
|
1727
|
+
registerTools(server, client, {
|
|
1728
|
+
config,
|
|
1729
|
+
allowWrites: config.allowWrites,
|
|
1730
|
+
session,
|
|
1731
|
+
devices
|
|
1732
|
+
});
|
|
1733
|
+
return {
|
|
1734
|
+
server,
|
|
1735
|
+
client,
|
|
1736
|
+
session,
|
|
1737
|
+
devices
|
|
1738
|
+
};
|
|
1739
|
+
};
|
|
1740
|
+
//#endregion
|
|
1741
|
+
export { saveSession as A, loadConfig as B, summarizeSensor as C, staticSessionProvider as D, createSessionProvider as E, LOGIN_PATH as F, BUILD_INFO as G, resolveConfigPath as H, PRIVATE_API_PATH as I, UPDATES_WS_PATH as L, ProtectApiError as M, ProtectAuthError as N, clearSession as O, WritesDisabledError as P, expandTilde as R, summarizeNvr as S, summarizeViewer as T, resolveSessionPath as U, normalizeBaseUrl as V, setupInstructions as W, summarizeChime as _, registerTools as a, summarizeLight as b, ProtectClient as c, retryAfterMs as d, createDeviceCache as f, summarizeCamera as g, summarizeBootstrap as h, createServer as i, NotConfiguredError as j, loadSession as k, backoffMs as l, isoTime as m, SERVER_VERSION as n, assertSafePath as o, buildNameIndex as p, USER_AGENT as r, toEpochMs as s, SERVER_NAME as t, buildQuery as u, summarizeEach as v, summarizeUser as w, summarizeLiveview as x, summarizeEvent as y, isConfigured as z };
|
|
1742
|
+
|
|
1743
|
+
//# sourceMappingURL=server-iu_3JECB.js.map
|