Add config entry for SimpliSafe (#17148)

* Initial move into component

* Base functionality in place

* Starting tests in place

* All config entry tests in place

* Made default scan interval more obvious and removed extra logging

* Inherit default scan interval from alarm_control_panel

* Updated coveragerc and CODEOWNERS

* Member-requested changes

* Hound

* Updated requirements

* Updated tests

* Member-requested changes

* Owner-requested changes

* Constant cleanup

* Fixed config flow test

* Owner-requested updates

* Updated requirements

* Using async_will_remove_from_hass

* Corrected scan interval logic

* Fixed tests

* Owner-requested changes

* Additional logging

* Owner-requested changes
This commit is contained in:
Aaron Bach 2018-10-12 11:07:47 -06:00 committed by Paulus Schoutsen
parent 1f863830e1
commit 401e22fc0c
14 changed files with 461 additions and 86 deletions

View file

@ -290,6 +290,9 @@ omit =
homeassistant/components/scsgate.py homeassistant/components/scsgate.py
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
homeassistant/components/*/sisyphus.py homeassistant/components/*/sisyphus.py
@ -401,7 +404,6 @@ omit =
homeassistant/components/alarm_control_panel/ifttt.py homeassistant/components/alarm_control_panel/ifttt.py
homeassistant/components/alarm_control_panel/manual_mqtt.py homeassistant/components/alarm_control_panel/manual_mqtt.py
homeassistant/components/alarm_control_panel/nx584.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/totalconnect.py
homeassistant/components/alarm_control_panel/yale_smart_alarm.py homeassistant/components/alarm_control_panel/yale_smart_alarm.py
homeassistant/components/apiai.py homeassistant/components/apiai.py

View file

@ -49,7 +49,6 @@ homeassistant/components/hassio/* @home-assistant/hassio
# Individual platforms # Individual platforms
homeassistant/components/alarm_control_panel/egardia.py @jeroenterheerdt homeassistant/components/alarm_control_panel/egardia.py @jeroenterheerdt
homeassistant/components/alarm_control_panel/manual_mqtt.py @colinodell 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/hikvision.py @mezz64
homeassistant/components/binary_sensor/threshold.py @fabaff homeassistant/components/binary_sensor/threshold.py @fabaff
homeassistant/components/camera/yi.py @bachya homeassistant/components/camera/yi.py @bachya
@ -192,7 +191,7 @@ homeassistant/components/melissa.py @kennedyshead
homeassistant/components/*/melissa.py @kennedyshead homeassistant/components/*/melissa.py @kennedyshead
homeassistant/components/*/mystrom.py @fabaff homeassistant/components/*/mystrom.py @fabaff
# U # O
homeassistant/components/openuv/* @bachya homeassistant/components/openuv/* @bachya
homeassistant/components/*/openuv.py @bachya homeassistant/components/*/openuv.py @bachya
@ -206,6 +205,10 @@ homeassistant/components/*/rainmachine.py @bachya
homeassistant/components/*/random.py @fabaff homeassistant/components/*/random.py @fabaff
homeassistant/components/*/rfxtrx.py @danielhiversen homeassistant/components/*/rfxtrx.py @danielhiversen
# S
homeassistant/components/simplisafe/* @bachya
homeassistant/components/*/simplisafe.py @bachya
# T # T
homeassistant/components/tahoma.py @philklei homeassistant/components/tahoma.py @philklei
homeassistant/components/*/tahoma.py @philklei homeassistant/components/*/tahoma.py @philklei

View file

@ -1,5 +1,5 @@
""" """
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 For more details about this platform, please refer to the documentation at
https://home-assistant.io/components/alarm_control_panel.simplisafe/ https://home-assistant.io/components/alarm_control_panel.simplisafe/
@ -7,86 +7,44 @@ https://home-assistant.io/components/alarm_control_panel.simplisafe/
import logging import logging
import re import re
import voluptuous as vol from homeassistant.components.alarm_control_panel import AlarmControlPanel
from homeassistant.components.simplisafe.const import (
from homeassistant.components.alarm_control_panel import ( DATA_CLIENT, DOMAIN, TOPIC_UPDATE)
PLATFORM_SCHEMA, AlarmControlPanel)
from homeassistant.const import ( from homeassistant.const import (
CONF_CODE, CONF_NAME, CONF_PASSWORD, CONF_USERNAME, STATE_ALARM_ARMED_AWAY, CONF_CODE, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME,
STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED) STATE_ALARM_DISARMED)
from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.core import callback
from homeassistant.util.json import load_json, save_json from homeassistant.helpers.dispatcher import async_dispatcher_connect
REQUIREMENTS = ['simplisafe-python==3.1.2']
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
ATTR_ALARM_ACTIVE = "alarm_active" ATTR_ALARM_ACTIVE = 'alarm_active'
ATTR_TEMPERATURE = "temperature" 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,
})
async def async_setup_platform( async def async_setup_platform(
hass, config, async_add_entities, discovery_info=None): hass, config, async_add_entities, discovery_info=None):
"""Set up the SimpliSafe platform.""" """Set up a SimpliSafe alarm control panel based on existing config."""
from simplipy import API pass
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) async def async_setup_entry(hass, entry, async_add_entities):
"""Set up a SimpliSafe alarm control panel based on a config entry."""
config_data = await hass.async_add_executor_job( systems = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id]
load_json, hass.config.path(DATA_FILE)) async_add_entities([
SimpliSafeAlarm(system, entry.data.get(CONF_CODE))
try: for system in systems
if config_data: ], True)
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
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)
class SimpliSafeAlarm(AlarmControlPanel): class SimpliSafeAlarm(AlarmControlPanel):
"""Representation of a SimpliSafe alarm.""" """Representation of a SimpliSafe alarm."""
def __init__(self, system, name, code): def __init__(self, system, code):
"""Initialize the SimpliSafe alarm.""" """Initialize the SimpliSafe alarm."""
self._async_unsub_dispatcher_connect = None
self._attrs = {} self._attrs = {}
self._code = str(code) if code else None self._code = code
self._name = name
self._system = system self._system = system
self._state = None self._state = None
@ -98,9 +56,7 @@ class SimpliSafeAlarm(AlarmControlPanel):
@property @property
def name(self): def name(self):
"""Return the name of the device.""" """Return the name of the device."""
if self._name: return self._system.address
return self._name
return 'Alarm {}'.format(self._system.system_id)
@property @property
def code_format(self): def code_format(self):
@ -128,6 +84,21 @@ class SimpliSafeAlarm(AlarmControlPanel):
_LOGGER.warning("Wrong code entered for %s", state) _LOGGER.warning("Wrong code entered for %s", state)
return check 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): async def async_alarm_disarm(self, code=None):
"""Send disarm command.""" """Send disarm command."""
if not self._validate_code(code, 'disarming'): if not self._validate_code(code, 'disarming'):
@ -151,22 +122,24 @@ class SimpliSafeAlarm(AlarmControlPanel):
async def async_update(self): async def async_update(self):
"""Update alarm status.""" """Update alarm status."""
await self._system.update() from simplipy.system import SystemStates
if self._system.state == self._system.SystemStates.off: await self._system.update()
self._state = STATE_ALARM_DISARMED
elif self._system.state in (
self._system.SystemStates.home,
self._system.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):
self._state = STATE_ALARM_ARMED_AWAY
else:
self._state = None
self._attrs[ATTR_ALARM_ACTIVE] = self._system.alarm_going_off self._attrs[ATTR_ALARM_ACTIVE] = self._system.alarm_going_off
if self._system.temperature: if self._system.temperature:
self._attrs[ATTR_TEMPERATURE] = 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 (SystemStates.home,
SystemStates.home_count):
self._state = STATE_ALARM_ARMED_HOME
elif self._system.state in (SystemStates.away, SystemStates.away_count,
SystemStates.exit_delay):
self._state = STATE_ALARM_ARMED_AWAY
else:
self._state = None

View file

@ -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"
}
}

View file

@ -0,0 +1,143 @@
"""
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={
**config_entry.data, CONF_TOKEN: token
})
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:
if 403 in str(err):
_LOGGER.error('Invalid credentials provided')
return False
_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:
_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()
return True

View file

@ -0,0 +1,80 @@
"""Config flow to configure the SimpliSafe component."""
from collections import OrderedDict
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.core import callback
from homeassistant.const import (
CONF_CODE, CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_TOKEN, CONF_USERNAME)
from homeassistant.helpers import aiohttp_client
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
@callback
def configured_instances(hass):
"""Return a set of configured SimpliSafe instances."""
return set(
entry.data[CONF_USERNAME]
for entry in hass.config_entries.async_entries(DOMAIN))
@config_entries.HANDLERS.register(DOMAIN)
class SimpliSafeFlowHandler(config_entries.ConfigFlow):
"""Handle a SimpliSafe config flow."""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
def __init__(self):
"""Initialize the config flow."""
self.data_schema = OrderedDict()
self.data_schema[vol.Required(CONF_USERNAME)] = str
self.data_schema[vol.Required(CONF_PASSWORD)] = str
self.data_schema[vol.Optional(CONF_CODE)] = str
async def _show_form(self, errors=None):
"""Show the form to the user."""
return self.async_show_form(
step_id='user',
data_schema=vol.Schema(self.data_schema),
errors=errors if errors else {},
)
async def async_step_import(self, import_config):
"""Import a config entry from configuration.yaml."""
return await self.async_step_user(import_config)
async def async_step_user(self, user_input=None):
"""Handle the start of the config flow."""
from simplipy import API
from simplipy.errors import SimplipyError
if not user_input:
return await self._show_form()
if user_input[CONF_USERNAME] in configured_instances(self.hass):
return await self._show_form({CONF_USERNAME: 'identifier_exists'})
username = user_input[CONF_USERNAME]
websession = aiohttp_client.async_get_clientsession(self.hass)
try:
simplisafe = await API.login_via_credentials(
username, user_input[CONF_PASSWORD], websession)
except SimplipyError:
return await self._show_form({'base': 'invalid_credentials'})
scan_interval = user_input.get(
CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
return self.async_create_entry(
title=user_input[CONF_USERNAME],
data={
CONF_USERNAME: username,
CONF_TOKEN: simplisafe.refresh_token,
CONF_SCAN_INTERVAL: scan_interval.seconds,
},
)

View file

@ -0,0 +1,10 @@
"""Define constants for the SimpliSafe component."""
from datetime import timedelta
DOMAIN = 'simplisafe'
DATA_CLIENT = 'client'
DEFAULT_SCAN_INTERVAL = timedelta(seconds=30)
TOPIC_UPDATE = 'update_{0}'

View file

@ -0,0 +1,19 @@
{
"config": {
"title": "SimpliSafe",
"step": {
"user": {
"title": "Fill in your information",
"data": {
"username": "Email Address",
"password": "Password",
"code": "Code (for Home Assistant)"
}
}
},
"error": {
"identifier_exists": "Account already registered",
"invalid_credentials": "Invalid credentials"
}
}
}

View file

@ -146,6 +146,7 @@ FLOWS = [
'mqtt', 'mqtt',
'nest', 'nest',
'openuv', 'openuv',
'simplisafe',
'smhi', 'smhi',
'sonos', 'sonos',
'tradfri', 'tradfri',

View file

@ -1346,8 +1346,8 @@ shodan==1.10.4
# homeassistant.components.notify.simplepush # homeassistant.components.notify.simplepush
simplepush==1.1.4 simplepush==1.1.4
# homeassistant.components.alarm_control_panel.simplisafe # homeassistant.components.simplisafe
simplisafe-python==3.1.2 simplisafe-python==3.1.7
# homeassistant.components.sisyphus # homeassistant.components.sisyphus
sisyphus-control==2.1 sisyphus-control==2.1

View file

@ -214,6 +214,9 @@ ring_doorbell==0.2.1
# homeassistant.components.media_player.yamaha # homeassistant.components.media_player.yamaha
rxv==0.5.1 rxv==0.5.1
# homeassistant.components.simplisafe
simplisafe-python==3.1.7
# homeassistant.components.sleepiq # homeassistant.components.sleepiq
sleepyq==0.6 sleepyq==0.6

View file

@ -97,6 +97,7 @@ TEST_REQUIREMENTS = (
'rflink', 'rflink',
'ring_doorbell', 'ring_doorbell',
'rxv', 'rxv',
'simplisafe-python',
'sleepyq', 'sleepyq',
'smhi-pkg', 'smhi-pkg',
'somecomfort', 'somecomfort',

View file

@ -0,0 +1 @@
"""Define tests for the SimpliSafe component."""

View file

@ -0,0 +1,120 @@
"""Define tests for the SimpliSafe config flow."""
import json
from datetime import timedelta
from unittest.mock import mock_open, patch, MagicMock, PropertyMock
from homeassistant import data_entry_flow
from homeassistant.components.simplisafe import DOMAIN, config_flow
from homeassistant.const import (
CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_TOKEN, CONF_USERNAME)
from tests.common import MockConfigEntry, mock_coro
def mock_api():
"""Mock SimpliSafe API class."""
api = MagicMock()
type(api).refresh_token = PropertyMock(return_value='12345abc')
return api
async def test_duplicate_error(hass):
"""Test that errors are shown when duplicates are added."""
conf = {
CONF_USERNAME: 'user@email.com',
CONF_PASSWORD: 'password',
}
MockConfigEntry(domain=DOMAIN, data=conf).add_to_hass(hass)
flow = config_flow.SimpliSafeFlowHandler()
flow.hass = hass
result = await flow.async_step_user(user_input=conf)
assert result['errors'] == {CONF_USERNAME: 'identifier_exists'}
async def test_invalid_credentials(hass):
"""Test that invalid credentials throws an error."""
from simplipy.errors import SimplipyError
conf = {
CONF_USERNAME: 'user@email.com',
CONF_PASSWORD: 'password',
}
flow = config_flow.SimpliSafeFlowHandler()
flow.hass = hass
with patch('simplipy.API.login_via_credentials',
return_value=mock_coro(exception=SimplipyError)):
result = await flow.async_step_user(user_input=conf)
assert result['errors'] == {'base': 'invalid_credentials'}
async def test_show_form(hass):
"""Test that the form is served with no input."""
flow = config_flow.SimpliSafeFlowHandler()
flow.hass = hass
result = await flow.async_step_user(user_input=None)
assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
assert result['step_id'] == 'user'
async def test_step_import(hass):
"""Test that the import step works."""
conf = {
CONF_USERNAME: 'user@email.com',
CONF_PASSWORD: 'password',
}
flow = config_flow.SimpliSafeFlowHandler()
flow.hass = hass
mop = mock_open(read_data=json.dumps({'refresh_token': '12345'}))
with patch('simplipy.API.login_via_credentials',
return_value=mock_coro(return_value=mock_api())):
with patch('homeassistant.util.json.open', mop, create=True):
with patch('homeassistant.util.json.os.open', return_value=0):
with patch('homeassistant.util.json.os.replace'):
result = await flow.async_step_import(import_config=conf)
assert result[
'type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result['title'] == 'user@email.com'
assert result['data'] == {
CONF_USERNAME: 'user@email.com',
CONF_TOKEN: '12345abc',
CONF_SCAN_INTERVAL: 30,
}
async def test_step_user(hass):
"""Test that the user step works."""
conf = {
CONF_USERNAME: 'user@email.com',
CONF_PASSWORD: 'password',
CONF_SCAN_INTERVAL: timedelta(seconds=90),
}
flow = config_flow.SimpliSafeFlowHandler()
flow.hass = hass
mop = mock_open(read_data=json.dumps({'refresh_token': '12345'}))
with patch('simplipy.API.login_via_credentials',
return_value=mock_coro(return_value=mock_api())):
with patch('homeassistant.util.json.open', mop, create=True):
with patch('homeassistant.util.json.os.open', return_value=0):
with patch('homeassistant.util.json.os.replace'):
result = await flow.async_step_user(user_input=conf)
assert result[
'type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result['title'] == 'user@email.com'
assert result['data'] == {
CONF_USERNAME: 'user@email.com',
CONF_TOKEN: '12345abc',
CONF_SCAN_INTERVAL: 90,
}