Posts to MQTT when you join or leave a call on macOS — for driving a busy light, a Home Assistant automation, a "do not disturb" sign.
Swift, single binary, no third-party dependencies (the MQTT client is a
small hand-rolled publisher over Network.framework).
Every call app shares one reliable signal: microphone use. On macOS 14+ CoreAudio's process API reports which apps hold the mic, and since we only read audio-graph state (never capture samples), this needs no microphone permission — and no permission prompt of any kind.
By default any app on the mic means you're in a meeting — Meet, Slack huddles,
Zoom, Teams, FaceTime, whatever — or you can restrict it to a list of apps (see
Which apps count). MicMonitor watches per-app mic input
via CoreAudio property listeners, so it reacts the instant the mic turns on or
off; a periodic poll catches any listener edge that gets missed.
MQTTClient publishes state changes retained, plus an availability topic backed
by an MQTT Last Will, so the broker marks you offline if the watcher dies or
drops off the network.
Requires macOS 14+ and the Xcode Command Line Tools
(xcode-select --install) — no full Xcode or SwiftPM.
./build.sh # compiles ./meeting-watcher with swiftc
cp config.example.json config.json # edit for your broker
./meeting-watcher # run the watcher
./meeting-watcher --once # print current state as JSON and exitconfig.json (see config.example.json), looked up in the working directory
and next to the binary. Environment variables override the file:
| Env var | Meaning |
|---|---|
MQTT_HOST |
Broker host (default localhost) |
MQTT_PORT |
Broker port (default 1883) |
MQTT_USERNAME |
Broker username |
MQTT_PASSWORD |
Broker password |
MQTT_BASE_TOPIC |
Base topic (default presence/<user>/meeting) |
MQTT_CLIENT_ID |
MQTT client id |
MQTT_TLS |
1 to enable TLS |
POLL_INTERVAL |
Interval for re-checking mic state in case a CoreAudio event was missed, seconds (default 5) |
MEETING_APPS |
Comma-separated app filter (empty = all apps) |
HA_DISCOVERY |
0 to disable Home Assistant discovery |
HA_DISCOVERY_PREFIX |
Discovery prefix (default homeassistant) |
baseTopic supports %{username} (default) and %{hostname} placeholders.
apps is empty by default, so every app holding the mic counts. Set it to
report only on specific apps:
"apps": ["Slack", "Chrome", "zoom", "Microsoft Teams"]Entries are matched case-insensitively as substrings of the app name, so
Chrome catches "Google Chrome" and zoom catches "zoom.us" — you don't need
the exact name. MEETING_APPS="Slack,Chrome" overrides the file;
MEETING_APPS="" clears the list back to "all apps".
Run ./meeting-watcher --once while a call is live to see the names to filter
on in detail.mic_apps.
For base topic presence/<user>/meeting (username of whoever runs the process):
| Topic | Payload |
|---|---|
presence/<user>/meeting |
Retained JSON (full state, below) |
presence/<user>/meeting/state |
ON / OFF — convenient for a binary sensor |
presence/<user>/meeting/availability |
online / offline (LWT-backed) |
homeassistant/binary_sensor/<id>/config |
Retained HA discovery config |
{
"in_meeting": true,
"detail": { "mic_apps": ["Slack", "zoom.us"] },
"user": "patch0",
"updated_at": "2026-08-05T14:15:58Z"
}examples/com.meeting-watcher.plist runs the watcher as a per-user LaunchAgent
and restarts it if it dies (KeepAlive). Edit the paths in it —
ProgramArguments and WorkingDirectory to match your clone, and
StandardOutPath/StandardErrorPath to match your home directory (launchd
does not expand ~) — then:
cp examples/com.meeting-watcher.plist ~/Library/LaunchAgents/
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.meeting-watcher.plist
launchctl kickstart -k gui/$(id -u)/com.meeting-watcher # restart (e.g. after ./build.sh)
launchctl bootout gui/$(id -u)/com.meeting-watcher # stop / uninstallKeepAlive means launchd restarts the process whenever it exits, so bootout
is the only way to actually stop it. Re-bootstrap after editing the plist.
Logs go to ~/Library/Logs/meeting-watcher.log (connection + publish activity)
and ~/Library/Logs/meeting-watcher.err. A healthy log shows TCP connected…, then
MQTT connected (CONNACK ok), then a Published: line per state change.
No YAML needed. The watcher publishes an MQTT Discovery config on
every connect, so as long as HA's MQTT integration points at the same broker, a
binary_sensor appears by itself under a Meeting Watcher device:
homeassistant/binary_sensor/<object_id>/config # retained discovery config
<object_id> is the resolved base topic with every character outside
[a-zA-Z0-9_-] replaced by _ — HA silently ignores discovery topics
containing anything else, and usernames commonly contain dots. So
presence/patch0/meeting gives presence_patch0_meeting, which
is also the entity's unique_id, so it survives restarts without duplicates.
The full state JSON is wired up as json_attributes_topic, so mic_apps is
available as an attribute if an automation wants to know what's holding the mic:
{{ state_attr('binary_sensor.presence_patch0_meeting', 'mic_apps') }}Discovery settings live under discovery in config.json:
| Key | Default | Meaning |
|---|---|---|
enabled |
true |
Publish the discovery config |
prefix |
homeassistant |
Must match HA's discovery prefix |
name |
In a meeting |
Entity name |
deviceName |
Meeting Watcher (%{hostname}) |
Device name in HA |
name and deviceName take the same placeholders as baseTopic. Disabling
discovery publishes an empty payload to the config topic, which tells HA to
remove the entity — otherwise the retained config would linger forever.
mqtt:
binary_sensor:
- name: "Patrick In A Meeting"
state_topic: "presence/patch0/meeting/state"
availability_topic: "presence/patch0/meeting/availability"
payload_on: "ON"
payload_off: "OFF"
device_class: running- With no
appsfilter, anything holding the mic counts, so dictation or a voice memo also reads as "in a meeting".detail.mic_appssays what's responsible; setappsif you want to narrow it. - Publishes at QoS 0 — fire-and-forget, which is what you want for frequently-updated presence state with a retained last value.