diff --git a/.coveragerc b/.coveragerc index 3ff0d49965cea9171ac47b4f2846c9b8a7810a94..5b543434bada3ce2448f5dde53df26e09909d994 100644 --- a/.coveragerc +++ b/.coveragerc @@ -838,6 +838,8 @@ omit = homeassistant/components/nmap_tracker/__init__.py homeassistant/components/nmap_tracker/device_tracker.py homeassistant/components/nmbs/sensor.py + homeassistant/components/nobo_hub/__init__.py + homeassistant/components/nobo_hub/climate.py homeassistant/components/notion/__init__.py homeassistant/components/notion/binary_sensor.py homeassistant/components/notion/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index b135a418566d8e5beccb8a332701e179f894cda2..97d2b9f9d9bfe6204d637e0784495f26438c8e1c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -748,6 +748,8 @@ build.json @home-assistant/supervisor /homeassistant/components/nissan_leaf/ @filcole /homeassistant/components/nmbs/ @thibmaek /homeassistant/components/noaa_tides/ @jdelaney72 +/homeassistant/components/nobo_hub/ @echoromeo @oyvindwe +/tests/components/nobo_hub/ @echoromeo @oyvindwe /homeassistant/components/notify/ @home-assistant/core /tests/components/notify/ @home-assistant/core /homeassistant/components/notify_events/ @matrozov @papajojo diff --git a/homeassistant/components/nobo_hub/__init__.py b/homeassistant/components/nobo_hub/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..7db9eb96f7e6a440d9e6e536872b484f29429744 --- /dev/null +++ b/homeassistant/components/nobo_hub/__init__.py @@ -0,0 +1,86 @@ +"""The Nobø Ecohub integration.""" +from __future__ import annotations + +import logging + +from pynobo import nobo + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_NAME, + CONF_IP_ADDRESS, + EVENT_HOMEASSISTANT_STOP, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry + +from .const import ( + ATTR_HARDWARE_VERSION, + ATTR_SERIAL, + ATTR_SOFTWARE_VERSION, + CONF_AUTO_DISCOVERED, + CONF_SERIAL, + DOMAIN, + NOBO_MANUFACTURER, +) + +PLATFORMS = [Platform.CLIMATE] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Nobø Ecohub from a config entry.""" + + serial = entry.data[CONF_SERIAL] + discover = entry.data[CONF_AUTO_DISCOVERED] + ip_address = None if discover else entry.data[CONF_IP_ADDRESS] + hub = nobo(serial=serial, ip=ip_address, discover=discover, synchronous=False) + await hub.start() + + hass.data.setdefault(DOMAIN, {}) + + # Register hub as device + dev_reg = device_registry.async_get(hass) + dev_reg.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, hub.hub_info[ATTR_SERIAL])}, + manufacturer=NOBO_MANUFACTURER, + name=hub.hub_info[ATTR_NAME], + model=f"Nobø Ecohub ({hub.hub_info[ATTR_HARDWARE_VERSION]})", + sw_version=hub.hub_info[ATTR_SOFTWARE_VERSION], + ) + + async def _async_close(event): + """Close the Nobø Ecohub socket connection when HA stops.""" + await hub.stop() + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_close) + ) + hass.data[DOMAIN][entry.entry_id] = hub + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + entry.async_on_unload(entry.add_update_listener(options_update_listener)) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + + hub: nobo = hass.data[DOMAIN][entry.entry_id] + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + await hub.stop() + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +async def options_update_listener( + hass: HomeAssistant, config_entry: ConfigEntry +) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/nobo_hub/climate.py b/homeassistant/components/nobo_hub/climate.py new file mode 100644 index 0000000000000000000000000000000000000000..3b7dc2debd9337c1fb77d055d9c3cbea9e2a80e4 --- /dev/null +++ b/homeassistant/components/nobo_hub/climate.py @@ -0,0 +1,209 @@ +"""Python Control of Nobø Hub - Nobø Energy Control.""" +from __future__ import annotations + +import logging +from typing import Any + +from pynobo import nobo + +from homeassistant.components.climate import ClimateEntity +from homeassistant.components.climate.const import ( + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + PRESET_AWAY, + PRESET_COMFORT, + PRESET_ECO, + PRESET_NONE, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_IDENTIFIERS, + ATTR_MODE, + ATTR_NAME, + ATTR_SUGGESTED_AREA, + ATTR_VIA_DEVICE, + PRECISION_WHOLE, + TEMP_CELSIUS, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ( + ATTR_OVERRIDE_ALLOWED, + ATTR_SERIAL, + ATTR_TARGET_ID, + ATTR_TARGET_TYPE, + ATTR_TEMP_COMFORT_C, + ATTR_TEMP_ECO_C, + CONF_OVERRIDE_TYPE, + DOMAIN, + OVERRIDE_TYPE_NOW, +) + +SUPPORT_FLAGS = ( + ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE +) + +PRESET_MODES = [PRESET_NONE, PRESET_COMFORT, PRESET_ECO, PRESET_AWAY] + +MIN_TEMPERATURE = 7 +MAX_TEMPERATURE = 40 + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Nobø Ecohub platform from UI configuration.""" + + # Setup connection with hub + hub: nobo = hass.data[DOMAIN][config_entry.entry_id] + + override_type = ( + nobo.API.OVERRIDE_TYPE_NOW + if config_entry.options.get(CONF_OVERRIDE_TYPE) == OVERRIDE_TYPE_NOW + else nobo.API.OVERRIDE_TYPE_CONSTANT + ) + + # Add zones as entities + async_add_entities( + [NoboZone(zone_id, hub, override_type) for zone_id in hub.zones], + True, + ) + + +class NoboZone(ClimateEntity): + """Representation of a Nobø zone. + + A Nobø zone consists of a group of physical devices that are + controlled as a unity. + """ + + _attr_max_temp = MAX_TEMPERATURE + _attr_min_temp = MIN_TEMPERATURE + _attr_precision = PRECISION_WHOLE + _attr_preset_modes = PRESET_MODES + # Need to poll to get preset change when in HVACMode.AUTO. + _attr_supported_features = SUPPORT_FLAGS + _attr_temperature_unit = TEMP_CELSIUS + + def __init__(self, zone_id, hub: nobo, override_type): + """Initialize the climate device.""" + self._id = zone_id + self._nobo = hub + self._attr_unique_id = f"{hub.hub_serial}:{zone_id}" + self._attr_name = None + self._attr_has_entity_name = True + self._attr_hvac_mode = HVACMode.AUTO + self._attr_hvac_modes = [HVACMode.HEAT, HVACMode.AUTO] + self._override_type = override_type + self._attr_device_info: DeviceInfo = { + ATTR_IDENTIFIERS: {(DOMAIN, f"{hub.hub_serial}:{zone_id}")}, + ATTR_NAME: hub.zones[zone_id][ATTR_NAME], + ATTR_VIA_DEVICE: (DOMAIN, hub.hub_info[ATTR_SERIAL]), + ATTR_SUGGESTED_AREA: hub.zones[zone_id][ATTR_NAME], + } + + async def async_added_to_hass(self) -> None: + """Register callback from hub.""" + self._nobo.register_callback(self._after_update) + + async def async_will_remove_from_hass(self) -> None: + """Deregister callback from hub.""" + self._nobo.deregister_callback(self._after_update) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target HVAC mode, if it's supported.""" + if hvac_mode not in self.hvac_modes: + raise ValueError( + f"Zone {self._id} '{self._attr_name}' called with unsupported HVAC mode '{hvac_mode}'" + ) + if hvac_mode == HVACMode.AUTO: + await self.async_set_preset_mode(PRESET_NONE) + elif hvac_mode == HVACMode.HEAT: + await self.async_set_preset_mode(PRESET_COMFORT) + self._attr_hvac_mode = hvac_mode + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new zone override.""" + if self._nobo.zones[self._id][ATTR_OVERRIDE_ALLOWED] != "1": + return + if preset_mode == PRESET_ECO: + mode = nobo.API.OVERRIDE_MODE_ECO + elif preset_mode == PRESET_AWAY: + mode = nobo.API.OVERRIDE_MODE_AWAY + elif preset_mode == PRESET_COMFORT: + mode = nobo.API.OVERRIDE_MODE_COMFORT + else: # PRESET_NONE + mode = nobo.API.OVERRIDE_MODE_NORMAL + await self._nobo.async_create_override( + mode, + self._override_type, + nobo.API.OVERRIDE_TARGET_ZONE, + self._id, + ) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + if ATTR_TARGET_TEMP_LOW in kwargs: + low = round(kwargs[ATTR_TARGET_TEMP_LOW]) + high = round(kwargs[ATTR_TARGET_TEMP_HIGH]) + low = min(low, high) + high = max(low, high) + await self._nobo.async_update_zone( + self._id, temp_comfort_c=high, temp_eco_c=low + ) + + async def async_update(self) -> None: + """Fetch new state data for this zone.""" + self._read_state() + + @callback + def _read_state(self) -> None: + """Read the current state from the hub. These are only local calls.""" + state = self._nobo.get_current_zone_mode(self._id) + self._attr_hvac_mode = HVACMode.AUTO + self._attr_preset_mode = PRESET_NONE + + if state == nobo.API.NAME_OFF: + self._attr_hvac_mode = HVACMode.OFF + elif state == nobo.API.NAME_AWAY: + self._attr_preset_mode = PRESET_AWAY + elif state == nobo.API.NAME_ECO: + self._attr_preset_mode = PRESET_ECO + elif state == nobo.API.NAME_COMFORT: + self._attr_preset_mode = PRESET_COMFORT + + if self._nobo.zones[self._id][ATTR_OVERRIDE_ALLOWED] == "1": + for override in self._nobo.overrides: + if self._nobo.overrides[override][ATTR_MODE] == "0": + continue # "normal" overrides + if ( + self._nobo.overrides[override][ATTR_TARGET_TYPE] + == nobo.API.OVERRIDE_TARGET_ZONE + and self._nobo.overrides[override][ATTR_TARGET_ID] == self._id + ): + self._attr_hvac_mode = HVACMode.HEAT + break + + current_temperature = self._nobo.get_current_zone_temperature(self._id) + self._attr_current_temperature = ( + None if current_temperature is None else float(current_temperature) + ) + self._attr_target_temperature_high = int( + self._nobo.zones[self._id][ATTR_TEMP_COMFORT_C] + ) + self._attr_target_temperature_low = int( + self._nobo.zones[self._id][ATTR_TEMP_ECO_C] + ) + + @callback + def _after_update(self, hub): + self._read_state() + self.async_write_ha_state() diff --git a/homeassistant/components/nobo_hub/config_flow.py b/homeassistant/components/nobo_hub/config_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..f1e2dd7d9d26b6b467922760d81f52352c6ddd72 --- /dev/null +++ b/homeassistant/components/nobo_hub/config_flow.py @@ -0,0 +1,210 @@ +"""Config flow for Nobø Ecohub integration.""" +from __future__ import annotations + +import socket +from typing import Any + +from pynobo import nobo +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError + +from .const import ( + CONF_AUTO_DISCOVERED, + CONF_OVERRIDE_TYPE, + CONF_SERIAL, + DOMAIN, + OVERRIDE_TYPE_CONSTANT, + OVERRIDE_TYPE_NOW, +) + +DATA_NOBO_HUB_IMPL = "nobo_hub_flow_implementation" +DEVICE_INPUT = "device_input" + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Nobø Ecohub.""" + + VERSION = 1 + + def __init__(self): + """Initialize the config flow.""" + self._discovered_hubs = None + self._hub = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if self._discovered_hubs is None: + self._discovered_hubs = dict(await nobo.async_discover_hubs()) + + if not self._discovered_hubs: + # No hubs auto discovered + return await self.async_step_manual() + + if user_input is not None: + if user_input["device"] == "manual": + return await self.async_step_manual() + self._hub = user_input["device"] + return await self.async_step_selected() + + hubs = self._hubs() + hubs["manual"] = "Manual" + data_schema = vol.Schema( + { + vol.Required("device"): vol.In(hubs), + } + ) + return self.async_show_form( + step_id="user", + data_schema=data_schema, + ) + + async def async_step_selected( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle configuration of a selected discovered device.""" + errors = {} + if user_input is not None: + serial_prefix = self._discovered_hubs[self._hub] + serial_suffix = user_input["serial_suffix"] + serial = f"{serial_prefix}{serial_suffix}" + try: + return await self._create_configuration(serial, self._hub, True) + except NoboHubConnectError as error: + errors["base"] = error.msg + + user_input = user_input or {} + return self.async_show_form( + step_id="selected", + data_schema=vol.Schema( + { + vol.Required( + "serial_suffix", default=user_input.get("serial_suffix") + ): str, + } + ), + errors=errors, + description_placeholders={ + "hub": self._format_hub(self._hub, self._discovered_hubs[self._hub]) + }, + ) + + async def async_step_manual( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle configuration of an undiscovered device.""" + errors = {} + if user_input is not None: + serial = user_input[CONF_SERIAL] + ip_address = user_input[CONF_IP_ADDRESS] + try: + return await self._create_configuration(serial, ip_address, False) + except NoboHubConnectError as error: + errors["base"] = error.msg + + user_input = user_input or {} + return self.async_show_form( + step_id="manual", + data_schema=vol.Schema( + { + vol.Required(CONF_SERIAL, default=user_input.get(CONF_SERIAL)): str, + vol.Required( + CONF_IP_ADDRESS, default=user_input.get(CONF_IP_ADDRESS) + ): str, + } + ), + errors=errors, + ) + + async def _create_configuration( + self, serial: str, ip_address: str, auto_discovered: bool + ) -> FlowResult: + await self.async_set_unique_id(serial) + self._abort_if_unique_id_configured() + name = await self._test_connection(serial, ip_address) + return self.async_create_entry( + title=name, + data={ + CONF_SERIAL: serial, + CONF_IP_ADDRESS: ip_address, + CONF_AUTO_DISCOVERED: auto_discovered, + }, + ) + + async def _test_connection(self, serial: str, ip_address: str) -> str: + if not len(serial) == 12 or not serial.isdigit(): + raise NoboHubConnectError("invalid_serial") + try: + socket.inet_aton(ip_address) + except OSError as err: + raise NoboHubConnectError("invalid_ip") from err + hub = nobo(serial=serial, ip=ip_address, discover=False, synchronous=False) + if not await hub.async_connect_hub(ip_address, serial): + raise NoboHubConnectError("cannot_connect") + name = hub.hub_info["name"] + await hub.close() + return name + + @staticmethod + def _format_hub(ip, serial_prefix): + return f"{serial_prefix}XXX ({ip})" + + def _hubs(self): + return { + ip: self._format_hub(ip, serial_prefix) + for ip, serial_prefix in self._discovered_hubs.items() + } + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> config_entries.OptionsFlow: + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) + + +class NoboHubConnectError(HomeAssistantError): + """Error with connecting to Nobø Ecohub.""" + + def __init__(self, msg) -> None: + """Instantiate error.""" + super().__init__() + self.msg = msg + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handles options flow for the component.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize the options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None) -> FlowResult: + """Manage the options.""" + + if user_input is not None: + data = { + CONF_OVERRIDE_TYPE: user_input.get(CONF_OVERRIDE_TYPE), + } + return self.async_create_entry(title="", data=data) + + override_type = self.config_entry.options.get( + CONF_OVERRIDE_TYPE, OVERRIDE_TYPE_CONSTANT + ) + + schema = vol.Schema( + { + vol.Required(CONF_OVERRIDE_TYPE, default=override_type): vol.In( + [OVERRIDE_TYPE_CONSTANT, OVERRIDE_TYPE_NOW] + ), + } + ) + + return self.async_show_form(step_id="init", data_schema=schema) diff --git a/homeassistant/components/nobo_hub/const.py b/homeassistant/components/nobo_hub/const.py new file mode 100644 index 0000000000000000000000000000000000000000..320c2f43c07863ae18347e2c9c27b85d3f621bea --- /dev/null +++ b/homeassistant/components/nobo_hub/const.py @@ -0,0 +1,19 @@ +"""Constants for the Nobø Ecohub integration.""" + +DOMAIN = "nobo_hub" + +CONF_AUTO_DISCOVERED = "auto_discovered" +CONF_SERIAL = "serial" +CONF_OVERRIDE_TYPE = "override_type" +OVERRIDE_TYPE_CONSTANT = "Constant" +OVERRIDE_TYPE_NOW = "Now" + +NOBO_MANUFACTURER = "Glen Dimplex Nordic AS" +ATTR_HARDWARE_VERSION = "hardware_version" +ATTR_SOFTWARE_VERSION = "software_version" +ATTR_SERIAL = "serial" +ATTR_TEMP_COMFORT_C = "temp_comfort_c" +ATTR_TEMP_ECO_C = "temp_eco_c" +ATTR_OVERRIDE_ALLOWED = "override_allowed" +ATTR_TARGET_TYPE = "target_type" +ATTR_TARGET_ID = "target_id" diff --git a/homeassistant/components/nobo_hub/manifest.json b/homeassistant/components/nobo_hub/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..14e10a1ffaf22a2ecea934ea4eb2b89005f2e7fa --- /dev/null +++ b/homeassistant/components/nobo_hub/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "nobo_hub", + "name": "Nob\u00f8 Ecohub", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/nobo_hub", + "requirements": ["pynobo==1.4.0"], + "codeowners": ["@echoromeo", "@oyvindwe"], + "iot_class": "local_push" +} diff --git a/homeassistant/components/nobo_hub/strings.json b/homeassistant/components/nobo_hub/strings.json new file mode 100644 index 0000000000000000000000000000000000000000..cfa339c98dfa680594a09a25f82f382b50173091 --- /dev/null +++ b/homeassistant/components/nobo_hub/strings.json @@ -0,0 +1,44 @@ +{ + "config": { + "step": { + "user": { + "description": "Select Nobø Ecohub to configure.", + "data": { + "device": "Discovered hubs" + } + }, + "selected": { + "description": "Configuring {hub}.\r\rTo connect to the hub, you need to enter the last 3 digits of the hub's serial number.", + "data": { + "serial_suffix": "Serial number suffix (3 digits)" + } + }, + "manual": { + "description": "Configure a Nobø Ecohub not discovered on your local network. If your hub is on another network, you can still connect to it by entering the complete serial number (12 digits) and its IP address.", + "data": { + "serial": "Serial number (12 digits)", + "ip_address": "[%key:common::config_flow::data::ip%]" + } + } + }, + "error": { + "cannot_connect": "Failed to connect - check serial number", + "invalid_serial": "Invalid serial number", + "invalid_ip": "Invalid IP address", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "options": { + "step": { + "init": { + "data": { + "override_type": "Override type" + }, + "description": "Select override type \"Now\" to end override on next week profile change." + } + } + } +} diff --git a/homeassistant/components/nobo_hub/translations/en.json b/homeassistant/components/nobo_hub/translations/en.json new file mode 100644 index 0000000000000000000000000000000000000000..b35a32101c3769324921dbf1dcd5ef5becfaba04 --- /dev/null +++ b/homeassistant/components/nobo_hub/translations/en.json @@ -0,0 +1,44 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect - check serial number", + "invalid_ip": "Invalid IP address", + "invalid_serial": "Invalid serial number", + "unknown": "Unexpected error" + }, + "step": { + "manual": { + "data": { + "ip_address": "IP Address", + "serial": "Serial number (12 digits)" + }, + "description": "Configure a Nob\u00f8 Ecohub not discovered on your local network. If your hub is on another network, you can still connect to it by entering the complete serial number (12 digits) and its IP address." + }, + "selected": { + "data": { + "serial_suffix": "Serial number suffix (3 digits)" + }, + "description": "Configuring {hub}.\r\rTo connect to the hub, you need to enter the last 3 digits of the hub's serial number." + }, + "user": { + "data": { + "device": "Discovered hubs" + }, + "description": "Select Nob\u00f8 Ecohub to configure." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "override_type": "Override type" + }, + "description": "Select override type \"Now\" to end override on next week profile change." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index c5437e14562f62e3d8dd1e6ee90c9a97ac4c3b45..5aa0ed6933612a5ce98e98b6e9afa455bc468938 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -255,6 +255,7 @@ FLOWS = { "nightscout", "nina", "nmap_tracker", + "nobo_hub", "notion", "nuheat", "nuki", diff --git a/requirements_all.txt b/requirements_all.txt index 3574dba00ad87dba1bcdad3992bf71e6f6d8cd92..a77007b00f80a71697e359f239ec73e96dcc4629 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1718,6 +1718,9 @@ pynetio==0.1.9.1 # homeassistant.components.nina pynina==0.1.8 +# homeassistant.components.nobo_hub +pynobo==1.4.0 + # homeassistant.components.nuki pynuki==1.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9f2436c8ae8733a98a309f92c8bd7f2a298c791c..518af11783d24c14898dd098acb7e3bbee6b5136 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1204,6 +1204,9 @@ pynetgear==0.10.8 # homeassistant.components.nina pynina==0.1.8 +# homeassistant.components.nobo_hub +pynobo==1.4.0 + # homeassistant.components.nuki pynuki==1.5.2 diff --git a/tests/components/nobo_hub/__init__.py b/tests/components/nobo_hub/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..023f53bf6ee1f6c09b00c6585cde87d6063acc7c --- /dev/null +++ b/tests/components/nobo_hub/__init__.py @@ -0,0 +1 @@ +"""Tests for the Nobø Ecohub integration.""" diff --git a/tests/components/nobo_hub/test_config_flow.py b/tests/components/nobo_hub/test_config_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..5cfcee9cdbf72dc97cf3cebea11dcf72c2af80d7 --- /dev/null +++ b/tests/components/nobo_hub/test_config_flow.py @@ -0,0 +1,289 @@ +"""Test the Nobø Ecohub config flow.""" +from unittest.mock import PropertyMock, patch + +from homeassistant import config_entries +from homeassistant.components.nobo_hub.const import CONF_OVERRIDE_TYPE, DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_configure_with_discover(hass: HomeAssistant) -> None: + """Test configure with discover.""" + with patch( + "pynobo.nobo.async_discover_hubs", + return_value=[("1.1.1.1", "123456789")], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["step_id"] == "user" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "device": "1.1.1.1", + }, + ) + assert result2["type"] == "form" + assert result2["errors"] == {} + assert result2["step_id"] == "selected" + + with patch( + "pynobo.nobo.async_connect_hub", return_value=True + ) as mock_connect, patch( + "pynobo.nobo.hub_info", + new_callable=PropertyMock, + create=True, + return_value={"name": "My Nobø Ecohub"}, + ), patch( + "homeassistant.components.nobo_hub.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + "serial_suffix": "012", + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == "create_entry" + assert result3["title"] == "My Nobø Ecohub" + assert result3["data"] == { + "ip_address": "1.1.1.1", + "serial": "123456789012", + "auto_discovered": True, + } + mock_connect.assert_awaited_once_with("1.1.1.1", "123456789012") + mock_setup_entry.assert_awaited_once() + + +async def test_configure_manual(hass: HomeAssistant) -> None: + """Test manual configuration when no hubs are discovered.""" + with patch( + "pynobo.nobo.async_discover_hubs", + return_value=[], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + assert result["step_id"] == "manual" + + with patch( + "pynobo.nobo.async_connect_hub", return_value=True + ) as mock_connect, patch( + "pynobo.nobo.hub_info", + new_callable=PropertyMock, + create=True, + return_value={"name": "My Nobø Ecohub"}, + ), patch( + "homeassistant.components.nobo_hub.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "serial": "123456789012", + "ip_address": "1.1.1.1", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == "My Nobø Ecohub" + assert result2["data"] == { + "serial": "123456789012", + "ip_address": "1.1.1.1", + "auto_discovered": False, + } + mock_connect.assert_awaited_once_with("1.1.1.1", "123456789012") + mock_setup_entry.assert_awaited_once() + + +async def test_configure_user_selected_manual(hass: HomeAssistant) -> None: + """Test configuration when user selects manual.""" + with patch( + "pynobo.nobo.async_discover_hubs", + return_value=[("1.1.1.1", "123456789")], + ): + 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"], + { + "device": "manual", + }, + ) + assert result2["type"] == "form" + assert result2["errors"] == {} + assert result2["step_id"] == "manual" + + with patch( + "pynobo.nobo.async_connect_hub", return_value=True + ) as mock_connect, patch( + "pynobo.nobo.hub_info", + new_callable=PropertyMock, + create=True, + return_value={"name": "My Nobø Ecohub"}, + ), patch( + "homeassistant.components.nobo_hub.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "serial": "123456789012", + "ip_address": "1.1.1.1", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == "My Nobø Ecohub" + assert result2["data"] == { + "serial": "123456789012", + "ip_address": "1.1.1.1", + "auto_discovered": False, + } + mock_connect.assert_awaited_once_with("1.1.1.1", "123456789012") + mock_setup_entry.assert_awaited_once() + + +async def test_configure_invalid_serial_suffix(hass: HomeAssistant) -> None: + """Test we handle invalid serial suffix error.""" + with patch( + "pynobo.nobo.async_discover_hubs", + return_value=[("1.1.1.1", "123456789")], + ): + 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"], + { + "device": "1.1.1.1", + }, + ) + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {"serial_suffix": "ABC"}, + ) + + assert result3["type"] == "form" + assert result3["errors"] == {"base": "invalid_serial"} + + +async def test_configure_invalid_serial_undiscovered(hass: HomeAssistant) -> None: + """Test we handle invalid serial error.""" + with patch( + "pynobo.nobo.async_discover_hubs", + return_value=[], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "manual"} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"ip_address": "1.1.1.1", "serial": "123456789"}, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_serial"} + + +async def test_configure_invalid_ip_address(hass: HomeAssistant) -> None: + """Test we handle invalid ip address error.""" + with patch( + "pynobo.nobo.async_discover_hubs", + return_value=[("1.1.1.1", "123456789")], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "manual"} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"serial": "123456789012", "ip_address": "ABCD"}, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_ip"} + + +async def test_configure_cannot_connect(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + with patch( + "pynobo.nobo.async_discover_hubs", + return_value=[("1.1.1.1", "123456789")], + ): + 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"], + { + "device": "1.1.1.1", + }, + ) + + with patch( + "pynobo.nobo.async_connect_hub", + return_value=False, + ) as mock_connect: + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {"serial_suffix": "012"}, + ) + assert result3["type"] == "form" + assert result3["errors"] == {"base": "cannot_connect"} + mock_connect.assert_awaited_once_with("1.1.1.1", "123456789012") + + +async def test_options_flow(hass: HomeAssistant) -> None: + """Test the options flow.""" + config_entry = MockConfigEntry( + domain="nobo_hub", + unique_id="123456789012", + data={"serial": "123456789012", "ip_address": "1.1.1.1", "auto_discover": True}, + ) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.nobo_hub.async_setup_entry", return_value=True + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == "form" + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_OVERRIDE_TYPE: "Constant", + }, + ) + + assert result["type"] == "create_entry" + assert config_entry.options == {CONF_OVERRIDE_TYPE: "Constant"} + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_OVERRIDE_TYPE: "Now", + }, + ) + + assert result["type"] == "create_entry" + assert config_entry.options == {CONF_OVERRIDE_TYPE: "Now"}