macOS daemon that exposes the Mac's ON/OFF state to Homebridge (plugin http-webhooks), and accepts ON/OFF commands coming from HomeKit.
Code comments, logs and diagnostic output are in French.
HomeKit ──► Homebridge ──► GET /wake | /sleep ──► macstatusd ──► macOS
▲ GET /state ◄──────────────┤
└───────── webhook push (state=true|false) ◄────────┘
| What the Mac is actually showing | State |
|---|---|
| Unlocked desktop, in an app | ON |
| Lock screen visible (password field) | ON |
| Login window (nobody logged in), display on | ON |
| Screen saver running | OFF |
| Display off (display sleep) | OFF |
| System sleep | OFF |
| No display connected | OFF |
In other words: ON = a user interface is visible and usable, OFF = nothing on screen.
Version 4 inferred the state by reading loginwindow's private log messages
(log stream --predicate 'process == "loginwindow"') and looking for strings
such as screenLockUIIsHidden or updatePlaceholderString. That was the root
cause of the problem: those strings are not an API, they change from one macOS
release to the next, a message split across two 4 KB reads is lost, and a flag
stuck at true freezes the state forever.
Version 5 no longer reads any log. Every fact comes from an observable API, with an independent fallback source:
| Fact | Primary source | Fallback |
|---|---|---|
| System sleep | IOKit IORegisterForSystemPower (event) |
NSWorkspace.didWake |
| Display off | CGDisplayIsAsleep (main display) |
all online displays asleep + screensDidSleep/Wake |
| Screen saver running | ScreenSaverEngine process |
on-screen window at kCGScreenSaverWindowLevel or above, owned by a screen saver process; com.apple.screensaver.didstart/didstop notifications |
| Screen saver alone on screen | IOHIDSystem.HIDIdleTime compared with its start time |
— (see below) |
| Session locked | IORegistry IOConsoleLocked / IOConsoleUsers |
CGSessionCopyCurrentDictionary |
This is the main trap, measured on macOS 26.5 (diagnostics/saver-probe.swift):
when a keystroke brings up the password field, the screen saver keeps running
behind the panel. In that state:
- the
ScreenSaverEngineprocess stays alive; SACScreenSaverIsRunning(Apple private API) still returns1;com.apple.screensaver.didstopis only posted on unlock;- the list of on-screen windows is unchanged;
IsSecureEventInputEnabledsays nothing useful:loginwindowcan hold secure input long after an unlock.
The only signal that changes is hardware activity. Since on macOS any keystroke or movement dismisses the screen saver, the rule is:
the screen saver occupies the screen if it is running and no keyboard / mouse activity has happened since it started.
HIDIdleTime is readable while the session is locked, which makes the rule
usable exactly where it is needed. Prolonged inactivity
(saver_redisplay_idle_seconds, 90 s) re-arms the screen saver, since macOS
goes back to it when the panel is left unanswered.
saver_dismiss_on_input: false disables the rule, and --check-rules verifies
it without a real screen saver.
Resulting properties:
- No stored state that can get stuck. Facts are re-read on every cycle; notifications only make reactions faster, they are never the sole source of truth.
- Explicit debouncing. A change is only published after a stability period
(
settle_on_ms/settle_off_ms), which absorbs transient states (a one-second black screen while locking, for example). - Self-healing. A gap in the polling loop (sleep, freeze, overload) is detected and triggers a full resync.
- OFF pushed before sleep. IOKit lets the daemon hold off sleep long enough to send the webhook; otherwise HomeKit would stay ON during the whole sleep.
- Commands verified against facts. A HomeKit command with no observable effect is escalated, then abandoned with a warning, and the state goes back to reality instead of lying.
- Heartbeat. The state is republished periodically: a lost webhook or a restarted Homebridge cannot leave HomeKit out of sync.
The "locked with no field visible" case is treated as ON by default (the lock
screen is an accessible UI). require_auth_ui_when_locked: true reverses that
choice by requiring secure input to be active.
./scripts/install.shWithout sudo. The script elevates privileges itself for /opt/macstatusd
and nothing else: run entirely as root, $UID is 0 and
launchctl bootstrap gui/0 fails with "Domain does not support specified
action" (a LaunchAgent belongs to a user's graphical session). If you call it
with sudo anyway, it re-runs itself as $SUDO_USER.
It builds in release mode, installs /opt/macstatusd/macstatusd, creates
/opt/macstatusd/config.json if it doesn't exist, then loads the LaunchAgent
~/Library/LaunchAgents/com.majid.macstatusd.plist and checks that the endpoint
responds.
LaunchAgent (Aqua session), not LaunchDaemon: that is what gives access to
display state, screen saver notifications and secure input. Consequence:
macstatusd doesn't run before login (after a reboot, Homebridge can't read
/state until someone has logged in). Session locking, on the other hand, is
read through the IORegistry and would also work from a LaunchDaemon.
Uninstall: ./scripts/uninstall.sh (add --purge to also remove the
configuration and logs).
| Route | Effect |
|---|---|
GET /state |
1 (ON) or 0 (OFF) — the published state, identical to the last webhook |
GET /status |
JSON diagnostics: raw facts, evidence, pending command, webhook state |
GET /health |
OK |
GET /sleep (/off) |
HomeKit OFF command |
GET /wake (/on) |
HomeKit ON command |
GET /resync |
republishes the current state to Homebridge |
If auth_token is set, /sleep, /wake and /resync require ?token=… or
the X-Auth-Token header. /state, /status and /health stay public
(Homebridge reads /state without a token).
/opt/macstatusd/config.json — every key is optional; a missing or invalid key
falls back to its default without preventing startup.
| Key | Default | Purpose |
|---|---|---|
enabled |
false |
enables webhooks to Homebridge |
webhook_base_url |
"" |
e.g. http://192.168.1.89:51828 |
accessory_id |
"mac" |
accessoryId sent to the plugin |
port |
9090 |
HTTP port |
bind_address |
"" |
"" = all interfaces, 127.0.0.1 = loopback |
auth_token |
"" |
protects commands |
off_action |
"screensaver" |
screensaver, display_sleep or system_sleep |
off_escalate_to_display_sleep |
true |
if the OFF action has no observed effect |
stop_screensaver_on_wake |
true |
terminates the screen saver on ON |
command_confirm_timeout_ms |
12000 |
delay before giving up on a command |
command_escalate_after_ms |
2500 |
delay before escalating a command with no effect |
saver_dismiss_on_input |
true |
activity after the screen saver started → ON |
saver_dismiss_grace_ms |
1500 |
margin ignored right after the start |
saver_redisplay_idle_seconds |
90 |
inactivity after which the screen saver is deemed back on screen |
poll_interval_ms |
500 |
how often facts are re-read |
settle_on_ms / settle_off_ms |
300 / 800 |
stability required before publishing |
heartbeat_seconds |
60 |
periodic republication (0 = disabled) |
require_auth_ui_when_locked |
false |
locked with no field visible → OFF |
webhook_timeout_ms / webhook_retries |
4000 / 3 |
delivery robustness |
log_level |
"info" |
error, warn, info, debug |
log_file |
"" |
"" = ~/Library/Logs/macstatusd/macstatusd.log |
off_command / wake_command |
[] |
replace the built-in action (argv, or a string passed to sh -c) |
off_action decides what HomeKit → OFF does:
screensaver(default) — starts the screen saver. The Mac stays awake and reachable, so HomeKit ON can actually turn it back on.display_sleep— turns the display off (pmset displaysleepnow). Same property.system_sleep— real sleep (pmset sleepnow). Beware: during sleep the daemon is frozen and/wakeis never received; Homebridge needs Wake-on-LAN to turn the Mac back on.
If the session is already locked, screensaver turns the display off instead:
measured on macOS 26, open -a ScreenSaverEngine has no effect on a locked
session. An OFF received while the Mac is already OFF triggers no action.
In all three cases the published state becomes OFF, and the command is only considered successful once the facts confirm it.
Plugin homebridge-http-webhooks, switch accessory:
{
"platform": "HttpWebHooks",
"webhook_port": "51828",
"switches": [
{
"id": "mac",
"name": "Mac",
"on_url": "http://<mac-ip>:9090/wake",
"on_method": "GET",
"off_url": "http://<mac-ip>:9090/sleep",
"off_method": "GET"
}
]
}webhook_base_url in config.json must point to that webhook_port, and
accessory_id must match id.
/opt/macstatusd/macstatusd --once # state + facts as JSON, then exits
/opt/macstatusd/macstatusd --watch # continuous table of facts
curl -s localhost:9090/status # full view of the running instance
tail -f ~/Library/Logs/macstatusd/macstatusd.log
launchctl print gui/$UID/com.majid.macstatusdreason in /status tells which rule decided: desktop-ui, lock-screen-ui,
login-window-ui, display-asleep, system-asleep, no-display,
locked-without-auth-ui, screensaver:<evidence> (for example
screensaver:process+notification), command:on, command:off/<action>.
./scripts/selftest.sh # 30 checks, without disturbing the session
/opt/macstatusd/macstatusd --check-rules # screen saver rule, pure logic
./scripts/validate-live.sh # guided validation through the real states
# Diagnostic probe: triggers a real OFF (screen saver) at t≈4 s
swiftc -O -o /tmp/saver-probe diagnostics/saver-probe.swift && /tmp/saver-probe 60selftest.sh simulates the screen saver with a fake binary named
ScreenSaverEngine (exactly what the "process" oracle observes) and a local
HTTP server playing Homebridge; it also checks the honest fallback of a command
with no effect, authentication, HTTP errors, webhook retries and server recovery
when the port is already taken.
validate-live.sh (run it without sudo) asks you to perform the real actions
(lock, start the screen saver, turn the display off, sleep) and automatically
checks what macstatusd reported during each one.
- System sleep: nothing runs during sleep.
/stateis unreachable and/wakecan't wake the Mac — Wake-on-LAN is needed. That's whyoff_actiondefaults toscreensaver. - Before login: the LaunchAgent isn't loaded yet.
- Multiple users: two open sessions mean two instances for a single port; the second one retries in a loop without crashing, but the published state is the one from the instance holding the port.
- External display physically switched off: macOS considers it on, so the state stays ON.
- Screen savers hosted by
legacyScreenSaver: detected by the window oracle, which requires the graphical session (hence the LaunchAgent). - Esc on the lock screen: dismissing the authentication overlay with Esc
doesn't bring the state back to OFF right away; it returns to OFF after
saver_redisplay_idle_seconds(90 s) of inactivity, or as soon as the display sleeps. No signal telling the hidden overlay apart from the displayed one has been identified yet (investigation ongoing withdiagnostics/saver-probe.swift).
Versionning/ and backtest+debug/ contain versions 1 to 4.4.4 and the capture
tools that were used to identify the usable signals. macstatusdV4.4.4.swift
stays at the root for reference; v5 doesn't replace it on disk.
MIT — see LICENSE.