Skip to content
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

Add Risco integration #36930

Merged
merged 11 commits into from
Aug 22, 2020
Merged
Show file tree
Hide file tree
Changes from all 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 CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,7 @@ homeassistant/components/random/* @fabaff
homeassistant/components/repetier/* @MTrab
homeassistant/components/rfxtrx/* @danielhiversen @elupus
homeassistant/components/ring/* @balloob
homeassistant/components/risco/* @OnFreund
homeassistant/components/rmvtransport/* @cgtobi
homeassistant/components/roku/* @ctalkington
homeassistant/components/roomba/* @pschmitt @cyr-ius @shenxn
Expand Down
89 changes: 89 additions & 0 deletions homeassistant/components/risco/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""The Risco integration."""
import asyncio
from datetime import timedelta
import logging

from pyrisco import CannotConnectError, OperationError, RiscoAPI, UnauthorizedError

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_PIN, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed

from .const import DATA_COORDINATOR, DOMAIN

PLATFORMS = ["alarm_control_panel"]


_LOGGER = logging.getLogger(__name__)


async def async_setup(hass: HomeAssistant, config: dict):
"""Set up the Risco component."""
hass.data.setdefault(DOMAIN, {})
return True


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up Risco from a config entry."""
data = entry.data
risco = RiscoAPI(data[CONF_USERNAME], data[CONF_PASSWORD], data[CONF_PIN])
try:
await risco.login(async_get_clientsession(hass))
except CannotConnectError as error:
raise ConfigEntryNotReady() from error
except UnauthorizedError:
_LOGGER.exception("Failed to login to Risco cloud")
return False

coordinator = RiscoDataUpdateCoordinator(hass, risco)
await coordinator.async_refresh()

hass.data[DOMAIN][entry.entry_id] = {
DATA_COORDINATOR: coordinator,
}

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


class RiscoDataUpdateCoordinator(DataUpdateCoordinator):
"""Class to manage fetching risco data."""

def __init__(self, hass, risco):
"""Initialize global risco data updater."""
self.risco = risco
interval = timedelta(seconds=30)
super().__init__(
hass, _LOGGER, name=DOMAIN, update_interval=interval,
)

async def _async_update_data(self):
"""Fetch data from risco."""
try:
return await self.risco.get_state()
except (CannotConnectError, UnauthorizedError, OperationError) as error:
raise UpdateFailed from error
162 changes: 162 additions & 0 deletions homeassistant/components/risco/alarm_control_panel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
"""Support for Risco alarms."""
import logging

from homeassistant.components.alarm_control_panel import AlarmControlPanelEntity
from homeassistant.components.alarm_control_panel.const import (
SUPPORT_ALARM_ARM_AWAY,
SUPPORT_ALARM_ARM_CUSTOM_BYPASS,
SUPPORT_ALARM_ARM_HOME,
SUPPORT_ALARM_ARM_NIGHT,
)
from homeassistant.const import (
STATE_ALARM_ARMED_AWAY,
STATE_ALARM_ARMED_CUSTOM_BYPASS,
STATE_ALARM_ARMED_HOME,
STATE_ALARM_ARMED_NIGHT,
STATE_ALARM_ARMING,
STATE_ALARM_DISARMED,
STATE_ALARM_TRIGGERED,
STATE_UNKNOWN,
)

from .const import DATA_COORDINATOR, DOMAIN

_LOGGER = logging.getLogger(__name__)

SUPPORTED_STATES = [
STATE_ALARM_DISARMED,
STATE_ALARM_ARMED_AWAY,
STATE_ALARM_ARMED_HOME,
STATE_ALARM_ARMED_NIGHT,
STATE_ALARM_ARMED_CUSTOM_BYPASS,
STATE_ALARM_TRIGGERED,
]


async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Risco alarm control panel."""
coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR]
entities = [
RiscoAlarm(hass, coordinator, partition_id)
for partition_id in coordinator.data.partitions.keys()
Copy link
Member

Choose a reason for hiding this comment

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

Keys is the default iterator of a dict. We can remove .keys().

]

async_add_entities(entities, False)


class RiscoAlarm(AlarmControlPanelEntity):
"""Representation of a Risco partition."""

def __init__(self, hass, coordinator, partition_id):
Copy link
Member

Choose a reason for hiding this comment

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

Don't pass in hass. It will be set on the entity when it has been added to home assistant.

"""Init the partition."""
self._hass = hass
Copy link
Member

Choose a reason for hiding this comment

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

Remove this.

self._coordinator = coordinator
self._partition_id = partition_id
self._partition = self._coordinator.data.partitions[self._partition_id]

@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

def _refresh_from_coordinator(self):
self._partition = self._coordinator.data.partitions[self._partition_id]
self.async_write_ha_state()

async def async_added_to_hass(self):
"""When entity is added to hass."""
self.async_on_remove(
self._coordinator.async_add_listener(self._refresh_from_coordinator)
)

@property
def _risco(self):
"""Return the Risco API object."""
return self._coordinator.risco

@property
def device_info(self):
"""Return device info for this device."""
return {
"identifiers": {(DOMAIN, self.unique_id)},
"name": self.name,
"manufacturer": "Risco",
}

@property
def name(self):
"""Return the name of the partition."""
return f"Risco {self._risco.site_name} Partition {self._partition_id}"

@property
def unique_id(self):
"""Return a unique id for that partition."""
return f"{self._risco.site_uuid}_{self._partition_id}"

@property
def state(self):
"""Return the state of the device."""
if self._partition.triggered:
return STATE_ALARM_TRIGGERED
if self._partition.arming:
return STATE_ALARM_ARMING
if self._partition.armed:
return STATE_ALARM_ARMED_AWAY
if self._partition.partially_armed:
return STATE_ALARM_ARMED_HOME
if self._partition.disarmed:
return STATE_ALARM_DISARMED

return STATE_UNKNOWN
Copy link
Member

Choose a reason for hiding this comment

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

Use None to represent unknown state.


@property
def supported_features(self):
"""Return the list of supported features."""
return (
SUPPORT_ALARM_ARM_HOME
| SUPPORT_ALARM_ARM_AWAY
| SUPPORT_ALARM_ARM_NIGHT
| SUPPORT_ALARM_ARM_CUSTOM_BYPASS
)

@property
def code_arm_required(self):
"""Whether the code is required for arm actions."""
return False

async def async_alarm_disarm(self, code=None):
"""Send disarm command."""
await self._call_alarm_method("disarm")

async def async_alarm_arm_home(self, code=None):
"""Send arm home command."""
await self._call_alarm_method("partial_arm")

async def async_alarm_arm_night(self, code=None):
"""Send arm night command."""
await self._call_alarm_method("partial_arm")
Copy link
Member

Choose a reason for hiding this comment

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

We shouldn't have more than one service partially arm.


async def async_alarm_arm_custom_bypass(self, code=None):
"""Send arm custom bypass command."""
await self._call_alarm_method("partial_arm")

async def async_alarm_arm_away(self, code=None):
"""Send arm away command."""
await self._call_alarm_method("arm")

async def _call_alarm_method(self, method, code=None):
alarm = await getattr(self._risco, method)(self._partition_id)
self._partition = alarm.partitions[self._partition_id]
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()
58 changes: 58 additions & 0 deletions homeassistant/components/risco/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""Config flow for Risco integration."""
import logging

from pyrisco import CannotConnectError, RiscoAPI, UnauthorizedError
import voluptuous as vol

from homeassistant import config_entries, core
from homeassistant.const import CONF_PASSWORD, CONF_PIN, CONF_USERNAME
from homeassistant.helpers.aiohttp_client import async_get_clientsession

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

_LOGGER = logging.getLogger(__name__)


DATA_SCHEMA = vol.Schema({CONF_USERNAME: str, CONF_PASSWORD: str, CONF_PIN: str})


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.
"""
risco = RiscoAPI(data[CONF_USERNAME], data[CONF_PASSWORD], data[CONF_PIN])

try:
await risco.login(async_get_clientsession(hass))
finally:
await risco.close()

return {"title": risco.site_name}


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

VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_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)
Copy link
Member

Choose a reason for hiding this comment

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

Please move the entry creation to an else: block.

Copy link
Member

Choose a reason for hiding this comment

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

We should use the username as unique_id and guard from creating more than one entry with the same username.

except CannotConnectError:
errors["base"] = "cannot_connect"
except UnauthorizedError:
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
)
5 changes: 5 additions & 0 deletions homeassistant/components/risco/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Constants for the Risco integration."""

DOMAIN = "risco"

DATA_COORDINATOR = "risco"
12 changes: 12 additions & 0 deletions homeassistant/components/risco/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"domain": "risco",
"name": "Risco",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/risco",
"requirements": [
"pyrisco==0.2.1"
],
"codeowners": [
"@OnFreund"
]
}
21 changes: 21 additions & 0 deletions homeassistant/components/risco/strings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"config": {
"step": {
"user": {
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]",
"pin": "Pin code"
}
}
},
"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%]"
}
}
}
1 change: 1 addition & 0 deletions homeassistant/generated/config_flows.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@
"rachio",
"rainmachine",
"ring",
"risco",
"roku",
"roomba",
"roon",
Expand Down
3 changes: 3 additions & 0 deletions requirements_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1594,6 +1594,9 @@ pyrecswitch==1.0.2
# homeassistant.components.repetier
pyrepetier==3.0.5

# homeassistant.components.risco
pyrisco==0.2.1

# homeassistant.components.sabnzbd
pysabnzbd==1.1.0

Expand Down
3 changes: 3 additions & 0 deletions requirements_test_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -750,6 +750,9 @@ pyps4-2ndscreen==1.1.1
# homeassistant.components.qwikswitch
pyqwikswitch==0.93

# homeassistant.components.risco
pyrisco==0.2.1

# homeassistant.components.acer_projector
# homeassistant.components.zha
pyserial==3.4
Expand Down
1 change: 1 addition & 0 deletions tests/components/risco/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Tests for the Risco integration."""
Loading