Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,15 @@ In the end your file structure should look like that:
```

## Configuration
Create a new sensor entry in your `configuration.yaml` and adjust the host name or the ip address.
This integration now uses Home Assistant UI config flow.

1. Go to **Settings -> Devices & Services -> Add Integration**.
2. Search for **Local Luftdaten**.
3. Fill in host, monitored conditions, and optional settings.

### YAML migration
Existing YAML platform config is automatically imported into a config entry at startup.
After import, remove the `sensor: - platform: local_luftdaten` block from `configuration.yaml`.

|Parameter |Type | Necessity | Description
|:----------------------|:-------|:------------ |:------------
Expand All @@ -34,6 +42,7 @@ Create a new sensor entry in your `configuration.yaml` and adjust the host name


```yaml
# Legacy format (auto-imported once):
sensor:
- platform: local_luftdaten
host: 192.168.0.123
Expand Down
28 changes: 22 additions & 6 deletions custom_components/local_luftdaten/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,25 @@
"""
Support for local Luftdaten sensors.
"""The Local Luftdaten integration."""

Copyright (c) 2019 Mario Villavecchia
from __future__ import annotations

Licensed under MIT. All rights reserved.
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant

https://github.com/lichtteil/local_luftdaten/
"""
PLATFORMS: list[Platform] = [Platform.SENSOR]


async def async_setup(hass: HomeAssistant, config: dict) -> bool:
"""Set up the Local Luftdaten integration."""
return True


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Local Luftdaten from a config entry."""
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
124 changes: 124 additions & 0 deletions custom_components/local_luftdaten/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
"""Config flow for Local Luftdaten."""

from __future__ import annotations

from typing import Any

import voluptuous as vol

from homeassistant import config_entries
from homeassistant.const import (
CONF_HOST,
CONF_MONITORED_CONDITIONS,
CONF_NAME,
CONF_RESOURCE,
CONF_SCAN_INTERVAL,
CONF_VERIFY_SSL,
)
from homeassistant.core import callback
from homeassistant.helpers import selector

from .const import (
DEFAULT_NAME,
DEFAULT_RESOURCE,
DEFAULT_SCAN_INTERVAL,
DEFAULT_VERIFY_SSL,
DOMAIN,
SENSOR_DESCRIPTIONS,
)

DEFAULT_MONITORED_CONDITIONS = ["SDS_P1", "SDS_P2", "temperature", "humidity"]


def _step_user_schema(user_input: dict[str, Any] | None = None) -> vol.Schema:
"""Return the user step schema."""
default_scan_interval = int(DEFAULT_SCAN_INTERVAL.total_seconds())
user_input = user_input or {}

return vol.Schema(
{
vol.Required(CONF_HOST, default=user_input.get(CONF_HOST, "")): str,
vol.Required(
CONF_NAME, default=user_input.get(CONF_NAME, DEFAULT_NAME)
): str,
vol.Required(
CONF_RESOURCE, default=user_input.get(CONF_RESOURCE, DEFAULT_RESOURCE)
): str,
vol.Required(
CONF_VERIFY_SSL, default=user_input.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL)
): bool,
vol.Required(
CONF_MONITORED_CONDITIONS,
default=user_input.get(
CONF_MONITORED_CONDITIONS, DEFAULT_MONITORED_CONDITIONS
),
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=sorted(SENSOR_DESCRIPTIONS.keys()),
multiple=True,
mode=selector.SelectSelectorMode.DROPDOWN,
)
),
vol.Required(
CONF_SCAN_INTERVAL,
default=user_input.get(CONF_SCAN_INTERVAL, default_scan_interval),
): vol.All(vol.Coerce(int), vol.Range(min=1)),
}
)


class LocalLuftdatenConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Local Luftdaten."""

VERSION = 1

async def async_step_user(self, user_input: dict[str, Any] | None = None):
"""Handle the initial step."""
errors: dict[str, str] = {}

if user_input is not None:
await self.async_set_unique_id(user_input[CONF_HOST])
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=user_input[CONF_NAME],
data=user_input,
)

return self.async_show_form(
step_id="user",
data_schema=_step_user_schema(user_input),
errors=errors,
)

async def async_step_import(self, user_input: dict[str, Any]):
"""Handle YAML import."""
await self.async_set_unique_id(user_input[CONF_HOST])
self._abort_if_unique_id_configured(updates=user_input)

return self.async_create_entry(
title=user_input[CONF_NAME],
data=user_input,
)

@staticmethod
@callback
def async_get_options_flow(config_entry: config_entries.ConfigEntry):
"""Get the options flow for this handler."""
return LocalLuftdatenOptionsFlow()


class LocalLuftdatenOptionsFlow(config_entries.OptionsFlow):
"""Options flow for Local Luftdaten."""

async def async_step_init(self, user_input: dict[str, Any] | None = None):
"""Manage options."""
if user_input is not None:
data = {**self.config_entry.data, **user_input}
self.hass.config_entries.async_update_entry(self.config_entry, data=data)
return self.async_create_entry(title="", data={})

return self.async_show_form(
step_id="init",
data_schema=_step_user_schema(self.config_entry.data),
errors={},
)
3 changes: 3 additions & 0 deletions custom_components/local_luftdaten/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
key=SENSOR_BME280_PRESSURE,
name='Pressure',
native_unit_of_measurement=UnitOfPressure.PA,
suggested_unit_of_measurement=UnitOfPressure.HPA,
state_class=SensorStateClass.MEASUREMENT,
),
SENSOR_BME280_TEMPERATURE: SensorEntityDescription(
Expand All @@ -84,6 +85,7 @@
key=SENSOR_BMP_PRESSURE,
name='Pressure',
native_unit_of_measurement=UnitOfPressure.PA,
suggested_unit_of_measurement=UnitOfPressure.HPA,
state_class=SensorStateClass.MEASUREMENT,
),
SENSOR_BMP_TEMPERATURE: SensorEntityDescription(
Expand All @@ -105,6 +107,7 @@
key=SENSOR_BMP280_PRESSURE,
name='Pressure',
native_unit_of_measurement=UnitOfPressure.PA,
suggested_unit_of_measurement=UnitOfPressure.HPA,
state_class=SensorStateClass.MEASUREMENT,
),
SENSOR_DS18B20_TEMPERATURE: SensorEntityDescription(
Expand Down
1 change: 1 addition & 0 deletions custom_components/local_luftdaten/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"documentation": "https://github.com/lichtteil/local_luftdaten/",
"issue_tracker": "https://github.com/lichtteil/local_luftdaten/issues",
"dependencies": [],
"config_flow": true,
"codeowners": ["@lichtteil"],
"requirements": [],
"version": "2.2.0",
Expand Down
94 changes: 72 additions & 22 deletions custom_components/local_luftdaten/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,25 @@
https://github.com/lichtteil/local_luftdaten/
"""

from __future__ import annotations

import logging
import asyncio
from typing import Optional
from typing import Any, Optional
import aiohttp
import datetime

import json

from homeassistant import config_entries
from homeassistant.config_entries import ConfigEntry
from .const import (
DOMAIN,
DEFAULT_NAME,
DEFAULT_RESOURCE,
DEFAULT_SCAN_INTERVAL,
DEFAULT_VERIFY_SSL,
SENSOR_DESCRIPTIONS
SENSOR_DESCRIPTIONS,
)
from homeassistant.const import (
CONF_HOST,
Expand All @@ -36,11 +41,12 @@
from homeassistant.components.sensor import (
PLATFORM_SCHEMA,
SensorDeviceClass,
SensorEntity
SensorEntity,
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity import Entity
import homeassistant.helpers.config_validation as cv
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo


_LOGGER = logging.getLogger(__name__)
Expand All @@ -58,37 +64,57 @@


async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Set up the Luftdaten sensor."""
name = config.get(CONF_NAME)
host = config.get(CONF_HOST)
scan_interval = config.get(CONF_SCAN_INTERVAL)

verify_ssl = config.get(CONF_VERIFY_SSL)

resource = config.get(CONF_RESOURCE).format(host)
"""Import YAML config into a config entry."""
await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data={
CONF_HOST: config[CONF_HOST],
CONF_MONITORED_CONDITIONS: config[CONF_MONITORED_CONDITIONS],
CONF_NAME: config[CONF_NAME],
CONF_RESOURCE: config[CONF_RESOURCE],
CONF_VERIFY_SSL: config[CONF_VERIFY_SSL],
CONF_SCAN_INTERVAL: int(config[CONF_SCAN_INTERVAL].total_seconds()),
},
)
return


async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities
) -> None:
"""Set up the sensor platform from a config entry."""
data = entry.data

name = data[CONF_NAME]
host = data[CONF_HOST]
scan_interval = datetime.timedelta(seconds=data[CONF_SCAN_INTERVAL])
verify_ssl = data[CONF_VERIFY_SSL]
resource = data[CONF_RESOURCE].format(host)

session = async_get_clientsession(hass, verify_ssl)
rest_client = LuftdatenClient(session, resource, scan_interval)

devices = []
for variable in config[CONF_MONITORED_CONDITIONS]:
devices.append(
LuftdatenSensor(rest_client, name, SENSOR_DESCRIPTIONS[variable]))

async_add_entities(devices, True)
entities = [
LuftdatenSensor(rest_client, name, host, SENSOR_DESCRIPTIONS[variable])
for variable in data[CONF_MONITORED_CONDITIONS]
]
async_add_entities(entities, True)


class LuftdatenSensor(SensorEntity):
"""Implementation of a LuftdatenSensor sensor."""

_name: str
_native_value: Optional[any]
_host: str
_native_value: Optional[Any]
_rest_client: "LuftdatenClient"

def __init__(self, rest_client, name, description):
def __init__(self, rest_client, name, host, description):
"""Initialize the LuftdatenSensor sensor."""
self._rest_client = rest_client
self._name = name
self._host = host
self._native_value = None

self.entity_description = description
Expand Down Expand Up @@ -118,6 +144,30 @@ def icon(self):

return None

@property
def suggested_display_precision(self) -> int | None:
"""Suggest 1 decimal for humidity, PM, pressure, and temperature."""
if self.device_class in {
SensorDeviceClass.HUMIDITY,
SensorDeviceClass.PM1,
SensorDeviceClass.PM25,
SensorDeviceClass.PM10,
SensorDeviceClass.PRESSURE,
SensorDeviceClass.TEMPERATURE,
}:
return 1
return None

@property
def device_info(self) -> DeviceInfo:
"""Return device information for grouping entities into one device."""
return DeviceInfo(
identifiers={(DOMAIN, self._host)},
name=self._name,
manufacturer="Luftdaten",
model="Local Sensor",
)

async def async_update(self):
"""Get the latest data from REST API and update the state."""
try:
Expand Down Expand Up @@ -158,8 +208,8 @@ async def async_update(self):
# Time difference since last data update
callTimeDiff = datetime.datetime.now() - self.lastUpdate
# Fetch sensor values only once per scan_interval
if (callTimeDiff < self.scan_interval):
if self.data != None:
if callTimeDiff < self.scan_interval:
if self.data is not None:
return

# Handle calltime differences: substract 5 second from current time
Expand Down
Loading