MapleWater: A Dive into Automatic Meter Reading (AMR) Technology
⚠️ LEGAL & PRIVACY WARNING
Intercepting radio communications may be illegal in your jurisdiction under federal wiretapping laws (18 USC § 2511). This research is presented for educational purposes only. Always comply with local laws and regulations. The techniques described should only be used to monitor your own meters or with explicit permission from meter owners. Unauthorized transmission on licensed frequencies without proper authorization may result in criminal prosecution.
Automatic Meter Reading (AMR) technology has revolutionized utility management by enabling wireless collection of consumption data from water, gas, and electric meters. In this post, we'll explore how we decoded the R900 water meter system—one of the most widely deployed AMR systems in North America—and discuss both the benefits and serious security implications of this unencrypted wireless infrastructure.
This research is the result of independent security analysis and is shared openly for educational purposes. It represents independent work in the field of critical infrastructure security and highlights the urgent need for encryption in utility systems. Special thanks to the rtlamr and librtlsdr projects for their foundational work in AMR decoding and Galois Field implementation.
What is AMR and the R900 System?
Automatic Meter Reading (AMR) is a technology that automatically collects consumption, diagnostic, and status data from water meters and transfers that data to a central database for billing, troubleshooting, and analysis. The R900 system, manufactured by Badger Meter and Neptune Technology Group, operates on the 912.38 MHz frequency in the ISM (Industrial, Scientific, and Medical) radio band.
How AMR is Used
Benefits for Utilities
- › Automated Billing: Eliminates manual meter reading, reducing labor costs
- › Leak Detection: Monitors continuous flow patterns
- › Consumption Analytics: Informs infrastructure planning
Monitoring Capabilities
- › No-Use Detection: Extended periods without water usage
- › Backflow Alerts: Potential contamination detection
- › Real-time Data: 14-second transmission intervals
📡 Technical Specifications
Frequency: 912.38 MHz (ISM Band)
Encoding: Manchester encoding with Reed-Solomon error correction
Range: 300-1000+ feet (varies with conditions and antenna)
Transmission Interval: ~14 seconds average
Security: None - unencrypted, unauthenticated broadcasts
The Security Gap: No Authentication or Encryption
🚨 Critical Security Flaw
R900 transmissions are completely unencrypted and unauthenticated. Every 14 seconds (on average), these meters broadcast their readings in plaintext over the 912 MHz frequency. Anyone with a $20 RTL-SDR (Software Defined Radio) dongle can receive and decode these signals from hundreds of feet away—or miles with a directional antenna.
The protocol transmits the following data in the clear:
Meter ID (32 bits)
A unique identifier for each meter - can be used to track specific properties and correlate with addresses
Consumption Data (24 bits)
Current water usage readings in gallons - reveals household activity patterns
Status Flags
Leak indicators, backflow alerts, no-use periods - valuable intelligence for social engineering attacks
Zero Security
No encryption. No authentication. No integrity checks. Just raw data broadcast to anyone listening.
How We Implemented the Decoder
Our implementation, MapleWater, uses Python with RTL-SDR hardware to capture and decode R900 signals. Let's walk through the key components:
1. Setting Up the SDR
First, we configure the Software Defined Radio to listen on the correct frequency:
sdr = RtlSdr()
sdr.sample_rate = 2359296 # ~2.36 MHz sample rate
sdr.center_freq = 912380000 # 912.38 MHz - R900 frequency
sdr.gain = 'auto' # Automatic gain control
2. Signal Processing and Manchester Decoding
R900 uses Manchester encoding, where each bit is represented by a transition. We decode this by analyzing the cumulative sum of signal strength:
def manchester_decode(self, signal, chip_len):
csum = np.zeros(len(signal) + 1)
sum_val = 0.0
for idx, v in enumerate(signal):
sum_val += v
csum[idx + 1] = sum_val
symbol_len = chip_len * 2
output = []
lower = csum[chip_len:]
upper = csum[symbol_len:]
for idx in range(len(signal) - symbol_len + 1):
l = lower[idx]
f = (l - csum[idx]) - (upper[idx] - l)
output.append(1 if f >= 0 else 0)
return np.array(output, dtype=np.uint8)
3. Finding the Preamble
Each R900 packet starts with a specific 32-bit preamble pattern. We search for this pattern to identify packet boundaries:
PREAMBLE = "00000000000000001110010101100100"
def find_preamble(self, quantized, preamble_bytes):
symLen = self.samples_per_chip * 2
preamble_len = len(preamble_bytes)
# Search for matches at each position
sIdxA = []
for qIdx in range(self.block_size):
if quantized[qIdx] == preamble_bytes[0]:
sIdxA.append(qIdx)
# Verify remaining preamble bits
for pIdx in range(1, preamble_len):
offset = pIdx * symLen
sIdxB = []
for qIdx in sIdxA:
if quantized[qIdx + offset] == preamble_bytes[pIdx]:
sIdxB.append(qIdx)
sIdxA = sIdxB
return sIdxA
4. Decoding the Packet Data
Once we find a valid packet, we extract the meter information from the bit stream:
def decode_packet(self, bits, signal_strength=0.0):
meter_id = int(bits[0:32], 2) # 32-bit meter ID
unkn1 = int(bits[32:40], 2) # Unknown field (possibly meter type)
no_use = int(bits[40:46], 2) # Days of no usage
backflow = int(bits[46:48], 2) # Backflow indicator
consumption = int(bits[48:72], 2) # Total consumption in gallons
leak = int(bits[74:78], 2) # Leak duration
leak_now = int(bits[78:80], 2) # Current leak status
message = {
"Time": datetime.now().isoformat(),
"Type": "R900",
"SignalStrength": round(signal_strength, 2),
"Message": {
"ID": meter_id,
"Consumption": consumption,
"Leak": leak,
"LeakNow": leak_now,
"BackFlow": backflow,
"NoUse": no_use
}
}
return message
5. Error Correction with Reed-Solomon
R900 includes Reed-Solomon error correction to ensure data integrity despite RF interference:
class GaloisField:
def __init__(self, order, poly, alpha):
self.order = order - 1
self.log = [0] * order
self.exp = [0] * ((order - 1) * 2)
# Build logarithm and exponential tables for GF math
# ...
def syndrome(self, message, parity_count, offset):
# Calculate error syndrome for Reed-Solomon decoding
syndrome = []
for idx in range(parity_count):
syn = message[0]
for v in message[1:]:
syn = self.add(self.mul(syn, self.exp_n(idx + offset)), v)
syndrome.append(syn)
return syndrome
MapleWater in Action
📡 Real-World Test Results
The following output shows actual data collected during a brief testing session from my office. In just a few minutes, MapleWater decoded transmissions from 16 different water meters in the surrounding area. Meter IDs have been partially censored for privacy. Hardware: RTL-SDR V3 + $6 inline LNA + basic antenna.
Live Decoding Output
The visual dashboard displays real-time meter readings with signal strength visualization, distance estimation, and status monitoring:
┌─────────────────────────────────────────────────────────────────────────────┐
│ Time: 15:16:51 Meter ID: 15XXXXXXX6 │
├─────────────────────────────────────────────────────────────────────────────┤
│ Consumption: 839.00K gal │
│ Signal: ██████████████████████████░░░░ -11.7 dB (~9ft) │
│ Status: ✓ Leak:0 BFlow:0 NoUse:0 │
│ Data: Unkn1:163 Unkn3:0 │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ Time: 15:17:09 Meter ID: 15XXXXXXX8 │
├─────────────────────────────────────────────────────────────────────────────┤
│ Consumption: 650.50K gal │
│ Signal: ████████████████████████░░░░░░ -17.1 dB (~14ft) │
│ Status: ✓ Leak:0 BFlow:0 NoUse:0 │
│ Data: Unkn1:163 Unkn3:0 │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ Time: 15:17:42 Meter ID: 15XXXXXX8 │
├─────────────────────────────────────────────────────────────────────────────┤
│ Consumption: 786.00K gal │
│ Signal: ████████████████████████░░░░░░ -17.1 dB (~14ft) │
│ Status: ✓ Leak:0 BFlow:0 NoUse:0 │
│ Data: Unkn1:163 Unkn3:0 │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ Time: 15:17:53 Meter ID: 15XXXXXXX6 │
├─────────────────────────────────────────────────────────────────────────────┤
│ Consumption: 112.00K gal │
│ Signal: █████████████████████████░░░░░ -16.4 dB (~13ft) │
│ Status: ✓ Leak:0 BFlow:0 NoUse:0 │
│ Data: Unkn1:163 Unkn3:0 │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ Time: 15:19:04 Meter ID: 15XXXXXXX0 │
├─────────────────────────────────────────────────────────────────────────────┤
│ Consumption: 292.50K gal │
│ Signal: ████████████████████████░░░░░░ -17.2 dB (~14ft) │
│ Status: ✓ Leak:0 BFlow:0 NoUse:0 │
│ Data: Unkn1:163 Unkn3:0 │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ Time: 15:19:30 Meter ID: 15XXXXXX2 │
├─────────────────────────────────────────────────────────────────────────────┤
│ Consumption: 414.00K gal │
│ Signal: █████████████████████████░░░░░ -15.9 dB (~13ft) │
│ Status: ✓ Leak:0 BFlow:0 NoUse:0 │
│ Data: Unkn1:163 Unkn3:0 │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ Time: 15:19:34 Meter ID: 15XXXXXXX8 │
├─────────────────────────────────────────────────────────────────────────────┤
│ Consumption: 786.00K gal │
│ Signal: ██████████████████████████░░░░ -12.3 dB (~10ft) │
│ Status: ✓ Leak:0 BFlow:0 NoUse:0 │
│ Data: Unkn1:163 Unkn3:0 │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ Time: 15:20:22 Meter ID: 15XXXXXXX6 │
├─────────────────────────────────────────────────────────────────────────────┤
│ Consumption: 979.00K gal │
│ Signal: ████████████████████████░░░░░░ -17.3 dB (~14ft) │
│ Status: ✓ Leak:0 BFlow:0 NoUse:0 │
│ Data: Unkn1:163 Unkn3:0 │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ Time: 15:20:28 Meter ID: 15XXXXXX0 │
├─────────────────────────────────────────────────────────────────────────────┤
│ Consumption: 292.50K gal │
│ Signal: ████████████████████████░░░░░░ -17.2 dB (~14ft) │
│ Status: ✓ Leak:0 BFlow:0 NoUse:0 │
│ Data: Unkn1:163 Unkn3:0 │
└─────────────────────────────────────────────────────────────────────────────┘
Session Statistics
At the end of each session, MapleWater provides comprehensive statistics showing all detected meters, message counts, signal quality, and consumption data:
═══════════════════════════════════════════════════════════════════════════════
SESSION STATISTICS
═══════════════════════════════════════════════════════════════════════════════
Runtime: 342.8 seconds
Total Samples Processed: 804,159,488
Total Messages Decoded: 52
Unique Meters Detected: 16
Meter Summary:
Meter ID Messages Last Reading Avg Signal Range
-------------------------------------------------------------------------------------
15XXXXXXX8 4 650.50K gal -17.2 dB -17.3 to -17.1 dB
15XXXXXXX8 3 798.00K gal -17.3 dB -17.4 to -17.2 dB
15XXXXXXX0 3 893.00K gal -17.0 dB -16.9 to -16.9 dB
15XXXXXXX8 5 786.00K gal -13.5 dB -12.7 to -12.3 dB
15XXXXXXX8 2 391.50K gal -16.8 dB -16.9 to -16.8 dB
15XXXXXXX2 2 1.13M gal -17.3 dB -17.3 to -17.3 dB
15XXXXXXX6 4 979.00K gal -17.1 dB -17.2 to -16.9 dB
15XXXXXXX0 6 292.50K gal -17.2 dB -17.2 to -17.2 dB
15XXXXXXX2 3 414.00K gal -16.2 dB -17.0 to -15.7 dB
15XXXXXXX8 1 1.01M gal -17.2 dB -17.2 to -17.2 dB
15XXXXXXX6 3 839.00K gal -11.9 dB -11.9 to -11.7 dB
15XXXXXXX2 1 559.50K gal -17.3 dB -17.3 to -17.3 dB
15XXXXXXX8 4 645.50K gal -12.0 dB -12.0 to -11.8 dB
15XXXXXXX6 2 911.50K gal -14.3 dB -14.6 to -13.9 dB
15XXXXXXX6 8 112.00K gal -16.8 dB -17.0 to -16.4 dB
15XXXXXXX6 1 1.09M gal -17.3 dB -17.3 to -17.3 dB
═══════════════════════════════════════════════════════════════════════════════
⚠️ Privacy Note
This output demonstrates the ease with which water meter data can be intercepted. 16 unique meters were detected from a single location without any special equipment or antenna positioning. Signal strengths of -11.7 to -17.3 dB indicate very close proximity (9-14 feet estimated), suggesting these are neighboring properties. This real-world test highlights the serious privacy implications of unencrypted AMR systems.
The Positive Use Cases
When used responsibly, this technology enables valuable research and utility improvements:
Personal Monitoring
💧 Real-time Leak Detection
Homeowners can monitor their own meters in real-time, detecting leaks immediately before they cause damage
📊 Conservation Tracking
Track water usage patterns and measure the impact of conservation efforts
Research & Analytics
🔬 Urban Planning Research
Study water usage patterns to inform infrastructure investment and drought response
🏢 Utility Efficiency
Reduced operational costs and improved service delivery through automation
The Dangers: Security Implications
⚠️ Real-World Attack Scenarios
The lack of encryption and authentication creates serious vulnerabilities that extend beyond theoretical concerns. These are practical, exploitable weaknesses that put privacy, property, and critical infrastructure at risk.
1. Privacy Invasion
By monitoring water meter transmissions, an attacker can build a detailed profile of household activity:
🏠 Occupancy Detection
Determine when residents are home or away based on usage patterns
✈️ Vacation Tracking
Identify extended periods of no usage - prime time for burglaries
⏰ Daily Routines
Water usage reveals shower times, meal preparation, sleep schedules
👥 Household Size
Usage patterns indicate number of occupants and demographics
Attack Example: Neighborhood Surveillance
A burglar with a $20 RTL-SDR dongle and basic antenna can monitor an entire neighborhood from their vehicle, identifying homes where residents are on vacation. No physical reconnaissance required - just passive radio reception from the street.
2. Data Spoofing and Manipulation
Since there's no authentication, attackers could potentially inject false data:
# Hypothetical attack: Generate fake leak alerts
fake_packet = {
"ID": target_meter_id,
"Consumption": normal_usage,
"LeakNow": 1 # Trigger leak alert
}
⚡ False Emergency Alerts
Trigger fake leak alerts for hundreds of meters, overwhelming maintenance crews and creating chaos
💰 Billing Fraud
Under-report consumption (theft of service) or over-report to cause billing disputes
🎯 Mass Disruption
Broadcast false data for entire neighborhoods simultaneously, crippling utility operations
3. Infrastructure Mapping
Passive monitoring reveals critical infrastructure intelligence:
- Location and density of water infrastructure
- Meter deployment patterns and service area boundaries
- Potential targets for physical attacks on critical systems
- Network topology for larger coordinated attacks
4. Social Engineering Attacks
Armed with real-time meter data, sophisticated attackers could:
- Impersonate utility workers with inside knowledge of consumption patterns
- Target vulnerable residents when alone (based on usage patterns indicating single occupancy)
- Coordinate break-ins with confirmed absence detected through no-usage periods
- Craft convincing phishing attacks using legitimate meter IDs and consumption data
Real-World Range and Accessibility
🔧 Testing Setup
Hardware: RTL-SDR V3 ($25) + Inline LNA ($6 from AliExpress) + Basic antenna
Result: Successfully decoded 16 different meters without leaving the office or adjusting antenna direction.
Excellent Signal
Perfect decoding, minimal errors, reliable data every transmission
Good Signal
Consistent decoding, occasional errors corrected by Reed-Solomon
Weak Signal
Decodable but requires error correction, some packet loss
⚠️ Extended Range Capabilities
With directional antenna: Range extends to several miles for drive-by collection. A simple Yagi antenna ($30) dramatically improves both range and signal quality.
Implications:
- • Attackers don't need physical proximity to target properties
- • Entire neighborhoods can be monitored from a parked vehicle
- • Data collection is completely passive and undetectable
- • No special skills required - automated tools do all the work
Recommendations and Mitigations
For Utilities
Upgrade to encrypted systems
Modern AMR systems support AES encryption. Migrate legacy R900 deployments to secure alternatives.
Implement authentication
Ensure transmitted data includes cryptographic signatures to prevent spoofing attacks.
Monitor for anomalies
Detect unusual transmission patterns that might indicate spoofing or tampering.
Customer education
Inform users about the technology and its current security limitations.
For Residents
Be aware of the exposure
Understand that your water usage data is publicly broadcast and can reveal household patterns.
Don't rely on meter patterns for security
Use proper security systems, timers, and other measures instead of depending on usage patterns.
Report suspicious activity
Notify utilities immediately of unusual meter readings or potential tampering.
Advocate for upgrades
Push for encrypted AMR systems in your area. Privacy is your right.
For Researchers
Responsible disclosure
Report vulnerabilities to manufacturers and utilities before public disclosure.
Ethical use only
Never exploit data for malicious purposes. Do not transmit without proper authorization.
Anonymize data
Protect privacy when publishing research by anonymizing all meter IDs and locations.
Follow the law
Ensure compliance with local regulations regarding radio reception and data handling.
Full Source Code
#!/usr/bin/env python3
"""
MapleWater: R900 Water Meter Decoder for RTL-SDR with Visual Dashboard
Author: Adnan Ahmad, Adnan Security Inc.
Thank you rltamr & librtlsdr for the orignal R900 decoder & Gallois Field implementation!
License: Hackers Choice License (HCL) - Use this code for good, not evil.
"""
import numpy as np
from datetime import datetime
import json
import argparse
import time
from rtlsdr import RtlSdr
import sys
from collections import defaultdict
class GaloisField:
def __init__(self, order, poly, alpha):
if order < 0 or order > 256:
raise ValueError(f"Invalid order: {order}")
self.order = order - 1
self.log = [0] * order
self.exp = [0] * ((order - 1) * 2)
x = 1
for i in range(self.order):
if x == 1 and i != 0:
raise ValueError(f"Invalid generator {alpha} for polynomial {poly}")
self.exp[i] = x
self.exp[i + self.order] = x
self.log[x] = i
x = self._mul_raw(x, alpha, order, poly)
self.log[0] = self.order
@staticmethod
def _mul_raw(x, y, order, poly):
z = 0
while x > 0:
if x & 1:
z ^= y
x >>= 1
y <<= 1
if y & order:
y ^= poly
return z
def add(self, x, y):
return x ^ y
def mul(self, x, y):
if x == 0 or y == 0:
return 0
return self.exp[self.log[x] + self.log[y]]
def exp_n(self, e):
if e < 0:
return 0
return self.exp[e % self.order]
def syndrome(self, message, parity_count, offset):
if offset < 0 or offset > self.order:
raise ValueError(f"Invalid offset: {offset}")
if parity_count < 0 or parity_count > len(message):
raise ValueError(f"Invalid parity_count: {parity_count}")
syndrome = []
for idx in range(parity_count):
syn = message[0]
for v in message[1:]:
syn = self.mul(syn, self.exp_n(offset + idx)) ^ v
syndrome.append(syn)
return syndrome
class R900Decoder:
CENTER_FREQ = 912380000
SAMPLE_RATE = 2359296
DATA_RATE = 32768
CHIP_LENGTH = 72
PREAMBLE = "RTFM" # Preamble string :-)
def __init__(self):
self.samples_per_chip = self.CHIP_LENGTH
self.symbol_length = self.CHIP_LENGTH * 2
self.preamble_length = len(self.PREAMBLE) * self.symbol_length
self.packet_symbols = 116
self.packet_length = self.packet_symbols * self.symbol_length
self.block_size = 262144
self.buffer_length = self.packet_length + self.block_size
self.decoder_signal_buffer = np.zeros(self.block_size + self.symbol_length)
self.decoder_quantized_buffer = np.zeros(self.buffer_length, dtype=np.uint8)
self.r900_signal_buffer = np.zeros(self.buffer_length)
self.r900_filtered_buffer = []
self.r900_quantized_buffer = []
self.field = GaloisField(32, 37, 2)
def manchester_decode(self, signal, chip_len):
csum = np.zeros(len(signal) + 1)
sum_val = 0.0
for idx, v in enumerate(signal):
sum_val += v
csum[idx + 1] = sum_val
symbol_len = chip_len * 2
output = []
lower = csum[chip_len:]
upper = csum[symbol_len:]
for idx in range(len(signal) - symbol_len + 1):
l = lower[idx]
f = (l - csum[idx]) - (upper[idx] - l)
output.append(1 if f >= 0 else 0)
return np.array(output, dtype=np.uint8)
def r900_filter(self, signal, chip_len):
csum = np.zeros(len(signal) + 1)
sum_val = 0.0
for idx, v in enumerate(signal):
sum_val += v
csum[idx + 1] = sum_val
filtered = []
for idx in range(len(signal) - chip_len * 4):
c0 = csum[idx]
c1 = csum[idx + chip_len] * 2.0
c2 = csum[idx + chip_len * 2] * 2.0
c3 = csum[idx + chip_len * 3] * 2.0
c4 = csum[idx + chip_len * 4]
f0 = c2 - c4 - c0
f1 = c1 - c2 + c3 - c4 - c0
f2 = c1 - c3 + c4 - c0
filtered.append([f0, f1, f2])
return filtered
def r900_quantize(self, filtered, chip_len):
quantized = []
for vec in filtered:
argmax = 0
max_val = abs(vec[0])
if abs(vec[1]) > max_val:
max_val = abs(vec[1])
argmax = 1
if abs(vec[2]) > max_val:
max_val = abs(vec[2])
argmax = 2
if vec[argmax] > 0:
quantized.append(argmax + 3)
else:
quantized.append(argmax)
return quantized
def find_preamble(self, quantized, preamble_bytes, verbose=False):
symLen = self.samples_per_chip * 2
preamble_len = len(preamble_bytes)
sIdxA = []
for qIdx in range(self.block_size):
if quantized[qIdx] == preamble_bytes[0]:
sIdxA.append(qIdx)
for pIdx in range(1, preamble_len):
offset = pIdx * symLen
sIdxB = []
for qIdx in sIdxA:
check_pos = offset + qIdx
if check_pos < len(quantized) and quantized[check_pos] == preamble_bytes[pIdx]:
sIdxB.append(qIdx)
sIdxA = sIdxB
if len(sIdxA) == 0:
return []
return sIdxA
def decode_symbols_to_bits(self, symbols):
bits = ""
for i in range(0, len(symbols)-1, 2):
if i+1 < len(symbols):
digit_str = str(symbols[i]) + str(symbols[i+1])
value = int(digit_str, 6)
if value > 31:
return None
bits += format(value, '05b')
return bits
def decode_packet(self, bits, signal_strength=0.0, verbose=False):
if len(bits) < 80:
return None
try:
meter_id = int(bits[0:32], 2)
unkn1 = int(bits[32:40], 2)
no_use = int(bits[40:46], 2)
backflow = int(bits[46:48], 2)
consumption = int(bits[48:72], 2)
unkn3 = int(bits[72:74], 2)
leak = int(bits[74:78], 2)
leak_now = int(bits[78:80], 2)
if meter_id < 1000000 or meter_id > 2000000000:
return None
if unkn1 not in [163, 164, 165, 160, 161, 162]:
return None
if consumption > 20000000:
return None
message = {
"Time": datetime.now().isoformat(),
"Offset": 0,
"Length": 0,
"Type": "R900",
"SignalStrength": round(signal_strength, 2),
"Message": {
"ID": meter_id,
"Unkn1": unkn1,
"NoUse": no_use,
"BackFlow": backflow,
"Consumption": consumption,
"Unkn3": unkn3,
"Leak": leak,
"LeakNow": leak_now
}
}
return message
except Exception as e:
return None
def process_samples(self, iq_samples, verbose=False):
messages = []
power = np.mean(np.abs(iq_samples) ** 2)
if power > 0:
signal_strength_db = 10 * np.log10(power)
else:
signal_strength_db = -100.0
magnitude = np.real(iq_samples) ** 2 + np.imag(iq_samples) ** 2
if len(magnitude) == 0:
return messages
block_size = len(magnitude)
self.decoder_signal_buffer[:-block_size] = self.decoder_signal_buffer[block_size:]
self.decoder_signal_buffer[-block_size:] = magnitude
manchester_quantized = self.manchester_decode(self.decoder_signal_buffer, self.samples_per_chip)
self.decoder_quantized_buffer[:-len(manchester_quantized)] = self.decoder_quantized_buffer[len(manchester_quantized):]
self.decoder_quantized_buffer[-len(manchester_quantized):] = manchester_quantized
preamble_bytes = np.array([int(c) for c in self.PREAMBLE], dtype=np.uint8)
preamble_indices = self.find_preamble(self.decoder_quantized_buffer, preamble_bytes, verbose=verbose)
inverted_preamble = np.array([1 - int(c) for c in self.PREAMBLE], dtype=np.uint8)
inverted_indices = self.find_preamble(self.decoder_quantized_buffer, inverted_preamble, verbose=False)
all_indices = sorted(set(list(preamble_indices) + list(inverted_indices)))
if len(all_indices) == 0:
return messages
self.r900_signal_buffer[:-block_size] = self.r900_signal_buffer[block_size:]
self.r900_signal_buffer[self.packet_length:self.packet_length+block_size] = self.decoder_signal_buffer[self.symbol_length:self.symbol_length+block_size]
self.r900_filtered_buffer = self.r900_filter(self.r900_signal_buffer, self.samples_per_chip)
self.r900_quantized_buffer = self.r900_quantize(self.r900_filtered_buffer, self.samples_per_chip)
seen_bits = set()
preambleLength = len(self.PREAMBLE) * self.symbol_length
for qIdx in all_indices:
if qIdx > self.block_size:
break
payloadIdx = qIdx + preambleLength - self.symbol_length
max_idx_needed = payloadIdx + (42 * 4 * self.samples_per_chip)
if max_idx_needed > len(self.r900_quantized_buffer):
continue
digits = ""
for idx in range(0, 42 * 4 * self.samples_per_chip, self.samples_per_chip * 4):
sym_idx = payloadIdx + idx
digits += str(self.r900_quantized_buffer[sym_idx])
if len(digits) < 42:
continue
bits = ""
symbols = []
bad_symbol = False
for idx in range(0, len(digits) - 1, 2):
symbol_str = digits[idx:idx+2]
symbol = int(symbol_str, 6)
if symbol > 31:
bad_symbol = True
break
symbols.append(symbol)
bits += format(symbol, '05b')
if bad_symbol or len(bits) < 80 or len(symbols) < 21:
continue
rs_buf = [0] * 31
rs_buf[0:16] = symbols[0:16]
rs_buf[26:31] = symbols[16:21]
syndrome = self.field.syndrome(rs_buf, 5, 29)
if any(s != 0 for s in syndrome):
continue
bits_80 = bits[:80]
if bits_80 in seen_bits:
continue
seen_bits.add(bits_80)
message = self.decode_packet(bits_80, signal_strength=signal_strength_db, verbose=False)
if message:
messages.append(message)
return messages
class DisplayFormatter:
COLORS = {
'RESET': '\033[0m',
'BOLD': '\033[1m',
'DIM': '\033[2m',
'RED': '\033[91m',
'GREEN': '\033[92m',
'YELLOW': '\033[93m',
'BLUE': '\033[94m',
'MAGENTA': '\033[95m',
'CYAN': '\033[96m',
'WHITE': '\033[97m',
'BG_BLACK': '\033[40m',
'BG_GREEN': '\033[42m',
'BG_YELLOW': '\033[43m',
'BG_RED': '\033[41m',
}
def __init__(self, use_color=True):
self.use_color = use_color
self.meter_stats = defaultdict(lambda: {
'first_seen': None,
'last_seen': None,
'message_count': 0,
'last_consumption': 0,
'max_signal': -100,
'min_signal': 0,
'avg_signal': []
})
self.strongest_signal_seen = -100
def color(self, color_name):
if self.use_color:
return self.COLORS.get(color_name, '')
return ''
def signal_bar(self, signal_db, width=20):
normalized = max(0, min(100, (signal_db + 100)))
filled = int((normalized / 100) * width)
bar = '█' * filled + '░' * (width - filled)
if signal_db > -40:
color = 'GREEN'
elif signal_db > -60:
color = 'YELLOW'
else:
color = 'RED'
return f"{self.color(color)}{bar}{self.color('RESET')}"
def estimate_distance(self, signal_db):
path_loss_exponent = 3.5
if self.strongest_signal_seen > -100:
db_diff = self.strongest_signal_seen - signal_db
reference_distance = 10.0
distance_multiplier = 10 ** (db_diff / (10 * path_loss_exponent))
distance_ft = reference_distance * distance_multiplier
else:
distance_ft = 30 * (10 ** ((-30 - signal_db) / (10 * path_loss_exponent)))
if distance_ft < 10:
dist_str = f"~{int(distance_ft)}ft"
elif distance_ft < 50:
dist_str = f"~{int(distance_ft)}ft"
elif distance_ft < 100:
dist_str = f"~{int(distance_ft/5)*5}ft"
elif distance_ft < 300:
dist_str = f"~{int(distance_ft/10)*10}ft"
elif distance_ft < 1000:
dist_str = f"~{int(distance_ft/25)*25}ft"
else:
dist_str = f"~{int(distance_ft/50)*50}ft"
return dist_str
def format_consumption(self, consumption):
if consumption >= 1000000:
return f"{consumption/1000000:.2f}M gal"
elif consumption >= 1000:
return f"{consumption/1000:.2f}K gal"
else:
return f"{consumption} gal"
def print_header(self):
header = f"""
{self.color('BOLD')}{self.color('CYAN')}╔═══════════════════════════════════════════════════════════════════════════════╗
║ MapleWater Enhanced - R900 Decoder ║
║ Adnan Security Inc. ║
╚═══════════════════════════════════════════════════════════════════════════════╝{self.color('RESET')}
"""
print(header)
def print_message(self, msg):
meter_id = msg['Message']['ID']
consumption = msg['Message']['Consumption']
signal_db = msg.get('SignalStrength', 0)
timestamp = datetime.fromisoformat(msg['Time']).strftime('%H:%M:%S')
stats = self.meter_stats[meter_id]
if stats['first_seen'] is None:
stats['first_seen'] = msg['Time']
stats['last_seen'] = msg['Time']
stats['message_count'] += 1
stats['last_consumption'] = consumption
stats['max_signal'] = max(stats['max_signal'], signal_db)
stats['min_signal'] = min(stats['min_signal'], signal_db) if stats['min_signal'] == 0 else signal_db
stats['avg_signal'].append(signal_db)
leak = msg['Message']['Leak']
leak_now = msg['Message']['LeakNow']
backflow = msg['Message']['BackFlow']
no_use = msg['Message']['NoUse']
status_parts = []
if leak_now:
status_parts.append(f"{self.color('BG_RED')}{self.color('WHITE')} 🚨 LEAK NOW {self.color('RESET')}")
elif leak:
status_parts.append(f"{self.color('RED')}⚠ Leak:{leak}d{self.color('RESET')}")
else:
status_parts.append(f"{self.color('GREEN')}✓ Leak:0{self.color('RESET')}")
if backflow:
status_parts.append(f"{self.color('BG_YELLOW')}{self.color('BLACK')} ⚡ BFlow:{backflow} {self.color('RESET')}")
else:
status_parts.append(f"{self.color('GREEN')}BFlow:0{self.color('RESET')}")
if no_use:
status_parts.append(f"{self.color('YELLOW')}💤 NoUse:{no_use}d{self.color('RESET')}")
else:
status_parts.append(f"{self.color('GREEN')}NoUse:0{self.color('RESET')}")
alert_str = ' '.join(status_parts)
distance_est = self.estimate_distance(signal_db)
if signal_db > self.strongest_signal_seen:
self.strongest_signal_seen = signal_db
print(f"{self.color('BOLD')}┌─────────────────────────────────────────────────────────────────────────────┐{self.color('RESET')}")
print(f"{self.color('BOLD')}│{self.color('RESET')} {self.color('CYAN')}Time:{self.color('RESET')} {timestamp} "
f"{self.color('MAGENTA')}Meter ID:{self.color('RESET')} {self.color('BOLD')}{meter_id}{self.color('RESET')}"
f"{' ' * (48 - len(str(meter_id)))}│")
print(f"{self.color('BOLD')}├─────────────────────────────────────────────────────────────────────────────┤{self.color('RESET')}")
consumption_str = self.format_consumption(consumption)
print(f"{self.color('BOLD')}│{self.color('RESET')} {self.color('YELLOW')}Consumption:{self.color('RESET')} "
f"{self.color('BOLD')}{consumption_str}{self.color('RESET')}"
f"{' ' * (63 - len(consumption_str))}│")
signal_bar = self.signal_bar(signal_db, width=30)
signal_info = f"{signal_db:>6.1f} dB ({distance_est})"
signal_padding = 68 - (30 + 1 + len(signal_info))
print(f"{self.color('BOLD')}│{self.color('RESET')} {self.color('BLUE')}Signal:{self.color('RESET')} "
f"{signal_bar} {signal_info}"
f"{' ' * signal_padding}│")
status_plain = []
if leak_now:
status_plain.append("🚨 LEAK NOW")
elif leak:
status_plain.append(f"⚠ Leak:{leak}d")
else:
status_plain.append("✓ Leak:0")
if backflow:
status_plain.append(f"⚡ BFlow:{backflow}")
else:
status_plain.append("BFlow:0")
if no_use:
status_plain.append(f"💤 NoUse:{no_use}d")
else:
status_plain.append("NoUse:0")
status_plain_str = ' '.join(status_plain)
status_padding = 68 - (len(status_plain_str))
print(f"{self.color('BOLD')}│{self.color('RESET')} {self.color('WHITE')}Status:{self.color('RESET')} {alert_str}"
f"{' ' * status_padding}│")
unkn1 = msg['Message']['Unkn1']
unkn3 = msg['Message']['Unkn3']
extra_info = f"Unkn1:{unkn1} Unkn3:{unkn3}"
print(f"{self.color('BOLD')}│{self.color('RESET')} {self.color('DIM')}Data:{self.color('RESET')} {extra_info}"
f"{' ' * (69 - len(extra_info))}│")
print(f"{self.color('BOLD')}└─────────────────────────────────────────────────────────────────────────────┘{self.color('RESET')}")
print()
def print_stats(self, total_samples, total_messages, elapsed_time):
print(f"\n{self.color('BOLD')}{self.color('CYAN')}═══════════════════════════════════════════════════════════════════════════════{self.color('RESET')}")
print(f"{self.color('BOLD')} SESSION STATISTICS {self.color('RESET')}")
print(f"{self.color('CYAN')}═══════════════════════════════════════════════════════════════════════════════{self.color('RESET')}\n")
print(f"{self.color('YELLOW')}Runtime:{self.color('RESET')} {elapsed_time:.1f} seconds")
print(f"{self.color('YELLOW')}Total Samples Processed:{self.color('RESET')} {total_samples:,}")
print(f"{self.color('YELLOW')}Total Messages Decoded:{self.color('RESET')} {total_messages}")
print(f"{self.color('YELLOW')}Unique Meters Detected:{self.color('RESET')} {len(self.meter_stats)}\n")
if self.meter_stats:
print(f"{self.color('BOLD')}Meter Summary:{self.color('RESET')}\n")
print(f"{'Meter ID':<15} {'Messages':<12} {'Last Reading':<20} {'Avg Signal':<12} {'Range':<15}")
print(f"{'-'*85}")
for meter_id, stats in sorted(self.meter_stats.items()):
avg_sig = sum(stats['avg_signal']) / len(stats['avg_signal']) if stats['avg_signal'] else 0
consumption_str = self.format_consumption(stats['last_consumption'])
sig_range = f"{stats['min_signal']:.1f} to {stats['max_signal']:.1f} dB"
print(f"{meter_id:<15} {stats['message_count']:<12} {consumption_str:<20} "
f"{avg_sig:>6.1f} dB {sig_range}")
print(f"\n{self.color('CYAN')}═══════════════════════════════════════════════════════════════════════════════{self.color('RESET')}\n")
def main():
parser = argparse.ArgumentParser(description='R900 Water Meter Decoder')
parser.add_argument('--freq', type=int, default=912380000,
help='Center frequency in Hz (default: 912380000)')
parser.add_argument('--sample-rate', type=int, default=2359296,
help='Sample rate in Hz (default: 2359296)')
parser.add_argument('--gain', default='auto',
help='Gain setting (default: auto)')
parser.add_argument('--samples', type=int, default=262144,
help='Number of samples per read (default: 262144)')
parser.add_argument('--output', type=str, default=None,
help='Output file for JSON messages')
parser.add_argument('--json-only', action='store_true',
help='Output JSON only (no visual formatting)')
parser.add_argument('--no-color', action='store_true',
help='Disable color output')
parser.add_argument('--require-checksum', action='store_true',
help='Only show packets with valid checksums (more strict filtering)')
parser.add_argument('--verbose', action='store_true',
help='Show verbose debug output')
args = parser.parse_args()
decoder = R900Decoder()
formatter = DisplayFormatter(use_color=not args.no_color)
seen_messages = {}
duplicate_threshold = 5
output_file = None
if args.output:
output_file = open(args.output, 'a')
sample_count = 0
message_count = 0
try:
if not args.json_only:
formatter.print_header()
print("Initializing RTL-SDR...")
sdr = RtlSdr()
print("Setting sample rate...")
sdr.sample_rate = args.sample_rate
print("Setting center frequency...")
sdr.center_freq = args.freq
print("Setting gain...")
if args.gain == 'auto':
sdr.gain = 'auto'
else:
try:
sdr.gain = float(args.gain)
except:
sdr.gain = 'auto'
if not args.json_only:
print(f"\n{formatter.color('GREEN')}● Decoder Active{formatter.color('RESET')}")
print(f"{formatter.color('BLUE')}Frequency:{formatter.color('RESET')} {args.freq/1e6:.3f} MHz")
print(f"{formatter.color('BLUE')}Sample Rate:{formatter.color('RESET')} {args.sample_rate/1e6:.3f} MHz")
print(f"{formatter.color('BLUE')}Gain:{formatter.color('RESET')} {sdr.gain}")
try:
print(f"{formatter.color('BLUE')}Available gains:{formatter.color('RESET')} {sdr.valid_gains_db}")
except:
pass
print(f"\n{formatter.color('YELLOW')}Listening for R900 transmissions...{formatter.color('RESET')}")
print(f"{formatter.color('DIM')}Press Ctrl+C to stop{formatter.color('RESET')}\n")
else:
print(f"R900 Decoder Started - Frequency: {args.freq/1e6:.2f} MHz, Sample Rate: {args.sample_rate/1e6:.2f} MHz")
print(f"Press Ctrl+C to stop\n")
start_time = time.time()
while True:
samples = sdr.read_samples(args.samples)
sample_count += len(samples)
if args.verbose and sample_count % (args.samples * 10) == 0:
elapsed = time.time() - start_time
print(f"[DEBUG] Samples: {sample_count}, Time: {elapsed:.1f}s, Messages: {message_count}")
messages = decoder.process_samples(samples, verbose=args.verbose and sample_count < args.samples * 2)
for msg in messages:
meter_id = msg['Message']['ID']
consumption = msg['Message']['Consumption']
current_time = time.time()
if args.require_checksum and not msg.get('ChecksumValid', False):
continue
msg_key = f"{meter_id}_{consumption}"
if msg_key not in seen_messages or \
(current_time - seen_messages[msg_key]) > duplicate_threshold:
message_count += 1
if args.json_only:
print(json.dumps(msg))
else:
formatter.print_message(msg)
if output_file:
output_file.write(json.dumps(msg) + '\n')
output_file.flush()
seen_messages[msg_key] = current_time
current_time = time.time()
seen_messages = {
k: v for k, v in seen_messages.items()
if (current_time - v) <= duplicate_threshold
}
except KeyboardInterrupt:
elapsed = time.time() - start_time
if not args.json_only:
print(f"\n\n{formatter.color('YELLOW')}Shutting down...{formatter.color('RESET')}")
formatter.print_stats(sample_count, message_count, elapsed)
else:
print(f"\n\nStopped - Total samples: {sample_count}, Messages: {message_count}, Time: {elapsed:.1f}s")
except Exception as e:
print(f"\n{formatter.color('RED')}Error: {e}{formatter.color('RESET')}")
import traceback
traceback.print_exc()
finally:
if output_file:
output_file.close()
sdr.close()
if __name__ == '__main__':
main()
Key features of the complete implementation:
- Real-time signal strength visualization with color-coded bars
- Distance estimation based on signal strength
- Duplicate message filtering
- Session statistics and meter tracking
- JSON output for integration with other tools
- Command-line arguments for customization
Conclusion
The R900 AMR system represents both the promise and peril of IoT (Internet of Things) infrastructure. While it delivers real benefits in efficiency and automation, the complete absence of security measures creates serious risks that extend beyond mere privacy concerns into the realms of safety, fraud, and critical infrastructure vulnerability.
Our MapleWater decoder demonstrates how accessible this technology is—requiring only modest technical skill and minimal equipment. This accessibility makes it an excellent educational tool for understanding RF communication and signal processing, but it also highlights the urgent need for security improvements in critical infrastructure.
As we deploy more wireless systems for essential services, we must prioritize security from the ground up. Legacy systems like R900 serve as cautionary tales: convenience and cost savings cannot come at the expense of security and privacy.
💡 Key Takeaways
• Education is the first step: Understanding vulnerabilities helps build more secure systems
• Use for good, not evil: This knowledge should improve security, not compromise it
• Advocate for change: Push utilities and manufacturers to prioritize security
• Stay legal: Always comply with local laws and regulations
⚠️ Legal Disclaimer
This research is presented for educational purposes only. Unauthorized interception of communications may be illegal in your jurisdiction under federal wiretapping laws (18 USC § 2511) and similar state statutes.
The techniques described should only be used to:
- • Monitor your own water meters on your property
- • Conduct authorized security research with proper permissions
- • Educate about infrastructure vulnerabilities for defensive purposes
Always comply with local laws and regulations. Seek legal counsel if uncertain about the legality of monitoring activities in your jurisdiction.
📜 License & Attribution
Hackers Choice License (HCL) - Use this code for good, not evil.
Special thanks to the rtlamr and librtlsdr projects for their foundational work in AMR decoding and Galois Field implementation. This research builds upon their excellent open-source contributions.
About the Author
Adnan Ahmad is a security researcher and penetration tester with 27 years of experience in critical infrastructure security. Principal at Adnan Security Inc., With a passion for system design, RF security, embedded systems and adversary simulation for Fortune 500 enterprises and government agencies.