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