diff --git a/.coveragerc b/.coveragerc index d7d8880fdf0..1a7d1f7d394 100644 --- a/.coveragerc +++ b/.coveragerc @@ -667,6 +667,9 @@ omit = homeassistant/components/poolsense/sensor.py homeassistant/components/poolsense/binary_sensor.py homeassistant/components/proliphix/climate.py + homeassistant/components/progettihwsw/__init__.py + homeassistant/components/progettihwsw/binary_sensor.py + homeassistant/components/progettihwsw/switch.py homeassistant/components/prometheus/* homeassistant/components/prowl/notify.py homeassistant/components/proxmoxve/* diff --git a/CODEOWNERS b/CODEOWNERS index e4a93391aba..ecf7745e595 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -325,6 +325,7 @@ homeassistant/components/plum_lightpad/* @ColinHarrington @prystupa homeassistant/components/point/* @fredrike homeassistant/components/poolsense/* @haemishkyd homeassistant/components/powerwall/* @bdraco @jrester +homeassistant/components/progettihwsw/* @ardaseremet homeassistant/components/prometheus/* @knyar homeassistant/components/proxmoxve/* @k4ds3 @jhollowe homeassistant/components/ps4/* @ktnrg45 diff --git a/homeassistant/components/progettihwsw/__init__.py b/homeassistant/components/progettihwsw/__init__.py new file mode 100644 index 00000000000..02418c963d4 --- /dev/null +++ b/homeassistant/components/progettihwsw/__init__.py @@ -0,0 +1,64 @@ +"""Automation manager for boards manufactured by ProgettiHWSW Italy.""" +import asyncio + +from ProgettiHWSW.ProgettiHWSWAPI import ProgettiHWSWAPI +from ProgettiHWSW.input import Input +from ProgettiHWSW.relay import Relay + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + +PLATFORMS = ["switch", "binary_sensor"] + + +async def async_setup(hass, config): + """Set up the ProgettiHWSW Automation component.""" + hass.data[DOMAIN] = {} + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up ProgettiHWSW Automation from a config entry.""" + + hass.data[DOMAIN][entry.entry_id] = ProgettiHWSWAPI( + f'{entry.data["host"]}:{entry.data["port"]}' + ) + + # Check board validation again to load new values to API. + await hass.data[DOMAIN][entry.entry_id].check_board() + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +def setup_input(api: ProgettiHWSWAPI, input_number: int) -> Input: + """Initialize the input pin.""" + return api.get_input(input_number) + + +def setup_switch(api: ProgettiHWSWAPI, switch_number: int, mode: str) -> Relay: + """Initialize the output pin.""" + return api.get_relay(switch_number, mode) diff --git a/homeassistant/components/progettihwsw/binary_sensor.py b/homeassistant/components/progettihwsw/binary_sensor.py new file mode 100644 index 00000000000..1ad0d919f15 --- /dev/null +++ b/homeassistant/components/progettihwsw/binary_sensor.py @@ -0,0 +1,95 @@ +"""Control binary sensor instances.""" + +from datetime import timedelta +import logging + +from ProgettiHWSW.input import Input +import async_timeout + +from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from . import setup_input +from .const import DEFAULT_POLLING_INTERVAL_SEC, DOMAIN + +_LOGGER = logging.getLogger(DOMAIN) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set the progettihwsw platform up and create sensor instances (legacy).""" + + return True + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the binary sensors from a config entry.""" + board_api = hass.data[DOMAIN][config_entry.entry_id] + input_count = config_entry.data["input_count"] + binary_sensors = [] + + async def async_update_data(): + """Fetch data from API endpoint of board.""" + async with async_timeout.timeout(5): + return await board_api.get_inputs() + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name="binary_sensor", + update_method=async_update_data, + update_interval=timedelta(seconds=DEFAULT_POLLING_INTERVAL_SEC), + ) + await coordinator.async_refresh() + + for i in range(1, int(input_count) + 1): + binary_sensors.append( + ProgettihwswBinarySensor( + hass, + coordinator, + config_entry, + f"Input #{i}", + setup_input(board_api, i), + ) + ) + + async_add_entities(binary_sensors) + + +class ProgettihwswBinarySensor(BinarySensorEntity): + """Represent a binary sensor.""" + + def __init__(self, hass, coordinator, config_entry, name, sensor: Input): + """Set initializing values.""" + self._name = name + self._sensor = sensor + self._coordinator = coordinator + + async def async_added_to_hass(self): + """When entity is added to hass.""" + self.async_on_remove( + self._coordinator.async_add_listener(self.async_write_ha_state) + ) + + @property + def name(self): + """Return the sensor name.""" + return self._name + + @property + def is_on(self): + """Get sensor state.""" + return self._coordinator.data[self._sensor.id] + + @property + def should_poll(self): + """No need to poll. Coordinator notifies entity of updates.""" + return False + + @property + def available(self): + """Return if entity is available.""" + return self._coordinator.last_update_success + + async def async_update(self): + """Update the state of binary sensor.""" + await self._coordinator.async_request_refresh() diff --git a/homeassistant/components/progettihwsw/config_flow.py b/homeassistant/components/progettihwsw/config_flow.py new file mode 100644 index 00000000000..9306b134dbb --- /dev/null +++ b/homeassistant/components/progettihwsw/config_flow.py @@ -0,0 +1,102 @@ +"""Config flow for ProgettiHWSW Automation integration.""" + +from ProgettiHWSW.ProgettiHWSWAPI import ProgettiHWSWAPI +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions + +from .const import DOMAIN # pylint: disable=unused-import + +DATA_SCHEMA = vol.Schema( + {vol.Required("host"): str, vol.Required("port", default=80): int} +) + + +async def validate_input(hass: core.HomeAssistant, data): + """Validate the user host input.""" + + api_instance = ProgettiHWSWAPI(f'{data["host"]}:{data["port"]}') + is_valid = await api_instance.check_board() + + if is_valid is False: + raise CannotConnect + + return { + "title": is_valid["title"], + "relay_count": is_valid["relay_count"], + "input_count": is_valid["input_count"], + "is_old": is_valid["is_old"], + } + + +async def validate_input_relay_modes(data): + """Validate the user input in relay modes form.""" + for mode in data.values(): + if mode not in ("bistable", "monostable"): + raise WrongInfo + + return True + + +class ProgettiHWSWConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for ProgettiHWSW Automation.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + async def async_step_relay_modes(self, user_input=None): + """Manage relay modes step.""" + errors = {} + if user_input is not None: + try: + await validate_input_relay_modes(user_input) + whole_data = user_input + whole_data.update(self.s1_in) + except WrongInfo: + errors["base"] = "wrong_info_relay_modes" + except Exception: # pylint: disable=broad-except + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=whole_data["title"], data=whole_data + ) + + relay_modes_schema = {} + for i in range(1, int(self.s1_in["relay_count"]) + 1): + relay_modes_schema[ + vol.Required(f"relay_{str(i)}", default="bistable") + ] = str + + return self.async_show_form( + step_id="relay_modes", + data_schema=vol.Schema(relay_modes_schema), + errors=errors, + ) + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + try: + info = await validate_input(self.hass, user_input) + user_input.update(info) + self.s1_in = ( # pylint: disable=attribute-defined-outside-init + user_input + ) + return await self.async_step_relay_modes() + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + errors["base"] = "unknown" + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot identify host.""" + + +class WrongInfo(exceptions.HomeAssistantError): + """Error to indicate we cannot validate relay modes input.""" diff --git a/homeassistant/components/progettihwsw/const.py b/homeassistant/components/progettihwsw/const.py new file mode 100644 index 00000000000..11d54d89c41 --- /dev/null +++ b/homeassistant/components/progettihwsw/const.py @@ -0,0 +1,5 @@ +"""Define constant variables for general usage.""" + +DOMAIN = "progettihwsw" + +DEFAULT_POLLING_INTERVAL_SEC = 5 diff --git a/homeassistant/components/progettihwsw/manifest.json b/homeassistant/components/progettihwsw/manifest.json new file mode 100644 index 00000000000..15987837fb5 --- /dev/null +++ b/homeassistant/components/progettihwsw/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "progettihwsw", + "name": "ProgettiHWSW Automation", + "documentation": "https://www.home-assistant.io/integrations/progettihwsw", + "codeowners": [ + "@ardaseremet" + ], + "requirements": [ + "progettihwsw==0.1.1" + ], + "config_flow": true +} \ No newline at end of file diff --git a/homeassistant/components/progettihwsw/strings.json b/homeassistant/components/progettihwsw/strings.json new file mode 100644 index 00000000000..2c25433fba9 --- /dev/null +++ b/homeassistant/components/progettihwsw/strings.json @@ -0,0 +1,40 @@ +{ + "config": { + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "wrong_info_relay_modes": "Relay mode selection must be monostable or bistable." + }, + "step": { + "user": { + "title": "Set up board", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + } + }, + "relay_modes": { + "title": "Set up relays", + "data": { + "relay_1": "Relay 1", + "relay_2": "Relay 2", + "relay_3": "Relay 3", + "relay_4": "Relay 4", + "relay_5": "Relay 5", + "relay_6": "Relay 6", + "relay_7": "Relay 7", + "relay_8": "Relay 8", + "relay_9": "Relay 9", + "relay_10": "Relay 10", + "relay_11": "Relay 11", + "relay_12": "Relay 12", + "relay_13": "Relay 13", + "relay_14": "Relay 14", + "relay_15": "Relay 15", + "relay_16": "Relay 16" + } + } + } + }, + "title": "ProgettiHWSW Automation" +} \ No newline at end of file diff --git a/homeassistant/components/progettihwsw/switch.py b/homeassistant/components/progettihwsw/switch.py new file mode 100644 index 00000000000..b6480d15c7b --- /dev/null +++ b/homeassistant/components/progettihwsw/switch.py @@ -0,0 +1,109 @@ +"""Control switches.""" + +from datetime import timedelta +import logging + +from ProgettiHWSW.relay import Relay +import async_timeout + +from homeassistant.components.switch import SwitchEntity +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from . import setup_switch +from .const import DEFAULT_POLLING_INTERVAL_SEC, DOMAIN + +_LOGGER = logging.getLogger(DOMAIN) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set the switch platform up (legacy).""" + return True + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the switches from a config entry.""" + board_api = hass.data[DOMAIN][config_entry.entry_id] + relay_count = config_entry.data["relay_count"] + switches = [] + + async def async_update_data(): + """Fetch data from API endpoint of board.""" + async with async_timeout.timeout(5): + return await board_api.get_switches() + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name="switch", + update_method=async_update_data, + update_interval=timedelta(seconds=DEFAULT_POLLING_INTERVAL_SEC), + ) + await coordinator.async_refresh() + + for i in range(1, int(relay_count) + 1): + switches.append( + ProgettihwswSwitch( + hass, + coordinator, + config_entry, + f"Relay #{i}", + setup_switch(board_api, i, config_entry.data[f"relay_{str(i)}"]), + ) + ) + + async_add_entities(switches) + + +class ProgettihwswSwitch(SwitchEntity): + """Represent a switch entity.""" + + def __init__(self, hass, coordinator, config_entry, name, switch: Relay): + """Initialize the values.""" + self._switch = switch + self._name = name + self._coordinator = coordinator + + async def async_added_to_hass(self): + """When entity is added to hass.""" + self.async_on_remove( + self._coordinator.async_add_listener(self.async_write_ha_state) + ) + + async def async_turn_on(self, **kwargs): + """Turn the switch on.""" + await self._switch.control(True) + await self._coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs): + """Turn the switch off.""" + await self._switch.control(False) + await self._coordinator.async_request_refresh() + + async def async_toggle(self, **kwargs): + """Toggle the state of switch.""" + await self._switch.toggle() + await self._coordinator.async_request_refresh() + + @property + def name(self): + """Return the switch name.""" + return self._name + + @property + def is_on(self): + """Get switch state.""" + return self._coordinator.data[self._switch.id] + + @property + def should_poll(self): + """No need to poll. Coordinator notifies entity of updates.""" + return False + + @property + def available(self): + """Return if entity is available.""" + return self._coordinator.last_update_success + + async def async_update(self): + """Update the state of switch.""" + await self._coordinator.async_request_refresh() diff --git a/homeassistant/components/progettihwsw/translations/en.json b/homeassistant/components/progettihwsw/translations/en.json new file mode 100644 index 00000000000..254620bcf8b --- /dev/null +++ b/homeassistant/components/progettihwsw/translations/en.json @@ -0,0 +1,40 @@ +{ + "config": { + "error": { + "cannot_connect": "Cannot connect to the board.", + "unknown": "Unknown error.", + "wrong_info_relay_modes": "Relay mode selection must be monostable or bistable." + }, + "step": { + "user": { + "title": "Set up board", + "data": { + "host": "Host", + "port": "Port Number" + } + }, + "relay_modes": { + "title": "Set up relays", + "data": { + "relay_1": "Relay 1", + "relay_2": "Relay 2", + "relay_3": "Relay 3", + "relay_4": "Relay 4", + "relay_5": "Relay 5", + "relay_6": "Relay 6", + "relay_7": "Relay 7", + "relay_8": "Relay 8", + "relay_9": "Relay 9", + "relay_10": "Relay 10", + "relay_11": "Relay 11", + "relay_12": "Relay 12", + "relay_13": "Relay 13", + "relay_14": "Relay 14", + "relay_15": "Relay 15", + "relay_16": "Relay 16" + } + } + } + }, + "title": "ProgettiHWSW Automation" + } \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index bc5470188b3..62877b614b1 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -140,6 +140,7 @@ FLOWS = [ "point", "poolsense", "powerwall", + "progettihwsw", "ps4", "pvpc_hourly_pricing", "rachio", diff --git a/requirements_all.txt b/requirements_all.txt index d4a91fc0bfb..3c17612b7bf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1115,6 +1115,9 @@ praw==7.1.0 # homeassistant.components.islamic_prayer_times prayer_times_calculator==0.0.3 +# homeassistant.components.progettihwsw +progettihwsw==0.1.1 + # homeassistant.components.proliphix proliphix==0.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 42240019185..21d610c2aba 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -539,6 +539,9 @@ praw==7.1.0 # homeassistant.components.islamic_prayer_times prayer_times_calculator==0.0.3 +# homeassistant.components.progettihwsw +progettihwsw==0.1.1 + # homeassistant.components.prometheus prometheus_client==0.7.1 diff --git a/tests/components/progettihwsw/__init__.py b/tests/components/progettihwsw/__init__.py new file mode 100644 index 00000000000..5049834cc10 --- /dev/null +++ b/tests/components/progettihwsw/__init__.py @@ -0,0 +1 @@ +"""Tests for the ProgettiHWSW Automation integration.""" diff --git a/tests/components/progettihwsw/test_config_flow.py b/tests/components/progettihwsw/test_config_flow.py new file mode 100644 index 00000000000..7da0ef82642 --- /dev/null +++ b/tests/components/progettihwsw/test_config_flow.py @@ -0,0 +1,168 @@ +"""Test the ProgettiHWSW Automation config flow.""" +from homeassistant import config_entries, setup +from homeassistant.components.progettihwsw.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM + +from tests.async_mock import patch + +mock_value_step_user = { + "title": "1R & 1IN Board", + "relay_count": 1, + "input_count": 1, + "is_old": False, +} + + +async def test_form(hass): + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + mock_value_step_rm = { + "relay_1": "bistable", # Mocking a single relay board instance. + } + + with patch( + "homeassistant.components.progettihwsw.config_flow.ProgettiHWSWAPI.check_board", + return_value=mock_value_step_user, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "", CONF_PORT: 80}, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["step_id"] == "relay_modes" + assert result2["errors"] == {} + + with patch( + "homeassistant.components.progettihwsw.async_setup", + return_value=True, + ), patch( + "homeassistant.components.progettihwsw.async_setup_entry", + return_value=True, + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + mock_value_step_rm, + ) + + assert result3["type"] == RESULT_TYPE_CREATE_ENTRY + assert result3["data"] + assert result3["data"]["title"] == "1R & 1IN Board" + assert result3["data"]["is_old"] is False + assert result3["data"]["relay_count"] == result3["data"]["input_count"] == 1 + + +async def test_form_wrong_info(hass): + """Test we handle wrong info exception.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.progettihwsw.config_flow.ProgettiHWSWAPI.check_board", + return_value=mock_value_step_user, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "", CONF_PORT: 80} + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["step_id"] == "relay_modes" + assert result2["errors"] == {} + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], {"relay_1": ""} + ) + + assert result3["type"] == RESULT_TYPE_FORM + assert result3["step_id"] == "relay_modes" + assert result3["errors"] == {"base": "wrong_info_relay_modes"} + + +async def test_form_cannot_connect(hass): + """Test we handle unexisting board.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.progettihwsw.config_flow.ProgettiHWSWAPI.check_board", + return_value=False, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "", CONF_PORT: 80}, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_user_exception(hass): + """Test we handle unknown exception.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.progettihwsw.config_flow.validate_input", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "", CONF_PORT: 80}, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "unknown"} + + +async def test_form_rm_exception(hass): + """Test we handle unknown exception on seconds step.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.progettihwsw.config_flow.ProgettiHWSWAPI.check_board", + return_value=mock_value_step_user, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "", CONF_PORT: 80}, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["step_id"] == "relay_modes" + assert result2["errors"] == {} + + with patch( + "homeassistant.components.progettihwsw.config_flow.validate_input_relay_modes", + side_effect=Exception, + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {"relay_1": "bistable"}, + ) + + assert result3["type"] == RESULT_TYPE_FORM + assert result3["step_id"] == "relay_modes" + assert result3["errors"] == {"base": "unknown"}