Skip to content
Closed
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
77 changes: 77 additions & 0 deletions homeassistant/components/neptune_apex/__init__.py
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:
Comment thread
jpelzer marked this conversation as resolved.
Outdated
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
94 changes: 94 additions & 0 deletions homeassistant/components/neptune_apex/config_flow.py
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)
Comment thread
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."""
3 changes: 3 additions & 0 deletions homeassistant/components/neptune_apex/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Constants for the Neptune Apex integration."""

DOMAIN = "neptune_apex"
126 changes: 126 additions & 0 deletions homeassistant/components/neptune_apex/light.py
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}"
Comment thread
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"]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The 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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could add a state attribute with the auto mode state?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a good example component that I can copy?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's the docs for adding a custom entity service:
https://developers.home-assistant.io/docs/dev_101_services#entity-services


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()
10 changes: 10 additions & 0 deletions homeassistant/components/neptune_apex/manifest.json
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"
]
}
23 changes: 23 additions & 0 deletions homeassistant/components/neptune_apex/strings.json
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%]"
}
}
}
24 changes: 24 additions & 0 deletions homeassistant/components/neptune_apex/translations/en.json
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"
}
}
}
1 change: 1 addition & 0 deletions homeassistant/generated/config_flows.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@
"mqtt",
"myq",
"neato",
"neptune_apex",
"nest",
"netatmo",
"nexia",
Expand Down
3 changes: 3 additions & 0 deletions requirements_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading