09 · API Reference
The complete public API of the RbAmp library v1.3.0 for Arduino. Source
of truth — the header files src/RbAmp.h,
src/RbAmpSnapshot.h,
src/RbAmpEnergy.h,
src/RbAmpRegisters.h (the last one is
auto-generated and must not be edited by hand).
This chapter is a reference: signatures, return values, side effects, edge cases. For working examples see 06 · Examples; for the quick start see 05 · Quickstart.
Header files
| Include | What it brings in |
|---|---|
<RbAmp.h> |
All public types (pulls in the rest) |
<RbAmpSnapshot.h> |
RbAmpSnapshot, RbAmpPeriodSnapshot, RbAmpTopology, RbAmpSensorClass |
<RbAmpEnergy.h> |
RbAmpEnergy |
<RbAmpRegisters.h> |
Auto-generated protocol constants — RB_OK, RB_ERR_*, SETTLE_MS_* |
A user sketch normally only needs #include <RbAmp.h> — the rest
are pulled in transitively.
RbAmp class
The main client. One instance per slave device on the I²C bus.
Multiple instances can share a single TwoWire bus to poll several
modules.
Constructor
explicit RbAmp(TwoWire& bus,
uint8_t addr = 0x50,
RbAmpTopology hint = RbAmpTopology::Single) noexcept;| Parameter | Description |
|---|---|
bus |
Reference to the TwoWire bus (usually Wire). The caller must call bus.begin() BEFORE begin(). |
addr |
7-bit slave address. Range 0x08..0x77, default 0x50. |
hint |
Topology hint: the number of current channels (1/2/3). On pre-v1.3 firmware begin() cannot reliably determine the variant, so the hint is used as a fallback for multi-channel SKUs. Defaults to Single — the safe default, which does not trigger spurious reads of absent channels. For multi-channel modules on pre-v1.3 firmware, pass the matching hint explicitly. |
The constructor generates no I²C traffic.
Lifecycle methods
bool begin() noexcept
Probe the device, commit the topology, prime LATCH.
Sequence:
- Read
REG_VERSION(0x03). If the device did not ACK —falsewithRB_ERR_NACK. If it returned0x00/0xFF—falsewithRB_ERR_VERSION. - Cache the topology from the constructor (
hint) — on the current firmwarebegin()does not auto-probe the variant. After this,topology()/channels()return valid values without touching the bus. - Read
U_rmsto determine whether a voltage sensor is present (threshold 1.0 V). - Write
CMD_LATCH_PERIODas a primer + wait 50 ms. The first snapshot after power-on is discarded (it is not handed to user code — it mixes in pre-power-on data). - Store
millis()for subsequent energy integration.
Idempotent: safe to call again.
Returns: true on success; false otherwise — see
lastError() for details.
bool probe() noexcept
A lightweight liveness check. A single read of REG_VERSION with no
side effects.
Returns: true if the slave ACKed and reported a supported
version (neither 0 nor 0xFF).
bool waitReady(uint32_t timeout_ms = 1000) noexcept
Polls the module's ready flag until bit 0 is set. Useful after power-on — the module may need up to 200 ms for its first RT window.
Returns: true if the bit was seen before timeout_ms elapsed;
false with RB_ERR_TIMEOUT otherwise.
uint8_t firmwareVersion() noexcept
Returns: the REG_VERSION (0x03) byte. Known values:
0x01 = v1.0, 0x02 = v1.1, 0x03 = v1.2, 0x04 = v1.3 (current
production). 0 or 0xFF indicate an I²C error (see lastError()).
RbAmpTopology topology() const noexcept
Returns: the cached topology (the value from the constructor or
from REG_TOPOLOGY 0x24). No bus access.
uint8_t channels() const noexcept
Returns: 1, 2, or 3 — the number of current channels derived
from topology().
bool hasVoltageHw() const noexcept
Returns: true if a voltage sensor was detected at begin()
(U_rms > 1.0 V). Cached, no traffic.
uint8_t address() const noexcept
Returns: the current 7-bit I²C address. Updated after a
successful commitAddressChange().
uint8_t rawTopology() noexcept
A direct read of REG_TOPOLOGY (0x24). Unlike topology(), this
performs an I²C transaction.
Returns:
1/2/3—SINGLE/SPLIT_PHASE/THREE_PHASE.0xFF— I²C error.
For full SKU detection use
hwVariant()(readsREG_HW_VARIANT 0x55— returns 1=UI1, 2=UI2, 3=UI3, 4=I1, 5=I2, 6=I3).
Real-time reads (RT block, 200 ms refresh)
All methods return float. On error they return NAN and set
lastError(). Under the hood a retry + sanity filter is used for
robustness against rare link glitches on ESP-IDF (see the diagnostic
counters below).
float readVoltage(uint8_t phase = 0) noexcept
Instantaneous RMS voltage. On the current firmware only phase = 0
is supported — other values return NAN with RB_ERR_PARAM.
float readVoltagePeak(uint8_t phase = 0) noexcept
Peak voltage over the RT window.
float readCurrent(uint8_t ch = 0) noexcept
Instantaneous RMS current on a channel. ch must be < channels()
— otherwise NAN with RB_ERR_PARAM.
float readCurrentPeak(uint8_t ch = 0) noexcept
Peak current on a channel over the RT window.
float readPower(uint8_t ch = 0) noexcept
Instantaneous active power on a channel. Signed — a negative value means export (the load feeds power back into the grid, relevant for PV / bidirectional installations).
float readPowerFactor(uint8_t ch = 0) noexcept
Power factor on a channel. Dimensionless, range −1..+1.
float readFrequency() noexcept
Grid frequency. Returns 50.0 or 60.0 Hz on a healthy grid.
bool readAll(RbAmpSnapshot& out) noexcept
The complete RT block in a single call, into a RbAmpSnapshot
struct. Equivalent to sequential readVoltage() + readVoltagePeak()
+ readCurrent(0..N) + readCurrentPeak(0..N) + readPower(0..N) +
readPowerFactor(0..N) + readFrequency(). Unused channels (beyond
channels()) are filled with zeros.
Returns: true on success; false if any single read failed —
lastError() holds the code of the first error.
Period metering
See 01 · Overview for the big picture and 05 · Quickstart Step 5 for a minimal template.
bool latchPeriod() noexcept
Writes CMD_LATCH_PERIOD (0x27) to REG_COMMAND. Does not wait —
the caller itself must observe the 50 ms settle and check
isPeriodValid() before reading.
For most tasks use readPeriodSnapshot() — it encapsulates the
entire sequence.
Returns: true on a successful write.
bool isPeriodValid() noexcept
Reads the latch ready bit. true if the last snapshot is fresh and
ready to read.
float readPeriodAvgPower(uint8_t ch = 0) noexcept
Average active power on a channel over the latched period. Must be
called after latchPeriod() + 50 ms settle + valid check.
float readPeriodMaxPower() noexcept
Peak instantaneous power on channel 0 over the latched period.
uint32_t readPeriodLatchMs() noexcept
Period duration from the device's point of view, in ms.
Diagnostic value. The module's internal timer has limited accuracy (a few percent). For energy integration use the master wall-clock (
master_dt_msfromRbAmpPeriodSnapshot), not this field.
bool readPeriodSnapshot(RbAmpPeriodSnapshot& out, uint16_t settle_ms = 50, bool skip_latch = false) noexcept
The recommended entry point for period metering. The full sequence under the hood:
- If
skip_latch == true— skip the LATCH write (applies afterbroadcastLatch()or a manual series of LATCH commands). - Otherwise — write
CMD_LATCH_PERIOD. - Capture
millis()to computemaster_dt_ms. delay(settle_ms)— 50 ms by default per the protocol specification.- Check the latch ready flag. If 0 — set
RB_ERR_STALEand record the timestamp (so the next snapshot does not double-count bydt). - Read
avg_p[0..channels-1]+max_p+latch_ms. - Update the timestamp + call
energy().tick(out, channels_)for automatic Wh integration.
| Parameter | Description |
|---|---|
out |
Output struct. |
settle_ms |
Wait after LATCH, 50 by default. |
skip_latch |
If true — do not write LATCH, only read (for a synchronized multi-board scene). |
Returns: true if out.valid == true; false if the read
failed or the snapshot is stale.
Energy metering (master-side accumulator)
RbAmpEnergy& energy() noexcept
const RbAmpEnergy& energy() const noexcept
Access to the per-channel Wh accumulator owned by this RbAmp
instance. The accumulator is updated automatically on every
successful readPeriodSnapshot(). For details see the "RbAmpEnergy
class" section below.
Sensor configuration
A two-step sequence: first the sensor class, then the model. Both steps are mandatory.
For a detailed model-selection guide see 03 · Current sensor selection.
CT model — a functional preset, not a label. Writing the CT model immediately applies the NF baseline, gain, and shape factor — the current readings change. Changing the
RbAmpSensorClassresetsREG_CT_MODEL = 0.
bool setSensorClass(RbAmpSensorClass cls) noexcept
Set the current sensor class with a save to flash.
Sequence: write REG_SENSOR_CLASS (0x25) → CMD_SAVE_USER_CONFIG (0x32)
→ wait 700 ms (flash erase). Blocking. Production-OK —
SAVE_USER_CONFIG is not gated.
Must be called before setCTModel(). Otherwise setCTModel()
returns false with RB_ERR_PARAM. It also resets REG_CT_MODEL
to 0 on the device side (protection against mixing a class and a
model through an incomplete reconfiguration).
| Argument | Meaning |
|---|---|
RbAmpSensorClass::Sct013 |
SCT-013 series (the only shipping class) |
Returns: true on success.
bool setCTModel(uint8_t code) noexcept
The single-parameter form — sets the CT clamp model on channel 0
only. Convenient for single-channel SKUs (UI1/I1); on multi-channel
ones it is equivalent to setCTModel(0, code).
Sequence: write REG_CT_MODEL (0x05) → CMD_SET_CT_MODEL_CH0 (opcode 0x28) → 5 ms settle → CMD_SAVE_USER_CONFIG (opcode 0x32) → 700 ms. Production-OK — uses the user-config save path, no develop-mode gating.
code |
Model |
|---|---|
| 1 | SCT-013-005 |
| 2 | SCT-013-010 |
| 3 | SCT-013-030 |
| 4 | SCT-013-050 |
| 6 | SCT-013-020 |
A code outside the valid set {1, 2, 3, 4, 6} for the Sct013 class → false with RB_ERR_PARAM. Code 5 is reserved in v1.3 firmware (the former SCT-013-100) and returns DEV_ERR_PARAM. For the full per-class accept-set see 03 · Current sensor selection.
For multi-channel modules use the per-channel form below.
bool setCTModel(uint8_t channel, uint8_t code) noexcept
The per-channel form. Sets the CT clamp model on a specific channel.
Under the hood: write REG_CT_MODEL (0x05) → command CMD_SET_CT_MODEL_CH0/1/2
(opcode 0x28/0x29/0x2A) according to channel → 5 ms settle → CMD_SAVE_USER_CONFIG
(opcode 0x32) → 700 ms. Production-OK — the user-config save path is the
production persistence channel for ct_model / sensor_class / per-channel CT.
Call order is arbitrary on v1.3 (Fix-A canon). Writing
REG_CT_MODEL (0x05)only stages a value — it is not auto- applied to channel 0. Application happens exclusively through the per-channel opcodeCMD_SET_CT_MODEL_CHn(0x28 / 0x29 / 0x2A), which copies the staged value into its own channel. Channels can therefore be configured in any order — ascending, descending, or interleaved — without clobber:
cpp dev.setCTModel(0, 1); // channel 0 = SCT-013-005 dev.setCTModel(1, 3); // channel 1 = SCT-013-030 dev.setCTModel(2, 6); // channel 2 = SCT-013-020Resulting state: ch0=preset 1, ch1=preset 3, ch2=preset 6. ✓
Any "descending order" / "bind ch0 last" guidance refers to pre-v1.3 firmware behavior and is no longer required.
RB_ERR_PARAM guard on the setSensorClass() precondition, same as
in the single-parameter form.
V2 verify mirrors — CT_MODEL_CH0/1/2 (0x51-0x53)
Read-only registers that reflect the CT preset actually applied on
each channel. The write wire-protocol does not change — writes
use the mechanism above (REG_CT_MODEL + CMD_SET_CT_MODEL_CHn).
0x51-0x53 are merely a read-back for verifying state and for
read-compare-write patterns:
uint8_t applied_ch0 = dev.readReg(0x51); // what is actually applied
uint8_t applied_ch1 = dev.readReg(0x52);
uint8_t applied_ch2 = dev.readReg(0x53);Returns: true on success.
bool saveGains() noexcept — develop-mode only
A "bare" CMD_SAVE_GAINS (0x26) write — the factory persistence
path for calibration coefficients (gains / noise floor / phase).
⚠ Factory-only. BLOCKED in production. On a production module
CMD_SAVE_GAINSis rejected by the firmware — the call returnsfalse,REG_ERRORis set toDEV_ERR_PARAM(0xFE), and the next reboot reverts any RAM-only writes. For all user-facing persistence (sensor class, CT model per channel, address, fleet config, label) usesaveUserConfig()(CMD_SAVE_USER_CONFIG, opcode 0x32) — that is the production-OK persistence path.saveGains()is exposed in the API for completeness; user code in a deployment context should not call it.
Returns: true on a successful command write (production: always
false with RB_ERR_MODE).
bool prepareAddressChange(uint8_t new_addr) noexcept
Step 1 of 2 for changing the module's I²C address. Production-OK on the current firmware (v1.3 two-phase commit, no develop-mode gating for the address).
Sequence: validate the new_addr range (0x08..0x77, ≠ current) → write the candidate to REG_I2C_ADDRESS (0x30) in RAM → write magic 0xA5 to REG_ADDR_COMMIT_MAGIC (0x31) — armed. The caller must call commitAddressChange() within 5 seconds, otherwise the arming expires.
Returns: true if armed successfully.
bool commitAddressChange() noexcept
Step 2 of 2. Must be called within 5 seconds of prepareAddressChange().
Sequence: check the freshness of the magic-armed state → CMD_COMMIT_ADDR (opcode 0x30) → persist to flash → CMD_RESET (0x01) → ~300 ms boot-to-responsive → update the internal address field. After this, all calls on this instance address the new address.
⚠ Re-enumeration after commit. After a successful commit the device resets and re-enumerates at the NEW address. Subsequent calls on this
RbAmpinstance address the new address transparently — but any OTHER master on the bus (a Python script, an ESP-IDF component on another MCU, a debug probe) will keep thinking the device is at the old address until its internal state is updated by hand.⚠ Verification path (v1.3 Fix 4): after reboot
REG_I2C_ADDRESS (0x30)reads the active address. A bus scan at the new address also works to confirm the commit.Historical: before the v1.3 fixes the address change was incorrectly documented as develop-only. Per the v1.3 A2 canon it is a production operation. The I²C address — the documented path is reconfiguration via the factory bench (outside the library's scope).
An additional note for commitAddressChange():
⚠ Restart and re-enumeration after commit. After a successful commit the device resets and re-enumerates at the NEW address. Subsequent calls on this
RbAmpinstance address the new address transparently — but any OTHER master on the bus (a Python script, an ESP-IDF component, a debug probe) will keep thinking the device is at the old address until its internal state is updated by hand.
If the arming window has expired, commitAddressChange() returns
false with RB_ERR_TIMEOUT and clears the arming flag — a fresh
prepareAddressChange() will be required.
Returns: true on success.
bool factoryReset() noexcept
The CMD_FACTORY_RESET command (0xAA) + wait 1500 ms.
⚠ Destructive operation. Erases ALL flash parameters (CT model, sensor class, calibration coefficients, I²C address). The module returns to its factory defaults —
RbAmpSensorClassbecomesUnset,REG_CT_MODELbecomes 0. Any setting previously applied viasetSensorClass()/setCTModel()is gone. The next user MUST re-apply bothsetSensorClass()andsetCTModel()before metering works again. This is not a "soft restart" — for a soft restart usereset().factoryReset()is reserved for recovery from a known-bad state or for handing the module off to another user / another installation.
Returns: true on a successful command write.
bool reset() noexcept
The CMD_RESET command (0x01) + wait 100 ms. A soft restart of the
device without losing flash parameters.
Returns: true on a successful command write.
Multi-module bus (static)
static bool broadcastLatch(TwoWire& bus) noexcept
Sends an I²C General-Call broadcast latch (A5 27 00 tick_lo tick_hi
to address 0x00) — all modules with GC enabled latch the period
simultaneously. On v1.3 firmware GC is supported as an opt-in
capability: per-module enable via REG_FLEET_CONFIG.bit0 (cleared by
default), capability bit CAP_GC_LATCH (bit 1) in REG_CAPABILITY
(0x57). The library checks the capability bit before broadcasting.
Return semantics:
- v1.3 firmware, GC enabled on at least one module — returns
true, the fleet latches in sync (skew < 1 ms across modules). - v1.3 firmware, GC disabled on all modules —
Wire.endTransmission()to0x00NACKs at the bus level → returnsfalse. Fix: enable GC per module viaenableGc(true)(writesFLEET_CONFIG.bit0+SAVE_USER_CONFIG+ reset), or useRbAmpFleet::enableGcAll(). - Legacy firmware without the
CAP_GC_LATCHcapability — returnsfalsewithout touching the bus. Fall back to a sequential series oflatchPeriod()on each device — see 06 · Examples, the multi-module bus section.
Diagnostics
int8_t lastError() const noexcept
Returns: the code of the last error — RB_OK (0) or one of the
RB_ERR_* values (negative). The full table is in the "Error codes"
section below + 10 · Troubleshooting.
static const char* errorString(int8_t code) noexcept
Returns: a constant string with a human-readable description of
the error code. Never returns NULL.
void setLogStream(Stream* stream) noexcept
An optional diagnostics sink. If set, the library briefly logs the
results of probe(), stale snapshots, and mode rejections. Pass
nullptr to turn it off. Off by default.
Typical use:
dev.setLogStream(&Serial);uint32_t retryExhaustionCount() const noexcept
How many times since power-on (or the last resetCounters()) the
retry loop was exhausted during a single-byte read. The steady-state
value is 0. A non-zero value is a sign of link problems (see
10 · Troubleshooting).
uint32_t sanityRejectCount() const noexcept
How many raw float values were rejected by the sanity filter
(!isfinite(x) || |x| > 10000). Steady-state — 0. A non-zero
value after retry mitigation usually means a rare ESP-IDF I²C-stack
artifact that survived the retry layer.
void resetCounters() noexcept
Zero both counters. Use it at the start of a soak test or a regression window to measure only the events within the window.
Identification and provisioning (v1.3)
These methods let the master understand exactly what is connected to the bus (variant, capability bit-map, UID, label) and prepare a new or freshly flashed module for operation (provisioning + saving the configuration to flash).
uint8_t readVariant() noexcept
Reads the module's HW_VARIANT (0x55) register. Values:
| Code | SKU | I channels | U channel |
|---|---|---|---|
1 |
UI1 | 1 | yes |
4 |
I1 | 1 | no |
5 |
I2 | 2 | no |
6 |
I3 | 3 | no |
Codes 2 (UI2) and 3 (UI3) are reserved for roadmap variants and
do not occur on the current hardware. The method does no caching —
it returns the "raw" byte; on v1.3 it is stable (read-only,
ROM-stored).
Variant autodetect via
readVariant()is the recommended path. On v1.3 the constructor topology hint remains as a fallback for pre-v1.3 firmware (whereHW_VARIANTis not yet documented). Default hint =Topology::Single— safe, it does not trigger spurious reads of absent channels on UI1/I1.
bool readCapability(uint16_t& out) noexcept
Reads the 16-bit feature map from the CAPABILITY (0x57/0x58)
registers, little-endian u16. Bit 8 (0x0100) indicates the
presence of a voltage front-end: if the bit is clear, the module does
not compute P / PF / energy and the values of those registers are
0. Bit 1 (0x0002, CAP_GC_LATCH) indicates support for the
General-Call broadcast latch (see broadcastLatch() above). The full
bit map is published in the rbAmp API reference.
Reading CAPABILITY as a full 16-bit value is the v1.3 canon (a
firmware fix ensures the low byte is correct). For a
backward-compatible extraction of has_voltage it is enough to read
the high byte (0x58): bool has_voltage = read_u8(0x58) & 0x01.
bool hasVoltage() noexcept
A convenience helper over readCapability — returns true if the
module has a U channel (bit 8). Cached inside the handle on the first
call; subsequent calls require no I²C operation.
uint8_t readProductId() noexcept
Reads PRODUCT_ID (0x54) — the byte identifier of the family
(rbAmp = 0x01). Used by the fleet scanner to confirm that the
device at an address really is an rbAmp and not some other I²C slave
that happened to respond at the same address.
bool readUid(uint8_t uid[12]) noexcept
Reads the 96-bit unique MCU identifier from UID (0x5C..0x67) in a
single burst operation. The UID is guaranteed unique for every
module and does not change on re-flash — convenient for serial logs,
master-side anti-clone checks, and identification when a module is
replaced in a deployed fleet.
bool readLabel(char out[9]) noexcept
Reads the 8-byte user label from LABEL (0x68..0x6F). ASCII,
zero-padded; the result is always null-terminated (a 9-byte array =
8 data bytes + the terminating \0). An empty label = unset.
bool writeLabel(const char* label) noexcept
Writes the 8-byte label. Accepts a null-terminated string — extra
bytes are truncated, missing ones are padded with zeros. The write
is byte-at-a-time for multi-byte writes (the rbAmp wire protocol
auto-increments register reads in burst mode, but not register
writes — see Wire-protocol details below;
the library does the byte loop automatically). After the write you
need dev.saveUserConfig() so the label survives a reset.
bool isProvisioned() noexcept
Checks whether the module has gone through initial provisioning.
Technically it checks REG_ERROR != 0xFB (DEV_ERR_FLASH_PARAMS_BAD)
on a freshly booted module — 0xFB is set by the firmware when the
user-config block is empty / corrupt. A module fresh from the factory
returns false — it needs saveUserConfig() (or provisioning via
RbAmpFleet::provision()) after the first configuration of the CT
model and address.
On v1.3 this code is normal for a virgin module (not fatal). Do not abort provisioning on the first sight of
0xFB.
bool saveUserConfig() noexcept
Issues CMD_SAVE_USER_CONFIG (opcode 0x32) — saves the user-config
block to flash: SENSOR_CLASS, CT_MODEL per channel,
I2C_ADDRESS, FLEET_CONFIG, GROUP_ID, LABEL. It does not
save factory-cal parameters — those are under a separate
CMD_SAVE_GAINS, locked in production mode.
Blocks the thread for ~700 ms (flash erase + write cycle). For the duration of the call, do not try to access the module from other tasks.
Read-back ≠ persistence: confirming that a value was written to flash requires a reboot + a re-read. See the "v1.3 error model" section below.
v1.3 error model — two independent channels
Errors have two independent channels that work in different ways. Understanding the difference is critical to interpreting the module's state correctly.
| Channel A — client-side, sync | Channel B — DUT-side, durable async | |
|---|---|---|
| Where it lives | Validation inside the library (Arduino lib) | REG_ERROR (0x02) + EVENT_FLAGS.bit3 (0x2A) |
| When it fires | Before the I²C transaction (the lib stops it) | After the FW accepted the write and rejected it |
| What the master sees | the setter returns false + lastError() == RB_ERR_PARAM; REG_ERROR on the device stays 0x00 |
REG_ERROR = the failure code immediately; bit3 latches ~200-300 ms after the transaction |
| Sticky | no (just a bool return) | REG_ERROR last-write; bit3 sticky W1C |
| Typical path | An argument outside the allowed bounds (channel ∉ {0,1,2}, code ∉ the characterized set, sensor_class out-of-enum) | A raw register write the library does not pre-validate (low-level use of writeReg() with an invalid mask) |
Channel A — client-side validation (library setters)
All library setters (setSensorClass, setCTModel,
setCTModel(ch, code), configureChannels, etc.) first check the
arguments against the allowed values. If an argument is out of
range, the setter returns false and sets
lastError() == RB_ERR_PARAM, without performing any I²C
transaction at all. Accordingly, REG_ERROR on the device is
not touched:
if (!dev.setCTModel(/*ch=*/99, /*code=*/3)) { // client-side guard
Serial.print(F("rejected: "));
Serial.println(dev.lastError()); // RB_ERR_PARAM (lib cached)
uint8_t reg_err;
dev.readLastError(reg_err); // device REG_ERROR byte
Serial.println(reg_err, HEX); // 0x00 — REG_ERROR untouched
}
lastError()vsreadLastError()— two different channels, not synonyms.lastError()returns the lib-side cachedRB_ERR_*code from the previous call — with no I²C operation.readLastError(out)does a single wire read of the device'sREG_ERROR (0x02)register — the last-write outcome from the firmware side. In Channel A (above),lastError()=RB_ERR_PARAMwhilereadLastError()=0x00(the lib never reached the wire — REG_ERROR untouched). In Channel B (below) both will show a code, but differently:lastError()— the last cached lib semantics;readLastError()— the exact value of the REG_ERROR byte right now.
This means: if you want to know exactly why a setter refused, the
reliable source is the function's return code + lastError().
REG_ERROR on the device will tell you nothing in this scenario.
Channel B — DUT-side rejection (durable async)
If the write went through I²C (the lib did not stop it because the argument looked valid) but the firmware rejected it, the second channel kicks in:
readLastError(out)immediately holds the reason code (for example,0xFE = DEV_ERR_PARAM). This is the last-write outcome: any next successful write will overwrite it. You must read it immediately after your write, before any other transaction.hasError(out)— the sticky W1C indicatorEVENT_FLAGS.bit3. It does not latch instantly: on the bench a ~200-300 ms delay was measured between the failed transaction and bit3 being set. Right after the write bit3 is stillfalse; after 300 ms it istrue. After that,bit3holds across any number of reads.
⚠ Do not use
hasError()to validate a write you just made. For the outcome of this specific write onlyreadLastError()is reliable, read immediately after the write before any other transaction (plus the setter's return code, if it has one).hasError()is an async channel for polling-style monitoring of "did anything fault between two checks", not for synchronous validation.
Clearing a durable error — clear both (REG_ERROR and bit3)
bit3 is picked up by the firmware every ~200-300 ms from the
current state of REG_ERROR. So a plain clearEventFlags(1 << 3)
will not work — bit3 will immediately be re-set from the
not-yet-cleared REG_ERROR. The correct order: first clearError()
(clears REG_ERROR), then clearEventFlags(1 << 3) (clears the
sticky latch). If you want atomicity — there must be no rejected
write between them.
bool readLastError(uint8_t& out) noexcept
Reads the device's REG_ERROR (0x02) register directly (a single
wire read) — the outcome of the last transaction successfully
accepted by the bus (a write or a command). Not sticky: any next
successful write overwrites it with its own outcome. Used in Channel
B (DUT-side rejection) for the extended reason code.
Not to be confused with
lastError(): that is a different method, which returns the library-cachedRB_ERR_*from the previous lib call with no I²C operation. See the callout in Channel A above.
bool hasError(bool& out) noexcept
A convenience helper over EVENT_FLAGS.bit3. Returns true in out
if bit3 is set. Sticky W1C; not cleared until an explicit clear (with
a caveat — see the "Clearing a durable error" section above).
bool readEventFlags(uint8_t& out) noexcept / bool clearEventFlags(uint8_t mask) noexcept
EVENT_FLAGS (0x2A) — a byte of sticky flags; the specific bits are
described in the specification (including ERROR_OCCURRED bit3).
Clear is write-1-to-clear: write a mask with ones in the bits you
want to reset.
bool clearError() noexcept
Issues CMD_CLEAR_ERROR (opcode 0x31) — zeros REG_ERROR.
Required to clear a durable error paired with
clearEventFlags(1 << 3) (see above). Without clearError(), a
simple clearEventFlags(1 << 3) will leave bit3 true after
~200-300 ms because of re-latch.
Multi-channel configuration (v1.3)
On multi-channel modules (I2, I3) each current channel has its
own CT sensor model. The atomic utility for bulk configuration is
configureChannels().
bool configureChannels(RbAmpSensorClass cls, const uint8_t* models, uint8_t n) noexcept
Parameters:
- cls — the sensor class, applied to all channels (on v1.3
bench-validated RbAmpSensorClass::Sct013; Wired/Builtin
reserved).
- models — an array of n CT-model codes, one per channel.
- n — the array length. Variant-clamped: if n is greater
than the number of channels on this variant (for example, n=3 on
I2), the function clamps to the actual channel count; extra array
elements are ignored. If n is smaller — the missing channels are
left untouched (they keep their previous model).
Internally the function performs:
setSensorClass(cls)once.- For each channel
k < min(n, channels): stageREG_CT_MODEL = models[k]+CMD_SET_CT_MODEL_CHk. - One terminal
CMD_SAVE_USER_CONFIGat the end — that is, one flash erase + write cycle for the whole configuration, instead ofnseparate cycles. On the bench: ~1.4 s for the 1-SAVE path vs ~3.5 s for 3 separate SAVE cycles. The key point — 3× fewer wear cycles on the same flash page.
Return:
- true — the configuration was applied and saved.
- false + lastError() == RB_ERR_PARAM — an invalid cls or
models[k] (client-side validation, see "Error model" above).
- false + other codes — bus error, timeout, etc.
Validated codes for
SCT_013:{1, 2, 3, 4, 6}. Codes5(SCT-013-100) and7(SCT-013-060) are reserved-uncharacterised on v1.3 firmware →RB_ERR_PARAMpre-bus (no I²C transaction). Details — 03 · Current sensor selection.
bool readCTModelCh(uint8_t ch, uint8_t& out) noexcept
Reads the applied (verify-mirror) CT sensor model on a specific
channel from register 0x51 (ch0) / 0x52 (ch1) / 0x53 (ch2).
This is a read-back from RAM, not a confirmation of persistence —
to confirm a flash save use dev.reset() + a re-read of the mirror.
RbAmpFleet — managing multiple modules
The RbAmpFleet class is the main high-level surface of the v1.3
library. It manages a collection of modules on a shared I²C bus:
discovery, provisioning, poll aggregation, error aggregation, and
synchronous latches via General-Call broadcast.
One fleet is bound to one TwoWire bus (usually Wire); each
adopted module is an owned RbAmp handle. The soft cap on the number
of modules is RBAMP_FLEET_MAX_MODULES = 16.
Lifecycle
RbAmpFleet(TwoWire& bus);
~RbAmpFleet(); // releases all adopted handlesAfter ~RbAmpFleet(), any handle pointers obtained via get(idx)
are invalid.
Discovery — scan()
bool scan(bool match_product, size_t& added);Probes the bus over the range 0x08..0x77, and for each ACKing
address:
- Checks identity (
PRODUCT_ID,HW_VARIANT,CAPABILITY). Ifmatch_product = true, it requires a match with the rbAmp PRODUCT_ID. - Runs a conflict detector on two independent signals
(identity-consistency + live-value). Suspicious addresses go into
the
excludedlist, not into the fleet. - Performs a Tier-2 wedge canary: it probes an address where there
should provably be nobody. If the canary gets an ACK, it returns
false— the bus is wedged, and the fleet stays empty (see the diagnostics in ch. 10).
After success, added holds the number of modules added to the
fleet. excludedCount() (see below) is available via a separate
getter.
Module access
size_t count() const noexcept;
RbAmp* get(size_t index) noexcept;
RbAmp* find(uint8_t address) noexcept;
bool add(RbAmp* dev) noexcept;
size_t excludedCount() const noexcept;
uint8_t excludedAddr(size_t i) const noexcept;get returns nullptr on out-of-range; find returns nullptr if
the address is not in the fleet (for example, because it ended up
excluded). add lets you manually add a handle created separately
(for example, after manual provisioning).
Polling — pollAll()
bool pollAll(RbAmpSnapshot* snaps,
RbAmpFleetPoll* status,
size_t capacity,
size_t& n_ok);Polls all modules in the fleet in a single call. For each module
it performs a full RT read (readAll()), attaching the result to the
corresponding snaps[i] element. In parallel, status[i] receives
the per-module result (ok=true/false + addr + channels).
After return, n_ok is the number of modules for which a valid
snapshot was obtained.
MISS-resilient: one silent module does not cancel the poll of the rest. This matters for a production fleet — losing one module (a broken wire, a burnout) must not knock out the entire monitoring.
Aggregation
bool totalPower(float& out_w) noexcept;
bool totalEnergyWh(double& out_wh) noexcept;
bool pollErrors(uint32_t& error_mask, size_t& n_errors) noexcept;totalPower sums the active power across all channels of all
modules. On I variants there is no active power → they contribute
0. In the canonical deployment (UI1 mains + I2/I3 sub-meters) the
result is the active power of the mains module.
totalEnergyWh likewise sums the Wh accumulators of the handles. It
too works correctly: I variants give 0, UI1 gives billing energy.
pollErrors iterates over the fleet, calling hasError() on each
module; error_mask is a bitmask of the EVENT bit3-flagged modules
by their position (meaningful for the first 32 modules); n_errors
is the total number of modules with the error flag at the moment of
the poll.
Addressing and conflict checking
bool assignAddress(RbAmp* dev, uint8_t new_addr);
bool checkConflict(uint8_t addr, bool& collision) noexcept;assignAddress re-addresses an existing module in the fleet to a new
address: internally — a two-phase commit + reset; the handle in the
fleet is updated automatically (subsequent calls keep working).
checkConflict — a best-effort read-only check for a specific
address: it reads identity (PRODUCT_ID / HW_VARIANT /
CAPABILITY) and compares it with the fleet's cached state. If an
inconsistency is detected — collision = true. To walk all known
addresses, iterate over the fleet:
for (size_t i = 0; i < fleet.count(); ++i) +
checkConflict(fleet.get(i)->address(), col). It does not
guarantee catching identical modules — see provisioning discipline.
Provisioning — provision() (static)
static bool provision(TwoWire& bus,
uint8_t desired_addr,
bool save_config,
RbAmp*& out_dev);Takes a TwoWire& (usually Wire) and out_dev as an out-param. On
success out_dev belongs to the caller — typically you pass it
straight into fleet.add(out_dev):
Provisions exactly one virgin module (at the factory 0x50) to
the specified address. Internally:
- Checks that there is a response at
0x50. - Checks that there is nobody yet at
desired_addr. - Performs the two-phase address commit.
- Resets the module and waits for it to appear at
desired_addrwithin the boot window. - Creates the handle; if
save_config = true— callssaveUserConfig()(persist to flash). - Returns the ready handle via
out.
⚠ MUST: exactly one virgin on the bus at the moment of the call. Two modules at
0x50at the same time are physically indistinguishable over I²C — they cannot be told apart even with special hardware. The guarantee rests on the operator, not the library. For recovery see 10 · Troubleshooting, the "Fleet: provisioning fail" section.
Fleet GC sync — synchronous snapshots across the whole fleet
bool enableGcAll(uint8_t group, size_t& ok_count);
bool gcLatch(uint8_t group, uint16_t tick, uint32_t settle_ms);
bool checkSync(uint16_t expected_tick,
RbAmpFleetSync* status,
size_t status_cap,
size_t& n_missed);For billing-grade snapshot synchronicity (skew < 1 ms across the whole fleet) an I²C General-Call broadcast latch is used.
enableGcAllsetsFLEET_CONFIG.bit0+GROUP_IDon all fleet modules and issuesCMD_SAVE_USER_CONFIGon each (persistent). A reboot of the modules is mandatory after enable — this is done automatically.ok_countis the number of modules for which enable succeeded.gcLatchsends the GC frameA5 27 group tick_lo tick_hito address0x00— all modules with GC enabled and a matchinggroupinstantly performLATCH_PERIOD.settle_msis the pause after the frame (typically 50 ms) before verification.checkSyncreadsGC_TICK (0x59)on each module intoRbAmpFleetSync status[status_cap]. The per-module result:
cpp
struct RbAmpFleetSync {
uint8_t addr; // I²C address of the module
uint16_t gc_tick; // REG_GC_TICK; 0xFFFF = never received a GC
bool in_sync; // true if gc_tick == expected_tick
bool reachable; // false if the read NACKed / timed out
};
n_missed is the number of modules with in_sync = false (or
simply not responding). If all are in_sync, n_missed == 0.
Per-device fleet config (v1.3)
These methods work with a single module directly (without a fleet) — for cases where you configure an individual module before adding it to a fleet, or want fine-grained control.
bool enableGc(bool enable) noexcept / bool readFleetConfig(uint8_t& out) noexcept
enableGc(true) writes 1 to FLEET_CONFIG.bit0 (0x27) + saves to
flash + reset — the module will start accepting GC latches after
boot. enableGc(false) clears the bit symmetrically. Blocks for
~1 second (SAVE_USER_CONFIG settle + reset settle). readFleetConfig
returns the current config byte — useful for read-back confirmation.
bool setGroupId(uint8_t group) noexcept / bool readGroupId(uint8_t& out) noexcept
GROUP_ID (0x28) — the GC-frame filter: the module reacts only to GC
frames with a matching group_id. Default = 0x00 (broadcast, reacts
to any). Used for multi-tenant buses — for example, a "phase A" group
and a "phase B" group on one shared bus.
bool readGcTick(uint16_t& out) noexcept
Reads GC_TICK (0x59) — the last tick value received from a GC
frame. Used for missed-frame detection (if two modules have different
ticks, someone missed a frame). 0xFFFF = the module has never
received a GC frame since boot.
static bool broadcastLatchGroup(TwoWire& bus, uint8_t group, uint16_t tick) noexcept
A low-level send of the GC frame A5 27 group tick_lo tick_hi
directly via bus (without a fleet). Useful if the fleet is not yet
created, or if you want manual control over the tick counter.
uint8_t readActiveAddress() noexcept
Reads REG_I2C_ADDRESS (0x30) — the module's active (running)
address. On v1.3, at boot it reads the active address, not a
staged candidate. Useful for read-back confirmation after
commitAddressChange().
RbAmpEnergy class
A per-channel Wh accumulator. Owned by RbAmp, accessed via
dev.energy(). The user normally does not create it directly.
Public methods
double wh(uint8_t ch = 0) const noexcept;The current Wh total on a channel. Signed — a negative value means
net export. If ch is out of range, returns 0.0.
void reset(uint8_t ch = 0) noexcept;
void resetAll() noexcept;Zero one channel or all channels.
void disable() noexcept;
void enable() noexcept;
bool isEnabled() const noexcept;Enable/disable automatic integration. While integration is disabled,
readPeriodSnapshot() keeps working but does not tick the
accumulator — useful if the master keeps its own persistent Wh
storage.
The internal tick()
void tick(const RbAmpPeriodSnapshot& snap, uint8_t channels) noexcept;Called automatically from RbAmp::readPeriodSnapshot() on success.
User code does not call it directly. Idempotent: snapshots with
valid == false are ignored.
The integration formula:
wh[ch] += snap.avg_p[ch] × snap.master_dt_ms / 1000 / 3600
[W] [milliseconds] → [Wh]Accuracy
Platform-dependent — for details see 04 · Hardware:
- ESP32 / ESP8266 / STM32duino / SAMD / RP2040: a 64-bit
doubleaccumulator. Drift < 1 LSB / year when polling once every 60 s. - Classic Arduino AVR (Uno, Mega, Nano): the toolchain defines
doubleas a 32-bitfloat. For long-running soak installations, periodically reset the accumulator (for example, once a day).
POD structs
RbAmpSnapshot
Returned by readAll(). All fields in SI units. Unused channels
(beyond channels) are filled with zeros.
struct RbAmpSnapshot {
float voltage; // V — RMS voltage
float voltage_peak; // V — peak voltage
float current[3]; // A — RMS current per channel
float current_peak[3]; // A — peak current per channel
float power[3]; // W — active power per channel (signed)
float power_factor[3]; // dimensionless, −1..+1
float frequency; // Hz — grid frequency
RbAmpTopology topology; // SINGLE / SPLIT_PHASE / THREE_PHASE
uint8_t channels; // 1..3 — number of valid channels
bool has_voltage_hw; // true if a voltage sensor is present
};RbAmpPeriodSnapshot
Returned by readPeriodSnapshot(). The energy-metering primitive.
struct RbAmpPeriodSnapshot {
float avg_p[3]; // W — average active power over the period
float max_p; // W — peak instantaneous power on channel 0
uint32_t latch_ms; // ms — period duration (device's view, diagnostic)
uint32_t master_dt_ms; // ms — master wall-clock dt since the last latch
bool valid; // true if the latch ready flag was set
};For energy integration use
master_dt_ms, notlatch_ms— the module's internal timer has limited accuracy.
RbAmpTopology
enum class RbAmpTopology : uint8_t {
Single = 1, // 1 current channel (UI1 / I1)
SplitPhase = 2, // 2 current channels (UI2 / I2)
ThreePhase = 3, // 3 current channels (UI3 / I3)
};RbAmpSensorClass
enum class RbAmpSensorClass : uint8_t {
Unset = 0, // Factory default; setCTModel() will refuse
Sct013 = 1, // SCT-013 series (the only shipping class)
WiredCT = 2, // Reserved — general-purpose wired CT
BuiltinCT = 3, // Reserved — built-in on-board CT
};Only Unset and Sct013 are meaningful for the current firmware.
The reserved values are present in the header file for compatibility
with future SKUs — for now, passing them to setSensorClass() is not
supported.
Error codes (namespace rbamp)
Defined in RbAmpRegisters.h, returned from lastError().
namespace rbamp {
constexpr int8_t RB_OK = 0;
constexpr int8_t RB_ERR_IO = -1; // I²C transport error
constexpr int8_t RB_ERR_NACK = -2; // retry exhausted, slave not responding
constexpr int8_t RB_ERR_TIMEOUT = -3; // timeout (waitReady / commit-addr window expired)
constexpr int8_t RB_ERR_NOT_READY = -4; // (reserved)
constexpr int8_t RB_ERR_STALE = -5; // period snapshot not ready
constexpr int8_t RB_ERR_PARAM = -6; // invalid channel / address / precondition
constexpr int8_t RB_ERR_MODE = -7; // operation requires develop mode
constexpr int8_t RB_ERR_CHECKSUM = -8; // (reserved)
constexpr int8_t RB_ERR_VERSION = -9; // REG_VERSION returned 0 or 0xFF
constexpr int8_t RB_ERR_NOT_IMPLEMENTED = -10; // (reserved for future firmware versions)
constexpr int8_t RB_ERR_NON_PHYSICAL = -11; // float rejected by the sanity filter
}Diagnostic recipes for each code — 10 · Troubleshooting.
Compile-time configuration
Define BEFORE #include <RbAmp.h> to override the defaults:
#define RBAMP_NACK_RETRY_ATTEMPTS 5 // number of retry attempts per byte
#define RBAMP_NACK_RETRY_GAP_MS 5 // pause between attempts, ms
#include <RbAmp.h>Platform defaults:
#if defined(ARDUINO_ARCH_ESP32) || defined(ESP_PLATFORM)
# define RBAMP_NACK_RETRY_ATTEMPTS 3
#else
# define RBAMP_NACK_RETRY_ATTEMPTS 1
#endif
#define RBAMP_NACK_RETRY_GAP_MS 5For more on the resilience subsystem — 10 · Troubleshooting.
Settle-time constants
Defined in RbAmpRegisters.h. The library uses them internally; they
are exported for users who want to honor the same timing during raw
bus access:
constexpr uint16_t SETTLE_MS_LATCH_PERIOD = 50; // after CMD_LATCH_PERIOD
constexpr uint16_t SETTLE_MS_SAVE_GAINS = 700; // after CMD_SAVE_GAINS (flash write)
constexpr uint16_t SETTLE_MS_RESET = 300; // after CMD_RESET (root canon)
constexpr uint16_t SETTLE_MS_FACTORY_RESET = 1500; // after CMD_FACTORY_RESETWire-protocol details
Register reads — auto-increment
A burst read <addr> + N bytes returns N consecutive bytes starting at addr. Library methods use bursts where possible (one I²C transaction = 4 bytes of float).
Register writes — byte-at-a-time (NOT auto-increment)
⚠ Writes do NOT auto-increment. A burst WRITE <addr> + N bytes lands only the first byte — bytes 1..N are dropped.
A multi-byte register is written one byte at a time — a separate single-byte I²C transaction for each address:
// WRONG — data loss
uint8_t label[8] = "MyMeter1";
Wire.beginTransmission(addr);
Wire.write(0x68); // start of LABEL
Wire.write(label, 8); // burst — only 'M' lands
Wire.endTransmission();
// CORRECT — byte-at-a-time loop
for (int i = 0; i < 8; i++) {
Wire.beginTransmission(addr);
Wire.write(0x68 + i);
Wire.write(label[i]);
Wire.endTransmission();
}Affects writable multi-byte registers: LABEL (0x68-0x6F), U_GAIN/I_GAIN (f32, develop-gated), NF (u16), THRESH (u16). Reads — burst OK.
Exception: the GC broadcast frame (5 bytes A5 27 g tl th to addr 0x00) is a separate wire-protocol path (general-call reception, not a register write), sent as a single transaction. See scenario 12 in 06 · Examples.
Reading unmapped registers
Any read from an unmapped address returns 0x00. The firmware
never NACKs on register reads. Device-absence detection is via
the I²C address-frame ACK, not via probing registers.
Variant detection
The authoritative SKU byte is REG_HW_VARIANT (0x55) (the
hwVariant() method):
| value | SKU |
|---|---|
0x01 |
UI1 (1× I + U) |
0x02 |
UI2 (2× I + U) |
0x03 |
UI3 (3× I + U) |
0x04 |
I1 (1× I, no U) |
0x05 |
I2 (2× I, no U) |
0x06 |
I3 (3× I, no U) |
REG_TOPOLOGY (0x24) is auxiliary (only the channel count, it does not distinguish the presence of U).
General-Call broadcast latch (multi-module synchronization)
Opt-in per-module via REG_FLEET_CONFIG.bit0 — GC_ENABLE. Default OFF. Enabled via CMD_SAVE_USER_CONFIG + CMD_RESET (soft or hard). After reset the module listens for a broadcast frame at addr 0x00:
addr=0x00 | A5 27 <group> <tick_lo> <tick_hi>A5— magic;27— opcodeCMD_LATCH_PERIOD.<group>—0x00= all-call, otherwise it must matchREG_GROUP_ID (0x28).<tick_lo/hi>— the 16-bit master-sync period counter.
The frame only latches; destructive opcodes are not transmitted over GC.
Failure mode detection (two-tier, C.10 HW-confirmed):
- GC-address NACK (immediately at the bus level): if GC is disabled on the whole fleet —
Wire.endTransmission()to0x00returns a non-zero code (NACK). This is a hard error, not a silent drop. The lib treats it as "GC not enabled" (requires the enable+reset preconditions). - Per-module witness (after settle): if no NACK occurred — read
REG_V03_PERIOD_VALID (0x07)on each expected module.1=latch succeeded;0=GC disabled on this module or the period is empty;!=1→ fall back to a per-device latch.
References
- 05 · Quickstart — your first working sketch
- 06 · Examples — working scenarios
- 10 · Troubleshooting — decoding errors and resolving common problems
- Source file with Doxygen comments:
src/RbAmp.h