From abbcabdedea2081b23ec5d840db40e671e006178 Mon Sep 17 00:00:00 2001 From: Dan Raper Date: Fri, 29 Dec 2023 12:57:58 +0000 Subject: [PATCH] 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 --- .github/workflows/main.yml | 16 ++ .tool-versions | 1 + README.md | 2 + custom_components/ohme/__init__.py | 22 +-- custom_components/ohme/api_client.py | 194 +++++++++++++++--------- custom_components/ohme/binary_sensor.py | 4 +- custom_components/ohme/button.py | 4 +- custom_components/ohme/config_flow.py | 10 +- custom_components/ohme/const.py | 8 +- custom_components/ohme/coordinator.py | 29 +++- custom_components/ohme/sensor.py | 104 ++++++++++++- custom_components/ohme/switch.py | 8 +- pyproject.toml | 2 + requirements.test.txt | 5 + tests/__init__.py | 1 + tests/conftest.py | 11 ++ tests/test_config_flow.py | 28 ++++ 17 files changed, 338 insertions(+), 111 deletions(-) create mode 100644 .tool-versions create mode 100644 pyproject.toml create mode 100644 requirements.test.txt create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_config_flow.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 7e67dfe..55407ab 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -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 diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..e6ea852 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +python 3.11.3 diff --git a/README.md b/README.md index 13dea13..89e3158 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/custom_components/ohme/__init__.py b/custom_components/ohme/__init__.py index 4c2a9e0..3d8fb7b 100644 --- a/custom_components/ohme/__init__.py +++ b/custom_components/ohme/__init__.py @@ -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: @@ -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() @@ -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( diff --git a/custom_components/ohme/api_client.py b/custom_components/ohme/api_client.py index 242f75f..776f019 100644 --- a/custom_components/ohme/api_client.py +++ b/custom_components/ohme/api_client.py @@ -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.""" @@ -20,126 +23,178 @@ 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 @@ -147,9 +202,6 @@ 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( @@ -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): + ... diff --git a/custom_components/ohme/binary_sensor.py b/custom_components/ohme/binary_sensor.py index 85a1d33..e41f90f 100644 --- a/custom_components/ohme/binary_sensor.py +++ b/custom_components/ohme/binary_sensor.py @@ -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 @@ -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), diff --git a/custom_components/ohme/button.py b/custom_components/ohme/button.py index 60f7302..7c2ef62 100644 --- a/custom_components/ohme/button.py +++ b/custom_components/ohme/button.py @@ -6,7 +6,7 @@ from homeassistant.helpers.entity import generate_entity_id from homeassistant.components.button import ButtonEntity -from .const import DOMAIN, DATA_CLIENT, DATA_CHARGESESSIONS_COORDINATOR +from .const import DOMAIN, DATA_CLIENT, DATA_COORDINATORS, COORDINATOR_CHARGESESSIONS from .coordinator import OhmeChargeSessionsCoordinator _LOGGER = logging.getLogger(__name__) @@ -19,7 +19,7 @@ async def async_setup_entry( ): """Setup switches.""" client = hass.data[DOMAIN][DATA_CLIENT] - coordinator = hass.data[DOMAIN][DATA_CHARGESESSIONS_COORDINATOR] + coordinator = hass.data[DOMAIN][DATA_COORDINATORS][COORDINATOR_CHARGESESSIONS] buttons = [] diff --git a/custom_components/ohme/config_flow.py b/custom_components/ohme/config_flow.py index 7d49c30..1d7279b 100644 --- a/custom_components/ohme/config_flow.py +++ b/custom_components/ohme/config_flow.py @@ -4,6 +4,11 @@ from .api_client import OhmeApiClient +USER_SCHEMA = vol.Schema({ + vol.Required("email"): str, + vol.Required("password"): str +}) + class OhmeConfigFlow(ConfigFlow, domain=DOMAIN): """Config flow.""" @@ -23,8 +28,5 @@ async def async_step_user(self, info): ) return self.async_show_form( - step_id="user", data_schema=vol.Schema({ - vol.Required("email"): str, - vol.Required("password"): str - }), errors=errors + step_id="user", data_schema=USER_SCHEMA, errors=errors ) diff --git a/custom_components/ohme/const.py b/custom_components/ohme/const.py index 352346b..b3445f5 100644 --- a/custom_components/ohme/const.py +++ b/custom_components/ohme/const.py @@ -2,6 +2,8 @@ DOMAIN = "ohme" DATA_CLIENT = "client" -DATA_CHARGESESSIONS_COORDINATOR = "coordinator" -DATA_STATISTICS_COORDINATOR = "statistics_coordinator" -DATA_ACCOUNTINFO_COORDINATOR = "accountinfo_coordinator" \ No newline at end of file +DATA_COORDINATORS = "coordinators" +COORDINATOR_CHARGESESSIONS = 0 +COORDINATOR_ACCOUNTINFO = 1 +COORDINATOR_STATISTICS = 2 +COORDINATOR_ADVANCED = 3 \ No newline at end of file diff --git a/custom_components/ohme/coordinator.py b/custom_components/ohme/coordinator.py index 2f280d5..95a8129 100644 --- a/custom_components/ohme/coordinator.py +++ b/custom_components/ohme/coordinator.py @@ -12,14 +12,14 @@ class OhmeChargeSessionsCoordinator(DataUpdateCoordinator): - """Coordinator to pull from API periodically.""" + """Coordinator to pull main charge state and power/current draw.""" def __init__(self, hass): """Initialise coordinator.""" super().__init__( hass, _LOGGER, - name="Ohme Charger", + name="Ohme Charge Sessions", update_interval=timedelta(seconds=30), ) self._client = hass.data[DOMAIN][DATA_CLIENT] @@ -34,7 +34,7 @@ async def _async_update_data(self): class OhmeAccountInfoCoordinator(DataUpdateCoordinator): - """Coordinator to pull from API periodically.""" + """Coordinator to pull charger settings.""" def __init__(self, hass): """Initialise coordinator.""" @@ -57,7 +57,7 @@ async def _async_update_data(self): class OhmeStatisticsCoordinator(DataUpdateCoordinator): """Coordinator to update statistics from API periodically. - (But less so than OhmeUpdateCoordinator)""" + (But less so than the others)""" def __init__(self, hass): """Initialise coordinator.""" @@ -76,3 +76,24 @@ async def _async_update_data(self): except BaseException: raise UpdateFailed("Error communicating with API") + +class OhmeAdvancedSettingsCoordinator(DataUpdateCoordinator): + """Coordinator to pull CT clamp reading.""" + + def __init__(self, hass): + """Initialise coordinator.""" + super().__init__( + hass, + _LOGGER, + name="Ohme Advanced Settings", + update_interval=timedelta(minutes=1), + ) + self._client = hass.data[DOMAIN][DATA_CLIENT] + + async def _async_update_data(self): + """Fetch data from API endpoint.""" + try: + return await self._client.async_get_ct_reading() + + except BaseException: + raise UpdateFailed("Error communicating with API") diff --git a/custom_components/ohme/sensor.py b/custom_components/ohme/sensor.py index c043924..1880e52 100644 --- a/custom_components/ohme/sensor.py +++ b/custom_components/ohme/sensor.py @@ -6,12 +6,12 @@ SensorEntity ) from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.const import UnitOfPower, UnitOfEnergy +from homeassistant.const import UnitOfPower, UnitOfEnergy, UnitOfElectricCurrent from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import generate_entity_id from homeassistant.util.dt import (utcnow) -from .const import DOMAIN, DATA_CLIENT, DATA_CHARGESESSIONS_COORDINATOR, DATA_STATISTICS_COORDINATOR -from .coordinator import OhmeChargeSessionsCoordinator, OhmeStatisticsCoordinator +from .const import DOMAIN, DATA_CLIENT, DATA_COORDINATORS, COORDINATOR_CHARGESESSIONS, COORDINATOR_STATISTICS, COORDINATOR_ADVANCED +from .coordinator import OhmeChargeSessionsCoordinator, OhmeStatisticsCoordinator, OhmeAdvancedSettingsCoordinator from .utils import charge_graph_next_slot @@ -22,18 +22,24 @@ async def async_setup_entry( ): """Setup sensors and configure coordinator.""" client = hass.data[DOMAIN][DATA_CLIENT] - coordinator = hass.data[DOMAIN][DATA_CHARGESESSIONS_COORDINATOR] - stats_coordinator = hass.data[DOMAIN][DATA_STATISTICS_COORDINATOR] + coordinators = hass.data[DOMAIN][DATA_COORDINATORS] - sensors = [PowerDrawSensor(coordinator, hass, client), EnergyUsageSensor( - stats_coordinator, hass, client), NextSlotSensor(coordinator, hass, client)] + coordinator = coordinators[COORDINATOR_CHARGESESSIONS] + stats_coordinator = coordinators[COORDINATOR_STATISTICS] + adv_coordinator = coordinators[COORDINATOR_ADVANCED] + + sensors = [PowerDrawSensor(coordinator, hass, client), + CurrentDrawSensor(coordinator, hass, client), + CTSensor(adv_coordinator, hass, client), + EnergyUsageSensor(stats_coordinator, hass, client), + NextSlotSensor(coordinator, hass, client)] async_add_entities(sensors, update_before_add=True) class PowerDrawSensor(CoordinatorEntity[OhmeChargeSessionsCoordinator], SensorEntity): """Sensor for car power draw.""" - _attr_name = "Current Power Draw" + _attr_name = "Power Draw" _attr_native_unit_of_measurement = UnitOfPower.WATT _attr_device_class = SensorDeviceClass.POWER @@ -73,6 +79,88 @@ def native_value(self): return 0 +class CurrentDrawSensor(CoordinatorEntity[OhmeChargeSessionsCoordinator], SensorEntity): + """Sensor for car power draw.""" + _attr_name = "Current Draw" + _attr_device_class = SensorDeviceClass.CURRENT + _attr_native_unit_of_measurement = UnitOfElectricCurrent.AMPERE + + def __init__( + self, + coordinator: OhmeChargeSessionsCoordinator, + hass: HomeAssistant, + client): + super().__init__(coordinator=coordinator) + + self._state = None + self._attributes = {} + self._last_updated = None + self._client = client + + self.entity_id = generate_entity_id( + "sensor.{}", "ohme_current_draw", hass=hass) + + self._attr_device_info = hass.data[DOMAIN][DATA_CLIENT].get_device_info( + ) + + @property + def unique_id(self) -> str: + """Return the unique ID of the sensor.""" + return self._client.get_unique_id("current_draw") + + @property + def icon(self): + """Icon of the sensor.""" + return "mdi:current-ac" + + @property + def native_value(self): + """Get value from data returned from API by coordinator""" + if self.coordinator.data and self.coordinator.data['power']: + return self.coordinator.data['power']['amp'] + return 0 + + +class CTSensor(CoordinatorEntity[OhmeChargeSessionsCoordinator], SensorEntity): + """Sensor for car power draw.""" + _attr_name = "CT Reading" + _attr_device_class = SensorDeviceClass.CURRENT + _attr_native_unit_of_measurement = UnitOfElectricCurrent.AMPERE + + def __init__( + self, + coordinator: OhmeChargeSessionsCoordinator, + hass: HomeAssistant, + client): + super().__init__(coordinator=coordinator) + + self._state = None + self._attributes = {} + self._last_updated = None + self._client = client + + self.entity_id = generate_entity_id( + "sensor.{}", "ohme_ct_reading", hass=hass) + + self._attr_device_info = hass.data[DOMAIN][DATA_CLIENT].get_device_info( + ) + + @property + def unique_id(self) -> str: + """Return the unique ID of the sensor.""" + return self._client.get_unique_id("ct_reading") + + @property + def icon(self): + """Icon of the sensor.""" + return "mdi:gauge" + + @property + def native_value(self): + """Get value from data returned from API by coordinator""" + return self.coordinator.data + + class EnergyUsageSensor(CoordinatorEntity[OhmeStatisticsCoordinator], SensorEntity): """Sensor for total energy usage.""" _attr_name = "Accumulative Energy Usage" diff --git a/custom_components/ohme/switch.py b/custom_components/ohme/switch.py index 67b9373..49b1225 100644 --- a/custom_components/ohme/switch.py +++ b/custom_components/ohme/switch.py @@ -11,7 +11,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.util.dt import (utcnow) -from .const import DOMAIN, DATA_CLIENT, DATA_CHARGESESSIONS_COORDINATOR, DATA_ACCOUNTINFO_COORDINATOR +from .const import DOMAIN, DATA_CLIENT, DATA_COORDINATORS, COORDINATOR_CHARGESESSIONS, COORDINATOR_ACCOUNTINFO from .coordinator import OhmeChargeSessionsCoordinator, OhmeAccountInfoCoordinator _LOGGER = logging.getLogger(__name__) @@ -23,8 +23,10 @@ async def async_setup_entry( async_add_entities ): """Setup switches and configure coordinator.""" - coordinator = hass.data[DOMAIN][DATA_CHARGESESSIONS_COORDINATOR] - accountinfo_coordinator = hass.data[DOMAIN][DATA_ACCOUNTINFO_COORDINATOR] + coordinators = hass.data[DOMAIN][DATA_COORDINATORS] + + coordinator = coordinators[COORDINATOR_CHARGESESSIONS] + accountinfo_coordinator = coordinators[COORDINATOR_ACCOUNTINFO] client = hass.data[DOMAIN][DATA_CLIENT] switches = [OhmePauseChargeSwitch(coordinator, hass, client), diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6eb3df5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,2 @@ +[tool.pytest.ini_options] +asyncio_mode = "auto" diff --git a/requirements.test.txt b/requirements.test.txt new file mode 100644 index 0000000..a8a479e --- /dev/null +++ b/requirements.test.txt @@ -0,0 +1,5 @@ +coverage==7.3.2 +pytest==7.4.3 +pytest-asyncio==0.21.0 +pytest-cov==4.1.0 +pytest-homeassistant-custom-component==0.13.85 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..60defa5 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the Ohme integration.""" \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..5978791 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,11 @@ +"""Global fixtures for custom integration.""" +import pytest +import pytest_socket + +@pytest.fixture(autouse=True) +def auto_enable_custom_integrations(enable_custom_integrations): + """Enable custom integrations defined in the test dir.""" + yield + +def enable_external_sockets(): + pytest_socket.enable_socket() diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py new file mode 100644 index 0000000..8a0b095 --- /dev/null +++ b/tests/test_config_flow.py @@ -0,0 +1,28 @@ +"""Tests for the config flow.""" +from unittest import mock +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME, CONF_PATH +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.ohme import config_flow +from custom_components.ohme.const import DOMAIN + +async def test_step_account(hass): + """Test the initialization of the form in the first step of the config flow.""" + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": "user"} + ) + + expected = { + 'type': 'form', + 'flow_id': mock.ANY, + 'handler': 'ohme', + 'step_id': 'user', + 'data_schema': config_flow.USER_SCHEMA, + 'errors': {}, + 'description_placeholders': None, + 'last_step': None, + 'preview': None + } + + assert expected == result