From f7a0d29d5203b7c748660348c7cbbaef212f1a1d Mon Sep 17 00:00:00 2001 From: Jordan Harvey Date: Mon, 24 Jun 2024 22:57:30 +0100 Subject: [PATCH] Feature: Cheapest Fuel Station Sensors (#18) * start #6 * add required config options for #6 * implement #6 * fix lint errors --- custom_components/fuel_prices/__init__.py | 56 +++++++++--- custom_components/fuel_prices/config_flow.py | 88 ++++++++++++++++--- custom_components/fuel_prices/const.py | 4 + custom_components/fuel_prices/entity.py | 20 +++++ custom_components/fuel_prices/manifest.json | 2 +- custom_components/fuel_prices/sensor.py | 76 ++++++++++++++-- custom_components/fuel_prices/strings.json | 20 ++++- .../fuel_prices/translations/en.json | 20 ++++- hacs.json | 2 +- requirements.txt | 4 +- 10 files changed, 252 insertions(+), 40 deletions(-) diff --git a/custom_components/fuel_prices/__init__.py b/custom_components/fuel_prices/__init__.py index 15109ac..61b4c0a 100644 --- a/custom_components/fuel_prices/__init__.py +++ b/custom_components/fuel_prices/__init__.py @@ -16,6 +16,7 @@ CONF_RADIUS, CONF_TIMEOUT, CONF_SCAN_INTERVAL, + CONF_NAME ) from homeassistant.core import ( HomeAssistant, @@ -25,7 +26,7 @@ ) from homeassistant.exceptions import HomeAssistantError -from .const import DOMAIN, CONF_AREAS, CONF_SOURCES +from .const import DOMAIN, CONF_AREAS, CONF_SOURCES, CONF_CHEAPEST_SENSORS, CONF_CHEAPEST_SENSORS_COUNT, CONF_CHEAPEST_SENSORS_FUEL_TYPE from .coordinator import FuelPricesCoordinator _LOGGER = logging.getLogger(__name__) @@ -48,7 +49,8 @@ def _build_configured_areas(hass_areas: dict) -> list[dict]: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Create ConfigEntry.""" _LOGGER.debug("Got request to setup entry.") - sources = entry.options.get(CONF_SOURCES, entry.data.get(CONF_SOURCES, None)) + sources = entry.options.get( + CONF_SOURCES, entry.data.get(CONF_SOURCES, None)) areas = entry.options.get(CONF_AREAS, entry.data.get(CONF_AREAS, None)) timeout = entry.options.get(CONF_TIMEOUT, entry.data.get(CONF_TIMEOUT, 30)) update_interval = entry.options.get( @@ -72,13 +74,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.error(err) raise CannotConnect from err - async def update_listener(hass: HomeAssistant, entry: ConfigEntry): - """Update listener.""" - await hass.data[DOMAIN][entry.entry_id].api.client_session.close() - await hass.config_entries.async_reload(entry.entry_id) - - entry.async_on_unload(entry.add_update_listener(update_listener)) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) async def handle_fuel_lookup(call: ServiceCall) -> ServiceResponse: @@ -97,7 +92,8 @@ async def handle_fuel_lookup(call: ServiceCall) -> ServiceResponse: ) } except ValueError as err: - raise HomeAssistantError("Country not available for fuel data.") from err + raise HomeAssistantError( + "Country not available for fuel data.") from err async def handle_fuel_location_lookup(call: ServiceCall) -> ServiceResponse: """Handle a fuel location lookup call.""" @@ -112,7 +108,8 @@ async def handle_fuel_location_lookup(call: ServiceCall) -> ServiceResponse: (lat, long), radius ) except ValueError as err: - raise HomeAssistantError("Country not available for fuel data.") from err + raise HomeAssistantError( + "Country not available for fuel data.") from err return {"items": locations, "sources": entry.data.get("sources", [])} @@ -136,6 +133,12 @@ async def handle_force_update(call: ServiceCall): hass.services.async_register(DOMAIN, "force_update", handle_force_update) + async def update_listener(hass: HomeAssistant, entry: ConfigEntry): + """Update listener.""" + await hass.config_entries.async_reload(entry.entry_id) + + entry.async_on_unload(entry.add_update_listener(update_listener)) + return True @@ -148,6 +151,37 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok +# Example migration function + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry): + """Migrate old entry.""" + _LOGGER.debug("Migrating configuration from version %s", + config_entry.version) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + + if config_entry.version == 1: + + new_data = {**config_entry.data} + if config_entry.options: + new_data = {**config_entry.options} + for area in new_data[CONF_AREAS]: + _LOGGER.debug("Upgrading area definition for %s", area[CONF_NAME]) + area[CONF_CHEAPEST_SENSORS] = False + area[CONF_CHEAPEST_SENSORS_COUNT] = 5 + area[CONF_CHEAPEST_SENSORS_FUEL_TYPE] = "" + + hass.config_entries.async_update_entry( + config_entry, data=new_data, version=2) + + _LOGGER.debug("Migration to configuration version %s successful", + config_entry.version) + + return True + class CannotConnect(HomeAssistantError): """Error to indicate we cannot connect.""" diff --git a/custom_components/fuel_prices/config_flow.py b/custom_components/fuel_prices/config_flow.py index 767cd34..bb07063 100644 --- a/custom_components/fuel_prices/config_flow.py +++ b/custom_components/fuel_prices/config_flow.py @@ -22,7 +22,7 @@ CONF_SCAN_INTERVAL, ) -from .const import DOMAIN, NAME, CONF_AREAS, CONF_SOURCES, CONF_STATE_VALUE +from .const import DOMAIN, NAME, CONF_AREAS, CONF_SOURCES, CONF_STATE_VALUE, CONF_CHEAPEST_SENSORS, CONF_CHEAPEST_SENSORS_COUNT, CONF_CHEAPEST_SENSORS_FUEL_TYPE _LOGGER = logging.getLogger(__name__) @@ -44,6 +44,16 @@ vol.Inclusive( CONF_LONGITUDE, "coordinates", "Latitude and longitude must exist together" ): cv.longitude, + vol.Optional(CONF_CHEAPEST_SENSORS, default=False): selector.BooleanSelector(), + vol.Optional(CONF_CHEAPEST_SENSORS_COUNT, default=5): selector.NumberSelector( + selector.NumberSelectorConfig( + mode=selector.NumberSelectorMode.SLIDER, + min=1, + max=10, + step=1 + ) + ), + vol.Optional(CONF_CHEAPEST_SENSORS_FUEL_TYPE, default=""): selector.TextSelector(), } ) @@ -51,7 +61,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow.""" - VERSION = 1 + VERSION = 2 configured_areas: list[dict] = [] configured_sources = [] configuring_area = {} @@ -172,6 +182,9 @@ async def async_step_area_create(self, user_input: dict[str, Any] | None = None) CONF_LATITUDE: user_input[CONF_LATITUDE], CONF_LONGITUDE: user_input[CONF_LONGITUDE], CONF_RADIUS: user_input[CONF_RADIUS], + CONF_CHEAPEST_SENSORS: user_input[CONF_CHEAPEST_SENSORS], + CONF_CHEAPEST_SENSORS_COUNT: user_input[CONF_CHEAPEST_SENSORS_COUNT], + CONF_CHEAPEST_SENSORS_FUEL_TYPE: user_input[CONF_CHEAPEST_SENSORS_FUEL_TYPE] } ) return await self.async_step_area_menu() @@ -217,6 +230,9 @@ async def async_step_area_update(self, user_input: dict[str, Any] | None = None) CONF_LATITUDE: user_input[CONF_LATITUDE], CONF_LONGITUDE: user_input[CONF_LONGITUDE], CONF_RADIUS: user_input[CONF_RADIUS], + CONF_CHEAPEST_SENSORS: user_input[CONF_CHEAPEST_SENSORS], + CONF_CHEAPEST_SENSORS_COUNT: user_input[CONF_CHEAPEST_SENSORS_COUNT], + CONF_CHEAPEST_SENSORS_FUEL_TYPE: user_input[CONF_CHEAPEST_SENSORS_FUEL_TYPE] } ) return await self.async_step_area_menu() @@ -250,6 +266,25 @@ async def async_step_area_update(self, user_input: dict[str, Any] | None = None) "Latitude and longitude must exist together", default=self.configuring_area[CONF_LONGITUDE], ): cv.longitude, + vol.Optional( + CONF_CHEAPEST_SENSORS, + default=self.configuring_area[CONF_CHEAPEST_SENSORS] + ): selector.BooleanSelector(), + vol.Optional( + CONF_CHEAPEST_SENSORS_COUNT, + default=self.configuring_area[CONF_CHEAPEST_SENSORS_COUNT] + ): selector.NumberSelector( + selector.NumberSelectorConfig( + mode=selector.NumberSelectorMode.SLIDER, + min=1, + max=10, + step=1 + ) + ), + vol.Optional( + CONF_CHEAPEST_SENSORS_FUEL_TYPE, + default=self.configuring_area[CONF_CHEAPEST_SENSORS_FUEL_TYPE] + ): selector.TextSelector() } ), errors=errors, @@ -342,6 +377,19 @@ def configured_area_names(self) -> list[str]: items.append(area["name"]) return items + async def _async_create_entry(self) -> config_entries.FlowResult: + """Create an entry.""" + return self.async_create_entry( + title=self.config_entry.title, + data={ + CONF_AREAS: self.configured_areas, + CONF_SOURCES: self.configured_sources, + CONF_SCAN_INTERVAL: self.interval, + CONF_TIMEOUT: self.timeout, + CONF_STATE_VALUE: self.state_value + } + ) + async def async_step_init(self, _: None = None): """User init option flow.""" return await self.async_step_main_menu() @@ -435,6 +483,9 @@ async def async_step_area_create(self, user_input: dict[str, Any] | None = None) CONF_LATITUDE: user_input[CONF_LATITUDE], CONF_LONGITUDE: user_input[CONF_LONGITUDE], CONF_RADIUS: user_input[CONF_RADIUS], + CONF_CHEAPEST_SENSORS: user_input[CONF_CHEAPEST_SENSORS], + CONF_CHEAPEST_SENSORS_COUNT: user_input[CONF_CHEAPEST_SENSORS_COUNT], + CONF_CHEAPEST_SENSORS_FUEL_TYPE: user_input[CONF_CHEAPEST_SENSORS_FUEL_TYPE] } ) return await self.async_step_area_menu() @@ -480,6 +531,9 @@ async def async_step_area_update(self, user_input: dict[str, Any] | None = None) CONF_LATITUDE: user_input[CONF_LATITUDE], CONF_LONGITUDE: user_input[CONF_LONGITUDE], CONF_RADIUS: user_input[CONF_RADIUS], + CONF_CHEAPEST_SENSORS: user_input[CONF_CHEAPEST_SENSORS], + CONF_CHEAPEST_SENSORS_COUNT: user_input[CONF_CHEAPEST_SENSORS_COUNT], + CONF_CHEAPEST_SENSORS_FUEL_TYPE: user_input[CONF_CHEAPEST_SENSORS_FUEL_TYPE] } ) return await self.async_step_area_menu() @@ -513,6 +567,25 @@ async def async_step_area_update(self, user_input: dict[str, Any] | None = None) "Latitude and longitude must exist together", default=self.configuring_area[CONF_LONGITUDE], ): cv.longitude, + vol.Optional( + CONF_CHEAPEST_SENSORS, + default=self.configuring_area[CONF_CHEAPEST_SENSORS] + ): selector.BooleanSelector(), + vol.Optional( + CONF_CHEAPEST_SENSORS_COUNT, + default=self.configuring_area[CONF_CHEAPEST_SENSORS_COUNT] + ): selector.NumberSelector( + selector.NumberSelectorConfig( + mode=selector.NumberSelectorMode.SLIDER, + min=1, + max=10, + step=1 + ) + ), + vol.Optional( + CONF_CHEAPEST_SENSORS_FUEL_TYPE, + default=self.configuring_area[CONF_CHEAPEST_SENSORS_FUEL_TYPE] + ): selector.TextSelector() } ), errors=errors, @@ -546,16 +619,7 @@ async def async_step_finished(self, user_input: dict[str, Any] | None = None): """Save configuration.""" errors: dict[str, str] = {} if user_input is not None: - user_input[CONF_SOURCES] = ( - self.configured_sources - if len(self.configured_sources) > 0 - else list(SOURCE_MAP) - ) - user_input[CONF_AREAS] = self.configured_areas - user_input[CONF_SCAN_INTERVAL] = self.interval - user_input[CONF_TIMEOUT] = self.timeout - user_input[CONF_STATE_VALUE] = self.state_value - return self.async_create_entry(title=NAME, data=user_input) + return await self._async_create_entry() return self.async_show_form(step_id="finished", errors=errors, last_step=True) diff --git a/custom_components/fuel_prices/const.py b/custom_components/fuel_prices/const.py index c7b8f12..d0656ee 100644 --- a/custom_components/fuel_prices/const.py +++ b/custom_components/fuel_prices/const.py @@ -7,3 +7,7 @@ CONF_SOURCES = "sources" CONF_STATE_VALUE = "state" + +CONF_CHEAPEST_SENSORS = "cheapest_stations" +CONF_CHEAPEST_SENSORS_COUNT = "cheapest_stations_count" +CONF_CHEAPEST_SENSORS_FUEL_TYPE = "cheapest_stations_fuel_type" diff --git a/custom_components/fuel_prices/entity.py b/custom_components/fuel_prices/entity.py index e9e40e4..819cb95 100644 --- a/custom_components/fuel_prices/entity.py +++ b/custom_components/fuel_prices/entity.py @@ -33,3 +33,23 @@ def _fuel_station(self): def unique_id(self) -> str | None: """Return unique ID.""" return f"fuelprices_{self._fuel_station_id}_{self._entity_id}" + + +class CheapestFuelEntity(CoordinatorEntity): + """Represents a fuel.""" + + def __init__( + self, coordinator: FuelPricesCoordinator, count: str, area: str, fuel: str, coords: tuple, radius: float): + """Initialize.""" + super().__init__(coordinator) + self.coordinator: FuelPricesCoordinator = coordinator + self._count = count + self._area = area + self._coords = coords + self._radius = radius + self._fuel = fuel + + @property + def unique_id(self) -> str | None: + """Return unique ID.""" + return f"fuelprices_cheapest_{self._fuel}_{self._count}_{self._area}" diff --git a/custom_components/fuel_prices/manifest.json b/custom_components/fuel_prices/manifest.json index 7b1c3d7..8665592 100644 --- a/custom_components/fuel_prices/manifest.json +++ b/custom_components/fuel_prices/manifest.json @@ -11,7 +11,7 @@ "issue_tracker": "https://github.com/pantherale0/ha-fuelprices/issues", "requirements": [ "these-united-states==1.1.0.21", - "pyfuelprices==2.3.4" + "pyfuelprices==2.5.1" ], "ssdp": [], "version": "0.0.0", diff --git a/custom_components/fuel_prices/sensor.py b/custom_components/fuel_prices/sensor.py index 2f3ab4c..53c2c9c 100644 --- a/custom_components/fuel_prices/sensor.py +++ b/custom_components/fuel_prices/sensor.py @@ -7,15 +7,16 @@ from collections.abc import Mapping from typing import Any +from datetime import datetime, timedelta -from homeassistant.components.sensor import SensorEntity, SensorDeviceClass -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS, CONF_NAME +from homeassistant.components.sensor import SensorEntity +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS, CONF_NAME, STATE_UNKNOWN from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from pyfuelprices.const import PROP_FUEL_LOCATION_SOURCE -from .const import CONF_AREAS, DOMAIN, CONF_STATE_VALUE -from .entity import FuelStationEntity +from .const import CONF_AREAS, DOMAIN, CONF_STATE_VALUE, CONF_CHEAPEST_SENSORS, CONF_CHEAPEST_SENSORS_COUNT, CONF_CHEAPEST_SENSORS_FUEL_TYPE +from .entity import FuelStationEntity, CheapestFuelEntity from .coordinator import FuelPricesCoordinator _LOGGER = logging.getLogger(__name__) @@ -50,7 +51,19 @@ async def async_setup_entry( ) ) found_entities.append(station["id"]) - + if area[CONF_CHEAPEST_SENSORS]: + _LOGGER.debug("Registering %s cheapest entities for area %s", + area[CONF_CHEAPEST_SENSORS_COUNT], + area[CONF_NAME]) + for x in range(0, int(area[CONF_CHEAPEST_SENSORS_COUNT]), 1): + entities.append(CheapestFuelSensor( + coordinator=cooridinator, + count=x+1, + area=area[CONF_NAME], + fuel=area[CONF_CHEAPEST_SENSORS_FUEL_TYPE], + coords=(area[CONF_LATITUDE], area[CONF_LONGITUDE]), + radius=area[CONF_RADIUS] + )) async_add_entities(entities, True) @@ -104,3 +117,56 @@ def state_class(self) -> str: if isinstance(self.native_value, str): return None return "total" + + +class CheapestFuelSensor(CheapestFuelEntity, SensorEntity): + """A entity that shows the cheapest fuel for an area.""" + + _attr_should_poll = True # we need to query the module for this data + _last_update = None + _next_update = datetime.now() + _cached_data = None + + async def async_update(self) -> None: + """Update device data.""" + if (self._last_update is not None) and ( + self._next_update > datetime.now() + ): + return True + data = await self.coordinator.api.find_fuel_from_point( + coordinates=self._coords, + fuel_type=self._fuel, + radius=self._radius + ) + if len(data) >= (int(self._count)-1): + self._cached_data = data[int(self._count)-1] + return True + self._cached_data = None + self._next_update = datetime.now() + timedelta(days=1) + + @property + def native_value(self) -> str | float: + """Return state of entity.""" + if self._cached_data is not None: + return self._cached_data["cost"] + return STATE_UNKNOWN + + @property + def name(self) -> str: + """Name of the entity.""" + return f"{self._area} cheapest {self._fuel} {self._count}" + + @property + def state_class(self) -> str: + """Return state type.""" + if isinstance(self.native_value, float): + return "total" + return None + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return extra state attributes.""" + return { + "area": self._area, + **self._cached_data + } diff --git a/custom_components/fuel_prices/strings.json b/custom_components/fuel_prices/strings.json index cc84926..9161804 100644 --- a/custom_components/fuel_prices/strings.json +++ b/custom_components/fuel_prices/strings.json @@ -33,7 +33,10 @@ "name": "Area name (must be unique)", "radius": "Maximum search radius", "latitude": "Latitude for the center of the search location", - "longitude": "Longitude for the center of the search location" + "longitude": "Longitude for the center of the search location", + "cheapest_stations": "Register entities for top n cheapest fuel stations in this area", + "cheapest_stations_count": "Number of cheapest fuel station entities to create", + "cheapest_stations_fuel_type": "The fuel type to use for the cheapest fuel station entites" } }, "area_update_select": { @@ -49,7 +52,10 @@ "name": "Area name (must be unique)", "radius": "Maximum search radius", "latitude": "Latitude for the center of the search location", - "longitude": "Longitude for the center of the search location" + "longitude": "Longitude for the center of the search location", + "cheapest_stations": "Register entities for top n cheapest fuel stations in this area", + "cheapest_stations_count": "Number of cheapest fuel station entities to create", + "cheapest_stations_fuel_type": "The fuel type to use for the cheapest fuel station entites" } }, "area_delete": { @@ -119,7 +125,10 @@ "name": "Area name (must be unique)", "radius": "Maximum search radius", "latitude": "Latitude for the center of the search location", - "longitude": "Longitude for the center of the search location" + "longitude": "Longitude for the center of the search location", + "cheapest_stations": "Register entities for top n cheapest fuel stations in this area", + "cheapest_stations_count": "Number of cheapest fuel station entities to create", + "cheapest_stations_fuel_type": "The fuel type to use for the cheapest fuel station entites" } }, "area_update_select": { @@ -135,7 +144,10 @@ "name": "Area name (must be unique)", "radius": "Maximum search radius", "latitude": "Latitude for the center of the search location", - "longitude": "Longitude for the center of the search location" + "longitude": "Longitude for the center of the search location", + "cheapest_stations": "Register entities for top n cheapest fuel stations in this area", + "cheapest_stations_count": "Number of cheapest fuel station entities to create", + "cheapest_stations_fuel_type": "The fuel type to use for the cheapest fuel station entites" } }, "area_delete": { diff --git a/custom_components/fuel_prices/translations/en.json b/custom_components/fuel_prices/translations/en.json index cc84926..9161804 100644 --- a/custom_components/fuel_prices/translations/en.json +++ b/custom_components/fuel_prices/translations/en.json @@ -33,7 +33,10 @@ "name": "Area name (must be unique)", "radius": "Maximum search radius", "latitude": "Latitude for the center of the search location", - "longitude": "Longitude for the center of the search location" + "longitude": "Longitude for the center of the search location", + "cheapest_stations": "Register entities for top n cheapest fuel stations in this area", + "cheapest_stations_count": "Number of cheapest fuel station entities to create", + "cheapest_stations_fuel_type": "The fuel type to use for the cheapest fuel station entites" } }, "area_update_select": { @@ -49,7 +52,10 @@ "name": "Area name (must be unique)", "radius": "Maximum search radius", "latitude": "Latitude for the center of the search location", - "longitude": "Longitude for the center of the search location" + "longitude": "Longitude for the center of the search location", + "cheapest_stations": "Register entities for top n cheapest fuel stations in this area", + "cheapest_stations_count": "Number of cheapest fuel station entities to create", + "cheapest_stations_fuel_type": "The fuel type to use for the cheapest fuel station entites" } }, "area_delete": { @@ -119,7 +125,10 @@ "name": "Area name (must be unique)", "radius": "Maximum search radius", "latitude": "Latitude for the center of the search location", - "longitude": "Longitude for the center of the search location" + "longitude": "Longitude for the center of the search location", + "cheapest_stations": "Register entities for top n cheapest fuel stations in this area", + "cheapest_stations_count": "Number of cheapest fuel station entities to create", + "cheapest_stations_fuel_type": "The fuel type to use for the cheapest fuel station entites" } }, "area_update_select": { @@ -135,7 +144,10 @@ "name": "Area name (must be unique)", "radius": "Maximum search radius", "latitude": "Latitude for the center of the search location", - "longitude": "Longitude for the center of the search location" + "longitude": "Longitude for the center of the search location", + "cheapest_stations": "Register entities for top n cheapest fuel stations in this area", + "cheapest_stations_count": "Number of cheapest fuel station entities to create", + "cheapest_stations_fuel_type": "The fuel type to use for the cheapest fuel station entites" } }, "area_delete": { diff --git a/hacs.json b/hacs.json index ca60302..018518a 100644 --- a/hacs.json +++ b/hacs.json @@ -2,7 +2,7 @@ "name": "Fuel Prices", "filename": "fuel_prices.zip", "hide_default_branch": true, - "homeassistant": "2023.1.0", + "homeassistant": "2024.3.3", "render_readme": true, "zip_release": true } diff --git a/requirements.txt b/requirements.txt index 2d63f66..971c250 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ colorlog==6.7.0 -homeassistant==2023.8.0 +homeassistant==2024.3.3 pip>=21.0,<23.2 ruff==0.0.292 -pyfuelprices==2.3.4 \ No newline at end of file +pyfuelprices==2.5.1 \ No newline at end of file