Add KEBA connect chargers
This commit is contained in:
parent
1729399ef9
commit
2045ce3bd7
6 changed files with 418 additions and 3 deletions
|
|
@ -7,8 +7,7 @@ EVCC is an extensible EV Charge Controller with PV integration implemented in [G
|
|||
## Features
|
||||
|
||||
- simple and clean user interface
|
||||
- multiple [chargers](#charger): Wallbe (tested with Wallbe Eco S), Phoenix controllers (EM-CP-PP-ETH), go-eCharger, openWB slave, Mobile Charger Connect (currently used by Porsche), any other charger using scripting
|
||||
- more chargers experimentally supported: NRGKick, SimpleEVSE, EVSEWifi
|
||||
- multiple [chargers](#charger): Wallbe (tested with Wallbe Eco S), Phoenix controllers (EM-CP-PP-ETH), go-eCharger, NRGKick, SimpleEVSE, EVSEWifi, KEBA KeContact and BMW, openWB slave, Mobile Charger Connect and any other charger using scripting
|
||||
- different [vehicles](#vehicle) to show battery status: Audi (eTron), BMW (i3), Tesla, Nissan (Leaf), any other vehicle using scripting
|
||||
- [plugins](#plugins) for integrating with hardware devices and home automation: Modbus (meters and grid inverters), MQTT and shell scripts
|
||||
- status notifications using [Telegram](https://telegram.org) and [PushOver](https://pushover.net)
|
||||
|
|
@ -134,6 +133,7 @@ Available charger implementations are:
|
|||
- `evsewifi`: chargers with SimpleEVSE controllers using [SimpleEVSE-Wifi](https://github.com/CurtRod/SimpleEVSE-WiFi)
|
||||
- `nrgkick`: NRGKick chargers with Connect module
|
||||
- `go-e`: go-eCharger chargers
|
||||
- `keba`: KEBA KeContact P20/P30 and BMW chargers (Note: requires enabled UDP function with DIP switch 1.3 = `ON`, see installation manual)
|
||||
- `mcc`: Mobile Charger Connect devices (Audi, Bentley, Porsche)
|
||||
- `default`: default charger implementation using configurable [plugins](#plugins) for integrating any type of charger
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ func NewFromConfig(log *util.Logger, typ string, other map[string]interface{}) a
|
|||
c = NewSimpleEVSEFromConfig(log, other)
|
||||
case "porsche", "audi", "bentley", "mcc":
|
||||
c = NewMobileConnectFromConfig(log, other)
|
||||
case "keba", "bmw":
|
||||
c = NewKebaFromConfig(log, other)
|
||||
default:
|
||||
log.FATAL.Fatalf("invalid charger type '%s'", typ)
|
||||
}
|
||||
|
|
|
|||
232
charger/keba.go
Normal file
232
charger/keba.go
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
package charger
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/andig/evcc/api"
|
||||
"github.com/andig/evcc/charger/keba"
|
||||
"github.com/andig/evcc/util"
|
||||
)
|
||||
|
||||
const (
|
||||
udpTimeout = time.Second
|
||||
kebaPort = "7090"
|
||||
)
|
||||
|
||||
// Keba is an api.Charger implementation with configurable getters and setters.
|
||||
type Keba struct {
|
||||
log *util.Logger
|
||||
conn string
|
||||
timeout time.Duration
|
||||
recv chan keba.UDPMsg
|
||||
}
|
||||
|
||||
// NewKebaFromConfig creates a new configurable charger
|
||||
func NewKebaFromConfig(log *util.Logger, other map[string]interface{}) api.Charger {
|
||||
cc := struct {
|
||||
URI string
|
||||
Timeout time.Duration
|
||||
}{}
|
||||
util.DecodeOther(log, other, &cc)
|
||||
|
||||
return NewKeba(cc.URI, cc.Timeout)
|
||||
}
|
||||
|
||||
// NewKeba creates a new charger
|
||||
func NewKeba(conn string, timeout time.Duration) api.Charger {
|
||||
log := util.NewLogger("keba")
|
||||
|
||||
if keba.Instance == nil {
|
||||
keba.Instance = keba.New(log, fmt.Sprintf(":%s", kebaPort))
|
||||
}
|
||||
|
||||
// add default port
|
||||
if _, _, err := net.SplitHostPort(conn); err != nil {
|
||||
conn = fmt.Sprintf("%s:%s", conn, kebaPort)
|
||||
}
|
||||
|
||||
if timeout == 0 {
|
||||
timeout = udpTimeout
|
||||
}
|
||||
|
||||
c := &Keba{
|
||||
log: log,
|
||||
conn: conn,
|
||||
timeout: timeout,
|
||||
recv: make(chan keba.UDPMsg),
|
||||
}
|
||||
|
||||
keba.Instance.Subscribe(conn, c.recv)
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Keba) send(msg string) error {
|
||||
raddr, err := net.ResolveUDPAddr("udp", c.conn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
conn, err := net.DialUDP("udp", nil, raddr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer conn.Close()
|
||||
|
||||
_, err = io.Copy(conn, strings.NewReader(msg))
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Keba) receive(report int, resC chan<- keba.UDPMsg, errC chan<- error) {
|
||||
t := time.NewTimer(c.timeout)
|
||||
for {
|
||||
select {
|
||||
case msg := <-c.recv:
|
||||
// matching result message
|
||||
if msg.Report == nil && report == 0 {
|
||||
resC <- msg
|
||||
return
|
||||
}
|
||||
// matching report result
|
||||
if msg.Report != nil && report == msg.Report.ID {
|
||||
resC <- msg
|
||||
return
|
||||
}
|
||||
case <-t.C:
|
||||
errC <- errors.New("recv timeout")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Keba) roundtrip(msg string, report int, res interface{}) error {
|
||||
resC := make(chan keba.UDPMsg)
|
||||
errC := make(chan error)
|
||||
|
||||
defer func() {
|
||||
close(resC)
|
||||
close(errC)
|
||||
}()
|
||||
|
||||
go c.receive(report, resC, errC)
|
||||
|
||||
if err := c.send(msg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
select {
|
||||
case resp := <-resC:
|
||||
if report == 0 {
|
||||
// use reflection to write to simple string
|
||||
rv := reflect.ValueOf(res)
|
||||
if rv.Kind() != reflect.Ptr || rv.IsNil() || rv.Elem().Kind() != reflect.String {
|
||||
return fmt.Errorf("invalid type: %s", reflect.TypeOf(res))
|
||||
}
|
||||
|
||||
rv.Elem().SetString(string(resp.Message))
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(resp.Message, &res)
|
||||
case err := <-errC:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Status implements the Charger.Status interface
|
||||
func (c *Keba) Status() (api.ChargeStatus, error) {
|
||||
var kr keba.Report2
|
||||
err := c.roundtrip("report 2", 2, &kr)
|
||||
if err != nil {
|
||||
return api.StatusA, err
|
||||
}
|
||||
|
||||
if kr.Plug == 0 {
|
||||
return api.StatusA, nil
|
||||
}
|
||||
if kr.State == 2 {
|
||||
return api.StatusB, nil
|
||||
}
|
||||
if kr.State == 3 {
|
||||
return api.StatusC, nil
|
||||
}
|
||||
|
||||
return api.StatusA, fmt.Errorf("unexpected status: %v", kr)
|
||||
}
|
||||
|
||||
// Enabled implements the Charger.Enabled interface
|
||||
func (c *Keba) Enabled() (bool, error) {
|
||||
var kr keba.Report2
|
||||
err := c.roundtrip("report 2", 2, &kr)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return kr.EnableSys == 1 || kr.EnableUser == 1, nil
|
||||
}
|
||||
|
||||
// Enable implements the Charger.Enable interface
|
||||
func (c *Keba) Enable(enable bool) error {
|
||||
var d int
|
||||
if enable {
|
||||
d = 1
|
||||
}
|
||||
|
||||
var resp string
|
||||
err := c.roundtrip(fmt.Sprintf("ena %d", d), 0, &resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if string(resp) == keba.OK {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("ena unexpected response: %s", resp)
|
||||
}
|
||||
|
||||
// MaxCurrent implements the Charger.MaxCurrent interface
|
||||
func (c *Keba) MaxCurrent(current int64) error {
|
||||
var resp string
|
||||
err := c.roundtrip(fmt.Sprintf("curr %d", 1000*current), 0, &resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if resp == keba.OK {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("curr unexpected response: %s", resp)
|
||||
}
|
||||
|
||||
// CurrentPower implements the Meter interface.
|
||||
func (c *Keba) CurrentPower() (float64, error) {
|
||||
var kr keba.Report3
|
||||
err := c.roundtrip("report 3", 3, &kr)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// mW to W
|
||||
return float64(kr.P) / 1e3, nil
|
||||
}
|
||||
|
||||
// ChargedEnergy implements the ChargeRater interface.
|
||||
func (c *Keba) ChargedEnergy() (float64, error) {
|
||||
var kr keba.Report3
|
||||
err := c.roundtrip("report 3", 3, &kr)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// 0,1Wh to kWh
|
||||
return float64(kr.EPres) / 1e5, nil
|
||||
}
|
||||
118
charger/keba/listener.go
Normal file
118
charger/keba/listener.go
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
package keba
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/andig/evcc/util"
|
||||
)
|
||||
|
||||
const (
|
||||
udpBufferSize = 1024
|
||||
|
||||
// OK is the KEBA confirmation message
|
||||
OK = "TCH-OK :done"
|
||||
)
|
||||
|
||||
// Instance is the KEBA listener instance
|
||||
// This is needed since KEBAs ignore the sender port and always UDP back to port 7090
|
||||
var Instance *Listener
|
||||
|
||||
// UDPMsg transports the KEBA response. Report is any of Report1,2,3
|
||||
type UDPMsg struct {
|
||||
Addr string
|
||||
Message []byte
|
||||
Report *Report
|
||||
}
|
||||
|
||||
// Listener singleton listens for KEBA UDP messages
|
||||
type Listener struct {
|
||||
mux sync.Mutex
|
||||
log *util.Logger
|
||||
conn *net.UDPConn
|
||||
clients map[string]chan<- UDPMsg
|
||||
}
|
||||
|
||||
// New creates a UDP listener that clients can subscribe to
|
||||
func New(log *util.Logger, addr string) *Listener {
|
||||
laddr, err := net.ResolveUDPAddr("udp", addr)
|
||||
if err != nil {
|
||||
log.FATAL.Fatal(err)
|
||||
}
|
||||
|
||||
conn, err := net.ListenUDP("udp", laddr)
|
||||
if err != nil {
|
||||
log.FATAL.Fatal(err)
|
||||
}
|
||||
|
||||
l := &Listener{
|
||||
log: log,
|
||||
conn: conn,
|
||||
}
|
||||
|
||||
go l.listen()
|
||||
|
||||
return l
|
||||
}
|
||||
|
||||
// Subscribe adds a client address and message channel
|
||||
func (l *Listener) Subscribe(addr string, c chan<- UDPMsg) {
|
||||
l.mux.Lock()
|
||||
defer l.mux.Unlock()
|
||||
|
||||
if l.clients == nil {
|
||||
l.clients = make(map[string]chan<- UDPMsg)
|
||||
}
|
||||
|
||||
l.clients[addr] = c
|
||||
}
|
||||
|
||||
func (l *Listener) listen() {
|
||||
b := make([]byte, udpBufferSize)
|
||||
|
||||
for {
|
||||
read, addr, err := l.conn.ReadFrom(b)
|
||||
if err != nil {
|
||||
l.log.WARN.Printf("listener: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
body := strings.TrimSpace(string(b[:read]))
|
||||
l.log.TRACE.Printf("recv from %s %v", addr.String(), body)
|
||||
|
||||
msg := UDPMsg{
|
||||
Addr: addr.String(),
|
||||
Message: []byte(body),
|
||||
}
|
||||
|
||||
if body != OK {
|
||||
var report Report
|
||||
if err := json.Unmarshal([]byte(body), &report); err != nil {
|
||||
l.log.WARN.Printf("listener: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
msg.Report = &report
|
||||
}
|
||||
|
||||
l.send(msg)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Listener) send(msg UDPMsg) {
|
||||
l.mux.Lock()
|
||||
defer l.mux.Unlock()
|
||||
|
||||
for addr, client := range l.clients {
|
||||
if addr == msg.Addr {
|
||||
select {
|
||||
case client <- msg:
|
||||
default:
|
||||
l.log.TRACE.Println("listener: recv blocked")
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
60
charger/keba/reports.go
Normal file
60
charger/keba/reports.go
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
package keba
|
||||
|
||||
// Report contains report id and device serial
|
||||
type Report struct {
|
||||
ID int `json:"ID,string"`
|
||||
Serial string `json:"Serial"`
|
||||
}
|
||||
|
||||
// Report1 is the report 1 command answer
|
||||
type Report1 struct {
|
||||
ID int `json:"ID,string"`
|
||||
Serial string `json:"Serial"`
|
||||
Product string `json:"Product"`
|
||||
Firmware string `json:"Firmware"`
|
||||
COMModule int `json:"COM-module"`
|
||||
Sec int64 `json:"Sec"`
|
||||
}
|
||||
|
||||
// Report2 is the report 2 command answer
|
||||
type Report2 struct {
|
||||
ID int `json:"ID,string"`
|
||||
Serial string `json:"Serial"`
|
||||
State int `json:"State"`
|
||||
Error1 int `json:"Error1"`
|
||||
Error2 int `json:"Error2"`
|
||||
Plug int `json:"Plug"`
|
||||
AuthON int `json:"AuthON"`
|
||||
AuthReq int `json:"Authreq"`
|
||||
EnableSys int `json:"Enable sys"`
|
||||
EnableUser int `json:"Enable user"`
|
||||
MaxCurr int `json:"Max curr"`
|
||||
MaxCurrPercent int `json:"Max curr %"`
|
||||
CurrHW int `json:"Curr HW"`
|
||||
Curruser int `json:"Curr user"`
|
||||
CurrFS int `json:"Curr FS"`
|
||||
TmoFS int `json:"Tmo FS"`
|
||||
CurrTimer int `json:"Curr timer"`
|
||||
TmoCT int `json:"Tmo CT"`
|
||||
SetEnergy int `json:"Setenergy"`
|
||||
Output int `json:"Output"`
|
||||
Input int `json:"Input"`
|
||||
Sec int64 `json:"Sec"`
|
||||
}
|
||||
|
||||
// Report3 is the report 3 command answer
|
||||
type Report3 struct {
|
||||
ID int `json:"ID,string"`
|
||||
Serial string `json:"Serial"`
|
||||
U1 int64 `json:"U1"`
|
||||
U2 int64 `json:"U2"`
|
||||
U3 int64 `json:"U3"`
|
||||
I1 int64 `json:"I1"`
|
||||
I2 int64 `json:"I2"`
|
||||
I3 int64 `json:"I3"`
|
||||
P int64 `json:"P"`
|
||||
PF int64 `json:"PF"`
|
||||
EPres int64 `json:"E pres"`
|
||||
ETotal int64 `json:"E total"`
|
||||
Sec int64 `json:"Sec"`
|
||||
}
|
||||
|
|
@ -91,8 +91,11 @@ chargers:
|
|||
macaddress: 00:99:22 # MAC address
|
||||
password: # password
|
||||
- name: go-e
|
||||
type: go-e # go-e charger
|
||||
type: go-e # go-eCharger
|
||||
uri: http://192.168.1.4 # go-e address
|
||||
- name: keba
|
||||
type: keba # KEBA charger
|
||||
uri: 192.168.1.4:7090 # KEBA address
|
||||
- name: mcc
|
||||
type: mcc # Mobile Charger Connect (Audi, Bentley, Porsche)
|
||||
uri: https://192.168.1.4 # Mobile Charger Connect address
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue