Skip to content

Conversation

@donavanbecker
Copy link
Contributor

This PR adds the ability to schedule automatic restarts of Homebridge and individual child bridges using cron expressions. Users can configure restart schedules globally for the main Homebridge process or individually for each child bridge, with optional timezone support.

Motivation

Many users want to schedule regular restarts of Homebridge to:

  • Clear memory leaks in long-running plugins
  • Apply configuration changes at predictable times
  • Maintain system stability with periodic restarts
  • Reduce manual intervention for maintenance

Previously, users had to rely on external cron jobs or system schedulers. This feature brings scheduling natively into the Homebridge Config UI X, making it accessible to all users regardless of their technical expertise.

Changes

Backend

Config Interfaces (src/core/config/config.interfaces.ts)

  • Added scheduledRestart field to HomebridgeUiConfig for global Homebridge restarts
  • Added scheduledRestart field to PluginChildBridge for per-child-bridge restarts
  • Structure: { enabled?: boolean, cron?: string, timezone?: string }

Restart Scheduler Service (src/modules/server/restart-scheduler.service.ts)

  • New service that manages scheduled restart jobs using node-schedule
  • Implements OnModuleInit to schedule jobs on startup
  • refreshSchedules(config?) method to cancel and reschedule all jobs when config changes
  • Supports IANA timezone specifications for accurate scheduling across timezones
  • Job naming convention: restart-homebridge for main, restart-child-{DEVICEID} for child bridges
  • Graceful error handling with detailed logging

Module Wiring

  • Integrated RestartSchedulerService into ServerModule with token-based provider
  • Modified ConfigEditorService to use optional lazy lookup via ModuleRef to avoid circular dependencies
  • Calls refreshSchedules() after config saves to automatically update schedules

Frontend

Global Settings (ui/src/app/modules/settings/)

  • New ScheduledRestartComponent modal for configuring global Homebridge restarts
  • Added "Scheduled Restart" option to Settings → General section
  • Modal includes:
    • Enable/disable toggle
    • Cron expression input field
    • Timezone input field (optional)
    • Helpful examples and descriptions
  • Auto-saves changes with debounce to /config-editor/ui endpoint

Child Bridge Settings (ui/src/app/core/manage-plugins/plugin-bridge/)

  • Added "Scheduled Restart" section to each child bridge configuration modal
  • Same UI controls as global settings but scoped to individual bridges
  • Persists to _bridge.scheduledRestart in plugin config blocks

Internationalization (ui/src/i18n/en.json)

  • Added restart.schedule.* keys for global settings
  • Added child_bridge.config.scheduled_restart.* keys for per-bridge settings
  • Includes labels, descriptions, placeholders, and help text

Screenshots

Child Bridge Settings

scheduled-restart-child-bridge-mockup

Global Settings List

scheduled-restart-settings-list-mockup

Global Settings Modal

scheduled-restart-modal-mockup

Usage Examples

Schedule Daily Restart at 3 AM

{
  "scheduledRestart": {
    "enabled": true,
    "cron": "0 3 * * *"
  }
}

Schedule Weekly Restart (Sunday at 4 AM, Pacific Time)

{
  "scheduledRestart": {
    "enabled": true,
    "cron": "0 4 * * 0",
    "timezone": "America/Los_Angeles"
  }
}

Schedule Child Bridge Restart Every 6 Hours

{
  "platform": "AirQuality",
  "_bridge": {
    "username": "0E:AE:C6:D3:94:EB",
    "port": 47593,
    "scheduledRestart": {
      "enabled": true,
      "cron": "0 */6 * * *"
    }
  }
}

Technical Details

Cron Expression Format

Uses standard cron syntax: minute hour day month day-of-week

  • * = any value
  • */n = every n units
  • 0-23 = specific range
  • 1,3,5 = specific values

Timezone Support

  • Uses IANA timezone database (e.g., America/New_York, Europe/London)
  • Falls back to system timezone if not specified
  • Properly handles daylight saving time transitions

Job Management

  • Jobs are cancelled and recreated when config is updated
  • Jobs persist across UI restarts (read from config on startup)
  • Scheduler service is optional to avoid breaking isolated test modules
  • Comprehensive logging for scheduling events and failures

Dependency Management

  • Uses token-based injection (UIX_RESTART_SCHEDULER) to avoid circular dependencies
  • Optional resolution via ModuleRef ensures compatibility with test modules
  • No breaking changes to existing code

Testing

  • ✅ All 310 existing tests pass
  • ✅ Build completes successfully (32 seconds)
  • ✅ Lint passes with zero errors
  • ✅ No circular dependencies
  • ✅ UI components render correctly
  • ✅ Config persistence works as expected

Manual Testing Checklist

  • Schedule global restart and verify job is created
  • Schedule child bridge restart and verify job is created
  • Disable scheduled restart and verify job is cancelled
  • Update cron expression and verify job is rescheduled
  • Test with timezone specified
  • Verify restart occurs at scheduled time
  • Check logs for scheduling confirmation messages
  • Verify schedules persist after UI restart

Breaking Changes

None. This is a completely new feature that is opt-in.

Migration Guide

No migration needed. Existing configurations continue to work without any changes.

Dependencies

No new npm dependencies. Uses existing node-schedule through Homebridge's SchedulerService.

Documentation

  • All UI elements include descriptive help text
  • Cron examples provided in the modal
  • Console logging for debugging and verification

Related Issues

Closes #[issue-number] (if applicable)

Checklist

  • Code follows the project's coding standards
  • Changes have been tested locally
  • All tests pass (npm run test)
  • Linting passes (npm run lint)
  • Build completes successfully (npm run build)
  • UI components added/modified
  • Backend services added/modified
  • i18n keys added for new text
  • Screenshots/mockups included
  • No breaking changes
  • Documentation updated (inline help text)

Additional Notes

This feature integrates seamlessly with the existing Homebridge UI architecture and follows established patterns for configuration management and scheduling. The implementation is robust, with proper error handling and graceful degradation if the scheduler service is unavailable.

Future enhancements could include:

  • Visual display of next scheduled run time in UI
  • Restart history/logs
  • Pre-restart notifications
  • Advanced scheduling options (e.g., "first Monday of month")

@github-actions github-actions bot added the latest Related to Latest Branch label Nov 6, 2025
@donavanbecker donavanbecker requested a review from bwp91 November 6, 2025 23:46
return ''
}

return webroot.replace(/\/+/g, '/').replace(/^\/+|\/+$/g, '')

Check failure

Code scanning / CodeQL

Polynomial regular expression used on uncontrolled data High

This
regular expression
that depends on
a user-provided value
may run slow on strings with many repetitions of '/'.
const cachedAccessoriesBackup = join(cachedAccessoriesDir, `.cachedAccessories.${id}.bak`)

if (await pathExists(cachedAccessories)) {
await unlink(cachedAccessories)

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
This path depends on a
user-provided value
.

Copilot Autofix

AI 6 days ago

The best way to fix this problem is to ensure that the device ID passed from user input (route/query parameters) cannot be used to escape the intended directory or craft dangerous file paths. This can be achieved by strictly validating the deviceId value before passing it to any function that performs filesystem actions:

  • Restrict deviceId to a safe pattern, e.g., allow only [a-zA-Z0-9] and maybe a few safe symbols (if needed), and forbid path-separators and dots.
  • Use a helper function to validate deviceId against a regex before usage; if it fails, throw BadRequestException.
  • Implement the validation centrally in the ServerService before any file path is constructed and any fs operation is performed, e.g., at the beginning of deleteDevicePairing, deleteDeviceAccessories, etc.
  • Optionally, ensure (defensively) that after resolving the target file path, it remains within the expected directory (i.e., after path resolution, compare with intended root).
  • Minimal change: Add a method for the validation, call it at the entry points in ServerService's public methods which take deviceId from user input (deleteDevicePairing, deleteDeviceMatterConfig, deleteDeviceAccessories).

You only need to edit src/modules/server/server.service.ts.
Steps:

  • Add an internal validateDeviceId(id: string) helper that throws on invalid input.
  • Call this method at the start of all relevant public methods that receive a device ID from user input (deleteDevicePairing, deleteDeviceMatterConfig, deleteDeviceAccessories).
  • Do not change business logic, just fail fast on bad input and prevent dangerous values from propagating.

Suggested changeset 1
src/modules/server/server.service.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/modules/server/server.service.ts b/src/modules/server/server.service.ts
--- a/src/modules/server/server.service.ts
+++ b/src/modules/server/server.service.ts
@@ -42,6 +42,20 @@
 export class ServerService {
   private serverServiceCache = new NodeCache({ stdTTL: 300 })
 
+  /**
+   * Validate a deviceId value for use as part of a filename.
+   * Only allows alphanumeric and uppercase letters (and optionally strip colons)
+   * Throws a BadRequestException if invalid.
+   * You may adjust the regex for business logic if needed.
+   */
+  private validateDeviceId(id: string) {
+    // Only allow letters, numbers (and optionally colons if needed)
+    // This pattern: No directory traversal, no slashes, no dots, no spaces
+    const safePattern = /^[A-Za-z0-9:\-]+$/;
+    if (!safePattern.test(id)) {
+      throw new BadRequestException(`Invalid deviceId format: ${id}`);
+    }
+  }
   private readonly accessoryId: string
   private readonly accessoryInfoPath: string
 
@@ -436,6 +450,7 @@
    * Remove a device pairing
    */
   public async deleteDevicePairing(id: string, resetPairingInfo: boolean) {
+    this.validateDeviceId(id);
     this.logger.warn(`Shutting down Homebridge before resetting paired bridge ${id}...`)
 
     // Wait for homebridge to stop
@@ -456,6 +471,7 @@
    * @throws InternalServerErrorException if removal fails
    */
   public async deleteDeviceMatterConfig(id: string): Promise<{ ok: boolean }> {
+    this.validateDeviceId(id);
     try {
       const configFile = await this.configEditorService.getConfigFile()
       // Format username with colons if not already present
@@ -537,6 +553,7 @@
    * Remove a device's accessories
    */
   public async deleteDeviceAccessories(id: string) {
+    this.validateDeviceId(id);
     this.logger.warn(`Shutting down Homebridge before removing accessories for paired bridge ${id}...`)
 
     // Wait for homebridge to stop.
EOF
@@ -42,6 +42,20 @@
export class ServerService {
private serverServiceCache = new NodeCache({ stdTTL: 300 })

/**
* Validate a deviceId value for use as part of a filename.
* Only allows alphanumeric and uppercase letters (and optionally strip colons)
* Throws a BadRequestException if invalid.
* You may adjust the regex for business logic if needed.
*/
private validateDeviceId(id: string) {
// Only allow letters, numbers (and optionally colons if needed)
// This pattern: No directory traversal, no slashes, no dots, no spaces
const safePattern = /^[A-Za-z0-9:\-]+$/;
if (!safePattern.test(id)) {
throw new BadRequestException(`Invalid deviceId format: ${id}`);
}
}
private readonly accessoryId: string
private readonly accessoryInfoPath: string

@@ -436,6 +450,7 @@
* Remove a device pairing
*/
public async deleteDevicePairing(id: string, resetPairingInfo: boolean) {
this.validateDeviceId(id);
this.logger.warn(`Shutting down Homebridge before resetting paired bridge ${id}...`)

// Wait for homebridge to stop
@@ -456,6 +471,7 @@
* @throws InternalServerErrorException if removal fails
*/
public async deleteDeviceMatterConfig(id: string): Promise<{ ok: boolean }> {
this.validateDeviceId(id);
try {
const configFile = await this.configEditorService.getConfigFile()
// Format username with colons if not already present
@@ -537,6 +553,7 @@
* Remove a device's accessories
*/
public async deleteDeviceAccessories(id: string) {
this.validateDeviceId(id);
this.logger.warn(`Shutting down Homebridge before removing accessories for paired bridge ${id}...`)

// Wait for homebridge to stop.
Copilot is powered by AI and may make mistakes. Always verify output.
await unlink(cachedAccessories)
this.logger.warn(`Bridge ${id} accessory removal: removed ${cachedAccessories}.`)
if (await pathExists(cachedAccessoriesBackup)) {
await unlink(cachedAccessoriesBackup)

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
This path depends on a
user-provided value
.

Copilot Autofix

AI 6 days ago

The best way to fix this issue is to validate and sanitize the deviceId parameter before using it in any file path operations. Since deviceId can be highly variable and may be passed via URL params, it is safest to restrict it to allowed characters (e.g., alphanumerics, possibly colons or dashes if needed), or alternatively, to ensure all constructed paths are strictly contained within expected parent directories.

Approach:

  • Implement a helper function to validate deviceId. Only allow device IDs that match a safe regex, e.g., /^[A-Za-z0-9:-]{1,64}$/. If it doesn't match, throw a BadRequestException or return early.
  • Apply this validation in every relevant method that accepts a raw id parameter before using it in any path construction (notably, in deleteSingleDeviceAccessories, deleteSingleDevicePairing, deleteDevicePairing, deleteDeviceMatterConfig, and any public method that takes an id).
  • Optionally, after constructing file paths, resolve and check that the resulting path is within an allowed directory (containment check); however, in this case, strict validation of the ID suffices unless the format of accepted IDs must be broad.

File(s)/region(s) to change:

  • src/modules/server/server.service.ts: Add a validation method (e.g., isValidDeviceId(id: string)). Use it in relevant public and private methods before constructing paths.
  • If coverage for all relevant uses is required, apply this to all entry-points for the parameter in the file.

Needed changes:

  • Add a helper function for validating device IDs.
  • At the top of each method taking an ID and using it for file path construction, validate the ID and throw a BadRequestException if it is invalid.
  • Add appropriate imports if not present.
Suggested changeset 1
src/modules/server/server.service.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/modules/server/server.service.ts b/src/modules/server/server.service.ts
--- a/src/modules/server/server.service.ts
+++ b/src/modules/server/server.service.ts
@@ -22,6 +22,7 @@
   InternalServerErrorException,
   NotFoundException,
   ServiceUnavailableException,
+  BadRequestException,
 } from '@nestjs/common'
 import { pathExists, readJson, remove, writeJson } from 'fs-extra/esm'
 import NodeCache from 'node-cache'
@@ -48,6 +49,12 @@
   public setupCode: string | null = null
   public paired: boolean = false
 
+  // Accept only strict device ids (hex, numbers, colons, dashes, up to 64 chars)
+  private isValidDeviceId(id: string): boolean {
+    // Accept: 0E:3C:22:18:EC:79, 0E3C2218EC79, etc. Adjust as needed for your format.
+    return /^[A-Za-z0-9:-]{1,64}$/.test(id)
+  }
+
   constructor(
     @Inject(ConfigService) private readonly configService: ConfigService,
     @Inject(ConfigEditorService) private readonly configEditorService: ConfigEditorService,
@@ -67,6 +74,9 @@
    * @private
    */
   private async deleteSingleDeviceAccessories(id: string, cachedAccessoriesDir: string, protocol: 'hap' | 'matter' | 'both' = 'both') {
+    if (!this.isValidDeviceId(id)) {
+      throw new BadRequestException(`Invalid device id format.`)
+    }
     // Clean HAP accessories
     if (protocol === 'hap' || protocol === 'both') {
       const cachedAccessories = join(cachedAccessoriesDir, `cachedAccessories.${id}`)
@@ -102,6 +112,9 @@
    * @private
    */
   private async deleteSingleDevicePairing(id: string, resetPairingInfo: boolean) {
+    if (!this.isValidDeviceId(id)) {
+      throw new BadRequestException(`Invalid device id format.`)
+    }
     const persistPath = join(this.configService.storagePath, 'persist')
     const accessoryInfo = join(persistPath, `AccessoryInfo.${id}.json`)
     const identifierCache = join(persistPath, `IdentifierCache.${id}.json`)
@@ -436,6 +449,9 @@
    * Remove a device pairing
    */
   public async deleteDevicePairing(id: string, resetPairingInfo: boolean) {
+    if (!this.isValidDeviceId(id)) {
+      throw new BadRequestException(`Invalid device id format.`)
+    }
     this.logger.warn(`Shutting down Homebridge before resetting paired bridge ${id}...`)
 
     // Wait for homebridge to stop
@@ -456,6 +472,9 @@
    * @throws InternalServerErrorException if removal fails
    */
   public async deleteDeviceMatterConfig(id: string): Promise<{ ok: boolean }> {
+    if (!this.isValidDeviceId(id)) {
+      throw new BadRequestException(`Invalid device id format.`)
+    }
     try {
       const configFile = await this.configEditorService.getConfigFile()
       // Format username with colons if not already present
@@ -537,6 +556,9 @@
    * Remove a device's accessories
    */
   public async deleteDeviceAccessories(id: string) {
+    if (!this.isValidDeviceId(id)) {
+      throw new BadRequestException(`Invalid device id format.`)
+    }
     this.logger.warn(`Shutting down Homebridge before removing accessories for paired bridge ${id}...`)
 
     // Wait for homebridge to stop.
EOF
Copilot is powered by AI and may make mistakes. Always verify output.
@donavanbecker donavanbecker changed the base branch from latest to beta-5.9.1 November 6, 2025 23:59
@bwp91 bwp91 force-pushed the beta-5.9.1 branch 6 times, most recently from bbbb1e3 to 501f284 Compare November 11, 2025 00:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

latest Related to Latest Branch

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants