diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..e69886d --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,20 @@ +changelog: + exclude: + labels: + - ignore-for-release + categories: + - title: Breaking Changes 🛠 + labels: + - breaking-change + - title: New Features 🎉 + labels: + - enhancement + - title: Bug Fixes 🛠 + labels: + - bug-fix + - title: 👒 Dependencies + labels: + - dependencies + - title: Other Changes + labels: + - "*" \ No newline at end of file diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml new file mode 100644 index 0000000..7542dbd --- /dev/null +++ b/.github/workflows/create-release.yml @@ -0,0 +1,24 @@ +name: Create release + +on: + workflow_dispatch: + push: + tags: "*" + +permissions: + contents: write + +jobs: + publish: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v3 + - uses: cho0o0/calver-release-action@2022.12.14.1 + with: + generate_release_notes: true + dry_run: true + # Do not use GITHUB_TOKEN if you want to trigger other workflows + timezone: "utc" + api_token: ${{secrets.GITHUB_TOKEN}} + release_title: "${version}" diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml new file mode 100644 index 0000000..b07a5e9 --- /dev/null +++ b/.github/workflows/documentation.yml @@ -0,0 +1,29 @@ +name: documentation +on: + push: + branches: + - main +permissions: + contents: write +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Configure Git Credentials + run: | + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + - uses: actions/setup-python@v5 + with: + python-version: 3.x + - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV + - uses: actions/cache@v4 + with: + key: mkdocs-material-${{ env.cache_id }} + path: .cache + restore-keys: | + mkdocs-material- + - name: "Install requirements" + run: python3 -m pip install -r "${{ github.workspace }}/requirements.docs.txt" + - run: mkdocs gh-deploy --force diff --git a/custom_components/fuel_prices/__init__.py b/custom_components/fuel_prices/__init__.py index d87c80a..68a6732 100644 --- a/custom_components/fuel_prices/__init__.py +++ b/custom_components/fuel_prices/__init__.py @@ -39,6 +39,7 @@ class FuelPricesConfig: coordinator: FuelPricesCoordinator areas: list[dict] + config: ConfigEntry type FuelPricesConfigEntry = ConfigEntry[FuelPricesConfig] @@ -92,10 +93,11 @@ async def handle_fuel_lookup(call: ServiceCall) -> ServiceResponse: lat = call.data.get("location", {}).get("latitude", default_lat) long = call.data.get("location", {}).get("longitude", default_long) fuel_type = call.data.get("type") + source = call.data.get("source", "") try: return { "fuels": await fuel_prices.find_fuel_from_point( - (lat, long), radius, fuel_type + (lat, long), radius, fuel_type, source ) } except ValueError as err: @@ -110,9 +112,10 @@ async def handle_fuel_location_lookup(call: ServiceCall) -> ServiceResponse: radius = radius / 1609 lat = call.data.get("location", {}).get("latitude", default_lat) long = call.data.get("location", {}).get("longitude", default_long) + source = call.data.get("source", "") try: locations = await fuel_prices.find_fuel_locations_from_point( - (lat, long), radius + (lat, long), radius, source ) except ValueError as err: raise HomeAssistantError( @@ -140,7 +143,8 @@ async def handle_force_update(call: ServiceCall): hass.services.async_register(DOMAIN, "force_update", handle_force_update) - entry.runtime_data = FuelPricesConfig(coordinator=coordinator, areas=areas) + entry.runtime_data = FuelPricesConfig( + coordinator=coordinator, areas=areas, config=entry) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -180,8 +184,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry): _LOGGER.warning("Removing jet and morrisons from config entry.") if "morrisons" in new_data[CONF_SOURCES]: new_data[CONF_SOURCES].remove("morrisons") - if "jet" in new_data[CONF_SOURCES]: - new_data[CONF_SOURCES].remove("jet") hass.config_entries.async_update_entry( config_entry, data=new_data, version=3 ) diff --git a/custom_components/fuel_prices/config_flow.py b/custom_components/fuel_prices/config_flow.py index 97979e1..ed05228 100644 --- a/custom_components/fuel_prices/config_flow.py +++ b/custom_components/fuel_prices/config_flow.py @@ -64,7 +64,8 @@ ): selector.SelectSelector( selector.SelectSelectorConfig( mode=selector.SelectSelectorMode.DROPDOWN, - options=list(SOURCE_MAP), + options=[k for k, v in SOURCE_MAP.items() if v[1] == + 1 and v[2] == 1], multiple=True, ) ), @@ -288,7 +289,8 @@ async def async_step_finished(self, user_input: dict[str, Any] | None = None): user_input[CONF_SOURCES] = COUNTRY_MAP.get( self.hass.config.country) else: - user_input[CONF_SOURCES] = list(SOURCE_MAP) + user_input[CONF_SOURCES] = [ + k for k, v in SOURCE_MAP.items() if v[1] == 1 and v[2] == 1] user_input[CONF_AREAS] = self.configured_areas user_input[CONF_SCAN_INTERVAL] = self.interval user_input[CONF_TIMEOUT] = self.timeout @@ -502,7 +504,8 @@ async def async_step_finished(self, user_input: dict[str, Any] | None = None): user_input[CONF_SOURCES] = COUNTRY_MAP.get( self.hass.config.country) else: - user_input[CONF_SOURCES] = list(SOURCE_MAP) + user_input[CONF_SOURCES] = [ + k for k, v in SOURCE_MAP.items() if v[1] == 1 and v[2] == 1] user_input[CONF_AREAS] = self.configured_areas user_input[CONF_SCAN_INTERVAL] = self.interval user_input[CONF_TIMEOUT] = self.timeout diff --git a/custom_components/fuel_prices/coordinator.py b/custom_components/fuel_prices/coordinator.py index f6a5da4..f99a1a6 100644 --- a/custom_components/fuel_prices/coordinator.py +++ b/custom_components/fuel_prices/coordinator.py @@ -6,7 +6,7 @@ import async_timeout from homeassistant.core import HomeAssistant -from pyfuelprices import FuelPrices +from pyfuelprices import FuelPrices, UpdateExceptionGroup from pyfuelprices.sources import UpdateFailedError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -32,11 +32,18 @@ async def _async_update_data(self): async with async_timeout.timeout(240): return await self.api.update() except TimeoutError as err: - _LOGGER.exception("Timeout updating fuel price data: %s", err) + _LOGGER.exception( + "Timeout updating fuel price data, will retry later: %s", err) except TypeError as err: - _LOGGER.exception("Error updating fuel price data: %s", err) + _LOGGER.exception( + "Error updating fuel price data, will retry later: %s", err) except UpdateFailedError as err: _LOGGER.exception( - "Error communicating with service (%s).", err.status, exc_info=err) + "Error communicating with a service %s", err.status, exc_info=err) + except UpdateExceptionGroup as err: + for e, v in err.failed_providers.items(): + _LOGGER.exception( + "Error communicating with service %s - %s", e, v, exc_info=v + ) except Exception as err: raise UpdateFailed(f"Error communicating with API {err}") from err diff --git a/custom_components/fuel_prices/entity.py b/custom_components/fuel_prices/entity.py index d4c8f73..2360cbb 100644 --- a/custom_components/fuel_prices/entity.py +++ b/custom_components/fuel_prices/entity.py @@ -4,17 +4,25 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.config_entries import ConfigEntry from .coordinator import FuelPricesCoordinator -class FuelStationEntity(CoordinatorEntity): +class FuelPriceEntity: + """Top level entity type.""" + + config: ConfigEntry + + +class FuelStationEntity(FuelPriceEntity, CoordinatorEntity): """Represents a fuel station.""" def __init__( - self, coordinator: FuelPricesCoordinator, fuel_station_id, entity_id, source, area, state_value + self, coordinator: FuelPricesCoordinator, fuel_station_id, entity_id, source, area, state_value, config: ConfigEntry ) -> None: """Initialize.""" + self.config = config super().__init__(coordinator) self.coordinator: FuelPricesCoordinator = coordinator self._fuel_station_id = fuel_station_id @@ -36,13 +44,14 @@ def unique_id(self) -> str | None: return f"fuelprices_{self._fuel_station_id}_{self._entity_id}" -class CheapestFuelEntity(Entity): +class CheapestFuelEntity(FuelPriceEntity, Entity): """Represents a fuel.""" def __init__( - self, coordinator: FuelPricesCoordinator, count: str, area: str, fuel: str, coords: tuple, radius: float): + self, coordinator: FuelPricesCoordinator, count: str, area: str, fuel: str, coords: tuple, radius: float, config: ConfigEntry): """Initialize.""" self.coordinator: FuelPricesCoordinator = coordinator + self.config = config self._count = count self._area = area self._coords = coords diff --git a/custom_components/fuel_prices/manifest.json b/custom_components/fuel_prices/manifest.json index fab0cb7..23d3738 100644 --- a/custom_components/fuel_prices/manifest.json +++ b/custom_components/fuel_prices/manifest.json @@ -16,7 +16,7 @@ "xmltodict", "brotli", "these-united-states==1.1.0.21", - "pyfuelprices==2025.1.2" + "pyfuelprices==2025.2.2" ], "ssdp": [], "version": "0.0.0", diff --git a/custom_components/fuel_prices/sensor.py b/custom_components/fuel_prices/sensor.py index eedbe2d..6fef2fa 100644 --- a/custom_components/fuel_prices/sensor.py +++ b/custom_components/fuel_prices/sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.components.sensor.const import SensorDeviceClass -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS, CONF_NAME, STATE_UNKNOWN +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS, CONF_NAME, STATE_UNKNOWN, CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from pyfuelprices.const import PROP_FUEL_LOCATION_SOURCE @@ -41,13 +41,14 @@ async def async_setup_entry( ): if station["id"] not in found_entities: entities.append( - FeulStationTracker( + FuelStationTracker( coordinator=entry.runtime_data.coordinator, fuel_station_id=station["id"], entity_id="devicetracker", source=station["props"][PROP_FUEL_LOCATION_SOURCE], area=area[CONF_NAME], - state_value=state_value + state_value=state_value, + config=entry ) ) found_entities.append(station["id"]) @@ -62,12 +63,13 @@ async def async_setup_entry( area=area[CONF_NAME], fuel=area[CONF_CHEAPEST_SENSORS_FUEL_TYPE], coords=(area[CONF_LATITUDE], area[CONF_LONGITUDE]), - radius=area[CONF_RADIUS] + radius=area[CONF_RADIUS], + config=entry )) async_add_entities(entities, True) -class FeulStationTracker(FuelStationEntity, SensorEntity): +class FuelStationTracker(FuelStationEntity, SensorEntity): """A fuel station entity.""" @property @@ -148,7 +150,10 @@ async def async_update(self) -> None: ) if len(data) >= (int(self._count)-1): self._last_update = datetime.now() - self._next_update = datetime.now() + timedelta(minutes=5) + self._next_update = datetime.now() + timedelta(minutes=self.config.options.get( + CONF_SCAN_INTERVAL, self.config.data.get( + CONF_SCAN_INTERVAL, 1440) + )) if len(data) >= self._count: self._cached_data = data[int(self._count)-1] else: diff --git a/custom_components/fuel_prices/services.yaml b/custom_components/fuel_prices/services.yaml index af32f27..6f21783 100644 --- a/custom_components/fuel_prices/services.yaml +++ b/custom_components/fuel_prices/services.yaml @@ -6,6 +6,11 @@ find_fuel_station: selector: location: radius: true + source: + required: false + selector: + text: + multiline: false find_fuels: fields: location: @@ -18,3 +23,8 @@ find_fuels: selector: text: multiline: false + source: + required: false + selector: + text: + multiline: false diff --git a/custom_components/fuel_prices/strings.json b/custom_components/fuel_prices/strings.json index 9161804..401729c 100644 --- a/custom_components/fuel_prices/strings.json +++ b/custom_components/fuel_prices/strings.json @@ -81,6 +81,10 @@ "type": { "name": "Fuel Type", "description": "The fuel type to search for (such as E5, E10, B7, SDV)" + }, + "source": { + "name": "Data Source to search", + "description": "The data source ID to search, defaults to 'any' for all data sources." } } }, @@ -91,6 +95,10 @@ "location": { "name": "Location", "description": "The location of the area to search" + }, + "source": { + "name": "Data Source to search", + "description": "The data source ID to search, defaults to 'any' for all data sources." } } } diff --git a/custom_components/fuel_prices/translations/en.json b/custom_components/fuel_prices/translations/en.json index 9161804..401729c 100644 --- a/custom_components/fuel_prices/translations/en.json +++ b/custom_components/fuel_prices/translations/en.json @@ -81,6 +81,10 @@ "type": { "name": "Fuel Type", "description": "The fuel type to search for (such as E5, E10, B7, SDV)" + }, + "source": { + "name": "Data Source to search", + "description": "The data source ID to search, defaults to 'any' for all data sources." } } }, @@ -91,6 +95,10 @@ "location": { "name": "Location", "description": "The location of the area to search" + }, + "source": { + "name": "Data Source to search", + "description": "The data source ID to search, defaults to 'any' for all data sources." } } } diff --git a/docs/.pages b/docs/.pages new file mode 100644 index 0000000..1ba4682 --- /dev/null +++ b/docs/.pages @@ -0,0 +1 @@ +title: Fuel Prices \ No newline at end of file diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..45c34f8 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,15 @@ +# Getting Started + +This is a data finder integration to retrieve local (or remote) fuel price data for Home Assistant using the `pyfuelprices` library. This library aims to provide the most extensive set of data sources for fuel prices in the world. + +You can use this service to: + +- Track fuel prices in your local area +- Query for fuel prices in an automation +- Calculate how much it will cost to fill your tank of fuel +- Find the cheapest station near a entity providing latitude and longitude (script required) + +## Warnings + +- Commercial usage of this integration and its Python library is strictly prohibited. +- You may fork and modify as you require or contribute to the project freely. diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..ef7a9f8 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,30 @@ +# Installation + +1. Add the repository to HACS +1. Install integration +1. Follow prompts to configure integration + +## Configuration Parameters + +The main configuration entry point is provided via a configuration flow. Using a `configuration.yaml` file to configure is not supported and will not be added in the future following Home Assistant's own design principles + +### Area Configuration Options + +| Option | Description | Type | Default | +|-----------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------|---------| +| `name` | (Required) The name of the area. | Text | None | +| `radius` | (Required) The radius of the area in miles. | Number (miles) | 5.0 | +| `latitude` | (Required, with `longitude`) The latitude of the center of the area. Must be used with `longitude`. | Latitude | None | +| `longitude` | (Required, with `latitude`) The longitude of the center of the area. Must be used with `latitude`. | Longitude | None | +| `cheapest_sensors` | (Optional) A boolean value to determine whether cheapest sensors should be created for this area. | Flag | False | +| `cheapest_sensors_count` | (Required, with `cheapest_sensors`) The number of cheapest sensors to create. Only used if `cheapest_sensors` is true. | Number (Min: 1, Max: 10, Step: 1) | 5 | +| `cheapest_sensors_fuel_type` | (Required, with `cheapest_sensors`) The fuel type for which the cheapest sensors should be created. Only used if `cheapest_sensors` is true. | Text | "" | + +### System Configuration Options + +| Option | Description | Type | Default | +|---------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------|---------| +| `sources` | (Required) A list of data sources (fuel price providers) to use. If not provided, the integration will attempt to determine the data source based on your Home Assistant configuration's country setting. | Dropdown, Multiple | None | +| `timeout` | (Optional) The timeout in seconds for requests to the data sources. | Number (Box, Unit: s, Min: 5, Max: 60) | 30 | +| `scan_interval` | (Optional) The interval in minutes between updates of the fuel prices. | Number (Box, Unit: m, Min: 360, Max: 1440) | 1440 | +| `state_value` | (Optional) The attribute to use for the state of the fuel price sensors. Used to select which piece of information from the source data is shown as the sensor's value (e.g., name, B7, E5, address). | Text | name | \ No newline at end of file diff --git a/docs/services/find_fuel_station.md b/docs/services/find_fuel_station.md new file mode 100644 index 0000000..0ae8e2b --- /dev/null +++ b/docs/services/find_fuel_station.md @@ -0,0 +1,24 @@ +# Find fuel stations from locations `find_fuel_station` + +**Name:** Find fuel stations from location + +**Description:** Find all of the available fuel stations, alongside available fuels and cost for a given location. The results are *not* sorted. + +**Fields:** + +| Field | Description | Required | Selector Type | +|------------|---------------------------------|----------|---------------| +| `location` | The location of the area to search. | Yes | Location (with radius) | + +**Example:** + +```yaml +service: fuel_prices.find_fuel_station +data: + location: + latitude: 52.520008 + longitude: 13.404954 + radius: 5 +``` + +This example would find fuel stations within a 5 mile radius of the provided coordinates. diff --git a/docs/services/find_fuels.md b/docs/services/find_fuels.md new file mode 100644 index 0000000..d8c0005 --- /dev/null +++ b/docs/services/find_fuels.md @@ -0,0 +1,26 @@ +# Find fuels from location `find_fuels` + +**Name:** Find fuel prices from location + +**Description:** This service retrieves all fuel prices for a given location, sorted by the cheapest first. + +**Fields:** + +| Field | Description | Required | Selector Type | +|------------|-------------------------------------------------|----------|---------------| +| `location` | The location of the area to search. | Yes | Location (with radius) | +| `type` | The fuel type to search for (such as E5, E10, B7, SDV). | Yes | Text (single line) | + +**Example:** + +```yaml +service: fuel_prices.find_fuels +data: + location: + latitude: 52.520008 + longitude: 13.404954 + radius: 10 + type: E10 +``` + +This example would find prices for E10 fuel within a 10-mile radius of the given latitude and longitude. diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..aa9c9f7 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,10 @@ +site_name: Home Assistant Fuel Prices +theme: + name: material + features: + - navigation.instant + - navigation.instant.progress +site_url: https://fuelprices.ha.system32.uk +plugins: + - search + - awesome-pages \ No newline at end of file diff --git a/requirements.docs.txt b/requirements.docs.txt new file mode 100644 index 0000000..88ca17b --- /dev/null +++ b/requirements.docs.txt @@ -0,0 +1,2 @@ +mkdocs-material +mkdocs-awesome-pages-plugin \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 9154bd3..10dd188 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,4 @@ colorlog==6.7.0 homeassistant==2024.11.0 pip>=21.0,<23.2 ruff==0.0.292 -pyfuelprices==2025.1.2 \ No newline at end of file +pyfuelprices==2025.2.2 \ No newline at end of file