Logi Circle public API refactor and config flow (#20624)

* Logi Circle now uses OAuth2 for authentication, added config flow.

* Service calls now dispatched to camera entities via signalled events

* Update from PR review

* Add unit tests for config flow

* Updated CODEOWNERS

* Reverted change to .coveragerc

* Improved test coverage of config flow
pull/22940/head
Evan Bruhn 2019-04-09 22:26:58 +10:00 committed by Martin Hjelmare
parent f81ce0b720
commit a48c0f2991
16 changed files with 822 additions and 210 deletions

View File

@ -321,7 +321,10 @@ omit =
homeassistant/components/liveboxplaytv/media_player.py homeassistant/components/liveboxplaytv/media_player.py
homeassistant/components/llamalab_automate/notify.py homeassistant/components/llamalab_automate/notify.py
homeassistant/components/lockitron/lock.py homeassistant/components/lockitron/lock.py
homeassistant/components/logi_circle/* homeassistant/components/logi_circle/__init__.py
homeassistant/components/logi_circle/camera.py
homeassistant/components/logi_circle/const.py
homeassistant/components/logi_circle/sensor.py
homeassistant/components/london_underground/sensor.py homeassistant/components/london_underground/sensor.py
homeassistant/components/loopenergy/sensor.py homeassistant/components/loopenergy/sensor.py
homeassistant/components/luci/device_tracker.py homeassistant/components/luci/device_tracker.py

View File

@ -125,6 +125,7 @@ homeassistant/components/lifx_legacy/* @amelchio
homeassistant/components/linux_battery/* @fabaff homeassistant/components/linux_battery/* @fabaff
homeassistant/components/liveboxplaytv/* @pschmitt homeassistant/components/liveboxplaytv/* @pschmitt
homeassistant/components/logger/* @home-assistant/core homeassistant/components/logger/* @home-assistant/core
homeassistant/components/logi_circle/* @evanjd
homeassistant/components/lovelace/* @home-assistant/core homeassistant/components/lovelace/* @home-assistant/core
homeassistant/components/luci/* @fbradyirl homeassistant/components/luci/* @fbradyirl
homeassistant/components/luftdaten/* @fabaff homeassistant/components/luftdaten/* @fabaff

View File

@ -93,39 +93,3 @@ onvif_ptz:
zoom: zoom:
description: "Zoom. Allowed values: ZOOM_IN, ZOOM_OUT" description: "Zoom. Allowed values: ZOOM_IN, ZOOM_OUT"
example: "ZOOM_IN" 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

View File

@ -0,0 +1,32 @@
{
"config": {
"title": "Logi Circle",
"step": {
"user": {
"title": "Authentication Provider",
"description": "Pick via which authentication provider you want to authenticate with Logi Circle.",
"data": {
"flow_impl": "Provider"
}
},
"auth": {
"title": "Authenticate with Logi Circle",
"description": "Please follow the link below and <b>Accept</b> access to your Logi Circle account, then come back and press <b>Submit</b> below.\n\n[Link]({authorization_url})"
}
},
"create_entry": {
"default": "Successfully authenticated with Logi Circle."
},
"error": {
"auth_error": "API authorization failed.",
"auth_timeout": "Authorization timed out when requesting access token.",
"follow_link": "Please follow the link and authenticate before pressing Submit."
},
"abort": {
"already_setup": "You can only configure a single Logi Circle account.",
"external_error": "Exception occurred from another flow.",
"external_setup": "Logi Circle successfully configured from another flow.",
"no_flows": "You need to configure Logi Circle before being able to authenticate with it. [Please read the instructions](https://www.home-assistant.io/components/logi_circle/)."
}
}
}

View File

@ -5,71 +5,203 @@ import logging
import async_timeout import async_timeout
import voluptuous as vol import voluptuous as vol
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant import config_entries
import homeassistant.helpers.config_validation as cv from homeassistant.components.camera import (
ATTR_FILENAME, CAMERA_SERVICE_SCHEMA)
from homeassistant.const import (
CONF_MONITORED_CONDITIONS, CONF_SENSORS, EVENT_HOMEASSISTANT_STOP)
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_send
REQUIREMENTS = ['logi_circle==0.1.7'] from . import config_flow
from .const import (
CONF_API_KEY, CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_REDIRECT_URI,
DATA_LOGI, DEFAULT_CACHEDB, DOMAIN, LED_MODE_KEY, LOGI_SENSORS,
RECORDING_MODE_KEY, SIGNAL_LOGI_CIRCLE_RECONFIGURE,
SIGNAL_LOGI_CIRCLE_RECORD, SIGNAL_LOGI_CIRCLE_SNAPSHOT)
REQUIREMENTS = ['logi_circle==0.2.2']
NOTIFICATION_ID = 'logi_circle_notification'
NOTIFICATION_TITLE = 'Logi Circle Setup'
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
_TIMEOUT = 15 # seconds _TIMEOUT = 15 # seconds
ATTRIBUTION = "Data provided by circle.logi.com" SERVICE_SET_CONFIG = 'set_config'
SERVICE_LIVESTREAM_SNAPSHOT = 'livestream_snapshot'
SERVICE_LIVESTREAM_RECORD = 'livestream_record'
NOTIFICATION_ID = 'logi_notification' ATTR_MODE = 'mode'
NOTIFICATION_TITLE = 'Logi Circle Setup' ATTR_VALUE = 'value'
ATTR_DURATION = 'duration'
DOMAIN = 'logi_circle' SENSOR_SCHEMA = vol.Schema({
DEFAULT_CACHEDB = '.logi_cache.pickle' vol.Optional(CONF_MONITORED_CONDITIONS, default=list(LOGI_SENSORS)):
DEFAULT_ENTITY_NAMESPACE = 'logi_circle' vol.All(cv.ensure_list, [vol.In(LOGI_SENSORS)])
})
CONFIG_SCHEMA = vol.Schema({ CONFIG_SCHEMA = vol.Schema(
DOMAIN: vol.Schema({ {
vol.Required(CONF_USERNAME): cv.string, DOMAIN:
vol.Required(CONF_PASSWORD): cv.string, vol.Schema({
}), vol.Required(CONF_CLIENT_ID): cv.string,
}, extra=vol.ALLOW_EXTRA) vol.Required(CONF_CLIENT_SECRET): cv.string,
vol.Required(CONF_API_KEY): cv.string,
vol.Required(CONF_REDIRECT_URI): cv.string,
vol.Optional(CONF_SENSORS, default={}): SENSOR_SCHEMA
})
},
extra=vol.ALLOW_EXTRA,
)
LOGI_CIRCLE_SERVICE_SET_CONFIG = CAMERA_SERVICE_SCHEMA.extend({
vol.Required(ATTR_MODE): vol.In([LED_MODE_KEY,
RECORDING_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(hass, config): async def async_setup(hass, config):
"""Set up the Logi Circle component.""" """Set up configured Logi Circle component."""
if DOMAIN not in config:
return True
conf = config[DOMAIN] conf = config[DOMAIN]
username = conf[CONF_USERNAME]
password = conf[CONF_PASSWORD] config_flow.register_flow_implementation(
hass,
DOMAIN,
client_id=conf[CONF_CLIENT_ID],
client_secret=conf[CONF_CLIENT_SECRET],
api_key=conf[CONF_API_KEY],
redirect_uri=conf[CONF_REDIRECT_URI],
sensors=conf[CONF_SENSORS])
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={'source': config_entries.SOURCE_IMPORT},
))
return True
async def async_setup_entry(hass, entry):
"""Set up Logi Circle from a config entry."""
from logi_circle import LogiCircle
from logi_circle.exception import AuthorizationFailed
from aiohttp.client_exceptions import ClientResponseError
logi_circle = LogiCircle(
client_id=entry.data[CONF_CLIENT_ID],
client_secret=entry.data[CONF_CLIENT_SECRET],
api_key=entry.data[CONF_API_KEY],
redirect_uri=entry.data[CONF_REDIRECT_URI],
cache_file=DEFAULT_CACHEDB
)
if not logi_circle.authorized:
hass.components.persistent_notification.create(
"Error: The cached access tokens are missing from {}.<br />"
"Please unload then re-add the Logi Circle integration to resolve."
''.format(DEFAULT_CACHEDB),
title=NOTIFICATION_TITLE,
notification_id=NOTIFICATION_ID)
return False
try: 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): with async_timeout.timeout(_TIMEOUT, loop=hass.loop):
await logi.login() # Ensure the cameras property returns the same Camera objects for
hass.data[DOMAIN] = await logi.cameras # all devices. Performs implicit login and session validation.
await logi_circle.synchronize_cameras()
if not logi.is_connected: except AuthorizationFailed:
return False
except (BadLogin, ClientResponseError) as ex:
_LOGGER.error('Unable to connect to Logi Circle API: %s', str(ex))
hass.components.persistent_notification.create( hass.components.persistent_notification.create(
'Error: {}<br />' "Error: Failed to obtain an access token from the cached "
'You will need to restart hass after fixing.' "refresh token.<br />"
''.format(ex), "Token may have expired or been revoked.<br />"
"Please unload then re-add the Logi Circle integration to resolve",
title=NOTIFICATION_TITLE, title=NOTIFICATION_TITLE,
notification_id=NOTIFICATION_ID) notification_id=NOTIFICATION_ID)
return False return False
except asyncio.TimeoutError: except asyncio.TimeoutError:
# The TimeoutError exception object returns nothing when casted to a # The TimeoutError exception object returns nothing when casted to a
# string, so we'll handle it separately. # string, so we'll handle it separately.
err = '{}s timeout exceeded when connecting to Logi Circle API'.format( err = "{}s timeout exceeded when connecting to Logi Circle API".format(
_TIMEOUT) _TIMEOUT)
_LOGGER.error(err)
hass.components.persistent_notification.create( hass.components.persistent_notification.create(
'Error: {}<br />' "Error: {}<br />"
'You will need to restart hass after fixing.' "You will need to restart hass after fixing."
''.format(err), ''.format(err),
title=NOTIFICATION_TITLE, title=NOTIFICATION_TITLE,
notification_id=NOTIFICATION_ID) notification_id=NOTIFICATION_ID)
return False return False
except ClientResponseError as 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
hass.data[DATA_LOGI] = logi_circle
for component in 'camera', 'sensor':
hass.async_create_task(hass.config_entries.async_forward_entry_setup(
entry, component))
async def service_handler(service):
"""Dispatch service calls to target entities."""
params = dict(service.data)
if service.service == SERVICE_SET_CONFIG:
async_dispatcher_send(hass, SIGNAL_LOGI_CIRCLE_RECONFIGURE, params)
if service.service == SERVICE_LIVESTREAM_SNAPSHOT:
async_dispatcher_send(hass, SIGNAL_LOGI_CIRCLE_SNAPSHOT, params)
if service.service == SERVICE_LIVESTREAM_RECORD:
async_dispatcher_send(hass, SIGNAL_LOGI_CIRCLE_RECORD, 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)
async def shut_down(event=None):
"""Close Logi Circle aiohttp session."""
await logi_circle.auth_provider.close()
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shut_down)
return True
async def async_unload_entry(hass, entry):
"""Unload a config entry."""
for component in 'camera', 'sensor':
await hass.config_entries.async_forward_entry_unload(
entry, component)
logi_circle = hass.data.pop(DATA_LOGI)
# Tell API wrapper to close all aiohttp sessions, invalidate WS connections
# and clear all locally cached tokens
await logi_circle.auth_provider.clear_authorization()
return True return True

View File

@ -1,114 +1,89 @@
"""Support to the Logi Circle cameras.""" """Support to the Logi Circle cameras."""
import asyncio
from datetime import timedelta from datetime import timedelta
import logging import logging
import voluptuous as vol
from homeassistant.components.camera import ( from homeassistant.components.camera import (
ATTR_ENTITY_ID, ATTR_FILENAME, CAMERA_SERVICE_SCHEMA, ATTR_ENTITY_ID, SUPPORT_ON_OFF, Camera)
PLATFORM_SCHEMA, SUPPORT_ON_OFF, Camera) from homeassistant.components.ffmpeg import DATA_FFMPEG
from homeassistant.components.camera.const import DOMAIN
from homeassistant.const import ( from homeassistant.const import (
ATTR_ATTRIBUTION, ATTR_BATTERY_CHARGING, ATTR_BATTERY_LEVEL, ATTR_ATTRIBUTION, ATTR_BATTERY_CHARGING, ATTR_BATTERY_LEVEL, STATE_OFF,
CONF_SCAN_INTERVAL, STATE_OFF, STATE_ON) STATE_ON)
from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect
from . import ATTRIBUTION, DOMAIN as LOGI_CIRCLE_DOMAIN from .const import (
ATTRIBUTION, DOMAIN as LOGI_CIRCLE_DOMAIN, LED_MODE_KEY,
RECORDING_MODE_KEY, SIGNAL_LOGI_CIRCLE_RECONFIGURE,
SIGNAL_LOGI_CIRCLE_RECORD, SIGNAL_LOGI_CIRCLE_SNAPSHOT)
DEPENDENCIES = ['logi_circle'] DEPENDENCIES = ['logi_circle', 'ffmpeg']
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=60) 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( async def async_setup_platform(
hass, config, async_add_entities, discovery_info=None): hass, config, async_add_entities, discovery_info=None):
"""Set up a Logi Circle Camera.""" """Set up a Logi Circle Camera. Obsolete."""
devices = hass.data[LOGI_CIRCLE_DOMAIN] _LOGGER.warning(
"Logi Circle no longer works with camera platform configuration")
cameras = []
for device in devices: async def async_setup_entry(hass, entry, async_add_entities):
cameras.append(LogiCam(device, config)) """Set up a Logi Circle Camera based on a config entry."""
devices = await hass.data[LOGI_CIRCLE_DOMAIN].cameras
ffmpeg = hass.data[DATA_FFMPEG]
cameras = [LogiCam(device, entry, ffmpeg)
for device in devices]
async_add_entities(cameras, True) 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): class LogiCam(Camera):
"""An implementation of a Logi Circle camera.""" """An implementation of a Logi Circle camera."""
def __init__(self, camera, device_info): def __init__(self, camera, device_info, ffmpeg):
"""Initialize Logi Circle camera.""" """Initialize Logi Circle camera."""
super().__init__() super().__init__()
self._camera = camera self._camera = camera
self._name = self._camera.name self._name = self._camera.name
self._id = self._camera.mac_address self._id = self._camera.mac_address
self._has_battery = self._camera.supports_feature('battery_level') self._has_battery = self._camera.supports_feature('battery_level')
self._ffmpeg = ffmpeg
self._listeners = []
async def async_added_to_hass(self):
"""Connect camera methods to signals."""
def _dispatch_proxy(method):
"""Expand parameters & filter entity IDs."""
async def _call(params):
entity_ids = params.get(ATTR_ENTITY_ID)
filtered_params = {k: v for k,
v in params.items() if k != ATTR_ENTITY_ID}
if entity_ids is None or self.entity_id in entity_ids:
await method(**filtered_params)
return _call
self._listeners.extend([
async_dispatcher_connect(
self.hass,
SIGNAL_LOGI_CIRCLE_RECONFIGURE,
_dispatch_proxy(self.set_config)),
async_dispatcher_connect(
self.hass,
SIGNAL_LOGI_CIRCLE_SNAPSHOT,
_dispatch_proxy(self.livestream_snapshot)),
async_dispatcher_connect(
self.hass,
SIGNAL_LOGI_CIRCLE_RECORD,
_dispatch_proxy(self.download_livestream)),
])
async def async_will_remove_from_hass(self):
"""Disconnect dispatcher listeners when removed."""
for detach in self._listeners:
detach()
@property @property
def unique_id(self): def unique_id(self):
@ -132,20 +107,19 @@ class LogiCam(Camera):
ATTR_ATTRIBUTION: ATTRIBUTION, ATTR_ATTRIBUTION: ATTRIBUTION,
'battery_saving_mode': ( 'battery_saving_mode': (
STATE_ON if self._camera.battery_saving else STATE_OFF), STATE_ON if self._camera.battery_saving else STATE_OFF),
'ip_address': self._camera.ip_address,
'microphone_gain': self._camera.microphone_gain 'microphone_gain': self._camera.microphone_gain
} }
# Add battery attributes if camera is battery-powered # Add battery attributes if camera is battery-powered
if self._has_battery: if self._has_battery:
state[ATTR_BATTERY_CHARGING] = self._camera.is_charging state[ATTR_BATTERY_CHARGING] = self._camera.charging
state[ATTR_BATTERY_LEVEL] = self._camera.battery_level state[ATTR_BATTERY_LEVEL] = self._camera.battery_level
return state return state
async def async_camera_image(self): async def async_camera_image(self):
"""Return a still image from the camera.""" """Return a still image from the camera."""
return await self._camera.get_snapshot_image() return await self._camera.live_stream.download_jpeg()
async def async_turn_off(self): async def async_turn_off(self):
"""Disable streaming mode for this camera.""" """Disable streaming mode for this camera."""
@ -163,11 +137,9 @@ class LogiCam(Camera):
async def set_config(self, mode, value): async def set_config(self, mode, value):
"""Set an configuration property for the target camera.""" """Set an configuration property for the target camera."""
if mode == LED_MODE_KEY: if mode == LED_MODE_KEY:
await self._camera.set_led(value) await self._camera.set_config('led', value)
if mode == PRIVACY_MODE_KEY: if mode == RECORDING_MODE_KEY:
await self._camera.set_privacy_mode(value) await self._camera.set_config('recording_disabled', not value)
if mode == BATTERY_SAVING_MODE_KEY:
await self._camera.set_battery_saving_mode(value)
async def download_livestream(self, filename, duration): async def download_livestream(self, filename, duration):
"""Download a recording from the camera's livestream.""" """Download a recording from the camera's livestream."""
@ -182,8 +154,10 @@ class LogiCam(Camera):
"Can't write %s, no access to path!", stream_file) "Can't write %s, no access to path!", stream_file)
return return
asyncio.shield(self._camera.record_livestream( await self._camera.live_stream.download_rtsp(
stream_file, timedelta(seconds=duration)), loop=self.hass.loop) filename=stream_file,
duration=timedelta(seconds=duration),
ffmpeg_bin=self._ffmpeg.binary)
async def livestream_snapshot(self, filename): async def livestream_snapshot(self, filename):
"""Download a still frame from the camera's livestream.""" """Download a still frame from the camera's livestream."""
@ -198,8 +172,9 @@ class LogiCam(Camera):
"Can't write %s, no access to path!", snapshot_file) "Can't write %s, no access to path!", snapshot_file)
return return
asyncio.shield(self._camera.get_livestream_image( await self._camera.live_stream.download_jpeg(
snapshot_file), loop=self.hass.loop) filename=snapshot_file,
refresh=True)
async def async_update(self): async def async_update(self):
"""Update camera entity and refresh attributes.""" """Update camera entity and refresh attributes."""

View File

@ -0,0 +1,205 @@
"""Config flow to configure Logi Circle component."""
import asyncio
from collections import OrderedDict
import async_timeout
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.components.http import HomeAssistantView
from homeassistant.const import CONF_SENSORS
from homeassistant.core import callback
from .const import (
CONF_API_KEY, CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_REDIRECT_URI,
DEFAULT_CACHEDB, DOMAIN)
_TIMEOUT = 15 # seconds
DATA_FLOW_IMPL = 'logi_circle_flow_implementation'
EXTERNAL_ERRORS = 'logi_errors'
AUTH_CALLBACK_PATH = '/api/logi_circle'
AUTH_CALLBACK_NAME = 'api:logi_circle'
@callback
def register_flow_implementation(hass, domain, client_id, client_secret,
api_key, redirect_uri, sensors):
"""Register a flow implementation.
domain: Domain of the component responsible for the implementation.
client_id: Client ID.
client_secret: Client secret.
api_key: API key issued by Logitech.
redirect_uri: Auth callback redirect URI.
sensors: Sensor config.
"""
if DATA_FLOW_IMPL not in hass.data:
hass.data[DATA_FLOW_IMPL] = OrderedDict()
hass.data[DATA_FLOW_IMPL][domain] = {
CONF_CLIENT_ID: client_id,
CONF_CLIENT_SECRET: client_secret,
CONF_API_KEY: api_key,
CONF_REDIRECT_URI: redirect_uri,
CONF_SENSORS: sensors,
EXTERNAL_ERRORS: None
}
@config_entries.HANDLERS.register(DOMAIN)
class LogiCircleFlowHandler(config_entries.ConfigFlow):
"""Config flow for Logi Circle component."""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
def __init__(self):
"""Initialize flow."""
self.flow_impl = None
async def async_step_import(self, user_input=None):
"""Handle external yaml configuration."""
if self.hass.config_entries.async_entries(DOMAIN):
return self.async_abort(reason='already_setup')
self.flow_impl = DOMAIN
return await self.async_step_auth()
async def async_step_user(self, user_input=None):
"""Handle a flow start."""
flows = self.hass.data.get(DATA_FLOW_IMPL, {})
if self.hass.config_entries.async_entries(DOMAIN):
return self.async_abort(reason='already_setup')
if not flows:
return self.async_abort(reason='no_flows')
if len(flows) == 1:
self.flow_impl = list(flows)[0]
return await self.async_step_auth()
if user_input is not None:
self.flow_impl = user_input['flow_impl']
return await self.async_step_auth()
return self.async_show_form(
step_id='user',
data_schema=vol.Schema({
vol.Required('flow_impl'):
vol.In(list(flows))
}))
async def async_step_auth(self, user_input=None):
"""Create an entry for auth."""
if self.hass.config_entries.async_entries(DOMAIN):
return self.async_abort(reason='external_setup')
external_error = (self.hass.data[DATA_FLOW_IMPL][DOMAIN]
[EXTERNAL_ERRORS])
errors = {}
if external_error:
# Handle error from another flow
errors['base'] = external_error
self.hass.data[DATA_FLOW_IMPL][DOMAIN][EXTERNAL_ERRORS] = None
elif user_input is not None:
errors['base'] = 'follow_link'
url = self._get_authorization_url()
return self.async_show_form(
step_id='auth',
description_placeholders={'authorization_url': url},
errors=errors)
def _get_authorization_url(self):
"""Create temporary Circle session and generate authorization url."""
from logi_circle import LogiCircle
flow = self.hass.data[DATA_FLOW_IMPL][self.flow_impl]
client_id = flow[CONF_CLIENT_ID]
client_secret = flow[CONF_CLIENT_SECRET]
api_key = flow[CONF_API_KEY]
redirect_uri = flow[CONF_REDIRECT_URI]
logi_session = LogiCircle(
client_id=client_id,
client_secret=client_secret,
api_key=api_key,
redirect_uri=redirect_uri)
self.hass.http.register_view(LogiCircleAuthCallbackView())
return logi_session.authorize_url
async def async_step_code(self, code=None):
"""Received code for authentication."""
if self.hass.config_entries.async_entries(DOMAIN):
return self.async_abort(reason='already_setup')
return await self._async_create_session(code)
async def _async_create_session(self, code):
"""Create Logi Circle session and entries."""
from logi_circle import LogiCircle
from logi_circle.exception import AuthorizationFailed
flow = self.hass.data[DATA_FLOW_IMPL][DOMAIN]
client_id = flow[CONF_CLIENT_ID]
client_secret = flow[CONF_CLIENT_SECRET]
api_key = flow[CONF_API_KEY]
redirect_uri = flow[CONF_REDIRECT_URI]
sensors = flow[CONF_SENSORS]
logi_session = LogiCircle(
client_id=client_id,
client_secret=client_secret,
api_key=api_key,
redirect_uri=redirect_uri,
cache_file=DEFAULT_CACHEDB)
try:
with async_timeout.timeout(_TIMEOUT, loop=self.hass.loop):
await logi_session.authorize(code)
except AuthorizationFailed:
(self.hass.data[DATA_FLOW_IMPL][DOMAIN]
[EXTERNAL_ERRORS]) = 'auth_error'
return self.async_abort(reason='external_error')
except asyncio.TimeoutError:
(self.hass.data[DATA_FLOW_IMPL][DOMAIN]
[EXTERNAL_ERRORS]) = 'auth_timeout'
return self.async_abort(reason='external_error')
account_id = (await logi_session.account)['accountId']
await logi_session.close()
return self.async_create_entry(
title='Logi Circle ({})'.format(account_id),
data={
CONF_CLIENT_ID: client_id,
CONF_CLIENT_SECRET: client_secret,
CONF_API_KEY: api_key,
CONF_REDIRECT_URI: redirect_uri,
CONF_SENSORS: sensors})
class LogiCircleAuthCallbackView(HomeAssistantView):
"""Logi Circle Authorization Callback View."""
requires_auth = False
url = AUTH_CALLBACK_PATH
name = AUTH_CALLBACK_NAME
async def get(self, request):
"""Receive authorization code."""
hass = request.app['hass']
if 'code' in request.query:
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={'source': 'code'},
data=request.query['code'],
))
return self.json_message("Authorisation code saved")
return self.json_message("Authorisation code missing "
"from query string", status_code=400)

View File

@ -0,0 +1,43 @@
"""Constants in Logi Circle component."""
CONF_CLIENT_ID = 'client_id'
CONF_CLIENT_SECRET = 'client_secret'
CONF_API_KEY = 'api_key'
CONF_REDIRECT_URI = 'redirect_uri'
DEFAULT_CACHEDB = '.logi_cache.pickle'
DOMAIN = 'logi_circle'
DATA_LOGI = DOMAIN
LED_MODE_KEY = 'LED'
RECORDING_MODE_KEY = 'RECORDING_MODE'
# Sensor types: Name, unit of measure, icon per sensor key.
LOGI_SENSORS = {
'battery_level': [
'Battery', '%', 'battery-50'],
'last_activity_time': [
"Last Activity", None, 'history'],
'recording': [
'Recording Mode', None, 'eye'],
'signal_strength_category': [
"WiFi Signal Category", None, 'wifi'],
'signal_strength_percentage': [
"WiFi Signal Strength", '%', 'wifi'],
'streaming': [
'Streaming Mode', None, 'camera'],
}
SIGNAL_LOGI_CIRCLE_RECONFIGURE = 'logi_circle_reconfigure'
SIGNAL_LOGI_CIRCLE_SNAPSHOT = 'logi_circle_snapshot'
SIGNAL_LOGI_CIRCLE_RECORD = 'logi_circle_record'
# Attribution
ATTRIBUTION = "Data provided by circle.logi.com"
DEVICE_BRAND = 'Logitech'

View File

@ -1,10 +1,8 @@
{ {
"domain": "logi_circle", "domain": "logi_circle",
"name": "Logi circle", "name": "Logi Circle",
"documentation": "https://www.home-assistant.io/components/logi_circle", "documentation": "https://www.home-assistant.io/components/logi_circle",
"requirements": [ "requirements": ["logi_circle==0.2.2"],
"logi_circle==0.1.7" "dependencies": ["ffmpeg"],
], "codeowners": ["@evanjd"]
"dependencies": [],
"codeowners": []
} }

View File

@ -1,51 +1,36 @@
"""Support for Logi Circle sensors.""" """Support for Logi Circle sensors."""
import logging import logging
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA
from homeassistant.const import ( from homeassistant.const import (
ATTR_ATTRIBUTION, ATTR_BATTERY_CHARGING, CONF_ENTITY_NAMESPACE, ATTR_ATTRIBUTION, ATTR_BATTERY_CHARGING, CONF_MONITORED_CONDITIONS,
CONF_MONITORED_CONDITIONS, STATE_OFF, STATE_ON) CONF_SENSORS, STATE_OFF, STATE_ON)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.icon import icon_for_battery_level
from homeassistant.util.dt import as_local from homeassistant.util.dt import as_local
from . import ( from .const import (
ATTRIBUTION, DEFAULT_ENTITY_NAMESPACE, DOMAIN as LOGI_CIRCLE_DOMAIN) ATTRIBUTION, DOMAIN as LOGI_CIRCLE_DOMAIN, LOGI_SENSORS as SENSOR_TYPES)
DEPENDENCIES = ['logi_circle'] DEPENDENCIES = ['logi_circle']
_LOGGER = logging.getLogger(__name__) _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( async def async_setup_platform(
hass, config, async_add_entities, discovery_info=None): hass, config, async_add_entities, discovery_info=None):
"""Set up a sensor for a Logi Circle device.""" """Set up a sensor for a Logi Circle device. Obsolete."""
devices = hass.data[LOGI_CIRCLE_DOMAIN] _LOGGER.warning(
'Logi Circle no longer works with sensor platform configuration')
async def async_setup_entry(hass, entry, async_add_entities):
"""Set up a Logi Circle sensor based on a config entry."""
devices = await hass.data[LOGI_CIRCLE_DOMAIN].cameras
time_zone = str(hass.config.time_zone) time_zone = str(hass.config.time_zone)
sensors = [] sensors = []
for sensor_type in config.get(CONF_MONITORED_CONDITIONS): for sensor_type in (entry.data.get(CONF_SENSORS)
.get(CONF_MONITORED_CONDITIONS)):
for device in devices: for device in devices:
if device.supports_feature(sensor_type): if device.supports_feature(sensor_type):
sensors.append(LogiSensor(device, time_zone, sensor_type)) sensors.append(LogiSensor(device, time_zone, sensor_type))
@ -64,6 +49,7 @@ class LogiSensor(Entity):
self._icon = 'mdi:{}'.format(SENSOR_TYPES.get(self._sensor_type)[2]) self._icon = 'mdi:{}'.format(SENSOR_TYPES.get(self._sensor_type)[2])
self._name = "{0} {1}".format( self._name = "{0} {1}".format(
self._camera.name, SENSOR_TYPES.get(self._sensor_type)[0]) self._camera.name, SENSOR_TYPES.get(self._sensor_type)[0])
self._activity = {}
self._state = None self._state = None
self._tz = time_zone self._tz = time_zone
@ -89,12 +75,11 @@ class LogiSensor(Entity):
ATTR_ATTRIBUTION: ATTRIBUTION, ATTR_ATTRIBUTION: ATTRIBUTION,
'battery_saving_mode': ( 'battery_saving_mode': (
STATE_ON if self._camera.battery_saving else STATE_OFF), STATE_ON if self._camera.battery_saving else STATE_OFF),
'ip_address': self._camera.ip_address,
'microphone_gain': self._camera.microphone_gain 'microphone_gain': self._camera.microphone_gain
} }
if self._sensor_type == 'battery_level': if self._sensor_type == 'battery_level':
state[ATTR_BATTERY_CHARGING] = self._camera.is_charging state[ATTR_BATTERY_CHARGING] = self._camera.charging
return state return state
@ -105,9 +90,9 @@ class LogiSensor(Entity):
self._state is not None): self._state is not None):
return icon_for_battery_level(battery_level=int(self._state), return icon_for_battery_level(battery_level=int(self._state),
charging=False) charging=False)
if (self._sensor_type == 'privacy_mode' and if (self._sensor_type == 'recording_mode' and
self._state is not None): self._state is not None):
return 'mdi:eye-off' if self._state == STATE_ON else 'mdi:eye' return 'mdi:eye' if self._state == STATE_ON else 'mdi:eye-off'
if (self._sensor_type == 'streaming_mode' and if (self._sensor_type == 'streaming_mode' and
self._state is not None): self._state is not None):
return ( return (
@ -125,7 +110,8 @@ class LogiSensor(Entity):
await self._camera.update() await self._camera.update()
if self._sensor_type == 'last_activity_time': if self._sensor_type == 'last_activity_time':
last_activity = await self._camera.last_activity last_activity = (await self._camera.
get_last_activity(force_refresh=True))
if last_activity is not None: if last_activity is not None:
last_activity_time = as_local(last_activity.end_time_utc) last_activity_time = as_local(last_activity.end_time_utc)
self._state = '{0:0>2}:{1:0>2}'.format( self._state = '{0:0>2}:{1:0>2}'.format(
@ -136,3 +122,4 @@ class LogiSensor(Entity):
self._state = STATE_ON if state is True else STATE_OFF self._state = STATE_ON if state is True else STATE_OFF
else: else:
self._state = state self._state = state
self._state = state

View File

@ -0,0 +1,37 @@
# Describes the format for available Logi Circle services
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: LED, RECORDING_MODE."
example: "RECORDING_MODE"
value:
description: "Operation value. Allowed values: true, false"
example: true
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"
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

View File

@ -0,0 +1,32 @@
{
"config": {
"title": "Logi Circle",
"step": {
"user": {
"title": "Authentication Provider",
"description": "Pick via which authentication provider you want to authenticate with Logi Circle.",
"data": {
"flow_impl": "Provider"
}
},
"auth": {
"title": "Authenticate with Logi Circle",
"description": "Please follow the link below and <b>Accept</b> access to your Logi Circle account, then come back and press <b>Submit</b> below.\n\n[Link]({authorization_url})"
}
},
"create_entry": {
"default": "Successfully authenticated with Logi Circle."
},
"error": {
"auth_error": "API authorization failed.",
"auth_timeout": "Authorization timed out when requesting access token.",
"follow_link": "Please follow the link and authenticate before pressing Submit."
},
"abort": {
"already_setup": "You can only configure a single Logi Circle account.",
"external_error": "Exception occurred from another flow.",
"external_setup": "Logi Circle successfully configured from another flow.",
"no_flows": "You need to configure Logi Circle before being able to authenticate with it. [Please read the instructions](https://www.home-assistant.io/components/logi_circle/)."
}
}
}

View File

@ -161,6 +161,7 @@ FLOWS = [
'ipma', 'ipma',
'lifx', 'lifx',
'locative', 'locative',
'logi_circle',
'luftdaten', 'luftdaten',
'mailgun', 'mailgun',
'mobile_app', 'mobile_app',

View File

@ -653,7 +653,7 @@ lmnotify==0.0.4
locationsharinglib==3.0.11 locationsharinglib==3.0.11
# homeassistant.components.logi_circle # homeassistant.components.logi_circle
logi_circle==0.1.7 logi_circle==0.2.2
# homeassistant.components.london_underground # homeassistant.components.london_underground
london-tube-status==0.2 london-tube-status==0.2

View File

@ -0,0 +1 @@
"""Tests for the Logi Circle component."""

View File

@ -0,0 +1,201 @@
"""Tests for Logi Circle config flow."""
import asyncio
from unittest.mock import Mock, patch
import pytest
from homeassistant import data_entry_flow
from homeassistant.components.logi_circle import config_flow
from homeassistant.components.logi_circle.config_flow import (
DOMAIN, LogiCircleAuthCallbackView)
from homeassistant.setup import async_setup_component
from tests.common import MockDependency, mock_coro
class AuthorizationFailed(Exception):
"""Dummy Exception."""
class MockRequest():
"""Mock request passed to HomeAssistantView."""
def __init__(self, hass, query):
"""Init request object."""
self.app = {"hass": hass}
self.query = query
def init_config_flow(hass):
"""Init a configuration flow."""
config_flow.register_flow_implementation(hass,
DOMAIN,
client_id='id',
client_secret='secret',
api_key='123',
redirect_uri='http://example.com',
sensors=None)
flow = config_flow.LogiCircleFlowHandler()
flow._get_authorization_url = Mock( # pylint: disable=W0212
return_value='http://example.com')
flow.hass = hass
return flow
@pytest.fixture
def mock_logi_circle():
"""Mock logi_circle."""
with MockDependency('logi_circle', 'exception') as mock_logi_circle_:
mock_logi_circle_.exception.AuthorizationFailed = AuthorizationFailed
mock_logi_circle_.LogiCircle().authorize = Mock(
return_value=mock_coro(return_value=True))
mock_logi_circle_.LogiCircle().close = Mock(
return_value=mock_coro(return_value=True))
mock_logi_circle_.LogiCircle().account = mock_coro(
return_value={'accountId': 'testId'})
mock_logi_circle_.LogiCircle().authorize_url = 'http://authorize.url'
yield mock_logi_circle_
async def test_step_import(hass, mock_logi_circle): # pylint: disable=W0621
"""Test that we trigger import when configuring with client."""
flow = init_config_flow(hass)
result = await flow.async_step_import()
assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
assert result['step_id'] == 'auth'
async def test_full_flow_implementation(hass, mock_logi_circle): # noqa pylint: disable=W0621
"""Test registering an implementation and finishing flow works."""
config_flow.register_flow_implementation(
hass,
'test-other',
client_id=None,
client_secret=None,
api_key=None,
redirect_uri=None,
sensors=None)
flow = init_config_flow(hass)
result = await flow.async_step_user()
assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
assert result['step_id'] == 'user'
result = await flow.async_step_user({'flow_impl': 'test-other'})
assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
assert result['step_id'] == 'auth'
assert result['description_placeholders'] == {
'authorization_url': 'http://example.com',
}
result = await flow.async_step_code('123ABC')
assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result['title'] == 'Logi Circle ({})'.format('testId')
async def test_we_reprompt_user_to_follow_link(hass):
"""Test we prompt user to follow link if previously prompted."""
flow = init_config_flow(hass)
result = await flow.async_step_auth('dummy')
assert result['errors']['base'] == 'follow_link'
async def test_abort_if_no_implementation_registered(hass):
"""Test we abort if no implementation is registered."""
flow = config_flow.LogiCircleFlowHandler()
flow.hass = hass
result = await flow.async_step_user()
assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
assert result['reason'] == 'no_flows'
async def test_abort_if_already_setup(hass):
"""Test we abort if Logi Circle is already setup."""
flow = init_config_flow(hass)
with patch.object(hass.config_entries, 'async_entries', return_value=[{}]):
result = await flow.async_step_user()
assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
assert result['reason'] == 'already_setup'
with patch.object(hass.config_entries, 'async_entries', return_value=[{}]):
result = await flow.async_step_import()
assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
assert result['reason'] == 'already_setup'
with patch.object(hass.config_entries, 'async_entries', return_value=[{}]):
result = await flow.async_step_code()
assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
assert result['reason'] == 'already_setup'
with patch.object(hass.config_entries, 'async_entries', return_value=[{}]):
result = await flow.async_step_auth()
assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
assert result['reason'] == 'external_setup'
@pytest.mark.parametrize('side_effect,error',
[(asyncio.TimeoutError, 'auth_timeout'),
(AuthorizationFailed, 'auth_error')])
async def test_abort_if_authorize_fails(hass, mock_logi_circle, side_effect, error): # noqa pylint: disable=W0621
"""Test we abort if authorizing fails."""
flow = init_config_flow(hass)
mock_logi_circle.LogiCircle().authorize.side_effect = side_effect
result = await flow.async_step_code('123ABC')
assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
assert result['reason'] == 'external_error'
result = await flow.async_step_auth()
assert result['errors']['base'] == error
async def test_not_pick_implementation_if_only_one(hass):
"""Test we bypass picking implementation if we have one flow_imp."""
flow = init_config_flow(hass)
result = await flow.async_step_user()
assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
assert result['step_id'] == 'auth'
async def test_gen_auth_url(hass, mock_logi_circle): # pylint: disable=W0621
"""Test generating authorize URL from Logi Circle API."""
config_flow.register_flow_implementation(hass,
'test-auth-url',
client_id='id',
client_secret='secret',
api_key='123',
redirect_uri='http://example.com',
sensors=None)
flow = config_flow.LogiCircleFlowHandler()
flow.hass = hass
flow.flow_impl = 'test-auth-url'
await async_setup_component(hass, 'http')
result = flow._get_authorization_url() # pylint: disable=W0212
assert result == 'http://authorize.url'
async def test_callback_view_rejects_missing_code(hass):
"""Test the auth callback view rejects requests with no code."""
view = LogiCircleAuthCallbackView()
resp = await view.get(MockRequest(hass, {}))
assert resp.status == 400
async def test_callback_view_accepts_code(hass, mock_logi_circle): # noqa pylint: disable=W0621
"""Test the auth callback view handles requests with auth code."""
init_config_flow(hass)
view = LogiCircleAuthCallbackView()
resp = await view.get(MockRequest(hass, {"code": "456"}))
assert resp.status == 200
await hass.async_block_till_done()
mock_logi_circle.LogiCircle.return_value.authorize.assert_called_with(
'456')