Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added testing, refactored API and coordinators, added current and CT sensors #15

Merged
merged 10 commits into from
Dec 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading