overview
I wanted a camera on my room without buying one, and my MacBook already has a webcam. The catch: Home Assistant can't turn a Mac's camera on. The macOS companion app only reports whether the camera is in use, as a read-only sensor. There's no service call to start it and no integration that adds one.
So I added the missing piece myself: a small Python server on the Mac that Home Assistant talks to over my home network. Home Assistant never touches the camera. It flips a switch, the switch sends an HTTP request, and the server on the Mac starts or stops the capture.
two processes
The whole design comes down to two processes with different lifetimes. Nearly every bug I hit while building this came from treating them as one.
start → spawn ffmpeg if it isn't running · stop → SIGTERM it and clear the frame · status → running or stopped · snapshot → the latest JPEG · stream → live MJPEG to any number of viewers
the stream
ffmpeg writes MJPEG to stdout. A reader thread in the server scans that byte stream for JPEG start
and end markers (FFD8 … FFD9) and keeps only the latest complete frame in
memory. Nothing is written to disk. Snapshots return that frame, and the stream endpoint pushes it
to every connected client as a multipart/x-mixed-replace response, one JPEG after
another.
The first version didn't have a server at all: ffmpeg served the stream itself with
-listen 1. That serves exactly one client, and ffmpeg exits when that client
disconnects, so closing a browser tab turned the camera off. It also couldn't give Home
Assistant a still image and a stream at the same time. Doing the serving in Python fixed both.
ThreadingTCPServer, so the kiosk, my phone and a laptop
can all watch at once.
ConnectionResetError. 1.0 closes after
each response and the noise stopped.
HEAD before it
GETs. Without a handler that probe got a 404.
the nv12 story
ffmpeg -f avfoundation -pixel_format nv12 -framerate 30 -video_size 1280x720 -i 0 -f mjpeg -q:v 6 -
For a while ffmpeg refused to open the camera with an error about the framerate. The
framerate was fine. Without -pixel_format nv12, ffmpeg asked the FaceTime camera for a
pixel format it doesn't offer, and reported that as a framerate problem. Adding one flag fixed it.
The framerate does have to be exactly 30, though: ffmpeg's default of 29.97 isn't on the camera's
list.
One more macOS detail: launchd runs the server with Homebrew's Python, which counts as a separate app
from my terminal as far as macOS privacy settings go, so it needed its own camera permission. launchd
also doesn't inherit my shell's PATH, so the LaunchAgent sets it explicitly or it can't
find ffmpeg.
construction
On the Home Assistant side there are three pieces. Two REST commands send start and stop. A REST sensor polls the status every 10 seconds. A template switch reads that sensor for its on/off state and calls the commands when flipped, which is why the switch can lag reality by up to 10 seconds. The camera itself is Home Assistant's MJPEG IP Camera integration pointed at the stream and snapshot endpoints. The Generic Camera integration rejected this source.
architecture
The server has no authentication, so it's only reachable on my home network and is never exposed
beyond it. It needs the lid open and the Mac awake; if the camera is already in use by FaceTime or
Zoom, ffmpeg fails to open it and the switch stays off.
Source? The server is a single Python file on my Mac. It's not on GitHub yet.