Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Mobile Charger Connect #40

Merged
merged 18 commits into from
Apr 19, 2020
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +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 (similar to Wallbe), go-eCharger, openWB slave, any other charger using scripting
- multiple [chargers](#charger): Wallbe (tested with Wallbe Eco S), Phoenix controllers (similar to Wallbe), go-eCharger, openWB slave, Mobile Charger Connect (currently used by Porsche), any other charger using scripting
- more chargers experimentally supported: NRGKick, SimpleEVSE, EVSEWifi
- different [vehicles](#vehicle) to show battery status: Audi (eTron), BMW (i3), Tesla, Nissan (Leaf), any other vehicle using scripting
- integration with home automation - supports shell scripts and MQTT
Expand Down Expand Up @@ -115,6 +115,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
- `mcc`: Mobile Charger Connect devices (Audi, Bentley, Porsche)
- `default`: default charger implementation using configurable [plugins](#plugins) for integrating any type of charger

#### Wallbe Hardware Preparation
Expand Down
7 changes: 7 additions & 0 deletions api/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ func (r *HTTPHelper) decodeJSON(resp *http.Response, err error, res interface{})
return b, err
}

// Request executes HTTP request returns the response body
// This variant uses the http method set on the request
DerAndereAndi marked this conversation as resolved.
Show resolved Hide resolved
func (r *HTTPHelper) Request(req *http.Request) ([]byte, error) {
resp, err := r.Client.Do(req)
return r.readBody(resp, err)
}

// Get executes HTTP GET request returns the response body
func (r *HTTPHelper) Get(url string) ([]byte, error) {
resp, err := r.Client.Get(url)
Expand Down
2 changes: 2 additions & 0 deletions charger/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ func NewFromConfig(log *api.Logger, typ string, other map[string]interface{}) ap
c = NewEVSEWifiFromConfig(log, other)
case "simpleevse", "evse":
c = NewSimpleEVSEFromConfig(log, other)
case "porsche", "audi", "bentley", "mcc":
c = NewMobileConnectFromConfig(log, other)
andig marked this conversation as resolved.
Show resolved Hide resolved
default:
log.FATAL.Fatalf("invalid charger type '%s'", typ)
}
Expand Down
350 changes: 350 additions & 0 deletions charger/mcc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,350 @@
package charger

import (
"crypto/tls"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"

"github.com/andig/evcc/api"
)

const (
apiLogin apiFunction = "jwt/login"
apiRefresh apiFunction = "jwt/refresh"
apiChargeState apiFunction = "v1/api/WebServer/properties/chargeState"
apiCurrentSession apiFunction = "v1/api/WebServer/properties/swaggerCurrentSession"
apiEnergy apiFunction = "v1/api/iCAN/properties/propjIcanEnergy"
apiSetCurrentLimit apiFunction = "v1/api/SCC/properties/propHMICurrentLimit?value="
apiCurrentCableInformation apiFunction = "v1/api/SCC/properties/json_CurrentCableInformation"
)

// MCCErrorResponse is the API response if status not OK
type MCCErrorResponse struct {
Error string
}

// MCCTokenResponse is the apiLogin response
type MCCTokenResponse struct {
Token string
}

// MCCCurrentSession is the apiCurrentSession response
type MCCCurrentSession struct {
Duration time.Duration
EnergySumKwh float64
}

// MCCEnergyPhase is the apiEnergy response for a single phase
type MCCEnergyPhase struct {
Power float64
}

// MCCEnergy is the apiEnergy response
type MCCEnergy struct {
L1, L2, L3 MCCEnergyPhase
}

// MCCCurrentCableInformation is the apiCurrentCableInformation response
type MCCCurrentCableInformation struct {
MaxValue, MinValue, Value int64
}

// MCC charger implementation for supporting Mobile Charger Connect devices from Audi, Bentley, Porsche
type MobileConnect struct {
*api.HTTPHelper
uri string
password string
token string
tokenValid time.Time
tokenRefresh time.Time
cableInformation MCCCurrentCableInformation
}

// NewMobileConnectFromConfig creates a MCC charger from generic config
func NewMobileConnectFromConfig(log *api.Logger, other map[string]interface{}) api.Charger {
cc := struct{ URI, Password string }{}
api.DecodeOther(log, other, &cc)

return NewMobileConnect(cc.URI, cc.Password)
}

// NewMobileConnect creates MCC charger
func NewMobileConnect(uri string, password string) *MobileConnect {
mcc := &MobileConnect{
HTTPHelper: api.NewHTTPHelper(api.NewLogger("mcc ")),
uri: strings.TrimRight(uri, "/"),
password: password,
}

// ignore the self signed certificate
customTransport := http.DefaultTransport.(*http.Transport).Clone()
customTransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
andig marked this conversation as resolved.
Show resolved Hide resolved

mcc.HTTPHelper.Client.Transport = customTransport
andig marked this conversation as resolved.
Show resolved Hide resolved

return mcc
}

// construct the URL for a given apiFunction
func (mcc *MobileConnect) apiURL(api apiFunction) string {
return fmt.Sprintf("%s/%s", mcc.uri, api)
}

// proces the http request to fetch the auth token for a login or refresh request
DerAndereAndi marked this conversation as resolved.
Show resolved Hide resolved
func (mcc *MobileConnect) fetchToken(request *http.Request) error {
var tr MCCTokenResponse
b, err := mcc.RequestJSON(request, &tr)
if err == nil {
if len(tr.Token) == 0 && len(b) > 0 {
var error MCCErrorResponse

if err := json.Unmarshal(b, &error); err != nil {
return err
}

return fmt.Errorf("response: %s", error.Error)
}

mcc.token = tr.Token
// According to the Web Interface, the token is valid for 10 minutes
mcc.tokenValid = time.Now().Add(10 * time.Minute)

// the web interface updates the token every 2 minutes, so lets do the same here
mcc.tokenRefresh = time.Now().Add(2 * time.Minute)
}

return err
}

// login as the home user with the given password
func (mcc *MobileConnect) login(password string) error {
uri := fmt.Sprintf("%s/%s", mcc.uri, apiLogin)

data := url.Values{
"user": []string{"user"},
andig marked this conversation as resolved.
Show resolved Hide resolved
"pass": []string{mcc.password},
}

req, err := http.NewRequest(http.MethodPost, uri, strings.NewReader(data.Encode()))
if err != nil {
return err
}

req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

return mcc.fetchToken(req)
}

// refresh the auth token with a new one
func (mcc *MobileConnect) refresh() error {
uri := fmt.Sprintf("%s/%s", mcc.uri, apiRefresh)

req, err := http.NewRequest(http.MethodGet, uri, nil)
if err != nil {
return err
}

req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", mcc.token))

return mcc.fetchToken(req)
}

// creates a http request that contains the auth token
func (mcc *MobileConnect) request(method, uri string) (*http.Request, error) {
// do we need to login?
andig marked this conversation as resolved.
Show resolved Hide resolved
if mcc.token == "" || time.Since(mcc.tokenValid) > 0 {
if err := mcc.login(mcc.password); err != nil {
return nil, err
}
}

// is it time to refresh the token?
if time.Since(mcc.tokenRefresh) > 0 {
if err := mcc.refresh(); err != nil {
return nil, err
}
}

// now lets process the request with the fetched token
req, err := http.NewRequest(method, uri, nil)
if err != nil {
return req, err
}

req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", mcc.token))

return req, nil
}

// use http GET to fetch a non structured value from an URI and stores it in result
func (mcc *MobileConnect) getValue(uri string) ([]byte, error) {
andig marked this conversation as resolved.
Show resolved Hide resolved
req, err := mcc.request(http.MethodGet, uri)
if err != nil {
return nil, err
}

return mcc.Request(req)
}

// use http GET to fetch an escaped JSON string and unmarshall the data in result
func (mcc *MobileConnect) getEscapedJSON(uri string, result interface{}) error {
req, err := mcc.request(http.MethodGet, uri)
if err != nil {
return err
}

b, err := mcc.Request(req)
if err == nil {
s, err := strconv.Unquote(strings.Trim(string(b), "\n"))
DerAndereAndi marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
return err
}
if err := json.Unmarshal([]byte(s), &result); err != nil {
return err
}
}

return err
}

// Status implements the Charger.Status interface
func (mcc *MobileConnect) Status() (api.ChargeStatus, error) {
b, err := mcc.getValue(mcc.apiURL(apiChargeState))
if err != nil {
return api.StatusNone, err
}

chargeState, err := strconv.ParseInt(strings.Trim(string(b), "\n"), 10, 8)
if err != nil {
return api.StatusNone, err
}

switch chargeState {
case 0:
// 0: Unplugged StatusA
DerAndereAndi marked this conversation as resolved.
Show resolved Hide resolved
return api.StatusA, nil
case 1:
// 1: Connecting StatusB
return api.StatusB, nil
case 2:
// 2: Error StatusE
return api.StatusE, nil
case 3:
// 3: Established StatusF
return api.StatusF, nil
case 4, 6:
// 4: Paused StatusD
// 6: Finished StatusD
return api.StatusD, nil
case 5:
// 5: Active StatusC
return api.StatusC, nil
default:
return api.StatusNone, fmt.Errorf("properties unknown result: %d", chargeState)
}
}

// Enabled implements the Charger.Enabled interface
func (mcc *MobileConnect) Enabled() (bool, error) {
// Check if the car is connected and Paused, Active, or Finished
b, err := mcc.getValue(mcc.apiURL(apiChargeState))
if err != nil {
return false, err
}

// return value is returned in the format 0\n
chargeState, err := strconv.ParseInt(strings.Trim(string(b), "\n"), 10, 8)
if err != nil {
return false, err
}

if chargeState >= 4 && chargeState <= 6 {
return true, nil
}

return false, nil
}

// Enable implements the Charger.Enable interface
func (mcc *MobileConnect) Enable(enable bool) error {
// As we don't know of the API to disable charging this for now always returns an error
return nil
}

// MaxCurrent implements the Charger.MaxCurrent interface
func (mcc *MobileConnect) MaxCurrent(current int64) error {
// The device doesn't return an error if we set a value greater than the
// current allowed max or smaller than the allowed min
// instead it will simply set it to max or min and return "OK" anyway
// Since the API here works differently, we fetch the limits
// and then return an error if the value is outside of the limits or
// otherwise set the new value
if mcc.cableInformation.MaxValue == 0 {
if err := mcc.getEscapedJSON(mcc.apiURL(apiCurrentCableInformation), &mcc.cableInformation); err != nil {
return err
}
}

if current < mcc.cableInformation.MinValue {
return fmt.Errorf("value is lower than the allowed minimum value %d", mcc.cableInformation.MinValue)
}

if current > mcc.cableInformation.MaxValue {
return fmt.Errorf("value is higher than the allowed maximum value %d", mcc.cableInformation.MaxValue)
}

url := fmt.Sprintf("%s%d", mcc.apiURL(apiSetCurrentLimit), current)

req, err := mcc.request(http.MethodPut, url)
if err != nil {
return err
}

b, err := mcc.Request(req)
if err != nil {
return err
}

// return value is returned in the format "OK"\n
DerAndereAndi marked this conversation as resolved.
Show resolved Hide resolved
s := strings.Trim(string(b), "\n")
s = strings.Trim(s, "\"")

if s != "OK" {
return fmt.Errorf("Call returned an unexpected error")
DerAndereAndi marked this conversation as resolved.
Show resolved Hide resolved
}

return nil
}

// CurrentPower implements the Meter interface.
func (mcc *MobileConnect) CurrentPower() (float64, error) {
var energy MCCEnergy
err := mcc.getEscapedJSON(mcc.apiURL(apiEnergy), &energy)
andig marked this conversation as resolved.
Show resolved Hide resolved

return energy.L1.Power + energy.L2.Power + energy.L3.Power, err
}

// ChargedEnergy implements the ChargeRater interface.
func (mcc *MobileConnect) ChargedEnergy() (float64, error) {
var currentSession MCCCurrentSession
if err := mcc.getEscapedJSON(mcc.apiURL(apiCurrentSession), &currentSession); err != nil {
andig marked this conversation as resolved.
Show resolved Hide resolved
return 0, err
}

return currentSession.EnergySumKwh, nil
}

// ChargingTime yields current charge run duration
func (mcc *MobileConnect) ChargingTime() (time.Duration, error) {
var currentSession MCCCurrentSession
if err := mcc.getEscapedJSON(mcc.apiURL(apiCurrentSession), &currentSession); err != nil {
return 0, err
}

return time.Duration(currentSession.Duration * time.Second), nil
}
Loading