@sonde/packs 0.1.7 → 0.1.9

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.
@@ -0,0 +1,194 @@
1
+ import type {
2
+ FetchFn,
3
+ IntegrationConfig,
4
+ IntegrationCredentials,
5
+ IntegrationPack,
6
+ IntegrationProbeHandler,
7
+ } from '@sonde/shared';
8
+
9
+ // --- API helper ---
10
+
11
+ /**
12
+ * Fetch a UniFi Access developer API endpoint.
13
+ * Auth: Authorization: Bearer <token>
14
+ * Token generated in UniFi Access > Settings > Developer API.
15
+ * Base path set by user in endpoint config (includes /proxy/access/api/v1/developer
16
+ * when accessed through UDM, or direct on port 12445).
17
+ */
18
+ export async function accessFetch<T = unknown>(
19
+ path: string,
20
+ config: IntegrationConfig,
21
+ credentials: IntegrationCredentials,
22
+ fetchFn: FetchFn,
23
+ ): Promise<T> {
24
+ const token = credentials.credentials.apiToken ?? '';
25
+ const endpoint = config.endpoint.replace(/\/$/, '');
26
+ const url = `${endpoint}/${path}`;
27
+
28
+ const res = await fetchFn(url, {
29
+ headers: {
30
+ Authorization: `Bearer ${token}`,
31
+ Accept: 'application/json',
32
+ ...config.headers,
33
+ },
34
+ });
35
+
36
+ if (!res.ok) {
37
+ throw new Error(
38
+ `UniFi Access API returned ${res.status}: ${res.statusText}`,
39
+ );
40
+ }
41
+
42
+ const body = (await res.json()) as { data?: T };
43
+ return (body.data ?? body) as T;
44
+ }
45
+
46
+ // --- Probe handlers ---
47
+
48
+ const doors: IntegrationProbeHandler = async (
49
+ _params,
50
+ config,
51
+ credentials,
52
+ fetchFn,
53
+ ) => {
54
+ const result = await accessFetch<Array<Record<string, unknown>>>(
55
+ 'doors',
56
+ config,
57
+ credentials,
58
+ fetchFn,
59
+ );
60
+
61
+ return {
62
+ doors: result.map((d) => ({
63
+ id: d.id ?? d.unique_id,
64
+ name: d.name,
65
+ status: d.door_guard,
66
+ lockRelay: d.lock_relay_status,
67
+ full_name: d.full_name,
68
+ })),
69
+ count: result.length,
70
+ };
71
+ };
72
+
73
+ const systemLogs: IntegrationProbeHandler = async (
74
+ params,
75
+ config,
76
+ credentials,
77
+ fetchFn,
78
+ ) => {
79
+ const pageSize = (params?.limit as number) || 25;
80
+ const topic = (params?.topic as string) ?? '';
81
+
82
+ let path = `system/logs?page_size=${pageSize}`;
83
+ if (topic) path += `&topic=${encodeURIComponent(topic)}`;
84
+
85
+ const result = await accessFetch<Array<Record<string, unknown>>>(
86
+ path,
87
+ config,
88
+ credentials,
89
+ fetchFn,
90
+ );
91
+
92
+ return { logs: result, count: result.length };
93
+ };
94
+
95
+ const accessDevices: IntegrationProbeHandler = async (
96
+ _params,
97
+ config,
98
+ credentials,
99
+ fetchFn,
100
+ ) => {
101
+ const result = await accessFetch<Array<Record<string, unknown>>>(
102
+ 'devices',
103
+ config,
104
+ credentials,
105
+ fetchFn,
106
+ );
107
+
108
+ return {
109
+ devices: result.map((d) => ({
110
+ id: d.id ?? d.unique_id,
111
+ name: d.name,
112
+ type: d.type ?? d.device_type,
113
+ model: d.model,
114
+ firmware: d.firmware ?? d.firmware_version,
115
+ status: d.status ?? d.adoption_status,
116
+ ip: d.ip,
117
+ })),
118
+ count: result.length,
119
+ };
120
+ };
121
+
122
+ // --- Pack definition ---
123
+
124
+ export const unifiAccessPack: IntegrationPack = {
125
+ manifest: {
126
+ name: 'unifi-access',
127
+ type: 'integration',
128
+ version: '0.2.0',
129
+ description:
130
+ 'Ubiquiti UniFi Access — doors, access logs, reader/hub devices',
131
+ requires: { groups: [], files: [], commands: [] },
132
+ probes: [
133
+ {
134
+ name: 'doors',
135
+ description: 'List all doors with name, status, and lock state',
136
+ capability: 'observe',
137
+ params: {},
138
+ timeout: 15000,
139
+ },
140
+ {
141
+ name: 'logs',
142
+ description:
143
+ 'Access event log (door unlocks, denied attempts, etc.)',
144
+ capability: 'observe',
145
+ params: {
146
+ topic: {
147
+ type: 'string',
148
+ description:
149
+ 'Filter by topic (e.g. "access.logs.add"). Omit for all.',
150
+ required: false,
151
+ },
152
+ limit: {
153
+ type: 'number',
154
+ description: 'Page size (default: 25)',
155
+ required: false,
156
+ },
157
+ },
158
+ timeout: 15000,
159
+ },
160
+ {
161
+ name: 'devices',
162
+ description: 'List access devices (readers, hubs) with status',
163
+ capability: 'observe',
164
+ params: {},
165
+ timeout: 15000,
166
+ },
167
+ ],
168
+ runbook: {
169
+ category: 'access-control',
170
+ probes: ['doors', 'devices'],
171
+ parallel: true,
172
+ },
173
+ },
174
+
175
+ handlers: {
176
+ doors,
177
+ logs: systemLogs,
178
+ devices: accessDevices,
179
+ },
180
+
181
+ testConnection: async (config, credentials, fetchFn) => {
182
+ const token = credentials.credentials.apiToken ?? '';
183
+ const endpoint = config.endpoint.replace(/\/$/, '');
184
+
185
+ const res = await fetchFn(`${endpoint}/doors`, {
186
+ headers: {
187
+ Authorization: `Bearer ${token}`,
188
+ Accept: 'application/json',
189
+ ...config.headers,
190
+ },
191
+ });
192
+ return res.ok;
193
+ },
194
+ };
@@ -0,0 +1,366 @@
1
+ import type {
2
+ FetchFn,
3
+ IntegrationConfig,
4
+ IntegrationCredentials,
5
+ IntegrationPack,
6
+ IntegrationProbeHandler,
7
+ } from '@sonde/shared';
8
+
9
+ // --- Paginated response shape from official API ---
10
+
11
+ interface PagedResponse<T> {
12
+ offset: number;
13
+ limit: number;
14
+ count: number;
15
+ totalCount: number;
16
+ data: T[];
17
+ }
18
+
19
+ // --- API helper ---
20
+
21
+ /**
22
+ * Fetch a UniFi Network official API endpoint.
23
+ * Auth: X-API-KEY header. Base path: /proxy/network/integration
24
+ * Docs: Settings > Control Plane > Integrations on your controller,
25
+ * or https://developer.ui.com/network/v10.1.84/gettingstarted
26
+ */
27
+ export async function unifiFetch<T = unknown>(
28
+ path: string,
29
+ config: IntegrationConfig,
30
+ credentials: IntegrationCredentials,
31
+ fetchFn: FetchFn,
32
+ ): Promise<T> {
33
+ const apiKey = credentials.credentials.apiKey ?? '';
34
+ const endpoint = config.endpoint.replace(/\/$/, '');
35
+ const url = `${endpoint}/proxy/network/integration${path}`;
36
+
37
+ const res = await fetchFn(url, {
38
+ headers: {
39
+ 'X-API-KEY': apiKey,
40
+ Accept: 'application/json',
41
+ ...config.headers,
42
+ },
43
+ });
44
+
45
+ if (!res.ok) {
46
+ throw new Error(
47
+ `UniFi Network API returned ${res.status}: ${res.statusText}`,
48
+ );
49
+ }
50
+
51
+ return (await res.json()) as T;
52
+ }
53
+
54
+ /** Collect all pages from a paginated endpoint (max 200 per page) */
55
+ async function fetchAllPages<T>(
56
+ path: string,
57
+ config: IntegrationConfig,
58
+ credentials: IntegrationCredentials,
59
+ fetchFn: FetchFn,
60
+ maxItems = 1000,
61
+ ): Promise<T[]> {
62
+ const results: T[] = [];
63
+ let offset = 0;
64
+ const limit = 200;
65
+
66
+ while (results.length < maxItems) {
67
+ const sep = path.includes('?') ? '&' : '?';
68
+ const page = await unifiFetch<PagedResponse<T>>(
69
+ `${path}${sep}offset=${offset}&limit=${limit}`,
70
+ config,
71
+ credentials,
72
+ fetchFn,
73
+ );
74
+ results.push(...page.data);
75
+ if (results.length >= page.totalCount) break;
76
+ offset += limit;
77
+ }
78
+
79
+ return results;
80
+ }
81
+
82
+ /** Resolve the siteId — uses the first site unless overridden */
83
+ async function resolveSiteId(
84
+ config: IntegrationConfig,
85
+ credentials: IntegrationCredentials,
86
+ fetchFn: FetchFn,
87
+ ): Promise<string> {
88
+ const explicit = credentials.credentials.siteId ?? '';
89
+ if (explicit) return explicit;
90
+
91
+ const sites = await unifiFetch<PagedResponse<{ id: string; name: string }>>(
92
+ '/v1/sites?limit=1',
93
+ config,
94
+ credentials,
95
+ fetchFn,
96
+ );
97
+ const first = sites.data[0];
98
+ if (!first) throw new Error('No sites found on this UniFi controller');
99
+ return first.id;
100
+ }
101
+
102
+ // --- Probe handlers ---
103
+
104
+ const appInfo: IntegrationProbeHandler = async (
105
+ _params,
106
+ config,
107
+ credentials,
108
+ fetchFn,
109
+ ) => {
110
+ return unifiFetch('/v1/info', config, credentials, fetchFn);
111
+ };
112
+
113
+ const sites: IntegrationProbeHandler = async (
114
+ _params,
115
+ config,
116
+ credentials,
117
+ fetchFn,
118
+ ) => {
119
+ const result = await fetchAllPages<Record<string, unknown>>(
120
+ '/v1/sites',
121
+ config,
122
+ credentials,
123
+ fetchFn,
124
+ );
125
+ return { sites: result, count: result.length };
126
+ };
127
+
128
+ const devices: IntegrationProbeHandler = async (
129
+ _params,
130
+ config,
131
+ credentials,
132
+ fetchFn,
133
+ ) => {
134
+ const siteId = await resolveSiteId(config, credentials, fetchFn);
135
+ const result = await fetchAllPages<Record<string, unknown>>(
136
+ `/v1/sites/${siteId}/devices`,
137
+ config,
138
+ credentials,
139
+ fetchFn,
140
+ );
141
+
142
+ return {
143
+ devices: result.map((d) => ({
144
+ id: d.id,
145
+ macAddress: d.macAddress,
146
+ ipAddress: d.ipAddress,
147
+ name: d.name,
148
+ model: d.model,
149
+ state: d.state,
150
+ firmwareVersion: d.firmwareVersion,
151
+ firmwareUpdatable: d.firmwareUpdatable,
152
+ features: d.features,
153
+ })),
154
+ count: result.length,
155
+ };
156
+ };
157
+
158
+ const deviceDetail: IntegrationProbeHandler = async (
159
+ params,
160
+ config,
161
+ credentials,
162
+ fetchFn,
163
+ ) => {
164
+ const deviceId = (params?.device_id as string) ?? '';
165
+ if (!deviceId) {
166
+ throw new Error('device_id parameter is required (device UUID)');
167
+ }
168
+
169
+ const siteId = await resolveSiteId(config, credentials, fetchFn);
170
+ return unifiFetch(
171
+ `/v1/sites/${siteId}/devices/${deviceId}`,
172
+ config,
173
+ credentials,
174
+ fetchFn,
175
+ );
176
+ };
177
+
178
+ const deviceStats: IntegrationProbeHandler = async (
179
+ params,
180
+ config,
181
+ credentials,
182
+ fetchFn,
183
+ ) => {
184
+ const deviceId = (params?.device_id as string) ?? '';
185
+ if (!deviceId) {
186
+ throw new Error('device_id parameter is required (device UUID)');
187
+ }
188
+
189
+ const siteId = await resolveSiteId(config, credentials, fetchFn);
190
+ return unifiFetch(
191
+ `/v1/sites/${siteId}/devices/${deviceId}/statistics/latest`,
192
+ config,
193
+ credentials,
194
+ fetchFn,
195
+ );
196
+ };
197
+
198
+ const clients: IntegrationProbeHandler = async (
199
+ _params,
200
+ config,
201
+ credentials,
202
+ fetchFn,
203
+ ) => {
204
+ const siteId = await resolveSiteId(config, credentials, fetchFn);
205
+ const result = await fetchAllPages<Record<string, unknown>>(
206
+ `/v1/sites/${siteId}/clients`,
207
+ config,
208
+ credentials,
209
+ fetchFn,
210
+ );
211
+
212
+ return {
213
+ clients: result.map((c) => ({
214
+ id: c.id,
215
+ name: c.name,
216
+ type: c.type,
217
+ ipAddress: c.ipAddress,
218
+ connectedAt: c.connectedAt,
219
+ })),
220
+ count: result.length,
221
+ };
222
+ };
223
+
224
+ const networks: IntegrationProbeHandler = async (
225
+ _params,
226
+ config,
227
+ credentials,
228
+ fetchFn,
229
+ ) => {
230
+ const siteId = await resolveSiteId(config, credentials, fetchFn);
231
+ const result = await fetchAllPages<Record<string, unknown>>(
232
+ `/v1/sites/${siteId}/networks`,
233
+ config,
234
+ credentials,
235
+ fetchFn,
236
+ );
237
+ return { networks: result, count: result.length };
238
+ };
239
+
240
+ const wans: IntegrationProbeHandler = async (
241
+ _params,
242
+ config,
243
+ credentials,
244
+ fetchFn,
245
+ ) => {
246
+ const siteId = await resolveSiteId(config, credentials, fetchFn);
247
+ const result = await fetchAllPages<Record<string, unknown>>(
248
+ `/v1/sites/${siteId}/wans`,
249
+ config,
250
+ credentials,
251
+ fetchFn,
252
+ );
253
+ return { wans: result, count: result.length };
254
+ };
255
+
256
+ // --- Pack definition ---
257
+
258
+ export const unifiPack: IntegrationPack = {
259
+ manifest: {
260
+ name: 'unifi',
261
+ type: 'integration',
262
+ version: '0.2.0',
263
+ description:
264
+ 'Ubiquiti UniFi Network — devices, clients, networks, WAN, device stats (official API)',
265
+ requires: { groups: [], files: [], commands: [] },
266
+ probes: [
267
+ {
268
+ name: 'info',
269
+ description: 'Application version and basic info',
270
+ capability: 'observe',
271
+ params: {},
272
+ timeout: 10000,
273
+ },
274
+ {
275
+ name: 'sites',
276
+ description: 'List all sites on this controller',
277
+ capability: 'observe',
278
+ params: {},
279
+ timeout: 15000,
280
+ },
281
+ {
282
+ name: 'devices',
283
+ description:
284
+ 'List adopted devices with state, model, firmware, features',
285
+ capability: 'observe',
286
+ params: {},
287
+ timeout: 15000,
288
+ },
289
+ {
290
+ name: 'device.detail',
291
+ description:
292
+ 'Full device details including interfaces and uplink',
293
+ capability: 'observe',
294
+ params: {
295
+ device_id: {
296
+ type: 'string',
297
+ description: 'Device UUID',
298
+ required: true,
299
+ },
300
+ },
301
+ timeout: 15000,
302
+ },
303
+ {
304
+ name: 'device.stats',
305
+ description:
306
+ 'Latest device statistics — CPU, memory, uptime, load averages',
307
+ capability: 'observe',
308
+ params: {
309
+ device_id: {
310
+ type: 'string',
311
+ description: 'Device UUID',
312
+ required: true,
313
+ },
314
+ },
315
+ timeout: 15000,
316
+ },
317
+ {
318
+ name: 'clients',
319
+ description: 'Connected clients with type, IP, connection time',
320
+ capability: 'observe',
321
+ params: {},
322
+ timeout: 15000,
323
+ },
324
+ {
325
+ name: 'networks',
326
+ description: 'List configured networks (VLANs, etc.)',
327
+ capability: 'observe',
328
+ params: {},
329
+ timeout: 15000,
330
+ },
331
+ {
332
+ name: 'wans',
333
+ description: 'WAN interface definitions',
334
+ capability: 'observe',
335
+ params: {},
336
+ timeout: 15000,
337
+ },
338
+ ],
339
+ runbook: {
340
+ category: 'network',
341
+ probes: ['info', 'devices', 'clients'],
342
+ parallel: true,
343
+ },
344
+ },
345
+
346
+ handlers: {
347
+ info: appInfo,
348
+ sites,
349
+ devices,
350
+ 'device.detail': deviceDetail,
351
+ 'device.stats': deviceStats,
352
+ clients,
353
+ networks,
354
+ wans,
355
+ },
356
+
357
+ testConnection: async (config, credentials, fetchFn) => {
358
+ const result = await unifiFetch<{ applicationVersion: string }>(
359
+ '/v1/info',
360
+ config,
361
+ credentials,
362
+ fetchFn,
363
+ );
364
+ return typeof result.applicationVersion === 'string';
365
+ },
366
+ };