diff --git a/.coveragerc b/.coveragerc index f15d36918ec..29b48e439ea 100644 --- a/.coveragerc +++ b/.coveragerc @@ -404,6 +404,9 @@ omit = homeassistant/components/fjaraskupan/sensor.py homeassistant/components/fleetgo/device_tracker.py homeassistant/components/flexit/climate.py + homeassistant/components/flexit_bacnet/__init__.py + homeassistant/components/flexit_bacnet/const.py + homeassistant/components/flexit_bacnet/climate.py homeassistant/components/flic/binary_sensor.py homeassistant/components/flick_electric/__init__.py homeassistant/components/flick_electric/sensor.py diff --git a/.strict-typing b/.strict-typing index a4969bcc810..daa4a56dead 100644 --- a/.strict-typing +++ b/.strict-typing @@ -128,6 +128,7 @@ homeassistant.components.file_upload.* homeassistant.components.filesize.* homeassistant.components.filter.* homeassistant.components.fitbit.* +homeassistant.components.flexit_bacnet.* homeassistant.components.flux_led.* homeassistant.components.forecast_solar.* homeassistant.components.fritz.* diff --git a/CODEOWNERS b/CODEOWNERS index ec32f941d56..d41975259b5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -395,6 +395,8 @@ build.json @home-assistant/supervisor /tests/components/fivem/ @Sander0542 /homeassistant/components/fjaraskupan/ @elupus /tests/components/fjaraskupan/ @elupus +/homeassistant/components/flexit_bacnet/ @lellky @piotrbulinski +/tests/components/flexit_bacnet/ @lellky @piotrbulinski /homeassistant/components/flick_electric/ @ZephireNZ /tests/components/flick_electric/ @ZephireNZ /homeassistant/components/flipr/ @cnico diff --git a/homeassistant/brands/flexit.json b/homeassistant/brands/flexit.json new file mode 100644 index 00000000000..4c61c5eeb07 --- /dev/null +++ b/homeassistant/brands/flexit.json @@ -0,0 +1,5 @@ +{ + "domain": "flexit", + "name": "Flexit", + "integrations": ["flexit", "flexit_bacnet"] +} diff --git a/homeassistant/components/flexit_bacnet/__init__.py b/homeassistant/components/flexit_bacnet/__init__.py new file mode 100644 index 00000000000..c9a0b332d93 --- /dev/null +++ b/homeassistant/components/flexit_bacnet/__init__.py @@ -0,0 +1,43 @@ +"""The Flexit Nordic (BACnet) integration.""" +from __future__ import annotations + +import asyncio.exceptions + +from flexit_bacnet import FlexitBACnet +from flexit_bacnet.bacnet import DecodingError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_DEVICE_ID, CONF_IP_ADDRESS, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN + +PLATFORMS: list[Platform] = [Platform.CLIMATE] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Flexit Nordic (BACnet) from a config entry.""" + + device = FlexitBACnet(entry.data[CONF_IP_ADDRESS], entry.data[CONF_DEVICE_ID]) + + try: + await device.update() + except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: + raise ConfigEntryNotReady( + f"Timeout while connecting to {entry.data['address']}" + ) from exc + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = device + + 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.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/flexit_bacnet/climate.py b/homeassistant/components/flexit_bacnet/climate.py new file mode 100644 index 00000000000..28f4a6ae178 --- /dev/null +++ b/homeassistant/components/flexit_bacnet/climate.py @@ -0,0 +1,148 @@ +"""The Flexit Nordic (BACnet) integration.""" +import asyncio.exceptions +from typing import Any + +from flexit_bacnet import ( + VENTILATION_MODE_AWAY, + VENTILATION_MODE_HOME, + VENTILATION_MODE_STOP, + FlexitBACnet, +) +from flexit_bacnet.bacnet import DecodingError + +from homeassistant.components.climate import ( + PRESET_AWAY, + PRESET_BOOST, + PRESET_HOME, + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ( + DOMAIN, + PRESET_TO_VENTILATION_MODE_MAP, + VENTILATION_TO_PRESET_MODE_MAP, +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_devices: AddEntitiesCallback, +) -> None: + """Set up the Flexit Nordic unit.""" + device = hass.data[DOMAIN][config_entry.entry_id] + + async_add_devices([FlexitClimateEntity(device)]) + + +class FlexitClimateEntity(ClimateEntity): + """Flexit air handling unit.""" + + _attr_name = None + + _attr_has_entity_name = True + + _attr_hvac_modes = [ + HVACMode.OFF, + HVACMode.FAN_ONLY, + ] + + _attr_preset_modes = [ + PRESET_AWAY, + PRESET_HOME, + PRESET_BOOST, + ] + + _attr_supported_features = ( + ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE + ) + + _attr_target_temperature_step = PRECISION_WHOLE + _attr_temperature_unit = UnitOfTemperature.CELSIUS + + def __init__(self, device: FlexitBACnet) -> None: + """Initialize the unit.""" + self._device = device + self._attr_unique_id = device.serial_number + self._attr_device_info = DeviceInfo( + identifiers={ + (DOMAIN, device.serial_number), + }, + name=device.device_name, + manufacturer="Flexit", + model="Nordic", + serial_number=device.serial_number, + ) + + async def async_update(self) -> None: + """Refresh unit state.""" + await self._device.update() + + @property + def current_temperature(self) -> float: + """Return the current temperature.""" + return self._device.room_temperature + + @property + def target_temperature(self) -> float: + """Return the temperature we try to reach.""" + if self._device.ventilation_mode == VENTILATION_MODE_AWAY: + return self._device.air_temp_setpoint_away + + return self._device.air_temp_setpoint_home + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: + return + + try: + if self._device.ventilation_mode == VENTILATION_MODE_AWAY: + await self._device.set_air_temp_setpoint_away(temperature) + else: + await self._device.set_air_temp_setpoint_home(temperature) + except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: + raise HomeAssistantError from exc + + @property + def preset_mode(self) -> str: + """Return the current preset mode, e.g., home, away, temp. + + Requires ClimateEntityFeature.PRESET_MODE. + """ + return VENTILATION_TO_PRESET_MODE_MAP[self._device.ventilation_mode] + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + ventilation_mode = PRESET_TO_VENTILATION_MODE_MAP[preset_mode] + + try: + await self._device.set_ventilation_mode(ventilation_mode) + except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: + raise HomeAssistantError from exc + + @property + def hvac_mode(self) -> HVACMode: + """Return hvac operation ie. heat, cool mode.""" + if self._device.ventilation_mode == VENTILATION_MODE_STOP: + return HVACMode.OFF + + return HVACMode.FAN_ONLY + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + try: + if hvac_mode == HVACMode.OFF: + await self._device.set_ventilation_mode(VENTILATION_MODE_STOP) + else: + await self._device.set_ventilation_mode(VENTILATION_MODE_HOME) + except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: + raise HomeAssistantError from exc diff --git a/homeassistant/components/flexit_bacnet/config_flow.py b/homeassistant/components/flexit_bacnet/config_flow.py new file mode 100644 index 00000000000..2c87dfc5b97 --- /dev/null +++ b/homeassistant/components/flexit_bacnet/config_flow.py @@ -0,0 +1,62 @@ +"""Config flow for Flexit Nordic (BACnet) integration.""" +from __future__ import annotations + +import asyncio.exceptions +import logging +from typing import Any + +from flexit_bacnet import FlexitBACnet +from flexit_bacnet.bacnet import DecodingError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_DEVICE_ID, CONF_IP_ADDRESS +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_DEVICE_ID = 2 + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_IP_ADDRESS): str, + vol.Required(CONF_DEVICE_ID, default=DEFAULT_DEVICE_ID): int, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Flexit Nordic (BACnet).""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + device = FlexitBACnet( + user_input[CONF_IP_ADDRESS], user_input[CONF_DEVICE_ID] + ) + try: + await device.update() + except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError): + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(device.serial_number) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=device.device_name, data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/flexit_bacnet/const.py b/homeassistant/components/flexit_bacnet/const.py new file mode 100644 index 00000000000..269a88c4cec --- /dev/null +++ b/homeassistant/components/flexit_bacnet/const.py @@ -0,0 +1,30 @@ +"""Constants for the Flexit Nordic (BACnet) integration.""" +from flexit_bacnet import ( + VENTILATION_MODE_AWAY, + VENTILATION_MODE_HIGH, + VENTILATION_MODE_HOME, + VENTILATION_MODE_STOP, +) + +from homeassistant.components.climate import ( + PRESET_AWAY, + PRESET_BOOST, + PRESET_HOME, + PRESET_NONE, +) + +DOMAIN = "flexit_bacnet" + +VENTILATION_TO_PRESET_MODE_MAP = { + VENTILATION_MODE_STOP: PRESET_NONE, + VENTILATION_MODE_AWAY: PRESET_AWAY, + VENTILATION_MODE_HOME: PRESET_HOME, + VENTILATION_MODE_HIGH: PRESET_BOOST, +} + +PRESET_TO_VENTILATION_MODE_MAP = { + PRESET_NONE: VENTILATION_MODE_STOP, + PRESET_AWAY: VENTILATION_MODE_AWAY, + PRESET_HOME: VENTILATION_MODE_HOME, + PRESET_BOOST: VENTILATION_MODE_HIGH, +} diff --git a/homeassistant/components/flexit_bacnet/manifest.json b/homeassistant/components/flexit_bacnet/manifest.json new file mode 100644 index 00000000000..d230e4ebb7a --- /dev/null +++ b/homeassistant/components/flexit_bacnet/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "flexit_bacnet", + "name": "Flexit Nordic (BACnet)", + "codeowners": ["@lellky", "@piotrbulinski"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/flexit_bacnet", + "integration_type": "device", + "iot_class": "local_polling", + "requirements": ["flexit_bacnet==2.1.0"] +} diff --git a/homeassistant/components/flexit_bacnet/strings.json b/homeassistant/components/flexit_bacnet/strings.json new file mode 100644 index 00000000000..fd2725c6403 --- /dev/null +++ b/homeassistant/components/flexit_bacnet/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "step": { + "user": { + "data": { + "ip_address": "[%key:common::config_flow::data::ip%]", + "device_id": "[%key:common::config_flow::data::device%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 57503f0ef32..30c884249a9 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -149,6 +149,7 @@ FLOWS = { "fitbit", "fivem", "fjaraskupan", + "flexit_bacnet", "flick_electric", "flipr", "flo", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index f0af72624f6..394b40ac630 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1766,9 +1766,20 @@ }, "flexit": { "name": "Flexit", - "integration_type": "hub", - "config_flow": false, - "iot_class": "local_polling" + "integrations": { + "flexit": { + "integration_type": "hub", + "config_flow": false, + "iot_class": "local_polling", + "name": "Flexit" + }, + "flexit_bacnet": { + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling", + "name": "Flexit Nordic (BACnet)" + } + } }, "flexom": { "name": "Bouygues Flexom", diff --git a/mypy.ini b/mypy.ini index a27282fc667..05525d03300 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1041,6 +1041,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.flexit_bacnet.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.flux_led.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index de84d8fb420..7f1b3d338f4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -822,6 +822,9 @@ fixerio==1.0.0a0 # homeassistant.components.fjaraskupan fjaraskupan==2.2.0 +# homeassistant.components.flexit_bacnet +flexit_bacnet==2.1.0 + # homeassistant.components.flipr flipr-api==1.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f2241b1c5d4..5f379b9a355 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -651,6 +651,9 @@ fivem-api==0.1.2 # homeassistant.components.fjaraskupan fjaraskupan==2.2.0 +# homeassistant.components.flexit_bacnet +flexit_bacnet==2.1.0 + # homeassistant.components.flipr flipr-api==1.5.0 diff --git a/tests/components/flexit_bacnet/__init__.py b/tests/components/flexit_bacnet/__init__.py new file mode 100644 index 00000000000..4cae6e4f4bf --- /dev/null +++ b/tests/components/flexit_bacnet/__init__.py @@ -0,0 +1 @@ +"""Tests for the Flexit Nordic (BACnet) integration.""" diff --git a/tests/components/flexit_bacnet/conftest.py b/tests/components/flexit_bacnet/conftest.py new file mode 100644 index 00000000000..b136b134e01 --- /dev/null +++ b/tests/components/flexit_bacnet/conftest.py @@ -0,0 +1,44 @@ +"""Configuration for Flexit Nordic (BACnet) tests.""" +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.flexit_bacnet.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +@pytest.fixture +async def flow_id(hass: HomeAssistant) -> str: + """Return initial ID for user-initiated configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + return result["flow_id"] + + +@pytest.fixture(autouse=True) +def mock_serial_number_and_device_name(): + """Mock serial number of the device.""" + with patch( + "homeassistant.components.flexit_bacnet.config_flow.FlexitBACnet.serial_number", + "0000-0001", + ), patch( + "homeassistant.components.flexit_bacnet.config_flow.FlexitBACnet.device_name", + "Device Name", + ): + yield + + +@pytest.fixture +def mock_setup_entry(): + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.flexit_bacnet.async_setup_entry", return_value=True + ) as setup_entry_mock: + yield setup_entry_mock diff --git a/tests/components/flexit_bacnet/test_config_flow.py b/tests/components/flexit_bacnet/test_config_flow.py new file mode 100644 index 00000000000..ed513587af6 --- /dev/null +++ b/tests/components/flexit_bacnet/test_config_flow.py @@ -0,0 +1,120 @@ +"""Test the Flexit Nordic (BACnet) config flow.""" +import asyncio.exceptions +from unittest.mock import patch + +from flexit_bacnet import DecodingError +import pytest + +from homeassistant.components.flexit_bacnet.const import DOMAIN +from homeassistant.const import CONF_DEVICE_ID, CONF_IP_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_form(hass: HomeAssistant, flow_id: str, mock_setup_entry) -> None: + """Test we get the form and the happy path works.""" + with patch( + "homeassistant.components.flexit_bacnet.config_flow.FlexitBACnet.update" + ): + result = await hass.config_entries.flow.async_configure( + flow_id, + { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_DEVICE_ID: 2, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Device Name" + assert result["context"]["unique_id"] == "0000-0001" + assert result["data"] == { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_DEVICE_ID: 2, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("error", "message"), + [ + ( + asyncio.exceptions.TimeoutError, + "cannot_connect", + ), + (ConnectionError, "cannot_connect"), + (DecodingError, "cannot_connect"), + (Exception(), "unknown"), + ], +) +async def test_flow_fails( + hass: HomeAssistant, flow_id: str, error: Exception, message: str, mock_setup_entry +) -> None: + """Test that we return 'cannot_connect' error when attempting to connect to an incorrect IP address. + + The flexit_bacnet library raises asyncio.exceptions.TimeoutError in that scenario. + """ + with patch( + "homeassistant.components.flexit_bacnet.config_flow.FlexitBACnet.update", + side_effect=error, + ): + result = await hass.config_entries.flow.async_configure( + flow_id, + { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_DEVICE_ID: 2, + }, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": message} + assert len(mock_setup_entry.mock_calls) == 0 + + # ensure that user can recover from this error + with patch( + "homeassistant.components.flexit_bacnet.config_flow.FlexitBACnet.update" + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_DEVICE_ID: 2, + }, + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Device Name" + assert result2["context"]["unique_id"] == "0000-0001" + assert result2["data"] == { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_DEVICE_ID: 2, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_device_already_exist(hass: HomeAssistant, flow_id: str) -> None: + """Test that we cannot add already added device.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_IP_ADDRESS: "1.1.1.1", + CONF_DEVICE_ID: 2, + }, + unique_id="0000-0001", + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.flexit_bacnet.config_flow.FlexitBACnet.update" + ): + result = await hass.config_entries.flow.async_configure( + flow_id, + { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_DEVICE_ID: 2, + }, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured"