diff --git a/README.md b/README.md index 9dd217505f..5c2c3487dd 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/api/http.go b/api/http.go index 0f70d6761c..ec312451a0 100644 --- a/api/http.go +++ b/api/http.go @@ -54,6 +54,12 @@ func (r *HTTPHelper) decodeJSON(resp *http.Response, err error, res interface{}) return b, err } +// Request executes HTTP request returns the response body +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) diff --git a/charger/config.go b/charger/config.go index 65f30cf9aa..8be76c6850 100644 --- a/charger/config.go +++ b/charger/config.go @@ -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) default: log.FATAL.Fatalf("invalid charger type '%s'", typ) } diff --git a/charger/mcc.go b/charger/mcc.go new file mode 100644 index 0000000000..dcc2f2bc77 --- /dev/null +++ b/charger/mcc.go @@ -0,0 +1,333 @@ +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 +} + +// MobileConnect charger supporting 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} + + mcc.HTTPHelper.Client.Transport = customTransport + + return mcc +} + +// construct the URL for a given apiFunction +func (mcc *MobileConnect) apiURL(api apiFunction) string { + return fmt.Sprintf("%s/%s", mcc.uri, api) +} + +// process the http request to fetch the auth token for a login or refresh request +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"}, + "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? + 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) { + 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 unmarshal 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 s string + if s, err = strconv.Unquote(strings.Trim(string(b), "\n")); err == nil { + err = json.Unmarshal([]byte(s), &result) + } + } + + 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: // Unplugged + return api.StatusA, nil + case 1, 3, 4, 6: // 1: Connecting, 3: Established, 4: Paused, 6: Finished + return api.StatusB, nil + case 2: // Error + return api.StatusF, nil + case 5: // Active + 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 + if strings.Trim(string(b), "\n\"") != "OK" { + return fmt.Errorf("maxcurrent unexpected response: %s", string(b)) + } + + return nil +} + +// CurrentPower implements the Meter interface. +func (mcc *MobileConnect) CurrentPower() (float64, error) { + var energy MCCEnergy + err := mcc.getEscapedJSON(mcc.apiURL(apiEnergy), &energy) + + 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), ¤tSession); err != nil { + 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), ¤tSession); err != nil { + return 0, err + } + + return time.Duration(currentSession.Duration * time.Second), nil +} diff --git a/charger/mcc_test.go b/charger/mcc_test.go new file mode 100644 index 0000000000..769613528a --- /dev/null +++ b/charger/mcc_test.go @@ -0,0 +1,348 @@ +package charger + +import ( + "bytes" + "io/ioutil" + "net/http" + "reflect" + "strings" + "testing" + "time" + + "github.com/andig/evcc/api" +) + +// HTTP testing appproach from http://hassansin.github.io/Unit-Testing-http-client-in-Go +type roundTripFunc func(req *http.Request) *http.Response + +// RoundTrip . +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req), nil +} + +// apiResponse helps to map an API Call to a test response +type apiResponse struct { + apiCall apiFunction + apiResponse string +} + +// NewTestClient returns *http.Client with Transport replaced to avoid making real calls +func NewTestClient(fn roundTripFunc) *http.Client { + return &http.Client{ + Transport: roundTripFunc(fn), + } +} + +// NewTestMobileConnect . +func NewTestMobileConnect(t *testing.T, responses []apiResponse) *MobileConnect { + mcc := &MobileConnect{ + HTTPHelper: api.NewHTTPHelper(nil), + uri: "http://192.168.1.1", + password: "none", + token: "token", + tokenValid: time.Now().Add(10 * time.Minute), + tokenRefresh: time.Now().Add(10 * time.Minute), + } + + mcc.Client = NewTestClient(func(req *http.Request) *http.Response { + // Each method may have multiple API calls, so we need to finde the proper + // response string for the currently invoked call + var responseString string + for _, s := range responses { + if strings.Contains("/"+string(s.apiCall), req.URL.Path) { + responseString = s.apiResponse + } + } + + return &http.Response{ + StatusCode: 200, + // Send response to be tested + Body: ioutil.NopCloser(bytes.NewBufferString(responseString)), + // Must be set to non-nil value or it panics + Header: make(http.Header), + } + }) + + return mcc +} + +func TestMobileConnect_login(t *testing.T) { + tests := []struct { + name string + responses []apiResponse + password string + wantErr bool + }{ + // test cases for software version 2914 + {"login - success", []apiResponse{{apiLogin, "{\n \"token\": \"1234567890._abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ\"\n}"}}, "password", false}, + {"login - wrong password", []apiResponse{{apiLogin, "{\n \"error\": \"wrong password\"\n}"}}, "wrong", true}, + {"login - bad return", []apiResponse{{apiLogin, "{{\n \"error\": \"wrong password\"\n}"}}, "wrong", true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mcc := NewTestMobileConnect(t, tc.responses) + + if err := mcc.login(tc.password); (err != nil) != tc.wantErr { + t.Errorf("MobileConnect.login() error = %v, wantErr %v", err, tc.wantErr) + } + }) + } +} + +func TestMobileConnect_refresh(t *testing.T) { + tests := []struct { + name string + responses []apiResponse + wantErr bool + }{ + // test cases for software version 2914 + {"refresh - success", []apiResponse{{apiRefresh, "{\n \"token\": \"1234567890._abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ\"\n}"}}, false}, + {"refresh - wrong password", []apiResponse{{apiRefresh, "{\n \"error\": \"signature mismatch: OP-gWPOgQ9fdKujMgRNHkeH4WHqYrHe3Z2RqVXeUEuw1\"\n}"}}, true}, + {"refresh - bad return", []apiResponse{{apiRefresh, "{{\n \"error\": \"\"\n}"}}, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mcc := NewTestMobileConnect(t, tc.responses) + + if err := mcc.refresh(); (err != nil) != tc.wantErr { + t.Errorf("MobileConnect.login() error = %v, wantErr %v", err, tc.wantErr) + } + }) + } +} + +func TestMobileConnect_Status(t *testing.T) { + tests := []struct { + name string + responses []apiResponse + want api.ChargeStatus + wantErr bool + }{ + // test cases for software version 2914 + {"home plug - Unexpected API response", []apiResponse{{apiChargeState, "abc"}}, api.StatusNone, true}, + {"home plug - Unplugged", []apiResponse{{apiChargeState, "0\n"}}, api.StatusA, false}, + {"home plug - Connecting", []apiResponse{{apiChargeState, "1\n"}}, api.StatusB, false}, + {"home plug - Error", []apiResponse{{apiChargeState, "2\n"}}, api.StatusF, false}, + {"home plug - Established", []apiResponse{{apiChargeState, "3\n"}}, api.StatusB, false}, + {"home plug - Paused", []apiResponse{{apiChargeState, "4\n"}}, api.StatusB, false}, + {"home plug - Active", []apiResponse{{apiChargeState, "5\n"}}, api.StatusC, false}, + {"home plug - Finished", []apiResponse{{apiChargeState, "6\n"}}, api.StatusB, false}, + {"home plug - Unexpected status value", []apiResponse{{apiChargeState, "10\n"}}, api.StatusNone, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mcc := NewTestMobileConnect(t, tc.responses) + + got, err := mcc.Status() + if (err != nil) != tc.wantErr { + t.Errorf("MobileConnect.Status() error = %v, wantErr %v", err, tc.wantErr) + return + } + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("MobileConnect.Status() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestMobileConnect_Enabled(t *testing.T) { + tests := []struct { + name string + responses []apiResponse + want bool + wantErr bool + }{ + // test cases for software version 2914 + {"home plug - Unexpected API response", []apiResponse{{apiChargeState, "abc"}}, false, true}, + {"home plug - Unplugged", []apiResponse{{apiChargeState, "0\n"}}, false, false}, + {"home plug - Connecting", []apiResponse{{apiChargeState, "1\n"}}, false, false}, + {"home plug - Error", []apiResponse{{apiChargeState, "2\n"}}, false, false}, + {"home plug - Established", []apiResponse{{apiChargeState, "3\n"}}, false, false}, + {"home plug - Paused", []apiResponse{{apiChargeState, "4\n"}}, true, false}, + {"home plug - Active", []apiResponse{{apiChargeState, "5\n"}}, true, false}, + {"home plug - Finished", []apiResponse{{apiChargeState, "6\n"}}, true, false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mcc := NewTestMobileConnect(t, tc.responses) + + got, err := mcc.Enabled() + if (err != nil) != tc.wantErr { + t.Errorf("MobileConnect.Enabled() error = %v, wantErr %v", err, tc.wantErr) + return + } + if got != tc.want { + t.Errorf("MobileConnect.Enabled() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestMobileConnect_MaxCurrent(t *testing.T) { + tests := []struct { + name string + responses []apiResponse + current int64 + wantErr bool + }{ + // test cases for software version 2914 + { + "home plug - success min value", + []apiResponse{ + {apiCurrentCableInformation, "\"{\\\"carCable\\\":5,\\\"gridCable\\\":8,\\\"hwfpMaxLimit\\\":32,\\\"maxValue\\\":10,\\\"minValue\\\":6,\\\"value\\\":10}\""}, + {apiSetCurrentLimit, "\"OK\"\n"}, + }, + 6, false, + }, + { + "home plug - success max value", + []apiResponse{ + {apiCurrentCableInformation, "\"{\\\"carCable\\\":5,\\\"gridCable\\\":8,\\\"hwfpMaxLimit\\\":32,\\\"maxValue\\\":10,\\\"minValue\\\":6,\\\"value\\\":10}\""}, + {apiSetCurrentLimit, "\"OK\"\n"}, + }, + 10, false, + }, + { + "home plug - error value too small", + []apiResponse{ + {apiCurrentCableInformation, "\"{\\\"carCable\\\":5,\\\"gridCable\\\":8,\\\"hwfpMaxLimit\\\":32,\\\"maxValue\\\":10,\\\"minValue\\\":6,\\\"value\\\":10}\""}, + }, + 0, true, + }, + { + "home plug - error value too big", + []apiResponse{ + {apiCurrentCableInformation, "\"{\\\"carCable\\\":5,\\\"gridCable\\\":8,\\\"hwfpMaxLimit\\\":32,\\\"maxValue\\\":10,\\\"minValue\\\":6,\\\"value\\\":10}\""}, + }, + 16, true, + }, + { + "home plug - 1st API success but 2nd API error", + []apiResponse{ + {apiCurrentCableInformation, "\"{\\\"carCable\\\":5,\\\"gridCable\\\":8,\\\"hwfpMaxLimit\\\":32,\\\"maxValue\\\":10,\\\"minValue\\\":6,\\\"value\\\":10}\""}, + {apiSetCurrentLimit, "Unexpected response"}, + }, + 10, true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mcc := NewTestMobileConnect(t, tc.responses) + + if err := mcc.MaxCurrent(tc.current); (err != nil) != tc.wantErr { + t.Errorf("MobileConnect.MaxCurrent() error = %v, wantErr %v", err, tc.wantErr) + } + }) + } +} + +func TestMobileConnect_CurrentPower(t *testing.T) { + tests := []struct { + name string + responses []apiResponse + want float64 + wantErr bool + }{ + // test cases for software version 2914 + { + "home plug - charging", + []apiResponse{ + {apiEnergy, "\"{\\n \\\"L1\\\": {\\n \\\"Ampere\\\": 9.9000000000000004,\\n \\\"Power\\\": 2308,\\n \\\"Volts\\\": 230.5\\n },\\n \\\"L2\\\": {\\n \\\"Ampere\\\": 0,\\n \\\"Power\\\": 0,\\n \\\"Volts\\\": 13.700000000000001\\n },\\n \\\"L3\\\": {\\n \\\"Ampere\\\": 0,\\n \\\"Power\\\": 0,\\n \\\"Volts\\\": 13.9\\n }\\n}\\n\""}, + }, + 2308, false, + }, + { + "home plug - error response", + []apiResponse{ + {apiEnergy, "\"{\\n \\\"L1\\\": {\\n \\\"Ampere\\\": 0,\\n \\\"Power\\\": 0,\\n \\\"Volts\\\": 246.60000000000002\\n },\\n \\\"L2\\\": {\\n \\\"Ampere\\\": 0,\\n \\\"Power\\\": 0,\\n \\\"Volts\\\": 16.800000000000001\\n },\\n \\\"L3\\\": {\\n \\\"Ampere\\\": 0,\\n \\\"Power\\\": 0,\\n \\\"Volts\\\": 16.300000000000001\\n }\\n}\\n\""}, + }, 0, false, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mcc := NewTestMobileConnect(t, tc.responses) + + got, err := mcc.CurrentPower() + if (err != nil) != tc.wantErr { + t.Errorf("MobileConnect.CurrentPower() error = %v, wantErr %v", err, tc.wantErr) + return + } + if got != tc.want { + t.Errorf("MobileConnect.CurrentPower() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestMobileConnect_ChargedEnergy(t *testing.T) { + tests := []struct { + name string + responses []apiResponse + want float64 + wantErr bool + }{ + // test cases for software version 2914 + { + "valid response", + []apiResponse{ + {apiCurrentSession, "\"{\\n \\\"account\\\": \\\"PRIVATE\\\",\\n \\\"chargingRate\\\": 0,\\n \\\"chargingType\\\": \\\"AC\\\",\\n \\\"clockSrc\\\": \\\"NTP\\\",\\n \\\"costs\\\": 0,\\n \\\"currency\\\": \\\"\\\",\\n \\\"departTime\\\": \\\"\\\",\\n \\\"duration\\\": 30789,\\n \\\"endOfChargeTime\\\": \\\"\\\",\\n \\\"endSoc\\\": 0,\\n \\\"endTime\\\": \\\"\\\",\\n \\\"energySumKwh\\\": 18.832000000000001,\\n \\\"evChargingRatekW\\\": 0,\\n \\\"evTargetSoc\\\": -1,\\n \\\"evVasAvailability\\\": false,\\n \\\"pcid\\\": \\\"\\\",\\n \\\"powerRange\\\": 0,\\n \\\"selfEnergy\\\": 0,\\n \\\"sessionId\\\": 13,\\n \\\"soc\\\": -1,\\n \\\"solarEnergyShare\\\": 0,\\n \\\"startSoc\\\": 0,\\n \\\"startTime\\\": \\\"2020-04-15T10:07:22+02:00\\\",\\n \\\"totalRange\\\": 0,\\n \\\"vehicleBrand\\\": \\\"\\\",\\n \\\"vehicleModel\\\": \\\"\\\",\\n \\\"whitelist\\\": false\\n}\\n\""}, + }, 18.832000000000001, false, + }, + { + "error response", + []apiResponse{ + {apiCurrentSession, "\"\""}, + }, 0, true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mcc := NewTestMobileConnect(t, tc.responses) + + got, err := mcc.ChargedEnergy() + if (err != nil) != tc.wantErr { + t.Errorf("MobileConnect.ChargedEnergy() error = %v, wantErr %v", err, tc.wantErr) + return + } + if got != tc.want { + t.Errorf("MobileConnect.ChargedEnergy() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestMobileConnect_ChargingTime(t *testing.T) { + tests := []struct { + name string + responses []apiResponse + want time.Duration + wantErr bool + }{ + { + "valid response", + []apiResponse{ + {apiCurrentSession, "\"{\\n \\\"account\\\": \\\"PRIVATE\\\",\\n \\\"chargingRate\\\": 0,\\n \\\"chargingType\\\": \\\"AC\\\",\\n \\\"clockSrc\\\": \\\"NTP\\\",\\n \\\"costs\\\": 0,\\n \\\"currency\\\": \\\"\\\",\\n \\\"departTime\\\": \\\"\\\",\\n \\\"duration\\\": 30789,\\n \\\"endOfChargeTime\\\": \\\"\\\",\\n \\\"endSoc\\\": 0,\\n \\\"endTime\\\": \\\"\\\",\\n \\\"energySumKwh\\\": 18.832000000000001,\\n \\\"evChargingRatekW\\\": 0,\\n \\\"evTargetSoc\\\": -1,\\n \\\"evVasAvailability\\\": false,\\n \\\"pcid\\\": \\\"\\\",\\n \\\"powerRange\\\": 0,\\n \\\"selfEnergy\\\": 0,\\n \\\"sessionId\\\": 13,\\n \\\"soc\\\": -1,\\n \\\"solarEnergyShare\\\": 0,\\n \\\"startSoc\\\": 0,\\n \\\"startTime\\\": \\\"2020-04-15T10:07:22+02:00\\\",\\n \\\"totalRange\\\": 0,\\n \\\"vehicleBrand\\\": \\\"\\\",\\n \\\"vehicleModel\\\": \\\"\\\",\\n \\\"whitelist\\\": false\\n}\\n\""}, + }, 30789 * time.Second, false, + }, + { + "error response", + []apiResponse{ + {apiCurrentSession, "\"\""}, + }, 0, true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mcc := NewTestMobileConnect(t, tc.responses) + + got, err := mcc.ChargingTime() + if (err != nil) != tc.wantErr { + t.Errorf("MobileConnect.ChargingTime() error = %v, wantErr %v", err, tc.wantErr) + return + } + if got != tc.want { + t.Errorf("MobileConnect.ChargingTime() = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/evcc.dist.yaml b/evcc.dist.yaml index 54a248f3fe..b146f632ae 100644 --- a/evcc.dist.yaml +++ b/evcc.dist.yaml @@ -83,6 +83,10 @@ chargers: - name: go-e type: go-e # go-e charger uri: http://192.168.1.4 # go-e address +- name: mcc + type: mcc # Mobile Charger Connect (Audi, Bentley, Porsche) + uri: https://192.168.1.4 # Mobile Charger Connect address + password: # home user password - name: configurable type: default # Configurable charger status: # charger status A..F