@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/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
|