Reads water meter digits using an ESP32 camera and PaddleOCR. Battery powered, no cables.
The ESP32 wakes from deep sleep on a timer, takes a photo of the meter, and POSTs the JPEG to your self-hosted OCR service.
The service extracts the reading and publishes it to Home Assistant via MQTT discovery, with Prometheus metrics for monitoring.
- Hardware
- OCR Service
- ESP32 Installation
- Configuration
- API
- Local Development
- Compared to Other Projects
| Component | Description |
|---|---|
| M5Stack Timer Camera X | ESP32-based controller with built-in battery, RTC, and deep sleep support. Has a 3MP OV3660 camera. |
| M5Stack Unit FlashLight | LED flash unit. Connected via the GROVE port. Required for meters installed in dark enclosures. |
The service listens on port 8080 by default. Note the hostname or IP address where you deploy it — you'll need it when configuring the Arduino sketch.
docker run -d -p 8080:8080 ghcr.io/dcelasun/esp32-meter-reader:latestWith MQTT and storage:
docker run -d -p 8080:8080 \
-v meter-data:/data \
-e STORAGE_PATH=/data \
-e MQTT_BROKER=tcp://192.168.1.100:1883 \
-e MQTT_USER=homeassistant \
-e MQTT_PASSWORD=secret \
-e METER_DIVISOR=1000 \
ghcr.io/dcelasun/esp32-meter-reader:latestCopy and customize the example manifest:
cp k8s/manifest.example.yaml k8s/manifest.yaml
# Edit the Secret, env vars, and resource limits to match your cluster
kubectl apply -f k8s/manifest.yamlThe manifest includes a Secret for MQTT credentials, a PVC for image storage, a Deployment, a Service, and a ServiceMonitor for Prometheus metrics. See k8s/manifest.example.yaml for all available environment variables.
The ESP32 runs an Arduino sketch that handles the wake → capture → upload → sleep cycle.
- Arduino IDE or PlatformIO
- M5Stack Board Manager v2.0.9+
- Libraries:
Open esp32/m5stack_timer_camera_x.ino and edit the defines at the top of the file. Set _SERVER_HOST and _SERVER_PORT to the hostname/IP and port of the OCR service you deployed above.
#define _WIFI_SSID "My SSID" // Your WiFi network name
#define _WIFI_PASS "MyPassword" // Your WiFi password
#define _SERVER_HOST "192.168.1.50" // IP or hostname of the OCR service
#define _SERVER_PORT 8080 // Port of the OCR service (default: 8080)
#define SLEEP_INTERVAL_SECS 14400 // Seconds between readings (14400 = 4 hours)The FlashLight brightness is set to mode 9 (100% brightness, 1.3s duration) in sendImage(). See the brightness table in the sketch for other modes. If your meter is well-lit, you can set it to 0 to disable the flash entirely.
- Connect the Timer Camera X via USB.
- Select board M5Stack-Timer-CAM in the Arduino IDE.
- Upload the sketch.
The device will immediately take a photo, POST it to the configured server, and enter deep sleep. The onboard LED will briefly flash on each wake cycle to confirm the device is alive.
All options can be set via CLI flags or environment variables. Flags take precedence.
| Flag | Env Var | Default | Description |
|---|---|---|---|
--listen-addr |
LISTEN_ADDR |
:8080 |
Address and port for the HTTP server |
--ocr-script |
OCR_SCRIPT |
ocr.py |
Path to the PaddleOCR Python script |
--python-bin |
PYTHON_BIN |
/usr/bin/python3 |
Path to the Python binary |
--storage-path |
STORAGE_PATH |
(disabled) | Directory to store images and readings.csv |
--crop |
CROP |
(disabled) | Crop rectangle as x0,y0,x1,y1 applied before OCR |
--ocr-match-regex |
OCR_MATCH_REGEX |
^000\d+$ |
Regex to identify the meter reading from OCR text |
--ocr-fix-regex |
OCR_FIX_REGEX |
(disabled) | Comma-separated list of regex substitutions as pattern=replacement applied in order before matching (e.g. ^O=0,^030=000) |
--ocr-mask-regions |
OCR_MASK_REGIONS |
(disabled) | Comma-separated rectangle coordinates to mask before OCR, as x1,y1,x2,y2[,x3,y3,x4,y4,...] (applied after crop) |
--ocr-mask-colors |
OCR_MASK_COLORS |
000000 |
Comma-separated hex colors for mask regions. One color applies to all; otherwise must match the number of regions |
--mqtt-broker |
MQTT_BROKER |
(disabled) | MQTT broker URL, e.g. tcp://192.168.1.100:1883 |
--mqtt-user |
MQTT_USER |
MQTT username | |
--mqtt-password |
MQTT_PASSWORD |
MQTT password | |
--mqtt-topic-prefix |
MQTT_TOPIC_PREFIX |
meter-reader |
Prefix for MQTT topics |
--mqtt-device-id |
MQTT_DEVICE_ID |
water_meter |
Device identifier for MQTT and HA discovery |
--mqtt-device-manufacturer |
MQTT_DEVICE_MANUFACTURER |
Generic |
Manufacturer shown in Home Assistant |
--mqtt-device-model |
MQTT_DEVICE_MODEL |
Generic |
Model shown in Home Assistant |
--meter-divisor |
METER_DIVISOR |
1000 |
Divisor to convert raw reading to m³ (e.g. 000354225 / 1000 = 354.225) |
--ocr-incr-only |
OCR_INCR_ONLY |
false |
Only publish readings that are ≥ the previous value (after dividing by meter-divisor), discarding likely OCR errors |
When --mqtt-broker is set, the service publishes MQTT discovery configs on connect. Three sensors are created under a single device:
| Sensor | Device Class | Unit | State Class |
|---|---|---|---|
| Water Meter Reading | water |
m³ | total_increasing |
| Water Meter Battery | battery |
% | measurement |
| Water Meter Battery Voltage | voltage |
mV | measurement |
When --storage-path is set, each successful reading stores:
- Original image as
YYYY/mm/dd/HH-MM-SS.jpg - Cropped image as
YYYY/mm/dd/HH-MM-SS_cropped.jpg(if--cropis set) - A row in
readings.csv:image_path,reading,timestamp
| Endpoint | Method | Description |
|---|---|---|
/ocr?bat_level=85&bat_voltage=4200 |
POST |
Submit a JPEG/PNG image as the request body. Returns 202 Accepted immediately; OCR runs in the background. |
/health |
GET |
Returns 200 OK. |
/metrics |
GET |
Prometheus metrics. |
Prometheus metrics:
| Metric | Type | Description |
|---|---|---|
meter_reading |
gauge | Last detected meter reading (raw integer) |
meter_battery_level_percent |
gauge | ESP32 battery level (0–100) |
meter_battery_voltage_millivolts |
gauge | ESP32 battery voltage in mV |
meter_ocr_duration_seconds |
histogram | OCR processing time |
meter_ocr_errors_total |
counter | Total OCR errors |
docker build -t esp32-meter-reader .docker run -d -p 8080:8080 esp32-meter-readercurl -X POST "http://localhost:8080/ocr?bat_level=85&bat_voltage=4200" \
-H "Content-Type: image/jpeg" \
--data-binary @meter-photo.jpggo test -race -v ./...
go build -o esp32-meter-reader .
# Requires Python 3.13+ with paddlepaddle==3.2.2 and paddleocr installed
./esp32-meter-reader --ocr-script ocr.py --python-bin /path/to/venv/bin/python3esp32-meter-reader was inspired by AI-on-the-edge-device.
That's a great project, but it didn't fit my use case very well. Specifically:
- It needs an SD card to work. I wanted to use my existing M5Stack Timer Camera X, which does not have an SD card slot.
- Its pre-trained model is optimized for meters with mechanical digits. My meter has a digital display.
- Similarly, its model expects a clear image from a close distance. My meter is installed in a dark cabinet, and I could only place the camera at a distance.
- I wanted to use a battery-powered solution, without needing to plug in a cable, which was infeasible for my cabinet.
Given these constraints, any character recognition would have to happen off device. So I wrote my own service that runs on Docker or Kubernetes. This way, the camera only briefly wakes up to take a photo, send it to the OCR service, and then goes back to sleep. This ensures that the battery lasts a long time, even with the flash attached.

