From 6d9619b2279562cdc1a185d5d0e998a8684adad2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 Aug 2025 22:46:33 +0000 Subject: [PATCH 1/3] Initial plan From 37a2a12dc65abc6a17e0a489d4ea44a74da311f3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 Aug 2025 22:55:12 +0000 Subject: [PATCH 2/3] Implement plugin reload functionality without full Homebridge restart Co-authored-by: donavanbecker <9875439+donavanbecker@users.noreply.github.com> --- src/ipcService.ts | 2 ++ src/plugin.spec.ts | 35 ++++++++++++++++++++++++ src/plugin.ts | 56 +++++++++++++++++++++++++++++++++++++-- src/pluginManager.spec.ts | 17 ++++++++++++ src/pluginManager.ts | 35 ++++++++++++++++++++++++ src/server.ts | 9 +++++++ 6 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 src/plugin.spec.ts diff --git a/src/ipcService.ts b/src/ipcService.ts index 99bc64f73..7698b9b54 100644 --- a/src/ipcService.ts +++ b/src/ipcService.ts @@ -5,6 +5,7 @@ export const enum IpcIncomingEvent { STOP_CHILD_BRIDGE = "stopChildBridge", START_CHILD_BRIDGE = "startChildBridge", CHILD_BRIDGE_METADATA_REQUEST = "childBridgeMetadataRequest", + RELOAD_PLUGIN = "reloadPlugin", } export const enum IpcOutgoingEvent { @@ -18,6 +19,7 @@ export declare interface IpcService { on(event: IpcIncomingEvent.STOP_CHILD_BRIDGE, listener: (childBridgeUsername: string) => void): this; on(event: IpcIncomingEvent.START_CHILD_BRIDGE, listener: (childBridgeUsername: string) => void): this; on(event: IpcIncomingEvent.CHILD_BRIDGE_METADATA_REQUEST, listener: () => void): this; + on(event: IpcIncomingEvent.RELOAD_PLUGIN, listener: (pluginIdentifier: string) => void): this; } export class IpcService extends EventEmitter { diff --git a/src/plugin.spec.ts b/src/plugin.spec.ts new file mode 100644 index 000000000..0def90c08 --- /dev/null +++ b/src/plugin.spec.ts @@ -0,0 +1,35 @@ +import { Plugin } from "./plugin"; + +describe("Plugin", () => { + describe("Plugin reload functionality", () => { + it("should have reload method", function() { + const mockPackageJSON = { + name: "homebridge-test-plugin", + version: "1.0.0", + main: "./index.js", + engines: { + homebridge: "^1.0.0", + }, + }; + + const plugin = new Plugin("homebridge-test-plugin", "/mock/path", mockPackageJSON); + expect(typeof plugin.reload).toBe("function"); + }); + + it("should reject reloading plugin that hasn't been loaded", async function() { + const mockPackageJSON = { + name: "homebridge-test-plugin", + version: "1.0.0", + main: "./index.js", + engines: { + homebridge: "^1.0.0", + }, + }; + + const plugin = new Plugin("homebridge-test-plugin", "/mock/path", mockPackageJSON); + + await expect(plugin.reload()) + .rejects.toThrow("Cannot reload plugin that has not been loaded yet!"); + }); + }); +}); \ No newline at end of file diff --git a/src/plugin.ts b/src/plugin.ts index 77af8938b..5a334dba3 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -39,6 +39,7 @@ export class Plugin { // ------------------ package.json content ------------------ readonly version: string; private readonly main: string; + private mainPath?: string; // resolved path to the main module file private loadContext?: { // used to store data for a limited time until the load method is called, will be reset afterward engines?: Record; dependencies?: Record; @@ -216,7 +217,7 @@ meaning they carry an additional copy of homebridge and hap-nodejs. This not onl major incompatibility issues and thus is considered bad practice. Please inform the developer to update their plugin!`); } - const mainPath = path.join(this.pluginPath, this.main); + this.mainPath = path.join(this.pluginPath, this.main); // try to require() it and grab the exported initialization hook // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -225,7 +226,7 @@ major incompatibility issues and thus is considered bad practice. Please inform // see https://github.com/nodejs/node/issues/31710 // eslint-disable-next-line @typescript-eslint/no-require-imports - const pluginModules = this.isESM ? await _importDynamic(pathToFileURL(mainPath).href) : require(mainPath); + const pluginModules = this.isESM ? await _importDynamic(pathToFileURL(this.mainPath).href) : require(this.mainPath); if (typeof pluginModules === "function") { this.pluginInitializer = pluginModules; @@ -244,5 +245,56 @@ major incompatibility issues and thus is considered bad practice. Please inform return this.pluginInitializer(api); } + /** + * Reload the plugin by clearing the module cache and loading it again. + * This allows for hot-reloading of plugins without restarting the entire Homebridge process. + */ + public async reload(): Promise { + if (!this.mainPath) { + throw new Error("Cannot reload plugin that has not been loaded yet!"); + } + + log.info(`Reloading plugin: ${this.getPluginIdentifier()}`); + + // Clear the plugin from cache + if (!this.isESM) { + // For CommonJS modules, we can delete from require.cache + const resolvedPath = require.resolve(this.mainPath); + delete require.cache[resolvedPath]; + + // Also clear any dependencies that might be cached + // This is a more thorough cache clearing for CommonJS + Object.keys(require.cache).forEach(key => { + if (key.startsWith(this.pluginPath)) { + delete require.cache[key]; + } + }); + } else { + // For ESM modules, we can't easily clear from cache + // The dynamic import will handle module reloading + log.warn(`Reloading ESM plugin "${this.getPluginIdentifier()}". Note: ESM modules may not fully reload all changes.`); + } + + // Reset plugin state + this.pluginInitializer = undefined; + this.registeredAccessories.clear(); + this.registeredPlatforms.clear(); + this.activeDynamicPlatforms.clear(); + + // Reload the plugin module + // eslint-disable-next-line @typescript-eslint/no-require-imports + const pluginModules = this.isESM ? await _importDynamic(pathToFileURL(this.mainPath).href + "?reload=" + Date.now()) : require(this.mainPath); + + if (typeof pluginModules === "function") { + this.pluginInitializer = pluginModules; + } else if (pluginModules && typeof pluginModules.default === "function") { + this.pluginInitializer = pluginModules.default; + } else { + throw new Error(`Plugin ${this.pluginPath} does not export a initializer function from main.`); + } + + log.info(`Successfully reloaded plugin: ${this.getPluginIdentifier()}`); + } + } diff --git a/src/pluginManager.spec.ts b/src/pluginManager.spec.ts index d896a0ceb..4565f63fe 100644 --- a/src/pluginManager.spec.ts +++ b/src/pluginManager.spec.ts @@ -1,4 +1,5 @@ import { PluginManager } from "./pluginManager"; +import { HomebridgeAPI } from "./api"; describe("PluginManager", () => { describe("PluginManager.isQualifiedPluginIdentifier", () => { @@ -60,4 +61,20 @@ describe("PluginManager", () => { }); }); + describe("Plugin reload functionality", () => { + it("should have reloadPlugin method", function() { + const api = new HomebridgeAPI(); + const pluginManager = new PluginManager(api); + expect(typeof pluginManager.reloadPlugin).toBe("function"); + }); + + it("should reject reloading non-existent plugin", async function() { + const api = new HomebridgeAPI(); + const pluginManager = new PluginManager(api); + + await expect(pluginManager.reloadPlugin("non-existent-plugin")) + .rejects.toThrow("Plugin 'non-existent-plugin' not found or not registered."); + }); + }); + }); diff --git a/src/pluginManager.ts b/src/pluginManager.ts index d9ce37764..814c7a372 100644 --- a/src/pluginManager.ts +++ b/src/pluginManager.ts @@ -486,4 +486,39 @@ export class PluginManager { } } + /** + * Reload a specific plugin by identifier + */ + public async reloadPlugin(pluginIdentifier: PluginIdentifier): Promise { + const plugin = this.getPlugin(pluginIdentifier); + if (!plugin) { + throw new Error(`Plugin '${pluginIdentifier}' not found or not registered.`); + } + + if (plugin.disabled) { + throw new Error(`Cannot reload disabled plugin '${pluginIdentifier}'.`); + } + + log.info(`Reloading plugin '${pluginIdentifier}'...`); + + try { + // Reload the plugin module + await plugin.reload(); + + // Reinitialize the plugin with the API + this.currentInitializingPlugin = plugin; + await plugin.initialize(this.api); + + log.info(`Successfully reloaded and reinitialized plugin '${pluginIdentifier}'.`); + } catch (error) { + log.error("===================="); + log.error(`ERROR RELOADING PLUGIN ${pluginIdentifier}:`); + log.error(error.stack); + log.error("===================="); + throw error; + } finally { + this.currentInitializingPlugin = undefined; + } + } + } diff --git a/src/server.ts b/src/server.ts index 56f101e04..8218605fd 100644 --- a/src/server.ts +++ b/src/server.ts @@ -555,6 +555,15 @@ export class Server { Array.from(this.childBridges.values()).map(x => x.getMetadata()), ); }); + + // handle reload plugin event + this.ipcService.on(IpcIncomingEvent.RELOAD_PLUGIN, (pluginIdentifier) => { + if (typeof pluginIdentifier === "string") { + this.pluginManager.reloadPlugin(pluginIdentifier).catch(error => { + log.error(`Failed to reload plugin '${pluginIdentifier}':`, error.message); + }); + } + }); } private printSetupInfo(pin: string): void { From bbb979dc0134198b5945eed0c83a94d0378f9150 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 Aug 2025 22:56:16 +0000 Subject: [PATCH 3/3] Fix import order linting issue in pluginManager.spec.ts Co-authored-by: donavanbecker <9875439+donavanbecker@users.noreply.github.com> --- src/pluginManager.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pluginManager.spec.ts b/src/pluginManager.spec.ts index 4565f63fe..f75e4eeeb 100644 --- a/src/pluginManager.spec.ts +++ b/src/pluginManager.spec.ts @@ -1,5 +1,5 @@ -import { PluginManager } from "./pluginManager"; import { HomebridgeAPI } from "./api"; +import { PluginManager } from "./pluginManager"; describe("PluginManager", () => { describe("PluginManager.isQualifiedPluginIdentifier", () => {