From be90fde29706bbdaf4f5ff1d3f961c48244c9c93 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 20 Jan 2026 15:42:38 +0100 Subject: [PATCH] Add Voltie charger (Modbus TCP) (#26841) --- charger/voltie.go | 252 +++++++++++++++++++++++ templates/definition/charger/voltie.yaml | 15 ++ 2 files changed, 267 insertions(+) create mode 100644 charger/voltie.go create mode 100644 templates/definition/charger/voltie.yaml diff --git a/charger/voltie.go b/charger/voltie.go new file mode 100644 index 000000000..3f8c9c91a --- /dev/null +++ b/charger/voltie.go @@ -0,0 +1,252 @@ +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 ( + "context" + "encoding/binary" + "fmt" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/modbus" + "github.com/evcc-io/evcc/util/sponsor" +) + +// Voltie charger implementation +// https://voltie.eu +// Modbus API documentation v1.02 + +const ( + voltieRegChargerID = 0x0000 // R, INT16, Voltie Charger ID + voltieRegFirmware = 0x0001 // R, INT16, FW version + voltieRegStatus = 0x000A // R, INT16, EVSE_STATE + voltieRegAutoStart = 0x000B // R/W, INT16, Auto Start enabled + voltieRegChargingEnabled = 0x000C // R/W, INT16, Charging enabled + voltieRegCharging = 0x000D // R, INT16, Charging (0=no charging, 1=charging) + voltieRegPhases = 0x000E // R, INT16, Number of phases in use + voltieRegStopReason = 0x0012 // R, INT16, Charge stop reason + voltieRegCurrentLimit = 0x0014 // R/W, INT16, Software current limit [mA] + + voltieRegVoltages = 0x2000 // R, INT32, Phase L1 voltage [mV] + voltieRegCurrents = 0x2006 // R, INT32, Phase L1 charging current [mA] + voltieRegChargeDuration = 0x200C // R, INT32, Charge duration [s] + voltieRegChargedEnergy = 0x200E // R, INT32, Charged energy in current session [Ws] + voltieRegChargingPower = 0x2010 // R, INT32, Charging power [W] +) + +// Voltie is an api.Charger implementation for Voltie wallboxes +type Voltie struct { + conn *modbus.Connection +} + +func init() { + registry.AddCtx("voltie", NewVoltieFromConfig) +} + +// NewVoltieFromConfig creates a Voltie charger from generic config +func NewVoltieFromConfig(ctx context.Context, other map[string]any) (api.Charger, error) { + cc := struct { + modbus.TcpSettings `mapstructure:",squash"` + }{ + TcpSettings: modbus.TcpSettings{ + ID: 1, + }, + } + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + return NewVoltie(ctx, cc.URI, cc.ID) +} + +// NewVoltie creates a Voltie charger +func NewVoltie(ctx context.Context, uri string, slaveID uint8) (*Voltie, error) { + 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("voltie") + conn.Logger(log.TRACE) + + wb := &Voltie{ + conn: conn, + } + + // Disable auto start + if _, err := wb.conn.WriteSingleRegister(voltieRegAutoStart, 0); err != nil { + return nil, err + } + + return wb, nil +} + +// Status implements the api.Charger interface +func (wb *Voltie) Status() (api.ChargeStatus, error) { + b, err := wb.conn.ReadHoldingRegisters(voltieRegStatus, 1) + if err != nil { + return api.StatusNone, err + } + + status := binary.BigEndian.Uint16(b) + + // EVSE states: + // 0x01: vehicle in state A – not connected + // 0x02: vehicle in state B – connected, ready + // 0x03: vehicle in state C – charging + // 0x04: vehicle in state D – charging, ventilation required + // 0x0D: vehicle in state E – vehicle error + // 0x05-0x0C, 0x0E-0x11: internal error states + // 0xFF: charger disabled, not functioning + + switch status { + case 0x01: + return api.StatusA, nil + case 0x02: + return api.StatusB, nil + case 0x03, 0x04: + return api.StatusC, nil + default: + return api.StatusNone, fmt.Errorf("invalid status: %0x", status) + } +} + +// Enabled implements the api.Charger interface +func (wb *Voltie) Enabled() (bool, error) { + b, err := wb.conn.ReadHoldingRegisters(voltieRegChargingEnabled, 1) + if err != nil { + return false, err + } + + return binary.BigEndian.Uint16(b) != 0, nil +} + +// Enable implements the api.Charger interface +func (wb *Voltie) Enable(enable bool) error { + var u uint16 + if enable { + u = 1 + } + + _, err := wb.conn.WriteSingleRegister(voltieRegChargingEnabled, u) + return err +} + +// MaxCurrent implements the api.Charger interface +func (wb *Voltie) MaxCurrent(current int64) error { + return wb.MaxCurrentMillis(float64(current)) +} + +var _ api.ChargerEx = (*Voltie)(nil) + +// MaxCurrentMillis implements the api.ChargerEx interface +func (wb *Voltie) MaxCurrentMillis(current float64) error { + if current < 6 { + return fmt.Errorf("invalid current %.1f", current) + } + + _, err := wb.conn.WriteSingleRegister(voltieRegCurrentLimit, uint16(current*1000)) + return err +} + +var _ api.Meter = (*Voltie)(nil) + +// CurrentPower implements the api.Meter interface +func (wb *Voltie) CurrentPower() (float64, error) { + b, err := wb.conn.ReadHoldingRegisters(voltieRegChargingPower, 2) + if err != nil { + return 0, err + } + + return float64(binary.BigEndian.Uint32(b)), nil +} + +var _ api.ChargeRater = (*Voltie)(nil) + +// ChargedEnergy implements the api.ChargeRater interface +func (wb *Voltie) ChargedEnergy() (float64, error) { + b, err := wb.conn.ReadHoldingRegisters(voltieRegChargedEnergy, 2) + if err != nil { + return 0, err + } + + return float64(binary.BigEndian.Uint32(b)) / 3.6e6, nil // Ws to kWh +} + +var _ api.PhaseCurrents = (*Voltie)(nil) + +// Currents implements the api.PhaseCurrents interface +func (wb *Voltie) Currents() (float64, float64, float64, error) { + b, err := wb.conn.ReadHoldingRegisters(voltieRegCurrents, 6) + + if err != nil { + return 0, 0, 0, err + } + + var res [3]float64 + for i := range res { + res[i] = float64(binary.BigEndian.Uint32(b[4*i:])) / 1e3 // mA to A + } + + return res[0], res[1], res[2], nil +} + +var _ api.PhaseVoltages = (*Voltie)(nil) + +// Voltages implements the api.PhaseVoltages interface +func (wb *Voltie) Voltages() (float64, float64, float64, error) { + b, err := wb.conn.ReadHoldingRegisters(voltieRegVoltages, 6) + + if err != nil { + return 0, 0, 0, err + } + + var res [3]float64 + for i := range res { + res[i] = float64(binary.BigEndian.Uint32(b[4*i:])) / 1e3 // mV to V + } + + return res[0], res[1], res[2], nil +} + +var _ api.Diagnosis = (*Voltie)(nil) + +// Diagnose implements the api.Diagnosis interface +func (wb *Voltie) Diagnose() { + if b, err := wb.conn.ReadHoldingRegisters(voltieRegChargerID, 1); err == nil { + fmt.Printf("\tCharger ID:\t%d\n", binary.BigEndian.Uint16(b)) + } + if b, err := wb.conn.ReadHoldingRegisters(voltieRegFirmware, 1); err == nil { + fmt.Printf("\tFirmware:\t%d\n", binary.BigEndian.Uint16(b)) + } + if b, err := wb.conn.ReadHoldingRegisters(voltieRegStatus, 1); err == nil { + fmt.Printf("\tStatus:\t\t0x%04X\n", binary.BigEndian.Uint16(b)) + } + if b, err := wb.conn.ReadHoldingRegisters(voltieRegPhases, 1); err == nil { + fmt.Printf("\tPhases:\t\t%d\n", binary.BigEndian.Uint16(b)) + } + if b, err := wb.conn.ReadHoldingRegisters(voltieRegStopReason, 1); err == nil { + fmt.Printf("\tStop reason:\t%d\n", binary.BigEndian.Uint16(b)) + } +} diff --git a/templates/definition/charger/voltie.yaml b/templates/definition/charger/voltie.yaml new file mode 100644 index 000000000..d292ad2e4 --- /dev/null +++ b/templates/definition/charger/voltie.yaml @@ -0,0 +1,15 @@ +template: voltie +products: + - brand: Voltie + description: + generic: Charger +capabilities: ["mA"] +requirements: + evcc: ["sponsorship"] +params: + - name: modbus + choice: ["tcpip"] + id: 1 +render: | + type: voltie + {{- include "modbus" . }}