This is a single integrated analog dashboard system consisting of:
All components are part of the same project and share the same data source.
┌──────────────────────────────────────────────────────────────────┐
│ ANALOG DASHBOARD PANEL │
│ (Single Physical Unit) │
├──────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ ESP32 #1: Display Controller │ │
│ │ Location: /home/claudessh/esp/dual-display-dashboard/ │ │
│ │ IP: 192.168.11.34 │ │
│ ├─────────────────────────────────────────────────────────┤ │
│ │ • Analog Meter 1 (GPIO26 PWM) - Download % │ │
│ │ • Analog Meter 2 (GPIO25 PWM) - Upload % │ │
│ │ • GC9A01 Round TFT (240x240) - WiFi client gauge │ │
│ │ + DL/UL text + time │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ ESP32 #2: LED Status Indicator │ │
│ │ Location: /home/claudessh/esp/led-test/ │ │
│ │ IP: 192.168.11.82 │ │
│ ├─────────────────────────────────────────────────────────┤ │
│ │ • LED 1 (GPIO25, RED) - Download > 100 Mbps │ │
│ │ • LED 2 (GPIO26, RED) - Upload > 5 Mbps │ │
│ │ • LED 3 (GPIO27, YELLOW) - WiFi clients > 10 │ │
│ │ • LED 4 (GPIO32, YELLOW) - WiFi clients > 20 │ │
│ │ • LED 5 (GPIO33, GREEN) - Reserved │ │
│ │ • LED 6 (GPIO12, GREEN) - Reserved │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────┘
▲
│ Both ESP32s fetch JSON every 1 second
│
┌─────────┴─────────┐
│ Dashboard Server │
│ 192.168.11.89 │
│ Port: 5000 │
│ Control UI: / │
└─────────┬─────────┘
│
┌─────────┴─────────┐
│ Observium DB │
│ Device Status │
│ Network Stats │
└────────────────────┘
All components use the same JSON endpoint: http://192.168.11.89:5000/api/display
/home/claudessh/dashboard-server/dashboard_server.pyThe web control panel at http://192.168.11.89:5000/ provides:
LED and gauge overrides are independent — you can override LEDs while gauges stay on auto, or vice versa.
| Endpoint | Method | Description |
|---|---|---|
/ |
GET | Web control panel UI |
/api/display |
GET | JSON data for ESP32 devices |
/api/status |
GET | Network/system status summary |
/api/leds/override |
POST | Set manual LED states (JSON body: {"led1": 1, ...}) |
/api/leds/auto |
POST | Return LEDs to automatic mode |
/api/gauges/override |
POST | Set manual gauge values (JSON body: {"meter1": 75, "meter2": 50, "gauge_value": 30}) |
/api/gauges/auto |
POST | Return gauges to automatic mode |
{
"leds": {
"led1": 0,
"led2": 0,
"led3": 1,
"led4": 0,
"led5": 0,
"led6": 0
},
"meter1": 2.1,
"meter2": 1.0,
"gauge_value": 4,
"gauge_max": 50,
"dl_mbps": 19.1,
"ul_mbps": 0.4,
"wifi_clients": 4,
"time": "05:48 PM",
"tm1637": "1748",
"gc9a01": {
"line1": "05:48 PM",
"line2": "DL: 19.1 Mbps",
"line3": "UL: 0.4 Mbps"
}
}
observium-db)
ifInOctets_rate, ifOutOctets_rate from ports table (interface: igb2)SUM(sensor_value) from sensors where sensor_descr LIKE 'Total%Clients'# Check if running
curl -s http://192.168.11.89:5000/api/display | python3 -m json.tool
# Start (as claudessh via SSH)
cd /home/claudessh/dashboard-server
nohup python3 dashboard_server.py > server.log 2>&1 &
# View logs
tail -f /home/claudessh/dashboard-server/server.log
/home/claudessh/esp/dual-display-dashboard/claude/esp-analog-dashboardwifi_clients field (not gauge_value) for the needlegc9a01.line2 and gc9a01.line3 for DL/UL displaymain/main.c - App entry, meters, display update loop
main/wifi_manager.c/h - WiFi STA connection management
main/dashboard_client.c/h - HTTP fetch + JSON parsing
main/gc9a01.c/h - SPI display driver
main/graphics.c/h - Gauge rendering, text drawing
cd /home/claudessh/esp/dual-display-dashboard
source /home/claudessh/esp/esp-idf/export.sh
idf.py build
idf.py -p /dev/ttyUSB0 flash # or /dev/ttyUSB1
idf.py -p /dev/ttyUSB0 monitor # requires TTY
/home/claudessh/esp/led-test/claude/esp-led-status| LED | GPIO | Color | Function (Auto Mode) |
|---|---|---|---|
| 1 | 25 | RED | Download > 100 Mbps |
| 2 | 26 | RED | Upload > 5 Mbps |
| 3 | 27 | YELLOW | WiFi clients > 10 |
| 4 | 32 | YELLOW | WiFi clients > 20 |
| 5 | 33 | GREEN | Reserved (default OFF) |
| 6 | 12 | GREEN | Reserved (default OFF) |
All LEDs have 4x 3.3kΩ resistors in parallel (≈825Ω) on negative side.
leds = {
'led1': 1 if dl > 100 else 0, # Red - Download > 100 Mbps
'led2': 1 if ul > 5 else 0, # Red - Upload > 5 Mbps
'led3': 1 if wifi > 10 else 0, # Yellow - WiFi clients > 10
'led4': 1 if wifi > 20 else 0, # Yellow - WiFi clients > 20
'led5': 0, # Green - Reserved
'led6': 0, # Green - Reserved
}
/api/display JSON every 1 secondleds object, sets GPIO levelsled5/led6 fields gracefully (defaults to OFF)cd /home/claudessh/esp/led-test
source /home/claudessh/esp/esp-idf/export.sh
idf.py build
idf.py -p /dev/ttyUSB0 flash # or /dev/ttyUSB1
| Device | IP |
|---|---|
| Dashboard Server | 192.168.11.89 |
| ESP32 #1 (Displays) | 192.168.11.34 |
| ESP32 #2 (LEDs) | 192.168.11.82 |
| Component | Status | Details |
|---|---|---|
| Dashboard Server | ✅ Running | Port 5000, Observium integration, web control panel |
| ESP32 #1 (Displays) | ✅ Deployed | WiFi working, meters + TFT operational |
| ESP32 #2 (LEDs) | ✅ Deployed | WiFi working, all 6 LEDs responding to dashboard |
| Observium Integration | ✅ Working | WAN speeds + WiFi client count queries |
| Control Panel UI | ✅ Working | Toggle switches (color-coded), gauge sliders, auto/override |
| Gitea Repos | ✅ Pushed | All 3 repos on git.resilientpathconsulting.net |
| Repo | Contents |
|---|---|
claude/esp-led-status |
ESP32 #2 WiFi LED firmware + handoff doc |
claude/esp-dashboard-server |
Flask dashboard server with control panel |
claude/esp-analog-dashboard |
ESP32 #1 display controller firmware |
Access at: http://git.resilientpathconsulting.net:3010/
/home/claudessh/esp/
├── dual-display-dashboard/ # ESP32 #1 (Displays) - WiFi ✅
│ ├── main/
│ │ ├── main.c # App entry, meters, display loop
│ │ ├── wifi_manager.c/h # WiFi connection management
│ │ ├── dashboard_client.c/h # HTTP fetch + JSON parsing
│ │ ├── gc9a01.c/h # Round TFT SPI driver
│ │ └── graphics.c/h # Gauge rendering
│ └── CMakeLists.txt
│
├── led-test/ # ESP32 #2 (LEDs) - WiFi ✅
│ ├── main/
│ │ └── main.c # WiFi + HTTP + JSON → 6 LEDs
│ ├── HANDOFF.md # Hardware/firmware handoff doc
│ └── CMakeLists.txt
│
├── LED_WIFI_UPDATE_HANDOFF.md # Original WiFi integration spec
└── ANALOG_DASHBOARD_PROJECT.md # This file
/home/claudessh/dashboard-server/
├── dashboard_server.py # Flask server (control panel + API)
├── start.sh # Startup script
└── server.log # Runtime logs
# Edit code
vim /home/claudessh/dashboard-server/dashboard_server.py
# Restart server
pkill -f dashboard_server.py
cd /home/claudessh/dashboard-server
nohup python3 dashboard_server.py > server.log 2>&1 &
# Test
curl -s http://localhost:5000/api/display | python3 -m json.tool
# Source ESP-IDF environment first
source /home/claudessh/esp/esp-idf/export.sh
# ESP32 #1 (Displays)
cd /home/claudessh/esp/dual-display-dashboard
idf.py build && idf.py -p /dev/ttyUSB0 flash
# ESP32 #2 (LEDs)
cd /home/claudessh/esp/led-test
idf.py build && idf.py -p /dev/ttyUSB0 flash
# Turn on LEDs 1, 3, 5
curl -X POST http://192.168.11.89:5000/api/leds/override \
-H 'Content-Type: application/json' \
-d '{"led1":1,"led2":0,"led3":1,"led4":0,"led5":1,"led6":0}'
# Set gauges to 75% download, 50% upload, 30 WiFi clients
curl -X POST http://192.168.11.89:5000/api/gauges/override \
-H 'Content-Type: application/json' \
-d '{"meter1":75,"meter2":50,"gauge_value":30}'
# Return to auto mode
curl -X POST http://192.168.11.89:5000/api/leds/auto
curl -X POST http://192.168.11.89:5000/api/gauges/auto
curl -s http://192.168.11.89:5000/api/display
# If no response:
pkill -f dashboard_server.py
cd /home/claudessh/dashboard-server
nohup python3 dashboard_server.py > server.log 2>&1 &
tail -20 /home/claudessh/dashboard-server/server.log
# Read serial output (no TTY needed)
stty -F /dev/ttyUSB0 115200 raw -echo
timeout 10 cat /dev/ttyUSB0 | strings
# Look for:
# "LED_STATUS: Connected! IP: 192.168.11.82"
# "LED_STATUS: LEDs updated from dashboard"
# Or error messages like "HTTP request failed"
# Check what the server is returning
curl -s http://192.168.11.89:5000/api/display | python3 -c \
"import json,sys;d=json.load(sys.stdin);print('LEDs:',d['leds']);print('DL:',d['dl_mbps'],'UL:',d['ul_mbps'],'WiFi:',d['wifi_clients'])"
This is a single unified analog dashboard project with two ESP32 controllers working together:
meter1, meter2, wifi_clients, gc9a01.*leds.*Important firmware detail: The display ESP32 reads wifi_clients (not gauge_value) for the TFT gauge needle. When overriding gauges, the server must also set wifi_clients, dl_mbps, ul_mbps, and gc9a01 text lines.
Do not treat these as separate projects. They are interconnected parts of one system.
Last Updated: April 11, 2026
Documentation Version: 2.0
System Status: Fully deployed and operational
ESP32 firmware that fetches JSON from a local Flask dashboard server and displays data on:
Connected via /dev/ttyUSB0 on host 192.168.11.89
| Pin | GPIO |
|---|---|
| MOSI (SDA) | 13 |
| SCLK | 14 |
| CS | 15 |
| DC | 2 |
| RST | 4 |
| VCC | 3.3V |
| GND | GND |
Uses SPI2_HOST at 40MHz. Full GC9A01 init sequence in gc9a01.c.
| Connection | GPIO/Pin |
|---|---|
| PWM output | GPIO 26 |
| VCC | 3.3V via resistor network |
| GND | GND |
Circuit: GPIO 26 → 1kΩ → 2N2222 base; collector → meter(+); meter(-) → GND; 3.3V → 3.3kΩ → meter(+)
PWM: 1kHz, 10-bit resolution (0-1023 duty). Meter was physically calibrated with the zero-adjust screw while holding 100% duty.
AliExpress "TM1637 0.36-inch Four-Digit LED Display Module" — actually has 5 pins:
Wired to GPIO 18 (DIO), 19 (SCLK), 25 (RCLK) but neither TM1637 nor 74HC595 protocol produced reliable output.
Flask server at http://192.168.11.89:5000/api/display
{
"tm1637": "1430",
"gc9a01": {
"line1": "02:30 PM",
"line2": "Temp: 72°F",
"line3": "Humid: 57%"
}
}
Server code: /home/claudessh/dashboard-server/dashboard_server.py
Start: cd /home/claudessh/dashboard-server && python3 dashboard_server.py &
export IDF_PATH=/home/claudessh/esp/esp-idf
export PATH="/home/nate/.espressif/tools/xtensa-esp-elf-gdb/14.2_20240403/xtensa-esp-elf-gdb/bin:/home/nate/.espressif/tools/xtensa-esp-elf/esp-14.2.0_20241119/xtensa-esp-elf/bin:/home/nate/.espressif/tools/esp32ulp-elf/2.38_20240113/esp32ulp-elf/bin:/home/nate/.espressif/tools/openocd-esp32/v0.12.0-esp32-20241016/openocd-esp32/bin:/home/nate/.espressif/python_env/idf5.4_py3.10_env/bin:/home/claudessh/esp/esp-idf/tools:$PATH"
export IDF_PYTHON_ENV_PATH="/home/nate/.espressif/python_env/idf5.4_py3.10_env"
cd /home/claudessh/esp/dual-display-dashboard
idf.py build
idf.py -p /dev/ttyUSB0 flash
Note: Must use newgrp claudessh or sg dialout for file access and serial port respectively.
main/
├── main.c # App entry, display update logic, PWM meter
├── wifi_manager.c/h # WiFi STA with auto-reconnect
├── dashboard_client.c/h # HTTP fetch + cJSON parsing
├── gc9a01.c/h # GC9A01 SPI driver (full init sequence)
├── graphics.c/h # 5x7 font, text, circles, arcs, gauge
├── tm1637.c/h # 74HC595 shift register driver (unused)
├── CMakeLists.txt
├── Kconfig.projbuild # WiFi SSID/password, dashboard URL config
├── idf_component.yml
AliExpress "TM1637" modules may not be TM1637. The actual chip/protocol depends on the manufacturer. The 5-pin variant (VCC, SCLK, RCLK, DIO, GND) is likely a 74HC595 shift register needing multiplexed driving.
TM1637 protocol on this module produced partial results — segments lit on positions 1 and 4 only, with incorrect segment mapping. The protocol was partially understood by the chip but not correctly.
74HC595 protocol was tested with all 8 combinations of (MSB/LSB, seg-first/dig-first, normal/inverted select, normal/inverted segments). Config C (LSB, seg first, dig second) showed "4321" once but could not be reproduced reliably.
All 6 permutations of the 3 GPIO pins mapped to (SCLK, RCLK, DIO) were tested. No combination produced reliable output.
ESP32 3.3V logic driving 5V module may be a contributing factor. A level shifter (3.3V→5V) might help.
Recommendation: Use a verified TM1637 4-pin module, or use SPI-based MAX7219 module which has well-documented protocol and ESP-IDF drivers.
BellNetsdkconfig.defaults and Kconfig.projbuildhttp://192.168.11.89:5000/api/displaygfx_draw_string_centered draws text with per-pixel background, avoiding a separate fill_rect clear (which causes visible flicker).