From f892c3394b4a2d2a2e77e9472b91409380875cf0 Mon Sep 17 00:00:00 2001 From: Joe Lu Date: Fri, 8 Dec 2017 01:40:45 -0800 Subject: [PATCH] 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 --- .coveragerc | 2 + .../components/alarm_control_panel/canary.py | 92 +++++++++++++ homeassistant/components/camera/canary.py | 95 +++++++++++++ homeassistant/components/canary.py | 117 ++++++++++++++++ homeassistant/components/sensor/canary.py | 85 ++++++++++++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + script/gen_requirements_all.py | 1 + tests/components/sensor/test_canary.py | 125 ++++++++++++++++++ tests/components/test_canary.py | 85 ++++++++++++ 10 files changed, 608 insertions(+) create mode 100644 homeassistant/components/alarm_control_panel/canary.py create mode 100644 homeassistant/components/camera/canary.py create mode 100644 homeassistant/components/canary.py create mode 100644 homeassistant/components/sensor/canary.py create mode 100644 tests/components/sensor/test_canary.py create mode 100644 tests/components/test_canary.py diff --git a/.coveragerc b/.coveragerc index e97d197ca94..9db732dfbde 100644 --- a/.coveragerc +++ b/.coveragerc @@ -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 diff --git a/homeassistant/components/alarm_control_panel/canary.py b/homeassistant/components/alarm_control_panel/canary.py new file mode 100644 index 00000000000..fb5c4c37e8d --- /dev/null +++ b/homeassistant/components/alarm_control_panel/canary.py @@ -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) diff --git a/homeassistant/components/camera/canary.py b/homeassistant/components/camera/canary.py new file mode 100644 index 00000000000..302758eee94 --- /dev/null +++ b/homeassistant/components/camera/canary.py @@ -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 diff --git a/homeassistant/components/canary.py b/homeassistant/components/canary.py new file mode 100644 index 00000000000..8ab7218e201 --- /dev/null +++ b/homeassistant/components/canary.py @@ -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: {}
' + '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) diff --git a/homeassistant/components/sensor/canary.py b/homeassistant/components/sensor/canary.py new file mode 100644 index 00000000000..b0d2c27ae5d --- /dev/null +++ b/homeassistant/components/sensor/canary.py @@ -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) diff --git a/requirements_all.txt b/requirements_all.txt index b34b7b9bb50..c09b4f6a93b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 72325d6305b..c932ce7ead9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index fbd60ffdadc..bdc75f3a69c 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -61,6 +61,7 @@ TEST_REQUIREMENTS = ( 'pilight', 'pmsensor', 'prometheus_client', + 'py-canary', 'pydispatcher', 'PyJWT', 'pylitejet', diff --git a/tests/components/sensor/test_canary.py b/tests/components/sensor/test_canary.py new file mode 100644 index 00000000000..99df05f36a4 --- /dev/null +++ b/tests/components/sensor/test_canary.py @@ -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) diff --git a/tests/components/test_canary.py b/tests/components/test_canary.py new file mode 100644 index 00000000000..67122813fb7 --- /dev/null +++ b/tests/components/test_canary.py @@ -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))