diff --git a/packages/modules/vehicles/leaf/__init__.py b/packages/modules/vehicles/leaf/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/packages/modules/vehicles/leaf/__init__.py @@ -0,0 +1 @@ + diff --git a/packages/modules/vehicles/leaf/config.py b/packages/modules/vehicles/leaf/config.py new file mode 100644 index 0000000000..7af45d37e1 --- /dev/null +++ b/packages/modules/vehicles/leaf/config.py @@ -0,0 +1,17 @@ +from typing import Optional + + +class LeafConfiguration: + def __init__(self, user_id: Optional[str] = None, password: Optional[str] = None): + self.user_id = user_id + self.password = password + + +class LeafSoc: + def __init__(self, + name: str = "Leaf", + type: str = "leaf", + configuration: LeafConfiguration = None) -> None: + self.name = name + self.type = type + self.configuration = configuration or LeafConfiguration() diff --git a/packages/modules/vehicles/leaf/pycarwings2.py b/packages/modules/vehicles/leaf/pycarwings2.py new file mode 100644 index 0000000000..6041ecb4d4 --- /dev/null +++ b/packages/modules/vehicles/leaf/pycarwings2.py @@ -0,0 +1,464 @@ +# Copyright 2016 Jason Horne +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +When logging in, you must specify a geographic 'region' parameter. The only +known values for this are as follows: + + NNA : USA + NE : Europe + NCI : Canada + NMA : Australia + NML : Japan + +Information about Nissan on the web (e.g. http://nissannews.com/en-US/nissan/usa/pages/executive-bios) +suggests others (this page suggests NMEX for Mexico, NLAC for Latin America) but +these have not been confirmed. + +There are three asynchronous operations in this API, paired with three follow-up +"status check" methods. + + request_update -> get_status_from_update + start_climate_control -> get_start_climate_control_result + stop_climate_control -> get_stop_climate_control_result + +The asynchronous operations immediately return a 'result key', which +is then supplied as a parameter for the corresponding status check method. + +Here's an example response from an asynchronous operation, showing the result key: + + { + "status":200, + "userId":"user@domain.com", + "vin":"1ABCDEFG2HIJKLM3N", + "resultKey":"12345678901234567890123456789012345678901234567890" + } + +The status check methods return a JSON blob containing a 'responseFlag' property. +If the communications are complete, the response flag value will be the string "1"; +otherwise the value will be the string "0". You just gotta poll until you get a +"1" back. Note that the official app seems to poll every 20 seconds. + +Example 'no response yet' result from a status check invocation: + + { + "status":200, + "responseFlag":"0" + } + +When the responseFlag does come back as "1", there will also be an "operationResult" +property. If there was an error communicating with the vehicle, it seems that +this field will contain the value "ELECTRIC_WAVE_ABNORMAL". Odd. + +""" + +import requests +from requests import Request, RequestException +import json +import logging +from datetime import date +from responses import (CarwingsInitialAppResponse, CarwingsLoginResponse, CarwingsBatteryStatusResponse, + CarwingsStartClimateControlResponse, CarwingsStopClimateControlResponse, CarwingsClimateControlScheduleResponse, + CarwingsDrivingAnalysisResponse, CarwingsLatestBatteryStatusResponse, CarwingsLatestClimateControlStatusResponse) +import base64 +from Crypto.Cipher import Blowfish + +BASE_URL = "https://gdcportalgw.its-mo.com/api_v230317_NE/gdc/" +log = logging.getLogger(__name__) + + +# from http://stackoverflow.com/questions/17134100/python-blowfish-encryption +def _PKCS5Padding(string): + byteNum = len(string) + packingLength = 8 - byteNum % 8 + appendage = chr(packingLength) * packingLength + return string + appendage + + +class CarwingsError(Exception): + pass + + +class Session(object): + """Maintains a connection to CARWINGS, refreshing it when needed""" + + def __init__(self, username, password, region="NNA"): + self.username = username + self.password = password + self.region_code = region + self.logged_in = False + self.custom_sessionid = None + + def _request_with_retry(self, endpoint, params): + ret = self._request(endpoint, params) + + if ("status" in ret) and (ret["status"] >= 400): + log.error( + "carwings error; logging in and trying request again: %s" % ret) + # try logging in again + self.connect() + ret = self._request(endpoint, params) + + return ret + + def _request(self, endpoint, params): + params["initial_app_str"] = "9s5rfKVuMrT03RtzajWNcA" + if self.custom_sessionid: + params["custom_sessionid"] = self.custom_sessionid + else: + params["custom_sessionid"] = "" + + req = Request('POST', url=BASE_URL + endpoint, data=params, headers={"User-Agent": ""}).prepare() + # ", headers={"User-Agent": ""}" added, + # see https://github.com/filcole/pycarwings2/blob/master/pycarwings2/pycarwings2.py + # added parameter is needed, to be able to run pycarwings2.py on a Windows PC with Python 3.12 + + log.debug("invoking carwings API: %s" % req.url) + log.debug("params: %s" % json.dumps( + {k: v.decode('utf-8') if isinstance(v, bytes) + else v for k, v in params.items()}, + sort_keys=True, indent=3, separators=(',', ': ')) + ) + + try: + sess = requests.Session() + response = sess.send(req) + log.debug('Response HTTP Status Code: {status_code}'.format( + status_code=response.status_code)) + log.debug('Response HTTP Response Body: {content}'.format( + content=response.content)) + except RequestException: + log.warning('HTTP Request failed') + raise CarwingsError + + # Nissan servers can return html instead of jSOn on occassion, e.g. + # + # + # + # 503 Service Temporarily Unavailable + # + #

Service Temporarily Unavailable> + #

The server is temporarily unable to service your + # request due to maintenance downtime or capacity + # problems. Please try again later.

+ # + try: + j = json.loads(response.text) + except ValueError: + log.error("Invalid JSON returned") + raise CarwingsError + + if "message" in j and j["message"] == "INVALID PARAMS": + log.error("carwings error %s: %s" % (j["message"], j["status"])) + raise CarwingsError("INVALID PARAMS") + if "ErrorMessage" in j: + log.error("carwings error %s: %s" % + (j["ErrorCode"], j["ErrorMessage"])) + raise CarwingsError + + return j + + def connect(self): + self.custom_sessionid = None + self.logged_in = False + + response = self._request("InitialApp_v2.php", { + "RegionCode": self.region_code, + "lg": "en-US", + }) + ret = CarwingsInitialAppResponse(response) + + c1 = Blowfish.new(ret.baseprm.encode(), Blowfish.MODE_ECB) + packedPassword = _PKCS5Padding(self.password) + encryptedPassword = c1.encrypt(packedPassword.encode()) + encodedPassword = base64.standard_b64encode(encryptedPassword) + + response = self._request("UserLoginRequest.php", { + "RegionCode": self.region_code, + "UserId": self.username, + "Password": encodedPassword, + }) + + ret = CarwingsLoginResponse(response) + + self.custom_sessionid = ret.custom_sessionid + + self.gdc_user_id = ret.gdc_user_id + log.debug("gdc_user_id: %s" % self.gdc_user_id) + self.dcm_id = ret.dcm_id + log.debug("dcm_id: %s" % self.dcm_id) + self.tz = ret.tz + log.debug("tz: %s" % self.tz) + self.language = ret.language + log.debug("language: %s" % self.language) + log.debug("vin: %s" % ret.vin) + log.debug("nickname: %s" % ret.nickname) + + self.leaf = Leaf(self, ret.leafs[0]) + + self.logged_in = True + + return ret + + def get_leaf(self, index=0): + if not self.logged_in: + self.connect() + + return self.leaf + + +class Leaf: + def __init__(self, session, params): + self.session = session + self.vin = params["vin"] + self.nickname = params["nickname"] + self.bound_time = params["bound_time"] + log.debug("created leaf %s/%s" % (self.vin, self.nickname)) + + def request_update(self): + response = self.session._request_with_retry("BatteryStatusCheckRequest.php", { + "RegionCode": self.session.region_code, + "VIN": self.vin, + }) + return response["resultKey"] + + def get_status_from_update(self, result_key): + response = self.session._request_with_retry("BatteryStatusCheckResultRequest.php", { + "RegionCode": self.session.region_code, + "lg": self.session.language, + "DCMID": self.session.dcm_id, + "VIN": self.vin, + "tz": self.session.tz, + "resultKey": result_key, + }) + # responseFlag will be "1" if a response has been returned; "0" otherwise + if response["responseFlag"] == "1": + return CarwingsBatteryStatusResponse(response) + + return None + + def start_climate_control(self): + response = self.session._request_with_retry("ACRemoteRequest.php", { + "RegionCode": self.session.region_code, + "lg": self.session.language, + "DCMID": self.session.dcm_id, + "VIN": self.vin, + "tz": self.session.tz, + }) + return response["resultKey"] + + def get_start_climate_control_result(self, result_key): + response = self.session._request_with_retry("ACRemoteResult.php", { + "RegionCode": self.session.region_code, + "lg": self.session.language, + "DCMID": self.session.dcm_id, + "VIN": self.vin, + "tz": self.session.tz, + "UserId": self.session.gdc_user_id, # this userid is the 'gdc' userid + "resultKey": result_key, + }) + if response["responseFlag"] == "1": + return CarwingsStartClimateControlResponse(response) + + return None + + def stop_climate_control(self): + response = self.session._request_with_retry("ACRemoteOffRequest.php", { + "RegionCode": self.session.region_code, + "lg": self.session.language, + "DCMID": self.session.dcm_id, + "VIN": self.vin, + "tz": self.session.tz, + }) + return response["resultKey"] + + def get_stop_climate_control_result(self, result_key): + response = self.session._request_with_retry("ACRemoteOffResult.php", { + "RegionCode": self.session.region_code, + "lg": self.session.language, + "DCMID": self.session.dcm_id, + "VIN": self.vin, + "tz": self.session.tz, + "UserId": self.session.gdc_user_id, # this userid is the 'gdc' userid + "resultKey": result_key, + }) + if response["responseFlag"] == "1": + return CarwingsStopClimateControlResponse(response) + + return None + + # execute time example: "2016-02-09 17:24" + # I believe this time is specified in GMT, despite the "tz" parameter + # TODO: change parameter to python datetime object(?) + def schedule_climate_control(self, execute_time): + response = self.session._request_with_retry("ACRemoteNewRequest.php", { + "RegionCode": self.session.region_code, + "lg": self.session.language, + "DCMID": self.session.dcm_id, + "VIN": self.vin, + "tz": self.session.tz, + "ExecuteTime": execute_time, + }) + return (response["status"] == 200) + + # execute time example: "2016-02-09 17:24" + # I believe this time is specified in GMT, despite the "tz" parameter + # TODO: change parameter to python datetime object(?) + def update_scheduled_climate_control(self, execute_time): + response = self.session._request_with_retry("ACRemoteUpdateRequest.php", { + "RegionCode": self.session.region_code, + "lg": self.session.language, + "DCMID": self.session.dcm_id, + "VIN": self.vin, + "tz": self.session.tz, + "ExecuteTime": execute_time, + }) + return (response["status"] == 200) + + def cancel_scheduled_climate_control(self): + response = self.session._request_with_retry("ACRemoteCancelRequest.php", { + "RegionCode": self.session.region_code, + "lg": self.session.language, + "DCMID": self.session.dcm_id, + "VIN": self.vin, + "tz": self.session.tz, + }) + return (response["status"] == 200) + + def get_climate_control_schedule(self): + response = self.session._request_with_retry("GetScheduledACRemoteRequest.php", { + "RegionCode": self.session.region_code, + "lg": self.session.language, + "DCMID": self.session.dcm_id, + "VIN": self.vin, + "tz": self.session.tz, + }) + if (response["status"] == 200): + if response["ExecuteTime"] != "": + return CarwingsClimateControlScheduleResponse(response) + + return None + + """ + { + "status":200, + } + """ + + def start_charging(self): + response = self.session._request_with_retry("BatteryRemoteChargingRequest.php", { + "RegionCode": self.session.region_code, + "lg": self.session.language, + "DCMID": self.session.dcm_id, + "VIN": self.vin, + "tz": self.session.tz, + "ExecuteTime": date.today().isoformat() + }) + if response["status"] == 200: + # This only indicates that the charging command has been received by the + # Nissan servers, it does not indicate that the car is now charging. + return True + + return False + + def get_driving_analysis(self): + response = self.session._request_with_retry("DriveAnalysisBasicScreenRequestEx.php", { + "RegionCode": self.session.region_code, + "lg": self.session.language, + "DCMID": self.session.dcm_id, + "VIN": self.vin, + "tz": self.session.tz, + }) + if response["status"] == 200: + return CarwingsDrivingAnalysisResponse(response) + + return None + + def get_latest_battery_status(self): + response = self.session._request_with_retry("BatteryStatusRecordsRequest.php", { + "RegionCode": self.session.region_code, + "lg": self.session.language, + "DCMID": self.session.dcm_id, + "VIN": self.vin, + "tz": self.session.tz, + "TimeFrom": self.bound_time + }) + if response["status"] == 200: + if "BatteryStatusRecords" in response: + return CarwingsLatestBatteryStatusResponse(response) + else: + log.warning('no battery status record returned by server') + + return None + + def get_latest_hvac_status(self): + response = self.session._request_with_retry("RemoteACRecordsRequest.php", { + "RegionCode": self.session.region_code, + "lg": self.session.language, + "DCMID": self.session.dcm_id, + "VIN": self.vin, + "tz": self.session.tz, + "TimeFrom": self.bound_time + }) + if response["status"] == 200: + if "RemoteACRecords" in response: + return CarwingsLatestClimateControlStatusResponse(response) + else: + log.warning('no remote a/c records returned by server') + + return None + + # target_month format: "YYYYMM" e.g. "201602" + def get_electric_rate_simulation(self, target_month): + response = self.session._request_with_retry("PriceSimulatorDetailInfoRequest.php", { + "RegionCode": self.session.region_code, + "lg": self.session.language, + "DCMID": self.session.dcm_id, + "VIN": self.vin, + "tz": self.session.tz, + "TargetMonth": target_month + }) + if response["status"] == 200: + return CarwingsElectricRateSimulationResponse(response) + + return None + + def request_location(self): + # As of 25th July the Locate My Vehicle functionality of the Europe version of the + # Nissan APIs was removed. It may return, so this call is left here. + # It currently errors with a 404 MyCarFinderRequest.php was not found on this server + # for European users. + response = self.session._request_with_retry("MyCarFinderRequest.php", { + "RegionCode": self.session.region_code, + "lg": self.session.language, + "DCMID": self.session.dcm_id, + "VIN": self.vin, + "tz": self.session.tz, + "UserId": self.session.gdc_user_id, # this userid is the 'gdc' userid + }) + return response["resultKey"] + + def get_status_from_location(self, result_key): + response = self.session._request_with_retry("MyCarFinderResultRequest.php", { + "RegionCode": self.session.region_code, + "lg": self.session.language, + "DCMID": self.session.dcm_id, + "VIN": self.vin, + "tz": self.session.tz, + "resultKey": result_key, + }) + if response["responseFlag"] == "1": + return CarwingsMyCarFinderResponse(response) + + return None diff --git a/packages/modules/vehicles/leaf/responses.py b/packages/modules/vehicles/leaf/responses.py new file mode 100644 index 0000000000..f7ce8b2581 --- /dev/null +++ b/packages/modules/vehicles/leaf/responses.py @@ -0,0 +1,724 @@ +# Copyright 2016 Jason Horne +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging +from datetime import timedelta, datetime +from modules.vehicles.leaf import pycarwings2 + +log = logging.getLogger(__name__) + + +def _time_remaining(t): + minutes = float(0) + if t: + if ("hours" in t) and t["hours"]: + minutes = 60 * float(t["hours"]) + elif ("HourRequiredToFull" in t) and t["HourRequiredToFull"]: + minutes = 60 * float(t["HourRequiredToFull"]) + if ("minutes" in t) and t["minutes"]: + minutes += float(t["minutes"]) + elif ("MinutesRequiredToFull" in t) and t["MinutesRequiredToFull"]: + minutes += float(t["MinutesRequiredToFull"]) + + return minutes + + +class CarwingsResponse: + def __init__(self, response): + op_result = None + if ("operationResult" in response): + op_result = response["operationResult"] + elif ("OperationResult" in response): + op_result = response["OperationResult"] + + # seems to indicate that the vehicle cannot be reached + if ("ELECTRIC_WAVE_ABNORMAL" == op_result): + log.error("could not establish communications with vehicle") + raise pycarwings2.CarwingsError("could not establish communications with vehicle") + + def _set_cruising_ranges(self, status, off_key="cruisingRangeAcOff", on_key="cruisingRangeAcOn"): + if off_key in status: + self.cruising_range_ac_off_km = float(status[off_key]) / 1000 + if on_key in status: + self.cruising_range_ac_on_km = float(status[on_key]) / 1000 + + def _set_timestamp(self, status): + self.timestamp = datetime.strptime(status["timeStamp"], "%Y-%m-%d %H:%M:%S") # "2016-01-02 17:17:38" + + +class CarwingsInitialAppResponse(CarwingsResponse): + def __init__(self, response): + CarwingsResponse.__init__(self, response) + self.baseprm = response["baseprm"] + + +class CarwingsLoginResponse(CarwingsResponse): + """ + example JSON response to login: + { + "status":200, + "message":"success", + "sessionId":"12345678-1234-1234-1234-1234567890", + "VehicleInfoList": { + "VehicleInfo": [ + { + "charger20066":"false", + "nickname":"LEAF", + "telematicsEnabled":"true", + "vin":"1ABCDEFG2HIJKLM3N" + } + ], + "vehicleInfo": [ + { + "charger20066":"false", + "nickname":"LEAF", + "telematicsEnabled":"true", + "vin":"1ABCDEFG2HIJKLM3N" + } + ] + }, + "vehicle": { + "profile": { + "vin":"1ABCDEFG2HIJKLM3N", + "gdcUserId":"FG12345678", + "gdcPassword":"password", + "encAuthToken":"ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZ", + "dcmId":"123456789012", + "nickname":"Alpha124", + "status":"ACCEPTED", + "statusDate": "Aug 15, 2015 07:00 PM" + } + }, + "EncAuthToken":"ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZ", + "CustomerInfo": { + "UserId":"AB12345678", + "Language":"en-US", + "Timezone":"America/New_York", + "RegionCode":"NNA", + "OwnerId":"1234567890", + "Nickname":"Bravo456", + "Country":"US", + "VehicleImage":"/content/language/default/images/img/ph_car.jpg", + "UserVehicleBoundDurationSec":"999971200", + "VehicleInfo": { + "VIN":"1ABCDEFG2HIJKLM3N", + "DCMID":"201212345678", + "SIMID":"12345678901234567890", + "NAVIID":"1234567890", + "EncryptedNAVIID":"1234567890ABCDEFGHIJKLMNOP", + "MSN":"123456789012345", + "LastVehicleLoginTime":"", + "UserVehicleBoundTime":"2015-08-17T14:16:32Z", + "LastDCMUseTime":"" + } + }, + "UserInfoRevisionNo":"1" + } + """ + def __init__(self, response): + CarwingsResponse.__init__(self, response) + + profile = response["vehicle"]["profile"] + self.gdc_user_id = profile["gdcUserId"] + self.dcm_id = profile["dcmId"] + self.vin = profile["vin"] + + # vehicleInfo block may be top level, or contained in a VehicleInfoList object; + # why it's sometimes one way and sometimes another is not clear. + if "VehicleInfoList" in response: + self.nickname = response["VehicleInfoList"]["vehicleInfo"][0]["nickname"] + self.custom_sessionid = response["VehicleInfoList"]["vehicleInfo"][0]["custom_sessionid"] + elif "vehicleInfo" in response: + self.nickname = response["vehicleInfo"][0]["nickname"] + self.custom_sessionid = response["vehicleInfo"][0]["custom_sessionid"] + + customer_info = response["CustomerInfo"] + self.tz = customer_info["Timezone"] + self.language = customer_info["Language"] + self.user_vehicle_bound_time = customer_info["VehicleInfo"]["UserVehicleBoundTime"] + + self.leafs = [{ + "vin": self.vin, + "nickname": self.nickname, + "bound_time": self.user_vehicle_bound_time + }] + + +class CarwingsBatteryStatusResponse(CarwingsResponse): + """ + Note that before December 2018 this returned a response. Between Dec-2018 and Aug-2019 + it did not return a response from the Nissan Servers. As of Aug 2019 a response is now + returned again. + + # Original + { + "status": 200, + "message": "success", + "responseFlag": "1", + "operationResult": "START", + "timeStamp": "2016-01-02 17:17:38", + "cruisingRangeAcOn": "115328.0", + "cruisingRangeAcOff": "117024.0", + "currentChargeLevel": "0", + "chargeMode": "220V", + "pluginState": "CONNECTED", + "charging": "YES", + "chargeStatus": "CT", + "batteryDegradation": "10", + "batteryCapacity": "12", + "timeRequiredToFull": { + "hours": "", + "minutes": "" + }, + "timeRequiredToFull200": { + "hours": "", + "minutes": "" + }, + "timeRequiredToFull200_6kW": { + "hours": "", + "minutes": "" + } + } + + # As at 21/01/2019 for a 30kWh Leaf now seems that + # BatteryStatusCheckResultRequest.php always returns this + # regardless of battery status. + { + "status":200, + "responseFlag":"0" + } + + { + "status":200, + "message":"success", + "responseFlag":"1", + "operationResult":"START", + "timeStamp":"2016-02-14 20:28:45", + "cruisingRangeAcOn":"107136.0", + "cruisingRangeAcOff":"115776.0", + "currentChargeLevel":"0", + "chargeMode":"NOT_CHARGING", + "pluginState":"QC_CONNECTED", + "charging":"YES", + "chargeStatus":"CT", + "batteryDegradation":"11", + "batteryCapacity":"12", + "timeRequiredToFull":{ + "hours":"", + "minutes":"" + }, + "timeRequiredToFull200":{ + "hours":"", + "minutes":"" + }, + "timeRequiredToFull200_6kW":{ + "hours":"", + "minutes":"" + } + } + + # As at 22/08/2019 for a 30kWh Leaf now seesm that + # BatteryStatusCheckResultRequest.php returns data again + # after polling a number of times. + { + "status": 200, + "responseFlag": "1", + "operationResult": "START", + "timeStamp": "2019-08-22 10:26:51", + "cruisingRangeAcOn": "129000.0", + "cruisingRangeAcOff": "132000.0", + "currentChargeLevel": "0", + "chargeMode": "NOT_CHARGING", + "pluginState": "NOT_CONNECTED", + "charging": "NO", + "chargeStatus": "0", + "batteryDegradation": "180", + "batteryCapacity": "240", + "timeRequiredToFull": { + "hours": "11", + "minutes": "30" + }, + "timeRequiredToFull200": { + "hours": "6", + "minutes": "30" + }, + "timeRequiredToFull200_6kW": { + "hours": "2", + "minutes": "30" + } + } + + """ + def __init__(self, status): + CarwingsResponse.__init__(self, status) + + self._set_timestamp(status) + self._set_cruising_ranges(status) + + self.answer = status + + self.battery_capacity = status["batteryCapacity"] + self.battery_degradation = status["batteryDegradation"] + + self.is_connected = ("NOT_CONNECTED" != status["pluginState"]) # fun double negative + self.plugin_state = status["pluginState"] + + self.charging_status = status["chargeMode"] + + self.is_charging = ("YES" == status["charging"]) + + self.is_quick_charging = ("RAPIDLY_CHARGING" == status["chargeMode"]) + self.is_connected_to_quick_charger = ("QC_CONNECTED" == status["pluginState"]) + + self.time_to_full_trickle = timedelta(minutes=_time_remaining(status["timeRequiredToFull"])) + self.time_to_full_l2 = timedelta(minutes=_time_remaining(status["timeRequiredToFull200"])) + self.time_to_full_l2_6kw = timedelta(minutes=_time_remaining(status["timeRequiredToFull200_6kW"])) + + # For some leafs the battery_percent is not returned + self.battery_percent = 100 * float(self.battery_degradation) / 12 + + +class CarwingsLatestClimateControlStatusResponse(CarwingsResponse): + """ + climate control on: + { + "status":200, + "message":"success", + "RemoteACRecords":{ + "OperationResult":"START_BATTERY", + "OperationDateAndTime":"Feb 10, 2016 10:22 PM", + "RemoteACOperation":"START", + "ACStartStopDateAndTime":"Feb 10, 2016 10:23 PM", + "CruisingRangeAcOn":"107712.0", + "CruisingRangeAcOff":"109344.0", + "ACStartStopURL":"", + "PluginState":"NOT_CONNECTED", + "ACDurationBatterySec":"900", + "ACDurationPluggedSec":"7200" + }, + "OperationDateAndTime":"" + } + + climate control off: + { + "status":200, + "message":"success", + "RemoteACRecords":{ + "OperationResult":"START", + "OperationDateAndTime":"Feb 10, 2016 10:26 PM", + "RemoteACOperation":"STOP", + "ACStartStopDateAndTime":"Feb 10, 2016 10:27 PM", + "CruisingRangeAcOn":"111936.0", + "CruisingRangeAcOff":"113632.0", + "ACStartStopURL":"", + "PluginState":"NOT_CONNECTED", + "ACDurationBatterySec":"900", + "ACDurationPluggedSec":"7200" + }, + "OperationDateAndTime":"" + } + + error: + { + "status":200, + "RemoteACRecords":{ + "OperationResult":"ELECTRIC_WAVE_ABNORMAL", + "OperationDateAndTime":"2018/04/08 10:00", + "RemoteACOperation":"START", + "ACStartStopDateAndTime":"08-Apr-2018 11:06", + "ACStartStopURL":"", + "PluginState":"INVALID", + "ACDurationBatterySec":"900", + "ACDurationPluggedSec":"7200", + "PreAC_unit":"C", + "PreAC_temp":"22" + } + } + noinfo (from a 2014 24kWh Leaf): + { + "status":200, + "RemoteACRecords":[] + } + + """ + def __init__(self, status): + CarwingsResponse.__init__(self, status["RemoteACRecords"]) + racr = status["RemoteACRecords"] + + self._set_cruising_ranges(racr, on_key="CruisingRangeAcOn", off_key="CruisingRangeAcOff") + + # If no empty RemoteACRecords list is returned then assume CC is off. + if type(racr) is not dict: + self.is_hvac_running = False + else: + # Seems to be running only if both of these contain "START". + self.is_hvac_running = ( + racr["OperationResult"] and + racr["OperationResult"].startswith("START") and + racr["RemoteACOperation"] == "START" + ) + + +class CarwingsStartClimateControlResponse(CarwingsResponse): + """ + { + "status":200, + "message":"success", + "responseFlag":"1", + "operationResult":"START_BATTERY", + "acContinueTime":"15", + "cruisingRangeAcOn":"106400.0", + "cruisingRangeAcOff":"107920.0", + "timeStamp":"2016-02-05 12:59:46", + "hvacStatus":"ON" + } + """ + def __init__(self, status): + CarwingsResponse.__init__(self, status) + + self._set_timestamp(status) + self._set_cruising_ranges(status) + + self.operation_result = status["operationResult"] # e.g. "START_BATTERY", ...? + self.ac_continue_time = timedelta(minutes=float(status["acContinueTime"])) + self.hvac_status = status["hvacStatus"] # "ON" or "OFF" + self.is_hvac_running = ("ON" == self.hvac_status) + + +class CarwingsStopClimateControlResponse(CarwingsResponse): + """ + { + "status":200, + "message":"success", + "responseFlag":"1", + "operationResult":"START", + "timeStamp":"2016-02-09 03:32:51", + "hvacStatus":"OFF" + } + """ + def __init__(self, status): + CarwingsResponse.__init__(self, status) + + self._set_timestamp(status) + self.hvac_status = status["hvacStatus"] # "ON" or "OFF" + self.is_hvac_running = ("ON" == self.hvac_status) + + +class CarwingsClimateControlScheduleResponse(CarwingsResponse): + """ + { + "status":200, + "message":"success", + "LastScheduledTime":"Feb 9, 2016 05:39 PM", + "ExecuteTime":"2016-02-10 01:00:00", + "DisplayExecuteTime":"Feb 9, 2016 08:00 PM", + "TargetDate":"2016/02/10 01:00" + } + """ + def __init__(self, status): + CarwingsResponse.__init__(self, status) + + self.display_execute_time = status["DisplayExecuteTime"] # displayable, timezone-adjusted + self.execute_time = datetime.strptime(status["ExecuteTime"] + " UTC", "%Y-%m-%d %H:%M:%S %Z") # GMT + self.display_last_scheduled_time = status["LastScheduledTime"] # displayable, timezone-adjusted + self.last_scheduled_time = datetime.strptime(status["LastScheduledTime"], "%b %d, %Y %I:%M %p") + # unknown purpose; don't surface to avoid confusion + # self.target_date = status["TargetDate"] + + +class CarwingsDrivingAnalysisResponse(CarwingsResponse): + """ + { + "status":200, + "message":"success", + "DriveAnalysisBasicScreenResponsePersonalData": { + "DateSummary":{ + "TargetDate":"2016-02-03", + "ElectricMileage":"4.4", + "ElectricMileageLevel":"3", + "PowerConsumptMoter":"295.2", + "PowerConsumptMoterLevel":"4", + "PowerConsumptMinus":"84.8", + "PowerConsumptMinusLevel":"3", + "PowerConsumptAUX":"17.1", + "PowerConsumptAUXLevel":"5", + "DisplayDate":"Feb 3, 16" + }, + "ElectricCostScale":"miles/kWh" + }, + "AdviceList":{ + "Advice":{ + "title":"World Number of Trips Rankings (last week):", + "body":"The highest number of trips driven was 130 by a driver located in Japan." + } + } + } + """ + def __init__(self, status): + CarwingsResponse.__init__(self, status) + + summary = status["DriveAnalysisBasicScreenResponsePersonalData"]["DateSummary"] + + # avg energy economy, in units of 'electric_cost_scale' (e.g. miles/kWh) + self.electric_mileage = summary["ElectricMileage"] + # rating for above, scale of 1-5 + self.electric_mileage_level = summary["ElectricMileageLevel"] + + # "acceleration performance": "electricity used for motor activation over 1km", Watt-Hours + self.power_consumption_moter = summary["PowerConsumptMoter"] + # rating for above, scale of 1-5 + self.power_consumption_moter_level = summary["PowerConsumptMoterLevel"] + + # Watt-Hours generated by braking + self.power_consumption_minus = summary["PowerConsumptMinus"] + # rating for above, scale of 1-5 + self.power_consumption_minus_level = summary["PowerConsumptMinusLevel"] + + # Electricity used by aux devices, Watt-Hours + self.power_consumption_aux = summary["PowerConsumptAUX"] + # rating for above, scale of 1-5 + self.power_consumption_aux_level = summary["PowerConsumptAUXLevel"] + + self.display_date = summary["DisplayDate"] # "Feb 3, 16" + + self.electric_cost_scale = status["DriveAnalysisBasicScreenResponsePersonalData"]["ElectricCostScale"] + + self.advice = [status["AdviceList"]["Advice"]] # will contain "title" and "body" + + +class CarwingsLatestBatteryStatusResponse(CarwingsResponse): + """ + # not connected to a charger + { + "status":200, + "message":"success", + "BatteryStatusRecords":{ + "OperationResult":"START", + "OperationDateAndTime":"Feb 9, 2016 11:09 PM", + "BatteryStatus":{ + "BatteryChargingStatus":"NOT_CHARGING", + "BatteryCapacity":"12", + "BatteryRemainingAmount":"3", + "BatteryRemainingAmountWH":"", + "BatteryRemainingAmountkWH":"" + }, + "PluginState":"NOT_CONNECTED", + "CruisingRangeAcOn":"39192.0", + "CruisingRangeAcOff":"39744.0", + "TimeRequiredToFull":{ # 120V + "HourRequiredToFull":"18", + "MinutesRequiredToFull":"30" + }, + "TimeRequiredToFull200":{ # 240V, 3kW + "HourRequiredToFull":"6", + "MinutesRequiredToFull":"0" + }, + "TimeRequiredToFull200_6kW":{ # 240V, 6kW + "HourRequiredToFull":"4", + "MinutesRequiredToFull":"0" + }, + "NotificationDateAndTime":"2016/02/10 04:10", + "TargetDate":"2016/02/10 04:09" + } + } + + # not connected to a charger - as at 21/01/2019 20:01 (for a 30kWh leaf) + { + "status":200, + "BatteryStatusRecords": { + "OperationResult":"START", + "OperationDateAndTime":"21-Jan-2019 13:29", + "BatteryStatus":{ + "BatteryChargingStatus":"NOT_CHARGING", + "BatteryCapacity":"240", + "BatteryRemainingAmount":"220", + "BatteryRemainingAmountWH":"24480", + "BatteryRemainingAmountkWH":"", + "SOC":{ + "Value":"91" + } + }, + "PluginState":"NOT_CONNECTED", + "CruisingRangeAcOn":"146000", + "CruisingRangeAcOff":"168000", + "TimeRequiredToFull":{ + "HourRequiredToFull":"4", + "MinutesRequiredToFull":"30" + }, + "TimeRequiredToFull200":{ + "HourRequiredToFull":"3" + ,"MinutesRequiredToFull":"0" + }, + "TimeRequiredToFull200_6kW":{ + "HourRequiredToFull":"1", + "MinutesRequiredToFull":"30" + }, + "NotificationDateAndTime":"2019/01/21 13:29", + "TargetDate":"2019/01/21 13:29" + } + } + + + # connected to a quick charger + { + "status":200, + "message":"success", + "BatteryStatusRecords":{ + "OperationResult":"START", + "OperationDateAndTime":"Feb 14, 2016 03:28 PM", + "BatteryStatus":{ + "BatteryChargingStatus":"RAPIDLY_CHARGING", + "BatteryCapacity":"12", + "BatteryRemainingAmount":"11", + "BatteryRemainingAmountWH":"", + "BatteryRemainingAmountkWH":"" + }, + "PluginState":"QC_CONNECTED", + "CruisingRangeAcOn":"107136.0", + "CruisingRangeAcOff":"115776.0", + "NotificationDateAndTime":"2016/02/14 20:28", + "TargetDate":"2016/02/14 20:28" + } + } + + # connected to a charging station + { + "status": 200, + "message": "success", + "BatteryStatusRecords": { + "OperationResult": "START", + "OperationDateAndTime": "Feb 19, 2016 12:12 PM", + "BatteryStatus": { + "BatteryChargingStatus": "NORMAL_CHARGING", + "BatteryCapacity": "12", + "BatteryRemainingAmount": "12", + "BatteryRemainingAmountWH": "", + "BatteryRemainingAmountkWH": "" + }, + "PluginState": "CONNECTED", + "CruisingRangeAcOn": "132000.0", + "CruisingRangeAcOff": "134000.0", + "TimeRequiredToFull200_6kW": { + "HourRequiredToFull": "0", + "MinutesRequiredToFull": "40" + }, + "NotificationDateAndTime": "2016/02/19 17:12", + "TargetDate": "2016/02/19 17:12" + } + } + """ + def __init__(self, status): + CarwingsResponse.__init__(self, status["BatteryStatusRecords"]) + self.answer = status + + recs = status["BatteryStatusRecords"] + + bs = recs["BatteryStatus"] + self.battery_capacity = bs["BatteryCapacity"] + self.battery_remaining_amount = bs["BatteryRemainingAmount"] + self.charging_status = bs["BatteryChargingStatus"] + self.is_charging = ("NOT_CHARGING" != bs["BatteryChargingStatus"]) # double negatives are fun + self.is_quick_charging = ("RAPIDLY_CHARGING" == bs["BatteryChargingStatus"]) + + self.plugin_state = recs["PluginState"] + self.is_connected = ("NOT_CONNECTED" != recs["PluginState"]) # another double negative + self.is_connected_to_quick_charger = ("QC_CONNECTED" == recs["PluginState"]) + + self._set_cruising_ranges(recs, off_key="CruisingRangeAcOff", on_key="CruisingRangeAcOn") + + if "TimeRequiredToFull" in recs: + self.time_to_full_trickle = timedelta(minutes=_time_remaining(recs["TimeRequiredToFull"])) + else: + self.time_to_full_trickle = None + + if "TimeRequiredToFull200" in recs: + self.time_to_full_l2 = timedelta(minutes=_time_remaining(recs["TimeRequiredToFull200"])) + else: + self.time_to_full_l2 = None + + if "TimeRequiredToFull200_6kW" in recs: + self.time_to_full_l2_6kw = timedelta(minutes=_time_remaining(recs["TimeRequiredToFull200_6kW"])) + else: + self.time_to_full_l2_6kw = None + + if float(self.battery_capacity) == 0: + log.debug("battery_capacity=0, status=%s", status) + self.battery_percent = 0 + else: + self.battery_percent = 100 * float(self.battery_remaining_amount) / 12 + + # Leaf 2016 has SOC (State Of Charge) in BatteryStatus, a more accurate battery_percentage + if "SOC" in bs: + self.state_of_charge = bs["SOC"]["Value"] + # Update battery_percent with more accurate version + self.battery_percent = float(self.state_of_charge) + else: + self.state_of_charge = None + + +class CarwingsElectricRateSimulationResponse(CarwingsResponse): + def __init__(self, status): + CarwingsResponse.__init__(self, status) + + r = status["PriceSimulatorDetailInfoResponsePersonalData"] + t = r["PriceSimulatorTotalInfo"] + + self.month = r["DisplayMonth"] # e.g. "Feb/2016" + + self.total_number_of_trips = t["TotalNumberOfTrips"] + self.total_power_consumption = t["TotalPowerConsumptTotal"] # in kWh + self.total_acceleration_power_consumption = t["TotalPowerConsumptMoter"] # in kWh + self.total_power_regenerated_in_braking = t["TotalPowerConsumptMinus"] # in kWh + self.total_travel_distance_km = float(t["TotalTravelDistance"]) / 1000 # assumed to be in meters + + self.total_electric_mileage = t["TotalElectricMileage"] + self.total_co2_reduction = t["TotalCO2Reductiont"] # (yep, extra 't' at the end) + + self.electricity_rate = r["ElectricPrice"] + self.electric_bill = r["ElectricBill"] + self.electric_cost_scale = r["ElectricCostScale"] # e.g. "miles/kWh" + + +class CarwingsMyCarFinderResponse(CarwingsResponse): + """ + { + "Location": { + "Country": "", + "Home": "OUTSIDE", + "LatitudeDeg": "69", + "LatitudeMin": "41", + "LatitudeMode": "NORTH", + "LatitudeSec": "5540", + "LocationType": "WGS84", + "LongitudeDeg": "18", + "LongitudeMin": "38", + "LongitudeMode": "EAST", + "LongitudeSec": "2506", + "Position": "UNAVAILABLE" + }, + "TargetDate": "2017/11/29 20:02", + "lat": "69.698722222222", + "lng": "18.640294444444", + "receivedDate": "2017/11/29 20:02", + "responseFlag": "1", + "resultCode": "1", + "status": 200, + "timeStamp": "2017-11-29 20:02:45" + } + """ + def __init__(self, status): + CarwingsResponse.__init__(self, status) + + self.latitude = status["lat"] + self.longitude = status["lng"] diff --git a/packages/modules/vehicles/leaf/soc.py b/packages/modules/vehicles/leaf/soc.py new file mode 100644 index 0000000000..a6a57e8cc6 --- /dev/null +++ b/packages/modules/vehicles/leaf/soc.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +from typing import List + +import logging +import time + +from helpermodules.cli import run_using_positional_cli_args +from modules.common import store +from modules.common.abstract_device import DeviceDescriptor +from modules.common.abstract_vehicle import VehicleUpdateData +from modules.common.component_state import CarState +from modules.common.configurable_vehicle import ConfigurableVehicle +from modules.vehicles.leaf.config import LeafSoc, LeafConfiguration + +from modules.vehicles.leaf import pycarwings2 + +log = logging.getLogger(__name__) + + +def fetch_soc(username, password, chargepoint) -> CarState: + + region = "NE" + + def getNissanSession(): # open Https session with Nissan server + log.debug("LP%s: login = %s, region = %s" % (chargepoint, username, region)) + s = pycarwings2.Session(username, password, region) + leaf = s.get_leaf() + time.sleep(1) # give Nissan server some time + return leaf + + def readSoc(leaf): # get SoC from Nissan server + leaf_info = leaf.get_latest_battery_status() + bat_percent = int(leaf_info.battery_percent) + log.debug("LP%s: Battery status %s" % (chargepoint, bat_percent)) + return bat_percent + + def requestSoc(leaf): # request Nissan server to request last SoC from car + log.debug("LP%s: Request SoC Update" % (chargepoint)) + key = leaf.request_update() + status = leaf.get_status_from_update(key) + sleepsecs = 20 + for i in range(0,9): + log.debug("Waiting {0} seconds".format(sleepsecs)) + time.sleep(sleepsecs) + status = leaf.get_status_from_update(key) + if status is not None: + break + log.debug("LP%s: Finished updating" % (chargepoint)) + + leaf = getNissanSession() # start Https session with Nissan Server + readSoc(leaf) # old SoC needs to be read from server before requesting new SoC from car + time.sleep(1) # give Nissan server some time + requestSoc(leaf) # Nissan server to request new SoC from car + time.sleep(1) # give Nissan server some time + soc = readSoc(leaf) # final read of SoC from server + return CarState(soc) + + +def create_vehicle(vehicle_config: LeafSoc, vehicle: int): + def updater(vehicle_update_data: VehicleUpdateData) -> CarState: + return fetch_soc( + vehicle_config.configuration.user_id, + vehicle_config.configuration.password, + vehicle) + return ConfigurableVehicle(vehicle_config=vehicle_config, component_updater=updater, vehicle=vehicle) + + +def leaf_update(user_id: str, password: str, charge_point: int): + log.debug("Leaf: user_id="+user_id+"charge_point="+str(charge_point)) + vehicle_config = LeafSoc(configuration=LeafConfiguration(charge_point, user_id, password)) + store.get_car_value_store(charge_point).store.set(fetch_soc( + vehicle_config.configuration.user_id, + vehicle_config.configuration.password, + charge_point)) + + +def main(argv: List[str]): + run_using_positional_cli_args(leaf_update, argv) + + +device_descriptor = DeviceDescriptor(configuration_factory=LeafSoc)