@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.
- package/LICENSE +21 -0
- package/README.md +306 -0
- package/dist/__tests__/homekit-service-type.test.d.ts +2 -0
- package/dist/__tests__/homekit-service-type.test.d.ts.map +1 -0
- package/dist/__tests__/homekit-service-type.test.js +239 -0
- package/dist/__tests__/homekit-service-type.test.js.map +1 -0
- package/dist/__tests__/homekit-visibility.test.d.ts +2 -0
- package/dist/__tests__/homekit-visibility.test.d.ts.map +1 -0
- package/dist/__tests__/homekit-visibility.test.js +157 -0
- package/dist/__tests__/homekit-visibility.test.js.map +1 -0
- package/dist/__tests__/server.test.d.ts +2 -0
- package/dist/__tests__/server.test.d.ts.map +1 -0
- package/dist/__tests__/server.test.js +837 -0
- package/dist/__tests__/server.test.js.map +1 -0
- package/dist/__tests__/timeseries.test.d.ts +2 -0
- package/dist/__tests__/timeseries.test.d.ts.map +1 -0
- package/dist/__tests__/timeseries.test.js +216 -0
- package/dist/__tests__/timeseries.test.js.map +1 -0
- package/dist/auth.d.ts +23 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +171 -0
- package/dist/auth.js.map +1 -0
- package/dist/daemon.d.ts +57 -0
- package/dist/daemon.d.ts.map +1 -0
- package/dist/daemon.js +636 -0
- package/dist/daemon.js.map +1 -0
- package/dist/homekit-service-type.d.ts +82 -0
- package/dist/homekit-service-type.d.ts.map +1 -0
- package/dist/homekit-service-type.js +184 -0
- package/dist/homekit-service-type.js.map +1 -0
- package/dist/homekit-visibility.d.ts +79 -0
- package/dist/homekit-visibility.d.ts.map +1 -0
- package/dist/homekit-visibility.js +164 -0
- package/dist/homekit-visibility.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +62 -0
- package/dist/index.js.map +1 -0
- package/dist/metrics.d.ts +17 -0
- package/dist/metrics.d.ts.map +1 -0
- package/dist/metrics.js +52 -0
- package/dist/metrics.js.map +1 -0
- package/dist/platform-client.d.ts +42 -0
- package/dist/platform-client.d.ts.map +1 -0
- package/dist/platform-client.js +92 -0
- package/dist/platform-client.js.map +1 -0
- package/dist/server.d.ts +30 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +1744 -0
- package/dist/server.js.map +1 -0
- package/dist/timeseries.d.ts +134 -0
- package/dist/timeseries.d.ts.map +1 -0
- package/dist/timeseries.js +378 -0
- package/dist/timeseries.js.map +1 -0
- package/dist/version.d.ts +4 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +28 -0
- package/dist/version.js.map +1 -0
- package/package.json +79 -0
- package/scripts/entrypoint.sh +82 -0
- package/scripts/fix-node-pty.mjs +22 -0
- package/ui-dist/android-chrome-192x192.png +0 -0
- package/ui-dist/android-chrome-512x512.png +0 -0
- package/ui-dist/apple-touch-icon.png +0 -0
- package/ui-dist/assets/index-Q0sxZHIR.css +32 -0
- package/ui-dist/assets/index-ZZYyAzBo.js +106 -0
- package/ui-dist/favicon-16x16.png +0 -0
- package/ui-dist/favicon-32x32.png +0 -0
- package/ui-dist/favicon.ico +0 -0
- package/ui-dist/fonts/MesloLGLDZNerdFontMono-Bold.woff2 +0 -0
- package/ui-dist/fonts/MesloLGLDZNerdFontMono-BoldItalic.woff2 +0 -0
- package/ui-dist/fonts/MesloLGLDZNerdFontMono-Italic.woff2 +0 -0
- package/ui-dist/fonts/MesloLGLDZNerdFontMono-Regular.woff2 +0 -0
- package/ui-dist/index.html +18 -0
- package/ui-dist/logo.svg +30 -0
- package/ui-dist/nubisco-logo.svg +120 -0
- 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
|