ohme 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ohme-1.0.0/PKG-INFO +16 -0
- ohme-1.0.0/README.md +3 -0
- ohme-1.0.0/ohme/__init__.py +1 -0
- ohme-1.0.0/ohme/ohme.py +342 -0
- ohme-1.0.0/ohme/utils.py +10 -0
- ohme-1.0.0/ohme.egg-info/PKG-INFO +16 -0
- ohme-1.0.0/ohme.egg-info/SOURCES.txt +10 -0
- ohme-1.0.0/ohme.egg-info/dependency_links.txt +1 -0
- ohme-1.0.0/ohme.egg-info/requires.txt +1 -0
- ohme-1.0.0/ohme.egg-info/top_level.txt +1 -0
- ohme-1.0.0/pyproject.toml +24 -0
- ohme-1.0.0/setup.cfg +4 -0
ohme-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: ohme
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A Python wrapper for the Ohme API, used by the Home Assistant integration.
|
|
5
|
+
Author-email: Dan Raper <git@danr.uk>
|
|
6
|
+
Project-URL: Homepage, https://github.com/dan-r/ohmepy
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/dan-r/ohmepy/issues
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Requires-Python: >=3.7
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: aiohttp>=3.0.0
|
|
13
|
+
|
|
14
|
+
# ohmepy
|
|
15
|
+
|
|
16
|
+
A Python wrapper for the Ohme API, used by the Home Assistant integration.
|
ohme-1.0.0/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .ohme import *
|
ohme-1.0.0/ohme/ohme.py
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import aiohttp
|
|
2
|
+
import importlib.metadata
|
|
3
|
+
import logging
|
|
4
|
+
import json
|
|
5
|
+
from time import time
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
from .utils import time_next_occurs
|
|
8
|
+
|
|
9
|
+
_LOGGER = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
GOOGLE_API_KEY = "AIzaSyC8ZeZngm33tpOXLpbXeKfwtyZ1WrkbdBY"
|
|
12
|
+
|
|
13
|
+
class OhmeApiClient:
|
|
14
|
+
"""API client for Ohme EV chargers."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, email, password):
|
|
17
|
+
if email is None or password is None:
|
|
18
|
+
raise Exception("Credentials not provided")
|
|
19
|
+
|
|
20
|
+
# Credentials from configuration
|
|
21
|
+
self.email = email
|
|
22
|
+
self._password = password
|
|
23
|
+
|
|
24
|
+
# Charger and its capabilities
|
|
25
|
+
self._device_info = None
|
|
26
|
+
self._capabilities = {}
|
|
27
|
+
self._ct_connected = False
|
|
28
|
+
self._provision_date = None
|
|
29
|
+
self._disable_cap = False
|
|
30
|
+
self._solar_capable = False
|
|
31
|
+
|
|
32
|
+
# Authentication
|
|
33
|
+
self._token_birth = 0
|
|
34
|
+
self._token = None
|
|
35
|
+
self._refresh_token = None
|
|
36
|
+
|
|
37
|
+
# User info
|
|
38
|
+
self._user_id = ""
|
|
39
|
+
self.serial = ""
|
|
40
|
+
|
|
41
|
+
# Cache the last rule to use when we disable max charge or change schedule
|
|
42
|
+
self._last_rule = {}
|
|
43
|
+
|
|
44
|
+
# Sessions
|
|
45
|
+
timeout = aiohttp.ClientTimeout(total=10)
|
|
46
|
+
self._session = aiohttp.ClientSession(
|
|
47
|
+
base_url="https://api.ohme.io", timeout=timeout)
|
|
48
|
+
self._auth_session = aiohttp.ClientSession(timeout=timeout)
|
|
49
|
+
|
|
50
|
+
# Auth methods
|
|
51
|
+
|
|
52
|
+
async def async_create_session(self):
|
|
53
|
+
"""Refresh the user auth token from the stored credentials."""
|
|
54
|
+
async with self._auth_session.post(
|
|
55
|
+
f"https://www.googleapis.com/identitytoolkit/v3/relyingparty/verifyPassword?key={GOOGLE_API_KEY}",
|
|
56
|
+
data={"email": self.email, "password": self._password,
|
|
57
|
+
"returnSecureToken": True}
|
|
58
|
+
) as resp:
|
|
59
|
+
if resp.status != 200:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
resp_json = await resp.json()
|
|
63
|
+
self._token_birth = time()
|
|
64
|
+
self._token = resp_json['idToken']
|
|
65
|
+
self._refresh_token = resp_json['refreshToken']
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
async def async_refresh_session(self):
|
|
69
|
+
"""Refresh auth token if needed."""
|
|
70
|
+
if self._token is None:
|
|
71
|
+
return await self.async_create_session()
|
|
72
|
+
|
|
73
|
+
# Don't refresh token unless its over 45 mins old
|
|
74
|
+
if time() - self._token_birth < 2700:
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
async with self._auth_session.post(
|
|
78
|
+
f"https://securetoken.googleapis.com/v1/token?key={GOOGLE_API_KEY}",
|
|
79
|
+
data={"grantType": "refresh_token",
|
|
80
|
+
"refreshToken": self._refresh_token}
|
|
81
|
+
) as resp:
|
|
82
|
+
if resp.status != 200:
|
|
83
|
+
text = await resp.text()
|
|
84
|
+
msg = f"Ohme auth refresh error: {text}"
|
|
85
|
+
_LOGGER.error(msg)
|
|
86
|
+
raise AuthException(msg)
|
|
87
|
+
|
|
88
|
+
resp_json = await resp.json()
|
|
89
|
+
self._token_birth = time()
|
|
90
|
+
self._token = resp_json['id_token']
|
|
91
|
+
self._refresh_token = resp_json['refresh_token']
|
|
92
|
+
return True
|
|
93
|
+
|
|
94
|
+
# Internal methods
|
|
95
|
+
|
|
96
|
+
async def _handle_api_error(self, url, resp):
|
|
97
|
+
"""Raise an exception if API response failed."""
|
|
98
|
+
if resp.status != 200:
|
|
99
|
+
text = await resp.text()
|
|
100
|
+
msg = f"Ohme API response error: {url}, {resp.status}; {text}"
|
|
101
|
+
_LOGGER.error(msg)
|
|
102
|
+
raise ApiException(msg)
|
|
103
|
+
|
|
104
|
+
def _get_headers(self):
|
|
105
|
+
"""Get auth and content-type headers"""
|
|
106
|
+
return {
|
|
107
|
+
"Authorization": "Firebase %s" % self._token,
|
|
108
|
+
"Content-Type": "application/json",
|
|
109
|
+
"User-Agent": f"ohmepy/{importlib.metadata.version('ohme')}"
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async def _post_request(self, url, skip_json=False, data=None):
|
|
113
|
+
"""Make a POST request."""
|
|
114
|
+
await self.async_refresh_session()
|
|
115
|
+
async with self._session.post(
|
|
116
|
+
url,
|
|
117
|
+
data=data,
|
|
118
|
+
headers=self._get_headers()
|
|
119
|
+
) as resp:
|
|
120
|
+
_LOGGER.debug(f"POST request to {url}, status code {resp.status}")
|
|
121
|
+
await self._handle_api_error(url, resp)
|
|
122
|
+
|
|
123
|
+
if skip_json:
|
|
124
|
+
return await resp.text()
|
|
125
|
+
|
|
126
|
+
return await resp.json()
|
|
127
|
+
|
|
128
|
+
async def _put_request(self, url, data=None):
|
|
129
|
+
"""Make a PUT request."""
|
|
130
|
+
await self.async_refresh_session()
|
|
131
|
+
async with self._session.put(
|
|
132
|
+
url,
|
|
133
|
+
data=json.dumps(data),
|
|
134
|
+
headers=self._get_headers()
|
|
135
|
+
) as resp:
|
|
136
|
+
_LOGGER.debug(f"PUT request to {url}, status code {resp.status}")
|
|
137
|
+
await self._handle_api_error(url, resp)
|
|
138
|
+
|
|
139
|
+
return True
|
|
140
|
+
|
|
141
|
+
async def _get_request(self, url):
|
|
142
|
+
"""Make a GET request."""
|
|
143
|
+
await self.async_refresh_session()
|
|
144
|
+
async with self._session.get(
|
|
145
|
+
url,
|
|
146
|
+
headers=self._get_headers()
|
|
147
|
+
) as resp:
|
|
148
|
+
_LOGGER.debug(f"GET request to {url}, status code {resp.status}")
|
|
149
|
+
await self._handle_api_error(url, resp)
|
|
150
|
+
|
|
151
|
+
return await resp.json()
|
|
152
|
+
|
|
153
|
+
# Simple getters
|
|
154
|
+
|
|
155
|
+
def ct_connected(self):
|
|
156
|
+
"""Is CT clamp connected."""
|
|
157
|
+
return self._ct_connected
|
|
158
|
+
|
|
159
|
+
def is_capable(self, capability):
|
|
160
|
+
"""Return whether or not this model has a given capability."""
|
|
161
|
+
return bool(self._capabilities[capability])
|
|
162
|
+
|
|
163
|
+
def solar_capable(self):
|
|
164
|
+
return self._solar_capable
|
|
165
|
+
|
|
166
|
+
def cap_available(self):
|
|
167
|
+
return not self._disable_cap
|
|
168
|
+
|
|
169
|
+
def get_device_info(self):
|
|
170
|
+
return self._device_info
|
|
171
|
+
|
|
172
|
+
# Push methods
|
|
173
|
+
|
|
174
|
+
async def async_pause_charge(self):
|
|
175
|
+
"""Pause an ongoing charge"""
|
|
176
|
+
result = await self._post_request(f"/v1/chargeSessions/{self.serial}/stop", skip_json=True)
|
|
177
|
+
return bool(result)
|
|
178
|
+
|
|
179
|
+
async def async_resume_charge(self):
|
|
180
|
+
"""Resume a paused charge"""
|
|
181
|
+
result = await self._post_request(f"/v1/chargeSessions/{self.serial}/resume", skip_json=True)
|
|
182
|
+
return bool(result)
|
|
183
|
+
|
|
184
|
+
async def async_approve_charge(self):
|
|
185
|
+
"""Approve a charge"""
|
|
186
|
+
result = await self._put_request(f"/v1/chargeSessions/{self.serial}/approve?approve=true")
|
|
187
|
+
return bool(result)
|
|
188
|
+
|
|
189
|
+
async def async_max_charge(self, state=True):
|
|
190
|
+
"""Enable max charge"""
|
|
191
|
+
result = await self._put_request(f"/v1/chargeSessions/{self.serial}/rule?maxCharge=" + str(state).lower())
|
|
192
|
+
return bool(result)
|
|
193
|
+
|
|
194
|
+
async def async_apply_session_rule(self, max_price=None, target_time=None, target_percent=None, pre_condition=None, pre_condition_length=None):
|
|
195
|
+
"""Apply rule to ongoing charge/stop max charge."""
|
|
196
|
+
# Check every property. If we've provided it, use that. If not, use the existing.
|
|
197
|
+
if max_price is None:
|
|
198
|
+
if 'settings' in self._last_rule and self._last_rule['settings'] is not None and len(self._last_rule['settings']) > 1:
|
|
199
|
+
max_price = self._last_rule['settings'][0]['enabled']
|
|
200
|
+
else:
|
|
201
|
+
max_price = False
|
|
202
|
+
|
|
203
|
+
if target_percent is None:
|
|
204
|
+
target_percent = self._last_rule['targetPercent'] if 'targetPercent' in self._last_rule else 80
|
|
205
|
+
|
|
206
|
+
if pre_condition is None:
|
|
207
|
+
pre_condition = self._last_rule['preconditioningEnabled'] if 'preconditioningEnabled' in self._last_rule else False
|
|
208
|
+
|
|
209
|
+
if pre_condition_length is None:
|
|
210
|
+
pre_condition_length = self._last_rule['preconditionLengthMins'] if (
|
|
211
|
+
'preconditionLengthMins' in self._last_rule and self._last_rule['preconditionLengthMins'] is not None) else 30
|
|
212
|
+
|
|
213
|
+
if target_time is None:
|
|
214
|
+
# Default to 9am
|
|
215
|
+
target_time = self._last_rule['targetTime'] if 'targetTime' in self._last_rule else 32400
|
|
216
|
+
target_time = (target_time // 3600,
|
|
217
|
+
(target_time % 3600) // 60)
|
|
218
|
+
|
|
219
|
+
target_ts = int(time_next_occurs(
|
|
220
|
+
target_time[0], target_time[1]).timestamp() * 1000)
|
|
221
|
+
|
|
222
|
+
# Convert these to string form
|
|
223
|
+
max_price = 'true' if max_price else 'false'
|
|
224
|
+
pre_condition = 'true' if pre_condition else 'false'
|
|
225
|
+
|
|
226
|
+
result = await self._put_request(f"/v1/chargeSessions/{self.serial}/rule?enableMaxPrice={max_price}&targetTs={target_ts}&enablePreconditioning={pre_condition}&toPercent={target_percent}&preconditionLengthMins={pre_condition_length}")
|
|
227
|
+
return bool(result)
|
|
228
|
+
|
|
229
|
+
async def async_change_price_cap(self, enabled=None, cap=None):
|
|
230
|
+
"""Change price cap settings."""
|
|
231
|
+
settings = await self._get_request("/v1/users/me/settings")
|
|
232
|
+
if enabled is not None:
|
|
233
|
+
settings['chargeSettings'][0]['enabled'] = enabled
|
|
234
|
+
|
|
235
|
+
if cap is not None:
|
|
236
|
+
settings['chargeSettings'][0]['value'] = cap
|
|
237
|
+
|
|
238
|
+
result = await self._put_request("/v1/users/me/settings", data=settings)
|
|
239
|
+
return bool(result)
|
|
240
|
+
|
|
241
|
+
async def async_get_schedule(self):
|
|
242
|
+
"""Get the first schedule."""
|
|
243
|
+
schedules = await self._get_request("/v1/chargeRules")
|
|
244
|
+
|
|
245
|
+
return schedules[0] if len(schedules) > 0 else None
|
|
246
|
+
|
|
247
|
+
async def async_update_schedule(self, target_percent=None, target_time=None, pre_condition=None, pre_condition_length=None):
|
|
248
|
+
"""Update the first listed schedule."""
|
|
249
|
+
rule = await self.async_get_schedule()
|
|
250
|
+
|
|
251
|
+
# Account for user having no rules
|
|
252
|
+
if not rule:
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
# Update percent and time if provided
|
|
256
|
+
if target_percent is not None:
|
|
257
|
+
rule['targetPercent'] = target_percent
|
|
258
|
+
if target_time is not None:
|
|
259
|
+
rule['targetTime'] = (target_time[0] * 3600) + \
|
|
260
|
+
(target_time[1] * 60)
|
|
261
|
+
|
|
262
|
+
# Update pre-conditioning if provided
|
|
263
|
+
if pre_condition is not None:
|
|
264
|
+
rule['preconditioningEnabled'] = pre_condition
|
|
265
|
+
if pre_condition_length is not None:
|
|
266
|
+
rule['preconditionLengthMins'] = pre_condition_length
|
|
267
|
+
|
|
268
|
+
await self._put_request(f"/v1/chargeRules/{rule['id']}", data=rule)
|
|
269
|
+
return True
|
|
270
|
+
|
|
271
|
+
async def async_set_configuration_value(self, values):
|
|
272
|
+
"""Set a configuration value or values."""
|
|
273
|
+
result = await self._put_request(f"/v1/chargeDevices/{self.serial}/appSettings", data=values)
|
|
274
|
+
return bool(result)
|
|
275
|
+
|
|
276
|
+
# Pull methods
|
|
277
|
+
|
|
278
|
+
async def async_get_charge_sessions(self, is_retry=False):
|
|
279
|
+
"""Try to fetch charge sessions endpoint.
|
|
280
|
+
If we get a non 200 response, refresh auth token and try again"""
|
|
281
|
+
resp = await self._get_request('/v1/chargeSessions')
|
|
282
|
+
resp = resp[0]
|
|
283
|
+
|
|
284
|
+
# Cache the current rule if we are given it
|
|
285
|
+
if resp["mode"] == "SMART_CHARGE" and 'appliedRule' in resp:
|
|
286
|
+
self._last_rule = resp["appliedRule"]
|
|
287
|
+
|
|
288
|
+
return resp
|
|
289
|
+
|
|
290
|
+
async def async_get_account_info(self):
|
|
291
|
+
resp = await self._get_request('/v1/users/me/account')
|
|
292
|
+
|
|
293
|
+
return resp
|
|
294
|
+
|
|
295
|
+
async def async_update_device_info(self, is_retry=False):
|
|
296
|
+
"""Update _device_info with our charger model."""
|
|
297
|
+
resp = await self.async_get_account_info()
|
|
298
|
+
|
|
299
|
+
device = resp['chargeDevices'][0]
|
|
300
|
+
|
|
301
|
+
self._capabilities = device['modelCapabilities']
|
|
302
|
+
self._user_id = resp['user']['id']
|
|
303
|
+
self.serial = device['id']
|
|
304
|
+
self._provision_date = device['provisioningTs']
|
|
305
|
+
|
|
306
|
+
self._device_info = {
|
|
307
|
+
"identifiers": {("ohme", f"ohme_charger_{self.serial}")},
|
|
308
|
+
"name": device['modelTypeDisplayName'],
|
|
309
|
+
"manufacturer": "Ohme",
|
|
310
|
+
"model": device['modelTypeDisplayName'].replace("Ohme ", ""),
|
|
311
|
+
"sw_version": device['firmwareVersionLabel'],
|
|
312
|
+
"serial_number": self.serial
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
if resp['tariff'] is not None and resp['tariff']['dsrTariff']:
|
|
317
|
+
self._disable_cap = True
|
|
318
|
+
|
|
319
|
+
solar_modes = device['modelCapabilities']['solarModes']
|
|
320
|
+
if isinstance(solar_modes, list) and len(solar_modes) == 1:
|
|
321
|
+
self._solar_capable = True
|
|
322
|
+
|
|
323
|
+
return True
|
|
324
|
+
|
|
325
|
+
async def async_get_advanced_settings(self):
|
|
326
|
+
"""Get advanced settings (mainly for CT clamp reading)"""
|
|
327
|
+
resp = await self._get_request(f"/v1/chargeDevices/{self.serial}/advancedSettings")
|
|
328
|
+
|
|
329
|
+
# If we ever get a reading above 0, assume CT connected
|
|
330
|
+
if resp['clampAmps'] and resp['clampAmps'] > 0:
|
|
331
|
+
self._ct_connected = True
|
|
332
|
+
|
|
333
|
+
return resp
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# Exceptions
|
|
337
|
+
class ApiException(Exception):
|
|
338
|
+
...
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
class AuthException(ApiException):
|
|
342
|
+
...
|
ohme-1.0.0/ohme/utils.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from datetime import datetime, timedelta
|
|
2
|
+
|
|
3
|
+
def time_next_occurs(hour, minute):
|
|
4
|
+
"""Find when this time next occurs."""
|
|
5
|
+
current = datetime.now()
|
|
6
|
+
target = current.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
|
7
|
+
if target <= datetime.now():
|
|
8
|
+
target = target + timedelta(days=1)
|
|
9
|
+
|
|
10
|
+
return target
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: ohme
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A Python wrapper for the Ohme API, used by the Home Assistant integration.
|
|
5
|
+
Author-email: Dan Raper <git@danr.uk>
|
|
6
|
+
Project-URL: Homepage, https://github.com/dan-r/ohmepy
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/dan-r/ohmepy/issues
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Requires-Python: >=3.7
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: aiohttp>=3.0.0
|
|
13
|
+
|
|
14
|
+
# ohmepy
|
|
15
|
+
|
|
16
|
+
A Python wrapper for the Ohme API, used by the Home Assistant integration.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
aiohttp>=3.0.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ohme
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ohme"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name="Dan Raper", email="git@danr.uk" },
|
|
10
|
+
]
|
|
11
|
+
description = "A Python wrapper for the Ohme API, used by the Home Assistant integration."
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
requires-python = ">=3.7"
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
]
|
|
18
|
+
dependencies = [
|
|
19
|
+
"aiohttp >= 3.0.0"
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
"Homepage" = "https://github.com/dan-r/ohmepy"
|
|
24
|
+
"Bug Tracker" = "https://github.com/dan-r/ohmepy/issues"
|
ohme-1.0.0/setup.cfg
ADDED