Everything the register map carries, sorted the way it gets asked about

The Modbus snapshot reported about half of what one poll already brings back.
The rest was read into the block and thrown away: line-to-line voltages,
reactive and apparent power per phase, the PWM flag, the control-pilot voltage,
and the identity block's product number, rated power and current range. All of
it now decodes — no extra requests, the registers were in hand already.

Added alongside it: the control block, read back over FC03. It answers a
question the live registers cannot, which is what the charger is *set* to as
opposed to what it is doing — a boost that was asked for reads there while the
live block still reports none running. Best effort, so a charger that refuses
it still reports its state.

Two registers the spec leaves blank are decoded on the hardware's evidence. The
control-pilot voltage reads 11873 while the CP signal register reports state A,
which that enum names as 12 V, so the register is millivolts. The identity
block's current range is in amps, whatever its unit column says about watts and
kVA.

The charging card lays this out in sections rather than a wall of forty numbers:
per-phase measurements as the matrix they are, then live state, then settings,
then the device itself, with alarms surfacing only when a word is non-zero.
Strings in en/da/pl.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5 committed 2026-09-01 19:37:22 +02:00
1 parent aaa89dfe10
commit 62cb691f98
6 files changed
+615 -19

No files matched your search

@@ -190,6 +190,101 @@ func TestDecodeIdentityReadsStrings(t *testing.T) {
}
}
func TestDecodeIdentityReadsRatings(t *testing.T) {
regs := make([]uint16, identityCount)
put32 := func(addr int, v uint32) {
regs[addr-identityStart] = uint16(v >> 16)
regs[addr-identityStart+1] = uint16(v)
}
regs[regProductNumber-identityStart] = 7
put32(regRatedPower, 7400)
put32(regMinOutCurrent, 6)
put32(regMaxOutCurrent, 32)
var snap ModbusSnapshot
decodeIdentity(&snap, regs)
for _, c := range []struct {
name string
got *int
want int
}{
{"ProductNumber", snap.ProductNumber, 7},
{"RatedPowerW", snap.RatedPowerW, 7400},
{"MinCurrentA", snap.MinCurrentA, 6},
{"MaxCurrentA", snap.MaxCurrentA, 32},
} {
if c.got == nil || *c.got != c.want {
t.Errorf("%s = %v, want %d", c.name, c.got, c.want)
}
}
}
// The control block says what the charger is set to, which is a different
// question from what it is doing: a boost that was asked for reads here even
// while the live block reports no boost running.
func TestDecodeSettingsReadsTheControlBlock(t *testing.T) {
regs := make([]uint16, settingsCount)
regs[regChargingCommand-settingsStart] = 1
regs[regMaxCurrentSet-settingsStart] = 160 // 16.0 A, gain 10
regs[regBoostSet-settingsStart] = 1
regs[regTimeoutSet-settingsStart] = 120
regs[regPhaseCountSet-settingsStart] = 2
var snap ModbusSnapshot
decodeSettings(&snap, regs)
if snap.Settings == nil {
t.Fatal("Settings is nil")
}
got := snap.Settings
if got.LastCommand == nil || *got.LastCommand != 1 {
t.Errorf("LastCommand = %v, want 1", got.LastCommand)
}
if got.MaxCurrentA == nil || *got.MaxCurrentA != 16 {
t.Errorf("MaxCurrentA = %v, want 16", got.MaxCurrentA)
}
if got.Boost == nil || !*got.Boost {
t.Errorf("Boost = %v, want true", got.Boost)
}
if got.TimeoutSeconds == nil || *got.TimeoutSeconds != 120 {
t.Errorf("TimeoutSeconds = %v, want 120", got.TimeoutSeconds)
}
if got.PhaseSetting == nil || *got.PhaseSetting != 2 || got.PhaseDesc != "fixed three-phase" {
t.Errorf("PhaseSetting = %v (%q), want 2 (fixed three-phase)", got.PhaseSetting, got.PhaseDesc)
}
}
// The registers below were read into the live block all along but never
// decoded, so the block's own alignment is what these guard.
func TestDecodeLiveReadsLineAndReactivePower(t *testing.T) {
b := newLiveBlock()
b.set(regVoltageL1L2, 3809) // 380.9 V, gain 10
b.set32(regReactiveL1, 120)
b.set32(regApparentL1+4, 340)
b.set(regPWMEnabled, 1)
b.set(regCPVoltage, 11873) // 11.873 V, millivolts
var snap ModbusSnapshot
decodeLive(&snap, b)
if snap.VoltageL1L2 == nil || *snap.VoltageL1L2 != 380.9 {
t.Errorf("VoltageL1L2 = %v, want 380.9", snap.VoltageL1L2)
}
if snap.ReactiveL1 == nil || *snap.ReactiveL1 != 120 {
t.Errorf("ReactiveL1 = %v, want 120", snap.ReactiveL1)
}
if snap.ApparentL3 == nil || *snap.ApparentL3 != 340 {
t.Errorf("ApparentL3 = %v, want 340", snap.ApparentL3)
}
if snap.PWMEnabled == nil || !*snap.PWMEnabled {
t.Errorf("PWMEnabled = %v, want true", snap.PWMEnabled)
}
if snap.CPVoltage == nil || *snap.CPVoltage != 11.873 {
t.Errorf("CPVoltage = %v, want 11.873", snap.CPVoltage)
}
}
// Below 6 A the charger stops instead of charging slowly, so a limit in that
// range has to be refused rather than quietly turned into a pause.
func TestModbusSetMaxCurrentRefusesBelowFloor(t *testing.T) {