From f52c00a1c1f43e23062f70bd91a2939da200a8d5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C3=98yvind=20Matheson=20Wergeland?= <oyvind@wergeland.org>
Date: Sat, 3 Sep 2022 10:11:40 +0200
Subject: [PATCH] =?UTF-8?q?Add=20Nob=C3=B8=20Ecohub=20integration=20(#5091?=
 =?UTF-8?q?3)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* Initial version of Nobø Ecohub.

* Options update listener for Nobø Ecohub

* Unit test for nobo_hub config flow

* Cleanup

* Moved comment re backwards compatibility

* Improved tests

* Improved tests

* Options flow test
Pylint

* Fix backwards compatibility mode

* Don't require Python 3.9

* Import form configuration.yaml

* Check if device is already configured.
Correct tests for only discovering serial prefix
Fix importing when only serial suffix is configured

* Use constants

* Pylint and variable name clenaup.

* Review

Co-authored-by: Franck Nijhof <frenck@frenck.nl>

* Fix tests

* Correct disabling off_command and on_commands ("Default" is a hard coded week profile in the hub).

* Improve options dialog

* Configure override type in options dialog

* Formatting

* pyupgrade

* Incorporated review comments

* Incorporated review comments.

* Incorporated second round of review comments.

* Add polling to discover preset change in HVAC_MODE_AUTO.

* Added tests/components/nobo_hub to CODEOWNERS.

* Update homeassistant/components/nobo_hub/config_flow.py

Review

Co-authored-by: Allen Porter <allen.porter@gmail.com>

* Update homeassistant/components/nobo_hub/climate.py

Review

Co-authored-by: Allen Porter <allen.porter@gmail.com>

* Simplify if tests.

* Update homeassistant/components/nobo_hub/__init__.py

Co-authored-by: Franck Nijhof <frenck@frenck.nl>

* Update homeassistant/components/nobo_hub/__init__.py

Co-authored-by: Franck Nijhof <frenck@frenck.nl>

* Update homeassistant/components/nobo_hub/__init__.py

Co-authored-by: Franck Nijhof <frenck@frenck.nl>

* Separate config step for manual configuration.

* Fixed indentation

* Made async_set_temperature more robust

* Thermometer supports tenths even though thermostat is in ones.

* Preserve serial suffix in config dialog on error.

* Initial version of Nobø Ecohub.

* Improved tests

* Review

Co-authored-by: Franck Nijhof <frenck@frenck.nl>

* Configure override type in options dialog

* Separate config step for manual configuration.

* Update homeassistant/components/nobo_hub/__init__.py

Co-authored-by: Franck Nijhof <frenck@frenck.nl>

* Formatting (prettier)

* Fix HA stop listener.

* Review

* Apply suggestions from code review

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

* Review
- Removed workaround to support "OFF" setting.
- Simplified config flow to add a new device.

* Apply suggestions from code review

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

* Apply suggestions from code review

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

* Fixed review comments

* Update en.json with correction in review.

* Implemented review comments:
- Register devices
- Simplifed async_set_temperature

* Register hub as device in init module

* Implemented review comments.
Upgraded pynobo to 1.4.0.

* Apply suggestions from code review

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

* Avoid tacking on the device name in the entity name

* Inherit entity name from device name

Co-authored-by: Franck Nijhof <frenck@frenck.nl>
Co-authored-by: Allen Porter <allen.porter@gmail.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
---
 .coveragerc                                   |   2 +
 CODEOWNERS                                    |   2 +
 homeassistant/components/nobo_hub/__init__.py |  86 ++++++
 homeassistant/components/nobo_hub/climate.py  | 209 +++++++++++++
 .../components/nobo_hub/config_flow.py        | 210 +++++++++++++
 homeassistant/components/nobo_hub/const.py    |  19 ++
 .../components/nobo_hub/manifest.json         |   9 +
 .../components/nobo_hub/strings.json          |  44 +++
 .../components/nobo_hub/translations/en.json  |  44 +++
 homeassistant/generated/config_flows.py       |   1 +
 requirements_all.txt                          |   3 +
 requirements_test_all.txt                     |   3 +
 tests/components/nobo_hub/__init__.py         |   1 +
 tests/components/nobo_hub/test_config_flow.py | 289 ++++++++++++++++++
 14 files changed, 922 insertions(+)
 create mode 100644 homeassistant/components/nobo_hub/__init__.py
 create mode 100644 homeassistant/components/nobo_hub/climate.py
 create mode 100644 homeassistant/components/nobo_hub/config_flow.py
 create mode 100644 homeassistant/components/nobo_hub/const.py
 create mode 100644 homeassistant/components/nobo_hub/manifest.json
 create mode 100644 homeassistant/components/nobo_hub/strings.json
 create mode 100644 homeassistant/components/nobo_hub/translations/en.json
 create mode 100644 tests/components/nobo_hub/__init__.py
 create mode 100644 tests/components/nobo_hub/test_config_flow.py

diff --git a/.coveragerc b/.coveragerc
index 3ff0d49965c..5b543434bad 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 b135a418566..97d2b9f9d9b 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 00000000000..7db9eb96f7e
--- /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 00000000000..3b7dc2debd9
--- /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 00000000000..f1e2dd7d9d2
--- /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 00000000000..320c2f43c07
--- /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 00000000000..14e10a1ffaf
--- /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 00000000000..cfa339c98df
--- /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 00000000000..b35a32101c3
--- /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 c5437e14562..5aa0ed69336 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 3574dba00ad..a77007b00f8 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 9f2436c8ae8..518af11783d 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 00000000000..023f53bf6ee
--- /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 00000000000..5cfcee9cdbf
--- /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"}
-- 
GitLab