diff --git a/README.md b/README.md index 3edfe48d4..4c380527b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/charger/config.go b/charger/config.go index 349399781..4b3863601 100644 --- a/charger/config.go +++ b/charger/config.go @@ -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) } diff --git a/charger/keba.go b/charger/keba.go new file mode 100644 index 000000000..44b32d9c1 --- /dev/null +++ b/charger/keba.go @@ -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 +} diff --git a/charger/keba/listener.go b/charger/keba/listener.go new file mode 100644 index 000000000..ce11c2769 --- /dev/null +++ b/charger/keba/listener.go @@ -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 + } + } +} diff --git a/charger/keba/reports.go b/charger/keba/reports.go new file mode 100644 index 000000000..50ed4aeb3 --- /dev/null +++ b/charger/keba/reports.go @@ -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"` +} diff --git a/evcc.dist.yaml b/evcc.dist.yaml index e11011a77..010cb60ee 100644 --- a/evcc.dist.yaml +++ b/evcc.dist.yaml @@ -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