diff --git a/.coveragerc b/.coveragerc index 30f768e01a4..6bc69125c30 100644 --- a/.coveragerc +++ b/.coveragerc @@ -230,6 +230,9 @@ omit = homeassistant/components/dublin_bus_transport/sensor.py homeassistant/components/dunehd/__init__.py homeassistant/components/dunehd/media_player.py + homeassistant/components/duotecno/__init__.py + homeassistant/components/duotecno/entity.py + homeassistant/components/duotecno/switch.py homeassistant/components/dwd_weather_warnings/const.py homeassistant/components/dwd_weather_warnings/coordinator.py homeassistant/components/dwd_weather_warnings/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index ef9634e1527..f09785a7781 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -297,6 +297,8 @@ build.json @home-assistant/supervisor /tests/components/dsmr_reader/ @depl0y @glodenox /homeassistant/components/dunehd/ @bieniu /tests/components/dunehd/ @bieniu +/homeassistant/components/duotecno/ @cereal2nd +/tests/components/duotecno/ @cereal2nd /homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192 @Hummel95 @andarotajo /tests/components/dwd_weather_warnings/ @runningman84 @stephan192 @Hummel95 @andarotajo /homeassistant/components/dynalite/ @ziv1234 diff --git a/homeassistant/components/duotecno/__init__.py b/homeassistant/components/duotecno/__init__.py new file mode 100644 index 00000000000..a1cf1c907a6 --- /dev/null +++ b/homeassistant/components/duotecno/__init__.py @@ -0,0 +1,37 @@ +"""The duotecno integration.""" +from __future__ import annotations + +from duotecno.controller import PyDuotecno +from duotecno.exceptions import InvalidPassword, LoadFailure + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN + +PLATFORMS: list[Platform] = [Platform.SWITCH] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up duotecno from a config entry.""" + + controller = PyDuotecno() + try: + await controller.connect( + entry.data[CONF_HOST], entry.data[CONF_PORT], entry.data[CONF_PASSWORD] + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + except (OSError, InvalidPassword, LoadFailure) as err: + raise ConfigEntryNotReady from err + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = controller + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/duotecno/config_flow.py b/homeassistant/components/duotecno/config_flow.py new file mode 100644 index 00000000000..37087d4ea1a --- /dev/null +++ b/homeassistant/components/duotecno/config_flow.py @@ -0,0 +1,61 @@ +"""Config flow for duotecno integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from duotecno.controller import PyDuotecno +from duotecno.exceptions import InvalidPassword +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_PORT): int, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for duotecno.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + try: + controller = PyDuotecno() + await controller.connect( + user_input[CONF_HOST], + user_input[CONF_PORT], + user_input[CONF_PASSWORD], + True, + ) + except ConnectionError: + errors["base"] = "cannot_connect" + except InvalidPassword: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=f"{user_input[CONF_HOST]}", data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/duotecno/const.py b/homeassistant/components/duotecno/const.py new file mode 100644 index 00000000000..114867b8d95 --- /dev/null +++ b/homeassistant/components/duotecno/const.py @@ -0,0 +1,3 @@ +"""Constants for the duotecno integration.""" + +DOMAIN = "duotecno" diff --git a/homeassistant/components/duotecno/entity.py b/homeassistant/components/duotecno/entity.py new file mode 100644 index 00000000000..f1c72aa55c4 --- /dev/null +++ b/homeassistant/components/duotecno/entity.py @@ -0,0 +1,36 @@ +"""Support for Velbus devices.""" +from __future__ import annotations + +from duotecno.unit import BaseUnit + +from homeassistant.helpers.entity import DeviceInfo, Entity + +from .const import DOMAIN + + +class DuotecnoEntity(Entity): + """Representation of a Duotecno entity.""" + + _attr_should_poll: bool = False + _unit: BaseUnit + + def __init__(self, unit) -> None: + """Initialize a Duotecno entity.""" + self._unit = unit + self._attr_name = unit.get_name() + self._attr_device_info = DeviceInfo( + identifiers={ + (DOMAIN, str(unit.get_node_address())), + }, + manufacturer="Duotecno", + name=unit.get_node_name(), + ) + self._attr_unique_id = f"{unit.get_node_address()}-{unit.get_number()}" + + async def async_added_to_hass(self) -> None: + """When added to hass.""" + self._unit.on_status_update(self._on_update) + + async def _on_update(self) -> None: + """When a unit has an update.""" + self.async_write_ha_state() diff --git a/homeassistant/components/duotecno/manifest.json b/homeassistant/components/duotecno/manifest.json new file mode 100644 index 00000000000..a630a3dedbd --- /dev/null +++ b/homeassistant/components/duotecno/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "duotecno", + "name": "duotecno", + "codeowners": ["@cereal2nd"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/duotecno", + "iot_class": "local_push", + "requirements": ["pyduotecno==2023.7.3"] +} diff --git a/homeassistant/components/duotecno/strings.json b/homeassistant/components/duotecno/strings.json new file mode 100644 index 00000000000..379291eb626 --- /dev/null +++ b/homeassistant/components/duotecno/strings.json @@ -0,0 +1,18 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} diff --git a/homeassistant/components/duotecno/switch.py b/homeassistant/components/duotecno/switch.py new file mode 100644 index 00000000000..a9921de85d3 --- /dev/null +++ b/homeassistant/components/duotecno/switch.py @@ -0,0 +1,50 @@ +"""Support for Duotecno switches.""" +from typing import Any + +from duotecno.unit import SwitchUnit + +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import DuotecnoEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Velbus switch based on config_entry.""" + cntrl = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + DuotecnoSwitch(channel) for channel in cntrl.get_units("SwitchUnit") + ) + + +class DuotecnoSwitch(DuotecnoEntity, SwitchEntity): + """Representation of a switch.""" + + _unit: SwitchUnit + + @property + def is_on(self) -> bool: + """Return true if the switch is on.""" + return self._unit.is_on() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Instruct the switch to turn on.""" + try: + await self._unit.turn_on() + except OSError as err: + raise HomeAssistantError("Transmit for the turn_on packet failed") from err + + async def async_turn_off(self, **kwargs: Any) -> None: + """Instruct the switch to turn off.""" + try: + await self._unit.turn_off() + except OSError as err: + raise HomeAssistantError("Transmit for the turn_off packet failed") from err diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 7283b187ba0..b4b9c409c6e 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -106,6 +106,7 @@ FLOWS = { "dsmr", "dsmr_reader", "dunehd", + "duotecno", "dwd_weather_warnings", "dynalite", "eafm", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 18e7f1c22e1..ebe16947a51 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1220,6 +1220,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "duotecno": { + "name": "duotecno", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "dwd_weather_warnings": { "name": "Deutscher Wetterdienst (DWD) Weather Warnings", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index f64d8f8e66b..617a2664217 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1646,6 +1646,9 @@ pydrawise==2023.7.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 +# homeassistant.components.duotecno +pyduotecno==2023.7.3 + # homeassistant.components.ebox pyebox==1.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 440d4a22c0c..650a78954de 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1219,6 +1219,9 @@ pydiscovergy==1.2.1 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 +# homeassistant.components.duotecno +pyduotecno==2023.7.3 + # homeassistant.components.econet pyeconet==0.1.20 diff --git a/tests/components/duotecno/__init__.py b/tests/components/duotecno/__init__.py new file mode 100644 index 00000000000..9cb20bcaec6 --- /dev/null +++ b/tests/components/duotecno/__init__.py @@ -0,0 +1 @@ +"""Tests for the duotecno integration.""" diff --git a/tests/components/duotecno/conftest.py b/tests/components/duotecno/conftest.py new file mode 100644 index 00000000000..82c3e0c7f44 --- /dev/null +++ b/tests/components/duotecno/conftest.py @@ -0,0 +1,14 @@ +"""Common fixtures for the duotecno tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.duotecno.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/duotecno/test_config_flow.py b/tests/components/duotecno/test_config_flow.py new file mode 100644 index 00000000000..a2dc265ae6e --- /dev/null +++ b/tests/components/duotecno/test_config_flow.py @@ -0,0 +1,89 @@ +"""Test the duotecno config flow.""" +from unittest.mock import AsyncMock, patch + +from duotecno.exceptions import InvalidPassword +import pytest + +from homeassistant import config_entries +from homeassistant.components.duotecno.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "duotecno.controller.PyDuotecno.connect", + return_value=None, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "port": 1234, + "password": "test-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "1.1.1.1" + assert result2["data"] == { + "host": "1.1.1.1", + "port": 1234, + "password": "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("test_side_effect", "test_error"), + [ + (InvalidPassword, "invalid_auth"), + (ConnectionError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_invalid(hass: HomeAssistant, test_side_effect, test_error): + """Test all side_effects on the controller.connect via parameters.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch("duotecno.controller.PyDuotecno.connect", side_effect=test_side_effect): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "port": 1234, + "password": "test-password", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": test_error} + + with patch("duotecno.controller.PyDuotecno.connect"): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "port": 1234, + "password": "test-password2", + }, + ) + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "1.1.1.1" + assert result2["data"] == { + "host": "1.1.1.1", + "port": 1234, + "password": "test-password2", + }