├── readme_files └── forecast.png ├── hacs.json ├── .github └── workflows │ ├── hassfest.yaml │ └── validate.yaml ├── custom_components └── worlds_air_quality_index │ ├── manifest.json │ ├── strings.json │ ├── translations │ ├── en.json │ ├── sk.json │ ├── pl.json │ ├── ur.json │ └── pt-BR.json │ ├── const.py │ ├── __init__.py │ ├── waqi_api.py │ ├── config_flow.py │ └── sensor.py └── README.md /readme_files/forecast.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawkakol1/worlds-air-quality-index/HEAD/readme_files/forecast.png -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "World's Air Quality Index", 3 | "render_readme": true, 4 | "country": ["GB", "US", "PL"] 5 | } 6 | -------------------------------------------------------------------------------- /.github/workflows/hassfest.yaml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: '0 0 * * *' 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v2" 14 | - uses: "home-assistant/actions/hassfest@master" 15 | -------------------------------------------------------------------------------- /.github/workflows/validate.yaml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v2" 14 | - name: HACS validation 15 | uses: "hacs/action@main" 16 | with: 17 | category: "integration" 18 | -------------------------------------------------------------------------------- /custom_components/worlds_air_quality_index/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "worlds_air_quality_index", 3 | "name": "World's Air Quality Index", 4 | "codeowners": ["@pawkakol1"], 5 | "config_flow": true, 6 | "dependencies": [], 7 | "documentation": "https://github.com/pawkakol1/worlds-air-quality-index", 8 | "homekit": {}, 9 | "iot_class": "cloud_polling", 10 | "issue_tracker": "https://github.com/pawkakol1/worlds-air-quality-index/issues", 11 | "requirements": [], 12 | "ssdp": [], 13 | "version": "1.1.0", 14 | "zeroconf": [] 15 | } 16 | -------------------------------------------------------------------------------- /custom_components/worlds_air_quality_index/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "invalid_token": "Invalid token", 5 | "unknow_station_id": "WAQI API doesn't support this station ID", 6 | "server_error": "Server error", 7 | "server_not_available": "Server is not available", 8 | "invalid_station_name": "Invalid station name" 9 | }, 10 | "step": { 11 | "user": { 12 | "title": "Choose station adding method WAQI", 13 | "description": "Choose station adding method WAQI, what you preffered: using geographic localization or using station ID", 14 | "data": { 15 | "geographic_localization": "Geographic localization", 16 | "station_id": "Station ID", 17 | "scan_interval": "Scan interval of station updating" 18 | } 19 | }, 20 | "geographic_localization": { 21 | "title": "Add WAQI station using grographic localization", 22 | "description": "Fill your WAQI token, latitude and longitude of your station or home, and optionally custom name for service instace. You can find WAQI token here https://aqicn.org/data-platform/token/", 23 | "data": { 24 | "token": "Token key of World's Air Quality Index account", 25 | "latitude": "Latitude of station", 26 | "longitude": "Longitude of station", 27 | "name": "Name of service" 28 | } 29 | }, 30 | "station_id": { 31 | "title": "Add WAQI station using ID", 32 | "description": "Fill your WAQI token, id of your station (without @ prefix) and optionally custom name for service instace. You can find WAQI token here https://aqicn.org/data-platform/token/", 33 | "data": { 34 | "token": "Token key of World's Air Quality Index account", 35 | "id": "Station ID", 36 | "name": "Name of service" 37 | } 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /custom_components/worlds_air_quality_index/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "invalid_token": "Invalid token", 5 | "unknow_station_id": "WAQI API doesn't support this station ID", 6 | "server_error": "Server error", 7 | "server_not_available": "Server is not available", 8 | "invalid_station_name": "Invalid station name" 9 | }, 10 | "step": { 11 | "user": { 12 | "title": "Choose station adding method WAQI", 13 | "description": "Choose station adding method WAQI, what you preffered: using geographic localization or using station ID", 14 | "data": { 15 | "geographic_localization": "Geographic localization", 16 | "station_id": "Station ID", 17 | "scan_interval": "Scan interval of station updating" 18 | } 19 | }, 20 | "geographic_localization": { 21 | "title": "Add WAQI station using grographic localization", 22 | "description": "Fill your WAQI token, latitude and longitude of your station or home, and optionally custom name for service instace. You can find WAQI token here https://aqicn.org/data-platform/token/", 23 | "data": { 24 | "token": "Token key of World's Air Quality Index account", 25 | "latitude": "Latitude of station", 26 | "longitude": "Longitude of station", 27 | "name": "Name of service" 28 | } 29 | }, 30 | "station_id": { 31 | "title": "Add WAQI station using ID", 32 | "description": "Fill your WAQI token, id of your station (without @ prefix) and optionally custom name for service instace. You can find WAQI token here https://aqicn.org/data-platform/token/", 33 | "data": { 34 | "token": "Token key of World's Air Quality Index account", 35 | "id": "Station ID", 36 | "name": "Name of service" 37 | } 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /custom_components/worlds_air_quality_index/translations/sk.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "invalid_token": "Neplatný Token", 5 | "unknow_station_id": "WAQI API nepodporuje túto ID stanice", 6 | "server_error": "Server error", 7 | "server_not_available": "Server nie je dostupný", 8 | "invalid_station_name": "Neplatný názov stanice" 9 | }, 10 | "step": { 11 | "user": { 12 | "title": "Vyberte spôsob pridávania staníc WAQI", 13 | "description": "Vyberte si spôsob pridávania stanice WAQI, čo preferujete: pomocou geografickej lokalizácie alebo pomocou ID stanice", 14 | "data": { 15 | "geographic_localization": "Geografická lokalizácia", 16 | "station_id": "ID stanice", 17 | "scan_interval": "Interval skenovania aktualizácie stanice" 18 | } 19 | }, 20 | "geographic_localization": { 21 | "title": "Pridajte stanicu WAQI pomocou grografickej lokalizácie", 22 | "description": "Vyplňte svoj WAQI token, zemepisnú šírku a dĺžku svojej stanice alebo domova a voliteľne vlastný názov pre inštanciu služby. WAQI token nájdete tu https://aqicn.org/data-platform/token/", 23 | "data": { 24 | "token": "Tokenový kľúč účtu World's Air Quality Index", 25 | "latitude": "Zemepisná šírka stanice", 26 | "longitude": "Zemepisná dĺžka stanice", 27 | "name": "Názov služby" 28 | } 29 | }, 30 | "station_id": { 31 | "title": "Pridajte stanicu WAQI pomocou ID", 32 | "description": "Vyplňte svoj WAQI token, ID vašej stanice (bez predpony @) a voliteľne vlastný názov pre inštanciu služby. WAQI token nájdete tuhttps://aqicn.org/data-platform/token/", 33 | "data": { 34 | "token": "Tokenový kľúč účtu World's Air Quality Index", 35 | "id": "ID stanice", 36 | "name": "Názov služby" 37 | } 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /custom_components/worlds_air_quality_index/translations/pl.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "invalid_token": "Niewłaściwy token", 5 | "unknow_station_id": "WAQI API nie obsługuje tego ID stacji", 6 | "server_error": "Błąd serwera", 7 | "server_not_available": "Serwer jest niedostępny", 8 | "invalid_station_name": "Niewłaściwa nazwa stacji" 9 | }, 10 | "step": { 11 | "user": { 12 | "title": "Wybierz metodę dodawania stacji WAQI", 13 | "description": "Wybierz metodę dodawania stacji WAQI, którą preferujesz: wykorzystując lokalizację geograficzną lub wykorzystując ID stacji", 14 | "data": { 15 | "geographic_localization": "Lokalizacja geograficzna", 16 | "station_id": "ID stacji", 17 | "scan_interval": "Częstotliwość aktualizacji danych stacji" 18 | } 19 | }, 20 | "geographic_localization": { 21 | "title": "Dodaj stację WAQI wykorzystująć lokalizację geograficzną", 22 | "description": "Wypełnij pola swoim tokenem WAQI, szerokością i długością geograficzną swojej stacji lub domu, a także opcjonalnie własną nazwą serwisu. Token WAQI można znaleźć tutaj https://aqicn.org/data-platform/token/", 23 | "data": { 24 | "token": "Klucz tokena konta World's Air Quality Index", 25 | "latitude": "Szerokość geograficzna stacji lub obiektu", 26 | "longitude": "Długość geograficzna stacji lub obiektu", 27 | "name": "Nazwa serwisu" 28 | } 29 | }, 30 | "station_id": { 31 | "title": "Dodaj stację WAQI wykorzystując ID", 32 | "description": "Wypełnij pola swoim tokenem WAQI, ID swojej stacji, a także opcjonalnie własną nazwą serwisu. Token WAQI można znaleźć tutaj https://aqicn.org/data-platform/token/", 33 | "data": { 34 | "token": "Klucz tokena konta World's Air Quality Index", 35 | "id": "ID stacji", 36 | "name": "Nazwa serwisu" 37 | } 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /custom_components/worlds_air_quality_index/translations/ur.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "invalid_token": "غلط ٹوکن ۔", 5 | "unknow_station_id": "WAQI API اس اسٹیشن آئی ڈی کو سپورٹ نہیں کرتا ہے۔", 6 | "server_error": "سرور کی خرابی", 7 | "server_not_available": "سرور دستیاب نہیں۔", 8 | "invalid_station_name": "غلط اسٹیشن کا نام" 9 | }, 10 | "step": { 11 | "user": { 12 | "title": "اسٹیشن کو شامل کرنے کا طریقہ WAQI منتخب کریں۔", 13 | "description": "اسٹیشن شامل کرنے کا طریقہ WAQI منتخب کریں، جسے آپ ترجیح دیتے ہیں: جغرافیائی لوکلائزیشن کا استعمال کرتے ہوئے یا اسٹیشن ID کا استعمال کرتے ہوئے", 14 | "data": { 15 | "geographic_localization": "جغرافیائی مقامی کاری", 16 | "station_id": "اسٹیشن کی شناخت", 17 | "scan_interval": "اسٹیشن کو اپ ڈیٹ کرنے کا وقفہ سکین کریں۔" 18 | } 19 | }, 20 | "geographic_localization": { 21 | "title": "جغرافیائی لوکلائزیشن کا استعمال کرتے ہوئے WAQI اسٹیشن کو شامل کریں۔", 22 | "description": "اپنے WAQI ٹوکن، اپنے اسٹیشن یا گھر کا عرض البلد اور طول البلد، اور سروس مثال کے لیے اختیاری طور پر حسب ضرورت نام پُر کریں۔ آپ یہاں WAQI ٹوکن تلاش کر سکتے ہیں https://aqicn.org/data-platform/token/", 23 | "data": { 24 | "token": "عالمی ایئر کوالٹی انڈیکس اکاؤنٹ کی ٹوکن کلید", 25 | "latitude": "اسٹیشن کا عرض بلد", 26 | "longitude": "اسٹیشن کا طول بلد", 27 | "name": "سروس کا نام" 28 | } 29 | }, 30 | "station_id": { 31 | "title": "آئی ڈی کا استعمال کرتے ہوئے WAQI اسٹیشن شامل کریں۔", 32 | "description": "اپنا WAQI ٹوکن، اپنے اسٹیشن کی آئی ڈی (بغیر @ سابقہ کے) اور سروس مثال کے لیے اختیاری طور پر حسب ضرورت نام پُر کریں۔ آپ کو یہاں WAQI ٹوکن مل سکتا ہے https://aqicn.org/data-platform/token/", 33 | "data": { 34 | "token": "ورلڈ ایئر کوالٹی انڈیکس اکاؤنٹ کی ٹوکن کلید", 35 | "id": "اسٹیشن کی شناخت", 36 | "name": "سروس کا نام" 37 | } 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /custom_components/worlds_air_quality_index/translations/pt-BR.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "invalid_token": "Token inválido", 5 | "unknow_station_id": "A API WAQI não é compatível com este ID de estação", 6 | "server_error": "Erro de servidor", 7 | "server_not_available": "O servidor não está disponível", 8 | "invalid_station_name": "Nome da estação inválido" 9 | }, 10 | "step": { 11 | "user": { 12 | "title": "Escolha o método de adição de estação WAQI", 13 | "description": "Escolha o método de adição de estação WAQI, você pode preferir: usando a localização geográfica ou usando o ID da estação", 14 | "data": { 15 | "geographic_localization": "Localização geográfica", 16 | "station_id": "ID da estação", 17 | "scan_interval": "Intervalo de varredura da atualização da estação" 18 | } 19 | }, 20 | "geographic_localization": { 21 | "title": "Adicionar estação WAQI usando localização grográfica", 22 | "description": "Preencha seu token WAQI, latitude e longitude de sua estação ou casa e, opcionalmente, nome personalizado para a instância do serviço. Você pode encontrar o token WAQI aqui https://aqicn.org/data-platform/token/", 23 | "data": { 24 | "token": "Chave de token da conta do World's Air Quality Index", 25 | "latitude": "Latitude da estação", 26 | "longitude": "Longitude da estação", 27 | "name": "Nome do serviço" 28 | } 29 | }, 30 | "station_id": { 31 | "title": "Adicionar estação WAQI usando ID", 32 | "description": "Preencha seu token WAQI, id de sua estação (sem prefixo @) e, opcionalmente, nome personalizado para a instância do serviço. Você pode encontrar o token WAQI aqui https://aqicn.org/data-platform/token/", 33 | "data": { 34 | "token": "Chave de token da conta do World's Air Quality Index", 35 | "id": "ID da estação", 36 | "name": "Nome do serviço" 37 | } 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /custom_components/worlds_air_quality_index/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the World Air Quality Index integration.""" 2 | 3 | from datetime import timedelta 4 | 5 | from typing import Final 6 | 7 | from homeassistant.const import ( 8 | UnitOfPressure, 9 | UnitOfSpeed, 10 | UnitOfTemperature, 11 | CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, 12 | UnitOfLength, 13 | PERCENTAGE, 14 | Platform 15 | ) 16 | from homeassistant.components.sensor import SensorDeviceClass 17 | 18 | DOMAIN = "worlds_air_quality_index" 19 | PLATFORMS = [Platform.SENSOR] 20 | SW_VERSION = "1.1.0" 21 | 22 | DEFAULT_NAME = 'waqi1' 23 | DISCOVERY_TYPE = "discovery_type" 24 | GEOGRAPHIC_LOCALIZATION = "Geographic localization" 25 | SCAN_INTERVAL = timedelta(minutes=30) 26 | STATION_ID = "Station ID" 27 | 28 | SENSORS = { 29 | 'aqi': ['Air Quality Index', ' ', 'mdi:leaf', SensorDeviceClass.AQI], 30 | 'pm10': ['Particulate matter (PM10)', CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, 'mdi:skull-outline', SensorDeviceClass.PM10], 31 | 'pm25': ['Particulate matter (PM2,5)', CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, 'mdi:skull-outline', SensorDeviceClass.PM25], 32 | 'co': ['Carbon monoxide (CO)', CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, 'mdi:molecule-co', SensorDeviceClass.CO], 33 | 'h': ['Humidity', PERCENTAGE, 'mdi:water-percent', SensorDeviceClass.HUMIDITY], 34 | 'no2': ['Nitrogen dioxide (NO2)', CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, 'mdi:smog', SensorDeviceClass.NITROGEN_DIOXIDE], 35 | 'o3': ['Ozone (O3)', CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, 'mdi:skull-outline', SensorDeviceClass.OZONE], 36 | 'p': ['Atmospheric pressure', UnitOfPressure.HPA, 'mdi:gauge', SensorDeviceClass.PRESSURE], 37 | 'so2': ['Sulphur dioxide (SO2)', CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, 'mdi:smog', SensorDeviceClass.SULPHUR_DIOXIDE], 38 | 't': ['Temperature', UnitOfTemperature.CELSIUS, 'mdi:thermometer', SensorDeviceClass.TEMPERATURE], 39 | 'r': ['Rain', UnitOfLength.MILLIMETERS, 'mdi:weather-rainy', None], 40 | 'w': ['Wind speed', UnitOfSpeed.METERS_PER_SECOND, 'mdi:weather-windy', None], 41 | 'wg': ['Wind gust', UnitOfSpeed.METERS_PER_SECOND, 'mdi:weather-windy', None], 42 | } 43 | -------------------------------------------------------------------------------- /custom_components/worlds_air_quality_index/__init__.py: -------------------------------------------------------------------------------- 1 | """The worlds_air_quality_index component.""" 2 | from __future__ import annotations 3 | 4 | import logging 5 | 6 | from homeassistant.config_entries import ConfigEntry 7 | from homeassistant.core import ( 8 | HomeAssistant 9 | ) 10 | from homeassistant.const import ( 11 | CONF_LATITUDE, 12 | CONF_LONGITUDE, 13 | CONF_LOCATION, 14 | CONF_METHOD, 15 | CONF_ID, 16 | CONF_TEMPERATURE_UNIT, 17 | UnitOfTemperature 18 | ) 19 | 20 | from .const import ( 21 | PLATFORMS 22 | ) 23 | 24 | _LOGGER = logging.getLogger(__name__) 25 | 26 | 27 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 28 | """Set up World's Air Quality Index from a config entry.""" 29 | 30 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 31 | entry.async_on_unload(entry.add_update_listener(update_listener)) 32 | return True 33 | 34 | 35 | async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: 36 | """Handle options update.""" 37 | await hass.config_entries.async_reload(entry.entry_id) 38 | 39 | 40 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 41 | """Unload worlds_air_quality_index config entry.""" 42 | 43 | return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 44 | 45 | async def async_migrate_entry(hass, config_entry): 46 | """Migrate worlds_air_quality_index old entry.""" 47 | config_entries = hass.config_entries 48 | data = config_entry.data 49 | version = config_entry.version 50 | 51 | _LOGGER.debug("Migrating World's Air Quality Index entry from version %s", version) 52 | 53 | if version == 1: 54 | method = CONF_LOCATION 55 | idx = None 56 | new_data = {**data, CONF_ID: idx, CONF_METHOD: method} 57 | 58 | latitude = data[CONF_LATITUDE] 59 | longitude = data[CONF_LONGITUDE] 60 | if latitude is None: 61 | latitude = hass.config.latitude 62 | new_data = {**new_data, CONF_LATITUDE: latitude} 63 | if longitude is None: 64 | longitude = hass.config.longitude 65 | new_data = {**new_data, CONF_LONGITUDE: longitude} 66 | 67 | version = 2 68 | config_entry.version = version 69 | config_entries.async_update_entry(config_entry, data=new_data) 70 | 71 | if version == 2: 72 | tempUnit = UnitOfTemperature.CELSIUS 73 | new_data = {**data, CONF_TEMPERATURE_UNIT: tempUnit} 74 | 75 | version = 3 76 | config_entry.version = version 77 | config_entries.async_update_entry(config_entry, data=new_data) 78 | 79 | 80 | _LOGGER.info("Migration to version %s successful", version) 81 | 82 | return True 83 | -------------------------------------------------------------------------------- /custom_components/worlds_air_quality_index/waqi_api.py: -------------------------------------------------------------------------------- 1 | import json 2 | import requests 3 | import logging 4 | from homeassistant.util import Throttle 5 | from homeassistant.const import ( 6 | CONF_ID, 7 | CONF_LOCATION 8 | ) 9 | from .const import SCAN_INTERVAL 10 | 11 | _LOGGER = logging.getLogger(__name__) 12 | 13 | class WaqiDataRequester(object): 14 | 15 | def __init__(self, lat, lng, token, idx, method): 16 | self._lat = lat 17 | self._lng = lng 18 | self._token = token 19 | self._idx = idx 20 | self._method = method 21 | self._data = None 22 | self._stationName = None 23 | self._stationIdx = None 24 | self._updateLastTime = None 25 | 26 | @Throttle(SCAN_INTERVAL) 27 | def update(self): 28 | _LOGGER.debug("Updating WAQI sensors") 29 | try: 30 | if self._method == CONF_LOCATION: 31 | _dat = requests.get(f"https://api.waqi.info/feed/geo:{self._lat};{self._lng}/?token={self._token}").text 32 | elif self._method == CONF_ID: 33 | _dat = requests.get(f"https://api.waqi.info/feed/@{self._idx}/?token={self._token}").text 34 | else: 35 | _LOGGER.debug("No choosen method") 36 | 37 | if _dat: 38 | self._data = json.loads(_dat) 39 | if self._data: 40 | if "data" in self._data: 41 | if "idx" in self._data["data"]: 42 | if self._method == CONF_LOCATION: 43 | self._stationIdx = self._data["data"]["idx"] 44 | elif self._method == CONF_ID: 45 | self._stationIdx = self._idx 46 | 47 | if "city" in self._data["data"]: 48 | if "name" in self._data["data"]["city"]: 49 | self._stationName = self._data["data"]["city"]["name"] 50 | 51 | if self._stationName: 52 | self._stationName = self._stationName.replace(", ", "_").replace(" ", "_").replace("(", "").replace(")","").lower() 53 | else: 54 | self._stationName = "UnknownName_" + self._stationIdx 55 | 56 | if "time" in self._data["data"]: 57 | if "iso" in self._data["data"]["time"]: 58 | self._updateLastTime = self._data["data"]["time"]["iso"] 59 | 60 | except requests.exceptions.RequestException as exc: 61 | _LOGGER.error("Error occurred while fetching data: %r", exc) 62 | self._data = None 63 | self._stationName = None 64 | self._stationIdx = None 65 | return False 66 | 67 | def GetData(self): 68 | return self._data 69 | 70 | def GetStationName(self): 71 | return self._stationName 72 | 73 | def GetStationIdx(self): 74 | return self._stationIdx 75 | 76 | def GetUpdateLastTime(self): 77 | return self._updateLastTime 78 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # worlds-air-quality-index 2 | 3 | A Home Assistant custom Integration for World's Air Quality Index (waqi.info). 4 | 5 | Integration supports below sensors of WAQI station: 6 | 7 | - Air Quality Index 8 | - Carbon monoxide (CO) 9 | - Nitrogen dioxide (NO2) 10 | - Ozone (O3) 11 | - Particulate matter (PM10) 12 | - Particulate matter (PM2,5) 13 | - Sulphur dioxide (SO2) 14 | - Atmospheric pressure 15 | - Humidity 16 | - Temperature 17 | - Rain 18 | - Wind gust 19 | - Wind speed 20 | 21 | Different stations support different data, "World's Air Quality Index" integration will recognise all parameters (available in station) according to list of integration's supported sensors. 22 | 23 | If station's API supports forecast for pollution sensors (these on above list): 24 | 25 | - Carbon monoxide (CO) 26 | - Nitrogen dioxide (NO2) 27 | - Ozone (O3) 28 | - Particulate matter (PM10) 29 | - Particulate matter (PM2,5) 30 | - Sulphur dioxide (SO2) 31 | 32 | Pollution sensors will update forecast, and it will be able to read as attributes of the sensor: 33 | 34 | 35 | 36 | If the station supports forecast of some pollution sensor, but it doesn't support actual value of this sensor, then the sensor will be also ceated. Its current state will be set to "UNAVAILABLE", but forecast will be able to read as the sensor attributes. 37 | 38 | There are 2 supported integration methods: 39 | 40 | - using geolocalized coordinates (it works with WAQI internal stations only), 41 | - using station ID. 42 | 43 | Notice: WAQI API supports 2 different methods of providing station ID. It depends on the station is internal WAQI station or downloaded from other source by their API. Internal stations need to put just a number of station ID (without @ char). For external stations it needs to put station ID number with 'A' char prefix. If you want to integrate some station using ID, but you don't know what method you need to use, you can always check it using web browser. You just need to copy one of below link, paste to the URL of web browser, change number of interested station, and paste your token instead {{token}} 44 | 45 | `https://api.waqi.info/feed/@13837/?token={{token}}` 46 | or 47 | `https://api.waqi.info/feed/A254464/?token={{token}}` 48 | 49 | Web browser will receive some data, if station is supported or "Unknown ID" message, if it doesn't, but one of above link types should work. 50 | 51 | # Installation 52 | 53 | Use HACS to install this repository. 54 | You can also copy worlds_air_quality_index folder into /config/custom_components of Home Assistant instance, then restart HA. 55 | 56 | # Adding Integration 57 | 58 | To add integration use "Add Integration" button in section Settings->Devices&Services section, and choose "World's Air Quality Index". 59 | In popup window choose method of station adding: 60 | 61 | - using geographic localization (NOTICE: it works with WAQI internal stations only), 62 | - using station ID (NOTICE: it works with all API types available in WAQI: 63 | - WAQI internal stations, 64 | - stations from CanAir.IO, 65 | - stations from Citizen Science project luftdaten.info. 66 | 67 | In case of geographic localization, there will be shown next window, where you need to put: 68 | 69 | - your waqi.info account token (required), 70 | - latitude of WAQI station (required), 71 | - longitude of WAQI station (required), 72 | - your own name of station (optional). 73 | 74 | In case of station ID, there will be shown next window, where you need to put: 75 | 76 | - your waqi.info account token (required), 77 | - ID of WAQI station (required) - WAQI Internal stations it is needed to put just a number (without @ char), for stations from other sources it needs to put 'A' char as a prefix(eg. A67564), 78 | - your own name of station (optional). 79 | 80 | To get WAQI token you need to sign up [here](https://aqicn.org/data-platform/token/). 81 | As a default your home coordinates (set in HA) are put in latitude and longitude fields in geographic localization method. This integration will find the closest station, what is supported by waqi.info API. 82 | If you won't put your own name it will take name of found station. 83 | You can add more than 1 station. 84 | -------------------------------------------------------------------------------- /custom_components/worlds_air_quality_index/config_flow.py: -------------------------------------------------------------------------------- 1 | """Adds config flow for worlds_air_quality_index integration.""" 2 | from __future__ import annotations 3 | 4 | from typing import Any 5 | 6 | from .waqi_api import WaqiDataRequester 7 | 8 | import voluptuous as vol 9 | 10 | from homeassistant import config_entries 11 | from homeassistant.data_entry_flow import FlowResult 12 | import homeassistant.helpers.config_validation as cv 13 | 14 | from homeassistant.const import ( 15 | CONF_NAME, 16 | CONF_LATITUDE, 17 | CONF_LONGITUDE, 18 | CONF_TOKEN, 19 | CONF_LOCATION, 20 | CONF_METHOD, 21 | CONF_ID, 22 | CONF_TEMPERATURE_UNIT, 23 | UnitOfTemperature 24 | ) 25 | from .const import ( 26 | DOMAIN, 27 | DEFAULT_NAME, 28 | GEOGRAPHIC_LOCALIZATION, 29 | STATION_ID 30 | ) 31 | 32 | 33 | class WorldsAirQualityIndexConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 34 | """Handle a config flow for worlds_air_quality_index integration.""" 35 | 36 | VERSION = 3 37 | 38 | async def async_step_import(self, config: dict[str, Any]) -> FlowResult: 39 | """Import a configuration from config.yaml.""" 40 | 41 | name = config.get(CONF_NAME, DEFAULT_NAME) 42 | self._async_abort_entries_match({CONF_NAME: name}) 43 | config[CONF_NAME] = name 44 | return await self.async_step_user(user_input=config) 45 | 46 | async def async_step_user(self, user_input: dict[str, Any] | None = None) -> FlowResult: 47 | """Handle the initial step.""" 48 | 49 | data_schema = vol.Schema( 50 | { 51 | vol.Required(CONF_METHOD, default=GEOGRAPHIC_LOCALIZATION): vol.In( 52 | ( 53 | GEOGRAPHIC_LOCALIZATION, 54 | STATION_ID 55 | ) 56 | ) 57 | 58 | } 59 | ) 60 | 61 | if user_input is None: 62 | return self.async_show_form( 63 | step_id="user", 64 | data_schema=data_schema, 65 | ) 66 | 67 | if user_input[CONF_METHOD] == GEOGRAPHIC_LOCALIZATION: 68 | return await self.async_step_geographic_localization() 69 | return await self.async_step_station_id() 70 | 71 | async def async_step_geographic_localization(self, user_input=None) -> FlowResult: 72 | """Handle the geographic localization step.""" 73 | errors = {} 74 | 75 | data_schema = vol.Schema( 76 | { 77 | vol.Required(CONF_TOKEN): cv.string, 78 | vol.Required(CONF_TEMPERATURE_UNIT, default=UnitOfTemperature.CELSIUS): vol.In( 79 | ( 80 | UnitOfTemperature.CELSIUS, 81 | UnitOfTemperature.FAHRENHEIT 82 | ) 83 | ), 84 | vol.Required(CONF_LATITUDE, default=self.hass.config.latitude): cv.latitude, 85 | vol.Required(CONF_LONGITUDE, default=self.hass.config.longitude): cv.longitude, 86 | vol.Optional(CONF_NAME): cv.string 87 | } 88 | ) 89 | 90 | if user_input: 91 | token = user_input[CONF_TOKEN] 92 | tempUnit = user_input[CONF_TEMPERATURE_UNIT] 93 | latitude = user_input[CONF_LATITUDE] 94 | longitude = user_input[CONF_LONGITUDE] 95 | method = CONF_LOCATION 96 | requester = WaqiDataRequester(latitude, longitude, token, None, method) 97 | await self.hass.async_add_executor_job(requester.update) 98 | 99 | validateData = requester.GetData() 100 | if validateData: 101 | if validateData["status"] == "ok": 102 | if "status" in validateData["data"]: 103 | if validateData["data"]["status"] == "error": 104 | if validateData["data"]["msg"] == "Unknown ID": 105 | errors["base"] = "unknow_station_id" 106 | else: 107 | errors["base"] = "server_error" 108 | elif validateData["status"] == "error": 109 | if validateData["data"] == "Invalid key": 110 | errors["base"] = "invalid_token" 111 | else: 112 | errors["base"] = "server_error" 113 | else: 114 | errors["base"] = "server_error" 115 | else: 116 | errors["base"] = "server_not_available" 117 | 118 | stationName = requester.GetStationName() 119 | name = user_input.get(CONF_NAME, stationName) 120 | 121 | if not errors: 122 | await self.async_set_unique_id(name) 123 | self._abort_if_unique_id_configured() 124 | 125 | return self.async_create_entry( 126 | title=name, 127 | data={ 128 | CONF_TOKEN: token, 129 | CONF_TEMPERATURE_UNIT: tempUnit, 130 | CONF_LATITUDE: latitude, 131 | CONF_LONGITUDE: longitude, 132 | CONF_NAME: name, 133 | CONF_METHOD: method, 134 | }, 135 | ) 136 | 137 | return self.async_show_form( 138 | step_id="geographic_localization", 139 | data_schema=data_schema, 140 | errors=errors, 141 | ) 142 | 143 | async def async_step_station_id(self, user_input=None) -> FlowResult: 144 | errors = {} 145 | 146 | data_schema = vol.Schema( 147 | { 148 | vol.Required(CONF_TOKEN): cv.string, 149 | vol.Required(CONF_TEMPERATURE_UNIT, default=UnitOfTemperature.CELSIUS): vol.In( 150 | ( 151 | UnitOfTemperature.CELSIUS, 152 | UnitOfTemperature.FAHRENHEIT 153 | ) 154 | ), 155 | vol.Required(CONF_ID): cv.string, 156 | vol.Optional(CONF_NAME): cv.string 157 | } 158 | ) 159 | 160 | if user_input: 161 | 162 | token = user_input[CONF_TOKEN] 163 | tempUnit = user_input[CONF_TEMPERATURE_UNIT] 164 | id = user_input[CONF_ID] 165 | method = CONF_ID 166 | requester = WaqiDataRequester(None, None, token, id, method) 167 | await self.hass.async_add_executor_job(requester.update) 168 | 169 | validateData = requester.GetData() 170 | if validateData: 171 | if validateData["status"] == "ok": 172 | if "status" in validateData["data"]: 173 | if validateData["data"]["status"] == "error": 174 | if validateData["data"]["msg"] == "Unknown ID": 175 | errors["base"] = "unknow_station_id" 176 | else: 177 | errors["base"] = "server_error" 178 | elif validateData["status"] == "error": 179 | if validateData["data"] == "Invalid key": 180 | errors["base"] = "invalid_token" 181 | else: 182 | errors["base"] = "server_error" 183 | else: 184 | errors["base"] = "server_error" 185 | else: 186 | errors["base"] = "server_not_available" 187 | 188 | stationName = requester.GetStationName() 189 | name = user_input.get(CONF_NAME, stationName) 190 | 191 | if not errors: 192 | await self.async_set_unique_id(name) 193 | self._abort_if_unique_id_configured() 194 | 195 | return self.async_create_entry( 196 | title=name, 197 | data={ 198 | CONF_TOKEN: token, 199 | CONF_TEMPERATURE_UNIT: tempUnit, 200 | CONF_ID: id, 201 | CONF_NAME: name, 202 | CONF_METHOD: method, 203 | }, 204 | ) 205 | 206 | return self.async_show_form( 207 | step_id="station_id", 208 | data_schema=data_schema, 209 | errors=errors, 210 | ) 211 | -------------------------------------------------------------------------------- /custom_components/worlds_air_quality_index/sensor.py: -------------------------------------------------------------------------------- 1 | """Get station's air quality informations""" 2 | from __future__ import annotations 3 | 4 | from datetime import date, timedelta 5 | import logging 6 | from this import s 7 | 8 | from typing import Any 9 | 10 | from .waqi_api import WaqiDataRequester 11 | 12 | from homeassistant.components.sensor import ( 13 | SensorDeviceClass, 14 | SensorEntity, 15 | ) 16 | from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry 17 | from homeassistant.core import HomeAssistant 18 | import homeassistant.helpers.config_validation as cv 19 | from homeassistant.helpers.device_registry import DeviceEntryType 20 | from homeassistant.helpers.entity import DeviceInfo 21 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 22 | from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType 23 | 24 | from homeassistant.const import ( 25 | CONF_ID, 26 | CONF_LATITUDE, 27 | CONF_LONGITUDE, 28 | CONF_METHOD, 29 | CONF_NAME, 30 | CONF_TEMPERATURE_UNIT, 31 | CONF_TOKEN, 32 | STATE_UNAVAILABLE, 33 | UnitOfTemperature, 34 | ) 35 | 36 | from .const import ( 37 | DOMAIN, 38 | DEFAULT_NAME, 39 | SENSORS, 40 | SW_VERSION, 41 | ) 42 | 43 | _LOGGER = logging.getLogger(__name__) 44 | 45 | async def async_setup_platform( 46 | hass: HomeAssistant, 47 | config: ConfigType, 48 | async_add_devices: AddEntitiesCallback, 49 | discovery_info: DiscoveryInfoType | None = None, 50 | ) -> None: 51 | """Set up the World's Air Quality Index sensors.""" 52 | _LOGGER.warning( 53 | "Configuration of the World's Air Quality Inde platform in YAML is deprecated and will be " 54 | "removed in Home Assistant 2022.4; Your existing configuration " 55 | "has been imported into the UI automatically and can be safely removed " 56 | "from your configuration.yaml file" 57 | ) 58 | hass.async_create_task( 59 | hass.config_entries.flow.async_init( 60 | DOMAIN, 61 | context={"source": SOURCE_IMPORT}, 62 | data=config, 63 | ) 64 | ) 65 | 66 | async def async_setup_entry( 67 | hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback 68 | ) -> None: 69 | """Set up the world_air_quality_index sensor entry.""" 70 | 71 | _LOGGER.debug("config token:") 72 | _LOGGER.debug(entry.data[CONF_TOKEN]) 73 | _LOGGER.debug("config method:") 74 | _LOGGER.debug(entry.data[CONF_METHOD]) 75 | _LOGGER.debug("config name:") 76 | _LOGGER.debug(entry.data[CONF_NAME]) 77 | 78 | name = entry.data[CONF_NAME] 79 | token = entry.data[CONF_TOKEN] 80 | method = entry.data[CONF_METHOD] 81 | tempUnit = entry.data[CONF_TEMPERATURE_UNIT] 82 | 83 | if method == CONF_ID: 84 | _LOGGER.debug("config ID:") 85 | _LOGGER.debug(entry.data[CONF_ID]) 86 | id = entry.data[CONF_ID] 87 | requester = WaqiDataRequester(None, None, token, id, method) 88 | else: 89 | _LOGGER.debug("config latitude:") 90 | _LOGGER.debug(entry.data[CONF_LATITUDE]) 91 | _LOGGER.debug("config longitude:") 92 | _LOGGER.debug(entry.data[CONF_LONGITUDE]) 93 | latitude = entry.data[CONF_LATITUDE] 94 | longitude = entry.data[CONF_LONGITUDE] 95 | requester = WaqiDataRequester(latitude, longitude, token, None, method) 96 | 97 | await hass.async_add_executor_job(requester.update) 98 | 99 | scannedData = requester.GetData() 100 | _LOGGER.debug("Got station data from WAQI server:") 101 | _LOGGER.debug(scannedData) 102 | 103 | if not "forecast" in scannedData['data']: 104 | _LOGGER.warning(f"Station {name} doesn't support forecast") 105 | scannedDataForecast = None 106 | else: 107 | scannedDataForecast = scannedData['data']['forecast']['daily'] 108 | scannedDataSensors = scannedData["data"]["iaqi"] 109 | 110 | entities = [] 111 | 112 | for res in SENSORS: 113 | if res == "aqi" or res in scannedDataSensors: 114 | entities.append(WorldsAirQualityIndexSensor(res, requester, tempUnit)) 115 | elif scannedDataForecast is not None: 116 | if res in scannedDataForecast: 117 | entities.append(WorldsAirQualityIndexSensor(res, requester, tempUnit)) 118 | 119 | async_add_entities(entities, update_before_add=True) 120 | 121 | 122 | 123 | class WorldsAirQualityIndexSensor(SensorEntity): 124 | """Representation of a Sensor.""" 125 | 126 | def __init__(self, resType: str, requester: WaqiDataRequester, tempUnit: str) -> None: 127 | self._state = None 128 | self._resType = resType 129 | self._requester = requester 130 | self._stationName = self._requester.GetStationName() 131 | self._stationIdx = self._requester.GetStationIdx() 132 | self._updateLastTime = self._requester.GetUpdateLastTime() 133 | self._data = self._requester.GetData() 134 | 135 | self._name = SENSORS[self._resType][0] 136 | self._tempUnit = tempUnit 137 | 138 | self._attr_name = self._name 139 | self._attr_unique_id = f"{self._stationName}_{self._stationIdx}_{self._name}" 140 | self._attr_extra_state_attributes = { 141 | "StationName": self._stationName 142 | } 143 | self._attr_device_info = DeviceInfo( 144 | entry_type = DeviceEntryType.SERVICE, 145 | identifiers = {(DOMAIN, f"{self._stationName}_{self._stationIdx}")}, 146 | manufacturer = "@pawkakol1", 147 | model = f"Idx:{self._stationIdx}", 148 | sw_version = SW_VERSION, 149 | name = self._stationName 150 | ) 151 | 152 | @property 153 | def state(self): 154 | #Return the state of the sensor. 155 | return self._state 156 | 157 | @property 158 | def unit_of_measurement(self) -> str: 159 | #Return the unit of measurement. 160 | 161 | if SENSORS[self._resType][1] == UnitOfTemperature.CELSIUS: 162 | return self._tempUnit 163 | else: 164 | return SENSORS[self._resType][1] 165 | 166 | @property 167 | def device_class(self) -> SensorDeviceClass | str | None: 168 | return SENSORS[self._resType][3] 169 | 170 | @property 171 | def icon(self) -> str | None: 172 | if self._resType != 'wg': 173 | return SENSORS[self._resType][2] 174 | 175 | def update(self) -> None: 176 | #Fetch new state data for the sensor. 177 | #This is the only method that should fetch new data for Home Assistant. 178 | 179 | self._requester.update() 180 | 181 | self._data = self._requester.GetData() 182 | self._updateLastTime = self._requester.GetUpdateLastTime() 183 | 184 | self._attr_extra_state_attributes = { 185 | "StationName": self._requester.GetStationName(), 186 | "LastUpdate": self._requester.GetUpdateLastTime() 187 | } 188 | 189 | if self._resType == 'aqi': 190 | if self._data["data"]["aqi"] == "-": 191 | _LOGGER.warning("aqi value from json waqi api was undefined ('-' value)") 192 | self._state = 0 193 | else: 194 | self._state = int(self._data["data"]["aqi"]) 195 | self._attr_extra_state_attributes['dominentpol'] = self._data["data"]["dominentpol"] 196 | 197 | elif self._resType in self._data["data"]["iaqi"]: 198 | if self._resType == 't': 199 | if self._tempUnit == UnitOfTemperature.FAHRENHEIT: 200 | self._state = 9.0 * float(self._data["data"]["iaqi"]['t']["v"]) / 5.0 + 32.0 201 | else: 202 | self._state = float(self._data["data"]["iaqi"]['t']["v"]) 203 | else: 204 | self._state = float(self._data["data"]["iaqi"][self._resType]["v"]) 205 | elif "forecast" in self._data['data']: 206 | if self._resType in self._data['data']['forecast']['daily']: 207 | self._state = STATE_UNAVAILABLE 208 | 209 | if "forecast" in self._data['data']: 210 | if self._resType in self._data['data']['forecast']['daily']: 211 | scannedDataForecast = self._data['data']['forecast']['daily'][self._resType] 212 | day = date.today() 213 | dayName = "Today" 214 | if scannedDataForecast is not None: 215 | for res in scannedDataForecast: 216 | readDate = date.fromisoformat(res["day"]) 217 | if readDate == day: 218 | self._attr_extra_state_attributes['Forecast' + dayName + 'Avg'] = res['avg'] 219 | self._attr_extra_state_attributes['Forecast' + dayName + 'Min'] = res['min'] 220 | self._attr_extra_state_attributes['Forecast' + dayName + 'Max'] = res['max'] 221 | _LOGGER.debug(f"Forecast{dayName} Avg/Min/Max extra state attributes added.") 222 | 223 | day = day + timedelta(days=1) 224 | if dayName == "Today": 225 | dayName = "Tomorrow" 226 | elif dayName == "Tomorrow": 227 | dayName = "2Days" 228 | elif dayName == "2Days": 229 | dayName = "3Days" 230 | elif dayName == "3Days": 231 | dayName = "4Days" 232 | elif dayName == "4Days": 233 | dayName = "5Days" 234 | --------------------------------------------------------------------------------