diff --git a/.coveragerc b/.coveragerc index 456b4fcad27..53621c5049f 100644 --- a/.coveragerc +++ b/.coveragerc @@ -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 diff --git a/CODEOWNERS b/CODEOWNERS index bf50495b8bd..77ea278f182 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/components/bsblan/__init__.py b/homeassistant/components/bsblan/__init__.py new file mode 100644 index 00000000000..c799f8daa9b --- /dev/null +++ b/homeassistant/components/bsblan/__init__.py @@ -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 diff --git a/homeassistant/components/bsblan/climate.py b/homeassistant/components/bsblan/climate.py new file mode 100644 index 00000000000..aaeb1fbffdb --- /dev/null +++ b/homeassistant/components/bsblan/climate.py @@ -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 == "°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, + } diff --git a/homeassistant/components/bsblan/config_flow.py b/homeassistant/components/bsblan/config_flow.py new file mode 100644 index 00000000000..858e369c849 --- /dev/null +++ b/homeassistant/components/bsblan/config_flow.py @@ -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() diff --git a/homeassistant/components/bsblan/const.py b/homeassistant/components/bsblan/const.py new file mode 100644 index 00000000000..1dd461e2081 --- /dev/null +++ b/homeassistant/components/bsblan/const.py @@ -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" diff --git a/homeassistant/components/bsblan/manifest.json b/homeassistant/components/bsblan/manifest.json new file mode 100644 index 00000000000..38611c8b7cc --- /dev/null +++ b/homeassistant/components/bsblan/manifest.json @@ -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"] +} diff --git a/homeassistant/components/bsblan/strings.json b/homeassistant/components/bsblan/strings.json new file mode 100644 index 00000000000..14ab3725c9d --- /dev/null +++ b/homeassistant/components/bsblan/strings.json @@ -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" + } + } +} diff --git a/homeassistant/components/bsblan/translations/en.json b/homeassistant/components/bsblan/translations/en.json new file mode 100644 index 00000000000..07c7312154c --- /dev/null +++ b/homeassistant/components/bsblan/translations/en.json @@ -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." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index d93c37fa6e6..97c4945a420 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -20,6 +20,7 @@ FLOWS = [ "blebox", "braviatv", "brother", + "bsblan", "cast", "cert_expiry", "coolmaster", diff --git a/requirements_all.txt b/requirements_all.txt index bbfeff9b39d..ac9de32470f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2eacfdf91e8..4e5cee7be57 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/bsblan/__init__.py b/tests/components/bsblan/__init__.py new file mode 100644 index 00000000000..1541555de55 --- /dev/null +++ b/tests/components/bsblan/__init__.py @@ -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 diff --git a/tests/components/bsblan/test_config_flow.py b/tests/components/bsblan/test_config_flow.py new file mode 100644 index 00000000000..48f71a8404f --- /dev/null +++ b/tests/components/bsblan/test_config_flow.py @@ -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" diff --git a/tests/fixtures/bsblan/info.json b/tests/fixtures/bsblan/info.json new file mode 100644 index 00000000000..82c8b919cc9 --- /dev/null +++ b/tests/fixtures/bsblan/info.json @@ -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 + } +} \ No newline at end of file