If your home server is a Raspberry Pi, you don't need an Arduino or Home Assistant to read your mains — a few lines of Python and an rbAmp module will print 230.4 V · 0.21 A · 48 W · 0.97 PF to the terminal, updating every second, and keep a running kilowatt-hour total. No cloud, no broker, no service. It's also the gentlest on-ramp to the rbAmp Python library; everything here carries into the bigger Pi projects (multi-circuit metering, load control, data logging) later in this series.
What you'll build
- Live voltage, current, real power, and power factor printed to the terminal, about once a second.
- A running energy (Wh) total, integrated on the Pi over wall-clock time.
- All over the Pi's I²C bus — four wires — in plain CPython.
- No Wi-Fi service, no cloud, no HA. Just a script you run.
Bill of materials
| Item | Qty | Notes |
|---|---|---|
| rbAmp Basic Wattmeter (UI1) | 1 | voltage + current + on-chip real power · product |
| SCT-013 current transformer | 1 | rating to match your circuit · product |
| Raspberry Pi | 1 | any model with the 40-pin I²C header |
| Jumper wires | — | the I²C bus (SDA / SCL / 5V / GND) |
Enable I²C and install the library
On the Pi, turn on the I²C interface once — sudo raspi-config → Interface Options → I2C → enable (or add
dtparam=i2c_arm=on to /boot/firmware/config.txt and reboot). Then install the package:
pip install rbamp smbus2(The Pi's kernel I²C driver runs at 100 kHz by default and is happy with the module — there's no 50 kHz workaround here; that only applies to the ESP-IDF driver on ESP32.)
Wire it up
The rbAmp module goes on the Pi's I²C pins:
Raspberry Pi header rbAmp (0x50)
------------------- ------------
pin 3 SDA (GPIO2) ────────── SDA
pin 5 SCL (GPIO3) ────────── SCL
pin 2 5V ────────── VCC (rbAmp wants 5 V)
pin 6 GND ────────── GND
│
└── SCT-013 clamped around ONE mains conductor (arrow → load)- The Pi's I²C bus is 3.3 V logic, and the rbAmp module has on-board pull-ups — so no extra pull-ups for a single module. Power the module at 5 V (the Pi's 5 V pins); the bus lines idle at 3.3 V. (Why 5 V power but a 3.3 V bus?)
- The SCT-013 clamps around an insulated conductor — you never cut or strip live wire for the current side. The AC voltage side connects to mains; if you're not comfortable near live conductors, have a qualified person make that connection. Mains is dangerous.
The script
Save it, set MY_CT (or pass --ct) to match your clamp, and run python tp1_standalone_pi_monitor.py:
"""
T-P1 — Standalone Raspberry Pi energy monitor (no network, no HA).
One rbAmp UI1 module on the Raspberry Pi hardware I2C bus. Prints live
mains Voltage / Current / Power / Power Factor to the terminal every
second, and every 30 s latches a period snapshot to roll a host-side
Energy (Wh) total. Nothing is invented on-device: Wh is integrated on
the host from average power x wall-clock dt, which is what makes the
total honest.
Wiring (Raspberry Pi, hardware I2C):
rbAmp SDA -> GPIO2 (Pi pin 3)
rbAmp SCL -> GPIO3 (Pi pin 5)
rbAmp GND -> any GND pin
rbAmp VCC -> 5V (Pi pin 2 or 4; 4.5-5.5 V -- below 4.5 V the ADC loses accuracy)
Enable I2C once on the Pi:
sudo raspi-config # Interface Options -> I2C -> Enable
# (equivalent to `dtparam=i2c_arm=on`)
The stock Raspberry Pi Broadcom I2C driver runs at 100 kHz and is
clean with rbAmp straight out of the box -- no `dtparam=i2c_arm_baudrate`
override is needed. (The 50 kHz workaround only applies to the ESP-IDF
i2c_master driver on ESP32.)
Install:
pip install rbamp smbus2
Run:
python tp1_standalone_pi_monitor.py
# or with a non-default bus / address / CT model:
# python tp1_standalone_pi_monitor.py --bus 1 --addr 0x50 --ct 3
Set MY_CT below (or pass --ct) to match YOUR clamp before first run.
The value persists to the module's flash, so this script only re-writes
it when it actually differs -- no flash wear on every power-up.
Accuracy: +/-0.5% of reading per channel with the matched rbAmp CT.
Ctrl-C to stop.
"""
import argparse
import time
from smbus2 import SMBus
from rbamp import (
RbAmp,
RbAmpError,
RbAmpStaleError,
RbAmpSensorClass,
)
# ---- configuration --------------------------------------------------------
# CT model code (matches your clamp; persists to module flash):
# 1 = SCT-013-005 (5 A) 4 = SCT-013-050 (50 A)
# 2 = SCT-013-010 (10 A) 6 = SCT-013-020 (20 A)
# 3 = SCT-013-030 (30 A)
MY_CT = 3
LIVE_S = 1.0 # refresh V/I/P/PF each second
ENERGY_S = 30.0 # latch a period + roll Wh every 30 s
def ensure_ct_preset(dev, ct_code):
"""Verify-then-set: only rewrite the CT preset if the module isn't
already on it. set_sensor_class / set_ct_model_ch both persist to
flash, so this avoids a needless flash write every power-up.
"""
try:
applied = dev.read_ct_model_ch(0)
except RbAmpError:
applied = -1
if applied == ct_code:
print(f"CT model already SCT_013 code={ct_code}; skipping flash write")
return False
print(f"configuring CT model SCT_013 code={ct_code} (persists to flash)...")
dev.set_sensor_class(RbAmpSensorClass.SCT_013)
dev.set_ct_model_ch(0, ct_code)
return True
def read_live(dev, last, counters):
"""Read one live sample. A single transient hiccup keeps the previous
plausible reading so the terminal keeps ticking (mirrors the Arduino
T-A1 NaN-guard)."""
try:
v = dev.voltage
i = dev.current[0]
p = dev.power[0]
pf = dev.power_factor[0]
except RbAmpError as e:
counters["bad"] += 1
print(f"read skipped: {type(e).__name__} -- {e}")
return
last["V"], last["I"], last["P"], last["PF"] = v, i, p, pf
counters["ok"] += 1
print(
f"U={v:6.1f}V I={i:6.3f}A P={p:7.1f}W PF={pf:+.2f} "
f"E={dev.energy.wh(0):8.4f}Wh "
f"[ok={counters['ok']} bad={counters['bad']}]"
)
def roll_energy(dev):
"""Latch a metering period; the library rolls Wh from avg power x master
dt on a successful valid snapshot. A stale / not-ready period is reported
and skipped so the loop keeps running on the last live values."""
try:
snap = dev.read_period_snapshot()
except RbAmpStaleError:
print(" * period STALE (skipped)")
return
except RbAmpError as e:
print(f" * period skipped: {type(e).__name__} -- {e}")
return
print(
f" * period ok: avg_P0={snap.avg_p[0]:6.2f}W "
f"dt={snap.master_dt_ms:5d}ms -> E={dev.energy.wh(0):.4f}Wh"
)
def run(bus, addr=0x50, ct_code=MY_CT,
live_s=LIVE_S, energy_s=ENERGY_S, max_iters=None):
"""Core loop; broken out so a test harness can swap in a mock bus."""
with RbAmp(bus, addr) as dev:
print(
f"rbAmp @ 0x{dev.address:02X} fw=0x{dev.firmware_version:02X} "
f"topology={dev.topology_name} channels={dev.channels} "
f"voltage_hw={'yes' if dev.has_voltage_hw else 'no'}"
)
ensure_ct_preset(dev, ct_code)
print("Accuracy: +/-0.5% of reading with matched CT. Ctrl-C to stop.\n")
last = {"V": 0.0, "I": 0.0, "P": 0.0, "PF": 0.0}
counters = {"ok": 0, "bad": 0}
next_live_t = time.monotonic()
next_energy_t = next_live_t + energy_s
iters = 0
while True:
now = time.monotonic()
if now >= next_live_t:
next_live_t = now + live_s
read_live(dev, last, counters)
if now >= next_energy_t:
next_energy_t = now + energy_s
roll_energy(dev)
iters += 1
if max_iters is not None and iters >= max_iters:
return counters
sleep_for = min(next_live_t, next_energy_t) - time.monotonic()
if sleep_for > 0:
time.sleep(sleep_for)
def main():
ap = argparse.ArgumentParser(
description="Standalone Raspberry Pi energy monitor for a single rbAmp UI1 module."
)
ap.add_argument("--bus", type=int, default=1,
help="I2C bus number (Raspberry Pi default: 1)")
ap.add_argument("--addr", type=lambda s: int(s, 0), default=0x50,
help="rbAmp I2C address (default: 0x50)")
ap.add_argument("--ct", type=int, default=MY_CT,
help="CT model code: 1=SCT-013-005, 2=-010, 3=-030, "
"4=-050, 6=-020 (default: 3 = 30 A)")
args = ap.parse_args()
with SMBus(args.bus) as bus:
run(bus, addr=args.addr, ct_code=args.ct)
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\nstopped.")(Run-verified against the rbAmp Python library's test backend on CPython 3.12 — rbamp 1.3.0 + smbus2 0.6.1.
set_ct_model_ch() persists to flash, so the script only re-writes the CT preset when it actually differs — no
flash wear on every run. The run() core is broken out so it's easy to test or reuse.)
What the script is doing
The whole rbAmp Python library fits in a couple of with blocks and a handful of reads.
The context managers
with SMBus(1) as bus:
with RbAmp(bus, 0x50) as dev:
...SMBus(1) opens the Pi's I²C bus; RbAmp(bus, 0x50) binds the library to the module at address 0x50. Both are
context managers, so the bus and the device are cleaned up automatically when the block exits (or on Ctrl-C) —
the Pythonic version of "open the bus, probe the module."
The live reads
v = dev.voltage
i = dev.current[0]
p = dev.power[0]
pf = dev.power_factor[0]These properties return the module's real measurements: dev.power[0] is true active power in watts — the
mean of instantaneous voltage × current, not current × an assumed voltage. (The method form
dev.read_power(0) is exactly equivalent — use whichever reads cleaner.) A transient read can raise
RbAmpError; the script catches it and keeps the previous good values rather than crashing.
Energy — the period snapshot
snap = dev.read_period_snapshot() # atomic LATCH: close the window, read avg power
print(dev.energy.wh(0)) # host-integrated watt-hours, honest over wall-clock timeread_period_snapshot() sends the module an atomic LATCH — "close the measurement window now" — and the
library rolls the host-side watt-hour total from average_power × elapsed_time. The Pi owns the integration
interval, so there's no on-device counter to drift; dev.energy.wh(0) is an honest running total. A stale window
raises RbAmpStaleError, which the script just skips.
What you'll see
In the terminal, a live readout that tracks the load — switch on a lamp or a kettle and watch the watts jump:
rbAmp @ 0x50 fw=0x04 topology=SINGLE channels=1 voltage_hw=yes
CT model already SCT_013 code=3; skipping flash write
Accuracy: +/-0.5% of reading with matched CT. Ctrl-C to stop.
U= 231.2V I= 4.874A P= 1123.5W PF=+1.00 E= 0.0000Wh [ok=1 bad=0]
U= 231.2V I= 4.874A P= 1123.5W PF=+1.00 E= 0.0000Wh [ok=2 bad=0]
* period ok: avg_P0=1119.40W dt= 50ms -> E=0.0622Wh
U= 231.2V I= 4.874A P= 1123.5W PF=+1.00 E= 0.0622Wh [ok=3 bad=0]How accurate is it?
With the matched rbAmp CT, each channel is rated ±0.5% of reading across the calibrated range. The module measures true active power with per-channel phase compensation — so reactive and low-power-factor loads read honestly. The why-behind-the-numbers: how CT energy measurement really works.
Make it your own
- Log it to a file or SQLite — the Pi has a real filesystem; append each reading to CSV or a database (that's the logger build).
- Serve it — the Pi is networked: expose the readings as a tiny Flask endpoint, or publish over MQTT to your own broker — without any cloud.
- A cron summary — run the script on a timer and email yourself a daily kWh total.
- Add a display — a small I²C OLED on the same bus for a headless readout.
- More circuits — put several modules on the bus and total them (that's the multi-module build).
None of these touch the measurement — they're what you do with the numbers the reads already give you.
Where to go next
This one module in the terminal is the foundation. From here the Python series builds up:
- Meter several circuits — multiple rbAmp modules on one bus, read and totalled by the Pi.
- Act on it — trip a GPIO relay when a circuit goes over a current limit.
- Log it — periodic energy to CSV or SQLite.
(Prefer a microcontroller instead of a Pi? The same module runs on Arduino or in Home Assistant via ESPHome.)