Voltie: rewrite Modbus driver for API v1.1 (#32671)

This commit is contained in:
Voltie EV Charging Solutions 2026-08-12 18:16:01 +02:00 • committed by GitHub
parent 8e5322da22
commit 95fe545308
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 354 additions and 82 deletions

View file

@ -21,6 +21,7 @@ import (
"context"
"encoding/binary"
"fmt"
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
@ -30,29 +31,191 @@ import (
// Voltie charger implementation
// https://voltie.eu
// Modbus API documentation v1.02
// Modbus API documentation v1.1
//
// Modbus TCP is supported from EVSE firmware 350 and charger software 1.3.40.
// Earlier firmware answers out-of-range reads with adjacent memory instead of
// an exception and silently accepts ineffective FC16 writes. The driver checks
// the firmware build number on startup; the charger software version is not
// exposed over Modbus and has to be checked in the Voltie app.
//
// The same register map is served over RS-485 (Modbus RTU) and over the Modbus
// TCP gateway. The gateway is a transparent bridge to the charger's MCU: it
// forwards one request at a time, at most two transactions per second, and
// abandons a request that is unanswered after 3s. The driver therefore fetches
// the register blocks through the shared bulk read cache instead of issuing a
// separate request per value, so an update cycle costs one request per block.
//
// Only function code 0x06 (write single register) is accepted for writes;
// 0x10 (write multiple) is rejected with exception 0x01.
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]
// register blocks fetched in bulk
voltieRegInfoBlock = 0x0000
voltieLenInfoBlock = 10 // 0x0000..0x0009
voltieRegStatusBlock = 0x000A
voltieLenStatusBlock = 12 // 0x000A..0x0015
voltieRegMeterBlock = 0x2000
voltieLenMeterBlock = 22 // 0x2000..0x2015
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]
// identification block. The 64 bit serial numbers are sent
// least-significant word first, unlike the metering values.
voltieRegChargerID = 0x0000 // INT16 Voltie charger ID
voltieRegFirmware = 0x0001 // INT16 EVSE firmware build number
voltieRegMcuSerial = 0x0002 // INT64 MCU serial number
voltieRegHpowSerial = 0x0006 // INT64 power board serial number
voltieSerialRegCount = 4
// status block
voltieRegStatus = 0x000A // INT16 EVSE_STATE
voltieRegAutoStart = 0x000B // INT16 auto start enabled
voltieRegChargeEnable = 0x000C // INT16 charging enabled
voltieRegCharging = 0x000D // INT16 charging
voltieRegPhases = 0x000E // INT16 number of phases in use
voltieRegDlmSet = 0x000F // INT16 stored DLM mode
voltieRegStopReason = 0x0012 // INT16 charge stop reason
voltieRegCurrent = 0x0014 // INT16 software current limit [mA]
voltieRegDlmEffective = 0x0015 // INT16 effective DLM mode
// meter block
voltieRegVoltages = 0x2000 // 3x INT32 phase voltage [mV]
voltieRegCurrents = 0x2006 // 3x INT32 phase charging current [mA]
voltieRegDuration = 0x200C // INT32 charge duration [s]
voltieRegEnergy = 0x200E // INT32 charged energy in session [Ws]
voltieRegPower = 0x2010 // INT32 charging power [W]
voltieRegCapacity = 0x2012 // INT32 instantaneous current capacity [mA]
// the charger's ampacity range [A]. The current limit register is typed
// INT16, so the milliampere value must stay below the sign boundary.
voltieMinCurrent = 6
voltieMaxCurrent = 32
// firmware that fixes the Modbus slave address checks and rejects FC16
voltieMinFirmware = 350
// the charger's documented default slave address
voltieDefaultSlaveID = 11
// the gateway abandons a forwarded request after 3s, so the client must
// wait longer than that to receive the resulting exception
voltieTimeout = 5 * time.Second
// the gateway forwards at most two transactions per second
voltieDelay = 500 * time.Millisecond
)
// EVSE states, see the "EVSE states" chapter of the Modbus API documentation
const (
voltieStateA = 0x01 // not connected
voltieStateB = 0x02 // connected, ready
voltieStateC = 0x03 // charging
)
var voltieStates = map[uint16]string{
0x00: "state not yet determined",
0x01: "vehicle state A, not connected",
0x02: "vehicle state B, connected",
0x03: "vehicle state C, charging",
0x04: "vehicle state D, charging with ventilation",
0x05: "diode check failed",
0x06: "GFCI fault",
0x07: "bad ground",
0x08: "relay stuck",
0x09: "GFI self-test failure",
0x0A: "over temperature",
0x0B: "over current",
0x0C: "hardware fault (voltage, current or temperature sensor)",
0x0D: "vehicle state E, vehicle error",
0x0E: "over humidity",
0x0F: "input power phase misconnected",
0x10: "overvoltage on the grid",
0x11: "undervoltage on the grid",
0x12: "charger disabled, not functioning",
0x13: "booting",
0x14: "no MID meter detected",
0x15: "power board unidentified",
0x18: "state undetermined",
0x19: "uploading VoltieMeter firmware",
}
// charge stop reasons reported by the MCU, see the "EVSE charge stop reasons"
// chapter. Reasons 23..31 originate in the charger's control software and are
// not reported through Modbus.
var voltieStopReasons = map[uint16]string{
1: "unspecified reason",
2: "preset charge duration reached",
3: "preset energy amount charged",
4: "stopped by the user",
5: "GFCI sensor tripped",
6: "charger disabled, out of order",
7: "firmware restart",
8: "charger in sleep mode, out of order",
9: "no voltage on the output (ground continuity or relay error)",
10: "vehicle disconnected",
11: "vehicle not accepting charge",
12: "power board I2C bus fault",
13: "GFCI self test failed",
14: "over temperature",
15: "diode error",
16: "PE-N over-voltage",
17: "relay stuck",
18: "over current",
21: "over humidity",
22: "wrong phase order on the input",
100: "not enough free building current available (dynamic load management)",
101: "not enough solar current available (eco/green mode)",
102: "grid voltage is not high enough (grid-controlled mode)",
103: "charge current limit set to zero",
104: "no MID meter available",
105: "overvoltage",
106: "vehicle error",
107: "undervoltage",
108: "vehicle in state D while state D is disabled",
109: "power board unidentified",
}
// Voltie is an api.Charger implementation for Voltie wallboxes
type Voltie struct {
conn *modbus.Connection
conn *modbus.Connection
log *util.Logger
cache *modbus.Cache
status modbus.Block
meter modbus.Block
info modbus.Block
}
// read fetches a register block through the shared bulk read cache, so all
// values taken from the same block within a poll cycle cost one request
func (wb *Voltie) read(block modbus.Block) ([]byte, error) {
key := fmt.Sprintf("%s/holding/%d/%d", wb.conn.Addr(), block.Register, block.Count)
payload, _, err := wb.cache.Fetch(key, func() ([]byte, error) {
return wb.conn.ReadHoldingRegisters(block.Register, block.Count)
})
return payload, err
}
// voltieSerial decodes a 64 bit serial number, which is sent least-significant
// word first unlike the 32 bit metering values
func voltieSerial(b []byte, off int) uint64 {
var res uint64
for i := range voltieSerialRegCount {
res |= uint64(binary.BigEndian.Uint16(b[off+2*i:])) << (16 * i)
}
return res
}
// voltieU16 returns the register at addr within a cached block payload
func voltieU16(block modbus.Block, b []byte, addr uint16) uint16 {
return binary.BigEndian.Uint16(b[block.ByteOffset(addr):])
}
// voltieU32 returns the 32 bit value at addr within a cached block payload,
// most-significant word first
func voltieU32(block modbus.Block, b []byte, addr uint16) uint32 {
return binary.BigEndian.Uint32(b[block.ByteOffset(addr):])
}
func init() {
@ -61,19 +224,27 @@ func init() {
// NewVoltieFromConfig creates a Voltie charger from generic config
func NewVoltieFromConfig(ctx context.Context, other map[string]any) (api.Charger, error) {
cc := modbus.TcpSettings{
ID: 1,
cc := struct {
modbus.TcpSettings `mapstructure:",squash"`
Cache time.Duration
}{
TcpSettings: modbus.TcpSettings{
ID: voltieDefaultSlaveID,
Timeout: voltieTimeout,
Delay: voltieDelay,
},
Cache: time.Second,
}
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
return NewVoltie(ctx, cc)
return NewVoltie(ctx, cc.TcpSettings, cc.Cache)
}
// NewVoltie creates a Voltie charger
func NewVoltie(ctx context.Context, settings modbus.TcpSettings) (*Voltie, error) {
func NewVoltie(ctx context.Context, settings modbus.TcpSettings, cache time.Duration) (*Voltie, error) {
conn, err := settings.Connection(ctx)
if err != nil {
return nil, err
@ -87,55 +258,97 @@ func NewVoltie(ctx context.Context, settings modbus.TcpSettings) (*Voltie, error
conn.Logger(log.TRACE)
wb := &Voltie{
conn: conn,
conn: conn,
log: log,
cache: modbus.NewCache(cache),
info: modbus.Block{Register: voltieRegInfoBlock, Count: voltieLenInfoBlock},
status: modbus.Block{Register: voltieRegStatusBlock, Count: voltieLenStatusBlock},
meter: modbus.Block{Register: voltieRegMeterBlock, Count: voltieLenMeterBlock},
}
// Disable auto start
if _, err := wb.conn.WriteSingleRegister(voltieRegAutoStart, 0); err != nil {
if b, err := wb.read(wb.info); err == nil {
if fw := voltieU16(wb.info, b, voltieRegFirmware); fw < voltieMinFirmware {
log.WARN.Printf("firmware %d is outdated, Modbus TCP requires %d or later", fw, voltieMinFirmware)
}
}
if err := wb.checkSettings(); err != nil {
return nil, err
}
return wb, nil
}
// checkSettings inspects the charger's settings once on startup. The charger
// must not start a session on its own while evcc is in control, and its own
// load management would silently cap the current requested by evcc.
func (wb *Voltie) checkSettings() error {
b, err := wb.read(wb.status)
if err != nil {
return err
}
if dlm := voltieU16(wb.status, b, voltieRegDlmEffective); dlm != 0 {
wb.log.WARN.Printf("charger-side load management is active (mode %d) and will cap the requested current", dlm)
}
// the auto start setting is persisted in the charger's EEPROM and stays off
// after evcc is removed, so it is only written when actually enabled
if voltieU16(wb.status, b, voltieRegAutoStart) == 0 {
return nil
}
if _, err := wb.conn.WriteSingleRegister(voltieRegAutoStart, 0); err != nil {
return fmt.Errorf("disable auto start: %w (is Modbus control enabled on the charger?)", err)
}
wb.cache.Clear()
wb.log.WARN.Println("auto start disabled, the setting is persistent and must be restored in the Voltie app when evcc is removed")
return nil
}
// Status implements the api.Charger interface
func (wb *Voltie) Status() (api.ChargeStatus, error) {
b, err := wb.conn.ReadHoldingRegisters(voltieRegStatus, 1)
b, err := wb.read(wb.status)
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:
switch state := voltieU16(wb.status, b, voltieRegStatus); state {
case voltieStateA:
return api.StatusA, nil
case 0x02:
case voltieStateB:
return api.StatusB, nil
case 0x03, 0x04:
case voltieStateC:
return api.StatusC, nil
default:
return api.StatusNone, fmt.Errorf("invalid status: %0x", status)
// any other state, including D where the vehicle requires ventilation,
// is reported as an error together with the MCU's stop reason
desc, ok := voltieStates[state]
if !ok {
desc = "unknown state"
}
if reason := voltieU16(wb.status, b, voltieRegStopReason); reason != 0 {
if txt, ok := voltieStopReasons[reason]; ok {
return api.StatusNone, fmt.Errorf("%s (0x%02X): %s", desc, state, txt)
}
return api.StatusNone, fmt.Errorf("%s (0x%02X): stop reason %d", desc, state, reason)
}
return api.StatusNone, fmt.Errorf("%s (0x%02X)", desc, state)
}
}
// Enabled implements the api.Charger interface
func (wb *Voltie) Enabled() (bool, error) {
b, err := wb.conn.ReadHoldingRegisters(voltieRegChargingEnabled, 1)
b, err := wb.read(wb.status)
if err != nil {
return false, err
}
return binary.BigEndian.Uint16(b) != 0, nil
return voltieU16(wb.status, b, voltieRegChargeEnable) != 0, nil
}
// Enable implements the api.Charger interface
@ -145,7 +358,11 @@ func (wb *Voltie) Enable(enable bool) error {
u = 1
}
_, err := wb.conn.WriteSingleRegister(voltieRegChargingEnabled, u)
_, err := wb.conn.WriteSingleRegister(voltieRegChargeEnable, u)
if err == nil {
wb.cache.Clear()
}
return err
}
@ -158,91 +375,132 @@ var _ api.ChargerEx = (*Voltie)(nil)
// MaxCurrentMillis implements the api.ChargerEx interface
func (wb *Voltie) MaxCurrentMillis(current float64) error {
if current < 6 {
if current < voltieMinCurrent || current > voltieMaxCurrent {
return fmt.Errorf("invalid current %.1f", current)
}
_, err := wb.conn.WriteSingleRegister(voltieRegCurrentLimit, uint16(current*1000))
_, err := wb.conn.WriteSingleRegister(voltieRegCurrent, uint16(current*1e3))
if err == nil {
wb.cache.Clear()
}
return err
}
var _ api.CurrentGetter = (*Voltie)(nil)
// GetMaxCurrent implements the api.CurrentGetter interface
func (wb *Voltie) GetMaxCurrent() (float64, error) {
b, err := wb.read(wb.status)
if err != nil {
return 0, err
}
return float64(voltieU16(wb.status, b, voltieRegCurrent)) / 1e3, nil
}
var _ api.Meter = (*Voltie)(nil)
// CurrentPower implements the api.Meter interface
func (wb *Voltie) CurrentPower() (float64, error) {
b, err := wb.conn.ReadHoldingRegisters(voltieRegChargingPower, 2)
b, err := wb.read(wb.meter)
if err != nil {
return 0, err
}
return float64(binary.BigEndian.Uint32(b)), nil
return float64(int32(voltieU32(wb.meter, b, voltieRegPower))), 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)
b, err := wb.read(wb.meter)
if err != nil {
return 0, err
}
return float64(binary.BigEndian.Uint32(b)) / 3.6e6, nil // Ws to kWh
return float64(voltieU32(wb.meter, b, voltieRegEnergy)) / 3.6e6, nil // Ws to kWh
}
var _ api.ChargeTimer = (*Voltie)(nil)
// ChargeDuration implements the api.ChargeTimer interface
func (wb *Voltie) ChargeDuration() (time.Duration, error) {
b, err := wb.read(wb.meter)
if err != nil {
return 0, err
}
return time.Duration(voltieU32(wb.meter, b, voltieRegDuration)) * time.Second, nil
}
// getPhaseValues returns 3 sequential 32 bit values from the meter block, scaled from milli units
func (wb *Voltie) getPhaseValues(reg uint16) (float64, float64, float64, error) {
b, err := wb.read(wb.meter)
if err != nil {
return 0, 0, 0, err
}
var res [3]float64
for i := range res {
res[i] = float64(voltieU32(wb.meter, b, reg+uint16(2*i))) / 1e3
}
return res[0], res[1], res[2], nil
}
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
return wb.getPhaseValues(voltieRegCurrents)
}
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)
return wb.getPhaseValues(voltieRegVoltages)
}
var _ api.PhaseGetter = (*Voltie)(nil)
// GetPhases implements the api.PhaseGetter interface
func (wb *Voltie) GetPhases() (int, error) {
b, err := wb.read(wb.status)
if err != nil {
return 0, 0, 0, err
return 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
return int(voltieU16(wb.status, b, voltieRegPhases)), 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.read(wb.info); err == nil {
fmt.Printf("\tCharger ID:\t%d\n", voltieU16(wb.info, b, voltieRegChargerID))
fmt.Printf("\tFirmware:\t%d\n", voltieU16(wb.info, b, voltieRegFirmware))
fmt.Printf("\tMCU serial:\t%d\n", voltieSerial(b, wb.info.ByteOffset(voltieRegMcuSerial)))
fmt.Printf("\tPower serial:\t%d\n", voltieSerial(b, wb.info.ByteOffset(voltieRegHpowSerial)))
}
if b, err := wb.conn.ReadHoldingRegisters(voltieRegFirmware, 1); err == nil {
fmt.Printf("\tFirmware:\t%d\n", binary.BigEndian.Uint16(b))
if b, err := wb.read(wb.status); err == nil {
state := voltieU16(wb.status, b, voltieRegStatus)
fmt.Printf("\tStatus:\t\t0x%02X (%s)\n", state, voltieStates[state])
fmt.Printf("\tAuto start:\t%d\n", voltieU16(wb.status, b, voltieRegAutoStart))
fmt.Printf("\tCharging:\t%d\n", voltieU16(wb.status, b, voltieRegCharging))
fmt.Printf("\tPhases:\t\t%d\n", voltieU16(wb.status, b, voltieRegPhases))
fmt.Printf("\tDLM mode:\t%d set, %d effective\n", voltieU16(wb.status, b, voltieRegDlmSet), voltieU16(wb.status, b, voltieRegDlmEffective))
fmt.Printf("\tCurrent limit:\t%d mA\n", voltieU16(wb.status, b, voltieRegCurrent))
reason := voltieU16(wb.status, b, voltieRegStopReason)
fmt.Printf("\tStop reason:\t%d (%s)\n", reason, voltieStopReasons[reason])
}
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))
if b, err := wb.read(wb.meter); err == nil {
fmt.Printf("\tCapacity:\t%d mA\n", voltieU32(wb.meter, b, voltieRegCapacity))
}
}

View file

@ -5,11 +5,25 @@ products:
generic: Charger
capabilities: ["mA", "meter", "dim"]
requirements:
description:
de: Modbus muss in der Voltie App im Modbus-Konfigurationsbildschirm auf "TCP" gestellt werden; die dort eingestellte Slave-Adresse (Standard 11) muss mit der ID übereinstimmen. Der Kommunikations-Timeout regelt die Ladung nach Verbindungsverlust auf 0 A herunter, er muss daher auf 0 gesetzt (deaktiviert) oder größer als das evcc-Aktualisierungsintervall gewählt werden. Benötigt EVSE-Firmware 350 oder neuer und Ladegerät-Software 1.3.40 oder neuer.
en: Modbus must be set to "TCP" on the Modbus config screen of the Voltie app, and the slave address configured there (default 11) must match the ID. The communication timeout reduces the charging current to 0 A after a loss of communication, so it has to be set to 0 (disabled) or to a value larger than the evcc update interval. Requires EVSE firmware 350 or later and charger software 1.3.40 or later.
evcc: ["sponsorship"]
caveats:
- description:
de: Der Auto-Start des Ladegeräts wird beim Start von evcc dauerhaft deaktiviert und muss nach dem Entfernen von evcc in der Voltie App wieder eingeschaltet werden.
en: The charger's auto start mode is disabled persistently when evcc starts and has to be switched back on in the Voltie app after evcc is removed.
- description:
de: Das Modbus-TCP-Gateway leitet höchstens zwei Transaktionen pro Sekunde weiter. Kürzere Abfrageintervalle führen zu Zeitüberschreitungen, nicht zu aktuelleren Daten.
en: The Modbus TCP gateway forwards at most two transactions per second. Shorter polling intervals produce timeouts rather than fresher data.
params:
- name: modbus
choice: ["tcpip"]
id: 1
id: 11
- name: cache
advanced: true
default: 1s
render: |
type: voltie
{{- include "modbus" . }}
cache: {{ .cache }}