Alpitronic HYC: fix status mapping and bundle register reads (#32260)
This commit is contained in:
parent
63b7e06612
commit
c32ec2e325
2 changed files with 528 additions and 52 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
362
charger/alpitronic_test.go
Normal file
362
charger/alpitronic_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue