-
-
Notifications
You must be signed in to change notification settings - Fork 31.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add Logi Circle component, camera and sensor platform #16540
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
1502a71
Added Logi Circle platform, camera and sensor
evanjd 5c6c3c4
Integrated with Logo Circle API’s feature detection to exclude sensor…
evanjd 3edebb3
Migrated livestream snapshot and recording functionality out of home …
evanjd 0b00eff
Added new Logi sensor types, updated to latest version of `logi_circl…
evanjd 1d054c5
Renamed `logi` component domain to `logi_circle`.
evanjd 4ec2a31
Updates based on PR feedback
evanjd 85e50d8
Replaced `asyncio.wait_for` with `async_timeout` to be consistent wit…
evanjd File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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: {}<br />' | ||
'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: {}<br />' | ||
'You will need to restart hass after fixing.' | ||
''.format(err), | ||
title=NOTIFICATION_TITLE, | ||
notification_id=NOTIFICATION_ID) | ||
return False | ||
return True |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why do we use
asyncio.shield
here? Just curious.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
record_livestream
method takes a DASH initialization file and concatenates it with stream segments. This uses NamedTemporaryFiles to stage some files, I wanted to guard against cancellation so that those temp files are always cleaned up.My understanding of asyncio is pretty shaky and I'm not sure what circumstances cancellation would occur. If this pointless let me know and I'll get rid of it. 😄
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks! I'm not an expert in asyncio either, but it sounds valid.