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:
parent
cf988354db
commit
f52c00a1c1
14 changed files with 922 additions and 0 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
86
homeassistant/components/nobo_hub/__init__.py
Normal file
86
homeassistant/components/nobo_hub/__init__.py
Normal 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)
|
209
homeassistant/components/nobo_hub/climate.py
Normal file
209
homeassistant/components/nobo_hub/climate.py
Normal 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()
|
210
homeassistant/components/nobo_hub/config_flow.py
Normal file
210
homeassistant/components/nobo_hub/config_flow.py
Normal 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)
|
19
homeassistant/components/nobo_hub/const.py
Normal file
19
homeassistant/components/nobo_hub/const.py
Normal 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"
|
9
homeassistant/components/nobo_hub/manifest.json
Normal file
9
homeassistant/components/nobo_hub/manifest.json
Normal 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"
|
||||
}
|
44
homeassistant/components/nobo_hub/strings.json
Normal file
44
homeassistant/components/nobo_hub/strings.json
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
44
homeassistant/components/nobo_hub/translations/en.json
Normal file
44
homeassistant/components/nobo_hub/translations/en.json
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -255,6 +255,7 @@ FLOWS = {
|
|||
"nightscout",
|
||||
"nina",
|
||||
"nmap_tracker",
|
||||
"nobo_hub",
|
||||
"notion",
|
||||
"nuheat",
|
||||
"nuki",
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
1
tests/components/nobo_hub/__init__.py
Normal file
1
tests/components/nobo_hub/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
"""Tests for the Nobø Ecohub integration."""
|
289
tests/components/nobo_hub/test_config_flow.py
Normal file
289
tests/components/nobo_hub/test_config_flow.py
Normal 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"}
|
Loading…
Add table
Reference in a new issue