@mp-consulting/homebridge-unifi-access 1.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/.claude/settings.local.json +91 -0
- package/CHANGELOG.md +13 -0
- package/LICENSE.md +22 -0
- package/README.md +159 -0
- package/config.schema.json +202 -0
- package/dist/access-controller.d.ts +41 -0
- package/dist/access-controller.js +342 -0
- package/dist/access-controller.js.map +1 -0
- package/dist/access-device-catalog.d.ts +43 -0
- package/dist/access-device-catalog.js +151 -0
- package/dist/access-device-catalog.js.map +1 -0
- package/dist/access-device.d.ts +68 -0
- package/dist/access-device.js +330 -0
- package/dist/access-device.js.map +1 -0
- package/dist/access-events.d.ts +27 -0
- package/dist/access-events.js +152 -0
- package/dist/access-events.js.map +1 -0
- package/dist/access-options.d.ts +32 -0
- package/dist/access-options.js +65 -0
- package/dist/access-options.js.map +1 -0
- package/dist/access-platform.d.ts +15 -0
- package/dist/access-platform.js +74 -0
- package/dist/access-platform.js.map +1 -0
- package/dist/access-types.d.ts +30 -0
- package/dist/access-types.js +42 -0
- package/dist/access-types.js.map +1 -0
- package/dist/hub/access-hub-api.d.ts +13 -0
- package/dist/hub/access-hub-api.js +140 -0
- package/dist/hub/access-hub-api.js.map +1 -0
- package/dist/hub/access-hub-events.d.ts +2 -0
- package/dist/hub/access-hub-events.js +229 -0
- package/dist/hub/access-hub-events.js.map +1 -0
- package/dist/hub/access-hub-mqtt.d.ts +2 -0
- package/dist/hub/access-hub-mqtt.js +137 -0
- package/dist/hub/access-hub-mqtt.js.map +1 -0
- package/dist/hub/access-hub-services.d.ts +4 -0
- package/dist/hub/access-hub-services.js +451 -0
- package/dist/hub/access-hub-services.js.map +1 -0
- package/dist/hub/access-hub-types.d.ts +145 -0
- package/dist/hub/access-hub-types.js +35 -0
- package/dist/hub/access-hub-types.js.map +1 -0
- package/dist/hub/access-hub-utils.d.ts +20 -0
- package/dist/hub/access-hub-utils.js +128 -0
- package/dist/hub/access-hub-utils.js.map +1 -0
- package/dist/hub/access-hub.d.ts +39 -0
- package/dist/hub/access-hub.js +185 -0
- package/dist/hub/access-hub.js.map +1 -0
- package/dist/hub/index.d.ts +4 -0
- package/dist/hub/index.js +7 -0
- package/dist/hub/index.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/settings.d.ts +16 -0
- package/dist/settings.js +49 -0
- package/dist/settings.js.map +1 -0
- package/docs/FeatureOptions.md +120 -0
- package/docs/MQTT.md +116 -0
- package/docs/api_reference.pdf +0 -0
- package/docs/media/homebridge-unifi-access.png +0 -0
- package/docs/media/homebridge-unifi-access.svg +21 -0
- package/eslint.config.mjs +99 -0
- package/homebridge-ui/public/app.js +104 -0
- package/homebridge-ui/public/index.html +267 -0
- package/homebridge-ui/public/modules/constants.js +22 -0
- package/homebridge-ui/public/modules/controllers.js +202 -0
- package/homebridge-ui/public/modules/discovery.js +89 -0
- package/homebridge-ui/public/modules/dom-helpers.js +41 -0
- package/homebridge-ui/public/modules/feature-options.js +625 -0
- package/homebridge-ui/public/modules/state.js +26 -0
- package/homebridge-ui/public/styles.css +533 -0
- package/homebridge-ui/server.js +374 -0
- package/package.json +83 -0
- package/scripts/event-schema-monitor.ts +350 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
#!/usr/bin/env npx tsx
|
|
2
|
+
/* Copyright(C) 2026, Mickael Palma. All rights reserved.
|
|
3
|
+
*
|
|
4
|
+
* event-schema-monitor.ts: Connects to a UniFi Access controller, listens to live events, and validates each message against known schemas. Reports any schema drift
|
|
5
|
+
* (new fields, missing fields, type changes) that could indicate an API change in a firmware update.
|
|
6
|
+
*
|
|
7
|
+
* Credentials are read from the Homebridge config file (tests/hbConfig/config.json) by default. Use --config to specify a different config file, or override individual
|
|
8
|
+
* fields with --address, --username, --password.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* npx tsx scripts/event-schema-monitor.ts # reads from tests/hbConfig/config.json
|
|
12
|
+
* npx tsx scripts/event-schema-monitor.ts --config /path/to/config.json # reads from a custom config file
|
|
13
|
+
* npx tsx scripts/event-schema-monitor.ts --address <ip> --username <u> --password <p> # explicit credentials
|
|
14
|
+
*/
|
|
15
|
+
import { AccessApi } from "unifi-access";
|
|
16
|
+
import type { AccessEventPacket } from "unifi-access";
|
|
17
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { parseArgs } from "node:util";
|
|
20
|
+
import {
|
|
21
|
+
eventSchemas, packetEnvelopeSchema, validateSchema, type SchemaIssue
|
|
22
|
+
} from "../tests/event-schemas.js";
|
|
23
|
+
|
|
24
|
+
// ---- Config file loading ----
|
|
25
|
+
|
|
26
|
+
const DEFAULT_CONFIG_PATH = join(import.meta.dirname ?? ".", "..", "tests", "hbConfig", "config.json");
|
|
27
|
+
|
|
28
|
+
// Read controller credentials from a Homebridge config.json file.
|
|
29
|
+
function loadFromConfig(configPath: string): { address: string; username: string; password: string } | undefined {
|
|
30
|
+
|
|
31
|
+
if(!existsSync(configPath)) {
|
|
32
|
+
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const raw = JSON.parse(readFileSync(configPath, "utf-8")) as {
|
|
37
|
+
platforms?: { controllers?: { address?: string; password?: string; username?: string }[]; platform?: string }[];
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const platform = raw.platforms?.find(p => p.platform === "UniFi Access");
|
|
41
|
+
const controller = platform?.controllers?.[0];
|
|
42
|
+
|
|
43
|
+
if(!controller?.address || !controller?.username || !controller?.password) {
|
|
44
|
+
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return { address: controller.address, password: controller.password, username: controller.username };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---- CLI ----
|
|
52
|
+
|
|
53
|
+
function parseCliArgs(): { address: string; username: string; password: string } {
|
|
54
|
+
|
|
55
|
+
const { values } = parseArgs({
|
|
56
|
+
|
|
57
|
+
options: {
|
|
58
|
+
|
|
59
|
+
address: { short: "a", type: "string" },
|
|
60
|
+
config: { short: "c", type: "string" },
|
|
61
|
+
password: { short: "p", type: "string" },
|
|
62
|
+
username: { short: "u", type: "string" }
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
strict: false
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Load from config file (explicit --config path, or default).
|
|
69
|
+
const configPath = (values.config as string) ?? DEFAULT_CONFIG_PATH;
|
|
70
|
+
const configCreds = loadFromConfig(configPath);
|
|
71
|
+
|
|
72
|
+
// CLI flags override config file values.
|
|
73
|
+
const address = (values.address as string) ?? configCreds?.address;
|
|
74
|
+
const username = (values.username as string) ?? configCreds?.username;
|
|
75
|
+
const password = (values.password as string) ?? configCreds?.password;
|
|
76
|
+
|
|
77
|
+
if(!address || !username || !password) {
|
|
78
|
+
|
|
79
|
+
console.error("Could not find controller credentials.");
|
|
80
|
+
console.error();
|
|
81
|
+
console.error("Options:");
|
|
82
|
+
console.error(" 1. Ensure tests/hbConfig/config.json has a UniFi Access platform with controller credentials.");
|
|
83
|
+
console.error(" 2. Use --config <path> to point to a different Homebridge config.json.");
|
|
84
|
+
console.error(" 3. Use --address <ip> --username <user> --password <pass> to specify credentials directly.");
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if(configCreds && !values.address) {
|
|
89
|
+
|
|
90
|
+
console.log(colors.dim(`Loaded credentials from ${configPath}`));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { address, password, username };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Color helpers for terminal output.
|
|
97
|
+
const colors = {
|
|
98
|
+
|
|
99
|
+
blue: (s: string) => `\x1b[34m${s}\x1b[0m`,
|
|
100
|
+
bold: (s: string) => `\x1b[1m${s}\x1b[0m`,
|
|
101
|
+
cyan: (s: string) => `\x1b[36m${s}\x1b[0m`,
|
|
102
|
+
dim: (s: string) => `\x1b[2m${s}\x1b[0m`,
|
|
103
|
+
green: (s: string) => `\x1b[32m${s}\x1b[0m`,
|
|
104
|
+
red: (s: string) => `\x1b[31m${s}\x1b[0m`,
|
|
105
|
+
yellow: (s: string) => `\x1b[33m${s}\x1b[0m`
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// Track event statistics.
|
|
109
|
+
const stats = {
|
|
110
|
+
|
|
111
|
+
matched: 0,
|
|
112
|
+
total: 0,
|
|
113
|
+
unknown: 0,
|
|
114
|
+
withIssues: 0
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// Map issue types to the source files that need updating.
|
|
118
|
+
const UPDATE_GUIDES: Record<string, { description: string; files: string[] }> = {
|
|
119
|
+
|
|
120
|
+
"data_schema": {
|
|
121
|
+
description: "Event payload schema changed",
|
|
122
|
+
files: [
|
|
123
|
+
"tests/event-schemas.ts — update the SchemaDefinition for this event type",
|
|
124
|
+
"src/hub/access-hub-types.ts — update the TypeScript interface if the plugin uses this field"
|
|
125
|
+
]
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
"envelope_schema": {
|
|
129
|
+
description: "Event envelope structure changed",
|
|
130
|
+
files: [
|
|
131
|
+
"tests/event-schemas.ts — update packetEnvelopeSchema"
|
|
132
|
+
]
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
"unknown_event": {
|
|
136
|
+
description: "New event type discovered",
|
|
137
|
+
files: [
|
|
138
|
+
"tests/event-schemas.ts — add a new SchemaDefinition + entry in eventSchemas",
|
|
139
|
+
"src/access-types.ts — add the event string to AccessEventType enum (if the plugin should handle it)",
|
|
140
|
+
"src/hub/access-hub-events.ts — add a handler in registerEventHandlers (if the plugin should react to it)"
|
|
141
|
+
]
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// Prompt the user about a detected schema change and show which files to update.
|
|
146
|
+
function promptSchemaChange(guideKey: string, eventType: string, issues: SchemaIssue[]): void {
|
|
147
|
+
|
|
148
|
+
const guide = UPDATE_GUIDES[guideKey];
|
|
149
|
+
|
|
150
|
+
console.log();
|
|
151
|
+
console.log(colors.cyan(" ┌─ Schema change detected ─────────────────────────────────────────"));
|
|
152
|
+
console.log(colors.cyan(" │"));
|
|
153
|
+
console.log(colors.cyan(` │ ${guide.description}: ${colors.bold(eventType)}`));
|
|
154
|
+
console.log(colors.cyan(" │"));
|
|
155
|
+
|
|
156
|
+
for(const issue of issues) {
|
|
157
|
+
|
|
158
|
+
const icon = issue.issue === "unexpected_field" ? "+" : issue.issue === "missing_required" ? "-" : "~";
|
|
159
|
+
|
|
160
|
+
console.log(colors.cyan(` │ ${icon} ${issue.field}: ${issue.detail}`));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
console.log(colors.cyan(" │"));
|
|
164
|
+
console.log(colors.cyan(" │ Files to update:"));
|
|
165
|
+
|
|
166
|
+
for(const file of guide.files) {
|
|
167
|
+
|
|
168
|
+
console.log(colors.cyan(` │ → ${file}`));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
console.log(colors.cyan(" │"));
|
|
172
|
+
console.log(colors.cyan(" └──────────────────────────────────────────────────────────────────"));
|
|
173
|
+
console.log();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Process a single incoming event packet.
|
|
177
|
+
function processEvent(packet: AccessEventPacket): void {
|
|
178
|
+
|
|
179
|
+
stats.total++;
|
|
180
|
+
|
|
181
|
+
const timestamp = new Date().toISOString();
|
|
182
|
+
const eventType = packet.event;
|
|
183
|
+
|
|
184
|
+
// Validate the envelope.
|
|
185
|
+
const envelopeIssues = validateSchema(packet as unknown as Record<string, unknown>, packetEnvelopeSchema, "packet.");
|
|
186
|
+
|
|
187
|
+
if(envelopeIssues.length > 0) {
|
|
188
|
+
|
|
189
|
+
stats.withIssues++;
|
|
190
|
+
console.log(`${colors.dim(timestamp)} ${colors.red("MISMATCH")} ${colors.bold("ENVELOPE")} event_object_id=${packet.event_object_id}`);
|
|
191
|
+
|
|
192
|
+
for(const issue of envelopeIssues) {
|
|
193
|
+
|
|
194
|
+
const icon = issue.issue === "unexpected_field" ? colors.yellow("+") : colors.red("!");
|
|
195
|
+
|
|
196
|
+
console.log(` ${icon} ${colors.bold(issue.field)}: ${issue.issue} — ${issue.detail}`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
promptSchemaChange("envelope_schema", eventType, envelopeIssues);
|
|
200
|
+
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Look up the data schema for this event type.
|
|
205
|
+
const eventDef = eventSchemas[eventType];
|
|
206
|
+
|
|
207
|
+
if(!eventDef) {
|
|
208
|
+
|
|
209
|
+
stats.unknown++;
|
|
210
|
+
const payloadKeys = Object.keys(packet.data as Record<string, unknown>);
|
|
211
|
+
|
|
212
|
+
console.log(`${colors.dim(timestamp)} ${colors.yellow("UNKNOWN")} ${colors.bold(eventType)} event_object_id=${packet.event_object_id}`);
|
|
213
|
+
console.log(` ${colors.yellow("No schema defined for this event type. Payload keys:")} ${payloadKeys.join(", ")}`);
|
|
214
|
+
|
|
215
|
+
promptSchemaChange("unknown_event", eventType, [{
|
|
216
|
+
detail: `Payload keys: ${payloadKeys.join(", ")}`,
|
|
217
|
+
field: "data",
|
|
218
|
+
issue: "unexpected_field"
|
|
219
|
+
}]);
|
|
220
|
+
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Validate the data payload.
|
|
225
|
+
const data = packet.data as Record<string, unknown>;
|
|
226
|
+
const dataIssues = Object.keys(eventDef.schema).length > 0 ? validateSchema(data, eventDef.schema, "data.") : [];
|
|
227
|
+
|
|
228
|
+
// Validate sub-schemas (e.g. access_method, location_states[]).
|
|
229
|
+
const subIssues: SchemaIssue[] = [];
|
|
230
|
+
|
|
231
|
+
if(eventDef.subSchemas) {
|
|
232
|
+
|
|
233
|
+
for(const sub of eventDef.subSchemas) {
|
|
234
|
+
|
|
235
|
+
const subData = data[sub.path];
|
|
236
|
+
|
|
237
|
+
if(subData === undefined || subData === null) {
|
|
238
|
+
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if(sub.isArray && Array.isArray(subData)) {
|
|
243
|
+
|
|
244
|
+
for(const [i, element] of (subData as Record<string, unknown>[]).entries()) {
|
|
245
|
+
|
|
246
|
+
subIssues.push(...validateSchema(element, sub.schema, `data.${sub.path}[${i}].`));
|
|
247
|
+
}
|
|
248
|
+
} else if(!sub.isArray && typeof subData === "object") {
|
|
249
|
+
|
|
250
|
+
subIssues.push(...validateSchema(subData as Record<string, unknown>, sub.schema, `data.${sub.path}.`));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const allIssues = [...envelopeIssues, ...dataIssues, ...subIssues];
|
|
256
|
+
|
|
257
|
+
if(allIssues.length > 0) {
|
|
258
|
+
|
|
259
|
+
stats.withIssues++;
|
|
260
|
+
console.log(`${colors.dim(timestamp)} ${colors.red("MISMATCH")} ${colors.bold(eventDef.name)} (${eventType}) event_object_id=${packet.event_object_id}`);
|
|
261
|
+
|
|
262
|
+
for(const issue of allIssues) {
|
|
263
|
+
|
|
264
|
+
const icon = issue.issue === "unexpected_field" ? colors.yellow("+") : colors.red("!");
|
|
265
|
+
|
|
266
|
+
console.log(` ${icon} ${colors.bold(issue.field)}: ${issue.issue} — ${issue.detail}`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
promptSchemaChange("data_schema", eventType, allIssues);
|
|
270
|
+
} else {
|
|
271
|
+
|
|
272
|
+
stats.matched++;
|
|
273
|
+
console.log(`${colors.dim(timestamp)} ${colors.green("OK")} ${colors.bold(eventDef.name)} (${eventType}) event_object_id=${packet.event_object_id}`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Print summary on exit.
|
|
278
|
+
function printSummary(): void {
|
|
279
|
+
|
|
280
|
+
console.log();
|
|
281
|
+
console.log(colors.bold("--- Session Summary ---"));
|
|
282
|
+
console.log(` Total events: ${stats.total}`);
|
|
283
|
+
console.log(` Schema matched: ${colors.green(String(stats.matched))}`);
|
|
284
|
+
console.log(` Schema mismatch: ${stats.withIssues > 0 ? colors.red(String(stats.withIssues)) : "0"}`);
|
|
285
|
+
console.log(` Unknown events: ${stats.unknown > 0 ? colors.yellow(String(stats.unknown)) : "0"}`);
|
|
286
|
+
console.log();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Main entry point.
|
|
290
|
+
async function main(): Promise<void> {
|
|
291
|
+
|
|
292
|
+
const { address, username, password } = parseCliArgs();
|
|
293
|
+
|
|
294
|
+
const log = {
|
|
295
|
+
|
|
296
|
+
debug: (message: string, ...params: unknown[]) => void 0,
|
|
297
|
+
error: (message: string, ...params: unknown[]) => console.error(colors.red("API Error:"), message, ...params),
|
|
298
|
+
info: (message: string, ...params: unknown[]) => void 0,
|
|
299
|
+
warn: (message: string, ...params: unknown[]) => console.warn(colors.yellow("API Warning:"), message, ...params)
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const api = new AccessApi(log);
|
|
303
|
+
|
|
304
|
+
console.log(colors.bold("UniFi Access Event Schema Monitor"));
|
|
305
|
+
console.log(`Connecting to ${colors.blue(address)}...`);
|
|
306
|
+
console.log();
|
|
307
|
+
|
|
308
|
+
const loginResult = await api.login(address, username, password);
|
|
309
|
+
|
|
310
|
+
if(!loginResult) {
|
|
311
|
+
|
|
312
|
+
console.error(colors.red("Login failed. Check your credentials and controller address."));
|
|
313
|
+
process.exit(1);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
console.log(colors.green("Connected.") + " Bootstrapping...");
|
|
317
|
+
|
|
318
|
+
await api.getBootstrap();
|
|
319
|
+
|
|
320
|
+
const deviceCount = api.devices?.length ?? 0;
|
|
321
|
+
|
|
322
|
+
console.log(colors.green(`Bootstrap complete.`) + ` ${deviceCount} device(s) found.`);
|
|
323
|
+
console.log();
|
|
324
|
+
console.log(colors.bold("Listening for events...") + " Press Ctrl+C to stop.");
|
|
325
|
+
console.log(colors.dim("Events will be validated against known schemas in real time."));
|
|
326
|
+
console.log();
|
|
327
|
+
|
|
328
|
+
// Listen for all events.
|
|
329
|
+
api.on("message", (packet: AccessEventPacket) => {
|
|
330
|
+
|
|
331
|
+
processEvent(packet);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// Graceful shutdown.
|
|
335
|
+
const shutdown = () => {
|
|
336
|
+
|
|
337
|
+
printSummary();
|
|
338
|
+
api.reset();
|
|
339
|
+
process.exit(0);
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
process.on("SIGINT", shutdown);
|
|
343
|
+
process.on("SIGTERM", shutdown);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
main().catch((error: unknown) => {
|
|
347
|
+
|
|
348
|
+
console.error(colors.red("Fatal error:"), error);
|
|
349
|
+
process.exit(1);
|
|
350
|
+
});
|