├── .flake8 ├── hacs.json ├── requirements.txt ├── manifest.json ├── .github └── workflows │ └── verify.yml ├── LICENSE ├── strings.json ├── translations ├── en.json └── fi.json ├── utils.py ├── README.md ├── const.py ├── debug └── test_lightning.py ├── config_flow.py ├── weather.py ├── sensor.py ├── __init__.py └── pylintrc /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-complexity = 10 3 | max-line-length = 100 4 | exclude = *venv*, .git/* 5 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "FMI", 3 | "content_in_root": true, 4 | "country": "FI", 5 | "render_readme": true 6 | } -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | python-dateutil 2 | async-timeout 3 | voluptuous 4 | requests>=2.32.4 5 | fmi-weather-client>=0.7.0 6 | geopy>=2.1.0 7 | xmltodict>=0.14.2 8 | flake8 9 | pylint 10 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "fmi", 3 | "version": "0.6.2", 4 | "name": "Finnish Meteorological Institute", 5 | "documentation": "https://www.home-assistant.io/integrations/fmi/", 6 | "requirements": [ 7 | "fmi-weather-client==0.7.0", 8 | "geopy>=2.1.0" 9 | ], 10 | "dependencies": [], 11 | "codeowners": [ 12 | "@anand-p-r" 13 | ], 14 | "config_flow": true, 15 | "iot_class": "cloud_polling" 16 | } 17 | -------------------------------------------------------------------------------- /.github/workflows/verify.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-24.04 11 | strategy: 12 | matrix: 13 | python-version: ["3.12", "3.13"] 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | - name: prepare env 21 | run: | 22 | python -m pip install --upgrade pip 23 | pip install -r requirements.txt 24 | - name: flake8 25 | run: | 26 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 27 | flake8 . --count --exit-zero --statistics 28 | - name: pylint 29 | run: | 30 | pylint *.py --init-hook='import sys; sys.path.append(".")' 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Anand Radhakrishnan 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. 22 | -------------------------------------------------------------------------------- /strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "description": "FMI Configuration Parameters", 6 | "title": "Finnish Meteorological Institute", 7 | "data": { 8 | "name": "[%key:common::config_flow::data::name%]", 9 | "latitude": "[%key:common::config_flow::data::latitude%]", 10 | "longitude": "[%key:common::config_flow::data::longitude%]" 11 | } 12 | } 13 | }, 14 | "error": { 15 | "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", 16 | "unknown": "[%key:common::config_flow::error::unknown%]" 17 | }, 18 | "abort": { 19 | "already_configured": "[%key:common::config_flow::abort::already_configured_location%]" 20 | } 21 | }, 22 | "options": { 23 | "step": { 24 | "user": { 25 | "title": "FMI Options", 26 | "description": "FMI Configuration Parameters", 27 | "data": { 28 | "offset": "Forecast Interval", 29 | "min_relative_humidity": "Min Relative Humidity", 30 | "max_relative_humidity": "Max Relative Humidity", 31 | "min_temperature": "Min Temperature", 32 | "max_temperature": "Max Temperature", 33 | "min_wind_speed": "Min Wind Speed", 34 | "max_wind_speed": "Max Wind Speed", 35 | "min_precipitation": "Min Precipitation", 36 | "max_precipitation": "Max Precipitation", 37 | "daily_mode": "Daily mode" 38 | } 39 | } 40 | } 41 | } 42 | } -------------------------------------------------------------------------------- /translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "The given location is already configured" 5 | }, 6 | "error": { 7 | "cannot_connect": "Failed to connect", 8 | "unknown": "Unexpected error" 9 | }, 10 | "step": { 11 | "user": { 12 | "data": { 13 | "latitude": "Latitude", 14 | "longitude": "Longitude", 15 | "name": "Name" 16 | }, 17 | "description": "FMI Configuration Parameters", 18 | "title": "Finnish Meteorological Institute" 19 | } 20 | } 21 | }, 22 | "options": { 23 | "step": { 24 | "user": { 25 | "data": { 26 | "max_precipitation": "Max Precipitation mm", 27 | "max_relative_humidity": "Max Relative Humidity %", 28 | "max_temperature": "Max Temperature °C", 29 | "max_wind_speed": "Max Wind Speed m/s", 30 | "min_precipitation": "Min Precipitation mm", 31 | "min_relative_humidity": "Min Relative Humidity %", 32 | "min_temperature": "Min Temperature °C", 33 | "min_wind_speed": "Min Wind Speed m/s", 34 | "offset": "Forecast Interval h", 35 | "forecast_days": "Num of forecast days", 36 | "daily_mode": "Activate Daily forecast entity", 37 | "lightning_sensor": "Activate Lightning sensor", 38 | "lightning_radius": "Lightning sensor radius km", 39 | "observation_station_id": "FMI's Observation Station ID" 40 | }, 41 | "description": "Maximum and minimum parameters are used to determine the Best Time Of The Day", 42 | "title": "Finnish Meteorological Institute Options" 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /translations/fi.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Antamasi sijainti on jo määritelty" 5 | }, 6 | "error": { 7 | "cannot_connect": "Yhteydenotto epäonnistui", 8 | "unknown": "Odottamaton virhe" 9 | }, 10 | "step": { 11 | "user": { 12 | "data": { 13 | "latitude": "Leveysaste", 14 | "longitude": "Pituusaste", 15 | "name": "Nimi" 16 | }, 17 | "description": "Ilmatieteen laitos määrittely parametrit", 18 | "title": "Ilmatieteen laitos" 19 | } 20 | } 21 | }, 22 | "options": { 23 | "step": { 24 | "user": { 25 | "data": { 26 | "max_precipitation": "Maksimi sateenkertymä mm", 27 | "max_relative_humidity": "Maksimi suhteellinen kosteus %", 28 | "max_temperature": "Maksimi lämpötila °C", 29 | "max_wind_speed": "Maksimi tuulennopeus m/s", 30 | "min_precipitation": "Minimi sateenkertymä mm", 31 | "min_relative_humidity": "Minimi suhteellinen kosteus %", 32 | "min_temperature": "Minimi lämpötila °C", 33 | "min_wind_speed": "Minimi tuulennopeus m/s", 34 | "offset": "Sääennusteen aikaväli h", 35 | "forecast_days": "Ennustepäivien määrä", 36 | "daily_mode": "Aktivoi vuorokausiennuste-entiteetti", 37 | "lightning_sensor": "Aktivoi ukkostutka", 38 | "lightning_radius": "Ukkostutkan säde km", 39 | "observation_station_id": "FMI:n havaintoaseman ID" 40 | }, 41 | "description": "Maksimin ja minimin sisältävät parametrit käytetään määrittämään päivän parhaan ajan", 42 | "title": "Ilmatieteen laitos asetukset" 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | """Common utilities for the FMI Weather and Sensor integrations.""" 2 | 3 | import math 4 | from datetime import date, datetime 5 | from dateutil import tz 6 | from homeassistant.helpers.sun import get_astral_event_date 7 | from homeassistant.const import SUN_EVENT_SUNSET, SUN_EVENT_SUNRISE 8 | 9 | try: 10 | from . import const 11 | except ImportError: 12 | import const 13 | 14 | 15 | class BoundingBox(): 16 | def __init__(self, lat_min=None, lon_min=None, 17 | lat_max=None, lon_max=None): 18 | self.lat_min = lat_min 19 | self.lon_min = lon_min 20 | self.lat_max = lat_max 21 | self.lon_max = lon_max 22 | 23 | 24 | def get_bounding_box_covering_finland(): 25 | """Bounding box to covert while Finland.""" 26 | box = BoundingBox() 27 | box.lat_min = const.BOUNDING_BOX_LAT_MIN 28 | box.lon_min = const.BOUNDING_BOX_LONG_MIN 29 | box.lat_max = const.BOUNDING_BOX_LAT_MAX 30 | box.lon_max = const.BOUNDING_BOX_LONG_MAX 31 | 32 | return box 33 | 34 | 35 | def get_bounding_box(latitude_in_degrees, longitude_in_degrees, half_side_in_km): 36 | """Calculate min and max coordinates for bounding box.""" 37 | assert 0 < half_side_in_km 38 | assert -90.0 <= latitude_in_degrees <= 90.0 39 | assert -180.0 <= longitude_in_degrees <= 180.0 40 | 41 | lat = math.radians(latitude_in_degrees) 42 | lon = math.radians(longitude_in_degrees) 43 | 44 | radius = 6371 45 | # Radius of the parallel at given latitude 46 | parallel_radius = radius * math.cos(lat) 47 | 48 | lat_min = lat - half_side_in_km / radius 49 | lat_max = lat + half_side_in_km / radius 50 | lon_min = lon - half_side_in_km / parallel_radius 51 | lon_max = lon + half_side_in_km / parallel_radius 52 | rad2deg = math.degrees 53 | 54 | box = BoundingBox() 55 | box.lat_min = rad2deg(lat_min) 56 | box.lon_min = rad2deg(lon_min) 57 | box.lat_max = rad2deg(lat_max) 58 | box.lon_max = rad2deg(lon_max) 59 | 60 | return box 61 | 62 | 63 | def get_weather_symbol(symbol, hass=None): 64 | """Get a weather symbol for the symbol value.""" 65 | ret_val = const.FMI_WEATHER_SYMBOL_MAP.get(symbol, "") 66 | 67 | if hass is None or symbol != 1: # was ret_val != 1 <- always False 68 | return ret_val 69 | 70 | # Clear as per FMI 71 | today = date.today() 72 | sunset = get_astral_event_date(hass, SUN_EVENT_SUNSET, today) 73 | sunset = sunset.astimezone(tz.tzlocal()) 74 | 75 | sunrise = get_astral_event_date(hass, SUN_EVENT_SUNRISE, today) 76 | sunrise = sunrise.astimezone(tz.tzlocal()) 77 | 78 | time_now = datetime.now().astimezone(tz.tzlocal()) 79 | if time_now <= sunrise or time_now >= sunset: 80 | # Clear night 81 | ret_val = const.FMI_WEATHER_SYMBOL_MAP[0] 82 | 83 | return ret_val 84 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fmi-hass-custom 2 | 3 | `fmi-hass-custom` is a Home Assistant custom component for weather and sensor platform. 4 | It uses FMI's [open data](https://en.ilmatieteenlaitos.fi/open-data) as a source for 5 | current and forecasted meteorological data for a given location. Data update frequency 6 | is hardcoded to 30 minutes. 7 | 8 | ## Pre-installation 9 | 10 | Follow these instructions if an older manually installed version of the component was in use 11 | 12 | 1. Remove all references of the sensor and weather platforms from configuration.yaml 13 | 2. Restart Home Assistant 14 | 3. UI references could also be removed (or they could be the markers to correct when the integration is loaded again using steps below) 15 | 4. Most importantly clear the browser cache where Home Assistant UI is accessed (the new integration may sometimes not show up without this step) 16 | 17 | ## HACS installation 18 | 19 | 1. Install [HACS](https://www.hacs.xyz/) 20 | 2. Add this repository as a [custom repository](https://www.hacs.xyz/docs/faq/custom_repositories/), type is "Integration" 21 | 3. Do steps 5-7 from "Manual installation" instructions below 22 | 23 | ## Manual installation 24 | 25 | 1. Using a tool of choice open the directory (folder) for HA configuration (where you find configuration YAML file) 26 | 2. If `custom_components` directory does not exist, create one 27 | 3. In the `custom_components` directory create a new folder called "fmi" 28 | 4. Download all the files from the this repository and place the files in the new directory created. 29 | If using `git clone` command, ensure that the local directory is renamed from `fmi-hass-custom` to `fmi`. 30 | Either way, all files of the repo/download should be in `/custom_components/fmi/` 31 | 5. Restart Home Assistant 32 | 6. Install integration from UI (Settings --> Devices & services --> Add integration --> Search for fmi) 33 | 7. Specify the latitude and longitude (default values are picked from the Home Assistant configuration) 34 | 35 | ## Weather and sensors 36 | 37 | In addition to the weather platform, default sensors include different weather conditions (temperature, humidity, wind speed, cloud coverage, etc.), 38 | "best time of the day" (based on preferences), closest lightning strikes and sea level forecasts. 39 | Preferences for "best time of the day" can be tweaked by changing the values via UI 40 | (Settings --> Devices & services --> Integrations --> Finnish Meteorological Institute --> \ --> Configure). 41 | For tracking the weather and sensors of another location follow steps 6-7 of "Manual installation" with the latitude and longitude of the location. 42 | 43 | Based on the latitude and longitude, location name is derived by reverse geo-coding. Sensors are then grouped based on the derived location name. 44 | For example `weather.`, `sensor._temperature`, `sensor._humidity`, etc. 45 | 46 | Integration options (Settings --> Devices & services --> Integrations --> Finnish Meteorological Institute --> \ --> Configure) 47 | include forecast interval and other weather parameters. These weather parameters are used to 48 | determine the "Best Time Of The Day". Additionally there is are two options: 49 | 50 | - Set "Daily mode" that will provide a view of minimum and maximum temperatures for the forecasts. By default this is set to True. 51 | - Set "Lightning sensor" to display closes lightning strikes within a bounding box of 500 kilometers. By default this is set to False. 52 | 53 | ## Original author 54 | 55 | Anand Radhakrishnan [@anand-p-r](https://github.com/anand-p-r) 56 | -------------------------------------------------------------------------------- /const.py: -------------------------------------------------------------------------------- 1 | """Constants for the FMI Weather and Sensor integrations.""" 2 | from datetime import timedelta 3 | 4 | import logging 5 | 6 | LOGGER = logging.getLogger(__package__) 7 | logging.basicConfig(format='%(levelname)s:%(message)s', level=logging.DEBUG) 8 | 9 | DOMAIN = "fmi" 10 | NAME = "FMI" 11 | MANUFACTURER = "Finnish Meteorological Institute" 12 | TIMEOUT_FMI_INTEG_IN_SEC = 40 13 | TIMEOUT_LIGHTNING_PULL_IN_SECS = 5 14 | TIMEOUT_MAREO_PULL_IN_SECS = 5 15 | LIGHTNING_LOOP_TIMEOUT_IN_SECS = 20 16 | 17 | COORDINATOR = "coordinator" 18 | COORDINATOR_OBSERVATION = "coordinator_observation" 19 | FORECAST_UPDATE_INTERVAL = timedelta(minutes=30) 20 | OBSERVATION_UPDATE_INTERVAL = timedelta(minutes=10) 21 | UNDO_UPDATE_LISTENER = "undo_update_listener" 22 | 23 | CONF_FORECAST_DAYS = "forecast_days" 24 | CONF_MIN_HUMIDITY = "min_relative_humidity" 25 | CONF_MAX_HUMIDITY = "max_relative_humidity" 26 | CONF_MIN_TEMP = "min_temperature" 27 | CONF_MAX_TEMP = "max_temperature" 28 | CONF_MIN_WIND_SPEED = "min_wind_speed" 29 | CONF_MAX_WIND_SPEED = "max_wind_speed" 30 | CONF_MIN_PRECIPITATION = "min_precipitation" 31 | CONF_MAX_PRECIPITATION = "max_precipitation" 32 | CONF_DAILY_MODE = "daily_mode" 33 | CONF_LIGHTNING = "lightning_sensor" 34 | CONF_LIGHTNING_DISTANCE = "lightning_radius" 35 | CONF_OBSERVATION_STATION = "observation_station_id" 36 | 37 | HUMIDITY_RANGE = list(range(1, 101)) 38 | HUMIDITY_MIN_DEFAULT = 30 39 | HUMIDITY_MAX_DEFAULT = 70 40 | TEMP_RANGE = list(range(-40, 50)) 41 | TEMP_MIN_DEFAULT = 10 42 | TEMP_MAX_DEFAULT = 30 43 | WIND_SPEED = list(range(0, 31)) 44 | WIND_SPEED_MIN_DEFAULT = 0 45 | WIND_SPEED_MAX_DEFAULT = 25 46 | DAYS_RANGE = list(range(0, 11)) # 0 to 10 days, 0 = disable forecast 47 | DAYS_DEFAULT = 4 # default to 4 days 48 | PRECIPITATION_MIN_DEFAULT = .0 49 | PRECIPITATION_MAX_DEFAULT = .2 50 | DAILY_MODE_DEFAULT = False 51 | LIGHTNING_DEFAULT = False 52 | 53 | FORECAST_OFFSET = [1, 2, 3, 4, 6, 8, 12, 24] # Based on API test runs 54 | DEFAULT_NAME = "FMI" 55 | 56 | ATTR_DISTANCE = "distance" 57 | ATTR_STRIKES = "strikes" 58 | ATTR_PEAK_CURRENT = "peak_current" 59 | ATTR_CLOUD_COVER = "cloud_cover" 60 | ATTR_ELLIPSE_MAJOR = "ellipse_major" 61 | ATTR_FORECAST = CONF_FORECAST = "forecast" 62 | ATTR_HUMIDITY = "relative_humidity" 63 | ATTR_WIND_SPEED = "wind_speed" 64 | ATTR_PRECIPITATION = "precipitation" 65 | 66 | ATTRIBUTION = "Weather Data provided by FMI" 67 | 68 | BEST_COND_SYMBOLS = [1, 2, 21, 3, 31, 32, 41, 42, 51, 52, 91, 92] 69 | BEST_CONDITION_AVAIL = "available" 70 | BEST_CONDITION_NOT_AVAIL = "not_available" 71 | 72 | # Constants for Lightning strikes 73 | BOUNDING_BOX_LAT_MIN = 58.406721 74 | BOUNDING_BOX_LONG_MIN = 15.311937 75 | BOUNDING_BOX_LAT_MAX = 70.440000 76 | BOUNDING_BOX_LONG_MAX = 39.262133 77 | BOUNDING_BOX_HALF_SIDE_KM = 200 78 | LIGHTNING_DAYS_LIMIT = 1 79 | LIGHTNING_LIMIT = 5 80 | 81 | URL_FMI_BASE = "https://opendata.fmi.fi/wfs?service=WFS&version=2.0.0" \ 82 | "&request=getFeature&storedquery_id=" 83 | 84 | LIGHTNING_QUERY_ID = "fmi::observations::lightning::multipointcoverage" 85 | LIGHTNING_GET_URL = f"{URL_FMI_BASE}{LIGHTNING_QUERY_ID}×tep=3600&" 86 | 87 | MAREO_QUERY_ID = "fmi::forecast::sealevel::point::simple" 88 | MAREO_GET_URL = f"{URL_FMI_BASE}{MAREO_QUERY_ID}×tep=30&" 89 | 90 | # MAREO_OBS_QUERY_ID = "fmi::observations::mareograph::simple" 91 | # MAREO_OBS_GET_URL = f"{URL_FMI_BASE}{MAREO_OBS_QUERY_ID}&fmisid=132310×tep=30" 92 | 93 | # MAREO_SEA_TEMP_QUERY_ID = "fmi::forecast::oaas::sealevel::point::simple" 94 | # MAREO_SEA_TEMP_GET_URL = f"{URL_FMI_BASE}{MAREO_OBS_QUERY_ID}×tep=30" \ 95 | # "&latlon=60.0,24.0&starttime=2021-04-11T13:24:00Z" 96 | # 97 | 98 | # FMI Weather Visibility Constants 99 | FMI_WEATHER_SYMBOL_MAP = { 100 | 0: "clear-night", # custom value 0 - not defined by FMI 101 | 1: "sunny", # "Clear", 102 | 2: "partlycloudy", # "Partially Clear", 103 | 21: "rainy", # "Light Showers", 104 | 22: "pouring", # "Showers", 105 | 23: "pouring", # "Strong Rain Showers", 106 | 3: "cloudy", # "Cloudy", 107 | 31: "rainy", # "Weak rains", 108 | 32: "rainy", # "Rains", 109 | 33: "pouring", # "Heavy Rains", 110 | 41: "snowy-rainy", # "Weak Snow", 111 | 42: "cloudy", # "Cloudy", 112 | 43: "snowy", # "Strong Snow", 113 | 51: "snowy", # "Light Snow", 114 | 52: "snowy", # "Snow", 115 | 53: "snowy", # "Heavy Snow", 116 | 61: "lightning", # "Thunderstorms", 117 | 62: "lightning-rainy", # "Strong Thunderstorms", 118 | 63: "lightning", # "Thunderstorms", 119 | 64: "lightning-rainy", # "Strong Thunderstorms", 120 | 71: "rainy", # "Weak Sleet", 121 | 72: "rainy", # "Sleet", 122 | 73: "pouring", # "Heavy Sleet", 123 | 81: "rainy", # "Light Sleet", 124 | 82: "rainy", # "Sleet", 125 | 83: "pouring", # "Heavy Sleet", 126 | 91: "fog", # "Fog", 127 | 92: "fog", # "Fog" 128 | } 129 | -------------------------------------------------------------------------------- /debug/test_lightning.py: -------------------------------------------------------------------------------- 1 | """Common utilities for the FMI Weather and Sensor integrations.""" 2 | 3 | import os 4 | import sys 5 | import logging 6 | from datetime import datetime, timedelta 7 | import xml.etree.ElementTree as ET 8 | import requests 9 | from geopy.distance import geodesic 10 | from geopy.geocoders import Nominatim 11 | 12 | # Add parent to sys path for project file imports 13 | sys.path.append(os.path.abspath(os.path.join(sys.path[0], ".."))) 14 | 15 | import const # noqa: E402 16 | import utils # noqa: E402 17 | 18 | 19 | _LOGGER = logging.getLogger(__package__) 20 | 21 | 22 | def __lightning_strikes_postions(loc_list: list, text: str, home_cords: tuple): 23 | val_list = text.lstrip().split("\n") 24 | 25 | for loc_index, val in enumerate(val_list): 26 | if not val: 27 | continue 28 | 29 | val_split = val.split(" ") 30 | lightning_coords = (float(val_split[0]), float(val_split[1])) 31 | distance = 0 32 | 33 | try: 34 | distance = geodesic(lightning_coords, home_cords).km 35 | except Exception: 36 | _LOGGER.info("Unable to find distance between " 37 | f"{lightning_coords} and {home_cords}") 38 | 39 | add_tuple = (val_split[0], val_split[1], val_split[2], 40 | distance, loc_index) 41 | loc_list.append(add_tuple) 42 | 43 | 44 | def __lightning_strikes_reasons_list(loc_list: list, text: str): 45 | val_list = text.lstrip().split("\n") 46 | 47 | for index, val in enumerate(val_list): 48 | if not val: 49 | continue 50 | 51 | val_split = val.split(" ") 52 | exist_tuple = loc_list[index] 53 | 54 | if index != exist_tuple[4]: 55 | _LOGGER.debug("Record mismatch - aborting query") 56 | break 57 | 58 | loc_list[index] = (exist_tuple[0], exist_tuple[1], exist_tuple[2], 59 | exist_tuple[3], val_split[0], val_split[1], 60 | val_split[2], val_split[3]) 61 | 62 | 63 | def update_lightning_strikes(latitude=None, longitude=None, custom_url=None): 64 | """Get the latest data from FMI and update the states.""" 65 | 66 | _LOGGER.debug("FMI: Lightning started") 67 | loc_time_list = [] 68 | home_cords = (latitude, longitude) 69 | 70 | start_time = datetime.today() - timedelta(days=const.LIGHTNING_DAYS_LIMIT) 71 | 72 | # Format datetime to string accepted as path parameter in REST 73 | start_time = str(start_time).split(".")[0] 74 | start_time = datetime.strptime(start_time, "%Y-%m-%d %H:%M:%S") 75 | start_time_uri_param = f"starttime={str(start_time.date())}T{str(start_time.time())}Z&" 76 | 77 | # Get Bounding Box coords 78 | bbox_coords = utils.get_bounding_box(latitude, longitude, 79 | half_side_in_km=const.BOUNDING_BOX_HALF_SIDE_KM) 80 | bbox_uri_param = "bbox=" \ 81 | f"{bbox_coords.lon_min},{bbox_coords.lat_min},"\ 82 | f"{bbox_coords.lon_max},{bbox_coords.lat_max}&" 83 | 84 | base_url = None 85 | if custom_url is None: 86 | base_url = const.LIGHTNING_GET_URL + start_time_uri_param + bbox_uri_param 87 | _LOGGER.debug(f"FMI: Lightning URI - {base_url}") 88 | else: 89 | base_url = custom_url 90 | _LOGGER.debug(f"FMI: Lightning URI - Using custom URL - {base_url}") 91 | 92 | # Fetch data 93 | loop_start_time = datetime.now() 94 | response = requests.get(base_url, timeout=const.TIMEOUT_LIGHTNING_PULL_IN_SECS) 95 | url_fetch_end = datetime.now() 96 | _LOGGER.debug(f"URL fetch time - {(url_fetch_end - loop_start_time).total_seconds()}s") 97 | 98 | loop_start_time = datetime.now() 99 | root = ET.fromstring(response.content) 100 | 101 | for child in root.iter(): 102 | if child.tag.find("positions") > 0: 103 | __lightning_strikes_postions(loc_time_list, child.text, home_cords) 104 | 105 | elif child.tag.find("doubleOrNilReasonTupleList") > 0: 106 | __lightning_strikes_reasons_list(loc_time_list, child.text) 107 | 108 | # First sort for closes entries and filter to limit 109 | loc_time_list = sorted(loc_time_list, key=lambda item: item[3]) 110 | 111 | url_fetch_end = datetime.now() 112 | _LOGGER.debug(f"Looping time - {(url_fetch_end - loop_start_time).total_seconds()}s") 113 | 114 | _LOGGER.debug(f"FMI - Coords retrieved for Lightning Data- {len(loc_time_list)}") 115 | 116 | loc_time_list = loc_time_list[:const.LIGHTNING_LIMIT] 117 | 118 | # Second Sort based on date 119 | loc_time_list = sorted(loc_time_list, key=(lambda item: item[2]), reverse=True) # date 120 | 121 | geolocator = Nominatim(user_agent="fmi_hassio_sensor") 122 | 123 | # Reverse geocoding 124 | for _, v in enumerate(loc_time_list): 125 | loc = str(v[0]) + ", " + str(v[1]) 126 | try: 127 | geolocator.reverse(loc, language="en").address 128 | except Exception as e: 129 | _LOGGER.info(f"Unable to reverse geocode for address-{loc}. Got error-{e}") 130 | 131 | _LOGGER.debug("FMI: Lightning ended") 132 | 133 | 134 | if __name__ == "__main__": 135 | locations = [ 136 | # (lat, lon) pairs 137 | (61.55289772079975, 23.78579634262166), 138 | (61.14397219067582, 25.351877243169923), 139 | ] 140 | 141 | box = utils.get_bounding_box(*locations[0], 750) 142 | _LOGGER.info(f"&bbox={box.lon_min},{box.lat_min},{box.lon_max},{box.lat_max}&") 143 | _LOGGER.info(f"for gmaps: {box.lat_min}, {box.lon_min} {box.lat_max}, {box.lon_max}") 144 | 145 | update_lightning_strikes(latitude=locations[1][0], longitude=locations[1][1]) 146 | -------------------------------------------------------------------------------- /config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for FMI (Finnish Meteorological Institute) integration.""" 2 | 3 | import fmi_weather_client as fmi_client 4 | import fmi_weather_client.errors as fmi_erros 5 | 6 | import voluptuous as vol 7 | 8 | from homeassistant import config_entries, core 9 | import homeassistant.const as ha_const 10 | from homeassistant.helpers import config_validation as cv 11 | 12 | from . import base_unique_id 13 | from . import const 14 | 15 | 16 | async def validate_user_config(hass: core.HomeAssistant, data): 17 | """Validate input configuration for FMI. 18 | 19 | Data contains Latitude / Longitude provided by user or from 20 | HASS default configuration. 21 | """ 22 | latitude = data[ha_const.CONF_LATITUDE] 23 | longitude = data[ha_const.CONF_LONGITUDE] 24 | 25 | errors = "" 26 | 27 | # Current Weather 28 | try: 29 | weather_data = await hass.async_add_executor_job( 30 | fmi_client.weather_by_coordinates, latitude, longitude 31 | ) 32 | 33 | return {"place": weather_data.place, "err": ""} 34 | except fmi_erros.ClientError as err: 35 | err_string = ( 36 | "Client error with status " 37 | + str(err.status_code) 38 | + " and message " 39 | + err.message 40 | ) 41 | errors = "client_connect_error" 42 | const.LOGGER.error(err_string) 43 | except fmi_erros.ServerError as err: 44 | err_string = ( 45 | "Server error with status " 46 | + str(err.status_code) 47 | + " and message " 48 | + err.body 49 | ) 50 | errors = "server_connect_error" 51 | const.LOGGER.error(err_string) 52 | 53 | return {"place": "None", "err": errors} 54 | 55 | 56 | class FMIConfigFlowHandler(config_entries.ConfigFlow, domain="fmi"): 57 | """Config flow handler for FMI.""" 58 | 59 | VERSION = 1 60 | CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL 61 | 62 | async def async_step_user(self, user_input=None): 63 | """Handle user step.""" 64 | # Display an option for the user to provide Lat/Long for the integration 65 | errors = {} 66 | if user_input is not None: 67 | 68 | await self.async_set_unique_id( 69 | base_unique_id(user_input[ha_const.CONF_LATITUDE], 70 | user_input[ha_const.CONF_LONGITUDE]) 71 | ) 72 | self._abort_if_unique_id_configured() 73 | 74 | valid = await validate_user_config(self.hass, user_input) 75 | 76 | if valid.get("err", "") == "": 77 | return self.async_create_entry(title=valid["place"], data=user_input) 78 | 79 | errors["fmi"] = valid["err"] 80 | 81 | data_schema = vol.Schema( 82 | { 83 | vol.Required(ha_const.CONF_NAME, default=const.DEFAULT_NAME): str, 84 | vol.Required(ha_const.CONF_LATITUDE, 85 | default=self.hass.config.latitude): cv.latitude, 86 | vol.Required(ha_const.CONF_LONGITUDE, 87 | default=self.hass.config.longitude): cv.longitude 88 | } 89 | ) 90 | 91 | return self.async_show_form( 92 | step_id="user", 93 | data_schema=data_schema, 94 | errors=errors, 95 | ) 96 | 97 | @staticmethod 98 | @core.callback 99 | def async_get_options_flow(config_entry): 100 | """Options callback for FMI.""" 101 | _ = config_entry 102 | return FMIOptionsFlowHandler() 103 | 104 | 105 | class FMIOptionsFlowHandler(config_entries.OptionsFlow): 106 | """Config flow options for FMI.""" 107 | 108 | @property 109 | def config_entry(self): 110 | """Return the config entry linked to the current options flow.""" 111 | return self.hass.config_entries.async_get_entry(self.handler) 112 | 113 | async def async_step_init(self, user_input=None): 114 | """Manage the options.""" 115 | _ = user_input 116 | return await self.async_step_user() 117 | 118 | async def async_step_user(self, user_input=None): 119 | """Handle a flow initialized by the user.""" 120 | if user_input is not None: 121 | return self.async_create_entry(title="FMI Options", data=user_input) 122 | 123 | _options = self.config_entry.options 124 | 125 | return self.async_show_form( 126 | step_id="user", 127 | data_schema=vol.Schema({ 128 | vol.Optional(const.CONF_FORECAST_DAYS, 129 | default=_options.get( 130 | const.CONF_FORECAST_DAYS, 131 | const.DAYS_DEFAULT)): vol.In(const.DAYS_RANGE), 132 | vol.Optional(ha_const.CONF_OFFSET, 133 | default=_options.get( 134 | ha_const.CONF_OFFSET, 135 | const.FORECAST_OFFSET[0])): vol.In(const.FORECAST_OFFSET), 136 | vol.Optional(const.CONF_MIN_HUMIDITY, 137 | default=_options.get( 138 | const.CONF_MIN_HUMIDITY, 139 | const.HUMIDITY_MIN_DEFAULT)): vol.In(const.HUMIDITY_RANGE), 140 | vol.Optional(const.CONF_MAX_HUMIDITY, 141 | default=_options.get( 142 | const.CONF_MAX_HUMIDITY, 143 | const.HUMIDITY_MAX_DEFAULT)): vol.In(const.HUMIDITY_RANGE), 144 | vol.Optional(const.CONF_MIN_TEMP, 145 | default=_options.get( 146 | const.CONF_MIN_TEMP, 147 | const.TEMP_MIN_DEFAULT)): vol.In(const.TEMP_RANGE), 148 | vol.Optional(const.CONF_MAX_TEMP, 149 | default=_options.get( 150 | const.CONF_MAX_TEMP, 151 | const.TEMP_MAX_DEFAULT)): vol.In(const.TEMP_RANGE), 152 | vol.Optional(const.CONF_MIN_WIND_SPEED, 153 | default=_options.get( 154 | const.CONF_MIN_WIND_SPEED, 155 | const.WIND_SPEED_MIN_DEFAULT)): vol.In(const.WIND_SPEED), 156 | vol.Optional(const.CONF_MAX_WIND_SPEED, 157 | default=_options.get( 158 | const.CONF_MAX_WIND_SPEED, 159 | const.WIND_SPEED_MAX_DEFAULT)): vol.In(const.WIND_SPEED), 160 | vol.Optional(const.CONF_MIN_PRECIPITATION, 161 | default=_options.get( 162 | const.CONF_MIN_PRECIPITATION, 163 | const.PRECIPITATION_MIN_DEFAULT)): cv.small_float, 164 | vol.Optional(const.CONF_MAX_PRECIPITATION, 165 | default=_options.get( 166 | const.CONF_MAX_PRECIPITATION, 167 | const.PRECIPITATION_MAX_DEFAULT)): cv.small_float, 168 | vol.Optional(const.CONF_DAILY_MODE, 169 | default=_options.get( 170 | const.CONF_DAILY_MODE, 171 | const.DAILY_MODE_DEFAULT)): cv.boolean, 172 | vol.Optional(const.CONF_LIGHTNING, 173 | default=_options.get( 174 | const.CONF_LIGHTNING, 175 | const.LIGHTNING_DEFAULT)): cv.boolean, 176 | vol.Optional(const.CONF_LIGHTNING_DISTANCE, 177 | default=_options.get( 178 | const.CONF_LIGHTNING_DISTANCE, 179 | const.BOUNDING_BOX_HALF_SIDE_KM)): cv.positive_int, 180 | vol.Optional(const.CONF_OBSERVATION_STATION, 181 | default=_options.get( 182 | const.CONF_OBSERVATION_STATION, 0)): cv.positive_int, 183 | }) 184 | ) 185 | -------------------------------------------------------------------------------- /weather.py: -------------------------------------------------------------------------------- 1 | """Support for retrieving meteorological data from FMI (Finnish Meteorological Institute).""" 2 | import math 3 | from dateutil import tz 4 | 5 | from homeassistant.components.weather import ( 6 | ATTR_FORECAST_CONDITION, 7 | ATTR_FORECAST_NATIVE_PRECIPITATION, 8 | ATTR_FORECAST_NATIVE_TEMP, 9 | ATTR_FORECAST_TIME, 10 | ATTR_FORECAST_WIND_BEARING, 11 | ATTR_FORECAST_NATIVE_WIND_SPEED, 12 | ATTR_FORECAST_NATIVE_TEMP_LOW, 13 | ATTR_FORECAST_CLOUD_COVERAGE, 14 | WeatherEntity, 15 | Forecast, 16 | ) 17 | from homeassistant.components.weather.const import ( 18 | ATTR_WEATHER_HUMIDITY, 19 | ATTR_WEATHER_PRESSURE, 20 | WeatherEntityFeature, 21 | ) 22 | from homeassistant.const import CONF_NAME 23 | import homeassistant.core as ha_core 24 | from homeassistant.helpers.device_registry import DeviceEntryType 25 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 26 | 27 | from . import FMIDataUpdateCoordinator 28 | from . import const 29 | from . import utils 30 | 31 | 32 | PARALLEL_UPDATES = 1 33 | 34 | 35 | async def async_setup_entry(hass, config_entry, async_add_entities): 36 | """Add an FMI weather entity from a config_entry.""" 37 | name = config_entry.data[CONF_NAME] 38 | daily_mode = config_entry.options.get(const.CONF_DAILY_MODE, False) 39 | station_id = bool(config_entry.options.get(const.CONF_OBSERVATION_STATION, 0)) 40 | 41 | domain_data = hass.data[const.DOMAIN][config_entry.entry_id] 42 | 43 | coordinator = domain_data[const.COORDINATOR] 44 | 45 | entity_list = [FMIWeatherEntity(name, coordinator)] 46 | if daily_mode: 47 | entity_list.append(FMIWeatherEntity(f"{name} (daily)", coordinator, 48 | daily_mode=True)) 49 | if station_id: 50 | try: 51 | coordinator = domain_data.get(const.COORDINATOR_OBSERVATION) 52 | if coordinator is not None: 53 | entity_list.append(FMIWeatherEntity( 54 | f"{name} (observation)", coordinator, station_id=station_id)) 55 | except (KeyError, AttributeError) as error: 56 | const.LOGGER.error("Unable to setup observation object! ERROR: %s", error) 57 | 58 | async_add_entities(entity_list, False) 59 | 60 | 61 | class FMIWeatherEntity(CoordinatorEntity, WeatherEntity): 62 | """Define an FMI Weather Entity.""" 63 | 64 | _attr_supported_features = ( 65 | WeatherEntityFeature.FORECAST_HOURLY | 66 | WeatherEntityFeature.FORECAST_DAILY 67 | ) 68 | 69 | def __init__(self, name, coordinator: FMIDataUpdateCoordinator, 70 | station_id: bool = False, daily_mode: bool = False): 71 | """Initialize FMI weather object.""" 72 | self.logger = const.LOGGER.getChild("weather") 73 | super().__init__(coordinator) 74 | self._daily_mode = daily_mode 75 | self._observation_mode = station_id 76 | self._data_func = coordinator.get_observation if station_id else coordinator.get_weather 77 | _weather = self._data_func() 78 | _attr_name = [_weather.place if _weather else name] 79 | _attr_unique_id = [f"{coordinator.unique_id}"] 80 | _name_extra = "" 81 | if daily_mode: 82 | _attr_name.append("(daily)") 83 | _attr_unique_id.append("daily") 84 | elif station_id: 85 | _attr_name.append("(observation)") 86 | _attr_unique_id.append("observation") 87 | _name_extra = " Observation" 88 | self._attr_name = " ".join(_attr_name) 89 | self._attr_unique_id = "_".join(_attr_unique_id) 90 | self._attr_device_info = { 91 | "identifiers": {(const.DOMAIN, coordinator.unique_id)}, 92 | "name": const.NAME + _name_extra, 93 | "manufacturer": const.MANUFACTURER, 94 | "entry_type": DeviceEntryType.SERVICE, 95 | } 96 | self._attr_should_poll = False 97 | self._attr_attribution = const.ATTRIBUTION 98 | # update initial values 99 | self.update_callback() 100 | # register the update callback 101 | coordinator.async_add_listener(self.update_callback) 102 | 103 | @ha_core.callback 104 | def _handle_coordinator_update(self) -> None: 105 | """Handle updated data from the coordinator.""" 106 | self.async_write_ha_state() 107 | 108 | def update_callback(self, *_, **__): 109 | """Update the entity attributes.""" 110 | _fmi: FMIDataUpdateCoordinator = self.coordinator 111 | _last_update_success = _fmi.last_update_success 112 | _weather = self._data_func() 113 | if _weather is None or not _last_update_success: 114 | self.logger.warning(f"{self._attr_name}: No data available from FMI!") 115 | return 116 | _time = _weather.data.time.astimezone(tz.tzlocal()) 117 | self.logger.debug(f"{self._attr_name}: updated: {_last_update_success} time {_time}") 118 | # Update the entity attributes 119 | self._attr_native_temperature_unit = self.__get_unit(_weather, "temperature") 120 | self._attr_native_pressure_unit = self.__get_unit(_weather, "pressure") 121 | self._attr_native_wind_speed_unit = self.__get_unit(_weather, "wind_speed") 122 | self._attr_native_temperature = self.__get_value(_weather, "temperature") 123 | self._attr_humidity = self.__get_value(_weather, "humidity") 124 | self._attr_native_precipitation = self.__get_value(_weather, "precipitation_amount") 125 | self._attr_native_wind_speed = self.__get_value(_weather, "wind_speed") 126 | wind_gust = self.__get_value(_weather, "wind_gust") 127 | if wind_gust is None or math.isnan(wind_gust): 128 | wind_gust = self.__get_value(_weather, "wind_max") 129 | self._attr_native_wind_gust_speed = wind_gust 130 | self._attr_wind_bearing = self.__get_value(_weather, "wind_direction") 131 | self._attr_cloud_coverage = self.__get_value(_weather, "cloud_cover") 132 | self._attr_native_pressure = self.__get_value(_weather, "pressure") 133 | self._attr_native_dew_point = self.__get_value(_weather, "dew_point") 134 | self._attr_condition = utils.get_weather_symbol(_weather.data.symbol.value, _fmi.hass) 135 | 136 | def __get_value(self, _weather, name): 137 | if _weather is None: 138 | return None 139 | value = getattr(_weather.data if hasattr(_weather, "data") else _weather, name) 140 | if value is None or value.value is None or math.isnan(value.value): 141 | return None 142 | return value.value 143 | 144 | def __get_unit(self, _weather, name): 145 | if _weather is None: 146 | return None 147 | value = getattr(_weather.data, name) 148 | if value is None or not value.unit: 149 | return None 150 | return value.unit 151 | 152 | def _forecast(self, daily_mode: bool) -> list[Forecast] | None: 153 | """Return the forecasts.""" 154 | 155 | _fmi: FMIDataUpdateCoordinator = self.coordinator 156 | _forecasts = _fmi.get_forecasts() 157 | _data = [] 158 | _get_val = self.__get_value 159 | 160 | _item = {} 161 | _current_day = 0 162 | 163 | for forecast in _forecasts: 164 | _time = forecast.time.astimezone(tz.tzlocal()) 165 | _temperature = _get_val(forecast, "temperature") 166 | if not daily_mode or _current_day != _time.day: 167 | # add a new day 168 | _current_day = _time.day 169 | _item = { 170 | ATTR_FORECAST_TIME: _time.isoformat(), 171 | ATTR_FORECAST_CONDITION: utils.get_weather_symbol(forecast.symbol.value), 172 | ATTR_FORECAST_NATIVE_TEMP: _temperature, 173 | ATTR_FORECAST_NATIVE_TEMP_LOW: _temperature if daily_mode else None, 174 | ATTR_FORECAST_NATIVE_PRECIPITATION: _get_val(forecast, "precipitation_amount"), 175 | ATTR_FORECAST_NATIVE_WIND_SPEED: _get_val(forecast, "wind_speed"), 176 | ATTR_FORECAST_WIND_BEARING: _get_val(forecast, "wind_direction"), 177 | ATTR_WEATHER_PRESSURE: _get_val(forecast, "pressure"), 178 | ATTR_WEATHER_HUMIDITY: _get_val(forecast, "humidity"), 179 | ATTR_FORECAST_CLOUD_COVERAGE: _get_val(forecast, "cloud_cover"), 180 | } 181 | _data.append(_item) 182 | 183 | else: 184 | # update daily high and low temperature values 185 | if _item[ATTR_FORECAST_NATIVE_TEMP] < _temperature: 186 | _item[ATTR_FORECAST_NATIVE_TEMP] = _temperature 187 | if _item[ATTR_FORECAST_NATIVE_TEMP_LOW] > _temperature: 188 | _item[ATTR_FORECAST_NATIVE_TEMP_LOW] = _temperature 189 | return _data 190 | 191 | @property 192 | def forecast(self) -> list[Forecast] | None: 193 | """Return the forecast array. Legacy version!""" 194 | return self._forecast(daily_mode=self._daily_mode) 195 | 196 | async def async_forecast_hourly(self) -> list[Forecast] | None: 197 | """Return the hourly forecast in native units.""" 198 | return self._forecast(daily_mode=self._daily_mode) 199 | 200 | async def async_forecast_twice_daily(self) -> list[Forecast] | None: 201 | """Return the daily forecast in native units.""" 202 | raise NotImplementedError 203 | 204 | async def async_forecast_daily(self) -> list[Forecast] | None: 205 | """Return the daily forecast in native units.""" 206 | return self._forecast(daily_mode=True) 207 | 208 | async def async_update(self) -> None: 209 | """Get the latest weather data.""" 210 | _fmi: FMIDataUpdateCoordinator = self.coordinator 211 | await _fmi.async_refresh() 212 | -------------------------------------------------------------------------------- /sensor.py: -------------------------------------------------------------------------------- 1 | """Support for weather service from FMI (Finnish Meteorological Institute) for sensor platform.""" 2 | 3 | import enum 4 | import math 5 | from datetime import datetime 6 | from dateutil import tz 7 | # Import homeassistant platform dependencies 8 | import homeassistant.const as ha_const 9 | import homeassistant.core as ha_core 10 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 11 | 12 | from . import FMIDataUpdateCoordinator 13 | from . import utils 14 | from . import const 15 | 16 | 17 | class SensorType(enum.IntEnum): 18 | PLACE = enum.auto() 19 | WEATHER = enum.auto() 20 | TEMPERATURE = enum.auto() 21 | WIND_SPEED = enum.auto() 22 | WIND_DIR = enum.auto() 23 | WIND_GUST = enum.auto() 24 | HUMIDITY = enum.auto() 25 | CLOUDS = enum.auto() 26 | RAIN = enum.auto() 27 | TIME_FORECAST = enum.auto() 28 | TIME = enum.auto() 29 | LIGHTNING = enum.auto() 30 | SEA_LEVEL = enum.auto() 31 | 32 | 33 | SENSOR_TYPES = { 34 | SensorType.PLACE: ["Place", None, "mdi:city-variant"], 35 | SensorType.WEATHER: ["Condition", None, None], 36 | SensorType.TEMPERATURE: ["Temperature", ha_const.UnitOfTemperature.CELSIUS, 37 | "mdi:thermometer"], 38 | SensorType.WIND_SPEED: ["Wind Speed", ha_const.UnitOfSpeed.METERS_PER_SECOND, 39 | "mdi:weather-windy"], 40 | SensorType.WIND_DIR: ["Wind Direction", "", "mdi:weather-windy"], 41 | SensorType.WIND_GUST: ["Wind Gust", ha_const.UnitOfSpeed.METERS_PER_SECOND, 42 | "mdi:weather-windy"], 43 | SensorType.HUMIDITY: ["Humidity", ha_const.PERCENTAGE, "mdi:water"], 44 | SensorType.CLOUDS: ["Cloud Coverage", ha_const.PERCENTAGE, "mdi:weather-cloudy"], 45 | SensorType.RAIN: ["Rain", ha_const.UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, 46 | "mdi:weather-pouring"], 47 | SensorType.TIME_FORECAST: ["Time", None, "mdi:av-timer"], 48 | SensorType.TIME: ["Best Time Of Day", None, "mdi:av-timer"], 49 | } 50 | 51 | SENSOR_LIGHTNING_TYPES = { 52 | SensorType.LIGHTNING: ["Lightning Strikes", None, "mdi:weather-lightning"] 53 | } 54 | 55 | SENSOR_MAREO_TYPES = { 56 | SensorType.SEA_LEVEL: ["Sea Level", ha_const.UnitOfLength.CENTIMETERS, "mdi:waves"] 57 | } 58 | 59 | PARALLEL_UPDATES = 1 60 | 61 | 62 | async def async_setup_entry(hass, config_entry, async_add_entities): 63 | """Set up the FMI Sensor, including Best Time Of the Day sensor.""" 64 | name = config_entry.data[ha_const.CONF_NAME] 65 | lightning_mode = config_entry.options.get(const.CONF_LIGHTNING, False) 66 | 67 | coordinator = hass.data[const.DOMAIN][config_entry.entry_id][const.COORDINATOR] 68 | 69 | entity_list = [] 70 | 71 | for sensor_type, sensor_data in SENSOR_TYPES.items(): 72 | entity_list.append( 73 | FMIBestConditionSensor( 74 | name, coordinator, sensor_type, sensor_data)) 75 | 76 | if lightning_mode: 77 | for sensor_type, sensor_data in SENSOR_LIGHTNING_TYPES.items(): 78 | entity_list.append( 79 | FMILightningStrikesSensor(name, coordinator, sensor_type, sensor_data)) 80 | 81 | for sensor_type, sensor_data in SENSOR_MAREO_TYPES.items(): 82 | entity_list.append( 83 | FMIMareoSensor(name, coordinator, sensor_type, sensor_data)) 84 | 85 | async_add_entities(entity_list, False) 86 | 87 | 88 | class _BaseSensorClass(CoordinatorEntity): 89 | """Common base class for all the sensor types.""" 90 | 91 | def __init__(self, name, coordinator: FMIDataUpdateCoordinator, 92 | sensor_type, sensor_data, only_name=None): 93 | """Initialize the sensor base data.""" 94 | self.logger = const.LOGGER.getChild("sensor") 95 | super().__init__(coordinator) 96 | self.client_name = name 97 | self._name, self._attr_unit_of_measurement, self._attr_icon = sensor_data 98 | self.type = sensor_type 99 | self._attr_unique_id = \ 100 | f"{coordinator.unique_id}_{name.replace(' ', '_')}_{self._name.replace(' ', '_')}" 101 | if only_name: 102 | self._attr_name = f"{self._name}" 103 | else: 104 | base_name = coordinator.get_current_place() 105 | if base_name is None: 106 | base_name = name 107 | self._attr_name = f"{base_name} {self._name}" 108 | self._attr_attribution = const.ATTRIBUTION 109 | self._attr_should_poll = False 110 | self._attr_state = ha_const.STATE_UNAVAILABLE 111 | self.update() 112 | 113 | coordinator.async_add_listener(self.update_callback) 114 | 115 | @ha_core.callback 116 | def _handle_coordinator_update(self) -> None: 117 | """Handle updated data from the coordinator.""" 118 | self.async_write_ha_state() 119 | 120 | def update_callback(self, *_, **__): 121 | """Update the entity attributes.""" 122 | _fmi: FMIDataUpdateCoordinator = self.coordinator 123 | _weather = _fmi.get_weather() 124 | if not _weather: 125 | return 126 | _time = _weather.data.time.astimezone(tz.tzlocal()) 127 | self.logger.debug(f"{self._attr_name}: updated: {_fmi.last_update_success} time {_time}") 128 | self.update() 129 | 130 | def update(self): 131 | """Update method prototype.""" 132 | raise NotImplementedError("Required update method is not implemented") 133 | 134 | 135 | class FMIBestConditionSensor(_BaseSensorClass): 136 | """Implementation of a FMI Weather sensor with best conditions of the day.""" 137 | 138 | def __init__(self, name, coordinator, sensor_type, sensor_data): 139 | """Initialize the sensor.""" 140 | self.update_state_func = { 141 | SensorType.WEATHER: self.__update_weather, 142 | SensorType.TEMPERATURE: self.__update_temperature, 143 | SensorType.WIND_SPEED: self.__update_wind_speed, 144 | SensorType.WIND_DIR: self.__update_wind_direction, 145 | SensorType.WIND_GUST: self.__update_wind_gust, 146 | SensorType.HUMIDITY: self.__update_humidity, 147 | SensorType.CLOUDS: self.__update_clouds, 148 | SensorType.RAIN: self.__update_rain, 149 | SensorType.TIME_FORECAST: self.__update_forecast_time, 150 | SensorType.TIME: self.__update_time, 151 | }.get(sensor_type, self.__update_dummy) 152 | super().__init__(name, coordinator, sensor_type, sensor_data, only_name=True) 153 | 154 | @staticmethod 155 | def get_wind_direction_string(wind_direction_in_deg): 156 | """Get the string interpretation of wind direction in degrees.""" 157 | 158 | if wind_direction_in_deg is None or \ 159 | wind_direction_in_deg < 0 or wind_direction_in_deg > 360: 160 | return ha_const.STATE_UNAVAILABLE 161 | 162 | if wind_direction_in_deg <= 23 or wind_direction_in_deg > 338: 163 | return "N" 164 | if 23 < wind_direction_in_deg <= 68: 165 | return "NE" 166 | if 68 < wind_direction_in_deg <= 113: 167 | return "E" 168 | if 113 < wind_direction_in_deg <= 158: 169 | return "SE" 170 | if 158 < wind_direction_in_deg <= 203: 171 | return "S" 172 | if 203 < wind_direction_in_deg <= 248: 173 | return "SW" 174 | if 248 < wind_direction_in_deg <= 293: 175 | return "W" 176 | if 293 < wind_direction_in_deg <= 338: 177 | return "NW" 178 | return ha_const.STATE_UNAVAILABLE 179 | 180 | def __convert_float(self, source_data, name): 181 | value = getattr(source_data, name) 182 | if value is None or math.isnan(value.value): 183 | self._attr_state = ha_const.STATE_UNAVAILABLE 184 | return 185 | self._attr_state = value.value 186 | 187 | def __update_dummy(self, source_data): 188 | _ = source_data 189 | self._attr_state = ha_const.STATE_UNKNOWN 190 | 191 | def __update_forecast_time(self, source_data): 192 | self._attr_state = source_data.time.astimezone(tz.tzlocal()).strftime("%H:%M") 193 | 194 | def __update_weather(self, source_data): 195 | self._attr_state = utils.get_weather_symbol(source_data.symbol.value) 196 | 197 | def __update_temperature(self, source_data): 198 | self.__convert_float(source_data, "temperature") 199 | 200 | def __update_wind_speed(self, source_data): 201 | self.__convert_float(source_data, "wind_speed") 202 | 203 | def __update_wind_direction(self, source_data): 204 | if source_data.wind_direction is None: 205 | self._attr_state = ha_const.STATE_UNAVAILABLE 206 | return 207 | self._attr_state = self.get_wind_direction_string(source_data.wind_direction.value) 208 | 209 | def __update_wind_gust(self, source_data): 210 | self.__convert_float(source_data, "wind_gust") 211 | 212 | def __update_humidity(self, source_data): 213 | self.__convert_float(source_data, "humidity") 214 | 215 | def __update_clouds(self, source_data): 216 | self.__convert_float(source_data, "cloud_cover") 217 | 218 | def __update_rain(self, source_data): 219 | self.__convert_float(source_data, "precipitation_amount") 220 | 221 | def __update_time(self, source_data): 222 | _ = source_data 223 | _fmi: FMIDataUpdateCoordinator = self.coordinator 224 | if _fmi.best_time is None: 225 | self._attr_state = ha_const.STATE_UNAVAILABLE 226 | return 227 | self._attr_state = _fmi.best_time.strftime("%H:%M") 228 | 229 | def update(self): 230 | """Update the state of the weather sensor.""" 231 | 232 | self.logger.debug("FMI: Sensor %s is updating", self._attr_name) 233 | 234 | _fmi: FMIDataUpdateCoordinator = self.coordinator 235 | weather = _fmi.get_weather() 236 | 237 | if weather is None: 238 | return 239 | 240 | # update the extra state attributes 241 | self._attr_extra_state_attributes = { 242 | ha_const.ATTR_LOCATION: weather.place, 243 | ha_const.ATTR_TIME: _fmi.best_time, 244 | ha_const.ATTR_TEMPERATURE: _fmi.best_temperature, 245 | const.ATTR_HUMIDITY: _fmi.best_humidity, 246 | const.ATTR_PRECIPITATION: _fmi.best_precipitation, 247 | const.ATTR_WIND_SPEED: _fmi.best_wind_speed, 248 | ha_const.ATTR_ATTRIBUTION: const.ATTRIBUTION, 249 | } 250 | 251 | if self.type == SensorType.PLACE: 252 | self._attr_state = weather.place 253 | return 254 | 255 | source_data = None 256 | 257 | # Update the sensor states 258 | if _fmi.time_step == 1: 259 | # Current weather 260 | source_data = weather.data 261 | else: 262 | # Forecasted weather based on configured time_step - next forecasted hour, if available 263 | 264 | _forecasts = _fmi.get_forecasts() 265 | 266 | if not _forecasts: 267 | self.logger.debug("FMI: Sensor _FMI Hourly Forecast is unavailable") 268 | return 269 | 270 | # If current time is half past or more then use the hour next to next hour 271 | # otherwise fallback to the next hour 272 | if len(_forecasts) > 1: 273 | curr_min = datetime.now().minute 274 | source_data = _forecasts[1 if curr_min >= 30 else 0] 275 | else: 276 | source_data = _forecasts[0] 277 | 278 | if source_data is None: 279 | self._attr_state = ha_const.STATE_UNAVAILABLE 280 | self.logger.debug("FMI: Sensor Source data is unavailable") 281 | return 282 | 283 | self.update_state_func(source_data) 284 | 285 | 286 | class FMILightningStrikesSensor(_BaseSensorClass): 287 | """Implementation of a FMI Lightning strikes sensor.""" 288 | 289 | def update(self): 290 | """Update the state of the lightning sensor.""" 291 | 292 | self.logger.debug("FMI: update lightning sensor %s", self._attr_name) 293 | 294 | _fmi: FMIDataUpdateCoordinator = self.coordinator 295 | _data = _fmi.lightning_data 296 | 297 | if not _data: 298 | self.logger.debug("FMI: Sensor lightning is unavailable") 299 | return 300 | 301 | self._attr_state = _data[0].location 302 | 303 | # update the extra state attributes 304 | self._attr_extra_state_attributes = { 305 | ha_const.ATTR_LOCATION: _data[0].location, 306 | ha_const.ATTR_TIME: _data[0].time, 307 | const.ATTR_DISTANCE: _data[0].distance, 308 | const.ATTR_STRIKES: _data[0].strikes, 309 | const.ATTR_PEAK_CURRENT: _data[0].peak_current, 310 | const.ATTR_CLOUD_COVER: _data[0].cloud_cover, 311 | const.ATTR_ELLIPSE_MAJOR: _data[0].ellipse_major, 312 | "OBSERVATIONS": [ 313 | { 314 | ha_const.ATTR_LOCATION: strike.location, 315 | ha_const.ATTR_TIME: strike.time, 316 | const.ATTR_DISTANCE: strike.distance, 317 | const.ATTR_STRIKES: strike.strikes, 318 | const.ATTR_PEAK_CURRENT: strike.peak_current, 319 | const.ATTR_CLOUD_COVER: strike.cloud_cover, 320 | const.ATTR_ELLIPSE_MAJOR: strike.ellipse_major, 321 | } 322 | for strike in _data[1:] 323 | ], 324 | ha_const.ATTR_ATTRIBUTION: const.ATTRIBUTION, 325 | } 326 | 327 | 328 | class FMIMareoSensor(_BaseSensorClass): 329 | """Implementation of a FMI sea water level sensor.""" 330 | 331 | def update(self): 332 | """Update the state of the mareo sensor.""" 333 | 334 | self.logger.debug("FMI: update mareo sensor %s", self._attr_name) 335 | 336 | _fmi: FMIDataUpdateCoordinator = self.coordinator 337 | mareo = _fmi.mareo_data 338 | 339 | if not mareo or not mareo.size(): 340 | self.logger.debug("FMI: Sensor mareo is unavailable") 341 | return 342 | 343 | mareo_data = mareo.get_values() 344 | 345 | self._attr_state = mareo_data[0].sea_level 346 | 347 | # update the extra state attributes 348 | self._attr_extra_state_attributes = { 349 | ha_const.ATTR_TIME: mareo_data[0].time, 350 | "FORECASTS": [ 351 | {"time": item.time, "height": item.sea_level} for item in mareo_data[1:] 352 | ], 353 | ha_const.ATTR_ATTRIBUTION: const.ATTRIBUTION, 354 | } 355 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | """The FMI (Finnish Meteorological Institute) component.""" 2 | 3 | import typing 4 | import time 5 | import xml.etree.ElementTree as ET 6 | from datetime import date, datetime, timedelta 7 | 8 | import fmi_weather_client as fmi 9 | import fmi_weather_client.models as fmi_models 10 | import fmi_weather_client.errors as fmi_erros 11 | import requests 12 | from async_timeout import timeout 13 | from dateutil import tz 14 | from geopy.distance import geodesic 15 | from geopy.geocoders import Nominatim 16 | from geopy.exc import GeocoderTimedOut, GeocoderUnavailable 17 | from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_OFFSET 18 | from homeassistant.core import HomeAssistant 19 | from homeassistant.helpers.typing import ConfigType 20 | from homeassistant.exceptions import ConfigEntryNotReady 21 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 22 | from homeassistant.helpers.update_coordinator import (DataUpdateCoordinator, 23 | UpdateFailed) 24 | 25 | from . import utils 26 | from . import const 27 | 28 | 29 | LOGGER = const.LOGGER 30 | PLATFORMS = ["sensor", "weather"] 31 | 32 | 33 | def base_unique_id(latitude, longitude): 34 | """Return unique id for entries in configuration.""" 35 | return f"{latitude}_{longitude}" 36 | 37 | 38 | async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: 39 | """Set up configured FMI.""" 40 | _ = config 41 | hass.data.setdefault(const.DOMAIN, {}) 42 | return True 43 | 44 | 45 | async def async_setup_entry(hass, config_entry) -> bool: 46 | """Set up FMI as config entry.""" 47 | websession = async_get_clientsession(hass) 48 | 49 | coordinator = FMIDataUpdateCoordinator( 50 | hass, websession, config_entry 51 | ) 52 | await coordinator.async_config_entry_first_refresh() 53 | 54 | if not coordinator.last_update_success: 55 | raise ConfigEntryNotReady 56 | 57 | try: 58 | coordinator_observation = FMIObservationUpdateCoordinator( 59 | hass, websession, config_entry) 60 | await coordinator_observation.async_config_entry_first_refresh() 61 | if not coordinator_observation.last_update_success: 62 | raise ConfigEntryNotReady 63 | except AttributeError: 64 | coordinator_observation = None 65 | 66 | undo_listener = config_entry.add_update_listener(update_listener) 67 | 68 | hass.data[const.DOMAIN][config_entry.entry_id] = { 69 | const.COORDINATOR: coordinator, 70 | const.COORDINATOR_OBSERVATION: coordinator_observation, 71 | const.UNDO_UPDATE_LISTENER: undo_listener, 72 | } 73 | 74 | await hass.config_entries.async_forward_entry_setups( 75 | config_entry, 76 | PLATFORMS 77 | ) 78 | 79 | return True 80 | 81 | 82 | async def async_unload_entry(hass, config_entry): 83 | """Unload an FMI config entry.""" 84 | await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) 85 | 86 | hass.data[const.DOMAIN][config_entry.entry_id][const.UNDO_UPDATE_LISTENER]() 87 | hass.data[const.DOMAIN].pop(config_entry.entry_id) 88 | 89 | return True 90 | 91 | 92 | async def update_listener(hass, config_entry): 93 | """Update FMI listener.""" 94 | await hass.config_entries.async_reload(config_entry.entry_id) 95 | 96 | 97 | class FMILightningStruct(): 98 | """Lightning data structure""" 99 | 100 | def __init__(self, time_val, location, distance, strikes, 101 | peak_current, cloud_cover, ellipse_major): 102 | """Initialize the lightning parameters.""" 103 | # self.time = time_val 104 | _time = datetime.fromisoformat(time_val) 105 | self.time = _time.strftime("%Y-%m-%d %H:%M") 106 | self.location = location 107 | self.distance = float(f"{float(distance):.2f}") 108 | self.strikes = int(strikes) 109 | self.peak_current = float(peak_current) 110 | self.cloud_cover = float(cloud_cover) 111 | self.ellipse_major = float(ellipse_major) 112 | 113 | 114 | class FMIMareoStruct(): 115 | """Mareo data structure""" 116 | 117 | class SeaLevelData(): 118 | def __init__(self, time_val: str, sea_level: float): 119 | """Initialize the sea level data.""" 120 | _time = datetime.fromisoformat(time_val) 121 | self.time = _time.strftime("%Y-%m-%d %H:%M") 122 | self.sea_level = float(sea_level) 123 | 124 | def __init__(self): 125 | """Initialize the sea height data.""" 126 | self.sea_levels: list[FMIMareoStruct.SeaLevelData] = [] 127 | 128 | def size(self) -> int: 129 | """Get the size of the sea level data.""" 130 | return len(self.sea_levels) 131 | 132 | def get_values(self) -> list[SeaLevelData]: 133 | """Get the sea level values.""" 134 | return list(self.sea_levels) 135 | 136 | def append(self, sea_level_data: SeaLevelData): 137 | """Clear the sea level data.""" 138 | self.sea_levels.append(sea_level_data) 139 | 140 | def append_values(self, time_val, sea_level): 141 | """Clear the sea level data.""" 142 | sea_level_data = FMIMareoStruct.SeaLevelData(time_val, sea_level) 143 | self.sea_levels.append(sea_level_data) 144 | 145 | 146 | class FMIDataUpdateCoordinator(DataUpdateCoordinator): 147 | """Class to manage fetching FMI data API.""" 148 | 149 | def __init__(self, hass: HomeAssistant, session, config_entry, 150 | update_interval=const.FORECAST_UPDATE_INTERVAL, 151 | unique_id_add="", name=""): 152 | """Initialize.""" 153 | _ = session 154 | 155 | self.logger = const.LOGGER.getChild("coordinator") 156 | 157 | self._hass = hass 158 | self.latitude = latitude = config_entry.data[CONF_LATITUDE] 159 | self.longitude = longitude = config_entry.data[CONF_LONGITUDE] 160 | self.unique_id = str(latitude) + ":" + str(longitude) + unique_id_add 161 | 162 | self.logger.debug(f"Using latitude: {latitude} and longitude: {longitude}") 163 | 164 | _options: dict = config_entry.options 165 | 166 | self.time_step = int(_options.get(CONF_OFFSET, const.FORECAST_OFFSET[0])) 167 | self.forecast_points = ( 168 | int(_options.get(const.CONF_FORECAST_DAYS, const.DAYS_DEFAULT) 169 | ) * 24 // self.time_step) 170 | self.min_temperature = float(_options.get( 171 | const.CONF_MIN_TEMP, const.TEMP_MIN_DEFAULT)) 172 | self.max_temperature = float(_options.get( 173 | const.CONF_MAX_TEMP, const.TEMP_MAX_DEFAULT)) 174 | self.min_humidity = float(_options.get( 175 | const.CONF_MIN_HUMIDITY, const.HUMIDITY_MIN_DEFAULT)) 176 | self.max_humidity = float(_options.get( 177 | const.CONF_MAX_HUMIDITY, const.HUMIDITY_MAX_DEFAULT)) 178 | self.min_wind_speed = float(_options.get( 179 | const.CONF_MIN_WIND_SPEED, const.WIND_SPEED_MIN_DEFAULT)) 180 | self.max_wind_speed = float(_options.get( 181 | const.CONF_MAX_WIND_SPEED, const.WIND_SPEED_MAX_DEFAULT)) 182 | self.min_precip = float(_options.get( 183 | const.CONF_MIN_PRECIPITATION, const.PRECIPITATION_MIN_DEFAULT)) 184 | self.max_precip = float(_options.get( 185 | const.CONF_MAX_PRECIPITATION, const.PRECIPITATION_MAX_DEFAULT)) 186 | self.daily_mode = bool(_options.get( 187 | const.CONF_DAILY_MODE, const.DAILY_MODE_DEFAULT)) 188 | self.lightning_mode = bool(_options.get( 189 | const.CONF_LIGHTNING, const.LIGHTNING_DEFAULT)) 190 | self.lightning_radius = int(_options.get( 191 | const.CONF_LIGHTNING_DISTANCE, const.BOUNDING_BOX_HALF_SIDE_KM)) 192 | 193 | # Observation data if the station id is set and valid 194 | self.observation: typing.Optional[fmi_models.Weather] = None 195 | # Current weather based on forecast for selected coordinates 196 | # Note: this is an estimation received from FMI 197 | self.current: typing.Optional[fmi_models.Weather] = None 198 | # Next day(s) forecasts 199 | self.forecast: typing.Optional[fmi_models.Forecast] = None 200 | 201 | # Best Time Attributes derived based on forecast weather data 202 | self.best_time: typing.Optional[datetime] = None 203 | self.best_temperature: typing.Optional[float] = None 204 | self.best_humidity: typing.Optional[float] = None 205 | self.best_wind_speed: typing.Optional[float] = None 206 | self.best_precipitation: typing.Optional[float] = None 207 | self.best_state: typing.Optional[str] = None 208 | 209 | # Lightning strikes 210 | self.lightning_data: typing.Optional[list[FMILightningStruct]] = None 211 | 212 | # Mareo 213 | self.mareo_data: typing.Optional[FMIMareoStruct] = None 214 | 215 | name = name if name else const.DOMAIN 216 | 217 | self.logger.debug(f"FMI {name}: Data will be updated every {update_interval} min") 218 | 219 | super().__init__(hass, self.logger, config_entry=config_entry, 220 | name=name, update_interval=update_interval) 221 | 222 | def get_observation(self) -> typing.Optional[fmi_models.Weather]: 223 | """Return the current observation data.""" 224 | return self.observation 225 | 226 | def get_weather(self) -> typing.Optional[fmi_models.Weather]: 227 | """Return the current weather data.""" 228 | return self.current 229 | 230 | def get_forecasts(self) -> typing.List[fmi_models.WeatherData]: 231 | """Return the current forecast data.""" 232 | if self.forecast is None: 233 | return [] 234 | return self.forecast.forecasts 235 | 236 | def get_current_place(self) -> typing.Optional[str]: 237 | """Return the current place.""" 238 | if self.current is not None and hasattr(self.current, 'place'): 239 | return self.current.place 240 | return None 241 | 242 | def __update_best_weather_condition(self): 243 | 244 | _weather = self.get_weather() 245 | 246 | if _weather is None: 247 | return 248 | 249 | _forecasts = self.get_forecasts() 250 | 251 | curr_date = date.today() 252 | 253 | # Init values 254 | self.best_state = const.BEST_CONDITION_NOT_AVAIL 255 | self.best_time = _weather.data.time.astimezone(tz.tzlocal()) 256 | self.best_temperature = _weather.data.temperature.value 257 | self.best_humidity = _weather.data.humidity.value 258 | self.best_wind_speed = _weather.data.wind_speed.value 259 | self.best_precipitation = _weather.data.precipitation_amount.value 260 | 261 | for forecast in _forecasts: 262 | local_time = forecast.time.astimezone(tz.tzlocal()) 263 | 264 | if local_time.day == curr_date.day + 1: 265 | # Tracking best conditions for only this day 266 | break 267 | 268 | if (forecast.symbol.value not in const.BEST_COND_SYMBOLS or 269 | (wind_speed := forecast.wind_speed.value) is None or 270 | wind_speed < self.min_wind_speed or 271 | wind_speed > self.max_wind_speed): 272 | continue 273 | 274 | temperature = forecast.temperature.value 275 | if (temperature is None or 276 | temperature < self.min_temperature or 277 | temperature > self.max_temperature): 278 | continue 279 | 280 | if ((humidity := forecast.humidity.value) is None or 281 | humidity < self.min_humidity or 282 | humidity > self.max_humidity): 283 | continue 284 | 285 | if ((precipitation_amount := forecast.precipitation_amount.value) is None or 286 | precipitation_amount < self.min_precip or 287 | precipitation_amount > self.max_precip): 288 | continue 289 | 290 | # What more can you ask for? 291 | # Compare with temperature value already stored and 292 | # update if necessary 293 | 294 | self.best_state = const.BEST_CONDITION_AVAIL 295 | 296 | if self.best_temperature is None or temperature > self.best_temperature: 297 | self.best_time = local_time 298 | self.best_temperature = temperature 299 | self.best_humidity = forecast.humidity.value 300 | self.best_wind_speed = forecast.wind_speed.value 301 | self.best_precipitation = forecast.precipitation_amount.value 302 | 303 | def __lightning_strikes_postions(self, loc_list: list, text: str, 304 | timeout_time: float): 305 | home_cords = (self.latitude, self.longitude) 306 | val_list = text.lstrip().split("\n") 307 | 308 | for loc_index, val in enumerate(val_list): 309 | if not val: 310 | continue 311 | 312 | val_split = val.split(" ") 313 | lightning_coords = (float(val_split[0]), float(val_split[1])) 314 | distance = 0 315 | 316 | try: 317 | distance = geodesic(lightning_coords, home_cords).km 318 | except (AttributeError, ValueError): 319 | self.logger.error("Unable to find distance between " 320 | f"{lightning_coords} and {home_cords}") 321 | 322 | add_tuple = (val_split[0], val_split[1], val_split[2], 323 | distance, loc_index) 324 | loc_list.append(add_tuple) 325 | 326 | if time.time() > timeout_time: 327 | break 328 | 329 | def __lightning_strikes_reasons_list(self, loc_list: list, text: str, 330 | timeout_time: float): 331 | val_list = text.lstrip().split("\n") 332 | 333 | for index, val in enumerate(val_list): 334 | if not val: 335 | continue 336 | 337 | val_split = val.split(" ") 338 | exist_tuple = loc_list[index] 339 | 340 | if index != exist_tuple[4]: 341 | self.logger.debug("Record mismatch - aborting query") 342 | break 343 | 344 | loc_list[index] = (exist_tuple[0], exist_tuple[1], exist_tuple[2], 345 | exist_tuple[3], val_split[0], val_split[1], 346 | val_split[2], val_split[3]) 347 | 348 | if time.time() > timeout_time: 349 | break 350 | 351 | def __get_lightning_url(self): 352 | """Generate URL and fetch data from FMI for lightning sensors.""" 353 | 354 | start_time = datetime.today() - timedelta(days=const.LIGHTNING_DAYS_LIMIT) 355 | # Format datetime to string accepted as path parameter in REST 356 | start_time = start_time.strftime("starttime=%Y-%m-%dT%H:%M:%SZ") 357 | 358 | # Get Bounding Box coords 359 | bbox_coords = utils.get_bounding_box(self.latitude, self.longitude, 360 | half_side_in_km=self.lightning_radius) 361 | bbox_uri_param = "bbox=" \ 362 | f"{bbox_coords.lon_min},{bbox_coords.lat_min},"\ 363 | f"{bbox_coords.lon_max},{bbox_coords.lat_max}" 364 | 365 | base_url = const.LIGHTNING_GET_URL + start_time + "&" + bbox_uri_param + "&" 366 | self.logger.debug(f"FMI: Lightning URI - {base_url}") 367 | 368 | # Fetch data 369 | response = requests.get(base_url, timeout=const.TIMEOUT_LIGHTNING_PULL_IN_SECS) 370 | return ET.fromstring(response.content) 371 | 372 | def __update_lightning_strikes(self): 373 | self.logger.debug("FMI: Lightning started") 374 | 375 | loc_time_list = [] 376 | root = self.__get_lightning_url() 377 | _timeout = time.time() + (const.LIGHTNING_LOOP_TIMEOUT_IN_SECS * 1000) 378 | 379 | for child in root.iter(): 380 | if not child.text: 381 | continue 382 | if child.tag.find("positions") > 0: 383 | self.__lightning_strikes_postions( 384 | loc_time_list, child.text, _timeout) 385 | 386 | elif child.tag.find("doubleOrNilReasonTupleList") > 0: 387 | self.__lightning_strikes_reasons_list( 388 | loc_time_list, child.text, _timeout) 389 | 390 | # First sort for closes entries and filter to limit 391 | loc_time_list = sorted(loc_time_list, key=lambda item: item[3]) 392 | 393 | self.logger.debug(f"FMI - Coords retrieved for Lightning Data- {len(loc_time_list)}") 394 | 395 | loc_time_list = loc_time_list[:const.LIGHTNING_LIMIT] 396 | 397 | # Second Sort based on date 398 | loc_time_list = sorted(loc_time_list, key=(lambda item: item[2]), reverse=True) 399 | 400 | geolocator = Nominatim(user_agent="fmi_hassio_sensor") 401 | 402 | # Reverse geocoding 403 | op_tuples = [] 404 | for v in loc_time_list: 405 | location = str(v[0]) + ", " + str(v[1]) 406 | loc_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(int(v[2]))) 407 | try: 408 | location = geolocator.reverse(location, language="en", timeout=5).address 409 | except (AttributeError, ValueError, GeocoderUnavailable, GeocoderTimedOut) as e: 410 | self.logger.error(f"Unable to reverse geocode for address-{location}. " 411 | f"Got error-{e}") 412 | 413 | # Time, Location, Distance, Strikes, Peak Current, Cloud Cover, Ellipse Major 414 | op = FMILightningStruct(time_val=loc_time, location=location, distance=v[3], 415 | strikes=v[4], peak_current=v[5], cloud_cover=v[6], 416 | ellipse_major=v[7]) 417 | op_tuples.append(op) 418 | self.lightning_data = op_tuples 419 | self.logger.debug("FMI: Lightning ended") 420 | 421 | # Update mareo data 422 | def __update_mareo_data(self): 423 | """Get the latest mareograph forecast data from FMI and update the states.""" 424 | 425 | self.logger.debug("FMI: mareo started") 426 | # Format datetime to string accepted as path parameter in REST 427 | start_time = datetime.today().strftime("starttime=%Y-%m-%dT%H:%M:%SZ") 428 | 429 | # Format location to string accepted as path parameter in REST 430 | loc_string = "latlon=" + str(self.latitude) + "," + str(self.longitude) 431 | 432 | base_mareo_url = const.MAREO_GET_URL + loc_string + "&" + start_time + "&" 433 | self.logger.debug("FMI: Using Mareo URL: %s", base_mareo_url) 434 | 435 | # Fetch data 436 | response_mareo = requests.get(base_mareo_url, timeout=const.TIMEOUT_MAREO_PULL_IN_SECS) 437 | 438 | root_mareo: list = ET.fromstring(response_mareo.content) 439 | 440 | self.mareo_data = mareo_data = FMIMareoStruct() 441 | 442 | # for n in range(len(root_mareo)): 443 | for index, mareo in enumerate(root_mareo): 444 | try: 445 | if mareo[0][2].text == 'SeaLevel': 446 | mareo_data.append_values(mareo[0][1].text, mareo[0][3].text) 447 | elif mareo[0][2].text == 'SeaLevelN2000': 448 | continue 449 | else: 450 | self.logger.debug("Sealevel forecast unsupported record: %s", 451 | mareo[0][2].text) 452 | continue 453 | except IndexError: 454 | self.logger.debug("Sealevel forecast records not in expected format " 455 | f"for index - {index} of locstring - {loc_string}") 456 | 457 | if mareo_data.size(): 458 | self.logger.debug("FMI: Mareo data updated") 459 | else: 460 | self.logger.debug("FMI: Mareo data not updated. No data available") 461 | 462 | async def _fetch_forecast_weather(self): 463 | """Fetch current weather data based on estimation (forecast).""" 464 | try: 465 | data = await fmi.async_weather_by_coordinates(self.latitude, self.longitude) 466 | except (fmi_erros.ClientError, fmi_erros.ServerError) as error: 467 | self.logger.error("FMI: unable to fetch weather data! error %s", error) 468 | return None 469 | return data 470 | 471 | async def _fetch_forecast(self): 472 | """Fetch current forecast data.""" 473 | if not self.forecast_points: 474 | return 475 | try: 476 | self.forecast = await fmi.async_forecast_by_coordinates( 477 | self.latitude, self.longitude, self.time_step, self.forecast_points) 478 | except (fmi_erros.ClientError, fmi_erros.ServerError) as error: 479 | self.logger.error("FMI: unable to fetch forecast data! error %s", error) 480 | 481 | async def _async_update_data(self): 482 | """Update data via Open API.""" 483 | 484 | # do actual data fetching 485 | try: 486 | async with timeout(const.TIMEOUT_FMI_INTEG_IN_SEC): 487 | self.logger.debug("FMI: fetch latest forecast data") 488 | weather_data = await self._fetch_forecast_weather() 489 | if not weather_data: 490 | # Weather is always needed! 491 | raise UpdateFailed("FMI: Unable to fetch observation or forecast data!") 492 | self.current = weather_data 493 | 494 | await self._fetch_forecast() 495 | 496 | # Update best time parameters 497 | await self._hass.async_add_executor_job(self.__update_best_weather_condition) 498 | self.logger.debug("FMI: Best Conditions updated") 499 | 500 | # Update lightning strikes 501 | if self.lightning_mode and self.lightning_radius: 502 | await self._hass.async_add_executor_job(self.__update_lightning_strikes) 503 | self.logger.debug("FMI: Lightning Conditions updated") 504 | 505 | # Update mareograph data on sea level 506 | await self._hass.async_add_executor_job(self.__update_mareo_data) 507 | self.logger.debug("FMI: Mareograph sea level data updated") 508 | 509 | except (TimeoutError, UpdateFailed) as error: 510 | raise UpdateFailed(error) from error 511 | 512 | self.async_set_updated_data({"current": self.current}) 513 | return {} 514 | 515 | 516 | class FMIObservationUpdateCoordinator(FMIDataUpdateCoordinator): 517 | 518 | def __init__(self, hass: HomeAssistant, session, config_entry, 519 | update_interval=const.OBSERVATION_UPDATE_INTERVAL): 520 | 521 | self.observation_station_id = int( 522 | config_entry.options.get(const.CONF_OBSERVATION_STATION, 0)) 523 | if not self.observation_station_id: 524 | raise AttributeError("observation not configured!") 525 | 526 | name = f"{const.DOMAIN} Observation" 527 | 528 | super().__init__(hass, session, config_entry, update_interval, 529 | unique_id_add="_observation", name=name) 530 | 531 | async def _fetch_observation(self): 532 | """Fetch the latest obsevation data from specified station.""" 533 | if not self.observation_station_id: 534 | return None 535 | try: 536 | self.observation = await fmi.async_observation_by_station_id( 537 | self.observation_station_id) 538 | except (fmi_erros.ClientError, fmi_erros.ServerError) as error: 539 | self.logger.error("FMI: unable to fetch observation data from station %d! error %s", 540 | self.observation_station_id, error) 541 | 542 | async def _async_update_data(self): 543 | """Update observation data via Open API.""" 544 | self.logger.debug("FMI: Updating observation data for station %d", 545 | self.observation_station_id) 546 | try: 547 | async with timeout(const.TIMEOUT_FMI_INTEG_IN_SEC): 548 | await self._fetch_observation() 549 | except (TimeoutError, UpdateFailed) as error: 550 | raise UpdateFailed(error) from error 551 | return {} 552 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [MAIN] 2 | 3 | # Analyse import fallback blocks. This can be used to support both Python 2 and 4 | # 3 compatible code, which means that the block might have code that exists 5 | # only in one or another interpreter, leading to false positives when analysed. 6 | analyse-fallback-blocks=no 7 | 8 | # Clear in-memory caches upon conclusion of linting. Useful if running pylint 9 | # in a server-like mode. 10 | clear-cache-post-run=no 11 | 12 | # Load and enable all available extensions. Use --list-extensions to see a list 13 | # all available extensions. 14 | #enable-all-extensions= 15 | 16 | # In error mode, messages with a category besides ERROR or FATAL are 17 | # suppressed, and no reports are done by default. Error mode is compatible with 18 | # disabling specific errors. 19 | #errors-only= 20 | 21 | # Always return a 0 (non-error) status code, even if lint errors are found. 22 | # This is primarily useful in continuous integration scripts. 23 | #exit-zero= 24 | 25 | # A comma-separated list of package or module names from where C extensions may 26 | # be loaded. Extensions are loading into the active Python interpreter and may 27 | # run arbitrary code. 28 | extension-pkg-allow-list= 29 | 30 | # A comma-separated list of package or module names from where C extensions may 31 | # be loaded. Extensions are loading into the active Python interpreter and may 32 | # run arbitrary code. (This is an alternative name to extension-pkg-allow-list 33 | # for backward compatibility.) 34 | extension-pkg-whitelist= 35 | 36 | # Return non-zero exit code if any of these messages/categories are detected, 37 | # even if score is above --fail-under value. Syntax same as enable. Messages 38 | # specified are enabled, while categories only check already-enabled messages. 39 | fail-on= 40 | 41 | # Specify a score threshold under which the program will exit with error. 42 | fail-under=10 43 | 44 | # Interpret the stdin as a python script, whose filename needs to be passed as 45 | # the module_or_package argument. 46 | #from-stdin= 47 | 48 | # Files or directories to be skipped. They should be base names, not paths. 49 | ignore=CVS 50 | 51 | # Add files or directories matching the regular expressions patterns to the 52 | # ignore-list. The regex matches against paths and can be in Posix or Windows 53 | # format. Because '\\' represents the directory delimiter on Windows systems, 54 | # it can't be used as an escape character. 55 | ignore-paths= 56 | 57 | # Files or directories matching the regular expression patterns are skipped. 58 | # The regex matches against base names, not paths. The default value ignores 59 | # Emacs file locks 60 | ignore-patterns=^\.# 61 | 62 | # List of module names for which member attributes should not be checked and 63 | # will not be imported (useful for modules/projects where namespaces are 64 | # manipulated during runtime and thus existing member attributes cannot be 65 | # deduced by static analysis). It supports qualified module names, as well as 66 | # Unix pattern matching. 67 | ignored-modules=homeassistant 68 | 69 | # Python code to execute, usually for sys.path manipulation such as 70 | # pygtk.require(). 71 | #init-hook= 72 | 73 | # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the 74 | # number of processors available to use, and will cap the count on Windows to 75 | # avoid hangs. 76 | jobs=1 77 | 78 | # Control the amount of potential inferred values when inferring a single 79 | # object. This can help the performance when dealing with large functions or 80 | # complex, nested conditions. 81 | limit-inference-results=100 82 | 83 | # List of plugins (as comma separated values of python module names) to load, 84 | # usually to register additional checkers. 85 | load-plugins= 86 | 87 | # Pickle collected data for later comparisons. 88 | persistent=yes 89 | 90 | # Resolve imports to .pyi stubs if available. May reduce no-member messages and 91 | # increase not-an-iterable messages. 92 | prefer-stubs=no 93 | 94 | # Minimum Python version to use for version dependent checks. Will default to 95 | # the version used to run pylint. 96 | py-version=3.12 97 | 98 | # Discover python modules and packages in the file system subtree. 99 | recursive=no 100 | 101 | # Add paths to the list of the source roots. Supports globbing patterns. The 102 | # source root is an absolute path or a path relative to the current working 103 | # directory used to determine a package namespace for modules located under the 104 | # source root. 105 | source-roots= 106 | 107 | # Allow loading of arbitrary C extensions. Extensions are imported into the 108 | # active Python interpreter and may run arbitrary code. 109 | unsafe-load-any-extension=no 110 | 111 | # In verbose mode, extra non-checker-related info will be displayed. 112 | #verbose= 113 | 114 | 115 | [BASIC] 116 | 117 | # Naming style matching correct argument names. 118 | argument-naming-style=snake_case 119 | 120 | # Regular expression matching correct argument names. Overrides argument- 121 | # naming-style. If left empty, argument names will be checked with the set 122 | # naming style. 123 | #argument-rgx= 124 | 125 | # Naming style matching correct attribute names. 126 | attr-naming-style=snake_case 127 | 128 | # Regular expression matching correct attribute names. Overrides attr-naming- 129 | # style. If left empty, attribute names will be checked with the set naming 130 | # style. 131 | #attr-rgx= 132 | 133 | # Bad variable names which should always be refused, separated by a comma. 134 | bad-names=foo, 135 | bar, 136 | baz, 137 | toto, 138 | tutu, 139 | tata 140 | 141 | # Bad variable names regexes, separated by a comma. If names match any regex, 142 | # they will always be refused 143 | bad-names-rgxs= 144 | 145 | # Naming style matching correct class attribute names. 146 | class-attribute-naming-style=any 147 | 148 | # Regular expression matching correct class attribute names. Overrides class- 149 | # attribute-naming-style. If left empty, class attribute names will be checked 150 | # with the set naming style. 151 | #class-attribute-rgx= 152 | 153 | # Naming style matching correct class constant names. 154 | class-const-naming-style=UPPER_CASE 155 | 156 | # Regular expression matching correct class constant names. Overrides class- 157 | # const-naming-style. If left empty, class constant names will be checked with 158 | # the set naming style. 159 | #class-const-rgx= 160 | 161 | # Naming style matching correct class names. 162 | class-naming-style=PascalCase 163 | 164 | # Regular expression matching correct class names. Overrides class-naming- 165 | # style. If left empty, class names will be checked with the set naming style. 166 | #class-rgx= 167 | 168 | # Naming style matching correct constant names. 169 | const-naming-style=UPPER_CASE 170 | 171 | # Regular expression matching correct constant names. Overrides const-naming- 172 | # style. If left empty, constant names will be checked with the set naming 173 | # style. 174 | #const-rgx= 175 | 176 | # Minimum line length for functions/classes that require docstrings, shorter 177 | # ones are exempt. 178 | docstring-min-length=-1 179 | 180 | # Naming style matching correct function names. 181 | function-naming-style=snake_case 182 | 183 | # Regular expression matching correct function names. Overrides function- 184 | # naming-style. If left empty, function names will be checked with the set 185 | # naming style. 186 | #function-rgx= 187 | 188 | # Good variable names which should always be accepted, separated by a comma. 189 | good-names=i, 190 | j, 191 | k, 192 | ex, 193 | Run, 194 | _, 195 | fmi-hass-custom 196 | 197 | # Good variable names regexes, separated by a comma. If names match any regex, 198 | # they will always be accepted 199 | good-names-rgxs= 200 | 201 | # Include a hint for the correct naming format with invalid-name. 202 | include-naming-hint=no 203 | 204 | # Naming style matching correct inline iteration names. 205 | inlinevar-naming-style=any 206 | 207 | # Regular expression matching correct inline iteration names. Overrides 208 | # inlinevar-naming-style. If left empty, inline iteration names will be checked 209 | # with the set naming style. 210 | #inlinevar-rgx= 211 | 212 | # Naming style matching correct method names. 213 | method-naming-style=snake_case 214 | 215 | # Regular expression matching correct method names. Overrides method-naming- 216 | # style. If left empty, method names will be checked with the set naming style. 217 | #method-rgx= 218 | 219 | # Naming style matching correct module names. 220 | module-naming-style=snake_case 221 | 222 | # Regular expression matching correct module names. Overrides module-naming- 223 | # style. If left empty, module names will be checked with the set naming style. 224 | #module-rgx= 225 | 226 | # Colon-delimited sets of names that determine each other's naming style when 227 | # the name regexes allow several styles. 228 | name-group= 229 | 230 | # Regular expression which should only match function or class names that do 231 | # not require a docstring. 232 | no-docstring-rgx=^_ 233 | 234 | # List of decorators that produce properties, such as abc.abstractproperty. Add 235 | # to this list to register other decorators that produce valid properties. 236 | # These decorators are taken in consideration only for invalid-name. 237 | property-classes=abc.abstractproperty 238 | 239 | # Regular expression matching correct type alias names. If left empty, type 240 | # alias names will be checked with the set naming style. 241 | #typealias-rgx= 242 | 243 | # Regular expression matching correct type variable names. If left empty, type 244 | # variable names will be checked with the set naming style. 245 | #typevar-rgx= 246 | 247 | # Naming style matching correct variable names. 248 | variable-naming-style=snake_case 249 | 250 | # Regular expression matching correct variable names. Overrides variable- 251 | # naming-style. If left empty, variable names will be checked with the set 252 | # naming style. 253 | #variable-rgx= 254 | 255 | 256 | [CLASSES] 257 | 258 | # Warn about protected attribute access inside special methods 259 | check-protected-access-in-special-methods=no 260 | 261 | # List of method names used to declare (i.e. assign) instance attributes. 262 | defining-attr-methods=__init__, 263 | __new__, 264 | setUp, 265 | asyncSetUp, 266 | __post_init__ 267 | 268 | # List of member names, which should be excluded from the protected access 269 | # warning. 270 | exclude-protected=_asdict,_fields,_replace,_source,_make,os._exit 271 | 272 | # List of valid names for the first argument in a class method. 273 | valid-classmethod-first-arg=cls 274 | 275 | # List of valid names for the first argument in a metaclass class method. 276 | valid-metaclass-classmethod-first-arg=mcs 277 | 278 | 279 | [DESIGN] 280 | 281 | # List of regular expressions of class ancestor names to ignore when counting 282 | # public methods (see R0903) 283 | exclude-too-few-public-methods= 284 | 285 | # List of qualified class names to ignore when counting class parents (see 286 | # R0901) 287 | ignored-parents= 288 | 289 | # Maximum number of arguments for function / method. 290 | max-args=8 291 | 292 | # Maximum number of attributes for a class (see R0902). 293 | max-attributes=30 294 | 295 | # Maximum number of boolean expressions in an if statement (see R0916). 296 | max-bool-expr=5 297 | 298 | # Maximum number of branch for function / method body. 299 | max-branches=12 300 | 301 | # Maximum number of locals for function / method body. 302 | max-locals=15 303 | 304 | # Maximum number of parents for a class (see R0901). 305 | max-parents=7 306 | 307 | # Maximum number of positional arguments for function / method. 308 | max-positional-arguments=8 309 | 310 | # Maximum number of public methods for a class (see R0904). 311 | max-public-methods=20 312 | 313 | # Maximum number of return / yield for function / method body. 314 | max-returns=10 315 | 316 | # Maximum number of statements in function / method body. 317 | max-statements=50 318 | 319 | # Minimum number of public methods for a class (see R0903). 320 | min-public-methods=0 321 | 322 | 323 | [EXCEPTIONS] 324 | 325 | # Exceptions that will emit a warning when caught. 326 | overgeneral-exceptions=builtins.BaseException,builtins.Exception 327 | 328 | 329 | [FORMAT] 330 | 331 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 332 | expected-line-ending-format= 333 | 334 | # Regexp for a line that is allowed to be longer than the limit. 335 | ignore-long-lines=^\s*(# )??$ 336 | 337 | # Number of spaces of indent required inside a hanging or continued line. 338 | indent-after-paren=4 339 | 340 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 341 | # tab). 342 | indent-string=' ' 343 | 344 | # Maximum number of characters on a single line. 345 | max-line-length=100 346 | 347 | # Maximum number of lines in a module. 348 | max-module-lines=1000 349 | 350 | # Allow the body of a class to be on the same line as the declaration if body 351 | # contains single statement. 352 | single-line-class-stmt=no 353 | 354 | # Allow the body of an if to be on the same line as the test if there is no 355 | # else. 356 | single-line-if-stmt=no 357 | 358 | 359 | [IMPORTS] 360 | 361 | # List of modules that can be imported at any level, not just the top level 362 | # one. 363 | allow-any-import-level= 364 | 365 | # Allow explicit reexports by alias from a package __init__. 366 | allow-reexport-from-package=no 367 | 368 | # Allow wildcard imports from modules that define __all__. 369 | allow-wildcard-with-all=no 370 | 371 | # Deprecated modules which should not be used, separated by a comma. 372 | deprecated-modules= 373 | 374 | # Output a graph (.gv or any supported image format) of external dependencies 375 | # to the given file (report RP0402 must not be disabled). 376 | ext-import-graph= 377 | 378 | # Output a graph (.gv or any supported image format) of all (i.e. internal and 379 | # external) dependencies to the given file (report RP0402 must not be 380 | # disabled). 381 | import-graph= 382 | 383 | # Output a graph (.gv or any supported image format) of internal dependencies 384 | # to the given file (report RP0402 must not be disabled). 385 | int-import-graph= 386 | 387 | # Force import order to recognize a module as part of the standard 388 | # compatibility libraries. 389 | known-standard-library= 390 | 391 | # Force import order to recognize a module as part of a third party library. 392 | known-third-party=enchant 393 | 394 | # Couples of modules and preferred modules, separated by a comma. 395 | preferred-modules= 396 | 397 | 398 | [LOGGING] 399 | 400 | # The type of string formatting that logging methods do. `old` means using % 401 | # formatting, `new` is for `{}` formatting. 402 | logging-format-style=old 403 | 404 | # Logging modules to check that the string format arguments are in logging 405 | # function parameter format. 406 | logging-modules=logging 407 | 408 | 409 | [MESSAGES CONTROL] 410 | 411 | # Only show warnings with the listed confidence levels. Leave empty to show 412 | # all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE, 413 | # UNDEFINED. 414 | confidence=HIGH, 415 | CONTROL_FLOW, 416 | INFERENCE, 417 | INFERENCE_FAILURE, 418 | UNDEFINED 419 | 420 | # Disable the message, report, category or checker with the given id(s). You 421 | # can either give multiple identifiers separated by comma (,) or put this 422 | # option multiple times (only on the command line, not in the configuration 423 | # file where it should appear only once). You can also use "--disable=all" to 424 | # disable everything first and then re-enable specific checks. For example, if 425 | # you want to run only the similarities checker, you can use "--disable=all 426 | # --enable=similarities". If you want to run only the classes checker, but have 427 | # no Warning level messages displayed, use "--disable=all --enable=classes 428 | # --disable=W". 429 | disable=raw-checker-failed, 430 | bad-inline-option, 431 | locally-disabled, 432 | file-ignored, 433 | suppressed-message, 434 | useless-suppression, 435 | deprecated-pragma, 436 | use-implicit-booleaness-not-comparison-to-string, 437 | use-implicit-booleaness-not-comparison-to-zero, 438 | use-symbolic-message-instead, 439 | W1201, W1203, C0115 440 | 441 | # Enable the message, report, category or checker with the given id(s). You can 442 | # either give multiple identifier separated by comma (,) or put this option 443 | # multiple time (only on the command line, not in the configuration file where 444 | # it should appear only once). See also the "--disable" option for examples. 445 | enable= 446 | 447 | 448 | [METHOD_ARGS] 449 | 450 | # List of qualified names (i.e., library.method) which require a timeout 451 | # parameter e.g. 'requests.api.get,requests.api.post' 452 | timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request 453 | 454 | 455 | [MISCELLANEOUS] 456 | 457 | # Whether or not to search for fixme's in docstrings. 458 | #check-fixme-in-docstring=no 459 | 460 | # List of note tags to take in consideration, separated by a comma. 461 | notes=FIXME, 462 | XXX, 463 | TODO 464 | 465 | # Regular expression of note tags to take in consideration. 466 | notes-rgx= 467 | 468 | 469 | [REFACTORING] 470 | 471 | # Maximum number of nested blocks for function / method body 472 | max-nested-blocks=5 473 | 474 | # Complete name of functions that never returns. When checking for 475 | # inconsistent-return-statements if a never returning function is called then 476 | # it will be considered as an explicit return statement and no message will be 477 | # printed. 478 | never-returning-functions=sys.exit,argparse.parse_error 479 | 480 | # Let 'consider-using-join' be raised when the separator to join on would be 481 | # non-empty (resulting in expected fixes of the type: ``"- " + " - 482 | # ".join(items)``) 483 | suggest-join-with-non-empty-separator=yes 484 | 485 | 486 | [REPORTS] 487 | 488 | # Python expression which should return a score less than or equal to 10. You 489 | # have access to the variables 'fatal', 'error', 'warning', 'refactor', 490 | # 'convention', and 'info' which contain the number of messages in each 491 | # category, as well as 'statement' which is the total number of statements 492 | # analyzed. This score is used by the global evaluation report (RP0004). 493 | evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)) 494 | 495 | # Template used to display messages. This is a python new-style format string 496 | # used to format the message information. See doc for all details. 497 | msg-template= 498 | 499 | # Set the output format. Available formats are: 'text', 'parseable', 500 | # 'colorized', 'json2' (improved json format), 'json' (old json format), msvs 501 | # (visual studio) and 'github' (GitHub actions). You can also give a reporter 502 | # class, e.g. mypackage.mymodule.MyReporterClass. 503 | #output-format= 504 | 505 | # Tells whether to display a full report or only the messages. 506 | reports=no 507 | 508 | # Activate the evaluation score. 509 | score=yes 510 | 511 | 512 | [SIMILARITIES] 513 | 514 | # Comments are removed from the similarity computation 515 | ignore-comments=yes 516 | 517 | # Docstrings are removed from the similarity computation 518 | ignore-docstrings=yes 519 | 520 | # Imports are removed from the similarity computation 521 | ignore-imports=yes 522 | 523 | # Signatures are removed from the similarity computation 524 | ignore-signatures=yes 525 | 526 | # Minimum lines number of a similarity. 527 | min-similarity-lines=4 528 | 529 | 530 | [SPELLING] 531 | 532 | # Limits count of emitted suggestions for spelling mistakes. 533 | max-spelling-suggestions=4 534 | 535 | # Spelling dictionary name. Available dictionaries: en (aspell), en_AU 536 | # (aspell), en_CA (aspell), en_GB (aspell), en_US (aspell). 537 | spelling-dict= 538 | 539 | # List of comma separated words that should be considered directives if they 540 | # appear at the beginning of a comment and should not be checked. 541 | spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy: 542 | 543 | # List of comma separated words that should not be checked. 544 | spelling-ignore-words= 545 | 546 | # A path to a file that contains the private dictionary; one word per line. 547 | spelling-private-dict-file= 548 | 549 | # Tells whether to store unknown words to the private dictionary (see the 550 | # --spelling-private-dict-file option) instead of raising a message. 551 | spelling-store-unknown-words=no 552 | 553 | 554 | [STRING] 555 | 556 | # This flag controls whether inconsistent-quotes generates a warning when the 557 | # character used as a quote delimiter is used inconsistently within a module. 558 | check-quote-consistency=no 559 | 560 | # This flag controls whether the implicit-str-concat should generate a warning 561 | # on implicit string concatenation in sequences defined over several lines. 562 | check-str-concat-over-line-jumps=no 563 | 564 | 565 | [TYPECHECK] 566 | 567 | # List of decorators that produce context managers, such as 568 | # contextlib.contextmanager. Add to this list to register other decorators that 569 | # produce valid context managers. 570 | contextmanager-decorators=contextlib.contextmanager 571 | 572 | # List of members which are set dynamically and missed by pylint inference 573 | # system, and so shouldn't trigger E1101 when accessed. Python regular 574 | # expressions are accepted. 575 | generated-members= 576 | 577 | # Tells whether to warn about missing members when the owner of the attribute 578 | # is inferred to be None. 579 | ignore-none=yes 580 | 581 | # This flag controls whether pylint should warn about no-member and similar 582 | # checks whenever an opaque object is returned when inferring. The inference 583 | # can return multiple potential results while evaluating a Python object, but 584 | # some branches might not be evaluated, which results in partial inference. In 585 | # that case, it might be useful to still emit no-member and other checks for 586 | # the rest of the inferred objects. 587 | ignore-on-opaque-inference=yes 588 | 589 | # List of symbolic message names to ignore for Mixin members. 590 | ignored-checks-for-mixins=no-member, 591 | not-async-context-manager, 592 | not-context-manager, 593 | attribute-defined-outside-init 594 | 595 | # List of class names for which member attributes should not be checked (useful 596 | # for classes with dynamically set attributes). This supports the use of 597 | # qualified names. 598 | ignored-classes=optparse.Values,thread._local,_thread._local,argparse.Namespace 599 | 600 | # Show a hint with possible names when a member name was not found. The aspect 601 | # of finding the hint is based on edit distance. 602 | missing-member-hint=yes 603 | 604 | # The maximum edit distance a name should have in order to be considered a 605 | # similar match for a missing member name. 606 | missing-member-hint-distance=1 607 | 608 | # The total number of similar names that should be taken in consideration when 609 | # showing a hint for a missing member. 610 | missing-member-max-choices=1 611 | 612 | # Regex pattern to define which classes are considered mixins. 613 | mixin-class-rgx=.*[Mm]ixin 614 | 615 | # List of decorators that change the signature of a decorated function. 616 | signature-mutators= 617 | 618 | 619 | [VARIABLES] 620 | 621 | # List of additional names supposed to be defined in builtins. Remember that 622 | # you should avoid defining new builtins when possible. 623 | additional-builtins= 624 | 625 | # Tells whether unused global variables should be treated as a violation. 626 | allow-global-unused-variables=yes 627 | 628 | # List of names allowed to shadow builtins 629 | allowed-redefined-builtins= 630 | 631 | # List of strings which can identify a callback function by name. A callback 632 | # name must start or end with one of those strings. 633 | callbacks=cb_, 634 | _cb 635 | 636 | # A regular expression matching the name of dummy variables (i.e. expected to 637 | # not be used). 638 | dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ 639 | 640 | # Argument names that match this expression will be ignored. 641 | ignored-argument-names=_.*|^ignored_|^unused_ 642 | 643 | # Tells whether we should check for unused import in __init__ files. 644 | init-import=no 645 | 646 | # List of qualified module names which can have objects that can redefine 647 | # builtins. 648 | redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io 649 | --------------------------------------------------------------------------------