This is a writeup of a talk streamed at PyOhio 2023, as I thought it would make for a good post. The conference recording is embedded here at the top (the link is here), and the written version is below. The slides and code are available here.
I just recently started taking over my wife’s home office, and had prior to that been working from the living room. My desk was near the kitchen, so sometimes she would go in to get a glass of water and want to say hi. Rather than having her constantly ask “are you busy?”, I thought I’d place a little status light on my desk that would communicate the answer preemptively.
┌─────────────────────────────────────────────────────┐ │ ┌────────────────────┐* │ │ │ │ │ │ │ DESK │ ── ── ── ── ┬─ ── ── KITCHEN │ │ │ │ │ │ │ └────────────────────┘ │ │ │ ┌─────────┘ │ │ │ │ └─────────┐ │ │ │ │ │ ENTRYWAY │ │ │ │ LIVING │ │ │ ROOM └─ ── ─┐ │ │ │ │ │ │ └──────────────────────────────────────┐ │ ┌────┘ │ │ │ HALLWAY │ │ │ │ │
Since changing the light manually required more discipline than I had, I found myself automating this rather quickly, and it turned out to be rather simple.
This is the core of our stack:
We need some additional hardware:
And some software:
We need four files:
The file should live on the device, so let’s make it /Volumes/CIRCUITPY/state
. Its initial contens are rather simple:
0
Yep, just a good old 0
.
This file should also live on the device, so let’s make it /Volumes/CIRCUITPY/code.py
, as that file will get automatically run at startup.
From the bundled library we need to import the cpx
module, which allows us to interact with the device’s inputs and outputs:
from adafruit_circuitplayground.express import cpx
Next up we want to define some colors, and we can go with green for no call and red for active call:
COLORS = (
(0, 255, 0), # green
(255, 0, 0), # red
)
We definitely want to set LED brightness to a lot less than 100% initially, as they’re very bright:
cpx.pixels.brightness = 0.01
As the device runs the code.py
file only once, we want to encapsulate the functionality inside an infinite loop. Inside that loop we want to open the status file, get the 0
or 1
we expect to find, use that value to pick the correct LED color, and finally set the LEDs to said color using the cpx
module.
while True:
with open("state") as state_file:
state = int(state_file.readline())
color = COLORS[state]
cpx.pixels.fill(color)
The file in its entirety looks like this:
from adafruit_circuitplayground.express import cpx
COLORS = (
(0, 255, 0), # green
(255, 0, 0), # red
)
cpx.pixels.brightness = 0.01
while True:
with open("state") as state_file:
state = int(state_file.readline())
color = COLORS[state]
cpx.pixels.fill(color)
The call detection script should live on your own machine. I made mine /Users/nik/bin/detect.py
, but you can keep it anywhere reasonable.
For this script we also need only one import, this time subprocess
from the standard library:
import subprocess
We want a function to encapsulate our business logic, which starts with running lsof -i 4UDP
, getting its output, converting it from a bytestring to a string, and splitting it by newline to get a list of rows. We can then look through those rows and keep only the ones which contain the word “zoom”.
def detect():
lsof_output = subprocess.check_output(["lsof", "-i", "4UDP"]).decode().split("\n")
zoom_rows = [row for row in lsof_output if "zoom" in row]
These “zoom” rows tell us whether there’s an active call or not. If Zoom isn’t running, there will be none. If Zoom is running, there may be one. And if there’s an active call, there will be more than one. This gives us the current state of things. At the same time we can initialize the state on the device before we actually read it.
current_state = int(len(zoom_rows) > 1) # 1 zoom process isn't a meeting
device_state = None
Immediately after we want to read the device state from the status file:
with open("/Volumes/CIRCUITPY/state", "r") as state_file:
device_state = int(state_file.read())
If the two states differ, we want to write the current state to the status file:
if device_state != current_state:
with open("/Volumes/CIRCUITPY/state", "w") as state_file:
state_file.write(str(current_state))
And finally, we need to make the script runnable:
if __name__ == "__main__":
detect()
The complete file looks like this:
import subprocess
def detect():
lsof_output = subprocess.check_output(["lsof", "-i", "4UDP"]).decode().split("\n")
zoom_rows = [row for row in lsof_output if "zoom" in row]
current_state = int(len(zoom_rows) > 1) # 1 zoom process isn't a meeting
device_state = None
with open("/Volumes/CIRCUITPY/state", "r") as state_file:
device_state = int(state_file.read())
if device_state != current_state:
with open("/Volumes/CIRCUITPY/state", "w") as state_file:
state_file.write(str(current_state))
if __name__ == "__main__":
detect()
The launchd service definition needs to live in your ~/Library/LaunchAgents
directory. Everything I found in mine seemed to be named something like com.{vendor}.{application}.{component}.plist
, so I made mine /Users/nik/Library/LaunchAgents/com.nik.OnAir.Detector.plist
.
We start with some standard boilerplate:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
Things get interesting when we start defining our actual service, starting with its name, which matches the file name:
<dict>
<key>Label</key>
<string>com.nik.OnAir.Detector.plist</string>
We then define the command to be run as an array of strings. Here we have a suitable Python interpreter and the path of the detection script:
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/bin/python3</string>
<string>/Users/nik/bin/detect.py</string>
</array>
The last part is quite important, as it ensures the detection script is run continuously:
<key>KeepAlive</key>
<true/>
And the closing tags:
</dict>
</plist>
The complete file:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.nik.OnAir.Detector.plist</string>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/bin/python3</string>
<string>/Users/nik/bin/detect.py</string>
</array>
<key>KeepAlive</key>
<true/>
</dict>
</plist>
Now that we have all the files in place, we can run the launchd service to start the whole thing. This is done via the launchctl
command, and it looks like this:
launchctl enable com.nik.OnAir.Detector.plist
If everything is going well, you should be able to replicate this:
Hooray!~ 🎉
You might be wondering if detecting camera and/or microphone use is a better option than looking for Zoom itself. I don’t know about you, but I definitely find myself on calls with both of them disabled, at least for periods of time, so that wouldn’t work for me.
Something I didn’t cover in the talk but is super useful is a manual switch. I used xbar and a shell script to toggle the state manually.
Another thing to consider as an improvement is adding other statuses. For example, maybe yellow means “not on a call, but trying to focus”.
The future of the project for me is unclear, as it’s currently decomissioned, what with the aforementioned move into a dedicated home office which has the ultimate do-not-disturb implementation: a door. I thought about hanging it on the door knob to communicate whether knocking is OK, but then I’d have to power it, which means batteries, and I have enough charge anxiety as it is.
While ostensibly about a simple, fun, useful project, this talk is actually meant to inspire.
You see, I had no meaningful hardware experience before doing this. Sure, I’ve had some Raspberry Pis lying around and doing some things, but nothing ever really involved any hardware. It was a pretty simple thing to do, and fun!
And since I could do it, so can you! Really, if any of this makes sense, you’re not far from your own exploration.
To aid you in said exploration, I recommend gizmos like the one shown here, with easily accessible inputs and outputs. The Circuit Playground Express has a bunch of sensors —e.g., light, temperature, accelerometer—and outputs—e.g., LEDs, beeper—which allow building silly toys. After I got it at PyCon, I felt a bit inspired and built a really crappy theremin in my hotel room: I piped the light sensor values into the beeper, so I could shine a light onto the device and move my hand around to get different sounds. Then I switched to the temperature sensor and used a hair dryer. It was a good laugh.
And if this sounds like child’s play, that’s exactly my point: play is important. Most of us work with things involving phrases like “web scale” and “business value”, but we maybe got into this in the first place through some sort of play when we were younger, and it’s healthy to hold on to some of that.
So play more!
Thanks for reading! You can keep up with my writing via the feed or newsletter, or you can get in touch via email or Mastodon.