diff --git a/.github/workflows/pull.yml b/.github/workflows/pull.yml index 2f021a0..b25005b 100644 --- a/.github/workflows/pull.yml +++ b/.github/workflows/pull.yml @@ -39,7 +39,7 @@ jobs: - name: Setup Python uses: "actions/setup-python@v1" with: - python-version: "3.10" + python-version: "3.12" - name: Install requirements run: python3 -m pip install -r requirements_test.txt - name: Run tests @@ -52,4 +52,5 @@ jobs: --cov custom_components.eo_mini \ -o console_output_style=count \ -p no:sugar \ + --asyncio-mode=auto \ tests diff --git a/custom_components/eo_mini/__init__.py b/custom_components/eo_mini/__init__.py index 01dfee8..c572d6c 100644 --- a/custom_components/eo_mini/__init__.py +++ b/custom_components/eo_mini/__init__.py @@ -4,14 +4,16 @@ For more details about this integration, please refer to https://github.com/twhittock/eo_mini """ + import asyncio from datetime import timedelta import logging from homeassistant.config_entries import ConfigEntry -from homeassistant.core import Config, HomeAssistant +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .api import EOApiClient, EOAuthError @@ -42,7 +44,7 @@ def eo_model(hub_serial: str): # pylint: disable-next=unused-argument -async def async_setup(hass: HomeAssistant, config: Config): +async def async_setup(hass: HomeAssistant, config: ConfigType): "Setting up this integration using YAML is not supported." return True @@ -68,11 +70,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): await coordinator.async_config_entry_first_refresh() - for platform in PLATFORMS: - coordinator.platforms.append(platform) - hass.async_add_job( - hass.config_entries.async_forward_entry_setup(entry, platform) - ) + coordinator.platforms.extend(PLATFORMS) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(async_reload_entry)) return True @@ -113,7 +112,7 @@ async def _async_update_data(self): self.device = self._minis_list[0] self.serial = self.device["hubSerial"] self.model = eo_model(self.serial) - + self.live_session = await self.api.async_get_session_liveness() self.data = await self.api.async_get_session() self.async_update_listeners() diff --git a/custom_components/eo_mini/api.py b/custom_components/eo_mini/api.py index cf049cd..54e00f8 100644 --- a/custom_components/eo_mini/api.py +++ b/custom_components/eo_mini/api.py @@ -79,6 +79,20 @@ async def async_get_session(self) -> list[dict]: "Get the current session if any" return await self._async_api_wrapper("get", f"{self.base_url}/api/session") + async def async_get_session_liveness(self) -> bool: + """ + Determine if a vehicle is connected to the charger. + + This call checks the session's liveness, indicating whether a vehicle + is connected to the charger. Note that "connected" refers to the physical + connection between the vehicle and the charger, regardless of whether + charging is actively in progress. + """ + live = await self._async_api_wrapper( + "get", f"{self.base_url}/api/session/alive" + ) + return live is not None + async def async_post_disable(self, address) -> list[dict]: "Disable the charger (lock)" return await self._async_api_wrapper( @@ -151,6 +165,8 @@ async def _async_api_wrapper( text = await response.read() _LOGGER.info("Response: %r", text) return text + if response.status == 404: + return None elif response.status == 400: # Handle expired/invalid tokens if not _reissue: diff --git a/custom_components/eo_mini/binary_sensor.py b/custom_components/eo_mini/binary_sensor.py new file mode 100644 index 0000000..517faef --- /dev/null +++ b/custom_components/eo_mini/binary_sensor.py @@ -0,0 +1,52 @@ +"""Binary Sensor platform for EO Mini.""" + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, + BinarySensorDeviceClass, +) +from homeassistant.core import callback + +from custom_components.eo_mini import EODataUpdateCoordinator +from .const import DOMAIN +from .entity import EOMiniChargerEntity + + +async def async_setup_entry(hass, entry, async_add_devices): + """Setup binary sensor platform.""" + coordinator: EODataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_devices( + [ + EOMiniChargerVehicleConnectedSensor(coordinator), + ] + ) + + +class EOMiniChargerVehicleConnectedSensor(EOMiniChargerEntity, BinarySensorEntity): + """EO Mini Charger vehicle connected binary sensor class.""" + + coordinator: EODataUpdateCoordinator + + _attr_icon = "mdi:car-electric" + _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY + _previous_state = None + + def __init__(self, *args): + self.entity_description = BinarySensorEntityDescription( + key=BinarySensorDeviceClass.CONNECTIVITY, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + name="Vehicle Connected", + ) + self._attr_is_on = False + super().__init__(*args) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._attr_is_on = self.coordinator.live_session + self.async_write_ha_state() + + @property + def unique_id(self): + """Return a unique ID for this entity.""" + return f"{DOMAIN}_charger_{self.coordinator.serial}_vehicle_connected" diff --git a/custom_components/eo_mini/config_flow.py b/custom_components/eo_mini/config_flow.py index 8e7f28c..7a0bb0e 100644 --- a/custom_components/eo_mini/config_flow.py +++ b/custom_components/eo_mini/config_flow.py @@ -1,8 +1,10 @@ """Adds config flow for Blueprint.""" + import logging import traceback import voluptuous as vol from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_create_clientsession @@ -18,7 +20,7 @@ _LOGGER: logging.Logger = logging.getLogger(__package__) -class EOMiniFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): +class EOMiniFlowHandler(ConfigFlow, domain=DOMAIN): "Config flow for EO Mini." VERSION = 1 @@ -96,12 +98,12 @@ async def _show_config_form(self, user_input): ) -class EOMiniOptionsFlowHandler(config_entries.OptionsFlow): +class EOMiniOptionsFlowHandler(OptionsFlow): "EO Mini config flow options handler." - def __init__(self, config_entry): + def __init__(self, config_entry: ConfigEntry): "Initialize." - self.config_entry = config_entry + self._entry = config_entry self.options = dict(config_entry.options) async def async_step_init(self, user_input=None): # pylint: disable=unused-argument diff --git a/custom_components/eo_mini/const.py b/custom_components/eo_mini/const.py index 2f3ca7e..8703f93 100644 --- a/custom_components/eo_mini/const.py +++ b/custom_components/eo_mini/const.py @@ -10,7 +10,8 @@ # Platforms SENSOR = "sensor" SWITCH = "switch" -PLATFORMS = [SENSOR, SWITCH] +BINARY_SENSOR = "binary_sensor" +PLATFORMS = [SENSOR, BINARY_SENSOR, SWITCH] # Configuration and options diff --git a/custom_components/eo_mini/sensor.py b/custom_components/eo_mini/sensor.py index 4c2aa96..0e974f2 100644 --- a/custom_components/eo_mini/sensor.py +++ b/custom_components/eo_mini/sensor.py @@ -1,4 +1,5 @@ """Sensor platform for EO Mini.""" + from datetime import datetime from homeassistant.components.sensor import ( SensorEntity, @@ -6,6 +7,7 @@ SensorStateClass, SensorDeviceClass, ) + from homeassistant.const import UnitOfTime, UnitOfEnergy from homeassistant.core import callback @@ -87,9 +89,6 @@ def _handle_coordinator_update(self) -> None: if self.coordinator.data: if self.coordinator.data["ESKWH"] == 0: - self._attr_last_reset = datetime.fromtimestamp( - self.coordinator.data["PiTime"] - ) self._attr_native_value = 0 else: self._attr_native_value = self.coordinator.data["ChargingTime"] diff --git a/requirements.txt b/requirements.txt index e0c9a1b..8c496a6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ colorlog==6.7.0 -homeassistant==2024.3.1 +homeassistant==2024.12.1 pip>=21.0,<23.2 ruff==0.0.292 \ No newline at end of file diff --git a/requirements_test.txt b/requirements_test.txt index 71f5999..e8c257a 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1 +1,2 @@ pytest-homeassistant-custom-component +homeassistant==2024.12.1 \ No newline at end of file diff --git a/tests/README.md b/tests/README.md index 9469994..8f18dda 100644 --- a/tests/README.md +++ b/tests/README.md @@ -20,5 +20,5 @@ This will install `homeassistant`, `pytest`, and `pytest-homeassistant-custom-co Command | Description ------- | ----------- `pytest tests/` | This will run all tests in `tests/` and tell you how many passed/failed -`pytest --durations=10 --cov-report term-missing --cov=custom_components.eo_mini tests` | This tells `pytest` that your target module to test is `custom_components.eo_mini` so that it can give you a [code coverage](https://en.wikipedia.org/wiki/Code_coverage) summary, including % of code that was executed and the line numbers of missed executions. +`pytest --durations=10 --cov-report term-missing --cov=custom_components.eo_mini tests --asyncio-mode=auto` | This tells `pytest` that your target module to test is `custom_components.eo_mini` so that it can give you a [code coverage](https://en.wikipedia.org/wiki/Code_coverage) summary, including % of code that was executed and the line numbers of missed executions. `pytest tests/test_init.py -k test_setup_unload_and_reload_entry` | Runs the `test_setup_unload_and_reload_entry` test function located in `tests/test_init.py` diff --git a/tests/example_data/session_liveness.json b/tests/example_data/session_liveness.json new file mode 100644 index 0000000..ec747fa --- /dev/null +++ b/tests/example_data/session_liveness.json @@ -0,0 +1 @@ +null \ No newline at end of file diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index d905a8c..f2449c7 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -18,7 +18,10 @@ @pytest.fixture(autouse=True) def bypass_setup_fixture(): """Prevent setup.""" - with patch("custom_components.eo_mini.async_setup", return_value=True,), patch( + with patch( + "custom_components.eo_mini.async_setup", + return_value=True, + ), patch( "custom_components.eo_mini.async_setup_entry", return_value=True, ): diff --git a/tests/test_init.py b/tests/test_init.py index 6aaaf5b..4ca2435 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -1,5 +1,6 @@ "Test integration_blueprint setup process." from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.config_entries import ConfigEntryState import pytest import aiohttp from unittest.mock import patch @@ -20,7 +21,9 @@ async def test_setup_unload_and_reload_entry(hass): """Test entry setup and unload.""" # Create a mock entry so we don't have to go through config flow - config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="test") + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_CONFIG, entry_id="test", state=ConfigEntryState.LOADED + ) with patch( "custom_components.eo_mini.EOApiClient.async_get_list", @@ -31,6 +34,9 @@ async def test_setup_unload_and_reload_entry(hass): ), patch( "custom_components.eo_mini.EOApiClient.async_get_session", return_value=json_load_file("session_charging.json"), + ), patch( + "custom_components.eo_mini.EOApiClient.async_get_session_liveness", + return_value=json_load_file("session_liveness.json"), ): # Set up the entry and assert that the values set during setup are where we expect # them to be. Because we have patched the BlueprintDataUpdateCoordinator.async_get_data