{"id":45596294,"url":"https://github.com/dcelasun/esp32-meter-reader","last_synced_at":"2026-04-13T12:01:11.640Z","repository":{"id":339786392,"uuid":"1163348140","full_name":"dcelasun/esp32-meter-reader","owner":"dcelasun","description":"Battery powered water meter reader for Home Assistant using ESP32 and PaddleOCR","archived":false,"fork":false,"pushed_at":"2026-04-13T10:15:53.000Z","size":277,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-04-13T11:34:52.208Z","etag":null,"topics":["arduino","docker-image","esp32","home-assistant","k8s-at-home","m5stack","mqtt","ocr","paddelocr","self-hosted","timer-camera-x"],"latest_commit_sha":null,"homepage":"","language":"Go","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/dcelasun.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-02-21T13:48:37.000Z","updated_at":"2026-04-13T10:15:58.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/dcelasun/esp32-meter-reader","commit_stats":null,"previous_names":["dcelasun/esp32-meter-reader"],"tags_count":13,"template":false,"template_full_name":null,"purl":"pkg:github/dcelasun/esp32-meter-reader","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/dcelasun%2Fesp32-meter-reader","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/dcelasun%2Fesp32-meter-reader/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/dcelasun%2Fesp32-meter-reader/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/dcelasun%2Fesp32-meter-reader/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/dcelasun","download_url":"https://codeload.github.com/dcelasun/esp32-meter-reader/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/dcelasun%2Fesp32-meter-reader/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":31751705,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-13T09:16:15.125Z","status":"ssl_error","status_checked_at":"2026-04-13T09:16:05.023Z","response_time":93,"last_error":"SSL_connect returned=1 errno=0 peeraddr=140.82.121.6:443 state=error: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["arduino","docker-image","esp32","home-assistant","k8s-at-home","m5stack","mqtt","ocr","paddelocr","self-hosted","timer-camera-x"],"created_at":"2026-02-23T13:42:41.502Z","updated_at":"2026-04-13T12:01:11.634Z","avatar_url":"https://github.com/dcelasun.png","language":"Go","funding_links":[],"categories":[],"sub_categories":[],"readme":"# esp32-meter-reader\n\nReads water meter digits using an ESP32 camera and PaddleOCR. Battery powered, no cables.\n\n## How It Works\n\nThe ESP32 wakes from deep sleep on a timer, takes a photo of the meter, and POSTs the JPEG to your self-hosted OCR service.\n\nThe service extracts the reading and publishes it to Home Assistant via MQTT discovery, with Prometheus metrics for monitoring.\n\n## Example\n\n  \u003cp align=\"center\"\u003e\n    \u003cimg src=\"/docs/sample-meter-photo.jpg\" width=\"200\"\u003e \n    \u003cimg src=\"docs/sample-home-assistant-mqtt.png\" width=\"200\"\u003e\n  \u003c/p\u003e\n\n## Table of Contents\n\n- [Hardware](#hardware)\n- [OCR Service](#ocr-service)\n  - [Docker](#docker)\n  - [Kubernetes](#kubernetes)\n- [ESP32 Installation](#esp32-installation)\n  - [Prerequisites](#prerequisites)\n  - [Configuration](#configuration)\n  - [Upload](#upload)\n- [Configuration](#configuration-1)\n  - [MQTT / Home Assistant](#mqtt--home-assistant)\n  - [Storage](#storage)\n- [API](#api)\n- [Local Development](#local-development)\n- [Compared to Other Projects](#compared-to-other-projects)\n\n## Hardware\n\n| Component | Description |\n|---|---------------------------------------------------------------------------------------------------|\n| [M5Stack Timer Camera X](https://docs.m5stack.com/en/unit/timercam_x) | ESP32-based controller with built-in battery, RTC, and deep sleep support. Has a 3MP OV3660 camera. |\n| [M5Stack Unit FlashLight](https://docs.m5stack.com/en/unit/FlashLight) | LED flash unit. Connected via the GROVE port. Required for meters installed in dark enclosures. |\n\n## OCR Service\n\nThe 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.\n\n### Docker\n\n```bash\ndocker run -d -p 8080:8080 ghcr.io/dcelasun/esp32-meter-reader:latest\n```\n\nWith MQTT and storage:\n\n```bash\ndocker run -d -p 8080:8080 \\\n  -v meter-data:/data \\\n  -e STORAGE_PATH=/data \\\n  -e MQTT_BROKER=tcp://192.168.1.100:1883 \\\n  -e MQTT_USER=homeassistant \\\n  -e MQTT_PASSWORD=secret \\\n  -e METER_DIVISOR=1000 \\\n  ghcr.io/dcelasun/esp32-meter-reader:latest\n```\n\n### Kubernetes\n\nCopy and customize the example manifest:\n\n```bash\ncp k8s/manifest.example.yaml k8s/manifest.yaml\n# Edit the Secret, env vars, and resource limits to match your cluster\nkubectl apply -f k8s/manifest.yaml\n```\n\nThe 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`](k8s/manifest.example.yaml) for all available environment variables.\n\n## ESP32 Installation\n\nThe ESP32 runs an Arduino sketch that handles the wake → capture → upload → sleep cycle.\n\n### Prerequisites\n\n- [Arduino IDE](https://www.arduino.cc/en/software) or PlatformIO\n- [M5Stack Board Manager](https://docs.m5stack.com/en/arduino/arduino_ide) v2.0.9+\n- Libraries:\n  - [TimerCam-arduino](https://github.com/m5stack/TimerCam-arduino)\n  - [ArduinoHttpClient](https://github.com/arduino-libraries/ArduinoHttpClient)\n\n### Configuration\n\nOpen [`esp32/m5stack_timer_camera_x.ino`](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.\n\n```cpp\n#define _WIFI_SSID \"My SSID\"        // Your WiFi network name\n#define _WIFI_PASS \"MyPassword\"      // Your WiFi password\n\n#define _SERVER_HOST \"192.168.1.50\"  // IP or hostname of the OCR service\n#define _SERVER_PORT 8080            // Port of the OCR service (default: 8080)\n\n#define SLEEP_INTERVAL_SECS 14400    // Seconds between readings (14400 = 4 hours)\n```\n\nThe 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.\n\n### Upload\n\n1. Connect the Timer Camera X via USB.\n2. Select board **M5Stack-Timer-CAM** in the Arduino IDE.\n3. Upload the sketch.\n\nThe 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.\n\n## Configuration\n\nAll options can be set via CLI flags or environment variables. Flags take precedence.\n\n| Flag | Env Var | Default | Description |\n|------|---------|---------|-------------|\n| `--listen-addr` | `LISTEN_ADDR` | `:8080` | Address and port for the HTTP server |\n| `--ocr-script` | `OCR_SCRIPT` | `ocr.py` | Path to the PaddleOCR Python script |\n| `--python-bin` | `PYTHON_BIN` | `/usr/bin/python3` | Path to the Python binary |\n| `--storage-path` | `STORAGE_PATH` | *(disabled)* | Directory to store images and `readings.csv` |\n| `--crop` | `CROP` | *(disabled)* | Crop rectangle as `x0,y0,x1,y1` applied before OCR |\n| `--ocr-match-regex` | `OCR_MATCH_REGEX` | `^000\\d+$` | Regex to identify the meter reading from OCR text |\n| `--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`) |\n| `--ocr-merge-texts` | `OCR_MERGE_TEXTS` | `false` | Concatenate all OCR text results into a single string before applying fix/match regexes (useful when readings are split across multiple detections, e.g. `[\"00036\", \"128\"]` → `\"00036128\"`) |\n| `--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) |\n| `--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 |\n| `--mqtt-broker` | `MQTT_BROKER` | *(disabled)* | MQTT broker URL, e.g. `tcp://192.168.1.100:1883` |\n| `--mqtt-user` | `MQTT_USER` | | MQTT username |\n| `--mqtt-password` | `MQTT_PASSWORD` | | MQTT password |\n| `--mqtt-topic-prefix` | `MQTT_TOPIC_PREFIX` | `meter-reader` | Prefix for MQTT topics |\n| `--mqtt-device-id` | `MQTT_DEVICE_ID` | `water_meter` | Device identifier for MQTT and HA discovery |\n| `--mqtt-device-manufacturer` | `MQTT_DEVICE_MANUFACTURER` | `Generic` | Manufacturer shown in Home Assistant |\n| `--mqtt-device-model` | `MQTT_DEVICE_MODEL` | `Generic` | Model shown in Home Assistant |\n| `--meter-divisor` | `METER_DIVISOR` | `1000` | Divisor to convert raw reading to m³ (e.g. `000354225` / `1000` = `354.225`) |\n| `--ocr-incr-only` | `OCR_INCR_ONLY` | `false` | Only publish readings that are ≥ the previous value (after dividing by `meter-divisor`), discarding likely OCR errors |\n| `--ocr-max-incr` | `OCR_MAX_INCR` | `0` | Maximum allowed increase between consecutive readings (after `meter-divisor`); larger jumps are discarded as OCR errors (`0` = disabled) |\n\n### MQTT / Home Assistant\n\nWhen `--mqtt-broker` is set, the service publishes [MQTT discovery](https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery) configs on connect. Three sensors are created under a single device:\n\n| Sensor | Device Class | Unit | State Class |\n|--------|-------------|------|-------------|\n| Water Meter Reading | `water` | m³ | `total_increasing` |\n| Water Meter Battery | `battery` | % | `measurement` |\n| Water Meter Battery Voltage | `voltage` | mV | `measurement` |\n\n### Storage\n\nWhen `--storage-path` is set, each successful reading stores:\n\n- Original image as `YYYY/mm/dd/HH-MM-SS.jpg`\n- Cropped image as `YYYY/mm/dd/HH-MM-SS_cropped.jpg` (if `--crop` is set)\n- A row in `readings.csv`: `image_path,reading,timestamp`\n\n## API\n\n| Endpoint | Method | Description |\n|----------|--------|-------------|\n| `/ocr?bat_level=85\u0026bat_voltage=4200` | `POST` | Submit a JPEG/PNG image as the request body. Returns `202 Accepted` immediately; OCR runs in the background. |\n| `/health` | `GET` | Returns `200 OK`. |\n| `/metrics` | `GET` | Prometheus metrics. |\n\n**Prometheus metrics:**\n\n| Metric | Type | Description |\n|--------|------|-------------|\n| `meter_reading` | gauge | Last detected meter reading (raw integer) |\n| `meter_battery_level_percent` | gauge | ESP32 battery level (0–100) |\n| `meter_battery_voltage_millivolts` | gauge | ESP32 battery voltage in mV |\n| `meter_ocr_duration_seconds` | histogram | OCR processing time |\n| `meter_ocr_errors_total` | counter | Total OCR errors |\n\n## Local Development\n\n### Build\n\n```bash\ndocker build -t esp32-meter-reader .\n```\n\n### Run\n\n```bash\ndocker run -d -p 8080:8080 esp32-meter-reader\n```\n\n### Test\n\n```bash\ncurl -X POST \"http://localhost:8080/ocr?bat_level=85\u0026bat_voltage=4200\" \\\n  -H \"Content-Type: image/jpeg\" \\\n  --data-binary @meter-photo.jpg\n```\n\n### Build from source\n\n```bash\ngo test -race -v ./...\ngo build -o esp32-meter-reader .\n\n# Requires Python 3.13+ with paddlepaddle==3.2.2 and paddleocr installed\n./esp32-meter-reader --ocr-script ocr.py --python-bin /path/to/venv/bin/python3\n```\n\n## Compared to Other Projects\n\nesp32-meter-reader was inspired by [AI-on-the-edge-device](https://github.com/jomjol/AI-on-the-edge-device).\n\nThat's a great project, but it didn't fit my use case very well. Specifically:\n\n- 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.\n- Its pre-trained model is optimized for meters with mechanical digits. My meter has a digital display.\n- 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.\n- I wanted to use a battery-powered solution, without needing to plug in a cable, which was infeasible for my cabinet.\n\nGiven these constraints, any character recognition would have to happen off device. So I wrote my own service that runs on Docker or Kubernetes.\nThis 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.","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fdcelasun%2Fesp32-meter-reader","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fdcelasun%2Fesp32-meter-reader","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fdcelasun%2Fesp32-meter-reader/lists"}