Skip to content

Latest commit

 

History

7 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

meeting-watcher

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).

How it works

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.

Build & run

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 exit

Configuration

config.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.

Which apps count

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.

MQTT topics

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"
}

Run at login (launchd)

examples/com.meeting-watcher.plist runs the watcher as a per-user LaunchAgent and restarts it if it dies (KeepAlive). Edit the paths in itProgramArguments 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 / uninstall

KeepAlive 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.

Home Assistant

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.

Manual configuration (if discovery is disabled)

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

Notes / limitations

  • With no apps filter, anything holding the mic counts, so dictation or a voice memo also reads as "in a meeting". detail.mic_apps says what's responsible; set apps if 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.

About

MacOS app that posts an update to MQTT when you're in a meeting

Resources

Stars

2 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages