Alpitronic: update handling for power absorption and fix status logic (v3.1.0) (#32820)

This commit is contained in:
premultiply 2026-08-14 16:42:52 +02:00 • committed by GitHub
parent 2201066430
commit eaa49fceb3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 58 additions and 51 deletions

View file

@ -36,6 +36,7 @@ type AlpitronicHYC struct {
conn *modbus.Connection
inputG func() ([]byte, error)
curr float64
enabled bool
connector uint16
}
@ -61,22 +62,20 @@ const (
const (
hycRegState = 0 // UINT16
hycRegChargingVoltage = 1 // UINT32, cV
hycRegChargingCurrent = 3 // UINT16, cA
hycRegChargingPower = 4 // UINT32, W
hycRegChargingCurrent = 3 // UINT16, cA up to 3.0, A from 3.1.0 on
hycRegPowerAbsorptionAC = 4 // UINT32, W- DC charging power up to 3.0, AC absorption from 3.1.0 on
hycRegChargeTime = 6 // UINT16, s
hycRegChargedEnergy = 7 // UINT16, kWh/100
hycRegChargedEnergy = 7 // UINT16, kWh/100 up to 3.0, kWh/10 from 3.1.0 on
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
hycRegMaxPowerAbsorption = 10 // UINT32, W- includes the limit written by us
hycRegMinPowerAbsorption = 12 // UINT32, W
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)
// read up to the last register in use (x32..x35). x14/x16 (VAR) are covered but
// no longer documented since 3.1.0- to be verified against the device
hycInputLength = 36
)
@ -104,7 +103,7 @@ const (
// 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
// for any connector to actually charge- see hycRegMinPowerAbsorption
const (
hycPowerPerAmp = 230 * 3 // W/A on the AC side
hycMinPowerAC = 1547 // W
@ -174,6 +173,7 @@ func newAlpitronicHYC(conn *modbus.Connection, connector uint16) (*AlpitronicHYC
if power := encoding.Uint32(b); power > hycMinPowerAC {
wb.curr = float64(power) / hycPowerPerAmp
wb.enabled = true
}
return wb, nil
@ -200,6 +200,12 @@ func (wb *AlpitronicHYC) setPower(power uint32) error {
encoding.PutUint32(b, power)
_, err := wb.conn.WriteMultipleRegisters(wb.reg(hycRegMaxPowerAC), 2, b)
if err == nil {
// Status is polled before Enabled, so track our own writes to keep
// the charging state from lagging a cycle behind
wb.enabled = power > hycMinPowerAC
}
return err
}
@ -241,6 +247,9 @@ func (wb *AlpitronicHYC) Status() (api.ChargeStatus, error) {
hycStateFinishing:
return api.StatusB, nil
case hycStateCharging:
if !wb.enabled {
return api.StatusB, nil
}
return api.StatusC, nil
case hycStateFaulted:
return api.StatusNone, errors.New("connector state: faulted")
@ -249,6 +258,25 @@ func (wb *AlpitronicHYC) Status() (api.ChargeStatus, error) {
}
}
var _ api.StatusReasoner = (*AlpitronicHYC)(nil)
// StatusReason implements the api.StatusReasoner interface
func (wb *AlpitronicHYC) StatusReason() (api.Reason, error) {
s, err := wb.state()
if err != nil {
return api.ReasonUnknown, err
}
switch s {
case hycStatePreparingEVReady:
return api.ReasonWaitingForAuthorization, nil
case hycStateFinishing:
return api.ReasonDisconnectRequired, nil
default:
return api.ReasonUnknown, nil
}
}
// Enabled implements the api.Charger interface
func (wb *AlpitronicHYC) Enabled() (bool, error) {
b, err := wb.conn.ReadHoldingRegisters(wb.reg(hycRegMaxPowerAC), 2)
@ -256,7 +284,9 @@ func (wb *AlpitronicHYC) Enabled() (bool, error) {
return false, err
}
return encoding.Uint32(b) > hycMinPowerAC, nil
wb.enabled = encoding.Uint32(b) > hycMinPowerAC
return wb.enabled, nil
}
// Enable implements the api.Charger interface
@ -303,7 +333,7 @@ func (wb *AlpitronicHYC) CurrentPower() (float64, error) {
return 0, err
}
return float64(encoding.Uint32(hycInput(b, hycRegChargingPower, 4))), nil
return float64(encoding.Uint32(hycInput(b, hycRegPowerAbsorptionAC, 4))), nil
}
var _ api.ChargeTimer = (*AlpitronicHYC)(nil)
@ -318,17 +348,9 @@ func (wb *AlpitronicHYC) ChargeDuration() (time.Duration, error) {
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.inputG()
if err != nil {
return 0, err
}
return float64(encoding.Uint16(hycInput(b, hycRegChargedEnergy, 2))) / 100, nil
}
// api.ChargeRater is not implemented: hycRegChargedEnergy is scaled kWh/100 up to
// firmware 3.0 and kWh/10 from 3.1.0 on, so the session energy is derived from
// TotalEnergy instead
var _ api.MeterEnergy = (*AlpitronicHYC)(nil)
@ -342,25 +364,6 @@ func (wb *AlpitronicHYC) TotalEnergy() (float64, error) {
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) {
s, err := wb.state()
if err != nil {
return api.ReasonUnknown, err
}
switch s {
case hycStatePreparingEVReady:
return api.ReasonWaitingForAuthorization, nil
case hycStateFinishing:
return api.ReasonDisconnectRequired, nil
default:
return api.ReasonUnknown, nil
}
}
var _ api.Identifier = (*AlpitronicHYC)(nil)
// Identify implements the api.Identifier interface

View file

@ -130,7 +130,7 @@ func TestAlpitronicStatus(t *testing.T) {
{hycStateAvailable, api.StatusA, false},
{hycStatePreparingTagIdReady, api.StatusA, false}, // authorized, nothing plugged in yet
{hycStatePreparingEVReady, api.StatusB, false},
{hycStateCharging, api.StatusC, false},
{hycStateCharging, api.StatusB, false}, // charging while disabled, see below
{hycStateSuspendedEV, api.StatusB, false},
{hycStateSuspendedEVSE, api.StatusB, false},
{hycStateFinishing, api.StatusB, false},
@ -153,6 +153,14 @@ func TestAlpitronicStatus(t *testing.T) {
}
assert.Equal(t, tc.status, status, "state %d", tc.state)
}
// charging while enabled
holding := map[uint16]uint16{hycReg(1, hycRegMaxPowerAC) + 1: 11040}
wb, _ := hycTestChargerWithLimit(t, 1, hycRegs(1, hycStateCharging), holding)
status, err := wb.Status()
require.NoError(t, err)
assert.Equal(t, api.StatusC, status)
}
func TestAlpitronicStatusReason(t *testing.T) {
@ -178,9 +186,8 @@ func TestAlpitronicStatusReason(t *testing.T) {
func TestAlpitronicMeasurements(t *testing.T) {
regs := hycRegs(1, hycStateCharging)
regs[hycReg(1, hycRegChargingPower)+1] = 11500 // 11500 W
regs[hycReg(1, hycRegPowerAbsorptionAC)+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
@ -194,10 +201,6 @@ func TestAlpitronicMeasurements(t *testing.T) {
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)
@ -319,9 +322,10 @@ func TestAlpitronicMaxCurrentInvalid(t *testing.T) {
func TestAlpitronicConnector(t *testing.T) {
// second connector reads and writes the 2xx block
regs := hycRegs(2, hycStateCharging)
regs[hycReg(2, hycRegChargingPower)+1] = 50000
regs[hycReg(2, hycRegPowerAbsorptionAC)+1] = 50000
wb, h := hycTestCharger(t, 2, regs)
holding := map[uint16]uint16{hycReg(2, hycRegMaxPowerAC) + 1: 50000}
wb, h := hycTestChargerWithLimit(t, 2, regs, holding)
status, err := wb.Status()
require.NoError(t, err)