├── .gitignore ├── .pylintrc ├── hacs.json ├── .github ├── workflows │ ├── hassfest.yml │ ├── hacs_validate.yaml │ └── close_inactive_issues.yml └── ISSUE_TEMPLATE │ └── bug_report.md ├── .vscode └── settings.json ├── custom_components └── kleenex_pollenradar │ ├── manifest.json │ ├── icons.json │ ├── const.py │ ├── __init__.py │ ├── coordinator.py │ ├── translations │ ├── en.json │ ├── nl.json │ ├── it.json │ └── fr.json │ ├── config_flow.py │ ├── sensor.py │ └── api.py └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MESSAGES CONTROL] 2 | disable=unexpected-keyword-arg -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Kleenex Pollen Radar", 3 | "homeassistant": "2025.1.0", 4 | "render_readme": true 5 | } 6 | -------------------------------------------------------------------------------- /.github/workflows/hassfest.yml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: '0 0 * * *' 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v5.0.0" 14 | - uses: "home-assistant/actions/hassfest@master" 15 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.analysis.diagnosticSeverityOverrides": { 3 | "reportMissingTypeStubs": "none", 4 | "reportMissingTypeArgument": "none", 5 | "reportUnknownMemberType": "none" 6 | }, 7 | "[python]": { 8 | "editor.defaultFormatter": "charliermarsh.ruff" 9 | }, 10 | "python.formatting.provider": "black" 11 | } 12 | -------------------------------------------------------------------------------- /.github/workflows/hacs_validate.yaml: -------------------------------------------------------------------------------- 1 | name: HACS Validation 2 | 3 | on: 4 | push: 5 | pull_request: 6 | # schedule: 7 | # - cron: "0 0 * * *" 8 | workflow_dispatch: 9 | 10 | jobs: 11 | validate-hacs: 12 | runs-on: "ubuntu-latest" 13 | steps: 14 | - uses: "actions/checkout@v5.0.0" 15 | - name: HACS validation 16 | uses: "hacs/action@main" 17 | with: 18 | category: "integration" 19 | -------------------------------------------------------------------------------- /custom_components/kleenex_pollenradar/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "kleenex_pollenradar", 3 | "name": "Kleenex Pollen Radar", 4 | "codeowners": [ 5 | "@MarcoGos" 6 | ], 7 | "config_flow": true, 8 | "dependencies": [], 9 | "documentation": "https://github.com/MarcoGos/kleenex_pollenradar", 10 | "homekit": {}, 11 | "integration_type": "hub", 12 | "iot_class": "cloud_polling", 13 | "issue_tracker": "https://github.com/MarcoGos/kleenex_pollenradar/issues", 14 | "requirements": ["beautifulsoup4==4.10.0"], 15 | "ssdp": [], 16 | "version": "1.4.4", 17 | "zeroconf": [] 18 | } 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | *Information** 14 | Please provide the following information: 15 | 1. GPS location (2 decimals is sufficient) 16 | 2. Selected region 17 | 3. Installed version 18 | 4. Home Assistant version 19 | 20 | **RAW information** 21 | Add the content of the Raw attribute of the Date sensor. 22 | 23 | **Additional context** 24 | Add any other context about the problem here. 25 | -------------------------------------------------------------------------------- /.github/workflows/close_inactive_issues.yml: -------------------------------------------------------------------------------- 1 | name: Close inactive issues 2 | on: 3 | schedule: 4 | - cron: "30 1 * * *" 5 | 6 | jobs: 7 | close-issues: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | issues: write 11 | pull-requests: write 12 | steps: 13 | - uses: actions/stale@v5 14 | with: 15 | days-before-issue-stale: 30 16 | days-before-issue-close: 14 17 | stale-issue-label: "stale" 18 | stale-issue-message: "This issue is stale because it has been open for 30 days with no activity." 19 | close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale." 20 | days-before-pr-stale: -1 21 | days-before-pr-close: -1 22 | repo-token: ${{ secrets.GITHUB_TOKEN }} 23 | -------------------------------------------------------------------------------- /custom_components/kleenex_pollenradar/icons.json: -------------------------------------------------------------------------------- 1 | { 2 | "entity": { 3 | "sensor": { 4 | "trees_level": { 5 | "default": "mdi:gauge-empty", 6 | "state": { 7 | "low": "mdi:gauge-empty", 8 | "moderate": "mdi:gauge-low", 9 | "high": "mdi:gauge", 10 | "very-high": "mdi:gauge-full" 11 | } 12 | }, 13 | "grass_level": { 14 | "default": "mdi:gauge-empty", 15 | "state": { 16 | "low": "mdi:gauge-empty", 17 | "moderate": "mdi:gauge-low", 18 | "high": "mdi:gauge", 19 | "very-high": "mdi:gauge-full" 20 | } 21 | }, 22 | "weeds_level": { 23 | "default": "mdi:gauge-empty", 24 | "state": { 25 | "low": "mdi:gauge-empty", 26 | "moderate": "mdi:gauge-low", 27 | "high": "mdi:gauge", 28 | "very-high": "mdi:gauge-full" 29 | } 30 | }, 31 | "detail_level": { 32 | "default": "mdi:gauge-empty", 33 | "state": { 34 | "low": "mdi:gauge-empty", 35 | "moderate": "mdi:gauge-low", 36 | "high": "mdi:gauge", 37 | "very-high": "mdi:gauge-full" 38 | } 39 | } 40 | } 41 | } 42 | } -------------------------------------------------------------------------------- /custom_components/kleenex_pollenradar/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the Kleenex pollen radar integration.""" 2 | 3 | from enum import Enum 4 | 5 | NAME = "Kleenex Pollen Radar" 6 | DOMAIN = "kleenex_pollenradar" 7 | MANUFACTURER = "Kleenex / Scottex" 8 | MODEL = "Pollen radar" 9 | 10 | # Platforms 11 | SENSOR = "sensor" 12 | PLATFORMS = [SENSOR] 13 | 14 | DEFAULT_SYNC_INTERVAL = 3600 # seconds 15 | 16 | 17 | class Regions(Enum): 18 | """Supported regions.""" 19 | 20 | FRANCE = "fr" 21 | ITALY = "it" 22 | NETHERLANDS = "nl" 23 | UNITED_KINGDOM = "uk" 24 | UNITED_STATES = "us" 25 | 26 | 27 | REGIONS = { 28 | Regions.FRANCE.value: { 29 | "name": "France", 30 | "url": "https://www.kleenex.fr/api/sitecore/Pollen/", 31 | "method": "get", 32 | }, 33 | Regions.ITALY.value: { 34 | "name": "Italy", 35 | "url": "https://www.it.scottex.com/api/sitecore/Pollen/", 36 | "method": "post", 37 | }, 38 | Regions.NETHERLANDS.value: { 39 | "name": "Netherlands", 40 | "url": "https://www.kleenex.nl/api/sitecore/Pollen/", 41 | "method": "get", 42 | }, 43 | Regions.UNITED_KINGDOM.value: { 44 | "name": "United Kingdom", 45 | "url": "https://www.kleenex.co.uk/api/sitecore/Pollen/", 46 | "method": "get", 47 | }, 48 | Regions.UNITED_STATES.value: { 49 | "name": "United States of America", 50 | "url": "https://www.kleenex.com/api/sitecore/Pollen/", 51 | "method": "get", 52 | }, 53 | } 54 | CONF_REGION = "region" 55 | CONF_GET_CONTENT_BY = "get_content_by" 56 | CONF_NAME = "name" 57 | CONF_CITY = "city" 58 | RETRY_ATTEMPTS = 5 59 | 60 | 61 | class GetContentBy(Enum): 62 | """Get content by.""" 63 | 64 | CITY = "city" 65 | LAT_LNG = "lat_lng" 66 | CITY_ITALY = "city_italy" 67 | 68 | 69 | METHODS = { 70 | GetContentBy.CITY: "GetPollenContentCity", 71 | GetContentBy.LAT_LNG: "GetPollenContent", 72 | GetContentBy.CITY_ITALY: "GetPollenData", 73 | } 74 | -------------------------------------------------------------------------------- /custom_components/kleenex_pollenradar/__init__.py: -------------------------------------------------------------------------------- 1 | """The Kleenex pollenradar integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from homeassistant.config_entries import ConfigEntry 7 | from homeassistant.core import HomeAssistant 8 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 9 | from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE 10 | 11 | from .api import PollenApi 12 | from .const import ( 13 | DOMAIN, 14 | PLATFORMS, 15 | CONF_REGION, 16 | CONF_GET_CONTENT_BY, 17 | CONF_CITY, 18 | GetContentBy, 19 | ) 20 | from .coordinator import PollenDataUpdateCoordinator 21 | 22 | _LOGGER: logging.Logger = logging.getLogger(__package__) 23 | 24 | 25 | async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: 26 | """Set up Kleenex pollen from a config entry.""" 27 | if hass.data.get(DOMAIN) is None: 28 | hass.data.setdefault(DOMAIN, {}) 29 | 30 | session = async_get_clientsession(hass) 31 | api = PollenApi( 32 | session=session, 33 | region=config_entry.data[CONF_REGION], 34 | get_content_by=GetContentBy( 35 | config_entry.data.get(CONF_GET_CONTENT_BY, GetContentBy.LAT_LNG.value) 36 | ), 37 | latitude=config_entry.data[CONF_LATITUDE], 38 | longitude=config_entry.data[CONF_LONGITUDE], 39 | city=config_entry.data.get(CONF_CITY, ""), 40 | ) 41 | 42 | hass.data[DOMAIN][config_entry.entry_id] = coordinator = ( 43 | PollenDataUpdateCoordinator(hass, api=api, config_entry=config_entry) 44 | ) 45 | 46 | await coordinator.async_config_entry_first_refresh() 47 | 48 | await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) 49 | config_entry.async_on_unload(config_entry.add_update_listener(async_reload_entry)) 50 | 51 | return True 52 | 53 | 54 | async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: 55 | """Unload a config entry.""" 56 | if unload_ok := await hass.config_entries.async_unload_platforms( 57 | config_entry, PLATFORMS 58 | ): 59 | hass.data[DOMAIN].pop(config_entry.entry_id) 60 | return unload_ok 61 | 62 | 63 | async def async_reload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> None: 64 | """Reload config entry.""" 65 | await hass.config_entries.async_reload(config_entry.entry_id) 66 | -------------------------------------------------------------------------------- /custom_components/kleenex_pollenradar/coordinator.py: -------------------------------------------------------------------------------- 1 | """Sensor platform for the Pollenradar integration.""" 2 | 3 | from datetime import datetime, timedelta 4 | from zoneinfo import ZoneInfo 5 | import logging 6 | import asyncio 7 | from homeassistant import config_entries 8 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator 9 | from homeassistant.core import HomeAssistant 10 | from .api import PollenApi 11 | from .const import ( 12 | DEFAULT_SYNC_INTERVAL, 13 | DOMAIN, 14 | RETRY_ATTEMPTS, 15 | ) 16 | 17 | _LOGGER: logging.Logger = logging.getLogger(__package__) 18 | 19 | 20 | class PollenDataUpdateCoordinator(DataUpdateCoordinator): 21 | """Class to manage fetching data from the API.""" 22 | 23 | def __init__( 24 | self, 25 | hass: HomeAssistant, 26 | api: PollenApi, 27 | config_entry: config_entries.ConfigEntry | None, 28 | ) -> None: 29 | """Initialize.""" 30 | self.api = api 31 | self.platforms: list[str] = [] 32 | self._hass = hass 33 | 34 | super().__init__( 35 | hass, 36 | _LOGGER, 37 | name=DOMAIN, 38 | update_interval=timedelta(seconds=DEFAULT_SYNC_INTERVAL), 39 | config_entry=config_entry, 40 | ) 41 | 42 | async def _async_update_data(self): 43 | """Update data via library.""" 44 | error = "" 45 | for attempt in range(1, RETRY_ATTEMPTS): 46 | try: 47 | data = await self.api.async_get_data() 48 | pollen = data.get("pollen", {}) 49 | location = data.get("location", {}) 50 | raw = data.get("raw", {}) 51 | last_updated = datetime.now().replace( 52 | tzinfo=ZoneInfo(self._hass.config.time_zone) 53 | ) 54 | return { 55 | "pollen": pollen, 56 | "city": location.get("city"), 57 | "latitude": location.get("latitude"), 58 | "longitude": location.get("longitude"), 59 | "raw": raw, 60 | "last_updated": last_updated, 61 | "error": "", 62 | } 63 | except Exception as e: 64 | error = str(e) 65 | await asyncio.sleep(attempt * 2) 66 | 67 | _LOGGER.warning("Warning: All %d attempts to get data failed", RETRY_ATTEMPTS) 68 | return (self.data or {}) | {"error": error} 69 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![GitHub Release][releases-shield]][releases] 2 | [![GitHub Activity][commits-shield]][commits] 3 | ![Install Stats][stats] 4 | 5 | ![Project Maintenance][maintenance-shield] 6 | [![Community Forum][forum-shield]][forum] 7 | 8 | # Kleenex pollen radar / Scottex 9 | 10 | This is a custom integration of the Kleenex pollen radar/Scottex. It will provide information about pollen counts and levels for trees, grass and weeds. Information will only be available for positions in the Netherlands, United Kingdom, France, Italy and United States of America as Kleenex/Scottex only provides information for these countries. 11 | 12 | ## Installation 13 | 14 | Via HACS: 15 | 16 | - Search for Kleenex Pollen Radar 17 | 18 | ## Setup 19 | 20 | During the setup of the integration a region, name, city or latitude and longitude needs to be provided. 21 | 22 | ## What to expect 23 | 24 | The following sensors will be registered: 25 | 26 | - Tree pollen 27 | - Tree pollen level 28 | - Grass pollen 29 | - Grass pollen level 30 | - Weeds pollen 31 | - Weeds pollen level 32 | - Date 33 | 34 | Next to these sensors, for every type of tree/grass/weeds pollen two sensors will be created, one for value and one for level. 35 | 36 | The tree/grass/weeds pollen sensors have additional attributes for the forecast for the upcoming 4 days. 37 | 38 | The sensor information is updated every hour although the values on the Kleenex pollen radar website are usually updated every 3 hours. 39 | 40 | Finally a diagnostic sensor called Last Updated (Pollen) which contains the date and time of the last update. 41 | 42 | ## Dashboard examples 43 | 44 | Ronald v/d Brink created nice dashboard examples related to this integration. Please take a look at [vdbrink] 45 | 46 | Also check out Kristians pollen prognosis card which supports the Kleenex pollen radar: [krissen] 47 | 48 | ## Trouble shooting 49 | 50 | If the Kleenex integration raises errors then first have a look at the Kleenex/Scottex website and check the pollen radar there: 51 | 52 | - For France: https://www.kleenex.fr/alertes-pollens 53 | - For Italy: https://www.it.scottex.com/allerta-pollini/previsioni-dei-pollini 54 | - For Netherlands: https://www.kleenex.nl/pollenradar 55 | - For UK: https://www.kleenex.co.uk/pollen-count 56 | - For USA: https://www.kleenex.com/en-us/pollen-count [^1] 57 | 58 | ## Disclaimer 59 | 60 | This Homeassistant integration uses an unofficial API client for the Kleenex/Scottex API and is not affiliated with, endorsed by, or associated with Kleenex/Scottex or any of its subsidiaries. 61 | 62 | Use this integratiion at your own risk. Kleemex/Scottex may update or modify its API without notice, which could render this integration inoperative or non-compliant. The maintainer of this project is not responsible for any misuse, legal implications, or damages arising from its use. 63 | 64 | ## Contributions 65 | * [hocuspocus69](https://github.com/hocuspocus69) → Added the Italian version. 66 | 67 | [commits-shield]: https://img.shields.io/github/commit-activity/y/MarcoGos/kleenex_pollenradar.svg?style=for-the-badge 68 | [commits]: https://github.com/MarcoGos/kleenex_pollenradar/commits/main 69 | [forum]: https://community.home-assistant.io/ 70 | [forum-shield]: https://img.shields.io/badge/community-forum-brightgreen.svg?style=for-the-badge 71 | [maintenance-shield]: https://img.shields.io/badge/maintainer-%40MarcoGos-blue.svg?style=for-the-badge 72 | [releases-shield]: https://img.shields.io/github/release/MarcoGos/kleenex_pollenradar.svg?style=for-the-badge 73 | [releases]: https://github.com/MarcoGos/kleenex_pollenradar/releases 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=$.kleenex_pollenradar.total&style=for-the-badge 75 | [vdbrink]: https://vdbrink.github.io/homeassistant/homeassistant_hacs_kleenex 76 | [krissen]: https://github.com/krissen/pollenprognos-card 77 | 78 | [^1]: The USA version of the Kleenex pollen radar uses a different version of the API, which is incompatible with the integration. This integration uses an older version of the Kleenex/Scottex API. As a result, the pollen values shown in Home Assistant may differ from those displayed on the official website. 79 | -------------------------------------------------------------------------------- /custom_components/kleenex_pollenradar/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Device is already configured", 5 | "reconfigure_successful": "Re-configuration was successful" 6 | }, 7 | "error": { 8 | "cannot_connect": "Failed to connect", 9 | "invalid_auth": "Whether there are problems with the Kleenex service or your location is not within the selected region", 10 | "unknown": "Unexpected error" 11 | }, 12 | "step": { 13 | "user": { 14 | "title": "Region and name", 15 | "description": "Select your region and provide a name for this instance", 16 | "data": { 17 | "region": "Region", 18 | "name": "Name" 19 | } 20 | }, 21 | "content_by": { 22 | "title": "Get information by", 23 | "description": "Select how you want to provide your location information", 24 | "data": { 25 | "get_content_by": "Get information by" 26 | } 27 | }, 28 | "lat_lng": { 29 | "title": "Latitude and Longitude", 30 | "description": "Provide latitude and longitude coordinates", 31 | "data": { 32 | "latitude": "Latitude", 33 | "longitude": "Longitude" 34 | } 35 | }, 36 | "city": { 37 | "title": "City or Postcode", 38 | "description": "Provide a city name or postcode", 39 | "data": { 40 | "city": "City or Postcode" 41 | } 42 | }, 43 | "final": { 44 | "title": "Validating", 45 | "description": "Please wait while we validate your information" 46 | }, 47 | "reconfigure": { 48 | "title": "Reconfigure", 49 | "description": "Modify your location information", 50 | "data": { 51 | "latitude": "Latitude", 52 | "longitude": "Longitude", 53 | "city": "City or Postcode" 54 | } 55 | } 56 | } 57 | }, 58 | "selector": { 59 | "region": { 60 | "options": { 61 | "fr": "France", 62 | "it": "Italy", 63 | "nl": "The Netherlands", 64 | "uk": "Great Britain", 65 | "us": "United States of America" 66 | } 67 | }, 68 | "get_content_by": { 69 | "options": { 70 | "city": "City or Postcode", 71 | "lat_lng": "Latitude and Longitude" 72 | } 73 | } 74 | }, 75 | "entity": { 76 | "sensor": { 77 | "trees": { 78 | "name": "Trees", 79 | "state_attributes": { 80 | "level": { 81 | "name": "Level", 82 | "state": { 83 | "low": "Low", 84 | "moderate": "Moderate", 85 | "high": "High", 86 | "very-high": "Very High" 87 | } 88 | }, 89 | "details": { 90 | "name": "Details" 91 | }, 92 | "forecast": { 93 | "name": "Forecast" 94 | } 95 | } 96 | }, 97 | "trees_level": { 98 | "name": "Trees Level", 99 | "state": { 100 | "low": "Low", 101 | "moderate": "Moderate", 102 | "high": "High", 103 | "very-high": "Very High" 104 | } 105 | }, 106 | "grass": { 107 | "name": "Grass", 108 | "state_attributes": { 109 | "level": { 110 | "name": "Level", 111 | "state": { 112 | "low": "Low", 113 | "moderate": "Moderate", 114 | "high": "High", 115 | "very-high": "Very High" 116 | } 117 | }, 118 | "details": { 119 | "name": "Details" 120 | }, 121 | "forecast": { 122 | "name": "Forecast" 123 | } 124 | } 125 | }, 126 | "grass_level": { 127 | "name": "Grass Level", 128 | "state": { 129 | "low": "Low", 130 | "moderate": "Moderate", 131 | "high": "High", 132 | "very-high": "Very High" 133 | } 134 | }, 135 | "weeds": { 136 | "name": "Weeds", 137 | "state_attributes": { 138 | "level": { 139 | "name": "Level", 140 | "state": { 141 | "low": "Low", 142 | "moderate": "Moderate", 143 | "high": "High", 144 | "very-high": "Very High" 145 | } 146 | }, 147 | "details": { 148 | "name": "Details" 149 | }, 150 | "forecast": { 151 | "name": "Forecast" 152 | } 153 | } 154 | }, 155 | "weeds_level": { 156 | "name": "Weeds Level", 157 | "state": { 158 | "low": "Low", 159 | "moderate": "Moderate", 160 | "high": "High", 161 | "very-high": "Very High" 162 | } 163 | }, 164 | "detail_value": { 165 | "name": "{name}" 166 | }, 167 | "detail_level": { 168 | "name": "{name} Level", 169 | "state": { 170 | "low": "Low", 171 | "moderate": "Moderate", 172 | "high": "High", 173 | "very-high": "Very High" 174 | } 175 | }, 176 | "date": { 177 | "name": "Date", 178 | "state_attributes": { 179 | "raw": { 180 | "name": "Raw" 181 | } 182 | } 183 | }, 184 | "region": { 185 | "name": "Region", 186 | "state": { 187 | "fr": "France", 188 | "it": "Italy", 189 | "nl": "Netherlands", 190 | "uk": "Great Britain", 191 | "us": "United States of America" 192 | } 193 | }, 194 | "latitude": { 195 | "name": "Latitude" 196 | }, 197 | "longitude": { 198 | "name": "Longitude" 199 | }, 200 | "city": { 201 | "name": "City/Postcode" 202 | }, 203 | "last_updated": { 204 | "name": "Last updated" 205 | }, 206 | "error": { 207 | "name": "Error" 208 | } 209 | } 210 | }, 211 | "exceptions": { 212 | "dns_error": { 213 | "message": "DNS error" 214 | } 215 | } 216 | } -------------------------------------------------------------------------------- /custom_components/kleenex_pollenradar/translations/nl.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Apparaat is al geconfigureerd", 5 | "reconfigure_successful": "Herconfiguratie is geslaagd" 6 | }, 7 | "error": { 8 | "cannot_connect": "Er zijn problemen met de verbinding", 9 | "invalid_auth": "Of er zijn problemen met de Kleenex service of je locatie bevindt zich niet binnen de geselecteerde regio.", 10 | "unknown": "Onverwachte foutmelding" 11 | }, 12 | "step": { 13 | "user": { 14 | "title": "Regio en naam", 15 | "description": "Selecteer je regio en geef een naam op voor deze instantie", 16 | "data": { 17 | "region": "Regio", 18 | "name": "Naam" 19 | } 20 | }, 21 | "content_by": { 22 | "title": "Informatie verkrijgen via", 23 | "description": "Selecteer hoe je je locatiegegevens wilt opgeven", 24 | "data": { 25 | "get_content_by": "Informatie verkrijgen via" 26 | } 27 | }, 28 | "lat_lng": { 29 | "title": "Breedte- en lengtegraad", 30 | "description": "Geef breedte- en lengtegraad coördinaten op", 31 | "data": { 32 | "latitude": "Breedtegraad", 33 | "longitude": "Lengtegraad" 34 | } 35 | }, 36 | "city": { 37 | "title": "Plaats of postcode", 38 | "description": "Geef een plaatsnaam of postcode op", 39 | "data": { 40 | "city": "Plaats of postcode" 41 | } 42 | }, 43 | "final": { 44 | "title": "Valideren", 45 | "description": "Even geduld terwijl we je gegevens valideren" 46 | }, 47 | "reconfigure": { 48 | "title": "Herconfigureren", 49 | "description": "Pas je locatiegegevens aan", 50 | "data": { 51 | "latitude": "Breedtegraad", 52 | "longitude": "Lengtegraad", 53 | "city": "Plaats of postcode" 54 | } 55 | } 56 | } 57 | }, 58 | "selector": { 59 | "region": { 60 | "options": { 61 | "fr": "Frankrijk", 62 | "it": "Italië", 63 | "nl": "Nederland", 64 | "uk": "Groot-Brittannië", 65 | "us": "Verenigde Staten van Amerika" 66 | } 67 | }, 68 | "get_content_by": { 69 | "options": { 70 | "city": "Plaats", 71 | "lat_lng": "Breedte- en lengtegraad" 72 | } 73 | } 74 | }, 75 | "entity": { 76 | "sensor": { 77 | "trees": { 78 | "name": "Bomen", 79 | "state_attributes": { 80 | "level": { 81 | "name": "Niveau", 82 | "state": { 83 | "low": "Laag", 84 | "moderate": "Gemiddeld", 85 | "high": "Hoog", 86 | "very-high": "Zeer hoog" 87 | } 88 | }, 89 | "details": { 90 | "name": "Details" 91 | }, 92 | "forecast": { 93 | "name": "Verwachting" 94 | } 95 | } 96 | }, 97 | "trees_level": { 98 | "name": "Bomen niveau", 99 | "state": { 100 | "low": "Laag", 101 | "moderate": "Gemiddeld", 102 | "high": "Hoog", 103 | "very-high": "Zeer hoog" 104 | } 105 | }, 106 | "grass": { 107 | "name": "Gras", 108 | "state_attributes": { 109 | "level": { 110 | "name": "Niveau", 111 | "state": { 112 | "low": "Laag", 113 | "moderate": "Gemiddeld", 114 | "high": "Hoog", 115 | "very-high": "Zeer hoog" 116 | } 117 | }, 118 | "details": { 119 | "name": "Details" 120 | }, 121 | "forecast": { 122 | "name": "Verwachting" 123 | } 124 | } 125 | }, 126 | "grass_level": { 127 | "name": "Gras niveau", 128 | "state": { 129 | "low": "Laag", 130 | "moderate": "Gemiddeld", 131 | "high": "Hoog", 132 | "very-high": "Zeer hoog" 133 | } 134 | }, 135 | "weeds": { 136 | "name": "Kruiden", 137 | "state_attributes": { 138 | "level": { 139 | "name": "Niveau", 140 | "state": { 141 | "low": "Laag", 142 | "moderate": "Gemiddeld", 143 | "high": "Hoog", 144 | "very-high": "Zeer hoog" 145 | } 146 | }, 147 | "details": { 148 | "name": "Details" 149 | }, 150 | "forecast": { 151 | "name": "Verwachting" 152 | } 153 | } 154 | }, 155 | "weeds_level": { 156 | "name": "Kruiden niveau", 157 | "state": { 158 | "low": "Laag", 159 | "moderate": "Gemiddeld", 160 | "high": "Hoog", 161 | "very-high": "Zeer hoog" 162 | } 163 | }, 164 | "detail_value": { 165 | "name": "{name}" 166 | }, 167 | "detail_level": { 168 | "name": "{name} niveau", 169 | "state": { 170 | "low": "Laag", 171 | "moderate": "Gemiddeld", 172 | "high": "Hoog", 173 | "very-high": "Zeer hoog" 174 | } 175 | }, 176 | "date": { 177 | "name": "Datum", 178 | "state_attributes": { 179 | "raw": { 180 | "name": "Ruw" 181 | } 182 | } 183 | }, 184 | "region": { 185 | "name": "Regio", 186 | "state": { 187 | "fr": "Frankrijk", 188 | "it": "Italië", 189 | "nl": "Nederland", 190 | "uk": "Groot-Brittannië", 191 | "us": "Verenigde Staten" 192 | } 193 | }, 194 | "latitude": { 195 | "name": "Breedtegraad" 196 | }, 197 | "longitude": { 198 | "name": "Lengtegraad" 199 | }, 200 | "city": { 201 | "name": "Plaats/Postcode" 202 | }, 203 | "last_updated": { 204 | "name": "Laatst bijgewerkt" 205 | }, 206 | "error": { 207 | "name": "Fout" 208 | } 209 | } 210 | }, 211 | "exceptions": { 212 | "dns_error": { 213 | "message": "DNS fout" 214 | } 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /custom_components/kleenex_pollenradar/translations/it.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "E' già configurato", 5 | "reconfigure_successful": "La riconfigurazione è stata completata con successo" 6 | }, 7 | "error": { 8 | "cannot_connect": "non riesco a connettermi", 9 | "invalid_auth": "Che ci siano problemi con il Kleenex servizio o che la tua posizione non sia nella regione selezionata.", 10 | "unknown": "Errore sconosciuto" 11 | }, 12 | "step": { 13 | "user": { 14 | "title": "Regione e nome", 15 | "description": "Seleziona la tua regione e fornisci un nome per questa istanza", 16 | "data": { 17 | "region": "Regione", 18 | "name": "Nome" 19 | } 20 | }, 21 | "content_by": { 22 | "title": "Ottieni informazioni tramite", 23 | "description": "Seleziona come desideri fornire le informazioni sulla tua posizione", 24 | "data": { 25 | "get_content_by": "Ottieni informazioni tramite" 26 | } 27 | }, 28 | "lat_lng": { 29 | "title": "Latitudine e Longitudine", 30 | "description": "Fornisci le coordinate di latitudine e longitudine", 31 | "data": { 32 | "latitude": "Latitudine", 33 | "longitude": "Longitudine" 34 | } 35 | }, 36 | "city": { 37 | "title": "Città o CAP", 38 | "description": "Fornisci un nome di città o un codice postale", 39 | "data": { 40 | "city": "Città o CAP" 41 | } 42 | }, 43 | "final": { 44 | "title": "Convalida", 45 | "description": "Per favore aspetta mentre convalidiamo le tue informazioni" 46 | }, 47 | "reconfigure": { 48 | "title": "Riconfigurare", 49 | "description": "Modifica le informazioni sulla tua posizione", 50 | "data": { 51 | "latitude": "Latitudine", 52 | "longitude": "Longitudine", 53 | "city": "Città o CAP" 54 | } 55 | } 56 | } 57 | }, 58 | "selector": { 59 | "region": { 60 | "options": { 61 | "fr": "Francia", 62 | "it": "Italia", 63 | "nl": "Paesi Bassi", 64 | "uk": "Gran Bretagna", 65 | "us": "Stati Uniti" 66 | } 67 | }, 68 | "get_content_by": { 69 | "options": { 70 | "city": "Città", 71 | "lat_lng": "Latitudine e Longitudine" 72 | } 73 | } 74 | }, 75 | "entity": { 76 | "sensor": { 77 | "trees": { 78 | "name": "Alberi", 79 | "state_attributes": { 80 | "level": { 81 | "name": "Livello", 82 | "state": { 83 | "low": "Basso", 84 | "moderate": "Moderato", 85 | "high": "Alto", 86 | "very-high": "Molto alto" 87 | } 88 | }, 89 | "details": { 90 | "name": "Dettagli" 91 | }, 92 | "forecast": { 93 | "name": "Previsione" 94 | } 95 | } 96 | }, 97 | "trees_level": { 98 | "name": "Alberi livello", 99 | "state": { 100 | "low": "Basso", 101 | "moderate": "Moderato", 102 | "high": "Alto", 103 | "very-high": "Molto alto" 104 | } 105 | }, 106 | "grass": { 107 | "name": "Graminacee", 108 | "state_attributes": { 109 | "level": { 110 | "name": "Livello", 111 | "state": { 112 | "low": "Basso", 113 | "moderate": "Moderato", 114 | "high": "Alto", 115 | "very-high": "Molto alto" 116 | } 117 | }, 118 | "details": { 119 | "name": "Dettagli" 120 | }, 121 | "forecast": { 122 | "name": "Previsione" 123 | } 124 | } 125 | }, 126 | "grass_level": { 127 | "name": "Graminacee livello", 128 | "state": { 129 | "low": "Basso", 130 | "moderate": "Moderato", 131 | "high": "Alto", 132 | "very-high": "Molto alto" 133 | } 134 | }, 135 | "weeds": { 136 | "name": "Erba", 137 | "state_attributes": { 138 | "level": { 139 | "name": "Livello", 140 | "state": { 141 | "low": "Basso", 142 | "moderate": "Moderato", 143 | "high": "Alto", 144 | "very-high": "Molto alto" 145 | } 146 | }, 147 | "details": { 148 | "name": "Dettagli" 149 | }, 150 | "forecast": { 151 | "name": "Previsione" 152 | } 153 | } 154 | }, 155 | "weeds_level": { 156 | "name": "Erba livello", 157 | "state": { 158 | "low": "Basso", 159 | "moderate": "Moderato", 160 | "high": "Alto", 161 | "very-high": "Molto alto" 162 | } 163 | }, 164 | "detail_value": { 165 | "name": "{name}" 166 | }, 167 | "detail_level": { 168 | "name": "{name} livello", 169 | "state": { 170 | "low": "Basso", 171 | "moderate": "Moderato", 172 | "high": "Alto", 173 | "very-high": "Molto alto" 174 | } 175 | }, 176 | "date": { 177 | "name": "Data", 178 | "state_attributes": { 179 | "raw": { 180 | "name": "Crudo" 181 | } 182 | } 183 | }, 184 | "region": { 185 | "name": "Regione", 186 | "state": { 187 | "fr": "Francia", 188 | "it": "Italia", 189 | "nl": "Paesi Bassi", 190 | "uk": "Gran Bretagna", 191 | "us": "Stati Uniti" 192 | } 193 | }, 194 | "latitude": { 195 | "name": "Latitudine" 196 | }, 197 | "longitude": { 198 | "name": "Longitudine" 199 | }, 200 | "city": { 201 | "name": "Città/CAP" 202 | }, 203 | "last_updated": { 204 | "name": "Ultimo aggiornamento" 205 | }, 206 | "error": { 207 | "name": "Errore" 208 | } 209 | } 210 | }, 211 | "exceptions": { 212 | "dns_error": { 213 | "message": "DNS errore" 214 | } 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /custom_components/kleenex_pollenradar/translations/fr.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "L'appareil est déjà configuré", 5 | "reconfigure_successful": "La reconfiguration a été réussie" 6 | }, 7 | "error": { 8 | "cannot_connect": "Échec de la connexionFailed to connect", 9 | "invalid_auth": "Que ce soit un problème avec le Kleenex service ou que votre emplacement ne soit pas dans la région sélectionnée.", 10 | "unknown": "Erreur inattendue" 11 | }, 12 | "step": { 13 | "user": { 14 | "title": "Région et nom", 15 | "description": "Sélectionnez votre région et fournissez un nom pour cette instance", 16 | "data": { 17 | "region": "Région", 18 | "name": "Nom" 19 | } 20 | }, 21 | "content_by": { 22 | "title": "Obtenir des informations par", 23 | "description": "Sélectionnez comment vous souhaitez fournir vos informations de localisation", 24 | "data": { 25 | "get_content_by": "Obtenir des informations par" 26 | } 27 | }, 28 | "lat_lng": { 29 | "title": "Latitude et Longitude", 30 | "description": "Fournir les coordonnées de latitude et de longitude", 31 | "data": { 32 | "latitude": "Latitude", 33 | "longitude": "Longitude" 34 | } 35 | }, 36 | "city": { 37 | "title": "Ville ou Code Postal", 38 | "description": "Fournir un nom de ville ou un code postal", 39 | "data": { 40 | "city": "Ville ou Code Postal" 41 | } 42 | }, 43 | "final": { 44 | "title": "Validation", 45 | "description": "Veuillez patienter pendant que nous validons vos informations" 46 | }, 47 | "reconfigure": { 48 | "title": "Reconfigurer", 49 | "description": "Modifier les informations de votre emplacement", 50 | "data": { 51 | "latitude": "Latitude", 52 | "longitude": "Longitude", 53 | "city": "Ville ou Code Postal" 54 | } 55 | } 56 | } 57 | }, 58 | "selector": { 59 | "region": { 60 | "options": { 61 | "fr": "France", 62 | "it": "Italie", 63 | "nl": "Pays-Bas", 64 | "uk": "Grande-Bretagne", 65 | "us": "États-Unis" 66 | } 67 | }, 68 | "get_content_by": { 69 | "options": { 70 | "city": "Ville", 71 | "lat_lng": "Latitude et Longitude" 72 | } 73 | } 74 | }, 75 | "entity": { 76 | "sensor": { 77 | "trees": { 78 | "name": "Arbres", 79 | "state_attributes": { 80 | "level": { 81 | "name": "Niveau", 82 | "state": { 83 | "low": "Faible", 84 | "moderate": "Modéré", 85 | "high": "Élevé", 86 | "very-high": "Très élevé" 87 | } 88 | }, 89 | "details": { 90 | "name": "Détails" 91 | }, 92 | "forecast": { 93 | "name": "Prévision" 94 | } 95 | } 96 | }, 97 | "trees_level": { 98 | "name": "Arbres niveau", 99 | "state": { 100 | "low": "Faible", 101 | "moderate": "Modéré", 102 | "high": "Élevé", 103 | "very-high": "Très élevé" 104 | } 105 | }, 106 | "grass": { 107 | "name": "Graminées", 108 | "state_attributes": { 109 | "level": { 110 | "name": "Niveau", 111 | "state": { 112 | "low": "Faible", 113 | "moderate": "Modéré", 114 | "high": "Élevé", 115 | "very-high": "Très élevé" 116 | } 117 | }, 118 | "details": { 119 | "name": "Détails" 120 | }, 121 | "forecast": { 122 | "name": "Prévision" 123 | } 124 | } 125 | }, 126 | "grass_level": { 127 | "name": "Graminées niveau", 128 | "state": { 129 | "low": "Faible", 130 | "moderate": "Modéré", 131 | "high": "Élevé", 132 | "very-high": "Très élevé" 133 | } 134 | }, 135 | "weeds": { 136 | "name": "Herbacées", 137 | "state_attributes": { 138 | "level": { 139 | "name": "Niveau", 140 | "state": { 141 | "low": "Faible", 142 | "moderate": "Modéré", 143 | "high": "Élevé", 144 | "very-high": "Très élevé" 145 | } 146 | }, 147 | "details": { 148 | "name": "Détails" 149 | }, 150 | "forecast": { 151 | "name": "Prévision" 152 | } 153 | } 154 | }, 155 | "weeds_level": { 156 | "name": "Herbacées niveau", 157 | "state": { 158 | "low": "Faible", 159 | "moderate": "Modéré", 160 | "high": "Élevé", 161 | "very-high": "Très élevé" 162 | } 163 | }, 164 | "detail_value": { 165 | "name": "{name}" 166 | }, 167 | "detail_level": { 168 | "name": "{name} niveau", 169 | "state": { 170 | "low": "Faible", 171 | "moderate": "Modéré", 172 | "high": "Élevé", 173 | "very-high": "Très élevé" 174 | } 175 | }, 176 | "date": { 177 | "name": "Date", 178 | "state_attributes": { 179 | "raw": { 180 | "name": "Brut" 181 | } 182 | } 183 | }, 184 | "region": { 185 | "name": "Région", 186 | "state": { 187 | "fr": "France", 188 | "it": "Italie", 189 | "nl": "Pays-Bas", 190 | "uk": "Grande-Bretagne", 191 | "us": "États-Unis" 192 | } 193 | }, 194 | "latitude": { 195 | "name": "Latitude" 196 | }, 197 | "longitude": { 198 | "name": "Longitude" 199 | }, 200 | "city": { 201 | "name": "Ville/Code Postal" 202 | }, 203 | "last_updated": { 204 | "name": "Dernière mise à jour" 205 | }, 206 | "error": { 207 | "name": "Erreur" 208 | } 209 | } 210 | }, 211 | "exceptions": { 212 | "dns_error": { 213 | "message": "DNS erreurr" 214 | } 215 | } 216 | } -------------------------------------------------------------------------------- /custom_components/kleenex_pollenradar/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for Kleenex pollen integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import Any 7 | 8 | import voluptuous as vol 9 | 10 | from homeassistant import config_entries 11 | from homeassistant.config_entries import ConfigFlowResult 12 | from homeassistant.exceptions import HomeAssistantError 13 | from homeassistant.helpers import config_validation, device_registry as dr 14 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 15 | from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE 16 | 17 | from .const import ( 18 | DOMAIN, 19 | REGIONS, 20 | CONF_GET_CONTENT_BY, 21 | CONF_REGION, 22 | CONF_NAME, 23 | CONF_CITY, 24 | GetContentBy, 25 | Regions, 26 | ) 27 | from .api import PollenApi, DNSError 28 | 29 | _LOGGER = logging.getLogger(__name__) 30 | 31 | 32 | class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 33 | """Handle a config flow for Kleenex pollen.""" 34 | 35 | VERSION = 1 36 | region: str = "" 37 | name: str = "" 38 | get_content_by: GetContentBy = GetContentBy.LAT_LNG 39 | city: str = "" 40 | latitude: float = 0.0 41 | longitude: float = 0.0 42 | 43 | async def async_step_user( 44 | self, user_input: dict[str, Any] | None = None 45 | ) -> ConfigFlowResult: 46 | """Handle the initial step.""" 47 | errors = {} 48 | 49 | if user_input is not None: 50 | self.region = user_input[CONF_REGION] 51 | self.name = user_input[CONF_NAME] 52 | if self.region == Regions.ITALY.value: 53 | self.get_content_by = GetContentBy.CITY_ITALY 54 | return await self.async_step_city() 55 | else: 56 | return await self.async_step_content_by() 57 | 58 | data_schema = vol.Schema( 59 | { 60 | vol.Required(CONF_REGION): vol.In( 61 | {key: details["name"] for key, details in REGIONS.items()} 62 | ), 63 | vol.Required(CONF_NAME, default=self.hass.config.location_name): str, 64 | } 65 | ) 66 | 67 | return self.async_show_form( 68 | step_id="user", data_schema=data_schema, errors=errors 69 | ) 70 | 71 | async def async_step_content_by( 72 | self, user_input: dict[str, Any] | None = None 73 | ) -> ConfigFlowResult: 74 | """Handle the content by step.""" 75 | if user_input is not None: 76 | self.get_content_by = GetContentBy(user_input[CONF_GET_CONTENT_BY]) 77 | if self.get_content_by == GetContentBy.CITY: 78 | return await self.async_step_city() 79 | else: 80 | return await self.async_step_lat_lng() 81 | 82 | data_schema = vol.Schema( 83 | { 84 | vol.Required(CONF_GET_CONTENT_BY, default=GetContentBy.CITY): vol.In( 85 | { 86 | GetContentBy.CITY.value: "City", 87 | GetContentBy.LAT_LNG.value: "Latitude / Longitude", 88 | } 89 | ), 90 | } 91 | ) 92 | return self.async_show_form(step_id="content_by", data_schema=data_schema) 93 | 94 | async def async_step_lat_lng( 95 | self, user_input: dict[str, Any] | None = None 96 | ) -> ConfigFlowResult: 97 | """Handle the latitude / longitude step.""" 98 | if user_input is not None: 99 | self.latitude = user_input[CONF_LATITUDE] 100 | self.longitude = user_input[CONF_LONGITUDE] 101 | return await self.async_step_final() 102 | 103 | data_schema = vol.Schema( 104 | { 105 | vol.Required( 106 | CONF_LATITUDE, default=self.hass.config.latitude 107 | ): config_validation.latitude, 108 | vol.Required( 109 | CONF_LONGITUDE, default=self.hass.config.longitude 110 | ): config_validation.longitude, 111 | } 112 | ) 113 | return self.async_show_form(step_id="lat_lng", data_schema=data_schema) 114 | 115 | async def async_step_city( 116 | self, user_input: dict[str, Any] | None = None 117 | ) -> ConfigFlowResult: 118 | """Handle the city step.""" 119 | if user_input is not None: 120 | self.city = user_input[CONF_CITY] 121 | return await self.async_step_final() 122 | 123 | data_schema = vol.Schema({vol.Required(CONF_CITY, default=""): str}) 124 | 125 | return self.async_show_form(step_id="city", data_schema=data_schema) 126 | 127 | async def async_step_final( 128 | self, user_input: dict[str, Any] | None = None 129 | ) -> ConfigFlowResult: 130 | """Handle the final step.""" 131 | errors = {} 132 | 133 | if user_input is not None: 134 | await self.async_set_unique_id(self.name) 135 | self._abort_if_unique_id_configured() 136 | user_input[CONF_REGION] = self.region 137 | user_input[CONF_NAME] = self.name 138 | user_input[CONF_GET_CONTENT_BY] = self.get_content_by.value 139 | user_input[CONF_CITY] = self.city 140 | user_input[CONF_LATITUDE] = self.latitude 141 | user_input[CONF_LONGITUDE] = self.longitude 142 | try: 143 | session = async_get_clientsession(self.hass) 144 | api = PollenApi( 145 | session=session, 146 | region=self.region, 147 | get_content_by=self.get_content_by, 148 | latitude=self.latitude, 149 | longitude=self.longitude, 150 | city=self.city, 151 | ) 152 | data = await api.async_get_data() 153 | if not data or not data.get("pollen"): 154 | raise InvalidAuth 155 | except InvalidAuth: 156 | errors["base"] = "invalid_auth" 157 | except DNSError: 158 | errors["base"] = "cannot_connect" 159 | except Exception: # pylint: disable=broad-except 160 | _LOGGER.exception("Unexpected exception") 161 | errors["base"] = "unknown" 162 | else: 163 | return self.async_create_entry(title=self.name, data=user_input) 164 | 165 | data_schema = vol.Schema({}) 166 | 167 | return self.async_show_form( 168 | step_id="final", data_schema=data_schema, errors=errors, last_step=True 169 | ) 170 | 171 | async def async_step_reconfigure( 172 | self, user_input: dict[str, Any] | None = None 173 | ) -> ConfigFlowResult: 174 | """Handle a reconfiguration flow initialized by the user.""" 175 | errors: dict[str, str] | None = {} 176 | entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) # type: ignore 177 | 178 | if user_input is not None and entry: 179 | try: 180 | session = async_get_clientsession(self.hass) 181 | api = PollenApi( 182 | session=session, 183 | region=entry.data[CONF_REGION], 184 | get_content_by=GetContentBy(entry.data[CONF_GET_CONTENT_BY]), 185 | latitude=user_input.get(CONF_LATITUDE, 0.0), 186 | longitude=user_input.get(CONF_LONGITUDE, 0.0), 187 | city=user_input.get(CONF_CITY, ""), 188 | ) 189 | data = await api.async_get_data() 190 | if not data or not data.get("pollen"): 191 | raise InvalidAuth 192 | except InvalidAuth: 193 | errors["base"] = "invalid_auth" 194 | except DNSError: 195 | errors["base"] = "cannot_connect" 196 | except Exception: # pylint: disable=broad-except 197 | _LOGGER.exception("Unexpected exception") 198 | errors["base"] = "unknown" 199 | else: 200 | if ( 201 | user_input.get(CONF_LATITUDE, 0.0) 202 | != entry.data.get(CONF_LATITUDE, 0.0) 203 | or user_input.get(CONF_LONGITUDE, 0.0) 204 | != entry.data.get(CONF_LONGITUDE, 0.0) 205 | or user_input.get(CONF_CITY, "") != entry.data.get(CONF_CITY, "") 206 | ): 207 | device_registry = dr.async_get(self.hass) 208 | device = device_registry.async_get_device( 209 | identifiers={ 210 | ( 211 | DOMAIN, 212 | f"{entry.data[CONF_NAME]}", 213 | ) 214 | } 215 | ) 216 | if device: 217 | device_registry.async_update_device( 218 | device_id=device.id, 219 | remove_config_entry_id=entry.entry_id, 220 | ) 221 | self.hass.config_entries.async_update_entry( 222 | entry, # type: ignore 223 | data=entry.data | user_input, # type: ignore 224 | title=entry.data[CONF_NAME], 225 | ) 226 | await self.hass.config_entries.async_reload(entry.entry_id) # type: ignore 227 | return self.async_abort(reason="reconfigure_successful") 228 | 229 | if ( 230 | entry 231 | and GetContentBy(entry.data.get(CONF_GET_CONTENT_BY, GetContentBy.LAT_LNG)) 232 | == GetContentBy.LAT_LNG 233 | ): 234 | data_schema = vol.Schema( 235 | { 236 | vol.Required( 237 | CONF_LATITUDE, default=self.hass.config.latitude 238 | ): config_validation.latitude, 239 | vol.Required( 240 | CONF_LONGITUDE, default=self.hass.config.longitude 241 | ): config_validation.longitude, 242 | } 243 | ) 244 | else: 245 | data_schema = vol.Schema( 246 | { 247 | vol.Required(CONF_CITY, ""): str, 248 | } 249 | ) 250 | 251 | return self.async_show_form( 252 | step_id="reconfigure", 253 | data_schema=self.add_suggested_values_to_schema( 254 | data_schema=data_schema, 255 | suggested_values=entry.data | (user_input or {}), # type: ignore 256 | ), 257 | description_placeholders={"name": entry.title}, # type: ignore 258 | errors=errors, 259 | ) 260 | 261 | 262 | class InvalidAuth(HomeAssistantError): 263 | """Error to indicate there is invalid auth.""" 264 | -------------------------------------------------------------------------------- /custom_components/kleenex_pollenradar/sensor.py: -------------------------------------------------------------------------------- 1 | """Sensor platform for the Pollenradar integration.""" 2 | 3 | import logging 4 | 5 | from collections.abc import Mapping 6 | from dataclasses import dataclass 7 | from typing import Any 8 | 9 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 10 | from homeassistant.config_entries import ConfigEntry 11 | from homeassistant.core import HomeAssistant 12 | from homeassistant.const import EntityCategory, CONF_LATITUDE, CONF_LONGITUDE 13 | from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo 14 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 15 | from homeassistant.helpers.typing import StateType 16 | from homeassistant.components.sensor import ( 17 | SensorEntity, 18 | SensorDeviceClass, 19 | SensorEntityDescription, 20 | ) 21 | from .coordinator import PollenDataUpdateCoordinator 22 | from .const import DOMAIN, NAME, MODEL, MANUFACTURER, CONF_NAME 23 | 24 | _LOGGER: logging.Logger = logging.getLogger(__package__) 25 | 26 | 27 | @dataclass(kw_only=True, frozen=True) 28 | class KleenexDetailSensorEntityDescription(SensorEntityDescription): 29 | """Describes Kleenex detail sensor entity.""" 30 | 31 | group: str | None = None 32 | pollen_type: str | None = None 33 | 34 | 35 | def get_sensor_descriptions() -> list[SensorEntityDescription]: 36 | """Return a list of sensor descriptions.""" 37 | level_options = ["low", "moderate", "high", "very-high"] 38 | descriptions: list[SensorEntityDescription] = [ 39 | *[ 40 | SensorEntityDescription( 41 | key=key, 42 | translation_key=key, 43 | icon=icon, 44 | state_class="measurement", 45 | native_unit_of_measurement="ppm", 46 | ) 47 | for key, icon in [ 48 | ("trees", "mdi:tree-outline"), 49 | ("grass", "mdi:grass"), 50 | ("weeds", "mdi:flower-pollen"), 51 | ] 52 | ], 53 | *[ 54 | SensorEntityDescription( 55 | key=key, 56 | translation_key=key, 57 | device_class=SensorDeviceClass.ENUM, 58 | options=level_options, 59 | ) 60 | for key in ["trees_level", "grass_level", "weeds_level"] 61 | ], 62 | SensorEntityDescription( 63 | key="date", 64 | translation_key="date", 65 | icon="mdi:calendar", 66 | device_class=SensorDeviceClass.DATE, 67 | entity_category=EntityCategory.DIAGNOSTIC, 68 | ), 69 | SensorEntityDescription( 70 | key="last_updated", 71 | translation_key="last_updated", 72 | icon="mdi:clock-outline", 73 | device_class=SensorDeviceClass.TIMESTAMP, 74 | entity_category=EntityCategory.DIAGNOSTIC, 75 | ), 76 | SensorEntityDescription( 77 | key="latitude", 78 | translation_key="latitude", 79 | icon="mdi:latitude", 80 | entity_category=EntityCategory.DIAGNOSTIC, 81 | entity_registry_enabled_default=False, 82 | ), 83 | SensorEntityDescription( 84 | key="longitude", 85 | translation_key="longitude", 86 | icon="mdi:longitude", 87 | entity_category=EntityCategory.DIAGNOSTIC, 88 | entity_registry_enabled_default=False, 89 | ), 90 | SensorEntityDescription( 91 | key="city", 92 | translation_key="city", 93 | icon="mdi:city", 94 | entity_category=EntityCategory.DIAGNOSTIC, 95 | entity_registry_enabled_default=False, 96 | ), 97 | SensorEntityDescription( 98 | key="region", 99 | translation_key="region", 100 | icon="mdi:earth", 101 | device_class=SensorDeviceClass.ENUM, 102 | entity_category=EntityCategory.DIAGNOSTIC, 103 | options=["fr", "it", "nl", "uk", "us"], 104 | entity_registry_enabled_default=False, 105 | ), 106 | SensorEntityDescription( 107 | key="error", 108 | translation_key="error", 109 | icon="mdi:alert-circle-outline", 110 | entity_category=EntityCategory.DIAGNOSTIC, 111 | entity_registry_enabled_default=False, 112 | ), 113 | ] 114 | return descriptions 115 | 116 | 117 | def get_detail_sensor_descriptions( 118 | pollen: list[dict[str, Any]], 119 | ) -> list[KleenexDetailSensorEntityDescription]: 120 | """Return a list of detail sensor descriptions.""" 121 | descriptions: list[KleenexDetailSensorEntityDescription] = [] 122 | if not pollen: 123 | return descriptions 124 | current = pollen[0] 125 | for group, icon in [ 126 | ("trees_details", "mdi:tree-outline"), 127 | ("grass_details", "mdi:grass"), 128 | ("weeds_details", "mdi:flower-pollen"), 129 | ]: 130 | for details in current.get(group, []): 131 | for key, translation_key, extra in [ 132 | ( 133 | "value", 134 | "detail_value", 135 | { 136 | "icon": icon, 137 | "state_class": "measurement", 138 | "native_unit_of_measurement": "ppm", 139 | }, 140 | ), 141 | ( 142 | "level", 143 | "detail_level", 144 | { 145 | "device_class": SensorDeviceClass.ENUM, 146 | "options": ["low", "moderate", "high", "very-high"], 147 | }, 148 | ), 149 | ]: 150 | descriptions.append( 151 | KleenexDetailSensorEntityDescription( 152 | key=key, 153 | pollen_type=details["name"], 154 | translation_key=translation_key, 155 | translation_placeholders={"name": details["name"]}, 156 | group=group, 157 | entity_registry_enabled_default=False, 158 | **extra, 159 | ) 160 | ) 161 | return descriptions 162 | 163 | 164 | async def async_setup_entry( 165 | hass: HomeAssistant, 166 | config_entry: ConfigEntry, 167 | async_add_entities: AddEntitiesCallback, 168 | ) -> None: 169 | """Set up the sensor platform.""" 170 | coordinator: PollenDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] 171 | pollen = coordinator.data.get("pollen", {}) 172 | 173 | name = config_entry.data.get(CONF_NAME) 174 | 175 | device_info = DeviceInfo( 176 | entry_type=DeviceEntryType.SERVICE, 177 | identifiers={(DOMAIN, f"{name}")}, 178 | name=f"{NAME} ({name})", 179 | model=MODEL, 180 | manufacturer=MANUFACTURER, 181 | ) 182 | 183 | entities = ( 184 | [ 185 | KleenexSensor( 186 | coordinator=coordinator, 187 | entry_id=config_entry.entry_id, 188 | description=description, 189 | config_entry=config_entry, 190 | device_info=device_info, 191 | ) 192 | for description in get_sensor_descriptions() 193 | ] 194 | + [ 195 | KleenexDetailSensor( 196 | coordinator=coordinator, 197 | entry_id=config_entry.entry_id, 198 | description=description, 199 | config_entry=config_entry, 200 | device_info=device_info, 201 | ) 202 | for description in get_detail_sensor_descriptions(pollen) 203 | ] 204 | if pollen 205 | else [] 206 | ) 207 | 208 | async_add_entities(entities) 209 | 210 | 211 | class KleenexSensor(CoordinatorEntity[PollenDataUpdateCoordinator], SensorEntity): 212 | """Representation of a sensor.""" 213 | 214 | _attr_has_entity_name = True 215 | 216 | def __init__( 217 | self, 218 | coordinator: PollenDataUpdateCoordinator, 219 | entry_id: str, 220 | description: SensorEntityDescription, 221 | config_entry: ConfigEntry, 222 | device_info: DeviceInfo, 223 | ) -> None: 224 | super().__init__(coordinator) 225 | self._config_entry = config_entry 226 | self._attr_unique_id = f"{entry_id}-{NAME}{description.key}" 227 | self._attr_device_info = device_info 228 | self.entity_description = description 229 | 230 | @property 231 | def native_value(self) -> StateType: 232 | """Return the state of the sensor.""" 233 | key = self.entity_description.key 234 | pollen = self.coordinator.data.get("pollen", {}) 235 | current = pollen[0] if pollen else {} 236 | 237 | value = current.get(key) 238 | if value is not None: 239 | return value 240 | 241 | value = self.coordinator.data.get(key) 242 | if value is not None: 243 | return value 244 | 245 | return self._config_entry.data.get(key) 246 | 247 | @property 248 | def extra_state_attributes(self) -> Mapping[str, Any] | None: 249 | """Return the state attributes of the sensor.""" 250 | key = self.entity_description.key 251 | # if key == "date": 252 | # return {"raw": self.coordinator.data.get("raw")} 253 | if key not in {"trees", "grass", "weeds"}: 254 | return None 255 | 256 | pollen = self.coordinator.data.get("pollen", {}) 257 | if not pollen: 258 | return None 259 | 260 | current = pollen[0] 261 | data = { 262 | "level": current.get(f"{key}_level"), 263 | "details": current.get(f"{key}_details"), 264 | } 265 | 266 | mapping = { 267 | key: "value", 268 | f"{key}_level": "level", 269 | f"{key}_details": "details", 270 | } 271 | 272 | data["forecast"] = [ 273 | { 274 | mapping.get(data_key, data_key): day.get(data_key) 275 | for data_key in ["date", key, f"{key}_level", f"{key}_details"] 276 | } 277 | for day in pollen[1:] 278 | ] 279 | return data 280 | 281 | 282 | class KleenexDetailSensor(CoordinatorEntity[PollenDataUpdateCoordinator], SensorEntity): 283 | """Representation of a detail sensor.""" 284 | 285 | _attr_has_entity_name = True 286 | 287 | entity_description: KleenexDetailSensorEntityDescription 288 | 289 | def __init__( 290 | self, 291 | coordinator: PollenDataUpdateCoordinator, 292 | entry_id: str, 293 | description: KleenexDetailSensorEntityDescription, 294 | config_entry: ConfigEntry, 295 | device_info: DeviceInfo, 296 | ) -> None: 297 | super().__init__(coordinator) 298 | self._config_entry = config_entry 299 | self._attr_unique_id = f"{entry_id}-{NAME}{description.group}-{description.pollen_type}-{description.key}" 300 | self._attr_device_info = device_info 301 | self.entity_description = description 302 | 303 | @property 304 | def native_value(self) -> StateType: 305 | """Return the state of the detail sensor.""" 306 | pollen = self.coordinator.data.get("pollen", {}) 307 | if not pollen: 308 | return None 309 | key = self.entity_description.key 310 | pollen_type = self.entity_description.pollen_type 311 | group = self.entity_description.group 312 | return self.__get_detail_value(pollen, 0, group, pollen_type, key) 313 | 314 | @property 315 | def extra_state_attributes(self) -> Mapping[str, Any] | None: 316 | """Return the state attributes of the detail sensor.""" 317 | pollen = self.coordinator.data.get("pollen", {}) 318 | if not pollen: 319 | return None 320 | 321 | key = self.entity_description.key 322 | pollen_type = self.entity_description.pollen_type 323 | group = self.entity_description.group 324 | 325 | forecast = [ 326 | { 327 | "date": pollen[day_offset]["date"], 328 | key: self.__get_detail_value( 329 | pollen, day_offset, group, pollen_type, key 330 | ), 331 | } 332 | for day_offset in range(1, len(pollen)) 333 | ] 334 | 335 | return {"forecast": forecast} 336 | 337 | def __get_detail_value( 338 | self, 339 | pollen: list[dict[str, Any]], 340 | day_offset: int, 341 | group: str | None, 342 | pollen_type: str | None, 343 | key: str, 344 | ) -> Any: 345 | details = pollen[day_offset].get(group, []) if group else [] 346 | detail = next((item for item in details if item["name"] == pollen_type), None) 347 | if detail is None: 348 | return None 349 | return detail.get(key) 350 | -------------------------------------------------------------------------------- /custom_components/kleenex_pollenradar/api.py: -------------------------------------------------------------------------------- 1 | """Kleenex API""" 2 | 3 | from typing import Any 4 | import logging 5 | 6 | from datetime import datetime, date 7 | import aiohttp 8 | import async_timeout 9 | 10 | from homeassistant.exceptions import HomeAssistantError 11 | 12 | from bs4 import BeautifulSoup, Tag 13 | from .const import DOMAIN, REGIONS, GetContentBy, METHODS 14 | 15 | TIMEOUT = 10 16 | 17 | _LOGGER: logging.Logger = logging.getLogger(__package__) 18 | 19 | 20 | class PollenApi: 21 | """Pollenradar API.""" 22 | 23 | _headers: dict[str, str] = { 24 | "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.3", 25 | "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", 26 | } 27 | _raw_data: Any = "" 28 | _pollen: list[dict[str, Any]] = [] 29 | _pollen_types = ("trees", "weeds", "grass") 30 | _pollen_detail_types: dict[str, str] = { 31 | "trees": "tree", 32 | "weeds": "weed", 33 | "grass": "grass", 34 | } 35 | _found_city: str = "" 36 | _found_latitude: float = 0.0 37 | _found_longitude: float = 0.0 38 | 39 | def __init__( 40 | self, 41 | session: aiohttp.ClientSession, 42 | region: str, 43 | get_content_by: GetContentBy, 44 | latitude: float = 0, 45 | longitude: float = 0, 46 | city: str = "", 47 | ) -> None: 48 | self._session = session 49 | self.region = region 50 | self.get_content_by = get_content_by 51 | self.latitude = latitude 52 | self.longitude = longitude 53 | self.city = city 54 | 55 | async def async_get_data(self) -> dict[str, Any]: 56 | """Get data from the API.""" 57 | await self.refresh_data() 58 | return { 59 | "pollen": self._pollen, 60 | "location": { 61 | "latitude": self._found_latitude, 62 | "longitude": self._found_longitude, 63 | "city": self._found_city, 64 | }, 65 | "raw": self._raw_data, 66 | } 67 | 68 | async def refresh_data(self): 69 | """Refresh data from the API.""" 70 | if (self.latitude != 0 and self.longitude != 0) or self.city: 71 | success = await self.__request_data() 72 | if success: 73 | if self.get_content_by == GetContentBy.CITY_ITALY: 74 | self.__decode_raw_data_italy() 75 | else: 76 | self.__decode_raw_data() 77 | 78 | async def __request_data(self) -> bool: 79 | """Request data from the API using latitude and longitude.""" 80 | if self.get_content_by == GetContentBy.LAT_LNG: 81 | params = {"lat": self.latitude, "lng": self.longitude} 82 | else: 83 | params = { 84 | "city" 85 | if self.get_content_by == GetContentBy.CITY 86 | else "location": self.city 87 | } 88 | url = self.__get_url_by_region() 89 | _LOGGER.debug("Requesting data from URL: %s with params: %s", url, params) 90 | data = await self.__perform_request(url, params) 91 | self._raw_data = data 92 | return data is not None 93 | 94 | def __get_url_by_region(self) -> str: 95 | """Get the URL for the API based on the region.""" 96 | return f"{REGIONS[self.region]['url']}{self.__get_url_page()}" 97 | 98 | def __get_url_page(self) -> str: 99 | """Get the URL for the page based on the region.""" 100 | return METHODS[self.get_content_by] 101 | 102 | async def __perform_request(self, url: str, params: Any) -> Any | None: 103 | """Perform the request to the API.""" 104 | try: 105 | async with async_timeout.timeout(TIMEOUT): 106 | if REGIONS[self.region]["method"] == "get": 107 | response = await self._session.get( 108 | url=url, params=params, headers=self._headers, ssl=False 109 | ) 110 | else: 111 | response = await self._session.post( 112 | url=url, data=params, headers=self._headers, ssl=False 113 | ) 114 | if response.status == 403: 115 | _LOGGER.error("Access forbidden: 403 error from server") 116 | return None 117 | if response.ok: 118 | if self.get_content_by == GetContentBy.CITY_ITALY: 119 | return await response.json() 120 | else: 121 | return await response.text() 122 | return None 123 | except aiohttp.ClientConnectorDNSError as e: 124 | raise DNSError( 125 | "dns_error", 126 | translation_domain=DOMAIN, 127 | translation_key="dns_error", 128 | ) from e 129 | except Exception as e: 130 | raise DNSError( 131 | "unknown_error", 132 | translation_domain=DOMAIN, 133 | translation_key="unknown_error", 134 | ) from e 135 | 136 | def __decode_raw_data(self): 137 | """Decode the raw data from the API.""" 138 | soup = BeautifulSoup(self._raw_data, "html.parser") 139 | 140 | self.__extract_location_data(soup) 141 | 142 | results = soup.find_all("button", class_="day-link") 143 | if results: 144 | self._pollen = [] 145 | tag_results = [el for el in results if isinstance(el, Tag)] 146 | for day in tag_results: 147 | day_no = int(day.select_one("span.day-number").contents[0]) # type: ignore 148 | pollen_date = self.__determine_pollen_date(day_no) 149 | pollen: dict[str, Any] = { 150 | "day": day_no, 151 | "date": pollen_date, 152 | } 153 | for pollen_type in self._pollen_types: 154 | count_unit = day.get(f"data-{pollen_type}-count", "0 PPM") 155 | try: 156 | pollen_count, unit_of_measure = count_unit.split(" ") # type: ignore 157 | pollen[pollen_type] = int(pollen_count) 158 | except (ValueError, AttributeError): 159 | pollen[pollen_type] = 0 160 | unit_of_measure = "ppm" 161 | pollen_level = day.get( 162 | f"data-{pollen_type}", "" 163 | ) or self.determine_level_by_count(pollen_type, pollen[pollen_type]) 164 | pollen[f"{pollen_type}_level"] = pollen_level 165 | pollen[f"{pollen_type}_unit_of_measure"] = unit_of_measure.lower() 166 | pollen[f"{pollen_type}_details"] = [] 167 | 168 | pollen_detail_type = self._pollen_detail_types[pollen_type] 169 | pollen_details_str = day.get(f"data-{pollen_detail_type}-detail", "") 170 | if pollen_details_str: 171 | for item in pollen_details_str.split("|"): # type: ignore 172 | sub_items = item.split(",") 173 | if len(sub_items) == 3: 174 | try: 175 | pollen_detail = { 176 | "name": sub_items[0], 177 | "value": int(sub_items[1]), 178 | "level": sub_items[2], 179 | } 180 | except ValueError: 181 | pollen_detail = { 182 | "name": sub_items[0], 183 | "value": 0, 184 | "level": sub_items[2], 185 | } 186 | pollen[f"{pollen_type}_details"].append(pollen_detail) 187 | self._pollen.append(pollen) 188 | 189 | def __decode_raw_data_italy(self): 190 | """Decode the raw data from the API for Italy.""" 191 | self.__extract_location_data_italy(self._raw_data.get("city", "")) 192 | data = self._raw_data.get("html", "") 193 | soup = BeautifulSoup(data, "html.parser") 194 | day_infos = soup.find_all("button", class_="day-wrapper") 195 | if day_infos: 196 | self._pollen = [] 197 | for day_info in day_infos: 198 | day_class = day_info.get("data-day-value", "day") 199 | day_no = int(day_info.find("span", "forecast-date").contents[0]) # type: ignore 200 | pollen_date = self.__determine_pollen_date(day_no) 201 | pollen: dict[str, Any] = { 202 | "day": day_no, 203 | "date": pollen_date, 204 | } 205 | pollen_infos = soup.find_all("button", class_=day_class) 206 | for pollen_info in pollen_infos: 207 | pollen_type = str(pollen_info.get("data-show", "")) 208 | if "-" in pollen_type: 209 | original_pollen_type = pollen_type.split("-")[0] 210 | pollen_type = original_pollen_type 211 | if pollen_type != "grass": 212 | pollen_type += "s" 213 | ppm_span = pollen_info.find("span", class_="number-text") 214 | if ppm_span: 215 | count_unit = ppm_span.text.strip() # type: ignore 216 | else: 217 | count_unit = "0 PPM" 218 | try: 219 | pollen_count, unit_of_measure = count_unit.split(" ") # type: ignore 220 | pollen[pollen_type] = int(pollen_count) 221 | except (ValueError, AttributeError): 222 | pollen[pollen_type] = 0 223 | unit_of_measure = "ppm" 224 | pollen_level = self.determine_level_by_count( 225 | pollen_type, pollen[pollen_type] 226 | ) 227 | pollen[f"{pollen_type}_level"] = pollen_level 228 | pollen[f"{pollen_type}_unit_of_measure"] = unit_of_measure.lower() 229 | pollen[f"{pollen_type}_details"] = [] 230 | pollen_analysis = soup.find( 231 | "div", 232 | class_=f"{original_pollen_type}-pollen-analysis-{day_class.replace('day', 'day-')}", 233 | ) 234 | if pollen_analysis: 235 | detail_infos = pollen_analysis.find_all( 236 | "div", class_="table-details" 237 | ) 238 | if detail_infos: 239 | for detail_info in detail_infos: 240 | name = ( 241 | detail_info.find("span", class_="name-text") 242 | .contents[0] 243 | .text 244 | ) 245 | value = ( 246 | detail_info.find("span", class_="quality-text") 247 | .contents[0] 248 | .text.split(" ") 249 | ) 250 | pollen_detail = { 251 | "name": name, 252 | "value": int(value[1]), 253 | "level": value[0], 254 | } 255 | pollen[f"{pollen_type}_details"].append(pollen_detail) 256 | 257 | self._pollen.append(pollen) 258 | 259 | def __extract_location_data(self, soup: BeautifulSoup) -> None: 260 | """Extract latitude and longitude from the soup.""" 261 | self._found_city = self.__get_location_str("cityName", soup) 262 | self._found_latitude = self.__get_location_float("pollenlat", soup) 263 | self._found_longitude = self.__get_location_float("pollenlng", soup) 264 | 265 | def __extract_location_data_italy(self, city_data: str) -> None: 266 | """Extract latitude and longitude from the city data.""" 267 | city_info = city_data.split("|") 268 | self._found_city = city_info[0] if len(city_info) > 0 else "" 269 | self._found_latitude = float(city_info[1]) if len(city_info) > 1 else 0.0 270 | self._found_longitude = float(city_info[2]) if len(city_info) > 2 else 0.0 271 | 272 | def __get_location_str(self, key: str, soup: BeautifulSoup) -> str: 273 | """Get a location value from the raw data.""" 274 | result = soup.find("input", id=key) 275 | return result.get("value", "") if result else "" # type: ignore 276 | 277 | def __get_location_float(self, key: str, soup: BeautifulSoup) -> float: 278 | """Get a location value from the raw data.""" 279 | result = soup.find("input", id=key) 280 | value = result.get("value", None) if result else None # type: ignore 281 | try: 282 | return float(value) 283 | except (TypeError, ValueError): 284 | return 0.0 285 | 286 | def __determine_pollen_date(self, day_no: int) -> date: 287 | """Determine the date of the pollen data.""" 288 | year = datetime.today().year 289 | month = datetime.today().month 290 | try: 291 | dt = datetime(year=year, month=month, day=day_no) 292 | invalid_date = False 293 | except ValueError: 294 | dt = datetime.today() 295 | invalid_date = True 296 | if dt.date() < datetime.today().date() or invalid_date: 297 | month += 1 298 | if month > 12: 299 | year += 1 300 | month = 1 301 | dt = datetime(year=year, month=month, day=day_no) 302 | return dt.date() 303 | 304 | @property 305 | def position(self) -> str: 306 | """Get the position of the pollen data.""" 307 | return f"{self.latitude}x{self.longitude}" 308 | 309 | def determine_level_by_count(self, pollen_type: str, pollen_count: int) -> str: 310 | """Determine the pollen level based on the count.""" 311 | thresholds = { 312 | "trees": [95, 207, 703], 313 | "weeds": [20, 77, 266], 314 | "grass": [29, 60, 341], 315 | } 316 | 317 | categories = ["low", "moderate", "high", "very-high"] 318 | 319 | for i, threshold in enumerate(thresholds.get(pollen_type, [])): 320 | if pollen_count <= threshold: 321 | return categories[i] 322 | 323 | return "very-high" 324 | 325 | 326 | class DNSError(HomeAssistantError): 327 | """Base class for Pollen API errors.""" 328 | --------------------------------------------------------------------------------