From 30d5315d1fad5fb0365f064998fade4cd00334d1 Mon Sep 17 00:00:00 2001 From: Paul Bot Date: Thu, 1 Jul 2021 15:05:03 +0300 Subject: [PATCH] Added new plugin for smartcar. --- smartcar/integrationpluginsmartcar.json | 135 ++++++++++++++++ smartcar/integrationpluginsmartcar.py | 204 ++++++++++++++++++++++++ smartcar/requirements.txt | 4 + 3 files changed, 343 insertions(+) create mode 100644 smartcar/integrationpluginsmartcar.json create mode 100644 smartcar/integrationpluginsmartcar.py create mode 100644 smartcar/requirements.txt diff --git a/smartcar/integrationpluginsmartcar.json b/smartcar/integrationpluginsmartcar.json new file mode 100644 index 00000000..4c7fb962 --- /dev/null +++ b/smartcar/integrationpluginsmartcar.json @@ -0,0 +1,135 @@ +{ + "name": "smartcar", + "displayName": "Smartcar", + "id": "f33bb38d-640e-4b15-a39e-f0e7b2cc6993", + "paramTypes": [ + { + "id": "bb5ee037-5034-4921-b1d8-3936bc515fa6", + "name": "customClientId", + "displayName": "Client ID", + "defaultValue": "", + "type": "QString" + }, + { + "id": "b60c7f7b-8175-437c-8056-8258cc8a3bd5", + "name": "customClientSecret", + "displayName": "Client secret", + "defaultValue": "", + "type": "QString" + } + ], + "apiKeys": ["smartcar"], + "vendors": [ + { + "name": "smartcar", + "displayName": "Smartcar", + "id": "e52d223f-910d-47a0-a0e4-8f0718d453cd", + "thingClasses": [ + { + "name": "smartcarAccount", + "displayName": "Smartcar", + "id": "da78acf9-a64c-4976-8344-26b8e68dcb5f", + "setupMethod": "oauth", + "createMethods": ["user"], + "interfaces": ["account"], + "paramTypes": [ + ], + "settingsTypes": [ + { + "id": "ca4f36ac-b202-4a87-8c17-d59a6e4cf07b", + "name": "socRefreshPeriod", + "displayName": "SOC Refresh Period", + "type": "uint", + "unit": "Seconds", + "defaultValue": 120, + "minValue": 10 + } + ], + "stateTypes":[ + { + "id": "4b7f54bc-460c-4bfa-a4e5-032d5ff3c984", + "name": "connected", + "displayName": "Connected", + "displayNameEvent": "Connected changed", + "defaultValue": true, + "cached": false, + "type": "bool" + }, + { + "id": "d03ce680-ebf4-45f1-950a-586b3f026119", + "name": "loggedIn", + "displayName": "Logged in", + "displayNameEvent": "Logged in changed", + "defaultValue": true, + "type": "bool" + } + ], + "actionTypes":[ + ], + "eventTypes":[ + ] + }, + { + "name": "vehicle", + "displayName": "Vehicle", + "id": "77ddd9f7-7c16-4bf3-bff0-c3a2208c8c66", + "createMethods": ["auto"], + "interfaces": ["battery"], + "paramTypes": [ + { + "id": "e78e6157-e884-43d0-9f50-6e433ce25ed4", + "name": "make", + "displayName": "Make", + "defaultValue": "-", + "type": "QString" + }, + { + "id": "33d63299-f3c0-4c07-a423-85233f2e11a0", + "name": "vehicleid", + "displayName": "Vehicle ID", + "defaultValue": "-", + "type": "QString" + } + ], + "stateTypes":[ + { + "id": "b772b264-5267-41b0-8806-ecc4351bc94a", + "name": "batteryLevel", + "displayName": "State of Charge", + "displayNameEvent": "Battery changed", + "type": "int", + "unit": "Percentage", + "defaultValue": 0, + "minValue": 0, + "maxValue": 100 + }, + { + "id": "297aed0b-72ce-4d04-a280-22d00e0389d8", + "name": "batteryCritical", + "displayName": "State of Charge Critical", + "displayNameEvent": "Battery critical changed", + "type": "bool", + "defaultValue": false + }, + { + "id": "fbf040a8-90aa-4d4f-8885-39f4bfcf0b00", + "name": "pluggedIn", + "displayName": "Plugged In", + "displayNameEvent": "Plugged In state changed", + "type": "bool", + "defaultValue": "false" + }, + { + "id": "056ac150-c31b-4c3b-8a8c-4a009e503eb9", + "name": "charging", + "displayName": "Charging", + "displayNameEvent": "Charging state changed", + "type": "bool", + "defaultValue": "false" + } + ] + } + ] + } + ] +} diff --git a/smartcar/integrationpluginsmartcar.py b/smartcar/integrationpluginsmartcar.py new file mode 100644 index 00000000..bb57fca1 --- /dev/null +++ b/smartcar/integrationpluginsmartcar.py @@ -0,0 +1,204 @@ +import nymea +import smartcar +import urllib.parse as urlparse +from urllib.parse import parse_qs +import threading +import time +import json +import datetime + +smartcarClient = None +defaultPollTimerInterval = 20 # will be read from settings +pollTimers = {} +oauthSessions = {} + +def init(): + logger.log("Smartcar init") + +def deinit(): + # If we started any poll timers, cancel at shutdown + for timerId in pollTimers: + if timerId in pollTimers and pollTimers[timerId] is not None: + pollTimers[timerId].cancel() + +def startPairing(info): + logger.log("Start pairing") + + global smartcarClient + global oauthSessions + + if info.thingClassId == smartcarAccountThingClassId: + logger.log("Starting pairing: ", smartcarAccountThingClassId) + smartcarClient = createSmartcarClient() + logger.log("Auth URL: ", smartcarClient.get_auth_url()) + oauthSessions[info.transactionId] = smartcarClient + info.oAuthUrl = smartcarClient.get_auth_url() + info.finish(nymea.ThingErrorNoError) + else: + logger.log("Unhandled pairing method") + info.finish(nymea.ThingErrorCreationMethodNotSupported) + +def confirmPairing(info, user, secret): + logger.log("Confirm pairing...") + if info.thingClassId == smartcarAccountThingClassId: + parsed = urlparse.urlparse(secret) + code = parse_qs(parsed.query)['code'][0] + accessToken = smartcarClient.exchange_code(code) + + saveToken(info.thingId, accessToken) + del oauthSessions[info.transactionId] + info.finish(nymea.ThingErrorNoError) + else: + info.finish(nymea.ThingErrorCreationMethodNotSupported) + +def setupThing(info): + logger.log("Setting up thing:", info.thing.name) + + # setup the account (login and discover vehicles) + if info.thing.thingClassId == smartcarAccountThingClassId: + logger.log("Setting up the account") + # deal with the connection / auth + try: + client = createSmartcarClient() + accessToken = getAccessToken(client, info.thing.id) + info.finish(nymea.ThingErrorNoError) + except Exception as e: + logger.error("Error setting up smartcar account: ", str(e)) + info.finish(nymea.ThingErrorAuthenticationFailure, str(e)) + return + + info.thing.setStateValue(smartcarAccountLoggedInStateTypeId, True) + info.thing.setStateValue(smartcarAccountConnectedStateTypeId, True) + vehicle_ids = smartcar.get_vehicle_ids(accessToken)['vehicles'] + + thingDescriptors = [] + for raw_vehicle in vehicle_ids: + found = False + vehicle = smartcar.Vehicle(raw_vehicle, accessToken) + logger.log(vehicle.info()) + logger.log("-----------------") + for thing in myThings(): + if thing.thingClassId == vehicleThingClassId and thing.paramValue(vehicleThingVehicleidParamTypeId) == vehicle.info()['id']: + logger.log("Vehicle already added: ", vehicle.info()['id']) + found = True + break + if found: + continue + + logger.log("Adding new vehicle to the system: ", vehicle.info()['id'], " parent id: ", info.thing.id) + thingDescriptor = nymea.ThingDescriptor(vehicleThingClassId, vehicle.info()['model'], parentId=info.thing.id) + thingDescriptor.params = [ + nymea.Param(vehicleThingVehicleidParamTypeId, vehicle.info()['id']), + nymea.Param(vehicleThingMakeParamTypeId, vehicle.info()['make']) + ] + thingDescriptors.append(thingDescriptor) + + logger.log("New vehicles appeared") + autoThingsAppeared(thingDescriptors) + createPollTimer(info.thing) + info.thing.settingChangedHandler = socRefreshRateChanged + return + + # setup individual vehicles + if info.thing.thingClassId == vehicleThingClassId: + logger.log("Should setup vehicle here: ", info.thing.name) + info.finish(nymea.ThingErrorNoError) + + +def postSetupThing(thing): + logger.log("postSetupThing") + +def thingRemoved(thing): + logger.log("thingRemoved:", thing.name) + +def createSmartcarClient(): + apiKey = apiKeyStorage().requestKey("smartcar") + clientId = apiKey.data("clientId") + clientSecret = apiKey.data("clientSecret") + redirectUri = "https://127.0.0.1:8888" + + smartcarClient = smartcar.AuthClient( + client_id=clientId, + client_secret=clientSecret, + redirect_uri=redirectUri, + scope=['required:read_vehicle_info', 'required:read_battery', 'required:read_charge'], + test_mode=True + ) + return smartcarClient + + +def socRefreshRateChanged(thing, paramTypeId, value): + if paramTypeId == smartcarAccountSettingsSocRefreshPeriodParamTypeId: + logger.log("Refresh rate changed for ", thing.id, " , new value: ", value) + + if (thing.id in pollTimers) and (pollTimers[thing.id] is not None): + logger.log("Timer already exists, cancelling it first") + pollTimers[thing.id].cancel() + + pollTimers[thing.id] = threading.Timer(value, pollService, [thing.id]) + pollTimers[thing.id].start() + +def createPollTimer(thing): + timerId = thing.id + if (timerId not in pollTimers) or (pollTimers[timerId] is None): + pollTimers[timerId] = threading.Timer(5, pollService, [thing.id]) + pollTimers[timerId].start() + +def pollService(parentThingId): + socRefreshSettingValue = defaultPollTimerInterval + for thing in myThings(): + if thing.parentId == parentThingId and thing.thingClassId == vehicleThingClassId: + try: + refreshVehicleSOC(createSmartcarClient(), thing) + except: + logger.error("Error refreshing vehicle SOC") + if thing.id == parentThingId: + socRefreshSettingValue = thing.setting(smartcarAccountSettingsSocRefreshPeriodParamTypeId) + + # pollTimerInterval can be modified in settings to refresh the SoC faster + # when needed for a demo. When not needed, this value should be higher (e.g. 120 sec) + # because the demo account has an API call quota whose limit can be easily reahed + # when using high refresh rates. + pollTimerInterval = socRefreshSettingValue or defaultPollTimerInterval + pollTimers[parentThingId] = threading.Timer(pollTimerInterval, pollService, [parentThingId]) + pollTimers[parentThingId].start() + +def refreshVehicleSOC(client, thing): + vid = thing.paramValue(vehicleThingVehicleidParamTypeId) + logger.log("Refreshing SOC for vehicle: ", vid) + vehicle = smartcar.Vehicle(vid, getAccessToken(client, thing.parentId)) + + # update the state of charge (battery level) + soc = vehicle.battery() + logger.log(soc) + percentage = soc["data"]["percentRemaining"] + thing.setStateValue(vehicleBatteryLevelStateTypeId, percentage * 100) + if percentage * 100 < 10: + thing.setStateValue(vehicleBatteryCriticalStateTypeId, True) + else: + thing.setStateValue(vehicleBatteryCriticalStateTypeId, False) + + # update charing status + chargingState = vehicle.charge() + logger.log(chargingState) + thing.setStateValue(vehiclePluggedInStateTypeId, chargingState["data"]["isPluggedIn"]) + thing.setStateValue(vehicleChargingStateTypeId, chargingState["data"]["state"] == "CHARGING") + + +def saveToken(thingId, token): + pluginStorage().beginGroup(thingId) + pluginStorage().setValue("token", json.dumps(token, default=str)) + pluginStorage().endGroup() + +def getAccessToken(client, thingId): + pluginStorage().beginGroup(thingId) + token = json.loads(pluginStorage().value("token")) + pluginStorage().endGroup() + + logger.log("Retrieved token from pluginStorage: ", token) + expiration_date_time_obj = datetime.datetime.strptime(token['expiration'], '%Y-%m-%d %H:%M:%S.%f') + if smartcar.is_expired(expiration_date_time_obj): + token = client.exchange_refresh_token(token['refresh_token']) + saveToken(thingId, token) + return token['access_token'] + \ No newline at end of file diff --git a/smartcar/requirements.txt b/smartcar/requirements.txt new file mode 100644 index 00000000..95d886ce --- /dev/null +++ b/smartcar/requirements.txt @@ -0,0 +1,4 @@ +Flask==1.0.2 +flask-cors==3.0.9 +requests==2.20.1 +smartcar==5.2.1