# Automated Meeting Status Indicator

> ESP32-powered status display that shows when I'm busy or available.

By Zsolt Bizderi · Published 2025-04-04
Canonical: https://ambientnode.uk/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.



![](/media/da93ee93-d2d7-4a43-9cfd-f94f5f100dbc/indicator.gif)



### 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
```
