ESP32-S3 based e-paper clock with SCD41 CO2/temperature/humidity sensor. Uses CrowPanel 5.79" E-Paper display (792x272 pixels).
This project requires ESP32 Arduino Core 2.0.17 (not 3.x). A project-specific Arduino environment is used.
cd /path/to/EPDEnvClock
# Create project-specific Arduino config (if not exists)
cat > arduino-cli.yaml << 'EOF'
board_manager:
additional_urls:
- https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json
directories:
data: /Users/hiko/Documents/repos/Personal/EPDEnvClock/.arduino15
downloads: /Users/hiko/Documents/repos/Personal/EPDEnvClock/.arduino15/staging
user: /Users/hiko/Documents/Arduino
EOF
# Install ESP32 core 2.0.17
arduino-cli --config-file arduino-cli.yaml core update-index
arduino-cli --config-file arduino-cli.yaml core install esp32:esp32@2.0.17Use arduwrap wrapper for compile and upload (requires arduwrap serve running in another terminal):
# Start server first (in a separate terminal/tmux pane)
scripts/arduwrap serve --port /dev/cu.usbserial-2110 --baud 115200
# Compile and upload
scripts/arduwrap compile --fqbn esp32:esp32:esp32s3:PartitionScheme=huge_app,PSRAM=opi,UploadSpeed=460800 EPDEnvClock- Uses project-specific config from
arduino-cli.yamlautomatically --uploadflag is added automatically- Port is managed by the running server (no
-pneeded) - Serial monitoring is paused during upload, then reconnects automatically
- Upload speed: 460800 baud (921600 is unstable on macOS Tahoe/Darwin 25, fails at baud rate change)
- FQBN options are used for upload speed (
UploadSpeed=460800), NOT--build-property upload.speed=
Install in user library dir (shared with system):
arduino-cli lib install "Sensirion I2C SCD4x"
arduino-cli lib install "Adafruit MAX1704X"- Actual resolution: 792x272 pixels
- Buffer size: 800x272 = 27,200 bytes (EPD_W=800 for address offset)
- Controller: Dual SSD1683 ICs (master/slave, 396px each, 8px address offset in center)
- SDA: GPIO 38
- SCL: GPIO 20
- I2C Address: 0x62
- SDA: GPIO 14
- SCL: GPIO 16
- I2C Address: 0x36
- Note: Uses separate I2C bus from SCD41 sensor
- Power: Powered by battery (CELL+/CELL- must be connected to LiPo)
- MOSI=40, MISO=13, SCK=39, CS=10, Power=42
- HOME=2, EXIT=1, PRV=6, NEXT=4, OK=5
- I2C Bus: Wire1 (SDA=14, SCL=16)
- I2C Address: 0x36
- Reported: Voltage (V), State of Charge (%), Charge Rate (%/hr)
- Power: Chip is powered by battery (must connect CELL+/CELL- to LiPo)
- Error handling: All battery getters return
-1.0fon error (voltage, percent, chargeRate) - Init retry:
FuelGauge_Init()retries up to 3 times with Wire1 bus reset between attempts - I2C bus recovery: On retry attempts 2+,
recoverI2CBus()bit-bangs SCL up to 9 times to release a stuck SDA line, then sends STOP condition. Added Mar 2025 after MAX17048 hung for ~36 hours (SDA stuck LOW survived deep sleep cycles, only power cycle recovered it) - WiFi policy: Sensor error (
-1.0V) does NOT block WiFi — only genuinely low voltage does
Two percentage values are tracked:
-
Linear Percent (
g_batteryPercent) - Used for display- Formula:
(voltage - 3.4V) / (4.2V - 3.4V) * 100% - 3.4V = 0%, 4.2V = 100%
- More accurate than MAX17048 below 3.8V (based on Dec 2025 discharge testing)
- Device crashes at ~3.4V with WiFi due to brownout
- Formula:
-
MAX17048 Percent (
g_batteryMax17048Percent) - For reference/logging- ModelGauge algorithm from MAX17048
- Increasingly pessimistic below 3.8V
- Logged as
batt_max17048_percentin sensor logs
- GPIO: 8
- Mode: INPUT_PULLUP (open-drain output from 4054A)
- Logic: LOW = Charging, HIGH = Not charging (or no battery)
- Note: Read BEFORE I2C operations to avoid noise interference
EPDEnvClock/
├── EPDEnvClock.ino # Main sketch (setup/loop)
├── parallel_tasks.* # Dual-core parallel WiFi/NTP + sensor reading
├── display_manager.* # Display rendering, layout, battery reading
├── sensor_manager.* # SCD41 sensor (single-shot mode with light sleep)
├── fuel_gauge_manager.* # MAX17048 fuel gauge + 4054A charging detection
├── network_manager.* # Wi-Fi connection, NTP sync
├── deep_sleep_manager.* # Deep sleep, RTC state, SD/SPIFFS frame buffer
├── font_renderer.* # Glyph drawing with kerning support
├── logger.* # Logging with levels (DEBUG/INFO/WARN/ERROR)
├── EPD.*, EPD_Init.* # Low-level EPD driver
├── spi.* # Bit-banging SPI for EPD
└── bitmaps/ # Number fonts (L/M), icons, units, kerning table
- Deep sleep ~52-54 seconds, wake at minute boundary
- Wi-Fi/NTP sync at the top of every hour
- SD card power off during deep sleep (GPIO 42 LOW)
- Both I2C buses held HIGH during sleep (Wire: SCD41, Wire1: MAX17048) to prevent bus stuck
- WiFi skipped only on genuinely low battery (not on sensor error)
WiFi/NTP sync and sensor reading run in parallel using FreeRTOS tasks:
- Core 0: WiFi/NTP task (WiFi stack runs on Core 0)
- Core 1: Sensor task (I2C sensor reading)
This reduces startup time by ~2 seconds and enables single screen update (instead of two-phase update).
- Check if minute changed (skip update if same)
- Clear buffer, draw time/date/sensor values
EPD_Display()→EPD_PartUpdate()(orEPD_Update()for full refresh)- Save frame buffer to SD (or SPIFFS fallback)
EPD_DeepSleep()before entering deep sleep
- NTP server:
ntp.nict.jp(fallback:jp.pool.ntp.org,time.google.com), Timezone: JST (UTC+9) - Time saved to RTC memory before sleep, restored on wake
- NTP sync at the top of every hour (when
tm_min == 0)
ESP32's system clock is lost during deep sleep. On wake, time is restored from RTC memory:
wakeup_time = saved_time + sleep_duration + boot_overhead + drift_compensation
Critical: All calculations use microsecond precision (int64_t) to avoid truncation drift:
savedTime(seconds) +savedTimeUs(microseconds) stored separately- Integer division truncation caused ~1 second loss per cycle → ~1 minute/hour drift (fixed Dec 2025)
ESP32's internal 150kHz RC oscillator drifts at a temperature-dependent rate. This drift is now actively compensated:
// In restoreTimeFromRTC()
drift_compensation = sleep_minutes * driftRateMsPerMin * 1000; // in microseconds
wakeup_time += drift_compensation;Drift rate calibration:
- Default rate: 50 ms/min (initial value)
- Calibrated via NTP sync (hourly at minute 0)
- True drift = residual + cumulative compensation
- Drift rate unit: ms/min of sleep (derived from total deep-sleep minutes since last sync)
- Exponential moving average (50% old + 50% new) for balanced convergence
- Skip rate updates when NTP interval is too short (<30 min) to avoid instability after reboots
- Clamped to -600..600 ms/min to prevent runaway feedback (negative allowed when device runs fast)
- Learning rate (
alpha) is normally 0.7; it is reduced to 0.5 only when NTP sync took long (>400 ms) and residual drift is already small (<300 ms). If residual is still large, keep 0.7 to converge faster. - On large residual + sign flip, adopt new rate immediately (faster recovery)
- Persisted to SD card (
/drift_rate.txt) across power cycles
Note (Dec 2025): Custom NTP sync computes offset + RTT using full NTP timestamps (t1/t2/t3/t4) to avoid “network latency bias” in rtc_drift_ms. Older data (Transmit timestamp only) could look like ~-1s even when RTC drift compensation logic changed.
Temperature dependency (observed Dec 2025):
- Lower temperature → lower drift rate (at room temp)
- ~20-22°C: 25-40 ms/min (observed 12/25)
- EMA adapts automatically to temperature changes
Expected accuracy after compensation: typically sub-second residual per sync cycle; if the clamp is too tight, residual can get stuck around ~1 second
Logged fields (NTP sync only):
rtc_drift_ms: Residual drift after compensationcumulative_comp_ms: Total compensation applied since last syncdrift_rate: Current drift rate used (ms/min)
Goal: Wake up and update display exactly at minute boundary (XX:XX:00).
Sleep calculation:
sleepMs = (ms until next minute) - estimatedProcessingTimeFeedback loop (runs when NTP sync not performed):
- If woke too early (had to wait for minute change): decrease
estimatedProcessingTime - If woke too late (delay > 0.1s past boundary): increase
estimatedProcessingTime - Smoothing factor: 0.5 (gradual adjustment)
- Clamped to 1-20 seconds range
- Single-shot mode: send 0x219d command, light sleep 5s, read result
- Temperature offset: 4.0°C (compensates for self-heating)
- Falls back to periodic mode if single-shot fails
- All commit messages in English
- Sketch directory name must match .ino filename (
EPDEnvClock/EPDEnvClock.ino) wifi_config.his gitignored - usewifi_config.h.exampleas template- Number fonts use "Baloo Bhai 2 Extra Bold" font
- SCL pin is GPIO 20, not 21
- Date format uses periods: YYYY.MM.DD (not slashes)
- Frame buffer is 27,200 bytes (800x272, not 792x272)
- EPD uses bit-banging SPI (pins 11,12,45,46,47,48), SD uses hardware HSPI
- Button pins are active LOW with internal pullup
- SD card needs power enable (GPIO 42 HIGH) before use
- Upload speed must be 460800 (not 921600) on macOS Tahoe — set via FQBN option
UploadSpeed=460800 - I2C bus can get stuck across deep sleep — MAX17048 hung for 36hrs (Mar 2025),
recoverI2CBus()now handles this - SD card logs go to
logs/directory (gitignored) — containssensor_logs/,error_logs/,serial_logs/
The scripts/arduwrap wrapper provides convenient compile and serial log access:
# Compile and upload (requires serve running in another terminal)
scripts/arduwrap compile --fqbn esp32:esp32:esp32s3:PartitionScheme=huge_app,PSRAM=opi,UploadSpeed=460800 EPDEnvClock
# Get serial log (in-memory buffer; default 512KB, configurable via `arduwrap serve --buffer-kb`)
scripts/arduwrap log
# Get serial log from the latest host-side file log (useful if buffer was cleared on upload)
scripts/arduwrap log --from-file
# List recent host-side serial log files
scripts/arduwrap log --list-files
# Get filtered log (regex pattern)
scripts/arduwrap log --filter "ERROR|WARN"
# Get last N lines
scripts/arduwrap log -n 50
# Clear log buffer
scripts/arduwrap log --clear
# Stop the server
scripts/arduwrap stoppython3 scripts/imagebw_server.py --port 8080Enable in server_config.h: #define ENABLE_IMAGEBW_EXPORT 1
python3 scripts/create_number_bitmaps.py \
--font-path "/path/to/BalooBhai2-ExtraBold.ttf" \
--font-size-px 90 \
--output-dir "assets/Number M"cd web
bun run build
bunx wrangler pages deploy dist --branch master--branch masteris required to deploy to production (Cloudflare Pages production branch ismaster)- Any other branch name (including
main,production) deploys to preview only - Preview URLs:
https://<hash>.epd-sensor-dashboard.pages.dev - Production URL:
https://epd-sensor-dashboard.pages.dev - Local dev server:
bun run dev→ http://localhost:4321/
Analyze sensor data from D1 database:
python3 scripts/analyze_data.py [hours]Authentication Required:
The script uses wrangler d1 execute to query the D1 database. Authentication methods:
-
Option 1: CLOUDFLARE_API_TOKEN in
.env(recommended)-
Add to
.envfile in project root:CLOUDFLARE_API_TOKEN=your-cloudflare-api-token
-
Wrangler automatically reads
CLOUDFLARE_API_TOKENfrom environment -
Get token from: https://dash.cloudflare.com/profile/api-tokens
- Required permissions: Account → D1 → Read
-
.envis gitignored (not committed)
-
-
Option 2: Wrangler Login (session expires)
cd web bunx wrangler loginCredentials are stored in
~/.wrangler/config/default.toml- Session expires, requires re-login periodically
-
Option 3: Manual export (temporary, current shell only)
export CLOUDFLARE_API_TOKEN='your-cloudflare-api-token'
Note: .env file also contains CF_ACCESS_CLIENT_ID and CF_ACCESS_CLIENT_SECRET for Cloudflare Access (API authentication to the dashboard). D1 database access uses CLOUDFLARE_API_TOKEN.
Database Info:
- Database name:
epd-sensor-db - Database ID:
fc27137d-cc9d-48cd-bfc0-5c270356dc98 - Config:
web/wrangler.toml
The script analyzes:
- RTC drift values and drift rate calibration
- Cumulative compensation and residual error
- Battery voltage trends and WiFi skip patterns
- Sensor readings (temperature, humidity, CO2)
- WiFi/NTP sync frequency vs battery voltage
- Temperature vs drift rate correlation