Repository files navigation

homebridge-script2

Execute custom scripts via HomeKit / Apple Home using Homebridge.

Core of the code written by @xxcombat. Original plugin: homebridge-script.

Recommended configuration

Use platform mode with:

  • on_off_switches for normal ON/OFF devices
  • stateless_switches for trigger-style devices

Legacy formats are still supported:

  • platform devices array
  • accessory-mode accessories entries

See LEGACY.md for legacy field details, examples, and migration guidance.

Homebridge UI Configuration

  • In Homebridge UI, go to Plugins → homebridge-script2 → Plugin Config.
  • Use the On/Off Switches and Stateless Switches sections.
  • Save and restart Homebridge when prompted.

Platform configuration parameters

NameValueRequiredNotes
on_off_switchesarraynoMain section for standard ON/OFF switches
stateless_switchesarraynoMain section for one-shot trigger switches
devicesarrayno (legacy only)Legacy compatibility list (see LEGACY.md)

on_off_switches item parameters

NameValueRequiredNotes
name(custom)yesAccessory name shown in Home app
on(custom)yesScript/command to execute the ON action
off(custom)yesScript/command to execute the OFF action
fileState(custom)fileState or stateFile flag used as current state; if set, it overrides state
state(custom)fileState or stateScript to determine current ON/OFF state
on_value(custom)no (default "true")Value matched against normalized state output
pollingtrue/falseno (default false)Enables periodic polling for state mode
polling_intervalinteger msno (default 5000)Poll interval when polling is enabled
polling_on_starttrue/falseno (default true)Immediately runs state poll on startup
state_cache_ttl_msinteger msno (default 1000)Cache TTL for burst reads
reset_state_cache_on_settrue/falseno (default false)Resets/seeds state cache after successful manual set
fail_on_state_exit_codetrue/falseno (default false)Treat non-zero state exit code as read error
command_timeoutinteger msno (default 10000)Maximum runtime for ON, OFF, and state commands
homekit_set_ack_timeout_msinteger msno (default 0)Opt in to acknowledging a still-running ON/OFF request after this delay; requires state or fileState
unique_serial(custom)noUnique serial per accessory is recommended

stateless_switches item parameters

NameValueRequiredNotes
name(custom)yesAccessory name shown in Home app
trigger(custom)yesScript/command to execute trigger action
auto_reset_msinteger msno (default 500)Delay before Home tile auto-resets
command_timeoutinteger msno (default 10000)Maximum runtime for the trigger command
stateless_trigger_onon/offno (default on)on triggers on ON; off triggers on OFF (tile defaults to ON)
unique_serial(custom)noUnique serial per accessory is recommended

Command timing and long-running ON/OFF actions

command_timeout and homekit_set_ack_timeout_ms control different deadlines:

  • command_timeout controls how long Script2 allows the external ON, OFF, state, or stateless trigger command to run. A command that exceeds this limit is reported as timed out. Increase it above the command's worst-case runtime for long-running scripts.
  • homekit_set_ack_timeout_ms applies only to stateful ON/OFF switches. Its backward-compatible default is 0, which means the HomeKit set callback waits for actual command completion.
  • Set homekit_set_ack_timeout_ms to a positive integer to opt into early HomeKit acknowledgement. For example, 5000 acknowledges the request after five seconds while the external command continues under command_timeout.
  • Early acknowledgement does not complete or duplicate the external operation: Script2 keeps the command in flight, coalesces duplicate requests, serializes opposite requests, and defers GET/poll presentation updates until the command settles.
  • Optimistic acknowledgement requires state or fileState. If the external command later fails, Script2 bypasses the TTL cache and uses that state source to reconcile HomeKit. Without a state source, early acknowledgement is disabled with a warning.
  • fail_on_state_exit_code is independent of both timeout settings. It controls whether a non-zero state command exit is fatal when the state command still prints usable stdout.

Recommended settings for a stateful command that may take up to two minutes:

"command_timeout": 120000,
"homekit_set_ack_timeout_ms": 5000

For existing synchronous behavior, omit homekit_set_ack_timeout_ms or set it to 0.

State script behavior for on_off_switches

  • The state script output is normalized to lowercase and compared against on_value (default "true").
  • on_value should be set to a string and use quotes. Default value is "true".
  • If both fileState and state are configured, fileState takes precedence: the state script is not used for status changes and the configured file flag is used instead.
  • If using fileState your on and off scripts should create the fileState file and delete the fileState file for homekit to see the changes.
  • If a script returns a non-zero exit code but still prints a valid value to stdout (for example true or false), the plugin will use stdout to determine state. You can set fail_on_state_exit_code to true to treat non-zero state exit code as read error.
  • When polling is enabled, the state script is executed on the configured interval and updates HomeKit if the value changes.
  • Polling options are ignored when fileState is configured, since fileState already uses filesystem change notifications to dynamically update homekit status.
  • When state_cache_ttl_ms is greater than 0, state reads are cached briefly to prevent duplicate script executions from burst get requests.
  • By default, manual HomeKit ON/OFF actions do not reset or extend state_cache_ttl_ms. Set reset_state_cache_on_set to true if you want successful manual set actions to reset the TTL timer and seed the cache with the newly set state.
  • If multiple get requests arrive while a state command is already running, they are coalesced and share the same in-flight command result.
  • Each getState request writes a single result log entry in the format GetState <name>: ON/OFF (path: <homekit-get|polling>, source: <state-script|ttl-cache|in-flight-coalesced|file-state>). Where Path is telling you if this was the result of a polling request or a homekit initiated get request (out of the plugin's control). And source is where the value was sourced from, state-script execution result, ttl cache, in-flight coalesced, or from file-state.
  • The TTL cache is per-accessory instance (per configured outlet/switch), not global across all accessories.
  • At startup with polling_on_start: true, the first read for each accessory is a cache miss by design, so one state-script execution per accessory is expected before subsequent reads are served from TTL.

Platform configuration example (recommended)

"platforms": [
{
"platform": "Script2Platform",
"name": "Script2",
"on_off_switches": [
{
"name": "Outlet 1",
"on": "/opt/scripts/on.sh 1",
"off": "/opt/scripts/off.sh 1",
"state": "/opt/scripts/state.sh 1",
"on_value": "true",
"command_timeout": 120000,
"homekit_set_ack_timeout_ms": 5000
},
{
"name": "Outlet 2",
"on": "/opt/scripts/on.sh 2",
"off": "/opt/scripts/off.sh 2",
"fileState": "/opt/scripts/outlet2.flag",
"polling": false
}
],
"stateless_switches": [
{
"name": "Outlet 1 Reboot",
"trigger": "/opt/scripts/reboot.sh 1",
"auto_reset_ms": 500,
"command_timeout": 30000,
"stateless_trigger_on": "off"
},
{
"name": "Outlet 2 Reboot",
"trigger": "/opt/scripts/reboot.sh 2",
"auto_reset_ms": 700,
"stateless_trigger_on": "on"
}
]
}
]

Installation

(Requires Node.js >=20.19.0)

  1. Install homebridge using: npm install -g homebridge
  2. Install this plugin using: npm install -g homebridge-script2
  3. Update your configuration file.
  4. Ensure scripts are executable and accessible by the Homebridge service user.

Troubleshooting FAQ

Why does my script work in terminal but not in Homebridge?

Homebridge runs scripts as the Homebridge service user, not your normal shell user. A script that works as pi, ubuntu, or root may fail as homebridge.

Test your script as the same user that runs Homebridge:

sudo -u homebridge /absolute/path/to/script.sh

If your Homebridge service runs as another user, replace homebridge with that user.

How can I confirm which user Homebridge runs as?

systemctl cat homebridge | grep -i '^User='

If no User= is set, check your service/unit setup and logs to determine runtime context.

Why does Homebridge say it ran the script, but nothing happens?

Most commonly:

  1. Wrong permissions (script or directories not executable/readable by Homebridge user)
  2. Wrong working directory
  3. Missing PATH in service environment
  4. Script exits early due to shell/line-ending issues

Do I need absolute paths?

Yes, strongly recommended. Do not rely on relative paths, ~, or shell-specific startup files.

Use absolute paths for:

  • Script files
  • Referenced files/directories
  • Binaries/interpreters (/usr/bin/python3, /usr/bin/node, etc.)

Example:

{
"on": "/home/homebridge/scripts/light_on.sh",
"off": "/home/homebridge/scripts/light_off.sh"
}

Inside scripts:

#!/usr/bin/env bashset -euo pipefail
cd /home/homebridge/scripts ||exit 1
/usr/bin/python3 /home/homebridge/scripts/device_on.py

How do I verify permissions quickly?

chmod +x /home/homebridge/scripts/light_on.sh
chown homebridge:homebridge /home/homebridge/scripts/light_on.sh

Also make sure the Homebridge user can traverse parent directories (x permission on each directory).

Check with:

namei -l /home/homebridge/scripts/light_on.sh

What is the best “same as Homebridge” test command?

Use the exact command from your config as the Homebridge user:

sudo -u homebridge /home/homebridge/scripts/light_on.sh

If this fails, Homebridge will fail too.

How can I collect script debug logs?

Add logging in your script so errors are visible:

#!/usr/bin/env bashset -euo pipefail
exec>>/tmp/homebridge-script2.log 2>&1echo"[$(date)] Starting light_on.sh as $(whoami) in $(pwd)"
/usr/bin/python3 /home/homebridge/scripts/device_on.py
echo"[$(date)] Done"

Then inspect:

tail -n 100 /tmp/homebridge-script2.log

Could line endings break my script?

Yes. Scripts edited on Windows may have CRLF line endings and fail on Linux.

Convert to LF:

dos2unix /home/homebridge/scripts/light_on.sh

My state works but on/off does not. Why?

This usually means:

  • Status-check command/path is valid
  • Action scripts (on/off) have permission/path/runtime issues

Validate each action script independently as Homebridge user:

sudo -u homebridge /home/homebridge/scripts/light_on.sh
sudo -u homebridge /home/homebridge/scripts/light_off.sh

If I use fileState, what should I check?

  • File path is absolute
  • Homebridge user can create/delete/read that file
  • Parent directory permissions are correct
  • No conflicting process recreates/deletes file unexpectedly

Recommended best practices

  • Always test as Homebridge user before troubleshooting plugin behavior.
  • Always use absolute paths in config and scripts.
  • Add logging and fail-fast flags (set -euo pipefail) in shell scripts.
  • Keep scripts minimal; move complex logic to separate files you can test independently.
  • Restart Homebridge after major script/permission changes to ensure a clean environment.

About

Execute custom scripts via HomeKit apps

Topics

Resources

Stars

98 stars

Watchers

10 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

homebridge-script2

Execute custom scripts via HomeKit / Apple Home using Homebridge.

Core of the code written by @xxcombat. Original plugin: homebridge-script.

Recommended configuration

Use platform mode with:

  • on_off_switches for normal ON/OFF devices
  • stateless_switches for trigger-style devices

Legacy formats are still supported:

  • platform devices array
  • accessory-mode accessories entries

See LEGACY.md for legacy field details, examples, and migration guidance.

Homebridge UI Configuration

  • In Homebridge UI, go to Plugins → homebridge-script2 → Plugin Config.
  • Use the On/Off Switches and Stateless Switches sections.
  • Save and restart Homebridge when prompted.

Platform configuration parameters

NameValueRequiredNotes
on_off_switchesarraynoMain section for standard ON/OFF switches
stateless_switchesarraynoMain section for one-shot trigger switches
devicesarrayno (legacy only)Legacy compatibility list (see LEGACY.md)

on_off_switches item parameters

NameValueRequiredNotes
name(custom)yesAccessory name shown in Home app
on(custom)yesScript/command to execute the ON action
off(custom)yesScript/command to execute the OFF action
fileState(custom)fileState or stateFile flag used as current state; if set, it overrides state
state(custom)fileState or stateScript to determine current ON/OFF state
on_value(custom)no (default "true")Value matched against normalized state output
pollingtrue/falseno (default false)Enables periodic polling for state mode
polling_intervalinteger msno (default 5000)Poll interval when polling is enabled
polling_on_starttrue/falseno (default true)Immediately runs state poll on startup
state_cache_ttl_msinteger msno (default 1000)Cache TTL for burst reads
reset_state_cache_on_settrue/falseno (default false)Resets/seeds state cache after successful manual set
fail_on_state_exit_codetrue/falseno (default false)Treat non-zero state exit code as read error
command_timeoutinteger msno (default 10000)Maximum runtime for ON, OFF, and state commands
homekit_set_ack_timeout_msinteger msno (default 0)Opt in to acknowledging a still-running ON/OFF request after this delay; requires state or fileState
unique_serial(custom)noUnique serial per accessory is recommended

stateless_switches item parameters

NameValueRequiredNotes
name(custom)yesAccessory name shown in Home app
trigger(custom)yesScript/command to execute trigger action
auto_reset_msinteger msno (default 500)Delay before Home tile auto-resets
command_timeoutinteger msno (default 10000)Maximum runtime for the trigger command
stateless_trigger_onon/offno (default on)on triggers on ON; off triggers on OFF (tile defaults to ON)
unique_serial(custom)noUnique serial per accessory is recommended

Command timing and long-running ON/OFF actions

command_timeout and homekit_set_ack_timeout_ms control different deadlines:

  • command_timeout controls how long Script2 allows the external ON, OFF, state, or stateless trigger command to run. A command that exceeds this limit is reported as timed out. Increase it above the command's worst-case runtime for long-running scripts.
  • homekit_set_ack_timeout_ms applies only to stateful ON/OFF switches. Its backward-compatible default is 0, which means the HomeKit set callback waits for actual command completion.
  • Set homekit_set_ack_timeout_ms to a positive integer to opt into early HomeKit acknowledgement. For example, 5000 acknowledges the request after five seconds while the external command continues under command_timeout.
  • Early acknowledgement does not complete or duplicate the external operation: Script2 keeps the command in flight, coalesces duplicate requests, serializes opposite requests, and defers GET/poll presentation updates until the command settles.
  • Optimistic acknowledgement requires state or fileState. If the external command later fails, Script2 bypasses the TTL cache and uses that state source to reconcile HomeKit. Without a state source, early acknowledgement is disabled with a warning.
  • fail_on_state_exit_code is independent of both timeout settings. It controls whether a non-zero state command exit is fatal when the state command still prints usable stdout.

Recommended settings for a stateful command that may take up to two minutes:

"command_timeout": 120000,
"homekit_set_ack_timeout_ms": 5000

For existing synchronous behavior, omit homekit_set_ack_timeout_ms or set it to 0.

State script behavior for on_off_switches

  • The state script output is normalized to lowercase and compared against on_value (default "true").
  • on_value should be set to a string and use quotes. Default value is "true".
  • If both fileState and state are configured, fileState takes precedence: the state script is not used for status changes and the configured file flag is used instead.
  • If using fileState your on and off scripts should create the fileState file and delete the fileState file for homekit to see the changes.
  • If a script returns a non-zero exit code but still prints a valid value to stdout (for example true or false), the plugin will use stdout to determine state. You can set fail_on_state_exit_code to true to treat non-zero state exit code as read error.
  • When polling is enabled, the state script is executed on the configured interval and updates HomeKit if the value changes.
  • Polling options are ignored when fileState is configured, since fileState already uses filesystem change notifications to dynamically update homekit status.
  • When state_cache_ttl_ms is greater than 0, state reads are cached briefly to prevent duplicate script executions from burst get requests.
  • By default, manual HomeKit ON/OFF actions do not reset or extend state_cache_ttl_ms. Set reset_state_cache_on_set to true if you want successful manual set actions to reset the TTL timer and seed the cache with the newly set state.
  • If multiple get requests arrive while a state command is already running, they are coalesced and share the same in-flight command result.
  • Each getState request writes a single result log entry in the format GetState <name>: ON/OFF (path: <homekit-get|polling>, source: <state-script|ttl-cache|in-flight-coalesced|file-state>). Where Path is telling you if this was the result of a polling request or a homekit initiated get request (out of the plugin's control). And source is where the value was sourced from, state-script execution result, ttl cache, in-flight coalesced, or from file-state.
  • The TTL cache is per-accessory instance (per configured outlet/switch), not global across all accessories.
  • At startup with polling_on_start: true, the first read for each accessory is a cache miss by design, so one state-script execution per accessory is expected before subsequent reads are served from TTL.

Platform configuration example (recommended)

"platforms": [
{
"platform": "Script2Platform",
"name": "Script2",
"on_off_switches": [
{
"name": "Outlet 1",
"on": "/opt/scripts/on.sh 1",
"off": "/opt/scripts/off.sh 1",
"state": "/opt/scripts/state.sh 1",
"on_value": "true",
"command_timeout": 120000,
"homekit_set_ack_timeout_ms": 5000
},
{
"name": "Outlet 2",
"on": "/opt/scripts/on.sh 2",
"off": "/opt/scripts/off.sh 2",
"fileState": "/opt/scripts/outlet2.flag",
"polling": false
}
],
"stateless_switches": [
{
"name": "Outlet 1 Reboot",
"trigger": "/opt/scripts/reboot.sh 1",
"auto_reset_ms": 500,
"command_timeout": 30000,
"stateless_trigger_on": "off"
},
{
"name": "Outlet 2 Reboot",
"trigger": "/opt/scripts/reboot.sh 2",
"auto_reset_ms": 700,
"stateless_trigger_on": "on"
}
]
}
]

Installation

(Requires Node.js >=20.19.0)

  1. Install homebridge using: npm install -g homebridge
  2. Install this plugin using: npm install -g homebridge-script2
  3. Update your configuration file.
  4. Ensure scripts are executable and accessible by the Homebridge service user.

Troubleshooting FAQ

Why does my script work in terminal but not in Homebridge?

Homebridge runs scripts as the Homebridge service user, not your normal shell user. A script that works as pi, ubuntu, or root may fail as homebridge.

Test your script as the same user that runs Homebridge:

sudo -u homebridge /absolute/path/to/script.sh

If your Homebridge service runs as another user, replace homebridge with that user.

How can I confirm which user Homebridge runs as?

systemctl cat homebridge | grep -i '^User='

If no User= is set, check your service/unit setup and logs to determine runtime context.

Why does Homebridge say it ran the script, but nothing happens?

Most commonly:

  1. Wrong permissions (script or directories not executable/readable by Homebridge user)
  2. Wrong working directory
  3. Missing PATH in service environment
  4. Script exits early due to shell/line-ending issues

Do I need absolute paths?

Yes, strongly recommended. Do not rely on relative paths, ~, or shell-specific startup files.

Use absolute paths for:

  • Script files
  • Referenced files/directories
  • Binaries/interpreters (/usr/bin/python3, /usr/bin/node, etc.)

Example:

{
"on": "/home/homebridge/scripts/light_on.sh",
"off": "/home/homebridge/scripts/light_off.sh"
}

Inside scripts:

#!/usr/bin/env bashset -euo pipefail
cd /home/homebridge/scripts ||exit 1
/usr/bin/python3 /home/homebridge/scripts/device_on.py

How do I verify permissions quickly?

chmod +x /home/homebridge/scripts/light_on.sh
chown homebridge:homebridge /home/homebridge/scripts/light_on.sh

Also make sure the Homebridge user can traverse parent directories (x permission on each directory).

Check with:

namei -l /home/homebridge/scripts/light_on.sh

What is the best “same as Homebridge” test command?

Use the exact command from your config as the Homebridge user:

sudo -u homebridge /home/homebridge/scripts/light_on.sh

If this fails, Homebridge will fail too.

How can I collect script debug logs?

Add logging in your script so errors are visible:

#!/usr/bin/env bashset -euo pipefail
exec>>/tmp/homebridge-script2.log 2>&1echo"[$(date)] Starting light_on.sh as $(whoami) in $(pwd)"
/usr/bin/python3 /home/homebridge/scripts/device_on.py
echo"[$(date)] Done"

Then inspect:

tail -n 100 /tmp/homebridge-script2.log

Could line endings break my script?

Yes. Scripts edited on Windows may have CRLF line endings and fail on Linux.

Convert to LF:

dos2unix /home/homebridge/scripts/light_on.sh

My state works but on/off does not. Why?

This usually means:

  • Status-check command/path is valid
  • Action scripts (on/off) have permission/path/runtime issues

Validate each action script independently as Homebridge user:

sudo -u homebridge /home/homebridge/scripts/light_on.sh
sudo -u homebridge /home/homebridge/scripts/light_off.sh

If I use fileState, what should I check?

  • File path is absolute
  • Homebridge user can create/delete/read that file
  • Parent directory permissions are correct
  • No conflicting process recreates/deletes file unexpectedly

Recommended best practices

  • Always test as Homebridge user before troubleshooting plugin behavior.
  • Always use absolute paths in config and scripts.
  • Add logging and fail-fast flags (set -euo pipefail) in shell scripts.
  • Keep scripts minimal; move complex logic to separate files you can test independently.
  • Restart Homebridge after major script/permission changes to ensure a clean environment.

About

Execute custom scripts via HomeKit apps

Topics

Resources

Stars

98 stars

Watchers

10 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

homebridge-script2

Execute custom scripts via HomeKit / Apple Home using Homebridge.

Core of the code written by @xxcombat. Original plugin: homebridge-script.

Recommended configuration

Use platform mode with:

  • on_off_switches for normal ON/OFF devices
  • stateless_switches for trigger-style devices

Legacy formats are still supported:

  • platform devices array
  • accessory-mode accessories entries

See LEGACY.md for legacy field details, examples, and migration guidance.

Homebridge UI Configuration

  • In Homebridge UI, go to Plugins → homebridge-script2 → Plugin Config.
  • Use the On/Off Switches and Stateless Switches sections.
  • Save and restart Homebridge when prompted.

Platform configuration parameters

NameValueRequiredNotes
on_off_switchesarraynoMain section for standard ON/OFF switches
stateless_switchesarraynoMain section for one-shot trigger switches
devicesarrayno (legacy only)Legacy compatibility list (see LEGACY.md)

on_off_switches item parameters

NameValueRequiredNotes
name(custom)yesAccessory name shown in Home app
on(custom)yesScript/command to execute the ON action
off(custom)yesScript/command to execute the OFF action
fileState(custom)fileState or stateFile flag used as current state; if set, it overrides state
state(custom)fileState or stateScript to determine current ON/OFF state
on_value(custom)no (default "true")Value matched against normalized state output
pollingtrue/falseno (default false)Enables periodic polling for state mode
polling_intervalinteger msno (default 5000)Poll interval when polling is enabled
polling_on_starttrue/falseno (default true)Immediately runs state poll on startup
state_cache_ttl_msinteger msno (default 1000)Cache TTL for burst reads
reset_state_cache_on_settrue/falseno (default false)Resets/seeds state cache after successful manual set
fail_on_state_exit_codetrue/falseno (default false)Treat non-zero state exit code as read error
command_timeoutinteger msno (default 10000)Maximum runtime for ON, OFF, and state commands
homekit_set_ack_timeout_msinteger msno (default 0)Opt in to acknowledging a still-running ON/OFF request after this delay; requires state or fileState
unique_serial(custom)noUnique serial per accessory is recommended

stateless_switches item parameters

NameValueRequiredNotes
name(custom)yesAccessory name shown in Home app
trigger(custom)yesScript/command to execute trigger action
auto_reset_msinteger msno (default 500)Delay before Home tile auto-resets
command_timeoutinteger msno (default 10000)Maximum runtime for the trigger command
stateless_trigger_onon/offno (default on)on triggers on ON; off triggers on OFF (tile defaults to ON)
unique_serial(custom)noUnique serial per accessory is recommended

Command timing and long-running ON/OFF actions

command_timeout and homekit_set_ack_timeout_ms control different deadlines:

  • command_timeout controls how long Script2 allows the external ON, OFF, state, or stateless trigger command to run. A command that exceeds this limit is reported as timed out. Increase it above the command's worst-case runtime for long-running scripts.
  • homekit_set_ack_timeout_ms applies only to stateful ON/OFF switches. Its backward-compatible default is 0, which means the HomeKit set callback waits for actual command completion.
  • Set homekit_set_ack_timeout_ms to a positive integer to opt into early HomeKit acknowledgement. For example, 5000 acknowledges the request after five seconds while the external command continues under command_timeout.
  • Early acknowledgement does not complete or duplicate the external operation: Script2 keeps the command in flight, coalesces duplicate requests, serializes opposite requests, and defers GET/poll presentation updates until the command settles.
  • Optimistic acknowledgement requires state or fileState. If the external command later fails, Script2 bypasses the TTL cache and uses that state source to reconcile HomeKit. Without a state source, early acknowledgement is disabled with a warning.
  • fail_on_state_exit_code is independent of both timeout settings. It controls whether a non-zero state command exit is fatal when the state command still prints usable stdout.

Recommended settings for a stateful command that may take up to two minutes:

"command_timeout": 120000,
"homekit_set_ack_timeout_ms": 5000

For existing synchronous behavior, omit homekit_set_ack_timeout_ms or set it to 0.

State script behavior for on_off_switches

  • The state script output is normalized to lowercase and compared against on_value (default "true").
  • on_value should be set to a string and use quotes. Default value is "true".
  • If both fileState and state are configured, fileState takes precedence: the state script is not used for status changes and the configured file flag is used instead.
  • If using fileState your on and off scripts should create the fileState file and delete the fileState file for homekit to see the changes.
  • If a script returns a non-zero exit code but still prints a valid value to stdout (for example true or false), the plugin will use stdout to determine state. You can set fail_on_state_exit_code to true to treat non-zero state exit code as read error.
  • When polling is enabled, the state script is executed on the configured interval and updates HomeKit if the value changes.
  • Polling options are ignored when fileState is configured, since fileState already uses filesystem change notifications to dynamically update homekit status.
  • When state_cache_ttl_ms is greater than 0, state reads are cached briefly to prevent duplicate script executions from burst get requests.
  • By default, manual HomeKit ON/OFF actions do not reset or extend state_cache_ttl_ms. Set reset_state_cache_on_set to true if you want successful manual set actions to reset the TTL timer and seed the cache with the newly set state.
  • If multiple get requests arrive while a state command is already running, they are coalesced and share the same in-flight command result.
  • Each getState request writes a single result log entry in the format GetState <name>: ON/OFF (path: <homekit-get|polling>, source: <state-script|ttl-cache|in-flight-coalesced|file-state>). Where Path is telling you if this was the result of a polling request or a homekit initiated get request (out of the plugin's control). And source is where the value was sourced from, state-script execution result, ttl cache, in-flight coalesced, or from file-state.
  • The TTL cache is per-accessory instance (per configured outlet/switch), not global across all accessories.
  • At startup with polling_on_start: true, the first read for each accessory is a cache miss by design, so one state-script execution per accessory is expected before subsequent reads are served from TTL.

Platform configuration example (recommended)

"platforms": [
{
"platform": "Script2Platform",
"name": "Script2",
"on_off_switches": [
{
"name": "Outlet 1",
"on": "/opt/scripts/on.sh 1",
"off": "/opt/scripts/off.sh 1",
"state": "/opt/scripts/state.sh 1",
"on_value": "true",
"command_timeout": 120000,
"homekit_set_ack_timeout_ms": 5000
},
{
"name": "Outlet 2",
"on": "/opt/scripts/on.sh 2",
"off": "/opt/scripts/off.sh 2",
"fileState": "/opt/scripts/outlet2.flag",
"polling": false
}
],
"stateless_switches": [
{
"name": "Outlet 1 Reboot",
"trigger": "/opt/scripts/reboot.sh 1",
"auto_reset_ms": 500,
"command_timeout": 30000,
"stateless_trigger_on": "off"
},
{
"name": "Outlet 2 Reboot",
"trigger": "/opt/scripts/reboot.sh 2",
"auto_reset_ms": 700,
"stateless_trigger_on": "on"
}
]
}
]

Installation

(Requires Node.js >=20.19.0)

  1. Install homebridge using: npm install -g homebridge
  2. Install this plugin using: npm install -g homebridge-script2
  3. Update your configuration file.
  4. Ensure scripts are executable and accessible by the Homebridge service user.

Troubleshooting FAQ

Why does my script work in terminal but not in Homebridge?

Homebridge runs scripts as the Homebridge service user, not your normal shell user. A script that works as pi, ubuntu, or root may fail as homebridge.

Test your script as the same user that runs Homebridge:

sudo -u homebridge /absolute/path/to/script.sh

If your Homebridge service runs as another user, replace homebridge with that user.

How can I confirm which user Homebridge runs as?

systemctl cat homebridge | grep -i '^User='

If no User= is set, check your service/unit setup and logs to determine runtime context.

Why does Homebridge say it ran the script, but nothing happens?

Most commonly:

  1. Wrong permissions (script or directories not executable/readable by Homebridge user)
  2. Wrong working directory
  3. Missing PATH in service environment
  4. Script exits early due to shell/line-ending issues

Do I need absolute paths?

Yes, strongly recommended. Do not rely on relative paths, ~, or shell-specific startup files.

Use absolute paths for:

  • Script files
  • Referenced files/directories
  • Binaries/interpreters (/usr/bin/python3, /usr/bin/node, etc.)

Example:

{
"on": "/home/homebridge/scripts/light_on.sh",
"off": "/home/homebridge/scripts/light_off.sh"
}

Inside scripts:

#!/usr/bin/env bashset -euo pipefail
cd /home/homebridge/scripts ||exit 1
/usr/bin/python3 /home/homebridge/scripts/device_on.py

How do I verify permissions quickly?

chmod +x /home/homebridge/scripts/light_on.sh
chown homebridge:homebridge /home/homebridge/scripts/light_on.sh

Also make sure the Homebridge user can traverse parent directories (x permission on each directory).

Check with:

namei -l /home/homebridge/scripts/light_on.sh

What is the best “same as Homebridge” test command?

Use the exact command from your config as the Homebridge user:

sudo -u homebridge /home/homebridge/scripts/light_on.sh

If this fails, Homebridge will fail too.

How can I collect script debug logs?

Add logging in your script so errors are visible:

#!/usr/bin/env bashset -euo pipefail
exec>>/tmp/homebridge-script2.log 2>&1echo"[$(date)] Starting light_on.sh as $(whoami) in $(pwd)"
/usr/bin/python3 /home/homebridge/scripts/device_on.py
echo"[$(date)] Done"

Then inspect:

tail -n 100 /tmp/homebridge-script2.log

Could line endings break my script?

Yes. Scripts edited on Windows may have CRLF line endings and fail on Linux.

Convert to LF:

dos2unix /home/homebridge/scripts/light_on.sh

My state works but on/off does not. Why?

This usually means:

  • Status-check command/path is valid
  • Action scripts (on/off) have permission/path/runtime issues

Validate each action script independently as Homebridge user:

sudo -u homebridge /home/homebridge/scripts/light_on.sh
sudo -u homebridge /home/homebridge/scripts/light_off.sh

If I use fileState, what should I check?

  • File path is absolute
  • Homebridge user can create/delete/read that file
  • Parent directory permissions are correct
  • No conflicting process recreates/deletes file unexpectedly

Recommended best practices

  • Always test as Homebridge user before troubleshooting plugin behavior.
  • Always use absolute paths in config and scripts.
  • Add logging and fail-fast flags (set -euo pipefail) in shell scripts.
  • Keep scripts minimal; move complex logic to separate files you can test independently.
  • Restart Homebridge after major script/permission changes to ensure a clean environment.

About

Execute custom scripts via HomeKit apps

Topics

Resources

Stars

98 stars

Watchers

10 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

homebridge-script2

Execute custom scripts via HomeKit / Apple Home using Homebridge.

Core of the code written by @xxcombat. Original plugin: homebridge-script.

Recommended configuration

Use platform mode with:

  • on_off_switches for normal ON/OFF devices
  • stateless_switches for trigger-style devices

Legacy formats are still supported:

  • platform devices array
  • accessory-mode accessories entries

See LEGACY.md for legacy field details, examples, and migration guidance.

Homebridge UI Configuration

  • In Homebridge UI, go to Plugins → homebridge-script2 → Plugin Config.
  • Use the On/Off Switches and Stateless Switches sections.
  • Save and restart Homebridge when prompted.

Platform configuration parameters

NameValueRequiredNotes
on_off_switchesarraynoMain section for standard ON/OFF switches
stateless_switchesarraynoMain section for one-shot trigger switches
devicesarrayno (legacy only)Legacy compatibility list (see LEGACY.md)

on_off_switches item parameters

NameValueRequiredNotes
name(custom)yesAccessory name shown in Home app
on(custom)yesScript/command to execute the ON action
off(custom)yesScript/command to execute the OFF action
fileState(custom)fileState or stateFile flag used as current state; if set, it overrides state
state(custom)fileState or stateScript to determine current ON/OFF state
on_value(custom)no (default "true")Value matched against normalized state output
pollingtrue/falseno (default false)Enables periodic polling for state mode
polling_intervalinteger msno (default 5000)Poll interval when polling is enabled
polling_on_starttrue/falseno (default true)Immediately runs state poll on startup
state_cache_ttl_msinteger msno (default 1000)Cache TTL for burst reads
reset_state_cache_on_settrue/falseno (default false)Resets/seeds state cache after successful manual set
fail_on_state_exit_codetrue/falseno (default false)Treat non-zero state exit code as read error
command_timeoutinteger msno (default 10000)Maximum runtime for ON, OFF, and state commands
homekit_set_ack_timeout_msinteger msno (default 0)Opt in to acknowledging a still-running ON/OFF request after this delay; requires state or fileState
unique_serial(custom)noUnique serial per accessory is recommended

stateless_switches item parameters

NameValueRequiredNotes
name(custom)yesAccessory name shown in Home app
trigger(custom)yesScript/command to execute trigger action
auto_reset_msinteger msno (default 500)Delay before Home tile auto-resets
command_timeoutinteger msno (default 10000)Maximum runtime for the trigger command
stateless_trigger_onon/offno (default on)on triggers on ON; off triggers on OFF (tile defaults to ON)
unique_serial(custom)noUnique serial per accessory is recommended

Command timing and long-running ON/OFF actions

command_timeout and homekit_set_ack_timeout_ms control different deadlines:

  • command_timeout controls how long Script2 allows the external ON, OFF, state, or stateless trigger command to run. A command that exceeds this limit is reported as timed out. Increase it above the command's worst-case runtime for long-running scripts.
  • homekit_set_ack_timeout_ms applies only to stateful ON/OFF switches. Its backward-compatible default is 0, which means the HomeKit set callback waits for actual command completion.
  • Set homekit_set_ack_timeout_ms to a positive integer to opt into early HomeKit acknowledgement. For example, 5000 acknowledges the request after five seconds while the external command continues under command_timeout.
  • Early acknowledgement does not complete or duplicate the external operation: Script2 keeps the command in flight, coalesces duplicate requests, serializes opposite requests, and defers GET/poll presentation updates until the command settles.
  • Optimistic acknowledgement requires state or fileState. If the external command later fails, Script2 bypasses the TTL cache and uses that state source to reconcile HomeKit. Without a state source, early acknowledgement is disabled with a warning.
  • fail_on_state_exit_code is independent of both timeout settings. It controls whether a non-zero state command exit is fatal when the state command still prints usable stdout.

Recommended settings for a stateful command that may take up to two minutes:

"command_timeout": 120000,
"homekit_set_ack_timeout_ms": 5000

For existing synchronous behavior, omit homekit_set_ack_timeout_ms or set it to 0.

State script behavior for on_off_switches

  • The state script output is normalized to lowercase and compared against on_value (default "true").
  • on_value should be set to a string and use quotes. Default value is "true".
  • If both fileState and state are configured, fileState takes precedence: the state script is not used for status changes and the configured file flag is used instead.
  • If using fileState your on and off scripts should create the fileState file and delete the fileState file for homekit to see the changes.
  • If a script returns a non-zero exit code but still prints a valid value to stdout (for example true or false), the plugin will use stdout to determine state. You can set fail_on_state_exit_code to true to treat non-zero state exit code as read error.
  • When polling is enabled, the state script is executed on the configured interval and updates HomeKit if the value changes.
  • Polling options are ignored when fileState is configured, since fileState already uses filesystem change notifications to dynamically update homekit status.
  • When state_cache_ttl_ms is greater than 0, state reads are cached briefly to prevent duplicate script executions from burst get requests.
  • By default, manual HomeKit ON/OFF actions do not reset or extend state_cache_ttl_ms. Set reset_state_cache_on_set to true if you want successful manual set actions to reset the TTL timer and seed the cache with the newly set state.
  • If multiple get requests arrive while a state command is already running, they are coalesced and share the same in-flight command result.
  • Each getState request writes a single result log entry in the format GetState <name>: ON/OFF (path: <homekit-get|polling>, source: <state-script|ttl-cache|in-flight-coalesced|file-state>). Where Path is telling you if this was the result of a polling request or a homekit initiated get request (out of the plugin's control). And source is where the value was sourced from, state-script execution result, ttl cache, in-flight coalesced, or from file-state.
  • The TTL cache is per-accessory instance (per configured outlet/switch), not global across all accessories.
  • At startup with polling_on_start: true, the first read for each accessory is a cache miss by design, so one state-script execution per accessory is expected before subsequent reads are served from TTL.

Platform configuration example (recommended)

"platforms": [
{
"platform": "Script2Platform",
"name": "Script2",
"on_off_switches": [
{
"name": "Outlet 1",
"on": "/opt/scripts/on.sh 1",
"off": "/opt/scripts/off.sh 1",
"state": "/opt/scripts/state.sh 1",
"on_value": "true",
"command_timeout": 120000,
"homekit_set_ack_timeout_ms": 5000
},
{
"name": "Outlet 2",
"on": "/opt/scripts/on.sh 2",
"off": "/opt/scripts/off.sh 2",
"fileState": "/opt/scripts/outlet2.flag",
"polling": false
}
],
"stateless_switches": [
{
"name": "Outlet 1 Reboot",
"trigger": "/opt/scripts/reboot.sh 1",
"auto_reset_ms": 500,
"command_timeout": 30000,
"stateless_trigger_on": "off"
},
{
"name": "Outlet 2 Reboot",
"trigger": "/opt/scripts/reboot.sh 2",
"auto_reset_ms": 700,
"stateless_trigger_on": "on"
}
]
}
]

Installation

(Requires Node.js >=20.19.0)

  1. Install homebridge using: npm install -g homebridge
  2. Install this plugin using: npm install -g homebridge-script2
  3. Update your configuration file.
  4. Ensure scripts are executable and accessible by the Homebridge service user.

Troubleshooting FAQ

Why does my script work in terminal but not in Homebridge?

Homebridge runs scripts as the Homebridge service user, not your normal shell user. A script that works as pi, ubuntu, or root may fail as homebridge.

Test your script as the same user that runs Homebridge:

sudo -u homebridge /absolute/path/to/script.sh

If your Homebridge service runs as another user, replace homebridge with that user.

How can I confirm which user Homebridge runs as?

systemctl cat homebridge | grep -i '^User='

If no User= is set, check your service/unit setup and logs to determine runtime context.

Why does Homebridge say it ran the script, but nothing happens?

Most commonly:

  1. Wrong permissions (script or directories not executable/readable by Homebridge user)
  2. Wrong working directory
  3. Missing PATH in service environment
  4. Script exits early due to shell/line-ending issues

Do I need absolute paths?

Yes, strongly recommended. Do not rely on relative paths, ~, or shell-specific startup files.

Use absolute paths for:

  • Script files
  • Referenced files/directories
  • Binaries/interpreters (/usr/bin/python3, /usr/bin/node, etc.)

Example:

{
"on": "/home/homebridge/scripts/light_on.sh",
"off": "/home/homebridge/scripts/light_off.sh"
}

Inside scripts:

#!/usr/bin/env bashset -euo pipefail
cd /home/homebridge/scripts ||exit 1
/usr/bin/python3 /home/homebridge/scripts/device_on.py

How do I verify permissions quickly?

chmod +x /home/homebridge/scripts/light_on.sh
chown homebridge:homebridge /home/homebridge/scripts/light_on.sh

Also make sure the Homebridge user can traverse parent directories (x permission on each directory).

Check with:

namei -l /home/homebridge/scripts/light_on.sh

What is the best “same as Homebridge” test command?

Use the exact command from your config as the Homebridge user:

sudo -u homebridge /home/homebridge/scripts/light_on.sh

If this fails, Homebridge will fail too.

How can I collect script debug logs?

Add logging in your script so errors are visible:

#!/usr/bin/env bashset -euo pipefail
exec>>/tmp/homebridge-script2.log 2>&1echo"[$(date)] Starting light_on.sh as $(whoami) in $(pwd)"
/usr/bin/python3 /home/homebridge/scripts/device_on.py
echo"[$(date)] Done"

Then inspect:

tail -n 100 /tmp/homebridge-script2.log

Could line endings break my script?

Yes. Scripts edited on Windows may have CRLF line endings and fail on Linux.

Convert to LF:

dos2unix /home/homebridge/scripts/light_on.sh

My state works but on/off does not. Why?

This usually means:

  • Status-check command/path is valid
  • Action scripts (on/off) have permission/path/runtime issues

Validate each action script independently as Homebridge user:

sudo -u homebridge /home/homebridge/scripts/light_on.sh
sudo -u homebridge /home/homebridge/scripts/light_off.sh

If I use fileState, what should I check?

  • File path is absolute
  • Homebridge user can create/delete/read that file
  • Parent directory permissions are correct
  • No conflicting process recreates/deletes file unexpectedly

Recommended best practices

  • Always test as Homebridge user before troubleshooting plugin behavior.
  • Always use absolute paths in config and scripts.
  • Add logging and fail-fast flags (set -euo pipefail) in shell scripts.
  • Keep scripts minimal; move complex logic to separate files you can test independently.
  • Restart Homebridge after major script/permission changes to ensure a clean environment.

About

Execute custom scripts via HomeKit apps

Topics

Resources

Stars

98 stars

Watchers

10 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

homebridge-script2

Execute custom scripts via HomeKit / Apple Home using Homebridge.

Core of the code written by @xxcombat. Original plugin: homebridge-script.

Recommended configuration

Use platform mode with:

  • on_off_switches for normal ON/OFF devices
  • stateless_switches for trigger-style devices

Legacy formats are still supported:

  • platform devices array
  • accessory-mode accessories entries

See LEGACY.md for legacy field details, examples, and migration guidance.

Homebridge UI Configuration

  • In Homebridge UI, go to Plugins → homebridge-script2 → Plugin Config.
  • Use the On/Off Switches and Stateless Switches sections.
  • Save and restart Homebridge when prompted.

Platform configuration parameters

NameValueRequiredNotes
on_off_switchesarraynoMain section for standard ON/OFF switches
stateless_switchesarraynoMain section for one-shot trigger switches
devicesarrayno (legacy only)Legacy compatibility list (see LEGACY.md)

on_off_switches item parameters

NameValueRequiredNotes
name(custom)yesAccessory name shown in Home app
on(custom)yesScript/command to execute the ON action
off(custom)yesScript/command to execute the OFF action
fileState(custom)fileState or stateFile flag used as current state; if set, it overrides state
state(custom)fileState or stateScript to determine current ON/OFF state
on_value(custom)no (default "true")Value matched against normalized state output
pollingtrue/falseno (default false)Enables periodic polling for state mode
polling_intervalinteger msno (default 5000)Poll interval when polling is enabled
polling_on_starttrue/falseno (default true)Immediately runs state poll on startup
state_cache_ttl_msinteger msno (default 1000)Cache TTL for burst reads
reset_state_cache_on_settrue/falseno (default false)Resets/seeds state cache after successful manual set
fail_on_state_exit_codetrue/falseno (default false)Treat non-zero state exit code as read error
command_timeoutinteger msno (default 10000)Maximum runtime for ON, OFF, and state commands
homekit_set_ack_timeout_msinteger msno (default 0)Opt in to acknowledging a still-running ON/OFF request after this delay; requires state or fileState
unique_serial(custom)noUnique serial per accessory is recommended

stateless_switches item parameters

NameValueRequiredNotes
name(custom)yesAccessory name shown in Home app
trigger(custom)yesScript/command to execute trigger action
auto_reset_msinteger msno (default 500)Delay before Home tile auto-resets
command_timeoutinteger msno (default 10000)Maximum runtime for the trigger command
stateless_trigger_onon/offno (default on)on triggers on ON; off triggers on OFF (tile defaults to ON)
unique_serial(custom)noUnique serial per accessory is recommended

Command timing and long-running ON/OFF actions

command_timeout and homekit_set_ack_timeout_ms control different deadlines:

  • command_timeout controls how long Script2 allows the external ON, OFF, state, or stateless trigger command to run. A command that exceeds this limit is reported as timed out. Increase it above the command's worst-case runtime for long-running scripts.
  • homekit_set_ack_timeout_ms applies only to stateful ON/OFF switches. Its backward-compatible default is 0, which means the HomeKit set callback waits for actual command completion.
  • Set homekit_set_ack_timeout_ms to a positive integer to opt into early HomeKit acknowledgement. For example, 5000 acknowledges the request after five seconds while the external command continues under command_timeout.
  • Early acknowledgement does not complete or duplicate the external operation: Script2 keeps the command in flight, coalesces duplicate requests, serializes opposite requests, and defers GET/poll presentation updates until the command settles.
  • Optimistic acknowledgement requires state or fileState. If the external command later fails, Script2 bypasses the TTL cache and uses that state source to reconcile HomeKit. Without a state source, early acknowledgement is disabled with a warning.
  • fail_on_state_exit_code is independent of both timeout settings. It controls whether a non-zero state command exit is fatal when the state command still prints usable stdout.

Recommended settings for a stateful command that may take up to two minutes:

"command_timeout": 120000,
"homekit_set_ack_timeout_ms": 5000

For existing synchronous behavior, omit homekit_set_ack_timeout_ms or set it to 0.

State script behavior for on_off_switches

  • The state script output is normalized to lowercase and compared against on_value (default "true").
  • on_value should be set to a string and use quotes. Default value is "true".
  • If both fileState and state are configured, fileState takes precedence: the state script is not used for status changes and the configured file flag is used instead.
  • If using fileState your on and off scripts should create the fileState file and delete the fileState file for homekit to see the changes.
  • If a script returns a non-zero exit code but still prints a valid value to stdout (for example true or false), the plugin will use stdout to determine state. You can set fail_on_state_exit_code to true to treat non-zero state exit code as read error.
  • When polling is enabled, the state script is executed on the configured interval and updates HomeKit if the value changes.
  • Polling options are ignored when fileState is configured, since fileState already uses filesystem change notifications to dynamically update homekit status.
  • When state_cache_ttl_ms is greater than 0, state reads are cached briefly to prevent duplicate script executions from burst get requests.
  • By default, manual HomeKit ON/OFF actions do not reset or extend state_cache_ttl_ms. Set reset_state_cache_on_set to true if you want successful manual set actions to reset the TTL timer and seed the cache with the newly set state.
  • If multiple get requests arrive while a state command is already running, they are coalesced and share the same in-flight command result.
  • Each getState request writes a single result log entry in the format GetState <name>: ON/OFF (path: <homekit-get|polling>, source: <state-script|ttl-cache|in-flight-coalesced|file-state>). Where Path is telling you if this was the result of a polling request or a homekit initiated get request (out of the plugin's control). And source is where the value was sourced from, state-script execution result, ttl cache, in-flight coalesced, or from file-state.
  • The TTL cache is per-accessory instance (per configured outlet/switch), not global across all accessories.
  • At startup with polling_on_start: true, the first read for each accessory is a cache miss by design, so one state-script execution per accessory is expected before subsequent reads are served from TTL.

Platform configuration example (recommended)

"platforms": [
{
"platform": "Script2Platform",
"name": "Script2",
"on_off_switches": [
{
"name": "Outlet 1",
"on": "/opt/scripts/on.sh 1",
"off": "/opt/scripts/off.sh 1",
"state": "/opt/scripts/state.sh 1",
"on_value": "true",
"command_timeout": 120000,
"homekit_set_ack_timeout_ms": 5000
},
{
"name": "Outlet 2",
"on": "/opt/scripts/on.sh 2",
"off": "/opt/scripts/off.sh 2",
"fileState": "/opt/scripts/outlet2.flag",
"polling": false
}
],
"stateless_switches": [
{
"name": "Outlet 1 Reboot",
"trigger": "/opt/scripts/reboot.sh 1",
"auto_reset_ms": 500,
"command_timeout": 30000,
"stateless_trigger_on": "off"
},
{
"name": "Outlet 2 Reboot",
"trigger": "/opt/scripts/reboot.sh 2",
"auto_reset_ms": 700,
"stateless_trigger_on": "on"
}
]
}
]

Installation

(Requires Node.js >=20.19.0)

  1. Install homebridge using: npm install -g homebridge
  2. Install this plugin using: npm install -g homebridge-script2
  3. Update your configuration file.
  4. Ensure scripts are executable and accessible by the Homebridge service user.

Troubleshooting FAQ

Why does my script work in terminal but not in Homebridge?

Homebridge runs scripts as the Homebridge service user, not your normal shell user. A script that works as pi, ubuntu, or root may fail as homebridge.

Test your script as the same user that runs Homebridge:

sudo -u homebridge /absolute/path/to/script.sh

If your Homebridge service runs as another user, replace homebridge with that user.

How can I confirm which user Homebridge runs as?

systemctl cat homebridge | grep -i '^User='

If no User= is set, check your service/unit setup and logs to determine runtime context.

Why does Homebridge say it ran the script, but nothing happens?

Most commonly:

  1. Wrong permissions (script or directories not executable/readable by Homebridge user)
  2. Wrong working directory
  3. Missing PATH in service environment
  4. Script exits early due to shell/line-ending issues

Do I need absolute paths?

Yes, strongly recommended. Do not rely on relative paths, ~, or shell-specific startup files.

Use absolute paths for:

  • Script files
  • Referenced files/directories
  • Binaries/interpreters (/usr/bin/python3, /usr/bin/node, etc.)

Example:

{
"on": "/home/homebridge/scripts/light_on.sh",
"off": "/home/homebridge/scripts/light_off.sh"
}

Inside scripts:

#!/usr/bin/env bashset -euo pipefail
cd /home/homebridge/scripts ||exit 1
/usr/bin/python3 /home/homebridge/scripts/device_on.py

How do I verify permissions quickly?

chmod +x /home/homebridge/scripts/light_on.sh
chown homebridge:homebridge /home/homebridge/scripts/light_on.sh

Also make sure the Homebridge user can traverse parent directories (x permission on each directory).

Check with:

namei -l /home/homebridge/scripts/light_on.sh

What is the best “same as Homebridge” test command?

Use the exact command from your config as the Homebridge user:

sudo -u homebridge /home/homebridge/scripts/light_on.sh

If this fails, Homebridge will fail too.

How can I collect script debug logs?

Add logging in your script so errors are visible:

#!/usr/bin/env bashset -euo pipefail
exec>>/tmp/homebridge-script2.log 2>&1echo"[$(date)] Starting light_on.sh as $(whoami) in $(pwd)"
/usr/bin/python3 /home/homebridge/scripts/device_on.py
echo"[$(date)] Done"

Then inspect:

tail -n 100 /tmp/homebridge-script2.log

Could line endings break my script?

Yes. Scripts edited on Windows may have CRLF line endings and fail on Linux.

Convert to LF:

dos2unix /home/homebridge/scripts/light_on.sh

My state works but on/off does not. Why?

This usually means:

  • Status-check command/path is valid
  • Action scripts (on/off) have permission/path/runtime issues

Validate each action script independently as Homebridge user:

sudo -u homebridge /home/homebridge/scripts/light_on.sh
sudo -u homebridge /home/homebridge/scripts/light_off.sh

If I use fileState, what should I check?

  • File path is absolute
  • Homebridge user can create/delete/read that file
  • Parent directory permissions are correct
  • No conflicting process recreates/deletes file unexpectedly

Recommended best practices

  • Always test as Homebridge user before troubleshooting plugin behavior.
  • Always use absolute paths in config and scripts.
  • Add logging and fail-fast flags (set -euo pipefail) in shell scripts.
  • Keep scripts minimal; move complex logic to separate files you can test independently.
  • Restart Homebridge after major script/permission changes to ensure a clean environment.

About

Execute custom scripts via HomeKit apps

Topics

Resources

Stars

98 stars

Watchers

10 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

homebridge-script2

Execute custom scripts via HomeKit / Apple Home using Homebridge.

Core of the code written by @xxcombat. Original plugin: homebridge-script.

Recommended configuration

Use platform mode with:

  • on_off_switches for normal ON/OFF devices
  • stateless_switches for trigger-style devices

Legacy formats are still supported:

  • platform devices array
  • accessory-mode accessories entries

See LEGACY.md for legacy field details, examples, and migration guidance.

Homebridge UI Configuration

  • In Homebridge UI, go to Plugins → homebridge-script2 → Plugin Config.
  • Use the On/Off Switches and Stateless Switches sections.
  • Save and restart Homebridge when prompted.

Platform configuration parameters

NameValueRequiredNotes
on_off_switchesarraynoMain section for standard ON/OFF switches
stateless_switchesarraynoMain section for one-shot trigger switches
devicesarrayno (legacy only)Legacy compatibility list (see LEGACY.md)

on_off_switches item parameters

NameValueRequiredNotes
name(custom)yesAccessory name shown in Home app
on(custom)yesScript/command to execute the ON action
off(custom)yesScript/command to execute the OFF action
fileState(custom)fileState or stateFile flag used as current state; if set, it overrides state
state(custom)fileState or stateScript to determine current ON/OFF state
on_value(custom)no (default "true")Value matched against normalized state output
pollingtrue/falseno (default false)Enables periodic polling for state mode
polling_intervalinteger msno (default 5000)Poll interval when polling is enabled
polling_on_starttrue/falseno (default true)Immediately runs state poll on startup
state_cache_ttl_msinteger msno (default 1000)Cache TTL for burst reads
reset_state_cache_on_settrue/falseno (default false)Resets/seeds state cache after successful manual set
fail_on_state_exit_codetrue/falseno (default false)Treat non-zero state exit code as read error
command_timeoutinteger msno (default 10000)Maximum runtime for ON, OFF, and state commands
homekit_set_ack_timeout_msinteger msno (default 0)Opt in to acknowledging a still-running ON/OFF request after this delay; requires state or fileState
unique_serial(custom)noUnique serial per accessory is recommended

stateless_switches item parameters

NameValueRequiredNotes
name(custom)yesAccessory name shown in Home app
trigger(custom)yesScript/command to execute trigger action
auto_reset_msinteger msno (default 500)Delay before Home tile auto-resets
command_timeoutinteger msno (default 10000)Maximum runtime for the trigger command
stateless_trigger_onon/offno (default on)on triggers on ON; off triggers on OFF (tile defaults to ON)
unique_serial(custom)noUnique serial per accessory is recommended

Command timing and long-running ON/OFF actions

command_timeout and homekit_set_ack_timeout_ms control different deadlines:

  • command_timeout controls how long Script2 allows the external ON, OFF, state, or stateless trigger command to run. A command that exceeds this limit is reported as timed out. Increase it above the command's worst-case runtime for long-running scripts.
  • homekit_set_ack_timeout_ms applies only to stateful ON/OFF switches. Its backward-compatible default is 0, which means the HomeKit set callback waits for actual command completion.
  • Set homekit_set_ack_timeout_ms to a positive integer to opt into early HomeKit acknowledgement. For example, 5000 acknowledges the request after five seconds while the external command continues under command_timeout.
  • Early acknowledgement does not complete or duplicate the external operation: Script2 keeps the command in flight, coalesces duplicate requests, serializes opposite requests, and defers GET/poll presentation updates until the command settles.
  • Optimistic acknowledgement requires state or fileState. If the external command later fails, Script2 bypasses the TTL cache and uses that state source to reconcile HomeKit. Without a state source, early acknowledgement is disabled with a warning.
  • fail_on_state_exit_code is independent of both timeout settings. It controls whether a non-zero state command exit is fatal when the state command still prints usable stdout.

Recommended settings for a stateful command that may take up to two minutes:

"command_timeout": 120000,
"homekit_set_ack_timeout_ms": 5000

For existing synchronous behavior, omit homekit_set_ack_timeout_ms or set it to 0.

State script behavior for on_off_switches

  • The state script output is normalized to lowercase and compared against on_value (default "true").
  • on_value should be set to a string and use quotes. Default value is "true".
  • If both fileState and state are configured, fileState takes precedence: the state script is not used for status changes and the configured file flag is used instead.
  • If using fileState your on and off scripts should create the fileState file and delete the fileState file for homekit to see the changes.
  • If a script returns a non-zero exit code but still prints a valid value to stdout (for example true or false), the plugin will use stdout to determine state. You can set fail_on_state_exit_code to true to treat non-zero state exit code as read error.
  • When polling is enabled, the state script is executed on the configured interval and updates HomeKit if the value changes.
  • Polling options are ignored when fileState is configured, since fileState already uses filesystem change notifications to dynamically update homekit status.
  • When state_cache_ttl_ms is greater than 0, state reads are cached briefly to prevent duplicate script executions from burst get requests.
  • By default, manual HomeKit ON/OFF actions do not reset or extend state_cache_ttl_ms. Set reset_state_cache_on_set to true if you want successful manual set actions to reset the TTL timer and seed the cache with the newly set state.
  • If multiple get requests arrive while a state command is already running, they are coalesced and share the same in-flight command result.
  • Each getState request writes a single result log entry in the format GetState <name>: ON/OFF (path: <homekit-get|polling>, source: <state-script|ttl-cache|in-flight-coalesced|file-state>). Where Path is telling you if this was the result of a polling request or a homekit initiated get request (out of the plugin's control). And source is where the value was sourced from, state-script execution result, ttl cache, in-flight coalesced, or from file-state.
  • The TTL cache is per-accessory instance (per configured outlet/switch), not global across all accessories.
  • At startup with polling_on_start: true, the first read for each accessory is a cache miss by design, so one state-script execution per accessory is expected before subsequent reads are served from TTL.

Platform configuration example (recommended)

"platforms": [
{
"platform": "Script2Platform",
"name": "Script2",
"on_off_switches": [
{
"name": "Outlet 1",
"on": "/opt/scripts/on.sh 1",
"off": "/opt/scripts/off.sh 1",
"state": "/opt/scripts/state.sh 1",
"on_value": "true",
"command_timeout": 120000,
"homekit_set_ack_timeout_ms": 5000
},
{
"name": "Outlet 2",
"on": "/opt/scripts/on.sh 2",
"off": "/opt/scripts/off.sh 2",
"fileState": "/opt/scripts/outlet2.flag",
"polling": false
}
],
"stateless_switches": [
{
"name": "Outlet 1 Reboot",
"trigger": "/opt/scripts/reboot.sh 1",
"auto_reset_ms": 500,
"command_timeout": 30000,
"stateless_trigger_on": "off"
},
{
"name": "Outlet 2 Reboot",
"trigger": "/opt/scripts/reboot.sh 2",
"auto_reset_ms": 700,
"stateless_trigger_on": "on"
}
]
}
]

Installation

(Requires Node.js >=20.19.0)

  1. Install homebridge using: npm install -g homebridge
  2. Install this plugin using: npm install -g homebridge-script2
  3. Update your configuration file.
  4. Ensure scripts are executable and accessible by the Homebridge service user.

Troubleshooting FAQ

Why does my script work in terminal but not in Homebridge?

Homebridge runs scripts as the Homebridge service user, not your normal shell user. A script that works as pi, ubuntu, or root may fail as homebridge.

Test your script as the same user that runs Homebridge:

sudo -u homebridge /absolute/path/to/script.sh

If your Homebridge service runs as another user, replace homebridge with that user.

How can I confirm which user Homebridge runs as?

systemctl cat homebridge | grep -i '^User='

If no User= is set, check your service/unit setup and logs to determine runtime context.

Why does Homebridge say it ran the script, but nothing happens?

Most commonly:

  1. Wrong permissions (script or directories not executable/readable by Homebridge user)
  2. Wrong working directory
  3. Missing PATH in service environment
  4. Script exits early due to shell/line-ending issues

Do I need absolute paths?

Yes, strongly recommended. Do not rely on relative paths, ~, or shell-specific startup files.

Use absolute paths for:

  • Script files
  • Referenced files/directories
  • Binaries/interpreters (/usr/bin/python3, /usr/bin/node, etc.)

Example:

{
"on": "/home/homebridge/scripts/light_on.sh",
"off": "/home/homebridge/scripts/light_off.sh"
}

Inside scripts:

#!/usr/bin/env bashset -euo pipefail
cd /home/homebridge/scripts ||exit 1
/usr/bin/python3 /home/homebridge/scripts/device_on.py

How do I verify permissions quickly?

chmod +x /home/homebridge/scripts/light_on.sh
chown homebridge:homebridge /home/homebridge/scripts/light_on.sh

Also make sure the Homebridge user can traverse parent directories (x permission on each directory).

Check with:

namei -l /home/homebridge/scripts/light_on.sh

What is the best “same as Homebridge” test command?

Use the exact command from your config as the Homebridge user:

sudo -u homebridge /home/homebridge/scripts/light_on.sh

If this fails, Homebridge will fail too.

How can I collect script debug logs?

Add logging in your script so errors are visible:

#!/usr/bin/env bashset -euo pipefail
exec>>/tmp/homebridge-script2.log 2>&1echo"[$(date)] Starting light_on.sh as $(whoami) in $(pwd)"
/usr/bin/python3 /home/homebridge/scripts/device_on.py
echo"[$(date)] Done"

Then inspect:

tail -n 100 /tmp/homebridge-script2.log

Could line endings break my script?

Yes. Scripts edited on Windows may have CRLF line endings and fail on Linux.

Convert to LF:

dos2unix /home/homebridge/scripts/light_on.sh

My state works but on/off does not. Why?

This usually means:

  • Status-check command/path is valid
  • Action scripts (on/off) have permission/path/runtime issues

Validate each action script independently as Homebridge user:

sudo -u homebridge /home/homebridge/scripts/light_on.sh
sudo -u homebridge /home/homebridge/scripts/light_off.sh

If I use fileState, what should I check?

  • File path is absolute
  • Homebridge user can create/delete/read that file
  • Parent directory permissions are correct
  • No conflicting process recreates/deletes file unexpectedly

Recommended best practices

  • Always test as Homebridge user before troubleshooting plugin behavior.
  • Always use absolute paths in config and scripts.
  • Add logging and fail-fast flags (set -euo pipefail) in shell scripts.
  • Keep scripts minimal; move complex logic to separate files you can test independently.
  • Restart Homebridge after major script/permission changes to ensure a clean environment.

About

Execute custom scripts via HomeKit apps

Topics

Resources

Stars

98 stars

Watchers

10 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

homebridge-script2

Execute custom scripts via HomeKit / Apple Home using Homebridge.

Core of the code written by @xxcombat. Original plugin: homebridge-script.

Recommended configuration

Use platform mode with:

  • on_off_switches for normal ON/OFF devices
  • stateless_switches for trigger-style devices

Legacy formats are still supported:

  • platform devices array
  • accessory-mode accessories entries

See LEGACY.md for legacy field details, examples, and migration guidance.

Homebridge UI Configuration

  • In Homebridge UI, go to Plugins → homebridge-script2 → Plugin Config.
  • Use the On/Off Switches and Stateless Switches sections.
  • Save and restart Homebridge when prompted.

Platform configuration parameters

NameValueRequiredNotes
on_off_switchesarraynoMain section for standard ON/OFF switches
stateless_switchesarraynoMain section for one-shot trigger switches
devicesarrayno (legacy only)Legacy compatibility list (see LEGACY.md)

on_off_switches item parameters

NameValueRequiredNotes
name(custom)yesAccessory name shown in Home app
on(custom)yesScript/command to execute the ON action
off(custom)yesScript/command to execute the OFF action
fileState(custom)fileState or stateFile flag used as current state; if set, it overrides state
state(custom)fileState or stateScript to determine current ON/OFF state
on_value(custom)no (default "true")Value matched against normalized state output
pollingtrue/falseno (default false)Enables periodic polling for state mode
polling_intervalinteger msno (default 5000)Poll interval when polling is enabled
polling_on_starttrue/falseno (default true)Immediately runs state poll on startup
state_cache_ttl_msinteger msno (default 1000)Cache TTL for burst reads
reset_state_cache_on_settrue/falseno (default false)Resets/seeds state cache after successful manual set
fail_on_state_exit_codetrue/falseno (default false)Treat non-zero state exit code as read error
command_timeoutinteger msno (default 10000)Maximum runtime for ON, OFF, and state commands
homekit_set_ack_timeout_msinteger msno (default 0)Opt in to acknowledging a still-running ON/OFF request after this delay; requires state or fileState
unique_serial(custom)noUnique serial per accessory is recommended

stateless_switches item parameters

NameValueRequiredNotes
name(custom)yesAccessory name shown in Home app
trigger(custom)yesScript/command to execute trigger action
auto_reset_msinteger msno (default 500)Delay before Home tile auto-resets
command_timeoutinteger msno (default 10000)Maximum runtime for the trigger command
stateless_trigger_onon/offno (default on)on triggers on ON; off triggers on OFF (tile defaults to ON)
unique_serial(custom)noUnique serial per accessory is recommended

Command timing and long-running ON/OFF actions

command_timeout and homekit_set_ack_timeout_ms control different deadlines:

  • command_timeout controls how long Script2 allows the external ON, OFF, state, or stateless trigger command to run. A command that exceeds this limit is reported as timed out. Increase it above the command's worst-case runtime for long-running scripts.
  • homekit_set_ack_timeout_ms applies only to stateful ON/OFF switches. Its backward-compatible default is 0, which means the HomeKit set callback waits for actual command completion.
  • Set homekit_set_ack_timeout_ms to a positive integer to opt into early HomeKit acknowledgement. For example, 5000 acknowledges the request after five seconds while the external command continues under command_timeout.
  • Early acknowledgement does not complete or duplicate the external operation: Script2 keeps the command in flight, coalesces duplicate requests, serializes opposite requests, and defers GET/poll presentation updates until the command settles.
  • Optimistic acknowledgement requires state or fileState. If the external command later fails, Script2 bypasses the TTL cache and uses that state source to reconcile HomeKit. Without a state source, early acknowledgement is disabled with a warning.
  • fail_on_state_exit_code is independent of both timeout settings. It controls whether a non-zero state command exit is fatal when the state command still prints usable stdout.

Recommended settings for a stateful command that may take up to two minutes:

"command_timeout": 120000,
"homekit_set_ack_timeout_ms": 5000

For existing synchronous behavior, omit homekit_set_ack_timeout_ms or set it to 0.

State script behavior for on_off_switches

  • The state script output is normalized to lowercase and compared against on_value (default "true").
  • on_value should be set to a string and use quotes. Default value is "true".
  • If both fileState and state are configured, fileState takes precedence: the state script is not used for status changes and the configured file flag is used instead.
  • If using fileState your on and off scripts should create the fileState file and delete the fileState file for homekit to see the changes.
  • If a script returns a non-zero exit code but still prints a valid value to stdout (for example true or false), the plugin will use stdout to determine state. You can set fail_on_state_exit_code to true to treat non-zero state exit code as read error.
  • When polling is enabled, the state script is executed on the configured interval and updates HomeKit if the value changes.
  • Polling options are ignored when fileState is configured, since fileState already uses filesystem change notifications to dynamically update homekit status.
  • When state_cache_ttl_ms is greater than 0, state reads are cached briefly to prevent duplicate script executions from burst get requests.
  • By default, manual HomeKit ON/OFF actions do not reset or extend state_cache_ttl_ms. Set reset_state_cache_on_set to true if you want successful manual set actions to reset the TTL timer and seed the cache with the newly set state.
  • If multiple get requests arrive while a state command is already running, they are coalesced and share the same in-flight command result.
  • Each getState request writes a single result log entry in the format GetState <name>: ON/OFF (path: <homekit-get|polling>, source: <state-script|ttl-cache|in-flight-coalesced|file-state>). Where Path is telling you if this was the result of a polling request or a homekit initiated get request (out of the plugin's control). And source is where the value was sourced from, state-script execution result, ttl cache, in-flight coalesced, or from file-state.
  • The TTL cache is per-accessory instance (per configured outlet/switch), not global across all accessories.
  • At startup with polling_on_start: true, the first read for each accessory is a cache miss by design, so one state-script execution per accessory is expected before subsequent reads are served from TTL.

Platform configuration example (recommended)

"platforms": [
{
"platform": "Script2Platform",
"name": "Script2",
"on_off_switches": [
{
"name": "Outlet 1",
"on": "/opt/scripts/on.sh 1",
"off": "/opt/scripts/off.sh 1",
"state": "/opt/scripts/state.sh 1",
"on_value": "true",
"command_timeout": 120000,
"homekit_set_ack_timeout_ms": 5000
},
{
"name": "Outlet 2",
"on": "/opt/scripts/on.sh 2",
"off": "/opt/scripts/off.sh 2",
"fileState": "/opt/scripts/outlet2.flag",
"polling": false
}
],
"stateless_switches": [
{
"name": "Outlet 1 Reboot",
"trigger": "/opt/scripts/reboot.sh 1",
"auto_reset_ms": 500,
"command_timeout": 30000,
"stateless_trigger_on": "off"
},
{
"name": "Outlet 2 Reboot",
"trigger": "/opt/scripts/reboot.sh 2",
"auto_reset_ms": 700,
"stateless_trigger_on": "on"
}
]
}
]

Installation

(Requires Node.js >=20.19.0)

  1. Install homebridge using: npm install -g homebridge
  2. Install this plugin using: npm install -g homebridge-script2
  3. Update your configuration file.
  4. Ensure scripts are executable and accessible by the Homebridge service user.

Troubleshooting FAQ

Why does my script work in terminal but not in Homebridge?

Homebridge runs scripts as the Homebridge service user, not your normal shell user. A script that works as pi, ubuntu, or root may fail as homebridge.

Test your script as the same user that runs Homebridge:

sudo -u homebridge /absolute/path/to/script.sh

If your Homebridge service runs as another user, replace homebridge with that user.

How can I confirm which user Homebridge runs as?

systemctl cat homebridge | grep -i '^User='

If no User= is set, check your service/unit setup and logs to determine runtime context.

Why does Homebridge say it ran the script, but nothing happens?

Most commonly:

  1. Wrong permissions (script or directories not executable/readable by Homebridge user)
  2. Wrong working directory
  3. Missing PATH in service environment
  4. Script exits early due to shell/line-ending issues

Do I need absolute paths?

Yes, strongly recommended. Do not rely on relative paths, ~, or shell-specific startup files.

Use absolute paths for:

  • Script files
  • Referenced files/directories
  • Binaries/interpreters (/usr/bin/python3, /usr/bin/node, etc.)

Example:

{
"on": "/home/homebridge/scripts/light_on.sh",
"off": "/home/homebridge/scripts/light_off.sh"
}

Inside scripts:

#!/usr/bin/env bashset -euo pipefail
cd /home/homebridge/scripts ||exit 1
/usr/bin/python3 /home/homebridge/scripts/device_on.py

How do I verify permissions quickly?

chmod +x /home/homebridge/scripts/light_on.sh
chown homebridge:homebridge /home/homebridge/scripts/light_on.sh

Also make sure the Homebridge user can traverse parent directories (x permission on each directory).

Check with:

namei -l /home/homebridge/scripts/light_on.sh

What is the best “same as Homebridge” test command?

Use the exact command from your config as the Homebridge user:

sudo -u homebridge /home/homebridge/scripts/light_on.sh

If this fails, Homebridge will fail too.

How can I collect script debug logs?

Add logging in your script so errors are visible:

#!/usr/bin/env bashset -euo pipefail
exec>>/tmp/homebridge-script2.log 2>&1echo"[$(date)] Starting light_on.sh as $(whoami) in $(pwd)"
/usr/bin/python3 /home/homebridge/scripts/device_on.py
echo"[$(date)] Done"

Then inspect:

tail -n 100 /tmp/homebridge-script2.log

Could line endings break my script?

Yes. Scripts edited on Windows may have CRLF line endings and fail on Linux.

Convert to LF:

dos2unix /home/homebridge/scripts/light_on.sh

My state works but on/off does not. Why?

This usually means:

  • Status-check command/path is valid
  • Action scripts (on/off) have permission/path/runtime issues

Validate each action script independently as Homebridge user:

sudo -u homebridge /home/homebridge/scripts/light_on.sh
sudo -u homebridge /home/homebridge/scripts/light_off.sh

If I use fileState, what should I check?

  • File path is absolute
  • Homebridge user can create/delete/read that file
  • Parent directory permissions are correct
  • No conflicting process recreates/deletes file unexpectedly

Recommended best practices

  • Always test as Homebridge user before troubleshooting plugin behavior.
  • Always use absolute paths in config and scripts.
  • Add logging and fail-fast flags (set -euo pipefail) in shell scripts.
  • Keep scripts minimal; move complex logic to separate files you can test independently.
  • Restart Homebridge after major script/permission changes to ensure a clean environment.

About

Execute custom scripts via HomeKit apps

Topics

Resources

Stars

98 stars

Watchers

10 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

homebridge-script2

Execute custom scripts via HomeKit / Apple Home using Homebridge.

Core of the code written by @xxcombat. Original plugin: homebridge-script.

Recommended configuration

Use platform mode with:

  • on_off_switches for normal ON/OFF devices
  • stateless_switches for trigger-style devices

Legacy formats are still supported:

  • platform devices array
  • accessory-mode accessories entries

See LEGACY.md for legacy field details, examples, and migration guidance.

Homebridge UI Configuration

  • In Homebridge UI, go to Plugins → homebridge-script2 → Plugin Config.
  • Use the On/Off Switches and Stateless Switches sections.
  • Save and restart Homebridge when prompted.

Platform configuration parameters

NameValueRequiredNotes
on_off_switchesarraynoMain section for standard ON/OFF switches
stateless_switchesarraynoMain section for one-shot trigger switches
devicesarrayno (legacy only)Legacy compatibility list (see LEGACY.md)

on_off_switches item parameters

NameValueRequiredNotes
name(custom)yesAccessory name shown in Home app
on(custom)yesScript/command to execute the ON action
off(custom)yesScript/command to execute the OFF action
fileState(custom)fileState or stateFile flag used as current state; if set, it overrides state
state(custom)fileState or stateScript to determine current ON/OFF state
on_value(custom)no (default "true")Value matched against normalized state output
pollingtrue/falseno (default false)Enables periodic polling for state mode
polling_intervalinteger msno (default 5000)Poll interval when polling is enabled
polling_on_starttrue/falseno (default true)Immediately runs state poll on startup
state_cache_ttl_msinteger msno (default 1000)Cache TTL for burst reads
reset_state_cache_on_settrue/falseno (default false)Resets/seeds state cache after successful manual set
fail_on_state_exit_codetrue/falseno (default false)Treat non-zero state exit code as read error
command_timeoutinteger msno (default 10000)Maximum runtime for ON, OFF, and state commands
homekit_set_ack_timeout_msinteger msno (default 0)Opt in to acknowledging a still-running ON/OFF request after this delay; requires state or fileState
unique_serial(custom)noUnique serial per accessory is recommended

stateless_switches item parameters

NameValueRequiredNotes
name(custom)yesAccessory name shown in Home app
trigger(custom)yesScript/command to execute trigger action
auto_reset_msinteger msno (default 500)Delay before Home tile auto-resets
command_timeoutinteger msno (default 10000)Maximum runtime for the trigger command
stateless_trigger_onon/offno (default on)on triggers on ON; off triggers on OFF (tile defaults to ON)
unique_serial(custom)noUnique serial per accessory is recommended

Command timing and long-running ON/OFF actions

command_timeout and homekit_set_ack_timeout_ms control different deadlines:

  • command_timeout controls how long Script2 allows the external ON, OFF, state, or stateless trigger command to run. A command that exceeds this limit is reported as timed out. Increase it above the command's worst-case runtime for long-running scripts.
  • homekit_set_ack_timeout_ms applies only to stateful ON/OFF switches. Its backward-compatible default is 0, which means the HomeKit set callback waits for actual command completion.
  • Set homekit_set_ack_timeout_ms to a positive integer to opt into early HomeKit acknowledgement. For example, 5000 acknowledges the request after five seconds while the external command continues under command_timeout.
  • Early acknowledgement does not complete or duplicate the external operation: Script2 keeps the command in flight, coalesces duplicate requests, serializes opposite requests, and defers GET/poll presentation updates until the command settles.
  • Optimistic acknowledgement requires state or fileState. If the external command later fails, Script2 bypasses the TTL cache and uses that state source to reconcile HomeKit. Without a state source, early acknowledgement is disabled with a warning.
  • fail_on_state_exit_code is independent of both timeout settings. It controls whether a non-zero state command exit is fatal when the state command still prints usable stdout.

Recommended settings for a stateful command that may take up to two minutes:

"command_timeout": 120000,
"homekit_set_ack_timeout_ms": 5000

For existing synchronous behavior, omit homekit_set_ack_timeout_ms or set it to 0.

State script behavior for on_off_switches

  • The state script output is normalized to lowercase and compared against on_value (default "true").
  • on_value should be set to a string and use quotes. Default value is "true".
  • If both fileState and state are configured, fileState takes precedence: the state script is not used for status changes and the configured file flag is used instead.
  • If using fileState your on and off scripts should create the fileState file and delete the fileState file for homekit to see the changes.
  • If a script returns a non-zero exit code but still prints a valid value to stdout (for example true or false), the plugin will use stdout to determine state. You can set fail_on_state_exit_code to true to treat non-zero state exit code as read error.
  • When polling is enabled, the state script is executed on the configured interval and updates HomeKit if the value changes.
  • Polling options are ignored when fileState is configured, since fileState already uses filesystem change notifications to dynamically update homekit status.
  • When state_cache_ttl_ms is greater than 0, state reads are cached briefly to prevent duplicate script executions from burst get requests.
  • By default, manual HomeKit ON/OFF actions do not reset or extend state_cache_ttl_ms. Set reset_state_cache_on_set to true if you want successful manual set actions to reset the TTL timer and seed the cache with the newly set state.
  • If multiple get requests arrive while a state command is already running, they are coalesced and share the same in-flight command result.
  • Each getState request writes a single result log entry in the format GetState <name>: ON/OFF (path: <homekit-get|polling>, source: <state-script|ttl-cache|in-flight-coalesced|file-state>). Where Path is telling you if this was the result of a polling request or a homekit initiated get request (out of the plugin's control). And source is where the value was sourced from, state-script execution result, ttl cache, in-flight coalesced, or from file-state.
  • The TTL cache is per-accessory instance (per configured outlet/switch), not global across all accessories.
  • At startup with polling_on_start: true, the first read for each accessory is a cache miss by design, so one state-script execution per accessory is expected before subsequent reads are served from TTL.

Platform configuration example (recommended)

"platforms": [
{
"platform": "Script2Platform",
"name": "Script2",
"on_off_switches": [
{
"name": "Outlet 1",
"on": "/opt/scripts/on.sh 1",
"off": "/opt/scripts/off.sh 1",
"state": "/opt/scripts/state.sh 1",
"on_value": "true",
"command_timeout": 120000,
"homekit_set_ack_timeout_ms": 5000
},
{
"name": "Outlet 2",
"on": "/opt/scripts/on.sh 2",
"off": "/opt/scripts/off.sh 2",
"fileState": "/opt/scripts/outlet2.flag",
"polling": false
}
],
"stateless_switches": [
{
"name": "Outlet 1 Reboot",
"trigger": "/opt/scripts/reboot.sh 1",
"auto_reset_ms": 500,
"command_timeout": 30000,
"stateless_trigger_on": "off"
},
{
"name": "Outlet 2 Reboot",
"trigger": "/opt/scripts/reboot.sh 2",
"auto_reset_ms": 700,
"stateless_trigger_on": "on"
}
]
}
]

Installation

(Requires Node.js >=20.19.0)

  1. Install homebridge using: npm install -g homebridge
  2. Install this plugin using: npm install -g homebridge-script2
  3. Update your configuration file.
  4. Ensure scripts are executable and accessible by the Homebridge service user.

Troubleshooting FAQ

Why does my script work in terminal but not in Homebridge?

Homebridge runs scripts as the Homebridge service user, not your normal shell user. A script that works as pi, ubuntu, or root may fail as homebridge.

Test your script as the same user that runs Homebridge:

sudo -u homebridge /absolute/path/to/script.sh

If your Homebridge service runs as another user, replace homebridge with that user.

How can I confirm which user Homebridge runs as?

systemctl cat homebridge | grep -i '^User='

If no User= is set, check your service/unit setup and logs to determine runtime context.

Why does Homebridge say it ran the script, but nothing happens?

Most commonly:

  1. Wrong permissions (script or directories not executable/readable by Homebridge user)
  2. Wrong working directory
  3. Missing PATH in service environment
  4. Script exits early due to shell/line-ending issues

Do I need absolute paths?

Yes, strongly recommended. Do not rely on relative paths, ~, or shell-specific startup files.

Use absolute paths for:

  • Script files
  • Referenced files/directories
  • Binaries/interpreters (/usr/bin/python3, /usr/bin/node, etc.)

Example:

{
"on": "/home/homebridge/scripts/light_on.sh",
"off": "/home/homebridge/scripts/light_off.sh"
}

Inside scripts:

#!/usr/bin/env bashset -euo pipefail
cd /home/homebridge/scripts ||exit 1
/usr/bin/python3 /home/homebridge/scripts/device_on.py

How do I verify permissions quickly?

chmod +x /home/homebridge/scripts/light_on.sh
chown homebridge:homebridge /home/homebridge/scripts/light_on.sh

Also make sure the Homebridge user can traverse parent directories (x permission on each directory).

Check with:

namei -l /home/homebridge/scripts/light_on.sh

What is the best “same as Homebridge” test command?

Use the exact command from your config as the Homebridge user:

sudo -u homebridge /home/homebridge/scripts/light_on.sh

If this fails, Homebridge will fail too.

How can I collect script debug logs?

Add logging in your script so errors are visible:

#!/usr/bin/env bashset -euo pipefail
exec>>/tmp/homebridge-script2.log 2>&1echo"[$(date)] Starting light_on.sh as $(whoami) in $(pwd)"
/usr/bin/python3 /home/homebridge/scripts/device_on.py
echo"[$(date)] Done"

Then inspect:

tail -n 100 /tmp/homebridge-script2.log

Could line endings break my script?

Yes. Scripts edited on Windows may have CRLF line endings and fail on Linux.

Convert to LF:

dos2unix /home/homebridge/scripts/light_on.sh

My state works but on/off does not. Why?

This usually means:

  • Status-check command/path is valid
  • Action scripts (on/off) have permission/path/runtime issues

Validate each action script independently as Homebridge user:

sudo -u homebridge /home/homebridge/scripts/light_on.sh
sudo -u homebridge /home/homebridge/scripts/light_off.sh

If I use fileState, what should I check?

  • File path is absolute
  • Homebridge user can create/delete/read that file
  • Parent directory permissions are correct
  • No conflicting process recreates/deletes file unexpectedly

Recommended best practices

  • Always test as Homebridge user before troubleshooting plugin behavior.
  • Always use absolute paths in config and scripts.
  • Add logging and fail-fast flags (set -euo pipefail) in shell scripts.
  • Keep scripts minimal; move complex logic to separate files you can test independently.
  • Restart Homebridge after major script/permission changes to ensure a clean environment.

About

Execute custom scripts via HomeKit apps

Topics

Resources

Stars

98 stars

Watchers

10 watching

Forks

Releases

Packages

Used by

Contributors

Languages