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.

the server: Always running, always idle until asked. It's a launchd LaunchAgent, so it starts at login and restarts itself if it crashes. If it's dead, the switch in Home Assistant shows as unavailable.
ffmpeg: The actual camera capture, started and killed by the server on demand. I never run it by hand. When the switch stopped working early on, it looked like ffmpeg needed restarting. It didn't: the server had died along with the terminal I'd started it from. Moving it into launchd fixed that for good.

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.

threaded: A ThreadingTCPServer, so the kiosk, my phone and a laptop can all watch at once.
HTTP/1.0 on purpose: With 1.1 keep-alive, sockets stayed open after each response and every client hang-up printed a ConnectionResetError. 1.0 closes after each response and the noise stopped.
HEAD requests: Home Assistant probes with 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

Python ffmpeg AVFoundation MJPEG launchd Home Assistant REST

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

CCTV switch → Home Assistant REST command → Python server (Mac) → ffmpeg + FaceTime camera → MJPEG stream → dashboards

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.