Skip to content

Commit 0318f55

Browse files
committed
Merge PR #451: WIP: New plugin - smartcar
2 parents c466deb + db8f564 commit 0318f55

File tree

5 files changed

+419
-0
lines changed

5 files changed

+419
-0
lines changed

debian/control

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -694,6 +694,22 @@ Description: nymea.io plugin for Solar-Log
694694
This package will install the nymea.io plugin for Solar-Log.
695695

696696

697+
Package: nymea-plugin-smartcar
698+
Architecture: any
699+
Depends: ${shlibs:Depends},
700+
${misc:Depends},
701+
nymea-plugins-translations,
702+
python3-pip,
703+
Description: nymea.io plugin for smartcar
704+
The nymea daemon is a plugin based IoT (Internet of Things) server. The
705+
server works like a translator for devices, things and services and
706+
allows them to interact.
707+
With the powerful rule engine you are able to connect any device available
708+
in the system and create individual scenes and behaviors for your environment.
709+
.
710+
This package will install the nymea.io plugin for Smartcar (see https://smartcar.com/).
711+
712+
697713
Package: nymea-plugin-tasmota
698714
Architecture: any
699715
Depends: ${shlibs:Depends},
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
smartcar/integrationpluginsmartcar.json usr/lib/@DEB_HOST_MULTIARCH@/nymea/plugins/smartcar/
2+
smartcar/integrationpluginsmartcar.py usr/lib/@DEB_HOST_MULTIARCH@/nymea/plugins/smartcar/
3+
smartcar/requirements.txt usr/lib/@DEB_HOST_MULTIARCH@/nymea/plugins/smartcar/
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
{
2+
"name": "smartcar",
3+
"displayName": "Smartcar",
4+
"id": "f33bb38d-640e-4b15-a39e-f0e7b2cc6993",
5+
"paramTypes": [
6+
{
7+
"id": "70e2c7a1-55b4-47fd-83b5-549de30b1391",
8+
"name": "testMode",
9+
"displayName": "Test Mode",
10+
"type": "bool",
11+
"defaultValue": false
12+
},
13+
{
14+
"id": "bb5ee037-5034-4921-b1d8-3936bc515fa6",
15+
"name": "customClientId",
16+
"displayName": "Client ID",
17+
"defaultValue": "",
18+
"type": "QString"
19+
},
20+
{
21+
"id": "b60c7f7b-8175-437c-8056-8258cc8a3bd5",
22+
"name": "customClientSecret",
23+
"displayName": "Client secret",
24+
"defaultValue": "",
25+
"type": "QString"
26+
}
27+
],
28+
"apiKeys": ["smartcar"],
29+
"vendors": [
30+
{
31+
"name": "smartcar",
32+
"displayName": "Smartcar",
33+
"id": "e52d223f-910d-47a0-a0e4-8f0718d453cd",
34+
"thingClasses": [
35+
{
36+
"name": "smartcarAccount",
37+
"displayName": "Smartcar",
38+
"id": "da78acf9-a64c-4976-8344-26b8e68dcb5f",
39+
"setupMethod": "oauth",
40+
"createMethods": ["user"],
41+
"interfaces": ["account"],
42+
"paramTypes": [
43+
],
44+
"settingsTypes": [
45+
{
46+
"id": "ca4f36ac-b202-4a87-8c17-d59a6e4cf07b",
47+
"name": "socRefreshPeriod",
48+
"displayName": "SOC Refresh Period",
49+
"type": "uint",
50+
"unit": "Seconds",
51+
"defaultValue": 120,
52+
"minValue": 10
53+
}
54+
],
55+
"stateTypes":[
56+
{
57+
"id": "4b7f54bc-460c-4bfa-a4e5-032d5ff3c984",
58+
"name": "connected",
59+
"displayName": "Connected",
60+
"displayNameEvent": "Connected changed",
61+
"defaultValue": true,
62+
"cached": false,
63+
"type": "bool"
64+
},
65+
{
66+
"id": "d03ce680-ebf4-45f1-950a-586b3f026119",
67+
"name": "loggedIn",
68+
"displayName": "Logged in",
69+
"displayNameEvent": "Logged in changed",
70+
"defaultValue": true,
71+
"type": "bool"
72+
}
73+
],
74+
"actionTypes":[
75+
],
76+
"eventTypes":[
77+
]
78+
},
79+
{
80+
"name": "vehicle",
81+
"displayName": "Vehicle",
82+
"id": "77ddd9f7-7c16-4bf3-bff0-c3a2208c8c66",
83+
"createMethods": ["auto"],
84+
"interfaces": ["battery"],
85+
"paramTypes": [
86+
{
87+
"id": "e78e6157-e884-43d0-9f50-6e433ce25ed4",
88+
"name": "make",
89+
"displayName": "Make",
90+
"defaultValue": "-",
91+
"type": "QString"
92+
},
93+
{
94+
"id": "33d63299-f3c0-4c07-a423-85233f2e11a0",
95+
"name": "vehicleid",
96+
"displayName": "Vehicle ID",
97+
"defaultValue": "-",
98+
"type": "QString"
99+
}
100+
],
101+
"stateTypes":[
102+
{
103+
"id": "b772b264-5267-41b0-8806-ecc4351bc94a",
104+
"name": "batteryLevel",
105+
"displayName": "State of Charge",
106+
"displayNameEvent": "Battery changed",
107+
"type": "int",
108+
"unit": "Percentage",
109+
"defaultValue": 0,
110+
"minValue": 0,
111+
"maxValue": 100
112+
},
113+
{
114+
"id": "297aed0b-72ce-4d04-a280-22d00e0389d8",
115+
"name": "batteryCritical",
116+
"displayName": "State of Charge Critical",
117+
"displayNameEvent": "Battery critical changed",
118+
"type": "bool",
119+
"defaultValue": false
120+
},
121+
{
122+
"id": "fbf040a8-90aa-4d4f-8885-39f4bfcf0b00",
123+
"name": "pluggedIn",
124+
"displayName": "Plugged In",
125+
"displayNameEvent": "Plugged In state changed",
126+
"type": "bool",
127+
"defaultValue": "false"
128+
},
129+
{
130+
"id": "056ac150-c31b-4c3b-8a8c-4a009e503eb9",
131+
"name": "charging",
132+
"displayName": "Charging",
133+
"displayNameEvent": "Charging state changed",
134+
"type": "bool",
135+
"defaultValue": "false"
136+
}
137+
]
138+
}
139+
]
140+
}
141+
]
142+
}
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import nymea
2+
import smartcar
3+
import urllib.parse as urlparse
4+
from urllib.parse import parse_qs
5+
import threading
6+
import time
7+
import json
8+
import datetime
9+
10+
smartcarClient = None
11+
defaultPollTimerInterval = 20 # will be read from settings
12+
pollTimers = {}
13+
oauthSessions = {}
14+
15+
def init():
16+
logger.log("Smartcar init")
17+
18+
def deinit():
19+
# If we started any poll timers, cancel at shutdown
20+
for timerId in pollTimers:
21+
if timerId in pollTimers and pollTimers[timerId] is not None:
22+
pollTimers[timerId].cancel()
23+
24+
def startPairing(info):
25+
logger.log("Start pairing")
26+
27+
global smartcarClient
28+
global oauthSessions
29+
30+
if info.thingClassId == smartcarAccountThingClassId:
31+
logger.log("Starting pairing: ", smartcarAccountThingClassId)
32+
smartcarClient = createSmartcarClient()
33+
logger.log("Auth URL: ", smartcarClient.get_auth_url())
34+
oauthSessions[info.transactionId] = smartcarClient
35+
info.oAuthUrl = smartcarClient.get_auth_url()
36+
info.finish(nymea.ThingErrorNoError)
37+
else:
38+
logger.log("Unhandled pairing method")
39+
info.finish(nymea.ThingErrorCreationMethodNotSupported)
40+
41+
def confirmPairing(info, user, secret):
42+
logger.log("Confirm pairing...")
43+
if info.thingClassId == smartcarAccountThingClassId:
44+
parsed = urlparse.urlparse(secret)
45+
code = parse_qs(parsed.query)['code'][0]
46+
accessToken = smartcarClient.exchange_code(code)
47+
48+
saveToken(info.thingId, accessToken)
49+
del oauthSessions[info.transactionId]
50+
info.finish(nymea.ThingErrorNoError)
51+
else:
52+
info.finish(nymea.ThingErrorCreationMethodNotSupported)
53+
54+
def setupThing(info):
55+
logger.log("Setting up thing:", info.thing.name)
56+
57+
# setup the account (login and discover vehicles)
58+
if info.thing.thingClassId == smartcarAccountThingClassId:
59+
logger.log("Setting up the account")
60+
# deal with the connection / auth
61+
try:
62+
client = createSmartcarClient()
63+
accessToken = getAccessToken(client, info.thing.id)
64+
info.finish(nymea.ThingErrorNoError)
65+
except Exception as e:
66+
logger.error("Error setting up smartcar account: ", str(e))
67+
info.finish(nymea.ThingErrorAuthenticationFailure, str(e))
68+
return
69+
70+
info.thing.setStateValue(smartcarAccountLoggedInStateTypeId, True)
71+
info.thing.setStateValue(smartcarAccountConnectedStateTypeId, True)
72+
vehicle_ids = smartcar.get_vehicle_ids(accessToken)['vehicles']
73+
74+
thingDescriptors = []
75+
for raw_vehicle in vehicle_ids:
76+
found = False
77+
vehicle = smartcar.Vehicle(raw_vehicle, accessToken)
78+
logger.log(vehicle.info())
79+
logger.log("-----------------")
80+
for thing in myThings():
81+
if thing.thingClassId == vehicleThingClassId and thing.paramValue(vehicleThingVehicleidParamTypeId) == vehicle.info()['id']:
82+
logger.log("Vehicle already added: ", vehicle.info()['id'])
83+
found = True
84+
break
85+
if found:
86+
continue
87+
88+
logger.log("Adding new vehicle to the system: ", vehicle.info()['id'], " parent id: ", info.thing.id)
89+
thingDescriptor = nymea.ThingDescriptor(vehicleThingClassId, vehicle.info()['model'], parentId=info.thing.id)
90+
thingDescriptor.params = [
91+
nymea.Param(vehicleThingVehicleidParamTypeId, vehicle.info()['id']),
92+
nymea.Param(vehicleThingMakeParamTypeId, vehicle.info()['make'])
93+
]
94+
thingDescriptors.append(thingDescriptor)
95+
96+
logger.log("New vehicles appeared")
97+
autoThingsAppeared(thingDescriptors)
98+
createPollTimer(info.thing)
99+
info.thing.settingChangedHandler = socRefreshRateChanged
100+
return
101+
102+
# setup individual vehicles
103+
if info.thing.thingClassId == vehicleThingClassId:
104+
logger.log("Should setup vehicle here: ", info.thing.name)
105+
info.finish(nymea.ThingErrorNoError)
106+
107+
108+
def postSetupThing(thing):
109+
logger.log("postSetupThing")
110+
111+
def thingRemoved(thing):
112+
logger.log("thingRemoved:", thing.name)
113+
114+
def createSmartcarClient():
115+
apiKey = apiKeyStorage().requestKey("smartcar")
116+
clientId = apiKey.data("clientId")
117+
clientSecret = apiKey.data("clientSecret")
118+
redirectUri = "https://127.0.0.1:8888"
119+
120+
testMode = configValue(smartcarPluginTestModeParamTypeId)
121+
logger.log("Test mode enabled: ", testMode)
122+
123+
smartcarClient = smartcar.AuthClient(
124+
client_id=clientId,
125+
client_secret=clientSecret,
126+
redirect_uri=redirectUri,
127+
scope=['required:read_vehicle_info', 'required:read_battery', 'required:read_charge'],
128+
test_mode=testMode
129+
)
130+
return smartcarClient
131+
132+
133+
def socRefreshRateChanged(thing, paramTypeId, value):
134+
if paramTypeId == smartcarAccountSettingsSocRefreshPeriodParamTypeId:
135+
logger.log("Refresh rate changed for ", thing.id, " , new value: ", value)
136+
137+
if (thing.id in pollTimers) and (pollTimers[thing.id] is not None):
138+
logger.log("Timer already exists, cancelling it first")
139+
pollTimers[thing.id].cancel()
140+
141+
pollTimers[thing.id] = threading.Timer(value, pollService, [thing.id])
142+
pollTimers[thing.id].start()
143+
144+
def createPollTimer(thing):
145+
timerId = thing.id
146+
if (timerId not in pollTimers) or (pollTimers[timerId] is None):
147+
pollTimers[timerId] = threading.Timer(5, pollService, [thing.id])
148+
pollTimers[timerId].start()
149+
150+
def pollService(parentThingId):
151+
socRefreshSettingValue = defaultPollTimerInterval
152+
for thing in myThings():
153+
if thing.parentId == parentThingId and thing.thingClassId == vehicleThingClassId:
154+
try:
155+
refreshVehicleSOC(createSmartcarClient(), thing)
156+
except:
157+
logger.error("Error refreshing vehicle SOC")
158+
if thing.id == parentThingId:
159+
socRefreshSettingValue = thing.setting(smartcarAccountSettingsSocRefreshPeriodParamTypeId)
160+
161+
# pollTimerInterval can be modified in settings to refresh the SoC faster
162+
# when needed for a demo. When not needed, this value should be higher (e.g. 120 sec)
163+
# because the demo account has an API call quota whose limit can be easily reahed
164+
# when using high refresh rates.
165+
pollTimerInterval = socRefreshSettingValue or defaultPollTimerInterval
166+
pollTimers[parentThingId] = threading.Timer(pollTimerInterval, pollService, [parentThingId])
167+
pollTimers[parentThingId].start()
168+
169+
def refreshVehicleSOC(client, thing):
170+
vid = thing.paramValue(vehicleThingVehicleidParamTypeId)
171+
logger.log("Refreshing SOC for vehicle: ", vid)
172+
vehicle = smartcar.Vehicle(vid, getAccessToken(client, thing.parentId))
173+
174+
# update the state of charge (battery level)
175+
soc = vehicle.battery()
176+
logger.log(soc)
177+
percentage = soc["data"]["percentRemaining"]
178+
thing.setStateValue(vehicleBatteryLevelStateTypeId, percentage * 100)
179+
if percentage * 100 < 10:
180+
thing.setStateValue(vehicleBatteryCriticalStateTypeId, True)
181+
else:
182+
thing.setStateValue(vehicleBatteryCriticalStateTypeId, False)
183+
184+
# update charing status
185+
chargingState = vehicle.charge()
186+
logger.log(chargingState)
187+
thing.setStateValue(vehiclePluggedInStateTypeId, chargingState["data"]["isPluggedIn"])
188+
thing.setStateValue(vehicleChargingStateTypeId, chargingState["data"]["state"] == "CHARGING")
189+
190+
191+
def saveToken(thingId, token):
192+
pluginStorage().beginGroup(thingId)
193+
pluginStorage().setValue("token", json.dumps(token, default=str))
194+
pluginStorage().endGroup()
195+
196+
def getAccessToken(client, thingId):
197+
pluginStorage().beginGroup(thingId)
198+
token = json.loads(pluginStorage().value("token"))
199+
pluginStorage().endGroup()
200+
201+
logger.log("Retrieved token from pluginStorage: ", token)
202+
expiration_date_time_obj = datetime.datetime.strptime(token['expiration'], '%Y-%m-%d %H:%M:%S.%f')
203+
if smartcar.is_expired(expiration_date_time_obj):
204+
token = client.exchange_refresh_token(token['refresh_token'])
205+
saveToken(thingId, token)
206+
return token['access_token']
207+
208+
209+
def configValueChanged(paramTypeId, value):
210+
if paramTypeId == smartcarPluginTestModeParamTypeId:
211+
logger.log("Test mode enabled changed to: ", value)
212+
#should retrigger a setup somehow here
213+

0 commit comments

Comments
 (0)