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 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,3 @@
1
+ # ohmepy
2
+
3
+ A Python wrapper for the Ohme API, used by the Home Assistant integration.
@@ -0,0 +1 @@
1
+ from .ohme import *
@@ -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
+ ...
@@ -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,10 @@
1
+ README.md
2
+ pyproject.toml
3
+ ohme/__init__.py
4
+ ohme/ohme.py
5
+ ohme/utils.py
6
+ ohme.egg-info/PKG-INFO
7
+ ohme.egg-info/SOURCES.txt
8
+ ohme.egg-info/dependency_links.txt
9
+ ohme.egg-info/requires.txt
10
+ ohme.egg-info/top_level.txt
@@ -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
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+