-
-
Notifications
You must be signed in to change notification settings - Fork 30.2k
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
Add Risco integration #36930
Changes from all commits
0b07263
1e97080
6da1010
b70ccd7
c53a2c9
4a3e177
db232ff
f32f142
c97a86d
0e4fdb9
86ac863
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,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 |
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() | ||
] | ||
|
||
async_add_entities(entities, False) | ||
|
||
|
||
class RiscoAlarm(AlarmControlPanelEntity): | ||
"""Representation of a Risco partition.""" | ||
|
||
def __init__(self, hass, coordinator, partition_id): | ||
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. Don't pass in |
||
"""Init the partition.""" | ||
self._hass = hass | ||
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. 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 | ||
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. Use |
||
|
||
@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") | ||
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 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() |
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) | ||
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. Please move the entry creation to an 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 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 | ||
) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
"""Constants for the Risco integration.""" | ||
|
||
DOMAIN = "risco" | ||
|
||
DATA_COORDINATOR = "risco" |
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" | ||
] | ||
} |
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%]" | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -142,6 +142,7 @@ | |
"rachio", | ||
"rainmachine", | ||
"ring", | ||
"risco", | ||
"roku", | ||
"roomba", | ||
"roon", | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
"""Tests for the Risco integration.""" |
There was a problem hiding this comment.
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()
.