Skip to content

The Modbus Codec

tsgb edited this page Jun 4, 2026 · 2 revisions

The Modbus Codec

The OpenMppt.Codecs.Modbus namespace contains a pure Modbus-RTU codec — no I/O, no hardware dependency, fully unit-tested. It is the foundation for every Modbus-based device driver in OpenMPPT and can be used independently against any byte source (BLE notification stream, serial port, Modbus-TCP wrapper, or test data).


ModbusCrc

ModbusCrc implements the standard CRC-16/MODBUS algorithm:

Parameter Value
Polynomial 0xA001 (reflected)
Initial value 0xFFFF
Byte order Low byte first
// Compute a 2-byte CRC (index 0 = low byte, index 1 = high byte)
byte[] crc = ModbusCrc.Compute(frameWithoutCrc);

// Verify a complete frame (returns void; throws or returns silently — check your usage)
ModbusCrc.Verify(fullFrameIncludingCrc);

Compute returns a two-element array where [0] is the low byte and [1] is the high byte, matching the wire order required by Modbus RTU.


ModbusFrame

ModbusFrame is a static class that builds and parses raw Modbus RTU frames. Three function codes are supported:

Constant Value Used for
FnRead 0x03 Read Holding Registers
FnReadInput 0x04 Read Input Registers (EPEver)
FnWrite 0x10 Write Multiple Registers

The default slave address is DefaultSlave = 0x01, which matches the generic Chinese MPPT controllers this library targets. Renogy and SRNE devices use slave 0xFF.

Building frames

// Poll 16 live registers from slave 0x01 (default)
byte[] request = ModbusFrame.Read(address: 0x0001, quantity: 16);

// Poll live registers from a Renogy device (slave 0xFF)
byte[] rRequest = ModbusFrame.Read(0x0001, 16, slave: 0xFF);

// EPEver uses function code 0x04 (Read Input Registers)
byte[] epRequest = ModbusFrame.Read(0x0001, 16, slave: 0x01, function: ModbusFrame.FnReadInput);

// Write a single value to a holding register
byte[] write = ModbusFrame.WriteSingle(address: MpptProtocol.Reg.OutputMode, value: 1);

Parsing responses

Parse returns a Response?null if the frame is malformed or does not match the expected slave address.

byte[] rawResponse = /* bytes received from transport */;

ModbusFrame.Response? resp = ModbusFrame.Parse(rawResponse, slave: 0x01);
if (resp is not null)
{
    int[] regs = resp.Registers();
    // regs.Length == quantity requested
}

Response carries the raw Payload bytes and exposes FunctionCode, so callers can inspect which function produced the reply.


ModbusReassembler

Real transports — BLE GATT notifications, serial reads, TCP segments — do not respect Modbus frame boundaries. A single poll-and-response exchange may arrive as multiple small chunks, or multiple frames may be concatenated into one delivery.

ModbusReassembler solves this: it accepts an arbitrary byte stream via Push and emits only whole, CRC-valid frames.

// Slave 0x01, holding-register reads (default)
var reassembler = new ModbusReassembler();

// For EPEver (slave 0x01, but uses function 0x04)
var epReassembler = new ModbusReassembler(slave: 0x01, readInputFunction: 0x04);

// For Renogy/SRNE (slave 0xFF)
var rReassembler = new ModbusReassembler(slave: 0xFF);

Feeding bytes

void OnBytesReceived(ReadOnlyMemory<byte> chunk)
{
    IReadOnlyList<byte[]> frames = reassembler.Push(chunk.Span);
    foreach (byte[] frame in frames)
    {
        ModbusFrame.Response? resp = ModbusFrame.Parse(frame, slave: 0x01);
        if (resp is not null)
            ProcessRegisters(resp.Registers());
    }
}

Resync behaviour

When the reassembler accumulates bytes that do not form a valid frame — bad CRC or unrecognised function code — it drops the leading byte and retries. This lets it recover from partial captures, missed writes, or transport glitches without requiring a full reconnect.

Reset() clears the internal buffer; call it when the transport reconnects or when you know the stream has a gap.


MpptProtocol

MpptProtocol encodes the generic Chinese MPPT register map that most white-label charge controllers expose. All addresses and quantities are constants; use them rather than hard-coding numbers.

Constant Value Description
LiveAddress 0x0001 First live-telemetry register
LiveQuantity 0x0010 (16) Number of live registers
SettingsAddress 0x1001 First settings register
SettingsQuantity 0x000F (15) Number of settings registers

Pre-built poll frames

byte[] livePoll     = MpptProtocol.PollLive();
byte[] settingsPoll = MpptProtocol.PollSettings();

These are equivalent to calling ModbusFrame.Read with the constants above and slave = 0x01.

Writable register addresses

The nested Reg class lists every writable register address:

Member Address
Reg.BatteryType 0x1001
Reg.TimerHour 0x1002
Reg.TimerMinute 0x1003
Reg.ChargeVoltageSetpoint 0x1004
Reg.OutputMode 0x1005
Reg.CutoffVoltageSetpoint 0x1006
Reg.ManualLoadOn 0x1007
Reg.VoltageMonitorMode 0x1008
Reg.RecoveryVoltageSetpoint 0x1009

Writing a register:

// Turn the load on manually
byte[] frame = MpptProtocol.WriteRegister(MpptProtocol.Reg.ManualLoadOn, value: 1);
await transport.WriteAsync(frame);

MpptRegisters

MpptRegisters converts raw register arrays into typed model records.

DecodeLive

// Build a BatteryProfile for your pack, then pass its SocFromVoltage delegate
var profile = new BatteryProfile(EmptyV: 20.0, FullV: 25.5, NominalV: 22.1, CapacityAh: 105);

// After receiving and parsing a live poll response:
int[] liveRegs = resp.Registers();   // length must be LiveQuantity (16)
MpptLive live = MpptRegisters.DecodeLive(
    liveRegs,
    timestampMs: DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
    socFromVoltage: profile.SocFromVoltage);

Console.WriteLine($"Battery: {live.BatteryVoltage:F2} V  SoC: {live.SocEstimate:F0}%");
Console.WriteLine($"Charger: {live.ChargerState.Label()}");

If no BatteryProfile is available, pass MpptLive.EstimateSoc as the delegate; it uses a 12 V lead-acid fallback table.

DecodeSettings

int[] settingsRegs = resp.Registers();   // length must be SettingsQuantity (15)
MpptSettings settings = MpptRegisters.DecodeSettings(settingsRegs);

Console.WriteLine($"Charge setpoint : {settings.ChargeVoltageSetpoint} V");
Console.WriteLine($"Output mode     : {settings.OutputModeEnum}");

// Use settings to build a voltage→SoC mapper
double soc = settings.ComputeSoc(live.BatteryVoltage) ?? 0.0;

End-to-end example

The following sketch shows a complete poll-and-decode cycle. The transport (IFrameTransport) is supplied by the host application.

var reassembler = new ModbusReassembler();  // slave 0x01

// Wire up incoming bytes
transport.BytesReceived += chunk =>
{
    foreach (byte[] frame in reassembler.Push(chunk.Span))
    {
        var resp = ModbusFrame.Parse(frame);
        if (resp is null) return;

        if (resp.FunctionCode == ModbusFrame.FnRead)
        {
            int[] regs = resp.Registers();

            if (regs.Length == MpptProtocol.LiveQuantity)
            {
                MpptLive live = MpptRegisters.DecodeLive(
                    regs,
                    DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
                    profile.SocFromVoltage);
                OnLiveUpdated(live);
            }
            else if (regs.Length == MpptProtocol.SettingsQuantity)
            {
                MpptSettings settings = MpptRegisters.DecodeSettings(regs);
                OnSettingsUpdated(settings);
            }
        }
    }
};

// Send a live poll
await transport.WriteAsync(MpptProtocol.PollLive());

Slave address reference

Controller family Slave address
Generic Chinese MPPT (default) 0x01
Renogy / SRNE 0xFF
EPEver 0x01 (but use FnReadInput = 0x04)

Pass the correct slave to both ModbusFrame.Read / Parse and to ModbusReassembler's constructor so CRC-valid frames addressed to other devices are not accidentally accepted.


See also

Clone this wiki locally