Skip to content

Commit c0d501c

Browse files
authored
Add Enovates charger (#30477)
1 parent 9bf76fc commit c0d501c

2 files changed

Lines changed: 298 additions & 0 deletions

File tree

charger/enovates.go

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
package charger
2+
3+
// LICENSE
4+
5+
// Copyright (c) evcc.io (andig, naltatis, premultiply)
6+
7+
// This module is NOT covered by the MIT license. All rights reserved.
8+
9+
// The above copyright notice and this permission notice shall be included in all
10+
// copies or substantial portions of the Software.
11+
12+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
13+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
14+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
15+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
16+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
17+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
18+
// SOFTWARE.
19+
20+
import (
21+
"context"
22+
"encoding/binary"
23+
"fmt"
24+
"strings"
25+
26+
"github.com/evcc-io/evcc/api"
27+
"github.com/evcc-io/evcc/util"
28+
"github.com/evcc-io/evcc/util/modbus"
29+
"github.com/evcc-io/evcc/util/sponsor"
30+
"github.com/volkszaehler/mbmd/meters/rs485"
31+
)
32+
33+
// https://github.com/enovates/enovates-modbus
34+
// EMS over Modbus TCP (ModBusTCPEMSEnabled) must be enabled on the charger.
35+
36+
// Enovates charger implementation
37+
type Enovates struct {
38+
log *util.Logger
39+
conn *modbus.Connection
40+
current uint16 // offered EMS limit in mA
41+
}
42+
43+
const (
44+
enovatesRegMaxAmp = 51 // uint16 RO, max amp per phase
45+
enovatesRegCurrents = 200 // 3x uint16 RO, mA
46+
enovatesRegVoltages = 203 // 3x int16 RO, V
47+
enovatesRegPower = 206 // int16 RO, W (active power total)
48+
enovatesRegEnergy = 216 // int32 RO, Wh (active energy import total)
49+
enovatesRegMode3StateStr = 301 // 2x register RO, IEC 61851 state, e.g. "C2"
50+
enovatesRegEMSLimit = 400 // int16 RW, mA (-1 = no limit, 0 = disabled)
51+
enovatesRegToken = 401 // 16x register RO, ASCII
52+
enovatesRegCurrentOffered = 417 // uint16 RO, mA
53+
enovatesRegSerial = 5032 // 16x register RO, ASCII
54+
enovatesRegModel = 5048 // 16x register RO, ASCII
55+
enovatesRegFirmware = 5064 // 16x register RO, ASCII
56+
)
57+
58+
func init() {
59+
registry.AddCtx("enovates", NewEnovatesFromConfig)
60+
}
61+
62+
// NewEnovatesFromConfig creates an Enovates charger from generic config
63+
func NewEnovatesFromConfig(ctx context.Context, other map[string]any) (api.Charger, error) {
64+
cc := modbus.TcpSettings{
65+
ID: 1,
66+
}
67+
68+
if err := util.DecodeOther(other, &cc); err != nil {
69+
return nil, err
70+
}
71+
72+
return NewEnovates(ctx, cc.URI, cc.ID)
73+
}
74+
75+
// NewEnovates creates an Enovates charger
76+
func NewEnovates(ctx context.Context, uri string, slaveID uint8) (api.Charger, error) {
77+
uri = util.DefaultPort(uri, 502)
78+
79+
conn, err := modbus.NewConnection(ctx, uri, "", "", 0, modbus.Tcp, slaveID)
80+
if err != nil {
81+
return nil, err
82+
}
83+
84+
if !sponsor.IsAuthorized() {
85+
return nil, api.ErrSponsorRequired
86+
}
87+
88+
log := util.NewLogger("enovates")
89+
conn.Logger(log.TRACE)
90+
91+
wb := &Enovates{
92+
log: log,
93+
conn: conn,
94+
current: 6000,
95+
}
96+
97+
// verify connection and EMS register availability
98+
if _, err := wb.conn.ReadHoldingRegisters(enovatesRegMode3StateStr, 2); err != nil {
99+
return nil, err
100+
}
101+
102+
return wb, nil
103+
}
104+
105+
// Status implements the api.Charger interface
106+
func (wb *Enovates) Status() (api.ChargeStatus, error) {
107+
b, err := wb.conn.ReadHoldingRegisters(enovatesRegMode3StateStr, 2)
108+
if err != nil {
109+
return api.StatusNone, err
110+
}
111+
112+
return api.ChargeStatusString(string(b))
113+
}
114+
115+
// Enabled implements the api.Charger interface
116+
func (wb *Enovates) Enabled() (bool, error) {
117+
b, err := wb.conn.ReadHoldingRegisters(enovatesRegEMSLimit, 1)
118+
if err != nil {
119+
return false, err
120+
}
121+
122+
// 0 = charging disabled, anything else (positive limit or -1 = no limit) = enabled
123+
return int16(binary.BigEndian.Uint16(b)) != 0, nil
124+
}
125+
126+
// Enable implements the api.Charger interface
127+
func (wb *Enovates) Enable(enable bool) error {
128+
var curr uint16
129+
if enable {
130+
curr = wb.current
131+
}
132+
133+
return wb.setCurrent(curr)
134+
}
135+
136+
// setCurrent writes the EMS limit in mA without modifying the stored current value
137+
func (wb *Enovates) setCurrent(current uint16) error {
138+
_, err := wb.conn.WriteSingleRegister(enovatesRegEMSLimit, current)
139+
return err
140+
}
141+
142+
// MaxCurrent implements the api.Charger interface
143+
func (wb *Enovates) MaxCurrent(current int64) error {
144+
return wb.MaxCurrentMillis(float64(current))
145+
}
146+
147+
var _ api.ChargerEx = (*Enovates)(nil)
148+
149+
// MaxCurrentMillis implements the api.ChargerEx interface
150+
func (wb *Enovates) MaxCurrentMillis(current float64) error {
151+
curr := uint16(current * 1e3)
152+
153+
err := wb.setCurrent(curr)
154+
if err == nil {
155+
wb.current = curr
156+
}
157+
158+
return err
159+
}
160+
161+
var _ api.CurrentGetter = (*Enovates)(nil)
162+
163+
// GetMaxCurrent implements the api.CurrentGetter interface
164+
func (wb *Enovates) GetMaxCurrent() (float64, error) {
165+
b, err := wb.conn.ReadHoldingRegisters(enovatesRegEMSLimit, 1)
166+
if err != nil {
167+
return 0, err
168+
}
169+
170+
// -1 signals no EMS limit, fall back to the hardware maximum
171+
if limit := int16(binary.BigEndian.Uint16(b)); limit >= 0 {
172+
return float64(limit) / 1e3, nil
173+
}
174+
175+
b, err = wb.conn.ReadHoldingRegisters(enovatesRegMaxAmp, 1)
176+
if err != nil {
177+
return 0, err
178+
}
179+
180+
return rs485.RTUUint16ToFloat64(b), nil
181+
}
182+
183+
var _ api.Meter = (*Enovates)(nil)
184+
185+
// CurrentPower implements the api.Meter interface
186+
func (wb *Enovates) CurrentPower() (float64, error) {
187+
b, err := wb.conn.ReadHoldingRegisters(enovatesRegPower, 1)
188+
if err != nil {
189+
return 0, err
190+
}
191+
192+
return rs485.RTUInt16ToFloat64(b), nil
193+
}
194+
195+
var _ api.MeterEnergy = (*Enovates)(nil)
196+
197+
// TotalEnergy implements the api.MeterEnergy interface
198+
func (wb *Enovates) TotalEnergy() (float64, error) {
199+
b, err := wb.conn.ReadHoldingRegisters(enovatesRegEnergy, 2)
200+
if err != nil {
201+
return 0, err
202+
}
203+
204+
return rs485.RTUInt32ToFloat64(b) / 1e3, nil
205+
}
206+
207+
var _ api.PhaseCurrents = (*Enovates)(nil)
208+
209+
// Currents implements the api.PhaseCurrents interface
210+
func (wb *Enovates) Currents() (float64, float64, float64, error) {
211+
b, err := wb.conn.ReadHoldingRegisters(enovatesRegCurrents, 3)
212+
if err != nil {
213+
return 0, 0, 0, err
214+
}
215+
216+
return float64(binary.BigEndian.Uint16(b[0:2])) / 1e3,
217+
float64(binary.BigEndian.Uint16(b[2:4])) / 1e3,
218+
float64(binary.BigEndian.Uint16(b[4:6])) / 1e3, nil
219+
}
220+
221+
var _ api.PhaseVoltages = (*Enovates)(nil)
222+
223+
// Voltages implements the api.PhaseVoltages interface
224+
func (wb *Enovates) Voltages() (float64, float64, float64, error) {
225+
b, err := wb.conn.ReadHoldingRegisters(enovatesRegVoltages, 3)
226+
if err != nil {
227+
return 0, 0, 0, err
228+
}
229+
230+
return float64(int16(binary.BigEndian.Uint16(b[0:2]))),
231+
float64(int16(binary.BigEndian.Uint16(b[2:4]))),
232+
float64(int16(binary.BigEndian.Uint16(b[4:6]))), nil
233+
}
234+
235+
var _ api.Identifier = (*Enovates)(nil)
236+
237+
// Identify implements the api.Identifier interface
238+
func (wb *Enovates) Identify() (string, error) {
239+
b, err := wb.conn.ReadHoldingRegisters(enovatesRegToken, 16)
240+
if err != nil {
241+
return "", err
242+
}
243+
244+
return trimModbusString(b), nil
245+
}
246+
247+
var _ api.Diagnosis = (*Enovates)(nil)
248+
249+
// Diagnose implements the api.Diagnosis interface
250+
func (wb *Enovates) Diagnose() {
251+
if b, err := wb.conn.ReadHoldingRegisters(enovatesRegSerial, 16); err == nil {
252+
fmt.Printf("\tSerial:\t%s\n", trimModbusString(b))
253+
}
254+
if b, err := wb.conn.ReadHoldingRegisters(enovatesRegModel, 16); err == nil {
255+
fmt.Printf("\tModel:\t%s\n", trimModbusString(b))
256+
}
257+
if b, err := wb.conn.ReadHoldingRegisters(enovatesRegFirmware, 16); err == nil {
258+
fmt.Printf("\tFirmware:\t%s\n", trimModbusString(b))
259+
}
260+
if b, err := wb.conn.ReadHoldingRegisters(enovatesRegEMSLimit, 1); err == nil {
261+
fmt.Printf("\tEMS Limit:\t%dmA\n", int16(binary.BigEndian.Uint16(b)))
262+
}
263+
if b, err := wb.conn.ReadHoldingRegisters(enovatesRegCurrentOffered, 1); err == nil {
264+
fmt.Printf("\tCurrent Offered:\t%dmA\n", binary.BigEndian.Uint16(b))
265+
}
266+
}
267+
268+
// trimModbusString decodes a NUL-padded ASCII modbus string
269+
func trimModbusString(b []byte) string {
270+
return strings.TrimRight(string(b), "\x00")
271+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
template: enovates
2+
products:
3+
- brand: Enovates
4+
description:
5+
generic: ENO
6+
- brand: Enovates
7+
description:
8+
generic: SingleWallBox
9+
- brand: Shell Recharge
10+
description:
11+
generic: Home Advanced
12+
- brand: NewMotion
13+
description:
14+
generic: Lolo
15+
capabilities: ["mA", "meter"]
16+
requirements:
17+
description:
18+
de: "EMS über Modbus TCP muss auf der Ladestation aktiviert sein (ModBusTCPEMSEnabled und modbus.tcp.enabled = true)."
19+
en: "EMS over Modbus TCP must be enabled on the charger (ModBusTCPEMSEnabled and modbus.tcp.enabled = true)."
20+
evcc: ["sponsorship"]
21+
params:
22+
- name: modbus
23+
choice: ["tcpip"]
24+
id: 1
25+
render: |
26+
type: enovates
27+
{{- include "modbus" . }}

0 commit comments

Comments
 (0)