A personal hardware + firmware project built from scratch — custom PCB design, cooperative non-blocking firmware, MQTT telemetry, local web dashboard, OTA updates, and Telegram push alerts.
- Displays NTP-synced time (UTC+7) and live temperature/humidity on a 16×2 I²C LCD
- Publishes sensor data to Adafruit IO via MQTT every 30 seconds
- Serves a local web dashboard (Chart.js, 30-min history, editable thresholds)
- Sends Telegram push notifications when temperature, humidity, or battery crosses a threshold
- Supports OTA firmware updates over WiFi — no USB cable needed after first flash
- Enters deep sleep on keypad command to save power; wakes, publishes one reading, sleeps again
Designed entirely from scratch: schematic and single-layer PCB layout in Proteus, then fabricated and hand-soldered.
Components: ESP32 DevKit v1 · DHT11 · 4×4 Keypad · 16×2 LCD (I²C) · Custom PCB rev1
| Block | Signal | ESP32 GPIO |
|---|---|---|
| LCD 1602 (I²C) | SDA / SCL | 21 / 22 |
| DHT11 | DATA | 5 |
| Keypad 4×4 | Rows | 32, 33, 25, 26 |
| Keypad 4×4 | Cols | 13, 12, 14, 27 |
| Battery ADC | VBAT (via divider) | 34 |
| Power | 5 V | VIN jack |
GPIO 12 and GPIO 5 are ESP32 strapping pins — noted as rev1 limitation, will be remapped in rev2.
The firmware is a cooperative, non-blocking scheduler — loop() ticks each service's update() in turn. Every update() returns in microseconds; timing is handled with millis(). No delay() anywhere in the steady-state loop.
loop()
├── timeService.update() // WiFi state machine + NTP sync
├── battery.update() // ADC voltage read every 30 s
├── sensor.update() // throttled DHT11 read
├── display.update() // LCD mode switch + redraw
├── telemetry.update() // MQTT state machine + publish
├── webDash.update() // HTTP handleClient()
├── ota.update() // ArduinoOTA handle()
├── alerter.update() // threshold check + Telegram task
└── handleInput() // keypad events
| Module | Responsibility |
|---|---|
TimeService |
WiFi state machine (CONNECTING → CONNECTED → FAILED → retry), NTP |
EnvironmentSensor |
Throttled DHT11 reads, cached values, validity flag |
DisplayManager |
LCD render, auto CLOCK ↔ ENVIRONMENT cycling, manual override |
TelemetryService |
MQTT state machine, JSON publish, LWT online/offline, command subscription |
WebDashboard |
HTTP server, Chart.js UI, NVS-persisted thresholds, alert log |
AlertNotifier |
Threshold monitoring, Telegram HTTPS via background FreeRTOS task |
OTAService |
ArduinoOTA wrapper, mDNS hostname, LCD progress during flash |
BatteryMonitor |
ADC voltage divider, LiPo percentage estimation |
KeypadInput |
4×4 matrix scanning |
Non-blocking MQTT reconnect — TelemetryService runs a state machine (WAITING_FOR_WIFI → CONNECTING → CONNECTED → FAILED) that mirrors the pattern in TimeService, so MQTT drops and WiFi drops are handled independently without stalling the loop.
Last Will & Testament — the MQTT broker auto-publishes offline to the status feed if the device disappears without a clean disconnect, giving the dashboard accurate presence info.
Telegram in a FreeRTOS task — HTTPS to Telegram can take 1–3 seconds. AlertNotifier spawns a background task so the main loop never blocks. The task frees itself on completion.
NVS persistence — alert thresholds edited in the web dashboard survive power cycles via the ESP32's Non-Volatile Storage.
- VS Code + PlatformIO IDE
- ESP32 DevKit v1 and a USB data cable (required for first flash only)
- WiFi network · Adafruit IO free account · Telegram bot (optional)
git clone https://github.com/DuyHai33/esp32-digital-clock.git
cd esp32-digital-clock
cp include/secrets.example.h include/secrets.hFill in include/secrets.h:
#define WIFI_SSID "your-wifi"
#define WIFI_PASSWORD "your-password"
#define MQTT_USERNAME "adafruit-io-username"
#define MQTT_PASSWORD "adafruit-io-key"
#define OTA_PASSWORD "choose-a-password"
#define TELEGRAM_BOT_TOKEN "" // leave empty to disable
#define TELEGRAM_CHAT_ID ""pio run -e esp32doit-devkit-v1 --target upload
pio device monitor # 115200 baudLook for OTA ready — hostname=esp32-clock in the log before trying OTA.
cp platformio.local.ini.example platformio.local.ini
# Edit platformio.local.ini — set --auth= to match OTA_PASSWORD in secrets.h
pio run -e esp32-ota --target upload| Key | Action |
|---|---|
A |
Show clock |
B |
Show temperature / humidity |
C |
Enter deep sleep (wakes every 5 min, publishes one reading) |
D |
Cancel deep sleep mode |
Auto-cycle when idle: clock for 30 s → environment for 2 min, repeating.
Open http://esp32-clock.local in a browser on the same network.
- Dashboard — live cards (temp, humidity, battery, WiFi RSSI) + 30-min Chart.js history
- Settings — edit alert thresholds, saved instantly to flash
- Alerts — timestamped log of every threshold breach
When a threshold is breached, a push notification is sent instantly via Telegram bot.
| Feed | Direction | Value |
|---|---|---|
temperature |
Publish | °C, every 30 s |
humidity |
Publish | %, every 30 s |
battery |
Publish | %, every 30 s |
status |
Publish (LWT) | online / offline |
command |
Subscribe | {"cmd":"set_mode","value":"clock"} |
esp32-digital-clock/
├── include/
│ ├── config.h # Pin map, timing, MQTT broker & topics
│ ├── secrets.example.h # Credential template (safe to commit)
│ ├── secrets.h # Actual credentials (gitignored)
│ └── Logger.h # LOGI / LOGW / LOGE macros
├── src/
│ ├── main.cpp
│ ├── TimeService.{h,cpp}
│ ├── EnvironmentSensor.{h,cpp}
│ ├── DisplayManager.{h,cpp}
│ ├── TelemetryService.{h,cpp}
│ ├── WebDashboard.{h,cpp}
│ ├── AlertNotifier.{h,cpp}
│ ├── OTAService.{h,cpp}
│ ├── BatteryMonitor.{h,cpp}
│ └── KeypadInput.{h,cpp}
├── docs/
│ ├── images/ # Screenshots and photos
│ └── hardware/ # Proteus schematic & PCB layout files
├── platformio.ini
├── platformio.local.ini.example
├── .gitignore
├── LICENSE
└── README.md
| Limitation | Planned fix (rev2) |
|---|---|
| GPIO 12 & 5 are strapping pins | Remap to non-strapping GPIOs |
| DHT11 accuracy ±2 °C / ±5 % RH | Upgrade to DHT22 or BME280 |
| Single-layer PCB, limited routing | Two-layer design |
| I²C pull-ups may exceed 3.3 V if LCD is 5 V | Add level shifter |
- Light sensor — BH1750 on shared I²C bus
- Air quality — PMS5003 dust sensor on UART2 (GPIO 16/17)
- NVS-persisted user config (no web form required after reboot)
- Rev2 PCB
MIT — see LICENSE.
Nguyen Duy Hai · Embedded Software / IoT · github.com/DuyHai33 · nguyenduyhaivt0303@gmail.com







