Automated Meeting Status Indicator

Working from home has its perks, but it also means family members can barge into your workspace at any time. I've wanted a "busy" indicator outside my office for years, and I finally got around to building one after half a decade.

Designing the Solution

The biggest challenge was determining what to monitor to decide if I'm busy. There are multiple ways I could be engaged in a conversation—Microsoft Teams, Zoom, Google Meet, or random phone calls. Initially, I considered pulling my calendar events, but that wouldn't cover spontaneous meetings or calls.

Instead of tracking individual apps, I approached the problem differently: sound. If I could detect whether my microphone was picking up speech, I could infer whether I was on a call and display my availability accordingly.

The Plan:

A client-server setup, where:

The client (laptop) monitors the microphone audio levels and determines if I'm speaking.

The server (ESP32) receives the status from the laptop and updates a small T-Display screen via Wi-Fi through an API endpoint.

This allows real-time updates to the indicator light, changing colours based on my microphone activity.

Hardware Used

ESP32 with T-Display (same as in the Pomodoro timer project).

Laptop with Python running the microphone detection script.

Software Setup

1. Laptop (Client) - Detecting Microphone Activity

The client is a Python script that:

Continuously listens to the microphone audio levels.

Uses a moving average filter to smooth out fluctuations.

Determines if the microphone is "ACTIVE" or "MUTED" based on a volume threshold.

Sends an HTTP POST request to the ESP32 whenever the status changes.

Code Explanation

import sounddevice as sd
import numpy as np
import requests
import time
import threading

# ESP32 API Endpoint
ESP32_IP = "http://ESP32-IP/update"
TARGET_MIC_NAME = "Microphone Name"

# Detection Parameters
SENSITIVITY_THRESHOLD = 0.02 # Sensitivity to speech
CONFIRMATION_COUNT = 5 # How many detections before switching to "ACTIVE"
DECAY_RATE = 0.4 # Slow fade to "MUTED"
COOLDOWN_TIME = 2.5 # Prevents frequent status flips

# Audio Settings
SAMPLE_RATE = 16000 # Balanced for speech
BLOCK_SIZE = 1024 # Optimized for responsiveness

# State Tracking
mic_status = "MUTED"
active_count = 0
last_status = None
last_change_time = time.time()
lock = threading.Lock()
volume_history = [] # Moving average buffer

# Identify the correct microphone

def get_microphone_index():
devices = sd.query_devices()
for index, device in enumerate(devices):
if TARGET_MIC_NAME in device["name"] and device["max_input_channels"] > 0:
print(f"Using Microphone: {device['name']} (Index {index})")
return index
return None

mic_index = get_microphone_index()
if mic_index is None:
print(f"Microphone '{TARGET_MIC_NAME}' not found!")
exit(1)

# Send microphone status to ESP32

def send_status_to_esp32(status):
global last_status
if status == last_status:
return # Avoid duplicate requests

def request_thread():
retries = 3
for attempt in range(retries):
try:
response = requests.post(ESP32_IP, data=status, timeout=1)
print(f"Sent status: {status} (Response: {response.status_code})")
return
except requests.RequestException as e:
print(f"Failed to send update (Attempt {attempt+1}/{retries}): {e}")
time.sleep(0.5)

threading.Thread(target=request_thread, daemon=True).start()
last_status = status

# Callback function for audio processing

def callback(indata, frames, timing_info, status):
global mic_status, active_count, last_change_time, volume_history

if status:
print(f"Audio stream status: {status}")

volume = np.sqrt(np.mean(np.square(indata))) # Calculate RMS volume
volume_history.append(volume)
if len(volume_history) > 10:
volume_history.pop(0) # Keep last 10 readings

smoothed_volume = np.mean(volume_history)

with lock:
if smoothed_volume > SENSITIVITY_THRESHOLD:
active_count = min(active_count + 1, CONFIRMATION_COUNT)
else:
active_count = max(0, active_count - DECAY_RATE)

new_status = "ACTIVE" if active_count >= CONFIRMATION_COUNT else "MUTED"

if new_status != mic_status and (time.time() - last_change_time) > COOLDOWN_TIME:
mic_status = new_status
send_status_to_esp32(mic_status)
last_change_time = time.time()

# Start microphone monitoring
with sd.InputStream(device=mic_index, channels=1, samplerate=SAMPLE_RATE, blocksize=BLOCK_SIZE, callback=callback):
while True:
time.sleep(0.1) # Keep CPU usage low

This script runs in the background and automatically detects whether I'm speaking. If I am, it sends a status update to the ESP32.

2. Compiling the Script into an Executable

To make the script run independently on Windows as a portable exe:

Install PyInstaller:

pip install pyinstaller

Create an executable:

pyinstaller --onefile --noconsole myscript.py

--onefile: Generates a single EXE file.

--noconsole: Prevents a command window from appearing.

The generated file will be found in dist/myscript.exe.

You can then set this to auto-launch during start-up, making it a set-and-forget solution.

2. ESP32 (Server) - Displaying Status on T-Display

First, initialise the board and libraries as per the Pomodoro timer project post.

Breakdown of the ESP32 logic:

Runs a web server that listens for status updates from the laptop.

Displays "ACTIVE" (red) or "MUTED" (green) on the screen.

Transitions between colours.

Has a screen dimming function if inactive for 15 minutes.

Randomly shifts pixels every 5 minutes to prevent screen burn-in.

#include <WiFi.h>
#include <WebServer.h>
#include <TFT_eSPI.h>

// WiFi Credentials
const char* ssid = "SSID";
const char* password = "PASSWORD";

// Web server on port 80
WebServer server(80);

// TFT Display
TFT_eSPI tft = TFT_eSPI();
String micStatus = "MUTED";
String targetStatus = "MUTED";
float fadeAmount = 0.0;
unsigned long lastChangeTime = 0;
unsigned long lastShiftTime = 0;
int shiftX = 0, shiftY = 0;
bool dimmed = false;

// Colors
uint16_t colorRed = tft.color565(97, 27, 36); // #611b24
uint16_t colorGreen = tft.color565(2, 84, 36); // #025424

void handleMicStatus() {
if (server.hasArg("plain")) {
String body = server.arg("plain");

if (body.indexOf("ACTIVE") != -1) {
targetStatus = "ACTIVE";
} else if (body.indexOf("MUTED") != -1) {
targetStatus = "MUTED";
}

lastChangeTime = millis();
dimmed = false;
server.send(200, "text/plain", "Status updated");
} else {
server.send(400, "text/plain", "Invalid request");
}
}

uint16_t blendColors(uint16_t color1, uint16_t color2, float blendFactor) {
uint8_t r1 = (color1 >> 11) & 0x1F;
uint8_t g1 = (color1 >> 5) & 0x3F;
uint8_t b1 = color1 & 0x1F;

uint8_t r2 = (color2 >> 11) & 0x1F;
uint8_t g2 = (color2 >> 5) & 0x3F;
uint8_t b2 = color2 & 0x1F;

uint8_t r = r1 + blendFactor * (r2 - r1);
uint8_t g = g1 + blendFactor * (g2 - g1);
uint8_t b = b1 + blendFactor * (b2 - b1);

return tft.color565(r << 3, g << 2, b << 3);
}

// Function to smoothly transition between colors
void drawFadeTransition() {
uint16_t startColor = (micStatus == "ACTIVE") ? colorRed : colorGreen;
uint16_t endColor = (targetStatus == "ACTIVE") ? colorRed : colorGreen;

if (startColor != endColor) {
fadeAmount += 0.05;
if (fadeAmount >= 1.0) {
fadeAmount = 1.0;
micStatus = targetStatus;
}
} else {
fadeAmount = 0.0;
}

uint16_t blendedColor = blendColors(startColor, endColor, fadeAmount);
tft.fillScreen(blendedColor);
}

// Function to shift screen pixels to prevent burn-in
void shiftScreen() {
if (millis() - lastShiftTime > 300000) { // Shift every 5 minutes
shiftX = random(-3, 4); // Small random shift
shiftY = random(-3, 4);

uint16_t buffer[tft.width()]; // Buffer to store row data

for (int y = 0; y < tft.height(); y++) {
tft.readRect(0, y, tft.width(), 1, buffer); // Read row of pixels
tft.pushImage(shiftX, y + shiftY, tft.width(), 1, buffer); // Redraw with offset
}

lastShiftTime = millis();
}
}

// Function to dim screen if inactive for 15 min
void dimScreen() {
if (millis() - lastChangeTime > 900000) { // 15 min timeout
if (!dimmed) {
tft.fillScreen(tft.color565(10, 10, 10)); // Dimmed dark screen
dimmed = true;
}
}
}

void setup() {
Serial.begin(115200);
tft.init();
tft.setRotation(1);
tft.fillScreen(colorGreen);

WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) {
delay(1000);
Serial.print(".");
}
Serial.println("\nWiFi connected!");

server.on("/update", HTTP_POST, handleMicStatus);
server.begin();
Serial.println("Server started!");
}

void loop() {
server.handleClient();
drawFadeTransition();
shiftScreen();
dimScreen();
delay(30);
}

Wrap-up

This project is a simple way to automatically indicate when I'm busy without manually toggling an indicator. The audio detection provides real-time updates, and the ESP32 display makes it easy for others to see my status.

Update 22/08/2025:

I have since started utilising Home Assistant Voice PE for indicating my current availability around the house. The LED ring on the PE is addressable and can be controlled through a HA automation.

Here's an example of an automation that uses my M365 calendar, filtering out non-meeting events and adjusting the LED ring according to my current availability:

alias: LED busy light during meetings (weekdays, call-only)
description: ""
triggers:
- entity_id: calendar.work_calendar
trigger: state
- at: "09:00:00"
trigger: time
- at: "17:00:00"
trigger: time
- at: "00:00:00"
trigger: time
actions:
- choose:
- conditions:
- condition: time
weekday:
- mon
- tue
- wed
- thu
- fri
after: "09:00:00"
before: "17:00:00"
sequence:
- choose:
- conditions:
- condition: state
entity_id: calendar.work_calendar
state: "on"
- condition: template
value_template: "{{ on_a_call }}"
sequence:
- target:
entity_id: light.home_assistant_voice_led_ring
data:
color_name: red
brightness: 255
action: light.turn_on
- conditions:
- condition: state
entity_id: calendar.work_calendar
state: "on"
- condition: template
value_template: "{{ not on_a_call }}"
sequence:
- target:
entity_id: light.home_assistant_voice_led_ring
data:
color_name: white
brightness: 255
action: light.turn_on
- conditions:
- condition: state
entity_id: calendar.work_calendar
state: "off"
sequence:
- target:
entity_id: light.home_assistant_voice_led_ring
data:
color_name: white
brightness: 255
action: light.turn_on
- conditions:
- condition: time
weekday:
- mon
- tue
- wed
- thu
- fri
- condition: or
conditions:
- condition: time
before: "09:00:00"
- condition: time
after: "17:00:00"
sequence:
- choose:
- conditions:
- condition: state
entity_id: calendar.work_calendar
state: "on"
- condition: template
value_template: "{{ on_a_call }}"
sequence:
- target:
entity_id: light.home_assistant_voice_led_ring
data:
color_name: red
brightness: 255
action: light.turn_on
- conditions:
- condition: or
conditions:
- condition: state
entity_id: calendar.work_calendar
state: "off"
- condition: template
value_template: "{{ not on_a_call }}"
sequence:
- target:
entity_id: light.home_assistant_voice_led_ring
action: light.turn_off
data: {}
- conditions:
- condition: time
weekday:
- sat
- sun
sequence:
- choose:
- conditions:
- condition: state
entity_id: calendar.work_calendar
state: "on"
- condition: template
value_template: "{{ on_a_call }}"
sequence:
- target:
entity_id: light.home_assistant_voice_led_ring
data:
color_name: red
brightness: 255
action: light.turn_on
- conditions:
- condition: or
conditions:
- condition: state
entity_id: calendar.work_calendar
state: "off"
- condition: template
value_template: "{{ not on_a_call }}"
sequence:
- target:
entity_id: light.home_assistant_voice_led_ring
action: light.turn_off
data: {}
variables:
on_a_call: >
{% set text = (
(state_attr('calendar.work_calendar','description') or '') ~ ' ' ~
(state_attr('calendar.work_calendar','message') or '') ~ ' ' ~
(state_attr('calendar.work_calendarr','location') or '')
) | lower %} {{ text |
regex_search('https?://|\\b(teams|meet|zoom|join)\\b', ignorecase=True) }}
mode: single