@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/daemon.js ADDED
@@ -0,0 +1,636 @@
1
+ import { resolve, join } from 'path';
2
+ import { createRequire } from 'module';
3
+ import { fileURLToPath } from 'url';
4
+ import { dirname } from 'path';
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
6
+ import os from 'os';
7
+ import { PluginRegistry, PluginLifecycle, loadPluginsFromDirectory, loadPlugin } from '@nubisco/openbridge-core';
8
+ import { Logger } from '@nubisco/openbridge-logger';
9
+ import { loadConfig, defaultConfigPath } from '@nubisco/openbridge-config';
10
+ import { createServer } from './server.js';
11
+ import { DeviceSeries, DEFAULT_TIERS } from './timeseries.js';
12
+ import { HomeKitVisibility } from './homekit-visibility.js';
13
+ import { HomeKitServiceTypes } from './homekit-service-type.js';
14
+ import { HomebridgeAPI, loadHomebridgePlugin, serializeAccessory } from '@nubisco/openbridge-compatibility-homebridge';
15
+ const log = Logger.create('system');
16
+ const __dirname = dirname(fileURLToPath(import.meta.url));
17
+ const req = createRequire(import.meta.url);
18
+ // Canonical storage root: every file OpenBridge owns lives here
19
+ export const OPENBRIDGE_HOME = resolve(os.homedir(), '.openbridge');
20
+ export const OB_PLUGINS_DIR = join(OPENBRIDGE_HOME, 'plugins', 'openbridge');
21
+ export const HB_PLUGINS_DIR = join(OPENBRIDGE_HOME, 'plugins', 'homebridge');
22
+ export const METRICS_DIR = join(OPENBRIDGE_HOME, 'metrics');
23
+ /** Metric sampling cadence, matched to the finest storage tier. */
24
+ const SAMPLE_INTERVAL_MS = DEFAULT_TIERS[0].interval * 1000;
25
+ export class Daemon {
26
+ registry = new PluginRegistry();
27
+ lifecycle = new PluginLifecycle(this.registry);
28
+ loadedPlugins = [];
29
+ /** npm package names of HB plugins already running via config.platforms: skip in discovery */
30
+ knownHbPackageNames = new Set();
31
+ controls = new Map();
32
+ restrictedControls = new Set();
33
+ /** Main HAP bridge and hap-nodejs module, shared with native plugins */
34
+ hapBridgeRef = null;
35
+ /** Time-series store per device, for devices that declare metrics */
36
+ metricSeries = new Map();
37
+ /** Per-service HomeKit type overrides, applied at the bridge */
38
+ homekitServiceTypes = new HomeKitServiceTypes(join(OPENBRIDGE_HOME, 'homekit-service-types.json'));
39
+ /** Per-accessory HomeKit visibility, enforced at the bridge */
40
+ homekitVisibility = new HomeKitVisibility(join(OPENBRIDGE_HOME, 'homekit-hidden.json'), null, this.homekitServiceTypes);
41
+ /**
42
+ * Find the HAP accessory a native plugin published for one of its devices.
43
+ *
44
+ * Native plugins add accessories straight to the bridge, so they never enter
45
+ * HomebridgeAPI's map and /api/accessories cannot see them. Every accessory
46
+ * does pass through the visibility proxy though, so it holds the full set.
47
+ *
48
+ * The link is by convention: plugins seed the accessory UUID from the device
49
+ * id (`hap.uuid.generate(deviceId)`), which is what the SDK's own accessory
50
+ * helpers do. A plugin that seeds it differently simply resolves to null and
51
+ * the UI omits the HomeKit controls, rather than showing the wrong ones.
52
+ */
53
+ resolveNativeAccessory(deviceId) {
54
+ const accessory = this.findNativeAccessory(deviceId);
55
+ return accessory ? serializeAccessory(accessory) : null;
56
+ }
57
+ /** As above, but only the UUID: cheap enough to call for every device. */
58
+ nativeAccessoryUuid(deviceId) {
59
+ return this.findNativeAccessory(deviceId)?.UUID ?? null;
60
+ }
61
+ findNativeAccessory(deviceId) {
62
+ const hap = this.hapBridgeRef?.hap;
63
+ if (!hap?.uuid?.generate)
64
+ return null;
65
+ return this.homekitVisibility.find(hap.uuid.generate(deviceId));
66
+ }
67
+ async start(options = {}) {
68
+ const configPath = options.configPath ?? defaultConfigPath();
69
+ log.info('Starting OpenBridge daemon...');
70
+ log.info(`Config: ${configPath}`);
71
+ // Prevent unhandled errors from silently killing plugin event loops
72
+ process.on('unhandledRejection', (reason) => {
73
+ log.error(`Unhandled promise rejection (plugin may need restart): ${reason}`);
74
+ });
75
+ process.on('uncaughtException', (err) => {
76
+ log.error(`Uncaught exception: ${err.message}\n${err.stack}`);
77
+ // Only exit on truly fatal errors (e.g. out of memory)
78
+ if (err.message?.includes('out of memory') || err.message?.includes('ENOMEM')) {
79
+ process.exit(1);
80
+ }
81
+ });
82
+ const config = await loadConfig(configPath);
83
+ Logger.setLevel(config.bridge.logLevel);
84
+ const port = options.port ?? config.bridge.port;
85
+ // Ensure canonical storage dirs exist
86
+ mkdirSync(OB_PLUGINS_DIR, { recursive: true });
87
+ mkdirSync(HB_PLUGINS_DIR, { recursive: true });
88
+ log.info(`Bridge name: ${config.bridge.name}`);
89
+ log.info(`HTTP API port: ${port}`);
90
+ log.info(`OpenBridge home: ${OPENBRIDGE_HOME}`);
91
+ // ── HAP Bridge (HomeKit) ──────────────────────────────────────────────────
92
+ // The default bridge is ALWAYS created: it advertises all accessories
93
+ // from both native plugins and Homebridge-compatible platforms.
94
+ let hapBridge = null;
95
+ let homebridgeAPI = null;
96
+ let hapInfo = null;
97
+ // Load disabled plugins list early: applies to both Homebridge platforms and native plugins
98
+ let disabledPlugins = [];
99
+ try {
100
+ const rawCfg = JSON.parse(readFileSync(configPath, 'utf8'));
101
+ disabledPlugins = (rawCfg.disabledPlugins ?? []);
102
+ }
103
+ catch {
104
+ /* config file may not exist yet */
105
+ }
106
+ try {
107
+ // Load hap-nodejs: look in daemon or workspace node_modules
108
+ const hapCandidates = [
109
+ resolve(__dirname, '../node_modules/hap-nodejs'), // apps/daemon/node_modules (from dist/)
110
+ resolve(__dirname, '../../node_modules/hap-nodejs'), // apps/node_modules
111
+ resolve(__dirname, '../../../node_modules/hap-nodejs'), // workspace root node_modules
112
+ resolve(process.cwd(), 'node_modules/hap-nodejs'),
113
+ ];
114
+ let hapNodeJs = null;
115
+ for (const candidate of hapCandidates) {
116
+ try {
117
+ hapNodeJs = req(candidate);
118
+ log.info(`Loaded hap-nodejs from ${candidate}`);
119
+ break;
120
+ }
121
+ catch {
122
+ /* try next */
123
+ }
124
+ }
125
+ if (!hapNodeJs) {
126
+ throw new Error('hap-nodejs not found. Run: pnpm add hap-nodejs --filter @nubisco/openbridge-daemon');
127
+ }
128
+ // Init HAP storage
129
+ const storagePath = resolve(process.env.HOME ?? '.', '.openbridge', 'hap-storage');
130
+ hapNodeJs.HAPStorage.setCustomStoragePath(storagePath);
131
+ // Create the bridge
132
+ hapBridge = new hapNodeJs.Bridge(config.bridge.name, hapNodeJs.uuid.generate(config.bridge.name));
133
+ hapBridge
134
+ .getService(hapNodeJs.Service.AccessoryInformation)
135
+ .setCharacteristic(hapNodeJs.Characteristic.Manufacturer, 'Nubisco')
136
+ .setCharacteristic(hapNodeJs.Characteristic.Model, 'OpenBridge')
137
+ .setCharacteristic(hapNodeJs.Characteristic.SoftwareRevision, '0.1.0');
138
+ // Wrap the bridge so per-accessory HomeKit visibility is enforced in one
139
+ // place. Both plugin kinds reach HomeKit through addBridgedAccessory, so
140
+ // wrapping here covers native and Homebridge-compat plugins alike, and
141
+ // works for plugins that offer no exposeToHomeKit setting of their own.
142
+ this.homekitServiceTypes.setHap(hapNodeJs);
143
+ const visibleBridge = this.homekitVisibility.wrapBridge(hapBridge);
144
+ // Store reference so native plugins can add accessories to the main bridge
145
+ this.hapBridgeRef = { bridge: visibleBridge, hap: hapNodeJs };
146
+ // Create the HomebridgeAPI shim
147
+ homebridgeAPI = new HomebridgeAPI(hapNodeJs, visibleBridge);
148
+ // Restore cached accessories so they survive container restarts.
149
+ // Must happen before launchPlatforms() so configureAccessory() can re-adopt them.
150
+ homebridgeAPI.loadCachedAccessories();
151
+ // ── Load Homebridge-compatible plugins ────────────────────────────────
152
+ // Legacy: config.platforms[] with explicit file paths (backward compat)
153
+ if (config.platforms && config.platforms.length > 0) {
154
+ const enabledPlatforms = config.platforms.filter((p) => !disabledPlugins.includes(p.platform));
155
+ const skippedPlatforms = config.platforms.filter((p) => disabledPlugins.includes(p.platform));
156
+ if (enabledPlatforms.length > 0) {
157
+ log.info(`Loading ${enabledPlatforms.length} legacy platform(s) from config.platforms...`);
158
+ }
159
+ for (const p of skippedPlatforms) {
160
+ log.info(`Skipping disabled platform: ${p.platform}`);
161
+ }
162
+ for (const platformConfig of enabledPlatforms) {
163
+ if (!platformConfig.plugin) {
164
+ log.warn(`Platform "${platformConfig.platform}" has no "plugin" path, skipping`);
165
+ continue;
166
+ }
167
+ const pluginPath = resolve(platformConfig.plugin);
168
+ log.info(`Loading Homebridge plugin: ${pluginPath}`);
169
+ try {
170
+ const pkgPath = pluginPath
171
+ .replace(/\/dist\/.*$/, '/package.json')
172
+ .replace(/\/index\.js$/, '/../package.json');
173
+ try {
174
+ const pkg = req(pkgPath);
175
+ platformConfig.version = pkg.version;
176
+ if (pkg.name)
177
+ this.knownHbPackageNames.add(pkg.name);
178
+ }
179
+ catch {
180
+ /* version stays unknown */
181
+ }
182
+ const pluginFn = loadHomebridgePlugin(pluginPath);
183
+ pluginFn(homebridgeAPI);
184
+ log.info(`Registered platform: ${platformConfig.platform}`);
185
+ }
186
+ catch (err) {
187
+ log.error(`Failed to load plugin ${platformConfig.plugin}: ${err}`);
188
+ }
189
+ }
190
+ const platformLogger = Logger.create('hap');
191
+ await homebridgeAPI.launchPlatforms(enabledPlatforms, platformLogger, this.registry);
192
+ }
193
+ // Publish the HAP bridge
194
+ const hapPort = config.bridge.hapPort ?? 51829;
195
+ const pincode = config.bridge.pincode ?? '031-45-154';
196
+ const username = config.bridge.username ?? generateUsername(config.bridge.name);
197
+ hapBridge.publish({
198
+ username,
199
+ pincode,
200
+ port: hapPort,
201
+ category: hapNodeJs.Categories.BRIDGE,
202
+ });
203
+ hapInfo = { setupURI: hapBridge.setupURI(), pincode };
204
+ log.info(`HAP bridge published: PIN: ${pincode}`);
205
+ printPairingInfo(hapInfo.setupURI, pincode);
206
+ }
207
+ catch (err) {
208
+ log.error(`HAP bridge setup failed: ${err}`);
209
+ }
210
+ // ── OpenBridge native plugins ─────────────────────────────────────────────
211
+ log.info(`Native plugins dir: ${OB_PLUGINS_DIR}`);
212
+ const plugins = await loadPluginsFromDirectory(OB_PLUGINS_DIR);
213
+ log.info(`Loaded ${plugins.length} native plugin(s)`);
214
+ for (const plugin of plugins) {
215
+ this.registry.register(plugin);
216
+ }
217
+ this.loadedPlugins = plugins;
218
+ // Mark disabled native plugins
219
+ for (const disabledId of disabledPlugins) {
220
+ const entry = this.registry.get(disabledId);
221
+ if (entry) {
222
+ entry.instance.disabled = true;
223
+ this.registry.updateStatus(disabledId, 'stopped');
224
+ log.debug(`Plugin marked as disabled: ${disabledId}`);
225
+ }
226
+ }
227
+ // Only start plugins that are not disabled
228
+ const enabledPlugins = plugins.filter((p) => !disabledPlugins.includes(p.manifest.name));
229
+ await this.lifecycle.startAll(enabledPlugins, (plugin) => this.makeContext(plugin, config));
230
+ await this.discoverMarketplacePlugins(config, disabledPlugins, homebridgeAPI);
231
+ // Every platform that is going to register has now done so, so anything
232
+ // left in the accessory cache belonging to an unknown platform came from a
233
+ // plugin that has since been uninstalled *or been disabled* (a disabled one
234
+ // is deliberately left unregistered above). Those accessories would
235
+ // otherwise stay on the bridge forever with nothing behind them.
236
+ if (homebridgeAPI) {
237
+ const orphans = homebridgeAPI.pruneOrphanedAccessories();
238
+ if (orphans.length > 0) {
239
+ log.info(`Removed ${orphans.length} orphaned accessory(ies) from inactive plugins: ${orphans.join(', ')}`);
240
+ }
241
+ }
242
+ // ── HTTP server ───────────────────────────────────────────────────────────
243
+ const localPluginSources = config.localPluginSources ?? [];
244
+ const server = await createServer(this.registry, homebridgeAPI, hapInfo, localPluginSources, this.knownHbPackageNames, this.controls, this.restrictedControls, this.homekitVisibility, this.homekitServiceTypes, (deviceId) => this.resolveNativeAccessory(deviceId), (deviceId) => this.nativeAccessoryUuid(deviceId));
245
+ await server.listen({ port, host: '0.0.0.0' });
246
+ // ── Energy history sampling ───────────────────────────────────────────────
247
+ setInterval(() => {
248
+ this.sampleEnergyHistory();
249
+ }, 5 * 60 * 1000);
250
+ // Also sample immediately on startup (after a short delay for devices to connect)
251
+ setTimeout(() => this.sampleEnergyHistory(), 30_000);
252
+ // ── Metric history sampling ───────────────────────────────────────────────
253
+ // Devices that declare metrics are recorded at the finest tier's cadence.
254
+ // The store samples on its own timer rather than having plugins push every
255
+ // reading, so a fast-polling plugin cannot flood the disk and storage stays
256
+ // independent of plugin behaviour.
257
+ setInterval(() => this.sampleMetrics(), SAMPLE_INTERVAL_MS);
258
+ setTimeout(() => this.sampleMetrics(), 15_000);
259
+ // Rolling up is cheap but pointless to run often: the finest rollup window
260
+ // is a minute, so once a minute is enough to keep every tier current.
261
+ setInterval(() => this.rollupMetrics(), 60_000);
262
+ // ── Platform health watchdog ─────────────────────────────────────────────
263
+ // Monitors Homebridge-compatible platforms and restarts them if they enter
264
+ // error status. Checks every 60 seconds, uses exponential backoff per platform.
265
+ if (homebridgeAPI) {
266
+ const watchdogInterval = 60_000;
267
+ const pendingRestarts = new Map();
268
+ setInterval(() => {
269
+ for (const instance of this.registry.getAll()) {
270
+ if (instance.source !== 'homebridge')
271
+ continue;
272
+ if (instance.disabled)
273
+ continue;
274
+ const name = instance.platformName ?? instance.id;
275
+ if (instance.status === 'error' && !pendingRestarts.has(name)) {
276
+ const backoff = homebridgeAPI.getRestartBackoff(name);
277
+ log.warn(`Platform "${name}" is in error state, scheduling restart in ${Math.round(backoff / 1000)}s`);
278
+ const timer = setTimeout(async () => {
279
+ pendingRestarts.delete(name);
280
+ const ok = await homebridgeAPI.restartPlatform(name);
281
+ if (!ok) {
282
+ log.error(`Platform "${name}" restart failed, will retry with longer backoff`);
283
+ }
284
+ }, backoff);
285
+ pendingRestarts.set(name, timer);
286
+ }
287
+ }
288
+ }, watchdogInterval);
289
+ }
290
+ log.info(`OpenBridge running at http://localhost:${port}`);
291
+ // ── Graceful shutdown ─────────────────────────────────────────────────────
292
+ const shutdown = async () => {
293
+ log.info('Shutting down...');
294
+ hapBridge?.unpublish();
295
+ await this.lifecycle.stopAll(this.loadedPlugins, (plugin) => this.makeContext(plugin, config));
296
+ await server.close();
297
+ process.exit(0);
298
+ };
299
+ process.on('SIGINT', shutdown);
300
+ process.on('SIGTERM', shutdown);
301
+ }
302
+ async discoverMarketplacePlugins(config, disabledPlugins, homebridgeAPI) {
303
+ const pluginsRoot = HB_PLUGINS_DIR;
304
+ const manifestPath = join(pluginsRoot, 'package.json');
305
+ if (!existsSync(manifestPath))
306
+ return;
307
+ let topLevel;
308
+ try {
309
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
310
+ topLevel = manifest.dependencies ?? {};
311
+ }
312
+ catch {
313
+ return;
314
+ }
315
+ for (const pkgName of Object.keys(topLevel)) {
316
+ if (this.registry.get(pkgName)) {
317
+ log.debug(`Marketplace: skipping ${pkgName} (already registered)`);
318
+ continue;
319
+ }
320
+ if (this.knownHbPackageNames.has(pkgName)) {
321
+ log.debug(`Marketplace: skipping ${pkgName} (already loaded as HB platform)`);
322
+ continue;
323
+ }
324
+ const pkgJsonPath = join(pluginsRoot, 'node_modules', pkgName, 'package.json');
325
+ if (!existsSync(pkgJsonPath)) {
326
+ log.debug(`Marketplace: skipping ${pkgName} (package.json not found)`);
327
+ continue;
328
+ }
329
+ try {
330
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
331
+ const name = pkg.name ?? pkgName;
332
+ const isNative = (Array.isArray(pkg.keywords) && pkg.keywords.includes('openbridge-plugin')) || pkg.openbridge != null;
333
+ const isHb = name.startsWith('homebridge-') || (Array.isArray(pkg.keywords) && pkg.keywords.includes('homebridge-plugin'));
334
+ // Native OpenBridge plugins: load via the plugin loader and start them
335
+ if (isNative) {
336
+ // Ensure the plugin can resolve peer dependencies (like hap-nodejs) from the daemon's node_modules
337
+ const daemonModules = resolve(__dirname, '../node_modules');
338
+ if (!process.env.NODE_PATH?.includes(daemonModules)) {
339
+ process.env.NODE_PATH = process.env.NODE_PATH ? `${process.env.NODE_PATH}:${daemonModules}` : daemonModules;
340
+ req('module').Module._initPaths();
341
+ }
342
+ const pluginDir = join(pluginsRoot, 'node_modules', pkgName);
343
+ const candidates = [join(pluginDir, 'dist', 'index.js'), join(pluginDir, 'index.js')];
344
+ let loaded = false;
345
+ for (const candidate of candidates) {
346
+ if (existsSync(candidate)) {
347
+ try {
348
+ const plugin = await loadPlugin(candidate);
349
+ this.registry.register(plugin);
350
+ this.loadedPlugins.push(plugin);
351
+ // Check if disabled
352
+ if (!disabledPlugins.includes(name)) {
353
+ await this.lifecycle.startAll([plugin], (p) => this.makeContext(p, config));
354
+ }
355
+ else {
356
+ const entry = this.registry.get(name);
357
+ if (entry)
358
+ entry.instance.disabled = true;
359
+ this.registry.updateStatus(name, 'stopped');
360
+ }
361
+ log.info(`Loaded native plugin from marketplace: ${name} v${pkg.version ?? '?'}`);
362
+ loaded = true;
363
+ break;
364
+ }
365
+ catch (err) {
366
+ log.error(`Failed to load native plugin ${name}: ${err}`);
367
+ }
368
+ }
369
+ }
370
+ if (loaded)
371
+ continue;
372
+ }
373
+ // Homebridge-compatible plugins: auto-load if configured in config.plugins
374
+ // Set when the plugin turns out to be disabled under its platform name,
375
+ // which is only knowable after the module has registered itself.
376
+ let disabledByPlatformName = false;
377
+ if (isHb && homebridgeAPI) {
378
+ const pluginEntry = config.plugins.find((p) => p.name === name);
379
+ const pluginDir = join(pluginsRoot, 'node_modules', pkgName);
380
+ const mainFile = join(pluginDir, pkg.main ?? 'dist/index.js');
381
+ if (existsSync(mainFile) && !disabledPlugins.includes(name)) {
382
+ try {
383
+ this.knownHbPackageNames.add(name);
384
+ const regCountBefore = homebridgeAPI._platformRegistrations?.length ?? 0;
385
+ const pluginFn = loadHomebridgePlugin(mainFile);
386
+ pluginFn(homebridgeAPI);
387
+ // Get the platform name from the plugin's registerPlatform() call
388
+ const regs = homebridgeAPI._platformRegistrations ?? [];
389
+ const newReg = regs[regs.length - 1];
390
+ const platformName = newReg && regs.length > regCountBefore
391
+ ? newReg.platformName
392
+ : (pluginEntry?.config?.platform ?? name);
393
+ // Disable entries written before the identifier was canonicalised
394
+ // hold the platform name ("ShellyDS9") rather than the package
395
+ // name, so honour both. Checked here rather than above because
396
+ // the platform name only exists once the module has registered.
397
+ if (disabledPlugins.includes(platformName)) {
398
+ log.info(`Skipping disabled Homebridge plugin: ${name} (platform ${platformName})`);
399
+ disabledByPlatformName = true;
400
+ // Undo the registration the module just made. Learning the
401
+ // platform name requires loading the plugin, but leaving it
402
+ // registered would make pruneOrphanedAccessories() treat the
403
+ // platform as installed and spare its cached accessories,
404
+ // which then sit on the bridge as inert "Default-Manufacturer"
405
+ // entries with no plugin behind them to drive or control them.
406
+ const registrations = homebridgeAPI._platformRegistrations;
407
+ if (Array.isArray(registrations))
408
+ registrations.length = regCountBefore;
409
+ // Falls through to the pseudo-plugin registration below so the
410
+ // plugin still appears in the UI and can be re-enabled.
411
+ }
412
+ else {
413
+ const pluginConfig = pluginEntry?.config ?? {};
414
+ const platformConfig = {
415
+ platform: platformName,
416
+ plugin: mainFile,
417
+ ...pluginConfig,
418
+ };
419
+ const platformLogger = Logger.create('hap');
420
+ await homebridgeAPI.launchPlatforms([platformConfig], platformLogger, this.registry);
421
+ // launchPlatforms registers the instance under the platform name,
422
+ // which is all it knows. Record the package name too: this
423
+ // plugin's config lives in config.plugins[] keyed by it, and
424
+ // without this the UI has no way back to that entry.
425
+ const launched = this.registry.get(platformName);
426
+ if (launched)
427
+ launched.instance.packageName = name;
428
+ log.info(`Loaded Homebridge plugin from marketplace: ${name} v${pkg.version ?? '?'}`);
429
+ continue;
430
+ }
431
+ }
432
+ catch (err) {
433
+ log.error(`Failed to load Homebridge plugin ${name}: ${err}`);
434
+ }
435
+ }
436
+ }
437
+ // Unconfigured or failed plugins: register as pseudo-plugin (stopped)
438
+ const pseudoPlugin = {
439
+ manifest: {
440
+ name,
441
+ version: pkg.version ?? '?.?.?',
442
+ description: pkg.description ?? '',
443
+ author: typeof pkg.author === 'string' ? pkg.author : (pkg.author?.name ?? ''),
444
+ },
445
+ };
446
+ const instance = this.registry.register(pseudoPlugin);
447
+ if (isHb)
448
+ instance.source = 'homebridge';
449
+ instance.packageName = name;
450
+ // Without this the UI's Disabled switch reads as off for a plugin the
451
+ // daemon deliberately did not start.
452
+ if (disabledPlugins.includes(name) || disabledByPlatformName)
453
+ instance.disabled = true;
454
+ this.registry.updateStatus(name, 'stopped');
455
+ log.info(instance.disabled
456
+ ? `Discovered marketplace plugin: ${name} v${pkg.version ?? '?'} (disabled)`
457
+ : `Discovered marketplace plugin: ${name} v${pkg.version ?? '?'} (not yet configured)`);
458
+ }
459
+ catch (err) {
460
+ log.warn(`Marketplace discovery: skipped ${pkgName}: ${err}`);
461
+ }
462
+ }
463
+ }
464
+ makeContext(plugin, config) {
465
+ const pluginConfig = config.plugins.find((p) => p.name === plugin.manifest.name)?.config ?? {};
466
+ const registry = this.registry;
467
+ const controls = this.controls;
468
+ const restrictedControls = this.restrictedControls;
469
+ return {
470
+ config: pluginConfig,
471
+ log: Logger.create(plugin.manifest.name),
472
+ reportTelemetry(deviceId, data) {
473
+ const entry = registry.get(plugin.manifest.name);
474
+ if (!entry)
475
+ return;
476
+ if (!entry.instance.telemetry)
477
+ entry.instance.telemetry = {};
478
+ entry.instance.telemetry[deviceId] = { ...data, _updatedAt: new Date().toISOString() };
479
+ },
480
+ registerDevice(device) {
481
+ const entry = registry.get(plugin.manifest.name);
482
+ if (!entry)
483
+ return;
484
+ if (!entry.instance.devices)
485
+ entry.instance.devices = {};
486
+ entry.instance.devices[device.id] = { ...device, pluginId: plugin.manifest.name };
487
+ },
488
+ registerControl(deviceId, controlId, handler) {
489
+ controls.set(`${deviceId}::${controlId}`, handler);
490
+ },
491
+ registerHapBridge(info) {
492
+ const entry = registry.get(plugin.manifest.name);
493
+ if (!entry) {
494
+ log.warn(`registerHapBridge: plugin ${plugin.manifest.name} not found in registry`);
495
+ return;
496
+ }
497
+ entry.instance.hapBridge = info;
498
+ log.info(`Plugin ${plugin.manifest.name} registered HAP bridge on port ${info.port} (PIN: ${info.pincode})`);
499
+ },
500
+ restrictControl(deviceId, controlId) {
501
+ restrictedControls.add(`${deviceId}::${controlId}`);
502
+ log.info(`Control restricted: ${deviceId}::${controlId}`);
503
+ },
504
+ getHapBridge: () => this.hapBridgeRef,
505
+ };
506
+ }
507
+ /**
508
+ * Look up (and cache) the series for a device that declares metrics.
509
+ *
510
+ * Returns null when the device declares none, which is the signal that it has
511
+ * live telemetry but nothing worth storing.
512
+ */
513
+ seriesFor(device) {
514
+ if (!device.metrics || device.metrics.length === 0)
515
+ return null;
516
+ const cached = this.metricSeries.get(device.id);
517
+ if (cached)
518
+ return cached;
519
+ const series = new DeviceSeries(join(OPENBRIDGE_HOME, 'metrics'), device.id, device.metrics);
520
+ this.metricSeries.set(device.id, series);
521
+ return series;
522
+ }
523
+ /** Record one sample per metric-declaring device from its latest telemetry. */
524
+ sampleMetrics() {
525
+ const t = Math.floor(Date.now() / 1000);
526
+ for (const instance of this.registry.getAll()) {
527
+ if (!instance.devices)
528
+ continue;
529
+ for (const device of Object.values(instance.devices)) {
530
+ const series = this.seriesFor(device);
531
+ if (!series)
532
+ continue;
533
+ const telemetry = instance.telemetry?.[device.id];
534
+ if (!telemetry)
535
+ continue;
536
+ const values = {};
537
+ let any = false;
538
+ for (const metric of device.metrics ?? []) {
539
+ const raw = telemetry[metric.key];
540
+ if (raw === undefined || raw === null)
541
+ continue;
542
+ const value = Number(raw);
543
+ if (!Number.isFinite(value))
544
+ continue;
545
+ values[metric.key] = value;
546
+ any = true;
547
+ }
548
+ // A device that is offline reports nothing; storing a row of zeros
549
+ // would draw a phantom dip rather than a gap.
550
+ if (!any)
551
+ continue;
552
+ try {
553
+ series.append({ t, values });
554
+ }
555
+ catch (err) {
556
+ log.warn(`Failed to record metrics for ${device.id}: ${err}`);
557
+ }
558
+ }
559
+ }
560
+ }
561
+ /** Roll finished windows into coarser tiers and drop expired records. */
562
+ rollupMetrics() {
563
+ for (const series of this.metricSeries.values()) {
564
+ try {
565
+ series.rollup();
566
+ }
567
+ catch (err) {
568
+ log.warn(`Failed to roll up metrics for ${series.deviceId}: ${err}`);
569
+ }
570
+ }
571
+ }
572
+ sampleEnergyHistory() {
573
+ const historyDir = join(OPENBRIDGE_HOME, 'energy-history');
574
+ mkdirSync(historyDir, { recursive: true });
575
+ for (const instance of this.registry.getAll()) {
576
+ if (!instance.devices)
577
+ continue;
578
+ for (const device of Object.values(instance.devices)) {
579
+ if (device.widgetType !== 'energy_meter')
580
+ continue;
581
+ const energy = instance.telemetry?.[device.id]?.totalForwardEnergy;
582
+ if (energy === undefined || energy === null)
583
+ continue;
584
+ const filePath = join(historyDir, `${device.id}.json`);
585
+ let samples = [];
586
+ try {
587
+ samples = JSON.parse(readFileSync(filePath, 'utf8'));
588
+ }
589
+ catch {
590
+ /* start fresh */
591
+ }
592
+ const now = new Date().toISOString();
593
+ samples.push({ t: now, e: Number(energy) });
594
+ // Keep only 2 years of 5-minute samples ≈ 210k entries max
595
+ const cutoff = new Date(Date.now() - 2 * 365 * 24 * 60 * 60 * 1000).toISOString();
596
+ samples = samples.filter((s) => s.t >= cutoff);
597
+ writeFileSync(filePath, JSON.stringify(samples));
598
+ }
599
+ }
600
+ }
601
+ }
602
+ function generateUsername(name) {
603
+ let hash = 0;
604
+ for (let i = 0; i < name.length; i++) {
605
+ hash = (hash << 5) - hash + name.charCodeAt(i);
606
+ hash |= 0;
607
+ }
608
+ const h = Math.abs(hash).toString(16).padStart(10, '0');
609
+ return `${h[0]}${h[1]}:${h[2]}${h[3]}:${h[4]}${h[5]}:${h[6]}${h[7]}:${h[8]}${h[9]}:AB`;
610
+ }
611
+ function printPairingInfo(setupURI, pincode) {
612
+ try {
613
+ const qrcode = req('qrcode-terminal');
614
+ qrcode.generate(setupURI, { small: true }, (qr) => {
615
+ const border = '─'.repeat(40);
616
+ console.log(`\n\x1b[35m┌${border}┐\x1b[0m`);
617
+ console.log(`\x1b[35m│\x1b[0m \x1b[1mScan to pair with HomeKit\x1b[0m` + ' '.repeat(13) + `\x1b[35m│\x1b[0m`);
618
+ console.log(`\x1b[35m│\x1b[0m` + ' '.repeat(40) + `\x1b[35m│\x1b[0m`);
619
+ for (const line of qr.split('\n')) {
620
+ const pad = ' '.repeat(Math.max(0, 38 - line.length));
621
+ console.log(`\x1b[35m│\x1b[0m ${line}${pad}\x1b[35m│\x1b[0m`);
622
+ }
623
+ console.log(`\x1b[35m│\x1b[0m` + ' '.repeat(40) + `\x1b[35m│\x1b[0m`);
624
+ const pinPrefix = ' PIN: ';
625
+ const pinPad = ' '.repeat(Math.max(0, 40 - pinPrefix.length - pincode.length));
626
+ console.log(`\x1b[35m│\x1b[0m${pinPrefix}\x1b[1;33m${pincode}\x1b[0m${pinPad}\x1b[35m│\x1b[0m`);
627
+ console.log(`\x1b[35m└${border}┘\x1b[0m\n`);
628
+ });
629
+ }
630
+ catch {
631
+ // qrcode-terminal not available: fall back to text
632
+ console.log(`\n HomeKit PIN: \x1b[1;33m${pincode}\x1b[0m`);
633
+ console.log(` Setup URI: ${setupURI}\n`);
634
+ }
635
+ }
636
+ //# sourceMappingURL=daemon.js.map