Skip to content

Commit

Permalink
Add Acmeda integration (home-assistant#33384)
Browse files Browse the repository at this point in the history
* First cut of Rollease Acmeda Pulse Hub integration.

* Acmeda integration improvements:
 - Moved common code into a base entity
 - Battery level sensor added
 - Localisation now working

* Added requirement for aiopulse now that it has been uploaded to PyPI.

* Exclude acmeda integration from coverage check as it relies on a hub being present.

* Fix Travis CI build issues.

* Remove unused constants.

* Remove unused group logic from cover.py

* Removed commented code from base.py

* Remove sensors (battery entities) on removal of hub.

* Remove unused groups from sensor.py

* Acmeda device and entity update made fully asynchronous using subscriptions to remove need for config polling.

* Updated aiopulse version dependency.
Removed non-functional battery charging indication.

* Rationalised common code to update entities into helpers.py

* Fix linting issue.

* Correct additional CI pylint errors.

* Index config_entries by entry_id.
Move entity loading and unloading to __init__.py
Add entry_id to dispatcher signal
Removed now unused polling code hub
Added config_flow unit tests

* Tweak to integration config_entry title.

* Bumped aiopulse module to 0.3.2.
Reduced verbosity of aiopulse module.

* Changed to using direct write of device state.
Removed old style async_step_init config_flow step.

* Remove superfluous battery_level and device_state_attributes from battery entity.

* Removal of unused strings.
Removal of unused create_config_flow helper.
Removal of stale comment.

* Remove use of shared container to track existing enities.
Moved removal and deregistration of entities to base class through use of dispatch helper.

* Fixed strings.json

* Fix incorrect use of remove instead of pop on dict.

* Add support for tilting covers, bump aiopulse version number.

* Bump aiopulse version to v0.3.4.
Fixed bug in cover supported_features.

* Bumped aiopulse version to 0.4.0
Update acmeda .coveragerc exclusions

* Removed already configured hub check from __init__.py async_setup_entry
Removed passing in hass reference to base entity class
Renamed entity async_reset to async_will_remove_from_hass
Changed device_info and properties
Migrated to CoveEntity from CoverDevice
Added dispatched_connect cleanup on hub removal
Removed unused entries from manifest
Removed override of battery icon
Renamed translations folder

* Reversed unintended change to .coveragerc

* Fixed config flow for multi-hub discovery.

* Acmeda enhancements as requested by MartinHjelmare

* Force import to connect to hub to retrieve id prior to creating entry

* Remove YAML configuration support.

* Tidied up config_flow and tests:
 - removed unnecessary steps
 - fixed typos

* Removed storage of hub in config_flow.
  • Loading branch information
atmurray authored May 17, 2020
1 parent eec1b3e commit 65e509e
Show file tree
Hide file tree
Showing 19 changed files with 736 additions and 0 deletions.
8 changes: 8 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ omit =
# omit pieces of code that rely on external devices being present
homeassistant/components/acer_projector/switch.py
homeassistant/components/actiontec/device_tracker.py
homeassistant/components/acmeda/__init__.py
homeassistant/components/acmeda/base.py
homeassistant/components/acmeda/const.py
homeassistant/components/acmeda/cover.py
homeassistant/components/acmeda/errors.py
homeassistant/components/acmeda/helpers.py
homeassistant/components/acmeda/hub.py
homeassistant/components/acmeda/sensor.py
homeassistant/components/adguard/__init__.py
homeassistant/components/adguard/const.py
homeassistant/components/adguard/sensor.py
Expand Down
1 change: 1 addition & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ homeassistant/scripts/check_config.py @kellerza

# Integrations
homeassistant/components/abode/* @shred86
homeassistant/components/acmeda/* @atmurray
homeassistant/components/adguard/* @frenck
homeassistant/components/agent_dvr/* @ispysoftware
homeassistant/components/airly/* @bieniu
Expand Down
59 changes: 59 additions & 0 deletions homeassistant/components/acmeda/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""The Rollease Acmeda Automate integration."""
import asyncio

from homeassistant import config_entries, core

from .const import DOMAIN
from .hub import PulseHub

CONF_HUBS = "hubs"

PLATFORMS = ["cover", "sensor"]


async def async_setup(hass: core.HomeAssistant, config: dict):
"""Set up the Rollease Acmeda Automate component."""
return True


async def async_setup_entry(
hass: core.HomeAssistant, config_entry: config_entries.ConfigEntry
):
"""Set up Rollease Acmeda Automate hub from a config entry."""
hub = PulseHub(hass, config_entry)

if not await hub.async_setup():
return False

hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][config_entry.entry_id] = hub

for component in PLATFORMS:
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(config_entry, component)
)

return True


async def async_unload_entry(
hass: core.HomeAssistant, config_entry: config_entries.ConfigEntry
):
"""Unload a config entry."""
hub = hass.data[DOMAIN][config_entry.entry_id]

unload_ok = all(
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_unload(config_entry, component)
for component in PLATFORMS
]
)
)
if not await hub.async_reset():
return False

if unload_ok:
hass.data[DOMAIN].pop(config_entry.entry_id)

return unload_ok
89 changes: 89 additions & 0 deletions homeassistant/components/acmeda/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""Base class for Acmeda Roller Blinds."""
import aiopulse

from homeassistant.core import callback
from homeassistant.helpers import entity
from homeassistant.helpers.device_registry import async_get_registry as get_dev_reg
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_registry import async_get_registry as get_ent_reg

from .const import ACMEDA_ENTITY_REMOVE, DOMAIN, LOGGER


class AcmedaBase(entity.Entity):
"""Base representation of an Acmeda roller."""

def __init__(self, roller: aiopulse.Roller):
"""Initialize the roller."""
self.roller = roller

async def async_remove_and_unregister(self):
"""Unregister from entity and device registry and call entity remove function."""
LOGGER.error("Removing %s %s", self.__class__.__name__, self.unique_id)

ent_registry = await get_ent_reg(self.hass)
if self.entity_id in ent_registry.entities:
ent_registry.async_remove(self.entity_id)

dev_registry = await get_dev_reg(self.hass)
device = dev_registry.async_get_device(
identifiers={(DOMAIN, self.unique_id)}, connections=set()
)
if device is not None:
dev_registry.async_update_device(
device.id, remove_config_entry_id=self.registry_entry.config_entry_id
)

await self.async_remove()

async def async_added_to_hass(self):
"""Entity has been added to hass."""
self.roller.callback_subscribe(self.notify_update)

self.async_on_remove(
async_dispatcher_connect(
self.hass,
ACMEDA_ENTITY_REMOVE.format(self.roller.id),
self.async_remove_and_unregister,
)
)

async def async_will_remove_from_hass(self):
"""Entity being removed from hass."""
self.roller.callback_unsubscribe(self.notify_update)

@callback
def notify_update(self):
"""Write updated device state information."""
LOGGER.debug("Device update notification received: %s", self.name)
self.async_write_ha_state()

@property
def should_poll(self):
"""Report that Acmeda entities do not need polling."""
return False

@property
def unique_id(self):
"""Return the unique ID of this roller."""
return self.roller.id

@property
def device_id(self):
"""Return the ID of this roller."""
return self.roller.id

@property
def name(self):
"""Return the name of roller."""
return self.roller.name

@property
def device_info(self):
"""Return the device info."""
return {
"identifiers": {(DOMAIN, self.unique_id)},
"name": self.roller.name,
"manufacturer": "Rollease Acmeda",
"via_device": (DOMAIN, self.roller.hub.id),
}
71 changes: 71 additions & 0 deletions homeassistant/components/acmeda/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""Config flow for Rollease Acmeda Automate Pulse Hub."""
import asyncio
from typing import Dict, Optional

import aiopulse
import async_timeout
import voluptuous as vol

from homeassistant import config_entries

from .const import DOMAIN # pylint: disable=unused-import


class AcmedaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a Acmeda config flow."""

VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL

def __init__(self):
"""Initialize the config flow."""
self.discovered_hubs: Optional[Dict[str, aiopulse.Hub]] = None

async def async_step_user(self, user_input=None):
"""Handle a flow initialized by the user."""
if (
user_input is not None
and self.discovered_hubs is not None
# pylint: disable=unsupported-membership-test
and user_input["id"] in self.discovered_hubs
):
# pylint: disable=unsubscriptable-object
return await self.async_create(self.discovered_hubs[user_input["id"]])

# Already configured hosts
already_configured = {
entry.unique_id for entry in self._async_current_entries()
}

hubs = []
try:
with async_timeout.timeout(5):
async for hub in aiopulse.Hub.discover():
if hub.id not in already_configured:
hubs.append(hub)
except asyncio.TimeoutError:
pass

if len(hubs) == 0:
return self.async_abort(reason="all_configured")

if len(hubs) == 1:
return await self.async_create(hubs[0])

self.discovered_hubs = {hub.id: hub for hub in hubs}

return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required("id"): vol.In(
{hub.id: f"{hub.id} {hub.host}" for hub in hubs}
)
}
),
)

async def async_create(self, hub):
"""Create the Acmeda Hub entry."""
await self.async_set_unique_id(hub.id, raise_on_progress=False)
return self.async_create_entry(title=hub.id, data={"host": hub.host})
8 changes: 8 additions & 0 deletions homeassistant/components/acmeda/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""Constants for the Rollease Acmeda Automate integration."""
import logging

LOGGER = logging.getLogger(__package__)
DOMAIN = "acmeda"

ACMEDA_HUB_UPDATE = "acmeda_hub_update_{}"
ACMEDA_ENTITY_REMOVE = "acmeda_entity_remove_{}"
122 changes: 122 additions & 0 deletions homeassistant/components/acmeda/cover.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""Support for Acmeda Roller Blinds."""
from homeassistant.components.cover import (
ATTR_POSITION,
SUPPORT_CLOSE,
SUPPORT_CLOSE_TILT,
SUPPORT_OPEN,
SUPPORT_OPEN_TILT,
SUPPORT_SET_POSITION,
SUPPORT_SET_TILT_POSITION,
SUPPORT_STOP,
SUPPORT_STOP_TILT,
CoverEntity,
)
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect

from .base import AcmedaBase
from .const import ACMEDA_HUB_UPDATE, DOMAIN
from .helpers import async_add_acmeda_entities


async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Acmeda Rollers from a config entry."""
hub = hass.data[DOMAIN][config_entry.entry_id]

current = set()

@callback
def async_add_acmeda_covers():
async_add_acmeda_entities(
hass, AcmedaCover, config_entry, current, async_add_entities
)

hub.cleanup_callbacks.append(
async_dispatcher_connect(
hass,
ACMEDA_HUB_UPDATE.format(config_entry.entry_id),
async_add_acmeda_covers,
)
)


class AcmedaCover(AcmedaBase, CoverEntity):
"""Representation of a Acmeda cover device."""

@property
def current_cover_position(self):
"""Return the current position of the roller blind.
None is unknown, 0 is closed, 100 is fully open.
"""
position = None
if self.roller.type != 7:
position = 100 - self.roller.closed_percent
return position

@property
def current_cover_tilt_position(self):
"""Return the current tilt of the roller blind.
None is unknown, 0 is closed, 100 is fully open.
"""
position = None
if self.roller.type == 7 or self.roller.type == 10:
position = 100 - self.roller.closed_percent
return position

@property
def supported_features(self):
"""Flag supported features."""
supported_features = 0
if self.current_cover_position is not None:
supported_features |= (
SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP | SUPPORT_SET_POSITION
)
if self.current_cover_tilt_position is not None:
supported_features |= (
SUPPORT_OPEN_TILT
| SUPPORT_CLOSE_TILT
| SUPPORT_STOP_TILT
| SUPPORT_SET_TILT_POSITION
)

return supported_features

@property
def is_closed(self):
"""Return if the cover is closed."""
is_closed = self.roller.closed_percent == 100
return is_closed

async def close_cover(self, **kwargs):
"""Close the roller."""
await self.roller.move_down()

async def open_cover(self, **kwargs):
"""Open the roller."""
await self.roller.move_up()

async def stop_cover(self, **kwargs):
"""Stop the roller."""
await self.roller.move_stop()

async def set_cover_position(self, **kwargs):
"""Move the roller shutter to a specific position."""
await self.roller.move_to(100 - kwargs[ATTR_POSITION])

async def close_cover_tilt(self, **kwargs):
"""Close the roller."""
await self.roller.move_down()

async def open_cover_tilt(self, **kwargs):
"""Open the roller."""
await self.roller.move_up()

async def stop_cover_tilt(self, **kwargs):
"""Stop the roller."""
await self.roller.move_stop()

async def set_cover_tilt(self, **kwargs):
"""Tilt the roller shutter to a specific position."""
await self.roller.move_to(100 - kwargs[ATTR_POSITION])
10 changes: 10 additions & 0 deletions homeassistant/components/acmeda/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""Errors for the Acmeda Pulse component."""
from homeassistant.exceptions import HomeAssistantError


class PulseException(HomeAssistantError):
"""Base class for Acmeda Pulse exceptions."""


class CannotConnect(PulseException):
"""Unable to connect to the bridge."""
Loading

0 comments on commit 65e509e

Please sign in to comment.