Add Nobø Ecohub integration (#50913)

* 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>
This commit is contained in:
Øyvind Matheson Wergeland 2022-09-03 10:11:40 +02:00 committed by GitHub
parent cf988354db
commit f52c00a1c1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 922 additions and 0 deletions

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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()

View file

@ -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)

View file

@ -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"

View file

@ -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"
}

View file

@ -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."
}
}
}
}

View file

@ -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."
}
}
}
}

View file

@ -255,6 +255,7 @@ FLOWS = {
"nightscout",
"nina",
"nmap_tracker",
"nobo_hub",
"notion",
"nuheat",
"nuki",

View file

@ -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

View file

@ -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

View file

@ -0,0 +1 @@
"""Tests for the Nobø Ecohub integration."""

View file

@ -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"}