Add GoodWe Wallbox (Gen2) charger (#29822)

This commit is contained in:
andig 2026-05-22 23:33:37 +02:00 • committed by GitHub
parent 14b422da85
commit ead776949d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 313 additions and 0 deletions

287
charger/goodwe.go Normal file
View file

@ -0,0 +1,287 @@
package charger
// LICENSE
// Copyright (c) evcc.io (andig, naltatis, premultiply)
// This module is NOT covered by the MIT license. All rights reserved.
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import (
"bytes"
"context"
"encoding/binary"
"fmt"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/api/implement"
"github.com/evcc-io/evcc/core/loadpoint"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/modbus"
)
// GoodWe AC EV Charger Gen2 (e.g. GW11K-HCA) — Modbus TCP.
// Protocol reference: "AC EV Charger 2nd Gen Modbus Protocol v1.0.15"
// derived from the Home Assistant integration on the gen2-modbus branch:
// https://github.com/pedrodivisez/goodwe-wallbox-sems-home-assistant/tree/gen2-modbus
// Default device ID is 247 (0xF7); TCP Modbus must be enabled in SolarGo.
type GoodWe struct {
implement.Caps
lp loadpoint.API
conn *modbus.Connection
phases int
maxPower int
}
const (
goodweRegVoltages = 10009 // U16 ×0.1 V, 3 regs (L1/L2/L3)
goodweRegCurrents = 10012 // U16 ×0.1 A, 3 regs (L1/L2/L3)
goodweRegActualPower = 10015 // U16 ×0.1 kW
goodweRegStatus = 10017 // U16 enum (see status mapping)
goodweRegPhaseSwEnabled = 10023 // U16 (0=Off, 1=On)
goodweRegMaxPower = 10029 // U16 ×0.1 kW, raw range [14,220]
goodweRegChargeMode = 10032 // U16 (0=fast, 1=PV, 2=PV+battery)
goodweRegRfid = 10050 // 7 regs ASCII (14 bytes)
goodweRegPowerSpec = 10058 // U16 (0=7kW, 1=11kW, 2=22kW)
goodweRegPhaseSpec = 10059 // U16 (0=1p, 1=3p)
goodweRegChargeCommand = 10060 // U16 (1=stop, 2=start)
goodweRegTotalEnergy = 10065 // U32 ×0.1 kWh, 2 regs
goodweChargeStop = 1
goodweChargeStart = 2
goodweChargeModeFast = 0
)
func init() {
registry.AddCtx("goodwe-wallbox", NewGoodWeFromConfig)
}
// NewGoodWeFromConfig creates a GoodWe wallbox charger from generic config.
func NewGoodWeFromConfig(ctx context.Context, other map[string]any) (api.Charger, error) {
cc := struct {
modbus.TcpSettings `mapstructure:",squash"`
}{
TcpSettings: modbus.TcpSettings{ID: 247},
}
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
return NewGoodWe(ctx, cc.URI, cc.ID)
}
// NewGoodWe creates a GoodWe wallbox charger.
func NewGoodWe(ctx context.Context, uri string, slaveID uint8) (api.Charger, error) {
uri = util.DefaultPort(uri, 502)
conn, err := modbus.NewConnection(ctx, uri, "", "", 0, modbus.Tcp, slaveID)
if err != nil {
return nil, err
}
log := util.NewLogger("goodwe")
conn.Logger(log.TRACE)
// defaults if hardware capability registers can't be read:
// assume 3-phase wallbox without phase switching at typical 11 kW class.
wb := &GoodWe{
Caps: implement.New(),
conn: conn,
phases: 3,
maxPower: 11000,
}
// read hardware power spec (0=7kW, 1=11kW, 2=22kW); fall back to default on error/unknown
if b, err := wb.conn.ReadHoldingRegisters(goodweRegPowerSpec, 1); err == nil {
switch v := binary.BigEndian.Uint16(b); v {
case 0:
wb.phases = 1
wb.maxPower = 7000
case 1:
wb.maxPower = 11000
case 2:
wb.maxPower = 22000
default:
log.WARN.Printf("unknown power spec %d, defaulting to %d W", v, wb.maxPower)
}
} else {
log.WARN.Printf("read power capability failed, defaulting to %d W: %v", wb.maxPower, err)
}
// read hardware phase spec (0=1-phase, 1=3-phase); fall back to 3-phase on error
if b, err := wb.conn.ReadHoldingRegisters(goodweRegPhaseSpec, 1); err == nil {
if binary.BigEndian.Uint16(b) == 0 {
wb.phases = 1
}
} else {
log.WARN.Printf("read hw phase count failed, defaulting to 3-phase: %v", err)
}
// only 3-phase hardware can do 1p/3p switching; conditionally register phase switching
// based on hardware capability. On read error, fall back to fixed-phase operation.
if wb.phases == 3 {
if b, err := wb.conn.ReadHoldingRegisters(goodweRegPhaseSwEnabled, 1); err == nil {
if binary.BigEndian.Uint16(b) == 1 {
implement.Has(wb, implement.PhaseSwitcher(wb.phases1p3p))
}
} else {
log.WARN.Printf("read phase switch config failed, disabling dynamic phase switching: %v", err)
}
}
// force "fast" charging mode so evcc fully controls power setpoint
if _, err := wb.conn.WriteSingleRegister(goodweRegChargeMode, goodweChargeModeFast); err != nil {
return nil, fmt.Errorf("set charge mode: %w", err)
}
return wb, nil
}
// calcPower converts a per-phase current target into the wallbox's 0.1 kW power setpoint.
// The protocol exposes only a total power register; how the wallbox converts that back into
// a per-phase current limit is unspecified — it may assume a fixed nominal voltage (e.g. 230 V)
// or use its own live voltage measurement. 230 V is used here as a best-effort approximation;
// actual delivered current may deviate accordingly until verified on hardware.
func (wb *GoodWe) calcPower(current float64, phases int) uint16 {
return uint16(min(max(230*float64(phases)*current, 1400), float64(wb.maxPower)) / 100) // 0.1 kW
}
// Status implements api.ChargeState
func (wb *GoodWe) Status() (api.ChargeStatus, error) {
b, err := wb.conn.ReadHoldingRegisters(goodweRegStatus, 1)
if err != nil {
return api.StatusNone, err
}
switch s := binary.BigEndian.Uint16(b); s {
case 0: // idle, no plug
return api.StatusA, nil
case 1, 2, 4, 6, 10: // plugged, handshaking, completed, scheduled, interrupted
return api.StatusB, nil
case 3: // charging
return api.StatusC, nil
default:
return api.StatusNone, fmt.Errorf("invalid status: %d", s)
}
}
// Enabled implements api.Charger
func (wb *GoodWe) Enabled() (bool, error) {
b, err := wb.conn.ReadHoldingRegisters(goodweRegChargeCommand, 1)
if err != nil {
return false, err
}
return binary.BigEndian.Uint16(b) == goodweChargeStart, nil
}
// Enable implements api.Charger
func (wb *GoodWe) Enable(enable bool) error {
v := uint16(goodweChargeStop)
if enable {
v = goodweChargeStart
}
_, err := wb.conn.WriteSingleRegister(goodweRegChargeCommand, v)
return err
}
// MaxCurrent implements api.Charger
func (wb *GoodWe) MaxCurrent(current int64) error {
return wb.MaxCurrentMillis(float64(current))
}
var _ api.ChargerEx = (*GoodWe)(nil)
// MaxCurrentMillis implements api.ChargerEx
func (wb *GoodWe) MaxCurrentMillis(current float64) error {
if current < 6 {
return fmt.Errorf("invalid current %.1f", current)
}
_, err := wb.conn.WriteSingleRegister(goodweRegMaxPower, wb.calcPower(current, wb.phases))
return err
}
var _ api.Meter = (*GoodWe)(nil)
// CurrentPower implements api.Meter
func (wb *GoodWe) CurrentPower() (float64, error) {
b, err := wb.conn.ReadHoldingRegisters(goodweRegActualPower, 1)
if err != nil {
return 0, err
}
return float64(binary.BigEndian.Uint16(b)) * 100, nil
}
var _ api.MeterEnergy = (*GoodWe)(nil)
// TotalEnergy implements api.MeterEnergy
func (wb *GoodWe) TotalEnergy() (float64, error) {
b, err := wb.conn.ReadHoldingRegisters(goodweRegTotalEnergy, 2)
if err != nil {
return 0, err
}
return float64(binary.BigEndian.Uint32(b)) / 10, nil
}
func (wb *GoodWe) phaseValues(reg uint16, divider float64) (float64, float64, float64, error) {
b, err := wb.conn.ReadHoldingRegisters(reg, 3)
if err != nil {
return 0, 0, 0, err
}
var v [3]float64
for i := range v {
v[i] = float64(binary.BigEndian.Uint16(b[2*i:])) / divider
}
return v[0], v[1], v[2], nil
}
var _ api.PhaseCurrents = (*GoodWe)(nil)
// Currents implements api.PhaseCurrents
func (wb *GoodWe) Currents() (float64, float64, float64, error) {
return wb.phaseValues(goodweRegCurrents, 10)
}
var _ api.PhaseVoltages = (*GoodWe)(nil)
// Voltages implements api.PhaseVoltages
func (wb *GoodWe) Voltages() (float64, float64, float64, error) {
return wb.phaseValues(goodweRegVoltages, 10)
}
func (wb *GoodWe) phases1p3p(phases int) error {
wb.phases = phases
return nil
}
var _ api.Identifier = (*GoodWe)(nil)
// Identify implements api.Identifier (RFID UID, 14-byte NUL-padded ASCII).
func (wb *GoodWe) Identify() (string, error) {
b, err := wb.conn.ReadHoldingRegisters(goodweRegRfid, 7)
if err != nil {
return "", err
}
return bytesAsString(bytes.Trim(b, "\x00")), nil
}
var _ loadpoint.Controller = (*GoodWe)(nil)
// LoadpointControl implements loadpoint.Controller
func (wb *GoodWe) LoadpointControl(lp loadpoint.API) {
wb.lp = lp
}

View file

@ -0,0 +1,26 @@
template: goodwe-wallbox
products:
- brand: GoodWe
description:
generic: HCA Wallbox (Gen2)
capabilities: ["mA", "rfid", "1p3p", "meter"]
requirements:
description:
de: |
Modbus TCP muss in der SolarGo App im Menü „Kommunikation → Wallbox" aktiviert werden.
Standard-Geräte-ID ist 247 (0xF7).
Die Wallbox erlaubt nur 2 gleichzeitige Modbus-TCP-Sitzungen (1× SEMS-Cloud, 1× Client). Bei aktiver Cloud-Verbindung konkurriert evcc um den zweiten Slot.
Hinweis: Die Implementierung wurde anhand der Home Assistant Integration (gen2-modbus Branch) erstellt und ist nicht herstellerseitig verifiziert.
en: |
Enable Modbus TCP in the SolarGo app via "Communication → Wallbox".
Default device ID is 247 (0xF7).
The wallbox only accepts 2 concurrent Modbus TCP sessions (1× SEMS cloud, 1× client). If the cloud is active, evcc competes for the second slot.
Note: Implementation is derived from the Home Assistant integration (gen2-modbus branch) and is not vendor-verified.
params:
- name: modbus
choice: ["tcpip"]
id: 247
port: 502
render: |
type: goodwe-wallbox
{{- include "modbus" . }}