From dd6bfe10322cc9f272a0a2569359c776e7b94af5 Mon Sep 17 00:00:00 2001 From: Jason Pelzer Date: Wed, 22 Apr 2020 16:05:00 -0400 Subject: [PATCH 01/10] Added support for Neptune Apex integration. --- CODEOWNERS | 1 + .../neptune_apex/.translations/en.json | 21 +++ .../components/neptune_apex/__init__.py | 80 +++++++++++ .../components/neptune_apex/config_flow.py | 81 +++++++++++ .../components/neptune_apex/const.py | 3 + .../components/neptune_apex/light.py | 131 ++++++++++++++++++ .../components/neptune_apex/manifest.json | 14 ++ .../components/neptune_apex/sensor.py | 89 ++++++++++++ .../components/neptune_apex/strings.json | 21 +++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/neptune_apex/__init__.py | 1 + .../neptune_apex/test_config_flow.py | 90 ++++++++++++ 14 files changed, 539 insertions(+) create mode 100644 homeassistant/components/neptune_apex/.translations/en.json create mode 100644 homeassistant/components/neptune_apex/__init__.py create mode 100644 homeassistant/components/neptune_apex/config_flow.py create mode 100644 homeassistant/components/neptune_apex/const.py create mode 100644 homeassistant/components/neptune_apex/light.py create mode 100644 homeassistant/components/neptune_apex/manifest.json create mode 100644 homeassistant/components/neptune_apex/sensor.py create mode 100644 homeassistant/components/neptune_apex/strings.json create mode 100644 tests/components/neptune_apex/__init__.py create mode 100644 tests/components/neptune_apex/test_config_flow.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/.translations/en.json b/homeassistant/components/neptune_apex/.translations/en.json new file mode 100644 index 00000000000000..60a71e52c55072 --- /dev/null +++ b/homeassistant/components/neptune_apex/.translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "title": "Connect to the device" + } + } + }, + "title": "Neptune Apex" +} \ No newline at end of file diff --git a/homeassistant/components/neptune_apex/__init__.py b/homeassistant/components/neptune_apex/__init__.py new file mode 100644 index 00000000000000..91d5ac0c6b983b --- /dev/null +++ b/homeassistant/components/neptune_apex/__init__.py @@ -0,0 +1,80 @@ +"""The Neptune Apex integration.""" +import asyncio +from datetime import timedelta +import logging + +import async_timeout +from pynepsys import Apex +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA) + +PLATFORMS = ["sensor", "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 Exception 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..b671cd36d42fbe --- /dev/null +++ b/homeassistant/components/neptune_apex/config_flow.py @@ -0,0 +1,81 @@ +"""Config flow for Neptune Apex integration.""" +import logging + +from pynepsys import Apex +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions + +from .const import DOMAIN # pylint:disable=unused-import + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema({"host": str, "username": str, "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 + elif result == "invalid_auth": + raise InvalidAuth + raise CannotConnect + + +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["host"]) + + if not await hub.authenticate(data["username"], data["password"]): + raise InvalidAuth + + # Return info that you want to store in the config entry. + return {"title": "Neptune Apex"} + + +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) + + 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..3f25a7c7c1e97f --- /dev/null +++ b/homeassistant/components/neptune_apex/light.py @@ -0,0 +1,131 @@ +"""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 i, name in enumerate(coordinator.data.outlets) + ) + + +class Outlet(Light): + """Shoehorn the Apex outlet concept into a hass Light entity. + + This feels gross, but the more you play with Apex, you realize that once you get past + a certain point of revulsion, there is beauty in it. And madness. + """ + + 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 "apex." + self._name + + @property + def unique_id(self): + """Name of this outlet as defined in the Apex.""" + return self.name + + @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(f"Enabling AUTO for outlet {self.name}") + self.outlet.enable_auto() + await self.apex.update_outlet(self.outlet) + self.async_schedule_update_ha_state(True) + _LOGGER.debug(f"Just turned on AUTO. is_on = {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(f"Turning outlet ON for {self.name}") + self.outlet.force_on() + await self.apex.update_outlet(self.outlet) + self.async_schedule_update_ha_state() + _LOGGER.debug(f"Just turned on outlet. is_on = {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(f"Just turned off outlet. is_on = {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..01d2aff41f6e9b --- /dev/null +++ b/homeassistant/components/neptune_apex/manifest.json @@ -0,0 +1,14 @@ +{ + "domain": "neptune_apex", + "name": "Neptune Apex", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/neptune_apex", + "requirements": ["pynepsys==1.1.0"], + "ssdp": [], + "zeroconf": [], + "homekit": {}, + "dependencies": [], + "codeowners": [ + "@jpelzer" + ] +} \ No newline at end of file diff --git a/homeassistant/components/neptune_apex/sensor.py b/homeassistant/components/neptune_apex/sensor.py new file mode 100644 index 00000000000000..ca766a87bf0652 --- /dev/null +++ b/homeassistant/components/neptune_apex/sensor.py @@ -0,0 +1,89 @@ +"""Support for getting probe data from Neptune Apex.""" +import logging + +import pynepsys + +from homeassistant.helpers.entity import Entity + +from . import NEPTUNE_APEX, NEPTUNE_APEX_COORDINATOR + +_LOGGER = logging.getLogger(__name__) + +ICON = "mdi:thermometer-lines" + + +async def async_setup_entry(hass, entry, async_add_entities): + """Parse through the Apex probes and creates Probe entities.""" + apex = hass.data[NEPTUNE_APEX] + coordinator = hass.data[NEPTUNE_APEX_COORDINATOR] + + async_add_entities( + Probe(coordinator, apex, name) for i, name in enumerate(coordinator.data.probes) + ) + + +class Probe(Entity): + """Abstract representation of Apex probes.""" + + def __init__(self, coordinator, apex: pynepsys.Apex, name): + """Initialize this probe, storing reference to parent Apex.""" + self.coordinator = coordinator + self._name = name + self.probe = apex.probes[name] + + @property + def unit_of_measurement(self): + """Return the probe type.""" + return self.probe.type + + @property + def device_class(self): + """Return device type for temperature and power probes.""" + if self.probe_type == "Temp": + return "temperature" + if self.probe_type == "Amps": + return "power" + return None + + @property + def probe_type(self): + """Return the probe type.""" + return self.probe.type + + @property + def state(self): + """Return the current value of the probe.""" + return self.probe.value + + @property + def name(self): + """Name of this probe (From the Apex), prefixed.""" + return "apex." + self._name + + @property + def unique_id(self): + """Name of this probe as defined in the Apex.""" + return self.name + + @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/strings.json b/homeassistant/components/neptune_apex/strings.json new file mode 100644 index 00000000000000..3a3ea7492e1059 --- /dev/null +++ b/homeassistant/components/neptune_apex/strings.json @@ -0,0 +1,21 @@ +{ + "title": "Neptune Apex", + "config": { + "step": { + "user": { + "title": "Connect to the device", + "data": { + "host": "Host" + } + } + }, + "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..69cd760715d376 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.1.0 + # homeassistant.components.netgear pynetgear==0.6.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fda697b3aad402..7329652909dae7 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.1.0 + # 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..618966f99d623f --- /dev/null +++ b/tests/components/neptune_apex/test_config_flow.py @@ -0,0 +1,90 @@ +"""Test the Neptune Apex config flow.""" +from asynctest import patch + +from homeassistant import config_entries, setup +from homeassistant.components.neptune_apex.config_flow import CannotConnect, InvalidAuth +from homeassistant.components.neptune_apex.const import DOMAIN + + +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.ApexHub.authenticate", + 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"] == "Name of the device" + 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.PlaceholderHub.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"} From 7db5fac23ddf6192f5c1a9a0d0181a82ee664dca Mon Sep 17 00:00:00 2001 From: Jason Pelzer Date: Wed, 22 Apr 2020 16:33:01 -0400 Subject: [PATCH 02/10] Updated config flow with better text and using constants. --- .../neptune_apex/.translations/en.json | 21 ---------------- .../components/neptune_apex/config_flow.py | 13 +++++++--- .../components/neptune_apex/strings.json | 7 ++++-- .../neptune_apex/translations/en.json | 24 +++++++++++++++++++ 4 files changed, 39 insertions(+), 26 deletions(-) delete mode 100644 homeassistant/components/neptune_apex/.translations/en.json create mode 100644 homeassistant/components/neptune_apex/translations/en.json diff --git a/homeassistant/components/neptune_apex/.translations/en.json b/homeassistant/components/neptune_apex/.translations/en.json deleted file mode 100644 index 60a71e52c55072..00000000000000 --- a/homeassistant/components/neptune_apex/.translations/en.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Device is already configured" - }, - "error": { - "cannot_connect": "Failed to connect, please try again", - "invalid_auth": "Invalid authentication", - "unknown": "Unexpected error" - }, - "step": { - "user": { - "data": { - "host": "Host" - }, - "title": "Connect to the device" - } - } - }, - "title": "Neptune Apex" -} \ No newline at end of file diff --git a/homeassistant/components/neptune_apex/config_flow.py b/homeassistant/components/neptune_apex/config_flow.py index b671cd36d42fbe..e9643fbad86360 100644 --- a/homeassistant/components/neptune_apex/config_flow.py +++ b/homeassistant/components/neptune_apex/config_flow.py @@ -5,12 +5,19 @@ 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({"host": str, "username": str, "password": str}) +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) class ApexHub: @@ -37,9 +44,9 @@ async def validate_input(hass: core.HomeAssistant, data): Data has the keys from DATA_SCHEMA with values provided by the user. """ - hub = ApexHub(data["host"]) + hub = ApexHub(data[CONF_HOST]) - if not await hub.authenticate(data["username"], data["password"]): + if not await hub.authenticate(data[CONF_USERNAME], data[CONF_PASSWORD]): raise InvalidAuth # Return info that you want to store in the config entry. diff --git a/homeassistant/components/neptune_apex/strings.json b/homeassistant/components/neptune_apex/strings.json index 3a3ea7492e1059..83485b4f3f8199 100644 --- a/homeassistant/components/neptune_apex/strings.json +++ b/homeassistant/components/neptune_apex/strings.json @@ -3,9 +3,12 @@ "config": { "step": { "user": { - "title": "Connect to the device", + "title": "Connect to Neptune Systems Apex Aquacontroller", + "description": "", "data": { - "host": "Host" + "host": "IP Address", + "username": "Username", + "password": "Password" } } }, 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 From 0d0915355e913e75761a14b2dcfb46d87258334c Mon Sep 17 00:00:00 2001 From: Jason Pelzer Date: Wed, 22 Apr 2020 18:05:21 -0400 Subject: [PATCH 03/10] Fixed bonehead unit test mistakes. --- tests/components/neptune_apex/test_config_flow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/neptune_apex/test_config_flow.py b/tests/components/neptune_apex/test_config_flow.py index 618966f99d623f..5673ed1f47aad0 100644 --- a/tests/components/neptune_apex/test_config_flow.py +++ b/tests/components/neptune_apex/test_config_flow.py @@ -33,7 +33,7 @@ async def test_form(hass): ) assert result2["type"] == "create_entry" - assert result2["title"] == "Name of the device" + assert result2["title"] == "Neptune Apex" assert result2["data"] == { "host": "1.1.1.1", "username": "test-username", @@ -74,7 +74,7 @@ async def test_form_cannot_connect(hass): ) with patch( - "homeassistant.components.neptune_apex.config_flow.PlaceholderHub.authenticate", + "homeassistant.components.neptune_apex.config_flow.ApexHub.authenticate", side_effect=CannotConnect, ): result2 = await hass.config_entries.flow.async_configure( From 0c40fbdb723761180ccc43bc6c22570ba82c256d Mon Sep 17 00:00:00 2001 From: Jason Pelzer Date: Wed, 22 Apr 2020 20:46:44 -0400 Subject: [PATCH 04/10] Fuxed some pylint errors. --- homeassistant/components/neptune_apex/config_flow.py | 2 +- homeassistant/components/neptune_apex/light.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/neptune_apex/config_flow.py b/homeassistant/components/neptune_apex/config_flow.py index e9643fbad86360..5a95efdaff81d8 100644 --- a/homeassistant/components/neptune_apex/config_flow.py +++ b/homeassistant/components/neptune_apex/config_flow.py @@ -34,7 +34,7 @@ async def authenticate(self, username, password) -> bool: result = await self.apex.validate_connection() if result == "success": return True - elif result == "invalid_auth": + if result == "invalid_auth": raise InvalidAuth raise CannotConnect diff --git a/homeassistant/components/neptune_apex/light.py b/homeassistant/components/neptune_apex/light.py index 3f25a7c7c1e97f..857d4de33889d8 100644 --- a/homeassistant/components/neptune_apex/light.py +++ b/homeassistant/components/neptune_apex/light.py @@ -79,28 +79,28 @@ async def async_turn_on(self, **kwargs): If effect is AUTO, ON, or OFF, put the outlet into that mode. """ if kwargs.get(ATTR_EFFECT) == "AUTO": - _LOGGER.debug(f"Enabling AUTO for outlet {self.name}") + _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(f"Just turned on AUTO. is_on = {self.outlet.is_on()}") + _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(f"Turning outlet ON for {self.name}") + _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(f"Just turned on outlet. is_on = {self.outlet.is_on()}") + _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(f"Just turned off outlet. is_on = {self.outlet.is_on()}") + _LOGGER.debug("Just turned off outlet. is_on = %s", self.outlet.is_on()) @property def icon(self): From 8f6564150bdcd3507b014d64fb64c5841fd88514 Mon Sep 17 00:00:00 2001 From: Jason Pelzer Date: Wed, 22 Apr 2020 23:40:58 -0400 Subject: [PATCH 05/10] The best way to up your coverage percentage is to exclude all your code. --- .coveragerc | 1 + 1 file changed, 1 insertion(+) 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 From 3f160a1d62610aff3d9d35740cdae87302fe6689 Mon Sep 17 00:00:00 2001 From: Jason Pelzer Date: Tue, 9 Jun 2020 19:19:29 -0400 Subject: [PATCH 06/10] Apply suggestions from code review Co-authored-by: Martin Hjelmare --- homeassistant/components/neptune_apex/__init__.py | 2 +- homeassistant/components/neptune_apex/light.py | 4 ++-- homeassistant/components/neptune_apex/manifest.json | 6 +----- tests/components/neptune_apex/test_config_flow.py | 2 +- 4 files changed, 5 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/neptune_apex/__init__.py b/homeassistant/components/neptune_apex/__init__.py index 91d5ac0c6b983b..cef4061da3ca77 100644 --- a/homeassistant/components/neptune_apex/__init__.py +++ b/homeassistant/components/neptune_apex/__init__.py @@ -15,7 +15,7 @@ CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA) -PLATFORMS = ["sensor", "light"] +PLATFORMS = ["light", "sensor"] NEPTUNE_APEX = "neptune_apex" NEPTUNE_APEX_COORDINATOR = "neptune_apex_coordinator" diff --git a/homeassistant/components/neptune_apex/light.py b/homeassistant/components/neptune_apex/light.py index 857d4de33889d8..8c68c5fa7d342b 100644 --- a/homeassistant/components/neptune_apex/light.py +++ b/homeassistant/components/neptune_apex/light.py @@ -17,7 +17,7 @@ async def async_setup_entry(hass, entry, async_add_entities): async_add_entities( Outlet(coordinator, apex, name) - for i, name in enumerate(coordinator.data.outlets) + for name in coordinator.data.outlets ) @@ -38,7 +38,7 @@ def __init__(self, coordinator, apex: pynepsys.Apex, name): @property def name(self): """Name of this outlet (From the Apex), prefixed.""" - return "apex." + self._name + return "apex.{self._name}" @property def unique_id(self): diff --git a/homeassistant/components/neptune_apex/manifest.json b/homeassistant/components/neptune_apex/manifest.json index 01d2aff41f6e9b..8da781a644e5c6 100644 --- a/homeassistant/components/neptune_apex/manifest.json +++ b/homeassistant/components/neptune_apex/manifest.json @@ -4,11 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/neptune_apex", "requirements": ["pynepsys==1.1.0"], - "ssdp": [], - "zeroconf": [], - "homekit": {}, - "dependencies": [], "codeowners": [ "@jpelzer" ] -} \ No newline at end of file +} diff --git a/tests/components/neptune_apex/test_config_flow.py b/tests/components/neptune_apex/test_config_flow.py index 5673ed1f47aad0..de04d18ca55b2a 100644 --- a/tests/components/neptune_apex/test_config_flow.py +++ b/tests/components/neptune_apex/test_config_flow.py @@ -16,7 +16,7 @@ async def test_form(hass): assert result["errors"] == {} with patch( - "homeassistant.components.neptune_apex.config_flow.ApexHub.authenticate", + "homeassistant.components.neptune_apex.config_flow.Apex.validate_connection", return_value=True, ), patch( "homeassistant.components.neptune_apex.async_setup", return_value=True From 93e822df259c94f8ce4172358cb2d2012a7551ad Mon Sep 17 00:00:00 2001 From: Jason Pelzer Date: Wed, 10 Jun 2020 17:14:44 -0400 Subject: [PATCH 07/10] Improvements from PR comments. --- homeassistant/components/neptune_apex/__init__.py | 3 --- .../components/neptune_apex/config_flow.py | 14 ++++++++++---- homeassistant/components/neptune_apex/light.py | 9 ++------- .../components/neptune_apex/strings.json | 15 +++++++-------- tests/components/neptune_apex/test_config_flow.py | 4 ++-- 5 files changed, 21 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/neptune_apex/__init__.py b/homeassistant/components/neptune_apex/__init__.py index cef4061da3ca77..3f9fdd679d1615 100644 --- a/homeassistant/components/neptune_apex/__init__.py +++ b/homeassistant/components/neptune_apex/__init__.py @@ -5,7 +5,6 @@ import async_timeout from pynepsys import Apex -import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -13,8 +12,6 @@ from .const import DOMAIN -CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA) - PLATFORMS = ["light", "sensor"] NEPTUNE_APEX = "neptune_apex" diff --git a/homeassistant/components/neptune_apex/config_flow.py b/homeassistant/components/neptune_apex/config_flow.py index 5a95efdaff81d8..b7c114aeea5b17 100644 --- a/homeassistant/components/neptune_apex/config_flow.py +++ b/homeassistant/components/neptune_apex/config_flow.py @@ -38,6 +38,11 @@ async def authenticate(self, username, password) -> bool: 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. @@ -46,11 +51,11 @@ async def validate_input(hass: core.HomeAssistant, data): """ hub = ApexHub(data[CONF_HOST]) - if not await hub.authenticate(data[CONF_USERNAME], data[CONF_PASSWORD]): - raise InvalidAuth + 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"} + return {"title": "Neptune Apex", "serial": serial} class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -65,7 +70,8 @@ async def async_step_user(self, user_input=None): 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" diff --git a/homeassistant/components/neptune_apex/light.py b/homeassistant/components/neptune_apex/light.py index 8c68c5fa7d342b..49b9e9dda2ca3c 100644 --- a/homeassistant/components/neptune_apex/light.py +++ b/homeassistant/components/neptune_apex/light.py @@ -16,17 +16,12 @@ async def async_setup_entry(hass, entry, async_add_entities): coordinator = hass.data[NEPTUNE_APEX_COORDINATOR] async_add_entities( - Outlet(coordinator, apex, name) - for name in coordinator.data.outlets + Outlet(coordinator, apex, name) for name in coordinator.data.outlets ) class Outlet(Light): - """Shoehorn the Apex outlet concept into a hass Light entity. - - This feels gross, but the more you play with Apex, you realize that once you get past - a certain point of revulsion, there is beauty in it. And madness. - """ + """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.""" diff --git a/homeassistant/components/neptune_apex/strings.json b/homeassistant/components/neptune_apex/strings.json index 83485b4f3f8199..c7e2ea8845bc12 100644 --- a/homeassistant/components/neptune_apex/strings.json +++ b/homeassistant/components/neptune_apex/strings.json @@ -4,21 +4,20 @@ "step": { "user": { "title": "Connect to Neptune Systems Apex Aquacontroller", - "description": "", "data": { - "host": "IP Address", - "username": "Username", - "password": "Password" + "host": "[%key:common::config_flow::data::ip%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" } } }, "error": { - "cannot_connect": "Failed to connect, please try again", - "invalid_auth": "Invalid authentication", - "unknown": "Unexpected 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": "Device is already configured" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } } } \ No newline at end of file diff --git a/tests/components/neptune_apex/test_config_flow.py b/tests/components/neptune_apex/test_config_flow.py index de04d18ca55b2a..e765b0d1d4dccb 100644 --- a/tests/components/neptune_apex/test_config_flow.py +++ b/tests/components/neptune_apex/test_config_flow.py @@ -1,10 +1,10 @@ """Test the Neptune Apex config flow.""" -from asynctest import patch - 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.""" From ae857bf9dec997a93250acd629e1903597a07862 Mon Sep 17 00:00:00 2001 From: Jason Pelzer Date: Wed, 10 Jun 2020 17:59:11 -0400 Subject: [PATCH 08/10] Removed Sensor.py to allow first pull request through. --- .../components/neptune_apex/__init__.py | 2 +- .../components/neptune_apex/light.py | 4 +- .../components/neptune_apex/sensor.py | 89 ------------------- 3 files changed, 3 insertions(+), 92 deletions(-) delete mode 100644 homeassistant/components/neptune_apex/sensor.py diff --git a/homeassistant/components/neptune_apex/__init__.py b/homeassistant/components/neptune_apex/__init__.py index 3f9fdd679d1615..b5b464a004fe05 100644 --- a/homeassistant/components/neptune_apex/__init__.py +++ b/homeassistant/components/neptune_apex/__init__.py @@ -12,7 +12,7 @@ from .const import DOMAIN -PLATFORMS = ["light", "sensor"] +PLATFORMS = ["light"] NEPTUNE_APEX = "neptune_apex" NEPTUNE_APEX_COORDINATOR = "neptune_apex_coordinator" diff --git a/homeassistant/components/neptune_apex/light.py b/homeassistant/components/neptune_apex/light.py index 49b9e9dda2ca3c..39c7bfe572adac 100644 --- a/homeassistant/components/neptune_apex/light.py +++ b/homeassistant/components/neptune_apex/light.py @@ -33,12 +33,12 @@ def __init__(self, coordinator, apex: pynepsys.Apex, name): @property def name(self): """Name of this outlet (From the Apex), prefixed.""" - return "apex.{self._name}" + return f"apex.{self._name}" @property def unique_id(self): """Name of this outlet as defined in the Apex.""" - return self.name + return f"apex.{self.apex.serial}.{self.outlet.device_id}" @property def is_on(self): diff --git a/homeassistant/components/neptune_apex/sensor.py b/homeassistant/components/neptune_apex/sensor.py deleted file mode 100644 index ca766a87bf0652..00000000000000 --- a/homeassistant/components/neptune_apex/sensor.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Support for getting probe data from Neptune Apex.""" -import logging - -import pynepsys - -from homeassistant.helpers.entity import Entity - -from . import NEPTUNE_APEX, NEPTUNE_APEX_COORDINATOR - -_LOGGER = logging.getLogger(__name__) - -ICON = "mdi:thermometer-lines" - - -async def async_setup_entry(hass, entry, async_add_entities): - """Parse through the Apex probes and creates Probe entities.""" - apex = hass.data[NEPTUNE_APEX] - coordinator = hass.data[NEPTUNE_APEX_COORDINATOR] - - async_add_entities( - Probe(coordinator, apex, name) for i, name in enumerate(coordinator.data.probes) - ) - - -class Probe(Entity): - """Abstract representation of Apex probes.""" - - def __init__(self, coordinator, apex: pynepsys.Apex, name): - """Initialize this probe, storing reference to parent Apex.""" - self.coordinator = coordinator - self._name = name - self.probe = apex.probes[name] - - @property - def unit_of_measurement(self): - """Return the probe type.""" - return self.probe.type - - @property - def device_class(self): - """Return device type for temperature and power probes.""" - if self.probe_type == "Temp": - return "temperature" - if self.probe_type == "Amps": - return "power" - return None - - @property - def probe_type(self): - """Return the probe type.""" - return self.probe.type - - @property - def state(self): - """Return the current value of the probe.""" - return self.probe.value - - @property - def name(self): - """Name of this probe (From the Apex), prefixed.""" - return "apex." + self._name - - @property - def unique_id(self): - """Name of this probe as defined in the Apex.""" - return self.name - - @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() From f7210c09a471a0b0d64dd7b2c95208dd74c56e4a Mon Sep 17 00:00:00 2001 From: Jason Pelzer Date: Wed, 10 Jun 2020 18:52:19 -0400 Subject: [PATCH 09/10] No longer catches overbroad Exception, looks for ApexException --- homeassistant/components/neptune_apex/__init__.py | 4 ++-- homeassistant/components/neptune_apex/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/neptune_apex/__init__.py b/homeassistant/components/neptune_apex/__init__.py index b5b464a004fe05..17ac198f8eab30 100644 --- a/homeassistant/components/neptune_apex/__init__.py +++ b/homeassistant/components/neptune_apex/__init__.py @@ -4,7 +4,7 @@ import logging import async_timeout -from pynepsys import Apex +from pynepsys import Apex, ApexException from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -35,7 +35,7 @@ async def async_update_data(): try: async with async_timeout.timeout(10): await apex.fetch_current_state() - except Exception as err: + except ApexException as err: raise UpdateFailed(f"Error communicating with Apex: {err}") return apex diff --git a/homeassistant/components/neptune_apex/manifest.json b/homeassistant/components/neptune_apex/manifest.json index 8da781a644e5c6..7baae0638a3b57 100644 --- a/homeassistant/components/neptune_apex/manifest.json +++ b/homeassistant/components/neptune_apex/manifest.json @@ -3,7 +3,7 @@ "name": "Neptune Apex", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/neptune_apex", - "requirements": ["pynepsys==1.1.0"], + "requirements": ["pynepsys==1.2.1"], "codeowners": [ "@jpelzer" ] diff --git a/requirements_all.txt b/requirements_all.txt index 69cd760715d376..1d0f10606f2639 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1486,7 +1486,7 @@ pynanoleaf==0.0.5 pynello==2.0.2 # homeassistant.components.neptune_apex -pynepsys==1.1.0 +pynepsys==1.2.1 # homeassistant.components.netgear pynetgear==0.6.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7329652909dae7..9de36f46436518 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -648,7 +648,7 @@ pymonoprice==0.3 pymyq==2.0.3 # homeassistant.components.neptune_apex -pynepsys==1.1.0 +pynepsys==1.2.1 # homeassistant.components.nut pynut2==2.1.2 From a135386d4723addad8cd577576b3c0c22eb3d3ea Mon Sep 17 00:00:00 2001 From: Jason Pelzer Date: Wed, 10 Jun 2020 18:56:00 -0400 Subject: [PATCH 10/10] Apply suggestions from code review Don't need 'apex' prefix for unique id. Co-authored-by: Martin Hjelmare --- homeassistant/components/neptune_apex/light.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/neptune_apex/light.py b/homeassistant/components/neptune_apex/light.py index 39c7bfe572adac..888e48a40aecdc 100644 --- a/homeassistant/components/neptune_apex/light.py +++ b/homeassistant/components/neptune_apex/light.py @@ -38,7 +38,7 @@ def name(self): @property def unique_id(self): """Name of this outlet as defined in the Apex.""" - return f"apex.{self.apex.serial}.{self.outlet.device_id}" + return f"{self.apex.serial}_{self.outlet.device_id}" @property def is_on(self):