An ESP32-based device for displaying and setting the Makerspace Bonn open/closed status. Features an OLED display showing current time and status, with a rotary encoder for setting closing times.
- Status Display: Shows current time and makerspace open/closed status
- Time Setting: Use rotary encoder to select closing time in 15-minute increments
- MQTT Integration: Receives real-time status updates via MQTT
- NTP Time Sync: Automatic time synchronization with DST support (CET/CEST)
- Screensaver: Bouncing logo animation with configurable timeout to prevent OLED burn-in
- Auto-Reconnect: WiFi and MQTT reconnection with keepalive pings and a receive watchdog, so a silently dead broker connection is detected instead of freezing the status
- State Freshness: Status older than
STATE_MAX_AGEis reported as unknown (?) rather than shown as current - Configurable Brightness: Separate brightness levels for init, normal, and screensaver modes
- Logging System: Centralized logging with configurable levels (DEBUG/INFO/WARN/ERROR)
| Component | Specification |
|---|---|
| Microcontroller | ESP32 |
| Display | SH1106 128x64 OLED (I2C) |
| Input | Rotary encoder with push button |
| Function | GPIO Pin |
|---|---|
| I2C SCL | 7 |
| I2C SDA | 6 |
| Button | 8 |
| Rotary A | 2 |
| Rotary B | 1 |
Everything below runs from the command line. Two tools are involved and they do different jobs:
| Tool | Job |
|---|---|
esptool |
Writes the MicroPython firmware to flash. Needed once per board. |
mpremote |
Copies application files onto a board that already runs MicroPython, and opens the REPL. This is the day-to-day tool. |
# mpremote - into the project venv, so it matches the repo
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
# esptool - only needed to flash firmware onto a fresh board
brew install esptool # or: .venv/bin/pip install esptool.venv/bin/mpremote devsThe board appears as an Espressif USB JTAG/serial device:
/dev/cu.usbmodem2101 aa:bb:cc:dd:ee:ff 303a:1001 Espressif USB JTAG/serial debug unit
Use that path as <PORT> below. On Linux it is usually /dev/ttyACM0 or
/dev/ttyUSB0. mpremote also accepts the shorthand a0, a1, ... for
/dev/ttyACM* and u0, u1, ... for /dev/ttyUSB*.
Skip this if the board already runs MicroPython - mpremote devs listing it is
a good sign that it does.
Download a firmware build for your board from
micropython.org/download - this project
runs on a LOLIN C3 MINI (ESP32-C3FH4), so
LOLIN_C3_MINI is the right
build. Then:
esptool --chip esp32c3 --port <PORT> erase-flash
esptool --chip esp32c3 --port <PORT> --baud 460800 \
write-flash -z 0x0 LOLIN_C3_MINI-<version>.binNotes:
- The offset is
0x0on ESP32-C3, not the0x1000used by the classic ESP32. Getting this wrong produces a board that boots to nothing. - Subcommands are spelled with dashes on esptool v5 (
write-flash). Older v4 releases use underscores (write_flash); v5 still accepts those with a deprecation warning. erase-flashwipessecrets.pyalong with everything else, so re-upload the whole application afterwards.- If esptool cannot reset the board into its bootloader, hold BOOT, tap RESET, release BOOT, then re-run the command.
Create src/secrets.py with your credentials. It is gitignored and never
committed:
wifi_access = {
"YourSSID": "YourPassword",
# Add multiple networks for fallback
}
mqtt_server = "your.mqtt.server"
mqtt_user = "username"
mqtt_pass = "password"
API_key = "your-api-key"The device has a flat filesystem - files live at the root, not under src/, so
copy the contents of src/ to ::
.venv/bin/mpremote connect <PORT> cp src/*.py src/*.pbm src/*.pf :mpremote compares hashes and prints Up to date for files it skips, so
re-running this is cheap and it is safe to use for incremental updates.
The glob deliberately lists the three extensions rather than using cp -r src/.,
which would drag __pycache__ onto the device.
To push only what you changed:
.venv/bin/mpremote connect <PORT> cp src/mqtt_service.py src/main.py :Uploading interrupts the running program, so reset when you are done:
.venv/bin/mpremote connect <PORT> resetSettings are defined at the top of main.py:
# Logging
LOG_LEVEL = 1 # 0=DEBUG, 1=INFO, 2=WARN, 3=ERROR
# Screensaver
SCREENSAVER_TIMEOUT = 300 # Seconds of inactivity (300 = 5 min)
# How long to wait for the broker to confirm a closing time we just set
REQUEST_CONFIRM_TIMEOUT = 10
# Display brightness (0-255)
BRIGHTNESS_INIT = 50 # During startup
BRIGHTNESS_NORMAL = 200 # Normal operation
BRIGHTNESS_SCREENSAVER = 5 # Screensaver modeMQTT connection health is tuned in mqtt_service.py:
KEEPALIVE = 60 # Broker keepalive announced at CONNECT
PING_INTERVAL = 20 # Send PINGREQ this often - the device only subscribes,
# so without pings the broker drops it after ~90s
RX_TIMEOUT = 90 # No inbound bytes at all for this long = dead socket
STATE_MAX_AGE = 23 * 60 * 60 # Received state is trusted for this long (23h)
READ_TIMEOUT = 5 # Ceiling on any blocking socket readmsb/state is published on change and retained by the broker, so the topic is
quiet for long stretches - a device that has heard nothing for an hour is
almost certainly just looking at a space whose status has not changed. Liveness
is the job of PING_INTERVAL and RX_TIMEOUT, which watch the connection;
STATE_MAX_AGE only exists so that a status we can no longer refresh at all
eventually stops being presented as current. Setting it to minutes would show
? nearly all the time.
The device subscribes to msb/state and never publishes. Without a periodic
PINGREQ it sends the broker nothing, so the broker closes the connection once
the keepalive lapses. Worse, umqtt.simple.check_msg() cannot tell a quiet
connection from a dead one - a half-open socket just returns None forever and
never raises. The result was a display stuck on hours-old status. RxWatchSocket
wraps the live socket to make inbound bytes (including PINGRESP) visible, which
is what lets the watchdog fire.
- Display shows current time and makerspace status (open/closed)
- If open, shows closing time
- Shows
?when no recent status has been received, rather than a stale value
- Turn rotary encoder to select desired closing time
- Press button to confirm
- Device sends new closing time to the API
- Display returns to normal once MQTT reports that closing time, or after
REQUEST_CONFIRM_TIMEOUTseconds if no matching confirmation arrives
- Activates after configurable timeout (default 5 minutes)
- Shows bouncing MSB logo with lock status icon
- Reduced brightness to save power and reduce burn-in
- Any input (rotation or button press) wakes the display
# Interactive REPL - Ctrl-] to exit, Ctrl-C to interrupt the running program
.venv/bin/mpremote connect <PORT> repl
# List files on the device
.venv/bin/mpremote connect <PORT> fs ls
# Pull a file back off the device
.venv/bin/mpremote connect <PORT> cp :main.py ./main-from-device.py
# Run a local file on the device without installing it
.venv/bin/mpremote connect <PORT> run src/main.py
# One-off expression against the device
.venv/bin/mpremote connect <PORT> exec "import os; print(os.listdir())"
# Host-side unit tests (no hardware needed)
./test.shmpremote repl shows the log output live. For a non-interactive capture -
useful for watching a boot sequence end to end - read the serial port directly:
.venv/bin/mpremote connect <PORT> reset
.venv/bin/python - <<'EOF'
import serial, time
ser = serial.Serial('<PORT>', 115200, timeout=0.5)
start = time.time()
while time.time() - start < 120:
line = ser.readline()
if line:
print("[%6.1fs] %s" % (time.time() - start,
line.decode('utf-8', 'replace').rstrip()))
EOFA hard reset makes the native-USB C3 drop off the bus and re-enumerate, so give the port a moment (or retry the open) before reading.
The serial module used above is pyserial, which mpremote already pulls in,
so it is present in the venv without a separate install.
exec "import main"does not inspect the running program - it starts a second one. MicroPython executesmain.pyas a script at boot rather than importing it, so importing it from the REPL re-runs the whole thing, infinite loop included, and hangs your session. To inspect service internals, build a throwaway instance in the REPL instead:.venv/bin/mpremote connect <PORT> exec " import secrets from mqtt_service import MQTTService s = MQTTService(secrets.mqtt_server, secrets.mqtt_user, secrets.mqtt_pass, 'probe') print(s.connect_and_subscribe(), s.rx_watch_active) "
- Only one program can hold the port. If
mpremotereports the device is busy, close any REPL or serial monitor still attached. [STATUS]lines only print in normal mode, so they stop once the screensaver kicks in. Turn the encoder to wake it if you are watching for them.
src/
├── main.py # Main application loop and configuration
├── logger.py # Centralized logging module
├── MSBDisplay.py # Display rendering (status, screensaver)
├── mqtt_service.py # MQTT client with auto-reconnect
├── wifi_manager.py # WiFi connection management
├── state_manager.py # API communication
├── button_handler.py # Button input with debouncing
├── rotary_irq_esp.py # Rotary encoder driver
├── enhanced_display.py # Extended display functions
├── sh1106.py # SH1106 OLED driver
├── packed_font.py # Custom font rendering
├── *.pbm # Bitmap images (logo, icons)
├── *.pf # Packed font files
└── secrets.py # Credentials (not in repo)
On the device:
- MicroPython (ESP32 port) - developed against 1.25
umqtt.simple(included in MicroPython)urequests(included in MicroPython)
On the host, for flashing and uploading:
MIT License