├── .gitattributes ├── docs ├── .pages ├── data_sources │ ├── .pages │ └── new_zealand │ │ ├── .pages │ │ ├── images │ │ ├── home_page.jpg │ │ ├── profile_page.jpg │ │ ├── sign_in_page.jpg │ │ └── account_details_page.jpg │ │ └── finelly.md ├── template_examples │ ├── .pages │ ├── cheapest_n_stations_device_tracker.md │ └── cheapest_n_stations.md ├── automation_examples │ └── .pages ├── index.md ├── services │ ├── find_fuel_station.md │ └── find_fuels.md └── installation.md ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yml │ └── bug.yml ├── release.yml ├── dependabot.yml ├── workflows │ ├── lint.yml │ ├── release-drafter.yml │ ├── validate.yml │ ├── release.yml │ └── documentation.yml └── release-drafter.yml ├── requirements.docs.txt ├── scripts ├── lint ├── setup └── develop ├── requirements.txt ├── hacs.json ├── .gitignore ├── mkdocs.yml ├── .vscode └── tasks.json ├── config └── configuration.yaml ├── custom_components └── fuel_prices │ ├── const.py │ ├── manifest.json │ ├── services.yaml │ ├── repairs.py │ ├── coordinator.py │ ├── entity.py │ ├── sensor.py │ ├── __init__.py │ ├── strings.json │ ├── translations │ └── en.json │ └── config_flow.py ├── LICENSE ├── .devcontainer.json ├── .ruff.toml ├── CONTRIBUTING.md └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf -------------------------------------------------------------------------------- /docs/.pages: -------------------------------------------------------------------------------- 1 | title: Fuel Prices -------------------------------------------------------------------------------- /docs/data_sources/.pages: -------------------------------------------------------------------------------- 1 | title: Data Sources -------------------------------------------------------------------------------- /docs/template_examples/.pages: -------------------------------------------------------------------------------- 1 | title: Templates -------------------------------------------------------------------------------- /docs/automation_examples/.pages: -------------------------------------------------------------------------------- 1 | title: Automations -------------------------------------------------------------------------------- /docs/data_sources/new_zealand/.pages: -------------------------------------------------------------------------------- 1 | title: New Zealand -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false -------------------------------------------------------------------------------- /requirements.docs.txt: -------------------------------------------------------------------------------- 1 | mkdocs-material 2 | mkdocs-awesome-pages-plugin -------------------------------------------------------------------------------- /scripts/lint: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | ruff check . --fix 8 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | colorlog==6.7.0 2 | homeassistant==2025.7.0 3 | pip>=21.0,<23.2 4 | ruff==0.0.292 5 | pyfuelprices==2025.11.0 6 | -------------------------------------------------------------------------------- /scripts/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | python3 -m pip install --requirement requirements.txt 8 | -------------------------------------------------------------------------------- /docs/data_sources/new_zealand/images/home_page.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pantherale0/ha-fuelprices/HEAD/docs/data_sources/new_zealand/images/home_page.jpg -------------------------------------------------------------------------------- /docs/data_sources/new_zealand/images/profile_page.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pantherale0/ha-fuelprices/HEAD/docs/data_sources/new_zealand/images/profile_page.jpg -------------------------------------------------------------------------------- /docs/data_sources/new_zealand/images/sign_in_page.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pantherale0/ha-fuelprices/HEAD/docs/data_sources/new_zealand/images/sign_in_page.jpg -------------------------------------------------------------------------------- /docs/data_sources/new_zealand/images/account_details_page.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pantherale0/ha-fuelprices/HEAD/docs/data_sources/new_zealand/images/account_details_page.jpg -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Fuel Prices", 3 | "filename": "fuel_prices.zip", 4 | "hide_default_branch": true, 5 | "homeassistant": "2024.3.3", 6 | "render_readme": true, 7 | "zip_release": true 8 | } 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # artifacts 2 | __pycache__ 3 | .pytest* 4 | *.egg-info 5 | */build/* 6 | */dist/* 7 | 8 | 9 | # misc 10 | .coverage 11 | .vscode 12 | coverage.xml 13 | 14 | 15 | # Home Assistant configuration 16 | config/* 17 | !config/configuration.yaml -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Home Assistant Fuel Prices 2 | theme: 3 | name: material 4 | features: 5 | - navigation.instant 6 | - navigation.instant.progress 7 | site_url: https://pantherale0.github.io/ha-fuelprices 8 | plugins: 9 | - search 10 | - awesome-pages -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Run Home Assistant on port 8123", 6 | "type": "shell", 7 | "command": "scripts/develop", 8 | "problemMatcher": [] 9 | } 10 | ] 11 | } -------------------------------------------------------------------------------- /config/configuration.yaml: -------------------------------------------------------------------------------- 1 | # https://www.home-assistant.io/integrations/default_config/ 2 | default_config: 3 | 4 | # https://www.home-assistant.io/integrations/logger/ 5 | logger: 6 | default: info 7 | logs: 8 | custom_components.fuel_prices: debug 9 | pyfuelprices: debug 10 | 11 | # Enable VSCode debugging 12 | debugpy: 13 | start: true 14 | wait: false 15 | -------------------------------------------------------------------------------- /custom_components/fuel_prices/const.py: -------------------------------------------------------------------------------- 1 | """Fuel Prices integration const.""" 2 | 3 | DOMAIN = "fuel_prices" 4 | NAME = "Fuel Prices" 5 | 6 | CONF_AREAS = "areas" 7 | CONF_SOURCES = "sources" 8 | CONF_SOURCE_CONFIG = "source_config" 9 | 10 | CONF_STATE_VALUE = "state" 11 | 12 | CONF_CHEAPEST_SENSORS = "cheapest_stations" 13 | CONF_CHEAPEST_SENSORS_COUNT = "cheapest_stations_count" 14 | CONF_CHEAPEST_SENSORS_FUEL_TYPE = "cheapest_stations_fuel_type" 15 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | labels: 4 | - ignore-for-release 5 | categories: 6 | - title: Breaking Changes 🛠 7 | labels: 8 | - breaking-change 9 | - title: New Features 🎉 10 | labels: 11 | - enhancement 12 | - title: Bug Fixes 🛠 13 | labels: 14 | - bug-fix 15 | - title: 👒 Dependencies 16 | labels: 17 | - dependencies 18 | - title: Other Changes 19 | labels: 20 | - "*" -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 2 | version: 2 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | 9 | - package-ecosystem: "pip" 10 | directory: "/" 11 | schedule: 12 | interval: "weekly" 13 | ignore: 14 | # Dependabot should not update Home Assistant as that should match the homeassistant key in hacs.json 15 | - dependency-name: "homeassistant" -------------------------------------------------------------------------------- /scripts/develop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | # Create config dir if not present 8 | if [[ ! -d "${PWD}/config" ]]; then 9 | mkdir -p "${PWD}/config" 10 | hass --config "${PWD}/config" --script ensure_config 11 | fi 12 | 13 | # Set the path to custom_components 14 | ## This let's us have the structure we want /custom_components/integration_blueprint 15 | ## while at the same time have Home Assistant configuration inside /config 16 | ## without resulting to symlinks. 17 | export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components" 18 | 19 | # Start Home Assistant 20 | hass --config "${PWD}/config" --debug 21 | -------------------------------------------------------------------------------- /custom_components/fuel_prices/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "fuel_prices", 3 | "name": "Fuel Prices", 4 | "codeowners": [ 5 | "@pantherale0" 6 | ], 7 | "config_flow": true, 8 | "documentation": "https://github.com/pantherale0/ha-fuelprices", 9 | "integration_type": "service", 10 | "iot_class": "cloud_polling", 11 | "issue_tracker": "https://github.com/pantherale0/ha-fuelprices/issues", 12 | "loggers": [ 13 | "pyfuelprices" 14 | ], 15 | "requirements": [ 16 | "xmltodict", 17 | "brotli", 18 | "these-united-states==1.1.0.21", 19 | "pyfuelprices==2025.11.1" 20 | ], 21 | "ssdp": [], 22 | "version": "0.0.0", 23 | "zeroconf": [] 24 | } 25 | -------------------------------------------------------------------------------- /custom_components/fuel_prices/services.yaml: -------------------------------------------------------------------------------- 1 | # Find Fuel Locations service 2 | find_fuel_station: 3 | fields: 4 | location: 5 | required: true 6 | selector: 7 | location: 8 | radius: true 9 | source: 10 | required: false 11 | selector: 12 | text: 13 | multiline: false 14 | find_fuels: 15 | fields: 16 | location: 17 | required: true 18 | selector: 19 | location: 20 | radius: true 21 | type: 22 | required: true 23 | selector: 24 | text: 25 | multiline: false 26 | source: 27 | required: false 28 | selector: 29 | text: 30 | multiline: false 31 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | 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. 4 | 5 | You can use this service to: 6 | 7 | - Track fuel prices in your local area 8 | - Query for fuel prices in an automation 9 | - Calculate how much it will cost to fill your tank of fuel 10 | - Find the cheapest station near a entity providing latitude and longitude (script required) 11 | 12 | ## Warnings 13 | 14 | - Commercial usage of this integration and its Python library is strictly prohibited. 15 | - You may fork and modify as you require or contribute to the project freely. 16 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: "Lint" 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | paths-ignore: 8 | - "docs/**" 9 | - ".github/**" 10 | pull_request: 11 | branches: 12 | - "main" 13 | 14 | jobs: 15 | ruff: 16 | name: "Ruff" 17 | runs-on: "ubuntu-latest" 18 | steps: 19 | - name: "Checkout the repository" 20 | uses: "actions/checkout@v4.1.0" 21 | 22 | - name: "Set up Python" 23 | uses: actions/setup-python@v4.7.1 24 | with: 25 | python-version: "3.13" 26 | cache: "pip" 27 | 28 | - name: "Install requirements" 29 | run: python3 -m pip install -r requirements.txt 30 | 31 | - name: "Run" 32 | run: python3 -m ruff check . 33 | -------------------------------------------------------------------------------- /custom_components/fuel_prices/repairs.py: -------------------------------------------------------------------------------- 1 | """Implementations for repairs.""" 2 | 3 | from homeassistant.core import HomeAssistant 4 | from homeassistant.config_entries import ConfigEntry 5 | from homeassistant.helpers import issue_registry as ir 6 | 7 | from .const import DOMAIN 8 | 9 | def raise_feature_deprecation(hass: HomeAssistant, config_entry: ConfigEntry, feature_key: str, break_version: str): 10 | """Raise a feature deprecation warning.""" 11 | ir.async_create_issue( 12 | hass, 13 | domain=DOMAIN, 14 | issue_id=f"deprecate_{feature_key}", 15 | is_fixable=False, 16 | severity=ir.IssueSeverity.WARNING, 17 | translation_key=f"deprecate_{feature_key}", 18 | breaks_in_ha_version=break_version, 19 | learn_more_url="https://github.com/pantherale0/ha-fuelprices/issues/47" 20 | ) 21 | -------------------------------------------------------------------------------- /docs/services/find_fuel_station.md: -------------------------------------------------------------------------------- 1 | # Find fuel stations from locations `find_fuel_station` 2 | 3 | **Name:** Find fuel stations from location 4 | 5 | **Description:** Find all of the available fuel stations, alongside available fuels and cost for a given location. The results are *not* sorted. 6 | 7 | **Fields:** 8 | 9 | | Field | Description | Required | Selector Type | 10 | |------------|---------------------------------|----------|---------------| 11 | | `location` | The location of the area to search. | Yes | Location (with radius) | 12 | 13 | **Example:** 14 | 15 | ```yaml 16 | service: fuel_prices.find_fuel_station 17 | data: 18 | location: 19 | latitude: 52.520008 20 | longitude: 13.404954 21 | radius: 5 22 | ``` 23 | 24 | This example would find fuel stations within a 5 mile radius of the provided coordinates. 25 | -------------------------------------------------------------------------------- /docs/services/find_fuels.md: -------------------------------------------------------------------------------- 1 | # Find fuels from location `find_fuels` 2 | 3 | **Name:** Find fuel prices from location 4 | 5 | **Description:** This service retrieves all fuel prices for a given location, sorted by the cheapest first. 6 | 7 | **Fields:** 8 | 9 | | Field | Description | Required | Selector Type | 10 | |------------|-------------------------------------------------|----------|---------------| 11 | | `location` | The location of the area to search. | Yes | Location (with radius) | 12 | | `type` | The fuel type to search for (such as E5, E10, B7, SDV). | Yes | Text (single line) | 13 | 14 | **Example:** 15 | 16 | ```yaml 17 | service: fuel_prices.find_fuels 18 | data: 19 | location: 20 | latitude: 52.520008 21 | longitude: 13.404954 22 | radius: 10 23 | type: E10 24 | ``` 25 | 26 | This example would find prices for E10 fuel within a 10-mile radius of the given latitude and longitude. 27 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - "**" 9 | - "!docs/**" 10 | - "!.github/**" 11 | pull_request: 12 | types: [opened, reopened, synchronize] 13 | 14 | permissions: 15 | contents: read 16 | 17 | jobs: 18 | update_release_draft: 19 | permissions: 20 | contents: write 21 | pull-requests: write 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: Generate CalVer version 25 | id: calver 26 | run: | 27 | export CALVER=$(date "+%Y.%-m") 28 | echo "version=${CALVER}" >> $GITHUB_OUTPUT 29 | echo "Version set to ${CALVER}" 30 | - uses: release-drafter/release-drafter@v6.0.0 31 | with: 32 | name: ${{ steps.calver.outputs.version }} 33 | tag: ${{ steps.calver.outputs.version }} 34 | version: ${{ steps.calver.outputs.version }} 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: "Validate" 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "0 0 * * *" 7 | push: 8 | branches: 9 | - "main" 10 | paths-ignore: 11 | - "docs/**" 12 | - ".github/**" 13 | pull_request: 14 | branches: 15 | - "main" 16 | 17 | jobs: 18 | hassfest: # https://developers.home-assistant.io/blog/2020/04/16/hassfest 19 | name: "Hassfest Validation" 20 | runs-on: "ubuntu-latest" 21 | steps: 22 | - name: "Checkout the repository" 23 | uses: "actions/checkout@v4.1.0" 24 | 25 | - name: "Run hassfest validation" 26 | uses: "home-assistant/actions/hassfest@master" 27 | 28 | hacs: # https://github.com/hacs/action 29 | name: "HACS Validation" 30 | runs-on: "ubuntu-latest" 31 | steps: 32 | - name: "Checkout the repository" 33 | uses: "actions/checkout@v4.1.0" 34 | 35 | - name: "Run HACS validation" 36 | uses: "hacs/action@main" 37 | with: 38 | category: "integration" 39 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: "Release" 2 | 3 | on: 4 | release: 5 | types: 6 | - "published" 7 | 8 | permissions: {} 9 | 10 | jobs: 11 | release: 12 | name: "Release" 13 | runs-on: "ubuntu-latest" 14 | permissions: 15 | contents: write 16 | steps: 17 | - name: "Checkout the repository" 18 | uses: "actions/checkout@v4.1.0" 19 | 20 | - name: "Adjust version number" 21 | shell: "bash" 22 | run: | 23 | yq -i -o json '.version="${{ github.event.release.tag_name }}"' \ 24 | "${{ github.workspace }}/custom_components/fuel_prices/manifest.json" 25 | 26 | - name: "ZIP the integration directory" 27 | shell: "bash" 28 | run: | 29 | cd "${{ github.workspace }}/custom_components/fuel_prices" 30 | zip fuel_prices.zip -r ./ 31 | 32 | - name: "Upload the ZIP file to the release" 33 | uses: softprops/action-gh-release@v0.1.15 34 | with: 35 | files: ${{ github.workspace }}/custom_components/fuel_prices/fuel_prices.zip 36 | -------------------------------------------------------------------------------- /.github/workflows/documentation.yml: -------------------------------------------------------------------------------- 1 | name: documentation 2 | on: 3 | push: 4 | branches: 5 | - main 6 | paths: 7 | - "docs/**" 8 | - "mkdocs.yml" 9 | - ".github/workflows/**" 10 | permissions: 11 | contents: write 12 | jobs: 13 | deploy: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Configure Git Credentials 18 | run: | 19 | git config user.name github-actions[bot] 20 | git config user.email 41898282+github-actions[bot]@users.noreply.github.com 21 | - uses: actions/setup-python@v5 22 | with: 23 | python-version: 3.x 24 | - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV 25 | - uses: actions/cache@v4 26 | with: 27 | key: mkdocs-material-${{ env.cache_id }} 28 | path: .cache 29 | restore-keys: | 30 | mkdocs-material- 31 | - name: "Install requirements" 32 | run: python3 -m pip install -r "${{ github.workspace }}/requirements.docs.txt" 33 | - run: mkdocs gh-deploy --force 34 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | include-pre-releases: true 2 | categories: 3 | - title: Breaking Changes 🛠 4 | labels: 5 | - breaking-change 6 | - title: 'New Features 🎉' 7 | labels: 8 | - 'feature' 9 | - 'enhancement' 10 | - title: 'Bug Fixes 🛠' 11 | labels: 12 | - 'fix' 13 | - 'bugfix' 14 | - 'bug' 15 | - title: 'Documentation' 16 | labels: 17 | - 'docs' 18 | - title: '👒 Dependencies and extras' 19 | collapse-after: 3 20 | labels: 21 | - 'chore' 22 | - 'dependencies' 23 | exclude-labels: 24 | - 'ignore-for-release' 25 | change-template: '- $TITLE @$AUTHOR (#$NUMBER)' 26 | change-title-escapes: '\<*_&' 27 | autolabeler: 28 | - label: 'docs' 29 | branch: 30 | - '/docs{0,1}\/.+/' 31 | - label: 'chore' 32 | files: 33 | - '*.md' 34 | - '*.yml' 35 | branch: 36 | - '/docs{0,1}\/.+/' 37 | - '/chore\/.+/' 38 | - label: 'bug' 39 | branch: 40 | - '/fix\/.+/' 41 | - label: 'enhancement' 42 | branch: 43 | - '/feature\/.+/' 44 | template: | 45 | ## Changes 46 | 47 | $CHANGES -------------------------------------------------------------------------------- /docs/data_sources/new_zealand/finelly.md: -------------------------------------------------------------------------------- 1 | # Finelly Provider Set Up 2 | 3 | ## Background 4 | 5 | Finelly integration requires a user ID in order to provide fuel prices. 6 | 7 | ## Retrieving Your User ID 8 | 9 | 1. **Install the Finelly app** from the [App Store](https://apps.apple.com/nz/app/finelly/id6478907591) or [Google Play Store](https://play.google.com/store/apps/details?id=com.finelly.finelly&hl=en), if you have not already. 10 | 11 | 1. **Open the app**. 12 | 13 | 1. **Sign in / Sign up** to the app in the most convenient way for you: using Google, Apple accounts, or email and password. 14 | \ 15 | ![Finelly home screen](images/sign_in_page.jpg) 16 | 1. **Now you are on the home screen**. 17 | 1. **Tap on the Profile menu item** in the bottom navigation bar. 18 | \ 19 | ![Finelly home screen](images/home_page.jpg) 20 | 1. **Select Account Data**. 21 | \ 22 | ![Finelly profile screen](images/profile_page.jpg) 23 | 24 | 1. **Long press on your User ID** and copy it. 25 | \ 26 | ![Finelly account screen](images/account_details_page.jpg) 27 | 28 | 1. **Paste your User ID** during the integration set up. 29 | 30 | You are done! 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 - 2023 Joakim Sørensen @ludeeus 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /.devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ludeeus/integration_blueprint", 3 | "image": "mcr.microsoft.com/devcontainers/python:1-3.13", 4 | "postCreateCommand": "scripts/setup", 5 | "forwardPorts": [8123, 5678], 6 | "portsAttributes": { 7 | "8123": { 8 | "label": "Home Assistant", 9 | "onAutoForward": "notify" 10 | }, 11 | "5678": { 12 | "label": "Home Assistant Debug", 13 | "onAutoForward": "silent" 14 | } 15 | }, 16 | "customizations": { 17 | "vscode": { 18 | "extensions": [ 19 | "ms-python.python", 20 | "github.vscode-pull-request-github", 21 | "ryanluker.vscode-coverage-gutters", 22 | "ms-python.vscode-pylance" 23 | ], 24 | "settings": { 25 | "files.eol": "\n", 26 | "editor.tabSize": 4, 27 | "python.pythonPath": "/usr/bin/python3", 28 | "python.analysis.autoSearchPaths": false, 29 | "python.linting.pylintEnabled": true, 30 | "python.linting.enabled": true, 31 | "python.formatting.provider": "black", 32 | "python.formatting.blackPath": "/usr/local/py-utils/bin/black", 33 | "editor.formatOnPaste": false, 34 | "editor.formatOnSave": true, 35 | "editor.formatOnType": true, 36 | "files.trimTrailingWhitespace": true 37 | } 38 | } 39 | }, 40 | "remoteUser": "vscode", 41 | "features": { 42 | "ghcr.io/devcontainers/features/rust:1": {} 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Feature request" 3 | description: "Suggest an idea for this project" 4 | labels: "Feature+Request" 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: Before you open a new feature request, search through the existing feature requests to see if others have had the same idea. 9 | - type: checkboxes 10 | attributes: 11 | label: Checklist 12 | options: 13 | - label: I have filled out the template to the best of my ability. 14 | required: true 15 | - label: This only contains 1 feature request (if you have multiple feature requests, open one feature request for each feature request). 16 | required: true 17 | - label: This issue is not a duplicate feature request of [previous feature requests](https://github.com/pantherale0/ha-fuelprices/issues?q=is%3Aissue+label%3A%22Feature+Request%22+). 18 | required: true 19 | 20 | - type: textarea 21 | attributes: 22 | label: "Is your feature request related to a problem? Please describe." 23 | description: "A clear and concise description of what the problem is." 24 | placeholder: "I'm always frustrated when [...]" 25 | validations: 26 | required: true 27 | 28 | - type: textarea 29 | attributes: 30 | label: "Describe the solution you'd like" 31 | description: "A clear and concise description of what you want to happen." 32 | validations: 33 | required: true 34 | 35 | - type: textarea 36 | attributes: 37 | label: "Describe alternatives you've considered" 38 | description: "A clear and concise description of any alternative solutions or features you've considered." 39 | validations: 40 | required: true 41 | 42 | - type: textarea 43 | attributes: 44 | label: "Additional context" 45 | description: "Add any other context or screenshots about the feature request here." 46 | validations: 47 | required: true 48 | -------------------------------------------------------------------------------- /custom_components/fuel_prices/coordinator.py: -------------------------------------------------------------------------------- 1 | """Fuel Prices data hub.""" 2 | 3 | import logging 4 | from datetime import timedelta 5 | 6 | import async_timeout 7 | 8 | from homeassistant.core import HomeAssistant 9 | from pyfuelprices import FuelPrices, UpdateExceptionGroup 10 | from pyfuelprices.sources import UpdateFailedError 11 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed 12 | 13 | _LOGGER = logging.getLogger(__name__) 14 | 15 | 16 | class FuelPricesCoordinator(DataUpdateCoordinator): 17 | """Fuel Prices data coordinator.""" 18 | 19 | def __init__(self, hass: HomeAssistant, api: FuelPrices, name: str) -> None: 20 | """Init the coordinator.""" 21 | super().__init__( 22 | hass=hass, 23 | logger=_LOGGER, 24 | name=name, 25 | update_interval=timedelta(minutes=30), 26 | ) 27 | self.api: FuelPrices = api 28 | 29 | async def _async_update_data(self): 30 | """Fetch and update data from the API.""" 31 | try: 32 | async with async_timeout.timeout(240): 33 | return await self.api.update() 34 | except TimeoutError as err: 35 | _LOGGER.exception( 36 | "Timeout updating fuel price data, will retry later: %s", err) 37 | except TypeError as err: 38 | _LOGGER.exception( 39 | "Error updating fuel price data, will retry later: %s", err) 40 | except UpdateFailedError as err: 41 | _LOGGER.exception( 42 | "Error communicating with a service %s", err.status, exc_info=err) 43 | except UpdateExceptionGroup as err: 44 | for e, v in err.failed_providers.items(): 45 | _LOGGER.exception( 46 | "Error communicating with service %s - %s", e, v, exc_info=v 47 | ) 48 | except Exception as err: 49 | raise UpdateFailed(f"Error communicating with API {err}") from err 50 | -------------------------------------------------------------------------------- /.ruff.toml: -------------------------------------------------------------------------------- 1 | # The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml 2 | 3 | target-version = "py310" 4 | 5 | select = [ 6 | "B007", # Loop control variable {name} not used within loop body 7 | "B014", # Exception handler with duplicate exception 8 | "C", # complexity 9 | "D", # docstrings 10 | "E", # pycodestyle 11 | "F", # pyflakes/autoflake 12 | "ICN001", # import concentions; {name} should be imported as {asname} 13 | "PGH004", # Use specific rule codes when using noqa 14 | "PLC0414", # Useless import alias. Import alias does not rename original package. 15 | "SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass 16 | "SIM117", # Merge with-statements that use the same scope 17 | "SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys() 18 | "SIM201", # Use {left} != {right} instead of not {left} == {right} 19 | "SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a} 20 | "SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'. 21 | "SIM401", # Use get from dict with default instead of an if block 22 | "T20", # flake8-print 23 | "TRY004", # Prefer TypeError exception for invalid type 24 | "RUF006", # Store a reference to the return value of asyncio.create_task 25 | "UP", # pyupgrade 26 | "W", # pycodestyle 27 | ] 28 | 29 | ignore = [ 30 | "D202", # No blank lines allowed after function docstring 31 | "D203", # 1 blank line required before class docstring 32 | "D213", # Multi-line docstring summary should start at the second line 33 | "D404", # First word of the docstring should not be This 34 | "D406", # Section name should end with a newline 35 | "D407", # Section name underlining 36 | "D411", # Missing blank line before section 37 | "E501", # line too long 38 | "E731", # do not assign a lambda expression, use a def 39 | ] 40 | 41 | [flake8-pytest-style] 42 | fixture-parentheses = false 43 | 44 | [pyupgrade] 45 | keep-runtime-typing = true 46 | 47 | [mccabe] 48 | max-complexity = 25 -------------------------------------------------------------------------------- /custom_components/fuel_prices/entity.py: -------------------------------------------------------------------------------- 1 | """Fuel Price entity base type.""" 2 | 3 | from __future__ import annotations 4 | 5 | from homeassistant.helpers.entity import Entity 6 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 7 | from homeassistant.config_entries import ConfigEntry 8 | 9 | from .coordinator import FuelPricesCoordinator 10 | 11 | 12 | class FuelPriceEntity: 13 | """Top level entity type.""" 14 | 15 | config: ConfigEntry 16 | 17 | 18 | class FuelStationEntity(FuelPriceEntity, CoordinatorEntity): 19 | """Represents a fuel station.""" 20 | 21 | def __init__( 22 | self, coordinator: FuelPricesCoordinator, fuel_station_id, entity_id, source, area, state_value, config: ConfigEntry 23 | ) -> None: 24 | """Initialize.""" 25 | self.config = config 26 | super().__init__(coordinator) 27 | self.coordinator: FuelPricesCoordinator = coordinator 28 | self._fuel_station_id = fuel_station_id 29 | self._entity_id = entity_id 30 | self._fuel_station_source = str(source).lower() 31 | self.area = area 32 | self.state_value = state_value 33 | 34 | @property 35 | def _fuel_station(self): 36 | """Return the fuel station.""" 37 | return self.coordinator.api.configured_sources[ 38 | self._fuel_station_source 39 | ].location_cache[self._fuel_station_id] 40 | 41 | @property 42 | def unique_id(self) -> str | None: 43 | """Return unique ID.""" 44 | return f"fuelprices_{self._fuel_station_id}_{self._entity_id}" 45 | 46 | 47 | class CheapestFuelEntity(FuelPriceEntity, Entity): 48 | """Represents a fuel.""" 49 | 50 | def __init__( 51 | self, coordinator: FuelPricesCoordinator, count: str, area: str, fuel: str, coords: tuple, radius: float, config: ConfigEntry): 52 | """Initialize.""" 53 | self.coordinator: FuelPricesCoordinator = coordinator 54 | self.config = config 55 | self._count = count 56 | self._area = area 57 | self._coords = coords 58 | self._radius = radius 59 | self._fuel = fuel 60 | 61 | @property 62 | def unique_id(self) -> str | None: 63 | """Return unique ID.""" 64 | return f"fuelprices_cheapest_{self._fuel}_{self._count}_{self._area}" 65 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Bug report" 3 | description: "Report a bug with the integration" 4 | labels: "Bug" 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: Before you open a new issue, search through the existing issues to see if others have had the same problem. 9 | - type: textarea 10 | attributes: 11 | label: "System Health details" 12 | description: "Paste the data from the System Health card in Home Assistant (https://www.home-assistant.io/more-info/system-health#github-issues)" 13 | validations: 14 | required: true 15 | - type: checkboxes 16 | attributes: 17 | label: Checklist 18 | options: 19 | - label: I have enabled debug logging for my installation. 20 | required: true 21 | - label: I have filled out the issue template to the best of my ability. 22 | required: true 23 | - label: This issue only contains 1 issue (if you have multiple issues, open one issue for each issue). 24 | required: true 25 | - label: This issue is not a duplicate issue of any [previous issues](https://github.com/pantherale0/ha-fuelprices/issues?q=is%3Aissue+label%3A%22Bug%22+).. 26 | required: true 27 | - type: textarea 28 | attributes: 29 | label: "Describe the issue" 30 | description: "A clear and concise description of what the issue is." 31 | validations: 32 | required: true 33 | - type: textarea 34 | attributes: 35 | label: Reproduction steps 36 | description: "Without steps to reproduce, it will be hard to fix. It is very important that you fill out this part. Issues without it will be closed." 37 | value: | 38 | 1. 39 | 2. 40 | 3. 41 | ... 42 | validations: 43 | required: true 44 | - type: textarea 45 | attributes: 46 | label: "Debug logs" 47 | description: "To enable debug logs check this https://www.home-assistant.io/integrations/logger/, this **needs** to include _everything_ from startup of Home Assistant to the point where you encounter the issue." 48 | render: text 49 | validations: 50 | required: true 51 | 52 | - type: textarea 53 | attributes: 54 | label: "Diagnostics dump" 55 | description: "Drag the diagnostics dump file here. (see https://www.home-assistant.io/integrations/diagnostics/ for info)" 56 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution guidelines 2 | 3 | Contributing to this project should be as easy and transparent as possible, whether it's: 4 | 5 | - Reporting a bug 6 | - Discussing the current state of the code 7 | - Submitting a fix 8 | - Proposing new features 9 | 10 | ## Github is used for everything 11 | 12 | Github is used to host code, to track issues and feature requests, as well as accept pull requests. 13 | 14 | Pull requests are the best way to propose changes to the codebase. 15 | 16 | 1. Fork the repo and create your branch from `main`. 17 | 2. If you've changed something, update the documentation. 18 | 3. Make sure your code lints (using `scripts/lint`). 19 | 4. Test you contribution. 20 | 5. Issue that pull request! 21 | 22 | ## Any contributions you make will be under the MIT Software License 23 | 24 | In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. 25 | 26 | ## Report bugs using Github's [issues](../../issues) 27 | 28 | GitHub issues are used to track public bugs. 29 | Report a bug by [opening a new issue](../../issues/new/choose); it's that easy! 30 | 31 | ## Write bug reports with detail, background, and sample code 32 | 33 | **Great Bug Reports** tend to have: 34 | 35 | - A quick summary and/or background 36 | - Steps to reproduce 37 | - Be specific! 38 | - Give sample code if you can. 39 | - What you expected would happen 40 | - What actually happens 41 | - Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) 42 | 43 | People *love* thorough bug reports. I'm not even kidding. 44 | 45 | ## Use a Consistent Coding Style 46 | 47 | Use [black](https://github.com/ambv/black) to make sure the code follows the style. 48 | 49 | ## Test your code modification 50 | 51 | This custom component is based on [integration_blueprint template](https://github.com/ludeeus/integration_blueprint). 52 | 53 | It comes with development environment in a container, easy to launch 54 | if you use Visual Studio Code. With this container you will have a stand alone 55 | Home Assistant instance running and already configured with the included 56 | [`configuration.yaml`](./config/configuration.yaml) 57 | file. 58 | 59 | ## License 60 | 61 | By contributing, you agree that your contributions will be licensed under its MIT License. 62 | -------------------------------------------------------------------------------- /docs/template_examples/cheapest_n_stations_device_tracker.md: -------------------------------------------------------------------------------- 1 | # Cheapest Fuel Price Sensor based on a device tracker 2 | 3 | This YAML configuration creates a sensor in Home Assistant that displays the price of diesel fuel (B7, this will vary depending on country / data source) at the nearest fuel stations to a given device tracker. It uses the `fuel_prices.find_fuels` action to retrieve this data. 4 | 5 | The template sensors will update every time HA has started, or whenever your device tracker state changes. 6 | 7 | Radius is defined in meters, so you can use whatever unit you would like (miles, km, etc) as long as it is converted to meters. 8 | 9 | Change `sensor.device_tracker_here` to another device tracker. 10 | 11 | ## Configuration 12 | 13 | This configuration should be placed within your `configuration.yaml` file under the `template` section. 14 | 15 | ```yaml 16 | - trigger: 17 | - platform: state 18 | entity_id: 19 | - sensor.device_tracker_here 20 | attribute: longitude 21 | - platform: state 22 | entity_id: 23 | - sensor.device_tracker_here 24 | attribute: latitude 25 | - platform: homeassistant 26 | event: start 27 | action: 28 | - variables: 29 | fuel_type: B7 30 | lat: | 31 | {{ state_attr('sensor.device_tracker_here', 'latitude') | float }} 32 | long: | 33 | {{ state_attr('sensor.device_tracker_here', 'longitude') | float }} 34 | radius: | 35 | {{ 5 * 1609.34 }} # 5 miles converted to meters 36 | - service: fuel_prices.find_fuels 37 | data: 38 | location: 39 | latitude: | 40 | {{ lat }} 41 | longitude: | 42 | {{ long }} 43 | radius: | 44 | {{ radius }} 45 | type: | 46 | {{ fuel_type }} 47 | response_variable: data 48 | sensor: 49 | - name: Device Tracker Cheapest Fuel Station 50 | unique_id: Device Tracker Cheapest Fuel Station 51 | state: | 52 | {{ data['fuels'][0].available_fuels[fuel_type] | float }} 53 | availability: | 54 | {{ data['fuels'] | count > 0 }} 55 | state_class: total 56 | device_class: monetary 57 | unit_of_measurement: | 58 | {{ data['fuels'][0].currency }} 59 | attributes: 60 | other_stations: | 61 | {{ data }} 62 | latitude: | 63 | {{ data['fuels'][0].latitude }} 64 | longitude: | 65 | {{ data['fuels'][0].longitude }} 66 | name: | 67 | {{ data['fuels'][0].name }} 68 | station: | 69 | {{ data['fuels'][0] }} 70 | ``` 71 | -------------------------------------------------------------------------------- /docs/template_examples/cheapest_n_stations.md: -------------------------------------------------------------------------------- 1 | # Cheapest Fuel Price Sensor 2 | 3 | This YAML configuration creates a set of sensors in Home Assistant that display the price of diesel fuel (B7, this will vary depending on country / data source) at the nearest fuel stations to your home. It uses the `fuel_prices.find_fuels` service to retrieve this data and the defined `zone.home` to get the search co-oridinates. 4 | 5 | The template sensors will update every time HA has started, or every 12 hours. 6 | 7 | Radius is defined in meters, so you can use whatever unit you would like (miles, km, etc) as long as it is converted to meters. 8 | 9 | Change `zone.home` to another zone (or device tracker). 10 | 11 | ## Configuration 12 | 13 | This configuration should be placed within your `configuration.yaml` file under the `template` section. 14 | 15 | ```yaml 16 | - trigger: 17 | - platform: homeassistant 18 | event: start 19 | - platform: time_pattern 20 | hours: /12 21 | action: 22 | - variables: 23 | fuel_type: B7 24 | lat: | 25 | {{ state_attr('zone.home', 'latitude') }} 26 | long: | 27 | {{ state_attr('zone.home', 'longitude') }} 28 | radius: | 29 | {{ 5 * 1609.34 }} # 5 miles converted to meters 30 | - service: fuel_prices.find_fuels 31 | data: 32 | location: 33 | latitude: | 34 | {{ lat }} 35 | longitude: | 36 | {{ long }} 37 | radius: | 38 | {{ radius }} 39 | type: | 40 | {{ fuel_type }} 41 | response_variable: data 42 | sensor: 43 | - name: Home Nearest Fuel Station 1 44 | unique_id: Home Nearest Fuel Station 1 45 | state: | 46 | {{ data['fuels'][0].available_fuels[fuel_type] | float }} 47 | availability: | 48 | {{ data['fuels'] | count > 0 }} 49 | state_class: total 50 | device_class: monetary 51 | unit_of_measurement: | 52 | {{ data['fuels'][0].currency }} 53 | attributes: 54 | latitude: | 55 | {{ data['fuels'][0].latitude }} 56 | longitude: | 57 | {{ data['fuels'][0].longitude }} 58 | name: | 59 | {{ data['fuels'][0].name }} 60 | station: | 61 | {{ data['fuels'][0] }} 62 | - name: Home Nearest Fuel Station 2 63 | unique_id: Home Nearest Fuel Station 2 64 | state: | 65 | {{ data['fuels'][1].available_fuels[fuel_type] | float }} 66 | availability: | 67 | {{ data['fuels'] | count > 1 }} 68 | state_class: total 69 | device_class: monetary 70 | unit_of_measurement: | 71 | {{ data['fuels'][0].currency }} 72 | attributes: 73 | latitude: | 74 | {{ data['fuels'][1].latitude }} 75 | longitude: | 76 | {{ data['fuels'][1].longitude }} 77 | name: | 78 | {{ data['fuels'][1].name }} 79 | station: | 80 | {{ data['fuels'][1] }} 81 | - name: Home Nearest Fuel Station 3 82 | unique_id: Home Nearest Fuel Station 3 83 | state: | 84 | {{ data['fuels'][2].available_fuels[fuel_type] | float }} 85 | availability: | 86 | {{ data['fuels'] | count > 2 }} 87 | state_class: total 88 | device_class: monetary 89 | unit_of_measurement: | 90 | {{ data['fuels'][0].currency }} 91 | attributes: 92 | latitude: | 93 | {{ data['fuels'][2].latitude }} 94 | longitude: | 95 | {{ data['fuels'][2].longitude }} 96 | name: | 97 | {{ data['fuels'][2].name }} 98 | station: | 99 | {{ data['fuels'][2] }} 100 | ``` 101 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | [![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=pantherale0&category=integration&repository=ha-fuelprices) 4 | 5 | 1. Add the repository to HACS 6 | 1. Install integration 7 | 1. Follow prompts to configure integration 8 | 9 | ## Configuration Parameters 10 | 11 | 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 12 | 13 | ### Area Configuration Options 14 | 15 | | Option | Description | Type | Default | 16 | |-----------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------|---------| 17 | | `name` | (Required) The name of the area. | Text | None | 18 | | `radius` | (Required) The radius of the area in miles. | Number (miles) | 5.0 | 19 | | `latitude` | (Required, with `longitude`) The latitude of the center of the area. Must be used with `longitude`. | Latitude | None | 20 | | `longitude` | (Required, with `latitude`) The longitude of the center of the area. Must be used with `latitude`. | Longitude | None | 21 | | `cheapest_sensors` | (Optional) A boolean value to determine whether cheapest sensors should be created for this area. | Flag | False | 22 | | `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 | 23 | | `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 | "" | 24 | 25 | ### System Configuration Options 26 | 27 | | Option | Description | Type | Default | 28 | |---------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------|---------| 29 | | `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 | 30 | | `timeout` | (Optional) The timeout in seconds for requests to the data sources. | Number (Box, Unit: s, Min: 5, Max: 60) | 30 | 31 | | `scan_interval` | (Optional) The interval in minutes between updates of the fuel prices. | Number (Box, Unit: m, Min: 360, Max: 1440) | 1440 | 32 | | `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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fuel Prices 2 | 3 | [![GitHub Release][releases-shield]][releases] 4 | [![GitHub Activity][commits-shield]][commits] 5 | ![Install Stats][stats] 6 | [![License][license-shield]](LICENSE) 7 | 8 | ![Project Maintenance][maintenance-shield] 9 | [![BuyMeCoffee][buymecoffeebadge]][buymecoffee] 10 | 11 | [![Discord][discord-shield]][discord] 12 | [![Community Forum][forum-shield]][forum] 13 | 14 | _Integration to integrate with [pyfuelprices][pyfuelprices]._ 15 | 16 | **This integration will set up the following platforms.** 17 | 18 | | Platform | Description | 19 | | -------- | ---------------------------------------------------------------------------- | 20 | | `sensor` | Optional entities for fuel stations, attributes will contain fuel price data | 21 | 22 | ## Installation 23 | 24 | ### Manual 25 | 26 | 1. Download latest release 27 | 1. Using the tool of choice open the directory (folder) for your HA configuration (where you find `configuration.yaml`). 28 | 1. If you do not have a `custom_components` directory (folder) there, you need to create it. 29 | 1. Extract downloaded release into `custom_components` directory (folder) 30 | 1. Restart Home Assistant 31 | 1. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "Fuel Prices" 32 | 33 | ### HACS 34 | 35 | [![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=pantherale0&category=integration&repository=ha-fuelprices) 36 | 37 | 1. Open HACS on your HA instance. 38 | 1. Copy the repository URL: [https://github.com/pantherale0/ha-fuelprices](https://github.com/pantherale0/ha-fuelprices). 39 | 1. In the HACS menu (3 dots in the top right corner), choose "Custom repositories." 40 | 1. Paste the copied URL into the repository field. 41 | 1. Set the Type as "Integration." 42 | 1. Click "Add." 43 | 1. Restart Home Assistant. 44 | 1. In the HA UI, go to "Configuration" -> "Integrations," click "+," and search for "Fuel Prices." 45 | 46 | ## Privacy notice 47 | 48 | This integration relies entirely on cloud services, alongside this a few libraries are used to geocode provided coordinates into location data for certain providers such as GasBuddy or TankerKoenig. 49 | 50 | For reverse geocoding a mix of Nominatim (https://nominatim.org/) and these-united-states (https://pypi.org/project/these-united-states/). This is done to improve performance, for example, looking up provided coordinates with Nominatim will allow us to restrict the fuel station search to data providers available in only that country. 51 | 52 | Similar to this, this integration will use these-united-states to retrieve the state of given coordinates, and finally Nominatim is also used to retrieve the nearest postcode for the TankerKoenig data source. 53 | 54 | ## Configuration 55 | 56 | A new configuration parameter was introduced in 2024.6.0 that allows you to specify what state to display within Home Assistant. By default this is name, however it can be changed to a value of your liking after setup by clicking `Configure` followed by `Configure data collection sources`. This parameter is called `State to show on the created sensors`. 57 | 58 | This value must be set to a fuel price key (available under `Available Fuels` for the produced sensor entities). In the UK this can be reliably set to E5 or B7, however if you set to SDV, a large number of fuel stations either do not stock this or do not provide this data. In this case the integration will default back to the fuel station name but this may create warnings / errors in your logs. Currently this cannot be configured by area. 59 | 60 | ## Configuration is done in the UI 61 | 62 | 63 | 64 | ## Contributions are welcome! 65 | 66 | If you want to contribute to this please read the [Contribution guidelines](CONTRIBUTING.md) 67 | 68 | --- 69 | 70 | [pyfuelprices]: https://github.com/pantherale0/pyfuelprices 71 | [buymecoffee]: https://www.buymeacoffee.com/pantherale0 72 | [buymecoffeebadge]: https://img.shields.io/badge/buy%20me%20a%20coffee-donate-yellow.svg?style=for-the-badge 73 | [commits-shield]: https://img.shields.io/github/commit-activity/y/pantherale0/ha-fuelprices.svg?style=for-the-badge 74 | [stats]: https://img.shields.io/badge/dynamic/json?color=41BDF5&logo=home-assistant&label=integration%20usage&suffix=%20installs&cacheSeconds=15600&url=https://analytics.home-assistant.io/custom_integrations.json&query=$.fuel_prices.total&style=for-the-badge 75 | [commits]: https://github.com/pantherale0/ha-fuelprices/commits/main 76 | [discord]: https://discord.gg/Qa5fW2R 77 | [discord-shield]: https://img.shields.io/discord/330944238910963714.svg?style=for-the-badge 78 | [exampleimg]: example.png 79 | [forum-shield]: https://img.shields.io/badge/community-forum-brightgreen.svg?style=for-the-badge 80 | [forum]: https://community.home-assistant.io/ 81 | [license-shield]: https://img.shields.io/github/license/pantherale0/ha-fuelprices.svg?style=for-the-badge 82 | [maintenance-shield]: https://img.shields.io/badge/maintainer-%40pantherale0-blue.svg?style=for-the-badge 83 | [releases-shield]: https://img.shields.io/github/release/pantherale0/ha-fuelprices.svg?style=for-the-badge 84 | [releases]: https://github.com/pantherale0/ha-fuelprices/releases 85 | -------------------------------------------------------------------------------- /custom_components/fuel_prices/sensor.py: -------------------------------------------------------------------------------- 1 | """Sensor for fuel prices.""" 2 | 3 | from __future__ import annotations 4 | 5 | 6 | import logging 7 | 8 | from collections.abc import Mapping 9 | from typing import Any 10 | from datetime import datetime, timedelta 11 | 12 | from homeassistant.components.sensor import SensorEntity 13 | from homeassistant.components.sensor.const import SensorDeviceClass 14 | from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS, CONF_NAME, STATE_UNKNOWN, CONF_SCAN_INTERVAL 15 | from homeassistant.core import HomeAssistant 16 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 17 | from pyfuelprices.const import PROP_FUEL_LOCATION_SOURCE 18 | from . import FuelPricesConfigEntry 19 | from .const import CONF_STATE_VALUE, CONF_CHEAPEST_SENSORS, CONF_CHEAPEST_SENSORS_COUNT, CONF_CHEAPEST_SENSORS_FUEL_TYPE 20 | from .entity import FuelStationEntity, CheapestFuelEntity 21 | 22 | _LOGGER = logging.getLogger(__name__) 23 | 24 | SCAN_INTERVAL = timedelta(minutes=1) 25 | 26 | 27 | async def async_setup_entry( 28 | hass: HomeAssistant, entry: FuelPricesConfigEntry, async_add_entities: AddEntitiesCallback 29 | ) -> None: 30 | """Integration platform creation.""" 31 | entities = [] 32 | found_entities = [] 33 | state_value = entry.options.get( 34 | CONF_STATE_VALUE, entry.data.get(CONF_STATE_VALUE, "name") 35 | ) 36 | for area in entry.runtime_data.areas: 37 | _LOGGER.debug("Registering entities for area %s", area[CONF_NAME]) 38 | for station in await entry.runtime_data.coordinator.api.find_fuel_locations_from_point( 39 | coordinates=(area[CONF_LATITUDE], area[CONF_LONGITUDE]), 40 | radius=area[CONF_RADIUS], 41 | ): 42 | if station["id"] not in found_entities: 43 | entities.append( 44 | FuelStationTracker( 45 | coordinator=entry.runtime_data.coordinator, 46 | fuel_station_id=station["id"], 47 | entity_id="devicetracker", 48 | source=station["props"][PROP_FUEL_LOCATION_SOURCE], 49 | area=area[CONF_NAME], 50 | state_value=state_value, 51 | config=entry 52 | ) 53 | ) 54 | found_entities.append(station["id"]) 55 | if area.get(CONF_CHEAPEST_SENSORS, False) and area.get(CONF_CHEAPEST_SENSORS_FUEL_TYPE) is not None: 56 | _LOGGER.debug("Registering %s cheapest entities for area %s", 57 | area[CONF_CHEAPEST_SENSORS_COUNT], 58 | area[CONF_NAME]) 59 | for x in range(0, int(area[CONF_CHEAPEST_SENSORS_COUNT]), 1): 60 | entities.append(CheapestFuelSensor( 61 | coordinator=entry.runtime_data.coordinator, 62 | count=x+1, 63 | area=area[CONF_NAME], 64 | fuel=area[CONF_CHEAPEST_SENSORS_FUEL_TYPE], 65 | coords=(area[CONF_LATITUDE], area[CONF_LONGITUDE]), 66 | radius=area[CONF_RADIUS], 67 | config=entry 68 | )) 69 | async_add_entities(entities, True) 70 | 71 | 72 | class FuelStationTracker(FuelStationEntity, SensorEntity): 73 | """A fuel station entity.""" 74 | 75 | @property 76 | def native_value(self) -> str: 77 | """Return the native value of the entity.""" 78 | if self.state_value == "name": 79 | return self._fuel_station.name 80 | return self._get_fuels.get(self.state_value, self._fuel_station.name) 81 | 82 | @property 83 | def _get_fuels(self) -> dict: 84 | """Return list of fuels.""" 85 | output = {} 86 | for fuel in self._fuel_station.available_fuels: 87 | output[fuel.fuel_type] = fuel.cost 88 | return output 89 | 90 | @property 91 | def extra_state_attributes(self) -> Mapping[str, Any] | None: 92 | """Return extra state attributes.""" 93 | return { 94 | **self._fuel_station.__dict__, 95 | **self._get_fuels, 96 | "area": self.area 97 | } 98 | 99 | @property 100 | def icon(self) -> str: 101 | """Return entity icon.""" 102 | if self._fuel_station.brand == "Pod Point": 103 | return "mdi:battery-charging" 104 | return "mdi:gas-station" 105 | 106 | @property 107 | def name(self) -> str: 108 | """Return the name of the entity.""" 109 | return self._fuel_station.name 110 | 111 | @property 112 | def native_unit_of_measurement(self) -> str: 113 | """Return unit of measurement.""" 114 | if isinstance(self.native_value, str): 115 | return None 116 | return self._fuel_station.currency.upper() 117 | 118 | @property 119 | def state_class(self) -> str: 120 | """Return state type.""" 121 | if isinstance(self.native_value, str): 122 | return None 123 | return "total" 124 | 125 | @property 126 | def device_class(self) -> SensorDeviceClass | None: 127 | """Return device class.""" 128 | if isinstance(self.native_value, str): 129 | return None 130 | return SensorDeviceClass.MONETARY 131 | 132 | 133 | class CheapestFuelSensor(CheapestFuelEntity, SensorEntity): 134 | """A entity that shows the cheapest fuel for an area.""" 135 | 136 | _attr_force_update = True 137 | _attr_should_poll = True # we need to query the module for this data 138 | _last_update = None 139 | _next_update = datetime.now() 140 | _cached_data = None 141 | 142 | async def async_update(self) -> None: 143 | """Update device data.""" 144 | if (self._last_update is not None) and ( 145 | self._next_update > datetime.now() 146 | ): 147 | return True 148 | data = await self.coordinator.api.find_fuel_from_point( 149 | coordinates=self._coords, 150 | fuel_type=self._fuel, 151 | radius=self._radius 152 | ) 153 | if len(data) >= (int(self._count)-1): 154 | self._last_update = datetime.now() 155 | self._next_update = datetime.now() + timedelta(minutes=self.config.options.get( 156 | CONF_SCAN_INTERVAL, self.config.data.get( 157 | CONF_SCAN_INTERVAL, 1440) 158 | )) 159 | if len(data) >= self._count: 160 | self._cached_data = data[int(self._count)-1] 161 | else: 162 | self._cached_data = {} 163 | return True 164 | self._cached_data = None 165 | 166 | @property 167 | def native_value(self) -> str | float | int: 168 | """Return state of entity.""" 169 | if self._cached_data is not None: 170 | return self._cached_data.get("cost", STATE_UNKNOWN) 171 | return STATE_UNKNOWN 172 | 173 | @property 174 | def device_class(self) -> SensorDeviceClass | None: 175 | """Return device class.""" 176 | if isinstance(self.native_value, float) or isinstance(self.native_value, int): 177 | return SensorDeviceClass.MONETARY 178 | return None 179 | 180 | @property 181 | def name(self) -> str: 182 | """Name of the entity.""" 183 | return f"{self._area} cheapest {self._fuel} {self._count}" 184 | 185 | @property 186 | def native_unit_of_measurement(self) -> str: 187 | """Return unit of measurement.""" 188 | if isinstance(self.native_value, float) or isinstance(self.native_value, int): 189 | return self._cached_data["currency"] 190 | return None 191 | 192 | @property 193 | def state_class(self) -> str: 194 | """Return state type.""" 195 | if isinstance(self.native_value, float) or isinstance(self.native_value, int): 196 | return "total" 197 | return None 198 | 199 | @property 200 | def extra_state_attributes(self) -> Mapping[str, Any] | None: 201 | """Return extra state attributes.""" 202 | data = {} 203 | if self._cached_data is not None: 204 | data = self._cached_data 205 | data["area"] = self._area 206 | data["sensor_last_poll"] = self._last_update 207 | data["sensor_next_poll"] = self._next_update 208 | return data 209 | -------------------------------------------------------------------------------- /custom_components/fuel_prices/__init__.py: -------------------------------------------------------------------------------- 1 | """Fuel Prices integration.""" 2 | 3 | import logging 4 | 5 | from dataclasses import dataclass 6 | 7 | from pyfuelprices import FuelPrices 8 | 9 | from homeassistant.config_entries import ConfigEntry 10 | from homeassistant.const import ( 11 | Platform, 12 | CONF_TIMEOUT, 13 | CONF_SCAN_INTERVAL, 14 | CONF_NAME 15 | ) 16 | from homeassistant.core import ( 17 | HomeAssistant, 18 | ServiceCall, 19 | ServiceResponse, 20 | SupportsResponse, 21 | ) 22 | from homeassistant.exceptions import HomeAssistantError 23 | from homeassistant.helpers.aiohttp_client import async_create_clientsession 24 | 25 | from .const import ( 26 | DOMAIN, 27 | CONF_AREAS, 28 | CONF_SOURCES, 29 | CONF_CHEAPEST_SENSORS, 30 | CONF_CHEAPEST_SENSORS_COUNT, 31 | CONF_CHEAPEST_SENSORS_FUEL_TYPE 32 | ) 33 | from .coordinator import FuelPricesCoordinator 34 | from .repairs import raise_feature_deprecation 35 | 36 | _LOGGER = logging.getLogger(__name__) 37 | PLATFORMS = [Platform.SENSOR] 38 | 39 | 40 | @dataclass 41 | class FuelPricesConfig: 42 | """Represent a Fuel Price Config.""" 43 | 44 | coordinator: FuelPricesCoordinator 45 | areas: list[dict] 46 | config: ConfigEntry 47 | 48 | 49 | type FuelPricesConfigEntry = ConfigEntry[FuelPricesConfig] 50 | 51 | 52 | def _build_module_config(entry: FuelPricesConfigEntry) -> dict: 53 | """Build a given config entry into the config dict for the pyfuelprices module.""" 54 | sources = entry.options.get( 55 | CONF_SOURCES, entry.data.get(CONF_SOURCES, {})) 56 | areas = entry.options.get(CONF_AREAS, entry.data.get(CONF_AREAS, None)) 57 | timeout = entry.options.get(CONF_TIMEOUT, entry.data.get(CONF_TIMEOUT, 30)) 58 | update_interval = entry.options.get( 59 | CONF_SCAN_INTERVAL, entry.data.get(CONF_SCAN_INTERVAL, 1440) 60 | ) 61 | return { 62 | "areas": areas, 63 | "providers": sources, 64 | "timeout": timeout, 65 | "update_interval": update_interval 66 | } 67 | 68 | 69 | async def async_setup_entry(hass: HomeAssistant, entry: FuelPricesConfigEntry) -> bool: 70 | """Create ConfigEntry.""" 71 | 72 | default_lat = hass.config.latitude 73 | default_long = hass.config.longitude 74 | mod_config = _build_module_config(entry) 75 | for area in mod_config["areas"]: 76 | if area.get(CONF_CHEAPEST_SENSORS, False) and area.get(CONF_CHEAPEST_SENSORS_FUEL_TYPE) is not None: 77 | raise_feature_deprecation( 78 | hass, 79 | entry, 80 | CONF_CHEAPEST_SENSORS, 81 | "2025.11.0" 82 | ) 83 | break 84 | try: 85 | fuel_prices: FuelPrices = FuelPrices.create( 86 | client_session=async_create_clientsession(hass), 87 | configuration=mod_config 88 | ) 89 | except Exception as err: 90 | _LOGGER.error(err) 91 | raise CannotConnect from err 92 | 93 | coordinator = FuelPricesCoordinator( 94 | hass=hass, api=fuel_prices, name=entry.entry_id) 95 | await coordinator.async_config_entry_first_refresh() 96 | 97 | async def handle_fuel_lookup(call: ServiceCall) -> ServiceResponse: 98 | """Handle a fuel lookup call.""" 99 | radius = call.data.get("location", {}).get( 100 | "radius", 8046.72 101 | ) # this is in meters 102 | radius = radius / 1609 103 | lat = call.data.get("location", {}).get("latitude", default_lat) 104 | long = call.data.get("location", {}).get("longitude", default_long) 105 | fuel_type = call.data.get("type") 106 | source = call.data.get("source", "") 107 | try: 108 | return { 109 | "fuels": await fuel_prices.find_fuel_from_point( 110 | (lat, long), radius, fuel_type, source 111 | ) 112 | } 113 | except ValueError as err: 114 | raise HomeAssistantError( 115 | "Country not available for fuel data.") from err 116 | 117 | async def handle_fuel_location_lookup(call: ServiceCall) -> ServiceResponse: 118 | """Handle a fuel location lookup call.""" 119 | radius = call.data.get("location", {}).get( 120 | "radius", 8046.72 121 | ) # this is in meters 122 | radius = radius / 1609 123 | lat = call.data.get("location", {}).get("latitude", default_lat) 124 | long = call.data.get("location", {}).get("longitude", default_long) 125 | source = call.data.get("source", "") 126 | try: 127 | locations = await fuel_prices.find_fuel_locations_from_point( 128 | (lat, long), radius, source 129 | ) 130 | except ValueError as err: 131 | raise HomeAssistantError( 132 | "Country not available for fuel data.") from err 133 | 134 | return {"items": locations, "sources": entry.data.get("sources", [])} 135 | 136 | async def handle_force_update(call: ServiceCall): 137 | """Handle a request to force update.""" 138 | await fuel_prices.update(force=True) 139 | 140 | hass.services.async_register( 141 | DOMAIN, 142 | "find_fuel_station", 143 | handle_fuel_location_lookup, 144 | supports_response=SupportsResponse.ONLY, 145 | ) 146 | 147 | hass.services.async_register( 148 | DOMAIN, 149 | "find_fuels", 150 | handle_fuel_lookup, 151 | supports_response=SupportsResponse.ONLY, 152 | ) 153 | 154 | hass.services.async_register(DOMAIN, "force_update", handle_force_update) 155 | 156 | entry.runtime_data = FuelPricesConfig( 157 | coordinator=coordinator, areas=mod_config[CONF_AREAS], config=entry) 158 | 159 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 160 | 161 | async def update_listener(hass: HomeAssistant, entry: FuelPricesConfigEntry): 162 | """Update listener.""" 163 | await hass.config_entries.async_reload(entry.entry_id) 164 | 165 | entry.add_update_listener(update_listener) 166 | 167 | return True 168 | 169 | 170 | async def async_unload_entry(hass: HomeAssistant, entry: FuelPricesConfigEntry) -> bool: 171 | """Unload a config entry.""" 172 | _LOGGER.debug("Unloading config entry %s", entry.entry_id) 173 | unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 174 | return unload_ok 175 | 176 | 177 | async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry): 178 | """Migrate old entry.""" 179 | _LOGGER.debug("Migrating configuration from version %s", 180 | config_entry.version) 181 | 182 | new_data = {**config_entry.data} 183 | if config_entry.options: 184 | new_data = {**config_entry.options} 185 | 186 | if config_entry.version > 4: 187 | # This means the user has downgraded from a future version 188 | return False 189 | 190 | if config_entry.version == 3: 191 | _LOGGER.warning("Updating configuration for fuel prices.") 192 | sources = new_data[CONF_SOURCES] 193 | providers = {} 194 | for source in sources: 195 | providers[source] = {} 196 | new_data[CONF_SOURCES] = providers 197 | new_data[CONF_SCAN_INTERVAL] = new_data[CONF_SCAN_INTERVAL]/60 198 | hass.config_entries.async_update_entry( 199 | config_entry, data=new_data, version=4, options=new_data 200 | ) 201 | 202 | if config_entry.version == 2: 203 | _LOGGER.warning("Removing morrisons from config entry.") 204 | if "morrisons" in new_data[CONF_SOURCES]: 205 | new_data[CONF_SOURCES].remove("morrisons") 206 | hass.config_entries.async_update_entry( 207 | config_entry, data=new_data, version=3 208 | ) 209 | 210 | if config_entry.version == 1: 211 | for area in new_data[CONF_AREAS]: 212 | _LOGGER.debug("Upgrading area definition for %s", area[CONF_NAME]) 213 | area[CONF_CHEAPEST_SENSORS] = False 214 | area[CONF_CHEAPEST_SENSORS_COUNT] = 5 215 | area[CONF_CHEAPEST_SENSORS_FUEL_TYPE] = "" 216 | hass.config_entries.async_update_entry( 217 | config_entry, data=new_data, version=2) 218 | _LOGGER.info("Migration to configuration version %s successful", 219 | config_entry.version) 220 | 221 | return True 222 | 223 | 224 | class CannotConnect(HomeAssistantError): 225 | """Error to indicate we cannot connect.""" 226 | -------------------------------------------------------------------------------- /custom_components/fuel_prices/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "For performance considerations and memory efficiency, only one instance of this integration is allowed." 5 | }, 6 | "step": { 7 | "area_create": { 8 | "data": { 9 | "cheapest_stations": "Register entities for top n cheapest fuel stations in this area", 10 | "cheapest_stations_count": "Number of cheapest fuel station entities to create", 11 | "cheapest_stations_fuel_type": "The fuel type to use for the cheapest fuel station entites", 12 | "latitude": "Latitude for the center of the search location", 13 | "longitude": "Longitude for the center of the search location", 14 | "name": "Area name (must be unique)", 15 | "radius": "Maximum search radius" 16 | }, 17 | "description": "Using this menu you can create areas to register devices and sensors. This integration will create a device for each fuel station discovered, under this a sensor will be created for each fuel type.", 18 | "title": "Create an area" 19 | }, 20 | "area_delete": { 21 | "data": { 22 | "name": "Area name" 23 | }, 24 | "title": "Select area to delete" 25 | }, 26 | "area_menu": { 27 | "description": "By default your home location has already been added automatically with a radius of 20 miles, this can be removed or changed if needed.", 28 | "menu_options": { 29 | "area_create": "Create an area", 30 | "area_delete": "Delete an area", 31 | "area_update_select": "Update an area", 32 | "main_menu": "Return to main menu" 33 | }, 34 | "title": "Configure areas to register devices and sensors." 35 | }, 36 | "area_update": { 37 | "data": { 38 | "cheapest_stations": "(Deprecated) Register entities for top n cheapest fuel stations in this area", 39 | "cheapest_stations_count": "(Deprecated) Number of cheapest fuel station entities to create", 40 | "cheapest_stations_fuel_type": "(Deprecated) The fuel type to use for the cheapest fuel station entites", 41 | "latitude": "Latitude for the center of the search location", 42 | "longitude": "Longitude for the center of the search location", 43 | "name": "Area name (must be unique)", 44 | "radius": "Maximum search radius" 45 | }, 46 | "description": "Using this menu you can create areas to register devices and sensors. This integration will create a device for each fuel station discovered, under this a sensor will be created for each fuel type.", 47 | "title": "Create an area" 48 | }, 49 | "area_update_select": { 50 | "data": { 51 | "name": "Area name" 52 | }, 53 | "title": "Select area to update" 54 | }, 55 | "finished": { 56 | "description": "Click submit to finish setup", 57 | "title": "Fuel Prices" 58 | }, 59 | "sources": { 60 | "data": { 61 | "scan_interval": "Data source update interval", 62 | "sources": "Data source(s)", 63 | "state": "State to show on the created sensors", 64 | "timeout": "Data source timeout" 65 | }, 66 | "description": "Using this menu you can change what providers the integration will collect data from. By default it will use all data sources available for your current country as configured in Home Assistant.", 67 | "title": "Configure data collection sources" 68 | } 69 | } 70 | }, 71 | "issues": { 72 | "deprecate_cheapest_stations": { 73 | "description": "The 'cheapest_stations' option is deprecated and will be removed in a future release. Please use the 'find_fuels' service to retrieve a list of cheapest fuel prices instead.", 74 | "title": "Cheapest N Fuel Stations deprecation" 75 | } 76 | }, 77 | "options": { 78 | "step": { 79 | "area_create": { 80 | "data": { 81 | "cheapest_stations": "Register entities for top n cheapest fuel stations in this area", 82 | "cheapest_stations_count": "Number of cheapest fuel station entities to create", 83 | "cheapest_stations_fuel_type": "The fuel type to use for the cheapest fuel station entites", 84 | "latitude": "Latitude for the center of the search location", 85 | "longitude": "Longitude for the center of the search location", 86 | "name": "Area name (must be unique)", 87 | "radius": "Maximum search radius" 88 | }, 89 | "description": "Using this menu you can create areas to register devices and sensors. This integration will create a device for each fuel station discovered, under this a sensor will be created for each fuel type.", 90 | "title": "Create an area" 91 | }, 92 | "area_delete": { 93 | "data": { 94 | "name": "Area name" 95 | }, 96 | "title": "Select area to delete" 97 | }, 98 | "area_menu": { 99 | "menu_options": { 100 | "area_create": "Create an area", 101 | "area_delete": "Delete an area", 102 | "area_update_select": "Update an area", 103 | "main_menu": "Return to main menu" 104 | }, 105 | "title": "Configure areas to register devices and sensors" 106 | }, 107 | "area_update": { 108 | "data": { 109 | "cheapest_stations": "Register entities for top n cheapest fuel stations in this area", 110 | "cheapest_stations_count": "Number of cheapest fuel station entities to create", 111 | "cheapest_stations_fuel_type": "The fuel type to use for the cheapest fuel station entites", 112 | "latitude": "Latitude for the center of the search location", 113 | "longitude": "Longitude for the center of the search location", 114 | "name": "Area name (must be unique)", 115 | "radius": "Maximum search radius" 116 | }, 117 | "description": "Using this menu you can create areas to register devices and sensors. This integration will create a device for each fuel station discovered, under this a sensor will be created for each fuel type.", 118 | "title": "Create an area" 119 | }, 120 | "area_update_select": { 121 | "data": { 122 | "name": "Area name" 123 | }, 124 | "title": "Select area to update" 125 | }, 126 | "finished": { 127 | "description": "Click submit to finish setup", 128 | "title": "Fuel Prices" 129 | }, 130 | "sources": { 131 | "data": { 132 | "sources": "Data source(s)", 133 | "state": "State to show on the created sensors" 134 | }, 135 | "description": "Using this menu you can change what providers the integration will collect data from.", 136 | "title": "Configure data collection sources" 137 | } 138 | } 139 | }, 140 | "services": { 141 | "find_fuel_station": { 142 | "description": "Find all of the available fuel stations, alongside available fuels and cost for a given location. The results are not sorted.", 143 | "fields": { 144 | "location": { 145 | "description": "The location of the area to search", 146 | "name": "Location" 147 | }, 148 | "source": { 149 | "description": "The data source ID to search, defaults to 'any' for all data sources.", 150 | "name": "Data Source to search" 151 | } 152 | }, 153 | "name": "Find fuel stations from location" 154 | }, 155 | "find_fuels": { 156 | "description": "This will retrieve all fuel prices for a given location sorted by the cheapest first.", 157 | "fields": { 158 | "location": { 159 | "description": "The location of the area to search", 160 | "name": "Location" 161 | }, 162 | "source": { 163 | "description": "The data source ID to search, defaults to 'any' for all data sources.", 164 | "name": "Data Source to search" 165 | }, 166 | "type": { 167 | "description": "The fuel type to search for (such as E5, E10, B7, SDV)", 168 | "name": "Fuel Type" 169 | } 170 | }, 171 | "name": "Find fuel prices from location" 172 | } 173 | }, 174 | "title": "Fuel Prices" 175 | } -------------------------------------------------------------------------------- /custom_components/fuel_prices/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "For performance considerations and memory efficiency, only one instance of this integration is allowed." 5 | }, 6 | "step": { 7 | "area_create": { 8 | "data": { 9 | "cheapest_stations": "Register entities for top n cheapest fuel stations in this area", 10 | "cheapest_stations_count": "Number of cheapest fuel station entities to create", 11 | "cheapest_stations_fuel_type": "The fuel type to use for the cheapest fuel station entites", 12 | "latitude": "Latitude for the center of the search location", 13 | "longitude": "Longitude for the center of the search location", 14 | "name": "Area name (must be unique)", 15 | "radius": "Maximum search radius" 16 | }, 17 | "description": "Using this menu you can create areas to register devices and sensors. This integration will create a device for each fuel station discovered, under this a sensor will be created for each fuel type.", 18 | "title": "Create an area" 19 | }, 20 | "area_delete": { 21 | "data": { 22 | "name": "Area name" 23 | }, 24 | "title": "Select area to delete" 25 | }, 26 | "area_menu": { 27 | "description": "By default your home location has already been added automatically with a radius of 20 miles, this can be removed or changed if needed.", 28 | "menu_options": { 29 | "area_create": "Create an area", 30 | "area_delete": "Delete an area", 31 | "area_update_select": "Update an area", 32 | "main_menu": "Return to main menu" 33 | }, 34 | "title": "Configure areas to register devices and sensors." 35 | }, 36 | "area_update": { 37 | "data": { 38 | "cheapest_stations": "(Deprecated) Register entities for top n cheapest fuel stations in this area", 39 | "cheapest_stations_count": "(Deprecated) Number of cheapest fuel station entities to create", 40 | "cheapest_stations_fuel_type": "(Deprecated) The fuel type to use for the cheapest fuel station entites", 41 | "latitude": "Latitude for the center of the search location", 42 | "longitude": "Longitude for the center of the search location", 43 | "name": "Area name (must be unique)", 44 | "radius": "Maximum search radius" 45 | }, 46 | "description": "Using this menu you can create areas to register devices and sensors. This integration will create a device for each fuel station discovered, under this a sensor will be created for each fuel type.", 47 | "title": "Create an area" 48 | }, 49 | "area_update_select": { 50 | "data": { 51 | "name": "Area name" 52 | }, 53 | "title": "Select area to update" 54 | }, 55 | "finished": { 56 | "description": "Click submit to finish setup", 57 | "title": "Fuel Prices" 58 | }, 59 | "sources": { 60 | "data": { 61 | "scan_interval": "Data source update interval", 62 | "sources": "Data source(s)", 63 | "state": "State to show on the created sensors", 64 | "timeout": "Data source timeout" 65 | }, 66 | "description": "Using this menu you can change what providers the integration will collect data from. By default it will use all data sources available for your current country as configured in Home Assistant.", 67 | "title": "Configure data collection sources" 68 | } 69 | } 70 | }, 71 | "issues": { 72 | "deprecate_cheapest_stations": { 73 | "description": "The 'Cheapest Stations' option is deprecated and will be removed in a future release. Please use the 'find_fuels' action to retrieve a list of cheapest fuel prices instead.", 74 | "title": "Deprecation Notice: Cheapest Fuel Station Sensors" 75 | } 76 | }, 77 | "options": { 78 | "step": { 79 | "area_create": { 80 | "data": { 81 | "cheapest_stations": "Register entities for top n cheapest fuel stations in this area", 82 | "cheapest_stations_count": "Number of cheapest fuel station entities to create", 83 | "cheapest_stations_fuel_type": "The fuel type to use for the cheapest fuel station entites", 84 | "latitude": "Latitude for the center of the search location", 85 | "longitude": "Longitude for the center of the search location", 86 | "name": "Area name (must be unique)", 87 | "radius": "Maximum search radius" 88 | }, 89 | "description": "Using this menu you can create areas to register devices and sensors. This integration will create a device for each fuel station discovered, under this a sensor will be created for each fuel type.", 90 | "title": "Create an area" 91 | }, 92 | "area_delete": { 93 | "data": { 94 | "name": "Area name" 95 | }, 96 | "title": "Select area to delete" 97 | }, 98 | "area_menu": { 99 | "menu_options": { 100 | "area_create": "Create an area", 101 | "area_delete": "Delete an area", 102 | "area_update_select": "Update an area", 103 | "main_menu": "Return to main menu" 104 | }, 105 | "title": "Configure areas to register devices and sensors" 106 | }, 107 | "area_update": { 108 | "data": { 109 | "cheapest_stations": "Register entities for top n cheapest fuel stations in this area", 110 | "cheapest_stations_count": "Number of cheapest fuel station entities to create", 111 | "cheapest_stations_fuel_type": "The fuel type to use for the cheapest fuel station entites", 112 | "latitude": "Latitude for the center of the search location", 113 | "longitude": "Longitude for the center of the search location", 114 | "name": "Area name (must be unique)", 115 | "radius": "Maximum search radius" 116 | }, 117 | "description": "Using this menu you can create areas to register devices and sensors. This integration will create a device for each fuel station discovered, under this a sensor will be created for each fuel type.", 118 | "title": "Create an area" 119 | }, 120 | "area_update_select": { 121 | "data": { 122 | "name": "Area name" 123 | }, 124 | "title": "Select area to update" 125 | }, 126 | "finished": { 127 | "description": "Click submit to finish setup", 128 | "title": "Fuel Prices" 129 | }, 130 | "sources": { 131 | "data": { 132 | "sources": "Data source(s)", 133 | "state": "State to show on the created sensors" 134 | }, 135 | "description": "Using this menu you can change what providers the integration will collect data from.", 136 | "title": "Configure data collection sources" 137 | } 138 | } 139 | }, 140 | "services": { 141 | "find_fuel_station": { 142 | "description": "Find all of the available fuel stations, alongside available fuels and cost for a given location. The results are not sorted.", 143 | "fields": { 144 | "location": { 145 | "description": "The location of the area to search", 146 | "name": "Location" 147 | }, 148 | "source": { 149 | "description": "The data source ID to search, defaults to 'any' for all data sources.", 150 | "name": "Data Source to search" 151 | } 152 | }, 153 | "name": "Find fuel stations from location" 154 | }, 155 | "find_fuels": { 156 | "description": "This will retrieve all fuel prices for a given location sorted by the cheapest first.", 157 | "fields": { 158 | "location": { 159 | "description": "The location of the area to search", 160 | "name": "Location" 161 | }, 162 | "source": { 163 | "description": "The data source ID to search, defaults to 'any' for all data sources.", 164 | "name": "Data Source to search" 165 | }, 166 | "type": { 167 | "description": "The fuel type to search for (such as E5, E10, B7, SDV)", 168 | "name": "Fuel Type" 169 | } 170 | }, 171 | "name": "Find fuel prices from location" 172 | } 173 | }, 174 | "title": "Fuel Prices" 175 | } -------------------------------------------------------------------------------- /custom_components/fuel_prices/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for Fuel Prices.""" 2 | 3 | import logging 4 | from typing import Any 5 | 6 | from pyfuelprices.sources.mapping import FULL_COUNTRY_MAP, COUNTRY_MAP 7 | import voluptuous as vol 8 | 9 | from homeassistant import config_entries 10 | from homeassistant.data_entry_flow import FlowResult 11 | from homeassistant.exceptions import HomeAssistantError 12 | from homeassistant.helpers import selector, entity_registry as er 13 | from homeassistant.helpers import config_validation as cv 14 | from homeassistant.core import callback 15 | from homeassistant.const import ( 16 | CONF_LATITUDE, 17 | CONF_LONGITUDE, 18 | CONF_RADIUS, 19 | CONF_NAME, 20 | CONF_TIMEOUT, 21 | CONF_SCAN_INTERVAL, 22 | ) 23 | 24 | from pyfuelprices import FuelPrices 25 | 26 | from . import FuelPricesConfigEntry 27 | from .const import ( 28 | DOMAIN, 29 | NAME, 30 | CONF_AREAS, 31 | CONF_SOURCES, 32 | CONF_STATE_VALUE, 33 | CONF_CHEAPEST_SENSORS, 34 | CONF_CHEAPEST_SENSORS_COUNT, 35 | CONF_CHEAPEST_SENSORS_FUEL_TYPE 36 | ) 37 | 38 | _LOGGER = logging.getLogger(__name__) 39 | 40 | 41 | def build_sources_list() -> list[selector.SelectOptionDict]: 42 | """Build source configuration dict.""" 43 | sources = [] 44 | for country, srcs in FULL_COUNTRY_MAP.items(): 45 | for src in srcs: 46 | label_val = src 47 | if src not in COUNTRY_MAP.get(country, []): 48 | label_val = f"{src} (beta)" 49 | sources.append(selector.SelectOptionDict( 50 | value=src, label=f"{country}: {label_val}")) 51 | sources.sort(key=lambda x: x['label']) 52 | return sources 53 | 54 | 55 | AREA_SCHEMA = vol.Schema( 56 | { 57 | vol.Required(CONF_NAME): selector.TextSelector(), 58 | vol.Required(CONF_RADIUS, default=5.0): selector.NumberSelector( 59 | selector.NumberSelectorConfig( 60 | mode=selector.NumberSelectorMode.BOX, 61 | unit_of_measurement="miles", 62 | min=1, 63 | max=50, 64 | step=0.1, 65 | ) 66 | ), 67 | vol.Inclusive( 68 | CONF_LATITUDE, "coordinates", "Latitude and longitude must exist together" 69 | ): cv.latitude, 70 | vol.Inclusive( 71 | CONF_LONGITUDE, "coordinates", "Latitude and longitude must exist together" 72 | ): cv.longitude 73 | } 74 | ) 75 | 76 | SYSTEM_SCHEMA = vol.Schema( 77 | { 78 | vol.Optional( 79 | CONF_SOURCES 80 | ): selector.SelectSelector( 81 | selector.SelectSelectorConfig( 82 | mode=selector.SelectSelectorMode.DROPDOWN, 83 | options=build_sources_list(), 84 | multiple=True, 85 | ) 86 | ), 87 | vol.Optional( 88 | CONF_TIMEOUT 89 | ): selector.NumberSelector( 90 | selector.NumberSelectorConfig( 91 | mode=selector.NumberSelectorMode.BOX, 92 | min=5, 93 | max=60, 94 | unit_of_measurement="s", 95 | ) 96 | ), 97 | vol.Optional( 98 | CONF_SCAN_INTERVAL 99 | ): selector.NumberSelector( 100 | selector.NumberSelectorConfig( 101 | mode=selector.NumberSelectorMode.BOX, 102 | min=1, 103 | max=24, 104 | unit_of_measurement="h", 105 | ) 106 | ) 107 | } 108 | ) 109 | 110 | OPTIONS_AREA_SCHEMA = AREA_SCHEMA.extend( 111 | { 112 | vol.Optional(CONF_CHEAPEST_SENSORS, default=False): selector.BooleanSelector(), 113 | vol.Optional(CONF_CHEAPEST_SENSORS_COUNT, default=5): selector.NumberSelector( 114 | selector.NumberSelectorConfig( 115 | mode=selector.NumberSelectorMode.SLIDER, 116 | min=1, 117 | max=10, 118 | step=1 119 | ) 120 | ) 121 | } 122 | ) 123 | 124 | 125 | class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 126 | """Handle a config flow.""" 127 | 128 | VERSION = 4 129 | configured_areas: list[dict] = [] 130 | source_configuration = {} 131 | configuring_area = {} 132 | configuring_index = -1 133 | state_value = "name" 134 | timeout = None 135 | interval = None 136 | 137 | @property 138 | def configured_area_names(self) -> list[str]: 139 | """Return a list of area names.""" 140 | items = [] 141 | for area in self.configured_areas: 142 | items.append(area["name"]) 143 | return items 144 | 145 | async def async_step_user( 146 | self, user_input: dict[str, Any] | None = None 147 | ) -> FlowResult: 148 | """Handle the intial step.""" 149 | # only one config entry allowed 150 | # users should use the options flow to adjust areas and sources. 151 | await self.async_set_unique_id(NAME) 152 | self._abort_if_unique_id_configured() 153 | self.configured_areas = [] 154 | self.source_configuration = {} 155 | self.configuring_area = {} 156 | self.configuring_index = -1 157 | self.timeout = 10 158 | self.interval = 24 159 | # add the home location as a default (this can optionally be removed). 160 | self.configured_areas.append( 161 | { 162 | CONF_NAME: self.hass.config.location_name, 163 | CONF_LATITUDE: self.hass.config.latitude, 164 | CONF_LONGITUDE: self.hass.config.longitude, 165 | CONF_RADIUS: 10.0, 166 | } 167 | ) 168 | return await self.async_step_main_menu() 169 | 170 | async def async_step_main_menu(self, _: None = None): 171 | """Display configuration menu.""" 172 | return self.async_show_menu( 173 | step_id="main_menu", 174 | menu_options={ 175 | "area_menu": "Configure areas to create devices/sensors", 176 | "sources": "Configure data collector sources", 177 | "finished": "Complete setup", 178 | }, 179 | ) 180 | 181 | async def async_step_sources(self, user_input: dict[str, Any] | None = None): 182 | """Set data source config.""" 183 | if user_input is not None: 184 | if len(user_input.keys()) > 0: 185 | self.source_configuration = dict.fromkeys( 186 | user_input[CONF_SOURCES], {}) 187 | self.timeout = user_input[CONF_TIMEOUT] 188 | self.interval = user_input[CONF_SCAN_INTERVAL] 189 | for src in self.source_configuration: 190 | if FuelPrices.source_requires_config(src) and len(self.source_configuration[src].keys()) == 0: 191 | return await self.async_step_source_config(source=src) 192 | else: 193 | self.source_configuration.setdefault(src, {}) 194 | return await self.async_step_main_menu(None) 195 | return self.async_show_form( 196 | step_id="sources", 197 | data_schema=self.add_suggested_values_to_schema(SYSTEM_SCHEMA, { 198 | CONF_SOURCES: list(self.source_configuration.keys()), 199 | CONF_TIMEOUT: self.timeout, 200 | CONF_SCAN_INTERVAL: self.interval 201 | })) 202 | 203 | async def async_step_source_config( 204 | self, 205 | user_input: dict[str, Any] | None = None, 206 | source: str | None = None 207 | ): 208 | """Show the config for a specific data source.""" 209 | if user_input is not None: 210 | self.source_configuration[self.configuring_source] = user_input 211 | return await self.async_step_sources({}) 212 | self.configuring_source = source 213 | return self.async_show_form( 214 | step_id="source_config", 215 | data_schema=FuelPrices.get_source_config_schema(source), 216 | description_placeholders={ 217 | "source": source.capitalize() 218 | } 219 | ) 220 | 221 | async def async_step_area_menu(self, _: None = None) -> FlowResult: 222 | """Show the area menu.""" 223 | return self.async_show_menu( 224 | step_id="area_menu", 225 | menu_options={ 226 | "area_create": "Define a new area", 227 | "area_update_select": "Update an area", 228 | "area_delete": "Delete an area", 229 | "main_menu": "Return to main menu", 230 | }, 231 | ) 232 | 233 | async def async_step_area_create(self, user_input: dict[str, Any] | None = None): 234 | """Handle an area configuration.""" 235 | errors: dict[str, str] = {} 236 | if user_input is not None: 237 | self.configured_areas.append( 238 | { 239 | CONF_NAME: user_input[CONF_NAME], 240 | CONF_LATITUDE: user_input[CONF_LATITUDE], 241 | CONF_LONGITUDE: user_input[CONF_LONGITUDE], 242 | CONF_RADIUS: user_input[CONF_RADIUS] 243 | } 244 | ) 245 | return await self.async_step_area_menu() 246 | return self.async_show_form( 247 | step_id="area_create", data_schema=AREA_SCHEMA, errors=errors 248 | ) 249 | 250 | async def async_step_area_update_select( 251 | self, user_input: dict[str, Any] | None = None 252 | ): 253 | """Show a menu to allow the user to select what option to update.""" 254 | if user_input is not None: 255 | for i, data in enumerate(self.configured_areas): 256 | if self.configured_areas[i]["name"] == user_input[CONF_NAME]: 257 | self.configuring_area = data 258 | self.configuring_index = i 259 | break 260 | return await self.async_step_area_update() 261 | if len(self.configured_areas) > 0: 262 | return self.async_show_form( 263 | step_id="area_update_select", 264 | data_schema=vol.Schema( 265 | { 266 | vol.Required(CONF_NAME): selector.SelectSelector( 267 | selector.SelectSelectorConfig( 268 | mode=selector.SelectSelectorMode.LIST, 269 | options=self.configured_area_names, 270 | ) 271 | ) 272 | } 273 | ), 274 | ) 275 | return await self.async_step_area_menu() 276 | 277 | async def async_step_area_update(self, user_input: dict[str, Any] | None = None): 278 | """Handle an area update.""" 279 | errors: dict[str, str] = {} 280 | if user_input is not None: 281 | self.configured_areas.pop(self.configuring_index) 282 | self.configured_areas.append( 283 | { 284 | CONF_NAME: user_input[CONF_NAME], 285 | CONF_LATITUDE: user_input[CONF_LATITUDE], 286 | CONF_LONGITUDE: user_input[CONF_LONGITUDE], 287 | CONF_RADIUS: user_input[CONF_RADIUS] 288 | } 289 | ) 290 | return await self.async_step_area_menu() 291 | return self.async_show_form( 292 | step_id="area_update", 293 | data_schema=self.add_suggested_values_to_schema( 294 | AREA_SCHEMA, self.configuring_area), 295 | errors=errors, 296 | ) 297 | 298 | async def async_step_area_delete(self, user_input: dict[str, Any] | None = None): 299 | """Delete a configured area.""" 300 | if user_input is not None: 301 | for i, data in enumerate(self.configured_areas): 302 | if data["name"] == user_input[CONF_NAME]: 303 | self.configured_areas.pop(i) 304 | break 305 | return await self.async_step_area_menu() 306 | if len(self.configured_areas) > 0: 307 | return self.async_show_form( 308 | step_id="area_delete", 309 | data_schema=vol.Schema( 310 | { 311 | vol.Required(CONF_NAME): selector.SelectSelector( 312 | selector.SelectSelectorConfig( 313 | mode=selector.SelectSelectorMode.LIST, 314 | options=self.configured_area_names, 315 | ) 316 | ) 317 | } 318 | ), 319 | ) 320 | return await self.async_step_area_menu() 321 | 322 | async def async_step_finished(self, user_input: dict[str, Any] | None = None): 323 | """Save configuration.""" 324 | errors: dict[str, str] = {} 325 | if user_input is not None: 326 | if len(self.source_configuration.keys()) > 0: 327 | user_input[CONF_SOURCES] = self.source_configuration 328 | elif self.hass.config.country is not None: 329 | user_input[CONF_SOURCES] = dict.fromkeys(COUNTRY_MAP.get( 330 | self.hass.config.country), {}) 331 | else: 332 | user_input[CONF_SOURCES] = dict.fromkeys([ 333 | k.value for k in build_sources_list()], {}) 334 | user_input[CONF_AREAS] = self.configured_areas 335 | user_input[CONF_SCAN_INTERVAL] = self.interval 336 | user_input[CONF_TIMEOUT] = self.timeout 337 | user_input[CONF_STATE_VALUE] = self.state_value 338 | return self.async_create_entry(title=NAME, data=user_input) 339 | return self.async_show_form(step_id="finished", errors=errors, last_step=True) 340 | 341 | @staticmethod 342 | @callback 343 | def async_get_options_flow(config_entry: FuelPricesConfigEntry) -> "FuelPricesOptionsFlow": 344 | """Return option flow.""" 345 | return FuelPricesOptionsFlow(config_entry) 346 | 347 | 348 | class FuelPricesOptionsFlow(config_entries.OptionsFlowWithConfigEntry): 349 | """OptionsFlow for fuel_prices module.""" 350 | 351 | global_config = {} 352 | configured_areas: list[dict] = [] 353 | source_configuration = {} 354 | configuring_area = {} 355 | configuring_index = -1 356 | configuring_source = "" 357 | timeout = 10 358 | interval = 24 359 | state_value = "name" 360 | config_entry: FuelPricesConfigEntry 361 | 362 | @property 363 | def configured_area_names(self) -> list[str]: 364 | """Return a list of area names.""" 365 | items = [] 366 | for area in self.configured_areas: 367 | items.append(area["name"]) 368 | return items 369 | 370 | async def _async_create_entry(self) -> config_entries.FlowResult: 371 | """Create an entry.""" 372 | return self.async_create_entry( 373 | title=self.config_entry.title, 374 | data={ 375 | CONF_AREAS: self.configured_areas, 376 | CONF_SOURCES: self.source_configuration, 377 | CONF_SCAN_INTERVAL: self.interval, 378 | CONF_TIMEOUT: self.timeout, 379 | CONF_STATE_VALUE: self.state_value 380 | } 381 | ) 382 | 383 | def build_available_fuels_list(self) -> list: 384 | """Build a list of available fuels according to data within entity registry.""" 385 | fuel_types = [] 386 | entities = er.async_entries_for_config_entry( 387 | er.async_get(self.hass), self.config_entry.entry_id) 388 | for entity in entities: 389 | state = self.hass.states.get(entity.entity_id).attributes 390 | for k in state.get("available_fuels", {}): 391 | if k not in fuel_types: 392 | fuel_types.append(k) 393 | return fuel_types 394 | 395 | def build_compatible_sensor_states(self) -> list: 396 | """Build a list of compatible sensor states for use in select controls.""" 397 | states = ["name"] 398 | states.extend(self.build_available_fuels_list()) 399 | return states 400 | 401 | async def async_step_init(self, _: None = None): 402 | """User init option flow.""" 403 | self.configured_areas = self.config_entry.options.get( 404 | CONF_AREAS, self.config_entry.data.get(CONF_AREAS, []) 405 | ) 406 | self.source_configuration = self.config_entry.options.get( 407 | CONF_SOURCES, self.config_entry.data.get(CONF_SOURCES, {}) 408 | ) 409 | self.timeout = self.config_entry.options.get( 410 | CONF_TIMEOUT, self.config_entry.data.get(CONF_TIMEOUT, 10) 411 | ) 412 | self.interval = self.config_entry.options.get( 413 | CONF_SCAN_INTERVAL, self.config_entry.data.get( 414 | CONF_SCAN_INTERVAL, 24) 415 | ) 416 | self.state_value = self.config_entry.options.get( 417 | CONF_STATE_VALUE, self.config_entry.data.get( 418 | CONF_STATE_VALUE, "name") 419 | ) 420 | return await self.async_step_main_menu() 421 | 422 | async def async_step_main_menu(self, _: None = None): 423 | """Display configuration menu.""" 424 | return self.async_show_menu( 425 | step_id="main_menu", 426 | menu_options={ 427 | "area_menu": "Configure areas to create devices/sensors", 428 | "sources": "Configure data collector sources", 429 | "finished": "Complete re-configuration", 430 | }, 431 | ) 432 | 433 | async def async_step_sources(self, user_input: dict[str, Any] | None = None): 434 | """Set data source config.""" 435 | if user_input is not None: 436 | if len(user_input.keys()) > 0: 437 | self.source_configuration = dict.fromkeys( 438 | user_input[CONF_SOURCES], {}) 439 | self.timeout = user_input[CONF_TIMEOUT] 440 | self.interval = user_input[CONF_SCAN_INTERVAL] 441 | self.state_value = user_input[CONF_STATE_VALUE] 442 | for src in self.source_configuration: 443 | if FuelPrices.source_requires_config(src) and len(self.source_configuration[src].keys()) == 0: 444 | return await self.async_step_source_config(source=src) 445 | else: 446 | self.source_configuration.setdefault(src, {}) 447 | return await self.async_step_main_menu(None) 448 | return self.async_show_form( 449 | step_id="sources", 450 | data_schema=self.add_suggested_values_to_schema( 451 | SYSTEM_SCHEMA.extend({ 452 | vol.Required( 453 | CONF_STATE_VALUE 454 | ): selector.SelectSelector( 455 | selector.SelectSelectorConfig( 456 | options=self.build_compatible_sensor_states(), 457 | multiple=False, 458 | custom_value=True, 459 | mode=selector.SelectSelectorMode.DROPDOWN, 460 | sort=True 461 | ) 462 | ) 463 | }), { 464 | CONF_SOURCES: list(self.source_configuration.keys()), 465 | CONF_TIMEOUT: self.timeout, 466 | CONF_SCAN_INTERVAL: self.interval, 467 | CONF_STATE_VALUE: self.state_value 468 | })) 469 | 470 | async def async_step_source_config( 471 | self, 472 | user_input: dict[str, Any] | None = None, 473 | source: str | None = None 474 | ): 475 | """Show the config for a specific data source.""" 476 | if user_input is not None: 477 | self.source_configuration[self.configuring_source] = user_input 478 | return await self.async_step_sources({}) 479 | self.configuring_source = source 480 | return self.async_show_form( 481 | step_id="source_config", 482 | data_schema=FuelPrices.get_source_config_schema(source), 483 | description_placeholders={ 484 | "source": source.capitalize() 485 | } 486 | ) 487 | 488 | async def async_step_area_menu(self, _: None = None) -> FlowResult: 489 | """Show the area menu.""" 490 | return self.async_show_menu( 491 | step_id="area_menu", 492 | menu_options={ 493 | "area_create": "Define a new area", 494 | "area_update_select": "Update an area", 495 | "area_delete": "Delete an area", 496 | "main_menu": "Return to main menu", 497 | }, 498 | ) 499 | 500 | async def async_step_area_create(self, user_input: dict[str, Any] | None = None): 501 | """Handle an area configuration.""" 502 | errors: dict[str, str] = {} 503 | if user_input is not None: 504 | self.configured_areas.append( 505 | { 506 | CONF_NAME: user_input[CONF_NAME], 507 | CONF_LATITUDE: user_input[CONF_LATITUDE], 508 | CONF_LONGITUDE: user_input[CONF_LONGITUDE], 509 | CONF_RADIUS: user_input[CONF_RADIUS], 510 | CONF_CHEAPEST_SENSORS: user_input.get(CONF_CHEAPEST_SENSORS, False), 511 | CONF_CHEAPEST_SENSORS_COUNT: user_input.get(CONF_CHEAPEST_SENSORS_COUNT, 5), 512 | CONF_CHEAPEST_SENSORS_FUEL_TYPE: user_input.get( 513 | CONF_CHEAPEST_SENSORS_FUEL_TYPE, None) 514 | } 515 | ) 516 | return await self.async_step_area_menu() 517 | return self.async_show_form( 518 | step_id="area_create", data_schema=OPTIONS_AREA_SCHEMA.extend( 519 | { 520 | vol.Optional( 521 | CONF_CHEAPEST_SENSORS_FUEL_TYPE 522 | ): selector.SelectSelector( 523 | selector.SelectSelectorConfig( 524 | options=self.build_available_fuels_list(), 525 | multiple=False, 526 | custom_value=False, 527 | mode=selector.SelectSelectorMode.DROPDOWN, 528 | sort=True 529 | ) 530 | ) 531 | } 532 | ), errors=errors 533 | ) 534 | 535 | async def async_step_area_update_select( 536 | self, user_input: dict[str, Any] | None = None 537 | ): 538 | """Show a menu to allow the user to select what option to update.""" 539 | if user_input is not None: 540 | for i, data in enumerate(self.configured_areas): 541 | if self.configured_areas[i]["name"] == user_input[CONF_NAME]: 542 | self.configuring_area = data 543 | self.configuring_index = i 544 | break 545 | return await self.async_step_area_update() 546 | if len(self.configured_areas) > 0: 547 | return self.async_show_form( 548 | step_id="area_update_select", 549 | data_schema=vol.Schema( 550 | { 551 | vol.Required(CONF_NAME): selector.SelectSelector( 552 | selector.SelectSelectorConfig( 553 | mode=selector.SelectSelectorMode.LIST, 554 | options=self.configured_area_names, 555 | ) 556 | ) 557 | } 558 | ), 559 | ) 560 | return await self.async_step_area_menu() 561 | 562 | async def async_step_area_update(self, user_input: dict[str, Any] | None = None): 563 | """Handle an area update.""" 564 | errors: dict[str, str] = {} 565 | if user_input is not None: 566 | self.configured_areas.pop(self.configuring_index) 567 | self.configured_areas.append( 568 | { 569 | CONF_NAME: user_input[CONF_NAME], 570 | CONF_LATITUDE: user_input[CONF_LATITUDE], 571 | CONF_LONGITUDE: user_input[CONF_LONGITUDE], 572 | CONF_RADIUS: user_input[CONF_RADIUS], 573 | CONF_CHEAPEST_SENSORS: user_input.get(CONF_CHEAPEST_SENSORS, False), 574 | CONF_CHEAPEST_SENSORS_COUNT: user_input.get(CONF_CHEAPEST_SENSORS_COUNT, 5), 575 | CONF_CHEAPEST_SENSORS_FUEL_TYPE: user_input.get( 576 | CONF_CHEAPEST_SENSORS_FUEL_TYPE, None) 577 | } 578 | ) 579 | return await self.async_step_area_menu() 580 | return self.async_show_form( 581 | step_id="area_update", 582 | data_schema=self.add_suggested_values_to_schema( 583 | OPTIONS_AREA_SCHEMA.extend( 584 | { 585 | vol.Optional( 586 | CONF_CHEAPEST_SENSORS_FUEL_TYPE 587 | ): selector.SelectSelector( 588 | selector.SelectSelectorConfig( 589 | options=self.build_available_fuels_list(), 590 | multiple=False, 591 | custom_value=False, 592 | mode=selector.SelectSelectorMode.DROPDOWN, 593 | sort=True 594 | ) 595 | ) 596 | } 597 | ), self.configuring_area), 598 | errors=errors, 599 | ) 600 | 601 | async def async_step_area_delete(self, user_input: dict[str, Any] | None = None): 602 | """Delete a configured area.""" 603 | if user_input is not None: 604 | for i, data in enumerate(self.configured_areas): 605 | if data["name"] == user_input[CONF_NAME]: 606 | self.configured_areas.pop(i) 607 | break 608 | return await self.async_step_area_menu() 609 | if len(self.configured_areas) > 0: 610 | return self.async_show_form( 611 | step_id="area_delete", 612 | data_schema=vol.Schema( 613 | { 614 | vol.Required(CONF_NAME): selector.SelectSelector( 615 | selector.SelectSelectorConfig( 616 | mode=selector.SelectSelectorMode.LIST, 617 | options=self.configured_area_names, 618 | ) 619 | ) 620 | } 621 | ), 622 | ) 623 | return await self.async_step_area_menu() 624 | 625 | async def async_step_finished(self, user_input: dict[str, Any] | None = None): 626 | """Save configuration.""" 627 | errors: dict[str, str] = {} 628 | if user_input is not None: 629 | if len(self.source_configuration.keys()) > 0: 630 | user_input[CONF_SOURCES] = self.source_configuration 631 | elif self.hass.config.country is not None: 632 | user_input[CONF_SOURCES] = dict.fromkeys(COUNTRY_MAP.get( 633 | self.hass.config.country), {}) 634 | else: 635 | user_input[CONF_SOURCES] = dict.fromkeys([ 636 | k.value for k in build_sources_list()], {}) 637 | user_input[CONF_AREAS] = self.configured_areas 638 | user_input[CONF_SCAN_INTERVAL] = self.interval 639 | user_input[CONF_TIMEOUT] = self.timeout 640 | user_input[CONF_STATE_VALUE] = self.state_value 641 | self.options.update(user_input) 642 | return self.async_create_entry(title=NAME, data=self.options) 643 | return self.async_show_form(step_id="finished", errors=errors, last_step=True) 644 | 645 | 646 | class CannotConnect(HomeAssistantError): 647 | """Error to indicate we cannot connect.""" 648 | --------------------------------------------------------------------------------