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 2 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
17 changes: 17 additions & 0 deletions api/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@ func NewHTTPHelper(log *Logger) *HTTPHelper {
return r
}

// NewHTTPHelperWithClient creates http helper for simplified PUT GET logic
// This variant requires a client to be passed over which allows it to have custom settings
DerAndereAndi marked this conversation as resolved.
Show resolved Hide resolved
func NewHTTPHelperWithClient(log *Logger, client *http.Client) *HTTPHelper {
r := &HTTPHelper{
Log: log,
Client: client,
}
return r
}

// Response codes other than HTTP 200 or 204 are raised as error
func (r *HTTPHelper) readBody(resp *http.Response, err error) ([]byte, error) {
if err != nil {
Expand Down Expand Up @@ -54,6 +64,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
353 changes: 353 additions & 0 deletions charger/mcc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,353 @@
package charger

import (
"crypto/tls"
"encoding/json"
"fmt"
"net/http"
"net/url"
"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 int64
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 MCCEnergyPhase
DerAndereAndi marked this conversation as resolved.
Show resolved Hide resolved
L2 MCCEnergyPhase
L3 MCCEnergyPhase
}

// MCCCurrentCableInformation is the apiCurrentCableInformation response
type MCCCurrentCableInformation struct {
MaxValue int64
DerAndereAndi marked this conversation as resolved.
Show resolved Hide resolved
MinValue int64
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
}

// 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 {
// 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

client := &http.Client{Transport: customTransport}

mcc := &MobileConnect{
HTTPHelper: api.NewHTTPHelperWithClient(api.NewLogger("MobileConnect"), client),
DerAndereAndi marked this conversation as resolved.
Show resolved Hide resolved
uri: strings.TrimRight(uri, "/"),
password: password,
}

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(time.Duration(10) * time.Minute)
DerAndereAndi marked this conversation as resolved.
Show resolved Hide resolved

// the web interface updates the token every 2 minutes, so lets do the same here
mcc.tokenRefresh = time.Now().Add(time.Duration(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))
DerAndereAndi marked this conversation as resolved.
Show resolved Hide resolved

return req, nil
}

// use http PUT to set a value on the URI, the value should be URL encoded in the URI parameter
func (mcc *MobileConnect) putValue(uri string) error {
req, err := mcc.request(http.MethodPut, uri)
if err != nil {
return err
}

b, err := mcc.Request(req)
if err == nil {
var result string
err = json.Unmarshal(b, &result)
DerAndereAndi marked this conversation as resolved.
Show resolved Hide resolved

if err == nil && result != "OK" {
return fmt.Errorf("Call returned an unexpected error")
}
}

return err
}

// use http GET to fetch a non structured value from an URI and stores it in result
func (mcc *MobileConnect) getValue(uri string, result interface{}) error {
req, err := mcc.request(http.MethodGet, uri)
if err != nil {
return err
}

b, err := mcc.Request(req)
DerAndereAndi marked this conversation as resolved.
Show resolved Hide resolved
if err == nil {
err = json.Unmarshal(b, &result)
}

return err
}

// 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 {
var unquote string
if err := json.Unmarshal([]byte(b), &unquote); err != nil {
DerAndereAndi marked this conversation as resolved.
Show resolved Hide resolved
return err
}

if err := json.Unmarshal([]byte(unquote), &result); err != nil {
return err
}
}

return err
}

// Status implements the Charger.Status interface
func (mcc *MobileConnect) Status() (api.ChargeStatus, error) {
var chargeState int64

if err := mcc.getValue(mcc.apiURL(apiChargeState), &chargeState); 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
var chargeState int64

if err := mcc.getValue(mcc.apiURL(apiChargeState), &chargeState); err != nil {
DerAndereAndi marked this conversation as resolved.
Show resolved Hide resolved
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
var cableInformation MCCCurrentCableInformation
// TODO: this only needs to run once
if err := mcc.getEscapedJSON(mcc.apiURL(apiCurrentCableInformation), &cableInformation); err != nil {
return err
}

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

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

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

return mcc.putValue(url)
}

// 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
err := mcc.getEscapedJSON(mcc.apiURL(apiCurrentSession), &currentSession)
if err != nil {
DerAndereAndi 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
err := mcc.getEscapedJSON(mcc.apiURL(apiCurrentSession), &currentSession)
andig marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
DerAndereAndi marked this conversation as resolved.
Show resolved Hide resolved
return 0, err
}

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