Add BSBLan Climate integration (#32375)

* Initial commit for BSBLan Climate component

The most basic climate functions work.

* Delete manifest 2.json

wrongly added to commit

* fix incorrect name

current_hvac_mode

* update coverage to exclude bsblan

* sorted and add configflow

* removed unused code, etc

* fix hvac, preset  mix up

now it sets hvac mode to none and preset to eco

* fix naming

* removed commented code and cleaned code that isn't needed

* Add test for the configflow

* Update requirements

fixing some issues in bsblan Lib

* Update coverage file to include configflow bsblan

* Fix hvac preset is not in hvac mode

rewrote how to handle presets.

* Add passkey option

My device had a passkey so I needed to push this functionality to do testing

* Update constants

include passkey and added some more for device indentification

* add passkey for configflow

* Fix use discovery_info instead of user_input

also added passkey

* Fix name

* Fix for discovery_info[CONF_PORT] is None

* Fix get value CONF_PORT

* Fix move translation to new location

* Fix get the right info

* Fix remove zeroconf and fix the code

* Add init for mockConfigEntry

* Fix removed zeroconfig and fix code

* Fix changed ClimateDevice to ClimatEntity

* Fix log error message

* Removed debug code

* Change name of device.

* Remove check

This is done in the configflow

* Remove period from logging message

* Update homeassistant/components/bsblan/strings.json

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Add passkey

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Willem-Jan 2020-05-10 04:16:21 +02:00 committed by GitHub
parent e2b622fb78
commit cf30895460
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 635 additions and 0 deletions

View File

@ -100,6 +100,9 @@ omit =
homeassistant/components/brottsplatskartan/sensor.py
homeassistant/components/browser/*
homeassistant/components/brunt/cover.py
homeassistant/components/bsblan/__init__.py
homeassistant/components/bsblan/climate.py
homeassistant/components/bsblan/const.py
homeassistant/components/bt_home_hub_5/device_tracker.py
homeassistant/components/bt_smarthub/device_tracker.py
homeassistant/components/buienradar/sensor.py

View File

@ -63,6 +63,7 @@ homeassistant/components/braviatv/* @robbiet480 @bieniu
homeassistant/components/broadlink/* @danielhiversen @felipediel
homeassistant/components/brother/* @bieniu
homeassistant/components/brunt/* @eavanvalkenburg
homeassistant/components/bsblan/* @liudger
homeassistant/components/bt_smarthub/* @jxwolstenholme
homeassistant/components/buienradar/* @mjj4791 @ties
homeassistant/components/cast/* @emontnemery

View File

@ -0,0 +1,64 @@
"""The BSB-Lan integration."""
from datetime import timedelta
import logging
from bsblan import BSBLan, BSBLanConnectionError
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from .const import CONF_PASSKEY, DATA_BSBLAN_CLIENT, DOMAIN
SCAN_INTERVAL = timedelta(seconds=30)
_LOGGER = logging.getLogger(__name__)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the BSB-Lan component."""
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up BSB-Lan from a config entry."""
session = async_get_clientsession(hass)
bsblan = BSBLan(
entry.data[CONF_HOST],
passkey=entry.data[CONF_PASSKEY],
loop=hass.loop,
port=entry.data[CONF_PORT],
session=session,
)
try:
await bsblan.info()
except BSBLanConnectionError as exception:
raise ConfigEntryNotReady from exception
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = {DATA_BSBLAN_CLIENT: bsblan}
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, CLIMATE_DOMAIN)
)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload BSBLan config entry."""
await hass.config_entries.async_forward_entry_unload(entry, CLIMATE_DOMAIN)
# Cleanup
del hass.data[DOMAIN][entry.entry_id]
if not hass.data[DOMAIN]:
del hass.data[DOMAIN]
return True

View File

@ -0,0 +1,237 @@
"""BSBLAN platform to control a compatible Climate Device."""
from datetime import timedelta
import logging
from typing import Any, Callable, Dict, List, Optional
from bsblan import BSBLan, BSBLanError, Info, State
from homeassistant.components.climate import ClimateEntity
from homeassistant.components.climate.const import (
ATTR_HVAC_MODE,
ATTR_PRESET_MODE,
HVAC_MODE_AUTO,
HVAC_MODE_HEAT,
HVAC_MODE_OFF,
PRESET_ECO,
PRESET_NONE,
SUPPORT_PRESET_MODE,
SUPPORT_TARGET_TEMPERATURE,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_NAME,
ATTR_TEMPERATURE,
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
)
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.typing import HomeAssistantType
from .const import (
ATTR_IDENTIFIERS,
ATTR_MANUFACTURER,
ATTR_MODEL,
ATTR_TARGET_TEMPERATURE,
DATA_BSBLAN_CLIENT,
DOMAIN,
)
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 1
SCAN_INTERVAL = timedelta(seconds=20)
SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE
HVAC_MODES = [
HVAC_MODE_AUTO,
HVAC_MODE_HEAT,
HVAC_MODE_OFF,
]
PRESET_MODES = [
PRESET_ECO,
PRESET_NONE,
]
HA_STATE_TO_BSBLAN = {
HVAC_MODE_AUTO: "1",
HVAC_MODE_HEAT: "3",
HVAC_MODE_OFF: "0",
}
BSBLAN_TO_HA_STATE = {value: key for key, value in HA_STATE_TO_BSBLAN.items()}
HA_PRESET_TO_BSBLAN = {
PRESET_ECO: "2",
}
BSBLAN_TO_HA_PRESET = {
2: PRESET_ECO,
}
async def async_setup_entry(
hass: HomeAssistantType,
entry: ConfigEntry,
async_add_entities: Callable[[List[Entity], bool], None],
) -> None:
"""Set up BSBLan device based on a config entry."""
bsblan: BSBLan = hass.data[DOMAIN][entry.entry_id][DATA_BSBLAN_CLIENT]
info = await bsblan.info()
async_add_entities([BSBLanClimate(entry.entry_id, bsblan, info)], True)
class BSBLanClimate(ClimateEntity):
"""Defines a BSBLan climate device."""
def __init__(
self, entry_id: str, bsblan: BSBLan, info: Info,
):
"""Initialize BSBLan climate device."""
self._current_temperature: Optional[float] = None
self._available = True
self._current_hvac_mode: Optional[int] = None
self._target_temperature: Optional[float] = None
self._info: Info = info
self.bsblan = bsblan
self._temperature_unit = None
self._hvac_mode = None
self._preset_mode = None
self._store_hvac_mode = None
@property
def name(self) -> str:
"""Return the name of the entity."""
return self._info.device_identification
@property
def available(self) -> bool:
"""Return True if entity is available."""
return self._available
@property
def unique_id(self) -> str:
"""Return the unique ID for this sensor."""
return self._info.device_identification
@property
def temperature_unit(self) -> str:
"""Return the unit of measurement which this thermostat uses."""
if self._temperature_unit == "&deg;C":
return TEMP_CELSIUS
return TEMP_FAHRENHEIT
@property
def supported_features(self) -> int:
"""Flag supported features."""
return SUPPORT_FLAGS
@property
def current_temperature(self):
"""Return the current temperature."""
return self._current_temperature
@property
def hvac_mode(self):
"""Return the current operation mode."""
return self._current_hvac_mode
@property
def hvac_modes(self):
"""Return the list of available operation modes."""
return HVAC_MODES
@property
def target_temperature(self):
"""Return the temperature we try to reach."""
return self._target_temperature
@property
def preset_modes(self):
"""List of available preset modes."""
return PRESET_MODES
@property
def preset_mode(self):
"""Return the preset_mode."""
return self._preset_mode
async def async_set_preset_mode(self, preset_mode):
"""Set preset mode."""
_LOGGER.debug("Setting preset mode to: %s", preset_mode)
if preset_mode == PRESET_NONE:
# restore previous hvac mode
self._current_hvac_mode = self._store_hvac_mode
else:
# Store hvac mode.
self._store_hvac_mode = self._current_hvac_mode
await self.async_set_data(preset_mode=preset_mode)
async def async_set_hvac_mode(self, hvac_mode):
"""Set HVAC mode."""
_LOGGER.debug("Setting HVAC mode to: %s", hvac_mode)
# preset should be none when hvac mode is set
self._preset_mode = PRESET_NONE
await self.async_set_data(hvac_mode=hvac_mode)
async def async_set_temperature(self, **kwargs):
"""Set new target temperatures."""
await self.async_set_data(**kwargs)
async def async_set_data(self, **kwargs: Any) -> None:
"""Set device settings using BSBLan."""
data = {}
if ATTR_TEMPERATURE in kwargs:
data[ATTR_TARGET_TEMPERATURE] = kwargs[ATTR_TEMPERATURE]
_LOGGER.debug("Set temperature data = %s", data)
if ATTR_HVAC_MODE in kwargs:
data[ATTR_HVAC_MODE] = HA_STATE_TO_BSBLAN[kwargs[ATTR_HVAC_MODE]]
_LOGGER.debug("Set hvac mode data = %s", data)
if ATTR_PRESET_MODE in kwargs:
# for now we set the preset as hvac_mode as the api expect this
data[ATTR_HVAC_MODE] = HA_PRESET_TO_BSBLAN[kwargs[ATTR_PRESET_MODE]]
try:
await self.bsblan.thermostat(**data)
except BSBLanError:
_LOGGER.error("An error occurred while updating the BSBLan device")
self._available = False
async def async_update(self) -> None:
"""Update BSBlan entity."""
try:
state: State = await self.bsblan.state()
except BSBLanError:
if self._available:
_LOGGER.error("An error occurred while updating the BSBLan device")
self._available = False
return
self._available = True
self._current_temperature = float(state.current_temperature)
self._target_temperature = float(state.target_temperature)
# check if preset is active else get hvac mode
_LOGGER.debug("state hvac/preset mode: %s", state.current_hvac_mode)
if state.current_hvac_mode == "2":
self._preset_mode = PRESET_ECO
else:
self._current_hvac_mode = BSBLAN_TO_HA_STATE[state.current_hvac_mode]
self._preset_mode = PRESET_NONE
self._temperature_unit = state.temperature_unit
@property
def device_info(self) -> Dict[str, Any]:
"""Return device information about this BSBLan device."""
return {
ATTR_IDENTIFIERS: {(DOMAIN, self._info.device_identification)},
ATTR_NAME: "BSBLan Device",
ATTR_MANUFACTURER: "BSBLan",
ATTR_MODEL: self._info.controller_variant,
}

View File

@ -0,0 +1,81 @@
"""Config flow for BSB-Lan integration."""
import logging
from typing import Any, Dict, Optional
from bsblan import BSBLan, BSBLanError, Info
import voluptuous as vol
from homeassistant.config_entries import CONN_CLASS_LOCAL_POLL, ConfigFlow
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.helpers import ConfigType
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import ( # pylint:disable=unused-import
CONF_DEVICE_IDENT,
CONF_PASSKEY,
DOMAIN,
)
_LOGGER = logging.getLogger(__name__)
class BSBLanFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a BSBLan config flow."""
VERSION = 1
CONNECTION_CLASS = CONN_CLASS_LOCAL_POLL
async def async_step_user(
self, user_input: Optional[ConfigType] = None
) -> Dict[str, Any]:
"""Handle a flow initiated by the user."""
if user_input is None:
return self._show_setup_form()
try:
info = await self._get_bsblan_info(
host=user_input[CONF_HOST],
port=user_input[CONF_PORT],
passkey=user_input[CONF_PASSKEY],
)
except BSBLanError:
return self._show_setup_form({"base": "connection_error"})
# Check if already configured
await self.async_set_unique_id(info.device_identification)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=info.device_identification,
data={
CONF_HOST: user_input[CONF_HOST],
CONF_PORT: user_input[CONF_PORT],
CONF_PASSKEY: user_input[CONF_PASSKEY],
CONF_DEVICE_IDENT: info.device_identification,
},
)
def _show_setup_form(self, errors: Optional[Dict] = None) -> Dict[str, Any]:
"""Show the setup form to the user."""
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Optional(CONF_PORT, default=80): int,
vol.Optional(CONF_PASSKEY, default=""): str,
}
),
errors=errors or {},
)
async def _get_bsblan_info(
self, host: str, passkey: Optional[str], port: int
) -> Info:
"""Get device information from an BSBLan device."""
session = async_get_clientsession(self.hass)
_LOGGER.debug("request bsblan.info:")
bsblan = BSBLan(
host, passkey=passkey, port=port, session=session, loop=self.hass.loop
)
return await bsblan.info()

View File

@ -0,0 +1,26 @@
"""Constants for the BSB-Lan integration."""
DOMAIN = "bsblan"
DATA_BSBLAN_CLIENT = "bsblan_client"
DATA_BSBLAN_TIMER = "bsblan_timer"
DATA_BSBLAN_UPDATED = "bsblan_updated"
ATTR_IDENTIFIERS = "identifiers"
ATTR_MODEL = "model"
ATTR_MANUFACTURER = "manufacturer"
ATTR_TARGET_TEMPERATURE = "target_temperature"
ATTR_INSIDE_TEMPERATURE = "inside_temperature"
ATTR_OUTSIDE_TEMPERATURE = "outside_temperature"
ATTR_STATE_ON = "on"
ATTR_STATE_OFF = "off"
CONF_DEVICE_IDENT = "device_identification"
CONF_CONTROLLER_FAM = "controller_family"
CONF_CONTROLLER_VARI = "controller_variant"
SENSOR_TYPE_TEMPERATURE = "temperature"
CONF_PASSKEY = "passkey"

View File

@ -0,0 +1,8 @@
{
"domain": "bsblan",
"name": "BSB-Lan",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/bsblan",
"requirements": ["bsblan==0.3.6"],
"codeowners": ["@liudger"]
}

View File

@ -0,0 +1,23 @@
{
"title": "BSB-Lan",
"config": {
"flow_title": "BSB-Lan: {name}",
"step": {
"user": {
"title": "Connect to the BSB-Lan device",
"description": "Set up you BSB-Lan device to integrate with Home Assistant.",
"data": {
"host": "Host or IP address",
"port": "Port number",
"passkey": "Passkey string"
}
}
},
"error": {
"connection_error": "Failed to connect to BSB-Lan device."
},
"abort": {
"already_configured": "Device is already configured"
}
}
}

View File

@ -0,0 +1,26 @@
{
"config": {
"title": "BSB-Lan",
"flow_title": "BSB-Lan: {name}",
"step": {
"user": {
"title": "Connect to the BSB-Lan device",
"description": "Set up you BSB-Lan device to integrate with Home Assistant.",
"data": {
"host": "Host or IP address",
"port": "Port number",
"passkey": "Passkey"
}
}
},
"error": {
"connection_error": "Failed to connect to BSB-Lan device.",
"invalid_auth": "Invalid authentication",
"unknown": "Unexpected error"
},
"abort": {
"already_configured": "Device is already configured",
"connection_error": "Failed to connect to BSB-Lan device."
}
}
}

View File

@ -20,6 +20,7 @@ FLOWS = [
"blebox",
"braviatv",
"brother",
"bsblan",
"cast",
"cert_expiry",
"coolmaster",

View File

@ -382,6 +382,9 @@ brottsplatskartan==0.0.1
# homeassistant.components.brunt
brunt==0.1.3
# homeassistant.components.bsblan
bsblan==0.3.6
# homeassistant.components.bluetooth_tracker
bt_proximity==0.2

View File

@ -161,6 +161,9 @@ broadlink==0.13.2
# homeassistant.components.brother
brother==0.1.14
# homeassistant.components.bsblan
bsblan==0.3.6
# homeassistant.components.buienradar
buienradar==1.0.4

View File

@ -0,0 +1,44 @@
"""Tests for the bsblan integration."""
from homeassistant.components.bsblan.const import (
CONF_DEVICE_IDENT,
CONF_PASSKEY,
DOMAIN,
)
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry, load_fixture
from tests.test_util.aiohttp import AiohttpClientMocker
async def init_integration(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, skip_setup: bool = False,
) -> MockConfigEntry:
"""Set up the BSBLan integration in Home Assistant."""
aioclient_mock.post(
"http://example.local:80/1234/JQ?Parameter=6224,6225,6226",
params={"Parameter": "6224,6225,6226"},
text=load_fixture("bsblan/info.json"),
headers={"Content-Type": "application/json"},
)
entry = MockConfigEntry(
domain=DOMAIN,
unique_id="RVS21.831F/127",
data={
CONF_HOST: "example.local",
CONF_PASSKEY: "1234",
CONF_PORT: 80,
CONF_DEVICE_IDENT: "RVS21.831F/127",
},
)
entry.add_to_hass(hass)
if not skip_setup:
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
return entry

View File

@ -0,0 +1,92 @@
"""Tests for the BSBLan device config flow."""
import aiohttp
from homeassistant import data_entry_flow
from homeassistant.components.bsblan import config_flow
from homeassistant.components.bsblan.const import CONF_DEVICE_IDENT, CONF_PASSKEY
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant
from . import init_integration
from tests.common import load_fixture
from tests.test_util.aiohttp import AiohttpClientMocker
async def test_show_user_form(hass: HomeAssistant) -> None:
"""Test that the user set up form is served."""
result = await hass.config_entries.flow.async_init(
config_flow.DOMAIN, context={"source": SOURCE_USER},
)
assert result["step_id"] == "user"
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
async def test_connection_error(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test we show user form on BSBLan connection error."""
aioclient_mock.post(
"http://example.local:80/1234/JQ?Parameter=6224,6225,6226",
exc=aiohttp.ClientError,
)
result = await hass.config_entries.flow.async_init(
config_flow.DOMAIN,
context={"source": SOURCE_USER},
data={CONF_HOST: "example.local", CONF_PASSKEY: "1234", CONF_PORT: 80},
)
assert result["errors"] == {"base": "connection_error"}
assert result["step_id"] == "user"
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
async def test_user_device_exists_abort(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test we abort zeroconf flow if BSBLan device already configured."""
await init_integration(hass, aioclient_mock)
result = await hass.config_entries.flow.async_init(
config_flow.DOMAIN,
context={"source": SOURCE_USER},
data={CONF_HOST: "example.local", CONF_PASSKEY: "1234", CONF_PORT: 80},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
async def test_full_user_flow_implementation(
hass: HomeAssistant, aioclient_mock
) -> None:
"""Test the full manual user flow from start to finish."""
aioclient_mock.post(
"http://example.local:80/1234/JQ?Parameter=6224,6225,6226",
text=load_fixture("bsblan/info.json"),
headers={"Content-Type": "application/json"},
)
result = await hass.config_entries.flow.async_init(
config_flow.DOMAIN, context={"source": SOURCE_USER},
)
assert result["step_id"] == "user"
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_HOST: "example.local", CONF_PASSKEY: "1234", CONF_PORT: 80},
)
assert result["data"][CONF_HOST] == "example.local"
assert result["data"][CONF_PASSKEY] == "1234"
assert result["data"][CONF_PORT] == 80
assert result["data"][CONF_DEVICE_IDENT] == "RVS21.831F/127"
assert result["title"] == "RVS21.831F/127"
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
entries = hass.config_entries.async_entries(config_flow.DOMAIN)
assert entries[0].unique_id == "RVS21.831F/127"

23
tests/fixtures/bsblan/info.json vendored Normal file
View File

@ -0,0 +1,23 @@
{
"6224": {
"name": "Geräte-Identifikation",
"value": "RVS21.831F/127",
"unit": "",
"desc": "",
"dataType": 7
},
"6225": {
"name": "Device family",
"value": "211",
"unit": "",
"desc": "",
"dataType": 0
},
"6226": {
"name": "Device variant",
"value": "127",
"unit": "",
"desc": "",
"dataType": 0
}
}