-
Notifications
You must be signed in to change notification settings - Fork 0
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 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 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.
// 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);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.
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);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());
}
}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 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 |
byte[] livePoll = MpptProtocol.PollLive();
byte[] settingsPoll = MpptProtocol.PollSettings();These are equivalent to calling ModbusFrame.Read with the constants above and slave = 0x01.
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 converts raw register arrays into typed model records.
// 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.
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;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());| 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.
⚡ OpenMPPT Wiki · Repository · GPL-3.0-or-later · contributions welcome