Skip to content

Commit

Permalink
Added testing, refactored API and coordinators, added current and CT …
Browse files Browse the repository at this point in the history
…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
  • Loading branch information
dan-r authored Dec 29, 2023
1 parent d698c2d commit abbcabd
Show file tree
Hide file tree
Showing 17 changed files with 338 additions and 111 deletions.
16 changes: 16 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,19 @@ jobs:
uses: "hacs/action@main"
with:
category: "integration"
pytest:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: asdf_install
uses: asdf-vm/actions/install@v1
- name: Install Python modules
run: |
pip install -r requirements.test.txt
- name: Run unit tests
run: |
python -m pytest tests
1 change: 1 addition & 0 deletions .tool-versions
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
python 3.11.3
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ This integration exposes the following entities:
* Pending Approval - On when a car is connected and waiting for approval
* Sensors
* Power Draw (Watts) - Power draw of connected car
* Current Draw (Amps) - Current draw of connected car
* CT Reading (Amps) - Reading from attached CT clamp
* Accumulative Energy Usage (kWh) - Total energy used by the charger
* Next Smart Charge Slot - The next time your car will start charging according to the Ohme-generated charge plan
* Switches (Settings) - Only options available to your charger model will show
Expand Down
22 changes: 11 additions & 11 deletions custom_components/ohme/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from homeassistant import core
from .const import *
from .api_client import OhmeApiClient
from .coordinator import OhmeChargeSessionsCoordinator, OhmeStatisticsCoordinator, OhmeAccountInfoCoordinator
from .coordinator import OhmeChargeSessionsCoordinator, OhmeStatisticsCoordinator, OhmeAccountInfoCoordinator, OhmeAdvancedSettingsCoordinator


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

await client.async_refresh_session()
await client.async_create_session()
await client.async_update_device_info()


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

await async_setup_dependencies(hass, config)

hass.data[DOMAIN][DATA_CHARGESESSIONS_COORDINATOR] = OhmeChargeSessionsCoordinator(
hass=hass)
await hass.data[DOMAIN][DATA_CHARGESESSIONS_COORDINATOR].async_config_entry_first_refresh()
coordinators = [
OhmeChargeSessionsCoordinator(hass=hass), # COORDINATOR_CHARGESESSIONS
OhmeAccountInfoCoordinator(hass=hass), # COORDINATOR_ACCOUNTINFO
OhmeStatisticsCoordinator(hass=hass), # COORDINATOR_STATISTICS
OhmeAdvancedSettingsCoordinator(hass=hass) # COORDINATOR_ADVANCED
]

hass.data[DOMAIN][DATA_STATISTICS_COORDINATOR] = OhmeStatisticsCoordinator(
hass=hass)
await hass.data[DOMAIN][DATA_STATISTICS_COORDINATOR].async_config_entry_first_refresh()
for coordinator in coordinators:
await coordinator.async_config_entry_first_refresh()

hass.data[DOMAIN][DATA_ACCOUNTINFO_COORDINATOR] = OhmeAccountInfoCoordinator(
hass=hass)
await hass.data[DOMAIN][DATA_ACCOUNTINFO_COORDINATOR].async_config_entry_first_refresh()
hass.data[DOMAIN][DATA_COORDINATORS] = coordinators

# Create tasks for each entity type
hass.async_create_task(
Expand Down
194 changes: 120 additions & 74 deletions custom_components/ohme/api_client.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import aiohttp
import logging
import json
from time import time
from datetime import datetime, timedelta
from homeassistant.helpers.entity import DeviceInfo
from .const import DOMAIN

_LOGGER = logging.getLogger(__name__)

GOOGLE_API_KEY = "AIzaSyC8ZeZngm33tpOXLpbXeKfwtyZ1WrkbdBY"


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

self._device_info = None
self._capabilities = {}
self._token_birth = 0
self._token = None
self._refresh_token = None
self._user_id = ""
self._serial = ""
self._session = aiohttp.ClientSession()
self._session = aiohttp.ClientSession(
base_url="https://api.ohme.io")
self._auth_session = aiohttp.ClientSession()

async def async_refresh_session(self):

# Auth methods
async def async_create_session(self):
"""Refresh the user auth token from the stored credentials."""
async with self._session.post(
'https://www.googleapis.com/identitytoolkit/v3/relyingparty/verifyPassword?key=AIzaSyC8ZeZngm33tpOXLpbXeKfwtyZ1WrkbdBY',
async with self._auth_session.post(
f"https://www.googleapis.com/identitytoolkit/v3/relyingparty/verifyPassword?key={GOOGLE_API_KEY}",
data={"email": self._email, "password": self._password,
"returnSecureToken": True}
) as resp:

if resp.status != 200:
return None

resp_json = await resp.json()
self._token_birth = time()
self._token = resp_json['idToken']
self._refresh_token = resp_json['refreshToken']
return True

async def _post_request(self, url, skip_json=False, data=None, is_retry=False):
"""Try to make a POST request
If we get a non 200 response, refresh auth token and try again"""
async def async_refresh_session(self):
"""Refresh auth token if needed."""
if self._token is None:
return await self.async_create_session()

# Don't refresh token unless its over 45 mins old
if time() - self._token_birth < 2700:
return

async with self._auth_session.post(
f"https://securetoken.googleapis.com/v1/token?key={GOOGLE_API_KEY}",
data={"grantType": "refresh_token",
"refreshToken": self._refresh_token}
) as resp:
if resp.status != 200:
text = await resp.text()
msg = f"Ohme auth refresh error: {text}"
_LOGGER.error(msg)
raise AuthException(msg)

resp_json = await resp.json()
self._token_birth = time()
self._token = resp_json['id_token']
self._refresh_token = resp_json['refresh_token']
return True


# Internal methods
def _last_second_of_month_timestamp(self):
"""Get the last second of this month."""
dt = datetime.today()
dt = dt.replace(day=1) + timedelta(days=32)
dt = dt.replace(day=1, hour=0, minute=0, second=0,
microsecond=0) - timedelta(seconds=1)
return int(dt.timestamp()*1e3)

async def _handle_api_error(self, url, resp):
"""Raise an exception if API response failed."""
if resp.status != 200:
text = await resp.text()
msg = f"Ohme API response error: {url}, {resp.status}; {text}"
_LOGGER.error(msg)
raise ApiException(msg)

def _get_headers(self):
"""Get auth and content-type headers"""
return {
"Authorization": "Firebase %s" % self._token,
"Content-Type": "application/json"
}

async def _post_request(self, url, skip_json=False, data=None):
"""Make a POST request."""
await self.async_refresh_session()
async with self._session.post(
url,
data=data,
headers={"Authorization": "Firebase %s" % self._token}
headers=self._get_headers()
) as resp:
if resp.status != 200 and not is_retry:
await self.async_refresh_session()
return await self._post_request(url, skip_json=skip_json, data=data, is_retry=True)
elif resp.status != 200:
return False
await self._handle_api_error(url, resp)

if skip_json:
return await resp.text()

resp_json = await resp.json()
return resp_json
return await resp.json()

async def _put_request(self, url, data=None, is_retry=False):
"""Try to make a PUT request
If we get a non 200 response, refresh auth token and try again"""
async def _put_request(self, url, data=None):
"""Make a PUT request."""
await self.async_refresh_session()
async with self._session.put(
url,
data=json.dumps(data),
headers={
"Authorization": "Firebase %s" % self._token,
"Content-Type": "application/json"
}
headers=self._get_headers()
) as resp:
if resp.status != 200 and not is_retry:
await self.async_refresh_session()
return await self._put_request(url, data=data, is_retry=True)
elif resp.status != 200:
return False
await self._handle_api_error(url, resp)

return True

async def _get_request(self, url, is_retry=False):
"""Try to make a GET request
If we get a non 200 response, refresh auth token and try again"""
async def _get_request(self, url):
"""Make a GET request."""
await self.async_refresh_session()
async with self._session.get(
url,
headers={"Authorization": "Firebase %s" % self._token}
headers=self._get_headers()
) as resp:
if resp.status != 200 and not is_retry:
await self.async_refresh_session()
return await self._get_request(url, is_retry=True)
elif resp.status != 200:
return False
await self._handle_api_error(url, resp)

return await resp.json()


# Simple getters
def is_capable(self, capability):
"""Return whether or not this model has a given capability."""
return bool(self._capabilities[capability])

def get_device_info(self):
return self._device_info

def get_unique_id(self, name):
return f"ohme_{self._serial}_{name}"


# Push methods
async def async_pause_charge(self):
"""Pause an ongoing charge"""
result = await self._post_request(f"https://api.ohme.io/v1/chargeSessions/{self._serial}/stop", skip_json=True)
result = await self._post_request(f"/v1/chargeSessions/{self._serial}/stop", skip_json=True)
return bool(result)

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

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

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

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

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


# Pull methods
async def async_get_charge_sessions(self, is_retry=False):
"""Try to fetch charge sessions endpoint.
If we get a non 200 response, refresh auth token and try again"""
resp = await self._get_request('https://api.ohme.io/v1/chargeSessions')

if not resp:
return False
resp = await self._get_request('/v1/chargeSessions')

return resp[0]

async def async_get_account_info(self):
resp = await self._get_request('https://api.ohme.io/v1/users/me/account')

if not resp:
return False
resp = await self._get_request('/v1/users/me/account')

return resp

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

if not resp:
return False

device = resp['chargeDevices'][0]

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

return True

def is_capable(self, capability):
"""Return whether or not this model has a given capability."""
return bool(self._capabilities[capability])

def _last_second_of_month_timestamp(self):
"""Get the last second of this month."""
dt = datetime.today()
dt = dt.replace(day=1) + timedelta(days=32)
dt = dt.replace(day=1, hour=0, minute=0, second=0,
microsecond=0) - timedelta(seconds=1)
return int(dt.timestamp()*1e3)

async def async_get_charge_statistics(self):
"""Get charge statistics. Currently this is just for all time (well, Jan 2019)."""
end_ts = self._last_second_of_month_timestamp()
resp = await self._get_request(f"https://api.ohme.io/v1/chargeSessions/summary/users/{self._user_id}?&startTs=1546300800000&endTs={end_ts}&granularity=MONTH")

if not resp:
return False
resp = await self._get_request(f"/v1/chargeSessions/summary/users/{self._user_id}?&startTs=1546300800000&endTs={end_ts}&granularity=MONTH")

return resp['totalStats']

def get_device_info(self):
return self._device_info
async def async_get_ct_reading(self):
"""Get CT clamp reading."""
resp = await self._get_request(f"/v1/chargeDevices/{self._serial}/advancedSettings")

def get_unique_id(self, name):
return f"ohme_{self._serial}_{name}"
return resp['clampAmps']



# Exceptions
class ApiException(Exception):
...

class AuthException(ApiException):
...
4 changes: 2 additions & 2 deletions custom_components/ohme/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import generate_entity_id
from .const import DOMAIN, DATA_CHARGESESSIONS_COORDINATOR, DATA_CLIENT
from .const import DOMAIN, DATA_COORDINATORS, COORDINATOR_CHARGESESSIONS, DATA_CLIENT
from .coordinator import OhmeChargeSessionsCoordinator


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

sensors = [ConnectedSensor(coordinator, hass, client),
ChargingSensor(coordinator, hass, client),
Expand Down
Loading

0 comments on commit abbcabd

Please sign in to comment.