Skip to content
This repository was archived by the owner on Mar 5, 2025. It is now read-only.

Commit abbcabd

Browse files
authored
Added testing, refactored API and coordinators, added current and CT sensors (#15)
* Testing essentials * First passing test! * Add pytest to action * Added .tool-versions * Refactor API client and use refresh tokens * Remove debug logging * Added current draw sensor * Add missing ampere unit * Refactor coordinators * Add CT reading sensor
1 parent d698c2d commit abbcabd

File tree

17 files changed

+338
-111
lines changed

17 files changed

+338
-111
lines changed

.github/workflows/main.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,19 @@ jobs:
1818
uses: "hacs/action@main"
1919
with:
2020
category: "integration"
21+
pytest:
22+
name: Unit Tests
23+
runs-on: ubuntu-latest
24+
steps:
25+
- name: Checkout
26+
uses: actions/checkout@v2
27+
with:
28+
fetch-depth: 0
29+
- name: asdf_install
30+
uses: asdf-vm/actions/install@v1
31+
- name: Install Python modules
32+
run: |
33+
pip install -r requirements.test.txt
34+
- name: Run unit tests
35+
run: |
36+
python -m pytest tests

.tool-versions

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
python 3.11.3

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ This integration exposes the following entities:
1919
* Pending Approval - On when a car is connected and waiting for approval
2020
* Sensors
2121
* Power Draw (Watts) - Power draw of connected car
22+
* Current Draw (Amps) - Current draw of connected car
23+
* CT Reading (Amps) - Reading from attached CT clamp
2224
* Accumulative Energy Usage (kWh) - Total energy used by the charger
2325
* Next Smart Charge Slot - The next time your car will start charging according to the Ohme-generated charge plan
2426
* Switches (Settings) - Only options available to your charger model will show

custom_components/ohme/__init__.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from homeassistant import core
22
from .const import *
33
from .api_client import OhmeApiClient
4-
from .coordinator import OhmeChargeSessionsCoordinator, OhmeStatisticsCoordinator, OhmeAccountInfoCoordinator
4+
from .coordinator import OhmeChargeSessionsCoordinator, OhmeStatisticsCoordinator, OhmeAccountInfoCoordinator, OhmeAdvancedSettingsCoordinator
55

66

77
async def async_setup(hass: core.HomeAssistant, config: dict) -> bool:
@@ -14,7 +14,7 @@ async def async_setup_dependencies(hass, config):
1414
client = OhmeApiClient(config['email'], config['password'])
1515
hass.data[DOMAIN][DATA_CLIENT] = client
1616

17-
await client.async_refresh_session()
17+
await client.async_create_session()
1818
await client.async_update_device_info()
1919

2020

@@ -31,17 +31,17 @@ async def async_setup_entry(hass, entry):
3131

3232
await async_setup_dependencies(hass, config)
3333

34-
hass.data[DOMAIN][DATA_CHARGESESSIONS_COORDINATOR] = OhmeChargeSessionsCoordinator(
35-
hass=hass)
36-
await hass.data[DOMAIN][DATA_CHARGESESSIONS_COORDINATOR].async_config_entry_first_refresh()
34+
coordinators = [
35+
OhmeChargeSessionsCoordinator(hass=hass), # COORDINATOR_CHARGESESSIONS
36+
OhmeAccountInfoCoordinator(hass=hass), # COORDINATOR_ACCOUNTINFO
37+
OhmeStatisticsCoordinator(hass=hass), # COORDINATOR_STATISTICS
38+
OhmeAdvancedSettingsCoordinator(hass=hass) # COORDINATOR_ADVANCED
39+
]
3740

38-
hass.data[DOMAIN][DATA_STATISTICS_COORDINATOR] = OhmeStatisticsCoordinator(
39-
hass=hass)
40-
await hass.data[DOMAIN][DATA_STATISTICS_COORDINATOR].async_config_entry_first_refresh()
41+
for coordinator in coordinators:
42+
await coordinator.async_config_entry_first_refresh()
4143

42-
hass.data[DOMAIN][DATA_ACCOUNTINFO_COORDINATOR] = OhmeAccountInfoCoordinator(
43-
hass=hass)
44-
await hass.data[DOMAIN][DATA_ACCOUNTINFO_COORDINATOR].async_config_entry_first_refresh()
44+
hass.data[DOMAIN][DATA_COORDINATORS] = coordinators
4545

4646
# Create tasks for each entity type
4747
hass.async_create_task(
Lines changed: 120 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import aiohttp
22
import logging
33
import json
4+
from time import time
45
from datetime import datetime, timedelta
56
from homeassistant.helpers.entity import DeviceInfo
67
from .const import DOMAIN
78

89
_LOGGER = logging.getLogger(__name__)
910

11+
GOOGLE_API_KEY = "AIzaSyC8ZeZngm33tpOXLpbXeKfwtyZ1WrkbdBY"
12+
1013

1114
class OhmeApiClient:
1215
"""API client for Ohme EV chargers."""
@@ -20,136 +23,185 @@ def __init__(self, email, password):
2023

2124
self._device_info = None
2225
self._capabilities = {}
26+
self._token_birth = 0
2327
self._token = None
28+
self._refresh_token = None
2429
self._user_id = ""
2530
self._serial = ""
26-
self._session = aiohttp.ClientSession()
31+
self._session = aiohttp.ClientSession(
32+
base_url="https://api.ohme.io")
33+
self._auth_session = aiohttp.ClientSession()
2734

28-
async def async_refresh_session(self):
35+
36+
# Auth methods
37+
async def async_create_session(self):
2938
"""Refresh the user auth token from the stored credentials."""
30-
async with self._session.post(
31-
'https://www.googleapis.com/identitytoolkit/v3/relyingparty/verifyPassword?key=AIzaSyC8ZeZngm33tpOXLpbXeKfwtyZ1WrkbdBY',
39+
async with self._auth_session.post(
40+
f"https://www.googleapis.com/identitytoolkit/v3/relyingparty/verifyPassword?key={GOOGLE_API_KEY}",
3241
data={"email": self._email, "password": self._password,
3342
"returnSecureToken": True}
3443
) as resp:
35-
3644
if resp.status != 200:
3745
return None
3846

3947
resp_json = await resp.json()
48+
self._token_birth = time()
4049
self._token = resp_json['idToken']
50+
self._refresh_token = resp_json['refreshToken']
4151
return True
4252

43-
async def _post_request(self, url, skip_json=False, data=None, is_retry=False):
44-
"""Try to make a POST request
45-
If we get a non 200 response, refresh auth token and try again"""
53+
async def async_refresh_session(self):
54+
"""Refresh auth token if needed."""
55+
if self._token is None:
56+
return await self.async_create_session()
57+
58+
# Don't refresh token unless its over 45 mins old
59+
if time() - self._token_birth < 2700:
60+
return
61+
62+
async with self._auth_session.post(
63+
f"https://securetoken.googleapis.com/v1/token?key={GOOGLE_API_KEY}",
64+
data={"grantType": "refresh_token",
65+
"refreshToken": self._refresh_token}
66+
) as resp:
67+
if resp.status != 200:
68+
text = await resp.text()
69+
msg = f"Ohme auth refresh error: {text}"
70+
_LOGGER.error(msg)
71+
raise AuthException(msg)
72+
73+
resp_json = await resp.json()
74+
self._token_birth = time()
75+
self._token = resp_json['id_token']
76+
self._refresh_token = resp_json['refresh_token']
77+
return True
78+
79+
80+
# Internal methods
81+
def _last_second_of_month_timestamp(self):
82+
"""Get the last second of this month."""
83+
dt = datetime.today()
84+
dt = dt.replace(day=1) + timedelta(days=32)
85+
dt = dt.replace(day=1, hour=0, minute=0, second=0,
86+
microsecond=0) - timedelta(seconds=1)
87+
return int(dt.timestamp()*1e3)
88+
89+
async def _handle_api_error(self, url, resp):
90+
"""Raise an exception if API response failed."""
91+
if resp.status != 200:
92+
text = await resp.text()
93+
msg = f"Ohme API response error: {url}, {resp.status}; {text}"
94+
_LOGGER.error(msg)
95+
raise ApiException(msg)
96+
97+
def _get_headers(self):
98+
"""Get auth and content-type headers"""
99+
return {
100+
"Authorization": "Firebase %s" % self._token,
101+
"Content-Type": "application/json"
102+
}
103+
104+
async def _post_request(self, url, skip_json=False, data=None):
105+
"""Make a POST request."""
106+
await self.async_refresh_session()
46107
async with self._session.post(
47108
url,
48109
data=data,
49-
headers={"Authorization": "Firebase %s" % self._token}
110+
headers=self._get_headers()
50111
) as resp:
51-
if resp.status != 200 and not is_retry:
52-
await self.async_refresh_session()
53-
return await self._post_request(url, skip_json=skip_json, data=data, is_retry=True)
54-
elif resp.status != 200:
55-
return False
112+
await self._handle_api_error(url, resp)
56113

57114
if skip_json:
58115
return await resp.text()
59116

60-
resp_json = await resp.json()
61-
return resp_json
117+
return await resp.json()
62118

63-
async def _put_request(self, url, data=None, is_retry=False):
64-
"""Try to make a PUT request
65-
If we get a non 200 response, refresh auth token and try again"""
119+
async def _put_request(self, url, data=None):
120+
"""Make a PUT request."""
121+
await self.async_refresh_session()
66122
async with self._session.put(
67123
url,
68124
data=json.dumps(data),
69-
headers={
70-
"Authorization": "Firebase %s" % self._token,
71-
"Content-Type": "application/json"
72-
}
125+
headers=self._get_headers()
73126
) as resp:
74-
if resp.status != 200 and not is_retry:
75-
await self.async_refresh_session()
76-
return await self._put_request(url, data=data, is_retry=True)
77-
elif resp.status != 200:
78-
return False
127+
await self._handle_api_error(url, resp)
79128

80129
return True
81130

82-
async def _get_request(self, url, is_retry=False):
83-
"""Try to make a GET request
84-
If we get a non 200 response, refresh auth token and try again"""
131+
async def _get_request(self, url):
132+
"""Make a GET request."""
133+
await self.async_refresh_session()
85134
async with self._session.get(
86135
url,
87-
headers={"Authorization": "Firebase %s" % self._token}
136+
headers=self._get_headers()
88137
) as resp:
89-
if resp.status != 200 and not is_retry:
90-
await self.async_refresh_session()
91-
return await self._get_request(url, is_retry=True)
92-
elif resp.status != 200:
93-
return False
138+
await self._handle_api_error(url, resp)
94139

95140
return await resp.json()
96141

142+
143+
# Simple getters
144+
def is_capable(self, capability):
145+
"""Return whether or not this model has a given capability."""
146+
return bool(self._capabilities[capability])
147+
148+
def get_device_info(self):
149+
return self._device_info
150+
151+
def get_unique_id(self, name):
152+
return f"ohme_{self._serial}_{name}"
153+
154+
155+
# Push methods
97156
async def async_pause_charge(self):
98157
"""Pause an ongoing charge"""
99-
result = await self._post_request(f"https://api.ohme.io/v1/chargeSessions/{self._serial}/stop", skip_json=True)
158+
result = await self._post_request(f"/v1/chargeSessions/{self._serial}/stop", skip_json=True)
100159
return bool(result)
101160

102161
async def async_resume_charge(self):
103162
"""Resume a paused charge"""
104-
result = await self._post_request(f"https://api.ohme.io/v1/chargeSessions/{self._serial}/resume", skip_json=True)
163+
result = await self._post_request(f"/v1/chargeSessions/{self._serial}/resume", skip_json=True)
105164
return bool(result)
106165

107166
async def async_approve_charge(self):
108167
"""Approve a charge"""
109-
result = await self._put_request(f"https://api.ohme.io/v1/chargeSessions/{self._serial}/approve?approve=true")
168+
result = await self._put_request(f"/v1/chargeSessions/{self._serial}/approve?approve=true")
110169
return bool(result)
111170

112171
async def async_max_charge(self):
113172
"""Enable max charge"""
114-
result = await self._put_request(f"https://api.ohme.io/v1/chargeSessions/{self._serial}/rule?maxCharge=true")
173+
result = await self._put_request(f"/v1/chargeSessions/{self._serial}/rule?maxCharge=true")
115174
return bool(result)
116175

117176
async def async_stop_max_charge(self):
118177
"""Stop max charge.
119178
This is more complicated than starting one as we need to give more parameters."""
120-
result = await self._put_request(f"https://api.ohme.io/v1/chargeSessions/{self._serial}/rule?enableMaxPrice=false&toPercent=80.0&inSeconds=43200")
179+
result = await self._put_request(f"/v1/chargeSessions/{self._serial}/rule?enableMaxPrice=false&toPercent=80.0&inSeconds=43200")
121180
return bool(result)
122181

123182
async def async_set_configuration_value(self, values):
124183
"""Set a configuration value or values."""
125-
result = await self._put_request(f"https://api.ohme.io/v1/chargeDevices/{self._serial}/appSettings", data=values)
184+
result = await self._put_request(f"/v1/chargeDevices/{self._serial}/appSettings", data=values)
126185
return bool(result)
127186

187+
188+
# Pull methods
128189
async def async_get_charge_sessions(self, is_retry=False):
129190
"""Try to fetch charge sessions endpoint.
130191
If we get a non 200 response, refresh auth token and try again"""
131-
resp = await self._get_request('https://api.ohme.io/v1/chargeSessions')
132-
133-
if not resp:
134-
return False
192+
resp = await self._get_request('/v1/chargeSessions')
135193

136194
return resp[0]
137195

138196
async def async_get_account_info(self):
139-
resp = await self._get_request('https://api.ohme.io/v1/users/me/account')
140-
141-
if not resp:
142-
return False
197+
resp = await self._get_request('/v1/users/me/account')
143198

144199
return resp
145200

146201
async def async_update_device_info(self, is_retry=False):
147202
"""Update _device_info with our charger model."""
148203
resp = await self.async_get_account_info()
149204

150-
if not resp:
151-
return False
152-
153205
device = resp['chargeDevices'][0]
154206

155207
info = DeviceInfo(
@@ -168,30 +220,24 @@ async def async_update_device_info(self, is_retry=False):
168220

169221
return True
170222

171-
def is_capable(self, capability):
172-
"""Return whether or not this model has a given capability."""
173-
return bool(self._capabilities[capability])
174-
175-
def _last_second_of_month_timestamp(self):
176-
"""Get the last second of this month."""
177-
dt = datetime.today()
178-
dt = dt.replace(day=1) + timedelta(days=32)
179-
dt = dt.replace(day=1, hour=0, minute=0, second=0,
180-
microsecond=0) - timedelta(seconds=1)
181-
return int(dt.timestamp()*1e3)
182-
183223
async def async_get_charge_statistics(self):
184224
"""Get charge statistics. Currently this is just for all time (well, Jan 2019)."""
185225
end_ts = self._last_second_of_month_timestamp()
186-
resp = await self._get_request(f"https://api.ohme.io/v1/chargeSessions/summary/users/{self._user_id}?&startTs=1546300800000&endTs={end_ts}&granularity=MONTH")
187-
188-
if not resp:
189-
return False
226+
resp = await self._get_request(f"/v1/chargeSessions/summary/users/{self._user_id}?&startTs=1546300800000&endTs={end_ts}&granularity=MONTH")
190227

191228
return resp['totalStats']
192229

193-
def get_device_info(self):
194-
return self._device_info
230+
async def async_get_ct_reading(self):
231+
"""Get CT clamp reading."""
232+
resp = await self._get_request(f"/v1/chargeDevices/{self._serial}/advancedSettings")
195233

196-
def get_unique_id(self, name):
197-
return f"ohme_{self._serial}_{name}"
234+
return resp['clampAmps']
235+
236+
237+
238+
# Exceptions
239+
class ApiException(Exception):
240+
...
241+
242+
class AuthException(ApiException):
243+
...

custom_components/ohme/binary_sensor.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from homeassistant.helpers.update_coordinator import CoordinatorEntity
99
from homeassistant.core import HomeAssistant
1010
from homeassistant.helpers.entity import generate_entity_id
11-
from .const import DOMAIN, DATA_CHARGESESSIONS_COORDINATOR, DATA_CLIENT
11+
from .const import DOMAIN, DATA_COORDINATORS, COORDINATOR_CHARGESESSIONS, DATA_CLIENT
1212
from .coordinator import OhmeChargeSessionsCoordinator
1313

1414

@@ -19,7 +19,7 @@ async def async_setup_entry(
1919
):
2020
"""Setup sensors and configure coordinator."""
2121
client = hass.data[DOMAIN][DATA_CLIENT]
22-
coordinator = hass.data[DOMAIN][DATA_CHARGESESSIONS_COORDINATOR]
22+
coordinator = hass.data[DOMAIN][DATA_COORDINATORS][COORDINATOR_CHARGESESSIONS]
2323

2424
sensors = [ConnectedSensor(coordinator, hass, client),
2525
ChargingSensor(coordinator, hass, client),

0 commit comments

Comments
 (0)