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" "github.com/evcc-io/evcc/util/sponsor" ) // 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) goodweRegMaxPower = 10029 // U16 ×0.1 kW, raw range [14,220] goodweRegChargeMode = 10032 // U16 (0=fast, 1=PV, 2=PV+battery) goodweRegPowerSpec = 10058 // U16 (0=7kW, 1=11kW, 2=22kW) goodweRegPhaseSpec = 10059 // U16 (0=3p, 1=1p) goodweRegRfid = 10500 // 7 regs ASCII (14-byte card UID) 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 } if !sponsor.IsAuthorized() { return nil, api.ErrSponsorRequired } 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=3-phase, 1=1-phase); fall back to 3-phase on error if b, err := wb.conn.ReadHoldingRegisters(goodweRegPhaseSpec, 1); err == nil { if binary.BigEndian.Uint16(b) == 1 { wb.phases = 1 } } else { log.WARN.Printf("read hw phase count failed, defaulting to 3-phase: %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) } 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 }