From c32ec2e325f6846a51fbd92d2ce087741f40994d Mon Sep 17 00:00:00 2001 From: premultiply <4681172+premultiply@users.noreply.github.com> Date: Sun, 2 Aug 2026 09:50:34 +0200 Subject: [PATCH] Alpitronic HYC: fix status mapping and bundle register reads (#32260) --- charger/alpitronic.go | 218 ++++++++++++++++------ charger/alpitronic_test.go | 362 +++++++++++++++++++++++++++++++++++++ 2 files changed, 528 insertions(+), 52 deletions(-) create mode 100644 charger/alpitronic_test.go diff --git a/charger/alpitronic.go b/charger/alpitronic.go index 6002d8b1a..5e7169aa4 100644 --- a/charger/alpitronic.go +++ b/charger/alpitronic.go @@ -20,6 +20,7 @@ package charger import ( "context" "encoding/hex" + "errors" "fmt" "time" @@ -32,25 +33,82 @@ import ( // AlpitronicHYC charger implementation type AlpitronicHYC struct { - log *util.Logger conn *modbus.Connection + inputG func() ([]byte, error) curr float64 connector uint16 } +// input registers of the charging station (connector 0) +// +//nolint:unused // complete register map as documented by the vendor const ( - // Input - hycRegState = 0 - hycRegChargingPower = 4 - hycRegChargeTime = 6 - hycRegChargedEnergy = 7 - hycRegSoC = 8 - hycRegVID = 18 - hycRegIdTag = 22 - hycRegTotalChargedEnergy = 32 + hycRegStationTime = 0 // UINT32, s + hycRegStationNumConnectors = 2 // UINT16 + hycRegStationState = 3 // UINT16, 0-Available, 8-Unavailable, 10-Faulted + hycRegStationPowerDrained = 4 // UINT32, W + hycRegStationSerial = 6 // 24 byte string + hycRegStationLoadManagement = 18 // UINT16, bool + hycRegStationChargepointId = 30 // 32 byte string + hycRegStationVersionMajor = 46 // UINT16 + hycRegStationVersionMinor = 47 // UINT16 + hycRegStationVersionPatch = 48 // UINT16 + hycRegStationVarInductive = 49 // UINT32, var + hycRegStationVarCapacitive = 51 // UINT32, var +) - // Holding - hycRegMaxPowerAC = 0 +// input registers, relative to the connector block (1xx..4xx) +const ( + hycRegState = 0 // UINT16 + hycRegChargingVoltage = 1 // UINT32, cV + hycRegChargingCurrent = 3 // UINT16, cA + hycRegChargingPower = 4 // UINT32, W + hycRegChargeTime = 6 // UINT16, s + hycRegChargedEnergy = 7 // UINT16, kWh/100 + hycRegSoC = 8 // UINT16, %/100 + hycRegConnectorType = 9 // UINT16, 0-ChargePoint, 1-CCS2, 2-CCS1, 3-CHAdeMO, 4-CCS_AC, 5-GBT, 6-MCS, 7-NACS + hycRegMaxChargingPowerDC = 10 // UINT32, W- includes the limit written by us + hycRegMinChargingPowerDC = 12 // UINT32, W + hycRegVarInductive = 14 // UINT32, var- deprecated, use hycRegStationVarInductive + hycRegVarCapacitive = 16 // UINT32, var- deprecated, use hycRegStationVarCapacitive + hycRegVID = 18 // 8 bytes + hycRegIdTag = 22 // 20 bytes + hycRegTotalChargedEnergy = 32 // INT64, Wh + hycRegMaxChargingPowerAC = 36 // UINT32, W- includes the limit written by us + + // the connector block is gapless- read up to the last register in use (x32..x35) + hycInputLength = 36 +) + +// holding registers, relative to the connector block (0xx is the station) +const ( + hycRegMaxPowerAC = 0 // UINT32, W per connector, VA for the station + hycRegSetReactivePower = 2 // INT32, var +) + +// connector states as per register x00 +const ( + hycStateAvailable uint16 = iota + hycStatePreparingTagIdReady + hycStatePreparingEVReady + hycStateCharging + hycStateSuspendedEV + hycStateSuspendedEVSE + hycStateFinishing + hycStateReserved + hycStateUnavailable + hycStateUnavailableFwUpdate + hycStateFaulted + hycStateUnavailableConnObj +) + +// a zero power limit makes the station report the connector as unavailable and +// abort a running session, so charging is inhibited with a limit that is too low +// for any connector to actually charge- see hycRegMinChargingPowerDC +const ( + hycPowerPerAmp = 230 * 3 // W/A on the AC side + hycMinPowerAC = 1547 // W + hycMinCurrent = 2.25 // A, lowest current exceeding hycMinPowerAC ) func init() { @@ -90,19 +148,54 @@ func NewAlpitronicHYC(ctx context.Context, settings modbus.TcpSettings, connecto log := util.NewLogger("alpitronic") conn.Logger(log.TRACE) + return newAlpitronicHYC(conn, connector) +} + +// newAlpitronicHYC wires the struct without sponsor gate (also used by tests) +func newAlpitronicHYC(conn *modbus.Connection, connector uint16) (*AlpitronicHYC, error) { wb := &AlpitronicHYC{ - log: log, conn: conn, - curr: 6, + curr: hycMinCurrent, connector: connector, } + // share a single bulk read of the connector's input block across all decoders. + // the station applies a fallback when it is not polled within GridFallbackTimeout, + // so keeping the number of roundtrips low matters here. + wb.inputG = util.Cached(func() ([]byte, error) { + return wb.conn.ReadInputRegisters(wb.reg(hycRegState), hycInputLength) + }, time.Second) + + // seed the current limit from the charger- this also validates the connector + b, err := wb.conn.ReadHoldingRegisters(wb.reg(hycRegMaxPowerAC), 2) + if err != nil { + return nil, err + } + + if power := encoding.Uint32(b); power > hycMinPowerAC { + wb.curr = float64(power) / hycPowerPerAmp + } + return wb, nil } -// setCurrent writes the current limit as power -func (wb *AlpitronicHYC) setCurrent(current float64) error { - power := uint32(current * 230 * 3) +// hycInput returns the bytes of the given register within the cached input block +func hycInput(b []byte, reg uint16, n int) []byte { + off := 2 * int(reg) + return b[off : off+n] +} + +// hycPower converts a charging current into the AC side power limit +func hycPower(current float64) uint32 { + if current <= 0 { + return 0 + } + + return uint32(current * hycPowerPerAmp) +} + +// setPower writes the connector's power limit +func (wb *AlpitronicHYC) setPower(power uint32) error { b := make([]byte, 4) encoding.PutUint32(b, power) @@ -115,20 +208,42 @@ func (wb *AlpitronicHYC) reg(reg uint16) uint16 { return (wb.connector * 100) + reg } +// state returns the connector state +func (wb *AlpitronicHYC) state() (uint16, error) { + b, err := wb.inputG() + if err != nil { + return 0, err + } + + return encoding.Uint16(hycInput(b, hycRegState, 2)), nil +} + // Status implements the api.Charger interface func (wb *AlpitronicHYC) Status() (api.ChargeStatus, error) { - b, err := wb.conn.ReadInputRegisters(wb.reg(hycRegState), 1) + s, err := wb.state() if err != nil { return api.StatusNone, err } - switch s := encoding.Uint16(b); s { - case 0, 7, 8, 9, 10: + switch s { + case + hycStateAvailable, + hycStatePreparingTagIdReady, // authorized, waiting for the cable to be plugged in + hycStateReserved, + hycStateUnavailable, + hycStateUnavailableFwUpdate, + hycStateUnavailableConnObj: return api.StatusA, nil - case 1, 2, 4, 5, 6: + case + hycStatePreparingEVReady, // plugged in, waiting for authorization + hycStateSuspendedEV, + hycStateSuspendedEVSE, + hycStateFinishing: return api.StatusB, nil - case 3: + case hycStateCharging: return api.StatusC, nil + case hycStateFaulted: + return api.StatusNone, errors.New("connector state: faulted") default: return api.StatusNone, fmt.Errorf("invalid status: %d", s) } @@ -141,17 +256,17 @@ func (wb *AlpitronicHYC) Enabled() (bool, error) { return false, err } - return encoding.Uint32(b) != 0, nil + return encoding.Uint32(b) > hycMinPowerAC, nil } // Enable implements the api.Charger interface func (wb *AlpitronicHYC) Enable(enable bool) error { - var c float64 + power := uint32(hycMinPowerAC) if enable { - c = wb.curr + power = hycPower(wb.curr) } - return wb.setCurrent(c) + return wb.setPower(power) } // MaxCurrent implements the api.Charger interface @@ -162,12 +277,16 @@ func (wb *AlpitronicHYC) MaxCurrent(current int64) error { var _ api.ChargerEx = (*AlpitronicHYC)(nil) // MaxCurrentMillis implements the api.ChargerEx interface +// +// api.CurrentLimiter is deliberately not implemented: registers x10/x36 contain +// the connector power limit written by evcc itself, so using them as upper bound +// would feed back into the limit. Use the loadpoint's maxcurrent instead. func (wb *AlpitronicHYC) MaxCurrentMillis(current float64) error { - if current < 6 { + if current < hycMinCurrent { return fmt.Errorf("invalid current %.1f", current) } - err := wb.setCurrent(current) + err := wb.setPower(hycPower(current)) if err == nil { wb.curr = current } @@ -179,63 +298,63 @@ var _ api.Meter = (*AlpitronicHYC)(nil) // CurrentPower implements the api.Meter interface func (wb *AlpitronicHYC) CurrentPower() (float64, error) { - b, err := wb.conn.ReadInputRegisters(wb.reg(hycRegChargingPower), 2) + b, err := wb.inputG() if err != nil { return 0, err } - return float64(encoding.Uint32(b)), err + return float64(encoding.Uint32(hycInput(b, hycRegChargingPower, 4))), nil } var _ api.ChargeTimer = (*AlpitronicHYC)(nil) // ChargeDuration implements the api.ChargeTimer interface func (wb *AlpitronicHYC) ChargeDuration() (time.Duration, error) { - b, err := wb.conn.ReadInputRegisters(wb.reg(hycRegChargeTime), 1) + b, err := wb.inputG() if err != nil { return 0, err } - return time.Duration(encoding.Uint16(b)) * time.Second, nil + return time.Duration(encoding.Uint16(hycInput(b, hycRegChargeTime, 2))) * time.Second, nil } var _ api.ChargeRater = (*AlpitronicHYC)(nil) // ChargedEnergy implements the api.ChargeRater interface func (wb *AlpitronicHYC) ChargedEnergy() (float64, error) { - b, err := wb.conn.ReadInputRegisters(wb.reg(hycRegChargedEnergy), 1) + b, err := wb.inputG() if err != nil { return 0, err } - return float64(encoding.Uint16(b)) / 100, err + return float64(encoding.Uint16(hycInput(b, hycRegChargedEnergy, 2))) / 100, nil } var _ api.MeterEnergy = (*AlpitronicHYC)(nil) // TotalEnergy implements the api.MeterEnergy interface func (wb *AlpitronicHYC) TotalEnergy() (float64, error) { - b, err := wb.conn.ReadInputRegisters(wb.reg(hycRegTotalChargedEnergy), 4) + b, err := wb.inputG() if err != nil { return 0, err } - return float64(encoding.Int64(b)) / 1e3, err + return float64(encoding.Int64(hycInput(b, hycRegTotalChargedEnergy, 8))) / 1e3, nil } var _ api.StatusReasoner = (*AlpitronicHYC)(nil) // StatusReason implements the api.StatusReasoner interface func (wb *AlpitronicHYC) StatusReason() (api.Reason, error) { - b, err := wb.conn.ReadInputRegisters(wb.reg(hycRegState), 1) + s, err := wb.state() if err != nil { return api.ReasonUnknown, err } - switch s := encoding.Uint16(b); s { - case 1: + switch s { + case hycStatePreparingEVReady: return api.ReasonWaitingForAuthorization, nil - case 6: + case hycStateFinishing: return api.ReasonDisconnectRequired, nil default: return api.ReasonUnknown, nil @@ -246,22 +365,17 @@ var _ api.Identifier = (*AlpitronicHYC)(nil) // Identify implements the api.Identifier interface func (wb *AlpitronicHYC) Identify() (string, error) { - b, err := wb.conn.ReadInputRegisters(wb.reg(hycRegVID), 4) + b, err := wb.inputG() if err != nil { return "", err } - if !allZero(b) { - return hex.EncodeToString(b), nil + if vid := hycInput(b, hycRegVID, 8); !allZero(vid) { + return hex.EncodeToString(vid), nil } - b, err = wb.conn.ReadInputRegisters(wb.reg(hycRegIdTag), 10) - if err != nil { - return "", err - } - - if !allZero(b) { - return hex.EncodeToString(b), nil + if idTag := hycInput(b, hycRegIdTag, 20); !allZero(idTag) { + return hex.EncodeToString(idTag), nil } return "", nil @@ -271,12 +385,12 @@ var _ api.Battery = (*AlpitronicHYC)(nil) // Soc implements the api.Battery interface func (wb *AlpitronicHYC) Soc() (float64, error) { - b, err := wb.conn.ReadInputRegisters(wb.reg(hycRegSoC), 1) + b, err := wb.inputG() if err != nil { return 0, err } - return float64(encoding.Uint16(b)) / 100, nil + return float64(encoding.Uint16(hycInput(b, hycRegSoC, 2))) / 100, nil } func allZero(s []byte) bool { diff --git a/charger/alpitronic_test.go b/charger/alpitronic_test.go new file mode 100644 index 000000000..5d16b226c --- /dev/null +++ b/charger/alpitronic_test.go @@ -0,0 +1,362 @@ +package charger + +import ( + "context" + "net" + "strings" + "sync" + "testing" + "time" + + "github.com/andig/mbserver" + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/util/modbus" + "github.com/evcc-io/evcc/util/sponsor" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type hycWrite struct { + funcCode uint8 + addr uint16 + args []uint16 +} + +// hycHandler mocks the Hypercharger's register space +type hycHandler struct { + mbserver.RequestHandler + input map[uint16]uint16 + holding map[uint16]uint16 + writes []hycWrite +} + +func (h *hycHandler) HandleInputRegisters(req *mbserver.InputRegistersRequest) ([]uint16, error) { + res := make([]uint16, 0, req.Quantity) + for i := range req.Quantity { + v, ok := h.input[req.Addr+i] + if !ok { + return nil, mbserver.ErrIllegalDataAddress + } + res = append(res, v) + } + return res, nil +} + +func (h *hycHandler) HandleHoldingRegisters(req *mbserver.HoldingRegistersRequest) ([]uint16, error) { + if req.IsWrite { + h.writes = append(h.writes, hycWrite{req.WriteFuncCode, req.Addr, req.Args}) + for i, v := range req.Args { + h.holding[req.Addr+uint16(i)] = v + } + return req.Args, nil + } + + res := make([]uint16, 0, req.Quantity) + for i := range req.Quantity { + res = append(res, h.holding[req.Addr+i]) + } + return res, nil +} + +// hycReg returns the absolute address of a connector register +func hycReg(connector, reg uint16) uint16 { + return connector*100 + reg +} + +// hycRegs returns a fully populated connector input block with the given state +func hycRegs(connector, state uint16) map[uint16]uint16 { + regs := make(map[uint16]uint16) + for reg := range uint16(hycInputLength) { + regs[hycReg(connector, reg)] = 0 + } + regs[hycReg(connector, hycRegState)] = state + + return regs +} + +// shared mock server: mbserver.Stop() races its accept goroutine, so the server +// is started once and never stopped; handler state is reset per test +var ( + hycOnce sync.Once + hycURI string + hycSrvH = &hycHandler{RequestHandler: new(mbserver.DummyHandler)} +) + +// hycTestCharger connects a charger to the shared mock Modbus server +func hycTestCharger(t *testing.T, connector uint16, regs map[uint16]uint16) (*AlpitronicHYC, *hycHandler) { + t.Helper() + + return hycTestChargerWithLimit(t, connector, regs, nil) +} + +// hycTestChargerWithLimit additionally seeds the connector's power limit +func hycTestChargerWithLimit(t *testing.T, connector uint16, regs, holding map[uint16]uint16) (*AlpitronicHYC, *hycHandler) { + t.Helper() + + hycOnce.Do(func() { + l, err := net.Listen("tcp", "localhost:0") + require.NoError(t, err) + + srv, err := mbserver.New(hycSrvH) + require.NoError(t, err) + require.NoError(t, srv.Start(l)) + + hycURI = l.Addr().String() + }) + + if holding == nil { + holding = make(map[uint16]uint16) + } + + hycSrvH.input = regs + hycSrvH.holding = holding + hycSrvH.writes = nil + + conn, err := modbus.NewConnection(context.Background(), hycURI, "", "", 0, modbus.Tcp, 1) + require.NoError(t, err) + + wb, err := newAlpitronicHYC(conn, connector) + require.NoError(t, err) + + return wb, hycSrvH +} + +func TestAlpitronicStatus(t *testing.T) { + tc := []struct { + state uint16 + status api.ChargeStatus + err bool + }{ + {hycStateAvailable, api.StatusA, false}, + {hycStatePreparingTagIdReady, api.StatusA, false}, // authorized, nothing plugged in yet + {hycStatePreparingEVReady, api.StatusB, false}, + {hycStateCharging, api.StatusC, false}, + {hycStateSuspendedEV, api.StatusB, false}, + {hycStateSuspendedEVSE, api.StatusB, false}, + {hycStateFinishing, api.StatusB, false}, + {hycStateReserved, api.StatusA, false}, + {hycStateUnavailable, api.StatusA, false}, + {hycStateUnavailableFwUpdate, api.StatusA, false}, + {hycStateFaulted, api.StatusNone, true}, + {hycStateUnavailableConnObj, api.StatusA, false}, + {12, api.StatusNone, true}, + } + + for _, tc := range tc { + wb, _ := hycTestCharger(t, 1, hycRegs(1, tc.state)) + + status, err := wb.Status() + if tc.err { + assert.Error(t, err, "state %d", tc.state) + } else { + assert.NoError(t, err, "state %d", tc.state) + } + assert.Equal(t, tc.status, status, "state %d", tc.state) + } +} + +func TestAlpitronicStatusReason(t *testing.T) { + tc := []struct { + state uint16 + reason api.Reason + }{ + {hycStateAvailable, api.ReasonUnknown}, + {hycStatePreparingTagIdReady, api.ReasonUnknown}, + {hycStatePreparingEVReady, api.ReasonWaitingForAuthorization}, + {hycStateCharging, api.ReasonUnknown}, + {hycStateFinishing, api.ReasonDisconnectRequired}, + } + + for _, tc := range tc { + wb, _ := hycTestCharger(t, 1, hycRegs(1, tc.state)) + + reason, err := wb.StatusReason() + require.NoError(t, err, "state %d", tc.state) + assert.Equal(t, tc.reason, reason, "state %d", tc.state) + } +} + +func TestAlpitronicMeasurements(t *testing.T) { + regs := hycRegs(1, hycStateCharging) + regs[hycReg(1, hycRegChargingPower)+1] = 11500 // 11500 W + regs[hycReg(1, hycRegChargeTime)] = 3600 // 1h + regs[hycReg(1, hycRegChargedEnergy)] = 1234 // 12.34 kWh + regs[hycReg(1, hycRegSoC)] = 6550 // 65.5 % + regs[hycReg(1, hycRegTotalChargedEnergy)+3] = 18705 // 18.705 kWh + + wb, _ := hycTestCharger(t, 1, regs) + + power, err := wb.CurrentPower() + require.NoError(t, err) + assert.Equal(t, 11500.0, power) + + dur, err := wb.ChargeDuration() + require.NoError(t, err) + assert.Equal(t, time.Hour, dur) + + charged, err := wb.ChargedEnergy() + require.NoError(t, err) + assert.Equal(t, 12.34, charged) + + soc, err := wb.Soc() + require.NoError(t, err) + assert.Equal(t, 65.5, soc) + + total, err := wb.TotalEnergy() + require.NoError(t, err) + assert.Equal(t, 18.705, total) +} + +func TestAlpitronicIdentify(t *testing.T) { + // no vehicle id, no tag + wb, _ := hycTestCharger(t, 1, hycRegs(1, hycStateAvailable)) + + id, err := wb.Identify() + require.NoError(t, err) + assert.Empty(t, id) + + // vehicle id takes precedence over the tag + regs := hycRegs(1, hycStateCharging) + regs[hycReg(1, hycRegVID)+1] = 0xAABB + regs[hycReg(1, hycRegVID)+2] = 0xCCDD + regs[hycReg(1, hycRegVID)+3] = 0xEEFF + regs[hycReg(1, hycRegIdTag)] = 0x4142 + + wb, _ = hycTestCharger(t, 1, regs) + + id, err = wb.Identify() + require.NoError(t, err) + assert.Equal(t, "0000aabbccddeeff", id) + + // tag as fallback + regs = hycRegs(1, hycStateCharging) + regs[hycReg(1, hycRegIdTag)] = 0x4142 + + wb, _ = hycTestCharger(t, 1, regs) + + id, err = wb.Identify() + require.NoError(t, err) + assert.Equal(t, "4142"+strings.Repeat("0", 36), id) +} + +func TestAlpitronicEnable(t *testing.T) { + wb, h := hycTestCharger(t, 1, hycRegs(1, hycStatePreparingEVReady)) + + enabled, err := wb.Enabled() + require.NoError(t, err) + assert.False(t, enabled) + + require.NoError(t, wb.Enable(true)) + enabled, err = wb.Enabled() + require.NoError(t, err) + assert.True(t, enabled) + + require.NoError(t, wb.Enable(false)) + enabled, err = wb.Enabled() + require.NoError(t, err) + assert.False(t, enabled) + + // default current -> 1552W, disable -> the station must not see a zero limit + require.Len(t, h.writes, 2) + assert.Equal(t, hycWrite{16, hycReg(1, hycRegMaxPowerAC), []uint16{0, 1552}}, h.writes[0]) + assert.Equal(t, hycWrite{16, hycReg(1, hycRegMaxPowerAC), []uint16{0, hycMinPowerAC}}, h.writes[1]) +} + +func TestAlpitronicMaxCurrent(t *testing.T) { + // current is written as power: A * 230V * 3p + tc := []struct { + current float64 + args []uint16 + }{ + {hycMinCurrent, []uint16{0, 1552}}, // lowest accepted current + {6, []uint16{0, 4140}}, + {16, []uint16{0, 11040}}, + {200, []uint16{138000 >> 16, 138000 & 0xFFFF}}, + } + + for _, tc := range tc { + wb, h := hycTestCharger(t, 1, hycRegs(1, hycStateCharging)) + + require.NoError(t, wb.MaxCurrentMillis(tc.current)) + require.Len(t, h.writes, 1, "current %v", tc.current) + assert.Equal(t, hycWrite{16, hycReg(1, hycRegMaxPowerAC), tc.args}, h.writes[0], "current %v", tc.current) + } +} + +func TestAlpitronicSeedCurrent(t *testing.T) { + // the default must not read back as disabled + assert.Greater(t, hycPower(hycMinCurrent), uint32(hycMinPowerAC)) + + // no usable limit set -> default + wb, _ := hycTestCharger(t, 1, hycRegs(1, hycStateAvailable)) + assert.Equal(t, hycMinCurrent, wb.curr) + + // the disabled sentinel must not be taken over + holding := map[uint16]uint16{hycReg(1, hycRegMaxPowerAC) + 1: hycMinPowerAC} + wb, _ = hycTestChargerWithLimit(t, 1, hycRegs(1, hycStateAvailable), holding) + assert.Equal(t, hycMinCurrent, wb.curr) + + // existing limit is adopted, so enabling does not fall back to the default + holding = map[uint16]uint16{hycReg(1, hycRegMaxPowerAC) + 1: 11040} + wb, h := hycTestChargerWithLimit(t, 1, hycRegs(1, hycStateCharging), holding) + assert.Equal(t, 16.0, wb.curr) + + require.NoError(t, wb.Enable(true)) + require.Len(t, h.writes, 1) + assert.Equal(t, hycWrite{16, hycReg(1, hycRegMaxPowerAC), []uint16{0, 11040}}, h.writes[0]) +} + +func TestAlpitronicMaxCurrentInvalid(t *testing.T) { + wb, h := hycTestCharger(t, 1, hycRegs(1, hycStateCharging)) + + // anything that does not exceed hycMinPowerAC would read back as disabled + assert.Error(t, wb.MaxCurrentMillis(2.24)) + assert.Error(t, wb.MaxCurrentMillis(0)) + assert.Error(t, wb.MaxCurrentMillis(-1)) + assert.Empty(t, h.writes) +} + +func TestAlpitronicConnector(t *testing.T) { + // second connector reads and writes the 2xx block + regs := hycRegs(2, hycStateCharging) + regs[hycReg(2, hycRegChargingPower)+1] = 50000 + + wb, h := hycTestCharger(t, 2, regs) + + status, err := wb.Status() + require.NoError(t, err) + assert.Equal(t, api.StatusC, status) + + power, err := wb.CurrentPower() + require.NoError(t, err) + assert.Equal(t, 50000.0, power) + + require.NoError(t, wb.Enable(false)) + require.Len(t, h.writes, 1) + assert.Equal(t, hycReg(2, hycRegMaxPowerAC), h.writes[0].addr) +} + +func TestAlpitronicReadFailure(t *testing.T) { + // missing register in the bulk-read block -> IllegalDataAddress propagates + regs := hycRegs(1, hycStateCharging) + delete(regs, hycReg(1, hycRegTotalChargedEnergy)+3) + + wb, _ := hycTestCharger(t, 1, regs) + + _, err := wb.Status() + assert.Error(t, err) + + _, err = wb.CurrentPower() + assert.Error(t, err) +} + +func TestAlpitronicSponsorGate(t *testing.T) { + // go-e tests set the global sponsor.Subject and never reset it + old := sponsor.Subject + sponsor.Subject = "" + t.Cleanup(func() { sponsor.Subject = old }) + + // tests run without sponsorship: the public constructor must refuse + _, err := NewAlpitronicHYC(t.Context(), modbus.TcpSettings{URI: "localhost:0", ID: 1}, 1) + assert.ErrorIs(t, err, api.ErrSponsorRequired) +}