@nubisco/openbridge 0.29.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.
Files changed (77) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +306 -0
  3. package/dist/__tests__/homekit-service-type.test.d.ts +2 -0
  4. package/dist/__tests__/homekit-service-type.test.d.ts.map +1 -0
  5. package/dist/__tests__/homekit-service-type.test.js +239 -0
  6. package/dist/__tests__/homekit-service-type.test.js.map +1 -0
  7. package/dist/__tests__/homekit-visibility.test.d.ts +2 -0
  8. package/dist/__tests__/homekit-visibility.test.d.ts.map +1 -0
  9. package/dist/__tests__/homekit-visibility.test.js +157 -0
  10. package/dist/__tests__/homekit-visibility.test.js.map +1 -0
  11. package/dist/__tests__/server.test.d.ts +2 -0
  12. package/dist/__tests__/server.test.d.ts.map +1 -0
  13. package/dist/__tests__/server.test.js +837 -0
  14. package/dist/__tests__/server.test.js.map +1 -0
  15. package/dist/__tests__/timeseries.test.d.ts +2 -0
  16. package/dist/__tests__/timeseries.test.d.ts.map +1 -0
  17. package/dist/__tests__/timeseries.test.js +216 -0
  18. package/dist/__tests__/timeseries.test.js.map +1 -0
  19. package/dist/auth.d.ts +23 -0
  20. package/dist/auth.d.ts.map +1 -0
  21. package/dist/auth.js +171 -0
  22. package/dist/auth.js.map +1 -0
  23. package/dist/daemon.d.ts +57 -0
  24. package/dist/daemon.d.ts.map +1 -0
  25. package/dist/daemon.js +636 -0
  26. package/dist/daemon.js.map +1 -0
  27. package/dist/homekit-service-type.d.ts +82 -0
  28. package/dist/homekit-service-type.d.ts.map +1 -0
  29. package/dist/homekit-service-type.js +184 -0
  30. package/dist/homekit-service-type.js.map +1 -0
  31. package/dist/homekit-visibility.d.ts +79 -0
  32. package/dist/homekit-visibility.d.ts.map +1 -0
  33. package/dist/homekit-visibility.js +164 -0
  34. package/dist/homekit-visibility.js.map +1 -0
  35. package/dist/index.d.ts +3 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +62 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/metrics.d.ts +17 -0
  40. package/dist/metrics.d.ts.map +1 -0
  41. package/dist/metrics.js +52 -0
  42. package/dist/metrics.js.map +1 -0
  43. package/dist/platform-client.d.ts +42 -0
  44. package/dist/platform-client.d.ts.map +1 -0
  45. package/dist/platform-client.js +92 -0
  46. package/dist/platform-client.js.map +1 -0
  47. package/dist/server.d.ts +30 -0
  48. package/dist/server.d.ts.map +1 -0
  49. package/dist/server.js +1744 -0
  50. package/dist/server.js.map +1 -0
  51. package/dist/timeseries.d.ts +134 -0
  52. package/dist/timeseries.d.ts.map +1 -0
  53. package/dist/timeseries.js +378 -0
  54. package/dist/timeseries.js.map +1 -0
  55. package/dist/version.d.ts +4 -0
  56. package/dist/version.d.ts.map +1 -0
  57. package/dist/version.js +28 -0
  58. package/dist/version.js.map +1 -0
  59. package/package.json +79 -0
  60. package/scripts/entrypoint.sh +82 -0
  61. package/scripts/fix-node-pty.mjs +22 -0
  62. package/ui-dist/android-chrome-192x192.png +0 -0
  63. package/ui-dist/android-chrome-512x512.png +0 -0
  64. package/ui-dist/apple-touch-icon.png +0 -0
  65. package/ui-dist/assets/index-Q0sxZHIR.css +32 -0
  66. package/ui-dist/assets/index-ZZYyAzBo.js +106 -0
  67. package/ui-dist/favicon-16x16.png +0 -0
  68. package/ui-dist/favicon-32x32.png +0 -0
  69. package/ui-dist/favicon.ico +0 -0
  70. package/ui-dist/fonts/MesloLGLDZNerdFontMono-Bold.woff2 +0 -0
  71. package/ui-dist/fonts/MesloLGLDZNerdFontMono-BoldItalic.woff2 +0 -0
  72. package/ui-dist/fonts/MesloLGLDZNerdFontMono-Italic.woff2 +0 -0
  73. package/ui-dist/fonts/MesloLGLDZNerdFontMono-Regular.woff2 +0 -0
  74. package/ui-dist/index.html +18 -0
  75. package/ui-dist/logo.svg +30 -0
  76. package/ui-dist/nubisco-logo.svg +120 -0
  77. package/ui-dist/site.webmanifest +19 -0
package/dist/server.js ADDED
@@ -0,0 +1,1744 @@
1
+ import Fastify from 'fastify';
2
+ import cors from '@fastify/cors';
3
+ import websocket from '@fastify/websocket';
4
+ import fastifyStatic from '@fastify/static';
5
+ import { existsSync, readFileSync, readdirSync, writeFileSync, mkdirSync, chmodSync } from 'fs';
6
+ import { resolve, dirname, join } from 'path';
7
+ import { fileURLToPath } from 'url';
8
+ import { spawn } from 'child_process';
9
+ import { createRequire } from 'module';
10
+ import os from 'os';
11
+ const _req = createRequire(import.meta.url);
12
+ import { Logger } from '@nubisco/openbridge-logger';
13
+ import { startMetrics, onMetrics, getHistory } from './metrics.js';
14
+ import { loadAuthConfig, registerAuthRoutes } from './auth.js';
15
+ const log = Logger.create('system');
16
+ const __dirname = dirname(fileURLToPath(import.meta.url));
17
+ // Version: resolved in version.ts so the CLI can read it without loading this module.
18
+ import { APP_VOLUME, VERSION_FILE, OPENBRIDGE_VERSION } from './version.js';
19
+ export { OPENBRIDGE_VERSION };
20
+ let updateProgress = { stage: 'idle' };
21
+ const updateListeners = new Set();
22
+ // Resolve UI dist: env override → npm layout (../ui-dist) → monorepo dev layout
23
+ const uiDist = [process.env.OPENBRIDGE_UI_PATH, resolve(__dirname, '../ui-dist'), resolve(__dirname, '../../../apps/ui/dist')]
24
+ .filter(Boolean)
25
+ .find((p) => existsSync(p)) ?? '';
26
+ const uiAvailable = !!uiDist;
27
+ // Set once the optional node-pty dependency is confirmed loadable (see /ws/shell below).
28
+ let shellAvailable = false;
29
+ import { OPENBRIDGE_HOME, OB_PLUGINS_DIR, HB_PLUGINS_DIR } from './daemon.js';
30
+ import { DeviceSeries } from './timeseries.js';
31
+ import { CONVERTIBLE_SERVICES, isConvertible, } from './homekit-service-type.js';
32
+ /** Find a registered device by id across every plugin. */
33
+ function findDevice(registry, deviceId) {
34
+ for (const instance of registry.getAll()) {
35
+ const device = instance.devices?.[deviceId];
36
+ if (device)
37
+ return device;
38
+ }
39
+ return null;
40
+ }
41
+ export async function createServer(registry, hapAPI = null, hapInfo = null, localPluginSources = [], knownHbPackageNames = new Set(), controls = new Map(), restrictedControls = new Set(), homekitVisibility = null, homekitServiceTypes = null,
42
+ /**
43
+ * Resolve the HAP accessory a native plugin published for one of its
44
+ * devices. Native plugins add accessories to the bridge directly, so they
45
+ * never appear in /api/accessories; this is how the UI reaches them.
46
+ */
47
+ resolveNativeAccessory = null,
48
+ /** UUID of the accessory a native plugin published for a device, if any. */
49
+ nativeAccessoryUuid = null) {
50
+ const app = Fastify({ logger: false });
51
+ await app.register(cors, { origin: true });
52
+ await app.register(websocket);
53
+ // Optional: Nubisco Platform auth. No-op when PLATFORM_ENABLED is unset.
54
+ const authConfig = loadAuthConfig();
55
+ await registerAuthRoutes(app, authConfig);
56
+ if (authConfig.enabled) {
57
+ log.info(`Platform auth enabled, issuer=${authConfig.issuer ?? 'unset'} appId=${authConfig.appId ?? 'unset'}`);
58
+ }
59
+ // Start live metrics collection
60
+ startMetrics(2000);
61
+ // ─── Health ───────────────────────────────────────────────────────────────
62
+ app.get('/api/health', async () => {
63
+ return {
64
+ status: 'ok',
65
+ version: OPENBRIDGE_VERSION,
66
+ timestamp: new Date().toISOString(),
67
+ // Optional features that may be absent depending on how OpenBridge was installed.
68
+ capabilities: { shell: shellAvailable, ui: uiAvailable },
69
+ };
70
+ });
71
+ // ─── Update check ─────────────────────────────────────────────────────────
72
+ // The latest version is resolved from the github.com releases redirect,
73
+ // which is NOT subject to the API's 60 req/hour per-IP rate limit
74
+ // (api.github.com regularly 403s on home networks that share an IP).
75
+ // Release notes still come from the API, but only best-effort.
76
+ // Cache the last successful check so failed requests can still report a
77
+ // known latest version instead of "up to date".
78
+ let lastKnownLatest = null;
79
+ async function fetchLatestVersion() {
80
+ const res = await fetch('https://github.com/nubisco/openbridge/releases/latest', {
81
+ signal: AbortSignal.timeout(8000),
82
+ redirect: 'manual',
83
+ headers: { 'User-Agent': 'openbridge-daemon' },
84
+ });
85
+ const location = res.headers.get('location') ?? '';
86
+ const match = location.match(/\/releases\/tag\/v?([^/]+)$/);
87
+ if (!match)
88
+ return null;
89
+ return { version: decodeURIComponent(match[1]), url: location };
90
+ }
91
+ app.get('/api/updates/check', async () => {
92
+ try {
93
+ const found = await fetchLatestVersion();
94
+ if (found && (found.version !== lastKnownLatest?.version || lastKnownLatest.notes === null)) {
95
+ let notes = null;
96
+ try {
97
+ const res = await fetch('https://api.github.com/repos/nubisco/openbridge/releases/latest', {
98
+ signal: AbortSignal.timeout(8000),
99
+ headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'openbridge-daemon' },
100
+ });
101
+ if (res.ok)
102
+ notes = (await res.json()).body;
103
+ }
104
+ catch {
105
+ /* notes are cosmetic: never fail the check over them */
106
+ }
107
+ lastKnownLatest = { version: found.version, url: found.url, notes };
108
+ }
109
+ }
110
+ catch {
111
+ // Network error, timeout, etc.: fall through to use lastKnownLatest
112
+ }
113
+ const latest = lastKnownLatest?.version ?? null;
114
+ const updateAvailable = latest !== null && latest !== OPENBRIDGE_VERSION;
115
+ // Detect if self-update is possible (volume is writable)
116
+ let updateMethod = 'manual';
117
+ try {
118
+ const testFile = join(APP_VOLUME, '.write-test');
119
+ writeFileSync(testFile, 'ok');
120
+ const { unlinkSync } = await import('fs');
121
+ unlinkSync(testFile);
122
+ updateMethod = 'self';
123
+ }
124
+ catch {
125
+ /* volume not writable: manual update only */
126
+ }
127
+ return {
128
+ current: OPENBRIDGE_VERSION,
129
+ latest,
130
+ updateAvailable,
131
+ updateMethod,
132
+ releaseUrl: lastKnownLatest?.url ?? null,
133
+ releaseNotes: lastKnownLatest?.notes ?? null,
134
+ };
135
+ });
136
+ // ─── Update progress WebSocket ────────────────────────────────────────────
137
+ app.get('/ws/updates', { websocket: true }, (connection) => {
138
+ const ws = connection.socket;
139
+ const listener = (msg) => {
140
+ try {
141
+ ws.send(JSON.stringify(msg));
142
+ }
143
+ catch {
144
+ /* disconnected */
145
+ }
146
+ };
147
+ updateListeners.add(listener);
148
+ // Send current state immediately
149
+ ws.send(JSON.stringify(updateProgress));
150
+ ws.on('close', () => updateListeners.delete(listener));
151
+ });
152
+ // ─── Self-update apply ────────────────────────────────────────────────────
153
+ app.post('/api/updates/apply', async () => {
154
+ if (updateProgress.stage !== 'idle' && updateProgress.stage !== 'error') {
155
+ throw { statusCode: 409, message: 'Update already in progress' };
156
+ }
157
+ const arch = os.arch() === 'x64' ? 'amd64' : os.arch() === 'arm64' ? 'arm64' : os.arch();
158
+ const currentDir = join(APP_VOLUME, 'current');
159
+ const stagingDir = join(APP_VOLUME, 'staging');
160
+ const previousDir = join(APP_VOLUME, 'previous');
161
+ // Check volume is writable
162
+ try {
163
+ mkdirSync(stagingDir, { recursive: true });
164
+ }
165
+ catch {
166
+ throw { statusCode: 503, message: 'Update volume not available. Mount /opt/openbridge as a Docker volume.' };
167
+ }
168
+ function broadcast(msg) {
169
+ updateProgress = msg;
170
+ for (const listener of updateListeners)
171
+ listener(msg);
172
+ }
173
+ // Run update async: respond immediately
174
+ ;
175
+ (async () => {
176
+ try {
177
+ // 1. Resolve latest version (github.com redirect, not rate-limited like the API)
178
+ log.info('Self-update: fetching release info...');
179
+ broadcast({ stage: 'downloading', progress: 0, message: 'Fetching release info...' });
180
+ const found = await fetchLatestVersion();
181
+ if (!found)
182
+ throw new Error('Could not resolve the latest release from GitHub');
183
+ const version = found.version;
184
+ const assetName = `openbridge-v${version}-linux-${arch}.tar.gz`;
185
+ const assetUrl = `https://github.com/nubisco/openbridge/releases/download/v${version}/${assetName}`;
186
+ // 2. Download tarball
187
+ log.info(`Self-update: downloading ${assetName}...`);
188
+ broadcast({ stage: 'downloading', progress: 0.1, message: `Downloading v${version}...`, version });
189
+ const dlRes = await fetch(assetUrl, {
190
+ headers: { 'User-Agent': 'openbridge-daemon' },
191
+ redirect: 'follow',
192
+ });
193
+ if (dlRes.status === 404)
194
+ throw new Error(`Release asset ${assetName} not found. Your architecture (${arch}) may not be supported yet.`);
195
+ if (!dlRes.ok || !dlRes.body)
196
+ throw new Error(`Download failed: ${dlRes.status}`);
197
+ const tarballPath = join(APP_VOLUME, 'download.tar.gz');
198
+ const { createWriteStream } = await import('fs');
199
+ const { pipeline } = await import('stream/promises');
200
+ const { Readable } = await import('stream');
201
+ // Stream download with progress (size from response headers; 0 → indeterminate)
202
+ const totalSize = Number(dlRes.headers.get('content-length') ?? 0);
203
+ let downloaded = 0;
204
+ const progressStream = new (await import('stream')).Transform({
205
+ transform(chunk, _encoding, callback) {
206
+ downloaded += chunk.length;
207
+ if (totalSize > 0) {
208
+ const pct = Math.round((downloaded / totalSize) * 100) / 100;
209
+ broadcast({
210
+ stage: 'downloading',
211
+ progress: pct,
212
+ message: `Downloading v${version}... ${Math.round(pct * 100)}%`,
213
+ version,
214
+ });
215
+ }
216
+ callback(null, chunk);
217
+ },
218
+ });
219
+ await pipeline(Readable.fromWeb(dlRes.body), progressStream, createWriteStream(tarballPath));
220
+ log.info(`Downloaded ${assetName} (${(downloaded / 1024 / 1024).toFixed(1)} MB)`);
221
+ // 3. Extract
222
+ broadcast({ stage: 'extracting', message: `Extracting v${version}...`, version });
223
+ // Clean staging
224
+ const { rmSync } = await import('fs');
225
+ rmSync(stagingDir, { recursive: true, force: true });
226
+ mkdirSync(stagingDir, { recursive: true });
227
+ await new Promise((resolve, reject) => {
228
+ const tar = spawn('tar', ['xzf', tarballPath, '-C', stagingDir]);
229
+ tar.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`tar exited with ${code}`))));
230
+ tar.on('error', reject);
231
+ });
232
+ // Clean up tarball
233
+ rmSync(tarballPath, { force: true });
234
+ log.info(`Extracted to staging directory`);
235
+ // 4. Swap: current → previous, staging → current
236
+ broadcast({ stage: 'swapping', message: 'Installing update...', version });
237
+ rmSync(previousDir, { recursive: true, force: true });
238
+ if (existsSync(currentDir)) {
239
+ const { renameSync } = await import('fs');
240
+ renameSync(currentDir, previousDir);
241
+ }
242
+ const { renameSync } = await import('fs');
243
+ renameSync(stagingDir, currentDir);
244
+ // Fix node-pty permissions
245
+ try {
246
+ const { execSync } = await import('child_process');
247
+ execSync(`find "${currentDir}/apps/daemon/node_modules" -name "spawn-helper" -exec chmod +x {} \\;`, {
248
+ stdio: 'ignore',
249
+ });
250
+ }
251
+ catch {
252
+ /* ignore */
253
+ }
254
+ // 5. Write version.json
255
+ writeFileSync(VERSION_FILE, JSON.stringify({
256
+ version,
257
+ arch,
258
+ source: 'self-update',
259
+ installedAt: new Date().toISOString(),
260
+ previousVersion: OPENBRIDGE_VERSION,
261
+ }, null, 2));
262
+ log.info(`Update to v${version} installed successfully: restarting...`);
263
+ broadcast({ stage: 'restarting', message: `Restarting with v${version}...`, version });
264
+ // 6. Restart: just exit; Docker's restart policy will bring us back
265
+ // The entrypoint will see source:"self-update" in version.json and keep the updated files
266
+ setTimeout(() => {
267
+ process.exit(0);
268
+ }, 500);
269
+ }
270
+ catch (err) {
271
+ log.error(`Self-update failed: ${err.message}`);
272
+ broadcast({ stage: 'error', message: err.message });
273
+ // Clean up staging on failure
274
+ try {
275
+ const { rmSync } = await import('fs');
276
+ rmSync(stagingDir, { recursive: true, force: true });
277
+ rmSync(join(APP_VOLUME, 'download.tar.gz'), { force: true });
278
+ }
279
+ catch {
280
+ /* ignore */
281
+ }
282
+ }
283
+ })();
284
+ return { updating: true };
285
+ });
286
+ // ─── Rollback ─────────────────────────────────────────────────────────────
287
+ app.post('/api/updates/rollback', async () => {
288
+ const currentDir = join(APP_VOLUME, 'current');
289
+ const previousDir = join(APP_VOLUME, 'previous');
290
+ if (!existsSync(previousDir)) {
291
+ throw { statusCode: 404, message: 'No previous version available for rollback' };
292
+ }
293
+ const { rmSync, renameSync } = await import('fs');
294
+ const rollbackDir = join(APP_VOLUME, 'rollback-tmp');
295
+ // Swap: current → rollback-tmp, previous → current
296
+ if (existsSync(currentDir))
297
+ renameSync(currentDir, rollbackDir);
298
+ renameSync(previousDir, currentDir);
299
+ rmSync(rollbackDir, { recursive: true, force: true });
300
+ // Read version from rolled-back files
301
+ let rolledBackVersion = 'unknown';
302
+ try {
303
+ const vf = JSON.parse(readFileSync(VERSION_FILE, 'utf8'));
304
+ rolledBackVersion = vf.previousVersion ?? 'unknown';
305
+ }
306
+ catch {
307
+ /* ignore */
308
+ }
309
+ writeFileSync(VERSION_FILE, JSON.stringify({
310
+ version: rolledBackVersion,
311
+ arch: os.arch() === 'x64' ? 'amd64' : os.arch(),
312
+ source: 'rollback',
313
+ installedAt: new Date().toISOString(),
314
+ }, null, 2));
315
+ log.info(`Rolled back to v${rolledBackVersion}: restarting...`);
316
+ // Restart
317
+ setTimeout(() => {
318
+ const child = spawn(process.execPath, process.argv.slice(1), {
319
+ detached: true,
320
+ stdio: 'inherit',
321
+ env: process.env,
322
+ });
323
+ child.unref();
324
+ process.exit(0);
325
+ }, 300);
326
+ return { rollingBack: true, version: rolledBackVersion };
327
+ });
328
+ // ─── System info ──────────────────────────────────────────────────────────
329
+ app.get('/api/system', async () => {
330
+ const ni = os.networkInterfaces();
331
+ const ip = Object.values(ni)
332
+ .flat()
333
+ .find((a) => a && !a.internal && a.family === 'IPv4')?.address ?? 'N/A';
334
+ return {
335
+ os: `${os.type()} ${os.release()}`,
336
+ arch: os.arch(),
337
+ hostname: os.hostname(),
338
+ user: os.userInfo().username,
339
+ nodeVersion: process.version,
340
+ ip,
341
+ configPath: join(OPENBRIDGE_HOME, 'config.json'),
342
+ obPluginsDir: OB_PLUGINS_DIR,
343
+ hbPluginsDir: HB_PLUGINS_DIR,
344
+ uptimeSystem: os.uptime(),
345
+ uptimeProcess: process.uptime(),
346
+ };
347
+ });
348
+ // ─── HomeKit QR ───────────────────────────────────────────────────────────
349
+ app.get('/api/qr', async () => {
350
+ return hapInfo ?? { setupURI: null, pincode: null };
351
+ });
352
+ // ─── Live metrics WebSocket ───────────────────────────────────────────────
353
+ app.get('/ws/metrics', { websocket: true }, (connection) => {
354
+ const ws = connection.socket;
355
+ // Send history immediately on connect
356
+ ws.send(JSON.stringify({ type: 'history', data: getHistory() }));
357
+ const unsub = onMetrics((snap) => {
358
+ if (ws.readyState === ws.OPEN) {
359
+ ws.send(JSON.stringify({ type: 'snapshot', data: snap }));
360
+ }
361
+ });
362
+ ws.on('close', unsub);
363
+ });
364
+ // ─── Plugins ─────────────────────────────────────────────────────────────
365
+ app.get('/api/plugins', async () => {
366
+ const plugins = registry.getAll();
367
+ // Attach cached enriched metadata to each plugin
368
+ const cache = loadMetadataCache();
369
+ for (const plugin of plugins) {
370
+ const pkgName = plugin.manifest.name;
371
+ if (cache[pkgName]) {
372
+ plugin.enrichedMetadata = cache[pkgName];
373
+ }
374
+ }
375
+ return { plugins };
376
+ });
377
+ // Re-scan HB plugins dir and register any newly installed packages
378
+ app.post('/api/plugins/refresh', async () => {
379
+ const manifestPath = join(HB_PLUGINS_DIR, 'package.json');
380
+ if (existsSync(manifestPath)) {
381
+ try {
382
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
383
+ const deps = manifest.dependencies ?? {};
384
+ for (const pkgName of Object.keys(deps)) {
385
+ if (registry.get(pkgName))
386
+ continue;
387
+ if (knownHbPackageNames.has(pkgName))
388
+ continue; // already running via config.platforms
389
+ const pkgJsonPath = join(HB_PLUGINS_DIR, 'node_modules', pkgName, 'package.json');
390
+ if (!existsSync(pkgJsonPath))
391
+ continue;
392
+ try {
393
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
394
+ const name = pkg.name ?? pkgName;
395
+ const pseudoPlugin = {
396
+ manifest: {
397
+ name,
398
+ version: pkg.version ?? '?.?.?',
399
+ description: pkg.description ?? '',
400
+ author: typeof pkg.author === 'string' ? pkg.author : (pkg.author?.name ?? ''),
401
+ },
402
+ };
403
+ const instance = registry.register(pseudoPlugin);
404
+ instance.source = 'homebridge';
405
+ registry.updateStatus(name, 'stopped');
406
+ log.info(`Discovered plugin: ${name}`);
407
+ }
408
+ catch {
409
+ /* skip */
410
+ }
411
+ }
412
+ }
413
+ catch {
414
+ /* ignore */
415
+ }
416
+ }
417
+ const plugins = registry.getAll();
418
+ // Attach cached enriched metadata to each plugin
419
+ const cache = loadMetadataCache();
420
+ for (const plugin of plugins) {
421
+ const pkgName = plugin.manifest.name;
422
+ if (cache[pkgName]) {
423
+ plugin.enrichedMetadata = cache[pkgName];
424
+ }
425
+ }
426
+ return { plugins };
427
+ });
428
+ app.get('/api/plugins/:id', async (req) => {
429
+ const { id } = req.params;
430
+ const entry = registry.get(id);
431
+ if (!entry)
432
+ throw { statusCode: 404, message: `Plugin '${id}' not found` };
433
+ const instance = entry.instance;
434
+ // Attach cached enriched metadata if available
435
+ const cache = loadMetadataCache();
436
+ const pkgName = instance.manifest.name;
437
+ if (cache[pkgName]) {
438
+ instance.enrichedMetadata = cache[pkgName];
439
+ }
440
+ return instance;
441
+ });
442
+ app.get('/api/plugins/:id/telemetry', async (req) => {
443
+ const { id } = req.params;
444
+ const entry = registry.get(id);
445
+ if (!entry)
446
+ throw { statusCode: 404, message: `Plugin '${id}' not found` };
447
+ return { telemetry: entry.instance.telemetry ?? {} };
448
+ });
449
+ // Returns all devices across all plugins with their descriptors and latest telemetry
450
+ app.get('/api/devices', async () => {
451
+ // Load custom names
452
+ const namesPath = join(OPENBRIDGE_HOME, 'device-names.json');
453
+ let customNames = {};
454
+ try {
455
+ customNames = JSON.parse(readFileSync(namesPath, 'utf8'));
456
+ }
457
+ catch {
458
+ /* no custom names */
459
+ }
460
+ const devices = [];
461
+ for (const instance of registry.getAll()) {
462
+ if (!instance.devices)
463
+ continue;
464
+ for (const device of Object.values(instance.devices)) {
465
+ const telemetry = instance.telemetry?.[device.id] ?? {};
466
+ const name = customNames[device.id] ?? device.name;
467
+ // What the device is *presented* as, which may differ from the widget
468
+ // type its plugin declared. Sent so the card can show a relay driving a
469
+ // lamp as a light rather than contradicting the inspector next to it.
470
+ const accessoryUuid = nativeAccessoryUuid?.(device.id) ?? null;
471
+ const homekitType = accessoryUuid ? (homekitServiceTypes?.typeForAccessory(accessoryUuid) ?? null) : null;
472
+ devices.push({ ...device, name, telemetry, pluginStatus: instance.status, homekitType });
473
+ }
474
+ }
475
+ return { devices };
476
+ });
477
+ // ─── HomeKit visibility ─────────────────────────────────────────────────
478
+ // Hiding is per accessory, not per plugin: one plugin often provides both
479
+ // devices worth exposing (a switch) and devices HomeKit cannot represent
480
+ // (an energy meter). Enforced at the bridge, so it applies to native and
481
+ // Homebridge-compat plugins alike.
482
+ app.get('/api/homekit/hidden', async () => {
483
+ return { hidden: homekitVisibility?.hiddenList() ?? [] };
484
+ });
485
+ app.post('/api/homekit/visibility/:uuid', async (req) => {
486
+ const { uuid } = req.params;
487
+ const { visible } = req.body;
488
+ if (typeof visible !== 'boolean')
489
+ throw { statusCode: 400, message: 'visible (boolean) is required' };
490
+ if (!homekitVisibility)
491
+ throw { statusCode: 503, message: 'HomeKit bridge is not available' };
492
+ const result = homekitVisibility.setVisible(uuid, visible);
493
+ // `applied: false` means the preference is stored but the running bridge
494
+ // could not be updated, so the Home app will not reflect it until restart.
495
+ return { uuid, visible, ...result };
496
+ });
497
+ // ─── HomeKit service type ───────────────────────────────────────────────
498
+ // A plugin publishing a generic relay as a Switch cannot know it drives a
499
+ // light. HomeKit takes its tile, icon and Siri grammar from the service type
500
+ // and re-typing in the Home app does not survive a restart, so the choice is
501
+ // recorded here and re-applied every time the accessory is bridged.
502
+ app.get('/api/homekit/service-types', async () => {
503
+ return {
504
+ overrides: homekitServiceTypes?.all() ?? {},
505
+ // The UI builds its picker from this rather than hardcoding a parallel
506
+ // list that could drift from what the daemon will actually accept.
507
+ available: Object.entries(CONVERTIBLE_SERVICES).map(([key, def]) => ({
508
+ key,
509
+ label: def.label,
510
+ serviceUuid: def.uuid,
511
+ })),
512
+ };
513
+ });
514
+ app.post('/api/homekit/service-type/:uuid', async (req) => {
515
+ const { uuid } = req.params;
516
+ const { serviceUuid, type } = req.body;
517
+ if (!serviceUuid)
518
+ throw { statusCode: 400, message: 'serviceUuid is required' };
519
+ if (!homekitServiceTypes)
520
+ throw { statusCode: 503, message: 'HomeKit bridge is not available' };
521
+ if (type != null && !(type in CONVERTIBLE_SERVICES)) {
522
+ throw {
523
+ statusCode: 400,
524
+ message: `Unsupported type '${type}'. Expected one of: ${Object.keys(CONVERTIBLE_SERVICES).join(', ')}`,
525
+ };
526
+ }
527
+ // Only On-based services are interchangeable; anything else would lose the
528
+ // characteristics that give it meaning.
529
+ if (type != null && !isConvertible(serviceUuid)) {
530
+ throw { statusCode: 400, message: `Service ${serviceUuid} cannot be re-typed` };
531
+ }
532
+ homekitServiceTypes.set(uuid, serviceUuid, (type ?? null));
533
+ log.info(`HomeKit service type for ${uuid}/${serviceUuid} set to ${type ?? 'default'}`);
534
+ // Never applied live: HomeKit caches an accessory's shape at pairing, so a
535
+ // type swapped on a published accessory is not picked up until restart.
536
+ return { uuid, serviceUuid, type: type ?? null, applied: false };
537
+ });
538
+ /**
539
+ * The HAP accessory behind a native plugin's device, if it published one.
540
+ *
541
+ * Lets the devices inspector offer the same HomeKit controls for a native
542
+ * device as for a compat one. Returns `accessory: null` rather than 404ing
543
+ * when a plugin publishes no accessory at all, which is a normal state (a
544
+ * telemetry-only device) and not an error.
545
+ */
546
+ app.get('/api/devices/:deviceId/accessory', async (req) => {
547
+ const { deviceId } = req.params;
548
+ const accessory = resolveNativeAccessory?.(deviceId) ?? null;
549
+ return { accessory };
550
+ });
551
+ // ─── Device rename ──────────────────────────────────────────────────────
552
+ app.post('/api/devices/:deviceId/rename', async (req) => {
553
+ const { deviceId } = req.params;
554
+ const { name } = req.body;
555
+ if (!name?.trim())
556
+ throw { statusCode: 400, message: 'name is required' };
557
+ // Update in registry (native devices)
558
+ for (const instance of registry.getAll()) {
559
+ if (instance.devices?.[deviceId]) {
560
+ instance.devices[deviceId].name = name.trim();
561
+ break;
562
+ }
563
+ }
564
+ // Update HAP accessory name if it exists
565
+ if (hapAPI) {
566
+ const raw = hapAPI.getRawAccessories?.() ?? [];
567
+ const acc = raw.find((a) => a.UUID === deviceId);
568
+ if (acc) {
569
+ // Find AccessoryInformation service and set Name characteristic
570
+ for (const svc of acc.services ?? []) {
571
+ for (const ch of svc.characteristics ?? []) {
572
+ if (ch.displayName === 'Name' || ch.constructor?.name === 'Name') {
573
+ try {
574
+ ch.setValue(name.trim());
575
+ }
576
+ catch {
577
+ /* ignore: some characteristics may not accept setValue */
578
+ }
579
+ }
580
+ }
581
+ }
582
+ // Also update the displayName
583
+ acc.displayName = name.trim();
584
+ }
585
+ }
586
+ // Persist custom names to file
587
+ const namesPath = join(OPENBRIDGE_HOME, 'device-names.json');
588
+ let customNames = {};
589
+ try {
590
+ customNames = JSON.parse(readFileSync(namesPath, 'utf8'));
591
+ }
592
+ catch {
593
+ /* start fresh */
594
+ }
595
+ customNames[deviceId] = name.trim();
596
+ writeFileSync(namesPath, JSON.stringify(customNames, null, 2));
597
+ log.info(`Device ${deviceId} renamed to "${name.trim()}"`);
598
+ return { ok: true, deviceId, name: name.trim() };
599
+ });
600
+ app.post('/api/devices/:deviceId/control', async (req) => {
601
+ const { deviceId } = req.params;
602
+ const { control, value } = req.body;
603
+ const key = `${deviceId}::${control}`;
604
+ if (restrictedControls.has(key)) {
605
+ throw { statusCode: 403, message: `Control '${control}' is restricted for device '${deviceId}'` };
606
+ }
607
+ const handler = controls.get(key);
608
+ if (!handler)
609
+ throw { statusCode: 404, message: `No control '${control}' registered for device '${deviceId}'` };
610
+ await handler(value);
611
+ return { ok: true };
612
+ });
613
+ /**
614
+ * Metric-aware history.
615
+ *
616
+ * The older /history route below serves cumulative energy bucketed into
617
+ * day/month/year kWh and is kept working unchanged, since the shipped UI
618
+ * depends on it. This route serves any declared metric over an arbitrary
619
+ * range, which is what per-phase charting needs.
620
+ */
621
+ app.get('/api/devices/:deviceId/metrics', async (req) => {
622
+ const { deviceId } = req.params;
623
+ const { metric, from, to, maxPoints } = req.query;
624
+ const device = findDevice(registry, deviceId);
625
+ if (!device)
626
+ throw { statusCode: 404, message: `Device '${deviceId}' not found` };
627
+ const metrics = device.metrics ?? [];
628
+ if (metrics.length === 0)
629
+ return { deviceId, metrics: [], series: null };
630
+ // Without an explicit metric, report what this device offers so the UI can
631
+ // populate its selector without a second round trip.
632
+ if (!metric)
633
+ return { deviceId, metrics, series: null };
634
+ const descriptor = metrics.find((m) => m.key === metric);
635
+ if (!descriptor)
636
+ throw { statusCode: 404, message: `Device '${deviceId}' does not report '${metric}'` };
637
+ const now = Math.floor(Date.now() / 1000);
638
+ const toSec = to ? Number(to) : now;
639
+ const fromSec = from ? Number(from) : toSec - 86400;
640
+ if (!Number.isFinite(fromSec) || !Number.isFinite(toSec) || fromSec >= toSec) {
641
+ throw { statusCode: 400, message: 'Invalid from/to range' };
642
+ }
643
+ const series = new DeviceSeries(join(OPENBRIDGE_HOME, 'metrics'), deviceId, metrics);
644
+ const points = Math.min(5000, Math.max(10, Number(maxPoints) || 720));
645
+ const result = series.query(metric, fromSec, toSec, points);
646
+ return {
647
+ deviceId,
648
+ metrics,
649
+ series: {
650
+ metric: descriptor,
651
+ resolution: result.tier.interval,
652
+ tier: result.tier.name,
653
+ from: fromSec,
654
+ to: toSec,
655
+ points: result.points,
656
+ },
657
+ };
658
+ });
659
+ app.get('/api/devices/:deviceId/history', async (req) => {
660
+ const { deviceId } = req.params;
661
+ const { period = 'day', date } = req.query;
662
+ const historyDir = join(OPENBRIDGE_HOME, 'energy-history');
663
+ const filePath = join(historyDir, `${deviceId}.json`);
664
+ let samples = [];
665
+ try {
666
+ samples = JSON.parse(readFileSync(filePath, 'utf8'));
667
+ }
668
+ catch {
669
+ return { period, date: date ?? new Date().toISOString().slice(0, 10), buckets: [] };
670
+ }
671
+ const ref = date ? new Date(date) : new Date();
672
+ if (period === 'day') {
673
+ // 24 hourly buckets for ref day
674
+ const dayStr = ref.toISOString().slice(0, 10);
675
+ const buckets = Array.from({ length: 24 }, (_, h) => {
676
+ const label = `${String(h).padStart(2, '0')}:00`;
677
+ const start = new Date(`${dayStr}T${String(h).padStart(2, '0')}:00:00Z`);
678
+ const end = new Date(`${dayStr}T${String(h + 1).padStart(2, '0') === '24' ? '23:59:59' : String(h + 1).padStart(2, '0') + ':00:00'}Z`);
679
+ const inWindow = samples.filter((s) => s.t >= start.toISOString() && s.t < end.toISOString());
680
+ if (inWindow.length < 2)
681
+ return { label, kwh: null };
682
+ const kwh = inWindow[inWindow.length - 1].e - inWindow[0].e;
683
+ return { label, kwh: Math.max(0, Math.round(kwh * 100) / 100) };
684
+ });
685
+ const totalKwh = buckets.reduce((sum, b) => sum + (b.kwh ?? 0), 0);
686
+ return { period, date: dayStr, buckets, totalKwh: Math.round(totalKwh * 100) / 100 };
687
+ }
688
+ if (period === 'month') {
689
+ // Daily buckets for ref month
690
+ const year = ref.getFullYear();
691
+ const month = ref.getMonth();
692
+ const daysInMonth = new Date(year, month + 1, 0).getDate();
693
+ const monthStr = `${year}-${String(month + 1).padStart(2, '0')}`;
694
+ const buckets = Array.from({ length: daysInMonth }, (_, d) => {
695
+ const dayStr = `${monthStr}-${String(d + 1).padStart(2, '0')}`;
696
+ const start = new Date(`${dayStr}T00:00:00Z`);
697
+ const end = new Date(`${dayStr}T23:59:59Z`);
698
+ const inWindow = samples.filter((s) => s.t >= start.toISOString() && s.t <= end.toISOString());
699
+ if (inWindow.length < 2)
700
+ return { label: String(d + 1), kwh: null };
701
+ const kwh = inWindow[inWindow.length - 1].e - inWindow[0].e;
702
+ return { label: String(d + 1), kwh: Math.max(0, Math.round(kwh * 100) / 100) };
703
+ });
704
+ const totalKwh = buckets.reduce((sum, b) => sum + (b.kwh ?? 0), 0);
705
+ return { period, date: monthStr, buckets, totalKwh: Math.round(totalKwh * 100) / 100 };
706
+ }
707
+ // period === 'year'
708
+ const year = ref.getFullYear();
709
+ const buckets = Array.from({ length: 12 }, (_, m) => {
710
+ const monthStr = `${year}-${String(m + 1).padStart(2, '0')}`;
711
+ const start = new Date(`${monthStr}-01T00:00:00Z`);
712
+ const end = new Date(year, m + 1, 0, 23, 59, 59);
713
+ const inWindow = samples.filter((s) => s.t >= start.toISOString() && s.t <= end.toISOString());
714
+ if (inWindow.length < 2)
715
+ return {
716
+ label: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][m],
717
+ kwh: null,
718
+ };
719
+ const kwh = inWindow[inWindow.length - 1].e - inWindow[0].e;
720
+ return {
721
+ label: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][m],
722
+ kwh: Math.max(0, Math.round(kwh * 100) / 100),
723
+ };
724
+ });
725
+ const totalKwh = buckets.reduce((sum, b) => sum + (b.kwh ?? 0), 0);
726
+ return { period, date: String(year), buckets, totalKwh: Math.round(totalKwh * 100) / 100 };
727
+ });
728
+ app.post('/api/plugins/:id/disabled', async (req) => {
729
+ const { id } = req.params;
730
+ const { disabled } = req.body;
731
+ const entry = registry.get(id);
732
+ if (!entry)
733
+ throw { statusCode: 404, message: `Plugin '${id}' not found` };
734
+ // Update the instance
735
+ entry.instance.disabled = disabled;
736
+ // Persist to config.json under a disabledPlugins array
737
+ try {
738
+ let cfg = {};
739
+ try {
740
+ cfg = JSON.parse(readFileSync(configPath, 'utf8'));
741
+ }
742
+ catch {
743
+ /* start fresh */
744
+ }
745
+ // Persist the npm package name when we know it, not the registry id.
746
+ // A Homebridge-compat plugin is registered under its *platform* name
747
+ // ("ShellyDS9") while the loader looks plugins up by *package* name
748
+ // ("homebridge-shelly-ds9"), so writing the id here meant the loader
749
+ // never matched and the plugin started anyway. For native plugins and
750
+ // legacy config.platforms[] entries the two are the same value.
751
+ const key = entry.instance.packageName ?? id;
752
+ // Remove every alias so a stale entry written under the old id cannot
753
+ // keep a re-enabled plugin switched off.
754
+ const aliases = new Set([id, key, entry.instance.platformName].filter(Boolean));
755
+ const disabledPlugins = (cfg.disabledPlugins ?? []).filter((n) => !aliases.has(n));
756
+ if (disabled)
757
+ disabledPlugins.push(key);
758
+ cfg.disabledPlugins = disabledPlugins;
759
+ writeFileSync(configPath, JSON.stringify(cfg, null, 2), 'utf8');
760
+ log.info(`Plugin disabled state toggled: ${key} -> ${disabled}`);
761
+ }
762
+ catch (err) {
763
+ log.warn(`Failed to persist disabled state: ${err}`);
764
+ }
765
+ return entry.instance;
766
+ });
767
+ // ─── Accessories (HAP / Homebridge) ───────────────────────────────────────
768
+ app.get('/api/accessories', async () => {
769
+ return { accessories: hapAPI ? hapAPI.getAccessories() : [] };
770
+ });
771
+ // Write a characteristic value: triggers the platform's onSet handler
772
+ app.post('/api/accessories/:uuid/characteristics', async (req) => {
773
+ if (!hapAPI)
774
+ throw { statusCode: 503, message: 'HAP not available' };
775
+ const { uuid } = req.params;
776
+ const { serviceUuid, charUuid, value } = req.body;
777
+ const raw = hapAPI.getRawAccessories();
778
+ const acc = raw.find((a) => a.UUID === uuid);
779
+ if (!acc)
780
+ throw { statusCode: 404, message: `Accessory '${uuid}' not found` };
781
+ const svc = acc.services?.find((s) => s.UUID === serviceUuid);
782
+ if (!svc)
783
+ throw { statusCode: 404, message: `Service '${serviceUuid}' not found` };
784
+ const ch = svc.characteristics?.find((c) => c.UUID === charUuid);
785
+ if (!ch)
786
+ throw { statusCode: 404, message: `Characteristic '${charUuid}' not found` };
787
+ try {
788
+ ch.setValue(value);
789
+ }
790
+ catch (err) {
791
+ throw { statusCode: 500, message: `setValue failed: ${err}` };
792
+ }
793
+ log.debug(`Set ${acc.displayName} / ${svc.displayName} / ${ch.displayName} = ${value}`);
794
+ return { uuid, serviceUuid, charUuid, value };
795
+ });
796
+ app.get('/api/accessories/debug', async () => {
797
+ if (!hapAPI)
798
+ return { hapAPI: false };
799
+ const raw = hapAPI.getRawAccessories();
800
+ return {
801
+ count: raw.length,
802
+ names: raw.map((a) => ({ displayName: a.displayName, UUID: a.UUID, category: a.category })),
803
+ };
804
+ });
805
+ // ─── Bridge config (structured) ───────────────────────────────────────────
806
+ app.get('/api/bridge', async () => {
807
+ try {
808
+ const cfg = JSON.parse(readFileSync(configPath, 'utf8'));
809
+ return cfg.bridge ?? {};
810
+ }
811
+ catch {
812
+ return {};
813
+ }
814
+ });
815
+ app.post('/api/bridge', async (req) => {
816
+ const bridgeUpdate = req.body;
817
+ let cfg = {};
818
+ try {
819
+ cfg = JSON.parse(readFileSync(configPath, 'utf8'));
820
+ }
821
+ catch {
822
+ /* start fresh */
823
+ }
824
+ cfg.bridge = { ...(cfg.bridge ?? {}), ...bridgeUpdate };
825
+ writeFileSync(configPath, JSON.stringify(cfg, null, 2), 'utf8');
826
+ log.info('Bridge config saved');
827
+ return { saved: true, bridge: cfg.bridge };
828
+ });
829
+ // ─── Config file editor ────────────────────────────────────────────────────
830
+ const configPath = resolve(os.homedir(), '.openbridge', 'config.json');
831
+ app.get('/api/config', async () => {
832
+ try {
833
+ return { content: readFileSync(configPath, 'utf8') };
834
+ }
835
+ catch {
836
+ return { content: '{}' };
837
+ }
838
+ });
839
+ app.post('/api/config', async (req) => {
840
+ const { content } = req.body;
841
+ try {
842
+ JSON.parse(content);
843
+ }
844
+ catch {
845
+ throw { statusCode: 400, message: 'Invalid JSON' };
846
+ }
847
+ writeFileSync(configPath, content, 'utf8');
848
+ log.info('Config saved');
849
+ return { saved: true };
850
+ });
851
+ // Upsert a single platform entry (add or replace by platform name)
852
+ app.post('/api/config/platform', async (req) => {
853
+ const { platform } = req.body;
854
+ if (!platform?.platform)
855
+ throw { statusCode: 400, message: 'platform.platform is required' };
856
+ let cfg = {};
857
+ try {
858
+ cfg = JSON.parse(readFileSync(configPath, 'utf8'));
859
+ }
860
+ catch {
861
+ /* start fresh */
862
+ }
863
+ const platforms = cfg.platforms ?? [];
864
+ const idx = platforms.findIndex((p) => p.platform === platform.platform);
865
+ if (idx >= 0)
866
+ platforms[idx] = platform;
867
+ else
868
+ platforms.push(platform);
869
+ cfg.platforms = platforms;
870
+ writeFileSync(configPath, JSON.stringify(cfg, null, 2), 'utf8');
871
+ log.info(`Platform config saved: ${platform.platform}`);
872
+ return { saved: true, platform: platform.platform };
873
+ });
874
+ // Get a single platform's config (or empty object if not configured)
875
+ app.get('/api/config/platform/:name', async (req) => {
876
+ const { name } = req.params;
877
+ try {
878
+ const cfg = JSON.parse(readFileSync(configPath, 'utf8'));
879
+ const entry = (cfg.platforms ?? []).find((p) => p.platform === name);
880
+ return { config: entry ?? null };
881
+ }
882
+ catch {
883
+ return { config: null };
884
+ }
885
+ });
886
+ // Get a single native plugin's config
887
+ app.get('/api/config/plugin/:name', async (req) => {
888
+ const { name } = req.params;
889
+ try {
890
+ const cfg = JSON.parse(readFileSync(configPath, 'utf8'));
891
+ const entry = (cfg.plugins ?? []).find((p) => p.name === name);
892
+ return { config: entry?.config ?? null };
893
+ }
894
+ catch {
895
+ return { config: null };
896
+ }
897
+ });
898
+ // Upsert a native plugin's config entry
899
+ app.post('/api/config/plugin', async (req) => {
900
+ const { name, config } = req.body;
901
+ if (!name)
902
+ throw { statusCode: 400, message: 'name is required' };
903
+ let cfg = {};
904
+ try {
905
+ cfg = JSON.parse(readFileSync(configPath, 'utf8'));
906
+ }
907
+ catch {
908
+ /* start fresh */
909
+ }
910
+ const plugins = cfg.plugins ?? [];
911
+ const idx = plugins.findIndex((p) => p.name === name);
912
+ if (idx >= 0)
913
+ plugins[idx] = { ...plugins[idx], config };
914
+ else
915
+ plugins.push({ name, enabled: true, config });
916
+ cfg.plugins = plugins;
917
+ writeFileSync(configPath, JSON.stringify(cfg, null, 2), 'utf8');
918
+ log.info(`Plugin config saved: ${name}`);
919
+ return { saved: true, name };
920
+ });
921
+ function readConfigFile() {
922
+ try {
923
+ return JSON.parse(readFileSync(configPath, 'utf8'));
924
+ }
925
+ catch {
926
+ return {};
927
+ }
928
+ }
929
+ function findHbConfig(cfg, platformName, packageName) {
930
+ const platformEntry = (cfg.platforms ?? []).find((p) => p.platform === platformName);
931
+ if (platformEntry)
932
+ return { config: platformEntry, location: 'platforms' };
933
+ // Match on the package name when the UI knows it, else fall back to the
934
+ // platform name: some entries are keyed that way.
935
+ const pluginEntry = (cfg.plugins ?? []).find((p) => p.name === (packageName ?? platformName));
936
+ if (pluginEntry)
937
+ return { config: pluginEntry.config ?? {}, location: 'plugins' };
938
+ return { config: null, location: null };
939
+ }
940
+ app.get('/api/config/hb-plugin/:name', async (req) => {
941
+ const { name } = req.params;
942
+ const { packageName } = req.query;
943
+ return findHbConfig(readConfigFile(), name, packageName);
944
+ });
945
+ app.post('/api/config/hb-plugin', async (req) => {
946
+ const { name, packageName, config } = req.body;
947
+ if (!name)
948
+ throw { statusCode: 400, message: 'name is required' };
949
+ const cfg = readConfigFile();
950
+ const { location } = findHbConfig(cfg, name, packageName);
951
+ if (location === 'platforms') {
952
+ const platforms = cfg.platforms ?? [];
953
+ const idx = platforms.findIndex((p) => p.platform === name);
954
+ platforms[idx] = { ...config, platform: name };
955
+ cfg.platforms = platforms;
956
+ }
957
+ else {
958
+ // Default for anything not already in platforms[]: auto-discovered
959
+ // plugins are read from plugins[], so a new entry must go there or the
960
+ // save is silently ignored on the next start.
961
+ const key = packageName ?? name;
962
+ const plugins = cfg.plugins ?? [];
963
+ const idx = plugins.findIndex((p) => p.name === key);
964
+ if (idx >= 0)
965
+ plugins[idx] = { ...plugins[idx], config };
966
+ else
967
+ plugins.push({ name: key, enabled: true, config });
968
+ cfg.plugins = plugins;
969
+ }
970
+ writeFileSync(configPath, JSON.stringify(cfg, null, 2), 'utf8');
971
+ log.info(`Homebridge plugin config saved: ${name} (${location ?? 'plugins'})`);
972
+ return { saved: true, name, location: location ?? 'plugins' };
973
+ });
974
+ // ─── Marketplace ──────────────────────────────────────────────────────────
975
+ // Plugins are discovered by npm keyword. Native OpenBridge plugins declare
976
+ // `openbridge-plugin`; Homebridge-compat ones declare `homebridge-plugin`.
977
+ // npm's search API has no OR for keywords (both `a,b` and `a+b` mean AND and
978
+ // return nothing), so each keyword needs its own query and the results are
979
+ // merged here. Searching only one keyword hides an entire class of plugin
980
+ // from the marketplace, including natives the user can actually install.
981
+ const PLUGIN_KEYWORDS = ['homebridge-plugin', 'openbridge-plugin'];
982
+ app.get('/api/marketplace/search', async (req) => {
983
+ const { q = '', from = '0', size = '20' } = req.query;
984
+ const results = await Promise.all(PLUGIN_KEYWORDS.map(async (keyword) => {
985
+ const text = encodeURIComponent(`keywords:${keyword} ${q}`.trim());
986
+ const url = `https://registry.npmjs.org/-/v1/search?text=${text}&size=${size}&from=${from}`;
987
+ const res = await fetch(url);
988
+ if (!res.ok)
989
+ throw { statusCode: 502, message: `npm registry error: ${res.status}` };
990
+ return (await res.json());
991
+ }));
992
+ // Dedupe by package name: a plugin may legitimately declare both keywords.
993
+ const seen = new Set();
994
+ const objects = [];
995
+ for (const result of results) {
996
+ for (const obj of result.objects ?? []) {
997
+ const name = obj?.package?.name;
998
+ if (!name || seen.has(name))
999
+ continue;
1000
+ seen.add(name);
1001
+ objects.push(obj);
1002
+ }
1003
+ }
1004
+ // Restore npm's own relevance ordering across the merged set, then trim to
1005
+ // the requested page size so the response stays the shape the UI expects.
1006
+ objects.sort((a, b) => (b?.searchScore ?? 0) - (a?.searchScore ?? 0));
1007
+ return {
1008
+ objects: objects.slice(0, Number(size) || 20),
1009
+ total: results.reduce((sum, r) => sum + (r.total ?? 0), 0),
1010
+ time: results[0]?.time,
1011
+ };
1012
+ });
1013
+ // Probe a marketplace plugin to discover its platform name(s) without fully starting it
1014
+ app.get('/api/marketplace/plugin-info/:name', async (req) => {
1015
+ const { name } = req.params;
1016
+ const pkgDir = join(HB_PLUGINS_DIR, 'node_modules', name);
1017
+ if (!existsSync(pkgDir))
1018
+ throw { statusCode: 404, message: `Plugin '${name}' not installed` };
1019
+ const pkgJsonPath = join(pkgDir, 'package.json');
1020
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
1021
+ const mainFile = join(pkgDir, pkg.main ?? 'index.js');
1022
+ const platforms = [];
1023
+ const spyAPI = {
1024
+ hap: { Characteristic: {}, Service: {}, Accessory: { Categories: {} }, uuid: { generate: () => '' } },
1025
+ platformAccessory: class {
1026
+ },
1027
+ // Handle both calling conventions:
1028
+ // 4-arg: registerPlatform(pluginName, platformName, Constructor, dynamic)
1029
+ // 3-arg: registerPlatform(platformName, Constructor, dynamic)
1030
+ registerPlatform: (...args) => {
1031
+ const platformName = typeof args[0] === 'string' && typeof args[1] === 'string' ? args[1] : args[0];
1032
+ if (typeof platformName === 'string')
1033
+ platforms.push(platformName);
1034
+ },
1035
+ registerAccessory: () => { },
1036
+ on: () => spyAPI,
1037
+ emit: () => false,
1038
+ version: '2.0.0',
1039
+ serverVersion: '2.0.0',
1040
+ user: {
1041
+ storagePath: () => join(os.homedir(), '.openbridge'),
1042
+ configPath: () => join(os.homedir(), '.openbridge', 'config.json'),
1043
+ persistPath: () => join(os.homedir(), '.openbridge', 'persist'),
1044
+ cachedAccessoryPath: () => join(os.homedir(), '.openbridge', 'accessories'),
1045
+ },
1046
+ };
1047
+ try {
1048
+ const { loadHomebridgePlugin } = await import('@nubisco/openbridge-compatibility-homebridge');
1049
+ const fn = loadHomebridgePlugin(mainFile);
1050
+ fn(spyAPI);
1051
+ }
1052
+ catch {
1053
+ /* plugin may fail without real HAP, but registration should have happened */
1054
+ }
1055
+ return { name, version: pkg.version, mainFile, platforms };
1056
+ });
1057
+ app.get('/api/marketplace/installed', async () => {
1058
+ const nmDir = join(HB_PLUGINS_DIR, 'node_modules');
1059
+ if (!existsSync(nmDir))
1060
+ return { packages: [] };
1061
+ try {
1062
+ const pkgJson = join(HB_PLUGINS_DIR, 'package.json');
1063
+ if (!existsSync(pkgJson))
1064
+ return { packages: [] };
1065
+ const pkg = JSON.parse(readFileSync(pkgJson, 'utf8'));
1066
+ const deps = { ...pkg.dependencies };
1067
+ return {
1068
+ packages: Object.entries(deps).map(([name, version]) => {
1069
+ let mainFile = '';
1070
+ try {
1071
+ const pkgPath = join(nmDir, name, 'package.json');
1072
+ const p = JSON.parse(readFileSync(pkgPath, 'utf8'));
1073
+ mainFile = join(nmDir, name, p.main ?? 'index.js');
1074
+ }
1075
+ catch {
1076
+ /* ignore */
1077
+ }
1078
+ return { name, version, mainFile };
1079
+ }),
1080
+ };
1081
+ }
1082
+ catch {
1083
+ return { packages: [] };
1084
+ }
1085
+ });
1086
+ // OpenBridge native + local dev plugins
1087
+ // Always scans ~/.openbridge/plugins/openbridge (canonical install location)
1088
+ // plus any extra directories in config.localPluginSources (for dev/testing)
1089
+ app.get('/api/marketplace/local', async () => {
1090
+ const plugins = [];
1091
+ const seen = new Set();
1092
+ // Helper: scan a directory for openbridge-* sub-packages
1093
+ function scanDir(dir) {
1094
+ if (!existsSync(dir))
1095
+ return;
1096
+ let entries;
1097
+ try {
1098
+ entries = readdirSync(dir, { encoding: 'utf8', withFileTypes: false });
1099
+ }
1100
+ catch {
1101
+ return;
1102
+ }
1103
+ for (const entry of entries) {
1104
+ if (!entry.startsWith('openbridge-'))
1105
+ continue;
1106
+ const pkgPath = join(dir, entry, 'package.json');
1107
+ if (!existsSync(pkgPath))
1108
+ continue;
1109
+ try {
1110
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
1111
+ const name = pkg.name ?? entry;
1112
+ if (seen.has(name))
1113
+ continue; // deduplicate across sources
1114
+ seen.add(name);
1115
+ plugins.push({
1116
+ name,
1117
+ version: pkg.version ?? '0.0.0',
1118
+ description: pkg.description ?? '',
1119
+ author: typeof pkg.author === 'string' ? pkg.author : (pkg.author?.name ?? ''),
1120
+ path: join(dir, entry),
1121
+ platform: pkg.openbridge?.platform,
1122
+ displayName: pkg.openbridge?.displayName,
1123
+ });
1124
+ }
1125
+ catch {
1126
+ /* skip */
1127
+ }
1128
+ }
1129
+ }
1130
+ // 1. Canonical install location (highest priority: shows installed native plugins)
1131
+ scanDir(OB_PLUGINS_DIR);
1132
+ // 2. Extra dev/test directories from config
1133
+ for (const sourceDir of localPluginSources)
1134
+ scanDir(sourceDir);
1135
+ return { plugins };
1136
+ });
1137
+ // ─── Plugin metadata cache helpers ─────────────────────────────────────────
1138
+ const metadataCachePath = resolve(os.homedir(), '.openbridge', 'plugin-metadata-cache.json');
1139
+ function loadMetadataCache() {
1140
+ try {
1141
+ if (existsSync(metadataCachePath)) {
1142
+ return JSON.parse(readFileSync(metadataCachePath, 'utf8'));
1143
+ }
1144
+ }
1145
+ catch {
1146
+ /* ignore */
1147
+ }
1148
+ return {};
1149
+ }
1150
+ function saveMetadataCache(cache) {
1151
+ try {
1152
+ mkdirSync(dirname(metadataCachePath), { recursive: true });
1153
+ writeFileSync(metadataCachePath, JSON.stringify(cache, null, 2), 'utf8');
1154
+ }
1155
+ catch (err) {
1156
+ log.warn(`Failed to save metadata cache: ${err}`);
1157
+ }
1158
+ }
1159
+ async function fetchAndCacheEnrichedMetadata(pkgName) {
1160
+ try {
1161
+ // Fetch from the enriched endpoint (same logic as GET /api/marketplace/enriched/:name)
1162
+ const enrichedRes = await fetch(`http://localhost:${process.env.PORT ?? 8000}/api/marketplace/enriched/${encodeURIComponent(pkgName)}`).catch(() => null);
1163
+ if (enrichedRes?.ok) {
1164
+ const enriched = await enrichedRes.json();
1165
+ // Save to cache
1166
+ const cache = loadMetadataCache();
1167
+ cache[pkgName] = enriched;
1168
+ saveMetadataCache(cache);
1169
+ return enriched;
1170
+ }
1171
+ }
1172
+ catch (err) {
1173
+ log.warn(`Failed to fetch enriched metadata for ${pkgName}: ${err}`);
1174
+ }
1175
+ return undefined;
1176
+ }
1177
+ // ─── Plugin update checker ─────────────────────────────────────────────────
1178
+ // Queries npm registry for latest versions of all installed plugins and
1179
+ // sets `availableUpdate` on each PluginInstance when a newer version exists.
1180
+ async function checkPluginUpdates() {
1181
+ const plugins = registry.getAll();
1182
+ let updatesFound = 0;
1183
+ for (const plugin of plugins) {
1184
+ const name = plugin.manifest.name;
1185
+ const currentVersion = plugin.manifest.version;
1186
+ if (!currentVersion || currentVersion === '?.?.?')
1187
+ continue;
1188
+ try {
1189
+ const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}/latest`, {
1190
+ signal: AbortSignal.timeout(5000),
1191
+ headers: { Accept: 'application/json' },
1192
+ });
1193
+ if (!res.ok)
1194
+ continue;
1195
+ const data = (await res.json());
1196
+ const latest = data.version;
1197
+ if (!latest)
1198
+ continue;
1199
+ if (latest !== currentVersion) {
1200
+ plugin.availableUpdate = latest;
1201
+ updatesFound++;
1202
+ }
1203
+ else {
1204
+ plugin.availableUpdate = undefined;
1205
+ }
1206
+ }
1207
+ catch {
1208
+ // npm unreachable for this package, skip
1209
+ }
1210
+ }
1211
+ if (updatesFound > 0) {
1212
+ log.info(`Plugin update check: ${updatesFound} update(s) available`);
1213
+ }
1214
+ return updatesFound;
1215
+ }
1216
+ // Check for updates on startup (after a delay for plugins to register) and every 6 hours
1217
+ setTimeout(() => checkPluginUpdates(), 60_000);
1218
+ setInterval(() => checkPluginUpdates(), 6 * 60 * 60_000);
1219
+ app.get('/api/plugins/updates', async () => {
1220
+ const count = await checkPluginUpdates();
1221
+ const updates = registry
1222
+ .getAll()
1223
+ .filter((p) => p.availableUpdate)
1224
+ .map((p) => ({
1225
+ name: p.manifest.name,
1226
+ current: p.manifest.version,
1227
+ latest: p.availableUpdate,
1228
+ }));
1229
+ return { count, updates };
1230
+ });
1231
+ app.post('/api/marketplace/install', async (req) => {
1232
+ const { package: pkg } = req.body;
1233
+ if (!pkg || !/^[a-z0-9@._/-]+$/i.test(pkg)) {
1234
+ throw { statusCode: 400, message: 'Invalid package name' };
1235
+ }
1236
+ mkdirSync(HB_PLUGINS_DIR, { recursive: true });
1237
+ await new Promise((ok, fail) => {
1238
+ const child = spawn('npm', ['install', '--prefix', HB_PLUGINS_DIR, pkg], {
1239
+ stdio: 'pipe',
1240
+ env: { ...process.env },
1241
+ });
1242
+ child.on('close', (code) => (code === 0 ? ok() : fail(new Error(`npm exited with ${code}`))));
1243
+ });
1244
+ // Find the installed plugin's main file
1245
+ let mainFile = '';
1246
+ try {
1247
+ const pkgPath = join(HB_PLUGINS_DIR, 'node_modules', pkg, 'package.json');
1248
+ const p = JSON.parse(readFileSync(pkgPath, 'utf8'));
1249
+ mainFile = join(HB_PLUGINS_DIR, 'node_modules', pkg, p.main ?? 'index.js');
1250
+ }
1251
+ catch {
1252
+ /* ignore */
1253
+ }
1254
+ // Detect if native OpenBridge plugin and register immediately
1255
+ let isNative = false;
1256
+ try {
1257
+ const pkgPath = join(HB_PLUGINS_DIR, 'node_modules', pkg, 'package.json');
1258
+ const p = JSON.parse(readFileSync(pkgPath, 'utf8'));
1259
+ isNative = (Array.isArray(p.keywords) && p.keywords.includes('openbridge-plugin')) || p.openbridge != null;
1260
+ if (isNative) {
1261
+ // Register as a pseudo-plugin so it appears in the UI immediately
1262
+ // It will be fully loaded on next restart
1263
+ const pseudoPlugin = {
1264
+ manifest: {
1265
+ name: p.name ?? pkg,
1266
+ version: p.version ?? '?.?.?',
1267
+ description: p.description ?? '',
1268
+ author: typeof p.author === 'string' ? p.author : (p.author?.name ?? ''),
1269
+ },
1270
+ };
1271
+ if (!registry.get(p.name ?? pkg)) {
1272
+ registry.register(pseudoPlugin);
1273
+ registry.updateStatus(p.name ?? pkg, 'stopped');
1274
+ }
1275
+ log.info(`Discovered plugin: ${p.name ?? pkg}`);
1276
+ }
1277
+ }
1278
+ catch {
1279
+ /* ignore detection errors */
1280
+ }
1281
+ // Fetch and cache enriched metadata in the background (non-blocking)
1282
+ fetchAndCacheEnrichedMetadata(pkg).catch((err) => log.warn(`Failed to cache metadata for ${pkg}: ${err}`));
1283
+ log.info(`Installed plugin: ${pkg}`);
1284
+ return { installed: pkg, mainFile, pluginsDir: HB_PLUGINS_DIR, isNative, needsRestart: isNative };
1285
+ });
1286
+ app.post('/api/marketplace/update/:name', async (req) => {
1287
+ const { name } = req.params;
1288
+ if (!name || !/^[a-z0-9@._/-]+$/i.test(name)) {
1289
+ throw { statusCode: 400, message: 'Invalid package name' };
1290
+ }
1291
+ const entry = registry.get(name);
1292
+ const targetVersion = entry?.instance.availableUpdate;
1293
+ const installArg = targetVersion ? `${name}@${targetVersion}` : name;
1294
+ mkdirSync(HB_PLUGINS_DIR, { recursive: true });
1295
+ await new Promise((ok, fail) => {
1296
+ const child = spawn('npm', ['install', '--prefix', HB_PLUGINS_DIR, installArg], {
1297
+ stdio: 'pipe',
1298
+ env: { ...process.env },
1299
+ });
1300
+ child.on('close', (code) => (code === 0 ? ok() : fail(new Error(`npm exited with ${code}`))));
1301
+ });
1302
+ // Update the in-memory manifest version so the UI reflects the change immediately
1303
+ if (entry) {
1304
+ try {
1305
+ const pkgPath = join(HB_PLUGINS_DIR, 'node_modules', name, 'package.json');
1306
+ const p = JSON.parse(readFileSync(pkgPath, 'utf8'));
1307
+ entry.instance.manifest.version = p.version ?? entry.instance.manifest.version;
1308
+ entry.instance.manifest.description = p.description ?? entry.instance.manifest.description;
1309
+ }
1310
+ catch {
1311
+ // If we can't read the new version, the restart will pick it up
1312
+ }
1313
+ entry.instance.availableUpdate = undefined;
1314
+ }
1315
+ log.info(`Updated plugin: ${name} to ${targetVersion ?? 'latest'}`);
1316
+ return { updated: name, version: targetVersion ?? 'latest', needsRestart: true };
1317
+ });
1318
+ app.delete('/api/marketplace/uninstall/:name', async (req) => {
1319
+ const { name } = req.params;
1320
+ if (!name || !/^[a-z0-9@._/-]+$/i.test(name)) {
1321
+ throw { statusCode: 400, message: 'Invalid package name' };
1322
+ }
1323
+ await new Promise((ok, fail) => {
1324
+ const child = spawn('npm', ['uninstall', '--prefix', HB_PLUGINS_DIR, name], {
1325
+ stdio: 'pipe',
1326
+ env: { ...process.env },
1327
+ });
1328
+ child.on('close', (code) => (code === 0 ? ok() : fail(new Error(`npm exited with ${code}`))));
1329
+ });
1330
+ // Remove stale plugin instances from in-memory registry so /plugins updates immediately.
1331
+ const removedInstances = registry.unregisterWhere((instance) => {
1332
+ if (instance.manifest.name === name)
1333
+ return true;
1334
+ const desc = instance.manifest.description ?? '';
1335
+ // Backward compatibility for older pseudo-plugin descriptions.
1336
+ return desc.includes(`Homebridge platform: ${name}`);
1337
+ });
1338
+ // Prune config.platforms AND config.plugins entries for this package.
1339
+ try {
1340
+ const cfg = JSON.parse(readFileSync(configPath, 'utf8'));
1341
+ let changed = false;
1342
+ // Remove from platforms (Homebridge-compat plugins)
1343
+ if (Array.isArray(cfg.platforms)) {
1344
+ const before = cfg.platforms.length;
1345
+ cfg.platforms = cfg.platforms.filter((p) => {
1346
+ const pluginPath = String(p?.plugin ?? '');
1347
+ return !pluginPath.includes(`/node_modules/${name}/`);
1348
+ });
1349
+ if (cfg.platforms.length !== before)
1350
+ changed = true;
1351
+ }
1352
+ // Remove from plugins (native OpenBridge plugins)
1353
+ if (Array.isArray(cfg.plugins)) {
1354
+ const before = cfg.plugins.length;
1355
+ cfg.plugins = cfg.plugins.filter((p) => p?.name !== name);
1356
+ if (cfg.plugins.length !== before)
1357
+ changed = true;
1358
+ }
1359
+ if (changed) {
1360
+ writeFileSync(configPath, JSON.stringify(cfg, null, 2), 'utf8');
1361
+ }
1362
+ }
1363
+ catch {
1364
+ /* ignore config pruning errors */
1365
+ }
1366
+ // Remove any symlink in the native plugins dir
1367
+ try {
1368
+ const { readdirSync, lstatSync, unlinkSync, readlinkSync } = await import('fs');
1369
+ const nativeDir = join(OPENBRIDGE_HOME, 'plugins', 'openbridge');
1370
+ if (existsSync(nativeDir)) {
1371
+ for (const entry of readdirSync(nativeDir)) {
1372
+ const entryPath = join(nativeDir, entry);
1373
+ try {
1374
+ const stat = lstatSync(entryPath);
1375
+ if (stat.isSymbolicLink()) {
1376
+ const target = readlinkSync(entryPath);
1377
+ if (entry.includes(name.replace(/^@[^/]+\//, '')) || target.includes(name)) {
1378
+ unlinkSync(entryPath);
1379
+ log.info(`Removed symlink: ${entryPath}`);
1380
+ }
1381
+ }
1382
+ }
1383
+ catch {
1384
+ /* skip */
1385
+ }
1386
+ }
1387
+ }
1388
+ }
1389
+ catch {
1390
+ /* ignore symlink cleanup errors */
1391
+ }
1392
+ // Clear metadata cache for this plugin
1393
+ try {
1394
+ const cache = loadMetadataCache();
1395
+ delete cache[name];
1396
+ saveMetadataCache(cache);
1397
+ }
1398
+ catch {
1399
+ /* ignore cache cleanup errors */
1400
+ }
1401
+ log.info(`Uninstalled plugin: ${name}`);
1402
+ return { uninstalled: name, removedInstances };
1403
+ });
1404
+ // ─── Daemon restart ───────────────────────────────────────────────────────
1405
+ app.post('/api/daemon/restart', async (_req, reply) => {
1406
+ log.info('Restart requested via API: respawning...');
1407
+ await reply.send({ restarting: true });
1408
+ setTimeout(() => {
1409
+ // In dev mode (OPENBRIDGE_DEV=true set by the dev script), tsx watch detects the exit and restarts.
1410
+ // In prod mode (node dist/index.js), we respawn using the same entry point.
1411
+ const isTsx = process.env.OPENBRIDGE_DEV === 'true';
1412
+ if (isTsx) {
1413
+ // Let tsx watch restart us
1414
+ process.exit(0);
1415
+ }
1416
+ else {
1417
+ const child = spawn(process.execPath, process.argv.slice(1), {
1418
+ detached: true,
1419
+ stdio: 'inherit',
1420
+ env: process.env,
1421
+ });
1422
+ child.unref();
1423
+ process.exit(0);
1424
+ }
1425
+ }, 300);
1426
+ });
1427
+ // ─── Logs ─────────────────────────────────────────────────────────────────
1428
+ app.get('/api/logs', async (req) => {
1429
+ const query = req.query;
1430
+ const entries = Logger.getEntries(query.plugin, query.limit ? parseInt(query.limit) : 200);
1431
+ return { entries };
1432
+ });
1433
+ app.get('/ws/logs', { websocket: true }, (connection) => {
1434
+ const ws = connection.socket;
1435
+ log.debug('Log WebSocket client connected');
1436
+ const unsubscribe = Logger.subscribe((entry) => {
1437
+ if (ws.readyState === ws.OPEN)
1438
+ ws.send(JSON.stringify(entry));
1439
+ });
1440
+ ws.on('close', () => {
1441
+ unsubscribe();
1442
+ log.debug('Log WebSocket client disconnected');
1443
+ });
1444
+ });
1445
+ // ─── Plugin config schema ────────────────────────────────────────────────
1446
+ // Returns parsed config.schema.json for a given installed HB plugin
1447
+ app.get('/api/marketplace/plugin-schema/:name', async (req) => {
1448
+ const { name } = req.params;
1449
+ const pkgDir = join(HB_PLUGINS_DIR, 'node_modules', name);
1450
+ const schemaPath = join(pkgDir, 'config.schema.json');
1451
+ if (!existsSync(schemaPath)) {
1452
+ // Also try at package root (some plugins ship it differently)
1453
+ const altPath = join(pkgDir, 'homebridge-ui', 'config.schema.json');
1454
+ if (existsSync(altPath))
1455
+ return { schema: JSON.parse(readFileSync(altPath, 'utf8')) };
1456
+ return { schema: null };
1457
+ }
1458
+ return { schema: JSON.parse(readFileSync(schemaPath, 'utf8')) };
1459
+ });
1460
+ // Helper: Extract GitHub repo URL from various sources
1461
+ function extractGithubRepo(pkg) {
1462
+ let repo = pkg.repository?.url ?? pkg.repository ?? pkg.homepage ?? pkg.links?.repository ?? '';
1463
+ if (typeof repo !== 'string')
1464
+ return null;
1465
+ // Normalize common npm package repository URL forms:
1466
+ // - git+https://github.com/owner/repo.git
1467
+ // - https://github.com/owner/repo
1468
+ // - github:owner/repo
1469
+ // - git@github.com:owner/repo.git
1470
+ repo = repo
1471
+ .trim()
1472
+ .replace(/^github:/, 'https://github.com/')
1473
+ .replace(/^git\+/, '')
1474
+ .replace(/^git@github\.com:/, 'https://github.com/');
1475
+ const match = repo.match(/github\.com\/([^/]+)\/([^/#?]+?)(?:\.git)?(?:[/?#].*)?$/);
1476
+ return match ? `${match[1]}/${match[2]}` : null;
1477
+ }
1478
+ function parseCompactGithubCount(raw) {
1479
+ const text = raw.trim().toLowerCase().replace(/,/g, '');
1480
+ const m = text.match(/([0-9]*\.?[0-9]+)\s*([km])?/);
1481
+ if (!m)
1482
+ return undefined;
1483
+ const value = Number(m[1]);
1484
+ if (Number.isNaN(value))
1485
+ return undefined;
1486
+ if (m[2] === 'k')
1487
+ return Math.round(value * 1000);
1488
+ if (m[2] === 'm')
1489
+ return Math.round(value * 1_000_000);
1490
+ return Math.round(value);
1491
+ }
1492
+ // Enriched marketplace metadata endpoint
1493
+ // Fetches npm download stats, GitHub stars, sponsors, and README
1494
+ app.get('/api/marketplace/enriched/:name', async (req) => {
1495
+ const { name } = req.params;
1496
+ try {
1497
+ // Start with basic package info from npm registry
1498
+ const npmRes = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}`, {
1499
+ signal: AbortSignal.timeout(5000),
1500
+ headers: { Accept: 'application/json' },
1501
+ });
1502
+ if (!npmRes.ok)
1503
+ return { name };
1504
+ const npmData = (await npmRes.json());
1505
+ const latestVersion = npmData['dist-tags']?.latest ?? npmData.version;
1506
+ const packData = npmData.versions?.[latestVersion] ?? {};
1507
+ // Fetch npm download stats
1508
+ let weeklyDownloads;
1509
+ try {
1510
+ const statsRes = await fetch(`https://api.npmjs.org/downloads/point/last-week/${encodeURIComponent(name)}`, {
1511
+ signal: AbortSignal.timeout(3000),
1512
+ headers: { Accept: 'application/json' },
1513
+ });
1514
+ if (statsRes.ok) {
1515
+ const stats = (await statsRes.json());
1516
+ weeklyDownloads = stats.downloads;
1517
+ }
1518
+ }
1519
+ catch {
1520
+ /* stats not available */
1521
+ }
1522
+ // Extract GitHub repo and fetch stars + sponsors
1523
+ const githubRepo = extractGithubRepo(packData);
1524
+ let githubStars;
1525
+ let githubSponsorsUrl;
1526
+ if (githubRepo) {
1527
+ const owner = githubRepo.split('/')[0];
1528
+ if (owner) {
1529
+ // Keep sponsor link available even when GitHub API rate limits.
1530
+ githubSponsorsUrl = `https://github.com/sponsors/${owner}`;
1531
+ }
1532
+ try {
1533
+ const ghRes = await fetch(`https://api.github.com/repos/${githubRepo}`, {
1534
+ signal: AbortSignal.timeout(5000),
1535
+ headers: { Accept: 'application/json', 'User-Agent': 'OpenBridge' },
1536
+ });
1537
+ if (ghRes.ok) {
1538
+ const ghData = (await ghRes.json());
1539
+ githubStars = ghData.stargazers_count;
1540
+ }
1541
+ }
1542
+ catch {
1543
+ /* GitHub API not available */
1544
+ }
1545
+ // Fallback: scrape stars from repo page when API is unavailable/rate-limited.
1546
+ if (githubStars == null) {
1547
+ try {
1548
+ const htmlRes = await fetch(`https://github.com/${githubRepo}`, {
1549
+ signal: AbortSignal.timeout(5000),
1550
+ headers: { 'User-Agent': 'OpenBridge' },
1551
+ });
1552
+ if (htmlRes.ok) {
1553
+ const html = await htmlRes.text();
1554
+ const starBlock = html.match(new RegExp(`href="/${githubRepo}/stargazers"[^>]*>\\s*([\\s\\S]*?)<\\/a>`, 'i'))?.[1] ?? '';
1555
+ const starText = starBlock
1556
+ .replace(/<[^>]*>/g, ' ')
1557
+ .replace(/\s+/g, ' ')
1558
+ .trim();
1559
+ githubStars = parseCompactGithubCount(starText);
1560
+ }
1561
+ }
1562
+ catch {
1563
+ /* HTML fallback unavailable */
1564
+ }
1565
+ }
1566
+ }
1567
+ // Try to fetch README
1568
+ let readme;
1569
+ let badges;
1570
+ if (githubRepo) {
1571
+ try {
1572
+ const readmeRes = await fetch(`https://raw.githubusercontent.com/${githubRepo}/HEAD/README.md`, {
1573
+ signal: AbortSignal.timeout(3000),
1574
+ });
1575
+ if (readmeRes.ok) {
1576
+ const content = await readmeRes.text();
1577
+ readme = content;
1578
+ // Extract badge markdown links: ![...](...)
1579
+ const badgeMatches = content.match(/!\[.*?\]\(.*?\)/g) ?? [];
1580
+ badges = badgeMatches.slice(0, 5); // Limit to first 5 badges
1581
+ }
1582
+ }
1583
+ catch {
1584
+ /* README not available */
1585
+ }
1586
+ }
1587
+ // Extract documentation URL from package.json
1588
+ let documentationUrl;
1589
+ if (packData.homepage)
1590
+ documentationUrl = packData.homepage;
1591
+ else if (packData.repository?.url) {
1592
+ const match = packData.repository.url.match(/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/);
1593
+ if (match)
1594
+ documentationUrl = `https://github.com/${match[1]}/${match[2]}`;
1595
+ }
1596
+ const enrichedPayload = {
1597
+ name,
1598
+ version: latestVersion,
1599
+ description: packData.description ?? npmData.description,
1600
+ author: packData.author,
1601
+ links: packData.links ?? {
1602
+ npm: `https://www.npmjs.com/package/${name}`,
1603
+ repository: githubRepo ? `https://github.com/${githubRepo}` : undefined,
1604
+ homepage: packData.homepage,
1605
+ },
1606
+ date: npmData.time?.[latestVersion] ?? new Date().toISOString(),
1607
+ weeklyDownloads,
1608
+ githubStars,
1609
+ githubSponsorsUrl,
1610
+ documentationUrl,
1611
+ badges,
1612
+ readme,
1613
+ };
1614
+ // Persist metadata so plugins page can reuse it across route changes/reloads.
1615
+ try {
1616
+ const cache = loadMetadataCache();
1617
+ cache[name] = enrichedPayload;
1618
+ saveMetadataCache(cache);
1619
+ }
1620
+ catch {
1621
+ /* ignore cache save errors */
1622
+ }
1623
+ return enrichedPayload;
1624
+ }
1625
+ catch (err) {
1626
+ log.debug(`Failed to enrich metadata for ${name}: ${err}`);
1627
+ return { name };
1628
+ }
1629
+ });
1630
+ // ─── Interactive shell WebSocket (PTY) ───────────────────────────────────
1631
+ // node-pty is an optional dependency: it only powers the interactive shell pane.
1632
+ // Everything else (HAP bridge, plugins, API, logs) works without it.
1633
+ try {
1634
+ const nodePtyDir = resolve(dirname(_req.resolve('node-pty')), '..');
1635
+ // Ensure node-pty's spawn-helper has execute permission (pnpm doesn't preserve +x on prebuilds)
1636
+ const arch = `${process.platform}-${process.arch}`;
1637
+ const spawnHelper = join(nodePtyDir, 'prebuilds', arch, 'spawn-helper');
1638
+ if (existsSync(spawnHelper))
1639
+ chmodSync(spawnHelper, 0o755);
1640
+ shellAvailable = true;
1641
+ }
1642
+ catch {
1643
+ log.info('node-pty is not installed: the interactive shell pane is disabled. Everything else runs normally.');
1644
+ }
1645
+ app.get('/ws/shell', { websocket: true }, (connection) => {
1646
+ const ws = connection.socket;
1647
+ let pty = null;
1648
+ if (!shellAvailable) {
1649
+ ws.send(`\r\n\x1b[33mInteractive shell unavailable: the optional 'node-pty' dependency is not installed.\x1b[0m\r\n` +
1650
+ `\x1b[2mInstall it to enable this pane, e.g. 'npm i -g node-pty'. All other OpenBridge features are unaffected.\x1b[0m\r\n`);
1651
+ ws.close();
1652
+ return;
1653
+ }
1654
+ try {
1655
+ const nodePty = _req('node-pty');
1656
+ const shell = process.env.SHELL || (process.platform === 'win32' ? 'cmd.exe' : '/bin/sh');
1657
+ pty = nodePty.spawn(shell, [], {
1658
+ name: 'xterm-256color',
1659
+ cols: 80,
1660
+ rows: 24,
1661
+ cwd: process.env.HOME ?? process.cwd(),
1662
+ env: { ...process.env },
1663
+ });
1664
+ pty.onData((data) => {
1665
+ if (ws.readyState === ws.OPEN)
1666
+ ws.send(data);
1667
+ });
1668
+ pty.onExit(() => {
1669
+ if (ws.readyState === ws.OPEN)
1670
+ ws.close();
1671
+ });
1672
+ }
1673
+ catch (err) {
1674
+ ws.send(`\r\n\x1b[31mFailed to start shell: ${err}\x1b[0m\r\n`);
1675
+ ws.close();
1676
+ return;
1677
+ }
1678
+ ws.on('message', (raw) => {
1679
+ try {
1680
+ const msg = JSON.parse(raw.toString());
1681
+ if (msg.type === 'input')
1682
+ pty?.write(msg.data);
1683
+ if (msg.type === 'resize')
1684
+ pty?.resize(Number(msg.cols), Number(msg.rows));
1685
+ }
1686
+ catch {
1687
+ // plain string input fallback
1688
+ pty?.write(raw.toString());
1689
+ }
1690
+ });
1691
+ ws.on('close', () => {
1692
+ try {
1693
+ pty?.kill();
1694
+ }
1695
+ catch {
1696
+ /* ignore */
1697
+ }
1698
+ });
1699
+ });
1700
+ // Terminal WebSocket: streams ANSI-formatted log lines for xterm.js
1701
+ app.get('/ws/terminal', { websocket: true }, (connection) => {
1702
+ const ws = connection.socket;
1703
+ function ansiLine(entry) {
1704
+ const ANSI = {
1705
+ debug: '\x1b[90m',
1706
+ info: '\x1b[36m',
1707
+ warn: '\x1b[33m',
1708
+ error: '\x1b[31m',
1709
+ };
1710
+ const reset = '\x1b[0m';
1711
+ const dim = '\x1b[2m';
1712
+ const time = new Date(entry.timestamp).toLocaleTimeString();
1713
+ const level = entry.level.toUpperCase().padEnd(5);
1714
+ const plugin = entry.plugin !== 'system' ? `\x1b[35m[${entry.plugin}]${reset} ` : '';
1715
+ return `${dim}${time}${reset} ${ANSI[entry.level] ?? ''}${level}${reset} ${plugin}${entry.message}\r\n`;
1716
+ }
1717
+ // Replay recent history
1718
+ const history = Logger.getEntries(undefined, 500);
1719
+ for (const entry of history) {
1720
+ if (ws.readyState === ws.OPEN)
1721
+ ws.send(ansiLine(entry));
1722
+ }
1723
+ const unsubscribe = Logger.subscribe((entry) => {
1724
+ if (ws.readyState === ws.OPEN)
1725
+ ws.send(ansiLine(entry));
1726
+ });
1727
+ ws.on('close', unsubscribe);
1728
+ });
1729
+ // ─── Serve built UI ───────────────────────────────────────────────────────
1730
+ if (uiAvailable) {
1731
+ await app.register(fastifyStatic, { root: uiDist, wildcard: false });
1732
+ app.setNotFoundHandler(async (_req, reply) => reply.sendFile('index.html'));
1733
+ log.info(`Serving UI from ${uiDist}`);
1734
+ }
1735
+ else {
1736
+ app.get('/', async () => ({
1737
+ name: 'OpenBridge Daemon',
1738
+ version: OPENBRIDGE_VERSION,
1739
+ ui: 'not built: run: cd apps/ui && pnpm build',
1740
+ }));
1741
+ }
1742
+ return app;
1743
+ }
1744
+ //# sourceMappingURL=server.js.map