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 config entry for SimpliSafe #17148

Merged
merged 23 commits into from Oct 12, 2018
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 3 additions & 1 deletion .coveragerc
Expand Up @@ -290,6 +290,9 @@ omit =
homeassistant/components/scsgate.py
homeassistant/components/*/scsgate.py

homeassistant/components/simplisafe/__init__.py
homeassistant/components/*/simplisafe.py

homeassistant/components/sisyphus.py
homeassistant/components/*/sisyphus.py

Expand Down Expand Up @@ -401,7 +404,6 @@ omit =
homeassistant/components/alarm_control_panel/ifttt.py
homeassistant/components/alarm_control_panel/manual_mqtt.py
homeassistant/components/alarm_control_panel/nx584.py
homeassistant/components/alarm_control_panel/simplisafe.py
homeassistant/components/alarm_control_panel/totalconnect.py
homeassistant/components/alarm_control_panel/yale_smart_alarm.py
homeassistant/components/apiai.py
Expand Down
7 changes: 5 additions & 2 deletions CODEOWNERS
Validating CODEOWNERS rules …
Expand Up @@ -49,7 +49,6 @@ homeassistant/components/hassio/* @home-assistant/hassio
# Individual platforms
homeassistant/components/alarm_control_panel/egardia.py @jeroenterheerdt
homeassistant/components/alarm_control_panel/manual_mqtt.py @colinodell
homeassistant/components/alarm_control_panel/simplisafe.py @bachya
homeassistant/components/binary_sensor/hikvision.py @mezz64
homeassistant/components/binary_sensor/threshold.py @fabaff
homeassistant/components/camera/yi.py @bachya
Expand Down Expand Up @@ -192,7 +191,7 @@ homeassistant/components/melissa.py @kennedyshead
homeassistant/components/*/melissa.py @kennedyshead
homeassistant/components/*/mystrom.py @fabaff

# U
# O
homeassistant/components/openuv/* @bachya
homeassistant/components/*/openuv.py @bachya

Expand All @@ -206,6 +205,10 @@ homeassistant/components/*/rainmachine.py @bachya
homeassistant/components/*/random.py @fabaff
homeassistant/components/*/rfxtrx.py @danielhiversen

# S
homeassistant/components/simplisafe/* @bachya
homeassistant/components/*/simplisafe.py @bachya

# T
homeassistant/components/tahoma.py @philklei
homeassistant/components/*/tahoma.py @philklei
Expand Down
131 changes: 52 additions & 79 deletions homeassistant/components/alarm_control_panel/simplisafe.py
@@ -1,92 +1,50 @@
"""
Interfaces with SimpliSafe alarm control panel.
This platform provides alarm control functionality for SimpliSafe.

For more details about this platform, please refer to the documentation at
https://home-assistant.io/components/alarm_control_panel.simplisafe/
"""
import logging
import re

import voluptuous as vol

from homeassistant.components.alarm_control_panel import (
PLATFORM_SCHEMA, AlarmControlPanel)
from homeassistant.components.alarm_control_panel import AlarmControlPanel
from homeassistant.components.simplisafe.const import (
DATA_CLIENT, DOMAIN, TOPIC_UPDATE)
from homeassistant.const import (
CONF_CODE, CONF_NAME, CONF_PASSWORD, CONF_USERNAME, STATE_ALARM_ARMED_AWAY,
STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED)
from homeassistant.helpers import aiohttp_client, config_validation as cv
from homeassistant.util.json import load_json, save_json

REQUIREMENTS = ['simplisafe-python==3.1.2']
CONF_CODE, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME,
STATE_ALARM_DISARMED)
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect

_LOGGER = logging.getLogger(__name__)

ATTR_ALARM_ACTIVE = "alarm_active"
ATTR_TEMPERATURE = "temperature"

DATA_FILE = '.simplisafe'

DEFAULT_NAME = 'SimpliSafe'

PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
vol.Required(CONF_USERNAME): cv.string,
vol.Required(CONF_PASSWORD): cv.string,
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_CODE): cv.string,
})
ATTR_ALARM_ACTIVE = 'alarm_active'
ATTR_TEMPERATURE = 'temperature'


async def async_setup_platform(
hass, config, async_add_entities, discovery_info=None):
"""Set up the SimpliSafe platform."""
from simplipy import API
from simplipy.errors import SimplipyError

username = config[CONF_USERNAME]
password = config[CONF_PASSWORD]
name = config.get(CONF_NAME)
code = config.get(CONF_CODE)

websession = aiohttp_client.async_get_clientsession(hass)

config_data = await hass.async_add_executor_job(
load_json, hass.config.path(DATA_FILE))

try:
if config_data:
try:
simplisafe = await API.login_via_token(
config_data['refresh_token'], websession)
_LOGGER.debug('Logging in with refresh token')
except SimplipyError:
_LOGGER.info('Refresh token expired; attempting credentials')
simplisafe = await API.login_via_credentials(
username, password, websession)
else:
simplisafe = await API.login_via_credentials(
username, password, websession)
_LOGGER.debug('Logging in with credentials')
except SimplipyError as err:
_LOGGER.error("There was an error during setup: %s", err)
return
"""Set up a SimpliSafe alarm control panel based on existing config."""
pass

config_data = {'refresh_token': simplisafe.refresh_token}
await hass.async_add_executor_job(
save_json, hass.config.path(DATA_FILE), config_data)

systems = await simplisafe.get_systems()
async_add_entities(
[SimpliSafeAlarm(system, name, code) for system in systems], True)
async def async_setup_entry(hass, entry, async_add_entities):
"""Set up a SimpliSafe alarm control panel based on a config entry."""
systems = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id]
async_add_entities([
SimpliSafeAlarm(system, entry.data.get(CONF_CODE))
for system in systems
], True)


class SimpliSafeAlarm(AlarmControlPanel):
"""Representation of a SimpliSafe alarm."""

def __init__(self, system, name, code):
def __init__(self, system, code):
"""Initialize the SimpliSafe alarm."""
self._async_unsub_dispatcher_connect = None
self._attrs = {}
self._code = str(code) if code else None
self._name = name
self._code = code
self._system = system
self._state = None

Expand All @@ -98,9 +56,7 @@ def unique_id(self):
@property
def name(self):
"""Return the name of the device."""
if self._name:
return self._name
return 'Alarm {}'.format(self._system.system_id)
return self._system.address

@property
def code_format(self):
Expand Down Expand Up @@ -128,6 +84,21 @@ def _validate_code(self, code, state):
_LOGGER.warning("Wrong code entered for %s", state)
return check

async def async_added_to_hass(self):
"""Register callbacks."""
@callback
def update():
"""Update the state."""
self.async_schedule_update_ha_state(True)

self._async_unsub_dispatcher_connect = async_dispatcher_connect(
self.hass, TOPIC_UPDATE, update)

async def async_will_remove_from_hass(self) -> None:
"""Disconnect dispatcher listener when removed."""
if self._async_unsub_dispatcher_connect:
self._async_unsub_dispatcher_connect()

async def async_alarm_disarm(self, code=None):
"""Send disarm command."""
if not self._validate_code(code, 'disarming'):
Expand All @@ -151,22 +122,24 @@ async def async_alarm_arm_away(self, code=None):

async def async_update(self):
"""Update alarm status."""
from simplipy.system import SystemStates

await self._system.update()

if self._system.state == self._system.SystemStates.off:
self._attrs[ATTR_ALARM_ACTIVE] = self._system.alarm_going_off
if self._system.temperature:
self._attrs[ATTR_TEMPERATURE] = self._system.temperature

if self._system.state == SystemStates.error:
return

if self._system.state == SystemStates.off:
self._state = STATE_ALARM_DISARMED
elif self._system.state in (
self._system.SystemStates.home,
self._system.SystemStates.home_count):
elif self._system.state in (SystemStates.home,
SystemStates.home_count):
self._state = STATE_ALARM_ARMED_HOME
elif self._system.state in (
self._system.SystemStates.away,
self._system.SystemStates.away_count,
self._system.SystemStates.exit_delay):
elif self._system.state in (SystemStates.away, SystemStates.away_count,
SystemStates.exit_delay):
self._state = STATE_ALARM_ARMED_AWAY
else:
self._state = None

self._attrs[ATTR_ALARM_ACTIVE] = self._system.alarm_going_off
if self._system.temperature:
self._attrs[ATTR_TEMPERATURE] = self._system.temperature
19 changes: 19 additions & 0 deletions homeassistant/components/simplisafe/.translations/en.json
@@ -0,0 +1,19 @@
{
"config": {
"error": {
"identifier_exists": "Account already registered",
"invalid_credentials": "Invalid credentials"
},
"step": {
"user": {
"data": {
"code": "Code (for Home Assistant)",
"password": "Password",
"username": "Email Address"
},
"title": "Fill in your information"
}
},
"title": "SimpliSafe"
}
}
146 changes: 146 additions & 0 deletions homeassistant/components/simplisafe/__init__.py
@@ -0,0 +1,146 @@
"""
Support for SimpliSafe alarm systems.

For more details about this platform, please refer to the documentation at
https://home-assistant.io/components/simplisafe/
"""
import logging
from datetime import timedelta

import voluptuous as vol

from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import (
CONF_CODE, CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_TOKEN, CONF_USERNAME)
from homeassistant.core import callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_track_time_interval

from homeassistant.helpers import config_validation as cv

from .config_flow import configured_instances
from .const import DATA_CLIENT, DEFAULT_SCAN_INTERVAL, DOMAIN, TOPIC_UPDATE

REQUIREMENTS = ['simplisafe-python==3.1.7']

_LOGGER = logging.getLogger(__name__)

CONF_ACCOUNTS = 'accounts'

DATA_LISTENER = 'listener'

ACCOUNT_CONFIG_SCHEMA = vol.Schema({
vol.Required(CONF_USERNAME): cv.string,
vol.Required(CONF_PASSWORD): cv.string,
vol.Optional(CONF_CODE): cv.string,
vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL):
cv.time_period
})

CONFIG_SCHEMA = vol.Schema({
DOMAIN: vol.Schema({
vol.Optional(CONF_ACCOUNTS):
vol.All(cv.ensure_list, [ACCOUNT_CONFIG_SCHEMA]),
}),
}, extra=vol.ALLOW_EXTRA)


@callback
def _async_save_refresh_token(hass, config_entry, token):
hass.config_entries.async_update_entry(
config_entry,
data={
Copy link
Member

Choose a reason for hiding this comment

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

This also works and is less likely to break in the future: {**config_entry.data, CONF_TOKEN: token}

Copy link
Member

Choose a reason for hiding this comment

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

CONF_USERNAME: config_entry.data[CONF_USERNAME],
CONF_TOKEN: token,
CONF_SCAN_INTERVAL: config_entry.data[CONF_SCAN_INTERVAL],
})


async def async_setup(hass, config):
"""Set up the SimpliSafe component."""
hass.data[DOMAIN] = {}
hass.data[DOMAIN][DATA_CLIENT] = {}
hass.data[DOMAIN][DATA_LISTENER] = {}

if DOMAIN not in config:
return True

conf = config[DOMAIN]

for account in conf[CONF_ACCOUNTS]:
if account[CONF_USERNAME] in configured_instances(hass):
continue

hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={'source': SOURCE_IMPORT},
data={
CONF_USERNAME: account[CONF_USERNAME],
CONF_PASSWORD: account[CONF_PASSWORD],
CONF_CODE: account.get(CONF_CODE),
CONF_SCAN_INTERVAL: account[CONF_SCAN_INTERVAL],
}))

return True


async def async_setup_entry(hass, config_entry):
"""Set up SimpliSafe as config entry."""
from simplipy import API
from simplipy.errors import SimplipyError

websession = aiohttp_client.async_get_clientsession(hass)

try:
simplisafe = await API.login_via_token(
config_entry.data[CONF_TOKEN], websession)
except SimplipyError as err:
bachya marked this conversation as resolved.
Show resolved Hide resolved
if 403 in str(err):
_LOGGER.error('Invalid credentials provided')
return False

Choose a reason for hiding this comment

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

blank line contains whitespace

_LOGGER.error('Config entry failed: %s', err)
raise ConfigEntryNotReady

_async_save_refresh_token(hass, config_entry, simplisafe.refresh_token)

systems = await simplisafe.get_systems()
hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = systems

hass.async_create_task(
hass.config_entries.async_forward_entry_setup(
config_entry, 'alarm_control_panel'))

async def refresh(event_time):
"""Refresh data from the SimpliSafe account."""
for system in systems:
_LOGGER.debug('Updating system data: %s', system.system_id)
await system.update()
async_dispatcher_send(hass, TOPIC_UPDATE.format(system.system_id))

if system.api.refresh_token_dirty:
bachya marked this conversation as resolved.
Show resolved Hide resolved
_async_save_refresh_token(
hass, config_entry, system.api.refresh_token)

hass.data[DOMAIN][DATA_LISTENER][
config_entry.entry_id] = async_track_time_interval(
hass,
refresh,
timedelta(seconds=config_entry.data[CONF_SCAN_INTERVAL]))

return True


async def async_unload_entry(hass, entry):
"""Unload a SimpliSafe config entry."""
await hass.config_entries.async_forward_entry_unload(
entry, 'alarm_control_panel')

hass.data[DOMAIN][DATA_CLIENT].pop(entry.entry_id)
remove_listener = hass.data[DOMAIN][DATA_LISTENER].pop(entry.entry_id)
remove_listener()

Copy link
Member

Choose a reason for hiding this comment

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

Forward entry unload to all platforms here. That will unload the alarm_control_panel platform.

See deconz component for example.

Copy link
Contributor Author

@bachya bachya Oct 5, 2018

Choose a reason for hiding this comment

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

I've added that code in my latest commit; the alarm control panel still doesn't get removed.

FYI: I've noticed that recently (in the last version or so), my OpenUV config entry has the same issue (and didn't previously).

Copy link
Member

Choose a reason for hiding this comment

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

Is it the entity that is still left?

Copy link
Contributor Author

@bachya bachya Oct 5, 2018

Choose a reason for hiding this comment

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

Correct: the config entry (thus, the component) goes away, but the alarm control panel entity remains.

Copy link
Member

Choose a reason for hiding this comment

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

Any errors in the logs?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Negative, unfortunately. A reboot of HASS clears away the alarm control panel, but even then, the logs don’t show anything. I’ll trace the unload calls and see if I can figure out where the break occurs.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No break that I can see. The only strangeness (to me) are these logs (with some of my debugging statements in place):

1. 2018-10-10 09:58:14 DEBUG (MainThread) [homeassistant.core] Removing entity: alarm_control_panel.1234_main_street
2. 2018-10-10 09:58:14 DEBUG (MainThread) [homeassistant.core] Firing event
3. 2018-10-10 09:58:14 DEBUG (MainThread) [homeassistant.core] Bus:Handling <Event state_changed[L]: entity_id=alarm_control_panel.1234_main_street, old_state=<state alarm_control_panel.1234_main_street=disarmed; code_format=None, changed_by=None, alarm_active=False, temperature=65, friendly_name=1234 Main Street @ 2018-10-10T09:57:57.711751-06:00>, new_state=None>
4. 2018-10-10 09:58:14 DEBUG (MainThread) [homeassistant.components.websocket_api.http.connection.4559956960] Sending {'id': 2, 'type': 'event', 'event': {'event_type': 'state_changed', 'data': {'entity_id': 'alarm_control_panel.1234_main_street', 'old_state': <state alarm_control_panel.1234_main_street=disarmed; code_format=None, changed_by=None, alarm_active=False, temperature=65, friendly_name=1234 Main Street @ 2018-10-10T09:57:57.711751-06:00>, 'new_state': None}, 'origin': 'LOCAL', 'time_fired': datetime.datetime(2018, 10, 10, 15, 58, 14, 595742, tzinfo=<UTC>), 'context': {'id': '1fb3f29315b44ed68b00afd99e2b3b17', 'user_id': None}}}
5. 2018-10-10 09:58:14 DEBUG (MainThread) [homeassistant.config_entries] Unload result is True
6. 2018-10-10 09:58:14 DEBUG (MainThread) [homeassistant.config_entries] Unload result is True
7. 2018-10-10 09:58:14 DEBUG (MainThread) [homeassistant.config_entries] state adjusted
8. 2018-10-10 09:58:14 DEBUG (MainThread) [homeassistant.core] Bus:Handling <Event state_changed[L]: entity_id=alarm_control_panel.1234_main_street, old_state=None, new_state=<state alarm_control_panel.1234_main_street=disarmed; code_format=None, changed_by=None, alarm_active=False, temperature=65, friendly_name=1234 Main Street @ 2018-10-10T09:58:14.621691-06:00>>
9. 2018-10-10 09:58:14 DEBUG (MainThread) [homeassistant.components.websocket_api.http.connection.4559956960] Sending {'id': 2, 'type': 'event', 'event': {'event_type': 'state_changed', 'data': {'entity_id': 'alarm_control_panel.1234_main_street', 'old_state': None, 'new_state': <state alarm_control_panel.1234_main_street=disarmed; code_format=None, changed_by=None, alarm_active=False, temperature=65, friendly_name=1234 Main Street @ 2018-10-10T09:58:14.621691-06:00>}, 'origin': 'LOCAL', 'time_fired': datetime.datetime(2018, 10, 10, 15, 58, 14, 621708, tzinfo=<UTC>), 'context': {'id': '8442566714d3438fb7b00f58fc0a5739', 'user_id': None}}}

From what I'm reading:

  • Line 1: homeassistant.core.async_remove is called with the correct entity ID.
  • Lines 2-4: The state_changed event is fired (with an old_state of whatever the alarm is currently set at and a new_state of None).
  • Lines 5-6: component.async_unload_entry is called twice (with the result being True both times).
  • Line 7: hass.config_entries.state is set to not_loaded.
  • Line 8: Another state_changed event is fired (with an old_state of None and a new_state of whatever the alarm is currently set at).

I'm not the expert on this subsystem, but it seems like there might be two areas of strangeness:

  1. Should component.async_unload_entry be called twice?
  2. Why does the second state change occur at the end? Does a non-None state somehow keep the entity around?

Apologies I'm not more famliar. Happy to help debugging if you can direct me.

Copy link
Member

Choose a reason for hiding this comment

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

Check which component is used for generating the unload result.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

2018-10-11 09:08:58 DEBUG (MainThread) [homeassistant.config_entries] Unload called from <module 'homeassistant.components.simplisafe' from '/Users/abach/Git/home-assistant/homeassistant/components/simplisafe/__init__.py'>
2018-10-11 09:08:58 DEBUG (MainThread) [homeassistant.config_entries] Unload called from <module 'homeassistant.components.alarm_control_panel' from '/Users/abach/Git/home-assistant/homeassistant/components/alarm_control_panel/__init__.py'>

Copy link
Contributor Author

Choose a reason for hiding this comment

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

FYI, tracking this issue separately: #17370.

return True