diff --git a/.coveragerc b/.coveragerc index 550bf857e1bb43..63f03ea9bd9b2d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -518,6 +518,7 @@ omit = homeassistant/components/neato/vacuum.py homeassistant/components/nederlandse_spoorwegen/sensor.py homeassistant/components/nello/lock.py + homeassistant/components/neptune_apex/* homeassistant/components/nest/* homeassistant/components/netatmo/__init__.py homeassistant/components/netatmo/api.py diff --git a/CODEOWNERS b/CODEOWNERS index 37f3aa30936ce9..191ffe2838650d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -262,6 +262,7 @@ homeassistant/components/mystrom/* @fabaff homeassistant/components/neato/* @dshokouhi @Santobert homeassistant/components/nederlandse_spoorwegen/* @YarmoM homeassistant/components/nello/* @pschmitt +homeassistant/components/neptune_apex/* @jpelzer homeassistant/components/ness_alarm/* @nickw444 homeassistant/components/nest/* @awarecan homeassistant/components/netatmo/* @cgtobi diff --git a/homeassistant/components/neptune_apex/__init__.py b/homeassistant/components/neptune_apex/__init__.py new file mode 100644 index 00000000000000..17ac198f8eab30 --- /dev/null +++ b/homeassistant/components/neptune_apex/__init__.py @@ -0,0 +1,77 @@ +"""The Neptune Apex integration.""" +import asyncio +from datetime import timedelta +import logging + +import async_timeout +from pynepsys import Apex, ApexException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +PLATFORMS = ["light"] + +NEPTUNE_APEX = "neptune_apex" +NEPTUNE_APEX_COORDINATOR = "neptune_apex_coordinator" + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Neptune Apex component.""" + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Neptune Apex from a config entry.""" + apex = Apex(entry.data["host"], entry.data["username"], entry.data["password"]) + hass.data[NEPTUNE_APEX] = apex + + async def async_update_data(): + """Fetch data for all outlets and probes at once.""" + try: + async with async_timeout.timeout(10): + await apex.fetch_current_state() + except ApexException as err: + raise UpdateFailed(f"Error communicating with Apex: {err}") + return apex + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + # Name of the data. For logging purposes. + name="neptune_apex", + update_method=async_update_data, + # Polling interval. Will only be polled if there are subscribers. + update_interval=timedelta(seconds=30), + ) + hass.data[NEPTUNE_APEX_COORDINATOR] = coordinator + + # Fetch initial data so we have data when entities subscribe + await coordinator.async_refresh() + + 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 diff --git a/homeassistant/components/neptune_apex/config_flow.py b/homeassistant/components/neptune_apex/config_flow.py new file mode 100644 index 00000000000000..b7c114aeea5b17 --- /dev/null +++ b/homeassistant/components/neptune_apex/config_flow.py @@ -0,0 +1,94 @@ +"""Config flow for Neptune Apex integration.""" +import logging + +from pynepsys import Apex +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME + +from .const import DOMAIN # pylint:disable=unused-import + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class ApexHub: + """Handle authentication validation during config flow.""" + + def __init__(self, host): + """Initialize.""" + self.host = host + self.apex = None + + async def authenticate(self, username, password) -> bool: + """Test if we can authenticate with the host.""" + self.apex = Apex(self.host, username, password) + result = await self.apex.validate_connection() + if result == "success": + return True + if result == "invalid_auth": + raise InvalidAuth + raise CannotConnect + + async def get_serial_number(self) -> str: + """Read the Apex serial number from the device previously connected to via authenticate.""" + await self.apex.fetch_current_state() + return self.apex.serial + + +async def validate_input(hass: core.HomeAssistant, data): + """Validate the user input allows us to connect. + + Data has the keys from DATA_SCHEMA with values provided by the user. + """ + hub = ApexHub(data[CONF_HOST]) + + await hub.authenticate(data[CONF_USERNAME], data[CONF_PASSWORD]) + serial = await hub.get_serial_number() + + # Return info that you want to store in the config entry. + return {"title": "Neptune Apex", "serial": serial} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Neptune Apex.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + 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) + await self.async_set_unique_id(info["serial"]) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=info["title"], data=user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + 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 connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/neptune_apex/const.py b/homeassistant/components/neptune_apex/const.py new file mode 100644 index 00000000000000..1a617bceb573f6 --- /dev/null +++ b/homeassistant/components/neptune_apex/const.py @@ -0,0 +1,3 @@ +"""Constants for the Neptune Apex integration.""" + +DOMAIN = "neptune_apex" diff --git a/homeassistant/components/neptune_apex/light.py b/homeassistant/components/neptune_apex/light.py new file mode 100644 index 00000000000000..888e48a40aecdc --- /dev/null +++ b/homeassistant/components/neptune_apex/light.py @@ -0,0 +1,126 @@ +"""Support outlet status/control from Neptune Apex.""" +import logging + +import pynepsys + +from homeassistant.components.light import ATTR_EFFECT, SUPPORT_EFFECT, Light + +from . import NEPTUNE_APEX, NEPTUNE_APEX_COORDINATOR + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, entry, async_add_entities): + """Parse through the Apex outlets and creates Outlet entities.""" + apex = hass.data[NEPTUNE_APEX] + coordinator = hass.data[NEPTUNE_APEX_COORDINATOR] + + async_add_entities( + Outlet(coordinator, apex, name) for name in coordinator.data.outlets + ) + + +class Outlet(Light): + """Shoehorn the Apex outlet concept into a hass Light entity.""" + + def __init__(self, coordinator, apex: pynepsys.Apex, name): + """Initialize this outlet, storing reference to parent Apex.""" + self.coordinator = coordinator + self._name = name + self.apex = apex + self.outlet: pynepsys.Outlet = apex.outlets[name] + + @property + def name(self): + """Name of this outlet (From the Apex), prefixed.""" + return f"apex.{self._name}" + + @property + def unique_id(self): + """Name of this outlet as defined in the Apex.""" + return f"{self.apex.serial}_{self.outlet.device_id}" + + @property + def is_on(self): + """Return True if the outlet is manually forced on or in AUTO mode and on.""" + return self.outlet.is_on() + + @property + def supported_features(self): + """We support effects as a hack to allow AUTO mode to exist.""" + return SUPPORT_EFFECT + + @property + def effect(self): + """Return the current state of the outlet. + + ON, OFF, AUTO are the useful effects for this integration, but you can + read profile names for pumps and lights via this property as well. + """ + return self.outlet.state + + @property + def effect_list(self): + """Return the profiles you can actually put this outlet into. + + AUTO represents automatic mode, ON/OFF being manually forced on/off + ignoring outlet programming. + """ + return ["AUTO", "ON", "OFF"] + + async def async_turn_on(self, **kwargs): + """Turn the outlet on except if an effect is defined. + + If effect is AUTO, ON, or OFF, put the outlet into that mode. + """ + if kwargs.get(ATTR_EFFECT) == "AUTO": + _LOGGER.debug("Enabling AUTO for outlet %s", self.name) + self.outlet.enable_auto() + await self.apex.update_outlet(self.outlet) + self.async_schedule_update_ha_state(True) + _LOGGER.debug("Just turned on AUTO. is_on = %s", self.outlet.is_on()) + elif kwargs.get(ATTR_EFFECT) == "ON": + await self.async_turn_on() + elif kwargs.get(ATTR_EFFECT) == "OFF": + await self.async_turn_off() + else: + _LOGGER.debug("Turning outlet ON for %s", self.name) + self.outlet.force_on() + await self.apex.update_outlet(self.outlet) + self.async_schedule_update_ha_state() + _LOGGER.debug("Just turned on outlet. is_on = %s", self.outlet.is_on()) + + async def async_turn_off(self, **kwargs): + """Turn the outlet off by forcing off, disabling AUTO if enabled.""" + self.outlet.force_off() + await self.apex.update_outlet(self.outlet) + self.async_schedule_update_ha_state() + _LOGGER.debug("Just turned off outlet. is_on = %s", self.outlet.is_on()) + + @property + def icon(self): + """We have a custom icon of a power socket.""" + return "mdi:power-socket-us" + + @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_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_update(self): + """Update the entity. + + Only used by the generic entity update service. + """ + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/neptune_apex/manifest.json b/homeassistant/components/neptune_apex/manifest.json new file mode 100644 index 00000000000000..7baae0638a3b57 --- /dev/null +++ b/homeassistant/components/neptune_apex/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "neptune_apex", + "name": "Neptune Apex", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/neptune_apex", + "requirements": ["pynepsys==1.2.1"], + "codeowners": [ + "@jpelzer" + ] +} diff --git a/homeassistant/components/neptune_apex/strings.json b/homeassistant/components/neptune_apex/strings.json new file mode 100644 index 00000000000000..c7e2ea8845bc12 --- /dev/null +++ b/homeassistant/components/neptune_apex/strings.json @@ -0,0 +1,23 @@ +{ + "title": "Neptune Apex", + "config": { + "step": { + "user": { + "title": "Connect to Neptune Systems Apex Aquacontroller", + "data": { + "host": "[%key:common::config_flow::data::ip%]", + "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%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/neptune_apex/translations/en.json b/homeassistant/components/neptune_apex/translations/en.json new file mode 100644 index 00000000000000..fe5e030ad1b350 --- /dev/null +++ b/homeassistant/components/neptune_apex/translations/en.json @@ -0,0 +1,24 @@ +{ + "title": "Neptune Apex", + "config": { + "step": { + "user": { + "title": "Connect to Neptune Systems Apex", + "description": "", + "data": { + "host": "IP Address", + "username": "Username", + "password": "Password" + } + } + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "Device is already configured" + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index e971c5dc4b91ab..63af9714fd359c 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -101,6 +101,7 @@ "mqtt", "myq", "neato", + "neptune_apex", "nest", "netatmo", "nexia", diff --git a/requirements_all.txt b/requirements_all.txt index 7a453b6f086eff..1d0f10606f2639 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1485,6 +1485,9 @@ pynanoleaf==0.0.5 # homeassistant.components.nello pynello==2.0.2 +# homeassistant.components.neptune_apex +pynepsys==1.2.1 + # homeassistant.components.netgear pynetgear==0.6.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fda697b3aad402..9de36f46436518 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -647,6 +647,9 @@ pymonoprice==0.3 # homeassistant.components.myq pymyq==2.0.3 +# homeassistant.components.neptune_apex +pynepsys==1.2.1 + # homeassistant.components.nut pynut2==2.1.2 diff --git a/tests/components/neptune_apex/__init__.py b/tests/components/neptune_apex/__init__.py new file mode 100644 index 00000000000000..2ae60e2352f22b --- /dev/null +++ b/tests/components/neptune_apex/__init__.py @@ -0,0 +1 @@ +"""Tests for the Neptune Apex integration.""" diff --git a/tests/components/neptune_apex/test_config_flow.py b/tests/components/neptune_apex/test_config_flow.py new file mode 100644 index 00000000000000..e765b0d1d4dccb --- /dev/null +++ b/tests/components/neptune_apex/test_config_flow.py @@ -0,0 +1,90 @@ +"""Test the Neptune Apex config flow.""" +from homeassistant import config_entries, setup +from homeassistant.components.neptune_apex.config_flow import CannotConnect, InvalidAuth +from homeassistant.components.neptune_apex.const import DOMAIN + +from tests.async_mock import patch + + +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"] == "form" + assert result["errors"] == {} + + with patch( + "homeassistant.components.neptune_apex.config_flow.Apex.validate_connection", + return_value=True, + ), patch( + "homeassistant.components.neptune_apex.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.neptune_apex.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "Neptune Apex" + assert result2["data"] == { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + } + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth(hass): + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.neptune_apex.config_flow.ApexHub.authenticate", + side_effect=InvalidAuth, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.neptune_apex.config_flow.ApexHub.authenticate", + side_effect=CannotConnect, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"}