diff --git a/.coveragerc b/.coveragerc index 39c2bce5ae5..79afac5709f 100644 --- a/.coveragerc +++ b/.coveragerc @@ -191,6 +191,9 @@ omit = homeassistant/components/linode.py homeassistant/components/*/linode.py + homeassistant/components/logi_circle.py + homeassistant/components/*/logi_circle.py + homeassistant/components/lutron.py homeassistant/components/*/lutron.py diff --git a/homeassistant/components/camera/logi_circle.py b/homeassistant/components/camera/logi_circle.py new file mode 100644 index 00000000000..1dae58ad0f7 --- /dev/null +++ b/homeassistant/components/camera/logi_circle.py @@ -0,0 +1,210 @@ +""" +This component provides support to the Logi Circle camera. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.logi_circle/ +""" +import logging +import asyncio +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.helpers import config_validation as cv +from homeassistant.components.logi_circle import ( + DOMAIN as LOGI_CIRCLE_DOMAIN, CONF_ATTRIBUTION) +from homeassistant.components.camera import ( + Camera, PLATFORM_SCHEMA, CAMERA_SERVICE_SCHEMA, SUPPORT_ON_OFF, + ATTR_ENTITY_ID, ATTR_FILENAME, DOMAIN) +from homeassistant.const import ( + ATTR_ATTRIBUTION, ATTR_BATTERY_CHARGING, ATTR_BATTERY_LEVEL, + CONF_SCAN_INTERVAL, STATE_ON, STATE_OFF) + +DEPENDENCIES = ['logi_circle'] + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=60) + +SERVICE_SET_CONFIG = 'logi_circle_set_config' +SERVICE_LIVESTREAM_SNAPSHOT = 'logi_circle_livestream_snapshot' +SERVICE_LIVESTREAM_RECORD = 'logi_circle_livestream_record' +DATA_KEY = 'camera.logi_circle' + +BATTERY_SAVING_MODE_KEY = 'BATTERY_SAVING' +PRIVACY_MODE_KEY = 'PRIVACY_MODE' +LED_MODE_KEY = 'LED' + +ATTR_MODE = 'mode' +ATTR_VALUE = 'value' +ATTR_DURATION = 'duration' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL): + cv.time_period, +}) + +LOGI_CIRCLE_SERVICE_SET_CONFIG = CAMERA_SERVICE_SCHEMA.extend({ + vol.Required(ATTR_MODE): vol.In([BATTERY_SAVING_MODE_KEY, LED_MODE_KEY, + PRIVACY_MODE_KEY]), + vol.Required(ATTR_VALUE): cv.boolean +}) + +LOGI_CIRCLE_SERVICE_SNAPSHOT = CAMERA_SERVICE_SCHEMA.extend({ + vol.Required(ATTR_FILENAME): cv.template +}) + +LOGI_CIRCLE_SERVICE_RECORD = CAMERA_SERVICE_SCHEMA.extend({ + vol.Required(ATTR_FILENAME): cv.template, + vol.Required(ATTR_DURATION): cv.positive_int +}) + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Set up a Logi Circle Camera.""" + devices = hass.data[LOGI_CIRCLE_DOMAIN] + + cameras = [] + for device in devices: + cameras.append(LogiCam(device, config)) + + async_add_entities(cameras, True) + + async def service_handler(service): + """Dispatch service calls to target entities.""" + params = {key: value for key, value in service.data.items() + if key != ATTR_ENTITY_ID} + entity_ids = service.data.get(ATTR_ENTITY_ID) + if entity_ids: + target_devices = [dev for dev in cameras + if dev.entity_id in entity_ids] + else: + target_devices = cameras + + for target_device in target_devices: + if service.service == SERVICE_SET_CONFIG: + await target_device.set_config(**params) + if service.service == SERVICE_LIVESTREAM_SNAPSHOT: + await target_device.livestream_snapshot(**params) + if service.service == SERVICE_LIVESTREAM_RECORD: + await target_device.download_livestream(**params) + + hass.services.async_register( + DOMAIN, SERVICE_SET_CONFIG, service_handler, + schema=LOGI_CIRCLE_SERVICE_SET_CONFIG) + + hass.services.async_register( + DOMAIN, SERVICE_LIVESTREAM_SNAPSHOT, service_handler, + schema=LOGI_CIRCLE_SERVICE_SNAPSHOT) + + hass.services.async_register( + DOMAIN, SERVICE_LIVESTREAM_RECORD, service_handler, + schema=LOGI_CIRCLE_SERVICE_RECORD) + + +class LogiCam(Camera): + """An implementation of a Logi Circle camera.""" + + def __init__(self, camera, device_info): + """Initialize Logi Circle camera.""" + super().__init__() + self._camera = camera + self._name = self._camera.name + self._id = self._camera.mac_address + self._has_battery = self._camera.supports_feature('battery_level') + + @property + def unique_id(self): + """Return a unique ID.""" + return self._id + + @property + def name(self): + """Return the name of this camera.""" + return self._name + + @property + def supported_features(self): + """Logi Circle camera's support turning on and off ("soft" switch).""" + return SUPPORT_ON_OFF + + @property + def device_state_attributes(self): + """Return the state attributes.""" + state = { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + 'battery_saving_mode': ( + STATE_ON if self._camera.battery_saving else STATE_OFF), + 'ip_address': self._camera.ip_address, + 'microphone_gain': self._camera.microphone_gain + } + + # Add battery attributes if camera is battery-powered + if self._has_battery: + state[ATTR_BATTERY_CHARGING] = self._camera.is_charging + state[ATTR_BATTERY_LEVEL] = self._camera.battery_level + + return state + + async def async_camera_image(self): + """Return a still image from the camera.""" + return await self._camera.get_snapshot_image() + + async def async_turn_off(self): + """Disable streaming mode for this camera.""" + await self._camera.set_streaming_mode(False) + + async def async_turn_on(self): + """Enable streaming mode for this camera.""" + await self._camera.set_streaming_mode(True) + + @property + def should_poll(self): + """Update the image periodically.""" + return True + + async def set_config(self, mode, value): + """Set an configuration property for the target camera.""" + if mode == LED_MODE_KEY: + await self._camera.set_led(value) + if mode == PRIVACY_MODE_KEY: + await self._camera.set_privacy_mode(value) + if mode == BATTERY_SAVING_MODE_KEY: + await self._camera.set_battery_saving_mode(value) + + async def download_livestream(self, filename, duration): + """Download a recording from the camera's livestream.""" + # Render filename from template. + filename.hass = self.hass + stream_file = filename.async_render( + variables={ATTR_ENTITY_ID: self.entity_id}) + + # Respect configured path whitelist. + if not self.hass.config.is_allowed_path(stream_file): + _LOGGER.error( + "Can't write %s, no access to path!", stream_file) + return + + asyncio.shield(self._camera.record_livestream( + stream_file, timedelta(seconds=duration)), loop=self.hass.loop) + + async def livestream_snapshot(self, filename): + """Download a still frame from the camera's livestream.""" + # Render filename from template. + filename.hass = self.hass + snapshot_file = filename.async_render( + variables={ATTR_ENTITY_ID: self.entity_id}) + + # Respect configured path whitelist. + if not self.hass.config.is_allowed_path(snapshot_file): + _LOGGER.error( + "Can't write %s, no access to path!", snapshot_file) + return + + asyncio.shield(self._camera.get_livestream_image( + snapshot_file), loop=self.hass.loop) + + async def async_update(self): + """Update camera entity and refresh attributes.""" + await self._camera.update() diff --git a/homeassistant/components/camera/services.yaml b/homeassistant/components/camera/services.yaml index b977fcd5c52..1cae5baf1cf 100644 --- a/homeassistant/components/camera/services.yaml +++ b/homeassistant/components/camera/services.yaml @@ -63,3 +63,39 @@ onvif_ptz: zoom: description: "Zoom. Allowed values: ZOOM_IN, ZOOM_OUT" example: "ZOOM_IN" + +logi_circle_set_config: + description: Set a configuration property. + fields: + entity_id: + description: Name(s) of entities to apply the operation mode to. + example: "camera.living_room_camera" + mode: + description: "Operation mode. Allowed values: BATTERY_SAVING, LED, PRIVACY_MODE." + example: "PRIVACY_MODE" + value: + description: "Operation value. Allowed values: true, false" + example: true + +logi_circle_livestream_snapshot: + description: Take a snapshot from the camera's livestream. Will wake the camera from sleep if required. + fields: + entity_id: + description: Name(s) of entities to create snapshots from. + example: "camera.living_room_camera" + filename: + description: Template of a Filename. Variable is entity_id. + example: "/tmp/snapshot_{{ entity_id }}.jpg" + +logi_circle_livestream_record: + description: Take a video recording from the camera's livestream. + fields: + entity_id: + description: Name(s) of entities to create recordings from. + example: "camera.living_room_camera" + filename: + description: Template of a Filename. Variable is entity_id. + example: "/tmp/snapshot_{{ entity_id }}.mp4" + duration: + description: Recording duration in seconds. + example: 60 diff --git a/homeassistant/components/logi_circle.py b/homeassistant/components/logi_circle.py new file mode 100644 index 00000000000..c0a7f4c2621 --- /dev/null +++ b/homeassistant/components/logi_circle.py @@ -0,0 +1,80 @@ +""" +Support for Logi Circle cameras. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/logi_circle/ +""" +import logging +import asyncio + +import voluptuous as vol +import async_timeout + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD + +REQUIREMENTS = ['logi_circle==0.1.7'] + +_LOGGER = logging.getLogger(__name__) +_TIMEOUT = 15 # seconds + +CONF_ATTRIBUTION = "Data provided by circle.logi.com" + +NOTIFICATION_ID = 'logi_notification' +NOTIFICATION_TITLE = 'Logi Circle Setup' + +DOMAIN = 'logi_circle' +DEFAULT_CACHEDB = '.logi_cache.pickle' +DEFAULT_ENTITY_NAMESPACE = 'logi_circle' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up the Logi Circle component.""" + conf = config[DOMAIN] + username = conf[CONF_USERNAME] + password = conf[CONF_PASSWORD] + + try: + from logi_circle import Logi + from logi_circle.exception import BadLogin + from aiohttp.client_exceptions import ClientResponseError + + cache = hass.config.path(DEFAULT_CACHEDB) + logi = Logi(username=username, password=password, cache_file=cache) + + with async_timeout.timeout(_TIMEOUT, loop=hass.loop): + await logi.login() + hass.data[DOMAIN] = await logi.cameras + + if not logi.is_connected: + return False + except (BadLogin, ClientResponseError) as ex: + _LOGGER.error('Unable to connect to Logi Circle API: %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 + except asyncio.TimeoutError: + # The TimeoutError exception object returns nothing when casted to a + # string, so we'll handle it separately. + err = '{}s timeout exceeded when connecting to Logi Circle API'.format( + _TIMEOUT) + _LOGGER.error(err) + hass.components.persistent_notification.create( + 'Error: {}
' + 'You will need to restart hass after fixing.' + ''.format(err), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + return False + return True diff --git a/homeassistant/components/sensor/logi_circle.py b/homeassistant/components/sensor/logi_circle.py new file mode 100644 index 00000000000..a0a2ca96444 --- /dev/null +++ b/homeassistant/components/sensor/logi_circle.py @@ -0,0 +1,156 @@ +""" +This component provides HA sensor support for Logi Circle cameras. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.logi_circle/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.logi_circle import ( + CONF_ATTRIBUTION, DEFAULT_ENTITY_NAMESPACE, DOMAIN as LOGI_CIRCLE_DOMAIN) +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_ATTRIBUTION, ATTR_BATTERY_CHARGING, + CONF_ENTITY_NAMESPACE, CONF_MONITORED_CONDITIONS, + STATE_ON, STATE_OFF) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.icon import icon_for_battery_level +from homeassistant.util.dt import as_local + +DEPENDENCIES = ['logi_circle'] + +_LOGGER = logging.getLogger(__name__) + +# Sensor types: Name, unit of measure, icon per sensor key. +SENSOR_TYPES = { + 'battery_level': [ + 'Battery', '%', 'battery-50'], + + 'last_activity_time': [ + 'Last Activity', None, 'history'], + + 'privacy_mode': [ + 'Privacy Mode', None, 'eye'], + + 'signal_strength_category': [ + 'WiFi Signal Category', None, 'wifi'], + + 'signal_strength_percentage': [ + 'WiFi Signal Strength', '%', 'wifi'], + + 'speaker_volume': [ + 'Volume', '%', 'volume-high'], + + 'streaming_mode': [ + 'Streaming Mode', None, 'camera'], +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_ENTITY_NAMESPACE, default=DEFAULT_ENTITY_NAMESPACE): + cv.string, + vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), +}) + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Set up a sensor for a Logi Circle device.""" + devices = hass.data[LOGI_CIRCLE_DOMAIN] + time_zone = str(hass.config.time_zone) + + sensors = [] + for sensor_type in config.get(CONF_MONITORED_CONDITIONS): + for device in devices: + if device.supports_feature(sensor_type): + sensors.append(LogiSensor(device, time_zone, sensor_type)) + + async_add_entities(sensors, True) + + +class LogiSensor(Entity): + """A sensor implementation for a Logi Circle camera.""" + + def __init__(self, camera, time_zone, sensor_type): + """Initialize a sensor for Logi Circle camera.""" + self._sensor_type = sensor_type + self._camera = camera + self._id = '{}-{}'.format(self._camera.mac_address, self._sensor_type) + self._icon = 'mdi:{}'.format(SENSOR_TYPES.get(self._sensor_type)[2]) + self._name = "{0} {1}".format( + self._camera.name, SENSOR_TYPES.get(self._sensor_type)[0]) + self._state = None + self._tz = time_zone + + @property + def unique_id(self): + """Return a unique ID.""" + return self._id + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def device_state_attributes(self): + """Return the state attributes.""" + state = { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + 'battery_saving_mode': ( + STATE_ON if self._camera.battery_saving else STATE_OFF), + 'ip_address': self._camera.ip_address, + 'microphone_gain': self._camera.microphone_gain + } + + if self._sensor_type == 'battery_level': + state[ATTR_BATTERY_CHARGING] = self._camera.is_charging + + return state + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + if (self._sensor_type == 'battery_level' and + self._state is not None): + return icon_for_battery_level(battery_level=int(self._state), + charging=False) + if (self._sensor_type == 'privacy_mode' and + self._state is not None): + return 'mdi:eye-off' if self._state == STATE_ON else 'mdi:eye' + if (self._sensor_type == 'streaming_mode' and + self._state is not None): + return ( + 'mdi:camera' if self._state == STATE_ON else 'mdi:camera-off') + return self._icon + + @property + def unit_of_measurement(self): + """Return the units of measurement.""" + return SENSOR_TYPES.get(self._sensor_type)[1] + + async def update(self): + """Get the latest data and updates the state.""" + _LOGGER.debug("Pulling data from %s sensor", self._name) + await self._camera.update() + + if self._sensor_type == 'last_activity_time': + last_activity = await self._camera.last_activity + if last_activity is not None: + last_activity_time = as_local(last_activity.end_time_utc) + self._state = '{0:0>2}:{1:0>2}'.format( + last_activity_time.hour, last_activity_time.minute) + else: + state = getattr(self._camera, self._sensor_type, None) + if isinstance(state, bool): + self._state = STATE_ON if state is True else STATE_OFF + else: + self._state = state diff --git a/requirements_all.txt b/requirements_all.txt index 1758e96c643..2c9871962f9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -556,6 +556,9 @@ lmnotify==0.0.4 # homeassistant.components.device_tracker.google_maps locationsharinglib==2.0.11 +# homeassistant.components.logi_circle +logi_circle==0.1.7 + # homeassistant.components.sensor.luftdaten luftdaten==0.2.0