// RESEARCH

MapleWater: A Dive into Automatic Meter Reading (AMR) Technology

Adnan Ahmad · · Security Research

⚠️ 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.

16
Unique meters detected
From single office location
52
Total messages decoded
In ~5.7 minutes runtime
9-14 ft
Estimated distances
Based on signal strength

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

10-50 ft
-40 to -50 dB

Perfect decoding, minimal errors, reliable data every transmission

Good Signal

50-300 ft
-50 to -70 dB

Consistent decoding, occasional errors corrected by Reed-Solomon

Weak Signal

300-1000+ ft
-70 to -90 dB

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

1.

Upgrade to encrypted systems

Modern AMR systems support AES encryption. Migrate legacy R900 deployments to secure alternatives.

2.

Implement authentication

Ensure transmitted data includes cryptographic signatures to prevent spoofing attacks.

3.

Monitor for anomalies

Detect unusual transmission patterns that might indicate spoofing or tampering.

4.

Customer education

Inform users about the technology and its current security limitations.

For Residents

1.

Be aware of the exposure

Understand that your water usage data is publicly broadcast and can reveal household patterns.

2.

Don't rely on meter patterns for security

Use proper security systems, timers, and other measures instead of depending on usage patterns.

3.

Report suspicious activity

Notify utilities immediately of unusual meter readings or potential tampering.

4.

Advocate for upgrades

Push for encrypted AMR systems in your area. Privacy is your right.

For Researchers

1.

Responsible disclosure

Report vulnerabilities to manufacturers and utilities before public disclosure.

2.

Ethical use only

Never exploit data for malicious purposes. Do not transmit without proper authorization.

3.

Anonymize data

Protect privacy when publishing research by anonymizing all meter IDs and locations.

4.

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.