Fades your monitor to black when you leave the desk, and brings it back before you've finished sitting down. macOS, one cheap radar sensor, no camera.
A 24GHz mmWave module watches the chair over USB serial. When it stops seeing a person, the daemon ramps the display's brightness down through BetterDisplay. Any key or mouse movement wakes it in a quarter second.
It reads breathing-level micro-motion, which is the whole reason for radar rather than a PIR motion sensor: it still sees you when you're sitting perfectly still reading, which is the exact case where a PIR decides you've left and kills the screen.
Requires: macOS on Apple Silicon or Intel, Python 3.9+, BetterDisplay running, and a display BetterDisplay can control. See PARTS.md for the shopping list and MONITOR.md for the display side.
git clone https://github.com/thejobot/presence-dim.git
cd presence-dim
python3 -m venv .venv
./.venv/bin/pip install -r requirements.txt
cp config.example.json config.json
Then edit config.json and put your display's name in displays. Get the
exact name with:
betterdisplaycli get --identifiers
Plug the sensor in, then watch raw radar readings:
./.venv/bin/python presence_dim.py --probe
You'll get a live stream like:
state=still distance= 72cm energy= 78 PRESENT
state=none distance= 0cm energy= 0 -
Sit normally and note the distance. Then get up, walk behind the chair, and
note what it reads for someone passing by rather than sitting. Set
max_distance_cm between those two numbers. That's what stops someone walking
through the room from waking the screen.
Then run it in the foreground for a while:
./.venv/bin/python presence_dim.py
When you're happy, install it as a login item. Edit the paths in
local.presence-dim.plist to match where you cloned it, then:
cp local.presence-dim.plist ~/Library/LaunchAgents/
launchctl load ~/Library/LaunchAgents/local.presence-dim.plist
Stop it with launchctl unload on the same path.
Vendor pages warn about installing drivers. That's a Windows problem. These
boards use a CH340 or a CP2102, and macOS ships Apple's own drivers for both,
plus FTDI, in /System/Library/DriverExtensions/:
com.apple.DriverKit-AppleUSBCHCOM.dext CH34x
com.apple.DriverKit-AppleUSBSLCOM.dext CP210x
com.apple.DriverKit-AppleUSBFTDI.dext FTDI
Either chip appears as /dev/cu.usbserial-*. Install nothing.
Plugged C-to-C into a modern Mac, the LD2410 test carrier is completely inert: no LED, no CH340E on the USB bus, no Bluetooth advertisement from the radar module. It looks exactly like dead hardware. It isn't.
The carrier omits the two 5.1k pull-down resistors on the Type-C CC pins that a USB-C source requires before it will switch on VBUS, so a USB-C host never delivers power at all. A USB-A port has 5V present unconditionally, so the same board comes straight up. Confirmed on an M4 Pro Mac mini: dead on the mini's USB-C, alive on a dock's USB-A within seconds.
Don't send it back. A proven-good cable and port will still show you nothing, because the fault is the board declining to ask for power, not the link failing to carry it.
If your Mac has no USB-A port, you need a dock or hub that provides one.
The LD2410 really does talk at 256000, but Apple's CH34x driver quantizes the rate you request, and 256000 lands just outside the window where sampling is correct.
The failure is subtle rather than obvious. Framing stays perfect — every frame
is exactly 23 bytes with intact f4f3f2f1 header and f8f7f6f5 tail, because
those bytes already have their top bit set — while payload bytes get their MSB
flipped on. aa arrives as ca, 00 as 80, so distances come out as
nonsense like 32984cm and energies exceed the 0-100 the radar can emit.
The measured clean window on this driver is 264000 to 284000. config.json
asks for 274000, the middle of it, which yields 100% clean frames at the full
10 Hz.
To re-derive it on other hardware, sweep requested rates and score frames on
f[4:8] == 0d 00 02 aa and f[17:19] == 55 00. Perfect framing with corrupt
payload bits always means a baud offset, never bad wiring or a bad sensor.
It does not need a case. It's a dev board and works fine bare.
Aim is everything, and it is the whole ballgame. The antenna fires perpendicular to the flat face, so the flat face points at your chest, not edge-on, not at the ceiling. Lying flat on the desk it fired upward and, across 36,000 samples over a full hour, never once registered a person sitting right in front of it — it only ever saw a wall at 220cm. Propped up facing the chair, the same sensor read a seated body at 30-90cm instantly. Nothing else changed. A holder that gets the angle wrong makes the sensor useless in a way that looks exactly like broken hardware.
Numbers to design a mount against, all from real captures rather than the datasheet:
hand waved 10cm from the face 30cm, moving energy 100, instant
seated, board aimed at chest 30-90cm, median ~75cm, energy 100
empty chair, same aim steady ~248cm, never inside the gate
empty room, board flat 194-300cm, nothing ever below 194cm
closest reading ever observed 30cm
frame rate 10 Hz
Practical consequences:
- Point the flat face at where your chest sits, not at the chair back, not at the desk surface. Torso height, angled up if the mount sits low.
- Keep it at least ~30cm from your body. 30cm behaves like a floor.
- An empty chair is not a false trigger — it read 248cm and passed the gate 0% of the time across 274 samples. You do not need to aim around the chair.
- The underside has exposed pads, and monitor chassis and docks are metal. Put something non-conductive in between: tape, a foam pad, a folded card.
- If you enclose it, plastic only. 24GHz passes through ABS, PLA or PETG at a millimetre or two, which is why these get hidden behind wall plates. It does not pass through metal or metallic paint. Leave a small air gap in front of the antenna rather than pressing material against it.
- These modules have a back lobe and pick up some motion behind them. If
--probeshows it triggering on things happening behind the monitor, a small metal shield goes behind the board, never in front. The distance gate masks most of this, so wait and see before bothering.
macOS lets two processes open the same /dev/cu.* device, and they then steal
bytes from each other, so neither gets whole frames. The sensor needs exactly
one owner.
radar_viz.py can be that owner. It serves a page that shows what the radar
sees, and republishes parsed readings for the daemon:
./.venv/bin/python radar_viz.py --port 8422
Then open http://127.0.0.1:8422/.
/ the page
/feed the newest parsed reading as JSON, plus age_sec and fresh
/stream the same thing as SSE at 20Hz
Set "sensor": "feed" and feed_url in config.json and the daemon reads
that instead of the serial device. It keeps all its own behaviour, including
failing open: if the feed is unreachable it logs a retry every five seconds and
leaves brightness alone.
Two consequences. The visualizer has to be running for the daemon to see anything. And because the visualizer puts the module into engineering mode, data frames are 35 bytes rather than 13 — the parser handles both, since the first eleven payload bytes are laid out identically.
If you'd rather keep it simple, leave "sensor": "ld2410" and the daemon opens
the serial port itself. Just don't run both at once.
The board's own thresholds, read back over the wire on a factory-default module:
moving sensitivity 50 50 40 30 20 15 15 15 15
still sensitivity 0 0 40 40 30 30 20 20 20
unmanned duration 5s
Note the first two zeros. The board reports no stationary energy at all for gates 0 and 1, which is everything inside five feet. Someone sitting perfectly still at desk distance can therefore be invisible in the per-gate stationary picture while the board's own target reading still sees them fine. Anything reading the per-gate data has to account for that.
The visualizer's second tab turns the numbers into a call: nobody, at the desk, coming closer, moving away, in the room.
It is deliberately rules rather than a model. This board exposes nine strength numbers and two distances at 10Hz, with no raw signal and no Doppler, which is what the published radar machine-learning work actually trains on. There is very little for a model to learn here, and it would hide its reasoning.
Three things make the rules hold up, all learned the hard way:
- The background level per slice is learned from the tenth percentile, not the median. A person only ever adds energy, so the bottom of the distribution is the part they cannot poison.
- How much a slice is allowed to wander before it counts as new is learned per slice, as three times the spread between the tenth and fortieth percentile. A flat threshold false-triggers constantly on the far wall return, which swings on its own.
- Learning freezes while anything is detected, with a forced sample every ten minutes so a room that genuinely changed cannot lock it out. Without the freeze, someone sitting at the desk all afternoon slowly becomes wallpaper.
Every score is also averaged over one second. Measured against 1,638 samples of an empty room, the moving channel alone spikes to 2.5 times its threshold on its own, so no single frame can be trusted.
One hour of continuous capture with nobody at the desk (36,000 samples at 10 Hz, with HID idle confirming the room was empty throughout):
moving target 194-300cm, median 220, energy median 42
stationary 212-253cm, median 220, energy median 100 (pegged)
detect distance 192-231cm, median 206
Nothing ever read closer than 194cm, so max_distance_cm at 150 produced zero
false "present" readings across the whole hour. Furniture can't wake the
screen. Your room will differ — run --probe, or capture your own hour and
summarize it with profile_report.py.
Worst case if the radar can't see you well: you sit reading without touching anything for the grace period and the panel fades. Any key or mouse movement brings it back in 0.25s. That's a mild, self-correcting failure.
Everything lives in config.json.
| key | default | what it does |
|---|---|---|
displays |
— | Display names as BetterDisplay reports them. |
control |
brightness |
Which BetterDisplay control to drive. See MONITOR.md. |
sensor |
ld2410 |
ld2410 for the serial device, feed for the visualizer. |
port |
auto |
Serial device, or auto to pick the first /dev/cu.usbserial-*. |
baud |
274000 | See the baud section above. |
max_distance_cm |
150 | Anything farther than this doesn't count as you. |
min_energy |
20 | Rejects weak returns. Raise it if it's twitchy. |
absent_grace_sec |
30 | How long you must be gone before it fades. |
fade_out_sec |
2.0 | Fade-to-black duration. |
fade_in_sec |
0.25 | Wake-up duration. Keep this snappy. |
dim_floor_pct |
0 | Where the fade lands. 0 is fully off. |
hid_guard_sec |
15 | Never dim if keyboard/mouse was touched this recently. |
deep_sleep_after_sec |
0 | Extra absence before pmset displaysleepnow. 0 = off. |
hid_guard_sec and absent_grace_sec stack. Touching the keyboard resets the
absence clock, so the countdown to a fade doesn't start until hid_guard_sec
after your last keystroke. With 30 and 45, standing up gets you a fade about 75
seconds later, not 45. If you want a total of about 45 seconds, use
hid_guard_sec 15 and absent_grace_sec 30. Time it with a stopwatch before
deciding it's broken.
Every write goes through betterdisplaycli, which is XPC into the running
BetterDisplay app. One owner of the display connection, which is the whole
point: multiple short-lived processes opening IOAVService on a timer trigger a
known macOS race that can take down WindowServer. This daemon never issues a
raw DDC write of its own, and never sends a power-control VCP.
Tested:
- Full cycle. Full brightness to zero and back, with the DDC value landing back on exactly where it started and BetterDisplay's mapping intact.
- Keyboard beats the radar. A fade in progress aborts the moment you touch the keyboard.
- Crash while black.
kill -9mid-dim leaves the screen dark, since no handler can run. The next launch readsstate.json, seesdimmed: true, clears the stale pid lock and restores. - Single instance. A second copy refuses to start rather than fight the first over the same display.
- Sensor missing at startup. It logs
sensor unavailable ... (retry in 5s)once every five seconds, retries forever, doesn't crash, and leaves brightness at baseline. A sensor it cannot read never turns your screen off. - Real hardware, full cycle. Radar streaming at 10 Hz, absence detected, fade to 0%, presence detected, restore.
Not tested: a sensor yanked mid-stream, as opposed to one that was already gone
at startup. A pseudo-terminal can't stand in for this — pyserial rejects a pty
with [Errno 25] Inappropriate ioctl for device.
Escape hatches, in order of how fast they are:
- Your keyboard's brightness keys, if you've mapped them in BetterDisplay. Fastest fix, always works.
touch OFFin the project directory — parks the daemon and restores the screen. Delete the file to resume../.venv/bin/python presence_dim.py --restore
Seat, not room. Only count you as present when you're actually in the seat, not merely somewhere in the room, so walking past the desk doesn't wake the screen. The measurement that would drive it:
walking past move 149cm still 371cm channels DISAGREE
seated move 74cm still 74cm channels AGREE
Sitting parks a stationary return close and holds it steady for many seconds. Walking through makes the moving channel flicker close for under a second while the stationary channel stays far away. The rule would be: require the stationary distance to be inside the seat band AND to hold there for a few seconds.
What will not work: capping the max detection gate, the standard Home Assistant fix. That works when the nuisance traffic is further out than the target. Here seated reads 74-88cm and a walk-by read 148cm, and with 75cm gates both land in gate 1. No distance cut separates them.
Also ruled out: micro-Doppler activity classification (HAROOD and similar) genuinely solves this, but it needs raw chirp data to build a Doppler spectrogram. The LD2410 processes on-chip and discards it. Nine energy numbers and two distances at 10 Hz is all that ever reaches the serial port.
Energy is useless as a discriminator — it saturates at 100 for a seated body and for a blank wall. Distance and duration carry all the information.
Aqara FP2, Screek Human Sensor 1U, Everything Presence. The polished consumer versions — enclosure, app, zone editing. They all talk WiFi to Home Assistant or HomeKit, and neither of those can reach in and dim a Mac's monitor. Buying one means standing up Home Assistant as a bridge: more off-the-shelf in the box, considerably more setup overall.
A webcam and an existing app. Nothing current and maintained. ProxSentry did exactly this and has been archived since February 2024. Should I Sleep is old. ScreenFocus dims on cursor position, not presence. They also hold the camera open, so the light stays on and video calls fight for it.
MIT. See LICENSE.