-
-
Notifications
You must be signed in to change notification settings - Fork 38.1k
Add integration support for Neptune Systems Apex Aquacontroller #34558
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 8 commits
dd6bfe1
7db5fac
0d09153
0c40fbd
8f65641
3f160a1
93e822d
ae857bf
f7210c0
a135386
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| """The Neptune Apex integration.""" | ||
| import asyncio | ||
| from datetime import timedelta | ||
| import logging | ||
|
|
||
| import async_timeout | ||
| from pynepsys import Apex | ||
|
|
||
| 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 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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
|
MartinHjelmare marked this conversation as resolved.
Outdated
|
||
| 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.""" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| """Constants for the Neptune Apex integration.""" | ||
|
|
||
| DOMAIN = "neptune_apex" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"apex.{self.apex.serial}.{self.outlet.device_id}" | ||
|
jpelzer marked this conversation as resolved.
Outdated
|
||
|
|
||
| @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"] | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These don't seem like effects? Why are we using the light entity if this is not a light device?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure there's a better analogue to the HA light for the Apex Outlet. Not to go too historical, but the older-generation Aquacontrollers used X10 to control lights and pumps, so that's where the concept of 'outlet' came from. The Apex has the obvious ON/OFF states, which are manual modes, but then has a third mode, 'AUTO'. This then leads to two more modes, AUTO-ON (AON) and AUTO-OFF (AOF). Pushing an outlet into AUTO and then waiting for the controller to update to AON/AOF is the only path. This works really well for regular things controlled via their EnergyBars, which have 4 or 8 physical plugs each. Cool, everything seems good for binary control. Except there are other outputs that are still called outlets, but are variable, some of 0-10V, some are 0-100%. And you cannot actually tell an outlet to go to 50% or 5V or whatever. You must enable a profile. Profiles specify start and end states, ramp times, cycle types, etc. So long story short, when an outlet is binary, it has five states, but 2 are ON, 2 are OFF, and 1 is unknown. If it's analogue, it has two states, OFF and NOT-OFF, as you can't actually tell what % or voltage it's currently at. HASS light seemed like the only type that could encompass all this and actually work. You can manually turn things on/off, and you can put the outlet back into AUTO mode by setting the effect to AUTO. Hopefully my logic checks?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we instead make this a switch and add a custom service to activate and disable the auto mode? It sounds like the state of the device is still binary, except when we don't know state. So the switch should work for reporting state.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh, one more fun thing... Putting an outlet into AUTO is also the way to get it to set a profile... It's via its own internal program. I forgot you can't force them straight to whatever. Profiles are user-defined names.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure that'd work with what I know of switch... As an example, right now I have an alert that fires if I leave a particular light on in manual mode (so 'ON') for longer than 30 minutes. But that light will correctly turn itself on for 8 hours a day and not trigger the alert (AON). It also allows you to see if you're in auto or manual mode via the regular interface, and also to see what state a pump is in (for instance right now my Vortech pump is in 'TBL' mode)
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We could add a state attribute with the auto mode state?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there a good example component that I can copy?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Here's the docs for adding a custom entity service: |
||
|
|
||
| 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() | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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.1.0"], | ||
| "codeowners": [ | ||
| "@jpelzer" | ||
| ] | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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%]" | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -101,6 +101,7 @@ | |
| "mqtt", | ||
| "myq", | ||
| "neato", | ||
| "neptune_apex", | ||
| "nest", | ||
| "netatmo", | ||
| "nexia", | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.