Skip to content

Repository files navigation

presence-dim

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.

Install

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

Bring it up

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.

The sensor: three things that will cost you hours

It will not ask you for a driver

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.

It must be plugged into USB-A

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.

Ask for 274000 baud, not 256000

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.

Mounting and aiming

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 --probe shows 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.

The visualizer, and who owns the sensor

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.

Reading what is happening

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:

  1. 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.
  2. 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.
  3. 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.

Empty-room baseline, measured

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.

Tuning

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.

The timer trap

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.

Safety, and what was actually tested

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 -9 mid-dim leaves the screen dark, since no handler can run. The next launch reads state.json, sees dimmed: 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:

  1. Your keyboard's brightness keys, if you've mapped them in BetterDisplay. Fastest fix, always works.
  2. touch OFF in the project directory — parks the daemon and restores the screen. Delete the file to resume.
  3. ./.venv/bin/python presence_dim.py --restore

Not built

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.

Alternatives considered

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.

License

MIT. See LICENSE.

About

Fades your Mac's monitor to black when you leave the desk. 24GHz presence radar over USB serial, brightness via BetterDisplay.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages