The Number Behind the Glass

The Constraints: 4 cm, No Light, a Glossy Cover
Constraint | Consequence |
|---|---|
| Only 3–4 cm between lens and LCD | The frame is about 5 cm wide. I centre it on the value and the screen code. Anything up to 999.99 kWh fits; the leftmost digit positions don't. |
| Sealed metal box, no ambient light | The camera has to bring its own light, or use the meter's. |
| Glossy clear cover | The on-board LED reflects straight back into the lens. |
| Stock lens focused at about 1 m | The OV2640 is fixed-focus. Sharp digits this close means breaking the glue dab and rotating the lens. |
Light Was the First Real Problem
- On/off flash was too much. At this distance full power blows the LCD out. The LED became PWM-driven, with a settle time (600 ms by default) so auto-exposure can converge from darkness, plus an optional fixed exposure and gain. All of it is adjustable from sliders in the device's web UI and saved on the device.
- 5 kHz PWM put stripes on the image. The rolling shutter picked up the LED flicker as banding. Moving the PWM to 40 kHz fixed it. The LED also sits on its own LEDC channel, because the camera clock owns channel 0.
- The best light wasn't mine at all. The meter's own backlight gives a cleaner image than the LED ever does, because it produces no reflection on the cover.
Cpp
// firmware/src/camera.cpp (abridged)
camera_fb_t *cameraCaptureAuto(CaptureInfo &info) {
// No LED first: shoot by the meter's backlight, then measure the LCD region.
camera_fb_t *fb = cameraCapture(0);
info.lcdLuma = cameraRegionLuma(fb);
if (settings.lightMode == LIGHT_BACKLIGHT || !fb || settings.ledDuty == 0 ||
info.lcdLuma < 0 || info.lcdLuma >= settings.backlightMinLuma) {
return fb;
}
// Backlight is off (e.g. after a blackout): light the LCD ourselves.
esp_camera_fb_return(fb);
info.usedLed = true;
return cameraCapture(settings.ledDuty);
}
The Boring Parts That Had to Work First
Problem | What the firmware does |
|---|---|
| Brownout resets from LED + Wi-Fi TX peaks | Starts Wi-Fi 1.5 s before the camera and caps TX power at 15 dBm. On a laptop USB port it still browns out once on the first boot after a USB flash; a proper 5 V supply fixes that. |
| USB flashing corrupts data | The CH340 on the programmer board fails at 460800 baud and above, so upload speed is 230400. |
| Reflashing means opening the box | OTA from the first day: over Wi-Fi from PlatformIO, or a browser upload at /update. Both need a password, and the uploader rejects anything that isn't an app image. |
| The network or the backend is down | Every capture also goes to a microSD rolling buffer in dated folders. The oldest files are deleted to keep at least 10% free. |
| SD and LED want the same pin | 4-bit SD mode uses GPIO 4, which drives the LED. The card runs in 1-bit mode. |
| Cards that acknowledge writes and discard them | On mount, the firmware writes a probe file and requires it to show up in a real directory listing. A card that fails is refused. |
Reading Seven Segments Without a Model

Python
def darkness_map(img):
"""0 = like the local background, 1 = much darker (a lit LCD segment)."""
gray = img.convert("L")
bg = gray.filter(ImageFilter.GaussianBlur(40))
g = np.asarray(gray, dtype=np.float32)
b = np.asarray(bg, dtype=np.float32) + 1.0
return np.clip(1.0 - g / b, 0.0, 1.0)
{"ok": true, "kwh": 59.23, "code": "37", "confidence": 1.0, "digits": " 5923", ...}
Calibration is one JSON file per installation, holding the digit box positions, pitch, slant and border anchors. A --debug flag writes an overlay that circles every sample point, red for lit and green for unlit, which makes tuning a matter of looking at one picture.
It reads 59.23 and code 37 on all of my reference frames. That's a narrower result than it sounds: those frames are all backlit and all show the same value. It hasn't been tested on an LED-lit frame with glare, or across enough different digits.
"Never Lie": The Plausibility Gate

Reading | Rule | Verdict |
|---|---|---|
| First one after a start or reset | Needs a second frame showing the same value | Pending, then accepted as baseline |
| Lower than the last accepted | Drop must be within 5.75 kW × elapsed time (a 25 A breaker at 230 V) | Accepted |
| Higher by 5 kWh or more | Looks like a top-up; the next frame must agree | Pending, then accepted as top-up |
| Anything else | Fell faster than the breaker allows, or rose by a little | Rejected |
| Same rejected value 6 times in a row | The baseline is probably the wrong one | Re-anchored |
Javascript
// node-red: plausibility function (abridged)
const hours = Math.max(0, (now - st.last.ts) / 3.6e6);
const drop = st.last.kwh - k;
if (drop >= -0.005 && drop <= maxKw * hours + 0.02) {
accept(null);
} else if (-drop >= topupMin) {
if (st.pending && same(st.pending.kwh, k)) {
topup = +(k - st.last.kwh).toFixed(2);
accept('topup');
} else hold('top-up: waiting for a confirming frame');
} else {
verdict = 'rejected';
// ...6 identical rejected reads re-anchor the baseline
}
From a Reading to a Live Rupiah Figure
credit (Rp) = (last reading in kWh − ∫ grid power since that reading) × 1,785.7 Rp/kWh
The camera supplies the anchor and the power series supplies everything in between. Each new accepted reading replaces the anchor, so the integral never has long to drift. The same endpoint accepts a hand-read value under a manual token, so the panel works before the camera is trusted.
Where It Stands
Phase | Status |
|---|---|
| 0 — Capture, lighting, push, OTA, SD buffer | Done |
| 2 — Recognition in the backend | Running in shadow mode |
| Next | Evaluate one to two days of frames, including an LED-lit one after a backlight-off event, then turn publishing on |
| Exit criteria for Phase 2 | Two weeks of readings at 99%+ accuracy (or explicitly unknown) and zero false top-ups |
| 3 — Recognition on the device | Not started |
| 4 — Browser flasher, phone setup, Home Assistant discovery | Not started |
What I Learned
Part of my ongoing home IoT series. See also: why my grid breaker stays off by default and the three-tier hysteresis engine that decides when the grid is allowed back on.