Add Em2Go Duo Power charger support

- Add em2go-duo-power.yaml template for dual-port AC WALLBOX DUO POWER 2X 22KW
- Extend em2go.go implementation to support dual connector addressing
- Add connector-based register offset (100 registers per connector)
- Support connector 1 and 2 configuration via template parameter

Addresses GitHub issue #22518

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
andig 2025-07-25 15:23:59 +02:00
parent b7015af551
commit 18c49ef45d
2 changed files with 106 additions and 25 deletions

View file

@ -38,6 +38,7 @@ type Em2Go struct {
current uint16
workaround bool
phases int
base uint16 // register base offset for dual connector support
}
const (
@ -60,6 +61,9 @@ const (
em2GoRegVoltages = 109 // Uint16 RO 0.1V
em2GoRegPhases = 200 // Set charging phase 1 unsigned
// Duo Power connector offset - connector 2 registers start at offset 100
em2GoDuoPowerOffset = 100
//removed due to unreliable session energy when pausing or switching phases
//em2GoRegChargedEnergy = 72 // Uint16 RO 0.1kWh
)
@ -67,6 +71,7 @@ const (
func init() {
registry.AddCtx("em2go", NewEm2GoFromConfig)
registry.AddCtx("em2go-home", NewEm2GoFromConfig)
registry.AddCtx("em2go-duo-power", NewEm2GoDuoPowerFromConfig)
}
//go:generate go tool decorate -f decorateEm2Go -b *Em2Go -r api.Charger -t "api.ChargerEx,MaxCurrentMillis,func(float64) error" -t "api.PhaseSwitcher,Phases1p3p,func(int) error" -t "api.PhaseGetter,GetPhases,func() (int, error)"
@ -84,6 +89,23 @@ func NewEm2GoFromConfig(ctx context.Context, other map[string]interface{}) (api.
return NewEm2Go(ctx, cc.URI, cc.ID)
}
// NewEm2GoDuoPowerFromConfig creates a Em2Go Duo Power charger from generic config
func NewEm2GoDuoPowerFromConfig(ctx context.Context, other map[string]interface{}) (api.Charger, error) {
cc := struct {
modbus.TcpSettings `mapstructure:",squash"`
Connector int
}{
TcpSettings: modbus.TcpSettings{ID: 255},
Connector: 1,
}
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
return NewEm2GoDuoPower(ctx, cc.URI, cc.ID, cc.Connector)
}
// NewEm2Go creates Em2Go charger
func NewEm2Go(ctx context.Context, uri string, slaveID uint8) (api.Charger, error) {
uri = util.DefaultPort(uri, 502)
@ -104,8 +126,44 @@ func NewEm2Go(ctx context.Context, uri string, slaveID uint8) (api.Charger, erro
conn: conn,
current: 60,
workaround: false,
base: 0, // single connector, no offset
}
return wb.initialize()
}
// NewEm2GoDuoPower creates Em2Go Duo Power charger
func NewEm2GoDuoPower(ctx context.Context, uri string, slaveID uint8, connector int) (api.Charger, error) {
if connector < 1 || connector > 2 {
return nil, fmt.Errorf("invalid connector: %d", connector)
}
uri = util.DefaultPort(uri, 502)
conn, err := modbus.NewConnection(ctx, uri, "", "", 0, modbus.Tcp, slaveID)
if err != nil {
return nil, err
}
// Add delay of 60 milliseconds between requests
conn.Delay(60 * time.Millisecond)
log := util.NewLogger("em2go-duo-power")
conn.Logger(log.TRACE)
wb := &Em2Go{
log: log,
conn: conn,
current: 60,
workaround: false,
base: uint16((connector - 1) * em2GoDuoPowerOffset),
}
return wb.initialize()
}
// initialize performs common initialization for both Em2Go models
func (wb *Em2Go) initialize() (api.Charger, error) {
var (
maxCurrent func(float64) error
phases1p3p func(int) error
@ -129,7 +187,7 @@ func NewEm2Go(ctx context.Context, uri string, slaveID uint8) (api.Charger, erro
maxCurrent = wb.maxCurrentMillis
}
if _, err := wb.conn.ReadHoldingRegisters(em2GoRegPhases, 1); err == nil {
if _, err := wb.conn.ReadHoldingRegisters(wb.base+em2GoRegPhases, 1); err == nil {
phases1p3p = wb.phases1p3p
phasesG = wb.getPhases
}
@ -139,7 +197,7 @@ func NewEm2Go(ctx context.Context, uri string, slaveID uint8) (api.Charger, erro
// Status implements the api.Charger interface
func (wb *Em2Go) Status() (api.ChargeStatus, error) {
b, err := wb.conn.ReadHoldingRegisters(em2GoRegStatus, 1)
b, err := wb.conn.ReadHoldingRegisters(wb.base+em2GoRegStatus, 1)
if err != nil {
return api.StatusNone, err
}
@ -162,7 +220,7 @@ func (wb *Em2Go) Status() (api.ChargeStatus, error) {
// Enabled implements the api.Charger interface
func (wb *Em2Go) Enabled() (bool, error) {
b, err := wb.conn.ReadHoldingRegisters(em2GoRegChargeCommand, 1)
b, err := wb.conn.ReadHoldingRegisters(wb.base+em2GoRegChargeCommand, 1)
if err != nil {
return false, err
}
@ -177,7 +235,7 @@ func (wb *Em2Go) Enable(enable bool) error {
b := make([]byte, 2)
binary.BigEndian.PutUint16(b, map[bool]uint16{true: 1, false: 2}[enable])
if _, err := wb.conn.WriteMultipleRegisters(em2GoRegChargeCommand, 1, b); err != nil {
if _, err := wb.conn.WriteMultipleRegisters(wb.base+em2GoRegChargeCommand, 1, b); err != nil {
return err
}
@ -185,7 +243,7 @@ func (wb *Em2Go) Enable(enable bool) error {
if wb.workaround && enable {
if wb.phases == 1 {
binary.BigEndian.PutUint16(b, uint16(wb.phases))
if _, err := wb.conn.WriteMultipleRegisters(em2GoRegPhases, 1, b); err != nil {
if _, err := wb.conn.WriteMultipleRegisters(wb.base+em2GoRegPhases, 1, b); err != nil {
return err
}
}
@ -201,7 +259,7 @@ func (wb *Em2Go) setCurrent(current uint16) error {
b := make([]byte, 2)
binary.BigEndian.PutUint16(b, current)
_, err := wb.conn.WriteMultipleRegisters(em2GoRegCurrentLimit, 1, b)
_, err := wb.conn.WriteMultipleRegisters(wb.base+em2GoRegCurrentLimit, 1, b)
return err
}
@ -224,7 +282,7 @@ var _ api.CurrentGetter = (*Em2Go)(nil)
// GetMaxCurrent implements the api.CurrentGetter interface
func (wb Em2Go) GetMaxCurrent() (float64, error) {
b, err := wb.conn.ReadHoldingRegisters(em2GoRegCurrentLimit, 1)
b, err := wb.conn.ReadHoldingRegisters(wb.base+em2GoRegCurrentLimit, 1)
if err != nil {
return 0, err
}
@ -236,7 +294,7 @@ var _ api.Meter = (*Em2Go)(nil)
// CurrentPower implements the api.Meter interface
func (wb *Em2Go) CurrentPower() (float64, error) {
b, err := wb.conn.ReadHoldingRegisters(em2GoRegPower, 2)
b, err := wb.conn.ReadHoldingRegisters(wb.base+em2GoRegPower, 2)
if err != nil {
return 0, err
}
@ -248,7 +306,7 @@ var _ api.MeterEnergy = (*Em2Go)(nil)
// TotalEnergy implements the api.MeterEnergy interface
func (wb *Em2Go) TotalEnergy() (float64, error) {
b, err := wb.conn.ReadHoldingRegisters(em2GoRegEnergy, 2)
b, err := wb.conn.ReadHoldingRegisters(wb.base+em2GoRegEnergy, 2)
if err != nil {
return 0, err
}
@ -261,7 +319,7 @@ func (wb *Em2Go) getPhaseValues(reg uint16) (float64, float64, float64, error) {
var res [3]float64
for i := range 3 {
b, err := wb.conn.ReadHoldingRegisters(reg+2*uint16(i), 1)
b, err := wb.conn.ReadHoldingRegisters(wb.base+reg+2*uint16(i), 1)
if err != nil {
return 0, 0, 0, err
}
@ -290,7 +348,7 @@ var _ api.ChargeTimer = (*Em2Go)(nil)
// ChargeDuration implements the api.ChargeTimer interface
func (wb *Em2Go) ChargeDuration() (time.Duration, error) {
b, err := wb.conn.ReadHoldingRegisters(em2GoRegChargeDuration, 2)
b, err := wb.conn.ReadHoldingRegisters(wb.base+em2GoRegChargeDuration, 2)
if err != nil {
return 0, err
}
@ -323,7 +381,7 @@ func (wb *Em2Go) phases1p3p(phases int) error {
b := make([]byte, 2)
binary.BigEndian.PutUint16(b, uint16(phases))
_, err := wb.conn.WriteMultipleRegisters(em2GoRegPhases, 1, b)
_, err := wb.conn.WriteMultipleRegisters(wb.base+em2GoRegPhases, 1, b)
if err == nil {
wb.phases = phases
}
@ -333,7 +391,7 @@ func (wb *Em2Go) phases1p3p(phases int) error {
// getPhases implements the api.PhaseGetter interface
func (wb *Em2Go) getPhases() (int, error) {
b, err := wb.conn.ReadHoldingRegisters(em2GoRegPhases, 1)
b, err := wb.conn.ReadHoldingRegisters(wb.base+em2GoRegPhases, 1)
if err != nil {
return 0, err
}
@ -345,46 +403,46 @@ var _ api.Diagnosis = (*Em2Go)(nil)
// Diagnose implements the api.Diagnosis interface
func (wb *Em2Go) Diagnose() {
if b, err := wb.conn.ReadHoldingRegisters(em2GoRegStatus, 1); err == nil {
if b, err := wb.conn.ReadHoldingRegisters(wb.base+em2GoRegStatus, 1); err == nil {
fmt.Printf("\tCharging Station Status:\t%d\n", binary.BigEndian.Uint16(b))
}
if b, err := wb.conn.ReadHoldingRegisters(em2GoRegConnectorState, 1); err == nil {
if b, err := wb.conn.ReadHoldingRegisters(wb.base+em2GoRegConnectorState, 1); err == nil {
fmt.Printf("\tConnector State:\t%d\n", binary.BigEndian.Uint16(b))
}
if b, err := wb.conn.ReadHoldingRegisters(em2GoRegErrorCode, 1); err == nil {
if b, err := wb.conn.ReadHoldingRegisters(wb.base+em2GoRegErrorCode, 1); err == nil {
fmt.Printf("\tError Code:\t%d\n", binary.BigEndian.Uint16(b))
}
if b, err := wb.conn.ReadHoldingRegisters(em2GoRegMaxCurrent, 1); err == nil {
if b, err := wb.conn.ReadHoldingRegisters(wb.base+em2GoRegMaxCurrent, 1); err == nil {
fmt.Printf("\tEVSE Max. Current:\t%.1fA\n", float64(binary.BigEndian.Uint16(b)/10))
}
if b, err := wb.conn.ReadHoldingRegisters(em2GoRegMaxCurrent, 1); err == nil {
if b, err := wb.conn.ReadHoldingRegisters(wb.base+em2GoRegMinCurrent, 1); err == nil {
fmt.Printf("\tEVSE Min. Current:\t%.1fA\n", float64(binary.BigEndian.Uint16(b)/10))
}
if b, err := wb.conn.ReadHoldingRegisters(em2GoRegCableMaxCurrent, 1); err == nil {
if b, err := wb.conn.ReadHoldingRegisters(wb.base+em2GoRegCableMaxCurrent, 1); err == nil {
fmt.Printf("\tCable Max. Current:\t%.1fA\n", float64(binary.BigEndian.Uint16(b)/10))
}
var serial []byte
for reg := 0; reg < 8; reg++ {
b, err := wb.conn.ReadHoldingRegisters(em2GoRegSerial+2*uint16(reg), 2)
b, err := wb.conn.ReadHoldingRegisters(wb.base+em2GoRegSerial+2*uint16(reg), 2)
if err != nil {
return
}
serial = append(serial, b...)
}
fmt.Printf("\tSerial: %s\n", string(serial))
if b, err := wb.conn.ReadHoldingRegisters(em2GoRegSafeCurrent, 1); err == nil {
if b, err := wb.conn.ReadHoldingRegisters(wb.base+em2GoRegSafeCurrent, 1); err == nil {
fmt.Printf("\tSafe Current:\t%.1fA\n", float64(binary.BigEndian.Uint16(b)/10))
}
if b, err := wb.conn.ReadHoldingRegisters(em2GoRegCommTimeout, 1); err == nil {
if b, err := wb.conn.ReadHoldingRegisters(wb.base+em2GoRegCommTimeout, 1); err == nil {
fmt.Printf("\tConnection Timeout:\t%d\n", binary.BigEndian.Uint16(b))
}
if b, err := wb.conn.ReadHoldingRegisters(em2GoRegCurrentLimit, 1); err == nil {
if b, err := wb.conn.ReadHoldingRegisters(wb.base+em2GoRegCurrentLimit, 1); err == nil {
fmt.Printf("\tCurrent Limit:\t%.1fA\n", float64(binary.BigEndian.Uint16(b)/10))
}
if b, err := wb.conn.ReadHoldingRegisters(em2GoRegChargeMode, 1); err == nil {
if b, err := wb.conn.ReadHoldingRegisters(wb.base+em2GoRegChargeMode, 1); err == nil {
fmt.Printf("\tCharge Mode:\t%d\n", binary.BigEndian.Uint16(b))
}
if b, err := wb.conn.ReadHoldingRegisters(em2GoRegChargeCommand, 1); err == nil {
if b, err := wb.conn.ReadHoldingRegisters(wb.base+em2GoRegChargeCommand, 1); err == nil {
fmt.Printf("\tCharge Command:\t%d\n", binary.BigEndian.Uint16(b))
}
}

View file

@ -0,0 +1,23 @@
template: em2go-duo-power
products:
- brand: EM2GO
description:
generic: AC WALLBOX DUO POWER 2X 22KW
capabilities: ["mA"]
requirements:
description:
de: "Aktuelle Firmware mit Modbus-Unterstützung notwendig. Mögliche Verbindungsprobleme - Device ID muss bei jeder Verbindung inkrementiert werden."
en: "Recent firmware with Modbus support required. Possible connection issues - Device ID needs to be incremented for each connection attempt."
params:
- name: modbus
choice: ["tcpip"]
id: 255
- name: connector
description:
de: "Anschluss (1 oder 2 für Duo Power)"
en: "Connector (1 or 2 for Duo Power)"
default: 1
render: |
type: em2go-duo-power
{{- include "modbus" . }}
connector: {{ .connector }}