A physical meeting indicator: when any app on your computer opens the microphone, the LED cube on your desk (or outside your office door) turns red. When the mic closes, it turns green. No more family members wandering into frame.
βββββββββββββββ mic state ββββββββββββββ WebSocket ββββββββββββ IR (NEC) ββββββββββββ
β OS audio βββ(push β host app βββ"<token>:R"βββΆβ ESP8266 βββββββ38kHzββΆβ LED cube β
β stack β events)βββββΆβ (Python) β β + IR LED β βcontrollerβ
βββββββββββββββ ββββββββββββββ ββββββββββββ ββββββββββββ
The cube itself is any cheap RGB/WRGB LED lamp that ships with a 24-key NEC IR remote β the ESP8266 simply impersonates the remote.
On Windows, grab MeetMaster.exe and double-click it. Everything else below is for building the cube or running the watcher on macOS/Linux.
-
Event-driven mic detection on all three desktop OSes β no screen scraping, no polling loops. The watcher thread sleeps until the OS says the mic state changed, then reacts in under a second:
OS Source of truth Wakeup mechanism Windows CapabilityAccessManager\ConsentStore\microphoneregistry keys (the same data behind the taskbar mic dot)RegNotifyChangeKeyValuemacOS CoreAudio kAudioDevicePropertyDeviceIsRunningSomewhereon the default input deviceAudioObjectAddPropertyListenerLinux PulseAudio/PipeWire recording streams ( pactl list source-outputs, monitor sources excluded)pactl subscribe -
Zero configuration β no IP addresses anywhere. The cube answers a UDP broadcast and advertises
espcube.local; the host rediscovers it mid-run when DHCP moves it. -
Day/night dimming β full brightness by day, dimmer after dark, gliding over ~15 minutes at sunrise and sunset. Sun times are computed on-device from lat/lon, so no weather API is involved.
-
OTA updates β after the first USB flash, reflash over WiFi forever.
-
Authenticated commands β the cube ignores WebSocket messages without the shared token, so nobody else on your LAN can turn your office red.
-
Control panel served by the cube β browse to it from any phone or laptop; no token is baked into the image.
-
/infodiagnostics endpoint β flash geometry, clock, sun times, and day/night state. Check this first when OTA misbehaves. -
ircube.pybench tool β dependency-free REPL for firing individual IR codes, sweeping brightness, and scanning the NEC command space.
- ESP8266/ESP8285 board
- IR LED on GPIO4 (add a transistor driver for more range)
- Any RGB/WRGB LED cube using the common 24-key NEC remote
If your lamp uses different IR codes, point IRrecvDumpV2 (from
IRremoteESP8266's examples) at your remote and swap the #defines at the
top of the sketch.
This is the one that will cost you an afternoon. Many of these modules
are ESP8285 parts with 2 MB of embedded flash, not 4 MB NodeMCUs.
Build for the wrong size and the sketch still runs fine over USB β but
every OTA update is rejected with ERROR[8]: Flash config wrong, because
the updater compares the running image's flash header against the real
chip and needs room for two images.
Check the real chip before building:
esptool.py --port /dev/ttyUSB0 flash_idThen match it in the IDE: Board: "Generic ESP8266 Module", Flash Size:
"2MB" (or whatever flash_id reported). Once running, http://<cube>/info
reports flash ok: yes|NO so you never have to guess again.
Rebuilding at the correct size does not fix a device already flashed wrong β the check reads the header of the running firmware, so it takes one corrected USB flash to escape.
- Use the stable by-id path, not
/dev/ttyUSB0. If the adapter is re-plugged, thettyUSB0node can point at a dead instance and every connection silently times out:/dev/serial/by-id/usb-FTDI_FT232R_USB_UART_XXXXXXXX-if00-port0 - Don't run a separate
flash_id/chip_idprobe with--after no_resetbefore flashing. That leaves esptool's RAM stub loader running and breaks the next sync. Do the whole job in one invocation. - Watch for backpowering: if the board is fed 5 V and the USB-serial adapter, unplugging only one won't actually reset it. Pull both.
- If the module is powered from an FT232R's 3V3 pin, expect brownouts β that regulator sources ~50 mA and an ESP8266 pulls ~350 mA bursts when the radio transmits.
- Arduino IDE with the ESP8266 core, plus libraries:
ESPAsyncTCP,ESPAsyncWebServer,IRremoteESP8266. cp firmware/ESPcube/secrets.h.example firmware/ESPcube/secrets.hand fill in WiFi credentials, a command token, and an OTA password.- Set the board and flash size per the section above, then flash over USB once.
- Every flash after that: Tools β Port β Network ports β
ESPcubeXXXX. The IDE prompts for your OTA password.
The cube strobes while connecting to WiFi, flashes white on success, and strobes again during an OTA update.
A single self-installing tray app, for the machine you actually take calls
on. Download MeetMaster.exe from the
latest build
(Artifacts section) and double-click it.
On first run it:
- copies itself to
%LOCALAPPDATA%\Programs\MeetMaster\β so it keeps working after you pull the USB stick out - registers auto-start for your user (
HKCU\...\CurrentVersion\Run) - asks once for the cube's token
- drops into the system tray and starts watching
Right-click the tray icon for:
| Item | Does |
|---|---|
| (status line) | shows free / on a call, and which cube it found |
| Start with Windows | checkable β toggles auto-start on the spot |
| Set token⦠| change the shared secret |
| Find cube again | force rediscovery after a router reboot |
| Exit | quit |
The icon itself is the status: green free, red on a call, grey if the cube can't be reached. It finds the cube by broadcast, so no IP is ever typed in.
Windows will warn that the exe is unsigned the first time β More info β Run anyway. Code signing needs a certificate; there isn't one here. Some antivirus engines also flag single-file PyInstaller builds on sight, which is a known false positive for the packer rather than anything about this program.
Corporate endpoint policy (AppLocker and friends) often refuses to run an
unsigned packed exe at all. MeetMaster-portable.zip (also on the
release) sidesteps
the whole class of problem: it's the official embeddable Python runtime
β whose python.exe is signed by the Python Software Foundation β plus the
stdlib-only watcher, driven by plain batch files. No PyInstaller, no pip,
no admin anywhere.
Unzip it (on the USB stick is fine) and double-click
Install MeetMaster.bat: it copies itself to
%LOCALAPPDATA%\Programs\MeetMaster, asks once for the token, registers
per-user auto-start (HKCU, no elevation), and starts the same tray app β
green/red/grey status icon, right-click menu with the checkable Start
with Windows, Open settings file, Find cube again, and Exit. The
tray icon here is raw Win32 via ctypes, so nothing needs to be installed
for it. If the machine already has Python, the scripts use that
automatically and the bundled runtime isn't even required.
To uninstall: Exit from the tray menu, untick Start with Windows first (or
delete the MeetMaster value under
HKCU\Software\Microsoft\Windows\CurrentVersion\Run), then delete
%LOCALAPPDATA%\Programs\MeetMaster\ and %APPDATA%\MeetMaster\.
PyInstaller can't cross-compile, so the exe is built by
.github/workflows/build-windows.yml on a Windows runner. On a Windows box:
pip install pyinstaller pystray pillow
pyinstaller --onefile --windowed --name MeetMaster --paths host \
--hidden-import pystray._win32 --icon windows/meetmaster.ico \
windows/meetmaster.pycd host
cp config.example.py config.py # set TOKEN; leave CUBE_URL = None
python3 ESPcubeEXE.py # console watcher β no dependencies at allESPcubeEXE.py is pure standard library: detection, discovery, and the
WebSocket frame are all hand-rolled, so it runs on a locked-down machine
with no pip install and no admin rights. It's also the quickest way to
verify a new box β join a test call and watch for call started.
The tray version needs two packages:
pip install -r requirements.txt # pystray, pillow
python3 ESPcube.pyPer-OS notes:
- Windows: use MeetMaster instead β it packages all of this with auto-start and a tray menu.
- macOS: no extra dependencies (CoreAudio via ctypes).
- Linux: needs
pactl(present on any PulseAudio or PipeWire desktop). The tray icon wants an AppIndicator/ayatana host; on GNOME that's the AppIndicator extension, or just run the headless variant.
To start it with your session: a Login Item or launchd agent (macOS), or a systemd user service / autostart entry (Linux).
Just browse to the cube β http://<cube-ip>/ serves the control panel
straight from the firmware. Colors, brightness, effects, and a raw-IR box,
on desktop or phone.
Enter the token once; the browser keeps it in localStorage. It is deliberately not baked into the firmware image, so serving the page to your LAN doesn't hand out the ability to drive the cube. The host field prefills itself when the cube serves the page.
The same file lives at host/cube_control.html if you'd rather open it
from disk. It's the single source of truth β after editing it, run
python3 tools/embed_html.py to regenerate firmware/ESPcube/webpage.h,
then reflash.
| Cube | State |
|---|---|
| Pulsing blue | Busy β booting, joining WiFi, or taking an OTA update |
| Solid white | WiFi joined successfully (3 s) |
| Green | Mic closed β nobody's on a call. This is the resting default. |
| Red | Mic is open β you're on a call |
Red is never used for anything but an open mic, so it can't be misread. The cube settles on green after boot rather than going dark, because "no call in progress" is a real state worth showing.
The cube can't be driven while the ESP sits in the serial bootloader β no code is running β so it holds whatever color it had.
Neither end needs to know the other's IP. The host app finds the cube by, in order:
- an explicit
CUBE_URLinconfig.py, if you set one (leave itNone) - the address that worked last time, cached on disk
- UDP broadcast β the cube listens on port 9999 and answers with its own address
- mDNS β
espcube.local
DHCP can move the cube whenever it likes; the first send fails, the app rediscovers, and it keeps working without a restart. The control page has the same property from the other direction: served by the cube, it reads its own address out of the URL.
If discovery ever comes up empty, the usual cause is AP client isolation
(common on guest networks), which blocks the broadcast. Pinning CUBE_URL
works around it.
The cube runs at full brightness during the day and backs off after dark, gliding one step at a time over about 15 minutes so sunset reads as dusk rather than a switch being thrown. Whatever color is showing at the time just gets dimmer β the color itself never changes.
Sunrise and sunset are computed on the device from your latitude and
longitude (set in secrets.h) using the standard Almanac algorithm, so
there's no weather API to depend on β only NTP for the clock. Daylight
saving is handled by the POSIX TZ string, which encodes the rules.
Check what it thinks with http://<cube>/info:
local time: 12:38
sunrise: 05:56
sunset: 19:54
mode: day (full)
Tune the night level with kNightDimSteps in the sketch (how many steps
below maximum) and kGlideStepMs (how long the fade takes).
python3 host/ircube.py # REPL
python3 host/ircube.py R # one-shot
python3 host/ircube.py sweep R # guided brightness sweepsweep floors the brightness, then steps BRIGHT_UP one press at a time
with a pause, printing the press number β watch the cube and note where it
stops changing. That tells you the controller's real step count instead of
guessing. raw <name|hex> fires a single IR code with no ON wrapper and
no auto-ramp, for isolating what each code actually does.
This remote is NEC address 0x00, so every possible button is
00FF<cmd><~cmd> β the whole command space is just 256 codes, and the 24
printed on the remote are only a tenth of them. scan walks all of them
with a pause between each, labelling the ones you already know:
python3 host/ircube.py # then: scan (all 256, ~6 min)
# or: scan 00 3f (a subrange)Watch the cube and note any command that does something the remote's own
buttons can't β a direct brightness level, a different white balance, a
stored scene. Send one by number afterwards with nec 90.
On a WRGB cube, W drives a dedicated white emitter while R drives a
single red die, so red reads dimmer than white even at maximum brightness.
If red isn't bright enough for your room, that's a hardware ceiling, not a
firmware bug β use a bigger cube or a dedicated red source.
One-shot WebSocket messages to ws://<cube>/ws, formatted
<token>:<command>:
| Command | Effect |
|---|---|
R G B W V P Y LG O YO |
solid colors (auto ON + brightness ramp) |
FLASH STROBE FADE SMOOTH |
built-in effects |
ON OFF BRIGHT_UP BRIGHT_DOWN |
power and manual brightness |
RAW:<hex> |
one raw NEC frame, no wrapper (diagnostics) |
BUMP:<n> |
press BRIGHT_UP n times (diagnostics) |
Anything on your network that can open a WebSocket can drive the cube β the meeting indicator is just one client.
- Commands require a shared token; OTA requires a password. Both live only
in gitignored files (
firmware/ESPcube/secrets.h,host/config.py). - Traffic is plaintext on your LAN (an ESP8266 isn't going to TLS its way out of that); the threat model is "mischievous roommate", not nation-state.
MIT