Add wiffi integration (#30784)

* Add integration for wiffi devices

wiffi devices are DIY board manufactured by stall.biz.
Several devices are available, e.g. a weather station (weatherman), an
indoor environmental sensor (wiffi-wz) and some more.
This intgration has been developed using a weatherman device, but should
also work for other devices from stall.biz.

* Fix pylint warning

* Use WIFFI / STALL WIFFI instead of wiffi to be consistent with stall.biz

* Don't update disabled entities.

* fix complains

- move wiffi specific code to pypi
- remove yaml configuration code

* incorporate various suggestions from code review

* fix remaining comments from Martin

* fix comments

* add tests for config flow

* fix comments

* add missing requirements for tests

* fix pylint warnings

* fix comments

* fix comments

remove debug log
rename .translations to translations

* rebase and adapt to latest dev branch

* Update homeassistant/components/wiffi/config_flow.py

Co-Authored-By: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/wiffi/config_flow.py

Co-Authored-By: Martin Hjelmare <marhje52@gmail.com>

* fix missing import

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
pull/35574/head
Steffen Zimmermann 2020-05-13 10:40:58 +02:00 committed by GitHub
parent 6464c94990
commit ee96ff2846
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 647 additions and 0 deletions

View File

@ -849,6 +849,7 @@ omit =
homeassistant/components/webostv/*
homeassistant/components/wemo/*
homeassistant/components/whois/sensor.py
homeassistant/components/wiffi/*
homeassistant/components/wink/*
homeassistant/components/wirelesstag/*
homeassistant/components/worldtidesinfo/sensor.py

View File

@ -449,6 +449,7 @@ homeassistant/components/weather/* @fabaff
homeassistant/components/webostv/* @bendavid
homeassistant/components/websocket_api/* @home-assistant/core
homeassistant/components/wemo/* @sqldiablo
homeassistant/components/wiffi/* @mampfes
homeassistant/components/withings/* @vangorra
homeassistant/components/wled/* @frenck
homeassistant/components/workday/* @fabaff

View File

@ -0,0 +1,230 @@
"""Component for wiffi support."""
import asyncio
from datetime import timedelta
import errno
import logging
from wiffi import WiffiTcpServer
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PORT
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.util.dt import utcnow
from .const import (
CHECK_ENTITIES_SIGNAL,
CREATE_ENTITY_SIGNAL,
DOMAIN,
UPDATE_ENTITY_SIGNAL,
)
_LOGGER = logging.getLogger(__name__)
PLATFORMS = ["sensor", "binary_sensor"]
async def async_setup(hass: HomeAssistant, config: dict):
"""Set up the wiffi component. config contains data from configuration.yaml."""
return True
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry):
"""Set up wiffi from a config entry, config_entry contains data from config entry database."""
# create api object
api = WiffiIntegrationApi(hass)
api.async_setup(config_entry)
# store api object
hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = api
try:
await api.server.start_server()
except OSError as exc:
if exc.errno != errno.EADDRINUSE:
_LOGGER.error("Start_server failed, errno: %d", exc.errno)
return False
_LOGGER.error("Port %s already in use", config_entry.data[CONF_PORT])
raise ConfigEntryNotReady from exc
for component in PLATFORMS:
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(config_entry, component)
)
return True
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry):
"""Unload a config entry."""
api: "WiffiIntegrationApi" = hass.data[DOMAIN][config_entry.entry_id]
await api.server.close_server()
unload_ok = all(
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_unload(config_entry, component)
for component in PLATFORMS
]
)
)
if unload_ok:
api = hass.data[DOMAIN].pop(config_entry.entry_id)
api.shutdown()
return unload_ok
def generate_unique_id(device, metric):
"""Generate a unique string for the entity."""
return f"{device.mac_address.replace(':', '')}-{metric.name}"
class WiffiIntegrationApi:
"""API object for wiffi handling. Stored in hass.data."""
def __init__(self, hass):
"""Initialize the instance."""
self._hass = hass
self._server = None
self._known_devices = {}
self._periodic_callback = None
def async_setup(self, config_entry):
"""Set up api instance."""
self._server = WiffiTcpServer(config_entry.data[CONF_PORT], self)
self._periodic_callback = async_track_time_interval(
self._hass, self._periodic_tick, timedelta(seconds=10)
)
def shutdown(self):
"""Shutdown wiffi api.
Remove listener for periodic callbacks.
"""
remove_listener = self._periodic_callback
if remove_listener is not None:
remove_listener()
async def __call__(self, device, metrics):
"""Process callback from TCP server if new data arrives from a device."""
if device.mac_address not in self._known_devices:
# add empty set for new device
self._known_devices[device.mac_address] = set()
for metric in metrics:
if metric.id not in self._known_devices[device.mac_address]:
self._known_devices[device.mac_address].add(metric.id)
async_dispatcher_send(self._hass, CREATE_ENTITY_SIGNAL, device, metric)
else:
async_dispatcher_send(
self._hass,
f"{UPDATE_ENTITY_SIGNAL}-{generate_unique_id(device, metric)}",
device,
metric,
)
@property
def server(self):
"""Return TCP server instance for start + close."""
return self._server
@callback
def _periodic_tick(self, now=None):
"""Check if any entity has timed out because it has not been updated."""
async_dispatcher_send(self._hass, CHECK_ENTITIES_SIGNAL)
class WiffiEntity(Entity):
"""Common functionality for all wiffi entities."""
def __init__(self, device, metric):
"""Initialize the base elements of a wiffi entity."""
self._id = generate_unique_id(device, metric)
self._device_info = {
"connections": {
(device_registry.CONNECTION_NETWORK_MAC, device.mac_address)
},
"identifiers": {(DOMAIN, device.mac_address)},
"manufacturer": "stall.biz",
"name": f"{device.moduletype} {device.mac_address}",
"model": device.moduletype,
"sw_version": device.sw_version,
}
self._name = metric.description
self._expiration_date = None
self._value = None
async def async_added_to_hass(self):
"""Entity has been added to hass."""
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{UPDATE_ENTITY_SIGNAL}-{self._id}",
self._update_value_callback,
)
)
self.async_on_remove(
async_dispatcher_connect(
self.hass, CHECK_ENTITIES_SIGNAL, self._check_expiration_date
)
)
@property
def should_poll(self):
"""Disable polling because data driven ."""
return False
@property
def device_info(self):
"""Return wiffi device info which is shared between all entities of a device."""
return self._device_info
@property
def unique_id(self):
"""Return unique id for entity."""
return self._id
@property
def name(self):
"""Return entity name."""
return self._name
@property
def available(self):
"""Return true if value is valid."""
return self._value is not None
def reset_expiration_date(self):
"""Reset value expiration date.
Will be called by derived classes after a value update has been received.
"""
self._expiration_date = utcnow() + timedelta(minutes=3)
@callback
def _update_value_callback(self, device, metric):
"""Update the value of the entity."""
@callback
def _check_expiration_date(self):
"""Periodically check if entity value has been updated.
If there are no more updates from the wiffi device, the value will be
set to unavailable.
"""
if (
self._value is not None
and self._expiration_date is not None
and utcnow() > self._expiration_date
):
self._value = None
self.async_write_ha_state()

View File

@ -0,0 +1,53 @@
"""Binary sensor platform support for wiffi devices."""
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from . import WiffiEntity
from .const import CREATE_ENTITY_SIGNAL
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up platform for a new integration.
Called by the HA framework after async_forward_entry_setup has been called
during initialization of a new integration (= wiffi).
"""
@callback
def _create_entity(device, metric):
"""Create platform specific entities."""
entities = []
if metric.is_bool:
entities.append(BoolEntity(device, metric))
async_add_entities(entities)
async_dispatcher_connect(hass, CREATE_ENTITY_SIGNAL, _create_entity)
class BoolEntity(WiffiEntity, BinarySensorEntity):
"""Entity for wiffi metrics which have a boolean value."""
def __init__(self, device, metric):
"""Initialize the entity."""
super().__init__(device, metric)
self._value = metric.value
self.reset_expiration_date()
@property
def is_on(self):
"""Return the state of the entity."""
return self._value
@callback
def _update_value_callback(self, device, metric):
"""Update the value of the entity.
Called if a new message has been received from the wiffi device.
"""
self.reset_expiration_date()
self._value = metric.value
self.async_write_ha_state()

View File

@ -0,0 +1,57 @@
"""Config flow for wiffi component.
Used by UI to setup a wiffi integration.
"""
import errno
import voluptuous as vol
from wiffi import WiffiTcpServer
from homeassistant import config_entries
from homeassistant.const import CONF_PORT
from homeassistant.core import callback
from .const import DEFAULT_PORT, DOMAIN # pylint: disable=unused-import
class WiffiFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Wiffi server setup config flow."""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH
async def async_step_user(self, user_input=None):
"""Handle the start of the config flow.
Called after wiffi integration has been selected in the 'add integration
UI'. The user_input is set to None in this case. We will open a config
flow form then.
This function is also called if the form has been submitted. user_input
contains a dict with the user entered values then.
"""
if user_input is None:
return self._async_show_form()
# received input from form or configuration.yaml
try:
# try to start server to check whether port is in use
server = WiffiTcpServer(user_input[CONF_PORT])
await server.start_server()
await server.close_server()
return self.async_create_entry(
title=f"Port {user_input[CONF_PORT]}", data=user_input
)
except OSError as exc:
if exc.errno == errno.EADDRINUSE:
return self.async_abort(reason="addr_in_use")
return self.async_abort(reason="start_server_failed")
@callback
def _async_show_form(self, errors=None):
"""Show the config flow form to the user."""
data_schema = {vol.Required(CONF_PORT, default=DEFAULT_PORT): int}
return self.async_show_form(
step_id="user", data_schema=vol.Schema(data_schema), errors=errors or {}
)

View File

@ -0,0 +1,12 @@
"""Constants for the wiffi component."""
# Component domain, used to store component data in hass data.
DOMAIN = "wiffi"
# Default port for TCP server
DEFAULT_PORT = 8189
# Signal name to send create/update to platform (sensor/binary_sensor)
CREATE_ENTITY_SIGNAL = "wiffi_create_entity_signal"
UPDATE_ENTITY_SIGNAL = "wiffi_update_entity_signal"
CHECK_ENTITIES_SIGNAL = "wiffi_check_entities_signal"

View File

@ -0,0 +1,11 @@
{
"domain": "wiffi",
"name": "Wiffi",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/wiffi",
"requirements": ["wiffi==1.0.0"],
"dependencies": [],
"codeowners": [
"@mampfes"
]
}

View File

@ -0,0 +1,125 @@
"""Sensor platform support for wiffi devices."""
from homeassistant.components.sensor import (
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_ILLUMINANCE,
DEVICE_CLASS_PRESSURE,
DEVICE_CLASS_TEMPERATURE,
)
from homeassistant.const import DEGREE, PRESSURE_MBAR, TEMP_CELSIUS
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from . import WiffiEntity
from .const import CREATE_ENTITY_SIGNAL
from .wiffi_strings import (
WIFFI_UOM_DEGREE,
WIFFI_UOM_LUX,
WIFFI_UOM_MILLI_BAR,
WIFFI_UOM_PERCENT,
WIFFI_UOM_TEMP_CELSIUS,
)
# map to determine HA device class from wiffi's unit of measurement
UOM_TO_DEVICE_CLASS_MAP = {
WIFFI_UOM_TEMP_CELSIUS: DEVICE_CLASS_TEMPERATURE,
WIFFI_UOM_PERCENT: DEVICE_CLASS_HUMIDITY,
WIFFI_UOM_MILLI_BAR: DEVICE_CLASS_PRESSURE,
WIFFI_UOM_LUX: DEVICE_CLASS_ILLUMINANCE,
}
# map to convert wiffi unit of measurements to common HA uom's
UOM_MAP = {
WIFFI_UOM_DEGREE: DEGREE,
WIFFI_UOM_TEMP_CELSIUS: TEMP_CELSIUS,
WIFFI_UOM_MILLI_BAR: PRESSURE_MBAR,
}
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up platform for a new integration.
Called by the HA framework after async_forward_entry_setup has been called
during initialization of a new integration (= wiffi).
"""
@callback
def _create_entity(device, metric):
"""Create platform specific entities."""
entities = []
if metric.is_number:
entities.append(NumberEntity(device, metric))
elif metric.is_string:
entities.append(StringEntity(device, metric))
async_add_entities(entities)
async_dispatcher_connect(hass, CREATE_ENTITY_SIGNAL, _create_entity)
class NumberEntity(WiffiEntity):
"""Entity for wiffi metrics which have a number value."""
def __init__(self, device, metric):
"""Initialize the entity."""
super().__init__(device, metric)
self._device_class = UOM_TO_DEVICE_CLASS_MAP.get(metric.unit_of_measurement)
self._unit_of_measurement = UOM_MAP.get(
metric.unit_of_measurement, metric.unit_of_measurement
)
self._value = metric.value
self.reset_expiration_date()
@property
def device_class(self):
"""Return the automatically determined device class."""
return self._device_class
@property
def unit_of_measurement(self):
"""Return the unit of measurement of this entity."""
return self._unit_of_measurement
@property
def state(self):
"""Return the value of the entity."""
return self._value
@callback
def _update_value_callback(self, device, metric):
"""Update the value of the entity.
Called if a new message has been received from the wiffi device.
"""
self.reset_expiration_date()
self._unit_of_measurement = UOM_MAP.get(
metric.unit_of_measurement, metric.unit_of_measurement
)
self._value = metric.value
self.async_write_ha_state()
class StringEntity(WiffiEntity):
"""Entity for wiffi metrics which have a string value."""
def __init__(self, device, metric):
"""Initialize the entity."""
super().__init__(device, metric)
self._value = metric.value
self.reset_expiration_date()
@property
def state(self):
"""Return the value of the entity."""
return self._value
@callback
def _update_value_callback(self, device, metric):
"""Update the value of the entity.
Called if a new message has been received from the wiffi device.
"""
self.reset_expiration_date()
self._value = metric.value
self.async_write_ha_state()

View File

@ -0,0 +1,16 @@
{
"config": {
"step": {
"user": {
"title": "Setup TCP server for WIFFI devices",
"data": {
"port": "Server Port"
}
}
},
"abort": {
"addr_in_use": "Server port already in use.",
"start_server_failed": "Start server failed."
}
}
}

View File

@ -0,0 +1,16 @@
{
"config": {
"abort": {
"addr_in_use": "Server port already in use.",
"start_server_failed": "Start server failed."
},
"step": {
"user": {
"data": {
"port": "Server Port"
},
"title": "Setup TCP server for WIFFI devices"
}
}
}
}

View File

@ -0,0 +1,8 @@
"""Definition of string used in wiffi json telegrams."""
# units of measurement
WIFFI_UOM_TEMP_CELSIUS = "gradC"
WIFFI_UOM_DEGREE = "grad"
WIFFI_UOM_PERCENT = "%"
WIFFI_UOM_MILLI_BAR = "mb"
WIFFI_UOM_LUX = "lux"

View File

@ -154,6 +154,7 @@ FLOWS = [
"vilfo",
"vizio",
"wemo",
"wiffi",
"withings",
"wled",
"wwlln",

View File

@ -2176,6 +2176,9 @@ webexteamssdk==1.1.1
# homeassistant.components.gpmdp
websocket-client==0.54.0
# homeassistant.components.wiffi
wiffi==1.0.0
# homeassistant.components.wirelesstag
wirelesstagpy==0.4.0

View File

@ -873,6 +873,9 @@ wakeonlan==1.1.6
# homeassistant.components.folder_watcher
watchdog==0.8.3
# homeassistant.components.wiffi
wiffi==1.0.0
# homeassistant.components.withings
withings-api==2.1.3

View File

@ -0,0 +1 @@
"""Tests for the wiffi integration."""

View File

@ -0,0 +1,109 @@
"""Test the wiffi integration config flow."""
import errno
from asynctest import patch
import pytest
from homeassistant import config_entries
from homeassistant.components.wiffi.const import DOMAIN
from homeassistant.const import CONF_PORT
from homeassistant.data_entry_flow import (
RESULT_TYPE_ABORT,
RESULT_TYPE_CREATE_ENTRY,
RESULT_TYPE_FORM,
)
@pytest.fixture(name="dummy_tcp_server")
def mock_dummy_tcp_server():
"""Mock a valid WiffiTcpServer."""
class Dummy:
async def start_server(self):
pass
async def close_server(self):
pass
server = Dummy()
with patch(
"homeassistant.components.wiffi.config_flow.WiffiTcpServer", return_value=server
):
yield server
@pytest.fixture(name="addr_in_use")
def mock_addr_in_use_server():
"""Mock a WiffiTcpServer with addr_in_use."""
class Dummy:
async def start_server(self):
raise OSError(errno.EADDRINUSE, "")
async def close_server(self):
pass
server = Dummy()
with patch(
"homeassistant.components.wiffi.config_flow.WiffiTcpServer", return_value=server
):
yield server
@pytest.fixture(name="start_server_failed")
def mock_start_server_failed():
"""Mock a WiffiTcpServer with start_server_failed."""
class Dummy:
async def start_server(self):
raise OSError(errno.EACCES, "")
async def close_server(self):
pass
server = Dummy()
with patch(
"homeassistant.components.wiffi.config_flow.WiffiTcpServer", return_value=server
):
yield server
async def test_form(hass, dummy_tcp_server):
"""Test how we get the form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == RESULT_TYPE_FORM
assert result["errors"] == {}
assert result["step_id"] == config_entries.SOURCE_USER
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_PORT: 8765},
)
assert result2["type"] == RESULT_TYPE_CREATE_ENTRY
async def test_form_addr_in_use(hass, addr_in_use):
"""Test how we handle addr_in_use error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_PORT: 8765},
)
assert result2["type"] == RESULT_TYPE_ABORT
assert result2["reason"] == "addr_in_use"
async def test_form_start_server_failed(hass, start_server_failed):
"""Test how we handle start_server_failed error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_PORT: 8765},
)
assert result2["type"] == RESULT_TYPE_ABORT
assert result2["reason"] == "start_server_failed"