├── .github └── workflows │ ├── hassfest.yaml │ └── validate.yml ├── .gitignore ├── LICENSE ├── README.md ├── custom_components └── local_luftdaten │ ├── __init__.py │ ├── const.py │ ├── manifest.json │ └── sensor.py └── hacs.json /.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.yml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | hacs: 11 | name: HACS Action 12 | runs-on: "ubuntu-latest" 13 | steps: 14 | - uses: "actions/checkout@v2" 15 | - name: HACS Action 16 | uses: "hacs/action@main" 17 | with: 18 | category: "integration" 19 | ignore: "brands" 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg)](https://github.com/custom-components/hacs) 2 | ![Validate with hassfest](https://github.com/lichtteil/local_luftdaten/workflows/Validate%20with%20hassfest/badge.svg) 3 | 4 | 5 | # Custom Luftdaten component for Home Assistant 6 | 7 | ## About 8 | This custom component for Home Assistant integrates your (own) local Luftdaten sensor (air quality/particle sensor) without using the cloud. If you want to know more about the sensor and how to build one check this website: https://luftdaten.info/en/home-en/ 9 | 10 | ## Installation 11 | ### HACS 12 | If you use [HACS](https://hacs.xyz/) you can install and update this component easily via the default repository. Go into HACS -> Integrations and search for **luftdaten**. 13 | 14 | ### Manual 15 | Download and unzip or clone this repository and copy `custom_components/local_luftdaten/` to your configuration directory of Home Assistant, e.g. `~/.homeassistant/custom_components/`. 16 | 17 | In the end your file structure should look like that: 18 | ``` 19 | ~/.homeassistant/custom_components/local_luftdaten/__init__.py 20 | ~/.homeassistant/custom_components/local_luftdaten/const.py 21 | ~/.homeassistant/custom_components/local_luftdaten/manifest.json 22 | ~/.homeassistant/custom_components/local_luftdaten/sensor.py 23 | ``` 24 | 25 | ## Configuration 26 | Create a new sensor entry in your `configuration.yaml` and adjust the host name or the ip address. 27 | 28 | |Parameter |Type | Necessity | Description 29 | |:----------------------|:-------|:------------ |:------------ 30 | |`host` | string | required | IP address of the sensor 31 | |`scan_interval` | number | default: 180 | Frequency (in seconds) between updates 32 | |`name` | string | required | Name of the sensor 33 | |`monitored_conditions` | list | required | List of the monitored sensors 34 | 35 | 36 | ```yaml 37 | sensor: 38 | - platform: local_luftdaten 39 | host: 192.168.0.123 40 | scan_interval: 180 41 | name: Feinstaubsensor 42 | monitored_conditions: 43 | - SDS_P1 44 | - SDS_P2 45 | - temperature 46 | - humidity 47 | ``` 48 | 49 | At the moment following sensor data can be read: 50 | 51 | - BME280_humidity 52 | - BME280_pressure 53 | - BME280_temperature 54 | - BMP_pressure 55 | - BMP_temperature 56 | - BMP280_pressure 57 | - BMP280_temperature 58 | - DS18B20_temperature 59 | - HECA_humidity 60 | - HECA_temperature 61 | - HPM_P1 62 | - HPM_P2 63 | - HTU21D_humidity 64 | - HTU21D_temperature 65 | - humidity 66 | - SDS_P1 67 | - SDS_P2 68 | - PMS_P0 69 | - PMS_P1 70 | - PMS_P2 71 | - SHT3X_humidity 72 | - SHT3X_temperature 73 | - SPS30_P0 74 | - SPS30_P1 75 | - SPS30_P2 76 | - SPS30_P4 77 | - SEN5X_P0 78 | - SEN5X_P1 79 | - SEN5X_P2 80 | - SEN5X_P4 81 | - SEN5X_NOX 82 | - SEN5X_VOC 83 | - temperature 84 | - signal 85 | 86 | Sensor type `signal` gives the wifi signal strength of the sensor device. 87 | 88 | Please open an issue if you want to see other attributes and provide me with a sample of your sensor data by calling `http://192.168.x.y/data.json`. 89 | 90 | 91 | 92 | 93 | ## Examples 94 | 95 | ### Rounding and offset 96 | 97 | Use [Template Sensors](https://www.home-assistant.io/integrations/template/) to round the values or to give them an offset. 98 | 99 | ``` 100 | sensor: 101 | - platform: template 102 | sensors: 103 | temperature: 104 | value_template: '{{ (states("sensor.feinstaubsensor_temperature") | float) | round(1) - 2}}' 105 | friendly_name: 'Temperature' 106 | unit_of_measurement: '°C' 107 | ``` 108 | 109 | 110 | 111 | ### Calculate equivalent atmospheric pressure at sea level 112 | 113 | To adjusted the atmospheric pressure to the equivalent sea level pressure you need to use the barometric formula which depends on the altitude and the current temperature. 114 | In this example we use an altitude of 300m and receive the temperature from our sensor `sensor.feinstaubsensor_temperature`. 115 | 116 | 117 | ``` 118 | atmospheric_pressure: 119 | value_template: >- 120 | {% set temperature_gradient = 0.0065 %} 121 | {% set exponent = 0.03416 / temperature_gradient %} 122 | 123 | {% set altitude_meters = 300 %} 124 | {% set temperature_celsius = states('sensor.feinstaubsensor_temperature') | float %} 125 | {% set temperautre_at_sealevel_kelvin = temperature_celsius + (temperature_celsius * temperature_gradient) + 273.15 %} 126 | {% set air_pressure_hpa = (states('sensor.feinstaubsensor_pressure') | float / 100) | round(1) %} 127 | 128 | {{ (air_pressure_hpa / (1 - ((temperature_gradient * altitude_meters) / temperautre_at_sealevel_kelvin)) ** exponent) | round(1) }} 129 | friendly_name: 'Atmospheric pressure' 130 | unit_of_measurement: 'hPa' 131 | ``` 132 | 133 | -------------------------------------------------------------------------------- /custom_components/local_luftdaten/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Support for local Luftdaten sensors. 3 | 4 | Copyright (c) 2019 Mario Villavecchia 5 | 6 | Licensed under MIT. All rights reserved. 7 | 8 | https://github.com/lichtteil/local_luftdaten/ 9 | """ 10 | -------------------------------------------------------------------------------- /custom_components/local_luftdaten/const.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from homeassistant.components.sensor import ( 4 | SensorDeviceClass, 5 | SensorEntityDescription, 6 | SensorStateClass, 7 | ) 8 | from homeassistant.const import ( 9 | CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, 10 | PERCENTAGE, 11 | UnitOfPressure, 12 | SIGNAL_STRENGTH_DECIBELS_MILLIWATT, 13 | UnitOfTemperature, 14 | ) 15 | from homeassistant.helpers.entity import EntityCategory 16 | 17 | DOMAIN = "local_luftdaten" 18 | 19 | DEFAULT_NAME = 'Luftdaten Sensor' 20 | DEFAULT_RESOURCE = 'http://{}/data.json' 21 | DEFAULT_VERIFY_SSL = True 22 | DEFAULT_SCAN_INTERVAL = timedelta(minutes=3) 23 | 24 | # Sensors 25 | SENSOR_BME280_HUMIDITY = 'BME280_humidity' 26 | SENSOR_BME280_PRESSURE = 'BME280_pressure' 27 | SENSOR_BME280_TEMPERATURE = 'BME280_temperature' 28 | SENSOR_BMP_PRESSURE = 'BMP_pressure' 29 | SENSOR_BMP_TEMPERATURE = 'BMP_temperature' 30 | SENSOR_BMP280_PRESSURE = 'BMP280_pressure' 31 | SENSOR_BMP280_TEMPERATURE = 'BMP280_temperature' 32 | SENSOR_DS18B20_TEMPERATURE = 'DS18B20_temperature' 33 | SENSOR_HECA_HUMIDITY = 'HECA_humidity' 34 | SENSOR_HECA_TEMPERATURE = 'HECA_temperature' 35 | SENSOR_HPM_P1 = 'HPM_P1' 36 | SENSOR_HPM_P2 = 'HPM_P2' 37 | SENSOR_HTU21D_HUMIDITY = 'HTU21D_humidity' 38 | SENSOR_HTU21D_TEMPERATURE = 'HTU21D_temperature' 39 | SENSOR_HUMIDITY = 'humidity' 40 | SENSOR_PM1 = 'SDS_P1' 41 | SENSOR_PM2 = 'SDS_P2' 42 | SENSOR_PMS_P0 = 'PMS_P0' 43 | SENSOR_PMS_P1 = 'PMS_P1' 44 | SENSOR_PMS_P2 = 'PMS_P2' 45 | SENSOR_SHT3X_HUMIDITY = 'SHT3X_humidity' 46 | SENSOR_SHT3X_TEMPERATURE = 'SHT3X_temperature' 47 | SENSOR_SPS30_P0 = 'SPS30_P0' 48 | SENSOR_SPS30_P1 = 'SPS30_P1' 49 | SENSOR_SPS30_P2 = 'SPS30_P2' 50 | SENSOR_SPS30_P4 = 'SPS30_P4' 51 | SENSOR_SEN5X_P0 = 'SEN5X_P0' 52 | SENSOR_SEN5X_P1 = 'SEN5X_P1' 53 | SENSOR_SEN5X_P2 = 'SEN5X_P2' 54 | SENSOR_SEN5X_P4 = 'SEN5X_P4' 55 | SENSOR_SEN5X_NOX = 'SEN5X_NOX' 56 | SENSOR_SEN5X_VOC = 'SEN5X_VOC' 57 | SENSOR_TEMPERATURE = 'temperature' 58 | SENSOR_WIFI_SIGNAL = 'signal' 59 | 60 | SENSOR_DESCRIPTIONS = { 61 | SENSOR_BME280_HUMIDITY: SensorEntityDescription( 62 | device_class=SensorDeviceClass.HUMIDITY, 63 | key=SENSOR_BME280_HUMIDITY, 64 | name='Humidity', 65 | native_unit_of_measurement=PERCENTAGE, 66 | state_class=SensorStateClass.MEASUREMENT, 67 | ), 68 | SENSOR_BME280_PRESSURE: SensorEntityDescription( 69 | device_class=SensorDeviceClass.PRESSURE, 70 | key=SENSOR_BME280_PRESSURE, 71 | name='Pressure', 72 | native_unit_of_measurement=UnitOfPressure.PA, 73 | state_class=SensorStateClass.MEASUREMENT, 74 | ), 75 | SENSOR_BME280_TEMPERATURE: SensorEntityDescription( 76 | device_class=SensorDeviceClass.TEMPERATURE, 77 | key=SENSOR_BME280_TEMPERATURE, 78 | name='Temperature', 79 | native_unit_of_measurement=UnitOfTemperature.CELSIUS, 80 | state_class=SensorStateClass.MEASUREMENT, 81 | ), 82 | SENSOR_BMP_PRESSURE: SensorEntityDescription( 83 | device_class=SensorDeviceClass.PRESSURE, 84 | key=SENSOR_BMP_PRESSURE, 85 | name='Pressure', 86 | native_unit_of_measurement=UnitOfPressure.PA, 87 | state_class=SensorStateClass.MEASUREMENT, 88 | ), 89 | SENSOR_BMP_TEMPERATURE: SensorEntityDescription( 90 | device_class=SensorDeviceClass.TEMPERATURE, 91 | key=SENSOR_BMP_TEMPERATURE, 92 | name='Temperature', 93 | native_unit_of_measurement=UnitOfTemperature.CELSIUS, 94 | state_class=SensorStateClass.MEASUREMENT, 95 | ), 96 | SENSOR_BMP280_TEMPERATURE: SensorEntityDescription( 97 | device_class=SensorDeviceClass.TEMPERATURE, 98 | key=SENSOR_BMP280_TEMPERATURE, 99 | name='Temperature', 100 | native_unit_of_measurement=UnitOfTemperature.CELSIUS, 101 | state_class=SensorStateClass.MEASUREMENT, 102 | ), 103 | SENSOR_BMP280_PRESSURE: SensorEntityDescription( 104 | device_class=SensorDeviceClass.PRESSURE, 105 | key=SENSOR_BMP280_PRESSURE, 106 | name='Pressure', 107 | native_unit_of_measurement=UnitOfPressure.PA, 108 | state_class=SensorStateClass.MEASUREMENT, 109 | ), 110 | SENSOR_DS18B20_TEMPERATURE: SensorEntityDescription( 111 | device_class=SensorDeviceClass.TEMPERATURE, 112 | key=SENSOR_DS18B20_TEMPERATURE, 113 | name='Temperature', 114 | native_unit_of_measurement=UnitOfTemperature.CELSIUS, 115 | state_class=SensorStateClass.MEASUREMENT, 116 | ), 117 | SENSOR_HECA_HUMIDITY: SensorEntityDescription( 118 | device_class=SensorDeviceClass.HUMIDITY, 119 | key=SENSOR_HECA_HUMIDITY, 120 | name='Humidity', 121 | native_unit_of_measurement=PERCENTAGE, 122 | state_class=SensorStateClass.MEASUREMENT, 123 | ), 124 | SENSOR_HECA_TEMPERATURE: SensorEntityDescription( 125 | device_class=SensorDeviceClass.TEMPERATURE, 126 | key=SENSOR_HECA_TEMPERATURE, 127 | name='Temperature', 128 | native_unit_of_measurement=UnitOfTemperature.CELSIUS, 129 | state_class=SensorStateClass.MEASUREMENT, 130 | ), 131 | SENSOR_HPM_P1: SensorEntityDescription( 132 | device_class=SensorDeviceClass.PM10, 133 | key=SENSOR_HPM_P1, 134 | name='PM10', 135 | native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, 136 | state_class=SensorStateClass.MEASUREMENT, 137 | ), 138 | SENSOR_HPM_P2: SensorEntityDescription( 139 | device_class=SensorDeviceClass.PM25, 140 | key=SENSOR_HPM_P2, 141 | name='PM2.5', 142 | native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, 143 | state_class=SensorStateClass.MEASUREMENT, 144 | ), 145 | SENSOR_HTU21D_HUMIDITY: SensorEntityDescription( 146 | device_class=SensorDeviceClass.HUMIDITY, 147 | key=SENSOR_HTU21D_HUMIDITY, 148 | name='Humidity', 149 | native_unit_of_measurement=PERCENTAGE, 150 | state_class=SensorStateClass.MEASUREMENT, 151 | ), 152 | SENSOR_HTU21D_TEMPERATURE: SensorEntityDescription( 153 | device_class=SensorDeviceClass.TEMPERATURE, 154 | key=SENSOR_HTU21D_TEMPERATURE, 155 | name='Temperature', 156 | native_unit_of_measurement=UnitOfTemperature.CELSIUS, 157 | state_class=SensorStateClass.MEASUREMENT, 158 | ), 159 | SENSOR_HUMIDITY: SensorEntityDescription( 160 | device_class=SensorDeviceClass.HUMIDITY, 161 | key=SENSOR_HUMIDITY, 162 | name='Humidity', 163 | native_unit_of_measurement=PERCENTAGE, 164 | state_class=SensorStateClass.MEASUREMENT, 165 | ), 166 | SENSOR_PM1: SensorEntityDescription( 167 | device_class=SensorDeviceClass.PM10, 168 | key=SENSOR_PM1, 169 | name='PM10', 170 | native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, 171 | state_class=SensorStateClass.MEASUREMENT, 172 | ), 173 | SENSOR_PM2: SensorEntityDescription( 174 | device_class=SensorDeviceClass.PM25, 175 | key=SENSOR_PM2, 176 | name='PM2.5', 177 | native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, 178 | state_class=SensorStateClass.MEASUREMENT, 179 | ), 180 | SENSOR_PMS_P0: SensorEntityDescription( 181 | device_class=SensorDeviceClass.PM1, 182 | key=SENSOR_PMS_P0, 183 | name='PM1', 184 | native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, 185 | state_class=SensorStateClass.MEASUREMENT, 186 | ), 187 | SENSOR_PMS_P1: SensorEntityDescription( 188 | device_class=SensorDeviceClass.PM10, 189 | key=SENSOR_PMS_P1, 190 | name='PM10', 191 | native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, 192 | state_class=SensorStateClass.MEASUREMENT, 193 | ), 194 | SENSOR_PMS_P2: SensorEntityDescription( 195 | device_class=SensorDeviceClass.PM25, 196 | key=SENSOR_PMS_P2, 197 | name='PM2.5', 198 | native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, 199 | state_class=SensorStateClass.MEASUREMENT, 200 | ), 201 | SENSOR_SHT3X_HUMIDITY: SensorEntityDescription( 202 | device_class=SensorDeviceClass.HUMIDITY, 203 | key=SENSOR_SHT3X_HUMIDITY, 204 | name='Humidity', 205 | native_unit_of_measurement=PERCENTAGE, 206 | state_class=SensorStateClass.MEASUREMENT, 207 | ), 208 | SENSOR_SHT3X_TEMPERATURE: SensorEntityDescription( 209 | device_class=SensorDeviceClass.TEMPERATURE, 210 | key=SENSOR_SHT3X_TEMPERATURE, 211 | name='Temperature', 212 | native_unit_of_measurement=UnitOfTemperature.CELSIUS, 213 | state_class=SensorStateClass.MEASUREMENT, 214 | ), 215 | SENSOR_SPS30_P0: SensorEntityDescription( 216 | device_class=SensorDeviceClass.PM1, 217 | key=SENSOR_SPS30_P0, 218 | name='PM1', 219 | native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, 220 | state_class=SensorStateClass.MEASUREMENT, 221 | ), 222 | SENSOR_SPS30_P1: SensorEntityDescription( 223 | device_class=SensorDeviceClass.PM10, 224 | key=SENSOR_SPS30_P1, 225 | name='PM10', 226 | native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, 227 | state_class=SensorStateClass.MEASUREMENT, 228 | ), 229 | SENSOR_SPS30_P2: SensorEntityDescription( 230 | device_class=SensorDeviceClass.PM25, 231 | key=SENSOR_SPS30_P2, 232 | name='PM2.5', 233 | native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, 234 | state_class=SensorStateClass.MEASUREMENT, 235 | ), 236 | SENSOR_SPS30_P4: SensorEntityDescription( 237 | device_class=SensorDeviceClass.PM25, # SensorDeviceClass.PM4 not supported. 238 | key=SENSOR_SPS30_P4, 239 | name='PM4', 240 | native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, 241 | state_class=SensorStateClass.MEASUREMENT, 242 | ), 243 | SENSOR_SEN5X_P0: SensorEntityDescription( 244 | device_class=SensorDeviceClass.PM1, 245 | key=SENSOR_SEN5X_P0, 246 | name='PM1', 247 | native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, 248 | state_class=SensorStateClass.MEASUREMENT, 249 | ), 250 | SENSOR_SEN5X_P1: SensorEntityDescription( 251 | device_class=SensorDeviceClass.PM10, 252 | key=SENSOR_SEN5X_P1, 253 | name='PM10', 254 | native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, 255 | state_class=SensorStateClass.MEASUREMENT, 256 | ), 257 | SENSOR_SEN5X_P2: SensorEntityDescription( 258 | device_class=SensorDeviceClass.PM25, 259 | key=SENSOR_SEN5X_P2, 260 | name='PM2.5', 261 | native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, 262 | state_class=SensorStateClass.MEASUREMENT, 263 | ), 264 | SENSOR_SEN5X_P4: SensorEntityDescription( 265 | device_class=SensorDeviceClass.PM25, # SensorDeviceClass.PM4 not supported. 266 | key=SENSOR_SEN5X_P4, 267 | name='PM4', 268 | native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, 269 | state_class=SensorStateClass.MEASUREMENT, 270 | ), 271 | SENSOR_SEN5X_NOX: SensorEntityDescription( 272 | device_class=SensorDeviceClass.AQI, 273 | # SensorDeviceClass.NOX_INDEX is not supported 274 | # SensorDeviceClass.NITROGEN_MONOXIDE and .NITROGEN_DIOXIDE are supported, but require a unit of ug/m3 which is not appropriate for a unitless index 275 | key=SENSOR_SEN5X_NOX, 276 | name='NOX', 277 | state_class=SensorStateClass.MEASUREMENT, 278 | ), 279 | SENSOR_SEN5X_VOC: SensorEntityDescription( 280 | device_class=SensorDeviceClass.AQI, 281 | # SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_INDEX is not supported 282 | # SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS is supported, but requires a unit of ug/m3 which is not appropriate for a unitless index 283 | key=SENSOR_SEN5X_VOC, 284 | name='VOC', 285 | state_class=SensorStateClass.MEASUREMENT, 286 | ), 287 | SENSOR_TEMPERATURE: SensorEntityDescription( 288 | device_class=SensorDeviceClass.TEMPERATURE, 289 | key=SENSOR_TEMPERATURE, 290 | name='Temperature', 291 | native_unit_of_measurement=UnitOfTemperature.CELSIUS, 292 | state_class=SensorStateClass.MEASUREMENT, 293 | ), 294 | SENSOR_WIFI_SIGNAL: SensorEntityDescription( 295 | device_class=SensorDeviceClass.SIGNAL_STRENGTH, 296 | key=SENSOR_WIFI_SIGNAL, 297 | name='WiFi signal', 298 | native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, 299 | state_class=SensorStateClass.MEASUREMENT, 300 | entity_category=EntityCategory.DIAGNOSTIC, 301 | ), 302 | } 303 | -------------------------------------------------------------------------------- /custom_components/local_luftdaten/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "local_luftdaten", 3 | "name": "Local Luftdaten sensor", 4 | "documentation": "https://github.com/lichtteil/local_luftdaten/", 5 | "issue_tracker": "https://github.com/lichtteil/local_luftdaten/issues", 6 | "dependencies": [], 7 | "codeowners": ["@lichtteil"], 8 | "requirements": [], 9 | "version": "2.2.0", 10 | "iot_class": "local_polling" 11 | } 12 | -------------------------------------------------------------------------------- /custom_components/local_luftdaten/sensor.py: -------------------------------------------------------------------------------- 1 | """ 2 | Support for Luftdaten sensors. 3 | 4 | Copyright (c) 2019 Mario Villavecchia 5 | 6 | Licensed under MIT. All rights reserved. 7 | 8 | https://github.com/lichtteil/local_luftdaten/ 9 | """ 10 | 11 | import logging 12 | import asyncio 13 | from typing import Optional 14 | import aiohttp 15 | import async_timeout 16 | import datetime 17 | 18 | import json 19 | 20 | from .const import ( 21 | DEFAULT_NAME, 22 | DEFAULT_RESOURCE, 23 | DEFAULT_SCAN_INTERVAL, 24 | DEFAULT_VERIFY_SSL, 25 | SENSOR_DESCRIPTIONS 26 | ) 27 | from homeassistant.const import ( 28 | CONF_HOST, 29 | CONF_MONITORED_CONDITIONS, 30 | CONF_NAME, 31 | CONF_RESOURCE, 32 | CONF_SCAN_INTERVAL, 33 | CONF_VERIFY_SSL 34 | ) 35 | import voluptuous as vol 36 | 37 | from homeassistant.components.sensor import ( 38 | PLATFORM_SCHEMA, 39 | SensorDeviceClass, 40 | SensorEntity 41 | ) 42 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 43 | from homeassistant.helpers.entity import Entity 44 | import homeassistant.helpers.config_validation as cv 45 | 46 | 47 | _LOGGER = logging.getLogger(__name__) 48 | 49 | 50 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ 51 | vol.Required(CONF_HOST): cv.string, 52 | vol.Required(CONF_MONITORED_CONDITIONS): 53 | vol.All(cv.ensure_list, [vol.In(SENSOR_DESCRIPTIONS)]), 54 | vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, 55 | vol.Optional(CONF_RESOURCE, default=DEFAULT_RESOURCE): cv.string, 56 | vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, 57 | vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): cv.time_period 58 | }) 59 | 60 | 61 | async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): 62 | """Set up the Luftdaten sensor.""" 63 | name = config.get(CONF_NAME) 64 | host = config.get(CONF_HOST) 65 | scan_interval = config.get(CONF_SCAN_INTERVAL) 66 | 67 | verify_ssl = config.get(CONF_VERIFY_SSL) 68 | 69 | resource = config.get(CONF_RESOURCE).format(host) 70 | 71 | session = async_get_clientsession(hass, verify_ssl) 72 | rest_client = LuftdatenClient(session, resource, scan_interval) 73 | 74 | devices = [] 75 | for variable in config[CONF_MONITORED_CONDITIONS]: 76 | devices.append( 77 | LuftdatenSensor(rest_client, name, SENSOR_DESCRIPTIONS[variable])) 78 | 79 | async_add_entities(devices, True) 80 | 81 | 82 | class LuftdatenSensor(SensorEntity): 83 | """Implementation of a LuftdatenSensor sensor.""" 84 | 85 | _name: str 86 | _native_value: Optional[any] 87 | _rest_client: "LuftdatenClient" 88 | 89 | def __init__(self, rest_client, name, description): 90 | """Initialize the LuftdatenSensor sensor.""" 91 | self._rest_client = rest_client 92 | self._name = name 93 | self._native_value = None 94 | 95 | self.entity_description = description 96 | 97 | @property 98 | def unique_id(self): 99 | """Return a unique ID.""" 100 | return '{}-{}'.format(self._name, self.entity_description.key) 101 | 102 | @property 103 | def name(self): 104 | """Return the name of the sensor.""" 105 | return '{} {}'.format(self._name, self.entity_description.name) 106 | 107 | @property 108 | def native_value(self): 109 | """Return the value reported by the sensor.""" 110 | return self._native_value 111 | 112 | @property 113 | def icon(self): 114 | """Return the icon to use in the frontend, if any.""" 115 | if self.device_class in [SensorDeviceClass.PM1, SensorDeviceClass.PM25]: 116 | return 'mdi:thought-bubble-outline' 117 | elif self.device_class == SensorDeviceClass.PM10: 118 | return 'mdi:thought-bubble' 119 | 120 | return None 121 | 122 | async def async_update(self): 123 | """Get the latest data from REST API and update the state.""" 124 | try: 125 | await self._rest_client.async_update() 126 | except LuftdatenError: 127 | return 128 | parsed_json = self._rest_client.data 129 | 130 | if parsed_json is None: 131 | return 132 | 133 | sensordata_values = parsed_json['sensordatavalues'] 134 | for sensordata_value in sensordata_values: 135 | if sensordata_value['value_type'] == self.entity_description.key: 136 | self._native_value = sensordata_value['value'] 137 | 138 | 139 | class LuftdatenError(Exception): 140 | pass 141 | 142 | 143 | class LuftdatenClient(object): 144 | """Class for handling the data retrieval.""" 145 | 146 | def __init__(self, session, resource, scan_interval): 147 | """Initialize the data object.""" 148 | self._session = session 149 | self._resource = resource 150 | self.lastUpdate = datetime.datetime.now() 151 | self.scan_interval = scan_interval 152 | self.data = None 153 | self.lock = asyncio.Lock() 154 | 155 | async def async_update(self): 156 | """Get the latest data from Luftdaten service.""" 157 | 158 | async with self.lock: 159 | # Time difference since last data update 160 | callTimeDiff = datetime.datetime.now() - self.lastUpdate 161 | # Fetch sensor values only once per scan_interval 162 | if (callTimeDiff < self.scan_interval): 163 | if self.data != None: 164 | return 165 | 166 | # Handle calltime differences: substract 5 second from current time 167 | self.lastUpdate = datetime.datetime.now() - datetime.timedelta(seconds=5) 168 | 169 | # Query local device 170 | responseData = None 171 | try: 172 | _LOGGER.debug("Get data from %s", str(self._resource)) 173 | with async_timeout.timeout(30): 174 | response = await self._session.get(self._resource) 175 | responseData = await response.text() 176 | _LOGGER.debug("Received data: %s", str(self.data)) 177 | except aiohttp.ClientError as err: 178 | _LOGGER.warning("REST request error: {0}".format(err)) 179 | self.data = None 180 | raise LuftdatenError 181 | except asyncio.TimeoutError: 182 | _LOGGER.warning("REST request timeout") 183 | self.data = None 184 | raise LuftdatenError 185 | 186 | # Parse REST response 187 | try: 188 | parsed_json = json.loads(responseData) 189 | if not isinstance(parsed_json, dict): 190 | _LOGGER.warning("JSON result was not a dictionary") 191 | self.data = None 192 | return 193 | # Set parsed json as data 194 | self.data = parsed_json 195 | except ValueError: 196 | _LOGGER.warning("REST result could not be parsed as JSON") 197 | _LOGGER.debug("Erroneous JSON: %s", responseData) 198 | self.data = None 199 | return 200 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Local Luftdaten Sensor", 3 | "render_readme": true, 4 | "domains": "sensor" 5 | } 6 | --------------------------------------------------------------------------------