No camera, no OCR, no battery: most mechanical water meters already carry a small disc in their register that turns with the flow, so that the vendor’s pulse module can count it. A reflective sensor can count it just as well. This is the reader I built for my garden water meter, the firmware behind it, and the openHAB configuration that turns it into daily consumption, a leak warning and a reading that survives a meter swap.
Where the idea came from
My garden tap has its own meter, an Allmess EVK 3/110 +m, so that the water for the garden can be billed without sewage charges. The +m means it has a module bay for a pulse module. Allmess sells one, the PM +m, but it runs on a glued-in lithium cell that is rated for 13 years, and water meters have to be swapped for recalibration every six. Buying a new module every second meter swap for something that counts a turning disc did not appeal to me.
So the plan was: put my own sensor where the module would sit, power it over USB, and send the reading over MQTT. The module bay is the same across the whole Allmess +m family, so whatever I build there also fits the meter that replaces this one.
How the meter can be read
Under a clear window next to the number rollers sits a scanning disc, half chrome, half black. It turns with the flow. On my meter the dial is marked x0,0001: 1000 revolutions per m³, so one revolution is one litre. A Vishay TCRT5000L reflective sensor a few millimetres above the window sees a square wave: chrome reflects the IR LED into the phototransistor, black does not. Every change from one to the other is an edge, and with two edges per revolution every edge is 0.5 L. That is twice the resolution of the vendor module.

The disc with the blank module removed. Serial number and code are blurred.
Before you open anything. Taking the blank module off tears the name plate at its perforation, and it does not go back. If the meter belongs to your utility or your landlord, your own hardware in its module bay may count as tampering with a calibrated device, so ask first. I have no connection to Allmess or any other manufacturer.
The hardware
Two parts and two resistors:
- an ESP32-C6 Super Mini (any small ESP32 with an ADC pin works, the case is sized for this one),
- a bare Vishay TCRT5000L, not one of the breakout boards with a comparator,
- R1 in series with the IR LED and R2 from the phototransistor’s emitter to ground; the emitter goes to GPIO1.

The two resistors are the part that differs from meter to meter, because the distance to the disc, the window and the reflectivity of the chrome all differ. R1 sets how bright the LED is, R2 how sensitive the transistor is. The goal is a dark level well above zero, a bright level well below 3.3 V, and as much swing as possible in between. My first try with 100 Ω / 10 kΩ saturated the transistor (both sectors at 3.2 V, nothing to count), 220 Ω / 2.2 kΩ was still too much, and 560 Ω / 1.56 kΩ (a 1 k and a 560 in series) gives ~0.5 V dark and ~2.9 V on chrome. The repository explains the tuning procedure step by step, including a table of what to change for which symptom.
The case replaces the blank module: an adapter that snaps onto the meter like the original and holds the sensor in a sleeve, and a cap that holds the board. Both print without supports in PETG and are joined with two M2×8 screws from behind. The outline came from a 300 dpi flatbed scan of the blank module. The OpenSCAD source and the STLs are in the repo, and other meters are meant to get their own folder next to it.

Wired: the board in the cap, the sensor with R1/R2 in the adapter. Only three wires go to the board: 3V3, GND and the signal on GPIO1.

Mounted on the meter, USB-C on the left.
The firmware
ESP-IDF, written for this; it shares its infrastructure with my bsh-dbus-adapter.
- An edge detector that does not invent water. Two thresholds with hysteresis, a dwell time against spikes, a minimum gap between edges, and a fault mode: unplugging the sensor or taking the case off stops counting, and on return it resynchronises without counting an edge.
- The reading is safe. Stored once a minute when it changed and before every restart, plus an edge log in a flash ring with the time of every edge.
- A web interface with a live scope of the raw signal for tuning, water draws from the edge log with CSV export, WiFi setup that tests credentials before it stores them, and OTA updates with rollback.
- MQTT (
mqtt://ormqtts://, login optional) with Home Assistant discovery for those who need it. openHAB does not need it, see below.

One revolution of the disc on the tuning page: chrome at ~3 V, black at ~0.5 V, both thresholds dashed, every detected edge as a green line. The swing is 2.6 V and not a single crossing was rejected.
Source, documentation, CAD and STLs: github.com/bruestel/flowtick-meter
What arrives in openHAB
Under the topic root you set in the web interface, mine is meter/water/garden, all retained:
| Topic | Type | Example |
|---|---|---|
status |
string | online / offline, also the last will |
state/total |
number | 44.5005 (m³) |
state/flow |
number | 6.0 (L/min, over 10 s) |
state/continuous_flow |
string | On / Off |
sensor |
JSON | device status, every minute |
status is the one availability topic. It says offline when the device is gone (last will, after about 45 s) and also when the sensor delivers no plausible signal, so a loose sensor does not look like a household that uses no water. continuous_flow turns On once edges have kept coming without a 10-minute pause for an hour, which is the leak indicator: at 0.5 L per edge a dripping tap never shows up in the flow value, but it does in this one.
The sensor JSON looks like this:
{
"mode": "run",
"firmware": "v0.10.1",
"uptime_s": 86400,
"boot_reason": "software restart",
"free_heap": 264820,
"min_free_heap": 255888,
"network": {
"hostname": "flowtick-garden",
"ssid": "MyWiFi",
"rssi_dbm": -67,
"channel": 6,
"tx_power_dbm": 17,
"mac": "aa:bb:cc:dd:ee:ff",
"ip": "192.168.1.50",
"netmask": "255.255.255.0",
"gateway": "192.168.1.1",
"dns": "192.168.1.1"
},
"time": {
"synced": true,
"server": "pool.ntp.org",
"timezone": "Europe/Berlin"
}
}
The thing
One generic MQTT thing. The availability topic does the work again: the thing goes OFFLINE by itself when the device disappears or the sensor is off the meter. The JSON channels need the JSONPath transformation add-on.
Thing mqtt:topic:water-meter:garden "Garden water meter" (mqtt:broker:central)
[ availabilityTopic="meter/water/garden/status",
payloadAvailable="online", payloadNotAvailable="offline" ] {
Channels:
Type number : meterReading "Meter reading" [ stateTopic = "meter/water/garden/state/total", commandTopic = "", unit = "m³" ]
Type number : flow "Flow" [ stateTopic = "meter/water/garden/state/flow", unit = "l/min" ]
Type switch : continuousFlow "Continuous flow" [ stateTopic = "meter/water/garden/state/continuous_flow", on = "On", off = "Off" ]
Type string : mode "Detector mode" [ stateTopic = "meter/water/garden/sensor", transformationPattern = "JSONPATH:$.mode" ]
Type number : rssi "WiFi signal" [ stateTopic = "meter/water/garden/sensor", transformationPattern = "JSONPATH:$.network.rssi_dbm", unit = "dBm" ]
Type string : firmware "Firmware" [ stateTopic = "meter/water/garden/sensor", transformationPattern = "JSONPATH:$.firmware" ]
}
The items
The raw reading goes into one item, and a second one holds a continuous total: raw reading plus an offset. When the meter is replaced, the new one starts at zero, the offset moves, and the total just carries on, so the charts and the consumption per day never see a drop.
// raw reading, starts again at 0 when the meter is replaced
Number:Volume Water_Meter_Garden_Value "Garden meter [%.4f m³]" <watermeter> (gPersist) ["Measurement"] {channel="mqtt:topic:water-meter:garden:meterReading"}
// continuous total (raw reading + offset), survives meter replacements
Number:Volume Water_Meter_Garden_Total "Garden meter (total) [%.4f m³]" <watermeter> (gPersist)
Number:Volume Water_Meter_Garden_Offset "Garden meter (offset) [%.4f m³]" <watermeter> (gPersistLastState)
Number:VolumetricFlowRate Water_Meter_Garden_Flow "Flow [%.1f l/min]" <water> (gPersist) ["Measurement"] {channel="mqtt:topic:water-meter:garden:flow"}
Switch Water_Meter_Garden_ContinuousFlow "Continuous flow [%s]" <alarm> (gPersistLastState) ["Alarm"] {channel="mqtt:topic:water-meter:garden:continuousFlow"}
String Water_Meter_Garden_Mode "Detector [%s]" <status> {channel="mqtt:topic:water-meter:garden:mode"}
Number:Power Water_Meter_Garden_RSSI "WiFi signal [%d dBm]" <network> {channel="mqtt:topic:water-meter:garden:rssi"}
Number:Volume WaterMeterGardenConsumptionToday "Garden (today) [%.4f m³]" <water> (gPersistLastState)
Number:Volume WaterMeterGardenConsumptionYesterday "Garden (yesterday) [%.4f m³]" <water> (gPersist)
gPersist goes to JDBC with everyChange, gPersistLastState to MapDB for restoreOnStartup.
A note on language. My own configuration is German, and the labels are translated for this post.
The rules
Three of them. The first keeps the total continuous across meter replacements and ignores single misreadings: a small increase is taken over directly, a drop or a big jump has to be confirmed by three consistent readings first. A confirmed drop means a new meter, so the offset moves and a notification goes out.
import java.math.BigDecimal
var BigDecimal pendingStart = null
var BigDecimal pendingLast = null
var int pendingCount = 0
rule "Garden water meter - init"
when
System reached start level 100
then
if (!(Water_Meter_Garden_Offset.state instanceof QuantityType)) Water_Meter_Garden_Offset.postUpdate("0 m³")
end
rule "Garden water meter - total"
when
Item Water_Meter_Garden_Value received update
then
val maxJump = new BigDecimal("0.5") // m³ between two readings
val tolerance = new BigDecimal("0.001") // m³, small drops are ignored silently
val confirmations = 3
if (!(Water_Meter_Garden_Value.state instanceof QuantityType) || !(Water_Meter_Garden_Offset.state instanceof QuantityType)) return;
val value = (Water_Meter_Garden_Value.state as QuantityType<Number>).toUnit("m³").toBigDecimal
if (value <= BigDecimal.ZERO) return;
var off = (Water_Meter_Garden_Offset.state as QuantityType<Number>).toUnit("m³").toBigDecimal
if (!(Water_Meter_Garden_Total.state instanceof QuantityType)) {
Water_Meter_Garden_Total.postUpdate(value.add(off).toString + " m³")
return;
}
val last = (Water_Meter_Garden_Total.state as QuantityType<Number>).toUnit("m³").toBigDecimal.subtract(off)
val delta = value.subtract(last)
if (delta >= BigDecimal.ZERO && delta <= maxJump) {
pendingCount = 0
Water_Meter_Garden_Total.postUpdate(value.add(off).toString + " m³")
return;
}
if (delta < BigDecimal.ZERO && delta.negate <= tolerance) {
pendingCount = 0
return;
}
if (pendingCount > 0 && value >= pendingLast && value.subtract(pendingLast) <= maxJump) {
pendingCount = pendingCount + 1
} else {
pendingStart = value
pendingCount = 1
}
pendingLast = value
if (pendingCount < confirmations) {
logWarn("WaterMeter", "Implausible reading " + value + " (last " + last + "), confirmation " + pendingCount + "/" + confirmations)
return;
}
pendingCount = 0
if (delta < BigDecimal.ZERO) {
// new meter: continue the total from its first reading
off = last.add(off).subtract(pendingStart)
Water_Meter_Garden_Offset.postUpdate(off.toString + " m³")
val actions = getActions("pushover", "pushover:pushover-account:account")
actions.sendMessage("Garden water meter replaced, new offset: " + off + " m³", "💧 Garden water", null, null, null, null, null, null, null)
}
Water_Meter_Garden_Total.postUpdate(value.add(off).toString + " m³")
end
The second computes today’s and yesterday’s consumption from the persisted total. Yesterday is stored at 23:59:50, so the persisted value belongs to the right day and a chart over a month shows one bar per day.
import java.math.BigDecimal
import java.time.temporal.ChronoUnit
rule "Garden water consumption (today & yesterday)"
when
Item Water_Meter_Garden_Total changed or
Time cron "5 0 0 * * ?" or
Time cron "50 59 23 * * ?"
then
val startToday = now.truncatedTo(ChronoUnit.DAYS)
var today = (Water_Meter_Garden_Total.deltaBetween(startToday, now) as QuantityType<Number>)?.toBigDecimal ?: BigDecimal.ZERO
if (today < BigDecimal.ZERO) today = BigDecimal.ZERO
WaterMeterGardenConsumptionToday.postUpdate(today.toString + " m³")
if (triggeringItemName === null && now.getHour == 23) {
WaterMeterGardenConsumptionYesterday.postUpdate(today.toString + " m³")
}
end
The third is the leak warning. The device does the hard part, so the rule only has to listen. If watering for more than an hour is normal at your place, add a condition on your irrigation valves here.
rule "Garden water meter - continuous flow"
when
Item Water_Meter_Garden_ContinuousFlow changed to ON
then
val actions = getActions("pushover", "pushover:pushover-account:account")
actions.sendMessage("Water has been running for an hour without a break.", "🚨 Garden water", null, null, null, null, null, null, null)
end
The sitemap
Frame label="Garden water" {
Text item=WaterMeterGardenConsumptionToday label="Consumption (today)"
Text item=WaterMeterGardenConsumptionYesterday label="Consumption (yesterday)"
Chart item=WaterMeterGardenConsumptionYesterday period=M interpolation="step" legend=false
Text item=Water_Meter_Garden_Value label="Meter reading" {
Text item=Water_Meter_Garden_Total
Text item=Water_Meter_Garden_Offset visibility=[Water_Meter_Garden_Offset!=0]
Text item=Water_Meter_Garden_Flow
Text item=Water_Meter_Garden_ContinuousFlow visibility=[Water_Meter_Garden_ContinuousFlow==ON]
Text item=Water_Meter_Garden_Mode
Text item=Water_Meter_Garden_RSSI
Chart item=Water_Meter_Garden_Total period=W legend=false
Chart item=Water_Meter_Garden_Total period=Y legend=false
}
}
Links
- bruestel/flowtick-meter - firmware, schematic, the case for the Allmess EVK 3/110 +m, and how to support another meter.
- Choosing the resistors - what to change when your meter’s signal is too weak or saturated.
- Adding a meter - if you have a different meter with a scanning disc, a case for it is very welcome in the repo.
- Firmware documentation - web interface, MQTT, updates and rollback.
