Add support for Canary component and platforms (#10306)

* Add Canary component

* Made some change to how canary data is updated and stored

* Updated to use py-canary:0.1.2

* Addressed flake8 warnings

* Import canary API locally

* Import canary API locally again

* Addressed pylint errors

* Updated requirements_all.txt

* Fixed incorrect unit of measurement for air quality sensor

* Added tests for Canary component and sensors

* Updated canary component to handle exception better when initializing

* Fixed tests

* Fixed tests again

* Addressed review comments

* Fixed houndci error

* Addressed comment about camera force update

* Addressed comment regarding timeout when fetching camera image

* Updated to use py-canary==0.2.2

* Increased update frequency to 30 seconds

* Added support for Canary alarm control panel

* Address review comments

* Fixed houndci error

* Fixed lint errors

* Updated test to only test setup component / platform

* Fixed flake error

* Fixed failing test

* Uptake py-canary:0.2.3

* canary.alarm_control_panel DISARM is now mapped to canary PRIVACY mode

* Fixed failing tests

* Removed unnecessary methods

* Removed polling in canary camera component and update camera info when getting camera image

* Added more tests to cover Canary sensors

* Address review comments

* Addressed review comment in tests

* Fixed pylint errors

* Excluded canary alarm_control_panel and camera from coverage calculation
This commit is contained in:
Joe Lu 2017-12-08 01:40:45 -08:00 committed by Martin Hjelmare
parent 929d49ed6f
commit f892c3394b
10 changed files with 608 additions and 0 deletions

View File

@ -264,6 +264,7 @@ omit =
homeassistant/components/*/zoneminder.py
homeassistant/components/alarm_control_panel/alarmdotcom.py
homeassistant/components/alarm_control_panel/canary.py
homeassistant/components/alarm_control_panel/concord232.py
homeassistant/components/alarm_control_panel/egardia.py
homeassistant/components/alarm_control_panel/ialarm.py
@ -285,6 +286,7 @@ omit =
homeassistant/components/browser.py
homeassistant/components/calendar/todoist.py
homeassistant/components/camera/bloomsky.py
homeassistant/components/camera/canary.py
homeassistant/components/camera/ffmpeg.py
homeassistant/components/camera/foscam.py
homeassistant/components/camera/mjpeg.py

View File

@ -0,0 +1,92 @@
"""
Support for Canary alarm.
For more details about this platform, please refer to the documentation at
https://home-assistant.io/components/alarm_control_panel.canary/
"""
import logging
from homeassistant.components.alarm_control_panel import AlarmControlPanel
from homeassistant.components.canary import DATA_CANARY
from homeassistant.const import STATE_ALARM_DISARMED, STATE_ALARM_ARMED_AWAY, \
STATE_ALARM_ARMED_NIGHT, STATE_ALARM_ARMED_HOME
DEPENDENCIES = ['canary']
_LOGGER = logging.getLogger(__name__)
def setup_platform(hass, config, add_devices, discovery_info=None):
"""Set up the Canary alarms."""
data = hass.data[DATA_CANARY]
devices = []
for location in data.locations:
devices.append(CanaryAlarm(data, location.location_id))
add_devices(devices, True)
class CanaryAlarm(AlarmControlPanel):
"""Representation of a Canary alarm control panel."""
def __init__(self, data, location_id):
"""Initialize a Canary security camera."""
self._data = data
self._location_id = location_id
@property
def name(self):
"""Return the name of the alarm."""
location = self._data.get_location(self._location_id)
return location.name
@property
def state(self):
"""Return the state of the device."""
from canary.api import LOCATION_MODE_AWAY, LOCATION_MODE_HOME, \
LOCATION_MODE_NIGHT
location = self._data.get_location(self._location_id)
if location.is_private:
return STATE_ALARM_DISARMED
mode = location.mode
if mode.name == LOCATION_MODE_AWAY:
return STATE_ALARM_ARMED_AWAY
elif mode.name == LOCATION_MODE_HOME:
return STATE_ALARM_ARMED_HOME
elif mode.name == LOCATION_MODE_NIGHT:
return STATE_ALARM_ARMED_NIGHT
else:
return None
@property
def device_state_attributes(self):
"""Return the state attributes."""
location = self._data.get_location(self._location_id)
return {
'private': location.is_private
}
def alarm_disarm(self, code=None):
"""Send disarm command."""
location = self._data.get_location(self._location_id)
self._data.set_location_mode(self._location_id, location.mode.name,
True)
def alarm_arm_home(self, code=None):
"""Send arm home command."""
from canary.api import LOCATION_MODE_HOME
self._data.set_location_mode(self._location_id, LOCATION_MODE_HOME)
def alarm_arm_away(self, code=None):
"""Send arm away command."""
from canary.api import LOCATION_MODE_AWAY
self._data.set_location_mode(self._location_id, LOCATION_MODE_AWAY)
def alarm_arm_night(self, code=None):
"""Send arm night command."""
from canary.api import LOCATION_MODE_NIGHT
self._data.set_location_mode(self._location_id, LOCATION_MODE_NIGHT)

View File

@ -0,0 +1,95 @@
"""
Support for Canary camera.
For more details about this platform, please refer to the documentation at
https://home-assistant.io/components/camera.canary/
"""
import logging
import requests
from homeassistant.components.camera import Camera
from homeassistant.components.canary import DATA_CANARY, DEFAULT_TIMEOUT
DEPENDENCIES = ['canary']
_LOGGER = logging.getLogger(__name__)
ATTR_MOTION_START_TIME = "motion_start_time"
ATTR_MOTION_END_TIME = "motion_end_time"
def setup_platform(hass, config, add_devices, discovery_info=None):
"""Set up the Canary sensors."""
data = hass.data[DATA_CANARY]
devices = []
for location in data.locations:
entries = data.get_motion_entries(location.location_id)
if entries:
devices.append(CanaryCamera(data, location.location_id,
DEFAULT_TIMEOUT))
add_devices(devices, True)
class CanaryCamera(Camera):
"""An implementation of a Canary security camera."""
def __init__(self, data, location_id, timeout):
"""Initialize a Canary security camera."""
super().__init__()
self._data = data
self._location_id = location_id
self._timeout = timeout
self._location = None
self._motion_entry = None
self._image_content = None
def camera_image(self):
"""Update the status of the camera and return bytes of camera image."""
self.update()
return self._image_content
@property
def name(self):
"""Return the name of this device."""
return self._location.name
@property
def is_recording(self):
"""Return true if the device is recording."""
return self._location.is_recording
@property
def device_state_attributes(self):
"""Return device specific state attributes."""
if self._motion_entry is None:
return None
return {
ATTR_MOTION_START_TIME: self._motion_entry.start_time,
ATTR_MOTION_END_TIME: self._motion_entry.end_time,
}
def update(self):
"""Update the status of the camera."""
self._data.update()
self._location = self._data.get_location(self._location_id)
entries = self._data.get_motion_entries(self._location_id)
if entries:
current = entries[0]
previous = self._motion_entry
if previous is None or previous.entry_id != current.entry_id:
self._motion_entry = current
self._image_content = requests.get(
current.thumbnails[0].image_url,
timeout=self._timeout).content
@property
def motion_detection_enabled(self):
"""Return the camera motion detection status."""
return not self._location.is_recording

View File

@ -0,0 +1,117 @@
"""
Support for Canary.
For more details about this component, please refer to the documentation at
https://home-assistant.io/components/canary/
"""
import logging
from datetime import timedelta
import voluptuous as vol
from requests import ConnectTimeout, HTTPError
import homeassistant.helpers.config_validation as cv
from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_TIMEOUT
from homeassistant.helpers import discovery
from homeassistant.util import Throttle
REQUIREMENTS = ['py-canary==0.2.3']
_LOGGER = logging.getLogger(__name__)
NOTIFICATION_ID = 'canary_notification'
NOTIFICATION_TITLE = 'Canary Setup'
DOMAIN = 'canary'
DATA_CANARY = 'canary'
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30)
DEFAULT_TIMEOUT = 10
CONFIG_SCHEMA = vol.Schema({
DOMAIN: vol.Schema({
vol.Required(CONF_USERNAME): cv.string,
vol.Required(CONF_PASSWORD): cv.string,
vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
}),
}, extra=vol.ALLOW_EXTRA)
CANARY_COMPONENTS = [
'alarm_control_panel', 'camera', 'sensor'
]
def setup(hass, config):
"""Set up the Canary component."""
conf = config[DOMAIN]
username = conf.get(CONF_USERNAME)
password = conf.get(CONF_PASSWORD)
timeout = conf.get(CONF_TIMEOUT)
try:
hass.data[DATA_CANARY] = CanaryData(username, password, timeout)
except (ConnectTimeout, HTTPError) as ex:
_LOGGER.error("Unable to connect to Canary service: %s", str(ex))
hass.components.persistent_notification.create(
'Error: {}<br />'
'You will need to restart hass after fixing.'
''.format(ex),
title=NOTIFICATION_TITLE,
notification_id=NOTIFICATION_ID)
return False
for component in CANARY_COMPONENTS:
discovery.load_platform(hass, component, DOMAIN, {}, config)
return True
class CanaryData(object):
"""Get the latest data and update the states."""
def __init__(self, username, password, timeout):
"""Init the Canary data object."""
from canary.api import Api
self._api = Api(username, password, timeout)
self._locations_by_id = {}
self._readings_by_device_id = {}
self._entries_by_location_id = {}
self.update()
@Throttle(MIN_TIME_BETWEEN_UPDATES)
def update(self, **kwargs):
"""Get the latest data from py-canary."""
for location in self._api.get_locations():
location_id = location.location_id
self._locations_by_id[location_id] = location
self._entries_by_location_id[location_id] = self._api.get_entries(
location_id, entry_type="motion", limit=1)
for device in location.devices:
if device.is_online:
self._readings_by_device_id[device.device_id] = \
self._api.get_latest_readings(device.device_id)
@property
def locations(self):
"""Return a list of locations."""
return self._locations_by_id.values()
def get_motion_entries(self, location_id):
"""Return a list of motion entries based on location_id."""
return self._entries_by_location_id.get(location_id, [])
def get_location(self, location_id):
"""Return a location based on location_id."""
return self._locations_by_id.get(location_id, [])
def get_readings(self, device_id):
"""Return a list of readings based on device_id."""
return self._readings_by_device_id.get(device_id, [])
def set_location_mode(self, location_id, mode_name, is_private=False):
"""Set location mode."""
self._api.set_location_mode(location_id, mode_name, is_private)
self.update(no_throttle=True)

View File

@ -0,0 +1,85 @@
"""
Support for Canary sensors.
For more details about this platform, please refer to the documentation at
https://home-assistant.io/components/sensor.canary/
"""
from homeassistant.components.canary import DATA_CANARY
from homeassistant.const import TEMP_FAHRENHEIT, TEMP_CELSIUS
from homeassistant.helpers.entity import Entity
DEPENDENCIES = ['canary']
SENSOR_VALUE_PRECISION = 1
def setup_platform(hass, config, add_devices, discovery_info=None):
"""Set up the Canary sensors."""
data = hass.data[DATA_CANARY]
devices = []
from canary.api import SensorType
for location in data.locations:
for device in location.devices:
if device.is_online:
for sensor_type in SensorType:
devices.append(CanarySensor(data, sensor_type, location,
device))
add_devices(devices, True)
class CanarySensor(Entity):
"""Representation of a Canary sensor."""
def __init__(self, data, sensor_type, location, device):
"""Initialize the sensor."""
self._data = data
self._sensor_type = sensor_type
self._device_id = device.device_id
self._is_celsius = location.is_celsius
self._sensor_value = None
sensor_type_name = sensor_type.value.replace("_", " ").title()
self._name = '{} {} {}'.format(location.name,
device.name,
sensor_type_name)
@property
def name(self):
"""Return the name of the Canary sensor."""
return self._name
@property
def state(self):
"""Return the state of the sensor."""
return self._sensor_value
@property
def unique_id(self):
"""Return the unique ID of this sensor."""
return "sensor_canary_{}_{}".format(self._device_id,
self._sensor_type.value)
@property
def unit_of_measurement(self):
"""Return the unit of measurement this sensor expresses itself in."""
from canary.api import SensorType
if self._sensor_type == SensorType.TEMPERATURE:
return TEMP_CELSIUS if self._is_celsius else TEMP_FAHRENHEIT
elif self._sensor_type == SensorType.HUMIDITY:
return "%"
elif self._sensor_type == SensorType.AIR_QUALITY:
return ""
return None
def update(self):
"""Get the latest state of the sensor."""
self._data.update()
readings = self._data.get_readings(self._device_id)
value = next((
reading.value for reading in readings
if reading.sensor_type == self._sensor_type), None)
if value is not None:
self._sensor_value = round(float(value), SENSOR_VALUE_PRECISION)

View File

@ -581,6 +581,9 @@ pushetta==1.0.15
# homeassistant.components.light.rpi_gpio_pwm
pwmled==1.2.1
# homeassistant.components.canary
py-canary==0.2.3
# homeassistant.components.sensor.cpuspeed
py-cpuinfo==3.3.0

View File

@ -115,6 +115,9 @@ pmsensor==0.4
# homeassistant.components.prometheus
prometheus_client==0.0.21
# homeassistant.components.canary
py-canary==0.2.3
# homeassistant.components.zwave
pydispatcher==2.0.5

View File

@ -61,6 +61,7 @@ TEST_REQUIREMENTS = (
'pilight',
'pmsensor',
'prometheus_client',
'py-canary',
'pydispatcher',
'PyJWT',
'pylitejet',

View File

@ -0,0 +1,125 @@
"""The tests for the Canary sensor platform."""
import copy
import unittest
from unittest.mock import patch, Mock
from canary.api import SensorType
from homeassistant.components import canary as base_canary
from homeassistant.components.canary import DATA_CANARY
from homeassistant.components.sensor import canary
from homeassistant.components.sensor.canary import CanarySensor
from tests.common import (get_test_home_assistant)
from tests.components.test_canary import mock_device, mock_reading, \
mock_location
VALID_CONFIG = {
"canary": {
"username": "foo@bar.org",
"password": "bar",
}
}
class TestCanarySensorSetup(unittest.TestCase):
"""Test the Canary platform."""
DEVICES = []
def add_devices(self, devices, action):
"""Mock add devices."""
for device in devices:
self.DEVICES.append(device)
def setUp(self):
"""Initialize values for this testcase class."""
self.hass = get_test_home_assistant()
self.config = copy.deepcopy(VALID_CONFIG)
def tearDown(self):
"""Stop everything that was started."""
self.hass.stop()
@patch('homeassistant.components.canary.CanaryData')
def test_setup_sensors(self, mock_canary):
"""Test the sensor setup."""
base_canary.setup(self.hass, self.config)
online_device_at_home = mock_device(20, "Dining Room", True)
offline_device_at_home = mock_device(21, "Front Yard", False)
online_device_at_work = mock_device(22, "Office", True)
self.hass.data[DATA_CANARY] = mock_canary()
self.hass.data[DATA_CANARY].locations = [
mock_location("Home", True, devices=[online_device_at_home,
offline_device_at_home]),
mock_location("Work", True, devices=[online_device_at_work]),
]
canary.setup_platform(self.hass, self.config, self.add_devices, None)
self.assertEqual(6, len(self.DEVICES))
def test_celsius_temperature_sensor(self):
"""Test temperature sensor with celsius."""
device = mock_device(10, "Family Room")
location = mock_location("Home", True)
data = Mock()
data.get_readings.return_value = [
mock_reading(SensorType.TEMPERATURE, 21.1234)]
sensor = CanarySensor(data, SensorType.TEMPERATURE, location, device)
sensor.update()
self.assertEqual("Home Family Room Temperature", sensor.name)
self.assertEqual("sensor_canary_10_temperature", sensor.unique_id)
self.assertEqual("°C", sensor.unit_of_measurement)
self.assertEqual(21.1, sensor.state)
def test_fahrenheit_temperature_sensor(self):
"""Test temperature sensor with fahrenheit."""
device = mock_device(10, "Family Room")
location = mock_location("Home", False)
data = Mock()
data.get_readings.return_value = [
mock_reading(SensorType.TEMPERATURE, 21.1567)]
sensor = CanarySensor(data, SensorType.TEMPERATURE, location, device)
sensor.update()
self.assertEqual("Home Family Room Temperature", sensor.name)
self.assertEqual("°F", sensor.unit_of_measurement)
self.assertEqual(21.2, sensor.state)
def test_humidity_sensor(self):
"""Test humidity sensor."""
device = mock_device(10, "Family Room")
location = mock_location("Home")
data = Mock()
data.get_readings.return_value = [
mock_reading(SensorType.HUMIDITY, 50.4567)]
sensor = CanarySensor(data, SensorType.HUMIDITY, location, device)
sensor.update()
self.assertEqual("Home Family Room Humidity", sensor.name)
self.assertEqual("%", sensor.unit_of_measurement)
self.assertEqual(50.5, sensor.state)
def test_air_quality_sensor(self):
"""Test air quality sensor."""
device = mock_device(10, "Family Room")
location = mock_location("Home")
data = Mock()
data.get_readings.return_value = [
mock_reading(SensorType.AIR_QUALITY, 50.4567)]
sensor = CanarySensor(data, SensorType.AIR_QUALITY, location, device)
sensor.update()
self.assertEqual("Home Family Room Air Quality", sensor.name)
self.assertEqual("", sensor.unit_of_measurement)
self.assertEqual(50.5, sensor.state)

View File

@ -0,0 +1,85 @@
"""The tests for the Canary component."""
import unittest
from unittest.mock import patch, MagicMock, PropertyMock
import homeassistant.components.canary as canary
from homeassistant import setup
from tests.common import (
get_test_home_assistant)
def mock_device(device_id, name, is_online=True):
"""Mock Canary Device class."""
device = MagicMock()
type(device).device_id = PropertyMock(return_value=device_id)
type(device).name = PropertyMock(return_value=name)
type(device).is_online = PropertyMock(return_value=is_online)
return device
def mock_location(name, is_celsius=True, devices=[]):
"""Mock Canary Location class."""
location = MagicMock()
type(location).name = PropertyMock(return_value=name)
type(location).is_celsius = PropertyMock(return_value=is_celsius)
type(location).devices = PropertyMock(return_value=devices)
return location
def mock_reading(sensor_type, sensor_value):
"""Mock Canary Reading class."""
reading = MagicMock()
type(reading).sensor_type = PropertyMock(return_value=sensor_type)
type(reading).value = PropertyMock(return_value=sensor_value)
return reading
class TestCanary(unittest.TestCase):
"""Tests the Canary component."""
def setUp(self):
"""Initialize values for this test case class."""
self.hass = get_test_home_assistant()
def tearDown(self): # pylint: disable=invalid-name
"""Stop everything that was started."""
self.hass.stop()
@patch('homeassistant.components.canary.CanaryData.update')
@patch('canary.api.Api.login')
def test_setup_with_valid_config(self, mock_login, mock_update):
"""Test setup component."""
config = {
"canary": {
"username": "foo@bar.org",
"password": "bar",
}
}
self.assertTrue(
setup.setup_component(self.hass, canary.DOMAIN, config))
mock_update.assert_called_once_with()
mock_login.assert_called_once_with()
def test_setup_with_missing_password(self):
"""Test setup component."""
config = {
"canary": {
"username": "foo@bar.org",
}
}
self.assertFalse(
setup.setup_component(self.hass, canary.DOMAIN, config))
def test_setup_with_missing_username(self):
"""Test setup component."""
config = {
"canary": {
"password": "bar",
}
}
self.assertFalse(
setup.setup_component(self.hass, canary.DOMAIN, config))